<suggest-elemental>
A list of links a text field can drive with the arrow keys, per the APG Combobox pattern with a listbox popup. It is the results panel a search field, a filter and a "jump to" box all end up needing β and only the half of it that has nothing to do with where the results came from.
It owns the keyboard and the ARIA. It does not fetch, does not filter, and has no opinion
about what put the links there. Give it a <ul> of <a>, point it at an input, and the
arrows, Enter and Escape behave the way the pattern says.
<div class="field">
<label for="jump">Jump to</label>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"/>
</svg>
<input type="search" id="jump" autocomplete="off" placeholder="type, or press β">
<suggest-elemental for="jump">
<ul>
<li><a href="accordion.html" target="_blank">Accordion</a></li>
<li><a href="combobox.html" target="_blank">Combobox</a></li>
<li><a href="disclosure.html" target="_blank">Disclosure</a></li>
<li><a href="menu.html" target="_blank">Menu</a></li>
<li><a href="modal.html" target="_blank">Modal</a></li>
</ul>
</suggest-elemental>
</div>
/* the panel is absolutely positioned, so it adds no height to anything β the bottom margin
is what keeps it inside this preview, and is not part of the pattern. A margin and not
padding: the panel's containing block is this box, so padding here would push the panel
down with it */
.field { position: relative; display: grid; max-width: 22rem; margin-block-end: 12rem; }
/* the icon and the field share one grid cell rather than being positioned over it, so the
glyph is centred on whatever height the control turns out to have */
.field svg, .field input { grid-row: 2; grid-column: 1; }
.field svg {
place-self: center start;
/* the two share a cell, and the field is painted after β so the glyph needs lifting out
from under its background, and taking out of the way of the click that focuses it */
z-index: 1;
margin-inline-start: 0.75rem;
fill: color-mix(in srgb, CanvasText 55%, transparent);
pointer-events: none;
}
/* the look of the control is the page's: the theme draws the panel and nothing else, on the
reasoning that a field is what a design system already owns */
.field input {
padding: 0.5rem 0.75rem;
padding-inline-start: 2.25rem;
font: inherit;
color: CanvasText;
background: Canvas;
border: 1px solid color-mix(in srgb, CanvasText 30%, transparent);
border-radius: 0.375rem;
}
.field input:focus-visible { outline: 2px solid CanvasText; outline-offset: 1px; }
.field label { margin-block-end: 0.35rem; font-size: 0.875rem; }
/* clear of the field rather than welded to it β the panel is a separate surface */
.field suggest-elemental { margin-block-start: 0.25rem; }
// the element never sees the query: it is handed a list, and this is the page deciding what
// goes in it. `replaceChildren` moves the original rows, so there is no markup to build and
// nothing to escape
const field = document.getElementById('jump');
const panel = document.querySelector('suggest-elemental');
const rows = [...panel.querySelectorAll('li')];
field.addEventListener('input', () => {
const query = field.value.trim().toLowerCase();
const hits = rows.filter((row) => row.textContent.toLowerCase().includes(query));
panel.querySelector('ul').replaceChildren(...hits);
panel.open = query !== '' && hits.length > 0;
});Type, or press β. The caret never leaves the field β the cursor moving down the list is
aria-activedescendant, not focus, which is what lets you keep typing while you look. The
filtering is the sample's own JS; the element never sees the query.
The rows open in a new tab, which is this preview's need and not the element's β a row is a link and a link in a frame with no history of its own has nowhere to come back from. On your page they are ordinary links.
The panel is positioned against whatever the page has made the containing block, which is
why the sample gives the wrapper position: relative. The element writes data-side and
data-align for which corner had room; it does not write coordinates, because a light-DOM
popup lives in your layout and an element setting top on it is an element fighting a
decision you already made.
The markup
Two things, joined by for:
<input type="search" id="q">
<suggest-elemental for="q">
<ul>
<li><a href="/docs/install/">Install</a></li>
</ul>
</suggest-elemental>
Only <a href> becomes an option. A link without an href is not a destination, and a row
that goes nowhere is a dead line on the list.
The <ul> and every <li> get role="presentation" β a listbox may only own options,
and a list inside one would otherwise announce its own item counts on top of the listbox's.
The boxes stay, so your CSS still has its list to lay out; only the semantics come off.
Replace the contents whenever you like. The element watches for it and re-marks the new rows, so nothing has to call a refresh β and forgetting one would be a list of options a screen reader cannot see. The cursor resets when the list changes, because a cursor pointing into the list that was on screen a moment ago points at a row that has moved or gone.
Attributes
| Attribute | Type | Default | What it does |
|---|---|---|---|
for |
string | β | id of the text field that drives it. Also read as data-for. Without it the element does nothing. |
open |
boolean | false |
Whether the panel is showing. Reflected, so [open] is a styling hook, and settable so whatever fills the list can show it: suggest.open = true. |
tab-completes |
boolean | false |
Tab takes the row under the cursor instead of leaving the field. For a completer whose rows are text about to be typed. |
suggest-toggle fires on every change, with detail.open.
Keyboard
| Key | Panel closed | Panel open |
|---|---|---|
| β | opens, cursor on the first option | moves down, wrapping |
| β | opens, cursor on the last option | moves up, wrapping |
| Alt + β | opens, no cursor β see the list without committing to a row | β |
| Alt + β | β | closes |
| Enter | left to the page, so the form still submits | follows the option under the cursor |
| Escape | left to the page, so it can clear the field | closes |
| Tab | leaves | closes, then leaves β or takes the row under the cursor, with tab-completes |
| Home End | move the caret through what you typed | the caret, until a row is under the cursor β then the ends of the list |
Everything else is left where it was typed.
Home and End are the pair worth explaining. The pattern calls them
optional and gives two answers: jump the list, or β "if the combobox is editable" β put the
caret back on the first character. This field is always editable, so both are right at
different moments. Up to the first arrow key the reader is still writing a query, and a
Home that jumped the list rather than reaching the start of install would be
wrong on nearly every press. Once an arrow has put a cursor on a row they are reading
results, and the ends of the list are the only thing those keys can mean. Escape,
or typing again, hands them back.
The pointer takes the cursor with it. Two cursors that disagree is the bug β the pointer
sitting on one row while aria-activedescendant names another, and Enter going
somewhere the reader is not looking.
tab-completes is off by default and should stay off for a list of links. These rows are
destinations, so a Tab that took the one under the cursor would navigate off the
page on the keystroke that means "move along". Where the rows are text about to be typed β a
mention, an emoji β it is what every editor does, so the markup asks for it rather than the
element guessing. It is not in the
pattern, whose Tab row says
only that the combobox is in the page's tab sequence; the thing the pattern does give you
is automatic selection, which is moveTo(0) and costs no attribute.
With nothing under the cursor Tab leaves regardless, however the attribute is set β a panel with no answer must never cost a second press to get out of.
Completing a token
The panel does not need a field whose whole value is the query. In a comment box the query
is one token under the caret β @sta for a person, :fi for an emoji β and the page owns
the parsing, the matching and what an accepted row does to the text. The element still owns
the popup, the arrows, aria-activedescendant and the ARIA on the field.
<div class="composer">
<label for="note">Comment</label>
<textarea id="note" rows="4" autocomplete="off">Ship it @</textarea>
<div class="at-caret">
<suggest-elemental for="note" tab-completes><ul></ul></suggest-elemental>
</div>
</div>
/* the panel is absolutely positioned, so it adds no height to anything β the bottom padding
is what keeps it inside this preview, and is not part of the pattern */
.composer { position: relative; padding-block-end: 9rem; }
/* `resize: vertical` and not `both`: a field dragged wider than its column takes the panel
anchored to it off the edge with it */
.composer textarea {
display: block; width: 100%; box-sizing: border-box; resize: vertical;
padding: 0.5rem; font: inherit; color: inherit;
background: none;
border: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
border-radius: 0.375rem;
}
/* the panel hangs off a box of no size that the page moves to the caret, so the element's
own `inset-block-start: 100%` resolves against the caret's line and not the field's
bottom edge */
.at-caret { position: absolute; inset-block-start: 0; inset-inline-start: 0; width: 0; height: 0; }
/* `width: max-content` because the anchor above is a box of no size: an absolutely
positioned panel shrinks to fit its containing block, and a containing block of zero
width wraps every row. The max-height is capped under the element's own 20rem so the
emoji list scrolls inside the space this sample reserves below the field */
.at-caret suggest-elemental {
min-width: 12rem; width: max-content; max-width: 20rem;
--suggest-elemental-max-height: 10rem;
}
.at-caret small { opacity: 0.65; margin-inline-start: 0.4rem; }
// Shortcode to glyph, because that is the shape this data has everywhere else β the rows the
// panel wants are built off it below.
const EMOJI = {
tada: 'π', fire: 'π₯', rocket: 'π', sparkles: 'β¨', bug: 'π', eyes: 'π',
joy: 'π', smile: 'π', wink: 'π', thinking: 'π€', sob: 'π', rage: 'π‘',
sunglasses: 'π', heart: 'β€οΈ', thumbsup: 'π', thumbsdown: 'π', clap: 'π',
pray: 'π', muscle: 'πͺ', wave: 'π', ok_hand: 'π', brain: 'π§ ', skull: 'π',
ghost: 'π»', robot: 'π€', poop: 'π©', star: 'β', zap: 'β‘', boom: 'π₯',
rainbow: 'π', coffee: 'β', beer: 'πΊ', pizza: 'π', cake: 'π', gift: 'π',
bell: 'π', lock: 'π', key: 'π', hammer: 'π¨', wrench: 'π§', gear: 'βοΈ',
package: 'π¦', books: 'π', memo: 'π', chart: 'π', calendar: 'π
', mag: 'π',
warning: 'β οΈ', construction: 'π§', white_check_mark: 'β
', x: 'β',
question: 'β', snake: 'π', whale: 'π³', cat: 'π±', dog: 'πΆ', unicorn: 'π¦',
penguin: 'π§'
};
const ROWS = {
'@': [
{ value: '@stamat', hint: 'Nikola StamatoviΔ' },
{ value: '@koyev', hint: 'Marko JeviΔ' },
{ value: '@msavin', hint: 'Max Savin' },
{ value: '@jesussandreas', hint: 'Jesus Sandrea' }
],
':': Object.entries(EMOJI).map(([name, glyph]) => ({ value: glyph, hint: `:${name}:` }))
};
const field = document.getElementById('note');
const panel = document.querySelector('suggest-elemental');
const anchor = document.querySelector('.at-caret');
const escapeText = (value) => value.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
// a trigger character at a word start, and the word after it β at a word start so an
// e-mail address and a `10:30` do not open the panel
const TOKEN = /(?:^|\s)([@:])([\p{L}\p{N}_+-]*)$/u;
function token() {
const hit = TOKEN.exec(field.value.slice(0, field.selectionStart));
if (!hit) return null;
return { trigger: hit[1], query: hit[2].toLowerCase(), at: field.selectionStart - hit[1].length - hit[2].length };
}
// where the caret is, in pixels, by mirroring the field into a div and measuring a span
// planted at the caret β nothing on the platform reports it, because neither Range nor
// getSelection reaches inside a form control
function caretPoint() {
const style = getComputedStyle(field);
const mirror = document.createElement('div');
for (const prop of ['font', 'padding', 'border', 'width', 'boxSizing', 'letterSpacing']) mirror.style[prop] = style[prop];
mirror.style.cssText += ';position:absolute;visibility:hidden;white-space:pre-wrap;overflow-wrap:break-word';
mirror.textContent = field.value.slice(0, field.selectionStart);
const caret = mirror.appendChild(document.createElement('span'));
caret.textContent = '.';
document.body.append(mirror);
const point = {
x: field.offsetLeft + caret.offsetLeft,
y: field.offsetTop + caret.offsetTop + parseFloat(style.lineHeight) - field.scrollTop
};
mirror.remove();
return point;
}
function refresh() {
const now = token();
if (!now) {
panel.open = false;
return;
}
// prefix before anywhere: typing `:fi` wants `:fire:` at the top, not every emoji with an
// `f` somewhere in it
const hits = ROWS[now.trigger]
.map((row) => ({ row, rank: (row.value + ' ' + row.hint).toLowerCase().indexOf(now.query) }))
.filter((scored) => scored.rank >= 0)
.sort((a, b) => a.rank - b.rank)
.map((scored) => scored.row);
panel.innerHTML = '<ul>' + hits.map((row) =>
`<li><a href="#" data-value="${escapeText(row.value)}">${escapeText(row.value)}<small>${escapeText(row.hint)}</small></a></li>`
).join('') + '</ul>';
const point = caretPoint();
anchor.style.transform = `translate(${point.x}px, ${point.y}px)`;
panel.open = hits.length > 0;
// the pattern's "automatic selection": the first row is the cursor from the start, so
// Enter takes it with no arrow key first. Deferred because replacing the rows is a
// mutation, and the element clears the cursor when that lands.
if (panel.open) queueMicrotask(() => panel.moveTo(0));
}
function accept(value) {
const now = token();
if (!now) return;
const before = field.value.slice(0, now.at) + value + ' ';
field.value = before + field.value.slice(field.selectionStart);
field.setSelectionRange(before.length, before.length);
field.focus();
}
field.addEventListener('input', refresh);
field.addEventListener('click', refresh);
// the arrows are the panel's while it is open β re-rendering on them would clear the cursor
// the arrow just moved, and Enter would fall through to the field as a newline
field.addEventListener('keyup', (event) => {
if (!panel.open && (event.key.startsWith('Arrow') || event.key === 'Home' || event.key === 'End')) refresh();
});
// the rows are `<a href="#">` because that is the only thing the element counts as an
// option, and a token is not a destination, so nothing here is allowed to navigate
panel.addEventListener('click', (event) => {
const row = event.target.closest('a[data-value]');
if (!row) return;
event.preventDefault();
accept(row.dataset.value);
});Type @ni, or :fi. The panel follows the caret onto the next line, the first row is
already under the cursor, and Enter β or Tab, because of
tab-completes β takes it without an arrow key first.
Three parts of that are worth naming, because each is a place the element stops and the page starts:
- The panel follows the caret, not the field. The element positions against whatever the
page made the containing block, so the page makes one of no size and moves it. Measuring
the caret is the page's, and it costs a mirrored
<div>β no browser API reports a caret offset inside a<textarea>. - The first row is the cursor before an arrow key. That is the pattern's
list autocomplete with automatic selection,
and it is
moveTo(0). It has to wait a microtask: the element watches for the rows changing and clears the cursor when they do, which is right when the list changed under a reader and wrong the moment you wanted to put one back. - Rows are still
<a href>. That is the whole option contract, so a token completer writeshref="#"and cancels every activation. It works, and it is the seam β these rows are not destinations, and the element's own rule is that a link going nowhere is a dead row. Treat this as the supported hack it is, not as the element growing a second kind of option.
Without script
A list of links, in flow, visible and reachable. Nothing is authored hidden, so nothing is
lost when the script never arrives β which is also why the panel is not styled as a floating
box until the element is defined.
Why not <combobox-elemental>
That one is a view of a <select>. It holds a value, submits under a name,
resets with the form, and its options carry aria-selected.
<combobox-elemental> |
<suggest-elemental> |
|
|---|---|---|
| An option is | a <select> option β a value |
a link β a destination |
| Carries | aria-selected, aria-multiselectable |
neither |
| After a pick | stays open when multiple | navigates away |
| Needs | a <select> |
an <a> and an input |
They share the cursor mechanics and nothing else. Reach for the combobox when the answer goes into a form; reach for this when the answer is somewhere to go.
What it will not do
No fetching, no filtering, no ranking, no match highlighting, and no result count announced
β the last one because "5 results" is the language of whatever built the list, not of the
list. Pair it with something that owns the query: a few lines of your own, as above, or
<search-elemental>, which is that half β the debounce, the abort, the
loading state and the count.
Styling
The structure stylesheet positions and scrolls the panel and nothing else. The theme is
optional and draws only what the element owns β the panel and its rows. Nothing here
styles your <input>: that control is yours, and styling it is what a design system does.
| Custom property | Default | What it does |
|---|---|---|
--suggest-elemental-radius |
0.375rem |
Corners of the panel |
--suggest-elemental-inset |
0.5rem |
The one padding unit: down the side of every row, three quarters of it above and below the text |
--suggest-elemental-max-height |
20rem |
How tall the panel gets before it scrolls |
--suggest-elemental-surface |
Canvas |
What the panel is painted on |
--suggest-elemental-active |
color-mix(in srgb, currentcolor 12%, transparent) |
The row under the cursor |
@use "book-of-elementals/suggest/style.scss";
@use "book-of-elementals/suggest/theme.scss"; // optional
import 'book-of-elementals/suggest';