Draw the text field's chrome without Tailwind
<x-field> renders data-md-field with its props and parts as data-md-* attributes (box, control, label, outline, support, counter, trailing buttons), and field.css moves into material.components on tokens: px geometry, spacing and state tokens, the 600px breakpoint (plan step 36). Its icons take a size prop, 24/20/16 by the field's size. Every control the field wraps now marks itself data-md-field-control, so the inputs, the pickers, choices, field.js, menu.css's select and listbox rules and timepicker.css follow the renamed hooks. Browser tests cover the error icon, the disabled field's hover, the counter and the width bound from 600px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
a6e5a593d5
commit
0341f9d4ca
@@ -629,7 +629,7 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire
|
|||||||
- `counter` (on `<x-input>` and `<x-textarea>`) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops.
|
- `counter` (on `<x-input>` and `<x-textarea>`) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops.
|
||||||
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`.
|
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`.
|
||||||
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
|
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
|
||||||
- `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `class="field-control"`; only for controls the package does not have.
|
- `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `data-md-field-control`; only for controls the package does not have.
|
||||||
|
|
||||||
### `<x-checkbox>`, `<x-radio>`, `<x-toggle>`
|
### `<x-checkbox>`, `<x-radio>`, `<x-toggle>`
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
/* Inputs, selection and data */
|
/* Inputs, selection and data */
|
||||||
@import './components/form.css';
|
@import './components/form.css';
|
||||||
|
@import './components/field.css';
|
||||||
|
|
||||||
/* Containment */
|
/* Containment */
|
||||||
|
|
||||||
|
|||||||
+167
-150
@@ -9,30 +9,44 @@
|
|||||||
* `<legend>`, carrying the label at its floated size, cuts the gap the label sits in, so the gap is
|
* `<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
|
* 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
|
* extra-small top corners and an active indicator line, the label floating inside it
|
||||||
* (FilledTextFieldTokens / OutlinedTextFieldTokens, androidx Compose Material 3, Apache-2.0).
|
* (FilledTextFieldTokens / OutlinedTextFieldTokens, androidx Compose Material 3, Apache-2.0): 56px
|
||||||
|
* tall, 16px padding, 24px icons 16px from the text, the label in body-small once it floats.
|
||||||
|
* The `sm` (40px) and `xs` (32px) sizes are this package's, for an unlabelled field in a toolbar.
|
||||||
|
* Disabled is on-surface at M3's 38% content and 12% container opacities (tokens/state.css); the
|
||||||
|
* filled field's disabled container is on-surface at 4%, FilledTextFieldTokens'
|
||||||
|
* DisabledContainerOpacity, which has no system token.
|
||||||
*
|
*
|
||||||
* Anatomy (resources/views/components/field.blade.php):
|
* Anatomy (resources/views/components/field.blade.php):
|
||||||
*
|
*
|
||||||
* .field the whole thing; `class` from the call site lands here; data-variant, data-size
|
* [data-md-field] the whole thing; `class` from the call site lands here;
|
||||||
* .field-box the 56px row: icon, prefix, control, suffix, trailing
|
* data-md-variant, data-md-size, data-md-invalid, data-md-floated,
|
||||||
* .field-control the <input>, <select> or <textarea>
|
* data-md-mono, data-md-full, data-md-readonly
|
||||||
* .field-trailing what ends the row; .field-error is the error icon M3 asks for as the
|
* [data-md-field-box] the 56px row: icon, prefix, control, suffix, trailing
|
||||||
* error state's second indicator
|
* [data-md-field-icon] the leading icon
|
||||||
* .field-outline the fieldset and its legend (the filled field's indicator line)
|
* [data-md-field-affix] a prefix or suffix beside the value
|
||||||
* .field-label the visible label
|
* [data-md-field-control] the <input>, <select> or <textarea>
|
||||||
* .field-support-row the row under the field
|
* [data-md-field-trailing] what ends the row; [data-md-field-error] is the error icon M3 asks
|
||||||
* .field-support the hint, or the error in its place
|
* for as the error state's second indicator; [data-md-field-button]
|
||||||
* .field-counter M3's character counter, `n/max`, at the end of that row
|
* a trailing icon button (clear, copy, reveal, open a picker)
|
||||||
|
* [data-md-field-outline] the fieldset and its legend (the filled field's indicator line)
|
||||||
|
* [data-md-field-label] the visible label
|
||||||
|
* [data-md-field-support-row] the row under the field
|
||||||
|
* [data-md-field-support] the hint, or the error in its place
|
||||||
|
* [data-md-field-counter] M3's character counter, `n/max`, at the end of that row
|
||||||
*
|
*
|
||||||
* A select's open list is not part of the field: it is the dropdown menu in components/menu.css.
|
* A select's open list is not part of the field: it is the dropdown menu in components/menu.css.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@layer components {
|
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||||
.field {
|
|
||||||
--field-height: 3.5rem;
|
@import './icon.css';
|
||||||
--field-pad: 1rem;
|
|
||||||
--field-icon: 1.5rem;
|
@layer material.components {
|
||||||
--field-gap: 1rem;
|
[data-md-field] {
|
||||||
|
--field-height: 56px;
|
||||||
|
--field-pad: var(--md-sys-measurement-space200);
|
||||||
|
--field-icon: 24px;
|
||||||
|
--field-gap: var(--md-sys-measurement-space200);
|
||||||
--field-start: var(--field-pad);
|
--field-start: var(--field-pad);
|
||||||
--field-edge: var(--md-sys-color-outline);
|
--field-edge: var(--md-sys-color-outline);
|
||||||
--field-ink: var(--md-sys-color-on-surface-variant);
|
--field-ink: var(--md-sys-color-on-surface-variant);
|
||||||
@@ -45,41 +59,42 @@
|
|||||||
/* M3 § Text Fields, Behaviour: "compact breakpoints can let a text field span full width;
|
/* M3 § Text Fields, Behaviour: "compact breakpoints can let a text field span full width;
|
||||||
* medium/expanded should bound it with flexible margins/other containers — never let it span the
|
* medium/expanded should bound it with flexible margins/other containers — never let it span the
|
||||||
* full width of a large screen." The site names no number, so 40rem is this package's: it is
|
* full width of a large screen." The site names no number, so 40rem is this package's: it is
|
||||||
* under half the `large` class's 1200px, a little over the `expanded` class's half, and holds
|
* under half the `large` breakpoint's 1200px, a little over half the `expanded` one's, and holds
|
||||||
* about the 70 characters body-large reads best at. It is a ceiling, not a width — a narrower
|
* about the 70 characters body-large reads best at — a measure of text, so it is in rem and grows
|
||||||
* pane still gets a narrower field. A `max-w-*` class from the call site beats it, because
|
* with the text. It is a ceiling, not a width — a narrower pane still gets a narrower field. A
|
||||||
* utilities come after this layer, and `full` takes it off for a field that really is the width
|
* width rule from the call site beats it, because an application's CSS and a utility both
|
||||||
* of its pane (a search-and-filter row, an editor). Below `medium` nothing is bounded. */
|
* outrank this layer, and `full` takes it off for a field that really is the width of its pane (a
|
||||||
@media (width >= 37.5rem) {
|
* search-and-filter row, an editor). Below `medium` nothing is bounded. */
|
||||||
.field:not([data-full]) {
|
@media (width >= 600px) {
|
||||||
|
[data-md-field]:not([data-md-full]) {
|
||||||
max-width: 40rem;
|
max-width: 40rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A filled field's resting indicator line is on-surface-variant, where an outline is outline. */
|
/* A filled field's resting indicator line is on-surface-variant, where an outline is outline. */
|
||||||
.field[data-variant="filled"] {
|
[data-md-field][data-md-variant='filled'] {
|
||||||
--field-edge: var(--md-sys-color-on-surface-variant);
|
--field-edge: var(--md-sys-color-on-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-size="sm"] {
|
[data-md-field][data-md-size='sm'] {
|
||||||
--field-height: 2.5rem;
|
--field-height: 40px;
|
||||||
--field-pad: 0.75rem;
|
--field-pad: 12px;
|
||||||
--field-icon: 1.25rem;
|
--field-icon: 20px;
|
||||||
--field-gap: 0.5rem;
|
--field-gap: var(--md-sys-measurement-space100);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-size="xs"] {
|
[data-md-field][data-md-size='xs'] {
|
||||||
--field-height: 2rem;
|
--field-height: 32px;
|
||||||
--field-pad: 0.625rem;
|
--field-pad: var(--md-sys-measurement-space125);
|
||||||
--field-icon: 1rem;
|
--field-icon: 16px;
|
||||||
--field-gap: 0.375rem;
|
--field-gap: var(--md-sys-measurement-space75);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field:has(.field-icon) {
|
[data-md-field]:has([data-md-field-icon]) {
|
||||||
--field-start: calc(var(--field-pad) + var(--field-icon) + var(--field-gap));
|
--field-start: calc(var(--field-pad) + var(--field-icon) + var(--field-gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-box {
|
[data-md-field-box] {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -90,7 +105,7 @@
|
|||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-control {
|
[data-md-field-control] {
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
@@ -102,23 +117,23 @@
|
|||||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-size="xs"] .field-control {
|
[data-md-field][data-md-size='xs'] [data-md-field-control] {
|
||||||
font: var(--md-sys-typescale-body-md);
|
font: var(--md-sys-typescale-body-md);
|
||||||
letter-spacing: var(--md-sys-typescale-body-md-tracking);
|
letter-spacing: var(--md-sys-typescale-body-md-tracking);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-mono] .field-control {
|
[data-md-field][data-md-mono] [data-md-field-control] {
|
||||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-control::placeholder {
|
[data-md-field-control]::placeholder {
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Autofill paints its own background, which on a card is a pale block in the
|
/* 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. */
|
wrong colour. Delaying the transition for a week keeps the field's own. */
|
||||||
.field-control:-webkit-autofill {
|
[data-md-field-control]:-webkit-autofill {
|
||||||
-webkit-text-fill-color: var(--md-sys-color-on-surface);
|
-webkit-text-fill-color: var(--md-sys-color-on-surface);
|
||||||
transition: background-color 604800s, color 604800s;
|
transition: background-color 604800s, color 604800s;
|
||||||
}
|
}
|
||||||
@@ -127,12 +142,12 @@
|
|||||||
textarea reaches to just inside the outline's end and bottom — the grip sits
|
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
|
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. */
|
its padding puts the text back where it was. */
|
||||||
textarea.field-control {
|
textarea[data-md-field-control] {
|
||||||
--field-grip: 0.25rem;
|
--field-grip: var(--md-sys-measurement-space50);
|
||||||
|
|
||||||
/* The first half-line of the top offset is a margin, not padding: text scrolled up in a full
|
/* 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 disappears under the edge instead of running through the label. */
|
||||||
--textarea-clear: 0.5rem;
|
--textarea-clear: var(--md-sys-measurement-space100);
|
||||||
--textarea-pad-top: calc((var(--field-height) - 1.5rem) / 2 - var(--textarea-clear));
|
--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));
|
--textarea-pad-bottom: calc((var(--field-height) - 1.5rem) / 2 - var(--field-grip));
|
||||||
|
|
||||||
@@ -145,7 +160,7 @@
|
|||||||
|
|
||||||
/* `<x-textarea>` grows with what is typed, from `rows` lines up to `max-rows`. Where the browser
|
/* `<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. */
|
cannot size a field to its content, resources/js/field.js sets the height instead. */
|
||||||
textarea.field-control[data-autogrow] {
|
textarea[data-md-field-control][data-md-autogrow] {
|
||||||
field-sizing: content;
|
field-sizing: content;
|
||||||
min-block-size: calc(var(--field-rows, 3) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
|
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));
|
max-block-size: calc(var(--field-max-rows, 1000) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
|
||||||
@@ -156,7 +171,7 @@
|
|||||||
the start, under the arrow to the end — and its own padding puts the value back
|
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
|
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). */
|
the browser anchors to the select, is the field's width (components/menu.css). */
|
||||||
select.field-control {
|
select[data-md-field-control] {
|
||||||
--field-end: calc(var(--field-icon) + var(--field-gap) + var(--field-pad));
|
--field-end: calc(var(--field-icon) + var(--field-gap) + var(--field-pad));
|
||||||
|
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@@ -165,7 +180,7 @@
|
|||||||
padding-inline: var(--field-start) var(--field-end);
|
padding-inline: var(--field-start) var(--field-end);
|
||||||
}
|
}
|
||||||
|
|
||||||
select.field-control:disabled {
|
select[data-md-field-control]:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +190,7 @@
|
|||||||
said from `:open`, because focus inside the list is in the top layer — and
|
said from `:open`, because focus inside the list is in the top layer — and
|
||||||
the arrow turns over, as the searchable choices' does. */
|
the arrow turns over, as the searchable choices' does. */
|
||||||
@supports (appearance: base-select) {
|
@supports (appearance: base-select) {
|
||||||
select.field-control {
|
select[data-md-field-control] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -187,36 +202,36 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
select.field-control::picker-icon {
|
select[data-md-field-control]::picker-icon {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-box:has(select.field-control:open) {
|
[data-md-field-box]:has(select[data-md-field-control]:open) {
|
||||||
--field-edge: var(--md-sys-color-primary);
|
--field-edge: var(--md-sys-color-primary);
|
||||||
--field-ink: var(--md-sys-color-primary);
|
--field-ink: var(--md-sys-color-primary);
|
||||||
|
|
||||||
.field-outline {
|
[data-md-field-outline] {
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field:has(select.field-control:open) .field-arrow {
|
[data-md-field]:has(select[data-md-field-control]:open) [data-md-field-arrow] {
|
||||||
rotate: 180deg;
|
rotate: 180deg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* `<x-file>`: the browser's own "No file chosen" line is transparent (the caller shows what was
|
/* `<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. */
|
chosen), and its button is a tonal pill — the thing to press. */
|
||||||
input[type="file"].field-control {
|
input[type='file'][data-md-field-control] {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="file"].field-control::file-selector-button {
|
input[type='file'][data-md-field-control]::file-selector-button {
|
||||||
height: 2rem;
|
height: 32px;
|
||||||
margin-inline-end: 0.75rem;
|
margin-inline-end: 12px;
|
||||||
padding-inline: 1rem;
|
padding-inline: var(--md-sys-measurement-space200);
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: var(--md-sys-shape-corner-full);
|
border-radius: var(--md-sys-shape-corner-full);
|
||||||
background-color: var(--md-sys-color-secondary-container);
|
background-color: var(--md-sys-color-secondary-container);
|
||||||
@@ -228,28 +243,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
input[type="file"].field-control:hover::file-selector-button {
|
input[type='file'][data-md-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));
|
background-color: color-mix(in srgb, var(--md-sys-color-on-secondary-container) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-sys-color-secondary-container));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="file"].field-control:disabled::file-selector-button {
|
input[type='file'][data-md-field-control]:disabled::file-selector-button {
|
||||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
|
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
|
||||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A combobox's arrow turns over while its list is open, as a select's does. */
|
/* 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 {
|
[data-md-field]:has([data-md-field-control][aria-expanded='true']) [data-md-field-arrow] {
|
||||||
rotate: 180deg;
|
rotate: 180deg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-arrow {
|
[data-md-field-arrow] {
|
||||||
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
transition: rotate var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-icon,
|
[data-md-field-icon],
|
||||||
.field-trailing {
|
[data-md-field-trailing] {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: var(--field-icon);
|
width: var(--field-icon);
|
||||||
height: var(--field-icon);
|
height: var(--field-icon);
|
||||||
@@ -257,69 +272,71 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The error's second indicator: M3 asks that the colour change never stand alone. */
|
/* The error's second indicator: M3 asks that the colour change never stand alone. */
|
||||||
.field-error {
|
[data-md-field-error] {
|
||||||
color: var(--md-sys-color-error);
|
color: var(--md-sys-color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-icon,
|
[data-md-field-icon],
|
||||||
.field-arrow {
|
[data-md-field-arrow] {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A trailing button — clear, copy, reveal — is an icon button: the icon in a 40px state layer. */
|
/* A trailing button — clear, copy, reveal — is an icon button: the icon in a 40px state layer. */
|
||||||
.field-button {
|
[data-md-field-button] {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
margin-inline-end: -0.25rem;
|
margin-inline-end: calc(-1 * var(--md-sys-measurement-space50));
|
||||||
border-radius: var(--md-sys-shape-corner-full);
|
border-radius: var(--md-sys-shape-corner-full);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-button::before {
|
[data-md-field-button]::before {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 50% auto auto 50%;
|
inset: 50% auto auto 50%;
|
||||||
width: calc(var(--field-icon) + 1rem);
|
width: calc(var(--field-icon) + var(--md-sys-measurement-space200));
|
||||||
height: calc(var(--field-icon) + 1rem);
|
height: calc(var(--field-icon) + var(--md-sys-measurement-space200));
|
||||||
translate: -50% -50%;
|
translate: -50% -50%;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.field-button:hover::before {
|
[data-md-field-button]:hover::before {
|
||||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent);
|
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-button:focus-visible::before {
|
[data-md-field-button]:focus-visible::before {
|
||||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
|
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), transparent);
|
||||||
outline: 3px solid var(--md-sys-color-secondary);
|
outline: 3px solid var(--md-sys-color-secondary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-button:active::before {
|
[data-md-field-button]:active::before {
|
||||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent);
|
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-affix {
|
[data-md-field-affix] {
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
font: var(--md-sys-typescale-body-lg);
|
font: var(--md-sys-typescale-body-lg);
|
||||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-size="xs"] .field-affix {
|
[data-md-field][data-md-size='xs'] [data-md-field-affix] {
|
||||||
font: var(--md-sys-typescale-body-md);
|
font: var(--md-sys-typescale-body-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-outline {
|
/* The legend is body-small's 16px line less its 5px above the outline: the fieldset starts
|
||||||
|
5px up so the notch's edge runs through the middle of the floated label. */
|
||||||
|
[data-md-field-outline] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -0.3125rem 0 0;
|
inset: -0.3125rem 0 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 calc(var(--field-pad) - 0.25rem);
|
padding: 0 calc(var(--field-pad) - var(--md-sys-measurement-space50));
|
||||||
border: 1px solid var(--field-edge);
|
border: 1px solid var(--field-edge);
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -327,7 +344,7 @@
|
|||||||
transition: border-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
transition: border-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-outline > legend {
|
[data-md-field-outline] > legend {
|
||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -341,12 +358,12 @@
|
|||||||
transition: max-width var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
transition: max-width var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-outline > legend > span {
|
[data-md-field-outline] > legend > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding-inline: 0.25rem;
|
padding-inline: var(--md-sys-measurement-space50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-label {
|
[data-md-field-label] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline-start: var(--field-pad);
|
inset-inline-start: var(--field-pad);
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -366,47 +383,47 @@
|
|||||||
|
|
||||||
/* `required` is marked from the control itself, in the label and in the notch,
|
/* `required` is marked from the control itself, in the label and in the notch,
|
||||||
so the gap is cut for the asterisk too. */
|
so the gap is cut for the asterisk too. */
|
||||||
.field:has(.field-control:required) .field-label::after,
|
[data-md-field]:has([data-md-field-control]:required) [data-md-field-label]::after,
|
||||||
.field:has(.field-control:required) .field-outline > legend > span::after {
|
[data-md-field]:has([data-md-field-control]:required) [data-md-field-outline] > legend > span::after {
|
||||||
content: " *";
|
content: ' *';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resting: a label, an empty control, and no focus. The label sits where the
|
/* 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
|
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
|
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`. */
|
a date always hold a value, so they mark themselves `data-md-floated`. */
|
||||||
.field:not([data-floated]):has(.field-label):has(.field-control:placeholder-shown):not(:focus-within) {
|
[data-md-field]:not([data-md-floated]):has([data-md-field-label]):has([data-md-field-control]:placeholder-shown):not(:focus-within) {
|
||||||
.field-label {
|
[data-md-field-label] {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
inset-inline-start: var(--field-start);
|
inset-inline-start: var(--field-start);
|
||||||
font: var(--md-sys-typescale-body-lg);
|
font: var(--md-sys-typescale-body-lg);
|
||||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-outline > legend {
|
[data-md-field-outline] > legend {
|
||||||
max-width: 0.01px;
|
max-width: 0.01px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-affix {
|
[data-md-field-affix] {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-size="xs"]:not([data-floated]):has(.field-label):has(.field-control:placeholder-shown):not(:focus-within) .field-label {
|
[data-md-field][data-md-size='xs']:not([data-md-floated]):has([data-md-field-label]):has([data-md-field-control]:placeholder-shown):not(:focus-within) [data-md-field-label] {
|
||||||
font: var(--md-sys-typescale-body-md);
|
font: var(--md-sys-typescale-body-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A textarea's label rests on its first line, not in the middle of the box. */
|
/* 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 {
|
[data-md-field]:not([data-md-floated]):has([data-md-field-label]):has(textarea[data-md-field-control]:placeholder-shown):not(:focus-within) [data-md-field-label] {
|
||||||
top: calc(var(--field-height) / 2);
|
top: calc(var(--field-height) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field:has(.field-label):not(:focus-within) .field-control::placeholder {
|
[data-md-field]:has([data-md-field-label]):not(:focus-within) [data-md-field-control]::placeholder {
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clearing is offered only once there is something to clear. */
|
/* Clearing is offered only once there is something to clear. */
|
||||||
.field:has(.field-control:placeholder-shown) .field-clear {
|
[data-md-field]:has([data-md-field-control]:placeholder-shown) [data-md-field-clear] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,82 +431,82 @@
|
|||||||
control no state layer at all, and the disabled edge below is inherited, so a declaration
|
control no state layer at all, and the disabled edge below is inherited, so a declaration
|
||||||
here would beat it whatever the selector weighs. */
|
here would beat it whatever the selector weighs. */
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.field:not(:has(.field-control:disabled)) .field-box:hover {
|
[data-md-field]:not(:has([data-md-field-control]:disabled)) [data-md-field-box]:hover {
|
||||||
--field-edge: var(--md-sys-color-on-surface);
|
--field-edge: var(--md-sys-color-on-surface);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-box:focus-within {
|
[data-md-field-box]:focus-within {
|
||||||
--field-edge: var(--md-sys-color-primary);
|
--field-edge: var(--md-sys-color-primary);
|
||||||
--field-ink: var(--md-sys-color-primary);
|
--field-ink: var(--md-sys-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-box:focus-within .field-outline {
|
[data-md-field-box]:focus-within [data-md-field-outline] {
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-invalid] {
|
[data-md-field][data-md-invalid] {
|
||||||
--field-edge: var(--md-sys-color-error);
|
--field-edge: var(--md-sys-color-error);
|
||||||
--field-ink: var(--md-sys-color-error);
|
--field-ink: var(--md-sys-color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.field[data-invalid]:not(:has(.field-control:disabled)) .field-box:hover {
|
[data-md-field][data-md-invalid]:not(:has([data-md-field-control]:disabled)) [data-md-field-box]:hover {
|
||||||
--field-edge: var(--md-sys-color-on-error-container);
|
--field-edge: var(--md-sys-color-on-error-container);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-invalid] .field-box:focus-within {
|
[data-md-field][data-md-invalid] [data-md-field-box]:focus-within {
|
||||||
--field-edge: var(--md-sys-color-error);
|
--field-edge: var(--md-sys-color-error);
|
||||||
--field-ink: 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
|
/* `data-md-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
|
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
|
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
|
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. */
|
stays for an application that wants a mark of its own. */
|
||||||
|
|
||||||
.field:has(.field-control:disabled) {
|
[data-md-field]:has([data-md-field-control]:disabled) {
|
||||||
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 12%, transparent);
|
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
|
||||||
--field-ink: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
--field-ink: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
|
||||||
|
|
||||||
.field-box {
|
[data-md-field-box] {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-control,
|
[data-md-field-control],
|
||||||
.field-icon,
|
[data-md-field-icon],
|
||||||
.field-affix,
|
[data-md-field-affix],
|
||||||
.field-trailing {
|
[data-md-field-trailing] {
|
||||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The hint or the error, and — when the field has a maximum — M3's character counter at the far
|
/* The hint or the error, and — when the field has a maximum — M3's character counter at the far
|
||||||
end of the same row (specs: "Padding between supporting text and counter | 16dp", which the
|
end of the same row (specs: "Padding between supporting text and counter | 16dp", which the
|
||||||
two paddings between them already make). */
|
two paddings between them already make). Supporting text sits 4px under the field. */
|
||||||
.field-support-row {
|
[data-md-field-support-row] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-support {
|
[data-md-field-support] {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0.25rem var(--field-pad) 0;
|
padding: var(--md-sys-measurement-space50) var(--field-pad) 0;
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
font: var(--md-sys-typescale-body-sm);
|
font: var(--md-sys-typescale-body-sm);
|
||||||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-invalid] .field-support {
|
[data-md-field][data-md-invalid] [data-md-field-support] {
|
||||||
color: var(--md-sys-color-error);
|
color: var(--md-sys-color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-counter {
|
[data-md-field-counter] {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
padding-block-start: 0.25rem;
|
padding-block-start: var(--md-sys-measurement-space50);
|
||||||
padding-inline-end: var(--field-pad);
|
padding-inline-end: var(--field-pad);
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
font: var(--md-sys-typescale-body-sm);
|
font: var(--md-sys-typescale-body-sm);
|
||||||
@@ -498,26 +515,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Past the maximum the count is an error, and says so in the error colour. */
|
/* Past the maximum the count is an error, and says so in the error colour. */
|
||||||
.field-counter[data-over] {
|
[data-md-field-counter][data-md-over] {
|
||||||
color: var(--md-sys-color-error);
|
color: var(--md-sys-color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- The filled text field ---------------------------------------------------------------- */
|
/* ---- The filled text field ---------------------------------------------------------------- */
|
||||||
|
|
||||||
.field[data-variant="filled"] .field-box {
|
[data-md-field][data-md-variant='filled'] [data-md-field-box] {
|
||||||
border-radius: var(--md-sys-shape-corner-xs) var(--md-sys-shape-corner-xs) 0 0;
|
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);
|
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);
|
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.field[data-variant="filled"] .field-box:hover {
|
[data-md-field][data-md-variant='filled'] [data-md-field-box]:hover {
|
||||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, var(--md-sys-color-surface-container-highest));
|
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-sys-color-surface-container-highest));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The outline becomes the active indicator: one line along the bottom. */
|
/* The outline becomes the active indicator: one line along the bottom. */
|
||||||
.field[data-variant="filled"] .field-outline {
|
[data-md-field][data-md-variant='filled'] [data-md-field-outline] {
|
||||||
inset: auto 0 0;
|
inset: auto 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -525,51 +542,51 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-variant="filled"] .field-outline > legend {
|
[data-md-field][data-md-variant='filled'] [data-md-field-outline] > legend {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-variant="filled"] .field-box:focus-within .field-outline {
|
[data-md-field][data-md-variant='filled'] [data-md-field-box]:focus-within [data-md-field-outline] {
|
||||||
border-block-end-width: 2px;
|
border-block-end-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The label floats inside the box, above the value, and the value sits below it. */
|
/* The label floats inside the box, above the value, and the value sits below it. */
|
||||||
.field[data-variant="filled"] .field-label {
|
[data-md-field][data-md-variant='filled'] [data-md-field-label] {
|
||||||
top: 1rem;
|
top: var(--md-sys-measurement-space200);
|
||||||
inset-inline-start: var(--field-start);
|
inset-inline-start: var(--field-start);
|
||||||
max-width: calc(100% - var(--field-start) - var(--field-pad));
|
max-width: calc(100% - var(--field-start) - var(--field-pad));
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-variant="filled"]:has(.field-label) .field-control {
|
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) [data-md-field-control] {
|
||||||
padding-block: 1.5rem 0.5rem;
|
padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-variant="filled"]:has(.field-label) textarea.field-control {
|
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) textarea[data-md-field-control] {
|
||||||
--textarea-clear: 1.5rem;
|
--textarea-clear: var(--md-sys-measurement-space300);
|
||||||
--textarea-pad-top: 0rem;
|
--textarea-pad-top: 0px;
|
||||||
--textarea-pad-bottom: calc(0.5rem - var(--field-grip));
|
--textarea-pad-bottom: calc(var(--md-sys-measurement-space100) - var(--field-grip));
|
||||||
|
|
||||||
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
|
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. */
|
/* 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 {
|
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) input[type='file'][data-md-field-control] {
|
||||||
padding-block: 1.375rem 0.375rem;
|
padding-block: 22px var(--md-sys-measurement-space75);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-variant="filled"] input[type="file"].field-control::file-selector-button {
|
[data-md-field][data-md-variant='filled'] input[type='file'][data-md-field-control]::file-selector-button {
|
||||||
height: 1.75rem;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-variant="filled"]:has(.field-label) .field-affix {
|
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) [data-md-field-affix] {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
padding-block: 1.5rem 0.5rem;
|
padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field[data-variant="filled"]:has(.field-control:disabled) {
|
[data-md-field][data-md-variant='filled']:has([data-md-field-control]:disabled) {
|
||||||
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
--field-edge: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
|
||||||
|
|
||||||
.field-box {
|
[data-md-field-box] {
|
||||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 4%, transparent);
|
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 4%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* - `<x-select>`'s: the native <select>, opted into the browser's customizable select
|
* - `<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
|
* (`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.
|
* 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`.
|
* - `<x-choices searchable>`'s listbox, which Alpine draws, as `[data-md-field-menu]` and `[data-md-field-option]`.
|
||||||
*
|
*
|
||||||
* M3's menu as its tokens have it: surface-container at elevation 2, extra-small corner, 48px rows in
|
* 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
|
* body-large, the chosen row in secondary-container with a tick at its end (so it is not told by colour
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-menu {
|
[data-md-field-menu] {
|
||||||
max-block-size: 18rem;
|
max-block-size: 18rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-block: 0.5rem;
|
padding-block: 0.5rem;
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
|
|
||||||
/* As a popover (`<x-choices searchable>`), it hangs under its field, as wide, and goes above only
|
/* As a popover (`<x-choices searchable>`), it hangs under its field, as wide, and goes above only
|
||||||
when there is no room below. */
|
when there is no room below. */
|
||||||
.field-menu[popover] {
|
[data-md-field-menu][popover] {
|
||||||
inset: auto;
|
inset: auto;
|
||||||
top: anchor(bottom);
|
top: anchor(bottom);
|
||||||
left: anchor(left);
|
left: anchor(left);
|
||||||
@@ -172,12 +172,12 @@
|
|||||||
position-try-fallbacks: flip-block;
|
position-try-fallbacks: flip-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-option[aria-disabled="true"] {
|
[data-md-field-option][aria-disabled="true"] {
|
||||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-option {
|
[data-md-field-option] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -190,30 +190,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* `data-active` is the row the arrow keys or the pointer are on. */
|
/* `data-active` is the row the arrow keys or the pointer are on. */
|
||||||
.field-option[data-active] {
|
[data-md-field-option][data-active] {
|
||||||
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
|
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-option[aria-selected="true"] {
|
[data-md-field-option][aria-selected="true"] {
|
||||||
background: var(--md-sys-color-secondary-container);
|
background: var(--md-sys-color-secondary-container);
|
||||||
color: var(--md-sys-color-on-secondary-container);
|
color: var(--md-sys-color-on-secondary-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-option[aria-selected="true"][data-active] {
|
[data-md-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));
|
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-check {
|
[data-md-field-check] {
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-option:not([aria-selected="true"]) .field-check {
|
[data-md-field-option]:not([aria-selected="true"]) [data-md-field-check] {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (appearance: base-select) {
|
@supports (appearance: base-select) {
|
||||||
select.field-control,
|
select[data-md-field-control],
|
||||||
select.field-control::picker(select) {
|
select[data-md-field-control]::picker(select) {
|
||||||
appearance: base-select;
|
appearance: base-select;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
order tries the roomier side first, which opened a menu upwards with room to
|
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
|
spare under it. It opens with a fade and a grow on the spatial spring and
|
||||||
closes with a fade, as `<x-menu>` does. */
|
closes with a fade, as `<x-menu>` does. */
|
||||||
select.field-control::picker(select) {
|
select[data-md-field-control]::picker(select) {
|
||||||
position-try-order: normal;
|
position-try-order: normal;
|
||||||
max-block-size: 18rem;
|
max-block-size: 18rem;
|
||||||
max-inline-size: calc(100vw - 2rem);
|
max-inline-size: calc(100vw - 2rem);
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
transition-behavior: allow-discrete;
|
transition-behavior: allow-discrete;
|
||||||
}
|
}
|
||||||
|
|
||||||
select.field-control:open::picker(select) {
|
select[data-md-field-control]:open::picker(select) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition-property: opacity, scale, display, overlay;
|
transition-property: opacity, scale, display, overlay;
|
||||||
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||||||
@@ -250,13 +250,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@starting-style {
|
@starting-style {
|
||||||
select.field-control:open::picker(select) {
|
select[data-md-field-control]:open::picker(select) {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
scale: 0.95;
|
scale: 0.95;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
select.field-control option {
|
select[data-md-field-control] option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -272,31 +272,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
select.field-control option:hover {
|
select[data-md-field-control] option:hover {
|
||||||
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
|
background: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
select.field-control option:focus-visible {
|
select[data-md-field-control] option:focus-visible {
|
||||||
background: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
|
background: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
select.field-control option:checked {
|
select[data-md-field-control] option:checked {
|
||||||
background: var(--md-sys-color-secondary-container);
|
background: var(--md-sys-color-secondary-container);
|
||||||
color: var(--md-sys-color-on-secondary-container);
|
color: var(--md-sys-color-on-secondary-container);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
select.field-control option:checked:hover {
|
select[data-md-field-control] option:checked:hover {
|
||||||
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 8%, var(--md-sys-color-secondary-container));
|
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 {
|
select[data-md-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));
|
background: color-mix(in srgb, var(--md-sys-color-on-secondary-container) 10%, var(--md-sys-color-secondary-container));
|
||||||
}
|
}
|
||||||
|
|
||||||
select.field-control option:disabled {
|
select[data-md-field-control] option:disabled {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -305,7 +305,7 @@
|
|||||||
/* The tick, at the row's end: the browser draws it before the label, and hides
|
/* 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
|
it on every row but the chosen one. The Material Symbol, as a mask, so it
|
||||||
takes the row's ink. */
|
takes the row's ink. */
|
||||||
select.field-control option::checkmark {
|
select[data-md-field-control] option::checkmark {
|
||||||
content: "";
|
content: "";
|
||||||
order: 1;
|
order: 1;
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
[data-timepicker-field] .field-control {
|
[data-timepicker-field] [data-md-field-control] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
caret-color: transparent;
|
caret-color: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
@import './components/menu.css';
|
@import './components/menu.css';
|
||||||
|
|
||||||
/* Inputs, selection and data — leaving in step 36 */
|
/* Inputs, selection and data — leaving in step 36 */
|
||||||
@import './components/field.css';
|
|
||||||
@import './components/selection.css';
|
@import './components/selection.css';
|
||||||
@import './components/search.css';
|
@import './components/search.css';
|
||||||
@import './components/datepicker.css';
|
@import './components/datepicker.css';
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* mark. A person pressing the box clears the property as the browser always does; the mark stays
|
* mark. A person pressing the box clears the property as the browser always does; the mark stays
|
||||||
* until the server decides again.
|
* until the server decides again.
|
||||||
*/
|
*/
|
||||||
const GROWING = 'textarea[data-autogrow]'
|
const GROWING = 'textarea[data-md-autogrow]'
|
||||||
const MIXED = 'input[type="checkbox"][data-indeterminate]'
|
const MIXED = 'input[type="checkbox"][data-indeterminate]'
|
||||||
|
|
||||||
const growsByItself = window.CSS?.supports?.('field-sizing', 'content') ?? false
|
const growsByItself = window.CSS?.supports?.('field-sizing', 'content') ?? false
|
||||||
@@ -76,7 +76,7 @@ new MutationObserver((records) => {
|
|||||||
subtree: true,
|
subtree: true,
|
||||||
childList: true,
|
childList: true,
|
||||||
attributes: true,
|
attributes: true,
|
||||||
attributeFilter: ['data-indeterminate', 'data-autogrow'],
|
attributeFilter: ['data-indeterminate', 'data-md-autogrow'],
|
||||||
})
|
})
|
||||||
|
|
||||||
markAll(document)
|
markAll(document)
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
@if ($messages !== []) aria-invalid="true" @endif
|
@if ($messages !== []) aria-invalid="true" @endif
|
||||||
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
||||||
placeholder="{{ filled($placeholder) ? $placeholder : ' ' }}"
|
placeholder="{{ filled($placeholder) ? $placeholder : ' ' }}"
|
||||||
class="field-control"
|
data-md-field-control
|
||||||
x-model="query"
|
x-model="query"
|
||||||
x-on:focus="show(); $nextTick(() => $el.select())"
|
x-on:focus="show(); $nextTick(() => $el.select())"
|
||||||
x-on:pointerdown="selecting = document.activeElement !== $el"
|
x-on:pointerdown="selecting = document.activeElement !== $el"
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<x-slot:trailing>
|
<x-slot:trailing>
|
||||||
<x-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
|
<x-livewire-material::icon name="arrow_drop_down" data-md-field-trailing data-md-field-arrow />
|
||||||
</x-slot:trailing>
|
</x-slot:trailing>
|
||||||
</x-livewire-material::field>
|
</x-livewire-material::field>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
x-ref="list"
|
x-ref="list"
|
||||||
@if (filled($label)) aria-label="{{ $label }}" @endif
|
@if (filled($label)) aria-label="{{ $label }}" @endif
|
||||||
style="position-anchor: {{ $anchor }}"
|
style="position-anchor: {{ $anchor }}"
|
||||||
class="field-menu"
|
data-md-field-menu
|
||||||
>
|
>
|
||||||
<template x-for="(option, index) in filtered" :key="option.value">
|
<template x-for="(option, index) in filtered" :key="option.value">
|
||||||
<li
|
<li
|
||||||
@@ -181,10 +181,10 @@
|
|||||||
x-bind:data-active="index === active ? '' : null"
|
x-bind:data-active="index === active ? '' : null"
|
||||||
x-on:mousedown.prevent="choose(option)"
|
x-on:mousedown.prevent="choose(option)"
|
||||||
x-on:mousemove="active = index"
|
x-on:mousemove="active = index"
|
||||||
class="field-option"
|
data-md-field-option
|
||||||
>
|
>
|
||||||
<span x-text="option.label"></span>
|
<span x-text="option.label"></span>
|
||||||
<x-livewire-material::icon name="check" class="field-check size-6" />
|
<x-livewire-material::icon name="check" data-md-field-check />
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
<li x-show="filtered.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</li>
|
<li x-show="filtered.length === 0" class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</li>
|
||||||
|
|||||||
@@ -142,6 +142,8 @@
|
|||||||
'invalidRange' => __('Invalid date range input'),
|
'invalidRange' => __('Invalid date range input'),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -162,9 +164,9 @@
|
|||||||
:$icon
|
:$icon
|
||||||
:$size
|
:$size
|
||||||
:$variant
|
:$variant
|
||||||
:data-invalid="$invalid ? '' : null"
|
:data-md-invalid="$invalid ? '' : null"
|
||||||
:data-readonly="$attributes->get('readonly') ? '' : null"
|
:data-md-readonly="$attributes->get('readonly') ? '' : null"
|
||||||
x-bind:data-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
|
x-bind:data-md-invalid="(fieldError !== '' || {{ $invalid ? 'true' : 'false' }}) ? '' : null"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }}
|
{{ $attributes->whereDoesntStartWith('wire:model')->except(['class', 'id', 'wire:key', 'x-model', 'name', 'value', 'placeholder', 'type']) }}
|
||||||
@@ -182,7 +184,7 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
value="{{ $display }}"
|
value="{{ $display }}"
|
||||||
placeholder=" "
|
placeholder=" "
|
||||||
class="field-control"
|
data-md-field-control
|
||||||
x-model="text"
|
x-model="text"
|
||||||
@if ($mode === 'docked')
|
@if ($mode === 'docked')
|
||||||
x-bind:placeholder="placeholder"
|
x-bind:placeholder="placeholder"
|
||||||
@@ -205,19 +207,21 @@
|
|||||||
@if ($clearable)
|
@if ($clearable)
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="field-trailing field-clear field-button"
|
|
||||||
aria-label="{{ __('Clear') }}"
|
aria-label="{{ __('Clear') }}"
|
||||||
x-on:click="clear()"
|
x-on:click="clear()"
|
||||||
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
|
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
|
||||||
data-field-clear
|
data-md-field-trailing
|
||||||
|
data-md-field-button
|
||||||
|
data-md-field-clear
|
||||||
>
|
>
|
||||||
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
|
<x-livewire-material::icon name="close" :size="$fieldIconSize" />
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="field-trailing field-button"
|
data-md-field-trailing
|
||||||
|
data-md-field-button
|
||||||
aria-label="{{ $title }}"
|
aria-label="{{ $title }}"
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
aria-controls="{{ $id }}-picker"
|
aria-controls="{{ $id }}-picker"
|
||||||
@@ -227,7 +231,7 @@
|
|||||||
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
|
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
|
||||||
data-datepicker-toggle
|
data-datepicker-toggle
|
||||||
>
|
>
|
||||||
<x-livewire-material::icon name="calendar_today" class="size-(--field-icon)" />
|
<x-livewire-material::icon name="calendar_today" :size="$fieldIconSize" />
|
||||||
</button>
|
</button>
|
||||||
</x-slot:trailing>
|
</x-slot:trailing>
|
||||||
</x-livewire-material::field>
|
</x-livewire-material::field>
|
||||||
@@ -462,7 +466,7 @@
|
|||||||
|
|
||||||
<div x-show="typing" data-datepicker-entry>
|
<div x-show="typing" data-datepicker-entry>
|
||||||
<div @if ($range) data-range @endif data-datepicker-entry-fields>
|
<div @if ($range) data-range @endif data-datepicker-entry-fields>
|
||||||
<x-livewire-material::field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
|
<x-livewire-material::field id="{{ $id }}-entry" :label="$range ? __('Start date') : __('Date')" :$variant x-bind:data-md-invalid="entryError !== '' ? '' : null">
|
||||||
<input
|
<input
|
||||||
id="{{ $id }}-entry"
|
id="{{ $id }}-entry"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -475,12 +479,12 @@
|
|||||||
x-model="entry"
|
x-model="entry"
|
||||||
x-on:input="typeEntry()"
|
x-on:input="typeEntry()"
|
||||||
x-on:keydown.enter.prevent="confirm()"
|
x-on:keydown.enter.prevent="confirm()"
|
||||||
class="field-control"
|
data-md-field-control
|
||||||
/>
|
/>
|
||||||
</x-livewire-material::field>
|
</x-livewire-material::field>
|
||||||
|
|
||||||
@if ($range)
|
@if ($range)
|
||||||
<x-livewire-material::field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-invalid="entryError !== '' ? '' : null">
|
<x-livewire-material::field id="{{ $id }}-entry-end" :label="__('End date')" :$variant x-bind:data-md-invalid="entryError !== '' ? '' : null">
|
||||||
<input
|
<input
|
||||||
id="{{ $id }}-entry-end"
|
id="{{ $id }}-entry-end"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -492,7 +496,7 @@
|
|||||||
x-model="entryEnd"
|
x-model="entryEnd"
|
||||||
x-on:input="typeEntry()"
|
x-on:input="typeEntry()"
|
||||||
x-on:keydown.enter.prevent="confirm()"
|
x-on:keydown.enter.prevent="confirm()"
|
||||||
class="field-control"
|
data-md-field-control
|
||||||
/>
|
/>
|
||||||
</x-livewire-material::field>
|
</x-livewire-material::field>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{{-- The chrome of M3's text fields, around whichever control is in the slot.
|
{{-- The chrome of M3's text fields, around whichever control is in the slot.
|
||||||
|
|
||||||
Not used at call sites: `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
Not used at call sites: `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||||||
and the pickers wrap their control in it, so a form reads as one family. The states — the
|
and the pickers wrap their control in it, so a form reads as one family. A custom control the
|
||||||
label resting or floating, the notch, focus, disabled, required — are selectors in
|
package does not have goes in the slot marked `data-md-field-control`. The states — the label
|
||||||
|
resting or floating, the notch, focus, disabled, required — are selectors in
|
||||||
resources/css/components/field.css, which explains the anatomy; the only state this file
|
resources/css/components/field.css, which explains the anatomy; the only state this file
|
||||||
decides is `data-invalid`, because only the server knows that.
|
decides is `data-md-invalid`, because only the server knows that.
|
||||||
|
|
||||||
`variant` is M3's two: `outlined` (a notched outline) and `filled` (a surface-container-highest
|
`variant` is M3's two: `outlined` (a notched outline) and `filled` (a surface-container-highest
|
||||||
box with an indicator line); without it, `config('livewire-material.fields.variant')`. A field
|
box with an indicator line); without it, `config('livewire-material.fields.variant')`. A field
|
||||||
@@ -12,7 +13,8 @@
|
|||||||
editor row, and is given `size="sm"` (40px, a button's height) or `size="xs"` (32px). The
|
editor row, and is given `size="sm"` (40px, a button's height) or `size="xs"` (32px). The
|
||||||
error replaces the hint rather than stacking under it, as M3 has it, and an `error` icon joins it
|
error replaces the hint rather than stacking under it, as M3 has it, and an `error` icon joins it
|
||||||
at the end of the row so the state has two indicators and not only a colour — unless the caller
|
at the end of the row so the state has two indicators and not only a colour — unless the caller
|
||||||
trails the field with something of its own, or the field is `xs` and has no room.
|
trails the field with something of its own, or the field is `xs` and has no room. The field's
|
||||||
|
icons are 24px, 20px in an `sm` field and 16px in an `xs` one.
|
||||||
|
|
||||||
`counter` is the maximum number of characters, and puts M3's character counter at the end of the
|
`counter` is the maximum number of characters, and puts M3's character counter at the end of the
|
||||||
supporting-text row: `n/max`, counted from the control on every `input`, in the error colour once
|
supporting-text row: `n/max`, counted from the control on every `input`, in the error colour once
|
||||||
@@ -21,12 +23,12 @@
|
|||||||
"Character count, 5/20", M3's own label, from a polite region a second after typing stops rather
|
"Character count, 5/20", M3's own label, from a polite region a second after typing stops rather
|
||||||
than on every keystroke, which would talk over the typing.
|
than on every keystroke, which would talk over the typing.
|
||||||
|
|
||||||
From `medium` the field is no wider than 40rem, because M3 asks that a text field never span
|
From `medium` (600px) the field is no wider than 40rem, because M3 asks that a text field never
|
||||||
the full width of a large screen (§ Text Fields, Behaviour); a `max-w-*` class from the call
|
span the full width of a large screen (§ Text Fields, Behaviour); a width rule from the call site
|
||||||
site beats that, and `full` takes it off altogether.
|
beats that, and `full` takes it off altogether.
|
||||||
|
|
||||||
`class` from the call site lands on the outermost element, never the control, so a margin or
|
`class` from the call site lands on the outermost element, never the control, so a margin or
|
||||||
a width is safe here. --}}
|
a width is safe here; `hint-class` lands on the hint. --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
'id',
|
'id',
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
? $variant
|
? $variant
|
||||||
: (config('livewire-material.fields.variant') === 'filled' ? 'filled' : 'outlined');
|
: (config('livewire-material.fields.variant') === 'filled' ? 'filled' : 'outlined');
|
||||||
$density = in_array($size, ['sm', 'xs'], true) ? $size : 'md';
|
$density = in_array($size, ['sm', 'xs'], true) ? $size : 'md';
|
||||||
|
$iconSize = ['md' => 24, 'sm' => 20, 'xs' => 16][$density];
|
||||||
// M3 pairs the error colours with a trailing error icon so the state has two indicators. It
|
// M3 pairs the error colours with a trailing error icon so the state has two indicators. It
|
||||||
// gives way to whatever the caller trails the field with, and to `xs`, which has no room.
|
// gives way to whatever the caller trails the field with, and to `xs`, which has no room.
|
||||||
$errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs';
|
$errorIcon = $messages !== [] && ! isset($trailing) && $density !== 'xs';
|
||||||
@@ -57,67 +60,67 @@
|
|||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div
|
<div
|
||||||
{{ $attributes->class(['field']) }}
|
data-md-field
|
||||||
data-variant="{{ $variant }}"
|
data-md-variant="{{ $variant }}"
|
||||||
data-size="{{ $density }}"
|
data-md-size="{{ $density }}"
|
||||||
@if ($messages !== []) data-invalid @endif
|
@if ($messages !== []) data-md-invalid @endif
|
||||||
@if ($floated) data-floated @endif
|
@if ($floated) data-md-floated @endif
|
||||||
@if ($mono) data-mono @endif
|
@if ($mono) data-md-mono @endif
|
||||||
@if ($full) data-full @endif
|
@if ($full) data-md-full @endif
|
||||||
|
{{ $attributes }}
|
||||||
>
|
>
|
||||||
<div class="field-box">
|
<div data-md-field-box>
|
||||||
@if ($icon)
|
@if ($icon)
|
||||||
<x-livewire-material::icon :name="$icon" class="field-icon size-(--field-icon)" />
|
<x-livewire-material::icon :name="$icon" :size="$iconSize" data-md-field-icon />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (filled($prefix))
|
@if (filled($prefix))
|
||||||
<span class="field-affix">{{ $prefix }}</span>
|
<span data-md-field-affix>{{ $prefix }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|
||||||
@if (filled($suffix))
|
@if (filled($suffix))
|
||||||
<span class="field-affix">{{ $suffix }}</span>
|
<span data-md-field-affix>{{ $suffix }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{ $trailing ?? '' }}
|
{{ $trailing ?? '' }}
|
||||||
|
|
||||||
@if ($errorIcon)
|
@if ($errorIcon)
|
||||||
<x-livewire-material::icon name="error" class="field-trailing field-error size-(--field-icon)" :label="__('Error')" />
|
<x-livewire-material::icon name="error" :size="$iconSize" data-md-field-trailing data-md-field-error :label="__('Error')" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<fieldset aria-hidden="true" class="field-outline">
|
<fieldset aria-hidden="true" data-md-field-outline>
|
||||||
<legend>@if (filled($label))<span>{{ $label }}</span>@endif</legend>
|
<legend>@if (filled($label))<span>{{ $label }}</span>@endif</legend>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
@if (filled($label))
|
@if (filled($label))
|
||||||
<label for="{{ $id }}" class="field-label">{{ $label }}</label>
|
<label for="{{ $id }}" data-md-field-label>{{ $label }}</label>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($messages !== [] || filled($hint) || $counter !== null)
|
@if ($messages !== [] || filled($hint) || $counter !== null)
|
||||||
<div class="field-support-row">
|
<div data-md-field-support-row>
|
||||||
@if ($messages !== [])
|
@if ($messages !== [])
|
||||||
<div id="{{ $id }}-support" class="field-support" role="alert">
|
<div id="{{ $id }}-support" data-md-field-support role="alert">
|
||||||
@foreach ($messages as $message)
|
@foreach ($messages as $message)
|
||||||
<p>{{ $message }}</p>
|
<p>{{ $message }}</p>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@elseif (filled($hint))
|
@elseif (filled($hint))
|
||||||
<p id="{{ $id }}-support" @class(array_filter(['field-support', $hintClass]))>{{ $hint }}</p>
|
<p id="{{ $id }}-support" data-md-field-support @if (filled($hintClass)) class="{{ $hintClass }}" @endif>{{ $hint }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($counter !== null)
|
@if ($counter !== null)
|
||||||
<span
|
<span
|
||||||
class="field-counter"
|
data-md-field-counter
|
||||||
data-field-counter
|
|
||||||
x-data="{
|
x-data="{
|
||||||
used: 0,
|
used: 0,
|
||||||
max: {{ $counter }},
|
max: {{ $counter }},
|
||||||
spoken: '',
|
spoken: '',
|
||||||
settle: null,
|
settle: null,
|
||||||
init() {
|
init() {
|
||||||
const control = this.$el.closest('.field').querySelector('.field-control');
|
const control = this.$el.closest('[data-md-field]').querySelector('[data-md-field-control]');
|
||||||
|
|
||||||
this.used = control.value.length;
|
this.used = control.value.length;
|
||||||
control.addEventListener('input', () => (this.used = control.value.length));
|
control.addEventListener('input', () => (this.used = control.value.length));
|
||||||
@@ -131,10 +134,10 @@
|
|||||||
clearTimeout(this.settle);
|
clearTimeout(this.settle);
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
x-bind:data-over="used > max"
|
x-bind:data-md-over="used > max"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" x-text="`${used}/${max}`">0/{{ $counter }}</span>
|
<span aria-hidden="true" x-text="`${used}/${max}`">0/{{ $counter }}</span>
|
||||||
<span class="sr-only" aria-live="polite" aria-atomic="true" x-text="spoken"></span>
|
<span class="md-visually-hidden" aria-live="polite" aria-atomic="true" x-text="spoken"></span>
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,6 @@
|
|||||||
id="{{ $id }}"
|
id="{{ $id }}"
|
||||||
@if ($messages !== []) aria-invalid="true" @endif
|
@if ($messages !== []) aria-invalid="true" @endif
|
||||||
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
||||||
class="field-control"
|
data-md-field-control
|
||||||
/>
|
/>
|
||||||
</x-livewire-material::field>
|
</x-livewire-material::field>
|
||||||
|
|||||||
@@ -39,16 +39,17 @@
|
|||||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
|
// M3's counter needs a maximum to count against: without `maxlength` there is nothing to show.
|
||||||
$max = $counter ? $attributes->get('maxlength') : null;
|
$max = $counter ? $attributes->get('maxlength') : null;
|
||||||
|
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :counter="$max" :$full :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$prefix :$suffix :$size :$variant :$mono :counter="$max" :$full :class="$attributes->get('class')" :data-md-readonly="$attributes->get('readonly') ? '' : null">
|
||||||
<input
|
<input
|
||||||
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
|
{{ $attributes->except(['class', 'id', 'placeholder'])->merge(['type' => 'text']) }}
|
||||||
id="{{ $id }}"
|
id="{{ $id }}"
|
||||||
placeholder="{{ $placeholder }}"
|
placeholder="{{ $placeholder }}"
|
||||||
@if ($messages !== []) aria-invalid="true" @endif
|
@if ($messages !== []) aria-invalid="true" @endif
|
||||||
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
||||||
class="field-control"
|
data-md-field-control
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@if ($clearable || $copyable || $iconRight)
|
@if ($clearable || $copyable || $iconRight)
|
||||||
@@ -57,12 +58,13 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-data
|
x-data
|
||||||
x-on:click="const control = $el.closest('.field').querySelector('.field-control'); control.value = ''; control.dispatchEvent(new Event('input', { bubbles: true })); control.dispatchEvent(new Event('change', { bubbles: true })); control.focus();"
|
x-on:click="const control = $el.closest('[data-md-field]').querySelector('[data-md-field-control]'); control.value = ''; control.dispatchEvent(new Event('input', { bubbles: true })); control.dispatchEvent(new Event('change', { bubbles: true })); control.focus();"
|
||||||
class="field-trailing field-clear field-button"
|
|
||||||
aria-label="{{ __('Clear') }}"
|
aria-label="{{ __('Clear') }}"
|
||||||
data-field-clear
|
data-md-field-trailing
|
||||||
|
data-md-field-button
|
||||||
|
data-md-field-clear
|
||||||
>
|
>
|
||||||
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
|
<x-livewire-material::icon name="close" :size="$fieldIconSize" />
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -70,17 +72,18 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-data
|
x-data
|
||||||
x-on:click="navigator.clipboard.writeText($el.closest('.field').querySelector('.field-control').value).then(() => window.materialToast(@js(__('Copied to the clipboard')), { type: 'success' }))"
|
x-on:click="navigator.clipboard.writeText($el.closest('[data-md-field]').querySelector('[data-md-field-control]').value).then(() => window.materialToast(@js(__('Copied to the clipboard')), { type: 'success' }))"
|
||||||
class="field-trailing field-button"
|
|
||||||
aria-label="{{ __('Copy') }}"
|
aria-label="{{ __('Copy') }}"
|
||||||
data-field-copy
|
data-md-field-trailing
|
||||||
|
data-md-field-button
|
||||||
|
data-md-field-copy
|
||||||
>
|
>
|
||||||
<x-livewire-material::icon name="content_copy" class="size-(--field-icon)" />
|
<x-livewire-material::icon name="content_copy" :size="$fieldIconSize" />
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($iconRight)
|
@if ($iconRight)
|
||||||
<x-livewire-material::icon :name="$iconRight" class="field-trailing size-(--field-icon)" />
|
<x-livewire-material::icon :name="$iconRight" :size="$fieldIconSize" data-md-field-trailing />
|
||||||
@endif
|
@endif
|
||||||
</x-slot:trailing>
|
</x-slot:trailing>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
|
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
|
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant :class="$attributes->get('class')" x-data="{ shown: false }">
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
placeholder="{{ filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ' }}"
|
placeholder="{{ filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ' }}"
|
||||||
@if ($messages !== []) aria-invalid="true" @endif
|
@if ($messages !== []) aria-invalid="true" @endif
|
||||||
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
||||||
class="field-control"
|
data-md-field-control
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-slot:trailing>
|
<x-slot:trailing>
|
||||||
@@ -42,11 +43,12 @@
|
|||||||
x-on:click="shown = ! shown"
|
x-on:click="shown = ! shown"
|
||||||
x-bind:aria-label="shown ? @js(__('Hide password')) : @js(__('Show password'))"
|
x-bind:aria-label="shown ? @js(__('Hide password')) : @js(__('Show password'))"
|
||||||
aria-label="{{ __('Show password') }}"
|
aria-label="{{ __('Show password') }}"
|
||||||
class="field-trailing field-button"
|
data-md-field-trailing
|
||||||
data-field-reveal
|
data-md-field-button
|
||||||
|
data-md-field-reveal
|
||||||
>
|
>
|
||||||
<x-livewire-material::icon name="visibility" class="size-(--field-icon)" x-show="! shown" />
|
<x-livewire-material::icon name="visibility" :size="$fieldIconSize" x-show="! shown" />
|
||||||
<x-livewire-material::icon name="visibility_off" class="size-(--field-icon)" x-show="shown" x-cloak />
|
<x-livewire-material::icon name="visibility_off" :size="$fieldIconSize" x-show="shown" x-cloak />
|
||||||
</button>
|
</button>
|
||||||
</x-slot:trailing>
|
</x-slot:trailing>
|
||||||
</x-livewire-material::field>
|
</x-livewire-material::field>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||||
|
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')">
|
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$icon :$size :$variant floated :class="$attributes->get('class')">
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
id="{{ $id }}"
|
id="{{ $id }}"
|
||||||
@if ($messages !== []) aria-invalid="true" @endif
|
@if ($messages !== []) aria-invalid="true" @endif
|
||||||
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
||||||
class="field-control"
|
data-md-field-control
|
||||||
>
|
>
|
||||||
@if (filled($placeholder))
|
@if (filled($placeholder))
|
||||||
<option value="{{ $placeholderValue }}">{{ $placeholder }}</option>
|
<option value="{{ $placeholderValue }}">{{ $placeholder }}</option>
|
||||||
@@ -54,6 +55,6 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<x-slot:trailing>
|
<x-slot:trailing>
|
||||||
<x-livewire-material::icon name="arrow_drop_down" class="field-trailing field-arrow size-(--field-icon)" />
|
<x-livewire-material::icon name="arrow_drop_down" :size="$fieldIconSize" data-md-field-trailing data-md-field-arrow />
|
||||||
</x-slot:trailing>
|
</x-slot:trailing>
|
||||||
</x-livewire-material::field>
|
</x-livewire-material::field>
|
||||||
|
|||||||
@@ -30,18 +30,18 @@
|
|||||||
$max = $counter ? $attributes->get('maxlength') : null;
|
$max = $counter ? $attributes->get('maxlength') : null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :$full :class="$attributes->get('class')" :data-readonly="$attributes->get('readonly') ? '' : null">
|
<x-livewire-material::field :$id :$label :$hint :hint-class="$hintClass" :$messages :$variant :counter="$max" :$full :class="$attributes->get('class')" :data-md-readonly="$attributes->get('readonly') ? '' : null">
|
||||||
<textarea
|
<textarea
|
||||||
{{ $attributes->except(['class', 'id', 'placeholder']) }}
|
{{ $attributes->except(['class', 'id', 'placeholder']) }}
|
||||||
id="{{ $id }}"
|
id="{{ $id }}"
|
||||||
rows="{{ (int) $rows }}"
|
rows="{{ (int) $rows }}"
|
||||||
placeholder="{{ $placeholder }}"
|
placeholder="{{ $placeholder }}"
|
||||||
@if ($autogrow)
|
@if ($autogrow)
|
||||||
data-autogrow
|
data-md-autogrow
|
||||||
style="--field-rows: {{ (int) $rows }};@if ($maxRows) --field-max-rows: {{ (int) $maxRows }};@endif"
|
style="--field-rows: {{ (int) $rows }};@if ($maxRows) --field-max-rows: {{ (int) $maxRows }};@endif"
|
||||||
@endif
|
@endif
|
||||||
@if ($messages !== []) aria-invalid="true" @endif
|
@if ($messages !== []) aria-invalid="true" @endif
|
||||||
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
||||||
class="field-control"
|
data-md-field-control
|
||||||
></textarea>
|
></textarea>
|
||||||
</x-livewire-material::field>
|
</x-livewire-material::field>
|
||||||
|
|||||||
@@ -135,6 +135,7 @@
|
|||||||
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']);
|
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']);
|
||||||
$disabled = (bool) $attributes->get('disabled');
|
$disabled = (bool) $attributes->get('disabled');
|
||||||
$described = $messages !== [] || filled($hint);
|
$described = $messages !== [] || filled($hint);
|
||||||
|
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -159,7 +160,7 @@
|
|||||||
x-bind:aria-expanded="open.toString()"
|
x-bind:aria-expanded="open.toString()"
|
||||||
@if ($messages !== []) aria-invalid="true" @endif
|
@if ($messages !== []) aria-invalid="true" @endif
|
||||||
@if ($described) aria-describedby="{{ $id }}-support" @endif
|
@if ($described) aria-describedby="{{ $id }}-support" @endif
|
||||||
class="field-control"
|
data-md-field-control
|
||||||
x-on:click="show()"
|
x-on:click="show()"
|
||||||
x-on:keydown.enter.prevent="show()"
|
x-on:keydown.enter.prevent="show()"
|
||||||
x-on:keydown.space.prevent="show()"
|
x-on:keydown.space.prevent="show()"
|
||||||
@@ -171,12 +172,13 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
x-on:click="value = null; $refs.input.focus()"
|
x-on:click="value = null; $refs.input.focus()"
|
||||||
class="field-trailing field-clear field-button"
|
data-md-field-trailing
|
||||||
|
data-md-field-button
|
||||||
|
data-md-field-clear
|
||||||
aria-label="{{ __('Clear') }}"
|
aria-label="{{ __('Clear') }}"
|
||||||
data-field-clear
|
|
||||||
@disabled($disabled)
|
@disabled($disabled)
|
||||||
>
|
>
|
||||||
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
|
<x-livewire-material::icon name="close" :size="$fieldIconSize" />
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -184,12 +186,13 @@
|
|||||||
type="button"
|
type="button"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
x-on:click="show()"
|
x-on:click="show()"
|
||||||
class="field-trailing field-button"
|
data-md-field-trailing
|
||||||
|
data-md-field-button
|
||||||
aria-label="{{ __('Choose time') }}"
|
aria-label="{{ __('Choose time') }}"
|
||||||
data-timepicker-open
|
data-timepicker-open
|
||||||
@disabled($disabled)
|
@disabled($disabled)
|
||||||
>
|
>
|
||||||
<x-livewire-material::icon name="schedule" class="size-(--field-icon)" />
|
<x-livewire-material::icon name="schedule" :size="$fieldIconSize" />
|
||||||
</button>
|
</button>
|
||||||
</x-slot:trailing>
|
</x-slot:trailing>
|
||||||
</x-livewire-material::field>
|
</x-livewire-material::field>
|
||||||
|
|||||||
@@ -26,13 +26,13 @@
|
|||||||
<div class="grid content-start gap-4">
|
<div class="grid content-start gap-4">
|
||||||
<x-input label="Email" required />
|
<x-input label="Email" required />
|
||||||
<x-field id="showcase-error" label="Expiry" :messages="['Choose a date in the future.']">
|
<x-field id="showcase-error" label="Expiry" :messages="['Choose a date in the future.']">
|
||||||
<input id="showcase-error" class="field-control" value="2020-01-01" aria-invalid="true" />
|
<input id="showcase-error" data-md-field-control value="2020-01-01" aria-invalid="true" />
|
||||||
</x-field>
|
</x-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid content-start gap-4">
|
<div class="grid content-start gap-4">
|
||||||
<x-field id="showcase-error-filled" variant="filled" label="Expiry" :messages="['Choose a date in the future.']">
|
<x-field id="showcase-error-filled" variant="filled" label="Expiry" :messages="['Choose a date in the future.']">
|
||||||
<input id="showcase-error-filled" class="field-control" value="2020-01-01" aria-invalid="true" />
|
<input id="showcase-error-filled" data-md-field-control value="2020-01-01" aria-invalid="true" />
|
||||||
</x-field>
|
</x-field>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<x-input size="sm" icon="search" placeholder="Filter" aria-label="Filter" class="w-48" />
|
<x-input size="sm" icon="search" placeholder="Filter" aria-label="Filter" class="w-48" />
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ it('opens the docked picker under its field and walks the grid from the keyboard
|
|||||||
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
|
->click('[aria-controls="expires-field-picker"][data-datepicker-toggle]')
|
||||||
->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'")
|
->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'")
|
||||||
->assertAttribute('#expires-field', 'aria-expanded', 'true')
|
->assertAttribute('#expires-field', 'aria-expanded', 'true')
|
||||||
->assertScript("(() => { const field = document.querySelector('#expires-field').closest('.field-box').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()")
|
->assertScript("(() => { const field = document.querySelector('#expires-field').closest('[data-md-field-box]').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()")
|
||||||
->assertScript(focusedDay('2026-09-13'));
|
->assertScript(focusedDay('2026-09-13'));
|
||||||
|
|
||||||
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14'));
|
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14'));
|
||||||
@@ -395,15 +395,15 @@ it('opens a docked picker as a modal one on a compact window', function () {
|
|||||||
|
|
||||||
it('empties a date, or both ends of a range, with its clear button', function () {
|
it('empties a date, or both ends of a range, with its clear button', function () {
|
||||||
$page = dateProbe()
|
$page = dateProbe()
|
||||||
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('.field').querySelector('[data-field-clear]')).display !== 'none'");
|
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('[data-md-field]').querySelector('[data-md-field-clear]')).display !== 'none'");
|
||||||
|
|
||||||
$page->click('[data-datepicker]:has(#expires-field) [data-field-clear]')
|
$page->click('[data-datepicker]:has(#expires-field) [data-md-field-clear]')
|
||||||
->assertScript("document.querySelector('#expires').textContent === ''")
|
->assertScript("document.querySelector('#expires').textContent === ''")
|
||||||
->assertValue('#expires-field', '')
|
->assertValue('#expires-field', '')
|
||||||
->assertScript("document.activeElement.id === 'expires-field'")
|
->assertScript("document.activeElement.id === 'expires-field'")
|
||||||
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'");
|
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-md-field-clear]')).display === 'none'");
|
||||||
|
|
||||||
$page->click('[data-datepicker]:has(#trip-field) [data-field-clear]')
|
$page->click('[data-datepicker]:has(#trip-field) [data-md-field-clear]')
|
||||||
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
||||||
->assertValue('#trip-field', '');
|
->assertValue('#trip-field', '');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ class FieldProbe extends Component
|
|||||||
<x-radio label="Audience" wire:model.live="audience" :options="[['id' => 'anyone', 'name' => 'Anyone'], ['id' => 'team', 'name' => 'Team']]" />
|
<x-radio label="Audience" wire:model.live="audience" :options="[['id' => 'anyone', 'name' => 'Anyone'], ['id' => 'team', 'name' => 'Team']]" />
|
||||||
<x-toggle id="public" label="Public" wire:model.live="public" />
|
<x-toggle id="public" label="Public" wire:model.live="public" />
|
||||||
<x-textarea id="message" label="Message" rows="2" max-rows="4" wire:model="message" />
|
<x-textarea id="message" label="Message" rows="2" max-rows="4" wire:model="message" />
|
||||||
|
<x-input id="bio-field" label="Bio" maxlength="10" counter />
|
||||||
|
<x-input id="off-field" label="Off" value="Not editable" disabled />
|
||||||
|
<x-input id="plain-name-field" label="Name, plain" wire:model="name" />
|
||||||
|
|
||||||
<x-button label="Save" wire:click="save" />
|
<x-button label="Save" wire:click="save" />
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +98,7 @@ it('clears a field and tells Livewire', function () {
|
|||||||
fieldProbe()
|
fieldProbe()
|
||||||
->type('#name-field', 'Holiday')
|
->type('#name-field', 'Holiday')
|
||||||
->assertSeeIn('#name', 'Holiday')
|
->assertSeeIn('#name', 'Holiday')
|
||||||
->click('[data-field-clear]')
|
->click('[data-md-field-clear]')
|
||||||
->assertScript("document.querySelector('#name-field').value === ''")
|
->assertScript("document.querySelector('#name-field').value === ''")
|
||||||
->assertScript("document.querySelector('#name').textContent === ''")
|
->assertScript("document.querySelector('#name').textContent === ''")
|
||||||
->assertScript("document.activeElement.id === 'name-field'");
|
->assertScript("document.activeElement.id === 'name-field'");
|
||||||
@@ -106,7 +109,7 @@ it('copies a field\'s value and says so', function () {
|
|||||||
|
|
||||||
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
||||||
|
|
||||||
$page->click('[data-field-copy]')
|
$page->click('[data-md-field-copy]')
|
||||||
->assertScript("window.copied === 'Holiday'")
|
->assertScript("window.copied === 'Holiday'")
|
||||||
->assertSee('Copied to the clipboard');
|
->assertSee('Copied to the clipboard');
|
||||||
});
|
});
|
||||||
@@ -114,10 +117,10 @@ it('copies a field\'s value and says so', function () {
|
|||||||
it('shows and hides a password', function () {
|
it('shows and hides a password', function () {
|
||||||
fieldProbe()
|
fieldProbe()
|
||||||
->type('#password-field', 'secret')
|
->type('#password-field', 'secret')
|
||||||
->click('[data-field-reveal]')
|
->click('[data-md-field-reveal]')
|
||||||
->assertScript("document.querySelector('#password-field').type === 'text'")
|
->assertScript("document.querySelector('#password-field').type === 'text'")
|
||||||
->assertAttribute('[data-field-reveal]', 'aria-label', 'Hide password')
|
->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password')
|
||||||
->click('[data-field-reveal]')
|
->click('[data-md-field-reveal]')
|
||||||
->assertScript("document.querySelector('#password-field').type === 'password'");
|
->assertScript("document.querySelector('#password-field').type === 'password'");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,7 +129,7 @@ it('shows the server\'s error in place of the hint', function () {
|
|||||||
->click('button:has-text("Save")')
|
->click('button:has-text("Save")')
|
||||||
->assertSee('The name field is required.')
|
->assertSee('The name field is required.')
|
||||||
->assertAttribute('#name-field', 'aria-invalid', 'true')
|
->assertAttribute('#name-field', 'aria-invalid', 'true')
|
||||||
->assertScript("document.querySelector('#name-field').closest('.field').hasAttribute('data-invalid')");
|
->assertScript("document.querySelector('#name-field').closest('[data-md-field]').hasAttribute('data-md-invalid')");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('binds a select to Livewire', function () {
|
it('binds a select to Livewire', function () {
|
||||||
@@ -183,3 +186,93 @@ it('grows a textarea with its text up to its maximum', function () {
|
|||||||
->assertScript("Math.abs(({$height}) - ({$three} + 24)) < 2")
|
->assertScript("Math.abs(({$height}) - ({$three} + 24)) < 2")
|
||||||
->assertScript("document.querySelector('#message').scrollHeight > document.querySelector('#message').clientHeight");
|
->assertScript("document.querySelector('#message').scrollHeight > document.querySelector('#message').clientHeight");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pairs the server\'s error with a trailing error icon in the error colour', function () {
|
||||||
|
$icon = "document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-error]')";
|
||||||
|
|
||||||
|
fieldProbe()
|
||||||
|
->assertScript("{$icon} === null")
|
||||||
|
->click('button:has-text("Save")')
|
||||||
|
->assertSee('The name field is required.')
|
||||||
|
// A field that trails with buttons of its own gives the icon's place to them.
|
||||||
|
->assertScript("document.querySelector('[data-md-field]:has(#name-field)').hasAttribute('data-md-invalid') && document.querySelector('[data-md-field]:has(#name-field) [data-md-field-error]') === null")
|
||||||
|
->assertScript("{$icon}.getAttribute('aria-label') === 'Error' && {$icon}.getAttribute('role') === 'img'")
|
||||||
|
->assertScript("{$icon}.getBoundingClientRect().width === 24")
|
||||||
|
->assertScript("getComputedStyle({$icon}).color === getComputedStyle(document.querySelector('[data-md-field]:has(#plain-name-field) [data-md-field-support]')).color");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lights an enabled field\'s outline on hover, and never a disabled one\'s', function () {
|
||||||
|
$edge = fn (string $id): string => "getComputedStyle(document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-outline]')).borderTopColor";
|
||||||
|
|
||||||
|
$page = fieldProbe();
|
||||||
|
|
||||||
|
$resting = $page->script("({$edge('name-field')})");
|
||||||
|
$disabled = $page->script("({$edge('off-field')})");
|
||||||
|
|
||||||
|
$page->hover('[data-md-field]:has(#name-field) [data-md-field-box]')
|
||||||
|
->assertScript("({$edge('name-field')}) !== '{$resting}'");
|
||||||
|
|
||||||
|
$page->hover('[data-md-field]:has(#off-field) [data-md-field-box]')
|
||||||
|
->assertScript("({$edge('off-field')}) === '{$disabled}'")
|
||||||
|
// The disabled edge is on-surface at 12%, which is translucent.
|
||||||
|
->assertScript("/rgba?\\(.*,\\s*0?\\.\\d+\\)|color\\(srgb .* \\/ 0?\\.\\d+\\)/.test('{$disabled}')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the characters as they are typed, marks a count past the maximum and says it once typing stops', function () {
|
||||||
|
$counter = "document.querySelector('[data-md-field]:has(#bio-field) [data-md-field-counter]')";
|
||||||
|
|
||||||
|
$page = fieldProbe()
|
||||||
|
->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '0/10'")
|
||||||
|
->type('#bio-field', 'Hello')
|
||||||
|
->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '5/10'")
|
||||||
|
->assertScript("! {$counter}.hasAttribute('data-md-over')");
|
||||||
|
|
||||||
|
$within = $page->script("getComputedStyle({$counter}).color");
|
||||||
|
|
||||||
|
// maxlength stops typing at the maximum, but a value put in from elsewhere can pass it.
|
||||||
|
$page->script("(() => { const input = document.querySelector('#bio-field'); input.value = 'Hello there!'; input.dispatchEvent(new Event('input', { bubbles: true })); })()");
|
||||||
|
|
||||||
|
$page->assertScript("{$counter}.querySelector('[aria-hidden]').textContent === '12/10'")
|
||||||
|
->assertScript("{$counter}.hasAttribute('data-md-over')")
|
||||||
|
->assertScript("getComputedStyle({$counter}).color !== '{$within}'")
|
||||||
|
->assertScript("{$counter}.querySelector('[aria-live]').textContent === ''")
|
||||||
|
->wait(1.3)
|
||||||
|
->assertScript("{$counter}.querySelector('[aria-live=\\'polite\\']').textContent === 'Character count, 12/10'");
|
||||||
|
});
|
||||||
|
|
||||||
|
function fieldWidthProbe(int $width)
|
||||||
|
{
|
||||||
|
Route::middleware('web')->get('/field-width-probe', fn () => Blade::render(<<<'BLADE'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<x-theme-script />
|
||||||
|
@vite(config('livewire-material.showcase.vite'))
|
||||||
|
<style>.probe-narrow { max-width: 200px; }</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="width: 1400px">
|
||||||
|
<x-input id="bounded" label="Bounded" />
|
||||||
|
<x-input id="narrow" label="Narrow" class="probe-narrow" />
|
||||||
|
<x-input id="full" label="Full" full />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
BLADE));
|
||||||
|
|
||||||
|
return visit('/field-width-probe')->resize($width, 800)->waitForEvent('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('bounds a field to 40rem from 600px, unless the caller\'s width rule or full says otherwise', function () {
|
||||||
|
$width = fn (string $id): string => "document.querySelector('[data-md-field]:has(#{$id})').getBoundingClientRect().width";
|
||||||
|
|
||||||
|
fieldWidthProbe(599)
|
||||||
|
->assertScript("({$width('bounded')}) === 1400")
|
||||||
|
->assertScript("({$width('narrow')}) === 200")
|
||||||
|
->assertScript("({$width('full')}) === 1400");
|
||||||
|
|
||||||
|
fieldWidthProbe(600)
|
||||||
|
->assertScript("({$width('bounded')}) === 640")
|
||||||
|
->assertScript("({$width('narrow')}) === 200")
|
||||||
|
->assertScript("({$width('full')}) === 1400");
|
||||||
|
});
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ it('draws a searchable combobox with a popover list', function () {
|
|||||||
->toContain('popover="manual"')
|
->toContain('popover="manual"')
|
||||||
->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
|
->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
|
||||||
->toContain('x-modelable="value"')
|
->toContain('x-modelable="value"')
|
||||||
->toContain('field-arrow')
|
->toContain('data-md-field-arrow')
|
||||||
// WAI-ARIA's combobox keyboard, Home and End included.
|
// WAI-ARIA's combobox keyboard, Home and End included.
|
||||||
->toContain('x-on:keydown.home="jump($event, false)"')
|
->toContain('x-on:keydown.home="jump($event, false)"')
|
||||||
->toContain('x-on:keydown.end="jump($event, true)"')
|
->toContain('x-on:keydown.end="jump($event, true)"')
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ it('draws a docked date field whose combobox controls a popover picker', functio
|
|||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('data-datepicker')
|
->toContain('data-datepicker')
|
||||||
->toContain('<label for="expires" class="field-label">Expires on</label>')
|
->toContain('<label for="expires" data-md-field-label>Expires on</label>')
|
||||||
->toContain('role="combobox"')
|
->toContain('role="combobox"')
|
||||||
->toContain('aria-haspopup="dialog"')
|
->toContain('aria-haspopup="dialog"')
|
||||||
->toContain('aria-controls="expires-picker"')
|
->toContain('aria-controls="expires-picker"')
|
||||||
@@ -155,8 +155,8 @@ it('puts class on the root and every other attribute on the text field', functio
|
|||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('class="w-60"')
|
->toContain('class="w-60"')
|
||||||
->toContain('data-variant="filled"')
|
->toContain('data-md-variant="filled"')
|
||||||
->toContain('data-size="sm"')
|
->toContain('data-md-size="sm"')
|
||||||
->toMatch('/<input\s[^>]*required[^>]*id="due"/s')
|
->toMatch('/<input\s[^>]*required[^>]*id="due"/s')
|
||||||
->toMatch('/<input\s[^>]*disabled[^>]*id="due"/s')
|
->toMatch('/<input\s[^>]*disabled[^>]*id="due"/s')
|
||||||
->and(datepickerConfig($html)['disabled'])->toBeTrue();
|
->and(datepickerConfig($html)['disabled'])->toBeTrue();
|
||||||
@@ -225,14 +225,14 @@ it('replaces the hint with the errors for the property and its start and end', f
|
|||||||
->not->toContain('Pick a day')
|
->not->toContain('Pick a day')
|
||||||
->toContain('aria-invalid="true"')
|
->toContain('aria-invalid="true"')
|
||||||
->toContain('data-datepicker-error')
|
->toContain('data-datepicker-error')
|
||||||
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
|
->and(substr_count($html, 'data-md-invalid=""'))->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers a clear button on request', function () {
|
it('offers a clear button on request', function () {
|
||||||
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
|
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
|
||||||
->toContain('data-field-clear')
|
->toContain('data-md-field-clear')
|
||||||
->toContain('x-on:click="clear()"')
|
->toContain('x-on:click="clear()"')
|
||||||
->toContain('aria-label="Clear"')
|
->toContain('aria-label="Clear"')
|
||||||
->and((string) $this->blade('<x-datepicker label="Expires" />'))
|
->and((string) $this->blade('<x-datepicker label="Expires" />'))
|
||||||
->not->toContain('data-field-clear');
|
->not->toContain('data-md-field-clear');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,31 +4,33 @@ it('draws an outlined field with a notched label unless told otherwise', functio
|
|||||||
$html = (string) $this->blade('<x-input label="Share name" />');
|
$html = (string) $this->blade('<x-input label="Share name" />');
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('data-variant="outlined"')
|
->toContain('data-md-field')
|
||||||
|
->toContain('data-md-variant="outlined"')
|
||||||
->toContain('<legend><span>Share name</span></legend>')
|
->toContain('<legend><span>Share name</span></legend>')
|
||||||
->toMatch('/<label for="(field-[0-9a-f]{12})" class="field-label">Share name<\/label>/')
|
->toMatch('/<label for="(field-[0-9a-f]{12})" data-md-field-label>Share name<\/label>/')
|
||||||
->toContain('placeholder=" "')
|
->toContain('placeholder=" "')
|
||||||
->toContain('class="field-control"');
|
->toContain('data-md-field-control')
|
||||||
|
->not->toContain('class=');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('draws a filled field on request, or by the application\'s default', function () {
|
it('draws a filled field on request, or by the application\'s default', function () {
|
||||||
expect((string) $this->blade('<x-input label="Name" variant="filled" />'))->toContain('data-variant="filled"');
|
expect((string) $this->blade('<x-input label="Name" variant="filled" />'))->toContain('data-md-variant="filled"');
|
||||||
|
|
||||||
config(['livewire-material.fields.variant' => 'filled']);
|
config(['livewire-material.fields.variant' => 'filled']);
|
||||||
|
|
||||||
expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-variant="filled"')
|
expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-md-variant="filled"')
|
||||||
->and((string) $this->blade('<x-input label="Name" variant="outlined" />'))->toContain('data-variant="outlined"')
|
->and((string) $this->blade('<x-input label="Name" variant="outlined" />'))->toContain('data-md-variant="outlined"')
|
||||||
->and((string) $this->blade('<x-input label="Name" variant="glass" />'))->toContain('data-variant="filled"');
|
->and((string) $this->blade('<x-input label="Name" variant="glass" />'))->toContain('data-md-variant="filled"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ties the label, the hint and the control together', function () {
|
it('ties the label, the hint and the control together', function () {
|
||||||
$html = (string) $this->blade('<x-input id="share-name" label="Share name" hint="Recipients see this name" wire:model="name" />');
|
$html = (string) $this->blade('<x-input id="share-name" label="Share name" hint="Recipients see this name" wire:model="name" />');
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('<label for="share-name" class="field-label">')
|
->toContain('<label for="share-name" data-md-field-label>')
|
||||||
->toContain('id="share-name"')
|
->toContain('id="share-name"')
|
||||||
->toContain('aria-describedby="share-name-support"')
|
->toContain('aria-describedby="share-name-support"')
|
||||||
->toContain('<p id="share-name-support" class="field-support">Recipients see this name</p>')
|
->toMatch('/<p id="share-name-support" data-md-field-support\s*>Recipients see this name<\/p>/')
|
||||||
->toContain('wire:model="name"')
|
->toContain('wire:model="name"')
|
||||||
->not->toContain('aria-invalid');
|
->not->toContain('aria-invalid');
|
||||||
});
|
});
|
||||||
@@ -38,8 +40,9 @@ it('replaces the hint with the errors for its model', function () {
|
|||||||
->blade('<x-input id="email" label="Email" hint="We never share it" wire:model.live="email" />');
|
->blade('<x-input id="email" label="Email" hint="We never share it" wire:model.live="email" />');
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('data-invalid')
|
->toContain('data-md-invalid')
|
||||||
->toContain('aria-invalid="true"')
|
->toContain('aria-invalid="true"')
|
||||||
|
->toContain('role="alert"')
|
||||||
->toContain('aria-describedby="email-support"')
|
->toContain('aria-describedby="email-support"')
|
||||||
->toContain('Enter a valid email address.')
|
->toContain('Enter a valid email address.')
|
||||||
->not->toContain('We never share it');
|
->not->toContain('We never share it');
|
||||||
@@ -49,28 +52,26 @@ it('pairs the error colours with a trailing error icon', function () {
|
|||||||
$errors = ['email' => ['Enter a valid email address.']];
|
$errors = ['email' => ['Enter a valid email address.']];
|
||||||
|
|
||||||
expect((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" wire:model="email" />'))
|
expect((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" wire:model="email" />'))
|
||||||
->toContain('field-trailing field-error')
|
->toMatch('/<svg(?=[^>]*data-md-field-trailing)(?=[^>]*data-md-field-error)(?=[^>]*aria-label="Error")(?=[^>]*role="img")[^>]*>/')
|
||||||
->toContain('aria-label="Error"')
|
|
||||||
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" size="xs" wire:model="email" />'))
|
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" size="xs" wire:model="email" />'))
|
||||||
->not->toContain('field-error')
|
->not->toContain('data-md-field-error')
|
||||||
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" icon-right="info" wire:model="email" />'))
|
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" icon-right="info" wire:model="email" />'))
|
||||||
->not->toContain('field-error')
|
->not->toContain('data-md-field-error')
|
||||||
->and((string) $this->blade('<x-input id="email" label="Email" />'))
|
->and((string) $this->blade('<x-input id="email" label="Email" />'))
|
||||||
->not->toContain('field-error');
|
->not->toContain('data-md-field-error');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('puts icons, affixes and the size on the field, and every other attribute on the input', function () {
|
it('puts icons, affixes and the size on the field, and every other attribute on the input', function () {
|
||||||
$html = (string) $this->blade('<x-input label="Price" icon="payments" icon-right="info" prefix="CHF" suffix="per month" size="sm" type="number" min="0" class="w-40" mono readonly />');
|
$html = (string) $this->blade('<x-input label="Price" icon="payments" icon-right="info" prefix="CHF" suffix="per month" size="sm" type="number" min="0" class="w-40" mono readonly />');
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('class="field w-40"')
|
->toMatch('/<div\s+data-md-field\s+data-md-variant="outlined"\s+data-md-size="sm"\s+data-md-mono\s+class="w-40" data-md-readonly=""\s*>/')
|
||||||
->toContain('data-size="sm"')
|
->toContain('<span data-md-field-affix>CHF</span>')
|
||||||
->toContain('data-mono')
|
->toContain('<span data-md-field-affix>per month</span>')
|
||||||
->toContain('data-readonly')
|
|
||||||
->toContain('<span class="field-affix">CHF</span>')
|
|
||||||
->toContain('<span class="field-affix">per month</span>')
|
|
||||||
->toContain('type="number"')
|
->toContain('type="number"')
|
||||||
->toContain('min="0"')
|
->toContain('min="0"')
|
||||||
|
// An sm field's icons are 20px, drawn from the 20 cut.
|
||||||
|
->and(substr_count($html, '--md-icon-size: 20px'))->toBe(2)
|
||||||
->and(substr_count($html, '<svg'))->toBe(2);
|
->and(substr_count($html, '<svg'))->toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,9 +79,12 @@ it('offers to clear and to copy the value', function () {
|
|||||||
$html = (string) $this->blade('<x-input label="Link" clearable copyable />');
|
$html = (string) $this->blade('<x-input label="Link" clearable copyable />');
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('data-field-clear')
|
->toContain('data-md-field-clear')
|
||||||
->toContain('aria-label="Clear"')
|
->toContain('aria-label="Clear"')
|
||||||
->toContain('data-field-copy')
|
->toContain('data-md-field-copy')
|
||||||
|
->toContain("\$el.closest('[data-md-field]').querySelector('[data-md-field-control]')")
|
||||||
|
// A labelled field is md: its icons are 24px.
|
||||||
|
->toContain('--md-icon-size: 24px')
|
||||||
->toContain('navigator.clipboard.writeText')
|
->toContain('navigator.clipboard.writeText')
|
||||||
->toContain('aria-label="Copy"');
|
->toContain('aria-label="Copy"');
|
||||||
});
|
});
|
||||||
@@ -92,7 +96,7 @@ it('lets a password be shown and hidden again', function () {
|
|||||||
->toContain('type="password"')
|
->toContain('type="password"')
|
||||||
->not->toContain('type="text"')
|
->not->toContain('type="text"')
|
||||||
->toContain('x-bind:type="shown ? \'text\' : \'password\'"')
|
->toContain('x-bind:type="shown ? \'text\' : \'password\'"')
|
||||||
->toContain('data-field-reveal')
|
->toContain('data-md-field-reveal')
|
||||||
->toContain('aria-label="Show password"')
|
->toContain('aria-label="Show password"')
|
||||||
// The flipping label already says the state; aria-pressed would say it again, inverted.
|
// The flipping label already says the state; aria-pressed would say it again, inverted.
|
||||||
->not->toContain('aria-pressed')
|
->not->toContain('aria-pressed')
|
||||||
@@ -102,12 +106,12 @@ it('lets a password be shown and hidden again', function () {
|
|||||||
it('grows a textarea from its rows up to its maximum, or leaves it to be resized', function () {
|
it('grows a textarea from its rows up to its maximum, or leaves it to be resized', function () {
|
||||||
expect((string) $this->blade('<x-textarea label="Message" rows="2" max-rows="6" />'))
|
expect((string) $this->blade('<x-textarea label="Message" rows="2" max-rows="6" />'))
|
||||||
->toContain('rows="2"')
|
->toContain('rows="2"')
|
||||||
->toContain('data-autogrow')
|
->toContain('data-md-autogrow')
|
||||||
->toContain('--field-rows: 2;')
|
->toContain('--field-rows: 2;')
|
||||||
->toContain('--field-max-rows: 6;')
|
->toContain('--field-max-rows: 6;')
|
||||||
->and((string) $this->blade('<x-textarea label="Message" :autogrow="false" />'))
|
->and((string) $this->blade('<x-textarea label="Message" :autogrow="false" />'))
|
||||||
->toContain('rows="3"')
|
->toContain('rows="3"')
|
||||||
->not->toContain('data-autogrow');
|
->not->toContain('data-md-autogrow');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('draws a native select whose label always floats', function () {
|
it('draws a native select whose label always floats', function () {
|
||||||
@@ -119,13 +123,13 @@ it('draws a native select whose label always floats', function () {
|
|||||||
BLADE);
|
BLADE);
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('data-floated')
|
->toContain('data-md-floated')
|
||||||
->toContain('<select')
|
->toContain('<select')
|
||||||
->toContain('wire:model="hours"')
|
->toContain('wire:model="hours"')
|
||||||
->toContain('<option value="">Choose…</option>')
|
->toContain('<option value="">Choose…</option>')
|
||||||
->toContain('<option value="1" >1 hour</option>')
|
->toContain('<option value="1" >1 hour</option>')
|
||||||
->toContain('<option value="720" disabled>30 days</option>')
|
->toContain('<option value="720" disabled>30 days</option>')
|
||||||
->toContain('field-arrow');
|
->toContain('data-md-field-arrow');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('takes a select\'s options from its slot as well', function () {
|
it('takes a select\'s options from its slot as well', function () {
|
||||||
@@ -142,7 +146,7 @@ it('collects a file field\'s own errors and each file\'s', function () {
|
|||||||
expect($html)
|
expect($html)
|
||||||
->toContain('type="file"')
|
->toContain('type="file"')
|
||||||
->toContain('multiple')
|
->toContain('multiple')
|
||||||
->toContain('data-floated')
|
->toContain('data-md-floated')
|
||||||
->toContain('Choose at most 10 photos.')
|
->toContain('Choose at most 10 photos.')
|
||||||
->toContain('photo.jpg is larger than 2 GB.');
|
->toContain('photo.jpg is larger than 2 GB.');
|
||||||
});
|
});
|
||||||
@@ -151,8 +155,10 @@ it('counts the characters of a field that has a maximum', function () {
|
|||||||
$html = (string) $this->blade('<x-input id="bio" label="Bio" hint="Shown on your profile" maxlength="60" counter />');
|
$html = (string) $this->blade('<x-input id="bio" label="Bio" hint="Shown on your profile" maxlength="60" counter />');
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('class="field-support-row"')
|
->toContain('data-md-field-support-row')
|
||||||
->toContain('class="field-counter"')
|
->toContain('data-md-field-counter')
|
||||||
|
->toContain('x-bind:data-md-over="used > max"')
|
||||||
|
->toContain('class="md-visually-hidden"')
|
||||||
->toContain('maxlength="60"')
|
->toContain('maxlength="60"')
|
||||||
->toContain('max: 60,')
|
->toContain('max: 60,')
|
||||||
->toContain('>0/60</span>')
|
->toContain('>0/60</span>')
|
||||||
@@ -160,24 +166,24 @@ it('counts the characters of a field that has a maximum', function () {
|
|||||||
->toContain('Shown on your profile');
|
->toContain('Shown on your profile');
|
||||||
|
|
||||||
expect((string) $this->blade('<x-textarea id="note" label="Note" maxlength="140" counter />'))
|
expect((string) $this->blade('<x-textarea id="note" label="Note" maxlength="140" counter />'))
|
||||||
->toContain('class="field-counter"')
|
->toContain('data-md-field-counter')
|
||||||
->toContain('>0/140</span>');
|
->toContain('>0/140</span>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows no counter without `counter` or without a maximum to count against', function () {
|
it('shows no counter without `counter` or without a maximum to count against', function () {
|
||||||
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('field-counter')
|
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('data-md-field-counter')
|
||||||
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('field-counter')
|
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('data-md-field-counter')
|
||||||
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('field-counter');
|
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('data-md-field-counter');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bounds a field\'s width from medium unless it is told to fill its pane', function () {
|
it('bounds a field\'s width from medium unless it is told to fill its pane', function () {
|
||||||
expect(file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))
|
expect(file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))
|
||||||
->toContain('@media (width >= 37.5rem)')
|
->toContain('@media (width >= 600px)')
|
||||||
->toMatch('/\.field:not\(\[data-full\]\)\s*\{\s*max-width: 40rem;/');
|
->toMatch('/\[data-md-field\]:not\(\[data-md-full\]\)\s*\{\s*max-width: 40rem;/');
|
||||||
|
|
||||||
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-full')
|
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-md-full')
|
||||||
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-full')
|
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-md-full')
|
||||||
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-full');
|
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-md-full');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lays out a form with its actions under an optional divider', function () {
|
it('lays out a form with its actions under an optional divider', function () {
|
||||||
@@ -206,3 +212,27 @@ it('draws a form\'s column and its actions from its stylesheet', function () {
|
|||||||
->toContain('gap: var(--md-sys-measurement-space200);')
|
->toContain('gap: var(--md-sys-measurement-space200);')
|
||||||
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/form.css';");
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/form.css';");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the field\'s chrome in its own stylesheet in the components layer', function () {
|
||||||
|
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css');
|
||||||
|
|
||||||
|
expect($css)->toContain('@layer material.components')
|
||||||
|
->toContain("@import './icon.css';")
|
||||||
|
->toContain('[data-md-field-box]')
|
||||||
|
->not->toMatch('/(?<![\\w-])\\.field\\b/')
|
||||||
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/field.css';")
|
||||||
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('field.css');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a custom control marked as the field\'s control, and a hint class', function () {
|
||||||
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
|
<x-field id="expiry" label="Expiry" hint="In the future" hint-class="md-ink-primary">
|
||||||
|
<input id="expiry" data-md-field-control value="2030-01-01" />
|
||||||
|
</x-field>
|
||||||
|
BLADE);
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain('<input id="expiry" data-md-field-control value="2030-01-01" />')
|
||||||
|
->toMatch('/<p id="expiry-support" data-md-field-support\s+class="md-ink-primary"\s*>In the future<\/p>/')
|
||||||
|
->toContain('<fieldset aria-hidden="true" data-md-field-outline>');
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
|
|||||||
$html = (string) $this->blade('<x-timepicker id="starts" label="Starts at" hint="In your time zone" value="14:30" format="24" />');
|
$html = (string) $this->blade('<x-timepicker id="starts" label="Starts at" hint="In your time zone" value="14:30" format="24" />');
|
||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toContain('<label for="starts" class="field-label">Starts at</label>')
|
->toContain('<label for="starts" data-md-field-label>Starts at</label>')
|
||||||
->toMatch('/<input[^>]*id="starts"[^>]*readonly/s')
|
->toMatch('/<input[^>]*id="starts"[^>]*readonly/s')
|
||||||
->toContain('value="14:30"')
|
->toContain('value="14:30"')
|
||||||
->toContain('aria-haspopup="dialog"')
|
->toContain('aria-haspopup="dialog"')
|
||||||
->toContain('aria-controls="starts-dialog"')
|
->toContain('aria-controls="starts-dialog"')
|
||||||
->toContain('aria-expanded="false"')
|
->toContain('aria-expanded="false"')
|
||||||
->toContain('aria-describedby="starts-support"')
|
->toContain('aria-describedby="starts-support"')
|
||||||
->toContain('<p id="starts-support" class="field-support">In your time zone</p>')
|
->toMatch('/<p id="starts-support" data-md-field-support\s*>In your time zone<\/p>/')
|
||||||
->toContain('x-on:keydown.enter.prevent="show()"')
|
->toContain('x-on:keydown.enter.prevent="show()"')
|
||||||
->toContain('data-timepicker-open')
|
->toContain('data-timepicker-open')
|
||||||
->toContain('aria-label="Choose time"')
|
->toContain('aria-label="Choose time"')
|
||||||
@@ -38,7 +38,7 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
|
|||||||
->toContain('x-modelable="value"')
|
->toContain('x-modelable="value"')
|
||||||
->toContain("materialTimepicker( '14:30' ,")
|
->toContain("materialTimepicker( '14:30' ,")
|
||||||
->not->toContain('aria-invalid="true"')
|
->not->toContain('aria-invalid="true"')
|
||||||
->not->toContain('data-invalid');
|
->not->toContain('data-md-invalid');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('draws the dial as a slider, with twelve numbers per ring and M3\'s 24-hour inner ring', function () {
|
it('draws the dial as a slider, with twelve numbers per ring and M3\'s 24-hour inner ring', function () {
|
||||||
@@ -153,7 +153,7 @@ it('shows the errors for its property in place of the hint', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(Livewire::test('timepicker-errors')->html())
|
expect(Livewire::test('timepicker-errors')->html())
|
||||||
->toContain('data-invalid')
|
->toContain('data-md-invalid')
|
||||||
->toContain('aria-invalid="true"')
|
->toContain('aria-invalid="true"')
|
||||||
->toContain('aria-describedby="starts-support"')
|
->toContain('aria-describedby="starts-support"')
|
||||||
->toContain('Choose a time during opening hours.')
|
->toContain('Choose a time during opening hours.')
|
||||||
@@ -165,13 +165,13 @@ it('puts the field props on the field, its attributes on the input, and posts a
|
|||||||
|
|
||||||
expect($html)
|
expect($html)
|
||||||
->toMatch('/^<div\s+class="w-60"/')
|
->toMatch('/^<div\s+class="w-60"/')
|
||||||
->toContain('data-variant="filled"')
|
->toContain('data-md-variant="filled"')
|
||||||
->toContain('data-size="sm"')
|
->toContain('data-md-size="sm"')
|
||||||
->toContain('data-timepicker-field')
|
->toContain('data-timepicker-field')
|
||||||
->toContain('required')
|
->toContain('required')
|
||||||
->toContain('placeholder="hh:mm"')
|
->toContain('placeholder="hh:mm"')
|
||||||
->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />')
|
->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />')
|
||||||
->toContain('data-field-clear')
|
->toContain('data-md-field-clear')
|
||||||
->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s')
|
->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s')
|
||||||
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
|
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ it('renders components that use others under a prefix', function () {
|
|||||||
expect(Blade::render('<x-m::drawer title="Share" with-close-button separator><x-m::input label="Name" clearable /></x-m::drawer>'))
|
expect(Blade::render('<x-m::drawer title="Share" with-close-button separator><x-m::input label="Name" clearable /></x-m::drawer>'))
|
||||||
->toContain('aria-label="Close"')
|
->toContain('aria-label="Close"')
|
||||||
->toContain('role="separator"')
|
->toContain('role="separator"')
|
||||||
->toContain('data-field-clear');
|
->toContain('data-md-field-clear');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is not shadowed by an application component of the same name', function () {
|
it('is not shadowed by an application component of the same name', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user