Files
livewire-material/resources/css/components/field.css
T
2026-09-14 06:15:10 +02:00

536 lines
20 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-trailing what ends the row; .field-error is the error icon M3 asks for as the
* error state's second indicator
* .field-outline the fieldset and its legend (the filled field's indicator line)
* .field-label the visible label
* .field-support 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;
}
/* A combobox's arrow turns over while its list is open, as a select's does. */
.field:has(.field-control[aria-expanded="true"]) .field-arrow {
rotate: 180deg;
}
.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);
}
/* The error's second indicator: M3 asks that the colour change never stand alone. */
.field-error {
color: var(--md-sys-color-error);
}
.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;
}
/* Hover only where a pointer can hover, and never on a disabled field: M3 gives a disabled
control no state layer at all, and the disabled edge below is inherited, so a declaration
here would beat it whatever the selector weighs. */
@media (hover: hover) {
.field:not(:has(.field-control:disabled)) .field-box:hover {
--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);
}
@media (hover: hover) {
.field[data-invalid]:not(:has(.field-control:disabled)) .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);
}
/* `data-readonly` marks a field the caller made read-only — on the field, not read off the
control, because a date picker makes its own input read-only too. Nothing here draws it
differently: M3 says a read-only field keeps "the same visual style as an editable field, but
clearly labeled read-only", and the native `readonly` attribute is what carries that. The hook
stays for an application that wants a mark of its own. */
.field:has(.field-control:disabled) {
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
--field-ink: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
.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);
}
}
}