A capacity-aware day/time-window grid with arrow-key navigation, 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, useReducedMotion } 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`) and snaps under reduced motion.
* One tab stop; arrow keys move between open slots.
*/
export function DeliverySlotPicker({
windows,
days,
value,
defaultValue = null,
onSelect,
className,
...props
}: DeliverySlotPickerProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const reduced = useReducedMotion();
const [internal, setInternal] = React.useState<[number, number] | null>(
defaultValue,
);
const selection = value ?? internal;
const cellRefs = React.useRef(new Map<string, HTMLButtonElement | null>());
const isOpen = React.useCallback(
(day: number, window: number) =>
(days[day]?.slots[window]?.capacity ?? 0) > 0,
[days],
);
const select = (day: number, window: number) => {
if (!isOpen(day, window)) return;
if (value === undefined) setInternal([day, window]);
onSelect?.([day, window]);
};
// Single tab stop: the selection when it's still open, else the first
// open slot. Arrow keys walk the grid, skipping full windows.
const tabStop = React.useMemo<[number, number] | null>(() => {
if (selection && isOpen(selection[0], selection[1])) return selection;
for (let wi = 0; wi < windows.length; wi++) {
for (let di = 0; di < days.length; di++) {
if (isOpen(di, wi)) return [di, wi];
}
}
return null;
}, [selection, days, windows, isOpen]);
const moveFocus = (
day: number,
window: number,
dayDelta: number,
windowDelta: number,
) => {
let di = day + dayDelta;
let wi = window + windowDelta;
while (di >= 0 && di < days.length && wi >= 0 && wi < windows.length) {
if (isOpen(di, wi)) {
cellRefs.current.get(`${di}-${wi}`)?.focus();
return;
}
di += dayDelta;
wi += windowDelta;
}
};
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 — display:contents keeps the grid tracks intact while
giving the grid its required row semantics. */}
<div role="row" className="contents">
<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>
))}
</div>
{/* Window rows */}
{windows.map((window, wi) => (
<div role="row" className="contents" 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
ref={(node) => {
cellRefs.current.set(`${di}-${wi}`, node);
}}
type="button"
disabled={full}
tabIndex={
tabStop?.[0] === di && tabStop?.[1] === wi ? 0 : -1
}
aria-pressed={active}
aria-label={`${day.weekday} ${day.date}, ${window}${
full
? ", full"
: `, ${capacity} slot${capacity === 1 ? "" : "s"} left`
}`}
onClick={() => select(di, wi)}
onKeyDown={(event) => {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
};
const delta = deltas[event.key];
if (delta) {
event.preventDefault();
moveFocus(di, wi, delta[0], delta[1]);
}
}}
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={
reduced
? { duration: 0 }
: { 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>
);
})}
</div>
))}
</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>
);
}