diff --git a/apps/docs/docgen.config.js b/apps/docs/docgen.config.js index e8f8879fef..ff1312da22 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 5b58593e98..353bd54e30 100644 --- a/apps/docs/docs/components/navigation/TabbedChipsAlpha/_webExamples.mdx +++ b/apps/docs/docs/components/navigation/TabbedChipsAlpha/_webExamples.mdx @@ -15,7 +15,7 @@ function ExampleDefault() { ### Compact -```jsx lived +```jsx live function ExampleCompactNoStart() { const tabs = [ { id: 'all', label: 'All' }, @@ -48,7 +48,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 @@ -78,14 +78,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}`, @@ -96,7 +96,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 71db0dade9..a02126e679 100644 --- a/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx +++ b/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx @@ -200,6 +200,42 @@ 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 3e288890bd..cffe532559 100644 --- a/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx +++ b/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx @@ -206,6 +206,42 @@ 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 c0db513f49..9ba62fcaff 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 8764d60d32..998405ef7d 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -603,6 +603,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/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index 9aaf028390..ab340ac97b 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -720,6 +720,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 e87d31e63c..afcda922e9 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, { forwardRef, memo, useCallback, useMemo, useState } from 'react'; -import { ScrollView, type StyleProp, type View, type ViewStyle } from 'react-native'; +import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import type { StyleProp, View, ViewStyle } from 'react-native'; import type { SharedAccessibilityProps, SharedProps, ThemeVars } from '@coinbase/cds-common'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; @@ -7,9 +7,8 @@ import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; import type { ChipProps } from '../../chips/ChipProps'; import { MediaChip } from '../../chips/MediaChip'; import { useComponentConfig } from '../../hooks/useComponentConfig'; -import { useHorizontalScrollToTarget } from '../../hooks/useHorizontalScrollToTarget'; -import { Box, type BoxProps, OverflowGradient } from '../../layout'; -import { Tabs, type TabsBaseProps, type TabsProps } from '../../tabs'; +import { type BoxProps } from '../../layout'; +import { Tabs, type TabsBaseProps, type TabsProps, TabsScrollArea } from '../../tabs'; const DefaultTabComponent = ({ label = '', @@ -31,7 +30,7 @@ const DefaultTabComponent = ({ ); }; -const TabsActiveIndicatorComponent = () => { +const DefaultTabsActiveIndicatorComponent = () => { return null; }; @@ -76,12 +75,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; }; @@ -102,6 +105,7 @@ const TabbedChipsComponent = memo( activeTab = tabs[0], testID = 'tabbed-chips', TabComponent = DefaultTabComponent, + TabsActiveIndicatorComponent = DefaultTabsActiveIndicatorComponent, onChange, width, gap = 1, @@ -110,16 +114,6 @@ const TabbedChipsComponent = memo( autoScrollOffset = 30, ...accessibilityProps } = mergedProps; - const [scrollTarget, setScrollTarget] = useState(null); - const { - scrollRef, - isScrollContentOverflowing, - isScrollContentOffscreenLeft, - isScrollContentOffscreenRight, - handleScroll, - handleScrollContainerLayout, - handleScrollContentSizeChange, - } = useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset }); const TabComponentWithCompact = useCallback( (props: TabValue) => { @@ -128,42 +122,37 @@ const TabbedChipsComponent = memo( [TabComponent, compact], ); + const tabsScrollAreaStyles = useMemo( + () => ({ + root: styles?.root, + scrollContainer: styles?.scrollContainer, + overflowIndicator: styles?.overflowIndicator, + }), + [styles], + ); + return ( - - + {(props) => ( - - {isScrollContentOverflowing && isScrollContentOffscreenLeft && ( - - )} - {isScrollContentOverflowing && isScrollContentOffscreenRight && ( - )} - + ); }), ); diff --git a/packages/mobile/src/core/componentConfig.ts b/packages/mobile/src/core/componentConfig.ts index 1b3abba7d9..bf70922248 100644 --- a/packages/mobile/src/core/componentConfig.ts +++ b/packages/mobile/src/core/componentConfig.ts @@ -67,6 +67,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'; @@ -157,6 +158,7 @@ export type ComponentConfig = { Stepper?: ConfigResolver; Switch?: ConfigResolver>; Tabs?: ConfigResolver; + TabsScrollArea?: ConfigResolver; Tag?: ConfigResolver; TextInput?: ConfigResolver; Toast?: ConfigResolver; diff --git a/packages/mobile/src/layout/OverflowGradient.tsx b/packages/mobile/src/layout/OverflowGradient.tsx index a3290f52f4..ccade75695 100644 --- a/packages/mobile/src/layout/OverflowGradient.tsx +++ b/packages/mobile/src/layout/OverflowGradient.tsx @@ -1,6 +1,5 @@ import React, { memo, useMemo } from 'react'; -import { StyleSheet } from 'react-native'; -import type { ViewStyle } from 'react-native'; +import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native'; import type { PinningDirection, SharedProps } from '@coinbase/cds-common'; import { LinearGradient } from '../gradients/LinearGradient'; @@ -9,7 +8,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..c368c1dc8c --- /dev/null +++ b/packages/mobile/src/tabs/TabsScrollArea.tsx @@ -0,0 +1,130 @@ +import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import { ScrollView, type StyleProp, type View, type ViewStyle } from 'react-native'; +import type { SharedAccessibilityProps } from '@coinbase/cds-common'; + +import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useHorizontalScrollToTarget } from '../hooks/useHorizontalScrollToTarget'; +import { type BoxBaseProps, HStack } from '../layout'; + +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 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; + styles?: { + /** Root layout element */ + root?: StyleProp; + /** Horizontal `ScrollView` wrapping `Tabs` */ + scrollContainer?: StyleProp; + /** + * applied to overflow indicators. + */ + overflowIndicator?: StyleProp; + }; +}; + +const TabsScrollAreaWithRef = forwardRef( + function TabsScrollArea(_props, ref) { + 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 renderedChildren = useMemo(() => { + if (typeof children === 'function') { + return children({ onActiveTabElementChange: setScrollTarget }); + } + console.warn( + 'TabsScrollArea expects a function child `({ onActiveTabElementChange }) => `.', + ); + return null; + }, [children]); + + const leftShow = isScrollContentOverflowing && isScrollContentOffscreenLeft; + const rightShow = isScrollContentOverflowing && isScrollContentOffscreenRight; + + return ( + + + {renderedChildren} + + + + + ); + }, +); + +export const TabsScrollArea = memo(TabsScrollAreaWithRef); + +TabsScrollAreaWithRef.displayName = 'TabsScrollArea'; diff --git a/packages/mobile/src/tabs/TabsScrollAreaOverflowIndicator.tsx b/packages/mobile/src/tabs/TabsScrollAreaOverflowIndicator.tsx new file mode 100644 index 0000000000..62478c9219 --- /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'; + +import { OverflowGradient } from '../layout'; + +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..566c8069b9 --- /dev/null +++ b/packages/mobile/src/tabs/__tests__/TabsScrollArea.test.tsx @@ -0,0 +1,74 @@ +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)).toHaveAccessibilityState({ selected: true }); + + fireEvent.press(screen.getByTestId(secondTestId)); + + await waitFor(() => + expect(screen.getByTestId(secondTestId)).toHaveAccessibilityState({ selected: true }), + ); + await waitFor(() => + expect(screen.getByTestId(firstTestId)).toHaveAccessibilityState({ selected: false }), + ); + }); +}); 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 4bb4a67b41..a3f5503206 100644 --- a/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx +++ b/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx @@ -1,34 +1,20 @@ -import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import type { SharedAccessibilityProps, SharedProps, ThemeVars } from '@coinbase/cds-common'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; -import { css } from '@linaria/core'; import type { ChipProps } from '../../chips/ChipProps'; import { MediaChip } 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'; +import { type HStackDefaultElement, type HStackProps } from '../../layout'; import { - Paddle, Tabs, type TabsActiveIndicatorComponent, type TabsBaseProps, type TabsProps, + TabsScrollArea, } from '../../tabs'; -const containerCss = css` - isolation: isolate; -`; - -const scrollContainerCss = css` - &::-webkit-scrollbar { - display: none; - } - scrollbar-width: none; -`; - const DefaultTabComponent = ({ label = '', id, @@ -91,12 +77,13 @@ 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. On web, this is also passed to + * {@link TabsScrollArea} as `compact` so the overflow chevron `IconButton`s use compact sizing. * @default false */ compact?: boolean; /** - * 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; @@ -114,28 +101,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; }; }; @@ -168,19 +174,6 @@ const TabbedChipsComponent = memo( autoScrollOffset = 50, ...accessibilityProps } = 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 TabComponentWithCompact = useCallback( (props: TabValue) => { @@ -189,58 +182,62 @@ const TabbedChipsComponent = memo( [TabComponent, compact], ); + 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 ( - - - + {(props) => ( - - - + )} + ); }), ); 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 729ecc5575..a4453ae41c 100644 --- a/packages/web/src/alpha/tabbed-chips/__stories__/TabbedChips.stories.tsx +++ b/packages/web/src/alpha/tabbed-chips/__stories__/TabbedChips.stories.tsx @@ -84,13 +84,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 3780807a3f..84c459304f 100644 --- a/packages/web/src/core/componentConfig.ts +++ b/packages/web/src/core/componentConfig.ts @@ -80,6 +80,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'; @@ -181,6 +182,7 @@ export type ComponentConfig = { TableCellFallback?: ConfigResolver; TableRow?: ConfigResolver; Tabs?: ConfigResolver; + TabsScrollArea?: ConfigResolver; Tag?: ConfigResolver; TextInput?: ConfigResolver; Tile?: ConfigResolver; diff --git a/packages/web/src/tabs/TabsScrollArea.tsx b/packages/web/src/tabs/TabsScrollArea.tsx new file mode 100644 index 0000000000..7c446be56e --- /dev/null +++ b/packages/web/src/tabs/TabsScrollArea.tsx @@ -0,0 +1,222 @@ +import React, { memo, useCallback, useMemo, useState } from 'react'; +import type { SharedAccessibilityProps } from '@coinbase/cds-common'; +import { css } from '@linaria/core'; + +import { cx } from '../cx'; +import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useHorizontalScrollToTarget } from '../hooks/useHorizontalScrollToTarget'; +import { HStack } from '../layout'; +import type { BoxBaseProps } from '../layout/Box'; +import type { StylesAndClassNames } from '../types'; + +import { + TabsScrollAreaOverflowIndicator, + type TabsScrollAreaOverflowIndicatorProps, +} from './TabsScrollAreaOverflowIndicator'; + +/** + * Values passed to `TabsScrollArea`'s function child. Pass `onActiveTab` to `Tabs` as + * `onActiveTabElementChange` so the scroll area can scroll the active tab into view. + */ +export type TabsScrollAreaRenderProps = { + /** + * Pass to `Tabs` as `onActiveTabElementChange={onActiveTab}`. + */ + 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 (e.g. so the active tab is not under a paddle). + * @default 50 + */ + autoScrollOffset?: number; + /** + * Passed to the {@link TabsScrollAreaOverflowIndicator} to render compact sub-components (`IconButton` `compact`). + */ + compact?: boolean; + /** + * 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%', + previousArrowAccessibilityLabel = 'Previous', + nextArrowAccessibilityLabel = 'Next', + autoScrollOffset = 50, + compact, + OverflowIndicatorComponent = TabsScrollAreaOverflowIndicator, + style, + styles, + className, + classNames, + ...props + } = mergedProps; + + if (typeof children !== 'function') { + throw new Error('TabsScrollArea expects a function child `(props) => `.'); + } + + 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 }); + } + console.warn( + 'TabsScrollArea expects a function child `({ onActiveTabElementChange }) => `.', + ); + return null; + }, [children]); + + 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..5756e5fd96 --- /dev/null +++ b/packages/web/src/tabs/TabsScrollAreaOverflowIndicator.tsx @@ -0,0 +1,201 @@ +import React, { memo, useMemo } from 'react'; +import { + animateGradientScaleConfig, + animatePaddleOpacityConfig, + animatePaddleScaleConfig, + paddleHidden, + paddleVisible, +} from '@coinbase/cds-common/animation/paddle'; +import { durations } from '@coinbase/cds-common/motion/tokens'; +import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; +import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; +import { css } from '@linaria/core'; +import { m as motion } from 'framer-motion'; + +import { NewAnimatePresence } from '../animation/NewAnimatePresence'; +import { IconButton } from '../buttons/IconButton'; +import { cx } from '../cx'; +import { Box } 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; + compact?: boolean; + 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); +`; + +const tabsScrollAreaOverflowBackground = 'bg' as const; + +/** + * Default scroll overflow control for {@link TabsScrollArea}: same visuals as {@link Paddle} with + * fixed background token and secondary icon (no `background` / `variant` props on this API). + */ +export const TabsScrollAreaOverflowIndicator = memo(function TabsScrollAreaOverflowIndicator({ + direction = 'left', + show, + onClick, + testID = `cds-paddle--${direction}`, + accessibilityLabel, + styles, + classNames, + style, + className, + compact, +}: TabsScrollAreaOverflowIndicatorProps) { + const buttonStyle = useMemo( + () => ({ + ...styles?.button, + }), + [styles?.button], + ); + + 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 && ( + + + + + + + )} + + ); +}); + +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..c98979d9d1 --- /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('throws when children is not a function', () => { + expect(() => + render( + + + {/* @ts-expect-error Intentionally invalid: `children` must be a render function */} + invalid + + , + ), + ).toThrow('TabsScrollArea expects a function child'); + }); + + 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';