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/apps/storybook/stories/BottomSheetSwitcher.stories.tsx b/apps/storybook/stories/BottomSheetSwitcher.stories.tsx index 2fbbc1259c5be..73809d78b982e 100644 --- a/apps/storybook/stories/BottomSheetSwitcher.stories.tsx +++ b/apps/storybook/stories/BottomSheetSwitcher.stories.tsx @@ -43,12 +43,15 @@ interface NotificationOverviewSheetProps { height: NotificationSheetHeight; onCancel: () => void; onContinue: () => void; + /** Pushes a stacked help sheet above this step (activeSheets flows). */ + onHelp?: () => void; } function NotificationOverviewSheet({ height, onCancel, onContinue, + onHelp, }: NotificationOverviewSheetProps) { return ( + {onHelp != null && ( + + + + + + + + + + + + + ); +} + +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(issuesLayer.getAttribute('style')).toContain( + 'blur(1px) brightness(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}> + * + *