Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/stacked-bottom-sheets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@astryxdesign/core': patch
'@astryxdesign/cli': patch
---

[feat] Add BottomSheetStack for controlled, layered bottom-sheet navigation.

@imdreamrunner
121 changes: 121 additions & 0 deletions apps/storybook/stories/BottomSheetStack.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof BottomSheetStack> = {
title: 'Core/BottomSheetStack',
component: BottomSheetStack,
tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
docs: {
story: {inline: false, height: '560px'},
},
},
decorators: [
Story => (
<div style={{minHeight: 480, padding: 32}}>
<Story />
</div>
),
],
};

export default meta;
type Story = StoryObj<typeof BottomSheetStack>;

function StackExample() {
const [openSheetIds, setOpenSheetIds] = useState<ReadonlyArray<string>>([]);
const push = (sheetId: string) =>
setOpenSheetIds(current => [...current, sheetId]);
const pop = () =>
setOpenSheetIds(current => current.slice(0, current.length - 1));

return (
<>
<Button label="Open inbox" onClick={() => setOpenSheetIds(['inbox'])} />
<BottomSheetStack
openSheetIds={openSheetIds}
onOpenSheetIdsChange={setOpenSheetIds}>
<BottomSheet sheetId="inbox" label="Inbox" height="capped">
<Section padding={4}>
<VStack gap={4}>
<VStack gap={1}>
<Heading level={3}>Inbox</Heading>
<Text type="supporting" color="secondary">
Select a message to inspect it without losing the list.
</Text>
</VStack>
<Divider />
<Button
label="Review release request"
variant="secondary"
onClick={() => push('message')}
/>
<Button
label="Close"
variant="ghost"
onClick={() => setOpenSheetIds([])}
/>
</VStack>
</Section>
</BottomSheet>

<BottomSheet sheetId="message" label="Release request" height="capped">
<Section padding={4}>
<VStack gap={4}>
<VStack gap={1}>
<Heading level={3}>Release request</Heading>
<Text type="supporting" color="secondary">
The Inbox remains mounted and visible beneath this sheet.
</Text>
</VStack>
<Divider />
<Text>
Version 3.4 is ready for review. Inspect the available actions
before deciding what to do next.
</Text>
<HStack gap={2} hAlign="end">
<Button label="Back" variant="secondary" onClick={pop} />
<Button label="View actions" onClick={() => push('actions')} />
</HStack>
</VStack>
</Section>
</BottomSheet>

<BottomSheet sheetId="actions" label="Release actions" height="hug">
<Section padding={4}>
<VStack gap={4}>
<VStack gap={1}>
<Heading level={3}>Release actions</Heading>
<Text type="supporting" color="secondary">
Three logical levels are open; only this sheet is interactive.
</Text>
</VStack>
<Divider />
<HStack gap={2} hAlign="end">
<Button label="Back" variant="secondary" onClick={pop} />
<Button
label="Approve and close"
onClick={() => setOpenSheetIds([])}
/>
</HStack>
</VStack>
</Section>
</BottomSheet>
</BottomSheetStack>
</>
);
}

