@stasho/ds 0.1.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 (30) hide show
  1. package/package.json +88 -0
  2. package/src/components/alert/alert.tsx +204 -0
  3. package/src/components/badge/badge.tsx +139 -0
  4. package/src/components/breadcrumb/breadcrumb.tsx +130 -0
  5. package/src/components/button/button.tsx +153 -0
  6. package/src/components/card/card.tsx +48 -0
  7. package/src/components/checkbox/checkbox.tsx +82 -0
  8. package/src/components/combobox/combobox.tsx +166 -0
  9. package/src/components/copyable-text/copyable-text.tsx +194 -0
  10. package/src/components/dialog/dialog.tsx +145 -0
  11. package/src/components/form-field/form-field.tsx +71 -0
  12. package/src/components/input/input.tsx +52 -0
  13. package/src/components/logo/logo.tsx +68 -0
  14. package/src/components/multi-select/multi-select.tsx +312 -0
  15. package/src/components/pagination/pagination.tsx +218 -0
  16. package/src/components/progress-bar/progress-bar.tsx +134 -0
  17. package/src/components/radio-group/radio-group.tsx +87 -0
  18. package/src/components/select/select.tsx +124 -0
  19. package/src/components/slider/slider.tsx +127 -0
  20. package/src/components/status-dot/status-dot.tsx +53 -0
  21. package/src/components/stepper/stepper.tsx +229 -0
  22. package/src/components/switch/switch.tsx +74 -0
  23. package/src/components/table/table.tsx +260 -0
  24. package/src/components/tabs/tabs.tsx +493 -0
  25. package/src/components/textarea/textarea.tsx +56 -0
  26. package/src/components/tooltip/tooltip.tsx +35 -0
  27. package/src/components/ui/skeleton.tsx +21 -0
  28. package/src/components/ui/spinner.tsx +27 -0
  29. package/src/lib/cn.ts +6 -0
  30. package/src/styles/tokens.css +459 -0
