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
|
||||
|
||||
Reference in New Issue
Block a user