From f37af3af7c23d7b902f35bb6efb60ef4b0698ebc Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Fri, 27 Jan 2023 15:54:31 -0500 Subject: [PATCH 1/2] Add `Tab` and `TabList` components --- src/components/navigation/Tab.tsx | 88 +++++++++++++++++++ src/components/navigation/TabList.tsx | 58 ++++++++++++ src/components/navigation/index.js | 2 + src/components/navigation/test/Tab-test.js | 52 +++++++++++ .../navigation/test/TabList-test.js | 75 ++++++++++++++++ src/next.js | 2 + 6 files changed, 277 insertions(+) create mode 100644 src/components/navigation/Tab.tsx create mode 100644 src/components/navigation/TabList.tsx create mode 100644 src/components/navigation/test/Tab-test.js create mode 100644 src/components/navigation/test/TabList-test.js diff --git a/src/components/navigation/Tab.tsx b/src/components/navigation/Tab.tsx new file mode 100644 index 00000000..0c11028d --- /dev/null +++ b/src/components/navigation/Tab.tsx @@ -0,0 +1,88 @@ +import classnames from 'classnames'; +import type { JSX } from 'preact'; + +import type { IconComponent, PresentationalProps } from '../../types'; +import { downcastRef } from '../../util/typing'; + +import ButtonBase from '../input/ButtonBase'; + +type ComponentProps = { + icon?: IconComponent; + /** + * Text string representing the content of the tab when selected. The tab + * button will be sized to accommodate this string in bold text. This can + * prevent tab jiggle. + */ + textContent?: string; + selected?: boolean; + variant?: 'basic'; +}; + +type HTMLAttributes = Omit< + JSX.HTMLAttributes, + 'size' | 'icon' | 'title' +>; + +export type TabProps = PresentationalProps & ComponentProps & HTMLAttributes; + +/** + * Render a button with appropriate ARIA tab affordances + */ +const TabNext = function Tab({ + children, + classes, + elementRef, + + icon: Icon, + textContent, + selected = false, + variant = 'basic', + + ...htmlAttributes +}: TabProps) { + return ( + + {Icon && ( + + )} + + {children} + + + ); +}; + +export default TabNext; diff --git a/src/components/navigation/TabList.tsx b/src/components/navigation/TabList.tsx new file mode 100644 index 00000000..b0d8faf0 --- /dev/null +++ b/src/components/navigation/TabList.tsx @@ -0,0 +1,58 @@ +import classnames from 'classnames'; +import type { JSX } from 'preact'; + +import { useArrowKeyNavigation } from '../../hooks/use-arrow-key-navigation'; +import { useSyncedRef } from '../../hooks/use-synced-ref'; +import type { PresentationalProps } from '../../types'; +import { downcastRef } from '../../util/typing'; + +type HTMLAttributes = Omit, 'size'>; + +type ComponentProps = { + /** + * By default, TabLists are oriented horizontally. Vertically-oriented + * TabLists add up/down arrow-key navigation. + */ + vertical?: boolean; +}; + +export type TabListProps = PresentationalProps & + ComponentProps & + HTMLAttributes; + +/** + * Render a tablist container for a set of tabs, with arrow key navigation per + * https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/ + */ +const TabListNext = function TabList({ + children, + classes, + elementRef, + + vertical = false, + + ...htmlAttributes +}: TabListProps) { + const tabListRef = useSyncedRef(elementRef); + + useArrowKeyNavigation(tabListRef, { + selector: 'button', + horizontal: true, + vertical, + }); + + return ( +
+ {children} +
+ ); +}; + +export default TabListNext; diff --git a/src/components/navigation/index.js b/src/components/navigation/index.js index cc830209..2efb6e92 100644 --- a/src/components/navigation/index.js +++ b/src/components/navigation/index.js @@ -2,3 +2,5 @@ export { default as PointerButton } from './PointerButton'; export { default as Link } from './Link'; export { default as LinkBase } from './LinkBase'; export { default as LinkButton } from './LinkButton'; +export { default as Tab } from './Tab'; +export { default as TabList } from './TabList'; diff --git a/src/components/navigation/test/Tab-test.js b/src/components/navigation/test/Tab-test.js new file mode 100644 index 00000000..e24a2879 --- /dev/null +++ b/src/components/navigation/test/Tab-test.js @@ -0,0 +1,52 @@ +import { mount } from 'enzyme'; + +import { testPresentationalComponent } from '../../test/common-tests'; + +import { ProfileIcon } from '../../icons'; +import Tab from '../Tab'; + +const contentFn = (Component, props = {}) => { + return mount( +
+ This is child content +
+ ); +}; + +describe('Tab', () => { + testPresentationalComponent(Tab, { + createContent: contentFn, + elementSelector: 'button[data-component="Tab"]', + }); + + it('sets `aria-selected` when selected', () => { + const tab1 = contentFn(Tab, { selected: true }); + const tab2 = contentFn(Tab, { selected: false }); + + assert.equal( + tab1.find('button').getDOMNode().getAttribute('aria-selected'), + 'true' + ); + assert.equal( + tab2.find('button').getDOMNode().getAttribute('aria-selected'), + 'false' + ); + }); + + it('sets content data attribute on sizing span when `textContent` provided', () => { + const wrapper = contentFn(Tab, { textContent: 'Tab Label' }); + assert.equal( + wrapper + .find('[data-testid="sizing-wrapper"]') + .getDOMNode() + .getAttribute('data-content'), + 'Tab Label' + ); + }); + + it('renders an icon when provided', () => { + const wrapper = contentFn(Tab, { icon: ProfileIcon }); + + assert.isTrue(wrapper.find('ProfileIcon').exists()); + }); +}); diff --git a/src/components/navigation/test/TabList-test.js b/src/components/navigation/test/TabList-test.js new file mode 100644 index 00000000..6121f561 --- /dev/null +++ b/src/components/navigation/test/TabList-test.js @@ -0,0 +1,75 @@ +import { mount } from 'enzyme'; + +import { testPresentationalComponent } from '../../test/common-tests'; + +import TabList from '../TabList'; +import { $imports } from '../TabList'; + +/** + * An element with `role="tablist"` needs at least one `role="tab"` child. + * Accessibility tests will fail without it. + */ +const contentFn = (Component, props = {}) => { + return mount( + + + + ); +}; + +describe('TabList', () => { + testPresentationalComponent(TabList, { createContent: contentFn }); + + describe('TabList orientation and keyboard navigation', () => { + let fakeUseArrowKeyNavigation; + + beforeEach(() => { + fakeUseArrowKeyNavigation = sinon.stub(); + $imports.$mock({ + '../../hooks/use-arrow-key-navigation': { + useArrowKeyNavigation: fakeUseArrowKeyNavigation, + }, + }); + }); + + afterEach(() => { + $imports.$restore(); + }); + + it('sets `aria-orientation` to `horizontal` or `vertical` based on `vertical` prop', () => { + const horizontalTabList = contentFn(TabList, {}); + const verticalTabList = contentFn(TabList, { vertical: true }); + + assert.equal( + horizontalTabList + .find('[data-component="TabList"]') + .getDOMNode() + .getAttribute('aria-orientation'), + 'horizontal' + ); + assert.equal( + verticalTabList + .find('[data-component="TabList"]') + .getDOMNode() + .getAttribute('aria-orientation'), + 'vertical' + ); + }); + + it('applies horizontal (left/right) keyboard navigation when horizontal', () => { + contentFn(TabList, {}); + + const navOpts = fakeUseArrowKeyNavigation.getCall(0).args[1]; + + assert.include(navOpts, { horizontal: true, vertical: false }); + }); + + it('applies horizontal and vertical (up/down) keyboard navigation when vertical', () => { + contentFn(TabList, { vertical: true }); + + const navOpts = fakeUseArrowKeyNavigation.getCall(0).args[1]; + + assert.include(navOpts, { horizontal: true, vertical: true }); + }); + }); +}); diff --git a/src/next.js b/src/next.js index 63ac3e72..20b3b1e5 100644 --- a/src/next.js +++ b/src/next.js @@ -41,4 +41,6 @@ export { Link, LinkBase, LinkButton, + Tab, + TabList, } from './components/navigation/'; From 932564ad1adf3daff672f2e8cdd17d49a5b1a74f Mon Sep 17 00:00:00 2001 From: Lyza Danger Gardner Date: Fri, 27 Jan 2023 15:54:41 -0500 Subject: [PATCH 2/2] Add pattern-library documentation for `Tab` and `TabList` --- .../components/patterns/navigation/TabPage.js | 390 ++++++++++++++++++ src/pattern-library/routes.ts | 7 + 2 files changed, 397 insertions(+) create mode 100644 src/pattern-library/components/patterns/navigation/TabPage.js diff --git a/src/pattern-library/components/patterns/navigation/TabPage.js b/src/pattern-library/components/patterns/navigation/TabPage.js new file mode 100644 index 00000000..a2ed59c7 --- /dev/null +++ b/src/pattern-library/components/patterns/navigation/TabPage.js @@ -0,0 +1,390 @@ +import classnames from 'classnames'; +import { useState } from 'preact/hooks'; + +import { + Card, + EmailIcon, + ProfileIcon, + SettingsIcon, + Tab, + TabList, +} from '../../../../next'; +import Library from '../../Library'; +import Next from '../../LibraryNext'; + +export default function TabPage() { + const [prefPanel, setPrefPanel] = useState('notifications'); + const [sidebarPanel, setSidebarPanel] = useState('annotations'); + const [sidebarPanel2, setSidebarPanel2] = useState('annotations'); + const [sidebarPanel3, setSidebarPanel3] = useState('annotations'); + const [verticalPanel, setVerticalPanel] = useState('notifications'); + return ( + + Tab and TabList are presentational + components for rendering accessible tabs. +

+ } + > + + Tab generates a button with appropriate ARIA + attributes. +

+ } + > + +

