Files
livewire-material/resources/js/datepicker.js
T
Andreas Reinhold / reiniandClaude Opus 5 72d9dbfc89 Draw the datepicker without Tailwind
Plan step 36 (inputs group): <x-datepicker> renders data-md-datepicker
with every part data-md-datepicker-* (data-md-presentation replaces
data-presentation; data-md-range, data-md-docked, data-md-concealed and
data-md-value replace their unprefixed hooks), and datepicker.css moves
into material.components on the same px, spacing and colour tokens,
importing field.css, icon.css and button.css for what the view renders.
The month's live-announced text drops its `sr-only` class for a
data-md-datepicker-month-live hook with the same visually-hidden clip
(a package view writes no class but the interaction classes), matching
field's and search's counter/status live regions. The day, year, menu
button and list option keep their own hand-rolled state layer and focus
ring rather than the foundation's classes, as data-md-field-button
already does: each is a bigger interactive box than the round indicator
drawn inside it, which the foundation's fixed-geometry classes can't
draw, and `:focus-visible` only ever matches the actually-focused
element. Cancel, OK and the icon buttons are <x-button>, which already
draws from the classes. datepicker.js follows the renamed hooks and
dataset properties.

Shift+M/Y (reaching the month/year dropdowns) and the full-screen range
picker at compact were already implemented; this commit adds the browser
tests the plan owed for them plus the month list's keyboard-driven growth
in both directions and Save/close from the full-screen app bar
(docs/plans/material-3-browser-tests.md), written but not run — the
inputs group's Chromium run follows this stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:12:58 +02:00

992 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `materialDatepicker`: the behaviour of `<x-datepicker>` — M3's docked, modal and modal-input
* date pickers on one `<dialog>`.
*
* 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 19002100, 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() ?? ''
},
}))
})