End a select's long value before its arrow, with an ellipsis
Where the browser has the customizable select, select.css made the select `display: flex` with `text-overflow: ellipsis`, but the closed select draws its value in a block of its user-agent shadow tree: a flex item at its automatic minimum width that takes no `text-overflow` from the select, and no author style reaches it (`display: block` on the select does not help either). So a long option in a narrow field, or at `size="sm"` and `xs`, ran on past the end padding under the arrow, which `overflow: hidden` only clips at the select's edge. The native select in Firefox cut the value at the padding with no ellipsis. The view now gives the select a first child `<button type="button" wire:ignore>` holding `<selectedcontent>`, the customizable select's own copy of the chosen option, and select.css makes that the flex item that shrinks and ends in an ellipsis; the native select takes `nowrap`, `overflow: hidden` and the ellipsis on itself. `wire:ignore`, because a Livewire render parses the server's empty element into the first option's label and morphed it back over the chosen one. A browser without the customizable select never renders the button; `type` keeps it from being a form's default button. A browser test measures the value against the arrow in md, sm, xs, filled and icon selects 240px wide, and again after choosing another option re-renders the component; it fails without the change in Chrome and Firefox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c5a3870b7f
commit
1885c3e54e
@@ -60,6 +60,14 @@
|
||||
ran. A standard sheet is now out of the row until then, and one whose `wire:model` property is
|
||||
open is rendered open and stands at its width. One opened from an Alpine scope alone still
|
||||
appears when Alpine starts, which only the script can know.
|
||||
- **A long value in `<x-select>` ends before the arrow, with an ellipsis.** Where the browser has
|
||||
the customizable select (Chrome, Safari), the closed select drew its value in a box of its own
|
||||
that took neither `text-overflow` nor a width from the select, so a long option in a narrow
|
||||
field, or at `size="sm"` and `xs`, ran on under the arrow; the native select (Firefox) cut it at
|
||||
the arrow with no ellipsis. The select's first child is now a `<button type="button"
|
||||
wire:ignore>` holding `<selectedcontent>`, the browser's copy of the chosen option, which
|
||||
select.css cuts; a browser without the customizable select never renders it. A test that
|
||||
matched a select's markup up to its first `<option>` finds the button there.
|
||||
|
||||
## From 2.0.0 to 2.1.0
|
||||
|
||||
|
||||
@@ -671,7 +671,7 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire
|
||||
- `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one; `counter`.
|
||||
- Width: M3 asks that a text field never span the full width of a large screen, so from `medium` (600px) every field stops at **40rem**; below that it fills its pane. A width rule of your own on its `class` (`max-inline-size: 24rem`) narrows or widens it, because your unlayered CSS outranks the package's, and `full` (on `<x-field>`, `<x-input>`, `<x-textarea>`) takes the bound off for a field that really is the width of its pane — a search row, an editor. `<x-search>`'s bar carries M3's own bound, 720px.
|
||||
- `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`. A value too long for the field ends before the arrow with an ellipsis; the customizable select draws it in a `<button>` holding `<selectedcontent>`, which the view renders as the select's first child.
|
||||
- `<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 `data-md-field-control`; only for controls the package does not have.
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
* while the list is open, and the arrow turns over. The open list itself is M3's menu, drawn by
|
||||
* components/menu.css.
|
||||
*
|
||||
* A value too long for the field ends before the arrow with an ellipsis. The native select cuts its
|
||||
* own text at its end padding; the customizable one draws the value in a box of its shadow tree
|
||||
* that no author style reaches, so the view gives it a `<button>` holding `<selectedcontent>`, the
|
||||
* browser's copy of the chosen option, and the ellipsis is drawn there.
|
||||
*
|
||||
* The field's root carries `data-md-select`.
|
||||
*/
|
||||
|
||||
@@ -22,7 +27,9 @@
|
||||
/* 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
|
||||
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).
|
||||
A value longer than the field is cut at that padding, with an ellipsis, rather
|
||||
than running on under the arrow. */
|
||||
select[data-md-field-control] {
|
||||
--field-end: calc(var(--field-icon) + var(--field-gap) + var(--field-pad));
|
||||
|
||||
@@ -30,6 +37,9 @@
|
||||
cursor: pointer;
|
||||
margin-inline: calc(-1 * var(--field-start)) calc(-1 * var(--field-end));
|
||||
padding-inline: var(--field-start) var(--field-end);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
select[data-md-field-control]:disabled {
|
||||
@@ -52,6 +62,15 @@
|
||||
padding-block: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* The customizable select's value is `<selectedcontent>`, the only part of the
|
||||
closed select an author style reaches: the box the browser draws otherwise
|
||||
takes neither `text-overflow` nor a width from the select, and ran on under
|
||||
the arrow. As the select's flex item it may shrink to the room left. */
|
||||
select[data-md-field-control] selectedcontent {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
`option-value` and `option-label` to read other keys, and a `placeholder` that becomes the
|
||||
first option, valued `placeholder-value` (empty by default). Or pass `<option>`s in the slot.
|
||||
`label`, `hint`, `icon`, `variant`, `size`; `full` takes the 40rem bound off a field its container already bounds. `class` and `style` land on the field's root, every
|
||||
other attribute on the `<select>`. The field's root carries `data-md-select`; what a
|
||||
select changes in the field's chrome is resources/css/components/select.css. --}}
|
||||
other attribute on the `<select>`. A value too long for the field ends before the arrow with
|
||||
an ellipsis. The field's root carries `data-md-select`; what a select changes in the field's
|
||||
chrome is resources/css/components/select.css. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
@@ -46,6 +47,12 @@
|
||||
@if ($messages !== [] || filled($hint)) aria-describedby="{{ $id }}-support" @endif
|
||||
data-md-field-control
|
||||
>
|
||||
{{-- The customizable select draws the chosen option in `<selectedcontent>`, which select.css
|
||||
cuts with an ellipsis before the arrow; a browser without it never renders the button.
|
||||
`wire:ignore`, because the browser fills it: a render would put back the first option's
|
||||
label, parsed from the server's HTML, or nothing. --}}
|
||||
<button type="button" wire:ignore><selectedcontent></selectedcontent></button>
|
||||
|
||||
@if (filled($placeholder))
|
||||
<option value="{{ $placeholderValue }}">{{ $placeholder }}</option>
|
||||
@endif
|
||||
|
||||
@@ -168,6 +168,83 @@ it('opens the customizable select picker as M3\'s menu, where the browser suppor
|
||||
->assertScript("parseFloat(getComputedStyle({$option}).minBlockSize) === 48");
|
||||
});
|
||||
|
||||
class SelectValueProbe extends Component
|
||||
{
|
||||
public string $format = 'dots';
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div style="display: grid; width: 240px; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
|
||||
<p>format: <span id="format">{{ $format }}</span></p>
|
||||
|
||||
@php($formats = [['id' => 'dots', 'name' => 'Day first, dots — 17.08.2026'], ['id' => 'slashes', 'name' => 'Month first, slashes — 08/17/2026']])
|
||||
|
||||
<x-select id="format-md" label="Date format" wire:model.live="format" :options="$formats" />
|
||||
<x-select id="format-sm" size="sm" aria-label="Date format" :options="$formats" />
|
||||
<x-select id="format-xs" size="xs" aria-label="Date format" :options="$formats" />
|
||||
<x-select id="format-filled" variant="filled" label="Date format" :options="$formats" />
|
||||
<x-select id="format-icon" icon="calendar_today" label="Date format" :options="$formats" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a select's value ends against its arrow, and how it is cut: `[ends before the arrow,
|
||||
* text-overflow, white-space, overflow, the value's text]`. The customizable select draws the value
|
||||
* in its `<selectedcontent>`; the native one in the select's own box, less its end padding.
|
||||
*/
|
||||
function selectValue(string $id): string
|
||||
{
|
||||
return "(() => {
|
||||
const select = document.getElementById('{$id}');
|
||||
const arrow = select.closest('[data-md-field]').querySelector('[data-md-field-arrow]').getBoundingClientRect();
|
||||
const value = CSS.supports('appearance', 'base-select') ? select.querySelector('selectedcontent') : select;
|
||||
|
||||
if (! value) {
|
||||
return 'nothing draws the value';
|
||||
}
|
||||
|
||||
const style = getComputedStyle(value);
|
||||
const end = value.getBoundingClientRect().right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth);
|
||||
|
||||
return [end <= arrow.left, style.textOverflow, style.whiteSpace, style.overflowX, (value === select ? select.selectedOptions[0] : value).textContent.trim()];
|
||||
})()";
|
||||
}
|
||||
|
||||
it('ends a select\'s value before its arrow with an ellipsis, at every size and after a render', function () {
|
||||
Livewire::component('select-value-probe', SelectValueProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/select-value-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body style="background-color: var(--md-sys-color-surface);">
|
||||
<livewire:select-value-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
$page = visit('/select-value-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
|
||||
foreach (['format-md', 'format-sm', 'format-xs', 'format-filled', 'format-icon'] as $id) {
|
||||
expect($page->script(selectValue($id)))->toBe([true, 'ellipsis', 'nowrap', 'hidden', 'Day first, dots — 17.08.2026']);
|
||||
}
|
||||
|
||||
// The customizable select copies the chosen option into its <selectedcontent>, which a
|
||||
// Livewire render must not empty again.
|
||||
$page->select('#format-md', 'slashes')->assertSeeIn('#format', 'slashes');
|
||||
|
||||
expect($page->script(selectValue('format-md')))->toBe([true, 'ellipsis', 'nowrap', 'hidden', 'Month first, slashes — 08/17/2026']);
|
||||
});
|
||||
|
||||
it('keeps a partly ticked checkbox in step with the server', function () {
|
||||
$all = "document.querySelector('#all')";
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ it('draws a native select whose label always floats', function () {
|
||||
->toContain('data-md-select')
|
||||
->toMatch('/<select(?=[^>]*data-md-field-control)[^>]*>/')
|
||||
->toContain('<select')
|
||||
// The customizable select's value, cut with an ellipsis before the arrow; the browser fills it.
|
||||
->toContain('<button type="button" wire:ignore><selectedcontent></selectedcontent></button>')
|
||||
->toContain('wire:model="hours"')
|
||||
->toContain('<option value="">Choose…</option>')
|
||||
->toContain('<option value="1" >1 hour</option>')
|
||||
|
||||
Reference in New Issue
Block a user