Inputs & Forms

Delivery Slot Picker

A capacity-aware day/time-window grid 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 } 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>
  );
}