@voila.dev/ui 1.1.9

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 (99) hide show
  1. package/package.json +78 -0
  2. package/src/components/ui/accordion.tsx +79 -0
  3. package/src/components/ui/alert-dialog.tsx +198 -0
  4. package/src/components/ui/alert.tsx +105 -0
  5. package/src/components/ui/aspect-ratio.tsx +30 -0
  6. package/src/components/ui/avatar.tsx +125 -0
  7. package/src/components/ui/badge-variants.ts +166 -0
  8. package/src/components/ui/badge.tsx +45 -0
  9. package/src/components/ui/banner.tsx +84 -0
  10. package/src/components/ui/breadcrumb.tsx +127 -0
  11. package/src/components/ui/button-group.tsx +99 -0
  12. package/src/components/ui/button-variants.ts +108 -0
  13. package/src/components/ui/button.tsx +54 -0
  14. package/src/components/ui/calendar.tsx +266 -0
  15. package/src/components/ui/card.tsx +155 -0
  16. package/src/components/ui/carousel.tsx +319 -0
  17. package/src/components/ui/chat.tsx +896 -0
  18. package/src/components/ui/checkbox-group.tsx +44 -0
  19. package/src/components/ui/checkbox.tsx +34 -0
  20. package/src/components/ui/chip.tsx +60 -0
  21. package/src/components/ui/collapsible.tsx +44 -0
  22. package/src/components/ui/color-picker.tsx +191 -0
  23. package/src/components/ui/combobox.tsx +317 -0
  24. package/src/components/ui/command.tsx +187 -0
  25. package/src/components/ui/confirm-dialog.tsx +125 -0
  26. package/src/components/ui/context-menu.tsx +254 -0
  27. package/src/components/ui/copyable-text.tsx +65 -0
  28. package/src/components/ui/date-picker.tsx +316 -0
  29. package/src/components/ui/date-time-picker.tsx +1071 -0
  30. package/src/components/ui/dialog.tsx +176 -0
  31. package/src/components/ui/direction.tsx +15 -0
  32. package/src/components/ui/drawer.tsx +178 -0
  33. package/src/components/ui/dropdown-menu.tsx +247 -0
  34. package/src/components/ui/empty.tsx +131 -0
  35. package/src/components/ui/field.tsx +257 -0
  36. package/src/components/ui/formatted-input.tsx +219 -0
  37. package/src/components/ui/gallery.tsx +161 -0
  38. package/src/components/ui/hover-card.tsx +77 -0
  39. package/src/components/ui/icon-picker.tsx +193 -0
  40. package/src/components/ui/image-cropper.tsx +787 -0
  41. package/src/components/ui/image-upload-field.tsx +344 -0
  42. package/src/components/ui/input-group.tsx +175 -0
  43. package/src/components/ui/input-otp.tsx +83 -0
  44. package/src/components/ui/input.tsx +21 -0
  45. package/src/components/ui/item.tsx +212 -0
  46. package/src/components/ui/kbd.tsx +62 -0
  47. package/src/components/ui/label.tsx +19 -0
  48. package/src/components/ui/list.tsx +46 -0
  49. package/src/components/ui/menu-variants.ts +71 -0
  50. package/src/components/ui/menubar.tsx +226 -0
  51. package/src/components/ui/money-input.tsx +96 -0
  52. package/src/components/ui/native-date-picker.tsx +120 -0
  53. package/src/components/ui/native-select.tsx +74 -0
  54. package/src/components/ui/navigation-menu.tsx +183 -0
  55. package/src/components/ui/pagination.tsx +150 -0
  56. package/src/components/ui/popover.tsx +104 -0
  57. package/src/components/ui/profile-header.tsx +196 -0
  58. package/src/components/ui/progress.tsx +71 -0
  59. package/src/components/ui/radio-group.tsx +115 -0
  60. package/src/components/ui/rating.tsx +200 -0
  61. package/src/components/ui/resizable.tsx +61 -0
  62. package/src/components/ui/responsive-dialog.tsx +278 -0
  63. package/src/components/ui/responsive-select.tsx +289 -0
  64. package/src/components/ui/responsive-sheet.tsx +225 -0
  65. package/src/components/ui/scroll-area.tsx +51 -0
  66. package/src/components/ui/section.tsx +106 -0
  67. package/src/components/ui/segmented-control.tsx +148 -0
  68. package/src/components/ui/select.tsx +216 -0
  69. package/src/components/ui/separator.tsx +69 -0
  70. package/src/components/ui/sheet.tsx +154 -0
  71. package/src/components/ui/shortcut.tsx +41 -0
  72. package/src/components/ui/sidebar.tsx +739 -0
  73. package/src/components/ui/skeleton.tsx +23 -0
  74. package/src/components/ui/slider.tsx +74 -0
  75. package/src/components/ui/sonner.tsx +64 -0
  76. package/src/components/ui/spinner.tsx +19 -0
  77. package/src/components/ui/stat-card.tsx +138 -0
  78. package/src/components/ui/stepper.tsx +181 -0
  79. package/src/components/ui/sticky-action-bar.tsx +42 -0
  80. package/src/components/ui/switch.tsx +30 -0
  81. package/src/components/ui/table.tsx +132 -0
  82. package/src/components/ui/tabs.tsx +77 -0
  83. package/src/components/ui/textarea.tsx +24 -0
  84. package/src/components/ui/time-picker.tsx +221 -0
  85. package/src/components/ui/toggle-group.tsx +86 -0
  86. package/src/components/ui/toggle.tsx +43 -0
  87. package/src/components/ui/tooltip.tsx +79 -0
  88. package/src/components/ui/translation-input.tsx +126 -0
  89. package/src/components/ui/user-avatar.tsx +74 -0
  90. package/src/css-modules.d.ts +4 -0
  91. package/src/hooks/use-command-palette.ts +28 -0
  92. package/src/hooks/use-mobile.ts +21 -0
  93. package/src/hooks/use-pagination.ts +81 -0
  94. package/src/hooks/use-picker-state.ts +44 -0
  95. package/src/lib/cva.ts +18 -0
  96. package/src/lib/time-math.ts +62 -0
  97. package/src/lib/utils.ts +14 -0
  98. package/src/styles/globals.css +36 -0
  99. package/src/styles.css +10 -0
