diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 8b9d8099..11745975 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -671,7 +671,7 @@ Bind with `wire:model` (entangled) or, without Livewire, `x-model`. The options ### `` -M3 search bar that opens into a search view: docked under the bar from `medium` (600px) over a scrim, full screen with a back arrow on a compact window (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. The results are a list and a live region says how many there are. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`; `trailing` slot (avatar, icon buttons). +M3 search bar that opens into a search view: docked under the bar from `medium` (600px) over a scrim, full screen with a back arrow on a compact window (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. The results are a list and a live region says how many there are. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`, `trigger`; `trailing` slot (avatar, icon buttons) and `suggestions` slot. ```blade @@ -684,6 +684,22 @@ M3 search bar that opens into a search view: docked under the bar from `medium` The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it. The bar is never wider than M3's 720px and grows to that width while it is focused; for M3's 360px resting bar, wrap it in an element carrying `style="--search-width: 22.5rem"`. +- `trigger="icon"` is M3's other entry point — search as a secondary action: one 48px search icon button that expands into the full-screen view at any width (so `docked` does not apply) and gives the button its focus back on close. Put it in a toolbar or an app bar row where a bar would not fit. +- The `suggestions` slot is shown in the view until the first keystroke — recent or popular searches — and the results slot takes over once something is typed. The live region counts whichever list is on screen and names suggestions as such. + +```blade + + + @foreach ($this->recent as $term) + + @endforeach + + @foreach ($this->results as $share) + + @endforeach + +``` + ### `` The adaptive app shell, a whole layout's body: one navigation per M3 window size class, the page as `
` behind a skip link, and the snackbar host (do not add another ``). It needs `` in ``. diff --git a/resources/css/components/search.css b/resources/css/components/search.css index 4c81443a..97eb04d5 100644 --- a/resources/css/components/search.css +++ b/resources/css/components/search.css @@ -15,14 +15,16 @@ * `--search-width: 22.5rem` on a wrapper. The leading and trailing padding is 24px unfocused and * 16px focused, as the search specs table gives it. * - * [data-search] the root; data-open, data-full-screen - * [data-search-scrim] over the page while the view is docked - * [data-search-bar] the pill, above the view + * [data-search] the root; data-open, data-full-screen, data-trigger + * [data-search-scrim] over the page while the view is docked + * [data-search-trigger] data-trigger="icon" only: the icon button that expands the view + * [data-search-bar] the pill, above the view * [data-search-leading], [data-search-field] (the combobox around [data-search-input]), * [data-search-clear], [data-search-trailing] - * [data-search-view] the container behind the bar - * [data-search-results] - * [data-search-status] the polite live region that counts the results + * [data-search-view] the container behind the bar + * [data-search-suggestions] before the first keystroke + * [data-search-results] once something is typed + * [data-search-status] the polite live region that counts whichever list is on screen */ @layer components { @@ -204,19 +206,62 @@ /* M3: the docked container is at least 240px tall — once there is something in it to be tall about; a search with nothing to show stays the height of its bar. */ - [data-search-view]:has([data-search-results]) { + [data-search-view]:has([data-search-results], [data-search-suggestions]) { min-height: 15rem; } /* No divider: that belongs to the divided style, which Expressive deprecates in favour of the - contained one this file draws. */ - [data-search-results] { + contained one this file draws. The suggestions stand in the same place, before the first + keystroke; only one of the two is ever on screen. */ + [data-search-results], + [data-search-suggestions] { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-block: 0.5rem; } + /* M3's second entry point: search as a secondary action, one icon button that expands into the + full-screen view. The root keeps the button's 48px whether the view is open or not, so a + toolbar does not shift under it, and the bar is the view's header rather than a resting bar. */ + [data-search][data-trigger="icon"] { + flex: none; + width: 3rem; + height: 3rem; + } + + [data-search-trigger] { + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + border-radius: var(--md-sys-shape-corner-full); + color: var(--md-sys-color-on-surface-variant); + cursor: pointer; + outline: none; + transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); + } + + @media (hover: hover) { + [data-search-trigger]:hover { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 8%, transparent); + } + } + + [data-search-trigger]:focus-visible { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent); + outline: 3px solid var(--md-sys-color-secondary); + outline-offset: -3px; + } + + [data-search-trigger]:active { + background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) 10%, transparent); + } + + [data-search][data-trigger="icon"]:not([data-open]) [data-search-bar] { + display: none; + } + /* Full screen, on a compact window. */ [data-search][data-full-screen] [data-search-bar] { position: fixed; diff --git a/resources/js/search.js b/resources/js/search.js index 7342cb5a..fb59822e 100644 --- a/resources/js/search.js +++ b/resources/js/search.js @@ -11,7 +11,12 @@ * Because the results arrive from the server, nothing in the page tells a screen reader they are * there; M3 asks that it be told. A MutationObserver counts the list items whenever the view's DOM * settles and writes "N results" into the polite live region the view renders, which is the one - * announcement M3's search accessibility page names. + * announcement M3's search accessibility page names. With a `suggestions` slot, whichever of the + * two lists is on screen is the one counted, and the suggestions are named as such. + * + * `trigger` is M3's entry point: the bar itself, or `icon` — a single search icon button that + * expands into the full-screen view wherever the window is wide enough to dock, because an icon + * button has nowhere to dock under. */ import { upTo } from './breakpoints.js' @@ -29,22 +34,25 @@ const RETURN_GUARD_MS = 250 const SETTLE_MS = 120 document.addEventListener('alpine:init', () => { - window.Alpine.data('materialSearch', (docked = false, announce = {}) => ({ + window.Alpine.data('materialSearch', (docked = false, announce = {}, trigger = 'bar') => ({ open: false, compact: false, closedAt: -Infinity, announcement: '', + // What is in the field, which is what tells suggestions from results. + query: '', observer: null, settle: null, init() { - const query = upTo('medium') + const media = upTo('medium') - this.compact = query.matches - query.addEventListener('change', (event) => (this.compact = event.matches)) + this.compact = media.matches + media.addEventListener('change', (event) => (this.compact = event.matches)) // Alpine registers x-ref as it walks the children, which is after this runs. this.$nextTick(() => { + this.query = this.$refs.input?.value ?? '' this.observer = new MutationObserver(() => this.countLater()) this.observer.observe(this.$refs.view, { childList: true, subtree: true, characterData: true }) }) @@ -69,25 +77,41 @@ document.addEventListener('alpine:init', () => { return } - const results = this.$refs.view.querySelector('[data-search-results]') - const items = results ? results.querySelectorAll('[role="listitem"], li') : [] - const total = items.length || (results ? this.results().length : 0) + // Suggestions and results never show together; count whichever one is on screen. + const list = [...this.$refs.view.querySelectorAll('[data-search-results], [data-search-suggestions]')] + .find((element) => element.getClientRects().length > 0) + const items = list ? list.querySelectorAll('[role="listitem"], li') : [] + const total = items.length || (list ? this.results().length : 0) + const suggesting = Boolean(list?.hasAttribute('data-search-suggestions')) this.announcement = total === 0 ? (announce.none ?? '') : total === 1 - ? (announce.one ?? '') - : (announce.many ?? '').replace(':count', total) + ? ((suggesting ? announce.suggestionOne : announce.one) ?? '') + : ((suggesting ? announce.suggestionMany : announce.many) ?? '').replace(':count', total) }, get fullScreen() { - return this.open && this.compact && !docked + // An icon button has nothing to dock under, so M3's icon entry point always expands. + return this.open && (trigger === 'icon' || (this.compact && !docked)) }, show() { this.open = true }, + /** M3's search-icon entry point: the button opens the view and hands over focus. */ + expand() { + this.show() + this.$nextTick(() => requestAnimationFrame(() => this.$refs.input?.focus())) + }, + + /** Every keystroke: the view opens, and the query decides suggestions or results. */ + typed(event) { + this.query = event.target.value + this.show() + }, + focused() { if (performance.now() - this.closedAt > RETURN_GUARD_MS) { this.show() @@ -99,7 +123,11 @@ document.addEventListener('alpine:init', () => { this.closedAt = performance.now() if (refocus) { - this.$refs.input.focus() + // Back to whatever opened the view: the icon button, or the field itself. A tick + // later, because the icon button is only on screen again once the view has closed. + const back = this.$refs.trigger ?? this.$refs.input + + this.$nextTick(() => back.focus()) } }, @@ -107,6 +135,7 @@ document.addEventListener('alpine:init', () => { const input = this.$refs.input input.value = '' + this.query = '' input.dispatchEvent(new Event('input', { bubbles: true })) input.focus() }, diff --git a/resources/views/components/search.blade.php b/resources/views/components/search.blade.php index 10605dc1..152fab5f 100644 --- a/resources/views/components/search.blade.php +++ b/resources/views/components/search.blade.php @@ -13,6 +13,15 @@ a `trailing` slot for an avatar or icon buttons in the bar. Every other attribute reaches the ``. + `trigger="icon"` is M3's second entry point, the search icon button: search as a secondary + action in a toolbar or an app bar, a single 48px button that expands into the full-screen view + and hands focus to the field (so `docked` has nothing to say about it). The bar itself is the + view's header there, and closing gives the button its focus back. + + `suggestions` is a slot shown in the view until the first keystroke — M3's suggestions + behaviour: recent searches, popular queries. The results slot takes over once something is + typed, and whichever of the two is on screen is what the live region counts. + The bar is a combobox: the wrapper around the input carries `role="combobox"` with `aria-expanded` and `aria-controls`, because ARIA gives a bare textbox neither. The results are a list, and a polite live region says how many of them there are whenever they change — M3 asks @@ -23,22 +32,26 @@ 'label' => null, 'icon' => 'search', 'docked' => false, + 'trigger' => 'bar', ]) @php $model = $attributes->wire('model')->value() ?: null; $placeholder ??= __('Search'); $id = $attributes->get('id') ?? 'material-search-'.substr(md5($model.'|'.$placeholder), 0, 10); + $trigger = $trigger === 'icon' ? 'icon' : 'bar'; $announce = [ 'none' => __('No results'), 'one' => __('1 result'), 'many' => __(':count results'), + 'suggestionOne' => __('1 suggestion'), + 'suggestionMany' => __(':count suggestions'), ]; @endphp
only(['class', 'wire:key'])->class(['relative']) }} > + @if ($trigger === 'icon') + + @endif +
- @@ -78,7 +109,7 @@ aria-label="{{ $label ?? $placeholder }}" x-on:focus="focused()" x-on:click="show()" - x-on:input="show()" + x-on:input="typed($event)" x-on:keydown.arrow-down.prevent="show(); $nextTick(() => step(1))" data-search-input /> @@ -103,10 +134,14 @@ x-on:keydown.arrow-up.prevent="step(-1)" x-on:click="choose($event)" > + @isset($suggestions) +
{{ $suggestions }}
+ @endisset + @if ($slot->hasActualContent()) -
{{ $slot }}
+
{{ $slot }}
@elseif (isset($empty)) -

{{ $empty }}

+

{{ $empty }}

@endif
diff --git a/resources/views/showcase/sections/fields.blade.php b/resources/views/showcase/sections/fields.blade.php index 0ae1bbed..717cb59e 100644 --- a/resources/views/showcase/sections/fields.blade.php +++ b/resources/views/showcase/sections/fields.blade.php @@ -169,6 +169,40 @@
BLADE, + 'Search entry points and suggestions' => <<<'BLADE' +
+
+ + + + + + + + + + + + + + +
+ + {{-- M3's search icon button: search as a secondary action, expanding into the full-screen view. --}} +
+ + Shares + + + + + + + No shares match. + +
+
+ BLADE, 'A form' => <<<'BLADE' diff --git a/tests/Feature/Components/SearchTest.php b/tests/Feature/Components/SearchTest.php index 79a642a5..b7b76409 100644 --- a/tests/Feature/Components/SearchTest.php +++ b/tests/Feature/Components/SearchTest.php @@ -47,6 +47,50 @@ it('shows the results as a list, or what to say when there are none', function ( ->not->toContain('data-search-results role="list"'); }); +it('offers M3\'s search-icon entry point', function () { + $html = (string) $this->blade(''); + + expect($html) + ->toContain('data-trigger="icon"') + ->toContain('data-search-trigger') + ->toContain('x-ref="trigger"') + ->toContain('x-on:click="expand()"') + ->toContain('aria-label="Search shares"') + ->toContain('aria-haspopup="dialog"') + ->toContain('aria-controls="find-view"') + ->toContain('materialSearch(false,') + ->toContain("'icon'") + // The bar is still there: it becomes the expanded view's header. + ->toContain('data-search-bar'); + + expect((string) $this->blade('')) + ->toContain('data-trigger="bar"') + ->not->toContain('data-search-trigger'); +}); + +it('shows suggestions until the first keystroke, then the results', function () { + $html = (string) $this->blade(<<<'BLADE' + + holiday.zip + Recent shares + + BLADE); + + expect($html) + ->toContain('data-search-suggestions role="list"') + ->toContain('x-show="query === \'\'"') + ->toContain('Recent shares') + ->toContain('x-show="query !== \'\'"') + ->toContain('holiday.zip') + ->toContain('1 suggestion') + ->toContain(':count suggestions'); + + // Without the slot the results stand alone and are never hidden. + expect((string) $this->blade('holiday.zip')) + ->not->toContain('data-search-suggestions') + ->not->toContain('x-show="query !== \'\'"'); +}); + it('names the input, trails the bar, and stays docked on request', function () { $html = (string) $this->blade('');