Command menu

A palette you open with a shortcut and walk with the arrow keys while the caret stays in the field.

Take it with you

This demo also exists as a standalone project — npm install && npm run dev, no changes needed.

npx giget@latest gh:lordicondev/ui-components/command-menu#standalone command-menu

Copies the command-menu folder of the standalone branch — where every demo lives on its own — into ./command-menu.

How it works

Define the element, put the icon in the markup, set the control's state. Then the parts that make this demo different.

Define the element Built-in triggers only. What each icon follows is written in its markup.
import { defineElement } from '@lordicon/element';

// Registers <lord-icon> with its built-in triggers. The markup says what each icon follows.
defineElement();
The search field data-keys makes the input the controller for the arrow keys.
html
<!-- The search bar's field, plus data-keys: the arrow keys move a cursor
     down the list while the caret stays in the input. -->
<div class="field" data-focused="false" data-clearable="false" data-rise>
    <label class="visually-hidden" for="command">Command</label>
    <lord-icon
        class="field__mark"
        current-color
        src="icons/magnifier.json"
        trigger="follow(data-focused)"
        target=".field"
        aria-hidden="true"
    ></lord-icon>
    <!-- prettier-ignore -->
    <input class="field__input" id="command" type="search" role="combobox"
           placeholder="Type a command or search..." autocomplete="off"
           aria-controls="commands" aria-expanded="true" data-keys />
    <!-- … -->
</div>
The rows A listbox of options. data-choose marks a row that can be chosen and arrowed to.
html
<div class="commands" id="commands" role="listbox" aria-label="Commands">
    <div class="menu__group" role="group" aria-labelledby="suggest-heading">
        <p class="menu__heading" data-rise id="suggest-heading">Suggestions</p>
        <div class="menu__list">
            <!-- prettier-ignore -->
            <div class="row" role="option" aria-selected="false" data-choose data-rise>
                <lord-icon
                    class="row__icon"
                    current-color
                    src="icons/file-text.json"
                    trigger="follow(data-active)"
                    target=".row"
                    aria-hidden="true"
                ></lord-icon>
                <span class="row__label" data-reveal>Create new document</span>
            </div>
            <!-- … -->
        </div>
    </div>
    <!-- … -->
</div>
The keyboard shortcut togglePopover() on the panel. The button is not involved.
// ⌘K / Ctrl+K toggles the palette. preventDefault, because the browser may use the
// shortcut itself. The panel still places itself against the button popovers() wired it to.
document.addEventListener('keydown', (event) => {
    if (event.key !== 'k' || !(event.metaKey || event.ctrlKey)) return;

    event.preventDefault();
    palette.togglePopover();
});
Filter the rows hidden on the rows; the empty state gets data-shown for its icon.
const rows = [...palette.querySelectorAll<HTMLElement>('.row')];

// `hidden` takes a row out of the layout and the accessibility tree. The stylesheet hides
// a group whose rows are all hidden. The empty state needs `hidden` for the layout and
// data-shown for its icon, which cannot animate out of display: none.
search.addEventListener('input', () => {
    const wanted = search.value.trim().toLowerCase();
    for (const row of rows) row.hidden = !row.textContent!.toLowerCase().includes(wanted);

    const none = rows.every((row) => row.hidden);
    empty.hidden = !none;
    empty.dataset.shown = String(none);
});
Open empty Cleared on beforetoggle, before the panel is painted.
// The palette opens empty. On beforetoggle, so the rows are back before the panel is
// measured and painted. Setting `value` raises no event, so `input` is raised by hand.
palette.addEventListener('beforetoggle', (event) => {
    if ((event as ToggleEvent).newState !== 'open' || !search.value) return;

    search.value = '';
    search.dispatchEvent(new Event('input', { bubbles: true }));
});
Hide empty groups Whether a group still has a visible row is a question CSS can ask with :has().
css
/* A group whose rows are all hidden goes too, heading included; and so does the list when
   every group has gone. The panel's height follows, without animating. */
.menu__group:not(:has(.row:not([hidden]))),
.commands:not(:has(.row:not([hidden]))) {
    display: none;
}

.empty {
    display: grid;
    justify-items: center;
    gap: var(--space-4);
    margin: 0;
    padding: var(--space-24) 0;
    color: var(--ink);
    font-size: var(--text-sm);
}

Every file

The demo's own files, whole, at the paths the export uses.

command-menu.css 77 lines
css
@import '@shared/styles/index.css';
@import '@shared/ui/popover.css';
@import '@shared/ui/menu.css';
@import '@shared/ui/field.css';

/* Near the top, so the panel has room to open downward. */
.demo {
    display: grid;
    align-content: start;
    justify-items: center;
    min-height: 100dvh;
    padding: var(--space-64) var(--gutter) var(--space-48);
}

/* Pressed while the palette is open. aria-expanded is written by popovers(). */
.opener {
    padding: var(--space-8) var(--space-12);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background: var(--surface);
    font-weight: 500;
    cursor: pointer;
}

.opener:hover,
.opener[aria-expanded='true'] {
    background: var(--surface-sunken);
}

/* Groups sit a step further apart than rows. */
.menu {
    --menu-width: 400px;
    --menu-gap: var(--space-16);
}

.commands {
    display: flex;
    flex: none;
    flex-direction: column;
    gap: var(--menu-gap, var(--space-12));
}

/* A group whose rows are all hidden goes too, heading included; and so does the list when
   every group has gone. The panel's height follows, without animating. */
.menu__group:not(:has(.row:not([hidden]))),
.commands:not(:has(.row:not([hidden]))) {
    display: none;
}

.empty {
    display: grid;
    justify-items: center;
    gap: var(--space-4);
    margin: 0;
    padding: var(--space-24) 0;
    color: var(--ink);
    font-size: var(--text-sm);
}

.empty__mark {
    display: block;
    width: var(--icon-size-24);
    height: var(--icon-size-24);
    color: var(--ink-muted);
}

/* The search bar's field, with no border at rest: the panel already has an edge. The
   border stays, transparent, so the words do not move when focus lights it. */
.field {
    flex: none;
    border-color: transparent;
}

