Overflow Nav
<x-wirekit:: shows a row of links that wraps onto at most lines rows, two by default. The links the rows have no room for move into a menu at the end of the last row, behind a button that shows how many it holds. The link marked current never moves into the menu, so the page on screen is always in the rows.
It suits a row of open records, recently viewed pages or filters, where the number of links changes and the width of the screen decides how many fit.
Usage
Pass the links as items. Each one takes a label and an href, and at most one should be current.
Only http, https, mailto, tel and relative addresses become links. An href with any other
scheme, such as javascript:, is dropped, and that entry is drawn without a link.
Lines
lines sets how many rows the links may take. With lines="1" the row never wraps, and everything that does not fit on one line goes into the menu.
Links that navigate with Livewire
Give an item attributes for anything its anchor should carry, such as wire:navigate. The same attributes go on the link in the rows and on its copy in the menu. Write them yourself and never fill them from data: they reach the element as written, and so do the attributes of an action. The one exception is an href among them, which is left out, so the item's own href is the only target the link has.
<x-wirekit::overflow-nav label="Open records" :items="$records->map(fn ($record) => [
'label' => $record->name,
'href' => route('records.show', $record),
'current' => $record->is($current),
'attributes' => ['wire:navigate' => true],
])->all()" />
An action beside an entry
Give an item an action to draw a button beside its link, such as the close button of a tab bar of
open records. The button is a sibling of the link, never inside it, and it goes with its entry into
the menu. label names it and is required, icon defaults to close, and attributes go on the
button, such as the wire:click that closes the record. An entry without an action has no button,
like the first tab below.
<x-wirekit::overflow-nav label="Open tabs" :items="$tabs->map(fn ($tab) => [
'label' => $tab->title,
'href' => $tab->url,
'key' => $tab->id,
'current' => $tab->is($current),
'action' => ['label' => __('Close :title', ['title' => $tab->title]), 'attributes' => ['wire:click' => 'closeTab('.$tab->id.')']],
])->all()" />
An icon and a mark in an entry
Give an item an icon to draw it before the label, and after for what follows the label: a dot
for unsaved changes, say, or the avatar of someone else who has the record open. Both appear in the
rows and in the menu, and the row counts them when it measures what fits.
after takes a string, which renders as text, or markup as an Htmlable: an HtmlString, a
view(), or a component you render with Blade::render(). The same value goes into the rows and
into the menu, so markup that carries an id, such as a tooltip, would carry it twice. Give that
markup as a closure instead. It is called once for the rows and once for the menu, with 'row' or
'menu', and each call renders its own ids.
<x-wirekit::overflow-nav label="Open records" :items="$tabs->map(fn ($tab) => [
'label' => $tab->title,
'href' => $tab->url,
'icon' => $tab->icon,
'after' => fn (string $place) => view('tabs.presence', ['tab' => $tab, 'place' => $place]),
])->all()" />
The icon is hidden from assistive technology, because the label already names the link. The
markup after the label sits inside the link, so its text joins the link's name: say in words what a
visual mark means, as the status dot above does with its label, and hide a
purely visual part with aria-hidden. A dot with a tooltip inside a link takes :focusable="false",
because the link is the tab stop. Keep controls out of it, since a control inside a link is two controls in one;
a button belongs in action. Alpine expressions in the markup, such as x-show="dirty", run in the
scope of your component around the nav.
An entry as a tab
Each entry in the rows is a list item around its link and its action, and the item block holds its
classes. It carries none by default, and an entry with an action sets the link and the button in a
line. Personalize the block to frame the two as one, the way a tab bar of open records draws each tab
around its label and its close button. The current entry carries data-wk-overflow-current, so a
data-[wk-overflow-current]: variant in the block reaches it, and group in the block lets the
action answer the pointer anywhere on the tab:
WireKit::personalize('overflow-nav', [
// 1. $vendor is the block as WireKit ships it: empty, or the line layout of an entry with an action.
// 2. A frame around the link and its button, and the accent on the top edge of the current tab.
'item' => fn (string $vendor): string => $vendor.' group rounded-[var(--radius-wk-md)] border-[length:var(--border-wk-width)] border-[var(--color-wk-border)] data-[wk-overflow-current]:border-t-[var(--color-wk-accent)]',
]);
The block styles the entries in the rows; the menu lays its entries out on its own. The current
link's weight is --font-wk-selected-weight, so a theme that marks a choice without bold sets that
token rather than a link block of its own.
The order of the menu
The menu lists its entries in the order of the rows unless menu-order says otherwise. It takes a
list of entry keys: an item's key, or its position in items when it has none. Which entries move
into the menu is still decided by the width; menu-order only orders them there, and entries it does
not name follow in the order of the rows. A tab bar that opens records in one order and lists the
menu by last use passes both:
<x-wirekit::overflow-nav label="Open tabs" :items="$tabs" :menu-order="$recentlyUsedKeys" />
An entry that stays, and a divider
The current entry never moves into the menu. Give any other entry pinned and it stays in the rows as well, at every
width, such as the list the open records come from. divider draws a line after an entry, which sets it apart from
the entries that follow without spending a color on it. The row measures an entry together with its line, and the
menu is a plain list, so an entry that moves there leaves its line behind:
<x-wirekit::overflow-nav label="Open records" :items="[
['label' => 'All orders', 'href' => route('orders.index'), 'pinned' => true, 'divider' => true],
...$openOrders,
]" />
Naming the button and the menu
The button counts the menu ("3 more links") and the menu is named "More links". Where the entries are something more
particular, say so with more-label and menu-label. more-label writes the count as :count and the singular and
the plural as one|many, the way a translation writes them, and takes a translation key as well:
<x-wirekit::overflow-nav
label="Open records"
:items="$records"
more-label=":count more open record|:count more open records"
menu-label="More open records"
/>
How it decides what fits
The component lays every link out once, reads its width and replays the wrapping of the row in arithmetic. Links come off the end one at a time until the rest and the button fit in lines rows, so every row is full before anything moves into the menu. When only the current link and the pinned ones would be left to move, nothing more moves, and the rows may then take one line more than lines.
Three details keep that measurement steady, and they matter if you build something like it yourself:
- It measures in one task. Showing every link through component state and measuring on the next tick lets the browser draw a frame in between; the row grows and shrinks, the resize observer sees it, and the row measures itself again on every frame. The component shows the links by hand, reads the widths and restores the styles before it sets any state.
x-showshows on the next frame. It hides at once but shows one animation frame later, so a link shown through state and measured right after still reads as hidden. The measurement reads the inline styles it set itself.- Only the width asks for a new measurement. The height changes every time a link moves into the menu, so the component measures again when the row's width changes or when links are added or removed, and never for a change of height.
Without JavaScript every link is shown, in as many rows as it takes, and there is no button.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items |
array |
[] |
The links, in order. Each takes label, href, and optionally current (the page on screen, never moved into the menu), attributes (extra attributes for the anchor), key (what menu-order names it by), icon and after (an icon before the label and markup after it — see An icon and a mark in an entry), action (a button beside the link: label, icon, attributes) — see An action beside an entry — and pinned and divider (an entry that stays in the rows, and a line after it — see An entry that stays, and a divider) |
lines |
int |
2 |
How many rows the links may take before the rest move into the menu |
label |
string|null |
null |
The landmark's name. With a label the row is a <nav>; without one it is a plain list, so two rows on a page never share a nameless landmark |
menuOrder |
array|null |
null |
The order of the menu, as a list of entry keys; entries it does not name follow in the order of the rows — see The order of the menu |
moreLabel |
string|null |
null |
The button's name: :count for the number, one|many for the singular and the plural, or a translation key. Unset, "3 more links" — see Naming the button and the menu |
menuLabel |
string|null |
null |
The menu's name. Unset, "More links" |
scope |
string|null |
null |
Scoped personalization key for the row, item, link and menu-link blocks — see An entry as a tab |
Accessibility
- The current link carries
aria-current="page", and it stays in the rows at every width. Its fill and its weight mark it on screen; forced colors replace the fill, so there it is framed in the system highlight color, and a theme may set--font-wk-selected-weightto the body weight without leaving it unmarked. - The button's accessible name counts the menu ("3 more links", or what
more-labelsays), and its visible text is the count ("+3"). - On a touch screen the button keeps the height of an entry and takes its 44 x 44 pixel target as an area that paints nothing, so the row that holds it is no taller than the others.
- The menu is a popover, named "More links" or what
menu-labelsays, with a focus trap while it is open.Escapecloses it and returns focus to the button. - A link that moves into the menu leaves the rows, so a screen reader meets every link exactly once.
- A divider is hidden from assistive technology: it sets entries apart on screen, and the links say what they are.
- An entry's action is a button of its own beside the link, never inside it, where it would be a control within a control. Its
labelis its name, and it goes with the entry into the menu. - An entry's
iconis hidden from assistive technology. Whatafterrenders is part of the link, so its text joins the link's name — see An icon and a mark in an entry.
Keyboard Interaction
| Key | Action |
|---|---|
Tab / Shift+Tab |
Move through the links in the rows, each followed by its action if it has one, then to the button |
Enter (on a link) |
Follow the link |
Enter / Space (on an entry's action) |
Press the action |
Enter / Space (on the button) |
Open or close the menu |
Tab / Shift+Tab (inside the open menu) |
Move through the links in the menu, which keeps focus while it is open |
Escape |
Close the menu and return focus to the button |
Every entry is a link with the browser's own keyboard behavior; the menu is a popover.
Design Tokens
| Token | Used for |
|---|---|
--gap-wk-xs |
Space between links, and between rows |
--padding-wk-x-sm, --padding-wk-y-xs |
Padding of a link |
--radius-wk-md |
Corner radius of a link |
--color-wk-text-muted |
A link that is not current |
--color-wk-text, --color-wk-bg-muted |
The current link, and a link under the pointer |
--font-wk-selected-weight |
The weight of the current link |
--ring-wk-width, --color-wk-ring |
The focus ring |