@vvedantb/ui 0.17.1 → 0.18.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 (129) hide show
  1. package/README.md +251 -3
  2. package/package.json +13 -5
  3. package/shell/src/SidebarResize.tsx +1 -4
  4. package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
  5. package/src/ai-elements/activity-question-utils.ts +26 -0
  6. package/src/ai-elements/activity-question.tsx +159 -0
  7. package/src/ai-elements/activity-shared.tsx +459 -0
  8. package/src/ai-elements/activity-step-detail.tsx +115 -0
  9. package/src/ai-elements/activity-step-label.ts +981 -0
  10. package/src/ai-elements/activity-tasks-utils.ts +199 -0
  11. package/src/ai-elements/activity-tasks.tsx +770 -0
  12. package/src/ai-elements/artifact.tsx +162 -52
  13. package/src/ai-elements/attachments.tsx +62 -32
  14. package/src/ai-elements/code-block.tsx +76 -22
  15. package/src/ai-elements/context.tsx +370 -0
  16. package/src/ai-elements/conversation.tsx +186 -62
  17. package/src/ai-elements/file-chip.tsx +53 -0
  18. package/src/ai-elements/message.tsx +161 -50
  19. package/src/ai-elements/model-picker-content.tsx +505 -0
  20. package/src/ai-elements/model-picker-types.ts +56 -0
  21. package/src/ai-elements/model-picker.tsx +301 -0
  22. package/src/ai-elements/model-selector.tsx +102 -21
  23. package/src/ai-elements/plan.tsx +130 -0
  24. package/src/ai-elements/prompt-input-settings.tsx +144 -0
  25. package/src/ai-elements/prompt-input-speech.tsx +189 -0
  26. package/src/ai-elements/prompt-input-traits.tsx +281 -0
  27. package/src/ai-elements/prompt-input.tsx +320 -134
  28. package/src/ai-elements/provider-icon.tsx +133 -0
  29. package/src/ai-elements/queue.tsx +293 -9
  30. package/src/ai-elements/shimmer.tsx +72 -1
  31. package/src/ai-elements/simple-model-ladder.tsx +108 -0
  32. package/src/ai-elements/test-results.tsx +510 -0
  33. package/src/ai-elements/trace.tsx +243 -0
  34. package/src/ai-elements/use-streaming-disclosure.ts +50 -0
  35. package/src/ai-elements/web-preview.tsx +349 -0
  36. package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
  37. package/src/index.ts +66 -12
  38. package/src/kibo/avatar-stack.tsx +40 -0
  39. package/src/kibo/contribution-graph.tsx +539 -0
  40. package/src/kibo/cursor.tsx +72 -0
  41. package/src/kibo/gantt-features.tsx +439 -0
  42. package/src/kibo/gantt-header.tsx +181 -0
  43. package/src/kibo/gantt-provider.tsx +657 -0
  44. package/src/kibo/gantt-sidebar.tsx +157 -0
  45. package/src/kibo/gantt-timeline.tsx +154 -0
  46. package/src/kibo/index.ts +151 -0
  47. package/src/kibo/kanban.tsx +338 -0
  48. package/src/kibo/kanbanDropTarget.ts +25 -0
  49. package/src/kibo/list.tsx +162 -0
  50. package/src/kibo/theme-switcher.tsx +109 -0
  51. package/src/kibo/video-player.tsx +237 -0
  52. package/src/markdown/Markdown.tsx +112 -0
  53. package/src/markdown/MarkdownBlockquote.tsx +45 -0
  54. package/src/markdown/MarkdownCodeBlock.tsx +197 -0
  55. package/src/markdown/MarkdownInlineCode.tsx +40 -0
  56. package/src/markdown/MarkdownLink.tsx +92 -0
  57. package/src/markdown/codeFence.ts +57 -0
  58. package/src/markdown/codeHighlight.ts +138 -0
  59. package/src/markdown/incrementalBlocks.ts +182 -0
  60. package/src/markdown/index.ts +3 -0
  61. package/src/markdown/markdownClipboard.ts +178 -0
  62. package/src/markdown/mermaid.ts +155 -0
  63. package/src/markdown/rehypeGithubAlerts.ts +80 -0
  64. package/src/motion/presets.ts +24 -0
  65. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
  66. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
  67. package/src/ui/_menu-parts.tsx +29 -0
  68. package/src/ui/accordion.alt.tsx +46 -53
  69. package/src/ui/accordion.tsx +59 -10
  70. package/src/ui/avatar.tsx +56 -0
  71. package/src/ui/badge.tsx +45 -21
  72. package/src/ui/border-beam.tsx +97 -0
  73. package/src/ui/button-group.tsx +50 -21
  74. package/src/ui/button.tsx +67 -45
  75. package/src/ui/calendar.tsx +48 -5
  76. package/src/ui/card.tsx +77 -8
  77. package/src/ui/carousel.tsx +60 -0
  78. package/src/ui/centered-spinner.tsx +30 -0
  79. package/src/ui/checkbox.tsx +34 -16
  80. package/src/ui/clear-input.alt.tsx +33 -75
  81. package/src/ui/clear-input.tsx +140 -15
  82. package/src/ui/collapsible.tsx +40 -2
  83. package/src/ui/command.tsx +143 -70
  84. package/src/ui/context-menu.tsx +159 -93
  85. package/src/ui/crossfade-icon.tsx +123 -0
  86. package/src/ui/dialog.tsx +154 -70
  87. package/src/ui/dropdown-menu.tsx +177 -117
  88. package/src/ui/hover-card.tsx +59 -17
  89. package/src/ui/input-group.alt.tsx +83 -83
  90. package/src/ui/input-group.tsx +129 -58
  91. package/src/ui/input.tsx +13 -4
  92. package/src/ui/label.tsx +11 -8
  93. package/src/ui/list-row.tsx +232 -0
  94. package/src/ui/loading-state.tsx +215 -0
  95. package/src/ui/popover.tsx +56 -23
  96. package/src/ui/progress.tsx +28 -16
  97. package/src/ui/refresh-spin-icon.tsx +41 -0
  98. package/src/ui/search-input.tsx +66 -15
  99. package/src/ui/select.tsx +171 -100
  100. package/src/ui/separator.tsx +23 -14
  101. package/src/ui/slider.tsx +21 -7
  102. package/src/ui/sonner.tsx +68 -30
  103. package/src/ui/spinner.tsx +229 -11
  104. package/src/ui/status-dot.tsx +84 -0
  105. package/src/ui/surface.tsx +49 -0
  106. package/src/ui/switch.tsx +29 -15
  107. package/src/ui/table.tsx +38 -16
  108. package/src/ui/tabs.tsx +123 -35
  109. package/src/ui/tabsSliding.ts +33 -1
  110. package/src/ui/textarea.tsx +13 -4
  111. package/src/ui/tooltip.tsx +50 -14
  112. package/src/ui/ui-config.tsx +149 -0
  113. package/src/utils/cat-color.ts +123 -0
  114. package/src/utils/dayjs.ts +6 -0
  115. package/src/utils/gesture.ts +59 -0
  116. package/src/utils/runtimeVisibility.ts +167 -0
  117. package/src/utils/sharedClock.ts +96 -0
  118. package/src/utils/surface-radius.ts +16 -0
  119. package/src/utils/useDragSensors.ts +58 -0
  120. package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
  121. package/tokens/examples/tokens-moved/README.md +20 -0
  122. package/tokens/examples/tokens-moved/index.html +12 -0
  123. package/tokens/examples/tokens-moved/main.tsx +6 -0
  124. package/tokens/examples/tokens-moved/package.json +14 -0
  125. package/tokens/examples/tokens-moved/styles.css +12 -0
  126. package/tokens/examples/tokens-moved/tsconfig.json +14 -0
  127. package/tokens/examples/tokens-moved/vite.config.ts +11 -0
  128. package/tokens/src/css/border-beam.css +116 -0
  129. package/tokens/src/css/markdown.css +318 -0