/* The prompt is a step smaller than what you type. */
.field__input::placeholder {
    font-size: var(--text-sm);
}
index.html 173 lines
html
<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>Command menu</title>
        <link rel="stylesheet" href="./command-menu.css" />
        <script type="module" src="./main.ts"></script>
    </head>
    <body>
        <main class="demo">
            <!-- aria-keyshortcuts announces the shortcut that main.ts listens for. -->
            <button
                class="opener"
                type="button"
                popovertarget="palette"
                aria-keyshortcuts="Meta+K Control+K"
            >
                Open menu (⌘K)
            </button>

            <div class="menu popover" id="palette" popover>
                <!-- The search bar's field, plus data-keys: the arrow keys move a cursor
                     down the list while the caret stays in the input. -->
                <div class="field" data-focused="false" data-clearable="false" data-rise>
                    <label class="visually-hidden" for="command">Command</label>
                    <lord-icon
                        class="field__mark"
                        current-color
                        src="icons/magnifier.json"
                        trigger="follow(data-focused)"
                        target=".field"
                        aria-hidden="true"
                    ></lord-icon>
                    <!-- prettier-ignore -->
                    <input class="field__input" id="command" type="search" role="combobox"
                           placeholder="Type a command or search..." autocomplete="off"
                           aria-controls="commands" aria-expanded="true" data-keys />
                    <button class="field__clear" type="button" aria-label="Clear">
                        <lord-icon
                            class="field__mark"
                            current-color
                            src="icons/cross.json"
                            trigger="follow(data-clearable)"
                            target=".field"
                            state="in-reveal"
                            aria-hidden="true"
                        ></lord-icon>
                    </button>
                </div>

                <div class="commands" id="commands" role="listbox" aria-label="Commands">
                    <div class="menu__group" role="group" aria-labelledby="suggest-heading">
                        <p class="menu__heading" data-rise id="suggest-heading">Suggestions</p>
                        <div class="menu__list">
                            <!-- prettier-ignore -->
                            <div class="row" role="option" aria-selected="false" data-choose data-rise>
                                <lord-icon
                                    class="row__icon"
                                    current-color
                                    src="icons/file-text.json"
                                    trigger="follow(data-active)"
                                    target=".row"
                                    aria-hidden="true"
                                ></lord-icon>
                                <span class="row__label" data-reveal>Create new document</span>
                            </div>
                            <!-- prettier-ignore -->
                            <div class="row" role="option" aria-selected="false" data-choose data-rise>
                                <lord-icon
                                    class="row__icon"
                                    current-color
                                    src="icons/clock-arrow-rotate-left.json"
                                    trigger="follow(data-active)"
                                    target=".row"
                                    aria-hidden="true"
                                ></lord-icon>
                                <span class="row__label" data-reveal>Open recent file</span>
                            </div>
                        </div>
                    </div>

                    <div class="menu__group" role="group" aria-labelledby="actions-heading">
                        <p class="menu__heading" data-rise id="actions-heading">Actions</p>
                        <div class="menu__list">
                            <!-- prettier-ignore -->
                            <div class="row" role="option" aria-selected="false" data-choose data-rise>
                                <lord-icon
                                    class="row__icon"
                                    current-color
                                    src="icons/link.json"
                                    trigger="follow(data-active)"
                                    target=".row"
                                    aria-hidden="true"
                                ></lord-icon>
                                <span class="row__label" data-reveal>Copy link</span>
                            </div>
                            <!-- prettier-ignore -->
                            <div class="row" role="option" aria-selected="false" data-choose data-rise>
                                <lord-icon
                                    class="row__icon"
                                    current-color
                                    src="icons/copy.json"
                                    trigger="follow(data-active)"
                                    target=".row"
                                    aria-hidden="true"
                                ></lord-icon>
                                <span class="row__label" data-reveal>Duplicate</span>
                            </div>
                            <!-- prettier-ignore -->
                            <div class="row" role="option" aria-selected="false" data-choose data-rise>
                                <lord-icon
                                    class="row__icon"
                                    current-color
                                    src="icons/trash-bin.json"
                                    trigger="follow(data-active)"
                                    target=".row"
                                    aria-hidden="true"
                                ></lord-icon>
                                <span class="row__label" data-reveal>Delete</span>
                            </div>
                        </div>
                    </div>

                    <div class="menu__group" role="group" aria-labelledby="navigate-heading">
                        <p class="menu__heading" data-rise id="navigate-heading">Navigation</p>
                        <div class="menu__list">
                            <!-- prettier-ignore -->
                            <div class="row" role="option" aria-selected="false" data-choose data-rise>
                                <lord-icon
                                    class="row__icon"
                                    current-color
                                    src="icons/cog.json"
                                    trigger="follow(data-active)"
                                    target=".row"
                                    aria-hidden="true"
                                ></lord-icon>
                                <span class="row__label" data-reveal>Open settings</span>
                            </div>
                            <!-- prettier-ignore -->
                            <div class="row" role="option" aria-selected="false" data-choose data-rise>
                                <lord-icon
                                    class="row__icon"
                                    current-color
                                    src="icons/avatar-circle.json"
                                    trigger="follow(data-active)"
                                    target=".row"
                                    aria-hidden="true"
                                ></lord-icon>
                                <span class="row__label" data-reveal>View profile</span>
                            </div>
                        </div>
                    </div>
                </div>

                <!-- Shown when nothing matches. hidden for the layout, data-shown for the
                     icon, which cannot animate out of display: none. -->
                <p class="empty" data-shown="false" hidden>
                    <lord-icon
                        class="empty__mark"
                        current-color
                        src="icons/cross-circle.json"
                        trigger="follow(data-shown)"
                        target=".empty"
                        state="in-reveal"
                        aria-hidden="true"
                    ></lord-icon>
                    No results found.
                </p>
            </div>
        </main>
    </body>
</html>
main.ts 50 lines
import { defineElement } from '@lordicon/element';

// Registers <lord-icon> with its built-in triggers. The markup says what each icon follows.
defineElement();

import { fields } from '@shared/ui/field.ts';
import { menus } from '@shared/ui/menu.ts';
import { popovers } from '@shared/ui/popover.ts';

const palette = document.querySelector<HTMLElement>('.menu')!;
const search = document.querySelector<HTMLInputElement>('.field__input')!;
const empty = document.querySelector<HTMLElement>('.empty')!;

// The panel, the field and the arrow keys. menus() focuses the field when the palette
// opens, so you can type at once.
popovers(document, { reveal: '[data-reveal]' });
fields();
menus();

// ⌘K / Ctrl+K toggles the palette. preventDefault, because the browser may use the
// shortcut itself. The panel still places itself against the button popovers() wired it to.
document.addEventListener('keydown', (event) => {
    if (event.key !== 'k' || !(event.metaKey || event.ctrlKey)) return;

    event.preventDefault();
    palette.togglePopover();
});

