Say how many search results there are, in a combobox

M3's search accessibility page asks that results be announced when they
appear and read as a list. The bar now wraps its input in a
role="combobox" that carries aria-expanded and aria-controls — ARIA gives
a bare textbox neither — the results container is a role="list", and a
polite live region counts the results whenever the view's DOM settles.

Plan step 13, finding IN-01.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 05:50:15 +02:00
co-authored by Claude Fable 5.1
parent 0adfb6fff4
commit 31663c48ba
4 changed files with 120 additions and 27 deletions
+25 -6
View File
@@ -1,6 +1,6 @@
<?php
it('draws a search bar whose input controls the view', function () {
it('draws a search bar whose combobox controls the view', function () {
$html = (string) $this->blade('<x-search id="find" placeholder="Search shares" wire:model.live.debounce.300ms="query" />');
expect($html)
@@ -9,22 +9,41 @@ it('draws a search bar whose input controls the view', function () {
->toContain('type="search"')
->toContain('placeholder="Search shares"')
->toContain('aria-label="Search shares"')
->toContain('role="combobox"')
->toContain('aria-haspopup="dialog"')
->toContain('aria-controls="find-view"')
->toContain('aria-expanded="false"')
->toContain('wire:model.live.debounce.300ms="query"')
->toContain('id="find-view"')
->toContain('data-search-clear')
->toContain('aria-label="Back"')
->toContain('materialSearch(false)')
->toContain('materialSearch(false,')
->not->toContain('data-search-results');
// The state belongs to the combobox, never to the bare textbox ARIA gives it to.
expect($html)->toMatch('/<span\s+data-search-field[^>]*aria-expanded/s');
expect($html)->not->toMatch('/<input[^>]*aria-expanded/s');
});
it('shows the results, or what to say when there are none', function () {
it('announces how many results there are, politely', function () {
$html = (string) $this->blade('<x-search />');
expect($html)
->toContain('data-search-status')
->toContain('aria-live="polite"')
->toContain('aria-atomic="true"')
->toContain('No results')
->toContain('1 result')
->toContain(':count results');
});
it('shows the results as a list, or what to say when there are none', function () {
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
->toContain('data-search-results')
->toContain('data-search-results role="list"')
->toContain('<a href="/shares/1">holiday.zip</a>')
->and((string) $this->blade('<x-search><x-slot:empty>No shares match.</x-slot:empty></x-search>'))
->toContain('No shares match.');
->toContain('No shares match.')
->not->toContain('data-search-results role="list"');
});
it('names the input, trails the bar, and stays docked on request', function () {
@@ -35,5 +54,5 @@ it('names the input, trails the bar, and stays docked on request', function () {
->toContain('aria-label="Search your shares"')
->toContain('data-search-trailing')
->toContain('<button>AR</button>')
->toContain('materialSearch(true)');
->toContain('materialSearch(true,');
});