Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f3e826a
chore(lab): Lab's animator hook, over Core's internal touch entry
vjeux Nov 3, 2026
7a64fbf
chore(core): Core's internal touch entry re-exports the action-group …
vjeux Nov 3, 2026
0b6f57f
feat(lab): TabBar, iOS 26's floating tab bar, with its selection lens…
vjeux Nov 3, 2026
7851f5a
Merge branch 'main' into c09/core-touch-entry-lab-animator
cixzhang Nov 5, 2026
a5ec7f1
Merge the updated lower stack branch into tabbar/core-touch-entry
cixzhang Nov 5, 2026
7755e54
Merge the updated lower stack branch into tabbar/lab-bar-lens
cixzhang Nov 5, 2026
4bbb9ca
Merge main (#9754 landed) into tabbar/core-touch-entry
vjeux Nov 5, 2026
15afcfe
Merge the updated lower stack branch into tabbar/lab-bar-lens
vjeux Nov 5, 2026
1f67124
Merge main into tabbar/core-touch-entry
vjeux Nov 5, 2026
4b18d41
Merge the updated lower stack branch into tabbar/lab-bar-lens
vjeux Nov 5, 2026
4c9c002
Merge main into tabbar/core-touch-entry
vjeux Nov 5, 2026
5aaac13
Merge the updated lower stack branch into tabbar/lab-bar-lens
vjeux Nov 5, 2026
64069d3
Merge main into tabbar/lab-bar-lens
vjeux Nov 5, 2026
9fc9bde
Merge main into the TabBar lab entry branch (#10188 landed)
cixzhang Nov 6, 2026
a2774b8
Merge the updated lower stack branch into the TabBar branch (own diff…
cixzhang Nov 6, 2026
a236965
Merge main into tabbar/core-touch-entry
vjeux Nov 6, 2026
634a859
Merge the updated lower stack branch into tabbar/lab-bar-lens
vjeux Nov 6, 2026
270f341
fix(lab): TabBar's finger paint fades on the compositor
vjeux Nov 6, 2026
92659fe
Merge main into tabbar/core-touch-entry
vjeux Nov 6, 2026
e700938
Merge the updated lower stack branch into tabbar/lab-bar-lens
vjeux Nov 6, 2026
5a5ebab
Merge main into tabbar/core-touch-entry
vjeux Nov 6, 2026
ec8e3a4
Merge the updated lower stack branch into tabbar/lab-bar-lens
vjeux Nov 6, 2026
d24b6d4
Merge branch 'main' into tabbar/core-touch-entry
cixzhang Nov 6, 2026
f9b11ba
Merge the updated TabBar base branch (main merged) into the TabBar pr…
cixzhang Nov 6, 2026
82971fa
Merge main into tabbar/core-touch-entry
vjeux Nov 7, 2026
92b3acb
Merge the updated lower stack branch into tabbar/lab-bar-lens
vjeux Nov 7, 2026
c7adb12
Merge branch 'main' into tabbar/lab-bar-lens
cixzhang Nov 7, 2026
a309756
test(lab): TabBar's spring cases name the motion each hold judges
vjeux Nov 7, 2026
ae94e02
Merge main into tabbar/core-touch-entry
vjeux Nov 7, 2026
04ca7ba
Merge the updated lower stack branch into tabbar/lab-bar-lens
vjeux Nov 7, 2026
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
7 changes: 7 additions & 0 deletions .changeset/cli-discover-tab-bar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/cli': patch
---

[experimental] `astryx discover` lists the experimental TabBar and TabBarItem components from `@astryxdesign/lab`.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/core-internal-entries-lab-tabbar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: Core's internal touch entry re-exports the action-group tracking hook, which gains a no-paint option and press callbacks for Lab's TabBar. A tab-bar string joins Core's message catalog. Apps import nothing new.

@vjeux
296 changes: 296 additions & 0 deletions apps/storybook/stories/TabBar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

import {useEffect, useState} from 'react';
import {flushSync} from 'react-dom';
import type {Meta, StoryObj} from '@storybook/react';
import {
BookOpenIcon as BookOpenOutline,
HomeIcon as HomeOutline,
Squares2X2Icon as GridOutline,
UserCircleIcon as UserOutline,
BellIcon as BellOutline,
} from '@heroicons/react/24/outline';
import {
BookOpenIcon as BookOpenSolid,
HomeIcon as HomeSolid,
Squares2X2Icon as GridSolid,
UserCircleIcon as UserSolid,
BellIcon as BellSolid,
} from '@heroicons/react/24/solid';
import {TabBar, TabBarItem} from '@astryxdesign/lab';
import {Button} from '@astryxdesign/core/Button';
import {LayerProvider} from '@astryxdesign/core/Layer';
import {Icon, type IconType} from '@astryxdesign/core/Icon';

// =============================================================================
// Meta
// =============================================================================

const meta: Meta<typeof TabBar> = {
// The bar floats over the bottom edge: declared on LayerProvider, menus,
// popovers and toasts clear it.
decorators: [
Story => (
<LayerProvider inset={{blockEnd: 83}}>
<Story />
</LayerProvider>
),
],
title: 'Lab/TabBar',
component: TabBar,
parameters: {
layout: 'fullscreen',
},
};

export default meta;
type Story = StoryObj<typeof TabBar>;

// =============================================================================
// The sections UIKit's own captures were taken with: four tabs over long
// scrolling screens
// =============================================================================

interface Section {
value: string;
label: string;
icon: IconType;
selectedIcon: IconType;
}

const SECTIONS: Section[] = [
{value: 'home', label: 'Home', icon: HomeOutline, selectedIcon: HomeSolid},
{
value: 'library',
label: 'Library',
icon: BookOpenOutline,
selectedIcon: BookOpenSolid,
},
{
value: 'browse',
label: 'Browse',
icon: GridOutline,
selectedIcon: GridSolid,
},
{
value: 'profile',
label: 'Profile',
icon: UserOutline,
selectedIcon: UserSolid,
},
];

const INBOX: Section = {
value: 'inbox',
label: 'Inbox',
icon: BellOutline,
selectedIcon: BellSolid,
};

/** A screen long enough to scroll under the bar. */
function Screen({title, reselects}: {title: string; reselects: number}) {
return (
<div style={{padding: '16px 16px 104px'}}>
<h1 style={{margin: '8px 0 16px', fontSize: 28}}>
{title || 'No tab selected'}
</h1>
<p data-testid="reselects">Reselected {reselects} times</p>
{Array.from({length: 40}, (_, i) => (
<p key={i} style={{margin: 0, padding: '12px 0'}}>
{title} row {i + 1}
</p>
))}
</div>
);
}

function Bar({
sections = SECTIONS,
initial = 'home',
badges = {library: 3},
keeps = [],
onChange,
}: {
sections?: Section[];
initial?: string;
badges?: Record<string, string | number>;
/** Tabs the app declines to switch to (it keeps the selection). */
keeps?: string[];
/** Also told of every choice. */
onChange?: (value: string) => void;
}) {
const [value, setValue] = useState(initial);
const [reselects, setReselects] = useState(0);
const title = sections.find(section => section.value === value)?.label ?? '';
return (
<>
<Screen title={title} reselects={reselects} />
<TabBar
value={value}
onChange={next => {
onChange?.(next);
if (!keeps.includes(next)) {
setValue(next);
}
}}
onReselect={() => setReselects(n => n + 1)}
label="Sections"
data-testid="tab-bar">
{sections.map(section => (
<TabBarItem
key={section.value}
value={section.value}
label={section.label}
icon={<Icon icon={section.icon} size="lg" />}
selectedIcon={<Icon icon={section.selectedIcon} size="lg" />}
badge={badges[section.value]}
/>
))}
</TabBar>
</>
);
}

/** Four tabs, a badge on Library. Choosing Home again counts a reselect. */
export const Default: Story = {
render: () => <Bar />,
};

/** Badges appear, change and disappear at once, as UIKit's do. */
function BadgesDemo() {
const [count, setCount] = useState(0);
const badge = count === 0 ? undefined : count > 99 ? '99+' : count;
return (
<>
<div style={{padding: 16, display: 'flex', gap: 8}}>
<Button label="Add one" onClick={() => setCount(n => n + 1)} />
<Button label="Add ten" onClick={() => setCount(n => n + 10)} />
<Button label="Clear" variant="secondary" onClick={() => setCount(0)} />
</div>
<TabBarHost badges={{inbox: badge ?? '', browse: '!'}} />
</>
);
}

function TabBarHost({badges}: {badges: Record<string, string | number>}) {
const [value, setValue] = useState('home');
return (
<TabBar
value={value}
onChange={setValue}
label="Sections"
data-testid="tab-bar">
{[SECTIONS[0], INBOX, SECTIONS[2], SECTIONS[3]].map(section => (
<TabBarItem
key={section.value}
value={section.value}
label={section.label}
icon={<Icon icon={section.icon} size="lg" />}
selectedIcon={<Icon icon={section.selectedIcon} size="lg" />}
badge={badges[section.value]}
badgeLabel={
section.value === 'inbox' && badges.inbox !== ''
? `${badges.inbox} unread`
: undefined
}
/>
))}
</TabBar>
);
}

/**
* The app keeps the selection when Profile is chosen (say it needs a
* sign-in first): the lens settles back onto the selected tab.
*/
export const KeepsSelection: Story = {
render: () => <Bar keeps={['profile']} />,
};

/** No tab selected (the value matches none): a tap chooses one. */
export const NoSelection: Story = {
render: () => <Bar initial="none" />,
};

/**
* The app removes the bar as a finger lifts: the lift reports nothing (each
* choice would be listed on `window.__tabBarChanges`).
*/
function RemovedOnLiftDemo() {
const [isShown, setIsShown] = useState(true);
useEffect(() => {
const hide = () => {
const log = window as unknown as {__tabBarChanges?: string[]};
log.__tabBarChanges = [...(log.__tabBarChanges ?? []), '(removed)'];
flushSync(() => setIsShown(false));
};
window.addEventListener('touchend', hide);
return () => window.removeEventListener('touchend', hide);
}, []);
return isShown ? (
<Bar
onChange={value => {
const log = window as unknown as {__tabBarChanges?: string[]};
log.__tabBarChanges = [...(log.__tabBarChanges ?? []), value];
}}
/>
) : (
<p style={{padding: 16}}>Removed</p>
);
}

export const RemovedOnLift: Story = {
render: () => <RemovedOnLiftDemo />,
};

/**
* The app drops its last tab on the window event `tabbar:drop-last`, sent while
* a finger holds the bar. The press ends without choosing a tab (each choice
* would be listed on `window.__tabBarChanges`).
*/
function DropsLastTabDemo() {
const [sections, setSections] = useState(SECTIONS);
useEffect(() => {
const drop = () => setSections(current => current.slice(0, -1));
window.addEventListener('tabbar:drop-last', drop);
return () => window.removeEventListener('tabbar:drop-last', drop);
}, []);
return (
<Bar
sections={sections}
onChange={value => {
const log = window as unknown as {__tabBarChanges?: string[]};
log.__tabBarChanges = [...(log.__tabBarChanges ?? []), value];
}}
/>
);
}

export const DropsLastTab: Story = {
render: () => <DropsLastTabDemo />,
};

export const Badges: Story = {
render: () => <BadgesDemo />,
};

/** Five tabs: the items overlap more, as they do on iOS. */
export const FiveTabs: Story = {
render: () => (
<Bar sections={[...SECTIONS.slice(0, 2), INBOX, ...SECTIONS.slice(2)]} />
),
};

/** Three tabs: the capsule narrows to its natural width and centres. */
export const ThreeTabs: Story = {
render: () => <Bar sections={SECTIONS.slice(0, 3)} />,
};

/** Right to left: the first tab sits on the right and the badge on the icon's top-left. */
export const RightToLeft: Story = {
render: () => (
<div dir="rtl">
<Bar initial="library" />
</div>
),
};
10 changes: 10 additions & 0 deletions internal/shadcn-registry/routes.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,16 @@
"path": "components/lab/svg-icon",
"aliases": []
},
{
"name": "component-lab-tab-bar",
"path": "components/lab/tab-bar",
"aliases": []
},
{
"name": "component-lab-tab-bar-item",
"path": "components/lab/tab-bar-item",
"aliases": []
},
{
"name": "component-lab-three-d-bar",
"path": "components/lab/three-d-bar",
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/assets/discover/official-packages.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@
"kind": "component",
"name": "SVGIcon"
},
{
"kind": "component",
"name": "TabBar"
},
{
"kind": "component",
"name": "TabBarItem"
},
{
"kind": "component",
"name": "ThreeDBar"
Expand Down
4 changes: 4 additions & 0 deletions packages/core/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,10 @@
"defaultMessage": "Maximum value",
"description": "Accessible name of the upper thumb of a range Slider (two thumbs), announced together with the Slider's own label, for example \"Price range, Maximum value, 80\". Never shown on screen."
},
"@astryx.tabBar.badgedItem": {
"defaultMessage": "{label}, {badge}",
"description": "Screen-reader name of a bottom tab bar item that shows a badge (\"Library, 3\"). {label} is the tab's visible name; {badge} is the badge's count or its own label (\"3 unread\"). Translators may reorder the two or change the separator."
},
"@astryx.tabList.label": {
"defaultMessage": "Tabs",
"description": "Screen-reader-only fallback name for a horizontal tab bar. Plural noun; \"Tabs\" here = UI tab panels, not browser tabs or the Tab key."
Expand Down
Loading
Loading