@blurrow/ui 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +93 -2
- package/dist/cli.js +1477 -0
- package/dist/index.d.ts +631 -0
- package/dist/index.js +5559 -0
- package/dist/index.js.map +1 -0
- package/dist/registry/Accordion.tsx +140 -0
- package/dist/registry/Alert.tsx +92 -0
- package/dist/registry/AuthorCard.tsx +67 -0
- package/dist/registry/Avatar.tsx +116 -0
- package/dist/registry/Badge.tsx +58 -0
- package/dist/registry/Banner.tsx +89 -0
- package/dist/registry/Breadcrumb.tsx +60 -0
- package/dist/registry/Button.tsx +372 -0
- package/dist/registry/ButtonGroup.tsx +82 -0
- package/dist/registry/Card.tsx +241 -0
- package/dist/registry/CardShell.tsx +78 -0
- package/dist/registry/Carousel.tsx +292 -0
- package/dist/registry/Checkbox.tsx +99 -0
- package/dist/registry/CloseButton.tsx +68 -0
- package/dist/registry/CodeBlock.tsx +202 -0
- package/dist/registry/CommandPalette.tsx +349 -0
- package/dist/registry/DataList.tsx +49 -0
- package/dist/registry/Drawer.tsx +164 -0
- package/dist/registry/DropdownMenu.tsx +257 -0
- package/dist/registry/EmptyState.tsx +55 -0
- package/dist/registry/Input.tsx +97 -0
- package/dist/registry/Kbd.tsx +24 -0
- package/dist/registry/Modal.tsx +128 -0
- package/dist/registry/Pagination.tsx +163 -0
- package/dist/registry/Popover.tsx +121 -0
- package/dist/registry/Progress.tsx +66 -0
- package/dist/registry/QuoteCard.tsx +93 -0
- package/dist/registry/Radio.tsx +202 -0
- package/dist/registry/ReactionBar.tsx +101 -0
- package/dist/registry/ReadingProgress.tsx +51 -0
- package/dist/registry/ReadingTime.tsx +35 -0
- package/dist/registry/Select.tsx +290 -0
- package/dist/registry/ShareButtons.tsx +134 -0
- package/dist/registry/Sidebar.tsx +182 -0
- package/dist/registry/Skeleton.tsx +83 -0
- package/dist/registry/SpotlightCard.tsx +106 -0
- package/dist/registry/Stat.tsx +82 -0
- package/dist/registry/StatCard.tsx +256 -0
- package/dist/registry/Steps.tsx +109 -0
- package/dist/registry/Table.tsx +121 -0
- package/dist/registry/TableOfContents.tsx +116 -0
- package/dist/registry/Tabs.tsx +136 -0
- package/dist/registry/TagInput.tsx +191 -0
- package/dist/registry/Textarea.tsx +84 -0
- package/dist/registry/Timeline.tsx +102 -0
- package/dist/registry/Toast.tsx +385 -0
- package/dist/registry/Toggle.tsx +99 -0
- package/dist/registry/Tooltip.tsx +212 -0
- package/dist/registry/styles.css +401 -0
- package/dist/registry/useModalBehavior.ts +77 -0
- package/dist/styles.css +401 -0
- package/package.json +56 -4
|
@@ -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 “{query}”
|
|
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
|
+
}
|