Merge branch 'worktree-agent-a41c756369d016a29'
This commit is contained in:
@@ -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>`
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-timepicker-field] .field-control {
|
||||
[data-timepicker-field] [data-md-field-control] {
|
||||
cursor: pointer;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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; })()");
|
||||
});
|
||||
|
||||
@@ -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', '');
|
||||
});
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 })()");
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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';");
|
||||
});
|
||||
|
||||
@@ -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\', {"column":"size","direction":"asc"})"')
|
||||
->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\', {"column":"size","direction":"desc"})')
|
||||
->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';");
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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';");
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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';");
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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';");
|
||||
|
||||
Reference in New Issue
Block a user