const rows = [...palette.querySelectorAll<HTMLElement>('.row')];

// `hidden` takes a row out of the layout and the accessibility tree. The stylesheet hides
// a group whose rows are all hidden. The empty state needs `hidden` for the layout and
// data-shown for its icon, which cannot animate out of display: none.
search.addEventListener('input', () => {
    const wanted = search.value.trim().toLowerCase();
    for (const row of rows) row.hidden = !row.textContent!.toLowerCase().includes(wanted);

    const none = rows.every((row) => row.hidden);
    empty.hidden = !none;
    empty.dataset.shown = String(none);
});

// The palette opens empty. On beforetoggle, so the rows are back before the panel is
// measured and painted. Setting `value` raises no event, so `input` is raised by hand.
palette.addEventListener('beforetoggle', (event) => {
    if ((event as ToggleEvent).newState !== 'open' || !search.value) return;

    search.value = '';
    search.dispatchEvent(new Event('input', { bubbles: true }));
});
What it imports 12 shared files
shared/motion/reduced-motion.ts 7 lines
/**
 * Whether the viewer asked for less motion. Script-driven animations have to check this
 * themselves; a media query only switches off CSS ones.
 */
export function prefersReducedMotion(): boolean {
    return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
}
shared/motion/text-reveal.ts 132 lines
/**
 * Text that arrives word by word and leaves in one piece.
 *
 * `revealText()` fades the words in as a wave: several are in flight at once. `concealText()`
 * slides the whole block down without fading; the box around it is expected to clip it.
 * This module only moves the text, the caller owns the box.
 */
import { prefersReducedMotion } from './reduced-motion.ts';

/** First word starting to last word finished, whatever the word count. */
const REVEAL_MS = 500;

/**
 * How much of the run one word spends fading. Higher means a softer, more overlapping wave;
 * lower means words arrive more one at a time.
 */
const FADE_SHARE = 0.3;

const FADE_EASE = 'ease-out';

const CONCEAL_MS = 300;

/** Pixels the block travels down. Enough to read as leaving, short enough to stay clipped. */
const CONCEAL_DISTANCE = 32;

const MOVE_EASING = 'cubic-bezier(0.3, 0, 0.2, 1)';

export type RevealOptions = {
    duration?: number;
    fadeShare?: number;
    easing?: string;
    /** Wait before the first word, for text arriving into a box that is still opening. */
    delay?: number;
};

export type ConcealOptions = {
    duration?: number;
    /** Pixels, downwards. */
    distance?: number;
    easing?: string;
};

/**
 * Wraps every word of `element` in its own span. Walks text nodes rather than rewriting
 * innerHTML, so inline markup inside the paragraph survives. Runs once; later calls return
 * the spans already there.
 */
export function splitWords(element: HTMLElement): HTMLElement[] {
    const already = element.querySelectorAll<HTMLElement>('[data-word]');
    if (already.length) return [...already];

    const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
    const texts: Text[] = [];
    while (walker.nextNode()) texts.push(walker.currentNode as Text);

    for (const text of texts) {
        if (!text.data.trim()) continue;

        const pieces = document.createDocumentFragment();
        // The capturing split keeps the whitespace, so copied text reads as before.
        for (const part of text.data.split(/(\s+)/)) {
            if (!part) continue;

            if (!part.trim()) {
                pieces.append(part);
                continue;
            }

            const word = document.createElement('span');
            word.dataset.word = '';
            word.textContent = part;
            pieces.append(word);
        }

        text.replaceWith(pieces);
    }

    return [...element.querySelectorAll<HTMLElement>('[data-word]')];
}

/**
 * Cancels whatever this block and its words have running. Scoped on purpose: a subtree
 * search from an ancestor would also cancel the box's own animation.
 */
export function settleText(element: HTMLElement): void {
    for (const animation of element.getAnimations()) animation.cancel();

    for (const word of element.querySelectorAll<HTMLElement>('[data-word]')) {
        for (const animation of word.getAnimations()) animation.cancel();
    }
}

/**
 * Fades the words in one after another. The run always takes `duration`; more words mean
 * a tighter stagger.
 */
export function revealText(element: HTMLElement, options: RevealOptions = {}): Animation[] {
    const { duration = REVEAL_MS, fadeShare = FADE_SHARE, easing = FADE_EASE, delay = 0 } = options;

    const words = splitWords(element);
    settleText(element);
    if (prefersReducedMotion()) return [];

    const solo = words.length < 2;
    const fade = solo ? duration : duration * fadeShare;
    const step = solo ? 0 : (duration - fade) / (words.length - 1);

    return words.map((word, index) =>
        // `backwards` keeps a word invisible through its delay. No forwards fill, so a
        // finished wave leaves nothing to undo.
        word.animate(
            { opacity: [0, 1] },
            { duration: fade, delay: delay + index * step, easing, fill: 'backwards' },
        ),
    );
}

/**
 * Slides the block down as one, without fading. Fills forwards so the text stays down
 * until the box has closed; the next reveal cancels it.
 */
export function concealText(element: HTMLElement, options: ConcealOptions = {}): Animation | null {
    const { duration = CONCEAL_MS, distance = CONCEAL_DISTANCE, easing = MOVE_EASING } = options;

    settleText(element);
    if (prefersReducedMotion()) return null;

    return element.animate(
        { transform: ['translateY(0)', `translateY(${distance}px)`] },
        { duration, easing, fill: 'forwards' },
    );
}
shared/styles/base.css 115 lines
css
/*
 * Fonts, reset and default typography. Nothing control-specific: every demo styles its
 * own control. Only the UI face is here; the mono face is the portal's.
 */
@font-face {
    font-family: 'Figtree';
    font-style: normal;
    font-weight: 300 900;
    font-display: swap;
    src: url('../fonts/figtree-latin.woff2') format('woff2');
    unicode-range:
        U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308,
        U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
    font-family: 'Figtree';
    font-style: normal;
    font-weight: 300 900;
    font-display: swap;
    src: url('../fonts/figtree-latin-ext.woff2') format('woff2');
    unicode-range:
        U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
        U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113,
        U+2C60-2C7F, U+A720-A7FF;
}

/* Arial with Figtree's measurements, for the moment before Figtree arrives, so the text does
   not move when it does. Measured on Figtree at 400 and 500. */
@font-face {
    font-family: 'Figtree fallback';
    src: local('Arial'), local('Liberation Sans');
    size-adjust: 100.3%;
    ascent-override: 94.7%;
    descent-override: 24.9%;
    line-gap-override: 0%;
}

