Skip to content
Draft
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/tablist-scroll-overflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] TabList: a strip narrower than its tabs now scrolls instead of spilling out of its container. Every tab stays a tab — nothing is hidden behind a menu — the edges fade to show there is more, and pointers that can hover get arrow affordances; keyboard and screen-reader users reach every tab with the arrow keys, which scrolls the focused tab into view. The selected tab is scrolled back into view whenever it would be out of sight, including on mount and when the host changes `value` itself. The new `overflow` prop takes `'auto'` (the default, which today always scrolls), `'scroll'`, or `'none'` to keep the old spill-out layout. Built on the existing `useScrollOverflow` hook, so there is no new measurement machinery and no `Carousel` in the tab strip — the documented Carousel recipe, which announced every tab as "slide N of M", is no longer needed and the stories now use the built-in behaviour.

@cixzhang
104 changes: 84 additions & 20 deletions apps/storybook/stories/TabList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import {useState} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import {TabList, Tab, TabMenu} from '@astryxdesign/core/TabList';
import {Carousel} from '@astryxdesign/core/Carousel';
import {Button} from '@astryxdesign/core/Button';
import {PlusIcon, FunnelIcon} from '@heroicons/react/24/outline';

Expand Down Expand Up @@ -291,17 +290,66 @@ export const FillLayout: Story = {
};

/**
* When tabs overflow, wrap TabList's children in Carousel.
* The Carousel handles scroll, fade masks, and arrow buttons.
* Each tab keeps its intrinsic label width — no truncation.
* A strip narrower than its tabs scrolls. Every tab stays a tab — nothing is
* hidden behind a menu — and the edges fade to show there is more. Pointers
* that can hover also get arrow affordances; keyboard users reach every tab
* with the arrow keys, which scrolls the focused tab into view.
*/
export const Overflow: Story = {
render: () => {
const [value, setValue] = useState('overview');
return (
<div style={{maxWidth: '400px', border: '1px dashed #ccc'}}>
<TabList value={value} onChange={setValue}>
<Carousel gap={0.5} hasSnap={false}>
<Tab value="overview" label="Overview" />
<Tab value="activity" label="Activity" />
<Tab value="members" label="Members" />
<Tab value="settings" label="Settings" />
<Tab value="integrations" label="Integrations" />
<Tab value="billing" label="Billing & Plans" />
<Tab value="security" label="Security" />
<Tab value="notifications" label="Notifications" />
<Tab value="api" label="API Keys" />
</TabList>
</div>
);
},
};

/**
* Overflow with divider — typical page header in a narrow viewport. The
* selected indicator still sits on the rail while the strip scrolls.
*/
export const OverflowWithDivider: Story = {
render: () => {
const [value, setValue] = useState('dashboard');
return (
<div style={{maxWidth: '350px'}}>
<TabList value={value} onChange={setValue} hasDivider size="lg">
<Tab value="dashboard" label="Dashboard" />
<Tab value="analytics" label="Analytics" />
<Tab value="reports" label="Reports" />
<Tab value="customers" label="Customers" />
<Tab value="products" label="Products" />
<Tab value="orders" label="Orders" />
</TabList>
</div>
);
},
};

