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
517 lines
18 KiB
CSS
517 lines
18 KiB
CSS
/*
|
|
* 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);
|
|
}
|
|
}
|
|
}
|