@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,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
|
+
}
|