Move the textarea's rules out of the field into textarea.css

<x-textarea> marks its field root data-md-textarea, and what a
<textarea> changes in the field — the grip in the corner, the label on
the first line, autogrow between rows and max-rows, the filled offsets —
moves from field.css to components/textarea.css (plan step 36).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 14:11:22 +02:00
co-authored by Claude Opus 5
parent cfdb4619ad
commit c2fe64e00d
5 changed files with 79 additions and 44 deletions
+1
View File
@@ -18,6 +18,7 @@
@import './components/field.css'; @import './components/field.css';
@import './components/input.css'; @import './components/input.css';
@import './components/password.css'; @import './components/password.css';
@import './components/textarea.css';
/* Containment */ /* Containment */
-42
View File
@@ -138,35 +138,6 @@
transition: background-color 604800s, color 604800s; transition: background-color 604800s, color 604800s;
} }
/* The browser draws the resize grip in the textarea's own corner, so the
textarea reaches to just inside the outline's end and bottom — the grip sits
in the field's corner, not on the bottom outline a padding in from it — and
its padding puts the text back where it was. */
textarea[data-md-field-control] {
--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
textarea disappears under the edge instead of running through the label. */
--textarea-clear: var(--md-sys-measurement-space100);
--textarea-pad-top: calc((var(--field-height) - 1.5rem) / 2 - var(--textarea-clear));
--textarea-pad-bottom: calc((var(--field-height) - 1.5rem) / 2 - var(--field-grip));
margin-block: var(--textarea-clear) var(--field-grip);
margin-inline-end: calc(var(--field-grip) - var(--field-pad));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
padding-inline-end: calc(var(--field-pad) - var(--field-grip));
resize: vertical;
}
/* `<x-textarea>` grows with what is typed, from `rows` lines up to `max-rows`. Where the browser
cannot size a field to its content, resources/js/field.js sets the height instead. */
textarea[data-md-field-control][data-md-autogrow] {
field-sizing: content;
min-block-size: calc(var(--field-rows, 3) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
max-block-size: calc(var(--field-max-rows, 1000) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
resize: none;
}
/* A select covers its whole field — out over the padding and the leading icon at /* A select covers its whole field — out over the padding and the leading icon at
the start, under the arrow to the end — and its own padding puts the value back 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
@@ -413,11 +384,6 @@
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. */
[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);
}
[data-md-field]:has([data-md-field-label]):not(:focus-within) [data-md-field-control]::placeholder { [data-md-field]:has([data-md-field-label]):not(:focus-within) [data-md-field-control]::placeholder {
color: transparent; color: transparent;
} }
@@ -561,14 +527,6 @@
padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100); padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
} }
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) textarea[data-md-field-control] {
--textarea-clear: var(--md-sys-measurement-space300);
--textarea-pad-top: 0px;
--textarea-pad-bottom: calc(var(--md-sys-measurement-space100) - var(--field-grip));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
}
/* A file picker's button is taller than a line of text: it sits lower, and a little smaller. */ /* A file picker's button is taller than a line of text: it sits lower, and a little smaller. */
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) input[type='file'][data-md-field-control] { [data-md-field][data-md-variant='filled']:has([data-md-field-label]) input[type='file'][data-md-field-control] {
padding-block: 22px var(--md-sys-measurement-space75); padding-block: 22px var(--md-sys-measurement-space75);
+63
View File
@@ -0,0 +1,63 @@
/*
* <x-textarea>: the text field grown to several lines (resources/views/components/textarea.blade.php).
*
* The chrome is the field's (components/field.css); this file is what a <textarea> changes in it.
* The browser's resize grip belongs in the field's corner, so the textarea reaches to just inside
* the outline's end and bottom and its padding puts the text back. Its label rests on the first
* line rather than in the middle of the box. With `data-md-autogrow` it grows with its text from
* `--field-rows` lines to `--field-max-rows` (both set inline by the view) through
* `field-sizing: content`, and resources/js/field.js sets the height where the browser cannot.
* A line is body-large's 1.5rem, so the row arithmetic stays in rem with the text.
*
* The field's root carries `data-md-textarea`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@layer material.components {
/* The browser draws the resize grip in the textarea's own corner, so the
textarea reaches to just inside the outline's end and bottom — the grip sits
in the field's corner, not on the bottom outline a padding in from it — and
its padding puts the text back where it was. */
textarea[data-md-field-control] {
--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
textarea disappears under the edge instead of running through the label. */
--textarea-clear: var(--md-sys-measurement-space100);
--textarea-pad-top: calc((var(--field-height) - 1.5rem) / 2 - var(--textarea-clear));
--textarea-pad-bottom: calc((var(--field-height) - 1.5rem) / 2 - var(--field-grip));
margin-block: var(--textarea-clear) var(--field-grip);
margin-inline-end: calc(var(--field-grip) - var(--field-pad));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
padding-inline-end: calc(var(--field-pad) - var(--field-grip));
resize: vertical;
}
/* `<x-textarea>` grows with what is typed, from `rows` lines up to `max-rows`. Where the browser
cannot size a field to its content, resources/js/field.js sets the height instead. */
textarea[data-md-field-control][data-md-autogrow] {
field-sizing: content;
min-block-size: calc(var(--field-rows, 3) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
max-block-size: calc(var(--field-max-rows, 1000) * 1.5rem + var(--textarea-pad-top) + var(--textarea-pad-bottom));
resize: none;
}
/* A textarea's label rests on its first line, not in the middle of the box. */
[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);
}
/* Filled, the label floats inside the box: the text starts under it, 24px down, and the
bottom keeps the field's 8px. */
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) textarea[data-md-field-control] {
--textarea-clear: var(--md-sys-measurement-space300);
--textarea-pad-top: 0px;
--textarea-pad-bottom: calc(var(--md-sys-measurement-space100) - var(--field-grip));
padding-block: var(--textarea-pad-top) var(--textarea-pad-bottom);
}
}
@@ -5,7 +5,8 @@
form's column never moves. The label rests on the first line rather than in the middle of the form's column never moves. The label rests on the first line rather than in the middle of the
box. `label`, `hint`, `variant`, `counter` for M3's character counter against `maxlength`, and box. `label`, `hint`, `variant`, `counter` for M3's character counter against `maxlength`, and
`full` to span the pane past the 40rem M3 bounds a field to from `medium`; every other `full` to span the pane past the 40rem M3 bounds a field to from `medium`; every other
attribute reaches the `<textarea>`. --}} attribute reaches the `<textarea>`. The field's root carries `data-md-textarea`; what a
textarea changes in the field's chrome is resources/css/components/textarea.css. --}}
@props([ @props([
'label' => null, 'label' => null,
@@ -30,7 +31,7 @@
$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-md-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" data-md-textarea>
<textarea <textarea
{{ $attributes->except(['class', 'id', 'placeholder']) }} {{ $attributes->except(['class', 'id', 'placeholder']) }}
id="{{ $id }}" id="{{ $id }}"
+12
View File
@@ -108,6 +108,7 @@ 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-md-textarea')
->toContain('data-md-autogrow') ->toContain('data-md-autogrow')
->toContain('--field-rows: 2;') ->toContain('--field-rows: 2;')
->toContain('--field-max-rows: 6;') ->toContain('--field-max-rows: 6;')
@@ -254,3 +255,14 @@ it('gives the password field a stylesheet that brings the field and its icons',
->toContain("@import './icon.css';") ->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/password.css';"); ->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/password.css';");
}); });
it('draws what a textarea changes in the field from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/textarea.css');
expect($css)->toContain("@import './field.css';")
->toContain('@layer material.components')
->toContain('textarea[data-md-field-control][data-md-autogrow]')
->toContain('field-sizing: content;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/textarea.css';");
});