*,
*::before,
*::after {
    box-sizing: border-box;
}

/* An author `display` on a component would beat the UA rule for [hidden], and then
   `el.hidden = true` would stop working. Settled once here. */
[hidden] {
    display: none !important;
}

body {
    margin: 0;
    background: var(--surface);
    color: var(--ink);
    --icon-filter: var(--filter-ink);
    font-family: var(--font-ui);
    font-size: var(--text-base);
    line-height: var(--leading-base);
    -webkit-font-smoothing: antialiased;
}

button,
input,
textarea,
select {
    font: inherit;
    color: inherit;
}

/* A second click on a button would otherwise select its label. */
button {
    -webkit-user-select: none;
    user-select: none;
}

:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
}

/*
 * In the accessibility tree but not on screen: labels and legends that the visual design
 * has no room for. Clipped rather than `display: none`, which would remove it from the
 * tree too, and it also hides live radio inputs that must stay reachable by keyboard.
 */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

/*
 * Until the script defines <lord-icon> it is an unknown inline element with no size. These
 * rules give it the box it will have, so nothing moves when it is defined.
 */
lord-icon {
    display: inline-block;
    width: var(--icon-size-32);
    height: var(--icon-size-32);
}

/* A placeholder inside the icon fills it until the icon is ready. */
lord-icon:not(:defined) > img {
    display: block;
    width: 100%;
    height: 100%;
}

/* Placeholders are black. Set --icon-filter wherever an icon's colour is set. */
lord-icon > img {
    filter: var(--icon-filter);
}
shared/styles/index.css 4 lines
css
/* The one stylesheet a demo imports. */
@import './palette.css';
@import './tokens.css';
@import './base.css';
shared/styles/palette.css 87 lines
css
/*
 * Generated from the Figma token export.
 * Raw values only — roles live in tokens.css. Edit the Figma variables, not this file.
 */
:root {
    /* color/ink */
    --gray-0: #FFFFFF;
    --gray-50: #F7F8FA;
    --gray-100: #EEF0F3;
    --gray-200: #DEE1E6;
    --gray-300: #C3C7D0;
    --gray-400: #9BA1AD;
    --gray-500: #767C89;
    --gray-600: #565C68;
    --gray-700: #3D414B;
    --gray-800: #282B33;
    --gray-900: #16181D;
    --gray-950: #0A0B0E;

    /* color/signal */
    --brand-100: #FFEEE9;
    --brand-200: #FFD7CA;
    --brand-300: #FFB199;
    --brand-400: #FF7D5C;
    --brand-500: #FF5A36;
    --brand-600: #E8431F;
    --brand-700: #C2340F;

    /* color/success */
    --green-100: #EDF9F1;
    --green-300: #A7D9BC;
    --green-700: #15803D;

    /* color/error */
    --red-100: #FDECEC;
    --red-300: #F5B5B5;
    --red-700: #DC2626;

    /* typography/font */
    --font-ui-name: 'Figtree';
    --font-mono-name: 'JetBrains Mono';

    /* typography/size */
    --text-xs: 12px;
    --text-sm: 14px;
    --text-base: 16px;
    --text-lg: 20px;
    --text-xl: 24px;
    --text-2xl: 32px;
    --text-3xl: 40px;

    /* typography/lineHeight */
    --leading-xs: 16px;
    --leading-sm: 20px;
    --leading-base: 24px;
    --leading-lg: 28px;
    --leading-xl: 32px;
    --leading-2xl: 40px;
    --leading-3xl: 48px;

    /* spacing (named after the value, the Figma scale is non-linear) */
    --space-4: 4px;
    --space-8: 8px;
    --space-12: 12px;
    --space-16: 16px;
    --space-24: 24px;
    --space-32: 32px;
    --space-48: 48px;
    --space-64: 64px;
    --space-96: 96px;

    /* size/icon */
    --icon-size-16: 16px;
    --icon-size-20: 20px;
    --icon-size-24: 24px;
    --icon-size-32: 32px;
    --icon-size-40: 40px;
    --icon-size-48: 48px;
    --icon-size-64: 64px;
    --icon-size-96: 96px;

    /* radius */
    --radius-sm: 4px;
    --radius-md: 8px;
    --radius-lg: 12px;
    --radius-pill: 9999px;
}
shared/styles/tokens.css 93 lines
css
/*
 * Design tokens by role. Raw values come from palette.css (generated); which grey is a
 * border and which is muted text is decided here, and so would a dark mode be.
 */
