@blurrow/ui 0.2.0 → 0.2.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 (271) hide show
  1. package/README.md +48 -36
  2. package/dist/Accordion.d.ts +17 -0
  3. package/dist/Accordion.js +138 -0
  4. package/dist/Accordion.js.map +1 -0
  5. package/dist/Alert.d.ts +13 -0
  6. package/dist/Alert.js +69 -0
  7. package/dist/Alert.js.map +1 -0
  8. package/dist/AuthorCard.d.ts +12 -0
  9. package/dist/AuthorCard.js +57 -0
  10. package/dist/AuthorCard.js.map +1 -0
  11. package/dist/Avatar.d.ts +21 -0
  12. package/dist/Avatar.js +96 -0
  13. package/dist/Avatar.js.map +1 -0
  14. package/dist/Badge.d.ts +12 -0
  15. package/dist/Badge.js +46 -0
  16. package/dist/Badge.js.map +1 -0
  17. package/dist/Banner.d.ts +15 -0
  18. package/dist/Banner.js +68 -0
  19. package/dist/Banner.js.map +1 -0
  20. package/dist/Breadcrumb.d.ts +13 -0
  21. package/dist/Breadcrumb.js +45 -0
  22. package/dist/Breadcrumb.js.map +1 -0
  23. package/dist/Button.d.ts +19 -0
  24. package/dist/Button.js +282 -0
  25. package/dist/Button.js.map +1 -0
  26. package/dist/ButtonGroup.d.ts +19 -0
  27. package/dist/ButtonGroup.js +62 -0
  28. package/dist/ButtonGroup.js.map +1 -0
  29. package/dist/Card.d.ts +40 -0
  30. package/dist/Card.js +175 -0
  31. package/dist/Card.js.map +1 -0
  32. package/dist/CardShell.d.ts +18 -0
  33. package/dist/CardShell.js +58 -0
  34. package/dist/CardShell.js.map +1 -0
  35. package/dist/Carousel.d.ts +22 -0
  36. package/dist/Carousel.js +247 -0
  37. package/dist/Carousel.js.map +1 -0
  38. package/dist/Checkbox.d.ts +15 -0
  39. package/dist/Checkbox.js +84 -0
  40. package/dist/Checkbox.js.map +1 -0
  41. package/dist/CloseButton.d.ts +12 -0
  42. package/dist/CloseButton.js +63 -0
  43. package/dist/CloseButton.js.map +1 -0
  44. package/dist/CodeBlock.d.ts +12 -0
  45. package/dist/CodeBlock.js +221 -0
  46. package/dist/CodeBlock.js.map +1 -0
  47. package/dist/ColorPicker.d.ts +24 -0
  48. package/dist/ColorPicker.js +366 -0
  49. package/dist/ColorPicker.js.map +1 -0
  50. package/dist/Combobox.d.ts +34 -0
  51. package/dist/Combobox.js +255 -0
  52. package/dist/Combobox.js.map +1 -0
  53. package/dist/CommandPalette.d.ts +25 -0
  54. package/dist/CommandPalette.js +304 -0
  55. package/dist/CommandPalette.js.map +1 -0
  56. package/dist/ContextMenu.d.ts +16 -0
  57. package/dist/ContextMenu.js +209 -0
  58. package/dist/ContextMenu.js.map +1 -0
  59. package/dist/DataList.d.ts +15 -0
  60. package/dist/DataList.js +42 -0
  61. package/dist/DataList.js.map +1 -0
  62. package/dist/DatePicker.d.ts +65 -0
  63. package/dist/DatePicker.js +391 -0
  64. package/dist/DatePicker.js.map +1 -0
  65. package/dist/DateRangePicker.d.ts +34 -0
  66. package/dist/DateRangePicker.js +180 -0
  67. package/dist/DateRangePicker.js.map +1 -0
  68. package/dist/Drawer.d.ts +17 -0
  69. package/dist/Drawer.js +132 -0
  70. package/dist/Drawer.js.map +1 -0
  71. package/dist/DropdownMenu.d.ts +23 -0
  72. package/dist/DropdownMenu.js +185 -0
  73. package/dist/DropdownMenu.js.map +1 -0
  74. package/dist/EmptyState.d.ts +13 -0
  75. package/dist/EmptyState.js +55 -0
  76. package/dist/EmptyState.js.map +1 -0
  77. package/dist/FieldMessage.d.ts +26 -0
  78. package/dist/FieldMessage.js +95 -0
  79. package/dist/FieldMessage.js.map +1 -0
  80. package/dist/FileUpload.d.ts +32 -0
  81. package/dist/FileUpload.js +253 -0
  82. package/dist/FileUpload.js.map +1 -0
  83. package/dist/Floating.d.ts +32 -0
  84. package/dist/Floating.js +95 -0
  85. package/dist/Floating.js.map +1 -0
  86. package/dist/HoverCard.d.ts +18 -0
  87. package/dist/HoverCard.js +90 -0
  88. package/dist/HoverCard.js.map +1 -0
  89. package/dist/Input.d.ts +17 -0
  90. package/dist/Input.js +96 -0
  91. package/dist/Input.js.map +1 -0
  92. package/dist/Kbd.d.ts +9 -0
  93. package/dist/Kbd.js +24 -0
  94. package/dist/Kbd.js.map +1 -0
  95. package/dist/Modal.d.ts +15 -0
  96. package/dist/Modal.js +109 -0
  97. package/dist/Modal.js.map +1 -0
  98. package/dist/NumberInput.d.ts +23 -0
  99. package/dist/NumberInput.js +162 -0
  100. package/dist/NumberInput.js.map +1 -0
  101. package/dist/OtpInput.d.ts +26 -0
  102. package/dist/OtpInput.js +173 -0
  103. package/dist/OtpInput.js.map +1 -0
  104. package/dist/Pagination.d.ts +11 -0
  105. package/dist/Pagination.js +148 -0
  106. package/dist/Pagination.js.map +1 -0
  107. package/dist/Popover.d.ts +13 -0
  108. package/dist/Popover.js +98 -0
  109. package/dist/Popover.js.map +1 -0
  110. package/dist/Progress.d.ts +12 -0
  111. package/dist/Progress.js +61 -0
  112. package/dist/Progress.js.map +1 -0
  113. package/dist/QuoteCard.d.ts +13 -0
  114. package/dist/QuoteCard.js +92 -0
  115. package/dist/QuoteCard.js.map +1 -0
  116. package/dist/Radio.d.ts +19 -0
  117. package/dist/Radio.js +161 -0
  118. package/dist/Radio.js.map +1 -0
  119. package/dist/RadioCards.d.ts +27 -0
  120. package/dist/RadioCards.js +205 -0
  121. package/dist/RadioCards.js.map +1 -0
  122. package/dist/ReactionBar.d.ts +15 -0
  123. package/dist/ReactionBar.js +92 -0
  124. package/dist/ReactionBar.js.map +1 -0
  125. package/dist/ReadingProgress.d.ts +8 -0
  126. package/dist/ReadingProgress.js +51 -0
  127. package/dist/ReadingProgress.js.map +1 -0
  128. package/dist/ReadingTime.d.ts +9 -0
  129. package/dist/ReadingTime.js +39 -0
  130. package/dist/ReadingTime.js.map +1 -0
  131. package/dist/Select.d.ts +20 -0
  132. package/dist/Select.js +244 -0
  133. package/dist/Select.js.map +1 -0
  134. package/dist/Separator.d.ts +14 -0
  135. package/dist/Separator.js +33 -0
  136. package/dist/Separator.js.map +1 -0
  137. package/dist/ShareButtons.d.ts +10 -0
  138. package/dist/ShareButtons.js +107 -0
  139. package/dist/ShareButtons.js.map +1 -0
  140. package/dist/Sidebar.d.ts +30 -0
  141. package/dist/Sidebar.js +140 -0
  142. package/dist/Sidebar.js.map +1 -0
  143. package/dist/Skeleton.d.ts +15 -0
  144. package/dist/Skeleton.js +67 -0
  145. package/dist/Skeleton.js.map +1 -0
  146. package/dist/Slider.d.ts +27 -0
  147. package/dist/Slider.js +182 -0
  148. package/dist/Slider.js.map +1 -0
  149. package/dist/Spinner.d.ts +12 -0
  150. package/dist/Spinner.js +24 -0
  151. package/dist/Spinner.js.map +1 -0
  152. package/dist/SpotlightCard.d.ts +17 -0
  153. package/dist/SpotlightCard.js +87 -0
  154. package/dist/SpotlightCard.js.map +1 -0
  155. package/dist/Stat.d.ts +19 -0
  156. package/dist/Stat.js +71 -0
  157. package/dist/Stat.js.map +1 -0
  158. package/dist/StatCard.d.ts +24 -0
  159. package/dist/StatCard.js +212 -0
  160. package/dist/StatCard.js.map +1 -0
  161. package/dist/Steps.d.ts +14 -0
  162. package/dist/Steps.js +94 -0
  163. package/dist/Steps.js.map +1 -0
  164. package/dist/Table.d.ts +21 -0
  165. package/dist/Table.js +93 -0
  166. package/dist/Table.js.map +1 -0
  167. package/dist/TableOfContents.d.ts +17 -0
  168. package/dist/TableOfContents.js +92 -0
  169. package/dist/TableOfContents.js.map +1 -0
  170. package/dist/Tabs.d.ts +17 -0
  171. package/dist/Tabs.js +123 -0
  172. package/dist/Tabs.js.map +1 -0
  173. package/dist/TagInput.d.ts +16 -0
  174. package/dist/TagInput.js +169 -0
  175. package/dist/TagInput.js.map +1 -0
  176. package/dist/Textarea.d.ts +13 -0
  177. package/dist/Textarea.js +74 -0
  178. package/dist/Textarea.js.map +1 -0
  179. package/dist/TimePicker.d.ts +28 -0
  180. package/dist/TimePicker.js +264 -0
  181. package/dist/TimePicker.js.map +1 -0
  182. package/dist/Timeline.d.ts +19 -0
  183. package/dist/Timeline.js +76 -0
  184. package/dist/Timeline.js.map +1 -0
  185. package/dist/Toast.d.ts +18 -0
  186. package/dist/Toast.js +318 -0
  187. package/dist/Toast.js.map +1 -0
  188. package/dist/Toggle.d.ts +13 -0
  189. package/dist/Toggle.js +84 -0
  190. package/dist/Toggle.js.map +1 -0
  191. package/dist/Tooltip.d.ts +13 -0
  192. package/dist/Tooltip.js +138 -0
  193. package/dist/Tooltip.js.map +1 -0
  194. package/dist/cli.js +172 -5
  195. package/dist/index.d.ts +68 -631
  196. package/dist/index.js +67 -5558
  197. package/dist/index.js.map +1 -1
  198. package/dist/registry/Accordion.tsx +6 -6
  199. package/dist/registry/Alert.tsx +13 -10
  200. package/dist/registry/AuthorCard.tsx +7 -7
  201. package/dist/registry/Avatar.tsx +5 -5
  202. package/dist/registry/Badge.tsx +12 -15
  203. package/dist/registry/Banner.tsx +15 -18
  204. package/dist/registry/Breadcrumb.tsx +3 -3
  205. package/dist/registry/Button.tsx +58 -69
  206. package/dist/registry/ButtonGroup.tsx +9 -9
  207. package/dist/registry/Card.tsx +15 -15
  208. package/dist/registry/CardShell.tsx +6 -6
  209. package/dist/registry/Carousel.tsx +19 -15
  210. package/dist/registry/Checkbox.tsx +14 -9
  211. package/dist/registry/CloseButton.tsx +2 -2
  212. package/dist/registry/CodeBlock.tsx +15 -15
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +31 -24
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +4 -4
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +17 -15
  221. package/dist/registry/DropdownMenu.tsx +39 -77
  222. package/dist/registry/EmptyState.tsx +8 -8
  223. package/dist/registry/FieldMessage.tsx +123 -0
  224. package/dist/registry/FileUpload.tsx +318 -0
  225. package/dist/registry/Floating.tsx +134 -0
  226. package/dist/registry/HoverCard.tsx +113 -0
  227. package/dist/registry/Input.tsx +54 -43
  228. package/dist/registry/Kbd.tsx +4 -5
  229. package/dist/registry/Modal.tsx +17 -15
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +15 -18
  233. package/dist/registry/Popover.tsx +40 -45
  234. package/dist/registry/Progress.tsx +4 -4
  235. package/dist/registry/QuoteCard.tsx +7 -7
  236. package/dist/registry/Radio.tsx +24 -21
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +5 -5
  239. package/dist/registry/ReadingProgress.tsx +2 -2
  240. package/dist/registry/ReadingTime.tsx +2 -2
  241. package/dist/registry/Select.tsx +28 -53
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +2 -2
  244. package/dist/registry/Sidebar.tsx +18 -18
  245. package/dist/registry/Skeleton.tsx +1 -1
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +6 -6
  249. package/dist/registry/Stat.tsx +4 -4
  250. package/dist/registry/StatCard.tsx +19 -19
  251. package/dist/registry/Steps.tsx +7 -7
  252. package/dist/registry/Table.tsx +12 -10
  253. package/dist/registry/TableOfContents.tsx +8 -8
  254. package/dist/registry/Tabs.tsx +6 -6
  255. package/dist/registry/TagInput.tsx +9 -9
  256. package/dist/registry/Textarea.tsx +5 -5
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +14 -14
  259. package/dist/registry/Toast.tsx +15 -12
  260. package/dist/registry/Toggle.tsx +7 -10
  261. package/dist/registry/Tooltip.tsx +21 -73
  262. package/dist/registry/styles.css +868 -62
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/styles.css +868 -62
  265. package/dist/useFloatingPanel.d.ts +24 -0
  266. package/dist/useFloatingPanel.js +89 -0
  267. package/dist/useFloatingPanel.js.map +1 -0
  268. package/dist/useModalBehavior.d.ts +15 -0
  269. package/dist/useModalBehavior.js +57 -0
  270. package/dist/useModalBehavior.js.map +1 -0
  271. package/package.json +1 -1
