From 2662040c246a3405b0a5b0c9ff0f5b96fc3f5fee Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:36:49 +0200 Subject: [PATCH] Open a range picker full screen on a compact window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3 § Date Pickers, Anatomy describes a 14-element full-screen range picker for compact breakpoints; `` 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 Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 +- resources/css/components/datepicker.css | 101 ++++++++++++++-- resources/js/datepicker.js | 113 ++++++++++++++++-- .../views/components/datepicker.blade.php | 43 ++++++- .../views/showcase/sections/pickers.blade.php | 3 +- tests/Feature/Components/DatepickerTest.php | 35 ++++++ 6 files changed, 277 insertions(+), 20 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 71e2bd06..8b9d8099 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -622,7 +622,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` | Prop | Default | | |---|---|---| | `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog on a compact window (below `medium`), as M3 asks; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing | -| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field | +| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field. On a compact window (below `medium`) it opens as M3's full-screen range picker: an app bar with a close button and **Save**, the range as the headline, and the months in one scrolling list instead of stepped one at a time | | `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside | | `label`, `hint`, `icon`, `variant`, `size` | | the field's | | `value` | `null` | the initial value without `wire:model` | diff --git a/resources/css/components/datepicker.css b/resources/css/components/datepicker.css index f55d5c8d..1d10d92e 100644 --- a/resources/css/components/datepicker.css +++ b/resources/css/components/datepicker.css @@ -1,10 +1,12 @@ /* * M3's date pickers — docked, modal and modal input (resources/views/components/datepicker.blade.php). * - * One `` wears both: docked it is a popover hung under the field by CSS anchor positioning - * (large corner), modal it is opened with `showModal()` over a scrim (extra-large corner, with M3's - * header: a title, the chosen date as the headline, and the switch between calendar and typing). - * Both are 360px of surface-container-high at elevation 3. + * One `` wears all three: docked it is a popover hung under the field by CSS anchor + * positioning (large corner), modal it is opened with `showModal()` over a scrim (extra-large + * corner, with M3's header: a title, the chosen date as the headline, and the switch between + * calendar and typing), and full — a `range` picker on a compact window — is that modal dialog + * grown to the whole screen, M3's full-screen range picker. The first two are 360px of + * surface-container-high at elevation 3; the third keeps the colour and drops the corner. * * Values from DatePickerModalTokens and DateInputModalTokens (androidx Compose Material 3, commit * 27cf9a7d5788aa0f5f2d8b6699ce279560daf326) and the layout of DatePicker.kt, DateRangePicker.kt and @@ -13,15 +15,19 @@ * * [data-datepicker] the root: the field, its support line, the dialog * [data-datepicker-support] the hint, a server error, or what is wrong with the typed date - * [data-datepicker-picker] the ; data-presentation="docked" | "modal" + * [data-datepicker-picker] the ; data-presentation="docked" | "modal" | "full" * [data-datepicker-surface] * [data-datepicker-header] modal only: title, headline, switch - * [data-datepicker-calendar] + * [data-datepicker-full] full only: [data-datepicker-app-bar] (close, switch, Save) + * and [data-datepicker-full-headline] (title, headline) + * [data-datepicker-calendar] the scroller, full screen * [data-datepicker-nav] month and year: one menu button and arrows (modal), * or a month and a year stepper (docked, data-docked) * [data-datepicker-grid] the weekdays and six weeks of [data-datepicker-day] cells: * data-today, data-selected, data-start, data-end, - * data-between, data-outside, data-blank, aria-disabled + * data-between, data-outside, data-blank, aria-disabled — + * full screen, every month of the window under a + * [data-datepicker-month-label] row * [data-datepicker-years] the modal year grid * [data-datepicker-menu] a docked month or year list of [data-datepicker-option]s * [data-datepicker-entry] the text field(s) of the modal input @@ -128,6 +134,87 @@ border-radius: var(--md-sys-shape-corner-xl); } + /* ---- The full-screen range picker (compact) ------------------------------------------------- */ + + /* M3 § Date Pickers: a range picker at a compact breakpoint is a full-screen dialog, "covering + * the whole screen for readability and touch-target size", with a close (×) icon button and a + * Save confirmation instead of Cancel and OK. Its anatomy is the modal picker's parts in a + * different order: an app bar, the supporting text and the range as the headline, a divider, the + * day-of-week labels, and a vertically scrolling list of months under them, each with its label. + * Only the months scroll; everything above them stays where it is. */ + [data-datepicker-picker][data-presentation="full"] { + inset: 0; + width: 100vw; + max-width: none; + height: 100dvh; + max-height: none; + margin: 0; + } + + [data-datepicker-picker][data-presentation="full"] [data-datepicker-surface] { + height: 100%; + max-height: none; + padding-block: var(--material-safe-top, env(safe-area-inset-top)) var(--material-safe-bottom, env(safe-area-inset-bottom)); + overflow: hidden; + border-radius: 0; + box-shadow: none; + } + + [data-datepicker-full] { + flex: none; + border-bottom: 1px solid var(--md-sys-color-outline-variant); + color: var(--md-sys-color-on-surface-variant); + } + + [data-datepicker-app-bar] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + height: 4rem; + padding-inline: 0.25rem 0.75rem; + } + + [data-datepicker-app-bar-actions] { + display: flex; + align-items: center; + gap: 0.25rem; + } + + [data-datepicker-full-headline] { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0 1.5rem 1rem; + } + + /* The calendar is the only part that scrolls, with the weekday header held at its top. */ + [data-datepicker-picker][data-presentation="full"] [data-datepicker-calendar] { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + } + + [data-datepicker-picker][data-presentation="full"] [data-datepicker-grid] thead th { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--md-sys-color-surface-container-high); + } + + [data-datepicker-picker][data-presentation="full"] [data-datepicker-day] { + scroll-margin-block: 3rem; + } + + [data-datepicker-month-label] { + padding: 1.25rem 0 0.5rem; + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-title-sm); + letter-spacing: var(--md-sys-typescale-title-sm-tracking); + text-align: start; + } + /* ---- The modal header ---------------------------------------------------------------------- */ [data-datepicker-header] { diff --git a/resources/js/datepicker.js b/resources/js/datepicker.js index cc439c99..1ed26d78 100644 --- a/resources/js/datepicker.js +++ b/resources/js/datepicker.js @@ -2,12 +2,15 @@ * `materialDatepicker`: the behaviour of `` — M3's docked, modal and modal-input * date pickers on one ``. * - * 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] diff --git a/resources/views/components/datepicker.blade.php b/resources/views/components/datepicker.blade.php index 033fec61..b869641d 100644 --- a/resources/views/components/datepicker.blade.php +++ b/resources/views/components/datepicker.blade.php @@ -10,6 +10,11 @@ calendar in a dialog, with a pencil to switch to typing. - `input`: the same dialog, opened on its text field, with a calendar icon to switch back. + A `range` picker on a compact window (below `medium`) is M3's full-screen range picker instead + of the 360px dialog: an app bar with a close button and **Save**, the range as the headline + under the supporting text, and the months in one vertically scrolling list rather than stepped + one at a time. Nothing changes for a single date, or for a docked picker at `medium` and up. + The calendar and the dialog's text field pick a draft; OK (or Enter on a day) makes it the value, Cancel or Escape leaves the value alone and returns focus to the field. What is typed into the docked field is the value as soon as it is a whole, allowed date. @@ -265,6 +270,30 @@ data-datepicker-picker >
+ {{-- M3's full-screen range picker (compact): an app bar with a close button and Save, + then the supporting text and the range as the headline, over a divider. --}} +
+
+ + + + + + + + + + + + +
+ +
+

{{ $title }}

+

+
+
+

{{ $title }}

@@ -280,7 +309,7 @@
-
+
@@ -350,9 +379,14 @@ -