Files
livewire-material/resources/views/components/chip.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:29:21 +02:00

242 lines
12 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.
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([
'data-md-chip-action' => '',
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes, $noWireNavigate),
'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,
]);
} else {
$tag = match (true) {
$checkbox => 'label',
$isLink => 'a',
default => 'button',
};
$control = $checkbox
? $attributes->except($outer)->merge([
'type' => 'checkbox',
'name' => $name,
'value' => $value,
'checked' => $checked ? true : null,
'disabled' => $disabled ? true : null,
'class' => 'md-visually-hidden',
])
: null;
// $state's own filtering (above) drops a null 'style' here same as everywhere else, so this
// one is not wrapped again.
$container = ($checkbox ? $attributes->only($outer) : $attributes)
->merge($state([
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes, $noWireNavigate),
'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,
]))
// The chip itself is the control here (never for an input chip, whose action and remove
// button draw their own layers): the foundation's state layer and 48px target, its
// `:has(:focus-visible)` and `[data-md-disabled]` extras kept in chip.css alone.
->class(['md-state-layer', 'md-touch-target']);
}
@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