@@ -0,0 +1,318 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useId, useMemo, useRef, useState } from "react";
4
+ import { Portal } from "./Floating";
5
+ import { FieldLabel, FieldMessage } from "./FieldMessage";
6
+ import { useFloatingPanel } from "./useFloatingPanel";
7
+
8
+ export interface ComboboxOption {
9
+ value: string;
10
+ label: string;
11
+ /** A second, quieter line under the label */
12
+ description?: string;
13
+ /** Extra words to match on that aren't shown */
14
+ keywords?: string[];
15
+ disabled?: boolean;
16
+ }
17
+
18
+ interface ComboboxProps {
19
+ options: ComboboxOption[];
20
+ value?: string | null;
21
+ defaultValue?: string | null;
22
+ onChange?: (value: string | null) => void;
23
+ placeholder?: string;
24
+ label?: string;
25
+ hint?: string;
26
+ error?: string;
27
+ /** Shown when nothing matches */
28
+ emptyText?: string;
29
+ /** Replace the default "contains" match */
30
+ filter?: (option: ComboboxOption, query: string) => boolean;
31
+ /** Show a key that empties the field */
32
+ clearable?: boolean;
33
+ disabled?: boolean;
34
+ fullWidth?: boolean;
35
+ name?: string;
36
+ className?: string;
37
+ }
38
+
39
+ const defaultFilter = (o: ComboboxOption, q: string) => {
40
+ const hay = [o.label, o.description ?? "", ...(o.keywords ?? [])].join(" ").toLowerCase();
41
+ return q
42
+ .toLowerCase()
43
+ .split(/\s+/)
44
+ .filter(Boolean)
45
+ .every((word) => hay.includes(word));
46
+ };
47
+
48
+ /* Bolds the part of the label that matched what was typed */
49
+ function Highlight({ text, query }: { text: string; query: string }) {
50
+ const q = query.trim().toLowerCase();
51
+ const at = q ? text.toLowerCase().indexOf(q) : -1;
52
+ if (at < 0) return <>{text}</>;
53
+ return (
54
+ <>
55
+ {text.slice(0, at)}
56
+ <mark className="bg-transparent font-semibold text-ink">{text.slice(at, at + q.length)}</mark>
57
+ {text.slice(at + q.length)}
58
+ </>
59
+ );
60
+ }
61
+
62
+ /* A Select you can type into. The field filters the list as you go,
63
+ arrows walk the matches, Enter picks one, and leaving the field puts
64
+ the chosen label back if what you typed didn't match anything. */
65
+ export function Combobox({
66
+ options,
67
+ value,
68
+ defaultValue = null,
69
+ onChange,
70
+ placeholder = "search…",
71
+ label,
72
+ hint,
73
+ error,
74
+ emptyText = "nothing matches",
75
+ filter = defaultFilter,
76
+ clearable = false,
77
+ disabled = false,
78
+ fullWidth = false,
79
+ name,
80
+ className = "",
81
+ }: ComboboxProps) {
82
+ const uid = useId();
83
+ const ref = useRef<HTMLDivElement>(null);
84
+ const inputRef = useRef<HTMLInputElement>(null);
85
+ const listRef = useRef<HTMLDivElement>(null);
86
+ const [internal, setInternal] = useState<string | null>(defaultValue);
87
+ const selectedValue = value !== undefined ? value : internal;
88
+ const selected = options.find((o) => o.value === selectedValue) ?? null;
89
+ const [query, setQuery] = useState<string | null>(null); // null: not typing, show the selection
90
+ const [active, setActive] = useState(0);
91
+ const { open, mounted, show, close, setFloating, panelStyle } = useFloatingPanel(ref, {
92
+ matchWidth: true,
93
+ onEscape: () => setQuery(null),
94
+ });
95
+ const messageId = error || hint ? `${uid}-message` : undefined;
96
+
97
+ const matches = useMemo(
98
+ () => (query ? options.filter((o) => filter(o, query)) : options),
99
+ [options, query, filter],
100
+ );
101
+
102
+ useEffect(() => {
103
+ if (!open) return;
104
+ listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
105
+ }, [open, active]);
106
+
107
+ const pick = (o: ComboboxOption | null) => {
108
+ if (o?.disabled) return;
109
+ setInternal(o?.value ?? null);
110
+ onChange?.(o?.value ?? null);
111
+ setQuery(null);
112
+ close();
113
+ };
114
+
115
+ const openList = () => {
116
+ if (open || disabled) return;
117
+ const i = matches.findIndex((o) => o.value === selectedValue);
118
+ setActive(Math.max(0, i));
119
+ show();
120
+ };
121
+
122
+ const step = (dir: 1 | -1) => {
123
+ if (!matches.length) return;
124
+ let i = active;
125
+ for (let n = 0; n < matches.length; n++) {
126
+ i = (i + dir + matches.length) % matches.length;
127
+ if (!matches[i].disabled) break;
128
+ }
129
+ setActive(i);
130
+ };
131
+
132
+ const onKeyDown = (e: React.KeyboardEvent) => {
133
+ switch (e.key) {
134
+ case "ArrowDown":
135
+ e.preventDefault();
136
+ if (!open) openList();
137
+ else step(1);
138
+ break;
139
+ case "ArrowUp":
140
+ e.preventDefault();
141
+ if (!open) openList();
142
+ else step(-1);
143
+ break;
144
+ case "Enter":
145
+ if (open && matches[active]) {
146
+ e.preventDefault();
147
+ pick(matches[active]);
148
+ }
149
+ break;
150
+ case "Tab":
151
+ if (open) close();
152
+ break;
153
+ }
154
+ };
155
+
156
+ const shownText = query ?? selected?.label ?? "";
157
+ const showClear = clearable && !!selected && !disabled;
158
+
159
+ return (
160
+ <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
161
+ {label && (
162
+ <FieldLabel id={`${uid}-label`} htmlFor={`${uid}-input`}>
163
+ {label}
164
+ </FieldLabel>
165
+ )}
166
+ {/* Closed, it reads exactly like Select: the chosen label and a
167
+ chevron. Focus it and it turns into a field you type into. */}
168
+ <div ref={ref} className={["group/combo relative isolate", fullWidth ? "w-full" : "w-60", className].join(" ")}>
169
+ <input
170
+ ref={inputRef}
171
+ id={`${uid}-input`}
172
+ role="combobox"
173
+ aria-expanded={open}
174
+ aria-controls={`${uid}-list`}
175
+ aria-autocomplete="list"
176
+ aria-activedescendant={open && matches[active] ? `${uid}-opt-${active}` : undefined}
177
+ aria-invalid={error ? true : undefined}
178
+ aria-describedby={messageId}
179
+ autoComplete="off"
180
+ spellCheck={false}
181
+ disabled={disabled}
182
+ placeholder={selected?.label ?? placeholder}
183
+ value={shownText}
184
+ onChange={(e) => {
185
+ setQuery(e.target.value);
186
+ setActive(0);
187
+ if (!open) show();
188
+ }}
189
+ onClick={openList}
190
+ // typing replaces the chosen label instead of adding to it
191
+ onFocus={(e) => e.target.select()}
192
+ onKeyDown={onKeyDown}
193
+ onBlur={() => {
194
+ setQuery(null);
195
+ if (open) close();
196
+ }}
197
+ data-open={open || undefined}
198
+ className={[
199
+ "field relative z-10 w-full rounded-xl py-3 pl-4 text-[13.5px] font-medium text-ink caret-ink",
200
+ "placeholder:font-normal placeholder:text-ink-faint focus:placeholder:text-ink-ghost",
201
+ "disabled:opacity-40 disabled:cursor-not-allowed",
202
+ open ? "cursor-text" : "cursor-pointer",
203
+ showClear ? "pr-[60px]" : "pr-10",
204
+ ].join(" ")}
205
+ />
206
+
207
+ {/* Input's sweep of light as the field comes up */}
208
+ <span aria-hidden className="pointer-events-none absolute inset-0 z-15 overflow-hidden rounded-xl">
209
+ <span
210
+ className="absolute inset-y-0 -left-[30%] block w-[30%] opacity-0 group-focus-within/combo:animate-input-shimmer"
211
+ style={{ background: "linear-gradient(90deg, transparent 0%, var(--shimmer) 50%, transparent 100%)" }}
212
+ />
213
+ </span>
214
+
215
+ {showClear && (
216
+ <button
217
+ type="button"
218
+ aria-label="clear"
219
+ tabIndex={-1}
220
+ onMouseDown={(e) => e.preventDefault()}
221
+ onClick={() => pick(null)}
222
+ className={[
223
+ "absolute right-9 top-1/2 z-20 grid h-[18px] w-[18px] -translate-y-1/2 cursor-pointer place-items-center rounded-full",
224
+ "text-ink-faint transition-[opacity,color,background-color] duration-150 hover:bg-surface-active hover:text-ink",
225
+ "opacity-0 group-hover/combo:opacity-100 group-focus-within/combo:opacity-100",
226
+ ].join(" ")}
227
+ >
228
+ <svg aria-hidden width="9" height="9" fill="none" stroke="currentColor" strokeWidth="2.75" viewBox="0 0 24 24">
229
+ <path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
230
+ </svg>
231
+ </button>
232
+ )}
233
+
234
+ <svg
235
+ aria-hidden
236
+ width="14"
237
+ height="14"
238
+ fill="none"
239
+ stroke="currentColor"
240
+ strokeWidth="2"
241
+ viewBox="0 0 24 24"
242
+ className={[
243
+ "pointer-events-none absolute right-4 top-1/2 z-20 -mt-[7px] transition-[transform,color] duration-300 ease-[var(--ease-spring)]",
244
+ open ? "rotate-180 text-ink" : "text-ink-faint group-hover/combo:text-ink-label",
245
+ ].join(" ")}
246
+ >
247
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
248
+ </svg>
249
+ {name && <input type="hidden" name={name} value={selectedValue ?? ""} />}
250
+ </div>
251
+
252
+ {mounted && (
253
+ <Portal>
254
+ <div
255
+ ref={setFloating}
256
+ className="rounded-[16px] p-1.5"
257
+ style={{ ...panelStyle, maxWidth: "min(420px, calc(100vw - 16px))" }}
258
+ >
259
+ <div
260
+ ref={listRef}
261
+ id={`${uid}-list`}
262
+ role="listbox"
263
+ aria-labelledby={label ? `${uid}-label` : undefined}
264
+ // keeps focus in the field, even when the scrollbar is dragged
265
+ onMouseDown={(e) => e.preventDefault()}
266
+ className="flex max-h-72 flex-col gap-0.5 overflow-y-auto rounded-[11px] bg-panel-face p-1 scroll-thin"
267
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
268
+ >
269
+ {matches.length === 0 && (
270
+ <div role="presentation" className="px-2.5 py-2 text-[13px] text-ink-faint">
271
+ {emptyText}
272
+ </div>
273
+ )}
274
+ {matches.map((o, i) => {
275
+ const isSel = o.value === selectedValue;
276
+ const isActive = i === active;
277
+ return (
278
+ <div
279
+ key={o.value}
280
+ id={`${uid}-opt-${i}`}
281
+ data-index={i}
282
+ role="option"
283
+ aria-selected={isSel}
284
+ aria-disabled={o.disabled || undefined}
285
+ onClick={() => pick(o)}
286
+ onMouseMove={() => !o.disabled && setActive(i)}
287
+ className={[
288
+ "flex w-full items-center justify-between gap-3 rounded-[8px] px-2.5 py-2 text-[13px] transition-colors duration-100",
289
+ o.disabled ? "cursor-not-allowed opacity-40" : "cursor-pointer",
290
+ isSel ? "font-medium" : "",
291
+ // one ink per state, as in Select
292
+ isSel || isActive ? "text-ink" : "text-ink-body",
293
+ isActive ? "bg-surface-active/80" : "",
294
+ ].join(" ")}
295
+ style={{ animation: `select-option-in 300ms var(--ease-out-soft) ${40 + Math.min(i, 8) * 22}ms both` }}
296
+ >
297
+ <span className="flex min-w-0 flex-col gap-px">
298
+ <span className="truncate">
299
+ <Highlight text={o.label} query={query ?? ""} />
300
+ </span>
301
+ {o.description && <span className="truncate text-[11.5px] font-normal text-ink-faint">{o.description}</span>}
302
+ </span>
303
+ {isSel && (
304
+ <svg aria-hidden className="shrink-0 text-ink" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
305
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
306
+ </svg>
307
+ )}
308
+ </div>
309
+ );
310
+ })}
311
+ </div>
312
+ </div>
313
+ </Portal>
314
+ )}
315
+ <FieldMessage id={messageId} error={error} hint={hint} />
316
+ </div>
317
+ );
318
+ }
@@ -9,6 +9,7 @@ import React, {
9
9
  useCallback,
10
10
  useId,
11
11
  } from "react";
