Open a range picker full screen on a compact window

M3 § Date Pickers, Anatomy describes a 14-element full-screen range picker for
compact breakpoints; `<x-datepicker range>` always used the 360px modal dialog,
which on a phone is cramped for a two-month scroll (plan step 24, audit
docs/audits/m3-alignment/inputs.md § Missing). A third presentation, "full",
joins docked and modal: the same dialog grown to the screen, an app bar with a
close button and Save, the supporting text and the range as the headline over a
divider, the weekday labels held at the top, and the months in one vertically
scrolling list, each under its own label. The grid now draws from one `rows`
getter, which is this month's weeks everywhere else, so the docked and
single-date pickers render exactly as before. The list is a window of months
grown by the scroll and by the keyboard, since nothing here is lazy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:36:49 +02:00
co-authored by Claude Fable 5.1
parent 790ef93c6a
commit 2662040c24
6 changed files with 277 additions and 20 deletions
+106 -7
View File
@@ -2,12 +2,15 @@
* `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
* 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()`. 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.
* 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
@@ -33,6 +36,17 @@ const WEEK_STARTS_SATURDAY = 'AE AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY'.spli
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')
}
@@ -181,6 +195,9 @@ document.addEventListener('alpine:init', () => {
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.
@@ -436,11 +453,21 @@ document.addEventListener('alpine:init', () => {
const value = this.current()
this.today = localToday()
this.presentation = config.mode === 'docked' && !this.compact ? 'docked' : 'modal'
// 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
@@ -566,12 +593,61 @@ document.addEventListener('alpine:init', () => {
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()
@@ -805,8 +881,9 @@ document.addEventListener('alpine:init', () => {
})
},
get weeks() {
const [year, month] = parts(this.shown)
/** 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
@@ -836,6 +913,28 @@ document.addEventListener('alpine:init', () => {
}))
},
/**
* 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]