Draw the timepicker without Tailwind

Plan step 36 (inputs group, last of the "not started" pair): <x-timepicker>
renders data-md-timepicker-* throughout (data-md-mode, data-md-cycle,
data-md-view, data-md-inner, data-md-dragging, data-md-disabled,
data-md-error and data-md-value replace their unprefixed hooks), and
timepicker.css moves into material.components, importing field.css,
icon.css and button.css. The dialog's Tailwind utility class (position,
the scrim, the spatial-fast pop and its @starting-style) becomes plain
declarations under data-md-timepicker-dialog, matching the datepicker's
modal dialog. The landscape rule stays keyed on orientation and viewport
height, in px (560/364/346), never a breakpoint, as M3 asks.

The hour and minute boxes and the period buttons swap their Tailwind
`state-layer focus-ring` utilities for `md-state-layer md-focus-ring`:
same declarations, so nothing else in the stylesheet changes for them.
The dial swaps in `md-focus-ring` for its own hand-rolled, byte-identical
outline-on-:focus-visible rule, which timepicker.css no longer carries.
The period selector was already the radio-in-a-list M3's labelling table
asks for (role="radio" in a role="radiogroup", moved by the arrow keys),
and Shift+M/Y already reached the date picker's dropdowns — both
predate this rewrite.

field.css's data-md-field-button note is tidied now that every component
that selects it (input, password, datepicker, timepicker) is rewritten.

