react-availability-grid
Version:
React component for selecting time availability across multiple days. Perfect for meeting schedulers and booking systems.
406 lines (400 loc) • 14 kB
JavaScript
// src/TimeGrid.tsx
import { useRef as useRef5, useEffect as useEffect3, useMemo, useCallback as useCallback2 } from "react";
import dayjs from "dayjs";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js";
// src/useFastMouseenter.ts
import { useCallback, useEffect, useRef, useState } from "react";
function useFastMouseenter(distance = 15) {
const [enabled, setEnabled] = useState(false);
const mousePos = useRef(null);
const lastEl = useRef(null);
const updateEl = useCallback((x, y) => {
const el = document.elementFromPoint(x, y);
if (el && el !== lastEl.current) {
lastEl.current = el;
var evt = new CustomEvent("fastmouseenter");
el.dispatchEvent(evt);
}
}, []);
const handleMouseMove = useCallback((event) => {
if (!lastEl.current) {
updateEl(event.clientX, event.clientY);
}
if (!mousePos.current) {
mousePos.current = { x: event.clientX, y: event.clientY };
return;
}
const { x: x1, y: y1 } = mousePos.current;
mousePos.current = { x: event.clientX, y: event.clientY };
const { clientX: x2, clientY: y2 } = event;
var dx = x2 - x1;
var dy = y2 - y1;
var d = Math.sqrt(dx * dx + dy * dy);
if (d < distance) {
updateEl(event.clientX, event.clientY);
return;
}
const t = distance / d;
const stepX = Math.trunc(t * dx);
const stepY = Math.trunc(t * dy);
const clampX = createClamp(x2, stepX);
const clampY = createClamp(y2, stepY);
let simulatedX = clampX(x1 + stepX);
let simulatedY = clampY(y1 + stepY);
while (simulatedX !== x2 || simulatedY !== y2) {
updateEl(simulatedX, simulatedY);
simulatedX = clampX(simulatedX + stepX);
simulatedY = clampY(simulatedY + stepY);
}
}, [updateEl, distance]);
useEffect(() => {
if (enabled) {
document.addEventListener("mousemove", handleMouseMove);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
};
} else {
mousePos.current = null;
lastEl.current = null;
}
}, [enabled, handleMouseMove]);
const hook = useCallback((enable, e) => {
setEnabled(enable);
if (enable && e) {
updateEl(e.clientX, e.clientY);
mousePos.current = { x: e.clientX, y: e.clientY };
}
}, [updateEl]);
return hook;
}
function createClamp(p2, delta) {
if (delta > 0) {
return (p1) => Math.min(p1, p2);
} else if (delta < 0) {
return (p1) => Math.max(p1, p2);
} else {
return (p1) => p2;
}
}
// src/TimeGridCell.tsx
import { useRef as useRef3 } from "react";
// src/useEventListener.ts
import { useEffect as useEffect2, useRef as useRef2 } from "react";
function useEventListener(eventName, handler, element) {
const savedHandler = useRef2(handler);
useEffect2(() => {
savedHandler.current = handler;
}, [handler]);
useEffect2(() => {
const targetElement = element?.current;
if (!targetElement) return;
const eventListener = (event) => {
savedHandler.current(event);
};
targetElement.addEventListener(eventName, eventListener);
return () => {
targetElement.removeEventListener(eventName, eventListener);
};
}, [eventName, element]);
}
// src/TimeGridCell.tsx
import { jsx } from "react/jsx-runtime";
function TimeGridCell({
day,
hour,
selectionSet,
isDisabled,
handleMouseDown,
handleMouseEnter,
handleKeyDown,
startHour,
finalHour
}) {
const dateTime = day.clone().hour(hour.hour()).minute(hour.minute()).second(0).millisecond(0);
const isSelected = selectionSet.has(dateTime.valueOf());
const disabled = isDisabled(dateTime);
const weekend = day.day() === 0 || day.day() === 6;
const earliest = hour.isSame(startHour, "hour");
const lastest = hour.isSame(finalHour, "hour");
const cellRef = useRef3(null);
useEventListener("fastmouseenter", () => {
handleMouseEnter(hour, day);
}, cellRef);
const classNames = [
"timegrid-cell",
isSelected && "selected",
disabled && "disabled",
weekend && "weekend",
earliest && "earliest",
lastest && "latest"
].filter(Boolean).join(" ");
const ariaLabel = `${day.format("dddd, MMMM D")} at ${hour.format("h:mm A")}${isSelected ? ", selected" : ""}${disabled ? ", unavailable" : ""}`;
const cellKey = `cell-${hour.format("H:mm")}-${day.format("ddd D")}`;
return /* @__PURE__ */ jsx(
"div",
{
ref: cellRef,
"data-cell-key": cellKey,
className: classNames,
role: "gridcell",
"aria-label": ariaLabel,
"aria-selected": isSelected,
"aria-disabled": disabled,
tabIndex: disabled ? -1 : 0,
onMouseDown: (e) => handleMouseDown(e, hour, day),
onKeyDown: handleKeyDown ? (e) => handleKeyDown(e, hour, day) : void 0,
children: hour.format("H:mm")
},
cellKey
);
}
// src/TimeGridDay.tsx
import { useRef as useRef4 } from "react";
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
function TimeGridDay({ day, handleMouseDown, handleMouseEnter }) {
const cellRef = useRef4(null);
useEventListener("fastmouseenter", () => {
handleMouseEnter(day);
}, cellRef);
return /* @__PURE__ */ jsxs(
"div",
{
ref: cellRef,
className: "timegrid-day",
role: "columnheader",
"aria-label": day.format("dddd, MMMM D, YYYY"),
onMouseDown: (e) => handleMouseDown(e, day),
children: [
day.format("ddd "),
/* @__PURE__ */ jsx2("div", { className: "timegrid-day-number", children: day.format("D") }),
day.format("MMM")
]
},
`day-${day.format("ddd D")}`
);
}
// src/TimeGrid.tsx
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
dayjs.extend(isSameOrBefore);
function TimeGrid({
selection,
setSelection,
startDate,
endDate,
intervalSize = 60,
earliestStart,
latestEnd,
allowedTimes,
onSelectionChange,
className,
style
}) {
const selectionAction = useRef5(null);
const selectionTarget = useRef5(null);
const fastMouseenter = useFastMouseenter();
const updateSelection = useCallback2((updater) => {
setSelection((prev) => {
const newSelection = typeof updater === "function" ? updater(prev) : updater;
if (onSelectionChange) {
onSelectionChange(newSelection);
}
return newSelection;
});
}, [setSelection, onSelectionChange]);
const days = useMemo(() => {
const daysArray = [];
let currentDay = startDate.clone().startOf("day");
while (currentDay.isSameOrBefore(endDate, "day")) {
daysArray.push(currentDay.clone());
currentDay = currentDay.add(1, "days");
}
return daysArray;
}, [startDate, endDate]);
const { hours, startHour, finalHour } = useMemo(() => {
const start = startDate.clone().hour(earliestStart.hour()).minute(0).second(0).millisecond(0);
const end = startDate.clone().hour(latestEnd.hour()).minute(0).second(0).millisecond(0);
const final = startDate.clone().hour(latestEnd.hour()).subtract(1, "hour").minute(0).second(0).millisecond(0);
const hoursArray = [];
let currentHour = start.clone();
while (currentHour.isBefore(end)) {
hoursArray.push(currentHour.clone());
currentHour = currentHour.add(intervalSize, "minutes");
}
return { hours: hoursArray, startHour: start, finalHour: final };
}, [startDate, earliestStart, latestEnd, intervalSize]);
const handleMouseUp = useCallback2(() => {
selectionAction.current = null;
selectionTarget.current = null;
fastMouseenter(false);
}, [fastMouseenter]);
useEffect3(() => {
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
};
}, [handleMouseUp]);
const selectionSet = useMemo(() => {
return new Set(selection.map((s) => s.valueOf()));
}, [selection]);
const isInAllowedTimes = useMemo(() => {
if (allowedTimes && allowedTimes.length > 0) {
return (dateTime) => allowedTimes.some((allowedTime) => dateTime.isSame(allowedTime));
}
return () => true;
}, [allowedTimes]);
const isDisabled = useCallback2((dateTime) => {
const currentTime = dayjs();
if (dateTime.isBefore(currentTime)) {
return true;
}
return !isInAllowedTimes(dateTime);
}, [isInAllowedTimes]);
const handleCellMouseDown = (e, hour, day) => {
const dateTime = day.clone().hour(hour.hour()).minute(hour.minute()).second(0).millisecond(0);
if (isDisabled(dateTime)) {
return;
}
const isSelected = selectionSet.has(dateTime.valueOf());
selectionAction.current = isSelected ? "unselect" : "select";
selectionTarget.current = "cell";
fastMouseenter(true, e.nativeEvent);
};
const handleCellMouseEnter = (hour, day) => {
const dateTime = day.clone().hour(hour.hour()).minute(hour.minute()).second(0).millisecond(0);
if (selectionTarget.current !== "cell" || isDisabled(dateTime)) {
return;
}
toggleTimeCell(dateTime);
};
const handleDayMouseDown = (e, day) => {
const daySlots = hours.filter(
(hour) => !isDisabled(day.clone().hour(hour.hour()).minute(hour.minute()).second(0).millisecond(0))
).map((hour) => day.clone().hour(hour.hour()).minute(hour.minute()).second(0).millisecond(0));
const allHoursSelected = daySlots.every(
(slot) => selectionSet.has(slot.valueOf())
);
selectionAction.current = allHoursSelected ? "unselect" : "select";
selectionTarget.current = "day";
fastMouseenter(true, e.nativeEvent);
};
const handleDayMouseEnter = (day) => {
if (selectionTarget.current !== "day") {
return;
}
handleDaySelection(day);
};
const toggleTimeCell = useCallback2((dateTime) => {
if (isDisabled(dateTime)) {
return;
}
const isSelected = selectionSet.has(dateTime.valueOf());
if (isSelected && selectionAction.current === "select" || !isSelected && selectionAction.current === "unselect") {
return;
}
if (isSelected) {
updateSelection((prevSelection) => {
return prevSelection.filter((t) => !t.isSame(dateTime));
});
} else {
updateSelection((prevSelection) => {
return [...prevSelection, dateTime];
});
}
}, [isDisabled, selectionSet, updateSelection]);
const handleDaySelection = (day) => {
const daySlots = hours.filter(
(hour) => !isDisabled(day.clone().hour(hour.hour()).minute(hour.minute()).second(0).millisecond(0))
).map((hour) => day.clone().hour(hour.hour()).minute(hour.minute()).second(0).millisecond(0));
const allHoursSelected = daySlots.every(
(slot) => selectionSet.has(slot.valueOf())
);
if (allHoursSelected && selectionAction.current === "select" || !allHoursSelected && selectionAction.current === "unselect") {
return;
}
if (allHoursSelected) {
updateSelection((prevSelection) => {
return prevSelection.filter((slot) => !slot.isSame(day, "day"));
});
} else {
updateSelection((prevSelection) => {
return [...prevSelection, ...daySlots];
});
}
};
const handleCellKeyDown = useCallback2((e, hour, day) => {
const dateTime = day.clone().hour(hour.hour()).minute(hour.minute()).second(0).millisecond(0);
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
toggleTimeCell(dateTime);
return;
}
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
e.preventDefault();
const currentDayIndex = days.findIndex((d) => d.isSame(day, "day"));
const currentHourIndex = hours.findIndex((h) => h.isSame(hour, "hour"));
let newDayIndex = currentDayIndex;
let newHourIndex = currentHourIndex;
if (e.key === "ArrowLeft") {
newDayIndex = Math.max(0, currentDayIndex - 1);
} else if (e.key === "ArrowRight") {
newDayIndex = Math.min(days.length - 1, currentDayIndex + 1);
} else if (e.key === "ArrowUp") {
newHourIndex = Math.max(0, currentHourIndex - 1);
} else if (e.key === "ArrowDown") {
newHourIndex = Math.min(hours.length - 1, currentHourIndex + 1);
}
if (newDayIndex !== currentDayIndex || newHourIndex !== currentHourIndex) {
const newDay = days[newDayIndex];
const newHour = hours[newHourIndex];
const cellKey = `cell-${newHour.format("H:mm")}-${newDay.format("ddd D")}`;
const cellElement = document.querySelector(`[data-cell-key="${cellKey}"]`);
if (cellElement) {
cellElement.focus();
}
}
}
}, [days, hours, toggleTimeCell]);
const rootClassName = ["timegrid", className].filter(Boolean).join(" ");
return /* @__PURE__ */ jsxs2(
"div",
{
className: rootClassName,
style,
role: "grid",
"aria-label": "Time availability grid",
"aria-multiselectable": "true",
children: [
/* @__PURE__ */ jsx3("div", { className: "timegrid-header", role: "row", children: days.map((day) => /* @__PURE__ */ jsx3(
TimeGridDay,
{
day,
handleMouseDown: handleDayMouseDown,
handleMouseEnter: handleDayMouseEnter
},
`day-${day.format("ddd D")}`
)) }),
hours.map((hour) => /* @__PURE__ */ jsx3("div", { className: "timegrid-row", role: "row", children: days.map((day) => {
return /* @__PURE__ */ jsx3(
TimeGridCell,
{
day,
hour,
startHour,
finalHour,
selectionSet,
isDisabled,
handleMouseDown: handleCellMouseDown,
handleMouseEnter: handleCellMouseEnter,
handleKeyDown: handleCellKeyDown
},
`cell-${hour.format("H:mm")}-${day.format("ddd D")}`
);
}) }, `hour-${hour.format("H:mm")}`))
]
}
);
}
export {
TimeGrid
};
//# sourceMappingURL=index.mjs.map