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:
Andreas Reinhold / reini
2026-09-17 06:50:53 +02:00
co-authored by Claude Opus 5
parent c5a3870b7f
commit 1885c3e54e
6 changed files with 117 additions and 4 deletions
+8
View File
@@ -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.
+20 -1
View File
@@ -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;
+9 -2
View File
@@ -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
+77
View File
@@ -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')";
+2
View File
@@ -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>')