Package views now write <x-livewire-material::button>, so a configured prefix (which Blade spells <x-m::button>) and an application component of the same name can no longer break or shadow them. The showcase rewrites its examples to the configured prefix. Adds the README, and waits for the adaptive rail to hear a resize before the navigation tests press its menu button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
299 lines
18 KiB
PHP
299 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 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" :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-x-2 after:-inset-y-3.75',
|
|
'cursor-not-allowed' => $disabled,
|
|
])
|
|
>
|
|
<x-livewire-material::icon name="close" class="size-4.5" />
|
|
</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" :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 :class="$check" />
|
|
</span>
|
|
@elseif ($icon)
|
|
<x-livewire-material::icon :name="$icon" :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" :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
|