@kinetixui/ui 0.11.0 → 0.13.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 (39) hide show
  1. package/dist/index.d.ts +364 -1
  2. package/dist/index.js +2209 -493
  3. package/package.json +6 -2
  4. package/src/components/alert.tsx +1 -1
  5. package/src/components/app-bar.tsx +2 -2
  6. package/src/components/color-picker.tsx +352 -0
  7. package/src/components/data-grid.tsx +304 -0
  8. package/src/components/dialog.tsx +3 -2
  9. package/src/components/diff-viewer.tsx +155 -0
  10. package/src/components/direction-provider.tsx +27 -0
  11. package/src/components/drawer.tsx +1 -1
  12. package/src/components/dropdown-menu.tsx +9 -9
  13. package/src/components/input-group.tsx +2 -2
  14. package/src/components/json-viewer.tsx +168 -0
  15. package/src/components/kanban-board.tsx +247 -0
  16. package/src/components/markdown-editor.tsx +288 -0
  17. package/src/components/message-bubble.tsx +83 -0
  18. package/src/components/multi-select.tsx +134 -0
  19. package/src/components/native-select.tsx +2 -2
  20. package/src/components/notification-center.tsx +119 -0
  21. package/src/components/select.tsx +2 -2
  22. package/src/components/sheet.tsx +5 -2
  23. package/src/components/tour.tsx +156 -0
  24. package/src/components/tree-view.tsx +273 -0
  25. package/src/components/virtual-list.tsx +92 -0
  26. package/src/index.ts +24 -0
  27. package/src/stories/ColorPicker.stories.tsx +28 -0
  28. package/src/stories/DataGrid.stories.tsx +54 -0
  29. package/src/stories/DiffViewer.stories.tsx +26 -0
  30. package/src/stories/JsonViewer.stories.tsx +31 -0
  31. package/src/stories/KanbanBoard.stories.tsx +44 -0
  32. package/src/stories/MarkdownEditor.stories.tsx +25 -0
  33. package/src/stories/MessageBubble.stories.tsx +32 -0
  34. package/src/stories/MultiSelect.stories.tsx +35 -0
  35. package/src/stories/NotificationCenter.stories.tsx +41 -0
  36. package/src/stories/Tour.stories.tsx +70 -0
  37. package/src/stories/TreeView.stories.tsx +38 -0
  38. package/src/stories/VirtualList.stories.tsx +36 -0
  39. package/tailwind.config.ts +2 -0
