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:
co-authored by
Claude Opus 5
parent
98afef7b45
commit
a99fbd3794
@@ -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
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user