@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,161 @@
1
+ import { ImageIcon } from "@phosphor-icons/react";
2
+ import * as React from "react";
3
+ import {
4
+ Carousel,
5
+ type CarouselApi,
6
+ CarouselContent,
7
+ CarouselItem,
8
+ CarouselNext,
9
+ CarouselPrevious,
10
+ } from "#/components/ui/carousel.tsx";
11
+ import { Dialog, DialogContent, DialogTitle } from "#/components/ui/dialog.tsx";
12
+ import {
13
+ Empty,
14
+ EmptyDescription,
15
+ EmptyHeader,
16
+ EmptyMedia,
17
+ EmptyTitle,
18
+ } from "#/components/ui/empty.tsx";
19
+ import { cn } from "#/lib/utils.ts";
20
+
21
+ type GalleryImage = { src: string; alt?: string };
22
+
23
+ /**
24
+ * Full-screen image viewer built on Dialog + Carousel. Controlled via `open`
25
+ * / `onOpenChange`; `startIndex` selects which image is shown first. Swipe or
26
+ * use the prev/next controls (embla handles touch on mobile).
27
+ */
28
+ function Lightbox({
29
+ images,
30
+ open,
31
+ onOpenChange,
32
+ startIndex = 0,
33
+ }: {
34
+ images: ReadonlyArray<GalleryImage>;
35
+ open: boolean;
36
+ onOpenChange: (open: boolean) => void;
37
+ startIndex?: number;
38
+ }) {
39
+ const [api, setApi] = React.useState<CarouselApi>();
40
+
41
+ // Jump to the requested slide whenever the lightbox (re)opens.
42
+ React.useEffect(() => {
43
+ if (open && api) api.scrollTo(startIndex, true);
44
+ }, [open, api, startIndex]);
45
+
46
+ return (
47
+ <Dialog open={open} onOpenChange={onOpenChange}>
48
+ <DialogContent
49
+ size="xl"
50
+ closeButtonLabel="Close gallery"
51
+ // The width override repeats the `data-[size=xl]` variant so it beats
52
+ // the size recipe's own `data-[size=xl]:sm:max-w-xl`.
53
+ className="bg-background p-2 data-[size=xl]:sm:max-w-3xl"
54
+ >
55
+ <DialogTitle className="sr-only">Image gallery</DialogTitle>
56
+ <Carousel
57
+ setApi={setApi}
58
+ opts={{ startIndex, loop: images.length > 1 }}
59
+ data-slot="lightbox-carousel"
60
+ className="w-full"
61
+ >
62
+ <CarouselContent>
63
+ {images.map((image, index) => (
64
+ <CarouselItem key={image.src ?? index}>
65
+ <div className="flex items-center justify-center">
66
+ <img
67
+ src={image.src}
68
+ alt={image.alt ?? ""}
69
+ data-slot="lightbox-image"
70
+ className="max-h-[80dvh] w-auto rounded-lg object-contain"
71
+ />
72
+ </div>
73
+ </CarouselItem>
74
+ ))}
75
+ </CarouselContent>
76
+ {images.length > 1 ? (
77
+ <>
78
+ <CarouselPrevious inset />
79
+ <CarouselNext inset />
80
+ </>
81
+ ) : null}
82
+ </Carousel>
83
+ </DialogContent>
84
+ </Dialog>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Responsive thumbnail grid that opens a full-screen `Lightbox` on click.
90
+ * Renders an `Empty` state when there are no images. Thumbnails are buttons so
91
+ * they're keyboard-navigable; the lightbox itself is swipeable on mobile.
92
+ */
93
+ function Gallery({
94
+ images,
95
+ emptyLabel = "No photos yet",
96
+ emptyDescription,
97
+ className,
98
+ ...props
99
+ }: Omit<React.ComponentProps<"div">, "children"> & {
100
+ images: ReadonlyArray<GalleryImage>;
101
+ emptyLabel?: React.ReactNode;
102
+ emptyDescription?: React.ReactNode;
103
+ }) {
104
+ const [openIndex, setOpenIndex] = React.useState<number | null>(null);
105
+
106
+ if (images.length === 0) {
107
+ return (
108
+ <Empty bordered className={className} {...props}>
109
+ <EmptyHeader>
110
+ <EmptyMedia variant="icon">
111
+ <ImageIcon />
112
+ </EmptyMedia>
113
+ <EmptyTitle>{emptyLabel}</EmptyTitle>
114
+ {emptyDescription ? (
115
+ <EmptyDescription>{emptyDescription}</EmptyDescription>
116
+ ) : null}
117
+ </EmptyHeader>
118
+ </Empty>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <div data-slot="gallery" className={className} {...props}>
124
+ <div
125
+ data-slot="gallery-grid"
126
+ className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4"
127
+ >
128
+ {images.map((image, index) => (
129
+ <button
130
+ key={image.src ?? index}
131
+ type="button"
132
+ data-slot="gallery-thumbnail"
133
+ aria-label={image.alt ?? `Open image ${index + 1}`}
134
+ className="group/thumb relative aspect-square touch-manipulation overflow-hidden rounded-lg bg-muted outline-none ring-1 ring-foreground/10 transition focus-visible:ring-2 focus-visible:ring-ring/50"
135
+ onClick={() => setOpenIndex(index)}
136
+ >
137
+ <img
138
+ src={image.src}
139
+ alt={image.alt ?? ""}
140
+ loading="lazy"
141
+ className={cn(
142
+ "size-full object-cover transition-transform duration-200",
143
+ "group-hover/thumb:scale-105",
144
+ )}
145
+ />
146
+ </button>
147
+ ))}
148
+ </div>
149
+ <Lightbox
150
+ images={images}
151
+ open={openIndex !== null}
152
+ startIndex={openIndex ?? 0}
153
+ onOpenChange={(next) => {
154
+ if (!next) setOpenIndex(null);
155
+ }}
156
+ />
157
+ </div>
158
+ );
159
+ }
160
+
161
+ export { Gallery, type GalleryImage, Lightbox };
@@ -0,0 +1,77 @@
1
+ import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
2
+
3
+ import { cn } from "#/lib/utils.ts";
4
+
5
+ /**
6
+ * Rich hover preview (Base UI PreviewCard) for link/mention triggers — an
7
+ * avatar, a bio, a few stats. For a plain text hint on any control, use
8
+ * Tooltip instead: a hover card is for content worth lingering on, so it opens
9
+ * slower and stays open while the pointer is over the card itself.
10
+ */
11
+ function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
12
+ return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />;
13
+ }
14
+
15
+ /**
16
+ * Opens the card after `delay` (600ms Base UI default) and closes it after
17
+ * `closeDelay` (300ms default) — both are tuned here, on the trigger.
18
+ */
19
+ function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
20
+ return (
21
+ <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
22
+ );
23
+ }
24
+
25
+ function HoverCardContent({
26
+ className,
27
+ align = "center",
28
+ alignOffset = 0,
29
+ side = "bottom",
30
+ sideOffset = 8,
31
+ collisionPadding = 8,
32
+ positionerProps,
33
+ ...props
34
+ }: PreviewCardPrimitive.Popup.Props &
35
+ Pick<
36
+ PreviewCardPrimitive.Positioner.Props,
37
+ "align" | "alignOffset" | "collisionPadding" | "side" | "sideOffset"
38
+ > & {
39
+ // Escape hatch for the Positioner props not surfaced as dedicated props
40
+ // (sticky, anchor, positionMethod, arrowPadding…).
41
+ positionerProps?: Omit<
42
+ PreviewCardPrimitive.Positioner.Props,
43
+ | "align"
44
+ | "alignOffset"
45
+ | "collisionPadding"
46
+ | "side"
47
+ | "sideOffset"
48
+ | "children"
49
+ >;
50
+ }) {
51
+ const { className: positionerClassName, ...restPositionerProps } =
52
+ positionerProps ?? {};
53
+ return (
54
+ <PreviewCardPrimitive.Portal data-slot="hover-card-portal">
55
+ <PreviewCardPrimitive.Positioner
56
+ align={align}
57
+ alignOffset={alignOffset}
58
+ collisionPadding={collisionPadding}
59
+ side={side}
60
+ sideOffset={sideOffset}
61
+ className={cn("isolate z-50", positionerClassName)}
62
+ {...restPositionerProps}
63
+ >
64
+ <PreviewCardPrimitive.Popup
65
+ data-slot="hover-card-content"
66
+ className={cn(
67
+ "z-50 w-72 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:animate-none",
68
+ className,
69
+ )}
70
+ {...props}
71
+ />
72
+ </PreviewCardPrimitive.Positioner>
73
+ </PreviewCardPrimitive.Portal>
74
+ );
75
+ }
76
+
77
+ export { HoverCard, HoverCardContent, HoverCardTrigger };
@@ -0,0 +1,193 @@
1
+ import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox";
2
+ import {
3
+ Icon,
4
+ type PhosphorIconName,
5
+ phosphorIconNames,
6
+ } from "@voila.dev/ui-icon/components/icon";
7
+ import { useMemo, useState } from "react";
8
+ import { Button } from "#/components/ui/button.tsx";
9
+ import {
10
+ Combobox,
11
+ ComboboxContent,
12
+ ComboboxEmpty,
13
+ ComboboxInput,
14
+ ComboboxList,
15
+ ComboboxRow,
16
+ } from "#/components/ui/combobox.tsx";
17
+ import { cn } from "#/lib/utils.ts";
18
+
19
+ /**
20
+ * Cap on rendered swatches — the full Phosphor set (~1,500) would jank. The
21
+ * grid is not virtualized; matches beyond the cap are summarized by the
22
+ * "+N more" hint and reachable by refining the search.
23
+ */
24
+ const RESULT_LIMIT = 120;
25
+
26
+ const GRID_COLUMNS = 6;
27
+
28
+ /** Human-friendly label for an icon name: "FirstAidKitIcon" → "First Aid Kit". */
29
+ const formatIconName = (name: string): string =>
30
+ name.replace(/Icon$/, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2");
31
+
32
+ const chunkIntoRows = (
33
+ iconNames: readonly string[],
34
+ columns: number,
35
+ ): string[][] => {
36
+ const rows: string[][] = [];
37
+ for (let index = 0; index < iconNames.length; index += columns) {
38
+ rows.push(iconNames.slice(index, index + columns) as string[]);
39
+ }
40
+ return rows;
41
+ };
42
+
43
+ export function IconPicker({
44
+ value: controlledValue,
45
+ defaultValue = null,
46
+ onValueChange,
47
+ placeholder = "Select an icon",
48
+ searchPlaceholder = "Search icons…",
49
+ emptyLabel = "No icons found",
50
+ clearLabel = "Clear selection",
51
+ moreLabel,
52
+ name,
53
+ disabled = false,
54
+ closeOnSelect = true,
55
+ className,
56
+ }: {
57
+ /** Controlled selection; omit to let the picker manage its own state. */
58
+ value?: PhosphorIconName | (string & {}) | null;
59
+ /** Initial selection for uncontrolled usage. */
60
+ defaultValue?: PhosphorIconName | (string & {}) | null;
61
+ onValueChange?: (name: string | null) => void;
62
+ placeholder?: string;
63
+ searchPlaceholder?: string;
64
+ emptyLabel?: string;
65
+ clearLabel?: string;
66
+ moreLabel?: (count: number) => string;
67
+ /** When set, renders a hidden input so plain form posts include the icon. */
68
+ name?: string;
69
+ disabled?: boolean;
70
+ /** Keep the popover open after a pick, for rapid-compare flows. */
71
+ closeOnSelect?: boolean;
72
+ className?: string;
73
+ }) {
74
+ const [query, setQuery] = useState("");
75
+
76
+ const matches = useMemo(() => {
77
+ // Spaces are stripped so the human-formatted spelling shown on the
78
+ // trigger ("First Aid Kit") finds the camelCase export it came from.
79
+ const q = query.trim().toLowerCase().replace(/\s+/g, "");
80
+ if (q.length === 0) return phosphorIconNames;
81
+ const prefixMatches: string[] = [];
82
+ const substringMatches: string[] = [];
83
+ for (const iconName of phosphorIconNames) {
84
+ const lower = iconName.toLowerCase();
85
+ if (!lower.includes(q)) continue;
86
+ if (lower.startsWith(q)) prefixMatches.push(iconName);
87
+ else substringMatches.push(iconName);
88
+ }
89
+ return [...prefixMatches, ...substringMatches];
90
+ }, [query]);
91
+
92
+ const shown = matches.slice(0, RESULT_LIMIT);
93
+ const hidden = matches.length - shown.length;
94
+ const rows = useMemo(() => chunkIntoRows(shown, GRID_COLUMNS), [shown]);
95
+
96
+ return (
97
+ <Combobox<string>
98
+ grid
99
+ filteredItems={rows}
100
+ {...(controlledValue !== undefined && { value: controlledValue })}
101
+ defaultValue={defaultValue}
102
+ onValueChange={(next) => onValueChange?.(next ?? null)}
103
+ inputValue={query}
104
+ onInputValueChange={setQuery}
105
+ onOpenChange={(nextOpen, eventDetails) => {
106
+ if (
107
+ !nextOpen &&
108
+ !closeOnSelect &&
109
+ eventDetails.reason === "item-press"
110
+ ) {
111
+ eventDetails.cancel();
112
+ return;
113
+ }
114
+ if (!nextOpen) setQuery("");
115
+ }}
116
+ name={name}
117
+ disabled={disabled}
118
+ >
119
+ <ComboboxPrimitive.Trigger
120
+ data-slot="icon-picker-trigger"
121
+ render={
122
+ <Button
123
+ type="button"
124
+ variant="outline"
125
+ disabled={disabled}
126
+ className={cn("w-full justify-start gap-2", className)}
127
+ />
128
+ }
129
+ >
130
+ <ComboboxPrimitive.Value>
131
+ {(selected: string | null) => (
132
+ <>
133
+ {selected ? <Icon name={selected} className="size-4" /> : null}
134
+ <span className="truncate">
135
+ {selected ? formatIconName(selected) : placeholder}
136
+ </span>
137
+ </>
138
+ )}
139
+ </ComboboxPrimitive.Value>
140
+ </ComboboxPrimitive.Trigger>
141
+ {/* Fixed width keeps the 6-column grid compact: anchored to a wide
142
+ (e.g. full-width) trigger, an anchor-width popup would stretch the
143
+ swatches apart. max-w clamps to the viewport on small screens. */}
144
+ <ComboboxContent className="w-72 min-w-72 max-w-(--available-width)">
145
+ <ComboboxInput
146
+ placeholder={searchPlaceholder}
147
+ showTrigger={false}
148
+ data-slot="icon-picker-search"
149
+ />
150
+ <ComboboxEmpty>{emptyLabel}</ComboboxEmpty>
151
+ <ComboboxList
152
+ aria-label={placeholder}
153
+ data-slot="icon-picker-grid"
154
+ className="space-y-1"
155
+ >
156
+ {(row: string[], rowIndex: number) => (
157
+ <ComboboxRow
158
+ key={row[0] ?? rowIndex}
159
+ className="grid grid-cols-6 gap-1"
160
+ >
161
+ {row.map((iconName) => (
162
+ <ComboboxPrimitive.Item
163
+ key={iconName}
164
+ value={iconName}
165
+ data-slot="icon-picker-swatch"
166
+ title={formatIconName(iconName)}
167
+ aria-label={formatIconName(iconName)}
168
+ className="flex aspect-square items-center justify-center rounded-md transition-colors duration-100 outline-none select-none data-highlighted:bg-muted data-selected:bg-accent data-selected:ring-1 data-selected:ring-primary"
169
+ >
170
+ <Icon name={iconName} className="size-5" />
171
+ </ComboboxPrimitive.Item>
172
+ ))}
173
+ </ComboboxRow>
174
+ )}
175
+ </ComboboxList>
176
+ {hidden > 0 && (
177
+ <p className="pb-1 text-center text-xs text-muted-foreground">
178
+ {moreLabel
179
+ ? moreLabel(hidden)
180
+ : `+${hidden} more — refine your search`}
181
+ </p>
182
+ )}
183
+ <ComboboxPrimitive.Clear
184
+ data-slot="icon-picker-clear"
185
+ render={<Button type="button" variant="ghost" size="sm" />}
186
+ className="m-1 mt-0"
187
+ >
188
+ {clearLabel}
189
+ </ComboboxPrimitive.Clear>
190
+ </ComboboxContent>
191
+ </Combobox>
192
+ );
193
+ }