From 36fdae7ebffb1f4b09a681576792b402209578a9 Mon Sep 17 00:00:00 2001 From: Maximo Macchi <232606069+maximo-macchi-cb@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:33:30 -0500 Subject: [PATCH 1/3] feat: [CDS-1575] Add type focus to ` + , + ); + + const button = screen.getByRole('button'); + button.focus(); + await user.keyboard('o'); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + }); + + it('focuses the first matching option when a letter key opens the dropdown', async () => { + const user = userEvent.setup(); + const typeAheadOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, + ]; + + 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('does not open dropdown when letter key is pressed while disabled', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const button = screen.getByRole('button'); + button.focus(); + await user.keyboard('{Control>}a{/Control}'); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); }); describe('Ref Forwarding', () => { From 7e37585aeb62fedd7c24c3c673f1f6b1cc722de8 Mon Sep 17 00:00:00 2001 From: adrienzheng-cb Date: Thu, 2 Apr 2026 12:29:19 -0400 Subject: [PATCH 2/3] feat: add DefaultTab and DefaultTabsActiveIndicator. (#558) * feat: add DefaultTab and DefaultTabsActiveIndicator. 1. Added default sub-components to Tabs to replace TabNavigation. 2. Made indvidual Tab's prop extensible. 3. Deprecated TabIndicator and TabLabel * release --- .../TabIndicator/mobileMetadata.json | 1 + .../navigation/TabIndicator/webMetadata.json | 1 + .../navigation/TabLabel/mobileMetadata.json | 1 + .../navigation/TabLabel/webMetadata.json | 1 + .../navigation/Tabs/_mobileExamples.mdx | 158 +++++------- .../navigation/Tabs/_webExamples.mdx | 160 +++++------- .../components/navigation/Tabs/_webStyles.mdx | 36 +-- .../navigation/Tabs/mobileMetadata.json | 16 +- .../navigation/Tabs/webMetadata.json | 17 +- packages/common/CHANGELOG.md | 6 + packages/common/package.json | 2 +- packages/common/src/tabs/TabsContext.ts | 14 +- packages/common/src/tabs/useTabs.ts | 22 +- packages/mcp-server/CHANGELOG.md | 4 + packages/mcp-server/package.json | 2 +- packages/mobile/CHANGELOG.md | 6 + packages/mobile/package.json | 2 +- packages/mobile/src/tabs/DefaultTab.tsx | 111 ++++++++ .../src/tabs/DefaultTabsActiveIndicator.tsx | 58 +++++ packages/mobile/src/tabs/TabIndicator.tsx | 2 + packages/mobile/src/tabs/TabLabel.tsx | 2 + packages/mobile/src/tabs/TabNavigation.tsx | 16 ++ packages/mobile/src/tabs/Tabs.tsx | 237 ++++++++++-------- .../src/tabs/__stories__/Tabs.stories.tsx | 230 +++++++++++++---- .../src/tabs/__tests__/SegmentedTabs.test.tsx | 10 +- packages/mobile/src/tabs/index.ts | 2 + packages/web/CHANGELOG.md | 6 + packages/web/package.json | 2 +- packages/web/src/tabs/DefaultTab.tsx | 125 +++++++++ .../src/tabs/DefaultTabsActiveIndicator.tsx | 47 ++++ packages/web/src/tabs/TabIndicator.tsx | 2 + packages/web/src/tabs/TabLabel.tsx | 3 + packages/web/src/tabs/TabNavigation.tsx | 16 ++ packages/web/src/tabs/Tabs.tsx | 86 ++++--- .../web/src/tabs/__stories__/Tabs.stories.tsx | 228 +++++++++++++++++ .../src/tabs/__tests__/SegmentedTabs.test.tsx | 10 +- packages/web/src/tabs/index.ts | 2 + 37 files changed, 1187 insertions(+), 457 deletions(-) create mode 100644 packages/mobile/src/tabs/DefaultTab.tsx create mode 100644 packages/mobile/src/tabs/DefaultTabsActiveIndicator.tsx create mode 100644 packages/web/src/tabs/DefaultTab.tsx create mode 100644 packages/web/src/tabs/DefaultTabsActiveIndicator.tsx create mode 100644 packages/web/src/tabs/__stories__/Tabs.stories.tsx 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 322496548a..af4c63704f 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 851ba23c75..c0db513f49 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 47aa6bcc66..fe69471111 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,12 @@ 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. diff --git a/packages/common/package.json b/packages/common/package.json index 987fc4c223..a5100a0fdd 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.63.0", + "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 86fc9e0538..26f599eb75 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ 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. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 7bf248eb69..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.63.0", + "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 d1b3c06ab2..a24da1c14e 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ 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. diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 2c7f91e888..e4340a638e 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.63.0", + "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 73475aa465..923b7fe2c4 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 1dd1a42c6a..2e632e4c49 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 = { @@ -49,31 +52,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; @@ -84,106 +100,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], + ); + + if (previousActiveRef.current !== activeTab) { + previousActiveRef.current = activeTab; + updateActiveTabRect(); + } + + return ( + + }> + + {tabs.map(({ id, Component: CustomTabComponent, ...props }) => { + const RenderedTab = CustomTabComponent ?? TabComponent; + return ( + + + + ); + })} + + ); - }, [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 ( - - - - ); - })} - - - ); - }), + }, + ), ); TabsComponent.displayName = 'Tabs'; @@ -194,6 +218,7 @@ export const TabsActiveIndicator = ({ activeTabRect, position = 'absolute', style, + testID = 'tabs-active-indicator', ...props }: TabsActiveIndicatorProps) => { const previousActiveTabRect = useRef(activeTabRect); @@ -224,7 +249,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 e53dfd049e..5b461814c5 100644 --- a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx +++ b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx @@ -89,7 +89,7 @@ describe('SegmentedTabs', () => { }); jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, height: 40, transform: [{ translateX: 0 }, { translateY: 0 }], @@ -129,7 +129,7 @@ describe('SegmentedTabs', () => { jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, height: 40, transform: [{ translateX: 68 }, { translateY: 0 }], @@ -208,7 +208,7 @@ describe('SegmentedTabs', () => { jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, height: 40, transform: [{ translateX: 20 }, { translateY: 0 }], @@ -242,7 +242,7 @@ describe('SegmentedTabs', () => { jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, height: 40, 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, height: 40, 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 3d6a49b413..3c394c4bf0 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,12 @@ 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 diff --git a/packages/web/package.json b/packages/web/package.json index 3e2fa92e12..c9dd2da71b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.63.0", + "version": "8.64.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/packages/web/src/tabs/DefaultTab.tsx b/packages/web/src/tabs/DefaultTab.tsx new file mode 100644 index 0000000000..0fe0a6c30e --- /dev/null +++ b/packages/web/src/tabs/DefaultTab.tsx @@ -0,0 +1,125 @@ +import React, { forwardRef, memo, useCallback } from 'react'; +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 { css } from '@linaria/core'; + +import { cx } from '../cx'; +import { DotCount, type DotCountBaseProps } from '../dots/DotCount'; +import { HStack } from '../layout'; +import { Pressable, type PressableBaseProps } from '../system/Pressable'; +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; + +const pressableCss = css` + margin: 0; + padding: 0; + white-space: nowrap; +`; + +const insetFocusRingCss = css` + position: relative; + &:focus { + outline: none; + } + &:focus-visible { + outline-style: solid; + outline-width: 2px; + outline-color: var(--color-bgPrimary); + outline-offset: -3px; + border-radius: 4px; + } +`; + +const labelPaddingCss = css` + padding-top: var(--space-2); + padding-bottom: calc(var(--space-2) - 2px); /* Account for the 2px tab indicator */ +`; + +export type DefaultTabProps = Omit & + TabComponentProps & DefaultTabLabelProps> & { + /** Callback that is fired when the tab is pressed, after the active tab updates. */ + onClick?: (id: TabId) => void; + }; + +type DefaultTabComponent = ( + props: DefaultTabProps & { ref?: React.ForwardedRef }, +) => React.ReactElement; + +const DefaultTabComponent = memo( + forwardRef( + ( + { + id, + label, + disabled: disabledProp, + onClick, + count, + max, + accessibilityLabel, + className, + ...props + }: DefaultTabProps, + ref: React.ForwardedRef, + ) => { + const { + activeTab, + updateActiveTab, + disabled: allTabsDisabled, + } = useTabsContext & DefaultTabLabelProps>(); + const isActive = activeTab?.id === id; + const isDisabled = disabledProp || allTabsDisabled; + + const handlePress = useCallback(() => { + updateActiveTab(id); + onClick?.(id); + }, [id, onClick, updateActiveTab]); + + return ( + + + + {label} + + {!!count && ( + + )} + + + ); + }, + ), +); + +DefaultTabComponent.displayName = 'DefaultTab'; + +export const DefaultTab = DefaultTabComponent as DefaultTabComponent; diff --git a/packages/web/src/tabs/DefaultTabsActiveIndicator.tsx b/packages/web/src/tabs/DefaultTabsActiveIndicator.tsx new file mode 100644 index 0000000000..df74a326a9 --- /dev/null +++ b/packages/web/src/tabs/DefaultTabsActiveIndicator.tsx @@ -0,0 +1,47 @@ +import { memo } from 'react'; +import { m as motion } from 'framer-motion'; + +import { Box } from '../layout/Box'; + +import { type TabsActiveIndicatorProps, tabsTransitionConfig } from './Tabs'; + +const MotionBox = motion(Box); + +/** + * Default underline-style indicator for `Tabs`. Pass as + * `TabsActiveIndicatorComponent={DefaultTabIndicator}` with `TabComponent={DefaultTab}`. + */ +export const DefaultTabsActiveIndicator = memo( + ({ + activeTabRect, + background = 'bgPrimary', + className, + style, + testID, + ...props + }: TabsActiveIndicatorProps) => { + const { width, x } = activeTabRect; + + if (!width) return null; + + return ( + + ); + }, +); + +DefaultTabsActiveIndicator.displayName = 'DefaultTabsActiveIndicator'; diff --git a/packages/web/src/tabs/TabIndicator.tsx b/packages/web/src/tabs/TabIndicator.tsx index a02ac66b36..350dd6c7be 100644 --- a/packages/web/src/tabs/TabIndicator.tsx +++ b/packages/web/src/tabs/TabIndicator.tsx @@ -20,6 +20,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/web/src/tabs/TabLabel.tsx b/packages/web/src/tabs/TabLabel.tsx index 97de8c742e..1e68a667a5 100644 --- a/packages/web/src/tabs/TabLabel.tsx +++ b/packages/web/src/tabs/TabLabel.tsx @@ -58,6 +58,9 @@ export type TabLabelBaseProps = SharedProps & export type TabLabelProps = TabLabelBaseProps & TextProps<'h2'> & { onLayout?: (key: string, props: TabIndicatorProps) => void }; +/** @deprecated Use DefaultTab instead. This will be removed in a future major release. */ +/** @deprecationExpectedRemoval v10 */ + export const TabLabel = memo( ({ id = '', diff --git a/packages/web/src/tabs/TabNavigation.tsx b/packages/web/src/tabs/TabNavigation.tsx index e94f463718..fb17752bc8 100644 --- a/packages/web/src/tabs/TabNavigation.tsx +++ b/packages/web/src/tabs/TabNavigation.tsx @@ -75,6 +75,10 @@ const insetFocusRingCss = css` } `; +/** + * @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" */ @@ -95,6 +99,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 @@ -105,6 +113,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 = SharedProps & BoxBaseProps & Pick, 'variant' | 'Component'> & { @@ -146,6 +158,10 @@ export type TabNavigationBaseProps = paddleStyle?: React.CSSProperties; }; +/** + * @deprecated Use Tabs instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TabNavigationProps = TabNavigationBaseProps; diff --git a/packages/web/src/tabs/Tabs.tsx b/packages/web/src/tabs/Tabs.tsx index 027c7cbfef..e3dede0784 100644 --- a/packages/web/src/tabs/Tabs.tsx +++ b/packages/web/src/tabs/Tabs.tsx @@ -19,6 +19,9 @@ import { useComponentConfig } from '../hooks/useComponentConfig'; import { Box, type BoxBaseProps, type BoxDefaultElement, type BoxProps } from '../layout/Box'; import { HStack, type HStackDefaultElement, type HStackProps } from '../layout/HStack'; +import { DefaultTab } from './DefaultTab'; +import { DefaultTabsActiveIndicator } from './DefaultTabsActiveIndicator'; + const MotionBox = motion>(Box); type TabContainerProps = { @@ -48,7 +51,10 @@ export type TabsActiveIndicatorProps = { } & BoxProps & MotionProps; -export type TabComponentProps = TabValue & { +export type TabComponentProps< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = Omit & { /** The tab index for the tab. Automatically set to manage focus behavior. */ tabIndex?: number; /** @@ -58,27 +64,37 @@ export type TabComponentProps = TabValue & role?: string; className?: string; style?: React.CSSProperties; + 'data-rendered-tab'?: boolean; }; -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: HTMLElement | null) => void; }; -export type TabsProps = TabsBaseProps & +export type TabsProps< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = TabsBaseProps & Omit, 'onChange' | 'ref'> & { /** Custom styles for individual elements of the Tabs component */ styles?: { @@ -100,18 +116,21 @@ 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) => { + = TabValue>( + _props: TabsProps, + ref: React.ForwardedRef, + ) => { const mergedProps = useComponentConfig('Tabs', _props); const { tabs, - TabComponent, - TabsActiveIndicatorComponent, + TabComponent = DefaultTab, + TabsActiveIndicatorComponent = DefaultTabsActiveIndicator, activeBackground, activeTab, onActiveTabElementChange, @@ -129,9 +148,10 @@ const TabsComponent = memo( borderBottomLeftRadius, borderBottomRightRadius, style, + testID, ...props } = mergedProps; - const api = useTabs({ tabs, activeTab, disabled, onChange }); + const api = useTabs({ tabs, activeTab, disabled, onChange }); const [tabsContainerRef, tabsContainerRect] = useMeasure({ debounce: 20, @@ -206,10 +226,7 @@ const TabsComponent = memo( [tabs, refMap], ); - const containerStyle = useMemo( - () => ({ opacity: disabled ? accessibleOpacityDisabled : 1, ...style, ...styles?.root }), - [disabled, style, styles?.root], - ); + const containerStyle = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]); const registerRef = useCallback( (tabId: string, ref: HTMLElement) => { @@ -231,13 +248,15 @@ const TabsComponent = memo( borderTopRightRadius={borderTopRightRadius} className={cx(className, classNames?.root)} onKeyDown={handleTabsContainerKeyDown} + opacity={disabled ? accessibleOpacityDisabled : 1} position={position} role={role} style={containerStyle} + testID={testID} width={width} {...props} > - }> + }> - {tabs.map(({ id, Component: CustomTabComponent, disabled: tabDisabled, ...props }) => { - const RenderedTab = CustomTabComponent ?? TabComponent; + {tabs.map((props) => { + const RenderedTab = props.Component ?? TabComponent; + const renderedTabProps = { + ...props, + 'data-rendered-tab': true, + className: classNames?.tab, + role: 'tab', + style: styles?.tab, + tabIndex: activeTab?.id === props.id || !activeTab ? 0 : -1, + }; return ( - - + + ); })} @@ -280,6 +299,7 @@ export const Tabs = TabsComponent as TabsFC; export const TabsActiveIndicator = ({ activeTabRect, position = 'absolute', + testID = 'tabs-active-indicator', ...props }: TabsActiveIndicatorProps) => { const { width, height, x } = activeTabRect; @@ -288,12 +308,12 @@ export const TabsActiveIndicator = ({ return ( diff --git a/packages/web/src/tabs/__stories__/Tabs.stories.tsx b/packages/web/src/tabs/__stories__/Tabs.stories.tsx new file mode 100644 index 0000000000..3bf5af8288 --- /dev/null +++ b/packages/web/src/tabs/__stories__/Tabs.stories.tsx @@ -0,0 +1,228 @@ +import { useCallback, useState } from 'react'; +import { sampleTabs } from '@coinbase/cds-common/internal/data/tabs'; +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; +import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; + +import { Box, VStack } from '../../layout'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultTheme } from '../../themes/defaultTheme'; +import { Text } from '../../typography/Text'; +import { DefaultTab, type DefaultTabLabelProps } from '../DefaultTab'; +import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator'; +import { + type TabComponent, + Tabs, + TabsActiveIndicator, + type TabsActiveIndicatorComponent, + type TabsActiveIndicatorProps, + type TabsProps, +} from '../Tabs'; + +import { MockTabPanel } from './MockTabPanel'; + +export default { + title: 'Components/Tabs/Tabs', + parameters: { + a11y: { + context: { + include: ['body'], + exclude: ['.no-a11y-checks'], + }, + }, + }, +}; + +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' }, +]; + +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 ( + + + {title} + + + + ); +}; + +export const Default = () => ( + + + +); + +export const All = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const tabsTabListOnlyA11y = { + a11y: { + context: { + include: ['body'], + exclude: ['.no-a11y-checks'], + }, + options: { + rules: { + 'aria-valid-attr-value': { enabled: false }, + 'duplicate-id': { enabled: false }, + 'duplicate-id-active': { enabled: false }, + }, + }, + }, +}; + +Default.parameters = tabsTabListOnlyA11y; +All.parameters = tabsTabListOnlyA11y; + +const panelTabs = sampleTabs.slice(0, 3); + +export const WithTabPanels = () => { + const [activeTab, setActiveTab] = useState | null>(panelTabs[0]); + + return ( + + + Pair tab buttons with role="tabpanel" regions (see + MockTabPanel). + + + {panelTabs.map((tab) => ( + + + Panel: {tab.label} + + + Content for this tab. + + + ))} + + ); +}; diff --git a/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx b/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx index 6622af98e7..92887b83a8 100644 --- a/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx +++ b/packages/web/src/tabs/__tests__/SegmentedTabs.test.tsx @@ -103,7 +103,7 @@ describe('SegmentedTabs', () => { , ); - const indicator = screen.getByTestId('tabs-active-indicator'); + const indicator = screen.getByTestId(`${TEST_ID}-active-indicator`); const style = indicator.getAttribute('style'); expect(style).toContain('--height: 40px'); expect(style).toContain('width: 68px'); @@ -139,7 +139,7 @@ describe('SegmentedTabs', () => { ); fireEvent.click(screen.getByTestId('sell-tab')); - const indicator = screen.getByTestId('tabs-active-indicator'); + const indicator = screen.getByTestId(`${TEST_ID}-active-indicator`); const style = indicator.getAttribute('style'); expect(style).toContain('--height: 40px'); expect(style).toContain('width: 68px'); @@ -218,7 +218,7 @@ describe('SegmentedTabs', () => { , ); - const indicator = screen.getByTestId('tabs-active-indicator'); + const indicator = screen.getByTestId(`${TEST_ID}-active-indicator`); const style = indicator.getAttribute('style'); expect(style).toContain('--height: 40px'); expect(style).toContain('width: 68px'); @@ -241,7 +241,7 @@ describe('SegmentedTabs', () => { , ); - expect(screen.queryByTestId('tabs-active-indicator')).not.toBeInTheDocument(); + expect(screen.queryByTestId(`${TEST_ID}-active-indicator`)).not.toBeInTheDocument(); }); it('positions indicator correctly with horizontal padding', () => { @@ -272,7 +272,7 @@ describe('SegmentedTabs', () => { , ); - const indicator = screen.getByTestId('tabs-active-indicator'); + const indicator = screen.getByTestId(`${TEST_ID}-active-indicator`); const style = indicator.getAttribute('style'); expect(style).toContain('transform: translateX(24px) translateZ(0)'); expect(style).toContain('left: 0'); diff --git a/packages/web/src/tabs/index.ts b/packages/web/src/tabs/index.ts index f351a031bd..31b5f23c91 100644 --- a/packages/web/src/tabs/index.ts +++ b/packages/web/src/tabs/index.ts @@ -1,3 +1,5 @@ +export * from './DefaultTab'; +export * from './DefaultTabsActiveIndicator'; export * from './Paddle'; export * from './SegmentedTab'; export * from './SegmentedTabs'; From 9a3257328f2e34a34debf5e7581a87ceb9a08279 Mon Sep 17 00:00:00 2001 From: Erich <137924413+cb-ekuersch@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:47:35 -0500 Subject: [PATCH 3/3] chore: revert debug workflow to its correct state (#581) --- .github/workflows/debug-workflow.yml | 199 +-------------------------- 1 file changed, 7 insertions(+), 192 deletions(-) diff --git a/.github/workflows/debug-workflow.yml b/.github/workflows/debug-workflow.yml index c6bc4f2328..6c3c03c6f6 100644 --- a/.github/workflows/debug-workflow.yml +++ b/.github/workflows/debug-workflow.yml @@ -20,198 +20,13 @@ 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: ./.github/actions/setup - - - name: Set Percy branch - run: | - BRANCH_INPUT="${{ inputs.branch }}" - if [[ -n "$BRANCH_INPUT" ]]; then - echo "PERCY_BRANCH=$BRANCH_INPUT" >> "$GITHUB_ENV" - elif [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo "PERCY_BRANCH=${{ github.head_ref }}" >> "$GITHUB_ENV" - else - echo "PERCY_BRANCH=${{ github.ref_name }}" >> "$GITHUB_ENV" - fi - - - name: Install Maestro - run: node packages/mobile-visreg/src/setup.mjs - - - name: Add Maestro to PATH - run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH - - - name: Prepare iOS app (extract prebuild + patch JS bundle) - run: yarn nx run mobile-app:patch-bundle-ios - - - name: Boot iOS simulator - run: | - xcrun simctl boot "iPhone 16" || true - xcrun simctl bootstatus booted - - - name: Install app on simulator - run: xcrun simctl install booted apps/mobile-app/prebuilds/ios-release-hermes.app - - - name: Capture screenshots - run: yarn nx run mobile-visreg:ios - - - name: Upload to Percy - id: percy-upload - if: always() - run: | - OUTPUT=$(yarn nx run mobile-visreg:upload 2>&1) - EXIT_CODE=$? - echo "$OUTPUT" - PERCY_URL=$(echo "$OUTPUT" | grep -oE 'https://percy\.io[^[:space:]]+' | head -1) - echo "percy_url=$PERCY_URL" >> "$GITHUB_OUTPUT" - exit $EXIT_CODE - env: - PERCY_TOKEN: ${{ secrets.PERCY_TOKEN_MOBILE }} - PERCY_BRANCH: ${{ env.PERCY_BRANCH }} - PERCY_PARALLEL_NONCE: ${{ github.run_id }} - PERCY_PARALLEL_TOTAL: 1 - - # android: - # name: Visreg Android - # runs-on: ubuntu-latest - # environment: production - # if: > - # github.event_name == 'push' || - # github.event_name == 'workflow_dispatch' || - # contains(github.event.pull_request.labels.*.name, 'visreg-mobile') - # 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: ./.github/actions/setup - - # - name: Set Percy branch - # run: | - # BRANCH_INPUT="${{ inputs.branch }}" - # if [[ -n "$BRANCH_INPUT" ]]; then - # echo "PERCY_BRANCH=$BRANCH_INPUT" >> "$GITHUB_ENV" - # elif [[ "${{ github.event_name }}" == "pull_request" ]]; then - # echo "PERCY_BRANCH=${{ github.head_ref }}" >> "$GITHUB_ENV" - # else - # echo "PERCY_BRANCH=${{ github.ref_name }}" >> "$GITHUB_ENV" - # fi - - # - name: Install Maestro - # run: node packages/mobile-visreg/src/setup.mjs - - # - name: Add Maestro to PATH - # run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH - - # - name: Prepare Android app (extract prebuild + patch JS bundle) - # run: yarn nx run mobile-app:patch-bundle-android - - # # Enable KVM hardware acceleration for the Android emulator. - # # Without this, the emulator runs in software emulation mode, which takes 6+ minutes to boot - # # and is significantly more flaky. Ubuntu GHA runners support KVM but it must be explicitly - # # unlocked via udev rules before use. - # # Ref: https://github.com/marketplace/actions/android-emulator-runner - # - name: Enable KVM - # run: | - # echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - # sudo udevadm control --reload-rules - # sudo udevadm trigger --name-match=kvm - - # - name: Start Android emulator + run visreg - # uses: reactivecircus/android-emulator-runner@v2 - # with: - # api-level: 30 - # arch: x86_64 - # profile: pixel_7_pro - # avd-name: cds_detox - # # -no-window -gpu swiftshader_indirect: headless software rendering (no display available in CI) - # # -no-boot-anim -noaudio -camera-back none: disable unused subsystems to speed up boot - # # -no-snapshot: disable snapshot load and save entirely (clean state every run) - # emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - # disable-animations: true - # script: | - # # Enable Demo Mode to freeze status bar (avoids false Percy diffs) - # adb shell settings put global sysui_demo_allowed 1 - # adb shell am broadcast -a com.android.systemui.demo -e command enter - # adb shell am broadcast -a com.android.systemui.demo -e command clock --es hhmm 1200 - # adb shell am broadcast -a com.android.systemui.demo -e command battery --es level 100 --es plugged false - # adb shell am broadcast -a com.android.systemui.demo -e command network --es mobile show --es level 4 --es wifi show - - # # sys.boot_completed=1 fires before all services are ready; wait for - # # the package manager specifically before attempting install. - # while ! adb shell pm list packages > /dev/null 2>&1; do echo "Waiting for package manager..."; sleep 1; done - - # adb install -r apps/mobile-app/prebuilds/android-release-hermes/binary.apk - - # # Copy Maestro debug artifacts after the run so they can be uploaded after the emulator shuts down - # yarn nx run mobile-visreg:android; cp -r ~/.maestro/tests /tmp/maestro-debug || true - - # - name: Upload Maestro debug artifacts - # if: always() - # uses: actions/upload-artifact@v4 - # with: - # name: maestro-debug-android - # path: /tmp/maestro-debug/ - # if-no-files-found: ignore - - # - name: Upload to Percy - # if: always() - # run: yarn nx run mobile-visreg:visreg-upload - # env: - # PERCY_TOKEN: ${{ secrets.PERCY_TOKEN_MOBILE }} - # PERCY_BRANCH: ${{ env.PERCY_BRANCH }} - # PERCY_PARALLEL_NONCE: ${{ github.run_id }} - # PERCY_PARALLEL_TOTAL: 2 - - # comment-pr: - # name: Comment Percy Link - # needs: [ios, android] - # if: always() && github.event_name == 'pull_request' - # runs-on: ubuntu-latest - # steps: - # - name: Post Percy link on PR - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # PERCY_URL: ${{ needs.ios.outputs.percy_url }} - # run: | - # BODY="${PERCY_URL:-Percy build URL unavailable}" + - uses: actions/checkout@v4 - # gh pr comment ${{ github.event.pull_request.number }} \ - # --repo ${{ github.repository }} \ - # --body "$BODY" \ - # --edit-last 2>/dev/null || \ - # gh pr comment ${{ github.event.pull_request.number }} \ - # --repo ${{ github.repository }} \ - # --body "$BODY" + # Test the published action + # - name: New CDS Action + # uses: [fill this in on new branch] + # with: [fill this in on new branch]