Add M3 text fields, selects and selection controls
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m4s
tests / lint (push) Successful in 59s
tests / browser (chrome, chromium) (push) Successful in 2m54s
tests / browser (firefox, firefox) (push) Successful in 3m3s
tests / browser (safari, webkit) (push) Successful in 4m13s
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m4s
tests / lint (push) Successful in 59s
tests / browser (chrome, chromium) (push) Successful in 2m54s
tests / browser (firefox, firefox) (push) Successful in 3m3s
tests / browser (safari, webkit) (push) Successful in 4m13s
Outlined and filled text fields (input, password, auto-growing textarea, native select with the customizable select menu, file), checkbox with an indeterminate state, radio buttons, the M3 switch and form, ported from ReStride and extended. The first half of Phase 6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad724662ca
commit
7f92f1cb29
@@ -0,0 +1,516 @@
|
||||
/*
|
||||
* M3's text fields — the chrome every form control here shares: outlined (the default) and filled.
|
||||
*
|
||||
* The states are selectors rather than classes: whether the label rests in the field or floats is a
|
||||
* question about the control's value and focus, and `:has()` asks it without JavaScript, so it is
|
||||
* right before Alpine starts, in a test, and after a Livewire morph rewrites the value underneath.
|
||||
*
|
||||
* The outlined field's notch is MUI's technique: a `<fieldset>` draws the outline and a hidden
|
||||
* `<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).
|
||||
*
|
||||
* 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-outline the fieldset and its legend (the filled field's indicator line)
|
||||
* .field-label the visible label
|
||||
* .field-support the hint, or the error in its place
|
||||
*
|
||||
* 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;
|
||||
--field-start: var(--field-pad);
|
||||
--field-edge: var(--md-sys-color-outline);
|
||||
--field-ink: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A filled field's resting indicator line is on-surface-variant, where an outline is outline. */
|
||||
.field[data-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;
|
||||
}
|
||||
|
||||
.field[data-size="xs"] {
|
||||
--field-height: 2rem;
|
||||
--field-pad: 0.625rem;
|
||||
--field-icon: 1rem;
|
||||
--field-gap: 0.375rem;
|
||||
}
|
||||
|
||||
.field:has(.field-icon) {
|
||||
--field-start: calc(var(--field-pad) + var(--field-icon) + var(--field-gap));
|
||||
}
|
||||
|
||||
.field-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--field-gap);
|
||||
min-height: var(--field-height);
|
||||
padding-inline: var(--field-pad);
|
||||
border-radius: var(--md-sys-shape-corner-xs);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
background: transparent;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
caret-color: var(--md-sys-color-primary);
|
||||
outline: none;
|
||||
font: var(--md-sys-typescale-body-lg);
|
||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||
}
|
||||
|
||||
.field[data-size="xs"] .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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
-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;
|
||||
}
|
||||
|
||||
.field-arrow {
|
||||
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
.field-icon,
|
||||
.field-trailing {
|
||||
flex: none;
|
||||
width: var(--field-icon);
|
||||
height: var(--field-icon);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.field-icon,
|
||||
.field-arrow {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* A trailing button — clear, copy, reveal — is an icon button: the icon in a 40px state layer. */
|
||||
.field-button {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-inline-end: -0.25rem;
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field-button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: calc(var(--field-icon) + 1rem);
|
||||
height: calc(var(--field-icon) + 1rem);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.field-button:focus-visible::before {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, 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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
font: var(--md-sys-typescale-body-md);
|
||||
}
|
||||
|
||||
.field-outline {
|
||||
position: absolute;
|
||||
inset: -0.3125rem 0 0;
|
||||
margin: 0;
|
||||
padding: 0 calc(var(--field-pad) - 0.25rem);
|
||||
border: 1px solid var(--field-edge);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
min-width: 0;
|
||||
transition: border-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
.field-outline > legend {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: 0.6875rem;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
font: var(--md-sys-typescale-body-sm);
|
||||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||||
transition: max-width var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
.field-outline > legend > span {
|
||||
display: inline-block;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
position: absolute;
|
||||
inset-inline-start: var(--field-pad);
|
||||
top: 0;
|
||||
max-width: calc(100% - 2 * var(--field-pad));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
translate: 0 -50%;
|
||||
color: var(--field-ink);
|
||||
font: var(--md-sys-typescale-body-sm);
|
||||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||||
pointer-events: none;
|
||||
transition-property: top, inset-inline-start, font-size, line-height, color;
|
||||
transition-duration: var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
/* `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: " *";
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
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 {
|
||||
max-width: 0.01px;
|
||||
}
|
||||
|
||||
.field-affix {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.field[data-size="xs"]:not([data-floated]):has(.field-label):has(.field-control:placeholder-shown):not(:focus-within) .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 {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Clearing is offered only once there is something to clear. */
|
||||
.field:has(.field-control:placeholder-shown) .field-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.field-box:hover {
|
||||
--field-edge: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.field-box:focus-within {
|
||||
--field-edge: var(--md-sys-color-primary);
|
||||
--field-ink: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.field-box:focus-within .field-outline {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.field[data-invalid] {
|
||||
--field-edge: var(--md-sys-color-error);
|
||||
--field-ink: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.field[data-invalid] .field-box:hover {
|
||||
--field-edge: var(--md-sys-color-on-error-container);
|
||||
}
|
||||
|
||||
.field[data-invalid] .field-box:focus-within {
|
||||
--field-edge: var(--md-sys-color-error);
|
||||
--field-ink: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
/* Dashed for a field the caller made read-only — marked on the field, not read off the control,
|
||||
because a date picker makes its own input read-only too. */
|
||||
.field[data-readonly] .field-outline {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.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);
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
.field-support {
|
||||
padding: 0.25rem 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 {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
/* ---- The filled text field ---------------------------------------------------------------- */
|
||||
|
||||
.field[data-variant="filled"] .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));
|
||||
}
|
||||
}
|
||||
|
||||
/* The outline becomes the active indicator: one line along the bottom. */
|
||||
.field[data-variant="filled"] .field-outline {
|
||||
inset: auto 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-block-end: 1px solid var(--field-edge);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.field[data-variant="filled"] .field-outline > legend {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.field[data-variant="filled"] .field-box:focus-within .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;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
align-self: stretch;
|
||||
padding-block: 1.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.field[data-variant="filled"]:has(.field-control:disabled) {
|
||||
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
||||
|
||||
.field-box {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 4%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* The exposed dropdown menu — the list a field drops open.
|
||||
*
|
||||
* Two lists wear it, so a form reads as one family:
|
||||
*
|
||||
* - `<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`.
|
||||
*
|
||||
* 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
|
||||
* alone), and the state layer on hover (only where a pointer can hover) and focus.
|
||||
*
|
||||
* Unlayered, as the rest of the package's component CSS, so a utility on the call site cannot half-undo
|
||||
* a row.
|
||||
*
|
||||
* Where the browser has no customizable select, the select keeps its native list, which
|
||||
* `color-scheme` themes. Every rule for the select's own rows is inside `@supports`: a browser that
|
||||
* paints option colours into its native list would otherwise show a half-painted one.
|
||||
*/
|
||||
|
||||
.field-menu {
|
||||
max-block-size: 18rem;
|
||||
overflow-y: auto;
|
||||
padding-block: 0.5rem;
|
||||
border-radius: var(--md-sys-shape-corner-xs);
|
||||
background: var(--md-sys-color-surface-container);
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
}
|
||||
|
||||
.field-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-block-size: 3rem;
|
||||
padding-inline: 1rem;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font: var(--md-sys-typescale-body-lg);
|
||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* `data-active` is the row the arrow keys or the pointer are on. */
|
||||
.field-option[data-active] {
|
||||
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
|
||||
}
|
||||
|
||||
.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] {
|
||||
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
|
||||
}
|
||||
|
||||
.field-check {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.field-option:not([aria-selected="true"]) .field-check {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@supports (appearance: base-select) {
|
||||
select.field-control,
|
||||
select.field-control::picker(select) {
|
||||
appearance: base-select;
|
||||
}
|
||||
|
||||
/* The menu. The browser anchors it to the select; the select covers its whole
|
||||
field (field.css), so the menu is at least the field's width. It hangs under
|
||||
the field and goes above only when it does not fit there — the browser's own
|
||||
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) {
|
||||
position-try-order: normal;
|
||||
max-block-size: 18rem;
|
||||
max-inline-size: calc(100vw - 2rem);
|
||||
margin-block: 0.25rem;
|
||||
padding-block: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: var(--md-sys-shape-corner-xs);
|
||||
background: var(--md-sys-color-surface-container);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
opacity: 0;
|
||||
transform-origin: top;
|
||||
transition-property: opacity, display, overlay;
|
||||
transition-duration: var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-fast);
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
select.field-control:open::picker(select) {
|
||||
opacity: 1;
|
||||
transition-property: opacity, scale, display, overlay;
|
||||
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-spatial-fast);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
select.field-control:open::picker(select) {
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
select.field-control option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-block-size: 3rem;
|
||||
padding-block: 0;
|
||||
padding-inline: 1rem;
|
||||
background: transparent;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font: var(--md-sys-typescale-body-lg);
|
||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
select.field-control option:hover {
|
||||
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
select.field-control option:focus-visible {
|
||||
background: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
|
||||
}
|
||||
|
||||
select.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 {
|
||||
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 {
|
||||
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 10%, var(--md-sys-color-secondary-container));
|
||||
}
|
||||
|
||||
select.field-control option:disabled {
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
content: "";
|
||||
order: 1;
|
||||
flex: none;
|
||||
inline-size: 1.5rem;
|
||||
block-size: 1.5rem;
|
||||
margin-inline-start: auto;
|
||||
background-color: currentColor;
|
||||
mask: url("../../svg/symbols/outlined/check.svg") center / contain no-repeat;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
* M3's selection controls: the checkbox, the radio button and the switch (CheckboxTokens,
|
||||
* RadioButtonTokens, SwitchTokens, androidx Compose Material 3, Apache-2.0).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* [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)
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@layer components {
|
||||
[data-selection] {
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
[data-selection]: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-checkbox],
|
||||
[data-radio] {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
[data-checkbox] > *,
|
||||
[data-radio] > * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
[data-checkbox]::before,
|
||||
[data-radio]::before,
|
||||
[data-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 {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
cursor: inherit;
|
||||
outline: none;
|
||||
transition-property: background-color, border-color;
|
||||
transition-duration: var(--md-sys-motion-effects-fast-duration);
|
||||
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) {
|
||||
--selection-ink: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
[data-selection][data-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-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-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);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
@import './tokens/state.css';
|
||||
@import './components/groups.css';
|
||||
@import './components/list.css';
|
||||
@import './components/field.css';
|
||||
@import './components/menu.css';
|
||||
@import './components/selection.css';
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
|
||||
Reference in New Issue
Block a user