From 3083fe28b221e0ed3cad56fa845a3ba1cf06512b Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:12:40 +0200 Subject: [PATCH] Move the file picker's rules out of the field into file.css 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/field.css | 43 ---------------- resources/css/components/file.css | 62 +++++++++++++++++++++++ resources/views/components/file.blade.php | 5 +- tests/Browser/FieldsTest.php | 13 +++++ tests/Feature/Components/FieldTest.php | 11 ++++ 6 files changed, 90 insertions(+), 45 deletions(-) create mode 100644 resources/css/components/file.css diff --git a/resources/css/components.css b/resources/css/components.css index e185a2bf..16372094 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -20,6 +20,7 @@ @import './components/password.css'; @import './components/textarea.css'; @import './components/select.css'; +@import './components/file.css'; /* Containment */ diff --git a/resources/css/components/field.css b/resources/css/components/field.css index dc54b777..99d1212f 100644 --- a/resources/css/components/field.css +++ b/resources/css/components/field.css @@ -138,40 +138,6 @@ transition: background-color 604800s, color 604800s; } - /* ``: 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. */ [data-md-field]:has([data-md-field-control][aria-expanded='true']) [data-md-field-arrow] { rotate: 180deg; @@ -474,15 +440,6 @@ 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] { align-self: stretch; padding-block: var(--md-sys-measurement-space300) var(--md-sys-measurement-space100); diff --git a/resources/css/components/file.css b/resources/css/components/file.css new file mode 100644 index 00000000..728ce503 --- /dev/null +++ b/resources/css/components/file.css @@ -0,0 +1,62 @@ +/* + * : 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 { + /* ``: 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; + } +} diff --git a/resources/views/components/file.blade.php b/resources/views/components/file.blade.php index 44c323cf..a930e6f2 100644 --- a/resources/views/components/file.blade.php +++ b/resources/views/components/file.blade.php @@ -4,7 +4,8 @@ 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 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: it cannot be truncated or wrapped, and the caller shows what was chosen anyway, as previews. A @@ -28,7 +29,7 @@ : []; @endphp - + except(['class', 'id', 'type']) }} type="file" diff --git a/tests/Browser/FieldsTest.php b/tests/Browser/FieldsTest.php index 6532f1f6..9eb751b2 100644 --- a/tests/Browser/FieldsTest.php +++ b/tests/Browser/FieldsTest.php @@ -50,6 +50,7 @@ class FieldProbe extends Component + @@ -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('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)'"); +}); diff --git a/tests/Feature/Components/FieldTest.php b/tests/Feature/Components/FieldTest.php index d0abd905..757f5d4f 100644 --- a/tests/Feature/Components/FieldTest.php +++ b/tests/Feature/Components/FieldTest.php @@ -151,6 +151,7 @@ it('collects a file field\'s own errors and each file\'s', function () { expect($html) ->toContain('type="file"') ->toContain('multiple') + ->toContain('data-md-file') ->toContain('data-md-floated') ->toContain('Choose at most 10 photos.') ->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.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';"); +});