Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/cli-discover-tab-bar-more-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/cli': patch
---

[experimental] `astryx discover` lists the experimental TabBarMoreList component from `@astryxdesign/lab`.

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

import {useState} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import {
BookOpenIcon as BookOpenOutline,
Cog6ToothIcon as CogOutline,
HomeIcon as HomeOutline,
QuestionMarkCircleIcon as HelpOutline,
Squares2X2Icon as GridOutline,
UserCircleIcon as UserOutline,
} from '@heroicons/react/24/outline';
import {
BookOpenIcon as BookOpenSolid,
Cog6ToothIcon as CogSolid,
HomeIcon as HomeSolid,
QuestionMarkCircleIcon as HelpSolid,
Squares2X2Icon as GridSolid,
UserCircleIcon as UserSolid,
} from '@heroicons/react/24/solid';
import {TabBar, TabBarItem, TabBarMoreList} 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 TabBarMoreList> = {
// 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/TabBarMoreList',
component: TabBarMoreList,
parameters: {
layout: 'fullscreen',
},
};

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

// =============================================================================
// Six sections: the four the bar holds, then two that overflow into More
// =============================================================================

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

const SIX: 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,
},
{
value: 'settings',
label: 'Settings',
icon: CogOutline,
selectedIcon: CogSolid,
},
{value: 'help', label: 'Help', icon: HelpOutline, selectedIcon: HelpSolid},
];

/** 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}</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 MoreDemo({disabled = []}: {disabled?: string[]}) {
const [value, setValue] = useState('home');
const [reselects, setReselects] = useState(0);
const tabs = SIX.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={section.value === 'help' ? 2 : undefined}
aria-disabled={disabled.includes(section.value) || undefined}
/>
));
const inMore = SIX.slice(4).find(section => section.value === value);
return (
<>
{value === 'more' ? (
<div style={{padding: '16px 16px 104px'}}>
<h1 style={{margin: '8px 0 16px', fontSize: 28}}>More</h1>
<TabBarMoreList value={value} onChange={setValue}>
{tabs}
</TabBarMoreList>
</div>
) : inMore != null ? (
<div style={{padding: '16px 16px 104px'}}>
<Button
label="More"
variant="ghost"
onClick={() => setValue('more')}
/>
<h1 style={{margin: '8px 0 16px', fontSize: 28}}>{inMore.label}</h1>
</div>
) : (
<Screen
title={SIX.find(section => section.value === value)?.label ?? ''}
reselects={reselects}
/>
)}
<TabBar
value={value}
onChange={setValue}
onReselect={reselected => {
// More again returns to its list, as iOS pops More's stack.
if (reselected === 'more') {
setValue('more');
} else {
setReselects(n => n + 1);
}
}}
label="Sections"
data-testid="tab-bar">
{tabs}
</TabBar>
</>
);
}

/**
* Six tabs: the bar shows the first four and More, whose list holds the
* rest. A section chosen there opens inside More, which stays selected;
* More again returns to the list.
*/
export const MoreTab: Story = {
render: () => <MoreDemo />,
};

/** Six tabs, Help disabled: its More row is dimmed and never chosen. */
export const MoreDisabledRow: Story = {
render: () => <MoreDemo disabled={['help']} />,
};
5 changes: 5 additions & 0 deletions internal/shadcn-registry/routes.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,11 @@
"path": "components/lab/tab-bar-item",
"aliases": []
},
{
"name": "component-lab-tab-bar-more-list",
"path": "components/lab/tab-bar-more-list",
"aliases": []
},
{
"name": "component-lab-three-d-bar",
"path": "components/lab/three-d-bar",
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/assets/discover/official-packages.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@
"kind": "component",
"name": "TabBarItem"
},
{
"kind": "component",
"name": "TabBarMoreList"
},
{
"kind": "component",
"name": "ThreeDBar"
Expand Down
18 changes: 16 additions & 2 deletions packages/lab/src/TabBar/TabBar.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ export const docs = {
'mobile navigation',
'app sections',
'badge',
'more tab',
'overflow tabs',
],
description:
"The bottom tab bar of a phone app, as on iOS 26: the app's top-level sections as a floating capsule above the bottom edge, with a selection lens that slides to the chosen tab. Each tab has an icon, a label and an optional badge. Under a finger, on iOS and Android alike, it behaves like iOS 26's: the lens lifts into glass as a finger lands, follows a finger dragged across the tabs and settles on the tab nearest where it lifts, while the bar flexes; under a mouse, a press is a plain button press: a click chooses a tab and the lens follows the theme's motion. With no tab selected, a held finger highlights the tab under it. Choosing the selected tab again scrolls its panel to the top and is reported separately, so a screen can also return to its root as iOS does. Experimental: lands in lab first.",
"The bottom tab bar of a phone app, as on iOS 26: the app's top-level sections as a floating capsule above the bottom edge, with a selection lens that slides to the chosen tab. Each tab has an icon, a label and an optional badge. Under a finger, on iOS and Android alike, it behaves like iOS 26's: the lens lifts into glass as a finger lands, follows a finger dragged across the tabs and settles on the tab nearest where it lifts, while the bar flexes; under a mouse, a press is a plain button press: a click chooses a tab and the lens follows the theme's motion. With no tab selected, a held finger highlights the tab under it. Choosing the selected tab again scrolls its panel to the top and is reported separately, so a screen can also return to its root as iOS does. With more than five tabs the bar shows the first four and a More tab, and TabBarMoreList lists the rest. Experimental: lands in lab first.",
components: [
{
name: 'TabBar',
Expand Down Expand Up @@ -55,9 +57,16 @@ export const docs = {
name: 'children',
type: 'ReactNode',
description:
'The tabs: TabBarItem elements, in order, as direct children.',
'The tabs: TabBarItem elements, in order, as direct children. With more than five, the bar shows the first four and a More tab that holds the rest.',
required: true,
},
{
name: 'moreValue',
type: 'string',
description:
"The More tab's value, with more than five tabs. Choosing More calls onChange with it (and onReselect while More holds a tab, to return to its list); More reads as selected while value is moreValue or any tab past the fourth. Render TabBarMoreList while value is moreValue, and a section chosen there inside More's own navigation stack.",
default: "'more'",
},
],
},
{
Expand Down Expand Up @@ -128,6 +137,11 @@ export const docs = {
description:
'Keep every tab visible and the set stable; give each a short label and an outline icon with a filled selectedIcon.',
},
{
guidance: true,
description:
'Keep to five tabs where you can; with more, put the sections people use most first, since the rest move into More.',
},
{
guidance: true,
description:
Expand Down
Loading
Loading