@blurrow/ui 0.1.0 → 0.2.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 (53) hide show
  1. package/README.md +28 -0
  2. package/dist/cli.js +1477 -0
  3. package/dist/registry/Accordion.tsx +140 -0
  4. package/dist/registry/Alert.tsx +92 -0
  5. package/dist/registry/AuthorCard.tsx +67 -0
  6. package/dist/registry/Avatar.tsx +116 -0
  7. package/dist/registry/Badge.tsx +58 -0
  8. package/dist/registry/Banner.tsx +89 -0
  9. package/dist/registry/Breadcrumb.tsx +60 -0
  10. package/dist/registry/Button.tsx +372 -0
  11. package/dist/registry/ButtonGroup.tsx +82 -0
  12. package/dist/registry/Card.tsx +241 -0
  13. package/dist/registry/CardShell.tsx +78 -0
  14. package/dist/registry/Carousel.tsx +292 -0
  15. package/dist/registry/Checkbox.tsx +99 -0
  16. package/dist/registry/CloseButton.tsx +68 -0
  17. package/dist/registry/CodeBlock.tsx +202 -0
  18. package/dist/registry/CommandPalette.tsx +349 -0
  19. package/dist/registry/DataList.tsx +49 -0
  20. package/dist/registry/Drawer.tsx +164 -0
  21. package/dist/registry/DropdownMenu.tsx +257 -0
  22. package/dist/registry/EmptyState.tsx +55 -0
  23. package/dist/registry/Input.tsx +97 -0
  24. package/dist/registry/Kbd.tsx +24 -0
  25. package/dist/registry/Modal.tsx +128 -0
  26. package/dist/registry/Pagination.tsx +163 -0
  27. package/dist/registry/Popover.tsx +121 -0
  28. package/dist/registry/Progress.tsx +66 -0
  29. package/dist/registry/QuoteCard.tsx +93 -0
  30. package/dist/registry/Radio.tsx +202 -0
  31. package/dist/registry/ReactionBar.tsx +101 -0
  32. package/dist/registry/ReadingProgress.tsx +51 -0
  33. package/dist/registry/ReadingTime.tsx +35 -0
  34. package/dist/registry/Select.tsx +290 -0
  35. package/dist/registry/ShareButtons.tsx +134 -0
  36. package/dist/registry/Sidebar.tsx +182 -0
  37. package/dist/registry/Skeleton.tsx +83 -0
  38. package/dist/registry/SpotlightCard.tsx +106 -0
  39. package/dist/registry/Stat.tsx +82 -0
  40. package/dist/registry/StatCard.tsx +256 -0
  41. package/dist/registry/Steps.tsx +109 -0
  42. package/dist/registry/Table.tsx +121 -0
  43. package/dist/registry/TableOfContents.tsx +116 -0
  44. package/dist/registry/Tabs.tsx +136 -0
  45. package/dist/registry/TagInput.tsx +191 -0
  46. package/dist/registry/Textarea.tsx +84 -0
  47. package/dist/registry/Timeline.tsx +102 -0
  48. package/dist/registry/Toast.tsx +385 -0
  49. package/dist/registry/Toggle.tsx +99 -0
  50. package/dist/registry/Tooltip.tsx +212 -0
  51. package/dist/registry/styles.css +401 -0
  52. package/dist/registry/useModalBehavior.ts +77 -0
  53. package/package.json +23 -6
