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.
npx shadcn@latest add @paragon/availability-grid"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>
);
}