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.
- `<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-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>`
+17
View File
@@ -14,6 +14,23 @@
/* Actions and communication */
/* 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 */
+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
* 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
* (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):
*
* .field the whole thing; `class` from the call site lands here; data-variant, data-size
* .field-box the 56px row: icon, prefix, control, suffix, trailing
* .field-control the <input>, <select> or <textarea>
* .field-trailing what ends the row; .field-error is the error icon M3 asks for as the
* error state's second indicator
* .field-outline the fieldset and its legend (the filled field's indicator line)
* .field-label the visible label
* .field-support-row the row under the field
* .field-support the hint, or the error in its place
* .field-counter M3's character counter, `n/max`, at the end of that row
* [data-md-field] the whole thing; `class` from the call site lands here;
* data-md-variant, data-md-size, data-md-invalid, data-md-floated,
* data-md-mono, data-md-full, data-md-readonly
* [data-md-field-box] the 56px row: icon, prefix, control, suffix, trailing
* [data-md-field-icon] the leading icon
* [data-md-field-affix] a prefix or suffix beside the value
* [data-md-field-control] the <input>, <select> or <textarea>
* [data-md-field-trailing] what ends the row; [data-md-field-error] is the error icon M3 asks
* for as the error state's second indicator; [data-md-field-button]
* 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.
*/
@layer components {
.field {
--field-height: 3.5rem;
--field-pad: 1rem;
--field-icon: 1.5rem;
--field-gap: 1rem;
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[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-edge: var(--md-sys-color-outline);
--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;
* 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
* under half the `large` class's 1200px, a little over the `expanded` class's half, and holds
* about the 70 characters body-large reads best at. It is a ceiling, not a width — a narrower
* pane still gets a narrower field. A `max-w-*` class from the call site beats it, because
* utilities come after this layer, and `full` takes it off for a field that really is the width
* of its pane (a search-and-filter row, an editor). Below `medium` nothing is bounded. */
@media (width >= 37.5rem) {
.field:not([data-full]) {
* 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 — a measure of text, so it is in rem and grows
* with the text. It is a ceiling, not a width — a narrower pane still gets a narrower field. A
* width rule from the call site beats it, because an application's CSS and a utility both
* outrank this layer, and `full` takes it off for a field that really is the width of its pane (a
* search-and-filter row, an editor). Below `medium` nothing is bounded. */
@media (width >= 600px) {
[data-md-field]:not([data-md-full]) {
max-width: 40rem;
}
}
/* 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[data-size="sm"] {
--field-height: 2.5rem;
--field-pad: 0.75rem;
--field-icon: 1.25rem;
--field-gap: 0.5rem;
[data-md-field][data-md-size='sm'] {
--field-height: 40px;
--field-pad: 12px;
--field-icon: 20px;
--field-gap: var(--md-sys-measurement-space100);
}
.field[data-size="xs"] {
--field-height: 2rem;
--field-pad: 0.625rem;
--field-icon: 1rem;
--field-gap: 0.375rem;
[data-md-field][data-md-size='xs'] {
--field-height: 32px;
--field-pad: var(--md-sys-measurement-space125);
--field-icon: 16px;
--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-box {
[data-md-field-box] {
position: relative;
display: flex;
align-items: center;
@@ -90,7 +105,7 @@
cursor: text;
}
.field-control {
[data-md-field-control] {
flex: 1 1 0%;
min-width: 0;
align-self: stretch;
@@ -102,154 +117,38 @@
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);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
.field[data-mono] .field-control {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
[data-md-field][data-md-mono] [data-md-field-control] {
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);
opacity: 1;
}
/* 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. */
.field-control:-webkit-autofill {
[data-md-field-control]:-webkit-autofill {
-webkit-text-fill-color: var(--md-sys-color-on-surface);
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. */
.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;
}
.field-arrow {
[data-md-field-arrow] {
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
.field-icon,
.field-trailing {
[data-md-field-icon],
[data-md-field-trailing] {
flex: none;
width: 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. */
.field-error {
[data-md-field-error] {
color: var(--md-sys-color-error);
}
.field-icon,
.field-arrow {
[data-md-field-icon],
[data-md-field-arrow] {
pointer-events: none;
}
/* 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;
display: grid;
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);
cursor: pointer;
outline: none;
}
.field-button::before {
content: "";
[data-md-field-button]::before {
content: '';
position: absolute;
inset: 50% auto auto 50%;
width: calc(var(--field-icon) + 1rem);
height: calc(var(--field-icon) + 1rem);
width: calc(var(--field-icon) + var(--md-sys-measurement-space200));
height: calc(var(--field-icon) + var(--md-sys-measurement-space200));
translate: -50% -50%;
border-radius: inherit;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
.field-button:hover::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent);
[data-md-field-button]:hover::before {
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 {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
[data-md-field-button]:focus-visible::before {
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-offset: 2px;
}
.field-button:active::before {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
[data-md-field-button]:active::before {
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;
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-lg);
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);
}
.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;
inset: -0.3125rem 0 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-radius: inherit;
pointer-events: none;
@@ -327,7 +228,7 @@
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;
width: auto;
max-width: 100%;
@@ -341,12 +242,12 @@
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;
padding-inline: 0.25rem;
padding-inline: var(--md-sys-measurement-space50);
}
.field-label {
[data-md-field-label] {
position: absolute;
inset-inline-start: var(--field-pad);
top: 0;
@@ -366,47 +267,42 @@
/* `required` is marked from the control itself, in the label and in the notch,
so the gap is cut for the asterisk too. */
.field:has(.field-control:required) .field-label::after,
.field:has(.field-control:required) .field-outline > legend > span::after {
content: " *";
[data-md-field]:has([data-md-field-control]:required) [data-md-field-label]::after,
[data-md-field]:has([data-md-field-control]:required) [data-md-field-outline] > legend > span::after {
content: ' *';
}
/* 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
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`. */
.field:not([data-floated]):has(.field-label):has(.field-control:placeholder-shown):not(:focus-within) {
.field-label {
a date always hold a value, so they mark themselves `data-md-floated`. */
[data-md-field]:not([data-md-floated]):has([data-md-field-label]):has([data-md-field-control]:placeholder-shown):not(:focus-within) {
[data-md-field-label] {
top: 50%;
inset-inline-start: var(--field-start);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
}
.field-outline > legend {
[data-md-field-outline] > legend {
max-width: 0.01px;
}
.field-affix {
[data-md-field-affix] {
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);
}
/* A textarea's label rests on its first line, not in the middle of the box. */
.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 {
[data-md-field]:has([data-md-field-label]):not(:focus-within) [data-md-field-control]::placeholder {
color: transparent;
}
/* 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;
}
@@ -414,82 +310,82 @@
control no state layer at all, and the disabled edge below is inherited, so a declaration
here would beat it whatever the selector weighs. */
@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-box:focus-within {
[data-md-field-box]:focus-within {
--field-edge: 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;
}
.field[data-invalid] {
[data-md-field][data-md-invalid] {
--field-edge: var(--md-sys-color-error);
--field-ink: var(--md-sys-color-error);
}
@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[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-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
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
stays for an application that wants a mark of its own. */
.field:has(.field-control:disabled) {
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
--field-ink: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-field]:has([data-md-field-control]:disabled) {
--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) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
.field-box {
[data-md-field-box] {
cursor: default;
}
.field-control,
.field-icon,
.field-affix,
.field-trailing {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-field-control],
[data-md-field-icon],
[data-md-field-affix],
[data-md-field-trailing] {
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
end of the same row (specs: "Padding between supporting text and counter | 16dp", which the
two paddings between them already make). */
.field-support-row {
two paddings between them already make). Supporting text sits 4px under the field. */
[data-md-field-support-row] {
display: flex;
align-items: baseline;
}
.field-support {
[data-md-field-support] {
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);
font: var(--md-sys-typescale-body-sm);
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);
}
.field-counter {
[data-md-field-counter] {
flex: none;
margin-inline-start: auto;
padding-block-start: 0.25rem;
padding-block-start: var(--md-sys-measurement-space50);
padding-inline-end: var(--field-pad);
color: var(--md-sys-color-on-surface-variant);
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. */
.field-counter[data-over] {
[data-md-field-counter][data-md-over] {
color: var(--md-sys-color-error);
}
/* ---- 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;
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);
}
@media (hover: hover) {
.field[data-variant="filled"] .field-box:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, var(--md-sys-color-surface-container-highest));
[data-md-field][data-md-variant='filled'] [data-md-field-box]:hover {
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. */
.field[data-variant="filled"] .field-outline {
[data-md-field][data-md-variant='filled'] [data-md-field-outline] {
inset: auto 0 0;
padding: 0;
border: 0;
@@ -525,51 +421,34 @@
border-radius: 0;
}
.field[data-variant="filled"] .field-outline > legend {
[data-md-field][data-md-variant='filled'] [data-md-field-outline] > legend {
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;
}
/* The label floats inside the box, above the value, and the value sits below it. */
.field[data-variant="filled"] .field-label {
top: 1rem;
[data-md-field][data-md-variant='filled'] [data-md-field-label] {
top: var(--md-sys-measurement-space200);
inset-inline-start: var(--field-start);
max-width: calc(100% - var(--field-start) - var(--field-pad));
}
.field[data-variant="filled"]:has(.field-label) .field-control {
padding-block: 1.5rem 0.5rem;
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) [data-md-field-control] {
padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
}
.field[data-variant="filled"]:has(.field-label) textarea.field-control {
--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 {
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) [data-md-field-affix] {
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) {
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[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) 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);
}
}
+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
* (`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.
* - `<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
* 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;
overflow-y: auto;
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
when there is no room below. */
.field-menu[popover] {
[data-md-field-menu][popover] {
inset: auto;
top: anchor(bottom);
left: anchor(left);
@@ -172,12 +172,12 @@
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);
cursor: default;
}
.field-option {
[data-md-field-option] {
display: flex;
align-items: center;
gap: 0.75rem;
@@ -190,30 +190,30 @@
}
/* `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);
}
.field-option[aria-selected="true"] {
[data-md-field-option][aria-selected="true"] {
background: var(--md-sys-color-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));
}
.field-check {
[data-md-field-check] {
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;
}
@supports (appearance: base-select) {
select.field-control,
select.field-control::picker(select) {
select[data-md-field-control],
select[data-md-field-control]::picker(select) {
appearance: base-select;
}
@@ -223,7 +223,7 @@
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
closes with a fade, as `<x-menu>` does. */
select.field-control::picker(select) {
select[data-md-field-control]::picker(select) {
position-try-order: normal;
max-block-size: 18rem;
max-inline-size: calc(100vw - 2rem);
@@ -242,7 +242,7 @@
transition-behavior: allow-discrete;
}
select.field-control:open::picker(select) {
select[data-md-field-control]:open::picker(select) {
opacity: 1;
transition-property: opacity, scale, display, overlay;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
@@ -250,13 +250,13 @@
}
@starting-style {
select.field-control:open::picker(select) {
select[data-md-field-control]:open::picker(select) {
opacity: 0;
scale: 0.95;
}
}
select.field-control option {
select[data-md-field-control] option {
display: flex;
align-items: center;
gap: 0.75rem;
@@ -272,31 +272,31 @@
}
@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);
}
}
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);
}
select.field-control option:checked {
select[data-md-field-control] option:checked {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
@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));
}
}
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));
}
select.field-control option:disabled {
select[data-md-field-control] option:disabled {
background: transparent;
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
cursor: default;
@@ -305,7 +305,7 @@
/* 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
takes the row's ink. */
select.field-control option::checkmark {
select[data-md-field-control] option::checkmark {
content: "";
order: 1;
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
* `--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-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.
*
* [data-search] the root; data-open, data-full-screen, data-trigger
* [data-search-scrim] over the page while the view is docked
* [data-search-trigger] data-trigger="icon" only: the icon button that expands the view
* [data-search-bar] the pill, above the view
* [data-search-leading], [data-search-field] (the combobox around [data-search-input]),
* [data-search-clear], [data-search-trailing]
* [data-search-view] the container behind the bar
* [data-search-suggestions] before the first keystroke
* [data-search-results] once something is typed
* [data-search-status] the polite live region that counts whichever list is on screen
* Hovered and focused buttons in the bar take M3's state-layer opacities (tokens/state.css), and the
* docked scrim is M3's scrim role at 32%, which no system token names. The breakpoint that makes the
* view full screen is decided in resources/js/search.js (`upTo('medium')`, 600px).
*
* [data-md-search] the root; data-md-open, data-md-full-screen, data-md-trigger
* [data-md-search-scrim] over the page while the view is docked
* [data-md-search-trigger] data-md-trigger="icon" only: the icon button that expands the view
* [data-md-search-bar] the pill, above the view
* [data-md-search-leading], [data-md-search-field] (the combobox around
* [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 {
[data-search] {
--search-height: 3.5rem;
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-search] {
--search-height: 56px;
/* M3: min 360px, max 720px, and wider while focused. */
--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;
}
/* 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. */
[data-search-scrim] {
[data-md-search-scrim] {
position: fixed;
inset: 0;
z-index: -1;
@@ -53,20 +63,20 @@
}
@starting-style {
[data-search-scrim] {
[data-md-search-scrim] {
opacity: 0;
}
}
[data-search-bar] {
[data-md-search-bar] {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.25rem;
gap: var(--md-sys-measurement-space50);
width: min(100%, var(--search-width));
height: var(--search-height);
padding-inline: 0.75rem;
padding-inline: 12px;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-high);
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. */
[data-search][data-open] [data-search-bar] {
[data-md-search][data-md-open] [data-md-search-bar] {
width: min(100%, var(--search-open-width));
padding-inline: 0.25rem;
padding-inline: var(--md-sys-measurement-space50);
}
@media (hover: hover) {
[data-search]:not([data-open]) [data-search-bar]:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, var(--md-sys-color-surface-container-high));
[data-md-search]:not([data-md-open]) [data-md-search-bar]:hover {
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;
}
[data-search-leading],
[data-search-clear] {
[data-md-search-leading],
[data-md-search-clear] {
display: grid;
flex: none;
place-items: center;
width: 3rem;
height: 3rem;
width: var(--md-sys-measurement-space600);
height: var(--md-sys-measurement-space600);
border-radius: var(--md-sys-shape-corner-full);
}
[data-search-leading] {
[data-md-search-leading] {
color: var(--md-sys-color-on-surface);
}
[data-search-clear] {
[data-md-search-clear] {
color: var(--md-sys-color-on-surface-variant);
cursor: pointer;
}
[data-search-bar] button {
[data-md-search-bar] button {
outline: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
[data-search-bar] button:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent);
[data-md-search-bar] button:hover {
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 {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
[data-md-search-bar] button:focus-visible {
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-offset: -3px;
}
/* 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;
flex: 1 1 0%;
min-width: 0;
height: 100%;
}
[data-search-input] {
[data-md-search-input] {
flex: 1 1 0%;
min-width: 0;
height: 100%;
padding-inline: 0.25rem;
padding-inline: var(--md-sys-measurement-space50);
appearance: none;
background: transparent;
color: var(--md-sys-color-on-surface);
@@ -150,41 +160,41 @@
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);
opacity: 1;
}
[data-search-input]::-webkit-search-cancel-button,
[data-search-input]::-webkit-search-decoration {
[data-md-search-input]::-webkit-search-cancel-button,
[data-md-search-input]::-webkit-search-decoration {
appearance: 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;
}
[data-search-trailing] {
[data-md-search-trailing] {
display: flex;
flex: none;
align-items: center;
gap: 0.25rem;
padding-inline-end: 0.25rem;
gap: var(--md-sys-measurement-space50);
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;
}
[data-search-view] {
[data-md-search-view] {
position: absolute;
inset-inline: 0;
top: 0;
display: flex;
flex-direction: column;
max-width: var(--search-open-width);
max-height: min(40rem, 70dvh);
max-height: min(640px, 70dvh);
padding-top: var(--search-height);
overflow: hidden;
border-radius: var(--md-sys-shape-corner-xl);
@@ -198,7 +208,7 @@
}
@starting-style {
[data-search-view] {
[data-md-search-view] {
opacity: 0;
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
about; a search with nothing to show stays the height of its bar. */
[data-search-view]:has([data-search-results], [data-search-suggestions]) {
min-height: 15rem;
[data-md-search-view]:has([data-md-search-results], [data-md-search-suggestions]) {
min-height: 240px;
}
/* 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
keystroke; only one of the two is ever on screen. */
[data-search-results],
[data-search-suggestions] {
[data-md-search-results],
[data-md-search-suggestions] {
min-height: 0;
overflow-y: auto;
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
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. */
[data-search][data-trigger="icon"] {
[data-md-search][data-md-trigger='icon'] {
flex: none;
width: 3rem;
height: 3rem;
width: var(--md-sys-measurement-space600);
height: var(--md-sys-measurement-space600);
}
[data-search-trigger] {
[data-md-search-trigger] {
display: grid;
place-items: center;
width: 3rem;
height: 3rem;
width: var(--md-sys-measurement-space600);
height: var(--md-sys-measurement-space600);
border-radius: var(--md-sys-shape-corner-full);
color: var(--md-sys-color-on-surface-variant);
cursor: pointer;
@@ -243,45 +253,55 @@
}
@media (hover: hover) {
[data-search-trigger]:hover {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent);
[data-md-search-trigger]:hover {
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 {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
[data-md-search-trigger]:focus-visible {
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-offset: -3px;
}
[data-search-trigger]:active {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
[data-md-search-trigger]:active {
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;
}
/* 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;
inset: 0 0 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-inline: 0.25rem;
padding-inline: var(--md-sys-measurement-space50);
border-radius: 0;
}
[data-search][data-full-screen] [data-search-view] {
[data-md-search][data-md-full-screen] [data-md-search-view] {
position: fixed;
inset: 0;
max-width: 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. */
background-color: var(--md-sys-color-surface-container-low);
border-radius: 0;
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,
* RadioButtonTokens, SwitchTokens, androidx Compose Material 3, Apache-2.0).
* What M3's three selection controls share: the checkbox (components/checkbox.css), the radio button
* (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
* (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.
* Hooks are data attributes, not class names: `checkbox`, `radio` and `toggle` are daisyUI's, which
* the design guard rejects.
* announces stay the browser's. The row around it is a `<label>`, so its text is pressable too:
*
* [data-selection] the <label> row; `data-invalid` when the server has an error for it
* [data-checkbox] the 18px box, its state layer a 40px circle around it
* input, [data-check], [data-mixed]
* [data-radio] the 20px ring and its 10px dot
* input, [data-dot]
* [data-switch] the 52×32 track; `data-icons` = "both" or "selected"
* input, [data-handle] (with [data-on] and [data-off] icons)
* [data-md-selection-row] the <label>: the control and its text, 16px apart; with a hint
* the control aligns to the first line; `data-md-right` puts the
* control at the end; `data-md-invalid` when the server has an error
* [data-md-checkbox-box] | [data-md-radio-button] | [data-md-switch] the control
* [data-md-selection-text] the label (body-large, on-surface) and the hint under it
* [data-md-selection-label], [data-md-selection-hint]
*
* Every state layer is on-surface when unselected and primary when selected, 8% on hover (only
* where a pointer can hover) and 10% on focus and press; the focus indicator is the package's
* secondary ring. A disabled control is on-surface at 38% and has no state layer.
* Every state layer is on-surface when unselected and primary when selected, at M3's hover opacity
* (only where a pointer can hover) and its focus and press opacities (tokens/state.css); the focus
* 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
* is pressed anywhere along its row, and a control with no label wears the `touch-target` utility
* (resources/css/tokens/state.css) so its box catches presses over M3's 48px minimum.
* is pressed anywhere along its row, and a control with no label wears `md-touch-target`
* (foundation/interaction.css) so its box catches presses over M3's 48px minimum.
*/
@layer components {
[data-selection] {
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@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;
-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;
}
[data-selection]:has(input:disabled) [data-selection-label] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
[data-md-selection-text] {
min-width: 0;
}
[data-checkbox],
[data-radio] {
position: relative;
display: grid;
flex: none;
place-items: center;
[data-md-selection-label] {
display: block;
color: var(--md-sys-color-on-surface);
font: var(--md-sys-typescale-body-lg);
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
font-variation-settings: normal;
}
[data-checkbox] > *,
[data-radio] > * {
grid-area: 1 / 1;
[data-md-selection-hint] {
display: block;
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-radio]::before,
[data-handle]::before {
content: "";
[data-md-selection-row]:has(input:disabled) [data-md-selection-label] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
:is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
content: '';
position: absolute;
border-radius: var(--md-sys-shape-corner-full);
pointer-events: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-checkbox] input,
[data-radio] input,
[data-switch] input {
:is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch]) input {
margin: 0;
appearance: none;
cursor: inherit;
@@ -74,275 +94,26 @@
transition-timing-function: var(--md-sys-motion-effects-fast);
}
/* The state layer. `--selection-ink` is what it is tinted with. */
[data-selection] {
--selection-ink: var(--md-sys-color-on-surface);
}
[data-selection]:has(input:checked),
[data-selection]:has(input:indeterminate) {
[data-md-selection-row]:has(input:checked),
[data-md-selection-row]:has(input:indeterminate) {
--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);
}
@media (hover: hover) {
[data-selection]:hover:not(:has(input:disabled)) :is([data-checkbox], [data-radio], [data-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) 8%, transparent);
[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) 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-selection]:not(:has(input:disabled)):active :is([data-checkbox], [data-radio], [data-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) 10%, transparent);
[data-md-selection-row]:has(input:focus-visible) :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
}
[data-checkbox]:has(input:focus-visible)::before,
[data-radio]:has(input:focus-visible)::before {
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);
[data-md-selection-row]:not(:has(input:disabled)):active :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), 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
* `<thead>`, `<tr>`, `<th>` and `<td>` inside it.
*
* So the styling is descendant selectors on the one attribute the component sets, all inside
* `:where()` and `@layer components`: a caller's `text-end` or `whitespace-nowrap` on a cell always
* wins. Header cells in title-small on-surface-variant over an outline-variant rule, body cells in
* body-medium between outline-variant rules. A row that opens something is `data-list-row` and
* So the styling is descendant selectors on the one attribute the component sets, `data-md-table`,
* with every cell and row selector inside `:where()`: a caller's alignment or wrapping on a cell
* always wins, whatever layer it comes from. Header cells in title-small on-surface-variant over an
* 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).
*
* A row is 52px: 16px above and below a body-medium line. Density is never applied by itself
* "don't apply density by default; offer an explicit density opt-in instead, keeping opt-out targets
* at >= 48x48 CSS px" (docs/reference/m3/foundations-supplement.md § Accessibility) so `dense`
* (`data-dense`) is the caller's decision, and it is the caller's to justify: a dense row is 36px,
* and a dense row inside an `xs` table 24px.
* A row is 52px: 16px above and below a body-medium line, 12px beside each cell. Density is never
* applied by itself "don't apply density by default; offer an explicit density opt-in instead,
* keeping opt-out targets at >= 48x48 CSS px" (docs/reference/m3/foundations-supplement.md
* § Accessibility) so `dense` (`data-md-dense`) is the caller's decision, and it is the caller's
* 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 {
/* `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
viewport. */
[data-table] {
--cell-x: 0.75rem;
--cell-y: 1rem;
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
/* `position: relative` makes the table the containing block for anything absolute inside it
a visually hidden header label, a tooltip. Without it they escape the scroll box and widen a
phone's layout viewport. */
[data-md-table] {
--cell-x: 12px;
--cell-y: var(--md-sys-measurement-space200);
position: relative;
width: 100%;
@@ -31,29 +37,29 @@
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-table][data-size="xs"] {
--cell-x: 0.5rem;
--cell-y: 0.5rem;
[data-md-table][data-md-size='xs'] {
--cell-x: var(--md-sys-measurement-space100);
--cell-y: var(--md-sys-measurement-space100);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
}
[data-table][data-dense] {
--cell-y: 0.5rem;
[data-md-table][data-md-dense] {
--cell-y: var(--md-sys-measurement-space100);
}
[data-table][data-size="xs"][data-dense] {
--cell-y: 0.25rem;
[data-md-table][data-md-size='xs'][data-md-dense] {
--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);
text-align: start;
vertical-align: middle;
}
[data-table] :where(thead th) {
[data-md-table] :where(thead th) {
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
@@ -61,21 +67,20 @@
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);
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-table] :where(tbody tr) {
[data-md-table] :where(tbody tr) {
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;
}
[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);
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 {
[data-timepicker-field] .field-control {
[data-timepicker-field] [data-md-field-control] {
cursor: pointer;
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';
/* 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/timepicker.css';
@import './components/table.css';
/* Containment — leaving in step 36 */
@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
* 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`,
* `data-scroll-end`), and the set fades those edges and where the pointer is fine, so there is no
* A scrolling set marks the edges it can still scroll towards (`data-md-scroll-start`,
* `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
* 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,
@@ -25,23 +25,23 @@ document.addEventListener('alpine:init', () => {
window.Alpine.data('materialChip', () => ({
init() {
const button = this.removeButton()
const label = this.$root.querySelector('[data-chip-label]')
const label = this.$root.querySelector('[data-md-chip-label]')
// A chip rendered by x-for has no label on the server: its button carries the translated
// words with a `:label` placeholder, filled in once Alpine has written the label.
if (button?.dataset.chipRemove && label) {
if (button?.dataset.mdChipRemove && label) {
this.$nextTick(() => {
const text = label.textContent.trim()
if (text !== '' && !button.hasAttribute('aria-label')) {
button.setAttribute('aria-label', button.dataset.chipRemove.replace(':label', text))
button.setAttribute('aria-label', button.dataset.mdChipRemove.replace(':label', text))
}
})
}
},
removeButton() {
return this.$root.querySelector('[data-chip-remove]')
return this.$root.querySelector('[data-md-chip-remove]')
},
removeOnKey(event) {
@@ -68,15 +68,15 @@ document.addEventListener('alpine:init', () => {
handOffFocus(backwards) {
const chip = this.$root
const set = chip.closest('[data-chip-set]') ?? chip.parentElement
const chips = [...(set?.querySelectorAll('[data-chip]') ?? [])]
const set = chip.closest('[data-md-chip-set-row]') ?? chip.parentElement
const chips = [...(set?.querySelectorAll('[data-md-chip]') ?? [])]
const index = chips.indexOf(chip)
const before = chips.slice(0, index).reverse()
const after = chips.slice(index + 1)
const onRemove = document.activeElement?.hasAttribute('data-chip-remove')
const onRemove = document.activeElement?.hasAttribute('data-md-chip-remove')
for (const other of backwards ? [...before, ...after] : [...after, ...before]) {
const target = (onRemove && other.querySelector('[data-chip-remove]:not(:disabled)')) || (other.matches(CONTROLS) ? other : other.querySelector(CONTROLS))
const target = (onRemove && other.querySelector('[data-md-chip-remove]:not(:disabled)')) || (other.matches(CONTROLS) ? other : other.querySelector(CONTROLS))
if (target) {
target.focus()
@@ -92,17 +92,17 @@ document.addEventListener('alpine:init', () => {
// seconds pass), whenever the set changes and focus has fallen away, focus the same
// control again — the element itself, or the chip with its `wire:key` after a morph.
holdFocus(target, set) {
const key = target.closest('[data-chip]')?.getAttribute('wire:key')
const onRemove = target.hasAttribute('data-chip-remove')
const key = target.closest('[data-md-chip]')?.getAttribute('wire:key')
const onRemove = target.hasAttribute('data-md-chip-remove')
const current = () => {
if (target.isConnected || !key) {
return target.isConnected ? target : null
}
const chip = [...set.querySelectorAll('[data-chip]')].find((other) => other.getAttribute('wire:key') === key)
const chip = [...set.querySelectorAll('[data-md-chip]')].find((other) => other.getAttribute('wire:key') === key)
return chip && (onRemove ? chip.querySelector('[data-chip-remove]') : chip.matches(CONTROLS) ? chip : chip.querySelector(CONTROLS))
return chip && (onRemove ? chip.querySelector('[data-md-chip-remove]') : chip.matches(CONTROLS) ? chip : chip.querySelector(CONTROLS))
}
const observer = new MutationObserver(() => {
@@ -140,8 +140,8 @@ document.addEventListener('alpine:init', () => {
const travelled = Math.abs(row.scrollLeft)
const room = row.scrollWidth - row.clientWidth
row.toggleAttribute('data-scroll-start', travelled > 1)
row.toggleAttribute('data-scroll-end', room - travelled > 1)
row.toggleAttribute('data-md-scroll-start', travelled > 1)
row.toggleAttribute('data-md-scroll-end', room - travelled > 1)
}
// A filter chip's check changes its width without resizing the row.
+6 -6
View File
@@ -8,13 +8,13 @@
* in.
*
* `<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
* mark. A person pressing the box clears the property as the browser always does; the mark stays
* until the server decides again.
*/
const GROWING = 'textarea[data-autogrow]'
const MIXED = 'input[type="checkbox"][data-indeterminate]'
const GROWING = 'textarea[data-md-autogrow]'
const MIXED = 'input[type="checkbox"][data-md-indeterminate]'
const growsByItself = window.CSS?.supports?.('field-sizing', 'content') ?? false
@@ -49,8 +49,8 @@ if (! growsByItself) {
new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'attributes') {
if (record.attributeName === 'data-indeterminate' && record.target instanceof HTMLInputElement) {
record.target.indeterminate = record.target.hasAttribute('data-indeterminate')
if (record.attributeName === 'data-md-indeterminate' && record.target instanceof HTMLInputElement) {
record.target.indeterminate = record.target.hasAttribute('data-md-indeterminate')
} else if (! growsByItself && record.target.matches(GROWING)) {
grow(record.target)
}
@@ -76,7 +76,7 @@ new MutationObserver((records) => {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['data-indeterminate', 'data-autogrow'],
attributeFilter: ['data-md-indeterminate', 'data-md-autogrow'],
})
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.
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)
const items = list ? list.querySelectorAll('[role="listitem"], li') : []
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
? (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.
*/
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 drawing = root.querySelector('[data-slider-drawing]')
const drawing = root.querySelector('[data-md-slider-drawing]')
const range = inputs.length > 1
const cleanups = []
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. */
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. */
const trackLength = () => Math.max((vertical() ? root.clientHeight : root.clientWidth) - HANDLE_WIDTH, 0)
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 { min, max } = bounds()
return sliderGeometry({
width,
size: root.dataset.size,
size: host.dataset.mdSize,
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,
tickFractions: tickFractions(),
})
@@ -257,13 +259,13 @@ function slider(root) {
}
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 part = (selector) => drawing.querySelector(selector)
const px = (number) => `${Math.round(number * 100) / 100}px`
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]
if (element) {
@@ -277,7 +279,7 @@ function slider(root) {
}
for (const name of ['start', 'end']) {
const element = part(`[data-stop="${name}"]`)
const element = part(`[data-md-slider-stop="${name}"]`)
if (element) {
element.hidden = stops[name] === undefined
@@ -288,20 +290,20 @@ function slider(root) {
const fractions = tickFractions()
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]
element.hidden = !tick || spacing < TICK_MIN_SPACING
if (tick) {
element.style.left = px(tick.x)
element.toggleAttribute('data-active', tick.active)
element.toggleAttribute('data-md-active', tick.active)
}
})
handles.forEach((x, i) => {
const thumb = part(`[data-handle="${i === 1 ? 'end' : 'start'}"]`)
const label = thumb?.querySelector('[data-value-label]')
const thumb = part(`[data-md-slider-handle="${i === 1 ? 'end' : 'start'}"]`)
const label = thumb?.querySelector('[data-md-slider-value]')
if (thumb) {
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.
const icon = part('[data-track-icon]')
const icon = part('[data-md-slider-icon]')
if (icon) {
const room = size.icon + ICON_PADDING * 2
@@ -321,7 +323,7 @@ function slider(root) {
const inInactive = !inActive && endTrackStart !== null && width - endTrackStart >= room
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)
}
}
@@ -371,10 +373,10 @@ function slider(root) {
}
/** 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. */
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) => {
// 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)
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)
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
`<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
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
read from the bag under the `wire:model` name. Every other attribute reaches the `<input>`
(resources/css/components/selection.css).
read from the bag under the `wire:model` name. Every other attribute reaches the `<input>`.
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,
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
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
group out yourself `<div class="grid gap-4 expanded:grid-cols-2">` around the checkboxes, or a
`<x-card>` or side sheet holding them and give the group a heading that names what it asks. --}}
group out yourself a grid of one column that takes two from `expanded`, or an `<x-card>` or
side sheet holding them and give the group a heading that names what it asks. --}}
@props([
'label' => null,
@@ -33,42 +34,34 @@
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
<label for="{{ $id }}" data-selection @if ($messages !== []) data-invalid @endif @class([
'flex gap-4',
'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),
])>
<div data-md-checkbox {{ $attributes->only(['class', 'style', 'wire:key']) }}>
<label for="{{ $id }}" data-md-selection-row @if ($right) data-md-right @endif @if ($messages !== []) data-md-invalid @endif>
<span data-md-checkbox-box @if (blank($label) && blank($hint)) class="md-touch-target" @endif>
<input
{{ $attributes->except(['class', 'id', 'wire:key']) }}
{{ $attributes->except(['class', 'style', 'id', 'wire:key']) }}
id="{{ $id }}"
type="checkbox"
@if ($indeterminate) data-indeterminate @endif
@if ($indeterminate) data-md-indeterminate @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="remove" class="size-4.5" optical="20" data-mixed />
<x-livewire-material::icon name="check" size="18" data-md-checkbox-check />
<x-livewire-material::icon name="remove" size="18" data-md-checkbox-mixed />
</span>
@if (filled($label) || filled($hint))
<span class="min-w-0">
<span data-md-selection-text>
@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
@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
</span>
@endif
</label>
@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)
<p>{{ $message }}</p>
@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
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([
'label' => null,
@@ -37,24 +41,25 @@
@endphp
<div
data-md-chip-set
@if ($scroll) data-md-scroll @endif
role="group"
@if (filled($label)) aria-labelledby="material-chip-set-{{ $key }}-label" @endif
@if ($describedBy) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->class('min-w-0') }}
{{ $attributes }}
>
@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
@if ($scroll)
<div x-data="materialChipSet" class="relative">
<div x-data="materialChipSet" data-md-chip-set-scroller>
<div
data-chip-set
data-md-chip-set-row
x-ref="row"
x-on:keydown="key($event)"
x-on:focusin="rove($event.target)"
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 }}
</div>
@@ -65,31 +70,26 @@
type="button"
tabindex="-1"
aria-hidden="true"
data-chip-scroll="{{ $edge }}"
data-md-chip-scroll="{{ $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>
@endforeach
</div>
@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 }}
</div>
@endif
@if ($messages !== [])
<div id="{{ $describedBy }}">
<div id="{{ $describedBy }}" data-md-chip-set-errors>
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
<p>{{ $message }}</p>
@endforeach
</div>
@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
</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
chip and over the label's own strip ("the target may extend beyond the visible chip
container"). The check grows in on the fast spatial spring and fades in on the slow effects
one; it shrinks on default effects and fades on fast effects, as AnimatingChipContent does. --}}
one; it shrinks on default effects and fades on fast effects, as AnimatingChipContent does.
The chip renders `data-md-chip` with its type (`data-md-chip="filter"`), `data-md-elevated`,
`data-md-disabled` and, on an input chip, `data-md-selected`; its colours, sizes and states are
resources/css/components/chip.css. --}}
@props([
'type' => 'assist',
@@ -87,44 +91,21 @@
$outer = ['class', 'style', 'wire:key'];
// Compose draws the 1px border inside the chip; a CSS border takes room, so the padding
// beside it is 1px short of the token (7px for 8, 15px for 16).
$base = 'group/chip inline-flex h-8 max-w-full shrink-0 select-none items-center whitespace-nowrap rounded-corner-sm border type-label-lg transition-[background-color,border-color,color,box-shadow] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast';
// Below 48px the touch target reaches past the chip, as M3 requires.
$target = 'after:absolute after:inset-x-0 after:top-1/2 after:h-12 after:-translate-y-1/2';
// Every colour class written out whole, so Tailwind compiles it. Selected is a state the browser
// owns on a filter chip (a checked checkbox, or aria-pressed), so it is written as a variant.
$colours = match (true) {
$kind === 'filter' && $raised && $disabled => 'border-transparent bg-on-surface/12 text-on-surface/38',
$kind === 'filter' && $raised => 'border-transparent bg-surface-container-low text-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2 has-checked:bg-secondary-container has-checked:text-on-secondary-container aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container',
$kind === 'filter' && $disabled => 'border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12 aria-pressed:border-transparent aria-pressed:bg-on-surface/12',
$kind === 'filter' => 'border-outline-variant text-on-surface-variant focus-visible:border-on-surface-variant has-focus-visible:border-on-surface-variant has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container hover:has-checked:shadow-elevation-1 aria-pressed:border-transparent aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container hover:aria-pressed:shadow-elevation-1',
$kind === 'input' && $disabled => $selected ? 'border-transparent bg-on-surface/12 text-on-surface/38' : 'border-on-surface/12 text-on-surface/38',
$kind === 'input' => $selected ? 'border-transparent bg-secondary-container text-on-secondary-container' : 'border-outline-variant text-on-surface-variant has-[[data-chip-action]:focus-visible]:border-on-surface-variant',
$raised && $disabled => 'border-transparent bg-on-surface/12 text-on-surface/38',
$raised => $kind === 'suggestion'
? 'border-transparent bg-surface-container-low text-on-surface-variant shadow-elevation-1 hover:shadow-elevation-2'
: 'border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2',
$disabled => 'border-on-surface/12 text-on-surface/38',
default => $kind === 'suggestion'
? 'border-outline-variant text-on-surface-variant focus-visible:border-on-surface-variant'
: 'border-outline-variant text-on-surface focus-visible:border-on-surface',
};
// Leading icons: primary on assist, suggestion and filter chips; on-surface-variant on an input
// chip, primary while it is hovered, focused or pressed, and primary on a selected one.
// An input chip's leading icon turns primary while a chip with an action of its own is hovered,
// focused or pressed.
$clicks = $attributes->whereStartsWith(['wire:click', 'x-on:click', '@click'])->isNotEmpty();
$leadingInk = match (true) {
$disabled => null,
$kind !== 'input', (bool) $selected => 'text-primary',
$isLink || $clicks => 'transition-colors duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-hover/chip:text-primary group-active/chip:text-primary group-has-focus-visible/chip:text-primary',
default => null,
};
$trailingInk = in_array($kind, ['assist', 'suggestion'], true) && ! $disabled ? 'text-primary' : null;
$actionable = $kind === 'input' && ! $disabled && ! $selected && ($isLink || $clicks);
$check = 'size-4.5 opacity-0 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-100 group-has-checked/chip:duration-(--md-sys-motion-effects-slow-duration) group-has-checked/chip:ease-effects-slow group-aria-pressed/chip:opacity-100 group-aria-pressed/chip:duration-(--md-sys-motion-effects-slow-duration) group-aria-pressed/chip:ease-effects-slow';
$state = fn (array $more = []): array => array_filter([
'data-md-chip' => $kind,
'data-md-elevated' => $raised ? '' : null,
'data-md-disabled' => $disabled ? '' : null,
'data-md-selected' => $kind === 'input' && $selected ? '' : null,
'data-md-actionable' => $actionable ? '' : null,
'data-md-icon-start' => filled($icon) || ($kind === 'input' && filled($avatar)) ? '' : null,
'data-md-icon-end' => filled($iconRight) && $kind !== 'input' ? '' : null,
...$more,
], fn ($attribute): bool => $attribute !== null);
if ($kind === 'input') {
$actionTag = match (true) {
@@ -132,7 +113,6 @@
$clicks || $selected !== null => 'button',
default => 'span',
};
$interactive = $actionTag !== 'span';
$removeExpression = implode('; ', array_filter([
'removeChip($event)',
filled($remove) ? $remove : null,
@@ -140,30 +120,15 @@
]));
$container = $attributes->only($outer)
->class([
$base,
'relative isolate',
$colours,
'has-[[data-chip-action]:focus-visible]:outline-3 has-[[data-chip-action]:focus-visible]:outline-offset-2 has-[[data-chip-action]:focus-visible]:outline-secondary',
])
->merge(array_filter(['style' => $anchor ? "anchor-name: {$anchor}" : null]));
->merge($state([
'data-md-avatar' => filled($avatar) ? '' : null,
'data-md-removable' => $removable ? '' : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
]));
$action = $attributes->except($outer)
->class([
'flex h-full min-w-0 items-center outline-none',
match (true) {
filled($avatar) => 'ps-0.75',
filled($icon) => 'ps-1.75',
default => 'ps-2.75',
},
$removable ? 'pe-2' : 'pe-2.75',
'cursor-pointer before:absolute before:-inset-px before:-z-10 before:rounded-corner-sm before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => $interactive && ! $disabled,
$target => $interactive,
'cursor-not-allowed' => $interactive && $disabled,
'pointer-events-none' => $inert,
])
->merge(array_filter([
'data-chip-action' => $interactive ? true : null,
'data-md-chip-action' => '',
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
@@ -187,25 +152,12 @@
'value' => $value,
'checked' => $checked ? true : null,
'disabled' => $disabled ? true : null,
], fn ($attribute): bool => $attribute !== null))->class('peer sr-only')
'class' => 'md-visually-hidden',
], fn ($attribute): bool => $attribute !== null))
: null;
$container = ($checkbox ? $attributes->only($outer) : $attributes)
->class([
$base,
'state-layer relative',
'focus-ring' => ! $checkbox,
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary has-focus-visible:before:opacity-10' => $checkbox,
$kind === 'filter' || filled($icon) ? 'ps-1.75' : 'ps-3.75',
$iconRight ? 'pe-1.75' : 'pe-3.75',
$colours,
$target,
'cursor-pointer' => ! $disabled,
'cursor-not-allowed' => $disabled && ! $isLink,
'pointer-events-none' => $inert,
])
->merge(array_filter([
'data-chip' => $kind,
->merge($state(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
@@ -218,42 +170,35 @@
'value' => $tag === 'button' ? $value : null,
'aria-pressed' => $kind === 'filter' && ! $checkbox ? ($selected ? 'true' : 'false') : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($attribute): bool => $attribute !== null));
], fn ($attribute): bool => $attribute !== null)));
}
@endphp
@if ($kind === 'input')
<span data-chip="input" @if ($removable) x-data="materialChip" x-on:keydown="removeOnKey($event)" @endif {{ $container }}>
<span @if ($removable) x-data="materialChip" x-on:keydown="removeOnKey($event)" @endif {{ $container }}>
<{{ $actionTag }} {{ $action }}>
@if ($avatar)
@if ($initials)
<span aria-hidden="true" @class(['me-2 grid size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container type-label-sm text-on-primary-container', 'opacity-38' => $disabled])>{{ $avatar }}</span>
<span aria-hidden="true" data-md-chip-avatar>{{ $avatar }}</span>
@else
<img src="{{ $avatar }}" alt="" @class(['me-2 size-6 shrink-0 rounded-corner-full object-cover', 'opacity-38' => $disabled]) />
<img src="{{ $avatar }}" alt="" data-md-chip-avatar />
@endif
@elseif ($icon)
<x-livewire-material::icon :name="$icon" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
<x-livewire-material::icon :name="$icon" size="18" data-md-chip-icon />
@endif
<span data-chip-label class="truncate">{{ $label ?? $slot }}</span>
<span data-md-chip-label>{{ $label ?? $slot }}</span>
</{{ $actionTag }}>
@if ($removable)
<button
type="button"
@if (filled($text)) data-chip-remove aria-label="{{ __('Remove :label', ['label' => $text]) }}" @else data-chip-remove="{{ __('Remove :label') }}" @endif
@if (filled($text)) data-md-chip-remove aria-label="{{ __('Remove :label', ['label' => $text]) }}" @else data-md-chip-remove="{{ __('Remove :label') }}" @endif
@if ($wireRemove) wire:click="{{ $wireRemove }}" @endif
x-on:click="{{ $removeExpression }}"
@disabled($disabled)
@class([
'relative me-1.75 grid size-4.5 shrink-0 place-items-center rounded-corner-full',
'cursor-pointer focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary' => ! $disabled,
'before:absolute before:-inset-0.75 before:rounded-corner-full before:bg-current before:opacity-0 before:transition-opacity before:duration-(--md-sys-motion-effects-fast-duration) before:ease-effects-fast hover:before:opacity-8 focus-visible:before:opacity-10 active:before:opacity-10' => ! $disabled,
'after:absolute after:-inset-3.75',
'cursor-not-allowed' => $disabled,
])
>
<x-livewire-material::icon name="close" class="size-4.5" optical="20" />
<x-livewire-material::icon name="close" size="18" />
</button>
@endif
@@ -273,24 +218,20 @@
@if ($kind === 'filter')
{{-- The check grows in from nothing; beside an icon it takes the icon's place. --}}
<span @class([
'me-2 grid shrink-0 overflow-hidden *:col-start-1 *:row-start-1',
'size-4.5' => filled($icon),
'h-4.5 w-0 transition-[width] duration-(--md-sys-motion-effects-default-duration) ease-effects-default group-has-checked/chip:w-4.5 group-has-checked/chip:duration-(--md-sys-motion-spatial-fast-duration) group-has-checked/chip:ease-spatial-fast group-aria-pressed/chip:w-4.5 group-aria-pressed/chip:duration-(--md-sys-motion-spatial-fast-duration) group-aria-pressed/chip:ease-spatial-fast' => blank($icon),
])>
<span data-md-chip-check-slot>
@if ($icon)
<x-livewire-material::icon :name="$icon" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['size-4.5 transition-opacity duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast group-has-checked/chip:opacity-0 group-aria-pressed/chip:opacity-0', $leadingInk])" />
<x-livewire-material::icon :name="$icon" size="18" data-md-chip-icon />
@endif
<x-livewire-material::icon name="check" data-chip-check optical="20" :class="$check" />
<x-livewire-material::icon name="check" size="18" data-md-chip-check />
</span>
@elseif ($icon)
<x-livewire-material::icon :name="$icon" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['me-2 size-4.5', $leadingInk])" />
<x-livewire-material::icon :name="$icon" size="18" data-md-chip-icon />
@endif
<span class="truncate">{{ $label ?? $slot }}</span>
<span data-md-chip-label>{{ $label ?? $slot }}</span>
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" optical="20" :class="\Illuminate\Support\Arr::toCssClasses(['ms-2 size-4.5', $trailingInk])" />
<x-livewire-material::icon :name="$iconRight" size="18" data-md-chip-trailing />
@endif
@if ($tooltip !== null)
+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
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 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([
'label' => null,
@@ -64,7 +69,9 @@
@if ($searchable)
<div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
data-md-choices
data-md-searchable
{{ $attributes->only(['class', 'style', 'wire:key', 'x-model']) }}
x-data="{
@if ($model !== null) value: @entangle($attributes->wire('model')), @else value: @js($current), @endif
options: @js($choices),
@@ -141,7 +148,7 @@
@if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
placeholder="{{ filled($placeholder) ? $placeholder : ' ' }}"
class="field-control"
data-md-field-control
x-model="query"
x-on:focus="show(); $nextTick(() => $el.select())"
x-on:pointerdown="selecting = document.activeElement !== $el"
@@ -158,7 +165,7 @@
/>
<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-livewire-material::field>
</div>
@@ -170,7 +177,7 @@
x-ref="list"
@if (filled($label)) aria-label="{{ $label }}" @endif
style="position-anchor: {{ $anchor }}"
class="field-menu"
data-md-field-menu
>
<template x-for="(option, index) in filtered" :key="option.value">
<li
@@ -181,18 +188,19 @@
x-bind:data-active="index === active ? '' : null"
x-on:mousedown.prevent="choose(option)"
x-on:mousemove="active = index"
class="field-option"
data-md-field-option
>
<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>
</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>
</div>
@else
<div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
data-md-choices
{{ $attributes->only(['class', 'style', 'wire:key', 'x-model']) }}
x-data="{
@if ($model !== null) selection: @entangle($attributes->wire('model')), @else selection: @js($current ?? ($single ? null : [])), @endif
options: @js(array_column($choices, 'value')),
+17 -13
View File
@@ -142,6 +142,8 @@
'invalidRange' => __('Invalid date range input'),
],
];
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp
<div
@@ -162,9 +164,9 @@
:$icon
:$size
:$variant
:data-invalid="$invalid ? '' : null"
:data-readonly="$attributes->get('readonly') ? '' : null"
x-bind:data-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
:data-md-invalid="$invalid ? '' : null"
:data-md-readonly="$attributes->get('readonly') ? '' : null"
x-bind:data-md-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
>
<input
{{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }}
@@ -182,7 +184,7 @@
autocomplete="off"
value="{{ $display }}"
placeholder=" "
class="field-control"
data-md-field-control
x-model="text"
@if ($mode === 'docked')
x-bind:placeholder="placeholder"
@@ -205,19 +207,21 @@
@if ($clearable)
<button
type="button"
class="field-trailing field-clear field-button"
aria-label="{{ __('Clear') }}"
x-on:click="clear()"
@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>
@endif
<button
type="button"
class="field-trailing field-button"
data-md-field-trailing
data-md-field-button
aria-label="{{ $title }}"
aria-haspopup="dialog"
aria-controls="{{ $id }}-picker"
@@ -227,7 +231,7 @@
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-datepicker-toggle
>
<x-livewire-material::icon name="calendar_today" class="size-(--field-icon)" />
<x-livewire-material::icon name="calendar_today" :size="$fieldIconSize" />
</button>
</x-slot:trailing>
</x-livewire-material::field>
@@ -462,7 +466,7 @@
<div x-show="typing" data-datepicker-entry>
<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
id="{{ $id }}-entry"
type="text"
@@ -475,12 +479,12 @@
x-model="entry"
x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()"
class="field-control"
data-md-field-control
/>
</x-livewire-material::field>
@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
id="{{ $id }}-entry-end"
type="text"
@@ -492,7 +496,7 @@
x-model="entryEnd"
x-on:input="typeEntry()"
x-on:keydown.enter.prevent="confirm()"
class="field-control"
data-md-field-control
/>
</x-livewire-material::field>
@endif
+33 -30
View File
@@ -1,10 +1,11 @@
{{-- 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>`
and the pickers wrap their control in it, so a form reads as one family. The states the
label resting or floating, the notch, focus, disabled, required are selectors in
and the pickers wrap their control in it, so a form reads as one family. A custom control the
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
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
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
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
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
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
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
the full width of a large screen (§ Text Fields, Behaviour); a `max-w-*` class from the call
site beats that, and `full` takes it off altogether.
From `medium` (600px) the field is no wider than 40rem, because M3 asks that a text field never
span the full width of a large screen (§ Text Fields, Behaviour); a width rule from the call site
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
a width is safe here. --}}
a width is safe here; `hint-class` lands on the hint. --}}
@props([
'id',
@@ -50,6 +52,7 @@
? $variant
: (config('livewire-material.fields.variant') === 'filled' ? 'filled' : 'outlined');
$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
// gives way to whatever the caller trails the field with, and to `xs`, which has no room.
$errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs';
@@ -57,67 +60,67 @@
@endphp
<div
{{ $attributes->class(['field']) }}
data-variant="{{ $variant }}"
data-size="{{ $density }}"
@if ($messages !== []) data-invalid @endif
@if ($floated) data-floated @endif
@if ($mono) data-mono @endif
@if ($full) data-full @endif
data-md-field
data-md-variant="{{ $variant }}"
data-md-size="{{ $density }}"
@if ($messages !== []) data-md-invalid @endif
@if ($floated) data-md-floated @endif
@if ($mono) data-md-mono @endif
@if ($full) data-md-full @endif
{{ $attributes }}
>
<div class="field-box">
<div data-md-field-box>
@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
@if (filled($prefix))
<span class="field-affix">{{ $prefix }}</span>
<span data-md-field-affix>{{ $prefix }}</span>
@endif
{{ $slot }}
@if (filled($suffix))
<span class="field-affix">{{ $suffix }}</span>
<span data-md-field-affix>{{ $suffix }}</span>
@endif
{{ $trailing ?? '' }}
@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
<fieldset aria-hidden="true" class="field-outline">
<fieldset aria-hidden="true" data-md-field-outline>
<legend>@if (filled($label))<span>{{ $label }}</span>@endif</legend>
</fieldset>
@if (filled($label))
<label for="{{ $id }}" class="field-label">{{ $label }}</label>
<label for="{{ $id }}" data-md-field-label>{{ $label }}</label>
@endif
</div>
@if ($messages !== [] || filled($hint) || $counter !== null)
<div class="field-support-row">
<div data-md-field-support-row>
@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)
<p>{{ $message }}</p>
@endforeach
</div>
@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
@if ($counter !== null)
<span
class="field-counter"
data-field-counter
data-md-field-counter
x-data="{
used: 0,
max: {{ $counter }},
spoken: '',
settle: null,
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;
control.addEventListener('input', () => (this.used = control.value.length));
@@ -131,10 +134,10 @@
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 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>
@endif
</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
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
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:
it cannot be truncated or wrapped, and the caller shows what was chosen anyway, as previews. A
@@ -28,13 +29,13 @@
: [];
@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
{{ $attributes->except(['class', 'id', 'type']) }}
type="file"
id="{{ $id }}"
@if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control"
data-md-field-control
/>
</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.
`gap-4` 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
of its own; `separator` draws a divider above it.
`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. --}}
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
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. --}}
@props([
'separator' => false,
])
<form {{ $attributes->class(['grid grid-cols-1 auto-rows-min gap-4']) }}>
<form data-md-form {{ $attributes }}>
{{ $slot }}
@isset($actions)
@@ -19,7 +17,7 @@
<x-livewire-material::divider />
@endif
<div {{ $actions->attributes->class(['flex flex-wrap items-center justify-end gap-2']) }}>
<div data-md-form-actions {{ $actions->attributes }}>
{{ $actions }}
</div>
@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
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([
'label' => null,
@@ -39,16 +42,17 @@
$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.
$max = $counter ? $attributes->get('maxlength') : null;
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@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
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
id="{{ $id }}"
placeholder="{{ $placeholder }}"
@if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control"
data-md-field-control
/>
@if ($clearable || $copyable || $iconRight)
@@ -57,12 +61,13 @@
<button
type="button"
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();"
class="field-trailing field-clear field-button"
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();"
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>
@endif
@@ -70,17 +75,18 @@
<button
type="button"
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' }))"
class="field-trailing field-button"
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' }))"
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>
@endif
@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
</x-slot:trailing>
@endif
+11 -7
View File
@@ -5,7 +5,9 @@
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.
`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([
'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`).
$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)) : [];
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@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
{{ $attributes->except(['class', 'id', 'type', 'placeholder']) }}
id="{{ $id }}"
@@ -33,7 +36,7 @@
placeholder="{{ filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ' }}"
@if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control"
data-md-field-control
/>
<x-slot:trailing>
@@ -42,11 +45,12 @@
x-on:click="shown = ! shown"
x-bind:aria-label="shown ? @js(__('Hide password')) : @js(__('Show password'))"
aria-label="{{ __('Show password') }}"
class="field-trailing field-button"
data-field-reveal
data-md-field-trailing
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_off" class="size-(--field-icon)" x-show="shown" x-cloak />
<x-livewire-material::icon name="visibility" :size="$fieldIconSize" x-show="! shown" />
<x-livewire-material::icon name="visibility_off" :size="$fieldIconSize" x-show="shown" x-cloak />
</button>
</x-slot:trailing>
</x-livewire-material::field>
+15 -20
View File
@@ -7,7 +7,7 @@
(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).
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
a group of two or three short labels that would waste a column, not the shape to reach for.
@@ -35,28 +35,23 @@
@endphp
<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 (filled($label))
<legend class="mb-3 type-label-lg text-on-surface-variant">{{ $label }}</legend>
<legend data-md-radio-legend>{{ $label }}</legend>
@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)
@php($optionHintText = data_get($option, $optionHint))
<label data-selection @if ($messages !== []) data-invalid @endif @class([
'flex gap-4',
'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),
])>
<label data-md-selection-row @if ($messages !== []) data-md-invalid @endif>
<span data-md-radio-button @if (blank(data_get($option, $optionLabel)) && blank($optionHintText)) class="md-touch-target" @endif>
<input
{{ $attributes->except(['class', 'wire:key', 'name', 'id']) }}
{{ $attributes->except(['class', 'style', 'wire:key', 'name', 'id']) }}
type="radio"
name="{{ $name }}"
value="{{ data_get($option, $optionValue) }}"
@@ -64,13 +59,13 @@
@checked($value !== null && (string) $value === (string) data_get($option, $optionValue))
@if ($messages !== []) aria-invalid="true" @endif
/>
<span data-dot></span>
<span data-md-radio-dot></span>
</span>
<span class="min-w-0">
<span class="block type-body-lg text-on-surface" data-selection-label>{{ data_get($option, $optionLabel) }}</span>
<span data-md-selection-text>
<span data-md-selection-label>{{ data_get($option, $optionLabel) }}</span>
@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
</span>
</label>
@@ -78,12 +73,12 @@
</div>
@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)
<p>{{ $message }}</p>
@endforeach
</div>
@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
</fieldset>
+24 -20
View File
@@ -25,7 +25,11 @@
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
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([
'placeholder' => null,
@@ -56,13 +60,13 @@
x-on:focusout="leave($event)"
x-on:pointerdown.outside="close()"
x-trap.noscroll="fullScreen"
x-bind:data-open="open ? '' : null"
x-bind:data-full-screen="fullScreen ? '' : null"
data-search
data-trigger="{{ $trigger }}"
{{ $attributes->only(['class', 'wire:key'])->class(['relative']) }}
x-bind:data-md-open="open ? '' : null"
x-bind:data-md-full-screen="fullScreen ? '' : null"
data-md-search
data-md-trigger="{{ $trigger }}"
{{ $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')
<button
@@ -75,23 +79,23 @@
aria-controls="{{ $id }}-view"
aria-expanded="false"
x-bind:aria-expanded="open.toString()"
data-search-trigger
data-md-search-trigger
>
<x-livewire-material::icon :name="$icon" />
</button>
@endif
<div data-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
<span data-search-leading x-show="! fullScreen">
<div data-md-search-bar role="search" x-on:click="if ($event.target === $el) $refs.input.focus()">
<span data-md-search-leading x-show="! fullScreen">
<x-livewire-material::icon :name="$icon" />
</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" />
</button>
<span
data-search-field
data-md-search-field
role="combobox"
aria-haspopup="dialog"
aria-controls="{{ $id }}-view"
@@ -111,23 +115,23 @@
x-on:click="show()"
x-on:input="typed($event)"
x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))"
data-search-input
data-md-search-input
/>
</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" />
</button>
@isset($trailing)
<span data-search-trailing>{{ $trailing }}</span>
<span data-md-search-trailing>{{ $trailing }}</span>
@endisset
</div>
<div
x-ref="view"
id="{{ $id }}-view"
data-search-view
data-md-search-view
x-cloak
x-show="open"
x-on:keydown.arrow-down.prevent="step(1)"
@@ -135,15 +139,15 @@
x-on:click="choose($event)"
>
@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
@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))
<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
</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>
+6 -4
View File
@@ -10,7 +10,8 @@
`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
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([
'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`).
$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)) : [];
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@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
{{ $attributes->except(['class', 'id']) }}
id="{{ $id }}"
@if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control"
data-md-field-control
>
@if (filled($placeholder))
<option value="{{ $placeholderValue }}">{{ $placeholder }}</option>
@@ -54,6 +56,6 @@
</select>
<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-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
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
one is tall and takes its length from the wrapper, so give it an explicit height
`class="h-64"`, which the label and the hint share; without one it is 192px. M3 keeps range
one is tall and takes its length from the wrapper, so give it an explicit height a height
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
`range` wins over `orientation`.
@@ -63,7 +63,11 @@
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
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([
'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);
$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']);
// 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]])
.'; 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'];
@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 ($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
<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
<div
x-data="materialSlider"
x-on:pointerdown="press($event)"
data-slider
data-size="{{ $size }}"
@if ($vertical) data-orientation="vertical" @endif
@if ($centered) data-centered @endif
data-md-slider-control
@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([
'pointer-events-none absolute noscript:hidden',
'inset-y-0 inset-x-0.5 rtl:-scale-x-100' => ! $vertical,
// 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)
<div data-md-slider-drawing wire:ignore aria-hidden="true">
<div data-md-slider-track>
@foreach (['start', 'active', 'end'] as $part)
<span
data-segment="{{ $part }}"
data-md-slider-segment="{{ $part }}"
@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) }}"
></span>
@endforeach
@foreach ($tickMarks as $mark)
<span
data-tick="{{ $format($mark['fraction']) }}"
data-md-slider-tick="{{ $format($mark['fraction']) }}"
@if ($mark['hidden']) hidden @endif
@if ($mark['active']) data-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'])
@if ($mark['active']) data-md-active @endif
style="left: {{ $calc($mark['at']) }}"
></span>
@endforeach
@foreach (['start', 'end'] as $part)
<span
data-stop="{{ $part }}"
data-md-slider-stop="{{ $part }}"
@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]) : '' }}"
></span>
@endforeach
@if ($icon !== null)
<span
data-track-icon
data-md-slider-icon
@if (! $iconInActive && ! $iconInInactive) hidden @endif
@if ($iconInActive) data-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',
])
@if ($iconInActive) data-md-active @endif
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>
@endif
</div>
@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 @class([
'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>
<span data-md-slider-handle="{{ $thumb }}" style="left: {{ $calc($place($fractions[$index])) }}">
<span data-md-slider-thumb></span>
@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-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 data-md-slider-value-anchor>
<span data-md-slider-value>{{ $format($values[$index]) }}</span>
</span>
@endif
</span>
@@ -425,7 +324,7 @@
max="{{ $format($maximum) }}"
step="{{ $continuous ? 'any' : $format($interval) }}"
value="{{ $format($values[$index]) }}"
data-thumb="{{ $thumb }}"
data-md-slider-input="{{ $thumb }}"
@if ($vertical) aria-orientation="vertical" @endif
@if ($range) aria-label="{{ $thumb === 'start' ? __('Range start') : __('Range end') }}" @endif
@if ($described) aria-describedby="{{ $described }}" @endif
@@ -433,22 +332,17 @@
@disabled($disabled)
{{ new \Illuminate\View\ComponentAttributeBag($bindingsFor($index)) }}
{{ $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
</div>
@if ($messages !== [])
<div id="{{ $id }}-hint">
<div id="{{ $id }}-hint" data-md-slider-errors>
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
<p>{{ $message }}</p>
@endforeach
</div>
@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
</div>
@@ -3,10 +3,12 @@
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>`.
`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
presses over M3's 48px minimum; the cell's own padding belongs to the cell, not to it. --}}
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. 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([
'sortBy' => [],
@@ -20,21 +22,14 @@
$next = $active && $direction === 'asc' ? 'desc' : 'asc';
@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
type="button"
wire:click="$set('{{ $model }}', {{ json_encode(['column' => $column, 'direction' => $next]) }})"
data-sort-header
@class([
'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,
])
data-md-sort-header-button
class="md-focus-ring md-touch-target"
>
{{ $slot }}
<x-livewire-material::icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" optical="20" :class="\Illuminate\Support\Arr::toCssClasses([
'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,
])" />
<x-livewire-material::icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" size="16" data-md-sort-header-arrow />
</button>
</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
look is resources/css/components/table.css, keyed on the `data-table` attribute set here (the
top of that file says why).
look is resources/css/components/table.css, keyed on the `data-md-table` attribute set here (the
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
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`).
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
`<x-sort-header>`. --}}
table in an element that scrolls sideways (`overflow-x: auto`) where the page needs it. A column
that sorts is `<x-sort-header>`. --}}
@props([
'size' => 'sm',
'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 }}
</table>
@@ -5,7 +5,8 @@
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
`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([
'label' => null,
@@ -30,18 +31,18 @@
$max = $counter ? $attributes->get('maxlength') : null;
@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
{{ $attributes->except(['class', 'id', 'placeholder']) }}
id="{{ $id }}"
rows="{{ (int) $rows }}"
placeholder="{{ $placeholder }}"
@if ($autogrow)
data-autogrow
data-md-autogrow
style="--field-rows: {{ (int) $rows }};@if ($maxRows) --field-max-rows: {{ (int) $maxRows }};@endif"
@endif
@if ($messages !== []) aria-invalid="true" @endif
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
class="field-control"
data-md-field-control
></textarea>
</x-livewire-material::field>
@@ -135,6 +135,7 @@
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']);
$disabled = (bool) $attributes->get('disabled');
$described = $messages !== [] || filled($hint);
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
@endphp
<div
@@ -159,7 +160,7 @@
x-bind:aria-expanded="open.toString()"
@if ($messages !== []) aria-invalid="true" @endif
@if ($described) aria-describedby="{{ $id }}-support" @endif
class="field-control"
data-md-field-control
x-on:click="show()"
x-on:keydown.enter.prevent="show()"
x-on:keydown.space.prevent="show()"
@@ -171,12 +172,13 @@
<button
type="button"
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') }}"
data-field-clear
@disabled($disabled)
>
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
<x-livewire-material::icon name="close" :size="$fieldIconSize" />
</button>
@endif
@@ -184,12 +186,13 @@
type="button"
tabindex="-1"
x-on:click="show()"
class="field-trailing field-button"
data-md-field-trailing
data-md-field-button
aria-label="{{ __('Choose time') }}"
data-timepicker-open
@disabled($disabled)
>
<x-livewire-material::icon name="schedule" class="size-(--field-icon)" />
<x-livewire-material::icon name="schedule" :size="$fieldIconSize" />
</button>
</x-slot:trailing>
</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
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>`
(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
presses over M3's 48px minimum. --}}
Without a label the row is only the 52×32 track, so the track carries `md-touch-target` and
catches presses over M3's 48px minimum. `class` and `style` land on the root, `data-md-toggle`. --}}
@props([
'label' => null,
@@ -23,35 +23,30 @@
$icons = $icons === 'selected' ? 'selected' : ($icons ? 'both' : null);
@endphp
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
<label for="{{ $id }}" data-selection @class([
'flex gap-4',
'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)])>
<div data-md-toggle {{ $attributes->only(['class', 'style', 'wire:key']) }}>
<label for="{{ $id }}" data-md-selection-row @if ($right) data-md-right @endif>
<span data-md-switch @if ($icons) data-md-icons="{{ $icons }}" @endif @if (blank($label) && blank($hint)) class="md-touch-target" @endif>
<input
{{ $attributes->except(['class', 'id', 'wire:key']) }}
{{ $attributes->except(['class', 'style', 'id', 'wire:key']) }}
id="{{ $id }}"
type="checkbox"
role="switch"
/>
<span data-handle>
<span data-md-switch-handle>
@if ($icons)
<x-livewire-material::icon name="check" class="size-4" optical="20" data-on />
<x-livewire-material::icon name="close" class="size-4" optical="20" data-off />
<x-livewire-material::icon name="check" size="16" data-md-switch-on />
<x-livewire-material::icon name="close" size="16" data-md-switch-off />
@endif
</span>
</span>
@if (filled($label) || filled($hint))
<span class="min-w-0">
<span data-md-selection-text>
@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
@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
</span>
@endif
+1 -1
View File
@@ -84,7 +84,7 @@
if (! result || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || (event.button ?? 0) !== 0) return;
event.preventDefault();
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 = '';
if (search) window.Alpine.$data(search).close();
if (url.pathname === window.location.pathname) {
@@ -26,13 +26,13 @@
<div class="grid content-start gap-4">
<x-input label="Email" required />
<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>
</div>
<div class="grid content-start gap-4">
<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>
<div class="flex flex-wrap items-center gap-3">
<x-input size="sm" icon="search" placeholder="Filter" aria-label="Filter" class="w-48" />
@@ -133,7 +133,7 @@
</div>
<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>
<tr>
<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 () {
$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();
@@ -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 () {
// 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->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'");
$page->script("{$row}.querySelector('input[value=\"unopened\"]').focus()");
$page->assertScript("Math.abs({$row}.scrollLeft) > 0")
->assertScript("{$row}.hasAttribute('data-scroll-start')")
->assertScript("getComputedStyle({$row}).getPropertyValue('--chip-fade-start').trim() === '1.5rem'");
->assertScript("{$row}.hasAttribute('data-md-scroll-start')")
->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>
{{ $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>
BLADE);
app('view')->addNamespace('probe', $views);
@@ -103,3 +110,19 @@ it('pages through Livewire results and marks the current page', function () {
$page->resize(400, 800)
->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]')
->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'")
->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'));
$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 () {
$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 === ''")
->assertValue('#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}')
->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-toggle id="public" label="Public" wire:model.live="public" />
<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" />
</div>
@@ -95,7 +104,7 @@ it('clears a field and tells Livewire', function () {
fieldProbe()
->type('#name-field', 'Holiday')
->assertSeeIn('#name', 'Holiday')
->click('[data-field-clear]')
->click('[data-md-field-clear]')
->assertScript("document.querySelector('#name-field').value === ''")
->assertScript("document.querySelector('#name').textContent === ''")
->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->click('[data-field-copy]')
$page->click('[data-md-field-copy]')
->assertScript("window.copied === 'Holiday'")
->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 () {
fieldProbe()
->type('#password-field', 'secret')
->click('[data-field-reveal]')
->click('[data-md-field-reveal]')
->assertScript("document.querySelector('#password-field').type === 'text'")
->assertAttribute('[data-field-reveal]', 'aria-label', 'Hide password')
->click('[data-field-reveal]')
->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password')
->click('[data-md-field-reveal]')
->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")')
->assertSee('The name field is required.')
->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 () {
@@ -142,12 +151,12 @@ it('keeps a partly ticked checkbox in step with the server', function () {
$page->click('label[for="file-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"]')
->click('label[for="file-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 () {
@@ -183,3 +192,134 @@ it('grows a textarea with its text up to its maximum', function () {
->assertScript("Math.abs(({$height}) - ({$three} + 24)) < 2")
->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 $iconQuery = '';
public function render(): string
{
return <<<'BLADE'
@@ -35,6 +37,20 @@ class PickProbe extends Component
<x-slot:empty>No files match.</x-slot:empty>
</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>
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
// 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'");
$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->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; })()")
->click('[data-search-back]')
->assertScript("! document.querySelector('[data-search]').hasAttribute('data-open')");
->click('[data-md-search]:has(#find) [data-md-search-back]')
->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'");
$page->type('#showcase-search', 'datepicker')
->assertSeeIn('[data-search-view]', '<x-datepicker>');
->assertSeeIn('[data-md-search-view]', '<x-datepicker>');
$page->keys('#showcase-search', 'Enter')
->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'")
->click('#showcase-search')
->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
(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}')
root.scrollIntoView({ block: 'center' })
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 left = (selector) => Number.parseFloat(root.querySelector(selector).style.left)
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)
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'])
->assertScript(onSlider('Volume', <<<'JS'
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('end').from, width * 0.42 + 8)
&& root.querySelector('[data-handle="start"]').hasAttribute('data-focused')
&& root.querySelector('[data-value-label]').textContent === '42'
&& root.querySelector('[data-md-slider-handle="start"]').hasAttribute('data-md-focused')
&& root.querySelector('[data-md-slider-value]').textContent === '42'
JS));
$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')
->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')
->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'
await pause(450)
const thumb = root.querySelector('[data-handle="start"]')
const label = thumb.querySelector('[data-value-label]')
const thumb = root.querySelector('[data-md-slider-handle="start"]')
const label = thumb.querySelector('[data-md-slider-value]')
return inputs[0].value === '75'
&& label.textContent === '75'
&& getComputedStyle(label).opacity === '1'
&& thumb.hasAttribute('data-pressed')
&& thumb.hasAttribute('data-md-pressed')
&& 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]
JS));
@@ -145,13 +145,13 @@ it('follows a pointer drag with a narrowed handle and its value label', function
$page->assertScript(onSlider('Volume', <<<'JS'
await pause(450)
const thumb = root.querySelector('[data-handle="start"]')
const thumb = root.querySelector('[data-md-slider-handle="start"]')
return inputs[0].value === '75'
&& window.__changed === 1
&& ! thumb.hasAttribute('data-pressed')
&& ! thumb.hasAttribute('data-focused')
&& ! thumb.hasAttribute('data-md-pressed')
&& ! thumb.hasAttribute('data-md-focused')
&& thumb.firstElementChild.offsetWidth === 4
&& getComputedStyle(thumb.querySelector('[data-value-label]')).opacity === '0'
&& getComputedStyle(thumb.querySelector('[data-md-slider-value]')).opacity === '0'
JS));
});
@@ -163,7 +163,7 @@ it('keeps a range\'s handles from crossing, by pointer and by keyboard', functio
$page->assertScript(onSlider('Price', <<<'JS'
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
JS));
@@ -183,7 +183,7 @@ it('fills a centred slider from the middle', function () {
$page = sliderShowcase();
$page->assertScript(onSlider('Balance', <<<'JS'
const handle = left('[data-handle="start"]')
const handle = left('[data-md-slider-handle="start"]')
return inputs[0].value === '15'
&& near(handle, width * 0.65)
&& 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->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 () {
@@ -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.
$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))
->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")')
->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->keys(':focus', 'ArrowLeft')
->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 () {
$html = (string) $this->blade('<x-chip label="Add to calendar" icon="event" icon-right="arrow_drop_down" />');
expect(chipTag('<x-chip label="Add to calendar" icon="event" />', 'button'))
->toContain('data-chip="assist"')
->toContain('type="button"')
->toContain('h-8')
->toContain('rounded-corner-sm')
->toContain('type-label-lg')
->toContain('border-outline-variant text-on-surface')
->toContain('ps-1.75 pe-3.75')
expect(chipTag('<x-chip label="Add to calendar" icon="event" class="me-2" />', 'button'))
->toBe('<button data-md-chip="assist" data-md-icon-start="" type="button" class="me-2">')
->and(chipTag('<x-chip label="Add to calendar" icon-right="arrow_drop_down" />', 'button'))
->toContain('data-md-icon-end=""')
->not->toContain('data-md-icon-start')
->and($html)
->toContain('me-2 size-4.5 text-primary')
->toContain('ms-2 size-4.5 text-primary')
->toContain('Add to calendar');
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-icon)[^>]*>/')
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-trailing)[^>]*>/')
->toContain('<span data-md-chip-label>Add to calendar</span>')
->not->toContain('class=');
});
it('draws a chip at Compose\'s size, corner, type and padding from its stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css');
expect($css)->toContain('@layer material.components')
->toContain("@import './icon.css';")
->toMatch('/\[data-md-chip\] \{[^}]*height: 32px;[^}]*border-radius: var\(--md-sys-shape-corner-sm\);[^}]*font: var\(--md-sys-typescale-label-lg\);/')
// 16px beside a label and 8px beside an icon, each 1px short for the border.
->toContain('padding-inline: 15px;')
->toContain('padding-inline-start: 7px;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/chip.css';");
});
it('pads a chip without icons by 16px on both sides', function () {
expect(chipTag('<x-chip label="Go" />', 'button'))->toContain('ps-3.75 pe-3.75');
expect(chipTag('<x-chip label="Go" />', 'button'))
->not->toContain('data-md-icon-start')
->not->toContain('data-md-icon-end');
});
it('falls back to an assist chip for a type it does not know', function () {
expect(chipTag('<x-chip type="choice" label="Go" />', 'button'))->toContain('data-chip="assist"');
expect(chipTag('<x-chip type="choice" label="Go" />', 'button'))->toContain('data-md-chip="assist"');
});
it('draws an elevated chip on surface-container-low at elevation 1', function () {
expect(chipTag('<x-chip label="Share" elevated />', 'button'))
->toContain('border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2')
->not->toContain('border-outline-variant');
expect(chipTag('<x-chip label="Share" elevated />', 'button'))->toContain('data-md-elevated=""')
->and(chipTag('<x-chip label="Share" />', 'button'))->not->toContain('data-md-elevated')
// Input chips are flat only.
->and(chipTag('<x-chip type="input" label="Anna" elevated />', 'span'))->not->toContain('data-md-elevated')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
->toMatch('/\[data-md-chip\]\[data-md-elevated\] \{[^}]*background-color: var\(--md-sys-color-surface-container-low\);[^}]*box-shadow: var\(--md-sys-elevation-1\);/');
});
it('greys a disabled chip, flat or elevated', function () {
expect(chipTag('<x-chip label="Share" icon="share" disabled />', 'button'))
->toContain('disabled')
->toContain('border-on-surface/12 text-on-surface/38')
->toContain('data-md-disabled=""')
->toMatch('/\sdisabled="disabled"/')
->and(chipTag('<x-chip label="Share" elevated disabled />', 'button'))
->toContain('bg-on-surface/12 text-on-surface/38')
->and((string) $this->blade('<x-chip label="Share" icon="share" disabled />'))
->not->toContain('text-primary');
->toContain('data-md-elevated=""')
->toContain('data-md-disabled=""');
});
it('links with wire:navigate, and a disabled link leaves the tab order', function () {
@@ -66,15 +80,14 @@ it('links with wire:navigate, and a disabled link leaves the tab order', functio
->and(chipTag('<x-chip label="Directions" link="/directions" disabled />', 'a'))
->toContain('aria-disabled="true"')
->toContain('tabindex="-1"')
->toContain('pointer-events-none');
->toContain('data-md-disabled=""');
});
it('draws a suggestion chip in on-surface-variant', function () {
expect(chipTag('<x-chip type="suggestion" label="Sounds good" />', 'button'))
->toContain('data-chip="suggestion"')
->toContain('border-outline-variant text-on-surface-variant')
->toContain('data-md-chip="suggestion"')
->and(chipTag('<x-chip type="suggestion" label="Sounds good" elevated />', 'button'))
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1');
->toContain('data-md-elevated=""');
});
it('attaches a plain tooltip', function () {
@@ -90,37 +103,31 @@ it('makes a filter chip without a model a toggle button the caller owns', functi
$html = (string) $this->blade('<x-chip type="filter" label="Starred" :selected="true" wire:click="toggleStarred" />');
expect(chipTag('<x-chip type="filter" label="Starred" :selected="true" wire:click="toggleStarred" />', 'button'))
->toContain('data-chip="filter"')
->toContain('data-md-chip="filter"')
->toContain('aria-pressed="true"')
->toContain('wire:click="toggleStarred"')
->toContain('aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container')
->and(chipTag('<x-chip type="filter" label="Starred" />', 'button'))
->toContain('aria-pressed="false"')
->and($html)
->not->toContain('type="checkbox"')
->toContain('group-aria-pressed/chip:w-4.5')
->toContain('data-chip-check');
->toContain('<span data-md-chip-check-slot>')
->toContain('data-md-chip-check');
});
it('makes a bound filter chip a native checkbox under the chip', function () {
$html = (string) $this->blade('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />');
expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />', 'label'))
->toContain('data-chip="filter"')
->toContain('me-4')
->toContain('wire:key="photos"')
->toContain('has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container')
->toContain('has-focus-visible:outline-3')
->not->toContain('x-model')
->toBe('<label data-md-chip="filter" class="me-4" wire:key="photos">')
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" />', 'input'))
->toContain('type="checkbox"')
->toContain('value="photos"')
->toContain('x-model="kinds"')
->toContain('sr-only')
->toContain('class="md-visually-hidden"')
->not->toContain('me-4')
->not->toContain('checked')
->and($html)
->toContain('group-has-checked/chip:w-4.5');
->toContain('data-md-chip-check');
});
it('submits a named filter chip, checked when selected, and greys a disabled one', function () {
@@ -130,48 +137,47 @@ it('submits a named filter chip, checked when selected, and greys a disabled one
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'input'))
->toContain('disabled')
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'label'))
->toContain('border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12')
->toContain('cursor-not-allowed');
->toContain('data-md-disabled=""');
});
it('swaps a filter chip\'s icon for the check when selected', function () {
$html = (string) $this->blade('<x-chip type="filter" label="Starred" icon="star" name="starred" elevated />');
expect($html)
->toContain('group-has-checked/chip:opacity-0')
->toContain('text-primary')
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1')
->not->toContain('w-0')
->and(substr_count($html, '<svg'))->toBe(2);
->toMatch('/<span data-md-chip-check-slot>\s*<svg[^>]*data-md-chip-icon[^>]*>.*?<\/svg>\s*<svg[^>]*data-md-chip-check/s')
->toContain('data-md-elevated=""')
->and(substr_count($html, '<svg'))->toBe(2)
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
->toContain("[data-md-chip]:is(:has(:checked), [aria-pressed='true']) [data-md-chip-check-slot] [data-md-chip-icon] {");
});
it('draws an input chip with an avatar, an icon or neither', function () {
expect(chipTag('<x-chip type="input" label="Anna" avatar="AM" />', 'span'))
->toContain('data-chip="input"')
->toContain('border-outline-variant text-on-surface-variant')
->toContain('data-md-chip="input"')
->toContain('data-md-avatar=""')
->not->toContain('x-data')
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="AM" />'))
->toContain('ps-0.75 pe-2.75')
->toContain('size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container')
->toContain('>AM</span>')
->toContain('<span aria-hidden="true" data-md-chip-avatar>AM</span>')
->not->toContain('<button')
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="/avatars/anna.jpg" />'))
->toContain('<img src="/avatars/anna.jpg" alt=""')
->and((string) $this->blade('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />'))
->toContain('ps-1.75 pe-2.75');
->toContain('<img src="/avatars/anna.jpg" alt="" data-md-chip-avatar />')
->and(chipTag('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />', 'span'))
->toContain('data-md-icon-start=""')
->not->toContain('data-md-avatar');
});
it('makes an input chip a button when it has its own action, and selects it', function () {
$html = (string) $this->blade('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />');
expect(chipTag('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />', 'button'))
->toContain('data-chip-action')
->toContain('data-md-chip-action')
->toContain('aria-pressed="true"')
->toContain('wire:click="open(1)"')
->and(chipTag('<x-chip type="input" label="Anna" :selected="true" />', 'span'))
->toContain('border-transparent bg-secondary-container text-on-secondary-container')
->and($html)
->toContain('me-2 size-4.5 text-primary');
->toContain('data-md-selected=""')
// An action of its own lights the leading icon on hover, focus and press.
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'span'))
->toContain('data-md-actionable=""')
->and(chipTag('<x-chip type="input" label="Anna" icon="person" />', 'span'))
->not->toContain('data-md-actionable');
});
it('gives a removable input chip a named remove button that calls wire:remove', function () {
@@ -180,15 +186,16 @@ it('gives a removable input chip a named remove button that calls wire:remove',
expect(chipTag('<x-chip type="input" label="anna@example.com" removable wire:remove="removeRecipient(3)" />', 'span'))
->toContain('x-data="materialChip"')
->toContain('x-on:keydown="removeOnKey($event)"')
->toContain('data-md-removable=""')
->not->toContain('wire:remove')
->and($html)
->toContain('data-chip-remove aria-label="Remove anna@example.com"')
->toContain('data-md-chip-remove aria-label="Remove anna@example.com"')
->toContain('wire:click="removeRecipient(3)"')
->toContain('x-on:click="removeChip($event)"')
->toContain('<input type="hidden" name="to[]" value="3" />')
->toContain('ps-2.75 pe-2')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
// 18px icon, 15px out on every side: M3's 48dp minimum for the close icon's target.
->toContain('after:absolute after:-inset-3.75');
->toMatch('/\[data-md-chip-remove\] \{.*?&::after \{\s*content: \'\';\s*position: absolute;\s*inset: -15px;/s');
});
it('runs an Alpine remove expression, or takes the chip off the page with neither', function () {
@@ -201,7 +208,7 @@ it('runs an Alpine remove expression, or takes the chip off the page with neithe
it('leaves a client-rendered chip\'s remove button to be named in the browser', function () {
expect((string) $this->blade('<x-chip type="input" removable remove="tags.pop()"><span x-text="tag"></span></x-chip>'))
->toContain('data-chip-remove="Remove :label"')
->toContain('data-md-chip-remove="Remove :label"')
->not->toContain('aria-label=');
});
@@ -209,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 />');
expect($html)
->toContain('border-on-surface/12 text-on-surface/38')
->toMatch('/<button[^>]*data-chip-remove[^>]*disabled/s');
->toContain('data-md-disabled=""')
->toMatch('/<button[^>]*data-md-chip-remove[^>]*disabled/s');
});
it('groups chips in a wrapping row named by its label, with a hint', function () {
$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-describedby="([^"]+)"/', $html, $describedBy);
expect($html)
->toContain('role="group"')
->toContain('data-chip-set x-data="materialChipSet"')
->toContain('flex flex-wrap gap-2')
->toMatch('/<div\s+data-md-chip-set\s+role="group"/')
->toContain('class="mt-4"')
->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.
->toContain('x-on:keydown="key($event)"')
->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=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</p>");
->toContain("id=\"{$labelledBy[1]}\" data-md-chip-set-label>File types</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 () {
expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>'))
->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('wire:ignore.self')
->toContain('overflow-x-auto')
->toContain('data-scroll-start:[--chip-fade-start:1.5rem]')
->not->toContain('flex-wrap');
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css'))
->toContain('overflow-x: auto;')
->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 () {
$html = (string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>');
expect($html)
->toContain('data-chip-scroll="start"')
->toContain('data-chip-scroll="end"')
->toContain('data-md-chip-scroll="start"')
->toContain('data-md-chip-scroll="end"')
->toContain('x-on:click="nudge(\'start\')"')
->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.
->toContain('tabindex="-1"')
->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.
->toContain('pointer-fine:scroll-px-10');
->toContain('scroll-padding-inline: var(--md-sys-measurement-space500);');
// 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>'))
->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 () {
+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]]" />');
expect($html)
->toMatch('/<div\\s+data-md-choices\\s/')
->not->toContain('data-md-searchable')
->toContain('role="group"')
->toContain('Days')
->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\']]" />');
expect($html)
->toMatch('/<div\\s+data-md-choices\\s+data-md-searchable\\s/')
->toContain('role="combobox"')
->toContain('aria-controls="zone-list"')
->toContain('aria-expanded="false"')
@@ -51,11 +54,14 @@ it('draws a searchable combobox with a popover list', function () {
->toContain('popover="manual"')
->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
->toContain('x-modelable="value"')
->toContain('field-arrow')
->toContain('data-md-field-arrow')
// WAI-ARIA's combobox keyboard, Home and End included.
->toContain('x-on:keydown.home="jump($event, false)"')
->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);
@@ -92,3 +98,13 @@ it('shows the errors for the property and its items', function () {
->toContain('Choose a time zone.')
->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 () {
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">')
->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-size="xs"')
->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-size="sm"');
->toContain('<table data-md-table data-md-size="sm" class="min-w-160">')
->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-md-size="xs"')
->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 () {
expect((string) $this->blade('<x-table />'))->not->toContain('data-dense')
->and((string) $this->blade('<x-table dense />'))->toContain('data-dense');
expect((string) $this->blade('<x-table />'))->not->toContain('data-md-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 () {
@@ -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;})"')
->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('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;})')
->and((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'size\', \'direction\' => \'desc\']">Size</x-sort-header>'))
->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']);
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())
->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\')"')
->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)
->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('aria-haspopup="dialog"')
->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)
->toContain('class="w-60"')
->toContain('data-variant="filled"')
->toContain('data-size="sm"')
->toContain('data-md-variant="filled"')
->toContain('data-md-size="sm"')
->toMatch('/<input\s[^>]*required[^>]*id="due"/s')
->toMatch('/<input\s[^>]*disabled[^>]*id="due"/s')
->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')
->toContain('aria-invalid="true"')
->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 () {
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
->toContain('data-field-clear')
->toContain('data-md-field-clear')
->toContain('x-on:click="clear()"')
->toContain('aria-label="Clear"')
->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" />');
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>')
->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('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 () {
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']);
expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-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="glass" />'))->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-md-variant="outlined"')
->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 () {
$html = (string) $this->blade('<x-input id="share-name" label="Share name" hint="Recipients see this name" wire:model="name" />');
expect($html)
->toContain('<label for="share-name" class="field-label">')
->toContain('<label for="share-name" data-md-field-label>')
->toContain('id="share-name"')
->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"')
->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" />');
expect($html)
->toContain('data-invalid')
->toContain('data-md-invalid')
->toContain('aria-invalid="true"')
->toContain('role="alert"')
->toContain('aria-describedby="email-support"')
->toContain('Enter a valid email address.')
->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.']];
expect((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" wire:model="email" />'))
->toContain('field-trailing field-error')
->toContain('aria-label="Error"')
->toMatch('/<svg(?=[^>]*data-md-field-trailing)(?=[^>]*data-md-field-error)(?=[^>]*aria-label="Error")(?=[^>]*role="img")[^>]*>/')
->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" />'))
->not->toContain('field-error')
->not->toContain('data-md-field-error')
->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 () {
$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)
->toContain('class="field w-40"')
->toContain('data-size="sm"')
->toContain('data-mono')
->toContain('data-readonly')
->toContain('<span class="field-affix">CHF</span>')
->toContain('<span class="field-affix">per month</span>')
->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('<span data-md-field-affix>CHF</span>')
->toContain('<span data-md-field-affix>per month</span>')
->toContain('type="number"')
->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);
});
@@ -78,9 +80,12 @@ it('offers to clear and to copy the value', function () {
$html = (string) $this->blade('<x-input label="Link" clearable copyable />');
expect($html)
->toContain('data-field-clear')
->toContain('data-md-field-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('aria-label="Copy"');
});
@@ -92,7 +97,8 @@ it('lets a password be shown and hidden again', function () {
->toContain('type="password"')
->not->toContain('type="text"')
->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"')
// The flipping label already says the state; aria-pressed would say it again, inverted.
->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 () {
expect((string) $this->blade('<x-textarea label="Message" rows="2" max-rows="6" />'))
->toContain('rows="2"')
->toContain('data-autogrow')
->toContain('data-md-textarea')
->toContain('data-md-autogrow')
->toContain('--field-rows: 2;')
->toContain('--field-max-rows: 6;')
->and((string) $this->blade('<x-textarea label="Message" :autogrow="false" />'))
->toContain('rows="3"')
->not->toContain('data-autogrow');
->not->toContain('data-md-autogrow');
});
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);
expect($html)
->toContain('data-floated')
->toContain('data-md-floated')
->toContain('data-md-select')
->toMatch('/<select(?=[^>]*data-md-field-control)[^>]*>/')
->toContain('<select')
->toContain('wire:model="hours"')
->toContain('<option value="">Choose…</option>')
->toContain('<option value="1" >1 hour</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 () {
@@ -142,7 +151,8 @@ it('collects a file field\'s own errors and each file\'s', function () {
expect($html)
->toContain('type="file"')
->toContain('multiple')
->toContain('data-floated')
->toContain('data-md-file')
->toContain('data-md-floated')
->toContain('Choose at most 10 photos.')
->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 />');
expect($html)
->toContain('class="field-support-row"')
->toContain('class="field-counter"')
->toContain('data-md-field-support-row')
->toContain('data-md-field-counter')
->toContain('x-bind:data-md-over="used > max"')
->toContain('class="md-visually-hidden"')
->toContain('maxlength="60"')
->toContain('max: 60,')
->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');
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>');
});
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')
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('field-counter')
->and((string) $this->blade('<x-textarea label="Note" counter />'))->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('data-md-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 () {
expect(file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))
->toContain('@media (width >= 37.5rem)')
->toMatch('/\.field:not\(\[data-full\]\)\s*\{\s*max-width: 40rem;/');
->toContain('@media (width >= 600px)')
->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')
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-full')
->and((string) $this->blade('<x-input label="Share name" />'))->not->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-md-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 () {
@@ -189,10 +201,91 @@ it('lays out a form with its actions under an optional divider', function () {
BLADE);
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('justify-between')
->toContain('<div data-md-form-actions class="justify-between">')
->not->toContain('grid-cols-1')
->toContain('<button>Save</button>')
->and((string) $this->blade('<x-form><x-slot:actions><button>Save</button></x-slot:actions></x-form>'))
->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" />');
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('type="search"')
->toContain('placeholder="Search shares"')
@@ -15,14 +18,15 @@ it('draws a search bar whose combobox controls the view', function () {
->toContain('aria-expanded="false"')
->toContain('wire:model.live.debounce.300ms="query"')
->toContain('id="find-view"')
->toContain('data-search-clear')
->toContain('data-search-scrim')
->toContain('data-md-search-clear')
->toContain('data-md-search-scrim')
->toContain('aria-label="Back"')
->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.
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');
});
@@ -30,7 +34,7 @@ it('announces how many results there are, politely', function () {
$html = (string) $this->blade('<x-search />');
expect($html)
->toContain('data-search-status')
->toContain('data-md-search-status class="md-visually-hidden"')
->toContain('aria-live="polite"')
->toContain('aria-atomic="true"')
->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 () {
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>')
->and((string) $this->blade('<x-search><x-slot:empty>No shares match.</x-slot:empty></x-search>'))
->toContain('No shares match.')
->not->toContain('data-search-results role="list"');
->toContain('<p data-md-search-empty>No shares match.</p>')
->not->toContain('data-md-search-results role="list"');
});
it('offers M3\'s search-icon entry point', function () {
$html = (string) $this->blade('<x-search id="find" trigger="icon" label="Search shares" />');
expect($html)
->toContain('data-trigger="icon"')
->toContain('data-search-trigger')
->toContain('data-md-trigger="icon"')
->toContain('data-md-search-trigger')
->toContain('x-ref="trigger"')
->toContain('x-on:click="expand()"')
->toContain('aria-label="Search shares"')
@@ -61,11 +65,11 @@ it('offers M3\'s search-icon entry point', function () {
->toContain('materialSearch(false,')
->toContain("'icon'")
// 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 />'))
->toContain('data-trigger="bar"')
->not->toContain('data-search-trigger');
->toContain('data-md-trigger="bar"')
->not->toContain('data-md-search-trigger');
});
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);
expect($html)
->toContain('data-search-suggestions role="list"')
->toContain('data-md-search-suggestions role="list"')
->toContain('x-show="query === \'\'"')
->toContain('Recent shares')
->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.
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 !== \'\'"');
});
@@ -97,7 +101,19 @@ it('names the input, trails the bar, and stays docked on request', function () {
expect($html)
->toContain('placeholder="Search"')
->toContain('aria-label="Search your shares"')
->toContain('data-search-trailing')
->toContain('data-md-search-trailing')
->toContain('<button>AR</button>')
->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
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)
->toContain('<div class="min-w-0 mt-2">')
->toContain('<label for="notify" data-selection')
->toContain('data-checkbox')
->toContain('<div data-md-checkbox class="mt-2" style="order: 2">')
->toMatch('/<label for="notify" data-md-selection-row\s*>/')
->toContain('<span data-md-checkbox-box >')
->toContain('type="checkbox"')
->toContain('wire:model="notify"')
->toContain('data-check')
->toContain('data-mixed')
->toContain('Notify me')
->toContain('On every download')
->not->toContain('data-indeterminate');
->toContain('data-md-checkbox-check')
->toContain('data-md-checkbox-mixed')
->toContain('<span data-md-selection-label>Notify me</span>')
->toContain('<span data-md-selection-hint>On every download</span>')
->not->toContain('data-md-indeterminate')
->not->toContain('min-w-0');
});
it('fills the box with an 18px tick from the 20 optical cut', function () {
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="remove" class="size-4.5" optical="20" data-mixed />'));
->toContain((string) $this->blade('<x-icon name="check" size="18" data-md-checkbox-check />'))
->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 () {
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 () {
expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toContain('touch-target')
->and((string) $this->blade('<x-toggle aria-label="Public link" />'))->toContain('touch-target')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'\']]" />'))->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" />'))->toMatch('/<span data-md-switch\\s+class="md-touch-target"\\s*>/')
->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.
expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('touch-target')
->and((string) $this->blade('<x-toggle label="Public link" />'))->not->toContain('touch-target')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->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('md-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 () {
@@ -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('Downloads')
->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.
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 () {
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 () {
@@ -69,7 +72,8 @@ it('shows a checkbox\'s errors', function () {
->blade('<x-checkbox id="terms" label="I accept the terms" wire:model="terms" />');
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-describedby="terms-support"')
->toContain('Accept the terms to continue.');
@@ -85,8 +89,11 @@ it('draws radio buttons in a fieldset named by its question', function () {
BLADE);
expect($html)
->toContain('<fieldset')
->toContain('Who can open it</legend>')
->toMatch('/<fieldset\\s+data-md-radio\\s/')
->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, 'name="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\']]" />');
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($html)->toMatch('/value="dark"\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 () {
expect((string) $this->withViewErrors(['audience' => ['Choose who can open it.']])
->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.');
});
@@ -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 />');
expect($html)
->toContain('<label for="public" data-selection')
->toContain('data-switch')
->toMatch('/<div data-md-toggle\s*>/')
->toMatch('/<label for="public" data-md-selection-row\\s+data-md-right\\s*>/')
->toContain('data-md-switch')
->toContain('role="switch"')
->toContain('type="checkbox"')
->toContain('wire:model.live="public"')
->toContain('data-handle')
->toContain('flex-row-reverse')
->not->toContain('data-icons');
->toContain('data-md-switch-handle')
->not->toContain('class=')
->not->toContain('data-md-icons');
});
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')
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-icons="selected"');
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 />'))->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('x-data="materialSlider"')
->toContain('x-on:pointerdown="press($event)"')
->toContain('data-slider-drawing wire:ignore aria-hidden="true"')
->toContain('noscript:appearance-auto')
->toContain('data-md-slider-control')
->toContain('<div data-md-slider-drawing wire:ignore aria-hidden="true">')
->toContain('data-md-slider-input="start"')
->not->toContain('class=')
->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" />');
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-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-segment="start"\s+hidden/')
->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/')
->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/')
->toMatch('/data-md-slider-segment="start"\s+hidden/')
->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)">')
->toMatch('/data-value-label\s+class="[^"]*opacity-0[^"]*"\s*>40<\/span>/');
->toContain('<span data-md-slider-handle="start" style="left: calc(40% + 0px)">')
->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 () {
expect((string) $this->blade('<x-slider value="40" />'))
->toContain('flex h-11 min-w-12 items-center')
->toMatch('/data-value-label[^>]*class="[^"]*origin-bottom/')
->toMatch('/data-stop="end"[^>]*class="[^"]*bg-on-secondary-container/');
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
expect((string) $this->blade('<x-slider value="40" />'))->toMatch('/data-md-slider-stop="end"\s+style="left: calc\(100% - 8px\)"/')
->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) {
@@ -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 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=');
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" />'))
->toContain('Too loud.')
->toContain('text-error')
->toContain('<div id="volume-hint" data-md-slider-errors>')
->toContain('aria-invalid="true"')
->toContain('aria-describedby="volume-hint"')
->not->toContain('Quietly');
@@ -133,105 +141,131 @@ it('replaces the hint with the validation message and marks the input invalid',
->not->toContain('Per night');
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');
});
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]))
->toContain("data-size=\"{$size}\"")
->toContain("-translate-y-1/2 {$track}\"")
->toContain($handle)
->toMatch("/group\\/slider [^\"]* {$body} /");
->toContain("data-md-size=\"{$size}\"");
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
$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([
'xs' => ['xs', 'h-4', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
'sm' => ['sm', 'h-6', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
'md' => ['md', 'h-10', 'h-13 group-data-focused/thumb:h-11.5', 'h-13'],
'lg' => ['lg', 'h-14', 'h-17 group-data-focused/thumb:h-15.5', 'h-17'],
'xl' => ['xl', 'h-24', 'h-27 group-data-focused/thumb:h-25.5', 'h-27'],
'xs' => ['xs', '16px', '44px', '48px'],
'sm' => ['sm', '24px', '44px', '48px'],
'md' => ['md', '40px', '52px', '52px'],
'lg' => ['lg', '56px', '68px', '68px'],
'xl' => ['xl', '96px', '108px', '108px'],
]);
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" />'))
->toContain('data-track-icon')
->toMatch('/data-track-icon\s+data-active/')
->toContain('text-on-secondary-container data-active:text-on-primary');
->toMatch('/data-md-slider-icon\s+data-md-active/')
->toContain('--md-icon-size: 24px');
expect((string) $this->blade('<x-slider size="xl" icon="volume_up" value="2" />'))
->toMatch('/data-track-icon\s+class/')
->toContain('size-8');
->toMatch('/data-md-slider-icon\s+style=/')
->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) {
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 () {
expect((string) $this->blade('<x-slider :min="-50" :max="50" value="25" centered />'))
->toContain('data-centered')
->toMatch('/data-segment="start"\s+class="[^"]*"\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-segment="end"\s+class="[^"]*"\s+style="left: calc\(75% \+ 8px\)/');
->toContain('data-md-centered')
->toMatch('/data-md-slider-segment="start"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/')
->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 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 () {
$html = (string) $this->blade('<x-slider ticks :max="10" value="5" />');
expect(substr_count($html, 'data-tick="'))->toBe(11)
->and($html)->toContain('data-tick="0.5"')
->and($html)->toContain('bg-on-secondary-container data-active:bg-on-primary');
expect(substr_count($html, 'data-md-slider-tick="'))->toBe(11)
->and($html)->toContain('data-md-slider-tick="0.5"')
->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=')
->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-tick=')
->and((string) $this->blade('<x-slider :max="10" />'))->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-md-slider-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 () {
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" />'))
->toContain('data-value-label')
->toContain('mt-12')
->not->toContain('opacity-0 scale-75');
->toContain('data-md-value-label="always"')
->toContain('<span data-md-slider-value>30</span>');
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 () {
$html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="h-64" />');
expect($html)
->toContain('data-orientation="vertical"')
->toContain('data-md-orientation="vertical"')
->toContain('aria-orientation="vertical"')
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
->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');
->toContain('class="h-64"');
// The sizes are the horizontal ones, across instead of along.
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');
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
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 () {
expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />'))
->not->toContain('data-orientation')
->not->toContain('aria-orientation')
->toContain('h-12');
->not->toContain('data-md-orientation')
->not->toContain('aria-orientation');
});
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]))
->toContain("absolute inset-y-0 {$active} group-has-disabled/slider:bg-on-surface/38")
->toContain("absolute inset-y-0 {$inactive} group-has-disabled/slider:bg-on-surface/12")
->toContain('data-md-color="'.($color === 'pink' ? 'primary' : $color).'"')
->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([
'primary' => ['primary', 'bg-primary', 'bg-secondary-container'],
'tertiary' => ['tertiary', 'bg-tertiary', 'bg-tertiary-container'],
'error' => ['error', 'bg-error', 'bg-error-container'],
'unknown' => ['pink', 'bg-primary', 'bg-secondary-container'],
'primary' => ['primary', 'primary', 'secondary-container'],
'tertiary' => ['tertiary', 'tertiary', 'tertiary-container'],
'error' => ['error', 'error', 'error-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" />');
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')
->toContain('value="14:30"')
->toContain('aria-haspopup="dialog"')
->toContain('aria-controls="starts-dialog"')
->toContain('aria-expanded="false"')
->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('data-timepicker-open')
->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("materialTimepicker( '14:30' ,")
->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 () {
@@ -153,7 +153,7 @@ it('shows the errors for its property in place of the hint', function () {
});
expect(Livewire::test('timepicker-errors')->html())
->toContain('data-invalid')
->toContain('data-md-invalid')
->toContain('aria-invalid="true"')
->toContain('aria-describedby="starts-support"')
->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)
->toMatch('/^<div\s+class="w-60"/')
->toContain('data-variant="filled"')
->toContain('data-size="sm"')
->toContain('data-md-variant="filled"')
->toContain('data-md-size="sm"')
->toContain('data-timepicker-field')
->toContain('required')
->toContain('placeholder="hh:mm"')
->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')
->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>'))
->toContain('aria-label="Close"')
->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 () {
+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);');
});
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'));
// 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';");
$tailwind = strpos($app, "@import 'tailwindcss'");
$components = strpos($app, "@import '../../../resources/css/tailwind.css';");