From d8072d878a9d32ffbf585877c3a8ae374f76fef4 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:50:29 +0200 Subject: [PATCH] Draw the chips without Tailwind 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/chip.css | 444 ++++++++++++++++++++++ resources/js/chips.js | 22 +- resources/views/components/chip.blade.php | 141 ++----- tests/Browser/ChipsTest.php | 39 +- tests/Feature/Components/ChipTest.php | 135 +++---- 6 files changed, 606 insertions(+), 176 deletions(-) create mode 100644 resources/css/components/chip.css diff --git a/resources/css/components.css b/resources/css/components.css index cd0fe72f..868e440a 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -24,6 +24,7 @@ @import './components/checkbox.css'; @import './components/radio.css'; @import './components/toggle.css'; +@import './components/chip.css'; /* Containment */ diff --git a/resources/css/components/chip.css b/resources/css/components/chip.css new file mode 100644 index 00000000..22091714 --- /dev/null +++ b/resources/css/components/chip.css @@ -0,0 +1,444 @@ +/* + * : 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 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 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); + } + } + } +} diff --git a/resources/js/chips.js b/resources/js/chips.js index 2b1e86c5..818ca6cb 100644 --- a/resources/js/chips.js +++ b/resources/js/chips.js @@ -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(() => { diff --git a/resources/views/components/chip.blade.php b/resources/views/components/chip.blade.php index a17c6cd1..7c4d156c 100644 --- a/resources/views/components/chip.blade.php +++ b/resources/views/components/chip.blade.php @@ -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') - + <{{ $actionTag }} {{ $action }}> @if ($avatar) @if ($initials) - + @else - $disabled]) /> + @endif @elseif ($icon) - + @endif - {{ $label ?? $slot }} + {{ $label ?? $slot }} @if ($removable) @endif @@ -273,24 +218,20 @@ @if ($kind === 'filter') {{-- The check grows in from nothing; beside an icon it takes the icon's place. --}} - 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) - + @endif - + @elseif ($icon) - + @endif - {{ $label ?? $slot }} + {{ $label ?? $slot }} @if ($iconRight) - + @endif @if ($tooltip !== null) diff --git a/tests/Browser/ChipsTest.php b/tests/Browser/ChipsTest.php index 543f205e..54b50106 100644 --- a/tests/Browser/ChipsTest.php +++ b/tests/Browser/ChipsTest.php @@ -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"); +}); diff --git a/tests/Feature/Components/ChipTest.php b/tests/Feature/Components/ChipTest.php index 8f8d57e7..595dcdc7 100644 --- a/tests/Feature/Components/ChipTest.php +++ b/tests/Feature/Components/ChipTest.php @@ -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(''); - expect(chipTag('', '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('', 'button')) + ->toBe('