Availability Grid
Inputs & Forms

Availability Grid

A week-by-hour selectable grid for office hours: drag paints a pointer-captured rectangular sweep, arrows rove with Space toggling, and selected cells grow in.

Install

npx shadcn@latest add @paragon/availability-grid

availability-grid.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
const DAY_NAMES = [
  "Sunday",
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
] as const;

const hourLabel = (h: number) =>
  h === 0 || h === 24
    ? "12 AM"
    : h === 12
      ? "12 PM"
      : h < 12
        ? `${h} AM`
        : `${h - 12} PM`;

/** Stable cell key: `${weekday}-${hour}` with JS weekday numbers (0 = Sunday). */
export const availabilityKey = (weekday: number, hour: number) =>
  `${weekday}-${hour}`;

export interface AvailabilityGridProps
  extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  /** Selected cell keys (see `availabilityKey`). */
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (keys: string[]) => void;
  /** Hour rows rendered, in whole hours (24h clock). */
  startHour?: number;
  endHour?: number;
  /** 0 = Sunday first column, 1 = Monday. */
  weekStartsOn?: 0 | 1;
  disabled?: boolean;
}

/**
 * A week x hour selectable grid for office hours and on-call windows.
 * Dragging paints a rectangular sweep (pointer-captured, add or remove
 * decided by the first cell), keyboard arrows rove with Space toggling,
 * and newly selected cells grow in on an interruptible transition.
 */