+ Tab is a new component. +

+
+ + + + + +
+ + Annotations + + {52} + + + + Page Notes + + {4} + + + + Orphans + + {2} + + +
+
+
+ +
    +
  • + Tabs must be direct children of an element + with role={'"tablist"'} (or use the{' '} + TabList component). +
  • +
  • + You should provide an{' '} + + aria-controls + {' '} + attribute to each Tab. This is not always feasible in + our applications. +
  • +
+
+ + + + An element with {'role="tab"'} should set an{' '} + aria-controls attribute when possible. See the full{' '} + TabList example below. + + + + Tabs may have icons. The icon will be displayed on the + left and sized proportionally to the inherited font size. + +
+ Profile + + Profile + + + Profile + +
+
+
+ + + This boolean property asserts that the Tab is currently + selected and the tabpanel it controls (where relevant) + is active and visible. + + + +

+ Bolding is used in our design patterns to indicate a selected tab. + Without any intervention, textual tabs will jiggle around when + they are selected. This has a simple cause: bold text takes up + more room. +

+

+ textContent is a string representing the text content + of the tab when selected.{' '} + + Setting textContent can help prevent jiggle in + selected tabs + + . The size of the tab will accommodate this string rendered in + bold text. +

+ + + setSidebarPanel('annotations')} + > + Annotations + + {52} + + + setSidebarPanel('pageNotes')} + > + Page Notes + + {48} + + + setSidebarPanel('orphans')} + > + Orphans + + {4} + + + + +