@@ -0,0 +1,168 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Check, ChevronRight, Copy } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+
7
+ export interface JsonViewerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
8
+ data: unknown;
9
+ /** tree depth (root = 0) to auto-expand; deeper nodes start collapsed. Default 1. */
10
+ expandDepth?: number;
11
+ hideCopy?: boolean;
12
+ }
13
+
14
+ /**
15
+ * JsonViewer — a collapsible, syntax-colored tree for arbitrary JSON data
16
+ * (API responses, registry payloads, token diffs — a dev-tool surface the
17
+ * site itself needs for `/docs/kinetixui-json` and friends). Distinct from
18
+ * `TreeView`: this renders a *data structure* (object/array/primitive),
19
+ * not a caller-composed hierarchy of `TreeItem`s, so expand state is
20
+ * per-node uncontrolled rather than a lifted `expanded` prop — there's no
21
+ * stable "value" to key a node by beyond its position in the data, and
22
+ * nothing external needs to drive which nodes are open. Value colors reuse
23
+ * the existing semantic tokens (`--success`/`--info`/`--warning`) rather
24
+ * than introducing a separate syntax-highlighting palette. Gap-fill
25
+ * addition (not in the original Figma source).
26
+ */
27
+ const JsonViewer = React.forwardRef<HTMLDivElement, JsonViewerProps>(
28
+ ({ data, expandDepth = 1, hideCopy, className, ...props }, ref) => {
29
+ const [copied, setCopied] = React.useState(false);
30
+
31
+ const copy = async () => {
32
+ try {
33
+ await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
34
+ setCopied(true);
35
+ window.setTimeout(() => setCopied(false), 1500);
36
+ } catch {
37
+ // clipboard unavailable — no-op
38
+ }
39
+ };
40
+
41
+ return (
42
+ <div
43
+ ref={ref}
44
+ className={cn("relative overflow-auto rounded-md border bg-muted/30 p-3 font-mono text-xs", className)}
45
+ {...props}
46
+ >
47
+ {!hideCopy && (
48
+ <button
49
+ type="button"
50
+ onClick={copy}
51
+ aria-label="Copy JSON"
52
+ className="absolute right-2 top-2 inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
53
+ >
54
+ {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
55
+ </button>
56
+ )}
57
+ <div role="tree">
58
+ <JsonNode value={data} depth={0} expandDepth={expandDepth} isLast />
59
+ </div>
60
+ </div>
61
+ );
62
+ },
63
+ );
64
+ JsonViewer.displayName = "JsonViewer";
65
+
66
+ function JsonNode({
67
+ name,
68
+ value,
69
+ depth,
70
+ expandDepth,
71
+ isLast,
72
+ }: {
73
+ name?: string;
74
+ value: unknown;
75
+ depth: number;
76
+ expandDepth: number;
77
+ isLast: boolean;
78
+ }) {
79
+ const isContainer = value !== null && typeof value === "object";
80
+ const [expanded, setExpanded] = React.useState(depth < expandDepth);
81
+
82
+ if (!isContainer) {
83
+ return (
84
+ <div role="treeitem" style={{ paddingLeft: depth * 16 }}>
85
+ {name !== undefined && <span className="text-muted-foreground">{JSON.stringify(name)}: </span>}
86
+ <JsonPrimitive value={value} />
87
+ {!isLast && <span className="text-muted-foreground">,</span>}
88
+ </div>
89
+ );
90
+ }
91
+
92
+ const isArray = Array.isArray(value);
93
+ const entries: [string, unknown][] = isArray
94
+ ? (value as unknown[]).map((v, i) => [String(i), v])
95
+ : Object.entries(value as Record<string, unknown>);
96
+ const openBracket = isArray ? "[" : "{";
97
+ const closeBracket = isArray ? "]" : "}";
98
+
99
+ if (entries.length === 0) {
100
+ return (
101
+ <div role="treeitem" style={{ paddingLeft: depth * 16 + 20 }}>
102
+ {name !== undefined && <span className="text-muted-foreground">{JSON.stringify(name)}: </span>}
103
+ <span>
104
+ {openBracket}
105
+ {closeBracket}
106
+ </span>
107
+ {!isLast && <span className="text-muted-foreground">,</span>}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ return (
113
+ <div role="treeitem" aria-expanded={expanded}>
114
+ <div style={{ paddingLeft: depth * 16 }} className="flex items-start gap-1">
115
+ <button
116
+ type="button"
117
+ onClick={() => setExpanded((e) => !e)}
118
+ aria-label={expanded ? "Collapse" : "Expand"}
119
+ className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground"
120
+ >
121
+ <ChevronRight className={cn("size-3 transition-transform", expanded && "rotate-90")} />
122
+ </button>
123
+ <span>
124
+ {name !== undefined && <span className="text-muted-foreground">{JSON.stringify(name)}: </span>}
125
+ <span>{openBracket}</span>
126
+ {!expanded && (
127
+ <span className="text-muted-foreground">
128
+ {" "}
129
+ {entries.length} {isArray ? "item" : "key"}
130
+ {entries.length === 1 ? "" : "s"}{" "}
131
+ </span>
132
+ )}
133
+ {!expanded && <span>{closeBracket}</span>}
134
+ {!expanded && !isLast && <span className="text-muted-foreground">,</span>}
135
+ </span>
136
+ </div>
137
+ {expanded && (
138
+ <>
139
+ {entries.map(([key, val], i) => (
140
+ <JsonNode
141
+ key={key}
142
+ name={isArray ? undefined : key}
143
+ value={val}
144
+ depth={depth + 1}
145
+ expandDepth={expandDepth}
146
+ isLast={i === entries.length - 1}
147
+ />
148
+ ))}
149
+ <div style={{ paddingLeft: depth * 16 }}>
150
+ <span>{closeBracket}</span>
151
+ {!isLast && <span className="text-muted-foreground">,</span>}
152
+ </div>
153
+ </>
154
+ )}
155
+ </div>
156
+ );
157
+ }
158
+
159
+ function JsonPrimitive({ value }: { value: unknown }) {
160
+ if (value === null) return <span className="italic text-muted-foreground">null</span>;
161
+ if (value === undefined) return <span className="italic text-muted-foreground">undefined</span>;
162
+ if (typeof value === "string") return <span className="text-success">{JSON.stringify(value)}</span>;
163
+ if (typeof value === "number") return <span className="text-info">{value}</span>;
164
+ if (typeof value === "boolean") return <span className="text-warning">{String(value)}</span>;
165
+ return <span>{String(value)}</span>;
166
+ }
167
+
168
+ export { JsonViewer };
@@ -0,0 +1,247 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ DndContext,
6
+ DragOverlay,
7
+ KeyboardSensor,
8
+ PointerSensor,
9
+ closestCenter,
10
+ getFirstCollision,
11
+ pointerWithin,
12
+ rectIntersection,
13
+ useDroppable,
14
+ useSensor,
15
+ useSensors,
16
+ type CollisionDetection,
17
+ type DragEndEvent,
18
+ type DragOverEvent,
19
+ type DragStartEvent,
20
+ } from "@dnd-kit/core";
21
+ import {
22
+ SortableContext,
23
+ arrayMove,
24
+ sortableKeyboardCoordinates,
25
+ useSortable,
26
+ verticalListSortingStrategy,
27
+ } from "@dnd-kit/sortable";
28
+ import { CSS } from "@dnd-kit/utilities";
29
+ import { tokens } from "@kinetixui/tokens";
30
+ import { cn } from "../lib/utils";
31
+
32
+ export interface KanbanCard {
33
+ id: string;
34
+ content: React.ReactNode;
35
+ }
36
+
37
+ export interface KanbanColumn {
38
+ id: string;
39
+ title: string;
40
+ cards: KanbanCard[];
41
+ }
42
+
43
+ export interface KanbanBoardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
44
+ columns: KanbanColumn[];
45
+ onColumnsChange: (columns: KanbanColumn[]) => void;
46
+ }
47
+
48
+ /**
49
+ * KanbanBoard — draggable cards across columns, with keyboard DnD. No DnD
50
+ * primitive existed in this package at all, and hand-rolling accessible
51
+ * drag-and-drop (pointer + touch + keyboard, collision detection, live
52
+ * screen-reader announcements) from scratch would both take far longer
53
+ * than this component's own logic and likely be worse than a battle-tested
54
+ * library — unlike `DiffViewer`'s hand-rolled LCS diff, there's no
55
+ * cross-platform-identical-algorithm argument here, since each native
56
+ * platform reaches for its own idiomatic drag primitive anyway (Compose's
57
+ * drag gestures, SwiftUI's `.draggable`/`.dropDestination`, Flutter's
58
+ * `Draggable`/`DragTarget`). `@dnd-kit` is the dependency: no runtime CSS,
59
+ * accessible by default, and this is exactly its documented "multiple
60
+ * containers" sortable pattern — `onDragOver` re-parents a card into the
61
+ * hovered column live (the standard approach; a little re-render churn
62
+ * mid-drag is expected/upstream-documented), `onDragEnd` commits the
63
+ * final within-column reorder. Column order itself isn't draggable — the
64
+ * `COMPONENT-ADDITIONS.md` entry only asked for card dragging.
65
+ *
66
+ * `collisionDetectionStrategy` below is dnd-kit's own documented fix for a
67
+ * specific multi-container gotcha: each column is both a `useDroppable`
68
+ * (so an empty column, or the space below its last card, is still a valid
69
+ * drop target) and wraps a `SortableContext` of its cards. A column's rect
70
+ * is a strict superset of its cards' rects, so plain `closestCorners` (or
71
+ * `closestCenter`/`rectIntersection` alone) almost always reports the
72
+ * column itself as the collision, never a specific card underneath — over
73
+ * gets stuck on the column id and item-level reordering never fires. The
74
+ * fix (lifted from dnd-kit's own multiple-containers example): try
75
+ * `pointerWithin` first, fall back to `rectIntersection`, and if the
76
+ * winning id is a column, re-run `closestCenter` scoped to just that
77
+ * column's cards to find the specific one under the pointer.
78
+ */
79
+ const KanbanBoard = React.forwardRef<HTMLDivElement, KanbanBoardProps>(
80
+ ({ columns, onColumnsChange, className, ...props }, ref) => {
81
+ const sensors = useSensors(
82
+ useSensor(PointerSensor, { activationConstraint: { distance: tokens.interaction.drag.threshold } }),
83
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
84
+ );
85
+ const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null);
86
+ const lastOverId = React.useRef<string | null>(null);
87
+
88
+ const findColumn = React.useCallback(
89
+ (id: string) => columns.find((col) => col.id === id || col.cards.some((c) => c.id === id)),
90
+ [columns],
91
+ );
92
+
93
+ const collisionDetectionStrategy: CollisionDetection = React.useCallback(
94
+ (args) => {
95
+ const pointerIntersections = pointerWithin(args);
96
+ const intersections = pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args);
97
+ let overId = getFirstCollision(intersections, "id");
98
+
99
+ if (overId != null) {
100
+ const overColumn = columns.find((col) => col.id === overId);
101
+ if (overColumn && overColumn.cards.length > 0) {
102
+ const cardIds = new Set(overColumn.cards.map((c) => c.id));
103
+ const cardCollision = getFirstCollision(
104
+ closestCenter({
105
+ ...args,
106
+ droppableContainers: args.droppableContainers.filter((c) => cardIds.has(String(c.id))),
107
+ }),
108
+ "id",
109
+ );
110
+ if (cardCollision != null) overId = cardCollision;
111
+ }
112
+ lastOverId.current = String(overId);
113
+ return [{ id: overId }];
114
+ }
115
+
116
+ return lastOverId.current ? [{ id: lastOverId.current }] : [];
117
+ },
118
+ [columns],
119
+ );
120
+
121
+ function handleDragStart(event: DragStartEvent) {
122
+ lastOverId.current = String(event.active.id);
123
+ const column = findColumn(String(event.active.id));
124
+ const card = column?.cards.find((c) => c.id === event.active.id);
125
+ setActiveCard(card ?? null);
126
+ }
127
+
128
+ function handleDragOver(event: DragOverEvent) {
129
+ const { active, over } = event;
130
+ if (!over) return;
131
+ const activeId = String(active.id);
132
+ const overId = String(over.id);
133
+ if (activeId === overId) return;
134
+
135
+ const activeColumn = findColumn(activeId);
136
+ const overColumn = findColumn(overId);
137
+ if (!activeColumn || !overColumn || activeColumn.id === overColumn.id) return;
138
+
139
+ onColumnsChange(
140
+ columns.map((col) => {
141
+ if (col.id === activeColumn.id) {
142
+ return { ...col, cards: col.cards.filter((c) => c.id !== activeId) };
143
+ }
144
+ if (col.id === overColumn.id) {
145
+ const movedCard = activeColumn.cards.find((c) => c.id === activeId);
146
+ if (!movedCard) return col;
147
+ const overIndex = col.cards.findIndex((c) => c.id === overId);
148
+ const insertAt = overIndex >= 0 ? overIndex : col.cards.length;
149
+ const nextCards = [...col.cards];
150
+ nextCards.splice(insertAt, 0, movedCard);
151
+ return { ...col, cards: nextCards };
152
+ }
153
+ return col;
154
+ }),
155
+ );
156
+ }
157
+
158
+ function handleDragEnd(event: DragEndEvent) {
159
+ setActiveCard(null);
160
+ lastOverId.current = null;
161
+ const { active, over } = event;
162
+ if (!over) return;
163
+ const activeId = String(active.id);
164
+ const overId = String(over.id);
165
+ const activeColumn = findColumn(activeId);
166
+ // overId may be the column itself (dropped in empty space / an empty column)
167
+ const overColumn = columns.find((col) => col.id === overId) ?? findColumn(overId);
168
+ if (!activeColumn || !overColumn || activeColumn.id !== overColumn.id) return;
169
+
170
+ const activeIndex = activeColumn.cards.findIndex((c) => c.id === activeId);
171
+ const rawOverIndex = overColumn.cards.findIndex((c) => c.id === overId);
172
+ const overIndex = rawOverIndex >= 0 ? rawOverIndex : overColumn.cards.length - 1;
173
+ if (activeIndex === -1 || overIndex === -1 || activeIndex === overIndex) return;
174
+
175
+ onColumnsChange(
176
+ columns.map((col) =>
177
+ col.id === activeColumn.id ? { ...col, cards: arrayMove(col.cards, activeIndex, overIndex) } : col,
178
+ ),
179
+ );
180
+ }
181
+
182
+ return (
183
+ <DndContext
184
+ sensors={sensors}
185
+ collisionDetection={collisionDetectionStrategy}
186
+ onDragStart={handleDragStart}
187
+ onDragOver={handleDragOver}
188
+ onDragEnd={handleDragEnd}
189
+ onDragCancel={() => {
190
+ setActiveCard(null);
191
+ lastOverId.current = null;
192
+ }}
193
+ >
194
+ <div ref={ref} role="group" className={cn("flex items-start gap-4 overflow-x-auto font-sans", className)} {...props}>
195
+ {columns.map((column) => (
196
+ <KanbanColumnView key={column.id} column={column} />
197
+ ))}
198
+ </div>
199
+ <DragOverlay>{activeCard ? <KanbanCardView card={activeCard} /> : null}</DragOverlay>
200
+ </DndContext>
201
+ );
202
+ },
203
+ );
204
+ KanbanBoard.displayName = "KanbanBoard";
205
+
206
+ function KanbanColumnView({ column }: { column: KanbanColumn }) {
207
+ const { setNodeRef } = useDroppable({ id: column.id });
208
+ return (
209
+ <div className="flex w-64 shrink-0 flex-col gap-2 rounded-md border bg-muted/30 p-2">
210
+ <div className="flex items-center justify-between px-1 py-1">
211
+ <span className="text-sm font-medium text-foreground">{column.title}</span>
212
+ <span className="text-xs text-muted-foreground">{column.cards.length}</span>
213
+ </div>
214
+ <SortableContext items={column.cards.map((c) => c.id)} strategy={verticalListSortingStrategy}>
215
+ <div ref={setNodeRef} className="flex min-h-10 flex-col gap-2">
216
+ {column.cards.map((card) => (
217
+ <SortableKanbanCard key={card.id} card={card} />
218
+ ))}
219
+ </div>
220
+ </SortableContext>
221
+ </div>
222
+ );
223
+ }
224
+
225
+ function SortableKanbanCard({ card }: { card: KanbanCard }) {
226
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: card.id });
227
+ const style: React.CSSProperties = {
228
+ transform: CSS.Transform.toString(transform),
229
+ transition,
230
+ opacity: isDragging ? 0.4 : 1,
231
+ };
232
+ return (
233
+ <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
234
+ <KanbanCardView card={card} />
235
+ </div>
236
+ );
237
+ }
238
+
239
+ function KanbanCardView({ card }: { card: KanbanCard }) {
240
+ return (
241
+ <div className="cursor-grab touch-none rounded-md border bg-background p-3 text-sm text-foreground shadow-sm outline-none active:cursor-grabbing focus-visible:ring-1 focus-visible:ring-ring">
242
+ {card.content}
243
+ </div>
244
+ );
245
+ }
246
+
247
+ export { KanbanBoard };