Browser tests written but not run (the inputs group's Chromium run
follows this stream): the period radios moving with the arrow keys, and
the dial lying on its side below the landscape height threshold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 19:19:34 +02:00
co-authored by Claude Opus 5
parent 72d9dbfc89
commit d46b07c13c
9 changed files with 340 additions and 254 deletions
+1
View File
@@ -53,6 +53,7 @@
@import './components/sort-header.css';
@import './components/pagination.css';
@import './components/datepicker.css';
@import './components/timepicker.css';
/* Containment */
+6 -5
View File
@@ -165,11 +165,12 @@
pointer-events: none;
}
/* A trailing button — clear, copy, reveal — is an icon button: the icon in a state layer that
scales with the field's own icon size (40px at `md`, smaller at `sm`/`xs`), which the
foundation's fixed-size classes cannot draw; the layer doubles as the touch target, since it
already reaches 40px+ without a second pseudo. The hook stays `data-md-field-button` rather
than a class because datepicker and timepicker, not yet rewritten, still select it. */
/* A trailing button — clear, copy, reveal, the datepicker's calendar and the timepicker's clock —
is an icon button: the icon in a state layer that scales with the field's own icon size (40px
at `md`, smaller at `sm`/`xs`), which the foundation's fixed-size classes cannot draw; the
layer doubles as the touch target, since it already reaches 40px+ without a second pseudo. The
hook stays `data-md-field-button` rather than a class for that reason alone now: every other
component that draws one — input, password, datepicker, timepicker — selects it from here. */
[data-md-field-button] {
position: relative;
display: grid;
+121 -90
View File
@@ -5,22 +5,22 @@
* 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,
* [data-md-timepicker-surface] surface-container-high, extra-large corner, elevation 3; 24px in
* [data-md-timepicker-title] label-medium, on-surface-variant, 20px above the content
* [data-md-timepicker-picker] the dial variant
* [data-md-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. On a 24-hour clock
* (data-cycle="24") there is no period selector and the boxes are
* (data-md-cycle="24") there is no period selector and the boxes are
* 114px, TimeSelector24HVerticalContainerWidth.
* [data-timepicker-dial] 256px, surface-container-highest; numbers in body-large at radius
* [data-md-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
* [data-md-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
* [data-md-timepicker-actions] a 48px row: the mode toggle, then Cancel and OK
*
* The period selector keeps Compose's current shape (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled,
* on by default): two buttons 4px apart, round and surface-container-lowest when off, a 12px corner
@@ -36,34 +36,71 @@
* The switch is orientation and viewport *height*, never a width breakpoint: M3 says the picker
* "swaps orientation/variant based on device orientation and viewport height"
* (docs/reference/m3/components-navigation-selection-inputs.md § Time pickers/Behaviour), and no
* window size class applies. 35rem is what the upright dial needs — 33rem of surface (3 padding,
* 2.25 title, 7.25 display, 17.5 dial, 3 actions) inside the dialog's `100dvh - 2rem` — so a
* landscape phone lies the dial down and a landscape tablet or desktop, which has the height,
* leaves it standing.
* window size class applies — so the two landscape media queries below are the one place in this
* file a length is not one of M3's four breakpoints. 560px is what the upright dial needs — 528px of
* surface (3 padding, 2.25 title, 7.25 display, 17.5 dial, 3 actions in rem, ×16) inside the
* dialog's `100dvh - 32px` — so a landscape phone lies the dial down and a landscape tablet or
* desktop, which has the height, leaves it standing.
*
* The hour and minute boxes and the period buttons render the foundation's `md-state-layer` and
* `md-focus-ring` (foundation/interaction.css) — each is its own interactive box with nothing drawn
* smaller inside it, so the classes need no refinement. The dial renders `md-focus-ring` alone (it
* draws no state layer of its own); nothing here copies its outline rule. Cancel, OK and the mode
* toggle are <x-button>, which draws from the classes already. No element here reaches a 48px target
* of its own accord, so `md-touch-target` is never rendered.
*
* 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.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@import './icon.css';
@import './button.css';
@layer material.components {
@property --timepicker-angle {
syntax: '<angle>';
inherits: true;
initial-value: 0deg;
}
@layer components {
[data-timepicker-field] [data-md-field-control] {
[data-md-timepicker-field] [data-md-field-control] {
cursor: pointer;
caret-color: transparent;
}
[data-timepicker-dialog] {
/* The dialog: M3's spatial-fast pop, a 32%-scrim backdrop, no default border or padding. */
[data-md-timepicker-dialog] {
max-width: calc(100vw - 2rem);
max-height: calc(100dvh - 2rem);
margin: auto;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
color: var(--md-sys-color-on-surface);
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);
}
[data-timepicker-surface] {
@starting-style {
[data-md-timepicker-dialog] {
opacity: 0;
scale: 0.95;
}
}
[data-md-timepicker-dialog]::backdrop {
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
}
[data-md-timepicker-surface] {
display: grid;
grid-template-columns: max-content;
justify-content: center;
@@ -74,14 +111,14 @@
color: var(--md-sys-color-on-surface);
}
[data-timepicker-title] {
[data-md-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] {
[data-md-timepicker-picker] {
display: flex;
flex-direction: column;
align-items: center;
@@ -89,30 +126,30 @@
/* 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"]) {
[data-md-timepicker-surface]:not([data-md-mode='input']) :is([data-md-timepicker-typing], [data-md-timepicker-when='input']),
[data-md-timepicker-surface][data-md-mode='input'] :is([data-md-timepicker-picker], [data-md-timepicker-when='dial']) {
display: none;
}
[data-timepicker-when] {
[data-md-timepicker-when] {
display: contents;
}
/* ---- The time selector and the period selector ------------------------------------------ */
[data-timepicker-display] {
[data-md-timepicker-display] {
display: flex;
margin-bottom: 2.25rem;
}
[data-timepicker-numbers],
[data-timepicker-inputs] {
[data-md-timepicker-numbers],
[data-md-timepicker-inputs] {
display: flex;
align-items: flex-start;
direction: ltr;
}
[data-timepicker-box] {
[data-md-timepicker-box] {
display: grid;
place-items: center;
width: 6rem;
@@ -129,7 +166,7 @@
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-timepicker-box][aria-pressed="true"] {
[data-md-timepicker-box][aria-pressed='true'] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
@@ -137,11 +174,11 @@
/* A 24-hour clock has no period selector beside the boxes, and M3 widens them into the room
(TimePickerTokens.TimeSelector24HVerticalContainerWidth = 114dp). Landscape keeps 96px: the
display is a 216px column there. */
[data-timepicker-display][data-cycle="24"] [data-timepicker-box] {
[data-md-timepicker-display][data-md-cycle='24'] [data-md-timepicker-box] {
width: 7.125rem;
}
[data-timepicker-separator] {
[data-md-timepicker-separator] {
display: grid;
place-items: center;
width: 1.5rem;
@@ -152,7 +189,7 @@
user-select: none;
}
[data-timepicker-period] {
[data-md-timepicker-period] {
display: flex;
flex-direction: column;
gap: 0.25rem;
@@ -161,7 +198,7 @@
margin-inline-start: 0.25rem;
}
[data-timepicker-period] > button {
[data-md-timepicker-period] > button {
flex: 1 1 0%;
min-width: 0;
border-radius: var(--md-sys-shape-corner-full);
@@ -175,20 +212,20 @@
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-timepicker-period] > button:active,
[data-timepicker-period] > button[aria-checked="true"] {
[data-md-timepicker-period] > button:active,
[data-md-timepicker-period] > button[aria-checked='true'] {
border-radius: 0.75rem;
}
/* Tertiary, not the primary the hour and minute boxes take: M3 gives the two selectors different
emphases (PeriodSelectorSelectedContainerColor = TertiaryContainer). */
[data-timepicker-period] > button[aria-checked="true"] {
[data-md-timepicker-period] > button[aria-checked='true'] {
background-color: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-on-tertiary-container);
font-weight: var(--md-ref-typeface-weight-bold);
}
[data-timepicker-period] > button:disabled {
[data-md-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) 12%, transparent);
@@ -196,7 +233,7 @@
/* ---- The dial ----------------------------------------------------------------------------- */
[data-timepicker-dial] {
[data-md-timepicker-dial] {
--dial: 16rem;
--unit: calc(var(--dial) / 256);
--outer: calc(101 * var(--unit));
@@ -219,50 +256,44 @@
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] {
[data-md-timepicker-dial][data-md-dragging] {
transition: none;
}
[data-timepicker-dial][data-inner] {
[data-md-timepicker-dial][data-md-inner] {
--reach: var(--inner);
}
[data-timepicker-labels],
[data-timepicker-ink],
[data-timepicker-set] {
[data-md-timepicker-labels],
[data-md-timepicker-ink],
[data-md-timepicker-set] {
position: absolute;
inset: 0;
}
[data-timepicker-set] {
[data-md-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"] {
[data-md-timepicker-dial]:not([data-md-cycle='24']) [data-md-timepicker-set='hour24'],
[data-md-timepicker-dial][data-md-cycle='24'] [data-md-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"] {
[data-md-timepicker-dial][data-md-view='minute'] :is([data-md-timepicker-set='hour12'], [data-md-timepicker-set='hour24']),
[data-md-timepicker-dial]:not([data-md-view='minute']) [data-md-timepicker-set='minute'] {
opacity: 0;
visibility: hidden;
}
[data-timepicker-set] > span {
[data-md-timepicker-set] > span {
--ring: var(--outer);
position: absolute;
@@ -277,22 +308,22 @@
font-variant-numeric: tabular-nums;
}
[data-timepicker-set] > span[data-inner] {
[data-md-timepicker-set] > span[data-md-inner] {
--ring: var(--inner);
}
[data-timepicker-set] > span[data-disabled] {
[data-md-timepicker-set] > span[data-md-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] {
[data-md-timepicker-selector] {
position: absolute;
inset: 0;
pointer-events: none;
}
[data-timepicker-track] {
[data-md-timepicker-track] {
position: absolute;
left: calc(50% - 1px);
bottom: 50%;
@@ -303,22 +334,22 @@
rotate: var(--timepicker-angle);
}
[data-timepicker-centre],
[data-timepicker-handle] {
[data-md-timepicker-centre],
[data-md-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] {
[data-md-timepicker-centre] {
left: 50%;
top: 50%;
width: 0.5rem;
height: 0.5rem;
}
[data-timepicker-handle] {
[data-md-timepicker-handle] {
left: calc(50% + sin(var(--timepicker-angle)) * var(--reach));
top: calc(50% - cos(var(--timepicker-angle)) * var(--reach));
width: var(--handle);
@@ -326,31 +357,31 @@
}
/* The numbers again, in on-primary, cut to the handle: what Compose draws with BlendMode.DstOver. */
[data-timepicker-ink] {
[data-md-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] {
[data-md-timepicker-ink] [data-md-timepicker-set] > span[data-md-disabled] {
color: inherit;
}
/* ---- The input variant -------------------------------------------------------------------- */
[data-timepicker-inputs] [data-timepicker-separator] {
[data-md-timepicker-inputs] [data-md-timepicker-separator] {
height: 4.5rem;
}
[data-timepicker-inputs] [data-timepicker-period] {
[data-md-timepicker-inputs] [data-md-timepicker-period] {
height: 4.5rem;
}
[data-timepicker-column] {
[data-md-timepicker-column] {
width: 6rem;
}
[data-timepicker-input] {
[data-md-timepicker-input] {
display: block;
width: 6rem;
height: 4.5rem;
@@ -370,25 +401,25 @@
transition-timing-function: var(--md-sys-motion-effects-fast);
}
[data-timepicker-input]:focus {
[data-md-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"] {
[data-md-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 {
[data-md-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] {
[data-md-timepicker-support] {
min-height: 2rem;
padding-top: 0.4375rem;
color: var(--md-sys-color-on-surface-variant);
@@ -396,89 +427,89 @@
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-timepicker-support][data-error],
[data-timepicker-range-error] {
[data-md-timepicker-support][data-md-error],
[data-md-timepicker-range-error] {
color: var(--md-sys-color-error);
}
[data-timepicker-range-error] {
[data-md-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] {
[data-md-timepicker-actions] {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 3rem;
}
[data-timepicker-actions] > [data-timepicker-spacer] {
[data-md-timepicker-actions] > [data-md-timepicker-spacer] {
flex: 1 1 0%;
}
/* ---- Landscape ---------------------------------------------------------------------------- */
@media (orientation: landscape) and (height < 35rem) {
[data-timepicker-surface][data-mode="dial"] {
@media (orientation: landscape) and (height < 560px) {
[data-md-timepicker-surface][data-md-mode='dial'] {
grid-template-columns: 13.5rem auto;
grid-template-areas:
"display dial"
"actions actions";
'display dial'
'actions actions';
column-gap: 2.25rem;
padding: 1rem 1.5rem 0.5rem;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-title] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-title] {
grid-area: display;
align-self: start;
margin-top: 0.5rem;
padding: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-picker] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-picker] {
display: contents;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-display] {
grid-area: display;
flex-direction: column;
align-self: center;
margin: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display][data-cycle="24"] [data-timepicker-box] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-display][data-md-cycle='24'] [data-md-timepicker-box] {
width: 6rem;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] [data-timepicker-period] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-display] [data-md-timepicker-period] {
flex-direction: row;
width: 13.5rem;
height: 2.375rem;
margin: 1rem 0 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial] {
grid-area: dial;
margin: 0;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-actions] {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-actions] {
grid-area: actions;
margin-top: 0.25rem;
}
}
@media (orientation: landscape) and (max-height: 22.75rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
@media (orientation: landscape) and (height <= 364px) {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial] {
--dial: 14.875rem;
}
}
@media (orientation: landscape) and (max-height: 21.625rem) {
[data-timepicker-surface][data-mode="dial"] [data-timepicker-dial] {
@media (orientation: landscape) and (height <= 346px) {
[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial] {
--dial: 12.5rem;
}
}
-3
View File
@@ -17,9 +17,6 @@
@import './tokens/theme.css';
@import './tokens/utilities.css';
/* Inputs, selection and data — leaving in step 36 */
@import './components/timepicker.css';
/* Containment — leaving in step 36 */
@import './components/list.css';
@import './components/collapse.css';
+1 -1
View File
@@ -392,7 +392,7 @@ document.addEventListener('alpine:init', () => {
}
this.setPeriod(pm)
event.currentTarget.querySelector(`[data-timepicker-period-option="${pm ? 'pm' : 'am'}"]`)?.focus()
event.currentTarget.querySelector(`[data-md-timepicker-period-option="${pm ? 'pm' : 'am'}"]`)?.focus()
},
/** The allowed time in a period nearest to `time` (periodAllowed has said there is one). */
+50 -50
View File
@@ -143,7 +143,7 @@
x-data="materialTimepicker(@if ($model !== null) @entangle($attributes->wire('model')) @else @js($current) @endif, @js($config))"
@if ($model === null) x-modelable="value" @endif
>
<x-livewire-material::field :$id :$label :$hint :$messages :$icon :$size :$variant data-timepicker-field>
<x-livewire-material::field :$id :$label :$hint :$messages :$icon :$size :$variant data-md-timepicker-field>
<input
{{ $inputAttributes }}
id="{{ $id }}"
@@ -189,7 +189,7 @@
data-md-field-trailing
data-md-field-button
aria-label="{{ __('Choose time') }}"
data-timepicker-open
data-md-timepicker-open
@disabled($disabled)
>
<x-livewire-material::icon name="schedule" :size="$fieldIconSize" />
@@ -206,33 +206,32 @@
wire:ignore
x-ref="dialog"
aria-labelledby="{{ $id }}-title"
data-timepicker-dialog
data-md-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-md-timepicker-surface x-bind:data-md-mode="mode" data-md-mode="dial">
<h2 id="{{ $id }}-title" data-md-timepicker-title x-text="mode === 'dial' ? @js(__('Select time')) : @js(__('Enter time'))">{{ __('Select time') }}</h2>
<div data-timepicker-picker>
<div data-timepicker-display x-bind:data-cycle="is24 ? '24' : '12'">
<div data-timepicker-numbers>
<div data-md-timepicker-picker>
<div data-md-timepicker-display x-bind:data-md-cycle="is24 ? '24' : '12'">
<div data-md-timepicker-numbers>
<button
type="button"
data-timepicker-box="hour"
class="state-layer focus-ring"
data-md-timepicker-box="hour"
class="md-state-layer md-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>
<span data-md-timepicker-separator aria-hidden="true">:</span>
<button
type="button"
data-timepicker-box="minute"
class="state-layer focus-ring"
data-md-timepicker-box="minute"
class="md-state-layer md-focus-ring"
aria-pressed="false"
x-bind:aria-pressed="(view === 'minute').toString()"
x-bind:aria-label="@js(__('Select minutes')) + ', ' + minuteLabel"
@@ -242,7 +241,7 @@
</div>
<div
data-timepicker-period
data-md-timepicker-period
role="radiogroup"
aria-label="{{ __('Select AM or PM') }}"
x-show="! is24"
@@ -254,8 +253,8 @@
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button
type="button"
data-timepicker-period-option="{{ $period }}"
class="state-layer focus-ring"
data-md-timepicker-period-option="{{ $period }}"
class="md-state-layer md-focus-ring"
role="radio"
aria-checked="false"
x-bind:aria-checked="({{ $pm ? '' : '! ' }}isPm).toString()"
@@ -270,7 +269,8 @@
<div
x-ref="dial"
data-timepicker-dial
class="md-focus-ring"
data-md-timepicker-dial
role="slider"
tabindex="0"
aria-valuemin="0"
@@ -278,31 +278,31 @@
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:data-md-view="view"
x-bind:data-md-cycle="is24 ? '24' : '12'"
x-bind:data-md-inner="inner ? '' : null"
x-bind:data-md-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 data-md-timepicker-selector aria-hidden="true">
<span data-md-timepicker-track></span>
<span data-md-timepicker-centre></span>
<span data-md-timepicker-handle></span>
</div>
@endif
<div data-timepicker-{{ $layer }} aria-hidden="true">
<div data-md-timepicker-{{ $layer }} aria-hidden="true">
@foreach ($sets as $set => $labels)
<div data-timepicker-set="{{ $set }}">
<div data-md-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
@if ($layer === 'labels') data-md-timepicker-label="{{ $set }}" data-md-value="{{ $spotLabel['value'] }}" @endif
@if ($spotLabel['inner']) data-md-inner @endif
@if ($constrained && $layer === 'labels') x-bind:data-md-disabled="{{ $spotLabel['allowed'] }} ? null : ''" @endif
style="{{ $spot($spotLabel['index']) }}"
>{{ $spotLabel['text'] }}</span>
@endforeach
@@ -313,21 +313,21 @@
</div>
</div>
<div data-timepicker-typing>
<div data-timepicker-inputs>
<div data-md-timepicker-typing>
<div data-md-timepicker-inputs>
@foreach (['hour' => __('Hour'), 'minute' => __('Minute')] as $part => $partLabel)
@if ($part === 'minute')
<span data-timepicker-separator aria-hidden="true">:</span>
<span data-md-timepicker-separator aria-hidden="true">:</span>
@endif
<div data-timepicker-column>
<div data-md-timepicker-column>
<input
id="{{ $id }}-{{ $part }}"
type="text"
inputmode="numeric"
maxlength="2"
autocomplete="off"
data-timepicker-input="{{ $part }}"
data-md-timepicker-input="{{ $part }}"
x-ref="{{ $part }}Input"
aria-label="{{ $partLabel }}"
aria-describedby="{{ $id }}-{{ $part }}-support"
@@ -339,16 +339,16 @@
/>
<p
id="{{ $id }}-{{ $part }}-support"
data-timepicker-support
data-md-timepicker-support
aria-live="polite"
x-bind:data-error="{{ $part }}Error ? '' : null"
x-bind:data-md-error="{{ $part }}Error ? '' : null"
x-text="{{ $part }}Error ?? @js($partLabel)"
>{{ $partLabel }}</p>
</div>
@endforeach
<div
data-timepicker-period
data-md-timepicker-period
role="radiogroup"
aria-label="{{ __('Select AM or PM') }}"
x-show="! is24"
@@ -360,8 +360,8 @@
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button
type="button"
data-timepicker-period-option="{{ $period }}"
class="state-layer focus-ring"
data-md-timepicker-period-option="{{ $period }}"
class="md-state-layer md-focus-ring"
role="radio"
aria-checked="false"
x-bind:aria-checked="({{ $pm ? '' : '! ' }}isPm).toString()"
@@ -374,19 +374,19 @@
</div>
</div>
<p data-timepicker-range-error role="alert" x-show="rangeError" x-text="rangeError"></p>
<p data-md-timepicker-range-error role="alert" x-show="rangeError" x-text="rangeError"></p>
</div>
<div data-timepicker-actions>
<span data-timepicker-when="dial">
<x-livewire-material::button icon="keyboard" :tooltip="__('Switch to text input mode')" x-on:click="toggleMode()" data-timepicker-mode="input" />
<div data-md-timepicker-actions>
<span data-md-timepicker-when="dial">
<x-livewire-material::button icon="keyboard" :tooltip="__('Switch to text input mode')" x-on:click="toggleMode()" data-md-timepicker-mode="input" />
</span>
<span data-timepicker-when="input">
<x-livewire-material::button icon="schedule" :tooltip="__('Switch to clock mode')" x-on:click="toggleMode()" data-timepicker-mode="dial" />
<span data-md-timepicker-when="input">
<x-livewire-material::button icon="schedule" :tooltip="__('Switch to clock mode')" x-on:click="toggleMode()" data-md-timepicker-mode="dial" />
</span>
<span data-timepicker-spacer></span>
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-timepicker-cancel />
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-timepicker-confirm />
<span data-md-timepicker-spacer></span>
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-md-timepicker-cancel />
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-md-timepicker-confirm />
</div>
</div>
</dialog>
+133 -78
View File
@@ -79,37 +79,37 @@ it('opens the dial on the hour, as the bound time says', function () {
->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-checked', 'true')
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuenow', '9')
->assertScript('document.activeElement === '.inPicker('meeting', '[data-timepicker-dial]'));
->assertAttribute('#meeting-dialog [data-md-timepicker-box="hour"]', 'aria-pressed', 'true')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '09')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="minute"]', '30')
->assertAttribute('#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="am"]', 'aria-checked', 'true')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '9')
->assertScript('document.activeElement === '.inPicker('meeting', '[data-md-timepicker-dial]'));
});
it('writes the hour and the minute pressed on the dial to Livewire on OK', function () {
$dial = inPicker('meeting', '[data-timepicker-dial]');
$dial = inPicker('meeting', '[data-md-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')
->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="3"]')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '03')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute')
->assertAttribute('#meeting-dialog [data-md-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();
const handle = {$dial}.querySelector('[data-md-timepicker-handle]').getBoundingClientRect();
const label = {$dial}.querySelector('[data-md-timepicker-label="minute"][data-md-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')
$page->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="45"]')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="minute"]', '45')
->assertSeeIn('#meeting-value', '09:30')
->click('#meeting-dialog [data-timepicker-confirm]')
->click('#meeting-dialog [data-md-timepicker-confirm]')
->assertSeeIn('#meeting-value', '03:45')
->assertValue('#meeting', '3:45 AM')
->assertScript("! document.querySelector('#meeting-dialog').open")
@@ -119,9 +119,9 @@ it('writes the hour and the minute pressed on the dial to Livewire on OK', funct
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]')
->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="7"]')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '07')
->click('#meeting-dialog [data-md-timepicker-cancel]')
->assertScript("! document.querySelector('#meeting-dialog').open")
->assertSeeIn('#meeting-value', '09:30')
->assertValue('#meeting', '9:30 AM');
@@ -130,21 +130,21 @@ it('leaves the time alone when the picker is cancelled', function () {
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-checked', '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]')
->click('#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]')
->assertAttribute('#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]', 'aria-checked', 'true')
->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="3"]')
->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="15"]')
->click('#meeting-dialog [data-md-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]');
$dial = inPicker('alarm', '[data-md-timepicker-dial]');
$radius = fn (string $value): string => <<<JS
(() => {
const dial = {$dial}.getBoundingClientRect();
const label = {$dial}.querySelector('[data-timepicker-label="hour24"][data-value="{$value}"]').getBoundingClientRect();
const label = {$dial}.querySelector('[data-md-timepicker-label="hour24"][data-md-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));
})()
@@ -152,41 +152,41 @@ it('draws a German dial with 24 hours, 12 to 23 on the inner ring', function ()
$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'")
->assertAttribute('#alarm-dialog [data-md-timepicker-dial]', 'data-md-cycle', '24')
->assertScript("getComputedStyle(document.querySelector('#alarm-dialog [data-md-timepicker-period]')).display === 'none'")
->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-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]')
$page->click('#alarm-dialog [data-md-timepicker-label="hour24"][data-md-value="15"]')
->assertSeeIn('#alarm-dialog [data-md-timepicker-box="hour"]', '15')
->assertAttribute('#alarm-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute')
->click('#alarm-dialog [data-md-timepicker-label="minute"][data-md-value="0"]')
->click('#alarm-dialog [data-md-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]')
->click('#alarm-dialog [data-md-timepicker-label="hour24"][data-md-value="3"]')
->assertSeeIn('#alarm-dialog [data-md-timepicker-box="hour"]', '03')
->click('#alarm-dialog [data-md-timepicker-label="minute"][data-md-value="0"]')
->click('#alarm-dialog [data-md-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]');
$dial = inPicker('meeting', '[data-md-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 dial = document.querySelector('#meeting-dialog [data-md-timepicker-dial]');
const box = dial.getBoundingClientRect();
const radians = {$degrees} * Math.PI / 180;
@@ -201,14 +201,14 @@ it('follows a drag round the dial and settles on the nearest hour', function ()
$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->assertScript("{$dial}.hasAttribute('data-md-dragging') && parseFloat(getComputedStyle({$dial}).getPropertyValue('--timepicker-angle')) % 360 === 125")
->assertSeeIn('#meeting-dialog [data-md-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');
$page->assertScript("! {$dial}.hasAttribute('data-md-dragging')")
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '04');
});
it('changes the focused hour and minute with the arrow keys and confirms with Enter', function () {
@@ -217,23 +217,23 @@ it('changes the focused hour and minute with the arrow keys and confirms with En
$page->script("document.querySelector('#meeting').focus()");
$page->keys('#meeting', 'Enter')
->assertScript('document.activeElement === '.inPicker('meeting', '[data-timepicker-dial]'))
->assertScript('document.activeElement === '.inPicker('meeting', '[data-md-timepicker-dial]'))
->keys(':focus', 'ArrowUp')
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuenow', '10')
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '10')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '10')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '10')
// The keyboard never moves on to the minutes by itself.
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'hour');
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'hour');
$page->script(inPicker('meeting', '[data-timepicker-box="minute"]').'.focus()');
$page->script(inPicker('meeting', '[data-md-timepicker-box="minute"]').'.focus()');
$page->keys(':focus', 'Enter')
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute');
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute');
$page->script(inPicker('meeting', '[data-timepicker-dial]').'.focus()');
$page->script(inPicker('meeting', '[data-md-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')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '28')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuetext', '28 minutes')
->keys(':focus', 'Enter')
->assertScript("! document.querySelector('#meeting-dialog').open")
->assertSeeIn('#meeting-value', '10:28');
@@ -242,7 +242,7 @@ it('changes the focused hour and minute with the arrow keys and confirms with En
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"]')
->click('#meeting-dialog [data-md-timepicker-mode="input"]')
->assertSeeIn('#meeting-title', 'Enter time')
->assertScript("document.activeElement.id === 'meeting-hour'")
->assertValue('#meeting-hour', '09')
@@ -252,7 +252,7 @@ it('takes a typed time in the input variant and says what is wrong with it', fun
->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 112'")
->click('#meeting-dialog [data-timepicker-confirm]')
->click('#meeting-dialog [data-md-timepicker-confirm]')
->assertScript("document.querySelector('#meeting-dialog').open")
->assertSeeIn('#meeting-value', '09:30');
@@ -273,37 +273,37 @@ it('takes a typed time in the input variant and says what is wrong with it', fun
});
it('keeps to the step and the limits', function () {
$dial = inPicker('slot', '[data-timepicker-dial]');
$dial = inPicker('slot', '[data-md-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')")
->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"8\"]').hasAttribute('data-md-disabled')")
->assertScript("! {$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"9\"]').hasAttribute('data-md-disabled')")
->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"18\"]').hasAttribute('data-md-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')")
->click('#slot-dialog [data-md-timepicker-label="hour24"][data-md-value="20"]')
->assertSeeIn('#slot-dialog [data-md-timepicker-box="hour"]', '10')
->click('#slot-dialog [data-md-timepicker-label="hour24"][data-md-value="16"]')
->assertAttribute('#slot-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute')
->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"minute\"][data-md-value=\"20\"]').hasAttribute('data-md-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');
->click('#slot-dialog [data-md-timepicker-label="minute"][data-md-value="20"]')
->assertSeeIn('#slot-dialog [data-md-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"]')
->assertAttribute('#slot-dialog [data-md-timepicker-dial]', 'aria-valuenow', '30')
->click('#slot-dialog [data-md-timepicker-mode="input"]')
->type('#slot-hour', '18')
->assertSeeIn('#slot-dialog [data-timepicker-range-error]', 'Choose a time from 09:00 to 17:00')
->assertSeeIn('#slot-dialog [data-md-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]')
->click('#slot-dialog [data-md-timepicker-confirm]')
->assertScript("document.activeElement.id === 'slot-minute'")
->type('#slot-minute', '45')
->click('#slot-dialog [data-timepicker-confirm]')
->click('#slot-dialog [data-md-timepicker-confirm]')
->assertSeeIn('#slot-value', '16:45');
});
@@ -324,16 +324,71 @@ it('gives focus back to the field on Escape', function () {
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');
->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="5"]')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-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-dialog [data-md-timepicker-box="hour"]', '05')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute')
->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="10"]')
->click('#meeting-dialog [data-md-timepicker-confirm]')
->assertSeeIn('#meeting-value', '05:10');
});
it('moves the period selector between AM and PM with arrow keys, as a radio group', function () {
$am = '#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="am"]';
$pm = '#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]';
$page = timeProbe()
->click('#meeting')
->assertAttribute($am, 'role', 'radio')
->assertAttribute($am, 'aria-checked', 'true')
->assertAttribute($pm, 'aria-checked', 'false');
$page->script("document.querySelector('{$am}').focus()");
$page->keys(':focus', 'ArrowRight')
->assertAttribute($pm, 'aria-checked', 'true')
->assertAttribute($am, 'aria-checked', 'false')
->assertScript("document.activeElement.dataset.mdTimepickerPeriodOption === 'pm'")
// M3's radio-in-a-list: an arrow key moves to the other option and selects it, so PM
// reaches the dial straight away — the hour turns 9 into 21 without a confirm.
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '09');
$page->keys(':focus', 'ArrowLeft')
->assertAttribute($am, 'aria-checked', 'true')
->assertAttribute($pm, 'aria-checked', 'false')
->assertScript("document.activeElement.dataset.mdTimepickerPeriodOption === 'am'");
});
it('lies the dial on its side in a short landscape window', function () {
$page = timeProbe()
->resize(700, 400)
->click('#meeting')
->assertScript("document.querySelector('#meeting-dialog').open");
// Side by side rather than stacked: the display and the dial share a top edge, the dial to
// the display's end, and the picker's own layout (flex column) gives way to the grid areas.
$page->assertScript(<<<'JS'
(() => {
const display = document.querySelector('#meeting-dialog [data-md-timepicker-display]').getBoundingClientRect();
const dial = document.querySelector('#meeting-dialog [data-md-timepicker-dial]').getBoundingClientRect();
return Math.abs(display.top - dial.top) < 4 && dial.left >= display.right;
})()
JS)
->assertScript("getComputedStyle(document.querySelector('#meeting-dialog [data-md-timepicker-picker]')).display === 'contents'");
$page->resize(390, 800)
->assertScript(<<<'JS'
(() => {
const display = document.querySelector('#meeting-dialog [data-md-timepicker-display]').getBoundingClientRect();
const dial = document.querySelector('#meeting-dialog [data-md-timepicker-dial]').getBoundingClientRect();
return dial.top >= display.bottom;
})()
JS);
});
@@ -34,6 +34,7 @@ dataset('input components', [
'table',
'sort-header',
'datepicker',
'timepicker',
]);
/**
+22 -22
View File
@@ -28,13 +28,13 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
->toContain('aria-describedby="starts-support"')
->toMatch('/<p id="starts-support" data-md-field-support\s*>In your time zone<\/p>/')
->toContain('x-on:keydown.enter.prevent="show()"')
->toContain('data-timepicker-open')
->toContain('data-md-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('<h2 id="starts-title" data-md-timepicker-title')
->toContain('x-modelable="value"')
->toContain("materialTimepicker( '14:30' ,")
->not->toContain('aria-invalid="true"')
@@ -47,18 +47,18 @@ it('draws the dial as a slider, with twelve numbers per ring and M3\'s 24-hour i
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(substr_count($html, 'data-md-timepicker-label="hour12"'))->toBe(12)
->and(substr_count($html, 'data-md-timepicker-label="hour24"'))->toBe(24)
->and(substr_count($html, 'data-md-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');
->toMatch('/data-md-timepicker-label="hour24" data-md-value="0"\s+style="--x: 0\.0000; --y: -1\.0000"\s*>00</')
->toMatch('/data-md-timepicker-label="hour24" data-md-value="12"\s+data-md-inner\s+style="--x: 0\.0000; --y: -1\.0000"\s*>12</')
->toMatch('/data-md-timepicker-label="hour24" data-md-value="15"\s+data-md-inner\s+style="--x: 1\.0000; --y: -0\.0000"\s*>15</')
->toMatch('/data-md-timepicker-label="minute" data-md-value="0"\s+style="[^"]*"\s*>00</')
->toMatch('/data-md-timepicker-label="hour12" data-md-value="12"\s+style="--x: 0\.0000; --y: -1\.0000"\s*>12</')
->toContain('data-md-timepicker-handle')
->toContain('data-md-timepicker-ink')
->not->toContain('x-bind:data-md-disabled');
});
it('offers the input variant, the period selector and the actions', function () {
@@ -70,19 +70,19 @@ it('offers the input variant, the period selector and the actions', function ()
->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('data-md-timepicker-period-option="am"')
->toContain('data-md-timepicker-period-option="pm"')
->toContain('aria-label="Select AM or PM"')
// M3's labelling table: AM and PM are radios in a list, not toggle buttons.
->toContain('role="radiogroup"')
->toContain('role="radio"')
->toContain('x-on:keydown.arrow-right.prevent="movePeriod($event)"')
// The 24-hour selector widens into the room the period selector leaves.
->toContain('data-timepicker-display x-bind:data-cycle="is24 ? \'24\' : \'12\'"')
->toContain('data-md-timepicker-display x-bind:data-md-cycle="is24 ? \'24\' : \'12\'"')
->toContain('aria-label="Switch to text input mode"')
->toContain('aria-label="Switch to clock mode"')
->toContain('data-timepicker-cancel')
->toContain('data-timepicker-confirm')
->toContain('data-md-timepicker-cancel')
->toContain('data-md-timepicker-confirm')
->toContain('>Cancel</span>')
->toContain('>OK</span>');
});
@@ -104,8 +104,8 @@ 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 : \'\'"');
->toContain('x-bind:data-md-disabled="hourAllowed(3 + (isPm ? 12 : 0)) ? null : \'\'"')
->toContain('x-bind:data-md-disabled="minuteAllowed(45) ? null : \'\'"');
});
it('writes the first frame as the locale writes the time', function () {
@@ -167,11 +167,11 @@ it('puts the field props on the field, its attributes on the input, and posts a
->toMatch('/^<div\s+class="w-60"/')
->toContain('data-md-variant="filled"')
->toContain('data-md-size="sm"')
->toContain('data-timepicker-field')
->toContain('data-md-timepicker-field')
->toContain('required')
->toContain('placeholder="hh:mm"')
->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />')
->toContain('data-md-field-clear')
->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s')
->toMatch('/<button[^>]*data-md-timepicker-open[^>]*disabled/s')
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
});