12
+ import { Portal } from "./Floating";
12
13
  import { Kbd } from "./Kbd";
13
14
  import { useModalBehavior } from "./useModalBehavior";
14
15
 
@@ -75,19 +76,23 @@ export function CommandPalette({
75
76
  [filtered],
76
77
  );
77
78
 
78
- // Mount / unmount lifecycle (matches Modal pattern)
79
- useEffect(() => {
79
+ // Mount / unmount lifecycle (matches Modal pattern); every opening starts fresh
80
+ const [wasOpen, setWasOpen] = useState(false);
81
+ if (open !== wasOpen) {
82
+ setWasOpen(open);
80
83
  if (open) {
81
84
  setMounted(true);
82
85
  setQuery("");
83
86
  setActiveIndex(0);
84
- return;
85
- }
86
- if (mounted) {
87
+ } else {
87
88
  setVisible(false);
88
- const t = setTimeout(() => setMounted(false), CLOSE_MS);
89
- return () => clearTimeout(t);
90
89
  }
90
+ }
91
+
92
+ useEffect(() => {
93
+ if (open || !mounted) return;
94
+ const t = setTimeout(() => setMounted(false), CLOSE_MS);
95
+ return () => clearTimeout(t);
91
96
  }, [open, mounted]);
92
97
 
93
98
  // Enter animation (matches Modal pattern)
@@ -165,7 +170,8 @@ export function CommandPalette({
165
170
  let itemIndex = 0;
166
171
 
167
172
  return (
168
- <div className="fixed inset-0 z-50 flex items-start justify-center pt-[min(20vh,140px)] px-4">
173
+ <Portal>
174
+ <div className="fixed inset-0 z-90 flex items-start justify-center pt-[min(20vh,140px)] px-4">
169
175
  {/* Backdrop */}
170
176
  <div
171
177
  className={[
@@ -197,13 +203,13 @@ export function CommandPalette({
197
203
  style={{ background: "var(--panel-bezel)", boxShadow: "var(--modal-shadow)" }}
198
204
  >
199
205
  <div
200
- className="bg-white rounded-[14px] overflow-hidden"
206
+ className="bg-panel-face rounded-[14px] overflow-hidden"
201
207
  style={{ boxShadow: "var(--panel-face-shadow)" }}
202
208
  >
203
209
  {/* Search input */}
204
210
  <div className="flex items-center gap-3 px-4 py-3.5">
205
211
  <svg
206
- className="shrink-0 text-neutral-400"
212
+ className="shrink-0 text-ink-faint"
207
213
  width="14"
208
214
  height="14"
209
215
  fill="none"
@@ -228,13 +234,13 @@ export function CommandPalette({
228
234
  aria-controls={`${uid}-list`}
229
235
  aria-activedescendant={flatItems[activeIndex] ? `${uid}-item-${activeIndex}` : undefined}
230
236
  aria-autocomplete="list"
231
- className="flex-1 min-w-0 text-[14.5px] font-medium text-neutral-900 placeholder:text-neutral-400 placeholder:font-normal bg-transparent border-none outline-none"
237
+ className="flex-1 min-w-0 text-[14.5px] font-medium text-ink placeholder:text-ink-faint placeholder:font-normal bg-transparent border-none outline-none"
232
238
  />
233
239
  <Kbd className="shrink-0">esc</Kbd>
234
240
  </div>
235
241
 
236
242
  {/* Separator */}
237
- <div className="border-b border-neutral-100" />
243
+ <div className="border-b border-line" />
238
244
 
239
245
  {/* Results */}
240
246
  <div
@@ -245,15 +251,15 @@ export function CommandPalette({
245
251
  >
246
252
  {filtered.length === 0 ? (
247
253
  <div className="py-10 flex flex-col items-center gap-1 text-center">
248
- <p className="text-[13px] font-medium text-neutral-700">
254
+ <p className="text-[13px] font-medium text-ink-label">
249
255
  nothing for &ldquo;{query}&rdquo;
250
256
  </p>
251
- <p className="text-[12px] text-neutral-400">try a shorter or different word.</p>
257
+ <p className="text-[12px] text-ink-faint">try a shorter or different word.</p>
252
258
  </div>
253
259
  ) : (
254
260
  filtered.map((section, si) => (
255
261
  <div key={section.title} role="group" aria-label={section.title} className={si > 0 ? "mt-3" : ""}>
256
- <div aria-hidden className="px-3 py-1.5 text-[11.5px] font-medium text-neutral-400 lowercase select-none">
262
+ <div aria-hidden className="px-3 py-1.5 text-[11.5px] font-medium text-ink-faint lowercase select-none">
257
263
  {section.title}
258
264
  </div>
259
265
  <div className="flex flex-col gap-0.5">
@@ -272,7 +278,7 @@ export function CommandPalette({
272
278
  className={[
273
279
  "group/cmd flex items-center gap-3 w-full text-left px-3 py-2 rounded-[10px] cursor-pointer",
274
280
  "transition-[background,box-shadow] duration-100",
275
- isActive ? "bg-neutral-50" : "",
281
+ isActive ? "bg-surface-hover" : "",
276
282
  ].join(" ")}
277
283
  style={{ boxShadow: isActive ? "var(--panel-face-shadow)" : undefined }}
278
284
  >
@@ -280,19 +286,19 @@ export function CommandPalette({
280
286
  <span
281
287
  className={[
282
288
  "shrink-0 w-7 h-7 rounded-[8px] flex items-center justify-center transition-colors duration-100",
283
- isActive ? "text-neutral-900" : "text-neutral-500",
289
+ isActive ? "text-ink" : "text-ink-muted",
284
290
  ].join(" ")}
285
- style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}
291
+ style={{ background: "var(--face)", boxShadow: "var(--face-shadow)" }}
286
292
  >
287
293
  {item.icon}
288
294
  </span>
289
295
  )}
290
296
  <div className="flex-1 min-w-0 flex flex-col">
291
- <span className="text-[13px] font-medium text-neutral-900 truncate">
297
+ <span className="text-[13px] font-medium text-ink truncate">
292
298
  {item.label}
293
299
  </span>
294
300
  {item.description && (
295
- <span className="text-[12px] text-neutral-500 truncate">
301
+ <span className="text-[12px] text-ink-muted truncate">
296
302
  {item.description}
297
303
  </span>
298
304
  )}
@@ -308,7 +314,7 @@ export function CommandPalette({
308
314
  strokeWidth="2"
309
315
  viewBox="0 0 24 24"
310
316
  className={[
311
- "shrink-0 text-neutral-400 transition-all duration-150",
317
+ "shrink-0 text-ink-faint transition-all duration-150",
312
318
  isActive ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-1",
313
319
  ].join(" ")}
314
320
  >
@@ -326,8 +332,8 @@ export function CommandPalette({
326
332
 
327
333
  {/* Key hints — teach the palette while it's open */}
328
334
  <div
329
- className="flex items-center gap-4 px-4 py-2.5 border-t border-neutral-100 text-[11.5px] text-neutral-500 select-none"
330
- style={{ background: "linear-gradient(180deg, #fcfcfc 0%, #f8f8f8 100%)" }}
335
+ className="flex items-center gap-4 px-4 py-2.5 border-t border-line text-[11.5px] text-ink-muted select-none"
336
+ style={{ background: "var(--palette-tray)" }}
331
337
  >
332
338
  <span className="inline-flex items-center gap-1.5">
333
339
  <Kbd>↑</Kbd>
@@ -338,12 +344,13 @@ export function CommandPalette({
338
344
  <Kbd>↵</Kbd>
339
345
  Open
340
346
  </span>
341
- <span className="ml-auto tabular-nums text-neutral-400">
347
+ <span className="ml-auto tabular-nums text-ink-faint">
342
348
  {flatItems.length} {flatItems.length === 1 ? "result" : "results"}
343
349
  </span>
344
350
  </div>
345
351
  </div>
346
352
  </div>
347
353
  </div>
354
+ </Portal>
348
355
  );
349
356
  }