@@ -0,0 +1,202 @@
1
+ "use client";
2
+
3
+ import React, { useState, useCallback, useMemo, useRef } from "react";
4
+
5
+ interface CodeBlockProps {
6
+ code: string;
7
+ language?: string;
8
+ showLineNumbers?: boolean;
9
+ title?: string;
10
+ className?: string;
11
+ }
12
+
13
+ const KEYWORDS = new Set([
14
+ "import", "from", "export", "default", "function", "return",
15
+ "const", "let", "var", "if", "else", "for", "while", "do",
16
+ "class", "extends", "new", "typeof", "instanceof",
17
+ "null", "undefined", "true", "false",
18
+ "async", "await", "try", "catch", "throw", "finally",
19
+ "interface", "type", "enum", "as", "in", "of",
20
+ "switch", "case", "break", "continue",
21
+ "void", "never", "any", "string", "number", "boolean",
22
+ "public", "private", "protected", "static", "abstract", "readonly",
23
+ ]);
24
+
25
+ const TOKEN_RE = /(\/\/.*$|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`|<\/?\s*[A-Z][a-zA-Z0-9.]*|<\/?\s*[a-z][a-zA-Z0-9-]*|=>|\b\d+(?:\.\d+)?\b|\b[a-zA-Z_$][a-zA-Z0-9_$]*\b)/;
26
+
27
+ const TOKEN_STYLES: Record<string, string> = {
28
+ keyword: "text-violet-600",
29
+ string: "text-emerald-700",
30
+ comment: "text-neutral-400 italic",
31
+ tag: "text-sky-700",
32
+ number: "text-amber-700",
33
+ };
34
+
35
+ function classifyToken(val: string): string {
36
+ if (val.startsWith("//")) return "comment";
37
+ if (val[0] === '"' || val[0] === "'" || val[0] === "`") return "string";
38
+ if (val[0] === "<") return "tag";
39
+ if (val === "=>") return "keyword";
40
+ if (/^\d/.test(val)) return "number";
41
+ if (KEYWORDS.has(val)) return "keyword";
42
+ return "plain";
43
+ }
44
+
45
+ function tokenizeLine(line: string): { type: string; value: string }[] {
46
+ const tokens: { type: string; value: string }[] = [];
47
+ const re = new RegExp(TOKEN_RE.source, "g");
48
+ let lastIndex = 0;
49
+ let match;
50
+
51
+ while ((match = re.exec(line)) !== null) {
52
+ if (match.index > lastIndex) {
53
+ tokens.push({ type: "plain", value: line.slice(lastIndex, match.index) });
54
+ }
55
+ tokens.push({ type: classifyToken(match[0]), value: match[0] });
56
+ lastIndex = re.lastIndex;
57
+ }
58
+
59
+ if (lastIndex < line.length) {
60
+ tokens.push({ type: "plain", value: line.slice(lastIndex) });
61
+ }
62
+
63
+ return tokens;
64
+ }
65
+
66
+ const HIGHLIGHTABLE = new Set(["js", "jsx", "ts", "tsx", "javascript", "typescript"]);
67
+
68
+ export function CodeBlock({
69
+ code,
70
+ language,
71
+ showLineNumbers = false,
72
+ title,
73
+ className = "",
74
+ }: CodeBlockProps) {
75
+ const [copied, setCopied] = useState(false);
76
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
77
+
78
+ const handleCopy = useCallback(() => {
79
+ navigator.clipboard.writeText(code).then(() => {
80
+ setCopied(true);
81
+ if (timer.current) clearTimeout(timer.current);
82
+ timer.current = setTimeout(() => setCopied(false), 2000);
83
+ });
84
+ }, [code]);
85
+
86
+ const highlight = language ? HIGHLIGHTABLE.has(language.toLowerCase()) : false;
87
+
88
+ const lines = useMemo(() => code.split("\n"), [code]);
89
+ const tokenized = useMemo(
90
+ () => (highlight ? lines.map((line) => tokenizeLine(line)) : null),
91
+ [lines, highlight],
92
+ );
93
+
94
+ return (
95
+ /* The header lives in the bezel and the code sits on the raised face,
96
+ so the filename reads as a label on the object, not part of it. */
97
+ <figure
98
+ className={["rounded-[18px] p-1.5 pt-0 m-0", className].filter(Boolean).join(" ")}
99
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
100
+ >
101
+ <figcaption className="flex items-center justify-between gap-3 pl-3 pr-0.5 h-10">
102
+ <div className="flex items-center gap-2 min-w-0">
103
+ {title && (
104
+ <span className="text-[12px] font-medium text-neutral-700 truncate">{title}</span>
105
+ )}
106
+ {language && (
107
+ <span
108
+ className="text-[11px] font-semibold text-neutral-500 lowercase px-1.5 py-0.5 rounded-[5px]"
109
+ style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
110
+ >
111
+ {language}
112
+ </span>
113
+ )}
114
+ </div>
115
+ {/* Both labels share one grid cell, so the key is always as wide as
116
+ the longer one and never jumps. Swapping rolls the old label up
117
+ and out of a small window while the new one rolls in beneath
118
+ it; the check draws itself as it arrives. */}
119
+ <button
120
+ type="button"
121
+ onClick={handleCopy}
122
+ aria-label={copied ? "copied to clipboard" : "copy code"}
123
+ className="inline-flex items-center h-7 px-2.5 rounded-[8px] text-[11.5px] font-medium cursor-pointer transition-[color,transform,scale] duration-150 active:scale-95 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900"
124
+ style={{
125
+ background: "var(--face-light)",
126
+ boxShadow: "var(--face-light-shadow)",
127
+ color: copied ? "#047857" : undefined,
128
+ }}
129
+ >
130
+ <span className="grid overflow-hidden text-neutral-600" style={{ color: "inherit" }}>
131
+ <span
132
+ aria-hidden
133
+ className="[grid-area:1/1] inline-flex items-center justify-center gap-1.5"
134
+ style={{
135
+ transform: copied ? "translateY(-110%)" : "none",
136
+ opacity: copied ? 0 : 1,
137
+ filter: copied ? "blur(2px)" : "none",
138
+ transition: "transform 320ms var(--ease-out-soft), opacity 220ms ease, filter 220ms ease",
139
+ }}
140
+ >
141
+ <svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
142
+ <rect x="9" y="9" width="13" height="13" rx="2" />
143
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
144
+ </svg>
145
+ copy
146
+ </span>
147
+ <span
148
+ aria-hidden
149
+ className="[grid-area:1/1] inline-flex items-center justify-center gap-1.5"
150
+ style={{
151
+ transform: copied ? "none" : "translateY(110%)",
152
+ opacity: copied ? 1 : 0,
153
+ filter: copied ? "none" : "blur(2px)",
154
+ transition: "transform 380ms var(--ease-spring), opacity 220ms ease, filter 220ms ease",
155
+ }}
156
+ >
157
+ <svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.25" viewBox="0 0 24 24">
158
+ <path
159
+ key={String(copied)}
160
+ strokeLinecap="round"
161
+ strokeLinejoin="round"
162
+ d="M5 13l4 4L19 7"
163
+ pathLength={15}
164
+ className={copied ? "animate-draw-check" : ""}
165
+ />
166
+ </svg>
167
+ copied
168
+ </span>
169
+ </span>
170
+ </button>
171
+ <span aria-live="polite" className="sr-only">{copied ? "copied to clipboard" : ""}</span>
172
+ </figcaption>
173
+ <div
174
+ className="bg-white rounded-[13px] overflow-x-auto scroll-thin"
175
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
176
+ >
177
+ <pre className="py-4 px-4 text-[13px] leading-[1.7] font-mono text-neutral-800">
178
+ <code>
179
+ {lines.map((line, i) => (
180
+ <div key={i} className="flex">
181
+ {showLineNumbers && (
182
+ <span aria-hidden className="select-none w-8 shrink-0 text-right pr-4 text-neutral-300 tabular-nums">
183
+ {i + 1}
184
+ </span>
185
+ )}
186
+ <span className="flex-1">
187
+ {tokenized && tokenized[i].length
188
+ ? tokenized[i].map((tok, j) => (
189
+ <span key={j} className={TOKEN_STYLES[tok.type] || ""}>
190
+ {tok.value}
191
+ </span>
192
+ ))
193
+ : (line || "\n")}
194
+ </span>
195
+ </div>
196
+ ))}
197
+ </code>
198
+ </pre>
199
+ </div>
200
+ </figure>
201
+ );
202
+ }
@@ -0,0 +1,349 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ useMemo,
9
+ useCallback,
10
+ useId,
11
+ } from "react";
12
+ import { Kbd } from "./Kbd";
13
+ import { useModalBehavior } from "./useModalBehavior";
14
+
15
+ interface CommandItem {
16
+ key: string;
17
+ label: string;
18
+ description?: string;
19
+ icon?: React.ReactNode;
20
+ shortcut?: string;
21
+ onSelect?: () => void;
22
+ }
23
+
24
+ interface CommandSection {
25
+ title: string;
26
+ items: CommandItem[];
27
+ }
28
+
29
+ interface CommandPaletteProps {
30
+ open: boolean;
31
+ onClose: () => void;
32
+ sections: CommandSection[];
33
+ placeholder?: string;
34
+ className?: string;
35
+ }
36
+
37
+ const CLOSE_MS = 240;
38
+
39
+ export function CommandPalette({
40
+ open,
41
+ onClose,
42
+ sections,
43
+ placeholder = "search commands…",
44
+ className = "",
45
+ }: CommandPaletteProps) {
46
+ const [mounted, setMounted] = useState(false);
47
+ const [visible, setVisible] = useState(false);
48
+ const [query, setQuery] = useState("");
49
+ const [activeIndex, setActiveIndex] = useState(0);
50
+
51
+ const dialogRef = useRef<HTMLDivElement>(null);
52
+ const inputRef = useRef<HTMLInputElement>(null);
53
+ const listRef = useRef<HTMLDivElement>(null);
54
+ const uid = useId();
55
+
56
+ // Filter sections based on query
57
+ const filtered = useMemo(() => {
58
+ const q = query.toLowerCase().trim();
59
+ if (!q) return sections;
60
+ return sections
61
+ .map((section) => ({
62
+ ...section,
63
+ items: section.items.filter(
64
+ (item) =>
65
+ item.label.toLowerCase().includes(q) ||
66
+ item.description?.toLowerCase().includes(q),
67
+ ),
68
+ }))
69
+ .filter((section) => section.items.length > 0);
70
+ }, [sections, query]);
71
+
72
+ // Flat list of all visible items for keyboard nav
73
+ const flatItems = useMemo(
74
+ () => filtered.flatMap((s) => s.items),
75
+ [filtered],
76
+ );
77
+
78
+ // Mount / unmount lifecycle (matches Modal pattern)
79
+ useEffect(() => {
80
+ if (open) {
81
+ setMounted(true);
82
+ setQuery("");
83
+ setActiveIndex(0);
84
+ return;
85
+ }
86
+ if (mounted) {
87
+ setVisible(false);
88
+ const t = setTimeout(() => setMounted(false), CLOSE_MS);
89
+ return () => clearTimeout(t);
90
+ }
91
+ }, [open, mounted]);
92
+
93
+ // Enter animation (matches Modal pattern)
94
+ useLayoutEffect(() => {
95
+ if (!mounted || !open || visible) return;
96
+
97
+ const el = dialogRef.current;
98
+ if (el) {
99
+ void el.getBoundingClientRect();
100
+ }
101
+
102
+ const id = requestAnimationFrame(() => {
103
+ setVisible(true);
104
+ });
105
+ return () => cancelAnimationFrame(id);
106
+ }, [mounted, open, visible]);
107
+
108
+ // Reset active index when filter changes
109
+ useEffect(() => {
110
+ setActiveIndex(0);
111
+ }, [query]);
112
+
113
+ // Scroll active item into view
114
+ useEffect(() => {
115
+ if (!listRef.current) return;
116
+ const activeEl = listRef.current.querySelector(
117
+ `[data-command-index="${activeIndex}"]`,
118
+ );
119
+ if (activeEl) {
120
+ activeEl.scrollIntoView({ block: "nearest" });
121
+ }
122
+ }, [activeIndex]);
123
+
124
+ // Handle selection
125
+ const handleSelect = useCallback(
126
+ (item: CommandItem) => {
127
+ item.onSelect?.();
128
+ onClose();
129
+ },
130
+ [onClose],
131
+ );
132
+
133
+ // Keyboard navigation
134
+ useEffect(() => {
135
+ if (!open) return;
136
+ const onKey = (e: KeyboardEvent) => {
137
+ switch (e.key) {
138
+ case "ArrowDown":
139
+ e.preventDefault();
140
+ setActiveIndex((i) => (i + 1 >= flatItems.length ? 0 : i + 1));
141
+ break;
142
+ case "ArrowUp":
143
+ e.preventDefault();
144
+ setActiveIndex((i) =>
145
+ i - 1 < 0 ? Math.max(flatItems.length - 1, 0) : i - 1,
146
+ );
147
+ break;
148
+ case "Enter":
149
+ e.preventDefault();
150
+ if (flatItems[activeIndex]) {
151
+ handleSelect(flatItems[activeIndex]);
152
+ }
153
+ break;
154
+ }
155
+ };
156
+ document.addEventListener("keydown", onKey);
157
+ return () => document.removeEventListener("keydown", onKey);
158
+ }, [open, onClose, flatItems, activeIndex, handleSelect]);
159
+
160
+ // Escape, focus trap + restore, scroll lock
161
+ useModalBehavior({ open, ready: visible, ref: dialogRef, onClose, initialFocus: inputRef });
162
+
163
+ if (!mounted) return null;
164
+
165
+ let itemIndex = 0;
166
+
167
+ return (
168
+ <div className="fixed inset-0 z-50 flex items-start justify-center pt-[min(20vh,140px)] px-4">
169
+ {/* Backdrop */}
170
+ <div
171
+ className={[
172
+ "absolute inset-0 backdrop-blur-[6px]",
173
+ "transition-opacity ease-out",
174
+ visible ? "opacity-100 duration-300" : "opacity-0 duration-200",
175
+ ].join(" ")}
176
+ style={{ background: "var(--scrim)" }}
177
+ aria-hidden
178
+ onClick={onClose}
179
+ />
180
+
181
+ {/* Palette panel */}
182
+ <div
183
+ ref={dialogRef}
184
+ role="dialog"
185
+ aria-modal="true"
186
+ aria-label="Command palette"
187
+ className={[
188
+ "relative z-10 w-full max-w-lg rounded-[20px] p-1.5 overflow-hidden",
189
+ "transition-[opacity,transform,scale,translate] ease-[var(--ease-spring)]",
190
+ visible
191
+ ? "opacity-100 scale-100 translate-y-0 duration-300"
192
+ : "opacity-0 scale-[0.94] translate-y-3 duration-200",
193
+ className,
194
+ ]
195
+ .filter(Boolean)
196
+ .join(" ")}
197
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--modal-shadow)" }}
198
+ >
199
+ <div
200
+ className="bg-white rounded-[14px] overflow-hidden"
201
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
202
+ >
203
+ {/* Search input */}
204
+ <div className="flex items-center gap-3 px-4 py-3.5">
205
+ <svg
206
+ className="shrink-0 text-neutral-400"
207
+ width="14"
208
+ height="14"
209
+ fill="none"
210
+ stroke="currentColor"
211
+ strokeWidth="1.75"
212
+ viewBox="0 0 24 24"
213
+ >
214
+ <path
215
+ strokeLinecap="round"
216
+ strokeLinejoin="round"
217
+ d="M21 21l-4.35-4.35M11 19a8 8 0 100-16 8 8 0 000 16z"
218
+ />
219
+ </svg>
220
+ <input
221
+ ref={inputRef}
222
+ type="text"
223
+ value={query}
224
+ onChange={(e) => setQuery(e.target.value)}
225
+ placeholder={placeholder}
226
+ role="combobox"
227
+ aria-expanded="true"
228
+ aria-controls={`${uid}-list`}
229
+ aria-activedescendant={flatItems[activeIndex] ? `${uid}-item-${activeIndex}` : undefined}
230
+ 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"
232
+ />
233
+ <Kbd className="shrink-0">esc</Kbd>
234
+ </div>
235
+
236
+ {/* Separator */}
237
+ <div className="border-b border-neutral-100" />
238
+
239
+ {/* Results */}
240
+ <div
241
+ ref={listRef}
242
+ id={`${uid}-list`}
243
+ role="listbox"
244
+ className="max-h-[340px] overflow-y-auto scroll-thin py-2 px-2"
245
+ >
246
+ {filtered.length === 0 ? (
247
+ <div className="py-10 flex flex-col items-center gap-1 text-center">
248
+ <p className="text-[13px] font-medium text-neutral-700">
249
+ nothing for &ldquo;{query}&rdquo;
250
+ </p>
251
+ <p className="text-[12px] text-neutral-400">try a shorter or different word.</p>
252
+ </div>
253
+ ) : (
254
+ filtered.map((section, si) => (
255
+ <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">
257
+ {section.title}
258
+ </div>
259
+ <div className="flex flex-col gap-0.5">
260
+ {section.items.map((item) => {
261
+ const currentIndex = itemIndex++;
262
+ const isActive = currentIndex === activeIndex;
263
+ return (
264
+ <div
265
+ key={item.key}
266
+ id={`${uid}-item-${currentIndex}`}
267
+ role="option"
268
+ aria-selected={isActive}
269
+ data-command-index={currentIndex}
270
+ onClick={() => handleSelect(item)}
271
+ onMouseMove={() => setActiveIndex(currentIndex)}
272
+ className={[
273
+ "group/cmd flex items-center gap-3 w-full text-left px-3 py-2 rounded-[10px] cursor-pointer",
274
+ "transition-[background,box-shadow] duration-100",
275
+ isActive ? "bg-neutral-50" : "",
276
+ ].join(" ")}
277
+ style={{ boxShadow: isActive ? "var(--panel-face-shadow)" : undefined }}
278
+ >
279
+ {item.icon && (
280
+ <span
281
+ className={[
282
+ "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",
284
+ ].join(" ")}
285
+ style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}
286
+ >
287
+ {item.icon}
288
+ </span>
289
+ )}
290
+ <div className="flex-1 min-w-0 flex flex-col">
291
+ <span className="text-[13px] font-medium text-neutral-900 truncate">
292
+ {item.label}
293
+ </span>
294
+ {item.description && (
295
+ <span className="text-[12px] text-neutral-500 truncate">
296
+ {item.description}
297
+ </span>
298
+ )}
299
+ </div>
300
+ {item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}
301
+ {!item.shortcut && (
302
+ <svg
303
+ aria-hidden
304
+ width="12"
305
+ height="12"
306
+ fill="none"
307
+ stroke="currentColor"
308
+ strokeWidth="2"
309
+ viewBox="0 0 24 24"
310
+ className={[
311
+ "shrink-0 text-neutral-400 transition-all duration-150",
312
+ isActive ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-1",
313
+ ].join(" ")}
314
+ >
315
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
316
+ </svg>
317
+ )}
318
+ </div>
319
+ );
320
+ })}
321
+ </div>
322
+ </div>
323
+ ))
324
+ )}
325
+ </div>
326
+
327
+ {/* Key hints — teach the palette while it's open */}
328
+ <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%)" }}
331
+ >
332
+ <span className="inline-flex items-center gap-1.5">
333
+ <Kbd>↑</Kbd>
334
+ <Kbd>↓</Kbd>
335
+ Navigate
336
+ </span>
337
+ <span className="inline-flex items-center gap-1.5">
338
+ <Kbd>↵</Kbd>
339
+ Open
340
+ </span>
341
+ <span className="ml-auto tabular-nums text-neutral-400">
342
+ {flatItems.length} {flatItems.length === 1 ? "result" : "results"}
343
+ </span>
344
+ </div>
345
+ </div>
346
+ </div>
347
+ </div>
348
+ );
349
+ }
@@ -0,0 +1,49 @@
1
+ import React from "react";
2
+
3
+ interface DataListItem {
4
+ label: string;
5
+ value: React.ReactNode;
6
+ }
7
+
8
+ interface DataListProps {
9
+ items: DataListItem[];
10
+ columns?: 1 | 2;
11
+ className?: string;
12
+ }
13
+
14
+ export function DataList({ items, columns = 1, className = "" }: DataListProps) {
15
+ return (
16
+ <div
17
+ className={["rounded-[18px] p-1.5", className].filter(Boolean).join(" ")}
18
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
19
+ >
20
+ <dl
21
+ className={[
22
+ // 1px gaps over a hairline-coloured face draw the grid lines, so
23
+ // borders never double up whatever the item count
24
+ "grid gap-px bg-neutral-900/[0.05] rounded-[13px] overflow-hidden",
25
+ columns === 2 ? "grid-cols-1 sm:grid-cols-2" : "grid-cols-1",
26
+ ].join(" ")}
27
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
28
+ >
29
+ {items.map((item, i) => (
30
+ <div
31
+ key={i}
32
+ className={[
33
+ "flex flex-col gap-1.5 px-5 py-4 bg-white",
34
+ // An odd last cell in two columns spans the row instead of leaving a hole
35
+ columns === 2 && items.length % 2 === 1 && i === items.length - 1 ? "sm:col-span-2" : "",
36
+ ].join(" ")}
37
+ >
38
+ <dt className="text-[12px] text-neutral-500">
39
+ {item.label}
40
+ </dt>
41
+ <dd className="min-h-6 flex items-center text-[14px] font-medium text-neutral-900 tracking-[-0.01em] tabular-nums">
42
+ {item.value}
43
+ </dd>
44
+ </div>
45
+ ))}
46
+ </dl>
47
+ </div>
48
+ );
49
+ }