Files
livewire-material/resources/views/components/chip.blade.php
T
Andreas Reinhold / reiniandClaude Fable 5.1 dd3d8afdb9 Walk a chip set with the arrow keys
M3's chip keyboard table makes a set one tab stop that the arrows move
through; ten filter chips cost ten Tab presses today. The set now keeps a
roving tabindex — re-applied whenever a Livewire morph rewrites the chips
— and walks left, right, Home and End, following the writing direction.
The input chip's remove button also grows to a full 48x48 target, where
it was 34px wide.

Plan step 20, findings IN-13 and IN-14.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:05:41 +02:00

301 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{-- An M3 chip assist, filter, input or suggestion in one component, picked by `type`.
<x-chip label="Add to calendar" icon="event" wire:click="addToCalendar" />
<x-chip type="filter" label="Photos" value="photos" wire:model.live="kinds" />
<x-chip type="input" label="anna@example.com" avatar="AM" removable wire:remove="removeRecipient(3)" wire:key="recipient-3" />
<x-chip type="suggestion" label="Sounds good" wire:click="reply('Sounds good')" />
`assist` (the default) is an action: a button, or an anchor with `link` (`wire:navigate` unless
`external` or `no-wire-navigate`; `disabled` works on a link too, as `aria-disabled`). Its icons
are primary. `suggestion` is the same in on-surface-variant, for a suggested reply or query.
`filter` is a toggle. With `wire:model`, `x-model` or `name` it is a native checkbox under the
chip, so Livewire and Alpine bind it as they bind any checkbox to a boolean, or to an array
with `value` for a multi-select set and a form submits it; `selected` is then only the
initial state without a model. Without any of them it is a toggle button whose `selected`
(`aria-pressed`) the caller owns, flipped by its own `wire:click`. Selected, it takes
secondary-container and a check that grows in at the start; with an `icon`, the check takes the
icon's place.
`input` is something a person entered: `avatar` (an image URL, or initials) or `icon` at the
start, `selected`, and `removable` for a trailing remove button named "Remove <label>". Removing
calls `wire:remove` (a Livewire action) or `remove` (an Alpine expression); with neither, the
chip takes itself off the page, hidden `name`/`value` input and all. Backspace or Delete on a
focused chip removes it too, and focus moves to the previous (Backspace) or next (Delete) chip.
The chip is a button only when it has something of its own to do (`wire:click`, `x-on:click`,
`link`, `selected`); otherwise the remove button is its one stop. Give a removable chip in a
Livewire loop a `wire:key`.
`elevated` draws assist, filter and suggestion chips on surface-container-low at elevation 1
instead of the outline; input chips are flat only, as in M3. `tooltip` attaches a plain tooltip.
Attributes: on a filter checkbox and an input chip, `class`, `style` and `wire:key` stay on the
chip and every other attribute goes to the control inside (the checkbox, or the chip's button).
Values from androidx Compose Material 3 (Chip.kt, AssistChipTokens, FilterChipTokens,
InputChipTokens, SuggestionChipTokens at androidx/androidx 27cf9a7, Apache-2.0): 32px tall,
small corner, label-large, 18px icons, 24px avatar; 16px padding beside a label, 8px beside an
icon, 8px between (an input chip: 12px, 8px, 4px beside an avatar). The chip and the remove
button each catch presses over 48×48, which M3 asks for even though it means reaching past the
chip and over the label's own strip ("the target may extend beyond the visible chip
container"). The check grows in on the fast spatial spring and fades in on the slow effects
one; it shrinks on default effects and fades on fast effects, as AnimatingChipContent does. --}}
@props([
'type' => 'assist',
'label' => null,
'icon' => null,
'iconRight' => null,
'avatar' => null,
'elevated' => false,
'selected' => null,
'removable' => false,
'remove' => null,
'link' => null,
'external' => false,
'noWireNavigate' => false,
'disabled' => false,
'name' => null,
'value' => null,
'tooltip' => null,
])
@php
$kind = in_array($type, ['assist', 'filter', 'input', 'suggestion'], true) ? $type : 'assist';
$text = $label ?? trim(strip_tags((string) $slot));
$isLink = filled($link) && $kind !== 'filter';
$inert = $disabled && $isLink;
$raised = $elevated && $kind !== 'input';
$initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.');
$anchor = $tooltip !== null ? '--material-chip-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
// A filter chip is a native checkbox whenever something binds or submits it.
$checkbox = $kind === 'filter' && ($attributes->whereStartsWith(['wire:model', 'x-model'])->isNotEmpty() || filled($name));
$wireModel = $attributes->whereStartsWith('wire:model')->first();
$checked = (bool) $selected;
// Rendered as the bound property already says, so nothing flips (and animates) once Livewire starts.
if ($checkbox && $wireModel !== null && ($component = \Livewire\Livewire::current()) !== null) {
$bound = data_get($component, $wireModel);
$checked = is_array($bound)
? in_array((string) $value, array_map(fn ($item): string => is_scalar($item) ? (string) $item : '', $bound), true)
: (bool) $bound;
}
$wireRemove = $attributes->get('wire:remove');
$attributes = $attributes->except(['wire:remove']);
$outer = ['class', 'style', 'wire:key'];
// Compose draws the 1px border inside the chip; a CSS border takes room, so the padding
// beside it is 1px short of the token (7px for 8, 15px for 16).
$base = 'group/chip inline-flex h-8 max-w-full shrink-0 select-none items-center whitespace-nowrap rounded-corner-sm border type-label-lg transition-[background-color,border-color,color,box-shadow] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast';
// Below 48px the touch target reaches past the chip, as M3 requires.
$target = 'after:absolute after:inset-x-0 after:top-1/2 after:h-12 after:-translate-y-1/2';
// Every colour class written out whole, so Tailwind compiles it. Selected is a state the browser
// owns on a filter chip (a checked checkbox, or aria-pressed), so it is written as a variant.
$colours = match (true) {
$kind === 'filter' && $raised && $disabled => 'border-transparent bg-on-surface/12 text-on-surface/38',
$kind === 'filter' && $raised => 'border-transparent bg-surface-container-low text-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2 has-checked:bg-secondary-container has-checked:text-on-secondary-container aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container',
$kind === 'filter' && $disabled => 'border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12 aria-pressed:border-transparent aria-pressed:bg-on-surface/12',
$kind === 'filter' => 'border-outline-variant text-on-surface-variant focus-visible:border-on-surface-variant has-focus-visible:border-on-surface-variant has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container hover:has-checked:shadow-elevation-1 aria-pressed:border-transparent aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container hover:aria-pressed:shadow-elevation-1',
$kind === 'input' && $disabled => $selected ? 'border-transparent bg-on-surface/12 text-on-surface/38' : 'border-on-surface/12 text-on-surface/38',
$kind === 'input' => $selected ? 'border-transparent bg-secondary-container text-on-secondary-container' : 'border-outline-variant text-on-surface-variant has-[[data-chip-action]:focus-visible]:border-on-surface-variant',
$raised && $disabled => 'border-transparent bg-on-surface/12 text-on-surface/38',
$raised => $kind === 'suggestion'
? 'border-transparent bg-surface-container-low text-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2'
: 'border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2',
$disabled => 'border-on-surface/12 text-on-surface/38',
default => $kind === 'suggestion'
? 'border-outline-variant text-on-surface-variant focus-visible:border-on-surface-variant'
: 'border-outline-variant text-on-surface focus-visible:border-on-surface',
};
// Leading icons: primary on assist, suggestion and filter chips; on-surface-variant on an input
// chip, primary while it is hovered, focused or pressed, and primary on a selected one.
$clicks = $attributes->whereStartsWith(['wire:click', 'x-on:click', '@click'])->isNotEmpty();
$leadingInk = match (true) {
$disabled => null,
$kind !== 'input', (bool) $selected => 'text-primary',
$isLink || $clicks => 'transition-colors duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-hover/chip:text-primary group-active/chip:text-primary group-has-focus-visible/chip:text-primary',
default => null,
};
$trailingInk = in_array($kind, ['assist', 'suggestion'], true) && ! $disabled ? 'text-primary' : null;
$check = 'size-4.5 opacity-0 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-100 group-has-checked/chip:duration-(--md-sys-motion-effects-slow-duration) group-has-checked/chip:ease-effects-slow group-aria-pressed/chip:opacity-100 group-aria-pressed/chip:duration-(--md-sys-motion-effects-slow-duration) group-aria-pressed/chip:ease-effects-slow';
if ($kind === 'input') {
$actionTag = match (true) {
$isLink => 'a',
$clicks || $selected !== null => 'button',
default => 'span',
};
$interactive = $actionTag !== 'span';
$removeExpression = implode('; ', array_filter([
'removeChip($event)',
filled($remove) ? $remove : null,
blank($remove) && blank($wireRemove) ? '$root.remove()' : null,
]));
$container = $attributes->only($outer)
->class([
$base,
'relative isolate',
$colours,
'has-[[data-chip-action]:focus-visible]:outline-3 has-[[data-chip-action]:focus-visible]:outline-offset-2 has-[[data-chip-action]:focus-visible]:outline-secondary',
])
->merge(array_filter(['style' => $anchor ? "anchor-name: {$anchor}" : null]));
$action = $attributes->except($outer)
->class([
'flex h-full min-w-0 items-center outline-none',
match (true) {
filled($avatar) => 'ps-0.75',
filled($icon) => 'ps-1.75',
default => 'ps-2.75',
},
$removable ? 'pe-2' : 'pe-2.75',
'cursor-pointer before:absolute before:-inset-px before:-z-10 before:rounded-corner-sm before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => $interactive && ! $disabled,
$target => $interactive,
'cursor-not-allowed' => $interactive && $disabled,
'pointer-events-none' => $inert,
])
->merge(array_filter([
'data-chip-action' => $interactive ? true : null,
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
'aria-disabled' => $inert ? 'true' : null,
'tabindex' => $inert ? '-1' : null,
'type' => $actionTag === 'button' ? 'button' : null,
'disabled' => $actionTag === 'button' && $disabled ? true : null,
'aria-pressed' => $actionTag === 'button' && $selected !== null ? ($selected ? 'true' : 'false') : null,
], fn ($attribute): bool => $attribute !== null));
} else {
$tag = match (true) {
$checkbox => 'label',
$isLink => 'a',
default => 'button',
};
$control = $checkbox
? $attributes->except($outer)->merge(array_filter([
'type' => 'checkbox',
'name' => $name,
'value' => $value,
'checked' => $checked ? true : null,
'disabled' => $disabled ? true : null,
], fn ($attribute): bool => $attribute !== null))->class('peer sr-only')
: null;
$container = ($checkbox ? $attributes->only($outer) : $attributes)
->class([
$base,
'state-layer relative',
'focus-ring' => ! $checkbox,
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary has-focus-visible:before:opacity-10' => $checkbox,
$kind === 'filter' || filled($icon) ? 'ps-1.75' : 'ps-3.75',
$iconRight ? 'pe-1.75' : 'pe-3.75',
$colours,
$target,
'cursor-pointer' => ! $disabled,
'cursor-not-allowed' => $disabled && ! $isLink,
'pointer-events-none' => $inert,
])
->merge(array_filter([
'data-chip' => $kind,
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
'aria-disabled' => $inert ? 'true' : null,
'tabindex' => $inert ? '-1' : null,
'type' => $tag === 'button' ? 'button' : null,
'disabled' => $tag === 'button' && $disabled ? true : null,
'name' => $tag === 'button' ? $name : null,
'value' => $tag === 'button' ? $value : null,
'aria-pressed' => $kind === 'filter' && ! $checkbox ? ($selected ? 'true' : 'false') : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($attribute): bool => $attribute !== null));
}
@endphp
@if ($kind === 'input')
<span data-chip="input" @if ($removable) x-data="materialChip" x-on:keydown="removeOnKey($event)" @endif {{ $container }}>
<{{ $actionTag }} {{ $action }}>
@if ($avatar)
@if ($initials)
<span aria-hidden="true" @class(['me-2 grid size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container type-label-sm text-on-primary-container', 'opacity-38' => $disabled])>{{ $avatar }}</span>
@else
<img src="{{ $avatar }}" alt="" @class(['me-2 size-6 shrink-0 rounded-corner-full object-cover', 'opacity-38' => $disabled]) />
@endif
@elseif ($icon)
<x-livewire-material::icon :name="$icon" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
@endif
<span data-chip-label class="truncate">{{ $label ?? $slot }}</span>
</{{ $actionTag }}>
@if ($removable)
<button
type="button"
@if (filled($text)) data-chip-remove aria-label="{{ __('Remove :label', ['label' => $text]) }}" @else data-chip-remove="{{ __('Remove :label') }}" @endif
@if ($wireRemove) wire:click="{{ $wireRemove }}" @endif
x-on:click="{{ $removeExpression }}"
@disabled($disabled)
@class([
'relative me-1.75 grid size-4.5 shrink-0 place-items-center rounded-corner-full',
'cursor-pointer focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary' => ! $disabled,
'before:absolute before:-inset-0.75 before:rounded-corner-full before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => ! $disabled,
'after:absolute after:-inset-3.75',
'cursor-not-allowed' => $disabled,
])
>
<x-livewire-material::icon name="close" class="size-4.5" optical="20" />
</button>
@endif
@if (filled($name))
<input type="hidden" name="{{ $name }}" value="{{ $value ?? $text }}" />
@endif
@if ($tooltip !== null)
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</span>
@else
<{{ $tag }} {{ $container }}>
@if ($checkbox)
<input {{ $control }} />
@endif
@if ($kind === 'filter')
{{-- The check grows in from nothing; beside an icon it takes the icon's place. --}}
<span @class([
'me-2 grid shrink-0 overflow-hidden *:col-start-1 *:row-start-1',
'size-4.5' => filled($icon),
'h-4.5 w-0 transition-[width] duration-(--md-sys-motion-effects-default-duration) ease-effects-default group-has-checked/chip:w-4.5 group-has-checked/chip:duration-(--md-sys-motion-spatial-fast-duration) group-has-checked/chip:ease-spatial-fast group-aria-pressed/chip:w-4.5 group-aria-pressed/chip:duration-(--md-sys-motion-spatial-fast-duration) group-aria-pressed/chip:ease-spatial-fast' => blank($icon),
])>
@if ($icon)
<x-livewire-material::icon :name="$icon" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['size-4.5 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-0 group-aria-pressed/chip:opacity-0', $leadingInk])" />
@endif
<x-livewire-material::icon name="check" data-chip-check optical="20" :class="$check" />
</span>
@elseif ($icon)
<x-livewire-material::icon :name="$icon" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
@endif
<span class="truncate">{{ $label ?? $slot }}</span>
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
@endif
@if ($tooltip !== null)
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</{{ $tag }}>
@endif