:root {
    /* Text. `--ink*` is always a colour; `--text-*` is always a size. */
    --ink: var(--gray-950);
    /* Text that reads as done or in progress, a step lighter than a heading. */
    --ink-strong: var(--gray-800);
    /* Body copy: a step down from --ink. */
    --ink-soft: var(--gray-600);
    --ink-muted: var(--gray-500);
    --ink-subtle: var(--gray-400);
    /* What has not started yet. */
    --ink-faint: var(--gray-300);
    --ink-inverse: var(--gray-0);

    /* Backgrounds */
    --surface: var(--gray-0);
    --surface-muted: var(--gray-50);
    /* A well inside a surface: the filled shape a single icon sits in. */
    --surface-sunken: var(--gray-100);

    /* Outlines */
    --border: var(--gray-200);
    /* The edge of a box that already has a fill, such as an input. */
    --border-soft: var(--gray-100);
    --border-strong: var(--gray-300);
    --border-accent: var(--brand-400);

    /* Brand accent */
    --accent: var(--brand-500);
    --accent-strong: var(--brand-600);
    --accent-surface: var(--brand-100);
    /* The outline of a tinted box, or a small shape on one. */
    --accent-border: var(--brand-300);
    /* Text or a mark on either of the above. */
    --accent-ink: var(--brand-700);

    /* Status. Warning rides the brand ramp — that is what the designs use. */
    --success: var(--green-700);
    --success-surface: var(--green-100);
    --success-border: var(--green-300);

    --warning: var(--brand-500);
    --warning-surface: var(--brand-100);
    --warning-border: var(--brand-300);

    --danger: var(--red-700);
    --danger-surface: var(--red-100);
    --danger-border: var(--red-300);

    /* Icons. Fed straight into --lord-icon-* by the demos. */
    --icon-color: var(--gray-950);
    --icon-accent: var(--brand-500);

    /* Icon placeholders are black SVGs. Each filter turns black into the colour of the same
       name. Made with a CSS filter generator: make a new one when its colour changes. */
    --filter-ink: invert(2%) sepia(4%) saturate(4817%) hue-rotate(193deg) brightness(114%)
        contrast(96%);
    --filter-ink-soft: invert(33%) sepia(18%) saturate(323%) hue-rotate(182deg) brightness(100%)
        contrast(90%);
    --filter-ink-muted: invert(55%) sepia(23%) saturate(191%) hue-rotate(181deg) brightness(84%)
        contrast(89%);
    --filter-ink-subtle: invert(70%) sepia(11%) saturate(284%) hue-rotate(181deg) brightness(91%)
        contrast(86%);
    --filter-ink-faint: invert(76%) sepia(17%) saturate(99%) hue-rotate(184deg) brightness(99%)
        contrast(100%);
    --filter-ink-inverse: invert(100%);
    --filter-accent: invert(49%) sepia(65%) saturate(3508%) hue-rotate(337deg) brightness(106%)
        contrast(113%);
    --filter-accent-strong: invert(29%) sepia(85%) saturate(1790%) hue-rotate(350deg)
        brightness(94%) contrast(93%);
    --filter-success: invert(38%) sepia(22%) saturate(2045%) hue-rotate(94deg) brightness(92%)
        contrast(84%);
    --filter-warning: var(--filter-accent);
    --filter-danger: invert(48%) sepia(74%) saturate(7139%) hue-rotate(343deg) brightness(84%)
        contrast(105%);

    /* Type stacks. The families themselves are in palette.css. */
    --font-ui:
        var(--font-ui-name), 'Figtree fallback', -apple-system, BlinkMacSystemFont, 'Segoe UI',
        Roboto, sans-serif;
    --font-mono: var(--font-mono-name), ui-monospace, SFMono-Regular, Menlo, monospace;

    /* Side padding of a page. The portal uses the same value, so demo and text line up. */
    --gutter: clamp(var(--space-16), 5vw, var(--space-48));

    /* A card's halo: no offset, so it reads as lift rather than as a light source. */
    --shadow-sm: 0 0 7.5px color-mix(in srgb, var(--gray-400) 20%, transparent);
    /* The same halo under a brand-coloured element. */
    --shadow-accent: 0 0 7.5px color-mix(in srgb, var(--accent-strong) 40%, transparent);
}
shared/ui/field.css 72 lines
css
/*
 * The look of a text field wired by field.ts. A demo overrides the resting border or the
 * focused look with a rule of its own after this import.
 */
.field {
    display: flex;
    align-items: center;
    gap: var(--space-8);
    padding: var(--space-8);
    border: 1px solid var(--border-soft);
    border-radius: var(--radius-md);
    background: var(--surface-muted);
}

.field[data-focused='true'] {
    border-color: var(--accent-border);
    box-shadow: var(--shadow-accent);
}

.field__input {
    flex: 1;
    min-width: 0;
    padding: 0;
    border: 0;
    background: none;
    color: var(--ink);
    font-weight: 500;
}

.field__input::placeholder {
    color: var(--ink-muted);
}

/* The box draws the focused state; no second ring inside it. */
.field__input:focus-visible {
    outline: none;
}

/* A search input has a clear button of its own; this field brings its own. */
.field__input::-webkit-search-cancel-button {
    appearance: none;
}

/* `visibility` rather than `hidden`: the button keeps its box, so the field does not
   change width when it appears. It is out of the tab order either way. */
.field__clear {
    display: grid;
    place-items: center;
    visibility: hidden;
    padding: 0;
    border: 0;
    background: none;
    color: var(--ink-muted);
    cursor: pointer;
}

.field[data-clearable='true'] .field__clear {
    visibility: visible;
}

.field__clear:hover {
    color: var(--ink);
}

/* Both icons take the placeholder's grey; the typed words are the only full-strength ink. */
.field__mark {
    flex: none;
    width: var(--icon-size-24);
    height: var(--icon-size-24);
    color: var(--ink-muted);
    --icon-filter: var(--filter-ink-muted);
}
shared/ui/field.ts 75 lines
/**
 * A text field that reports its state in two attributes:
 *
 *     <div class="field" data-focused="false" data-clearable="false">
 *         <lord-icon … trigger="follow(data-focused)" target=".field"></lord-icon>
 *         <input class="field__input" />
 *         <button class="field__clear">…</button>
 *     </div>
 *
 * `data-focused` is whether focus is anywhere inside the field. `data-clearable` turns true
 * once the typing has paused with something in the input, and is what shows the clear
 * button. Icons and the stylesheet read the attributes; this module only writes them.
 *
 * The clear button is optional. Without one, `data-clearable` is never set.
 */

/** How long the typing has to pause before the field offers to clear. */
const SETTLED = 500;

export type FieldOptions = { settled?: number };

/** Wires every `.field` under `root`. Called once; nothing here needs taking apart. */
export function fields(root: ParentNode = document, options: FieldOptions = {}): void {
    for (const field of root.querySelectorAll<HTMLElement>('.field')) {
        wire(field, options.settled ?? SETTLED);
    }
}

function wire(field: HTMLElement, settled: number): void {
    const input = field.querySelector<HTMLInputElement>('.field__input');
    if (!input) return;

    const clear = field.querySelector<HTMLButtonElement>('.field__clear');
    let settling: ReturnType<typeof setTimeout> | undefined;

    const offer = (clearable: boolean) => {
        clearTimeout(settling);
        field.dataset.clearable = String(clearable);
    };

    // focusin/focusout bubble, so the field counts as focused with focus on the input or
    // on the clear button.
    field.addEventListener('focusin', () => (field.dataset.focused = 'true'));

    field.addEventListener('focusout', (event) => {
        // Moving focus to the clear button is not leaving the field.
        if (field.contains(event.relatedTarget as Node | null)) return;

        field.dataset.focused = 'false';
        if (clear && input.value) offer(true);
    });

    if (!clear) return;

    // The clear button appears once the typing pauses, not on every keystroke.
    input.addEventListener('input', () => {
        clearTimeout(settling);

        if (!input.value) return offer(false);

        // Once shown it stays, so it is there when reached for.
        if (field.dataset.clearable === 'true') return;

        settling = setTimeout(() => offer(true), settled);
    });

    clear.addEventListener('click', () => {
        input.value = '';
        offer(false);
        // Setting `value` from script raises no event; anything filtering on this field
        // listens for `input`.
        input.dispatchEvent(new Event('input', { bubbles: true }));
        input.focus();
    });
}
shared/ui/menu.css 94 lines
css
/*
 * The look of a menu: the panel, its group headings and its rows. Placement and the
 * opening animation are in popover.ts and popover.css; the keyboard cursor is in menu.ts.
 *
 * Import after popover.css, which resets the UA popover rules at the same specificity.
 * Most demos only need to set `--menu-width`.
 */

