+
+
+ {({ 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: (
+ <>
+ = {
+ 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 (
+
+
handleTabsScroll(e.target as HTMLDivElement)}
+ ref={headerRef}
+ >
+
+
+
{activeTabPanel}
+
+ );
+};
+
+/**
+ * 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,