@kinetixui/ui 0.11.0 → 0.12.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/dist/index.d.ts +344 -1
- package/dist/index.js +1705 -2
- package/package.json +5 -2
- package/src/components/color-picker.tsx +352 -0
- package/src/components/data-grid.tsx +304 -0
- package/src/components/diff-viewer.tsx +155 -0
- package/src/components/json-viewer.tsx +168 -0
- package/src/components/kanban-board.tsx +246 -0
- package/src/components/markdown-editor.tsx +288 -0
- package/src/components/message-bubble.tsx +83 -0
- package/src/components/multi-select.tsx +134 -0
- package/src/components/notification-center.tsx +119 -0
- package/src/components/tour.tsx +156 -0
- package/src/components/tree-view.tsx +273 -0
- package/src/components/virtual-list.tsx +92 -0
- package/src/index.ts +20 -0
- package/src/stories/ColorPicker.stories.tsx +28 -0
- package/src/stories/DataGrid.stories.tsx +54 -0
- package/src/stories/DiffViewer.stories.tsx +26 -0
- package/src/stories/JsonViewer.stories.tsx +31 -0
- package/src/stories/KanbanBoard.stories.tsx +44 -0
- package/src/stories/MarkdownEditor.stories.tsx +25 -0
- package/src/stories/MessageBubble.stories.tsx +32 -0
- package/src/stories/MultiSelect.stories.tsx +35 -0
- package/src/stories/NotificationCenter.stories.tsx +41 -0
- package/src/stories/Tour.stories.tsx +70 -0
- package/src/stories/TreeView.stories.tsx +38 -0
- package/src/stories/VirtualList.stories.tsx +36 -0
- package/tailwind.config.ts +2 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Bold, Code, Eye, EyeOff, Heading2, Italic, Link as LinkIcon, List, ListOrdered, Quote } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { Textarea } from "./textarea";
|
|
7
|
+
|
|
8
|
+
export interface MarkdownEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onChange"> {
|
|
9
|
+
value: string;
|
|
10
|
+
onChange: (value: string) => void;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
rows?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function wrapSelection(text: string, start: number, end: number, before: string, after: string, placeholder: string) {
|
|
16
|
+
const selected = text.slice(start, end) || placeholder;
|
|
17
|
+
const next = text.slice(0, start) + before + selected + after + text.slice(end);
|
|
18
|
+
return { text: next, selectionStart: start + before.length, selectionEnd: start + before.length + selected.length };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function prefixLines(text: string, start: number, end: number, prefix: (lineIndex: number) => string) {
|
|
22
|
+
const lineStart = text.lastIndexOf("\n", Math.max(start - 1, 0)) + 1;
|
|
23
|
+
let lineEnd = text.indexOf("\n", Math.max(end, lineStart));
|
|
24
|
+
if (lineEnd === -1) lineEnd = text.length;
|
|
25
|
+
const block = text.slice(lineStart, lineEnd);
|
|
26
|
+
const lines = block.split("\n");
|
|
27
|
+
const prefixed = lines.map((line, i) => prefix(i) + line).join("\n");
|
|
28
|
+
return {
|
|
29
|
+
text: text.slice(0, lineStart) + prefixed + text.slice(lineEnd),
|
|
30
|
+
selectionStart: start + prefix(0).length,
|
|
31
|
+
selectionEnd: end + (prefixed.length - block.length),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** HTML-escapes source text before any markup is inserted — see the render call below. */
|
|
36
|
+
function escapeHtml(s: string) {
|
|
37
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderInline(escaped: string): string {
|
|
41
|
+
return escaped
|
|
42
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
43
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
44
|
+
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
|
45
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label: string, url: string) => {
|
|
46
|
+
const safe = /^(https?:|mailto:|\/|#)/i.test(url.trim());
|
|
47
|
+
return safe ? `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>` : match;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A small hand-rolled Markdown → HTML renderer covering exactly the syntax
|
|
53
|
+
* the toolbar below produces (headings, bold, italic, links, inline/block
|
|
54
|
+
* code, bullet/numbered lists, blockquotes, paragraphs) — not a full
|
|
55
|
+
* CommonMark implementation. Kept dependency-free and simple enough to
|
|
56
|
+
* port identically to Compose/SwiftUI/Flutter, the same reasoning as
|
|
57
|
+
* `DiffViewer`'s hand-rolled LCS diff.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Matches `markerRe` at the start of `line`, then requires at least one
|
|
62
|
+
* space/tab before returning the rest as `content`. Deliberately not a
|
|
63
|
+
* single `markerRe` + `\s+(.*)$` regex: two adjacent unbounded quantifiers
|
|
64
|
+
* over overlapping character classes (`\s` and `.` both match a space) is
|
|
65
|
+
* a catastrophic-backtracking shape CodeQL flags as a ReDoS — walking the
|
|
66
|
+
* whitespace run by hand is linear and can't backtrack.
|
|
67
|
+
*/
|
|
68
|
+
function matchMarker(line: string, markerRe: RegExp): { marker: string; content: string } | null {
|
|
69
|
+
const m = markerRe.exec(line);
|
|
70
|
+
if (!m) return null;
|
|
71
|
+
let i = m[0].length;
|
|
72
|
+
if (line[i] !== " " && line[i] !== "\t") return null;
|
|
73
|
+
while (line[i] === " " || line[i] === "\t") i++;
|
|
74
|
+
return { marker: m[0], content: line.slice(i) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderMarkdown(source: string): string {
|
|
78
|
+
const lines = source.split("\n");
|
|
79
|
+
const out: string[] = [];
|
|
80
|
+
let inCode = false;
|
|
81
|
+
let codeLines: string[] = [];
|
|
82
|
+
let listType: "ul" | "ol" | null = null;
|
|
83
|
+
|
|
84
|
+
const closeList = () => {
|
|
85
|
+
if (listType) {
|
|
86
|
+
out.push(`</${listType}>`);
|
|
87
|
+
listType = null;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
if (line.trim().startsWith("```")) {
|
|
93
|
+
if (inCode) {
|
|
94
|
+
out.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
|
95
|
+
codeLines = [];
|
|
96
|
+
inCode = false;
|
|
97
|
+
} else {
|
|
98
|
+
closeList();
|
|
99
|
+
inCode = true;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (inCode) {
|
|
104
|
+
codeLines.push(line);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const heading = matchMarker(line, /^#{1,6}/);
|
|
109
|
+
if (heading) {
|
|
110
|
+
closeList();
|
|
111
|
+
const level = heading.marker.length;
|
|
112
|
+
out.push(`<h${level}>${renderInline(escapeHtml(heading.content))}</h${level}>`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const quote = line.match(/^>\s?(.*)$/);
|
|
117
|
+
if (quote) {
|
|
118
|
+
closeList();
|
|
119
|
+
out.push(`<blockquote>${renderInline(escapeHtml(quote[1]!))}</blockquote>`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const bullet = matchMarker(line, /^[-*]/);
|
|
124
|
+
if (bullet) {
|
|
125
|
+
if (listType !== "ul") {
|
|
126
|
+
closeList();
|
|
127
|
+
out.push("<ul>");
|
|
128
|
+
listType = "ul";
|
|
129
|
+
}
|
|
130
|
+
out.push(`<li>${renderInline(escapeHtml(bullet.content))}</li>`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const numbered = matchMarker(line, /^\d+\./);
|
|
135
|
+
if (numbered) {
|
|
136
|
+
if (listType !== "ol") {
|
|
137
|
+
closeList();
|
|
138
|
+
out.push("<ol>");
|
|
139
|
+
listType = "ol";
|
|
140
|
+
}
|
|
141
|
+
out.push(`<li>${renderInline(escapeHtml(numbered.content))}</li>`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
closeList();
|
|
146
|
+
if (line.trim() === "") continue;
|
|
147
|
+
out.push(`<p>${renderInline(escapeHtml(line))}</p>`);
|
|
148
|
+
}
|
|
149
|
+
closeList();
|
|
150
|
+
if (inCode) out.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
|
151
|
+
|
|
152
|
+
return out.join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* MarkdownEditor — toolbar + a plain `Textarea` (the markdown source) with
|
|
157
|
+
* an optional rendered preview pane. Deliberately *not* `contenteditable`
|
|
158
|
+
* (no Tiptap/Lexical): `contenteditable` has no native-platform analogue,
|
|
159
|
+
* so a WYSIWYG rich-text editor would be a web-only, eighth standing
|
|
160
|
+
* non-port. A markdown textarea is just a text buffer the toolbar inserts
|
|
161
|
+
* syntax into — SwiftUI's `TextEditor`, Compose's `BasicTextField`, and
|
|
162
|
+
* Flutter's `TextField` all support the same selection-based insertion, so
|
|
163
|
+
* this ports cleanly to all four platforms instead.
|
|
164
|
+
*/
|
|
165
|
+
const MarkdownEditor = React.forwardRef<HTMLDivElement, MarkdownEditorProps>(
|
|
166
|
+
({ value, onChange, placeholder, rows = 10, className, ...props }, ref) => {
|
|
167
|
+
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
168
|
+
const [showPreview, setShowPreview] = React.useState(false);
|
|
169
|
+
const pendingSelection = React.useRef<{ start: number; end: number } | null>(null);
|
|
170
|
+
|
|
171
|
+
React.useEffect(() => {
|
|
172
|
+
if (pendingSelection.current && textareaRef.current) {
|
|
173
|
+
const { start, end } = pendingSelection.current;
|
|
174
|
+
textareaRef.current.focus();
|
|
175
|
+
textareaRef.current.setSelectionRange(start, end);
|
|
176
|
+
pendingSelection.current = null;
|
|
177
|
+
}
|
|
178
|
+
}, [value]);
|
|
179
|
+
|
|
180
|
+
const applyInline = (before: string, after: string, placeholder: string) => {
|
|
181
|
+
const el = textareaRef.current;
|
|
182
|
+
if (!el) return;
|
|
183
|
+
const result = wrapSelection(value, el.selectionStart, el.selectionEnd, before, after, placeholder);
|
|
184
|
+
pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
|
|
185
|
+
onChange(result.text);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const applyLinePrefix = (prefix: (lineIndex: number) => string) => {
|
|
189
|
+
const el = textareaRef.current;
|
|
190
|
+
if (!el) return;
|
|
191
|
+
const result = prefixLines(value, el.selectionStart, el.selectionEnd, prefix);
|
|
192
|
+
pendingSelection.current = { start: result.selectionStart, end: result.selectionEnd };
|
|
193
|
+
onChange(result.text);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div ref={ref} className={cn("overflow-hidden rounded-md border font-sans", className)} {...props}>
|
|
198
|
+
<div role="toolbar" aria-label="Formatting" className="flex items-center gap-0.5 border-b bg-muted/40 p-1">
|
|
199
|
+
<ToolbarButton label="Bold" onClick={() => applyInline("**", "**", "bold text")}>
|
|
200
|
+
<Bold className="size-4" />
|
|
201
|
+
</ToolbarButton>
|
|
202
|
+
<ToolbarButton label="Italic" onClick={() => applyInline("*", "*", "italic text")}>
|
|
203
|
+
<Italic className="size-4" />
|
|
204
|
+
</ToolbarButton>
|
|
205
|
+
<ToolbarButton label="Heading" onClick={() => applyLinePrefix(() => "## ")}>
|
|
206
|
+
<Heading2 className="size-4" />
|
|
207
|
+
</ToolbarButton>
|
|
208
|
+
<ToolbarButton label="Link" onClick={() => applyInline("[", "](https://)", "link text")}>
|
|
209
|
+
<LinkIcon className="size-4" />
|
|
210
|
+
</ToolbarButton>
|
|
211
|
+
<ToolbarButton label="Bulleted list" onClick={() => applyLinePrefix(() => "- ")}>
|
|
212
|
+
<List className="size-4" />
|
|
213
|
+
</ToolbarButton>
|
|
214
|
+
<ToolbarButton label="Numbered list" onClick={() => applyLinePrefix((i) => `${i + 1}. `)}>
|
|
215
|
+
<ListOrdered className="size-4" />
|
|
216
|
+
</ToolbarButton>
|
|
217
|
+
<ToolbarButton label="Code" onClick={() => applyInline("`", "`", "code")}>
|
|
218
|
+
<Code className="size-4" />
|
|
219
|
+
</ToolbarButton>
|
|
220
|
+
<ToolbarButton label="Quote" onClick={() => applyLinePrefix(() => "> ")}>
|
|
221
|
+
<Quote className="size-4" />
|
|
222
|
+
</ToolbarButton>
|
|
223
|
+
<ToolbarButton
|
|
224
|
+
label={showPreview ? "Hide preview" : "Show preview"}
|
|
225
|
+
onClick={() => setShowPreview((v) => !v)}
|
|
226
|
+
active={showPreview}
|
|
227
|
+
className="ml-auto"
|
|
228
|
+
>
|
|
229
|
+
{showPreview ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
230
|
+
</ToolbarButton>
|
|
231
|
+
</div>
|
|
232
|
+
<div className={cn("grid", showPreview && "grid-cols-2 divide-x")}>
|
|
233
|
+
<Textarea
|
|
234
|
+
ref={textareaRef}
|
|
235
|
+
value={value}
|
|
236
|
+
onChange={(e) => onChange(e.target.value)}
|
|
237
|
+
placeholder={placeholder}
|
|
238
|
+
rows={rows}
|
|
239
|
+
className="resize-none rounded-none border-0 font-mono text-[13px] focus-visible:border-transparent focus-visible:shadow-none"
|
|
240
|
+
/>
|
|
241
|
+
{showPreview && (
|
|
242
|
+
<div
|
|
243
|
+
className="overflow-auto p-3 text-sm [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_p]:mb-2 [&_pre]:overflow-auto [&_pre]:rounded-sm [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:list-disc"
|
|
244
|
+
// Rendered from renderMarkdown above, which HTML-escapes all
|
|
245
|
+
// source text before inserting any markup — safe from
|
|
246
|
+
// injection, same trust boundary as any other self-generated
|
|
247
|
+
// innerHTML (not raw user HTML).
|
|
248
|
+
dangerouslySetInnerHTML={{ __html: renderMarkdown(value) }}
|
|
249
|
+
/>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
MarkdownEditor.displayName = "MarkdownEditor";
|
|
257
|
+
|
|
258
|
+
function ToolbarButton({
|
|
259
|
+
label,
|
|
260
|
+
onClick,
|
|
261
|
+
active,
|
|
262
|
+
className,
|
|
263
|
+
children,
|
|
264
|
+
}: {
|
|
265
|
+
label: string;
|
|
266
|
+
onClick: () => void;
|
|
267
|
+
active?: boolean;
|
|
268
|
+
className?: string;
|
|
269
|
+
children: React.ReactNode;
|
|
270
|
+
}) {
|
|
271
|
+
return (
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
onClick={onClick}
|
|
275
|
+
aria-label={label}
|
|
276
|
+
aria-pressed={active}
|
|
277
|
+
className={cn(
|
|
278
|
+
"inline-flex size-7 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground",
|
|
279
|
+
active && "bg-accent text-foreground",
|
|
280
|
+
className,
|
|
281
|
+
)}
|
|
282
|
+
>
|
|
283
|
+
{children}
|
|
284
|
+
</button>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export { MarkdownEditor };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Check, CheckCheck } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* MessageBubble — a sent/received chat bubble with grouping, timestamp,
|
|
9
|
+
* and a status tick. `grouped` is a lightweight consecutive-run cue
|
|
10
|
+
* (reduces the outer top corner's radius, the side that would otherwise
|
|
11
|
+
* repeat the previous bubble's rounding) rather than full first/middle/
|
|
12
|
+
* last position tracking — the caller already knows which messages are
|
|
13
|
+
* consecutive, so it stays a single boolean rather than the component
|
|
14
|
+
* inferring group position itself. Gap-fill addition (not in the
|
|
15
|
+
* original Figma source), the last pick from the `COMPONENT-ADDITIONS.md`
|
|
16
|
+
* Tier 2 backlog.
|
|
17
|
+
*/
|
|
18
|
+
export interface MessageBubbleProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
19
|
+
variant?: "sent" | "received";
|
|
20
|
+
timestamp?: React.ReactNode;
|
|
21
|
+
status?: "sent" | "delivered" | "read";
|
|
22
|
+
/** part of a consecutive run of bubbles from the same sender */
|
|
23
|
+
grouped?: boolean;
|
|
24
|
+
avatar?: React.ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MessageBubble = React.forwardRef<HTMLDivElement, MessageBubbleProps>(
|
|
28
|
+
({ className, variant = "received", timestamp, status, grouped = false, avatar, children, ...props }, ref) => {
|
|
29
|
+
const sent = variant === "sent";
|
|
30
|
+
return (
|
|
31
|
+
<div ref={ref} className={cn("flex items-end gap-2 font-sans", sent && "flex-row-reverse", className)} {...props}>
|
|
32
|
+
{avatar && !sent && <div className="shrink-0 self-end">{avatar}</div>}
|
|
33
|
+
<div className={cn("flex max-w-[75%] flex-col gap-1", sent ? "items-end" : "items-start")}>
|
|
34
|
+
<div
|
|
35
|
+
className={cn(
|
|
36
|
+
"min-w-0 rounded-2xl px-3.5 py-2 text-body-sm",
|
|
37
|
+
sent ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
|
|
38
|
+
sent && grouped && "rounded-tr-md",
|
|
39
|
+
!sent && grouped && "rounded-tl-md",
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</div>
|
|
44
|
+
{(timestamp || (sent && status)) && (
|
|
45
|
+
<div className="flex items-center gap-1 px-1 text-label-sm text-muted-foreground">
|
|
46
|
+
{timestamp}
|
|
47
|
+
{sent && status && (
|
|
48
|
+
<span className={cn(status === "read" && "text-primary")}>
|
|
49
|
+
{status === "sent" ? <Check className="size-3.5" /> : <CheckCheck className="size-3.5" />}
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
MessageBubble.displayName = "MessageBubble";
|
|
60
|
+
|
|
61
|
+
const TypingIndicator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
62
|
+
({ className, ...props }, ref) => (
|
|
63
|
+
<div
|
|
64
|
+
ref={ref}
|
|
65
|
+
role="status"
|
|
66
|
+
aria-label="Typing"
|
|
67
|
+
className={cn("flex w-fit items-center gap-1 rounded-2xl bg-muted px-3.5 py-2.5", className)}
|
|
68
|
+
{...props}
|
|
69
|
+
>
|
|
70
|
+
{[0, 1, 2].map((i) => (
|
|
71
|
+
<span
|
|
72
|
+
key={i}
|
|
73
|
+
aria-hidden
|
|
74
|
+
className="size-1.5 animate-typing-dot rounded-full bg-muted-foreground"
|
|
75
|
+
style={{ animationDelay: `${i * 150}ms` }}
|
|
76
|
+
/>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
TypingIndicator.displayName = "TypingIndicator";
|
|
82
|
+
|
|
83
|
+
export { MessageBubble, TypingIndicator };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Check } from "lucide-react";
|
|
5
|
+
import { Popover, PopoverAnchor, PopoverContent } from "./popover";
|
|
6
|
+
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "./command";
|
|
7
|
+
import { Tag } from "./tag";
|
|
8
|
+
import { cn } from "../lib/utils";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* MultiSelect — a `Combobox` that keeps multiple chips; a free-entry
|
|
12
|
+
* token field with `creatable`. `Combobox` (the existing Popover +
|
|
13
|
+
* Command recipe) is single-value and `Tag` is display-only, so this
|
|
14
|
+
* composes both into an actual shippable component rather than another
|
|
15
|
+
* "recipe" doc page. The trigger is `role="combobox"` on a plain `div`,
|
|
16
|
+
* not a `<button>` — `Tag`'s own remove control is a real `<button>`,
|
|
17
|
+
* and a `<button>` can't nest inside a `<button>` (invalid HTML); using
|
|
18
|
+
* `PopoverAnchor` + manual open-state instead of `PopoverTrigger` avoids
|
|
19
|
+
* that while keeping the whole thing keyboard-operable. Gap-fill
|
|
20
|
+
* addition (not in the original Figma source).
|
|
21
|
+
*/
|
|
22
|
+
export interface MultiSelectOption {
|
|
23
|
+
value: string;
|
|
24
|
+
label: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MultiSelectProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
28
|
+
options: MultiSelectOption[];
|
|
29
|
+
value?: string[];
|
|
30
|
+
defaultValue?: string[];
|
|
31
|
+
onValueChange?: (value: string[]) => void;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
/** allow adding free-entry tags that aren't in `options` */
|
|
34
|
+
creatable?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
|
|
38
|
+
(
|
|
39
|
+
{ className, options, value, defaultValue = [], onValueChange, placeholder = "Select…", creatable = false, ...props },
|
|
40
|
+
ref,
|
|
41
|
+
) => {
|
|
42
|
+
const [internal, setInternal] = React.useState(defaultValue);
|
|
43
|
+
const current = value ?? internal;
|
|
44
|
+
const [open, setOpen] = React.useState(false);
|
|
45
|
+
const [query, setQuery] = React.useState("");
|
|
46
|
+
|
|
47
|
+
const set = (next: string[]) => {
|
|
48
|
+
setInternal(next);
|
|
49
|
+
onValueChange?.(next);
|
|
50
|
+
};
|
|
51
|
+
const toggle = (v: string) => set(current.includes(v) ? current.filter((x) => x !== v) : [...current, v]);
|
|
52
|
+
const remove = (v: string) => set(current.filter((x) => x !== v));
|
|
53
|
+
const labelFor = (v: string) => options.find((o) => o.value === v)?.label ?? v;
|
|
54
|
+
|
|
55
|
+
const trimmed = query.trim();
|
|
56
|
+
const canCreate = creatable && trimmed.length > 0 && !options.some((o) => o.label.toLowerCase() === trimmed.toLowerCase());
|
|
57
|
+
const visibleOptions = creatable
|
|
58
|
+
? options.filter((o) => o.label.toLowerCase().includes(trimmed.toLowerCase()))
|
|
59
|
+
: options;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
63
|
+
<PopoverAnchor asChild>
|
|
64
|
+
<div
|
|
65
|
+
ref={ref}
|
|
66
|
+
role="combobox"
|
|
67
|
+
aria-expanded={open}
|
|
68
|
+
aria-haspopup="listbox"
|
|
69
|
+
tabIndex={0}
|
|
70
|
+
onClick={() => setOpen(true)}
|
|
71
|
+
onKeyDown={(e) => {
|
|
72
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
setOpen(true);
|
|
75
|
+
}
|
|
76
|
+
}}
|
|
77
|
+
className={cn(
|
|
78
|
+
"flex min-h-11 w-full flex-wrap items-center gap-1.5 border border-input bg-background px-3 py-2 font-sans",
|
|
79
|
+
"outline-none focus-visible:border-primary focus-visible:shadow-focus",
|
|
80
|
+
"aria-expanded:border-primary aria-expanded:shadow-focus",
|
|
81
|
+
className,
|
|
82
|
+
)}
|
|
83
|
+
{...props}
|
|
84
|
+
>
|
|
85
|
+
{current.length === 0 && <span className="text-body-sm text-muted-foreground">{placeholder}</span>}
|
|
86
|
+
{current.map((v) => (
|
|
87
|
+
<Tag key={v} variant="secondary" onRemove={() => remove(v)} onClick={(e) => e.stopPropagation()}>
|
|
88
|
+
{labelFor(v)}
|
|
89
|
+
</Tag>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</PopoverAnchor>
|
|
93
|
+
<PopoverContent
|
|
94
|
+
align="start"
|
|
95
|
+
className="w-[var(--radix-popover-trigger-width)] p-0"
|
|
96
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
97
|
+
>
|
|
98
|
+
<Command shouldFilter={!creatable}>
|
|
99
|
+
<CommandInput placeholder="Search…" value={query} onValueChange={setQuery} />
|
|
100
|
+
<CommandList>
|
|
101
|
+
<CommandEmpty>
|
|
102
|
+
{canCreate ? (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className="flex w-full items-center px-2 py-1.5 text-left text-body-sm text-foreground hover:bg-accent"
|
|
106
|
+
onClick={() => {
|
|
107
|
+
toggle(trimmed);
|
|
108
|
+
setQuery("");
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
Create “{trimmed}”
|
|
112
|
+
</button>
|
|
113
|
+
) : (
|
|
114
|
+
"No results."
|
|
115
|
+
)}
|
|
116
|
+
</CommandEmpty>
|
|
117
|
+
<CommandGroup>
|
|
118
|
+
{visibleOptions.map((option) => (
|
|
119
|
+
<CommandItem key={option.value} value={option.label} onSelect={() => toggle(option.value)}>
|
|
120
|
+
<Check className={cn("size-4", current.includes(option.value) ? "opacity-100" : "opacity-0")} />
|
|
121
|
+
{option.label}
|
|
122
|
+
</CommandItem>
|
|
123
|
+
))}
|
|
124
|
+
</CommandGroup>
|
|
125
|
+
</CommandList>
|
|
126
|
+
</Command>
|
|
127
|
+
</PopoverContent>
|
|
128
|
+
</Popover>
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
MultiSelect.displayName = "MultiSelect";
|
|
133
|
+
|
|
134
|
+
export { MultiSelect };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Bell } from "lucide-react";
|
|
5
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { cn } from "../lib/utils";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* NotificationCenter — a bell trigger opening a popover list of
|
|
11
|
+
* read/unread items with a "mark all read" action. Composed directly
|
|
12
|
+
* from `Popover` (re-exported as the root — no new open-state logic)
|
|
13
|
+
* and `Button`; `NotificationItem` follows `ListItem`'s interactive-row
|
|
14
|
+
* convention. `unreadCount`/`unread`/`onMarkAllRead` are plain props —
|
|
15
|
+
* the read-state itself stays the caller's, same as every other
|
|
16
|
+
* controlled/stateless component in this library. Gap-fill addition
|
|
17
|
+
* (not in the original Figma source).
|
|
18
|
+
*/
|
|
19
|
+
const NotificationCenter = Popover;
|
|
20
|
+
|
|
21
|
+
export interface NotificationCenterTriggerProps extends React.ComponentPropsWithoutRef<typeof Button> {
|
|
22
|
+
unreadCount?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NotificationCenterTrigger = React.forwardRef<HTMLButtonElement, NotificationCenterTriggerProps>(
|
|
26
|
+
({ className, unreadCount = 0, children, ...props }, ref) => (
|
|
27
|
+
<PopoverTrigger asChild>
|
|
28
|
+
<Button
|
|
29
|
+
ref={ref}
|
|
30
|
+
variant="Ghost"
|
|
31
|
+
size="icon"
|
|
32
|
+
aria-label={unreadCount > 0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
|
|
33
|
+
className={cn("relative", className)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{children ?? <Bell className="size-5" />}
|
|
37
|
+
{unreadCount > 0 && (
|
|
38
|
+
<span className="absolute right-1.5 top-1.5 flex size-2 rounded-full bg-destructive ring-2 ring-background" />
|
|
39
|
+
)}
|
|
40
|
+
</Button>
|
|
41
|
+
</PopoverTrigger>
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
NotificationCenterTrigger.displayName = "NotificationCenterTrigger";
|
|
45
|
+
|
|
46
|
+
export interface NotificationCenterContentProps extends React.ComponentPropsWithoutRef<typeof PopoverContent> {
|
|
47
|
+
onMarkAllRead?: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NotificationCenterContent = React.forwardRef<HTMLDivElement, NotificationCenterContentProps>(
|
|
51
|
+
({ className, onMarkAllRead, children, align = "end", ...props }, ref) => (
|
|
52
|
+
<PopoverContent ref={ref} align={align} className={cn("w-80 p-0", className)} {...props}>
|
|
53
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
|
54
|
+
<p className="text-title-sm font-medium text-foreground">Notifications</p>
|
|
55
|
+
{onMarkAllRead && (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={onMarkAllRead}
|
|
59
|
+
className="text-label-md font-medium text-primary outline-none hover:underline focus-visible:underline"
|
|
60
|
+
>
|
|
61
|
+
Mark all read
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
<div className="flex max-h-80 flex-col divide-y divide-border overflow-y-auto">{children}</div>
|
|
66
|
+
</PopoverContent>
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
NotificationCenterContent.displayName = "NotificationCenterContent";
|
|
70
|
+
|
|
71
|
+
export interface NotificationItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
72
|
+
title: React.ReactNode;
|
|
73
|
+
description?: React.ReactNode;
|
|
74
|
+
time?: React.ReactNode;
|
|
75
|
+
unread?: boolean;
|
|
76
|
+
onSelect?: () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const NotificationItem = React.forwardRef<HTMLDivElement, NotificationItemProps>(
|
|
80
|
+
({ className, title, description, time, unread, onSelect, ...props }, ref) => {
|
|
81
|
+
const interactive = !!onSelect;
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
ref={ref}
|
|
85
|
+
role={interactive ? "button" : undefined}
|
|
86
|
+
tabIndex={interactive ? 0 : undefined}
|
|
87
|
+
onClick={onSelect}
|
|
88
|
+
onKeyDown={
|
|
89
|
+
interactive
|
|
90
|
+
? (e) => {
|
|
91
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
onSelect?.();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
: undefined
|
|
97
|
+
}
|
|
98
|
+
className={cn(
|
|
99
|
+
"flex items-start gap-3 px-4 py-3 font-sans outline-none",
|
|
100
|
+
interactive && "cursor-pointer hover:bg-accent focus-visible:bg-accent",
|
|
101
|
+
className,
|
|
102
|
+
)}
|
|
103
|
+
{...props}
|
|
104
|
+
>
|
|
105
|
+
<span className={cn("mt-1.5 size-2 shrink-0 rounded-full", unread ? "bg-primary" : "bg-transparent")} />
|
|
106
|
+
<div className="min-w-0 flex-1">
|
|
107
|
+
<p className={cn("truncate text-body-sm", unread ? "font-medium text-foreground" : "text-muted-foreground")}>
|
|
108
|
+
{title}
|
|
109
|
+
</p>
|
|
110
|
+
{description && <p className="truncate text-body-sm text-muted-foreground">{description}</p>}
|
|
111
|
+
{time && <p className="mt-0.5 text-label-sm text-muted-foreground">{time}</p>}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
NotificationItem.displayName = "NotificationItem";
|
|
118
|
+
|
|
119
|
+
export { NotificationCenter, NotificationCenterTrigger, NotificationCenterContent, NotificationItem };
|