@@ -0,0 +1,493 @@
1
+ "use client";
2
+
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ type ComponentPropsWithoutRef,
10
+ } from "react";
11
+ import { Tabs as TabsPrimitive, DropdownMenu } from "radix-ui";
12
+ import { DotsThree } from "@phosphor-icons/react";
13
+ import { cn } from "@ac/lib/cn";
14
+
15
+ /* ── Root (direct re-export) ─────────────────── */
16
+
17
+ const Tabs = TabsPrimitive.Root;
18
+
19
+ /* ── List (with sliding indicator) ───────────── */
20
+
21
+ type TabsVariant = "underline" | "pill";
22
+ type TabsSize = "sm" | "md";
23
+
24
+ type TabsListProps = ComponentPropsWithoutRef<typeof TabsPrimitive.List> & {
25
+ variant?: TabsVariant;
26
+ size?: TabsSize;
27
+ overflow?: "collapse";
28
+ maxVisible?: number;
29
+ };
30
+
31
+ /* ── Overflow hook ───────────────────────────── */
32
+
33
+ function useOverflow(
34
+ listRef: React.RefObject<HTMLElement | null>,
35
+ overflowTriggerRef: React.RefObject<HTMLElement | null>,
36
+ enabled: boolean,
37
+ maxVisible: number | undefined,
38
+ ) {
39
+ const [hiddenTabs, setHiddenTabs] = useState<HiddenTab[]>([]);
40
+ const [hasActiveHidden, setHasActiveHidden] = useState(false);
41
+
42
+ const measure = useCallback(() => {
43
+ const list = listRef.current;
44
+ const trigger = overflowTriggerRef.current;
45
+ if (!list || !trigger || !enabled) {
46
+ setHiddenTabs([]);
47
+ setHasActiveHidden(false);
48
+ return;
49
+ }
50
+
51
+ const tabs = Array.from(
52
+ list.querySelectorAll<HTMLElement>('[role="tab"]'),
53
+ );
54
+
55
+ // Reset all tabs to normal flow for measurement
56
+ for (const tab of tabs) {
57
+ tab.style.visibility = "";
58
+ tab.style.position = "";
59
+ tab.style.pointerEvents = "";
60
+ }
61
+ list.style.minHeight = "";
62
+
63
+ // Snapshot container height before hiding tabs so the tallest
64
+ // tab's height is captured. Applied as min-height after hiding
65
+ // to prevent layout collapse when the tallest tab overflows.
66
+ const containerHeight = list.offsetHeight;
67
+ const containerLeft = list.getBoundingClientRect().left;
68
+ const containerWidth = list.clientWidth;
69
+ const triggerWidth = trigger.offsetWidth;
70
+
71
+ let widthBreakIndex: number | null = null;
72
+
73
+ for (let i = 0; i < tabs.length; i++) {
74
+ const tab = tabs[i] as HTMLElement;
75
+ const tabRight = tab.getBoundingClientRect().right - containerLeft;
76
+ if (tabRight + triggerWidth > containerWidth) {
77
+ widthBreakIndex = i;
78
+ break;
79
+ }
80
+ }
81
+
82
+ // Final break index — stricter of width-based and count-based limits.
83
+ // null means no overflow.
84
+ let newBreakIndex: number | null = null;
85
+ if (widthBreakIndex !== null && maxVisible !== undefined) {
86
+ newBreakIndex = Math.min(widthBreakIndex, maxVisible);
87
+ } else if (widthBreakIndex !== null) {
88
+ newBreakIndex = widthBreakIndex;
89
+ } else if (maxVisible !== undefined && maxVisible < tabs.length) {
90
+ newBreakIndex = maxVisible;
91
+ }
92
+
93
+ // All tabs fit by width — verify last tab actually fits before bailing
94
+ if (newBreakIndex === null) {
95
+ const lastTab = tabs[tabs.length - 1];
96
+ if (lastTab) {
97
+ const lastRight =
98
+ lastTab.getBoundingClientRect().right - containerLeft;
99
+ if (lastRight <= containerWidth) {
100
+ setHiddenTabs([]);
101
+ setHasActiveHidden(false);
102
+ return;
103
+ }
104
+ }
105
+ setHiddenTabs([]);
106
+ setHasActiveHidden(false);
107
+ return;
108
+ }
109
+
110
+ // Hide overflowed tabs
111
+ for (let i = newBreakIndex; i < tabs.length; i++) {
112
+ const tab = tabs[i] as HTMLElement;
113
+ tab.style.visibility = "hidden";
114
+ tab.style.position = "absolute";
115
+ tab.style.pointerEvents = "none";
116
+ }
117
+
118
+ // Lock container height so it doesn't collapse when the
119
+ // tallest tab is removed from flow by position:absolute
120
+ list.style.minHeight = `${String(containerHeight)}px`;
121
+
122
+ // Focus management: move focus to trigger if focused tab overflowed
123
+ const focused = document.activeElement;
124
+ if (focused instanceof HTMLElement) {
125
+ for (let i = newBreakIndex; i < tabs.length; i++) {
126
+ const tab = tabs[i] as HTMLElement;
127
+ if (tab === focused || tab.contains(focused)) {
128
+ trigger.focus();
129
+ break;
130
+ }
131
+ }
132
+ }
133
+
134
+ // Build hiddenTabs array
135
+ const newHidden: HiddenTab[] = [];
136
+ let activeHidden = false;
137
+ for (let i = newBreakIndex; i < tabs.length; i++) {
138
+ const tab = tabs[i] as HTMLElement;
139
+ newHidden.push({
140
+ value: tab.getAttribute("data-value") ?? tab.id ?? "",
141
+ label: tab.textContent ?? "",
142
+ disabled: tab.hasAttribute("disabled"),
143
+ triggerEl: tab,
144
+ });
145
+ if (tab.dataset["state"] === "active") activeHidden = true;
146
+ }
147
+
148
+ setHiddenTabs(newHidden);
149
+ setHasActiveHidden(activeHidden);
150
+ }, [listRef, overflowTriggerRef, enabled, maxVisible]);
151
+
152
+ useEffect(() => {
153
+ const list = listRef.current;
154
+ if (!list || !enabled) return;
155
+
156
+ measure();
157
+
158
+ const resizeObserver = new ResizeObserver(measure);
159
+ resizeObserver.observe(list);
160
+
161
+ const mutationObserver = new MutationObserver(measure);
162
+ mutationObserver.observe(list, {
163
+ attributes: true,
164
+ subtree: true,
165
+ attributeFilter: ["data-state"],
166
+ });
167
+
168
+ return () => {
169
+ resizeObserver.disconnect();
170
+ mutationObserver.disconnect();
171
+ };
172
+ }, [enabled, measure]);
173
+
174
+ return { hiddenTabs, hasActiveHidden };
175
+ }
176
+
177
+ /* ── Overflow types & trigger ────────────────── */
178
+
179
+ type HiddenTab = {
180
+ value: string;
181
+ label: string;
182
+ disabled: boolean;
183
+ triggerEl: HTMLElement;
184
+ };
185
+
186
+ type OverflowTriggerProps = {
187
+ isPill: boolean;
188
+ isSmall: boolean;
189
+ hiddenTabs: HiddenTab[];
190
+ hasActiveHidden: boolean;
191
+ visible: boolean;
192
+ };
193
+
194
+ const OverflowTrigger = forwardRef<HTMLButtonElement, OverflowTriggerProps>(
195
+ ({ isPill, isSmall, hiddenTabs, hasActiveHidden, visible }, ref) => (
196
+ <DropdownMenu.Root>
197
+ <DropdownMenu.Trigger asChild>
198
+ <button
199
+ ref={ref}
200
+ type="button"
201
+ aria-label="More tabs"
202
+ className={cn(
203
+ "inline-flex items-center justify-center shrink-0",
204
+ "font-heading font-bold",
205
+ hasActiveHidden && isPill
206
+ ? "text-white"
207
+ : hasActiveHidden
208
+ ? "text-primary-600 dark:text-primary-400"
209
+ : "text-muted-foreground",
210
+ "transition-colors duration-200",
211
+ "hover:text-primary-600 dark:hover:text-primary-400",
212
+ "focus-visible:outline-none focus-visible:ring-2",
213
+ "focus-visible:ring-primary-400 focus-visible:ring-offset-2",
214
+ "motion-reduce:transition-none",
215
+ isPill
216
+ ? cn(
217
+ "relative z-10 rounded-full",
218
+ isSmall ? "px-2 py-0.5 text-xs" : "px-3 py-1.5 text-sm",
219
+ )
220
+ : isSmall
221
+ ? "px-3 py-1.5 text-sm"
222
+ : "px-4 py-3 text-lg",
223
+ !visible && "invisible",
224
+ )}
225
+ >
226
+ <DotsThree weight="bold" className="size-5" aria-hidden="true" />
227
+ </button>
228
+ </DropdownMenu.Trigger>
229
+ <DropdownMenu.Portal>
230
+ <DropdownMenu.Content
231
+ className={cn(
232
+ "z-50 min-w-[8rem]",
233
+ "rounded-md bg-surface border border-edge shadow-brand",
234
+ "p-1",
235
+ "motion-reduce:transition-none",
236
+ )}
237
+ sideOffset={4}
238
+ align="end"
239
+ >
240
+ {hiddenTabs.map((tab) => (
241
+ <DropdownMenu.Item
242
+ key={tab.value}
243
+ disabled={tab.disabled}
244
+ onSelect={() => {
245
+ const el = tab.triggerEl;
246
+ // Defer focus until after DropdownMenu closes and
247
+ // releases its focus trap. Restoring visibility lets
248
+ // the trigger receive focus, which activates the tab
249
+ // via Radix. measure() re-hides via MutationObserver
250
+ // after data-state updates.
251
+ requestAnimationFrame(() => {
252
+ el.style.visibility = "";
253
+ el.style.pointerEvents = "";
254
+ el.focus();
255
+ });
256
+ }}
257
+ className={cn(
258
+ "flex w-full items-center rounded-sm px-3 py-2",
259
+ "text-sm text-foreground cursor-pointer select-none",
260
+ "outline-none",
261
+ "hover:bg-muted focus-visible:bg-muted",
262
+ "data-[disabled]:opacity-50 data-[disabled]:pointer-events-none",
263
+ tab.triggerEl.dataset["state"] === "active" &&
264
+ "text-primary-600 dark:text-primary-400 font-bold",
265
+ )}
266
+ >
267
+ {tab.label}
268
+ </DropdownMenu.Item>
269
+ ))}
270
+ </DropdownMenu.Content>
271
+ </DropdownMenu.Portal>
272
+ </DropdownMenu.Root>
273
+ ),
274
+ );
275
+
276
+ OverflowTrigger.displayName = "OverflowTrigger";
277
+
278
+ const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
279
+ ({ className, children, variant = "underline", size = "md", overflow, maxVisible, ...rest }, ref) => {
280
+ const innerRef = useRef<HTMLDivElement>(null);
281
+ const indicatorRef = useRef<HTMLDivElement>(null);
282
+ const overflowTriggerRef = useRef<HTMLButtonElement>(null);
283
+ const [ready, setReady] = useState(false);
284
+ const isPill = variant === "pill";
285
+ const isSmall = size === "sm";
286
+ const isCollapse = overflow === "collapse";
287
+ // maxVisible activates the same overflow code path; isCollapse stays
288
+ // bound to overflow="collapse" so layout (full-width pill) only changes
289
+ // when the consumer opts into width-based collapse.
290
+ const showOverflow = isCollapse || maxVisible !== undefined;
291
+
292
+ const setRefs = (node: HTMLDivElement | null) => {
293
+ innerRef.current = node;
294
+ if (typeof ref === "function") ref(node);
295
+ else if (ref) ref.current = node;
296
+ };
297
+
298
+ const { hiddenTabs, hasActiveHidden } = useOverflow(
299
+ innerRef,
300
+ overflowTriggerRef,
301
+ showOverflow,
302
+ maxVisible,
303
+ );
304
+
305
+ useEffect(() => {
306
+ const list = innerRef.current;
307
+ const indicator = indicatorRef.current;
308
+ if (!list || !indicator) return;
309
+
310
+ function updateIndicator() {
311
+ const activeTab = list!.querySelector<HTMLElement>(
312
+ '[data-state="active"]',
313
+ );
314
+ if (!activeTab || !indicator) return;
315
+
316
+ // When the active tab is overflowed, slide the indicator
317
+ // behind the "..." trigger instead of hiding it
318
+ if (activeTab.style.visibility === "hidden") {
319
+ const trigger = overflowTriggerRef.current;
320
+ if (trigger) {
321
+ indicator.style.opacity = "";
322
+ indicator.style.transform = `translateX(${String(trigger.offsetLeft)}px)`;
323
+ indicator.style.width = `${String(trigger.offsetWidth)}px`;
324
+ if (!ready) setReady(true);
325
+ } else {
326
+ indicator.style.opacity = "0";
327
+ }
328
+ return;
329
+ }
330
+ indicator.style.opacity = "";
331
+
332
+ const left = activeTab.offsetLeft;
333
+ const width = activeTab.offsetWidth;
334
+ indicator.style.transform = `translateX(${String(left)}px)`;
335
+ indicator.style.width = `${String(width)}px`;
336
+ if (!ready) setReady(true);
337
+ }
338
+
339
+ updateIndicator();
340
+
341
+ const observer = new MutationObserver(updateIndicator);
342
+ observer.observe(list, {
343
+ attributes: true,
344
+ subtree: true,
345
+ attributeFilter: ["data-state"],
346
+ });
347
+
348
+ const resizeObserver = new ResizeObserver(updateIndicator);
349
+ resizeObserver.observe(list);
350
+
351
+ return () => {
352
+ observer.disconnect();
353
+ resizeObserver.disconnect();
354
+ };
355
+ }, [ready]);
356
+
357
+ return (
358
+ <TabsPrimitive.List
359
+ ref={setRefs}
360
+ data-variant={variant}
361
+ data-size={size}
362
+ className={cn(
363
+ "group relative flex",
364
+ isPill
365
+ ? [
366
+ "rounded-full bg-muted",
367
+ isSmall ? "p-0.5" : "p-1",
368
+ !isCollapse && "inline-flex",
369
+ ]
370
+ : isSmall
371
+ ? "border-b-2 border-edge/40"
372
+ : "border-b-4 border-edge/40",
373
+ className,
374
+ )}
375
+ {...rest}
376
+ >
377
+ {children}
378
+ {showOverflow && (
379
+ <OverflowTrigger
380
+ ref={overflowTriggerRef}
381
+ isPill={isPill}
382
+ isSmall={isSmall}
383
+ hiddenTabs={hiddenTabs}
384
+ hasActiveHidden={hasActiveHidden}
385
+ visible={hiddenTabs.length > 0}
386
+ />
387
+ )}
388
+ <div
389
+ ref={indicatorRef}
390
+ className={cn(
391
+ "absolute left-0",
392
+ isPill
393
+ ? [
394
+ isSmall ? "inset-y-0.5" : "inset-y-1",
395
+ "rounded-full bg-primary-600 dark:bg-primary-500",
396
+ ready ? "opacity-100" : "opacity-0",
397
+ ready
398
+ ? "transition-[transform,width,opacity] duration-200 ease-out"
399
+ : "",
400
+ ]
401
+ : [
402
+ isSmall ? "-bottom-0.5 h-0.5" : "-bottom-1 h-1",
403
+ "bg-primary-600 dark:bg-primary-400",
404
+ ready
405
+ ? "transition-[transform,width] duration-200 ease-out"
406
+ : "",
407
+ ],
408
+ "motion-reduce:transition-none",
409
+ )}
410
+ aria-hidden
411
+ />
412
+ </TabsPrimitive.List>
413
+ );
414
+ },
415
+ );
416
+
417
+ TabsList.displayName = "TabsList";
418
+
419
+ /* ── Trigger ─────────────────────────────────── */
420
+
421
+ const TabsTrigger = forwardRef<
422
+ HTMLButtonElement,
423
+ ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
424
+ >(({ className, ...rest }, ref) => (
425
+ <TabsPrimitive.Trigger
426
+ ref={ref}
427
+ className={cn(
428
+ [
429
+ "inline-flex items-center gap-2 px-4 py-3",
430
+ "font-heading font-bold text-lg",
431
+ "text-foreground",
432
+ "transition-[color,transform] duration-200 ease-out",
433
+ "hover:text-primary-600 dark:hover:text-primary-400",
434
+ "data-[state=active]:text-primary-600",
435
+ "dark:data-[state=active]:text-primary-400",
436
+ "data-[state=active]:-translate-y-0.5",
437
+ "disabled:opacity-20 disabled:pointer-events-none",
438
+ "focus-visible:outline-none focus-visible:ring-2",
439
+ "focus-visible:ring-primary-400 focus-visible:ring-offset-2",
440
+ "motion-reduce:transition-none",
441
+ // Small size overrides (underline)
442
+ "group-data-[size=sm]:px-3 group-data-[size=sm]:py-1.5",
443
+ "group-data-[size=sm]:text-sm group-data-[size=sm]:gap-1.5",
444
+ // Pill variant overrides (via group data attribute on TabsList)
445
+ "group-data-[variant=pill]:relative group-data-[variant=pill]:z-10",
446
+ "group-data-[variant=pill]:rounded-full",
447
+ "group-data-[variant=pill]:px-5 group-data-[variant=pill]:py-1.5",
448
+ "group-data-[variant=pill]:text-sm",
449
+ "group-data-[variant=pill]:text-muted-foreground",
450
+ "group-data-[variant=pill]:translate-y-0",
451
+ "group-data-[variant=pill]:hover:text-foreground",
452
+ "group-data-[variant=pill]:data-[state=active]:text-white",
453
+ "group-data-[variant=pill]:data-[state=active]:translate-y-0",
454
+ "group-data-[variant=pill]:focus-visible:ring-offset-0",
455
+ // Small pill overrides (compound group selector)
456
+ "group-[[data-variant=pill][data-size=sm]]:px-3",
457
+ "group-[[data-variant=pill][data-size=sm]]:py-1",
458
+ "group-[[data-variant=pill][data-size=sm]]:text-xs",
459
+ ].join(" "),
460
+ className,
461
+ )}
462
+ {...rest}
463
+ />
464
+ ));
465
+
466
+ TabsTrigger.displayName = "TabsTrigger";
467
+
468
+ /* ── Content ─────────────────────────────────── */
469
+
470
+ const TabsContent = forwardRef<
471
+ HTMLDivElement,
472
+ ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
473
+ >(({ className, ...rest }, ref) => (
474
+ <TabsPrimitive.Content
475
+ ref={ref}
476
+ className={cn("mt-4", className)}
477
+ {...rest}
478
+ />
479
+ ));
480
+
481
+ TabsContent.displayName = "TabsContent";
482
+
483
+ /* ── Exports ─────────────────────────────────── */
484
+
485
+ export {
486
+ Tabs,
487
+ TabsContent,
488
+ TabsList,
489
+ TabsTrigger,
490
+ type TabsListProps,
491
+ type TabsSize,
492
+ type TabsVariant,
493
+ };
@@ -0,0 +1,56 @@
1
+ import { forwardRef, type TextareaHTMLAttributes } from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+ import { cn } from "@ac/lib/cn";
4
+
5
+ const textareaVariants = cva(
6
+ [
7
+ "w-full font-sans text-foreground bg-primary-100 dark:bg-base-700",
8
+ "border-0 rounded-2xl",
9
+ "placeholder:text-muted-foreground",
10
+ "focus-visible:outline-none focus-visible:ring-3",
11
+ "focus-visible:ring-primary-500",
12
+ "disabled:opacity-50 disabled:pointer-events-none",
13
+ "ring-0 resize-y transition-colors",
14
+ ].join(" "),
15
+ {
16
+ variants: {
17
+ size: {
18
+ sm: "py-1.5 px-4 text-sm",
19
+ md: "py-2 px-5 text-base",
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ size: "md",
24
+ },
25
+ },
26
+ );
27
+
28
+ type TextareaProps = Omit<
29
+ TextareaHTMLAttributes<HTMLTextAreaElement>,
30
+ "size"
31
+ > &
32
+ VariantProps<typeof textareaVariants> & {
33
+ error?: boolean;
34
+ };
35
+
36
+ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
37
+ ({ size, error = false, rows = 4, className, ...rest }, ref) => {
38
+ return (
39
+ <textarea
40
+ ref={ref}
41
+ rows={rows}
42
+ className={cn(
43
+ textareaVariants({ size }),
44
+ error && "border-3 border-error-400 hover:border-error-500",
45
+ className,
46
+ )}
47
+ aria-invalid={error || undefined}
48
+ {...rest}
49
+ />
50
+ );
51
+ },
52
+ );
53
+
54
+ Textarea.displayName = "Textarea";
55
+
56
+ export { Textarea, textareaVariants, type TextareaProps };
@@ -0,0 +1,35 @@
1
+ import { forwardRef, type ComponentPropsWithoutRef } from "react";
2
+ import { Tooltip as TooltipPrimitive } from "radix-ui";
3
+ import { cn } from "@ac/lib/cn";
4
+
5
+ const TooltipProvider = TooltipPrimitive.Provider;
6
+ const Tooltip = TooltipPrimitive.Root;
7
+ const TooltipTrigger = TooltipPrimitive.Trigger;
8
+
9
+ const TooltipContent = forwardRef<
10
+ HTMLDivElement,
11
+ ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
12
+ >(({ className, sideOffset = 6, ...rest }, ref) => (
13
+ <TooltipPrimitive.Portal>
14
+ <TooltipPrimitive.Content
15
+ ref={ref}
16
+ sideOffset={sideOffset}
17
+ className={cn(
18
+ [
19
+ "z-50 rounded-lg bg-neutral-900 dark:bg-base-800 px-3 py-1.5",
20
+ "text-sm text-white shadow-brand-sm",
21
+ "animate-in fade-in-0 zoom-in-95",
22
+ "data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
23
+ "data-[state=closed]:zoom-out-95",
24
+ "motion-reduce:animate-none",
25
+ ].join(" "),
26
+ className,
27
+ )}
28
+ {...rest}
29
+ />
30
+ </TooltipPrimitive.Portal>
31
+ ));
32
+
33
+ TooltipContent.displayName = "TooltipContent";
34
+
35
+ export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -0,0 +1,21 @@
1
+ import { forwardRef, type HTMLAttributes } from "react";
2
+ import { cn } from "@ac/lib/cn";
3
+
4
+ type SkeletonProps = HTMLAttributes<HTMLDivElement>;
5
+
6
+ const Skeleton = forwardRef<HTMLDivElement, SkeletonProps>(
7
+ ({ className, ...rest }, ref) => {
8
+ return (
9
+ <div
10
+ ref={ref}
11
+ aria-hidden="true"
12
+ className={cn("animate-pulse motion-reduce:animate-none rounded-md bg-muted", className)}
13
+ {...rest}
14
+ />
15
+ );
16
+ },
17
+ );
18
+
19
+ Skeleton.displayName = "Skeleton";
20
+
21
+ export { Skeleton, type SkeletonProps };
@@ -0,0 +1,27 @@
1
+ import { cn } from "@ac/lib/cn";
2
+
3
+ export function Spinner({ className }: { className?: string }) {
4
+ return (
5
+ <svg
6
+ className={cn("animate-spin motion-reduce:animate-none", className)}
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ fill="none"
9
+ viewBox="0 0 24 24"
10
+ aria-hidden="true"
11
+ >
12
+ <circle
13
+ className="opacity-25"
14
+ cx="12"
15
+ cy="12"
16
+ r="10"
17
+ stroke="currentColor"
18
+ strokeWidth="4"
19
+ />
20
+ <path
21
+ className="opacity-75"
22
+ fill="currentColor"
23
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
24
+ />
25
+ </svg>
26
+ );
27
+ }
package/src/lib/cn.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]): string {
5
+ return twMerge(clsx(inputs));
6
+ }