From 80a5e183b6e59aabd71a451480d99cc7d9513102 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Thu, 2 Apr 2026 15:27:30 +0100 Subject: [PATCH] design improvements for rule creation tabs - use radix tabs for better accessibility and keyboard navigation - update styles based on design feedback --- package.json | 1 + src/components/Layout/mdx/Tabs.test.tsx | 29 +++----- src/components/Layout/mdx/Tabs.tsx | 88 +++++++++++-------------- yarn.lock | 33 ++-------- 4 files changed, 53 insertions(+), 98 deletions(-) diff --git a/package.json b/package.json index 975044a49d..0ed960b9ea 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@react-hook/media-query": "^1.1.1", "@sentry/gatsby": "^9.19.0", diff --git a/src/components/Layout/mdx/Tabs.test.tsx b/src/components/Layout/mdx/Tabs.test.tsx index 003710ae83..8aa392f7fc 100644 --- a/src/components/Layout/mdx/Tabs.test.tsx +++ b/src/components/Layout/mdx/Tabs.test.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Tabs, Tab } from './Tabs'; describe('Tabs', () => { @@ -31,11 +31,11 @@ describe('Tabs', () => { , ); - expect(screen.getByText('Content A')).toBeInTheDocument(); - expect(screen.queryByText('Content B')).not.toBeInTheDocument(); + expect(screen.getByRole('tabpanel')).toHaveTextContent('Content A'); }); - it('switches content when a tab is clicked', () => { + it('switches content when a tab is clicked', async () => { + const user = userEvent.setup(); render( @@ -47,13 +47,13 @@ describe('Tabs', () => { , ); - fireEvent.click(screen.getByRole('tab', { name: 'Beta' })); + await user.click(screen.getByRole('tab', { name: 'Beta' })); - expect(screen.queryByText('Content A')).not.toBeInTheDocument(); - expect(screen.getByText('Content B')).toBeInTheDocument(); + expect(screen.getByRole('tabpanel')).toHaveTextContent('Content B'); }); - it('sets aria-selected correctly', () => { + it('sets aria-selected correctly', async () => { + const user = userEvent.setup(); render( @@ -68,7 +68,7 @@ describe('Tabs', () => { expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('aria-selected', 'false'); - fireEvent.click(screen.getByRole('tab', { name: 'Beta' })); + await user.click(screen.getByRole('tab', { name: 'Beta' })); expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'false'); expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('aria-selected', 'true'); @@ -88,13 +88,4 @@ describe('Tabs', () => { expect(screen.getByRole('tabpanel')).toHaveTextContent('Content A'); }); - - it('renders nothing for Tab used outside of Tabs', () => { - const { container } = render( - - Orphan - , - ); - expect(container).toBeEmptyDOMElement(); - }); }); diff --git a/src/components/Layout/mdx/Tabs.tsx b/src/components/Layout/mdx/Tabs.tsx index 9d3cace28d..3e043860a5 100644 --- a/src/components/Layout/mdx/Tabs.tsx +++ b/src/components/Layout/mdx/Tabs.tsx @@ -1,29 +1,17 @@ -import React, { useState, createContext, useContext, isValidElement, ReactNode, useId } from 'react'; +import React, { isValidElement, ReactNode } from 'react'; +import * as RadixTabs from '@radix-ui/react-tabs'; import cn from '@ably/ui/core/utils/cn'; -type TabsContextType = { - activeTab: string; - tabsId: string; -}; - -const TabsContext = createContext(undefined); - interface TabProps { value: string; label: string; children: ReactNode; } -export const Tab: React.FC = ({ value, children }) => { - const context = useContext(TabsContext); - if (!context) { - return null; - } - return context.activeTab === value ? ( -
- {children} -
- ) : null; +export const Tab: React.FC = ({ children }) => { + // Tab is only used declaratively — Tabs reads its props and renders RadixTabs.Content. + // When used outside of Tabs, render nothing. + return <>{children}; }; interface TabsProps { @@ -31,45 +19,45 @@ interface TabsProps { } export const Tabs: React.FC = ({ children }) => { - const tabsId = useId(); - const tabs: { value: string; label: string }[] = []; + const contentByValue: Record = {}; + React.Children.forEach(children, (child) => { if (isValidElement(child) && child.props.value) { tabs.push({ value: child.props.value, label: child.props.label ?? child.props.value }); + contentByValue[child.props.value] = child.props.children; } }); - const [activeTab, setActiveTab] = useState(tabs[0]?.value ?? ''); - return ( - -
-
- {tabs.map(({ value, label }) => ( - - ))} -
-
{children}
-
-
+ + + {tabs.map(({ value, label }) => ( + + {label} + + ))} + + {tabs.map(({ value }) => ( + + {contentByValue[value]} + + ))} + ); }; diff --git a/yarn.lock b/yarn.lock index 1841c1f910..1da1eebe3b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3403,7 +3403,7 @@ "@radix-ui/react-use-previous" "1.1.1" "@radix-ui/react-use-size" "1.1.1" -"@radix-ui/react-tabs@^1.1.1": +"@radix-ui/react-tabs@^1.1.1", "@radix-ui/react-tabs@^1.1.13": version "1.1.13" resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz#3537ce379d7e7ff4eeb6b67a0973e139c2ac1f15" integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A== @@ -14732,16 +14732,7 @@ string-similarity@^1.2.2: lodash.map "^4.6.0" lodash.maxby "^4.6.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14851,7 +14842,7 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -14872,13 +14863,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.2" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" @@ -16328,7 +16312,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16346,15 +16330,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"