@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.
- package/package.json +78 -0
- package/src/components/ui/accordion.tsx +79 -0
- package/src/components/ui/alert-dialog.tsx +198 -0
- package/src/components/ui/alert.tsx +105 -0
- package/src/components/ui/aspect-ratio.tsx +30 -0
- package/src/components/ui/avatar.tsx +125 -0
- package/src/components/ui/badge-variants.ts +166 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/banner.tsx +84 -0
- package/src/components/ui/breadcrumb.tsx +127 -0
- package/src/components/ui/button-group.tsx +99 -0
- package/src/components/ui/button-variants.ts +108 -0
- package/src/components/ui/button.tsx +54 -0
- package/src/components/ui/calendar.tsx +266 -0
- package/src/components/ui/card.tsx +155 -0
- package/src/components/ui/carousel.tsx +319 -0
- package/src/components/ui/chat.tsx +896 -0
- package/src/components/ui/checkbox-group.tsx +44 -0
- package/src/components/ui/checkbox.tsx +34 -0
- package/src/components/ui/chip.tsx +60 -0
- package/src/components/ui/collapsible.tsx +44 -0
- package/src/components/ui/color-picker.tsx +191 -0
- package/src/components/ui/combobox.tsx +317 -0
- package/src/components/ui/command.tsx +187 -0
- package/src/components/ui/confirm-dialog.tsx +125 -0
- package/src/components/ui/context-menu.tsx +254 -0
- package/src/components/ui/copyable-text.tsx +65 -0
- package/src/components/ui/date-picker.tsx +316 -0
- package/src/components/ui/date-time-picker.tsx +1071 -0
- package/src/components/ui/dialog.tsx +176 -0
- package/src/components/ui/direction.tsx +15 -0
- package/src/components/ui/drawer.tsx +178 -0
- package/src/components/ui/dropdown-menu.tsx +247 -0
- package/src/components/ui/empty.tsx +131 -0
- package/src/components/ui/field.tsx +257 -0
- package/src/components/ui/formatted-input.tsx +219 -0
- package/src/components/ui/gallery.tsx +161 -0
- package/src/components/ui/hover-card.tsx +77 -0
- package/src/components/ui/icon-picker.tsx +193 -0
- package/src/components/ui/image-cropper.tsx +787 -0
- package/src/components/ui/image-upload-field.tsx +344 -0
- package/src/components/ui/input-group.tsx +175 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/item.tsx +212 -0
- package/src/components/ui/kbd.tsx +62 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/list.tsx +46 -0
- package/src/components/ui/menu-variants.ts +71 -0
- package/src/components/ui/menubar.tsx +226 -0
- package/src/components/ui/money-input.tsx +96 -0
- package/src/components/ui/native-date-picker.tsx +120 -0
- package/src/components/ui/native-select.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +183 -0
- package/src/components/ui/pagination.tsx +150 -0
- package/src/components/ui/popover.tsx +104 -0
- package/src/components/ui/profile-header.tsx +196 -0
- package/src/components/ui/progress.tsx +71 -0
- package/src/components/ui/radio-group.tsx +115 -0
- package/src/components/ui/rating.tsx +200 -0
- package/src/components/ui/resizable.tsx +61 -0
- package/src/components/ui/responsive-dialog.tsx +278 -0
- package/src/components/ui/responsive-select.tsx +289 -0
- package/src/components/ui/responsive-sheet.tsx +225 -0
- package/src/components/ui/scroll-area.tsx +51 -0
- package/src/components/ui/section.tsx +106 -0
- package/src/components/ui/segmented-control.tsx +148 -0
- package/src/components/ui/select.tsx +216 -0
- package/src/components/ui/separator.tsx +69 -0
- package/src/components/ui/sheet.tsx +154 -0
- package/src/components/ui/shortcut.tsx +41 -0
- package/src/components/ui/sidebar.tsx +739 -0
- package/src/components/ui/skeleton.tsx +23 -0
- package/src/components/ui/slider.tsx +74 -0
- package/src/components/ui/sonner.tsx +64 -0
- package/src/components/ui/spinner.tsx +19 -0
- package/src/components/ui/stat-card.tsx +138 -0
- package/src/components/ui/stepper.tsx +181 -0
- package/src/components/ui/sticky-action-bar.tsx +42 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +132 -0
- package/src/components/ui/tabs.tsx +77 -0
- package/src/components/ui/textarea.tsx +24 -0
- package/src/components/ui/time-picker.tsx +221 -0
- package/src/components/ui/toggle-group.tsx +86 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +79 -0
- package/src/components/ui/translation-input.tsx +126 -0
- package/src/components/ui/user-avatar.tsx +74 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/use-command-palette.ts +28 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/hooks/use-pagination.ts +81 -0
- package/src/hooks/use-picker-state.ts +44 -0
- package/src/lib/cva.ts +18 -0
- package/src/lib/time-math.ts +62 -0
- package/src/lib/utils.ts +14 -0
- package/src/styles/globals.css +36 -0
- 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,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
|
+
}
|
package/src/lib/utils.ts
ADDED
|
@@ -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.*";
|