Move the file picker's rules out of the field into file.css

<x-file> marks its field root data-md-file, and the tonal
::file-selector-button with its hover and disabled states moves from
field.css to components/file.css on the state tokens (plan step 36). A
browser test pins the field's 56px box and 16px padding and the pill.

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:12:40 +02:00
co-authored by Claude Opus 5
parent 23f2bdabf9
commit 3083fe28b2
6 changed files with 90 additions and 45 deletions
+1
View File
@@ -20,6 +20,7 @@
@import './components/password.css'; @import './components/password.css';
@import './components/textarea.css'; @import './components/textarea.css';
@import './components/select.css'; @import './components/select.css';
@import './components/file.css';
/* Containment */ /* Containment */
-43
View File
@@ -138,40 +138,6 @@
transition: background-color 604800s, color 604800s; transition: background-color 604800s, color 604800s;
} }
/* `<x-file>`: the browser's own "No file chosen" line is transparent (the caller shows what was
chosen), and its button is a tonal pill — the thing to press. */
input[type='file'][data-md-field-control] {
align-self: center;
color: transparent;
cursor: pointer;
}
input[type='file'][data-md-field-control]::file-selector-button {
height: 32px;
margin-inline-end: 12px;
padding-inline: var(--md-sys-measurement-space200);
border: 0;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
cursor: pointer;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
input[type='file'][data-md-field-control]:hover::file-selector-button {
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'][data-md-field-control]:disabled::file-selector-button {
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) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
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. */
[data-md-field]:has([data-md-field-control][aria-expanded='true']) [data-md-field-arrow] { [data-md-field]:has([data-md-field-control][aria-expanded='true']) [data-md-field-arrow] {
rotate: 180deg; rotate: 180deg;
@@ -474,15 +440,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);
} }
/* 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] {
padding-block: 22px var(--md-sys-measurement-space75);
}
[data-md-field][data-md-variant='filled'] input[type='file'][data-md-field-control]::file-selector-button {
height: 28px;
}
[data-md-field][data-md-variant='filled']:has([data-md-field-label]) [data-md-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: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100); padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100);
+62
View File
@@ -0,0 +1,62 @@
/*
* <x-file>: the browser's file input in the text field's chrome
* (resources/views/components/file.blade.php).
*
* The chrome is the field's (components/field.css); this file restyles only what the browser
* draws. Its "No file chosen" line is transparent, because it can be neither truncated nor wrapped
* and the caller shows what was chosen; its `::file-selector-button` is a tonal pill — a 32px
* button in secondary-container with label-large, 16px padding, 12px before the line — so it reads
* as the thing to press, with M3's hover state layer and disabled opacities (tokens/state.css). In
* a filled field the button sits under the floating label, 28px tall.
*
* The field's root carries `data-md-file`.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './field.css';
@layer material.components {
/* `<x-file>`: the browser's own "No file chosen" line is transparent (the caller shows what was
chosen), and its button is a tonal pill — the thing to press. */
input[type='file'][data-md-field-control] {
align-self: center;
color: transparent;
cursor: pointer;
}
input[type='file'][data-md-field-control]::file-selector-button {
height: 32px;
margin-inline-end: 12px;
padding-inline: var(--md-sys-measurement-space200);
border: 0;
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
cursor: pointer;
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
@media (hover: hover) {
input[type='file'][data-md-field-control]:hover::file-selector-button {
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'][data-md-field-control]:disabled::file-selector-button {
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) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
cursor: default;
}
/* 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] {
padding-block: 22px var(--md-sys-measurement-space75);
}
[data-md-field][data-md-variant='filled'] input[type='file'][data-md-field-control]::file-selector-button {
height: 28px;
}
}
+3 -2
View File
@@ -4,7 +4,8 @@
picker, a file dropped on it, the keyboard, what a screen reader announces, the `change` a picker, a file dropped on it, the keyboard, what a screen reader announces, the `change` a
Livewire upload listens for. So the input stays native, its label always floats (the button Livewire upload listens for. So the input stays native, its label always floats (the button
stands where a resting label would), and only its `::file-selector-button` is restyled: a tonal stands where a resting label would), and only its `::file-selector-button` is restyled: a tonal
pill, so it reads as the thing to press (resources/css/components/field.css). pill, so it reads as the thing to press (resources/css/components/file.css, on the field's
chrome; the field's root carries `data-md-file`).
The browser's own line beside the button ("No file chosen", "3 files") is drawn transparent: The browser's own line beside the button ("No file chosen", "3 files") is drawn transparent:
it cannot be truncated or wrapped, and the caller shows what was chosen anyway, as previews. A it cannot be truncated or wrapped, and the caller shows what was chosen anyway, as previews. A
@@ -28,7 +29,7 @@
: []; : [];
@endphp @endphp
<x-livewire-material::field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')"> <x-livewire-material::field :$id :$label :$hint :$messages :$variant floated :class="$attributes->get('class')" data-md-file>
<input <input
{{ $attributes->except(['class', 'id', 'type']) }} {{ $attributes->except(['class', 'id', 'type']) }}
type="file" type="file"
+13
View File
@@ -50,6 +50,7 @@ class FieldProbe extends Component
<x-input id="bio-field" label="Bio" maxlength="10" counter /> <x-input id="bio-field" label="Bio" maxlength="10" counter />
<x-input id="off-field" label="Off" value="Not editable" disabled /> <x-input id="off-field" label="Off" value="Not editable" disabled />
<x-input id="plain-name-field" label="Name, plain" wire:model="name" /> <x-input id="plain-name-field" label="Name, plain" wire:model="name" />
<x-file id="upload-field" label="Upload" />
<x-button label="Save" wire:click="save" /> <x-button label="Save" wire:click="save" />
</div> </div>
@@ -276,3 +277,15 @@ it('bounds a field to 40rem from 600px, unless the caller\'s width rule or full
->assertScript("({$width('narrow')}) === 200") ->assertScript("({$width('narrow')}) === 200")
->assertScript("({$width('full')}) === 1400"); ->assertScript("({$width('full')}) === 1400");
}); });
it('draws the field at M3\'s geometry and the file picker\'s button as a tonal pill', function () {
$box = fn (string $id): string => "document.querySelector('[data-md-field]:has(#{$id}) [data-md-field-box]').getBoundingClientRect()";
fieldProbe()
->assertScript("({$box('name-field')}).height === 56")
// The control starts after the box's 16px padding.
->assertScript("document.querySelector('#name-field').getBoundingClientRect().left - ({$box('name-field')}).left === 16")
->assertScript("getComputedStyle(document.querySelector('#upload-field'), '::file-selector-button').height === '32px'")
->assertScript("getComputedStyle(document.querySelector('#upload-field'), '::file-selector-button').borderTopLeftRadius !== '0px'")
->assertScript("getComputedStyle(document.querySelector('#upload-field')).color === 'rgba(0, 0, 0, 0)'");
});
+11
View File
@@ -151,6 +151,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-md-file')
->toContain('data-md-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.');
@@ -278,3 +279,13 @@ it('draws what a select changes in the field from its own stylesheet', function
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[') ->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/select.css';"); ->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/select.css';");
}); });
it('draws the file picker\'s button from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/file.css');
expect($css)->toContain("@import './field.css';")
->toContain("input[type='file'][data-md-field-control]::file-selector-button")
->toContain('background-color: var(--md-sys-color-secondary-container);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/file.css';");
});