diff --git a/src/components/TabGroup/TabGroup.module.css b/src/components/TabGroup/TabGroup.module.css new file mode 100644 index 000000000..4e648fe2c --- /dev/null +++ b/src/components/TabGroup/TabGroup.module.css @@ -0,0 +1,209 @@ +@import '../../design-tokens/mixins.css'; + +/*------------------------------------*\ +    #TABGROUP +\*------------------------------------*/ + +/** + * List of of links where each link toggles open associated information + */ + +/** + * Tabs header + * + * Contains the tab list. + */ +.tabs__header { + /* Tab overflow behavior */ + overflow-x: auto; + position: relative; + padding-bottom: 1rem; +} + +/** + * Fade scrollable indicators to display if there is scrollable area on either side. + * + * The color "white" is arbitrary and any non transparent color can be used here. + */ +.tabs--scrollable-left { + -webkit-mask-image: -webkit-linear-gradient( + left, + transparent, + white 4rem + ); +} + +.tabs--scrollable-left .tabs__list--align-center, +.tabs--scrollable-right .tabs__list--align-center { + justify-content: unset; +} + +.tabs--scrollable-right { + -webkit-mask-image: -webkit-linear-gradient( + right, + transparent, + white 4rem + ); +} + +.tabs--scrollable-left.tabs--scrollable-right { + -webkit-mask-image: -webkit-linear-gradient( + left, + transparent, + white 4rem, + white calc(100% - 4rem), + transparent 100% + ); +} + +/** + * Tabs list + * + * Actual unordered list of tabs. + */ +.tabs__list { + list-style: none; + display: flex; + gap: 0.5rem; + border-bottom: var(--eds-theme-border-width) solid + var(--eds-theme-color-border-utility-neutral-low-emphasis); + font: var(--eds-theme-typography-tab-lg); + line-height: 1.429; + + @media all and (max-width: $eds-bp-md) { + font: var(--eds-theme-typography-tab-sm); + } + + &:not(.tabs--has-divider) { + border-bottom-color: transparent; + } +} + +/** + * Control the positioning of the tabs: left, center, or right aligned (horizontally) + */ +.tabs__list--align-left { + justify-content: left; +} + +.tabs__list--align-center { + justify-content: center; +} + +.tabs__list--align-right { + justify-content: right; +} + +/** + * Tabs item + */ +.tabs__item { + /** + * Flex shrink 0 keeps tabs from expanding to fill up the space of the container. + */ + flex-shrink: 0; + position: relative; + overflow: hidden; + /* border-radius: 0.125rem; */ + + &.eds-is-active { + font-weight: 500; + } + + &.tabs--width-full { + flex-shrink: 1; + flex-grow: 1; + + /* use ellipsis */ + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} + +/** + * Tabs link + */ +.tabs__link { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + padding: 0.75rem 0.5rem; + text-align: center; + + text-decoration: none; + color: var(--eds-theme-color-text-utility-interactive-primary); + + /* use ellipsis */ + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + transition: color var(--eds-anim-fade-quick) var(--eds-anim-ease), + box-shadow var(--eds-anim-fade-quick) var(--eds-anim-ease), + background-color var(--eds-anim-fade-quick) var(--eds-anim-ease); + + &:focus-visible { + box-shadow: inset 0 0 0 0.125rem var(--eds-theme-color-border-utility-focus); + } + + + @media screen and (prefers-reduced-motion) { + transition: none; + } + + &:hover { + background-color: var(--eds-theme-color-background-utility-interactive-no-emphasis-hover); + } + + &:active { + background-color: var(--eds-theme-color-background-utility-interactive-no-emphasis-active); + } +} + +.tab__icon { + margin-right: 0.25rem; +} + +.tab__illustration { + width: 100%; + display: flex; + justify-content: center; + + margin: 0.5rem; +} + +.tab__highlight { + border-radius: var(--eds-border-radius-full); + transition: bottom var(--eds-anim-fade-quick) var(--eds-anim-ease), + width var(--eds-anim-fade-quick) var(--eds-anim-ease); + + .tabs__item.eds-is-active & { + position: absolute; + bottom: 0; + height: 0.125rem; + width: 100%; + + /* TODO-AH: --eds-theme-color-icon-utility-interactive-primary not defined */ + background-color: var(--eds-theme-color-icon-utility-interactive-primary, currentColor); + } + + .tabs__item .tabs__link:focus-visible & { + bottom: 0.25rem; + width: calc(100% - 0.5rem); + + border-radius: var(--eds-border-radius-full); + + } + + + .tabs--has-divider & { + /* TODO-AH: use token instead of rem values: --eds-theme-border-radius-tab-underline */ + border-top-left-radius: 0.125rem; + border-top-right-radius: 0.125rem; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } +} + diff --git a/src/components/TabGroup/TabGroup.stories.tsx b/src/components/TabGroup/TabGroup.stories.tsx new file mode 100644 index 000000000..9101985ba --- /dev/null +++ b/src/components/TabGroup/TabGroup.stories.tsx @@ -0,0 +1,671 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import { within } from '@storybook/testing-library'; +import React from 'react'; + +import { TabGroup } from './TabGroup'; +import { chromaticViewports } from '../../util/viewports'; +import Heading from '../Heading'; +import Text from '../Text'; + +export default { + title: 'Components/V2/TabGroup', + component: TabGroup, + parameters: { + badges: ['intro-1.0', 'current-2.0'], + }, + args: { + children: ( + <> + +
+ + Tab 1 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 2 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 3 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 4 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + ), + }, + argTypes: { + children: { + control: { + type: null, + }, + }, + }, +} as Meta; + +type Args = React.ComponentProps; + +export const Default: StoryObj = { + parameters: { + chromatic: { + viewports: [ + chromaticViewports.googlePixel2, + chromaticViewports.chromebook, + ], + }, + }, +}; + +export const Centered: StoryObj = { + args: { + align: 'center', + }, + parameters: { + chromatic: { + viewports: [ + chromaticViewports.googlePixel2, + chromaticViewports.chromebook, + ], + }, + }, +}; + +/** + * Tabs can instead take up the full width available. + * + * (This will cause text truncation at small sizes) + */ +export const TabWidthFull: StoryObj = { + args: { + ...Centered.args, + tabWidth: 'full', + }, +}; + +/** + * Individual tabs can have an EDS icon attached to them + */ +export const WithTabIcons: StoryObj = { + args: { + ...Centered.args, + children: ( + <> + +
+ + Tab 1 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 2 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 3 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + ), + }, +}; + +const TabIllustration = () => { + return
; +}; + +/** + * Individual tabs can also have illustrations of a few specific sizes: + * + * - 3rem, 3.5rem, 4rem, 4.5rem, 5rem (specified in the designs) + */ +export const WithTabIllustrations: StoryObj = { + args: { + ...Centered.args, + children: ( + <> + } title="Tab Title 1"> +
+ + Tab 1 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + } title="Tab Title 2"> +
+ + Tab 2 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + } title="Tab Title 3"> +
+ + Tab 3 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + ), + }, +}; + +const IconTab = ({ isActive, title }: { isActive: boolean; title: string }) => ( +
+
+ {`${ + isActive ? '●' : '◦' + } ${title}`} +
+); + +/** + * If you require advanced control over the tab content, you can use a render prop and `Tab.Button` to + * handle the inputs for each tab. This should only be used sparingly. + */ +export const CustomTabGroup: StoryObj = { + decorators: [(Story) =>
{Story()}
], + parameters: { + docs: { + source: { + code: ` + + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 1 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 2 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 3 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 4 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 5 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 6 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+
+ `, + }, + }, + }, + args: { + children: ( + <> + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 1 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 2 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 3 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 4 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 5 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + + + {({ isActive, title }) => ( + + )} + +
+ + Tab 6 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + ), + }, +}; + +/** + * For Chromatic visual regression testing of the masks on both sides of the TabGroup. Currently does not work properly on local. + */ +export const ScrollMiddle: StoryObj = { + args: { + children: ( + <> + +
+ + Tab 1 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 2 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 3 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 4 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 5 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 6 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 7 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 8 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + +
+ + Tab 9 + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex{' '} + +
+
+ + ), + }, + parameters: { + viewport: { + defaultViewport: 'googlePixel2', + }, + // Skip these b/c test environment cannot execute "scroll" on the parent div + snapshot: { skip: true }, + chromatic: { viewports: [chromaticViewports.googlePixel2] }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const tablist = await canvas.findByRole('tablist'); + // eslint-disable-next-line testing-library/no-node-access + tablist?.parentElement?.scroll(50, 0); + }, +}; diff --git a/src/components/TabGroup/TabGroup.test.tsx b/src/components/TabGroup/TabGroup.test.tsx new file mode 100644 index 000000000..2af68313c --- /dev/null +++ b/src/components/TabGroup/TabGroup.test.tsx @@ -0,0 +1,64 @@ +import { generateSnapshots } from '@chanzuckerberg/story-utils'; +import { composeStories } from '@storybook/testing-react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import * as stories from './TabGroup.stories'; +import TabGroup from '../TabGroup'; + +const { Default } = composeStories(stories); + +describe('', () => { + generateSnapshots(stories); + + it('should focus and select with keyboard controls', async () => { + const user = userEvent.setup(); + render(); + const firstTab = screen.getByRole('tab', { name: 'Tab Title 1' }); + const secondTab = screen.getByRole('tab', { name: 'Tab Title 2' }); + firstTab.focus(); + + await user.keyboard('{arrowright}'); + expect(secondTab).toHaveFocus(); + + await user.keyboard('{arrowleft}'); + expect(firstTab).toHaveFocus(); + + await user.keyboard('{arrowdown}'); + expect(secondTab).toHaveFocus(); + + await user.keyboard('{arrowup}'); + expect(firstTab).toHaveFocus(); + + await user.keyboard('{arrowdown}'); + await user.keyboard('{arrowright}'); + await user.keyboard('{enter}'); + expect(screen.getByRole('tab', { name: 'Tab Title 3' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('changes the active tab when activeIndex changes', () => { + const { rerender } = render(); + expect(screen.getByRole('heading', { name: 'Tab 1' })).toBeInTheDocument(); + + rerender(); + expect(screen.getByRole('heading', { name: 'Tab 2' })).toBeInTheDocument(); + }); + + it('does not include invalid characters in tab ids', () => { + render( + + + Tab numero uno + + , + ); + + expect(screen.getByTestId('tab-1')).toHaveAttribute( + 'aria-labelledby', + 'foo-Tab-Title-1', + ); + }); +}); diff --git a/src/components/TabGroup/TabGroup.tsx b/src/components/TabGroup/TabGroup.tsx new file mode 100644 index 000000000..ac92a74cf --- /dev/null +++ b/src/components/TabGroup/TabGroup.tsx @@ -0,0 +1,422 @@ +import clsx from 'clsx'; +import debounce from 'lodash/debounce'; +import React, { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; +import { allByType, oneByType, withoutTypes } from 'react-children-by-type'; + +import { + L_ARROW_KEYCODE, + U_ARROW_KEYCODE, + R_ARROW_KEYCODE, + D_ARROW_KEYCODE, +} from '../../util/keycodes'; +import { useId } from '../../util/useId'; +import type { RenderProps } from '../../util/utility-types'; +import type { Align } from '../../util/variant-types'; +import Icon, { type IconName } from '../Icon'; + +import styles from './TabGroup.module.css'; + +export interface TabGroupProps { + // Component API + /** + * Reference to another element that describes the purpose of the set of tabs. + */ + 'aria-labelledby'?: string; + /** + * Calls back with the active index + */ + onChange?: (index: number) => void; + /** + * Child node(s) that can be nested inside component + */ + children?: ReactNode; + /** + * CSS class names that can be appended to the component. + */ + className?: string; + /** + * HTML id for the component + */ + id?: string; + /** + * Passed down to initially set the activeIndex state + */ + activeIndex?: number; + // Design API + /** + * Alignment of the tabs, when there is additional space available (not full width) + */ + align?: Align; + /** + * Whether the divider line (separating tabs from content) is visible. + * + * **Default is `"true"`. + */ + hasDivider?: boolean; + /** + * + */ + isSticky?: boolean; + /** + * Control how the indidivual tabs take up the available space. + * + * **Default is `"auto"`. + */ + tabWidth?: 'auto' | 'full'; +} + +export interface TabProps { + // Component API + /** + * aria-labelledby attribute that associates a tab panel with its accompanying tab title + */ + 'aria-labelledby'?: string; + /** + * Child node(s) that can be nested inside component + */ + children?: ReactNode; + /** + * CSS class names that can be appended to the component. + */ + className?: string; + /** + * HTML id for the component + */ + id?: string; + /** + * Number passed down from Tabs to show the active index state of Tabs + */ + activeIndex?: number; + // Design API + /** + * Text used to label the tab in the tab list + */ + title: string; + /** + * Icon name from the defined set of EDS icons + * + * **NOTE**: this cannot be used with `illustration`. + */ + icon?: IconName; + /** + * Illustration to appear above the tab text + * + * **NOTE**: this cannot be used with `icon` + */ + illustration?: ReactNode; +} + +type TabButtonProps = RenderProps; +export type TabContextArgs = { + isActive: boolean; + title: string; +}; + +/** + * `import {TabGroup} from "@chanzuckerberg/eds";` + * + * List of of links where each link toggles open associated information in a tab panel. + * + * Individual tabs allow for a simple text tab header using the `title` prop on each `` instance. + * For a more custom tabs, you can use an additonal `` sub-component with a render prop exposing `active` and `title`. + */ +export const TabGroup = ({ + activeIndex = 0, + align, + 'aria-labelledby': ariaLabelledBy, + children, + className, + hasDivider = true, + isSticky = false, + onChange, + tabWidth = 'auto', + ...other +}: TabGroupProps) => { + const activeTabPanelId = useId(); + const headerRef = useRef(null); + const [activeIndexState, setActiveIndexState] = useState(activeIndex); + const [scrollableLeft, setScrollableLeft] = useState(false); + const [scrollableRight, setScrollableRight] = useState(false); + + /** Children that are actually tabs. Any others are ignored. */ + const tabs = useMemo(() => { + return allByType(children, Tab); + }, [children]); + + const tabRefs = useMemo( + // This usage of React.createRef is intentional. + // eslint-disable-next-line @chanzuckerberg/edu-react/no-create-ref-in-function-component + () => tabs.map(() => React.createRef()), + [tabs], + ); + + const generatedId = useId(); + const tabIdPrefix = other.id || generatedId; + const tabIds = useMemo( + () => + tabs.map( + (tab) => `${tabIdPrefix}-${tab.props.title.replace(/\s/g, '-')}`, + ), + [tabs, tabIdPrefix], + ); + + // Set the active tab if the `activeIndex` prop changes. + const prevActiveIndex = usePrevious(activeIndex); + useEffect(() => { + if (prevActiveIndex != null && prevActiveIndex !== activeIndex) { + setActiveIndexState(activeIndex); + tabRefs[activeIndex].current?.focus(); + } + }, [prevActiveIndex, activeIndex, tabRefs]); + + /** + * Handles if scroll fade indicators should be displayed. + */ + const handleTabsScroll = useCallback((headerEl: HTMLDivElement) => { + const scrollLeft = headerEl.scrollLeft; + const width = headerEl.clientWidth; + const scrollWidth = headerEl.scrollWidth; + + if (scrollLeft > 0) { + setScrollableLeft(true); + } else { + setScrollableLeft(false); + } + + if (scrollWidth > width && scrollLeft + width < scrollWidth) { + setScrollableRight(true); + } else { + setScrollableRight(false); + } + }, []); + + /** + * Listens for window resize to display scroll fade indicators. + */ + useEffect(() => { + if (headerRef && headerRef.current) { + const resizeHandleTabs = debounce( + () => { + if (headerRef.current) { + handleTabsScroll(headerRef.current); + } + }, + 100, + { leading: true }, + ); + + /** + * The event listener actually calls the callback once when initiated, but the event listener + * is not triggered with prop changes so this line is required. + * This means the callback may be called twice on initial paint, which is fine, and + * is better than it not being called at all. + */ + resizeHandleTabs(); + window.addEventListener('resize', resizeHandleTabs); + return () => { + window.removeEventListener('resize', resizeHandleTabs); + }; + } + }, [handleTabsScroll]); + + function handleClick(index: number) { + setActiveIndexState(index); + + if (onChange) { + onChange(index); + } + } + + function handleKeyDown(e: KeyboardEvent) { + let activeTab = null; + + tabRefs.map((item) => { + if (item.current === document.activeElement) { + activeTab = item; + } + return item; + }); + + if (!activeTab) return; + + // Set active, next, and previous tab. + const index = tabRefs.indexOf(activeTab); + const next = index === tabRefs.length - 1 ? 0 : index + 1; + const prev = index === 0 ? tabRefs.length - 1 : index - 1; + + if ([R_ARROW_KEYCODE, D_ARROW_KEYCODE].includes(e.key)) { + // Right or down arrow was pressed. Focus the next tab. + tabRefs[next].current?.focus(); + } else if ([L_ARROW_KEYCODE, U_ARROW_KEYCODE].includes(e.key)) { + // Left or up was pressed. Focus the previous tab. + tabRefs[prev].current?.focus(); + } + } + + const componentClassName = clsx(styles['tabs'], className); + + const headerClassName = clsx( + styles['tabs__header'], + scrollableLeft && styles['tabs--scrollable-left'], + scrollableRight && styles['tabs--scrollable-right'], + ); + + const activeTabPanel = React.cloneElement(tabs[activeIndexState], { + id: activeTabPanelId, + 'aria-labelledby': tabIds[activeIndexState], + }); + + return ( + + ); +}; + +/** + * Get the previous value of a prop. Useful for comparing the previous to the current value. + */ +function usePrevious(prop: T) { + const ref = useRef(prop); + // eslint-disable-next-line @chanzuckerberg/edu-react/use-effect-deps-presence + useEffect(() => { + ref.current = prop; + }); + return ref.current; +} + +/** + * `import {Tab} from "@chanzuckerberg/eds";` + * + * Individual tab within the Tabs component. + * - Use the `title` prop for text-only tab headers + * - For more custom tab headers use `` which uses a render prop with state information + */ +export const Tab = ({ + 'aria-labelledby': ariaLabelledBy, + children, + className, + icon, + id, + illustration, + // Destructure `title` so it is not applied to the rendered element + title, + ...other +}: TabProps) => { + return ( +
+ {withoutTypes(children, TabButton)} +
+ ); +}; + +/** + * This component is a stub, and exists to give a type to the custom Tab button. + * It cannot be used as a standalone sub-component. See for where we trigger the render prop. + * + * Allows for control of the Tab Title contents, for custom tab handling + * + * ```jsx + * + * {({active, title}) => ( + * + * )} + * + * ``` + */ +const TabButton = (props: TabButtonProps) => { + return
; +}; + +Tab.displayName = 'Tab'; +Tab.Button = TabButton; +TabButton.displayName = 'TabGroup.Button'; + +TabGroup.displayName = 'TabGroup'; +TabGroup.Tab = Tab; diff --git a/src/components/TabGroup/__snapshots__/TabGroup.test.tsx.snap b/src/components/TabGroup/__snapshots__/TabGroup.test.tsx.snap new file mode 100644 index 000000000..c83437502 --- /dev/null +++ b/src/components/TabGroup/__snapshots__/TabGroup.test.tsx.snap @@ -0,0 +1,854 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Centered story renders snapshot 1`] = ` +
+ +
+
+
+

