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:
co-authored by
Claude Fable 5.1
parent
790ef93c6a
commit
2662040c24
@@ -622,7 +622,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
|||||||
| Prop | Default | |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
|
||||||
| `value` | `null` | the initial value without `wire:model` |
|
| `value` | `null` | the initial value without `wire:model` |
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/*
|
/*
|
||||||
* M3's date pickers — docked, modal and modal input (resources/views/components/datepicker.blade.php).
|
* M3's date pickers — docked, modal and modal input (resources/views/components/datepicker.blade.php).
|
||||||
*
|
*
|
||||||
* One `<dialog>` wears both: docked it is a popover hung under the field by CSS anchor positioning
|
* One `<dialog>` wears all three: docked it is a popover hung under the field by CSS anchor
|
||||||
* (large corner), modal it is opened with `showModal()` over a scrim (extra-large corner, with M3's
|
* positioning (large corner), modal it is opened with `showModal()` over a scrim (extra-large
|
||||||
* header: a title, the chosen date as the headline, and the switch between calendar and typing).
|
* corner, with M3's header: a title, the chosen date as the headline, and the switch between
|
||||||
* Both are 360px of surface-container-high at elevation 3.
|
* 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
|
* Values from DatePickerModalTokens and DateInputModalTokens (androidx Compose Material 3, commit
|
||||||
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326) and the layout of DatePicker.kt, DateRangePicker.kt and
|
* 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] 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-support] the hint, a server error, or what is wrong with the typed date
|
||||||
* [data-datepicker-picker] the <dialog>; data-presentation="docked" | "modal"
|
* [data-datepicker-picker] the <dialog>; data-presentation="docked" | "modal" | "full"
|
||||||
* [data-datepicker-surface]
|
* [data-datepicker-surface]
|
||||||
* [data-datepicker-header] modal only: title, headline, switch
|
* [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),
|
* [data-datepicker-nav] month and year: one menu button and arrows (modal),
|
||||||
* or a month and a year stepper (docked, data-docked)
|
* or a month and a year stepper (docked, data-docked)
|
||||||
* [data-datepicker-grid] the weekdays and six weeks of [data-datepicker-day] cells:
|
* [data-datepicker-grid] the weekdays and six weeks of [data-datepicker-day] cells:
|
||||||
* data-today, data-selected, data-start, data-end,
|
* 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-years] the modal year grid
|
||||||
* [data-datepicker-menu] a docked month or year list of [data-datepicker-option]s
|
* [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
|
* [data-datepicker-entry] the text field(s) of the modal input
|
||||||
@@ -128,6 +134,87 @@
|
|||||||
border-radius: var(--md-sys-shape-corner-xl);
|
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 ---------------------------------------------------------------------- */
|
/* ---- The modal header ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
[data-datepicker-header] {
|
[data-datepicker-header] {
|
||||||
|
|||||||
+106
-7
@@ -2,12 +2,15 @@
|
|||||||
* `materialDatepicker`: the behaviour of `<x-datepicker>` — M3's docked, modal and modal-input
|
* `materialDatepicker`: the behaviour of `<x-datepicker>` — M3's docked, modal and modal-input
|
||||||
* date pickers on one `<dialog>`.
|
* 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
|
* 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
|
* 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
|
* field) it is opened with `showModal()`; full, a `range` picker on a compact window, is that same
|
||||||
* the state below, and the dialog is `wire:ignore`, so a Livewire render never touches it while
|
* modal dialog grown to the whole screen — M3's full-screen range picker, with an app bar carrying
|
||||||
* it is open.
|
* 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
|
* 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
|
* 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_FROM = 1900
|
||||||
const YEARS_TO = 2100
|
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) {
|
function pad(number, length = 2) {
|
||||||
return String(number).padStart(length, '0')
|
return String(number).padStart(length, '0')
|
||||||
}
|
}
|
||||||
@@ -181,6 +195,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
entryError: '',
|
entryError: '',
|
||||||
today: localToday(),
|
today: localToday(),
|
||||||
compact: false,
|
compact: false,
|
||||||
|
// The first and last month the full-screen range picker draws, as `yyyy-MM-01`.
|
||||||
|
monthsFrom: null,
|
||||||
|
monthsTo: null,
|
||||||
refocus: true,
|
refocus: true,
|
||||||
// Set in init() from the config. Declared here, or Alpine writes them to the outermost
|
// 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.
|
// 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()
|
const value = this.current()
|
||||||
|
|
||||||
this.today = localToday()
|
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.typing = config.mode === 'input'
|
||||||
this.view = 'days'
|
this.view = 'days'
|
||||||
this.draft = value
|
this.draft = value
|
||||||
|
this.monthsFrom = null
|
||||||
|
this.monthsTo = null
|
||||||
this.moveTo(this.start() ?? this.today, false)
|
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.entryError = ''
|
||||||
this.fillEntry()
|
this.fillEntry()
|
||||||
this.refocus = true
|
this.refocus = true
|
||||||
@@ -566,12 +593,61 @@ document.addEventListener('alpine:init', () => {
|
|||||||
moveTo(value, focus = true) {
|
moveTo(value, focus = true) {
|
||||||
this.focused = this.clamp(value)
|
this.focused = this.clamp(value)
|
||||||
this.shown = this.focused.slice(0, 8) + '01'
|
this.shown = this.focused.slice(0, 8) + '01'
|
||||||
|
this.coverMonth(this.shown)
|
||||||
|
|
||||||
if (focus) {
|
if (focus) {
|
||||||
this.focusDay()
|
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. */
|
/** The single date, or a range's start. */
|
||||||
start() {
|
start() {
|
||||||
const value = this.current()
|
const value = this.current()
|
||||||
@@ -805,8 +881,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
get weeks() {
|
/** Six weeks of cells for one month, `yyyy-MM-01`. */
|
||||||
const [year, month] = parts(this.shown)
|
weeksOf(shownMonth) {
|
||||||
|
const [year, month] = parts(shownMonth)
|
||||||
const first = utc(year, month, 1)
|
const first = utc(year, month, 1)
|
||||||
const offset = (first.getUTCDay() - this.firstDay + 7) % 7
|
const offset = (first.getUTCDay() - this.firstDay + 7) % 7
|
||||||
const range = config.range ? (this.draft ?? { start: null, end: null }) : null
|
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() {
|
get years() {
|
||||||
const shown = parts(this.shown)[0]
|
const shown = parts(this.shown)[0]
|
||||||
const current = parts(this.today)[0]
|
const current = parts(this.today)[0]
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
calendar in a dialog, with a pencil to switch to typing.
|
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.
|
- `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
|
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
|
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.
|
into the docked field is the value as soon as it is a whole, allowed date.
|
||||||
@@ -265,6 +270,30 @@
|
|||||||
data-datepicker-picker
|
data-datepicker-picker
|
||||||
>
|
>
|
||||||
<div tabindex="-1" data-datepicker-surface>
|
<div tabindex="-1" data-datepicker-surface>
|
||||||
|
{{-- 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. --}}
|
||||||
|
<div data-datepicker-full x-cloak x-show="presentation === 'full'">
|
||||||
|
<div data-datepicker-app-bar>
|
||||||
|
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="cancel()" data-datepicker-close />
|
||||||
|
|
||||||
|
<span data-datepicker-app-bar-actions>
|
||||||
|
<span x-show="! typing">
|
||||||
|
<x-livewire-material::button icon="edit" :tooltip="__('Switch to text input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
|
||||||
|
</span>
|
||||||
|
<span x-show="typing">
|
||||||
|
<x-livewire-material::button icon="date_range" :tooltip="__('Switch to calendar input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<x-livewire-material::button :label="__('Save')" x-on:click="confirm()" data-datepicker-save />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-datepicker-full-headline>
|
||||||
|
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
|
||||||
|
<p data-datepicker-headline aria-live="polite" x-text="headline"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div data-datepicker-header x-show="presentation === 'modal'" @if ($range) data-range @endif>
|
<div data-datepicker-header x-show="presentation === 'modal'" @if ($range) data-range @endif>
|
||||||
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
|
<p data-datepicker-title x-text="typing ? @js($inputTitle) : @js($title)">{{ $title }}</p>
|
||||||
|
|
||||||
@@ -280,7 +309,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="! typing" data-datepicker-calendar>
|
<div x-show="! typing" x-on:scroll="extendMonths($event)" data-datepicker-calendar>
|
||||||
<span id="{{ $id }}-month" class="sr-only" aria-live="polite" x-text="monthYear"></span>
|
<span id="{{ $id }}-month" class="sr-only" aria-live="polite" x-text="monthYear"></span>
|
||||||
|
|
||||||
<div data-datepicker-nav x-show="presentation === 'modal'">
|
<div data-datepicker-nav x-show="presentation === 'modal'">
|
||||||
@@ -350,9 +379,14 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template x-for="(week, row) in weeks" :key="row">
|
<template x-for="row in rows" :key="row.key">
|
||||||
<tr>
|
<tr>
|
||||||
<template x-for="(cell, column) in week" :key="column">
|
{{-- Full screen: a month's name heads its weeks, M3's month label. --}}
|
||||||
|
<template x-if="row.label">
|
||||||
|
<th colspan="7" scope="colgroup" x-text="row.label" data-datepicker-month-label></th>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-for="(cell, column) in (row.cells ?? [])" :key="column">
|
||||||
<td
|
<td
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
x-bind:tabindex="cell.blank ? null : (cell.focused ? 0 : -1)"
|
x-bind:tabindex="cell.blank ? null : (cell.focused ? 0 : -1)"
|
||||||
@@ -469,7 +503,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-show="view === 'days' || typing || presentation === 'modal'" data-datepicker-actions>
|
{{-- The full-screen picker confirms from its app bar, so it draws no action row. --}}
|
||||||
|
<div x-show="presentation !== 'full' && (view === 'days' || typing || presentation === 'modal')" data-datepicker-actions>
|
||||||
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-datepicker-cancel />
|
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-datepicker-cancel />
|
||||||
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-datepicker-confirm />
|
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-datepicker-confirm />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
'Range' => <<<'BLADE'
|
'Range' => <<<'BLADE'
|
||||||
|
{{-- Narrow the window below 600px: a range picker becomes M3's full-screen one, with a close button, Save, and the months scrolled through. --}}
|
||||||
<div class="grid w-full gap-6 medium:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
|
<div class="grid w-full gap-6 medium:grid-cols-2" x-data="{ trip: { start: '{{ now()->addDays(3)->format('Y-m-d') }}', end: '{{ now()->addDays(9)->format('Y-m-d') }}' }, leave: { start: null, end: null } }">
|
||||||
<div class="grid content-start gap-4">
|
<div class="grid content-start gap-4">
|
||||||
<x-datepicker label="Trip" range x-model="trip" />
|
<x-datepicker label="Trip" range x-model="trip" />
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid content-start gap-4">
|
<div class="grid content-start gap-4">
|
||||||
<x-datepicker label="Leave" range mode="modal" x-model="leave" />
|
<x-datepicker label="Leave" range mode="modal" x-model="leave" hint="Full screen on a compact window" />
|
||||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(leave)"></code></p>
|
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(leave)"></code></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,6 +54,41 @@ it('makes the field a read-only trigger for the modal and modal input pickers',
|
|||||||
->and(datepickerConfig($html)['mode'])->toBe($mode);
|
->and(datepickerConfig($html)['mode'])->toBe($mode);
|
||||||
})->with(['modal', 'input']);
|
})->with(['modal', 'input']);
|
||||||
|
|
||||||
|
it('gives a range picker M3\'s full-screen presentation, with an app bar and Save', function () {
|
||||||
|
$html = (string) $this->blade('<x-datepicker id="trip" label="Trip" range mode="modal" />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('data-datepicker-full')
|
||||||
|
->toContain('x-show="presentation === \'full\'"')
|
||||||
|
->toContain('data-datepicker-app-bar')
|
||||||
|
->toContain('data-datepicker-close')
|
||||||
|
->toContain('data-datepicker-save')
|
||||||
|
->toContain('Save')
|
||||||
|
->toContain('aria-label="Select dates"')
|
||||||
|
// The months come from one list of rows, each headed by its label.
|
||||||
|
->toContain('x-for="row in rows"')
|
||||||
|
->toContain('data-datepicker-month-label')
|
||||||
|
// The action row is the modal's; the full-screen picker confirms from its app bar.
|
||||||
|
->toContain('x-show="presentation !== \'full\' && (view === \'days\' || typing || presentation === \'modal\')"')
|
||||||
|
->toContain('x-on:scroll="extendMonths($event)"');
|
||||||
|
|
||||||
|
expect(datepickerConfig($html))->toMatchArray(['range' => true, 'mode' => 'modal']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the docked and single-date pickers as they were', function () {
|
||||||
|
// Only the presentation is new: a single date never reaches it, whatever the window.
|
||||||
|
expect(file_get_contents(__DIR__.'/../../../resources/js/datepicker.js'))
|
||||||
|
->toContain("this.presentation = config.range && this.compact ? 'full' : config.mode === 'docked' && !this.compact ? 'docked' : 'modal'");
|
||||||
|
|
||||||
|
$html = (string) $this->blade('<x-datepicker id="expires" label="Expires on" />');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('data-datepicker-nav data-docked')
|
||||||
|
->toContain('x-show="presentation === \'docked\'"')
|
||||||
|
->toContain('data-datepicker-menu-button="months"')
|
||||||
|
->toContain('x-show="presentation === \'modal\'"');
|
||||||
|
});
|
||||||
|
|
||||||
it('names each dropdown so Shift+M and Shift+Y can reach it', function () {
|
it('names each dropdown so Shift+M and Shift+Y can reach it', function () {
|
||||||
$html = (string) $this->blade('<x-datepicker label="Birthday" />');
|
$html = (string) $this->blade('<x-datepicker label="Birthday" />');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user