feat: add reusable date and time picker primitives

This commit is contained in:
2026-04-11 00:02:51 -04:00
parent 1ad5603bf6
commit 82d04e7a84
17 changed files with 4023 additions and 39 deletions

View File

@@ -0,0 +1,185 @@
"use client";
import { addDays } from "date-fns";
import * as React from "react";
import { DatePicker } from "@/components/ui/date-picker";
import { Label } from "@/components/ui/label";
import {
TimePicker,
TimePickerContent,
TimePickerHour,
TimePickerInput,
TimePickerInputGroup,
TimePickerLabel,
TimePickerMinute,
TimePickerPeriod,
TimePickerSeparator,
TimePickerTrigger,
} from "@/components/ui/time-picker";
import { cn } from "@/lib/utils";
function getIs12Hour(locale?: string): boolean {
const testDate = new Date(2000, 0, 1, 13, 0, 0);
const formatted = new Intl.DateTimeFormat(locale, {
hour: "numeric",
}).format(testDate);
return /am|pm/i.test(formatted) || !formatted.includes("13");
}
interface FlightTimePickerProps {
defaultValue: string;
id: string;
is12Hour: boolean;
label: string;
locale: string | undefined;
}
function FlightTimePicker({
defaultValue,
id,
is12Hour,
label,
locale,
}: FlightTimePickerProps) {
return (
<TimePicker
key={`${id}-${locale ?? "default"}`}
defaultValue={defaultValue}
locale={locale}
className="w-28 shrink-0"
>
<TimePickerLabel className="sr-only">{label}</TimePickerLabel>
<TimePickerInputGroup className="bg-background">
<TimePickerInput id={id} segment="hour" />
<TimePickerSeparator />
<TimePickerInput segment="minute" />
{is12Hour ? (
<TimePickerInput className="ml-1" segment="period" />
) : null}
<TimePickerTrigger aria-label={`Open ${label.toLowerCase()} picker`} />
</TimePickerInputGroup>
<TimePickerContent className="max-w-none w-max overflow-hidden p-0">
<div className="flex">
<div className="flex flex-col border-r">
<div className="border-b px-2 py-1.5 text-center text-xs font-semibold text-muted-foreground">
Hr
</div>
<TimePickerHour className="min-w-12 border-none" format="2-digit" />
</div>
<div className={cn("flex flex-col", is12Hour && "border-r")}>
<div className="border-b px-2 py-1.5 text-center text-xs font-semibold text-muted-foreground">
Min
</div>
<TimePickerMinute className="min-w-12 border-none" />
</div>
{is12Hour ? (
<div className="flex flex-col">
<div className="border-b px-2 py-1.5 text-center text-xs font-semibold text-muted-foreground">
AM/PM
</div>
<TimePickerPeriod className="min-w-14 border-none" />
</div>
) : null}
</div>
</TimePickerContent>
</TimePicker>
);
}
export function CombinedDatePickerDemo() {
const dateFromId = React.useId();
const dateToId = React.useId();
const timeFromId = React.useId();
const timeToId = React.useId();
const today = React.useMemo(() => new Date(), []);
const departureDefault = React.useMemo(() => today, [today]);
const returnDefault = React.useMemo(() => addDays(today, 7), [today]);
const [openFrom, setOpenFrom] = React.useState(false);
const [openTo, setOpenTo] = React.useState(false);
const [dateFrom, setDateFrom] = React.useState<Date | undefined>(
departureDefault,
);
const [dateTo, setDateTo] = React.useState<Date | undefined>(returnDefault);
const [monthFrom, setMonthFrom] = React.useState(departureDefault);
const [monthTo, setMonthTo] = React.useState(returnDefault);
const [timeLocale, setTimeLocale] = React.useState<string | undefined>(
undefined,
);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
setTimeLocale(
new Intl.DateTimeFormat(undefined, {
hour: "numeric",
}).resolvedOptions().locale,
);
}, []);
const is12Hour = mounted ? getIs12Hour(timeLocale) : false;
return (
<div className="flex w-full max-w-sm min-w-0 flex-col gap-6">
<div className="flex items-end gap-4">
<div className="flex flex-1 flex-col gap-3">
<Label htmlFor={dateFromId} className="px-1">
Departure date
</Label>
<DatePicker
id={dateFromId}
date={dateFrom}
variant="input"
open={openFrom}
onOpenChange={setOpenFrom}
month={monthFrom}
onMonthChange={setMonthFrom}
onSelect={(nextDate) => {
setDateFrom(nextDate);
if (nextDate && dateTo && nextDate > dateTo) {
setDateTo(nextDate);
setMonthTo(nextDate);
}
}}
/>
</div>
<div className="flex flex-col gap-3">
<FlightTimePicker
id={timeFromId}
defaultValue="09:30"
is12Hour={is12Hour}
label="Departure time"
locale={timeLocale}
/>
</div>
</div>
<div className="flex items-end gap-4">
<div className="flex flex-1 flex-col gap-3">
<Label htmlFor={dateToId} className="px-1">
Return date
</Label>
<DatePicker
id={dateToId}
date={dateTo}
variant="input"
open={openTo}
onOpenChange={setOpenTo}
month={monthTo}
onMonthChange={setMonthTo}
disabled={dateFrom ? { before: dateFrom } : undefined}
onSelect={setDateTo}
/>
</div>
<div className="flex flex-col gap-3">
<FlightTimePicker
id={timeToId}
defaultValue="18:30"
is12Hour={is12Hour}
label="Return time"
locale={timeLocale}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,307 @@
"use client";
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon
data-slot="combobox-trigger-icon"
className="pointer-events-none size-4 text-muted-foreground"
/>
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<Button variant="ghost" size="icon" className="size-6 p-0" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<Button
size="icon"
variant="ghost"
asChild
data-slot="input-group-button"
className="size-6 p-0 group-has-[data-slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</Button>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-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-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none 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",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
className,
)}
{...props}
/>
);
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
data-slot="combobox-item-indicator"
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
);
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn(
"px-2 py-1.5 text-xs text-muted-foreground pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
className,
)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className,
)}
{...props}
/>
);
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-[3px] has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1.5 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon" className="size-5 p-0" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxSeparator,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};

