import { addMeltEventListener, makeElement, createElHelpers, effect, executeCallbacks, generateIds, isBrowser, isHTMLElement, isValidIndex, kbd, omit, overridable, styleToString, toWritableStores, withGet, } from '../../internal/helpers/index.js'; import { createFormatter, createMonths, dateStore, getAnnouncer, getDefaultDate, getSelectableCells, isAfter, isBefore, isCalendarCell, parseStringToDateValue, setPlaceholderToNodeValue, toDate, } from '../../internal/helpers/date/index.js'; import { getLocalTimeZone, isSameDay, isSameMonth, isToday, } from '@internationalized/date'; import { tick } from 'svelte'; import { derived, writable } from 'svelte/store'; export const defaults = { isDateDisabled: undefined, isDateUnavailable: undefined, value: undefined, preventDeselect: false, numberOfMonths: 1, pagedNavigation: false, weekStartsOn: 0, fixedWeeks: false, calendarLabel: 'Event Date', locale: 'en', minValue: undefined, maxValue: undefined, disabled: false, readonly: false, weekdayFormat: 'narrow', }; const { name } = createElHelpers('calendar'); export const calendarIdParts = ['calendar', 'accessibleHeading']; export function createCalendar(props) { const withDefaults = { ...defaults, ...props }; const options = toWritableStores({ ...omit(withDefaults, 'value', 'placeholder', 'multiple', 'ids'), multiple: withDefaults.multiple ?? false, }); const { preventDeselect, numberOfMonths, pagedNavigation, weekStartsOn, fixedWeeks, calendarLabel, locale, minValue, maxValue, multiple, isDateUnavailable, disabled, readonly, weekdayFormat, } = options; const ids = toWritableStores({ ...generateIds(calendarIdParts), ...withDefaults.ids }); const defaultDate = getDefaultDate({ defaultPlaceholder: withDefaults.defaultPlaceholder, defaultValue: withDefaults.defaultValue, }); const formatter = createFormatter(withDefaults.locale); const valueWritable = withDefaults.value ?? writable(withDefaults.defaultValue); const value = overridable(valueWritable, withDefaults.onValueChange); const placeholderWritable = withDefaults.placeholder ?? writable(withDefaults.defaultPlaceholder ?? defaultDate); const placeholder = dateStore(overridable(placeholderWritable, withDefaults.onPlaceholderChange), withDefaults.defaultPlaceholder ?? defaultDate); /** * A store containing the months to display in the calendar. */ const months = withGet(writable(createMonths({ dateObj: placeholder.get(), weekStartsOn: withDefaults.weekStartsOn, locale: withDefaults.locale, fixedWeeks: withDefaults.fixedWeeks, numberOfMonths: withDefaults.numberOfMonths, }))); /** * A derived store that maintains the currently visible months in the calendar, * which we use to determine how keyboard navigation and if we should apply * `data-outside-month` to cells. */ const visibleMonths = withGet.derived([months], ([$months]) => { return $months.map((month) => { return month.value; }); }); const isOutsideVisibleMonths = derived([visibleMonths], ([$visibleMonths]) => { return (date) => { return !$visibleMonths.some((month) => isSameMonth(date, month)); }; }); const isNextButtonDisabled = withGet.derived([months, maxValue, disabled], ([$months, $maxValue, $disabled]) => { if (!$maxValue || !$months.length) return false; if ($disabled) return true; const lastMonthInView = $months[$months.length - 1].value; const firstMonthOfNextPage = lastMonthInView.add({ months: 1 }).set({ day: 1 }); return isAfter(firstMonthOfNextPage, $maxValue); }); const isPrevButtonDisabled = withGet.derived([months, minValue, disabled], ([$months, $minValue, $disabled]) => { if (!$minValue || !$months.length) return false; if ($disabled) return true; const firstMonthInView = $months[0].value; const lastMonthOfPrevPage = firstMonthInView.subtract({ months: 1 }).set({ day: 35 }); return isBefore(lastMonthOfPrevPage, $minValue); }); /** * A derived store function that determines if a date is disabled based * on the `isDateDisabled` prop, `minValue`, and `maxValue` props. */ const isDateDisabled = withGet.derived([options.isDateDisabled, minValue, maxValue, disabled], ([$isDateDisabled, $minValue, $maxValue, $disabled]) => { return (date) => { if ($isDateDisabled?.(date) || $disabled) return true; if ($minValue && isBefore(date, $minValue)) return true; if ($maxValue && isBefore($maxValue, date)) return true; return false; }; }); const isDateSelected = derived([value], ([$value]) => { return (date) => { if (Array.isArray($value)) { return $value.some((d) => isSameDay(d, date)); } else if (!$value) { return false; } else { return isSameDay($value, date); } }; }); /** * A derived helper store that evaluates to true if a currently selected date is invalid. */ const isInvalid = derived([value, isDateDisabled, options.isDateUnavailable], ([$value, $isDateDisabled, $isDateUnavailable]) => { if (Array.isArray($value)) { if (!$value.length) return false; for (const date of $value) { if ($isDateDisabled?.(date)) return true; if ($isDateUnavailable?.(date)) return true; } } else { if (!$value) return false; if ($isDateDisabled?.($value)) return true; if ($isDateUnavailable?.($value)) return true; } return false; }); /** * Initialize the announcer, which currently remains inactive in this context since it will * be server-side rendered, but we'll initialize it in the calendar's action. * * The announcer is in charge of providing `aria-live` announcements for the calendar, * such as when a date is selected. */ let announcer = getAnnouncer(); /** * The current heading value for the calendar, meant to be utilized with * the {@link heading} builder. * It renders the current displayed month and year, formatted for the current locale. * This value updates automatically as the user navigates the calendar, even when * displaying multiple months using the `numberOfMonths` prop. */ const headingValue = withGet.derived([months, locale], ([$months, $locale]) => { if (!$months.length) return ''; if ($locale !== formatter.getLocale()) { formatter.setLocale($locale); } if ($months.length === 1) { const month = $months[0].value; return `${formatter.fullMonthAndYear(toDate(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; }); /** * The accessible heading label for the calendar, generated by combining the `calendarLabel` * prop and the `headingValue` store to create a label like `Event Date, January 2021`. */ const fullCalendarLabel = withGet.derived([headingValue, calendarLabel], ([$headingValue, $calendarLabel]) => { return `${$calendarLabel}, ${$headingValue}`; }); /** * The root element of the calendar, capable of housing multiple grids/months * when using paged navigation. */ const calendar = makeElement(name(), { stores: [fullCalendarLabel, isInvalid, disabled, readonly, ids.calendar], returned: ([$fullCalendarLabel, $isInvalid, $disabled, $readonly, $calendarId]) => { return { id: $calendarId, role: 'application', 'aria-label': $fullCalendarLabel, 'data-invalid': $isInvalid ? '' : undefined, 'data-disabled': $disabled ? '' : undefined, 'data-readonly': $readonly ? '' : undefined, }; }, action: (node) => { /** * Generates the accessible calendar heading when the grid is mounted. * The label is dynamically updated through an effect whenever there * are changes in the active date or label. */ createAccessibleHeading(node, fullCalendarLabel.get()); announcer = getAnnouncer(); const unsubKb = addMeltEventListener(node, 'keydown', handleCalendarKeydown); return { destroy() { unsubKb(); }, }; }, }); /** * The calendar heading, visually displaying the current month and year. This heading * is hidden from screen readers as an accessible heading is automatically generated * for the calendar. * * To customize the accessible heading's prefix, use the `calendarLabel` prop. By default, * the accessible heading reads as `Event Date, January 2021` for January 2021. If you set * the `calendarLabel` prop to 'Booking Date', the accessible heading will be 'Booking Date, * January 2021' for the same month and year. */ const heading = makeElement(name('heading'), { stores: [disabled], returned: ([$disabled]) => { return { 'aria-hidden': true, 'data-disabled': $disabled ? '' : undefined, }; }, }); /** * A grid element that serves as a container for a single month in the calendar. * Grids should be rendered for each month present in the `months` store returned * by the `createCalendar` builder. * * For more details about the structure of the month object, refer to {@link Month}. */ const grid = makeElement(name('grid'), { stores: [readonly, disabled], returned: ([$readonly, $disabled]) => { return { tabindex: -1, role: 'grid', 'aria-readonly': $readonly ? 'true' : undefined, 'aria-disabled': $disabled ? 'true' : undefined, 'data-readonly': $readonly ? '' : undefined, 'data-disabled': $disabled ? '' : undefined, }; }, }); /** * The 'prev' button for the calendar, enabling navigation to the * previous page. In paged navigation mode, it moves the calendar * back by the number of months specified in the `numberOfMonths` prop. * In non-paged mode, it shifts the calendar back by one month. */ const prevButton = makeElement(name('prevButton'), { stores: [isPrevButtonDisabled], returned: ([$isPrevButtonDisabled]) => { const disabled = $isPrevButtonDisabled; return { role: 'button', type: 'button', 'aria-label': 'Previous', 'aria-disabled': disabled ? 'true' : undefined, 'data-disabled': disabled ? '' : undefined, disabled: disabled ? true : undefined, }; }, action: (node) => { const unsub = executeCallbacks(addMeltEventListener(node, 'click', () => { if (isPrevButtonDisabled.get()) return; prevPage(); })); return { destroy: unsub, }; }, }); /** * A button element designed for navigating to the next page of the calendar. * If using paged navigation, it advances the calendar by the number of months * specified in the `numberOfMonths` prop. If not using paged navigation, it * moves the calendar forward by one month. */ const nextButton = makeElement(name('nextButton'), { stores: [isNextButtonDisabled], returned: ([$isNextButtonDisabled]) => { const disabled = $isNextButtonDisabled; return { role: 'button', type: 'button', 'aria-label': 'Next', 'aria-disabled': disabled ? 'true' : undefined, 'data-disabled': disabled ? '' : undefined, disabled: disabled ? true : undefined, }; }, action: (node) => { const unsub = executeCallbacks(addMeltEventListener(node, 'click', () => { if (isNextButtonDisabled.get()) return; nextPage(); })); return { destroy: unsub, }; }, }); /** * Represents an individual date cell in the calendar grid, * signifying a single day within the month. Configured with * essential attributes and event handlers for accessibility * and interactivity. */ const cell = makeElement(name('cell'), { stores: [ isDateSelected, isDateDisabled, isDateUnavailable, isOutsideVisibleMonths, placeholder, ], returned: ([$isDateSelected, $isDateDisabled, $isDateUnavailable, $isOutsideVisibleMonths, $placeholder,]) => { /** * Applies the appropriate attributes to each date cell in the calendar. * * @params cellValue - The `DateValue` for the current cell. * @params monthValue - The `DateValue` for the current month, which is used * to determine if the current cell is outside the current month. */ return (cellValue, monthValue) => { const cellDate = toDate(cellValue); const isDisabled = $isDateDisabled?.(cellValue); const isUnavailable = $isDateUnavailable?.(cellValue); const isDateToday = isToday(cellValue, getLocalTimeZone()); const isOutsideMonth = !isSameMonth(cellValue, monthValue); const isOutsideVisibleMonths = $isOutsideVisibleMonths(cellValue); const isFocusedDate = isSameDay(cellValue, $placeholder); const isSelectedDate = $isDateSelected(cellValue); const labelText = formatter.custom(cellDate, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', }); return { role: 'button', 'aria-label': labelText, 'aria-selected': isSelectedDate ? true : undefined, 'aria-disabled': isOutsideMonth || isDisabled || isUnavailable ? true : undefined, 'data-selected': isSelectedDate ? true : undefined, 'data-value': cellValue.toString(), 'data-disabled': isDisabled || isOutsideMonth ? '' : undefined, 'data-unavailable': isUnavailable ? '' : undefined, 'data-today': isDateToday ? '' : undefined, 'data-outside-month': isOutsideMonth ? '' : undefined, 'data-outside-visible-months': isOutsideVisibleMonths ? '' : undefined, 'data-focused': isFocusedDate ? '' : undefined, tabindex: isFocusedDate ? 0 : isOutsideMonth || isDisabled ? undefined : -1, }; }; }, action: (node) => { const getElArgs = () => { const value = node.getAttribute('data-value'); const label = node.getAttribute('data-label'); const disabled = node.hasAttribute('data-disabled'); return { value, label: label ?? node.textContent ?? null, disabled: disabled ? true : false, }; }; const unsub = executeCallbacks(addMeltEventListener(node, 'click', () => { const args = getElArgs(); if (args.disabled) return; if (!args.value) return; handleCellClick(parseStringToDateValue(args.value, placeholder.get())); })); return { destroy: unsub, }; }, }); /** * Synchronize the locale used within the formatter to ensure * dynamic updates are reflected in the calendar. */ effect([locale], ([$locale]) => { if (formatter.getLocale() === $locale) return; formatter.setLocale($locale); }); /** * Updates the displayed months based on changes in the placeholder value, * which determines the months to show in the calendar. */ effect([placeholder], ([$placeholder]) => { if (!isBrowser || !$placeholder) return; const $visibleMonths = visibleMonths.get(); /** * If the placeholder's month is already in the visible months, * we don't need to do anything. */ if ($visibleMonths.some((month) => isSameMonth(month, $placeholder))) { return; } const $weekStartsOn = weekStartsOn.get(); const $locale = locale.get(); const $fixedWeeks = fixedWeeks.get(); const $numberOfMonths = numberOfMonths.get(); const defaultMonthProps = { weekStartsOn: $weekStartsOn, locale: $locale, fixedWeeks: $fixedWeeks, numberOfMonths: $numberOfMonths, }; months.set(createMonths({ ...defaultMonthProps, dateObj: $placeholder, })); }); /** * Updates the displayed months based on changes in the the options values, * which determines the months to show in the calendar. */ effect([weekStartsOn, locale, fixedWeeks, numberOfMonths], ([$weekStartsOn, $locale, $fixedWeeks, $numberOfMonths]) => { const $placeholder = placeholder.get(); if (!isBrowser || !$placeholder) return; const defaultMonthProps = { weekStartsOn: $weekStartsOn, locale: $locale, fixedWeeks: $fixedWeeks, numberOfMonths: $numberOfMonths, }; months.set(createMonths({ ...defaultMonthProps, dateObj: $placeholder, })); }); /** * Update the accessible heading's text content when the * `fullCalendarLabel` store changes. */ effect([fullCalendarLabel], ([$fullCalendarLabel]) => { if (!isBrowser) return; const node = document.getElementById(ids.accessibleHeading.get()); if (!isHTMLElement(node)) return; node.textContent = $fullCalendarLabel; }); /** * Synchronizing the placeholder value with the current value. */ effect([value], ([$value]) => { if (Array.isArray($value) && $value.length) { const lastValue = $value[$value.length - 1]; if (lastValue && placeholder.get() !== lastValue) { placeholder.set(lastValue); } } else if (!Array.isArray($value) && $value && placeholder.get() !== $value) { placeholder.set($value); } }); /** * This derived store holds an array of localized day names for the current * locale and calendar view. It dynamically syncs with the 'weekStartsOn' option, * updating its content when the option changes. Using this store to render the * calendar's days of the week is strongly recommended, as it guarantees that * the days are correctly formatted for the current locale and calendar view. * * @example * ```svelte *
*
* {day}
*
* |
* {/each}
*
---|
*
* {new Intl.DateTimeFormat('en', { weekday: 'long' }).format
* (dayOfWeek)}
*
* |
* {/each}
*
---|