Draw the chips without Tailwind

<x-chip> renders data-md-chip with its type and its states and parts as
data-md-* attributes (elevated, disabled, selected, actionable, the
icon, check slot, avatar, action and remove button), and every colour,
size, target and transition moves into components/chip.css on tokens
(plan step 36). Icons take size 18; the filter checkbox is
md-visually-hidden. chips.js follows the renamed chip hooks.

A disabled filter chip drawn as a label no longer shows a hover state
layer, which M3 never gives a disabled control. Browser tests cover the
32px chip and the remove button's 48px target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 14:50:29 +02:00
co-authored by Claude Opus 5
parent b42069503d
commit d8072d878a
6 changed files with 606 additions and 176 deletions
+1
View File
@@ -24,6 +24,7 @@
@import './components/checkbox.css';
@import './components/radio.css';
@import './components/toggle.css';
@import './components/chip.css';
/* Containment */
+444
View File
@@ -0,0 +1,444 @@
/*
* <x-chip>: M3's assist, filter, input and suggestion chips (resources/views/components/chip.blade.php).
*
* From androidx Compose Material 3 (Chip.kt, AssistChipTokens, FilterChipTokens, InputChipTokens,
* SuggestionChipTokens at androidx/androidx 27cf9a7, Apache-2.0): 32px tall, the small corner,
* label-large, 18px icons and a 24px avatar; 16px padding beside a label and 8px beside an icon,
* 8px between (an input chip: 12px, 8px, and 4px beside an avatar). Compose draws the 1px border
* inside the chip; a CSS border takes room, so every padding beside it is 1px short of its token
* (7px for 8, 11px for 12, 15px for 16).
*
* Colours by type and state, each one role (never an opacity but M3's disabled 12% and 38%,
* tokens/state.css):
*
* flat outline-variant border; on-surface ink (assist) or on-surface-variant (the rest);
* the border turns on-surface (assist) or on-surface-variant while keyboard-focused
* elevated surface-container-low at elevation 1, 2 on hover, no border (not for input chips)
* selected a filter chip that is checked or pressed, an input chip marked `data-md-selected`:
* secondary-container with on-secondary-container ink, no border; a flat selected
* filter chip rises to elevation 1 on hover
* disabled on-surface ink at 38%; the border (flat) or the container (elevated, selected) at 12%
*
* Leading icons are primary on assist, suggestion and filter chips; on an input chip they are
* on-surface-variant, primary on a selected chip and while a chip with its own action is hovered,
* focused or pressed (`data-md-actionable`). A trailing icon is primary on assist and suggestion
* chips. A disabled chip's icons take its ink.
*
* The state layer is the chip's own ::before in the chip's ink (an input chip draws it on its
* action, since the remove button is a second control), at M3's hover, focus and press opacities;
* the focus indicator is the package's secondary ring outside the chip. Both the chip (or an input
* chip's action) and the remove button catch presses over 48×48 — M3 lets the target extend beyond
* the visible container. The filter chip's 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.
*
* [data-md-chip="assist|filter|input|suggestion"] the chip; data-md-elevated, data-md-disabled,
* data-md-selected, data-md-actionable,
* data-md-icon-start, data-md-icon-end,
* data-md-avatar, data-md-removable
* [data-md-chip-check-slot] a filter chip's check, over its icon when it has one
* [data-md-chip-icon], [data-md-chip-check]
* [data-md-chip-action] an input chip's own button or link (a <span> without one)
* [data-md-chip-avatar], [data-md-chip-icon], [data-md-chip-label]
* [data-md-chip-remove] an input chip's remove button
* [data-md-chip-label], [data-md-chip-icon], [data-md-chip-trailing]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
/* TODO(step 36): @import './tooltip.css' once <x-tooltip> leaves Tailwind (actions stream). */
@layer material.components {
[data-md-chip] {
--chip-disabled-container: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
--chip-disabled-ink: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
position: relative;
display: inline-flex;
flex-shrink: 0;
align-items: center;
max-width: 100%;
height: 32px;
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-sm);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
white-space: nowrap;
-webkit-user-select: none;
user-select: none;
transition-property: background-color, border-color, color, box-shadow;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
/* ---- Assist, filter and suggestion: the chip is the control ------------------------------ */
[data-md-chip]:not([data-md-chip='input']) {
isolation: isolate;
padding-inline: 15px;
outline: none;
cursor: pointer;
/* The 48px target, past the chip's 32px. */
&::after {
content: '';
position: absolute;
inset-inline: 0;
top: 50%;
height: var(--md-sys-measurement-space600);
translate: 0 -50%;
}
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: currentColor;
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:focus-visible::before,
&:has(:focus-visible)::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
&:is(:focus-visible, :has(:focus-visible)) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
&:is(:disabled, [aria-disabled='true'], [data-md-disabled])::before {
display: none;
}
}
[data-md-chip]:is([data-md-chip='filter'], [data-md-icon-start]):not([data-md-chip='input']) {
padding-inline-start: 7px;
}
[data-md-chip][data-md-icon-end] {
padding-inline-end: 7px;
}
[data-md-chip='assist'] {
color: var(--md-sys-color-on-surface);
}
[data-md-chip='assist']:not([data-md-elevated], [data-md-disabled]):focus-visible {
border-color: var(--md-sys-color-on-surface);
}
:is([data-md-chip='suggestion'], [data-md-chip='filter']):not([data-md-elevated], [data-md-disabled], :has(:checked), [aria-pressed='true']):is(:focus-visible, :has(:focus-visible)) {
border-color: var(--md-sys-color-on-surface-variant);
}
[data-md-chip][data-md-elevated] {
border-color: transparent;
background-color: var(--md-sys-color-surface-container-low);
box-shadow: var(--md-sys-elevation-1);
}
@media (hover: hover) {
[data-md-chip][data-md-elevated]:not([data-md-disabled]):hover {
box-shadow: var(--md-sys-elevation-2);
}
}
/* Selected: a checked filter chip, or a pressed one. */
[data-md-chip='filter']:is(:has(:checked), [aria-pressed='true']),
[data-md-chip='input'][data-md-selected] {
border-color: transparent;
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
@media (hover: hover) {
[data-md-chip='filter']:not([data-md-elevated], [data-md-disabled]):is(:has(:checked), [aria-pressed='true']):hover {
box-shadow: var(--md-sys-elevation-1);
}
}
[data-md-chip][data-md-disabled] {
border-color: var(--chip-disabled-container);
color: var(--chip-disabled-ink);
box-shadow: none;
}
[data-md-chip][data-md-disabled]:is([data-md-elevated], [data-md-selected], :has(:checked), [aria-pressed='true']) {
border-color: transparent;
background-color: var(--chip-disabled-container);
}
[data-md-chip][data-md-disabled]:not(a) {
cursor: not-allowed;
}
/* A disabled link leaves the pointer too. */
a[data-md-chip][aria-disabled='true'] {
pointer-events: none;
}
/* ---- Contents ------------------------------------------------------------------------------ */
[data-md-chip-label] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:is([data-md-chip], [data-md-chip-action]) > [data-md-chip-icon] {
margin-inline-end: var(--md-sys-measurement-space100);
}
[data-md-chip-trailing] {
margin-inline-start: var(--md-sys-measurement-space100);
}
[data-md-chip]:not([data-md-chip='input'], [data-md-disabled]) [data-md-chip-icon],
[data-md-chip='input'][data-md-selected]:not([data-md-disabled]) [data-md-chip-icon],
:is([data-md-chip='assist'], [data-md-chip='suggestion']):not([data-md-disabled]) [data-md-chip-trailing] {
color: var(--md-sys-color-primary);
}
[data-md-chip][data-md-actionable] [data-md-chip-icon] {
transition: color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-chip][data-md-actionable]:is(:active, :has(:focus-visible)) [data-md-chip-icon] {
color: var(--md-sys-color-primary);
}
@media (hover: hover) {
[data-md-chip][data-md-actionable]:hover [data-md-chip-icon] {
color: var(--md-sys-color-primary);
}
}
/* The filter chip's check: from nothing to 18px as it is selected, or over the icon it replaces. */
[data-md-chip-check-slot] {
display: grid;
flex-shrink: 0;
width: 0;
height: 18px;
margin-inline-end: var(--md-sys-measurement-space100);
overflow: hidden;
transition: width var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
[data-md-chip-check-slot] > * {
grid-area: 1 / 1;
}
[data-md-chip-check-slot]:has([data-md-chip-icon]) {
width: 18px;
transition: none;
}
[data-md-chip]:is(:has(:checked), [aria-pressed='true']) [data-md-chip-check-slot]:not(:has([data-md-chip-icon])) {
width: 18px;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-md-chip-check-slot] [data-md-chip-icon] {
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-chip-check] {
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-chip]:is(:has(:checked), [aria-pressed='true']) [data-md-chip-check-slot] [data-md-chip-icon] {
opacity: 0;
}
[data-md-chip]:is(:has(:checked), [aria-pressed='true']) [data-md-chip-check] {
opacity: 1;
transition-duration: var(--md-sys-motion-effects-slow-duration);
transition-timing-function: var(--md-sys-motion-effects-slow);
}
/* ---- Input chips: an action of their own, and a remove button ---------------------------- */
[data-md-chip='input'] {
isolation: isolate;
}
[data-md-chip='input']:not([data-md-selected], [data-md-disabled]):has([data-md-chip-action]:focus-visible) {
border-color: var(--md-sys-color-on-surface-variant);
}
[data-md-chip='input']:has([data-md-chip-action]:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-md-chip-action] {
display: flex;
align-items: center;
min-width: 0;
height: 100%;
padding-inline: 11px;
outline: none;
}
[data-md-chip][data-md-icon-start] [data-md-chip-action] {
padding-inline-start: 7px;
}
[data-md-chip][data-md-avatar] [data-md-chip-action] {
padding-inline-start: 3px;
}
[data-md-chip][data-md-removable] [data-md-chip-action] {
padding-inline-end: var(--md-sys-measurement-space100);
}
:is(a, button)[data-md-chip-action] {
cursor: pointer;
/* The 48px target and the state layer, across the whole chip. */
&::after {
content: '';
position: absolute;
inset-inline: 0;
top: 50%;
height: var(--md-sys-measurement-space600);
translate: 0 -50%;
}
}
[data-md-chip]:not([data-md-disabled]) :is(a, button)[data-md-chip-action] {
&::before {
content: '';
position: absolute;
inset: -1px;
z-index: -1;
border-radius: var(--md-sys-shape-corner-sm);
background-color: currentColor;
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:focus-visible::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
}
[data-md-chip][data-md-disabled] :is(a, button)[data-md-chip-action] {
cursor: not-allowed;
}
[data-md-chip][data-md-disabled] a[data-md-chip-action] {
pointer-events: none;
}
[data-md-chip-avatar] {
flex-shrink: 0;
width: 24px;
height: 24px;
margin-inline-end: var(--md-sys-measurement-space100);
border-radius: var(--md-sys-shape-corner-full);
}
span[data-md-chip-avatar] {
display: grid;
place-items: center;
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
font: var(--md-sys-typescale-label-sm);
letter-spacing: var(--md-sys-typescale-label-sm-tracking);
font-variation-settings: normal;
}
img[data-md-chip-avatar] {
object-fit: cover;
}
[data-md-chip][data-md-disabled] [data-md-chip-avatar] {
opacity: var(--md-sys-state-disabled-content-opacity);
}
/* An 18px icon in a 24px state layer, catching presses 15px out on every side: M3's 48px. */
[data-md-chip-remove] {
position: relative;
display: grid;
flex-shrink: 0;
place-items: center;
width: 18px;
height: 18px;
margin-inline-end: 7px;
border-radius: var(--md-sys-shape-corner-full);
outline: none;
cursor: pointer;
&::after {
content: '';
position: absolute;
inset: -15px;
}
&:disabled {
cursor: not-allowed;
}
&:not(:disabled) {
&::before {
content: '';
position: absolute;
inset: -3px;
border-radius: var(--md-sys-shape-corner-full);
background-color: currentColor;
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
&:focus-visible::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
}
}
}
+11 -11
View File
@@ -25,23 +25,23 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialChip', () => ({
init() {
const button = this.removeButton()
const label = this.$root.querySelector('[data-chip-label]')
const label = this.$root.querySelector('[data-md-chip-label]')
// A chip rendered by x-for has no label on the server: its button carries the translated
// words with a `:label` placeholder, filled in once Alpine has written the label.
if (button?.dataset.chipRemove && label) {
if (button?.dataset.mdChipRemove && label) {
this.$nextTick(() => {
const text = label.textContent.trim()
if (text !== '' && !button.hasAttribute('aria-label')) {
button.setAttribute('aria-label', button.dataset.chipRemove.replace(':label', text))
button.setAttribute('aria-label', button.dataset.mdChipRemove.replace(':label', text))
}
})
}
},
removeButton() {
return this.$root.querySelector('[data-chip-remove]')
return this.$root.querySelector('[data-md-chip-remove]')
},
removeOnKey(event) {
@@ -69,14 +69,14 @@ document.addEventListener('alpine:init', () => {
handOffFocus(backwards) {
const chip = this.$root
const set = chip.closest('[data-chip-set]') ?? chip.parentElement
const chips = [...(set?.querySelectorAll('[data-chip]') ?? [])]
const chips = [...(set?.querySelectorAll('[data-md-chip]') ?? [])]
const index = chips.indexOf(chip)
const before = chips.slice(0, index).reverse()
const after = chips.slice(index + 1)
const onRemove = document.activeElement?.hasAttribute('data-chip-remove')
const onRemove = document.activeElement?.hasAttribute('data-md-chip-remove')
for (const other of backwards ? [...before, ...after] : [...after, ...before]) {
const target = (onRemove && other.querySelector('[data-chip-remove]:not(:disabled)')) || (other.matches(CONTROLS) ? other : other.querySelector(CONTROLS))
const target = (onRemove && other.querySelector('[data-md-chip-remove]:not(:disabled)')) || (other.matches(CONTROLS) ? other : other.querySelector(CONTROLS))
if (target) {
target.focus()
@@ -92,17 +92,17 @@ document.addEventListener('alpine:init', () => {
// seconds pass), whenever the set changes and focus has fallen away, focus the same
// control again — the element itself, or the chip with its `wire:key` after a morph.
holdFocus(target, set) {
const key = target.closest('[data-chip]')?.getAttribute('wire:key')
const onRemove = target.hasAttribute('data-chip-remove')
const key = target.closest('[data-md-chip]')?.getAttribute('wire:key')
const onRemove = target.hasAttribute('data-md-chip-remove')
const current = () => {
if (target.isConnected || !key) {
return target.isConnected ? target : null
}
const chip = [...set.querySelectorAll('[data-chip]')].find((other) => other.getAttribute('wire:key') === key)
const chip = [...set.querySelectorAll('[data-md-chip]')].find((other) => other.getAttribute('wire:key') === key)
return chip && (onRemove ? chip.querySelector('[data-chip-remove]') : chip.matches(CONTROLS) ? chip : chip.querySelector(CONTROLS))
return chip && (onRemove ? chip.querySelector('[data-md-chip-remove]') : chip.matches(CONTROLS) ? chip : chip.querySelector(CONTROLS))
}
const observer = new MutationObserver(() => {
+41 -100
View File
@@ -39,7 +39,11 @@
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. --}}
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',
@@ -87,44 +91,21 @@
$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.
// 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();
$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;
$actionable = $kind === 'input' && ! $disabled && ! $selected && ($isLink || $clicks);
$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';
$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) {
@@ -132,7 +113,6 @@
$clicks || $selected !== null => 'button',
default => 'span',
};
$interactive = $actionTag !== 'span';
$removeExpression = implode('; ', array_filter([
'removeChip($event)',
filled($remove) ? $remove : null,
@@ -140,30 +120,15 @@
]));
$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]));
->merge($state([
'data-md-avatar' => filled($avatar) ? '' : null,
'data-md-removable' => $removable ? '' : null,
'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,
'data-md-chip-action' => '',
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
@@ -187,25 +152,12 @@
'value' => $value,
'checked' => $checked ? true : null,
'disabled' => $disabled ? true : null,
], fn ($attribute): bool => $attribute !== null))->class('peer sr-only')
'class' => 'md-visually-hidden',
], fn ($attribute): bool => $attribute !== null))
: 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,
->merge($state(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
@@ -218,42 +170,35 @@
'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));
], 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 }}>
<span @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>
<span aria-hidden="true" data-md-chip-avatar>{{ $avatar }}</span>
@else
<img src="{{ $avatar }}" alt="" @class(['me-2 size-6 shrink-0 rounded-corner-full object-cover', 'opacity-38' => $disabled]) />
<img src="{{ $avatar }}" alt="" data-md-chip-avatar />
@endif
@elseif ($icon)
<x-livewire-material::icon :name="$icon" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
<x-livewire-material::icon :name="$icon" size="18" data-md-chip-icon />
@endif
<span data-chip-label class="truncate">{{ $label ?? $slot }}</span>
<span data-md-chip-label>{{ $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 (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)
@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" />
<x-livewire-material::icon name="close" size="18" />
</button>
@endif
@@ -273,24 +218,20 @@
@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),
])>
<span data-md-chip-check-slot>
@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])" />
<x-livewire-material::icon :name="$icon" size="18" data-md-chip-icon />
@endif
<x-livewire-material::icon name="check" data-chip-check optical="20" :class="$check" />
<x-livewire-material::icon name="check" size="18" data-md-chip-check />
</span>
@elseif ($icon)
<x-livewire-material::icon :name="$icon" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
<x-livewire-material::icon :name="$icon" size="18" data-md-chip-icon />
@endif
<span class="truncate">{{ $label ?? $slot }}</span>
<span data-md-chip-label>{{ $label ?? $slot }}</span>
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
<x-livewire-material::icon :name="$iconRight" size="18" data-md-chip-trailing />
@endif
@if ($tooltip !== null)
+38 -1
View File
@@ -87,7 +87,7 @@ function filterInput(string $value): string
}
it('toggles a filter chip with a click and with Space, and grows its check in', function () {
$check = fn (string $value): string => filterInput($value).".parentElement.querySelector('[data-chip-check]').parentElement.getBoundingClientRect().width";
$check = fn (string $value): string => filterInput($value).".parentElement.querySelector('[data-md-chip-check]').parentElement.getBoundingClientRect().width";
$page = chipShowcase()->assertNoJavaScriptErrors();
@@ -173,3 +173,40 @@ it('scrolls a chip set sideways, fading the edge it can still scroll towards', f
->assertScript("{$row}.hasAttribute('data-scroll-start')")
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '1.5rem'");
});
it('draws the chip 32px tall and catches presses over 48px, the chip and its remove button alike', function () {
$page = chipProbe();
$page->assertScript("document.querySelector('[data-md-chip=\"input\"]').getBoundingClientRect().height === 32")
// The remove button is an 18px icon whose target reaches 15px past it on every side.
->assertScript("(() => {
const button = document.querySelector('button[aria-label=\"Remove css\"]');
const box = button.getBoundingClientRect();
const after = getComputedStyle(button, '::after');
return box.width === 18 && box.height === 18 && after.position === 'absolute' && after.top === '-15px' && after.left === '-15px';
})()");
// A press 22px out from the icon's centre, over the chip's 48px strip, still removes the chip.
$page->script("(() => {
const button = document.querySelector('button[aria-label=\"Remove css\"]');
const box = button.getBoundingClientRect();
document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2 + 22).click();
})()");
$page->assertSeeIn('#tags', 'php,js')
->assertScript("document.querySelector('button[aria-label=\"Remove css\"]') === null");
});
it('draws a selected filter chip without its outline, and puts an unselected one\'s label 16px in', function () {
$input = fn (string $value): string => "document.querySelector('input[value=\"{$value}\"]')";
$page = chipProbe();
$page->assertScript("getComputedStyle({$input('photos')}.parentElement).borderTopColor === 'rgba(0, 0, 0, 0)'")
->assertScript("getComputedStyle({$input('documents')}.parentElement).borderTopWidth === '1px'")
->assertScript("getComputedStyle({$input('documents')}.parentElement).borderTopColor !== 'rgba(0, 0, 0, 0)'")
// 1px border, 7px padding, the empty check's 8px margin: Compose's 16px before the label.
->assertScript("Math.round({$input('documents')}.parentElement.querySelector('[data-md-chip-label]').getBoundingClientRect().left - {$input('documents')}.parentElement.getBoundingClientRect().left) === 16");
});
+71 -64
View File
@@ -16,42 +16,56 @@ function chipTag(string $blade, string $tag = '[a-z]+'): string
it('is an outlined assist chip, a button, by default', function () {
$html = (string) $this->blade('<x-chip label="Add to calendar" icon="event" icon-right="arrow_drop_down" />');
expect(chipTag('<x-chip label="Add to calendar" icon="event" />', 'button'))
->toContain('data-chip="assist"')
->toContain('type="button"')
->toContain('h-8')
->toContain('rounded-corner-sm')
->toContain('type-label-lg')
->toContain('border-outline-variant text-on-surface')
->toContain('ps-1.75 pe-3.75')
expect(chipTag('<x-chip label="Add to calendar" icon="event" class="me-2" />', 'button'))
->toBe('<button data-md-chip="assist" data-md-icon-start="" type="button" class="me-2">')
->and(chipTag('<x-chip label="Add to calendar" icon-right="arrow_drop_down" />', 'button'))
->toContain('data-md-icon-end=""')
->not->toContain('data-md-icon-start')
->and($html)
->toContain('me-2 size-4.5 text-primary')
->toContain('ms-2 size-4.5 text-primary')
->toContain('Add to calendar');
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-icon)[^>]*>/')
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-trailing)[^>]*>/')
->toContain('<span data-md-chip-label>Add to calendar</span>')
->not->toContain('class=');
});
it('draws a chip at Compose\'s size, corner, type and padding from its stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css');
expect($css)->toContain('@layer material.components')
->toContain("@import './icon.css';")
->toMatch('/\[data-md-chip\] \{[^}]*height: 32px;[^}]*border-radius: var\(--md-sys-shape-corner-sm\);[^}]*font: var\(--md-sys-typescale-label-lg\);/')
// 16px beside a label and 8px beside an icon, each 1px short for the border.
->toContain('padding-inline: 15px;')
->toContain('padding-inline-start: 7px;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/chip.css';");
});
it('pads a chip without icons by 16px on both sides', function () {
expect(chipTag('<x-chip label="Go" />', 'button'))->toContain('ps-3.75 pe-3.75');
expect(chipTag('<x-chip label="Go" />', 'button'))
->not->toContain('data-md-icon-start')
->not->toContain('data-md-icon-end');
});
it('falls back to an assist chip for a type it does not know', function () {
expect(chipTag('<x-chip type="choice" label="Go" />', 'button'))->toContain('data-chip="assist"');
expect(chipTag('<x-chip type="choice" label="Go" />', 'button'))->toContain('data-md-chip="assist"');
});
it('draws an elevated chip on surface-container-low at elevation 1', function () {
expect(chipTag('<x-chip label="Share" elevated />', 'button'))
->toContain('border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2')
->not->toContain('border-outline-variant');
expect(chipTag('<x-chip label="Share" elevated />', 'button'))->toContain('data-md-elevated=""')
->and(chipTag('<x-chip label="Share" />', 'button'))->not->toContain('data-md-elevated')
// Input chips are flat only.
->and(chipTag('<x-chip type="input" label="Anna" elevated />', 'span'))->not->toContain('data-md-elevated')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
->toMatch('/\[data-md-chip\]\[data-md-elevated\] \{[^}]*background-color: var\(--md-sys-color-surface-container-low\);[^}]*box-shadow: var\(--md-sys-elevation-1\);/');
});
it('greys a disabled chip, flat or elevated', function () {
expect(chipTag('<x-chip label="Share" icon="share" disabled />', 'button'))
->toContain('disabled')
->toContain('border-on-surface/12 text-on-surface/38')
->toContain('data-md-disabled=""')
->toMatch('/\sdisabled="disabled"/')
->and(chipTag('<x-chip label="Share" elevated disabled />', 'button'))
->toContain('bg-on-surface/12 text-on-surface/38')
->and((string) $this->blade('<x-chip label="Share" icon="share" disabled />'))
->not->toContain('text-primary');
->toContain('data-md-elevated=""')
->toContain('data-md-disabled=""');
});
it('links with wire:navigate, and a disabled link leaves the tab order', function () {
@@ -66,15 +80,14 @@ it('links with wire:navigate, and a disabled link leaves the tab order', functio
->and(chipTag('<x-chip label="Directions" link="/directions" disabled />', 'a'))
->toContain('aria-disabled="true"')
->toContain('tabindex="-1"')
->toContain('pointer-events-none');
->toContain('data-md-disabled=""');
});
it('draws a suggestion chip in on-surface-variant', function () {
expect(chipTag('<x-chip type="suggestion" label="Sounds good" />', 'button'))
->toContain('data-chip="suggestion"')
->toContain('border-outline-variant text-on-surface-variant')
->toContain('data-md-chip="suggestion"')
->and(chipTag('<x-chip type="suggestion" label="Sounds good" elevated />', 'button'))
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1');
->toContain('data-md-elevated=""');
});
it('attaches a plain tooltip', function () {
@@ -90,37 +103,31 @@ it('makes a filter chip without a model a toggle button the caller owns', functi
$html = (string) $this->blade('<x-chip type="filter" label="Starred" :selected="true" wire:click="toggleStarred" />');
expect(chipTag('<x-chip type="filter" label="Starred" :selected="true" wire:click="toggleStarred" />', 'button'))
->toContain('data-chip="filter"')
->toContain('data-md-chip="filter"')
->toContain('aria-pressed="true"')
->toContain('wire:click="toggleStarred"')
->toContain('aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container')
->and(chipTag('<x-chip type="filter" label="Starred" />', 'button'))
->toContain('aria-pressed="false"')
->and($html)
->not->toContain('type="checkbox"')
->toContain('group-aria-pressed/chip:w-4.5')
->toContain('data-chip-check');
->toContain('<span data-md-chip-check-slot>')
->toContain('data-md-chip-check');
});
it('makes a bound filter chip a native checkbox under the chip', function () {
$html = (string) $this->blade('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />');
expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />', 'label'))
->toContain('data-chip="filter"')
->toContain('me-4')
->toContain('wire:key="photos"')
->toContain('has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container')
->toContain('has-focus-visible:outline-3')
->not->toContain('x-model')
->toBe('<label data-md-chip="filter" class="me-4" wire:key="photos">')
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" />', 'input'))
->toContain('type="checkbox"')
->toContain('value="photos"')
->toContain('x-model="kinds"')
->toContain('sr-only')
->toContain('class="md-visually-hidden"')
->not->toContain('me-4')
->not->toContain('checked')
->and($html)
->toContain('group-has-checked/chip:w-4.5');
->toContain('data-md-chip-check');
});
it('submits a named filter chip, checked when selected, and greys a disabled one', function () {
@@ -130,48 +137,47 @@ it('submits a named filter chip, checked when selected, and greys a disabled one
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'input'))
->toContain('disabled')
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'label'))
->toContain('border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12')
->toContain('cursor-not-allowed');
->toContain('data-md-disabled=""');
});
it('swaps a filter chip\'s icon for the check when selected', function () {
$html = (string) $this->blade('<x-chip type="filter" label="Starred" icon="star" name="starred" elevated />');
expect($html)
->toContain('group-has-checked/chip:opacity-0')
->toContain('text-primary')
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1')
->not->toContain('w-0')
->and(substr_count($html, '<svg'))->toBe(2);
->toMatch('/<span data-md-chip-check-slot>\s*<svg[^>]*data-md-chip-icon[^>]*>.*?<\/svg>\s*<svg[^>]*data-md-chip-check/s')
->toContain('data-md-elevated=""')
->and(substr_count($html, '<svg'))->toBe(2)
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
->toContain("[data-md-chip]:is(:has(:checked), [aria-pressed='true']) [data-md-chip-check-slot] [data-md-chip-icon] {");
});
it('draws an input chip with an avatar, an icon or neither', function () {
expect(chipTag('<x-chip type="input" label="Anna" avatar="AM" />', 'span'))
->toContain('data-chip="input"')
->toContain('border-outline-variant text-on-surface-variant')
->toContain('data-md-chip="input"')
->toContain('data-md-avatar=""')
->not->toContain('x-data')
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="AM" />'))
->toContain('ps-0.75 pe-2.75')
->toContain('size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container')
->toContain('>AM</span>')
->toContain('<span aria-hidden="true" data-md-chip-avatar>AM</span>')
->not->toContain('<button')
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="/avatars/anna.jpg" />'))
->toContain('<img src="/avatars/anna.jpg" alt=""')
->and((string) $this->blade('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />'))
->toContain('ps-1.75 pe-2.75');
->toContain('<img src="/avatars/anna.jpg" alt="" data-md-chip-avatar />')
->and(chipTag('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />', 'span'))
->toContain('data-md-icon-start=""')
->not->toContain('data-md-avatar');
});
it('makes an input chip a button when it has its own action, and selects it', function () {
$html = (string) $this->blade('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />');
expect(chipTag('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />', 'button'))
->toContain('data-chip-action')
->toContain('data-md-chip-action')
->toContain('aria-pressed="true"')
->toContain('wire:click="open(1)"')
->and(chipTag('<x-chip type="input" label="Anna" :selected="true" />', 'span'))
->toContain('border-transparent bg-secondary-container text-on-secondary-container')
->and($html)
->toContain('me-2 size-4.5 text-primary');
->toContain('data-md-selected=""')
// An action of its own lights the leading icon on hover, focus and press.
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'span'))
->toContain('data-md-actionable=""')
->and(chipTag('<x-chip type="input" label="Anna" icon="person" />', 'span'))
->not->toContain('data-md-actionable');
});
it('gives a removable input chip a named remove button that calls wire:remove', function () {
@@ -180,15 +186,16 @@ it('gives a removable input chip a named remove button that calls wire:remove',
expect(chipTag('<x-chip type="input" label="anna@example.com" removable wire:remove="removeRecipient(3)" />', 'span'))
->toContain('x-data="materialChip"')
->toContain('x-on:keydown="removeOnKey($event)"')
->toContain('data-md-removable=""')
->not->toContain('wire:remove')
->and($html)
->toContain('data-chip-remove aria-label="Remove anna@example.com"')
->toContain('data-md-chip-remove aria-label="Remove anna@example.com"')
->toContain('wire:click="removeRecipient(3)"')
->toContain('x-on:click="removeChip($event)"')
->toContain('<input type="hidden" name="to[]" value="3" />')
->toContain('ps-2.75 pe-2')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
// 18px icon, 15px out on every side: M3's 48dp minimum for the close icon's target.
->toContain('after:absolute after:-inset-3.75');
->toMatch('/\[data-md-chip-remove\] \{.*?&::after \{\s*content: \'\';\s*position: absolute;\s*inset: -15px;/s');
});
it('runs an Alpine remove expression, or takes the chip off the page with neither', function () {
@@ -201,7 +208,7 @@ it('runs an Alpine remove expression, or takes the chip off the page with neithe
it('leaves a client-rendered chip\'s remove button to be named in the browser', function () {
expect((string) $this->blade('<x-chip type="input" removable remove="tags.pop()"><span x-text="tag"></span></x-chip>'))
->toContain('data-chip-remove="Remove :label"')
->toContain('data-md-chip-remove="Remove :label"')
->not->toContain('aria-label=');
});
@@ -209,8 +216,8 @@ it('disables a removable input chip and its remove button', function () {
$html = (string) $this->blade('<x-chip type="input" label="php" removable disabled />');
expect($html)
->toContain('border-on-surface/12 text-on-surface/38')
->toMatch('/<button[^>]*data-chip-remove[^>]*disabled/s');
->toContain('data-md-disabled=""')
->toMatch('/<button[^>]*data-md-chip-remove[^>]*disabled/s');
});
it('groups chips in a wrapping row named by its label, with a hint', function () {