@sovereignfs/ui 0.21.3 → 0.33.0
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 +109 -0
- 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 +73 -46
- package/dist/DragHandleRow.module.css +57 -0
- package/dist/Drawer.module.css +61 -0
- package/dist/FormField.module.css +5 -0
- package/dist/Icon.module.css +5 -0
- package/dist/Input.module.css +10 -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/Select.module.css +9 -0
- 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/Textarea.module.css +39 -0
- package/dist/Toast.module.css +7 -5
- package/dist/Toggle.module.css +1 -1
- package/dist/Tooltip.module.css +6 -1
- package/dist/index.d.ts +549 -20
- package/dist/index.js +1496 -193
- package/dist/tokens/primitives.css +19 -0
- package/dist/tokens/semantic.css +19 -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 +109 -0
- package/src/components/Checkbox/Checkbox.tsx +78 -0
- 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 +73 -46
- 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 +57 -0
- package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
- 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/FormField/FormField.module.css +5 -0
- package/src/components/FormField/FormField.tsx +36 -11
- package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
- 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/Input/Input.module.css +10 -0
- 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/Select/Select.module.css +9 -0
- package/src/components/Select/Select.tsx +10 -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/Textarea/Textarea.module.css +39 -0
- package/src/components/Textarea/Textarea.tsx +15 -0
- package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
- package/src/components/Toast/Toast.module.css +7 -5
- package/src/components/Toggle/Toggle.module.css +1 -1
- 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 +37 -2
- package/src/motion.ts +78 -0
- package/src/stories/Checkbox.stories.tsx +45 -0
- package/src/stories/DesignSystemOverview.stories.tsx +354 -8
- package/src/stories/DragHandleRow.stories.tsx +41 -0
- package/src/stories/FormField.stories.tsx +8 -8
- package/src/stories/InteractionHooks.stories.tsx +388 -0
- package/src/stories/MobilePatterns.stories.tsx +4 -4
- package/src/stories/Textarea.stories.tsx +51 -0
- package/src/stories/TokenGallery.stories.tsx +87 -1
- package/src/tokens/primitives.css +19 -0
- package/src/tokens/semantic.css +19 -0
- package/src/components/Icon/icons/terminal.tsx +0 -19
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Icon } from '../Icon/Icon';
|
|
3
|
+
import styles from './OverlayHeader.module.css';
|
|
4
|
+
|
|
5
|
+
export interface OverlayHeaderProps {
|
|
6
|
+
/** Overlay title, shown between the optional back button and the trailing
|
|
7
|
+
* slots. Optional — some consumers (e.g. `Dialog`'s mobile bar) need the
|
|
8
|
+
* row's close button present even when no title applies. */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** Always renders a close button; called on click. */
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
/** When provided, renders a back-chevron button before the title (for
|
|
13
|
+
* hierarchical navigation within a single overlay — going back without
|
|
14
|
+
* fully closing it). Omit for a flat overlay that only ever closes. */
|
|
15
|
+
onBack?: () => void;
|
|
16
|
+
/** Trailing slot next to the close button — e.g. a "Save" action. */
|
|
17
|
+
action?: ReactNode;
|
|
18
|
+
/** A second row below the title row — e.g. a tab strip. */
|
|
19
|
+
secondRow?: ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* OverlayHeader — the shared fixed secondary header for `Dialog`'s mobile
|
|
25
|
+
* mode, `Sheet`, and `Drawer`: title + close (+ optional back button /
|
|
26
|
+
* trailing action / tab-strip row). Not itself `position: sticky` or
|
|
27
|
+
* `position: fixed` — it stays visually pinned because the consuming overlay
|
|
28
|
+
* renders it as a non-scrolling flex sibling before its own scrollable
|
|
29
|
+
* content region, the same technique `Dialog`'s `.mobileBar` already used
|
|
30
|
+
* before this component existed to generalize it.
|
|
31
|
+
*
|
|
32
|
+
* Content that needs a fully custom header (e.g. an editable title, or
|
|
33
|
+
* controls that aren't a simple title/back/action shape) should not use this
|
|
34
|
+
* component — render its own header row instead; `Sheet`'s `title` prop is
|
|
35
|
+
* optional specifically to leave that escape hatch open.
|
|
36
|
+
*/
|
|
37
|
+
export function OverlayHeader({
|
|
38
|
+
title,
|
|
39
|
+
onClose,
|
|
40
|
+
onBack,
|
|
41
|
+
action,
|
|
42
|
+
secondRow,
|
|
43
|
+
className,
|
|
44
|
+
}: OverlayHeaderProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div className={[styles.header, className].filter(Boolean).join(' ')}>
|
|
47
|
+
<div className={styles.row}>
|
|
48
|
+
{onBack && (
|
|
49
|
+
<button type="button" className={styles.backButton} aria-label="Back" onClick={onBack}>
|
|
50
|
+
<Icon name="chevron-left" size="md" aria-hidden />
|
|
51
|
+
</button>
|
|
52
|
+
)}
|
|
53
|
+
<span className={styles.title}>{title}</span>
|
|
54
|
+
{action && <div className={styles.action}>{action}</div>}
|
|
55
|
+
<button type="button" className={styles.closeButton} aria-label="Close" onClick={onClose}>
|
|
56
|
+
<Icon name="x" size="sm" aria-hidden />
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
{secondRow && <div className={styles.secondRow}>{secondRow}</div>}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { OverlayHeader } from '../OverlayHeader';
|
|
5
|
+
|
|
6
|
+
describe('OverlayHeader', () => {
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
it('renders the title and calls onClose on the close button', () => {
|
|
10
|
+
const onClose = vi.fn();
|
|
11
|
+
render(<OverlayHeader title="Account" onClose={onClose} />);
|
|
12
|
+
expect(screen.getByText('Account')).toBeTruthy();
|
|
13
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
14
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('does not render a back button when onBack is omitted', () => {
|
|
18
|
+
render(<OverlayHeader title="Account" onClose={() => {}} />);
|
|
19
|
+
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders and wires a back button when onBack is provided', () => {
|
|
23
|
+
const onBack = vi.fn();
|
|
24
|
+
render(<OverlayHeader title="Account" onClose={() => {}} onBack={onBack} />);
|
|
25
|
+
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
|
26
|
+
expect(onBack).toHaveBeenCalledOnce();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('renders the trailing action slot', () => {
|
|
30
|
+
render(
|
|
31
|
+
<OverlayHeader
|
|
32
|
+
title="Edit list"
|
|
33
|
+
onClose={() => {}}
|
|
34
|
+
action={<button type="button">Save</button>}
|
|
35
|
+
/>,
|
|
36
|
+
);
|
|
37
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders just the close button when title is omitted', () => {
|
|
41
|
+
const onClose = vi.fn();
|
|
42
|
+
render(<OverlayHeader onClose={onClose} />);
|
|
43
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
|
44
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders the second row', () => {
|
|
48
|
+
render(<OverlayHeader title="Account" onClose={() => {}} secondRow={<nav>Tab strip</nav>} />);
|
|
49
|
+
expect(screen.getByText('Tab strip')).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
border: 1px solid var(--sv-color-border);
|
|
12
12
|
border-radius: var(--sv-radius-2xl);
|
|
13
13
|
box-shadow: var(--sv-shadow-popover);
|
|
14
|
-
|
|
14
|
+
/* auto, not hidden: Popover.tsx applies an inline max-height only when the
|
|
15
|
+
panel is taller than the room actually available on whichever side it
|
|
16
|
+
opens (e.g. a tall calendar on a short mobile viewport, where neither
|
|
17
|
+
above nor below has enough space for the whole thing) — auto lets that
|
|
18
|
+
capped case scroll internally instead of silently clipping content with
|
|
19
|
+
no way to reach it. When no max-height is applied (the common case, panel
|
|
20
|
+
already fits), auto behaves identically to hidden — nothing to scroll,
|
|
21
|
+
content still clips at the rounded corners exactly as before. */
|
|
22
|
+
overflow-x: hidden;
|
|
23
|
+
overflow-y: auto;
|
|
15
24
|
/* Slide in from slightly above */
|
|
16
25
|
animation: sv-popover-in 0.12s ease;
|
|
17
26
|
}
|
|
@@ -42,3 +51,24 @@
|
|
|
42
51
|
.left {
|
|
43
52
|
left: 0;
|
|
44
53
|
}
|
|
54
|
+
|
|
55
|
+
/* Collision flip: opens above the trigger instead of below, when the panel
|
|
56
|
+
wouldn't fit within the viewport underneath it (see Popover.tsx's
|
|
57
|
+
layout-effect measurement). Same slide-in treatment, mirrored to slide up
|
|
58
|
+
from slightly below instead of down from slightly above. */
|
|
59
|
+
.upward {
|
|
60
|
+
top: auto;
|
|
61
|
+
bottom: calc(100% + var(--sv-space-2));
|
|
62
|
+
animation-name: sv-popover-in-upward;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@keyframes sv-popover-in-upward {
|
|
66
|
+
from {
|
|
67
|
+
opacity: 0;
|
|
68
|
+
transform: translateY(4px);
|
|
69
|
+
}
|
|
70
|
+
to {
|
|
71
|
+
opacity: 1;
|
|
72
|
+
transform: translateY(0);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -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>
|
|
@@ -33,6 +33,15 @@
|
|
|
33
33
|
border-radius: var(--sv-radius-sm);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/* iOS Safari zooms the page when a <select> under 16px receives focus, exactly
|
|
37
|
+
as it does for text inputs. Lift the default size to >=16px on touch devices;
|
|
38
|
+
the compact `.sm` variant is for dense desktop tables and is left untouched. */
|
|
39
|
+
@media (pointer: coarse) {
|
|
40
|
+
.select {
|
|
41
|
+
font-size: var(--sv-font-size-md);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
.select:focus-visible {
|
|
37
46
|
outline: none;
|
|
38
47
|
border-color: var(--sv-color-accent);
|
|
@@ -12,13 +12,21 @@ export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'>
|
|
|
12
12
|
* Uses the native picker on mobile — no custom dropdown, maximum a11y coverage.
|
|
13
13
|
*
|
|
14
14
|
* RSC-safe: presentational, no hooks, forwards all native select props.
|
|
15
|
+
*
|
|
16
|
+
* `className` sizes the outer box (the wrapper), not the `<select>` itself —
|
|
17
|
+
* the chevron is absolutely positioned against the wrapper, so a className
|
|
18
|
+
* that constrains only the `<select>` (e.g. `max-width`) shrinks the select
|
|
19
|
+
* while the wrapper (and thus the chevron) stays full width, leaving the
|
|
20
|
+
* chevron stranded past the visible box.
|
|
15
21
|
*/
|
|
16
22
|
export function Select({ className, size = 'md', children, ...rest }: SelectProps) {
|
|
17
23
|
return (
|
|
18
24
|
<div
|
|
19
|
-
className={[styles.wrapper, size === 'sm' ? styles.sm : undefined]
|
|
25
|
+
className={[styles.wrapper, size === 'sm' ? styles.sm : undefined, className]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join(' ')}
|
|
20
28
|
>
|
|
21
|
-
<select className={
|
|
29
|
+
<select className={styles.select} {...rest}>
|
|
22
30
|
{children}
|
|
23
31
|
</select>
|
|
24
32
|
<svg
|
|
@@ -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
|
+
}
|