/** * `materialDatepicker`: the behaviour of `` — M3's docked, modal and modal-input * date pickers on one ``. * * The dialog is shown three ways. Docked, it is a `popover="manual"` placed under the field by CSS * anchor positioning; modal (the `modal` and `input` modes, and a docked picker on a compact * window — below `medium`, 600px, where M3 puts the picker in a dialog rather than under the * field) it is opened with `showModal()`; full, a `range` picker on a compact window, is that same * modal dialog grown to the whole screen — M3's full-screen range picker, with an app bar carrying * a close button and **Save**, the range as the headline, and a list of months scrolled through * rather than stepped (§ Date Pickers, Anatomy). Everything a person sees in it is drawn by Alpine * from the state below, and the dialog is `wire:ignore`, so a Livewire render never touches it * while it is open. * * Dates are ISO strings (`2026-09-13`) throughout, computed in UTC so no time zone or daylight * saving change can move a day; only "today" is read in the browser's own zone. Month and weekday * names, the week's first day and the typed format come from `Intl` for the locale the server * passes (the application's), unless the component names the first day (`weekStart`, 0 for Sunday * to 6) or the format (`format`, such as `dd.MM.yyyy`); everything that reads `firstDay` and * `format` below then follows those. * * The keyboard is WAI-ARIA's date picker dialog: arrows move a day or a week, Home and End go to * the start and end of the week, PageUp and PageDown a month (with Shift a year), Space selects, * Enter selects and confirms, Escape closes and hands focus back to the field. M3's own table says * Home and End "move to the first day of the month"; the APG behaviour is the stronger web * convention and is what this keeps. Shift+M and Shift+Y are M3's, and reach the month and year * dropdowns. Focus never leaves the `min`–`max` span, so a disabled day cannot be chosen from the * keyboard either. */ import { upTo } from './breakpoints.js' // CLDR's first day of the week by region, for an engine without `Intl.Locale#getWeekInfo`. const WEEK_STARTS_SUNDAY = 'AG AS BD BR BS BT BW BZ CA CN CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW'.split(' ') const WEEK_STARTS_SATURDAY = 'AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY'.split(' ') const YEARS_FROM = 1900 const YEARS_TO = 2100 /** * The full-screen range picker scrolls through months rather than stepping between them, so it * draws a window of them: this many either side of the month it opens on, grown by the same number * whenever the scroll or the keyboard reaches an end. Compose's own list is lazy; this one is not, * so the window stays small enough for the DOM it costs. */ const MONTH_WINDOW = 6 /** How near an end of the month list a scroll has to come before the window grows. */ const MONTH_EDGE = 400 function pad(number, length = 2) { return String(number).padStart(length, '0') } /** A UTC timestamp for a calendar day; `setUTCFullYear` so years below 100 are not read as 19xx. */ function utc(year, month, day) { const date = new Date(0) date.setUTCFullYear(year, month - 1, day) return date } function iso(date) { return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` } /** The ISO string, or null when it is not a real calendar day. */ function valid(value) { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typeof value === 'string' ? value : '') if (!match) { return null } const date = utc(+match[1], +match[2], +match[3]) return date.getUTCMonth() + 1 === +match[2] && date.getUTCDate() === +match[3] ? match[0] : null } function parts(value) { return value.split('-').map(Number) } function addDays(value, days) { const [year, month, day] = parts(value) return iso(utc(year, month, day + days)) } /** The same day in another month, or that month's last day when it is shorter. */ function addMonths(value, months) { const [year, month, day] = parts(value) const first = utc(year, month + months, 1) const length = utc(first.getUTCFullYear(), first.getUTCMonth() + 2, 0).getUTCDate() return iso(utc(first.getUTCFullYear(), first.getUTCMonth() + 1, Math.min(day, length))) } function localToday() { const now = new Date() return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` } /** 0 for Sunday … 6 for Saturday: `weekStart` when it is one of those, otherwise the locale's. */ function firstDayOfWeek(locale, weekStart = null) { if (Number.isInteger(weekStart) && weekStart >= 0 && weekStart <= 6) { return weekStart } try { const tag = new Intl.Locale(locale) const info = typeof tag.getWeekInfo === 'function' ? tag.getWeekInfo() : tag.weekInfo if (info?.firstDay) { return info.firstDay % 7 } const region = tag.maximize().region if (WEEK_STARTS_SUNDAY.includes(region)) { return 0 } if (WEEK_STARTS_SATURDAY.includes(region)) { return 6 } } catch { // An unknown tag falls through to Monday, ISO 8601's first day. } return 1 } /** * The typed format: the locale's short numeric date, reduced to `dd`, `MM` and `yyyy` and one * delimiter — androidx's `datePatternAsInputFormat`, fed from `formatToParts` because `Intl` has * no pattern to give. `de` gives `dd.MM.yyyy`, `en-US` `MM/dd/yyyy`, `ja` `yyyy/MM/dd`. A `chosen` * pattern of the same shape (each unit once, one delimiter) replaces the locale's. */ function inputFormat(locale, chosen = null) { const units = /^(dd|MM|yyyy)([/\-.])(dd|MM|yyyy)\2(dd|MM|yyyy)$/.exec(typeof chosen === 'string' ? chosen : '') if (units && new Set([units[1], units[3], units[4]]).size === 3) { return formatOf(chosen) } let pattern = '' try { pattern = new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'UTC' }) .formatToParts(utc(2026, 11, 22)) .map((part) => ({ year: 'y', month: 'M', day: 'd', literal: part.value })[part.type] ?? '') .join('') .replace(/[^dMy/\-.]/g, '') .replace(/d{1,2}/g, 'dd') .replace(/M{1,2}/g, 'MM') .replace(/y{1,4}/g, 'yyyy') .replace('My', 'M/y') .replace(/\.$/, '') } catch { pattern = '' } return formatOf(/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern) ? pattern : 'yyyy-MM-dd') } /** A pattern, the placeholder it shows (`DD.MM.YYYY`) and the order its units are typed in (`dMy`). */ function formatOf(pattern) { return { pattern, placeholder: pattern.toUpperCase(), order: pattern.replace(/[^dMy]/g, '').replace('dd', 'd').replace('MM', 'M').replace('yyyy', 'y'), } } function normaliseRange(value) { return { start: valid(value?.start), end: valid(value?.end) } } document.addEventListener('alpine:init', () => { window.Alpine.data('materialDatepicker', (config) => ({ value: config.value, open: false, presentation: 'docked', typing: false, view: 'days', draft: null, focused: null, shown: null, text: '', fieldError: '', entry: '', entryEnd: '', entryError: '', today: localToday(), compact: false, // The first and last month the full-screen range picker draws, as `yyyy-MM-01`. monthsFrom: null, monthsTo: null, refocus: true, // Set in init() from the config. Declared here, or Alpine writes them to the outermost // x-data scope, where every picker inside the same page scope would share the last one's. firstDay: 0, format: null, numbers: null, formats: {}, min: null, max: null, yearsFrom: null, yearsTo: null, init() { const locale = config.locale || document.documentElement.lang || 'en' const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options }) this.firstDay = firstDayOfWeek(locale, config.weekStart ?? null) this.format = inputFormat(locale, config.format ?? null) this.numbers = new Intl.NumberFormat(locale, { useGrouping: false }) this.formats = { monthYear: format({ year: 'numeric', month: 'long' }), month: format({ month: 'short' }), monthLong: format({ month: 'long' }), year: format({ year: 'numeric' }), headline: format({ year: 'numeric', month: 'short', day: 'numeric' }), long: format({ weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }), weekdayNarrow: format({ weekday: 'narrow' }), weekdayLong: format({ weekday: 'long' }), } this.min = valid(config.min) this.max = valid(config.max) // androidx's 1900–2100, narrowed to the years `min` and `max` leave open. this.yearsFrom = this.min ? parts(this.min)[0] : Math.min(YEARS_FROM, this.max ? parts(this.max)[0] : YEARS_FROM) this.yearsTo = this.max ? parts(this.max)[0] : Math.max(YEARS_TO, this.yearsFrom) this.text = this.display(this.current()) this.moveTo(this.start() ?? this.today, false) this.$watch('value', () => { if (document.activeElement !== this.$refs.input) { this.text = this.display(this.current()) this.fieldError = '' } }) const query = upTo('medium') this.compact = query.matches query.addEventListener('change', (event) => (this.compact = event.matches)) }, // ---- Values -------------------------------------------------------------------------- /** The bound value, cleaned: an ISO string or null, or `{ start, end }` for a range. */ current() { return config.range ? normaliseRange(this.value) : valid(this.value) }, /** Writes only a change, so leaving a field untouched sends Livewire nothing. */ write(value) { const next = config.range ? { start: value?.start ?? null, end: value?.end ?? null } : (value ?? null) if (JSON.stringify(next) !== JSON.stringify(this.current())) { this.value = next } }, /** A date as the field shows and takes it: `13.09.2026`. */ typed(value) { if (!value) { return '' } const [year, month, day] = parts(value) return this.format.pattern.replace('yyyy', pad(year, 4)).replace('MM', pad(month)).replace('dd', pad(day)) }, display(value) { if (!config.range) { return this.typed(value) } if (!value.start && !value.end) { return '' } return `${this.typed(value.start)} – ${this.typed(value.end)}` }, /** * Reads typed digits in the locale's order: `13.9.2026`, `13/09/2026` and `13092026` are the * same day in `de`. Returns an ISO string, or null for text that is not a date. */ parse(text) { const groups = String(text ?? '').match(/\d+/g) ?? [] let values = groups if (groups.length === 1 && groups[0].length === 8) { let offset = 0 values = [...this.format.order].map((unit) => { const length = unit === 'y' ? 4 : 2 const piece = groups[0].slice(offset, offset + length) offset += length return piece }) } if (values.length !== 3) { return null } const units = Object.fromEntries([...this.format.order].map((unit, index) => [unit, values[index]])) if (units.y.length !== 4 || units.M.length > 2 || units.d.length > 2) { return null } return valid(`${units.y}-${pad(+units.M)}-${pad(+units.d)}`) }, parseRange(text) { const groups = String(text ?? '').match(/\d+/g) ?? [] if (groups.length === 2 && groups.every((group) => group.length === 8)) { return { start: this.parse(groups[0]), end: this.parse(groups[1]) } } if (groups.length === 6) { return { start: this.parse(groups.slice(0, 3).join(' ')), end: this.parse(groups.slice(3).join(' ')) } } return null }, /** androidx's DateInputValidator: the pattern, then min and max, then the year range. */ problem(value) { if (!value) { return config.strings.pattern.replace(':pattern', this.format.placeholder) } if (this.disabled(value)) { return config.strings.notAllowed.replace(':date', this.formats.headline.format(utc(...parts(value)))) } const year = parts(value)[0] if (year < this.yearsFrom || year > this.yearsTo) { return config.strings.yearRange.replace(':start', this.numbers.format(this.yearsFrom)).replace(':end', this.numbers.format(this.yearsTo)) } return '' }, disabled(value) { return (this.min !== null && value < this.min) || (this.max !== null && value > this.max) }, clamp(value) { if (this.min !== null && value < this.min) { return this.min } return this.max !== null && value > this.max ? this.max : value }, // ---- The field ---------------------------------------------------------------------- /** Typing into the docked field: a complete, allowed date is written at once. */ typeInField() { const text = this.text.trim() const value = config.range ? this.parseRange(text) : this.parse(text) const complete = config.range ? value?.start && value?.end : value if (!complete) { return } const problem = this.fieldProblem(value) if (problem === '') { this.fieldError = '' this.write(value) this.follow(config.range ? value.start : value) } }, /** Leaving the field, or Enter: the text is the value, or it says what is wrong with it. */ commitField() { const text = this.text.trim() if (text === '') { this.fieldError = '' if (config.range ? this.current().start || this.current().end : this.current()) { this.write(null) } return } const value = config.range ? this.parseRange(text) : this.parse(text) const problem = this.fieldProblem(value) this.fieldError = problem if (problem === '') { this.write(value) this.text = this.display(config.range ? normaliseRange(value) : value) } }, /** The clear button: no date (no start and no end), closed, and focus back in the field. */ clear() { if (this.open) { this.cancel(false) } this.fieldError = '' this.text = '' this.write(null) this.$refs.input.focus() }, fieldProblem(value) { if (!config.range) { return this.problem(value) } if (!value) { return config.strings.pattern.replace(':pattern', `${this.format.placeholder} – ${this.format.placeholder}`) } return this.problem(value.start) || this.problem(value.end) || (value.start > value.end ? config.strings.invalidRange : '') }, /** An open calendar follows a date typed into the field. */ follow(value) { if (this.open && value) { this.draft = this.current() this.moveTo(value, false) } }, // ---- Opening and closing -------------------------------------------------------------- show(focusInside = true) { if (this.open || config.disabled || config.readonly) { return } const value = this.current() this.today = localToday() // M3 gives a range picker its own full-screen dialog at compact; everything else is the // docked popover at medium and up, and the modal dialog otherwise. this.presentation = config.range && this.compact ? 'full' : config.mode === 'docked' && !this.compact ? 'docked' : 'modal' this.typing = config.mode === 'input' this.view = 'days' this.draft = value this.monthsFrom = null this.monthsTo = null this.moveTo(this.start() ?? this.today, false) if (this.presentation === 'full') { this.monthsFrom = this.clampMonth(addMonths(this.shown, -MONTH_WINDOW)) this.monthsTo = this.clampMonth(addMonths(this.shown, MONTH_WINDOW)) } this.entryError = '' this.fillEntry() this.refocus = true this.open = true const dialog = this.$refs.dialog if (this.presentation === 'docked') { dialog.showPopover() } else { dialog.showModal() } if (focusInside || this.presentation === 'modal') { this.settled(() => this.focusInside()) } }, /** * Runs a step once what it needs is on screen. `x-show` reveals an element a frame after the * state changes (a timeout in a hidden tab), and a hidden element cannot take focus. */ settled(callback) { this.$nextTick(() => (document.visibilityState === 'visible' ? requestAnimationFrame : setTimeout)(callback)) }, focusInside() { if (this.typing) { this.$refs.entry?.focus() } else { this.focusDay() } }, /** Closes without keeping what was picked. */ cancel(refocus = true) { if (!this.open) { return } this.refocus = refocus this.close() }, close() { const dialog = this.$refs.dialog this.open = false this.view = 'days' if (dialog.matches(':popover-open')) { dialog.hidePopover() } if (dialog.open) { dialog.close() } if (this.refocus) { this.$refs.input.focus() } }, /** OK: the picked date (or typed one) becomes the value. */ confirm() { if (this.typing && !this.takeEntry()) { return } this.write(this.draft) this.text = this.display(this.current()) this.fieldError = '' this.refocus = true this.close() }, /** A press outside a docked picker, or focus leaving it, puts it away. */ leave(event) { if (this.open && this.presentation === 'docked' && event.relatedTarget && !this.$root.contains(event.relatedTarget)) { this.cancel(false) } }, // ---- Picking -------------------------------------------------------------------------- pick(value) { if (!value || this.disabled(value)) { return } this.focused = value this.shown = value.slice(0, 8) + '01' if (!config.range) { this.draft = value return } const { start, end } = this.draft ?? { start: null, end: null } this.draft = start && !end && value >= start ? { start, end: value } : { start: value, end: null } }, complete() { return config.range ? Boolean(this.draft?.start && this.draft?.end) : Boolean(this.draft) }, choose(cell) { if (cell.blank || cell.disabled) { return } this.pick(cell.value) this.focusDay() }, focusDay() { this.settled(() => this.$refs.dialog.querySelector('[data-md-datepicker-day][tabindex="0"]')?.focus()) }, /** Focuses a day (inside min and max) and shows its month. */ moveTo(value, focus = true) { this.focused = this.clamp(value) this.shown = this.focused.slice(0, 8) + '01' this.coverMonth(this.shown) if (focus) { this.focusDay() } }, // ---- The full-screen range picker's list of months -------------------------------------- /** A month inside the years the picker allows, and inside `min` and `max`. */ clampMonth(month) { const floor = this.min ? this.min.slice(0, 8) + '01' : `${pad(this.yearsFrom, 4)}-01-01` const ceiling = this.max ? this.max.slice(0, 8) + '01' : `${pad(this.yearsTo, 4)}-12-01` return month < floor ? floor : month > ceiling ? ceiling : month }, /** Keeps a month the keyboard walked to inside the window the list draws. */ coverMonth(month) { if (this.monthsFrom === null) { return } if (month < this.monthsFrom) { this.monthsFrom = this.clampMonth(month) } else if (month > this.monthsTo) { this.monthsTo = this.clampMonth(month) } }, /** * Grows the window when the scroll comes near either end. Adding months above moves * everything down, so the scroll is put back by however much the list grew. */ extendMonths(event) { if (this.presentation !== 'full' || this.monthsFrom === null) { return } const list = event.currentTarget if (list.scrollTop < MONTH_EDGE) { const from = this.clampMonth(addMonths(this.monthsFrom, -MONTH_WINDOW)) if (from !== this.monthsFrom) { const before = list.scrollHeight this.monthsFrom = from this.$nextTick(() => (list.scrollTop += list.scrollHeight - before)) } } else if (list.scrollHeight - list.scrollTop - list.clientHeight < MONTH_EDGE) { this.monthsTo = this.clampMonth(addMonths(this.monthsTo, MONTH_WINDOW)) } }, /** The single date, or a range's start. */ start() { const value = this.current() return config.range ? value.start : value }, /** * The month or the year dropdown, whichever of them this presentation draws: the docked * picker has one of each, the modal picker a single button that opens the years. */ menuButton(view) { const buttons = [...this.$refs.dialog.querySelectorAll('[data-md-datepicker-menu-button]')].filter((button) => button.getClientRects().length > 0) return buttons.find((button) => button.dataset.mdDatepickerMenuButton === view) ?? buttons[0] ?? null }, gridKey(event) { const rtl = getComputedStyle(this.$refs.dialog).direction === 'rtl' const focused = this.focused const column = (utc(...parts(focused)).getUTCDay() - this.firstDay + 7) % 7 // M3's keyboard table: Shift+M moves to the month dropdown, Shift+Y to the year one. if (event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && ['M', 'Y'].includes(event.key.toUpperCase())) { const button = this.menuButton(event.key.toUpperCase() === 'M' ? 'months' : 'years') if (button) { event.preventDefault() button.focus() } return } const target = { ArrowRight: () => addDays(focused, rtl ? -1 : 1), ArrowLeft: () => addDays(focused, rtl ? 1 : -1), ArrowDown: () => addDays(focused, 7), ArrowUp: () => addDays(focused, -7), Home: () => addDays(focused, -column), End: () => addDays(focused, 6 - column), PageUp: () => addMonths(focused, event.shiftKey ? -12 : -1), PageDown: () => addMonths(focused, event.shiftKey ? 12 : 1), }[event.key] if (target) { event.preventDefault() this.moveTo(target()) return } if (event.key === ' ' || event.key === 'Enter') { event.preventDefault() this.pick(focused) if (event.key === 'Enter' && this.complete()) { this.confirm() } } }, /** The previous or next month (or year) buttons; focus stays on the button. */ step(months) { if (!this.canStep(months)) { return } this.moveTo(addMonths(this.focused, months), false) }, monthAllowed(year, month) { const first = `${pad(year, 4)}-${pad(month)}-01` const last = iso(utc(year, month + 1, 0)) return year >= this.yearsFrom && year <= this.yearsTo && !(this.max !== null && first > this.max) && !(this.min !== null && last < this.min) }, canStep(months) { const [year, month] = parts(addMonths(this.shown, months)) return this.monthAllowed(year, month) }, // ---- Years and months --------------------------------------------------------------- /** Opens the year grid (or a docked picker's month or year list), or goes back to the days. */ toggleView(view) { this.view = this.view === view ? 'days' : view if (this.view === 'days') { this.focusDay() return } this.settled(() => { const list = [...this.$refs.dialog.querySelectorAll('[data-md-datepicker-list]')].find((each) => each.getClientRects().length > 0) const option = list?.querySelector('[aria-selected="true"]') ?? list?.querySelector('[role="option"]') option?.scrollIntoView({ block: 'center' }) option?.focus() }) }, /** The focused day moves to the chosen year and month, kept inside min and max. */ showMonthOf(year, month) { const [focusedYear, focusedMonth] = parts(this.focused) this.moveTo(addMonths(this.focused, (year - focusedYear) * 12 + month - focusedMonth)) this.view = 'days' }, /** Arrows, Home and End in a year or month list; the options are buttons, so Enter and Space are the browser's. */ listKey(event, columns = 1) { const options = [...event.currentTarget.querySelectorAll('[role="option"]')] const index = options.indexOf(document.activeElement) const move = { ArrowDown: columns, ArrowUp: -columns, ArrowRight: columns > 1 ? 1 : 0, ArrowLeft: columns > 1 ? -1 : 0, }[event.key] let next = null if (move) { next = options[Math.min(options.length - 1, Math.max(0, index + move))] } else if (event.key === 'Home') { next = options[0] } else if (event.key === 'End') { next = options.at(-1) } if (next) { event.preventDefault() next.focus() next.scrollIntoView({ block: 'nearest' }) } }, // ---- Text entry in the dialog -------------------------------------------------------- fillEntry() { if (config.range) { this.entry = this.typed(this.draft?.start) this.entryEnd = this.typed(this.draft?.end) } else { this.entry = this.typed(this.draft) } }, /** As it is typed, a whole and allowed date becomes the draft, so the headline follows it. */ typeEntry() { const read = (text) => { const value = this.parse(text) return value && this.problem(value) === '' ? value : null } this.entryError = '' this.draft = config.range ? { start: read(this.entry), end: read(this.entryEnd) } : read(this.entry) }, /** Reads the dialog's text fields into the draft; false, with the reason shown, when it cannot. */ takeEntry() { if (!config.range) { if (this.entry.trim() === '') { this.draft = null this.entryError = '' return true } const value = this.parse(this.entry) this.entryError = this.problem(value) if (this.entryError !== '') { return false } this.draft = value return true } const start = this.entry.trim() === '' ? null : this.parse(this.entry) const end = this.entryEnd.trim() === '' ? null : this.parse(this.entryEnd) this.entryError = (this.entry.trim() !== '' ? this.problem(start) : '') || (this.entryEnd.trim() !== '' ? this.problem(end) : '') || (start && end && start > end ? config.strings.invalidRange : '') if (this.entryError !== '') { return false } this.draft = { start, end } return true }, toggleTyping() { if (this.typing) { if (!this.takeEntry()) { return } const start = config.range ? this.draft.start : this.draft this.moveTo(start ?? this.focused, false) } else { this.fillEntry() this.entryError = '' } this.typing = !this.typing this.view = 'days' this.settled(() => this.focusInside()) }, // ---- What the template draws --------------------------------------------------------- get weekdays() { return Array.from({ length: 7 }, (_, index) => { // 1 January 2023 was a Sunday. const date = utc(2023, 1, 1 + ((this.firstDay + index) % 7)) return { narrow: this.formats.weekdayNarrow.format(date), long: this.formats.weekdayLong.format(date) } }) }, /** Six weeks of cells for one month, `yyyy-MM-01`. */ weeksOf(shownMonth) { const [year, month] = parts(shownMonth) const first = utc(year, month, 1) const offset = (first.getUTCDay() - this.firstDay + 7) % 7 const range = config.range ? (this.draft ?? { start: null, end: null }) : null const outside = this.presentation === 'docked' return Array.from({ length: 6 }, (_, row) => Array.from({ length: 7 }, (_, column) => { const date = utc(year, month, 1 - offset + row * 7 + column) const value = iso(date) const inMonth = date.getUTCMonth() + 1 === month const start = range ? value === range.start : value === this.draft const end = range ? value === range.end : false return { value, blank: !inMonth && !outside, outside: !inMonth, label: this.numbers.format(date.getUTCDate()), name: this.formats.long.format(date), disabled: this.disabled(value), today: value === this.today, selected: start || end, start: range !== null && start && Boolean(range.end) && range.end !== range.start, end: range !== null && end && range.end !== range.start, between: range !== null && Boolean(range.start && range.end) && value > range.start && value < range.end, focused: inMonth && value === this.focused, } })) }, /** * The rows the grid draws: this month's six weeks, or — full screen — every month of the * window, each headed by its name, which is M3's vertically scrolling range picker. A * month's empty trailing weeks are dropped there, so one month follows the next. */ get rows() { const weeks = (month) => this.weeksOf(month).map((cells, row) => ({ key: `${month}-${row}`, cells })) if (this.presentation !== 'full' || this.monthsFrom === null) { return weeks(this.shown) } const rows = [] for (let month = this.monthsFrom; month <= this.monthsTo; month = addMonths(month, 1)) { rows.push({ key: `label-${month}`, label: this.formats.monthYear.format(utc(...parts(month))) }) rows.push(...weeks(month).filter((row) => row.cells.some((cell) => !cell.blank))) } return rows }, get years() { const shown = parts(this.shown)[0] const current = parts(this.today)[0] return Array.from({ length: this.yearsTo - this.yearsFrom + 1 }, (_, index) => { const year = this.yearsFrom + index return { value: year, label: this.numbers.format(year), selected: year === shown, current: year === current } }) }, get months() { const [year, shown] = parts(this.shown) return Array.from({ length: 12 }, (_, index) => ({ value: index + 1, label: this.formats.monthLong.format(utc(2023, index + 1, 1)), selected: index + 1 === shown, disabled: !this.monthAllowed(year, index + 1), })) }, get monthYear() { return this.shown ? this.formats.monthYear.format(utc(...parts(this.shown))) : '' }, get monthLabel() { return this.shown ? this.formats.month.format(utc(...parts(this.shown))) : '' }, get yearLabel() { return this.shown ? this.formats.year.format(utc(...parts(this.shown))) : '' }, /** M3's headline: the chosen date, or what the picker is for until there is one. */ get headline() { const date = (value) => (value ? this.formats.headline.format(utc(...parts(value))) : null) if (config.range) { return `${date(this.draft?.start) ?? config.strings.start} – ${date(this.draft?.end) ?? config.strings.end}` } return date(this.draft) ?? (this.typing ? config.strings.entered : config.strings.selected) }, get placeholder() { return config.range ? `${this.format.placeholder} – ${this.format.placeholder}` : this.format.placeholder }, get serialised() { return config.range ? this.current() : this.current() ?? '' }, })) })