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:
co-authored by
Claude Opus 5
parent
23f2bdabf9
commit
3083fe28b2
@@ -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 */
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)'");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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';");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user