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:
co-authored by
Claude Opus 5
parent
b42069503d
commit
d8072d878a
@@ -24,6 +24,7 @@
|
||||
@import './components/checkbox.css';
|
||||
@import './components/radio.css';
|
||||
@import './components/toggle.css';
|
||||
@import './components/chip.css';
|
||||
|
||||
/* Containment */
|
||||
|
||||
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user