Build the theme and contrast rows on the connected button group

`mode="picker"` hand-rolled an outlined segmented button, point for point the
design M3 Expressive deprecates, and its own arrow keys neither wrapped nor
handled Up/Down. Both rows are now <x-group>: native radios in a fieldset, so the
browser supplies the radiogroup, both axes, the wrap and the roving tab stop, and
the store is bound through an x-model accessor — a contrast row still marks the
resolved level but writes through setContrast(). A new `label` prop legends the
row. Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-04, N-17).

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 06:00:07 +02:00
co-authored by Claude Opus 5
parent 98afef7b45
commit a99fbd3794
5 changed files with 85 additions and 69 deletions
@@ -821,7 +821,7 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
### `<x-theme-toggle>`
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (a row of three for settings pages), `contrast` (the same row for M3's standard, medium and high levels, marking the one in force). Every toggle on a page shares the store.
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (a row of three for settings pages), `contrast` (the same row for M3's standard, medium and high levels, marking the one in force). Every toggle on a page shares the store. Both rows are `<x-group>` — a connected button group over native radios, so the arrow keys, the wrap and the roving tab stop are the browser's; `label` adds a visible legend, and without one the row is still named for a screen reader. Nothing is checked until Alpine has read the store, because the theme is known only in the browser.
### `<x-scheme-picker>`
@@ -6,76 +6,79 @@
you a sun while the page is dark, a moon while it is light and it is a toggle button,
"Dark theme", pressed while dark.
- `cycle`: an icon button that steps light dark system, showing the choice it is on.
- `picker`: M3's segmented buttons for the three choices, for a settings page.
- `picker`: the three choices as a connected button group, for a settings page.
- `contrast`: the same row for M3's three contrast levels — standard, medium (3:1) and high
(7:1), the three the scheme is generated in. The row marks the level in force
(`resolvedContrast`), so the operating system's setting shows while the choice is
`system`, and choosing one is an explicit choice from then on.
The two rows are `<x-group>` M3 Expressive's connected button group, the successor of the
segmented button, which the expressive update deprecates. Its segments are native radios, so
the browser supplies the radiogroup semantics, both arrow axes, the wrap and the roving tab
stop; the store is bound through an `x-model` accessor on the wrapper, since the level a
contrast row marks is the resolved one but the level it writes goes through `setContrast()`.
`label` adds a visible legend (a settings page); without one the row is still named for a
screen reader. `class` lands on the button or on the group.
The theme is the visitor's, known only in the browser, so the parts that depend on it wait for
Alpine (`x-cloak`) rather than render a guess. `class` lands on the button or the group. --}}
Alpine (`x-cloak`, an unchecked radio) rather than render a guess. --}}
@props([
'mode' => 'toggle',
'label' => null,
])
@php
$mode = in_array($mode, ['toggle', 'cycle', 'picker', 'contrast'], true) ? $mode : 'toggle';
$row = [
'picker' => [
'name' => __('Theme'),
'field' => 'material-theme',
'read' => '$store.theme.choice',
'write' => '$store.theme.set(value)',
'options' => [
['id' => 'light', 'name' => __('Light'), 'icon' => 'light_mode'],
['id' => 'dark', 'name' => __('Dark'), 'icon' => 'dark_mode'],
['id' => 'system', 'name' => __('System'), 'icon' => 'brightness_auto'],
],
],
'contrast' => [
'name' => __('Contrast'),
'field' => 'material-contrast',
'read' => '$store.theme.resolvedContrast',
'write' => '$store.theme.setContrast(value)',
'options' => [
['id' => 'standard', 'name' => __('Standard'), 'icon' => 'contrast'],
['id' => 'medium', 'name' => __('Medium'), 'icon' => 'contrast_circle'],
['id' => 'high', 'name' => __('High'), 'icon' => 'contrast_square'],
],
],
][$mode] ?? null;
@endphp
@if ($mode === 'contrast')
@if ($row !== null)
<div
role="radiogroup"
aria-label="{{ __('Contrast') }}"
x-data
data-theme-toggle="contrast"
{{ $attributes->class(['inline-flex h-10 rounded-corner-full border border-outline']) }}
role="group"
aria-label="{{ $label ?? $row['name'] }}"
data-theme-toggle="{{ $mode }}"
x-data="{
get chosen() {
return this.{{ $row['read'] }}
},
set chosen(value) {
this.{{ $row['write'] }}
},
}"
>
@foreach (['standard' => ['Standard', 'contrast'], 'medium' => ['Medium', 'contrast_circle'], 'high' => ['High', 'contrast_square']] as $level => [$text, $icon])
<button
type="button"
role="radio"
aria-checked="false"
x-bind:aria-checked="($store.theme.resolvedContrast === '{{ $level }}').toString()"
x-bind:tabindex="$store.theme.resolvedContrast === '{{ $level }}' ? 0 : -1"
x-on:click="$store.theme.setContrast('{{ $level }}')"
x-on:keydown.arrow-right.prevent="$el.nextElementSibling?.click(); $el.nextElementSibling?.focus();"
x-on:keydown.arrow-left.prevent="$el.previousElementSibling?.click(); $el.previousElementSibling?.focus();"
data-contrast-option="{{ $level }}"
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
>
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
{{ __($text) }}
</button>
@endforeach
</div>
@elseif ($mode === 'picker')
<div
role="radiogroup"
aria-label="{{ __('Theme') }}"
x-data
data-theme-toggle="picker"
{{ $attributes->class(['inline-flex h-10 rounded-corner-full border border-outline']) }}
>
@foreach (['light' => ['Light', 'light_mode'], 'dark' => ['Dark', 'dark_mode'], 'system' => ['System', 'brightness_auto']] as $choice => [$text, $icon])
<button
type="button"
role="radio"
aria-checked="false"
x-bind:aria-checked="($store.theme.choice === '{{ $choice }}').toString()"
x-bind:tabindex="$store.theme.choice === '{{ $choice }}' ? 0 : -1"
x-on:click="$store.theme.set('{{ $choice }}')"
x-on:keydown.arrow-right.prevent="$el.nextElementSibling?.click(); $el.nextElementSibling?.focus();"
x-on:keydown.arrow-left.prevent="$el.previousElementSibling?.click(); $el.previousElementSibling?.focus();"
data-theme-option="{{ $choice }}"
class="state-layer focus-ring inline-flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-2 border-outline px-3 type-label-lg text-on-surface not-first:border-s first:rounded-s-corner-full last:rounded-e-corner-full aria-checked:bg-secondary-container aria-checked:text-on-secondary-container"
>
<x-livewire-material::icon name="check" class="hidden size-4.5 in-aria-checked:block" />
<x-livewire-material::icon :name="$icon" class="size-4.5 in-aria-checked:hidden" />
{{ __($text) }}
</button>
@endforeach
<x-livewire-material::group
inline
x-model="chosen"
:label="$label"
:name="$row['field']"
:options="$row['options']"
{{ $attributes }}
/>
</div>
@else
<button
+2 -2
View File
@@ -120,14 +120,14 @@ it('moves focus along a toolbar with the arrow keys', function () {
it('switches the theme from a toggle, a cycle and a picker', function () {
$page = barsProbe()
->click('[data-theme-option="light"]')
->click('label:has(input[name="material-theme"][value="light"])')
->assertScript("document.documentElement.dataset.theme === 'light'")
->assertAttribute('#toggle', 'aria-pressed', 'false');
$page->click('#toggle')
->assertScript("document.documentElement.dataset.theme === 'dark'")
->assertAttribute('#toggle', 'aria-pressed', 'true')
->assertAttribute('[data-theme-option="dark"]', 'aria-checked', 'true');
->assertScript('document.querySelector(\'input[name="material-theme"][value="dark"]\').checked');
$page->click('#cycle')
->assertScript("document.documentElement.dataset.themeChoice === 'system'")
+4 -4
View File
@@ -31,9 +31,9 @@ it('keeps the visitor\'s choice over the operating system', function () {
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->assertScript(theme('data-theme', 'dark'));
$page->click('[data-theme-option="light"]')
$page->click('label:has(input[name="material-theme"][value="light"])')
->assertScript(theme('data-theme', 'light'))
->assertAttribute('[data-theme-option="light"]', 'aria-checked', 'true')
->assertScript('document.querySelector(\'input[name="material-theme"][value="light"]\').checked')
->assertScript("localStorage.getItem('material-theme') === 'light'");
$page->refresh()
@@ -98,7 +98,7 @@ it('adds a theme-color meta in the painted surface, and follows the theme and th
->assertScript(themeColorIs($profiles['baseline']['light']['surface']))
->assertScript(pageSurfaceIs($profiles['baseline']['light']['surface']));
$page->click('[data-theme-option="dark"]')
$page->click('label:has(input[name="material-theme"][value="dark"])')
->assertScript(theme('data-theme', 'dark'))
->assertScript(themeColorIs($profiles['baseline']['dark']['surface']))
->assertScript(pageSurfaceIs($profiles['baseline']['dark']['surface']));
@@ -108,7 +108,7 @@ it('adds a theme-color meta in the painted surface, and follows the theme and th
->assertScript(themeColorIs($profiles['rose']['dark']['surface']))
->assertScript(pageSurfaceIs($profiles['rose']['dark']['surface']));
$page->click('[data-theme-option="light"]')
$page->click('label:has(input[name="material-theme"][value="light"])')
->assertScript(themeColorIs($profiles['rose']['light']['surface']))
->assertScript(pageSurfaceIs($profiles['rose']['light']['surface']))
->assertNoJavaScriptErrors();
+22 -9
View File
@@ -81,14 +81,25 @@ it('keeps a toolbar at the bottom clear of the navigation bar', function () {
it('offers M3\'s three contrast levels, marking the one in force', function () {
expect((string) $this->blade('<x-theme-toggle mode="contrast" />'))
->toContain('data-theme-toggle="contrast"')
->toContain('role="radiogroup"')
->toContain('aria-label="Contrast"')
->toContain('data-contrast-option="standard"')
->toContain('data-contrast-option="medium"')
->toContain('data-contrast-option="high"')
->toContain("\$store.theme.setContrast('high')")
// A connected button group over native radios, not the deprecated segmented button (N-04).
->toContain('data-button-group="connected"')
->toContain('name="material-contrast"')
->toContain('value="standard"')
->toContain('value="medium"')
->toContain('value="high"')
->toContain('$store.theme.setContrast(value)')
// The row follows the resolved level, so the operating system's shows under `system`.
->toContain("(\$store.theme.resolvedContrast === 'medium').toString()");
->toContain('return this.$store.theme.resolvedContrast');
});
it('names a theme row for a screen reader and legends it on request', function () {
expect((string) $this->blade('<x-theme-toggle mode="picker" />'))
->toContain('aria-label="Theme"')
->not->toContain('<legend')
->and((string) $this->blade('<x-theme-toggle mode="picker" label="Appearance" />'))
->toContain('aria-label="Appearance"')
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Appearance</legend>');
});
it('switches the theme through the store in three shapes', function () {
@@ -102,9 +113,11 @@ it('switches the theme through the store in three shapes', function () {
->toContain('data-theme-toggle="cycle"')
->toContain("{ light: 'dark', dark: 'system', system: 'light' }")
->and((string) $this->blade('<x-theme-toggle mode="picker" />'))
->toContain('role="radiogroup"')
->toContain('data-theme-option="system"')
->toContain("\$store.theme.set('dark')");
->toContain('data-theme-toggle="picker"')
->toContain('x-model="chosen"')
->toContain('name="material-theme"')
->toContain('value="system"')
->toContain('$store.theme.set(value)');
});
it('opens an account menu from the initials of a name', function () {