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(
+
+ Tab 1
+
+ );
+};
+
+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}
+
+
+
+
+
+
+
+
+ Tab
s 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.
+
+
+
+ Tab
s 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',