From 03664a55f474095a0156147163259b5078244390 Mon Sep 17 00:00:00 2001 From: Adrien Zheng Date: Thu, 13 Aug 2026 13:41:21 -0400 Subject: [PATCH] feat: TabsScrollArea + TabsScrollAreaOverflowIndicator Adds TabsScrollArea on web and mobile as the supported way to host a horizontally scrolling Tabs row, closing the last gap that kept consumers on the deprecated TabNavigation. A render prop hands back onActiveTabElementChange so Tabs can scroll the active tab into view, and alpha TabbedChips now composes it instead of carrying its own scroll logic. Rebased onto current master and updated for APIs that landed since the change was first written: ref-as-prop instead of forwardRef on mobile, size instead of the now-deprecated compact, and the StylesAndClassNames helper for style slots. Non-function children fail softly on both platforms rather than throwing on web, and background is forwarded to the overflow indicators so their gradients keep matching the surface behind the tab row. Co-authored-by: Cursor --- apps/docs/docgen.config.js | 1 + .../TabbedChipsAlpha/_webExamples.mdx | 12 +- .../TabbedChipsAlpha/webMetadata.json | 4 + .../navigation/Tabs/_mobileExamples.mdx | 40 +++ .../navigation/Tabs/_webExamples.mdx | 40 +++ .../navigation/Tabs/mobileMetadata.json | 4 + .../navigation/Tabs/webMetadata.json | 4 + .../TabsScrollArea/_mobileExamples.mdx | 125 ++++++++++ .../TabsScrollArea/_mobilePropsTable.mdx | 10 + .../TabsScrollArea/_mobileStyles.mdx | 7 + .../TabsScrollArea/_webExamples.mdx | 119 +++++++++ .../TabsScrollArea/_webPropsTable.mdx | 10 + .../navigation/TabsScrollArea/_webStyles.mdx | 55 +++++ .../navigation/TabsScrollArea/index.mdx | 44 ++++ .../TabsScrollArea/mobileMetadata.json | 15 ++ .../TabsScrollArea/webMetadata.json | 22 ++ apps/docs/sidebars.ts | 5 + apps/expo-app/src/routes.ts | 5 + .../src/alpha/tabbed-chips/TabbedChips.tsx | 63 ++--- packages/mobile/src/core/componentConfig.ts | 2 + .../mobile/src/layout/OverflowGradient.tsx | 4 +- packages/mobile/src/tabs/TabsScrollArea.tsx | 138 +++++++++++ .../tabs/TabsScrollAreaOverflowIndicator.tsx | 38 +++ .../__stories__/TabsScrollArea.stories.tsx | 159 ++++++++++++ .../tabs/__tests__/TabsScrollArea.test.tsx | 70 ++++++ packages/mobile/src/tabs/index.ts | 2 + .../src/alpha/tabbed-chips/TabbedChips.tsx | 146 ++++++----- .../__stories__/TabbedChips.stories.tsx | 6 +- packages/web/src/core/componentConfig.ts | 2 + packages/web/src/tabs/TabsScrollArea.tsx | 231 ++++++++++++++++++ .../tabs/TabsScrollAreaOverflowIndicator.tsx | 210 ++++++++++++++++ .../__stories__/TabsScrollArea.stories.tsx | 192 +++++++++++++++ .../tabs/__tests__/TabsScrollArea.test.tsx | 135 ++++++++++ packages/web/src/tabs/index.ts | 2 + 34 files changed, 1799 insertions(+), 123 deletions(-) create mode 100644 apps/docs/docs/components/navigation/TabsScrollArea/_mobileExamples.mdx create mode 100644 apps/docs/docs/components/navigation/TabsScrollArea/_mobilePropsTable.mdx create mode 100644 apps/docs/docs/components/navigation/TabsScrollArea/_mobileStyles.mdx create mode 100644 apps/docs/docs/components/navigation/TabsScrollArea/_webExamples.mdx create mode 100644 apps/docs/docs/components/navigation/TabsScrollArea/_webPropsTable.mdx create mode 100644 apps/docs/docs/components/navigation/TabsScrollArea/_webStyles.mdx create mode 100644 apps/docs/docs/components/navigation/TabsScrollArea/index.mdx create mode 100644 apps/docs/docs/components/navigation/TabsScrollArea/mobileMetadata.json create mode 100644 apps/docs/docs/components/navigation/TabsScrollArea/webMetadata.json create mode 100644 packages/mobile/src/tabs/TabsScrollArea.tsx create mode 100644 packages/mobile/src/tabs/TabsScrollAreaOverflowIndicator.tsx create mode 100644 packages/mobile/src/tabs/__stories__/TabsScrollArea.stories.tsx create mode 100644 packages/mobile/src/tabs/__tests__/TabsScrollArea.test.tsx create mode 100644 packages/web/src/tabs/TabsScrollArea.tsx create mode 100644 packages/web/src/tabs/TabsScrollAreaOverflowIndicator.tsx create mode 100644 packages/web/src/tabs/__stories__/TabsScrollArea.stories.tsx create mode 100644 packages/web/src/tabs/__tests__/TabsScrollArea.test.tsx diff --git a/apps/docs/docgen.config.js b/apps/docs/docgen.config.js index 444bfd15dd..ac76315c12 100644 --- a/apps/docs/docgen.config.js +++ b/apps/docs/docgen.config.js @@ -176,6 +176,7 @@ module.exports = { 'tabs/TabLabel', 'tabs/TabNavigation', 'tabs/Tabs', + 'tabs/TabsScrollArea', 'tag/Tag', 'tour/Tour', 'typography/Link', diff --git a/apps/docs/docs/components/navigation/TabbedChipsAlpha/_webExamples.mdx b/apps/docs/docs/components/navigation/TabbedChipsAlpha/_webExamples.mdx index a448bd1a49..c238bcc231 100644 --- a/apps/docs/docs/components/navigation/TabbedChipsAlpha/_webExamples.mdx +++ b/apps/docs/docs/components/navigation/TabbedChipsAlpha/_webExamples.mdx @@ -61,7 +61,7 @@ function ExampleWithPaddles() { ### With autoScrollOffset :::tip Auto-scroll offset -The `autoScrollOffset` prop controls the X position offset when auto-scrolling to the active tab. This prevents the active tab from being covered by the paddle on the left side. Try clicking tabs near the edges to see the difference. +The `autoScrollOffset` prop controls the horizontal offset when auto-scrolling the active tab into view, so it stays clear of the leading overflow control. Try selecting tabs near the edges to see the difference. ::: ```jsx live @@ -91,14 +91,14 @@ function ExampleAutoScrollOffset() { } ``` -### With custom sized paddles +### With custom sized overflow controls -:::tip Paddle styling -You can adjust the size of the paddles via `styles.paddle`. +:::tip Overflow control styling +Target the chevron buttons with **`styles.overflowIndicatorButton`** (or **`overflowIndicator`** / **`overflowIndicatorGradient`**). ::: ```jsx live -function ExampleCustomPaddles() { +function ExampleCustomOverflowControls() { const tabs = Array.from({ length: 10 }).map((_, i) => ({ id: `t_${i + 1}`, label: `Item ${i + 1}`, @@ -109,7 +109,7 @@ function ExampleCustomPaddles() { activeTab={activeTab} onChange={setActiveTab} tabs={tabs} - styles={{ paddle: { transform: 'scale(0.8)' } }} + styles={{ overflowIndicatorButton: { transform: 'scale(0.8)' } }} /> ); } diff --git a/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json b/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json index 8ccb13f533..d339789b3a 100644 --- a/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json @@ -22,6 +22,10 @@ "label": "Tabs", "url": "/components/navigation/Tabs/" }, + { + "label": "TabsScrollArea", + "url": "/components/navigation/TabsScrollArea/" + }, { "label": "SelectChip", "url": "/components/inputs/SelectChip/" diff --git a/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx b/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx index e8a709e4f2..5e0168ea79 100644 --- a/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx +++ b/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx @@ -218,3 +218,43 @@ function Example() { ); } ``` + +## Scrolling with TabsScrollArea + +When the tab row can overflow horizontally, wrap **`Tabs`** in [TabsScrollArea](/components/navigation/TabsScrollArea/). Pass the render prop’s **`onActiveTabElementChange`** into **`Tabs`**. Set **`width`** / **`maxWidth`** on **`TabsScrollArea`** so the row overflows and edge gradients appear. + +```jsx +function Example() { + const tabs = [ + { id: 't1', label: 'Overview' }, + { id: 't2', label: 'Markets' }, + { id: 't3', label: 'Trade' }, + { id: 't4', label: 'Earn' }, + { id: 't5', label: 'Learn' }, + { id: 't6', label: 'More' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + + {({ onActiveTabElementChange }) => ( + + )} + + ); +} +``` + +## Accessibility + +Set **`accessibilityLabel`** on **`Tabs`**. **`DefaultTab`** wires `accessibilityRole="tab"` and selection state; keep tab panels in sync in your screen content. diff --git a/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx b/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx index 2a8a15e17f..63a8199c6b 100644 --- a/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx +++ b/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx @@ -224,3 +224,43 @@ function Example() { ); } ``` + +## Scrolling with TabsScrollArea + +When the tab row can overflow horizontally, wrap **`Tabs`** in [TabsScrollArea](/components/navigation/TabsScrollArea/). Pass the render prop’s **`onActiveTabElementChange`** into **`Tabs`** so the active tab scrolls into view. Narrow the viewport or set **`width`** / **`maxWidth`** on **`TabsScrollArea`** to see overflow controls. + +```jsx live +function Example() { + const tabs = [ + { id: 't1', label: 'Overview' }, + { id: 't2', label: 'Markets' }, + { id: 't3', label: 'Trade' }, + { id: 't4', label: 'Earn' }, + { id: 't5', label: 'Learn' }, + { id: 't6', label: 'More' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + + {({ onActiveTabElementChange }) => ( + + )} + + ); +} +``` + +## Accessibility + +Provide a descriptive **`accessibilityLabel`** on **`Tabs`** for the tab list. **`DefaultTab`** sets `aria-controls` / `aria-selected` for each tab; pair tabs with **`role="tabpanel"`** regions in your page content when you switch panels. diff --git a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json index c8fc565aa3..63174f6513 100644 --- a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json @@ -4,6 +4,10 @@ "description": "Tabs is a flexible, accessible tab list for switching between related views. Use `DefaultTab` and `DefaultTabsActiveIndicator` for a standard underline tab row without custom tab wiring, or provide your own `TabComponent` and `TabsActiveIndicatorComponent`. For pill-style selection, see SegmentedTabs.", "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=25128-9889&t=7bpcjquwgXNk9lnN-4", "relatedComponents": [ + { + "label": "TabsScrollArea", + "url": "/components/navigation/TabsScrollArea/" + }, { "label": "SegmentedTabs", "url": "/components/navigation/SegmentedTabs/" diff --git a/apps/docs/docs/components/navigation/Tabs/webMetadata.json b/apps/docs/docs/components/navigation/Tabs/webMetadata.json index f5a729f2a7..89ccbeeb0e 100644 --- a/apps/docs/docs/components/navigation/Tabs/webMetadata.json +++ b/apps/docs/docs/components/navigation/Tabs/webMetadata.json @@ -4,6 +4,10 @@ "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-tabs-tabs--all", "description": "Tabs is a flexible, accessible tab list for switching between related views. Use `DefaultTab` and `DefaultTabsActiveIndicator` for a standard underline tab row without wiring custom components, or supply your own `TabComponent` and `TabsActiveIndicatorComponent` for full control. For pill-style selection, see SegmentedTabs.", "relatedComponents": [ + { + "label": "TabsScrollArea", + "url": "/components/navigation/TabsScrollArea/" + }, { "label": "SegmentedTabs", "url": "/components/navigation/SegmentedTabs" diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_mobileExamples.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_mobileExamples.mdx new file mode 100644 index 0000000000..6db6e853a1 --- /dev/null +++ b/apps/docs/docs/components/navigation/TabsScrollArea/_mobileExamples.mdx @@ -0,0 +1,125 @@ +TabsScrollArea wraps a horizontal [Tabs](/components/navigation/Tabs/) row in a `ScrollView` and shows edge gradients when content overflows. Use a **function child** that receives `onActiveTabElementChange` and pass it through to **`Tabs`** so the active tab can scroll into view. + +## Basics + +Set **`width`** / **`maxWidth`** on **`TabsScrollArea`** so the tab row overflows on smaller screens; gradients appear when there is offscreen content. + +```jsx +function Example() { + const tabs = [ + { id: 't1', label: 'Overview' }, + { id: 't2', label: 'Markets' }, + { id: 't3', label: 'Trade' }, + { id: 't4', label: 'Earn' }, + { id: 't5', label: 'Learn' }, + { id: 't6', label: 'More' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + + {({ onActiveTabElementChange }) => ( + + )} + + ); +} +``` + +## Overflow indicator + +On React Native, the default **`TabsScrollAreaOverflowIndicator`** renders an **`OverflowGradient`** at each edge when there is offscreen content. Scrolling is **gesture-based** (horizontal pan on the **`ScrollView`**); the default gradient is **visual-only** and does not receive press handlers from **`TabsScrollArea`**. + +### Custom `OverflowIndicatorComponent` + +Provide a component that satisfies **`TabsScrollAreaOverflowIndicatorProps`**: **`direction`** (`'left'` \| `'right'`), **`show`**, optional **`style`**, and **`testID`**. The default implementation maps **`direction`** to **`OverflowGradient`**’s **`pin`**. + +The example below replaces the gradient with a simple edge strip (visual-only; no scroll wiring). + +```tsx +import { Box } from '@coinbase/cds-mobile/layout'; +import { + DefaultTab, + DefaultTabsActiveIndicator, + Tabs, + TabsScrollArea, + type TabsScrollAreaOverflowIndicatorProps, +} from '@coinbase/cds-mobile/tabs'; + +function CustomOverflowIndicator({ + direction, + show, + style, + testID, +}: TabsScrollAreaOverflowIndicatorProps) { + if (!show) { + return null; + } + const isLeft = direction === 'left'; + return ( + + ); +} + +function Example() { + const tabs = [ + { id: 't1', label: 'Overview' }, + { id: 't2', label: 'Markets' }, + { id: 't3', label: 'Trade' }, + { id: 't4', label: 'Earn' }, + { id: 't5', label: 'Learn' }, + { id: 't6', label: 'More' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + + {({ onActiveTabElementChange }) => ( + + )} + + ); +} +``` + +## Styling + +Use **`styles.root`**, **`styles.scrollContainer`**, and **`styles.overflowIndicator`** to align with layout tokens. Mobile uses a single **`overflowIndicator`** style slot for both edges (see **Styles** tab). + +## Accessibility + +Set **`accessibilityLabel`** on **`TabsScrollArea`** (root container) and on **`Tabs`** for screen readers. diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_mobilePropsTable.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_mobilePropsTable.mdx new file mode 100644 index 0000000000..8c3e7aa42e --- /dev/null +++ b/apps/docs/docs/components/navigation/TabsScrollArea/_mobilePropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import mobilePropsData from ':docgen/mobile/tabs/TabsScrollArea/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_mobileStyles.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_mobileStyles.mdx new file mode 100644 index 0000000000..bede3238ea --- /dev/null +++ b/apps/docs/docs/components/navigation/TabsScrollArea/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/tabs/TabsScrollArea/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_webExamples.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_webExamples.mdx new file mode 100644 index 0000000000..a6282c88f4 --- /dev/null +++ b/apps/docs/docs/components/navigation/TabsScrollArea/_webExamples.mdx @@ -0,0 +1,119 @@ +TabsScrollArea is the recommended wrapper when a [Tabs](/components/navigation/Tabs/) row may overflow horizontally. Pass a **function child** that receives `onActiveTabElementChange` and spread that onto **`Tabs`** (along with shared accessibility props) so the scroll region can measure tab positions and scroll the active tab into view. + +## Basics + +Pair **`TabsScrollArea`** with **`Tabs`**, **`DefaultTab`**, and **`DefaultTabsActiveIndicator`**. Set **`width`** / **`maxWidth`** on **`TabsScrollArea`** so the row overflows on smaller viewports; chevron controls and edge gradients appear when there is offscreen content. + +```jsx live +function Example() { + const tabs = [ + { id: 't1', label: 'Overview' }, + { id: 't2', label: 'Markets' }, + { id: 't3', label: 'Trade' }, + { id: 't4', label: 'Earn' }, + { id: 't5', label: 'Learn' }, + { id: 't6', label: 'More' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + + {({ onActiveTabElementChange }) => ( + + )} + + ); +} +``` + +## Overflow indicator + +On web, the default **`TabsScrollAreaOverflowIndicator`** combines a fade gradient with chevron controls. + +### Custom `OverflowIndicatorComponent` + +Provide your own component that matches **`TabsScrollAreaOverflowIndicatorProps`**: **`direction`** (`'left'` \| `'right'`), **`show`**, **`onClick`**, plus optional **`styles`** / **`classNames`** slots. **`TabsScrollArea`** passes **`accessibilityLabel`** for the side control—forward it to a button or **`IconButton`** when you build a custom control. + +```jsx live +function Example() { + const tabs = [ + { id: 't1', label: 'Overview' }, + { id: 't2', label: 'Markets' }, + { id: 't3', label: 'Trade' }, + { id: 't4', label: 'Earn' }, + { id: 't5', label: 'Learn' }, + { id: 't6', label: 'More' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + const CustomOverflowIndicator = useCallback( + ({ accessibilityLabel, direction, show, onClick, style, className }) => { + if (!show) { + return null; + } + return ( + + + + ); + }, + [], + ); + return ( + + {({ onActiveTabElementChange }) => ( + + )} + + ); +} +``` + +## Styling + +Use **`styles`** / **`classNames`** to target the root, scroll container, and overflow controls (`overflowIndicator`, `overflowIndicatorButton`, `overflowIndicatorButtonContainer`, `overflowIndicatorGradient`, and related slots on web). + +## Accessibility + +Set **`accessibilityLabel`** on **`TabsScrollArea`** for the scrollable tab list region. On web, customize **`previousArrowAccessibilityLabel`** and **`nextArrowAccessibilityLabel`** for the default overflow controls. Ensure **`Tabs`** also has a meaningful **`accessibilityLabel`** for the tablist, as in the [Tabs](/components/navigation/Tabs/) examples. diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_webPropsTable.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_webPropsTable.mdx new file mode 100644 index 0000000000..436b33f48e --- /dev/null +++ b/apps/docs/docs/components/navigation/TabsScrollArea/_webPropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import webPropsData from ':docgen/web/tabs/TabsScrollArea/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/_webStyles.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/_webStyles.mdx new file mode 100644 index 0000000000..1aa20844f6 --- /dev/null +++ b/apps/docs/docs/components/navigation/TabsScrollArea/_webStyles.mdx @@ -0,0 +1,55 @@ +import { useMemo, useState } from 'react'; +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { + DefaultTab, + DefaultTabsActiveIndicator, + Tabs, + TabsScrollArea, +} from '@coinbase/cds-web/tabs'; + +import webStylesData from ':docgen/web/tabs/TabsScrollArea/styles-data'; + +export const TabsScrollAreaStylesExample = ({ classNames }) => { + const longTabs = useMemo( + () => + Array.from({ length: 9 }, (_, i) => ({ + id: `tab-${i}`, + label: `Section ${i + 1}`, + })), + [], + ); + const [activeTab, setActiveTab] = useState(longTabs[0]); + return ( + + {({ onActiveTabElementChange }) => ( + + )} + + ); +}; + +## Explorer + + + {(classNames) => } + + +## Selectors + + diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/index.mdx b/apps/docs/docs/components/navigation/TabsScrollArea/index.mdx new file mode 100644 index 0000000000..667a4b76ae --- /dev/null +++ b/apps/docs/docs/components/navigation/TabsScrollArea/index.mdx @@ -0,0 +1,44 @@ +--- +id: tabsScrollArea +title: TabsScrollArea +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web/tabs/TabsScrollArea/toc-props'; +import mobilePropsToc from ':docgen/mobile/tabs/TabsScrollArea/toc-props'; + +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + mobileExamplesToc={mobileExamplesToc} + mobilePropsTable={} + mobilePropsToc={mobilePropsToc} + mobileStyles={} + mobileStylesToc={mobileStylesToc} + webExamples={} + webExamplesToc={webExamplesToc} + webPropsTable={} + webPropsToc={webPropsToc} + webStyles={} + webStylesToc={webStylesToc} + /> + diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/mobileMetadata.json b/apps/docs/docs/components/navigation/TabsScrollArea/mobileMetadata.json new file mode 100644 index 0000000000..3e779ef998 --- /dev/null +++ b/apps/docs/docs/components/navigation/TabsScrollArea/mobileMetadata.json @@ -0,0 +1,15 @@ +{ + "import": "import { TabsScrollArea } from '@coinbase/cds-mobile/tabs'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/tabs/TabsScrollArea.tsx", + "description": "Horizontal scroll container for tab rows on React Native, with edge overflow gradients and automatic scroll-into-view for the active tab.", + "relatedComponents": [ + { + "label": "Tabs", + "url": "/components/navigation/Tabs/" + }, + { + "label": "TabbedChips (Alpha)", + "url": "/components/navigation/TabbedChipsAlpha/" + } + ] +} diff --git a/apps/docs/docs/components/navigation/TabsScrollArea/webMetadata.json b/apps/docs/docs/components/navigation/TabsScrollArea/webMetadata.json new file mode 100644 index 0000000000..0f11b77bf6 --- /dev/null +++ b/apps/docs/docs/components/navigation/TabsScrollArea/webMetadata.json @@ -0,0 +1,22 @@ +{ + "import": "import { TabsScrollArea } from '@coinbase/cds-web/tabs'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/tabs/TabsScrollArea.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-tabs-tabsscrollarea--default", + "description": "Horizontal scroll container for tab rows on web, with chevron controls, edge gradients, and automatic scroll-into-view for the active tab.", + "relatedComponents": [ + { + "label": "Tabs", + "url": "/components/navigation/Tabs/" + }, + { + "label": "TabbedChips (Alpha)", + "url": "/components/navigation/TabbedChipsAlpha/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 6b8d97ce72..d66894aa8f 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -609,6 +609,11 @@ const sidebars: SidebarsConfig = { label: 'BrowserBar', }, { type: 'doc', id: 'components/navigation/Tabs/tabs', label: 'Tabs' }, + { + type: 'doc', + id: 'components/navigation/TabsScrollArea/tabsScrollArea', + label: 'TabsScrollArea', + }, { type: 'doc', id: 'components/navigation/Coachmark/coachmark', diff --git a/apps/expo-app/src/routes.ts b/apps/expo-app/src/routes.ts index 8f31ff3835..ef2653adac 100644 --- a/apps/expo-app/src/routes.ts +++ b/apps/expo-app/src/routes.ts @@ -748,6 +748,11 @@ export const routes = [ key: 'Tabs', getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/Tabs.stories').default, }, + { + key: 'TabsScrollArea', + getComponent: () => + require('@coinbase/cds-mobile/tabs/__stories__/TabsScrollArea.stories').default, + }, { key: 'Tag', getComponent: () => require('@coinbase/cds-mobile/tag/__stories__/Tag.stories').default, diff --git a/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx b/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx index 48502b7657..0c914e0600 100644 --- a/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx +++ b/packages/mobile/src/alpha/tabbed-chips/TabbedChips.tsx @@ -1,5 +1,5 @@ -import React, { memo, useCallback, useMemo, useState } from 'react'; -import { ScrollView, type StyleProp, type View, type ViewStyle } from 'react-native'; +import React, { memo, useCallback, useMemo } from 'react'; +import type { StyleProp, View, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; @@ -9,10 +9,9 @@ import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; import type { ChipProps, ChipSize } from '../../chips/ChipProps'; import { MediaChip, type MediaChipBaseProps } from '../../chips/MediaChip'; import { useComponentConfig } from '../../hooks/useComponentConfig'; -import { useHorizontalScrollToTarget } from '../../hooks/useHorizontalScrollToTarget'; -import { Box, type BoxProps } from '../../layout/Box'; -import { OverflowGradient } from '../../layout/OverflowGradient'; +import type { BoxProps } from '../../layout/Box'; import { Tabs, type TabsBaseProps, type TabsProps } from '../../tabs/Tabs'; +import { TabsScrollArea, type TabsScrollAreaStyles } from '../../tabs/TabsScrollArea'; const DefaultTabComponent = ({ label = '', @@ -39,7 +38,7 @@ const DefaultTabComponent = ({ ); }; -const TabsActiveIndicatorComponent = () => { +const DefaultTabsActiveIndicatorComponent = () => { return null; }; @@ -101,12 +100,16 @@ export type TabbedChipsProps = TabbedChipsBasePro gap?: ThemeVars.Space; /** * The width of the scroll container, defaults to 100% of the parent container - * If the tabs are wider than the width of the container, paddles will be shown to scroll the tabs. + * If the tabs are wider than the width of the container, overflow gradients are shown at the edges. */ width?: BoxProps['width']; styles?: { /** Root container element */ root?: StyleProp; + /** Horizontal scroll region wrapping the tab row (aligned with {@link TabsScrollArea}). */ + scrollContainer?: StyleProp; + /** Single overflow affordance (gradient); applied to both edges (aligned with {@link TabsScrollArea}). */ + overflowIndicator?: StyleProp; /** Tabs root element */ tabs?: StyleProp; }; @@ -128,6 +131,7 @@ const TabbedChipsComponent = memo(function TabbedChips(null); - const { - scrollRef, - isScrollContentOverflowing, - isScrollContentOffscreenLeft, - isScrollContentOffscreenRight, - handleScroll, - handleScrollContainerLayout, - handleScrollContentSizeChange, - } = useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset }); const TabComponentWithSize = useCallback( (props: TabValue) => { @@ -157,42 +151,37 @@ const TabbedChipsComponent = memo(function TabbedChips ({ + root: styles?.root, + scrollContainer: styles?.scrollContainer, + overflowIndicator: styles?.overflowIndicator, + }), + [styles], + ); + return ( - - + {({ onActiveTabElementChange }) => ( - - {isScrollContentOverflowing && isScrollContentOffscreenLeft && ( - - )} - {isScrollContentOverflowing && isScrollContentOffscreenRight && ( - )} - + ); }); diff --git a/packages/mobile/src/core/componentConfig.ts b/packages/mobile/src/core/componentConfig.ts index 197b1fa9a8..17f96ef4a5 100644 --- a/packages/mobile/src/core/componentConfig.ts +++ b/packages/mobile/src/core/componentConfig.ts @@ -68,6 +68,7 @@ import type { StepperBaseProps } from '../stepper/Stepper'; import type { SegmentedTabBaseProps } from '../tabs/SegmentedTab'; import type { SegmentedTabsBaseProps } from '../tabs/SegmentedTabs'; import type { TabsBaseProps } from '../tabs/Tabs'; +import type { TabsScrollAreaBaseProps } from '../tabs/TabsScrollArea'; import type { TagBaseProps } from '../tag/Tag'; import type { TourBaseProps } from '../tour/Tour'; import type { LinkBaseProps } from '../typography/Link'; @@ -161,6 +162,7 @@ export type ComponentConfig = { Stepper?: ConfigResolver; Switch?: ConfigResolver>; Tabs?: ConfigResolver; + TabsScrollArea?: ConfigResolver; Tag?: ConfigResolver; Text?: ConfigResolver; TextFallback?: ConfigResolver; diff --git a/packages/mobile/src/layout/OverflowGradient.tsx b/packages/mobile/src/layout/OverflowGradient.tsx index 5966e10f78..cc2f34085f 100644 --- a/packages/mobile/src/layout/OverflowGradient.tsx +++ b/packages/mobile/src/layout/OverflowGradient.tsx @@ -1,6 +1,6 @@ import React, { memo, useMemo } from 'react'; import { StyleSheet } from 'react-native'; -import type { ViewStyle } from 'react-native'; +import type { StyleProp, ViewStyle } from 'react-native'; import type { PinningDirection } from '@coinbase/cds-common/types/BoxBaseProps'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; @@ -10,7 +10,7 @@ import { pinStyles } from '../styles/pinStyles'; export type OverflowGradientProps = { pin?: Exclude; - style?: ViewStyle; + style?: StyleProp; } & SharedProps; const gradient = { diff --git a/packages/mobile/src/tabs/TabsScrollArea.tsx b/packages/mobile/src/tabs/TabsScrollArea.tsx new file mode 100644 index 0000000000..e9293d22db --- /dev/null +++ b/packages/mobile/src/tabs/TabsScrollArea.tsx @@ -0,0 +1,138 @@ +import React, { memo, useMemo, useState } from 'react'; +import { ScrollView } from 'react-native'; +import type { StyleProp, View, ViewStyle } from 'react-native'; +import type { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; + +import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useHorizontalScrollToTarget } from '../hooks/useHorizontalScrollToTarget'; +import { Box, type BoxBaseProps } from '../layout/Box'; + +import { + TabsScrollAreaOverflowIndicator, + type TabsScrollAreaOverflowIndicatorProps, +} from './TabsScrollAreaOverflowIndicator'; + +/** + * Values passed to `TabsScrollArea`'s function child. Pass `onActiveTabElementChange` to `Tabs` as + * `onActiveTabElementChange` so the scroll area can scroll the active tab into view. + */ +export type TabsScrollAreaRenderProps = { + /** + * Pass to `Tabs` as `onActiveTabElementChange={onActiveTabElementChange}`. + */ + onActiveTabElementChange: (element: View | null) => void; +}; + +export type TabsScrollAreaStyles = { + /** Root layout element */ + root?: StyleProp; + /** Horizontal `ScrollView` wrapping `Tabs` */ + scrollContainer?: StyleProp; + /** Applied to the overflow indicator at each edge */ + overflowIndicator?: StyleProp; +}; + +export type TabsScrollAreaBaseProps = Omit & + SharedAccessibilityProps & { + /** + * Horizontal offset when auto-scrolling to the active tab (e.g. so the active tab is not under the overflow gradient). + * @default 30 + */ + autoScrollOffset?: number; + /** + * Rendered at each end when content overflows. Defaults to {@link TabsScrollAreaOverflowIndicator} + * ({@link OverflowGradient}). Props must extend {@link TabsScrollAreaOverflowIndicatorProps}. + */ + OverflowIndicatorComponent?: React.FC; + }; + +export type TabsScrollAreaProps = TabsScrollAreaBaseProps & { + /** + * Render function that receives `onActiveTabElementChange` (wire to `Tabs` as + * `onActiveTabElementChange`). + */ + children: (props: TabsScrollAreaRenderProps) => React.ReactNode; + /** Custom style for the root element */ + style?: StyleProp; + /** Custom styles for individual elements of the TabsScrollArea component */ + styles?: TabsScrollAreaStyles; + ref?: React.Ref; +}; + +export const TabsScrollArea = memo(function TabsScrollArea({ + ref, + ..._props +}: TabsScrollAreaProps) { + const mergedProps = useComponentConfig('TabsScrollArea', _props); + const { + children, + testID, + width, + autoScrollOffset = 30, + OverflowIndicatorComponent = TabsScrollAreaOverflowIndicator, + style, + styles: { + root: rootStyle, + scrollContainer: scrollContainerStyle, + overflowIndicator: overflowIndicatorStyle, + } = {}, + ...props + } = mergedProps; + + const [scrollTarget, setScrollTarget] = useState(null); + const { + scrollRef, + isScrollContentOverflowing, + isScrollContentOffscreenLeft, + isScrollContentOffscreenRight, + handleScroll, + handleScrollContainerLayout, + handleScrollContentSizeChange, + } = useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset }); + + const rootStyles = useMemo(() => [style, rootStyle], [rootStyle, style]); + + const renderedChildren = useMemo(() => { + if (typeof children === 'function') { + return children({ onActiveTabElementChange: setScrollTarget }); + } + return children ?? null; + }, [children]); + + return ( + + + {renderedChildren} + + + + + ); +}); + +TabsScrollArea.displayName = 'TabsScrollArea'; diff --git a/packages/mobile/src/tabs/TabsScrollAreaOverflowIndicator.tsx b/packages/mobile/src/tabs/TabsScrollAreaOverflowIndicator.tsx new file mode 100644 index 0000000000..2cccf0298c --- /dev/null +++ b/packages/mobile/src/tabs/TabsScrollAreaOverflowIndicator.tsx @@ -0,0 +1,38 @@ +import { memo } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; + +import { OverflowGradient } from '../layout/OverflowGradient'; + +export type TabsScrollAreaOverflowIndicatorBaseProps = SharedProps & { + /** + * Direction of the indicator. + */ + direction?: 'left' | 'right'; + /** + * When false, nothing is rendered. + */ + show: boolean; +}; + +export type TabsScrollAreaOverflowIndicatorProps = TabsScrollAreaOverflowIndicatorBaseProps & { + style?: StyleProp; +}; + +/** + * Default overflow affordance for {@link TabsScrollArea} on React Native: a single-layer + * {@link OverflowGradient} (no separate button / container slots). + */ +export const TabsScrollAreaOverflowIndicator = memo(function TabsScrollAreaOverflowIndicator({ + show, + direction = 'left', + ...props +}: TabsScrollAreaOverflowIndicatorProps) { + if (!show) { + return null; + } + + return ; +}); + +TabsScrollAreaOverflowIndicator.displayName = 'TabsScrollAreaOverflowIndicator'; diff --git a/packages/mobile/src/tabs/__stories__/TabsScrollArea.stories.tsx b/packages/mobile/src/tabs/__stories__/TabsScrollArea.stories.tsx new file mode 100644 index 0000000000..370dfcbe54 --- /dev/null +++ b/packages/mobile/src/tabs/__stories__/TabsScrollArea.stories.tsx @@ -0,0 +1,159 @@ +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { StyleSheet } from 'react-native'; +import { sampleTabs } from '@coinbase/cds-common/internal/data/tabs'; +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; +import { gutter } from '@coinbase/cds-common/tokens/sizing'; +import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; + +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { LinearGradient } from '../../gradients/LinearGradient'; +import { useTheme } from '../../hooks/useTheme'; +import { type BoxProps, VStack } from '../../layout'; +import { pinStyles } from '../../styles/pinStyles'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultTheme } from '../../themes/defaultTheme'; +import { Text } from '../../typography/Text'; +import { DefaultTab } from '../DefaultTab'; +import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator'; +import { Tabs } from '../Tabs'; +import { TabsScrollArea } from '../TabsScrollArea'; +import type { TabsScrollAreaOverflowIndicatorProps } from '../TabsScrollAreaOverflowIndicator'; + +const basicTabs: (TabValue & { testID?: string })[] = [ + { id: 'buy', label: 'Buy', testID: 'buy-tab' }, + { id: 'sell', label: 'Sell', testID: 'sell-tab' }, + { id: 'convert', label: 'Convert', testID: 'convert-tab' }, +]; + +const longTabs = sampleTabs.slice(0, 9); + +const storyShadowGradientDirections = { + right: { + start: { x: 1, y: 0 }, + end: { x: 0, y: 0 }, + }, + left: { + start: { x: 0, y: 0 }, + end: { x: 1, y: 0 }, + }, +} as const; + +const StoryCustomOverflowIndicator = memo(function StoryCustomOverflowIndicator({ + direction = 'left', + show, + style, + testID, +}: TabsScrollAreaOverflowIndicatorProps) { + const theme = useTheme(); + const shadowGradientColors = useMemo( + () => ['rgba(0, 0, 0, 0.22)', 'rgba(0, 0, 0, 0.06)', theme.color.transparent], + [theme.color.transparent], + ); + + if (!show) { + return null; + } + + return ( + + ); +}); + +const styles = StyleSheet.create({ + gradientShadow: { + width: 44, + }, +}); + +StoryCustomOverflowIndicator.displayName = 'StoryCustomOverflowIndicator'; + +type TabsScrollAreaExampleProps = { + title: string; + description?: string; + width?: BoxProps['width']; + maxWidth?: BoxProps['maxWidth']; + tabs: TabValue[]; + OverflowIndicatorComponent?: React.FC; +}; + +const TabsScrollAreaExample = ({ + title, + description = 'Use a narrow width so the tab row overflows and edge gradients appear. Scroll horizontally to move the row.', + width, + maxWidth, + tabs, + OverflowIndicatorComponent, +}: TabsScrollAreaExampleProps) => { + const [activeTab, setActiveTab] = useState | null>(tabs[0]); + const handleChange = useCallback((next: TabValue | null) => setActiveTab(next), []); + + return ( + + + + {description} + + + {({ onActiveTabElementChange }) => ( + + )} + + + + ); +}; + +const TabsScrollAreaStoriesScreen = () => ( + + + + + + + + + +); + +export default TabsScrollAreaStoriesScreen; diff --git a/packages/mobile/src/tabs/__tests__/TabsScrollArea.test.tsx b/packages/mobile/src/tabs/__tests__/TabsScrollArea.test.tsx new file mode 100644 index 0000000000..3f73d9c60e --- /dev/null +++ b/packages/mobile/src/tabs/__tests__/TabsScrollArea.test.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { Text } from 'react-native'; +import { sampleTabs } from '@coinbase/cds-common/internal/data/tabs'; +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { DefaultThemeProvider } from '../../utils/testHelpers'; +import { DefaultTab } from '../DefaultTab'; +import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator'; +import { Tabs } from '../Tabs'; +import { TabsScrollArea } from '../TabsScrollArea'; + +const tabs = sampleTabs.slice(0, 5); + +const testID = 'tabs-scroll-area'; + +const Demo = () => { + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + + + {({ onActiveTabElementChange }) => ( + + )} + + + ); +}; + +describe('TabsScrollArea', () => { + it('passes a11y', () => { + render(); + expect(screen.getByTestId(testID)).toBeAccessible(); + }); + + it('renders the scroll area and tabs', () => { + render(); + expect(screen.getByTestId(testID)).toBeVisible(); + expect(screen.getByText('Tab one')).toBeVisible(); + }); + + it('forwards accessibilityLabel to the root', () => { + render(); + expect(screen.getByLabelText('Scrollable tab list')).toBeVisible(); + }); + + it('updates selected tab on press', async () => { + render(); + const firstTestId = tabs[0].testID ?? tabs[0].id; + const secondTestId = tabs[1].testID ?? tabs[1].id; + + expect(screen.getByTestId(firstTestId)).toBeSelected(); + + fireEvent.press(screen.getByTestId(secondTestId)); + + await waitFor(() => expect(screen.getByTestId(secondTestId)).toBeSelected()); + await waitFor(() => expect(screen.getByTestId(firstTestId)).not.toBeSelected()); + }); +}); diff --git a/packages/mobile/src/tabs/index.ts b/packages/mobile/src/tabs/index.ts index 4d05fca873..b0aaf3ad1d 100644 --- a/packages/mobile/src/tabs/index.ts +++ b/packages/mobile/src/tabs/index.ts @@ -5,3 +5,5 @@ export * from './TabIndicator'; export * from './TabLabel'; export * from './TabNavigation'; export * from './Tabs'; +export * from './TabsScrollArea'; +export * from './TabsScrollAreaOverflowIndicator'; diff --git a/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx b/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx index 57cf18fc33..cc7942cbd9 100644 --- a/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx +++ b/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx @@ -1,35 +1,21 @@ -import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; import type { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; -import { css } from '@linaria/core'; import type { ChipProps, ChipSize } from '../../chips/ChipProps'; import { MediaChip, type MediaChipBaseProps } from '../../chips/MediaChip'; -import { cx } from '../../cx'; import { useComponentConfig } from '../../hooks/useComponentConfig'; -import { useHorizontalScrollToTarget } from '../../hooks/useHorizontalScrollToTarget'; -import { HStack, type HStackDefaultElement, type HStackProps } from '../../layout/HStack'; -import { Paddle } from '../../tabs/Paddle'; +import type { HStackDefaultElement, HStackProps } from '../../layout/HStack'; import { Tabs, type TabsActiveIndicatorComponent, type TabsBaseProps, type TabsProps, } from '../../tabs/Tabs'; - -const containerCss = css` - isolation: isolate; -`; - -const scrollContainerCss = css` - &::-webkit-scrollbar { - display: none; - } - scrollbar-width: none; -`; +import { TabsScrollArea } from '../../tabs/TabsScrollArea'; const DefaultTabComponent = ({ label = '', @@ -108,19 +94,20 @@ export type TabbedChipsBaseProps = Omit< TabsActiveIndicatorComponent?: TabsProps['TabsActiveIndicatorComponent']; tabs: TabbedChipProps[]; /** - * Turn on to use a compact Chip component for each tab. + * Turn on to use a compact `MediaChip` for each tab. * @default false * @deprecated Use `size="xs"` instead. This will be removed in a future major release. * @deprecationExpectedRemoval v10 */ compact?: boolean; /** - * Set the size of each tab chip. + * Set the size of each tab chip. Also sizes the overflow indicator buttons rendered by + * {@link TabsScrollArea}. * @default s */ size?: ChipSize; /** - * X position offset when auto-scrolling to active tab (to avoid active tab being covered by the paddle on the left side, default: 50px) + * X position offset when auto-scrolling to active tab (to avoid active tab being covered by the overflow indicator on the left side, default: 50px) * @default 50 */ autoScrollOffset?: number; @@ -138,28 +125,47 @@ export type TabbedChipsProps = TabbedChipsBasePro */ gap?: HStackProps['gap']; /** - * The width of the scroll container, defaults to 100% of the parent container - * If the tabs are wider than the width of the container, paddles will be shown to scroll the tabs. + * Width of the scroll region; defaults to the full width of the parent. When the tab row is wider + * than this container, overflow indicators appear. * @default 100% */ width?: HStackProps['width']; styles?: { /** Root container element */ root?: React.CSSProperties; - /** Scroll container element */ + /** Horizontal scroll region wrapping the tab row (aligned with {@link TabsScrollArea}). */ scrollContainer?: React.CSSProperties; - /** Paddle icon buttons */ + /** + * @deprecated Use `overflowIndicatorButton` (or other `overflowIndicator*` style slots). + * @deprecationExpectedRemoval v10 + */ paddle?: React.CSSProperties; /** Tabs root element */ tabs?: React.CSSProperties; + /** Overflow indicator root */ + overflowIndicator?: React.CSSProperties; + /** Overflow indicator icon button. */ + overflowIndicatorButton?: React.CSSProperties; + /** Overflow indicator icon button container. */ + overflowIndicatorButtonContainer?: React.CSSProperties; + /** Overflow indicator gradient. */ + overflowIndicatorGradient?: React.CSSProperties; }; classNames?: { /** Root container element */ root?: string; - /** Scroll container element */ + /** Horizontal scroll region wrapping the tab row */ scrollContainer?: string; /** Tabs root element */ tabs?: string; + /** Overflow control outer wrapper (each side). */ + overflowIndicator?: string; + /** Overflow indicator icon button. */ + overflowIndicatorButton?: string; + /** Overflow indicator icon button container. */ + overflowIndicatorButtonContainer?: string; + /** Overflow indicator gradient. */ + overflowIndicatorGradient?: string; }; }; @@ -195,19 +201,6 @@ const TabbedChipsComponent = memo( } = mergedProps; // Size is driven by `size`; deprecated `compact` falls back to its legacy `xs` size. const resolvedSize: ChipSize = size ?? (compact ? 'xs' : 's'); - const [scrollTarget, setScrollTarget] = useState(null); - const { scrollRef, isScrollContentOffscreenLeft, isScrollContentOffscreenRight, handleScroll } = - useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset }); - - const handleScrollLeft = useCallback(() => { - scrollRef?.current?.scrollTo({ left: 0, behavior: 'smooth' }); - }, [scrollRef]); - - const handleScrollRight = useCallback(() => { - if (!scrollRef.current) return; - const maxScroll = scrollRef.current.scrollWidth - scrollRef.current.clientWidth; - scrollRef.current.scrollTo({ left: maxScroll, behavior: 'smooth' }); - }, [scrollRef]); const TabComponentWithSize = useCallback( (props: TabValue) => { @@ -216,60 +209,63 @@ const TabbedChipsComponent = memo( [TabComponent, resolvedSize], ); + const tabsScrollAreaStyles = useMemo( + () => ({ + root: styles?.root, + scrollContainer: styles?.scrollContainer, + overflowIndicator: styles?.overflowIndicator, + overflowIndicatorButton: { + ...styles?.paddle, + ...styles?.overflowIndicatorButton, + }, + overflowIndicatorButtonContainer: styles?.overflowIndicatorButtonContainer, + overflowIndicatorGradient: styles?.overflowIndicatorGradient, + }), + [styles], + ); + + const tabsScrollAreaClassNames = useMemo( + () => ({ + root: classNames?.root, + scrollContainer: classNames?.scrollContainer, + overflowIndicator: classNames?.overflowIndicator, + overflowIndicatorButton: classNames?.overflowIndicatorButton, + overflowIndicatorButtonContainer: classNames?.overflowIndicatorButtonContainer, + overflowIndicatorGradient: classNames?.overflowIndicatorGradient, + }), + [classNames], + ); + return ( - - - + {({ onActiveTabElementChange }) => ( - - - + )} + ); }), ); diff --git a/packages/web/src/alpha/tabbed-chips/__stories__/TabbedChips.stories.tsx b/packages/web/src/alpha/tabbed-chips/__stories__/TabbedChips.stories.tsx index 995659bb2c..976cef8c7e 100644 --- a/packages/web/src/alpha/tabbed-chips/__stories__/TabbedChips.stories.tsx +++ b/packages/web/src/alpha/tabbed-chips/__stories__/TabbedChips.stories.tsx @@ -97,13 +97,13 @@ export const Default = () => { - With paddles + With overflow (many tabs) - With custom sized paddles + With custom sized overflow controls - + With long text diff --git a/packages/web/src/core/componentConfig.ts b/packages/web/src/core/componentConfig.ts index c7fd1f8b52..b20d05676d 100644 --- a/packages/web/src/core/componentConfig.ts +++ b/packages/web/src/core/componentConfig.ts @@ -81,6 +81,7 @@ import type { TableRowBaseProps } from '../tables/TableRow'; import type { SegmentedTabBaseProps } from '../tabs/SegmentedTab'; import type { SegmentedTabsBaseProps } from '../tabs/SegmentedTabs'; import type { TabsBaseProps } from '../tabs/Tabs'; +import type { TabsScrollAreaBaseProps } from '../tabs/TabsScrollArea'; import type { TagBaseProps } from '../tag/Tag'; import type { TourBaseProps } from '../tour/Tour'; import type { LinkBaseProps } from '../typography/Link'; @@ -186,6 +187,7 @@ export type ComponentConfig = { TableCellFallback?: ConfigResolver; TableRow?: ConfigResolver; Tabs?: ConfigResolver; + TabsScrollArea?: ConfigResolver; Tag?: ConfigResolver; Text?: ConfigResolver; TextFallback?: ConfigResolver; diff --git a/packages/web/src/tabs/TabsScrollArea.tsx b/packages/web/src/tabs/TabsScrollArea.tsx new file mode 100644 index 0000000000..6f0457d58e --- /dev/null +++ b/packages/web/src/tabs/TabsScrollArea.tsx @@ -0,0 +1,231 @@ +import React, { memo, useCallback, useMemo, useState } from 'react'; +import type { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; +import { css } from '@linaria/core'; + +import type { IconButtonSize } from '../buttons/IconButton'; +import { cx } from '../cx'; +import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useHorizontalScrollToTarget } from '../hooks/useHorizontalScrollToTarget'; +import type { BoxBaseProps } from '../layout/Box'; +import { HStack } from '../layout/HStack'; +import type { StylesAndClassNames } from '../types'; + +import { + TabsScrollAreaOverflowIndicator, + type TabsScrollAreaOverflowIndicatorProps, +} from './TabsScrollAreaOverflowIndicator'; + +/** + * Values passed to `TabsScrollArea`'s function child. Pass `onActiveTabElementChange` to `Tabs` as + * `onActiveTabElementChange` so the scroll area can scroll the active tab into view. + */ +export type TabsScrollAreaRenderProps = { + /** + * Pass to `Tabs` as `onActiveTabElementChange={onActiveTabElementChange}`. + */ + onActiveTabElementChange: (element: HTMLElement | null) => void; +}; + +/** + * Static class names for TabsScrollArea component parts. + * Use these selectors to target specific elements with CSS. + */ +export const tabsScrollAreaClassNames = { + /** Root layout element */ + root: 'cds-TabsScrollArea', + /** Horizontal scroll region wrapping `Tabs` */ + scrollContainer: 'cds-TabsScrollArea-scrollContainer', + /** Applied to each overflow indicator's root */ + overflowIndicator: 'cds-TabsScrollArea-overflowIndicator', + /** Applied to each overflow indicator's icon button */ + overflowIndicatorButton: 'cds-TabsScrollArea-overflowIndicatorButton', + /** Applied to each overflow indicator's icon button container */ + overflowIndicatorButtonContainer: 'cds-TabsScrollArea-overflowIndicatorButtonContainer', + /** Applied to each overflow indicator's gradient */ + overflowIndicatorGradient: 'cds-TabsScrollArea-overflowIndicatorGradient', +} as const; + +export type TabsScrollAreaBaseProps = Omit & + Pick & { + previousArrowAccessibilityLabel?: string; + nextArrowAccessibilityLabel?: string; + /** + * Horizontal offset when auto-scrolling to the active tab, so the active tab does not end up + * underneath an overflow indicator. + * @default 50 + */ + autoScrollOffset?: number; + /** + * Size of the overflow indicator buttons, so they can track the size of the content they scroll. + * @default s + */ + size?: IconButtonSize; + /** + * Painted on the root and passed to the OverflowIndicatorComponent, so overflow affordances + * always blend into the surface behind the tab row. + * @default bg + */ + background?: BoxBaseProps['background']; + /** + * Component rendered at each end when content overflows (left / right). Defaults to + * {@link TabsScrollAreaOverflowIndicator}. Props must extend {@link TabsScrollAreaOverflowIndicatorProps}. + */ + OverflowIndicatorComponent?: React.FC; + }; + +export type TabsScrollAreaProps = TabsScrollAreaBaseProps & + StylesAndClassNames & { + /** + * Render function that receives `onActiveTabElementChange` (wire to `Tabs` as `onActiveTabElementChange`). + */ + children: (props: TabsScrollAreaRenderProps) => React.ReactNode; + /** Merged with the root `HStack`. */ + style?: React.CSSProperties; + /** Merged with the root `HStack`. */ + className?: string; + }; + +const containerCss = css` + isolation: isolate; +`; + +const scrollContainerCss = css` + &::-webkit-scrollbar { + display: none; + } + scrollbar-width: none; +`; + +export const TabsScrollArea = memo(function TabsScrollArea(_props: TabsScrollAreaProps) { + const mergedProps = useComponentConfig('TabsScrollArea', _props); + const { + children, + position = 'relative', + testID, + width = '100%', + background = 'bg', + previousArrowAccessibilityLabel = 'Previous', + nextArrowAccessibilityLabel = 'Next', + autoScrollOffset = 50, + size, + OverflowIndicatorComponent = TabsScrollAreaOverflowIndicator, + style, + styles, + className, + classNames, + ...props + } = mergedProps; + + const [scrollTarget, setScrollTarget] = useState(null); + const { scrollRef, isScrollContentOffscreenLeft, isScrollContentOffscreenRight, handleScroll } = + useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset }); + + const handleScrollLeft = useCallback(() => { + scrollRef.current?.scrollTo({ left: 0, behavior: 'smooth' }); + }, [scrollRef]); + + const handleScrollRight = useCallback(() => { + if (!scrollRef.current) return; + const maxScroll = scrollRef.current.scrollWidth - scrollRef.current.clientWidth; + scrollRef.current.scrollTo({ left: maxScroll, behavior: 'smooth' }); + }, [scrollRef]); + + const renderedChildren = useMemo(() => { + if (typeof children === 'function') { + return children({ onActiveTabElementChange: setScrollTarget }); + } + return children ?? null; + }, [children]); + + const rootStyle = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]); + + const overflowIndicatorClassNames = useMemo( + () => ({ + root: cx(tabsScrollAreaClassNames.overflowIndicator, classNames?.overflowIndicator), + button: cx( + tabsScrollAreaClassNames.overflowIndicatorButton, + classNames?.overflowIndicatorButton, + ), + buttonContainer: cx( + tabsScrollAreaClassNames.overflowIndicatorButtonContainer, + classNames?.overflowIndicatorButtonContainer, + ), + gradient: cx( + tabsScrollAreaClassNames.overflowIndicatorGradient, + classNames?.overflowIndicatorGradient, + ), + }), + [ + classNames?.overflowIndicator, + classNames?.overflowIndicatorButton, + classNames?.overflowIndicatorButtonContainer, + classNames?.overflowIndicatorGradient, + ], + ); + + const overflowIndicatorStyles = useMemo( + () => ({ + root: styles?.overflowIndicator, + button: styles?.overflowIndicatorButton, + buttonContainer: styles?.overflowIndicatorButtonContainer, + gradient: styles?.overflowIndicatorGradient, + }), + [ + styles?.overflowIndicator, + styles?.overflowIndicatorButton, + styles?.overflowIndicatorButtonContainer, + styles?.overflowIndicatorGradient, + ], + ); + + return ( + + + + {renderedChildren} + + + + ); +}); + +TabsScrollArea.displayName = 'TabsScrollArea'; diff --git a/packages/web/src/tabs/TabsScrollAreaOverflowIndicator.tsx b/packages/web/src/tabs/TabsScrollAreaOverflowIndicator.tsx new file mode 100644 index 0000000000..9d2f91a787 --- /dev/null +++ b/packages/web/src/tabs/TabsScrollAreaOverflowIndicator.tsx @@ -0,0 +1,210 @@ +import React, { memo, useMemo } from 'react'; +import { + animateGradientScaleConfig, + animatePaddleOpacityConfig, + animatePaddleScaleConfig, + paddleHidden, + paddleVisible, +} from '@coinbase/cds-common/animation/paddle'; +import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; +import type { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; +import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; +import { css } from '@linaria/core'; +import { m as motion } from 'framer-motion'; + +import { NewAnimatePresence } from '../animation/NewAnimatePresence'; +import { IconButton, type IconButtonSize } from '../buttons/IconButton'; +import { cx } from '../cx'; +import { Box, type BoxBaseProps } from '../layout/Box'; +import { useMotionProps } from '../motion/useMotionProps'; + +import { paddleWidth } from './Paddle'; + +const MotionBox = motion(Box); + +export type TabsScrollAreaOverflowIndicatorBaseProps = SharedProps & + SharedAccessibilityProps & { + direction?: 'left' | 'right'; + show: boolean; + /** + * Size of the indicator's `IconButton`, so it can track the size of the content it scrolls. + * @default s + */ + size?: IconButtonSize; + /** + * The background behind the scroll area, so the indicator can blend into that surface. Each + * implementation decides how to apply it; the default indicator fades its gradient into it. + * @default bg + */ + background?: BoxBaseProps['background']; + onClick: () => void; + }; + +export type TabsScrollAreaOverflowIndicatorProps = TabsScrollAreaOverflowIndicatorBaseProps & { + style?: React.CSSProperties; + className?: string; + classNames?: { + root?: string; + button?: string; + buttonContainer?: string; + gradient?: string; + }; + styles?: { + root?: React.CSSProperties; + button?: React.CSSProperties; + buttonContainer?: React.CSSProperties; + gradient?: React.CSSProperties; + }; +}; + +const tabLabelOffset = '7px'; + +const gradientCss = css` + display: block; + position: absolute; + pointer-events: none; + z-index: ${zIndex.interactable}; + top: 0; + width: calc(${paddleWidth}px + var(--space-2)); + height: 100%; +`; + +const gradientLeftCss = css` + background: linear-gradient(to right, currentColor 50%, var(--color-transparent) 100%); + left: 0px; + transform-origin: left; +`; + +const gradientRightCss = css` + background: linear-gradient(to left, currentColor 50%, var(--color-transparent) 100%); + right: 0px; + transform-origin: right; +`; + +const containerCss = css` + display: block; + position: absolute; + z-index: ${zIndex.navigation + 1}; + padding-top: calc(var(--space-2) - ${tabLabelOffset}); + padding-bottom: calc(var(--space-2) - ${tabLabelOffset}); +`; + +const buttonCss = css` + display: block; + position: relative; + z-index: ${zIndex.navigation}; +`; + +const paddleLeftCss = css` + left: calc(var(--space-2) * -1); + padding-inline-start: var(--space-2); + padding-inline-end: var(--space-2); +`; + +const paddleRightCss = css` + right: calc(var(--space-2) * -1); + padding-inline-start: var(--space-2); + padding-inline-end: var(--space-2); +`; + +/** + * Default scroll overflow control for {@link TabsScrollArea}: the same visuals as {@link Paddle}, + * with a secondary icon button and no `variant` prop on this API. + */ +export const TabsScrollAreaOverflowIndicator = memo(function TabsScrollAreaOverflowIndicator({ + direction = 'left', + show, + onClick, + testID = `cds-paddle--${direction}`, + accessibilityLabel, + styles, + classNames, + style, + className, + size = 's', + background = 'bg', +}: TabsScrollAreaOverflowIndicatorProps) { + const rootStyle = useMemo( + () => ({ + ...styles?.root, + ...style, + }), + [style, styles?.root], + ); + + /** Opacity on the motion root so {@link NewAnimatePresence} can run exit on the direct child. */ + const containerPresenceMotionProps = useMotionProps({ + enterConfigs: [{ ...animatePaddleOpacityConfig, toValue: paddleVisible }], + exitConfigs: [{ ...animatePaddleOpacityConfig, toValue: paddleHidden }], + exit: 'exit', + }); + + const buttonScaleMotionProps = useMotionProps({ + enterConfigs: [{ ...animatePaddleScaleConfig, toValue: paddleVisible }], + exitConfigs: [{ ...animatePaddleScaleConfig, toValue: paddleHidden }], + exit: 'exit', + }); + + const gradientMotionProps = useMotionProps({ + enterConfigs: [{ ...animateGradientScaleConfig, toValue: paddleVisible }], + exitConfigs: [{ ...animateGradientScaleConfig, toValue: paddleHidden }], + exit: 'exit', + }); + + return ( + + {show && ( + + {/* TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility */} + )} + > + + + {/* TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility */} + )} + /> + + )} + + ); +}); + +TabsScrollAreaOverflowIndicator.displayName = 'TabsScrollAreaOverflowIndicator'; diff --git a/packages/web/src/tabs/__stories__/TabsScrollArea.stories.tsx b/packages/web/src/tabs/__stories__/TabsScrollArea.stories.tsx new file mode 100644 index 0000000000..21e911f4d5 --- /dev/null +++ b/packages/web/src/tabs/__stories__/TabsScrollArea.stories.tsx @@ -0,0 +1,192 @@ +import { type FC, memo, useCallback, useState } from 'react'; +import { sampleTabs } from '@coinbase/cds-common/internal/data/tabs'; +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; +import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; +import { css } from '@linaria/core'; + +import { IconButton } from '../../buttons/IconButton'; +import { cx } from '../../cx'; +import { Box, VStack } from '../../layout'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultTheme } from '../../themes/defaultTheme'; +import { Text } from '../../typography/Text'; +import { DefaultTab } from '../DefaultTab'; +import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator'; +import { Tabs } from '../Tabs'; +import { TabsScrollArea } from '../TabsScrollArea'; +import type { TabsScrollAreaOverflowIndicatorProps } from '../TabsScrollAreaOverflowIndicator'; + +const customOverflowIndicatorRootCss = css` + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: ${zIndex.navigation + 2}; +`; + +const StoryCustomOverflowIndicator = memo(function StoryCustomOverflowIndicator({ + direction, + show, + onClick, + style, + className, +}: TabsScrollAreaOverflowIndicatorProps) { + if (!show) { + return null; + } + + return ( + + + + ); +}); + +StoryCustomOverflowIndicator.displayName = 'StoryCustomOverflowIndicator'; + +export default { + title: 'Components/Tabs/TabsScrollArea', + parameters: { + a11y: { + context: { + include: ['body'], + exclude: ['.no-a11y-checks'], + }, + }, + }, +}; + +const basicTabs: (TabValue & { testID?: string })[] = [ + { id: 'buy', label: 'Buy', testID: 'buy-tab' }, + { id: 'sell', label: 'Sell', testID: 'sell-tab' }, + { id: 'convert', label: 'Convert', testID: 'convert-tab' }, +]; + +const longTabs = sampleTabs.slice(0, 9); + +const tabsTabListOnlyA11y = { + a11y: { + context: { + include: ['body'], + exclude: ['.no-a11y-checks'], + }, + options: { + rules: { + 'aria-valid-attr-value': { enabled: false }, + 'duplicate-id': { enabled: false }, + 'duplicate-id-active': { enabled: false }, + }, + }, + }, +}; + +type TabsScrollAreaExampleProps = { + title: string; + description?: string; + maxWidth: number | string; + tabs: TabValue[]; + OverflowIndicatorComponent?: FC; +}; + +const TabsScrollAreaExample = ({ + title, + description = 'Narrow the Storybook viewport or use the constrained width below so the tab row overflows and the side paddles appear.', + maxWidth, + tabs, + OverflowIndicatorComponent, +}: TabsScrollAreaExampleProps) => { + const [activeTab, setActiveTab] = useState | null>(tabs[0]); + const handleChange = useCallback((next: TabValue | null) => setActiveTab(next), []); + + return ( + + + {title} + + + {description} + + + {({ onActiveTabElementChange: onActiveTab }) => ( + + )} + + + ); +}; + +export const Default = () => ( + +); + +export const ManyTabs = () => ( + +); + +export const FitsWithoutOverflow = () => ( + +); + +export const LightAndDark = () => ( + + + + + + + + +); + +export const CustomOverflowIndicator = () => ( + +); + +Default.parameters = tabsTabListOnlyA11y; +ManyTabs.parameters = tabsTabListOnlyA11y; +FitsWithoutOverflow.parameters = tabsTabListOnlyA11y; +LightAndDark.parameters = tabsTabListOnlyA11y; +CustomOverflowIndicator.parameters = tabsTabListOnlyA11y; diff --git a/packages/web/src/tabs/__tests__/TabsScrollArea.test.tsx b/packages/web/src/tabs/__tests__/TabsScrollArea.test.tsx new file mode 100644 index 0000000000..6455d80ad1 --- /dev/null +++ b/packages/web/src/tabs/__tests__/TabsScrollArea.test.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react'; +import useMeasure from 'react-use-measure'; +import { useRefMap } from '@coinbase/cds-common/hooks/useRefMap'; +import { sampleTabs } from '@coinbase/cds-common/internal/data/tabs'; +import { render, screen } from '@testing-library/react'; + +import { DefaultThemeProvider } from '../../utils/test'; +import { Tabs } from '../Tabs'; +import { TabsScrollArea } from '../TabsScrollArea'; + +jest.mock('react-use-measure'); +jest.mock('@coinbase/cds-common/hooks/useRefMap'); + +const NoopFn = () => {}; + +const mockUseMeasure = (mocks: Partial>) => { + (useMeasure as jest.Mock).mockReturnValue(mocks); +}; + +const mockUseRefMap = (mocks: ReturnType) => { + (useRefMap as jest.Mock).mockReturnValue(mocks); +}; + +const mockDimensions: Partial> = [ + jest.fn(), + { + width: 400, + x: 0, + y: 0, + height: 40, + top: 0, + right: 0, + left: 0, + bottom: 0, + }, +]; + +const refMap: ReturnType = { + refs: { current: {} }, + registerRef: NoopFn, + getRef: jest.fn(() => ({ + getBoundingClientRect: jest.fn(() => ({ + x: 0, + y: 0, + width: 80, + height: 40, + })), + offsetLeft: 0, + offsetTop: 0, + offsetWidth: 80, + offsetHeight: 40, + offsetParent: {}, + })), +}; + +const tabs = sampleTabs.slice(0, 3); + +const MockTabsScrollArea = () => { + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + + {({ onActiveTabElementChange: onActiveTab }) => ( + { + if (tab) setActiveTab(tab); + }} + tabs={tabs} + /> + )} + + ); +}; + +describe('TabsScrollArea', () => { + const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })); + + beforeAll(() => { + global.ResizeObserver = mockResizeObserver; + Element.prototype.scrollTo = jest.fn(); + }); + + beforeEach(() => { + mockUseMeasure(mockDimensions); + mockUseRefMap(refMap); + }); + + it('renders the scroll area and tabs', () => { + render( + + + , + ); + + expect(screen.getByTestId('tabs-scroll-area')).toBeVisible(); + expect(screen.getByText('Tab one')).toBeVisible(); + }); + + it('renders non-function children without throwing', () => { + render( + + + {/* @ts-expect-error Intentionally invalid: `children` must be a render function */} + invalid + + , + ); + + expect(screen.getByText('invalid')).toBeVisible(); + }); + + it('forwards accessibilityLabel to the root', () => { + render( + + + {({ onActiveTabElementChange: onActiveTab }) => ( + + )} + + , + ); + + expect(screen.getByLabelText('Scrollable tab list')).toBeVisible(); + }); +}); diff --git a/packages/web/src/tabs/index.ts b/packages/web/src/tabs/index.ts index 31b5f23c91..aee60fe931 100644 --- a/packages/web/src/tabs/index.ts +++ b/packages/web/src/tabs/index.ts @@ -7,3 +7,5 @@ export * from './TabIndicator'; export * from './TabLabel'; export * from './TabNavigation'; export * from './Tabs'; +export * from './TabsScrollArea'; +export * from './TabsScrollAreaOverflowIndicator';