@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
|
@@ -11,7 +11,7 @@ const meta = {
|
|
|
11
11
|
docs: {
|
|
12
12
|
description: {
|
|
13
13
|
component:
|
|
14
|
-
'Floating panel anchored
|
|
14
|
+
'Floating panel anchored to a trigger, below it by default. Non-modal: closes on outside click or Escape. `align` controls which trigger edge the panel aligns to; `width` accepts a fixed px number or `"trigger"` to match the trigger\'s own rendered width. Automatically flips to open upward instead of downward if the panel wouldn\'t fit below the trigger within the viewport.',
|
|
15
15
|
},
|
|
16
16
|
},
|
|
17
17
|
},
|
|
@@ -230,3 +230,128 @@ export const LeftAligned: Story = {
|
|
|
230
230
|
);
|
|
231
231
|
},
|
|
232
232
|
};
|
|
233
|
+
|
|
234
|
+
/** `panelStyle` overrides the panel's own chrome (square corners here) —
|
|
235
|
+
* an escape hatch for compact pickers where the default rounded panel
|
|
236
|
+
* doesn't fit the content (e.g. a small colour-swatch grid). */
|
|
237
|
+
export const SquareCorners: Story = {
|
|
238
|
+
render: (_args) => {
|
|
239
|
+
const [open, setOpen] = useState(false);
|
|
240
|
+
return (
|
|
241
|
+
<div style={{ padding: 40 }}>
|
|
242
|
+
<Popover
|
|
243
|
+
open={open}
|
|
244
|
+
onClose={() => setOpen(false)}
|
|
245
|
+
align="left"
|
|
246
|
+
width={160}
|
|
247
|
+
panelStyle={{ borderRadius: 0 }}
|
|
248
|
+
aria-label="Colour"
|
|
249
|
+
trigger={<Button onClick={() => setOpen((o) => !o)}>Open (square panel)</Button>}
|
|
250
|
+
>
|
|
251
|
+
<div
|
|
252
|
+
style={{
|
|
253
|
+
padding: 16,
|
|
254
|
+
fontFamily: 'var(--sv-font-family)',
|
|
255
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
256
|
+
color: 'var(--sv-color-text-muted)',
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
No rounded corners.
|
|
260
|
+
</div>
|
|
261
|
+
</Popover>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/** `width="trigger"` matches the panel's width to the trigger's own rendered
|
|
268
|
+
* width — for a dropdown under a full-width form field, a fixed px value
|
|
269
|
+
* would either overflow or leave a gap depending on the field's own width. */
|
|
270
|
+
export const MatchesTriggerWidth: Story = {
|
|
271
|
+
render: (_args) => {
|
|
272
|
+
const [open, setOpen] = useState(false);
|
|
273
|
+
return (
|
|
274
|
+
<div style={{ padding: 40, width: 360 }}>
|
|
275
|
+
<Popover
|
|
276
|
+
open={open}
|
|
277
|
+
onClose={() => setOpen(false)}
|
|
278
|
+
align="left"
|
|
279
|
+
width="trigger"
|
|
280
|
+
aria-label="Set due date"
|
|
281
|
+
trigger={
|
|
282
|
+
<button
|
|
283
|
+
type="button"
|
|
284
|
+
onClick={() => setOpen((o) => !o)}
|
|
285
|
+
style={{
|
|
286
|
+
width: '100%',
|
|
287
|
+
boxSizing: 'border-box',
|
|
288
|
+
textAlign: 'left',
|
|
289
|
+
padding: '10px 14px',
|
|
290
|
+
border: '1px solid var(--sv-color-border)',
|
|
291
|
+
borderRadius: 'var(--sv-radius-md)',
|
|
292
|
+
background: 'var(--sv-color-surface-raised)',
|
|
293
|
+
fontFamily: 'var(--sv-font-family)',
|
|
294
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
295
|
+
color: 'var(--sv-color-text-primary)',
|
|
296
|
+
cursor: 'pointer',
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
No due date
|
|
300
|
+
</button>
|
|
301
|
+
}
|
|
302
|
+
>
|
|
303
|
+
<div
|
|
304
|
+
style={{
|
|
305
|
+
padding: 16,
|
|
306
|
+
fontFamily: 'var(--sv-font-family)',
|
|
307
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
308
|
+
color: 'var(--sv-color-text-muted)',
|
|
309
|
+
}}
|
|
310
|
+
>
|
|
311
|
+
Panel width matches the 360px trigger above exactly, instead of a fixed pixel value that
|
|
312
|
+
would overflow or leave a gap at other trigger widths.
|
|
313
|
+
</div>
|
|
314
|
+
</Popover>
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/** Flips to open upward automatically when the panel wouldn't fit below the
|
|
321
|
+
* trigger within the viewport — no prop needed, this is always on. */
|
|
322
|
+
export const OpensUpward: Story = {
|
|
323
|
+
render: (_args) => {
|
|
324
|
+
const [open, setOpen] = useState(false);
|
|
325
|
+
return (
|
|
326
|
+
<div
|
|
327
|
+
style={{
|
|
328
|
+
height: '70vh',
|
|
329
|
+
display: 'flex',
|
|
330
|
+
alignItems: 'flex-end',
|
|
331
|
+
justifyContent: 'center',
|
|
332
|
+
paddingBottom: 24,
|
|
333
|
+
}}
|
|
334
|
+
>
|
|
335
|
+
<Popover
|
|
336
|
+
open={open}
|
|
337
|
+
onClose={() => setOpen(false)}
|
|
338
|
+
align="left"
|
|
339
|
+
aria-label="Options"
|
|
340
|
+
trigger={<Button onClick={() => setOpen((o) => !o)}>Open (near viewport bottom)</Button>}
|
|
341
|
+
>
|
|
342
|
+
<div
|
|
343
|
+
style={{
|
|
344
|
+
padding: 16,
|
|
345
|
+
fontFamily: 'var(--sv-font-family)',
|
|
346
|
+
fontSize: 'var(--sv-font-size-sm)',
|
|
347
|
+
color: 'var(--sv-color-text-muted)',
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
This trigger sits near the bottom of the viewport, so the panel opens upward instead of
|
|
351
|
+
getting clipped below.
|
|
352
|
+
</div>
|
|
353
|
+
</Popover>
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
},
|
|
357
|
+
};
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type ReactElement,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from 'react';
|
|
4
12
|
import styles from './Popover.module.css';
|
|
5
13
|
|
|
6
14
|
export interface PopoverProps {
|
|
@@ -10,17 +18,57 @@ export interface PopoverProps {
|
|
|
10
18
|
onClose: () => void;
|
|
11
19
|
/** Which edge of the trigger the panel aligns to. Default: 'right'. */
|
|
12
20
|
align?: 'left' | 'right';
|
|
13
|
-
/** Panel width in px
|
|
14
|
-
width
|
|
21
|
+
/** Panel width in px, or `'trigger'` to match the trigger element's own
|
|
22
|
+
* rendered width exactly (e.g. a dropdown under a full-width form field,
|
|
23
|
+
* where a fixed pixel width would either overflow or leave a gap
|
|
24
|
+
* depending on viewport size). Default: 288. */
|
|
25
|
+
width?: number | 'trigger';
|
|
26
|
+
/**
|
|
27
|
+
* Inline style overrides for the panel, merged after `width`. Escape hatch
|
|
28
|
+
* for callers that need to override the panel's own chrome (e.g. no
|
|
29
|
+
* rounded corners for a compact swatch picker) — inline styles always win
|
|
30
|
+
* regardless of CSS module load order, unlike passing an extra className.
|
|
31
|
+
*/
|
|
32
|
+
panelStyle?: CSSProperties;
|
|
15
33
|
'aria-label': string;
|
|
16
34
|
children: ReactNode;
|
|
17
35
|
}
|
|
18
36
|
|
|
37
|
+
// Matches the gap already applied via .panel's `calc(100% + var(--sv-space-2))`
|
|
38
|
+
// offset — used here in plain px since layout math needs a number, not a
|
|
39
|
+
// CSS custom property.
|
|
40
|
+
const TRIGGER_GAP = 8;
|
|
41
|
+
// Breathing room kept between the panel and the viewport edge it's closest
|
|
42
|
+
// to — without this, a panel that just barely fits ends up flush against
|
|
43
|
+
// the edge (e.g. touching a mobile footer nav bar with no gap at all). Kept
|
|
44
|
+
// small deliberately: this subtracts directly from the space available
|
|
45
|
+
// before the internal-scroll cap kicks in (see the layout effect below), so
|
|
46
|
+
// a larger value here trades "more edge clearance" for "more panels that
|
|
47
|
+
// would otherwise fit exactly get an unwanted scrollbar instead" — a bad
|
|
48
|
+
// trade for a purely cosmetic gap.
|
|
49
|
+
const VIEWPORT_MARGIN = 4;
|
|
50
|
+
|
|
19
51
|
/**
|
|
20
|
-
* Popover — floating panel anchored
|
|
52
|
+
* Popover — floating panel anchored to a trigger element, below it by
|
|
53
|
+
* default.
|
|
21
54
|
*
|
|
22
55
|
* Positioning is `position: absolute` inside a `position: relative` wrapper —
|
|
23
|
-
* no floating-ui dependency. Supports left/right alignment against the trigger
|
|
56
|
+
* no floating-ui dependency. Supports left/right alignment against the trigger
|
|
57
|
+
* edge, and automatically opens on whichever side (above or below the
|
|
58
|
+
* trigger) has more room within the viewport.
|
|
59
|
+
*
|
|
60
|
+
* If the panel doesn't fit on *either* side as the trigger currently sits —
|
|
61
|
+
* common for a tall panel (e.g. a full calendar month) nested partway down a
|
|
62
|
+
* long scrollable mobile sheet — the trigger's scrollable ancestor is
|
|
63
|
+
* scrolled so the trigger sits at the top of it first, maximizing the room
|
|
64
|
+
* available below, then the panel opens downward against that. This mirrors
|
|
65
|
+
* how most mobile date pickers behave (scroll the page, not the picker) and
|
|
66
|
+
* avoids the panel needing its own internal scroll in the vast majority of
|
|
67
|
+
* cases. If it's still too tall even after that (a genuinely tiny viewport,
|
|
68
|
+
* or an unusually tall panel), it's capped to whatever room actually exists
|
|
69
|
+
* and scrolls internally as a last resort, rather than rendering past the
|
|
70
|
+
* edge of the viewport with no way to reach the rest of it.
|
|
71
|
+
*
|
|
24
72
|
* Closes on outside click or Escape. Does not trap focus (it is non-modal).
|
|
25
73
|
*/
|
|
26
74
|
export function Popover({
|
|
@@ -29,10 +77,88 @@ export function Popover({
|
|
|
29
77
|
onClose,
|
|
30
78
|
align = 'right',
|
|
31
79
|
width = 288,
|
|
80
|
+
panelStyle,
|
|
32
81
|
'aria-label': ariaLabel,
|
|
33
82
|
children,
|
|
34
83
|
}: PopoverProps) {
|
|
35
84
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
85
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
86
|
+
const [triggerWidth, setTriggerWidth] = useState<number | null>(null);
|
|
87
|
+
// Whether the panel opens upward instead of downward. Defaults to false
|
|
88
|
+
// (today's only behavior) so nothing shifts before this can actually be
|
|
89
|
+
// measured.
|
|
90
|
+
const [openUpward, setOpenUpward] = useState(false);
|
|
91
|
+
// Caps the panel to whatever room actually exists on its chosen side, only
|
|
92
|
+
// when the panel is taller than that — null leaves it at its natural
|
|
93
|
+
// height, unconstrained, for the common case where it already fits.
|
|
94
|
+
const [maxHeight, setMaxHeight] = useState<number | null>(null);
|
|
95
|
+
|
|
96
|
+
// width="trigger": measure the trigger's own rendered width and keep the
|
|
97
|
+
// panel matching it, including live resizes (e.g. rotating the device, or
|
|
98
|
+
// the trigger's own width changing for any other reason).
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (width !== 'trigger') return;
|
|
101
|
+
const el = containerRef.current?.firstElementChild as HTMLElement | null;
|
|
102
|
+
if (!el) return;
|
|
103
|
+
function recompute() {
|
|
104
|
+
if (el) setTriggerWidth(el.getBoundingClientRect().width);
|
|
105
|
+
}
|
|
106
|
+
recompute();
|
|
107
|
+
const ro = new ResizeObserver(recompute);
|
|
108
|
+
ro.observe(el);
|
|
109
|
+
return () => ro.disconnect();
|
|
110
|
+
}, [width]);
|
|
111
|
+
|
|
112
|
+
// Collision detection: once open, measure the room available above and
|
|
113
|
+
// below the trigger within the viewport. If neither side has enough for
|
|
114
|
+
// the panel as things currently sit, scroll the trigger's scrollable
|
|
115
|
+
// ancestor so the trigger sits at the top of it (scrollIntoView with
|
|
116
|
+
// behavior: 'instant' applies synchronously, so re-measuring right after
|
|
117
|
+
// reflects the new position with no extra frame to wait for) — this is
|
|
118
|
+
// usually enough on its own to make the panel fit below. Whatever's left
|
|
119
|
+
// after that: open on whichever side has more room, and only if it still
|
|
120
|
+
// doesn't fit, cap the panel's height to that room (.panel's overflow-y:
|
|
121
|
+
// auto then lets it scroll internally as a last resort, rather than
|
|
122
|
+
// extending past the viewport edge with no way to reach the clipped part).
|
|
123
|
+
// Runs in a layout effect (before paint) so there's no visible flash of
|
|
124
|
+
// "opens one way, then snaps to the other" — measured and corrected within
|
|
125
|
+
// the same commit the browser paints.
|
|
126
|
+
useLayoutEffect(() => {
|
|
127
|
+
if (!open) {
|
|
128
|
+
setOpenUpward(false);
|
|
129
|
+
setMaxHeight(null);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const container = containerRef.current;
|
|
133
|
+
const panel = panelRef.current;
|
|
134
|
+
if (!container || !panel) return;
|
|
135
|
+
|
|
136
|
+
// Arrow function expression, not a hoisted declaration — TypeScript only
|
|
137
|
+
// preserves the non-null narrowing above for a closure defined after it.
|
|
138
|
+
const measure = () => {
|
|
139
|
+
const triggerRect = container.getBoundingClientRect();
|
|
140
|
+
const panelHeight = panel.getBoundingClientRect().height;
|
|
141
|
+
return {
|
|
142
|
+
panelHeight,
|
|
143
|
+
spaceBelow: window.innerHeight - triggerRect.bottom - TRIGGER_GAP - VIEWPORT_MARGIN,
|
|
144
|
+
spaceAbove: triggerRect.top - TRIGGER_GAP - VIEWPORT_MARGIN,
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
let { panelHeight, spaceBelow, spaceAbove } = measure();
|
|
149
|
+
if (panelHeight > spaceBelow && panelHeight > spaceAbove) {
|
|
150
|
+
container.scrollIntoView({ block: 'start', behavior: 'instant' });
|
|
151
|
+
({ panelHeight, spaceBelow, spaceAbove } = measure());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const upward = panelHeight > spaceBelow && spaceAbove > spaceBelow;
|
|
155
|
+
setOpenUpward(upward);
|
|
156
|
+
const available = upward ? spaceAbove : spaceBelow;
|
|
157
|
+
setMaxHeight(panelHeight > available ? Math.max(available, 0) : null);
|
|
158
|
+
// children included: content that changes the panel's height (e.g. a
|
|
159
|
+
// calendar switching months to a row with fewer/more weeks) should
|
|
160
|
+
// re-run this measurement, not just the open/close transition itself.
|
|
161
|
+
}, [open, children]);
|
|
36
162
|
|
|
37
163
|
// Outside-click dismissal
|
|
38
164
|
useEffect(() => {
|
|
@@ -61,11 +187,18 @@ export function Popover({
|
|
|
61
187
|
{trigger}
|
|
62
188
|
{open && (
|
|
63
189
|
<div
|
|
190
|
+
ref={panelRef}
|
|
64
191
|
role="dialog"
|
|
65
192
|
aria-label={ariaLabel}
|
|
66
193
|
aria-modal={false}
|
|
67
|
-
className={[styles.panel, styles[align]
|
|
68
|
-
|
|
194
|
+
className={[styles.panel, styles[align], openUpward ? styles.upward : '']
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
.join(' ')}
|
|
197
|
+
style={{
|
|
198
|
+
width: width === 'trigger' ? (triggerWidth ?? undefined) : width,
|
|
199
|
+
maxHeight: maxHeight ?? undefined,
|
|
200
|
+
...panelStyle,
|
|
201
|
+
}}
|
|
69
202
|
>
|
|
70
203
|
{children}
|
|
71
204
|
</div>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* Sheet — fills the same rect as a plugin's content area (between the shell's
|
|
2
|
+
* sticky header and footer nav — see --sv-shell-header-height /
|
|
3
|
+
* --sv-shell-footer-height, set by the shell), sliding in from an edge
|
|
4
|
+
* instead of Dialog's centered box or Drawer's partial-height sheet-over-
|
|
5
|
+
* scrim. z-index matches Dialog/Drawer's scrim so it layers consistently
|
|
6
|
+
* with any other overlay. No scrim of its own — see the component doc
|
|
7
|
+
* comment for why. */
|
|
8
|
+
|
|
9
|
+
.panel {
|
|
10
|
+
position: fixed;
|
|
11
|
+
left: 0;
|
|
12
|
+
right: 0;
|
|
13
|
+
top: var(--sv-shell-header-height, 0);
|
|
14
|
+
bottom: var(--sv-shell-footer-height, 0);
|
|
15
|
+
z-index: 100;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
background: var(--sv-color-surface);
|
|
19
|
+
outline: none;
|
|
20
|
+
transform: translateY(100%);
|
|
21
|
+
transition: transform var(--sv-motion-duration-base) var(--sv-motion-ease-out);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.panelOpen {
|
|
25
|
+
transform: translateY(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Opt-in top variant (slideFrom="top") — a short options/actions menu opened
|
|
29
|
+
* from a header button reads more naturally sliding down from that button's
|
|
30
|
+
* edge than traveling up from off-screen bottom. :not(.panelOpen) is what
|
|
31
|
+
* lets this coexist with .panelOpen above despite equal-looking single-class
|
|
32
|
+
* specificity elsewhere in this file: its (0,2,0) beats .panelOpen's (0,1,0)
|
|
33
|
+
* while closed/entering/closing, and yields to it (transform: translateY(0),
|
|
34
|
+
* same value either direction) once open. */
|
|
35
|
+
.panelFromTop:not(.panelOpen) {
|
|
36
|
+
transform: translateY(-100%);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* See Dialog.module.css's identical rule for why 0.01ms rather than 0. */
|
|
40
|
+
@media (prefers-reduced-motion: reduce) {
|
|
41
|
+
.panel {
|
|
42
|
+
transition-duration: 0.01ms;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Always the scroll container, with or without OverlayHeader present (see
|
|
47
|
+
* Sheet.tsx). No opinionated padding here — matches Drawer's convention of
|
|
48
|
+
* leaving horizontal/top padding and safe-area-bottom clearance to the
|
|
49
|
+
* content itself, since a Sheet's content shape varies far more than a
|
|
50
|
+
* fixed-size Dialog's. */
|
|
51
|
+
.content {
|
|
52
|
+
flex: 1 1 auto;
|
|
53
|
+
overflow-y: auto;
|
|
54
|
+
overscroll-behavior: contain;
|
|
55
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useEffect, useRef } from 'react';
|
|
4
|
+
import { lockBodyScroll, unlockBodyScroll } from '../../scroll-lock';
|
|
5
|
+
import { useMountTransition, usePrefersReducedMotion } from '../../motion';
|
|
6
|
+
import { OverlayHeader } from '../OverlayHeader/OverlayHeader';
|
|
7
|
+
import styles from './Sheet.module.css';
|
|
8
|
+
|
|
9
|
+
export interface SheetProps {
|
|
10
|
+
/** Whether the sheet is shown. When false, nothing renders. */
|
|
11
|
+
open: boolean;
|
|
12
|
+
/** Called on Esc, or the built-in `OverlayHeader`'s close button. There is
|
|
13
|
+
* no scrim to tap — a Sheet replaces the content area it fills, not a
|
|
14
|
+
* layer floating above it (see the component doc comment). */
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
/** Accessible name for the sheet. Defaults to `title` when omitted. */
|
|
17
|
+
'aria-label'?: string;
|
|
18
|
+
/** Renders a built-in `OverlayHeader` (title + close, optional back button
|
|
19
|
+
* and trailing action). Omit when the content supplies its own header —
|
|
20
|
+
* e.g. a form whose title doubles as an editable field. */
|
|
21
|
+
title?: string;
|
|
22
|
+
/** Forwarded to `OverlayHeader` — see its own `onBack` doc. Has no effect
|
|
23
|
+
* without `title`. */
|
|
24
|
+
onBack?: () => void;
|
|
25
|
+
/** Forwarded to `OverlayHeader`'s trailing-action slot. Has no effect
|
|
26
|
+
* without `title`. */
|
|
27
|
+
headerAction?: ReactNode;
|
|
28
|
+
/**
|
|
29
|
+
* Which edge the panel slides in from. Defaults to `'bottom'` (detail
|
|
30
|
+
* views, forms — content the user is reaching down into). `'top'` suits a
|
|
31
|
+
* short options/actions menu opened from a header button near the top of
|
|
32
|
+
* the screen, where sliding up from the bottom would travel past empty
|
|
33
|
+
* space before reaching content that visually belongs near the trigger.
|
|
34
|
+
*/
|
|
35
|
+
slideFrom?: 'bottom' | 'top';
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FOCUSABLE =
|
|
40
|
+
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
41
|
+
|
|
42
|
+
// Matches --sv-motion-duration-base (Sheet.module.css) — see Dialog.tsx's
|
|
43
|
+
// identical constant for why this stays a plain JS number.
|
|
44
|
+
const MOTION_DURATION_MS = 250;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sheet — a full-page overlay that replaces a plugin's content area between
|
|
48
|
+
* the shell's fixed header and footer (RFC 0013's mobile chrome stays visible
|
|
49
|
+
* and interactive around it), sliding in from an edge instead of centering
|
|
50
|
+
* like `Dialog`. Promoted from the tasks plugin's `MobileFullPageOverlay`
|
|
51
|
+
* (now the reference pattern for this component). Desktop has no equivalent
|
|
52
|
+
* presentation — a desktop layout shows the same content inline (columns,
|
|
53
|
+
* panes) instead of mounting a `Sheet` at all; this component is for the
|
|
54
|
+
* mobile case specifically.
|
|
55
|
+
*
|
|
56
|
+
* Unlike `Dialog`/`Drawer` there is no scrim: a Sheet visually *replaces* the
|
|
57
|
+
* region it covers rather than floating a layer above it, so there is no
|
|
58
|
+
* "outside" to tap-dismiss. Behaviour: Esc dismisses; focus moves into the
|
|
59
|
+
* panel on open and is restored on close; Tab is trapped within the panel;
|
|
60
|
+
* animated open/close via the same two-phase mount as `Dialog`/`Drawer`,
|
|
61
|
+
* respecting `prefers-reduced-motion`.
|
|
62
|
+
*/
|
|
63
|
+
export function Sheet({
|
|
64
|
+
open,
|
|
65
|
+
onClose,
|
|
66
|
+
'aria-label': ariaLabel,
|
|
67
|
+
title,
|
|
68
|
+
onBack,
|
|
69
|
+
headerAction,
|
|
70
|
+
slideFrom = 'bottom',
|
|
71
|
+
children,
|
|
72
|
+
}: SheetProps) {
|
|
73
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
74
|
+
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
75
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
76
|
+
const { mounted, phase } = useMountTransition(open, reducedMotion ? 0 : MOTION_DURATION_MS);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!mounted) return;
|
|
80
|
+
lockBodyScroll();
|
|
81
|
+
return unlockBodyScroll;
|
|
82
|
+
}, [mounted]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!mounted) return;
|
|
86
|
+
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
|
87
|
+
const panel = panelRef.current;
|
|
88
|
+
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
|
|
89
|
+
(first ?? panel)?.focus();
|
|
90
|
+
return () => previouslyFocused.current?.focus();
|
|
91
|
+
}, [mounted]);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!open) return;
|
|
95
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
96
|
+
if (e.key === 'Escape') {
|
|
97
|
+
e.stopPropagation();
|
|
98
|
+
onClose();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (e.key !== 'Tab') return;
|
|
102
|
+
const panel = panelRef.current;
|
|
103
|
+
if (!panel) return;
|
|
104
|
+
const focusable = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
105
|
+
const first = focusable[0];
|
|
106
|
+
const last = focusable.at(-1);
|
|
107
|
+
if (!first || !last) {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const active = document.activeElement;
|
|
112
|
+
if (e.shiftKey && active === first) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
last.focus();
|
|
115
|
+
} else if (!e.shiftKey && active === last) {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
first.focus();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
121
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
122
|
+
}, [open, onClose]);
|
|
123
|
+
|
|
124
|
+
if (!mounted) return null;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
ref={panelRef}
|
|
129
|
+
role="dialog"
|
|
130
|
+
aria-modal="true"
|
|
131
|
+
aria-label={ariaLabel ?? title}
|
|
132
|
+
tabIndex={-1}
|
|
133
|
+
className={[
|
|
134
|
+
styles.panel,
|
|
135
|
+
slideFrom === 'top' ? styles.panelFromTop : '',
|
|
136
|
+
phase === 'open' ? styles.panelOpen : '',
|
|
137
|
+
]
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
.join(' ')}
|
|
140
|
+
>
|
|
141
|
+
{title && (
|
|
142
|
+
<OverlayHeader title={title} onClose={onClose} onBack={onBack} action={headerAction} />
|
|
143
|
+
)}
|
|
144
|
+
{/* Content is always the scroll container — with or without a header —
|
|
145
|
+
so a header's sibling stays fixed and any content-owned internal
|
|
146
|
+
sticky positioning (e.g. a form's own sub-header) resolves against
|
|
147
|
+
this same scrolling ancestor either way. */}
|
|
148
|
+
<div className={styles.content}>{children}</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Sheet } from '../Sheet';
|
|
5
|
+
|
|
6
|
+
// Sheet's exit animation reads prefers-reduced-motion via matchMedia, which
|
|
7
|
+
// jsdom does not implement — see Dialog.test.tsx's identical setup.
|
|
8
|
+
function installMatchMedia() {
|
|
9
|
+
vi.stubGlobal(
|
|
10
|
+
'matchMedia',
|
|
11
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
12
|
+
matches: false,
|
|
13
|
+
media: query,
|
|
14
|
+
addEventListener: vi.fn(),
|
|
15
|
+
removeEventListener: vi.fn(),
|
|
16
|
+
addListener: vi.fn(),
|
|
17
|
+
removeListener: vi.fn(),
|
|
18
|
+
dispatchEvent: vi.fn(),
|
|
19
|
+
})),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Sheet', () => {
|
|
24
|
+
beforeEach(installMatchMedia);
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
cleanup();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders nothing when closed', () => {
|
|
31
|
+
render(
|
|
32
|
+
<Sheet open={false} onClose={() => {}}>
|
|
33
|
+
Body
|
|
34
|
+
</Sheet>,
|
|
35
|
+
);
|
|
36
|
+
expect(screen.queryByRole('dialog')).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders the panel with its accessible name when open', () => {
|
|
40
|
+
render(
|
|
41
|
+
<Sheet open onClose={() => {}} aria-label="Task detail">
|
|
42
|
+
Body
|
|
43
|
+
</Sheet>,
|
|
44
|
+
);
|
|
45
|
+
const panel = screen.getByRole('dialog', { name: 'Task detail' });
|
|
46
|
+
expect(panel.getAttribute('aria-modal')).toBe('true');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('falls back to title for the accessible name when aria-label is omitted', () => {
|
|
50
|
+
render(
|
|
51
|
+
<Sheet open onClose={() => {}} title="Edit list">
|
|
52
|
+
Body
|
|
53
|
+
</Sheet>,
|
|
54
|
+
);
|
|
55
|
+
expect(screen.getByRole('dialog', { name: 'Edit list' })).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('does not render OverlayHeader when title is omitted (headerless escape hatch)', () => {
|
|
59
|
+
render(
|
|
60
|
+
<Sheet open onClose={() => {}} aria-label="Custom">
|
|
61
|
+
<button type="button">Own close button</button>
|
|
62
|
+
</Sheet>,
|
|
63
|
+
);
|
|
64
|
+
// Only the content's own button — no OverlayHeader close button.
|
|
65
|
+
expect(screen.getAllByRole('button')).toHaveLength(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders OverlayHeader (title + close) when title is provided', () => {
|
|
69
|
+
const onClose = vi.fn();
|
|
70
|
+
render(
|
|
71
|
+
<Sheet open onClose={onClose} title="Edit list">
|
|
72
|
+
Body
|
|
73
|
+
</Sheet>,
|
|
74
|
+
);
|
|
75
|
+
expect(screen.getByText('Edit list')).toBeTruthy();
|
|
76
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
77
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('calls onClose on Escape', () => {
|
|
81
|
+
const onClose = vi.fn();
|
|
82
|
+
render(
|
|
83
|
+
<Sheet open onClose={onClose} aria-label="Panel">
|
|
84
|
+
Body
|
|
85
|
+
</Sheet>,
|
|
86
|
+
);
|
|
87
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
88
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('applies the top slide-from variant class', () => {
|
|
92
|
+
render(
|
|
93
|
+
<Sheet open onClose={() => {}} aria-label="Menu" slideFrom="top">
|
|
94
|
+
Body
|
|
95
|
+
</Sheet>,
|
|
96
|
+
);
|
|
97
|
+
expect(screen.getByRole('dialog').className).toMatch(/panelFromTop/);
|
|
98
|
+
});
|
|
99
|
+
});
|