Delivery Slot Picker
Inputs & Forms

Delivery Slot Picker

A capacity-aware day/time-window grid with arrow-key navigation, where full windows are disabled and the selection morphs between cells.

Install

npx shadcn@latest add @paragon/delivery-slot-picker

delivery-slot-picker.tsx

"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>
  );
}