Compare commits
2
Commits
83ed671c4e
...
f4be9848e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4be9848e2 | ||
|
|
b8239dd215 |
@@ -21,6 +21,20 @@ M3 Expressive carousel keylines (resources/js/carousel.js)
|
||||
Ported from androidx Compose Material 3 (carousel/*.kt), as recorded at the top of the file.
|
||||
Copyright The Android Open Source Project. Apache License 2.0.
|
||||
|
||||
M3 date pickers (resources/views/components/datepicker.blade.php, resources/js/datepicker.js,
|
||||
resources/css/components/datepicker.css)
|
||||
Token values and layout from androidx Compose Material 3 (DatePickerModalTokens,
|
||||
DateInputModalTokens, DatePicker.kt, DateRangePicker.kt, DateInput.kt; the input-format
|
||||
derivation ported from datePatternAsInputFormat), commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326,
|
||||
and the docked picker's values from @material/web's md-comp-date-picker-docked tokens (v0_192).
|
||||
Copyright The Android Open Source Project; Copyright Google LLC. Apache License 2.0.
|
||||
|
||||
M3 time picker (resources/js/timepicker.js, resources/css/components/timepicker.css)
|
||||
Behaviour and geometry ported from androidx Compose Material 3 (TimePicker.kt,
|
||||
TimePickerDialog.kt, tokens/TimePickerTokens.kt, tokens/TimeInputTokens.kt), as recorded at
|
||||
the top of resources/js/timepicker.js.
|
||||
Copyright The Android Open Source Project. Apache License 2.0.
|
||||
|
||||
Google Sans Flex (resources/fonts/google-sans-flex)
|
||||
Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt).
|
||||
Subset: Latin and Latin Extended, weight 400–700, roundness 0–100.
|
||||
|
||||
@@ -493,6 +493,24 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
|
||||
app locale; `min`/`max`; single and range; `wire:model` stores `Y-m-d`), `timepicker`
|
||||
(dial and input; 12/24h from locale; stores `H:i`). APG grid keyboard for the calendar.
|
||||
|
||||
**Phase 7 is done (2026-09-13).** Both pickers were built by separate agents in their own
|
||||
worktrees. What changed from the step above:
|
||||
|
||||
- **`<x-datepicker>` is one `<dialog wire:ignore>` for every mode**: docked opens it as a
|
||||
`popover="manual"` under the field (CSS anchor positioning, flipping above), modal and input
|
||||
with `showModal()`, and docked becomes modal below `sm`. Dates are `Y-m-d` strings computed in
|
||||
UTC; only "today" reads the browser's zone. The typed format is androidx's
|
||||
`datePatternAsInputFormat` from `Intl` (`de` → `dd.MM.yyyy`). **A range binds one array**,
|
||||
`['start' => …, 'end' => …]`, not two models: one update, so `after_or_equal:trip.start`
|
||||
validates against the matching end, and all its errors land on one field. Docked values come from
|
||||
material-web's docked tokens, which Compose lacks.
|
||||
- **`<x-timepicker>` follows current Compose, not older Android**: a 24-hour dial puts 00–11 outside
|
||||
and 12–23 inside, and the period selector is two separate toggles. The selector angle is a
|
||||
registered custom property, so one transition turns the line and moves the handle.
|
||||
- **Alpine's `x-show` reveals an element on the next animation frame**: focusing into it from
|
||||
`$nextTick` failed in Firefox and Safari. Wait a frame, or switch views with an attribute and CSS.
|
||||
- **A Livewire public property named `$slot` renders empty** in the component's view.
|
||||
|
||||
### Phase 8 — Navigation
|
||||
|
||||
28. `app-bar` (small, center-aligned, medium flexible, large flexible, search app bar; sticky,
|
||||
|
||||
@@ -463,6 +463,49 @@ M3 Expressive's slider on native `<input type="range">`s (one per handle), so th
|
||||
|
||||
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
|
||||
|
||||
### `<x-datepicker>`
|
||||
|
||||
M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire).
|
||||
|
||||
```blade
|
||||
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
|
||||
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
|
||||
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" />
|
||||
```
|
||||
|
||||
| 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 below `sm`; `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 |
|
||||
| `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` |
|
||||
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
|
||||
|
||||
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||||
|
||||
### `<x-timepicker>`
|
||||
|
||||
M3's time picker in a modal dialog, opened from a read-only text field (a press, Enter, Space, ArrowDown or its clock icon). The dial picks the hour, then the minutes, by press or drag; a 24-hour clock puts 12–23 on the inner ring. The keyboard icon switches to two text fields. The arrow keys change the focused dial's value, Home and End go to the ends, Enter confirms; Escape, Cancel or the scrim close it unchanged and give focus back to the field. In a landscape window the dial lies on its side.
|
||||
|
||||
```blade
|
||||
<x-timepicker label="Starts at" wire:model="startsAt" />
|
||||
<x-timepicker label="Appointment" wire:model.live="appointmentAt" format="24" step="15" min="08:00" max="17:30" clearable />
|
||||
```
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `wire:model` / `x-model` | | the time as `H:i`, null until chosen; `H:i:s` (a `time` column) is read and written back as `H:i`; nothing is written until OK |
|
||||
| `format` | the locale's | `12` or `24`; otherwise the hour cycle of `locale` as `Intl.DateTimeFormat` reports it |
|
||||
| `locale` | app locale | the hour cycle, and how the field writes the time |
|
||||
| `step` | `1` | minutes between choices (a tap picks fives, or steps when five is not a multiple of the step) |
|
||||
| `min`, `max` | | `H:i`, inclusive; `min` later than `max` spans midnight. Outside values are greyed out and refused in the picker — validate on the server as well |
|
||||
| `clearable` | `false` | a button that empties the field |
|
||||
| `name` | | posts the value from a hidden input |
|
||||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's; `required`, `disabled` and `placeholder` reach its input |
|
||||
|
||||
Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`: a Livewire render leaves an open picker open with its draft. Never name a Livewire property `$slot`: it renders empty in the component's view.
|
||||
|
||||
### `<x-choices>`
|
||||
|
||||
Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint.
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
/*
|
||||
* 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
|
||||
* (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.
|
||||
*
|
||||
* Values from DatePickerModalTokens and DateInputModalTokens (androidx Compose Material 3, commit
|
||||
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326) and the layout of DatePicker.kt, DateRangePicker.kt and
|
||||
* DateInput.kt; the docked picker's from material-web's md-comp-date-picker-docked tokens (v0_192).
|
||||
* Apache-2.0.
|
||||
*
|
||||
* [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 <dialog>; data-presentation="docked" | "modal"
|
||||
* [data-datepicker-surface]
|
||||
* [data-datepicker-header] modal only: title, headline, switch
|
||||
* [data-datepicker-calendar]
|
||||
* [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-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
|
||||
* [data-datepicker-actions] Cancel and OK
|
||||
*
|
||||
* Every state layer is the content colour at 8% on hover (only where a pointer can hover) and 10% on
|
||||
* focus and press; keyboard focus adds the package's 3px secondary ring.
|
||||
*/
|
||||
|
||||
@layer components {
|
||||
/* ---- The support line under the field ------------------------------------------------------ */
|
||||
|
||||
/* The padding is on the lines, not the box, so a box with nothing to say takes no room. */
|
||||
[data-datepicker-support] {
|
||||
--datepicker-pad: 1rem;
|
||||
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-body-sm);
|
||||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||||
}
|
||||
|
||||
[data-datepicker-support] > * {
|
||||
padding: 0.25rem var(--datepicker-pad) 0;
|
||||
}
|
||||
|
||||
[data-datepicker-support][data-size="sm"] {
|
||||
--datepicker-pad: 0.75rem;
|
||||
}
|
||||
|
||||
[data-datepicker-support][data-size="xs"] {
|
||||
--datepicker-pad: 0.625rem;
|
||||
}
|
||||
|
||||
[data-datepicker-error] {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
/* ---- The dialog ---------------------------------------------------------------------------- */
|
||||
|
||||
[data-datepicker-picker] {
|
||||
inset: auto;
|
||||
width: 22.5rem;
|
||||
max-width: calc(100vw - 1rem);
|
||||
max-height: calc(100dvh - 1rem);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
[data-datepicker-picker]:popover-open {
|
||||
top: anchor(bottom);
|
||||
left: anchor(left);
|
||||
margin-block: 0.25rem;
|
||||
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
|
||||
opacity: 1;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-datepicker-picker]:popover-open {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-datepicker-picker]:modal {
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
transition-property: opacity, scale;
|
||||
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-spatial-fast);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-datepicker-picker]:modal {
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
[data-datepicker-picker]:modal::backdrop {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
}
|
||||
|
||||
[data-datepicker-surface] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: inherit;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
border-radius: var(--md-sys-shape-corner-lg);
|
||||
background-color: var(--md-sys-color-surface-container-high);
|
||||
box-shadow: var(--md-sys-elevation-3);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-datepicker-picker][data-presentation="modal"] [data-datepicker-surface] {
|
||||
border-radius: var(--md-sys-shape-corner-xl);
|
||||
}
|
||||
|
||||
/* ---- The modal header ---------------------------------------------------------------------- */
|
||||
|
||||
[data-datepicker-header] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
flex: none;
|
||||
min-height: 7.5rem;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
[data-datepicker-header][data-range] {
|
||||
min-height: 8rem;
|
||||
}
|
||||
|
||||
[data-datepicker-title] {
|
||||
padding: 1rem 0.75rem 0 1.5rem;
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
}
|
||||
|
||||
[data-datepicker-headline-row] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
[data-datepicker-headline] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--md-sys-typescale-headline-lg);
|
||||
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
|
||||
}
|
||||
|
||||
[data-datepicker-header][data-range] [data-datepicker-headline] {
|
||||
font: var(--md-sys-typescale-title-lg);
|
||||
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
|
||||
}
|
||||
|
||||
/* ---- Month and year navigation -------------------------------------------------------------- */
|
||||
|
||||
[data-datepicker-nav] {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 3.5rem;
|
||||
padding-inline: 0.75rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
[data-datepicker-nav][data-docked] {
|
||||
height: 4rem;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
[data-datepicker-stepper],
|
||||
[data-datepicker-arrows] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* While a year or month list is open the arrows keep their place but are gone, as androidx hides them. */
|
||||
[data-datepicker-arrows][data-concealed] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
[data-datepicker-menu-button] {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 2.5rem;
|
||||
padding-inline: 0.75rem 0.5rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
[data-datepicker-nav][data-docked] [data-datepicker-menu-button] {
|
||||
padding-inline: 0.5rem 0.25rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
[data-datepicker-menu-arrow] {
|
||||
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
[data-datepicker-menu-button][aria-expanded="true"] [data-datepicker-menu-arrow] {
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
/* ---- State layers shared by days, years, menu buttons and list rows ------------------------ */
|
||||
|
||||
[data-datepicker-menu-button]::before,
|
||||
[data-datepicker-day] > span::before,
|
||||
[data-datepicker-year]::before,
|
||||
[data-datepicker-option]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: var(--datepicker-layer, currentColor);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-datepicker-menu-button]:hover::before,
|
||||
[data-datepicker-day]:not([aria-disabled="true"], [data-blank]):hover > span::before,
|
||||
[data-datepicker-year]:hover::before,
|
||||
[data-datepicker-option]:not([aria-disabled="true"]):hover::before {
|
||||
opacity: 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
[data-datepicker-menu-button]:focus-visible::before,
|
||||
[data-datepicker-menu-button]:active::before,
|
||||
[data-datepicker-day]:focus-visible > span::before,
|
||||
[data-datepicker-day]:not([aria-disabled="true"], [data-blank]):active > span::before,
|
||||
[data-datepicker-year]:focus-visible::before,
|
||||
[data-datepicker-year]:active::before,
|
||||
[data-datepicker-option]:focus-visible::before,
|
||||
[data-datepicker-option]:not([aria-disabled="true"]):active::before {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
[data-datepicker-menu-button]:focus-visible,
|
||||
[data-datepicker-day]:focus-visible > span,
|
||||
[data-datepicker-year]:focus-visible {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-datepicker-option]:focus-visible {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
/* ---- The days ----------------------------------------------------------------------------- */
|
||||
|
||||
[data-datepicker-grid] {
|
||||
width: calc(100% - 1.5rem);
|
||||
margin-inline: 0.75rem;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
[data-datepicker-grid] th {
|
||||
height: 3rem;
|
||||
padding: 0;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font: var(--md-sys-typescale-body-lg);
|
||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-datepicker-day] {
|
||||
--datepicker-layer: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
position: relative;
|
||||
height: 3rem;
|
||||
padding: 0;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font: var(--md-sys-typescale-body-lg);
|
||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
isolation: isolate;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
[data-datepicker-day] > span {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
margin: auto;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
[data-datepicker-day][data-blank] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* A docked picker shows the neighbouring months' days, quieter. */
|
||||
[data-datepicker-day][data-outside] {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
[data-datepicker-day][data-today] {
|
||||
--datepicker-layer: var(--md-sys-color-primary);
|
||||
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
[data-datepicker-day][data-today] > span {
|
||||
box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
/* A range's band: secondary-container behind the days between its ends, from the middle of each end. */
|
||||
[data-datepicker-day][data-between]::before,
|
||||
[data-datepicker-day][data-start]::before,
|
||||
[data-datepicker-day][data-end]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-block: 0.25rem;
|
||||
inset-inline: 0;
|
||||
z-index: -1;
|
||||
background-color: var(--md-sys-color-secondary-container);
|
||||
}
|
||||
|
||||
[data-datepicker-day][data-start]::before {
|
||||
inset-inline-start: 50%;
|
||||
}
|
||||
|
||||
[data-datepicker-day][data-end]::before {
|
||||
inset-inline-end: 50%;
|
||||
}
|
||||
|
||||
[data-datepicker-day][data-between] {
|
||||
--datepicker-layer: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
[data-datepicker-day][data-selected] {
|
||||
--datepicker-layer: var(--md-sys-color-on-primary);
|
||||
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
[data-datepicker-day][data-selected] > span {
|
||||
background-color: var(--md-sys-color-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-datepicker-day][aria-disabled="true"] {
|
||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-datepicker-day][aria-disabled="true"][data-today] > span {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
||||
}
|
||||
|
||||
/* ---- The modal year grid ----------------------------------------------------------------- */
|
||||
|
||||
[data-datepicker-years] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-content: start;
|
||||
justify-items: center;
|
||||
row-gap: 1rem;
|
||||
height: 20.9375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
[data-datepicker-year] {
|
||||
--datepicker-layer: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
position: relative;
|
||||
width: 4.5rem;
|
||||
height: 2.25rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-body-lg);
|
||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
isolation: isolate;
|
||||
scroll-margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
[data-datepicker-year][data-current] {
|
||||
--datepicker-layer: var(--md-sys-color-primary);
|
||||
|
||||
color: var(--md-sys-color-primary);
|
||||
box-shadow: inset 0 0 0 1px var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
[data-datepicker-year][aria-selected="true"] {
|
||||
--datepicker-layer: var(--md-sys-color-on-primary);
|
||||
|
||||
background-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ---- The docked month and year lists ------------------------------------------------------ */
|
||||
|
||||
[data-datepicker-menu] {
|
||||
height: 21.5rem;
|
||||
padding-block: 0.5rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border-top: 1px solid var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
[data-datepicker-option] {
|
||||
--datepicker-layer: var(--md-sys-color-on-surface);
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
height: 3rem;
|
||||
padding-inline: 1rem;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font: var(--md-sys-typescale-body-lg);
|
||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
[data-datepicker-option][aria-selected="true"] {
|
||||
background-color: var(--md-sys-color-surface-container-highest);
|
||||
}
|
||||
|
||||
[data-datepicker-option][aria-disabled="true"] {
|
||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-datepicker-check] {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
[data-datepicker-option]:not([aria-selected="true"]) [data-datepicker-check] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* ---- The modal input ------------------------------------------------------------------------ */
|
||||
|
||||
[data-datepicker-entry] {
|
||||
padding: 0.625rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
[data-datepicker-entry-fields][data-range] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* ---- Cancel and OK ---------------------------------------------------------------------------- */
|
||||
|
||||
[data-datepicker-actions] {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.375rem 0.5rem 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
/*
|
||||
* M3's time picker (resources/views/components/timepicker.blade.php, resources/js/timepicker.js):
|
||||
* the dial, the input variant, and the dialog around them.
|
||||
*
|
||||
* Sizes and roles are androidx Compose Material 3's (TimePickerTokens, TimeInputTokens, TimePicker.kt
|
||||
* and TimePickerDialog.kt at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, Apache-2.0):
|
||||
*
|
||||
* [data-timepicker-surface] surface-container-high, extra-large corner, elevation 3; 24px in
|
||||
* [data-timepicker-title] label-medium, on-surface-variant, 20px above the content
|
||||
* [data-timepicker-picker] the dial variant
|
||||
* [data-timepicker-display] the time selector: hour and minute boxes 96×80 (display-large,
|
||||
* small corner; primary-container when selected, otherwise
|
||||
* surface-container-highest), a 24px separator, and the period
|
||||
* selector 52×80 beside them, 4px away
|
||||
* [data-timepicker-dial] 256px, surface-container-highest; numbers in body-large at radius
|
||||
* 101 (the 24-hour inner ring at 69), a primary selector — a 2px line,
|
||||
* an 8px centre and a 48px handle — with the number under the handle
|
||||
* in on-primary; 36px below the display, 24px above the actions
|
||||
* [data-timepicker-inputs] the input variant: fields 96×72 in display-medium, "Hour" and
|
||||
* "Minute" (or the error) below in body-small, the period 52×72
|
||||
* [data-timepicker-actions] a 48px row: the mode toggle, then Cancel and OK
|
||||
*
|
||||
* The period selector is Compose's current one (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled,
|
||||
* on by default): two toggle buttons 4px apart, round and surface-container-lowest when off, a 12px
|
||||
* corner and primary-container with a bold label when on, not the outlined pair of the tokens.
|
||||
*
|
||||
* In a landscape window the dial variant lies on its side, as Compose's HorizontalTimePicker does
|
||||
* whenever the screen is wider than it is tall: the display with the period selector (216×38, 16px
|
||||
* under it) on the start side, the dial 36px after it, the actions under both; and the dial shrinks
|
||||
* to 238 or 200px when the window is short (ClockFaceSizeModifier). The input variant always stands.
|
||||
*
|
||||
* The selector's angle is a registered property, so a single transition turns the line, carries the
|
||||
* handle round and moves the clip that inks the number under it, on the default spatial spring. The
|
||||
* numbers cross-fade between hours and minutes on the default effects spring.
|
||||
*/
|
||||
|
||||
@property --timepicker-angle {
|
||||
syntax: '<angle>';
|
||||
inherits: true;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-timepicker-field] .field-control {
|
||||
cursor: pointer;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
[data-timepicker-dialog] {
|
||||
max-width: calc(100vw - 2rem);
|
||||
max-height: calc(100dvh - 2rem);
|
||||
}
|
||||
|
||||
[data-timepicker-surface] {
|
||||
display: grid;
|
||||
grid-template-columns: max-content;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--md-sys-shape-corner-xl);
|
||||
background-color: var(--md-sys-color-surface-container-high);
|
||||
box-shadow: var(--md-sys-elevation-3);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
[data-timepicker-title] {
|
||||
padding-bottom: 1.25rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-label-md);
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
}
|
||||
|
||||
[data-timepicker-picker] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* The mode is an attribute rather than x-show, which shows only on the next frame: focus moves
|
||||
into the variant that was just switched to within the same tick. */
|
||||
[data-timepicker-surface]:not([data-mode="input"]) :is([data-timepicker-typing], [data-timepicker-when="input"]),
|
||||
[data-timepicker-surface][data-mode="input"] :is([data-timepicker-picker], [data-timepicker-when="dial"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-timepicker-when] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* ---- The time selector and the period selector ------------------------------------------ */
|
||||
|
||||
[data-timepicker-display] {
|
||||
display: flex;
|
||||
margin-bottom: 2.25rem;
|
||||
}
|
||||
|
||||
[data-timepicker-numbers],
|
||||
[data-timepicker-inputs] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
[data-timepicker-box] {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 6rem;
|
||||
height: 5rem;
|
||||
border-radius: var(--md-sys-shape-corner-sm);
|
||||
background-color: var(--md-sys-color-surface-container-highest);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font: var(--md-sys-typescale-display-lg);
|
||||
letter-spacing: var(--md-sys-typescale-display-lg-tracking);
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
transition-property: background-color, color;
|
||||
transition-duration: var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
[data-timepicker-box][aria-pressed="true"] {
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
|
||||
[data-timepicker-separator] {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.5rem;
|
||||
height: 5rem;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font: var(--md-sys-typescale-display-lg);
|
||||
translate: 0 -0.25rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-timepicker-period] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
width: 3.25rem;
|
||||
height: 5rem;
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
[data-timepicker-period] > button {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
background-color: var(--md-sys-color-surface-container-lowest);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-title-md);
|
||||
letter-spacing: var(--md-sys-typescale-title-md-tracking);
|
||||
cursor: pointer;
|
||||
transition-property: border-radius, background-color, color;
|
||||
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-spatial-fast);
|
||||
}
|
||||
|
||||
[data-timepicker-period] > button:active,
|
||||
[data-timepicker-period] > button[aria-pressed="true"] {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
[data-timepicker-period] > button[aria-pressed="true"] {
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
font-weight: var(--md-ref-typeface-weight-bold);
|
||||
}
|
||||
|
||||
[data-timepicker-period] > button:disabled {
|
||||
cursor: default;
|
||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
|
||||
}
|
||||
|
||||
/* ---- The dial ----------------------------------------------------------------------------- */
|
||||
|
||||
[data-timepicker-dial] {
|
||||
--dial: 16rem;
|
||||
--unit: calc(var(--dial) / 256);
|
||||
--outer: calc(101 * var(--unit));
|
||||
--inner: calc(69 * var(--unit));
|
||||
--handle: calc(48 * var(--unit));
|
||||
--reach: var(--outer);
|
||||
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: var(--dial);
|
||||
height: var(--dial);
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
background-color: var(--md-sys-color-surface-container-highest);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font: var(--md-sys-typescale-body-lg);
|
||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
direction: ltr;
|
||||
transition: --timepicker-angle var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default);
|
||||
}
|
||||
|
||||
[data-timepicker-dial]:focus-visible {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* A drag keeps the handle under the pointer; the spring only settles it on release. */
|
||||
[data-timepicker-dial][data-dragging] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
[data-timepicker-dial][data-inner] {
|
||||
--reach: var(--inner);
|
||||
}
|
||||
|
||||
[data-timepicker-labels],
|
||||
[data-timepicker-ink],
|
||||
[data-timepicker-set] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
[data-timepicker-set] {
|
||||
transition-property: opacity, visibility;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-default);
|
||||
}
|
||||
|
||||
[data-timepicker-dial]:not([data-cycle="24"]) [data-timepicker-set="hour24"],
|
||||
[data-timepicker-dial][data-cycle="24"] [data-timepicker-set="hour12"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-timepicker-dial][data-view="minute"] :is([data-timepicker-set="hour12"], [data-timepicker-set="hour24"]),
|
||||
[data-timepicker-dial]:not([data-view="minute"]) [data-timepicker-set="minute"] {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
[data-timepicker-set] > span {
|
||||
--ring: var(--outer);
|
||||
|
||||
position: absolute;
|
||||
left: calc(50% + var(--x) * var(--ring));
|
||||
top: calc(50% + var(--y) * var(--ring));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
translate: -50% -50%;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-timepicker-set] > span[data-inner] {
|
||||
--ring: var(--inner);
|
||||
}
|
||||
|
||||
[data-timepicker-set] > span[data-disabled] {
|
||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
||||
}
|
||||
|
||||
/* The selector: the line from the centre to the handle's edge, the centre dot, the handle. */
|
||||
[data-timepicker-selector] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-timepicker-track] {
|
||||
position: absolute;
|
||||
left: calc(50% - 1px);
|
||||
bottom: 50%;
|
||||
width: 2px;
|
||||
height: calc(var(--reach) - var(--handle) / 2);
|
||||
background-color: var(--md-sys-color-primary);
|
||||
transform-origin: 50% 100%;
|
||||
rotate: var(--timepicker-angle);
|
||||
}
|
||||
|
||||
[data-timepicker-centre],
|
||||
[data-timepicker-handle] {
|
||||
position: absolute;
|
||||
translate: -50% -50%;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
background-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
[data-timepicker-centre] {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
[data-timepicker-handle] {
|
||||
left: calc(50% + sin(var(--timepicker-angle)) * var(--reach));
|
||||
top: calc(50% - cos(var(--timepicker-angle)) * var(--reach));
|
||||
width: var(--handle);
|
||||
height: var(--handle);
|
||||
}
|
||||
|
||||
/* The numbers again, in on-primary, cut to the handle: what Compose draws with BlendMode.DstOver. */
|
||||
[data-timepicker-ink] {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
pointer-events: none;
|
||||
clip-path: circle(calc(var(--handle) / 2) at calc(50% + sin(var(--timepicker-angle)) * var(--reach)) calc(50% - cos(var(--timepicker-angle)) * var(--reach)));
|
||||
}
|
||||
|
||||
[data-timepicker-ink] [data-timepicker-set] > span[data-disabled] {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ---- The input variant -------------------------------------------------------------------- */
|
||||
|
||||
[data-timepicker-inputs] [data-timepicker-separator] {
|
||||
height: 4.5rem;
|
||||
}
|
||||
|
||||
[data-timepicker-inputs] [data-timepicker-period] {
|
||||
height: 4.5rem;
|
||||
}
|
||||
|
||||
[data-timepicker-column] {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
[data-timepicker-input] {
|
||||
display: block;
|
||||
width: 6rem;
|
||||
height: 4.5rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--md-sys-shape-corner-sm);
|
||||
outline: none;
|
||||
background-color: var(--md-sys-color-surface-container-highest);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
caret-color: var(--md-sys-color-primary);
|
||||
font: var(--md-sys-typescale-display-md);
|
||||
letter-spacing: var(--md-sys-typescale-display-md-tracking);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
transition-property: background-color, color, box-shadow;
|
||||
transition-duration: var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
[data-timepicker-input]:focus {
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
box-shadow: inset 0 0 0 2px var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
[data-timepicker-input][aria-invalid="true"] {
|
||||
background-color: var(--md-sys-color-error-container);
|
||||
color: var(--md-sys-color-error);
|
||||
caret-color: var(--md-sys-color-error);
|
||||
box-shadow: inset 0 0 0 1px var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
[data-timepicker-input][aria-invalid="true"]:focus {
|
||||
box-shadow: inset 0 0 0 2px var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
/* SupportingText: two lines' room, 7px under the field; the error in its place. */
|
||||
[data-timepicker-support] {
|
||||
min-height: 2rem;
|
||||
padding-top: 0.4375rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-body-sm);
|
||||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||||
}
|
||||
|
||||
[data-timepicker-support][data-error],
|
||||
[data-timepicker-range-error] {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
[data-timepicker-range-error] {
|
||||
max-width: 17rem;
|
||||
padding-bottom: 0.5rem;
|
||||
font: var(--md-sys-typescale-body-sm);
|
||||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||||
}
|
||||
|
||||
[data-timepicker-actions] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
[data-timepicker-actions] > [data-timepicker-spacer] {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
/* ---- Landscape ---------------------------------------------------------------------------- */
|
||||
|
||||
@media (orientation: landscape) and (min-width: 37rem) {
|
||||
[data-timepicker-surface][data-mode="dial"] {
|
||||
grid-template-columns: 13.5rem auto;
|
||||
grid-template-areas:
|
||||
"display dial"
|
||||
"actions actions";
|
||||
column-gap: 2.25rem;
|
||||
padding: 1rem 1.5rem 0.5rem;
|
||||
}
|
||||
|
||||
[data-timepicker-surface][data-mode="dial"] [data-timepicker-title] {
|
||||
grid-area: display;
|
||||
align-self: start;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-timepicker-surface][data-mode="dial"] [data-timepicker-picker] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] {
|
||||
grid-area: display;
|
||||
flex-direction: column;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] [data-timepicker-period] {
|
||||
flex-direction: row;
|
||||
width: 13.5rem;
|
||||
height: 2.375rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
|
||||
grid-area: dial;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-timepicker-surface][data-mode="dial"] [data-timepicker-actions] {
|
||||
grid-area: actions;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 22.75rem) {
|
||||
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
|
||||
--dial: 14.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 37rem) and (max-height: 21.625rem) {
|
||||
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
|
||||
--dial: 12.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@
|
||||
@import './components/menu.css';
|
||||
@import './components/selection.css';
|
||||
@import './components/search.css';
|
||||
@import './components/datepicker.css';
|
||||
@import './components/timepicker.css';
|
||||
@import './components/tabs.css';
|
||||
@import './components/app-bar.css';
|
||||
@import './components/toolbar.css';
|
||||
|
||||
@@ -0,0 +1,830 @@
|
||||
/**
|
||||
* `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).
|
||||
*
|
||||
* 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. */
|
||||
function firstDayOfWeek(locale) {
|
||||
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`.
|
||||
*/
|
||||
function inputFormat(locale) {
|
||||
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 = ''
|
||||
}
|
||||
|
||||
if (!/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[/\-.][dMy]+[/\-.][dMy]+$/.test(pattern)) {
|
||||
pattern = 'yyyy-MM-dd'
|
||||
}
|
||||
|
||||
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)
|
||||
this.format = inputFormat(locale)
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
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() ?? ''
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -20,6 +20,8 @@ import './carousel.js'
|
||||
import './chips.js'
|
||||
import './field.js'
|
||||
import './search.js'
|
||||
import './datepicker.js'
|
||||
import './timepicker.js'
|
||||
import './slider.js'
|
||||
import './tabs.js'
|
||||
import './app-bar.js'
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* `materialTimepicker(value, config)`: the behaviour of `<x-timepicker>` — a field that opens M3's
|
||||
* time picker in a modal `<dialog>`, as a clock dial or as two text fields.
|
||||
*
|
||||
* The picker edits a draft, `hour` (0–23) and `minute`; only OK writes it to `value` as `H:i`.
|
||||
* Cancel, Escape and a press on the scrim leave `value` alone, and every close hands focus back to
|
||||
* the field. Whether the hours run 1–12 with AM and PM or 0–23 comes from `config.format` or, without
|
||||
* one, from the locale (`Intl.DateTimeFormat(locale, { hour: 'numeric' })`'s hour cycle).
|
||||
*
|
||||
* The dial:
|
||||
* - A press sets the value where it lands, and the selector turns there on the default spatial
|
||||
* spring; a drag makes the handle follow the pointer and settles on the nearest value when
|
||||
* released. Hours then move on to minutes, as Compose does after a tap (100ms later) or a drag.
|
||||
* - On a 24-hour dial the inner ring holds 12–23 and the outer 00–11: a press nearer the centre than
|
||||
* 74dp (scaled with the dial) is on the inner ring.
|
||||
* - A tap picks minutes in fives (or in `step`s, when `step` is not a divisor of five); a drag in ones
|
||||
* (or `step`s).
|
||||
* - The dial is a slider for the keyboard: the arrows change the hour or minute it shows, Home and End
|
||||
* go to the first and last allowed, Enter confirms. The keyboard never moves on to minutes by
|
||||
* itself; the hour and minute boxes above the dial switch between them.
|
||||
*
|
||||
* `min` and `max` (`H:i`, inclusive; `min` later than `max` is a range across midnight) and `step`
|
||||
* (minutes) limit what can be chosen: a tap on a number outside them does nothing, a drag and the
|
||||
* arrows skip to the nearest allowed value, a period switch that would leave them lands on the
|
||||
* nearest allowed time, and typed values outside them are errors. They are a
|
||||
* convenience for the person choosing, not validation — validate on the server too.
|
||||
*
|
||||
* The selector's angle is a registered custom property (`--timepicker-angle`, components/
|
||||
* timepicker.css), so one CSS transition turns the line, moves the handle, and moves the clip that
|
||||
* shows the number under the handle in on-primary, all on the same spring.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Behaviour and geometry follow androidx Compose Material 3 (https://github.com/androidx/androidx),
|
||||
* commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
|
||||
*
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/TimePicker.kt
|
||||
* (AnalogTimePickerState.rotateTo, endValueForAnimation, moveSelector, onTap, ClockDialNode,
|
||||
* selectorPos, TimeInputImpl, shouldSwitchFocusToMinute, the ring and distance constants)
|
||||
*
|
||||
* Copyright 2022-2026 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* ---------------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/** ClockDialContainerSize: the dial's own coordinate space. */
|
||||
const DIAL = 256
|
||||
|
||||
/** MaxDistance: nearer the centre than this, a 24-hour dial is on its inner ring. */
|
||||
const INNER_REACH = 74
|
||||
|
||||
/** onTap's delay(100) before a tapped hour moves on to minutes. */
|
||||
const MOVE_ON_MS = 100
|
||||
|
||||
/** How far a pointer moves before a press on the dial is a drag. */
|
||||
const DRAG_SLOP = 4
|
||||
|
||||
const pad = (number) => String(number).padStart(2, '0')
|
||||
|
||||
/** "1–12" stays on one line in a 96px column: word joiners either side of a dash between numbers. */
|
||||
const keepRange = (text) => text.replace(/(\d)\s*([–-])\s*(\d)/g, '$1\u2060$2\u2060$3')
|
||||
|
||||
/** The shortest way round from one angle to another, in degrees. */
|
||||
const turn = (from, to) => ((((to - from) % 360) + 540) % 360) - 180
|
||||
|
||||
function parse(value) {
|
||||
const match = /^(\d{1,2}):(\d{2})/.exec(typeof value === 'string' ? value : '')
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hour = Number(match[1])
|
||||
const minute = Number(match[2])
|
||||
|
||||
return hour < 24 && minute < 60 ? hour * 60 + minute : null
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialTimepicker', (value = null, config = {}) => ({
|
||||
value,
|
||||
open: false,
|
||||
mode: 'dial',
|
||||
view: 'hour',
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
angle: 0,
|
||||
dragging: false,
|
||||
scrimPressed: false,
|
||||
hourText: '',
|
||||
minuteText: '',
|
||||
attempted: false,
|
||||
is24: false,
|
||||
step: 1,
|
||||
earliest: null,
|
||||
latest: null,
|
||||
moveOn: null,
|
||||
formatter: null,
|
||||
periods: ['AM', 'PM'],
|
||||
|
||||
init() {
|
||||
const locale = config.locale || undefined
|
||||
|
||||
try {
|
||||
this.is24 = config.format ? Number(config.format) === 24 : ['h23', 'h24'].includes(new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hourCycle)
|
||||
this.formatter = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', hourCycle: this.is24 ? 'h23' : 'h12' })
|
||||
const dayPeriod = (hour) => new Intl.DateTimeFormat(locale, { hour: 'numeric', hourCycle: 'h12' }).formatToParts(new Date(2000, 0, 1, hour)).find((part) => part.type === 'dayPeriod')?.value
|
||||
this.periods = [dayPeriod(9) ?? config.strings.am, dayPeriod(21) ?? config.strings.pm]
|
||||
} catch {
|
||||
this.is24 = Number(config.format) === 24
|
||||
this.formatter = null
|
||||
this.periods = [config.strings.am, config.strings.pm]
|
||||
}
|
||||
|
||||
this.step = Math.min(Math.max(Math.trunc(Number(config.step) || 1), 1), 60)
|
||||
this.earliest = parse(config.min)
|
||||
this.latest = parse(config.max)
|
||||
},
|
||||
|
||||
// ---- What is shown ---------------------------------------------------------------------
|
||||
|
||||
get display() {
|
||||
const time = parse(this.value)
|
||||
|
||||
return time === null ? '' : this.format(time)
|
||||
},
|
||||
|
||||
format(time) {
|
||||
const date = new Date(2000, 0, 1, Math.floor(time / 60), time % 60)
|
||||
|
||||
return this.formatter ? this.formatter.format(date) : `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
},
|
||||
|
||||
get isPm() {
|
||||
return this.hour >= 12
|
||||
},
|
||||
|
||||
get hourLabel() {
|
||||
return this.is24 ? pad(this.hour) : pad(this.hour % 12 || 12)
|
||||
},
|
||||
|
||||
get minuteLabel() {
|
||||
return pad(this.minute)
|
||||
},
|
||||
|
||||
/** selectorPos: a 24-hour dial's afternoon hours are on the inner ring. */
|
||||
get inner() {
|
||||
return this.is24 && this.view === 'hour' && this.hour >= 12
|
||||
},
|
||||
|
||||
get valueText() {
|
||||
const strings = config.strings
|
||||
|
||||
if (this.view === 'minute') {
|
||||
return strings.minutes.replace(':minute', this.minute)
|
||||
}
|
||||
|
||||
return this.is24 ? strings.hours.replace(':hour', this.hour) : `${strings.oclock.replace(':hour', this.hour % 12 || 12)} ${this.periods[this.isPm ? 1 : 0]}`
|
||||
},
|
||||
|
||||
// ---- What may be chosen ----------------------------------------------------------------
|
||||
|
||||
inRange(time) {
|
||||
const { earliest, latest } = this
|
||||
|
||||
if (earliest !== null && latest !== null && earliest > latest) {
|
||||
return time >= earliest || time <= latest
|
||||
}
|
||||
|
||||
return (earliest === null || time >= earliest) && (latest === null || time <= latest)
|
||||
},
|
||||
|
||||
allowed(hour, minute) {
|
||||
return minute % this.step === 0 && this.inRange(hour * 60 + minute)
|
||||
},
|
||||
|
||||
hourAllowed(hour) {
|
||||
for (let minute = 0; minute < 60; minute += this.step) {
|
||||
if (this.allowed(hour, minute)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
minuteAllowed(minute) {
|
||||
return this.allowed(this.hour, minute)
|
||||
},
|
||||
|
||||
periodAllowed(pm) {
|
||||
for (let hour = pm ? 12 : 0; hour < (pm ? 24 : 12); hour++) {
|
||||
if (this.hourAllowed(hour)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
/** The allowed time nearest to one that is not, searching outwards a minute at a time. */
|
||||
nearest(time) {
|
||||
for (let distance = 0; distance <= 720; distance++) {
|
||||
for (const candidate of [time - distance, time + distance]) {
|
||||
const wrapped = (candidate + 1440) % 1440
|
||||
|
||||
if (this.allowed(Math.floor(wrapped / 60), wrapped % 60)) {
|
||||
return wrapped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
/** The minute nearest to this one that the current hour allows. */
|
||||
nearestMinute(minute) {
|
||||
for (let distance = 0; distance <= 30; distance++) {
|
||||
for (const candidate of [minute - distance, minute + distance]) {
|
||||
const wrapped = (candidate + 60) % 60
|
||||
|
||||
if (this.minuteAllowed(wrapped)) {
|
||||
return wrapped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
/** The hour nearest to this one on its own ring (period), then on the other. */
|
||||
nearestHour(hour) {
|
||||
const base = hour >= 12 ? 12 : 0
|
||||
|
||||
for (const ring of [base, 12 - base]) {
|
||||
for (let distance = 0; distance <= 6; distance++) {
|
||||
for (const candidate of [hour - distance, hour + distance]) {
|
||||
const inRing = ring + ((((candidate - base) % 12) + 12) % 12)
|
||||
|
||||
if (this.hourAllowed(inRing)) {
|
||||
return inRing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.is24) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
|
||||
settle(time) {
|
||||
const allowed = this.allowed(Math.floor(time / 60), time % 60) ? time : this.nearest(time)
|
||||
|
||||
if (allowed !== null) {
|
||||
this.hour = Math.floor(allowed / 60)
|
||||
this.minute = allowed % 60
|
||||
}
|
||||
},
|
||||
|
||||
/** An hour chosen: keep the minute if the new hour allows it, otherwise the nearest one it does. */
|
||||
setHour(hour) {
|
||||
this.hour = hour
|
||||
|
||||
if (!this.minuteAllowed(this.minute)) {
|
||||
this.minute = this.nearestMinute(this.minute) ?? this.minute
|
||||
}
|
||||
},
|
||||
|
||||
// ---- The dialog ------------------------------------------------------------------------
|
||||
|
||||
show() {
|
||||
if (this.open || this.$refs.input.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const time = parse(this.value) ?? now.getHours() * 60 + Math.round(now.getMinutes() / this.step) * this.step
|
||||
|
||||
this.settle(time % 1440)
|
||||
this.view = 'hour'
|
||||
this.attempted = false
|
||||
this.fillText()
|
||||
this.angle = this.angleFor()
|
||||
this.open = true
|
||||
|
||||
// After Alpine has drawn the draft, so nothing animates from the last time it was open.
|
||||
this.$nextTick(() => {
|
||||
this.$refs.dialog.showModal()
|
||||
this.focusMode()
|
||||
})
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.$refs.dialog.close()
|
||||
},
|
||||
|
||||
confirm() {
|
||||
if (this.mode === 'input' && !this.readText()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.allowed(this.hour, this.minute)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.value = `${pad(this.hour)}:${pad(this.minute)}`
|
||||
this.$refs.dialog.close()
|
||||
},
|
||||
|
||||
closed() {
|
||||
clearTimeout(this.moveOn)
|
||||
this.open = false
|
||||
this.dragging = false
|
||||
this.$refs.input.focus({ preventScroll: true })
|
||||
},
|
||||
|
||||
focusMode() {
|
||||
if (this.mode === 'input') {
|
||||
const field = this.view === 'minute' ? this.$refs.minuteInput : this.$refs.hourInput
|
||||
|
||||
field.focus()
|
||||
field.select()
|
||||
} else {
|
||||
this.$refs.dial.focus({ preventScroll: true })
|
||||
}
|
||||
},
|
||||
|
||||
toggleMode() {
|
||||
clearTimeout(this.moveOn)
|
||||
|
||||
if (this.mode === 'dial') {
|
||||
this.mode = 'input'
|
||||
this.attempted = false
|
||||
this.fillText()
|
||||
} else {
|
||||
this.mode = 'dial'
|
||||
this.angle = this.angleFor()
|
||||
}
|
||||
|
||||
this.$nextTick(() => this.focusMode())
|
||||
},
|
||||
|
||||
/** The hour or minute box above the dial. */
|
||||
choose(view) {
|
||||
clearTimeout(this.moveOn)
|
||||
this.view = view
|
||||
this.aim()
|
||||
},
|
||||
|
||||
setPeriod(pm) {
|
||||
if (this.isPm === pm || !this.periodAllowed(pm)) {
|
||||
return
|
||||
}
|
||||
|
||||
const hour = this.hour + (pm ? 12 : -12)
|
||||
|
||||
if (this.hourAllowed(hour)) {
|
||||
this.setHour(hour)
|
||||
} else {
|
||||
const time = this.nearestInPeriod(pm, hour * 60 + this.minute)
|
||||
|
||||
this.hour = Math.floor(time / 60)
|
||||
this.minute = time % 60
|
||||
}
|
||||
|
||||
this.fillText()
|
||||
this.aim()
|
||||
},
|
||||
|
||||
/** The allowed time in a period nearest to `time` (periodAllowed has said there is one). */
|
||||
nearestInPeriod(pm, time) {
|
||||
let best = null
|
||||
|
||||
for (let candidate = pm ? 720 : 0; candidate < (pm ? 1440 : 720); candidate++) {
|
||||
if (this.allowed(Math.floor(candidate / 60), candidate % 60) && (best === null || Math.abs(candidate - time) < Math.abs(best - time))) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? time
|
||||
},
|
||||
|
||||
// ---- The dial --------------------------------------------------------------------------
|
||||
|
||||
angleFor() {
|
||||
return this.view === 'hour' ? (this.hour % 12) * 30 : this.minute * 6
|
||||
},
|
||||
|
||||
/** endValueForAnimation: turn the short way round, so 11 to 1 never spins backwards. */
|
||||
aim(degrees = this.angleFor()) {
|
||||
this.angle += turn(this.angle, degrees)
|
||||
},
|
||||
|
||||
press(event) {
|
||||
if (event.button !== 0 || !event.isPrimary) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
clearTimeout(this.moveOn)
|
||||
|
||||
const dial = this.$refs.dial
|
||||
const startX = event.clientX
|
||||
const startY = event.clientY
|
||||
let dragged = false
|
||||
|
||||
dial.focus({ preventScroll: true })
|
||||
|
||||
try {
|
||||
dial.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// A pointer the browser does not know (a synthetic event) cannot be captured.
|
||||
}
|
||||
|
||||
const move = (moveEvent) => {
|
||||
if (!dragged && Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < DRAG_SLOP) {
|
||||
return
|
||||
}
|
||||
|
||||
dragged = true
|
||||
this.dragging = true
|
||||
this.pick(moveEvent, false)
|
||||
}
|
||||
|
||||
const release = (upEvent, cancelled = false) => {
|
||||
dial.removeEventListener('pointermove', move)
|
||||
dial.removeEventListener('pointerup', release)
|
||||
dial.removeEventListener('pointercancel', abandon)
|
||||
this.dragging = false
|
||||
|
||||
if (cancelled) {
|
||||
this.aim()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (dragged) {
|
||||
this.aim()
|
||||
} else if (!this.pick(upEvent, true)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.view === 'hour') {
|
||||
this.moveOn = setTimeout(() => this.choose('minute'), dragged ? 0 : MOVE_ON_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const abandon = (cancelEvent) => release(cancelEvent, true)
|
||||
|
||||
dial.addEventListener('pointermove', move)
|
||||
dial.addEventListener('pointerup', release)
|
||||
dial.addEventListener('pointercancel', abandon)
|
||||
},
|
||||
|
||||
/**
|
||||
* The value under the pointer. A tap turns the selector to the value it chose; a drag keeps the
|
||||
* handle under the pointer (rotateTo without animation) until it is released.
|
||||
*/
|
||||
pick(event, tap) {
|
||||
const box = this.$refs.dial.getBoundingClientRect()
|
||||
const x = event.clientX - (box.left + box.width / 2)
|
||||
const y = event.clientY - (box.top + box.height / 2)
|
||||
const degrees = ((Math.atan2(x, -y) * 180) / Math.PI + 360) % 360
|
||||
|
||||
if (this.view === 'hour') {
|
||||
let hour = Math.round(degrees / 30) % 12
|
||||
|
||||
if (this.is24 ? Math.hypot(x, y) < (INNER_REACH * box.width) / DIAL : this.isPm) {
|
||||
hour += 12
|
||||
}
|
||||
|
||||
// A tap on a number outside the limits does nothing; a drag settles on the nearest allowed.
|
||||
hour = this.hourAllowed(hour) ? hour : tap ? null : this.nearestHour(hour)
|
||||
|
||||
if (hour === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.setHour(hour)
|
||||
} else {
|
||||
const unit = tap && 5 % this.step === 0 ? 5 : this.step
|
||||
let minute = (Math.round(degrees / 6 / unit) * unit) % 60
|
||||
|
||||
minute = this.minuteAllowed(minute) ? minute : tap ? null : this.nearestMinute(minute)
|
||||
|
||||
if (minute === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.minute = minute
|
||||
}
|
||||
|
||||
this.aim(tap ? this.angleFor() : degrees)
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
key(event) {
|
||||
const moves = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1 }
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
this.confirm()
|
||||
} else if (event.key in moves) {
|
||||
event.preventDefault()
|
||||
this.nudge(moves[event.key])
|
||||
} else if (event.key === 'Home' || event.key === 'End') {
|
||||
event.preventDefault()
|
||||
this.extreme(event.key === 'End')
|
||||
}
|
||||
},
|
||||
|
||||
nudge(direction) {
|
||||
clearTimeout(this.moveOn)
|
||||
|
||||
if (this.view === 'hour') {
|
||||
for (let offset = 1; offset <= 24; offset++) {
|
||||
const hour = (((this.hour + direction * offset) % 24) + 24) % 24
|
||||
|
||||
if (this.hourAllowed(hour)) {
|
||||
this.setHour(hour)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const aligned = Math.round(this.minute / this.step) * this.step
|
||||
|
||||
for (let offset = aligned === this.minute ? 1 : 0; offset <= 60; offset++) {
|
||||
const minute = (((aligned + direction * offset * this.step) % 60) + 60) % 60
|
||||
|
||||
if (this.minuteAllowed(minute)) {
|
||||
this.minute = minute
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.aim()
|
||||
},
|
||||
|
||||
extreme(last) {
|
||||
const values = this.view === 'hour'
|
||||
? [...Array(24).keys()].filter((hour) => this.hourAllowed(hour))
|
||||
: [...Array(60).keys()].filter((minute) => this.minuteAllowed(minute))
|
||||
|
||||
if (values.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.view === 'hour') {
|
||||
this.setHour(last ? values.at(-1) : values[0])
|
||||
} else {
|
||||
this.minute = last ? values.at(-1) : values[0]
|
||||
}
|
||||
|
||||
this.aim()
|
||||
},
|
||||
|
||||
// ---- The text fields -------------------------------------------------------------------
|
||||
|
||||
fillText() {
|
||||
this.hourText = this.hourLabel
|
||||
this.minuteText = this.minuteLabel
|
||||
},
|
||||
|
||||
get hourTextValid() {
|
||||
const number = Number(this.hourText)
|
||||
|
||||
return /^\d{1,2}$/.test(this.hourText) && (this.is24 ? number <= 23 : number >= 1 && number <= 12)
|
||||
},
|
||||
|
||||
get minuteTextValid() {
|
||||
return /^\d{1,2}$/.test(this.minuteText) && Number(this.minuteText) <= 59
|
||||
},
|
||||
|
||||
get minuteTextOnStep() {
|
||||
return !this.minuteTextValid || Number(this.minuteText) % this.step === 0
|
||||
},
|
||||
|
||||
get hourError() {
|
||||
if (this.hourTextValid || (this.hourText === '' && !this.attempted)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return keepRange(this.is24 ? config.strings.hourError24 : config.strings.hourError12)
|
||||
},
|
||||
|
||||
get minuteError() {
|
||||
if (this.minuteText === '' && !this.attempted) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!this.minuteTextValid) {
|
||||
return keepRange(config.strings.minuteError)
|
||||
}
|
||||
|
||||
return this.minuteTextOnStep ? null : config.strings.stepError.replace(':step', this.step)
|
||||
},
|
||||
|
||||
get rangeError() {
|
||||
if (this.earliest === null && this.latest === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (this.mode !== 'input' || !this.hourTextValid || !this.minuteTextValid || this.inRange(this.hour * 60 + this.minute)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const strings = config.strings
|
||||
|
||||
if (this.earliest !== null && this.latest !== null) {
|
||||
return strings.between.replace(':min', this.format(this.earliest)).replace(':max', this.format(this.latest))
|
||||
}
|
||||
|
||||
return this.earliest !== null ? strings.after.replace(':min', this.format(this.earliest)) : strings.before.replace(':max', this.format(this.latest))
|
||||
},
|
||||
|
||||
typeHour(event) {
|
||||
const text = event.target.value.replace(/\D/g, '').slice(0, 2)
|
||||
|
||||
event.target.value = text
|
||||
this.hourText = text
|
||||
|
||||
if (this.hourTextValid) {
|
||||
const number = Number(text)
|
||||
|
||||
this.hour = this.is24 ? number : (number % 12) + (this.isPm ? 12 : 0)
|
||||
}
|
||||
|
||||
// shouldSwitchFocusToMinute: two valid digits typed at the end move on to the minute.
|
||||
if (event.inputType?.startsWith('insert') && text.length === 2 && this.hourTextValid && event.target.selectionStart === 2) {
|
||||
this.view = 'minute'
|
||||
this.$refs.minuteInput.focus()
|
||||
this.$refs.minuteInput.select()
|
||||
}
|
||||
},
|
||||
|
||||
typeMinute(event) {
|
||||
const text = event.target.value.replace(/\D/g, '').slice(0, 2)
|
||||
|
||||
event.target.value = text
|
||||
this.minuteText = text
|
||||
|
||||
if (this.minuteTextValid) {
|
||||
this.minute = Number(text)
|
||||
}
|
||||
},
|
||||
|
||||
/** Reads both fields for OK; on an error, says so and puts focus on the field to fix. */
|
||||
readText() {
|
||||
this.attempted = true
|
||||
|
||||
const invalid = !this.hourTextValid
|
||||
? this.$refs.hourInput
|
||||
: !this.minuteTextValid || !this.minuteTextOnStep
|
||||
? this.$refs.minuteInput
|
||||
: !this.inRange(this.hour * 60 + this.minute)
|
||||
? this.$refs.hourInput
|
||||
: null
|
||||
|
||||
if (invalid) {
|
||||
invalid.focus()
|
||||
invalid.select()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,447 @@
|
||||
{{-- A date field with M3's date pickers: docked under the field, modal, or modal text input.
|
||||
|
||||
`mode` picks M3's three:
|
||||
|
||||
- `docked` (the default): a text field that takes a typed date in the locale's numeric format
|
||||
(`13.09.2026` in `de`, `09/13/2026` in `en-US`), with the calendar dropping open under it —
|
||||
on a press of the field, its calendar button, or ArrowDown. On a compact window (below
|
||||
`sm`) the calendar opens as the modal picker instead, where a docked one would not fit.
|
||||
- `modal`: the field only shows the date; pressing it (or Enter, Space, ArrowDown) opens the
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
`wire:model` stores `Y-m-d` strings (`x-model` without Livewire). `range` picks a start and an
|
||||
end, bound as one array, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null) —
|
||||
one property rather than two, because a `wire:model` names one property, Livewire sends a
|
||||
range as one update so `after_or_equal:period.start` validates against the end it came
|
||||
with, and the errors for `period`, `period.start` and `period.end` all belong to this field.
|
||||
`min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the
|
||||
keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size`
|
||||
are the field's; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
|
||||
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under
|
||||
the `wire:model` name replace the hint, and so does a typed date that cannot be read.
|
||||
|
||||
Month and weekday names, the first day of the week and the typed format come from `Intl` for
|
||||
`app()->getLocale()` (resources/js/datepicker.js). Replaces ReStride's flatpickr picker: its
|
||||
`config` becomes `min`, `max`, `range` and `mode`.
|
||||
|
||||
M3's date pickers (DatePickerModalTokens and DateInputModalTokens from androidx Compose
|
||||
Material 3, androidx commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326, with the layout of
|
||||
DatePicker.kt, DateRangePicker.kt and DateInput.kt; the docked picker's values from
|
||||
material-web's md-comp-date-picker-docked tokens, v0_192, as Compose has none; all Apache-2.0):
|
||||
surface-container-high at elevation 3, 360px wide, the extra-large corner (modal) or large
|
||||
(docked), 40px days in 48px cells, today outlined in primary, the chosen day in primary and a
|
||||
range's middle in secondary-container. The styles are resources/css/components/datepicker.css. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'hint' => null,
|
||||
'icon' => null,
|
||||
'size' => 'md',
|
||||
'variant' => null,
|
||||
'mode' => 'docked',
|
||||
'range' => false,
|
||||
'min' => null,
|
||||
'max' => null,
|
||||
'value' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$mode = in_array($mode, ['docked', 'modal', 'input'], true) ? $mode : 'docked';
|
||||
$id = $attributes->get('id') ?? 'field-'.substr(md5($model.'|'.$label.'|datepicker'), 0, 12);
|
||||
$anchor = '--material-datepicker-'.preg_replace('/[^A-Za-z0-9_-]/', '-', $id);
|
||||
$messages = $model !== null && isset($errors)
|
||||
? array_values(array_unique(\Illuminate\Support\Arr::flatten([$errors->get($model), $errors->get($model.'.*')])))
|
||||
: [];
|
||||
$locale = str_replace('_', '-', app()->getLocale());
|
||||
|
||||
$toIso = function (mixed $date): ?string {
|
||||
if ($date instanceof \DateTimeInterface) {
|
||||
return $date->format('Y-m-d');
|
||||
}
|
||||
|
||||
if (is_string($date) && preg_match('/^(\d{4})-(\d{2})-(\d{2})(?:$|[T ])/', $date, $match) === 1 && checkdate((int) $match[2], (int) $match[3], (int) $match[1])) {
|
||||
return substr($date, 0, 10);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Rendered as the bound property already says, so the field does not change once Alpine starts.
|
||||
$current = $value;
|
||||
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
|
||||
$current = data_get($component, $model);
|
||||
}
|
||||
$current = $range
|
||||
? ['start' => $toIso(data_get($current, 'start')), 'end' => $toIso(data_get($current, 'end'))]
|
||||
: $toIso($current);
|
||||
|
||||
// The typed format as resources/js/datepicker.js derives it, from ICU's short date when PHP has intl.
|
||||
$pattern = 'yyyy-MM-dd';
|
||||
if (class_exists(\IntlDateFormatter::class)) {
|
||||
$short = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::SHORT, \IntlDateFormatter::NONE))->getPattern();
|
||||
$candidate = rtrim(str_replace('My', 'M/y', (string) preg_replace(['/[^dMy\/\-.]/', '/d{1,2}/', '/M{1,2}/', '/y{1,4}/'], ['', 'dd', 'MM', 'yyyy'], $short)), '.');
|
||||
if (preg_match('/^(?=.*dd)(?=.*MM)(?=.*yyyy)[dMy]+[\/\-.][dMy]+[\/\-.][dMy]+$/', $candidate) === 1) {
|
||||
$pattern = $candidate;
|
||||
}
|
||||
}
|
||||
$typed = fn (?string $date): string => $date === null ? '' : strtr($pattern, ['yyyy' => substr($date, 0, 4), 'MM' => substr($date, 5, 2), 'dd' => substr($date, 8, 2)]);
|
||||
$display = $range
|
||||
? ($current['start'] === null && $current['end'] === null ? '' : $typed($current['start']).' – '.$typed($current['end']))
|
||||
: $typed($current);
|
||||
|
||||
$title = $range ? __('Select dates') : __('Select date');
|
||||
$inputTitle = $range ? __('Enter dates') : __('Select date');
|
||||
$fieldName = $attributes->get('name');
|
||||
$invalid = $messages !== [];
|
||||
|
||||
$config = [
|
||||
'locale' => $locale,
|
||||
'mode' => $mode,
|
||||
'range' => (bool) $range,
|
||||
'min' => $toIso($min),
|
||||
'max' => $toIso($max),
|
||||
'disabled' => (bool) $attributes->get('disabled'),
|
||||
'readonly' => (bool) $attributes->get('readonly'),
|
||||
'strings' => [
|
||||
'selected' => __('Selected date'),
|
||||
'entered' => __('Entered date'),
|
||||
'start' => __('Start date'),
|
||||
'end' => __('End date'),
|
||||
'pattern' => __('Date does not match expected pattern: :pattern'),
|
||||
'yearRange' => __('Date out of expected year range :start - :end'),
|
||||
'notAllowed' => __('Date not allowed: :date'),
|
||||
'invalidRange' => __('Invalid date range input'),
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div
|
||||
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
|
||||
x-data="materialDatepicker({
|
||||
@if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
|
||||
...@js($config),
|
||||
})"
|
||||
@if ($model === null) x-modelable="value" @endif
|
||||
x-on:focusout="leave($event)"
|
||||
x-on:pointerdown.outside="open && presentation === 'docked' && cancel(false)"
|
||||
data-datepicker
|
||||
>
|
||||
<div style="anchor-name: {{ $anchor }}">
|
||||
<x-field
|
||||
:$id
|
||||
:$label
|
||||
:$icon
|
||||
:$size
|
||||
:$variant
|
||||
:data-invalid="$invalid ? '' : null"
|
||||
:data-readonly="$attributes->get('readonly') ? '' : null"
|
||||
x-bind:data-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
|
||||
>
|
||||
<input
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }}
|
||||
x-ref="input"
|
||||
id="{{ $id }}"
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="{{ $id }}-picker"
|
||||
aria-expanded="false"
|
||||
x-bind:aria-expanded="open.toString()"
|
||||
aria-describedby="{{ $id }}-support"
|
||||
@if ($invalid) aria-invalid="true" @endif
|
||||
x-bind:aria-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? 'true' : null"
|
||||
autocomplete="off"
|
||||
value="{{ $display }}"
|
||||
placeholder=" "
|
||||
class="field-control"
|
||||
x-model="text"
|
||||
@if ($mode === 'docked')
|
||||
x-bind:placeholder="placeholder"
|
||||
x-on:input="typeInField()"
|
||||
x-on:change="commitField()"
|
||||
x-on:keydown.enter="commitField()"
|
||||
x-on:click="show(false)"
|
||||
x-on:keydown.arrow-down.prevent="open ? focusInside() : show()"
|
||||
x-on:keydown.escape="if (open) { $event.preventDefault(); $event.stopPropagation(); cancel(); }"
|
||||
@else
|
||||
readonly
|
||||
x-on:click="show()"
|
||||
x-on:keydown.enter.prevent="show()"
|
||||
x-on:keydown.space.prevent="show()"
|
||||
x-on:keydown.arrow-down.prevent="show()"
|
||||
@endif
|
||||
/>
|
||||
|
||||
<x-slot:trailing>
|
||||
<button
|
||||
type="button"
|
||||
class="field-trailing field-button"
|
||||
aria-label="{{ $title }}"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="{{ $id }}-picker"
|
||||
x-bind:aria-expanded="open.toString()"
|
||||
x-on:click="open ? cancel() : show()"
|
||||
@if ($mode !== 'docked') tabindex="-1" @endif
|
||||
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
|
||||
data-datepicker-toggle
|
||||
>
|
||||
<x-icon name="calendar_today" class="size-(--field-icon)" />
|
||||
</button>
|
||||
</x-slot:trailing>
|
||||
</x-field>
|
||||
</div>
|
||||
|
||||
<div id="{{ $id }}-support" data-datepicker-support data-size="{{ in_array($size, ['sm', 'xs'], true) ? $size : 'md' }}">
|
||||
<p x-cloak x-show="fieldError !== ''" x-text="fieldError" role="alert" data-datepicker-error></p>
|
||||
|
||||
@if ($invalid)
|
||||
<div x-show="fieldError === ''" role="alert" data-datepicker-error>
|
||||
@foreach ($messages as $message)
|
||||
<p>{{ $message }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif (filled($hint))
|
||||
<p x-show="fieldError === ''">{{ $hint }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($fieldName))
|
||||
@if ($range)
|
||||
<input type="hidden" name="{{ $fieldName }}[start]" value="{{ $current['start'] }}" x-bind:value="serialised.start ?? ''" />
|
||||
<input type="hidden" name="{{ $fieldName }}[end]" value="{{ $current['end'] }}" x-bind:value="serialised.end ?? ''" />
|
||||
@else
|
||||
<input type="hidden" name="{{ $fieldName }}" value="{{ $current }}" x-bind:value="serialised" />
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<dialog
|
||||
wire:ignore
|
||||
x-ref="dialog"
|
||||
id="{{ $id }}-picker"
|
||||
popover="manual"
|
||||
aria-label="{{ $title }}"
|
||||
style="position-anchor: {{ $anchor }}"
|
||||
x-bind:data-presentation="presentation"
|
||||
x-on:cancel.prevent="cancel()"
|
||||
x-on:click.self="presentation === 'modal' && cancel()"
|
||||
x-on:keydown.escape.prevent.stop="cancel()"
|
||||
data-datepicker-picker
|
||||
>
|
||||
<div tabindex="-1" data-datepicker-surface>
|
||||
<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>
|
||||
|
||||
<div data-datepicker-headline-row>
|
||||
<p data-datepicker-headline aria-live="polite" x-text="headline"></p>
|
||||
|
||||
<span x-show="! typing">
|
||||
<x-button icon="edit" :tooltip="__('Switch to text input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
|
||||
</span>
|
||||
<span x-show="typing">
|
||||
<x-button icon="date_range" :tooltip="__('Switch to calendar input mode')" x-on:click="toggleTyping()" data-datepicker-switch />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="! typing" data-datepicker-calendar>
|
||||
<span id="{{ $id }}-month" class="sr-only" aria-live="polite" x-text="monthYear"></span>
|
||||
|
||||
<div data-datepicker-nav x-show="presentation === 'modal'">
|
||||
<button
|
||||
type="button"
|
||||
data-datepicker-menu-button
|
||||
x-on:click="toggleView('years')"
|
||||
x-bind:aria-expanded="(view === 'years').toString()"
|
||||
x-bind:aria-label="monthYear + ', ' + @js(__('Switch to selecting a year'))"
|
||||
>
|
||||
<span x-text="monthYear"></span>
|
||||
<x-icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
|
||||
</button>
|
||||
|
||||
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
|
||||
<x-button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" data-datepicker-previous />
|
||||
<x-button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" data-datepicker-next />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div data-datepicker-nav data-docked x-show="presentation === 'docked'">
|
||||
<span data-datepicker-stepper>
|
||||
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
|
||||
<x-button icon="chevron_left" :tooltip="__('Previous month')" x-on:click="step(-1)" x-bind:disabled="view !== 'days' || ! canStep(-1)" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-datepicker-menu-button
|
||||
x-on:click="toggleView('months')"
|
||||
x-bind:aria-expanded="(view === 'months').toString()"
|
||||
x-bind:aria-label="monthLabel + ', ' + @js(__('Switch to selecting a month'))"
|
||||
>
|
||||
<span x-text="monthLabel"></span>
|
||||
<x-icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
|
||||
</button>
|
||||
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
|
||||
<x-button icon="chevron_right" :tooltip="__('Next month')" x-on:click="step(1)" x-bind:disabled="view !== 'days' || ! canStep(1)" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span data-datepicker-stepper>
|
||||
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
|
||||
<x-button icon="chevron_left" :tooltip="__('Previous year')" x-on:click="step(-12)" x-bind:disabled="view !== 'days' || ! canStep(-12)" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-datepicker-menu-button
|
||||
x-on:click="toggleView('years')"
|
||||
x-bind:aria-expanded="(view === 'years').toString()"
|
||||
x-bind:aria-label="yearLabel + ', ' + @js(__('Switch to selecting a year'))"
|
||||
>
|
||||
<span x-text="yearLabel"></span>
|
||||
<x-icon name="arrow_drop_down" class="size-4.5" data-datepicker-menu-arrow />
|
||||
</button>
|
||||
<span data-datepicker-arrows x-bind:data-concealed="view !== 'days' ? '' : null">
|
||||
<x-button icon="chevron_right" :tooltip="__('Next year')" x-on:click="step(12)" x-bind:disabled="view !== 'days' || ! canStep(12)" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table role="grid" aria-labelledby="{{ $id }}-month" x-show="view === 'days'" x-on:keydown="gridKey($event)" data-datepicker-grid>
|
||||
<thead>
|
||||
<tr>
|
||||
<template x-for="weekday in weekdays" :key="weekday.long">
|
||||
<th scope="col" x-bind:abbr="weekday.long" x-text="weekday.narrow"></th>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="(week, row) in weeks" :key="row">
|
||||
<tr>
|
||||
<template x-for="(cell, column) in week" :key="column">
|
||||
<td
|
||||
role="gridcell"
|
||||
x-bind:tabindex="cell.blank ? null : (cell.focused ? 0 : -1)"
|
||||
x-bind:aria-label="cell.blank ? null : cell.name"
|
||||
x-bind:aria-selected="! cell.blank && (cell.selected || cell.between) ? 'true' : null"
|
||||
x-bind:aria-disabled="! cell.blank && cell.disabled ? 'true' : null"
|
||||
x-bind:aria-current="! cell.blank && cell.today ? 'date' : null"
|
||||
x-bind:data-value="cell.blank ? null : cell.value"
|
||||
x-bind:data-blank="cell.blank ? '' : null"
|
||||
x-bind:data-outside="cell.outside ? '' : null"
|
||||
x-bind:data-today="cell.today ? '' : null"
|
||||
x-bind:data-selected="cell.selected ? '' : null"
|
||||
x-bind:data-start="cell.start ? '' : null"
|
||||
x-bind:data-end="cell.end ? '' : null"
|
||||
x-bind:data-between="cell.between ? '' : null"
|
||||
x-on:click="choose(cell)"
|
||||
data-datepicker-day
|
||||
><span x-text="cell.blank ? '' : cell.label"></span></td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div role="listbox" aria-label="{{ __('Years') }}" x-show="view === 'years' && presentation === 'modal'" x-on:keydown="listKey($event, 3)" data-datepicker-list data-datepicker-years>
|
||||
<template x-for="year in years" :key="year.value">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
x-bind:aria-selected="year.selected.toString()"
|
||||
x-bind:tabindex="year.selected ? 0 : -1"
|
||||
x-bind:data-current="year.current ? '' : null"
|
||||
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
|
||||
x-text="year.label"
|
||||
data-datepicker-year
|
||||
></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div role="listbox" aria-label="{{ __('Months') }}" x-show="view === 'months' && presentation === 'docked'" x-on:keydown="listKey($event)" data-datepicker-list data-datepicker-menu>
|
||||
<template x-for="month in months" :key="month.value">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
x-bind:aria-selected="month.selected.toString()"
|
||||
x-bind:aria-disabled="month.disabled ? 'true' : null"
|
||||
x-bind:tabindex="month.selected ? 0 : -1"
|
||||
x-on:click="month.disabled || showMonthOf(+shown.slice(0, 4), month.value)"
|
||||
data-datepicker-option
|
||||
>
|
||||
<x-icon name="check" data-datepicker-check />
|
||||
<span x-text="month.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div role="listbox" aria-label="{{ __('Years') }}" x-show="view === 'years' && presentation === 'docked'" x-on:keydown="listKey($event)" data-datepicker-list data-datepicker-menu>
|
||||
<template x-for="year in years" :key="year.value">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
x-bind:aria-selected="year.selected.toString()"
|
||||
x-bind:tabindex="year.selected ? 0 : -1"
|
||||
x-on:click="showMonthOf(year.value, +shown.slice(5, 7))"
|
||||
data-datepicker-option
|
||||
>
|
||||
<x-icon name="check" data-datepicker-check />
|
||||
<span x-text="year.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="typing" data-datepicker-entry>
|
||||
<div @if ($range) data-range @endif data-datepicker-entry-fields>
|
||||
<x-field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
|
||||
<input
|
||||
id="{{ $id }}-entry"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder=" "
|
||||
x-bind:placeholder="format.placeholder"
|
||||
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
|
||||
aria-describedby="{{ $id }}-entry-support"
|
||||
x-ref="entry"
|
||||
x-model="entry"
|
||||
x-on:input="typeEntry()"
|
||||
x-on:keydown.enter.prevent="confirm()"
|
||||
class="field-control"
|
||||
/>
|
||||
</x-field>
|
||||
|
||||
@if ($range)
|
||||
<x-field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
|
||||
<input
|
||||
id="{{ $id }}-entry-end"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder=" "
|
||||
x-bind:placeholder="format.placeholder"
|
||||
x-bind:aria-invalid="entryError !== '' ? 'true' : null"
|
||||
aria-describedby="{{ $id }}-entry-support"
|
||||
x-model="entryEnd"
|
||||
x-on:input="typeEntry()"
|
||||
x-on:keydown.enter.prevent="confirm()"
|
||||
class="field-control"
|
||||
/>
|
||||
</x-field>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div id="{{ $id }}-entry-support" data-datepicker-support>
|
||||
<p x-show="entryError !== ''" x-text="entryError" role="alert" data-datepicker-error></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="view === 'days' || typing || presentation === 'modal'" data-datepicker-actions>
|
||||
<x-button :label="__('Cancel')" x-on:click="cancel()" data-datepicker-cancel />
|
||||
<x-button :label="__('OK')" x-on:click="confirm()" data-datepicker-confirm />
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
@@ -0,0 +1,362 @@
|
||||
{{-- A time: M3's time picker, opened from a text field.
|
||||
|
||||
<x-timepicker label="Starts at" wire:model="startsAt" />
|
||||
<x-timepicker label="Alarm" wire:model.live="alarm" format="24" step="5" min="06:00" max="22:00" clearable />
|
||||
|
||||
The field is read-only and opens the picker in a modal `<dialog>` on a press, Enter, Space or
|
||||
ArrowDown (the clock icon at its end opens it too). The picker is M3's dial: the hour and minute
|
||||
boxes on top, AM and PM beside them on a 12-hour clock, the clock face below. A press or a drag
|
||||
on the dial picks the hour, then it moves on to the minutes; the arrow keys change the value on
|
||||
the focused dial, Home and End go to the ends, Enter confirms, and Escape, Cancel or a press on
|
||||
the scrim close it without a change, handing focus back to the field. The keyboard icon swaps
|
||||
the dial for M3's input variant: two text fields, with the error under a field that holds
|
||||
something impossible. In a landscape window the dial lies on its side, as Compose lays it out.
|
||||
|
||||
`wire:model` (or `x-model`) holds the time as `H:i`, and null until one is chosen; a value with
|
||||
seconds (`09:30:00`, from a `time` column) is read, and written back as `H:i`. Nothing is written
|
||||
until OK. The draft starts at the bound time, or now. Errors are read from the bag under the
|
||||
`wire:model` name and replace the hint.
|
||||
|
||||
`format` is `12` or `24`; without it the hour cycle is the locale's (`locale`, the app locale by
|
||||
default) as `Intl.DateTimeFormat` reports it, and the field shows the time as the locale writes
|
||||
it. `step` is the minute step (a tap on the minute dial picks fives, or steps when five is not a
|
||||
multiple of the step; a drag and the arrows move by the step). `min` and `max` (`H:i`, inclusive;
|
||||
a `min` later than `max` spans midnight) grey out and skip what lies outside them and turn typed
|
||||
values outside them into errors — validate on the server as well. `clearable` adds a button that
|
||||
empties the field; `name` posts the value from a hidden input. `label`, `hint`, `icon`, `variant`
|
||||
and `size` are the field's; other attributes (`required`, `disabled`, `placeholder`) reach the
|
||||
field's input.
|
||||
|
||||
From androidx Compose Material 3 at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326 (Apache-2.0):
|
||||
TimePickerTokens and TimeInputTokens — a surface-container-high dialog with an extra-large
|
||||
corner and elevation 3, a 256dp surface-container-highest dial with body-large numbers, a primary
|
||||
selector with a 48dp handle, a 2dp line and an 8dp centre, 96×80 time selector boxes in
|
||||
display-large (primary-container when selected), 96×72 time fields in display-medium — and
|
||||
TimePicker.kt and TimePickerDialog.kt for the layout, the 24-hour inner ring (12–23, at 69dp;
|
||||
00–11 outside at 101dp, as Material Components for Android labels them too), the gestures, the
|
||||
move on to minutes and the error texts. The period selector is Compose's current default
|
||||
(`isUpdatedTimepickerToggleEnabled`): two separate shape-morphing toggle buttons in
|
||||
primary-container, not the outlined pair its tokens still describe. The dial's numbers are
|
||||
aria-hidden; the dial is a `slider` whose value text names the hour or minute. The dialog is
|
||||
`wire:ignore`, so a Livewire render never closes an open picker or resets its draft. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'hint' => null,
|
||||
'icon' => null,
|
||||
'variant' => null,
|
||||
'size' => 'md',
|
||||
'format' => null,
|
||||
'locale' => null,
|
||||
'min' => null,
|
||||
'max' => null,
|
||||
'step' => 1,
|
||||
'value' => null,
|
||||
'name' => null,
|
||||
'clearable' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
|
||||
$id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12);
|
||||
|
||||
$cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null;
|
||||
$locale = str_replace('_', '-', filled($locale) ? $locale : app()->getLocale());
|
||||
$interval = is_numeric($step) && (int) $step >= 1 && (int) $step <= 60 ? (int) $step : 1;
|
||||
$clock = fn (mixed $time): ?string => is_string($time) && preg_match('/^([01]?\d|2[0-3]):([0-5]\d)/', $time, $parts) === 1
|
||||
? sprintf('%02d:%02d', $parts[1], $parts[2])
|
||||
: null;
|
||||
$earliest = $clock($min);
|
||||
$latest = $clock($max);
|
||||
|
||||
// Rendered as the bound property already says, so the field is not empty until Alpine starts.
|
||||
$current = $value;
|
||||
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
|
||||
$current = data_get($component, $model);
|
||||
}
|
||||
$current = $clock($current);
|
||||
|
||||
// The first frame writes the time as the browser will; ICU decides the hour cycle on both sides.
|
||||
$display = '';
|
||||
if ($current !== null) {
|
||||
$time = \Carbon\CarbonImmutable::createFromFormat('!H:i', $current, 'UTC');
|
||||
$shown = $cycle;
|
||||
|
||||
if (class_exists(\IntlDatePatternGenerator::class)) {
|
||||
$generator = new \IntlDatePatternGenerator(str_replace('-', '_', $locale));
|
||||
$shown ??= preg_match('/[hK]/', preg_replace("/'[^']*'/", '', (string) $generator->getBestPattern('j'))) === 1 ? 12 : 24;
|
||||
$pattern = $generator->getBestPattern($shown === 12 ? 'hmm' : 'Hmm');
|
||||
$display = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', null, $pattern))->format($time);
|
||||
} else {
|
||||
$display = $shown === 12 ? $time->format('g:i A') : $time->format('H:i');
|
||||
}
|
||||
}
|
||||
|
||||
$config = [
|
||||
'locale' => $locale,
|
||||
'format' => $cycle,
|
||||
'min' => $earliest,
|
||||
'max' => $latest,
|
||||
'step' => $interval,
|
||||
'strings' => [
|
||||
'am' => __('AM'),
|
||||
'pm' => __('PM'),
|
||||
'oclock' => __(':hour o\'clock'),
|
||||
'hours' => __(':hour hours'),
|
||||
'minutes' => __(':minute minutes'),
|
||||
'hourError12' => __('Hour must be 1–12'),
|
||||
'hourError24' => __('Hour must be 0–23'),
|
||||
'minuteError' => __('Minute must be 0–59'),
|
||||
'stepError' => __('Minute must be a multiple of :step'),
|
||||
'between' => __('Choose a time from :min to :max'),
|
||||
'after' => __('Choose :min or later'),
|
||||
'before' => __('Choose :max or earlier'),
|
||||
],
|
||||
];
|
||||
|
||||
$constrained = $earliest !== null || $latest !== null || $interval > 1;
|
||||
|
||||
// The dial's numbers, placed round the ring from twelve o'clock (TimePicker.kt's CircularLayout).
|
||||
$spot = fn (int $index): string => sprintf('--x: %.4F; --y: %.4F', sin(deg2rad($index * 30)), -cos(deg2rad($index * 30)));
|
||||
$sets = [
|
||||
'hour12' => array_map(fn (int $index): array => ['value' => $index ?: 12, 'text' => $index ?: 12, 'index' => $index, 'inner' => false, 'allowed' => "hourAllowed({$index} + (isPm ? 12 : 0))"], range(0, 11)),
|
||||
'hour24' => [
|
||||
...array_map(fn (int $index): array => ['value' => $index, 'text' => $index === 0 ? '00' : $index, 'index' => $index, 'inner' => false, 'allowed' => "hourAllowed({$index})"], range(0, 11)),
|
||||
...array_map(fn (int $index): array => ['value' => $index + 12, 'text' => $index + 12, 'index' => $index, 'inner' => true, 'allowed' => 'hourAllowed('.($index + 12).')'], range(0, 11)),
|
||||
],
|
||||
'minute' => array_map(fn (int $index): array => ['value' => $index * 5, 'text' => $index === 0 ? '00' : $index * 5, 'index' => $index, 'inner' => false, 'allowed' => 'minuteAllowed('.($index * 5).')'], range(0, 11)),
|
||||
];
|
||||
|
||||
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']);
|
||||
$disabled = (bool) $attributes->get('disabled');
|
||||
$described = $messages !== [] || filled($hint);
|
||||
@endphp
|
||||
|
||||
<div
|
||||
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
|
||||
x-data="materialTimepicker(@if ($model !== null) @entangle($attributes->wire('model')) @else @js($current) @endif, @js($config))"
|
||||
@if ($model === null) x-modelable="value" @endif
|
||||
>
|
||||
<x-field :$id :$label :$hint :$messages :$icon :$size :$variant data-timepicker-field>
|
||||
<input
|
||||
{{ $inputAttributes }}
|
||||
id="{{ $id }}"
|
||||
type="text"
|
||||
readonly
|
||||
value="{{ $display }}"
|
||||
x-bind:value="display"
|
||||
x-ref="input"
|
||||
placeholder="{{ filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ' }}"
|
||||
autocomplete="off"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="{{ $id }}-dialog"
|
||||
aria-expanded="false"
|
||||
x-bind:aria-expanded="open.toString()"
|
||||
@if ($messages !== []) aria-invalid="true" @endif
|
||||
@if ($described) aria-describedby="{{ $id }}-support" @endif
|
||||
class="field-control"
|
||||
x-on:click="show()"
|
||||
x-on:keydown.enter.prevent="show()"
|
||||
x-on:keydown.space.prevent="show()"
|
||||
x-on:keydown.arrow-down.prevent="show()"
|
||||
/>
|
||||
|
||||
<x-slot:trailing>
|
||||
@if ($clearable)
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="value = null; $refs.input.focus()"
|
||||
class="field-trailing field-clear field-button"
|
||||
aria-label="{{ __('Clear') }}"
|
||||
data-field-clear
|
||||
@disabled($disabled)
|
||||
>
|
||||
<x-icon name="close" class="size-(--field-icon)" />
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<button
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
x-on:click="show()"
|
||||
class="field-trailing field-button"
|
||||
aria-label="{{ __('Choose time') }}"
|
||||
data-timepicker-open
|
||||
@disabled($disabled)
|
||||
>
|
||||
<x-icon name="schedule" class="size-(--field-icon)" />
|
||||
</button>
|
||||
</x-slot:trailing>
|
||||
</x-field>
|
||||
|
||||
@if (filled($name))
|
||||
<input type="hidden" name="{{ $name }}" value="{{ $current }}" x-bind:value="value ?? ''" />
|
||||
@endif
|
||||
|
||||
<dialog
|
||||
id="{{ $id }}-dialog"
|
||||
wire:ignore
|
||||
x-ref="dialog"
|
||||
aria-labelledby="{{ $id }}-title"
|
||||
data-timepicker-dialog
|
||||
x-on:close="closed()"
|
||||
x-on:pointerdown="scrimPressed = $event.target === $el"
|
||||
x-on:click.self="if (scrimPressed) cancel()"
|
||||
class="m-auto overflow-visible bg-transparent p-0 text-on-surface backdrop:bg-scrim/32 opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast"
|
||||
>
|
||||
<div data-timepicker-surface x-bind:data-mode="mode" data-mode="dial">
|
||||
<h2 id="{{ $id }}-title" data-timepicker-title x-text="mode === 'dial' ? @js(__('Select time')) : @js(__('Enter time'))">{{ __('Select time') }}</h2>
|
||||
|
||||
<div data-timepicker-picker>
|
||||
<div data-timepicker-display>
|
||||
<div data-timepicker-numbers>
|
||||
<button
|
||||
type="button"
|
||||
data-timepicker-box="hour"
|
||||
class="state-layer focus-ring"
|
||||
aria-pressed="true"
|
||||
x-bind:aria-pressed="(view === 'hour').toString()"
|
||||
x-bind:aria-label="@js(__('Select hour')) + ', ' + hourLabel"
|
||||
x-on:click="choose('hour')"
|
||||
x-text="hourLabel"
|
||||
></button>
|
||||
<span data-timepicker-separator aria-hidden="true">:</span>
|
||||
<button
|
||||
type="button"
|
||||
data-timepicker-box="minute"
|
||||
class="state-layer focus-ring"
|
||||
aria-pressed="false"
|
||||
x-bind:aria-pressed="(view === 'minute').toString()"
|
||||
x-bind:aria-label="@js(__('Select minutes')) + ', ' + minuteLabel"
|
||||
x-on:click="choose('minute')"
|
||||
x-text="minuteLabel"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<div data-timepicker-period role="group" aria-label="{{ __('Select AM or PM') }}" x-show="! is24">
|
||||
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
|
||||
<button
|
||||
type="button"
|
||||
data-timepicker-period-option="{{ $period }}"
|
||||
class="state-layer focus-ring"
|
||||
x-bind:aria-pressed="({{ $pm ? '' : '! ' }}isPm).toString()"
|
||||
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
|
||||
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
|
||||
x-text="periods[{{ (int) $pm }}]"
|
||||
>{{ $pm ? __('PM') : __('AM') }}</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-ref="dial"
|
||||
data-timepicker-dial
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuemin="0"
|
||||
x-bind:aria-label="view === 'hour' ? @js(__('Select hour')) : @js(__('Select minutes'))"
|
||||
x-bind:aria-valuemax="view === 'hour' ? 23 : 59"
|
||||
x-bind:aria-valuenow="view === 'hour' ? hour : minute"
|
||||
x-bind:aria-valuetext="valueText"
|
||||
x-bind:data-view="view"
|
||||
x-bind:data-cycle="is24 ? '24' : '12'"
|
||||
x-bind:data-inner="inner ? '' : null"
|
||||
x-bind:data-dragging="dragging ? '' : null"
|
||||
x-bind:style="{ '--timepicker-angle': angle + 'deg' }"
|
||||
x-on:pointerdown="press($event)"
|
||||
x-on:keydown="key($event)"
|
||||
>
|
||||
@foreach (['labels', 'ink'] as $layer)
|
||||
@if ($layer === 'ink')
|
||||
<div data-timepicker-selector aria-hidden="true">
|
||||
<span data-timepicker-track></span>
|
||||
<span data-timepicker-centre></span>
|
||||
<span data-timepicker-handle></span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div data-timepicker-{{ $layer }} aria-hidden="true">
|
||||
@foreach ($sets as $set => $labels)
|
||||
<div data-timepicker-set="{{ $set }}">
|
||||
@foreach ($labels as $spotLabel)
|
||||
<span
|
||||
@if ($layer === 'labels') data-timepicker-label="{{ $set }}" data-value="{{ $spotLabel['value'] }}" @endif
|
||||
@if ($spotLabel['inner']) data-inner @endif
|
||||
@if ($constrained && $layer === 'labels') x-bind:data-disabled="{{ $spotLabel['allowed'] }} ? null : ''" @endif
|
||||
style="{{ $spot($spotLabel['index']) }}"
|
||||
>{{ $spotLabel['text'] }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-timepicker-typing>
|
||||
<div data-timepicker-inputs>
|
||||
@foreach (['hour' => __('Hour'), 'minute' => __('Minute')] as $part => $partLabel)
|
||||
@if ($part === 'minute')
|
||||
<span data-timepicker-separator aria-hidden="true">:</span>
|
||||
@endif
|
||||
|
||||
<div data-timepicker-column>
|
||||
<input
|
||||
id="{{ $id }}-{{ $part }}"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="2"
|
||||
autocomplete="off"
|
||||
data-timepicker-input="{{ $part }}"
|
||||
x-ref="{{ $part }}Input"
|
||||
aria-label="{{ $partLabel }}"
|
||||
aria-describedby="{{ $id }}-{{ $part }}-support"
|
||||
x-bind:aria-invalid="{{ $part }}Error ? 'true' : null"
|
||||
x-bind:value="{{ $part }}Text"
|
||||
x-on:input="{{ $part === 'hour' ? 'typeHour' : 'typeMinute' }}($event)"
|
||||
x-on:focus="view = '{{ $part }}'; $el.select()"
|
||||
x-on:keydown.enter.prevent="confirm()"
|
||||
/>
|
||||
<p
|
||||
id="{{ $id }}-{{ $part }}-support"
|
||||
data-timepicker-support
|
||||
aria-live="polite"
|
||||
x-bind:data-error="{{ $part }}Error ? '' : null"
|
||||
x-text="{{ $part }}Error ?? @js($partLabel)"
|
||||
>{{ $partLabel }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<div data-timepicker-period role="group" aria-label="{{ __('Select AM or PM') }}" x-show="! is24">
|
||||
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
|
||||
<button
|
||||
type="button"
|
||||
data-timepicker-period-option="{{ $period }}"
|
||||
class="state-layer focus-ring"
|
||||
x-bind:aria-pressed="({{ $pm ? '' : '! ' }}isPm).toString()"
|
||||
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
|
||||
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
|
||||
x-text="periods[{{ (int) $pm }}]"
|
||||
>{{ $pm ? __('PM') : __('AM') }}</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p data-timepicker-range-error role="alert" x-show="rangeError" x-text="rangeError"></p>
|
||||
</div>
|
||||
|
||||
<div data-timepicker-actions>
|
||||
<span data-timepicker-when="dial">
|
||||
<x-button icon="keyboard" :tooltip="__('Switch to text input mode')" x-on:click="toggleMode()" data-timepicker-mode="input" />
|
||||
</span>
|
||||
<span data-timepicker-when="input">
|
||||
<x-button icon="schedule" :tooltip="__('Switch to clock mode')" x-on:click="toggleMode()" data-timepicker-mode="dial" />
|
||||
</span>
|
||||
<span data-timepicker-spacer></span>
|
||||
<x-button :label="__('Cancel')" x-on:click="cancel()" data-timepicker-cancel />
|
||||
<x-button :label="__('OK')" x-on:click="confirm()" data-timepicker-confirm />
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
@@ -21,6 +21,8 @@
|
||||
@include('livewire-material::showcase.sections.fields')
|
||||
@include('livewire-material::showcase.sections.chips')
|
||||
@include('livewire-material::showcase.sections.sliders')
|
||||
@include('livewire-material::showcase.sections.pickers')
|
||||
@include('livewire-material::showcase.sections.timepickers')
|
||||
@include('livewire-material::showcase.sections.bars')
|
||||
@include('livewire-material::showcase.sections.data')
|
||||
</main>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
||||
|
||||
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'bars' => 'Bars', 'data' => 'Data'] as $anchor => $section)
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders', 'pickers' => 'Date pickers', 'timepickers' => 'Time pickers', 'bars' => 'Bars', 'data' => 'Data'] as $anchor => $section)
|
||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
@php
|
||||
$examples = [
|
||||
'docked' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Expires on" x-model="expires" hint="Type a date or pick one" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p>
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Starts on" variant="filled" icon="event" x-model="starts" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(starts)"></code></p>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'modal and modal input' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ birthday: '1990-05-17', delivery: null }">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Birthday" mode="modal" x-model="birthday" :max="now()" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(birthday)"></code></p>
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Delivery" mode="input" x-model="delivery" hint="Opens on a text field" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(delivery)"></code></p>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'range' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md: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">
|
||||
<x-datepicker label="Trip" range x-model="trip" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(trip)"></code></p>
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Leave" range mode="modal" x-model="leave" />
|
||||
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(leave)"></code></p>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'limits, errors and states' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Within the next 30 days" :min="now()" :max="now()->addDays(30)" hint="Days outside are disabled" />
|
||||
<x-datepicker label="Required" required />
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-datepicker label="Disabled" value="2026-09-13" disabled />
|
||||
<x-datepicker label="Read-only" value="2026-09-13" readonly />
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<x-datepicker size="sm" aria-label="From" class="w-48" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="pickers" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Date pickers</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-datepicker></code> — docked, modal and modal input; single dates and ranges. Month and weekday names and the first day of the week follow the application's locale.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
</section>
|
||||
@@ -0,0 +1,44 @@
|
||||
@php
|
||||
$examples = [
|
||||
'Time pickers: 12 and 24 hours, outlined and filled' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-timepicker label="Meeting starts" value="09:30" hint="The locale's clock" />
|
||||
<x-timepicker label="Pick-up" value="17:15" format="12" icon="local_shipping" clearable />
|
||||
<x-timepicker label="Disabled" value="12:00" disabled />
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4">
|
||||
<x-timepicker variant="filled" label="Alarm" value="06:45" format="24" icon="alarm" />
|
||||
<x-timepicker variant="filled" label="Reminder" format="24" hint="Opens at the current time" />
|
||||
<x-timepicker variant="filled" label="Required" format="24" required />
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
'Time pickers: steps and limits' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6 md:grid-cols-2">
|
||||
<div class="grid content-start gap-4">
|
||||
<x-timepicker label="Appointment" value="10:00" format="24" step="15" min="08:00" max="17:30" hint="Quarter hours from 08:00 to 17:30" />
|
||||
<x-timepicker label="Night shift starts" value="23:00" format="24" min="22:00" max="06:00" hint="From 22:00, across midnight, to 06:00" />
|
||||
</div>
|
||||
|
||||
<div class="grid content-start gap-4" x-data="{ time: '14:05' }">
|
||||
<x-timepicker label="Bound with x-model" x-model="time" format="12" step="5" />
|
||||
<p class="type-body-md text-on-surface-variant">Stored as <code x-text="time ?? 'null'"></code></p>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="timepickers" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Time pickers</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-timepicker></code> — M3's dial and input time picker in a modal dialog, opened from a text field.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code />
|
||||
@endforeach
|
||||
</section>
|
||||
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class DateProbe extends Component
|
||||
{
|
||||
public ?string $expires = '2026-09-13';
|
||||
|
||||
public ?string $birthday = '2000-05-17';
|
||||
|
||||
public ?string $delivery = null;
|
||||
|
||||
/** @var array{start: ?string, end: ?string} */
|
||||
public array $trip = ['start' => '2026-09-13', 'end' => '2026-09-15'];
|
||||
|
||||
public int $renders = 0;
|
||||
|
||||
public function touch(): void
|
||||
{
|
||||
$this->renders++;
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="grid max-w-md gap-6 p-4">
|
||||
<p>expires: <span id="expires">{{ $expires }}</span></p>
|
||||
<p>birthday: <span id="birthday">{{ $birthday }}</span></p>
|
||||
<p>delivery: <span id="delivery">{{ $delivery }}</span></p>
|
||||
<p>trip: <span id="trip">{{ json_encode($trip) }}</span></p>
|
||||
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||||
|
||||
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" />
|
||||
<x-datepicker id="birthday-field" label="Birthday" mode="modal" wire:model.live="birthday" />
|
||||
<x-datepicker id="delivery-field" label="Delivery" mode="input" wire:model.live="delivery" min="2026-01-01" />
|
||||
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
function dateProbe(string $locale = 'en')
|
||||
{
|
||||
Livewire::component('date-probe', DateProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/date-probe/{locale}', function (string $locale) {
|
||||
app()->setLocale($locale);
|
||||
|
||||
return Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:date-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE);
|
||||
});
|
||||
|
||||
return visit("/date-probe/{$locale}")->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
/** A picker's day cell, by its ISO date. */
|
||||
function day(string $picker, string $date): string
|
||||
{
|
||||
return "#{$picker}-picker [data-value=\"{$date}\"]";
|
||||
}
|
||||
|
||||
function focusedDay(string $date): string
|
||||
{
|
||||
return "document.activeElement.dataset.value === '{$date}'";
|
||||
}
|
||||
|
||||
it('opens the docked picker under its field and walks the grid from the keyboard', function () {
|
||||
$picker = "document.querySelector('#expires-field-picker')";
|
||||
|
||||
$page = dateProbe()
|
||||
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'")
|
||||
->assertAttribute('#expires-field', 'aria-expanded', 'true')
|
||||
->assertScript("(() => { const field = document.querySelector('#expires-field').closest('.field-box').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()")
|
||||
->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14'));
|
||||
$page->keys(':focus', 'ArrowDown')->assertScript(focusedDay('2026-09-21'));
|
||||
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-26'));
|
||||
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-20'));
|
||||
$page->keys(':focus', 'ArrowUp')->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->keys(':focus', 'PageDown')
|
||||
->assertScript(focusedDay('2026-10-13'))
|
||||
->assertScript("document.querySelector('#expires-field-month').textContent === 'October 2026'");
|
||||
|
||||
$page->keys(':focus', 'Enter')
|
||||
->assertSeeIn('#expires', '2026-10-13')
|
||||
->assertScript("! {$picker}.matches(':popover-open')")
|
||||
->assertScript("document.activeElement.id === 'expires-field'")
|
||||
->assertValue('#expires-field', '10/13/2026');
|
||||
});
|
||||
|
||||
it('keeps focus and choice inside min and max', function () {
|
||||
$page = dateProbe()
|
||||
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
// Playwright will not press an aria-disabled cell, so the press is dispatched.
|
||||
$page->assertAttribute(day('expires-field', '2026-09-09'), 'aria-disabled', 'true')
|
||||
->script("document.querySelector('".day('expires-field', '2026-09-09')."').click()");
|
||||
|
||||
$page->assertScript("! document.querySelector('".day('expires-field', '2026-09-09')."').hasAttribute('data-selected')")
|
||||
->assertScript("document.querySelector('".day('expires-field', '2026-09-13')."').hasAttribute('data-selected')");
|
||||
|
||||
$page->keys(':focus', 'PageUp')->assertScript(focusedDay('2026-09-10'));
|
||||
$page->keys(':focus', 'ArrowLeft')->assertScript(focusedDay('2026-09-10'));
|
||||
$page->keys(':focus', 'Shift+PageDown')->assertScript(focusedDay('2026-10-20'));
|
||||
|
||||
$page->keys(':focus', 'Enter')->assertSeeIn('#expires', '2026-10-20');
|
||||
});
|
||||
|
||||
it('takes a date typed into the docked field once it is whole, and says when it cannot', function () {
|
||||
$page = dateProbe()
|
||||
->type('#expires-field', '09/15/2026')
|
||||
->assertSeeIn('#expires', '2026-09-15');
|
||||
|
||||
$page->type('#expires-field', '13/45/2026')
|
||||
->keys('#expires-field', 'Enter')
|
||||
->assertSee('Date does not match expected pattern: MM/DD/YYYY')
|
||||
->assertAttribute('#expires-field', 'aria-invalid', 'true')
|
||||
->assertSeeIn('#expires', '2026-09-15');
|
||||
|
||||
$page->type('#expires-field', '09/01/2026')
|
||||
->keys('#expires-field', 'Enter')
|
||||
->assertSee('Date not allowed: Sep 1, 2026')
|
||||
->assertSeeIn('#expires', '2026-09-15');
|
||||
});
|
||||
|
||||
it('opens the modal picker, jumps through the year grid and confirms with OK', function () {
|
||||
$dialog = "document.querySelector('#birthday-field-picker')";
|
||||
|
||||
$page = dateProbe()
|
||||
->click('#birthday-field')
|
||||
->assertScript("{$dialog}.matches(':modal')")
|
||||
->assertSeeIn('#birthday-field-picker [data-datepicker-headline]', 'May 17, 2000')
|
||||
->click('#birthday-field-picker [data-datepicker-nav]:not([data-docked]) [data-datepicker-menu-button]')
|
||||
->assertScript("getComputedStyle(document.querySelector('#birthday-field-picker [data-datepicker-years]')).display !== 'none'")
|
||||
->assertScript("document.activeElement.textContent.trim() === '2000'");
|
||||
|
||||
$page->click('#birthday-field-picker [data-datepicker-year]:has-text("1990")')
|
||||
->assertSeeIn('#birthday-field-picker [data-datepicker-nav]', 'May 1990')
|
||||
->click(day('birthday-field', '1990-05-03'))
|
||||
->assertSeeIn('#birthday-field-picker [data-datepicker-headline]', 'May 3, 1990')
|
||||
->assertSeeIn('#birthday', '2000-05-17');
|
||||
|
||||
$page->click('#birthday-field-picker [data-datepicker-confirm]')
|
||||
->assertSeeIn('#birthday', '1990-05-03')
|
||||
->assertScript("! {$dialog}.open");
|
||||
});
|
||||
|
||||
it('closes on Escape without keeping the draft and hands focus back to the field', function () {
|
||||
$dialog = "document.querySelector('#birthday-field-picker')";
|
||||
|
||||
$page = dateProbe();
|
||||
|
||||
$page->script("document.querySelector('#birthday-field').focus()");
|
||||
|
||||
$page->keys('#birthday-field', 'Enter')
|
||||
->assertScript("{$dialog}.open")
|
||||
->assertScript(focusedDay('2000-05-17'));
|
||||
|
||||
$page->keys(':focus', 'ArrowRight')
|
||||
->keys(':focus', 'Space')
|
||||
->assertSeeIn('#birthday-field-picker [data-datepicker-headline]', 'May 18, 2000');
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertScript("! {$dialog}.open")
|
||||
->assertScript("document.activeElement.id === 'birthday-field'")
|
||||
->assertSeeIn('#birthday', '2000-05-17');
|
||||
|
||||
// The docked picker too.
|
||||
$page->script("document.querySelector('[aria-controls=\"expires-field-picker\"][data-datepicker-toggle]').focus()");
|
||||
|
||||
$page->keys(':focus', 'Enter')
|
||||
->assertScript("document.querySelector('#expires-field-picker').matches(':popover-open')")
|
||||
->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')")
|
||||
->assertScript("document.activeElement.id === 'expires-field'");
|
||||
});
|
||||
|
||||
it('reads the modal input in the locale\'s format and explains a date it cannot take', function () {
|
||||
$dialog = "document.querySelector('#delivery-field-picker')";
|
||||
|
||||
$page = dateProbe()
|
||||
->click('#delivery-field')
|
||||
->assertScript("{$dialog}.matches(':modal')")
|
||||
->assertScript("document.activeElement.id === 'delivery-field-entry'")
|
||||
->assertSeeIn('#delivery-field-picker [data-datepicker-headline]', 'Entered date');
|
||||
|
||||
$page->type('#delivery-field-entry', '2026-03-07')
|
||||
->click('#delivery-field-picker [data-datepicker-confirm]')
|
||||
->assertSeeIn('#delivery-field-entry-support', 'Date does not match expected pattern: MM/DD/YYYY')
|
||||
->assertAttribute('#delivery-field-entry', 'aria-invalid', 'true')
|
||||
->assertScript("{$dialog}.open");
|
||||
|
||||
$page->type('#delivery-field-entry', '12/24/2025')
|
||||
->keys('#delivery-field-entry', 'Enter')
|
||||
->assertSeeIn('#delivery-field-entry-support', 'Date not allowed: Dec 24, 2025')
|
||||
->assertScript("{$dialog}.open");
|
||||
|
||||
$page->type('#delivery-field-entry', '3/7/2026')
|
||||
->assertSeeIn('#delivery-field-picker [data-datepicker-headline]', 'Mar 7, 2026')
|
||||
->keys('#delivery-field-entry', 'Enter')
|
||||
->assertSeeIn('#delivery', '2026-03-07')
|
||||
->assertScript("! {$dialog}.open")
|
||||
->assertValue('#delivery-field', '03/07/2026');
|
||||
});
|
||||
|
||||
it('picks a range: a start, an end, and the days between', function () {
|
||||
$page = dateProbe()
|
||||
->click('[aria-controls="trip-field-picker"][data-datepicker-toggle]')
|
||||
->assertAttribute(day('trip-field', '2026-09-14'), 'aria-selected', 'true');
|
||||
|
||||
$page->click(day('trip-field', '2026-09-20'))
|
||||
->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-selected')")
|
||||
->assertScript("! document.querySelector('".day('trip-field', '2026-09-14')."').hasAttribute('data-between')");
|
||||
|
||||
$page->click(day('trip-field', '2026-09-24'))
|
||||
->assertScript("[...document.querySelectorAll('#trip-field-picker [data-between]')].map((cell) => cell.dataset.value).join() === '2026-09-21,2026-09-22,2026-09-23'")
|
||||
->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-start') && document.querySelector('".day('trip-field', '2026-09-24')."').hasAttribute('data-end')")
|
||||
->assertSeeIn('#trip', '2026-09-15');
|
||||
|
||||
$page->click('#trip-field-picker [data-datepicker-confirm]')
|
||||
->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}')
|
||||
->assertValue('#trip-field', '09/20/2026 – 09/24/2026');
|
||||
});
|
||||
|
||||
it('follows the locale: German weeks start on Monday, American ones on Sunday', function () {
|
||||
$firstWeekday = "document.querySelector('#expires-field-picker thead th').getAttribute('abbr')";
|
||||
|
||||
$page = dateProbe('de')
|
||||
->assertValue('#expires-field', '13.09.2026')
|
||||
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript("{$firstWeekday} === 'Montag'")
|
||||
->assertScript("document.querySelector('#expires-field-month').textContent === 'September 2026'")
|
||||
->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-10'));
|
||||
$page->keys(':focus', 'Escape');
|
||||
|
||||
$page->type('#expires-field', '20.9.2026')
|
||||
->assertSeeIn('#expires', '2026-09-20')
|
||||
->assertAttribute('#expires-field', 'placeholder', 'DD.MM.YYYY');
|
||||
|
||||
dateProbe('en_US')
|
||||
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript("{$firstWeekday} === 'Sunday'");
|
||||
});
|
||||
|
||||
it('stays open, where it was, through a Livewire render', function () {
|
||||
$page = dateProbe()
|
||||
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript(focusedDay('2026-09-13'));
|
||||
|
||||
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14'));
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->assertScript("document.querySelector('#expires-field-picker').matches(':popover-open')")
|
||||
->assertAttribute('#expires-field', 'aria-expanded', 'true')
|
||||
->assertScript("document.querySelector('".day('expires-field', '2026-09-14')."').tabIndex === 0");
|
||||
|
||||
$page->keys(day('expires-field', '2026-09-14'), 'Enter')->assertSeeIn('#expires', '2026-09-14');
|
||||
|
||||
$page->click('#birthday-field')
|
||||
->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '2')
|
||||
->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')");
|
||||
});
|
||||
|
||||
it('opens a docked picker as a modal one on a compact window', function () {
|
||||
dateProbe()
|
||||
->resize(400, 800)
|
||||
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
|
||||
->assertScript("document.querySelector('#expires-field-picker').matches(':modal')")
|
||||
->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-datepicker-header]')).display !== 'none'");
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class TimeProbe extends Component
|
||||
{
|
||||
public ?string $meeting = '09:30';
|
||||
|
||||
public ?string $alarm = null;
|
||||
|
||||
public ?string $appointment = '10:00';
|
||||
|
||||
public int $renders = 0;
|
||||
|
||||
public function touch(): void
|
||||
{
|
||||
$this->renders++;
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="grid max-w-md gap-6 p-4">
|
||||
<p>meeting: <span id="meeting-value">{{ $meeting ?? 'null' }}</span></p>
|
||||
<p>alarm: <span id="alarm-value">{{ $alarm ?? 'null' }}</span></p>
|
||||
<p>appointment: <span id="slot-value">{{ $appointment ?? 'null' }}</span></p>
|
||||
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||||
|
||||
<x-timepicker id="meeting" label="Meeting" wire:model.live="meeting" format="12" />
|
||||
<x-timepicker id="alarm" label="Alarm" wire:model.live="alarm" locale="de" />
|
||||
<x-timepicker id="slot" label="Slot" wire:model.live="appointment" format="24" step="15" min="09:00" max="17:00" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
function timeProbe()
|
||||
{
|
||||
Livewire::component('time-probe', TimeProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/time-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:time-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/time-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
/** A JavaScript expression for an element inside the named picker's dialog. */
|
||||
function inPicker(string $id, string $selector): string
|
||||
{
|
||||
return "document.querySelector('#{$id}-dialog {$selector}')";
|
||||
}
|
||||
|
||||
/** A JavaScript expression for the text under a time field, without its word joiners. */
|
||||
function supportText(string $id): string
|
||||
{
|
||||
return "document.querySelector('#{$id}-support').textContent.replaceAll('\\u2060', '')";
|
||||
}
|
||||
|
||||
it('opens the dial on the hour, as the bound time says', function () {
|
||||
timeProbe()
|
||||
->assertValue('#meeting', '9:30 AM')
|
||||
->click('#meeting')
|
||||
->assertScript("document.querySelector('#meeting-dialog').open")
|
||||
->assertAttribute('#meeting', 'aria-expanded', 'true')
|
||||
->assertSeeIn('#meeting-title', 'Select time')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-box="hour"]', 'aria-pressed', 'true')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '09')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="minute"]', '30')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-display] [data-timepicker-period-option="am"]', 'aria-pressed', 'true')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuenow', '9')
|
||||
->assertScript('document.activeElement === '.inPicker('meeting', '[data-timepicker-dial]'));
|
||||
});
|
||||
|
||||
it('writes the hour and the minute pressed on the dial to Livewire on OK', function () {
|
||||
$dial = inPicker('meeting', '[data-timepicker-dial]');
|
||||
|
||||
$page = timeProbe()
|
||||
->click('#meeting')
|
||||
->click('#meeting-dialog [data-timepicker-label="hour12"][data-value="3"]')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '03')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-box="minute"]', 'aria-pressed', 'true')
|
||||
// The selector turns to the value it chose: the handle ends over the number.
|
||||
->assertScript(<<<JS
|
||||
(() => {
|
||||
const handle = {$dial}.querySelector('[data-timepicker-handle]').getBoundingClientRect();
|
||||
const label = {$dial}.querySelector('[data-timepicker-label="minute"][data-value="30"]').getBoundingClientRect();
|
||||
|
||||
return Math.abs(handle.x - label.x) < 1 && Math.abs(handle.y - label.y) < 1;
|
||||
})()
|
||||
JS);
|
||||
|
||||
$page->click('#meeting-dialog [data-timepicker-label="minute"][data-value="45"]')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="minute"]', '45')
|
||||
->assertSeeIn('#meeting-value', '09:30')
|
||||
->click('#meeting-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#meeting-value', '03:45')
|
||||
->assertValue('#meeting', '3:45 AM')
|
||||
->assertScript("! document.querySelector('#meeting-dialog').open")
|
||||
->assertAttribute('#meeting', 'aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('leaves the time alone when the picker is cancelled', function () {
|
||||
timeProbe()
|
||||
->click('#meeting')
|
||||
->click('#meeting-dialog [data-timepicker-label="hour12"][data-value="7"]')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '07')
|
||||
->click('#meeting-dialog [data-timepicker-cancel]')
|
||||
->assertScript("! document.querySelector('#meeting-dialog').open")
|
||||
->assertSeeIn('#meeting-value', '09:30')
|
||||
->assertValue('#meeting', '9:30 AM');
|
||||
});
|
||||
|
||||
it('turns three o\'clock into 15 with PM', function () {
|
||||
timeProbe()
|
||||
->click('#meeting')
|
||||
->click('#meeting-dialog [data-timepicker-display] [data-timepicker-period-option="pm"]')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-display] [data-timepicker-period-option="pm"]', 'aria-pressed', 'true')
|
||||
->click('#meeting-dialog [data-timepicker-label="hour12"][data-value="3"]')
|
||||
->click('#meeting-dialog [data-timepicker-label="minute"][data-value="15"]')
|
||||
->click('#meeting-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#meeting-value', '15:15')
|
||||
->assertValue('#meeting', '3:15 PM');
|
||||
});
|
||||
|
||||
it('draws a German dial with 24 hours, 12 to 23 on the inner ring', function () {
|
||||
$dial = inPicker('alarm', '[data-timepicker-dial]');
|
||||
$radius = fn (string $value): string => <<<JS
|
||||
(() => {
|
||||
const dial = {$dial}.getBoundingClientRect();
|
||||
const label = {$dial}.querySelector('[data-timepicker-label="hour24"][data-value="{$value}"]').getBoundingClientRect();
|
||||
|
||||
return Math.round(Math.hypot(label.x + label.width / 2 - dial.x - dial.width / 2, label.y + label.height / 2 - dial.y - dial.height / 2));
|
||||
})()
|
||||
JS;
|
||||
|
||||
$page = timeProbe()
|
||||
->click('#alarm')
|
||||
->assertAttribute('#alarm-dialog [data-timepicker-dial]', 'data-cycle', '24')
|
||||
->assertScript("getComputedStyle(document.querySelector('#alarm-dialog [data-timepicker-period]')).display === 'none'")
|
||||
->assertScript("{$dial}.querySelector('[data-timepicker-label=\"hour24\"][data-value=\"0\"]').textContent.trim() === '00'")
|
||||
->assertScript($radius('0').' === 101')
|
||||
->assertScript($radius('11').' === 101')
|
||||
->assertScript($radius('12').' === 69')
|
||||
->assertScript($radius('13').' === 69')
|
||||
->assertScript($radius('23').' === 69');
|
||||
|
||||
$page->click('#alarm-dialog [data-timepicker-label="hour24"][data-value="15"]')
|
||||
->assertSeeIn('#alarm-dialog [data-timepicker-box="hour"]', '15')
|
||||
->assertAttribute('#alarm-dialog [data-timepicker-dial]', 'data-view', 'minute')
|
||||
->click('#alarm-dialog [data-timepicker-label="minute"][data-value="0"]')
|
||||
->click('#alarm-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#alarm-value', '15:00')
|
||||
->assertValue('#alarm', '15:00');
|
||||
|
||||
// The outer ring is the morning.
|
||||
$page->click('#alarm')
|
||||
->click('#alarm-dialog [data-timepicker-label="hour24"][data-value="3"]')
|
||||
->assertSeeIn('#alarm-dialog [data-timepicker-box="hour"]', '03')
|
||||
->click('#alarm-dialog [data-timepicker-label="minute"][data-value="0"]')
|
||||
->click('#alarm-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#alarm-value', '03:00');
|
||||
});
|
||||
|
||||
it('follows a drag round the dial and settles on the nearest hour', function () {
|
||||
$dial = inPicker('meeting', '[data-timepicker-dial]');
|
||||
|
||||
$page = timeProbe()->click('#meeting');
|
||||
|
||||
// Pointer events built in the page's own realm, from twelve o'clock round to just past four.
|
||||
$send = fn (string $type, int $degrees): string => <<<JS
|
||||
window.eval(`(() => {
|
||||
const dial = document.querySelector('#meeting-dialog [data-timepicker-dial]');
|
||||
const box = dial.getBoundingClientRect();
|
||||
const radians = {$degrees} * Math.PI / 180;
|
||||
|
||||
dial.dispatchEvent(new PointerEvent('{$type}', {
|
||||
bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1,
|
||||
clientX: box.x + box.width / 2 + Math.sin(radians) * 90,
|
||||
clientY: box.y + box.height / 2 - Math.cos(radians) * 90,
|
||||
}));
|
||||
})()`)
|
||||
JS;
|
||||
|
||||
$page->script($send('pointerdown', 0).';'.$send('pointermove', 20).';'.$send('pointermove', 60).';'.$send('pointermove', 125));
|
||||
|
||||
// While dragging, the handle stays under the pointer rather than on a number.
|
||||
$page->assertScript("{$dial}.hasAttribute('data-dragging') && parseFloat(getComputedStyle({$dial}).getPropertyValue('--timepicker-angle')) % 360 === 125")
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '04');
|
||||
|
||||
$page->script($send('pointerup', 125));
|
||||
|
||||
$page->assertScript("! {$dial}.hasAttribute('data-dragging')")
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '04');
|
||||
});
|
||||
|
||||
it('changes the focused hour and minute with the arrow keys and confirms with Enter', function () {
|
||||
$page = timeProbe();
|
||||
|
||||
$page->script("document.querySelector('#meeting').focus()");
|
||||
|
||||
$page->keys('#meeting', 'Enter')
|
||||
->assertScript('document.activeElement === '.inPicker('meeting', '[data-timepicker-dial]'))
|
||||
->keys(':focus', 'ArrowUp')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuenow', '10')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '10')
|
||||
// The keyboard never moves on to the minutes by itself.
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'hour');
|
||||
|
||||
$page->script(inPicker('meeting', '[data-timepicker-box="minute"]').'.focus()');
|
||||
|
||||
$page->keys(':focus', 'Enter')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute');
|
||||
|
||||
$page->script(inPicker('meeting', '[data-timepicker-dial]').'.focus()');
|
||||
|
||||
$page->keys(':focus', ['ArrowDown', 'ArrowLeft'])
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuenow', '28')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuetext', '28 minutes')
|
||||
->keys(':focus', 'Enter')
|
||||
->assertScript("! document.querySelector('#meeting-dialog').open")
|
||||
->assertSeeIn('#meeting-value', '10:28');
|
||||
});
|
||||
|
||||
it('takes a typed time in the input variant and says what is wrong with it', function () {
|
||||
$page = timeProbe()
|
||||
->click('#meeting')
|
||||
->click('#meeting-dialog [data-timepicker-mode="input"]')
|
||||
->assertSeeIn('#meeting-title', 'Enter time')
|
||||
->assertScript("document.activeElement.id === 'meeting-hour'")
|
||||
->assertValue('#meeting-hour', '09')
|
||||
->assertValue('#meeting-minute', '30');
|
||||
|
||||
$page->type('#meeting-hour', '13')
|
||||
->assertAttribute('#meeting-hour', 'aria-invalid', 'true')
|
||||
// Word joiners keep the range on one line in the narrow column.
|
||||
->assertScript(supportText('meeting-hour')." === 'Hour must be 1–12'")
|
||||
->click('#meeting-dialog [data-timepicker-confirm]')
|
||||
->assertScript("document.querySelector('#meeting-dialog').open")
|
||||
->assertSeeIn('#meeting-value', '09:30');
|
||||
|
||||
$page->clear('#meeting-hour')
|
||||
->typeSlowly('#meeting-hour', '11', 20)
|
||||
->assertScript("document.activeElement.id === 'meeting-minute'")
|
||||
->assertScript(supportText('meeting-hour')." === 'Hour'")
|
||||
->assertScript("! document.querySelector('#meeting-hour').hasAttribute('aria-invalid')");
|
||||
|
||||
$page->clear('#meeting-minute')
|
||||
->typeSlowly('#meeting-minute', '75', 20)
|
||||
->assertScript(supportText('meeting-minute')." === 'Minute must be 0–59'")
|
||||
->clear('#meeting-minute')
|
||||
->typeSlowly('#meeting-minute', '05', 20)
|
||||
->keys('#meeting-minute', 'Enter')
|
||||
->assertScript("! document.querySelector('#meeting-dialog').open")
|
||||
->assertSeeIn('#meeting-value', '11:05');
|
||||
});
|
||||
|
||||
it('keeps to the step and the limits', function () {
|
||||
$dial = inPicker('slot', '[data-timepicker-dial]');
|
||||
|
||||
$page = timeProbe()
|
||||
->click('#slot')
|
||||
->assertScript("{$dial}.querySelector('[data-timepicker-label=\"hour24\"][data-value=\"8\"]').hasAttribute('data-disabled')")
|
||||
->assertScript("! {$dial}.querySelector('[data-timepicker-label=\"hour24\"][data-value=\"9\"]').hasAttribute('data-disabled')")
|
||||
->assertScript("{$dial}.querySelector('[data-timepicker-label=\"hour24\"][data-value=\"18\"]').hasAttribute('data-disabled')")
|
||||
// An hour outside the limits is not taken.
|
||||
->click('#slot-dialog [data-timepicker-label="hour24"][data-value="20"]')
|
||||
->assertSeeIn('#slot-dialog [data-timepicker-box="hour"]', '10')
|
||||
->click('#slot-dialog [data-timepicker-label="hour24"][data-value="16"]')
|
||||
->assertAttribute('#slot-dialog [data-timepicker-dial]', 'data-view', 'minute')
|
||||
->assertScript("{$dial}.querySelector('[data-timepicker-label=\"minute\"][data-value=\"20\"]').hasAttribute('data-disabled')")
|
||||
// A press between the steps lands on the nearest one.
|
||||
->click('#slot-dialog [data-timepicker-label="minute"][data-value="20"]')
|
||||
->assertSeeIn('#slot-dialog [data-timepicker-box="minute"]', '15');
|
||||
|
||||
$page->script("{$dial}.focus()");
|
||||
|
||||
$page->keys(':focus', 'ArrowUp')
|
||||
->assertAttribute('#slot-dialog [data-timepicker-dial]', 'aria-valuenow', '30')
|
||||
->click('#slot-dialog [data-timepicker-mode="input"]')
|
||||
->type('#slot-hour', '18')
|
||||
->assertSeeIn('#slot-dialog [data-timepicker-range-error]', 'Choose a time from 09:00 to 17:00')
|
||||
->type('#slot-hour', '16')
|
||||
->type('#slot-minute', '20')
|
||||
->assertSeeIn('#slot-minute-support', 'Minute must be a multiple of 15')
|
||||
->click('#slot-dialog [data-timepicker-confirm]')
|
||||
->assertScript("document.activeElement.id === 'slot-minute'")
|
||||
->type('#slot-minute', '45')
|
||||
->click('#slot-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#slot-value', '16:45');
|
||||
});
|
||||
|
||||
it('gives focus back to the field on Escape', function () {
|
||||
$page = timeProbe();
|
||||
|
||||
$page->script("document.querySelector('#meeting').focus()");
|
||||
|
||||
$page->keys('#meeting', 'Enter')
|
||||
->assertScript("document.querySelector('#meeting-dialog').open")
|
||||
->keys(':focus', 'ArrowUp')
|
||||
->keys(':focus', 'Escape')
|
||||
->assertScript("! document.querySelector('#meeting-dialog').open")
|
||||
->assertScript("document.activeElement.id === 'meeting'")
|
||||
->assertSeeIn('#meeting-value', '09:30');
|
||||
});
|
||||
|
||||
it('stays open with its draft through a Livewire render', function () {
|
||||
$page = timeProbe()
|
||||
->click('#meeting')
|
||||
->click('#meeting-dialog [data-timepicker-label="hour12"][data-value="5"]')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->assertScript("document.querySelector('#meeting-dialog').open")
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '05')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute')
|
||||
->click('#meeting-dialog [data-timepicker-label="minute"][data-value="10"]')
|
||||
->click('#meeting-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#meeting-value', '05:10');
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* The JSON config handed to `materialDatepicker(…)`, decoded.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function datepickerConfig(string $html): array
|
||||
{
|
||||
preg_match("/\.\.\.JSON\.parse\('(.+?)'\)/", $html, $match);
|
||||
|
||||
return json_decode(json_decode('"'.$match[1].'"'), true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
it('draws a docked date field whose combobox controls a popover picker', function () {
|
||||
$html = (string) $this->blade('<x-datepicker id="expires" label="Expires on" hint="Type a date or pick one" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-datepicker')
|
||||
->toContain('<label for="expires" class="field-label">Expires on</label>')
|
||||
->toContain('role="combobox"')
|
||||
->toContain('aria-haspopup="dialog"')
|
||||
->toContain('aria-controls="expires-picker"')
|
||||
->toContain('aria-expanded="false"')
|
||||
->toContain('aria-describedby="expires-support"')
|
||||
->toContain('Type a date or pick one')
|
||||
->toContain('<dialog')
|
||||
->toContain('id="expires-picker"')
|
||||
->toContain('popover="manual"')
|
||||
->toContain('wire:ignore')
|
||||
->toContain('aria-label="Select date"')
|
||||
->toContain('role="grid"')
|
||||
->toContain('data-datepicker-toggle')
|
||||
->toContain('x-modelable="value"')
|
||||
->toContain('x-on:input="typeInField()"')
|
||||
->not->toMatch('/<input[^>]*\sreadonly[\s>]/')
|
||||
->not->toContain('aria-invalid="true"')
|
||||
->and(datepickerConfig($html))->toMatchArray(['mode' => 'docked', 'range' => false, 'min' => null, 'max' => null, 'locale' => 'en'])
|
||||
->and($html)->toContain('style="anchor-name: --material-datepicker-expires"')
|
||||
->toContain('style="position-anchor: --material-datepicker-expires"');
|
||||
});
|
||||
|
||||
it('makes the field a read-only trigger for the modal and modal input pickers', function (string $mode) {
|
||||
$html = (string) $this->blade('<x-datepicker label="Birthday" :mode="$mode" />', ['mode' => $mode]);
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<input[^>]*\sreadonly[\s>]/')
|
||||
->toContain('x-on:keydown.space.prevent="show()"')
|
||||
->not->toContain('x-on:input="typeInField()"')
|
||||
->and(datepickerConfig($html)['mode'])->toBe($mode);
|
||||
})->with(['modal', 'input']);
|
||||
|
||||
it('falls back to the docked picker for an unknown mode', function () {
|
||||
expect(datepickerConfig((string) $this->blade('<x-datepicker label="Date" mode="wheel" />'))['mode'])->toBe('docked');
|
||||
});
|
||||
|
||||
it('hands min, max and the application locale to the picker as Y-m-d', function () {
|
||||
app()->setLocale('de_CH');
|
||||
|
||||
$config = datepickerConfig((string) $this->blade(
|
||||
'<x-datepicker label="Date" :min="$min" max="2026-12-31 23:59:00" />',
|
||||
['min' => CarbonImmutable::parse('2026-09-13 08:30')],
|
||||
));
|
||||
|
||||
expect($config)->toMatchArray(['min' => '2026-09-13', 'max' => '2026-12-31', 'locale' => 'de-CH'])
|
||||
->and($config['strings']['pattern'])->toBe('Date does not match expected pattern: :pattern')
|
||||
->and(datepickerConfig((string) $this->blade('<x-datepicker label="Date" min="2026-02-30" max="soon" />')))
|
||||
->toMatchArray(['min' => null, 'max' => null]);
|
||||
});
|
||||
|
||||
it('shows the value in the locale\'s numeric format before Alpine starts', function () {
|
||||
expect((string) $this->blade('<x-datepicker label="Date" value="2026-09-13" name="expires" />'))
|
||||
->toContain('value="09/13/2026"')
|
||||
->toContain('<input type="hidden" name="expires" value="2026-09-13"');
|
||||
|
||||
app()->setLocale('de');
|
||||
|
||||
expect((string) $this->blade('<x-datepicker label="Trip" range :value="[\'start\' => \'2026-09-13\', \'end\' => \'2026-09-20\']" name="trip" />'))
|
||||
->toContain('value="13.09.2026 – 20.09.2026"')
|
||||
->toContain('name="trip[start]" value="2026-09-13"')
|
||||
->toContain('name="trip[end]" value="2026-09-20"')
|
||||
->toContain('aria-label="Select dates"');
|
||||
});
|
||||
|
||||
it('puts class on the root and every other attribute on the text field', function () {
|
||||
$html = (string) $this->blade('<x-datepicker id="due" label="Due" class="w-60" required disabled variant="filled" size="sm" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('class="w-60"')
|
||||
->toContain('data-variant="filled"')
|
||||
->toContain('data-size="sm"')
|
||||
->toMatch('/<input\s[^>]*required[^>]*id="due"/s')
|
||||
->toMatch('/<input\s[^>]*disabled[^>]*id="due"/s')
|
||||
->and(datepickerConfig($html)['disabled'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('entangles the value with its Livewire property and renders it as the property says', function () {
|
||||
Livewire::component('datepicker-probe', new class extends Component
|
||||
{
|
||||
public ?string $expires = '2026-09-13';
|
||||
|
||||
/** @var array{start: ?string, end: ?string} */
|
||||
public array $trip = ['start' => '2026-09-01', 'end' => null];
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-datepicker label="Expires" wire:model.live="expires" />
|
||||
<x-datepicker label="Trip" wire:model="trip" range mode="modal" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
});
|
||||
|
||||
$html = Livewire::test('datepicker-probe')->html();
|
||||
|
||||
expect($html)
|
||||
->toContain(".entangle('expires').live")
|
||||
->toContain(".entangle('trip')")
|
||||
->not->toContain('x-modelable')
|
||||
->not->toContain('wire:model')
|
||||
->toContain('value="09/13/2026"')
|
||||
->toContain('value="09/01/2026 – "');
|
||||
});
|
||||
|
||||
it('replaces the hint with the errors for the property and its start and end', function () {
|
||||
Livewire::component('datepicker-errors', new class extends Component
|
||||
{
|
||||
public ?string $expires = null;
|
||||
|
||||
/** @var array{start: ?string, end: ?string} */
|
||||
public array $trip = ['start' => null, 'end' => null];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->addError('expires', 'Choose a date in the future.');
|
||||
$this->addError('trip.end', 'The end must follow the start.');
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-datepicker id="expires" label="Expires" hint="Pick a day" wire:model="expires" />
|
||||
<x-datepicker id="trip" label="Trip" wire:model="trip" range />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
});
|
||||
|
||||
$html = Livewire::test('datepicker-errors')->html();
|
||||
|
||||
expect($html)
|
||||
->toContain('Choose a date in the future.')
|
||||
->toContain('The end must follow the start.')
|
||||
->not->toContain('Pick a day')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('data-datepicker-error')
|
||||
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* The JSON the component hands its Alpine data as its second argument.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function timepickerConfig(string $html): array
|
||||
{
|
||||
preg_match('/materialTimepicker\((.*?), JSON\.parse\(\'(.*?)\'\)\)"/s', $html, $matches);
|
||||
|
||||
return json_decode((string) json_decode('"'.($matches[2] ?? '{}').'"'), true) ?? [];
|
||||
}
|
||||
|
||||
it('draws a read-only field that opens the picker in a labelled dialog', function () {
|
||||
$html = (string) $this->blade('<x-timepicker id="starts" label="Starts at" hint="In your time zone" value="14:30" format="24" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<label for="starts" class="field-label">Starts at</label>')
|
||||
->toMatch('/<input[^>]*id="starts"[^>]*readonly/s')
|
||||
->toContain('value="14:30"')
|
||||
->toContain('aria-haspopup="dialog"')
|
||||
->toContain('aria-controls="starts-dialog"')
|
||||
->toContain('aria-expanded="false"')
|
||||
->toContain('aria-describedby="starts-support"')
|
||||
->toContain('<p id="starts-support" class="field-support">In your time zone</p>')
|
||||
->toContain('x-on:keydown.enter.prevent="show()"')
|
||||
->toContain('data-timepicker-open')
|
||||
->toContain('aria-label="Choose time"')
|
||||
->toContain('<dialog')
|
||||
->toContain('id="starts-dialog"')
|
||||
->toContain('wire:ignore')
|
||||
->toContain('aria-labelledby="starts-title"')
|
||||
->toContain('<h2 id="starts-title" data-timepicker-title')
|
||||
->toContain('x-modelable="value"')
|
||||
->toContain("materialTimepicker( '14:30' ,")
|
||||
->not->toContain('aria-invalid="true"')
|
||||
->not->toContain('data-invalid');
|
||||
});
|
||||
|
||||
it('draws the dial as a slider, with twelve numbers per ring and M3\'s 24-hour inner ring', function () {
|
||||
$html = (string) $this->blade('<x-timepicker id="alarm" label="Alarm" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('role="slider"')
|
||||
->toContain('x-bind:aria-valuetext="valueText"')
|
||||
->and(substr_count($html, 'data-timepicker-label="hour12"'))->toBe(12)
|
||||
->and(substr_count($html, 'data-timepicker-label="hour24"'))->toBe(24)
|
||||
->and(substr_count($html, 'data-timepicker-label="minute"'))->toBe(12)
|
||||
->and($html)
|
||||
->toMatch('/data-timepicker-label="hour24" data-value="0"\s+style="--x: 0\.0000; --y: -1\.0000"\s*>00</')
|
||||
->toMatch('/data-timepicker-label="hour24" data-value="12"\s+data-inner\s+style="--x: 0\.0000; --y: -1\.0000"\s*>12</')
|
||||
->toMatch('/data-timepicker-label="hour24" data-value="15"\s+data-inner\s+style="--x: 1\.0000; --y: -0\.0000"\s*>15</')
|
||||
->toMatch('/data-timepicker-label="minute" data-value="0"\s+style="[^"]*"\s*>00</')
|
||||
->toMatch('/data-timepicker-label="hour12" data-value="12"\s+style="--x: 0\.0000; --y: -1\.0000"\s*>12</')
|
||||
->toContain('data-timepicker-handle')
|
||||
->toContain('data-timepicker-ink')
|
||||
->not->toContain('x-bind:data-disabled');
|
||||
});
|
||||
|
||||
it('offers the input variant, the period selector and the actions', function () {
|
||||
$html = (string) $this->blade('<x-timepicker id="pickup" label="Pick-up" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('id="pickup-hour"')
|
||||
->toContain('id="pickup-minute"')
|
||||
->toContain('inputmode="numeric"')
|
||||
->toContain('aria-describedby="pickup-hour-support"')
|
||||
->toContain('aria-describedby="pickup-minute-support"')
|
||||
->toContain('data-timepicker-period-option="am"')
|
||||
->toContain('data-timepicker-period-option="pm"')
|
||||
->toContain('aria-label="Select AM or PM"')
|
||||
->toContain('aria-label="Switch to text input mode"')
|
||||
->toContain('aria-label="Switch to clock mode"')
|
||||
->toContain('data-timepicker-cancel')
|
||||
->toContain('data-timepicker-confirm')
|
||||
->toContain('>Cancel</span>')
|
||||
->toContain('>OK</span>');
|
||||
});
|
||||
|
||||
it('passes the format, locale, limits and step to the picker, and drops what it cannot use', function () {
|
||||
app()->setLocale('de_CH');
|
||||
|
||||
$config = timepickerConfig((string) $this->blade('<x-timepicker label="Slot" format="12" step="15" min="8:00" max="17:30:00" />'));
|
||||
|
||||
expect($config)->toMatchArray(['locale' => 'de-CH', 'format' => 12, 'min' => '08:00', 'max' => '17:30', 'step' => 15])
|
||||
->and($config['strings'])->toHaveKeys(['hourError12', 'hourError24', 'minuteError', 'stepError', 'between']);
|
||||
|
||||
$fallback = timepickerConfig((string) $this->blade('<x-timepicker label="Slot" format="13" step="0" min="25:00" max="noon" locale="en_GB" />'));
|
||||
|
||||
expect($fallback)->toMatchArray(['locale' => 'en-GB', 'format' => null, 'min' => null, 'max' => null, 'step' => 1]);
|
||||
});
|
||||
|
||||
it('marks what lies outside the limits only when there are limits', function () {
|
||||
$html = (string) $this->blade('<x-timepicker label="Slot" step="15" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('x-bind:data-disabled="hourAllowed(3 + (isPm ? 12 : 0)) ? null : \'\'"')
|
||||
->toContain('x-bind:data-disabled="minuteAllowed(45) ? null : \'\'"');
|
||||
});
|
||||
|
||||
it('writes the first frame as the locale writes the time', function () {
|
||||
expect((string) $this->blade('<x-timepicker label="A" value="14:05" format="12" />'))->toMatch('/value="2:05[\s\x{202F}]PM"/u')
|
||||
->and((string) $this->blade('<x-timepicker label="B" value="09:05:00" format="24" />'))->toContain('value="09:05"')
|
||||
->and((string) $this->blade('<x-timepicker label="C" value="21:40" locale="de" />'))->toContain('value="21:40"')
|
||||
->and((string) $this->blade('<x-timepicker label="D" value="21:40" locale="en" />'))->toMatch('/value="9:40[\s\x{202F}]PM"/u')
|
||||
->and((string) $this->blade('<x-timepicker label="E" value="not a time" />'))->toMatch('/<input[^>]*value=""[^>]*x-bind:value="display"/s');
|
||||
});
|
||||
|
||||
it('entangles the time with its Livewire property and shows it before Alpine starts', function () {
|
||||
Livewire::component('timepicker-probe', new class extends Component
|
||||
{
|
||||
public ?string $startsAt = '18:45:00';
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return '<div><x-timepicker id="starts" label="Starts" wire:model.live="startsAt" format="24" /></div>';
|
||||
}
|
||||
});
|
||||
|
||||
$html = Livewire::test('timepicker-probe')->html();
|
||||
|
||||
expect($html)
|
||||
->toContain(".entangle('startsAt').live")
|
||||
->toContain('value="18:45"')
|
||||
->not->toContain('x-modelable')
|
||||
->not->toContain('wire:model.live="startsAt"');
|
||||
});
|
||||
|
||||
it('shows the errors for its property in place of the hint', function () {
|
||||
Livewire::component('timepicker-errors', new class extends Component
|
||||
{
|
||||
public ?string $startsAt = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->addError('startsAt', 'Choose a time during opening hours.');
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return '<div><x-timepicker id="starts" label="Starts" hint="Opening hours only" wire:model="startsAt" /></div>';
|
||||
}
|
||||
});
|
||||
|
||||
expect(Livewire::test('timepicker-errors')->html())
|
||||
->toContain('data-invalid')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('aria-describedby="starts-support"')
|
||||
->toContain('Choose a time during opening hours.')
|
||||
->not->toContain('Opening hours only');
|
||||
});
|
||||
|
||||
it('puts the field props on the field, its attributes on the input, and posts a name', function () {
|
||||
$html = (string) $this->blade('<x-timepicker label="Alarm" icon="alarm" variant="filled" size="sm" class="w-60" name="alarm" value="06:30" format="24" required disabled clearable placeholder="hh:mm" />');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/^<div\s+class="w-60"/')
|
||||
->toContain('data-variant="filled"')
|
||||
->toContain('data-size="sm"')
|
||||
->toContain('data-timepicker-field')
|
||||
->toContain('required')
|
||||
->toContain('placeholder="hh:mm"')
|
||||
->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />')
|
||||
->toContain('data-field-clear')
|
||||
->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s')
|
||||
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user