Hold the time picker's two selectors apart

The 24-hour clock has no period selector beside the hour and minute
boxes, so M3 widens them to 114px; AM/PM takes its own
tertiary-container rather than borrowing the boxes' primary-container;
a disabled period button uses M3's 12% disabled container, not 10%; and
the pair is a radio group, which is what M3's labelling table asks for,
so the arrows move between AM and PM.

Plan step 20, findings IN-10, IN-11, IN-26 and IN-27.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:01:23 +02:00
co-authored by Claude Fable 5.1
parent f30fd575cf
commit 9624d4bfd4
5 changed files with 88 additions and 24 deletions
+26 -9
View File
@@ -11,7 +11,9 @@
* [data-timepicker-display] the time selector: hour and minute boxes 96×80 (display-large, * [data-timepicker-display] the time selector: hour and minute boxes 96×80 (display-large,
* small corner; primary-container when selected, otherwise * small corner; primary-container when selected, otherwise
* surface-container-highest), a 24px separator, and the period * surface-container-highest), a 24px separator, and the period
* selector 52×80 beside them, 4px away * selector 52×80 beside them, 4px away. On a 24-hour clock
* (data-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-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, * 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 * an 8px centre and a 48px handle — with the number under the handle
@@ -20,9 +22,11 @@
* "Minute" (or the error) below in body-small, the period 52×72 * "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-timepicker-actions] a 48px row: the mode toggle, then Cancel and OK
* *
* The period selector is Compose's current one (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled, * The period selector keeps Compose's current shape (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled,
* on by default): two toggle buttons 4px apart, round and surface-container-lowest when off, a 12px * on by default): two buttons 4px apart, round and surface-container-lowest when off, a 12px corner
* corner and primary-container with a bold label when on, not the outlined pair of the tokens. * when on, not the outlined pair of the tokens. The selected colour is M3's own tertiary-container
* with a bold label (PeriodSelectorSelectedContainerColor), which keeps AM/PM apart from the
* primary-container hour and minute boxes. They are radios, so the state is `aria-checked`.
* *
* In a landscape window with no room for the upright dial the dial variant lies on its side, as * In a landscape window with no room for the upright dial the dial variant lies on its side, as
* Compose's HorizontalTimePicker does: the display with the period selector (216×38, 16px under it) * Compose's HorizontalTimePicker does: the display with the period selector (216×38, 16px under it)
@@ -130,6 +134,13 @@
color: var(--md-sys-color-on-primary-container); color: var(--md-sys-color-on-primary-container);
} }
/* 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] {
width: 7.125rem;
}
[data-timepicker-separator] { [data-timepicker-separator] {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -165,20 +176,22 @@
} }
[data-timepicker-period] > button:active, [data-timepicker-period] > button:active,
[data-timepicker-period] > button[aria-pressed="true"] { [data-timepicker-period] > button[aria-checked="true"] {
border-radius: 0.75rem; border-radius: 0.75rem;
} }
[data-timepicker-period] > button[aria-pressed="true"] { /* Tertiary, not the primary the hour and minute boxes take: M3 gives the two selectors different
background-color: var(--md-sys-color-primary-container); emphases (PeriodSelectorSelectedContainerColor = TertiaryContainer). */
color: var(--md-sys-color-on-primary-container); [data-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); font-weight: var(--md-ref-typeface-weight-bold);
} }
[data-timepicker-period] > button:disabled { [data-timepicker-period] > button:disabled {
cursor: default; cursor: default;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); 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); background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
} }
/* ---- The dial ----------------------------------------------------------------------------- */ /* ---- The dial ----------------------------------------------------------------------------- */
@@ -436,6 +449,10 @@
margin: 0; margin: 0;
} }
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display][data-cycle="24"] [data-timepicker-box] {
width: 6rem;
}
[data-timepicker-surface][data-mode="dial"] [data-timepicker-display] [data-timepicker-period] { [data-timepicker-surface][data-mode="dial"] [data-timepicker-display] [data-timepicker-period] {
flex-direction: row; flex-direction: row;
width: 13.5rem; width: 13.5rem;
+15
View File
@@ -380,6 +380,21 @@ document.addEventListener('alpine:init', () => {
this.aim() this.aim()
}, },
/**
* AM and PM are a radio group, which M3's labelling table asks for: an arrow key moves to
* the other option and selects it, and focus follows, as WAI-ARIA's radio group does.
*/
movePeriod(event) {
const pm = !this.isPm
if (!this.periodAllowed(pm)) {
return
}
this.setPeriod(pm)
event.currentTarget.querySelector(`[data-timepicker-period-option="${pm ? 'pm' : 'am'}"]`)?.focus()
},
/** The allowed time in a period nearest to `time` (periodAllowed has said there is one). */ /** The allowed time in a period nearest to `time` (periodAllowed has said there is one). */
nearestInPeriod(pm, time) { nearestInPeriod(pm, time) {
let best = null let best = null
+39 -13
View File
@@ -31,14 +31,16 @@
TimePickerTokens and TimeInputTokens a surface-container-high dialog with an extra-large 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 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 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 display-large (primary-container when selected; 114dp wide on a 24-hour clock, where there is no
TimePicker.kt and TimePickerDialog.kt for the layout, the 24-hour inner ring (1223, at 69dp; period selector beside them, per TimeSelector24HVerticalContainerWidth), 96×72 time fields in
0011 outside at 101dp, as Material Components for Android labels them too), the gestures, the display-medium and TimePicker.kt and TimePickerDialog.kt for the layout, the 24-hour inner
move on to minutes and the error texts. The period selector is Compose's current default ring (1223, at 69dp; 0011 outside at 101dp, as Material Components for Android labels them
(`isUpdatedTimepickerToggleEnabled`): two separate shape-morphing toggle buttons in too), the gestures, the move on to minutes and the error texts. The period selector keeps
primary-container, not the outlined pair its tokens still describe. The dial's numbers are Compose's current default shape morph (`isUpdatedTimepickerToggleEnabled`) but M3's own
aria-hidden; the dial is a `slider` whose value text names the hour or minute. The dialog is tertiary-container, which is what holds hour/minute (primary-container) and AM/PM apart; it is a
`wire:ignore`, so a Livewire render never closes an open picker or resets its draft. --}} radio group, as M3's labelling table says, so the arrows move between AM and PM. 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([ @props([
'label' => null, 'label' => null,
@@ -211,7 +213,7 @@
<h2 id="{{ $id }}-title" data-timepicker-title x-text="mode === 'dial' ? @js(__('Select time')) : @js(__('Enter time'))">{{ __('Select time') }}</h2> <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-picker>
<div data-timepicker-display> <div data-timepicker-display x-bind:data-cycle="is24 ? '24' : '12'">
<div data-timepicker-numbers> <div data-timepicker-numbers>
<button <button
type="button" type="button"
@@ -236,13 +238,25 @@
></button> ></button>
</div> </div>
<div data-timepicker-period role="group" aria-label="{{ __('Select AM or PM') }}" x-show="! is24"> <div
data-timepicker-period
role="radiogroup"
aria-label="{{ __('Select AM or PM') }}"
x-show="! is24"
x-on:keydown.arrow-right.prevent="movePeriod($event)"
x-on:keydown.arrow-left.prevent="movePeriod($event)"
x-on:keydown.arrow-down.prevent="movePeriod($event)"
x-on:keydown.arrow-up.prevent="movePeriod($event)"
>
@foreach ([false => 'am', true => 'pm'] as $pm => $period) @foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button <button
type="button" type="button"
data-timepicker-period-option="{{ $period }}" data-timepicker-period-option="{{ $period }}"
class="state-layer focus-ring" class="state-layer focus-ring"
x-bind:aria-pressed="({{ $pm ? '' : '! ' }}isPm).toString()" role="radio"
aria-checked="false"
x-bind:aria-checked="({{ $pm ? '' : '! ' }}isPm).toString()"
x-bind:tabindex="({{ $pm ? '' : '! ' }}isPm) ? 0 : -1"
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})" x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})" x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]" x-text="periods[{{ (int) $pm }}]"
@@ -330,13 +344,25 @@
</div> </div>
@endforeach @endforeach
<div data-timepicker-period role="group" aria-label="{{ __('Select AM or PM') }}" x-show="! is24"> <div
data-timepicker-period
role="radiogroup"
aria-label="{{ __('Select AM or PM') }}"
x-show="! is24"
x-on:keydown.arrow-right.prevent="movePeriod($event)"
x-on:keydown.arrow-left.prevent="movePeriod($event)"
x-on:keydown.arrow-down.prevent="movePeriod($event)"
x-on:keydown.arrow-up.prevent="movePeriod($event)"
>
@foreach ([false => 'am', true => 'pm'] as $pm => $period) @foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button <button
type="button" type="button"
data-timepicker-period-option="{{ $period }}" data-timepicker-period-option="{{ $period }}"
class="state-layer focus-ring" class="state-layer focus-ring"
x-bind:aria-pressed="({{ $pm ? '' : '! ' }}isPm).toString()" role="radio"
aria-checked="false"
x-bind:aria-checked="({{ $pm ? '' : '! ' }}isPm).toString()"
x-bind:tabindex="({{ $pm ? '' : '! ' }}isPm) ? 0 : -1"
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})" x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})" x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]" x-text="periods[{{ (int) $pm }}]"
+2 -2
View File
@@ -82,7 +82,7 @@ it('opens the dial on the hour, as the bound time says', function () {
->assertAttribute('#meeting-dialog [data-timepicker-box="hour"]', 'aria-pressed', 'true') ->assertAttribute('#meeting-dialog [data-timepicker-box="hour"]', 'aria-pressed', 'true')
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '09') ->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '09')
->assertSeeIn('#meeting-dialog [data-timepicker-box="minute"]', '30') ->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-display] [data-timepicker-period-option="am"]', 'aria-checked', 'true')
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuenow', '9') ->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuenow', '9')
->assertScript('document.activeElement === '.inPicker('meeting', '[data-timepicker-dial]')); ->assertScript('document.activeElement === '.inPicker('meeting', '[data-timepicker-dial]'));
}); });
@@ -131,7 +131,7 @@ it('turns three o\'clock into 15 with PM', function () {
timeProbe() timeProbe()
->click('#meeting') ->click('#meeting')
->click('#meeting-dialog [data-timepicker-display] [data-timepicker-period-option="pm"]') ->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') ->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="hour12"][data-value="3"]')
->click('#meeting-dialog [data-timepicker-label="minute"][data-value="15"]') ->click('#meeting-dialog [data-timepicker-label="minute"][data-value="15"]')
->click('#meeting-dialog [data-timepicker-confirm]') ->click('#meeting-dialog [data-timepicker-confirm]')
@@ -73,6 +73,12 @@ it('offers the input variant, the period selector and the actions', function ()
->toContain('data-timepicker-period-option="am"') ->toContain('data-timepicker-period-option="am"')
->toContain('data-timepicker-period-option="pm"') ->toContain('data-timepicker-period-option="pm"')
->toContain('aria-label="Select AM or 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('aria-label="Switch to text input mode"') ->toContain('aria-label="Switch to text input mode"')
->toContain('aria-label="Switch to clock mode"') ->toContain('aria-label="Switch to clock mode"')
->toContain('data-timepicker-cancel') ->toContain('data-timepicker-cancel')