+ For tabs that have a simple text label, setting{' '} + textContent to that string avoids the jiggle. Text + will still change size (bold text is larger) but the tabs + themselves do not move. +

+ + + setSidebarPanel2('annotations')} + textContent="Annotations" + > + Annotations + + setSidebarPanel2('pageNotes')} + textContent="Page Notes" + > + Page Notes + + setSidebarPanel2('orphans')} + textContent="Orphans" + > + Orphans + + + +

+ For tabs with styled or dynamic content, textContent{' '} + can be set to an estimated {'"widest-possible-text-content"'}{' '} + value. A trial and error approach worked here: +

+ + + setSidebarPanel3('annotations')} + textContent="Annotations##" + > + Annotations + + {52} + + + setSidebarPanel3('pageNotes')} + textContent="Page Notes##" + > + Page Notes + + {56} + + + setSidebarPanel3('orphans')} + textContent="Orphans##" + > + Orphans + + {2} + + + + +
+
+
+ + TabList is a presentational component that provides a{' '} + {'role="tablist"'} container and arrow-key navigation + as outlined by{' '} + + WAI-ARIA authoring practices + + . +

+ } + > + +

+ TabList is a new component. +

+
+ + + +

+ This example demonstrates a full Tab pattern with{' '} + TabList, Tab and some tabpanels. The + tabpanels have been made focusable here as they contain no + focusable elements: pressing tab when in the tablist + will move focus to the active tabpanel. Tabs may be navigated with + the left and right arrows. +

+ +
+ + setPrefPanel('profile')} + > + Profile + + setPrefPanel('notifications')} + > + Notifications + + setPrefPanel('account')} + > + Account + + + + Profile + + + Notifications + + + Account + +
+
+
+
+ + + +

+ By default, TabList layout is horizontal. Set the + boolean vertical prop for a vertical layout. This + will also enable arrow-key navigation using the up and down + arrows. +

+

+ The following example demonstrates vertical layout and up/down + keyboard navigation. +

+ + + setVerticalPanel('profile')} + selected={verticalPanel === 'profile'} + textContent="Profile" + > + Profile + + setVerticalPanel('notifications')} + selected={verticalPanel === 'notifications'} + textContent="Notifications" + > + Notifications + + setVerticalPanel('account')} + selected={verticalPanel === 'account'} + textContent="Account" + > + Account + + + +
+
+
+
+ ); +} diff --git a/src/pattern-library/routes.ts b/src/pattern-library/routes.ts index 0ea41d2d..08ba6e0d 100644 --- a/src/pattern-library/routes.ts +++ b/src/pattern-library/routes.ts @@ -29,6 +29,7 @@ import OverlayPage from './components/patterns/layout/OverlayPage'; import PointerButtonPage from './components/patterns/navigation/PointerButtonPage'; import LinkPage from './components/patterns/navigation/LinkPage'; import LinkButtonPage from './components/patterns/navigation/LinkButtonPage'; +import TabPage from './components/patterns/navigation/TabPage'; // Legacy pattern-library pages @@ -211,6 +212,12 @@ const routes: PlaygroundRoute[] = [ component: PointerButtonPage, route: '/navigation-pointerbutton', }, + { + title: 'Tabs', + group: 'navigation', + component: TabPage, + route: '/navigation-tab', + }, { route: '/components-buttons', title: 'Buttons',