A capacity-aware day/time-window grid where full windows are disabled and the selection morphs between cells.
npx shadcn@latest add @paragon/delivery-slot-picker"use client";
import * as React from "react";
import { motion } from "motion/react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface DeliverySlot {
/** Remaining capacity for this window. 0 disables it. */
capacity: number;
}
export interface DeliveryDay {
/** Weekday abbreviation, e.g. "Mon". */
weekday: string;
/** Day-of-month label, e.g. "12". */
date: string;
/** One entry per time window, aligned to `windows`. */
slots: DeliverySlot[];
}
export interface DeliverySlotPickerProps
extends Omit<React.ComponentProps<"div">, "onSelect" | "defaultValue"> {
/** Time-window row labels, e.g. ["8–10", "10–12", ...]. */
windows: string[];
days: DeliveryDay[];
/** Controlled selection as [dayIndex, windowIndex]. */
value?: [number, number] | null;
defaultValue?: [number, number] | null;
onSelect?: (selection: [number, number]) => void;
}
/**
* A capacity-aware delivery-slot grid: days across the top, time windows down
* the side. Full windows (capacity 0) are disabled; low-capacity windows are
* flagged. The active cell is marked by a shared layout indicator that morphs
* from cell to cell (motion/react `layoutId`), which respects reduced motion.
*/
export function DeliverySlotPicker({
windows,
days,
value,
defaultValue = null,
onSelect,
className,
...props
}: DeliverySlotPickerProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const [internal, setInternal] = React.useState<[number, number] | null>(
defaultValue,
);
const selection = value ?? internal;
const select = (day: number, window: number) => {
if (days[day]?.slots[window]?.capacity <= 0) return;
if (value === undefined) setInternal([day, window]);
onSelect?.([day, window]);
};
return (
<div
data-slot="delivery-slot-picker"
className={cn(
"w-full max-w-xl rounded-xl bg-card p-4 shadow-border",
className,
)}
{...props}
>
<div
role="grid"
aria-label="Delivery time slots"
className="grid gap-1.5"
style={{
gridTemplateColumns: `4.5rem repeat(${days.length}, minmax(0, 1fr))`,
}}
>
{/* Header row */}
<div role="columnheader" aria-hidden />
{days.map((day) => (
<div
key={`${day.weekday}-${day.date}`}
role="columnheader"
className="flex flex-col items-center pb-1 text-center"
>
<span className="text-xs text-muted-foreground">{day.weekday}</span>
<span className="text-sm font-medium tabular-nums">{day.date}</span>
</div>
))}
{/* Window rows */}
{windows.map((window, wi) => (
<React.Fragment key={window}>
<div
role="rowheader"
className="flex items-center pr-1 text-xs text-muted-foreground tabular-nums"
>
{window}
</div>
{days.map((day, di) => {
const slot = day.slots[wi];
const capacity = slot?.capacity ?? 0;
const full = capacity <= 0;
const low = capacity > 0 && capacity <= 2;
const active =
selection?.[0] === di && selection?.[1] === wi;
return (
<div key={di} role="gridcell" className="relative">
<button
type="button"
disabled={full}
aria-pressed={active}
aria-label={`${day.weekday} ${day.date}, ${window}${
full
? ", full"
: `, ${capacity} slot${capacity === 1 ? "" : "s"} left`
}`}
onClick={() => select(di, wi)}
className={cn(
"pressable relative flex h-11 w-full items-center justify-center rounded-lg text-xs font-medium outline-none transition-[background-color,color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
full &&
"cursor-not-allowed bg-secondary/40 text-muted-foreground/50 line-through",
!full &&
!active &&
!low &&
"bg-secondary/60 text-foreground hover:bg-secondary",
!full &&
!active &&
low &&
"bg-warning/15 text-foreground hover:bg-warning/25",
active && "text-primary-foreground",
)}
>
{active && (
<motion.span
layoutId={`slot-active-${id}`}
aria-hidden
className="absolute inset-0 rounded-lg bg-primary"
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
/>
)}
<span className="relative z-10 flex items-center gap-1">
{active ? (
<Check className="size-3.5" />
) : full ? (
"Full"
) : low ? (
`${capacity} left`
) : (
"Open"
)}
</span>
</button>
</div>
);
})}
</React.Fragment>
))}
</div>
<div className="mt-3 flex items-center gap-4 border-t border-border pt-3 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-secondary" /> Available
</span>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-warning" /> Limited
</span>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-secondary/40" /> Full
</span>
</div>
</div>
);
}