Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 3 additions & 26 deletions .github/workflows/debug-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,198 +20,175 @@
# 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:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cb-ekuersch does this part look right?

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

- 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 test-expo: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/test-expo/prebuilds/ios-release/testexpo.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 test-expo: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/test-expo/prebuilds/android-release/testexpo.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}"

# 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"

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
158 changes: 59 additions & 99 deletions apps/docs/docs/components/navigation/Tabs/_mobileExamples.mdx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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 (
<Pressable
onPress={() => updateActiveTab(id)}
disabled={disabled}
accessibilityRole="tab"
accessibilityState={{ selected: isActive, disabled }}
>
<TabLabel id={id} active={isActive}>
{label}
</TabLabel>
</Pressable>
);
}, []);

const ActiveIndicator = useCallback(
(props) => <TabsActiveIndicator {...props} background="bgPrimary" bottom={0} height={2} />,
[],
);

const [activeTab, setActiveTab] = useState(tabs[0]);
return (
<Tabs
gap={4}
tabs={tabs}
accessibilityLabel="Example tabs"
activeBackground="bgPrimary"
activeTab={activeTab}
background="bg"
gap={4}
onChange={setActiveTab}
TabComponent={TabComponent}
TabsActiveIndicatorComponent={ActiveIndicator}
// default value; can be omitted.
TabComponent={DefaultTab}
tabs={tabs}
// default value; can be omitted.
TabsActiveIndicatorComponent={DefaultTabsActiveIndicator}
/>
);
}
```

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() {
Expand All @@ -59,99 +41,77 @@ 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 (
<Pressable
onPress={() => updateActiveTab(id)}
disabled={disabled}
accessibilityRole="tab"
accessibilityState={{ selected: isActive, disabled }}
>
<TabLabel id={id} active={isActive}>
{label}
</TabLabel>
</Pressable>
);
}, []);

const ActiveIndicator = useCallback(
(props) => <TabsActiveIndicator {...props} background="bgPrimary" bottom={0} height={2} />,
[],
);

const [activeTab, setActiveTab] = useState(null);
return (
<Tabs
accessibilityLabel="Example tabs"
activeBackground="bgPrimary"
activeTab={activeTab}
background="bg"
gap={4}
onChange={setActiveTab}
tabs={tabs}
/>
);
}
```

### 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 (
<Tabs
accessibilityLabel="Mail folders"
activeBackground="bgPrimary"
activeTab={activeTab}
background="bg"
gap={4}
onChange={setActiveTab}
TabComponent={TabComponent}
TabsActiveIndicatorComponent={ActiveIndicator}
tabs={tabs}
/>
);
}
```

### 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 (
<Pressable
onPress={() => updateActiveTab(id)}
disabled={disabled}
accessibilityRole="tab"
accessibilityState={{ selected: isActive, disabled }}
>
<TabLabel id={id} active={isActive}>
{label}
</TabLabel>
</Pressable>
);
}, []);

const ActiveIndicator = useCallback(
(props) => <TabsActiveIndicator {...props} background="bgPrimary" bottom={0} height={2} />,
[],
);

const [activeTab, setActiveTab] = useState(tabs[0]);
return (
<Tabs
disabled
gap={4}
tabs={tabs}
accessibilityLabel="Example tabs"
activeBackground="bgPrimary"
activeTab={activeTab}
background="bg"
gap={4}
onChange={setActiveTab}
TabComponent={TabComponent}
TabsActiveIndicatorComponent={ActiveIndicator}
tabs={tabs}
/>
);
}
```

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' },
];

Expand All @@ -165,9 +125,9 @@ function Example() {
accessibilityRole="tab"
accessibilityState={{ selected: isActive, disabled }}
>
<TabLabel id={id} active={isActive}>
<Text font="headline" color={isActive ? 'fgPositive' : 'fg'}>
{label}
</TabLabel>
</Text>
</Pressable>
);
}, []);
Expand All @@ -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() {
Expand All @@ -217,9 +173,9 @@ function Example() {
>
<HStack gap={1} alignItems="center">
<Icon name={icon} size="s" color={isActive ? 'fgPrimary' : 'fgMuted'} />
<TabLabel id={id} active={isActive}>
<Text font="headline" color={isActive ? 'fgPrimary' : 'fg'}>
{label}
</TabLabel>
</Text>
</HStack>
</Pressable>
);
Expand All @@ -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.
Loading
Loading