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
395 lines
19 KiB
PHP
395 lines
19 KiB
PHP
<?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-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-md-timepicker-dial]');
|
||
|
||
$page = timeProbe()
|
||
->click('#meeting')
|
||
->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-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-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-md-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-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');
|
||
});
|
||
|
||
it('turns three o\'clock into 15 with PM', function () {
|
||
timeProbe()
|
||
->click('#meeting')
|
||
->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-md-timepicker-dial]');
|
||
$radius = fn (string $value): string => <<<JS
|
||
(() => {
|
||
const dial = {$dial}.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));
|
||
})()
|
||
JS;
|
||
|
||
$page = timeProbe()
|
||
->click('#alarm')
|
||
->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-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-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-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-md-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-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-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 () {
|
||
$page = timeProbe();
|
||
|
||
$page->script("document.querySelector('#meeting').focus()");
|
||
|
||
$page->keys('#meeting', 'Enter')
|
||
->assertScript('document.activeElement === '.inPicker('meeting', '[data-md-timepicker-dial]'))
|
||
->keys(':focus', 'ArrowUp')
|
||
->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-md-timepicker-dial]', 'data-md-view', 'hour');
|
||
|
||
$page->script(inPicker('meeting', '[data-md-timepicker-box="minute"]').'.focus()');
|
||
|
||
$page->keys(':focus', 'Enter')
|
||
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute');
|
||
|
||
$page->script(inPicker('meeting', '[data-md-timepicker-dial]').'.focus()');
|
||
|
||
$page->keys(':focus', ['ArrowDown', 'ArrowLeft'])
|
||
->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');
|
||
});
|
||
|
||
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-md-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-md-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-md-timepicker-dial]');
|
||
|
||
$page = timeProbe()
|
||
->click('#slot')
|
||
->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-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-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-md-timepicker-dial]', 'aria-valuenow', '30')
|
||
->click('#slot-dialog [data-md-timepicker-mode="input"]')
|
||
->type('#slot-hour', '18')
|
||
->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-md-timepicker-confirm]')
|
||
->assertScript("document.activeElement.id === 'slot-minute'")
|
||
->type('#slot-minute', '45')
|
||
->click('#slot-dialog [data-md-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-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-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);
|
||
});
|