View File

@@ -0,0 +1,322 @@
"use client";
import { addDays, format, isValid, parse } from "date-fns";
import { CalendarIcon, ChevronDownIcon } from "lucide-react";
import * as React from "react";
import type { DropdownProps, Matcher } from "react-day-picker";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Field, FieldLabel } from "@/components/ui/field";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface DropdownItem {
disabled?: boolean;
label: string;
value: string;
}
function CalendarDropdown(props: DropdownProps) {
const { options, value, onChange, "aria-label": ariaLabel } = props;
const items: DropdownItem[] =
options?.map((option) => ({
disabled: option.disabled,
label: option.label,
value: option.value.toString(),
})) ?? [];
const selectedItem = items.find((item) => item.value === value?.toString());
const handleValueChange = (newValue: DropdownItem | null) => {
if (onChange && newValue) {
const syntheticEvent = {
target: { value: newValue.value },
} as React.ChangeEvent<HTMLSelectElement>;
onChange(syntheticEvent);
}
};
return (
<>
<div className="relative flex items-center sm:hidden">
<select
aria-label={ariaLabel}
className="absolute inset-0 z-10 w-full cursor-pointer opacity-0"
value={value?.toString() ?? ""}
onChange={onChange}
>
{options?.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>
<Button
variant="ghost"
size="sm"
className="pointer-events-none h-8 w-full justify-between gap-2 px-2 font-medium"
tabIndex={-1}
>
{selectedItem?.label}
<ChevronDownIcon className="size-4 opacity-50" />
</Button>
</div>
<div className="hidden sm:block">
<Combobox
aria-label={ariaLabel}
autoHighlight
items={items}
onValueChange={handleValueChange}
value={selectedItem}
>
<ComboboxInput
className="mx-1 h-8 min-w-[90px] border-none bg-transparent shadow-none outline-none ring-0 focus-within:ring-0 before:hidden hover:bg-accent hover:text-accent-foreground focus-within:bg-accent focus-within:text-accent-foreground **:[input]:w-0 **:[input]:flex-1 **:[input]:cursor-pointer **:[input]:text-center **:[input]:font-medium"
onFocus={(e) => e.currentTarget.select()}
/>
<ComboboxContent aria-label={ariaLabel}>
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList>
{(item: DropdownItem) => (
<ComboboxItem
disabled={item.disabled}
key={item.value}
value={item}
>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</>
);
}
export interface DatePickerProps {
date?: Date;
onSelect?: (date: Date | undefined) => void;
startMonth?: Date;
endMonth?: Date;
disabled?: Matcher | Matcher[];
label?: string;
id?: string;
variant?: "button" | "input";
open?: boolean;
onOpenChange?: (open: boolean) => void;
month?: Date;
onMonthChange?: (month: Date) => void;
}
export function DatePicker({
date,
onSelect,
startMonth = new Date(1900, 0, 1),
endMonth = new Date(2100, 11, 31),
disabled,
label,
id: providedId,
variant = "button",
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
month: controlledMonth,
onMonthChange: controlledOnMonthChange,
}: DatePickerProps) {
const fallbackId = React.useId();
const id = providedId ?? fallbackId;
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = controlledOnOpenChange ?? setUncontrolledOpen;
const today = new Date();
const [uncontrolledMonth, setUncontrolledMonth] = React.useState(
date || today,
);
const month = controlledMonth ?? uncontrolledMonth;
const setMonth = controlledOnMonthChange ?? setUncontrolledMonth;
const [inputValue, setInputValue] = React.useState(() =>
date ? format(date, "yyyy-MM-dd") : "",
);
React.useEffect(() => {
setInputValue(date ? format(date, "yyyy-MM-dd") : "");
}, [date]);
const handleSelect = (selectedDate: Date | undefined) => {
onSelect?.(selectedDate);
setOpen(false);
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = event.target.value;
const numericValue = nextValue.replace(/\D/g, "");
let formattedValue = "";
if (numericValue.length > 0) {
formattedValue += numericValue.substring(0, 4);
}
if (numericValue.length >= 5) {
formattedValue += `-${numericValue.substring(4, 6)}`;
}
if (numericValue.length >= 7) {
formattedValue += `-${numericValue.substring(6, 8)}`;
}
setInputValue(formattedValue);
if (!formattedValue) {
onSelect?.(undefined);
return;
}
if (formattedValue.length === 10) {
const nextDate = parse(formattedValue, "yyyy-MM-dd", new Date());
if (isValid(nextDate)) {
onSelect?.(nextDate);
setMonth(nextDate);
}
}
};
const triggerContent =
variant === "input" ? (
<div className="flex h-10 w-full cursor-text items-center gap-0.5 rounded-md border border-input bg-background px-3 py-2 shadow-xs outline-none transition-shadow has-[input:focus]:border-ring has-[input:focus]:ring-[3px] has-[input:focus]:ring-ring/50">
<input
id={id}
type="text"
inputMode="text"
placeholder="YYYY-MM-DD"
value={inputValue}
onChange={handleInputChange}
onClick={(event) => event.stopPropagation()}
className="inline-flex h-full w-full min-w-0 border-0 bg-transparent text-sm tabular-nums font-mono outline-none transition-colors focus:bg-transparent disabled:cursor-not-allowed disabled:opacity-50"
/>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="ml-auto size-7 shrink-0 text-muted-foreground hover:text-foreground"
>
<CalendarIcon aria-hidden="true" className="size-4" />
</Button>
</PopoverTrigger>
</div>
) : (
<PopoverTrigger asChild>
<Button
id={id}
className="w-full justify-start font-normal"
variant="outline"
>
<CalendarIcon aria-hidden="true" className="mr-2 h-4 w-4" />
{date ? format(date, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
);
const popover = (
<Popover open={open} onOpenChange={setOpen}>
{triggerContent}
<PopoverContent className="w-auto p-0" align="start">
<div className="flex max-sm:flex-col">
<div className="relative py-1 ps-1 max-sm:order-1 max-sm:border-t">
<div className="flex h-full flex-col sm:border-e sm:pe-3">
<Button
className="w-full justify-start"
onClick={() => {
handleSelect(today);
setMonth(today);
}}
size="sm"
variant="ghost"
>
Today
</Button>
<Button
className="w-full justify-start"
onClick={() => {
const tomorrow = addDays(today, 1);
handleSelect(tomorrow);
setMonth(tomorrow);
}}
size="sm"
variant="ghost"
>
Tomorrow
</Button>
<Button
className="w-full justify-start"
onClick={() => {
const in3Days = addDays(today, 3);
handleSelect(in3Days);
setMonth(in3Days);
}}
size="sm"
variant="ghost"
>
In 3 days
</Button>
<Button
className="w-full justify-start"
onClick={() => {
const inAWeek = addDays(today, 7);
handleSelect(inAWeek);
setMonth(inAWeek);
}}
size="sm"
variant="ghost"
>
In a week
</Button>
</div>
</div>
<Calendar
className="max-sm:pb-3 sm:ps-2"
mode="single"
captionLayout="dropdown"
components={{ Dropdown: CalendarDropdown }}
startMonth={startMonth}
endMonth={endMonth}
disabled={disabled}
month={month}
onMonthChange={setMonth}
onSelect={handleSelect}
selected={date}
/>
</div>
</PopoverContent>
</Popover>
);
if (label) {
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
{popover}
</Field>
);
}
return popover;
}

246
src/components/ui/field.tsx Normal file
View File

@@ -0,0 +1,246 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className,
)}
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"fieldset"> & VariantProps<typeof fieldVariants>) {
return (
<fieldset
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className,
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
];
if (uniqueErrors?.length === 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error) =>
error?.message && <li key={error.message}>{error.message}</li>,
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
};

View File

@@ -0,0 +1,99 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text select-none items-center justify-center gap-2 leading-none [&>kbd]:rounded-[calc(var(--radius)-5px)] in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:-mx-0.5 not-has-[button]:**:[svg:not([class*='opacity-'])]:opacity-80",
{
defaultVariants: {
align: "inline-start",
},
variants: {
align: {
"block-end":
"order-last w-full justify-start px-[calc(--spacing(3)-1px)] pb-[calc(--spacing(3)-1px)] [.border-t]:pt-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
"block-start":
"order-first w-full justify-start px-[calc(--spacing(3)-1px)] pt-[calc(--spacing(3)-1px)] [.border-b]:pb-[calc(--spacing(3)-1px)] [[data-size=sm]+&]:px-[calc(--spacing(2.5)-1px)]",
"inline-end":
"order-last pe-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-me-1.5 has-[>button]:-me-2 has-[>kbd:last-child]:me-[-0.35rem] [[data-size=sm]+&]:pe-[calc(--spacing(2.5)-1px)]",
"inline-start":
"order-first ps-[calc(--spacing(3)-1px)] has-[>:last-child[data-slot=badge]]:-ms-1.5 has-[>button]:-ms-2 has-[>kbd:last-child]:ms-[-0.35rem] [[data-size=sm]+&]:ps-[calc(--spacing(2.5)-1px)]",
},
},
},
);
export function InputGroup({
className,
...props
}: React.ComponentProps<"div">): React.ReactElement {
return (
<div
className={cn(
"relative inline-flex w-full min-w-0 items-center rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)] has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]",
className,
)}
data-slot="input-group"
{...props}
/>
);
}
export function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof inputGroupAddonVariants>): React.ReactElement {
return (
<div
className={cn(inputGroupAddonVariants({ align }), className)}
data-align={align}
data-slot="input-group-addon"
{...props}
/>
);
}
export function InputGroupText({
className,
...props
}: React.ComponentProps<"span">): React.ReactElement {
return (
<span
className={cn(
"line-clamp-1 flex items-center gap-2 whitespace-nowrap text-muted-foreground leading-none in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4.5 sm:in-[[data-slot=input-group]:has([data-slot=input-control],[data-slot=textarea-control])]:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5",
className,
)}
{...props}
/>
);
}
export function InputGroupInput({
className,
...props
}: React.ComponentProps<typeof Input>): React.ReactElement {
return (
<Input
className={cn("border-0 bg-transparent shadow-none", className)}
{...props}
/>
);
}
export function InputGroupTextarea({
className,
...props
}: React.ComponentProps<typeof Textarea>): React.ReactElement {
return (
<Textarea
className={cn("border-0 bg-transparent shadow-none", className)}
{...props}
/>
);
}

View File

@@ -0,0 +1,17 @@
import { Loader2Icon } from "lucide-react";
import type React from "react";
import { cn } from "@/lib/utils";
export function Spinner({
className,
...props
}: React.ComponentProps<typeof Loader2Icon>): React.ReactElement {
return (
<Loader2Icon
aria-label="Loading"
className={cn("animate-spin", className)}
role="status"
{...props}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };