tests / lint (push) Successful in 1m4s
tests / feature (8.4) (push) Successful in 1m8s
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (chrome, chromium) (push) Successful in 3m6s
tests / browser (firefox, firefox) (push) Successful in 3m45s
tests / browser (safari, webkit) (push) Successful in 5m0s
M3's assist, filter, input and suggestion chips with chip sets; choices as filter chips or a searchable combobox whose list is an anchored popover; and the search bar that opens into a docked or full-screen search view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
522 lines
19 KiB
CSS
522 lines
19 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;
|
|
}
|
|
|
|
/* 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);
|
|
}
|
|
|
|
.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);
|
|
}
|
|
}
|
|
}
|