An application that lets each person choose when their week starts and how dates are written needs the picker to follow that choice rather than the locale. <x-datepicker> takes week-start, 0 (Sunday) to 6 (Saturday), and format, dd, MM and yyyy each once around one delimiter (dd.MM.yyyy, dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd). They replace what Intl derives for firstDayOfWeek() and inputFormat(), so the field, the server-rendered value, the typed-date reader (year first too), the calendar's columns and weekday header, Home and End, the dialog's text fields and ranges all follow them. Month and weekday names stay the locale's, and wire:model still stores Y-m-d. Values that are neither are ignored, and without the props nothing changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
857 lines
30 KiB
JavaScript
857 lines
30 KiB
JavaScript
/**
|
||
* `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), 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. 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: `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,
|
||
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, 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 = 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)
|
||
}
|
||
},
|
||
|
||
/** 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()
|
||
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() ?? ''
|
||
},
|
||
}))
|
||
})
|