Files
Andreas Reinhold / reiniandClaude Opus 5 e3fcbf39ed Hide field, search and chip text with md-visually-hidden again
Plan step 36 review. 52fd65a3 replaced the md-visually-hidden class on
field's spoken counter, search's result count and a bound filter chip's
native checkbox with hooks carrying copies of its clip, on the reading
that a package view writes no class but the interaction classes. The
brief and step 34 let a view write the text classes too, and one shared
source beats three copies: the class is back, the copies and the
data-md-field-live and data-md-chip-native hooks are gone
(data-md-search-status predates them and stays). chip.css keeps its
tooltip.css import.

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

242 lines
12 KiB
PHP
Raw Permalink 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.
The chip renders `data-md-chip` with its type (`data-md-chip="filter"`), `data-md-elevated`,
`data-md-disabled` and, on an input chip, `data-md-selected`; its colours, sizes and states are
resources/css/components/chip.css. --}}
@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'];
// An input chip's leading icon turns primary while a chip with an action of its own is hovered,
// focused or pressed.
$clicks = $attributes->whereStartsWith(['wire:click', 'x-on:click', '@click'])->isNotEmpty();
$actionable = $kind === 'input' && ! $disabled && ! $selected && ($isLink || $clicks);
$state = fn (array $more = []): array => array_filter([
'data-md-chip' => $kind,
'data-md-elevated' => $raised ? '' : null,
'data-md-disabled' => $disabled ? '' : null,
'data-md-selected' => $kind === 'input' && $selected ? '' : null,
'data-md-actionable' => $actionable ? '' : null,
'data-md-icon-start' => filled($icon) || ($kind === 'input' && filled($avatar)) ? '' : null,
'data-md-icon-end' => filled($iconRight) && $kind !== 'input' ? '' : null,
...$more,
], fn ($attribute): bool => $attribute !== null);
if ($kind === 'input') {
$actionTag = match (true) {
$isLink => 'a',
$clicks || $selected !== null => 'button',
default => 'span',
};
$removeExpression = implode('; ', array_filter([
'removeChip($event)',
filled($remove) ? $remove : null,
blank($remove) && blank($wireRemove) ? '$root.remove()' : null,
]));
$container = $attributes->only($outer)
->merge($state([
'data-md-avatar' => filled($avatar) ? '' : null,
'data-md-removable' => $removable ? '' : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
]));
$action = $attributes->except($outer)
->merge(array_filter([
'data-md-chip-action' => '',
'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,
'class' => 'md-visually-hidden',
], fn ($attribute): bool => $attribute !== null))
: null;
$container = ($checkbox ? $attributes->only($outer) : $attributes)
->merge($state(array_filter([
'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 @if ($removable) x-data="materialChip" x-on:keydown="removeOnKey($event)" @endif {{ $container }}>
<{{ $actionTag }} {{ $action }}>
@if ($avatar)
@if ($initials)
<span aria-hidden="true" data-md-chip-avatar>{{ $avatar }}</span>
@else
<img src="{{ $avatar }}" alt="" data-md-chip-avatar />
@endif
@elseif ($icon)
<x-livewire-material::icon :name="$icon" size="18" data-md-chip-icon />
@endif
<span data-md-chip-label>{{ $label ?? $slot }}</span>
</{{ $actionTag }}>
@if ($removable)
<button
type="button"
@if (filled($text)) data-md-chip-remove aria-label="{{ __('Remove :label', ['label' => $text]) }}" @else data-md-chip-remove="{{ __('Remove :label') }}" @endif
@if ($wireRemove) wire:click="{{ $wireRemove }}" @endif
x-on:click="{{ $removeExpression }}"
@disabled($disabled)
>
<x-livewire-material::icon name="close" size="18" />
</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 data-md-chip-check-slot>
@if ($icon)
<x-livewire-material::icon :name="$icon" size="18" data-md-chip-icon />
@endif
<x-livewire-material::icon name="check" size="18" data-md-chip-check />
</span>
@elseif ($icon)
<x-livewire-material::icon :name="$icon" size="18" data-md-chip-icon />
@endif
<span data-md-chip-label>{{ $label ?? $slot }}</span>
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" size="18" data-md-chip-trailing />
@endif
@if ($tooltip !== null)
<x-livewire-material::tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</{{ $tag }}>
@endif