Scope Switcher
The control in a breadcrumb that changes which thing the page is about — this team, this server, this site. It shows the current one, and opening it offers the others with a search field above them and a way to create a new one below.
It is navigation, not a form field. Choosing an entry goes to a page; nothing is submitted, and there is no hidden input. That distinction is what decides its whole shape, and it is why the combobox and the dropdown are the wrong starting points — see Why not a combobox or a dropdown.
Usage
The name is required and must be stable. Every option's DOM id is built from it, and the
search field points at those ids through aria-activedescendant — a name that changes
between renders quietly severs the link between the field and the row it says is active.
Item shape
Only key, label and url are required.
| Key | Type | Meaning |
|---|---|---|
key |
string |
Stable and unique. Becomes part of the option's DOM id |
label |
string |
What the reader sees and searches |
url |
string |
Where choosing it goes |
icon |
string |
An icon alias |
image |
string |
A URL — an avatar or favicon. Wins over icon |
status |
string |
success, warning, danger or neutral — a dot on the icon |
meta |
string |
Secondary text, aligned to the trailing edge |
keywords |
array |
Extra words the search should match, never displayed |
group |
string |
A heading to file this entry under |
A duplicate key, an unknown status or a missing name throws rather than rendering
something subtly wrong.
Status, metadata and groups
A group whose entries are all filtered out hides its heading with them — a heading over nothing reads as a bug.
A status renders as a dot on the entry's icon, and a dot is not readable — so each one also
puts its meaning into the row as text only a screen reader sees. The four words are
translatable like every other string this library emits:
status |
Announced |
|---|---|
success |
Healthy |
warning |
Needs attention |
danger |
Failing |
neutral |
Inactive |
Pass anything else and the entry renders without a dot, with the reason in the log when
APP_DEBUG is on — a status nobody can see is worse than none.
In a breadcrumb
Each level is its own switcher, and the entries of one usually depend on the choice in the one before it.
Searching
Typing filters the list and never reorders it — the order the server sent is the order the reader sees, before and after. Every word typed must match, so adding one narrows the result rather than widening it.
It tolerates a typo. Each word is tried as a substring first, then as a subsequence — which
forgives a dropped letter, so wrker still finds worker-01 — and finally against each part
of the entry within one edit, or two once the word is long enough to afford it. A swapped
pair of letters counts as one edit rather than two, because that is the commonest way a
hostname typed from memory comes out wrong.
Accents are folded on both sides, so munchen finds München. keywords join the
searchable text without appearing in the row, which is where an internal name or an IP
address belongs.
The filtering happens in the browser, which suits the few hundred entries a breadcrumb level
normally has. Past client_filter_max the component notes in the debug log that it is
shipping the whole list to search a fraction of it.
Why not a combobox or a dropdown
Both are close, and both are wrong for a reason worth knowing.
A combobox is a form field. Its trigger is the text input, and its value travels in a hidden input to be submitted. Here the value is the page you are on.
A dropdown implements the WAI-ARIA menu pattern. A text input inside
role="menu" breaks the owned-elements rule — a menu may contain menu items, and an input is
not one — so a menu cannot hold a search field. It offers type-ahead instead, which jumps but
does not filter.
So this composes the two halves directly: a popover for the anchored panel
and focus handling, holding a role="listbox" of links with a search field above them.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
name |
string |
— | Required. Stable id base |
label |
string |
'' |
What is being switched. Names the control and the dialog |
items |
array |
[] |
The entries — see the shape above |
current |
string|null |
null |
key of the entry the page is showing |
create |
array|null |
null |
['label' => …, 'url' => …, 'icon' => …] |
searchPlaceholder |
string|null |
derived | Placeholder and accessible name of the field |
emptyText |
string|null |
No results. |
Shown when nothing matches |
placement |
string|null |
bottom-start |
Where the panel opens |
width |
string|null |
20rem |
Panel width |
listMaxHeight |
string|null |
22rem |
Height at which the list starts scrolling |
Defaults come from config/wirekit.php under components.scope-switcher.
Accessibility
- The trigger takes
aria-haspopup="dialog",aria-expandedandaria-controlsfrom the popover, and is named after what it switches. - The panel is a
role="dialog"named "Switch - The search field is a
role="combobox"that controls the listbox and publishes the active row witharia-activedescendant. aria-selected="true"marks the current scope only — not the keyboard's position. Those are two different states: one is where the page is, the other is where the reader is looking. The keyboard's position usesdata-activefor its highlight.- A live region announces the number of matches, throttled so a fast typist is not read out letter by letter.
- The create action sits outside the listbox. It is an action, not one of the things
being chosen between, so it is reached with
Tabwhile the arrow keys stay in the list.
Keyboard Interaction
Focus stays in the search field the whole time. The rows are never focused; the active one is
published through aria-activedescendant, which is what lets you type and steer at once.
| Key | Action |
|---|---|
Enter / Space on the trigger |
Open, focus the search field, start on the current entry |
| Any text | Filter; the first match becomes active |
ArrowDown / ArrowUp |
Move between visible entries. No wrap |
Home / End |
First / last visible entry |
Enter |
Go to the active entry. On the current one, just close |
Tab |
Move to the create action, if there is one |
Escape |
Close and return focus to the trigger |
Choosing the entry you are already on closes without navigating. A reader who opens the switcher and picks the current scope has changed their mind, and a page load to arrive where they already are says nothing.
Design Tokens
| Token | Used for |
|---|---|
--color-wk-bg-subtle |
Row hover |
--color-wk-bg-muted |
The keyboard's active row |
--color-wk-accent |
The check on the current entry |
--color-wk-border |
Header and footer separators |
--color-wk-text-muted |
Placeholder, metadata, group headings |
Config Defaults
Every prop below falls back to config/wirekit.php, so an application sets the shape once
rather than on each switcher:
// config/wirekit.php
return [
'components' => [
'scope-switcher' => [
// 1. Where the panel opens relative to the trigger.
'placement' => 'bottom-start',
// 2. Distance in px between trigger and panel.
'offset' => 8,
// 3. Panel width. It never exceeds the viewport — the panel caps itself at
// calc(100vw - 2rem), so this is a preference and not a risk on a phone.
'width' => '20rem',
// 4. How tall the list grows before it scrolls.
'list_max_height' => '22rem',
// 5. Warm the target page on hover, so choosing feels instant.
'prefetch_on_hover' => true,
// 6. Above this many entries, the debug log notes that the whole list is being
// shipped to the browser to search a fraction of it. Nothing breaks; it is a
// nudge toward a server-side search at a size a breadcrumb rarely reaches.
'client_filter_max' => 300,
],
],
];
See Also
- Popover — the panel this is built on
- Breadcrumb — where a scope switcher usually lives
- Command Palette — for searching everything at once, not one level