@sovereignfs/ui 0.54.0 → 0.83.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Accordion.module.css +1 -1
- package/dist/Alert.module.css +1 -1
- package/dist/AppsLauncher.module.css +96 -0
- package/dist/Avatar.module.css +1 -1
- package/dist/Badge.module.css +30 -7
- package/dist/BalanceChip.module.css +1 -1
- package/dist/Button.module.css +18 -2
- package/dist/Card.module.css +1 -1
- package/dist/CardTile.module.css +181 -0
- package/dist/CodeTextarea.module.css +1 -1
- package/dist/Collapsible.module.css +1 -1
- package/dist/ColorPicker.module.css +132 -0
- package/dist/Combobox.module.css +2 -2
- package/dist/Command.module.css +1 -1
- package/dist/ConfirmDialog.module.css +2 -2
- package/dist/ConsentPrompt.module.css +21 -0
- package/dist/DatePicker.module.css +1 -1
- package/dist/Dialog.module.css +130 -21
- package/dist/Drawer.module.css +36 -7
- package/dist/Header.module.css +62 -0
- package/dist/HeaderFooterLayout.module.css +21 -0
- package/dist/IconPicker.module.css +1 -1
- package/dist/Input.module.css +1 -1
- package/dist/Kbd.module.css +1 -1
- package/dist/Markdown.module.css +24 -0
- package/dist/Menu.module.css +1 -0
- package/dist/Message.module.css +1 -1
- package/dist/MessageScroller.module.css +12 -1
- package/dist/MobileAppsDrawer.module.css +10 -9
- package/dist/MobileFooter.module.css +1 -1
- package/dist/MobileHeader.module.css +14 -1
- package/dist/NavList.module.css +130 -0
- package/dist/NavTabs.module.css +1 -1
- package/dist/NotificationsPanel.module.css +258 -0
- package/dist/OverlayHeader.module.css +1 -1
- package/dist/PageContainer.module.css +62 -0
- package/dist/PageHeader.module.css +54 -0
- package/dist/PageLayout.module.css +51 -0
- package/dist/Popover.module.css +1 -1
- package/dist/QuantityStepper.module.css +1 -1
- package/dist/Resizable.module.css +3 -3
- package/dist/RootLayout.module.css +12 -0
- package/dist/SegmentedControl.module.css +1 -1
- package/dist/Select.module.css +1 -1
- package/dist/Sheet.module.css +4 -1
- package/dist/SplitPane.module.css +4 -4
- package/dist/StatusBadge.module.css +1 -1
- package/dist/StepStrip.module.css +53 -0
- package/dist/SuggestionInput.module.css +2 -2
- package/dist/SwipableMobileCarousel.module.css +12 -3
- package/dist/SwipableMobileCarouselDots.module.css +15 -0
- package/dist/SwipeStack.module.css +101 -0
- package/dist/SystemBanner.module.css +10 -0
- package/dist/Table.module.css +2 -2
- package/dist/Tabs.module.css +1 -1
- package/dist/TagInput.module.css +2 -2
- package/dist/Textarea.module.css +9 -1
- package/dist/ThreeColumnLayout.module.css +35 -0
- package/dist/Toast.module.css +62 -3
- package/dist/Tooltip.module.css +1 -1
- package/dist/UserMenu.module.css +90 -0
- package/dist/index.d.ts +1047 -45
- package/dist/index.js +3908 -1456
- package/dist/tokens/primitives.css +11 -0
- package/dist/tokens/semantic.css +32 -0
- package/dist/tokens/theme-presets.css +33 -0
- package/package.json +25 -9
- package/src/__tests__/package-json.test.ts +8 -0
- package/src/components/Accordion/Accordion.module.css +1 -1
- package/src/components/Alert/Alert.module.css +1 -1
- package/src/components/AppsLauncher/AppsLauncher.module.css +96 -0
- package/src/components/AppsLauncher/AppsLauncher.stories.tsx +58 -0
- package/src/components/AppsLauncher/AppsLauncher.tsx +117 -0
- package/src/components/AppsLauncher/__tests__/AppsLauncher.test.tsx +52 -0
- package/src/components/Avatar/Avatar.module.css +1 -1
- package/src/components/Badge/Badge.module.css +30 -7
- package/src/components/Badge/Badge.stories.tsx +16 -4
- package/src/components/Badge/Badge.tsx +7 -4
- package/src/components/Badge/__tests__/Badge.test.tsx +4 -2
- package/src/components/BalanceChip/BalanceChip.module.css +1 -1
- package/src/components/Button/Button.module.css +18 -2
- package/src/components/Card/Card.module.css +1 -1
- package/src/components/CardTile/CardTile.module.css +181 -0
- package/src/components/CardTile/CardTile.stories.tsx +86 -0
- package/src/components/CardTile/CardTile.tsx +128 -0
- package/src/components/CardTile/__tests__/CardTile.test.tsx +56 -0
- package/src/components/CodeTextarea/CodeTextarea.module.css +1 -1
- package/src/components/Collapsible/Collapsible.module.css +1 -1
- package/src/components/ColorPicker/ColorPicker.module.css +132 -0
- package/src/components/ColorPicker/ColorPicker.stories.tsx +80 -0
- package/src/components/ColorPicker/ColorPicker.tsx +157 -0
- package/src/components/ColorPicker/__tests__/ColorPicker.test.tsx +117 -0
- package/src/components/Combobox/Combobox.module.css +2 -2
- package/src/components/Command/Command.module.css +1 -1
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +2 -2
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +28 -2
- package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +71 -3
- package/src/components/ConsentPrompt/ConsentPrompt.module.css +21 -0
- package/src/components/ConsentPrompt/ConsentPrompt.stories.tsx +31 -0
- package/src/components/ConsentPrompt/ConsentPrompt.tsx +66 -0
- package/src/components/ContextMenu/ContextMenu.tsx +1 -1
- package/src/components/DatePicker/DatePicker.module.css +1 -1
- package/src/components/Dialog/Dialog.module.css +130 -21
- package/src/components/Dialog/Dialog.stories.tsx +179 -2
- package/src/components/Dialog/Dialog.tsx +129 -35
- package/src/components/Dialog/__tests__/Dialog.test.tsx +190 -9
- package/src/components/DragHandleRow/DragHandleRow.tsx +2 -21
- package/src/components/Drawer/Drawer.module.css +36 -7
- package/src/components/Drawer/Drawer.tsx +9 -10
- package/src/components/Drawer/__tests__/Drawer.test.tsx +33 -6
- package/src/components/GripIcon/GripIcon.tsx +27 -0
- package/src/components/Header/Header.module.css +62 -0
- package/src/components/Header/Header.stories.tsx +452 -0
- package/src/components/Header/Header.tsx +102 -0
- package/src/components/Header/__tests__/Header.test.tsx +104 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.module.css +21 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.stories.tsx +111 -0
- package/src/components/HeaderFooterLayout/HeaderFooterLayout.tsx +54 -0
- package/src/components/HeaderFooterLayout/__tests__/HeaderFooterLayout.test.tsx +58 -0
- package/src/components/Icon/icons/arrow-left-right.tsx +23 -0
- package/src/components/Icon/icons/bold.tsx +20 -0
- package/src/components/Icon/icons/calendar.tsx +4 -4
- package/src/components/Icon/icons/carrot.tsx +3 -3
- package/src/components/Icon/icons/circle-chevron-left.tsx +21 -0
- package/src/components/Icon/icons/code.tsx +21 -0
- package/src/components/Icon/icons/copy.tsx +21 -0
- package/src/components/Icon/icons/credit-card.tsx +21 -0
- package/src/components/Icon/icons/database.tsx +22 -0
- package/src/components/Icon/icons/download.tsx +22 -0
- package/src/components/Icon/icons/file-text.tsx +24 -0
- package/src/components/Icon/icons/folder-closed.tsx +21 -0
- package/src/components/Icon/icons/folder-open.tsx +20 -0
- package/src/components/Icon/icons/folder.tsx +20 -0
- package/src/components/Icon/icons/folders.tsx +21 -0
- package/src/components/Icon/icons/hat-glasses.tsx +24 -0
- package/src/components/Icon/icons/history.tsx +22 -0
- package/src/components/Icon/icons/inbox.tsx +21 -0
- package/src/components/Icon/icons/index.ts +161 -1
- package/src/components/Icon/icons/italic.tsx +22 -0
- package/src/components/Icon/icons/layers.tsx +22 -0
- package/src/components/Icon/icons/layout-dashboard.tsx +23 -0
- package/src/components/Icon/icons/layout-grid.tsx +23 -0
- package/src/components/Icon/icons/link.tsx +21 -0
- package/src/components/Icon/icons/list-ordered.tsx +25 -0
- package/src/components/Icon/icons/list.tsx +25 -0
- package/src/components/Icon/icons/luggage.tsx +24 -0
- package/src/components/Icon/icons/map-pin.tsx +21 -0
- package/src/components/Icon/icons/minus.tsx +20 -0
- package/src/components/Icon/icons/paint-bucket.tsx +23 -0
- package/src/components/Icon/icons/panel-left.tsx +21 -0
- package/src/components/Icon/icons/pin.tsx +21 -0
- package/src/components/Icon/icons/rectangle-ellipsis.tsx +23 -0
- package/src/components/Icon/icons/refresh-cw.tsx +23 -0
- package/src/components/Icon/icons/rotate-cw.tsx +21 -0
- package/src/components/Icon/icons/route.tsx +22 -0
- package/src/components/Icon/icons/send.tsx +21 -0
- package/src/components/Icon/icons/share-2.tsx +24 -0
- package/src/components/Icon/icons/sheet.tsx +24 -0
- package/src/components/Icon/icons/square-kanban.tsx +23 -0
- package/src/components/Icon/icons/table.tsx +23 -0
- package/src/components/Icon/icons/user-round-plus.tsx +23 -0
- package/src/components/Icon/icons/users.tsx +23 -0
- package/src/components/IconPicker/IconPicker.module.css +1 -1
- package/src/components/Input/Input.module.css +1 -1
- package/src/components/Input/Input.tsx +8 -2
- package/src/components/Kbd/Kbd.module.css +1 -1
- package/src/components/Markdown/Markdown.module.css +24 -0
- package/src/components/Markdown/Markdown.stories.tsx +10 -0
- package/src/components/Markdown/Markdown.tsx +166 -29
- package/src/components/Markdown/__tests__/Markdown.test.tsx +144 -0
- package/src/components/Menu/Menu.module.css +1 -0
- package/src/components/Menu/Menu.tsx +72 -22
- package/src/components/Menu/__tests__/Menu.test.tsx +30 -0
- package/src/components/Menubar/Menubar.tsx +1 -1
- package/src/components/Message/Message.module.css +1 -1
- package/src/components/MessageScroller/MessageScroller.module.css +12 -1
- package/src/components/MessageScroller/MessageScroller.tsx +12 -0
- package/src/components/MobileAppsDrawer/MobileAppsDrawer.module.css +10 -9
- package/src/components/MobileFooter/MobileFooter.module.css +1 -1
- package/src/components/MobileFooter/MobileFooter.tsx +15 -2
- package/src/components/MobileFooter/__tests__/MobileFooter.test.tsx +58 -0
- package/src/components/MobileHeader/MobileHeader.module.css +14 -1
- package/src/components/MobileHeader/MobileHeader.tsx +15 -2
- package/src/components/MobileHeader/__tests__/MobileHeader.test.tsx +56 -1
- package/src/components/NavList/NavList.module.css +130 -0
- package/src/components/NavList/NavList.tsx +136 -0
- package/src/components/NavList/__tests__/NavList.test.tsx +109 -0
- package/src/components/NavTabs/NavTabs.module.css +1 -1
- package/src/components/NotificationsPanel/NotificationsPanel.module.css +258 -0
- package/src/components/NotificationsPanel/NotificationsPanel.stories.tsx +61 -0
- package/src/components/NotificationsPanel/NotificationsPanel.tsx +187 -0
- package/src/components/NotificationsPanel/__tests__/NotificationsPanel.test.tsx +78 -0
- package/src/components/OverlayHeader/OverlayHeader.module.css +1 -1
- package/src/components/OverlayHeader/OverlayHeader.tsx +19 -4
- package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +18 -0
- package/src/components/PageContainer/PageContainer.module.css +62 -0
- package/src/components/PageContainer/PageContainer.tsx +37 -4
- package/src/components/PageContainer/__tests__/PageContainer.test.tsx +41 -8
- package/src/components/PageHeader/PageHeader.module.css +54 -0
- package/src/components/PageHeader/PageHeader.tsx +146 -5
- package/src/components/PageHeader/__tests__/PageHeader.test.tsx +103 -0
- package/src/components/PageLayout/PageLayout.module.css +51 -0
- package/src/components/PageLayout/PageLayout.stories.tsx +73 -0
- package/src/components/PageLayout/PageLayout.tsx +51 -0
- package/src/components/PageLayout/__tests__/PageLayout.test.tsx +34 -0
- package/src/components/Popover/Popover.module.css +1 -1
- package/src/components/Popover/Popover.tsx +86 -4
- package/src/components/Popover/__tests__/Popover.test.tsx +165 -0
- package/src/components/QuantityStepper/QuantityStepper.module.css +1 -1
- package/src/components/Resizable/Resizable.module.css +3 -3
- package/src/components/RootLayout/RootLayout.module.css +12 -0
- package/src/components/RootLayout/RootLayout.stories.tsx +125 -0
- package/src/components/RootLayout/RootLayout.tsx +185 -0
- package/src/components/RootLayout/__tests__/RootLayout.test.tsx +161 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +1 -1
- package/src/components/Select/Select.module.css +1 -1
- package/src/components/Sheet/Sheet.module.css +4 -1
- package/src/components/Sheet/Sheet.tsx +5 -5
- package/src/components/SplitPane/SplitPane.module.css +4 -4
- package/src/components/StatusBadge/StatusBadge.module.css +1 -1
- package/src/components/StepStrip/StepStrip.module.css +53 -0
- package/src/components/StepStrip/StepStrip.tsx +74 -0
- package/src/components/SuggestionInput/SuggestionInput.module.css +2 -2
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.module.css +12 -3
- package/src/components/SwipableMobileCarousel/SwipableMobileCarousel.tsx +25 -9
- package/src/components/SwipableMobileCarousel/__tests__/SwipableMobileCarousel.test.tsx +81 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.module.css +15 -0
- package/src/components/SwipableMobileCarouselDots/SwipableMobileCarouselDots.tsx +12 -1
- package/src/components/SwipeStack/SwipeStack.module.css +101 -0
- package/src/components/SwipeStack/SwipeStack.tsx +199 -0
- package/src/components/SwipeStack/SwipeStackCard.tsx +22 -0
- package/src/components/SwipeStack/__tests__/SwipeStack.test.tsx +169 -0
- package/src/components/SystemBanner/SystemBanner.module.css +10 -0
- package/src/components/Table/Table.module.css +2 -2
- package/src/components/Tabs/Tabs.module.css +1 -1
- package/src/components/TagInput/TagInput.module.css +2 -2
- package/src/components/Textarea/Textarea.module.css +9 -1
- package/src/components/Textarea/Textarea.tsx +28 -5
- package/src/components/Textarea/__tests__/Textarea.test.tsx +23 -1
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.module.css +35 -0
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.stories.tsx +152 -0
- package/src/components/ThreeColumnLayout/ThreeColumnLayout.tsx +87 -0
- package/src/components/ThreeColumnLayout/__tests__/ThreeColumnLayout.test.tsx +78 -0
- package/src/components/Toast/Toast.module.css +62 -3
- package/src/components/Toast/Toast.stories.tsx +1 -1
- package/src/components/Tooltip/Tooltip.module.css +1 -1
- package/src/components/UserMenu/UserMenu.module.css +90 -0
- package/src/components/UserMenu/UserMenu.stories.tsx +49 -0
- package/src/components/UserMenu/UserMenu.tsx +132 -0
- package/src/components/UserMenu/__tests__/UserMenu.test.tsx +77 -0
- package/src/hooks/__tests__/usePublishShellChromeHeight.test.tsx +118 -0
- package/src/hooks/__tests__/useReorderSensors.test.ts +26 -0
- package/src/hooks/__tests__/useSnapCarousel.test.tsx +82 -0
- package/src/hooks/__tests__/useSwipeStack.test.tsx +155 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/usePublishShellChromeHeight.ts +96 -0
- package/src/hooks/useReorderSensors.ts +88 -0
- package/src/hooks/useSnapCarousel.ts +211 -6
- package/src/hooks/useSwipeStack.ts +235 -0
- package/src/index.ts +61 -1
- package/src/overlay-shell.ts +65 -0
- package/src/stories/DesignSystemOverview.stories.tsx +617 -11
- package/src/stories/GripIcon.stories.tsx +29 -0
- package/src/stories/LayoutSystem.stories.tsx +639 -0
- package/src/stories/MenuEntries.stories.tsx +86 -0
- package/src/stories/NavList.stories.tsx +147 -0
- package/src/stories/PageContainer.stories.tsx +52 -7
- package/src/stories/PageHeader.stories.tsx +54 -1
- package/src/stories/StepStrip.stories.tsx +76 -0
- package/src/stories/SwipableMobileCarousel.stories.tsx +58 -0
- package/src/stories/SwipeStack.stories.tsx +200 -0
- package/src/stories/Textarea.stories.tsx +12 -0
- package/src/stories/TokenGallery.stories.tsx +50 -0
- package/src/tokens/primitives.css +11 -0
- package/src/tokens/semantic.css +32 -0
- package/src/tokens/theme-presets.css +33 -0
- package/src/tokens/theme-presets.ts +72 -0
|
@@ -3,19 +3,15 @@
|
|
|
3
3
|
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react';
|
|
4
4
|
import { useMountTransition, usePrefersReducedMotion } from '../../motion';
|
|
5
5
|
import {
|
|
6
|
+
OVERLAY_MOTION_DURATION_MS,
|
|
6
7
|
useOverlayFocusCapture,
|
|
7
8
|
useOverlayKeyboardTrap,
|
|
8
9
|
useOverlayScrollLock,
|
|
9
10
|
} from '../../overlay-shell';
|
|
11
|
+
import { Icon } from '../Icon/Icon';
|
|
10
12
|
import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
|
|
11
13
|
import styles from './Dialog.module.css';
|
|
12
14
|
|
|
13
|
-
// Matches --sv-motion-duration-base (Dialog.module.css) — kept as a plain JS
|
|
14
|
-
// constant rather than read from the CSS custom property so the unmount timer
|
|
15
|
-
// and the CSS transition duration can't silently drift apart at build time;
|
|
16
|
-
// change both together if this value ever changes.
|
|
17
|
-
const MOTION_DURATION_MS = 250;
|
|
18
|
-
|
|
19
15
|
// undefined (the default, outside any Provider) means "no Dialog ancestor" —
|
|
20
16
|
// distinct from a real setter function, so useOverlaySecondRow can silently
|
|
21
17
|
// no-op when called outside a Dialog instead of throwing.
|
|
@@ -26,11 +22,13 @@ const OverlaySecondRowContext = createContext<((node: ReactNode | null) => void)
|
|
|
26
22
|
/**
|
|
27
23
|
* Lets content deep inside a `Dialog` (e.g. a plugin's own route layout,
|
|
28
24
|
* rendered several levels below wherever the `Dialog` itself is
|
|
29
|
-
* instantiated) supply the second-row content of the Dialog's
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
25
|
+
* instantiated) supply the second-row content of the Dialog's `OverlayHeader`
|
|
26
|
+
* — typically a tab strip. Solves the "double header" problem: without this,
|
|
27
|
+
* a plugin's own tab strip has no way to reach the Dialog's header and ends
|
|
28
|
+
* up rendered a second time, as its own sticky bar, inside the scrolling
|
|
29
|
+
* content area. Reaches the header regardless of whether it's showing via
|
|
30
|
+
* the `header` prop (both breakpoints) or the default `title`-driven mobile
|
|
31
|
+
* bar (mobile only) — see `DialogProps.header`'s own doc comment.
|
|
34
32
|
*
|
|
35
33
|
* A no-op when there is no enclosing `Dialog` (e.g. the same plugin layout
|
|
36
34
|
* also rendered on a plain, non-overlay route) — safe to call unconditionally.
|
|
@@ -56,7 +54,27 @@ export function useOverlaySecondRow(node: ReactNode | null): boolean {
|
|
|
56
54
|
return setSecondRow !== undefined;
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
|
|
57
|
+
// BREAKING (packages/ui minor bump + docs/upgrade.md migration note): `xl`
|
|
58
|
+
// and `full` were removed here — `full` was a dead alias of `lg` (identical
|
|
59
|
+
// CSS), and `xl` was a fixed-width variant with no distinct behavior `auto`
|
|
60
|
+
// doesn't now cover better. `auto` sizes to content on BOTH width and
|
|
61
|
+
// height, capped rather than fixed on either axis — for content whose
|
|
62
|
+
// natural size varies a lot (a card detail modal with an optional
|
|
63
|
+
// checklist/comments section) rather than reliably filling a fixed width.
|
|
64
|
+
// `lg` is a true fixed 100%/100% box — the panel holds still regardless of
|
|
65
|
+
// content, at the cost of always filling the viewport.
|
|
66
|
+
//
|
|
67
|
+
// `fixed` (added for epic task 14.5, Account's vertical nav): a true fixed
|
|
68
|
+
// box like `lg` — width AND height set, content never resizes it — but
|
|
69
|
+
// capped rather than filling the viewport. Named distinctly from the
|
|
70
|
+
// component's own always-applied `.panel` CSS class (Dialog.module.css),
|
|
71
|
+
// not from `lg`'s own fixed-ness. For a plugin that needs a stable
|
|
72
|
+
// footprint (e.g. one whose route tree includes a client-side redirect
|
|
73
|
+
// through a near-empty intermediate page — `auto`/`sm`/`md`'s
|
|
74
|
+
// content-driven sizing would visibly shrink to fit that empty page, then
|
|
75
|
+
// grow once the real content lands) without reading as heavy as `lg`'s
|
|
76
|
+
// full-screen box for what is a compact settings surface.
|
|
77
|
+
export type DialogSize = 'sm' | 'md' | 'lg' | 'auto' | 'fixed';
|
|
60
78
|
|
|
61
79
|
export interface DialogProps {
|
|
62
80
|
/** Whether the dialog is shown. When false, nothing renders. */
|
|
@@ -65,11 +83,42 @@ export interface DialogProps {
|
|
|
65
83
|
onClose: () => void;
|
|
66
84
|
/** Panel size on desktop. Mobile always renders as a full-screen sheet. */
|
|
67
85
|
size?: DialogSize;
|
|
68
|
-
/** Accessible name for the dialog (sets `aria-label` on the panel).
|
|
86
|
+
/** Accessible name for the dialog (sets `aria-label` on the panel). Falls
|
|
87
|
+
* back to `title` when omitted — this still applies even when `header` is
|
|
88
|
+
* used instead of `title` for the visible header (see `header`'s own doc
|
|
89
|
+
* comment), so pass `aria-label` explicitly in that case if `title` is
|
|
90
|
+
* also omitted. */
|
|
69
91
|
'aria-label'?: string;
|
|
70
|
-
/**
|
|
71
|
-
*
|
|
92
|
+
/** Shown in the top bar alongside the close button, on mobile only —
|
|
93
|
+
* desktop has no header row by default (see `header` for that). Ignored
|
|
94
|
+
* as visible content when `header` is provided; still used as the
|
|
95
|
+
* `aria-label` fallback either way. */
|
|
72
96
|
title?: string;
|
|
97
|
+
/** Renders a persistent header row (via the same `OverlayHeader` component
|
|
98
|
+
* `title` alone only shows on mobile) on **both** desktop and mobile —
|
|
99
|
+
* the "Header + Body" / "Header + Body + Footer" shapes. Supersedes
|
|
100
|
+
* `title` for visible content when provided; `title` remains the
|
|
101
|
+
* `aria-label` fallback if `aria-label` is also omitted. Omitting both
|
|
102
|
+
* `header` and `footer` is "Body only" — today's default, unchanged. */
|
|
103
|
+
header?: ReactNode;
|
|
104
|
+
/** Renders a persistent footer row below the scrollable body — pinned on
|
|
105
|
+
* both desktop and mobile the same way `header`/the mobile title bar are
|
|
106
|
+
* (a non-scrolling flex sibling, not `position: sticky`): only the region
|
|
107
|
+
* between header and footer scrolls. Typically action buttons (Save/
|
|
108
|
+
* Cancel). Omit for no footer — today's default, where any actions live
|
|
109
|
+
* inside `children` and scroll with the rest of the body. Layout-agnostic
|
|
110
|
+
* (no built-in button alignment) — arrange the footer's own content as
|
|
111
|
+
* needed, same as `children`. */
|
|
112
|
+
footer?: ReactNode;
|
|
113
|
+
/** Forwarded to the active `OverlayHeader` instance's own `rowClassName`
|
|
114
|
+
* (whichever is showing — `header` or the mobile `title` bar) — restyle
|
|
115
|
+
* the title row (e.g. tighter padding to match a consumer's own compact
|
|
116
|
+
* header elsewhere) without changing its title/close layout. */
|
|
117
|
+
rowClassName?: string;
|
|
118
|
+
/** Forwarded to the active `OverlayHeader` instance's own `titleClassName`
|
|
119
|
+
* — restyle the title text (e.g. a smaller font size to match a
|
|
120
|
+
* consumer's own compact header elsewhere). */
|
|
121
|
+
titleClassName?: string;
|
|
73
122
|
children: ReactNode;
|
|
74
123
|
}
|
|
75
124
|
|
|
@@ -88,6 +137,12 @@ export interface DialogProps {
|
|
|
88
137
|
* `onClose` API is unchanged — closing still stays mounted internally for the
|
|
89
138
|
* exit transition before actually unmounting; `prefers-reduced-motion: reduce`
|
|
90
139
|
* collapses both to near-instant.
|
|
140
|
+
*
|
|
141
|
+
* Three composition shapes, chosen by which of `header`/`footer` are passed:
|
|
142
|
+
* Body only (both omitted — today's default), Header + Body (`header` only),
|
|
143
|
+
* Header + Body + Footer (`header` and `footer`). Whichever of header/footer
|
|
144
|
+
* are present render as non-scrolling flex siblings around `.content`, which
|
|
145
|
+
* stays the only scrollable region in every shape.
|
|
91
146
|
*/
|
|
92
147
|
export function Dialog({
|
|
93
148
|
open,
|
|
@@ -95,11 +150,18 @@ export function Dialog({
|
|
|
95
150
|
size = 'lg',
|
|
96
151
|
'aria-label': ariaLabel,
|
|
97
152
|
title,
|
|
153
|
+
header,
|
|
154
|
+
footer,
|
|
155
|
+
rowClassName,
|
|
156
|
+
titleClassName,
|
|
98
157
|
children,
|
|
99
158
|
}: DialogProps) {
|
|
100
159
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
101
160
|
const reducedMotion = usePrefersReducedMotion();
|
|
102
|
-
const { mounted, phase } = useMountTransition(
|
|
161
|
+
const { mounted, phase } = useMountTransition(
|
|
162
|
+
open,
|
|
163
|
+
reducedMotion ? 0 : OVERLAY_MOTION_DURATION_MS,
|
|
164
|
+
);
|
|
103
165
|
const [secondRow, setSecondRow] = useState<ReactNode | null>(null);
|
|
104
166
|
|
|
105
167
|
useOverlayScrollLock(mounted);
|
|
@@ -112,15 +174,17 @@ export function Dialog({
|
|
|
112
174
|
return (
|
|
113
175
|
// role="presentation" removes the scrim from the AT (it is purely visual).
|
|
114
176
|
// e.target check lets clicks inside the panel bubble without triggering dismiss.
|
|
177
|
+
// No onKeyDown here — useOverlayKeyboardTrap above already owns Escape via a
|
|
178
|
+
// document-level listener; a second handler here used to double-fire onClose
|
|
179
|
+
// per keypress (the keydown bubbles through this element on its way to
|
|
180
|
+
// document), which is exactly the kind of bug that turns a single Escape
|
|
181
|
+
// press into two router.back() calls for @modal-driven consumers.
|
|
115
182
|
<div
|
|
116
183
|
className={[styles.scrim, isOpenPhase ? styles.scrimOpen : ''].filter(Boolean).join(' ')}
|
|
117
184
|
role="presentation"
|
|
118
185
|
onClick={(e) => {
|
|
119
186
|
if (e.target === e.currentTarget) onClose();
|
|
120
187
|
}}
|
|
121
|
-
onKeyDown={(e) => {
|
|
122
|
-
if (e.key === 'Escape') onClose();
|
|
123
|
-
}}
|
|
124
188
|
>
|
|
125
189
|
<div
|
|
126
190
|
ref={panelRef}
|
|
@@ -132,28 +196,58 @@ export function Dialog({
|
|
|
132
196
|
.filter(Boolean)
|
|
133
197
|
.join(' ')}
|
|
134
198
|
>
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
199
|
+
{header ? (
|
|
200
|
+
/* header supersedes the mobile-only title bar below — OverlayHeader
|
|
201
|
+
itself has no internal breakpoint gating (see its own module
|
|
202
|
+
CSS), so rendering it with no Dialog-level display-toggling
|
|
203
|
+
className, as here, is what makes it show on both desktop and
|
|
204
|
+
mobile. It carries its own close button, so the separate
|
|
205
|
+
desktop-only .close button below is skipped entirely in this
|
|
206
|
+
branch — never render both at once. */
|
|
207
|
+
<OverlayHeader
|
|
208
|
+
title={header}
|
|
209
|
+
onClose={onClose}
|
|
210
|
+
secondRow={secondRow}
|
|
211
|
+
rowClassName={rowClassName}
|
|
212
|
+
titleClassName={titleClassName}
|
|
213
|
+
/>
|
|
214
|
+
) : (
|
|
215
|
+
<>
|
|
216
|
+
{/* Mobile: shared OverlayHeader (title + close in one row, hidden
|
|
217
|
+
on desktop via CSS — see .mobileHeader). secondRow is
|
|
218
|
+
populated by a descendant's useOverlaySecondRow call, e.g. a
|
|
219
|
+
plugin's own tab strip — see that hook's doc comment for why. */}
|
|
220
|
+
<OverlayHeader
|
|
221
|
+
title={title}
|
|
222
|
+
onClose={onClose}
|
|
223
|
+
secondRow={secondRow}
|
|
224
|
+
className={styles.mobileHeader}
|
|
225
|
+
rowClassName={rowClassName}
|
|
226
|
+
titleClassName={titleClassName}
|
|
227
|
+
/>
|
|
228
|
+
{/* Desktop: absolute close button (hidden on mobile via CSS).
|
|
229
|
+
Plain `x`, matching the mobile OverlayHeader's own close icon
|
|
230
|
+
above it (OverlayHeader.tsx) — a single consistent close
|
|
231
|
+
affordance across breakpoints instead of desktop's own
|
|
232
|
+
separate `circle-x` glyph (the previous choice here).
|
|
233
|
+
Platform-wide: every `Dialog` consumer without a `header`
|
|
234
|
+
gets this, not just the one it was requested against —
|
|
235
|
+
`Dialog` has no per-instance override for its own close
|
|
236
|
+
button. */}
|
|
237
|
+
<button type="button" className={styles.close} aria-label="Close" onClick={onClose}>
|
|
238
|
+
<Icon name="x" size="md" aria-hidden={true} />
|
|
239
|
+
</button>
|
|
240
|
+
</>
|
|
241
|
+
)}
|
|
149
242
|
{/* The panel is a fixed-size box; only this region scrolls, so the
|
|
150
|
-
panel never resizes with its content and the close button
|
|
151
|
-
pinned. */}
|
|
243
|
+
panel never resizes with its content and the header/close button
|
|
244
|
+
and footer (if present) stay pinned. */}
|
|
152
245
|
<div className={styles.content}>
|
|
153
246
|
<OverlaySecondRowContext.Provider value={setSecondRow}>
|
|
154
247
|
{children}
|
|
155
248
|
</OverlaySecondRowContext.Provider>
|
|
156
249
|
</div>
|
|
250
|
+
{footer && <div className={styles.footer}>{footer}</div>}
|
|
157
251
|
</div>
|
|
158
252
|
</div>
|
|
159
253
|
);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
import { ConfirmDialog } from '../../ConfirmDialog/ConfirmDialog';
|
|
4
6
|
import { Dialog, useOverlaySecondRow } from '../Dialog';
|
|
5
7
|
|
|
6
8
|
// Dialog's exit animation reads prefers-reduced-motion via matchMedia, which
|
|
@@ -48,16 +50,24 @@ describe('Dialog', () => {
|
|
|
48
50
|
expect(dialog.getAttribute('aria-modal')).toBe('true');
|
|
49
51
|
});
|
|
50
52
|
|
|
51
|
-
it('calls onClose on Escape', () => {
|
|
53
|
+
it('calls onClose exactly once on Escape with focus inside the panel', () => {
|
|
52
54
|
const onClose = vi.fn();
|
|
53
55
|
render(
|
|
54
|
-
<Dialog open onClose={onClose}>
|
|
55
|
-
|
|
56
|
+
<Dialog open onClose={onClose} aria-label="Panel">
|
|
57
|
+
<button>Focus me</button>
|
|
56
58
|
</Dialog>,
|
|
57
59
|
);
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
60
|
+
// Dispatched from the actually-focused element (matching real usage,
|
|
61
|
+
// where useOverlayFocusCapture moves focus into the panel on open) so the
|
|
62
|
+
// keydown bubbles through the scrim on its way to document — the exact
|
|
63
|
+
// path that used to trigger both the scrim's own (now-removed) Escape
|
|
64
|
+
// handler and useOverlayKeyboardTrap's document-level listener, double-
|
|
65
|
+
// firing onClose. Dispatching straight on `document` (the previous form
|
|
66
|
+
// of this test) skips that bubble path and would pass even if the bug
|
|
67
|
+
// were reintroduced.
|
|
68
|
+
const button = screen.getByRole('button', { name: 'Focus me' });
|
|
69
|
+
button.focus();
|
|
70
|
+
fireEvent.keyDown(button, { key: 'Escape' });
|
|
61
71
|
expect(onClose).toHaveBeenCalledOnce();
|
|
62
72
|
});
|
|
63
73
|
|
|
@@ -78,11 +88,11 @@ describe('Dialog', () => {
|
|
|
78
88
|
|
|
79
89
|
it('applies the size class', () => {
|
|
80
90
|
render(
|
|
81
|
-
<Dialog open onClose={() => {}} size="
|
|
91
|
+
<Dialog open onClose={() => {}} size="lg" aria-label="Big">
|
|
82
92
|
Body
|
|
83
93
|
</Dialog>,
|
|
84
94
|
);
|
|
85
|
-
expect(screen.getByRole('dialog').className).toContain('
|
|
95
|
+
expect(screen.getByRole('dialog').className).toContain('lg');
|
|
86
96
|
});
|
|
87
97
|
|
|
88
98
|
it('supports the sm size', () => {
|
|
@@ -94,6 +104,24 @@ describe('Dialog', () => {
|
|
|
94
104
|
expect(screen.getByRole('dialog').className).toContain('sm');
|
|
95
105
|
});
|
|
96
106
|
|
|
107
|
+
it('supports the auto size', () => {
|
|
108
|
+
render(
|
|
109
|
+
<Dialog open onClose={() => {}} size="auto" aria-label="Auto-sized">
|
|
110
|
+
Body
|
|
111
|
+
</Dialog>,
|
|
112
|
+
);
|
|
113
|
+
expect(screen.getByRole('dialog').className).toContain('auto');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('supports the fixed size (epic task 14.5)', () => {
|
|
117
|
+
render(
|
|
118
|
+
<Dialog open onClose={() => {}} size="fixed" aria-label="Fixed-size">
|
|
119
|
+
Body
|
|
120
|
+
</Dialog>,
|
|
121
|
+
);
|
|
122
|
+
expect(screen.getByRole('dialog').className).toContain('fixed');
|
|
123
|
+
});
|
|
124
|
+
|
|
97
125
|
it('renders a descendant-supplied secondRow via useOverlaySecondRow', () => {
|
|
98
126
|
function TabStrip() {
|
|
99
127
|
useOverlaySecondRow(<nav>Tab strip</nav>);
|
|
@@ -134,4 +162,157 @@ describe('Dialog', () => {
|
|
|
134
162
|
expect(screen.getByText('Standalone content')).toBeTruthy();
|
|
135
163
|
expect(screen.queryByText('Tab strip')).toBeNull();
|
|
136
164
|
});
|
|
165
|
+
|
|
166
|
+
describe('Body only (default — no header/footer props)', () => {
|
|
167
|
+
it('renders exactly one close button and no footer', () => {
|
|
168
|
+
render(
|
|
169
|
+
<Dialog open onClose={() => {}} title="Settings" aria-label="Settings">
|
|
170
|
+
Body
|
|
171
|
+
</Dialog>,
|
|
172
|
+
);
|
|
173
|
+
// Both the mobile-only OverlayHeader's close button and the
|
|
174
|
+
// desktop-only floating one are always in the DOM (CSS toggles which
|
|
175
|
+
// is visible per breakpoint, not React) — unchanged from before this
|
|
176
|
+
// task, since this is the `header`-omitted branch.
|
|
177
|
+
expect(screen.getAllByRole('button', { name: 'Close' })).toHaveLength(2);
|
|
178
|
+
expect(screen.getByText('Settings')).toBeTruthy();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('Header + Body (header prop)', () => {
|
|
183
|
+
it('renders header content via a single OverlayHeader, on both breakpoints, instead of the mobile-only title bar', () => {
|
|
184
|
+
render(
|
|
185
|
+
<Dialog open onClose={() => {}} header={<span>Card detail</span>} aria-label="Card detail">
|
|
186
|
+
Body
|
|
187
|
+
</Dialog>,
|
|
188
|
+
);
|
|
189
|
+
expect(screen.getByText('Card detail')).toBeTruthy();
|
|
190
|
+
// Only one close button now — the header branch skips the separate
|
|
191
|
+
// desktop .close button entirely (see Dialog.tsx's own comment).
|
|
192
|
+
expect(screen.getAllByRole('button', { name: 'Close' })).toHaveLength(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('supersedes title for visible content but keeps title as the aria-label fallback', () => {
|
|
196
|
+
render(
|
|
197
|
+
<Dialog open onClose={() => {}} title="Fallback name" header={<span>Visible header</span>}>
|
|
198
|
+
Body
|
|
199
|
+
</Dialog>,
|
|
200
|
+
);
|
|
201
|
+
expect(screen.getByText('Visible header')).toBeTruthy();
|
|
202
|
+
expect(screen.queryByText('Fallback name')).toBeNull();
|
|
203
|
+
expect(screen.getByRole('dialog', { name: 'Fallback name' })).toBeTruthy();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('Header + Body + Footer (header and footer props)', () => {
|
|
208
|
+
it('renders footer content as a non-scrolling sibling of the body', () => {
|
|
209
|
+
render(
|
|
210
|
+
<Dialog
|
|
211
|
+
open
|
|
212
|
+
onClose={() => {}}
|
|
213
|
+
header={<span>Card detail</span>}
|
|
214
|
+
footer={<button type="button">Save</button>}
|
|
215
|
+
aria-label="Card detail"
|
|
216
|
+
>
|
|
217
|
+
<p>Body content</p>
|
|
218
|
+
</Dialog>,
|
|
219
|
+
);
|
|
220
|
+
const footerButton = screen.getByRole('button', { name: 'Save' });
|
|
221
|
+
const bodyText = screen.getByText('Body content');
|
|
222
|
+
expect(footerButton).toBeTruthy();
|
|
223
|
+
// Footer is a sibling of .content (the scroll region), not nested
|
|
224
|
+
// inside it — it must never scroll away with the body.
|
|
225
|
+
expect(bodyText.closest('[class*="content"]')?.contains(footerButton)).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('omits the footer row entirely when footer is not provided', () => {
|
|
229
|
+
render(
|
|
230
|
+
<Dialog open onClose={() => {}} header={<span>Card detail</span>} aria-label="Card detail">
|
|
231
|
+
Body
|
|
232
|
+
</Dialog>,
|
|
233
|
+
);
|
|
234
|
+
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('Dialog Escape precedence with a nested ConfirmDialog', () => {
|
|
240
|
+
beforeEach(() => {
|
|
241
|
+
installMatchMedia();
|
|
242
|
+
// jsdom does not implement HTMLDialogElement — see ConfirmDialog.test.tsx
|
|
243
|
+
// for the full rationale on the guard + deferred dispatch (both matter
|
|
244
|
+
// here too: without the guard, this file's own reuse of the mock across
|
|
245
|
+
// renders would double-dispatch; without deferring, ConfirmDialog's
|
|
246
|
+
// unregister-on-close wouldn't have run yet by the time this test reads
|
|
247
|
+
// the outer Dialog's response to a second Escape).
|
|
248
|
+
HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) {
|
|
249
|
+
this.setAttribute('open', '');
|
|
250
|
+
};
|
|
251
|
+
HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) {
|
|
252
|
+
if (!this.hasAttribute('open')) return;
|
|
253
|
+
this.removeAttribute('open');
|
|
254
|
+
queueMicrotask(() => this.dispatchEvent(new Event('close')));
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
afterEach(() => {
|
|
258
|
+
vi.unstubAllGlobals();
|
|
259
|
+
cleanup();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
async function flush() {
|
|
263
|
+
await act(async () => {
|
|
264
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
it('defers Escape to a nested open ConfirmDialog instead of closing the outer Dialog', async () => {
|
|
269
|
+
const outerOnClose = vi.fn();
|
|
270
|
+
|
|
271
|
+
function NestedHarness() {
|
|
272
|
+
// Starts closed and opens via a later click — matching the real
|
|
273
|
+
// sequence (CardDetailOverlay: the outer Dialog is already mounted and
|
|
274
|
+
// registered before the user ever triggers the nested confirm).
|
|
275
|
+
// Mounting both already-open in the same initial render would instead
|
|
276
|
+
// have React fire the child's (ConfirmDialog's) registration effect
|
|
277
|
+
// before the parent's (Dialog's) in that one shared commit — the one
|
|
278
|
+
// case this stack-order approach doesn't handle, and not the shape of
|
|
279
|
+
// the bug this task fixes.
|
|
280
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
281
|
+
return (
|
|
282
|
+
<Dialog open onClose={outerOnClose} aria-label="Card detail">
|
|
283
|
+
<button onClick={() => setConfirmOpen(true)}>Delete card…</button>
|
|
284
|
+
<ConfirmDialog
|
|
285
|
+
open={confirmOpen}
|
|
286
|
+
onClose={() => setConfirmOpen(false)}
|
|
287
|
+
onConfirm={() => {}}
|
|
288
|
+
title="Delete card?"
|
|
289
|
+
message="This can't be undone."
|
|
290
|
+
/>
|
|
291
|
+
</Dialog>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
render(<NestedHarness />);
|
|
296
|
+
fireEvent.click(screen.getByRole('button', { name: 'Delete card…' }));
|
|
297
|
+
await flush();
|
|
298
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy();
|
|
299
|
+
|
|
300
|
+
// The nested ConfirmDialog is topmost — the outer Dialog's document-level
|
|
301
|
+
// Escape listener must not act while it's open.
|
|
302
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
303
|
+
await flush();
|
|
304
|
+
expect(outerOnClose).not.toHaveBeenCalled();
|
|
305
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy();
|
|
306
|
+
|
|
307
|
+
// Dismiss the nested confirm (unregisters it from the open-overlay
|
|
308
|
+
// stack) — the outer Dialog is now topmost again.
|
|
309
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
310
|
+
await flush();
|
|
311
|
+
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
|
|
312
|
+
|
|
313
|
+
// A second Escape now reaches the outer Dialog.
|
|
314
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
315
|
+
await flush();
|
|
316
|
+
expect(outerOnClose).toHaveBeenCalledOnce();
|
|
317
|
+
});
|
|
137
318
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
import { GripIcon } from '../GripIcon/GripIcon';
|
|
2
3
|
import styles from './DragHandleRow.module.css';
|
|
3
4
|
|
|
4
5
|
export interface DragHandleRowProps extends HTMLAttributes<HTMLDivElement> {
|
|
@@ -33,29 +34,9 @@ export function DragHandleRow({
|
|
|
33
34
|
tabIndex={-1}
|
|
34
35
|
{...handleProps}
|
|
35
36
|
>
|
|
36
|
-
<
|
|
37
|
+
<GripIcon className={styles.icon} />
|
|
37
38
|
</button>
|
|
38
39
|
<div className={styles.content}>{children}</div>
|
|
39
40
|
</div>
|
|
40
41
|
);
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
function DragIcon() {
|
|
44
|
-
return (
|
|
45
|
-
<svg
|
|
46
|
-
width="14"
|
|
47
|
-
height="14"
|
|
48
|
-
viewBox="0 0 14 14"
|
|
49
|
-
fill="none"
|
|
50
|
-
aria-hidden="true"
|
|
51
|
-
className={styles.icon}
|
|
52
|
-
>
|
|
53
|
-
{/* Two columns of three dots */}
|
|
54
|
-
{[3, 7, 11].map((cy) =>
|
|
55
|
-
[4, 10].map((cx) => (
|
|
56
|
-
<circle key={`${cx}-${cy}`} cx={cx} cy={cy} r={1.2} fill="currentColor" />
|
|
57
|
-
)),
|
|
58
|
-
)}
|
|
59
|
-
</svg>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
.scrim {
|
|
8
8
|
position: fixed;
|
|
9
9
|
inset: 0;
|
|
10
|
-
/* When
|
|
11
|
-
|
|
10
|
+
/* When --sv-shell-footer-height is set (mobile nav bar — by the platform
|
|
11
|
+
shell, or by a self-rendered MobileFooter via usePublishShellChromeHeight
|
|
12
|
+
for a shellConfig.mobileFooter: false plugin), stop the scrim above the
|
|
13
|
+
footer so the panel never hides behind it. */
|
|
12
14
|
bottom: var(--sv-shell-footer-height, 0);
|
|
13
15
|
z-index: 100;
|
|
14
16
|
display: flex;
|
|
@@ -27,14 +29,21 @@
|
|
|
27
29
|
.panel {
|
|
28
30
|
width: 100%;
|
|
29
31
|
max-height: 80dvh;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
/* The panel itself no longer scrolls — see .body below. display: flex
|
|
33
|
+
(column) lets .handle (and OverlayHeader, when title is set) stay
|
|
34
|
+
fixed while .body's own overflow-y handles content taller than the
|
|
35
|
+
80dvh cap, the same fixed-header/scrollable-body split Sheet.module.css
|
|
36
|
+
already establishes (its .panel/.content pair). Before this, the whole
|
|
37
|
+
.panel scrolled as one block, so a tall drawer (e.g. many installed
|
|
38
|
+
plugins in the Apps grid) scrolled the grab handle itself out of view
|
|
39
|
+
along with the content — reachable in the DOM, invisible on screen. */
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: column;
|
|
42
|
+
overflow: hidden;
|
|
32
43
|
background: var(--sv-color-surface-raised);
|
|
33
|
-
border-top:
|
|
44
|
+
border-top: var(--sv-border-width-hairline) solid var(--sv-color-border);
|
|
34
45
|
border-radius: var(--sv-radius-3xl) var(--sv-radius-3xl) 0 0;
|
|
35
46
|
box-shadow: var(--sv-shadow-overlay);
|
|
36
|
-
/* Pull content above the home indicator / bottom bar in standalone mode. */
|
|
37
|
-
padding-bottom: env(safe-area-inset-bottom);
|
|
38
47
|
outline: none;
|
|
39
48
|
/* Slide up from below the viewport. Always a bottom sheet, so unlike Dialog
|
|
40
49
|
there's no desktop-vs-mobile fork here. */
|
|
@@ -42,6 +51,26 @@
|
|
|
42
51
|
transition: transform var(--sv-motion-duration-base) var(--sv-motion-ease-out);
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
/* The scroll container — everything below the fixed handle/OverlayHeader.
|
|
55
|
+
Scrollbar hidden (not overflow removed) the same way Tabs.module.css's
|
|
56
|
+
.tablist already does for this design system; scroll-driven UI here reads
|
|
57
|
+
as a native swipe list, not a document needing a visible scroll affordance. */
|
|
58
|
+
.body {
|
|
59
|
+
flex: 1 1 auto;
|
|
60
|
+
min-height: 0;
|
|
61
|
+
overflow-y: auto;
|
|
62
|
+
overscroll-behavior: contain;
|
|
63
|
+
scrollbar-width: none;
|
|
64
|
+
/* Pull content above the home indicator / bottom bar in standalone mode —
|
|
65
|
+
moved here from .panel now that this is the element whose bottom edge
|
|
66
|
+
is the actual scrollable content boundary. */
|
|
67
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.body::-webkit-scrollbar {
|
|
71
|
+
display: none;
|
|
72
|
+
}
|
|
73
|
+
|
|
45
74
|
.panelOpen {
|
|
46
75
|
transform: translateY(0);
|
|
47
76
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { type PointerEvent as ReactPointerEvent, type ReactNode, useRef } from 'react';
|
|
4
4
|
import { useMountTransition, usePrefersReducedMotion } from '../../motion';
|
|
5
5
|
import {
|
|
6
|
+
OVERLAY_MOTION_DURATION_MS,
|
|
6
7
|
useOverlayFocusCapture,
|
|
7
8
|
useOverlayKeyboardTrap,
|
|
8
9
|
useOverlayScrollLock,
|
|
@@ -10,11 +11,6 @@ import {
|
|
|
10
11
|
import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
|
|
11
12
|
import styles from './Drawer.module.css';
|
|
12
13
|
|
|
13
|
-
// Matches --sv-motion-duration-base (Drawer.module.css) — see Dialog.tsx's
|
|
14
|
-
// identical constant for why this stays a plain JS number instead of being
|
|
15
|
-
// read from the CSS custom property.
|
|
16
|
-
const MOTION_DURATION_MS = 250;
|
|
17
|
-
|
|
18
14
|
export interface DrawerProps {
|
|
19
15
|
/** Whether the drawer is shown. When false, nothing renders. */
|
|
20
16
|
open: boolean;
|
|
@@ -74,7 +70,10 @@ export function Drawer({
|
|
|
74
70
|
}: DrawerProps) {
|
|
75
71
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
76
72
|
const reducedMotion = usePrefersReducedMotion();
|
|
77
|
-
const { mounted, phase } = useMountTransition(
|
|
73
|
+
const { mounted, phase } = useMountTransition(
|
|
74
|
+
open,
|
|
75
|
+
reducedMotion ? 0 : OVERLAY_MOTION_DURATION_MS,
|
|
76
|
+
);
|
|
78
77
|
// Tracks an in-progress handle drag. Kept in a ref (not state) so pointermove
|
|
79
78
|
// updates the DOM directly at 60fps instead of re-rendering on every event —
|
|
80
79
|
// same technique as the tasks plugin's swipe-to-reveal rows.
|
|
@@ -132,15 +131,15 @@ export function Drawer({
|
|
|
132
131
|
return (
|
|
133
132
|
// role="presentation" removes the scrim from the AT (it is purely visual).
|
|
134
133
|
// e.target check lets clicks inside the panel bubble without triggering dismiss.
|
|
134
|
+
// No onKeyDown here — useOverlayKeyboardTrap above already owns Escape via a
|
|
135
|
+
// document-level listener; a second handler here used to double-fire onClose
|
|
136
|
+
// per keypress (see Dialog.tsx's identical fix for the full explanation).
|
|
135
137
|
<div
|
|
136
138
|
className={[styles.scrim, isOpenPhase ? styles.scrimOpen : ''].filter(Boolean).join(' ')}
|
|
137
139
|
role="presentation"
|
|
138
140
|
onClick={(e) => {
|
|
139
141
|
if (e.target === e.currentTarget) onClose();
|
|
140
142
|
}}
|
|
141
|
-
onKeyDown={(e) => {
|
|
142
|
-
if (e.key === 'Escape') onClose();
|
|
143
|
-
}}
|
|
144
143
|
>
|
|
145
144
|
<div
|
|
146
145
|
ref={panelRef}
|
|
@@ -169,7 +168,7 @@ export function Drawer({
|
|
|
169
168
|
onPointerCancel={(e) => releaseDrag(e, false)}
|
|
170
169
|
/>
|
|
171
170
|
{title && <OverlayHeader title={title} onClose={onClose} />}
|
|
172
|
-
{children}
|
|
171
|
+
<div className={styles.body}>{children}</div>
|
|
173
172
|
</div>
|
|
174
173
|
</div>
|
|
175
174
|
);
|