From 4d5804a20f4cbaed2eebfe2dbc22aafd23645e6c Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Mon, 28 Sep 2026 16:57:06 -0700 Subject: [PATCH 1/6] feat(core): BottomSheetSwitcher ordered activeSheets path (AST-044) Add the controlled activeSheets list API to BottomSheetSwitcher per spec:AST-044 (vibe-test winner: list form): - activeSheets: ReadonlyArray is the canonical bottom-to-top path; [] closes, append pushes a drill-in sheet, dropping the final id pops, replacing a suffix changes branch. Only the top id is interactive. - onActiveSheetsChange(nextIds, {reason: 'escape'|'scrim'|'swipe', dismissedSheetId}) reports implicit one-level dismissal; close-all stays an explicit caller update. - Released singular activeSheet/onActiveSheetChange stays a length <= 1 projection with identical shipped behavior (including the single-level enter/align/fade handoff); supplying both forms warns in dev and the list form wins. - Covered sheets stay mounted, inert, aria-hidden, and recede with a bounded stack transform; push records covered focus, pop restores it. - Invalid ids (empty, repeated, unknown, ambiguous registration) present the longest valid prefix, warn once per condition in dev, and never rewrite the controlled value. - finalFocusRef names the preferred focus destination after [] + exit; modal flows fall back to the captured opener, non-modal closes never steal focus that left the flow. - hasScrim keeps its released showModal()/show() coupling at every depth; no modality prop, no new component, index exports unchanged. --- .../bottom-sheet-switcher-active-sheets.md | 23 + packages/core/src/BottomSheet/BottomSheet.tsx | 118 +++- .../BottomSheet/BottomSheetSwitcher.doc.mjs | 67 ++- .../BottomSheet/BottomSheetSwitcher.spec.md | 107 ++-- .../BottomSheet/BottomSheetSwitcher.test.tsx | 359 +++++++++++- .../src/BottomSheet/BottomSheetSwitcher.tsx | 523 +++++++++++++++--- .../BottomSheet/BottomSheetSwitcherContext.ts | 21 +- 7 files changed, 1075 insertions(+), 143 deletions(-) create mode 100644 .changeset/bottom-sheet-switcher-active-sheets.md diff --git a/.changeset/bottom-sheet-switcher-active-sheets.md b/.changeset/bottom-sheet-switcher-active-sheets.md new file mode 100644 index 0000000000000..b19b98f7bc93c --- /dev/null +++ b/.changeset/bottom-sheet-switcher-active-sheets.md @@ -0,0 +1,23 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Add the BottomSheetSwitcher ordered-path API (`activeSheets`). + +`BottomSheetSwitcher` now accepts a controlled `activeSheets` list — the +bottom-to-top path of open sheet ids — plus `onActiveSheetsChange(nextIds, +{reason, dismissedSheetId})` and `finalFocusRef`. Appending an id pushes a +drill-in sheet above the current one (which stays mounted, inert, and visually +receded), removing the final id pops back and restores that sheet's recorded +focus, replacing a suffix changes branch, and `[]` closes the flow. Only the +top sheet is interactive; implicit dismissal (Escape, modal-scrim click, or +swipe, per the top sheet's `purpose`) requests the path without its final id. +Invalid, repeated, unknown, or ambiguously registered ids present the longest +valid prefix and warn in development. + +The change is additive: the released singular `activeSheet` / +`onActiveSheetChange` pair keeps its exact shipped behavior as a length ≤ 1 +projection of the list, `hasScrim` keeps its released meaning at every path +length, and no exports change. + +@imdreamrunner diff --git a/packages/core/src/BottomSheet/BottomSheet.tsx b/packages/core/src/BottomSheet/BottomSheet.tsx index 40cddbb32efd3..5152b4751236d 100644 --- a/packages/core/src/BottomSheet/BottomSheet.tsx +++ b/packages/core/src/BottomSheet/BottomSheet.tsx @@ -10,9 +10,10 @@ * * BottomSheet selects one of two focused hosts. A standalone host owns its * native dialog lifecycle; a switcher item participates in the parent's shared - * dialog and transition state machine. Both render the same BottomSheetPanel, - * which owns sheet presentation, gestures, mobile-keyboard accommodation, and - * motion completion. + * dialog, ordered sheet path, and transition state machine — registering its + * id, receding while covered, and recording covered focus for a later pop. + * Both render the same BottomSheetPanel, which owns sheet presentation, + * gestures, mobile-keyboard accommodation, and motion completion. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/BottomSheet/BottomSheetPanel.tsx @@ -38,7 +39,12 @@ import { import * as stylex from '@stylexjs/stylex'; import type {BaseProps} from '../BaseProps'; import type {DialogPurpose} from '../Dialog'; -import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex'; +import { + colorVars, + durationVars, + easeVars, + spacingVars, +} from '../theme/tokens.stylex'; import {useDevWarning, useScrollLock} from '../hooks'; import {isImeKeyEvent} from '../utils'; import { @@ -56,6 +62,22 @@ import { export type {BottomSheetHeight, BottomSheetSnapPoint} from './BottomSheetPanel'; import type {BottomSheetHeight, BottomSheetSnapPoint} from './BottomSheetPanel'; +// Covered sheets recede behind the top sheet with a bounded visual depth: +// every covered level stays mounted and inert, but only the nearest levels +// remain visually distinguishable (spec:AST-044/FR25). The exact geometry is +// stack-owned and internal; it is not a public theming surface. +const STACK_VISUAL_DEPTH_LIMIT = 2; +const STACK_SCALE_STEP = 0.04; + +function transformForStackDepth(depth: number): string { + const visualDepth = Math.min(STACK_VISUAL_DEPTH_LIMIT, Math.max(0, depth)); + if (visualDepth === 0) { + return 'translateY(0) scale(1)'; + } + const scale = (1 - visualDepth * STACK_SCALE_STEP).toFixed(2); + return `translateY(calc(${spacingVars['--spacing-2']} * -${visualDepth})) scale(${scale})`; +} + const styles = stylex.create({ dialog: { position: 'fixed', @@ -125,6 +147,24 @@ const styles = stylex.create({ positionerTop: { zIndex: 1, }, + // Stacked-path positioner treatment: recede and return use state plus + // CSS-native motion only, with no per-frame measurement (spec:AST-044/FR43). + positionerStackMotion: { + transformOrigin: '50% 0', + transitionProperty: 'transform', + transitionDuration: durationVars['--duration-medium'], + transitionTimingFunction: easeVars['--ease-standard'], + willChange: 'transform', + '@media (prefers-reduced-motion: reduce)': { + transitionDuration: '0.01s', + }, + }, + positionerStackTransform: (transform: string) => ({ + transform, + }), + positionerStackLayer: (zIndex: number) => ({ + zIndex, + }), }); interface BottomSheetSharedProps extends BaseProps { @@ -413,11 +453,15 @@ function SwitcherBottomSheetItem({ ...props }: SwitcherBottomSheetItemProps) { const { - activeSheet, + topSheet, hasScrim, - onActiveSheetChange, + isStackedFlow, + requestSwipeDismiss, getSheetPhase, getSheetAlignmentOffset, + getSheetDepth, + getSheetStackLayer, + registerSheetPresence, registerSheetElement, registerSheetLabel, registerSheetPurpose, @@ -430,6 +474,10 @@ function SwitcherBottomSheetItem({ const alignmentOffset = hasValidSheetId ? getSheetAlignmentOffset(sheetId) : 0; + const stackDepth = + hasValidSheetId && isStackedFlow ? getSheetDepth(sheetId) : 0; + const stackLayer = + hasValidSheetId && isStackedFlow ? getSheetStackLayer(sheetId) : 0; const panelState = panelStateForSwitcherPhase(phase, alignmentOffset); const isInteractive = phase === 'active' || phase === 'entering'; const isInactive = @@ -440,6 +488,7 @@ function SwitcherBottomSheetItem({ const isPresented = phase !== 'hidden'; const isTopSheet = phase === 'active' || phase === 'entering'; const panelRef = useRef(null); + const coveredFocusRef = useRef(null); const previousPhaseRef = useRef(phase); const previousPhase = previousPhaseRef.current; const hasPresentedRef = useRef(false); @@ -448,11 +497,39 @@ function SwitcherBottomSheetItem({ previousPhaseRef.current = phase; }, [phase]); + // Participating destinations register with the nearest controller so the + // controlled path can validate its ids against exactly-one mounted sheet. + useLayoutEffect(() => { + if (!hasValidSheetId) { + return; + } + return registerSheetPresence(sheetId); + }, [hasValidSheetId, registerSheetPresence, sheetId]); + + // A push records the focused element of the sheet it covers before focus + // moves into the new top. The record is discarded once the sheet leaves + // presentation, so a later registration lifetime cannot inherit it. + useLayoutEffect(() => { + const wasInteractive = + previousPhase === 'active' || previousPhase === 'entering'; + if (wasInteractive && phase === 'covered' && stackDepth > 0) { + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLElement && + panelRef.current?.contains(activeElement) + ) { + coveredFocusRef.current = activeElement; + } + } else if (phase === 'hidden') { + coveredFocusRef.current = null; + } + }, [phase, previousPhase, stackDepth]); + const dismissOnSwipe = useCallback(() => { - if (purpose === 'info' && hasValidSheetId && activeSheet === sheetId) { - onActiveSheetChange(null); + if (purpose === 'info' && hasValidSheetId && topSheet === sheetId) { + requestSwipeDismiss(sheetId); } - }, [activeSheet, hasValidSheetId, onActiveSheetChange, purpose, sheetId]); + }, [hasValidSheetId, purpose, requestSwipeDismiss, sheetId, topSheet]); const handlePanelElementChange = useCallback( (element: HTMLDivElement | null) => { panelRef.current = element; @@ -508,7 +585,20 @@ function SwitcherBottomSheetItem({ const wasInteractive = previousPhase === 'active' || previousPhase === 'entering'; if (!hasPresentedRef.current || !wasInteractive) { - focusPanel(panelRef.current, hasScrim); + // A pop restores the covered sheet's recorded focus while it remains + // connected and owned by this sheet; otherwise focus entry follows + // the shared autofocus rule. + const coveredFocus = coveredFocusRef.current; + coveredFocusRef.current = null; + if ( + coveredFocus != null && + coveredFocus.isConnected && + panelRef.current?.contains(coveredFocus) === true + ) { + coveredFocus.focus({preventScroll: true}); + } else { + focusPanel(panelRef.current, hasScrim); + } } hasPresentedRef.current = true; } else if (phase === 'hidden') { @@ -527,8 +617,14 @@ function SwitcherBottomSheetItem({
0 && + styles.positionerStackTransform(transformForStackDepth(stackDepth)), + isStackedFlow + ? styles.positionerStackLayer(stackLayer) + : isTopSheet && styles.positionerTop, !isPresented && styles.positionerHidden, - isTopSheet && styles.positionerTop, )} hidden={!isPresented} aria-hidden={isInactive ? 'true' : undefined} diff --git a/packages/core/src/BottomSheet/BottomSheetSwitcher.doc.mjs b/packages/core/src/BottomSheet/BottomSheetSwitcher.doc.mjs index 840f31295dfe6..9ba0e2bc67539 100644 --- a/packages/core/src/BottomSheet/BottomSheetSwitcher.doc.mjs +++ b/packages/core/src/BottomSheet/BottomSheetSwitcher.doc.mjs @@ -12,7 +12,7 @@ const anatomy = [ name: 'Sheet panels', required: true, description: - 'Direct BottomSheet children; exactly one is interactive while a previous panel may remain visible and inert during a handoff.', + 'Direct BottomSheet children; exactly one (the last id in activeSheets) is interactive while covered or retained panels stay visible and inert.', }, { name: 'Scrim', @@ -142,7 +142,7 @@ export const docs = { }, }, description: - 'Coordinates multiple BottomSheets as a mutually exclusive flow. One activeSheet ID selects the only interactive sheet; during a handoff, the new sheet enters above the inert previous sheet. If the new sheet is shorter, the previous sheet simultaneously moves down until their top edges align, then fades after both transforms complete. The switcher owns one shared native : modal flows call showModal() once for one top-layer boundary and one ::backdrop across the whole flow, while no-scrim flows use a non-modal show() shell. Its ref and shared DOM props target that dialog.', + 'Coordinates multiple BottomSheets in one shared native through a controlled ordered path. The canonical activeSheets list is ordered bottom-to-top: [] closes the flow, one id presents a single sheet, appending an id pushes a drill-in sheet above the current one (which stays mounted, inert, and visually receded), removing the final id pops back, and replacing a suffix changes branch. Only the last id is interactive. The released singular activeSheet remains supported as a length ≤ 1 projection with the shipped handoff: the new sheet enters above the inert previous sheet, aligns a taller one downward, then fades it. Modal flows call showModal() once for one top-layer boundary and one ::backdrop across the whole flow, while no-scrim flows use a non-modal show() shell. Its ref and shared DOM props target that dialog.', props: [ { name: 'ref', @@ -155,27 +155,43 @@ export const docs = { description: 'Called before the switcher handles a native dialog cancel request. Calling preventDefault() keeps the controlled flow open.', }, + { + name: 'activeSheets', + type: 'ReadonlyArray', + description: + 'Ordered bottom-to-top path of open BottomSheet ids — the canonical controlled value. [] closes the flow; the last id is the only interactive sheet while covered ids stay mounted, inert, and receded. Append one id to push a drill-in sheet, drop the final id to pop, replace a suffix to change branch, and set [] to close all. Ids must be non-empty, unique, and match nested sheetId values; presentation stops before the first invalid id and warns in development. Supply with onActiveSheetsChange instead of the singular form.', + }, + { + name: 'onActiveSheetsChange', + type: "(nextIds: ReadonlyArray, details: {reason: 'escape' | 'scrim' | 'swipe', dismissedSheetId: string}) => void", + description: + "Called when the top sheet requests an implicit dismissal permitted by its purpose: Escape or platform close ('escape'), a visible modal-scrim click ('scrim'), or a completed swipe ('swipe'). One dismissal removes one visible level, so nextIds is the presented path without its final id. Close-all stays an explicit caller update to [].", + }, { name: 'activeSheet', type: 'string | null', description: - "ID of the interactive BottomSheet, or null when the flow should close. Match a nested BottomSheet's unique sheetId; the previous sheet may remain visually present and inert while the new sheet enters, simultaneously align downward behind a shorter step, then fade away.", - required: true, + 'ID of the interactive BottomSheet, or null when the flow should close — the released singular form, equivalent to activeSheets of length ≤ 1 (null ⇔ []). Existing single-sheet flows keep their shipped behavior unchanged. Prefer activeSheets for new flows; supplying both forms warns in development and the list form wins.', }, { name: 'onActiveSheetChange', type: '(activeSheet: string | null) => void', description: - "Called with null when the active sheet dismisses according to its purpose. Child BottomSheets may use purpose='form' or purpose='required' to limit implicit dismissal while flow controls can still use the same state setter to switch sheets or close the flow.", - required: true, + "The singular counterpart of onActiveSheetsChange: called with nextIds.at(-1) ?? null (and no details) when the active sheet dismisses according to its purpose. Child BottomSheets may use purpose='form' or purpose='required' to limit implicit dismissal while flow controls can still use the same state setter to switch sheets or close the flow.", }, { name: 'hasScrim', type: 'boolean', description: - "Whether the shared dialog is modal. true uses showModal() once for one native ::backdrop, focus trap, scroll lock, and click-to-dismiss when the active BottomSheet has purpose='info'. false uses show() with no backdrop and leaves the page interactive; avoid transformed, contained, or clipping ancestors because the non-modal dialog remains in its containing context.", + "Whether the shared dialog is modal, at every path length. true uses showModal() once for one native ::backdrop, focus trap, scroll lock, and click-to-dismiss when the top BottomSheet has purpose='info'. false uses show() with no backdrop and leaves the page interactive; avoid transformed, contained, or clipping ancestors because the non-modal dialog remains in its containing context.", default: 'true', }, + { + name: 'finalFocusRef', + type: 'RefObject', + description: + 'Preferred focus destination after the path reaches [] and exit completes. Use when routing may replace the element that opened the flow; an absent or disconnected target falls back to the opener captured when a modal flow opened. A non-modal close never steals focus that already moved outside the flow.', + }, { name: 'children', type: 'ReactNode', @@ -186,7 +202,7 @@ export const docs = { usage: { anatomy, description: - "Coordinates a multi-step bottom-sheet flow in one shared dialog; set activeSheet to a nested BottomSheet's sheetId to open or switch steps, and to null to close.", + 'Coordinates a multi-step or drill-in bottom-sheet flow in one shared dialog; set activeSheets to the ordered path of nested sheetId values (append to push, slice to pop, [] to close), or use the released singular activeSheet for one sheet at a time.', bestPractices: [ { guidance: true, @@ -206,7 +222,7 @@ export const docs = { { guidance: false, description: - "Don't use the switcher when multiple panels must stay interactive or visible together; activeSheet intentionally selects one interactive step.", + "Don't use the switcher when multiple panels must stay interactive together; the path intentionally keeps one interactive sheet, and covered levels are inert context, not parallel workspaces.", }, ], }, @@ -235,6 +251,33 @@ export const docs = { + + + + + + + + + + + + ); +} + +describe('BottomSheetSwitcher ordered path', () => { + it('opens, pushes, and keeps only the top sheet interactive', () => { + render(); + fireEvent.click(screen.getByRole('button', {name: 'Open issues'})); + const issuesLayer = getSheetLayer('issues-sheet'); + const detailsLayer = getSheetLayer('issue-details-sheet'); + + expect(issuesLayer).not.toHaveAttribute('hidden'); + expect(issuesLayer).not.toHaveAttribute('inert'); + expect(detailsLayer).toHaveAttribute('hidden'); + expect(getSharedDialog()).toHaveAccessibleName('Issues'); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', {name: 'Open issue'})); + + // Only the top sheet is interactive; the covered level stays mounted, + // inert, accessibility-hidden, and receded by the stack transform. + expect(getSharedDialog()).toHaveAccessibleName('Issue details'); + expect(issuesLayer).not.toHaveAttribute('hidden'); + expect(issuesLayer).toHaveAttribute('inert'); + expect(issuesLayer).toHaveAttribute('aria-hidden', 'true'); + expect(issuesLayer.getAttribute('style')).toContain('scale(0.96)'); + expect(detailsLayer).not.toHaveAttribute('hidden'); + expect(detailsLayer).not.toHaveAttribute('inert'); + expect(detailsLayer.getAttribute('style') ?? '').not.toContain('scale(0.9'); + + // A push swaps panels inside the one already-open shared dialog. + expect(document.querySelectorAll('dialog[open]')).toHaveLength(1); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + }); + + it('pops one level, restores covered focus, and exits only the former top', () => { + render(); + fireEvent.click(screen.getByRole('button', {name: 'Open issues'})); + const openIssueButton = screen.getByRole('button', {name: 'Open issue'}); + openIssueButton.focus(); + fireEvent.click(openIssueButton); + + const issuesLayer = getSheetLayer('issues-sheet'); + const detailsLayer = getSheetLayer('issue-details-sheet'); + expect(getSheetPanel(detailsLayer)).toHaveFocus(); + + fireEvent.click(screen.getByRole('button', {name: 'Back'})); + + // The revealed sheet is active again and regains its recorded focus; the + // former top stays visible but inert through its exit. + expect(issuesLayer).not.toHaveAttribute('inert'); + expect(detailsLayer).not.toHaveAttribute('hidden'); + expect(detailsLayer).toHaveAttribute('inert'); + expect(openIssueButton).toHaveFocus(); + + finishSheetTransition(detailsLayer, 'transform'); + expect(detailsLayer).toHaveAttribute('hidden'); + expect(getSharedDialog()).toHaveAttribute('open'); + expect(document.querySelectorAll('dialog[open]')).toHaveLength(1); + }); + + it('requests one-level dismissal with details from Escape and scrim', () => { + const onActiveSheetsChange = vi.fn(); + render( + + + Content + + + Content + + , + ); + const dialog = getSharedDialog(); + + fireEvent.keyDown(dialog, {key: 'Escape'}); + expect(onActiveSheetsChange).toHaveBeenNthCalledWith(1, ['issues'], { + reason: 'escape', + dismissedSheetId: 'issue-details', + }); + + fireEvent.click(dialog); + expect(onActiveSheetsChange).toHaveBeenNthCalledWith(2, ['issues'], { + reason: 'scrim', + dismissedSheetId: 'issue-details', + }); + + // The caller kept the prior value both times: the same top stays + // presented and each gesture was reported exactly once. + expect(onActiveSheetsChange).toHaveBeenCalledTimes(2); + expect(dialog).toHaveAttribute('open'); + expect(dialog).toHaveAccessibleName('Issue details'); + }); + + it('reports a completed top-sheet swipe with swipe details', () => { + const onActiveSheetsChange = vi.fn(); + render( + + + Content + + + Content + + , + ); + const detailsPanel = getSheetPanel(getSheetLayer('issue-details-sheet')); + const handle = detailsPanel.firstElementChild; + if (!(handle instanceof HTMLElement)) { + throw new Error('sheet handle not found'); + } + + fireEvent.pointerDown(handle, { + pointerId: 1, + clientY: 0, + button: 0, + isPrimary: true, + }); + fireEvent.pointerMove(handle, {pointerId: 1, clientY: 40}); + fireEvent.pointerMove(handle, {pointerId: 1, clientY: 120}); + fireEvent.pointerUp(handle, {pointerId: 1, clientY: 120}); + + expect(onActiveSheetsChange).toHaveBeenCalledWith(['issues'], { + reason: 'swipe', + dismissedSheetId: 'issue-details', + }); + }); + + it('keeps the released singular form as a projection of the list', () => { + const onActiveSheetChange = vi.fn(); + render( + + + Content + + , + ); + + // The singular projection presents one sheet with no stack treatment. + const issuesLayer = getSheetLayer('issues-sheet'); + expect(issuesLayer.getAttribute('style') ?? '').not.toContain('scale('); + expect(getSharedDialog()).toHaveAccessibleName('Issues'); + + // Dismissal reaches the singular callback as `next.at(-1) ?? null`. + fireEvent.keyDown(getSharedDialog(), {key: 'Escape'}); + expect(onActiveSheetChange).toHaveBeenCalledTimes(1); + expect(onActiveSheetChange).toHaveBeenCalledWith(null); + }); + + it('warns when both forms are supplied and lets the list form win', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + render( + {}} + activeSheet="issues" + onActiveSheetChange={() => {}}> + + Content + + + Content + + , + ); + + expect(getSharedDialog()).toHaveAccessibleName('Issue details'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('the list form wins'), + ); + } finally { + warn.mockRestore(); + } + }); + + it('presents the longest valid prefix and warns for an unknown id', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + render( + {}}> + + Content + + + Content + + , + ); + + // Presentation stops before the first invalid id: the valid ancestor + // stays interactive and the disconnected suffix never presents. + expect(getSharedDialog()).toHaveAccessibleName('Issues'); + expect(getSheetLayer('issues-sheet')).not.toHaveAttribute('hidden'); + expect(getSheetLayer('issues-sheet')).not.toHaveAttribute('inert'); + expect(getSheetLayer('issue-details-sheet')).toHaveAttribute('hidden'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('activeSheets[1]'), + ); + } finally { + warn.mockRestore(); + } + }); + + it('keeps an ambiguously registered id unavailable', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + render( + {}}> + + Content + + + Content + + + Content + + , + ); + + // Neither duplicate wins by source or mount order. + expect(getSharedDialog()).toHaveAccessibleName('Issues'); + expect(getSheetLayer('dup-a-sheet')).toHaveAttribute('hidden'); + expect(getSheetLayer('dup-b-sheet')).toHaveAttribute('hidden'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('more than one'), + ); + } finally { + warn.mockRestore(); + } + }); + + it('presents a deep initial path with focus only in the top sheet', () => { + render(); + const issuesLayer = getSheetLayer('issues-sheet'); + const detailsLayer = getSheetLayer('issue-details-sheet'); + + expect(issuesLayer).not.toHaveAttribute('hidden'); + expect(issuesLayer).toHaveAttribute('inert'); + expect(issuesLayer).toHaveAttribute('aria-hidden', 'true'); + expect(detailsLayer).not.toHaveAttribute('inert'); + expect(getSheetPanel(detailsLayer)).toHaveFocus(); + expect(getSharedDialog()).toHaveAccessibleName('Issue details'); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + }); + + it('closes the whole path with one exit while covered levels hide', () => { + render(); + const issuesLayer = getSheetLayer('issues-sheet'); + const detailsLayer = getSheetLayer('issue-details-sheet'); + + fireEvent.click(screen.getByRole('button', {name: 'Close all'})); + + // Close-all converges: the last visible panel exits coherently and the + // covered level hides without its own choreography. + const dialog = getSharedDialog(); + expect(issuesLayer).toHaveAttribute('hidden'); + expect(detailsLayer).not.toHaveAttribute('hidden'); + expect(detailsLayer).toHaveAttribute('inert'); + expect(dialog).toHaveAttribute('open'); + + finishSheetTransition(detailsLayer, 'transform'); + expect(dialog).not.toHaveAttribute('open'); + }); + + it('returns focus to finalFocusRef after the path closes', () => { + render(); + const opener = screen.getByRole('button', {name: 'Open issues'}); + opener.focus(); + fireEvent.click(opener); + + fireEvent.click(screen.getByRole('button', {name: 'Close all'})); + finishSheetTransition(getSheetLayer('issues-sheet'), 'transform'); + + // An eligible finalFocusRef target wins over the captured opener. + expect( + screen.getByRole('button', {name: 'Final focus target'}), + ).toHaveFocus(); + expect(opener).not.toHaveFocus(); + }); +}); diff --git a/packages/core/src/BottomSheet/BottomSheetSwitcher.tsx b/packages/core/src/BottomSheet/BottomSheetSwitcher.tsx index cf8bfc472cf06..a6f54ac3c1b99 100644 --- a/packages/core/src/BottomSheet/BottomSheetSwitcher.tsx +++ b/packages/core/src/BottomSheet/BottomSheetSwitcher.tsx @@ -6,14 +6,19 @@ * @file BottomSheetSwitcher.tsx * @input Uses React context, StyleX, theme tokens, shared layer dismissal, focus/scroll-lock hooks, BottomSheetSwitcherContext * @output Exports BottomSheetSwitcher and BottomSheetSwitcherProps - * @position Core switcher for mutually exclusive BottomSheet flows + * @position Core controller for mutually exclusive and stacked BottomSheet flows * - * The switcher turns a set of declaratively nested BottomSheets into a - * controlled single-selection group: `activeSheet` names the one interactive - * child, or is null when the flow is closed. During a handoff the new sheet - * enters above the previous sheet. If it is shorter, the previous sheet moves - * down at the same time until their top edges align; otherwise it stays - * stationary. The previous sheet fades only after both motions complete. + * The switcher turns declaratively nested BottomSheets into one controlled + * flow. The canonical value is `activeSheets`, an ordered bottom-to-top path + * of sheet ids: `[]` is closed, one id is the released single-sheet flow, and + * a longer path presents a drill-in stack where only the last id is + * interactive while covered sheets stay mounted, inert, and receded. The + * released singular `activeSheet` remains supported as a length ≤ 1 + * projection of the same list. Single-level replacement keeps the released + * handoff: the new sheet enters above the previous sheet, a taller previous + * sheet aligns down to a shorter entering sheet, and it fades only after both + * motions complete. Ids that are empty, repeated, unregistered, or ambiguous + * cut presentation to the longest valid prefix and warn in development. * * All child sheets render as panels inside one switcher-owned ``. A * scrim flow calls showModal() once and keeps that native top-layer dialog open @@ -23,6 +28,7 @@ * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/BottomSheet/BottomSheet.tsx * - /packages/core/src/BottomSheet/BottomSheetEdgeTint.tsx + * - /packages/core/src/BottomSheet/BottomSheetSwitcherContext.ts * - /packages/core/src/BottomSheet/BottomSheetSwitcher.doc.mjs * - /packages/core/src/BottomSheet/BottomSheetSwitcher.spec.md * - /packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx @@ -33,6 +39,7 @@ import { useCallback, + useEffect, useLayoutEffect, useMemo, useRef, @@ -46,7 +53,7 @@ import * as stylex from '@stylexjs/stylex'; import type {BaseProps} from '../BaseProps'; import type {DialogPurpose} from '../Dialog'; import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex'; -import {useScrollLock} from '../hooks'; +import {useDevWarning, useScrollLock} from '../hooks'; import { useFocusTrap, useFocusTrapEscapeCompatibilitySignal, @@ -55,6 +62,7 @@ import {LayerDepthProvider} from '../Layer/LayerDepthContext'; import {dispatchLayerEscapeKeyDown} from '../Layer/layerStack'; import {useLayerDismissal} from '../Layer/useLayerDismissal'; import {composeEventHandlers, mergeProps} from '../utils'; +import {devWarn} from '../utils/devWarning'; import {BottomSheetEdgeTint} from './BottomSheetEdgeTint'; import { BottomSheetSwitcherContext, @@ -139,31 +147,151 @@ const IDLE_TRANSITION: SheetTransitionState = { const ALIGNMENT_THRESHOLD_PX = 1; -function transitionForActiveSheetChange( - previousSheet: string | null, - nextSheet: string | null, +const EMPTY_SHEET_PATH: ReadonlyArray = []; + +type SwitcherDismissReason = 'escape' | 'scrim' | 'swipe'; + +function sheetPathsEqual( + first: ReadonlyArray, + second: ReadonlyArray, +): boolean { + return ( + first.length === second.length && + first.every((sheetId, index) => sheetId === second[index]) + ); +} + +function isPathPrefix( + prefix: ReadonlyArray, + path: ReadonlyArray, +): boolean { + return prefix.every((sheetId, index) => sheetId === path[index]); +} + +/** + * Chooses the animated treatment for a presented-path change. Only the + * single-suffix edits promise motion: a push enters above the newly covered + * level, a pop exits the former top, a close from any depth exits the last + * visible panel, and a single-level replacement keeps the released handoff. + * Every other edit (multi-pop, branch replacement, reorder, deep link) + * converges to the requested path without bespoke choreography. + */ +function transitionForSheetPathChange( + previousSheets: ReadonlyArray, + nextSheets: ReadonlyArray, ): SheetTransitionState { - if (previousSheet == null) { + const previousTop = previousSheets[previousSheets.length - 1] ?? null; + const nextTop = nextSheets[nextSheets.length - 1] ?? null; + if (previousTop == null || previousTop === nextTop) { return IDLE_TRANSITION; } - if (nextSheet == null) { + if (nextTop == null) { + return { + enteringSheet: null, + retainedSheet: previousTop, + retainedPhase: 'exiting', + alignmentOffset: 0, + isAlignmentComplete: false, + }; + } + if ( + nextSheets.length === previousSheets.length + 1 && + isPathPrefix(previousSheets, nextSheets) + ) { + // Push: the previous top stays in the path as a covered level, so no + // retained transition state is needed for it. + return { + enteringSheet: nextTop, + retainedSheet: null, + retainedPhase: null, + alignmentOffset: 0, + isAlignmentComplete: false, + }; + } + if ( + previousSheets.length === nextSheets.length + 1 && + isPathPrefix(nextSheets, previousSheets) + ) { + // Pop: only the former top exits; the revealed level returns beneath it. return { enteringSheet: null, - retainedSheet: previousSheet, + retainedSheet: previousTop, retainedPhase: 'exiting', alignmentOffset: 0, isAlignmentComplete: false, }; } + if (previousSheets.length === 1 && nextSheets.length === 1) { + // Released single-level handoff choreography. + return { + enteringSheet: nextTop, + retainedSheet: previousTop, + retainedPhase: 'covered', + alignmentOffset: 0, + isAlignmentComplete: false, + }; + } + return IDLE_TRANSITION; +} + +type SheetPathInvalidReason = + 'empty-id' | 'repeated-id' | 'unknown-id' | 'duplicate-registration'; + +interface SheetPathValidation { + presentedSheets: ReadonlyArray; + invalidIndex: number; + invalidReason: SheetPathInvalidReason | null; +} + +/** + * Cuts the requested path to the longest leading prefix whose ids are + * non-empty, unique within the value, and resolve to exactly one mounted + * participating sheet. Presentation stops before the first invalid id; the + * controlled value itself is never rewritten. + */ +function validateSheetPath( + requestedSheets: ReadonlyArray, + registrationCounts: ReadonlyMap, +): SheetPathValidation { + const seenSheetIds = new Set(); + for (let index = 0; index < requestedSheets.length; index++) { + const sheetId = requestedSheets[index] ?? ''; + let reason: SheetPathInvalidReason | null = null; + if (sheetId.length === 0) { + reason = 'empty-id'; + } else if (seenSheetIds.has(sheetId)) { + reason = 'repeated-id'; + } else { + const registrations = registrationCounts.get(sheetId) ?? 0; + if (registrations === 0) { + reason = 'unknown-id'; + } else if (registrations > 1) { + reason = 'duplicate-registration'; + } + } + if (reason != null) { + return { + presentedSheets: requestedSheets.slice(0, index), + invalidIndex: index, + invalidReason: reason, + }; + } + seenSheetIds.add(sheetId); + } return { - enteringSheet: nextSheet, - retainedSheet: previousSheet, - retainedPhase: 'covered', - alignmentOffset: 0, - isAlignmentComplete: false, + presentedSheets: requestedSheets, + invalidIndex: -1, + invalidReason: null, }; } +const INVALID_PATH_MESSAGES: Record = { + 'empty-id': 'is an empty id', + 'repeated-id': 'repeats an id used earlier in the path', + 'unknown-id': 'does not match a mounted BottomSheet sheetId', + 'duplicate-registration': 'matches more than one mounted BottomSheet', +}; + function alignmentOffsetForElements( enteringElement: HTMLElement | undefined, retainedElement: HTMLElement | undefined, @@ -185,16 +313,49 @@ export interface BottomSheetSwitcherProps extends BaseProps { /** Called when the shared native dialog receives a cancel event. */ onCancel?: (event: SyntheticEvent) => void; + /** + * Ordered bottom-to-top path of open BottomSheet ids — the canonical + * controlled value. `[]` closes the flow, one id presents a single sheet, + * appending one id pushes a drill-in sheet above the current one, removing + * the final id reveals the previous sheet, and replacing a suffix changes + * branch. Only the last id is interactive; covered ids stay mounted, inert, + * and visually receded. Ids must be non-empty, unique, and match mounted + * BottomSheet `sheetId` values; presentation stops before the first invalid + * id. Supply together with `onActiveSheetsChange` instead of the singular + * `activeSheet` form. + */ + activeSheets?: ReadonlyArray; + + /** + * Called with the next path when the top sheet requests an implicit + * dismissal — Escape or platform close, modal-scrim activation, or a + * completed swipe, as permitted by the top sheet's `purpose`. One dismissal + * removes one visible level: `nextIds` is the presented path without its + * final id, and `details` carries the initiating channel and the dismissed + * sheet id. Close-all remains an explicit caller update to `[]`. + */ + onActiveSheetsChange?: ( + nextIds: ReadonlyArray, + details: { + reason: 'escape' | 'scrim' | 'swipe'; + dismissedSheetId: string; + }, + ) => void; + /** * ID of the interactive BottomSheet, or null when the flow should close. - * Must match a nested BottomSheet's `sheetId`. The previous sheet may remain - * visually present and inert while the new sheet enters, moving downward at - * the same time if the new sheet is shorter, then fade away. + * The released singular form, equivalent to `activeSheets` of length ≤ 1 + * (`null` ⇔ `[]`). Prefer `activeSheets` for new flows; supplying both + * forms warns in development and the list form wins. */ - activeSheet: string | null; + activeSheet?: string | null; - /** Called with null when the active sheet requests dismissal. */ - onActiveSheetChange: (sheetId: string | null) => void; + /** + * Called with `null` when the active sheet requests dismissal — the + * singular counterpart of `onActiveSheetsChange`, receiving + * `nextIds.at(-1) ?? null` and no details. + */ + onActiveSheetChange?: (sheetId: string | null) => void; /** * Whether to open the shared dialog modally with its native ::backdrop. @@ -203,29 +364,56 @@ export interface BottomSheetSwitcherProps extends BaseProps { */ hasScrim?: boolean; + /** + * Preferred focus destination after the path reaches `[]` and exit + * completes. Use when routing may replace the element that opened the + * flow. An absent or disconnected target falls back to the opener captured + * when a modal flow opened; a non-modal close never steals focus that has + * already moved outside the flow. + */ + finalFocusRef?: React.RefObject; + /** BottomSheets identified by unique `sheetId` values. */ children: ReactNode; } /** - * Coordinates a set of BottomSheets so zero or one is active at a time inside - * one shared native dialog. During a handoff the previous panel stays visible - * and inert beneath the entering panel, then fades after motion completes. + * Coordinates a set of BottomSheets inside one shared native dialog. The + * controlled `activeSheets` path opens, stacks, and closes sheets: only the + * last id is interactive, covered sheets stay mounted and inert with a + * receded treatment, and implicit dismissal pops one visible level. The + * released singular `activeSheet` form remains a length ≤ 1 projection with + * the shipped single-sheet behavior, including the enter-above handoff. * * @example * ``` + * const [activeSheets, setActiveSheets] = useState([]); + * * - * … - * … + * activeSheets={activeSheets} + * onActiveSheetsChange={setActiveSheets}> + * + *