From 8900e2bb7b9492053579c434102800985d857b7d Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 9 Nov 2026 15:45:26 -0800 Subject: [PATCH] feat(lab): TabBar shows a More tab past five tabs With more than five tabs, the bar shows the first four and a More tab, as UITabBarController does, at the five-tab layout. More is the selected tab while `value` is `moreValue` or any tab past the fourth. Choosing More again while it holds a tab is a reselect, back to its list. TabBarMoreList, a Lab component in its own folder, is More's screen: one row per tab past the fourth, with its icon, label, Core's error Badge and a disclosure chevron. Choosing a row calls `onChange`. A disabled tab's row is disabled too, so it never activates there either. The More label is Core's `@astryx.tabBar.more` message. TabBarMoreList's route joins the ShadCN route lock and its entry the CLI's official packages. --- .changeset/cli-discover-tab-bar-more-list.md | 7 + .../stories/TabBarMoreList.stories.tsx | 175 ++++++++++++++++++ internal/shadcn-registry/routes.lock.json | 5 + .../assets/discover/official-packages.json | 4 + packages/lab/src/TabBar/TabBar.doc.mjs | 18 +- packages/lab/src/TabBar/TabBar.test.tsx | 161 ++++++++++++++++ packages/lab/src/TabBar/TabBar.tsx | 91 +++++++-- .../TabBar.interaction.browser.spec.ts | 170 ++++++++++++++++- .../src/TabBarMoreList/TabBarMoreList.doc.mjs | 48 +++++ .../lab/src/TabBarMoreList/TabBarMoreList.tsx | 97 ++++++++++ packages/lab/src/TabBarMoreList/index.ts | 13 ++ packages/lab/src/index.ts | 3 + 12 files changed, 772 insertions(+), 20 deletions(-) create mode 100644 .changeset/cli-discover-tab-bar-more-list.md create mode 100644 apps/storybook/stories/TabBarMoreList.stories.tsx create mode 100644 packages/lab/src/TabBarMoreList/TabBarMoreList.doc.mjs create mode 100644 packages/lab/src/TabBarMoreList/TabBarMoreList.tsx create mode 100644 packages/lab/src/TabBarMoreList/index.ts diff --git a/.changeset/cli-discover-tab-bar-more-list.md b/.changeset/cli-discover-tab-bar-more-list.md new file mode 100644 index 0000000000000..eb8d723bef1d6 --- /dev/null +++ b/.changeset/cli-discover-tab-bar-more-list.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[experimental] `astryx discover` lists the experimental TabBarMoreList component from `@astryxdesign/lab`. + +@vjeux diff --git a/apps/storybook/stories/TabBarMoreList.stories.tsx b/apps/storybook/stories/TabBarMoreList.stories.tsx new file mode 100644 index 0000000000000..b3ec0b1050cd3 --- /dev/null +++ b/apps/storybook/stories/TabBarMoreList.stories.tsx @@ -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 = { + // The bar floats over the bottom edge: declared on LayerProvider, menus, + // popovers and toasts clear it. + decorators: [ + Story => ( + + + + ), + ], + title: 'Lab/TabBarMoreList', + component: TabBarMoreList, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================================================================= +// 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 ( +
+

{title}

+

Reselected {reselects} times

+ {Array.from({length: 40}, (_, i) => ( +

+ {title} row {i + 1} +

+ ))} +
+ ); +} + +function MoreDemo({disabled = []}: {disabled?: string[]}) { + const [value, setValue] = useState('home'); + const [reselects, setReselects] = useState(0); + const tabs = SIX.map(section => ( + } + selectedIcon={} + 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' ? ( +
+

More

+ + {tabs} + +
+ ) : inMore != null ? ( +
+
+ ) : ( + section.value === value)?.label ?? ''} + reselects={reselects} + /> + )} + { + // 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} + + + ); +} + +/** + * 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: () => , +}; + +/** Six tabs, Help disabled: its More row is dimmed and never chosen. */ +export const MoreDisabledRow: Story = { + render: () => , +}; diff --git a/internal/shadcn-registry/routes.lock.json b/internal/shadcn-registry/routes.lock.json index f01c2537df300..9154ec80bb742 100644 --- a/internal/shadcn-registry/routes.lock.json +++ b/internal/shadcn-registry/routes.lock.json @@ -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", diff --git a/packages/cli/assets/discover/official-packages.json b/packages/cli/assets/discover/official-packages.json index 910381263ec77..8f7cce56619a1 100644 --- a/packages/cli/assets/discover/official-packages.json +++ b/packages/cli/assets/discover/official-packages.json @@ -200,6 +200,10 @@ "kind": "component", "name": "TabBarItem" }, + { + "kind": "component", + "name": "TabBarMoreList" + }, { "kind": "component", "name": "ThreeDBar" diff --git a/packages/lab/src/TabBar/TabBar.doc.mjs b/packages/lab/src/TabBar/TabBar.doc.mjs index 93972c6609a65..80e7433094d83 100644 --- a/packages/lab/src/TabBar/TabBar.doc.mjs +++ b/packages/lab/src/TabBar/TabBar.doc.mjs @@ -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', @@ -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'", + }, ], }, { @@ -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: diff --git a/packages/lab/src/TabBar/TabBar.test.tsx b/packages/lab/src/TabBar/TabBar.test.tsx index 051d7c80ca4b4..71c5c2222b103 100644 --- a/packages/lab/src/TabBar/TabBar.test.tsx +++ b/packages/lab/src/TabBar/TabBar.test.tsx @@ -16,6 +16,7 @@ import {fireEvent, render, screen, waitFor} from '@testing-library/react'; import {InternationalizationProvider} from '@astryxdesign/core/i18n'; import {TOUCH_QUERY} from '@astryxdesign/core/internal/touch'; import {TabBar, TabBarItem, type TabBarProps} from './TabBar'; +import {TabBarMoreList} from '../TabBarMoreList/TabBarMoreList'; function Glyph({name}: {name: string}) { return ; @@ -563,3 +564,163 @@ describe('TabBar disabled tab', () => { expect(onChange).toHaveBeenCalledWith('profile'); }); }); + +describe('TabBar More tab', () => { + const SIX = ['home', 'library', 'browse', 'profile', 'settings', 'help']; + const tabs = SIX.map(value => ( + } + /> + )); + + it('shows the first four tabs and More when there are more than five', () => { + render( + {}} label="Sections"> + {tabs} + , + ); + expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual([ + 'Home', + 'Library', + 'Browse', + 'Profile', + 'More', + ]); + }); + + it('names the More tab and its list with one catalog message, so a translation can change it', () => { + render( + + {}} label="Sections"> + {tabs} + + {}}>{tabs} + , + ); + expect(screen.getByRole('tab', {name: 'Others'})).toBeInTheDocument(); + expect(screen.getByRole('list', {name: 'Others'})).toBeInTheDocument(); + }); + + it('selects More for a tab it holds, and chooses More with its value', () => { + const onChange = vi.fn(); + const {rerender} = render( + + {tabs} + , + ); + expect(screen.getByRole('tab', {name: 'More'})).toHaveAttribute( + 'aria-selected', + 'true', + ); + rerender( + + {tabs} + , + ); + fireEvent.click(screen.getByRole('tab', {name: 'More'})); + expect(onChange).toHaveBeenCalledWith('more'); + }); + + it('More again while it holds a tab is a reselect, back to its list', () => { + const onChange = vi.fn(); + const onReselect = vi.fn(); + render( + + {tabs} + , + ); + fireEvent.click(screen.getByRole('tab', {name: 'More'})); + expect(onReselect).toHaveBeenCalledWith('more'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('shows a tab’s badge in its More row, spoken with the row', () => { + render( + {}}> + {[ + ...tabs.slice(0, 4), + } + />, + } + badge={2} + />, + ]} + , + ); + expect(screen.getByRole('button', {name: /Help, 2/})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: /Help/})).toHaveTextContent('2'); + }); + + it('lists the tabs past the fourth, and a row chooses its tab', () => { + const onChange = vi.fn(); + render( + + {tabs} + , + ); + const list = screen.getByRole('list', {name: 'More'}); + expect(list).toHaveTextContent('Settings'); + expect(list).toHaveTextContent('Help'); + expect(list).not.toHaveTextContent('Profile'); + fireEvent.click(screen.getByRole('button', {name: /Settings/})); + expect(onChange).toHaveBeenCalledWith('settings'); + }); + + it('reads the selected tab’s More row as selected, and no other row', () => { + render( + {}}> + {tabs} + , + ); + const rows = screen.getAllByRole('listitem'); + const help = rows.find(row => row.textContent?.includes('Help')); + const settings = rows.find(row => row.textContent?.includes('Settings')); + expect(help).toHaveAttribute('aria-current', 'true'); + expect(settings).not.toHaveAttribute('aria-current'); + }); + + it('keeps a disabled tab past the fourth disabled in its More row, which a tap never chooses (spec:AST-085 FR2)', () => { + const onChange = vi.fn(); + render( + + {[ + ...tabs.slice(0, 5), + } + aria-disabled + />, + ]} + , + ); + const help = screen.getByRole('button', {name: /Help/}); + // Core's disabled row: `aria-disabled` on the row, its button inert. + expect(help.closest('.astryx-item')).toHaveAttribute( + 'aria-disabled', + 'true', + ); + fireEvent.click(help); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', {name: /Settings/})); + expect(onChange).toHaveBeenCalledWith('settings'); + }); +}); diff --git a/packages/lab/src/TabBar/TabBar.tsx b/packages/lab/src/TabBar/TabBar.tsx index c716f0a8206c2..619419b503b93 100644 --- a/packages/lab/src/TabBar/TabBar.tsx +++ b/packages/lab/src/TabBar/TabBar.tsx @@ -7,7 +7,8 @@ * @input React, StyleX, Core's theme tokens and hooks, TabBar's touch layer * (TabBarTouch.ts), its geometry (tabBarModel.ts), its UIKit values * (uikitTabBar.ts) and its panel scroll (panelScroll.ts) - * @output TabBar (iOS 26's floating tab bar) and TabBarItem + * @output TabBar (iOS 26's floating tab bar), TabBarItem, and the tab reading + * TabBarMoreList shares (overflowItems, badgedName) * @position Lab component: the phone presentation of an app's top-level * sections, UITabBarController's counterpart * @@ -39,6 +40,7 @@ import { typographyVars, } from '@astryxdesign/core/theme/tokens.stylex'; import {useTranslator} from '@astryxdesign/core/i18n'; +import {Icon} from '@astryxdesign/core/Icon'; import {mergeProps, themeProps} from '@astryxdesign/core/utils'; import {tabBarItemMarker} from './tabBar.stylex'; import {tabBar} from './uikitTabBar'; @@ -110,6 +112,13 @@ export interface TabBarProps extends Omit< 'aria-label'?: string; /** Names the tab list after another element; it wins over both. */ 'aria-labelledby'?: string; + /** + * The More tab's value, when there are more than five tabs. The bar shows the + * first four and a More tab, which `value` selects as this or as any tab past + * the fourth (UIKit's `moreNavigationController`). + * @default 'more' + */ + moreValue?: string; /** The tabs: `TabBarItem` elements, in order. */ children: ReactNode; } @@ -130,15 +139,44 @@ function readItems(children: ReactNode): TabBarItemProps[] { ); } -function isDisabledItem(item: TabBarItemProps | undefined): boolean { +/** Whether a tab is disabled, read as Core's Tab reads `aria-disabled`. */ +export function isDisabledItem(item: TabBarItemProps | undefined): boolean { const disabled = item?.['aria-disabled']; return disabled === true || disabled === 'true'; } -function hasBadge(badge: string | number | undefined): boolean { +/** More than this many tabs and the bar shows the first four and More. */ +const MAX_TABS = 5; + +/** + * The tabs past the fourth when there are more than five: the ones a More + * tab holds. + */ +export function overflowItems(children: ReactNode): TabBarItemProps[] { + const items = readItems(children); + return items.length > MAX_TABS ? items.slice(MAX_TABS - 1) : []; +} + +export function hasBadge(badge: string | number | undefined): boolean { return badge != null && badge !== ''; } +/** + * A badged item's accessible name: one message with its label and badge, so + * a tab and its More row read the same. Undefined without a badge. + */ +export function badgedName( + item: TabBarItemProps, + t: ReturnType, +): string | undefined { + return hasBadge(item.badge) + ? t('@astryx.tabBar.badgedItem', { + label: item.label, + badge: item.badgeLabel ?? String(item.badge), + }) + : undefined; +} + // ============================================================================= // Geometry, as CSS on the capsule (a size container: 100cqw is its width) // ============================================================================= @@ -510,6 +548,9 @@ function ItemFace({ * Each button spans to halfway to its neighbours, so a press goes to the * nearest item. While the lens moves under a finger, a copy of the items rides * in it, so an item half under the lens is half tinted. + * + * Past five tabs, More takes the fifth slot at the five-tab layout, as + * UITabBarController does. */ /** * The iOS 26 tab bar: the app's top-level sections as a floating capsule at @@ -530,6 +571,7 @@ export function TabBar({ label, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, + moreValue = 'more', children, xstyle, className, @@ -544,9 +586,28 @@ export function TabBar({ ariaLabelledBy != null ? undefined : (ariaLabel ?? label ?? t('@astryx.tabList.label')); - const items = readItems(children); + const allItems = readItems(children); + const overflow = overflowItems(children); + const hasMore = overflow.length > 0; + // UITabBarController shows four tabs and More past five. + const items: TabBarItemProps[] = hasMore + ? [ + ...allItems.slice(0, MAX_TABS - 1), + { + value: moreValue, + label: t('@astryx.tabBar.more'), + icon: , + }, + ] + : allItems; const count = items.length; - const selectedIndex = items.findIndex(item => item.value === value); + // iOS keeps More selected while it shows a tab it holds. + const isMoreSelected = + hasMore && + (value === moreValue || overflow.some(item => item.value === value)); + const selectedIndex = isMoreSelected + ? MAX_TABS - 1 + : items.findIndex(item => item.value === value); const baseId = useId(); // UIKit's motion belongs to a finger (a coarse primary pointer, iOS and @@ -565,9 +626,14 @@ export function TabBar({ if (isDisabledItem(item)) { return; } - if (item.value === value) { + // More again while it holds a tab returns its list, as iOS pops More's + // navigation stack: a reselect. Only the selected tab scrolls its panel. + if ( + item.value === value || + (item.value === moreValue && isMoreSelected) + ) { const document = capsuleRef.current?.ownerDocument; - if (document != null) { + if (item.value === value && document != null) { scrollPanelToTop(document, item.panelId); } onReselect?.(item.value); @@ -575,7 +641,7 @@ export function TabBar({ onChange(item.value); } }, - [value, onChange, onReselect], + [value, onChange, onReselect, moreValue, isMoreSelected], ); const lens = useTabBarLens({ capsuleRef, @@ -634,14 +700,7 @@ export function TabBar({ isSelected || (selectedIndex < 0 && index === 0) ? 0 : -1 } // A badge is part of the name: one message with both. - aria-label={ - hasBadge(item.badge) - ? t('@astryx.tabBar.badgedItem', { - label: item.label, - badge: item.badgeLabel ?? String(item.badge), - }) - : undefined - } + aria-label={badgedName(item, t)} data-tab-bar-value={item.value} onClick={() => choose(item)} {...mergeProps( diff --git a/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts b/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts index 187ca2ed60abc..c83a5ab83c68c 100644 --- a/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts +++ b/packages/lab/src/TabBar/__tests__/TabBar.interaction.browser.spec.ts @@ -175,9 +175,9 @@ const liftedAt = (page: Page) => () => (window as unknown as {__tabBarLifted?: number}).__tabBarLifted ?? 0, ); -async function openBar(page: Page, story = 'default') { +async function openBar(page: Page, story = 'default', title = 'lab-tabbar') { await page.addInitScript(animationLog); - await openStory(page, storybook(), `lab-tabbar--${story}`); + await openStory(page, storybook(), `${title}--${story}`); const driver = await touchDriver(page); const bar = page.getByRole('tablist', {name: 'Sections'}); await expect(bar).toBeVisible(); @@ -3152,3 +3152,169 @@ test.describe('a disabled tab under a mouse', () => { await expect(tab(page, 'Profile')).toBeFocused(); }); }); + +/** Flips the story to right to left: the tab bar's own direction ancestor. */ +async function rightToLeft(page: Page) { + await page.evaluate(() => { + document + .querySelector('[role="tablist"]') + ?.closest('[dir]') + ?.setAttribute('dir', 'rtl'); + }); + await page.evaluate( + () => + new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); +} + +/** The More tab's stories live under TabBarMoreList's own title. */ +const MORE_LIST = 'lab-tabbarmorelist'; + +test.describe('More tab', () => { + test('six tabs: the bar shows the first four and More at the five-tab layout, as UIKit does', async ({ + page, + }) => { + await openBar(page, 'more-tab', MORE_LIST); + const tabs = page.getByRole('tab'); + await expect(tabs).toHaveCount(5); + await expect(tabs.last()).toHaveAccessibleName('More'); + // iOS 26.2 on a 402 pt screen: five 77 pt items at x 25, 93.75, 162.5, + // 231.25 and 300. + const viewport = page.viewportSize(); + const lefts: number[] = []; + for (let index = 0; index < 5; index += 1) { + const box = await tabs.nth(index).locator('span').first().boundingBox(); + if (box == null) { + throw new Error('no item'); + } + // Within the item-width fit's 1 px (tabBar.ts `itemWidth`). + expect(Math.abs(box.width - 77)).toBeLessThan(1); + lefts.push(box.x); + } + if (viewport?.width === 402) { + [25, 93.75, 162.5, 231.25, 300].forEach((x, index) => { + expect(Math.abs(lefts[index] - x)).toBeLessThan(1); + }); + } + }); + + test('with Reduce Motion a tap on More changes the lens by opacity only, with no slide', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + const {driver} = await openBar(page, 'more-tab', MORE_LIST); + const more = await itemCenter(page, 'More'); + const since = await timelineNow(page); + await driver.tap(more); + // The fade starts with the input; what it changes follows a few + // frames later. + await expectCrossFade(page, since); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + assertOpacityOnly(await runningAnimations(page, since)); + // Nothing travels, so the lens carries no copy: it lands on More, and + // More draws its own selected look. + await waitForRest(lensBody(page)); + await expect(lensCopy(page)).toHaveCount(0); + expect(Math.abs((await lensCenterX(page)) - more.x)).toBeLessThan(0.5); + expect(await labelWeight(page, 'More')).toBe('600'); + }); + + test('a section chosen in More opens inside it, More staying selected; More again returns to the list', async ({ + page, + }) => { + const {driver} = await openBar(page, 'more-tab', MORE_LIST); + const more = await itemCenter(page, 'More'); + await driver.tap(more); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + const settings = page.getByRole('button', {name: /Settings/}); + await expect(settings).toBeVisible(); + await settings.click(); + await expect(page.getByRole('heading', {name: 'Settings'})).toBeVisible(); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + await waitForRest(page.getByRole('tablist')); + expect(Math.abs((await lensCenterX(page)) - more.x)).toBeLessThan(0.5); + await driver.tap(more); + await expect(page.getByRole('heading', {name: 'More'})).toBeVisible(); + }); + + test('a disabled tab past the fourth stays disabled in the More list: a tap on its row chooses nothing (spec:AST-085 FR2)', async ({ + page, + }) => { + const {driver} = await openBar(page, 'more-disabled-row', MORE_LIST); + await driver.tap(await itemCenter(page, 'More')); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + const help = page.getByRole('button', {name: /Help/}); + await expect(page.locator('.astryx-item', {has: help})).toHaveAttribute( + 'aria-disabled', + 'true', + ); + const box = await help.boundingBox(); + if (box == null) { + throw new Error('no Help row'); + } + await driver.tap({x: box.x + box.width / 2, y: box.y + box.height / 2}); + // The list stays: no section opened inside More. + await expect(page.getByRole('heading', {name: 'More'})).toBeVisible(); + await expect(page.getByRole('heading', {name: 'Help'})).toHaveCount(0); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + // Its enabled neighbour still opens. + await page.getByRole('button', {name: /Settings/}).click(); + await expect(page.getByRole('heading', {name: 'Settings'})).toBeVisible(); + }); + + test('right to left, the More list’s disclosure chevrons point left, as Core’s disclosure rows’ do', async ({ + page, + }) => { + const {driver} = await openBar(page, 'more-tab', MORE_LIST); + await rightToLeft(page); + await driver.tap(await itemCenter(page, 'More')); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + const settings = page.getByRole('button', {name: /Settings/}); + await expect(settings).toBeVisible(); + // On screen first: the row's chevron. + await expectPainted( + page.locator('li', {has: settings}).locator('svg').last(), + ); + // The row's last glyph is its chevron (beside the label's button): flipped + // on it or a wrapper. + const transform = await settings.evaluate(button => { + const row = button.closest('li') ?? button; + const glyphs = [...row.querySelectorAll('svg')]; + for ( + let node: Element | null = glyphs[glyphs.length - 1] ?? null; + node != null && node !== row; + node = node.parentElement + ) { + const value = getComputedStyle(node).transform; + if (value !== 'none') { + return value; + } + } + return 'none'; + }); + expect(transform).toBe('matrix(-1, 0, 0, 1, 0, 0)'); + }); + + test.describe('under a mouse', () => { + test.use({hasTouch: false, isMobile: false}); + + test('More works the same, the lens on the theme’s motion', async ({ + page, + }) => { + await expectFinePointer(page); + await openStory(page, storybook(), `${MORE_LIST}--more-tab`); + const more = await itemCenter(page, 'More'); + await tab(page, 'More').click(); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + await page.getByRole('button', {name: /Settings/}).click(); + await expect(page.getByRole('heading', {name: 'Settings'})).toBeVisible(); + await expect(tab(page, 'More')).toHaveAttribute('aria-selected', 'true'); + await expect + .poll(async () => Math.abs((await lensCenterX(page)) - more.x)) + .toBeLessThan(0.5); + await expectCssSlide(page, {isRunning: false}); + }); + }); +}); diff --git a/packages/lab/src/TabBarMoreList/TabBarMoreList.doc.mjs b/packages/lab/src/TabBarMoreList/TabBarMoreList.doc.mjs new file mode 100644 index 0000000000000..f604e5e92ef6b --- /dev/null +++ b/packages/lab/src/TabBarMoreList/TabBarMoreList.doc.mjs @@ -0,0 +1,48 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */ + +export const docs = { + name: 'TabBarMoreList', + displayName: 'Tab Bar More List', + category: 'Navigation', + keywords: ['more tab', 'more list', 'overflow tabs', 'tab bar', 'more'], + description: + "The More tab's screen, as on iOS: one row per tab past the fourth, with its icon, its label and a disclosure chevron. Give it the same TabBarItem children as TabBar, and render it while TabBar's value is its moreValue. A disabled tab's row is disabled too. Rearranging the tabs (iOS's Edit) is not available yet. Experimental: lands in lab first.", + props: [ + { + name: 'children', + type: 'ReactNode', + description: 'The same TabBarItem elements given to TabBar.', + required: true, + }, + { + name: 'onChange', + type: '(value: string) => void', + description: + "Called with a row's value when it is chosen: show that section inside More, which stays selected in the bar.", + required: true, + }, + { + name: 'value', + type: 'string', + description: "The selected tab's value: its row reads as selected.", + }, + ], + usage: { + description: + "Render TabBarMoreList as the More tab's screen while TabBar's value is its moreValue, with the same TabBarItem children. Show a section chosen there inside More's own navigation stack.", + bestPractices: [ + { + guidance: true, + description: + 'Give it the same tabs, in the same order, as the TabBar it belongs to.', + }, + { + guidance: false, + description: + "Don't use it as a general menu or list; use List for those.", + }, + ], + }, +}; diff --git a/packages/lab/src/TabBarMoreList/TabBarMoreList.tsx b/packages/lab/src/TabBarMoreList/TabBarMoreList.tsx new file mode 100644 index 0000000000000..7097deb979575 --- /dev/null +++ b/packages/lab/src/TabBarMoreList/TabBarMoreList.tsx @@ -0,0 +1,97 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file TabBarMoreList.tsx + * @input React, core's List, Icon and Badge, core i18n, and TabBar.tsx's + * tab reading (`overflowItems`) + * @output Exports TabBarMoreList: the More tab's screen, a list of the tabs + * past the fourth (UIKit's `UIMoreListController`) + * @position Lab component: the screen of TabBar's More tab (../TabBar) + * + * SYNC: When modified, update TabBarMoreList.doc.mjs, + * ../TabBar/TabBar.test.tsx and + * ../TabBar/__tests__/TabBar.interaction.browser.spec.ts. + */ + +import type {ReactNode} from 'react'; +import type {BaseProps} from '@astryxdesign/core'; +import {Badge} from '@astryxdesign/core/Badge'; +import {useTranslator} from '@astryxdesign/core/i18n'; +import {Icon} from '@astryxdesign/core/Icon'; +import {List, ListItem} from '@astryxdesign/core/List'; +import {rtlStyles} from '@astryxdesign/core/utils'; +import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden'; +import { + badgedName, + hasBadge, + isDisabledItem, + overflowItems, +} from '../TabBar/TabBar'; + +export interface TabBarMoreListProps extends Omit< + BaseProps, + 'onChange' +> { + /** The same `TabBarItem` elements given to TabBar. */ + children: ReactNode; + /** The selected tab's value: its row reads as selected. */ + value?: string; + /** Called with a row's value when it is chosen. */ + onChange: (value: string) => void; +} + +/** + * The More tab's screen: a list of the tabs past the fourth. + * + * @example + * ``` + * {tab === 'more' ? ( + * {tabs} + * ) : null} + * {tabs} + * ``` + */ +// The app passes TabBar's own children, so both agree on which tabs overflow. +export function TabBarMoreList({ + children, + value, + onChange, + ...props +}: TabBarMoreListProps) { + const t = useTranslator(); + return ( + + {overflowItems(children).map(item => ( + + {item.label} + {badgedName(item, t)} + + ) + } + startContent={item.icon} + endContent={ + <> + {hasBadge(item.badge) ? ( + + ) : null} + {/* Right to left it points left, as Core's disclosure rows' do. */} + + + } + isSelected={item.value === value} + isDisabled={isDisabledItem(item)} + onClick={() => onChange(item.value)} + /> + ))} + + ); +} diff --git a/packages/lab/src/TabBarMoreList/index.ts b/packages/lab/src/TabBarMoreList/index.ts new file mode 100644 index 0000000000000..ab349aa2e56d9 --- /dev/null +++ b/packages/lab/src/TabBarMoreList/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file index.ts + * @input Imports from TabBarMoreList.tsx + * @output Re-exports TabBarMoreList and its props type + * @position Barrel export; consumed by packages/lab/src/index.ts + */ + +export {TabBarMoreList} from './TabBarMoreList'; +export type {TabBarMoreListProps} from './TabBarMoreList'; diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index 2047b020245b1..60016bf7844a9 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -360,6 +360,9 @@ export { type TabBarProps, } from './TabBar'; +// TabBarMoreList: the screen of TabBar's More tab (UIMoreListController). +export {TabBarMoreList, type TabBarMoreListProps} from './TabBarMoreList'; + // RichTextEditor (RFC meta/astryx#3899) moved out of @astryxdesign/lab into // its own package, @astryxdesign/richtext. Import it from there: // import {RichTextEditor, RichTextView} from '@astryxdesign/richtext';