This commit is contained in:
eewing
2026-02-17 14:10:16 -06:00
parent 2bca5834c5
commit cf73cd3b4c
11246 changed files with 1690552 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
export type Announcer = ReturnType<typeof getAnnouncer>;
/**
* Creates an announcer object that can be used to make `aria-live` announcements to screen readers.
*/
export declare function getAnnouncer(doc: Document | null): {
announce: (value: string | null | number, kind?: "assertive" | "polite", timeout?: number) => NodeJS.Timeout | undefined;
};
+82
View File
@@ -0,0 +1,82 @@
import { srOnlyStylesString } from "svelte-toolbelt";
import { isBrowser, isHTMLElement } from "../is.js";
/**
* Creates or gets an announcer element which is used to announce messages to screen readers.
* Within the date components, we use this to announce when the values of the individual segments
* change, as without it we get inconsistent behavior across screen readers.
*/
function initAnnouncer(doc) {
if (!isBrowser || !doc)
return null;
let el = doc.querySelector("[data-bits-announcer]");
/**
* Creates a log element for assertive or polite announcements.
*/
const createLog = (kind) => {
const log = doc.createElement("div");
log.role = "log";
log.ariaLive = kind;
log.setAttribute("aria-relevant", "additions");
return log;
};
if (!isHTMLElement(el)) {
const div = doc.createElement("div");
div.style.cssText = srOnlyStylesString;
div.setAttribute("data-bits-announcer", "");
div.appendChild(createLog("assertive"));
div.appendChild(createLog("polite"));
el = div;
doc.body.insertBefore(el, doc.body.firstChild);
}
/**
* Retrieves the log element for assertive or polite announcements.
*/
const getLog = (kind) => {
if (!isHTMLElement(el))
return null;
const log = el.querySelector(`[aria-live="${kind}"]`);
if (!isHTMLElement(log))
return null;
return log;
};
return {
getLog,
};
}
/**
* Creates an announcer object that can be used to make `aria-live` announcements to screen readers.
*/
export function getAnnouncer(doc) {
const announcer = initAnnouncer(doc);
/**
* Announces a message to screen readers using the specified kind of announcement.
*/
function announce(value, kind = "assertive", timeout = 7500) {
if (!announcer || !isBrowser || !doc)
return;
const log = announcer.getLog(kind);
const content = doc.createElement("div");
if (typeof value === "number") {
value = value.toString();
}
else if (value === null) {
value = "Empty";
}
else {
value = value.trim();
}
content.innerText = value;
if (kind === "assertive") {
log?.replaceChildren(content);
}
else {
log?.appendChild(content);
}
return setTimeout(() => {
content.remove();
}, timeout);
}
return {
announce,
};
}
@@ -0,0 +1,208 @@
import { type DateValue } from "@internationalized/date";
import { type ReadableBox, type WritableBox } from "svelte-toolbelt";
import type { Formatter } from "./formatter.js";
import type { DateMatcher, Month } from "../../shared/index.js";
/**
* Checks if a given node is a calendar cell element.
*
* @param node - The node to check.
*/
export declare function isCalendarDayNode(node: unknown): node is HTMLElement;
/**
* Retrieves an array of date values representing the days between
* the provided start and end dates.
*/
export declare function getDaysBetween(start: DateValue, end: DateValue): DateValue[];
export type CreateMonthProps = {
/**
* The date object representing the month's date (usually the first day of the month).
*/
dateObj: DateValue;
/**
* The day of the week to start the calendar on (0 for Sunday, 1 for Monday, etc.).
*/
weekStartsOn: number | undefined;
/**
* Whether to always render 6 weeks in the calendar, even if the month doesn't
* span 6 weeks.
*/
fixedWeeks: boolean;
/**
* The locale to use when creating the calendar month.
*/
locale: string;
};
type SetMonthProps = CreateMonthProps & {
numberOfMonths: number | undefined;
currentMonths?: Month<DateValue>[];
};
export declare function createMonths(props: SetMonthProps): Month<DateValue>[];
export declare function getSelectableCells(calendarNode: HTMLElement | null): HTMLElement[];
/**
* 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 declare function setPlaceholderToNodeValue(node: HTMLElement, placeholder: WritableBox<DateValue>): void;
type ShiftCalendarFocusProps = {
/**
* The day node with current focus.
*/
node: HTMLElement;
/**
* The number of days to shift the focus by.
*/
add: number;
/**
* The `placeholder` value box
*/
placeholder: WritableBox<DateValue>;
/**
* The calendar node.
*/
calendarNode: HTMLElement | null;
/**
* Whether the previous button is disabled.
*/
isPrevButtonDisabled: boolean;
/**
* Whether the next button is disabled.
*/
isNextButtonDisabled: boolean;
/**
* The months array of the calendar.
*/
months: Month<DateValue>[];
/**
* The number of months being displayed in the calendar.
*/
numberOfMonths: number;
};
/**
* Shared logic for shifting focus between cells in the
* calendar and range calendar.
*/
export declare function shiftCalendarFocus({ node, add, placeholder, calendarNode, isPrevButtonDisabled, isNextButtonDisabled, months, numberOfMonths, }: ShiftCalendarFocusProps): void;
type HandleCalendarKeydownProps = {
event: KeyboardEvent;
handleCellClick: (event: Event, date: DateValue) => void;
shiftFocus: (node: HTMLElement, add: number) => void;
placeholderValue: DateValue;
};
/**
* Shared keyboard event handler for the calendar and range calendar.
*/
export declare function handleCalendarKeydown({ event, handleCellClick, shiftFocus, placeholderValue, }: HandleCalendarKeydownProps): void;
type HandleCalendarPageProps = {
months: Month<DateValue>[];
setMonths: (months: Month<DateValue>[]) => void;
numberOfMonths: number;
pagedNavigation: boolean;
weekStartsOn: number | undefined;
locale: string;
fixedWeeks: boolean;
setPlaceholder: (date: DateValue) => void;
};
export declare function handleCalendarNextPage({ months, setMonths, numberOfMonths, pagedNavigation, weekStartsOn, locale, fixedWeeks, setPlaceholder, }: HandleCalendarPageProps): void;
export declare function handleCalendarPrevPage({ months, setMonths, numberOfMonths, pagedNavigation, weekStartsOn, locale, fixedWeeks, setPlaceholder, }: HandleCalendarPageProps): void;
type GetWeekdaysProps = {
months: Month<DateValue>[];
weekdayFormat: Intl.DateTimeFormatOptions["weekday"];
formatter: Formatter;
};
export declare function getWeekdays({ months, formatter, weekdayFormat }: GetWeekdaysProps): string[];
type UseMonthViewSyncProps = {
weekStartsOn: ReadableBox<number | undefined>;
locale: ReadableBox<string>;
fixedWeeks: ReadableBox<boolean>;
numberOfMonths: ReadableBox<number>;
placeholder: WritableBox<DateValue>;
setMonths: (months: Month<DateValue>[]) => void;
};
/**
* Updates the displayed months based on changes in the options values,
* which determines the month to show in the calendar.
*/
export declare function useMonthViewOptionsSync(props: UseMonthViewSyncProps): void;
type CreateAccessibleHeadingProps = {
calendarNode: HTMLElement;
label: string;
accessibleHeadingId: string;
};
/**
* Creates an accessible heading element for the calendar.
* Returns a function that removes the heading element.
*/
export declare function createAccessibleHeading({ calendarNode, label, accessibleHeadingId, }: CreateAccessibleHeadingProps): () => void;
type UseMonthViewPlaceholderSyncProps = {
placeholder: WritableBox<DateValue>;
getVisibleMonths: () => DateValue[];
weekStartsOn: ReadableBox<number | undefined>;
locale: ReadableBox<string>;
fixedWeeks: ReadableBox<boolean>;
numberOfMonths: ReadableBox<number>;
setMonths: (months: Month<DateValue>[]) => void;
};
export declare function useMonthViewPlaceholderSync({ placeholder, getVisibleMonths, weekStartsOn, locale, fixedWeeks, numberOfMonths, setMonths, }: UseMonthViewPlaceholderSyncProps): void;
type GetIsNextButtonDisabledProps = {
maxValue: DateValue | undefined;
months: Month<DateValue>[];
disabled: boolean;
};
export declare function getIsNextButtonDisabled({ maxValue, months, disabled, }: GetIsNextButtonDisabledProps): boolean;
type GetIsPrevButtonDisabledProps = {
minValue: DateValue | undefined;
months: Month<DateValue>[];
disabled: boolean;
};
export declare function getIsPrevButtonDisabled({ minValue, months, disabled, }: GetIsPrevButtonDisabledProps): boolean;
type GetCalendarHeadingValueProps = {
months: Month<DateValue>[];
formatter: Formatter;
locale: string;
};
export declare function getCalendarHeadingValue({ months, locale, formatter, }: GetCalendarHeadingValueProps): string;
type GetCalendarElementProps = {
fullCalendarLabel: string;
id: string;
isInvalid: boolean;
disabled: boolean;
readonly: boolean;
};
export declare function getCalendarElementProps({ fullCalendarLabel, id, isInvalid, disabled, readonly, }: GetCalendarElementProps): {
readonly id: string;
readonly role: "application";
readonly "aria-label": string;
readonly "data-invalid": "" | undefined;
readonly "data-disabled": "" | undefined;
readonly "data-readonly": "" | undefined;
};
export type CalendarParts = "root" | "grid" | "cell" | "next-button" | "prev-button" | "day" | "grid-body" | "grid-head" | "grid-row" | "head-cell" | "header" | "heading" | "month-select" | "year-select";
export declare function pickerOpenFocus(e: Event): void;
export declare function getFirstNonDisabledDateInView(calendarRef: HTMLElement): DateValue | undefined;
/**
* Ensures the placeholder is not set to a disabled date,
* which would prevent the user from entering the Calendar
* via the keyboard.
*/
export declare function useEnsureNonDisabledPlaceholder({ ref, placeholder, defaultPlaceholder, minValue, maxValue, isDateDisabled, }: {
ref: WritableBox<HTMLElement | null>;
placeholder: WritableBox<DateValue | undefined>;
isDateDisabled: ReadableBox<DateMatcher>;
minValue: ReadableBox<DateValue | undefined>;
maxValue: ReadableBox<DateValue | undefined>;
defaultPlaceholder: DateValue;
}): void;
export declare function getDateWithPreviousTime(date: DateValue | undefined, prev: DateValue | undefined): DateValue | undefined;
export declare const calendarAttrs: import("../attrs.js").CreateBitsAttrsReturn<readonly ["root", "grid", "cell", "next-button", "prev-button", "day", "grid-body", "grid-head", "grid-row", "head-cell", "header", "heading", "month-select", "year-select"]>;
type GetDefaultYearsProps = {
placeholderYear: number;
minValue: DateValue | undefined;
maxValue: DateValue | undefined;
};
export declare function getDefaultYears(opts: GetDefaultYearsProps): number[];
export {};
+567
View File
@@ -0,0 +1,567 @@
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);
}
+86
View File
@@ -0,0 +1,86 @@
import type { DateValue } from "@internationalized/date";
import type { Formatter } from "../formatter.js";
import type { DateAndTimeSegmentObj, DateSegmentPart, EditableSegmentPart, SegmentContentObj, SegmentPart, SegmentStateMap, SegmentValueObj } from "./types.js";
import type { Granularity, HourCycle } from "../../../shared/date/types.js";
export declare function initializeSegmentValues(granularity: Granularity): SegmentValueObj;
type SharedContentProps = {
granularity: Granularity;
dateRef: DateValue;
formatter: Formatter;
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;
};
type CreateContentObjProps = SharedContentProps & {
segmentValues: SegmentValueObj;
locale: string;
};
type CreateContentProps = CreateContentObjProps;
export declare function createContent(props: CreateContentProps): {
obj: SegmentContentObj;
arr: {
part: SegmentPart;
value: string;
}[];
};
export declare function initSegmentStates(): SegmentStateMap;
export declare function initSegmentIds(): any;
export declare function isDateSegmentPart(part: unknown): part is DateSegmentPart;
export declare function isSegmentPart(part: string): part is EditableSegmentPart;
export declare function isAnySegmentPart(part: unknown): part is SegmentPart;
type GetValueFromSegments = {
segmentObj: SegmentValueObj;
fieldNode: HTMLElement | null;
dateRef: DateValue;
};
export declare function getValueFromSegments(props: GetValueFromSegments): DateValue;
/**
* Check if all the segments being used have been filled.
* We use this to determine when we should set the value
* store of the date field(s).
*
* @param segmentValues - The current `SegmentValueObj`
* @param fieldNode - The id of the date field
*/
export declare function areAllSegmentsFilled(segmentValues: SegmentValueObj, fieldNode: HTMLElement | null): boolean;
/**
* Determines if the provided object is a valid `DateAndTimeSegmentObj`
* by checking if it has the correct keys and values for each key.
*/
export declare function isDateAndTimeSegmentObj(obj: unknown): obj is DateAndTimeSegmentObj;
/**
* Infer the granularity to use based on the
* value and granularity props.
*/
export declare function inferGranularity(value: DateValue, granularity: Granularity | undefined): Granularity;
export declare function isAcceptableSegmentKey(key: string): boolean;
/**
* Determines if the element with the provided id is the first focusable
* segment in the date field with the provided fieldId.
*
* @param id - The id of the element to check if it's the first segment
* @param fieldNode - The id of the date field associated with the segment
*/
export declare function isFirstSegment(id: string, fieldNode: HTMLElement | null): boolean;
type SetDescriptionProps = {
id: string;
formatter: Formatter;
value: DateValue;
doc: Document;
};
/**
* Creates or updates a description element for a date field
* which enables screen readers to read the date field's value.
*
* This element is hidden from view, and is portalled to the body
* so it can be associated via `aria-describedby` and read by
* screen readers as the user interacts with the date field.
*/
export declare function setDescription(props: SetDescriptionProps): void;
/**
* Removes the description element for the date field with
* the provided ID. This function should be called when the
* date field is unmounted.
*/
export declare function removeDescriptionElement(id: string, doc: Document): void;
export declare function getDefaultHourCycle(locale: string): 12 | 24;
export {};
+387
View File
@@ -0,0 +1,387 @@
import { styleToString } from "svelte-toolbelt";
import { getPlaceholder } from "../placeholders.js";
import { hasTime, isZonedDateTime } from "../utils.js";
import { ALL_SEGMENT_PARTS, DATE_SEGMENT_PARTS, EDITABLE_SEGMENT_PARTS, EDITABLE_TIME_SEGMENT_PARTS, } from "./parts.js";
import { getSegments } from "./segments.js";
import { isBrowser, isNull, isNumberString } from "../../is.js";
import { useId } from "../../use-id.js";
import { kbd } from "../../kbd.js";
export function initializeSegmentValues(granularity) {
const calendarDateTimeGranularities = ["hour", "minute", "second"];
const initialParts = EDITABLE_SEGMENT_PARTS.map((part) => {
if (part === "dayPeriod") {
return [part, "AM"];
}
return [part, null];
}).filter(([key]) => {
if (key === "literal" || key === null)
return false;
if (granularity === "day") {
return !calendarDateTimeGranularities.includes(key);
}
else {
return true;
}
});
return Object.fromEntries(initialParts);
}
function createContentObj(props) {
const { segmentValues, formatter, locale, dateRef } = props;
const content = Object.keys(segmentValues).reduce((obj, part) => {
if (!isSegmentPart(part))
return obj;
if ("hour" in segmentValues && part === "dayPeriod") {
const value = segmentValues[part];
if (!isNull(value)) {
obj[part] = value;
}
else {
obj[part] = getPlaceholder(part, "AM", locale);
}
}
else {
obj[part] = getPartContent(part);
}
return obj;
}, {});
function getPartContent(part) {
if ("hour" in segmentValues) {
const value = segmentValues[part];
const leadingZero = typeof value === "string" && value?.startsWith("0");
const intValue = value !== null ? Number.parseInt(value) : null;
if (value === "0" && part !== "year") {
return "0";
}
else if (!isNull(value) && !isNull(intValue)) {
const formatted = formatter.part(dateRef.set({ [part]: value }), part, {
hourCycle: props.hourCycle === 24 ? "h23" : undefined,
});
/**
* If we're operating in a 12 hour clock and the part is an hour, we handle
* the conversion to 12 hour format with 2 digit hours and leading zeros here.
*/
const is12HourMode = props.hourCycle === 12 ||
(props.hourCycle === undefined && getDefaultHourCycle(locale) === 12);
if (part === "hour" && is12HourMode) {
/**
* If the value is over 12, we convert to 12 hour format and add leading
* zeroes if the value is less than 10.
*/
if (intValue > 12) {
const hour = intValue - 12;
if (hour === 0) {
return "12";
}
else if (hour < 10) {
return `0${hour}`;
}
else {
return `${hour}`;
}
}
/**
* If the value is 0, we convert to 12, since 0 is not a valid 12 hour time.
*/
if (intValue === 0) {
return "12";
}
/**
* If the value is less than 10, we add a leading zero to the value.
*/
if (intValue < 10) {
return `0${intValue}`;
}
/**
* Otherwise, we don't need to do anything to the value.
*/
return `${intValue}`;
}
if (part === "year") {
return `${value}`;
}
if (leadingZero && formatted.length === 1) {
return `0${formatted}`;
}
return formatted;
}
else {
return getPlaceholder(part, "", locale);
}
}
else {
if (isDateSegmentPart(part)) {
const value = segmentValues[part];
const leadingZero = typeof value === "string" && value?.startsWith("0");
if (value === "0") {
return "0";
}
else if (!isNull(value)) {
const formatted = formatter.part(dateRef.set({ [part]: value }), part);
if (part === "year") {
return `${value}`;
}
if (leadingZero && formatted.length === 1) {
return `0${formatted}`;
}
return formatted;
}
else {
return getPlaceholder(part, "", locale);
}
}
return "";
}
}
return content;
}
function createContentArr(props) {
const { granularity, dateRef, formatter, contentObj, hideTimeZone, hourCycle } = props;
const parts = formatter.toParts(dateRef, getOptsByGranularity(granularity, hourCycle));
const segmentContentArr = parts
.map((part) => {
const defaultParts = ["literal", "dayPeriod", "timeZoneName", null];
if (defaultParts.includes(part.type) || !isSegmentPart(part.type)) {
return {
part: part.type,
value: part.value,
};
}
return {
part: part.type,
value: contentObj[part.type],
};
})
.filter((segment) => {
if (isNull(segment.part) || isNull(segment.value))
return false;
if (segment.part === "timeZoneName" && (!isZonedDateTime(dateRef) || hideTimeZone)) {
return false;
}
return true;
});
return segmentContentArr;
}
export function createContent(props) {
const contentObj = createContentObj(props);
const contentArr = createContentArr({
contentObj,
...props,
});
return {
obj: contentObj,
arr: contentArr,
};
}
function getOptsByGranularity(granularity, hourCycle) {
const opts = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
hourCycle: hourCycle === 24 ? "h23" : undefined,
hour12: hourCycle === 24 ? false : undefined,
};
if (granularity === "day") {
delete opts.second;
delete opts.hour;
delete opts.minute;
delete opts.timeZoneName;
}
if (granularity === "hour") {
delete opts.minute;
}
if (granularity === "minute") {
delete opts.second;
}
return opts;
}
export function initSegmentStates() {
return EDITABLE_SEGMENT_PARTS.reduce((acc, key) => {
acc[key] = {
lastKeyZero: false,
hasLeftFocus: true,
updating: null,
};
return acc;
}, {});
}
export function initSegmentIds() {
return Object.fromEntries(ALL_SEGMENT_PARTS.map((part) => {
return [part, useId()];
}).filter(([key]) => key !== "literal"));
}
export function isDateSegmentPart(part) {
return DATE_SEGMENT_PARTS.includes(part);
}
export function isSegmentPart(part) {
return EDITABLE_SEGMENT_PARTS.includes(part);
}
export function isAnySegmentPart(part) {
return ALL_SEGMENT_PARTS.includes(part);
}
/**
* Get the segments being used/ are rendered in the DOM.
* We're using this to determine when to set the value of
* the date picker, which is when all the segments have
* been filled.
*/
function getUsedSegments(fieldNode) {
if (!isBrowser || !fieldNode)
return [];
const usedSegments = getSegments(fieldNode)
.map((el) => el.dataset.segment)
.filter((part) => {
return EDITABLE_SEGMENT_PARTS.includes(part);
});
return usedSegments;
}
export function getValueFromSegments(props) {
const { segmentObj, fieldNode, dateRef } = props;
const usedSegments = getUsedSegments(fieldNode);
let date = dateRef;
for (const part of usedSegments) {
if ("hour" in segmentObj) {
const value = segmentObj[part];
if (isNull(value))
continue;
date = date.set({ [part]: segmentObj[part] });
}
else if (isDateSegmentPart(part)) {
const value = segmentObj[part];
if (isNull(value))
continue;
date = date.set({ [part]: segmentObj[part] });
}
}
return date;
}
/**
* Check if all the segments being used have been filled.
* We use this to determine when we should set the value
* store of the date field(s).
*
* @param segmentValues - The current `SegmentValueObj`
* @param fieldNode - The id of the date field
*/
export function areAllSegmentsFilled(segmentValues, fieldNode) {
const usedSegments = getUsedSegments(fieldNode);
for (const part of usedSegments) {
if ("hour" in segmentValues) {
if (segmentValues[part] === null) {
return false;
}
}
else if (isDateSegmentPart(part)) {
if (segmentValues[part] === null) {
return false;
}
}
}
return true;
}
/**
* Determines if the provided object is a valid `DateAndTimeSegmentObj`
* by checking if it has the correct keys and values for each key.
*/
export function isDateAndTimeSegmentObj(obj) {
if (typeof obj !== "object" || obj === null) {
return false;
}
return Object.entries(obj).every(([key, value]) => {
const validKey = EDITABLE_TIME_SEGMENT_PARTS.includes(key) ||
DATE_SEGMENT_PARTS.includes(key);
const validValue = key === "dayPeriod"
? value === "AM" || value === "PM" || value === null
: typeof value === "string" || typeof value === "number" || value === null;
return validKey && validValue;
});
}
/**
* Infer the granularity to use based on the
* value and granularity props.
*/
export function inferGranularity(value, granularity) {
if (granularity)
return granularity;
if (hasTime(value))
return "minute";
return "day";
}
export function isAcceptableSegmentKey(key) {
const acceptableSegmentKeys = [
kbd.ENTER,
kbd.ARROW_UP,
kbd.ARROW_DOWN,
kbd.ARROW_LEFT,
kbd.ARROW_RIGHT,
kbd.BACKSPACE,
kbd.SPACE,
];
if (acceptableSegmentKeys.includes(key))
return true;
if (isNumberString(key))
return true;
return false;
}
/**
* Determines if the element with the provided id is the first focusable
* segment in the date field with the provided fieldId.
*
* @param id - The id of the element to check if it's the first segment
* @param fieldNode - The id of the date field associated with the segment
*/
export function isFirstSegment(id, fieldNode) {
if (!isBrowser)
return false;
const segments = getSegments(fieldNode);
return segments.length ? segments[0].id === id : false;
}
/**
* Creates or updates a description element for a date field
* which enables screen readers to read the date field's value.
*
* This element is hidden from view, and is portalled to the body
* so it can be associated via `aria-describedby` and read by
* screen readers as the user interacts with the date field.
*/
export function setDescription(props) {
const { id, formatter, value, doc } = props;
if (!isBrowser)
return;
const valueString = formatter.selectedDate(value);
const el = doc.getElementById(id);
if (!el) {
const div = doc.createElement("div");
div.style.cssText = styleToString({
display: "none",
});
div.id = id;
div.innerText = `Selected Date: ${valueString}`;
doc.body.appendChild(div);
}
else {
el.innerText = `Selected Date: ${valueString}`;
}
}
/**
* Removes the description element for the date field with
* the provided ID. This function should be called when the
* date field is unmounted.
*/
export function removeDescriptionElement(id, doc) {
if (!isBrowser)
return;
const el = doc.getElementById(id);
if (!el)
return;
doc.body.removeChild(el);
}
export function getDefaultHourCycle(locale) {
const formatter = new Intl.DateTimeFormat(locale, { hour: "numeric" });
const parts = formatter.formatToParts(new Date("2023-01-01T13:00:00"));
const hourPart = parts.find((part) => part.type === "hour");
return hourPart?.value === "1" ? 12 : 24;
}
+8
View File
@@ -0,0 +1,8 @@
export declare const DATE_SEGMENT_PARTS: readonly ["day", "month", "year"];
export declare const EDITABLE_TIME_SEGMENT_PARTS: readonly ["hour", "minute", "second", "dayPeriod"];
export declare const NON_EDITABLE_SEGMENT_PARTS: readonly ["literal", "timeZoneName"];
export declare const EDITABLE_SEGMENT_PARTS: readonly ["day", "month", "year", "hour", "minute", "second", "dayPeriod"];
export declare const ALL_SEGMENT_PARTS: readonly ["day", "month", "year", "hour", "minute", "second", "dayPeriod", "literal", "timeZoneName"];
export declare const ALL_TIME_SEGMENT_PARTS: readonly ["hour", "minute", "second", "dayPeriod", "literal", "timeZoneName"];
export declare const ALL_EXCEPT_LITERAL_PARTS: ("day" | "month" | "year" | "hour" | "minute" | "second" | "dayPeriod" | "timeZoneName")[];
export declare const ALL_TIME_EXCEPT_LITERAL_PARTS: ("hour" | "minute" | "second" | "dayPeriod" | "timeZoneName")[];
+17
View File
@@ -0,0 +1,17 @@
export const DATE_SEGMENT_PARTS = ["day", "month", "year"];
export const EDITABLE_TIME_SEGMENT_PARTS = ["hour", "minute", "second", "dayPeriod"];
export const NON_EDITABLE_SEGMENT_PARTS = ["literal", "timeZoneName"];
export const EDITABLE_SEGMENT_PARTS = [
...DATE_SEGMENT_PARTS,
...EDITABLE_TIME_SEGMENT_PARTS,
];
export const ALL_SEGMENT_PARTS = [
...EDITABLE_SEGMENT_PARTS,
...NON_EDITABLE_SEGMENT_PARTS,
];
export const ALL_TIME_SEGMENT_PARTS = [
...EDITABLE_TIME_SEGMENT_PARTS,
...NON_EDITABLE_SEGMENT_PARTS,
];
export const ALL_EXCEPT_LITERAL_PARTS = ALL_SEGMENT_PARTS.filter((part) => part !== "literal");
export const ALL_TIME_EXCEPT_LITERAL_PARTS = ALL_TIME_SEGMENT_PARTS.filter((part) => part !== "literal");
+60
View File
@@ -0,0 +1,60 @@
/**
* Handles segment navigation based on the provided keyboard event and field ID.
*
* @param e - The keyboard event
* @param fieldNode - The ID of the field we're navigating within
*/
export declare function handleSegmentNavigation(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
export declare function handleTimeSegmentNavigation(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
/**
* Retrieves the next segment in the list of segments relative to the provided node.
*
* @param node - The node we're starting from
* @param segments - The list of candidate segments to navigate through
*/
export declare function getNextSegment(node: HTMLElement, segments: HTMLElement[]): HTMLElement | null | undefined;
/**
* Retrieves the previous segment in the list of segments relative to the provided node.
*
* @param node - The node we're starting from
* @param segments - The list of candidate segments to navigate through
*/
export declare function getPrevSegment(node: HTMLElement, segments: HTMLElement[]): HTMLElement | null | undefined;
/**
* Retrieves an object containing the next and previous segments relative to the current node.
*
* @param startingNode - The node we're starting from
* @param fieldNode - The ID of the field we're navigating within
*/
export declare function getPrevNextSegments(startingNode: HTMLElement, fieldNode: HTMLElement | null): {
next: HTMLElement | null | undefined;
prev: HTMLElement | null | undefined;
};
export declare function getPrevNextTimeSegments(startingNode: HTMLElement, fieldNode: HTMLElement | null): {
next: HTMLElement | null | undefined;
prev: HTMLElement | null | undefined;
};
/**
* Shifts the focus to the next segment in the list of segments
* within the field identified by the provided ID.
*/
export declare function moveToNextSegment(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
export declare function moveToNextTimeSegment(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
export declare function moveToPrevTimeSegment(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
/**
* Shifts the focus to the previous segment in the list of segments
* within the field identified by the provided ID. If this is the first
* segment, focus will not be shifted.
*/
export declare function moveToPrevSegment(e: KeyboardEvent, fieldNode: HTMLElement | null): void;
export declare function isSegmentNavigationKey(key: string): boolean;
/**
* Retrieves all the interactive segments within the field identified by the provided ID.
*/
export declare function getSegments(fieldNode: HTMLElement | null): HTMLElement[];
export declare function getTimeSegments(fieldNode: HTMLElement | null): HTMLElement[];
export declare function getFirstTimeSegment(fieldNode: HTMLElement | null): HTMLElement | undefined;
/**
* Get the first interactive segment within the field identified by the provided ID.
*/
export declare function getFirstSegment(fieldNode: HTMLElement | null): HTMLElement | undefined;
+193
View File
@@ -0,0 +1,193 @@
import { isAnySegmentPart } from "./helpers.js";
import { isHTMLElement } from "../../is.js";
import { kbd } from "../../kbd.js";
/**
* Handles segment navigation based on the provided keyboard event and field ID.
*
* @param e - The keyboard event
* @param fieldNode - The ID of the field we're navigating within
*/
export function handleSegmentNavigation(e, fieldNode) {
const currentTarget = e.currentTarget;
if (!isHTMLElement(currentTarget))
return;
const { prev, next } = getPrevNextSegments(currentTarget, fieldNode);
if (e.key === kbd.ARROW_LEFT) {
if (!prev)
return;
prev.focus();
}
else if (e.key === kbd.ARROW_RIGHT) {
if (!next)
return;
next.focus();
}
}
export function handleTimeSegmentNavigation(e, fieldNode) {
const currentTarget = e.currentTarget;
if (!isHTMLElement(currentTarget))
return;
const { prev, next } = getPrevNextTimeSegments(currentTarget, fieldNode);
if (e.key === kbd.ARROW_LEFT) {
if (!prev)
return;
prev.focus();
}
else if (e.key === kbd.ARROW_RIGHT) {
if (!next)
return;
next.focus();
}
}
/**
* Retrieves the next segment in the list of segments relative to the provided node.
*
* @param node - The node we're starting from
* @param segments - The list of candidate segments to navigate through
*/
export function getNextSegment(node, segments) {
const index = segments.indexOf(node);
if (index === segments.length - 1 || index === -1)
return null;
const nextIndex = index + 1;
const nextSegment = segments[nextIndex];
return nextSegment;
}
/**
* Retrieves the previous segment in the list of segments relative to the provided node.
*
* @param node - The node we're starting from
* @param segments - The list of candidate segments to navigate through
*/
export function getPrevSegment(node, segments) {
const index = segments.indexOf(node);
if (index === 0 || index === -1)
return null;
const prevIndex = index - 1;
const prevSegment = segments[prevIndex];
return prevSegment;
}
/**
* Retrieves an object containing the next and previous segments relative to the current node.
*
* @param startingNode - The node we're starting from
* @param fieldNode - The ID of the field we're navigating within
*/
export function getPrevNextSegments(startingNode, fieldNode) {
const segments = getSegments(fieldNode);
if (!segments.length) {
return {
next: null,
prev: null,
};
}
return {
next: getNextSegment(startingNode, segments),
prev: getPrevSegment(startingNode, segments),
};
}
export function getPrevNextTimeSegments(startingNode, fieldNode) {
const segments = getTimeSegments(fieldNode);
if (!segments.length) {
return {
next: null,
prev: null,
};
}
return {
next: getNextSegment(startingNode, segments),
prev: getPrevSegment(startingNode, segments),
};
}
/**
* Shifts the focus to the next segment in the list of segments
* within the field identified by the provided ID.
*/
export function moveToNextSegment(e, fieldNode) {
const node = e.currentTarget;
if (!isHTMLElement(node))
return;
const { next } = getPrevNextSegments(node, fieldNode);
if (!next)
return;
next.focus();
}
export function moveToNextTimeSegment(e, fieldNode) {
const node = e.currentTarget;
if (!isHTMLElement(node))
return;
const { next } = getPrevNextTimeSegments(node, fieldNode);
if (!next)
return;
next.focus();
}
export function moveToPrevTimeSegment(e, fieldNode) {
const node = e.currentTarget;
if (!isHTMLElement(node))
return;
const { prev } = getPrevNextTimeSegments(node, fieldNode);
if (!prev)
return;
prev.focus();
}
/**
* Shifts the focus to the previous segment in the list of segments
* within the field identified by the provided ID. If this is the first
* segment, focus will not be shifted.
*/
export function moveToPrevSegment(e, fieldNode) {
const node = e.currentTarget;
if (!isHTMLElement(node))
return;
const { prev } = getPrevNextSegments(node, fieldNode);
if (!prev)
return;
prev.focus();
}
export function isSegmentNavigationKey(key) {
if (key === kbd.ARROW_RIGHT || key === kbd.ARROW_LEFT)
return true;
return false;
}
/**
* Retrieves all the interactive segments within the field identified by the provided ID.
*/
export function getSegments(fieldNode) {
if (!fieldNode)
return [];
const segments = Array.from(fieldNode.querySelectorAll("[data-segment]")).filter((el) => {
if (!isHTMLElement(el))
return false;
const segment = el.dataset.segment;
if (segment === "trigger")
return true;
if (!isAnySegmentPart(segment) || segment === "literal")
return false;
return true;
});
return segments;
}
export function getTimeSegments(fieldNode) {
if (!fieldNode)
return [];
const segments = Array.from(fieldNode.querySelectorAll("[data-segment]")).filter((el) => {
if (!isHTMLElement(el))
return false;
const segment = el.dataset.segment;
if (segment === "trigger")
return true;
if (segment === "literal")
return false;
return true;
});
return segments;
}
export function getFirstTimeSegment(fieldNode) {
return getTimeSegments(fieldNode)[0];
}
/**
* Get the first interactive segment within the field identified by the provided ID.
*/
export function getFirstSegment(fieldNode) {
return getSegments(fieldNode)[0];
}
+83
View File
@@ -0,0 +1,83 @@
import type { EditableTimeSegmentPart, HourCycle, TimeGranularity, TimeSegmentContentObj, TimeSegmentStateMap, TimeSegmentValueObj, TimeValue } from "../../../shared/date/types.js";
import { CalendarDateTime, Time, ZonedDateTime } from "@internationalized/date";
import type { TimeFormatter } from "../formatter.js";
import type { TimeSegmentPart } from "./types.js";
export declare function initializeSegmentValues(): TimeSegmentValueObj;
type SharedTimeContentProps = {
granularity: TimeGranularity;
timeRef: TimeValue;
formatter: TimeFormatter;
hideTimeZone: boolean;
hourCycle: HourCycle | undefined;
};
type CreateTimeContentObjProps = SharedTimeContentProps & {
segmentValues: TimeSegmentValueObj;
locale: string;
};
type CreateTimeContentProps = CreateTimeContentObjProps;
export declare function createTimeContent(props: CreateTimeContentProps): {
obj: TimeSegmentContentObj;
arr: {
part: TimeSegmentPart;
value: string;
}[];
};
export declare function initTimeSegmentStates(): TimeSegmentStateMap;
export declare function initTimeSegmentIds(): any;
export declare function isEditableTimeSegmentPart(part: unknown): part is EditableTimeSegmentPart;
export declare function isAnyTimeSegmentPart(part: unknown): part is TimeSegmentPart;
type GetTimeValueFromSegments<T extends TimeValue = Time> = {
segmentObj: TimeSegmentValueObj;
fieldNode: HTMLElement | null;
timeRef: T;
};
export declare function getTimeValueFromSegments<T extends TimeValue = Time>(props: GetTimeValueFromSegments<T>): T;
/**
* Check if all the segments being used have been filled.
* We use this to determine when we should set the value
* store of the date field(s).
*
* @param segmentValues - The current `SegmentValueObj`
* @param fieldNode - The id of the date field
*/
export declare function areAllTimeSegmentsFilled(segmentValues: TimeSegmentValueObj, fieldNode: HTMLElement | null): boolean;
/**
* Infer the granularity to use based on the
* value and granularity props.
*/
export declare function inferTimeGranularity(granularity: TimeGranularity | undefined): TimeGranularity;
/**
* Determines if the element with the provided id is the first focusable
* segment in the date field with the provided fieldId.
*
* @param id - The id of the element to check if it's the first segment
* @param fieldNode - The id of the date field associated with the segment
*/
export declare function isFirstTimeSegment(id: string, fieldNode: HTMLElement | null): boolean;
type SetTimeDescriptionProps = {
id: string;
formatter: TimeFormatter;
value: TimeValue;
doc: Document;
};
/**
* Creates or updates a description element for a date field
* which enables screen readers to read the date field's value.
*
* This element is hidden from view, and is portalled to the body
* so it can be associated via `aria-describedby` and read by
* screen readers as the user interacts with the date field.
*/
export declare function setTimeDescription(props: SetTimeDescriptionProps): void;
/**
* Removes the description element for the date field with
* the provided ID. This function should be called when the
* date field is unmounted.
*/
export declare function removeTimeDescriptionElement(id: string, doc: Document): void;
export declare function convertTimeValueToDateValue(time: TimeValue): CalendarDateTime | ZonedDateTime;
export declare function convertTimeValueToTime(time: TimeValue): Time;
export declare function isTimeBefore(timeToCompare: Time, referenceTime: Time): boolean;
export declare function isTimeAfter(timeToCompare: Time, referenceTime: Time): boolean;
export declare function getISOTimeValue(time: TimeValue): string;
export {};
+301
View File
@@ -0,0 +1,301 @@
import { isBrowser, isNull } from "../../is.js";
import { CalendarDateTime, Time, ZonedDateTime } from "@internationalized/date";
import { ALL_TIME_SEGMENT_PARTS, EDITABLE_TIME_SEGMENT_PARTS } from "./parts.js";
import { getTimeSegments } from "./segments.js";
import { styleToString } from "svelte-toolbelt";
import { useId } from "../../use-id.js";
import { getPlaceholder } from "../placeholders.js";
import { isZonedDateTime } from "../utils.js";
import { getDefaultHourCycle } from "./helpers.js";
export function initializeSegmentValues() {
const initialParts = EDITABLE_TIME_SEGMENT_PARTS.map((part) => {
if (part === "dayPeriod") {
return [part, "AM"];
}
return [part, null];
}).filter(([key]) => {
if (key === "literal" || key === null)
return false;
return true;
});
return Object.fromEntries(initialParts);
}
function createTimeContentObj(props) {
const { segmentValues, formatter, locale, timeRef } = props;
const content = Object.keys(segmentValues).reduce((obj, part) => {
if (!isEditableTimeSegmentPart(part))
return obj;
if (part === "dayPeriod") {
const value = segmentValues[part];
if (!isNull(value)) {
obj[part] = value;
}
else {
obj[part] = getPlaceholder(part, "AM", locale);
}
}
else {
obj[part] = getPartContent(part);
}
return obj;
}, {});
function getPartContent(part) {
const value = segmentValues[part];
const leadingZero = typeof value === "string" && value?.startsWith("0");
const intValue = value !== null ? Number.parseInt(value) : null;
if (!isNull(value) && !isNull(intValue)) {
const formatted = formatter.part(timeRef.set({ [part]: value }), part, {
hourCycle: props.hourCycle === 24 ? "h23" : undefined,
});
/**
* If we're operating in a 12 hour clock and the part is an hour, we handle
* the conversion to 12 hour format with 2 digit hours and leading zeros here.
*/
const is12HourMode = props.hourCycle === 12 ||
(props.hourCycle === undefined && getDefaultHourCycle(locale) === 12);
if (part === "hour" && is12HourMode) {
/**
* If the value is over 12, we convert to 12 hour format and add leading
* zeroes if the value is less than 10.
*/
if (intValue > 12) {
const hour = intValue - 12;
if (hour === 0) {
return "12";
}
else if (hour < 10) {
return `0${hour}`;
}
else {
return `${hour}`;
}
}
/**
* If the value is 0, we convert to 12, since 0 is not a valid 12 hour time.
*/
if (intValue === 0) {
return "12";
}
/**
* If the value is less than 10, we add a leading zero to the value.
*/
if (intValue < 10) {
return `0${intValue}`;
}
/**
* Otherwise, we don't need to do anything to the value.
*/
return `${intValue}`;
}
if (leadingZero && formatted.length === 1) {
return `0${formatted}`;
}
return formatted;
}
else {
return getPlaceholder(part, "", locale);
}
}
return content;
}
function createTimeContentArr(props) {
const { granularity, timeRef, formatter, contentObj, hideTimeZone, hourCycle } = props;
const parts = formatter.toParts(timeRef, getOptsByGranularity(granularity, hourCycle));
const timeSegmentContentArr = parts
.map((part) => {
const defaultParts = ["literal", "timeZoneName", null];
if (defaultParts.includes(part.type) || !isEditableTimeSegmentPart(part.type)) {
return {
part: part.type,
value: part.value,
};
}
return {
part: part.type,
value: contentObj[part.type],
};
})
.filter((segment) => {
if (isNull(segment.part) || isNull(segment.value))
return false;
if (segment.part === "timeZoneName" && (!isZonedDateTime(timeRef) || hideTimeZone)) {
return false;
}
return true;
});
return timeSegmentContentArr;
}
export function createTimeContent(props) {
const contentObj = createTimeContentObj(props);
const contentArr = createTimeContentArr({
contentObj,
...props,
});
return {
obj: contentObj,
arr: contentArr,
};
}
function getOptsByGranularity(granularity, hourCycle) {
const opts = {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
hourCycle: hourCycle === 24 ? "h23" : undefined,
hour12: hourCycle === 24 ? false : undefined,
};
if (granularity === "hour") {
delete opts.minute;
delete opts.second;
}
if (granularity === "minute") {
delete opts.second;
}
return opts;
}
export function initTimeSegmentStates() {
return EDITABLE_TIME_SEGMENT_PARTS.reduce((acc, key) => {
acc[key] = {
lastKeyZero: false,
hasLeftFocus: true,
updating: null,
};
return acc;
}, {});
}
export function initTimeSegmentIds() {
return Object.fromEntries(ALL_TIME_SEGMENT_PARTS.map((part) => {
return [part, useId()];
}).filter(([key]) => key !== "literal"));
}
export function isEditableTimeSegmentPart(part) {
return EDITABLE_TIME_SEGMENT_PARTS.includes(part);
}
export function isAnyTimeSegmentPart(part) {
return ALL_TIME_SEGMENT_PARTS.includes(part);
}
/**
* Get the segments being used/ are rendered in the DOM.
* We're using this to determine when to set the value of
* the date picker, which is when all the segments have
* been filled.
*/
function getUsedTimeSegments(fieldNode) {
if (!isBrowser || !fieldNode)
return [];
const usedSegments = getTimeSegments(fieldNode)
.map((el) => el.dataset.segment)
.filter((part) => {
return EDITABLE_TIME_SEGMENT_PARTS.includes(part);
});
return usedSegments;
}
export function getTimeValueFromSegments(props) {
const usedSegments = getUsedTimeSegments(props.fieldNode);
for (const part of usedSegments) {
const value = props.segmentObj[part];
if (isNull(value))
continue;
// @ts-expect-error shhh
props.timeRef = props.timeRef.set({ [part]: props.segmentObj[part] });
}
return props.timeRef;
}
/**
* Check if all the segments being used have been filled.
* We use this to determine when we should set the value
* store of the date field(s).
*
* @param segmentValues - The current `SegmentValueObj`
* @param fieldNode - The id of the date field
*/
export function areAllTimeSegmentsFilled(segmentValues, fieldNode) {
const usedSegments = getUsedTimeSegments(fieldNode);
for (const part of usedSegments) {
if (segmentValues[part] === null)
return false;
}
return true;
}
/**
* Infer the granularity to use based on the
* value and granularity props.
*/
export function inferTimeGranularity(granularity) {
if (granularity)
return granularity;
return "minute";
}
/**
* Determines if the element with the provided id is the first focusable
* segment in the date field with the provided fieldId.
*
* @param id - The id of the element to check if it's the first segment
* @param fieldNode - The id of the date field associated with the segment
*/
export function isFirstTimeSegment(id, fieldNode) {
if (!isBrowser)
return false;
const segments = getTimeSegments(fieldNode);
return segments.length ? segments[0].id === id : false;
}
/**
* Creates or updates a description element for a date field
* which enables screen readers to read the date field's value.
*
* This element is hidden from view, and is portalled to the body
* so it can be associated via `aria-describedby` and read by
* screen readers as the user interacts with the date field.
*/
export function setTimeDescription(props) {
if (!isBrowser)
return;
const valueString = props.formatter.selectedTime(props.value);
const el = props.doc.getElementById(props.id);
if (!el) {
const div = props.doc.createElement("div");
div.style.cssText = styleToString({
display: "none",
});
div.id = props.id;
div.innerText = `Selected Time: ${valueString}`;
props.doc.body.appendChild(div);
}
else {
el.innerText = `Selected Time: ${valueString}`;
}
}
/**
* Removes the description element for the date field with
* the provided ID. This function should be called when the
* date field is unmounted.
*/
export function removeTimeDescriptionElement(id, doc) {
if (!isBrowser)
return;
const el = doc.getElementById(id);
if (!el)
return;
doc.body.removeChild(el);
}
export function convertTimeValueToDateValue(time) {
if (time instanceof Time) {
return new CalendarDateTime(2020, 1, 1, time.hour, time.minute, time.second, time.millisecond);
}
return time;
}
export function convertTimeValueToTime(time) {
if (time instanceof Time)
return time;
return new Time(time.hour, time.minute, time.second, time.millisecond);
}
export function isTimeBefore(timeToCompare, referenceTime) {
return timeToCompare.compare(referenceTime) < 0;
}
export function isTimeAfter(timeToCompare, referenceTime) {
return timeToCompare.compare(referenceTime) > 0;
}
export function getISOTimeValue(time) {
return convertTimeValueToTime(time).toString();
}
+25
View File
@@ -0,0 +1,25 @@
import type { DATE_SEGMENT_PARTS, EDITABLE_SEGMENT_PARTS, NON_EDITABLE_SEGMENT_PARTS, EDITABLE_TIME_SEGMENT_PARTS } from "./parts.js";
export type DateSegmentPart = (typeof DATE_SEGMENT_PARTS)[number];
export type TimeSegmentPart = (typeof EDITABLE_TIME_SEGMENT_PARTS)[number];
export type EditableSegmentPart = (typeof EDITABLE_SEGMENT_PARTS)[number];
export type NonEditableSegmentPart = (typeof NON_EDITABLE_SEGMENT_PARTS)[number];
export type SegmentPart = EditableSegmentPart | NonEditableSegmentPart;
export type AnyExceptLiteral = Exclude<SegmentPart, "literal">;
export type DayPeriod = "AM" | "PM" | null;
export type DateSegmentObj = {
[K in DateSegmentPart]: string | null;
};
export type TimeSegmentObj = {
[K in TimeSegmentPart]: K extends "dayPeriod" ? DayPeriod : string | null;
};
export type DateAndTimeSegmentObj = DateSegmentObj & TimeSegmentObj;
export type SegmentValueObj = DateSegmentObj | DateAndTimeSegmentObj;
export type SegmentContentObj = Record<EditableSegmentPart, string>;
export type SegmentState = {
lastKeyZero: boolean;
hasLeftFocus: boolean;
updating: string | null;
};
export type SegmentStateMap = {
[K in EditableSegmentPart]: SegmentState;
};
+1
View File
@@ -0,0 +1 @@
export {};
+41
View File
@@ -0,0 +1,41 @@
import { type DateValue } from "@internationalized/date";
import type { HourCycle, TimeValue } from "../../shared/date/types.js";
import type { ReadableBox } from "svelte-toolbelt";
export type Formatter = ReturnType<typeof createFormatter>;
export type TimeFormatter = ReturnType<typeof createTimeFormatter>;
type CreateFormatterOptions = {
initialLocale: string;
monthFormat: ReadableBox<Intl.DateTimeFormatOptions["month"] | ((month: number) => string)>;
yearFormat: ReadableBox<Intl.DateTimeFormatOptions["year"] | ((year: number) => string)>;
};
/**
* Creates a wrapper around the `DateFormatter`, which is
* an improved version of the {@link Intl.DateTimeFormat} API,
* that is used internally by the various date builders to
* easily format dates in a consistent way.
*
* @see [DateFormatter](https://react-spectrum.adobe.com/internationalized/date/DateFormatter.html)
*/
export declare function createFormatter(opts: CreateFormatterOptions): {
setLocale: (newLocale: string) => void;
getLocale: () => string;
fullMonth: (date: Date) => string;
fullYear: (date: Date) => string;
fullMonthAndYear: (date: Date) => string;
toParts: (date: DateValue, options?: Intl.DateTimeFormatOptions) => Intl.DateTimeFormatPart[];
custom: (date: Date, options: Intl.DateTimeFormatOptions) => string;
part: (dateObj: DateValue, type: Intl.DateTimeFormatPartTypes, options?: Intl.DateTimeFormatOptions) => string;
dayPeriod: (date: Date, hourCycle?: HourCycle | undefined) => "AM" | "PM";
selectedDate: (date: DateValue, includeTime?: boolean) => string;
dayOfWeek: (date: Date, length?: Intl.DateTimeFormatOptions["weekday"]) => string;
};
export declare function createTimeFormatter(initialLocale: string): {
setLocale: (newLocale: string) => void;
getLocale: () => string;
toParts: (timeValue: TimeValue, options?: Intl.DateTimeFormatOptions) => Intl.DateTimeFormatPart[];
custom: (date: Date, options: Intl.DateTimeFormatOptions) => string;
part: (dateObj: TimeValue, type: Intl.DateTimeFormatPartTypes, options?: Intl.DateTimeFormatOptions) => string;
dayPeriod: (date: Date, hourCycle?: HourCycle | undefined) => "AM" | "PM";
selectedTime: (date: TimeValue) => string;
};
export {};
+166
View File
@@ -0,0 +1,166 @@
import { DateFormatter } from "@internationalized/date";
import { hasTime, isZonedDateTime, toDate } from "./utils.js";
import { convertTimeValueToDateValue } from "./field/time-helpers.js";
const defaultPartOptions = {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
};
/**
* Creates a wrapper around the `DateFormatter`, which is
* an improved version of the {@link Intl.DateTimeFormat} API,
* that is used internally by the various date builders to
* easily format dates in a consistent way.
*
* @see [DateFormatter](https://react-spectrum.adobe.com/internationalized/date/DateFormatter.html)
*/
export function createFormatter(opts) {
let locale = opts.initialLocale;
function setLocale(newLocale) {
locale = newLocale;
}
function getLocale() {
return locale;
}
function custom(date, options) {
return new DateFormatter(locale, options).format(date);
}
function selectedDate(date, includeTime = true) {
if (hasTime(date) && includeTime) {
return custom(toDate(date), {
dateStyle: "long",
timeStyle: "long",
});
}
else {
return custom(toDate(date), {
dateStyle: "long",
});
}
}
function fullMonthAndYear(date) {
if (typeof opts.monthFormat.current !== "function" &&
typeof opts.yearFormat.current !== "function") {
return new DateFormatter(locale, {
month: opts.monthFormat.current,
year: opts.yearFormat.current,
}).format(date);
}
const formattedMonth = typeof opts.monthFormat.current === "function"
? opts.monthFormat.current(date.getMonth() + 1)
: new DateFormatter(locale, { month: opts.monthFormat.current }).format(date);
const formattedYear = typeof opts.yearFormat.current === "function"
? opts.yearFormat.current(date.getFullYear())
: new DateFormatter(locale, { year: opts.yearFormat.current }).format(date);
return `${formattedMonth} ${formattedYear}`;
}
function fullMonth(date) {
return new DateFormatter(locale, { month: "long" }).format(date);
}
function fullYear(date) {
return new DateFormatter(locale, { year: "numeric" }).format(date);
}
function toParts(date, options) {
if (isZonedDateTime(date)) {
return new DateFormatter(locale, {
...options,
timeZone: date.timeZone,
}).formatToParts(toDate(date));
}
else {
return new DateFormatter(locale, options).formatToParts(toDate(date));
}
}
function dayOfWeek(date, length = "narrow") {
return new DateFormatter(locale, { weekday: length }).format(date);
}
function dayPeriod(date, hourCycle = undefined) {
const parts = new DateFormatter(locale, {
hour: "numeric",
minute: "numeric",
hourCycle: hourCycle === 24 ? "h23" : undefined,
}).formatToParts(date);
const value = parts.find((p) => p.type === "dayPeriod")?.value;
if (value === "PM") {
return "PM";
}
return "AM";
}
function part(dateObj, type, options = {}) {
const opts = { ...defaultPartOptions, ...options };
const parts = toParts(dateObj, opts);
const part = parts.find((p) => p.type === type);
return part ? part.value : "";
}
return {
setLocale,
getLocale,
fullMonth,
fullYear,
fullMonthAndYear,
toParts,
custom,
part,
dayPeriod,
selectedDate,
dayOfWeek,
};
}
export function createTimeFormatter(initialLocale) {
let locale = initialLocale;
function setLocale(newLocale) {
locale = newLocale;
}
function getLocale() {
return locale;
}
function custom(date, options) {
return new DateFormatter(locale, options).format(date);
}
function selectedTime(date) {
return custom(toDate(convertTimeValueToDateValue(date)), {
timeStyle: "long",
});
}
function toParts(timeValue, options) {
const dateValue = convertTimeValueToDateValue(timeValue);
if (isZonedDateTime(dateValue)) {
return new DateFormatter(locale, {
...options,
timeZone: dateValue.timeZone,
}).formatToParts(toDate(dateValue));
}
else {
return new DateFormatter(locale, options).formatToParts(toDate(dateValue));
}
}
function dayPeriod(date, hourCycle = undefined) {
const parts = new DateFormatter(locale, {
hour: "numeric",
minute: "numeric",
hourCycle: hourCycle === 24 ? "h23" : undefined,
}).formatToParts(date);
const value = parts.find((p) => p.type === "dayPeriod")?.value;
if (value === "PM")
return "PM";
return "AM";
}
function part(dateObj, type, options = {}) {
const opts = { ...defaultPartOptions, ...options };
const parts = toParts(dateObj, opts);
const part = parts.find((p) => p.type === type);
return part ? part.value : "";
}
return {
setLocale,
getLocale,
toParts,
custom,
part,
dayPeriod,
selectedTime,
};
}
+8
View File
@@ -0,0 +1,8 @@
declare const supportedLocales: readonly ["ach", "af", "am", "an", "ar", "ast", "az", "be", "bg", "bn", "br", "bs", "ca", "cak", "ckb", "cs", "cy", "da", "de", "dsb", "el", "en", "eo", "es", "et", "eu", "fa", "ff", "fi", "fr", "fy", "ga", "gd", "gl", "he", "hr", "hsb", "hu", "ia", "id", "it", "ja", "ka", "kk", "kn", "ko", "lb", "lo", "lt", "lv", "meh", "ml", "ms", "nl", "nn", "no", "oc", "pl", "pt", "rm", "ro", "ru", "sc", "scn", "sk", "sl", "sr", "sv", "szl", "tg", "th", "tr", "uk", "zh-CN", "zh-TW"];
declare const placeholderFields: readonly ["year", "month", "day"];
type SupportedLocale = (typeof supportedLocales)[number];
type PlaceholderField = (typeof placeholderFields)[number];
export type PlaceholderMap = Record<SupportedLocale, Record<PlaceholderField, string>>;
type Field = "era" | "year" | "month" | "day" | "hour" | "minute" | "second" | "dayPeriod";
export declare function getPlaceholder(field: Field, value: string, locale: SupportedLocale | (string & {})): string;
export {};
+129
View File
@@ -0,0 +1,129 @@
// prettier-ignore
const supportedLocales = [
'ach', 'af', 'am', 'an', 'ar', 'ast', 'az', 'be', 'bg', 'bn', 'br', 'bs',
'ca', 'cak', 'ckb', 'cs', 'cy', 'da', 'de', 'dsb', 'el', 'en', 'eo', 'es',
'et', 'eu', 'fa', 'ff', 'fi', 'fr', 'fy', 'ga', 'gd', 'gl', 'he', 'hr',
'hsb', 'hu', 'ia', 'id', 'it', 'ja', 'ka', 'kk', 'kn', 'ko', 'lb', 'lo',
'lt', 'lv', 'meh', 'ml', 'ms', 'nl', 'nn', 'no', 'oc', 'pl', 'pt', 'rm',
'ro', 'ru', 'sc', 'scn', 'sk', 'sl', 'sr', 'sv', 'szl', 'tg', 'th', 'tr',
'uk', 'zh-CN', 'zh-TW',
];
const placeholderFields = ["year", "month", "day"];
const placeholders = {
ach: { year: "mwaka", month: "dwe", day: "nino" },
af: { year: "jjjj", month: "mm", day: "dd" },
am: { year: "ዓዓዓዓ", month: "ሚሜ", day: "ቀቀ" },
an: { year: "aaaa", month: "mm", day: "dd" },
ar: { year: "سنة", month: "شهر", day: "يوم" },
ast: { year: "aaaa", month: "mm", day: "dd" },
az: { year: "iiii", month: "aa", day: "gg" },
be: { year: "гггг", month: "мм", day: "дд" },
bg: { year: "гггг", month: "мм", day: "дд" },
bn: { year: "yyyy", month: "মিমি", day: "dd" },
br: { year: "bbbb", month: "mm", day: "dd" },
bs: { year: "gggg", month: "mm", day: "dd" },
ca: { year: "aaaa", month: "mm", day: "dd" },
cak: { year: "jjjj", month: "ii", day: "q'q'" },
ckb: { year: "ساڵ", month: "مانگ", day: "ڕۆژ" },
cs: { year: "rrrr", month: "mm", day: "dd" },
cy: { year: "bbbb", month: "mm", day: "dd" },
da: { year: "åååå", month: "mm", day: "dd" },
de: { year: "jjjj", month: "mm", day: "tt" },
dsb: { year: "llll", month: "mm", day: "źź" },
el: { year: "εεεε", month: "μμ", day: "ηη" },
en: { year: "yyyy", month: "mm", day: "dd" },
eo: { year: "jjjj", month: "mm", day: "tt" },
es: { year: "aaaa", month: "mm", day: "dd" },
et: { year: "aaaa", month: "kk", day: "pp" },
eu: { year: "uuuu", month: "hh", day: "ee" },
fa: { year: "سال", month: "ماه", day: "روز" },
ff: { year: "hhhh", month: "ll", day: "ññ" },
fi: { year: "vvvv", month: "kk", day: "pp" },
fr: { year: "aaaa", month: "mm", day: "jj" },
fy: { year: "jjjj", month: "mm", day: "dd" },
ga: { year: "bbbb", month: "mm", day: "ll" },
gd: { year: "bbbb", month: "mm", day: "ll" },
gl: { year: "aaaa", month: "mm", day: "dd" },
he: { year: "שנה", month: "חודש", day: "יום" },
hr: { year: "gggg", month: "mm", day: "dd" },
hsb: { year: "llll", month: "mm", day: "dd" },
hu: { year: "éééé", month: "hh", day: "nn" },
ia: { year: "aaaa", month: "mm", day: "dd" },
id: { year: "tttt", month: "bb", day: "hh" },
it: { year: "aaaa", month: "mm", day: "gg" },
ja: { year: " 年 ", month: "月", day: "日" },
ka: { year: "წწწწ", month: "თთ", day: "რრ" },
kk: { year: "жжжж", month: "аа", day: "кк" },
kn: { year: "ವವವವ", month: "ಮಿಮೀ", day: "ದಿದಿ" },
ko: { year: "연도", month: "월", day: "일" },
lb: { year: "jjjj", month: "mm", day: "dd" },
lo: { year: "ປປປປ", month: "ດດ", day: "ວວ" },
lt: { year: "mmmm", month: "mm", day: "dd" },
lv: { year: "gggg", month: "mm", day: "dd" },
meh: { year: "aaaa", month: "mm", day: "dd" },
ml: { year: "വർഷം", month: "മാസം", day: "തീയതി" },
ms: { year: "tttt", month: "mm", day: "hh" },
nl: { year: "jjjj", month: "mm", day: "dd" },
nn: { year: "åååå", month: "mm", day: "dd" },
no: { year: "åååå", month: "mm", day: "dd" },
oc: { year: "aaaa", month: "mm", day: "jj" },
pl: { year: "rrrr", month: "mm", day: "dd" },
pt: { year: "aaaa", month: "mm", day: "dd" },
rm: { year: "oooo", month: "mm", day: "dd" },
ro: { year: "aaaa", month: "ll", day: "zz" },
ru: { year: "гггг", month: "мм", day: "дд" },
sc: { year: "aaaa", month: "mm", day: "dd" },
scn: { year: "aaaa", month: "mm", day: "jj" },
sk: { year: "rrrr", month: "mm", day: "dd" },
sl: { year: "llll", month: "mm", day: "dd" },
sr: { year: "гггг", month: "мм", day: "дд" },
sv: { year: "åååå", month: "mm", day: "dd" },
szl: { year: "rrrr", month: "mm", day: "dd" },
tg: { year: "сссс", month: "мм", day: "рр" },
th: { year: "ปปปป", month: "ดด", day: "วว" },
tr: { year: "yyyy", month: "aa", day: "gg" },
uk: { year: "рррр", month: "мм", day: "дд" },
"zh-CN": { year: "年", month: "月", day: "日" },
"zh-TW": { year: "年", month: "月", day: "日" },
};
function getPlaceholderObj(locale) {
if (!isSupportedLocale(locale)) {
const localeLanguage = getLocaleLanguage(locale);
if (!isSupportedLocale(localeLanguage)) {
return placeholders.en;
}
else {
return placeholders[localeLanguage];
}
}
else {
return placeholders[locale];
}
}
export function getPlaceholder(field, value, locale) {
if (isPlaceholderField(field))
return getPlaceholderObj(locale)[field];
if (isDefaultField(field))
return value;
if (isTimeField(field))
return "––";
return "";
}
function isSupportedLocale(locale) {
return supportedLocales.includes(locale);
}
function isPlaceholderField(field) {
return placeholderFields.includes(field);
}
function isTimeField(field) {
return field === "hour" || field === "minute" || field === "second";
}
function isDefaultField(field) {
return field === "era" || field === "dayPeriod";
}
function getLocaleLanguage(locale) {
if (Intl.Locale) {
return new Intl.Locale(locale).language;
}
return locale.split("-")[0];
}
+76
View File
@@ -0,0 +1,76 @@
import { CalendarDateTime, type DateValue, ZonedDateTime } from "@internationalized/date";
import type { DateMatcher, Granularity, TimeGranularity, TimeValue } from "../../shared/date/types.js";
type GetDefaultDateProps = {
defaultValue?: DateValue | DateValue[] | undefined;
minValue?: DateValue;
maxValue?: DateValue;
granularity?: Granularity;
};
type GetDefaultTimeProps = {
defaultValue?: TimeValue | undefined;
granularity?: TimeGranularity;
};
/**
* A helper function used throughout the various date builders
* to generate a default `DateValue` using the `defaultValue`,
* `defaultPlaceholder`, `minValue`, `maxValue`, and `granularity` props.
*
* It's important to match the `DateValue` type being used
* elsewhere in the builder, so they behave according to the
* behavior the user expects based on the props they've provided.
*
*/
export declare function getDefaultDate(opts: GetDefaultDateProps): DateValue;
export declare function getDefaultTime(opts: GetDefaultTimeProps): TimeValue;
/**
* Given a date string and a reference `DateValue` object, parse the
* string to the same type as the reference object.
*
* Useful for parsing strings from data attributes, which are always
* strings, to the same type being used by the date component.
*/
export declare function parseStringToDateValue(dateStr: string, referenceVal: DateValue): DateValue;
/**
* Given a `DateValue` object, convert it to a native `Date` object.
* If a timezone is provided, the date will be converted to that timezone.
* If no timezone is provided, the date will be converted to the local timezone.
*/
export declare function toDate(dateValue: DateValue, tz?: string): Date;
export declare function getDateValueType(date: DateValue): string;
export declare function parseAnyDateValue(value: string, type: string): DateValue;
export declare function isZonedDateTime(dateValue: DateValue | TimeValue): dateValue is ZonedDateTime;
export declare function hasTime(dateValue: DateValue): dateValue is CalendarDateTime | ZonedDateTime;
/**
* Given a date, return the number of days in the month.
*/
export declare function getDaysInMonth(date: Date | DateValue): number;
/**
* Determine if a date is before the reference date.
* @param dateToCompare - is this date before the `referenceDate`
* @param referenceDate - is the `dateToCompare` before this date
*
* @see {@link isBeforeOrSame} for inclusive
*/
export declare function isBefore(dateToCompare: DateValue, referenceDate: DateValue): boolean;
/**
* Determine if a date is after the reference date.
* @param dateToCompare - is this date after the `referenceDate`
* @param referenceDate - is the `dateToCompare` after this date
*
* @see {@link isAfterOrSame} for inclusive
*/
export declare function isAfter(dateToCompare: DateValue, referenceDate: DateValue): boolean;
/**
* Determine if a date is inclusively between a start and end reference date.
*
* @param date - is this date inclusively between the `start` and `end` dates
* @param start - the start reference date to make the comparison against
* @param end - the end reference date to make the comparison against
*
* @see {@link isBetween} for non-inclusive
*/
export declare function isBetweenInclusive(date: DateValue, start: DateValue, end: DateValue): boolean;
export declare function getLastFirstDayOfWeek<T extends DateValue = DateValue>(date: T, firstDayOfWeek: number, locale: string): T;
export declare function getNextLastDayOfWeek<T extends DateValue = DateValue>(date: T, firstDayOfWeek: number, locale: string): T;
export declare function areAllDaysBetweenValid(start: DateValue, end: DateValue, isUnavailable: DateMatcher | undefined, isDisabled: DateMatcher | undefined): boolean;
export {};
+232
View File
@@ -0,0 +1,232 @@
import { CalendarDate, CalendarDateTime, Time, ZonedDateTime, getDayOfWeek, getLocalTimeZone, parseDate, parseDateTime, parseZonedDateTime, toCalendar, } from "@internationalized/date";
const defaultDateDefaults = {
defaultValue: undefined,
granularity: "day",
};
const defaultTimeDefaults = {
defaultValue: undefined,
granularity: "minute",
};
/**
* A helper function used throughout the various date builders
* to generate a default `DateValue` using the `defaultValue`,
* `defaultPlaceholder`, `minValue`, `maxValue`, and `granularity` props.
*
* It's important to match the `DateValue` type being used
* elsewhere in the builder, so they behave according to the
* behavior the user expects based on the props they've provided.
*
*/
export function getDefaultDate(opts) {
const withDefaults = { ...defaultDateDefaults, ...opts };
const { defaultValue, granularity, minValue, maxValue } = withDefaults;
if (Array.isArray(defaultValue) && defaultValue.length) {
return defaultValue[defaultValue.length - 1];
}
if (defaultValue && !Array.isArray(defaultValue)) {
return defaultValue;
}
else {
let date = new Date();
if (minValue && date < minValue.toDate(getLocalTimeZone())) {
date = minValue.toDate(getLocalTimeZone());
}
else if (maxValue && date > maxValue.toDate(getLocalTimeZone())) {
date = maxValue.toDate(getLocalTimeZone());
}
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const calendarDateTimeGranularities = ["hour", "minute", "second"];
if (calendarDateTimeGranularities.includes(granularity ?? "day")) {
return new CalendarDateTime(year, month, day, 0, 0, 0);
}
return new CalendarDate(year, month, day);
}
}
export function getDefaultTime(opts) {
const withDefaults = { ...defaultTimeDefaults, ...opts };
const { defaultValue } = withDefaults;
if (defaultValue) {
return defaultValue;
}
else {
return new Time(0, 0, 0);
}
}
/**
* Given a date string and a reference `DateValue` object, parse the
* string to the same type as the reference object.
*
* Useful for parsing strings from data attributes, which are always
* strings, to the same type being used by the date component.
*/
export function parseStringToDateValue(dateStr, referenceVal) {
let dateValue;
if (referenceVal instanceof ZonedDateTime) {
dateValue = parseZonedDateTime(dateStr);
}
else if (referenceVal instanceof CalendarDateTime) {
dateValue = parseDateTime(dateStr);
}
else {
dateValue = parseDate(dateStr);
}
// ensure the parsed date is in the same calendar as the reference date set by the user.
return dateValue.calendar !== referenceVal.calendar
? toCalendar(dateValue, referenceVal.calendar)
: dateValue;
}
/**
* Given a `DateValue` object, convert it to a native `Date` object.
* If a timezone is provided, the date will be converted to that timezone.
* If no timezone is provided, the date will be converted to the local timezone.
*/
export function toDate(dateValue, tz = getLocalTimeZone()) {
if (dateValue instanceof ZonedDateTime) {
return dateValue.toDate();
}
else {
return dateValue.toDate(tz);
}
}
export function getDateValueType(date) {
if (date instanceof CalendarDate)
return "date";
if (date instanceof CalendarDateTime)
return "datetime";
if (date instanceof ZonedDateTime)
return "zoneddatetime";
throw new Error("Unknown date type");
}
export function parseAnyDateValue(value, type) {
switch (type) {
case "date":
return parseDate(value);
case "datetime":
return parseDateTime(value);
case "zoneddatetime":
return parseZonedDateTime(value);
default:
throw new Error(`Unknown date type: ${type}`);
}
}
function isCalendarDateTime(dateValue) {
return dateValue instanceof CalendarDateTime;
}
export function isZonedDateTime(dateValue) {
return dateValue instanceof ZonedDateTime;
}
export function hasTime(dateValue) {
return isCalendarDateTime(dateValue) || isZonedDateTime(dateValue);
}
/**
* Given a date, return the number of days in the month.
*/
export function getDaysInMonth(date) {
if (date instanceof Date) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
/**
* By using zero as the day, we get the
* last day of the previous month, which
* is the month we originally passed in.
*/
return new Date(year, month, 0).getDate();
}
else {
return date.set({ day: 100 }).day;
}
}
/**
* Determine if a date is before the reference date.
* @param dateToCompare - is this date before the `referenceDate`
* @param referenceDate - is the `dateToCompare` before this date
*
* @see {@link isBeforeOrSame} for inclusive
*/
export function isBefore(dateToCompare, referenceDate) {
return dateToCompare.compare(referenceDate) < 0;
}
/**
* Determine if a date is after the reference date.
* @param dateToCompare - is this date after the `referenceDate`
* @param referenceDate - is the `dateToCompare` after this date
*
* @see {@link isAfterOrSame} for inclusive
*/
export function isAfter(dateToCompare, referenceDate) {
return dateToCompare.compare(referenceDate) > 0;
}
/**
* Determine if a date is before or the same as the reference date.
*
* @param dateToCompare - the date to compare
* @param referenceDate - the reference date to make the comparison against
*
* @see {@link isBefore} for non-inclusive
*/
function isBeforeOrSame(dateToCompare, referenceDate) {
return dateToCompare.compare(referenceDate) <= 0;
}
/**
* Determine if a date is after or the same as the reference date.
*
* @param dateToCompare - is this date after or the same as the `referenceDate`
* @param referenceDate - is the `dateToCompare` after or the same as this date
*
* @see {@link isAfter} for non-inclusive
*/
function isAfterOrSame(dateToCompare, referenceDate) {
return dateToCompare.compare(referenceDate) >= 0;
}
/**
* Determine if a date is inclusively between a start and end reference date.
*
* @param date - is this date inclusively between the `start` and `end` dates
* @param start - the start reference date to make the comparison against
* @param end - the end reference date to make the comparison against
*
* @see {@link isBetween} for non-inclusive
*/
export function isBetweenInclusive(date, start, end) {
return isAfterOrSame(date, start) && isBeforeOrSame(date, end);
}
export function getLastFirstDayOfWeek(date, firstDayOfWeek, locale) {
const day = getDayOfWeek(date, locale);
if (firstDayOfWeek > day) {
return date.subtract({ days: day + 7 - firstDayOfWeek });
}
if (firstDayOfWeek === day) {
return date;
}
return date.subtract({ days: day - firstDayOfWeek });
}
export function getNextLastDayOfWeek(date, firstDayOfWeek, locale) {
const day = getDayOfWeek(date, locale);
const lastDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
if (day === lastDayOfWeek) {
return date;
}
if (day > lastDayOfWeek) {
return date.add({ days: 7 - day + lastDayOfWeek });
}
return date.add({ days: lastDayOfWeek - day });
}
export function areAllDaysBetweenValid(start, end, isUnavailable, isDisabled) {
if (isUnavailable === undefined && isDisabled === undefined) {
return true;
}
let dCurrent = start.add({ days: 1 });
if (isDisabled?.(dCurrent) || isUnavailable?.(dCurrent)) {
return false;
}
const dEnd = end;
while (dCurrent.compare(dEnd) < 0) {
dCurrent = dCurrent.add({ days: 1 });
if (isDisabled?.(dCurrent) || isUnavailable?.(dCurrent)) {
return false;
}
}
return true;
}