@@ -0,0 +1,338 @@
1
+ "use client";
2
+
3
+ import type {
4
+ Announcements,
5
+ CollisionDetection,
6
+ DragCancelEvent,
7
+ DragEndEvent,
8
+ DragOverEvent,
9
+ DragStartEvent,
10
+ } from "@dnd-kit/core";
11
+ import {
12
+ closestCenter,
13
+ DndContext,
14
+ DragOverlay,
15
+ useDroppable,
16
+ } from "@dnd-kit/core";
17
+ import { SortableContext, useSortable } from "@dnd-kit/sortable";
18
+ import { CSS } from "@dnd-kit/utilities";
19
+ import {
20
+ createContext,
21
+ type HTMLAttributes,
22
+ type ReactNode,
23
+ useContext,
24
+ useState,
25
+ } from "react";
26
+ import { createPortal } from "react-dom";
27
+ import { useCn, useUiConfig } from "../ui/ui-config";
28
+ import { SURFACE_RADIUS_CLASS } from "../utils/surface-radius";
29
+ import { useDragSensors } from "../utils/useDragSensors";
30
+ import { resolveOverColumnId } from "./kanbanDropTarget";
31
+
32
+ export interface KanbanConfig {
33
+ /** Replaces the built-in classes of a part. */
34
+ classes?: {
35
+ /**
36
+ * `KanbanCard`: grab cursor, the transitions it runs itself and press
37
+ * feedback. The radius and the dragging state are added on top.
38
+ */
39
+ card?: string;
40
+ };
41
+ /**
42
+ * The transitions `KanbanCard` runs itself (press scale and drag fade),
43
+ * appended to the reorder transition dnd-kit writes inline. A CSS
44
+ * `transition` list.
45
+ */
46
+ cardTransition?: string;
47
+ }
48
+
49
+ const defaultClasses = {
50
+ card: "cursor-grab transition-[opacity,transform,scale] duration-150 active:scale-[0.98]",
51
+ };
52
+
53
+ const DEFAULT_CARD_TRANSITION =
54
+ "scale 150ms cubic-bezier(0.22, 1, 0.36, 1), opacity 150ms cubic-bezier(0.22, 1, 0.36, 1)";
55
+
56
+ export type KanbanItem = {
57
+ id: string;
58
+ name: string;
59
+ column: string;
60
+ };
61
+
62
+ export type KanbanColumnDef = {
63
+ id: string;
64
+ name: string;
65
+ };
66
+
67
+ type KanbanContextValue = {
68
+ columns: KanbanColumnDef[];
69
+ data: KanbanItem[];
70
+ activeCardId: string | null;
71
+ /**
72
+ * Column the dragged card would land in. Distinct from a column's own
73
+ * `isOver`: pointing at a card resolves `over` to that card, so the column
74
+ * under the pointer never reports `isOver` once it holds any cards.
75
+ */
76
+ overColumnId: string | null;
77
+ };
78
+
79
+ const KanbanContext = createContext<KanbanContextValue>({
80
+ columns: [],
81
+ data: [],
82
+ activeCardId: null,
83
+ overColumnId: null,
84
+ });
85
+
86
+ export const useKanbanContext = () => useContext(KanbanContext);
87
+
88
+ // --- KanbanBoard: droppable column zone ---
89
+
90
+ export type KanbanBoardProps = {
91
+ id: string;
92
+ children: ReactNode;
93
+ className?: string;
94
+ disabled?: boolean;
95
+ };
96
+
97
+ export const KanbanBoard = ({
98
+ id,
99
+ children,
100
+ className,
101
+ disabled = false,
102
+ }: KanbanBoardProps) => {
103
+ const { isOver, setNodeRef } = useDroppable({ id, disabled });
104
+ const { overColumnId } = useKanbanContext();
105
+ const merge = useCn();
106
+ const isDropTarget = !disabled && (isOver || overColumnId === id);
107
+
108
+ return (
109
+ <div
110
+ className={merge(
111
+ "flex size-full min-h-40 flex-col overflow-hidden bg-surface-secondary/40 text-xs transition-[background-color,border-color]",
112
+ SURFACE_RADIUS_CLASS,
113
+ className,
114
+ // After `className` on purpose: callers set a column background, and
115
+ // tailwind-merge keeps whichever `bg-*` / `border-*` comes last — so
116
+ // the drop highlight has to be the last word or it never shows.
117
+ isDropTarget && "border border-primary/40 bg-primary/10",
118
+ )}
119
+ ref={setNodeRef}
120
+ >
121
+ {children}
122
+ </div>
123
+ );
124
+ };
125
+
126
+ // --- KanbanCard: sortable item wrapper ---
127
+
128
+ export type KanbanCardProps = {
129
+ id: string;
130
+ children: ReactNode;
131
+ className?: string;
132
+ onClick?: () => void;
133
+ };
134
+
135
+ export const KanbanCard = ({
136
+ id,
137
+ children,
138
+ className,
139
+ onClick,
140
+ }: KanbanCardProps) => {
141
+ const {
142
+ attributes,
143
+ listeners,
144
+ setNodeRef,
145
+ transition,
146
+ transform,
147
+ isDragging,
148
+ } = useSortable({ id });
149
+ const config = useUiConfig("Kanban");
150
+ const classes = { ...defaultClasses, ...config?.classes };
151
+ const merge = useCn();
152
+
153
+ const style = {
154
+ /**
155
+ * `useSortable` hands back a `transition` string, and putting it in inline
156
+ * style outranks every `transition-*` class on the element — the shorthand
157
+ * resets `transition-property`, so while a sort is animating the class below
158
+ * is not merely overridden on duration, it stops applying at all and the
159
+ * press scale snaps. dnd-kit's own entry only ever names `transform`.
160
+ *
161
+ * Appending the two properties the card animates itself keeps the reflow
162
+ * tween dnd-kit wants and the press and drag-fade the card wants. The value
163
+ * is `undefined` when nothing is sorting, which is why it is filtered rather
164
+ * than interpolated.
165
+ */
166
+ transition: [transition, config?.cardTransition ?? DEFAULT_CARD_TRANSITION]
167
+ .filter(Boolean)
168
+ .join(", "),
169
+ transform: CSS.Transform.toString(transform),
170
+ };
171
+
172
+ return (
173
+ <div
174
+ style={style}
175
+ {...listeners}
176
+ {...attributes}
177
+ ref={setNodeRef}
178
+ onClick={onClick}
179
+ className={merge(
180
+ // Press feedback on pointer-down, not on release — the card has to
181
+ // acknowledge the grab before the drag threshold is crossed, or the
182
+ // first few pixels of every drag read as a dead control.
183
+ //
184
+ // `scale` is named explicitly alongside `transform`: an arbitrary
185
+ // `transition-[…]` emits exactly the properties listed, and Tailwind
186
+ // compiles `scale-[0.98]` to the individual `scale` property, which
187
+ // `transform` does not match — so this press was a no-op. `transform`
188
+ // stays for the inline one `useSortable` writes below.
189
+ classes.card,
190
+ SURFACE_RADIUS_CLASS,
191
+ isDragging && "pointer-events-none cursor-grabbing opacity-30",
192
+ className,
193
+ )}
194
+ >
195
+ {children}
196
+ </div>
197
+ );
198
+ };
199
+
200
+ // --- KanbanCards: SortableContext + auto-filter by column from context ---
201
+
202
+ export type KanbanCardsProps = Omit<
203
+ HTMLAttributes<HTMLDivElement>,
204
+ "children" | "id"
205
+ > & {
206
+ children: (item: KanbanItem) => ReactNode;
207
+ id: string;
208
+ };
209
+
210
+ export const KanbanCards = ({
211
+ children,
212
+ className,
213
+ id,
214
+ ...props
215
+ }: KanbanCardsProps) => {
216
+ const { data } = useKanbanContext();
217
+ const filteredData = data.filter((item) => item.column === id);
218
+ const itemIds = filteredData.map((item) => item.id);
219
+ const merge = useCn();
220
+
221
+ return (
222
+ <SortableContext items={itemIds}>
223
+ <div className={merge("flex grow flex-col gap-1.5", className)} {...props}>
224
+ {filteredData.map(children)}
225
+ </div>
226
+ </SortableContext>
227
+ );
228
+ };
229
+
230
+ // --- KanbanHeader: simple styled header ---
231
+
232
+ export type KanbanHeaderProps = HTMLAttributes<HTMLDivElement>;
233
+
234
+ export const KanbanHeader = ({ className, ...props }: KanbanHeaderProps) => {
235
+ const merge = useCn();
236
+ return <div className={merge("m-0 p-2 font-semibold text-sm", className)} {...props} />;
237
+ };
238
+
239
+ // --- KanbanProvider: DnD context + Kanban context + a11y ---
240
+
241
+ export type KanbanProviderProps = {
242
+ children: ReactNode;
243
+ columns: KanbanColumnDef[];
244
+ data: KanbanItem[];
245
+ overlay?: ReactNode;
246
+ className?: string;
247
+ onDragStart?: (event: DragStartEvent) => void;
248
+ onDragEnd?: (event: DragEndEvent) => void;
249
+ onDragOver?: (event: DragOverEvent) => void;
250
+ onDragCancel?: (event: DragCancelEvent) => void;
251
+ collisionDetection?: CollisionDetection;
252
+ };
253
+
254
+ export const KanbanProvider = ({
255
+ children,
256
+ columns,
257
+ data,
258
+ overlay,
259
+ className,
260
+ onDragStart,
261
+ onDragEnd,
262
+ onDragOver,
263
+ onDragCancel,
264
+ collisionDetection = closestCenter,
265
+ }: KanbanProviderProps) => {
266
+ const [activeCardId, setActiveCardId] = useState<string | null>(null);
267
+ const [overColumnId, setOverColumnId] = useState<string | null>(null);
268
+
269
+ const sensors = useDragSensors({ sortable: true });
270
+
271
+ const handleDragStart = (event: DragStartEvent) => {
272
+ const card = data.find((item) => item.id === event.active.id);
273
+ if (card) {
274
+ setActiveCardId(String(event.active.id));
275
+ }
276
+ onDragStart?.(event);
277
+ };
278
+
279
+ const handleDragOver = (event: DragOverEvent) => {
280
+ const overId = event.over ? String(event.over.id) : null;
281
+ setOverColumnId(resolveOverColumnId(overId, columns, data));
282
+ onDragOver?.(event);
283
+ };
284
+
285
+ const handleDragEnd = (event: DragEndEvent) => {
286
+ setActiveCardId(null);
287
+ setOverColumnId(null);
288
+ onDragEnd?.(event);
289
+ };
290
+
291
+ const handleDragCancel = (event: DragCancelEvent) => {
292
+ setActiveCardId(null);
293
+ setOverColumnId(null);
294
+ onDragCancel?.(event);
295
+ };
296
+
297
+ const announcements: Announcements = {
298
+ onDragStart({ active }) {
299
+ const item = data.find((d) => d.id === active.id);
300
+ const col = columns.find((c) => c.id === item?.column);
301
+ return `Picked up card "${item?.name}" from "${col?.name}" column`;
302
+ },
303
+ onDragOver({ active, over }) {
304
+ const item = data.find((d) => d.id === active.id);
305
+ const col = columns.find((c) => c.id === over?.id);
306
+ return `Dragged card "${item?.name}" over "${col?.name}" column`;
307
+ },
308
+ onDragEnd({ active, over }) {
309
+ const item = data.find((d) => d.id === active.id);
310
+ const col = columns.find((c) => c.id === over?.id);
311
+ return `Dropped card "${item?.name}" into "${col?.name}" column`;
312
+ },
313
+ onDragCancel({ active }) {
314
+ const item = data.find((d) => d.id === active.id);
315
+ return `Cancelled dragging card "${item?.name}"`;
316
+ },
317
+ };
318
+
319
+ return (
320
+ <KanbanContext.Provider
321
+ value={{ columns, data, activeCardId, overColumnId }}
322
+ >
323
+ <DndContext
324
+ accessibility={{ announcements }}
325
+ collisionDetection={collisionDetection}
326
+ onDragEnd={handleDragEnd}
327
+ onDragOver={handleDragOver}
328
+ onDragStart={handleDragStart}
329
+ onDragCancel={handleDragCancel}
330
+ sensors={sensors}
331
+ >
332
+ {className ? <div className={className}>{children}</div> : children}
333
+ {typeof window !== "undefined" &&
334
+ createPortal(<DragOverlay>{overlay}</DragOverlay>, document.body)}
335
+ </DndContext>
336
+ </KanbanContext.Provider>
337
+ );
338
+ };
@@ -0,0 +1,25 @@
1
+ import type { KanbanColumnDef, KanbanItem } from "./kanban";
2
+
3
+ /**
4
+ * The column a dragged card would land in, given whatever dnd-kit reports as
5
+ * `over`.
6
+ *
7
+ * `over` is the column only while the pointer is on that column's empty space —
8
+ * once it is on a card, `over` is the card. A column that relies on its own
9
+ * `isOver` therefore stops highlighting the moment it holds anything, which is
10
+ * every column that matters. Resolving the card back to its column is what makes
11
+ * the drop target visible during a real drag.
12
+ */
13
+ export function resolveOverColumnId(
14
+ overId: string | null,
15
+ columns: KanbanColumnDef[],
16
+ data: KanbanItem[],
17
+ ): string | null {
18
+ if (overId === null) {
19
+ return null;
20
+ }
21
+ if (columns.some((column) => column.id === overId)) {
22
+ return overId;
23
+ }
24
+ return data.find((item) => item.id === overId)?.column ?? null;
25
+ }
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import {
4
+ DndContext,
5
+ type DragEndEvent,
6
+ rectIntersection,
7
+ useDraggable,
8
+ useDroppable,
9
+ } from "@dnd-kit/core";
10
+ import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
11
+ import type { ReactNode } from "react";
12
+ import { useCn } from "../ui/ui-config";
13
+ import { useDragSensors } from "../utils/useDragSensors";
14
+
15
+ export type { DragEndEvent as SortableListDragEndEvent } from "@dnd-kit/core";
16
+
17
+ export type SortableListItemsProps = {
18
+ children: ReactNode;
19
+ className?: string;
20
+ };
21
+
22
+ export const SortableListItems = ({ children, className }: SortableListItemsProps) => {
23
+ const merge = useCn();
24
+ return (
25
+ <div className={merge("flex flex-1 flex-col gap-0.5", className)}>
26
+ {children}
27
+ </div>
28
+ );
29
+ };
30
+
31
+ export type SortableListHeaderProps =
32
+ | {
33
+ children: ReactNode;
34
+ }
35
+ | {
36
+ name: string;
37
+ color: string;
38
+ className?: string;
39
+ };
40
+
41
+ export const SortableListHeader = (props: SortableListHeaderProps) => {
42
+ const merge = useCn();
43
+ return "children" in props ? (
44
+ props.children
45
+ ) : (
46
+ <div
47
+ className={merge(
48
+ "flex shrink-0 items-center gap-2 bg-foreground/5 p-3",
49
+ props.className,
50
+ )}
51
+ >
52
+ <div
53
+ className="h-2 w-2 rounded-full"
54
+ style={{ backgroundColor: props.color }}
55
+ />
56
+ <p className="m-0 font-semibold text-sm">{props.name}</p>
57
+ </div>
58
+ );
59
+ };
60
+
61
+ export type SortableListGroupProps = {
62
+ id: string;
63
+ children: ReactNode;
64
+ className?: string;
65
+ };
66
+
67
+ export const SortableListGroup = ({ id, children, className }: SortableListGroupProps) => {
68
+ const { setNodeRef, isOver } = useDroppable({ id });
69
+ const merge = useCn();
70
+
71
+ return (
72
+ <div
73
+ className={merge(
74
+ "transition-colors",
75
+ isOver && "bg-surface-secondary/40",
76
+ className,
77
+ )}
78
+ ref={setNodeRef}
79
+ >
80
+ {children}
81
+ </div>
82
+ );
83
+ };
84
+
85
+ export type SortableListItemProps = {
86
+ readonly id: string;
87
+ readonly name: string;
88
+ readonly index: number;
89
+ readonly parent: string;
90
+ readonly children?: ReactNode;
91
+ readonly className?: string;
92
+ };
93
+
94
+ // Not built on `ListRow`: that row always wraps its children in a padded body
95
+ // `<div>` and adds its own surface, radius and hover, so rendering through it
96
+ // would change this item's DOM for every caller. This item is only the draggable box.
97
+ export const SortableListItem = ({
98
+ id,
99
+ name,
100
+ index,
101
+ parent,
102
+ children,
103
+ className,
104
+ }: SortableListItemProps) => {
105
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
106
+ useDraggable({
107
+ id,
108
+ data: { index, parent },
109
+ });
110
+ const merge = useCn();
111
+
112
+ return (
113
+ <div
114
+ className={merge(
115
+ "cursor-grab",
116
+ isDragging && "cursor-grabbing opacity-50",
117
+ className,
118
+ )}
119
+ style={{
120
+ transform: transform
121
+ ? `translateX(${transform.x}px) translateY(${transform.y}px)`
122
+ : "none",
123
+ }}
124
+ {...listeners}
125
+ {...attributes}
126
+ ref={setNodeRef}
127
+ >
128
+ {children ?? <p className="m-0 font-medium text-sm">{name}</p>}
129
+ </div>
130
+ );
131
+ };
132
+
133
+ export type SortableListProviderProps = {
134
+ children: ReactNode;
135
+ onDragEnd: (event: DragEndEvent) => void;
136
+ className?: string;
137
+ };
138
+
139
+ export const SortableListProvider = ({
140
+ children,
141
+ onDragEnd,
142
+ className,
143
+ }: SortableListProviderProps) => {
144
+ // A lone `PointerSensor` armed on distance meant the list could not be
145
+ // reordered by touch at all: the whole row is the drag handle, the drag is
146
+ // restricted to the vertical axis, and that is the same axis the finger
147
+ // scrolls on — so the browser claimed the gesture and cancelled the pointer
148
+ // every time.
149
+ const sensors = useDragSensors();
150
+ const merge = useCn();
151
+
152
+ return (
153
+ <DndContext
154
+ sensors={sensors}
155
+ collisionDetection={rectIntersection}
156
+ modifiers={[restrictToVerticalAxis]}
157
+ onDragEnd={onDragEnd}
158
+ >
159
+ <div className={merge("flex size-full flex-col", className)}>{children}</div>
160
+ </DndContext>
161
+ );
162
+ };
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
+ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
5
+ import { motion } from "motion/react";
6
+ import { useCallback } from "react";
7
+ import { springTransition } from "../motion/presets";
8
+ import { useCn, useUiConfig } from "../ui/ui-config";
9
+
10
+ const themes = [
11
+ {
12
+ key: "system",
13
+ icon: IconDeviceDesktop,
14
+ label: "System theme",
15
+ },
16
+ {
17
+ key: "light",
18
+ icon: IconSun,
19
+ label: "Light theme",
20
+ },
21
+ {
22
+ key: "dark",
23
+ icon: IconMoon,
24
+ label: "Dark theme",
25
+ },
26
+ ] as const;
27
+
28
+ type ThemeKey = "light" | "dark" | "system";
29
+
30
+ export interface ThemeSwitcherConfig {
31
+ /** Replaces the built-in classes of a part. */
32
+ classes?: {
33
+ /** Each theme button: size, shape and press feedback. */
34
+ button?: string;
35
+ };
36
+ }
37
+
38
+ const defaultClasses = {
39
+ button:
40
+ "relative h-6 w-6 rounded-full transition-[scale] duration-150 ease-out active:scale-[0.94] hit-target",
41
+ };
42
+
43
+ export type ThemeSwitcherProps = {
44
+ value?: ThemeKey;
45
+ onChange?: (theme: ThemeKey) => void;
46
+ defaultValue?: ThemeKey;
47
+ className?: string;
48
+ };
49
+
50
+ export const ThemeSwitcher = ({
51
+ value,
52
+ onChange,
53
+ defaultValue = "system",
54
+ className,
55
+ }: ThemeSwitcherProps) => {
56
+ const classes = { ...defaultClasses, ...useUiConfig("ThemeSwitcher")?.classes };
57
+ const merge = useCn();
58
+ const [theme, setTheme] = useControllableState({
59
+ defaultProp: defaultValue,
60
+ prop: value,
61
+ onChange,
62
+ });
63
+ const handleThemeClick = useCallback(
64
+ (themeKey: ThemeKey) => {
65
+ setTheme(themeKey);
66
+ },
67
+ [setTheme],
68
+ );
69
+
70
+ // No mount gate: the selected theme comes from props rather than anything
71
+ // browser-only, so a client render has no hydration mismatch to skip the
72
+ // first paint for.
73
+ return (
74
+ <div
75
+ className={merge(
76
+ "relative isolate flex h-8 rounded-full bg-background p-1 ring-1 ring-border",
77
+ className,
78
+ )}
79
+ >
80
+ {themes.map(({ key, icon: Icon, label }) => {
81
+ const isActive = theme === key;
82
+
83
+ return (
84
+ <button
85
+ aria-label={label}
86
+ className={classes.button}
87
+ key={key}
88
+ onClick={() => handleThemeClick(key)}
89
+ type="button"
90
+ >
91
+ {isActive && (
92
+ <motion.div
93
+ className="absolute inset-0 rounded-full bg-secondary"
94
+ layoutId="activeTheme"
95
+ transition={springTransition}
96
+ />
97
+ )}
98
+ <Icon
99
+ className={merge(
100
+ "relative z-10 m-auto h-4 w-4",
101
+ isActive ? "text-foreground" : "text-muted-foreground",
102
+ )}
103
+ />
104
+ </button>
105
+ );
106
+ })}
107
+ </div>
108
+ );
109
+ };