Merge branch 'worktree-agent-a41c756369d016a29'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 15:40:58 +02:00
67 changed files with 3434 additions and 1497 deletions
@@ -629,7 +629,7 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire
- `counter` (on `<x-input>` and `<x-textarea>`) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops. - `counter` (on `<x-input>` and `<x-textarea>`) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops.
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`. - `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`.
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself. - `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
- `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `class="field-control"`; only for controls the package does not have. - `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `data-md-field-control`; only for controls the package does not have.
### `<x-checkbox>`, `<x-radio>`, `<x-toggle>` ### `<x-checkbox>`, `<x-radio>`, `<x-toggle>`
+17
View File
@@ -14,6 +14,23 @@
/* Actions and communication */ /* Actions and communication */
/* Inputs, selection and data */ /* Inputs, selection and data */
@import './components/form.css';
@import './components/field.css';
@import './components/input.css';
@import './components/password.css';
@import './components/textarea.css';
@import './components/select.css';
@import './components/file.css';
@import './components/checkbox.css';
@import './components/radio.css';
@import './components/toggle.css';
@import './components/chip.css';
@import './components/chip-set.css';
@import './components/choices.css';
@import './components/slider.css';
@import './components/search.css';
@import './components/table.css';
@import './components/sort-header.css';
/* Containment */ /* Containment */
+114
View File
@@ -0,0 +1,114 @@
/*
* <x-checkbox>: M3's checkbox (resources/views/components/checkbox.blade.php), on the row the
* selection controls share (components/selection.css).
*
* CheckboxTokens (androidx Compose Material 3, Apache-2.0): an 18px box with a 2px on-surface-variant
* outline and 2px corners, filled with primary and holding an on-primary tick (or dash, for the
* mixed state) at the 18px icon size once it is ticked; the state layer a 40px circle around it,
* 11px past each edge. The tick grows in and fades in on the fast spatial spring. With the server's
* error the outline, the fill and the state layer are error and the tick on-error. Disabled, the
* outline and the fill are on-surface at 38% and the tick is surface. Beside a hint the box drops
* 3px, to sit on the label's first line.
*
* [data-md-checkbox] the root: the row and its errors; `class` and `style` land here
* [data-md-selection-row]
* [data-md-checkbox-box] the box: input, [data-md-checkbox-check], [data-md-checkbox-mixed]
* [data-md-selection-support] the errors, 4px under the row
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './selection.css';
@import './icon.css';
@layer material.components {
[data-md-checkbox] {
min-width: 0;
}
[data-md-checkbox-box] {
position: relative;
display: grid;
flex: none;
place-items: center;
width: 18px;
height: 18px;
}
[data-md-checkbox-box] > * {
grid-area: 1 / 1;
}
[data-md-selection-row]:has([data-md-selection-hint]) [data-md-checkbox-box] {
margin-top: 3px;
}
[data-md-checkbox-box]::before {
inset: -11px;
}
[data-md-checkbox-box]:has(input:focus-visible)::before {
outline: 3px solid var(--md-sys-color-secondary);
}
[data-md-checkbox-box] input {
width: 18px;
height: 18px;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: 2px;
}
[data-md-checkbox-box] input:is(:checked, :indeterminate) {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-md-checkbox-box] :is([data-md-checkbox-check], [data-md-checkbox-mixed]) {
color: var(--md-sys-color-on-primary);
opacity: 0;
scale: 0.5;
pointer-events: none;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-md-checkbox-box] input:checked:not(:indeterminate) ~ [data-md-checkbox-check],
[data-md-checkbox-box] input:indeterminate ~ [data-md-checkbox-mixed] {
opacity: 1;
scale: 1;
}
[data-md-selection-row][data-md-invalid] [data-md-checkbox-box] input {
border-color: var(--md-sys-color-error);
}
[data-md-selection-row][data-md-invalid] [data-md-checkbox-box] input:is(:checked, :indeterminate) {
background-color: var(--md-sys-color-error);
}
[data-md-selection-row][data-md-invalid] [data-md-checkbox-box] :is([data-md-checkbox-check], [data-md-checkbox-mixed]) {
color: var(--md-sys-color-on-error);
}
[data-md-checkbox-box] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-checkbox-box] input:disabled:is(:checked, :indeterminate) {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-checkbox-box] input:disabled ~ :is([data-md-checkbox-check], [data-md-checkbox-mixed]) {
color: var(--md-sys-color-surface);
}
[data-md-checkbox] [data-md-selection-support] {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-error);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
}
+128
View File
@@ -0,0 +1,128 @@
/*
* <x-chip-set>: a set of M3 chips (resources/views/components/chip-set.blade.php), named for screen
* readers as a group.
*
* The chips sit 8px apart (M3's chip spacing), in a row that wraps. Above it the label in
* label-large on-surface-variant, 8px over the row; under it the hint, or the errors in its place,
* body-small, 4px under the row.
*
* `data-md-scroll` keeps the chips on one line that scrolls sideways, as M3 lays chips out on a
* narrow screen. M3's chips accessibility page asks that a row which overflows say so: the edge the
* row can still scroll towards (`data-md-scroll-start`, `data-md-scroll-end`, set by
* resources/js/chips.js) fades over 24px through a mask, black meaning only "opaque" there, and in a
* right-to-left document the fade runs the other way. Where the pointer is fine, and so there is no
* swipe, a 32px round button in surface-container-high at elevation 1 sits over each fading edge;
* the row's scroll padding grows from 24px to 40px there, so a chip the keyboard reaches scrolls
* clear of the button as well as the fade. The row reaches 6px and 8px past its box so a focused
* chip's ring and an elevated chip's shadow are not clipped by the scroller.
*
* [data-md-chip-set] the group; data-md-scroll
* [data-md-chip-set-label]
* [data-md-chip-set-scroller] (scrolling) the row and its buttons
* [data-md-chip-set-row] the chips; data-md-scroll-start, data-md-scroll-end
* [data-md-chip-scroll="start|end"]
* [data-md-chip-set-hint] | [data-md-chip-set-errors]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './chip.css';
@import './icon.css';
@layer material.components {
[data-md-chip-set] {
min-width: 0;
}
[data-md-chip-set-label] {
margin-bottom: var(--md-sys-measurement-space100);
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;
}
[data-md-chip-set-row] {
display: flex;
flex-wrap: wrap;
gap: var(--md-sys-measurement-space100);
}
[data-md-chip-set-hint],
[data-md-chip-set-errors] > p {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-chip-set-errors] > p {
color: var(--md-sys-color-error);
}
/* ---- Scrolling --------------------------------------------------------------------------- */
[data-md-chip-set-scroller] {
position: relative;
}
[data-md-chip-set-scroller] > [data-md-chip-set-row] {
--chip-fade-start: 0px;
--chip-fade-end: 0px;
flex-wrap: nowrap;
margin: calc(-1 * var(--md-sys-measurement-space100)) calc(-1 * var(--md-sys-measurement-space75));
padding: var(--md-sys-measurement-space100) var(--md-sys-measurement-space75);
overflow-x: auto;
scroll-padding-inline: var(--md-sys-measurement-space300);
scrollbar-width: none;
mask-image: linear-gradient(to right, transparent, black var(--chip-fade-start), black calc(100% - var(--chip-fade-end)), transparent);
&:dir(rtl) {
mask-image: linear-gradient(to left, transparent, black var(--chip-fade-start), black calc(100% - var(--chip-fade-end)), transparent);
}
&[data-md-scroll-start] {
--chip-fade-start: var(--md-sys-measurement-space300);
}
&[data-md-scroll-end] {
--chip-fade-end: var(--md-sys-measurement-space300);
}
@media (pointer: fine) {
scroll-padding-inline: var(--md-sys-measurement-space500);
}
}
[data-md-chip-scroll] {
position: absolute;
inset-block: 0;
display: none;
place-items: center;
width: 32px;
height: 32px;
margin-block: auto;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface-variant);
box-shadow: var(--md-sys-elevation-1);
}
[data-md-chip-scroll='start'] {
inset-inline-start: 0;
}
[data-md-chip-scroll='end'] {
inset-inline-end: 0;
}
/* Only while the row can still scroll that way, and only where the pointer cannot swipe. */
@media (pointer: fine) {
[data-md-chip-set-row][data-md-scroll-start] ~ [data-md-chip-scroll='start'],
[data-md-chip-set-row][data-md-scroll-end] ~ [data-md-chip-scroll='end'] {
display: grid;
}
}
}
+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);
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
/*
* <x-choices>: choosing from a list, as M3's filter chips or as a searchable field with a menu
* (resources/views/components/choices.blade.php).
*
* Almost nothing of its own to draw: the chips are <x-chip-set>'s and <x-chip>'s, the searchable
* field is <x-field>'s with its arrow turning over while the list is open (components/field.css),
* and the open list is M3's menu, drawn by components/menu.css on `data-md-field-menu` and
* `data-md-field-option`. What is left is the row that says nothing matches: body-medium in
* on-surface-variant, padded like a menu row (16px across, 12px down).
*
* [data-md-choices] the root; data-md-searchable
* [data-md-choices-empty] the list's "Nothing matches" row
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@import './icon.css';
@import './chip-set.css';
@import './chip.css';
/* TODO(step 36): @import './menu.css' once the menu leaves Tailwind (actions stream); its field-menu rules draw the open list. */
@layer material.components {
[data-md-choices-empty] {
padding: 12px var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
}
+137 -258
View File
@@ -9,30 +9,44 @@
* `<legend>`, carrying the label at its floated size, cuts the gap the label sits in, so the gap is * `<legend>`, carrying the label at its floated size, cuts the gap the label sits in, so the gap is
* right on any background. The filled field has no outline: a surface-container-highest box with * right on any background. The filled field has no outline: a surface-container-highest box with
* extra-small top corners and an active indicator line, the label floating inside it * extra-small top corners and an active indicator line, the label floating inside it
* (FilledTextFieldTokens / OutlinedTextFieldTokens, androidx Compose Material 3, Apache-2.0). * (FilledTextFieldTokens / OutlinedTextFieldTokens, androidx Compose Material 3, Apache-2.0): 56px
* tall, 16px padding, 24px icons 16px from the text, the label in body-small once it floats.
* The `sm` (40px) and `xs` (32px) sizes are this package's, for an unlabelled field in a toolbar.
* Disabled is on-surface at M3's 38% content and 12% container opacities (tokens/state.css); the
* filled field's disabled container is on-surface at 4%, FilledTextFieldTokens'
* DisabledContainerOpacity, which has no system token.
* *
* Anatomy (resources/views/components/field.blade.php): * Anatomy (resources/views/components/field.blade.php):
* *
* .field the whole thing; `class` from the call site lands here; data-variant, data-size * [data-md-field] the whole thing; `class` from the call site lands here;
* .field-box the 56px row: icon, prefix, control, suffix, trailing * data-md-variant, data-md-size, data-md-invalid, data-md-floated,
* .field-control the <input>, <select> or <textarea> * data-md-mono, data-md-full, data-md-readonly
* .field-trailing what ends the row; .field-error is the error icon M3 asks for as the * [data-md-field-box] the 56px row: icon, prefix, control, suffix, trailing
* error state's second indicator * [data-md-field-icon] the leading icon
* .field-outline the fieldset and its legend (the filled field's indicator line) * [data-md-field-affix] a prefix or suffix beside the value
* .field-label the visible label * [data-md-field-control] the <input>, <select> or <textarea>
* .field-support-row the row under the field * [data-md-field-trailing] what ends the row; [data-md-field-error] is the error icon M3 asks
* .field-support the hint, or the error in its place * for as the error state's second indicator; [data-md-field-button]
* .field-counter M3's character counter, `n/max`, at the end of that row * a trailing icon button (clear, copy, reveal, open a picker)
* [data-md-field-outline] the fieldset and its legend (the filled field's indicator line)
* [data-md-field-label] the visible label
* [data-md-field-support-row] the row under the field
* [data-md-field-support] the hint, or the error in its place
* [data-md-field-counter] M3's character counter, `n/max`, at the end of that row
* *
* A select's open list is not part of the field: it is the dropdown menu in components/menu.css. * A select's open list is not part of the field: it is the dropdown menu in components/menu.css.
*/ */
@layer components { @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
.field {
--field-height: 3.5rem; @import './icon.css';
--field-pad: 1rem;
--field-icon: 1.5rem; @layer material.components {
--field-gap: 1rem; [data-md-field] {
--field-height: 56px;
--field-pad: var(--md-sys-measurement-space200);
--field-icon: 24px;
--field-gap: var(--md-sys-measurement-space200);
--field-start: var(--field-pad); --field-start: var(--field-pad);
--field-edge: var(--md-sys-color-outline); --field-edge: var(--md-sys-color-outline);
--field-ink: var(--md-sys-color-on-surface-variant); --field-ink: var(--md-sys-color-on-surface-variant);
@@ -45,41 +59,42 @@
/* M3 § Text Fields, Behaviour: "compact breakpoints can let a text field span full width; /* M3 § Text Fields, Behaviour: "compact breakpoints can let a text field span full width;
* medium/expanded should bound it with flexible margins/other containers — never let it span the * medium/expanded should bound it with flexible margins/other containers — never let it span the
* full width of a large screen." The site names no number, so 40rem is this package's: it is * full width of a large screen." The site names no number, so 40rem is this package's: it is
* under half the `large` class's 1200px, a little over the `expanded` class's half, and holds * under half the `large` breakpoint's 1200px, a little over half the `expanded` one's, and holds
* about the 70 characters body-large reads best at. It is a ceiling, not a width — a narrower * about the 70 characters body-large reads best at — a measure of text, so it is in rem and grows
* pane still gets a narrower field. A `max-w-*` class from the call site beats it, because * with the text. It is a ceiling, not a width — a narrower pane still gets a narrower field. A
* utilities come after this layer, and `full` takes it off for a field that really is the width * width rule from the call site beats it, because an application's CSS and a utility both
* of its pane (a search-and-filter row, an editor). Below `medium` nothing is bounded. */ * outrank this layer, and `full` takes it off for a field that really is the width of its pane (a
@media (width >= 37.5rem) { * search-and-filter row, an editor). Below `medium` nothing is bounded. */
.field:not([data-full]) { @media (width >= 600px) {
[data-md-field]:not([data-md-full]) {
max-width: 40rem; max-width: 40rem;
} }
} }
/* A filled field's resting indicator line is on-surface-variant, where an outline is outline. */ /* A filled field's resting indicator line is on-surface-variant, where an outline is outline. */
.field[data-variant="filled"] { [data-md-field][data-md-variant='filled'] {
--field-edge: var(--md-sys-color-on-surface-variant); --field-edge: var(--md-sys-color-on-surface-variant);
} }
.field[data-size="sm"] { [data-md-field][data-md-size='sm'] {
--field-height: 2.5rem; --field-height: 40px;
--field-pad: 0.75rem; --field-pad: 12px;
--field-icon: 1.25rem; --field-icon: 20px;
--field-gap: 0.5rem; --field-gap: var(--md-sys-measurement-space100);
} }
.field[data-size="xs"] { [data-md-field][data-md-size='xs'] {
--field-height: 2rem; --field-height: 32px;
--field-pad: 0.625rem; --field-pad: var(--md-sys-measurement-space125);
--field-icon: 1rem; --field-icon: 16px;
--field-gap: 0.375rem; --field-gap: var(--md-sys-measurement-space75);
} }
.field:has(.field-icon) { [data-md-field]:has([data-md-field-icon]) {
--field-start: calc(var(--field-pad) + var(--field-icon) + var(--field-gap)); --field-start: calc(var(--field-pad) + var(--field-icon) + var(--field-gap));
} }
.field-box { [data-md-field-box] {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -90,7 +105,7 @@
cursor: text; cursor: text;
} }
.field-control { [data-md-field-control] {
flex: 1 1 0%; flex: 1 1 0%;
min-width: 0; min-width: 0;
align-self: stretch; align-self: stretch;
@@ -102,154 +117,38 @@
letter-spacing: var(--md-sys-typescale-body-lg-tracking); letter-spacing: var(--md-sys-typescale-body-lg-tracking);
} }
.field[data-size="xs"] .field-control { [data-md-field][data-md-size='xs'] [data-md-field-control] {
font: var(--md-sys-typescale-body-md); font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking); letter-spacing: var(--md-sys-typescale-body-md-tracking);
} }
.field[data-mono] .field-control { [data-md-field][data-md-mono] [data-md-field-control] {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
} }
.field-control::placeholder { [data-md-field-control]::placeholder {
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
opacity: 1; opacity: 1;
} }
/* Autofill paints its own background, which on a card is a pale block in the /* Autofill paints its own background, which on a card is a pale block in the
wrong colour. Delaying the transition for a week keeps the field's own. */ wrong colour. Delaying the transition for a week keeps the field's own. */
.field-control:-webkit-autofill { [data-md-field-control]:-webkit-autofill {
-webkit-text-fill-color: var(--md-sys-color-on-surface); -webkit-text-fill-color: var(--md-sys-color-on-surface);
transition: background-color 604800s, color 604800s; transition: background-color 604800s, color 604800s;
} }
/* The browser draws the resize grip in the textarea's own corner, so the
textarea reaches to just inside the outline's end and bottom — the grip sits
in the field's corner, not on the bottom outline a padding in from it — and
its padding puts the text back where it was. */
textarea.field-control {
--field-grip: 0.25rem;
/* The first half-line of the top offset is a margin, not padding: text scrolled up in a full
textarea disappears under the edge instead of running through the label. */
--textarea-clear: 0.5rem;
--textarea-pad-top: calc((var(--field-height) - 1.5rem) / 2 - var(--textarea-clear));
--textarea-pad-bottom: calc((var(--field-height) - 1.5rem) / 2 - var(--field-grip));
margin-block: var(--textarea-clear) var(--field-grip);
margin-inline-end: calc(var(--field-grip) - var(--field-pad));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
padding-inline-end: calc(var(--field-pad) - var(--field-grip));
resize: vertical;
}
/* `<x-textarea>` grows with what is typed, from `rows` lines up to `max-rows`. Where the browser
cannot size a field to its content, resources/js/field.js sets the height instead. */
textarea.field-control[data-autogrow] {
field-sizing: content;
min-block-size: calc(var(--field-rows, 3) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
max-block-size: calc(var(--field-max-rows, 1000) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
resize: none;
}
/* A select covers its whole field — out over the padding and the leading icon at
the start, under the arrow to the end — and its own padding puts the value back
where it was. So a press anywhere on the field opens it, and the list, which
the browser anchors to the select, is the field's width (components/menu.css). */
select.field-control {
--field-end: calc(var(--field-icon) + var(--field-gap) + var(--field-pad));
appearance: none;
cursor: pointer;
margin-inline: calc(-1 * var(--field-start)) calc(-1 * var(--field-end));
padding-inline: var(--field-start) var(--field-end);
}
select.field-control:disabled {
cursor: default;
}
/* Where the browser has a customizable select, the closed select is its own
button: without the border, padding and arrow that button brings, it is the
same field as before. While its list is open the field reads as focused —
said from `:open`, because focus inside the list is in the top layer — and
the arrow turns over, as the searchable choices' does. */
@supports (appearance: base-select) {
select.field-control {
display: flex;
align-items: center;
border: 0;
padding-block: 0;
border-radius: 0;
background: transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
select.field-control::picker-icon {
display: none;
}
.field-box:has(select.field-control:open) {
--field-edge: var(--md-sys-color-primary);
--field-ink: var(--md-sys-color-primary);
.field-outline {
border-width: 2px;
}
}
.field:has(select.field-control:open) .field-arrow {
rotate: 180deg;
}
}
/* `<x-file>`: the browser's own "No file chosen" line is transparent (the caller shows what was
chosen), and its button is a tonal pill — the thing to press. */
input[type="file"].field-control {
align-self: center;
color: transparent;
cursor: pointer;
}
input[type="file"].field-control::file-selector-button {
height: 2rem;
margin-inline-end: 0.75rem;
padding-inline: 1rem;
border: 0;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
cursor: pointer;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
input[type="file"].field-control:hover::file-selector-button {
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
}
}
input[type="file"].field-control:disabled::file-selector-button {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
}
/* A combobox's arrow turns over while its list is open, as a select's does. */ /* A combobox's arrow turns over while its list is open, as a select's does. */
.field:has(.field-control[aria-expanded="true"]) .field-arrow { [data-md-field]:has([data-md-field-control][aria-expanded='true']) [data-md-field-arrow] {
rotate: 180deg; rotate: 180deg;
} }
.field-arrow { [data-md-field-arrow] {
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
} }
.field-icon, [data-md-field-icon],
.field-trailing { [data-md-field-trailing] {
flex: none; flex: none;
width: var(--field-icon); width: var(--field-icon);
height: var(--field-icon); height: var(--field-icon);
@@ -257,69 +156,71 @@
} }
/* The error's second indicator: M3 asks that the colour change never stand alone. */ /* The error's second indicator: M3 asks that the colour change never stand alone. */
.field-error { [data-md-field-error] {
color: var(--md-sys-color-error); color: var(--md-sys-color-error);
} }
.field-icon, [data-md-field-icon],
.field-arrow { [data-md-field-arrow] {
pointer-events: none; pointer-events: none;
} }
/* A trailing button — clear, copy, reveal — is an icon button: the icon in a 40px state layer. */ /* A trailing button — clear, copy, reveal — is an icon button: the icon in a 40px state layer. */
.field-button { [data-md-field-button] {
position: relative; position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
margin-inline-end: -0.25rem; margin-inline-end: calc(-1 * var(--md-sys-measurement-space50));
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
cursor: pointer; cursor: pointer;
outline: none; outline: none;
} }
.field-button::before { [data-md-field-button]::before {
content: ""; content: '';
position: absolute; position: absolute;
inset: 50% auto auto 50%; inset: 50% auto auto 50%;
width: calc(var(--field-icon) + 1rem); width: calc(var(--field-icon) + var(--md-sys-measurement-space200));
height: calc(var(--field-icon) + 1rem); height: calc(var(--field-icon) + var(--md-sys-measurement-space200));
translate: -50% -50%; translate: -50% -50%;
border-radius: inherit; border-radius: inherit;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
} }
@media (hover: hover) { @media (hover: hover) {
.field-button:hover::before { [data-md-field-button]:hover::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent); background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent);
} }
} }
.field-button:focus-visible::before { [data-md-field-button]:focus-visible::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent); background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
outline: 3px solid var(--md-sys-color-secondary); outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px; outline-offset: 2px;
} }
.field-button:active::before { [data-md-field-button]:active::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent); background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent);
} }
.field-affix { [data-md-field-affix] {
flex: none; flex: none;
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-lg); font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking); letter-spacing: var(--md-sys-typescale-body-lg-tracking);
} }
.field[data-size="xs"] .field-affix { [data-md-field][data-md-size='xs'] [data-md-field-affix] {
font: var(--md-sys-typescale-body-md); font: var(--md-sys-typescale-body-md);
} }
.field-outline { /* The legend is body-small's 16px line less its 5px above the outline: the fieldset starts
5px up so the notch's edge runs through the middle of the floated label. */
[data-md-field-outline] {
position: absolute; position: absolute;
inset: -0.3125rem 0 0; inset: -0.3125rem 0 0;
margin: 0; margin: 0;
padding: 0 calc(var(--field-pad) - 0.25rem); padding: 0 calc(var(--field-pad) - var(--md-sys-measurement-space50));
border: 1px solid var(--field-edge); border: 1px solid var(--field-edge);
border-radius: inherit; border-radius: inherit;
pointer-events: none; pointer-events: none;
@@ -327,7 +228,7 @@
transition: border-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); transition: border-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
} }
.field-outline > legend { [data-md-field-outline] > legend {
display: block; display: block;
width: auto; width: auto;
max-width: 100%; max-width: 100%;
@@ -341,12 +242,12 @@
transition: max-width var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); transition: max-width var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
} }
.field-outline > legend > span { [data-md-field-outline] > legend > span {
display: inline-block; display: inline-block;
padding-inline: 0.25rem; padding-inline: var(--md-sys-measurement-space50);
} }
.field-label { [data-md-field-label] {
position: absolute; position: absolute;
inset-inline-start: var(--field-pad); inset-inline-start: var(--field-pad);
top: 0; top: 0;
@@ -366,47 +267,42 @@
/* `required` is marked from the control itself, in the label and in the notch, /* `required` is marked from the control itself, in the label and in the notch,
so the gap is cut for the asterisk too. */ so the gap is cut for the asterisk too. */
.field:has(.field-control:required) .field-label::after, [data-md-field]:has([data-md-field-control]:required) [data-md-field-label]::after,
.field:has(.field-control:required) .field-outline > legend > span::after { [data-md-field]:has([data-md-field-control]:required) [data-md-field-outline] > legend > span::after {
content: " *"; content: ' *';
} }
/* Resting: a label, an empty control, and no focus. The label sits where the /* Resting: a label, an empty control, and no focus. The label sits where the
text will go, at the text's size; the notch closes; the placeholder and the text will go, at the text's size; the notch closes; the placeholder and the
prefix wait, because the label is standing where they would be. A select and prefix wait, because the label is standing where they would be. A select and
a date always hold a value, so they mark themselves `data-floated`. */ a date always hold a value, so they mark themselves `data-md-floated`. */
.field:not([data-floated]):has(.field-label):has(.field-control:placeholder-shown):not(:focus-within) { [data-md-field]:not([data-md-floated]):has([data-md-field-label]):has([data-md-field-control]:placeholder-shown):not(:focus-within) {
.field-label { [data-md-field-label] {
top: 50%; top: 50%;
inset-inline-start: var(--field-start); inset-inline-start: var(--field-start);
font: var(--md-sys-typescale-body-lg); font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking); letter-spacing: var(--md-sys-typescale-body-lg-tracking);
} }
.field-outline > legend { [data-md-field-outline] > legend {
max-width: 0.01px; max-width: 0.01px;
} }
.field-affix { [data-md-field-affix] {
visibility: hidden; visibility: hidden;
} }
} }
.field[data-size="xs"]:not([data-floated]):has(.field-label):has(.field-control:placeholder-shown):not(:focus-within) .field-label { [data-md-field][data-md-size='xs']:not([data-md-floated]):has([data-md-field-label]):has([data-md-field-control]:placeholder-shown):not(:focus-within) [data-md-field-label] {
font: var(--md-sys-typescale-body-md); font: var(--md-sys-typescale-body-md);
} }
/* A textarea's label rests on its first line, not in the middle of the box. */ [data-md-field]:has([data-md-field-label]):not(:focus-within) [data-md-field-control]::placeholder {
.field:not([data-floated]):has(.field-label):has(textarea.field-control:placeholder-shown):not(:focus-within) .field-label {
top: calc(var(--field-height) / 2);
}
.field:has(.field-label):not(:focus-within) .field-control::placeholder {
color: transparent; color: transparent;
} }
/* Clearing is offered only once there is something to clear. */ /* Clearing is offered only once there is something to clear. */
.field:has(.field-control:placeholder-shown) .field-clear { [data-md-field]:has([data-md-field-control]:placeholder-shown) [data-md-field-clear] {
display: none; display: none;
} }
@@ -414,82 +310,82 @@
control no state layer at all, and the disabled edge below is inherited, so a declaration control no state layer at all, and the disabled edge below is inherited, so a declaration
here would beat it whatever the selector weighs. */ here would beat it whatever the selector weighs. */
@media (hover: hover) { @media (hover: hover) {
.field:not(:has(.field-control:disabled)) .field-box:hover { [data-md-field]:not(:has([data-md-field-control]:disabled)) [data-md-field-box]:hover {
--field-edge: var(--md-sys-color-on-surface); --field-edge: var(--md-sys-color-on-surface);
} }
} }
.field-box:focus-within { [data-md-field-box]:focus-within {
--field-edge: var(--md-sys-color-primary); --field-edge: var(--md-sys-color-primary);
--field-ink: var(--md-sys-color-primary); --field-ink: var(--md-sys-color-primary);
} }
.field-box:focus-within .field-outline { [data-md-field-box]:focus-within [data-md-field-outline] {
border-width: 2px; border-width: 2px;
} }
.field[data-invalid] { [data-md-field][data-md-invalid] {
--field-edge: var(--md-sys-color-error); --field-edge: var(--md-sys-color-error);
--field-ink: var(--md-sys-color-error); --field-ink: var(--md-sys-color-error);
} }
@media (hover: hover) { @media (hover: hover) {
.field[data-invalid]:not(:has(.field-control:disabled)) .field-box:hover { [data-md-field][data-md-invalid]:not(:has([data-md-field-control]:disabled)) [data-md-field-box]:hover {
--field-edge: var(--md-sys-color-on-error-container); --field-edge: var(--md-sys-color-on-error-container);
} }
} }
.field[data-invalid] .field-box:focus-within { [data-md-field][data-md-invalid] [data-md-field-box]:focus-within {
--field-edge: var(--md-sys-color-error); --field-edge: var(--md-sys-color-error);
--field-ink: var(--md-sys-color-error); --field-ink: var(--md-sys-color-error);
} }
/* `data-readonly` marks a field the caller made read-only — on the field, not read off the /* `data-md-readonly` marks a field the caller made read-only — on the field, not read off the
control, because a date picker makes its own input read-only too. Nothing here draws it control, because a date picker makes its own input read-only too. Nothing here draws it
differently: M3 says a read-only field keeps "the same visual style as an editable field, but differently: M3 says a read-only field keeps "the same visual style as an editable field, but
clearly labeled read-only", and the native `readonly` attribute is what carries that. The hook clearly labeled read-only", and the native `readonly` attribute is what carries that. The hook
stays for an application that wants a mark of its own. */ stays for an application that wants a mark of its own. */
.field:has(.field-control:disabled) { [data-md-field]:has([data-md-field-control]:disabled) {
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent); --field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
--field-ink: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); --field-ink: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
.field-box { [data-md-field-box] {
cursor: default; cursor: default;
} }
.field-control, [data-md-field-control],
.field-icon, [data-md-field-icon],
.field-affix, [data-md-field-affix],
.field-trailing { [data-md-field-trailing] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
} }
} }
/* The hint or the error, and — when the field has a maximum — M3's character counter at the far /* The hint or the error, and — when the field has a maximum — M3's character counter at the far
end of the same row (specs: "Padding between supporting text and counter | 16dp", which the end of the same row (specs: "Padding between supporting text and counter | 16dp", which the
two paddings between them already make). */ two paddings between them already make). Supporting text sits 4px under the field. */
.field-support-row { [data-md-field-support-row] {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
} }
.field-support { [data-md-field-support] {
min-width: 0; min-width: 0;
padding: 0.25rem var(--field-pad) 0; padding: var(--md-sys-measurement-space50) var(--field-pad) 0;
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm); font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking); letter-spacing: var(--md-sys-typescale-body-sm-tracking);
} }
.field[data-invalid] .field-support { [data-md-field][data-md-invalid] [data-md-field-support] {
color: var(--md-sys-color-error); color: var(--md-sys-color-error);
} }
.field-counter { [data-md-field-counter] {
flex: none; flex: none;
margin-inline-start: auto; margin-inline-start: auto;
padding-block-start: 0.25rem; padding-block-start: var(--md-sys-measurement-space50);
padding-inline-end: var(--field-pad); padding-inline-end: var(--field-pad);
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm); font: var(--md-sys-typescale-body-sm);
@@ -498,26 +394,26 @@
} }
/* Past the maximum the count is an error, and says so in the error colour. */ /* Past the maximum the count is an error, and says so in the error colour. */
.field-counter[data-over] { [data-md-field-counter][data-md-over] {
color: var(--md-sys-color-error); color: var(--md-sys-color-error);
} }
/* ---- The filled text field ---------------------------------------------------------------- */ /* ---- The filled text field ---------------------------------------------------------------- */
.field[data-variant="filled"] .field-box { [data-md-field][data-md-variant='filled'] [data-md-field-box] {
border-radius: var(--md-sys-shape-corner-xs) var(--md-sys-shape-corner-xs) 0 0; border-radius: var(--md-sys-shape-corner-xs) var(--md-sys-shape-corner-xs) 0 0;
background-color: var(--md-sys-color-surface-container-highest); background-color: var(--md-sys-color-surface-container-highest);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
} }
@media (hover: hover) { @media (hover: hover) {
.field[data-variant="filled"] .field-box:hover { [data-md-field][data-md-variant='filled'] [data-md-field-box]:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, var(--md-sys-color-surface-container-highest)); background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-sys-color-surface-container-highest));
} }
} }
/* The outline becomes the active indicator: one line along the bottom. */ /* The outline becomes the active indicator: one line along the bottom. */
.field[data-variant="filled"] .field-outline { [data-md-field][data-md-variant='filled'] [data-md-field-outline] {
inset: auto 0 0; inset: auto 0 0;
padding: 0; padding: 0;
border: 0; border: 0;
@@ -525,51 +421,34 @@
border-radius: 0; border-radius: 0;
} }
.field[data-variant="filled"] .field-outline > legend { [data-md-field][data-md-variant='filled'] [data-md-field-outline] > legend {
display: none; display: none;
} }
.field[data-variant="filled"] .field-box:focus-within .field-outline { [data-md-field][data-md-variant='filled'] [data-md-field-box]:focus-within [data-md-field-outline] {
border-block-end-width: 2px; border-block-end-width: 2px;
} }
/* The label floats inside the box, above the value, and the value sits below it. */ /* The label floats inside the box, above the value, and the value sits below it. */
.field[data-variant="filled"] .field-label { [data-md-field][data-md-variant='filled'] [data-md-field-label] {
top: 1rem; top: var(--md-sys-measurement-space200);
inset-inline-start: var(--field-start); inset-inline-start: var(--field-start);
max-width: calc(100% - var(--field-start) - var(--field-pad)); max-width: calc(100% - var(--field-start) - var(--field-pad));
} }
.field[data-variant="filled"]:has(.field-label) .field-control { [data-md-field][data-md-variant='filled']:has([data-md-field-label]) [data-md-field-control] {
padding-block: 1.5rem 0.5rem; padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
} }
.field[data-variant="filled"]:has(.field-label) textarea.field-control { [data-md-field][data-md-variant='filled']:has([data-md-field-label]) [data-md-field-affix] {
--textarea-clear: 1.5rem;
--textarea-pad-top: 0rem;
--textarea-pad-bottom: calc(0.5rem - var(--field-grip));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
}
/* A file picker's button is taller than a line of text: it sits lower, and a little smaller. */
.field[data-variant="filled"]:has(.field-label) input[type="file"].field-control {
padding-block: 1.375rem 0.375rem;
}
.field[data-variant="filled"] input[type="file"].field-control::file-selector-button {
height: 1.75rem;
}
.field[data-variant="filled"]:has(.field-label) .field-affix {
align-self: stretch; align-self: stretch;
padding-block: 1.5rem 0.5rem; padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
} }
.field[data-variant="filled"]:has(.field-control:disabled) { [data-md-field][data-md-variant='filled']:has([data-md-field-control]:disabled) {
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); --field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
.field-box { [data-md-field-box] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 4%, transparent); background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 4%, transparent);
} }
} }
+62
View File
@@ -0,0 +1,62 @@
/*
* <x-file>: the browser's file input in the text field's chrome
* (resources/views/components/file.blade.php).
*
* The chrome is the field's (components/field.css); this file restyles only what the browser
* draws. Its "No file chosen" line is transparent, because it can be neither truncated nor wrapped
* and the caller shows what was chosen; its `::file-selector-button` is a tonal pill a 32px
* button in secondary-container with label-large, 16px padding, 12px before the line so it reads
* as the thing to press, with M3's hover state layer and disabled opacities (tokens/state.css). In
* a filled field the button sits under the floating label, 28px tall.
*
* The field's root carries `data-md-file`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@layer material.components {
/* `<x-file>`: the browser's own "No file chosen" line is transparent (the caller shows what was
chosen), and its button is a tonal pill the thing to press. */
input[type='file'][data-md-field-control] {
align-self: center;
color: transparent;
cursor: pointer;
}
input[type='file'][data-md-field-control]::file-selector-button {
height: 32px;
margin-inline-end: 12px;
padding-inline: var(--md-sys-measurement-space200);
border: 0;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
cursor: pointer;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
input[type='file'][data-md-field-control]:hover::file-selector-button {
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-sys-color-secondary-container));
}
}
input[type='file'][data-md-field-control]:disabled::file-selector-button {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
cursor: default;
}
/* A file picker's button is taller than a line of text: it sits lower, and a little smaller. */
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) input[type='file'][data-md-field-control] {
padding-block: 22px var(--md-sys-measurement-space75);
}
[data-md-field][data-md-variant='filled'] input[type='file'][data-md-field-control]::file-selector-button {
height: 28px;
}
}
+37
View File
@@ -0,0 +1,37 @@
/*
* <x-form>: its fields in one column, and its actions at the foot
* (resources/views/components/form.blade.php).
*
* The column is a one-track grid whose track is `minmax(0, 1fr)`: a bare grid's implicit column
* floors at its content's min-content, so on a phone a wide field would push the form past its
* pane. Rows take their content's height. 16px (`space200`) between fields, because a text field
* floats its label half above its outline and any less lets the hint under one field run into the
* label of the next.
*
* The actions wrap, end-aligned, 8px (`space100`) apart M3's gap between buttons in a row. A
* caller's class on the `actions` slot lands on the row and outranks this layer.
*
* [data-md-form] the <form>
* [data-md-form-actions] the row of actions, under the divider when `separator` asks for one
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
/* TODO(step 36): @import './divider.css' once <x-divider> leaves Tailwind (containment stream). */
@layer material.components {
[data-md-form] {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-auto-rows: min-content;
gap: var(--md-sys-measurement-space200);
}
[data-md-form-actions] {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--md-sys-measurement-space100);
}
}
+13
View File
@@ -0,0 +1,13 @@
/*
* <x-input>: M3's one-line text field (resources/views/components/input.blade.php).
*
* Nothing of its own to draw: the outlined and filled chrome, the floating label, the prefix and
* suffix, the clear and copy buttons and the character counter are the field's
* (components/field.css), and its icons are <x-icon>s. The field's root carries `data-md-input`
* for an application that wants to tell a one-line field from the others.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@import './icon.css';
+23 -23
View File
@@ -6,7 +6,7 @@
* - `<x-select>`'s: the native <select>, opted into the browser's customizable select * - `<x-select>`'s: the native <select>, opted into the browser's customizable select
* (`appearance: base-select`). `::picker(select)` is the menu and each <option> a row, while the * (`appearance: base-select`). `::picker(select)` is the menu and each <option> a row, while the
* keyboard, type-to-find, the screen reader and the form value stay the browser's own. * keyboard, type-to-find, the screen reader and the form value stay the browser's own.
* - `<x-choices searchable>`'s listbox, which Alpine draws, as `.field-menu` and `.field-option`. * - `<x-choices searchable>`'s listbox, which Alpine draws, as `[data-md-field-menu]` and `[data-md-field-option]`.
* *
* M3's menu as its tokens have it: surface-container at elevation 2, extra-small corner, 48px rows in * M3's menu as its tokens have it: surface-container at elevation 2, extra-small corner, 48px rows in
* body-large, the chosen row in secondary-container with a tick at its end (so it is not told by colour * body-large, the chosen row in secondary-container with a tick at its end (so it is not told by colour
@@ -149,7 +149,7 @@
} }
} }
.field-menu { [data-md-field-menu] {
max-block-size: 18rem; max-block-size: 18rem;
overflow-y: auto; overflow-y: auto;
padding-block: 0.5rem; padding-block: 0.5rem;
@@ -160,7 +160,7 @@
/* As a popover (`<x-choices searchable>`), it hangs under its field, as wide, and goes above only /* As a popover (`<x-choices searchable>`), it hangs under its field, as wide, and goes above only
when there is no room below. */ when there is no room below. */
.field-menu[popover] { [data-md-field-menu][popover] {
inset: auto; inset: auto;
top: anchor(bottom); top: anchor(bottom);
left: anchor(left); left: anchor(left);
@@ -172,12 +172,12 @@
position-try-fallbacks: flip-block; position-try-fallbacks: flip-block;
} }
.field-option[aria-disabled="true"] { [data-md-field-option][aria-disabled="true"] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default; cursor: default;
} }
.field-option { [data-md-field-option] {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -190,30 +190,30 @@
} }
/* `data-active` is the row the arrow keys or the pointer are on. */ /* `data-active` is the row the arrow keys or the pointer are on. */
.field-option[data-active] { [data-md-field-option][data-active] {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent); background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
} }
.field-option[aria-selected="true"] { [data-md-field-option][aria-selected="true"] {
background: var(--md-sys-color-secondary-container); background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container); color: var(--md-sys-color-on-secondary-container);
} }
.field-option[aria-selected="true"][data-active] { [data-md-field-option][aria-selected="true"][data-active] {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container)); background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
} }
.field-check { [data-md-field-check] {
margin-inline-start: auto; margin-inline-start: auto;
} }
.field-option:not([aria-selected="true"]) .field-check { [data-md-field-option]:not([aria-selected="true"]) [data-md-field-check] {
visibility: hidden; visibility: hidden;
} }
@supports (appearance: base-select) { @supports (appearance: base-select) {
select.field-control, select[data-md-field-control],
select.field-control::picker(select) { select[data-md-field-control]::picker(select) {
appearance: base-select; appearance: base-select;
} }
@@ -223,7 +223,7 @@
order tries the roomier side first, which opened a menu upwards with room to order tries the roomier side first, which opened a menu upwards with room to
spare under it. It opens with a fade and a grow on the spatial spring and spare under it. It opens with a fade and a grow on the spatial spring and
closes with a fade, as `<x-menu>` does. */ closes with a fade, as `<x-menu>` does. */
select.field-control::picker(select) { select[data-md-field-control]::picker(select) {
position-try-order: normal; position-try-order: normal;
max-block-size: 18rem; max-block-size: 18rem;
max-inline-size: calc(100vw - 2rem); max-inline-size: calc(100vw - 2rem);
@@ -242,7 +242,7 @@
transition-behavior: allow-discrete; transition-behavior: allow-discrete;
} }
select.field-control:open::picker(select) { select[data-md-field-control]:open::picker(select) {
opacity: 1; opacity: 1;
transition-property: opacity, scale, display, overlay; transition-property: opacity, scale, display, overlay;
transition-duration: var(--md-sys-motion-spatial-fast-duration); transition-duration: var(--md-sys-motion-spatial-fast-duration);
@@ -250,13 +250,13 @@
} }
@starting-style { @starting-style {
select.field-control:open::picker(select) { select[data-md-field-control]:open::picker(select) {
opacity: 0; opacity: 0;
scale: 0.95; scale: 0.95;
} }
} }
select.field-control option { select[data-md-field-control] option {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -272,31 +272,31 @@
} }
@media (hover: hover) { @media (hover: hover) {
select.field-control option:hover { select[data-md-field-control] option:hover {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent); background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
} }
} }
select.field-control option:focus-visible { select[data-md-field-control] option:focus-visible {
background: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent); background: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
} }
select.field-control option:checked { select[data-md-field-control] option:checked {
background: var(--md-sys-color-secondary-container); background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container); color: var(--md-sys-color-on-secondary-container);
} }
@media (hover: hover) { @media (hover: hover) {
select.field-control option:checked:hover { select[data-md-field-control] option:checked:hover {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container)); background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
} }
} }
select.field-control option:checked:focus-visible { select[data-md-field-control] option:checked:focus-visible {
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 10%, var(--md-sys-color-secondary-container)); background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 10%, var(--md-sys-color-secondary-container));
} }
select.field-control option:disabled { select[data-md-field-control] option:disabled {
background: transparent; background: transparent;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default; cursor: default;
@@ -305,7 +305,7 @@
/* The tick, at the row's end: the browser draws it before the label, and hides /* The tick, at the row's end: the browser draws it before the label, and hides
it on every row but the chosen one. The Material Symbol, as a mask, so it it on every row but the chosen one. The Material Symbol, as a mask, so it
takes the row's ink. */ takes the row's ink. */
select.field-control option::checkmark { select[data-md-field-control] option::checkmark {
content: ""; content: "";
order: 1; order: 1;
flex: none; flex: none;
+12
View File
@@ -0,0 +1,12 @@
/*
* <x-password>: a text field with M3's trailing eye (resources/views/components/password.blade.php).
*
* Nothing of its own to draw: the chrome and the eye, a trailing icon button, are the field's
* (components/field.css), and the eye's two glyphs are <x-icon>s. The field's root carries
* `data-md-password`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@import './icon.css';
+133
View File
@@ -0,0 +1,133 @@
/*
* <x-radio>: M3's radio buttons (resources/views/components/radio.blade.php), on the row the
* selection controls share (components/selection.css).
*
* RadioButtonTokens (androidx Compose Material 3, Apache-2.0): a 20px ring with a 2px
* on-surface-variant outline, primary once chosen, and a 10px primary dot that grows in on the fast
* spatial spring; the state layer a 40px circle around it. With the server's error the ring and
* the dot are error. Disabled, both are on-surface at 38%. Beside a hint the ring drops 2px, to sit
* on the label's first line.
*
* The group is a <fieldset> whose legend (label-large, on-surface-variant, 12px above the options)
* is the question. The options stack 16px apart, as M3 lays radio buttons out; `data-md-inline`
* lays them in a wrapping row 24px apart from `medium` (600px), and compact always stacks. The
* hint, or the errors in its place, sits 8px under the options.
*
* [data-md-radio] the <fieldset>; `class` and `style` land here; data-md-inline
* [data-md-radio-legend]
* [data-md-radio-options]
* [data-md-selection-row]
* [data-md-radio-button] the ring: input, [data-md-radio-dot]
* [data-md-selection-support] the hint, or the errors
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './selection.css';
@layer material.components {
[data-md-radio] {
min-width: 0;
}
[data-md-radio-legend] {
margin-bottom: 12px;
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;
}
[data-md-radio-options] {
display: grid;
gap: var(--md-sys-measurement-space200);
}
/* M3 stacks radio buttons and cautions against a row at any width; `inline` is for two or three
short labels, and only from medium. */
@media (width >= 600px) {
[data-md-radio][data-md-inline] [data-md-radio-options] {
display: flex;
flex-wrap: wrap;
column-gap: var(--md-sys-measurement-space300);
}
}
[data-md-radio] [data-md-selection-support] {
margin-top: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-radio] [data-md-selection-support][role='alert'] {
color: var(--md-sys-color-error);
}
[data-md-radio-button] {
position: relative;
display: grid;
flex: none;
place-items: center;
width: 20px;
height: 20px;
}
[data-md-radio-button] > * {
grid-area: 1 / 1;
}
[data-md-selection-row]:has([data-md-selection-hint]) [data-md-radio-button] {
margin-top: var(--md-sys-measurement-space25);
}
[data-md-radio-button]::before {
inset: -10px;
}
[data-md-radio-button]:has(input:focus-visible)::before {
outline: 3px solid var(--md-sys-color-secondary);
}
[data-md-radio-button] input {
width: 20px;
height: 20px;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: var(--md-sys-shape-corner-full);
}
[data-md-radio-button] [data-md-radio-dot] {
width: 10px;
height: 10px;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary);
scale: 0;
pointer-events: none;
transition: scale var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-md-radio-button] input:checked {
border-color: var(--md-sys-color-primary);
}
[data-md-radio-button] input:checked ~ [data-md-radio-dot] {
scale: 1;
}
[data-md-selection-row][data-md-invalid] [data-md-radio-button] input {
border-color: var(--md-sys-color-error);
}
[data-md-selection-row][data-md-invalid] [data-md-radio-button] [data-md-radio-dot] {
background-color: var(--md-sys-color-error);
}
[data-md-radio-button] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-radio-button] input:disabled ~ [data-md-radio-dot] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
}
+94 -74
View File
@@ -12,36 +12,46 @@
* Widths are M3's: never wider than 720px, and the bar grows to that width while it is focused * Widths are M3's: never wider than 720px, and the bar grows to that width while it is focused
* `--search-width` is its resting width (the room it is given, by default) and * `--search-width` is its resting width (the room it is given, by default) and
* `--search-open-width` the focused one, so a caller that wants M3's 360px resting bar sets * `--search-open-width` the focused one, so a caller that wants M3's 360px resting bar sets
* `--search-width: 22.5rem` on a wrapper. The leading and trailing padding is 24px unfocused and * `--search-width: 360px` on a wrapper. The leading and trailing padding is 24px unfocused and
* 16px focused, as the search specs table gives it. * 16px focused, as the search specs table gives it.
* *
* [data-search] the root; data-open, data-full-screen, data-trigger * Hovered and focused buttons in the bar take M3's state-layer opacities (tokens/state.css), and the
* [data-search-scrim] over the page while the view is docked * docked scrim is M3's scrim role at 32%, which no system token names. The breakpoint that makes the
* [data-search-trigger] data-trigger="icon" only: the icon button that expands the view * view full screen is decided in resources/js/search.js (`upTo('medium')`, 600px).
* [data-search-bar] the pill, above the view *
* [data-search-leading], [data-search-field] (the combobox around [data-search-input]), * [data-md-search] the root; data-md-open, data-md-full-screen, data-md-trigger
* [data-search-clear], [data-search-trailing] * [data-md-search-scrim] over the page while the view is docked
* [data-search-view] the container behind the bar * [data-md-search-trigger] data-md-trigger="icon" only: the icon button that expands the view
* [data-search-suggestions] before the first keystroke * [data-md-search-bar] the pill, above the view
* [data-search-results] once something is typed * [data-md-search-leading], [data-md-search-field] (the combobox around
* [data-search-status] the polite live region that counts whichever list is on screen * [data-md-search-input]), [data-md-search-clear], [data-md-search-trailing]
* [data-md-search-view] the container behind the bar
* [data-md-search-suggestions] before the first keystroke
* [data-md-search-results] once something is typed; [data-md-search-empty] when nothing is
* [data-md-search-status] the polite live region that counts whichever list is on screen
*/ */
@layer components { @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
[data-search] {
--search-height: 3.5rem; @import './icon.css';
@layer material.components {
[data-md-search] {
--search-height: 56px;
/* M3: min 360px, max 720px, and wider while focused. */ /* M3: min 360px, max 720px, and wider while focused. */
--search-width: 100%; --search-width: 100%;
--search-open-width: 45rem; --search-open-width: 720px;
position: relative;
} }
[data-search][data-open] { [data-md-search][data-md-open] {
z-index: 50; z-index: 50;
} }
/* Docked, the view opens over the page: M3 puts a scrim under it. Behind the bar and the view /* Docked, the view opens over the page: M3 puts a scrim under it. Behind the bar and the view
inside the root's own stacking context, above everything the page draws outside it. */ inside the root's own stacking context, above everything the page draws outside it. */
[data-search-scrim] { [data-md-search-scrim] {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: -1; z-index: -1;
@@ -53,20 +63,20 @@
} }
@starting-style { @starting-style {
[data-search-scrim] { [data-md-search-scrim] {
opacity: 0; opacity: 0;
} }
} }
[data-search-bar] { [data-md-search-bar] {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: var(--md-sys-measurement-space50);
width: min(100%, var(--search-width)); width: min(100%, var(--search-width));
height: var(--search-height); height: var(--search-height);
padding-inline: 0.75rem; padding-inline: 12px;
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-high); background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
@@ -77,70 +87,70 @@
} }
/* Focused: wider, and the padding drops from 24px to the contained style's 16px. */ /* Focused: wider, and the padding drops from 24px to the contained style's 16px. */
[data-search][data-open] [data-search-bar] { [data-md-search][data-md-open] [data-md-search-bar] {
width: min(100%, var(--search-open-width)); width: min(100%, var(--search-open-width));
padding-inline: 0.25rem; padding-inline: var(--md-sys-measurement-space50);
} }
@media (hover: hover) { @media (hover: hover) {
[data-search]:not([data-open]) [data-search-bar]:hover { [data-md-search]:not([data-md-open]) [data-md-search-bar]:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, var(--md-sys-color-surface-container-high)); background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-sys-color-surface-container-high));
} }
} }
[data-search][data-open] [data-search-bar] { [data-md-search][data-md-open] [data-md-search-bar] {
background-color: transparent; background-color: transparent;
} }
[data-search-leading], [data-md-search-leading],
[data-search-clear] { [data-md-search-clear] {
display: grid; display: grid;
flex: none; flex: none;
place-items: center; place-items: center;
width: 3rem; width: var(--md-sys-measurement-space600);
height: 3rem; height: var(--md-sys-measurement-space600);
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
} }
[data-search-leading] { [data-md-search-leading] {
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
} }
[data-search-clear] { [data-md-search-clear] {
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
cursor: pointer; cursor: pointer;
} }
[data-search-bar] button { [data-md-search-bar] button {
outline: none; outline: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
} }
@media (hover: hover) { @media (hover: hover) {
[data-search-bar] button:hover { [data-md-search-bar] button:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent); background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent);
} }
} }
[data-search-bar] button:focus-visible { [data-md-search-bar] button:focus-visible {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent); background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
outline: 3px solid var(--md-sys-color-secondary); outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px; outline-offset: -3px;
} }
/* The combobox wrapper is only a role holder; it has to lay out as the input used to. */ /* The combobox wrapper is only a role holder; it has to lay out as the input used to. */
[data-search-field] { [data-md-search-field] {
display: flex; display: flex;
flex: 1 1 0%; flex: 1 1 0%;
min-width: 0; min-width: 0;
height: 100%; height: 100%;
} }
[data-search-input] { [data-md-search-input] {
flex: 1 1 0%; flex: 1 1 0%;
min-width: 0; min-width: 0;
height: 100%; height: 100%;
padding-inline: 0.25rem; padding-inline: var(--md-sys-measurement-space50);
appearance: none; appearance: none;
background: transparent; background: transparent;
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);
@@ -150,41 +160,41 @@
letter-spacing: var(--md-sys-typescale-body-lg-tracking); letter-spacing: var(--md-sys-typescale-body-lg-tracking);
} }
[data-search-input]::placeholder { [data-md-search-input]::placeholder {
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
opacity: 1; opacity: 1;
} }
[data-search-input]::-webkit-search-cancel-button, [data-md-search-input]::-webkit-search-cancel-button,
[data-search-input]::-webkit-search-decoration { [data-md-search-input]::-webkit-search-decoration {
appearance: none; appearance: none;
display: none; display: none;
} }
[data-search-bar]:has([data-search-input]:placeholder-shown) [data-search-clear] { [data-md-search-bar]:has([data-md-search-input]:placeholder-shown) [data-md-search-clear] {
display: none; display: none;
} }
[data-search-trailing] { [data-md-search-trailing] {
display: flex; display: flex;
flex: none; flex: none;
align-items: center; align-items: center;
gap: 0.25rem; gap: var(--md-sys-measurement-space50);
padding-inline-end: 0.25rem; padding-inline-end: var(--md-sys-measurement-space50);
} }
[data-search][data-open] [data-search-trailing] { [data-md-search][data-md-open] [data-md-search-trailing] {
display: none; display: none;
} }
[data-search-view] { [data-md-search-view] {
position: absolute; position: absolute;
inset-inline: 0; inset-inline: 0;
top: 0; top: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: var(--search-open-width); max-width: var(--search-open-width);
max-height: min(40rem, 70dvh); max-height: min(640px, 70dvh);
padding-top: var(--search-height); padding-top: var(--search-height);
overflow: hidden; overflow: hidden;
border-radius: var(--md-sys-shape-corner-xl); border-radius: var(--md-sys-shape-corner-xl);
@@ -198,7 +208,7 @@
} }
@starting-style { @starting-style {
[data-search-view] { [data-md-search-view] {
opacity: 0; opacity: 0;
scale: 1 0.9; scale: 1 0.9;
} }
@@ -206,35 +216,35 @@
/* M3: the docked container is at least 240px tall once there is something in it to be tall /* M3: the docked container is at least 240px tall once there is something in it to be tall
about; a search with nothing to show stays the height of its bar. */ about; a search with nothing to show stays the height of its bar. */
[data-search-view]:has([data-search-results], [data-search-suggestions]) { [data-md-search-view]:has([data-md-search-results], [data-md-search-suggestions]) {
min-height: 15rem; min-height: 240px;
} }
/* No divider: that belongs to the divided style, which Expressive deprecates in favour of the /* No divider: that belongs to the divided style, which Expressive deprecates in favour of the
contained one this file draws. The suggestions stand in the same place, before the first contained one this file draws. The suggestions stand in the same place, before the first
keystroke; only one of the two is ever on screen. */ keystroke; only one of the two is ever on screen. */
[data-search-results], [data-md-search-results],
[data-search-suggestions] { [data-md-search-suggestions] {
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
padding-block: 0.5rem; padding-block: var(--md-sys-measurement-space100);
} }
/* M3's second entry point: search as a secondary action, one icon button that expands into the /* M3's second entry point: search as a secondary action, one icon button that expands into the
full-screen view. The root keeps the button's 48px whether the view is open or not, so a full-screen view. The root keeps the button's 48px whether the view is open or not, so a
toolbar does not shift under it, and the bar is the view's header rather than a resting bar. */ toolbar does not shift under it, and the bar is the view's header rather than a resting bar. */
[data-search][data-trigger="icon"] { [data-md-search][data-md-trigger='icon'] {
flex: none; flex: none;
width: 3rem; width: var(--md-sys-measurement-space600);
height: 3rem; height: var(--md-sys-measurement-space600);
} }
[data-search-trigger] { [data-md-search-trigger] {
display: grid; display: grid;
place-items: center; place-items: center;
width: 3rem; width: var(--md-sys-measurement-space600);
height: 3rem; height: var(--md-sys-measurement-space600);
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
cursor: pointer; cursor: pointer;
@@ -243,45 +253,55 @@
} }
@media (hover: hover) { @media (hover: hover) {
[data-search-trigger]:hover { [data-md-search-trigger]:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent); background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent);
} }
} }
[data-search-trigger]:focus-visible { [data-md-search-trigger]:focus-visible {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent); background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
outline: 3px solid var(--md-sys-color-secondary); outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px; outline-offset: -3px;
} }
[data-search-trigger]:active { [data-md-search-trigger]:active {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent); background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent);
} }
[data-search][data-trigger="icon"]:not([data-open]) [data-search-bar] { [data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] {
display: none; display: none;
} }
/* Full screen, on a compact window. */ /* Full screen, on a compact window. */
[data-search][data-full-screen] [data-search-bar] { [data-md-search][data-md-full-screen] [data-md-search-bar] {
position: fixed; position: fixed;
inset: 0 0 auto; inset: 0 0 auto;
width: auto; width: auto;
height: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top))); height: calc(var(--md-sys-measurement-space900) + var(--material-safe-top, env(safe-area-inset-top)));
padding-top: var(--material-safe-top, env(safe-area-inset-top)); padding-top: var(--material-safe-top, env(safe-area-inset-top));
padding-inline: 0.25rem; padding-inline: var(--md-sys-measurement-space50);
border-radius: 0; border-radius: 0;
} }
[data-search][data-full-screen] [data-search-view] { [data-md-search][data-md-full-screen] [data-md-search-view] {
position: fixed; position: fixed;
inset: 0; inset: 0;
max-width: none; max-width: none;
max-height: none; max-height: none;
padding-top: calc(4.5rem + var(--material-safe-top, env(safe-area-inset-top))); padding-top: calc(var(--md-sys-measurement-space900) + var(--material-safe-top, env(safe-area-inset-top)));
/* The full-screen layout has its own container role, one step from the docked one. */ /* The full-screen layout has its own container role, one step from the docked one. */
background-color: var(--md-sys-color-surface-container-low); background-color: var(--md-sys-color-surface-container-low);
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
} }
/* What the caller says when nothing matches: body-medium in on-surface-variant, padded like a
list row (16px across, 12px down). */
[data-md-search-empty] {
padding: 12px var(--md-sys-measurement-space200);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
} }
+74
View File
@@ -0,0 +1,74 @@
/*
* <x-select>: the native <select> in the text field's chrome, drawn as M3's exposed dropdown menu
* (resources/views/components/select.blade.php).
*
* The chrome is the field's (components/field.css); this file is what a <select> changes in it. The
* select covers its whole field, so a press anywhere opens it and the list the browser anchors to
* it is the field's width. Where the browser has the customizable select (`appearance:
* base-select`), the closed select loses the button chrome that brings, the field reads as focused
* while the list is open, and the arrow turns over. The open list itself is M3's menu, drawn by
* components/menu.css.
*
* The field's root carries `data-md-select`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@import './icon.css';
/* TODO(step 36): @import './menu.css' once the menu leaves Tailwind (actions stream); its select rules draw the open list. */
@layer material.components {
/* A select covers its whole field out over the padding and the leading icon at
the start, under the arrow to the end and its own padding puts the value back
where it was. So a press anywhere on the field opens it, and the list, which
the browser anchors to the select, is the field's width (components/menu.css). */
select[data-md-field-control] {
--field-end: calc(var(--field-icon) + var(--field-gap) + var(--field-pad));
appearance: none;
cursor: pointer;
margin-inline: calc(-1 * var(--field-start)) calc(-1 * var(--field-end));
padding-inline: var(--field-start) var(--field-end);
}
select[data-md-field-control]:disabled {
cursor: default;
}
/* Where the browser has a customizable select, the closed select is its own
button: without the border, padding and arrow that button brings, it is the
same field as before. While its list is open the field reads as focused
said from `:open`, because focus inside the list is in the top layer and
the arrow turns over, as the searchable choices' does. */
@supports (appearance: base-select) {
select[data-md-field-control] {
display: flex;
align-items: center;
border: 0;
padding-block: 0;
border-radius: 0;
background: transparent;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
select[data-md-field-control]::picker-icon {
display: none;
}
[data-md-field-box]:has(select[data-md-field-control]:open) {
--field-edge: var(--md-sys-color-primary);
--field-ink: var(--md-sys-color-primary);
[data-md-field-outline] {
border-width: 2px;
}
}
[data-md-field]:has(select[data-md-field-control]:open) [data-md-field-arrow] {
rotate: 180deg;
}
}
}
+67 -296
View File
@@ -1,70 +1,90 @@
/* /*
* M3's selection controls: the checkbox, the radio button and the switch (CheckboxTokens, * What M3's three selection controls share: the checkbox (components/checkbox.css), the radio button
* RadioButtonTokens, SwitchTokens, androidx Compose Material 3, Apache-2.0). * (components/radio.css) and the switch (components/toggle.css) CheckboxTokens, RadioButtonTokens,
* SwitchTokens, androidx Compose Material 3, Apache-2.0. Each control's stylesheet imports this one.
* *
* Each is a real `<input>` with its appearance removed and drawn over, so focus, the keyboard * Each is a real `<input>` with its appearance removed and drawn over, so focus, the keyboard
* (Space; the arrow keys between radios), the form value, `wire:model` and what a screen reader * (Space; the arrow keys between radios), the form value, `wire:model` and what a screen reader
* announces stay the browser's. The row around it is a `<label>`, so its text is pressable too. * announces stay the browser's. The row around it is a `<label>`, so its text is pressable too:
* Hooks are data attributes, not class names: `checkbox`, `radio` and `toggle` are daisyUI's, which
* the design guard rejects.
* *
* [data-selection] the <label> row; `data-invalid` when the server has an error for it * [data-md-selection-row] the <label>: the control and its text, 16px apart; with a hint
* [data-checkbox] the 18px box, its state layer a 40px circle around it * the control aligns to the first line; `data-md-right` puts the
* input, [data-check], [data-mixed] * control at the end; `data-md-invalid` when the server has an error
* [data-radio] the 20px ring and its 10px dot * [data-md-checkbox-box] | [data-md-radio-button] | [data-md-switch] the control
* input, [data-dot] * [data-md-selection-text] the label (body-large, on-surface) and the hint under it
* [data-switch] the 52×32 track; `data-icons` = "both" or "selected" * [data-md-selection-label], [data-md-selection-hint]
* input, [data-handle] (with [data-on] and [data-off] icons)
* *
* Every state layer is on-surface when unselected and primary when selected, 8% on hover (only * Every state layer is on-surface when unselected and primary when selected, at M3's hover opacity
* where a pointer can hover) and 10% on focus and press; the focus indicator is the package's * (only where a pointer can hover) and its focus and press opacities (tokens/state.css); the focus
* secondary ring. A disabled control is on-surface at 38% and has no state layer. * indicator is the package's secondary ring. A disabled control is on-surface at 38% and has no
* state layer.
* *
* The state layer is a ::before with no pointer events, so it is not a target: a labelled control * The state layer is a ::before with no pointer events, so it is not a target: a labelled control
* is pressed anywhere along its row, and a control with no label wears the `touch-target` utility * is pressed anywhere along its row, and a control with no label wears `md-touch-target`
* (resources/css/tokens/state.css) so its box catches presses over M3's 48px minimum. * (foundation/interaction.css) so its box catches presses over M3's 48px minimum.
*/ */
@layer components { @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
[data-selection] {
@layer material.components {
[data-md-selection-row] {
/* The state layer's tint. */
--selection-ink: var(--md-sys-color-on-surface);
display: flex;
align-items: center;
gap: var(--md-sys-measurement-space200);
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
[data-selection]:has(input:disabled) { [data-md-selection-row]:has([data-md-selection-hint]) {
align-items: flex-start;
}
[data-md-selection-row][data-md-right] {
flex-direction: row-reverse;
justify-content: space-between;
}
[data-md-selection-row]:has(input:disabled) {
cursor: default; cursor: default;
} }
[data-selection]:has(input:disabled) [data-selection-label] { [data-md-selection-text] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); min-width: 0;
} }
[data-checkbox], [data-md-selection-label] {
[data-radio] { display: block;
position: relative; color: var(--md-sys-color-on-surface);
display: grid; font: var(--md-sys-typescale-body-lg);
flex: none; letter-spacing: var(--md-sys-typescale-body-lg-tracking);
place-items: center; font-variation-settings: normal;
} }
[data-checkbox] > *, [data-md-selection-hint] {
[data-radio] > * { display: block;
grid-area: 1 / 1; margin-top: var(--md-sys-measurement-space25);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
} }
[data-checkbox]::before, [data-md-selection-row]:has(input:disabled) [data-md-selection-label] {
[data-radio]::before, color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
[data-handle]::before { }
content: "";
:is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
content: '';
position: absolute; position: absolute;
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
pointer-events: none; pointer-events: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
} }
[data-checkbox] input, :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch]) input {
[data-radio] input,
[data-switch] input {
margin: 0; margin: 0;
appearance: none; appearance: none;
cursor: inherit; cursor: inherit;
@@ -74,275 +94,26 @@
transition-timing-function: var(--md-sys-motion-effects-fast); transition-timing-function: var(--md-sys-motion-effects-fast);
} }
/* The state layer. `--selection-ink` is what it is tinted with. */ [data-md-selection-row]:has(input:checked),
[data-selection] { [data-md-selection-row]:has(input:indeterminate) {
--selection-ink: var(--md-sys-color-on-surface);
}
[data-selection]:has(input:checked),
[data-selection]:has(input:indeterminate) {
--selection-ink: var(--md-sys-color-primary); --selection-ink: var(--md-sys-color-primary);
} }
[data-selection][data-invalid] { [data-md-selection-row][data-md-invalid] {
--selection-ink: var(--md-sys-color-error); --selection-ink: var(--md-sys-color-error);
} }
@media (hover: hover) { @media (hover: hover) {
[data-selection]:hover:not(:has(input:disabled)) :is([data-checkbox], [data-radio], [data-handle])::before { [data-md-selection-row]:hover:not(:has(input:disabled)) :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) 8%, transparent); background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent);
} }
} }
[data-selection]:has(input:focus-visible) :is([data-checkbox], [data-radio], [data-handle])::before, [data-md-selection-row]:has(input:focus-visible) :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
[data-selection]:not(:has(input:disabled)):active :is([data-checkbox], [data-radio], [data-handle])::before { background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
background-color: color-mix(in srgb, var(--selection-ink) 10%, transparent);
} }
[data-checkbox]:has(input:focus-visible)::before, [data-md-selection-row]:not(:has(input:disabled)):active :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
[data-radio]:has(input:focus-visible)::before { background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent);
outline: 3px solid var(--md-sys-color-secondary);
}
/* ---- Checkbox ---------------------------------------------------------------------------- */
[data-checkbox] {
width: 1.125rem;
height: 1.125rem;
}
[data-checkbox]::before {
inset: -0.6875rem;
}
[data-checkbox] input {
width: 1.125rem;
height: 1.125rem;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: 2px;
}
[data-checkbox] input:is(:checked, :indeterminate) {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-checkbox] :is([data-check], [data-mixed]) {
color: var(--md-sys-color-on-primary);
opacity: 0;
scale: 0.5;
pointer-events: none;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-checkbox] input:checked:not(:indeterminate) ~ [data-check],
[data-checkbox] input:indeterminate ~ [data-mixed] {
opacity: 1;
scale: 1;
}
[data-selection][data-invalid] [data-checkbox] input {
border-color: var(--md-sys-color-error);
}
[data-selection][data-invalid] [data-checkbox] input:is(:checked, :indeterminate) {
background-color: var(--md-sys-color-error);
}
[data-selection][data-invalid] [data-checkbox] :is([data-check], [data-mixed]) {
color: var(--md-sys-color-on-error);
}
[data-checkbox] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
[data-checkbox] input:disabled:is(:checked, :indeterminate) {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
[data-checkbox] input:disabled ~ :is([data-check], [data-mixed]) {
color: var(--md-sys-color-surface);
}
/* ---- Radio button ------------------------------------------------------------------------ */
[data-radio] {
width: 1.25rem;
height: 1.25rem;
}
[data-radio]::before {
inset: -0.625rem;
}
[data-radio] input {
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: var(--md-sys-shape-corner-full);
}
[data-radio] [data-dot] {
width: 0.625rem;
height: 0.625rem;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary);
scale: 0;
pointer-events: none;
transition: scale var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-radio] input:checked {
border-color: var(--md-sys-color-primary);
}
[data-radio] input:checked ~ [data-dot] {
scale: 1;
}
[data-selection][data-invalid] [data-radio] input {
border-color: var(--md-sys-color-error);
}
[data-selection][data-invalid] [data-radio] [data-dot] {
background-color: var(--md-sys-color-error);
}
[data-radio] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
[data-radio] input:disabled ~ [data-dot] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
/* ---- Switch -------------------------------------------------------------------------------
*
* The handle's centre is 16px from the track's start unselected and 36px selected; it is 16px
* across unselected, 24px with an icon or selected, and 28px while pressed. The positions below
* are those centres less half the size, measured inside the track's 2px outline. */
[data-switch] {
position: relative;
display: grid;
flex: none;
width: 3.25rem;
height: 2rem;
}
[data-switch] input {
width: 100%;
height: 100%;
border: 2px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-highest);
}
[data-switch] input:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-handle] {
--handle: 1rem;
position: absolute;
top: 50%;
inset-inline-start: calc(1rem - var(--handle) / 2);
display: grid;
place-items: center;
width: var(--handle);
height: var(--handle);
translate: 0 -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-outline);
color: var(--md-sys-color-surface-container-highest);
pointer-events: none;
transition-property: inset-inline-start, width, height, background-color;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-handle]::before {
inset: 50% auto auto 50%;
width: 2.5rem;
height: 2.5rem;
translate: -50% -50%;
}
[data-handle] > svg {
grid-area: 1 / 1;
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-switch][data-icons="both"] [data-handle] {
--handle: 1.5rem;
}
[data-switch][data-icons="both"] input:not(:checked) ~ [data-handle] [data-off],
[data-switch][data-icons] input:checked ~ [data-handle] [data-on] {
opacity: 1;
}
[data-switch] input:checked {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-switch] input:checked ~ [data-handle] {
--handle: 1.5rem;
inset-inline-start: calc(2.25rem - var(--handle) / 2);
background-color: var(--md-sys-color-on-primary);
color: var(--md-sys-color-on-primary-container);
}
[data-selection]:active [data-switch] input:not(:disabled) ~ [data-handle] {
--handle: 1.75rem;
}
@media (hover: hover) {
[data-selection]:hover [data-switch] input:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-on-surface-variant);
}
[data-selection]:hover [data-switch] input:checked:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-primary-container);
}
}
[data-switch] input:focus-visible ~ [data-handle],
[data-selection]:active [data-switch] input:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-on-surface-variant);
}
[data-switch] input:checked:focus-visible ~ [data-handle],
[data-selection]:active [data-switch] input:checked:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-primary-container);
}
[data-switch] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-surface-container-highest) 12%, transparent);
}
[data-switch] input:disabled ~ [data-handle] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
color: color-mix(in srgb, var(--md-sys-color-surface-container-highest) 38%, transparent);
}
[data-switch] input:disabled:checked {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
}
[data-switch] input:disabled:checked ~ [data-handle] {
background-color: var(--md-sys-color-surface);
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
} }
} }
+403
View File
@@ -0,0 +1,403 @@
/*
* <x-slider>: M3 Expressive's slider (resources/views/components/slider.blade.php), drawn over native
* range inputs by resources/js/slider.js, which places every part with inline `left`, `width` and
* `border-radius`; this file gives the parts their size, colour and states.
*
* From androidx Compose Material 3 at commit 27cf9a7 (Slider.kt, SliderTokens.kt, Apache-2.0) and
* M3's sliders/specs table: a 4px handle, 2px while pressed or focused and 6px shorter while its
* focus ring shows (Material Components' m3_slider_focus_ring_thumb_height_decrease); 4px stop
* indicators and ticks; the active track and the handle in the colour, the inactive track in its
* container, and a stop, a tick or the inset icon in the "on" colour of the part of the track it
* sits on. Disabled, the active parts are on-surface at 38% and the inactive track, and the ticks
* over the active track, at 12% (tokens/state.css). The value label is M3's label container: 44px
* tall and at least 48px wide in inverse-surface with label-large, 4px above the handle, growing in
* on the fast effects spring while the handle is pressed or focused (`data-md-value-label="drag"`).
*
* Expressive's sizes, by `data-md-size`, as custom properties on the root:
*
* size track handle (and the slider across) inset icon
* xs 16px 44px (the slider 48px)
* sm 24px 44px (48px)
* md 40px 52px 24px
* lg 56px 68px 24px
* xl 96px 108px 32px
*
* `data-md-orientation="vertical"` turns the same drawing a quarter inside a size container: the
* slider is as wide as a horizontal one is tall and 192px long unless the root's height says
* otherwise; the value label turns back so it reads across, beside the handle. A right-to-left page
* mirrors a horizontal drawing and turns its label and icon back. Without script the inputs show as
* native ranges in the colour.
*
* [data-md-slider] the root; data-md-size, data-md-color, data-md-value-label,
* data-md-orientation, data-md-centered
* [data-md-slider-label]
* [data-md-slider-control] the element the pointer presses; holds the inputs
* [data-md-slider-drawing]
* [data-md-slider-track]
* [data-md-slider-segment="start|active|end"], [data-md-slider-tick] (data-md-active),
* [data-md-slider-stop="start|end"], [data-md-slider-icon] (data-md-active)
* [data-md-slider-handle="start|end"] (data-md-pressed, data-md-focused)
* [data-md-slider-thumb], [data-md-slider-value-anchor] > [data-md-slider-value]
* [data-md-slider-input="start|end"]
* [data-md-slider-hint] | [data-md-slider-errors]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-slider] {
--slider-active: var(--md-sys-color-primary);
--slider-inactive: var(--md-sys-color-secondary-container);
--slider-on-active: var(--md-sys-color-on-primary);
--slider-on-inactive: var(--md-sys-color-on-secondary-container);
--slider-disabled-active: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
--slider-disabled-inactive: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
--slider-across: 48px;
--slider-track: 16px;
--slider-handle: 44px;
width: 100%;
min-width: 0;
}
[data-md-slider][data-md-size='sm'] {
--slider-track: 24px;
}
[data-md-slider][data-md-size='md'] {
--slider-across: 52px;
--slider-track: 40px;
--slider-handle: 52px;
}
[data-md-slider][data-md-size='lg'] {
--slider-across: 68px;
--slider-track: 56px;
--slider-handle: 68px;
}
[data-md-slider][data-md-size='xl'] {
--slider-across: 108px;
--slider-track: 96px;
--slider-handle: 108px;
}
[data-md-slider][data-md-color='secondary'] {
--slider-active: var(--md-sys-color-secondary);
--slider-on-active: var(--md-sys-color-on-secondary);
}
[data-md-slider][data-md-color='tertiary'] {
--slider-active: var(--md-sys-color-tertiary);
--slider-inactive: var(--md-sys-color-tertiary-container);
--slider-on-active: var(--md-sys-color-on-tertiary);
--slider-on-inactive: var(--md-sys-color-on-tertiary-container);
}
[data-md-slider][data-md-color='error'] {
--slider-active: var(--md-sys-color-error);
--slider-inactive: var(--md-sys-color-error-container);
--slider-on-active: var(--md-sys-color-on-error);
--slider-on-inactive: var(--md-sys-color-on-error-container);
}
[data-md-slider][data-md-color='success'] {
--slider-active: var(--md-sys-color-success);
--slider-inactive: var(--md-sys-color-success-container);
--slider-on-active: var(--md-sys-color-on-success);
--slider-on-inactive: var(--md-sys-color-on-success-container);
}
[data-md-slider][data-md-color='warning'] {
--slider-active: var(--md-sys-color-warning);
--slider-inactive: var(--md-sys-color-warning-container);
--slider-on-active: var(--md-sys-color-on-warning);
--slider-on-inactive: var(--md-sys-color-on-warning-container);
}
[data-md-slider][data-md-color='info'] {
--slider-active: var(--md-sys-color-info);
--slider-inactive: var(--md-sys-color-info-container);
--slider-on-active: var(--md-sys-color-on-info);
--slider-on-inactive: var(--md-sys-color-on-info-container);
}
/* Disabled: the colours give way to on-surface at M3's disabled opacities. */
[data-md-slider]:has([data-md-slider-input]:disabled) {
--slider-active: var(--slider-disabled-active);
--slider-inactive: var(--slider-disabled-inactive);
--slider-on-active: var(--slider-disabled-inactive);
--slider-on-inactive: var(--slider-disabled-active);
}
[data-md-slider][data-md-orientation='vertical'] {
display: flex;
flex-direction: column;
width: fit-content;
}
[data-md-slider-label] {
display: block;
margin-bottom: var(--md-sys-measurement-space100);
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;
}
[data-md-slider-control] {
position: relative;
height: var(--slider-across);
cursor: pointer;
touch-action: pan-y;
-webkit-user-select: none;
user-select: none;
}
[data-md-slider-control]:has(:disabled) {
cursor: not-allowed;
}
/* Standing up: as wide as a lying slider is tall, 192px long unless the root says otherwise, and
a size container, which lets the drawing be as long as this is tall. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-control] {
flex: auto;
width: var(--slider-across);
height: 192px;
min-height: 0;
container-type: size;
touch-action: pan-x;
}
/* Room for a label that never hides: half the handle, 4px and the 44px pill above the track
lying down, beside it standing up. */
[data-md-slider][data-md-value-label='always'] [data-md-slider-control] {
margin-top: var(--md-sys-measurement-space600);
}
[data-md-slider][data-md-value-label='always'][data-md-orientation='vertical'] [data-md-slider-control] {
margin-top: 0;
margin-inline-start: var(--md-sys-measurement-space600);
}
[data-md-slider-drawing] {
position: absolute;
inset-block: 0;
inset-inline: var(--md-sys-measurement-space25);
pointer-events: none;
&:dir(rtl) {
scale: -1 1;
}
}
/* The same drawing, laid out as long as the slider is tall and turned a quarter anticlockwise so
the value grows upwards. Everything inside it is placed as if the slider were lying down. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-drawing] {
inset: auto;
top: 50%;
left: 50%;
width: calc(100cqh - var(--md-sys-measurement-space50));
height: 100cqw;
translate: -50% -50%;
rotate: -90deg;
scale: none;
}
[data-md-slider-track] {
position: absolute;
inset-inline: 0;
top: 50%;
height: var(--slider-track);
translate: 0 -50%;
}
[data-md-slider-segment] {
position: absolute;
inset-block: 0;
background-color: var(--slider-inactive);
}
[data-md-slider-segment='active'] {
background-color: var(--slider-active);
}
:is([data-md-slider-tick], [data-md-slider-stop]) {
position: absolute;
top: 50%;
width: 4px;
height: 4px;
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--slider-on-inactive);
}
[data-md-slider-tick][data-md-active] {
background-color: var(--slider-on-active);
}
/* A disabled icon is on-surface at 38% wherever it sits. */
[data-md-slider]:has([data-md-slider-input]:disabled) [data-md-slider-icon] {
color: var(--slider-disabled-active);
}
[data-md-slider-icon] {
position: absolute;
top: 50%;
display: flex;
translate: 0 -50%;
color: var(--slider-on-inactive);
&[data-md-active] {
color: var(--slider-on-active);
}
&:dir(rtl) {
scale: -1 1;
}
}
/* Turned back the quarter the drawing turns, so the glyph stands up. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-icon] {
rotate: 90deg;
scale: none;
}
[data-md-slider-handle] {
position: absolute;
inset-block: 0;
width: 0;
}
[data-md-slider-thumb] {
position: absolute;
top: 50%;
left: 0;
width: 4px;
height: var(--slider-handle);
translate: -50% -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--slider-active);
outline-offset: 2px;
transition-property: width, height;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
:is([data-md-pressed], [data-md-focused]) > [data-md-slider-thumb] {
width: 2px;
}
[data-md-focused] > [data-md-slider-thumb] {
height: calc(var(--slider-handle) - 6px);
outline: 3px solid var(--md-sys-color-secondary);
}
[data-md-slider-value-anchor] {
position: absolute;
bottom: calc(50% + var(--slider-handle) / 2 + var(--md-sys-measurement-space50));
left: 0;
z-index: 10;
display: flex;
justify-content: center;
translate: -50% 0;
&:dir(rtl) {
scale: -1 1;
}
}
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-value-anchor] {
scale: none;
}
[data-md-slider-value] {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--md-sys-measurement-space600);
height: 44px;
padding-inline: var(--md-sys-measurement-space125);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
white-space: nowrap;
transform-origin: bottom;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
}
/* Turned back the quarter the drawing turns: the pill lands beside the handle and reads across. */
[data-md-slider][data-md-orientation='vertical'] [data-md-slider-value] {
transform-origin: center;
rotate: 90deg;
}
[data-md-slider][data-md-value-label='drag'] [data-md-slider-value] {
opacity: 0;
scale: 0.75;
}
[data-md-slider][data-md-value-label='drag'] :is([data-md-pressed], [data-md-focused]) [data-md-slider-value] {
opacity: 1;
scale: 1;
}
[data-md-slider-input] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
appearance: none;
background: transparent;
opacity: 0;
pointer-events: none;
accent-color: var(--slider-active);
}
[data-md-slider-hint],
[data-md-slider-errors] > p {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-slider-errors] > p {
color: var(--md-sys-color-error);
}
/* Without script there is no drawing: the inputs show as native ranges, one under the other. */
@media (scripting: none) {
:is([data-md-slider], [data-md-slider][data-md-orientation]) [data-md-slider-control] {
height: auto;
cursor: auto;
}
[data-md-slider-control] > :not(:last-child) {
margin-block-end: var(--md-sys-measurement-space100);
}
[data-md-slider-drawing] {
display: none;
}
[data-md-slider-input] {
position: static;
display: block;
width: 100%;
height: 24px;
appearance: auto;
opacity: 1;
pointer-events: auto;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
/*
* <x-sort-header>: a column header that sorts the table (resources/views/components/sort-header.blade.php),
* inside <x-table> (components/table.css), which draws the cell itself.
*
* The button is the header's own text, in the header cell's type and colour, with a 16px arrow 4px
* after it; the corner is extra-small, for the focus ring the button wears (`md-focus-ring`), and
* `md-touch-target` gives the one-line button M3's 48px target (foundation/interaction.css). The
* sorted column (`data-md-active`) reads in on-surface with its arrow showing the direction; another
* column turns on-surface only while hovered, and shows a faint upward arrow while hovered or
* focused, at 60% of the ink a hint of what pressing would do, not a state M3 names, fading on the
* fast effects spring.
*
* [data-md-sort-header] the <th>; data-md-active, aria-sort
* [data-md-sort-header-button]
* [data-md-sort-header-arrow]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-sort-header-button] {
display: inline-flex;
align-items: center;
gap: var(--md-sys-measurement-space50);
border-radius: var(--md-sys-shape-corner-xs);
cursor: pointer;
}
[data-md-sort-header][data-md-active] [data-md-sort-header-button] {
color: var(--md-sys-color-on-surface);
}
@media (hover: hover) {
[data-md-sort-header-button]:hover {
color: var(--md-sys-color-on-surface);
}
}
[data-md-sort-header-arrow] {
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-sort-header]:not([data-md-active]) [data-md-sort-header-arrow] {
opacity: 0;
}
[data-md-sort-header]:not([data-md-active]) [data-md-sort-header-button]:focus-visible [data-md-sort-header-arrow] {
opacity: 0.6;
}
@media (hover: hover) {
[data-md-sort-header]:not([data-md-active]) [data-md-sort-header-button]:hover [data-md-sort-header-arrow] {
opacity: 0.6;
}
}
}
+35 -30
View File
@@ -2,26 +2,32 @@
* Data tables: `<x-table>` (resources/views/components/table.blade.php), whose callers write plain * Data tables: `<x-table>` (resources/views/components/table.blade.php), whose callers write plain
* `<thead>`, `<tr>`, `<th>` and `<td>` inside it. * `<thead>`, `<tr>`, `<th>` and `<td>` inside it.
* *
* So the styling is descendant selectors on the one attribute the component sets, all inside * So the styling is descendant selectors on the one attribute the component sets, `data-md-table`,
* `:where()` and `@layer components`: a caller's `text-end` or `whitespace-nowrap` on a cell always * with every cell and row selector inside `:where()`: a caller's alignment or wrapping on a cell
* wins. Header cells in title-small on-surface-variant over an outline-variant rule, body cells in * always wins, whatever layer it comes from. Header cells in title-small on-surface-variant over an
* body-medium between outline-variant rules. A row that opens something is `data-list-row` and * outline-variant rule, body cells in body-medium between outline-variant rules the role itself,
* never a fraction of it, since M3 reserves opacity for state layers and disabled. A selected row
* (`aria-selected="true"`) is secondary-container. A row that opens something is `data-list-row` and
* answers a pointer as a list row does (components/list.css). * answers a pointer as a list row does (components/list.css).
* *
* A row is 52px: 16px above and below a body-medium line. Density is never applied by itself * A row is 52px: 16px above and below a body-medium line, 12px beside each cell. Density is never
* "don't apply density by default; offer an explicit density opt-in instead, keeping opt-out targets * applied by itself "don't apply density by default; offer an explicit density opt-in instead,
* at >= 48x48 CSS px" (docs/reference/m3/foundations-supplement.md § Accessibility) so `dense` * keeping opt-out targets at >= 48x48 CSS px" (docs/reference/m3/foundations-supplement.md
* (`data-dense`) is the caller's decision, and it is the caller's to justify: a dense row is 36px, * § Accessibility) so `dense` (`data-md-dense`) is the caller's decision, and it is the caller's
* and a dense row inside an `xs` table 24px. * to justify: a dense row is 36px (8px above and below). `data-md-size="xs"`, for a table inside a
* panel inside a panel, is body-small with label-medium headers, 8px around each cell (32px rows),
* and 4px above and below when dense (24px).
*/ */
@layer components { @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
/* `relative` makes the table the containing block for anything absolute inside it an `sr-only`
header label, a tooltip. Without it they escape the scroll box and widen a phone's layout @layer material.components {
viewport. */ /* `position: relative` makes the table the containing block for anything absolute inside it
[data-table] { a visually hidden header label, a tooltip. Without it they escape the scroll box and widen a
--cell-x: 0.75rem; phone's layout viewport. */
--cell-y: 1rem; [data-md-table] {
--cell-x: 12px;
--cell-y: var(--md-sys-measurement-space200);
position: relative; position: relative;
width: 100%; width: 100%;
@@ -31,29 +37,29 @@
letter-spacing: var(--md-sys-typescale-body-md-tracking); letter-spacing: var(--md-sys-typescale-body-md-tracking);
} }
[data-table][data-size="xs"] { [data-md-table][data-md-size='xs'] {
--cell-x: 0.5rem; --cell-x: var(--md-sys-measurement-space100);
--cell-y: 0.5rem; --cell-y: var(--md-sys-measurement-space100);
font: var(--md-sys-typescale-body-sm); font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking); letter-spacing: var(--md-sys-typescale-body-sm-tracking);
} }
[data-table][data-dense] { [data-md-table][data-md-dense] {
--cell-y: 0.5rem; --cell-y: var(--md-sys-measurement-space100);
} }
[data-table][data-size="xs"][data-dense] { [data-md-table][data-md-size='xs'][data-md-dense] {
--cell-y: 0.25rem; --cell-y: var(--md-sys-measurement-space50);
} }
[data-table] :where(th, td) { [data-md-table] :where(th, td) {
padding: var(--cell-y) var(--cell-x); padding: var(--cell-y) var(--cell-x);
text-align: start; text-align: start;
vertical-align: middle; vertical-align: middle;
} }
[data-table] :where(thead th) { [data-md-table] :where(thead th) {
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm); font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking); letter-spacing: var(--md-sys-typescale-title-sm-tracking);
@@ -61,21 +67,20 @@
border-bottom: 1px solid var(--md-sys-color-outline-variant); border-bottom: 1px solid var(--md-sys-color-outline-variant);
} }
[data-table][data-size="xs"] :where(thead th) { [data-md-table][data-md-size='xs'] :where(thead th) {
font: var(--md-sys-typescale-label-md); font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking); letter-spacing: var(--md-sys-typescale-label-md-tracking);
} }
/* The role itself, not a fraction of it: M3 reserves opacity for state layers and disabled. */ [data-md-table] :where(tbody tr) {
[data-table] :where(tbody tr) {
border-bottom: 1px solid var(--md-sys-color-outline-variant); border-bottom: 1px solid var(--md-sys-color-outline-variant);
} }
[data-table] :where(tbody tr:last-child) { [data-md-table] :where(tbody tr:last-child) {
border-bottom: 0; border-bottom: 0;
} }
[data-table] :where(tbody tr[aria-selected="true"]) { [data-md-table] :where(tbody tr[aria-selected='true']) {
background-color: var(--md-sys-color-secondary-container); background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container); color: var(--md-sys-color-on-secondary-container);
} }
+63
View File
@@ -0,0 +1,63 @@
/*
* <x-textarea>: the text field grown to several lines (resources/views/components/textarea.blade.php).
*
* The chrome is the field's (components/field.css); this file is what a <textarea> changes in it.
* The browser's resize grip belongs in the field's corner, so the textarea reaches to just inside
* the outline's end and bottom and its padding puts the text back. Its label rests on the first
* line rather than in the middle of the box. With `data-md-autogrow` it grows with its text from
* `--field-rows` lines to `--field-max-rows` (both set inline by the view) through
* `field-sizing: content`, and resources/js/field.js sets the height where the browser cannot.
* A line is body-large's 1.5rem, so the row arithmetic stays in rem with the text.
*
* The field's root carries `data-md-textarea`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@layer material.components {
/* The browser draws the resize grip in the textarea's own corner, so the
textarea reaches to just inside the outline's end and bottom the grip sits
in the field's corner, not on the bottom outline a padding in from it and
its padding puts the text back where it was. */
textarea[data-md-field-control] {
--field-grip: var(--md-sys-measurement-space50);
/* The first half-line of the top offset is a margin, not padding: text scrolled up in a full
textarea disappears under the edge instead of running through the label. */
--textarea-clear: var(--md-sys-measurement-space100);
--textarea-pad-top: calc((var(--field-height) - 1.5rem) / 2 - var(--textarea-clear));
--textarea-pad-bottom: calc((var(--field-height) - 1.5rem) / 2 - var(--field-grip));
margin-block: var(--textarea-clear) var(--field-grip);
margin-inline-end: calc(var(--field-grip) - var(--field-pad));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
padding-inline-end: calc(var(--field-pad) - var(--field-grip));
resize: vertical;
}
/* `<x-textarea>` grows with what is typed, from `rows` lines up to `max-rows`. Where the browser
cannot size a field to its content, resources/js/field.js sets the height instead. */
textarea[data-md-field-control][data-md-autogrow] {
field-sizing: content;
min-block-size: calc(var(--field-rows, 3) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
max-block-size: calc(var(--field-max-rows, 1000) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
resize: none;
}
/* A textarea's label rests on its first line, not in the middle of the box. */
[data-md-field]:not([data-md-floated]):has([data-md-field-label]):has(textarea[data-md-field-control]:placeholder-shown):not(:focus-within) [data-md-field-label] {
top: calc(var(--field-height) / 2);
}
/* Filled, the label floats inside the box: the text starts under it, 24px down, and the
bottom keeps the field's 8px. */
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) textarea[data-md-field-control] {
--textarea-clear: var(--md-sys-measurement-space300);
--textarea-pad-top: 0px;
--textarea-pad-bottom: calc(var(--md-sys-measurement-space100) - var(--field-grip));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
}
}
+1 -1
View File
@@ -53,7 +53,7 @@
} }
@layer components { @layer components {
[data-timepicker-field] .field-control { [data-timepicker-field] [data-md-field-control] {
cursor: pointer; cursor: pointer;
caret-color: transparent; caret-color: transparent;
} }
+151
View File
@@ -0,0 +1,151 @@
/*
* <x-toggle>: M3's switch (resources/views/components/toggle.blade.php), on the row the selection
* controls share (components/selection.css). The hooks say `switch` because `toggle` is daisyUI's
* class, which the design guard rejects.
*
* SwitchTokens (androidx Compose Material 3, Apache-2.0): a 52×32 track with a 2px outline, in
* surface-container-highest when off and primary when on. The handle's centre is 16px from the
* track's start off and 36px on; it is 16px across off, 24px with an icon or on, and 28px while
* pressed, moving and growing on the fast spatial spring. Off it is outline, on-surface-variant
* while hovered, focused or pressed; on it is on-primary, primary-container in those states. The
* state layer is a 40px circle around the handle. Its icons are 16px, on-primary-container when on.
* Disabled, the track is on-surface at 12% and the handle on-surface at 38% (surface when on).
*
* [data-md-toggle] the root; `class` and `style` land here
* [data-md-selection-row] data-md-right puts the switch at the end of the row
* [data-md-switch] the track; `data-md-icons` = "both" or "selected"
* input, [data-md-switch-handle] (with [data-md-switch-on] and [data-md-switch-off] icons)
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './selection.css';
@import './icon.css';
@layer material.components {
[data-md-toggle] {
min-width: 0;
}
/* The positions below are the handle's centres less half its size, measured inside the track's
2px outline. */
[data-md-switch] {
position: relative;
display: grid;
flex: none;
width: 52px;
height: 32px;
}
[data-md-switch] input {
width: 100%;
height: 100%;
border: 2px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-highest);
}
[data-md-switch] input:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-md-switch-handle] {
--handle: 16px;
position: absolute;
top: 50%;
inset-inline-start: calc(16px - var(--handle) / 2);
display: grid;
place-items: center;
width: var(--handle);
height: var(--handle);
translate: 0 -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-outline);
color: var(--md-sys-color-surface-container-highest);
pointer-events: none;
transition-property: inset-inline-start, width, height, background-color;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-md-switch-handle]::before {
inset: 50% auto auto 50%;
width: 40px;
height: 40px;
translate: -50% -50%;
}
[data-md-switch-handle] > svg {
grid-area: 1 / 1;
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-switch][data-md-icons='both'] [data-md-switch-handle] {
--handle: 24px;
}
[data-md-switch][data-md-icons='both'] input:not(:checked) ~ [data-md-switch-handle] [data-md-switch-off],
[data-md-switch][data-md-icons] input:checked ~ [data-md-switch-handle] [data-md-switch-on] {
opacity: 1;
}
[data-md-switch] input:checked {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-md-switch] input:checked ~ [data-md-switch-handle] {
--handle: 24px;
inset-inline-start: calc(36px - var(--handle) / 2);
background-color: var(--md-sys-color-on-primary);
color: var(--md-sys-color-on-primary-container);
}
[data-md-selection-row]:active [data-md-switch] input:not(:disabled) ~ [data-md-switch-handle] {
--handle: 28px;
}
@media (hover: hover) {
[data-md-selection-row]:hover [data-md-switch] input:not(:disabled) ~ [data-md-switch-handle] {
background-color: var(--md-sys-color-on-surface-variant);
}
[data-md-selection-row]:hover [data-md-switch] input:checked:not(:disabled) ~ [data-md-switch-handle] {
background-color: var(--md-sys-color-primary-container);
}
}
[data-md-switch] input:focus-visible ~ [data-md-switch-handle],
[data-md-selection-row]:active [data-md-switch] input:not(:disabled) ~ [data-md-switch-handle] {
background-color: var(--md-sys-color-on-surface-variant);
}
[data-md-switch] input:checked:focus-visible ~ [data-md-switch-handle],
[data-md-selection-row]:active [data-md-switch] input:checked:not(:disabled) ~ [data-md-switch-handle] {
background-color: var(--md-sys-color-primary-container);
}
[data-md-switch] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
background-color: color-mix(in srgb, var(--md-sys-color-surface-container-highest) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
}
[data-md-switch] input:disabled ~ [data-md-switch-handle] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
color: color-mix(in srgb, var(--md-sys-color-surface-container-highest) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-switch] input:disabled:checked {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
}
[data-md-switch] input:disabled:checked ~ [data-md-switch-handle] {
background-color: var(--md-sys-color-surface);
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
}
-4
View File
@@ -22,12 +22,8 @@
@import './components/menu.css'; @import './components/menu.css';
/* Inputs, selection and data — leaving in step 36 */ /* Inputs, selection and data — leaving in step 36 */
@import './components/field.css';
@import './components/selection.css';
@import './components/search.css';
@import './components/datepicker.css'; @import './components/datepicker.css';
@import './components/timepicker.css'; @import './components/timepicker.css';
@import './components/table.css';
/* Containment — leaving in step 36 */ /* Containment — leaving in step 36 */
@import './components/list.css'; @import './components/list.css';
+16 -16
View File
@@ -6,8 +6,8 @@
* chip taking itself off the page. Before that, focus moves to the chip before it (Backspace) or * chip taking itself off the page. Before that, focus moves to the chip before it (Backspace) or
* after it (Delete), because a focused element that disappears leaves focus on the body. * after it (Delete), because a focused element that disappears leaves focus on the body.
* *
* A scrolling set marks the edges it can still scroll towards (`data-scroll-start`, * A scrolling set marks the edges it can still scroll towards (`data-md-scroll-start`,
* `data-scroll-end`), and the set fades those edges and where the pointer is fine, so there is no * `data-md-scroll-end`), and the set fades those edges and where the pointer is fine, so there is no
* swipe puts a button over each of them, which is the visible affordance M3's chips accessibility * swipe puts a button over each of them, which is the visible affordance M3's chips accessibility
* page asks a scrolling row for. Its row carries `wire:ignore.self`, so a Livewire morph keeps the * page asks a scrolling row for. Its row carries `wire:ignore.self`, so a Livewire morph keeps the
* marks. Wrapping, the row is the element with `x-data`; scrolling, it is that element's `row` ref, * marks. Wrapping, the row is the element with `x-data`; scrolling, it is that element's `row` ref,
@@ -25,23 +25,23 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialChip', () => ({ window.Alpine.data('materialChip', () => ({
init() { init() {
const button = this.removeButton() 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 // 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. // 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(() => { this.$nextTick(() => {
const text = label.textContent.trim() const text = label.textContent.trim()
if (text !== '' && !button.hasAttribute('aria-label')) { 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() { removeButton() {
return this.$root.querySelector('[data-chip-remove]') return this.$root.querySelector('[data-md-chip-remove]')
}, },
removeOnKey(event) { removeOnKey(event) {
@@ -68,15 +68,15 @@ document.addEventListener('alpine:init', () => {
handOffFocus(backwards) { handOffFocus(backwards) {
const chip = this.$root const chip = this.$root
const set = chip.closest('[data-chip-set]') ?? chip.parentElement const set = chip.closest('[data-md-chip-set-row]') ?? chip.parentElement
const chips = [...(set?.querySelectorAll('[data-chip]') ?? [])] const chips = [...(set?.querySelectorAll('[data-md-chip]') ?? [])]
const index = chips.indexOf(chip) const index = chips.indexOf(chip)
const before = chips.slice(0, index).reverse() const before = chips.slice(0, index).reverse()
const after = chips.slice(index + 1) 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]) { 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) { if (target) {
target.focus() target.focus()
@@ -92,17 +92,17 @@ document.addEventListener('alpine:init', () => {
// seconds pass), whenever the set changes and focus has fallen away, focus the same // 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. // control again — the element itself, or the chip with its `wire:key` after a morph.
holdFocus(target, set) { holdFocus(target, set) {
const key = target.closest('[data-chip]')?.getAttribute('wire:key') const key = target.closest('[data-md-chip]')?.getAttribute('wire:key')
const onRemove = target.hasAttribute('data-chip-remove') const onRemove = target.hasAttribute('data-md-chip-remove')
const current = () => { const current = () => {
if (target.isConnected || !key) { if (target.isConnected || !key) {
return target.isConnected ? target : null 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(() => { const observer = new MutationObserver(() => {
@@ -140,8 +140,8 @@ document.addEventListener('alpine:init', () => {
const travelled = Math.abs(row.scrollLeft) const travelled = Math.abs(row.scrollLeft)
const room = row.scrollWidth - row.clientWidth const room = row.scrollWidth - row.clientWidth
row.toggleAttribute('data-scroll-start', travelled > 1) row.toggleAttribute('data-md-scroll-start', travelled > 1)
row.toggleAttribute('data-scroll-end', room - travelled > 1) row.toggleAttribute('data-md-scroll-end', room - travelled > 1)
} }
// A filter chip's check changes its width without resizing the row. // A filter chip's check changes its width without resizing the row.
+6 -6
View File
@@ -8,13 +8,13 @@
* in. * in.
* *
* `<x-checkbox indeterminate>`: HTML has no attribute for a checkbox's mixed state, only the * `<x-checkbox indeterminate>`: HTML has no attribute for a checkbox's mixed state, only the
* `indeterminate` property, so the component marks the input `data-indeterminate` and this keeps * `indeterminate` property, so the component marks the input `data-md-indeterminate` and this keeps
* the property in step with the mark when the page loads, and when a render adds or removes the * the property in step with the mark when the page loads, and when a render adds or removes the
* mark. A person pressing the box clears the property as the browser always does; the mark stays * mark. A person pressing the box clears the property as the browser always does; the mark stays
* until the server decides again. * until the server decides again.
*/ */
const GROWING = 'textarea[data-autogrow]' const GROWING = 'textarea[data-md-autogrow]'
const MIXED = 'input[type="checkbox"][data-indeterminate]' const MIXED = 'input[type="checkbox"][data-md-indeterminate]'
const growsByItself = window.CSS?.supports?.('field-sizing', 'content') ?? false const growsByItself = window.CSS?.supports?.('field-sizing', 'content') ?? false
@@ -49,8 +49,8 @@ if (! growsByItself) {
new MutationObserver((records) => { new MutationObserver((records) => {
for (const record of records) { for (const record of records) {
if (record.type === 'attributes') { if (record.type === 'attributes') {
if (record.attributeName === 'data-indeterminate' && record.target instanceof HTMLInputElement) { if (record.attributeName === 'data-md-indeterminate' && record.target instanceof HTMLInputElement) {
record.target.indeterminate = record.target.hasAttribute('data-indeterminate') record.target.indeterminate = record.target.hasAttribute('data-md-indeterminate')
} else if (! growsByItself && record.target.matches(GROWING)) { } else if (! growsByItself && record.target.matches(GROWING)) {
grow(record.target) grow(record.target)
} }
@@ -76,7 +76,7 @@ new MutationObserver((records) => {
subtree: true, subtree: true,
childList: true, childList: true,
attributes: true, attributes: true,
attributeFilter: ['data-indeterminate', 'data-autogrow'], attributeFilter: ['data-md-indeterminate', 'data-md-autogrow'],
}) })
markAll(document) markAll(document)
+2 -2
View File
@@ -78,11 +78,11 @@ document.addEventListener('alpine:init', () => {
} }
// Suggestions and results never show together; count whichever one is on screen. // Suggestions and results never show together; count whichever one is on screen.
const list = [...this.$refs.view.querySelectorAll('[data-search-results], [data-search-suggestions]')] const list = [...this.$refs.view.querySelectorAll('[data-md-search-results], [data-md-search-suggestions]')]
.find((element) => element.getClientRects().length > 0) .find((element) => element.getClientRects().length > 0)
const items = list ? list.querySelectorAll('[role="listitem"], li') : [] const items = list ? list.querySelectorAll('[role="listitem"], li') : []
const total = items.length || (list ? this.results().length : 0) const total = items.length || (list ? this.results().length : 0)
const suggesting = Boolean(list?.hasAttribute('data-search-suggestions')) const suggesting = Boolean(list?.hasAttribute('data-md-search-suggestions'))
this.announcement = total === 0 this.announcement = total === 0
? (announce.none ?? '') ? (announce.none ?? '')
+20 -18
View File
@@ -200,8 +200,10 @@ function sliderGeometry({ width, size, fractions, centered = false, range = fals
* effect, and anything reactive read or written on the way to the drawing would re-run it. * effect, and anything reactive read or written on the way to the drawing would re-run it.
*/ */
function slider(root) { function slider(root) {
// The component's root carries the props; `root` is the control inside it the pointer presses.
const host = root.closest('[data-md-slider]') ?? root
const inputs = [...root.querySelectorAll(':scope > input[type="range"]')] const inputs = [...root.querySelectorAll(':scope > input[type="range"]')]
const drawing = root.querySelector('[data-slider-drawing]') const drawing = root.querySelector('[data-md-slider-drawing]')
const range = inputs.length > 1 const range = inputs.length > 1
const cleanups = [] const cleanups = []
let queued = false let queued = false
@@ -228,24 +230,24 @@ function slider(root) {
} }
/** M3 Expressive's second orientation: the drawing is turned a quarter, the geometry is not. */ /** M3 Expressive's second orientation: the drawing is turned a quarter, the geometry is not. */
const vertical = () => root.dataset.orientation === 'vertical' const vertical = () => host.dataset.mdOrientation === 'vertical'
/** The track's length: the slider's height while it stands up, its width while it lies down. */ /** The track's length: the slider's height while it stands up, its width while it lies down. */
const trackLength = () => Math.max((vertical() ? root.clientHeight : root.clientWidth) - HANDLE_WIDTH, 0) const trackLength = () => Math.max((vertical() ? root.clientHeight : root.clientWidth) - HANDLE_WIDTH, 0)
const values = () => inputs.map((input) => Number.parseFloat(input.value)) const values = () => inputs.map((input) => Number.parseFloat(input.value))
const thumbOf = (input) => drawing?.querySelector(`[data-handle="${inputs.indexOf(input) === 1 ? 'end' : 'start'}"]`) const thumbOf = (input) => drawing?.querySelector(`[data-md-slider-handle="${inputs.indexOf(input) === 1 ? 'end' : 'start'}"]`)
const tickFractions = () => [...(drawing?.querySelectorAll('[data-tick]') ?? [])].map((tick) => Number.parseFloat(tick.dataset.tick)) const tickFractions = () => [...(drawing?.querySelectorAll('[data-md-slider-tick]') ?? [])].map((tick) => Number.parseFloat(tick.dataset.mdSliderTick))
const geometry = (width) => { const geometry = (width) => {
const { min, max } = bounds() const { min, max } = bounds()
return sliderGeometry({ return sliderGeometry({
width, width,
size: root.dataset.size, size: host.dataset.mdSize,
fractions: values().map((value) => (Number.isFinite(value) ? clamp((value - min) / (max - min), 0, 1) : 0)), fractions: values().map((value) => (Number.isFinite(value) ? clamp((value - min) / (max - min), 0, 1) : 0)),
centered: root.hasAttribute('data-centered'), centered: host.hasAttribute('data-md-centered'),
range, range,
tickFractions: tickFractions(), tickFractions: tickFractions(),
}) })
@@ -257,13 +259,13 @@ function slider(root) {
} }
const width = trackLength() const width = trackLength()
const size = SIZES[root.dataset.size] ?? SIZES.xs const size = SIZES[host.dataset.mdSize] ?? SIZES.xs
const { segments, stops, ticks, handles, activeTrack, endTrackStart } = geometry(width) const { segments, stops, ticks, handles, activeTrack, endTrackStart } = geometry(width)
const part = (selector) => drawing.querySelector(selector) const part = (selector) => drawing.querySelector(selector)
const px = (number) => `${Math.round(number * 100) / 100}px` const px = (number) => `${Math.round(number * 100) / 100}px`
for (const name of ['start', 'active', 'end']) { for (const name of ['start', 'active', 'end']) {
const element = part(`[data-segment="${name}"]`) const element = part(`[data-md-slider-segment="${name}"]`)
const segment = segments[name] const segment = segments[name]
if (element) { if (element) {
@@ -277,7 +279,7 @@ function slider(root) {
} }
for (const name of ['start', 'end']) { for (const name of ['start', 'end']) {
const element = part(`[data-stop="${name}"]`) const element = part(`[data-md-slider-stop="${name}"]`)
if (element) { if (element) {
element.hidden = stops[name] === undefined element.hidden = stops[name] === undefined
@@ -288,20 +290,20 @@ function slider(root) {
const fractions = tickFractions() const fractions = tickFractions()
const spacing = fractions.length > 1 ? (width - size.corner * 2) * Math.abs(fractions[1] - fractions[0]) : Infinity const spacing = fractions.length > 1 ? (width - size.corner * 2) * Math.abs(fractions[1] - fractions[0]) : Infinity
drawing.querySelectorAll('[data-tick]').forEach((element, i) => { drawing.querySelectorAll('[data-md-slider-tick]').forEach((element, i) => {
const tick = ticks[i] const tick = ticks[i]
element.hidden = !tick || spacing < TICK_MIN_SPACING element.hidden = !tick || spacing < TICK_MIN_SPACING
if (tick) { if (tick) {
element.style.left = px(tick.x) element.style.left = px(tick.x)
element.toggleAttribute('data-active', tick.active) element.toggleAttribute('data-md-active', tick.active)
} }
}) })
handles.forEach((x, i) => { handles.forEach((x, i) => {
const thumb = part(`[data-handle="${i === 1 ? 'end' : 'start'}"]`) const thumb = part(`[data-md-slider-handle="${i === 1 ? 'end' : 'start'}"]`)
const label = thumb?.querySelector('[data-value-label]') const label = thumb?.querySelector('[data-md-slider-value]')
if (thumb) { if (thumb) {
thumb.style.left = px(x) thumb.style.left = px(x)
@@ -313,7 +315,7 @@ function slider(root) {
}) })
// The inset icon: at the start of the active track while it fits, else of the inactive one. // The inset icon: at the start of the active track while it fits, else of the inactive one.
const icon = part('[data-track-icon]') const icon = part('[data-md-slider-icon]')
if (icon) { if (icon) {
const room = size.icon + ICON_PADDING * 2 const room = size.icon + ICON_PADDING * 2
@@ -321,7 +323,7 @@ function slider(root) {
const inInactive = !inActive && endTrackStart !== null && width - endTrackStart >= room const inInactive = !inActive && endTrackStart !== null && width - endTrackStart >= room
icon.hidden = !inActive && !inInactive icon.hidden = !inActive && !inInactive
icon.toggleAttribute('data-active', inActive) icon.toggleAttribute('data-md-active', inActive)
icon.style.left = px((inActive ? activeTrack[0] : (endTrackStart ?? 0)) + ICON_PADDING) icon.style.left = px((inActive ? activeTrack[0] : (endTrackStart ?? 0)) + ICON_PADDING)
} }
} }
@@ -371,10 +373,10 @@ function slider(root) {
} }
/** Whether a handle shows keyboard focus: narrowed, ringed and labelled. */ /** Whether a handle shows keyboard focus: narrowed, ringed and labelled. */
const showFocus = (input, shown) => thumbOf(input)?.toggleAttribute('data-focused', shown) const showFocus = (input, shown) => thumbOf(input)?.toggleAttribute('data-md-focused', shown)
/** Compose narrows a handle and shows its label while it is pressed or dragged. */ /** Compose narrows a handle and shows its label while it is pressed or dragged. */
const pressed = (index) => drawing?.querySelectorAll('[data-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-pressed', i === index)) const pressed = (index) => drawing?.querySelectorAll('[data-md-slider-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-md-pressed', i === index))
inputs.forEach((input) => { inputs.forEach((input) => {
// A value written by script fires no event: catch it on the input's own setters. // A value written by script fires no event: catch it on the input's own setters.
@@ -493,7 +495,7 @@ function slider(root) {
const mutations = new MutationObserver(schedule) const mutations = new MutationObserver(schedule)
inputs.forEach((input) => mutations.observe(input, { attributes: true, attributeFilter: ['min', 'max', 'step', 'value', 'disabled'] })) inputs.forEach((input) => mutations.observe(input, { attributes: true, attributeFilter: ['min', 'max', 'step', 'value', 'disabled'] }))
mutations.observe(root, { attributes: true, attributeFilter: ['data-size', 'data-centered', 'data-orientation', 'dir'] }) mutations.observe(host, { attributes: true, attributeFilter: ['data-md-size', 'data-md-centered', 'data-md-orientation', 'dir'] })
const resizes = new ResizeObserver(schedule) const resizes = new ResizeObserver(schedule)
resizes.observe(root) resizes.observe(root)
+18 -25
View File
@@ -4,19 +4,20 @@
The label and its hint sit beside it and are its accessible name the whole row is the The label and its hint sit beside it and are its accessible name the whole row is the
`<label>`, so either can be pressed. `right` puts the box at the end of the row, for a setting `<label>`, so either can be pressed. `right` puts the box at the end of the row, for a setting
read left to right. `indeterminate` shows the mixed state of a "select all" whose items are read left to right. `indeterminate` shows the mixed state of a "select all" whose items are
partly ticked: HTML has no attribute for it, so the input is marked `data-indeterminate` and partly ticked: HTML has no attribute for it, so the input is marked `data-md-indeterminate` and
resources/js/field.js keeps the property in step, on load and after every morph. Errors are resources/js/field.js keeps the property in step, on load and after every morph. Errors are
read from the bag under the `wire:model` name. Every other attribute reaches the `<input>` read from the bag under the `wire:model` name. Every other attribute reaches the `<input>`.
(resources/css/components/selection.css). The drawing is resources/css/components/checkbox.css, on the row the three selection controls
share (resources/css/components/selection.css).
Without a label a "select all" in a table header, a row's tick the row is only the 18px box, Without a label a "select all" in a table header, a row's tick the row is only the 18px box,
so the box carries `touch-target` and catches presses over M3's 48px minimum. so the box carries `md-touch-target` and catches presses over M3's 48px minimum.
Grouping is the caller's, not this component's: M3 asks that from `expanded` (840px) a set of Grouping is the caller's, not this component's: M3 asks that from `expanded` (840px) a set of
related checkboxes be gathered into a contained region rather than left as one long column related checkboxes be gathered into a contained region rather than left as one long column
(docs/reference/m3/components-navigation-selection-inputs.md § Checkbox, Behaviour). Lay the (docs/reference/m3/components-navigation-selection-inputs.md § Checkbox, Behaviour). Lay the
group out yourself `<div class="grid gap-4 expanded:grid-cols-2">` around the checkboxes, or a group out yourself a grid of one column that takes two from `expanded`, or an `<x-card>` or
`<x-card>` or side sheet holding them and give the group a heading that names what it asks. --}} side sheet holding them and give the group a heading that names what it asks. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -33,42 +34,34 @@
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp @endphp
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}> <div data-md-checkbox {{ $attributes->only(['class', 'style', 'wire:key']) }}>
<label for="{{ $id }}" data-selection @if ($messages !== []) data-invalid @endif @class([ <label for="{{ $id }}" data-md-selection-row @if ($right) data-md-right @endif @if ($messages !== []) data-md-invalid @endif>
'flex gap-4', <span data-md-checkbox-box @if (blank($label) && blank($hint)) class="md-touch-target" @endif>
'items-start' => filled($hint),
'items-center' => blank($hint),
'flex-row-reverse justify-between' => $right,
])>
<span data-checkbox @class([
'mt-[3px]' => filled($hint),
'touch-target' => blank($label) && blank($hint),
])>
<input <input
{{ $attributes->except(['class', 'id', 'wire:key']) }} {{ $attributes->except(['class', 'style', 'id', 'wire:key']) }}
id="{{ $id }}" id="{{ $id }}"
type="checkbox" type="checkbox"
@if ($indeterminate) data-indeterminate @endif @if ($indeterminate) data-md-indeterminate @endif
@if ($messages !== []) aria-invalid="true" aria-describedby="{{ $id }}-support" @endif @if ($messages !== []) aria-invalid="true" aria-describedby="{{ $id }}-support" @endif
/> />
<x-livewire-material::icon name="check" class="size-4.5" optical="20" data-check /> <x-livewire-material::icon name="check" size="18" data-md-checkbox-check />
<x-livewire-material::icon name="remove" class="size-4.5" optical="20" data-mixed /> <x-livewire-material::icon name="remove" size="18" data-md-checkbox-mixed />
</span> </span>
@if (filled($label) || filled($hint)) @if (filled($label) || filled($hint))
<span class="min-w-0"> <span data-md-selection-text>
@if (filled($label)) @if (filled($label))
<span class="block type-body-lg text-on-surface" data-selection-label>{{ $label }}</span> <span data-md-selection-label>{{ $label }}</span>
@endif @endif
@if (filled($hint)) @if (filled($hint))
<span class="mt-0.5 block type-body-sm text-on-surface-variant">{{ $hint }}</span> <span data-md-selection-hint>{{ $hint }}</span>
@endif @endif
</span> </span>
@endif @endif
</label> </label>
@if ($messages !== []) @if ($messages !== [])
<div id="{{ $id }}-support" class="mt-1 type-body-sm text-error" role="alert"> <div id="{{ $id }}-support" data-md-selection-support role="alert">
@foreach ($messages as $message) @foreach ($messages as $message)
<p>{{ $message }}</p> <p>{{ $message }}</p>
@endforeach @endforeach
+17 -17
View File
@@ -19,7 +19,11 @@
The set is one tab stop and the arrow keys move between the chips inside it, with Home and End The set is one tab stop and the arrow keys move between the chips inside it, with Home and End
at the ends M3's chip keyboard table. Backspace and Delete still remove a focused input at the ends M3's chip keyboard table. Backspace and Delete still remove a focused input
chip. --}} chip.
The root renders `data-md-chip-set` (and `data-md-scroll`), the row `data-md-chip-set-row`, which
resources/js/chips.js marks `data-md-scroll-start` and `data-md-scroll-end`; the drawing is
resources/css/components/chip-set.css. `class` and every other attribute land on the root. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -37,24 +41,25 @@
@endphp @endphp
<div <div
data-md-chip-set
@if ($scroll) data-md-scroll @endif
role="group" role="group"
@if (filled($label)) aria-labelledby="material-chip-set-{{ $key }}-label" @endif @if (filled($label)) aria-labelledby="material-chip-set-{{ $key }}-label" @endif
@if ($describedBy) aria-describedby="{{ $describedBy }}" @endif @if ($describedBy) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->class('min-w-0') }} {{ $attributes }}
> >
@if (filled($label)) @if (filled($label))
<p id="material-chip-set-{{ $key }}-label" class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</p> <p id="material-chip-set-{{ $key }}-label" data-md-chip-set-label>{{ $label }}</p>
@endif @endif
@if ($scroll) @if ($scroll)
<div x-data="materialChipSet" class="relative"> <div x-data="materialChipSet" data-md-chip-set-scroller>
<div <div
data-chip-set data-md-chip-set-row
x-ref="row" x-ref="row"
x-on:keydown="key($event)" x-on:keydown="key($event)"
x-on:focusin="rove($event.target)" x-on:focusin="rove($event.target)"
wire:ignore.self wire:ignore.self
class="peer -mx-1.5 -my-2 flex scroll-px-6 gap-2 overflow-x-auto px-1.5 py-2 pointer-fine:scroll-px-10 [scrollbar-width:none] [--chip-fade-end:0px] [--chip-fade-start:0px] data-scroll-end:[--chip-fade-end:1.5rem] data-scroll-start:[--chip-fade-start:1.5rem] [mask-image:linear-gradient(to_right,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)] rtl:[mask-image:linear-gradient(to_left,transparent,#000_var(--chip-fade-start),#000_calc(100%_-_var(--chip-fade-end)),transparent)]"
> >
{{ $slot }} {{ $slot }}
</div> </div>
@@ -65,31 +70,26 @@
type="button" type="button"
tabindex="-1" tabindex="-1"
aria-hidden="true" aria-hidden="true"
data-chip-scroll="{{ $edge }}" data-md-chip-scroll="{{ $edge }}"
x-on:click="nudge('{{ $edge }}')" x-on:click="nudge('{{ $edge }}')"
@class([
'absolute inset-y-0 my-auto hidden size-8 place-items-center rounded-corner-full bg-surface-container-high text-on-surface-variant shadow-elevation-1',
'start-0 peer-data-scroll-start:pointer-fine:grid' => $edge === 'start',
'end-0 peer-data-scroll-end:pointer-fine:grid' => $edge === 'end',
])
> >
<x-livewire-material::icon :name="$glyph" class="size-4.5 rtl:-scale-x-100" optical="20" /> <x-livewire-material::icon :name="$glyph" size="18" mirror-rtl />
</button> </button>
@endforeach @endforeach
</div> </div>
@else @else
<div data-chip-set x-data="materialChipSet" x-on:keydown="key($event)" x-on:focusin="rove($event.target)" class="flex flex-wrap gap-2"> <div data-md-chip-set-row x-data="materialChipSet" x-on:keydown="key($event)" x-on:focusin="rove($event.target)">
{{ $slot }} {{ $slot }}
</div> </div>
@endif @endif
@if ($messages !== []) @if ($messages !== [])
<div id="{{ $describedBy }}"> <div id="{{ $describedBy }}" data-md-chip-set-errors>
@foreach ($messages as $message) @foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p> <p>{{ $message }}</p>
@endforeach @endforeach
</div> </div>
@elseif (filled($hint)) @elseif (filled($hint))
<p id="{{ $describedBy }}" class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p> <p id="{{ $describedBy }}" data-md-chip-set-hint>{{ $hint }}</p>
@endif @endif
</div> </div>
+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 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 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 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([ @props([
'type' => 'assist', 'type' => 'assist',
@@ -87,44 +91,21 @@
$outer = ['class', 'style', 'wire:key']; $outer = ['class', 'style', 'wire:key'];
// Compose draws the 1px border inside the chip; a CSS border takes room, so the padding // An input chip's leading icon turns primary while a chip with an action of its own is hovered,
// beside it is 1px short of the token (7px for 8, 15px for 16). // focused or pressed.
$base = 'group/chip inline-flex h-8 max-w-full shrink-0 select-none items-center whitespace-nowrap rounded-corner-sm border type-label-lg transition-[background-color,border-color,color,box-shadow] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast';
// Below 48px the touch target reaches past the chip, as M3 requires.
$target = 'after:absolute after:inset-x-0 after:top-1/2 after:h-12 after:-translate-y-1/2';
// Every colour class written out whole, so Tailwind compiles it. Selected is a state the browser
// owns on a filter chip (a checked checkbox, or aria-pressed), so it is written as a variant.
$colours = match (true) {
$kind === 'filter' && $raised && $disabled => 'border-transparent bg-on-surface/12 text-on-surface/38',
$kind === 'filter' && $raised => 'border-transparent bg-surface-container-low text-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2 has-checked:bg-secondary-container has-checked:text-on-secondary-container aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container',
$kind === 'filter' && $disabled => 'border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12 aria-pressed:border-transparent aria-pressed:bg-on-surface/12',
$kind === 'filter' => 'border-outline-variant text-on-surface-variant focus-visible:border-on-surface-variant has-focus-visible:border-on-surface-variant has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container hover:has-checked:shadow-elevation-1 aria-pressed:border-transparent aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container hover:aria-pressed:shadow-elevation-1',
$kind === 'input' && $disabled => $selected ? 'border-transparent bg-on-surface/12 text-on-surface/38' : 'border-on-surface/12 text-on-surface/38',
$kind === 'input' => $selected ? 'border-transparent bg-secondary-container text-on-secondary-container' : 'border-outline-variant text-on-surface-variant has-[[data-chip-action]:focus-visible]:border-on-surface-variant',
$raised && $disabled => 'border-transparent bg-on-surface/12 text-on-surface/38',
$raised => $kind === 'suggestion'
? 'border-transparent bg-surface-container-low text-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2'
: 'border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2',
$disabled => 'border-on-surface/12 text-on-surface/38',
default => $kind === 'suggestion'
? 'border-outline-variant text-on-surface-variant focus-visible:border-on-surface-variant'
: 'border-outline-variant text-on-surface focus-visible:border-on-surface',
};
// Leading icons: primary on assist, suggestion and filter chips; on-surface-variant on an input
// chip, primary while it is hovered, focused or pressed, and primary on a selected one.
$clicks = $attributes->whereStartsWith(['wire:click', 'x-on:click', '@click'])->isNotEmpty(); $clicks = $attributes->whereStartsWith(['wire:click', 'x-on:click', '@click'])->isNotEmpty();
$leadingInk = match (true) { $actionable = $kind === 'input' && ! $disabled && ! $selected && ($isLink || $clicks);
$disabled => null,
$kind !== 'input', (bool) $selected => 'text-primary',
$isLink || $clicks => 'transition-colors duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-hover/chip:text-primary group-active/chip:text-primary group-has-focus-visible/chip:text-primary',
default => null,
};
$trailingInk = in_array($kind, ['assist', 'suggestion'], true) && ! $disabled ? 'text-primary' : null;
$check = 'size-4.5 opacity-0 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-100 group-has-checked/chip:duration-(--md-sys-motion-effects-slow-duration) group-has-checked/chip:ease-effects-slow group-aria-pressed/chip:opacity-100 group-aria-pressed/chip:duration-(--md-sys-motion-effects-slow-duration) group-aria-pressed/chip:ease-effects-slow'; $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') { if ($kind === 'input') {
$actionTag = match (true) { $actionTag = match (true) {
@@ -132,7 +113,6 @@
$clicks || $selected !== null => 'button', $clicks || $selected !== null => 'button',
default => 'span', default => 'span',
}; };
$interactive = $actionTag !== 'span';
$removeExpression = implode('; ', array_filter([ $removeExpression = implode('; ', array_filter([
'removeChip($event)', 'removeChip($event)',
filled($remove) ? $remove : null, filled($remove) ? $remove : null,
@@ -140,30 +120,15 @@
])); ]));
$container = $attributes->only($outer) $container = $attributes->only($outer)
->class([ ->merge($state([
$base, 'data-md-avatar' => filled($avatar) ? '' : null,
'relative isolate', 'data-md-removable' => $removable ? '' : null,
$colours, 'style' => $anchor ? "anchor-name: {$anchor}" : null,
'has-[[data-chip-action]:focus-visible]:outline-3 has-[[data-chip-action]:focus-visible]:outline-offset-2 has-[[data-chip-action]:focus-visible]:outline-secondary', ]));
])
->merge(array_filter(['style' => $anchor ? "anchor-name: {$anchor}" : null]));
$action = $attributes->except($outer) $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([ ->merge(array_filter([
'data-chip-action' => $interactive ? true : null, 'data-md-chip-action' => '',
'href' => $isLink ? $link : null, 'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null, 'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null, 'rel' => $isLink && $external ? 'noopener' : null,
@@ -187,25 +152,12 @@
'value' => $value, 'value' => $value,
'checked' => $checked ? true : null, 'checked' => $checked ? true : null,
'disabled' => $disabled ? 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; : null;
$container = ($checkbox ? $attributes->only($outer) : $attributes) $container = ($checkbox ? $attributes->only($outer) : $attributes)
->class([ ->merge($state(array_filter([
$base,
'state-layer relative',
'focus-ring' => ! $checkbox,
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary has-focus-visible:before:opacity-10' => $checkbox,
$kind === 'filter' || filled($icon) ? 'ps-1.75' : 'ps-3.75',
$iconRight ? 'pe-1.75' : 'pe-3.75',
$colours,
$target,
'cursor-pointer' => ! $disabled,
'cursor-not-allowed' => $disabled && ! $isLink,
'pointer-events-none' => $inert,
])
->merge(array_filter([
'data-chip' => $kind,
'href' => $isLink ? $link : null, 'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null, 'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null, 'rel' => $isLink && $external ? 'noopener' : null,
@@ -218,42 +170,35 @@
'value' => $tag === 'button' ? $value : null, 'value' => $tag === 'button' ? $value : null,
'aria-pressed' => $kind === 'filter' && ! $checkbox ? ($selected ? 'true' : 'false') : null, 'aria-pressed' => $kind === 'filter' && ! $checkbox ? ($selected ? 'true' : 'false') : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null, 'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($attribute): bool => $attribute !== null)); ], fn ($attribute): bool => $attribute !== null)));
} }
@endphp @endphp
@if ($kind === 'input') @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 }}> <{{ $actionTag }} {{ $action }}>
@if ($avatar) @if ($avatar)
@if ($initials) @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 @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 @endif
@elseif ($icon) @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 @endif
<span data-chip-label class="truncate">{{ $label ?? $slot }}</span> <span data-md-chip-label>{{ $label ?? $slot }}</span>
</{{ $actionTag }}> </{{ $actionTag }}>
@if ($removable) @if ($removable)
<button <button
type="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 @if ($wireRemove) wire:click="{{ $wireRemove }}" @endif
x-on:click="{{ $removeExpression }}" x-on:click="{{ $removeExpression }}"
@disabled($disabled) @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> </button>
@endif @endif
@@ -273,24 +218,20 @@
@if ($kind === 'filter') @if ($kind === 'filter')
{{-- The check grows in from nothing; beside an icon it takes the icon's place. --}} {{-- The check grows in from nothing; beside an icon it takes the icon's place. --}}
<span @class([ <span data-md-chip-check-slot>
'me-2 grid shrink-0 overflow-hidden *:col-start-1 *:row-start-1',
'size-4.5' => filled($icon),
'h-4.5 w-0 transition-[width] duration-(--md-sys-motion-effects-default-duration) ease-effects-default group-has-checked/chip:w-4.5 group-has-checked/chip:duration-(--md-sys-motion-spatial-fast-duration) group-has-checked/chip:ease-spatial-fast group-aria-pressed/chip:w-4.5 group-aria-pressed/chip:duration-(--md-sys-motion-spatial-fast-duration) group-aria-pressed/chip:ease-spatial-fast' => blank($icon),
])>
@if ($icon) @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 @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> </span>
@elseif ($icon) @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 @endif
<span class="truncate">{{ $label ?? $slot }}</span> <span data-md-chip-label>{{ $label ?? $slot }}</span>
@if ($iconRight) @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 @endif
@if ($tooltip !== null) @if ($tooltip !== null)
+17 -9
View File
@@ -19,7 +19,12 @@
string: a list of integers stays a list of integers. Without `wire:model` it works on its own string: a list of integers stays a list of integers. Without `wire:model` it works on its own
and binds with `x-model`. Errors for the property and its items replace the hint. When the and binds with `x-model`. Errors for the property and its items replace the hint. When the
options change on the server, give the component a `wire:key` that changes with them: the options change on the server, give the component a `wire:key` that changes with them: the
options are baked into its Alpine state. --}} options are baked into its Alpine state.
The root renders `data-md-choices` (and `data-md-searchable`); the chips are `<x-chip-set>`'s, the
field `<x-field>`'s, and the open list M3's menu (resources/css/components/menu.css, as
`data-md-field-menu` and `data-md-field-option`). resources/css/components/choices.css brings
them together. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -64,7 +69,9 @@
@if ($searchable) @if ($searchable)
<div <div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }} data-md-choices
data-md-searchable
{{ $attributes->only(['class', 'style', 'wire:key', 'x-model']) }}
x-data="{ x-data="{
@if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif @if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
options: @js($choices), options: @js($choices),
@@ -141,7 +148,7 @@
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
placeholder="{{ filled($placeholder) ? $placeholder : ' ' }}" placeholder="{{ filled($placeholder) ? $placeholder : ' ' }}"
class="field-control" data-md-field-control
x-model="query" x-model="query"
x-on:focus="show(); $nextTick(() => $el.select())" x-on:focus="show(); $nextTick(() => $el.select())"
x-on:pointerdown="selecting = document.activeElement !== $el" x-on:pointerdown="selecting = document.activeElement !== $el"
@@ -158,7 +165,7 @@
/> />
<x-slot:trailing> <x-slot:trailing>
<x-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" /> <x-livewire-material::icon name="arrow_drop_down" data-md-field-trailing data-md-field-arrow />
</x-slot:trailing> </x-slot:trailing>
</x-livewire-material::field> </x-livewire-material::field>
</div> </div>
@@ -170,7 +177,7 @@
x-ref="list" x-ref="list"
@if (filled($label)) aria-label="{{ $label }}" @endif @if (filled($label)) aria-label="{{ $label }}" @endif
style="position-anchor: {{ $anchor }}" style="position-anchor: {{ $anchor }}"
class="field-menu" data-md-field-menu
> >
<template x-for="(option, index) in filtered" :key="option.value"> <template x-for="(option, index) in filtered" :key="option.value">
<li <li
@@ -181,18 +188,19 @@
x-bind:data-active="index === active ? '' : null" x-bind:data-active="index === active ? '' : null"
x-on:mousedown.prevent="choose(option)" x-on:mousedown.prevent="choose(option)"
x-on:mousemove="active = index" x-on:mousemove="active = index"
class="field-option" data-md-field-option
> >
<span x-text="option.label"></span> <span x-text="option.label"></span>
<x-livewire-material::icon name="check" class="field-check size-6" /> <x-livewire-material::icon name="check" data-md-field-check />
</li> </li>
</template> </template>
<li x-show="filtered.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</li> <li x-show="filtered.length === 0" data-md-choices-empty>{{ __('Nothing matches') }}</li>
</ul> </ul>
</div> </div>
@else @else
<div <div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }} data-md-choices
{{ $attributes->only(['class', 'style', 'wire:key', 'x-model']) }}
x-data="{ x-data="{
@if ($model !== null) selection: @entangle($attributes->wire('model')), @else selection: @js($current ?? ($single ? null : [])), @endif @if ($model !== null) selection: @entangle($attributes->wire('model')), @else selection: @js($current ?? ($single ? null : [])), @endif
options: @js(array_column($choices, 'value')), options: @js(array_column($choices, 'value')),
+17 -13
View File
@@ -142,6 +142,8 @@
'invalidRange' => __('Invalid date range input'), 'invalidRange' => __('Invalid date range input'),
], ],
]; ];
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp @endphp
<div <div
@@ -162,9 +164,9 @@
:$icon :$icon
:$size :$size
:$variant :$variant
:data-invalid="$invalid ? '' : null" :data-md-invalid="$invalid ? '' : null"
:data-readonly="$attributes->get('readonly') ? '' : null" :data-md-readonly="$attributes->get('readonly') ? '' : null"
x-bind:data-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null" x-bind:data-md-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
> >
<input <input
{{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }} {{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }}
@@ -182,7 +184,7 @@
autocomplete="off" autocomplete="off"
value="{{ $display }}" value="{{ $display }}"
placeholder=" " placeholder=" "
class="field-control" data-md-field-control
x-model="text" x-model="text"
@if ($mode === 'docked') @if ($mode === 'docked')
x-bind:placeholder="placeholder" x-bind:placeholder="placeholder"
@@ -205,19 +207,21 @@
@if ($clearable) @if ($clearable)
<button <button
type="button" type="button"
class="field-trailing field-clear field-button"
aria-label="{{ __('Clear') }}" aria-label="{{ __('Clear') }}"
x-on:click="clear()" x-on:click="clear()"
@disabled($attributes->get('disabled') || $attributes->get('readonly')) @disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-field-clear data-md-field-trailing
data-md-field-button
data-md-field-clear
> >
<x-livewire-material::icon name="close" class="size-(--field-icon)" /> <x-livewire-material::icon name="close" :size="$fieldIconSize" />
</button> </button>
@endif @endif
<button <button
type="button" type="button"
class="field-trailing field-button" data-md-field-trailing
data-md-field-button
aria-label="{{ $title }}" aria-label="{{ $title }}"
aria-haspopup="dialog" aria-haspopup="dialog"
aria-controls="{{ $id }}-picker" aria-controls="{{ $id }}-picker"
@@ -227,7 +231,7 @@
@disabled($attributes->get('disabled') || $attributes->get('readonly')) @disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-datepicker-toggle data-datepicker-toggle
> >
<x-livewire-material::icon name="calendar_today" class="size-(--field-icon)" /> <x-livewire-material::icon name="calendar_today" :size="$fieldIconSize" />
</button> </button>
</x-slot:trailing> </x-slot:trailing>
</x-livewire-material::field> </x-livewire-material::field>
@@ -462,7 +466,7 @@
<div x-show="typing" data-datepicker-entry> <div x-show="typing" data-datepicker-entry>
<div @if ($range) data-range @endif data-datepicker-entry-fields> <div @if ($range) data-range @endif data-datepicker-entry-fields>
<x-livewire-material::field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null"> <x-livewire-material::field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-md-invalid="entryError !== '' ? '' : null">
<input <input
id="{{ $id }}-entry" id="{{ $id }}-entry"
type="text" type="text"
@@ -475,12 +479,12 @@
x-model="entry" x-model="entry"
x-on:input="typeEntry()" x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()" x-on:keydown.enter.prevent="confirm()"
class="field-control" data-md-field-control
/> />
</x-livewire-material::field> </x-livewire-material::field>
@if ($range) @if ($range)
<x-livewire-material::field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null"> <x-livewire-material::field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-md-invalid="entryError !== '' ? '' : null">
<input <input
id="{{ $id }}-entry-end" id="{{ $id }}-entry-end"
type="text" type="text"
@@ -492,7 +496,7 @@
x-model="entryEnd" x-model="entryEnd"
x-on:input="typeEntry()" x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()" x-on:keydown.enter.prevent="confirm()"
class="field-control" data-md-field-control
/> />
</x-livewire-material::field> </x-livewire-material::field>
@endif @endif
+33 -30
View File
@@ -1,10 +1,11 @@
{{-- The chrome of M3's text fields, around whichever control is in the slot. {{-- The chrome of M3's text fields, around whichever control is in the slot.
Not used at call sites: `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>` Not used at call sites: `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
and the pickers wrap their control in it, so a form reads as one family. The states the and the pickers wrap their control in it, so a form reads as one family. A custom control the
label resting or floating, the notch, focus, disabled, required are selectors in package does not have goes in the slot marked `data-md-field-control`. The states the label
resting or floating, the notch, focus, disabled, required are selectors in
resources/css/components/field.css, which explains the anatomy; the only state this file resources/css/components/field.css, which explains the anatomy; the only state this file
decides is `data-invalid`, because only the server knows that. decides is `data-md-invalid`, because only the server knows that.
`variant` is M3's two: `outlined` (a notched outline) and `filled` (a surface-container-highest `variant` is M3's two: `outlined` (a notched outline) and `filled` (a surface-container-highest
box with an indicator line); without it, `config('livewire-material.fields.variant')`. A field box with an indicator line); without it, `config('livewire-material.fields.variant')`. A field
@@ -12,7 +13,8 @@
editor row, and is given `size="sm"` (40px, a button's height) or `size="xs"` (32px). The editor row, and is given `size="sm"` (40px, a button's height) or `size="xs"` (32px). The
error replaces the hint rather than stacking under it, as M3 has it, and an `error` icon joins it error replaces the hint rather than stacking under it, as M3 has it, and an `error` icon joins it
at the end of the row so the state has two indicators and not only a colour unless the caller at the end of the row so the state has two indicators and not only a colour unless the caller
trails the field with something of its own, or the field is `xs` and has no room. trails the field with something of its own, or the field is `xs` and has no room. The field's
icons are 24px, 20px in an `sm` field and 16px in an `xs` one.
`counter` is the maximum number of characters, and puts M3's character counter at the end of the `counter` is the maximum number of characters, and puts M3's character counter at the end of the
supporting-text row: `n/max`, counted from the control on every `input`, in the error colour once supporting-text row: `n/max`, counted from the control on every `input`, in the error colour once
@@ -21,12 +23,12 @@
"Character count, 5/20", M3's own label, from a polite region a second after typing stops rather "Character count, 5/20", M3's own label, from a polite region a second after typing stops rather
than on every keystroke, which would talk over the typing. than on every keystroke, which would talk over the typing.
From `medium` the field is no wider than 40rem, because M3 asks that a text field never span From `medium` (600px) the field is no wider than 40rem, because M3 asks that a text field never
the full width of a large screen (§ Text Fields, Behaviour); a `max-w-*` class from the call span the full width of a large screen (§ Text Fields, Behaviour); a width rule from the call site
site beats that, and `full` takes it off altogether. beats that, and `full` takes it off altogether.
`class` from the call site lands on the outermost element, never the control, so a margin or `class` from the call site lands on the outermost element, never the control, so a margin or
a width is safe here. --}} a width is safe here; `hint-class` lands on the hint. --}}
@props([ @props([
'id', 'id',
@@ -50,6 +52,7 @@
? $variant ? $variant
: (config('livewire-material.fields.variant') === 'filled' ? 'filled' : 'outlined'); : (config('livewire-material.fields.variant') === 'filled' ? 'filled' : 'outlined');
$density = in_array($size, ['sm', 'xs'], true) ? $size : 'md'; $density = in_array($size, ['sm', 'xs'], true) ? $size : 'md';
$iconSize = ['md' => 24, 'sm' => 20, 'xs' => 16][$density];
// M3 pairs the error colours with a trailing error icon so the state has two indicators. It // M3 pairs the error colours with a trailing error icon so the state has two indicators. It
// gives way to whatever the caller trails the field with, and to `xs`, which has no room. // gives way to whatever the caller trails the field with, and to `xs`, which has no room.
$errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs'; $errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs';
@@ -57,67 +60,67 @@
@endphp @endphp
<div <div
{{ $attributes->class(['field']) }} data-md-field
data-variant="{{ $variant }}" data-md-variant="{{ $variant }}"
data-size="{{ $density }}" data-md-size="{{ $density }}"
@if ($messages !== []) data-invalid @endif @if ($messages !== []) data-md-invalid @endif
@if ($floated) data-floated @endif @if ($floated) data-md-floated @endif
@if ($mono) data-mono @endif @if ($mono) data-md-mono @endif
@if ($full) data-full @endif @if ($full) data-md-full @endif
{{ $attributes }}
> >
<div class="field-box"> <div data-md-field-box>
@if ($icon) @if ($icon)
<x-livewire-material::icon :name="$icon" class="field-icon size-(--field-icon)" /> <x-livewire-material::icon :name="$icon" :size="$iconSize" data-md-field-icon />
@endif @endif
@if (filled($prefix)) @if (filled($prefix))
<span class="field-affix">{{ $prefix }}</span> <span data-md-field-affix>{{ $prefix }}</span>
@endif @endif
{{ $slot }} {{ $slot }}
@if (filled($suffix)) @if (filled($suffix))
<span class="field-affix">{{ $suffix }}</span> <span data-md-field-affix>{{ $suffix }}</span>
@endif @endif
{{ $trailing ?? '' }} {{ $trailing ?? '' }}
@if ($errorIcon) @if ($errorIcon)
<x-livewire-material::icon name="error" class="field-trailing field-error size-(--field-icon)" :label="__('Error')" /> <x-livewire-material::icon name="error" :size="$iconSize" data-md-field-trailing data-md-field-error :label="__('Error')" />
@endif @endif
<fieldset aria-hidden="true" class="field-outline"> <fieldset aria-hidden="true" data-md-field-outline>
<legend>@if (filled($label))<span>{{ $label }}</span>@endif</legend> <legend>@if (filled($label))<span>{{ $label }}</span>@endif</legend>
</fieldset> </fieldset>
@if (filled($label)) @if (filled($label))
<label for="{{ $id }}" class="field-label">{{ $label }}</label> <label for="{{ $id }}" data-md-field-label>{{ $label }}</label>
@endif @endif
</div> </div>
@if ($messages !== [] || filled($hint) || $counter !== null) @if ($messages !== [] || filled($hint) || $counter !== null)
<div class="field-support-row"> <div data-md-field-support-row>
@if ($messages !== []) @if ($messages !== [])
<div id="{{ $id }}-support" class="field-support" role="alert"> <div id="{{ $id }}-support" data-md-field-support role="alert">
@foreach ($messages as $message) @foreach ($messages as $message)
<p>{{ $message }}</p> <p>{{ $message }}</p>
@endforeach @endforeach
</div> </div>
@elseif (filled($hint)) @elseif (filled($hint))
<p id="{{ $id }}-support" @class(array_filter(['field-support', $hintClass]))>{{ $hint }}</p> <p id="{{ $id }}-support" data-md-field-support @if (filled($hintClass)) class="{{ $hintClass }}" @endif>{{ $hint }}</p>
@endif @endif
@if ($counter !== null) @if ($counter !== null)
<span <span
class="field-counter" data-md-field-counter
data-field-counter
x-data="{ x-data="{
used: 0, used: 0,
max: {{ $counter }}, max: {{ $counter }},
spoken: '', spoken: '',
settle: null, settle: null,
init() { init() {
const control = this.$el.closest('.field').querySelector('.field-control'); const control = this.$el.closest('[data-md-field]').querySelector('[data-md-field-control]');
this.used = control.value.length; this.used = control.value.length;
control.addEventListener('input', () => (this.used = control.value.length)); control.addEventListener('input', () => (this.used = control.value.length));
@@ -131,10 +134,10 @@
clearTimeout(this.settle); clearTimeout(this.settle);
}, },
}" }"
x-bind:data-over="used > max" x-bind:data-md-over="used > max"
> >
<span aria-hidden="true" x-text="`${used}/${max}`">0/{{ $counter }}</span> <span aria-hidden="true" x-text="`${used}/${max}`">0/{{ $counter }}</span>
<span class="sr-only" aria-live="polite" aria-atomic="true" x-text="spoken"></span> <span class="md-visually-hidden" aria-live="polite" aria-atomic="true" x-text="spoken"></span>
</span> </span>
@endif @endif
</div> </div>
+4 -3
View File
@@ -4,7 +4,8 @@
picker, a file dropped on it, the keyboard, what a screen reader announces, the `change` a picker, a file dropped on it, the keyboard, what a screen reader announces, the `change` a
Livewire upload listens for. So the input stays native, its label always floats (the button Livewire upload listens for. So the input stays native, its label always floats (the button
stands where a resting label would), and only its `::file-selector-button` is restyled: a tonal stands where a resting label would), and only its `::file-selector-button` is restyled: a tonal
pill, so it reads as the thing to press (resources/css/components/field.css). pill, so it reads as the thing to press (resources/css/components/file.css, on the field's
chrome; the field's root carries `data-md-file`).
The browser's own line beside the button ("No file chosen", "3 files") is drawn transparent: The browser's own line beside the button ("No file chosen", "3 files") is drawn transparent:
it cannot be truncated or wrapped, and the caller shows what was chosen anyway, as previews. A it cannot be truncated or wrapped, and the caller shows what was chosen anyway, as previews. A
@@ -28,13 +29,13 @@
: []; : [];
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')"> <x-livewire-material::field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')" data-md-file>
<input <input
{{ $attributes->except(['class', 'id', 'type']) }} {{ $attributes->except(['class', 'id', 'type']) }}
type="file" type="file"
id="{{ $id }}" id="{{ $id }}"
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control" data-md-field-control
/> />
</x-livewire-material::field> </x-livewire-material::field>
+6 -8
View File
@@ -1,17 +1,15 @@
{{-- A form: its fields in one column, and its actions at the foot. {{-- A form: its fields in one column, and its actions at the foot.
`gap-4` between fields, because a text field floats its label half above its outline: any less 16px between fields, because a text field floats its label half above its outline: any less
and the hint under one field runs into the label of the next. The `actions` slot takes a class and the hint under one field runs into the label of the next. The `actions` slot takes
of its own; `separator` draws a divider above it. attributes of its own (a `class` from the call site lands on the row untouched); `separator`
draws a divider above it. The column is resources/css/components/form.css. --}}
`grid-cols-1`, because a bare `grid` has no columns and its implicit one floors at the
content's min-content: on a phone a wide field would push the form past its column. --}}
@props([ @props([
'separator' => false, 'separator' => false,
]) ])
<form {{ $attributes->class(['grid grid-cols-1 auto-rows-min gap-4']) }}> <form data-md-form {{ $attributes }}>
{{ $slot }} {{ $slot }}
@isset($actions) @isset($actions)
@@ -19,7 +17,7 @@
<x-livewire-material::divider /> <x-livewire-material::divider />
@endif @endif
<div {{ $actions->attributes->class(['flex flex-wrap items-center justify-end gap-2']) }}> <div data-md-form-actions {{ $actions->attributes }}>
{{ $actions }} {{ $actions }}
</div> </div>
@endisset @endisset
+18 -12
View File
@@ -11,7 +11,10 @@
The placeholder is a single space when none is given, because the label can only tell an The placeholder is a single space when none is given, because the label can only tell an
empty field from a filled one through `:placeholder-shown`. With a label, a real placeholder empty field from a filled one through `:placeholder-shown`. With a label, a real placeholder
shows only while the field has focus until then the label stands where it would be. --}} shows only while the field has focus until then the label stands where it would be.
The field's root carries `data-md-input`; everything it draws is the field's chrome
(resources/css/components/field.css), which resources/css/components/input.css imports. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -39,16 +42,17 @@
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show. // M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
$max = $counter ? $attributes->get('maxlength') : null; $max = $counter ? $attributes->get('maxlength') : null;
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :counter="$max" :$full :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null"> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :counter="$max" :$full :class="$attributes->get('class')" :data-md-readonly="$attributes->get('readonly') ? '' : null" data-md-input>
<input <input
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }} {{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
id="{{ $id }}" id="{{ $id }}"
placeholder="{{ $placeholder }}" placeholder="{{ $placeholder }}"
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control" data-md-field-control
/> />
@if ($clearable || $copyable || $iconRight) @if ($clearable || $copyable || $iconRight)
@@ -57,12 +61,13 @@
<button <button
type="button" type="button"
x-data x-data
x-on:click="const control = $el.closest('.field').querySelector('.field-control'); control.value = ''; control.dispatchEvent(new Event('input', { bubbles: true })); control.dispatchEvent(new Event('change', { bubbles: true })); control.focus();" x-on:click="const control = $el.closest('[data-md-field]').querySelector('[data-md-field-control]'); control.value = ''; control.dispatchEvent(new Event('input', { bubbles: true })); control.dispatchEvent(new Event('change', { bubbles: true })); control.focus();"
class="field-trailing field-clear field-button"
aria-label="{{ __('Clear') }}" aria-label="{{ __('Clear') }}"
data-field-clear data-md-field-trailing
data-md-field-button
data-md-field-clear
> >
<x-livewire-material::icon name="close" class="size-(--field-icon)" /> <x-livewire-material::icon name="close" :size="$fieldIconSize" />
</button> </button>
@endif @endif
@@ -70,17 +75,18 @@
<button <button
type="button" type="button"
x-data x-data
x-on:click="navigator.clipboard.writeText($el.closest('.field').querySelector('.field-control').value).then(() => window.materialToast(@js(__('Copied to the clipboard')), { type: 'success' }))" x-on:click="navigator.clipboard.writeText($el.closest('[data-md-field]').querySelector('[data-md-field-control]').value).then(() => window.materialToast(@js(__('Copied to the clipboard')), { type: 'success' }))"
class="field-trailing field-button"
aria-label="{{ __('Copy') }}" aria-label="{{ __('Copy') }}"
data-field-copy data-md-field-trailing
data-md-field-button
data-md-field-copy
> >
<x-livewire-material::icon name="content_copy" class="size-(--field-icon)" /> <x-livewire-material::icon name="content_copy" :size="$fieldIconSize" />
</button> </button>
@endif @endif
@if ($iconRight) @if ($iconRight)
<x-livewire-material::icon :name="$iconRight" class="field-trailing size-(--field-icon)" /> <x-livewire-material::icon :name="$iconRight" :size="$fieldIconSize" data-md-field-trailing />
@endif @endif
</x-slot:trailing> </x-slot:trailing>
@endif @endif
+11 -7
View File
@@ -5,7 +5,9 @@
M3's own pattern for an interactive trailing icon ("Show password" / "Hide password") and it M3's own pattern for an interactive trailing icon ("Show password" / "Hide password") and it
carries no `aria-pressed`, which would say the state a second time and the other way round. carries no `aria-pressed`, which would say the state a second time and the other way round.
`label`, `hint`, `icon`, `variant`, `size`; every other attribute reaches the `<input>` `label`, `hint`, `icon`, `variant`, `size`; every other attribute reaches the `<input>`
(`autocomplete="current-password"`, `wire:model`). --}} (`autocomplete="current-password"`, `wire:model`). The field's root carries
`data-md-password`; the chrome and the eye's icon button are the field's
(resources/css/components/field.css, brought by resources/css/components/password.css). --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -22,9 +24,10 @@
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }"> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }" data-md-password>
<input <input
{{ $attributes->except(['class', 'id', 'type', 'placeholder']) }} {{ $attributes->except(['class', 'id', 'type', 'placeholder']) }}
id="{{ $id }}" id="{{ $id }}"
@@ -33,7 +36,7 @@
placeholder="{{ filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ' }}" placeholder="{{ filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ' }}"
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control" data-md-field-control
/> />
<x-slot:trailing> <x-slot:trailing>
@@ -42,11 +45,12 @@
x-on:click="shown = ! shown" x-on:click="shown = ! shown"
x-bind:aria-label="shown ? @js(__('Hide password')) : @js(__('Show password'))" x-bind:aria-label="shown ? @js(__('Hide password')) : @js(__('Show password'))"
aria-label="{{ __('Show password') }}" aria-label="{{ __('Show password') }}"
class="field-trailing field-button" data-md-field-trailing
data-field-reveal data-md-field-button
data-md-field-reveal
> >
<x-livewire-material::icon name="visibility" class="size-(--field-icon)" x-show="! shown" /> <x-livewire-material::icon name="visibility" :size="$fieldIconSize" x-show="! shown" />
<x-livewire-material::icon name="visibility_off" class="size-(--field-icon)" x-show="shown" x-cloak /> <x-livewire-material::icon name="visibility_off" :size="$fieldIconSize" x-show="shown" x-cloak />
</button> </button>
</x-slot:trailing> </x-slot:trailing>
</x-livewire-material::field> </x-livewire-material::field>
+15 -20
View File
@@ -7,7 +7,7 @@
(600px, M3's compact/medium boundary compact always stacks), and (600px, M3's compact/medium boundary compact always stacks), and
`value` for the option chosen when the page loads (with `wire:model`, Livewire sets it). `value` for the option chosen when the page loads (with `wire:model`, Livewire sets it).
Errors are read from the bag under the `wire:model` name Errors are read from the bag under the `wire:model` name
(resources/css/components/selection.css). (resources/css/components/radio.css).
M3 stacks radio buttons vertically and cautions against a row at any width, so `inline` is for M3 stacks radio buttons vertically and cautions against a row at any width, so `inline` is for
a group of two or three short labels that would waste a column, not the shape to reach for. a group of two or three short labels that would waste a column, not the shape to reach for.
@@ -35,28 +35,23 @@
@endphp @endphp
<fieldset <fieldset
{{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }} data-md-radio
@if ($inline) data-md-inline @endif
{{ $attributes->only(['class', 'style', 'wire:key']) }}
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
> >
@if (filled($label)) @if (filled($label))
<legend class="mb-3 type-label-lg text-on-surface-variant">{{ $label }}</legend> <legend data-md-radio-legend>{{ $label }}</legend>
@endif @endif
<div @class(['grid gap-4', 'medium:flex medium:flex-wrap medium:gap-x-6' => $inline])> <div data-md-radio-options>
@foreach ($options as $option) @foreach ($options as $option)
@php($optionHintText = data_get($option, $optionHint)) @php($optionHintText = data_get($option, $optionHint))
<label data-selection @if ($messages !== []) data-invalid @endif @class([ <label data-md-selection-row @if ($messages !== []) data-md-invalid @endif>
'flex gap-4', <span data-md-radio-button @if (blank(data_get($option, $optionLabel)) && blank($optionHintText)) class="md-touch-target" @endif>
'items-start' => filled($optionHintText),
'items-center' => blank($optionHintText),
])>
<span data-radio @class([
'mt-0.5' => filled($optionHintText),
'touch-target' => blank(data_get($option, $optionLabel)) && blank($optionHintText),
])>
<input <input
{{ $attributes->except(['class', 'wire:key', 'name', 'id']) }} {{ $attributes->except(['class', 'style', 'wire:key', 'name', 'id']) }}
type="radio" type="radio"
name="{{ $name }}" name="{{ $name }}"
value="{{ data_get($option, $optionValue) }}" value="{{ data_get($option, $optionValue) }}"
@@ -64,13 +59,13 @@
@checked($value !== null && (string) $value === (string) data_get($option, $optionValue)) @checked($value !== null && (string) $value === (string) data_get($option, $optionValue))
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
/> />
<span data-dot></span> <span data-md-radio-dot></span>
</span> </span>
<span class="min-w-0"> <span data-md-selection-text>
<span class="block type-body-lg text-on-surface" data-selection-label>{{ data_get($option, $optionLabel) }}</span> <span data-md-selection-label>{{ data_get($option, $optionLabel) }}</span>
@if (filled($optionHintText)) @if (filled($optionHintText))
<span class="mt-0.5 block type-body-sm text-on-surface-variant">{{ $optionHintText }}</span> <span data-md-selection-hint>{{ $optionHintText }}</span>
@endif @endif
</span> </span>
</label> </label>
@@ -78,12 +73,12 @@
</div> </div>
@if ($messages !== []) @if ($messages !== [])
<div id="{{ $id }}-support" class="mt-2 type-body-sm text-error" role="alert"> <div id="{{ $id }}-support" data-md-selection-support role="alert">
@foreach ($messages as $message) @foreach ($messages as $message)
<p>{{ $message }}</p> <p>{{ $message }}</p>
@endforeach @endforeach
</div> </div>
@elseif (filled($hint)) @elseif (filled($hint))
<p id="{{ $id }}-support" class="mt-2 type-body-sm text-on-surface-variant">{{ $hint }}</p> <p id="{{ $id }}-support" data-md-selection-support>{{ $hint }}</p>
@endif @endif
</fieldset> </fieldset>
+24 -20
View File
@@ -25,7 +25,11 @@
The bar is a combobox: the wrapper around the input carries `role="combobox"` with The bar is a combobox: the wrapper around the input carries `role="combobox"` with
`aria-expanded` and `aria-controls`, because ARIA gives a bare textbox neither. The results are `aria-expanded` and `aria-controls`, because ARIA gives a bare textbox neither. The results are
a list, and a polite live region says how many of them there are whenever they change M3 asks a list, and a polite live region says how many of them there are whenever they change M3 asks
for both (docs/reference/m3/components-navigation-selection-inputs.md § Search). --}} for both (docs/reference/m3/components-navigation-selection-inputs.md § Search).
The root renders `data-md-search` with `data-md-trigger`, and `data-md-open` and
`data-md-full-screen` while they hold; the parts are `data-md-search-*`, drawn by
resources/css/components/search.css. `class` and `wire:key` land on the root. --}}
@props([ @props([
'placeholder' => null, 'placeholder' => null,
@@ -56,13 +60,13 @@
x-on:focusout="leave($event)" x-on:focusout="leave($event)"
x-on:pointerdown.outside="close()" x-on:pointerdown.outside="close()"
x-trap.noscroll="fullScreen" x-trap.noscroll="fullScreen"
x-bind:data-open="open ? '' : null" x-bind:data-md-open="open ? '' : null"
x-bind:data-full-screen="fullScreen ? '' : null" x-bind:data-md-full-screen="fullScreen ? '' : null"
data-search data-md-search
data-trigger="{{ $trigger }}" data-md-trigger="{{ $trigger }}"
{{ $attributes->only(['class', 'wire:key'])->class(['relative']) }} {{ $attributes->only(['class', 'style', 'wire:key']) }}
> >
<div data-search-scrim x-cloak x-show="open && ! fullScreen" x-on:pointerdown="close()" aria-hidden="true"></div> <div data-md-search-scrim x-cloak x-show="open && ! fullScreen" x-on:pointerdown="close()" aria-hidden="true"></div>
@if ($trigger === 'icon') @if ($trigger === 'icon')
<button <button
@@ -75,23 +79,23 @@
aria-controls="{{ $id }}-view" aria-controls="{{ $id }}-view"
aria-expanded="false" aria-expanded="false"
x-bind:aria-expanded="open.toString()" x-bind:aria-expanded="open.toString()"
data-search-trigger data-md-search-trigger
> >
<x-livewire-material::icon :name="$icon" /> <x-livewire-material::icon :name="$icon" />
</button> </button>
@endif @endif
<div data-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()"> <div data-md-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
<span data-search-leading x-show="! fullScreen"> <span data-md-search-leading x-show="! fullScreen">
<x-livewire-material::icon :name="$icon" /> <x-livewire-material::icon :name="$icon" />
</span> </span>
<button type="button" data-search-leading data-search-back x-show="fullScreen" x-cloak x-on:click="close(true)" aria-label="{{ __('Back') }}"> <button type="button" data-md-search-leading data-md-search-back x-show="fullScreen" x-cloak x-on:click="close(true)" aria-label="{{ __('Back') }}">
<x-livewire-material::icon name="arrow_back" /> <x-livewire-material::icon name="arrow_back" />
</button> </button>
<span <span
data-search-field data-md-search-field
role="combobox" role="combobox"
aria-haspopup="dialog" aria-haspopup="dialog"
aria-controls="{{ $id }}-view" aria-controls="{{ $id }}-view"
@@ -111,23 +115,23 @@
x-on:click="show()" x-on:click="show()"
x-on:input="typed($event)" x-on:input="typed($event)"
x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))" x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
data-search-input data-md-search-input
/> />
</span> </span>
<button type="button" data-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}"> <button type="button" data-md-search-clear x-on:click="clear()" aria-label="{{ __('Clear') }}">
<x-livewire-material::icon name="close" /> <x-livewire-material::icon name="close" />
</button> </button>
@isset($trailing) @isset($trailing)
<span data-search-trailing>{{ $trailing }}</span> <span data-md-search-trailing>{{ $trailing }}</span>
@endisset @endisset
</div> </div>
<div <div
x-ref="view" x-ref="view"
id="{{ $id }}-view" id="{{ $id }}-view"
data-search-view data-md-search-view
x-cloak x-cloak
x-show="open" x-show="open"
x-on:keydown.arrow-down.prevent="step(1)" x-on:keydown.arrow-down.prevent="step(1)"
@@ -135,15 +139,15 @@
x-on:click="choose($event)" x-on:click="choose($event)"
> >
@isset($suggestions) @isset($suggestions)
<div data-search-suggestions role="list" x-show="query === ''">{{ $suggestions }}</div> <div data-md-search-suggestions role="list" x-show="query === ''">{{ $suggestions }}</div>
@endisset @endisset
@if ($slot->hasActualContent()) @if ($slot->hasActualContent())
<div data-search-results role="list" @isset($suggestions) x-cloak x-show="query !== ''" @endisset>{{ $slot }}</div> <div data-md-search-results role="list" @isset($suggestions) x-cloak x-show="query !== ''" @endisset>{{ $slot }}</div>
@elseif (isset($empty)) @elseif (isset($empty))
<div data-search-results @isset($suggestions) x-cloak x-show="query !== ''" @endisset><p class="px-4 py-3 type-body-md text-on-surface-variant">{{ $empty }}</p></div> <div data-md-search-results @isset($suggestions) x-cloak x-show="query !== ''" @endisset><p data-md-search-empty>{{ $empty }}</p></div>
@endif @endif
</div> </div>
<span data-search-status class="sr-only" aria-live="polite" aria-atomic="true" x-text="announcement"></span> <span data-md-search-status class="md-visually-hidden" aria-live="polite" aria-atomic="true" x-text="announcement"></span>
</div> </div>
+6 -4
View File
@@ -10,7 +10,8 @@
`options` as a list of `['id' => …, 'name' => …]` (`'disabled' => true` greys one out), `options` as a list of `['id' => …, 'name' => …]` (`'disabled' => true` greys one out),
`option-value` and `option-label` to read other keys, and a `placeholder` that becomes the `option-value` and `option-label` to read other keys, and a `placeholder` that becomes the
first option, valued `placeholder-value` (empty by default). Or pass `<option>`s in the slot. first option, valued `placeholder-value` (empty by default). Or pass `<option>`s in the slot.
`label`, `hint`, `icon`, `variant`, `size`. --}} `label`, `hint`, `icon`, `variant`, `size`. The field's root carries `data-md-select`; what a
select changes in the field's chrome is resources/css/components/select.css. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -32,15 +33,16 @@
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`). // A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null); $errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')"> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')" data-md-select>
<select <select
{{ $attributes->except(['class', 'id']) }} {{ $attributes->except(['class', 'id']) }}
id="{{ $id }}" id="{{ $id }}"
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control" data-md-field-control
> >
@if (filled($placeholder)) @if (filled($placeholder))
<option value="{{ $placeholderValue }}">{{ $placeholder }}</option> <option value="{{ $placeholderValue }}">{{ $placeholder }}</option>
@@ -54,6 +56,6 @@
</select> </select>
<x-slot:trailing> <x-slot:trailing>
<x-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" /> <x-livewire-material::icon name="arrow_drop_down" :size="$fieldIconSize" data-md-field-trailing data-md-field-arrow />
</x-slot:trailing> </x-slot:trailing>
</x-livewire-material::field> </x-livewire-material::field>
+37 -143
View File
@@ -30,8 +30,8 @@
drawing, turned a quarter so the value grows upwards, with the value label beside the handle drawing, turned a quarter so the value grows upwards, with the value label beside the handle
instead of above it and every size unchanged. The keyboard is the native range's, so Up and instead of above it and every size unchanged. The keyboard is the native range's, so Up and
Right raise the value and Down and Left lower it. A vertical slider is as wide as a horizontal Right raise the value and Down and Left lower it. A vertical slider is as wide as a horizontal
one is tall and takes its length from the wrapper, so give it an explicit height one is tall and takes its length from the wrapper, so give it an explicit height a height
`class="h-64"`, which the label and the hint share; without one it is 192px. M3 keeps range rule on its `class`, which the label and the hint share; without one it is 192px. M3 keeps range
sliders horizontal ("never use range sliders vertically — too much cognitive load"), so sliders horizontal ("never use range sliders vertically — too much cognitive load"), so
`range` wins over `orientation`. `range` wins over `orientation`.
@@ -63,7 +63,11 @@
The server draws the first frame; the drawing is `wire:ignore`, so a Livewire render never The server draws the first frame; the drawing is `wire:ignore`, so a Livewire render never
fights a handle being dragged, and a value the server changes moves the handle after the fights a handle being dragged, and a value the server changes moves the handle after the
morph. --}} morph.
The root renders `data-md-slider` with `data-md-size`, `data-md-color`, `data-md-value-label`,
`data-md-orientation` and `data-md-centered`; the parts are `data-md-slider-*`, and everything
they look like is resources/css/components/slider.css. `class` and `style` land on the root. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -142,7 +146,6 @@
$id = $attributes->get('id') ?? 'material-slider-'.substr(md5(($model ?? $name ?? '').'|'.$label.'|'.($model ?? $name ?? \Illuminate\Support\Str::random(10))), 0, 10); $id = $attributes->get('id') ?? 'material-slider-'.substr(md5(($model ?? $name ?? '').'|'.$label.'|'.($model ?? $name ?? \Illuminate\Support\Str::random(10))), 0, 10);
$described = $messages !== [] || filled($hint) ? "{$id}-hint" : null; $described = $messages !== [] || filled($hint) ? "{$id}-hint" : null;
$sized = preg_match('/(^|\s)w-/', (string) $attributes->get('class')) === 1;
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'style', 'wire:key', 'id']); $inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'style', 'wire:key', 'id']);
// The first frame: Compose's layout and drawTrack, with every position a percentage of the // The first frame: Compose's layout and drawTrack, with every position a percentage of the
@@ -234,182 +237,78 @@
$segmentStyle = fn (?array $segment): string => $segment === null ? '' : 'left: '.$calc($segment[0]).'; width: '.$calc([$segment[1][0] - $segment[0][0], $segment[1][1] - $segment[0][1]]) $segmentStyle = fn (?array $segment): string => $segment === null ? '' : 'left: '.$calc($segment[0]).'; width: '.$calc([$segment[1][0] - $segment[0][0], $segment[1][1] - $segment[0][1]])
.'; border-radius: '.$segment[2].'px '.$segment[3].'px '.$segment[3].'px '.$segment[2].'px'; .'; border-radius: '.$segment[2].'px '.$segment[3].'px '.$segment[3].'px '.$segment[2].'px';
// Every colour class written out whole, so Tailwind compiles it.
$activeInk = [
'primary' => 'bg-primary', 'secondary' => 'bg-secondary', 'tertiary' => 'bg-tertiary', 'error' => 'bg-error',
'success' => 'bg-success', 'warning' => 'bg-warning', 'info' => 'bg-info',
];
$inactiveInk = [
'primary' => 'bg-secondary-container', 'secondary' => 'bg-secondary-container', 'tertiary' => 'bg-tertiary-container',
'error' => 'bg-error-container', 'success' => 'bg-success-container', 'warning' => 'bg-warning-container', 'info' => 'bg-info-container',
];
// A tick, a stop indicator and the icon are drawn on the track, so each takes the "on" colour of
// the part it sits on: on-primary over the active track, on-secondary-container over the
// inactive one (M3's nine slider colour roles, in anatomy order). `data-active` marks the ones
// over the active track.
$tickInk = [
'primary' => 'bg-on-secondary-container data-active:bg-on-primary', 'secondary' => 'bg-on-secondary-container data-active:bg-on-secondary',
'tertiary' => 'bg-on-tertiary-container data-active:bg-on-tertiary', 'error' => 'bg-on-error-container data-active:bg-on-error',
'success' => 'bg-on-success-container data-active:bg-on-success', 'warning' => 'bg-on-warning-container data-active:bg-on-warning',
'info' => 'bg-on-info-container data-active:bg-on-info',
];
// A stop indicator only ever sits at an open end of the track, which is inactive.
$stopInk = [
'primary' => 'bg-on-secondary-container', 'secondary' => 'bg-on-secondary-container', 'tertiary' => 'bg-on-tertiary-container',
'error' => 'bg-on-error-container', 'success' => 'bg-on-success-container', 'warning' => 'bg-on-warning-container',
'info' => 'bg-on-info-container',
];
$iconInk = [
'primary' => 'text-on-secondary-container data-active:text-on-primary', 'secondary' => 'text-on-secondary-container data-active:text-on-secondary',
'tertiary' => 'text-on-tertiary-container data-active:text-on-tertiary', 'error' => 'text-on-error-container data-active:text-on-error',
'success' => 'text-on-success-container data-active:text-on-success', 'warning' => 'text-on-warning-container data-active:text-on-warning',
'info' => 'text-on-info-container data-active:text-on-info',
];
$accent = [
'primary' => 'noscript:accent-primary', 'secondary' => 'noscript:accent-secondary', 'tertiary' => 'noscript:accent-tertiary',
'error' => 'noscript:accent-error', 'success' => 'noscript:accent-success', 'warning' => 'noscript:accent-warning', 'info' => 'noscript:accent-info',
];
$trackHeight = ['xs' => 'h-4', 'sm' => 'h-6', 'md' => 'h-10', 'lg' => 'h-14', 'xl' => 'h-24'][$size];
// A focused handle is 6px shorter, so its ring stays clear of the label (Material Components'
// m3_slider_focus_ring_thumb_height_decrease).
$handleHeight = [
'xs' => 'h-11 group-data-focused/thumb:h-9.5', 'sm' => 'h-11 group-data-focused/thumb:h-9.5', 'md' => 'h-13 group-data-focused/thumb:h-11.5',
'lg' => 'h-17 group-data-focused/thumb:h-15.5', 'xl' => 'h-27 group-data-focused/thumb:h-25.5',
][$size];
// The label's bottom sits 4px above the handle: half of 44, 44, 52, 68 and 108px, plus 4.
$labelBottom = ['xs' => 'bottom-[calc(50%+1.625rem)]', 'sm' => 'bottom-[calc(50%+1.625rem)]', 'md' => 'bottom-[calc(50%+1.875rem)]', 'lg' => 'bottom-[calc(50%+2.375rem)]', 'xl' => 'bottom-[calc(50%+3.625rem)]'][$size];
// The measure across the slider — the handle's height, and so the slider's own. Standing up it
// is the width instead, and the length comes from the wrapper the caller sized.
$across = [
'xs' => $vertical ? 'w-12' : 'h-12',
'sm' => $vertical ? 'w-12' : 'h-12',
'md' => $vertical ? 'w-13' : 'h-13',
'lg' => $vertical ? 'w-17' : 'h-17',
'xl' => $vertical ? 'w-27' : 'h-27',
][$size];
$thumbs = $range ? ['start', 'end'] : ['start']; $thumbs = $range ? ['start', 'end'] : ['start'];
@endphp @endphp
<div {{ $attributes->only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized && ! $vertical, 'flex w-fit flex-col' => $vertical]) }}> <div
data-md-slider
data-md-size="{{ $size }}"
data-md-color="{{ $color }}"
data-md-value-label="{{ $valueLabel }}"
@if ($vertical) data-md-orientation="vertical" @endif
@if ($centered) data-md-centered @endif
{{ $attributes->only(['class', 'style', 'wire:key']) }}
>
@if (filled($label)) @if (filled($label))
@if ($range) @if ($range)
<span id="{{ $id }}-label" class="mb-2 block type-label-lg text-on-surface-variant">{{ $label }}</span> <span id="{{ $id }}-label" data-md-slider-label>{{ $label }}</span>
@else @else
<label for="{{ $id }}" id="{{ $id }}-label" class="mb-2 block type-label-lg text-on-surface-variant">{{ $label }}</label> <label for="{{ $id }}" id="{{ $id }}-label" data-md-slider-label>{{ $label }}</label>
@endif @endif
@endif @endif
<div <div
x-data="materialSlider" x-data="materialSlider"
x-on:pointerdown="press($event)" x-on:pointerdown="press($event)"
data-slider data-md-slider-control
data-size="{{ $size }}"
@if ($vertical) data-orientation="vertical" @endif
@if ($centered) data-centered @endif
@if ($range) role="group" @if (filled($label)) aria-labelledby="{{ $id }}-label" @endif @endif @if ($range) role="group" @if (filled($label)) aria-labelledby="{{ $id }}-label" @endif @endif
@class([
'group/slider relative cursor-pointer select-none has-disabled:cursor-not-allowed',
$across,
'touch-pan-y' => ! $vertical,
// Standing up: 192px long unless the wrapper says otherwise, and a size container,
// which is what lets the drawing below be as long as this is tall.
'touch-pan-x h-48 min-h-0 flex-auto [container-type:size]' => $vertical,
// Room for a label that never hides: half the handle, 4px and the 44px pill — above the
// track when the slider lies down, beside it when it stands up.
'mt-12' => $valueLabel === 'always' && ! $vertical,
'ms-12' => $valueLabel === 'always' && $vertical,
'noscript:h-auto noscript:cursor-auto noscript:space-y-2',
])
> >
<div data-slider-drawing wire:ignore aria-hidden="true" @class([ <div data-md-slider-drawing wire:ignore aria-hidden="true">
'pointer-events-none absolute noscript:hidden', <div data-md-slider-track>
'inset-y-0 inset-x-0.5 rtl:-scale-x-100' => ! $vertical, @foreach (['start', 'active', 'end'] as $part)
// The same drawing, laid out as long as the slider is tall and then turned a quarter
// anticlockwise so the value grows upwards. Everything inside it — the segments, the
// ticks, the handle and its label — is placed as if the slider were lying down.
'top-1/2 left-1/2 h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90' => $vertical,
])>
<div @class(['absolute inset-x-0 top-1/2 -translate-y-1/2', $trackHeight])>
@foreach (['start' => $inactiveInk[$color], 'active' => $activeInk[$color], 'end' => $inactiveInk[$color]] as $part => $ink)
<span <span
data-segment="{{ $part }}" data-md-slider-segment="{{ $part }}"
@if (! isset($segments[$part])) hidden @endif @if (! isset($segments[$part])) hidden @endif
@class([
'absolute inset-y-0',
$ink,
'group-has-disabled/slider:bg-on-surface/38' => $part === 'active',
'group-has-disabled/slider:bg-on-surface/12' => $part !== 'active',
])
style="{{ $segmentStyle($segments[$part] ?? null) }}" style="{{ $segmentStyle($segments[$part] ?? null) }}"
></span> ></span>
@endforeach @endforeach
@foreach ($tickMarks as $mark) @foreach ($tickMarks as $mark)
<span <span
data-tick="{{ $format($mark['fraction']) }}" data-md-slider-tick="{{ $format($mark['fraction']) }}"
@if ($mark['hidden']) hidden @endif @if ($mark['hidden']) hidden @endif
@if ($mark['active']) data-active @endif @if ($mark['active']) data-md-active @endif
@class(['absolute top-1/2 size-1 -translate-1/2 rounded-corner-full', $tickInk[$color], 'group-has-disabled/slider:bg-on-surface/38 group-has-disabled/slider:data-active:bg-on-surface/12'])
style="left: {{ $calc($mark['at']) }}" style="left: {{ $calc($mark['at']) }}"
></span> ></span>
@endforeach @endforeach
@foreach (['start', 'end'] as $part) @foreach (['start', 'end'] as $part)
<span <span
data-stop="{{ $part }}" data-md-slider-stop="{{ $part }}"
@if (! isset($stops[$part])) hidden @endif @if (! isset($stops[$part])) hidden @endif
@class(['absolute top-1/2 size-1 -translate-1/2 rounded-corner-full', $stopInk[$color], 'group-has-disabled/slider:bg-on-surface/38'])
style="{{ isset($stops[$part]) ? 'left: '.$calc($stops[$part]) : '' }}" style="{{ isset($stops[$part]) ? 'left: '.$calc($stops[$part]) : '' }}"
></span> ></span>
@endforeach @endforeach
@if ($icon !== null) @if ($icon !== null)
<span <span
data-track-icon data-md-slider-icon
@if (! $iconInActive && ! $iconInInactive) hidden @endif @if (! $iconInActive && ! $iconInInactive) hidden @endif
@if ($iconInActive) data-active @endif @if ($iconInActive) data-md-active @endif
@class([
'absolute top-1/2 flex -translate-y-1/2',
'rtl:-scale-x-100' => ! $vertical,
// Turned back the quarter the drawing turns, so the glyph stands up.
'rotate-90' => $vertical,
$iconInk[$color],
'group-has-disabled/slider:text-on-surface/38',
])
style="left: {{ $calc($iconAt) }}" style="left: {{ $calc($iconAt) }}"
> >
<x-livewire-material::icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" /> <x-livewire-material::icon :name="$icon" :size="$iconSize" />
</span> </span>
@endif @endif
</div> </div>
@foreach ($thumbs as $index => $thumb) @foreach ($thumbs as $index => $thumb)
<span data-handle="{{ $thumb }}" class="group/thumb absolute inset-y-0 w-0" style="left: {{ $calc($place($fractions[$index])) }}"> <span data-md-slider-handle="{{ $thumb }}" style="left: {{ $calc($place($fractions[$index])) }}">
<span @class([ <span data-md-slider-thumb></span>
'absolute top-1/2 left-0 w-1 -translate-1/2 rounded-corner-full',
'transition-[width,height] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'group-data-pressed/thumb:w-0.5 group-data-focused/thumb:w-0.5',
'outline-offset-2 outline-secondary group-data-focused/thumb:outline-3',
$handleHeight,
$activeInk[$color],
'group-has-disabled/slider:bg-on-surface/38',
])></span>
@if ($valueLabel !== 'never') @if ($valueLabel !== 'never')
<span @class(['absolute left-0 z-10 flex -translate-x-1/2 justify-center', 'rtl:-scale-x-100' => ! $vertical, $labelBottom])> <span data-md-slider-value-anchor>
<span <span data-md-slider-value>{{ $format($values[$index]) }}</span>
data-value-label
@class([
'flex h-11 min-w-12 items-center justify-center whitespace-nowrap rounded-corner-full bg-inverse-surface px-2.5 text-inverse-on-surface type-label-lg',
'origin-bottom' => ! $vertical,
// Turned back the quarter the drawing turns: the pill lands
// beside the handle rather than above it, and reads across.
'rotate-90' => $vertical,
'transition-[opacity,scale] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
'opacity-0 scale-75 group-data-pressed/thumb:opacity-100 group-data-pressed/thumb:scale-100 group-data-focused/thumb:opacity-100 group-data-focused/thumb:scale-100' => $valueLabel === 'drag',
])
>{{ $format($values[$index]) }}</span>
</span> </span>
@endif @endif
</span> </span>
@@ -425,7 +324,7 @@
max="{{ $format($maximum) }}" max="{{ $format($maximum) }}"
step="{{ $continuous ? 'any' : $format($interval) }}" step="{{ $continuous ? 'any' : $format($interval) }}"
value="{{ $format($values[$index]) }}" value="{{ $format($values[$index]) }}"
data-thumb="{{ $thumb }}" data-md-slider-input="{{ $thumb }}"
@if ($vertical) aria-orientation="vertical" @endif @if ($vertical) aria-orientation="vertical" @endif
@if ($range) aria-label="{{ $thumb === 'start' ? __('Range start') : __('Range end') }}" @endif @if ($range) aria-label="{{ $thumb === 'start' ? __('Range start') : __('Range end') }}" @endif
@if ($described) aria-describedby="{{ $described }}" @endif @if ($described) aria-describedby="{{ $described }}" @endif
@@ -433,22 +332,17 @@
@disabled($disabled) @disabled($disabled)
{{ new \Illuminate\View\ComponentAttributeBag($bindingsFor($index)) }} {{ new \Illuminate\View\ComponentAttributeBag($bindingsFor($index)) }}
{{ $inputAttributes }} {{ $inputAttributes }}
@class([
'pointer-events-none absolute inset-0 m-0 size-full appearance-none bg-transparent opacity-0',
'noscript:pointer-events-auto noscript:static noscript:block noscript:h-6 noscript:w-full noscript:appearance-auto noscript:opacity-100',
$accent[$color],
])
/> />
@endforeach @endforeach
</div> </div>
@if ($messages !== []) @if ($messages !== [])
<div id="{{ $id }}-hint"> <div id="{{ $id }}-hint" data-md-slider-errors>
@foreach ($messages as $message) @foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p> <p>{{ $message }}</p>
@endforeach @endforeach
</div> </div>
@elseif (filled($hint)) @elseif (filled($hint))
<p id="{{ $id }}-hint" class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p> <p id="{{ $id }}-hint" data-md-slider-hint>{{ $hint }}</p>
@endif @endif
</div> </div>
@@ -3,10 +3,12 @@
Pressing it sorts by this column, ascending first and then flipping; the arrow says which way, Pressing it sorts by this column, ascending first and then flipping; the arrow says which way,
and `aria-sort` says it to a screen reader. A column that cannot be sorted is a plain `<th>`. and `aria-sort` says it to a screen reader. A column that cannot be sorted is a plain `<th>`.
`class` lands on the `<th>` (`text-end` for a number column moves the button with it). `class` lands on the `<th>` (end-aligning a number column moves the button with it).
The button is only as tall as its title-small line, so it carries `touch-target` and catches The button is only as tall as its title-small line, so it carries `md-touch-target` and catches
presses over M3's 48px minimum; the cell's own padding belongs to the cell, not to it. --}} presses over M3's 48px minimum; the cell's own padding belongs to the cell, not to it. The cell
renders `data-md-sort-header` (`data-md-active` on the sorted column) and the button
`data-md-sort-header-button`, drawn by resources/css/components/sort-header.css. --}}
@props([ @props([
'sortBy' => [], 'sortBy' => [],
@@ -20,21 +22,14 @@
$next = $active && $direction === 'asc' ? 'desc' : 'asc'; $next = $active && $direction === 'asc' ? 'desc' : 'asc';
@endphp @endphp
<th @if ($active) aria-sort="{{ $direction === 'asc' ? 'ascending' : 'descending' }}" @endif {{ $attributes }}> <th data-md-sort-header @if ($active) data-md-active aria-sort="{{ $direction === 'asc' ? 'ascending' : 'descending' }}" @endif {{ $attributes }}>
<button <button
type="button" type="button"
wire:click="$set('{{ $model }}', {{ json_encode(['column' => $column, 'direction' => $next]) }})" wire:click="$set('{{ $model }}', {{ json_encode(['column' => $column, 'direction' => $next]) }})"
data-sort-header data-md-sort-header-button
@class([ class="md-focus-ring md-touch-target"
'group/sort focus-ring touch-target inline-flex cursor-pointer items-center gap-1 rounded-corner-xs',
'text-on-surface' => $active,
'hover:text-on-surface' => ! $active,
])
> >
{{ $slot }} {{ $slot }}
<x-livewire-material::icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" optical="20" :class="\Illuminate\Support\Arr::toCssClasses([ <x-livewire-material::icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" size="16" data-md-sort-header-arrow />
'size-4 transition-opacity duration-(--md-sys-motion-effects-fast-duration)',
'opacity-0 group-hover/sort:opacity-60 group-focus-visible/sort:opacity-60' => ! $active,
])" />
</button> </button>
</th> </th>
+5 -5
View File
@@ -1,20 +1,20 @@
{{-- A data table. Its callers write `<thead>`, `<tr>`, `<th>` and `<td>` as they always do; how they {{-- A data table. Its callers write `<thead>`, `<tr>`, `<th>` and `<td>` as they always do; how they
look is resources/css/components/table.css, keyed on the `data-table` attribute set here (the look is resources/css/components/table.css, keyed on the `data-md-table` attribute set here (the
top of that file says why). top of that file says why), with `data-md-size` and `data-md-dense`.
Rows are 52px, a target a finger can hit. `dense` tightens them to 36px M3 asks that density Rows are 52px, a target a finger can hit. `dense` tightens them to 36px M3 asks that density
always be an opt-in and never a default, so it is the caller's decision and the caller's to always be an opt-in and never a default, so it is the caller's decision and the caller's to
justify. `size="xs"` for a table inside a panel inside a panel (32px rows, 24px with `dense`). justify. `size="xs"` for a table inside a panel inside a panel (32px rows, 24px with `dense`).
A selected row is `<tr aria-selected="true">`. Horizontal scrolling stays the caller's wrap the A selected row is `<tr aria-selected="true">`. Horizontal scrolling stays the caller's wrap the
table in `<div class="overflow-x-auto">` where the page needs it. A column that sorts is table in an element that scrolls sideways (`overflow-x: auto`) where the page needs it. A column
`<x-sort-header>`. --}} that sorts is `<x-sort-header>`. --}}
@props([ @props([
'size' => 'sm', 'size' => 'sm',
'dense' => false, 'dense' => false,
]) ])
<table data-table data-size="{{ $size === 'xs' ? 'xs' : 'sm' }}"{{ $dense ? ' data-dense' : '' }} {{ $attributes }}> <table data-md-table data-md-size="{{ $size === 'xs' ? 'xs' : 'sm' }}"{{ $dense ? ' data-md-dense' : '' }} {{ $attributes }}>
{{ $slot }} {{ $slot }}
</table> </table>
@@ -5,7 +5,8 @@
form's column never moves. The label rests on the first line rather than in the middle of the form's column never moves. The label rests on the first line rather than in the middle of the
box. `label`, `hint`, `variant`, `counter` for M3's character counter against `maxlength`, and box. `label`, `hint`, `variant`, `counter` for M3's character counter against `maxlength`, and
`full` to span the pane past the 40rem M3 bounds a field to from `medium`; every other `full` to span the pane past the 40rem M3 bounds a field to from `medium`; every other
attribute reaches the `<textarea>`. --}} attribute reaches the `<textarea>`. The field's root carries `data-md-textarea`; what a
textarea changes in the field's chrome is resources/css/components/textarea.css. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -30,18 +31,18 @@
$max = $counter ? $attributes->get('maxlength') : null; $max = $counter ? $attributes->get('maxlength') : null;
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :$full :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null"> <x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :$full :class="$attributes->get('class')" :data-md-readonly="$attributes->get('readonly') ? '' : null" data-md-textarea>
<textarea <textarea
{{ $attributes->except(['class', 'id', 'placeholder']) }} {{ $attributes->except(['class', 'id', 'placeholder']) }}
id="{{ $id }}" id="{{ $id }}"
rows="{{ (int) $rows }}" rows="{{ (int) $rows }}"
placeholder="{{ $placeholder }}" placeholder="{{ $placeholder }}"
@if ($autogrow) @if ($autogrow)
data-autogrow data-md-autogrow
style="--field-rows: {{ (int) $rows }};@if ($maxRows) --field-max-rows: {{ (int) $maxRows }};@endif" style="--field-rows: {{ (int) $rows }};@if ($maxRows) --field-max-rows: {{ (int) $maxRows }};@endif"
@endif @endif
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif @if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control" data-md-field-control
></textarea> ></textarea>
</x-livewire-material::field> </x-livewire-material::field>
@@ -135,6 +135,7 @@
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']); $inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']);
$disabled = (bool) $attributes->get('disabled'); $disabled = (bool) $attributes->get('disabled');
$described = $messages !== [] || filled($hint); $described = $messages !== [] || filled($hint);
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp @endphp
<div <div
@@ -159,7 +160,7 @@
x-bind:aria-expanded="open.toString()" x-bind:aria-expanded="open.toString()"
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
@if ($described) aria-describedby="{{ $id }}-support" @endif @if ($described) aria-describedby="{{ $id }}-support" @endif
class="field-control" data-md-field-control
x-on:click="show()" x-on:click="show()"
x-on:keydown.enter.prevent="show()" x-on:keydown.enter.prevent="show()"
x-on:keydown.space.prevent="show()" x-on:keydown.space.prevent="show()"
@@ -171,12 +172,13 @@
<button <button
type="button" type="button"
x-on:click="value = null; $refs.input.focus()" x-on:click="value = null; $refs.input.focus()"
class="field-trailing field-clear field-button" data-md-field-trailing
data-md-field-button
data-md-field-clear
aria-label="{{ __('Clear') }}" aria-label="{{ __('Clear') }}"
data-field-clear
@disabled($disabled) @disabled($disabled)
> >
<x-livewire-material::icon name="close" class="size-(--field-icon)" /> <x-livewire-material::icon name="close" :size="$fieldIconSize" />
</button> </button>
@endif @endif
@@ -184,12 +186,13 @@
type="button" type="button"
tabindex="-1" tabindex="-1"
x-on:click="show()" x-on:click="show()"
class="field-trailing field-button" data-md-field-trailing
data-md-field-button
aria-label="{{ __('Choose time') }}" aria-label="{{ __('Choose time') }}"
data-timepicker-open data-timepicker-open
@disabled($disabled) @disabled($disabled)
> >
<x-livewire-material::icon name="schedule" class="size-(--field-icon)" /> <x-livewire-material::icon name="schedule" :size="$fieldIconSize" />
</button> </button>
</x-slot:trailing> </x-slot:trailing>
</x-livewire-material::field> </x-livewire-material::field>
+13 -18
View File
@@ -5,10 +5,10 @@
its name a switch without `label` needs an `aria-label`. `right` puts it at the end of the its name a switch without `label` needs an `aria-label`. `right` puts it at the end of the
row, where a setting's switch usually is. `icons` draws a check on the handle when on and a row, where a setting's switch usually is. `icons` draws a check on the handle when on and a
cross when off; `icons="selected"` only the check. Every other attribute reaches the `<input>` cross when off; `icons="selected"` only the check. Every other attribute reaches the `<input>`
(resources/css/components/selection.css). (resources/css/components/toggle.css).
Without a label the row is only the 52×32 track, so the track carries `touch-target` and catches Without a label the row is only the 52×32 track, so the track carries `md-touch-target` and
presses over M3's 48px minimum. --}} catches presses over M3's 48px minimum. `class` and `style` land on the root, `data-md-toggle`. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -23,35 +23,30 @@
$icons = $icons === 'selected' ? 'selected' : ($icons ? 'both' : null); $icons = $icons === 'selected' ? 'selected' : ($icons ? 'both' : null);
@endphp @endphp
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}> <div data-md-toggle {{ $attributes->only(['class', 'style', 'wire:key']) }}>
<label for="{{ $id }}" data-selection @class([ <label for="{{ $id }}" data-md-selection-row @if ($right) data-md-right @endif>
'flex gap-4', <span data-md-switch @if ($icons) data-md-icons="{{ $icons }}" @endif @if (blank($label) && blank($hint)) class="md-touch-target" @endif>
'items-start' => filled($hint),
'items-center' => blank($hint),
'flex-row-reverse justify-between' => $right,
])>
<span data-switch @if ($icons) data-icons="{{ $icons }}" @endif @class(['touch-target' => blank($label) && blank($hint)])>
<input <input
{{ $attributes->except(['class', 'id', 'wire:key']) }} {{ $attributes->except(['class', 'style', 'id', 'wire:key']) }}
id="{{ $id }}" id="{{ $id }}"
type="checkbox" type="checkbox"
role="switch" role="switch"
/> />
<span data-handle> <span data-md-switch-handle>
@if ($icons) @if ($icons)
<x-livewire-material::icon name="check" class="size-4" optical="20" data-on /> <x-livewire-material::icon name="check" size="16" data-md-switch-on />
<x-livewire-material::icon name="close" class="size-4" optical="20" data-off /> <x-livewire-material::icon name="close" size="16" data-md-switch-off />
@endif @endif
</span> </span>
</span> </span>
@if (filled($label) || filled($hint)) @if (filled($label) || filled($hint))
<span class="min-w-0"> <span data-md-selection-text>
@if (filled($label)) @if (filled($label))
<span class="block type-body-lg text-on-surface" data-selection-label>{{ $label }}</span> <span data-md-selection-label>{{ $label }}</span>
@endif @endif
@if (filled($hint)) @if (filled($hint))
<span class="mt-0.5 block type-body-sm text-on-surface-variant">{{ $hint }}</span> <span data-md-selection-hint>{{ $hint }}</span>
@endif @endif
</span> </span>
@endif @endif
+1 -1
View File
@@ -84,7 +84,7 @@
if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return; if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return;
event.preventDefault(); event.preventDefault();
const url = new URL(result.url, window.location.href); const url = new URL(result.url, window.location.href);
const search = this.$root.querySelector('[data-search]'); const search = this.$root.querySelector('[data-md-search]');
this.query = ''; this.query = '';
if (search) window.Alpine.$data(search).close(); if (search) window.Alpine.$data(search).close();
if (url.pathname === window.location.pathname) { if (url.pathname === window.location.pathname) {
@@ -26,13 +26,13 @@
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-input label="Email" required /> <x-input label="Email" required />
<x-field id="showcase-error" label="Expiry" :messages="['Choose a date in the future.']"> <x-field id="showcase-error" label="Expiry" :messages="['Choose a date in the future.']">
<input id="showcase-error" class="field-control" value="2020-01-01" aria-invalid="true" /> <input id="showcase-error" data-md-field-control value="2020-01-01" aria-invalid="true" />
</x-field> </x-field>
</div> </div>
<div class="grid content-start gap-4"> <div class="grid content-start gap-4">
<x-field id="showcase-error-filled" variant="filled" label="Expiry" :messages="['Choose a date in the future.']"> <x-field id="showcase-error-filled" variant="filled" label="Expiry" :messages="['Choose a date in the future.']">
<input id="showcase-error-filled" class="field-control" value="2020-01-01" aria-invalid="true" /> <input id="showcase-error-filled" data-md-field-control value="2020-01-01" aria-invalid="true" />
</x-field> </x-field>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<x-input size="sm" icon="search" placeholder="Filter" aria-label="Filter" class="w-48" /> <x-input size="sm" icon="search" placeholder="Filter" aria-label="Filter" class="w-48" />
@@ -133,7 +133,7 @@
</div> </div>
<div class="overflow-x-auto rounded-corner-lg border border-structure"> <div class="overflow-x-auto rounded-corner-lg border border-structure">
<table class="w-full type-body-md" data-table> <table class="w-full type-body-md" data-md-table>
<thead> <thead>
<tr> <tr>
<th class="text-start">Class</th> <th class="text-start">Class</th>
+115 -5
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 () { 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(); $page = chipShowcase()->assertNoJavaScriptErrors();
@@ -159,17 +159,127 @@ it('removes an Alpine input chip with Backspace, focusing the chip before it', f
it('scrolls a chip set sideways, fading the edge it can still scroll towards', function () { it('scrolls a chip set sideways, fading the edge it can still scroll towards', function () {
// The scrolling row, inside the component that also holds its scroll buttons. // The scrolling row, inside the component that also holds its scroll buttons.
$row = "document.querySelector('#chips [x-data=\"materialChipSet\"] > [data-chip-set]')"; $row = "document.querySelector('#chips [data-md-chip-set-scroller] > [data-md-chip-set-row]')";
$page = chipShowcase(); $page = chipShowcase();
$page->assertScript("{$row}.scrollWidth > {$row}.clientWidth") $page->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
->assertScript("{$row}.hasAttribute('data-scroll-end') && ! {$row}.hasAttribute('data-scroll-start')") ->assertScript("{$row}.hasAttribute('data-md-scroll-end') && ! {$row}.hasAttribute('data-md-scroll-start')")
->assertScript("getComputedStyle({$row}).flexWrap === 'nowrap'"); ->assertScript("getComputedStyle({$row}).flexWrap === 'nowrap'");
$page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()"); $page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()");
$page->assertScript("Math.abs({$row}.scrollLeft) > 0") $page->assertScript("Math.abs({$row}.scrollLeft) > 0")
->assertScript("{$row}.hasAttribute('data-scroll-start')") ->assertScript("{$row}.hasAttribute('data-md-scroll-start')")
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '1.5rem'"); ->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '24px'");
});
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");
});
function chipScrollProbe(string $dir)
{
Route::middleware('web')->get('/chip-scroll-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html dir="{{ request('dir') }}">
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body>
<div style="width: 320px; padding: 24px">
<x-chip-set aria-label="Sort" scroll>
@foreach (['Newest', 'Oldest', 'Largest', 'Smallest', 'Shared', 'Starred', 'Unopened', 'Expired'] as $sort)
<x-chip type="filter" :label="$sort" :value="$sort" name="sort[]" />
@endforeach
</x-chip-set>
</div>
@livewireScripts
</body>
</html>
BLADE));
return visit("/chip-scroll-probe?dir={$dir}")->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
}
it('shows a scroll button only on the edge the row can still scroll towards, in either direction', function (string $dir) {
$row = "document.querySelector('[data-md-chip-set-row]')";
$shown = fn (string $edge): string => "getComputedStyle(document.querySelector('[data-md-chip-scroll=\"{$edge}\"]')).display === 'grid'";
$page = chipScrollProbe($dir)
->assertScript("{$row}.scrollWidth > {$row}.clientWidth")
->assertScript("! ({$shown('start')}) && ({$shown('end')})");
$page->click('[data-md-chip-scroll="end"]')
->wait(0.8)
->assertScript("Math.abs({$row}.scrollLeft) > 0")
->assertScript("({$shown('start')})");
$page->script("{$row}.scrollTo({ left: ({$row}.scrollWidth) * (getComputedStyle({$row}).direction === 'rtl' ? -1 : 1), behavior: 'instant' })");
$page->wait(0.2)
->assertScript("({$shown('start')}) && ! ({$shown('end')})")
// The end button sits over the end edge: on the right in LTR, on the left in RTL.
->assertScript("(() => { const start = document.querySelector('[data-md-chip-scroll=\"start\"]').getBoundingClientRect(); const box = {$row}.parentElement.getBoundingClientRect(); return '{$dir}' === 'rtl' ? Math.abs(start.right - box.right) < 1 : Math.abs(start.left - box.left) < 1; })()");
})->with(['ltr', 'rtl']);
it('walks a chip set with the arrow keys as one tab stop, and scrolls the focused chip clear of the buttons', function () {
$inputs = "[...document.querySelectorAll('[data-md-chip-set-row] input')]";
$page = chipScrollProbe('ltr')
->assertScript("{$inputs}.filter((input) => input.tabIndex === 0).length === 1");
$page->script("{$inputs}[0].focus()");
$page->keys(':focus', 'ArrowRight')
->assertScript("document.activeElement === {$inputs}[1]")
->assertScript("{$inputs}.filter((input) => input.tabIndex === 0).map((input) => input.value).join() === 'Oldest'");
$page->keys(':focus', 'End')
->assertScript("document.activeElement === {$inputs}.at(-1)")
->wait(0.8)
// The focused chip stands clear of the start button and its fade.
->assertScript("(() => { const chip = document.activeElement.closest('[data-md-chip]').getBoundingClientRect(); const button = document.querySelector('[data-md-chip-scroll=\"start\"]').getBoundingClientRect(); return getComputedStyle(document.querySelector('[data-md-chip-scroll=\"start\"]')).display === 'grid' && chip.left >= button.right; })()");
$page->keys(':focus', 'Home')
->assertScript("document.activeElement === {$inputs}[0]");
$page->keys(':focus', 'ArrowLeft')
->assertScript("document.activeElement === {$inputs}.at(-1)");
}); });
+23
View File
@@ -48,6 +48,13 @@ function dataProbe()
</x-table> </x-table>
{{ $files->links() }} {{ $files->links() }}
<x-table dense id="dense-table">
<tbody>
<tr><td>dense-a.zip</td><td>1</td></tr>
<tr aria-selected="true"><td>dense-b.zip</td><td>2</td></tr>
</tbody>
</x-table>
</div> </div>
BLADE); BLADE);
app('view')->addNamespace('probe', $views); app('view')->addNamespace('probe', $views);
@@ -103,3 +110,19 @@ it('pages through Livewire results and marks the current page', function () {
$page->resize(400, 800) $page->resize(400, 800)
->assertSee('Page 2 of 3'); ->assertSee('Page 2 of 3');
}); });
it('draws 52px rows, and 36px ones only in a table that asks to be dense', function () {
$row = fn (string $table): string => "document.querySelector('{$table} tbody tr').getBoundingClientRect().height";
dataProbe()
->assertScript("Math.abs(({$row('[data-md-table]:not([data-md-dense])')}) - 52) <= 1")
->assertScript("Math.abs(({$row('#dense-table')}) - 36) <= 1")
->assertScript("getComputedStyle(document.querySelector('#dense-table tr[aria-selected=\"true\"]')).backgroundColor !== 'rgba(0, 0, 0, 0)'")
->assertScript("getComputedStyle(document.querySelector('[data-md-table] thead th')).borderBottomWidth === '1px'");
});
it('lets the sort button be pressed anywhere in its 48px target', function () {
dataProbe()
->assertScript("(() => { const button = document.querySelector('#by-size [data-md-sort-header-button]'); const after = getComputedStyle(button, '::after'); return button.getBoundingClientRect().height < 48 && after.minHeight === '48px' && after.minWidth === '48px'; })()")
->assertScript("(() => { const button = document.querySelector('#by-size [data-md-sort-header-button]'); const box = button.getBoundingClientRect(); const probe = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2 - 22); return probe === button; })()");
});
+5 -5
View File
@@ -183,7 +183,7 @@ it('opens the docked picker under its field and walks the grid from the keyboard
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]') ->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'") ->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'")
->assertAttribute('#expires-field', 'aria-expanded', 'true') ->assertAttribute('#expires-field', 'aria-expanded', 'true')
->assertScript("(() => { const field = document.querySelector('#expires-field').closest('.field-box').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()") ->assertScript("(() => { const field = document.querySelector('#expires-field').closest('[data-md-field-box]').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()")
->assertScript(focusedDay('2026-09-13')); ->assertScript(focusedDay('2026-09-13'));
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14')); $page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14'));
@@ -395,15 +395,15 @@ it('opens a docked picker as a modal one on a compact window', function () {
it('empties a date, or both ends of a range, with its clear button', function () { it('empties a date, or both ends of a range, with its clear button', function () {
$page = dateProbe() $page = dateProbe()
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('.field').querySelector('[data-field-clear]')).display !== 'none'"); ->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('[data-md-field]').querySelector('[data-md-field-clear]')).display !== 'none'");
$page->click('[data-datepicker]:has(#expires-field) [data-field-clear]') $page->click('[data-datepicker]:has(#expires-field) [data-md-field-clear]')
->assertScript("document.querySelector('#expires').textContent === ''") ->assertScript("document.querySelector('#expires').textContent === ''")
->assertValue('#expires-field', '') ->assertValue('#expires-field', '')
->assertScript("document.activeElement.id === 'expires-field'") ->assertScript("document.activeElement.id === 'expires-field'")
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'"); ->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-md-field-clear]')).display === 'none'");
$page->click('[data-datepicker]:has(#trip-field) [data-field-clear]') $page->click('[data-datepicker]:has(#trip-field) [data-md-field-clear]')
->assertSeeIn('#trip', '{"start":null,"end":null}') ->assertSeeIn('#trip', '{"start":null,"end":null}')
->assertValue('#trip-field', ''); ->assertValue('#trip-field', '');
}); });
+148 -8
View File
@@ -47,6 +47,15 @@ class FieldProbe extends Component
<x-radio label="Audience" wire:model.live="audience" :options="[['id' => 'anyone', 'name' => 'Anyone'], ['id' => 'team', 'name' => 'Team']]" /> <x-radio label="Audience" wire:model.live="audience" :options="[['id' => 'anyone', 'name' => 'Anyone'], ['id' => 'team', 'name' => 'Team']]" />
<x-toggle id="public" label="Public" wire:model.live="public" /> <x-toggle id="public" label="Public" wire:model.live="public" />
<x-textarea id="message" label="Message" rows="2" max-rows="4" wire:model="message" /> <x-textarea id="message" label="Message" rows="2" max-rows="4" wire:model="message" />
<x-input id="bio-field" label="Bio" maxlength="10" counter />
<x-input id="off-field" label="Off" value="Not editable" disabled />
<x-input id="plain-name-field" label="Name, plain" wire:model="name" />
<x-file id="upload-field" label="Upload" />
<div style="display: flex; gap: 48px; padding: 24px">
<x-checkbox id="bare-check" aria-label="Select every row" />
<x-toggle id="bare-switch" aria-label="Bare switch" />
<x-radio name="bare-radio" :options="[['id' => 'only', 'name' => '']]" />
</div>
<x-button label="Save" wire:click="save" /> <x-button label="Save" wire:click="save" />
</div> </div>
@@ -95,7 +104,7 @@ it('clears a field and tells Livewire', function () {
fieldProbe() fieldProbe()
->type('#name-field', 'Holiday') ->type('#name-field', 'Holiday')
->assertSeeIn('#name', 'Holiday') ->assertSeeIn('#name', 'Holiday')
->click('[data-field-clear]') ->click('[data-md-field-clear]')
->assertScript("document.querySelector('#name-field').value === ''") ->assertScript("document.querySelector('#name-field').value === ''")
->assertScript("document.querySelector('#name').textContent === ''") ->assertScript("document.querySelector('#name').textContent === ''")
->assertScript("document.activeElement.id === 'name-field'"); ->assertScript("document.activeElement.id === 'name-field'");
@@ -106,7 +115,7 @@ it('copies a field\'s value and says so', function () {
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")"); $page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
$page->click('[data-field-copy]') $page->click('[data-md-field-copy]')
->assertScript("window.copied === 'Holiday'") ->assertScript("window.copied === 'Holiday'")
->assertSee('Copied to the clipboard'); ->assertSee('Copied to the clipboard');
}); });
@@ -114,10 +123,10 @@ it('copies a field\'s value and says so', function () {
it('shows and hides a password', function () { it('shows and hides a password', function () {
fieldProbe() fieldProbe()
->type('#password-field', 'secret') ->type('#password-field', 'secret')
->click('[data-field-reveal]') ->click('[data-md-field-reveal]')
->assertScript("document.querySelector('#password-field').type === 'text'") ->assertScript("document.querySelector('#password-field').type === 'text'")
->assertAttribute('[data-field-reveal]', 'aria-label', 'Hide password') ->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password')
->click('[data-field-reveal]') ->click('[data-md-field-reveal]')
->assertScript("document.querySelector('#password-field').type === 'password'"); ->assertScript("document.querySelector('#password-field').type === 'password'");
}); });
@@ -126,7 +135,7 @@ it('shows the server\'s error in place of the hint', function () {
->click('button:has-text("Save")') ->click('button:has-text("Save")')
->assertSee('The name field is required.') ->assertSee('The name field is required.')
->assertAttribute('#name-field', 'aria-invalid', 'true') ->assertAttribute('#name-field', 'aria-invalid', 'true')
->assertScript("document.querySelector('#name-field').closest('.field').hasAttribute('data-invalid')"); ->assertScript("document.querySelector('#name-field').closest('[data-md-field]').hasAttribute('data-md-invalid')");
}); });
it('binds a select to Livewire', function () { it('binds a select to Livewire', function () {
@@ -142,12 +151,12 @@ it('keeps a partly ticked checkbox in step with the server', function () {
$page->click('label[for="file-a"]') $page->click('label[for="file-a"]')
->assertSeeIn('#files', 'a') ->assertSeeIn('#files', 'a')
->assertScript("{$all}.hasAttribute('data-indeterminate') && {$all}.indeterminate"); ->assertScript("{$all}.hasAttribute('data-md-indeterminate') && {$all}.indeterminate");
$page->click('label[for="file-b"]') $page->click('label[for="file-b"]')
->click('label[for="file-c"]') ->click('label[for="file-c"]')
->assertSeeIn('#files', 'a,b,c') ->assertSeeIn('#files', 'a,b,c')
->assertScript("! {$all}.hasAttribute('data-indeterminate') && ! {$all}.indeterminate"); ->assertScript("! {$all}.hasAttribute('data-md-indeterminate') && ! {$all}.indeterminate");
}); });
it('moves between radio buttons with the arrow keys', function () { it('moves between radio buttons with the arrow keys', function () {
@@ -183,3 +192,134 @@ it('grows a textarea with its text up to its maximum', function () {
->assertScript("Math.abs(({$height}) - ({$three} + 24)) < 2") ->assertScript("Math.abs(({$height}) - ({$three} + 24)) < 2")
->assertScript("document.querySelector('#message').scrollHeight > document.querySelector('#message').clientHeight"); ->assertScript("document.querySelector('#message').scrollHeight > document.querySelector('#message').clientHeight");
}); });
it('pairs the server\'s error with a trailing error icon in the error colour', function () {
$icon = "document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-error]')";
fieldProbe()
->assertScript("{$icon} === null")
->click('button:has-text("Save")')
->assertSee('The name field is required.')
// A field that trails with buttons of its own gives the icon's place to them.
->assertScript("document.querySelector('[data-md-field]:has(#name-field)').hasAttribute('data-md-invalid') && document.querySelector('[data-md-field]:has(#name-field) [data-md-field-error]') === null")
->assertScript("{$icon}.getAttribute('aria-label') === 'Error' && {$icon}.getAttribute('role') === 'img'")
->assertScript("{$icon}.getBoundingClientRect().width === 24")
->assertScript("getComputedStyle({$icon}).color === getComputedStyle(document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-support]')).color");
});
it('lights an enabled field\'s outline on hover, and never a disabled one\'s', function () {
$edge = fn (string $id): string => "getComputedStyle(document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-outline]')).borderTopColor";
$page = fieldProbe();
$resting = $page->script("({$edge('name-field')})");
$disabled = $page->script("({$edge('off-field')})");
$page->hover('[data-md-field]:has(#name-field) [data-md-field-box]')
->assertScript("({$edge('name-field')}) !== '{$resting}'");
$page->hover('[data-md-field]:has(#off-field) [data-md-field-box]')
->assertScript("({$edge('off-field')}) === '{$disabled}'")
// The disabled edge is on-surface at 12%, which is translucent.
->assertScript("/rgba?\\(.*,\\s*0?\\.\\d+\\)|color\\(srgb .* \\/ 0?\\.\\d+\\)/.test('{$disabled}')");
});
it('counts the characters as they are typed, marks a count past the maximum and says it once typing stops', function () {
$counter = "document.querySelector('[data-md-field]:has(#bio-field) [data-md-field-counter]')";
$page = fieldProbe()
->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '0/10'")
->type('#bio-field', 'Hello')
->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '5/10'")
->assertScript("! {$counter}.hasAttribute('data-md-over')");
$within = $page->script("getComputedStyle({$counter}).color");
// maxlength stops typing at the maximum, but a value put in from elsewhere can pass it.
$page->script("(() => { const input = document.querySelector('#bio-field'); input.value = 'Hello there!'; input.dispatchEvent(new Event('input', { bubbles: true })); })()");
$page->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '12/10'")
->assertScript("{$counter}.hasAttribute('data-md-over')")
->assertScript("getComputedStyle({$counter}).color !== '{$within}'")
->assertScript("{$counter}.querySelector('[aria-live]').textContent === ''")
->wait(1.3)
->assertScript("{$counter}.querySelector('[aria-live=\\'polite\\']').textContent === 'Character count, 12/10'");
});
function fieldWidthProbe(int $width)
{
Route::middleware('web')->get('/field-width-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
<style>.probe-narrow { max-width: 200px; }</style>
</head>
<body>
<div style="width: 1400px">
<x-input id="bounded" label="Bounded" />
<x-input id="narrow" label="Narrow" class="probe-narrow" />
<x-input id="full" label="Full" full />
</div>
</body>
</html>
BLADE));
return visit('/field-width-probe')->resize($width, 800)->waitForEvent('networkidle');
}
it('bounds a field to 40rem from 600px, unless the caller\'s width rule or full says otherwise', function () {
$width = fn (string $id): string => "document.querySelector('[data-md-field]:has(#{$id})').getBoundingClientRect().width";
fieldWidthProbe(599)
->assertScript("({$width('bounded')}) === 1400")
->assertScript("({$width('narrow')}) === 200")
->assertScript("({$width('full')}) === 1400");
fieldWidthProbe(600)
->assertScript("({$width('bounded')}) === 640")
->assertScript("({$width('narrow')}) === 200")
->assertScript("({$width('full')}) === 1400");
});
it('draws the field at M3\'s geometry and the file picker\'s button as a tonal pill', function () {
$box = fn (string $id): string => "document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-box]').getBoundingClientRect()";
fieldProbe()
->assertScript("({$box('name-field')}).height === 56")
// The control starts after the box's 16px padding.
->assertScript("document.querySelector('#name-field').getBoundingClientRect().left - ({$box('name-field')}).left === 16")
->assertScript("getComputedStyle(document.querySelector('#upload-field'), '::file-selector-button').height === '32px'")
->assertScript("getComputedStyle(document.querySelector('#upload-field'), '::file-selector-button').borderTopLeftRadius !== '0px'")
->assertScript("getComputedStyle(document.querySelector('#upload-field')).color === 'rgba(0, 0, 0, 0)'");
});
it('lets a selection control without a label be pressed at the edge of its 48px target', function () {
// A press 22px from the centre, past the drawn box but inside M3's 48px, lands on the control.
$pressAtEdge = fn (string $input, string $dx, string $dy): string => "(() => {
const input = document.querySelector('{$input}');
input.scrollIntoView({ block: 'center' });
const box = input.parentElement.getBoundingClientRect();
const target = document.elementFromPoint(box.left + box.width / 2 + {$dx}, box.top + box.height / 2 + {$dy});
target.click();
return input.checked;
})()";
fieldProbe()
->assertScript($pressAtEdge('#bare-check', '22', '0'))
->assertScript($pressAtEdge('#bare-switch', '0', '22'))
->assertScript($pressAtEdge('input[name=\"bare-radio\"]', '0', '-22'));
});
it('draws the switch\'s handle at SwitchTokens\' sizes and centres, off and on', function () {
$handle = "(() => { const track = document.querySelector('[data-md-switch]:has(#public)').getBoundingClientRect(); const handle = document.querySelector('[data-md-switch]:has(#public) [data-md-switch-handle]').getBoundingClientRect(); return [track.width, track.height, handle.width, Math.round(handle.left + handle.width / 2 - track.left), Math.round(handle.top + handle.height / 2 - track.top)].join(); })()";
$page = fieldProbe()->assertScript("{$handle} === '52,32,16,16,16'");
$page->keys('#public', 'Space')
->assertSeeIn('#public-state', 'true')
->wait(0.7)
->assertScript("{$handle} === '52,32,24,36,16'");
});
+84 -4
View File
@@ -14,6 +14,8 @@ class PickProbe extends Component
public string $query = ''; public string $query = '';
public string $iconQuery = '';
public function render(): string public function render(): string
{ {
return <<<'BLADE' return <<<'BLADE'
@@ -35,6 +37,20 @@ class PickProbe extends Component
<x-slot:empty>No files match.</x-slot:empty> <x-slot:empty>No files match.</x-slot:empty>
</x-search> </x-search>
<div id="toolbar" style="display: flex; align-items: center; gap: 8px">
<span>Files</span>
<x-search id="find-icon" trigger="icon" label="Find files" wire:model.live="iconQuery">
@foreach (array_filter(['holiday.zip', 'contract.pdf'], fn ($file) => str_contains($file, $iconQuery)) as $file)
<button type="button" wire:key="icon-result-{{ $file }}">{{ $file }}</button>
@endforeach
<x-slot:suggestions>
<button type="button">Recent: holiday.zip</button>
<button type="button">Recent: review.mp4</button>
<button type="button">Recent: notes.txt</button>
</x-slot:suggestions>
</x-search>
</div>
</div> </div>
BLADE; BLADE;
} }
@@ -149,7 +165,7 @@ it('says so when nothing matches, and closes on a press outside or a chosen resu
// The docked view opens over a scrim that covers the rest of the page (inputs.md IN-09), so a // The docked view opens over a scrim that covers the rest of the page (inputs.md IN-09), so a
// press outside the view lands on the scrim. // press outside the view lands on the scrim.
$page->click('[data-search-scrim]') $page->click('[data-md-search]:has(#find) [data-md-search-scrim]')
->assertScript("getComputedStyle({$view}).display === 'none'"); ->assertScript("getComputedStyle({$view}).display === 'none'");
$page->click('#find') $page->click('#find')
@@ -163,8 +179,72 @@ it('takes the whole screen on a compact window, with a back arrow', function ()
$page = pickProbe()->resize(400, 800); $page = pickProbe()->resize(400, 800);
$page->click('#find') $page->click('#find')
->assertScript("document.querySelector('[data-search]').hasAttribute('data-full-screen')") ->assertScript("document.querySelector('[data-md-search]').hasAttribute('data-md-full-screen')")
->assertScript("(() => { const box = document.querySelector('#find-view').getBoundingClientRect(); return box.top === 0 && box.width === 400; })()") ->assertScript("(() => { const box = document.querySelector('#find-view').getBoundingClientRect(); return box.top === 0 && box.width === 400; })()")
->click('[data-search-back]') ->click('[data-md-search]:has(#find) [data-md-search-back]')
->assertScript("! document.querySelector('[data-search]').hasAttribute('data-open')"); ->assertScript("! document.querySelector('[data-md-search]').hasAttribute('data-md-open')");
});
it('says how many results there are, politely, as they change', function () {
$status = "document.querySelector('[data-md-search]:has(#find) [data-md-search-status]')";
$page = pickProbe()
->assertScript("{$status}.getAttribute('aria-live') === 'polite' && {$status}.getAttribute('aria-atomic') === 'true'")
->click('#find')
->assertScript("{$status}.textContent === '3 results'");
$page->type('#find', 'con')
->assertSeeIn('#query', 'con')
->assertScript("{$status}.textContent === '1 result'");
$page->type('#find', 'zzz')
->assertSeeIn('#query', 'zzz')
->assertScript("{$status}.textContent === 'No results'");
// Closed from a result (Escape in the field itself would first clear the query), the region
// falls silent.
$page->type('#find', 'hol')
->assertSeeIn('#query', 'hol')
->assertScript("{$status}.textContent === '1 result'");
$page->keys('#find', 'ArrowDown')
->assertScript("document.activeElement.textContent.trim() === 'holiday.zip'");
$page->keys(':focus', 'Escape')
->assertScript("{$status}.textContent === ''");
});
it('expands the search icon into the full-screen view, and hands focus back to the icon on close', function () {
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$trigger = "{$root}.querySelector('[data-md-search-trigger]')";
$page = pickProbe()
->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-bar]')).display === 'none'")
->assertScript("{$root}.getBoundingClientRect().width === 48 && {$trigger}.getAttribute('aria-expanded') === 'false'");
$page->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.hasAttribute('data-md-full-screen')")
->assertScript("document.activeElement.id === 'find-icon'")
->assertScript("(() => { const view = {$root}.querySelector('[data-md-search-view]').getBoundingClientRect(); return view.top === 0 && view.left === 0 && view.width === innerWidth; })()")
// The toolbar does not shift under the expanded search.
->assertScript("{$root}.getBoundingClientRect().width === 48");
$page->keys('#find-icon', 'Escape')
->assertScript("! {$root}.hasAttribute('data-md-open')")
->assertScript("document.activeElement === {$trigger}")
->assertScript("getComputedStyle({$trigger}).display !== 'none'");
});
it('shows the suggestions until the first key, then the results, and counts whichever is on screen', function () {
$root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$shown = fn (string $list): string => "{$root}.querySelector('[data-md-search-{$list}]').checkVisibility()";
$page = pickProbe()
->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
->assertScript("({$shown('suggestions')}) && ! ({$shown('results')})")
->assertScript("{$root}.querySelector('[data-md-search-status]').textContent === '3 suggestions'");
$page->type('#find-icon', 'h')
->assertScript("! ({$shown('suggestions')}) && ({$shown('results')})")
->assertScript("{$root}.querySelector('[data-md-search-status]').textContent === '1 result'");
}); });
+2 -2
View File
@@ -28,7 +28,7 @@ it('finds a component from anywhere and opens its section', function () {
->assertScript("document.activeElement.id === 'showcase-search'"); ->assertScript("document.activeElement.id === 'showcase-search'");
$page->type('#showcase-search', 'datepicker') $page->type('#showcase-search', 'datepicker')
->assertSeeIn('[data-search-view]', '<x-datepicker>'); ->assertSeeIn('[data-md-search-view]', '<x-datepicker>');
$page->keys('#showcase-search', 'Enter') $page->keys('#showcase-search', 'Enter')
->assertScript("location.pathname.endsWith('/material/pickers')") ->assertScript("location.pathname.endsWith('/material/pickers')")
@@ -51,5 +51,5 @@ it('says so when nothing matches', function () {
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'") ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->click('#showcase-search') ->click('#showcase-search')
->type('#showcase-search', 'zzzz') ->type('#showcase-search', 'zzzz')
->assertSeeIn('[data-search-view]', 'Nothing matches'); ->assertSeeIn('[data-md-search-view]', 'Nothing matches');
}); });
+99 -20
View File
@@ -75,7 +75,7 @@ function onSlider(string $label, string $body): string
{ {
return <<<JS return <<<JS
(async () => { (async () => {
const root = [...document.querySelectorAll('[data-slider]')] const root = [...document.querySelectorAll('[data-md-slider-control]')]
.find((slider) => slider.parentElement.querySelector(':scope > label, :scope > span')?.textContent.trim() === '{$label}') .find((slider) => slider.parentElement.querySelector(':scope > label, :scope > span')?.textContent.trim() === '{$label}')
root.scrollIntoView({ block: 'center' }) root.scrollIntoView({ block: 'center' })
const inputs = [...root.querySelectorAll('input[type="range"]')] const inputs = [...root.querySelectorAll('input[type="range"]')]
@@ -84,7 +84,7 @@ function onSlider(string $label, string $body): string
const near = (a, b, tolerance = 1) => Math.abs(a - b) <= tolerance const near = (a, b, tolerance = 1) => Math.abs(a - b) <= tolerance
const left = (selector) => Number.parseFloat(root.querySelector(selector).style.left) const left = (selector) => Number.parseFloat(root.querySelector(selector).style.left)
const segment = (name) => { const segment = (name) => {
const element = root.querySelector(`[data-segment="\${name}"]`) const element = root.querySelector(`[data-md-slider-segment="\${name}"]`)
const from = Number.parseFloat(element.style.left) const from = Number.parseFloat(element.style.left)
return element.hidden ? null : { from, to: from + Number.parseFloat(element.style.width) } return element.hidden ? null : { from, to: from + Number.parseFloat(element.style.width) }
} }
@@ -106,18 +106,18 @@ it('moves with the keyboard, and the drawing follows', function () {
$page->keys(':focus', ['ArrowRight', 'ArrowRight']) $page->keys(':focus', ['ArrowRight', 'ArrowRight'])
->assertScript(onSlider('Volume', <<<'JS' ->assertScript(onSlider('Volume', <<<'JS'
return inputs[0].value === '42' return inputs[0].value === '42'
&& near(left('[data-handle="start"]'), width * 0.42) && near(left('[data-md-slider-handle="start"]'), width * 0.42)
&& near(segment('active').to, width * 0.42 - 8) && near(segment('active').to, width * 0.42 - 8)
&& near(segment('end').from, width * 0.42 + 8) && near(segment('end').from, width * 0.42 + 8)
&& root.querySelector('[data-handle="start"]').hasAttribute('data-focused') && root.querySelector('[data-md-slider-handle="start"]').hasAttribute('data-md-focused')
&& root.querySelector('[data-value-label]').textContent === '42' && root.querySelector('[data-md-slider-value]').textContent === '42'
JS)); JS));
$page->keys(':focus', 'PageUp') $page->keys(':focus', 'PageUp')
->assertScript(onSlider('Volume', "return inputs[0].value === '52' && near(left('[data-handle=\"start\"]'), width * 0.52)")); ->assertScript(onSlider('Volume', "return inputs[0].value === '52' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.52)"));
$page->keys(':focus', 'End') $page->keys(':focus', 'End')
->assertScript(onSlider('Volume', "return inputs[0].value === '100' && segment('end') === null && ! root.querySelector('[data-stop=\"end\"]').checkVisibility()")); ->assertScript(onSlider('Volume', "return inputs[0].value === '100' && segment('end') === null && ! root.querySelector('[data-md-slider-stop=\"end\"]').checkVisibility()"));
$page->keys(':focus', 'Home') $page->keys(':focus', 'Home')
->assertScript(onSlider('Volume', "return inputs[0].value === '0' && segment('active') === null && near(segment('end').from, 8)")); ->assertScript(onSlider('Volume', "return inputs[0].value === '0' && segment('active') === null && near(segment('end').from, 8)"));
@@ -130,14 +130,14 @@ it('follows a pointer drag with a narrowed handle and its value label', function
$page->assertScript(onSlider('Volume', <<<'JS' $page->assertScript(onSlider('Volume', <<<'JS'
await pause(450) await pause(450)
const thumb = root.querySelector('[data-handle="start"]') const thumb = root.querySelector('[data-md-slider-handle="start"]')
const label = thumb.querySelector('[data-value-label]') const label = thumb.querySelector('[data-md-slider-value]')
return inputs[0].value === '75' return inputs[0].value === '75'
&& label.textContent === '75' && label.textContent === '75'
&& getComputedStyle(label).opacity === '1' && getComputedStyle(label).opacity === '1'
&& thumb.hasAttribute('data-pressed') && thumb.hasAttribute('data-md-pressed')
&& thumb.firstElementChild.offsetWidth === 2 && thumb.firstElementChild.offsetWidth === 2
&& near(left('[data-handle="start"]'), width * 0.75) && near(left('[data-md-slider-handle="start"]'), width * 0.75)
&& document.activeElement === inputs[0] && document.activeElement === inputs[0]
JS)); JS));
@@ -145,13 +145,13 @@ it('follows a pointer drag with a narrowed handle and its value label', function
$page->assertScript(onSlider('Volume', <<<'JS' $page->assertScript(onSlider('Volume', <<<'JS'
await pause(450) await pause(450)
const thumb = root.querySelector('[data-handle="start"]') const thumb = root.querySelector('[data-md-slider-handle="start"]')
return inputs[0].value === '75' return inputs[0].value === '75'
&& window.__changed === 1 && window.__changed === 1
&& ! thumb.hasAttribute('data-pressed') && ! thumb.hasAttribute('data-md-pressed')
&& ! thumb.hasAttribute('data-focused') && ! thumb.hasAttribute('data-md-focused')
&& thumb.firstElementChild.offsetWidth === 4 && thumb.firstElementChild.offsetWidth === 4
&& getComputedStyle(thumb.querySelector('[data-value-label]')).opacity === '0' && getComputedStyle(thumb.querySelector('[data-md-slider-value]')).opacity === '0'
JS)); JS));
}); });
@@ -163,7 +163,7 @@ it('keeps a range\'s handles from crossing, by pointer and by keyboard', functio
$page->assertScript(onSlider('Price', <<<'JS' $page->assertScript(onSlider('Price', <<<'JS'
return inputs[0].value === '60' && inputs[1].value === '60' return inputs[0].value === '60' && inputs[1].value === '60'
&& near(left('[data-handle="start"]'), left('[data-handle="end"]')) && near(left('[data-md-slider-handle="start"]'), left('[data-md-slider-handle="end"]'))
&& segment('active') === null && segment('active') === null
JS)); JS));
@@ -183,7 +183,7 @@ it('fills a centred slider from the middle', function () {
$page = sliderShowcase(); $page = sliderShowcase();
$page->assertScript(onSlider('Balance', <<<'JS' $page->assertScript(onSlider('Balance', <<<'JS'
const handle = left('[data-handle="start"]') const handle = left('[data-md-slider-handle="start"]')
return inputs[0].value === '15' return inputs[0].value === '15'
&& near(handle, width * 0.65) && near(handle, width * 0.65)
&& near(segment('start').to, width / 2 - 6) && near(segment('start').to, width / 2 - 6)
@@ -209,7 +209,7 @@ it('follows a value x-model sets from outside', function () {
$page->script("[...document.querySelectorAll('#sliders button')].find((button) => button.textContent.trim() === 'Set 80').click()"); $page->script("[...document.querySelectorAll('#sliders button')].find((button) => button.textContent.trim() === 'Set 80').click()");
$page->assertScript(onSlider('Bound volume', "return inputs[0].value === '80' && near(left('[data-handle=\"start\"]'), width * 0.8)")); $page->assertScript(onSlider('Bound volume', "return inputs[0].value === '80' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.8)"));
}); });
it('sends live values to Livewire, is not moved back by a render mid-drag, and moves when the server sets a value', function () { it('sends live values to Livewire, is not moved back by a render mid-drag, and moves when the server sets a value', function () {
@@ -237,7 +237,7 @@ it('sends live values to Livewire, is not moved back by a render mid-drag, and m
// Both round trips back: the handle is where it was dragged, and so is the property. // Both round trips back: the handle is where it was dragged, and so is the property.
$page->assertScript(onSlider('Volume', <<<'JS' $page->assertScript(onSlider('Volume', <<<'JS'
return window.__renders >= 2 && inputs[0].value === '70' && near(left('[data-handle="start"]'), width * 0.7) return window.__renders >= 2 && inputs[0].value === '70' && near(left('[data-md-slider-handle="start"]'), width * 0.7)
JS)) JS))
->assertSeeIn('#volume', '70'); ->assertSeeIn('#volume', '70');
@@ -245,10 +245,89 @@ it('sends live values to Livewire, is not moved back by a render mid-drag, and m
$page->click('button:has-text("Maximise")') $page->click('button:has-text("Maximise")')
->assertSeeIn('#volume', '90') ->assertSeeIn('#volume', '90')
->assertScript(onSlider('Volume', "return inputs[0].value === '90' && near(left('[data-handle=\"start\"]'), width * 0.9) && root.querySelector('[data-value-label]').textContent === '90'")); ->assertScript(onSlider('Volume', "return inputs[0].value === '90' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.9) && root.querySelector('[data-md-slider-value]').textContent === '90'"));
$page->script(onSlider('Price', 'inputs[1].focus()')); $page->script(onSlider('Price', 'inputs[1].focus()'));
$page->keys(':focus', 'ArrowLeft') $page->keys(':focus', 'ArrowLeft')
->assertSeeIn('#price', '20,79'); ->assertSeeIn('#price', '20,79');
}); });
it('centres the handle on the track at every size', function () {
$page = sliderShowcase();
foreach (['Volume', 'Small (24px)', 'Medium (40px)', 'Extra large (96px)'] as $label) {
$page->assertScript(onSlider($label, <<<'JS'
const thumb = root.querySelector('[data-md-slider-thumb]').getBoundingClientRect()
const track = root.querySelector('[data-md-slider-track]').getBoundingClientRect()
const box = root.getBoundingClientRect()
return near(thumb.top + thumb.height / 2, track.top + track.height / 2)
&& near(track.top + track.height / 2, box.top + box.height / 2)
&& thumb.height === box.height - (box.height === 48 ? 4 : 0)
JS));
}
});
it('moves by the large interval with an arrow while Space is held', function () {
$page = sliderShowcase();
$page->script(onSlider('Volume', "inputs[0].focus(); inputs[0].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', bubbles: true, cancelable: true }))"));
$page->keys(':focus', 'ArrowRight')
->assertScript(onSlider('Volume', "return inputs[0].value === '50' && near(left('[data-md-slider-handle=\"start\"]'), width * 0.5)"));
$page->script(onSlider('Volume', "inputs[0].dispatchEvent(new KeyboardEvent('keyup', { key: ' ', code: 'Space', bubbles: true }))"));
$page->keys(':focus', 'ArrowRight')
->assertScript(onSlider('Volume', "return inputs[0].value === '51'"));
});
it('stands a slider up: dragged along Y, moved by Up, Down, Home and End, its label beside the handle', function () {
$page = sliderShowcase();
// Where the handle sits along a vertical slider, measured up from its bottom edge.
$vertical = <<<'JS'
const controls = [...document.querySelectorAll('[data-md-slider][data-md-orientation="vertical"] [data-md-slider-control]')]
const slider = controls.find((control) => control.parentElement.querySelector(':scope > label')?.textContent.trim() === 'Warmth')
slider.scrollIntoView({ block: 'center' })
const input = slider.querySelector('input[type="range"]')
const length = slider.clientHeight - 4
const box = () => slider.getBoundingClientRect()
const thumb = () => slider.querySelector('[data-md-slider-thumb]').getBoundingClientRect()
const up = () => box().bottom - 2 - (thumb().top + thumb().height / 2)
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const pointer = (type, fraction) => (type === 'pointerdown' ? slider : window).dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, clientX: box().left + box().width / 2, clientY: box().bottom - 2 - length * fraction, button: 0, pointerId: 1, pointerType: 'mouse' }))
JS;
$page->assertScript("(async () => { {$vertical}; return input.value === '6' && input.getAttribute('aria-orientation') === 'vertical' && box().height > box().width })()");
// Warmth has ten steps on ticks, so a drag to 30% of the way up lands on 3.
$page->script("(async () => { {$vertical}; pointer('pointerdown', 0.9); pointer('pointermove', 0.3) })()");
$page->assertScript("(async () => { {$vertical}; await pause(400);
const label = slider.querySelector('[data-md-slider-value]').getBoundingClientRect()
const handle = thumb()
return input.value === '3'
&& slider.querySelector('[data-md-slider-value]').textContent === '3'
// Beside the handle, not above it: level with it, and off to one side.
&& Math.abs((label.top + label.height / 2) - (handle.top + handle.height / 2)) < 2
&& (label.right <= handle.left || label.left >= handle.right)
})()");
$page->script("(async () => { {$vertical}; pointer('pointerup', 0.3); input.focus() })()");
$page->keys(':focus', 'ArrowUp')
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '4' })()");
$page->keys(':focus', 'ArrowDown')
->keys(':focus', 'ArrowDown')
->assertScript("(async () => { {$vertical}; return input.value === '2' })()");
$page->keys(':focus', 'End')
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '10' && up() > length - 2 })()");
$page->keys(':focus', 'Home')
->assertScript("(async () => { {$vertical}; await pause(400); return input.value === '0' && up() < 2 })()");
});
+102 -81
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 () { 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" />'); $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')) expect(chipTag('<x-chip label="Add to calendar" icon="event" class="me-2" />', 'button'))
->toContain('data-chip="assist"') ->toBe('<button data-md-chip="assist" data-md-icon-start="" type="button" class="me-2">')
->toContain('type="button"') ->and(chipTag('<x-chip label="Add to calendar" icon-right="arrow_drop_down" />', 'button'))
->toContain('h-8') ->toContain('data-md-icon-end=""')
->toContain('rounded-corner-sm') ->not->toContain('data-md-icon-start')
->toContain('type-label-lg')
->toContain('border-outline-variant text-on-surface')
->toContain('ps-1.75 pe-3.75')
->and($html) ->and($html)
->toContain('me-2 size-4.5 text-primary') ->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-icon)[^>]*>/')
->toContain('ms-2 size-4.5 text-primary') ->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-trailing)[^>]*>/')
->toContain('Add to calendar'); ->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 () { 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 () { 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 () { it('draws an elevated chip on surface-container-low at elevation 1', function () {
expect(chipTag('<x-chip label="Share" elevated />', 'button')) expect(chipTag('<x-chip label="Share" elevated />', 'button'))->toContain('data-md-elevated=""')
->toContain('border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2') ->and(chipTag('<x-chip label="Share" />', 'button'))->not->toContain('data-md-elevated')
->not->toContain('border-outline-variant'); // 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 () { it('greys a disabled chip, flat or elevated', function () {
expect(chipTag('<x-chip label="Share" icon="share" disabled />', 'button')) expect(chipTag('<x-chip label="Share" icon="share" disabled />', 'button'))
->toContain('disabled') ->toContain('data-md-disabled=""')
->toContain('border-on-surface/12 text-on-surface/38') ->toMatch('/\sdisabled="disabled"/')
->and(chipTag('<x-chip label="Share" elevated disabled />', 'button')) ->and(chipTag('<x-chip label="Share" elevated disabled />', 'button'))
->toContain('bg-on-surface/12 text-on-surface/38') ->toContain('data-md-elevated=""')
->and((string) $this->blade('<x-chip label="Share" icon="share" disabled />')) ->toContain('data-md-disabled=""');
->not->toContain('text-primary');
}); });
it('links with wire:navigate, and a disabled link leaves the tab order', function () { 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')) ->and(chipTag('<x-chip label="Directions" link="/directions" disabled />', 'a'))
->toContain('aria-disabled="true"') ->toContain('aria-disabled="true"')
->toContain('tabindex="-1"') ->toContain('tabindex="-1"')
->toContain('pointer-events-none'); ->toContain('data-md-disabled=""');
}); });
it('draws a suggestion chip in on-surface-variant', function () { it('draws a suggestion chip in on-surface-variant', function () {
expect(chipTag('<x-chip type="suggestion" label="Sounds good" />', 'button')) expect(chipTag('<x-chip type="suggestion" label="Sounds good" />', 'button'))
->toContain('data-chip="suggestion"') ->toContain('data-md-chip="suggestion"')
->toContain('border-outline-variant text-on-surface-variant')
->and(chipTag('<x-chip type="suggestion" label="Sounds good" elevated />', 'button')) ->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 () { 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" />'); $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')) 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('aria-pressed="true"')
->toContain('wire:click="toggleStarred"') ->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')) ->and(chipTag('<x-chip type="filter" label="Starred" />', 'button'))
->toContain('aria-pressed="false"') ->toContain('aria-pressed="false"')
->and($html) ->and($html)
->not->toContain('type="checkbox"') ->not->toContain('type="checkbox"')
->toContain('group-aria-pressed/chip:w-4.5') ->toContain('<span data-md-chip-check-slot>')
->toContain('data-chip-check'); ->toContain('data-md-chip-check');
}); });
it('makes a bound filter chip a native checkbox under the chip', function () { 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" />'); $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')) expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />', 'label'))
->toContain('data-chip="filter"') ->toBe('<label data-md-chip="filter" class="me-4" wire:key="photos">')
->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')
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" />', 'input')) ->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" />', 'input'))
->toContain('type="checkbox"') ->toContain('type="checkbox"')
->toContain('value="photos"') ->toContain('value="photos"')
->toContain('x-model="kinds"') ->toContain('x-model="kinds"')
->toContain('sr-only') ->toContain('class="md-visually-hidden"')
->not->toContain('me-4') ->not->toContain('me-4')
->not->toContain('checked') ->not->toContain('checked')
->and($html) ->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 () { 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')) ->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'input'))
->toContain('disabled') ->toContain('disabled')
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'label')) ->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('data-md-disabled=""');
->toContain('cursor-not-allowed');
}); });
it('swaps a filter chip\'s icon for the check when selected', function () { 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 />'); $html = (string) $this->blade('<x-chip type="filter" label="Starred" icon="star" name="starred" elevated />');
expect($html) expect($html)
->toContain('group-has-checked/chip:opacity-0') ->toMatch('/<span data-md-chip-check-slot>\s*<svg[^>]*data-md-chip-icon[^>]*>.*?<\/svg>\s*<svg[^>]*data-md-chip-check/s')
->toContain('text-primary') ->toContain('data-md-elevated=""')
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1') ->and(substr_count($html, '<svg'))->toBe(2)
->not->toContain('w-0') ->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
->and(substr_count($html, '<svg'))->toBe(2); ->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 () { it('draws an input chip with an avatar, an icon or neither', function () {
expect(chipTag('<x-chip type="input" label="Anna" avatar="AM" />', 'span')) expect(chipTag('<x-chip type="input" label="Anna" avatar="AM" />', 'span'))
->toContain('data-chip="input"') ->toContain('data-md-chip="input"')
->toContain('border-outline-variant text-on-surface-variant') ->toContain('data-md-avatar=""')
->not->toContain('x-data') ->not->toContain('x-data')
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="AM" />')) ->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="AM" />'))
->toContain('ps-0.75 pe-2.75') ->toContain('<span aria-hidden="true" data-md-chip-avatar>AM</span>')
->toContain('size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container')
->toContain('>AM</span>')
->not->toContain('<button') ->not->toContain('<button')
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="/avatars/anna.jpg" />')) ->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="/avatars/anna.jpg" />'))
->toContain('<img src="/avatars/anna.jpg" alt=""') ->toContain('<img src="/avatars/anna.jpg" alt="" data-md-chip-avatar />')
->and((string) $this->blade('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />')) ->and(chipTag('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />', 'span'))
->toContain('ps-1.75 pe-2.75'); ->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 () { 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')) 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('aria-pressed="true"')
->toContain('wire:click="open(1)"') ->toContain('wire:click="open(1)"')
->and(chipTag('<x-chip type="input" label="Anna" :selected="true" />', 'span')) ->and(chipTag('<x-chip type="input" label="Anna" :selected="true" />', 'span'))
->toContain('border-transparent bg-secondary-container text-on-secondary-container') ->toContain('data-md-selected=""')
->and($html) // An action of its own lights the leading icon on hover, focus and press.
->toContain('me-2 size-4.5 text-primary'); ->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 () { 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')) expect(chipTag('<x-chip type="input" label="anna@example.com" removable wire:remove="removeRecipient(3)" />', 'span'))
->toContain('x-data="materialChip"') ->toContain('x-data="materialChip"')
->toContain('x-on:keydown="removeOnKey($event)"') ->toContain('x-on:keydown="removeOnKey($event)"')
->toContain('data-md-removable=""')
->not->toContain('wire:remove') ->not->toContain('wire:remove')
->and($html) ->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('wire:click="removeRecipient(3)"')
->toContain('x-on:click="removeChip($event)"') ->toContain('x-on:click="removeChip($event)"')
->toContain('<input type="hidden" name="to[]" value="3" />') ->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. // 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 () { 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 () { 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>')) 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='); ->not->toContain('aria-label=');
}); });
@@ -209,58 +216,72 @@ it('disables a removable input chip and its remove button', function () {
$html = (string) $this->blade('<x-chip type="input" label="php" removable disabled />'); $html = (string) $this->blade('<x-chip type="input" label="php" removable disabled />');
expect($html) expect($html)
->toContain('border-on-surface/12 text-on-surface/38') ->toContain('data-md-disabled=""')
->toMatch('/<button[^>]*data-chip-remove[^>]*disabled/s'); ->toMatch('/<button[^>]*data-md-chip-remove[^>]*disabled/s');
}); });
it('groups chips in a wrapping row named by its label, with a hint', function () { it('groups chips in a wrapping row named by its label, with a hint', function () {
$html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these"><x-chip label="A" /></x-chip-set>'); $html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these" class="mt-4"><x-chip label="A" /></x-chip-set>');
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy); preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy); preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy);
expect($html) expect($html)
->toContain('role="group"') ->toMatch('/<div\s+data-md-chip-set\s+role="group"/')
->toContain('data-chip-set x-data="materialChipSet"') ->toContain('class="mt-4"')
->toContain('flex flex-wrap gap-2') ->toContain('<div data-md-chip-set-row x-data="materialChipSet"')
// M3's chip keyboard: the set is one tab stop and the arrows walk it. // M3's chip keyboard: the set is one tab stop and the arrows walk it.
->toContain('x-on:keydown="key($event)"') ->toContain('x-on:keydown="key($event)"')
->toContain('x-on:focusin="rove($event.target)"') ->toContain('x-on:focusin="rove($event.target)"')
->toContain("id=\"{$labelledBy[1]}\" class=\"mb-2 type-label-lg text-on-surface-variant\">File types</p>") ->toContain("id=\"{$labelledBy[1]}\" data-md-chip-set-label>File types</p>")
->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</p>"); ->toContain("id=\"{$describedBy[1]}\" data-md-chip-set-hint>Show only these</p>")
->not->toContain('data-md-scroll')
->not->toContain('flex-wrap');
});
it('draws the set from its stylesheet: 8px between chips, wrapping unless it scrolls', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css');
expect($css)->toContain("@import './chip.css';")
->toMatch('/\[data-md-chip-set-row\] \{\s*display: flex;\s*flex-wrap: wrap;\s*gap: var\(--md-sys-measurement-space100\);/')
->toContain('[data-md-chip-set-scroller] > [data-md-chip-set-row] {')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/chip-set.css';");
}); });
it('scrolls a chip set on one line with fading edges', function () { it('scrolls a chip set on one line with fading edges', function () {
expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>')) expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>'))
->toContain('aria-label="Sort"') ->toContain('aria-label="Sort"')
->toContain('x-data="materialChipSet"') ->toMatch('/data-md-chip-set\s+data-md-scroll\s/')
->toContain('<div x-data="materialChipSet" data-md-chip-set-scroller>')
->toContain('x-ref="row"') ->toContain('x-ref="row"')
->toContain('wire:ignore.self') ->toContain('wire:ignore.self')
->toContain('overflow-x-auto') ->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css'))
->toContain('data-scroll-start:[--chip-fade-start:1.5rem]') ->toContain('overflow-x: auto;')
->not->toContain('flex-wrap'); ->toContain('--chip-fade-start: var(--md-sys-measurement-space300);');
}); });
it('puts a scroll button over each fading edge, for a pointer that cannot swipe', function () { it('puts a scroll button over each fading edge, for a pointer that cannot swipe', function () {
$html = (string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>'); $html = (string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>');
expect($html) expect($html)
->toContain('data-chip-scroll="start"') ->toContain('data-md-chip-scroll="start"')
->toContain('data-chip-scroll="end"') ->toContain('data-md-chip-scroll="end"')
->toContain('x-on:click="nudge(\'start\')"') ->toContain('x-on:click="nudge(\'start\')"')
->toContain('x-on:click="nudge(\'end\')"') ->toContain('x-on:click="nudge(\'end\')"')
// Only while the row can still scroll that way, and only where the pointer is fine.
->toContain('peer-data-scroll-start:pointer-fine:grid')
->toContain('peer-data-scroll-end:pointer-fine:grid')
// A pointer affordance, not a tab stop: the arrows already walk every chip. // A pointer affordance, not a tab stop: the arrows already walk every chip.
->toContain('tabindex="-1"') ->toContain('tabindex="-1"')
->toContain('aria-hidden="true"') ->toContain('aria-hidden="true"')
->toContain('data-md-mirror-rtl');
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css'))
// Only while the row can still scroll that way, and only where the pointer is fine.
->toMatch("/@media \(pointer: fine\) \{\s*\[data-md-chip-set-row\]\[data-md-scroll-start\] ~ \[data-md-chip-scroll='start'\],\s*\[data-md-chip-set-row\]\[data-md-scroll-end\] ~ \[data-md-chip-scroll='end'\] \{\s*display: grid;/")
// A chip the keyboard reaches clears the buttons as well as the fade. // A chip the keyboard reaches clears the buttons as well as the fade.
->toContain('pointer-fine:scroll-px-10'); ->toContain('scroll-padding-inline: var(--md-sys-measurement-space500);');
// A wrapping set has no edge to scroll towards. // A wrapping set has no edge to scroll towards.
expect((string) $this->blade('<x-chip-set aria-label="Sort"><x-chip label="A" /></x-chip-set>')) expect((string) $this->blade('<x-chip-set aria-label="Sort"><x-chip label="A" /></x-chip-set>'))
->not->toContain('data-chip-scroll'); ->not->toContain('data-md-chip-scroll');
}); });
it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () { it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {
+18 -2
View File
@@ -7,6 +7,8 @@ it('lays every option out as a filter chip', function () {
$html = (string) $this->blade('<x-choices label="Days" hint="Pick any" :value="[2]" :options="[[\'id\' => 1, \'name\' => \'Mon\'], [\'id\' => 2, \'name\' => \'Tue\'], [\'id\' => 3, \'name\' => \'Wed\', \'disabled\' => true]]" />'); $html = (string) $this->blade('<x-choices label="Days" hint="Pick any" :value="[2]" :options="[[\'id\' => 1, \'name\' => \'Mon\'], [\'id\' => 2, \'name\' => \'Tue\'], [\'id\' => 3, \'name\' => \'Wed\', \'disabled\' => true]]" />');
expect($html) expect($html)
->toMatch('/<div\\s+data-md-choices\\s/')
->not->toContain('data-md-searchable')
->toContain('role="group"') ->toContain('role="group"')
->toContain('Days') ->toContain('Days')
->toContain('Pick any') ->toContain('Pick any')
@@ -42,6 +44,7 @@ it('draws a searchable combobox with a popover list', function () {
$html = (string) $this->blade('<x-choices id="zone" label="Time zone" searchable icon="public" value="Europe/Zurich" :options="[[\'id\' => \'Europe/Zurich\', \'name\' => \'Zurich\']]" />'); $html = (string) $this->blade('<x-choices id="zone" label="Time zone" searchable icon="public" value="Europe/Zurich" :options="[[\'id\' => \'Europe/Zurich\', \'name\' => \'Zurich\']]" />');
expect($html) expect($html)
->toMatch('/<div\\s+data-md-choices\\s+data-md-searchable\\s/')
->toContain('role="combobox"') ->toContain('role="combobox"')
->toContain('aria-controls="zone-list"') ->toContain('aria-controls="zone-list"')
->toContain('aria-expanded="false"') ->toContain('aria-expanded="false"')
@@ -51,11 +54,14 @@ it('draws a searchable combobox with a popover list', function () {
->toContain('popover="manual"') ->toContain('popover="manual"')
->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/') ->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
->toContain('x-modelable="value"') ->toContain('x-modelable="value"')
->toContain('field-arrow') ->toContain('data-md-field-arrow')
// WAI-ARIA's combobox keyboard, Home and End included. // WAI-ARIA's combobox keyboard, Home and End included.
->toContain('x-on:keydown.home="jump($event, false)"') ->toContain('x-on:keydown.home="jump($event, false)"')
->toContain('x-on:keydown.end="jump($event, true)"') ->toContain('x-on:keydown.end="jump($event, true)"')
->toContain('Nothing matches'); ->toContain('<li x-show="filtered.length === 0" data-md-choices-empty>Nothing matches</li>')
->toContain('data-md-field-menu')
->toContain('data-md-field-option')
->not->toContain('class=');
preg_match('/anchor-name: (--material-choices-[a-z0-9]{10})/', $html, $anchor); preg_match('/anchor-name: (--material-choices-[a-z0-9]{10})/', $html, $anchor);
@@ -92,3 +98,13 @@ it('shows the errors for the property and its items', function () {
->toContain('Choose a time zone.') ->toContain('Choose a time zone.')
->toContain('aria-invalid="true"'); ->toContain('aria-invalid="true"');
}); });
it('brings the stylesheets of what it renders, and draws its empty row', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/choices.css');
expect($css)->toContain("@import './field.css';")
->toContain("@import './chip-set.css';")
->toContain("@import './chip.css';")
->toContain('[data-md-choices-empty] {')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/choices.css';");
});
+27 -7
View File
@@ -8,14 +8,25 @@ use Livewire\WithPagination;
it('marks a table for its styles and takes a size', function () { it('marks a table for its styles and takes a size', function () {
expect((string) $this->blade('<x-table class="min-w-160"><tbody><tr><td>a</td></tr></tbody></x-table>')) expect((string) $this->blade('<x-table class="min-w-160"><tbody><tr><td>a</td></tr></tbody></x-table>'))
->toContain('<table data-table data-size="sm" class="min-w-160">') ->toContain('<table data-md-table data-md-size="sm" class="min-w-160">')
->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-size="xs"') ->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-md-size="xs"')
->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-size="sm"'); ->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-md-size="sm"');
}); });
it('tightens a table only when the caller asks for it', function () { it('tightens a table only when the caller asks for it', function () {
expect((string) $this->blade('<x-table />'))->not->toContain('data-dense') expect((string) $this->blade('<x-table />'))->not->toContain('data-md-dense')
->and((string) $this->blade('<x-table dense />'))->toContain('data-dense'); ->and((string) $this->blade('<x-table dense />'))->toContain('data-md-dense');
});
it('draws 52px rows, 36px dense ones, from its stylesheet in the components layer', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/table.css');
expect($css)->toContain('@layer material.components')
->toMatch('/\\[data-md-table\\] \\{\\s*--cell-x: 12px;\\s*--cell-y: var\\(--md-sys-measurement-space200\\);/')
->toMatch('/\\[data-md-table\\]\\[data-md-dense\\] \\{\\s*--cell-y: var\\(--md-sys-measurement-space100\\);/')
->toContain('[data-md-table] :where(tbody tr[aria-selected=\'true\'])')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/table.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('table.css');
}); });
it('sorts by its column, ascending first and then flipping', function () { it('sorts by its column, ascending first and then flipping', function () {
@@ -24,7 +35,7 @@ it('sorts by its column, ascending first and then flipping', function () {
->toContain('wire:click="$set(\'sortBy\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;asc&quot;})"') ->toContain('wire:click="$set(\'sortBy\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;asc&quot;})"')
->and((string) $this->blade('<x-sort-header column="size" model="order" :sort-by="[\'column\' => \'size\', \'direction\' => \'asc\']" class="text-end">Size</x-sort-header>')) ->and((string) $this->blade('<x-sort-header column="size" model="order" :sort-by="[\'column\' => \'size\', \'direction\' => \'asc\']" class="text-end">Size</x-sort-header>'))
->toContain('aria-sort="ascending"') ->toContain('aria-sort="ascending"')
->toContain('class="text-end"') ->toMatch('/<th data-md-sort-header\\s+data-md-active aria-sort="ascending"\\s+class="text-end">/')
->toContain('$set(\'order\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;desc&quot;})') ->toContain('$set(\'order\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;desc&quot;})')
->and((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'size\', \'direction\' => \'desc\']">Size</x-sort-header>')) ->and((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'size\', \'direction\' => \'desc\']">Size</x-sort-header>'))
->toContain('aria-sort="descending"') ->toContain('aria-sort="descending"')
@@ -35,7 +46,9 @@ it('gives the sort button and every page control a 48px target', function () {
$pages = new LengthAwarePaginator(range(1, 10), 95, 10, 3, ['path' => '/shares']); $pages = new LengthAwarePaginator(range(1, 10), 95, 10, 3, ['path' => '/shares']);
expect((string) $this->blade('<x-sort-header column="size" :sort-by="[]">Size</x-sort-header>')) expect((string) $this->blade('<x-sort-header column="size" :sort-by="[]">Size</x-sort-header>'))
->toContain('touch-target') ->toMatch('/<button(?=[^>]*data-md-sort-header-button)(?=[^>]*class="md-focus-ring md-touch-target")[^>]*>/')
->toContain('--md-icon-size: 16px')
->not->toContain('data-md-active')
->and((string) $pages->links()) ->and((string) $pages->links())
->toContain('state-layer focus-ring touch-target'); ->toContain('state-layer focus-ring touch-target');
}); });
@@ -80,3 +93,10 @@ it('draws Livewire\'s paginators in M3, wired to its page actions', function ()
->toContain('wire:click="nextPage(\'page\')"') ->toContain('wire:click="nextPage(\'page\')"')
->not->toContain('text-gray'); ->not->toContain('text-gray');
}); });
it('draws the sort header from its stylesheet, its arrow hidden until the column sorts or is hovered', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/sort-header.css'))
->toContain('@layer material.components')
->toMatch('/\\[data-md-sort-header\\]:not\\(\\[data-md-active\\]\\) \\[data-md-sort-header-arrow\\] \\{\\s*opacity: 0;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/sort-header.css';");
});
+6 -6
View File
@@ -21,7 +21,7 @@ it('draws a docked date field whose combobox controls a popover picker', functio
expect($html) expect($html)
->toContain('data-datepicker') ->toContain('data-datepicker')
->toContain('<label for="expires" class="field-label">Expires on</label>') ->toContain('<label for="expires" data-md-field-label>Expires on</label>')
->toContain('role="combobox"') ->toContain('role="combobox"')
->toContain('aria-haspopup="dialog"') ->toContain('aria-haspopup="dialog"')
->toContain('aria-controls="expires-picker"') ->toContain('aria-controls="expires-picker"')
@@ -155,8 +155,8 @@ it('puts class on the root and every other attribute on the text field', functio
expect($html) expect($html)
->toContain('class="w-60"') ->toContain('class="w-60"')
->toContain('data-variant="filled"') ->toContain('data-md-variant="filled"')
->toContain('data-size="sm"') ->toContain('data-md-size="sm"')
->toMatch('/<input\s[^>]*required[^>]*id="due"/s') ->toMatch('/<input\s[^>]*required[^>]*id="due"/s')
->toMatch('/<input\s[^>]*disabled[^>]*id="due"/s') ->toMatch('/<input\s[^>]*disabled[^>]*id="due"/s')
->and(datepickerConfig($html)['disabled'])->toBeTrue(); ->and(datepickerConfig($html)['disabled'])->toBeTrue();
@@ -225,14 +225,14 @@ it('replaces the hint with the errors for the property and its start and end', f
->not->toContain('Pick a day') ->not->toContain('Pick a day')
->toContain('aria-invalid="true"') ->toContain('aria-invalid="true"')
->toContain('data-datepicker-error') ->toContain('data-datepicker-error')
->and(substr_count($html, 'data-invalid=""'))->toBe(2); ->and(substr_count($html, 'data-md-invalid=""'))->toBe(2);
}); });
it('offers a clear button on request', function () { it('offers a clear button on request', function () {
expect((string) $this->blade('<x-datepicker label="Expires" clearable />')) expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
->toContain('data-field-clear') ->toContain('data-md-field-clear')
->toContain('x-on:click="clear()"') ->toContain('x-on:click="clear()"')
->toContain('aria-label="Clear"') ->toContain('aria-label="Clear"')
->and((string) $this->blade('<x-datepicker label="Expires" />')) ->and((string) $this->blade('<x-datepicker label="Expires" />'))
->not->toContain('data-field-clear'); ->not->toContain('data-md-field-clear');
}); });
+135 -42
View File
@@ -4,31 +4,34 @@ it('draws an outlined field with a notched label unless told otherwise', functio
$html = (string) $this->blade('<x-input label="Share name" />'); $html = (string) $this->blade('<x-input label="Share name" />');
expect($html) expect($html)
->toContain('data-variant="outlined"') ->toContain('data-md-field')
->toContain('data-md-input')
->toContain('data-md-variant="outlined"')
->toContain('<legend><span>Share name</span></legend>') ->toContain('<legend><span>Share name</span></legend>')
->toMatch('/<label for="(field-[0-9a-f]{12})" class="field-label">Share name<\/label>/') ->toMatch('/<label for="(field-[0-9a-f]{12})" data-md-field-label>Share name<\/label>/')
->toContain('placeholder=" "') ->toContain('placeholder=" "')
->toContain('class="field-control"'); ->toContain('data-md-field-control')
->not->toContain('class=');
}); });
it('draws a filled field on request, or by the application\'s default', function () { it('draws a filled field on request, or by the application\'s default', function () {
expect((string) $this->blade('<x-input label="Name" variant="filled" />'))->toContain('data-variant="filled"'); expect((string) $this->blade('<x-input label="Name" variant="filled" />'))->toContain('data-md-variant="filled"');
config(['livewire-material.fields.variant' => 'filled']); config(['livewire-material.fields.variant' => 'filled']);
expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-variant="filled"') expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-md-variant="filled"')
->and((string) $this->blade('<x-input label="Name" variant="outlined" />'))->toContain('data-variant="outlined"') ->and((string) $this->blade('<x-input label="Name" variant="outlined" />'))->toContain('data-md-variant="outlined"')
->and((string) $this->blade('<x-input label="Name" variant="glass" />'))->toContain('data-variant="filled"'); ->and((string) $this->blade('<x-input label="Name" variant="glass" />'))->toContain('data-md-variant="filled"');
}); });
it('ties the label, the hint and the control together', function () { it('ties the label, the hint and the control together', function () {
$html = (string) $this->blade('<x-input id="share-name" label="Share name" hint="Recipients see this name" wire:model="name" />'); $html = (string) $this->blade('<x-input id="share-name" label="Share name" hint="Recipients see this name" wire:model="name" />');
expect($html) expect($html)
->toContain('<label for="share-name" class="field-label">') ->toContain('<label for="share-name" data-md-field-label>')
->toContain('id="share-name"') ->toContain('id="share-name"')
->toContain('aria-describedby="share-name-support"') ->toContain('aria-describedby="share-name-support"')
->toContain('<p id="share-name-support" class="field-support">Recipients see this name</p>') ->toMatch('/<p id="share-name-support" data-md-field-support\s*>Recipients see this name<\/p>/')
->toContain('wire:model="name"') ->toContain('wire:model="name"')
->not->toContain('aria-invalid'); ->not->toContain('aria-invalid');
}); });
@@ -38,8 +41,9 @@ it('replaces the hint with the errors for its model', function () {
->blade('<x-input id="email" label="Email" hint="We never share it" wire:model.live="email" />'); ->blade('<x-input id="email" label="Email" hint="We never share it" wire:model.live="email" />');
expect($html) expect($html)
->toContain('data-invalid') ->toContain('data-md-invalid')
->toContain('aria-invalid="true"') ->toContain('aria-invalid="true"')
->toContain('role="alert"')
->toContain('aria-describedby="email-support"') ->toContain('aria-describedby="email-support"')
->toContain('Enter a valid email address.') ->toContain('Enter a valid email address.')
->not->toContain('We never share it'); ->not->toContain('We never share it');
@@ -49,28 +53,26 @@ it('pairs the error colours with a trailing error icon', function () {
$errors = ['email' => ['Enter a valid email address.']]; $errors = ['email' => ['Enter a valid email address.']];
expect((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" wire:model="email" />')) expect((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" wire:model="email" />'))
->toContain('field-trailing field-error') ->toMatch('/<svg(?=[^>]*data-md-field-trailing)(?=[^>]*data-md-field-error)(?=[^>]*aria-label="Error")(?=[^>]*role="img")[^>]*>/')
->toContain('aria-label="Error"')
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" size="xs" wire:model="email" />')) ->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" size="xs" wire:model="email" />'))
->not->toContain('field-error') ->not->toContain('data-md-field-error')
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" icon-right="info" wire:model="email" />')) ->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" icon-right="info" wire:model="email" />'))
->not->toContain('field-error') ->not->toContain('data-md-field-error')
->and((string) $this->blade('<x-input id="email" label="Email" />')) ->and((string) $this->blade('<x-input id="email" label="Email" />'))
->not->toContain('field-error'); ->not->toContain('data-md-field-error');
}); });
it('puts icons, affixes and the size on the field, and every other attribute on the input', function () { it('puts icons, affixes and the size on the field, and every other attribute on the input', function () {
$html = (string) $this->blade('<x-input label="Price" icon="payments" icon-right="info" prefix="CHF" suffix="per month" size="sm" type="number" min="0" class="w-40" mono readonly />'); $html = (string) $this->blade('<x-input label="Price" icon="payments" icon-right="info" prefix="CHF" suffix="per month" size="sm" type="number" min="0" class="w-40" mono readonly />');
expect($html) expect($html)
->toContain('class="field w-40"') ->toMatch('/<div\s+data-md-field\s+data-md-variant="outlined"\s+data-md-size="sm"\s+data-md-mono\s+class="w-40" data-md-readonly="" data-md-input="data-md-input"\s*>/')
->toContain('data-size="sm"') ->toContain('<span data-md-field-affix>CHF</span>')
->toContain('data-mono') ->toContain('<span data-md-field-affix>per month</span>')
->toContain('data-readonly')
->toContain('<span class="field-affix">CHF</span>')
->toContain('<span class="field-affix">per month</span>')
->toContain('type="number"') ->toContain('type="number"')
->toContain('min="0"') ->toContain('min="0"')
// An sm field's icons are 20px, drawn from the 20 cut.
->and(substr_count($html, '--md-icon-size: 20px'))->toBe(2)
->and(substr_count($html, '<svg'))->toBe(2); ->and(substr_count($html, '<svg'))->toBe(2);
}); });
@@ -78,9 +80,12 @@ it('offers to clear and to copy the value', function () {
$html = (string) $this->blade('<x-input label="Link" clearable copyable />'); $html = (string) $this->blade('<x-input label="Link" clearable copyable />');
expect($html) expect($html)
->toContain('data-field-clear') ->toContain('data-md-field-clear')
->toContain('aria-label="Clear"') ->toContain('aria-label="Clear"')
->toContain('data-field-copy') ->toContain('data-md-field-copy')
->toContain("\$el.closest('[data-md-field]').querySelector('[data-md-field-control]')")
// A labelled field is md: its icons are 24px.
->toContain('--md-icon-size: 24px')
->toContain('navigator.clipboard.writeText') ->toContain('navigator.clipboard.writeText')
->toContain('aria-label="Copy"'); ->toContain('aria-label="Copy"');
}); });
@@ -92,7 +97,8 @@ it('lets a password be shown and hidden again', function () {
->toContain('type="password"') ->toContain('type="password"')
->not->toContain('type="text"') ->not->toContain('type="text"')
->toContain('x-bind:type="shown ? \'text\' : \'password\'"') ->toContain('x-bind:type="shown ? \'text\' : \'password\'"')
->toContain('data-field-reveal') ->toContain('data-md-password')
->toMatch('/<button(?=[^>]*data-md-field-button)(?=[^>]*data-md-field-reveal)[^>]*>/')
->toContain('aria-label="Show password"') ->toContain('aria-label="Show password"')
// The flipping label already says the state; aria-pressed would say it again, inverted. // The flipping label already says the state; aria-pressed would say it again, inverted.
->not->toContain('aria-pressed') ->not->toContain('aria-pressed')
@@ -102,12 +108,13 @@ it('lets a password be shown and hidden again', function () {
it('grows a textarea from its rows up to its maximum, or leaves it to be resized', function () { it('grows a textarea from its rows up to its maximum, or leaves it to be resized', function () {
expect((string) $this->blade('<x-textarea label="Message" rows="2" max-rows="6" />')) expect((string) $this->blade('<x-textarea label="Message" rows="2" max-rows="6" />'))
->toContain('rows="2"') ->toContain('rows="2"')
->toContain('data-autogrow') ->toContain('data-md-textarea')
->toContain('data-md-autogrow')
->toContain('--field-rows: 2;') ->toContain('--field-rows: 2;')
->toContain('--field-max-rows: 6;') ->toContain('--field-max-rows: 6;')
->and((string) $this->blade('<x-textarea label="Message" :autogrow="false" />')) ->and((string) $this->blade('<x-textarea label="Message" :autogrow="false" />'))
->toContain('rows="3"') ->toContain('rows="3"')
->not->toContain('data-autogrow'); ->not->toContain('data-md-autogrow');
}); });
it('draws a native select whose label always floats', function () { it('draws a native select whose label always floats', function () {
@@ -119,13 +126,15 @@ it('draws a native select whose label always floats', function () {
BLADE); BLADE);
expect($html) expect($html)
->toContain('data-floated') ->toContain('data-md-floated')
->toContain('data-md-select')
->toMatch('/<select(?=[^>]*data-md-field-control)[^>]*>/')
->toContain('<select') ->toContain('<select')
->toContain('wire:model="hours"') ->toContain('wire:model="hours"')
->toContain('<option value="">Choose…</option>') ->toContain('<option value="">Choose…</option>')
->toContain('<option value="1" >1 hour</option>') ->toContain('<option value="1" >1 hour</option>')
->toContain('<option value="720" disabled>30 days</option>') ->toContain('<option value="720" disabled>30 days</option>')
->toContain('field-arrow'); ->toContain('data-md-field-arrow');
}); });
it('takes a select\'s options from its slot as well', function () { it('takes a select\'s options from its slot as well', function () {
@@ -142,7 +151,8 @@ it('collects a file field\'s own errors and each file\'s', function () {
expect($html) expect($html)
->toContain('type="file"') ->toContain('type="file"')
->toContain('multiple') ->toContain('multiple')
->toContain('data-floated') ->toContain('data-md-file')
->toContain('data-md-floated')
->toContain('Choose at most 10 photos.') ->toContain('Choose at most 10 photos.')
->toContain('photo.jpg is larger than 2 GB.'); ->toContain('photo.jpg is larger than 2 GB.');
}); });
@@ -151,8 +161,10 @@ it('counts the characters of a field that has a maximum', function () {
$html = (string) $this->blade('<x-input id="bio" label="Bio" hint="Shown on your profile" maxlength="60" counter />'); $html = (string) $this->blade('<x-input id="bio" label="Bio" hint="Shown on your profile" maxlength="60" counter />');
expect($html) expect($html)
->toContain('class="field-support-row"') ->toContain('data-md-field-support-row')
->toContain('class="field-counter"') ->toContain('data-md-field-counter')
->toContain('x-bind:data-md-over="used > max"')
->toContain('class="md-visually-hidden"')
->toContain('maxlength="60"') ->toContain('maxlength="60"')
->toContain('max: 60,') ->toContain('max: 60,')
->toContain('>0/60</span>') ->toContain('>0/60</span>')
@@ -160,24 +172,24 @@ it('counts the characters of a field that has a maximum', function () {
->toContain('Shown on your profile'); ->toContain('Shown on your profile');
expect((string) $this->blade('<x-textarea id="note" label="Note" maxlength="140" counter />')) expect((string) $this->blade('<x-textarea id="note" label="Note" maxlength="140" counter />'))
->toContain('class="field-counter"') ->toContain('data-md-field-counter')
->toContain('>0/140</span>'); ->toContain('>0/140</span>');
}); });
it('shows no counter without `counter` or without a maximum to count against', function () { it('shows no counter without `counter` or without a maximum to count against', function () {
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('field-counter') expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('data-md-field-counter')
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('field-counter') ->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('data-md-field-counter')
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('field-counter'); ->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('data-md-field-counter');
}); });
it('bounds a field\'s width from medium unless it is told to fill its pane', function () { it('bounds a field\'s width from medium unless it is told to fill its pane', function () {
expect(file_get_contents(__DIR__.'/../../../resources/css/components/field.css')) expect(file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))
->toContain('@media (width >= 37.5rem)') ->toContain('@media (width >= 600px)')
->toMatch('/\.field:not\(\[data-full\]\)\s*\{\s*max-width: 40rem;/'); ->toMatch('/\[data-md-field\]:not\(\[data-md-full\]\)\s*\{\s*max-width: 40rem;/');
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-full') expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-md-full')
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-full') ->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-md-full')
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-full'); ->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-md-full');
}); });
it('lays out a form with its actions under an optional divider', function () { it('lays out a form with its actions under an optional divider', function () {
@@ -189,10 +201,91 @@ it('lays out a form with its actions under an optional divider', function () {
BLADE); BLADE);
expect($html) expect($html)
->toContain('<form class="grid grid-cols-1 auto-rows-min gap-4" wire:submit="save">') ->toContain('<form data-md-form wire:submit="save">')
->toContain('role="separator"') ->toContain('role="separator"')
->toContain('justify-between') ->toContain('<div data-md-form-actions class="justify-between">')
->not->toContain('grid-cols-1')
->toContain('<button>Save</button>') ->toContain('<button>Save</button>')
->and((string) $this->blade('<x-form><x-slot:actions><button>Save</button></x-slot:actions></x-form>')) ->and((string) $this->blade('<x-form><x-slot:actions><button>Save</button></x-slot:actions></x-form>'))
->not->toContain('role="separator"'); ->not->toContain('role="separator"');
}); });
it('draws a form\'s column and its actions from its stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/form.css');
expect($css)->toContain('@layer material.components')
->toContain('grid-template-columns: minmax(0, 1fr);')
->toContain('gap: var(--md-sys-measurement-space200);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/form.css';");
});
it('keeps the field\'s chrome in its own stylesheet in the components layer', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css');
expect($css)->toContain('@layer material.components')
->toContain("@import './icon.css';")
->toContain('[data-md-field-box]')
->not->toMatch('/(?<![\\w-])\\.field\\b/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/field.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('field.css');
});
it('takes a custom control marked as the field\'s control, and a hint class', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-field id="expiry" label="Expiry" hint="In the future" hint-class="md-ink-primary">
<input id="expiry" data-md-field-control value="2030-01-01" />
</x-field>
BLADE);
expect($html)
->toContain('<input id="expiry" data-md-field-control value="2030-01-01" />')
->toMatch('/<p id="expiry-support" data-md-field-support\s+class="md-ink-primary"\s*>In the future<\/p>/')
->toContain('<fieldset aria-hidden="true" data-md-field-outline>');
});
it('gives the one-line field a stylesheet that brings the field and its icons', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/input.css');
expect($css)->toStartWith('/*')
->toContain("@import './field.css';")
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/input.css';");
});
it('gives the password field a stylesheet that brings the field and its icons', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/password.css'))
->toContain("@import './field.css';")
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/password.css';");
});
it('draws what a textarea changes in the field from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/textarea.css');
expect($css)->toContain("@import './field.css';")
->toContain('@layer material.components')
->toContain('textarea[data-md-field-control][data-md-autogrow]')
->toContain('field-sizing: content;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/textarea.css';");
});
it('draws what a select changes in the field from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/select.css');
expect($css)->toContain("@import './field.css';")
->toContain('@supports (appearance: base-select)')
->toContain('select[data-md-field-control]:open')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/select.css';");
});
it('draws the file picker\'s button from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/file.css');
expect($css)->toContain("@import './field.css';")
->toContain("input[type='file'][data-md-field-control]::file-selector-button")
->toContain('background-color: var(--md-sys-color-secondary-container);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/file.css';");
});
+33 -17
View File
@@ -4,7 +4,10 @@ it('draws a search bar whose combobox controls the view', function () {
$html = (string) $this->blade('<x-search id="find" placeholder="Search shares" wire:model.live.debounce.300ms="query" />'); $html = (string) $this->blade('<x-search id="find" placeholder="Search shares" wire:model.live.debounce.300ms="query" />');
expect($html) expect($html)
->toContain('data-search') ->toContain('data-md-search')
->toContain('data-md-trigger="bar"')
->toContain('x-bind:data-md-open="open ? \'\' : null"')
->toContain('x-bind:data-md-full-screen="fullScreen ? \'\' : null"')
->toContain('role="search"') ->toContain('role="search"')
->toContain('type="search"') ->toContain('type="search"')
->toContain('placeholder="Search shares"') ->toContain('placeholder="Search shares"')
@@ -15,14 +18,15 @@ it('draws a search bar whose combobox controls the view', function () {
->toContain('aria-expanded="false"') ->toContain('aria-expanded="false"')
->toContain('wire:model.live.debounce.300ms="query"') ->toContain('wire:model.live.debounce.300ms="query"')
->toContain('id="find-view"') ->toContain('id="find-view"')
->toContain('data-search-clear') ->toContain('data-md-search-clear')
->toContain('data-search-scrim') ->toContain('data-md-search-scrim')
->toContain('aria-label="Back"') ->toContain('aria-label="Back"')
->toContain('materialSearch(false,') ->toContain('materialSearch(false,')
->not->toContain('data-search-results'); ->not->toContain('data-md-search-results')
->not->toContain('class="relative"');
// The state belongs to the combobox, never to the bare textbox ARIA gives it to. // The state belongs to the combobox, never to the bare textbox ARIA gives it to.
expect($html)->toMatch('/<span\s+data-search-field[^>]*aria-expanded/s'); expect($html)->toMatch('/<span\s+data-md-search-field[^>]*aria-expanded/s');
expect($html)->not->toMatch('/<input[^>]*aria-expanded/s'); expect($html)->not->toMatch('/<input[^>]*aria-expanded/s');
}); });
@@ -30,7 +34,7 @@ it('announces how many results there are, politely', function () {
$html = (string) $this->blade('<x-search />'); $html = (string) $this->blade('<x-search />');
expect($html) expect($html)
->toContain('data-search-status') ->toContain('data-md-search-status class="md-visually-hidden"')
->toContain('aria-live="polite"') ->toContain('aria-live="polite"')
->toContain('aria-atomic="true"') ->toContain('aria-atomic="true"')
->toContain('No results') ->toContain('No results')
@@ -40,19 +44,19 @@ it('announces how many results there are, politely', function () {
it('shows the results as a list, or what to say when there are none', function () { it('shows the results as a list, or what to say when there are none', function () {
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>')) expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
->toContain('data-search-results role="list"') ->toContain('data-md-search-results role="list"')
->toContain('<a href="/shares/1">holiday.zip</a>') ->toContain('<a href="/shares/1">holiday.zip</a>')
->and((string) $this->blade('<x-search><x-slot:empty>No shares match.</x-slot:empty></x-search>')) ->and((string) $this->blade('<x-search><x-slot:empty>No shares match.</x-slot:empty></x-search>'))
->toContain('No shares match.') ->toContain('<p data-md-search-empty>No shares match.</p>')
->not->toContain('data-search-results role="list"'); ->not->toContain('data-md-search-results role="list"');
}); });
it('offers M3\'s search-icon entry point', function () { it('offers M3\'s search-icon entry point', function () {
$html = (string) $this->blade('<x-search id="find" trigger="icon" label="Search shares" />'); $html = (string) $this->blade('<x-search id="find" trigger="icon" label="Search shares" />');
expect($html) expect($html)
->toContain('data-trigger="icon"') ->toContain('data-md-trigger="icon"')
->toContain('data-search-trigger') ->toContain('data-md-search-trigger')
->toContain('x-ref="trigger"') ->toContain('x-ref="trigger"')
->toContain('x-on:click="expand()"') ->toContain('x-on:click="expand()"')
->toContain('aria-label="Search shares"') ->toContain('aria-label="Search shares"')
@@ -61,11 +65,11 @@ it('offers M3\'s search-icon entry point', function () {
->toContain('materialSearch(false,') ->toContain('materialSearch(false,')
->toContain("'icon'") ->toContain("'icon'")
// The bar is still there: it becomes the expanded view's header. // The bar is still there: it becomes the expanded view's header.
->toContain('data-search-bar'); ->toContain('data-md-search-bar');
expect((string) $this->blade('<x-search />')) expect((string) $this->blade('<x-search />'))
->toContain('data-trigger="bar"') ->toContain('data-md-trigger="bar"')
->not->toContain('data-search-trigger'); ->not->toContain('data-md-search-trigger');
}); });
it('shows suggestions until the first keystroke, then the results', function () { it('shows suggestions until the first keystroke, then the results', function () {
@@ -77,7 +81,7 @@ it('shows suggestions until the first keystroke, then the results', function ()
BLADE); BLADE);
expect($html) expect($html)
->toContain('data-search-suggestions role="list"') ->toContain('data-md-search-suggestions role="list"')
->toContain('x-show="query === \'\'"') ->toContain('x-show="query === \'\'"')
->toContain('Recent shares') ->toContain('Recent shares')
->toContain('x-show="query !== \'\'"') ->toContain('x-show="query !== \'\'"')
@@ -87,7 +91,7 @@ it('shows suggestions until the first keystroke, then the results', function ()
// Without the slot the results stand alone and are never hidden. // Without the slot the results stand alone and are never hidden.
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>')) expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
->not->toContain('data-search-suggestions') ->not->toContain('data-md-search-suggestions')
->not->toContain('x-show="query !== \'\'"'); ->not->toContain('x-show="query !== \'\'"');
}); });
@@ -97,7 +101,19 @@ it('names the input, trails the bar, and stays docked on request', function () {
expect($html) expect($html)
->toContain('placeholder="Search"') ->toContain('placeholder="Search"')
->toContain('aria-label="Search your shares"') ->toContain('aria-label="Search your shares"')
->toContain('data-search-trailing') ->toContain('data-md-search-trailing')
->toContain('<button>AR</button>') ->toContain('<button>AR</button>')
->toContain('materialSearch(true,'); ->toContain('materialSearch(true,');
}); });
it('draws the bar and the view from its stylesheet, at M3\'s widths', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/search.css');
expect($css)->toContain('@layer material.components')
->toMatch('/\\[data-md-search\\] \\{\\s*--search-height: 56px;[^}]*--search-open-width: 720px;/')
->toContain('[data-md-search][data-md-full-screen] [data-md-search-view] {')
->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] {")
->not->toMatch('/\\d(?:\\.\\d+)?rem/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/search.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('search.css');
});
+73 -32
View File
@@ -1,40 +1,42 @@
<?php <?php
it('draws a checkbox whose row is its label', function () { it('draws a checkbox whose row is its label', function () {
$html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" />'); $html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" style="order: 2" />');
expect($html) expect($html)
->toContain('<div class="min-w-0 mt-2">') ->toContain('<div data-md-checkbox class="mt-2" style="order: 2">')
->toContain('<label for="notify" data-selection') ->toMatch('/<label for="notify" data-md-selection-row\s*>/')
->toContain('data-checkbox') ->toContain('<span data-md-checkbox-box >')
->toContain('type="checkbox"') ->toContain('type="checkbox"')
->toContain('wire:model="notify"') ->toContain('wire:model="notify"')
->toContain('data-check') ->toContain('data-md-checkbox-check')
->toContain('data-mixed') ->toContain('data-md-checkbox-mixed')
->toContain('Notify me') ->toContain('<span data-md-selection-label>Notify me</span>')
->toContain('On every download') ->toContain('<span data-md-selection-hint>On every download</span>')
->not->toContain('data-indeterminate'); ->not->toContain('data-md-indeterminate')
->not->toContain('min-w-0');
}); });
it('fills the box with an 18px tick from the 20 optical cut', function () { it('fills the box with an 18px tick from the 20 optical cut', function () {
expect((string) $this->blade('<x-checkbox label="Notify me" />')) expect((string) $this->blade('<x-checkbox label="Notify me" />'))
->toContain((string) $this->blade('<x-icon name="check" class="size-4.5" optical="20" data-check />')) ->toContain((string) $this->blade('<x-icon name="check" size="18" data-md-checkbox-check />'))
->toContain((string) $this->blade('<x-icon name="remove" class="size-4.5" optical="20" data-mixed />')); ->toContain((string) $this->blade('<x-icon name="remove" size="18" data-md-checkbox-mixed />'))
->toContain('--md-icon-size: 18px');
}); });
it('marks a checkbox that is partly ticked', function () { it('marks a checkbox that is partly ticked', function () {
expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-indeterminate'); expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-md-indeterminate');
}); });
it('gives a selection control without a label a 48px target', function () { it('gives a selection control without a label a 48px target', function () {
expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toContain('touch-target') expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toMatch('/<span data-md-checkbox-box\s+class="md-touch-target"\s*>/')
->and((string) $this->blade('<x-toggle aria-label="Public link" />'))->toContain('touch-target') ->and((string) $this->blade('<x-toggle aria-label="Public link" />'))->toMatch('/<span data-md-switch\\s+class="md-touch-target"\\s*>/')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'\']]" />'))->toContain('touch-target'); ->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'\']]" />'))->toMatch('/<span data-md-radio-button\\s+class="md-touch-target"\\s*>/');
// A labelled control is pressed anywhere along its row, so it needs no extra target. // A labelled control is pressed anywhere along its row, so it needs no extra target.
expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('touch-target') expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('md-touch-target')
->and((string) $this->blade('<x-toggle label="Public link" />'))->not->toContain('touch-target') ->and((string) $this->blade('<x-toggle label="Public link" />'))->not->toContain('md-touch-target')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->not->toContain('touch-target'); ->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->not->toContain('md-touch-target');
}); });
it('leaves a checkbox group to the caller and says how M3 wants it laid out', function () { it('leaves a checkbox group to the caller and says how M3 wants it laid out', function () {
@@ -52,7 +54,7 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
->toContain('class="grid gap-4 expanded:grid-cols-2"') ->toContain('class="grid gap-4 expanded:grid-cols-2"')
->toContain('Downloads') ->toContain('Downloads')
->toContain('Comments') ->toContain('Comments')
->and(substr_count($html, 'data-checkbox'))->toBe(2); ->and(substr_count($html, 'data-md-checkbox-box'))->toBe(2);
// The rule is guidance, not markup, so the component's header has to carry it. // The rule is guidance, not markup, so the component's header has to carry it.
expect(file_get_contents(__DIR__.'/../../../resources/views/components/checkbox.blade.php')) expect(file_get_contents(__DIR__.'/../../../resources/views/components/checkbox.blade.php'))
@@ -61,7 +63,8 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
}); });
it('puts a checkbox at the end of its row on request', function () { it('puts a checkbox at the end of its row on request', function () {
expect((string) $this->blade('<x-checkbox label="Show" right />'))->toContain('flex-row-reverse justify-between'); expect((string) $this->blade('<x-checkbox label="Show" right />'))->toMatch('/<label for="check-[0-9a-f]{12}" data-md-selection-row\s+data-md-right\s*>/')
->and((string) $this->blade('<x-checkbox label="Show" />'))->not->toContain('data-md-right');
}); });
it('shows a checkbox\'s errors', function () { it('shows a checkbox\'s errors', function () {
@@ -69,7 +72,8 @@ it('shows a checkbox\'s errors', function () {
->blade('<x-checkbox id="terms" label="I accept the terms" wire:model="terms" />'); ->blade('<x-checkbox id="terms" label="I accept the terms" wire:model="terms" />');
expect($html) expect($html)
->toContain('data-invalid') ->toContain('data-md-invalid')
->toContain('<div id="terms-support" data-md-selection-support role="alert">')
->toContain('aria-invalid="true"') ->toContain('aria-invalid="true"')
->toContain('aria-describedby="terms-support"') ->toContain('aria-describedby="terms-support"')
->toContain('Accept the terms to continue.'); ->toContain('Accept the terms to continue.');
@@ -85,8 +89,11 @@ it('draws radio buttons in a fieldset named by its question', function () {
BLADE); BLADE);
expect($html) expect($html)
->toContain('<fieldset') ->toMatch('/<fieldset\\s+data-md-radio\\s/')
->toContain('Who can open it</legend>') ->toContain('<legend data-md-radio-legend>Who can open it</legend>')
->toContain('<div data-md-radio-options>')
->toContain('<span data-md-selection-hint>Share it separately</span>')
->not->toContain('class=')
->and(substr_count($html, 'type="radio"'))->toBe(3) ->and(substr_count($html, 'type="radio"'))->toBe(3)
->and(substr_count($html, 'name="audience"'))->toBe(3) ->and(substr_count($html, 'name="audience"'))->toBe(3)
->and(substr_count($html, 'wire:model.live="audience"'))->toBe(3) ->and(substr_count($html, 'wire:model.live="audience"'))->toBe(3)
@@ -99,7 +106,7 @@ it('names unbound radios after their group and checks the given value', function
$html = (string) $this->blade('<x-radio name="theme" value="dark" inline :options="[[\'id\' => \'light\', \'name\' => \'Light\'], [\'id\' => \'dark\', \'name\' => \'Dark\']]" />'); $html = (string) $this->blade('<x-radio name="theme" value="dark" inline :options="[[\'id\' => \'light\', \'name\' => \'Light\'], [\'id\' => \'dark\', \'name\' => \'Dark\']]" />');
expect($html) expect($html)
->toContain('medium:flex') ->toMatch('/<fieldset\\s+data-md-radio\\s+data-md-inline\\s/')
->and(substr_count($html, 'name="theme"'))->toBe(2) ->and(substr_count($html, 'name="theme"'))->toBe(2)
->and($html)->toMatch('/value="dark"\s+checked/') ->and($html)->toMatch('/value="dark"\s+checked/')
->not->toMatch('/value="light"\s+checked/'); ->not->toMatch('/value="light"\s+checked/');
@@ -108,7 +115,8 @@ it('names unbound radios after their group and checks the given value', function
it('shows the errors of a radio group', function () { it('shows the errors of a radio group', function () {
expect((string) $this->withViewErrors(['audience' => ['Choose who can open it.']]) expect((string) $this->withViewErrors(['audience' => ['Choose who can open it.']])
->blade('<x-radio wire:model="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />')) ->blade('<x-radio wire:model="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-invalid') ->toContain('data-md-invalid')
->toMatch('/<div id="radio-[0-9a-f]{12}-support" data-md-selection-support role="alert">/')
->toContain('Choose who can open it.'); ->toContain('Choose who can open it.');
}); });
@@ -116,17 +124,50 @@ it('draws a switch on a checkbox with the switch role', function () {
$html = (string) $this->blade('<x-toggle id="public" label="Public link" wire:model.live="public" right />'); $html = (string) $this->blade('<x-toggle id="public" label="Public link" wire:model.live="public" right />');
expect($html) expect($html)
->toContain('<label for="public" data-selection') ->toMatch('/<div data-md-toggle\s*>/')
->toContain('data-switch') ->toMatch('/<label for="public" data-md-selection-row\\s+data-md-right\\s*>/')
->toContain('data-md-switch')
->toContain('role="switch"') ->toContain('role="switch"')
->toContain('type="checkbox"') ->toContain('type="checkbox"')
->toContain('wire:model.live="public"') ->toContain('wire:model.live="public"')
->toContain('data-handle') ->toContain('data-md-switch-handle')
->toContain('flex-row-reverse') ->not->toContain('class=')
->not->toContain('data-icons'); ->not->toContain('data-md-icons');
}); });
it('puts icons on a switch\'s handle', function () { it('puts icons on a switch\'s handle', function () {
expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-icons="both"')->toContain('data-on')->toContain('data-off') expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-md-icons="both"')->toContain('data-md-switch-on')->toContain('data-md-switch-off')
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-icons="selected"'); ->and((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('--md-icon-size: 16px')
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-md-icons="selected"');
});
it('draws the checkbox from its stylesheet, on the row the selection controls share', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/checkbox.css');
expect($css)->toContain("@import './selection.css';")
->toContain("@import './icon.css';")
->toContain('@layer material.components')
->toMatch('/\\[data-md-checkbox-box\\] \\{[^}]*width: 18px;[^}]*height: 18px;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/selection.css'))
->toContain('[data-md-selection-row]')
->toContain('@layer material.components')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/checkbox.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('selection.css');
});
it('lays the radio group out from its stylesheet, inline only from 600px', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/radio.css');
expect($css)->toContain("@import './selection.css';")
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-radio\\]\\[data-md-inline\\] \\[data-md-radio-options\\] \\{\\s*display: flex;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/radio.css';")
->and((string) $this->blade('<x-radio name="size" class="mt-4" style="order: 1" :options="[[\'id\' => \'s\', \'name\' => \'S\']]" />'))
->toMatch('/data-md-radio\\s+class="mt-4" style="order: 1"/');
});
it('draws the switch from its stylesheet', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/toggle.css'))
->toContain("@import './selection.css';")
->toMatch('/\\[data-md-switch\\] \\{[^}]*width: 52px;[^}]*height: 32px;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/toggle.css';");
}); });
+100 -66
View File
@@ -19,8 +19,10 @@ it('is a labelled native range input under a drawing only the script touches', f
->toContain('value="40"') ->toContain('value="40"')
->toContain('x-data="materialSlider"') ->toContain('x-data="materialSlider"')
->toContain('x-on:pointerdown="press($event)"') ->toContain('x-on:pointerdown="press($event)"')
->toContain('data-slider-drawing wire:ignore aria-hidden="true"') ->toContain('data-md-slider-control')
->toContain('noscript:appearance-auto') ->toContain('<div data-md-slider-drawing wire:ignore aria-hidden="true">')
->toContain('data-md-slider-input="start"')
->not->toContain('class=')
->not->toContain('role="group"'); ->not->toContain('role="group"');
}); });
@@ -28,19 +30,24 @@ it('draws the first frame on the server: the track split around the handle, and
$html = (string) $this->blade('<x-slider value="40" />'); $html = (string) $this->blade('<x-slider value="40" />');
expect($html) expect($html)
->toMatch('/data-segment="active"\s+class="[^"]*bg-primary[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/') ->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/')
->toMatch('/data-segment="end"\s+class="[^"]*bg-secondary-container[^"]*"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/') ->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/')
->toMatch('/data-segment="start"\s+hidden/') ->toMatch('/data-md-slider-segment="start"\s+hidden/')
->toContain('style="left: calc(100% - 8px)"') ->toContain('style="left: calc(100% - 8px)"')
->toContain('<span data-handle="start" class="group/thumb absolute inset-y-0 w-0" style="left: calc(40% + 0px)">') ->toContain('<span data-md-slider-handle="start" style="left: calc(40% + 0px)">')
->toMatch('/data-value-label\s+class="[^"]*opacity-0[^"]*"\s*>40<\/span>/'); ->toContain('<span data-md-slider-value>40</span>')
->toMatch('/<div\s+data-md-slider\s+data-md-size="xs"\s+data-md-color="primary"\s+data-md-value-label="drag"/');
}); });
it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () { it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () {
expect((string) $this->blade('<x-slider value="40" />')) $css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
->toContain('flex h-11 min-w-12 items-center')
->toMatch('/data-value-label[^>]*class="[^"]*origin-bottom/') expect((string) $this->blade('<x-slider value="40" />'))->toMatch('/data-md-slider-stop="end"\s+style="left: calc\(100% - 8px\)"/')
->toMatch('/data-stop="end"[^>]*class="[^"]*bg-on-secondary-container/'); ->and($css)
->toMatch('/\[data-md-slider-value\] \{[^}]*min-width: var\(--md-sys-measurement-space600\);[^}]*height: 44px;[^}]*background-color: var\(--md-sys-color-inverse-surface\);[^}]*transform-origin: bottom;/')
->toMatch('/:is\(\[data-md-slider-tick\], \[data-md-slider-stop\]\) \{[^}]*background-color: var\(--slider-on-inactive\);/')
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/slider.css';");
}); });
it('snaps and clamps the value to the step grid', function (string $template, string $value) { it('snaps and clamps the value to the step grid', function (string $template, string $value) {
@@ -77,7 +84,8 @@ it('gives a range two inputs bound to the ends of an array, in a named group', f
->toContain('aria-label="Range start"') ->toContain('aria-label="Range start"')
->toContain('aria-label="Range end"') ->toContain('aria-label="Range end"')
->toContain('id="price-end"') ->toContain('id="price-end"')
->toContain('data-handle="end"') ->toContain('data-md-slider-handle="end"')
->toContain('data-md-slider-input="end"')
->not->toContain('<label for='); ->not->toContain('<label for=');
expect(substr_count($html, 'name="price[]"'))->toBe(2) expect(substr_count($html, 'name="price[]"'))->toBe(2)
@@ -123,7 +131,7 @@ it('replaces the hint with the validation message and marks the input invalid',
expect((string) $this->blade('<x-slider name="volume" hint="Quietly" id="volume" />')) expect((string) $this->blade('<x-slider name="volume" hint="Quietly" id="volume" />'))
->toContain('Too loud.') ->toContain('Too loud.')
->toContain('text-error') ->toContain('<div id="volume-hint" data-md-slider-errors>')
->toContain('aria-invalid="true"') ->toContain('aria-invalid="true"')
->toContain('aria-describedby="volume-hint"') ->toContain('aria-describedby="volume-hint"')
->not->toContain('Quietly'); ->not->toContain('Quietly');
@@ -133,105 +141,131 @@ it('replaces the hint with the validation message and marks the input invalid',
->not->toContain('Per night'); ->not->toContain('Per night');
expect((string) $this->blade('<x-slider name="balance" hint="Centre is even" />')) expect((string) $this->blade('<x-slider name="balance" hint="Centre is even" />'))
->toContain('Centre is even') ->toMatch('/data-md-slider-hint>Centre is even<\/p>/')
->not->toContain('aria-invalid'); ->not->toContain('aria-invalid');
}); });
it('sizes the track and the handle by Expressive\'s tokens', function (string $size, string $track, string $handle, string $body) { it('sizes the track and the handle by Expressive\'s tokens', function (string $size, string $track, string $handle, string $across) {
expect((string) $this->blade('<x-slider :size="$size" />', ['size' => $size])) expect((string) $this->blade('<x-slider :size="$size" />', ['size' => $size]))
->toContain("data-size=\"{$size}\"") ->toContain("data-md-size=\"{$size}\"");
->toContain("-translate-y-1/2 {$track}\"")
->toContain($handle) $css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
->toMatch("/group\\/slider [^\"]* {$body} /"); $block = $size === 'xs'
? '/\[data-md-slider\] \{[^}]*/'
: '/\[data-md-slider\]\[data-md-size=\''.$size.'\'\] \{[^}]*/';
preg_match($block, $css, $rule);
foreach (['--slider-across' => $across, '--slider-track' => $track, '--slider-handle' => $handle] as $property => $value) {
if ($size === 'sm' && $property !== '--slider-track') {
// sm keeps xs's handle and height.
continue;
}
expect($rule[0])->toContain("{$property}: {$value};");
}
// A focused handle is 6px shorter, so its ring stays clear of the label.
expect($css)->toContain('height: calc(var(--slider-handle) - 6px);');
})->with([ })->with([
'xs' => ['xs', 'h-4', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'], 'xs' => ['xs', '16px', '44px', '48px'],
'sm' => ['sm', 'h-6', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'], 'sm' => ['sm', '24px', '44px', '48px'],
'md' => ['md', 'h-10', 'h-13 group-data-focused/thumb:h-11.5', 'h-13'], 'md' => ['md', '40px', '52px', '52px'],
'lg' => ['lg', 'h-14', 'h-17 group-data-focused/thumb:h-15.5', 'h-17'], 'lg' => ['lg', '56px', '68px', '68px'],
'xl' => ['xl', 'h-24', 'h-27 group-data-focused/thumb:h-25.5', 'h-27'], 'xl' => ['xl', '96px', '108px', '108px'],
]); ]);
it('insets an icon in the track from md, but not on a small, range or centred slider', function () { it('insets an icon in the track from md, but not on a small, range or centred slider', function () {
expect((string) $this->blade('<x-slider size="md" icon="volume_up" value="60" />')) expect((string) $this->blade('<x-slider size="md" icon="volume_up" value="60" />'))
->toContain('data-track-icon') ->toMatch('/data-md-slider-icon\s+data-md-active/')
->toMatch('/data-track-icon\s+data-active/') ->toContain('--md-icon-size: 24px');
->toContain('text-on-secondary-container data-active:text-on-primary');
expect((string) $this->blade('<x-slider size="xl" icon="volume_up" value="2" />')) expect((string) $this->blade('<x-slider size="xl" icon="volume_up" value="2" />'))
->toMatch('/data-track-icon\s+class/') ->toMatch('/data-md-slider-icon\s+style=/')
->toContain('size-8'); ->toContain('--md-icon-size: 32px');
foreach (['<x-slider size="sm" icon="volume_up" />', '<x-slider size="lg" icon="volume_up" range />', '<x-slider size="lg" icon="volume_up" centered />'] as $template) { foreach (['<x-slider size="sm" icon="volume_up" />', '<x-slider size="lg" icon="volume_up" range />', '<x-slider size="lg" icon="volume_up" centered />'] as $template) {
expect((string) $this->blade($template))->not->toContain('data-track-icon'); expect((string) $this->blade($template))->not->toContain('data-md-slider-icon');
} }
}); });
it('fills a centred slider from the middle', function () { it('fills a centred slider from the middle', function () {
expect((string) $this->blade('<x-slider :min="-50" :max="50" value="25" centered />')) expect((string) $this->blade('<x-slider :min="-50" :max="50" value="25" centered />'))
->toContain('data-centered') ->toContain('data-md-centered')
->toMatch('/data-segment="start"\s+class="[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/') ->toMatch('/data-md-slider-segment="start"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/')
->toMatch('/data-segment="active"\s+class="[^"]*"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/') ->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/')
->toMatch('/data-segment="end"\s+class="[^"]*"\s+style="left: calc\(75% \+ 8px\)/'); ->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(75% \+ 8px\)/');
}); });
it('marks every step when asked, and no more than 200 of them', function () { it('marks every step when asked, and no more than 200 of them', function () {
$html = (string) $this->blade('<x-slider ticks :max="10" value="5" />'); $html = (string) $this->blade('<x-slider ticks :max="10" value="5" />');
expect(substr_count($html, 'data-tick="'))->toBe(11) expect(substr_count($html, 'data-md-slider-tick="'))->toBe(11)
->and($html)->toContain('data-tick="0.5"') ->and($html)->toContain('data-md-slider-tick="0.5"')
->and($html)->toContain('bg-on-secondary-container data-active:bg-on-primary'); ->and($html)->toMatch('/data-md-slider-tick="0.2"\s+data-md-active/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'))
->toMatch('/\[data-md-slider-tick\]\[data-md-active\] \{\s*background-color: var\(--slider-on-active\);/');
expect((string) $this->blade('<x-slider ticks :max="1000" />'))->not->toContain('data-tick=') expect((string) $this->blade('<x-slider ticks :max="1000" />'))->not->toContain('data-md-slider-tick=')
->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-tick=') ->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-md-slider-tick=')
->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-tick='); ->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-md-slider-tick=');
}); });
it('shows the value label on drag, always, or never', function () { it('shows the value label on drag, always, or never', function () {
expect((string) $this->blade('<x-slider />'))->toContain('opacity-0 scale-75 group-data-pressed/thumb:opacity-100'); expect((string) $this->blade('<x-slider />'))->toContain('data-md-value-label="drag"')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'))
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='drag'\\] \\[data-md-slider-value\\] \\{\\s*opacity: 0;\\s*scale: 0.75;/")
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='always'\\] \\[data-md-slider-control\\] \\{\\s*margin-top: var\\(--md-sys-measurement-space600\\);/");
expect((string) $this->blade('<x-slider value-label="always" value="30" />')) expect((string) $this->blade('<x-slider value-label="always" value="30" />'))
->toContain('data-value-label') ->toContain('data-md-value-label="always"')
->toContain('mt-12') ->toContain('<span data-md-slider-value>30</span>');
->not->toContain('opacity-0 scale-75');
expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-value-label'); expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-md-slider-value');
}); });
it('stands a slider up on request, turning the drawing a quarter', function () { it('stands a slider up on request, turning the drawing a quarter', function () {
$html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="h-64" />'); $html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="h-64" />');
expect($html) expect($html)
->toContain('data-orientation="vertical"') ->toContain('data-md-orientation="vertical"')
->toContain('aria-orientation="vertical"') ->toContain('aria-orientation="vertical"')
// The slider is as wide as a horizontal one is tall, and as long as its wrapper. ->toContain('class="h-64"');
->toContain('w-12')
->toContain('touch-pan-x h-48 min-h-0 flex-auto [container-type:size]')
->toContain('h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90')
// The value label is turned back, so it lands beside the handle and reads across.
->toMatch('/data-value-label[^>]*class="[^"]*rotate-90/')
->not->toContain('origin-bottom')
->not->toContain('rtl:-scale-x-100');
// The sizes are the horizontal ones, across instead of along. $css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
expect((string) $this->blade('<x-slider orientation="vertical" size="xl" />'))->toContain('w-27')
->and((string) $this->blade('<x-slider orientation="vertical" value-label="always" />'))->toContain('ms-12'); expect($css)
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-control\\] \\{[^}]*width: var\\(--slider-across\\);[^}]*height: 192px;[^}]*container-type: size;/")
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-drawing\\] \\{[^}]*width: calc\\(100cqh - var\\(--md-sys-measurement-space50\\)\\);[^}]*height: 100cqw;[^}]*rotate: -90deg;/")
// The value label is turned back, so it lands beside the handle and reads across.
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-value\\] \\{[^}]*rotate: 90deg;/")
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='always'\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-control\\] \\{[^}]*margin-inline-start: var\\(--md-sys-measurement-space600\\);/");
}); });
it('keeps a range slider horizontal, as M3 asks', function () { it('keeps a range slider horizontal, as M3 asks', function () {
expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />')) expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />'))
->not->toContain('data-orientation') ->not->toContain('data-md-orientation')
->not->toContain('aria-orientation') ->not->toContain('aria-orientation');
->toContain('h-12');
}); });
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) { it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color])) expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
->toContain("absolute inset-y-0 {$active} group-has-disabled/slider:bg-on-surface/38") ->toContain('data-md-color="'.($color === 'pink' ? 'primary' : $color).'"')
->toContain("absolute inset-y-0 {$inactive} group-has-disabled/slider:bg-on-surface/12")
->toMatch('/<input[^>]*\sdisabled[\s>]/'); ->toMatch('/<input[^>]*\sdisabled[\s>]/');
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
if ($color === 'primary' || $color === 'pink') {
expect($css)->toMatch("/\\[data-md-slider\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
} else {
expect($css)->toMatch("/\\[data-md-slider\\]\\[data-md-color='{$color}'\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
}
expect($css)->toMatch('/\[data-md-slider\]:has\(\[data-md-slider-input\]:disabled\) \{[^}]*--slider-active: var\(--slider-disabled-active\);[^}]*--slider-inactive: var\(--slider-disabled-inactive\);/');
})->with([ })->with([
'primary' => ['primary', 'bg-primary', 'bg-secondary-container'], 'primary' => ['primary', 'primary', 'secondary-container'],
'tertiary' => ['tertiary', 'bg-tertiary', 'bg-tertiary-container'], 'tertiary' => ['tertiary', 'tertiary', 'tertiary-container'],
'error' => ['error', 'bg-error', 'bg-error-container'], 'error' => ['error', 'error', 'error-container'],
'unknown' => ['pink', 'bg-primary', 'bg-secondary-container'], 'unknown' => ['pink', 'primary', 'secondary-container'],
]); ]);
+7 -7
View File
@@ -19,14 +19,14 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
$html = (string) $this->blade('<x-timepicker id="starts" label="Starts at" hint="In your time zone" value="14:30" format="24" />'); $html = (string) $this->blade('<x-timepicker id="starts" label="Starts at" hint="In your time zone" value="14:30" format="24" />');
expect($html) expect($html)
->toContain('<label for="starts" class="field-label">Starts at</label>') ->toContain('<label for="starts" data-md-field-label>Starts at</label>')
->toMatch('/<input[^>]*id="starts"[^>]*readonly/s') ->toMatch('/<input[^>]*id="starts"[^>]*readonly/s')
->toContain('value="14:30"') ->toContain('value="14:30"')
->toContain('aria-haspopup="dialog"') ->toContain('aria-haspopup="dialog"')
->toContain('aria-controls="starts-dialog"') ->toContain('aria-controls="starts-dialog"')
->toContain('aria-expanded="false"') ->toContain('aria-expanded="false"')
->toContain('aria-describedby="starts-support"') ->toContain('aria-describedby="starts-support"')
->toContain('<p id="starts-support" class="field-support">In your time zone</p>') ->toMatch('/<p id="starts-support" data-md-field-support\s*>In your time zone<\/p>/')
->toContain('x-on:keydown.enter.prevent="show()"') ->toContain('x-on:keydown.enter.prevent="show()"')
->toContain('data-timepicker-open') ->toContain('data-timepicker-open')
->toContain('aria-label="Choose time"') ->toContain('aria-label="Choose time"')
@@ -38,7 +38,7 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
->toContain('x-modelable="value"') ->toContain('x-modelable="value"')
->toContain("materialTimepicker( '14:30' ,") ->toContain("materialTimepicker( '14:30' ,")
->not->toContain('aria-invalid="true"') ->not->toContain('aria-invalid="true"')
->not->toContain('data-invalid'); ->not->toContain('data-md-invalid');
}); });
it('draws the dial as a slider, with twelve numbers per ring and M3\'s 24-hour inner ring', function () { it('draws the dial as a slider, with twelve numbers per ring and M3\'s 24-hour inner ring', function () {
@@ -153,7 +153,7 @@ it('shows the errors for its property in place of the hint', function () {
}); });
expect(Livewire::test('timepicker-errors')->html()) expect(Livewire::test('timepicker-errors')->html())
->toContain('data-invalid') ->toContain('data-md-invalid')
->toContain('aria-invalid="true"') ->toContain('aria-invalid="true"')
->toContain('aria-describedby="starts-support"') ->toContain('aria-describedby="starts-support"')
->toContain('Choose a time during opening hours.') ->toContain('Choose a time during opening hours.')
@@ -165,13 +165,13 @@ it('puts the field props on the field, its attributes on the input, and posts a
expect($html) expect($html)
->toMatch('/^<div\s+class="w-60"/') ->toMatch('/^<div\s+class="w-60"/')
->toContain('data-variant="filled"') ->toContain('data-md-variant="filled"')
->toContain('data-size="sm"') ->toContain('data-md-size="sm"')
->toContain('data-timepicker-field') ->toContain('data-timepicker-field')
->toContain('required') ->toContain('required')
->toContain('placeholder="hh:mm"') ->toContain('placeholder="hh:mm"')
->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />') ->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />')
->toContain('data-field-clear') ->toContain('data-md-field-clear')
->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s') ->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s')
->and(substr_count($html, 'class="field w-60"'))->toBe(0); ->and(substr_count($html, 'class="field w-60"'))->toBe(0);
}); });
+1 -1
View File
@@ -46,7 +46,7 @@ it('renders components that use others under a prefix', function () {
expect(Blade::render('<x-m::drawer title="Share" with-close-button separator><x-m::input label="Name" clearable /></x-m::drawer>')) expect(Blade::render('<x-m::drawer title="Share" with-close-button separator><x-m::input label="Name" clearable /></x-m::drawer>'))
->toContain('aria-label="Close"') ->toContain('aria-label="Close"')
->toContain('role="separator"') ->toContain('role="separator"')
->toContain('data-field-clear'); ->toContain('data-md-field-clear');
}); });
it('is not shadowed by an application component of the same name', function () { it('is not shadowed by an application component of the same name', function () {
+5 -1
View File
@@ -555,9 +555,13 @@ it('draws the interaction classes as the utilities the Tailwind components still
->and(stylesheetBlock($interaction, '.md-touch-target'))->toContain('min-width: var(--md-sys-measurement-space600);'); ->and(stylesheetBlock($interaction, '.md-touch-target'))->toContain('min-width: var(--md-sys-measurement-space600);');
}); });
it('declares the material layers in the Workbench before Tailwind\'s', function () { it('declares the material layers in the Workbench between Tailwind\'s preflight and its utilities', function () {
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css')); $app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
// The first statement orders the layers: above preflight, whose `* { padding: 0 }` would
// otherwise beat a rewritten component's padding, and below the utilities.
expect(trim($app))->toStartWith('@layer theme, base, material, components, utilities;');
$foundation = strpos($app, "@import '../../../resources/css/foundation.css';"); $foundation = strpos($app, "@import '../../../resources/css/foundation.css';");
$tailwind = strpos($app, "@import 'tailwindcss'"); $tailwind = strpos($app, "@import 'tailwindcss'");
$components = strpos($app, "@import '../../../resources/css/tailwind.css';"); $components = strpos($app, "@import '../../../resources/css/tailwind.css';");