diff --git a/.changeset/bottom-sheet-unstable-stack-seam.md b/.changeset/bottom-sheet-unstable-stack-seam.md new file mode 100644 index 0000000000000..9becf3cbcd5c5 --- /dev/null +++ b/.changeset/bottom-sheet-unstable-stack-seam.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] `BottomSheet` and `BottomSheetSwitcher` read internal, `unstable_`-prefixed stack contexts (`@astryxdesign/core/BottomSheet/unstable_stack`) that let an external host present several sheets inside one shared dialog; a sheet under such a host registers itself with it from wherever it is rendered. Without such a host the contexts are null and both components behave exactly as before. The subpath is Lab-only coordination with no stability promise and is not part of the documented API. + +@imdreamrunner diff --git a/apps/storybook/stories/BottomSheetStack.stories.tsx b/apps/storybook/stories/BottomSheetStack.stories.tsx new file mode 100644 index 0000000000000..97ca1cdd33a1b --- /dev/null +++ b/apps/storybook/stories/BottomSheetStack.stories.tsx @@ -0,0 +1,833 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +/** + * @file BottomSheetStack.stories.tsx + * @input Uses BottomSheetStack (Lab) with Core BottomSheet and + * BottomSheetSwitcher levels at phone width + * @output Storybook scenarios: a drill-in with Close all, a deep link at + * depth 3, a switcher base with a help overlay, a list with a switcher top, + * a mapped file browser of unknown depth, sheets rendered anywhere in the + * tree (wrapper, Suspense, a row that opens its own detail sheet, a Core + * Dialog above the stack), and a nested stack that merges into the outer one + * @position Lab story; stacked Core bottom sheets + */ +import {Suspense, use, useState, type ReactNode} from 'react'; +import type {Meta, StoryObj} from '@storybook/react'; +import {BottomSheet, BottomSheetSwitcher} from '@astryxdesign/core/BottomSheet'; +import {Button} from '@astryxdesign/core/Button'; +import {Dialog} from '@astryxdesign/core/Dialog'; +import {Divider} from '@astryxdesign/core/Divider'; +import {Heading} from '@astryxdesign/core/Heading'; +import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList'; +import {Section} from '@astryxdesign/core/Section'; +import {HStack, VStack} from '@astryxdesign/core/Stack'; +import {Text} from '@astryxdesign/core/Text'; +import {TextInput} from '@astryxdesign/core/TextInput'; +import {BottomSheetStack} from '@astryxdesign/lab'; + +const meta: Meta = { + title: 'Lab/BottomSheetStack', + component: BottomSheetStack, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + viewport: {defaultViewport: 'mobile1'}, + docs: { + story: {inline: false, height: '560px'}, + }, + }, + argTypes: { + ref: {control: false}, + onCancel: {control: false}, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// Orders: list → order → edit, with Close all at every level +// --------------------------------------------------------------------------- + +const ORDERS = [ + {id: '1042', title: 'Trail runners', status: 'Shipped'}, + {id: '1043', title: 'Rain shell', status: 'Processing'}, + {id: '1044', title: 'Wool socks ×3', status: 'Delivered'}, +]; + +function OrdersFlow({deepLink = false}: {deepLink?: boolean}) { + const [isListOpen, setIsListOpen] = useState(deepLink); + const [orderId, setOrderId] = useState( + deepLink ? ORDERS[0].id : null, + ); + const [isEditing, setIsEditing] = useState(deepLink); + const [note, setNote] = useState('Leave at the side door'); + const order = ORDERS.find(entry => entry.id === orderId) ?? null; + + const closeAll = () => { + setIsListOpen(false); + setOrderId(null); + setIsEditing(false); + }; + + return ( + + Orders + + A drill-in: the list opens an order, the order opens an editor. Each + level is a standalone BottomSheet; Close all closes every level in one + update. + + + + , + ); + host.place({A: 1}, null); + expect(transformOf('a')).toBe( + 'translateY(calc(var(--spacing-2) * -1)) scale(0.96)', + ); + expect(getPanel('a')).not.toHaveFocus(); + host.place({A: 2}, null); + expect(transformOf('a')).toBe( + 'translateY(calc(var(--spacing-2) * -2)) scale(0.92)', + ); + host.place({A: 5}, null); + expect(transformOf('a')).toBe( + 'translateY(calc(var(--spacing-2) * -2)) scale(0.92)', + ); + host.place({A: 0}, 'A'); + expect(transformOf('a')).toBe('translateY(0) scale(1)'); + expect(getPanel('a')).toHaveFocus(); + host.dispose(); + }); + + it('reports a closed level as not open and not presented, then presented through its exit, and unregisters on unmount', () => { + const host = createTestHost(); + const sheet = (isOpen: boolean) => ( + + {}} + label="A" + data-testid="a"> + A + + + ); + const {rerender, unmount} = render(sheet(false)); + expect(host.entry('A')?.state).toMatchObject({ + isOpen: false, + isPresented: false, + }); + host.place({A: 0}, null); + expect(getPositioner('a')).toHaveAttribute('hidden'); + rerender(sheet(true)); + expect(host.entry('A')?.state).toMatchObject({ + isOpen: true, + isPresented: true, + }); + rerender(sheet(false)); + expect(host.entry('A')?.state).toMatchObject({ + isOpen: false, + isPresented: true, + }); + fireEvent.transitionEnd(getPanel('a'), {propertyName: 'transform'}); + expect(host.entry('A')?.state).toMatchObject({ + isOpen: false, + isPresented: false, + }); + unmount(); + expect(host.entries()).toEqual([]); + expect(host.log.at(-1)).toBe('unregister sheet'); + host.dispose(); + }); + + it('StrictMode leaves one registration per level', () => { + const host = createTestHost(); + render( + + + {}} label="A" data-testid="a"> + A + + + , + ); + expect(host.entries()).toHaveLength(1); + expect(host.log.filter(l => l === 'register sheet')).toHaveLength(2); + expect(host.log.filter(l => l === 'unregister sheet')).toHaveLength(1); + expect(host.slot.children).toHaveLength(1); + host.dispose(); + }); + + it('a sheet inside a level’s content registers as a child of that level', () => { + const host = createTestHost(); + render( + + {}} + label="Outer" + data-testid="outer"> + {}} + label="Inner" + data-testid="inner"> + Inner + + + , + ); + host.place({Outer: 0}, 'Outer'); + // The inner sheet renders inside the outer panel's content at the same + // layer depth, under the outer level's parent-level context. + const outer = host.entry('Outer'); + const inner = host.entry('Inner'); + expect(inner?.parentId).toBe(outer?.id); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled(); + expect(getPanel('outer')).toContainElement(inner?.anchor as HTMLElement); + host.dispose(); + }); + + it('joins only at the host’s layer depth: a LayerDepthProvider between host and sheet makes the sheet standalone', () => { + const host = createTestHost(); + render( + + {}} + label="Level" + data-testid="level"> + Level + + + {}} + label="Deeper" + data-testid="deeper"> + Deeper + + + , + ); + expect(host.entries().map(e => e.state?.label)).toEqual(['Level']); + const deeperDialog = getPanel('deeper').closest('dialog'); + expect(deeperDialog).toHaveAccessibleName('Deeper'); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + host.dispose(); + }); + + it('as a level, a panel’s gesture handlers ignore React-bubbled events whose native target is outside the panel; standalone panels are unchanged', () => { + const host = createTestHost(); + const parentDismiss = vi.fn(); + render( + + + {}} + label="Child" + data-testid="child"> + Child + + + , + ); + // The child registers once the parent's panel has mounted (one + // placement later), so place twice: the real host does this in one pass. + host.place({Parent: 0}, 'Parent'); + host.place({Parent: 1, Child: 0}, 'Child'); + const parentBody = getPanel('parent').querySelector( + '[aria-label="Parent"]', + ) as HTMLElement; + const childBody = getPanel('child').querySelector( + '[aria-label="Child"]', + ) as HTMLElement; + const parentCapture = vi.fn(); + ( + parentBody as HTMLElement & {setPointerCapture: unknown} + ).setPointerCapture = parentCapture; + expect(getPanel('parent').contains(childBody)).toBe(false); + fireEvent.pointerDown(childBody, { + pointerId: 3, + clientY: 0, + button: 0, + isPrimary: true, + }); + fireEvent.pointerMove(childBody, {pointerId: 3, clientY: 60}); + fireEvent.pointerMove(childBody, {pointerId: 3, clientY: 200}); + fireEvent.pointerUp(childBody, {pointerId: 3, clientY: 200}); + expect(parentCapture).not.toHaveBeenCalled(); + expect(parentDismiss).not.toHaveBeenCalled(); + host.dispose(); + }); + + it('a Core Dialog inside a level pushes its content a layer deeper: a sheet inside it is standalone with its own dialog', () => { + const host = createTestHost(); + render( + + {}} + label="Level" + data-testid="level"> + {}} aria-label="Confirm"> + {}} + label="Inner" + data-testid="inner"> + Inner + + + + , + ); + host.place({Level: 0}, 'Level'); + expect(host.entries().map(e => e.state?.label)).toEqual(['Level']); + const innerDialog = getPanel('inner').closest('dialog'); + expect(innerDialog).toHaveAccessibleName('Inner'); + expect(innerDialog).toHaveAttribute('aria-modal', 'true'); + // The Dialog's showModal and the inner sheet's; the level has none. + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(2); + expect(transformOf('inner')).toBe(''); + host.dispose(); + }); + + it('warns about hasScrim on a stacked sheet and ignores it', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const host = createTestHost(); + render( + + {}} + label="A" + hasScrim={false} + data-testid="a"> + A + + , + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + '`hasScrim` on a BottomSheet or BottomSheetSwitcher inside a BottomSheetStack is ignored', + ), + ); + expect(HTMLDialogElement.prototype.show).not.toHaveBeenCalled(); + host.dispose(); + }); + + it('forwards gesture scrim opacity to the host once placed as the top', () => { + const host = createTestHost(); + render( + + {}} label="A" data-testid="a"> + A + + , + ); + host.place({A: 0}, 'A'); + const handle = getPanel('a').querySelector( + '[aria-hidden="true"]', + ); + if (handle == null) { + throw new Error('handle not found'); + } + fireEvent.pointerDown(handle, { + pointerId: 1, + clientY: 0, + button: 0, + isPrimary: true, + }); + fireEvent.pointerMove(handle, {pointerId: 1, clientY: 40}); + expect(host.log.some(l => l.startsWith('scrim '))).toBe(true); + host.dispose(); + }); + + it('hosts a BottomSheetSwitcher level: registers as a switcher, reports the active sheet, routes dismiss, and its items follow the level view', () => { + const host = createTestHost(); + const onActiveSheetChange = vi.fn(); + function Flow() { + const [activeSheet, setActiveSheet] = useState('a'); + return ( + + { + onActiveSheetChange(id); + setActiveSheet(id); + }}> + + + + + B + + + + ); + } + render(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled(); + const entry = host.entries()[0]; + expect(entry.kind).toBe('switcher'); + expect(entry.state).toMatchObject({ + isOpen: true, + isPresented: true, + purpose: 'info', + finalFocusRef: undefined, + }); + // The items mount once the level is placed; the label they register is + // reported in the same synchronous pass. + host.place({switcher: 0}, 'switcher'); + expect(host.entries()[0].state).toMatchObject({label: 'A'}); + host.place({A: 0}, 'A'); + expect( + document.querySelector('[data-bottom-sheet-switcher-level]'), + ).not.toBeNull(); + expect(host.slot).toContainElement(getPanel('a')); + expect(getPositioner('a')).not.toHaveAttribute('aria-hidden'); + expect(getPanel('a')).toHaveFocus(); + + fireEvent.click(screen.getByRole('button', {name: 'Next'})); + expect(host.entry('B')?.state).toMatchObject({ + purpose: 'required', + label: 'B', + }); + + // Covered by another level: the active item renders covered, in place. + host.place({B: 1}, null); + expect(getPositioner('b')).toHaveAttribute('aria-hidden', 'true'); + expect(getPositioner('b')).toHaveAttribute('inert'); + expect(transformOf('b')).toMatch(/scale\(0\.96\)/); + host.place({B: 0}, 'B'); + expect(getPositioner('b')).not.toHaveAttribute('aria-hidden'); + expect(transformOf('b')).toMatch(/scale\(1\)/); + + act(() => { + host.entry('B')?.state?.dismiss(); + }); + expect(onActiveSheetChange).toHaveBeenCalledWith(null); + expect(host.entries()[0].state).toMatchObject({ + isOpen: false, + isPresented: true, + }); + host.dispose(); + }); + + it('a standalone sheet inside a switcher step registers as a child of the switcher level', () => { + const host = createTestHost(); + render( + + {}}> + + {}} + label="Nested" + data-testid="nested"> + Nested + + + + , + ); + host.place({switcher: 0}, 'switcher'); + const switcher = host.entries().find(e => e.kind === 'switcher'); + const nested = host.entry('Nested'); + expect(nested?.kind).toBe('sheet'); + expect(nested?.parentId).toBe(switcher?.id); + expect(screen.queryByRole('dialog')).toBeNull(); + host.dispose(); + }); + + it('a level whose render is kept but whose effects are torn down leaves the registry', () => { + const host = createTestHost(); + function Probe({children}: {children: ReactNode}) { + const ref = useRef(0); + useLayoutEffect(() => { + ref.current += 1; + }); + return <>{children}; + } + const {unmount} = render( + + + {}} label="A" data-testid="a"> + A + + + , + ); + expect(host.entries()).toHaveLength(1); + unmount(); + expect(host.entries()).toHaveLength(0); + expect(host.slot.children).toHaveLength(0); + host.dispose(); + }); +}); diff --git a/packages/core/src/BottomSheet/unstable_stack.ts b/packages/core/src/BottomSheet/unstable_stack.ts new file mode 100644 index 0000000000000..a89b23dbecfef --- /dev/null +++ b/packages/core/src/BottomSheet/unstable_stack.ts @@ -0,0 +1,392 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file unstable_stack.ts + * @input Uses React, the layer depth context, the Dialog purpose type, and the shared focus-return helper + * @output unstable_ host and parent-level contexts, the depth-checked host reader, the level protocol hook, their types, and the focus-return helper an external host needs to stack BottomSheet levels + * @position Lab-only coordination seam between Core sheet hosts and @astryxdesign/lab BottomSheetStack; not part of the public API + * + * A BottomSheet or BottomSheetSwitcher rendered under a stacking host gives up + * its own native dialog and becomes one level of that host. The host owns the + * dialog, scrim, modal boundary, scroll lock, and dismissal; it never reads + * its children. Each level registers itself from wherever it is rendered — + * through wrappers, DOM elements, Suspense, Activity, providers, portals — and + * reports what the host needs: whether it is open and presented, its purpose, + * label, final-focus target, and a way to close it through its own callback. + * + * Registration happens only in layout effects, keyed by `useId`, so a render + * that never commits registers nothing and StrictMode leaves one entry per + * level. Order is tree order: each level renders a hidden in-place anchor and + * names its parent level through context, and the host sorts siblings by the + * anchors' document position with children right after their parent. Each + * level owns the box its panel portals into, so re-registration (Suspense, + * Activity, StrictMode) keeps the panel and its state. + * + * A sheet joins a host only at the host's own layer depth. The host records + * the depth of its content (`useLayerDepth()` inside its LayerDepthProvider) + * and a sheet compares it with its own; every Core layer that opens its own + * top-layer element (Dialog, AlertDialog, popover-based layers, Lightbox, + * MobileNav) already pushes its content one level deeper, so a sheet opened + * inside such a layer is a normal standalone sheet above it rather than a + * level painted below it, with no change to those layers. A stacked level + * adds no depth around its own content, so a sheet inside a level's content + * joins the stack right above that level. + * + * Without a provider the contexts are null and every Core host behaves + * exactly as it does today: the reads are inert, nothing is registered, and + * no provider is added to Core's render tree. + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/core/src/BottomSheet/BottomSheet.tsx + * - /packages/core/src/BottomSheet/BottomSheetSwitcher.tsx + * - /packages/core/src/BottomSheet/unstable_stack.test.tsx + * - /packages/lab/src/BottomSheetStack/BottomSheetStack.tsx + * - /scripts/sync-exports.js + */ + +import { + createContext, + use, + useCallback, + useId, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, + type RefObject, +} from 'react'; +import type {DialogPurpose} from '../Dialog'; +import {useLayerDepth} from '../Layer/LayerDepthContext'; +import {focusForLastInput} from '../utils/focusReturn'; + +/** + * What kind of Core host a level is. Lab-only coordination for + * `@astryxdesign/lab` BottomSheetStack; no stability promise. + */ +export type unstable_BottomSheetStackLevelKind = 'sheet' | 'switcher'; + +/** + * What a level reports to its host. Lab-only coordination for + * `@astryxdesign/lab` BottomSheetStack; no stability promise. + */ +export interface unstable_BottomSheetStackLevelState { + /** + * Whether the level is open by its own controlled state (`isOpen`, or a + * non-null `activeSheet` naming a mounted sheet). The host derives the open + * set, the top level, and every level's depth from this. + */ + isOpen: boolean; + /** + * Whether the level is on screen, including its exit motion. The host keeps + * its dialog open while any level is presented. + */ + isPresented: boolean; + /** + * Dismissal purpose of the level's interactive sheet: for a switcher, the + * active child's. The host applies it to Escape and scrim interaction. + */ + purpose: DialogPurpose; + /** Accessible name of the level's interactive sheet, for the host dialog. */ + label: string | undefined; + /** + * Element that receives focus when this level's close also closes the host. + * A switcher level has none and reports `undefined`. + */ + finalFocusRef: RefObject | undefined; + /** + * Closes the level through its own controlled callback + * (`onOpenChange(false)` or `onActiveSheetChange(null)`). Identity stays + * stable for the life of the level; purpose gating is the host's. + */ + dismiss: () => void; +} + +/** + * What the host tells one level. Lab-only coordination for + * `@astryxdesign/lab` BottomSheetStack; no stability promise. + */ +export interface unstable_BottomSheetStackLevelView { + /** + * Open levels above this one. `0` is the top; a covered level stays + * mounted, inert, and visibly receded by its depth. + */ + depth: number; + /** Whether this level is the top, interactive level. */ + isTop: boolean; +} + +/** + * How a level registers with its host. Lab-only coordination for + * `@astryxdesign/lab` BottomSheetStack; no stability promise. + */ +export interface unstable_BottomSheetStackLevelRegistration { + /** The level's `useId`, stable for its life. */ + id: string; + kind: unstable_BottomSheetStackLevelKind; + /** + * Hidden element the level renders in place. The host orders sibling + * levels by the anchors' document position. + */ + anchor: Element; + /** Id of the level whose content this level is rendered in, or null. */ + parentId: string | null; + /** + * Level-owned element the level portals its panel into. The host places it + * inside its dialog, in stack order, and marks it inert while covered. + */ + box: HTMLElement; +} + +/** + * What a stacking host provides to every level under it. Lab-only + * coordination for `@astryxdesign/lab` BottomSheetStack; no stability promise. + */ +export interface unstable_BottomSheetStackHostContextValue { + /** Whether the host dialog is modal with a scrim. Levels never set this. */ + hasScrim: boolean; + /** + * Layer depth of the host's content. A sheet joins only when its own + * `useLayerDepth()` equals this: a Core layer opened inside a level pushes + * its content deeper, so a sheet inside it stays standalone above that layer. + */ + layerDepth: number; + /** + * Adds a level. Called from a layout effect; returns the matching removal. + * Registering an id again replaces its registration and keeps its report. + */ + register: ( + registration: unstable_BottomSheetStackLevelRegistration, + ) => () => void; + /** Reports a registered level's current state. Idempotent for an equal state. */ + report: (id: string, state: unstable_BottomSheetStackLevelState) => void; + /** + * Asks the host to re-derive order from the anchors. A level calls it on + * every commit so a move in the tree is followed; the host bails out when + * the order is unchanged. + */ + touch: () => void; + /** + * Forwards a gesture-driven scrim opacity (0..1) from a level's panel to + * the host's shared backdrop. The host honours only its top level. + */ + onScrimOpacity: (id: string, opacity: number) => void; + /** + * The host's current view of every registered level, by id. A level that + * has registered but not yet been placed has no entry. + */ + views: ReadonlyMap; +} + +/** + * Context a stacking host provides once around everything it stacks. Core + * reads it through `unstable_useBottomSheetStackHost`; the default is `null`, + * so without a host nothing changes. + * + * Lab-only coordination for `@astryxdesign/lab` BottomSheetStack; no stability + * promise. + */ +// eslint-disable-next-line @eslint-react/naming-convention-context-name -- the unstable_ prefix marks this Lab-only seam +export const unstable_BottomSheetStackHostContext = + createContext(null); +unstable_BottomSheetStackHostContext.displayName = + 'unstable_BottomSheetStackHostContext'; + +/** + * Id of the level whose content the current subtree is rendered in, or null + * at the host's root. A level provides its own id around its content so a + * sheet rendered inside it stacks right above it. + * + * Lab-only coordination for `@astryxdesign/lab` BottomSheetStack; no stability + * promise. + */ +// eslint-disable-next-line @eslint-react/naming-convention-context-name -- the unstable_ prefix marks this Lab-only seam +export const unstable_BottomSheetStackParentLevelContext = createContext< + string | null +>(null); +unstable_BottomSheetStackParentLevelContext.displayName = + 'unstable_BottomSheetStackParentLevelContext'; + +/** + * The host the current subtree is a level of, or null: no host, or a host + * whose content is at a different layer depth than this subtree (a Core + * layer opened inside a level sits between them). Core hosts and the Lab + * stack itself read the host through this, never through the raw context. + * + * Lab-only coordination for `@astryxdesign/lab` BottomSheetStack; no stability + * promise. + */ +export const unstable_useBottomSheetStackHost: () => unstable_BottomSheetStackHostContextValue | null = + useBottomSheetStackHost; + +function useBottomSheetStackHost(): unstable_BottomSheetStackHostContextValue | null { + const host = use(unstable_BottomSheetStackHostContext); + const depth = useLayerDepth(); + return host != null && host.layerDepth === depth ? host : null; +} + +/** + * The layer depth a host's content renders at when the host wraps it in one + * LayerDepthProvider: what the host publishes as `layerDepth`. Reads the + * ambient depth, so a host inside a Core layer still matches its own levels. + * + * Lab-only coordination for `@astryxdesign/lab` BottomSheetStack; no stability + * promise. + */ +export const unstable_useBottomSheetStackContentDepth: () => number = + useBottomSheetStackContentDepth; + +function useBottomSheetStackContentDepth(): number { + return useLayerDepth() + 1; +} + +const subscribeToNothing = () => () => {}; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; + +const UNPLACED_VIEW: unstable_BottomSheetStackLevelView = { + depth: 0, + isTop: false, +}; + +/** + * What the level protocol hook hands back to a Core host. Lab-only + * coordination for `@astryxdesign/lab` BottomSheetStack; no stability promise. + */ +export interface unstable_BottomSheetStackLevel { + /** The level's id; provide it as the parent level around the content. */ + id: string; + /** Ref for the hidden in-place anchor element the level renders. */ + anchorRef: RefObject; + /** + * Where to portal the level's panel, or null while there is nothing to + * portal into: on the server, during hydration, and until the host has + * placed the level for the first time. Once placed it stays set, so a + * level hidden by Suspense or Activity keeps its panel and its state. + */ + portalTarget: HTMLElement | null; + /** + * The host's view of this level. Before the first placement the level is + * not top and has depth 0; after it leaves the stack (Suspense, Activity) + * the last placed depth is kept so a return does not animate from zero. + */ + view: unstable_BottomSheetStackLevelView; + /** Forwards a gesture-driven scrim opacity for this level. */ + onScrimOpacity: (opacity: number) => void; +} + +/** + * The level side of the stack protocol, shared by the Core hosts that can be + * levels. Registers in a layout effect with a `useId` key and an in-place + * anchor, reports the given state whenever it changes, touches the host on + * every commit, and owns the box the panel portals into. + * + * With a null host (no stack) it registers nothing, creates nothing, and + * returns an unplaced view, so a Core host can call it unconditionally. + * + * Lab-only coordination for `@astryxdesign/lab` BottomSheetStack; no stability + * promise. + */ +export const unstable_useBottomSheetStackLevel: ( + host: unstable_BottomSheetStackHostContextValue | null, + kind: unstable_BottomSheetStackLevelKind, + state: unstable_BottomSheetStackLevelState, +) => unstable_BottomSheetStackLevel = useBottomSheetStackLevel; + +function useBottomSheetStackLevel( + host: unstable_BottomSheetStackHostContextValue | null, + kind: unstable_BottomSheetStackLevelKind, + state: unstable_BottomSheetStackLevelState, +): unstable_BottomSheetStackLevel { + const id = useId(); + const parentId = use(unstable_BottomSheetStackParentLevelContext); + const anchorRef = useRef(null); + // The level owns its box so a re-registration (StrictMode, Suspense, + // Activity) keeps the same portal target and the panel keeps its state. + // Created on the client only; the server renders no portal. + const [box] = useState(() => + host == null || typeof document === 'undefined' + ? null + : document.createElement('div'), + ); + // Server snapshot false; the hydrating render matches the server (no + // portal), and the post-hydration re-render mounts it. + const isClient = useSyncExternalStore( + subscribeToNothing, + getClientSnapshot, + getServerSnapshot, + ); + const register = host?.register; + const report = host?.report; + const touch = host?.touch; + const onScrimOpacity = host?.onScrimOpacity; + + useLayoutEffect(() => { + const anchor = anchorRef.current; + if (register == null || anchor == null || box == null) { + return; + } + return register({id, kind, anchor, parentId, box}); + }, [box, id, kind, parentId, register]); + + const {isOpen, isPresented, purpose, label, finalFocusRef, dismiss} = state; + useLayoutEffect(() => { + report?.(id, {isOpen, isPresented, purpose, label, finalFocusRef, dismiss}); + }, [dismiss, finalFocusRef, id, isOpen, isPresented, label, purpose, report]); + + // Any commit of this level may have moved it in the tree. + useLayoutEffect(() => { + touch?.(); + }); + + const placedView = host?.views.get(id); + // The panel mounts one commit after registration, once the host has placed + // the level, so its first painted frame already has the right depth and + // nothing animates from a wrong value. The latch keeps it mounted after. + const [hasBeenPlaced, setHasBeenPlaced] = useState(false); + useLayoutEffect(() => { + if (placedView != null && !hasBeenPlaced) { + // eslint-disable-next-line @eslint-react/set-state-in-effect -- latches the first placement so the panel stays mounted while the level is out of the stack + setHasBeenPlaced(true); + } + }, [hasBeenPlaced, placedView]); + const lastViewRef = useRef(UNPLACED_VIEW); + useLayoutEffect(() => { + if (placedView != null) { + lastViewRef.current = placedView; + } + }, [placedView]); + const view = + placedView ?? + (hasBeenPlaced ? {...lastViewRef.current, isTop: false} : UNPLACED_VIEW); + + const forwardScrimOpacity = useCallback( + (opacity: number) => onScrimOpacity?.(id, opacity), + [id, onScrimOpacity], + ); + + return { + id, + anchorRef, + portalTarget: + isClient && (placedView != null || hasBeenPlaced) ? box : null, + view, + onScrimOpacity: forwardScrimOpacity, + }; +} + +/** + * Moves focus so its ring follows the last input, as a standalone sheet's + * focus return does: no ring after pointer input, a ring after keyboard input. + * A host calls it when the last level closes and focus returns to the opener + * or a level's `finalFocusRef`. Core levels turn the document-wide modality + * tracking on while they are mounted. + * + * Lab-only coordination for `@astryxdesign/lab` BottomSheetStack; no stability + * promise. + */ +export const unstable_focusForLastInput: ( + element: HTMLElement, + options?: FocusOptions, +) => void = focusForLastInput; diff --git a/packages/lab/src/BottomSheetStack/BottomSheetStack.doc.mjs b/packages/lab/src/BottomSheetStack/BottomSheetStack.doc.mjs new file mode 100644 index 0000000000000..7e8ff3c9870db --- /dev/null +++ b/packages/lab/src/BottomSheetStack/BottomSheetStack.doc.mjs @@ -0,0 +1,214 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file BottomSheetStack.doc.mjs + * @input BottomSheetStack props, level anatomy, and story-aligned examples + * @output Consumer documentation for the Lab BottomSheetStack + * @position CLI and docsite metadata for the Lab stack of Core bottom sheets + */ + +/** @type {import('@astryxdesign/cli/authoring').ComponentAnatomyElement[]} */ +const anatomy = [ + { + name: 'Host dialog', + required: true, + description: + 'One native shared by every level: the single scrim, modal boundary, scroll lock, and Escape owner for the whole stack.', + }, + { + name: 'Level', + required: true, + description: + 'One BottomSheet (isOpen / onOpenChange) or one whole BottomSheetSwitcher (activeSheet / onActiveSheetChange) rendered anywhere under the stack: directly, behind your own wrapper components, inside DOM elements, Suspense, Activity, providers, or a mapped list. Levels stack in tree order; a sheet rendered inside another level’s content sits right above that level. The last open level is the top.', + }, + { + name: 'Covered level', + required: false, + description: + 'An open level below the top. It stays mounted with its scroll position and inputs, is inert and hidden from assistive technology, and recedes behind the top: the nearest two covered levels shrink and lift a step each, deeper ones sit behind the second step.', + }, + { + name: 'Scrim', + required: false, + description: + 'The host dialog backdrop (hasScrim, the default). A tap on it closes only the top level, as that level’s purpose allows.', + }, +]; + +/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */ +export const docs = { + name: 'BottomSheetStack', + displayName: 'Bottom Sheet Stack', + group: 'BottomSheetStack', + category: 'Overlay', + keywords: [ + 'bottom sheet', + 'stack', + 'stacked sheets', + 'drill-in', + 'nested sheets', + 'mobile', + 'touch', + 'overlay', + 'deep link', + ], + description: + 'Stacks Core bottom sheets. Put the sheets of a flow anywhere inside one BottomSheetStack and open them with their own isOpen or activeSheet state; every BottomSheet and BottomSheetSwitcher rendered under the stack is one level, stacked in tree order, and only the top level is interactive. Covered levels stay mounted with their state and recede behind the top. Escape, a scrim tap, and a swipe close one level at a time through that level’s own callback.', + props: [ + { + name: 'children', + type: 'ReactNode', + description: + 'The subtree whose BottomSheets (isOpen / onOpenChange) and BottomSheetSwitchers (activeSheet / onActiveSheetChange) are the levels, wherever they are rendered in it: directly, behind your own components whatever their props are called, inside DOM wrappers, Suspense, Activity, providers, or a mapped list. A whole switcher is one level and keeps switching in place. Levels stack in tree order, bottom to top; give mapped sheets stable keys.', + required: true, + }, + { + name: 'hasScrim', + type: 'boolean', + description: + 'Whether the stack opens its one shared dialog modally with a scrim, focus containment, and page scroll lock. Set it here only: hasScrim on a sheet or switcher inside the stack is ignored with a development warning.', + default: 'true', + }, + ], + usage: { + anatomy, + description: + 'Use a stack when a touch flow drills in: a list opens a detail, the detail opens an editor, and each step should remain visible beneath the next. Every BottomSheet and BottomSheetSwitcher rendered under the stack is a level, however deep: the stack never looks at its children, so your own wrapper components, DOM wrappers, Suspense, Activity, providers, and mapped lists all work. A BottomSheet level opens with isOpen and closes through onOpenChange(false); a BottomSheetSwitcher level opens when activeSheet is set, switches its own sheets in place while it is the top, and closes through onActiveSheetChange(null). Open several levels in one update to deep-link into a flow; they appear together, already ordered.\n\nLevels stack in tree order, not in the order they opened: the order in which the sheets appear in the document, with a sheet rendered inside another level’s content directly above that level. That lets a detail sheet live next to the row that opens it, inside the list sheet, with no state hoisting. Reordering sheets while they are open reorders the stack and keeps each sheet’s state.\n\nDismissal reaches only the top level: Escape, a scrim tap, and a downward swipe each call the top level’s own callback, and that level’s purpose applies (required blocks all three, form blocks swipe and scrim). Nothing closes the stack at once; close every level yourself, in one update when the whole flow is done, and the levels animate out together.\n\nFocus follows the levels. Opening a level moves focus into it; closing it moves focus back into the level beneath, to the control that opened the closed level when it is still there. When the last level closes, focus returns to the element that opened the first level, or to the bottom level’s finalFocusRef when it has one. A BottomSheet level keeps its own finalFocusRef; a switcher level has none.\n\nThe stack owns the shared dialog, so sheets inside it never set hasScrim. A single open level behaves like a normal sheet. A Core Dialog, AlertDialog, Popover, Lightbox, or MobileNav opened from a level opens above the stack as usual, and a BottomSheet inside that layer is a normal standalone sheet above it, not a level (a stack inside it is its own stack). The same rule applies to a LayerDepthProvider you place yourself: sheets inside it are one layer deeper and stay standalone. A BottomSheetStack rendered inside another one, at the same layer, merges into the outer stack with a development warning.\n\nTwo caveats follow from how levels are placed. Each level’s panel is rendered into the shared dialog, not where the sheet sits in your markup, so CSS that a wrapper applies by inheritance (a font or colour set on a div around one sheet) does not reach that sheet’s panel; React context does, so a ThemeProvider around a sheet still applies. And a sheet you render through your own createPortal is ordered by where that portal’s content lands in the document, not by where the portal is called.', + bestPractices: [ + { + guidance: true, + description: + 'Keep each level’s open state where its data lives: derive isOpen from the selected item and clear the selection in onOpenChange.', + }, + { + guidance: true, + description: + 'Order sheets bottom-to-top as the flow drills in; the stack orders levels by tree position, not by when they opened.', + }, + { + guidance: true, + description: + 'Render a detail sheet inside the sheet that opens it when the two belong together; it stacks directly above its parent.', + }, + { + guidance: true, + description: + 'Use a BottomSheetSwitcher as a level when steps replace each other (edit → saved) and standalone sheets when steps pile up.', + }, + { + guidance: true, + description: + 'For a flow of unknown depth, map a path to BottomSheet children with stable keys and keep closed entries mounted until their exit finishes.', + }, + { + guidance: false, + description: + 'Do not set hasScrim on a sheet or switcher inside the stack; set it once on the stack.', + }, + { + guidance: false, + description: + 'Do not rely on CSS inherited from a wrapper around one sheet to style its panel; panels render in the shared dialog. Use the sheet’s own props or a theme.', + }, + { + guidance: false, + description: + 'Do not nest a BottomSheetStack inside another to get a second scrim; nested stacks merge into the outer one.', + }, + { + guidance: false, + description: + 'Do not ship stable product on this Lab API; it has no theming or spec contract yet.', + }, + ], + accessibility: [ + { + name: 'One reachable level', + requirement: + 'Only the top level is reachable. Covered levels are inert and hidden from assistive technology, and the host dialog takes the top level’s label.', + description: + 'The stack marks every level below the top as inert and aria-hidden, so Tab, screen readers, and pointer input reach only the top level. Each level still needs its own label.', + }, + { + name: 'Way out', + requirement: + 'Give a required or form level a visible close control. Escape and the scrim do not close it.', + description: + 'A required level blocks Escape, the scrim, and swipe; a form level blocks the scrim and swipe. Its content must offer a button that calls the level’s own callback.', + }, + ], + }, + examples: [ + { + label: 'Drill-in: list, detail, editor', + code: `const [order, setOrder] = useState(null); +const [isEditing, setIsEditing] = useState(false); + + + + {orders.map(id => ( + + ))} + + !open && setOrder(null)} + label="Order"> + + + + … + +`, + }, + { + label: 'Switcher base with a help overlay', + code: ` + + … + … + + + … + +`, + }, + { + label: 'Detail sheet next to the row that opens it', + code: ` + + {orders.map(order => ( + + ))} + + + +function OrderRow({order}) { + const [isOpen, setIsOpen] = useState(false); + return ( + <> + + + + + { + onListChange?.(next); + setIsListOpen(next); + }} + label="List" + purpose={listPurpose} + finalFocusRef={listFinalFocusRef} + data-testid="list"> + + + + { + onDetailChange?.(next); + setIsDetailOpen(next); + }} + label="Detail" + purpose={detailPurpose} + finalFocusRef={detailFinalFocusRef} + data-testid="detail"> + + + + + ); +} + +describe('BottomSheetStack', () => { + describe('R1 sheets anywhere in the tree', () => { + it('stacks sheets behind wrappers, DOM elements, Suspense, an ErrorBoundary, a provider, Activity, a multi-sheet component, and a mapped list, in tree order', () => { + const closed: string[] = []; + const Theme = createContext('light'); + function Two() { + return ( + <> + closed.push('TwoA')} /> + closed.push('TwoB')} /> + + ); + } + function Themed() { + const theme = use(Theme); + return ( + closed.push('Themed')} + /> + ); + } + render( + + closed.push('W')} /> +
+ closed.push('Div')} /> +
+ loading}> + closed.push('Susp')} /> + + + closed.push('Err')} /> + + + + + + closed.push('Act')} /> + + + {['L1', 'L2'].map(name => ( + closed.push(name)} + /> + ))} +
, + ); + expect(levelSummary()).toEqual([ + 'W:covered:8', + 'Div:covered:7', + 'Susp:covered:6', + 'Err:covered:5', + 'Themed-dark:covered:4', + 'Act:covered:3', + 'TwoA:covered:2', + 'TwoB:covered:1', + 'L1:closed:1', + 'L2:top:0', + ]); + expect(document.querySelectorAll('dialog')).toHaveLength(1); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + expect(getDialog()).toHaveAccessibleName('L2'); + expect(positionerTransform('TwoB')).toBe(RECEDE_1); + expect(positionerTransform('TwoA')).toBe(RECEDE_2); + expect(positionerTransform('W')).toBe(RECEDE_2); + pressEscape(); + expect(closed).toEqual(['L2']); + }); + + it('keeps wrapper output off the scrim: the children region is hidden and the panels live in the slot', () => { + render( + +
+ +
+
, + ); + const region = document.querySelector( + '[data-bottom-sheet-stack-children]', + ); + expect(region).toHaveAttribute('hidden'); + expect(region).toContainElement(screen.getByTestId('wrapper')); + expect(region).not.toContainElement(getPanel('A')); + expect( + document.querySelector('[data-bottom-sheet-stack-levels]'), + ).toContainElement(getPanel('A')); + }); + }); + + describe('R2 no dependence on children props or shape', () => { + it('follows the sheet, not a wrapper prop that happens to be named isOpen', () => { + // The wrapper's `isOpen` means the opposite of its sheet's. + function Inverted({isOpen}: {isOpen: boolean}) { + return ; + } + render( + + + + , + ); + expect(levelSummary()).toEqual(['Inv:top:0', 'B:closed:0']); + expect(getDialog()).toHaveAttribute('open'); + }); + + it('does not warn about children that are not sheets', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + +
Not a level
+ <> + + +
, + ); + expect(warn).not.toHaveBeenCalled(); + expect(levelSummary()).toEqual(['A:top:0']); + }); + }); + + describe('R3 concurrent React', () => { + it('StrictMode leaves one level per sheet, one showModal, and keeps panel state', () => { + render( + + + + {}} + label="B" + data-testid="B"> + + + + , + ); + fireEvent.change(screen.getByLabelText('bInput'), { + target: {value: 'typed'}, + }); + expect(levelSummary()).toEqual(['A:covered:1', 'B:top:0']); + expect( + document.querySelectorAll('[data-bottom-sheet-stack-level]'), + ).toHaveLength(2); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText('bInput')).toHaveValue('typed'); + }); + + it('a transition that suspends registers nothing until it commits', async () => { + const gate = deferred(); + function Gate({children}: {children: ReactNode}) { + if (!gate.isSettled()) { + throw gate.promise; + } + return <>{children}; + } + let setShow!: (value: boolean) => void; + function App() { + const [show, set] = useState(false); + setShow = set; + return ( + + + + {show ? ( + + + + ) : null} + + + ); + } + render(); + act(() => { + startTransition(() => setShow(true)); + }); + expect(levelSummary()).toEqual(['A:top:0']); + expect(getPanel('A')).toHaveFocus(); + await act(async () => { + gate.resolve(); + await gate.promise; + }); + expect(levelSummary()).toEqual(['A:covered:1', 'B:top:0']); + }); + + it('Suspense hide takes a level out and reveal puts it back in its tree position', async () => { + const gate = deferred(); + function MaybeSuspend({suspend}: {suspend: boolean}) { + if (suspend && !gate.isSettled()) { + throw gate.promise; + } + return null; + } + let setSuspend!: (value: boolean) => void; + function App() { + const [suspend, set] = useState(false); + setSuspend = set; + return ( + + + }> + + + + + + + ); + } + render(); + expect(levelSummary()).toEqual(['A:covered:2', 'B:covered:1', 'C:top:0']); + act(() => setSuspend(true)); + expect(screen.getByTestId('fallback')).toBeInTheDocument(); + expect(levelSummary()).toEqual(['A:covered:1', 'C:top:0']); + expect(positionerTransform('A')).toBe(RECEDE_1); + await act(async () => { + gate.resolve(); + await gate.promise; + }); + expect(levelSummary()).toEqual(['A:covered:2', 'B:covered:1', 'C:top:0']); + expect(positionerTransform('A')).toBe(RECEDE_2); + }); + + it('Activity hidden takes a level out and visible puts it back with its state', () => { + let setHidden!: (value: boolean) => void; + function App() { + const [hidden, set] = useState(false); + setHidden = set; + return ( + + + + {}} + label="B" + data-testid="B"> + + + + + + ); + } + render(); + fireEvent.change(screen.getByLabelText('bInput'), { + target: {value: 'kept'}, + }); + act(() => setHidden(true)); + expect(levelSummary()).toEqual(['A:covered:1', 'C:top:0']); + act(() => setHidden(false)); + expect(levelSummary()).toEqual(['A:covered:2', 'B:covered:1', 'C:top:0']); + expect(screen.getByLabelText('bInput')).toHaveValue('kept'); + }); + + it('useDeferredValue opens a level in the later commit', async () => { + let setOpen!: (value: boolean) => void; + function App() { + const [open, set] = useState(false); + setOpen = set; + const deferredOpen = useDeferredValue(open); + return ( + + + + + ); + } + render(); + await act(async () => { + setOpen(true); + }); + expect(levelSummary()).toEqual(['A:covered:1', 'B:top:0']); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + }); + + it('SSR and hydration markup match; a closed sheet registers as a closed level and opens on the client', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + let setOpen!: (value: boolean) => void; + function App() { + const [open, set] = useState(false); + setOpen = set; + return ( + +
+ +
+
+ ); + } + const html = renderToString(); + expect(html).toContain('data-bottom-sheet-stack-anchor'); + expect(html).not.toContain('data-testid="A"'); + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + let root!: ReturnType; + await act(async () => { + root = hydrateRoot(container, ); + }); + expect(error).not.toHaveBeenCalled(); + expect(levelSummary(container)).toEqual(['A:closed:0']); + expect(getDialog(container)).not.toHaveAttribute('open'); + act(() => setOpen(true)); + expect(levelSummary(container)).toEqual(['A:top:0']); + expect(getDialog(container)).toHaveAttribute('open'); + act(() => root.unmount()); + container.remove(); + }); + }); + + describe('R4 first visible frame', () => { + function mountSync(element: ReactNode) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + let microtaskRan = false; + void Promise.resolve().then(() => { + microtaskRan = true; + }); + flushSync(() => { + root.render(element); + }); + return { + container, + root, + microtaskRan: () => microtaskRan, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; + } + + it('under flushSync the DOM is final before any microtask: dialog open, one showModal, order, depth, inert, transforms', () => { + const mounted = mountSync( + + +
+ +
+ +
, + ); + expect(mounted.microtaskRan()).toBe(false); + const levels = getLevels(mounted.container); + expect(levelSummary(mounted.container)).toEqual([ + 'A:covered:1', + 'B:top:0', + 'C:closed:0', + ]); + expect(levels[0]).toHaveAttribute('inert'); + expect(levels[0]).toHaveAttribute('aria-hidden', 'true'); + expect(levels[1]).not.toHaveAttribute('inert'); + expect(levels[2]).toHaveAttribute('inert'); + expect(positionerTransform('A')).toBe(RECEDE_1); + expect(positionerTransform('B')).toBe(FLAT); + expect(getDialog(mounted.container)).toHaveAttribute('open'); + expect(getDialog(mounted.container)).toHaveAttribute( + 'aria-modal', + 'true', + ); + expect(getDialog(mounted.container)).toHaveAccessibleName('B'); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + mounted.unmount(); + }); + + it('under flushSync a deep link opens three levels and a one-update close animates them together before any microtask', () => { + let setDepth!: (value: number) => void; + function App() { + const [depth, set] = useState(0); + setDepth = set; + return ( + + 0} label="One" /> + 1} label="Two" /> + 2} label="Three" /> + + ); + } + const mounted = mountSync(); + expect(getDialog(mounted.container)).not.toHaveAttribute('open'); + let microtaskRan = false; + void Promise.resolve().then(() => { + microtaskRan = true; + }); + flushSync(() => setDepth(3)); + expect(microtaskRan).toBe(false); + expect(levelSummary(mounted.container)).toEqual([ + 'One:covered:2', + 'Two:covered:1', + 'Three:top:0', + ]); + expect(positionerTransform('One')).toBe(RECEDE_2); + expect(positionerTransform('Two')).toBe(RECEDE_1); + expect(positionerTransform('Three')).toBe(FLAT); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + + flushSync(() => setDepth(0)); + expect(microtaskRan).toBe(false); + expect(levelSummary(mounted.container)).toEqual([ + 'One:exiting:0', + 'Two:exiting:0', + 'Three:exiting:0', + ]); + expect(positionerTransform('One')).toBe(FLAT); + expect(getDialog(mounted.container)).toHaveAttribute('open'); + expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled(); + mounted.unmount(); + }); + + it('a panel measuring itself in a mount layout effect sees a connected element', () => { + const seen: boolean[] = []; + function Measure() { + const ref = useRef(null); + useLayoutEffect(() => { + seen.push(ref.current?.isConnected === true); + }, []); + return
; + } + render( + + + + + + , + ); + expect(seen).toEqual([true]); + expect(levelSummary()).toEqual(['A:covered:1', 'B:top:0']); + }); + }); + + describe('R5 nested stacks', () => { + it('merges an inner stack, direct or inside a level, into the outer one: one dialog, one scrim, one warning each, inner hasScrim ignored', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const closed: string[] = []; + render( + + + closed.push('Inner1')} + /> + + closed.push('Outer')}> + + closed.push('InContent')} + /> + + + , + ); + expect(document.querySelectorAll('dialog')).toHaveLength(1); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + expect(HTMLDialogElement.prototype.show).not.toHaveBeenCalled(); + expect(getDialog()).toHaveAttribute('aria-modal', 'true'); + expect(levelSummary()).toEqual([ + 'Inner1:covered:2', + 'Outer:covered:1', + 'InContent:top:0', + ]); + const nestedWarnings = warn.mock.calls.filter(call => + String(call[0]).includes('inside another BottomSheetStack'), + ); + expect(nestedWarnings).toHaveLength(2); + pressEscape(); + expect(closed).toEqual(['InContent']); + }); + }); + + describe('R6 no global stack', () => { + it('a sheet outside the stack keeps its own dialog, and an empty stack opens nothing', () => { + render( + <> + {}} + label="Alone" + data-testid="alone"> + Alone + + +
no sheets here
+
+ , + ); + expect(getDialog()).not.toHaveAttribute('open'); + expect(getLevels()).toHaveLength(0); + const own = getPanel('alone').closest('dialog'); + expect(own).not.toBe(getDialog()); + expect(own).toHaveAttribute('open'); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + }); + }); + + describe('R7 tree order', () => { + it('stacks open levels in tree order, not open order', () => { + function OverlayFirst() { + const [isListOpen, setIsListOpen] = useState(false); + const [isHelpOpen, setIsHelpOpen] = useState(true); + return ( + <> + + + + List + + + Help + + + + ); + } + render(); + expect(levelState(0)).toBe('closed'); + expect(levelState(1)).toBe('top'); + expect(getDialog()).toHaveAccessibleName('Help'); + + fireEvent.click(screen.getByRole('button', {name: 'Open list'})); + // The list opened last but sits first in the tree, so it goes under Help. + expect(levelState(0)).toBe('covered'); + expect(levelState(1)).toBe('top'); + expect(getDialog()).toHaveAccessibleName('Help'); + expect(positionerTransform('list')).toBe(RECEDE_1); + expect(positionerTransform('help')).toBe(FLAT); + }); + + it('opens three levels in one update, ordered and receded by depth', () => { + function DeepLink() { + const [depth, setDepth] = useState(0); + return ( + <> + + + 0} + onOpenChange={() => setDepth(0)} + label="One" + data-testid="one"> + One + + 1} + onOpenChange={() => setDepth(1)} + label="Two" + data-testid="two"> + Two + + 2} + onOpenChange={() => setDepth(2)} + label="Three" + data-testid="three"> + Three + + + + ); + } + render(); + expect(screen.queryByRole('dialog')).toBeNull(); + fireEvent.click(screen.getByRole('button', {name: 'Deep link'})); + + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + expect(levelSummary()).toEqual([ + 'one:covered:2', + 'two:covered:1', + 'three:top:0', + ]); + expect(positionerTransform('one')).toBe(RECEDE_2); + expect(positionerTransform('two')).toBe(RECEDE_1); + expect(positionerTransform('three')).toBe(FLAT); + expect(getDialog()).toHaveAccessibleName('Three'); + expect(getPanel('three')).toHaveFocus(); + }); + + it('a sheet inside another sheet’s content stacks right above it, with its panel outside the parent’s inert box', () => { + const closed: string[] = []; + function App({b}: {b: boolean}) { + return ( + + closed.push('A')} + label="A" + data-testid="A"> + + closed.push('C')} /> + closed.push('C2')} /> + + closed.push('B')} /> + + ); + } + const {rerender} = render(); + expect(levelSummary()).toEqual([ + 'A:covered:2', + 'C:covered:1', + 'C2:top:0', + 'B:closed:0', + ]); + // C's anchor is in A's panel (tree position); C's panel is in its own + // box, so A's inert and recede transform never reach it. + expect(getPanel('A')).toContainElement( + getPanel('A').ownerDocument.querySelector( + '[data-bottom-sheet-stack-anchor]', + ), + ); + expect(getLevel(0)).not.toContainElement(getPanel('C')); + expect(getLevel(0)).toHaveAttribute('inert'); + expect(getLevel(2)).not.toHaveAttribute('inert'); + expect(positionerTransform('C2')).toBe(FLAT); + expect(positionerTransform('C')).toBe(RECEDE_1); + expect(positionerTransform('A')).toBe(RECEDE_2); + expect(getDialog()).toHaveAccessibleName('C2'); + pressEscape(); + expect(closed).toEqual(['C2']); + rerender(); + expect(levelSummary()).toEqual([ + 'A:covered:3', + 'C:covered:2', + 'C2:covered:1', + 'B:top:0', + ]); + }); + + it('reordering open levels while open follows the tree and keeps their state', () => { + let setFlip!: (value: boolean) => void; + function App() { + const [flip, set] = useState(false); + setFlip = set; + const a = ( + {}} + label="A" + data-testid="A"> + + + ); + const b = ; + return {flip ? [b, a] : [a, b]}; + } + render(); + fireEvent.change(screen.getByLabelText('aInput'), { + target: {value: 'kept'}, + }); + expect(levelSummary()).toEqual(['A:covered:1', 'B:top:0']); + act(() => setFlip(true)); + expect(levelSummary()).toEqual(['B:covered:1', 'A:top:0']); + expect(positionerTransform('A')).toBe(FLAT); + expect(positionerTransform('B')).toBe(RECEDE_1); + expect(getDialog()).toHaveAccessibleName('A'); + expect(screen.getByLabelText('aInput')).toHaveValue('kept'); + }); + + it('a reorder of memoised subtrees that re-renders neither the stack nor the sheets is corrected at the microtask checkpoint', async () => { + const MemoA = memo(function MemoA() { + return ; + }); + const MemoB = memo(function MemoB() { + return ; + }); + // The stack's children are a stable element, so the host never + // re-renders; the memoised sheets never re-render either. Only the + // wrapper between them re-renders, and all it changes is DOM order. + let setFlip!: (value: boolean) => void; + function Reorderable() { + const [flip, set] = useState(false); + setFlip = set; + const a = ; + const b = ; + return <>{flip ? [b, a] : [a, b]}; + } + const stackChildren = ; + function App() { + return {stackChildren}; + } + render(); + expect(levelSummary()).toEqual(['A:covered:1', 'B:top:0']); + let raf = false; + requestAnimationFrame(() => { + raf = true; + }); + act(() => setFlip(true)); + // MutationObserver callbacks run as microtasks; nothing has yielded to + // an animation frame yet. + await Promise.resolve(); + expect(raf).toBe(false); + expect(levelSummary()).toEqual(['B:covered:1', 'A:top:0']); + expect(positionerTransform('A')).toBe(FLAT); + expect(positionerTransform('B')).toBe(RECEDE_1); + expect(getDialog()).toHaveAccessibleName('A'); + }); + + it('a sheet rendered through an app-owned portal joins the stack where its portal content lands', () => { + const closed: string[] = []; + function Portaled({target}: {target: HTMLElement}) { + return createPortal( + closed.push('P')} />, + target, + ); + } + function App() { + const [target, setTarget] = useState(null); + return ( + + closed.push('A')} /> +
+ {target == null ? null : } + closed.push('B')} /> + + ); + } + render(); + expect(levelSummary()).toEqual(['A:covered:2', 'P:covered:1', 'B:top:0']); + pressEscape(); + expect(closed).toEqual(['B']); + }); + }); + + describe('R8 Core layers above the stack', () => { + function LevelWithDialog({ + Layer, + }: { + Layer: (props: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; + }) => ReactNode; + }) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isInnerOpen, setIsInnerOpen] = useState(false); + return ( + + + {}} + label="Top" + data-testid="Top"> + + + + + Inner + + + + + ); + } + + it('a Core Dialog opened from a level paints above the stack, and a BottomSheet inside it is standalone above the Dialog', () => { + render( + ( + + {children} + + )} + />, + ); + expect(levelSummary()).toEqual(['Base:covered:1', 'Top:top:0']); + fireEvent.click(screen.getByRole('button', {name: 'Open dialog'})); + // The Dialog is its own top-layer dialog, shown after the stack's. + const dialogs = Array.from(document.querySelectorAll('dialog[open]')); + expect(dialogs).toHaveLength(2); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(2); + expect(levelSummary()).toEqual(['Base:covered:1', 'Top:top:0']); + + fireEvent.click(screen.getByRole('button', {name: 'Open inner sheet'})); + // The inner sheet is not a level: it owns its own dialog, the third + // one shown, so it paints above the Dialog. + expect(levelSummary()).toEqual(['Base:covered:1', 'Top:top:0']); + const innerDialog = getPanel('Inner').closest('dialog'); + expect(innerDialog).not.toBe(getDialog()); + expect(innerDialog).toHaveAttribute('open'); + expect(innerDialog).toHaveAccessibleName('Inner'); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(3); + expect(Array.from(document.querySelectorAll('dialog[open]')).at(-1)).toBe( + innerDialog, + ); + expect(positionerTransform('Inner')).toBe(''); + // Escape reaches the inner sheet first, not the stack. + pressEscape(innerDialog ?? undefined); + expect(levelSummary()).toEqual(['Base:covered:1', 'Top:top:0']); + }); + + it('a level and a Dialog inside it opening in the same commit: the stack shell shows before the Dialog', () => { + render( + + {}} + label="Level" + data-testid="Level"> + {}} aria-label="Confirm"> + Confirm + + + , + ); + expect(nativeLog.map(entry => `${entry.call}:${entry.label}`)).toEqual([ + 'showModal:Level', + 'showModal:Confirm', + ]); + expect(levelSummary()).toEqual(['Level:top:0']); + }); + + it('a nested BottomSheetStack inside a Dialog opened from a level is its own host above the Dialog, not merged', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + {}} + label="Level" + data-testid="Level"> + {}} aria-label="Confirm"> + + + + + + , + ); + const stacks = document.querySelectorAll('[data-bottom-sheet-stack]'); + expect(stacks).toHaveLength(2); + expect(nativeLog.map(entry => `${entry.call}:${entry.label}`)).toEqual([ + 'showModal:Level', + 'showModal:Confirm', + 'showModal:InDialog', + ]); + expect( + warn.mock.calls.some(call => + String(call[0]).includes('inside another BottomSheetStack'), + ), + ).toBe(false); + // The outer stack has one level; the inner stack has its own. + expect(levelSummary(stacks[0])).toEqual(['Level:top:0']); + expect(levelSummary(stacks[1])).toEqual(['InDialog:top:0']); + }); + + it.each([ + { + name: 'Popover', + Layer: ({children}: {children: ReactNode}) => ( + {}} + label="Options" + content={children}> + + + ), + }, + { + name: 'Lightbox', + Layer: ({children}: {children: ReactNode}) => ( + {}} + media={{src: '/photo.jpg', alt: 'Photo', caption: children}} + /> + ), + }, + { + name: 'MobileNav', + Layer: ({children}: {children: ReactNode}) => ( + {}}> + {children} + + ), + }, + ])( + 'a BottomSheet inside a $name opened from a level is standalone above it, and the stack is unaffected', + ({Layer}) => { + render( + + {}} + label="Level" + data-testid="Level"> + + {}} + label="Inner" + data-testid="Inner"> + Inner + + + + , + ); + expect(levelSummary()).toEqual(['Level:top:0']); + const innerDialog = getPanel('Inner').closest('dialog'); + expect(innerDialog).not.toBeNull(); + expect(innerDialog).not.toBe(getDialog()); + expect(innerDialog).toHaveAccessibleName('Inner'); + expect(innerDialog).toHaveAttribute('open'); + expect(positionerTransform('Inner')).toBe(''); + // The inner sheet's own dialog was shown after the stack's shell and + // after the layer it is in. (Which of the layer and the shell shows + // first when both open in one commit is Core's existing ordering and + // out of scope here.) + const shown = nativeLog + .filter(entry => entry.call !== 'close') + .map(entry => entry.label); + expect(shown.indexOf('Inner')).toBeGreaterThan(shown.indexOf('Level')); + expect(shown.filter(label => label === 'Inner')).toHaveLength(1); + }, + ); + + it('a Selector with a bottom-sheet presentation inside a level stacks its picker sheet above that level', async () => { + render( + + {}} + label="Level" + data-testid="Level"> + {}} + presentation="bottom-sheet" + /> + + , + ); + fireEvent.click(screen.getByRole('combobox')); + // The picker is a lazily loaded Core BottomSheet rendered in the + // level's content, so it is a level right above its parent in the one + // shared dialog. + await screen.findByRole('option', {name: /Banana/}); + expect(document.querySelectorAll('dialog')).toHaveLength(1); + const levels = getLevels(); + expect(levels).toHaveLength(2); + expect(levels[0]).toHaveAttribute( + 'data-bottom-sheet-stack-level-state', + 'covered', + ); + expect(levels[1]).toHaveAttribute( + 'data-bottom-sheet-stack-level-state', + 'top', + ); + expect(getDialog()).toHaveAccessibleName('Fruit'); + expect(screen.getByRole('option', {name: /Banana/})).toBeInTheDocument(); + }); + + it('an AlertDialog opened from a level is its own top-layer dialog and its content is outside the stack', () => { + function App() { + const [isAlertOpen, setIsAlertOpen] = useState(false); + const [isInnerOpen, setIsInnerOpen] = useState(false); + return ( + + {}} + label="Top" + data-testid="Top"> + + setIsInnerOpen(true)} + /> + + + + ); + } + render(); + fireEvent.click(screen.getByRole('button', {name: 'Delete'})); + expect(document.querySelectorAll('dialog[open]')).toHaveLength(2); + expect(screen.getByRole('alertdialog')).toHaveAccessibleName('Delete?'); + // A sheet rendered in the level's own content (not in the alert) is + // still a level above its parent. + fireEvent.click(screen.getByRole('button', {name: 'More options'})); + expect(levelSummary()).toEqual(['Top:covered:1', 'Options:top:0']); + }); + }); + + describe('R9 known bugs', () => { + it('P5: a component that renders no sheet opens no dialog and leaves no stuck backdrop', () => { + const spy = vi.fn(); + function Gated(_props: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + }) { + return null; + } + render( + + + , + ); + expect(getDialog()).not.toHaveAttribute('open'); + expect(getDialog()).not.toHaveAttribute('aria-modal'); + expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled(); + expect(getLevels()).toHaveLength(0); + pressEscape(); + clickScrim(); + expect(spy).not.toHaveBeenCalled(); + }); + + it('P6: a component that renders two sheets is two levels, each dismissible in turn', () => { + const aSpy = vi.fn(); + const bSpy = vi.fn(); + function Two(props: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + }) { + const [b, setB] = useState(true); + return ( + <> + + a + + { + bSpy(value); + setB(value); + }} + label="TwoB" + data-testid="TwoB"> + b + + + ); + } + function App() { + const [open, setOpen] = useState(true); + return ( + + { + aSpy(value); + setOpen(value); + }} + /> + + ); + } + render(); + expect(levelSummary()).toEqual(['TwoA:covered:1', 'TwoB:top:0']); + expect(getDialog()).toHaveAccessibleName('TwoB'); + expect(positionerTransform('TwoA')).toBe(RECEDE_1); + pressEscape(); + expect(bSpy).toHaveBeenCalledWith(false); + expect(aSpy).not.toHaveBeenCalled(); + finishExit('TwoB'); + expect(levelSummary()).toEqual(['TwoA:top:0', 'TwoB:closed:0']); + pressEscape(); + expect(aSpy).toHaveBeenCalledWith(false); + finishExit('TwoA'); + expect(getDialog()).not.toHaveAttribute('open'); + }); + + it('P11: a sheet inside a switcher step is a level above the switcher, and the switcher stays dismissible after it closes', () => { + const switcherSpy = vi.fn(); + const nestedSpy = vi.fn(); + function App() { + const [step, setStep] = useState('a'); + const [nested, setNested] = useState(false); + return ( + + { + switcherSpy(value); + setStep(value); + }}> + + + { + nestedSpy(value); + setNested(value); + }} + label="NestedInStep" + data-testid="nestedInStep"> + n + + + + + ); + } + render(); + expect(levelSummary()).toEqual(['stepA:top:0', 'nestedInStep:closed:0']); + expect(getDialog()).toHaveAccessibleName('StepA'); + fireEvent.click(screen.getByRole('button', {name: 'open nested'})); + expect(document.querySelectorAll('dialog')).toHaveLength(1); + expect(levelSummary()).toEqual(['stepA:covered:1', 'nestedInStep:top:0']); + expect(getDialog()).toHaveAccessibleName('NestedInStep'); + expect(getPositioner('stepA')).toHaveAttribute('inert'); + expect(positionerTransform('stepA')).toBe(RECEDE_1); + pressEscape(); + expect(nestedSpy).toHaveBeenCalledWith(false); + expect(switcherSpy).not.toHaveBeenCalled(); + finishExit('nestedInStep'); + expect(levelSummary()).toEqual(['stepA:top:0', 'nestedInStep:closed:0']); + expect(getDialog()).toHaveAccessibleName('StepA'); + expect(getPositioner('stepA')).not.toHaveAttribute('inert'); + pressEscape(); + expect(switcherSpy).toHaveBeenCalledWith(null); + expect(nestedSpy).toHaveBeenCalledTimes(1); + }); + + it('P13: removing an open level from the tree closes the dialog when nothing else is presented', () => { + function App() { + const [show, setShow] = useState(true); + return ( + <> + + + {show ? : null} + + + ); + } + render(); + expect(getDialog()).toHaveAttribute('open'); + fireEvent.click(screen.getByRole('button', {name: 'remove'})); + expect(getDialog()).not.toHaveAttribute('open'); + expect(getLevels()).toHaveLength(0); + }); + + it('P15: a keyless conditional sibling appearing keeps the other level’s identity and state', () => { + function App() { + const [showA, setShowA] = useState(false); + return ( + <> + + + {showA ? : null} + {}} + label="B" + data-testid="B"> + + + + + ); + } + render(); + const input = screen.getByLabelText('bInput'); + fireEvent.change(input, {target: {value: 'typed'}}); + fireEvent.click(screen.getByRole('button', {name: 'toggleA'})); + expect(screen.getByLabelText('bInput')).toBe(input); + expect(input).toHaveValue('typed'); + expect(levelSummary()).toEqual(['A:covered:1', 'B:top:0']); + }); + }); + + describe('R10 levels', () => { + it('keeps only the top level interactive', () => { + render(); + expect(getLevel(0)).toHaveAttribute('inert'); + expect(getLevel(0)).toHaveAttribute('aria-hidden', 'true'); + expect(getLevel(1)).not.toHaveAttribute('inert'); + expect(getLevel(1)).not.toHaveAttribute('aria-hidden'); + // The covered panel is retained: present, not hidden, pointer-inert. + expect(getPositioner('list')).not.toHaveAttribute('hidden'); + expect(getPanel('detail')).toHaveFocus(); + }); + + it('preserves a covered level and its state', () => { + render(); + const input = screen.getByLabelText('Search'); + fireEvent.change(input, {target: {value: 'shoes'}}); + fireEvent.click(screen.getByRole('button', {name: 'Open detail'})); + expect(levelState(0)).toBe('covered'); + expect(screen.getByLabelText('Search')).toBe(input); + expect(input).toHaveValue('shoes'); + + fireEvent.click(screen.getByRole('button', {name: 'Done'})); + expect(levelState(0)).toBe('top'); + expect(levelState(1)).toBe('exiting'); + expect(screen.getByLabelText('Search')).toBe(input); + expect(input).toHaveValue('shoes'); + finishExit('detail'); + expect(levelState(1)).toBe('closed'); + expect(getPositioner('detail')).toHaveAttribute('hidden'); + expect(getDialog()).toHaveAttribute('open'); + }); + + it('treats a lone level like a normal sheet', () => { + const onListChange = vi.fn(); + render(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toBe(getDialog()); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAccessibleName('List'); + expect(getLevel(0)).not.toHaveAttribute('inert'); + expect(levelState(1)).toBe('closed'); + expect(getPanel('list')).toHaveFocus(); + expect(positionerTransform('list')).toBe(FLAT); + + pressEscape(); + expect(onListChange).toHaveBeenCalledWith(false); + finishExit('list'); + expect(HTMLDialogElement.prototype.close).toHaveBeenCalled(); + expect(dialog).not.toHaveAttribute('open'); + }); + + it('stacks mapped children of variable depth', () => { + function Browser() { + const [path, setPath] = useState(['Home', 'Photos', 'Trips']); + const [openDepth, setOpenDepth] = useState(3); + return ( + <> + + + + {path.map((folder, index) => ( + { + if (!open) { + setOpenDepth(index); + } + }} + label={folder} + data-testid={`folder-${folder}`}> + {folder} + + ))} + + + ); + } + render(); + expect(levelState(0)).toBe('covered'); + expect(levelState(1)).toBe('covered'); + expect(levelState(2)).toBe('top'); + expect(getDialog()).toHaveAccessibleName('Trips'); + + pressEscape(); + expect(levelState(2)).toBe('exiting'); + expect(levelState(1)).toBe('top'); + expect(getDialog()).toHaveAccessibleName('Photos'); + + fireEvent.click(screen.getByRole('button', {name: 'Add'})); + fireEvent.click(screen.getByRole('button', {name: 'Open 4'})); + expect(levelState(2)).toBe('covered'); + expect(levelState(3)).toBe('top'); + expect(getDialog()).toHaveAccessibleName('Alps'); + expect(positionerTransform('folder-Home')).toBe(RECEDE_2); + expect(positionerTransform('folder-Photos')).toBe(RECEDE_2); + expect(positionerTransform('folder-Trips')).toBe(RECEDE_1); + }); + }); + + describe('R10 dismissal', () => { + it('Escape closes only the top level through its own callback', () => { + const onListChange = vi.fn(); + const onDetailChange = vi.fn(); + render( + , + ); + pressEscape(); + expect(onDetailChange).toHaveBeenCalledTimes(1); + expect(onDetailChange).toHaveBeenCalledWith(false); + expect(onListChange).not.toHaveBeenCalled(); + expect(levelState(0)).toBe('top'); + expect(levelState(1)).toBe('exiting'); + expect(getDialog()).toHaveAttribute('open'); + }); + + it('a scrim tap closes only the top level through its own callback', () => { + const onListChange = vi.fn(); + const onDetailChange = vi.fn(); + render( + , + ); + clickScrim(); + expect(onDetailChange).toHaveBeenCalledWith(false); + expect(onListChange).not.toHaveBeenCalled(); + }); + + it('a swipe closes only the top level through its own callback', () => { + const onListChange = vi.fn(); + const onDetailChange = vi.fn(); + render( + , + ); + swipeDown('detail'); + expect(onDetailChange).toHaveBeenCalledWith(false); + expect(onListChange).not.toHaveBeenCalled(); + }); + + it('a platform close request pops one level', () => { + const onListChange = vi.fn(); + const onDetailChange = vi.fn(); + render( + , + ); + const cancel = new Event('cancel', {bubbles: false, cancelable: true}); + fireEvent(getDialog(), cancel); + expect(cancel.defaultPrevented).toBe(true); + expect(onDetailChange).toHaveBeenCalledWith(false); + expect(onListChange).not.toHaveBeenCalled(); + }); + + it('respects a required top level and ignores the base purpose', () => { + const onListChange = vi.fn(); + const onDetailChange = vi.fn(); + render( + , + ); + expect(getDialog()).toHaveAttribute('role', 'alertdialog'); + pressEscape(); + clickScrim(); + swipeDown('detail'); + expect(onDetailChange).not.toHaveBeenCalled(); + expect(onListChange).not.toHaveBeenCalled(); + }); + + it('respects a form top level: Escape closes, scrim and swipe do not', () => { + const onDetailChange = vi.fn(); + render( + , + ); + expect(getDialog()).not.toHaveAttribute('role', 'alertdialog'); + clickScrim(); + swipeDown('detail'); + expect(onDetailChange).not.toHaveBeenCalled(); + pressEscape(); + expect(onDetailChange).toHaveBeenCalledWith(false); + }); + + it('closes every level together in one update and then the dialog once', () => { + render(); + fireEvent.click(screen.getByRole('button', {name: 'Close all'})); + expect(levelState(0)).toBe('exiting'); + expect(levelState(1)).toBe('exiting'); + expect(getDialog()).toHaveAttribute('open'); + // Nothing is covered any more: both slide out at full size. + expect(positionerTransform('list')).toBe(FLAT); + finishExit('detail'); + expect(getDialog()).toHaveAttribute('open'); + finishExit('list'); + expect(getDialog()).not.toHaveAttribute('open'); + expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1); + }); + }); + + describe('R10 switcher levels', () => { + function Filters({ + onStepChange, + onHelpChange, + }: { + onStepChange?: (step: string | null) => void; + onHelpChange?: (isOpen: boolean) => void; + }) { + const [step, setStep] = useState('categories'); + const [isHelpOpen, setIsHelpOpen] = useState(false); + return ( + + { + onStepChange?.(next); + setStep(next); + }}> + + + + + + + + + { + onHelpChange?.(next); + setIsHelpOpen(next); + }} + label="Help" + data-testid="help"> + Help + + + ); + } + + it('uses a switcher as the base and an overlay on top', () => { + const onStepChange = vi.fn(); + const onHelpChange = vi.fn(); + render( + , + ); + expect(getLevel(0)).toHaveAttribute( + 'data-bottom-sheet-stack-level-kind', + 'switcher', + ); + expect(levelState(0)).toBe('top'); + expect(getDialog()).toHaveAccessibleName('Categories'); + // The switcher renders no dialog of its own. + expect(document.querySelectorAll('dialog')).toHaveLength(1); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + + // Switching base steps while uncovered happens in place. + fireEvent.click(screen.getByRole('button', {name: 'Next'})); + expect(levelState(0)).toBe('top'); + expect(getDialog()).toHaveAccessibleName('Options'); + expect(getPositioner('options')).not.toHaveAttribute('aria-hidden'); + + // The overlay sits over the second step and covers the whole level. + fireEvent.click(screen.getByRole('button', {name: 'Help'})); + expect(levelState(0)).toBe('covered'); + expect(levelState(1)).toBe('top'); + expect(getLevel(0)).toHaveAttribute('inert'); + expect(getPositioner('options')).toHaveAttribute('aria-hidden', 'true'); + expect(positionerTransform('options')).toBe(RECEDE_1); + expect(getDialog()).toHaveAccessibleName('Help'); + + // Escape pops the overlay through its own callback only. + pressEscape(); + expect(onHelpChange).toHaveBeenCalledWith(false); + expect(onStepChange).not.toHaveBeenCalled(); + expect(levelState(0)).toBe('top'); + expect(getPositioner('options')).not.toHaveAttribute('aria-hidden'); + expect(getDialog()).toHaveAccessibleName('Options'); + + // The overlay works over the first step too, and then Escape closes the + // switcher level through onActiveSheetChange(null). + finishExit('help'); + fireEvent.click(screen.getByRole('button', {name: 'Help'})); + expect(levelState(1)).toBe('top'); + pressEscape(); + pressEscape(); + expect(onStepChange).toHaveBeenCalledWith(null); + }); + + it('uses a switcher as the top level: edit → saved replaces, back returns to the base', () => { + const onEditChange = vi.fn(); + const onContactsChange = vi.fn(); + function Contacts() { + const [isContactsOpen, setIsContactsOpen] = useState(true); + const [editStep, setEditStep] = useState(null); + return ( + + { + onContactsChange(next); + setIsContactsOpen(next); + }} + label="Contacts" + data-testid="contacts"> + + + { + onEditChange(next); + setEditStep(next); + }}> + + + + + + + + + ); + } + render(); + expect(levelState(0)).toBe('top'); + expect(levelState(1)).toBe('closed'); + + const editButton = screen.getByRole('button', {name: 'Edit'}); + editButton.focus(); + fireEvent.click(editButton); + expect(levelState(0)).toBe('covered'); + expect(levelState(1)).toBe('top'); + expect(getDialog()).toHaveAccessibleName('Edit contact'); + expect(getPanel('edit')).toHaveFocus(); + + fireEvent.click(screen.getByRole('button', {name: 'Save'})); + expect(levelState(1)).toBe('top'); + expect(getDialog()).toHaveAccessibleName('Saved'); + expect(getPositioner('saved')).not.toHaveAttribute('aria-hidden'); + expect(getLevel(0)).toHaveAttribute('inert'); + + fireEvent.click(screen.getByRole('button', {name: 'Back'})); + expect(levelState(1)).toBe('exiting'); + expect(levelState(0)).toBe('top'); + expect(getDialog()).toHaveAccessibleName('Contacts'); + expect(onContactsChange).not.toHaveBeenCalled(); + // Focus returns to the control that opened the closed level. + expect(editButton).toHaveFocus(); + }); + }); + + describe('R10 focus', () => { + it('returns focus to the opener of the closed level when it remains', () => { + render(); + const opener = screen.getByRole('button', {name: 'Open detail'}); + opener.focus(); + fireEvent.click(opener); + expect(getPanel('detail')).toHaveFocus(); + fireEvent.click(screen.getByRole('button', {name: 'Done'})); + expect(opener).toHaveFocus(); + }); + + it('focuses into the new top level when the opener is gone', () => { + function Replacing() { + const [isListOpen, setIsListOpen] = useState(true); + const [isDetailOpen, setIsDetailOpen] = useState(false); + return ( + + + {isDetailOpen ? ( + Loading + ) : ( + + )} + + + + + + ); + } + render(); + const opener = screen.getByRole('button', {name: 'Open detail'}); + opener.focus(); + fireEvent.click(opener); + expect(getPanel('detail')).toHaveFocus(); + fireEvent.click(screen.getByRole('button', {name: 'Done'})); + expect(getPanel('list')).toHaveFocus(); + }); + + it('applies a level’s own finalFocusRef when its close closes the stack', () => { + const target = createRef(); + function Harness() { + return ( + <> + + + + ); + } + render(); + pressEscape(); + finishExit('list'); + expect(target.current).toHaveFocus(); + }); + + it('lets the bottom level’s finalFocusRef win when every level closes together', () => { + const listTarget = createRef(); + const detailTarget = createRef(); + function Harness() { + return ( + <> + + + + + ); + } + render(); + fireEvent.click(screen.getByRole('button', {name: 'Close all'})); + // Exit order does not matter: the bottom level's ref still wins. + finishExit('list'); + finishExit('detail'); + expect(listTarget.current).toHaveFocus(); + }); + + it('returns focus to the element that opened the first level without a ref', () => { + render(); + const opener = screen.getByRole('button', {name: 'Deep link'}); + opener.focus(); + fireEvent.click(opener); + expect(getPanel('detail')).toHaveFocus(); + fireEvent.click(screen.getByRole('button', {name: 'Close all'})); + finishExit('detail'); + finishExit('list'); + expect(opener).toHaveFocus(); + }); + + it('ignores a switcher level for final focus and falls back to the opener', () => { + function Harness() { + const [step, setStep] = useState(null); + const openerRef = useRef(null); + return ( + <> + + + + + Edit + + + + + ); + } + render(); + const opener = screen.getByRole('button', {name: 'Start'}); + opener.focus(); + fireEvent.click(opener); + expect(getPanel('edit')).toHaveFocus(); + pressEscape(); + finishExit('edit'); + expect(getDialog()).not.toHaveAttribute('open'); + expect(opener).toHaveFocus(); + }); + + it('asks the browser for no focus ring after a pointer close and leaves it to the browser after a keyboard close', () => { + function openThenClose(closeWith: 'pointer' | 'keyboard') { + render(); + const opener = screen.getByRole('button', {name: 'Open list'}); + opener.focus(); + fireEvent.click(opener); + const focus = vi.spyOn(opener, 'focus'); + if (closeWith === 'pointer') { + fireEvent.pointerDown(getDialog()); + clickScrim(); + } else { + pressEscape(); + } + finishExit('list'); + return {opener, focus}; + } + const pointer = openThenClose('pointer'); + expect(pointer.opener).toHaveFocus(); + expect(pointer.focus.mock.calls).toEqual([[{focusVisible: false}]]); + document.body.innerHTML = ''; + const keyboard = openThenClose('keyboard'); + expect(keyboard.opener).toHaveFocus(); + expect(keyboard.focus.mock.calls).toEqual([[]]); + }); + }); + + describe('R10 host', () => { + it('locks scroll and traps focus once, on the stack', () => { + render(); + expect(document.querySelectorAll('dialog')).toHaveLength(1); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + expect(getDialog()).toHaveAttribute('aria-modal', 'true'); + }); + + it('opens non-modally without a scrim', () => { + render(); + expect(HTMLDialogElement.prototype.show).toHaveBeenCalledTimes(1); + expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled(); + expect(getDialog()).not.toHaveAttribute('aria-modal'); + }); + + it('warns about hasScrim on a level and ignores it', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + {}} + label="List" + hasScrim={false} + data-testid="list"> + List + + {}} + hasScrim={false}> + + A + + + , + ); + const scrimWarnings = warn.mock.calls.filter(call => + String(call[0]).includes( + '`hasScrim` on a BottomSheet or BottomSheetSwitcher inside a BottomSheetStack is ignored', + ), + ); + expect(scrimWarnings).toHaveLength(2); + // The host is still modal: the levels' hasScrim={false} did nothing. + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + expect(getDialog()).toHaveAttribute('aria-modal', 'true'); + }); + + it('every native close() runs while the shell is still displayed, and showModal/close pair once per cycle', () => { + render(); + fireEvent.click(screen.getByRole('button', {name: 'Close all'})); + finishExit('detail'); + finishExit('list'); + expect(getDialog()).not.toHaveAttribute('open'); + const stackCalls = nativeLog.filter(entry => entry.stack); + expect(stackCalls.map(entry => entry.call)).toEqual([ + 'showModal', + 'close', + ]); + expect(stackCalls.every(entry => entry.displayed)).toBe(true); + // After the close the shell is no longer displayed. + expect(isDisplayed(getDialog())).toBe(false); + + // Second cycle: one more pair. + fireEvent.click(screen.getByRole('button', {name: 'Open list'})); + pressEscape(); + finishExit('list'); + const again = nativeLog.filter(entry => entry.stack); + expect(again.map(entry => entry.call)).toEqual([ + 'showModal', + 'close', + 'showModal', + 'close', + ]); + expect(again.every(entry => entry.displayed)).toBe(true); + }); + + it('returns focus once, to the intended target, and the focus trap’s cleanup does not move it again', () => { + render(); + const opener = screen.getByRole('button', {name: 'Open list'}); + opener.focus(); + fireEvent.click(opener); + const focusCalls: Array<{ + target: string; + args: unknown[]; + afterClose: boolean; + }> = []; + const originalFocus = HTMLElement.prototype.focus; + const focusSpy = vi + .spyOn(HTMLElement.prototype, 'focus') + .mockImplementation(function (this: HTMLElement, ...args: unknown[]) { + focusCalls.push({ + target: this.textContent ?? this.tagName, + args, + afterClose: nativeLog.some(entry => entry.call === 'close'), + }); + return originalFocus.apply(this, args as []); + }); + fireEvent.pointerDown(getDialog()); + clickScrim(); + finishExit('list'); + focusSpy.mockRestore(); + expect(opener).toHaveFocus(); + const returns = focusCalls.filter(call => call.afterClose); + expect(returns).toEqual([ + {target: 'Open list', args: [{focusVisible: false}], afterClose: true}, + ]); + }); + + it('a nested level’s gestures never arm its covered parent: parent handlers ignore events whose native target is outside the parent panel', () => { + const parentSpy = vi.fn(); + const childSpy = vi.fn(); + render( + + + + Child + + + , + ); + expect(levelSummary()).toEqual(['Parent:covered:1', 'Child:top:0']); + const parentPanel = getPanel('Parent'); + const childPanel = getPanel('Child'); + // The child's panel is portaled out of the parent's DOM, but React + // still bubbles its events to the parent's handlers. + expect(parentPanel.contains(childPanel)).toBe(false); + const parentBody = parentPanel.querySelector( + '[aria-label="Parent"]', + ) as HTMLElement; + const childBody = childPanel.querySelector( + '[aria-label="Child"]', + ) as HTMLElement; + // jsdom has no pointer capture; the gesture hook calls it when it arms. + const parentCapture = vi.fn(); + const childCapture = vi.fn(); + ( + parentBody as HTMLElement & {setPointerCapture: unknown} + ).setPointerCapture = parentCapture; + ( + childBody as HTMLElement & {setPointerCapture: unknown} + ).setPointerCapture = childCapture; + // Body drag on the child: arms the child's body only. + fireEvent.pointerDown(childBody, { + pointerId: 7, + clientY: 0, + button: 0, + isPrimary: true, + }); + fireEvent.pointerMove(childBody, {pointerId: 7, clientY: 40}); + fireEvent.pointerMove(childBody, {pointerId: 7, clientY: 160}); + fireEvent.pointerUp(childBody, {pointerId: 7, clientY: 160}); + expect(parentCapture).not.toHaveBeenCalled(); + expect(childCapture).toHaveBeenCalled(); + expect(childSpy).toHaveBeenCalledWith(false); + expect(parentSpy).not.toHaveBeenCalled(); + // Handle swipe on the child closes the child only. + swipeDown('Child'); + expect(parentSpy).not.toHaveBeenCalled(); + }); + + it('an app onClick around the stack still sees clicks inside a nested level', () => { + const onClick = vi.fn(); + render( +
+ + + + + + + +
, + ); + fireEvent.click(screen.getByRole('button', {name: 'In child'})); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('forwards the ref, onCancel, and dialog attributes to the shared dialog', () => { + const ref = createRef(); + const onCancel = vi.fn(); + render( + + + , + ); + expect(ref.current).toBe(getDialog()); + expect(getDialog()).toHaveAttribute('data-testid', 'stack'); + fireEvent(getDialog(), new Event('cancel', {cancelable: true})); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/lab/src/BottomSheetStack/BottomSheetStack.tsx b/packages/lab/src/BottomSheetStack/BottomSheetStack.tsx new file mode 100644 index 0000000000000..0feade39ec834 --- /dev/null +++ b/packages/lab/src/BottomSheetStack/BottomSheetStack.tsx @@ -0,0 +1,750 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file BottomSheetStack.tsx + * @input Uses public @astryxdesign/core hooks (useFocusTrap, useScrollLock, + * useMergedRefs, useDevWarning), Layer (useLayerDismissal, + * LayerDepthProvider), utils, theme tokens, react-dom flushSync, and the + * Lab-only `@astryxdesign/core/BottomSheet/unstable_stack` host and + * parent-level contexts + * @output Exports BottomSheetStack and BottomSheetStackProps + * @position Lab (canary) host that stacks Core BottomSheet and + * BottomSheetSwitcher levels in one shared native dialog + * + * Every Core BottomSheet (`isOpen` / `onOpenChange`) and BottomSheetSwitcher + * (`activeSheet` / `onActiveSheetChange`) rendered anywhere under the stack + * is one level: through wrappers, DOM elements, Suspense, Activity, context + * providers, portals, and mapped lists alike. The host never reads its + * children. Each level registers itself in a layout effect, with a hidden + * in-place anchor and the id of the level whose content it is rendered in, + * and reports whether it is open and presented, its purpose, label, + * final-focus target, and a way to close it through its own callback. + * + * Order is tree order, derived from the DOM: sibling levels by their + * anchors' document position, a level rendered inside another level's content + * right above that level. The open set, top, bottom, every level's depth and + * `inert` all follow from the registrations. Each level owns the box its + * panel portals into; the host places the boxes in a slot inside its dialog + * in stack order on every commit, and a reorder that re-renders nothing is + * caught by a MutationObserver and applied before the next paint. + * + * The stack owns one dialog, one scrim, one modal boundary, one scroll lock, + * and one dismissal owner. Escape, a scrim tap, and a swipe close only the top + * level, through that level's own controlled callback. There is no close-all + * prop: callers close every level themselves, and a one-update close animates + * every level out together. A stack rendered inside another stack merges into + * the outer one with a development warning. + * + * Core levels learn about the stack through the unstable_stack host context, + * and join only at the host's layer depth: a Core Dialog, AlertDialog, or + * popover layer opened inside a level pushes its content one layer deeper, so + * a sheet (or stack) inside it is standalone above that layer. Without a + * stack the context is null and Core behaves exactly as today. + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/core/src/BottomSheet/unstable_stack.ts + * - /packages/lab/src/BottomSheetStack/BottomSheetStack.doc.mjs + * - /packages/lab/src/BottomSheetStack/BottomSheetStack.test.tsx + * - /packages/lab/src/BottomSheetStack/index.ts + * - /apps/storybook/stories/BottomSheetStack.stories.tsx + */ + +import { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type ReactNode, + type SyntheticEvent, +} from 'react'; +import {flushSync} from 'react-dom'; +import * as stylex from '@stylexjs/stylex'; +import type {BaseProps} from '@astryxdesign/core'; +import { + unstable_BottomSheetStackHostContext, + unstable_BottomSheetStackParentLevelContext, + unstable_focusForLastInput, + unstable_useBottomSheetStackContentDepth, + unstable_useBottomSheetStackHost, + type unstable_BottomSheetStackHostContextValue, + type unstable_BottomSheetStackLevelRegistration, + type unstable_BottomSheetStackLevelState, + type unstable_BottomSheetStackLevelView, +} from '@astryxdesign/core/BottomSheet/unstable_stack'; +import { + useDevWarning, + useFocusTrap, + useMergedRefs, + useScrollLock, +} from '@astryxdesign/core/hooks'; +import {LayerDepthProvider, useLayerDismissal} from '@astryxdesign/core/Layer'; +import { + colorVars, + durationVars, + easeVars, +} from '@astryxdesign/core/theme/tokens.stylex'; +import {composeEventHandlers, mergeProps} from '@astryxdesign/core/utils'; + +// JSX needs capitalized tags to treat the providers as components. +const HostContext = unstable_BottomSheetStackHostContext; +const ParentLevelContext = unstable_BottomSheetStackParentLevelContext; + +const styles = stylex.create({ + // The host shell matches Core's sheet dialogs: a full-viewport, transparent + // native dialog whose ::backdrop is the one scrim for every level. + dialog: { + position: 'fixed', + inset: 0, + width: '100dvw', + height: '100dvh', + maxWidth: 'none', + maxHeight: 'none', + margin: 0, + padding: 0, + borderWidth: 0, + borderStyle: 'none', + backgroundColor: 'transparent', + overflow: 'visible', + display: 'none', + outline: 'none', + }, + dialogOpen: { + display: 'block', + }, + dialogNonModal: { + pointerEvents: 'none', + zIndex: 1000, + width: '100%', + height: '100%', + }, + scrim: { + '::backdrop': { + backgroundColor: colorVars['--color-overlay'], + opacity: { + default: 'var(--_sheet-scrim-opacity, 1)', + '@starting-style': 0, + }, + transitionProperty: 'opacity, display', + transitionDuration: durationVars['--duration-medium'], + transitionTimingFunction: easeVars['--ease-standard'], + transitionBehavior: 'allow-discrete', + '@media (prefers-reduced-motion: reduce)': { + transitionDuration: '0.01s', + }, + }, + }, + // The dim leaves with the last level on a linear curve, as in Core. + scrimClosing: { + '::backdrop': { + transitionTimingFunction: 'linear', + }, + }, + // The slot holds one box per level, in stack order. The host never renders + // React children into it: the boxes are level-owned nodes placed here + // imperatively, so React and the host never fight over its children. + slot: { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + }, + // One box per level: full size, pointer-transparent, its own stacking + // context so a switcher level's internal panel order stays internal. Slot + // order is paint order, so a closing level slides out above the levels it + // covered. + level: { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + isolation: 'isolate', + }, + // Colours the iOS Safari toolbar strip below the stack. Same element and + // reasoning as Core's BottomSheetEdgeTint, which is not exported: WebKit + // samples the declared background of a fixed box flush with the bottom + // edge, and the mask keeps the strip invisible. + edgeTint: { + position: 'fixed', + insetInline: 0, + insetBlockEnd: 0, + height: '12px', + backgroundColor: colorVars['--color-background-surface'], + zIndex: 2, + pointerEvents: 'none', + maskImage: 'linear-gradient(transparent, transparent)', + WebkitMaskImage: 'linear-gradient(transparent, transparent)', + }, +}); + +const levelClassName = stylex.props(styles.level).className ?? ''; + +interface LevelEntry extends unstable_BottomSheetStackLevelRegistration { + state: unstable_BottomSheetStackLevelState | null; +} + +type LevelStatus = 'top' | 'covered' | 'exiting' | 'closed'; + +function compareAnchors(a: Element, b: Element): number { + if (a === b) { + return 0; + } + if (!a.isConnected || !b.isConnected) { + return a.isConnected ? -1 : b.isConnected ? 1 : 0; + } + const position = a.compareDocumentPosition(b); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; +} + +/** + * Tree order as a pre-order walk: levels at the host's root sorted by their + * anchors' document position, each followed by the levels rendered in its + * content, sorted the same way. A level whose parent is not registered is + * treated as a root level. + */ +function orderLevels(entries: ReadonlyMap): string[] { + const byParent = new Map(); + for (const entry of entries.values()) { + const parent = + entry.parentId != null && entries.has(entry.parentId) + ? entry.parentId + : null; + const siblings = byParent.get(parent) ?? []; + siblings.push(entry); + byParent.set(parent, siblings); + } + const out: string[] = []; + const visit = (parent: string | null) => { + const siblings = byParent.get(parent) ?? []; + siblings.sort((a, b) => compareAnchors(a.anchor, b.anchor)); + for (const entry of siblings) { + out.push(entry.id); + visit(entry.id); + } + }; + visit(null); + return out; +} + +function isSameOrder(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((id, index) => id === b[index]); +} + +function isSameLevelState( + a: unstable_BottomSheetStackLevelState | null, + b: unstable_BottomSheetStackLevelState, +): boolean { + return ( + a != null && + a.isOpen === b.isOpen && + a.isPresented === b.isPresented && + a.purpose === b.purpose && + a.label === b.label && + a.finalFocusRef === b.finalFocusRef && + a.dismiss === b.dismiss + ); +} + +export interface BottomSheetStackProps extends BaseProps { + /** Ref forwarded to the shared native dialog. */ + ref?: React.Ref; + + /** Called when the shared native dialog receives a cancel event. */ + onCancel?: (event: SyntheticEvent) => void; + + /** + * Whether the stack opens its one shared dialog modally with a scrim. + * Levels never set `hasScrim` themselves; one set on a level is ignored. + * @default true + */ + hasScrim?: boolean; + + /** + * The subtree whose BottomSheets (`isOpen` / `onOpenChange`) and + * BottomSheetSwitchers (`activeSheet` / `onActiveSheetChange`) are the + * levels, wherever they are rendered in it. Levels stack in tree order. + */ + children: ReactNode; +} + +/** + * Stacks Core bottom sheets. Every BottomSheet and BottomSheetSwitcher + * rendered under the stack is one level, in tree order; the last open level + * is the top and the only interactive one. Covered levels stay mounted, + * inert, and receded. Escape, scrim tap, and swipe close the top level + * through its own callback. + * + * @example + * ``` + * + * + * … + * + * + * … + * + * + * ``` + */ +export function BottomSheetStack(props: BottomSheetStackProps) { + // A stack at an outer stack's own layer depth merges into it. One inside a + // Core layer opened from a level (a Dialog, say) is above that stack and + // becomes a host of its own. + const outerHost = unstable_useBottomSheetStackHost(); + if (outerHost != null) { + return {props.children}; + } + return ; +} + +BottomSheetStack.displayName = 'BottomSheetStack'; + +/** + * A stack inside another stack is a pass-through boundary: its sheets keep + * registering with the outer host, at this stack's place in the tree, so one + * dialog and one scrim stay in charge and nothing disappears. Its own props + * have no effect. + */ +function NestedBottomSheetStack({children}: {children: ReactNode}) { + useDevWarning( + 'BottomSheetStack', + 'is rendered inside another BottomSheetStack. Its sheets join the outer ' + + 'stack as levels, and its own `hasScrim`, `ref`, and `onCancel` are ' + + 'ignored; the outer stack owns the one dialog and scrim.', + ); + return <>{children}; +} + +function BottomSheetStackHost({ + hasScrim = true, + children, + ref, + xstyle, + className, + style, + onCancel, + onClick, + ...props +}: BottomSheetStackProps) { + const dialogRef = useRef(null); + const slotRef = useRef(null); + const childrenRegionRef = useRef(null); + const dialogModeRef = useRef<'modal' | 'non-modal' | null>(null); + const triggerRef = useRef(null); + // The depth the children render at: one deeper than the host, which is + // what the LayerDepthProvider below gives them. Levels compare it with + // their own depth to decide whether they are this host's. + const layerDepth = unstable_useBottomSheetStackContentDepth(); + // The shell stays displayed through its own close(): the native close runs + // in a layout effect of the commit that ends presentation, and this latch + // keeps `display: block` until that effect has run, so the backdrop's exit + // transition starts from a displayed element rather than from `none`. + const [isShellOpen, setIsShellOpen] = useState(false); + + // The registry is mutated only in layout effects (commit phase), so a + // concurrent render never observes a half-applied change; `entries` and + // `order` are its snapshots for render. Order is state of its own because + // it depends on the DOM, not only on the registrations. + const registryRef = useRef(new Map()); + const [entries, setEntries] = useState>( + () => new Map(), + ); + const [order, setOrder] = useState([]); + const committedOrderRef = useRef(order); + + const attachBox = useCallback((box: HTMLElement) => { + const slot = slotRef.current; + if (slot != null && box.parentNode !== slot) { + slot.appendChild(box); + } + }, []); + + const recompute = useCallback(() => { + const next = orderLevels(registryRef.current); + setOrder(current => (isSameOrder(current, next) ? current : next)); + }, []); + + const register = useCallback( + (registration: unstable_BottomSheetStackLevelRegistration) => { + const {id, box} = registration; + const existing = registryRef.current.get(id); + registryRef.current.set(id, { + ...registration, + state: existing?.state ?? null, + }); + box.className = levelClassName; + // The box joins the slot now (in registration order; this commit's + // placement re-sorts it), so the level has a connected portal target + // from its first placed render on. + attachBox(box); + setEntries(new Map(registryRef.current)); + recompute(); + return () => { + if (registryRef.current.get(id)?.box === box) { + registryRef.current.delete(id); + box.remove(); + setEntries(new Map(registryRef.current)); + recompute(); + } + }; + }, + [attachBox, recompute], + ); + + const report = useCallback( + (id: string, state: unstable_BottomSheetStackLevelState) => { + const entry = registryRef.current.get(id); + if (entry == null || isSameLevelState(entry.state, state)) { + return; + } + registryRef.current.set(id, {...entry, state}); + setEntries(new Map(registryRef.current)); + }, + [], + ); + + // Derived from the snapshots: the open set in stack order, the top, the + // bottom of the open set, and presentation. + const openIds = order.filter(id => entries.get(id)?.state?.isOpen === true); + const topId = openIds.length === 0 ? null : openIds[openIds.length - 1]; + const bottomOpenId = openIds.length === 0 ? null : openIds[0]; + const presentedIds = order.filter( + id => entries.get(id)?.state?.isPresented === true, + ); + const isStackVisible = topId != null || presentedIds.length > 0; + const isModal = hasScrim && isStackVisible; + const topState = topId == null ? null : (entries.get(topId)?.state ?? null); + const topPurpose = topState?.purpose ?? 'info'; + const allowsEscapeDismiss = topState != null && topPurpose !== 'required'; + const allowsLightDismiss = topState != null && topPurpose === 'info'; + + const views = useMemo(() => { + const map = new Map(); + order.forEach((id, index) => { + if (!entries.has(id)) { + return; + } + let depth = 0; + for (let above = index + 1; above < order.length; above += 1) { + if (entries.get(order[above])?.state?.isOpen === true) { + depth += 1; + } + } + map.set(id, {depth, isTop: id === topId}); + }); + return map; + }, [entries, order, topId]); + + const setScrimOpacity = useCallback((opacity: number) => { + dialogRef.current?.style.setProperty( + '--_sheet-scrim-opacity', + String(opacity), + ); + }, []); + // Only the committed top level drives the shared backdrop; a pointer still + // captured by a level that just closed must not. + const committedTopIdRef = useRef(topId); + useLayoutEffect(() => { + committedTopIdRef.current = topId; + setScrimOpacity(topId == null ? 0 : 1); + }, [setScrimOpacity, topId]); + const forwardScrimOpacity = useCallback( + (id: string, opacity: number) => { + if (id === committedTopIdRef.current) { + setScrimOpacity(opacity); + } + }, + [setScrimOpacity], + ); + + // Place the boxes in the slot in stack order and mark them. Imperative on + // purpose: the boxes are level-owned nodes, and a level needs its box + // placed and marked in the same synchronous pass that registered it, so + // the first painted frame has the right order, depth, and inertness. + const placeBoxes = useCallback( + ( + placedOrder: readonly string[], + placedEntries: ReadonlyMap, + placedTopId: string | null, + placedViews: ReadonlyMap, + ) => { + const slot = slotRef.current; + if (slot == null) { + return; + } + placedOrder.forEach((id, index) => { + const entry = placedEntries.get(id); + if (entry == null) { + return; + } + const {box, kind} = entry; + const isTop = id === placedTopId; + const status: LevelStatus = isTop + ? 'top' + : entry.state?.isOpen === true + ? 'covered' + : entry.state?.isPresented === true + ? 'exiting' + : 'closed'; + box.setAttribute('data-bottom-sheet-stack-level', String(index)); + box.setAttribute('data-bottom-sheet-stack-level-kind', kind); + box.setAttribute('data-bottom-sheet-stack-level-state', status); + box.setAttribute( + 'data-bottom-sheet-stack-level-depth', + String(placedViews.get(id)?.depth ?? 0), + ); + box.style.zIndex = String(index + 1); + if (isTop) { + box.removeAttribute('inert'); + box.removeAttribute('aria-hidden'); + } else { + box.setAttribute('inert', ''); + box.setAttribute('aria-hidden', 'true'); + } + if (slot.children[index] !== box) { + slot.insertBefore(box, slot.children[index] ?? null); + } + }); + // A box that is no longer registered leaves; one registered in this + // commit but not yet in the placed order stays where registration + // appended it until the synchronous follow-up places it. + const registered = new Set(); + for (const entry of registryRef.current.values()) { + registered.add(entry.box); + } + for (const child of Array.from(slot.children)) { + if (!registered.has(child)) { + child.remove(); + } + } + }, + [], + ); + + useLayoutEffect(() => { + committedOrderRef.current = order; + placeBoxes(order, entries, topId, views); + }); + + // Order follows the DOM, so every host commit re-derives it, and every + // level touches the host on its own commits. A reorder of memoised + // subtrees that re-renders neither runs no React code at all; the observer + // catches that one and applies the new order before the next paint. + useLayoutEffect(() => { + recompute(); + }); + useLayoutEffect(() => { + const region = childrenRegionRef.current; + if (region == null || typeof MutationObserver === 'undefined') { + return; + } + const observer = new MutationObserver(() => { + const next = orderLevels(registryRef.current); + if (isSameOrder(committedOrderRef.current, next)) { + return; + } + flushSync(() => { + setOrder(next); + }); + }); + observer.observe(region, {childList: true, subtree: true}); + return () => observer.disconnect(); + }, []); + + const dismissOnEscape = useCallback(() => { + if (allowsEscapeDismiss) { + topState?.dismiss(); + } + }, [allowsEscapeDismiss, topState]); + const dismissOnLightInteraction = useCallback(() => { + if (allowsLightDismiss) { + topState?.dismiss(); + } + }, [allowsLightDismiss, topState]); + + const {containerRef} = useFocusTrap({ + isActive: isModal, + }); + const {shouldDismissOnCloseRequest} = useLayerDismissal({ + isActive: isStackVisible, + escapeBehavior: allowsEscapeDismiss ? 'close' : 'block', + onDismiss: dismissOnEscape, + getContainer: () => dialogRef.current, + isPresent: () => dialogRef.current?.open ?? false, + }); + useScrollLock(isModal); + + // Focus return when the whole stack closes: the bottom level of the last + // open set decides (its `finalFocusRef` when it has one), else the element + // that opened the first level. + const closeFocusBottomIdRef = useRef(null); + useLayoutEffect(() => { + if (bottomOpenId != null) { + closeFocusBottomIdRef.current = bottomOpenId; + } + }, [bottomOpenId]); + + // One shared shell for every level. A modal stack enters the native top + // layer once; pushes and pops only change levels inside it. The last + // presented level owns the exit timing, so isStackVisible drops only when + // the dialog can close and focus can return. Focus returns here, in the + // same synchronous pass as the close; the focus trap's own cleanup runs + // afterwards and finds focus already placed, so it leaves it alone. + useLayoutEffect(() => { + const dialog = dialogRef.current; + if (dialog == null) { + return; + } + + if (isStackVisible) { + const nextMode = hasScrim ? 'modal' : 'non-modal'; + if (dialog.open && dialogModeRef.current !== nextMode) { + dialog.close(); + } + if (!dialog.open) { + if (hasScrim && triggerRef.current == null) { + triggerRef.current = document.activeElement as HTMLElement | null; + } + if (hasScrim) { + dialog.showModal(); + } else { + dialog.show(); + } + } + dialogModeRef.current = nextMode; + // Latches the shell's display until its own close() has run. + setIsShellOpen(true); + return; + } + + if (dialog.open) { + dialog.close(); + } + // Releases the display latch after close(), in the same synchronous pass. + setIsShellOpen(false); + if (dialogModeRef.current === 'modal') { + const bottomId = closeFocusBottomIdRef.current; + const bottomFinalFocus = + bottomId == null + ? null + : (registryRef.current.get(bottomId)?.state?.finalFocusRef?.current ?? + null); + // Returned focus rings only after keyboard input, as a standalone + // sheet's return does; a pointer close leaves no ring. + const returnTarget = bottomFinalFocus ?? triggerRef.current; + if (returnTarget != null) { + unstable_focusForLastInput(returnTarget); + } + } + closeFocusBottomIdRef.current = null; + triggerRef.current = null; + dialogModeRef.current = null; + }, [hasScrim, isStackVisible]); + + const handleCancel = useCallback( + (event: SyntheticEvent) => { + // Always keep the controlled dialog open; answer the platform's close + // request with the shared stack's topmost rule. + event.preventDefault(); + if (shouldDismissOnCloseRequest()) { + dismissOnEscape(); + } + }, + [dismissOnEscape, shouldDismissOnCloseRequest], + ); + const handleClick = useCallback( + (event: ReactMouseEvent) => { + if (hasScrim && event.target === event.currentTarget) { + dismissOnLightInteraction(); + } + }, + [dismissOnLightInteraction, hasScrim], + ); + + const hostValue = useMemo( + () => ({ + hasScrim, + layerDepth, + register, + report, + touch: recompute, + onScrimOpacity: forwardScrimOpacity, + views, + }), + [ + forwardScrimOpacity, + hasScrim, + layerDepth, + recompute, + register, + report, + views, + ], + ); + + const activeLabel = + topState?.label ?? + entries.get(presentedIds[presentedIds.length - 1] ?? '')?.state?.label; + const ariaLabel = + props['aria-label'] ?? + (props['aria-labelledby'] == null ? activeLabel : undefined); + + const mergedDialogRef = useMergedRefs(ref, dialogRef, containerRef); + const dialogPresentationProps = mergeProps( + stylex.props( + styles.dialog, + (isStackVisible || isShellOpen) && styles.dialogOpen, + hasScrim && styles.scrim, + hasScrim && isStackVisible && topId == null && styles.scrimClosing, + !hasScrim && styles.dialogNonModal, + xstyle, + ), + className, + style, + ); + + // The slot is a leaf the host never renders React children into. The + // children region holds the levels' anchors and whatever wrapper DOM the + // subtree renders; it is hidden so a wrapper's own output (a Suspense + // fallback, a padded div) never shows on the scrim. Portals and contexts + // are unaffected by the hidden ancestor. + return ( + +
+ +
+ ); +} diff --git a/packages/lab/src/BottomSheetStack/index.ts b/packages/lab/src/BottomSheetStack/index.ts new file mode 100644 index 0000000000000..ba28ab876fc52 --- /dev/null +++ b/packages/lab/src/BottomSheetStack/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file index.ts + * @input BottomSheetStack component + * @output Public BottomSheetStack entry point for @astryxdesign/lab + * @position Component barrel; re-exported by /packages/lab/src/index.ts + */ + +export {BottomSheetStack} from './BottomSheetStack'; +export type {BottomSheetStackProps} from './BottomSheetStack'; diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index 86ba68beb4ca9..6e9dd493bfdf6 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -267,6 +267,12 @@ export { type MobileTokenizerChange, } from './MobileTokenizer'; +// BottomSheetStack — Lab host that stacks Core BottomSheet and +// BottomSheetSwitcher levels in one shared dialog. Every sheet rendered under +// the stack is one level; levels stack in tree order and only the top is +// interactive. +export {BottomSheetStack, type BottomSheetStackProps} from './BottomSheetStack'; + // RichTextEditor (RFC facebook/astryx#3899) has graduated out of @astryxdesign/lab // into its own canary-only package, @astryxdesign/richtext, so it can be canaried // independently by an adopting application. Import it from there: diff --git a/scripts/sync-exports.js b/scripts/sync-exports.js index ddc98d5f035c4..f1c54264f6436 100644 --- a/scripts/sync-exports.js +++ b/scripts/sync-exports.js @@ -221,8 +221,15 @@ const UTIL_SUBPATH_DIRS = [ * * `Markdown/remark` is the limited Remark compatibility adapter * (`module:Markdown/remark`, `spec:AST-036` FR24). + * + * `BottomSheet/unstable_stack` is the `unstable_`-prefixed coordination + * context between Core sheet hosts and the Lab BottomSheetStack. It is not a + * public API: no docs, no root barrel export, no stability promise. */ -const FILE_MODULE_SUBPATH_EXPORTS = ['Markdown/remark']; +const FILE_MODULE_SUBPATH_EXPORTS = [ + 'Markdown/remark', + 'BottomSheet/unstable_stack', +]; /** * Discover all exportable directories under src/.