diff --git a/.github/workflows/debug-workflow.yml b/.github/workflows/debug-workflow.yml index b11f07e652..93154a991c 100644 --- a/.github/workflows/debug-workflow.yml +++ b/.github/workflows/debug-workflow.yml @@ -20,34 +20,11 @@ on: # Manual trigger for testing workflow_dispatch: -concurrency: - group: Visreg-Mobile-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -permissions: - contents: read - actions: read - pull-requests: write - -env: - CI: true - jobs: - ios: - name: Visreg iOS - runs-on: macos-latest - environment: production - outputs: - percy_url: ${{ steps.percy-upload.outputs.percy_url }} + test-local: + runs-on: [small, default-config] steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 - with: - egress-policy: audit - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 1 - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - uses: actions/checkout@v4 - uses: ./.github/actions/setup diff --git a/apps/docs/docs/components/navigation/TabIndicator/mobileMetadata.json b/apps/docs/docs/components/navigation/TabIndicator/mobileMetadata.json index 2ed5f0b36d..248d5d185e 100644 --- a/apps/docs/docs/components/navigation/TabIndicator/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabIndicator/mobileMetadata.json @@ -2,6 +2,7 @@ "import": "import { TabIndicator } from '@coinbase/cds-mobile/tabs/TabIndicator'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/tabs/TabIndicator.tsx", "description": "A visual indicator that shows the active tab position.", + "warning": "This component is deprecated along with the TabNavigation component. Please use the Tabs component and DefaultTabsActiveIndicator instead.", "relatedComponents": [ { "label": "TabNavigation", diff --git a/apps/docs/docs/components/navigation/TabIndicator/webMetadata.json b/apps/docs/docs/components/navigation/TabIndicator/webMetadata.json index 26caadaa9e..023854ed94 100644 --- a/apps/docs/docs/components/navigation/TabIndicator/webMetadata.json +++ b/apps/docs/docs/components/navigation/TabIndicator/webMetadata.json @@ -2,6 +2,7 @@ "import": "import { TabIndicator } from '@coinbase/cds-web/tabs/TabIndicator'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/tabs/TabIndicator.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-tabs-tabindicator--default", + "warning": "This component is deprecated along with the TabNavigation component. Please use the Tabs component and DefaultTabsActiveIndicator instead.", "description": "A visual indicator that shows the active tab position.", "relatedComponents": [ { diff --git a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json index 749f4f45f4..a19864c6e1 100644 --- a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json @@ -2,6 +2,7 @@ "import": "import { TabLabel } from '@coinbase/cds-mobile/tabs/TabLabel'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/tabs/TabLabel.tsx", "description": "A text label component used within tab navigation.", + "warning": "This component is deprecated along with the TabNavigation component. Please use the Tabs component and DefaultTab instead.", "relatedComponents": [ { "label": "TabNavigation", diff --git a/apps/docs/docs/components/navigation/TabLabel/webMetadata.json b/apps/docs/docs/components/navigation/TabLabel/webMetadata.json index 0cfca5a265..2020c5bd4c 100644 --- a/apps/docs/docs/components/navigation/TabLabel/webMetadata.json +++ b/apps/docs/docs/components/navigation/TabLabel/webMetadata.json @@ -3,6 +3,7 @@ "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/tabs/TabLabel.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-tabs-tablabel--default", "description": "A text label component used within tab navigation.", + "warning": "This component is deprecated along with the TabNavigation component. Please use the Tabs component and DefaultTab instead.", "relatedComponents": [ { "label": "TabNavigation", diff --git a/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx b/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx index 83d8764979..71db0dade9 100644 --- a/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx +++ b/apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx @@ -1,10 +1,8 @@ -Tabs is a low-level primitive for building custom tab interfaces. It requires a `TabComponent` and `TabsActiveIndicatorComponent` to render. For a ready-to-use tab experience, see [SegmentedTabs](/components/navigation/SegmentedTabs). +Tabs manages which tab is active and positions the animated indicator. For the common **underline** pattern, pass **`TabsActiveIndicatorComponent={DefaultTabsActiveIndicator}`** and rely on the default **`TabComponent` (`DefaultTab`)**. Use a custom **`TabComponent`** when you need layout or content beyond what `DefaultTab` provides. For **pill / segmented** controls, use [SegmentedTabs](/components/navigation/SegmentedTabs/) instead. ## Basics -### Initial Value - -Use `useTabsContext` inside your `TabComponent` to access the active tab state. Pair with [TabLabel](/components/navigation/TabLabel) for consistent label styling and [TabsActiveIndicator](/components/navigation/TabIndicator) for the animated indicator. +Out of the box, **`Tabs`** uses **`DefaultTab`** for each row (headline text, optional [DotCount](/components/other/DotCount/) via `count` / `max` on each tab) and **`DefaultTabsActiveIndicator`** for the animated underline. **`activeBackground`** sets the **underline color** (it is forwarded to the indicator as its `background` token). ```jsx function Example() { @@ -13,44 +11,28 @@ function Example() { { id: 'tab2', label: 'Tab 2' }, { id: 'tab3', label: 'Tab 3' }, ]; - - const TabComponent = useCallback(({ id, label, disabled }) => { - const { activeTab, updateActiveTab } = useTabsContext(); - const isActive = activeTab?.id === id; - return ( - updateActiveTab(id)} - disabled={disabled} - accessibilityRole="tab" - accessibilityState={{ selected: isActive, disabled }} - > - - {label} - - - ); - }, []); - - const ActiveIndicator = useCallback( - (props) => , - [], - ); - const [activeTab, setActiveTab] = useState(tabs[0]); return ( ); } ``` -Tabs can also start with no active selection by passing `null`. +You can omit `TabComponent` explicitly: **`Tabs`** defaults it to **`DefaultTab`**. + +### No initial selection ```jsx function Example() { @@ -59,38 +41,39 @@ function Example() { { id: 'tab2', label: 'Tab 2' }, { id: 'tab3', label: 'Tab 3' }, ]; - - const TabComponent = useCallback(({ id, label, disabled }) => { - const { activeTab, updateActiveTab } = useTabsContext(); - const isActive = activeTab?.id === id; - return ( - updateActiveTab(id)} - disabled={disabled} - accessibilityRole="tab" - accessibilityState={{ selected: isActive, disabled }} - > - - {label} - - - ); - }, []); - - const ActiveIndicator = useCallback( - (props) => , - [], - ); - const [activeTab, setActiveTab] = useState(null); return ( + ); +} +``` + +### Dot counts + +```jsx +function Example() { + const tabs = [ + { id: 'inbox', label: 'Inbox', count: 3, max: 99 }, + { id: 'sent', label: 'Sent' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + ); } @@ -98,60 +81,37 @@ function Example() { ### Disabled -The entire component can be disabled with the `disabled` prop. - ```jsx function Example() { const tabs = [ { id: 'tab1', label: 'Tab 1' }, - { id: 'tab2', label: 'Tab 2' }, + { id: 'tab2', label: 'Tab 2', disabled: true }, { id: 'tab3', label: 'Tab 3' }, ]; - - const TabComponent = useCallback(({ id, label, disabled }) => { - const { activeTab, updateActiveTab } = useTabsContext(); - const isActive = activeTab?.id === id; - return ( - updateActiveTab(id)} - disabled={disabled} - accessibilityRole="tab" - accessibilityState={{ selected: isActive, disabled }} - > - - {label} - - - ); - }, []); - - const ActiveIndicator = useCallback( - (props) => , - [], - ); - const [activeTab, setActiveTab] = useState(tabs[0]); return ( ); } ``` -Individual tabs can also be disabled while keeping others interactive. +## Custom `TabComponent` + +Use **`useTabsContext`** with your own **`Pressable`** and **`Text`** for labels (and a custom **`TabsActiveIndicatorComponent`** if needed) when you need more control than `DefaultTab`. ```jsx function Example() { const tabs = [ { id: 'tab1', label: 'Tab 1' }, - { id: 'tab2', label: 'Tab 2', disabled: true }, + { id: 'tab2', label: 'Tab 2' }, { id: 'tab3', label: 'Tab 3' }, ]; @@ -165,9 +125,9 @@ function Example() { accessibilityRole="tab" accessibilityState={{ selected: isActive, disabled }} > - + {label} - + ); }, []); @@ -191,11 +151,7 @@ function Example() { } ``` -## Custom Components - -### Tab - -Pass additional data through the tab definitions and access it in your `TabComponent` to render custom content like icons. +### Custom label content ```jsx function Example() { @@ -217,9 +173,9 @@ function Example() { > - + {label} - + ); @@ -243,3 +199,7 @@ function Example() { ); } ``` + +## 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 239731e356..3e288890bd 100644 --- a/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx +++ b/apps/docs/docs/components/navigation/Tabs/_webExamples.mdx @@ -1,10 +1,8 @@ -Tabs is a low-level primitive for building custom tab interfaces. It requires a `TabComponent` and `TabsActiveIndicatorComponent` to render. For a ready-to-use tab experience, see [SegmentedTabs](/components/navigation/SegmentedTabs). +Tabs manages which tab is active and positions the animated indicator. For the common **underline** pattern, pass **`TabsActiveIndicatorComponent={DefaultTabsActiveIndicator}`** and rely on the default **`TabComponent` (`DefaultTab`)**. Use a custom **`TabComponent`** when you need layout or content beyond what `DefaultTab` provides. For **pill / segmented** controls, use [SegmentedTabs](/components/navigation/SegmentedTabs) instead. ## Basics -### Initial Value - -Use `useTabsContext` inside your `TabComponent` to access the active tab state. Pair with [TabLabel](/components/navigation/TabLabel) for consistent label styling and [TabsActiveIndicator](/components/navigation/TabIndicator) for the animated indicator. +Out of the box, **`Tabs`** uses **`DefaultTab`** for each row (headline text, optional [DotCount](/components/other/DotCount/) via `count` / `max` on each tab) and **`DefaultTabsActiveIndicator`** for the animated underline. **`activeBackground`** sets the **underline color** (it is forwarded to the indicator as its `background` token). ```jsx live function Example() { @@ -13,44 +11,28 @@ function Example() { { id: 'tab2', label: 'Tab 2' }, { id: 'tab3', label: 'Tab 3' }, ]; - - const TabComponent = useCallback(({ id, label, disabled, ...props }) => { - const { activeTab, updateActiveTab } = useTabsContext(); - const isActive = activeTab?.id === id; - return ( - updateActiveTab(id)} - disabled={disabled} - aria-pressed={isActive} - {...props} - > - - {label} - - - ); - }, []); - - const ActiveIndicator = useCallback( - (props) => , - [], - ); - const [activeTab, setActiveTab] = useState(tabs[0]); return ( ); } ``` -Tabs can also start with no active selection by passing `null`. +You can omit `TabComponent` explicitly: **`Tabs`** defaults it to **`DefaultTab`**. + +### No initial selection ```jsx live function Example() { @@ -59,38 +41,41 @@ function Example() { { id: 'tab2', label: 'Tab 2' }, { id: 'tab3', label: 'Tab 3' }, ]; - - const TabComponent = useCallback(({ id, label, disabled, ...props }) => { - const { activeTab, updateActiveTab } = useTabsContext(); - const isActive = activeTab?.id === id; - return ( - updateActiveTab(id)} - disabled={disabled} - aria-pressed={isActive} - {...props} - > - - {label} - - - ); - }, []); - - const ActiveIndicator = useCallback( - (props) => , - [], - ); - const [activeTab, setActiveTab] = useState(null); return ( + ); +} +``` + +### Dot counts + +Optional **`count`** and **`max`** on each tab are forwarded to the badge next to the label (see [DotCount](/components/other/DotCount/)). + +```jsx live +function Example() { + const tabs = [ + { id: 'inbox', label: 'Inbox', count: 3, max: 99 }, + { id: 'sent', label: 'Sent' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + ); } @@ -98,60 +83,39 @@ function Example() { ### Disabled -The entire component can be disabled with the `disabled` prop. +Disable the whole row with **`disabled`**, or set **`disabled: true`** on individual tab items. ```jsx live function Example() { const tabs = [ { id: 'tab1', label: 'Tab 1' }, - { id: 'tab2', label: 'Tab 2' }, + { id: 'tab2', label: 'Tab 2', disabled: true }, { id: 'tab3', label: 'Tab 3' }, ]; - - const TabComponent = useCallback(({ id, label, disabled, ...props }) => { - const { activeTab, updateActiveTab } = useTabsContext(); - const isActive = activeTab?.id === id; - return ( - updateActiveTab(id)} - disabled={disabled} - aria-pressed={isActive} - {...props} - > - - {label} - - - ); - }, []); - - const ActiveIndicator = useCallback( - (props) => , - [], - ); - const [activeTab, setActiveTab] = useState(tabs[0]); return ( ); } ``` -Individual tabs can also be disabled while keeping others interactive. +## Custom `TabComponent` + +Use **`useTabsContext`** inside your own tab button. ```jsx live function Example() { const tabs = [ { id: 'tab1', label: 'Tab 1' }, - { id: 'tab2', label: 'Tab 2', disabled: true }, + { id: 'tab2', label: 'Tab 2' }, { id: 'tab3', label: 'Tab 3' }, ]; @@ -165,9 +129,9 @@ function Example() { aria-pressed={isActive} {...props} > - + {label} - + ); }, []); @@ -191,11 +155,9 @@ function Example() { } ``` -## Custom Components +### Custom label content -### Tab - -Pass additional data through the tab definitions and access it in your `TabComponent` to render custom content like icons. +Pass extra fields on each tab and read them in your `TabComponent` (for example icons). ```jsx live function Example() { @@ -217,9 +179,9 @@ function Example() { > - + {label} - + ); @@ -243,3 +205,7 @@ function Example() { ); } ``` + +## 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/_webStyles.mdx b/apps/docs/docs/components/navigation/Tabs/_webStyles.mdx index ee3c6a6678..45c03dac67 100644 --- a/apps/docs/docs/components/navigation/Tabs/_webStyles.mdx +++ b/apps/docs/docs/components/navigation/Tabs/_webStyles.mdx @@ -1,10 +1,7 @@ -import { useState, useCallback } from 'react'; -import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; +import { useState } from 'react'; import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; -import { Tabs, TabsActiveIndicator } from '@coinbase/cds-web/tabs/Tabs'; -import { Pressable } from '@coinbase/cds-web/system/Pressable'; -import { TabLabel } from '@coinbase/cds-web/tabs/TabLabel'; +import { DefaultTabsActiveIndicator, Tabs } from '@coinbase/cds-web/tabs'; import webStylesData from ':docgen/web/tabs/Tabs/styles-data'; @@ -14,36 +11,17 @@ export const TabsExample = ({ classNames }) => { { id: 'tab2', label: 'Tab 2' }, { id: 'tab3', label: 'Tab 3' }, ]; - const TabComponent = useCallback(({ id, label, disabled, ...props }) => { - const api = useTabsContext(); - const isActive = api.activeTab?.id === id; - return ( - api.updateActiveTab(id)} - disabled={disabled} - aria-pressed={isActive} - {...props} - > - - {label} - - - ); - }, []); - const CustomIndicator = useCallback( - (props) => , - [], - ); const [activeTab, setActiveTab] = useState(tabs[0]); return ( ); }; diff --git a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json index 07c03025ec..c8fc565aa3 100644 --- a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json @@ -1,16 +1,20 @@ { - "import": "import { Tabs } from '@coinbase/cds-mobile/tabs/Tabs'", + "import": "import { Tabs, DefaultTabsActiveIndicator } from '@coinbase/cds-mobile/tabs'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/tabs/Tabs.tsx", - "description": "Tabs is a flexible, accessible tab navigation component for React Native, supporting animated indicators, custom tab components, and full accessibility.", + "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": "TabNavigation", - "url": "/components/navigation/TabNavigation/" - }, { "label": "SegmentedTabs", "url": "/components/navigation/SegmentedTabs/" + }, + { + "label": "TabIndicator", + "url": "/components/navigation/TabIndicator/" + }, + { + "label": "TabLabel", + "url": "/components/navigation/TabLabel/" } ], "dependencies": [ diff --git a/apps/docs/docs/components/navigation/Tabs/webMetadata.json b/apps/docs/docs/components/navigation/Tabs/webMetadata.json index cefe3817ee..f5a729f2a7 100644 --- a/apps/docs/docs/components/navigation/Tabs/webMetadata.json +++ b/apps/docs/docs/components/navigation/Tabs/webMetadata.json @@ -1,15 +1,20 @@ { - "import": "import { Tabs } from '@coinbase/cds-web/tabs/Tabs'", + "import": "import { Tabs, DefaultTabsActiveIndicator } from '@coinbase/cds-web/tabs'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/tabs/Tabs.tsx", - "description": "Tabs is a flexible, accessible tab navigation component for switching between content sections. It supports custom tab components, animated active indicators, and full keyboard navigation.", + "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": "TabNavigation", - "url": "/components/navigation/TabNavigation/" - }, { "label": "SegmentedTabs", "url": "/components/navigation/SegmentedTabs" + }, + { + "label": "TabIndicator", + "url": "/components/navigation/TabIndicator/" + }, + { + "label": "TabLabel", + "url": "/components/navigation/TabLabel/" } ], "dependencies": [ diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 65fc89fe03..fe69471111 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,16 @@ All notable changes to this project will be documented in this file. +## 8.64.0 (4/2/2026 PST) + +#### 🚀 Updates + +- UseTabs: Added an optional second generic TTab extends TabValue so tabs, activeTab, and onChange can be typed with custom tab row shapes (defaults preserve the old behavior). [[#558](https://github.com/coinbase/cds/pull/558)] + +## 8.63.0 ((4/1/2026, 03:43 PM PST)) + +This is an artificial version bump with no new change. + ## 8.62.1 (4/1/2026 PST) #### 🐞 Fixes diff --git a/packages/common/package.json b/packages/common/package.json index 9c0b17fca4..49a44fc828 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.62.1", + "version": "8.64.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/common/src/tabs/TabsContext.ts b/packages/common/src/tabs/TabsContext.ts index 766c51b4a0..ae6e0e261a 100644 --- a/packages/common/src/tabs/TabsContext.ts +++ b/packages/common/src/tabs/TabsContext.ts @@ -1,13 +1,19 @@ import { createContext, useContext } from 'react'; -import { type TabsApi } from './useTabs'; +import { type TabsApi, type TabValue } from './useTabs'; -export type TabsContextValue = TabsApi; +export type TabsContextValue< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = TabsApi; export const TabsContext = createContext(undefined); -export const useTabsContext = (): TabsContextValue => { - const context = useContext(TabsContext) as TabsContextValue | undefined; +export const useTabsContext = < + TabId extends string, + TTab extends TabValue = TabValue, +>(): TabsContextValue => { + const context = useContext(TabsContext) as TabsContextValue | undefined; if (!context) throw Error('useTabsContext must be used within a TabsContext.Provider'); return context; }; diff --git a/packages/common/src/tabs/useTabs.ts b/packages/common/src/tabs/useTabs.ts index 2b0543c61f..c5f4ce11b4 100644 --- a/packages/common/src/tabs/useTabs.ts +++ b/packages/common/src/tabs/useTabs.ts @@ -9,18 +9,24 @@ export type TabValue = { disabled?: boolean; }; -export type TabsOptions = { +export type TabsOptions< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = { /** The array of tabs data. */ - tabs: TabValue[]; + tabs: TTab[]; /** React state for the currently active tab. Setting it to `null` results in no active tab. */ - activeTab: TabValue | null; + activeTab: TTab | null; /** Callback that is fired when the active tab changes. Use this callback to update the `activeTab` state. */ - onChange: (activeTab: TabValue | null) => void; + onChange: (activeTab: TTab | null) => void; /** Disable interactions on all the tabs. */ disabled?: boolean; }; -export type TabsApi = Omit, 'onChange'> & { +export type TabsApi< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = Omit, 'onChange'> & { /** Update the currently active tab to the tab with `tabId`. */ updateActiveTab: (tabId: TabId | null) => void; /** Update the currently active tab to the next enabled tab in the tabs array. Does nothing if the last tab is already active. */ @@ -30,15 +36,15 @@ export type TabsApi = Omit, 'o }; /** A controlled hook for managing tabs state, such as the currently active tab. */ -export const useTabs = ({ +export const useTabs = = TabValue>({ tabs, activeTab, disabled, onChange, -}: TabsOptions): TabsApi => { +}: TabsOptions): TabsApi => { const updateActiveTab = useCallback( (tabId: TabId | null) => { - let newActiveTab: TabValue | null = null; + let newActiveTab: TTab | null = null; if (typeof tabId === 'string' && tabId !== '') { newActiveTab = tabs.find((tab) => tab.id === tabId) ?? tabs[0]; } diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 946b32a060..26f599eb75 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to this project will be documented in this file. +## 8.64.0 ((4/2/2026, 07:51 AM PST)) + +This is an artificial version bump with no new change. + +## 8.63.0 ((4/1/2026, 03:43 PM PST)) + +This is an artificial version bump with no new change. + ## 8.62.1 ((4/1/2026, 12:25 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 53973741bf..d63fb6ac71 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.62.1", + "version": "8.64.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index eb68ec44c6..a24da1c14e 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,16 @@ All notable changes to this project will be documented in this file. +## 8.64.0 (4/2/2026 PST) + +#### 🚀 Updates + +- Added DefaultTab and DefaultTabActiveIndicator and deprecate types used by TabNavigation. [[#558](https://github.com/coinbase/cds/pull/558)] + +## 8.63.0 ((4/1/2026, 03:43 PM PST)) + +This is an artificial version bump with no new change. + ## 8.62.1 ((4/1/2026, 12:25 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 3e17ed120a..23d44fc6b8 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.62.1", + "version": "8.64.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/tabs/DefaultTab.tsx b/packages/mobile/src/tabs/DefaultTab.tsx new file mode 100644 index 0000000000..82173dc3ca --- /dev/null +++ b/packages/mobile/src/tabs/DefaultTab.tsx @@ -0,0 +1,111 @@ +import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import { + type GestureResponderEvent, + Pressable, + type PressableProps, + type StyleProp, + type View, + type ViewStyle, +} from 'react-native'; +import type { SharedAccessibilityProps } from '@coinbase/cds-common'; +import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; +import { accessibleOpacityDisabled } from '@coinbase/cds-common/tokens/interactable'; + +import { DotCount, type DotCountBaseProps } from '../dots/DotCount'; +import { useTheme } from '../hooks/useTheme'; +import { HStack } from '../layout'; +import { Text } from '../typography/Text'; + +import type { TabComponentProps } from './Tabs'; + +/** Optional dot count and a11y overrides for the default tab row. */ +export type DefaultTabLabelProps = Partial> & + Pick; + +export type DefaultTabProps = Omit< + PressableProps, + 'children' | 'onPress' | 'style' +> & + TabComponentProps & DefaultTabLabelProps> & { + /** Callback that is fired when the tab is pressed, after the active tab updates. */ + onPress?: (id: TabId, event: GestureResponderEvent) => void; + style?: StyleProp; + }; + +type DefaultTabComponent = ( + props: DefaultTabProps & { ref?: React.ForwardedRef }, +) => React.ReactElement; + +const DefaultTabComponent = memo( + forwardRef( + ( + { + id, + label, + disabled: disabledProp, + onPress, + count, + max, + accessibilityLabel, + style, + testID, + ...props + }: DefaultTabProps, + ref: React.ForwardedRef, + ) => { + const theme = useTheme(); + const { + activeTab, + updateActiveTab, + disabled: allTabsDisabled, + } = useTabsContext & DefaultTabLabelProps>(); + const isActive = activeTab?.id === id; + const isDisabled = disabledProp || allTabsDisabled; + + const handlePress = useCallback( + (event: GestureResponderEvent) => { + updateActiveTab(id); + onPress?.(id, event); + }, + [id, onPress, updateActiveTab], + ); + + const labelPaddingStyle = useMemo( + () => ({ + paddingTop: theme.space[2], + paddingBottom: theme.space[2] - 2, + }), + [theme.space], + ); + + return ( + + + + {label} + + {!!count && } + + + ); + }, + ), +); + +DefaultTabComponent.displayName = 'DefaultTab'; + +export const DefaultTab = DefaultTabComponent as DefaultTabComponent; diff --git a/packages/mobile/src/tabs/DefaultTabsActiveIndicator.tsx b/packages/mobile/src/tabs/DefaultTabsActiveIndicator.tsx new file mode 100644 index 0000000000..50cbd3e266 --- /dev/null +++ b/packages/mobile/src/tabs/DefaultTabsActiveIndicator.tsx @@ -0,0 +1,58 @@ +import { memo, useEffect } from 'react'; +import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; + +import { Box } from '../layout'; + +import { type TabsActiveIndicatorProps, tabsSpringConfig } from './Tabs'; + +/** + * Default underline-style indicator for mobile `Tabs`. Pass as + * `TabsActiveIndicatorComponent={DefaultTabsActiveIndicator}` with `TabComponent={DefaultTab}`. + */ +const AnimatedBox = Animated.createAnimatedComponent(Box); + +export const DefaultTabsActiveIndicator = memo( + ({ + activeTabRect, + background = 'bgPrimary', + style, + testID, + ...props + }: TabsActiveIndicatorProps) => { + const { width, x } = activeTabRect; + const rect = useSharedValue({ width, x }); + + useEffect(() => { + if (!width) return; + rect.value = withSpring({ x, width }, tabsSpringConfig); + }, [rect, width, x]); + + const animatedBoxStyle = useAnimatedStyle( + () => ({ + transform: [{ translateX: rect.value.x }], + width: rect.value.width, + }), + [], + ); + + if (!width) return null; + + return ( + + ); + }, +); + +DefaultTabsActiveIndicator.displayName = 'DefaultTabsActiveIndicator'; diff --git a/packages/mobile/src/tabs/TabIndicator.tsx b/packages/mobile/src/tabs/TabIndicator.tsx index c21aa1c0fa..8ea545eec7 100644 --- a/packages/mobile/src/tabs/TabIndicator.tsx +++ b/packages/mobile/src/tabs/TabIndicator.tsx @@ -19,6 +19,8 @@ export type TabIndicatorProps = SharedProps & { background?: ThemeVars.Color; }; +/** @deprecated Use DefaultTabsActiveIndicator instead. This will be removed in a future major release. */ +/** @deprecationExpectedRemoval v10 */ export const TabIndicator = memo( forwardRef( ( diff --git a/packages/mobile/src/tabs/TabLabel.tsx b/packages/mobile/src/tabs/TabLabel.tsx index 7cd566e9bb..3a4f51eb02 100644 --- a/packages/mobile/src/tabs/TabLabel.tsx +++ b/packages/mobile/src/tabs/TabLabel.tsx @@ -34,6 +34,8 @@ export type TabLabelBaseProps = SharedProps & export type TabLabelProps = TabLabelBaseProps & TextProps; +/** @deprecated Use DefaultTab instead. This will be removed in a future major release. */ +/** @deprecationExpectedRemoval v10 */ export const TabLabel = memo( ({ active, variant = 'primary', count = 0, max, ...props }: TabLabelProps) => { const theme = useTheme(); diff --git a/packages/mobile/src/tabs/TabNavigation.tsx b/packages/mobile/src/tabs/TabNavigation.tsx index 0cb14c837a..5c680058cf 100644 --- a/packages/mobile/src/tabs/TabNavigation.tsx +++ b/packages/mobile/src/tabs/TabNavigation.tsx @@ -15,6 +15,10 @@ import { Pressable } from '../system/Pressable'; import { TabIndicator } from './TabIndicator'; import { TabLabel } from './TabLabel'; +/** + * @deprecated Use Tabs instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TabProps = SharedProps & Partial> & { /** The id should be a meaningful and useful identifier like "watchlist" or "forSale" */ @@ -35,6 +39,10 @@ export type TabProps = SharedProps & Component?: (props: CustomTabProps) => React.ReactNode; }; +/** + * @deprecated Use Tabs instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CustomTabProps = Pick & { /** * @default false @@ -45,6 +53,10 @@ export type CustomTabProps = Pick & { label?: React.ReactNode; }; +/** + * @deprecated Use Tabs instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TabNavigationBaseProps = BoxBaseProps & Pick & Pick & { @@ -88,6 +100,10 @@ export type TabNavigationBaseProps = id?: string; }; +/** + * @deprecated Use Tabs instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TabNavigationProps = TabNavigationBaseProps; diff --git a/packages/mobile/src/tabs/Tabs.tsx b/packages/mobile/src/tabs/Tabs.tsx index 54ddedc6e9..9749b4ea72 100644 --- a/packages/mobile/src/tabs/Tabs.tsx +++ b/packages/mobile/src/tabs/Tabs.tsx @@ -22,6 +22,9 @@ import { useComponentConfig } from '../hooks/useComponentConfig'; import type { BoxBaseProps, BoxProps, HStackProps } from '../layout'; import { Box, HStack } from '../layout'; +import { DefaultTab } from './DefaultTab'; +import { DefaultTabsActiveIndicator } from './DefaultTabsActiveIndicator'; + const AnimatedBox = Animated.createAnimatedComponent(Box); type TabContainerProps = { @@ -51,31 +54,44 @@ export type TabsActiveIndicatorProps = { activeTabRect: Rect; } & BoxProps; -export type TabComponentProps = TabValue & { +export type TabComponentProps< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = Omit & { + id: TabId; style?: StyleProp; }; -export type TabComponent = React.FC>; +export type TabComponent< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = React.FC>; export type TabsActiveIndicatorComponent = React.FC; -export type TabsBaseProps = Omit & - Omit, 'tabs'> & { +export type TabsBaseProps< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = Omit & + Omit, 'tabs'> & { /** The array of tabs data. Each tab may optionally define a custom Component to render. */ - tabs: (TabValue & { Component?: TabComponent })[]; + tabs: (TTab & { Component?: TabComponent })[]; /** The default Component to render each tab. */ - TabComponent: TabComponent; + TabComponent?: TabComponent; /** The default Component to render the tabs active indicator. */ - TabsActiveIndicatorComponent: TabsActiveIndicatorComponent; + TabsActiveIndicatorComponent?: TabsActiveIndicatorComponent; /** Background color passed to the TabsActiveIndicatorComponent. */ activeBackground?: ThemeVars.Color; /** Optional callback to receive the active tab element. */ onActiveTabElementChange?: (element: View | null) => void; - /** Custom styles for individual elements of the Tabs component */ }; -export type TabsProps = TabsBaseProps & +export type TabsProps< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = TabsBaseProps & Omit & { + /** Custom styles for individual elements of the Tabs component */ styles?: { /** Root container element */ root?: StyleProp; @@ -86,106 +102,114 @@ export type TabsProps = TabsBaseProps & }; }; -type TabsFC = ( - props: TabsProps & { ref?: React.ForwardedRef }, +type TabsFC = = TabValue>( + props: TabsProps & { ref?: React.ForwardedRef }, ) => React.ReactElement; const TabsComponent = memo( - forwardRef((_props: TabsProps, ref: React.ForwardedRef) => { - const mergedProps = useComponentConfig('Tabs', _props); - const { - tabs, - TabComponent, - TabsActiveIndicatorComponent, - activeBackground, - activeTab, - disabled, - onChange, - styles, - style, - role = 'tablist', - position = 'relative', - alignSelf = 'flex-start', - opacity, - onActiveTabElementChange, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, - borderBottomLeftRadius, - borderBottomRightRadius, - ...props - } = mergedProps; - const tabsContainerRef = useRef(null); - useImperativeHandle(ref, () => tabsContainerRef.current as View, []); // merge internal ref to forwarded ref - - const refMap = useRefMap(); - const api = useTabs({ tabs, activeTab, disabled, onChange }); - - const [activeTabRect, setActiveTabRect] = useState(defaultRect); - const previousActiveRef = useRef(activeTab); - - const updateActiveTabRect = useCallback(() => { - const activeTabRef = activeTab ? refMap.getRef(activeTab.id) : null; - if (!activeTabRef || !tabsContainerRef.current) return; - activeTabRef.measureLayout(tabsContainerRef.current, (x, y, width, height) => - setActiveTabRect({ x, y, width, height }), + forwardRef( + = TabValue>( + _props: TabsProps, + ref: React.ForwardedRef, + ) => { + const mergedProps = useComponentConfig('Tabs', _props); + const { + tabs, + TabComponent = DefaultTab, + TabsActiveIndicatorComponent = DefaultTabsActiveIndicator, + activeBackground, + activeTab, + disabled, + onChange, + styles, + style, + role = 'tablist', + position = 'relative', + alignSelf = 'flex-start', + opacity, + onActiveTabElementChange, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + testID, + ...props + } = mergedProps; + const tabsContainerRef = useRef(null); + useImperativeHandle(ref, () => tabsContainerRef.current as View, []); // merge internal ref to forwarded ref + + const refMap = useRefMap(); + const api = useTabs({ tabs, activeTab, disabled, onChange }); + + const [activeTabRect, setActiveTabRect] = useState(defaultRect); + const previousActiveRef = useRef(activeTab); + + const updateActiveTabRect = useCallback(() => { + const activeTabRef = activeTab ? refMap.getRef(activeTab.id) : null; + if (!activeTabRef || !tabsContainerRef.current) return; + activeTabRef.measureLayout(tabsContainerRef.current, (x, y, width, height) => + setActiveTabRect({ x, y, width, height }), + ); + }, [activeTab, refMap]); + + const registerRef = useCallback( + (tabId: string, ref: View) => { + refMap.registerRef(tabId, ref); + if (activeTab?.id === tabId) { + onActiveTabElementChange?.(ref); + } + }, + [activeTab, onActiveTabElementChange, refMap], ); - }, [activeTab, refMap]); - - const registerRef = useCallback( - (tabId: string, ref: View) => { - refMap.registerRef(tabId, ref); - if (activeTab?.id === tabId) { - onActiveTabElementChange?.(ref); - } - }, - [activeTab, onActiveTabElementChange, refMap], - ); - - if (previousActiveRef.current !== activeTab) { - previousActiveRef.current = activeTab; - updateActiveTabRect(); - } - - return ( - - }> - - {tabs.map(({ id, Component: CustomTabComponent, disabled: tabDisabled, ...props }) => { - const RenderedTab = CustomTabComponent ?? TabComponent; - return ( - - - - ); - })} - - - ); - }), + + if (previousActiveRef.current !== activeTab) { + previousActiveRef.current = activeTab; + updateActiveTabRect(); + } + + return ( + + }> + + {tabs.map(({ id, Component: CustomTabComponent, ...props }) => { + const RenderedTab = CustomTabComponent ?? TabComponent; + return ( + + + + ); + })} + + + ); + }, + ), ); TabsComponent.displayName = 'Tabs'; @@ -196,6 +220,7 @@ export const TabsActiveIndicator = ({ activeTabRect, position = 'absolute', style, + testID = 'tabs-active-indicator', ...props }: TabsActiveIndicatorProps) => { const previousActiveTabRect = useRef(activeTabRect); @@ -227,7 +252,7 @@ export const TabsActiveIndicator = ({ position={position} role="none" style={[animatedBoxStyle, style]} - testID="tabs-active-indicator" + testID={testID} {...props} /> ); diff --git a/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx b/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx index 59925fd5d7..bc3d0fa7a6 100644 --- a/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx +++ b/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx @@ -1,67 +1,187 @@ -import React, { useState } from 'react'; +import { useCallback, useState } from 'react'; +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 { VStack } from '../../layout/VStack'; +import { VStack } from '../../layout'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultTheme } from '../../themes/defaultTheme'; import { Text } from '../../typography/Text'; -import { TabNavigation, type TabNavigationProps, type TabProps } from '../TabNavigation'; - -const tabs: TabProps[] = [ - { - id: 'first_item', - label: 'First item', - onPress: console.warn, - }, - { - id: 'second_item', - label: 'Second item', - }, - { - id: 'third_item', - label: 'Third item', - onPress: console.warn, - }, - { - id: 'fourth_item', - label: 'Fourth item', - }, - { - id: 'fifth_item', - label: 'Fifth item', - }, +import { DefaultTab, type DefaultTabLabelProps } from '../DefaultTab'; +import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator'; +import { + type TabComponent, + Tabs, + TabsActiveIndicator, + type TabsActiveIndicatorComponent, + type TabsActiveIndicatorProps, + type TabsProps, +} from '../Tabs'; + +type TradingAction = 'buy' | 'sell' | 'convert'; + +type TabRowWithTestId = TabValue & { testID?: string }; + +const basicTabs: TabRowWithTestId[] = [ + { id: 'buy', label: 'Buy', testID: 'buy-tab' }, + { id: 'sell', label: 'Sell', testID: 'sell-tab' }, + { id: 'convert', label: 'Convert', testID: 'convert-tab' }, ]; -// TODO update once _Tabs_ component is complete -const TabScreen = () => { - const [activeTabOne, setActiveTabOne] = useState(tabs[0].id); +const longTabs = sampleTabs.slice(0, 9); + +const tabsWithDisabled = [ + { id: 'buy', label: 'Buy' }, + { id: 'sell', label: 'Sell', disabled: true }, + { id: 'convert', label: 'Convert' }, +]; + +const typedTabs: TabValue[] = [ + { id: 'buy', label: 'Buy' }, + { id: 'sell', label: 'Sell' }, + { id: 'convert', label: 'Convert' }, +]; + +type TradingTab = TabValue & DefaultTabLabelProps; +const tabsWithDotCounts: TradingTab[] = basicTabs.map((tab, index) => + index === 0 ? { ...tab, count: 3, max: 99 } : tab, +); + +const CustomSpringIndicator = (props: TabsActiveIndicatorProps) => ( + +); + +type TabsExampleProps = TabValue> = { + title: string; + defaultActiveTab: TTab | null; + TabComponent?: TabComponent; + TabsActiveIndicatorComponent?: TabsActiveIndicatorComponent; +} & Omit< + TabsProps, + 'activeTab' | 'onChange' | 'TabComponent' | 'TabsActiveIndicatorComponent' +>; + +const TabsExample = = TabValue>({ + title, + defaultActiveTab, + TabComponent = DefaultTab, + TabsActiveIndicatorComponent = DefaultTabsActiveIndicator, + ...props +}: TabsExampleProps) => { + const [activeTab, setActiveTab] = useState(defaultActiveTab); + const handleChange = useCallback((next: TTab | null) => setActiveTab(next), []); return ( - - - - - Static preview - - {activeTabOne} - - - - - + + + ); +}; + +const panelTabs = sampleTabs.slice(0, 3); + +const TabsWithPanelsExample = () => { + const [activeTab, setActiveTab] = useState | null>(panelTabs[0]); + + return ( + + + + Pair tab buttons with content regions that follow the active tab (see panel below). + + - - Static preview - - {activeTabOne} - - - - + {panelTabs.map((tab) => + activeTab?.id === tab.id ? ( + + Panel: {tab.label} + Content for this tab. + + ) : null, + )} + + ); }; -export default TabScreen; +const DefaultTabsScreen = () => ( + + + + + + + + + + + + + + + + +); + +export default DefaultTabsScreen; diff --git a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx index fe02cafb01..95557c4658 100644 --- a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx +++ b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx @@ -93,7 +93,7 @@ describe('SegmentedTabs', () => { }); jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, transform: [{ translateX: 0 }, { translateY: 0 }], }); @@ -132,7 +132,7 @@ describe('SegmentedTabs', () => { jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, transform: [{ translateX: 68 }, { translateY: 0 }], }); @@ -210,7 +210,7 @@ describe('SegmentedTabs', () => { jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, transform: [{ translateX: 20 }, { translateY: 0 }], }); @@ -243,7 +243,7 @@ describe('SegmentedTabs', () => { jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, transform: [{ translateX: 0 }, { translateY: 8 }], }); @@ -276,7 +276,7 @@ describe('SegmentedTabs', () => { jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, transform: [{ translateX: 20 }, { translateY: 8 }], }); diff --git a/packages/mobile/src/tabs/index.ts b/packages/mobile/src/tabs/index.ts index 498c1d29a4..4d05fca873 100644 --- a/packages/mobile/src/tabs/index.ts +++ b/packages/mobile/src/tabs/index.ts @@ -1,3 +1,5 @@ +export * from './DefaultTab'; +export * from './DefaultTabsActiveIndicator'; export * from './SegmentedTabs'; export * from './TabIndicator'; export * from './TabLabel'; diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 05114d15c1..3c394c4bf0 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,18 @@ All notable changes to this project will be documented in this file. +## 8.64.0 (4/2/2026 PST) + +#### 🚀 Updates + +- Added DefaultTab and DefaultTabActiveIndicator and deprecate types used by TabNavigation. [[#558](https://github.com/coinbase/cds/pull/558)] + +## 8.63.0 (4/1/2026 PST) + +#### 🚀 Updates + +- Add type focus to Select. [[#571](https://github.com/coinbase/cds/pull/571)] + ## 8.62.1 (4/1/2026 PST) #### 🐞 Fixes diff --git a/packages/web/jest/setup.js b/packages/web/jest/setup.js index b99d21bd76..e36e89068d 100644 --- a/packages/web/jest/setup.js +++ b/packages/web/jest/setup.js @@ -1,3 +1,32 @@ +/* -------------------------------------------------------------------------- */ +/* @floating-ui/react-dom */ +/* -------------------------------------------------------------------------- */ +jest.mock('@floating-ui/react-dom', () => { + const floatingRef = { current: null }; + return { + useFloating: () => ({ + refs: { + setReference: jest.fn(), + setFloating: jest.fn((el) => { + floatingRef.current = el; + }), + reference: { current: null }, + floating: floatingRef, + }, + floatingStyles: {}, + placement: 'bottom', + middlewareData: { arrow: {} }, + }), + // Middleware stubs — these are called as functions and must return a value + arrow: () => ({}), + autoPlacement: () => ({}), + autoUpdate: jest.fn(), + flip: () => ({}), + offset: () => ({}), + shift: () => ({}), + }; +}); + jest.mock('framer-motion', () => ({ ...jest.requireActual('framer-motion'), m: jest.requireActual('framer-motion')?.motion, diff --git a/packages/web/package.json b/packages/web/package.json index 1a1baa4478..8e8d469894 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.62.1", + "version": "8.64.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/packages/web/src/alpha/combobox/__tests__/Combobox.test.tsx b/packages/web/src/alpha/combobox/__tests__/Combobox.test.tsx index a8f474733b..50fa341a13 100644 --- a/packages/web/src/alpha/combobox/__tests__/Combobox.test.tsx +++ b/packages/web/src/alpha/combobox/__tests__/Combobox.test.tsx @@ -23,20 +23,6 @@ const defaultProps: ComboboxProps<'single' | 'multi'> = { label: 'Test Combobox', }; -// Mock floating-ui to simplify testing -jest.mock('@floating-ui/react-dom', () => ({ - useFloating: () => ({ - refs: { - setReference: jest.fn(), - setFloating: jest.fn(), - reference: { current: null }, - floating: { current: null }, - }, - floatingStyles: {}, - }), - flip: () => ({}), -})); - jest.mock('../../../overlays/Portal', () => ({ Portal: ({ children }: { children: React.ReactNode }) => (
{children}
diff --git a/packages/web/src/alpha/select-chip/__tests__/SelectChip.test.tsx b/packages/web/src/alpha/select-chip/__tests__/SelectChip.test.tsx index 335f24a7c1..cf90e43a67 100644 --- a/packages/web/src/alpha/select-chip/__tests__/SelectChip.test.tsx +++ b/packages/web/src/alpha/select-chip/__tests__/SelectChip.test.tsx @@ -8,19 +8,6 @@ import type { SelectOption } from '../../select/types'; import type { SelectChipProps } from '../SelectChip'; import { SelectChip } from '../SelectChip'; -jest.mock('@floating-ui/react-dom', () => ({ - useFloating: () => ({ - refs: { - setReference: jest.fn(), - setFloating: jest.fn(), - reference: { current: null }, - floating: { current: null }, - }, - floatingStyles: {}, - }), - flip: () => ({}), -})); - jest.mock('../../../overlays/Portal', () => ({ Portal: ({ children, containerId }: { children: React.ReactNode; containerId?: string }) => (
{children}
diff --git a/packages/web/src/alpha/select/DefaultSelectControl.tsx b/packages/web/src/alpha/select/DefaultSelectControl.tsx index 173b02a2b9..2171d36439 100644 --- a/packages/web/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/web/src/alpha/select/DefaultSelectControl.tsx @@ -90,6 +90,7 @@ const DefaultSelectControlComponent = memo( accessibilityLabel, ariaHaspopup, tabIndex = 0, + onKeyDown, styles, classNames, ...props @@ -368,6 +369,8 @@ const DefaultSelectControlComponent = memo( focusable={false} minWidth={0} onClick={() => setOpen((s) => !s)} + onKeyDown={onKeyDown} + paddingStart={1} role={role} style={styles?.controlInputNode} tabIndex={tabIndex} @@ -432,6 +435,7 @@ const DefaultSelectControlComponent = memo( styles?.controlStartNode, styles?.controlValueNode, tabIndex, + onKeyDown, startNode, shouldShowCompactLabel, labelNode, diff --git a/packages/web/src/alpha/select/DefaultSelectDropdown.tsx b/packages/web/src/alpha/select/DefaultSelectDropdown.tsx index 538c39f2bd..528b02e566 100644 --- a/packages/web/src/alpha/select/DefaultSelectDropdown.tsx +++ b/packages/web/src/alpha/select/DefaultSelectDropdown.tsx @@ -295,6 +295,7 @@ const DefaultSelectDropdownComponent = memo( > ], }); + const pendingTypeAheadKeyRef = useRef(null); + + const handleControlKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (disabled || open) return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + + const key = event.key; + if (/^[a-z]$/.test(key)) { + pendingTypeAheadKeyRef.current = key; + setOpen(true); + } + }, + [disabled, open, setOpen], + ); + + useEffect(() => { + if (!open || !pendingTypeAheadKeyRef.current) return; + + const key = pendingTypeAheadKeyRef.current; + pendingTypeAheadKeyRef.current = null; + + const floatingEl = refs.floating.current; + if (!floatingEl) return; + + const optionRole = accessibilityRoles?.option ?? 'option'; + const options = floatingEl.querySelectorAll(`[role="${optionRole}"]`); + const matchingOption = Array.from(options).find((option) => { + const firstLetterMatch = option.textContent?.match(/[a-z]/i); + return firstLetterMatch?.[0]?.toLowerCase() === key; + }); + + if (matchingOption) { + (matchingOption as HTMLElement).focus(); + } + }, [open, refs.floating, accessibilityRoles?.option]); + const rootStyles = useMemo( () => ({ ...style, @@ -274,6 +320,7 @@ const SelectBase = memo( labelVariant={labelVariant} maxSelectedOptionsToShow={maxSelectedOptionsToShow} onChange={onChange} + onKeyDown={handleControlKeyDown} open={open} options={options} placeholder={placeholder} diff --git a/packages/web/src/alpha/select/__stories__/MultiSelect.stories.tsx b/packages/web/src/alpha/select/__stories__/MultiSelect.stories.tsx index b313f5761d..0d27b8396b 100644 --- a/packages/web/src/alpha/select/__stories__/MultiSelect.stories.tsx +++ b/packages/web/src/alpha/select/__stories__/MultiSelect.stories.tsx @@ -40,14 +40,14 @@ const hoveredBackgroundCss = css` export const Default = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1', '2'], @@ -69,14 +69,14 @@ export const Default = () => { export const Compact = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1', '2'], @@ -98,10 +98,10 @@ export const Compact = () => { export const InsideLabelVariant = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1', '2', '3', '4'], @@ -123,16 +123,16 @@ export const InsideLabelVariant = () => { export const CompactManySelected = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, - { value: '10', label: 'Option 10' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, + { value: '10', label: 'Kiwi' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1', '3', '7', '8', '9', '10'], @@ -154,14 +154,14 @@ export const CompactManySelected = () => { export const HideSelectAll = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -183,14 +183,14 @@ export const HideSelectAll = () => { export const Alignments = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -260,14 +260,14 @@ export const Alignments = () => { export const CustomSelectAllLabel = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -292,14 +292,14 @@ export const CustomClearAllLabel = () => { }); const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; return ( @@ -318,14 +318,14 @@ export const CustomClearAllLabel = () => { export const CustomSelectAllOption = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -399,14 +399,14 @@ export const LongOptionLabels = () => { export const Disabled = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -437,14 +437,14 @@ Disabled.parameters = { export const DisabledOptions = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', disabled: true }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4', disabled: true }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6', disabled: true }, - { value: '7', label: 'Option 7', disabled: true }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple', disabled: true }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date', disabled: true }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig', disabled: true }, + { value: '7', label: 'Grape', disabled: true }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1', '2', '3', '4', '5', '6', '7', '8'], @@ -465,14 +465,14 @@ export const DisabledOptions = () => { export const CustomAccessory = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -494,14 +494,14 @@ export const CustomAccessory = () => { export const CustomMedia = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -589,14 +589,14 @@ export const CustomHiddenSelectedOptionsLabel = () => { export const Descriptions = () => { const exampleOptionsWithDescription = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', description: 'Description 1' }, - { value: '2', label: 'Option 2', description: 'Description 2' }, - { value: '3', label: 'Option 3', description: 'Description 3' }, - { value: '4', label: 'Option 4', description: 'Description 4' }, - { value: '5', label: 'Option 5', description: 'Description 5' }, - { value: '6', label: 'Option 6', description: 'Description 6' }, - { value: '7', label: 'Option 7', description: 'Description 7' }, - { value: '8', label: 'Option 8', description: 'Description 8' }, + { value: '1', label: 'Apple', description: 'Crisp and sweet' }, + { value: '2', label: 'Banana', description: 'Bright and yellow' }, + { value: '3', label: 'Cherry', description: 'Dark and tart' }, + { value: '4', label: 'Date', description: 'Dense and sweet' }, + { value: '5', label: 'Elderberry', description: 'Earthy and rich' }, + { value: '6', label: 'Fig', description: 'Fresh and jammy' }, + { value: '7', label: 'Grape', description: 'Juicy clusters' }, + { value: '8', label: 'Honeydew', description: 'Honeyed melon' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -617,10 +617,10 @@ export const Descriptions = () => { export const DescriptionsOnly = () => { const exampleOptionsWithOnlyDescription = [ { value: null, label: 'Remove selection' }, - { value: '1', description: 'Description 1' }, - { value: '2', description: 'Description 2' }, - { value: '3', description: 'Description 3' }, - { value: '4', description: 'Description 4' }, + { value: '1', description: 'A crisp red apple' }, + { value: '2', description: 'Bright yellow banana' }, + { value: '3', description: 'Cherry red and tart' }, + { value: '4', description: 'Date palm fruit' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -643,31 +643,31 @@ export const MixedAccessoriesMedia = () => { { value: null, label: 'Remove selection' }, { value: '1', - label: 'Option 1', + label: 'Apple', accessory: , media: , }, { value: '2', - label: 'Option 2', + label: 'Banana', accessory: , media: , }, { value: '3', - label: 'Option 3', + label: 'Cherry', accessory: , media: , }, { value: '4', - label: 'Option 4', + label: 'Date', accessory: , media: , }, { value: '5', - label: 'Option 5', + label: 'Elderberry', accessory: , media: , }, @@ -693,31 +693,31 @@ export const AllCombinedFeatures = () => { { value: null, label: 'Remove selection' }, { value: '1', - label: 'Option 1', + label: 'Apple', accessory: , media: , }, { value: '2', - label: 'Option 2', + label: 'Banana', accessory: , media: , }, { value: '3', - label: 'Option 3', + label: 'Cherry', accessory: , media: , }, { value: '4', - label: 'Option 4', + label: 'Date', accessory: , media: , }, { value: '5', - label: 'Option 5', + label: 'Elderberry', accessory: , media: , }, @@ -769,14 +769,14 @@ export const EdgeCaseEmptyLabels = () => { export const ControlledOpen = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -806,14 +806,14 @@ export const ControlledOpen = () => { export const PositiveVariant = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -836,14 +836,14 @@ export const PositiveVariant = () => { export const NegativeVariant = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -866,14 +866,14 @@ export const NegativeVariant = () => { export const StartNode = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -910,14 +910,14 @@ export const EmptyOptions = () => { export const ComplexStyles = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1', '2'], diff --git a/packages/web/src/alpha/select/__stories__/Select.stories.tsx b/packages/web/src/alpha/select/__stories__/Select.stories.tsx index f11d62b569..9f32e38a3f 100644 --- a/packages/web/src/alpha/select/__stories__/Select.stories.tsx +++ b/packages/web/src/alpha/select/__stories__/Select.stories.tsx @@ -51,15 +51,15 @@ const hoveredBackgroundCss = css` export const Default = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -77,15 +77,15 @@ export const Default = () => { export const Compact = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -104,15 +104,15 @@ export const Compact = () => { export const LabelVariant = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); return ( @@ -130,15 +130,15 @@ export const LabelVariant = () => { export const ExampleForm = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); const { value: multiSelectValue, onChange: multiSelectOnChange } = useMultiSelect({ @@ -204,15 +204,15 @@ export const ExampleForm = () => { export const HelperText = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -231,14 +231,14 @@ export const HelperText = () => { export const Description = () => { const exampleOptionsWithDescription = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', description: 'Description 1' }, - { value: '2', label: 'Option 2', description: 'Description 2' }, - { value: '3', label: 'Option 3', description: 'Description 3' }, - { value: '4', label: 'Option 4', description: 'Description 4' }, - { value: '5', label: 'Option 5', description: 'Description 5' }, - { value: '6', label: 'Option 6', description: 'Description 6' }, - { value: '7', label: 'Option 7', description: 'Description 7' }, - { value: '8', label: 'Option 8', description: 'Description 8' }, + { value: '1', label: 'Apple', description: 'Crisp and sweet' }, + { value: '2', label: 'Banana', description: 'Bright and yellow' }, + { value: '3', label: 'Cherry', description: 'Dark and tart' }, + { value: '4', label: 'Date', description: 'Dense and sweet' }, + { value: '5', label: 'Elderberry', description: 'Earthy and rich' }, + { value: '6', label: 'Fig', description: 'Fresh and jammy' }, + { value: '7', label: 'Grape', description: 'Juicy clusters' }, + { value: '8', label: 'Honeydew', description: 'Honeyed melon' }, ]; const [value, setValue] = useState('1'); @@ -256,10 +256,10 @@ export const Description = () => { export const OnlyDescription = () => { const exampleOptionsWithOnlyDescription = [ { value: null, label: 'Remove selection' }, - { value: '1', description: 'Description 1' }, - { value: '2', description: 'Description 2' }, - { value: '3', description: 'Description 3' }, - { value: '4', description: 'Description 4' }, + { value: '1', description: 'A crisp red apple' }, + { value: '2', description: 'Bright yellow banana' }, + { value: '3', description: 'Cherry red and tart' }, + { value: '4', description: 'Date palm fruit' }, ]; const [value, setValue] = useState('1'); @@ -277,15 +277,15 @@ export const OnlyDescription = () => { export const AccessibilityLabel = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -305,15 +305,15 @@ export const AccessibilityLabel = () => { export const AccessibilityRoles = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -332,9 +332,9 @@ export const AccessibilityRoles = () => { export const Alignments = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, ]; const [value, setValue] = useState('1'); @@ -396,15 +396,15 @@ export const Alignments = () => { export const NoLabel = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -422,14 +422,14 @@ export const NoLabel = () => { export const Disabled = () => { const exampleOptionsWithDescription = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', description: 'Description 1' }, - { value: '2', label: 'Option 2', description: 'Description 2' }, - { value: '3', label: 'Option 3', description: 'Description 3' }, - { value: '4', label: 'Option 4', description: 'Description 4' }, - { value: '5', label: 'Option 5', description: 'Description 5' }, - { value: '6', label: 'Option 6', description: 'Description 6' }, - { value: '7', label: 'Option 7', description: 'Description 7' }, - { value: '8', label: 'Option 8', description: 'Description 8' }, + { value: '1', label: 'Apple', description: 'Crisp and sweet' }, + { value: '2', label: 'Banana', description: 'Bright and yellow' }, + { value: '3', label: 'Cherry', description: 'Dark and tart' }, + { value: '4', label: 'Date', description: 'Dense and sweet' }, + { value: '5', label: 'Elderberry', description: 'Earthy and rich' }, + { value: '6', label: 'Fig', description: 'Fresh and jammy' }, + { value: '7', label: 'Grape', description: 'Juicy clusters' }, + { value: '8', label: 'Honeydew', description: 'Honeyed melon' }, ]; const [value, setValue] = useState('1'); @@ -463,10 +463,10 @@ Disabled.parameters = { export const DisabledOptions = () => { const exampleOptionsWithSomeDisabled = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', disabled: true }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4', disabled: true }, + { value: '1', label: 'Apple', disabled: true }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date', disabled: true }, ]; const [value, setValue] = useState('1'); @@ -483,14 +483,14 @@ export const DisabledOptions = () => { export const WithoutNull = () => { const exampleOptionsWithoutNull = [ - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const [value, setValue] = useState(null); @@ -509,28 +509,28 @@ export const OptionsAsReactNodes = () => { const exampleOptionsWithReactNodes = [ { value: '1', - label: Option 1, - description: Description 1, + label: Apple, + description: Crisp and sweet, }, { value: '2', - label: 'Option 2', + label: 'Banana', description: 'Not a react node', }, { value: '3', - label: Option 3, - description: Description 3, + label: Cherry, + description: Dark and tart, }, { value: '4', - label: 'Option 4', + label: 'Date', description: 'Not a react node', }, { value: '5', - label: Option 5, - description: Description 5, + label: Elderberry, + description: Earthy and rich, }, ]; const [value, setValue] = useState('1'); @@ -561,10 +561,10 @@ export const MixedDefaultAndCustomComponentOptions = () => { }; const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', Component: CustomOptionComponent }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3', Component: CustomOptionComponent }, - { value: '4', label: 'Option 4' }, + { value: '1', label: 'Apple', Component: CustomOptionComponent }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry', Component: CustomOptionComponent }, + { value: '4', label: 'Date' }, ]; const [value, setValue] = useState('1'); @@ -583,15 +583,15 @@ export const MixedDefaultAndCustomComponentOptions = () => { export const StartNode = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -610,15 +610,15 @@ export const StartNode = () => { export const CustomEndNode = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -637,15 +637,15 @@ export const CustomEndNode = () => { export const CustomAccessory = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -664,15 +664,15 @@ export const CustomAccessory = () => { export const CustomMedia = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -692,31 +692,31 @@ export const UniqueAccessoryAndMedia = () => { const exampleOptionsWithCustomAccessoriesAndMedia = [ { value: '1', - label: 'Option 1', + label: 'Apple', accessory: , media: , }, { value: '2', - label: 'Option 2', + label: 'Banana', accessory: , media: , }, { value: '3', - label: 'Option 3', + label: 'Cherry', accessory: , media: , }, { value: '4', - label: 'Option 4', + label: 'Date', accessory: , media: , }, { value: '5', - label: 'Option 5', + label: 'Elderberry', accessory: , media: , }, @@ -737,9 +737,9 @@ export const UniqueAccessoryAndMedia = () => { export const UniqueEndNodeForEachOption = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', end: }, - { value: '2', label: 'Option 2', end: }, - { value: '3', label: 'Option 3', end: }, + { value: '1', label: 'Apple', end: }, + { value: '2', label: 'Banana', end: }, + { value: '3', label: 'Cherry', end: }, ]; const [value, setValue] = useState('1'); @@ -757,15 +757,15 @@ export const UniqueEndNodeForEachOption = () => { export const PositiveVariant = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -785,15 +785,15 @@ export const PositiveVariant = () => { export const NegativeVariant = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -813,15 +813,15 @@ export const NegativeVariant = () => { export const CustomStyles = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -858,15 +858,15 @@ export const CustomStyles = () => { export const CustomClassNames = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -890,15 +890,15 @@ export const Typed = () => { const typedOptions: SelectOption[] = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -916,15 +916,15 @@ export const Typed = () => { export const DefaultOpen = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -952,15 +952,15 @@ DefaultOpen.parameters = { export const DisabledClickOutsideClose = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -979,15 +979,15 @@ export const DisabledClickOutsideClose = () => { export const ControlledOpen = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); const [open, setOpen] = useState(false); @@ -1084,14 +1084,12 @@ export const VeryLongLabels = () => { }, { value: '4', - label: 'A moderately long label that is somewhere between short and extremely long', - description: - 'A moderately long description that is somewhere between short and extremely long', + label: 'Moderately long label that is somewhere between short and extremely long', + description: 'Moderately long description that is somewhere between short and extremely long', }, { value: '5', - description: - 'This is a very long description that is somewhere between short and extremely long', + description: 'Distinctly long description that is somewhere between short and extremely long', }, ]; const [value, setValue] = useState('1'); @@ -1136,11 +1134,11 @@ export const VeryLongLabels = () => { export const MixedOptionsWithAndWithoutDescriptions = () => { const mixedOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', description: 'Has description' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3', description: 'Also has description' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5', description: 'Another description' }, + { value: '1', label: 'Apple', description: 'Has description' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry', description: 'Also has description' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry', description: 'Another description' }, ]; const [value, setValue] = useState('1'); @@ -1159,17 +1157,17 @@ export const OptionsWithOnlyAccessory = () => { const accessoryOnlyOptions = [ { value: '1', - label: 'Option 1', + label: 'Apple', accessory: , }, { value: '2', - label: 'Option 2', + label: 'Banana', accessory: , }, { value: '3', - label: 'Option 3', + label: 'Cherry', accessory: , }, ]; @@ -1190,17 +1188,17 @@ export const OptionsWithOnlyMedia = () => { const mediaOnlyOptions = [ { value: '1', - label: 'Option 1', + label: 'Apple', media: , }, { value: '2', - label: 'Option 2', + label: 'Banana', media: , }, { value: '3', - label: 'Option 3', + label: 'Cherry', media: , }, ]; @@ -1220,15 +1218,15 @@ export const OptionsWithOnlyMedia = () => { export const CompactWithVariants = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [positiveValue, setPositiveValue] = useState('1'); const [negativeValue, setNegativeValue] = useState('2'); @@ -1262,15 +1260,15 @@ export const CompactWithVariants = () => { export const DisabledWithVariants = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [positiveValue, setPositiveValue] = useState('1'); const [negativeValue, setNegativeValue] = useState('2'); @@ -1314,15 +1312,15 @@ DisabledWithVariants.parameters = { export const StartNodeWithVariants = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [positiveValue, setPositiveValue] = useState('1'); const [negativeValue, setNegativeValue] = useState('2'); @@ -1356,15 +1354,15 @@ export const StartNodeWithVariants = () => { export const LongHelperText = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -1383,15 +1381,15 @@ export const LongHelperText = () => { export const CustomLongPlaceholder = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState(null); @@ -1410,31 +1408,31 @@ export const AllCombinedFeatures = () => { const exampleOptionsWithCustomAccessoriesAndMedia = [ { value: '1', - label: 'Option 1', + label: 'Apple', accessory: , media: , }, { value: '2', - label: 'Option 2', + label: 'Banana', accessory: , media: , }, { value: '3', - label: 'Option 3', + label: 'Cherry', accessory: , media: , }, { value: '4', - label: 'Option 4', + label: 'Date', accessory: , media: , }, { value: '5', - label: 'Option 5', + label: 'Elderberry', accessory: , media: , }, @@ -1458,15 +1456,15 @@ export const AllCombinedFeatures = () => { export const ComplexStyleCombinations = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -1601,15 +1599,15 @@ export const StressTestManyOptionsWithDescriptions = () => { export const CustomControlComponent = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -1632,15 +1630,15 @@ export const CustomControlComponent = () => { export const CustomOptionComponent = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -1671,15 +1669,15 @@ export const CustomOptionComponent = () => { export const ValueDisplayed = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -1700,15 +1698,15 @@ export const ValueDisplayed = () => { export const RefImperativeHandle = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); const selectRef = useRef(null); @@ -1742,10 +1740,10 @@ export const RefImperativeHandle = () => { export const Borderless = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, ]; const [singleValue, setSingleValue] = useState('1'); diff --git a/packages/web/src/alpha/select/__tests__/Select.test.tsx b/packages/web/src/alpha/select/__tests__/Select.test.tsx index 1cd8402035..29b82019b6 100644 --- a/packages/web/src/alpha/select/__tests__/Select.test.tsx +++ b/packages/web/src/alpha/select/__tests__/Select.test.tsx @@ -22,19 +22,6 @@ const defaultProps: SelectProps<'single' | 'multi'> = { label: 'Test Select', }; -jest.mock('@floating-ui/react-dom', () => ({ - useFloating: () => ({ - refs: { - setReference: jest.fn(), - setFloating: jest.fn(), - reference: { current: null }, - floating: { current: null }, - }, - floatingStyles: {}, - }), - flip: () => ({}), -})); - jest.mock('../../../overlays/Portal', () => ({ Portal: ({ children, containerId }: { children: React.ReactNode; containerId?: string }) => (
{children}
@@ -592,6 +579,111 @@ describe('Select', () => { expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); }); + + it('opens dropdown when a letter key is pressed while closed', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const button = screen.getByRole('button'); + button.focus(); + await user.keyboard('b'); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + await waitFor(() => { + const options = screen.getAllByRole('option'); + const bananaOption = options.find((opt) => opt.textContent?.includes('Banana')); + expect(bananaOption).toHaveFocus(); + }); + }); + + it('focuses the matching option even when textContent has non-letter prefix characters', async () => { + const user = userEvent.setup(); + const typeAheadOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + ]; + + render( + + + , + ); + + const button = screen.getByRole('button'); + button.focus(); + await user.keyboard('o'); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('does not open dropdown when modifier key + letter is pressed', async () => { + const user = userEvent.setup(); + render( + +