Skip to content

Commit

Permalink
Add Tab and TabList components
Browse files Browse the repository at this point in the history
  • Loading branch information
lyzadanger committed Jan 27, 2023
1 parent b413cc0 commit 389c03c
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 0 deletions.
77 changes: 77 additions & 0 deletions src/components/navigation/Tab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import classnames from 'classnames';
import type { JSX } from 'preact';

import type { PresentationalProps } from '../../types';
import { downcastRef } from '../../util/typing';

import ButtonBase from '../input/ButtonBase';

type ComponentProps = {
/**
* 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<HTMLButtonElement>,
'size' | 'icon' | 'title'
>;

export type TabProps = PresentationalProps & ComponentProps & HTMLAttributes;

/**
* Render a button with appropriate ARIA tab affordances
*/
const TabNext = function Tab({
children,
classes,
elementRef,

textContent,
selected = false,
variant = 'basic',

...htmlAttributes
}: TabProps) {
return (
<ButtonBase
{...htmlAttributes}
classes={classnames(
'enabled:hover:text-brand-dark',
{
'font-bold': selected && variant === 'basic',
},
classes
)}
elementRef={downcastRef(elementRef)}
aria-selected={selected}
role="tab"
data-component="Tab"
>
<span
data-content={textContent}
data-testid="sizing-wrapper"
className={classnames({
// Set the content of this span's ::before pseudo-element to
// `textContent` and make it bold.
'before:content-[attr(data-content)] before:font-bold': textContent,
// Make the ::before occupy space within the button, but make it
// invisible. This ensures that the tab button is wide enough to show
// bolded text even if the visible text is not currently bold. See
// https://css-tricks.com/bold-on-hover-without-the-layout-shift/
'before:block before:invisible before:h-0 before:overflow-hidden':
textContent,
})}
>
{children}
</span>
</ButtonBase>
);
};

export default TabNext;
45 changes: 45 additions & 0 deletions src/components/navigation/TabList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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<JSX.HTMLAttributes<HTMLElement>, 'size'>;

export type TabListProps = PresentationalProps & 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,

...htmlAttributes
}: TabListProps) {
const tabListRef = useSyncedRef(elementRef);

useArrowKeyNavigation(tabListRef, {
selector: 'button',
horizontal: true,
vertical: false,
});

return (
<div
{...htmlAttributes}
ref={downcastRef(tabListRef)}
className={classnames('flex', classes)}
role="tablist"
data-component="TabList"
>
{children}
</div>
);
};

export default TabListNext;
2 changes: 2 additions & 0 deletions src/components/navigation/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
45 changes: 45 additions & 0 deletions src/components/navigation/test/Tab-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { mount } from 'enzyme';

import { testPresentationalComponent } from '../../test/common-tests';

import Tab from '../Tab';

const contentFn = (Component, props = {}) => {
return mount(
<div role="tablist">
<Component {...props}>This is child content</Component>
</div>
);
};

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'
);
});
});
21 changes: 21 additions & 0 deletions src/components/navigation/test/TabList-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { mount } from 'enzyme';

import { testPresentationalComponent } from '../../test/common-tests';

import TabList 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(
<Component {...props}>
<button role="tab">Tab 1</button>
</Component>
);
};

describe('TabList', () => {
testPresentationalComponent(TabList, { createContent: contentFn });
});
2 changes: 2 additions & 0 deletions src/next.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ export {
Link,
LinkBase,
LinkButton,
Tab,
TabList,
} from './components/navigation/';

0 comments on commit 389c03c

Please sign in to comment.