From 841aefde752dd7722d16d2b2fbb4fa0aa67a83bc Mon Sep 17 00:00:00 2001 From: Ivor Zhou Date: Mon, 31 Aug 2026 22:09:43 -0700 Subject: [PATCH] feat(core): add controlled BottomSheetStack --- .changeset/stacked-bottom-sheets.md | 8 + .../stories/BottomSheetStack.stories.tsx | 121 ++++ docs/families/overlay-dismissal.md | 5 +- .../BottomSheetStackShowcase.doc.mjs | 24 + .../BottomSheet/BottomSheetStackShowcase.tsx | 73 +++ .../core/src/BottomSheet/BottomSheet.doc.mjs | 22 +- packages/core/src/BottomSheet/BottomSheet.tsx | 262 +++++++- .../src/BottomSheet/BottomSheetStack.doc.mjs | 188 ++++++ .../src/BottomSheet/BottomSheetStack.test.tsx | 235 ++++++++ .../core/src/BottomSheet/BottomSheetStack.tsx | 557 ++++++++++++++++++ .../BottomSheet/BottomSheetStackContext.ts | 44 ++ .../src/BottomSheet/BottomSheetSwitcher.tsx | 40 +- packages/core/src/BottomSheet/index.ts | 4 +- 13 files changed, 1538 insertions(+), 45 deletions(-) create mode 100644 .changeset/stacked-bottom-sheets.md create mode 100644 apps/storybook/stories/BottomSheetStack.stories.tsx create mode 100644 packages/cli/assets/templates/blocks/components/BottomSheet/BottomSheetStackShowcase.doc.mjs create mode 100644 packages/cli/assets/templates/blocks/components/BottomSheet/BottomSheetStackShowcase.tsx create mode 100644 packages/core/src/BottomSheet/BottomSheetStack.doc.mjs create mode 100644 packages/core/src/BottomSheet/BottomSheetStack.test.tsx create mode 100644 packages/core/src/BottomSheet/BottomSheetStack.tsx create mode 100644 packages/core/src/BottomSheet/BottomSheetStackContext.ts diff --git a/.changeset/stacked-bottom-sheets.md b/.changeset/stacked-bottom-sheets.md new file mode 100644 index 0000000000000..b2ca9b5bbae5a --- /dev/null +++ b/.changeset/stacked-bottom-sheets.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': patch +'@astryxdesign/cli': patch +--- + +[feat] Add BottomSheetStack for controlled, layered bottom-sheet navigation. + +@imdreamrunner diff --git a/apps/storybook/stories/BottomSheetStack.stories.tsx b/apps/storybook/stories/BottomSheetStack.stories.tsx new file mode 100644 index 0000000000000..2ec88f3998c13 --- /dev/null +++ b/apps/storybook/stories/BottomSheetStack.stories.tsx @@ -0,0 +1,121 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import type {Meta, StoryObj} from '@storybook/react'; +import {useState} from 'react'; +import {BottomSheet, BottomSheetStack} from '@astryxdesign/core/BottomSheet'; +import {Button} from '@astryxdesign/core/Button'; +import {Divider} from '@astryxdesign/core/Divider'; +import {Heading} from '@astryxdesign/core/Heading'; +import {Section} from '@astryxdesign/core/Section'; +import {HStack, VStack} from '@astryxdesign/core/Stack'; +import {Text} from '@astryxdesign/core/Text'; + +const meta: Meta = { + title: 'Core/BottomSheetStack', + component: BottomSheetStack, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + story: {inline: false, height: '560px'}, + }, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +function StackExample() { + const [openSheetIds, setOpenSheetIds] = useState>([]); + const push = (sheetId: string) => + setOpenSheetIds(current => [...current, sheetId]); + const pop = () => + setOpenSheetIds(current => current.slice(0, current.length - 1)); + + return ( + <> + + + + + + + + + + + ); +} + +describe('BottomSheetStack', () => { + it('keeps covered sheets mounted and makes only the top sheet interactive', () => { + render(); + + fireEvent.click(screen.getByRole('button', {name: 'Open filters'})); + const filtersLayer = getSheetLayer('filters-sheet'); + const detailsLayer = getSheetLayer('details-sheet'); + const showDetails = screen.getByRole('button', {name: 'Show details'}); + showDetails.focus(); + fireEvent.click(showDetails); + + expect(filtersLayer).not.toHaveAttribute('hidden'); + expect(filtersLayer).toHaveAttribute('aria-hidden', 'true'); + expect(filtersLayer).toHaveAttribute('inert'); + expect(filtersLayer.style.getPropertyValue('--x-transform')).toContain( + 'scale(0.96)', + ); + expect(detailsLayer).not.toHaveAttribute('hidden'); + expect(detailsLayer).not.toHaveAttribute('aria-hidden'); + expect(detailsLayer).not.toHaveAttribute('inert'); + expect(detailsLayer.style.getPropertyValue('--x-transform')).toContain( + 'scale(1)', + ); + expect(getSharedDialog()).toHaveAccessibleName('Details'); + expect(document.querySelectorAll('dialog[open]')).toHaveLength(1); + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1); + }); + + it('dismisses only the top sheet from the shared scrim and restores covered focus', () => { + render(); + + fireEvent.click(screen.getByRole('button', {name: 'Open filters'})); + const showDetails = screen.getByRole('button', {name: 'Show details'}); + showDetails.focus(); + fireEvent.click(showDetails); + + const details = screen.getByTestId('details-sheet'); + fireEvent.click(getSharedDialog()); + + expect(getSheetLayer('filters-sheet')).not.toHaveAttribute('inert'); + expect(getSheetLayer('details-sheet')).toHaveAttribute('inert'); + expect(getSharedDialog()).toHaveAccessibleName('Filters'); + expect(showDetails).toHaveFocus(); + + finishSheetTransition(details); + expect(getSheetLayer('details-sheet')).toHaveAttribute('hidden'); + expect(getSharedDialog()).toHaveAttribute('open'); + }); + + it('keeps the shared dialog until the root exit finishes, then restores the opener', () => { + render(); + + const opener = screen.getByRole('button', {name: 'Open filters'}); + opener.focus(); + fireEvent.click(opener); + const filters = screen.getByTestId('filters-sheet'); + + fireEvent.click(getSharedDialog()); + expect(getSharedDialog()).toHaveAttribute('open'); + expect(getSheetLayer('filters-sheet')).toHaveAttribute('inert'); + + finishSheetTransition(filters); + expect(document.querySelector('dialog[open]')).toBeNull(); + expect(opener).toHaveFocus(); + }); + + it('requests a suffix pop for Escape', () => { + const onOpenSheetIdsChange = vi.fn(); + render( + + + Filters + + + Details + + , + ); + + fireEvent.keyDown(document, {key: 'Escape'}); + + expect(onOpenSheetIdsChange).toHaveBeenCalledTimes(1); + expect(onOpenSheetIdsChange).toHaveBeenCalledWith(['filters']); + }); + + it('uses the top sheet purpose for implicit dismissal', () => { + const onOpenSheetIdsChange = vi.fn(); + render( + + + Filters + + + Required + + , + ); + + expect(getSharedDialog()).toHaveAttribute('role', 'alertdialog'); + fireEvent.keyDown(document, {key: 'Escape'}); + fireEvent.click(getSharedDialog()); + expect(onOpenSheetIdsChange).not.toHaveBeenCalled(); + }); + + it('uses one non-modal shell and still routes Escape to the top sheet', () => { + const onOpenSheetIdsChange = vi.fn(); + render( + + + Filters + + + Details + + , + ); + + expect(HTMLDialogElement.prototype.show).toHaveBeenCalledTimes(1); + expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled(); + expect(getSharedDialog()).not.toHaveAttribute('aria-modal'); + + fireEvent.keyDown(document, {key: 'Escape'}); + expect(onOpenSheetIdsChange).toHaveBeenCalledWith(['filters']); + }); +}); diff --git a/packages/core/src/BottomSheet/BottomSheetStack.tsx b/packages/core/src/BottomSheet/BottomSheetStack.tsx new file mode 100644 index 0000000000000..e49390859c855 --- /dev/null +++ b/packages/core/src/BottomSheet/BottomSheetStack.tsx @@ -0,0 +1,557 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file BottomSheetStack.tsx + * @input Uses React context, StyleX, focus/scroll-lock/layer hooks, BottomSheetStackContext + * @output Exports BottomSheetStack and BottomSheetStackProps + * @position Core controller for visibly stacked BottomSheet flows + * + * BottomSheetStack turns declaratively nested BottomSheets into one controlled + * ordered stack. `openSheetIds` is ordered bottom-to-top. The last sheet is the + * only interactive and dismissible sheet; covered sheets stay mounted, visible, + * inert, and progressively transformed by their depth. + * + * All sheets render inside one stack-owned native . This keeps one modal + * boundary, one scrim, one focus trap, and one scroll lock across push and pop + * transitions. Logical state follows the controlled array immediately while a + * removed top sheet may remain mounted long enough to finish its exit motion. + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/core/src/BottomSheet/BottomSheet.tsx + * - /packages/core/src/BottomSheet/BottomSheetEdgeTint.tsx + * - /packages/core/src/BottomSheet/BottomSheetStackContext.ts + * - /packages/core/src/BottomSheet/BottomSheetStack.doc.mjs + * - /packages/core/src/BottomSheet/BottomSheetStack.test.tsx + * - /packages/core/src/BottomSheet/index.ts + * - /apps/storybook/stories/BottomSheetStack.stories.tsx + * - /packages/cli/assets/templates/blocks/components/BottomSheet/BottomSheetStackShowcase.tsx + * - /docs/families/overlay-dismissal.md + */ + +import { + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type ReactNode, + type SyntheticEvent, +} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import type {BaseProps} from '../BaseProps'; +import type {DialogPurpose} from '../Dialog'; +import { + useDevWarning, + useFocusTrap, + useMergedRefs, + useScrollLock, +} from '../hooks'; +import {LayerDepthProvider} from '../Layer/LayerDepthContext'; +import {useLayerDismissal} from '../Layer/useLayerDismissal'; +import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex'; +import {composeEventHandlers, mergeProps} from '../utils'; +import {BottomSheetEdgeTint} from './BottomSheetEdgeTint'; +import { + BottomSheetStackContext, + type BottomSheetStackContextValue, + type BottomSheetStackPhase, + type BottomSheetStackTransitionEvent, +} from './BottomSheetStackContext'; +import {BottomSheetSwitcherContext} from './BottomSheetSwitcherContext'; + +const styles = stylex.create({ + dialog: { + position: 'fixed', + inset: 0, + width: '100dvw', + height: '100dvh', + maxWidth: 'none', + maxHeight: 'none', + margin: 0, + padding: 0, + border: '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', + }, + }, + }, +}); + +interface StackTransitionState { + enteringSheet: string | null; + exitingSheet: string | null; +} + +const IDLE_TRANSITION: StackTransitionState = { + enteringSheet: null, + exitingSheet: null, +}; + +function stacksEqual( + first: ReadonlyArray, + second: ReadonlyArray, +): boolean { + return ( + first.length === second.length && + first.every((sheetId, index) => sheetId === second[index]) + ); +} + +function isPrefix( + prefix: ReadonlyArray, + sheets: ReadonlyArray, +): boolean { + return prefix.every((sheetId, index) => sheetId === sheets[index]); +} + +function transitionForOpenSheetIdsChange( + previousSheets: ReadonlyArray, + nextSheets: ReadonlyArray, +): StackTransitionState { + if ( + nextSheets.length === previousSheets.length + 1 && + isPrefix(previousSheets, nextSheets) + ) { + return { + enteringSheet: nextSheets[nextSheets.length - 1] ?? null, + exitingSheet: null, + }; + } + if ( + previousSheets.length === nextSheets.length + 1 && + isPrefix(nextSheets, previousSheets) + ) { + return { + enteringSheet: null, + exitingSheet: previousSheets[previousSheets.length - 1] ?? null, + }; + } + return IDLE_TRANSITION; +} + +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; + + /** + * Open BottomSheet IDs ordered bottom-to-top. IDs must be unique and match + * nested BottomSheet `sheetId` values. An empty array closes the stack. + */ + openSheetIds: ReadonlyArray; + + /** Called with the next stack when the top sheet requests dismissal. */ + onOpenSheetIdsChange: (openSheetIds: ReadonlyArray) => void; + + /** + * Whether to open the shared dialog modally with its native ::backdrop. + * Disable for a viewport-anchored, non-modal stack over an interactive page. + * @default true + */ + hasScrim?: boolean; + + /** BottomSheets identified by unique `sheetId` values. */ + children: ReactNode; +} + +/** + * Coordinates BottomSheets as an ordered visual stack inside one shared native + * dialog. Only the last openSheetIds entry is interactive and dismissible. + */ +export function BottomSheetStack({ + openSheetIds, + onOpenSheetIdsChange, + hasScrim = true, + children, + ref, + xstyle, + className, + style, + onCancel, + onClick, + ...props +}: BottomSheetStackProps) { + const dialogRef = useRef(null); + const dialogModeRef = useRef<'modal' | 'non-modal' | null>(null); + const triggerRef = useRef(null); + const sheetElementsRef = useRef(new Map()); + const committedOpenSheetIdsRef = useRef>([ + ...openSheetIds, + ]); + const [sheetLabels, setSheetLabels] = useState>( + () => new Map(), + ); + const [sheetPurposes, setSheetPurposes] = useState< + ReadonlyMap + >(() => new Map()); + const [transition, setTransition] = + useState(IDLE_TRANSITION); + + const openSheetIdsChanged = !stacksEqual( + committedOpenSheetIdsRef.current, + openSheetIds, + ); + const visibleTransition = openSheetIdsChanged + ? transitionForOpenSheetIdsChange( + committedOpenSheetIdsRef.current, + openSheetIds, + ) + : transition; + const topSheet = openSheetIds[openSheetIds.length - 1] ?? null; + const isStackVisible = + topSheet != null || visibleTransition.exitingSheet != null; + const isModal = hasScrim && isStackVisible; + const topSheetPurpose = + (topSheet == null + ? visibleTransition.exitingSheet == null + ? null + : sheetPurposes.get(visibleTransition.exitingSheet) + : sheetPurposes.get(topSheet)) ?? 'info'; + const allowsEscapeDismiss = topSheetPurpose !== 'required'; + const allowsLightDismiss = topSheetPurpose === 'info'; + + useDevWarning( + 'BottomSheetStack', + '`openSheetIds` must contain unique BottomSheet IDs ordered bottom-to-top.', + new Set(openSheetIds).size !== openSheetIds.length, + ); + + useLayoutEffect(() => { + const previousSheets = committedOpenSheetIdsRef.current; + if (stacksEqual(previousSheets, openSheetIds)) { + return; + } + committedOpenSheetIdsRef.current = [...openSheetIds]; + const nextTransition = transitionForOpenSheetIdsChange( + previousSheets, + openSheetIds, + ); + setTransition( + nextTransition.exitingSheet != null && + !sheetElementsRef.current.has(nextTransition.exitingSheet) + ? IDLE_TRANSITION + : nextTransition, + ); + }, [openSheetIds]); + + const requestTopDismiss = useCallback(() => { + if (topSheet != null) { + onOpenSheetIdsChange(openSheetIds.slice(0, -1)); + } + }, [onOpenSheetIdsChange, openSheetIds, topSheet]); + const dismissOnEscape = useCallback(() => { + if (allowsEscapeDismiss) { + requestTopDismiss(); + } + }, [allowsEscapeDismiss, requestTopDismiss]); + const dismissOnLightInteraction = useCallback(() => { + if (allowsLightDismiss) { + requestTopDismiss(); + } + }, [allowsLightDismiss, requestTopDismiss]); + + 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); + + 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; + return; + } + + if (dialog.open) { + dialog.close(); + } + if (dialogModeRef.current === 'modal') { + triggerRef.current?.focus(); + } + triggerRef.current = null; + dialogModeRef.current = null; + }, [hasScrim, isStackVisible]); + + const getSheetPhase = useCallback( + (sheetId: string): BottomSheetStackPhase => { + if (sheetId === visibleTransition.exitingSheet) { + return 'exiting'; + } + const sheetIndex = openSheetIds.lastIndexOf(sheetId); + if (sheetIndex < 0) { + return 'hidden'; + } + if (sheetIndex === openSheetIds.length - 1) { + return sheetId === visibleTransition.enteringSheet + ? 'entering' + : 'active'; + } + return 'covered'; + }, + [openSheetIds, visibleTransition], + ); + + const getSheetDepth = useCallback( + (sheetId: string) => { + const sheetIndex = openSheetIds.lastIndexOf(sheetId); + return sheetIndex < 0 ? 0 : openSheetIds.length - 1 - sheetIndex; + }, + [openSheetIds], + ); + + const getSheetLayer = useCallback( + (sheetId: string) => { + if (sheetId === visibleTransition.exitingSheet) { + return openSheetIds.length + 1; + } + const sheetIndex = openSheetIds.lastIndexOf(sheetId); + return sheetIndex < 0 ? 0 : sheetIndex + 1; + }, + [openSheetIds, visibleTransition.exitingSheet], + ); + + const registerSheetElement = useCallback( + (sheetId: string, element: HTMLElement | null) => { + if (element == null) { + sheetElementsRef.current.delete(sheetId); + setTransition(current => + current.enteringSheet === sheetId || current.exitingSheet === sheetId + ? IDLE_TRANSITION + : current, + ); + } else { + sheetElementsRef.current.set(sheetId, element); + } + }, + [], + ); + + const registerSheetLabel = useCallback( + (sheetId: string, label: string | null) => { + setSheetLabels(current => { + if (label == null) { + if (!current.has(sheetId)) { + return current; + } + const next = new Map(current); + next.delete(sheetId); + return next; + } + if (current.get(sheetId) === label) { + return current; + } + const next = new Map(current); + next.set(sheetId, label); + return next; + }); + }, + [], + ); + + const registerSheetPurpose = useCallback( + (sheetId: string, purpose: DialogPurpose | null) => { + setSheetPurposes(current => { + if (purpose == null) { + if (!current.has(sheetId)) { + return current; + } + const next = new Map(current); + next.delete(sheetId); + return next; + } + if (current.get(sheetId) === purpose) { + return current; + } + const next = new Map(current); + next.set(sheetId, purpose); + return next; + }); + }, + [], + ); + + const onSheetTransitionComplete = useCallback( + ({sheetId, phase}: BottomSheetStackTransitionEvent) => { + setTransition(current => { + if (phase === 'entering' && current.enteringSheet === sheetId) { + return {...current, enteringSheet: null}; + } + if (phase === 'exiting' && current.exitingSheet === sheetId) { + return {...current, exitingSheet: null}; + } + return current; + }); + }, + [], + ); + + const setScrimOpacity = useCallback((opacity: number) => { + dialogRef.current?.style.setProperty( + '--_sheet-scrim-opacity', + String(opacity), + ); + }, []); + const onSheetScrimOpacityChange = useCallback( + (sheetId: string, opacity: number) => { + const committedSheets = committedOpenSheetIdsRef.current; + if (sheetId !== committedSheets[committedSheets.length - 1]) { + return; + } + setScrimOpacity(opacity); + }, + [setScrimOpacity], + ); + + useLayoutEffect(() => { + setScrimOpacity(topSheet == null ? 0 : 1); + }, [setScrimOpacity, topSheet]); + + const contextValue = useMemo( + () => ({ + openSheetIds, + topSheet, + hasScrim, + requestTopDismiss, + getSheetPhase, + getSheetDepth, + getSheetLayer, + registerSheetElement, + registerSheetLabel, + registerSheetPurpose, + onSheetTransitionComplete, + onSheetScrimOpacityChange, + }), + [ + getSheetDepth, + getSheetLayer, + getSheetPhase, + hasScrim, + onSheetScrimOpacityChange, + onSheetTransitionComplete, + openSheetIds, + registerSheetElement, + registerSheetLabel, + registerSheetPurpose, + requestTopDismiss, + topSheet, + ], + ); + + const activeLabel = + (topSheet == null ? null : sheetLabels.get(topSheet)) ?? + (visibleTransition.exitingSheet == null + ? undefined + : sheetLabels.get(visibleTransition.exitingSheet)); + + const handleCancel = useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + if (shouldDismissOnCloseRequest()) { + dismissOnEscape(); + } + }, + [dismissOnEscape, shouldDismissOnCloseRequest], + ); + const handleClick = useCallback( + (event: ReactMouseEvent) => { + if (hasScrim && event.target === event.currentTarget) { + dismissOnLightInteraction(); + } + }, + [dismissOnLightInteraction, hasScrim], + ); + + const dialogStyleProps = stylex.props( + styles.dialog, + isStackVisible && styles.dialogOpen, + hasScrim && styles.scrim, + !hasScrim && styles.dialogNonModal, + xstyle, + ); + const dialogPresentationProps = mergeProps( + dialogStyleProps, + className, + style, + ); + const ariaLabel = + props['aria-label'] ?? + (props['aria-labelledby'] == null ? activeLabel : undefined); + + return ( + + + + {children} + + + + + ); +} + +BottomSheetStack.displayName = 'BottomSheetStack'; diff --git a/packages/core/src/BottomSheet/BottomSheetStackContext.ts b/packages/core/src/BottomSheet/BottomSheetStackContext.ts new file mode 100644 index 0000000000000..2956da038f4c8 --- /dev/null +++ b/packages/core/src/BottomSheet/BottomSheetStackContext.ts @@ -0,0 +1,44 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file BottomSheetStackContext.ts + * @input Uses React context + * @output Internal stack phase and lifecycle context shared by BottomSheetStack and BottomSheet + * @position Private coordination layer for visibly stacked BottomSheets + */ + +import {createContext} from 'react'; +import type {DialogPurpose} from '../Dialog'; + +export type BottomSheetStackPhase = + 'entering' | 'active' | 'covered' | 'exiting' | 'hidden'; + +export interface BottomSheetStackTransitionEvent { + sheetId: string; + phase: 'entering' | 'exiting'; +} + +export interface BottomSheetStackContextValue { + openSheetIds: ReadonlyArray; + topSheet: string | null; + hasScrim: boolean; + requestTopDismiss: () => void; + getSheetPhase: (sheetId: string) => BottomSheetStackPhase; + getSheetDepth: (sheetId: string) => number; + getSheetLayer: (sheetId: string) => number; + registerSheetElement: (sheetId: string, element: HTMLElement | null) => void; + registerSheetLabel: (sheetId: string, label: string | null) => void; + registerSheetPurpose: ( + sheetId: string, + purpose: DialogPurpose | null, + ) => void; + onSheetTransitionComplete: (event: BottomSheetStackTransitionEvent) => void; + onSheetScrimOpacityChange: (sheetId: string, opacity: number) => void; +} + +export const BottomSheetStackContext = + createContext(null); + +BottomSheetStackContext.displayName = 'BottomSheetStackContext'; diff --git a/packages/core/src/BottomSheet/BottomSheetSwitcher.tsx b/packages/core/src/BottomSheet/BottomSheetSwitcher.tsx index 3012c53b75325..d32614dbdb0d5 100644 --- a/packages/core/src/BottomSheet/BottomSheetSwitcher.tsx +++ b/packages/core/src/BottomSheet/BottomSheetSwitcher.tsx @@ -4,7 +4,7 @@ /** * @file BottomSheetSwitcher.tsx - * @input Uses React context, StyleX, theme tokens, focus/scroll-lock hooks, BottomSheetSwitcherContext + * @input Uses React context, StyleX, theme tokens, focus/scroll-lock hooks, sheet controller contexts * @output Exports BottomSheetSwitcher and BottomSheetSwitcherProps * @position Core switcher for mutually exclusive BottomSheet flows * @@ -22,6 +22,7 @@ * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/BottomSheet/BottomSheet.tsx + * - /packages/core/src/BottomSheet/BottomSheetStackContext.ts * - /packages/core/src/BottomSheet/BottomSheetEdgeTint.tsx * - /packages/core/src/BottomSheet/BottomSheetSwitcher.doc.mjs * - /packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx @@ -53,6 +54,7 @@ import { } from '../hooks'; import {composeEventHandlers, mergeProps} from '../utils'; import {BottomSheetEdgeTint} from './BottomSheetEdgeTint'; +import {BottomSheetStackContext} from './BottomSheetStackContext'; import { BottomSheetSwitcherContext, type BottomSheetSwitcherContextValue, @@ -618,23 +620,25 @@ export function BottomSheetSwitcher({ (props['aria-labelledby'] == null ? activeLabel : undefined); return ( - - - {children} - - - + + + + {children} + + + + ); } diff --git a/packages/core/src/BottomSheet/index.ts b/packages/core/src/BottomSheet/index.ts index b2ceeff86ace8..dee6d735409ee 100644 --- a/packages/core/src/BottomSheet/index.ts +++ b/packages/core/src/BottomSheet/index.ts @@ -4,7 +4,7 @@ /** * @file index.ts - * @input BottomSheet.tsx, BottomSheetSwitcher.tsx + * @input BottomSheet.tsx, BottomSheetStack.tsx, BottomSheetSwitcher.tsx * @output Re-exports the BottomSheet public API * @position Component entry point; re-exported by /packages/core/src/index.ts */ @@ -15,5 +15,7 @@ export type { BottomSheetProps, BottomSheetSnapPoint, } from './BottomSheet'; +export {BottomSheetStack} from './BottomSheetStack'; +export type {BottomSheetStackProps} from './BottomSheetStack'; export {BottomSheetSwitcher} from './BottomSheetSwitcher'; export type {BottomSheetSwitcherProps} from './BottomSheetSwitcher';