export const Default: Story = {
render: () => <StackExample />,
};
5 changes: 4 additions & 1 deletion docs/families/overlay-dismissal.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ verified_by:
packages/core/src/Layer/layerDismissalInvariants.test.tsx,
packages/core/src/Layer/layerDismissalFamilies.test.tsx,
packages/core/src/hooks/useFocusTrap.test.tsx,
packages/core/src/BottomSheet/BottomSheetStack.test.tsx,
]
members:
[
Expand All @@ -31,6 +32,7 @@ members:
component:MobileNav,
component:BottomSheet,
component:BottomSheetSwitcher,
component:BottomSheetStack,
component:CommandPalette,
component:ContextMenu,
component:PowerSearchEditPopover,
Expand Down Expand Up @@ -88,7 +90,7 @@ this record's current membership snapshot must be updated with it.

- **Current members:** Dialog, AlertDialog, Popover, DropdownMenu,
DropdownMenuSubMenu, MoreMenu, Tooltip, HoverCard, Lightbox, MobileNav,
BottomSheet, BottomSheetSwitcher, CommandPalette, ContextMenu,
BottomSheet, BottomSheetSwitcher, BottomSheetStack, CommandPalette, ContextMenu,
PowerSearchEditPopover, Lab Drawer, and the component-owned popup surfaces
listed below.
- **Component-owned input popups:** ChatComposerInput, ComplexSelector,
Expand Down Expand Up @@ -192,6 +194,7 @@ this record's current membership snapshot must be updated with it.
| Tooltip, HoverCard | shared owner with DOM presence reporting | Neither provides nesting depth to descendant layers |
| Focus traps with `onEscape` | shared owner through `useFocusTrap` | They provide DOM containment, not descendant depth |
| BreadcrumbItem, ChatComposerInput, ComplexSelector, DateInput, DateRangeInput, DateTimeInput, Selector, MultiSelector, PowerSearch, BaseTypeahead, Typeahead, Tokenizer, SideNavHeading, SideNavItem, TabMenu, TopNavHeading, TopNavMenu, TopNavMegaMenu, Table, Lab TourStep, Lab ChatEmojiPicker | shared owner through `usePopover` or a composed Popover owner | Adaptive BottomSheet paths inherit BottomSheet's adoption gap; Table filtering owns controlled Popover state and discards its draft on close; TourStep routes Popover close to the Tour; ChatEmojiPicker owns controlled Popover state |
| BottomSheetStack | shared owner | none |
| BottomSheetSwitcher | partial | Modal mode registers through `useFocusTrap`; non-modal mode retains local Escape handling |
| BottomSheet, CommandPalette, ContextMenu, DropdownMenuSubMenu, PowerSearchEditPopover, Lab Drawer | local only | Must migrate from component-specific listeners or registries to the shared owner |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
export const doc = {
type: 'block',
exampleFor: 'BottomSheetStack',
name: 'Bottom Sheet Stack',
displayName: 'Bottom Sheet Stack',
description:
'A drill-down flow that keeps the previous sheet mounted and visibly scaled beneath the active sheet.',
isReady: true,
isShowcase: true,
aspectRatio: 3 / 4,
componentsUsed: [
'BottomSheet',
'BottomSheetStack',
'Button',
'Divider',
'Heading',
'Section',
'Stack',
'Text',
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

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';

export default function BottomSheetStackShowcase() {
const [openSheetIds, setOpenSheetIds] = useState<ReadonlyArray<string>>([]);
const push = (sheetId: string) =>
setOpenSheetIds(current => [...current, sheetId]);
const pop = () =>
setOpenSheetIds(current => current.slice(0, current.length - 1));

return (
<>
<Button label="Open inbox" onClick={() => setOpenSheetIds(['inbox'])} />
<BottomSheetStack
openSheetIds={openSheetIds}
onOpenSheetIdsChange={setOpenSheetIds}>
<BottomSheet sheetId="inbox" label="Inbox" height="capped">
<Section padding={4}>
<VStack gap={4}>
<VStack gap={1}>
<Heading level={3}>Inbox</Heading>
<Text type="supporting" color="secondary">
Select a message to preserve this list beneath its details.
</Text>
</VStack>
<Divider />
<Button
label="Review release request"
variant="secondary"
onClick={() => push('message')}
/>
<Button
label="Close"
variant="ghost"
onClick={() => setOpenSheetIds([])}
/>
</VStack>
</Section>
</BottomSheet>

<BottomSheet sheetId="message" label="Release request" height="capped">
<Section padding={4}>
<VStack gap={4}>
<VStack gap={1}>
<Heading level={3}>Release request</Heading>
<Text type="supporting" color="secondary">
The Inbox stays mounted, inert, and visible beneath this
sheet.
</Text>
</VStack>
<Divider />
<Text>Version 3.4 is ready for review.</Text>
<HStack gap={2} hAlign="end">
<Button label="Back" variant="secondary" onClick={pop} />
<Button label="Approve" onClick={() => setOpenSheetIds([])} />
</HStack>
</VStack>
</Section>
</BottomSheet>
</BottomSheetStack>
</>
);
}
22 changes: 11 additions & 11 deletions packages/core/src/BottomSheet/BottomSheet.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,38 +86,38 @@ export const docs = {
targets: [{className: 'astryx-bottom-sheet', visualProps: []}],
},
description:
"A mobile touch sheet that rises from the bottom edge, with animated entrance and exit, a grab handle, optional drag-to-resize snap points, and purpose-controlled dismissal. A standalone sheet owns a native <dialog>; inside BottomSheetSwitcher it renders a panel in the switcher's shared dialog. In both modes, ref and shared DOM props target the visual panel <div>.",
"A mobile touch sheet that rises from the bottom edge, with animated entrance and exit, a grab handle, optional drag-to-resize snap points, and purpose-controlled dismissal. A standalone sheet owns a native <dialog>; inside BottomSheetSwitcher or BottomSheetStack it renders a panel in the controller's shared dialog. In all modes, ref and shared DOM props target the visual panel <div>.",
props: [
{
name: 'isOpen',
type: 'boolean',
description:
'Whether a standalone sheet is open. Fully controlled; pair with onOpenChange. Omit inside BottomSheetSwitcher.',
'Whether a standalone sheet is open. Fully controlled; pair with onOpenChange. Omit inside BottomSheetSwitcher or BottomSheetStack.',
},
{
name: 'onOpenChange',
type: '(isOpen: boolean) => void',
description:
'For a standalone sheet, called when it requests an open-state change. Automatic calls follow purpose: info dismisses on Escape, scrim click, or swipe; form dismisses on Escape only; required never dismisses implicitly. Omit inside BottomSheetSwitcher.',
'For a standalone sheet, called when it requests an open-state change. Automatic calls follow purpose: info dismisses on Escape, scrim click, or swipe; form dismisses on Escape only; required never dismisses implicitly. Omit inside BottomSheetSwitcher or BottomSheetStack.',
},
{
name: 'finalFocusRef',
type: 'RefObject<HTMLElement | null>',
description:
'Optional explicit focus-return target for a standalone sheet. Use when the opener can remount or the active element is not a reliable trigger, such as an adaptive presentation switch. Omit inside BottomSheetSwitcher.',
'Optional explicit focus-return target for a standalone sheet. Use when the opener can remount or the active element is not a reliable trigger, such as an adaptive presentation switch. Omit inside BottomSheetSwitcher or BottomSheetStack.',
},
{
name: 'purpose',
type: "'required' | 'form' | 'info'",
description:
"Controls implicit dismissal behavior, matching Dialog. info allows Escape, scrim click, and swipe-to-dismiss. form protects entered data by blocking scrim click and swipe while allowing Escape. required blocks every implicit dismissal path and uses role='alertdialog'. Explicit controls may still update the controlled state. Works for standalone and BottomSheetSwitcher-managed sheets.",
"Controls implicit dismissal behavior, matching Dialog. info allows Escape, scrim click, and swipe-to-dismiss. form protects entered data by blocking scrim click and swipe while allowing Escape. required blocks every implicit dismissal path and uses role='alertdialog'. Explicit controls may still update the controlled state. Works for standalone, BottomSheetSwitcher-managed, and BottomSheetStack-managed sheets.",
default: "'info'",
},
{
name: 'sheetId',
type: 'string',
description:
'Unique ID for this sheet inside BottomSheetSwitcher. The switcher opens it when activeSheet matches. Omit isOpen and onOpenChange when sheetId is used.',
'Unique ID for this sheet inside BottomSheetSwitcher or BottomSheetStack. A switcher opens it when activeSheet matches; a stack presents it at its position in openSheetIds. Omit isOpen and onOpenChange when sheetId is used.',
},
{
name: 'label',
Expand Down Expand Up @@ -150,14 +150,14 @@ export const docs = {
name: 'hasScrim',
type: 'boolean',
description:
"For a standalone BottomSheet, whether to render a scrim, the semi-transparent overlay that covers and blocks the background. true (default) uses showModal(): top layer, focus trap, ::backdrop scrim, body scroll lock, and tap-scrim-to-dismiss when purpose='info', with the background inert. false uses show() with no scrim, leaving the page behind interactive and scrollable. For a multi-step flow, configure hasScrim on BottomSheetSwitcher instead; it owns one shared dialog across every child.",
"For a standalone BottomSheet, whether to render a scrim, the semi-transparent overlay that covers and blocks the background. true (default) uses showModal(): top layer, focus trap, ::backdrop scrim, body scroll lock, and tap-scrim-to-dismiss when purpose='info', with the background inert. false uses show() with no scrim, leaving the page behind interactive and scrollable. For a controlled flow, configure hasScrim on BottomSheetSwitcher or BottomSheetStack instead; the controller owns one shared dialog across every child.",
default: 'true',
},
],
usage: {
anatomy,
description:
'A mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for multi-step flows.',
'A mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for replacing steps and BottomSheetStack for visible drill-down layers.',
bestPractices: [
{
guidance: true,
Expand All @@ -177,7 +177,7 @@ export const docs = {
{
guidance: false,
description:
"Don't make the sheet content overly long. Consider breaking it into steps and using Bottom Sheet Switcher.",
"Don't make the sheet content overly long. Use Bottom Sheet Switcher when steps replace one another, or Bottom Sheet Stack when a detail sheet should preserve visible context beneath it.",
},
],
},
Expand Down Expand Up @@ -309,7 +309,7 @@ export const docsDense = {
usage: {
anatomy,
description:
'Mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for multi-step flows.',
'Mobile touch surface for filters, actions, forms, and detail views that should rise from the bottom of the viewport; use BottomSheetSwitcher for replacing steps and BottomSheetStack for visible drill-down layers.',
bestPractices: [
{
guidance: true,
Expand All @@ -329,7 +329,7 @@ export const docsDense = {
{
guidance: false,
description:
"Don't make the sheet content overly long. Consider breaking it into steps and using Bottom Sheet Switcher.",
"Don't make the sheet content overly long. Use Bottom Sheet Switcher when steps replace one another, or Bottom Sheet Stack when a detail sheet should preserve visible context beneath it.",
},
],
},
Expand Down
Loading
Loading