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,
* small corner; primary-container when selected, otherwise
* 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
* 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
@@ -20,9 +22,11 @@
* "Minute" (or the error) below in body-small, the period 52×72
* [data-timepicker-actions] a 48px row: the mode toggle, then Cancel and OK
*
* The period selector is Compose's current one (ComposeMaterial3Flags.isUpdatedTimepickerToggleEnabled,
* on by default): two toggle buttons 4px apart, round and surface-container-lowest when off, a 12px
* corner and primary-container with a bold label when on, not the outlined pair of the tokens.
* 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
* 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
* 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);
}
/* 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] {
display: grid;
place-items: center;
@@ -165,20 +176,22 @@
}
[data-timepicker-period] > button:active,
[data-timepicker-period] > button[aria-pressed="true"] {
[data-timepicker-period] > button[aria-checked="true"] {
border-radius: 0.75rem;
}
[data-timepicker-period] > button[aria-pressed="true"] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
/* 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"] {
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 {
cursor: default;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
}
/* ---- The dial ----------------------------------------------------------------------------- */
@@ -436,6 +449,10 @@
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] {
flex-direction: row;
width: 13.5rem;
+15
View File
@@ -380,6 +380,21 @@ document.addEventListener('alpine:init', () => {
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). */
nearestInPeriod(pm, time) {
let best = null
+39 -13
View File
@@ -31,14 +31,16 @@
TimePickerTokens and TimeInputTokens a surface-container-high dialog with an extra-large
corner and elevation 3, a 256dp surface-container-highest dial with body-large numbers, a primary
selector with a 48dp handle, a 2dp line and an 8dp centre, 96×80 time selector boxes in
display-large (primary-container when selected), 96×72 time fields in display-medium and
TimePicker.kt and TimePickerDialog.kt for the layout, the 24-hour inner ring (1223, at 69dp;
0011 outside at 101dp, as Material Components for Android labels them too), the gestures, the
move on to minutes and the error texts. The period selector is Compose's current default
(`isUpdatedTimepickerToggleEnabled`): two separate shape-morphing toggle buttons in
primary-container, not the outlined pair its tokens still describe. The dial's numbers are
aria-hidden; the dial is a `slider` whose value text names the hour or minute. The dialog is
`wire:ignore`, so a Livewire render never closes an open picker or resets its draft. --}}
display-large (primary-container when selected; 114dp wide on a 24-hour clock, where there is no
period selector beside them, per TimeSelector24HVerticalContainerWidth), 96×72 time fields in
display-medium and TimePicker.kt and TimePickerDialog.kt for the layout, the 24-hour inner
ring (1223, at 69dp; 0011 outside at 101dp, as Material Components for Android labels them
too), the gestures, the move on to minutes and the error texts. The period selector keeps
Compose's current default shape morph (`isUpdatedTimepickerToggleEnabled`) but M3's own
tertiary-container, which is what holds hour/minute (primary-container) and AM/PM apart; it is a
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([
'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>
<div data-timepicker-picker>
<div data-timepicker-display>
<div data-timepicker-display x-bind:data-cycle="is24 ? '24' : '12'">
<div data-timepicker-numbers>
<button
type="button"
@@ -236,13 +238,25 @@
></button>
</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)
<button
type="button"
data-timepicker-period-option="{{ $period }}"
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-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]"
@@ -330,13 +344,25 @@
</div>
@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)
<button
type="button"
data-timepicker-period-option="{{ $period }}"
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-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
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')
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '09')
->assertSeeIn('#meeting-dialog [data-timepicker-box="minute"]', '30')
->assertAttribute('#meeting-dialog [data-timepicker-display] [data-timepicker-period-option="am"]', 'aria-pressed', 'true')
->assertAttribute('#meeting-dialog [data-timepicker-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]'));
});
@@ -131,7 +131,7 @@ it('turns three o\'clock into 15 with PM', function () {
timeProbe()
->click('#meeting')
->click('#meeting-dialog [data-timepicker-display] [data-timepicker-period-option="pm"]')
->assertAttribute('#meeting-dialog [data-timepicker-display] [data-timepicker-period-option="pm"]', 'aria-pressed', 'true')
->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]')
@@ -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="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 clock mode"')
->toContain('data-timepicker-cancel')