export function AvailabilityGrid({
  value: valueProp,
  defaultValue = [],
  onValueChange,
  startHour = 8,
  endHour = 18,
  weekStartsOn = 1,
  disabled = false,
  className,
  "aria-label": ariaLabel = "Weekly availability",
  ...props
}: AvailabilityGridProps) {
  const [valueState, setValueState] = React.useState<ReadonlySet<string>>(
    () => new Set(defaultValue),
  );
  const selected = React.useMemo(
    () => (valueProp !== undefined ? new Set(valueProp) : valueState),
    [valueProp, valueState],
  );

  const rows = Math.max(1, endHour - startHour);
  const weekdayAt = (col: number) => (col + weekStartsOn) % 7;

  const gridRef = React.useRef<HTMLDivElement>(null);
  const [drag, setDrag] = React.useState<{
    anchor: [number, number];
    current: [number, number];
    mode: "add" | "remove";
  } | null>(null);
  const dragRef = React.useRef(drag);
  dragRef.current = drag;

  const [focusCell, setFocusCell] = React.useState<[number, number]>([0, 0]);

  const commit = (next: Set<string>) => {
    setValueState(next);
    onValueChange?.([...next].sort());
  };

  const inSweep = (row: number, col: number) => {
    if (!drag) return false;
    const [ar, ac] = drag.anchor;
    const [cr, cc] = drag.current;
    return (
      row >= Math.min(ar, cr) &&
      row <= Math.max(ar, cr) &&
      col >= Math.min(ac, cc) &&
      col <= Math.max(ac, cc)
    );
  };

  const cellFromPoint = (clientX: number, clientY: number): [number, number] | null => {
    const rect = gridRef.current?.getBoundingClientRect();
    if (!rect || rect.width === 0 || rect.height === 0) return null;
    const col = Math.floor(((clientX - rect.left) / rect.width) * 7);
    const row = Math.floor(((clientY - rect.top) / rect.height) * rows);
    return [
      Math.max(0, Math.min(rows - 1, row)),
      Math.max(0, Math.min(6, col)),
    ];
  };

  const applySweep = () => {
    const d = dragRef.current;
    if (!d) return;
    const next = new Set(selected);
    const [ar, ac] = d.anchor;
    const [cr, cc] = d.current;
    for (let r = Math.min(ar, cr); r <= Math.max(ar, cr); r++) {
      for (let c = Math.min(ac, cc); c <= Math.max(ac, cc); c++) {
        const key = availabilityKey(weekdayAt(c), startHour + r);
        if (d.mode === "add") next.add(key);
        else next.delete(key);
      }
    }
    commit(next);
    setDrag(null);
  };

  const toggle = (row: number, col: number) => {
    const key = availabilityKey(weekdayAt(col), startHour + row);
    const next = new Set(selected);
    if (next.has(key)) next.delete(key);
    else next.add(key);
    commit(next);
  };

  const moveFocus = (row: number, col: number) => {
    const r = Math.max(0, Math.min(rows - 1, row));
    const c = Math.max(0, Math.min(6, col));
    setFocusCell([r, c]);
    gridRef.current
      ?.querySelector<HTMLButtonElement>(`[data-cell="${r}-${c}"]`)
      ?.focus();
  };

  const totalHours = selected.size;

  return (
    <div
      className={cn("w-full max-w-md min-w-0 select-none", className)}
      {...props}
    >
      <span aria-live="polite" className="sr-only">
        {totalHours} {totalHours === 1 ? "hour" : "hours"} selected
      </span>
      <div className="flex">
        <div className="w-12 shrink-0" aria-hidden />
        <div className="grid flex-1 grid-cols-7">
          {Array.from({ length: 7 }, (_, c) => (
            <span
              key={c}
              aria-hidden
              className="pb-1.5 text-center text-[11px] font-medium text-muted-foreground"
            >
              {DAY_LABELS[weekdayAt(c)]}
            </span>
          ))}
        </div>
      </div>
      <div className="flex">
        <div
          className="relative w-12 shrink-0"
          style={{ height: rows * 28 }}
          aria-hidden
        >
          {Array.from({ length: rows }, (_, r) => (
            <span
              key={r}
              className="absolute right-2.5 flex items-center text-[10px] text-muted-foreground tabular-nums"
              style={{ top: r * 28, height: 25 }}
            >
              {hourLabel(startHour + r)}
            </span>
          ))}
        </div>
        <div
          ref={gridRef}
          role="grid"
          aria-label={ariaLabel}
          aria-disabled={disabled || undefined}
          className={cn(
            "grid flex-1 touch-none grid-cols-7 gap-[3px]",
            disabled && "pointer-events-none opacity-50",
          )}
          style={{ gridTemplateRows: `repeat(${rows}, 25px)` }}
          onPointerMove={(e) => {
            if (!dragRef.current) return;
            const cell = cellFromPoint(e.clientX, e.clientY);
            if (!cell) return;
            setDrag((d) =>
              d && (d.current[0] !== cell[0] || d.current[1] !== cell[1])
                ? { ...d, current: cell }
                : d,
            );
          }}
          onPointerUp={applySweep}
          onPointerCancel={() => setDrag(null)}
        >
          {Array.from({ length: rows * 7 }, (_, i) => {
            const row = Math.floor(i / 7);
            const col = i % 7;
            const weekday = weekdayAt(col);
            const hour = startHour + row;
            const key = availabilityKey(weekday, hour);
            const committed = selected.has(key);
            const on = drag && inSweep(row, col) ? drag.mode === "add" : committed;
            const isTabbable = focusCell[0] === row && focusCell[1] === col;
            return (
              <button
                key={key}
                type="button"
                role="gridcell"
                data-cell={`${row}-${col}`}
                tabIndex={isTabbable ? 0 : -1}
                aria-selected={on}
                aria-label={`${DAY_NAMES[weekday]} ${hourLabel(hour)}`}
                onPointerDown={(e) => {
                  if (e.button !== 0) return;
                  gridRef.current?.setPointerCapture(e.pointerId);
                  setDrag({
                    anchor: [row, col],
                    current: [row, col],
                    mode: committed ? "remove" : "add",
                  });
                  setFocusCell([row, col]);
                }}
                onKeyDown={(e) => {
                  if (e.key === " " || e.key === "Enter") {
                    e.preventDefault();
                    toggle(row, col);
                  } else if (e.key === "ArrowUp") {
                    e.preventDefault();
                    moveFocus(row - 1, col);
                  } else if (e.key === "ArrowDown") {
                    e.preventDefault();
                    moveFocus(row + 1, col);
                  } else if (e.key === "ArrowLeft") {
                    e.preventDefault();
                    moveFocus(row, col - 1);
                  } else if (e.key === "ArrowRight") {
                    e.preventDefault();
                    moveFocus(row, col + 1);
                  } else if (e.key === "Home") {
                    e.preventDefault();
                    moveFocus(row, 0);
                  } else if (e.key === "End") {
                    e.preventDefault();
                    moveFocus(row, 6);
                  }
                }}
                className="relative rounded-[5px] bg-muted/70 outline-none transition-colors duration-100 ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
              >
                <span
                  aria-hidden
                  className={cn(
                    "absolute inset-0 rounded-[5px] bg-primary transition-[scale,opacity] duration-150 ease-out motion-reduce:scale-100",
                    on ? "scale-100 opacity-100" : "scale-50 opacity-0",
                  )}
                />
              </button>
            );
          })}
        </div>
      </div>
    </div>
  );
}