@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.
Files changed (130) hide show
  1. package/dist/Button.module.css +75 -8
  2. package/dist/Calendar.module.css +126 -0
  3. package/dist/Card.module.css +4 -2
  4. package/dist/Checkbox.module.css +109 -0
  5. package/dist/CodeTextarea.module.css +53 -0
  6. package/dist/ConfirmDialog.module.css +109 -0
  7. package/dist/DatePicker.module.css +57 -0
  8. package/dist/Dialog.module.css +73 -46
  9. package/dist/DragHandleRow.module.css +57 -0
  10. package/dist/Drawer.module.css +61 -0
  11. package/dist/FormField.module.css +5 -0
  12. package/dist/Icon.module.css +5 -0
  13. package/dist/Input.module.css +10 -0
  14. package/dist/Menu.module.css +82 -0
  15. package/dist/NavTabs.module.css +4 -2
  16. package/dist/OverlayHeader.module.css +98 -0
  17. package/dist/Popover.module.css +31 -1
  18. package/dist/SegmentedControl.module.css +4 -2
  19. package/dist/Select.module.css +9 -0
  20. package/dist/Sheet.module.css +55 -0
  21. package/dist/SplitPane.module.css +85 -0
  22. package/dist/StatusBadge.module.css +59 -0
  23. package/dist/SystemBanner.module.css +4 -2
  24. package/dist/Tabs.module.css +4 -2
  25. package/dist/TagInput.module.css +141 -0
  26. package/dist/Textarea.module.css +39 -0
  27. package/dist/Toast.module.css +7 -5
  28. package/dist/Toggle.module.css +1 -1
  29. package/dist/Tooltip.module.css +6 -1
  30. package/dist/index.d.ts +549 -20
  31. package/dist/index.js +1496 -193
  32. package/dist/tokens/primitives.css +19 -0
  33. package/dist/tokens/semantic.css +19 -0
  34. package/package.json +1 -1
  35. package/src/__tests__/motion.test.tsx +105 -0
  36. package/src/components/Button/Button.module.css +75 -8
  37. package/src/components/Button/Button.stories.tsx +6 -0
  38. package/src/components/Button/Button.tsx +17 -1
  39. package/src/components/Button/__tests__/Button.test.tsx +19 -0
  40. package/src/components/Calendar/Calendar.module.css +126 -0
  41. package/src/components/Calendar/Calendar.tsx +213 -0
  42. package/src/components/Calendar/__tests__/Calendar.test.tsx +98 -0
  43. package/src/components/Calendar/dateUtils.ts +97 -0
  44. package/src/components/Card/Card.module.css +4 -2
  45. package/src/components/Checkbox/Checkbox.module.css +109 -0
  46. package/src/components/Checkbox/Checkbox.tsx +78 -0
  47. package/src/components/CodeTextarea/CodeTextarea.module.css +53 -0
  48. package/src/components/CodeTextarea/CodeTextarea.stories.tsx +77 -0
  49. package/src/components/CodeTextarea/CodeTextarea.tsx +36 -0
  50. package/src/components/ConfirmDialog/ConfirmDialog.module.css +109 -0
  51. package/src/components/ConfirmDialog/ConfirmDialog.tsx +124 -0
  52. package/src/components/ConfirmDialog/__tests__/ConfirmDialog.test.tsx +146 -0
  53. package/src/components/DatePicker/DatePicker.module.css +57 -0
  54. package/src/components/DatePicker/DatePicker.tsx +103 -0
  55. package/src/components/DatePicker/__tests__/DatePicker.test.tsx +89 -0
  56. package/src/components/Dialog/Dialog.module.css +73 -46
  57. package/src/components/Dialog/Dialog.stories.tsx +51 -1
  58. package/src/components/Dialog/Dialog.tsx +86 -22
  59. package/src/components/Dialog/__tests__/Dialog.test.tsx +67 -3
  60. package/src/components/DragHandleRow/DragHandleRow.module.css +57 -0
  61. package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
  62. package/src/components/Drawer/Drawer.module.css +61 -0
  63. package/src/components/Drawer/Drawer.tsx +117 -13
  64. package/src/components/Drawer/__tests__/Drawer.test.tsx +123 -2
  65. package/src/components/FormField/FormField.module.css +5 -0
  66. package/src/components/FormField/FormField.tsx +36 -11
  67. package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
  68. package/src/components/Icon/Icon.module.css +5 -0
  69. package/src/components/Icon/Icon.stories.tsx +4 -3
  70. package/src/components/Icon/Icon.tsx +1 -1
  71. package/src/components/Icon/icons/calendar.tsx +23 -0
  72. package/src/components/Icon/icons/ellipsis-vertical.tsx +22 -0
  73. package/src/components/Icon/icons/index.ts +8 -4
  74. package/src/components/Icon/icons/sliders-horizontal.tsx +9 -9
  75. package/src/components/Input/Input.module.css +10 -0
  76. package/src/components/Menu/Menu.module.css +82 -0
  77. package/src/components/Menu/Menu.tsx +137 -0
  78. package/src/components/Menu/__tests__/Menu.test.tsx +197 -0
  79. package/src/components/NavTabs/NavTabs.module.css +4 -2
  80. package/src/components/OverlayHeader/OverlayHeader.module.css +98 -0
  81. package/src/components/OverlayHeader/OverlayHeader.tsx +62 -0
  82. package/src/components/OverlayHeader/__tests__/OverlayHeader.test.tsx +51 -0
  83. package/src/components/Popover/Popover.module.css +31 -1
  84. package/src/components/Popover/Popover.stories.tsx +126 -1
  85. package/src/components/Popover/Popover.tsx +140 -7
  86. package/src/components/SegmentedControl/SegmentedControl.module.css +4 -2
  87. package/src/components/Select/Select.module.css +9 -0
  88. package/src/components/Select/Select.tsx +10 -2
  89. package/src/components/Sheet/Sheet.module.css +55 -0
  90. package/src/components/Sheet/Sheet.tsx +151 -0
  91. package/src/components/Sheet/__tests__/Sheet.test.tsx +99 -0
  92. package/src/components/SplitPane/SplitPane.module.css +85 -0
  93. package/src/components/SplitPane/SplitPane.stories.tsx +160 -0
  94. package/src/components/SplitPane/SplitPane.tsx +149 -0
  95. package/src/components/SplitPane/__tests__/SplitPane.test.tsx +51 -0
  96. package/src/components/StatusBadge/StatusBadge.module.css +59 -0
  97. package/src/components/StatusBadge/StatusBadge.stories.tsx +74 -0
  98. package/src/components/StatusBadge/StatusBadge.tsx +65 -0
  99. package/src/components/SystemBanner/SystemBanner.module.css +4 -2
  100. package/src/components/Tabs/Tabs.module.css +4 -2
  101. package/src/components/TagInput/TagInput.module.css +141 -0
  102. package/src/components/TagInput/TagInput.stories.tsx +109 -0
  103. package/src/components/TagInput/TagInput.tsx +174 -0
  104. package/src/components/TagInput/__tests__/TagInput.test.tsx +69 -0
  105. package/src/components/Textarea/Textarea.module.css +39 -0
  106. package/src/components/Textarea/Textarea.tsx +15 -0
  107. package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
  108. package/src/components/Toast/Toast.module.css +7 -5
  109. package/src/components/Toggle/Toggle.module.css +1 -1
  110. package/src/components/Tooltip/Tooltip.module.css +6 -1
  111. package/src/hooks/__tests__/useDoubleTap.test.tsx +117 -0
  112. package/src/hooks/__tests__/useIsMobile.test.tsx +80 -0
  113. package/src/hooks/__tests__/useLongPress.test.tsx +158 -0
  114. package/src/hooks/index.ts +4 -0
  115. package/src/hooks/useDoubleTap.ts +91 -0
  116. package/src/hooks/useIsMobile.ts +33 -0
  117. package/src/hooks/useLongPress.ts +185 -0
  118. package/src/index.ts +37 -2
  119. package/src/motion.ts +78 -0
  120. package/src/stories/Checkbox.stories.tsx +45 -0
  121. package/src/stories/DesignSystemOverview.stories.tsx +354 -8
  122. package/src/stories/DragHandleRow.stories.tsx +41 -0
  123. package/src/stories/FormField.stories.tsx +8 -8
  124. package/src/stories/InteractionHooks.stories.tsx +388 -0
  125. package/src/stories/MobilePatterns.stories.tsx +4 -4
  126. package/src/stories/Textarea.stories.tsx +51 -0
  127. package/src/stories/TokenGallery.stories.tsx +87 -1
  128. package/src/tokens/primitives.css +19 -0
  129. package/src/tokens/semantic.css +19 -0
  130. 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
- overflow: hidden;
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 below a trigger. Non-modal: closes on outside click or Escape. `align` controls which trigger edge the panel aligns to.',
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 { useEffect, useRef, type ReactElement, type ReactNode } from 'react';
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. Default: 288. */
14
- width?: number;
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 below a trigger element.
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 edge.
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]].join(' ')}
68
- style={{ width }}
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>
@@ -48,6 +48,8 @@
48
48
  color: var(--sv-color-text-muted);
49
49
  }
50
50
 
51
- .inactive:hover {
52
- color: var(--sv-color-text-primary);
51
+ @media (hover: hover) {
52
+ .inactive:hover {
53
+ color: var(--sv-color-text-primary);
54
+ }
53
55
  }
@@ -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].filter(Boolean).join(' ')}
25
+ className={[styles.wrapper, size === 'sm' ? styles.sm : undefined, className]
26
+ .filter(Boolean)
27
+ .join(' ')}
20
28
  >
21
- <select className={[styles.select, className].filter(Boolean).join(' ')} {...rest}>
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
+ }