/**
* A tab selected while it is out of view is scrolled back in — on mount and
* whenever the host changes `value` itself.
*/
export const OverflowSelectedOffscreen: Story = {
render: () => {
const [value, setValue] = useState('api');
return (
<div
style={{display: 'grid', gap: '8px', maxWidth: '400px', minWidth: 0}}>
<div style={{border: '1px dashed #ccc', minWidth: 0}}>
<TabList value={value} onChange={setValue}>
<Tab value="overview" label="Overview" />
<Tab value="activity" label="Activity" />
<Tab value="members" label="Members" />
Expand All @@ -311,30 +359,46 @@ export const Overflow: Story = {
<Tab value="security" label="Security" />
<Tab value="notifications" label="Notifications" />
<Tab value="api" label="API Keys" />
</Carousel>
</TabList>
</TabList>
</div>
<div style={{display: 'flex', gap: '4px'}}>
<Button
label="Select first"
variant="secondary"
size="sm"
onClick={() => setValue('overview')}
/>
<Button
label="Select last"
variant="secondary"
size="sm"
onClick={() => setValue('api')}
/>
</div>
</div>
);
},
};

/**
* Overflow with divider — typical page header in a narrow viewport.
* `overflow="none"` opts out: the tabs keep their intrinsic widths and spill
* out of the strip, for a host that handles overflow itself.
*/
export const OverflowWithDivider: Story = {
export const OverflowNone: Story = {
render: () => {
const [value, setValue] = useState('dashboard');
const [value, setValue] = useState('overview');
return (
<div style={{maxWidth: '350px'}}>
<TabList value={value} onChange={setValue} hasDivider size="lg">
<Carousel gap={0.5} hasSnap={false}>
<Tab value="dashboard" label="Dashboard" />
<Tab value="analytics" label="Analytics" />
<Tab value="reports" label="Reports" />
<Tab value="customers" label="Customers" />
<Tab value="products" label="Products" />
<Tab value="orders" label="Orders" />
</Carousel>
<div style={{maxWidth: '400px', border: '1px dashed #ccc'}}>
<TabList value={value} onChange={setValue} overflow="none">
<Tab value="overview" label="Overview" />
<Tab value="activity" label="Activity" />
<Tab value="members" label="Members" />
<Tab value="settings" label="Settings" />
<Tab value="integrations" label="Integrations" />
<Tab value="billing" label="Billing & Plans" />
<Tab value="security" label="Security" />
<Tab value="notifications" label="Notifications" />
<Tab value="api" label="API Keys" />
</TabList>
</div>
);
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/TabList/TabList.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const docs = {
theming: {
targets: [
{className: 'astryx-tab-list', visualProps: ['size']},
{className: 'astryx-tab-strip'},
{className: 'astryx-tab-scroll-button'},
{className: 'astryx-tab', states: ['selected']},
{className: 'astryx-tab-indicator', states: ['selected']},
{className: 'astryx-tab-menu'},
Expand Down Expand Up @@ -58,6 +60,12 @@ export const docs = {
description: 'Whether to show a bottom border divider under the tab list.',
default: 'false',
},
{
name: 'overflow',
type: "'auto' | 'scroll' | 'none'",
description: "What happens when the tabs are wider than the strip. 'auto' lets the component choose, which today always scrolls. 'scroll' scrolls the tabs horizontally, with edge fades and arrow affordances for pointers that can hover. 'none' turns overflow handling off and lets the tabs spill out of the strip. The selected tab is always scrolled back into view.",
default: "'auto'",
},
{
name: 'children',
type: 'ReactNode',
Expand Down Expand Up @@ -88,7 +96,7 @@ export const docs = {
'TabList provides tab-style navigation for organizing content into categorized sections. Use it to let users switch between related views without leaving the page, with overflow items handled by a built-in "more" menu.',
bestPractices: [
{ guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
{ guidance: true, description: 'Use TabMenu to group overflow items when horizontal space is limited rather than scrolling tabs off-screen.' },
{ guidance: true, description: 'Leave overflow handling on: a strip narrower than its tabs scrolls, and the selected tab is kept in view. Use TabMenu when you want a curated group of extra options rather than a scrolling strip.' },
{ guidance: true, description: 'When using hasDivider with action buttons alongside tabs, match the Button size to the TabList size (both md, both sm); the divided tab strip reserves space so tabs and same-size buttons align to a shared baseline above the rail.' },
{ guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
{ guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
Expand All @@ -109,7 +117,7 @@ export const docsZh = {
'TabList provides tab-style navigation for organizing content into categorized sections. Use it to let users switch between related views without leaving the page, with overflow items handled by a built-in "more" menu.',
bestPractices: [
{ guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
{ guidance: true, description: 'Use TabMenu to group overflow items when horizontal space is limited rather than scrolling tabs off-screen.' },
{ guidance: true, description: 'Leave overflow handling on: a strip narrower than its tabs scrolls, and the selected tab is kept in view. Use TabMenu when you want a curated group of extra options rather than a scrolling strip.' },
{ guidance: true, description: 'When using hasDivider with action buttons alongside tabs, match the Button size to the TabList size (both md, both sm); the divided tab strip reserves space so tabs and same-size buttons align to a shared baseline above the rail.' },
{ guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
{ guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
Expand All @@ -131,7 +139,7 @@ export const docsDense = {
'TabList provides tab-style navigation for organizing content into categorized sections. Use it to let users switch between related views without leaving the page, with overflow items handled by a built-in "more" menu.',
bestPractices: [
{ guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
{ guidance: true, description: 'Use TabMenu to group overflow items when horizontal space is limited rather than scrolling tabs off-screen.' },
{ guidance: true, description: 'Leave overflow handling on: a strip narrower than its tabs scrolls, and the selected tab is kept in view. Use TabMenu when you want a curated group of extra options rather than a scrolling strip.' },
{ guidance: true, description: 'When using hasDivider with action buttons alongside tabs, match the Button size to the TabList size (both md, both sm); the divided tab strip reserves space so tabs and same-size buttons align to a shared baseline above the rail.' },
{ guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
{ guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
Expand Down
Loading
Loading