feat: add reusable date and time picker primitives
This commit is contained in:
185
src/components/ui/combined-date-picker-demo.tsx
Normal file
185
src/components/ui/combined-date-picker-demo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
307
src/components/ui/combobox.tsx
Normal file
307
src/components/ui/combobox.tsx
Normal 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,
|
||||
};
|
||||
322
src/components/ui/date-picker.tsx
Normal file
322
src/components/ui/date-picker.tsx
Normal 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
246
src/components/ui/field.tsx
Normal 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,
|
||||
};
|
||||
99
src/components/ui/input-group.tsx
Normal file
99
src/components/ui/input-group.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/components/ui/spinner.tsx
Normal file
17
src/components/ui/spinner.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
2228
src/components/ui/time-picker.tsx
Normal file
2228
src/components/ui/time-picker.tsx
Normal file
File diff suppressed because it is too large
Load Diff
160
src/components/visually-hidden-input.tsx
Normal file
160
src/components/visually-hidden-input.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user