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
+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')";