Files
livewire-material/resources/views/components/theme-toggle.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 4a4982b1ef Reach M3's 48px target from every 40px navigation control
The theme toggle and the account-menu avatar are drawn 40px and had no target
extension; both now carry `touch-target`, and the avatar's clipping moves from
the button (which would have cut the pseudo-target off) to the <img>. The rail's
hand-rolled `after:` target becomes the same utility.
Plan: docs/plans/material-3-alignment.md, step 14 (navigation N-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 05:52:09 +02:00

108 lines
6.2 KiB
PHP

{{-- Switches the colour theme through `$store.theme` (resources/js/theme.js), so every toggle on a
page reads and writes the one choice and none needs an id.
`mode`:
- `toggle` (the default): an icon button between light and dark. The icon is where it takes
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.
- `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 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. --}}
@props([
'mode' => 'toggle',
])
@php
$mode = in_array($mode, ['toggle', 'cycle', 'picker', 'contrast'], true) ? $mode : 'toggle';
@endphp
@if ($mode === 'contrast')
<div
role="radiogroup"
aria-label="{{ __('Contrast') }}"
x-data
data-theme-toggle="contrast"
{{ $attributes->class(['inline-flex h-10 rounded-corner-full border border-outline']) }}
>
@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
</div>
@else
<button
type="button"
x-data
data-icon-button
data-theme-toggle="{{ $mode }}"
@if ($mode === 'cycle')
aria-label="{{ __('Theme') }}"
x-bind:aria-label="{ light: @js(__('Theme: light')), dark: @js(__('Theme: dark')), system: @js(__('Theme: system')) }[$store.theme.choice]"
x-on:click="$store.theme.set({ light: 'dark', dark: 'system', system: 'light' }[$store.theme.choice])"
@else
aria-label="{{ __('Dark theme') }}"
aria-pressed="false"
x-bind:aria-pressed="($store.theme.resolved === 'dark').toString()"
x-on:click="$store.theme.toggle()"
@endif
{{ $attributes->class(['state-layer touch-target focus-ring inline-flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-corner-full text-on-surface-variant transition-[border-radius] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast active:rounded-corner-sm']) }}
>
@if ($mode === 'cycle')
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.choice === 'light'" />
<x-livewire-material::icon name="dark_mode" x-cloak x-show="$store.theme.choice === 'dark'" />
<x-livewire-material::icon name="brightness_auto" x-cloak x-show="$store.theme.choice === 'system'" />
@else
<x-livewire-material::icon name="light_mode" x-cloak x-show="$store.theme.resolved === 'dark'" />
<x-livewire-material::icon name="dark_mode" x-cloak x-show="$store.theme.resolved !== 'dark'" />
@endif
</button>
@endif