/* `display: flex` matters: popover.css hides a closed panel with `display: none`, and an
   author `display` is what keeps the browser's own hiding rule from applying. */
.menu {
    display: flex;
    flex-direction: column;
    gap: var(--menu-gap, var(--space-12));
    width: var(--menu-width);
    padding: var(--menu-pad, var(--space-12));
    border: 1px solid var(--border-soft);
    border-radius: var(--radius-lg);
    background: var(--surface);
    box-shadow: var(--shadow-sm);
    /* The UA sheet gives a popover `color: CanvasText`; nothing inherits past it. */
    color: var(--ink);
}

/* The panel takes focus when it opens so arrow keys have somewhere to land, but the cursor
   drawn by menu.ts is what the eye should follow, not a ring around the whole box. */
.menu[data-keys]:focus {
    outline: none;
}

.menu__group {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
}

.menu__heading {
    margin: 0;
    color: var(--ink-muted);
    font-size: var(--text-sm);
    line-height: var(--leading-sm);
    font-weight: 600;
}

/* `flex: none`: a column animating from `height: 0` squashes its children unless they
   refuse to shrink. */
.menu__list {
    display: flex;
    flex-direction: column;
    flex: none;
}

/* Two variables colour a row: `--row-fill` at rest and `--row-hover` under the cursor.
   A row that is already tinted, such as an unread notification, sets both. */
.row {
    --row-fill: transparent;
    --row-hover: var(--surface-muted);
    display: flex;
    align-items: var(--row-align, center);
    gap: var(--row-gap, var(--space-8));
    width: 100%;
    padding: var(--row-pad, var(--space-8));
    border: 0;
    border-radius: var(--radius-md);
    background: var(--row-fill);
    color: var(--ink);
    font: inherit;
    text-align: start;
    cursor: pointer;
}

/* `data-active` is written by the keyboard cursor and by the pointer alike, so hovered and
   arrowed-to are one state with one look. No transition: this is a state, not a movement. */
.row:hover,
.row[data-active='true'] {
    background: var(--row-hover);
}

/* The rows touch, so the focus ring goes inside the row rather than over its neighbours. */
.row:focus-visible {
    outline-offset: -2px;
}

.row__icon {
    display: block;
    flex: none;
    width: var(--icon-size-24);
    height: var(--icon-size-24);
}

.row__label {
    flex: 1;
    font-weight: 500;
}
shared/ui/menu.ts 111 lines
/**
 * Arrow keys over the rows of a menu or a command palette.
 *
 *     <div class="menu popover" popover role="menu" tabindex="-1" data-keys>
 *         <button class="row" role="menuitem" data-choose>…</button>
 *
 * The element with `data-keys` holds focus and takes the key presses: the panel itself for
 * a menu, the search field for a palette. The rows are the `[data-choose]` elements inside
 * the panel, the same attribute popover.ts uses for rows that can be chosen.
 *
 * The cursor is `data-active="true"` on a row plus `aria-activedescendant` on the
 * controller, not focus, so a palette's field keeps focus while the list is walked. The
 * pointer moves the same cursor, so hover and keyboard share one state and one style.
 *
 * Not covered: typeahead, and scrolling a row into view.
 */

const ROW = '[data-choose]';

let wired = 0;

export type MenuOptions = {
    /** Which descendants are rows. Defaults to `[data-choose]`. */
    row?: string;
};

/** Wires every `[data-keys]` controller under `root`. Called once. */
export function menus(root: ParentNode = document, options: MenuOptions = {}): void {
    for (const keys of root.querySelectorAll<HTMLElement>('[data-keys]')) {
        wire(keys, options.row ?? ROW);
    }
}

function wire(keys: HTMLElement, selector: string): void {
    // The panel the rows live in. For a menu the controller is the panel itself.
    const panel = keys.closest<HTMLElement>('[popover]') ?? keys;
    const prefix = panel.id || `menu-${++wired}`;

    // Home and End belong to the caret when the controller is a text field.
    const typed = keys.matches('input, textarea');
    let active: HTMLElement | null = null;

    /** The rows that can be walked right now; a filter may have hidden some. */
    const live = (): HTMLElement[] =>
        [...panel.querySelectorAll<HTMLElement>(selector)].filter(
            (row) => !row.hidden && !row.closest('[hidden]'),
        );

    // `aria-activedescendant` names a row by id, so every row needs one.
    panel.querySelectorAll<HTMLElement>(selector).forEach((row, index) => {
        if (!row.id) row.id = `${prefix}-row-${index}`;
        if (!row.dataset.active) row.dataset.active = 'false';
    });

    /** Moves the cursor to `row`, or clears it with null. */
    const mark = (row: HTMLElement | null): void => {
        if (active) {
            active.dataset.active = 'false';
            if (active.getAttribute('role') === 'option')
                active.setAttribute('aria-selected', 'false');
        }

        active = row;
        if (!row) return keys.removeAttribute('aria-activedescendant');

        row.dataset.active = 'true';
        // A listbox option carries aria-selected; a menuitem has no such state.
        if (row.getAttribute('role') === 'option') row.setAttribute('aria-selected', 'true');
        keys.setAttribute('aria-activedescendant', row.id);
    };

    // The index is worked out on every press because a filter may have changed the list.
    // From no cursor, Down lands on the first row and Up on the last.
    keys.addEventListener('keydown', (event) => {
        const rows = live();
        const at = active ? rows.indexOf(active) : -1;
        const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
        const first = step > 0 ? 0 : rows.length - 1;

        if (step !== 0)
            mark(rows[at < 0 ? first : (at + step + rows.length) % rows.length] ?? null);
        else if (event.key === 'Enter' && active) active.click();
        else if (!typed && event.key === 'Home') mark(rows[0] ?? null);
        else if (!typed && event.key === 'End') mark(rows.at(-1) ?? null);
        else return;

        event.preventDefault();
    });

    // The pointer moves the same cursor. `pointerover` fires once per row entered.
    panel.addEventListener('pointerover', (event) => {
        const row = (event.target as Element).closest<HTMLElement>(selector);
        if (row && panel.contains(row) && row !== active) mark(row);
    });

    panel.addEventListener('pointerleave', () => mark(null));

    // A filter narrows the list by raising `input`; the cursor may have been on a row it hid.
    panel.addEventListener('input', () => {
        if (active && !live().includes(active)) mark(null);
    });

    // Opening focuses the controller so the first arrow key has somewhere to arrive.
    // `preventScroll`: a popover sits in the top layer and needs no scrolling into view.
    if (panel.matches('[popover]')) {
        panel.addEventListener('toggle', (event) => {
            mark(null);
            if ((event as ToggleEvent).newState === 'open') keys.focus({ preventScroll: true });
        });
    }
}
shared/ui/popover.css 63 lines
css
/*
 * The look of a popover panel, which way it opened, and how it leaves. Placement and the
 * opening animation are in popover.ts, which writes `data-direction` on the panel before
 * it is painted.
 */

