@milesroxas/design-hub 0.0.0-stage → 0.1.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 (217) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/client.d.ts +6 -0
  4. package/configs/biome/biome.json +64 -0
  5. package/configs/tsconfig.project.json +17 -0
  6. package/core/bytes.test.ts +14 -0
  7. package/core/bytes.ts +14 -0
  8. package/core/catalog.test.ts +44 -0
  9. package/core/catalog.ts +43 -0
  10. package/core/checks.test.ts +23 -0
  11. package/core/checks.ts +72 -0
  12. package/core/contacts.test.ts +31 -0
  13. package/core/contacts.ts +38 -0
  14. package/core/doc.ts +4 -0
  15. package/core/feature.ts +6 -0
  16. package/core/fixtures.test.ts +33 -0
  17. package/core/fixtures.ts +69 -0
  18. package/core/frontmatter.ts +15 -0
  19. package/core/html.ts +3 -0
  20. package/core/image.test.ts +22 -0
  21. package/core/image.ts +37 -0
  22. package/core/jev.test.ts +66 -0
  23. package/core/jev.ts +77 -0
  24. package/core/review/model.ts +189 -0
  25. package/core/review/policy.ts +26 -0
  26. package/core/review/service.test.ts +163 -0
  27. package/core/review/service.ts +309 -0
  28. package/core/review/store.ts +51 -0
  29. package/core/review/triage.test.ts +41 -0
  30. package/core/review/triage.ts +58 -0
  31. package/core/states.test.ts +36 -0
  32. package/core/states.ts +34 -0
  33. package/dist/core/bytes.d.ts +5 -0
  34. package/dist/core/bytes.js +9 -0
  35. package/dist/core/catalog.d.ts +17 -0
  36. package/dist/core/catalog.js +24 -0
  37. package/dist/core/checks.d.ts +41 -0
  38. package/dist/core/checks.js +53 -0
  39. package/dist/core/contacts.d.ts +9 -0
  40. package/dist/core/contacts.js +34 -0
  41. package/dist/core/doc.d.ts +11 -0
  42. package/dist/core/doc.js +1 -0
  43. package/dist/core/feature.d.ts +3 -0
  44. package/dist/core/feature.js +4 -0
  45. package/dist/core/fixtures.d.ts +19 -0
  46. package/dist/core/fixtures.js +52 -0
  47. package/dist/core/frontmatter.d.ts +4 -0
  48. package/dist/core/frontmatter.js +15 -0
  49. package/dist/core/html.d.ts +2 -0
  50. package/dist/core/html.js +2 -0
  51. package/dist/core/image.d.ts +6 -0
  52. package/dist/core/image.js +34 -0
  53. package/dist/core/jev.d.ts +54 -0
  54. package/dist/core/jev.js +52 -0
  55. package/dist/core/review/model.d.ts +85 -0
  56. package/dist/core/review/model.js +112 -0
  57. package/dist/core/review/policy.d.ts +3 -0
  58. package/dist/core/review/policy.js +19 -0
  59. package/dist/core/review/service.d.ts +79 -0
  60. package/dist/core/review/service.js +245 -0
  61. package/dist/core/review/store.d.ts +20 -0
  62. package/dist/core/review/store.js +37 -0
  63. package/dist/core/review/triage.d.ts +6 -0
  64. package/dist/core/review/triage.js +45 -0
  65. package/dist/core/states.d.ts +6 -0
  66. package/dist/core/states.js +29 -0
  67. package/dist/scripts/bootstrap.d.ts +51 -0
  68. package/dist/scripts/bootstrap.js +278 -0
  69. package/dist/scripts/calibrate.d.ts +127 -0
  70. package/dist/scripts/calibrate.js +164 -0
  71. package/dist/scripts/check.d.ts +25 -0
  72. package/dist/scripts/check.js +30 -0
  73. package/dist/scripts/cli.d.ts +2 -0
  74. package/dist/scripts/cli.js +73 -0
  75. package/dist/scripts/commands.d.ts +25 -0
  76. package/dist/scripts/commands.js +49 -0
  77. package/dist/scripts/comments.d.ts +63 -0
  78. package/dist/scripts/comments.js +152 -0
  79. package/dist/scripts/dev.d.ts +46 -0
  80. package/dist/scripts/dev.js +84 -0
  81. package/dist/scripts/elyx.d.ts +61 -0
  82. package/dist/scripts/elyx.js +195 -0
  83. package/dist/scripts/fixtures-pull.d.ts +60 -0
  84. package/dist/scripts/fixtures-pull.js +121 -0
  85. package/dist/scripts/guard.d.ts +58 -0
  86. package/dist/scripts/guard.js +201 -0
  87. package/dist/scripts/hook.d.ts +18 -0
  88. package/dist/scripts/hook.js +46 -0
  89. package/dist/scripts/lib/bin.d.ts +4 -0
  90. package/dist/scripts/lib/bin.js +20 -0
  91. package/dist/scripts/lib/catalog.d.ts +7 -0
  92. package/dist/scripts/lib/catalog.js +18 -0
  93. package/dist/scripts/lib/cli.d.ts +75 -0
  94. package/dist/scripts/lib/cli.js +88 -0
  95. package/dist/scripts/lib/css-tokens.d.ts +13 -0
  96. package/dist/scripts/lib/css-tokens.js +67 -0
  97. package/dist/scripts/lib/judge.d.ts +24 -0
  98. package/dist/scripts/lib/judge.js +60 -0
  99. package/dist/scripts/lib/ports.d.ts +35 -0
  100. package/dist/scripts/lib/ports.js +113 -0
  101. package/dist/scripts/lib/review.d.ts +7 -0
  102. package/dist/scripts/lib/review.js +22 -0
  103. package/dist/scripts/lib/rules.d.ts +12 -0
  104. package/dist/scripts/lib/rules.js +20 -0
  105. package/dist/scripts/lib/vercel.d.ts +17 -0
  106. package/dist/scripts/lib/vercel.js +53 -0
  107. package/dist/scripts/new.d.ts +47 -0
  108. package/dist/scripts/new.js +113 -0
  109. package/dist/scripts/people.d.ts +59 -0
  110. package/dist/scripts/people.js +73 -0
  111. package/dist/scripts/preflight.d.ts +35 -0
  112. package/dist/scripts/preflight.js +251 -0
  113. package/dist/scripts/release-check.d.ts +19 -0
  114. package/dist/scripts/release-check.js +39 -0
  115. package/dist/scripts/skill-hint.d.ts +32 -0
  116. package/dist/scripts/skill-hint.js +103 -0
  117. package/dist/scripts/skills.d.ts +13 -0
  118. package/dist/scripts/skills.js +34 -0
  119. package/dist/scripts/visual-match.d.ts +46 -0
  120. package/dist/scripts/visual-match.js +93 -0
  121. package/dist/server/api.d.ts +22 -0
  122. package/dist/server/api.js +143 -0
  123. package/dist/server/checks.d.ts +4 -0
  124. package/dist/server/checks.js +22 -0
  125. package/dist/server/dev.d.ts +2 -0
  126. package/dist/server/dev.js +54 -0
  127. package/dist/server/env.d.ts +8 -0
  128. package/dist/server/env.js +57 -0
  129. package/dist/server/fake-upstash.d.ts +1 -0
  130. package/dist/server/fake-upstash.js +37 -0
  131. package/dist/server/file-kv.d.ts +2 -0
  132. package/dist/server/file-kv.js +24 -0
  133. package/dist/server/local.d.ts +4 -0
  134. package/dist/server/local.js +25 -0
  135. package/dist/server/paths.d.ts +22 -0
  136. package/dist/server/paths.js +81 -0
  137. package/dist/server/session.d.ts +22 -0
  138. package/dist/server/session.js +110 -0
  139. package/dist/server/upstash.d.ts +7 -0
  140. package/dist/server/upstash.js +41 -0
  141. package/dist/vercel/index.d.ts +5 -0
  142. package/dist/vercel/index.js +12 -0
  143. package/dist/vercel/proxy.d.ts +11 -0
  144. package/dist/vercel/proxy.js +238 -0
  145. package/dist/vite/kit.d.ts +4 -0
  146. package/dist/vite/kit.js +35 -0
  147. package/dist/vite/markdown.d.ts +2 -0
  148. package/dist/vite/markdown.js +212 -0
  149. package/dist/vite/plugin.d.ts +15 -0
  150. package/dist/vite/plugin.js +84 -0
  151. package/dist/vite/registry.d.ts +7 -0
  152. package/dist/vite/registry.js +70 -0
  153. package/kit/Icon.tsx +39 -0
  154. package/kit/Space.tsx +25 -0
  155. package/kit/Variants.tsx +134 -0
  156. package/kit/ViewTools.tsx +34 -0
  157. package/kit/base.css +27 -0
  158. package/kit/dials.css +98 -0
  159. package/kit/dials.tsx +322 -0
  160. package/kit/fixtures.ts +3 -0
  161. package/kit/format.ts +13 -0
  162. package/kit/harness.css +99 -0
  163. package/kit/hooks.ts +35 -0
  164. package/kit/icons.ts +329 -0
  165. package/kit/motion.ts +18 -0
  166. package/kit/prefs.ts +111 -0
  167. package/kit/space.css +12 -0
  168. package/kit/ui/Button.tsx +135 -0
  169. package/kit/ui/Menu.tsx +386 -0
  170. package/kit/ui/Overlay.tsx +300 -0
  171. package/kit/ui/Toast.tsx +187 -0
  172. package/kit/ui/index.ts +13 -0
  173. package/kit/ui/layer.ts +51 -0
  174. package/kit/ui/ui.css +238 -0
  175. package/package.json +87 -3
  176. package/shell/Guide.tsx +160 -0
  177. package/shell/Index.tsx +301 -0
  178. package/shell/Notice.tsx +27 -0
  179. package/shell/Prototype.tsx +31 -0
  180. package/shell/a11y.ts +50 -0
  181. package/shell/doc.d.ts +7 -0
  182. package/shell/doc.ts +3 -0
  183. package/shell/guide.css +133 -0
  184. package/shell/index.html +17 -0
  185. package/shell/main.tsx +53 -0
  186. package/shell/public/favicon.svg +1 -0
  187. package/shell/registry.ts +21 -0
  188. package/shell/registry.virtual.d.ts +10 -0
  189. package/shell/review/Review.tsx +989 -0
  190. package/shell/review/anchor.ts +132 -0
  191. package/shell/review/client.ts +165 -0
  192. package/shell/review/mount.tsx +12 -0
  193. package/shell/review/review.css +296 -0
  194. package/shell/shell.css +160 -0
  195. package/shell/system.css +238 -0
  196. package/skills/miles-conductor/SKILL.md +37 -0
  197. package/skills/miles-deploy/SKILL.md +67 -0
  198. package/skills/miles-design-hub/SKILL.md +62 -0
  199. package/skills/miles-get-hostile/SKILL.md +61 -0
  200. package/skills/miles-new-feature/SKILL.md +40 -0
  201. package/skills/miles-paper-to-prototype/SKILL.md +46 -0
  202. package/skills/miles-port/SKILL.md +59 -0
  203. package/skills/miles-port/references/astro.md +35 -0
  204. package/skills/miles-port/references/next.md +36 -0
  205. package/skills/miles-prototype/SKILL.md +83 -0
  206. package/skills/miles-prototype/references/accessibility.md +48 -0
  207. package/skills/miles-prototype/references/motion.md +59 -0
  208. package/skills/miles-prototype/references/performance.md +45 -0
  209. package/skills/miles-review/SKILL.md +39 -0
  210. package/templates/app/README.md +41 -0
  211. package/templates/app/index.tsx +32 -0
  212. package/templates/app/styles.css +1 -0
  213. package/templates/conductor/settings.toml +29 -0
  214. package/templates/guide.md +38 -0
  215. package/templates/space/README.md +37 -0
  216. package/templates/space/index.tsx +43 -0
  217. package/templates/space/styles.css +13 -0
