@sovereignfs/ui 0.23.0 → 0.33.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/Button.module.css +75 -8
- package/dist/Calendar.module.css +126 -0
- package/dist/Card.module.css +4 -2
- package/dist/Checkbox.module.css +14 -2
- package/dist/CodeTextarea.module.css +53 -0
- package/dist/ConfirmDialog.module.css +109 -0
- package/dist/DatePicker.module.css +57 -0
- package/dist/Dialog.module.css +77 -50
- package/dist/DragHandleRow.module.css +9 -3
- package/dist/Drawer.module.css +61 -0
- package/dist/Icon.module.css +5 -0
- package/dist/Menu.module.css +82 -0
- package/dist/NavTabs.module.css +4 -2
- package/dist/OverlayHeader.module.css +98 -0
- package/dist/Popover.module.css +31 -1
- package/dist/SegmentedControl.module.css +4 -2
- package/dist/Sheet.module.css +55 -0
- package/dist/SplitPane.module.css +85 -0
- package/dist/StatusBadge.module.css +59 -0
- package/dist/SystemBanner.module.css +4 -2
- package/dist/Tabs.module.css +4 -2
- package/dist/TagInput.module.css +141 -0
- package/dist/Toast.module.css +4 -2
- package/dist/Tooltip.module.css +6 -1
- package/dist/index.d.ts +493 -17
- package/dist/index.js +1390 -209
- package/dist/tokens/primitives.css +19 -0
- package/dist/tokens/semantic.css +13 -0
- package/package.json +1 -1
- package/src/__tests__/motion.test.tsx +105 -0
- package/src/components/Button/Button.module.css +75 -8
- package/src/components/Button/Button.stories.tsx +6 -0
- package/src/components/Button/Button.tsx +17 -1
- package/src/components/Button/__tests__/Button.test.tsx +19 -0
- package/src/components/Calendar/Calendar.module.css +126 -0
- package/src/components/Calendar/Calendar.tsx +213 -0
- package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
- package/src/components/Calendar/dateUtils.ts +97 -0
- package/src/components/Card/Card.module.css +4 -2
- package/src/components/Checkbox/Checkbox.module.css +14 -2
- package/src/components/Checkbox/Checkbox.tsx +22 -12
- package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
- package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
- package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
- package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
- package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
- package/src/components/DatePicker/DatePicker.module.css +57 -0
- package/src/components/DatePicker/DatePicker.tsx +103 -0
- package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
- package/src/components/Dialog/Dialog.module.css +77 -50
- package/src/components/Dialog/Dialog.stories.tsx +51 -1
- package/src/components/Dialog/Dialog.tsx +86 -22
- package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
- package/src/components/DragHandleRow/DragHandleRow.module.css +9 -3
- package/src/components/Drawer/Drawer.module.css +61 -0
- package/src/components/Drawer/Drawer.tsx +117 -13
- package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
- package/src/components/Icon/Icon.module.css +5 -0
- package/src/components/Icon/Icon.stories.tsx +4 -3
- package/src/components/Icon/Icon.tsx +1 -1
- package/src/components/Icon/icons/calendar.tsx +23 -0
- package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
- package/src/components/Icon/icons/index.ts +8 -4
- package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
- package/src/components/Menu/Menu.module.css +82 -0
- package/src/components/Menu/Menu.tsx +137 -0
- package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
- package/src/components/NavTabs/NavTabs.module.css +4 -2
- package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
- package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
- package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
- package/src/components/Popover/Popover.module.css +31 -1
- package/src/components/Popover/Popover.stories.tsx +126 -1
- package/src/components/Popover/Popover.tsx +140 -7
- package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
- package/src/components/Sheet/Sheet.module.css +55 -0
- package/src/components/Sheet/Sheet.tsx +151 -0
- package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
- package/src/components/SplitPane/SplitPane.module.css +85 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
- package/src/components/SplitPane/SplitPane.tsx +149 -0
- package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
- package/src/components/StatusBadge/StatusBadge.module.css +59 -0
- package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
- package/src/components/StatusBadge/StatusBadge.tsx +65 -0
- package/src/components/SystemBanner/SystemBanner.module.css +4 -2
- package/src/components/Tabs/Tabs.module.css +4 -2
- package/src/components/TagInput/TagInput.module.css +141 -0
- package/src/components/TagInput/TagInput.stories.tsx +109 -0
- package/src/components/TagInput/TagInput.tsx +174 -0
- package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
- package/src/components/Toast/Toast.module.css +4 -2
- package/src/components/Tooltip/Tooltip.module.css +6 -1
- package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
- package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
- package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useDoubleTap.ts +91 -0
- package/src/hooks/useIsMobile.ts +33 -0
- package/src/hooks/useLongPress.ts +185 -0
- package/src/index.ts +30 -1
- package/src/motion.ts +78 -0
- package/src/stories/DesignSystemOverview.stories.tsx +292 -4
- package/src/stories/InteractionHooks.stories.tsx +388 -0
- package/src/stories/TokenGallery.stories.tsx +85 -1
- package/src/tokens/primitives.css +19 -0
- package/src/tokens/semantic.css +13 -0
- package/src/components/Icon/icons/terminal.tsx +0 -19
|
@@ -31,6 +31,16 @@
|
|
|
31
31
|
padding: var(--sv-space-8);
|
|
32
32
|
padding-left: calc(var(--sv-dialog-inset-left, 0px) + var(--sv-space-8));
|
|
33
33
|
background: var(--sv-color-scrim);
|
|
34
|
+
/* Entrance/exit fade. The Dialog component drives this via a two-phase
|
|
35
|
+
mount (see motion.ts) — .scrimOpen is added one frame after mount so this
|
|
36
|
+
transition has somewhere to animate from, and removed (while staying
|
|
37
|
+
mounted) for the exit before the component actually unmounts. */
|
|
38
|
+
opacity: 0;
|
|
39
|
+
transition: opacity var(--sv-motion-duration-base) var(--sv-motion-ease-out);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.scrimOpen {
|
|
43
|
+
opacity: 1;
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
.panel {
|
|
@@ -43,6 +53,30 @@
|
|
|
43
53
|
border: 1px solid var(--sv-color-border);
|
|
44
54
|
border-radius: var(--sv-radius-lg);
|
|
45
55
|
box-shadow: var(--sv-shadow-overlay);
|
|
56
|
+
/* Desktop entrance: fade + gentle scale-up. Same two-phase-mount pattern as
|
|
57
|
+
.scrim above. Mobile overrides this to a slide-up instead (below). */
|
|
58
|
+
opacity: 0;
|
|
59
|
+
transform: scale(0.96);
|
|
60
|
+
transition:
|
|
61
|
+
opacity var(--sv-motion-duration-base) var(--sv-motion-ease-out),
|
|
62
|
+
transform var(--sv-motion-duration-base) var(--sv-motion-ease-out);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.panelOpen {
|
|
66
|
+
opacity: 1;
|
|
67
|
+
transform: scale(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* prefers-reduced-motion: collapse both fade/scale and slide to near-instant.
|
|
71
|
+
Not literally 0 — a 0ms transition can skip applying entirely in some
|
|
72
|
+
engines, and the JS unmount timer (Dialog.tsx's MOTION_DURATION_MS,
|
|
73
|
+
switched to ~0 via usePrefersReducedMotion) needs a transition to still be
|
|
74
|
+
"present" for the visual state to actually reach its end value in time. */
|
|
75
|
+
@media (prefers-reduced-motion: reduce) {
|
|
76
|
+
.scrim,
|
|
77
|
+
.panel {
|
|
78
|
+
transition-duration: 0.01ms;
|
|
79
|
+
}
|
|
46
80
|
}
|
|
47
81
|
|
|
48
82
|
.content {
|
|
@@ -53,7 +87,7 @@
|
|
|
53
87
|
/* Disable elastic overscroll within the dialog: `none` stops both scroll-
|
|
54
88
|
chaining to the parent and the iOS rubber-band bounce inside the panel.
|
|
55
89
|
`contain` only prevents chaining — the panel itself still bounces, which
|
|
56
|
-
reveals white panel-background between
|
|
90
|
+
reveals white panel-background between OverlayHeader and the sticky tab
|
|
57
91
|
strip on iOS PWA. A dialog is a bounded surface where bounce is a visual
|
|
58
92
|
bug, not a feature. */
|
|
59
93
|
overscroll-behavior: none;
|
|
@@ -100,9 +134,11 @@
|
|
|
100
134
|
cursor: pointer;
|
|
101
135
|
}
|
|
102
136
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
137
|
+
@media (hover: hover) {
|
|
138
|
+
.close:hover {
|
|
139
|
+
background: var(--sv-color-surface-sunken);
|
|
140
|
+
color: var(--sv-color-text-primary);
|
|
141
|
+
}
|
|
106
142
|
}
|
|
107
143
|
|
|
108
144
|
.close:focus-visible {
|
|
@@ -110,8 +146,10 @@
|
|
|
110
146
|
outline-offset: 2px;
|
|
111
147
|
}
|
|
112
148
|
|
|
113
|
-
/* Hidden on desktop; shown only on mobile where it replaces the close button.
|
|
114
|
-
|
|
149
|
+
/* Hidden on desktop; shown only on mobile where it replaces the close button.
|
|
150
|
+
* OverlayHeader owns its own background/border/padding — this wrapper class
|
|
151
|
+
* only toggles visibility per breakpoint. */
|
|
152
|
+
.mobileHeader {
|
|
115
153
|
display: none;
|
|
116
154
|
}
|
|
117
155
|
|
|
@@ -122,10 +160,14 @@
|
|
|
122
160
|
@media (max-width: 768px) {
|
|
123
161
|
/* On mobile the overlay feels like a full-page push, not a floating modal.
|
|
124
162
|
Remove the dim scrim and panel chrome so it's indistinguishable from a
|
|
125
|
-
regular page render. router.back() still closes it correctly.
|
|
163
|
+
regular page render. router.back() still closes it correctly.
|
|
164
|
+
bottom stops the scrim above the footer nav (matching Drawer) instead of
|
|
165
|
+
running the full viewport height — the panel used to extend under the
|
|
166
|
+
footer with only .content's own padding compensating for it. */
|
|
126
167
|
.scrim {
|
|
127
168
|
padding: 0;
|
|
128
169
|
background: transparent;
|
|
170
|
+
bottom: var(--sv-shell-footer-height, 0);
|
|
129
171
|
}
|
|
130
172
|
|
|
131
173
|
.panel,
|
|
@@ -143,54 +185,39 @@
|
|
|
143
185
|
background: var(--sv-color-surface);
|
|
144
186
|
}
|
|
145
187
|
|
|
146
|
-
/*
|
|
147
|
-
|
|
148
|
-
|
|
188
|
+
/* Mobile entrance is a slide-up, not the desktop fade+scale — matches "page
|
|
189
|
+
push" framing above. No opacity change (the panel is opaque full-screen
|
|
190
|
+
content, not a floating box), so only transform needs a transition here. */
|
|
191
|
+
.panel {
|
|
192
|
+
opacity: 1;
|
|
193
|
+
transform: translateY(100%);
|
|
194
|
+
transition: transform var(--sv-motion-duration-base) var(--sv-motion-ease-out);
|
|
149
195
|
}
|
|
150
196
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
.mobileBar {
|
|
154
|
-
display: flex;
|
|
155
|
-
align-items: center;
|
|
156
|
-
flex-shrink: 0;
|
|
157
|
-
padding: var(--sv-space-3) var(--sv-space-4);
|
|
158
|
-
background: var(--sv-color-surface);
|
|
159
|
-
border-bottom: 1px solid var(--sv-color-border);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
.mobileBarTitle {
|
|
163
|
-
flex: 1;
|
|
164
|
-
font-size: var(--sv-font-size-xl);
|
|
165
|
-
font-weight: var(--sv-font-weight-semibold);
|
|
166
|
-
color: var(--sv-color-text-primary);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
.mobileBarClose {
|
|
170
|
-
display: inline-flex;
|
|
171
|
-
align-items: center;
|
|
172
|
-
justify-content: center;
|
|
173
|
-
width: var(--sv-space-8);
|
|
174
|
-
height: var(--sv-space-8);
|
|
175
|
-
flex-shrink: 0;
|
|
176
|
-
border: none;
|
|
177
|
-
border-radius: var(--sv-radius-md);
|
|
178
|
-
background: transparent;
|
|
179
|
-
color: var(--sv-color-text-muted);
|
|
180
|
-
font-size: var(--sv-font-size-xl);
|
|
181
|
-
line-height: 1;
|
|
182
|
-
cursor: pointer;
|
|
197
|
+
.panelOpen {
|
|
198
|
+
transform: translateY(0);
|
|
183
199
|
}
|
|
184
200
|
|
|
185
|
-
.
|
|
186
|
-
|
|
187
|
-
|
|
201
|
+
/* Desktop close button is replaced by OverlayHeader on mobile. */
|
|
202
|
+
.close {
|
|
203
|
+
display: none;
|
|
188
204
|
}
|
|
189
205
|
|
|
190
|
-
/*
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
206
|
+
/* Title + close button in one solid row, via the shared OverlayHeader
|
|
207
|
+
component. Being a flex sibling before .content means it never scrolls —
|
|
208
|
+
the content area scrolls below it.
|
|
209
|
+
.content itself needs no mobile override here any more: it used to zero
|
|
210
|
+
out padding-top so a plugin's hand-rolled sticky header (rendered as
|
|
211
|
+
.content's own first child, right below OverlayHeader) would sit flush
|
|
212
|
+
against it. That case no longer exists — a plugin with a tab strip/
|
|
213
|
+
secondary nav now hands it to OverlayHeader's own secondRow slot
|
|
214
|
+
(useOverlaySecondRow) instead of rendering it inside .content; Account
|
|
215
|
+
and Console both migrated to this (mobile design-system plan, Phase
|
|
216
|
+
C1b). With .content's first child back to being genuine page content, it
|
|
217
|
+
needs the same breathing room from the header that .content's own base
|
|
218
|
+
padding (var(--sv-space-6) on every side) already provides everywhere
|
|
219
|
+
else. */
|
|
220
|
+
.mobileHeader {
|
|
221
|
+
display: block;
|
|
195
222
|
}
|
|
196
223
|
}
|
|
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
|
2
2
|
import { expect, userEvent, within } from 'storybook/test';
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { Button } from '../Button/Button';
|
|
5
|
-
import { Dialog } from './Dialog';
|
|
5
|
+
import { Dialog, useOverlaySecondRow } from './Dialog';
|
|
6
6
|
|
|
7
7
|
// Controlled wrapper so the play function can open/inspect the dialog.
|
|
8
8
|
function DialogDemo({
|
|
@@ -95,3 +95,53 @@ export const OpenViaInteraction: Story = {
|
|
|
95
95
|
await expect(dialog).toBeVisible();
|
|
96
96
|
},
|
|
97
97
|
};
|
|
98
|
+
|
|
99
|
+
// A stand-in for a plugin's own route layout — several component layers below
|
|
100
|
+
// wherever <Dialog> itself is instantiated, exactly like the real
|
|
101
|
+
// AccountLayout/ConsoleLayout usage this hook was built for.
|
|
102
|
+
function NestedTabStrip() {
|
|
103
|
+
const insideOverlay = useOverlaySecondRow(
|
|
104
|
+
<div style={{ display: 'flex', gap: 16, padding: '0 16px' }}>
|
|
105
|
+
{['Profile', 'Security', 'Preferences'].map((tab) => (
|
|
106
|
+
<span key={tab} style={{ fontSize: 14, color: 'var(--sv-color-text-primary)' }}>
|
|
107
|
+
{tab}
|
|
108
|
+
</span>
|
|
109
|
+
))}
|
|
110
|
+
</div>,
|
|
111
|
+
);
|
|
112
|
+
return (
|
|
113
|
+
<p style={{ color: 'var(--sv-color-text-muted)', fontSize: 13 }}>
|
|
114
|
+
useOverlaySecondRow found a Dialog ancestor: <strong>{String(insideOverlay)}</strong>. Switch
|
|
115
|
+
the viewport toolbar to a mobile width to see the tab strip render inside the Dialog's own
|
|
116
|
+
mobile OverlayHeader instead of here.
|
|
117
|
+
</p>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Demonstrates useOverlaySecondRow — solves the "double header" problem for
|
|
123
|
+
* overlay-shell plugins (Console, Account): a deeply-nested layout hands its
|
|
124
|
+
* tab strip up to the enclosing Dialog's mobile OverlayHeader instead of
|
|
125
|
+
* rendering a second header bar as ordinary content. Only visible at mobile
|
|
126
|
+
* widths, where Dialog's own OverlayHeader takes over; at desktop widths the
|
|
127
|
+
* secondRow prop has no visible effect (OverlayHeader is desktop-hidden).
|
|
128
|
+
*/
|
|
129
|
+
export const WithOverlaySecondRow: Story = {
|
|
130
|
+
args: { open: false, onClose: () => {}, children: null },
|
|
131
|
+
render: (_args) => {
|
|
132
|
+
function Demo() {
|
|
133
|
+
const [open, setOpen] = useState(false);
|
|
134
|
+
return (
|
|
135
|
+
<>
|
|
136
|
+
<Button onClick={() => setOpen(true)}>Open dialog with tab strip</Button>
|
|
137
|
+
<Dialog open={open} onClose={() => setOpen(false)} title="Account" aria-label="Account">
|
|
138
|
+
<div style={{ padding: 24, fontFamily: 'system-ui' }}>
|
|
139
|
+
<NestedTabStrip />
|
|
140
|
+
</div>
|
|
141
|
+
</Dialog>
|
|
142
|
+
</>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return <Demo />;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
@@ -1,9 +1,57 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { type ReactNode, useEffect, useRef } from 'react';
|
|
3
|
+
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react';
|
|
4
4
|
import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
|
|
5
|
+
import { useMountTransition, usePrefersReducedMotion } from '../../motion';
|
|
6
|
+
import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
|
|
5
7
|
import styles from './Dialog.module.css';
|
|
6
8
|
|
|
9
|
+
// Matches --sv-motion-duration-base (Dialog.module.css) — kept as a plain JS
|
|
10
|
+
// constant rather than read from the CSS custom property so the unmount timer
|
|
11
|
+
// and the CSS transition duration can't silently drift apart at build time;
|
|
12
|
+
// change both together if this value ever changes.
|
|
13
|
+
const MOTION_DURATION_MS = 250;
|
|
14
|
+
|
|
15
|
+
// undefined (the default, outside any Provider) means "no Dialog ancestor" —
|
|
16
|
+
// distinct from a real setter function, so useOverlaySecondRow can silently
|
|
17
|
+
// no-op when called outside a Dialog instead of throwing.
|
|
18
|
+
const OverlaySecondRowContext = createContext<((node: ReactNode | null) => void) | undefined>(
|
|
19
|
+
undefined,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Lets content deep inside a `Dialog` (e.g. a plugin's own route layout,
|
|
24
|
+
* rendered several levels below wherever the `Dialog` itself is
|
|
25
|
+
* instantiated) supply the second-row content of the Dialog's mobile
|
|
26
|
+
* `OverlayHeader` — typically a tab strip. Solves the "double header"
|
|
27
|
+
* problem: without this, a plugin's own tab strip has no way to reach the
|
|
28
|
+
* Dialog's header and ends up rendered a second time, as its own sticky bar,
|
|
29
|
+
* inside the scrolling content area.
|
|
30
|
+
*
|
|
31
|
+
* A no-op when there is no enclosing `Dialog` (e.g. the same plugin layout
|
|
32
|
+
* also rendered on a plain, non-overlay route) — safe to call unconditionally.
|
|
33
|
+
* Returns whether an enclosing `Dialog` actually received the content, so a
|
|
34
|
+
* caller that also renders its own inline header/tab-strip copy for the
|
|
35
|
+
* no-Dialog case can hide that copy on mobile specifically when this
|
|
36
|
+
* returned `true` (the Dialog's own header is showing it instead there).
|
|
37
|
+
*
|
|
38
|
+
* ```tsx
|
|
39
|
+
* function AccountLayout({ children }) {
|
|
40
|
+
* const insideOverlay = useOverlaySecondRow(<nav>...tab strip...</nav>);
|
|
41
|
+
* return <div>{children}</div>;
|
|
42
|
+
* }
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function useOverlaySecondRow(node: ReactNode | null): boolean {
|
|
46
|
+
const setSecondRow = useContext(OverlaySecondRowContext);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!setSecondRow) return;
|
|
49
|
+
setSecondRow(node);
|
|
50
|
+
return () => setSecondRow(null);
|
|
51
|
+
}, [setSecondRow, node]);
|
|
52
|
+
return setSecondRow !== undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
7
55
|
export type DialogSize = 'sm' | 'md' | 'lg' | 'full';
|
|
8
56
|
|
|
9
57
|
export interface DialogProps {
|
|
@@ -33,6 +81,12 @@ const FOCUSABLE =
|
|
|
33
81
|
* is restored to the previously-focused element on close; Tab is trapped within
|
|
34
82
|
* the panel. Styling references `--sv-*` tokens only; on mobile the panel becomes
|
|
35
83
|
* a full-screen sheet regardless of `size`.
|
|
84
|
+
*
|
|
85
|
+
* Animated open/close: fade + scale on desktop, slide-up on mobile (matching
|
|
86
|
+
* the "feels like a page push" framing of the mobile sheet). The `open`/
|
|
87
|
+
* `onClose` API is unchanged — closing still stays mounted internally for the
|
|
88
|
+
* exit transition before actually unmounting; `prefers-reduced-motion: reduce`
|
|
89
|
+
* collapses both to near-instant.
|
|
36
90
|
*/
|
|
37
91
|
export function Dialog({
|
|
38
92
|
open,
|
|
@@ -44,15 +98,20 @@ export function Dialog({
|
|
|
44
98
|
}: DialogProps) {
|
|
45
99
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
46
100
|
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
101
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
102
|
+
const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
|
|
103
|
+
const [secondRow, setSecondRow] = useState<ReactNode | null>(null);
|
|
47
104
|
|
|
48
|
-
// Prevent document-level scroll
|
|
49
|
-
//
|
|
50
|
-
//
|
|
105
|
+
// Prevent document-level scroll for the whole mounted lifetime, including
|
|
106
|
+
// the exit animation — not just while `open` — so the background can't
|
|
107
|
+
// scroll while the panel is still visibly sliding/fading away. Ref-counted
|
|
108
|
+
// so nested overlays (e.g. a confirmation dialog inside an overlay plugin)
|
|
109
|
+
// don't release the lock while a sibling is still open.
|
|
51
110
|
useEffect(() => {
|
|
52
|
-
if (!
|
|
111
|
+
if (!mounted) return;
|
|
53
112
|
lockBodyScroll();
|
|
54
113
|
return unlockBodyScroll;
|
|
55
|
-
}, [
|
|
114
|
+
}, [mounted]);
|
|
56
115
|
|
|
57
116
|
// Capture focus on open; restore it on close/unmount.
|
|
58
117
|
useEffect(() => {
|
|
@@ -98,13 +157,14 @@ export function Dialog({
|
|
|
98
157
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
99
158
|
}, [open, onClose]);
|
|
100
159
|
|
|
101
|
-
if (!
|
|
160
|
+
if (!mounted) return null;
|
|
161
|
+
const isOpenPhase = phase === 'open';
|
|
102
162
|
|
|
103
163
|
return (
|
|
104
164
|
// role="presentation" removes the scrim from the AT (it is purely visual).
|
|
105
165
|
// e.target check lets clicks inside the panel bubble without triggering dismiss.
|
|
106
166
|
<div
|
|
107
|
-
className={styles.scrim}
|
|
167
|
+
className={[styles.scrim, isOpenPhase ? styles.scrimOpen : ''].filter(Boolean).join(' ')}
|
|
108
168
|
role="presentation"
|
|
109
169
|
onClick={(e) => {
|
|
110
170
|
if (e.target === e.currentTarget) onClose();
|
|
@@ -119,20 +179,20 @@ export function Dialog({
|
|
|
119
179
|
aria-modal="true"
|
|
120
180
|
aria-label={ariaLabel ?? title}
|
|
121
181
|
tabIndex={-1}
|
|
122
|
-
className={[styles.panel, styles[size]
|
|
182
|
+
className={[styles.panel, styles[size], isOpenPhase ? styles.panelOpen : '']
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.join(' ')}
|
|
123
185
|
>
|
|
124
|
-
{/* Mobile: title + close
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
</button>
|
|
135
|
-
</div>
|
|
186
|
+
{/* Mobile: shared OverlayHeader (title + close in one row, hidden on
|
|
187
|
+
desktop via CSS — see .mobileHeader). secondRow is populated by a
|
|
188
|
+
descendant's useOverlaySecondRow call, e.g. a plugin's own tab
|
|
189
|
+
strip — see that hook's doc comment for why. */}
|
|
190
|
+
<OverlayHeader
|
|
191
|
+
title={title}
|
|
192
|
+
onClose={onClose}
|
|
193
|
+
secondRow={secondRow}
|
|
194
|
+
className={styles.mobileHeader}
|
|
195
|
+
/>
|
|
136
196
|
{/* Desktop: absolute close button (hidden on mobile via CSS). */}
|
|
137
197
|
<button type="button" className={styles.close} aria-label="Close" onClick={onClose}>
|
|
138
198
|
×
|
|
@@ -140,7 +200,11 @@ export function Dialog({
|
|
|
140
200
|
{/* The panel is a fixed-size box; only this region scrolls, so the
|
|
141
201
|
panel never resizes with its content and the close button stays
|
|
142
202
|
pinned. */}
|
|
143
|
-
<div className={styles.content}>
|
|
203
|
+
<div className={styles.content}>
|
|
204
|
+
<OverlaySecondRowContext.Provider value={setSecondRow}>
|
|
205
|
+
{children}
|
|
206
|
+
</OverlaySecondRowContext.Provider>
|
|
207
|
+
</div>
|
|
144
208
|
</div>
|
|
145
209
|
</div>
|
|
146
210
|
);
|
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
-
import { Dialog } from '../Dialog';
|
|
4
|
+
import { Dialog, useOverlaySecondRow } from '../Dialog';
|
|
5
|
+
|
|
6
|
+
// Dialog's exit animation reads prefers-reduced-motion via matchMedia, which
|
|
7
|
+
// jsdom does not implement. `matches: false` (motion enabled) exercises the
|
|
8
|
+
// normal animated path; the actual reduced-motion behaviour is covered by
|
|
9
|
+
// motion.ts's own tests.
|
|
10
|
+
function installMatchMedia() {
|
|
11
|
+
vi.stubGlobal(
|
|
12
|
+
'matchMedia',
|
|
13
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
14
|
+
matches: false,
|
|
15
|
+
media: query,
|
|
16
|
+
addEventListener: vi.fn(),
|
|
17
|
+
removeEventListener: vi.fn(),
|
|
18
|
+
addListener: vi.fn(),
|
|
19
|
+
removeListener: vi.fn(),
|
|
20
|
+
dispatchEvent: vi.fn(),
|
|
21
|
+
})),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
5
24
|
|
|
6
25
|
describe('Dialog', () => {
|
|
7
|
-
|
|
26
|
+
beforeEach(installMatchMedia);
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
cleanup();
|
|
30
|
+
});
|
|
8
31
|
|
|
9
32
|
it('renders nothing when closed', () => {
|
|
10
33
|
render(
|
|
@@ -70,4 +93,45 @@ describe('Dialog', () => {
|
|
|
70
93
|
);
|
|
71
94
|
expect(screen.getByRole('dialog').className).toContain('sm');
|
|
72
95
|
});
|
|
96
|
+
|
|
97
|
+
it('renders a descendant-supplied secondRow via useOverlaySecondRow', () => {
|
|
98
|
+
function TabStrip() {
|
|
99
|
+
useOverlaySecondRow(<nav>Tab strip</nav>);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
render(
|
|
103
|
+
<Dialog open onClose={() => {}} title="Account" aria-label="Account">
|
|
104
|
+
<TabStrip />
|
|
105
|
+
</Dialog>,
|
|
106
|
+
);
|
|
107
|
+
expect(screen.getByText('Tab strip')).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('removes the secondRow when the supplying descendant unmounts', () => {
|
|
111
|
+
function TabStrip() {
|
|
112
|
+
useOverlaySecondRow(<nav>Tab strip</nav>);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
function Harness({ showTabs }: { showTabs: boolean }) {
|
|
116
|
+
return (
|
|
117
|
+
<Dialog open onClose={() => {}} title="Account" aria-label="Account">
|
|
118
|
+
{showTabs && <TabStrip />}
|
|
119
|
+
</Dialog>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const { rerender } = render(<Harness showTabs />);
|
|
123
|
+
expect(screen.getByText('Tab strip')).toBeTruthy();
|
|
124
|
+
rerender(<Harness showTabs={false} />);
|
|
125
|
+
expect(screen.queryByText('Tab strip')).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('is a no-op when called outside a Dialog', () => {
|
|
129
|
+
function Standalone() {
|
|
130
|
+
useOverlaySecondRow(<nav>Tab strip</nav>);
|
|
131
|
+
return <span>Standalone content</span>;
|
|
132
|
+
}
|
|
133
|
+
render(<Standalone />);
|
|
134
|
+
expect(screen.getByText('Standalone content')).toBeTruthy();
|
|
135
|
+
expect(screen.queryByText('Tab strip')).toBeNull();
|
|
136
|
+
});
|
|
73
137
|
});
|
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
position: relative;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/* Hover-reveal only where hover genuinely exists — on touch the handle would
|
|
9
|
+
otherwise never become visible (tap-generated hover doesn't count as a
|
|
10
|
+
sustained hover state, and :not(:hover) is always true with no hover
|
|
11
|
+
capability at all), so it stays visible by default there. */
|
|
12
|
+
@media (hover: hover) {
|
|
13
|
+
.row:not(:hover) .handle,
|
|
14
|
+
.row:not(:focus-within) .handle {
|
|
15
|
+
opacity: 0;
|
|
16
|
+
}
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
.handle {
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
display: flex;
|
|
15
15
|
align-items: flex-end;
|
|
16
16
|
background: var(--sv-color-scrim);
|
|
17
|
+
/* Entrance/exit fade — same two-phase-mount pattern as Dialog (see motion.ts
|
|
18
|
+
and Drawer.tsx). */
|
|
19
|
+
opacity: 0;
|
|
20
|
+
transition: opacity var(--sv-motion-duration-base) var(--sv-motion-ease-out);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.scrimOpen {
|
|
24
|
+
opacity: 1;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
.panel {
|
|
@@ -28,4 +36,57 @@
|
|
|
28
36
|
/* Pull content above the home indicator / bottom bar in standalone mode. */
|
|
29
37
|
padding-bottom: env(safe-area-inset-bottom);
|
|
30
38
|
outline: none;
|
|
39
|
+
/* Slide up from below the viewport. Always a bottom sheet, so unlike Dialog
|
|
40
|
+
there's no desktop-vs-mobile fork here. */
|
|
41
|
+
transform: translateY(100%);
|
|
42
|
+
transition: transform var(--sv-motion-duration-base) var(--sv-motion-ease-out);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.panelOpen {
|
|
46
|
+
transform: translateY(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* 'half' snapHeight — a fixed height instead of the default content-sized cap
|
|
50
|
+
* above. Per the design-system's "half screen or less" convention for
|
|
51
|
+
* Drawer; content taller than this belongs in Sheet instead. */
|
|
52
|
+
.panelHalf {
|
|
53
|
+
height: 50dvh;
|
|
54
|
+
max-height: 50dvh;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* See Dialog.module.css's identical rule for why 0.01ms rather than 0. */
|
|
58
|
+
@media (prefers-reduced-motion: reduce) {
|
|
59
|
+
.scrim,
|
|
60
|
+
.panel {
|
|
61
|
+
transition-duration: 0.01ms;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Grab handle — the sole drag-initiation region for swipe-down-to-dismiss
|
|
66
|
+
* (see Drawer.tsx's doc comment for why not the whole panel). The visual bar
|
|
67
|
+
* (::before) stays the small, established 36x4px size; only the hit area
|
|
68
|
+
* grows to a comfortable touch target. */
|
|
69
|
+
.handle {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
justify-content: center;
|
|
73
|
+
width: 100%;
|
|
74
|
+
height: var(--sv-touch-target-min, 44px);
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
cursor: grab;
|
|
77
|
+
/* JS-driven drag — the browser must not treat a press here as a
|
|
78
|
+
scroll/pan gesture that would fight it. */
|
|
79
|
+
touch-action: none;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.handle:active {
|
|
83
|
+
cursor: grabbing;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.handle::before {
|
|
87
|
+
content: '';
|
|
88
|
+
width: 36px;
|
|
89
|
+
height: 4px;
|
|
90
|
+
border-radius: var(--sv-radius-full);
|
|
91
|
+
background: var(--sv-color-border-strong);
|
|
31
92
|
}
|