bits-ui
Version:
The headless components for Svelte.
568 lines (567 loc) • 21 kB
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, getDataDisabled, getDataInvalid, getDataReadonly, } 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;
// eslint-disable-next-line @typescript-eslint/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,
};
// eslint-disable-next-line @typescript-eslint/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": getDataInvalid(isInvalid),
"data-disabled": getDataDisabled(disabled),
"data-readonly": getDataReadonly(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);
}