+ Tab 1 +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + +

+
+
+
+
+`; + +exports[` CustomTabGroup story renders snapshot 1`] = ` +
+
+ +
+
+
+

+ Tab 1 +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + +

+
+
+
+
+
+`; + +exports[` Default story renders snapshot 1`] = ` +
+ +
+
+
+

+ Tab 1 +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + +

+
+
+
+
+`; + +exports[` TabWidthFull story renders snapshot 1`] = ` +
+ +
+
+
+

+ Tab 1 +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + +

+
+
+
+
+`; + +exports[` WithTabIcons story renders snapshot 1`] = ` +
+ +
+
+
+

+ Tab 1 +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + +

+
+
+
+
+`; + +exports[` WithTabIllustrations story renders snapshot 1`] = ` +
+ +
+
+
+

+ Tab 1 +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + +

+
+
+
+
+`; diff --git a/src/components/TabGroup/index.ts b/src/components/TabGroup/index.ts new file mode 100644 index 000000000..deca4ab93 --- /dev/null +++ b/src/components/TabGroup/index.ts @@ -0,0 +1 @@ +export { TabGroup as default } from './TabGroup'; diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx index 24f877f04..289fe7d20 100644 --- a/src/components/Tabs/Tabs.tsx +++ b/src/components/Tabs/Tabs.tsx @@ -90,6 +90,8 @@ export type TabContextArgs = { * * Individual tabs allow for a simple text tab header using the `title` prop on each `` instance. * For a more custom tabs, you can use an additonal `` sub-component with a render prop exposing `active` and `title`. + * + * @deprecated */ export const Tabs = ({ activeIndex = 0,