"use client";
import * as React from "react";
/* ---------------------------------- types ---------------------------------- */
export type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
/** A half-open time range on one day, as 24-hour "HH:mm" strings (end is exclusive). */
export interface AvailabilityRange {
start: string;
end: string;
}
/** Available ranges per weekday, merged and sorted. Days without ranges may be omitted. */
export type AvailabilityValue = Partial<Record<Weekday, AvailabilityRange[]>>;
export interface AvailabilityGridProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "children"> {
/** Controlled availability. */
value?: AvailabilityValue;
/** Initial availability when uncontrolled. */
defaultValue?: AvailabilityValue;
/** Called with merged ranges per day after every paint, toggle or action. */
onChange?: (value: AvailabilityValue) => void;
/** Columns, in order. */
days?: Weekday[];
/** First slot start, "HH:mm". */
startTime?: string;
/** Last slot end, "HH:mm". */
endTime?: string;
/** Slot length in minutes (a divisor of 60, e.g. 15, 30, 60). */
slotMinutes?: number;
/** Time zone shown in the toolbar, e.g. "Kuala Lumpur · GMT+8". Display only. */
timeZoneLabel?: string;
/** BCP 47 locale for weekday names. */
locale?: string;
/** Clock used for time labels. */
hourCycle?: "12" | "24";
/** Accessible name and visible title. */
label?: string;
/** Per-day summary under the grid. */
showSummary?: boolean;
/** "Copy Monday to weekdays" and "Clear" actions. */
showActions?: boolean;
/** Row height in px. */
rowHeight?: number;
/** Painted ranges, focus ring and drag highlight colour. */
accent?: string;
}
/* --------------------------------- helpers --------------------------------- */
const ALL_DAYS: Weekday[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
const WEEKDAYS: Weekday[] = ["mon", "tue", "wed", "thu", "fri"];
/** 2024-01-01 was a Monday — used only to ask Intl for weekday names. */
const DAY_INDEX: Record<Weekday, number> = { mon: 0, tue: 1, wed: 2, thu: 3, fri: 4, sat: 5, sun: 6 };
const pad = (n: number) => String(n).padStart(2, "0");
function toMinutes(time: string) {
const match = /^(\d{1,2}):(\d{2})$/.exec(time);
return match ? Math.min(24 * 60, Number(match[1]) * 60 + Number(match[2])) : 0;
}
const toTime = (minutes: number) => `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
function formatTime(minutes: number, hourCycle: "12" | "24") {
const h = Math.floor(minutes / 60) % 24;
const m = pad(minutes % 60);
if (hourCycle === "24") return `${h}:${m}`;
return `${h % 12 || 12}:${m} ${h < 12 ? "AM" : "PM"}`;
}
function weekdayNames(locale: string, style: "short" | "long") {
let fmt: Intl.DateTimeFormat;
try {
fmt = new Intl.DateTimeFormat(locale, { weekday: style, timeZone: "UTC" });
} catch {
fmt = new Intl.DateTimeFormat("en", { weekday: style, timeZone: "UTC" });
}
return (day: Weekday) => fmt.format(new Date(Date.UTC(2024, 0, 1 + DAY_INDEX[day]))).replace(/[ ]/g, " ");
}
type Slots = Record<Weekday, boolean[]>;
interface SlotWindow {
start: number;
step: number;
count: number;
}
function toSlots(value: AvailabilityValue, days: Weekday[], w: SlotWindow): Slots {
const slots = {} as Slots;
for (const day of days) {
const row = new Array<boolean>(w.count).fill(false);
for (const range of value[day] ?? []) {
const a = toMinutes(range.start);
const b = toMinutes(range.end);
for (let i = 0; i < w.count; i++) {
const s = w.start + i * w.step;
if (s >= a && s + w.step <= b) row[i] = true;
}
}
slots[day] = row;
}
return slots;
}
function rangesOf(row: boolean[], w: SlotWindow): AvailabilityRange[] {
const out: AvailabilityRange[] = [];
let i = 0;
while (i < row.length) {
if (!row[i]) {
i++;
continue;
}
const from = i;
while (i < row.length && row[i]) i++;
out.push({ start: toTime(w.start + from * w.step), end: toTime(w.start + i * w.step) });
}
return out;
}
function toValue(slots: Slots, days: Weekday[], w: SlotWindow): AvailabilityValue {
const value: AvailabilityValue = {};
for (const day of days) {
const ranges = rangesOf(slots[day], w);
if (ranges.length) value[day] = ranges;
}
return value;
}
/** Copy of `base` with the rectangle between two cells set to `on`. */
function paintRect(base: Slots, days: Weekday[], a: Cell, b: Cell, on: boolean): Slots {
const next = {} as Slots;
for (const day of days) next[day] = [...base[day]];
const [d0, d1] = [Math.min(a.day, b.day), Math.max(a.day, b.day)];
const [s0, s1] = [Math.min(a.slot, b.slot), Math.max(a.slot, b.slot)];
for (let d = d0; d <= d1; d++) for (let s = s0; s <= s1; s++) next[days[d]][s] = on;
return next;
}
interface Cell {
day: number;
slot: number;
}
/* -------------------------------- component -------------------------------- */
/**
* A weekly availability picker: days across, time slots down. Click-and-drag paints or erases a
* rectangle (the first cell decides which), the keyboard works like a spreadsheet (Space toggles,
* Shift + arrows extend), adjacent slots merge into ranges, and each day is summarised in words.
*/
export function AvailabilityGrid({
value,
defaultValue,
onChange,
days = ALL_DAYS,
startTime = "08:00",
endTime = "20:00",
slotMinutes = 30,
timeZoneLabel,
locale = "en",
hourCycle = "24",
label = "Weekly availability",
showSummary = true,
showActions = true,
rowHeight = 18,
accent = "#FF7448",
className = "",
style,
...props
}: AvailabilityGridProps) {
const uid = React.useId();
const titleId = `${uid}-title`;
const hintId = `${uid}-hint`;
const step = [5, 10, 15, 20, 30, 60].includes(slotMinutes) ? slotMinutes : 30;
const startMin = Math.floor(toMinutes(startTime) / step) * step;
const endMin = Math.max(startMin + step, Math.ceil(toMinutes(endTime) / step) * step);
const w: SlotWindow = { start: startMin, step, count: (endMin - startMin) / step };
const [inner, setInner] = React.useState<AvailabilityValue>(() => defaultValue ?? {});
const current = value ?? inner;
const slots = toSlots(current, days, w);
const [focus, setFocus] = React.useState<Cell>({ day: 0, slot: Math.min(w.count - 1, Math.max(0, (9 * 60 - startMin) / step)) });
const [dragging, setDragging] = React.useState(false);
const [announcement, setAnnouncement] = React.useState("");
const gridRef = React.useRef<HTMLDivElement>(null);
const pendingFocus = React.useRef(false);
/** Pointer paint in progress: the anchor cell, paint or erase, the slots before the gesture and the last cell. */
const drag = React.useRef<{ anchor: Cell; on: boolean; base: Slots; last: Cell; pointerId: number } | null>(null);
/** Keyboard selection anchor for Shift + arrows. */
const kbAnchor = React.useRef<{ anchor: Cell; on: boolean; base: Slots } | null>(null);
const shortName = weekdayNames(locale, "short");
const longName = weekdayNames(locale, "long");
const clampCell = (c: Cell): Cell => ({
day: Math.max(0, Math.min(days.length - 1, c.day)),
slot: Math.max(0, Math.min(w.count - 1, c.slot)),
});
const fc = clampCell(focus);
const commit = (next: Slots) => {
const v = toValue(next, days, w);
if (value === undefined) setInner(v);
onChange?.(v);
};
const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));
const slotText = (slot: number) => `${formatTime(startMin + slot * step, hourCycle)} to ${formatTime(startMin + (slot + 1) * step, hourCycle)}`;
const daySummary = (row: boolean[]) => {
const ranges = rangesOf(row, w);
return ranges.length
? ranges.map((r) => `${formatTime(toMinutes(r.start), hourCycle)}–${formatTime(toMinutes(r.end), hourCycle)}`).join(", ")
: "Unavailable";
};
/* --------------------------------- pointer -------------------------------- */
const cellFromPoint = (x: number, y: number): Cell | null => {
const el = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-cell]");
if (!el || !gridRef.current?.contains(el)) return null;
return { day: Number(el.dataset.day), slot: Number(el.dataset.slot) };
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const cell = cellFromPoint(event.clientX, event.clientY);
if (!cell) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
const on = !slots[days[cell.day]][cell.slot];
drag.current = { anchor: cell, on, base: slots, last: cell, pointerId: event.pointerId };
kbAnchor.current = null;
setDragging(true);
setFocus(cell);
// preventDefault (no text selection) also blocks focus, so move it explicitly.
pendingFocus.current = true;
commit(paintRect(slots, days, cell, cell, on));
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.pointerId !== event.pointerId) return;
const cell = cellFromPoint(event.clientX, event.clientY);
// Only re-paint when the pointer crosses into a different cell.
if (!cell || (cell.day === d.last.day && cell.slot === d.last.slot)) return;
d.last = cell;
setFocus(cell);
commit(paintRect(d.base, days, d.anchor, cell, d.on));
};
const endDrag = () => {
const d = drag.current;
if (!d) return;
drag.current = null;
setDragging(false);
pendingFocus.current = true;
const d0 = Math.min(d.anchor.day, d.last.day);
const d1 = Math.max(d.anchor.day, d.last.day);
const s0 = Math.min(d.anchor.slot, d.last.slot);
const s1 = Math.max(d.anchor.slot, d.last.slot);
const span = d0 === d1 ? longName(days[d0]) : `${longName(days[d0])} to ${longName(days[d1])}`;
announce(`${span}, ${formatTime(startMin + s0 * step, hourCycle)}–${formatTime(startMin + (s1 + 1) * step, hourCycle)} ${d.on ? "available" : "cleared"}.`);
};
/* -------------------------------- keyboard -------------------------------- */
React.useEffect(() => {
if (!pendingFocus.current) return;
pendingFocus.current = false;
gridRef.current?.querySelector<HTMLElement>(`[data-day="${fc.day}"][data-slot="${fc.slot}"]`)?.focus();
});
const toggleCell = (cell: Cell) => {
const on = !slots[days[cell.day]][cell.slot];
const next = paintRect(slots, days, cell, cell, on);
kbAnchor.current = { anchor: cell, on, base: slots };
commit(next);
announce(`${longName(days[cell.day])} ${slotText(cell.slot)} ${on ? "available" : "unavailable"}.`);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const ctrl = event.ctrlKey || event.metaKey;
const perHour = Math.max(1, 60 / step);
const moves: Record<string, () => Cell> = {
ArrowUp: () => ({ ...fc, slot: fc.slot - 1 }),
ArrowDown: () => ({ ...fc, slot: fc.slot + 1 }),
ArrowLeft: () => ({ ...fc, day: fc.day - 1 }),
ArrowRight: () => ({ ...fc, day: fc.day + 1 }),
PageUp: () => ({ ...fc, slot: fc.slot - perHour * 2 }),
PageDown: () => ({ ...fc, slot: fc.slot + perHour * 2 }),
Home: () => (ctrl ? { day: 0, slot: 0 } : { ...fc, day: 0 }),
End: () => (ctrl ? { day: days.length - 1, slot: w.count - 1 } : { ...fc, day: days.length - 1 }),
};
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
toggleCell(fc);
return;
}
const move = moves[event.key];
if (!move) return;
event.preventDefault();
const target = clampCell(move());
if (event.shiftKey && event.key.startsWith("Arrow")) {
// Extend from the anchor (the last toggled cell, or here) with the anchor's state.
const k = kbAnchor.current ?? { anchor: fc, on: true, base: slots };
kbAnchor.current = k;
commit(paintRect(k.base, days, k.anchor, target, k.on));
const s0 = Math.min(k.anchor.slot, target.slot);
const s1 = Math.max(k.anchor.slot, target.slot);
announce(`${formatTime(startMin + s0 * step, hourCycle)}–${formatTime(startMin + (s1 + 1) * step, hourCycle)} ${k.on ? "available" : "cleared"}.`);
} else {
kbAnchor.current = null;
}
setFocus(target);
pendingFocus.current = true;
};
/* --------------------------------- actions -------------------------------- */
const copyFirstToWeekdays = () => {
const source = days.find((d) => WEEKDAYS.includes(d));
if (!source) return;
const next = { ...slots };
for (const day of days) if (WEEKDAYS.includes(day) && day !== source) next[day] = [...slots[source]];
commit(next);
announce(`${longName(source)} copied to all weekdays.`);
};
const clearAll = () => {
const next = {} as Slots;
for (const day of days) next[day] = new Array<boolean>(w.count).fill(false);
commit(next);
announce("All availability cleared.");
};
/* --------------------------------- render --------------------------------- */
const totalSlots = days.reduce((n, d) => n + slots[d].filter(Boolean).length, 0);
const totalHours = Math.round(((totalSlots * step) / 60) * 10) / 10;
const firstWeekday = days.find((d) => WEEKDAYS.includes(d));
const gridTemplate = `3.5rem repeat(${days.length}, minmax(3rem, 1fr))`;
const rootStyle = { ["--ag-accent" as string]: accent, ...style };
const actionClass =
"rounded-md border border-zinc-900/15 px-2.5 py-1 text-[12px] font-medium outline-hidden transition-colors hover:bg-zinc-900/[0.04] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ag-accent)] disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/15 dark:hover:bg-white/[0.06]";
return (
<div
className={`flex w-full min-w-0 flex-col gap-3 text-zinc-900 dark:text-zinc-100 ${className}`}
style={rootStyle}
{...props}
>
<div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
<div className="flex flex-col gap-0.5">
<h3 id={titleId} className="text-[14px] font-semibold tracking-[-0.01em]">
{label}
</h3>
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{totalHours} h / week{timeZoneLabel ? ` · ${timeZoneLabel}` : ""}
</p>
</div>
{showActions ? (
<div className="flex flex-wrap gap-2">
<button type="button" onClick={copyFirstToWeekdays} disabled={!firstWeekday} className={actionClass}>
Copy {firstWeekday ? shortName(firstWeekday) : "Mon"} to weekdays
</button>
<button type="button" onClick={clearAll} disabled={totalSlots === 0} className={actionClass}>
Clear
</button>
</div>
) : null}
</div>
<div className="min-w-0 overflow-x-auto rounded-xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900">
<div
ref={gridRef}
role="grid"
aria-labelledby={titleId}
aria-describedby={hintId}
aria-multiselectable="true"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
onKeyDown={handleKeyDown}
className={`min-w-max select-none touch-pan-x p-2 pr-3 sm:min-w-0 ${dragging ? "cursor-crosshair" : "cursor-cell"}`}
>
<div role="row" className="grid pb-1.5" style={{ gridTemplateColumns: gridTemplate }}>
<div role="columnheader">
<span className="sr-only">Time</span>
</div>
{days.map((day) => (
<div
key={day}
role="columnheader"
aria-label={longName(day)}
className="text-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400"
>
{shortName(day)}
</div>
))}
</div>
{Array.from({ length: w.count }, (_, slot) => {
const minutes = startMin + slot * step;
const onHour = minutes % 60 === 0;
return (
<div key={slot} role="row" className="grid" style={{ gridTemplateColumns: gridTemplate, height: rowHeight }}>
<div
role="rowheader"
aria-label={slotText(slot)}
className="relative -top-[0.45em] pr-2 text-right font-mono text-[10px] leading-none tabular-nums text-zinc-400 dark:text-zinc-500"
>
{onHour ? formatTime(minutes, hourCycle) : ""}
</div>
{days.map((day, d) => {
const row = slots[day];
const on = row[slot];
const prevOn = slot > 0 && row[slot - 1];
const nextOn = slot < w.count - 1 && row[slot + 1];
const isFocus = fc.day === d && fc.slot === slot;
const rangeStart = on && !prevOn;
// Label a range at its first cell when it is at least an hour long.
let runLength = 0;
if (rangeStart) while (row[slot + runLength]) runLength++;
return (
<div
key={day}
role="gridcell"
data-cell=""
data-day={d}
data-slot={slot}
tabIndex={isFocus ? 0 : -1}
aria-selected={on}
aria-label={`${longName(day)} ${slotText(slot)}`}
onFocus={() => {
if (!isFocus) setFocus({ day: d, slot });
}}
className={`group/cell relative px-[3px] outline-hidden ${onHour && slot > 0 ? "border-t border-zinc-900/[0.07] dark:border-white/[0.07]" : "border-t border-transparent"}`}
>
<span
aria-hidden="true"
className={`absolute inset-x-[3px] transition-colors duration-100 motion-reduce:transition-none ${
on
? `bg-[var(--ag-accent)] ${prevOn ? "-top-[2px]" : "top-[1px] rounded-t-[5px]"} ${nextOn ? "-bottom-px" : "bottom-[1px] rounded-b-[5px]"}`
: "inset-y-[1px] rounded-[4px] bg-zinc-900/[0.035] group-hover/cell:bg-zinc-900/[0.08] dark:bg-white/[0.04] dark:group-hover/cell:bg-white/[0.09]"
}`}
/>
{rangeStart && runLength * step >= 60 ? (
<span
aria-hidden="true"
className="pointer-events-none absolute left-[7px] top-[3px] font-mono text-[9px] font-semibold leading-none tabular-nums text-zinc-950/80"
>
{formatTime(minutes, hourCycle)}
</span>
) : null}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-[1px] inset-y-0 rounded-[6px] opacity-0 ring-2 ring-[var(--ag-accent)] ring-offset-1 ring-offset-white group-focus-visible/cell:opacity-100 dark:ring-offset-zinc-900"
/>
</div>
);
})}
</div>
);
})}
</div>
</div>
{showSummary ? (
<dl className="grid grid-cols-1 gap-x-6 gap-y-1 text-[12px] sm:grid-cols-2">
{days.map((day) => {
const text = daySummary(slots[day]);
return (
<div key={day} className="flex min-w-0 gap-3 border-b border-zinc-900/[0.06] py-1 dark:border-white/[0.06]">
<dt className="w-10 shrink-0 font-mono text-[10px] uppercase leading-[18px] tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{shortName(day)}
</dt>
<dd className={`min-w-0 tabular-nums ${text === "Unavailable" ? "text-zinc-400 dark:text-zinc-500" : "text-zinc-800 dark:text-zinc-200"}`}>
{text}
</dd>
</div>
);
})}
</dl>
) : null}
<p id={hintId} className="sr-only">
Arrow keys move between slots. Space toggles a slot; Shift plus arrow keys extend from the last toggled slot. Drag with a pointer to paint or erase.
</p>
<span className="sr-only" aria-live="polite">
{announcement}
</span>
</div>
);
}