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