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
242 lines
12 KiB
PHP
242 lines
12 KiB
PHP
{{-- 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
|