@@ -0,0 +1,126 @@
1
+ import type * as React from "react";
2
+ import {
3
+ InputGroup,
4
+ InputGroupAddon,
5
+ InputGroupInput,
6
+ } from "#/components/ui/input-group.tsx";
7
+ import { cn } from "#/lib/utils.ts";
8
+
9
+ /** A value carrying one string per locale, keyed by locale tag. */
10
+ type TranslationValue = Record<string, string>;
11
+
12
+ const isBlank = (text: string | undefined): boolean =>
13
+ (text ?? "").trim().length === 0;
14
+
15
+ /**
16
+ * Writes `text` to `locale`, carrying it into every locale that is still blank
17
+ * so a value written once is complete. Locales that already hold their own
18
+ * wording are left alone - this only ever fills gaps, never overwrites a
19
+ * translation.
20
+ */
21
+ function writeLocale(
22
+ value: TranslationValue,
23
+ locales: ReadonlyArray<string>,
24
+ locale: string,
25
+ text: string,
26
+ ): TranslationValue {
27
+ const next: TranslationValue = { ...value, [locale]: text };
28
+ for (const tag of locales) {
29
+ if (tag !== locale && isBlank(value[tag])) {
30
+ next[tag] = text;
31
+ }
32
+ }
33
+ return next;
34
+ }
35
+
36
+ type TranslationInputProps = Omit<
37
+ React.ComponentProps<typeof InputGroupInput>,
38
+ "type" | "value" | "onChange" | "onValueChange"
39
+ > & {
40
+ /** The whole per-locale record; only the active locale's string is shown. */
41
+ value: TranslationValue;
42
+ /** Receives the whole record with the active locale's string replaced. */
43
+ onValueChange: (value: TranslationValue) => void;
44
+ /** Locale whose string is currently being edited. */
45
+ locale: string;
46
+ /** Locale tags offered by the select, in display order. */
47
+ locales: ReadonlyArray<string>;
48
+ /** Receives the newly selected locale. */
49
+ onLocaleChange: (locale: string) => void;
50
+ /** Accessible name for the locale select. */
51
+ localeLabel: string;
52
+ /** Short display code per locale (e.g. `{ "fr-FR": "FR" }`). Falls back to the tag. */
53
+ localeCodes?: Record<string, string>;
54
+ /** Wrapper class (width/layout), forwarded to the `InputGroup`. */
55
+ className?: string;
56
+ };
57
+
58
+ /**
59
+ * A translated text input: one field editing the active locale's string, with a
60
+ * trailing locale select to switch between them. The whole per-locale record is
61
+ * the value, so a consumer binds one field instead of one per language and a
62
+ * switch never loses what the other locales hold.
63
+ *
64
+ * The select marks locales that are still empty with a dot, so a partially
65
+ * translated value is visible without opening each language in turn.
66
+ *
67
+ * A locale that is still EMPTY follows what you type, so writing the wording
68
+ * once yields a complete value instead of forcing the author through every
69
+ * language before the field can be saved. As soon as a locale has its own
70
+ * wording it stops following and is only edited directly.
71
+ */
72
+ function TranslationInput({
73
+ value,
74
+ onValueChange,
75
+ locale,
76
+ locales,
77
+ onLocaleChange,
78
+ localeLabel,
79
+ localeCodes,
80
+ className,
81
+ ...props
82
+ }: TranslationInputProps) {
83
+ return (
84
+ <InputGroup
85
+ // The input has no intrinsic width, so under `table-layout: auto` a
86
+ // grid cell would collapse it to a couple of characters. A floor keeps
87
+ // the text readable wherever it is dropped.
88
+ className={cn("min-w-44", className)}
89
+ data-slot="translation-input"
90
+ >
91
+ <InputGroupInput
92
+ type="text"
93
+ value={value[locale] ?? ""}
94
+ onChange={(event) =>
95
+ onValueChange(writeLocale(value, locales, locale, event.target.value))
96
+ }
97
+ {...props}
98
+ />
99
+ <InputGroupAddon align="inline-end">
100
+ <select
101
+ data-slot="translation-input-locale"
102
+ aria-label={localeLabel}
103
+ value={locale}
104
+ onChange={(event) => onLocaleChange(event.target.value)}
105
+ className={cn(
106
+ "cursor-pointer appearance-none bg-transparent pr-1 text-sm font-medium text-muted-foreground outline-none",
107
+ )}
108
+ >
109
+ {locales.map((tag) => {
110
+ const filled = (value[tag] ?? "").trim().length > 0;
111
+ const code = localeCodes?.[tag] ?? tag;
112
+ // The bullet flags a locale with nothing in it yet; `<option>`
113
+ // cannot host markup, so the status rides in the text.
114
+ return (
115
+ <option key={tag} value={tag}>
116
+ {filled ? code : `• ${code}`}
117
+ </option>
118
+ );
119
+ })}
120
+ </select>
121
+ </InputGroupAddon>
122
+ </InputGroup>
123
+ );
124
+ }
125
+
126
+ export { TranslationInput, type TranslationInputProps, type TranslationValue };
@@ -0,0 +1,74 @@
1
+ import type * as React from "react";
2
+ import {
3
+ Avatar,
4
+ AvatarBadge,
5
+ AvatarFallback,
6
+ AvatarImage,
7
+ } from "#/components/ui/avatar.tsx";
8
+
9
+ import { cn } from "#/lib/utils.ts";
10
+
11
+ /** Derive up-to-two-letter initials from a full name ("Camille Dubois" → "CD"). */
12
+ function getInitials(name: string): string {
13
+ const words = name.trim().split(/\s+/).filter(Boolean);
14
+ const firstLetter = words[0]?.[0] ?? "";
15
+ const lastLetter = words.length > 1 ? (words.at(-1)?.[0] ?? "") : "";
16
+ return `${firstLetter}${lastLetter}`.toUpperCase();
17
+ }
18
+
19
+ /**
20
+ * Identity row pairing an Avatar with a name and an optional role line - the
21
+ * recurring pattern in chat headers, tables and cards. Initials are derived
22
+ * from `name` when the image is missing or fails to load.
23
+ */
24
+ function UserAvatar({
25
+ className,
26
+ name,
27
+ description,
28
+ src,
29
+ status,
30
+ size = "default",
31
+ ...props
32
+ }: React.ComponentProps<"div"> & {
33
+ name: string;
34
+ description?: React.ReactNode;
35
+ src?: string;
36
+ status?: React.ComponentProps<typeof AvatarBadge>["status"];
37
+ size?: React.ComponentProps<typeof Avatar>["size"];
38
+ }) {
39
+ return (
40
+ <div
41
+ data-slot="user-avatar"
42
+ data-size={size}
43
+ className={cn(
44
+ "group/user-avatar flex min-w-0 items-center gap-2",
45
+ className,
46
+ )}
47
+ {...props}
48
+ >
49
+ <Avatar size={size}>
50
+ {src ? <AvatarImage src={src} alt={name} /> : null}
51
+ <AvatarFallback>{getInitials(name)}</AvatarFallback>
52
+ {status ? <AvatarBadge status={status} /> : null}
53
+ </Avatar>
54
+ <div className="flex min-w-0 flex-col">
55
+ <span
56
+ data-slot="user-avatar-name"
57
+ className="truncate text-sm leading-tight font-medium"
58
+ >
59
+ {name}
60
+ </span>
61
+ {description ? (
62
+ <span
63
+ data-slot="user-avatar-description"
64
+ className="truncate text-xs leading-tight text-muted-foreground"
65
+ >
66
+ {description}
67
+ </span>
68
+ ) : null}
69
+ </div>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ export { getInitials, UserAvatar };
@@ -0,0 +1,4 @@
1
+ // Side-effect CSS imports (e.g. maplibre-gl's stylesheet) carry no type
2
+ // declarations; the app bundlers get this from `vite/client`, but this package
3
+ // typechecks standalone, so declare the module shape here.
4
+ declare module "*.css" {}
@@ -0,0 +1,28 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Open/close state for a CommandDialog bound to a global hotkey
5
+ * (⌘+key on macOS, Ctrl+key elsewhere). Defaults to ⌘K / Ctrl+K.
6
+ */
7
+ export function useCommandPalette(key = "k") {
8
+ const [open, setOpen] = React.useState(false);
9
+
10
+ React.useEffect(() => {
11
+ const onKeyDown = (event: KeyboardEvent) => {
12
+ // Synthetic keydowns (browser autofill, IME composition) can arrive
13
+ // without a `key`, so guard before lowercasing it.
14
+ if (
15
+ event.key !== undefined &&
16
+ event.key.toLowerCase() === key.toLowerCase() &&
17
+ (event.metaKey || event.ctrlKey)
18
+ ) {
19
+ event.preventDefault();
20
+ setOpen((previousOpen) => !previousOpen);
21
+ }
22
+ };
23
+ document.addEventListener("keydown", onKeyDown);
24
+ return () => document.removeEventListener("keydown", onKeyDown);
25
+ }, [key]);
26
+
27
+ return { open, setOpen };
28
+ }
@@ -0,0 +1,21 @@
1
+ import * as React from "react";
2
+
3
+ const MOBILE_BREAKPOINT = 768;
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
7
+ undefined,
8
+ );
9
+
10
+ React.useEffect(() => {
11
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
12
+ const onChange = () => {
13
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
14
+ };
15
+ mql.addEventListener("change", onChange);
16
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
17
+ return () => mql.removeEventListener("change", onChange);
18
+ }, []);
19
+
20
+ return !!isMobile;
21
+ }
@@ -0,0 +1,81 @@
1
+ import * as React from "react";
2
+
3
+ /** Marker emitted by `getPaginationRange` where page numbers are elided. */
4
+ export const PAGINATION_ELLIPSIS = "ellipsis";
5
+
6
+ export type PaginationRangeItem = number | typeof PAGINATION_ELLIPSIS;
7
+
8
+ export type PaginationRangeOptions = {
9
+ /** Zero-based index of the current page. */
10
+ page: number;
11
+ /** Total number of pages. */
12
+ pageCount: number;
13
+ /** Pages shown on each side of the current page. */
14
+ siblingCount?: number;
15
+ /** Pages always shown at the start and end of the range. */
16
+ boundaryCount?: number;
17
+ };
18
+
19
+ function range(start: number, end: number): number[] {
20
+ return Array.from({ length: end - start + 1 }, (_, index) => start + index);
21
+ }
22
+
23
+ /**
24
+ * Windows a page range into at most
25
+ * `2 * boundaryCount + 2 * siblingCount + 3` items (current page + siblings,
26
+ * both boundaries, and up to two ellipsis markers). Items are zero-based page
27
+ * indexes; render `item + 1` for the visible page number.
28
+ */
29
+ export function getPaginationRange({
30
+ page,
31
+ pageCount,
32
+ siblingCount = 1,
33
+ boundaryCount = 1,
34
+ }: PaginationRangeOptions): PaginationRangeItem[] {
35
+ if (pageCount <= 0) {
36
+ return [];
37
+ }
38
+
39
+ // An ellipsis only ever hides 2+ pages: with windows this small it is
40
+ // cheaper to show every page than to elide one.
41
+ if (pageCount <= 2 * boundaryCount + 2 * siblingCount + 3) {
42
+ return range(0, pageCount - 1);
43
+ }
44
+
45
+ const startBoundary = range(0, boundaryCount - 1);
46
+ const endBoundary = range(pageCount - boundaryCount, pageCount - 1);
47
+
48
+ // Clamp the sibling window so it always spans the same number of pages,
49
+ // sliding it inward when the current page sits near either boundary.
50
+ const siblingStart = Math.max(
51
+ Math.min(
52
+ page - siblingCount,
53
+ pageCount - boundaryCount - 2 * siblingCount - 2,
54
+ ),
55
+ boundaryCount + 1,
56
+ );
57
+ const siblingEnd = siblingStart + 2 * siblingCount;
58
+
59
+ return [
60
+ ...startBoundary,
61
+ siblingStart > boundaryCount + 1 ? PAGINATION_ELLIPSIS : boundaryCount,
62
+ ...range(siblingStart, siblingEnd),
63
+ siblingEnd < pageCount - boundaryCount - 2
64
+ ? PAGINATION_ELLIPSIS
65
+ : pageCount - boundaryCount - 1,
66
+ ...endBoundary,
67
+ ];
68
+ }
69
+
70
+ /** Memoized `getPaginationRange` for render-time use. */
71
+ export function usePagination({
72
+ page,
73
+ pageCount,
74
+ siblingCount = 1,
75
+ boundaryCount = 1,
76
+ }: PaginationRangeOptions): PaginationRangeItem[] {
77
+ return React.useMemo(
78
+ () => getPaginationRange({ page, pageCount, siblingCount, boundaryCount }),
79
+ [page, pageCount, siblingCount, boundaryCount],
80
+ );
81
+ }
@@ -0,0 +1,44 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * The controlled/uncontrolled plumbing shared by every popover picker
5
+ * (date, date range, date-time, time): an optionally controlled value —
6
+ * `undefined` means uncontrolled, `null` a controlled empty selection — and an
7
+ * optionally controlled open state whose setter always notifies `onOpenChange`.
8
+ */
9
+ export function usePickerState<Value>({
10
+ value: controlledValue,
11
+ defaultValue,
12
+ open: controlledOpen,
13
+ defaultOpen,
14
+ onOpenChange,
15
+ }: {
16
+ value?: Value | null;
17
+ defaultValue?: Value;
18
+ open?: boolean;
19
+ defaultOpen?: boolean;
20
+ onOpenChange?: (open: boolean) => void;
21
+ }): {
22
+ isControlled: boolean;
23
+ value: Value | null | undefined;
24
+ setUncontrolledValue: (value: Value | undefined) => void;
25
+ open: boolean;
26
+ setOpen: (open: boolean) => void;
27
+ } {
28
+ const isControlled = controlledValue !== undefined;
29
+ const [uncontrolledValue, setUncontrolledValue] = React.useState<
30
+ Value | undefined
31
+ >(defaultValue);
32
+ const value = isControlled ? controlledValue : uncontrolledValue;
33
+
34
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
35
+ defaultOpen ?? false,
36
+ );
37
+ const open = controlledOpen ?? uncontrolledOpen;
38
+ const setOpen = (next: boolean) => {
39
+ setUncontrolledOpen(next);
40
+ onOpenChange?.(next);
41
+ };
42
+
43
+ return { isControlled, value, setUncontrolledValue, open, setOpen };
44
+ }
package/src/lib/cva.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "cva";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ /**
5
+ * Project-wide `cva` (the class-variance-authority successor, v1 beta),
6
+ * configured to run every generated class string through `tailwind-merge`. This
7
+ * is the single source of variant styling for both the app and the marketing
8
+ * site - always import `cva`/`cx`/`compose` from here (or `@voila.dev/ui/cva`),
9
+ * never from the raw `cva` package, so conflicting Tailwind utilities resolve
10
+ * consistently everywhere.
11
+ */
12
+ export const { cva, cx, compose } = defineConfig({
13
+ hooks: {
14
+ onComplete: (className) => twMerge(className),
15
+ },
16
+ });
17
+
18
+ export type { VariantProps } from "cva";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Minute-of-day arithmetic shared by the time pickers (`time-picker.tsx`,
3
+ * `date-time-picker.tsx`), which both model a time as minutes since midnight.
4
+ */
5
+
6
+ /** Time-of-day formatting: short localized time (e.g. "2:30 PM" / "14:30"). */
7
+ const DEFAULT_TIME_FORMAT: Intl.DateTimeFormatOptions = {
8
+ hour: "numeric",
9
+ minute: "2-digit",
10
+ };
11
+
12
+ /** Parse an "HH:mm" string to minutes since midnight (null when malformed). */
13
+ export function parseTimeToMinutes(
14
+ time: string | null | undefined,
15
+ ): number | null {
16
+ if (!time) return null;
17
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time);
18
+ if (!match) return null;
19
+ const hours = Number(match[1]);
20
+ const minutes = Number(match[2]);
21
+ if (hours > 23 || minutes > 59) return null;
22
+ return hours * 60 + minutes;
23
+ }
24
+
25
+ /** Serialize minutes since midnight back to a zero-padded "HH:mm" string. */
26
+ export function minutesToTimeValue(totalMinutes: number): string {
27
+ const hours = Math.floor(totalMinutes / 60);
28
+ const minutes = totalMinutes % 60;
29
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
30
+ }
31
+
32
+ /** Minutes since midnight of a `Date`'s local time-of-day. */
33
+ export function minutesOfDay(date: Date): number {
34
+ return date.getHours() * 60 + date.getMinutes();
35
+ }
36
+
37
+ /** A copy of `date` with the time-of-day set to `totalMinutes` (seconds/ms zeroed). */
38
+ export function withMinutes(date: Date, totalMinutes: number): Date {
39
+ const next = new Date(date);
40
+ next.setHours(Math.floor(totalMinutes / 60), totalMinutes % 60, 0, 0);
41
+ return next;
42
+ }
43
+
44
+ /**
45
+ * Localized label for a minute-of-day via the native `Intl.DateTimeFormat`, so
46
+ * AM/PM vs 24-hour follows the given BCP-47 locale (e.g. "2:30 PM" for `en-US`,
47
+ * "14:30" for `fr-FR`). `locale` undefined falls back to the runtime default.
48
+ */
49
+ export function formatMinutesLabel(
50
+ totalMinutes: number,
51
+ locale: string | undefined,
52
+ options: Intl.DateTimeFormatOptions = DEFAULT_TIME_FORMAT,
53
+ ): string {
54
+ const date = new Date(
55
+ 2000,
56
+ 0,
57
+ 1,
58
+ Math.floor(totalMinutes / 60),
59
+ totalMinutes % 60,
60
+ );
61
+ return new Intl.DateTimeFormat(locale, options).format(date);
62
+ }
@@ -0,0 +1,14 @@
1
+ import { type ClassValue, clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ /**
9
+ * Inline-link affordance shared by the kit's long-form text containers
10
+ * (Alert title/description, Accordion content) so links read the same
11
+ * everywhere.
12
+ */
13
+ export const proseLinkClassName =
14
+ "[&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground";
@@ -0,0 +1,36 @@
1
+ @import "tailwindcss";
2
+ @import "../styles.css";
3
+ @import "tw-animate-css";
4
+ @import "@voila.dev/ui-tokens/design-tokens.css";
5
+
6
+ @layer base {
7
+ * {
8
+ @apply border-border outline-ring/50;
9
+ }
10
+ body {
11
+ @apply bg-background text-foreground;
12
+ }
13
+ }
14
+
15
+ /* Indeterminate progress: a half-width bar sweeping across the track. */
16
+ @theme {
17
+ --animate-progress-indeterminate: progress-indeterminate 1.5s ease-in-out
18
+ infinite;
19
+
20
+ @keyframes progress-indeterminate {
21
+ from {
22
+ translate: -100% 0;
23
+ }
24
+ to {
25
+ translate: 200% 0;
26
+ }
27
+ }
28
+ }
29
+
30
+ /* Scrollable regions that hide their scrollbar (sidebar, command, combobox). */
31
+ @utility no-scrollbar {
32
+ scrollbar-width: none;
33
+ &::-webkit-scrollbar {
34
+ display: none;
35
+ }
36
+ }
package/src/styles.css ADDED
@@ -0,0 +1,10 @@
1
+ /*
2
+ * Tailwind must scan this package or none of its classes are generated — the
3
+ * components ship as .tsx source, not as compiled CSS.
4
+ *
5
+ * `@source` resolves relative to the file that declares it, so this works
6
+ * wherever the package ends up: node_modules in an app, a symlink in a
7
+ * workspace. Consumers import this file and add nothing of their own.
8
+ */
9
+ @source "./";
10
+ @source not "./**/*.test.*";