Plan step 16. `navigation.js` takes its standard-rail threshold from
`from('expanded')` (840px) instead of the 64rem it had, and `navigation.css`'s
three 64rem queries follow, so the whole expanded class gets the in-layout
collapsible rail M3 asks for rather than one that opens over a scrim (N-06);
its two 40rem queries become 37.5rem, the compact/medium boundary the bar's own
container query already used (N-07). `search.js` and `datepicker.js` swap their
39.99rem media strings for `upTo('medium')`, so the full-screen search view and
the modal date picker end at 600px, not 640px.
The time picker's landscape layout stops keying on width at all: M3 swaps it on
orientation and viewport height, so it is now landscape plus a window too short
for the upright dial (35rem, the dialog's own height), and the two dial-shrink
rules key on height alone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
868 lines
30 KiB
JavaScript
868 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 — below `medium`, 600px, where M3 puts the picker in a dialog rather than under the
|
||
* field) 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.
|
||
*/
|
||
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
|
||
|
||
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,
|
||
// 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()
|
||
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() ?? ''
|
||
},
|
||
}))
|
||
})
|