/* The UA sheet pins a popover to all four edges and centres it with auto margins.
   popover.ts sets two of the edges, so the rest is undone here. */
.popover {
    position: fixed;
    inset: auto;
    margin: 0;
    padding: 0;
    border: 0;
    /* The UA `overflow: auto` would clip a submenu reaching across the gap. The opening
       animation clips from an inline style and hands back to this afterwards. */
    overflow: visible;
}

/* The browser hides a closed popover from its own stylesheet, and any author `display`
   overrides that. Without this rule, a panel given `display: flex` is never hidden. */
.popover:not(:popover-open) {
    display: none;
}

/* Leaving is a CSS transition. A closing popover leaves the top layer at once; the
   `allow-discrete` transitions hold `display` and `overlay` until the fade is over. */
.popover {
    --popover-leave: var(--space-12);
    opacity: 0;
    translate: 0 var(--popover-leave);
    transition:
        opacity 160ms ease-in,
        translate 160ms ease-in,
        overlay 160ms allow-discrete,
        display 160ms allow-discrete;
}

.popover:popover-open {
    opacity: 1;
    translate: 0 0;
    transition: none;
}

/* Opening upward: the panel leaves upward too, and while it is still short its rows
   overflow at the top, so the ones nearest the control show first. Children must not
   shrink, or the flex column squashes them instead of overflowing. */
.popover[data-direction='up'] {
    --popover-leave: calc(var(--space-12) * -1);
    justify-content: flex-end;
}

.popover[data-direction='up'] > * {
    flex-shrink: 0;
}

/* popover.ts skips its animations under reduced motion too. */
@media (prefers-reduced-motion: reduce) {
    .popover {
        translate: 0 0;
        transition: none;
    }
}
shared/ui/popover.ts 249 lines
/**
 * A panel that opens beside the control that owns it, built on the browser's popover:
 *
 *     <button type="button" popovertarget="feed">…</button>
 *     <div id="feed" popover>…</div>
 *
 * The browser draws the panel in the top layer, closes it on Escape and on a click away,
 * and links button and panel for assistive tech. This module adds what it does not do:
 *
 * - places the panel against the button, opening up or down depending on the room
 * - writes `aria-expanded` on the button and `data-direction` on the panel
 * - animates the opening: the panel's height unrolls and its `[data-rise]` rows fade in
 *   one after another, starting from the end nearest the button
 * - closes the whole chain of panels when a `[data-choose]` row is clicked, and raises a
 *   `popover-choose` event carrying the row
 *
 * Closing is not animated here. A closing popover leaves the top layer, and the only way to
 * hold it there is `transition-behavior: allow-discrete` in popover.css.
 */
import { prefersReducedMotion } from '../motion/reduced-motion.ts';
import { revealText } from '../motion/text-reveal.ts';

/** Between the control and the panel's near edge. */
const GAP = 8;

/** The height unrolling. */
const OPEN_MS = 220;
const OPEN_EASE = 'cubic-bezier(0.3, 0, 0.2, 1)';

/** Before the first row starts, and between one row and the next. */
const RISE_LEAD = 60;
const RISE_STAGGER = 40;

/** How far beyond its place a row starts. */
const RISE_DISTANCE = 12;

/** A row settling into place. Its fade is shorter, so it is readable before it stops. */
const RISE_MS = 360;
const RISE_EASE = 'cubic-bezier(0.25, 1, 0.5, 1)';
const FADE_MS = 180;

/** A label arriving word by word, when a `reveal` selector is given. */
const REVEAL_MS = 260;
const REVEAL_FADE = 0.45;

export type PopoverDirection = 'up' | 'down';

/** What a `popover-choose` event carries. */
export type PopoverChoice = { item: HTMLElement };

export type PopoverOptions = {
    gap?: number;
    /** Which way the panel opens. `auto` picks the side with more room. */
    direction?: PopoverDirection | 'auto';
    duration?: number;
    lead?: number;
    stagger?: number;
    /** Called for each rising row with the delay it was given, to start more in step. */
    onRise?: (row: HTMLElement, delay: number) => void;
    /** A selector inside each rising row whose text arrives word by word. */
    reveal?: string;
};

const wired = new WeakSet<HTMLElement>();

/**
 * Which way there is room to open. Decided from the control's rectangle alone, because the
 * panel is still hidden and has no height yet. A tie opens downwards. A panel opened from
 * inside another panel follows that one.
 */
function roomFor(anchor: HTMLElement, gap: number): PopoverDirection {
    const outer = anchor.closest<HTMLElement>('[data-direction]');
    if (outer) return outer.dataset.direction as PopoverDirection;

    const box = anchor.getBoundingClientRect();
    const below = document.documentElement.clientHeight - box.bottom - gap;

    return below >= box.top - gap ? 'down' : 'up';
}

/**
 * Puts the panel against the control. A popover is positioned like a fixed element, so
 * the control's viewport rectangle is already in the right coordinates. The panel is
 * pinned by the edge it grows away from, so the height animation moves the far edge only.
 *
 * `data-side="inline"` on the panel puts it beside the control instead, for a submenu.
 * It clears the panel the control sits in, not the control itself.
 *
 * Runs before the panel shows, so the browser's own position is never painted, and again
 * after, when the panel has a width to keep on screen.
 */
