Files
livewire-material/resources/js/datepicker.js
T
Andreas Reinhold / reiniandClaude Opus 5 f4be9848e2
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m10s
tests / feature (8.5) (push) Successful in 1m7s
tests / browser (firefox, firefox) (push) Failing after 8m28s
tests / browser (chrome, chromium) (push) Failing after 5m58s
tests / browser (safari, webkit) (push) Failing after 7m24s
Add the M3 date pickers
Docked, modal and modal input date pickers for single dates and ranges,
with locale month names, week start and typed format from Intl, min and
max, and the APG grid keyboard. Completes Phase 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:48:56 +02:00

831 lines
28 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 two 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) it is opened with `showModal()`. 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).
*
* 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. Focus never leaves
* the `min``max` span, so a disabled day cannot be chosen from the keyboard either.
*/
const COMPACT = '(max-width: 39.99rem)'
// 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
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. */
function firstDayOfWeek(locale) {
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`.
*/
function inputFormat(locale) {
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 = ''
}
if (!/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern)) {
pattern = 'yyyy-MM-dd'
}
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,
refocus: true,
init() {
const locale = config.locale || document.documentElement.lang || 'en'
const format = (options) => new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options })
this.firstDay = firstDayOfWeek(locale)
this.format = inputFormat(locale)
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 = window.matchMedia(COMPACT)
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)
}
},
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()
this.presentation = config.mode === 'docked' && !this.compact ? 'docked' : 'modal'
this.typing = config.mode === 'input'
this.view = 'days'
this.draft = value
this.moveTo(this.start() ?? this.today, false)
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-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'
if (focus) {
this.focusDay()
}
},
/** The single date, or a range's start. */
start() {
const value = this.current()
return config.range ? value.start : value
},
gridKey(event) {
const rtl = getComputedStyle(this.$refs.dialog).direction === 'rtl'
const focused = this.focused
const column = (utc(...parts(focused)).getUTCDay() - this.firstDay + 7) % 7
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-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) }
})
},
get weeks() {
const [year, month] = parts(this.shown)
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,
}
}))
},
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() ?? ''
},
}))
})