568 lines
20 KiB
JavaScript
568 lines
20 KiB
JavaScript
import { endOfMonth, isSameDay, isSameMonth, startOfMonth, } from "@internationalized/date";
|
|
import { afterTick, getDocument, styleToString, } from "svelte-toolbelt";
|
|
import { untrack } from "svelte";
|
|
import { getDaysInMonth, getLastFirstDayOfWeek, getNextLastDayOfWeek, hasTime, isAfter, isBefore, parseAnyDateValue, parseStringToDateValue, toDate, } from "./utils.js";
|
|
import { createBitsAttrs, boolToEmptyStrOrUndef } from "../attrs.js";
|
|
import { chunk, isValidIndex } from "../arrays.js";
|
|
import { isBrowser, isHTMLElement } from "../is.js";
|
|
import { kbd } from "../kbd.js";
|
|
import { watch } from "runed";
|
|
/**
|
|
* Checks if a given node is a calendar cell element.
|
|
*
|
|
* @param node - The node to check.
|
|
*/
|
|
export function isCalendarDayNode(node) {
|
|
if (!isHTMLElement(node))
|
|
return false;
|
|
if (!node.hasAttribute("data-bits-day"))
|
|
return false;
|
|
return true;
|
|
}
|
|
/**
|
|
* Retrieves an array of date values representing the days between
|
|
* the provided start and end dates.
|
|
*/
|
|
export function getDaysBetween(start, end) {
|
|
const days = [];
|
|
let dCurrent = start.add({ days: 1 });
|
|
const dEnd = end;
|
|
while (dCurrent.compare(dEnd) < 0) {
|
|
days.push(dCurrent);
|
|
dCurrent = dCurrent.add({ days: 1 });
|
|
}
|
|
return days;
|
|
}
|
|
/**
|
|
* Creates a calendar month object.
|
|
*
|
|
* @remarks
|
|
* Given a date, this function returns an object containing
|
|
* the necessary values to render a calendar month, including
|
|
* the month's date (the first day of that month), which can be
|
|
* used to render the name of the month, an array of all dates
|
|
* in that month, and an array of weeks. Each week is an array
|
|
* of dates, useful for rendering an accessible calendar grid
|
|
* using a loop and table elements.
|
|
*
|
|
*/
|
|
function createMonth(props) {
|
|
const { dateObj, weekStartsOn, fixedWeeks, locale } = props;
|
|
const daysInMonth = getDaysInMonth(dateObj);
|
|
const datesArray = Array.from({ length: daysInMonth }, (_, i) => dateObj.set({ day: i + 1 }));
|
|
const firstDayOfMonth = startOfMonth(dateObj);
|
|
const lastDayOfMonth = endOfMonth(dateObj);
|
|
const lastSunday = weekStartsOn !== undefined
|
|
? getLastFirstDayOfWeek(firstDayOfMonth, weekStartsOn, "en-US")
|
|
: getLastFirstDayOfWeek(firstDayOfMonth, 0, locale);
|
|
const nextSaturday = weekStartsOn !== undefined
|
|
? getNextLastDayOfWeek(lastDayOfMonth, weekStartsOn, "en-US")
|
|
: getNextLastDayOfWeek(lastDayOfMonth, 0, locale);
|
|
const lastMonthDays = getDaysBetween(lastSunday.subtract({ days: 1 }), firstDayOfMonth);
|
|
const nextMonthDays = getDaysBetween(lastDayOfMonth, nextSaturday.add({ days: 1 }));
|
|
const totalDays = lastMonthDays.length + datesArray.length + nextMonthDays.length;
|
|
if (fixedWeeks && totalDays < 42) {
|
|
const extraDays = 42 - totalDays;
|
|
let startFrom = nextMonthDays[nextMonthDays.length - 1];
|
|
if (!startFrom) {
|
|
startFrom = dateObj.add({ months: 1 }).set({ day: 1 });
|
|
}
|
|
let length = extraDays;
|
|
if (nextMonthDays.length === 0) {
|
|
length = extraDays - 1;
|
|
nextMonthDays.push(startFrom);
|
|
}
|
|
const extraDaysArray = Array.from({ length }, (_, i) => {
|
|
const incr = i + 1;
|
|
return startFrom.add({ days: incr });
|
|
});
|
|
nextMonthDays.push(...extraDaysArray);
|
|
}
|
|
const allDays = lastMonthDays.concat(datesArray, nextMonthDays);
|
|
const weeks = chunk(allDays, 7);
|
|
return {
|
|
value: dateObj,
|
|
dates: allDays,
|
|
weeks,
|
|
};
|
|
}
|
|
export function createMonths(props) {
|
|
const { numberOfMonths, dateObj, ...monthProps } = props;
|
|
const months = [];
|
|
if (!numberOfMonths || numberOfMonths === 1) {
|
|
months.push(createMonth({
|
|
...monthProps,
|
|
dateObj,
|
|
}));
|
|
return months;
|
|
}
|
|
months.push(createMonth({
|
|
...monthProps,
|
|
dateObj,
|
|
}));
|
|
// Create all the months, starting with the current month
|
|
for (let i = 1; i < numberOfMonths; i++) {
|
|
const nextMonth = dateObj.add({ months: i });
|
|
months.push(createMonth({
|
|
...monthProps,
|
|
dateObj: nextMonth,
|
|
}));
|
|
}
|
|
return months;
|
|
}
|
|
export function getSelectableCells(calendarNode) {
|
|
if (!calendarNode)
|
|
return [];
|
|
const selectableSelector = `[data-bits-day]:not([data-disabled]):not([data-outside-visible-months])`;
|
|
return Array.from(calendarNode.querySelectorAll(selectableSelector)).filter((el) => isHTMLElement(el));
|
|
}
|
|
/**
|
|
* A helper function to extract the date from the `data-value`
|
|
* attribute of a date cell and set it as the placeholder value.
|
|
*
|
|
* Shared between the calendar and range calendar builders.
|
|
*
|
|
* @param node - The node to extract the date from.
|
|
* @param placeholder - The placeholder value store which will be set to the extracted date.
|
|
*/
|
|
export function setPlaceholderToNodeValue(node, placeholder) {
|
|
const cellValue = node.getAttribute("data-value");
|
|
if (!cellValue)
|
|
return;
|
|
placeholder.current = parseStringToDateValue(cellValue, placeholder.current);
|
|
}
|
|
/**
|
|
* Shared logic for shifting focus between cells in the
|
|
* calendar and range calendar.
|
|
*/
|
|
export function shiftCalendarFocus({ node, add, placeholder, calendarNode, isPrevButtonDisabled, isNextButtonDisabled, months, numberOfMonths, }) {
|
|
const candidateCells = getSelectableCells(calendarNode);
|
|
if (!candidateCells.length)
|
|
return;
|
|
const index = candidateCells.indexOf(node);
|
|
const nextIndex = index + add;
|
|
/**
|
|
* If the next cell is within the bounds of the displayed cells,
|
|
* easy day, we just focus it.
|
|
*/
|
|
if (isValidIndex(nextIndex, candidateCells)) {
|
|
const nextCell = candidateCells[nextIndex];
|
|
setPlaceholderToNodeValue(nextCell, placeholder);
|
|
return nextCell.focus();
|
|
}
|
|
/**
|
|
* When the next cell falls outside the displayed cells range,
|
|
* we update the focus to the previous or next month based on the
|
|
* direction, and then focus on the relevant cell.
|
|
*/
|
|
if (nextIndex < 0) {
|
|
/**
|
|
* To handle negative indices, we rewind by one month,
|
|
* retrieve candidate cells for that month, and shift focus
|
|
* by the difference between the nextIndex starting from the end
|
|
* of the array.
|
|
*/
|
|
// shift the calendar back a month unless prev month is disabled
|
|
if (isPrevButtonDisabled)
|
|
return;
|
|
const firstMonth = months[0]?.value;
|
|
if (!firstMonth)
|
|
return;
|
|
placeholder.current = firstMonth.subtract({ months: numberOfMonths });
|
|
// Without a tick here, it seems to be too quick for the DOM to update
|
|
afterTick(() => {
|
|
const newCandidateCells = getSelectableCells(calendarNode);
|
|
if (!newCandidateCells.length)
|
|
return;
|
|
/**
|
|
* Starting at the end of the array, shift focus by the diff
|
|
* between the nextIndex and the length of the array, since the
|
|
* nextIndex is negative.
|
|
*/
|
|
const newIndex = newCandidateCells.length - Math.abs(nextIndex);
|
|
if (isValidIndex(newIndex, newCandidateCells)) {
|
|
const newCell = newCandidateCells[newIndex];
|
|
setPlaceholderToNodeValue(newCell, placeholder);
|
|
return newCell.focus();
|
|
}
|
|
});
|
|
}
|
|
if (nextIndex >= candidateCells.length) {
|
|
/**
|
|
* Since we're in the positive index range, we need to go forward
|
|
* a month, refetch the candidate cells within that month, and then
|
|
* starting at the beginning of the array, shift focus by the nextIndex
|
|
* amount.
|
|
*/
|
|
// shift the calendar forward a month unless next month is disabled
|
|
if (isNextButtonDisabled)
|
|
return;
|
|
const firstMonth = months[0]?.value;
|
|
if (!firstMonth)
|
|
return;
|
|
placeholder.current = firstMonth.add({ months: numberOfMonths });
|
|
afterTick(() => {
|
|
const newCandidateCells = getSelectableCells(calendarNode);
|
|
if (!newCandidateCells.length)
|
|
return;
|
|
/**
|
|
* We need to determine how far into the next month we need to go
|
|
* to get the next index. So if we only went over the previous month
|
|
* by one, we need to go into the next month by 1 to get the right index.
|
|
*/
|
|
const newIndex = nextIndex - candidateCells.length;
|
|
if (isValidIndex(newIndex, newCandidateCells)) {
|
|
const nextCell = newCandidateCells[newIndex];
|
|
return nextCell.focus();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
const ARROW_KEYS = [kbd.ARROW_DOWN, kbd.ARROW_UP, kbd.ARROW_LEFT, kbd.ARROW_RIGHT];
|
|
const SELECT_KEYS = [kbd.ENTER, kbd.SPACE];
|
|
/**
|
|
* Shared keyboard event handler for the calendar and range calendar.
|
|
*/
|
|
export function handleCalendarKeydown({ event, handleCellClick, shiftFocus, placeholderValue, }) {
|
|
const currentCell = event.target;
|
|
if (!isCalendarDayNode(currentCell))
|
|
return;
|
|
// oxlint-disable-next-line no-explicit-any
|
|
if (!ARROW_KEYS.includes(event.key) && !SELECT_KEYS.includes(event.key))
|
|
return;
|
|
event.preventDefault();
|
|
const kbdFocusMap = {
|
|
[kbd.ARROW_DOWN]: 7,
|
|
[kbd.ARROW_UP]: -7,
|
|
[kbd.ARROW_LEFT]: -1,
|
|
[kbd.ARROW_RIGHT]: 1,
|
|
};
|
|
// oxlint-disable-next-line no-explicit-any
|
|
if (ARROW_KEYS.includes(event.key)) {
|
|
const add = kbdFocusMap[event.key];
|
|
if (add !== undefined) {
|
|
shiftFocus(currentCell, add);
|
|
}
|
|
}
|
|
if (SELECT_KEYS.includes(event.key)) {
|
|
const cellValue = currentCell.getAttribute("data-value");
|
|
if (!cellValue)
|
|
return;
|
|
handleCellClick(event, parseStringToDateValue(cellValue, placeholderValue));
|
|
}
|
|
}
|
|
export function handleCalendarNextPage({ months, setMonths, numberOfMonths, pagedNavigation, weekStartsOn, locale, fixedWeeks, setPlaceholder, }) {
|
|
const firstMonth = months[0]?.value;
|
|
if (!firstMonth)
|
|
return;
|
|
if (pagedNavigation) {
|
|
setPlaceholder(firstMonth.add({ months: numberOfMonths }));
|
|
}
|
|
else {
|
|
// Calculate the target date first, then update both months and placeholder
|
|
// to ensure they're synchronized and prevent useMonthViewPlaceholderSync from
|
|
// double-triggering
|
|
const targetDate = firstMonth.add({ months: 1 });
|
|
const newMonths = createMonths({
|
|
dateObj: targetDate,
|
|
weekStartsOn,
|
|
locale,
|
|
fixedWeeks,
|
|
numberOfMonths,
|
|
});
|
|
setPlaceholder(targetDate);
|
|
setMonths(newMonths);
|
|
}
|
|
}
|
|
export function handleCalendarPrevPage({ months, setMonths, numberOfMonths, pagedNavigation, weekStartsOn, locale, fixedWeeks, setPlaceholder, }) {
|
|
const firstMonth = months[0]?.value;
|
|
if (!firstMonth)
|
|
return;
|
|
if (pagedNavigation) {
|
|
setPlaceholder(firstMonth.subtract({ months: numberOfMonths }));
|
|
}
|
|
else {
|
|
// Calculate the target date first, then update both months and placeholder
|
|
// to ensure they're synchronized and prevent useMonthViewPlaceholderSync from
|
|
// double-triggering
|
|
const targetDate = firstMonth.subtract({ months: 1 });
|
|
const newMonths = createMonths({
|
|
dateObj: targetDate,
|
|
weekStartsOn,
|
|
locale,
|
|
fixedWeeks,
|
|
numberOfMonths,
|
|
});
|
|
setPlaceholder(targetDate);
|
|
setMonths(newMonths);
|
|
}
|
|
}
|
|
export function getWeekdays({ months, formatter, weekdayFormat }) {
|
|
if (!months.length)
|
|
return [];
|
|
const firstMonth = months[0];
|
|
const firstWeek = firstMonth.weeks[0];
|
|
if (!firstWeek)
|
|
return [];
|
|
return firstWeek.map((date) => formatter.dayOfWeek(toDate(date), weekdayFormat));
|
|
}
|
|
/**
|
|
* Updates the displayed months based on changes in the options values,
|
|
* which determines the month to show in the calendar.
|
|
*/
|
|
export function useMonthViewOptionsSync(props) {
|
|
$effect(() => {
|
|
const weekStartsOn = props.weekStartsOn.current;
|
|
const locale = props.locale.current;
|
|
const fixedWeeks = props.fixedWeeks.current;
|
|
const numberOfMonths = props.numberOfMonths.current;
|
|
untrack(() => {
|
|
const placeholder = props.placeholder.current;
|
|
if (!placeholder)
|
|
return;
|
|
const defaultMonthProps = {
|
|
weekStartsOn,
|
|
locale,
|
|
fixedWeeks,
|
|
numberOfMonths,
|
|
};
|
|
props.setMonths(createMonths({ ...defaultMonthProps, dateObj: placeholder }));
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* Creates an accessible heading element for the calendar.
|
|
* Returns a function that removes the heading element.
|
|
*/
|
|
export function createAccessibleHeading({ calendarNode, label, accessibleHeadingId, }) {
|
|
const doc = getDocument(calendarNode);
|
|
const div = doc.createElement("div");
|
|
div.style.cssText = styleToString({
|
|
border: "0px",
|
|
clip: "rect(0px, 0px, 0px, 0px)",
|
|
clipPath: "inset(50%)",
|
|
height: "1px",
|
|
margin: "-1px",
|
|
overflow: "hidden",
|
|
padding: "0px",
|
|
position: "absolute",
|
|
whiteSpace: "nowrap",
|
|
width: "1px",
|
|
});
|
|
const h2 = doc.createElement("div");
|
|
h2.textContent = label;
|
|
h2.id = accessibleHeadingId;
|
|
h2.role = "heading";
|
|
h2.ariaLevel = "2";
|
|
calendarNode.insertBefore(div, calendarNode.firstChild);
|
|
div.appendChild(h2);
|
|
return () => {
|
|
const h2 = doc.getElementById(accessibleHeadingId);
|
|
if (!h2)
|
|
return;
|
|
div.parentElement?.removeChild(div);
|
|
h2.remove();
|
|
};
|
|
}
|
|
export function useMonthViewPlaceholderSync({ placeholder, getVisibleMonths, weekStartsOn, locale, fixedWeeks, numberOfMonths, setMonths, }) {
|
|
$effect(() => {
|
|
placeholder.current;
|
|
untrack(() => {
|
|
/**
|
|
* If the placeholder's month is already in this visible months,
|
|
* we don't need to do anything.
|
|
*/
|
|
if (getVisibleMonths().some((month) => isSameMonth(month, placeholder.current))) {
|
|
return;
|
|
}
|
|
const defaultMonthProps = {
|
|
weekStartsOn: weekStartsOn.current,
|
|
locale: locale.current,
|
|
fixedWeeks: fixedWeeks.current,
|
|
numberOfMonths: numberOfMonths.current,
|
|
};
|
|
setMonths(createMonths({ ...defaultMonthProps, dateObj: placeholder.current }));
|
|
});
|
|
});
|
|
}
|
|
export function getIsNextButtonDisabled({ maxValue, months, disabled, }) {
|
|
if (!maxValue || !months.length)
|
|
return false;
|
|
if (disabled)
|
|
return true;
|
|
const lastMonthInView = months[months.length - 1]?.value;
|
|
if (!lastMonthInView)
|
|
return false;
|
|
const firstMonthOfNextPage = lastMonthInView
|
|
.add({
|
|
months: 1,
|
|
})
|
|
.set({ day: 1 });
|
|
return isAfter(firstMonthOfNextPage, maxValue);
|
|
}
|
|
export function getIsPrevButtonDisabled({ minValue, months, disabled, }) {
|
|
if (!minValue || !months.length)
|
|
return false;
|
|
if (disabled)
|
|
return true;
|
|
const firstMonthInView = months[0]?.value;
|
|
if (!firstMonthInView)
|
|
return false;
|
|
const lastMonthOfPrevPage = firstMonthInView
|
|
.subtract({
|
|
months: 1,
|
|
})
|
|
.set({ day: 35 });
|
|
return isBefore(lastMonthOfPrevPage, minValue);
|
|
}
|
|
export function getCalendarHeadingValue({ months, locale, formatter, }) {
|
|
if (!months.length)
|
|
return "";
|
|
if (locale !== formatter.getLocale()) {
|
|
formatter.setLocale(locale);
|
|
}
|
|
if (months.length === 1) {
|
|
const month = toDate(months[0].value);
|
|
return `${formatter.fullMonthAndYear(month)}`;
|
|
}
|
|
const startMonth = toDate(months[0].value);
|
|
const endMonth = toDate(months[months.length - 1].value);
|
|
const startMonthName = formatter.fullMonth(startMonth);
|
|
const endMonthName = formatter.fullMonth(endMonth);
|
|
const startMonthYear = formatter.fullYear(startMonth);
|
|
const endMonthYear = formatter.fullYear(endMonth);
|
|
const content = startMonthYear === endMonthYear
|
|
? `${startMonthName} - ${endMonthName} ${endMonthYear}`
|
|
: `${startMonthName} ${startMonthYear} - ${endMonthName} ${endMonthYear}`;
|
|
return content;
|
|
}
|
|
export function getCalendarElementProps({ fullCalendarLabel, id, isInvalid, disabled, readonly, }) {
|
|
return {
|
|
id,
|
|
role: "application",
|
|
"aria-label": fullCalendarLabel,
|
|
"data-invalid": boolToEmptyStrOrUndef(isInvalid),
|
|
"data-disabled": boolToEmptyStrOrUndef(disabled),
|
|
"data-readonly": boolToEmptyStrOrUndef(readonly),
|
|
};
|
|
}
|
|
export function pickerOpenFocus(e) {
|
|
const doc = getDocument(e.target);
|
|
const nodeToFocus = doc.querySelector("[data-bits-day][data-focused]");
|
|
if (nodeToFocus) {
|
|
e.preventDefault();
|
|
nodeToFocus?.focus();
|
|
}
|
|
}
|
|
export function getFirstNonDisabledDateInView(calendarRef) {
|
|
if (!isBrowser)
|
|
return;
|
|
const daysInView = Array.from(calendarRef.querySelectorAll("[data-bits-day]:not([aria-disabled=true])"));
|
|
if (daysInView.length === 0)
|
|
return;
|
|
const element = daysInView[0];
|
|
const value = element?.getAttribute("data-value");
|
|
const type = element?.getAttribute("data-type");
|
|
if (!value || !type)
|
|
return;
|
|
return parseAnyDateValue(value, type);
|
|
}
|
|
/**
|
|
* Ensures the placeholder is not set to a disabled date,
|
|
* which would prevent the user from entering the Calendar
|
|
* via the keyboard.
|
|
*/
|
|
export function useEnsureNonDisabledPlaceholder({ ref, placeholder, defaultPlaceholder, minValue, maxValue, isDateDisabled, }) {
|
|
function isDisabled(date) {
|
|
if (isDateDisabled.current(date))
|
|
return true;
|
|
if (minValue.current && isBefore(date, minValue.current))
|
|
return true;
|
|
if (maxValue.current && isBefore(maxValue.current, date))
|
|
return true;
|
|
return false;
|
|
}
|
|
watch(() => ref.current, () => {
|
|
if (!ref.current)
|
|
return;
|
|
/**
|
|
* If the placeholder is still the default placeholder and it's a disabled date, find
|
|
* the first available date in the calendar view and set it as the placeholder.
|
|
*
|
|
* This prevents the placeholder from being a disabled date and no date being tabbable
|
|
* preventing the user from entering the Calendar. If all dates in the view are
|
|
* disabled, currently that is considered an error on the developer's part and should
|
|
* be handled by them.
|
|
*
|
|
* Perhaps in the future we can introduce a dev-only log message to prevent this from
|
|
* being a silent error.
|
|
*/
|
|
if (placeholder.current &&
|
|
isSameDay(placeholder.current, defaultPlaceholder) &&
|
|
isDisabled(defaultPlaceholder)) {
|
|
placeholder.current =
|
|
getFirstNonDisabledDateInView(ref.current) ?? defaultPlaceholder;
|
|
}
|
|
});
|
|
}
|
|
export function getDateWithPreviousTime(date, prev) {
|
|
if (!date || !prev)
|
|
return date;
|
|
if (hasTime(date) && hasTime(prev)) {
|
|
return date.set({
|
|
hour: prev.hour,
|
|
minute: prev.minute,
|
|
millisecond: prev.millisecond,
|
|
second: prev.second,
|
|
});
|
|
}
|
|
return date;
|
|
}
|
|
export const calendarAttrs = createBitsAttrs({
|
|
component: "calendar",
|
|
parts: [
|
|
"root",
|
|
"grid",
|
|
"cell",
|
|
"next-button",
|
|
"prev-button",
|
|
"day",
|
|
"grid-body",
|
|
"grid-head",
|
|
"grid-row",
|
|
"head-cell",
|
|
"header",
|
|
"heading",
|
|
"month-select",
|
|
"year-select",
|
|
],
|
|
});
|
|
export function getDefaultYears(opts) {
|
|
const currentYear = new Date().getFullYear();
|
|
const latestYear = Math.max(opts.placeholderYear, currentYear);
|
|
// use minValue/maxValue as boundaries if provided, otherwise calculate default range
|
|
let minYear;
|
|
let maxYear;
|
|
if (opts.minValue) {
|
|
minYear = opts.minValue.year;
|
|
}
|
|
else {
|
|
// (111 years: latestYear - 100 to latestYear + 10)
|
|
const initialMinYear = latestYear - 100;
|
|
minYear =
|
|
opts.placeholderYear < initialMinYear ? opts.placeholderYear - 10 : initialMinYear;
|
|
}
|
|
if (opts.maxValue) {
|
|
maxYear = opts.maxValue.year;
|
|
}
|
|
else {
|
|
maxYear = latestYear + 10;
|
|
}
|
|
// ensure we have at least one year and minYear <= maxYear
|
|
if (minYear > maxYear) {
|
|
minYear = maxYear;
|
|
}
|
|
const totalYears = maxYear - minYear + 1;
|
|
return Array.from({ length: totalYears }, (_, i) => minYear + i);
|
|
}
|