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
301 lines
18 KiB
PHP
301 lines
18 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. --}}
|
||
|
||
@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
|