function place(anchor: HTMLElement, panel: HTMLElement, gap: number, to: PopoverDirection): void {
    const box = anchor.getBoundingClientRect();
    const wide = document.documentElement.clientWidth;
    const tall = document.documentElement.clientHeight;
    const width = panel.offsetWidth; // 0 while hidden
    const beside = panel.dataset.side === 'inline';

    // Both edges are written every time; an inline `top` from an earlier opening would
    // otherwise fight a `bottom` set now.
    panel.style.top = to === 'down' ? `${beside ? box.top : box.bottom + gap}px` : 'auto';
    panel.style.bottom = to === 'up' ? `${tall - (beside ? box.bottom : box.top - gap)}px` : 'auto';

    if (!beside) {
        panel.style.left = `${Math.max(gap, Math.min(box.left, wide - width - gap))}px`;
        return;
    }

    const from = anchor.closest<HTMLElement>('[popover]')?.getBoundingClientRect() ?? box;
    const after = from.right + gap;

    panel.style.left = `${after + width <= wide - gap ? after : Math.max(gap, from.left - gap - width)}px`;
}

/**
 * Collapses the panel before it is painted. `toggle` fires after the panel is already
 * showing at full height, so a frame of it would flash; `beforetoggle` runs while it is
 * still hidden.
 */
function collapse(panel: HTMLElement): void {
    panel.style.overflow = 'clip';
    panel.style.height = '0px';
}

/**
 * Animates the height from collapsed to full. Both ends are measured, not assumed: with
 * `box-sizing: border-box`, `height: 0` still renders as padding plus border, and animating
 * from 0 would spend the first part of the curve below that floor with nothing moving.
 * `offsetHeight` rather than `scrollHeight` at the far end, because it includes the border.
 */
function unroll(panel: HTMLElement, duration: number): void {
    const from = panel.offsetHeight;

    panel.style.height = '';
    const to = panel.offsetHeight;

    const opening = panel.animate(
        { height: [`${from}px`, `${to}px`] },
        { duration, easing: OPEN_EASE, fill: 'backwards' },
    );

    void opening.finished
        .catch(() => null)
        .then(() => {
            panel.style.overflow = '';
        });
}

/** The outermost panel in a chain of nested ones. */
function outermost(panel: HTMLElement): HTMLElement {
    let found = panel;
    let above = found.parentElement?.closest<HTMLElement>('[popover]');

    while (above) {
        found = above;
        above = found.parentElement?.closest<HTMLElement>('[popover]');
    }

    return found;
}

/**
 * A `[data-choose]` row was clicked: raise `popover-choose` and close the whole chain.
 * Rows without the attribute, such as one that opens a submenu, do not close anything.
 */
function choose(panel: HTMLElement, item: HTMLElement): void {
    const detail: PopoverChoice = { item };

    panel.dispatchEvent(new CustomEvent('popover-choose', { bubbles: true, detail }));
    outermost(panel).hidePopover();
}

/** The panel's own `[data-rise]` rows, not those of a submenu nested inside it. */
function own(panel: HTMLElement): HTMLElement[] {
    return [...panel.querySelectorAll<HTMLElement>('[data-rise]')].filter((row) => {
        const inner = row.closest('[popover]');
        return inner === panel || !panel.contains(inner);
    });
}

/**
 * Fades the rows in from a little beyond their place, one after another. An upward panel
 * takes its rows in reverse, so the row nearest the control always arrives first.
 */
function raise(panel: HTMLElement, to: PopoverDirection, options: PopoverOptions): void {
    const { lead = RISE_LEAD, stagger = RISE_STAGGER } = options;
    const rows = own(panel);
    const cascade = to === 'up' ? rows.reverse() : rows;
    const from = to === 'up' ? RISE_DISTANCE : -RISE_DISTANCE;

    cascade.forEach((row, index) => {
        const delay = lead + index * stagger;

        row.animate(
            { opacity: [0, 1] },
            { duration: FADE_MS, delay, easing: 'ease-out', fill: 'backwards' },
        );
        row.animate(
            { transform: [`translateY(${from}px)`, 'translateY(0)'] },
            { duration: RISE_MS, delay, easing: RISE_EASE, fill: 'backwards' },
        );
        const label = options.reveal && row.querySelector<HTMLElement>(options.reveal);
        if (label) revealText(label, { duration: REVEAL_MS, fadeShare: REVEAL_FADE, delay });

        options.onRise?.(row, delay);
    });
}

/** Wires every `[popovertarget]` under `root` to the panel it names. Called once. */
export function popovers(root: ParentNode = document, options: PopoverOptions = {}): void {
    const { gap = GAP, duration = OPEN_MS, direction = 'auto' } = options;

    for (const anchor of root.querySelectorAll<HTMLElement>('[popovertarget]')) {
        const panel = document.getElementById(anchor.getAttribute('popovertarget') ?? '');
        if (!panel || wired.has(panel)) continue;
        wired.add(panel);

        // Placing and collapsing happen in `beforetoggle`, before the panel is painted.
        panel.addEventListener('beforetoggle', (event) => {
            if ((event as ToggleEvent).newState !== 'open') return;

            const to = direction === 'auto' ? roomFor(anchor, gap) : direction;
            panel.dataset.direction = to; // read again in `toggle`, and by the stylesheet

            place(anchor, panel, gap, to);
            if (!prefersReducedMotion()) collapse(panel);
        });

        // Only this panel's rows: a click inside a nested panel bubbles through here too.
        panel.addEventListener('click', (event) => {
            const item = (event.target as Element).closest<HTMLElement>('[data-choose]');
            if (item && item.closest('[popover]') === panel) choose(panel, item);
        });

        panel.addEventListener('toggle', (event) => {
            const open = (event as ToggleEvent).newState === 'open';
            const to = (panel.dataset.direction ?? 'down') as PopoverDirection;
            if (open) place(anchor, panel, gap, to); // again, now that it has a width

            // The one attribute the stylesheet and an icon trigger can read the state from.
            anchor.setAttribute('aria-expanded', String(open));

            if (!open || prefersReducedMotion()) return;

            unroll(panel, duration);
            raise(panel, to, options);
        });
    }
}

Icons

Hover a preview to play it. The icons are under the Lordicon License Terms, not the MIT licence of the code.

Preview Used as Lordicon icon
magnifier system-outline-19-magnifier (edited by hand)
cross system-outline-38-cross
file-text system-outline-56-file-text (edited by hand)
clock-arrow-rotate-left system-outline-196-clock-arrow-rotate-left (edited by hand)
link system-outline-11-link (edited by hand)
copy system-outline-378-copy (edited by hand)
trash-bin system-outline-185-trash-bin (edited by hand)
cog system-outline-39-cog (edited by hand)
avatar-circle system-outline-44-avatar-circle (edited by hand)
cross-circle system-outline-25-cross-circle (edited by hand)