@@ -0,0 +1,386 @@
1
+ import {
2
+ type KeyboardEvent,
3
+ type RefObject,
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { createPortal } from 'react-dom';
12
+ import { Icon, type IconName } from '../Icon';
13
+ import { afterTransition } from './layer';
14
+ import './ui.css';
15
+
16
+ export type MenuItem = {
17
+ label: string;
18
+ value?: string;
19
+ desc?: string;
20
+ icon?: IconName;
21
+ keys?: (string | { icon: IconName; label: string })[];
22
+ danger?: boolean;
23
+ disabled?: boolean;
24
+ onClick?: () => void;
25
+ };
26
+
27
+ export type MenuEntry = MenuItem | '-';
28
+
29
+ export type MenuConfig = {
30
+ items: MenuEntry[];
31
+ align?: 'start' | 'end';
32
+ width?: number | ((trigger: HTMLElement) => number);
33
+ header?: string;
34
+ label?: string;
35
+ selected?: string;
36
+ checkable?: boolean;
37
+ role?: 'menu' | 'listbox';
38
+ onSelect?: (value: string | undefined, item: MenuItem) => void;
39
+ };
40
+
41
+ type Phase = 'closed' | 'open' | 'closing';
42
+
43
+ let closeOpen: (() => void) | null = null;
44
+
45
+ // Spread triggerProps on the trigger; render `menu` anywhere (it portals).
46
+ export function useMenu(config: MenuConfig) {
47
+ const triggerRef = useRef<HTMLElement | null>(null);
48
+ const popupRef = useRef<HTMLDivElement | null>(null);
49
+ const [phase, setPhase] = useState<Phase>('closed');
50
+
51
+ const quietClose = useCallback(() => setPhase((p) => (p === 'open' ? 'closing' : p)), []);
52
+
53
+ const close = useCallback(
54
+ (focusTrigger = false) => {
55
+ if (closeOpen === quietClose) closeOpen = null;
56
+ quietClose();
57
+ if (focusTrigger) triggerRef.current?.focus();
58
+ },
59
+ [quietClose],
60
+ );
61
+
62
+ const open = useCallback(() => {
63
+ if (closeOpen && closeOpen !== quietClose) closeOpen();
64
+ closeOpen = quietClose;
65
+ setPhase('open');
66
+ }, [quietClose]);
67
+
68
+ useEffect(
69
+ () => () => {
70
+ if (closeOpen === quietClose) closeOpen = null;
71
+ },
72
+ [quietClose],
73
+ );
74
+
75
+ useEffect(() => {
76
+ if (phase !== 'open') return;
77
+ const onPointer = (e: PointerEvent) => {
78
+ const t = e.target as Node;
79
+ if (popupRef.current?.contains(t) || triggerRef.current?.contains(t)) return;
80
+ close();
81
+ };
82
+ const onKey = (e: globalThis.KeyboardEvent) => {
83
+ if (e.key !== 'Escape') return;
84
+ e.preventDefault();
85
+ close(true);
86
+ };
87
+ const onAway = () => close();
88
+ document.addEventListener('pointerdown', onPointer);
89
+ document.addEventListener('keydown', onKey);
90
+ addEventListener('resize', onAway);
91
+ addEventListener('hashchange', onAway);
92
+ return () => {
93
+ document.removeEventListener('pointerdown', onPointer);
94
+ document.removeEventListener('keydown', onKey);
95
+ removeEventListener('resize', onAway);
96
+ removeEventListener('hashchange', onAway);
97
+ };
98
+ }, [phase, close]);
99
+
100
+ const onExited = useCallback(() => setPhase((p) => (p === 'closing' ? 'closed' : p)), []);
101
+ const isOpen = phase === 'open';
102
+
103
+ const triggerProps = {
104
+ ref: (el: HTMLElement | null) => {
105
+ triggerRef.current = el;
106
+ },
107
+ onClick: () => (isOpen ? close() : open()),
108
+ 'aria-haspopup': config.role === 'listbox' ? ('listbox' as const) : ('menu' as const),
109
+ 'aria-expanded': isOpen,
110
+ };
111
+
112
+ const menu =
113
+ phase === 'closed'
114
+ ? null
115
+ : createPortal(
116
+ <MenuPopup
117
+ config={config}
118
+ phase={phase}
119
+ triggerRef={triggerRef}
120
+ popupRef={popupRef}
121
+ close={close}
122
+ onExited={onExited}
123
+ />,
124
+ document.body,
125
+ );
126
+
127
+ return { triggerProps, menu, isOpen, open, close };
128
+ }
129
+
130
+ function MenuPopup({
131
+ config,
132
+ phase,
133
+ triggerRef,
134
+ popupRef,
135
+ close,
136
+ onExited,
137
+ }: {
138
+ config: MenuConfig;
139
+ phase: Phase;
140
+ triggerRef: RefObject<HTMLElement | null>;
141
+ popupRef: RefObject<HTMLDivElement | null>;
142
+ close: (focusTrigger?: boolean) => void;
143
+ onExited: () => void;
144
+ }) {
145
+ const { items, align = 'end', header, selected, checkable, role = 'menu', onSelect } = config;
146
+ const [shown, setShown] = useState(false);
147
+ const typed = useRef({ text: '', at: 0 });
148
+
149
+ const enabled = () => [
150
+ ...(popupRef.current?.querySelectorAll<HTMLButtonElement>('.menu-item:not([disabled])') ?? []),
151
+ ];
152
+
153
+ // biome-ignore lint/correctness/useExhaustiveDependencies: placed once, on open
154
+ useLayoutEffect(() => {
155
+ const menu = popupRef.current;
156
+ const trigger = triggerRef.current;
157
+ if (!menu || !trigger) return;
158
+ place(menu, trigger, typeof config.width === 'function' ? config.width(trigger) : config.width, align);
159
+ const frame = requestAnimationFrame(() => setShown(true));
160
+ const focus = setTimeout(() => {
161
+ const picked = selected != null ? menu.querySelector<HTMLElement>('.menu-item.is-selected') : null;
162
+ (picked ?? enabled()[0])?.focus();
163
+ }, 20);
164
+ return () => {
165
+ cancelAnimationFrame(frame);
166
+ clearTimeout(focus);
167
+ };
168
+ }, []);
169
+
170
+ useEffect(() => {
171
+ if (phase === 'closing') afterTransition(popupRef.current, 120).then(onExited);
172
+ }, [phase, onExited, popupRef]);
173
+
174
+ // Type to jump: letters typed within half a second build one query, matched from the item after focus.
175
+ const typeahead = (e: KeyboardEvent<HTMLDivElement>, list: HTMLButtonElement[], i: number) => {
176
+ const now = Date.now();
177
+ const text = (now - typed.current.at < 500 ? typed.current.text : '') + e.key.toLowerCase();
178
+ typed.current = { text, at: now };
179
+ const hit = [...list.slice(i + 1), ...list.slice(0, i + 1)].find((b) =>
180
+ b.dataset.label?.toLowerCase().startsWith(text),
181
+ );
182
+ if (!hit) return;
183
+ e.preventDefault();
184
+ hit.focus();
185
+ };
186
+
187
+ const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
188
+ const list = enabled();
189
+ const i = list.indexOf(document.activeElement as HTMLButtonElement);
190
+ const step = STEP[e.key];
191
+ if (step) {
192
+ e.preventDefault();
193
+ list[(step(i, list.length - 1) + list.length) % list.length]?.focus();
194
+ } else if (e.key === 'Tab') close(true);
195
+ else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) typeahead(e, list, i);
196
+ };
197
+
198
+ const pick = (item: MenuItem) => {
199
+ if (item.disabled) return;
200
+ close(true);
201
+ item.onClick?.();
202
+ onSelect?.(item.value, item);
203
+ };
204
+
205
+ const cls = ['menu', shown && phase === 'open' ? 'is-open' : '', phase === 'closing' ? 'is-closing' : '']
206
+ .filter(Boolean)
207
+ .join(' ');
208
+
209
+ return (
210
+ // biome-ignore lint/a11y/noStaticElementInteractions: role is menu or listbox, both interactive
211
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: menu and listbox both take aria-label
212
+ <div
213
+ ref={popupRef}
214
+ className={cls}
215
+ role={role}
216
+ tabIndex={-1}
217
+ aria-label={config.label ?? header}
218
+ onKeyDown={onKeyDown}
219
+ >
220
+ {header ? (
221
+ <div className="menu-header" aria-hidden="true">
222
+ {header}
223
+ </div>
224
+ ) : null}
225
+ {items.map((item, n) => {
226
+ // biome-ignore lint/a11y/useSemanticElements: an <hr> brings borders the menu rule does not want
227
+ // biome-ignore lint/suspicious/noArrayIndexKey: separators have no identity; the list is fixed while open
228
+ if (item === '-') return <div key={`sep-${n}`} className="menu-sep" role="separator" />;
229
+ return (
230
+ <MenuRow
231
+ key={`${item.label}-${item.value ?? n}`}
232
+ item={item}
233
+ role={role === 'listbox' ? 'option' : checkable ? 'menuitemradio' : 'menuitem'}
234
+ selected={selected != null && selected === item.value}
235
+ onPick={pick}
236
+ />
237
+ );
238
+ })}
239
+ </div>
240
+ );
241
+ }
242
+
243
+ const STEP: Record<string, (i: number, last: number) => number> = {
244
+ ArrowDown: (i) => i + 1,
245
+ ArrowUp: (i) => i - 1,
246
+ Home: () => 0,
247
+ End: (_, last) => last,
248
+ };
249
+
250
+ /**
251
+ * Opens below the trigger when it fits, above when it does not, aligned to
252
+ * the trigger's end or start and kept 8px inside the viewport. The origin
253
+ * sits on the trigger, so the menu grows out of it.
254
+ */
255
+ function place(menu: HTMLElement, trigger: HTMLElement, width: number | undefined, align: 'start' | 'end') {
256
+ const r = trigger.getBoundingClientRect();
257
+ if (width) menu.style.width = `${width}px`;
258
+ const mw = width || menu.offsetWidth;
259
+ const mh = menu.offsetHeight;
260
+ const below = r.bottom + 4 + mh <= innerHeight - 8 || r.top - mh - 4 < 8;
261
+ const top = below ? r.bottom + 4 : r.top - mh - 4;
262
+ const left = Math.max(8, Math.min(align === 'end' ? r.right - mw : r.left, innerWidth - mw - 8));
263
+ menu.dataset.side = below ? 'below' : 'above';
264
+ menu.style.top = `${top + scrollY}px`;
265
+ menu.style.left = `${left + scrollX}px`;
266
+ menu.style.transformOrigin = `${below ? 'top' : 'bottom'} ${align === 'end' ? 'right' : 'left'}`;
267
+ }
268
+
269
+ function MenuKeys({ keys }: { keys: NonNullable<MenuItem['keys']> }) {
270
+ return (
271
+ <span className="menu-keys">
272
+ {keys.map((k) => (
273
+ <kbd key={typeof k === 'string' ? k : k.label} className="kbd">
274
+ {typeof k === 'string' ? k : <Icon name={k.icon} size={12} label={k.label} />}
275
+ </kbd>
276
+ ))}
277
+ </span>
278
+ );
279
+ }
280
+
281
+ const rowClass = (item: MenuItem, selected: boolean) =>
282
+ ['menu-item', item.danger && 'is-danger', item.disabled && 'is-disabled', selected && 'is-selected']
283
+ .filter(Boolean)
284
+ .join(' ');
285
+
286
+ function MenuRow({
287
+ item,
288
+ role,
289
+ selected,
290
+ onPick,
291
+ }: {
292
+ item: MenuItem;
293
+ role: 'option' | 'menuitemradio' | 'menuitem';
294
+ selected: boolean;
295
+ onPick: (item: MenuItem) => void;
296
+ }) {
297
+ return (
298
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-checked only on menuitemradio, aria-selected only on option
299
+ <button
300
+ type="button"
301
+ className={rowClass(item, selected)}
302
+ role={role}
303
+ aria-checked={role === 'menuitemradio' ? selected : undefined}
304
+ aria-selected={role === 'option' ? selected : undefined}
305
+ aria-disabled={item.disabled || undefined}
306
+ disabled={item.disabled}
307
+ data-label={item.label}
308
+ onClick={() => onPick(item)}
309
+ >
310
+ {item.icon ? (
311
+ <span className="menu-glyph">
312
+ <Icon name={item.icon} size={16} />
313
+ </span>
314
+ ) : null}
315
+ <span className="menu-text">
316
+ <span className="menu-label">{item.label}</span>
317
+ {item.desc ? <span className="menu-desc">{item.desc}</span> : null}
318
+ </span>
319
+ {item.keys ? <MenuKeys keys={item.keys} /> : null}
320
+ {selected ? (
321
+ <span className="menu-check">
322
+ <Icon name="check" size={16} />
323
+ </span>
324
+ ) : null}
325
+ </button>
326
+ );
327
+ }
328
+
329
+ export type SelectOption = { value: string; label: string };
330
+
331
+ export function Select({
332
+ value,
333
+ options,
334
+ onChange,
335
+ width,
336
+ className,
337
+ placeholder = 'Select',
338
+ labelId,
339
+ 'aria-label': ariaLabel,
340
+ }: {
341
+ value: string | null;
342
+ options: SelectOption[];
343
+ onChange: (value: string) => void;
344
+ width?: number;
345
+ className?: string;
346
+ placeholder?: string;
347
+ labelId?: string;
348
+ 'aria-label'?: string;
349
+ }) {
350
+ const valueId = useId();
351
+ const current = options.find((o) => o.value === value);
352
+ const menu = useMenu({
353
+ role: 'listbox',
354
+ align: 'start',
355
+ items: options.map((o) => ({ label: o.label, value: o.value })),
356
+ selected: value ?? undefined,
357
+ label: ariaLabel,
358
+ width: (trigger) => Math.max(trigger.offsetWidth, width || 0),
359
+ onSelect: (v) => {
360
+ if (v != null && v !== value) onChange(v);
361
+ },
362
+ });
363
+ return (
364
+ <>
365
+ <button
366
+ type="button"
367
+ className={className ? `select ${className}` : 'select'}
368
+ style={width ? { width } : undefined}
369
+ aria-label={labelId ? undefined : ariaLabel}
370
+ aria-labelledby={labelId ? `${labelId} ${valueId}` : undefined}
371
+ {...menu.triggerProps}
372
+ onKeyDown={(e) => {
373
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
374
+ e.preventDefault();
375
+ if (!menu.isOpen) menu.open();
376
+ }}
377
+ >
378
+ <span id={valueId} className={current ? 'select-value' : 'select-value is-placeholder'}>
379
+ {current ? current.label : placeholder}
380
+ </span>
381
+ <Icon name="chevron-down" size={20} className="select-chevron" />
382
+ </button>
383
+ {menu.menu}
384
+ </>
385
+ );
386
+ }
@@ -0,0 +1,300 @@
1
+ import {
2
+ type ReactNode,
3
+ type RefObject,
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ useSyncExternalStore,
11
+ } from 'react';
12
+ import { createPortal } from 'react-dom';
13
+ import { Button } from './Button';
14
+ import { afterTransition, trapTab } from './layer';
15
+ import './ui.css';
16
+
17
+ type Action = {
18
+ label: string;
19
+ value: unknown;
20
+ kind?: 'primary' | 'secondary' | 'tertiary' | 'primary-error';
21
+ };
22
+
23
+ type DialogEntry = {
24
+ kind: 'dialog';
25
+ id: number;
26
+ title: string;
27
+ body: ReactNode;
28
+ actions: Action[];
29
+ danger: boolean;
30
+ width: number;
31
+ resolve: (value: unknown) => void;
32
+ };
33
+
34
+ export type SheetHandle = { close: () => void };
35
+
36
+ type SheetEntry = {
37
+ kind: 'sheet';
38
+ id: number;
39
+ title: string;
40
+ width: number;
41
+ content: (close: () => void) => ReactNode;
42
+ handle: SheetHandle;
43
+ };
44
+
45
+ type Entry = DialogEntry | SheetEntry;
46
+
47
+ let layers: Entry[] = [];
48
+ let seq = 0;
49
+ const closing = new Set<number>();
50
+ const listeners = new Set<() => void>();
51
+
52
+ function setLayers(next: Entry[]) {
53
+ layers = next;
54
+ for (const fn of listeners) fn();
55
+ }
56
+
57
+ const subscribe = (fn: () => void) => {
58
+ listeners.add(fn);
59
+ return () => listeners.delete(fn);
60
+ };
61
+
62
+ const syncBody = () =>
63
+ document.body.classList.toggle(
64
+ 'has-overlay',
65
+ layers.some((l) => !closing.has(l.id)),
66
+ );
67
+
68
+ const isTop = (id: number) => layers.filter((l) => !closing.has(l.id)).at(-1)?.id === id;
69
+
70
+ export function openDialog({
71
+ title,
72
+ body,
73
+ actions,
74
+ danger = false,
75
+ width = 440,
76
+ }: {
77
+ title: string;
78
+ body: ReactNode;
79
+ actions: Action[];
80
+ danger?: boolean;
81
+ width?: number;
82
+ }) {
83
+ return new Promise<unknown>((resolve) => {
84
+ setLayers([...layers, { kind: 'dialog', id: ++seq, title, body, actions, danger, width, resolve }]);
85
+ });
86
+ }
87
+
88
+ // Both verbs are named by the caller: the confirm is the outcome (Delete
89
+ // report, Cancel the run), and the dismiss says what it keeps doing when
90
+ // Cancel would be ambiguous (Keep running).
91
+ export function confirmDialog(
92
+ title: string,
93
+ body: ReactNode,
94
+ {
95
+ confirmLabel,
96
+ dismissLabel = 'Cancel',
97
+ danger = true,
98
+ }: { confirmLabel: string; dismissLabel?: string; danger?: boolean },
99
+ ) {
100
+ return openDialog({
101
+ title,
102
+ body,
103
+ danger,
104
+ actions: [
105
+ { label: dismissLabel, value: false },
106
+ { label: confirmLabel, value: true, kind: danger ? 'primary-error' : 'primary' },
107
+ ],
108
+ }).then((v) => v === true);
109
+ }
110
+
111
+ // content renders <SheetBody> and, optionally, <SheetFoot>.
112
+ export function openSheet({
113
+ title,
114
+ width = 520,
115
+ content,
116
+ }: {
117
+ title: string;
118
+ width?: number;
119
+ content: (close: () => void) => ReactNode;
120
+ }) {
121
+ const handle: SheetHandle = { close: () => {} };
122
+ setLayers([...layers, { kind: 'sheet', id: ++seq, title, width, content, handle }]);
123
+ return handle;
124
+ }
125
+
126
+ export function SheetBody({ children }: { children: ReactNode }) {
127
+ return <div className="sheet-body">{children}</div>;
128
+ }
129
+
130
+ export function SheetFoot({ children }: { children?: ReactNode }) {
131
+ return <div className="sheet-foot">{children}</div>;
132
+ }
133
+
134
+ export function OverlayHost() {
135
+ const list = useSyncExternalStore(subscribe, () => layers);
136
+ return createPortal(
137
+ list.map((entry) =>
138
+ entry.kind === 'dialog' ? (
139
+ <DialogLayer key={entry.id} entry={entry} />
140
+ ) : (
141
+ <SheetLayer key={entry.id} entry={entry} />
142
+ ),
143
+ ),
144
+ document.body,
145
+ );
146
+ }
147
+
148
+ function useLayer(id: number, panel: RefObject<HTMLDivElement | null>, exitMs: number) {
149
+ const overlay = useRef<HTMLDivElement>(null);
150
+ const prev = useRef<Element | null>(null);
151
+ const done = useRef(false);
152
+ const [open, setOpen] = useState(false);
153
+
154
+ useLayoutEffect(() => {
155
+ prev.current ??= document.activeElement;
156
+ void overlay.current?.offsetWidth;
157
+ setOpen(true);
158
+ syncBody();
159
+ }, []);
160
+
161
+ const close = useCallback(() => {
162
+ if (done.current) return false;
163
+ done.current = true;
164
+ closing.add(id);
165
+ setOpen(false);
166
+ syncBody();
167
+ afterTransition(panel.current, exitMs).then(() => {
168
+ closing.delete(id);
169
+ setLayers(layers.filter((l) => l.id !== id));
170
+ });
171
+ (prev.current as HTMLElement | null)?.focus?.();
172
+ return true;
173
+ }, [id, panel, exitMs]);
174
+
175
+ useEffect(() => {
176
+ const el = overlay.current;
177
+ const onDown = (e: PointerEvent) => {
178
+ if (e.target === el) close();
179
+ };
180
+ el?.addEventListener('pointerdown', onDown);
181
+ return () => el?.removeEventListener('pointerdown', onDown);
182
+ }, [close]);
183
+
184
+ return { overlay, open, close };
185
+ }
186
+
187
+ function useLayerKeys(id: number, panel: RefObject<HTMLDivElement | null>, onEscape: () => void) {
188
+ useEffect(() => {
189
+ const onKey = (e: KeyboardEvent) => {
190
+ if (!isTop(id) || document.querySelector('.menu.is-open')) return;
191
+ if (e.key === 'Escape') {
192
+ e.preventDefault();
193
+ onEscape();
194
+ } else if (panel.current) trapTab(e, panel.current);
195
+ };
196
+ document.addEventListener('keydown', onKey);
197
+ return () => document.removeEventListener('keydown', onKey);
198
+ }, [id, panel, onEscape]);
199
+ }
200
+
201
+ function DialogLayer({ entry }: { entry: DialogEntry }) {
202
+ const dialog = useRef<HTMLDivElement>(null);
203
+ const titleId = useId();
204
+ const { overlay, open, close } = useLayer(entry.id, dialog, 150);
205
+ const finish = useCallback(
206
+ (value: unknown) => {
207
+ if (close()) entry.resolve(value);
208
+ },
209
+ [close, entry],
210
+ );
211
+ const onEscape = useCallback(() => finish(null), [finish]);
212
+ useLayerKeys(entry.id, dialog, onEscape);
213
+
214
+ useEffect(() => {
215
+ const t = setTimeout(() => {
216
+ const buttons = [...(dialog.current?.querySelectorAll<HTMLElement>('button') ?? [])];
217
+ (
218
+ dialog.current?.querySelector<HTMLElement>('[data-autofocus]') ?? buttons.at(entry.danger ? 0 : -1)
219
+ )?.focus();
220
+ }, 30);
221
+ return () => clearTimeout(t);
222
+ }, [entry.danger]);
223
+
224
+ return (
225
+ <div ref={overlay} className={open ? 'overlay is-open' : 'overlay'} role="presentation">
226
+ <div
227
+ ref={dialog}
228
+ className="dialog"
229
+ role="alertdialog"
230
+ aria-modal="true"
231
+ aria-labelledby={titleId}
232
+ style={{ width: entry.width }}
233
+ >
234
+ <div className="dialog-body">
235
+ <h2 id={titleId} className="dialog-title">
236
+ {entry.title}
237
+ </h2>
238
+ <div className="dialog-text">{entry.body}</div>
239
+ </div>
240
+ <div className="dialog-actions">
241
+ {entry.actions.map((a) => (
242
+ <Button
243
+ key={a.label}
244
+ kind={a.kind ?? 'secondary'}
245
+ label={a.label}
246
+ onClick={() => finish(a.value)}
247
+ />
248
+ ))}
249
+ </div>
250
+ </div>
251
+ </div>
252
+ );
253
+ }
254
+
255
+ function SheetLayer({ entry }: { entry: SheetEntry }) {
256
+ const sheet = useRef<HTMLDivElement>(null);
257
+ const titleId = useId();
258
+ const { overlay, open, close } = useLayer(entry.id, sheet, 320);
259
+ const dismiss = useCallback(() => {
260
+ close();
261
+ }, [close]);
262
+ entry.handle.close = dismiss;
263
+ useLayerKeys(entry.id, sheet, dismiss);
264
+
265
+ useEffect(() => {
266
+ const t = setTimeout(() => {
267
+ const root = sheet.current;
268
+ (
269
+ root?.querySelector<HTMLElement>('[data-autofocus]') ??
270
+ root?.querySelector<HTMLElement>('.sheet-body :is(input, button)')
271
+ )?.focus();
272
+ }, 60);
273
+ return () => clearTimeout(t);
274
+ }, []);
275
+
276
+ return (
277
+ <div
278
+ ref={overlay}
279
+ className={open ? 'overlay overlay-sheet is-open' : 'overlay overlay-sheet'}
280
+ role="presentation"
281
+ >
282
+ <div
283
+ ref={sheet}
284
+ className="sheet"
285
+ role="dialog"
286
+ aria-modal="true"
287
+ aria-labelledby={titleId}
288
+ style={{ width: entry.width }}
289
+ >
290
+ <div className="sheet-head">
291
+ <h2 id={titleId} className="sheet-title">
292
+ {entry.title}
293
+ </h2>
294
+ <Button kind="tertiary" size={null} icon="x-close" aria-label="Close" onClick={dismiss} />
295
+ </div>
296
+ {entry.content(dismiss)}
297
+ </div>
298
+ </div>
299
+ );
300
+ }