Draw the checkbox and the selection controls' row without Tailwind

<x-checkbox> renders data-md-checkbox, its box, tick and dash, and its
row, text and errors as data-md-* attributes; the 18px tick takes a size
prop and a label-less box md-touch-target (plan step 36). selection.css
moves into material.components as the row, text and state layer the
three controls share, and each control's drawing goes to its own file:
checkbox.css, radio.css, toggle.css, on the state tokens. The radio and
the switch take the renamed shared hooks now and lose their layout
classes in their own commits. field.js follows data-md-indeterminate.

Renaming the switch's data-handle also stops selection.css matching the
slider's handles, which it drew 24px above the track.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 14:34:00 +02:00
co-authored by Claude Opus 5
parent 3083fe28b2
commit f86afc0542
12 changed files with 510 additions and 365 deletions
+3
View File
@@ -21,6 +21,9 @@
@import './components/textarea.css'; @import './components/textarea.css';
@import './components/select.css'; @import './components/select.css';
@import './components/file.css'; @import './components/file.css';
@import './components/checkbox.css';
@import './components/radio.css';
@import './components/toggle.css';
/* Containment */ /* Containment */
+114
View File
@@ -0,0 +1,114 @@
/*
* <x-checkbox>: M3's checkbox (resources/views/components/checkbox.blade.php), on the row the
* selection controls share (components/selection.css).
*
* CheckboxTokens (androidx Compose Material 3, Apache-2.0): an 18px box with a 2px on-surface-variant
* outline and 2px corners, filled with primary and holding an on-primary tick (or dash, for the
* mixed state) at the 18px icon size once it is ticked; the state layer a 40px circle around it,
* 11px past each edge. The tick grows in and fades in on the fast spatial spring. With the server's
* error the outline, the fill and the state layer are error and the tick on-error. Disabled, the
* outline and the fill are on-surface at 38% and the tick is surface. Beside a hint the box drops
* 3px, to sit on the label's first line.
*
* [data-md-checkbox] the root: the row and its errors; `class` and `style` land here
* [data-md-selection-row]
* [data-md-checkbox-box] the box: input, [data-md-checkbox-check], [data-md-checkbox-mixed]
* [data-md-selection-support] the errors, 4px under the row
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './selection.css';
@import './icon.css';
@layer material.components {
[data-md-checkbox] {
min-width: 0;
}
[data-md-checkbox-box] {
position: relative;
display: grid;
flex: none;
place-items: center;
width: 18px;
height: 18px;
}
[data-md-checkbox-box] > * {
grid-area: 1 / 1;
}
[data-md-selection-row]:has([data-md-selection-hint]) [data-md-checkbox-box] {
margin-top: 3px;
}
[data-md-checkbox-box]::before {
inset: -11px;
}
[data-md-checkbox-box]:has(input:focus-visible)::before {
outline: 3px solid var(--md-sys-color-secondary);
}
[data-md-checkbox-box] input {
width: 18px;
height: 18px;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: 2px;
}
[data-md-checkbox-box] input:is(:checked, :indeterminate) {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-md-checkbox-box] :is([data-md-checkbox-check], [data-md-checkbox-mixed]) {
color: var(--md-sys-color-on-primary);
opacity: 0;
scale: 0.5;
pointer-events: none;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-md-checkbox-box] input:checked:not(:indeterminate) ~ [data-md-checkbox-check],
[data-md-checkbox-box] input:indeterminate ~ [data-md-checkbox-mixed] {
opacity: 1;
scale: 1;
}
[data-md-selection-row][data-md-invalid] [data-md-checkbox-box] input {
border-color: var(--md-sys-color-error);
}
[data-md-selection-row][data-md-invalid] [data-md-checkbox-box] input:is(:checked, :indeterminate) {
background-color: var(--md-sys-color-error);
}
[data-md-selection-row][data-md-invalid] [data-md-checkbox-box] :is([data-md-checkbox-check], [data-md-checkbox-mixed]) {
color: var(--md-sys-color-on-error);
}
[data-md-checkbox-box] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-checkbox-box] input:disabled:is(:checked, :indeterminate) {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
}
[data-md-checkbox-box] input:disabled ~ :is([data-md-checkbox-check], [data-md-checkbox-mixed]) {
color: var(--md-sys-color-surface);
}
[data-md-checkbox] [data-md-selection-support] {
margin-top: var(--md-sys-measurement-space50);
color: var(--md-sys-color-error);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
}
+79
View File
@@ -0,0 +1,79 @@
/*
* <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%.
*
* [data-md-radio-button] the ring: input, [data-md-radio-dot]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './selection.css';
@layer material.components {
[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-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);
}
}
+67 -296
View File
@@ -1,70 +1,90 @@
/* /*
* M3's selection controls: the checkbox, the radio button and the switch (CheckboxTokens, * What M3's three selection controls share: the checkbox (components/checkbox.css), the radio button
* RadioButtonTokens, SwitchTokens, androidx Compose Material 3, Apache-2.0). * (components/radio.css) and the switch (components/toggle.css) — CheckboxTokens, RadioButtonTokens,
* SwitchTokens, androidx Compose Material 3, Apache-2.0. Each control's stylesheet imports this one.
* *
* Each is a real `<input>` with its appearance removed and drawn over, so focus, the keyboard * Each is a real `<input>` with its appearance removed and drawn over, so focus, the keyboard
* (Space; the arrow keys between radios), the form value, `wire:model` and what a screen reader * (Space; the arrow keys between radios), the form value, `wire:model` and what a screen reader
* announces stay the browser's. The row around it is a `<label>`, so its text is pressable too. * announces stay the browser's. The row around it is a `<label>`, so its text is pressable too:
* Hooks are data attributes, not class names: `checkbox`, `radio` and `toggle` are daisyUI's, which
* the design guard rejects.
* *
* [data-selection] the <label> row; `data-invalid` when the server has an error for it * [data-md-selection-row] the <label>: the control and its text, 16px apart; with a hint
* [data-checkbox] the 18px box, its state layer a 40px circle around it * the control aligns to the first line; `data-md-right` puts the
* input, [data-check], [data-mixed] * control at the end; `data-md-invalid` when the server has an error
* [data-radio] the 20px ring and its 10px dot * [data-md-checkbox-box] | [data-md-radio-button] | [data-md-switch] the control
* input, [data-dot] * [data-md-selection-text] the label (body-large, on-surface) and the hint under it
* [data-switch] the 52×32 track; `data-icons` = "both" or "selected" * [data-md-selection-label], [data-md-selection-hint]
* input, [data-handle] (with [data-on] and [data-off] icons)
* *
* Every state layer is on-surface when unselected and primary when selected, 8% on hover (only * Every state layer is on-surface when unselected and primary when selected, at M3's hover opacity
* where a pointer can hover) and 10% on focus and press; the focus indicator is the package's * (only where a pointer can hover) and its focus and press opacities (tokens/state.css); the focus
* secondary ring. A disabled control is on-surface at 38% and has no state layer. * indicator is the package's secondary ring. A disabled control is on-surface at 38% and has no
* state layer.
* *
* The state layer is a ::before with no pointer events, so it is not a target: a labelled control * The state layer is a ::before with no pointer events, so it is not a target: a labelled control
* is pressed anywhere along its row, and a control with no label wears the `touch-target` utility * is pressed anywhere along its row, and a control with no label wears `md-touch-target`
* (resources/css/tokens/state.css) so its box catches presses over M3's 48px minimum. * (foundation/interaction.css) so its box catches presses over M3's 48px minimum.
*/ */
@layer components { @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
[data-selection] {
@layer material.components {
[data-md-selection-row] {
/* The state layer's tint. */
--selection-ink: var(--md-sys-color-on-surface);
display: flex;
align-items: center;
gap: var(--md-sys-measurement-space200);
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
[data-selection]:has(input:disabled) { [data-md-selection-row]:has([data-md-selection-hint]) {
align-items: flex-start;
}
[data-md-selection-row][data-md-right] {
flex-direction: row-reverse;
justify-content: space-between;
}
[data-md-selection-row]:has(input:disabled) {
cursor: default; cursor: default;
} }
[data-selection]:has(input:disabled) [data-selection-label] { [data-md-selection-text] {
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); min-width: 0;
} }
[data-checkbox], [data-md-selection-label] {
[data-radio] { display: block;
position: relative; color: var(--md-sys-color-on-surface);
display: grid; font: var(--md-sys-typescale-body-lg);
flex: none; letter-spacing: var(--md-sys-typescale-body-lg-tracking);
place-items: center; font-variation-settings: normal;
} }
[data-checkbox] > *, [data-md-selection-hint] {
[data-radio] > * { display: block;
grid-area: 1 / 1; margin-top: var(--md-sys-measurement-space25);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
} }
[data-checkbox]::before, [data-md-selection-row]:has(input:disabled) [data-md-selection-label] {
[data-radio]::before, color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
[data-handle]::before { }
content: "";
:is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
content: '';
position: absolute; position: absolute;
border-radius: var(--md-sys-shape-corner-full); border-radius: var(--md-sys-shape-corner-full);
pointer-events: none; pointer-events: none;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
} }
[data-checkbox] input, :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch]) input {
[data-radio] input,
[data-switch] input {
margin: 0; margin: 0;
appearance: none; appearance: none;
cursor: inherit; cursor: inherit;
@@ -74,275 +94,26 @@
transition-timing-function: var(--md-sys-motion-effects-fast); transition-timing-function: var(--md-sys-motion-effects-fast);
} }
/* The state layer. `--selection-ink` is what it is tinted with. */ [data-md-selection-row]:has(input:checked),
[data-selection] { [data-md-selection-row]:has(input:indeterminate) {
--selection-ink: var(--md-sys-color-on-surface);
}
[data-selection]:has(input:checked),
[data-selection]:has(input:indeterminate) {
--selection-ink: var(--md-sys-color-primary); --selection-ink: var(--md-sys-color-primary);
} }
[data-selection][data-invalid] { [data-md-selection-row][data-md-invalid] {
--selection-ink: var(--md-sys-color-error); --selection-ink: var(--md-sys-color-error);
} }
@media (hover: hover) { @media (hover: hover) {
[data-selection]:hover:not(:has(input:disabled)) :is([data-checkbox], [data-radio], [data-handle])::before { [data-md-selection-row]:hover:not(:has(input:disabled)) :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
background-color: color-mix(in srgb, var(--selection-ink) 8%, transparent); background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent);
} }
} }
[data-selection]:has(input:focus-visible) :is([data-checkbox], [data-radio], [data-handle])::before, [data-md-selection-row]:has(input:focus-visible) :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
[data-selection]:not(:has(input:disabled)):active :is([data-checkbox], [data-radio], [data-handle])::before { background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
background-color: color-mix(in srgb, var(--selection-ink) 10%, transparent);
} }
[data-checkbox]:has(input:focus-visible)::before, [data-md-selection-row]:not(:has(input:disabled)):active :is([data-md-checkbox-box], [data-md-radio-button], [data-md-switch-handle])::before {
[data-radio]:has(input:focus-visible)::before { background-color: color-mix(in srgb, var(--selection-ink) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent);
outline: 3px solid var(--md-sys-color-secondary);
}
/* ---- Checkbox ---------------------------------------------------------------------------- */
[data-checkbox] {
width: 1.125rem;
height: 1.125rem;
}
[data-checkbox]::before {
inset: -0.6875rem;
}
[data-checkbox] input {
width: 1.125rem;
height: 1.125rem;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: 2px;
}
[data-checkbox] input:is(:checked, :indeterminate) {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-checkbox] :is([data-check], [data-mixed]) {
color: var(--md-sys-color-on-primary);
opacity: 0;
scale: 0.5;
pointer-events: none;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-checkbox] input:checked:not(:indeterminate) ~ [data-check],
[data-checkbox] input:indeterminate ~ [data-mixed] {
opacity: 1;
scale: 1;
}
[data-selection][data-invalid] [data-checkbox] input {
border-color: var(--md-sys-color-error);
}
[data-selection][data-invalid] [data-checkbox] input:is(:checked, :indeterminate) {
background-color: var(--md-sys-color-error);
}
[data-selection][data-invalid] [data-checkbox] :is([data-check], [data-mixed]) {
color: var(--md-sys-color-on-error);
}
[data-checkbox] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
[data-checkbox] input:disabled:is(:checked, :indeterminate) {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
[data-checkbox] input:disabled ~ :is([data-check], [data-mixed]) {
color: var(--md-sys-color-surface);
}
/* ---- Radio button ------------------------------------------------------------------------ */
[data-radio] {
width: 1.25rem;
height: 1.25rem;
}
[data-radio]::before {
inset: -0.625rem;
}
[data-radio] input {
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--md-sys-color-on-surface-variant);
border-radius: var(--md-sys-shape-corner-full);
}
[data-radio] [data-dot] {
width: 0.625rem;
height: 0.625rem;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary);
scale: 0;
pointer-events: none;
transition: scale var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-radio] input:checked {
border-color: var(--md-sys-color-primary);
}
[data-radio] input:checked ~ [data-dot] {
scale: 1;
}
[data-selection][data-invalid] [data-radio] input {
border-color: var(--md-sys-color-error);
}
[data-selection][data-invalid] [data-radio] [data-dot] {
background-color: var(--md-sys-color-error);
}
[data-radio] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
[data-radio] input:disabled ~ [data-dot] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
}
/* ---- Switch -------------------------------------------------------------------------------
*
* The handle's centre is 16px from the track's start unselected and 36px selected; it is 16px
* across unselected, 24px with an icon or selected, and 28px while pressed. The positions below
* are those centres less half the size, measured inside the track's 2px outline. */
[data-switch] {
position: relative;
display: grid;
flex: none;
width: 3.25rem;
height: 2rem;
}
[data-switch] input {
width: 100%;
height: 100%;
border: 2px solid var(--md-sys-color-outline);
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-surface-container-highest);
}
[data-switch] input:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-handle] {
--handle: 1rem;
position: absolute;
top: 50%;
inset-inline-start: calc(1rem - var(--handle) / 2);
display: grid;
place-items: center;
width: var(--handle);
height: var(--handle);
translate: 0 -50%;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-outline);
color: var(--md-sys-color-surface-container-highest);
pointer-events: none;
transition-property: inset-inline-start, width, height, background-color;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
[data-handle]::before {
inset: 50% auto auto 50%;
width: 2.5rem;
height: 2.5rem;
translate: -50% -50%;
}
[data-handle] > svg {
grid-area: 1 / 1;
opacity: 0;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-switch][data-icons="both"] [data-handle] {
--handle: 1.5rem;
}
[data-switch][data-icons="both"] input:not(:checked) ~ [data-handle] [data-off],
[data-switch][data-icons] input:checked ~ [data-handle] [data-on] {
opacity: 1;
}
[data-switch] input:checked {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-primary);
}
[data-switch] input:checked ~ [data-handle] {
--handle: 1.5rem;
inset-inline-start: calc(2.25rem - var(--handle) / 2);
background-color: var(--md-sys-color-on-primary);
color: var(--md-sys-color-on-primary-container);
}
[data-selection]:active [data-switch] input:not(:disabled) ~ [data-handle] {
--handle: 1.75rem;
}
@media (hover: hover) {
[data-selection]:hover [data-switch] input:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-on-surface-variant);
}
[data-selection]:hover [data-switch] input:checked:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-primary-container);
}
}
[data-switch] input:focus-visible ~ [data-handle],
[data-selection]:active [data-switch] input:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-on-surface-variant);
}
[data-switch] input:checked:focus-visible ~ [data-handle],
[data-selection]:active [data-switch] input:checked:not(:disabled) ~ [data-handle] {
background-color: var(--md-sys-color-primary-container);
}
[data-switch] input:disabled {
border-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
background-color: color-mix(in srgb, var(--md-sys-color-surface-container-highest) 12%, transparent);
}
[data-switch] input:disabled ~ [data-handle] {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
color: color-mix(in srgb, var(--md-sys-color-surface-container-highest) 38%, transparent);
}
[data-switch] input:disabled:checked {
border-color: transparent;
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
}
[data-switch] input:disabled:checked ~ [data-handle] {
background-color: var(--md-sys-color-surface);
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
} }
} }
+145
View File
@@ -0,0 +1,145 @@
/*
* <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-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 {
/* 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);
}
}
-1
View File
@@ -22,7 +22,6 @@
@import './components/menu.css'; @import './components/menu.css';
/* Inputs, selection and data — leaving in step 36 */ /* Inputs, selection and data — leaving in step 36 */
@import './components/selection.css';
@import './components/search.css'; @import './components/search.css';
@import './components/datepicker.css'; @import './components/datepicker.css';
@import './components/timepicker.css'; @import './components/timepicker.css';
+5 -5
View File
@@ -8,13 +8,13 @@
* in. * in.
* *
* `<x-checkbox indeterminate>`: HTML has no attribute for a checkbox's mixed state, only the * `<x-checkbox indeterminate>`: HTML has no attribute for a checkbox's mixed state, only the
* `indeterminate` property, so the component marks the input `data-indeterminate` and this keeps * `indeterminate` property, so the component marks the input `data-md-indeterminate` and this keeps
* the property in step with the mark — when the page loads, and when a render adds or removes the * the property in step with the mark — when the page loads, and when a render adds or removes the
* mark. A person pressing the box clears the property as the browser always does; the mark stays * mark. A person pressing the box clears the property as the browser always does; the mark stays
* until the server decides again. * until the server decides again.
*/ */
const GROWING = 'textarea[data-md-autogrow]' const GROWING = 'textarea[data-md-autogrow]'
const MIXED = 'input[type="checkbox"][data-indeterminate]' const MIXED = 'input[type="checkbox"][data-md-indeterminate]'
const growsByItself = window.CSS?.supports?.('field-sizing', 'content') ?? false const growsByItself = window.CSS?.supports?.('field-sizing', 'content') ?? false
@@ -49,8 +49,8 @@ if (! growsByItself) {
new MutationObserver((records) => { new MutationObserver((records) => {
for (const record of records) { for (const record of records) {
if (record.type === 'attributes') { if (record.type === 'attributes') {
if (record.attributeName === 'data-indeterminate' && record.target instanceof HTMLInputElement) { if (record.attributeName === 'data-md-indeterminate' && record.target instanceof HTMLInputElement) {
record.target.indeterminate = record.target.hasAttribute('data-indeterminate') record.target.indeterminate = record.target.hasAttribute('data-md-indeterminate')
} else if (! growsByItself && record.target.matches(GROWING)) { } else if (! growsByItself && record.target.matches(GROWING)) {
grow(record.target) grow(record.target)
} }
@@ -76,7 +76,7 @@ new MutationObserver((records) => {
subtree: true, subtree: true,
childList: true, childList: true,
attributes: true, attributes: true,
attributeFilter: ['data-indeterminate', 'data-md-autogrow'], attributeFilter: ['data-md-indeterminate', 'data-md-autogrow'],
}) })
markAll(document) markAll(document)
+18 -25
View File
@@ -4,19 +4,20 @@
The label and its hint sit beside it and are its accessible name the whole row is the The label and its hint sit beside it and are its accessible name the whole row is the
`<label>`, so either can be pressed. `right` puts the box at the end of the row, for a setting `<label>`, so either can be pressed. `right` puts the box at the end of the row, for a setting
read left to right. `indeterminate` shows the mixed state of a "select all" whose items are read left to right. `indeterminate` shows the mixed state of a "select all" whose items are
partly ticked: HTML has no attribute for it, so the input is marked `data-indeterminate` and partly ticked: HTML has no attribute for it, so the input is marked `data-md-indeterminate` and
resources/js/field.js keeps the property in step, on load and after every morph. Errors are resources/js/field.js keeps the property in step, on load and after every morph. Errors are
read from the bag under the `wire:model` name. Every other attribute reaches the `<input>` read from the bag under the `wire:model` name. Every other attribute reaches the `<input>`.
(resources/css/components/selection.css). The drawing is resources/css/components/checkbox.css, on the row the three selection controls
share (resources/css/components/selection.css).
Without a label a "select all" in a table header, a row's tick — the row is only the 18px box, Without a label a "select all" in a table header, a row's tick — the row is only the 18px box,
so the box carries `touch-target` and catches presses over M3's 48px minimum. so the box carries `md-touch-target` and catches presses over M3's 48px minimum.
Grouping is the caller's, not this component's: M3 asks that from `expanded` (840px) a set of Grouping is the caller's, not this component's: M3 asks that from `expanded` (840px) a set of
related checkboxes be gathered into a contained region rather than left as one long column related checkboxes be gathered into a contained region rather than left as one long column
(docs/reference/m3/components-navigation-selection-inputs.md § Checkbox, Behaviour). Lay the (docs/reference/m3/components-navigation-selection-inputs.md § Checkbox, Behaviour). Lay the
group out yourself `<div class="grid gap-4 expanded:grid-cols-2">` around the checkboxes, or a group out yourself a grid of one column that takes two from `expanded`, or an `<x-card>` or
`<x-card>` or side sheet holding them and give the group a heading that names what it asks. --}} side sheet holding them and give the group a heading that names what it asks. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -33,42 +34,34 @@
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : []; $messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
@endphp @endphp
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}> <div data-md-checkbox {{ $attributes->only(['class', 'style', 'wire:key']) }}>
<label for="{{ $id }}" data-selection @if ($messages !== []) data-invalid @endif @class([ <label for="{{ $id }}" data-md-selection-row @if ($right) data-md-right @endif @if ($messages !== []) data-md-invalid @endif>
'flex gap-4', <span data-md-checkbox-box @if (blank($label) && blank($hint)) class="md-touch-target" @endif>
'items-start' => filled($hint),
'items-center' => blank($hint),
'flex-row-reverse justify-between' => $right,
])>
<span data-checkbox @class([
'mt-[3px]' => filled($hint),
'touch-target' => blank($label) && blank($hint),
])>
<input <input
{{ $attributes->except(['class', 'id', 'wire:key']) }} {{ $attributes->except(['class', 'style', 'id', 'wire:key']) }}
id="{{ $id }}" id="{{ $id }}"
type="checkbox" type="checkbox"
@if ($indeterminate) data-indeterminate @endif @if ($indeterminate) data-md-indeterminate @endif
@if ($messages !== []) aria-invalid="true" aria-describedby="{{ $id }}-support" @endif @if ($messages !== []) aria-invalid="true" aria-describedby="{{ $id }}-support" @endif
/> />
<x-livewire-material::icon name="check" class="size-4.5" optical="20" data-check /> <x-livewire-material::icon name="check" size="18" data-md-checkbox-check />
<x-livewire-material::icon name="remove" class="size-4.5" optical="20" data-mixed /> <x-livewire-material::icon name="remove" size="18" data-md-checkbox-mixed />
</span> </span>
@if (filled($label) || filled($hint)) @if (filled($label) || filled($hint))
<span class="min-w-0"> <span data-md-selection-text>
@if (filled($label)) @if (filled($label))
<span class="block type-body-lg text-on-surface" data-selection-label>{{ $label }}</span> <span data-md-selection-label>{{ $label }}</span>
@endif @endif
@if (filled($hint)) @if (filled($hint))
<span class="mt-0.5 block type-body-sm text-on-surface-variant">{{ $hint }}</span> <span data-md-selection-hint>{{ $hint }}</span>
@endif @endif
</span> </span>
@endif @endif
</label> </label>
@if ($messages !== []) @if ($messages !== [])
<div id="{{ $id }}-support" class="mt-1 type-body-sm text-error" role="alert"> <div id="{{ $id }}-support" data-md-selection-support role="alert">
@foreach ($messages as $message) @foreach ($messages as $message)
<p>{{ $message }}</p> <p>{{ $message }}</p>
@endforeach @endforeach
+5 -5
View File
@@ -7,7 +7,7 @@
(600px, M3's compact/medium boundary — compact always stacks), and (600px, M3's compact/medium boundary — compact always stacks), and
`value` for the option chosen when the page loads (with `wire:model`, Livewire sets it). `value` for the option chosen when the page loads (with `wire:model`, Livewire sets it).
Errors are read from the bag under the `wire:model` name Errors are read from the bag under the `wire:model` name
(resources/css/components/selection.css). (resources/css/components/radio.css).
M3 stacks radio buttons vertically and cautions against a row at any width, so `inline` is for M3 stacks radio buttons vertically and cautions against a row at any width, so `inline` is for
a group of two or three short labels that would waste a column, not the shape to reach for. a group of two or three short labels that would waste a column, not the shape to reach for.
@@ -46,12 +46,12 @@
@foreach ($options as $option) @foreach ($options as $option)
@php($optionHintText = data_get($option, $optionHint)) @php($optionHintText = data_get($option, $optionHint))
<label data-selection @if ($messages !== []) data-invalid @endif @class([ <label data-md-selection-row @if ($messages !== []) data-md-invalid @endif @class([
'flex gap-4', 'flex gap-4',
'items-start' => filled($optionHintText), 'items-start' => filled($optionHintText),
'items-center' => blank($optionHintText), 'items-center' => blank($optionHintText),
])> ])>
<span data-radio @class([ <span data-md-radio-button @class([
'mt-0.5' => filled($optionHintText), 'mt-0.5' => filled($optionHintText),
'touch-target' => blank(data_get($option, $optionLabel)) && blank($optionHintText), 'touch-target' => blank(data_get($option, $optionLabel)) && blank($optionHintText),
])> ])>
@@ -64,11 +64,11 @@
@checked($value !== null && (string) $value === (string) data_get($option, $optionValue)) @checked($value !== null && (string) $value === (string) data_get($option, $optionValue))
@if ($messages !== []) aria-invalid="true" @endif @if ($messages !== []) aria-invalid="true" @endif
/> />
<span data-dot></span> <span data-md-radio-dot></span>
</span> </span>
<span class="min-w-0"> <span class="min-w-0">
<span class="block type-body-lg text-on-surface" data-selection-label>{{ data_get($option, $optionLabel) }}</span> <span class="block type-body-lg text-on-surface" data-md-selection-label>{{ data_get($option, $optionLabel) }}</span>
@if (filled($optionHintText)) @if (filled($optionHintText))
<span class="mt-0.5 block type-body-sm text-on-surface-variant">{{ $optionHintText }}</span> <span class="mt-0.5 block type-body-sm text-on-surface-variant">{{ $optionHintText }}</span>
@endif @endif
+7 -7
View File
@@ -5,7 +5,7 @@
its name — a switch without `label` needs an `aria-label`. `right` puts it at the end of the its name — a switch without `label` needs an `aria-label`. `right` puts it at the end of the
row, where a setting's switch usually is. `icons` draws a check on the handle when on and a row, where a setting's switch usually is. `icons` draws a check on the handle when on and a
cross when off; `icons="selected"` only the check. Every other attribute reaches the `<input>` cross when off; `icons="selected"` only the check. Every other attribute reaches the `<input>`
(resources/css/components/selection.css). (resources/css/components/toggle.css).
Without a label the row is only the 52×32 track, so the track carries `touch-target` and catches Without a label the row is only the 52×32 track, so the track carries `touch-target` and catches
presses over M3's 48px minimum. --}} presses over M3's 48px minimum. --}}
@@ -24,23 +24,23 @@
@endphp @endphp
<div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}> <div {{ $attributes->only(['class', 'wire:key'])->class(['min-w-0']) }}>
<label for="{{ $id }}" data-selection @class([ <label for="{{ $id }}" data-md-selection-row @class([
'flex gap-4', 'flex gap-4',
'items-start' => filled($hint), 'items-start' => filled($hint),
'items-center' => blank($hint), 'items-center' => blank($hint),
'flex-row-reverse justify-between' => $right, 'flex-row-reverse justify-between' => $right,
])> ])>
<span data-switch @if ($icons) data-icons="{{ $icons }}" @endif @class(['touch-target' => blank($label) && blank($hint)])> <span data-md-switch @if ($icons) data-md-icons="{{ $icons }}" @endif @class(['touch-target' => blank($label) && blank($hint)])>
<input <input
{{ $attributes->except(['class', 'id', 'wire:key']) }} {{ $attributes->except(['class', 'id', 'wire:key']) }}
id="{{ $id }}" id="{{ $id }}"
type="checkbox" type="checkbox"
role="switch" role="switch"
/> />
<span data-handle> <span data-md-switch-handle>
@if ($icons) @if ($icons)
<x-livewire-material::icon name="check" class="size-4" optical="20" data-on /> <x-livewire-material::icon name="check" class="size-4" optical="20" data-md-switch-on />
<x-livewire-material::icon name="close" class="size-4" optical="20" data-off /> <x-livewire-material::icon name="close" class="size-4" optical="20" data-md-switch-off />
@endif @endif
</span> </span>
</span> </span>
@@ -48,7 +48,7 @@
@if (filled($label) || filled($hint)) @if (filled($label) || filled($hint))
<span class="min-w-0"> <span class="min-w-0">
@if (filled($label)) @if (filled($label))
<span class="block type-body-lg text-on-surface" data-selection-label>{{ $label }}</span> <span class="block type-body-lg text-on-surface" data-md-selection-label>{{ $label }}</span>
@endif @endif
@if (filled($hint)) @if (filled($hint))
<span class="mt-0.5 block type-body-sm text-on-surface-variant">{{ $hint }}</span> <span class="mt-0.5 block type-body-sm text-on-surface-variant">{{ $hint }}</span>
+25 -2
View File
@@ -51,6 +51,11 @@ class FieldProbe extends Component
<x-input id="off-field" label="Off" value="Not editable" disabled /> <x-input id="off-field" label="Off" value="Not editable" disabled />
<x-input id="plain-name-field" label="Name, plain" wire:model="name" /> <x-input id="plain-name-field" label="Name, plain" wire:model="name" />
<x-file id="upload-field" label="Upload" /> <x-file id="upload-field" label="Upload" />
<div style="display: flex; gap: 48px; padding: 24px">
<x-checkbox id="bare-check" aria-label="Select every row" />
<x-toggle id="bare-switch" aria-label="Bare switch" />
<x-radio name="bare-radio" :options="[['id' => 'only', 'name' => '']]" />
</div>
<x-button label="Save" wire:click="save" /> <x-button label="Save" wire:click="save" />
</div> </div>
@@ -146,12 +151,12 @@ it('keeps a partly ticked checkbox in step with the server', function () {
$page->click('label[for="file-a"]') $page->click('label[for="file-a"]')
->assertSeeIn('#files', 'a') ->assertSeeIn('#files', 'a')
->assertScript("{$all}.hasAttribute('data-indeterminate') && {$all}.indeterminate"); ->assertScript("{$all}.hasAttribute('data-md-indeterminate') && {$all}.indeterminate");
$page->click('label[for="file-b"]') $page->click('label[for="file-b"]')
->click('label[for="file-c"]') ->click('label[for="file-c"]')
->assertSeeIn('#files', 'a,b,c') ->assertSeeIn('#files', 'a,b,c')
->assertScript("! {$all}.hasAttribute('data-indeterminate') && ! {$all}.indeterminate"); ->assertScript("! {$all}.hasAttribute('data-md-indeterminate') && ! {$all}.indeterminate");
}); });
it('moves between radio buttons with the arrow keys', function () { it('moves between radio buttons with the arrow keys', function () {
@@ -289,3 +294,21 @@ it('draws the field at M3\'s geometry and the file picker\'s button as a tonal p
->assertScript("getComputedStyle(document.querySelector('#upload-field'), '::file-selector-button').borderTopLeftRadius !== '0px'") ->assertScript("getComputedStyle(document.querySelector('#upload-field'), '::file-selector-button').borderTopLeftRadius !== '0px'")
->assertScript("getComputedStyle(document.querySelector('#upload-field')).color === 'rgba(0, 0, 0, 0)'"); ->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'));
});
+42 -24
View File
@@ -1,38 +1,40 @@
<?php <?php
it('draws a checkbox whose row is its label', function () { it('draws a checkbox whose row is its label', function () {
$html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" />'); $html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" style="order: 2" />');
expect($html) expect($html)
->toContain('<div class="min-w-0 mt-2">') ->toContain('<div data-md-checkbox class="mt-2" style="order: 2">')
->toContain('<label for="notify" data-selection') ->toMatch('/<label for="notify" data-md-selection-row\s*>/')
->toContain('data-checkbox') ->toContain('<span data-md-checkbox-box >')
->toContain('type="checkbox"') ->toContain('type="checkbox"')
->toContain('wire:model="notify"') ->toContain('wire:model="notify"')
->toContain('data-check') ->toContain('data-md-checkbox-check')
->toContain('data-mixed') ->toContain('data-md-checkbox-mixed')
->toContain('Notify me') ->toContain('<span data-md-selection-label>Notify me</span>')
->toContain('On every download') ->toContain('<span data-md-selection-hint>On every download</span>')
->not->toContain('data-indeterminate'); ->not->toContain('data-md-indeterminate')
->not->toContain('min-w-0');
}); });
it('fills the box with an 18px tick from the 20 optical cut', function () { it('fills the box with an 18px tick from the 20 optical cut', function () {
expect((string) $this->blade('<x-checkbox label="Notify me" />')) expect((string) $this->blade('<x-checkbox label="Notify me" />'))
->toContain((string) $this->blade('<x-icon name="check" class="size-4.5" optical="20" data-check />')) ->toContain((string) $this->blade('<x-icon name="check" size="18" data-md-checkbox-check />'))
->toContain((string) $this->blade('<x-icon name="remove" class="size-4.5" optical="20" data-mixed />')); ->toContain((string) $this->blade('<x-icon name="remove" size="18" data-md-checkbox-mixed />'))
->toContain('--md-icon-size: 18px');
}); });
it('marks a checkbox that is partly ticked', function () { it('marks a checkbox that is partly ticked', function () {
expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-indeterminate'); expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-md-indeterminate');
}); });
it('gives a selection control without a label a 48px target', function () { it('gives a selection control without a label a 48px target', function () {
expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toContain('touch-target') expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toMatch('/<span data-md-checkbox-box\s+class="md-touch-target"\s*>/')
->and((string) $this->blade('<x-toggle aria-label="Public link" />'))->toContain('touch-target') ->and((string) $this->blade('<x-toggle aria-label="Public link" />'))->toContain('touch-target')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'\']]" />'))->toContain('touch-target'); ->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'\']]" />'))->toContain('touch-target');
// A labelled control is pressed anywhere along its row, so it needs no extra target. // A labelled control is pressed anywhere along its row, so it needs no extra target.
expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('touch-target') expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('md-touch-target')
->and((string) $this->blade('<x-toggle label="Public link" />'))->not->toContain('touch-target') ->and((string) $this->blade('<x-toggle label="Public link" />'))->not->toContain('touch-target')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->not->toContain('touch-target'); ->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->not->toContain('touch-target');
}); });
@@ -52,7 +54,7 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
->toContain('class="grid gap-4 expanded:grid-cols-2"') ->toContain('class="grid gap-4 expanded:grid-cols-2"')
->toContain('Downloads') ->toContain('Downloads')
->toContain('Comments') ->toContain('Comments')
->and(substr_count($html, 'data-checkbox'))->toBe(2); ->and(substr_count($html, 'data-md-checkbox-box'))->toBe(2);
// The rule is guidance, not markup, so the component's header has to carry it. // The rule is guidance, not markup, so the component's header has to carry it.
expect(file_get_contents(__DIR__.'/../../../resources/views/components/checkbox.blade.php')) expect(file_get_contents(__DIR__.'/../../../resources/views/components/checkbox.blade.php'))
@@ -61,7 +63,8 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
}); });
it('puts a checkbox at the end of its row on request', function () { it('puts a checkbox at the end of its row on request', function () {
expect((string) $this->blade('<x-checkbox label="Show" right />'))->toContain('flex-row-reverse justify-between'); expect((string) $this->blade('<x-checkbox label="Show" right />'))->toMatch('/<label for="check-[0-9a-f]{12}" data-md-selection-row\s+data-md-right\s*>/')
->and((string) $this->blade('<x-checkbox label="Show" />'))->not->toContain('data-md-right');
}); });
it('shows a checkbox\'s errors', function () { it('shows a checkbox\'s errors', function () {
@@ -69,7 +72,8 @@ it('shows a checkbox\'s errors', function () {
->blade('<x-checkbox id="terms" label="I accept the terms" wire:model="terms" />'); ->blade('<x-checkbox id="terms" label="I accept the terms" wire:model="terms" />');
expect($html) expect($html)
->toContain('data-invalid') ->toContain('data-md-invalid')
->toContain('<div id="terms-support" data-md-selection-support role="alert">')
->toContain('aria-invalid="true"') ->toContain('aria-invalid="true"')
->toContain('aria-describedby="terms-support"') ->toContain('aria-describedby="terms-support"')
->toContain('Accept the terms to continue.'); ->toContain('Accept the terms to continue.');
@@ -108,7 +112,7 @@ it('names unbound radios after their group and checks the given value', function
it('shows the errors of a radio group', function () { it('shows the errors of a radio group', function () {
expect((string) $this->withViewErrors(['audience' => ['Choose who can open it.']]) expect((string) $this->withViewErrors(['audience' => ['Choose who can open it.']])
->blade('<x-radio wire:model="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />')) ->blade('<x-radio wire:model="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-invalid') ->toContain('data-md-invalid')
->toContain('Choose who can open it.'); ->toContain('Choose who can open it.');
}); });
@@ -116,17 +120,31 @@ it('draws a switch on a checkbox with the switch role', function () {
$html = (string) $this->blade('<x-toggle id="public" label="Public link" wire:model.live="public" right />'); $html = (string) $this->blade('<x-toggle id="public" label="Public link" wire:model.live="public" right />');
expect($html) expect($html)
->toContain('<label for="public" data-selection') ->toContain('<label for="public" data-md-selection-row')
->toContain('data-switch') ->toContain('data-md-switch')
->toContain('role="switch"') ->toContain('role="switch"')
->toContain('type="checkbox"') ->toContain('type="checkbox"')
->toContain('wire:model.live="public"') ->toContain('wire:model.live="public"')
->toContain('data-handle') ->toContain('data-md-switch-handle')
->toContain('flex-row-reverse') ->toContain('flex-row-reverse')
->not->toContain('data-icons'); ->not->toContain('data-md-icons');
}); });
it('puts icons on a switch\'s handle', function () { it('puts icons on a switch\'s handle', function () {
expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-icons="both"')->toContain('data-on')->toContain('data-off') expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-md-icons="both"')->toContain('data-md-switch-on')->toContain('data-md-switch-off')
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-icons="selected"'); ->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="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');
}); });