A composer at the foot of the page, so its menu opens upward, with a submenu and a search inside.
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/input-form#standalone input-form
Copies the input-form folder of the
standalone branch — where every demo lives
on its own — into ./input-form.
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();
import { defineElement } from '@lordicon/element';
// Registers <lord-icon> with its built-in triggers. The markup says what each icon follows.
defineElement();
The composer and the plus button popovertarget is all it takes to open the menu.
<!-- popovertarget on the plus is what opens the menu. Which way it opens is
decided by popovers() from the room around the button. -->
<form class="composer">
<label class="visually-hidden" for="prompt">Message</label>
<!-- prettier-ignore -->
<input class="composer__input" id="prompt" type="text"
placeholder="What's on your mind?" autocomplete="off" />
<div class="composer__tools">
<button
class="plus"
type="button"
popovertarget="more"
aria-label="Add context"
>
<lord-icon
class="plus__mark"
current-color
src="icons/plus.json"
loading="interaction"
trigger="hover"
target=".plus"
aria-hidden="true"
>
<img alt="" src="icons/plus.svg" />
</lord-icon>
</button>
<!-- … -->
</div>
</form>
The row that opens the submenu No data-choose: it is a route, not an answer. Two icons share one target.
<!-- The row that opens the submenu. No data-choose, because choosing
closes the menus. popovertargetaction="show", not toggle: the pointer
already opens it, and a click must not close it again. -->
<li class="menu__row" data-rise>
<button
class="row"
type="button"
popovertarget="projects"
popovertargetaction="show"
>
<lord-icon
class="row__icon"
current-color
src="icons/folder.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
<span class="row__label" data-reveal>Add to project</span>
<!-- Both icons target the row, so both play on the same hover. -->
<lord-icon
class="row__more"
current-color
src="icons/chevron-right.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
</button>
</li>
Wire the panels and the field Two calls into shared/ui. The demo's own code starts below.
// popovers() places each panel, picks the direction it opens in, animates the rows in and
// closes the chain when a data-choose row is clicked. `reveal` names the labels whose
// words arrive one by one. fields() writes the search field's two attributes.
popovers(document, { reveal: '[data-reveal]' });
fields();
// popovers() places each panel, picks the direction it opens in, animates the rows in and
// closes the chain when a data-choose row is clicked. `reveal` names the labels whose
// words arrive one by one. fields() writes the search field's two attributes.
popovers(document, { reveal: '[data-reveal]' });
fields();
Open the submenu on hover, close it after a grace period Opening on click and closing on Escape are the browser's.
let leaving: ReturnType<typeof setTimeout> | undefined;
// The pointer resting on the row opens the submenu; leaving both closes it after a grace
// period, unless focus is inside it. Click and Enter open it through popovertargetaction;
// Escape and clicking away are the browser's.
for (const element of [opener, projects]) {
element.addEventListener('pointerenter', () => {
clearTimeout(leaving);
if (!projects.matches(':popover-open')) projects.showPopover();
});
element.addEventListener('pointerleave', () => {
clearTimeout(leaving);
leaving = setTimeout(() => {
if (!projects.contains(document.activeElement)) projects.hidePopover();
}, GRACE);
});
}
let leaving;
// The pointer resting on the row opens the submenu; leaving both closes it after a grace
// period, unless focus is inside it. Click and Enter open it through popovertargetaction;
// Escape and clicking away are the browser's.
for (const element of [opener, projects]) {
element.addEventListener('pointerenter', () => {
clearTimeout(leaving);
if (!projects.matches(':popover-open')) projects.showPopover();
});
element.addEventListener('pointerleave', () => {
clearTimeout(leaving);
leaving = setTimeout(() => {
if (!projects.contains(document.activeElement)) projects.hidePopover();
}, GRACE);
});
}
After a choice popovers() closes the panels and raises popover-choose.
// A data-choose row was clicked, at either level. popovers() has closed the panels; the
// cursor goes back to the composer.
document.addEventListener('popover-choose', () => prompt.focus());
composer.addEventListener('submit', (event) => {
event.preventDefault();
prompt.value = '';
prompt.focus();
});
// A data-choose row was clicked, at either level. popovers() has closed the panels; the
// cursor goes back to the composer.
document.addEventListener('popover-choose', () => prompt.focus());
composer.addEventListener('submit', (event) => {
event.preventDefault();
prompt.value = '';
prompt.focus();
});
Filter the projects hidden rather than a class: out of the layout and the accessibility tree.
const rows = [...projects.querySelectorAll<HTMLElement>('.projects__row')];
const search = projects.querySelector<HTMLInputElement>('.field__input')!;
// The clear button raises `input` too, so clearing the search shows every row again.
search.addEventListener('input', () => {
const wanted = search.value.trim().toLowerCase();
for (const row of rows) {
row.hidden = !row.textContent!.toLowerCase().includes(wanted);
}
});
const rows = [...projects.querySelectorAll('.projects__row')];
const search = projects.querySelector('.field__input');
// The clear button raises `input` too, so clearing the search shows every row again.
search.addEventListener('input', () => {
const wanted = search.value.trim().toLowerCase();
for (const row of rows) {
row.hidden = !row.textContent.toLowerCase().includes(wanted);
}
});
Every file
The demo's own files, whole, at the paths the export uses.
index.html 229 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Input form</title>
<link rel="stylesheet" href="./input-form.css" />
<script type="module" src="./main.ts"></script>
</head>
<body>
<main class="demo">
<!-- popovertarget on the plus is what opens the menu. Which way it opens is
decided by popovers() from the room around the button. -->
<form class="composer">
<label class="visually-hidden" for="prompt">Message</label>
<!-- prettier-ignore -->
<input class="composer__input" id="prompt" type="text"
placeholder="What's on your mind?" autocomplete="off" />
<div class="composer__tools">
<button
class="plus"
type="button"
popovertarget="more"
aria-label="Add context"
>
<lord-icon
class="plus__mark"
current-color
src="icons/plus.json"
loading="interaction"
trigger="hover"
target=".plus"
aria-hidden="true"
>
<img alt="" src="icons/plus.svg" />
</lord-icon>
</button>
<div class="composer__say">
<button class="mic" type="button" aria-label="Dictate">
<lord-icon
class="tool__mark"
current-color
src="icons/microphone.json"
loading="interaction"
trigger="hover"
target=".mic"
aria-hidden="true"
>
<img alt="" src="icons/microphone.svg" />
</lord-icon>
</button>
<button class="send" type="submit" aria-label="Send">
<lord-icon
class="tool__mark"
current-color
src="icons/arrow-up.json"
loading="interaction"
trigger="hover"
target=".send"
aria-hidden="true"
>
<img alt="" src="icons/arrow-up.svg" />
</lord-icon>
</button>
</div>
</div>
</form>
<!-- The submenu is nested inside this panel, so the browser treats the two as
a pair: opening the inner does not dismiss the outer. -->
<div class="menu popover" id="more" popover>
<ul class="menu__list">
<li class="menu__row" data-rise>
<button class="row" type="button" data-choose>
<lord-icon
class="row__icon"
current-color
src="icons/link.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
<span class="row__label" data-reveal>Add files or photos</span>
</button>
</li>
<!-- The row that opens the submenu. No data-choose, because choosing
closes the menus. popovertargetaction="show", not toggle: the pointer
already opens it, and a click must not close it again. -->
<li class="menu__row" data-rise>
<button
class="row"
type="button"
popovertarget="projects"
popovertargetaction="show"
>
<lord-icon
class="row__icon"
current-color
src="icons/folder.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
<span class="row__label" data-reveal>Add to project</span>
<!-- Both icons target the row, so both play on the same hover. -->
<lord-icon
class="row__more"
current-color
src="icons/chevron-right.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
</button>
</li>
<li class="menu__row" data-rise>
<button class="row" type="button" data-choose>
<lord-icon
class="row__icon"
current-color
src="icons/bulb.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
<span class="row__label" data-reveal>Skills</span>
</button>
</li>
<li class="menu__row" data-rise>
<button class="row" type="button" data-choose>
<lord-icon
class="row__icon"
current-color
src="icons/plug.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
<span class="row__label" data-reveal>Add plugins</span>
</button>
</li>
</ul>
<div class="menu submenu popover" id="projects" popover data-side="inline">
<div class="field" data-focused="false" data-clearable="false" data-rise>
<label class="visually-hidden" for="project">Search projects</label>
<lord-icon
class="field__mark"
current-color
src="icons/magnifier.json"
trigger="follow(data-focused)"
target=".field"
aria-hidden="true"
></lord-icon>
<input
class="field__input"
id="project"
type="search"
placeholder="Search projects"
/>
<button class="field__clear" type="button" aria-label="Clear search">
<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>
<ul class="projects">
<li class="projects__row" data-rise>
<button class="row" type="button" data-choose>
<lord-icon
class="row__icon"
current-color
src="icons/folder.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
<span class="row__label" data-reveal>Gardening</span>
</button>
</li>
<li class="projects__row" data-rise>
<button class="row" type="button" data-choose>
<lord-icon
class="row__icon"
current-color
src="icons/folder.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
<span class="row__label" data-reveal>Finance App</span>
</button>
</li>
<li class="projects__row" data-rise>
<button class="row" type="button" data-choose>
<lord-icon
class="row__icon"
current-color
src="icons/folder.json"
trigger="hover"
target=".row"
aria-hidden="true"
></lord-icon>
<span class="row__label" data-reveal>My wedding</span>
</button>
</li>
</ul>
</div>
</div>
</main>
</body>
</html>
input-form.css 242 lines
@import '@shared/styles/index.css';
@import '@shared/ui/popover.css';
@import '@shared/ui/menu.css';
/* The composer sits at the bottom, which is why the menu opens upward. */
.demo {
display: grid;
align-content: end;
justify-items: center;
min-height: 100dvh;
padding: var(--space-64) var(--gutter) var(--space-48);
}
/* The border and the halo, at rest and focused. The send button shares the halo, so the
page has one glow at a time. :focus-within rather than an attribute: no icon watches
the composer. */
.composer {
--composer-line: var(--border);
--composer-halo: none;
}
.composer:focus-within {
--composer-line: var(--accent-border);
--composer-halo: var(--shadow-accent);
}
.composer {
display: grid;
gap: var(--space-8);
width: min(374px, 100%);
padding: var(--space-12);
border: 1px solid var(--composer-line);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--composer-halo);
}
.composer__input {
padding: 0;
border: 0;
background: none;
color: var(--ink);
}
.composer__input::placeholder {
color: var(--ink-subtle);
}
/* The box draws the focused state; no second ring inside it. */
.composer__input:focus-visible {
outline: none;
}
.composer__input::-webkit-search-cancel-button {
appearance: none;
}
.composer__tools {
display: flex;
align-items: center;
justify-content: space-between;
}
.composer__say {
display: flex;
align-items: center;
gap: var(--space-16);
}
/* Darker while the menu is open. aria-expanded is written by popovers(). */
.plus {
display: grid;
place-items: center;
width: var(--icon-size-40);
height: var(--icon-size-40);
padding: 0;
border: 0;
border-radius: var(--radius-md);
background: var(--surface-muted);
color: var(--ink);
--icon-filter: var(--filter-ink);
cursor: pointer;
}
.plus[aria-expanded='true'] {
background: var(--border-soft);
}
.mic {
display: grid;
place-items: center;
padding: 0;
border: 0;
background: none;
color: var(--ink-muted);
--icon-filter: var(--filter-ink-muted);
cursor: pointer;
}
.mic:hover {
color: var(--ink);
}
.send {
display: grid;
place-items: center;
padding: var(--space-8);
border: 0;
border-radius: var(--radius-md);
background: var(--accent);
color: var(--ink-inverse);
--icon-filter: var(--filter-ink-inverse);
box-shadow: var(--composer-halo);
cursor: pointer;
}
.send:hover {
background: var(--accent-strong);
}
.plus__mark,
.tool__mark {
display: block;
width: var(--icon-size-24);
height: var(--icon-size-24);
}
/* Both panels use menu.css, one spacing step tighter than the notification menu.
`flex: none` on the lists: a column animating from height 0 would squash them. */
.menu {
--menu-width: 280px;
--menu-gap: var(--space-8);
--menu-pad: var(--space-8);
}
.menu__list,
.projects {
flex: none;
margin: 0;
padding: 0;
list-style: none;
}
/* Invisible strips down both sides of the submenu bridge the gap to the menu, so the
pointer crossing it is never over nothing. Both sides, because the panel can open on
either side of the menu. */
.submenu::before,
.submenu::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: var(--space-12);
}
.submenu::before {
right: 100%;
}
.submenu::after {
left: 100%;
}
/* The chevron on the row that opens the submenu. */
.row__more {
flex: none;
width: var(--icon-size-20);
height: var(--icon-size-20);
color: var(--ink-subtle);
}
/* An empty list would still take the column's gap, so it is removed instead. */
.projects:not(:has(.projects__row:not([hidden]))) {
display: none;
}
/* The search bar's field. */
.field {
display: flex;
align-items: center;
gap: var(--space-8);
flex: none;
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__mark {
flex: none;
width: var(--icon-size-24);
height: var(--icon-size-24);
color: var(--ink-muted);
}
.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);
}
.field__input:focus-visible {
outline: none;
}
.field__input::-webkit-search-cancel-button {
appearance: none;
}
/* Invisible but still taking up room, so the field does not change width when the
button appears. */
.field__clear {
display: grid;
visibility: hidden;
place-items: center;
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);
}
main.ts 63 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 { popovers } from '@shared/ui/popover.ts';
const composer = document.querySelector<HTMLFormElement>('.composer')!;
const prompt = document.querySelector<HTMLInputElement>('.composer__input')!;
const projects = document.querySelector<HTMLElement>('.submenu')!;
const opener = document.querySelector<HTMLButtonElement>('[popovertarget="projects"]')!;
// popovers() places each panel, picks the direction it opens in, animates the rows in and
// closes the chain when a data-choose row is clicked. `reveal` names the labels whose
// words arrive one by one. fields() writes the search field's two attributes.
popovers(document, { reveal: '[data-reveal]' });
fields();
/** How long the pointer may be off the row and the submenu before the submenu closes. */
const GRACE = 150;
let leaving: ReturnType<typeof setTimeout> | undefined;
// The pointer resting on the row opens the submenu; leaving both closes it after a grace
// period, unless focus is inside it. Click and Enter open it through popovertargetaction;
// Escape and clicking away are the browser's.
for (const element of [opener, projects]) {
element.addEventListener('pointerenter', () => {
clearTimeout(leaving);
if (!projects.matches(':popover-open')) projects.showPopover();
});
element.addEventListener('pointerleave', () => {
clearTimeout(leaving);
leaving = setTimeout(() => {
if (!projects.contains(document.activeElement)) projects.hidePopover();
}, GRACE);
});
}
// A data-choose row was clicked, at either level. popovers() has closed the panels; the
// cursor goes back to the composer.
document.addEventListener('popover-choose', () => prompt.focus());
composer.addEventListener('submit', (event) => {
event.preventDefault();
prompt.value = '';
prompt.focus();
});
const rows = [...projects.querySelectorAll<HTMLElement>('.projects__row')];
const search = projects.querySelector<HTMLInputElement>('.field__input')!;
// The clear button raises `input` too, so clearing the search shows every row again.
search.addEventListener('input', () => {
const wanted = search.value.trim().toLowerCase();
for (const row of rows) {
row.hidden = !row.textContent!.toLowerCase().includes(wanted);
}
});
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.js';
import { popovers } from '@shared/ui/popover.js';
const composer = document.querySelector('.composer');
const prompt = document.querySelector('.composer__input');
const projects = document.querySelector('.submenu');
const opener = document.querySelector('[popovertarget="projects"]');
// popovers() places each panel, picks the direction it opens in, animates the rows in and
// closes the chain when a data-choose row is clicked. `reveal` names the labels whose
// words arrive one by one. fields() writes the search field's two attributes.
popovers(document, { reveal: '[data-reveal]' });
fields();
/** How long the pointer may be off the row and the submenu before the submenu closes. */
const GRACE = 150;
let leaving;
// The pointer resting on the row opens the submenu; leaving both closes it after a grace
// period, unless focus is inside it. Click and Enter open it through popovertargetaction;
// Escape and clicking away are the browser's.
for (const element of [opener, projects]) {
element.addEventListener('pointerenter', () => {
clearTimeout(leaving);
if (!projects.matches(':popover-open')) projects.showPopover();
});
element.addEventListener('pointerleave', () => {
clearTimeout(leaving);
leaving = setTimeout(() => {
if (!projects.contains(document.activeElement)) projects.hidePopover();
}, GRACE);
});
}
// A data-choose row was clicked, at either level. popovers() has closed the panels; the
// cursor goes back to the composer.
document.addEventListener('popover-choose', () => prompt.focus());
composer.addEventListener('submit', (event) => {
event.preventDefault();
prompt.value = '';
prompt.focus();
});
const rows = [...projects.querySelectorAll('.projects__row')];
const search = projects.querySelector('.field__input');
// The clear button raises `input` too, so clearing the search shows every row again.
search.addEventListener('input', () => {
const wanted = search.value.trim().toLowerCase();
for (const row of rows) {
row.hidden = !row.textContent.toLowerCase().includes(wanted);
}
});
What it imports 10 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;
}
/**
* 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() {
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' },
);
}
/**
* 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.js';
/** 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)';
/**
* 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) {
const already = element.querySelectorAll('[data-word]');
if (already.length) return [...already];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const texts = [];
while (walker.nextNode()) texts.push(walker.currentNode);
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('[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) {
for (const animation of element.getAnimations()) animation.cancel();
for (const word of element.querySelectorAll('[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, options = {}) {
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, options = {}) {
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
/*
* 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
/* The one stylesheet a demo imports. */
@import './palette.css';
@import './tokens.css';
@import './base.css';
shared/styles/palette.css 87 lines
/*
* 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
/*
* 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.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();
});
}
/**
* 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;
/** Wires every `.field` under `root`. Called once; nothing here needs taking apart. */
export function fields(root = document, options = {}) {
for (const field of root.querySelectorAll('.field')) {
wire(field, options.settled ?? SETTLED);
}
}
function wire(field, settled) {
const input = field.querySelector('.field__input');
if (!input) return;
const clear = field.querySelector('.field__clear');
let settling;
const offer = (clearable) => {
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)) 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
/*
* 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/popover.css 63 lines
/*
* 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);
});
}
}
/**
* 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.js';
import { revealText } from '../motion/text-reveal.js';
/** 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;
/** What a `popover-choose` event carries. */
const wired = new WeakSet();
/**
* 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, gap) {
const outer = anchor.closest('[data-direction]');
if (outer) return outer.dataset.direction;
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, panel, gap, to) {
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('[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) {
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, duration) {
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) {
let found = panel;
let above = found.parentElement?.closest('[popover]');
while (above) {
found = above;
above = found.parentElement?.closest('[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, item) {
const detail = { 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) {
return [...panel.querySelectorAll('[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, to, options) {
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(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 = document, options = {}) {
const { gap = GAP, duration = OPEN_MS, direction = 'auto' } = options;
for (const anchor of root.querySelectorAll('[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.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.closest('[data-choose]');
if (item && item.closest('[popover]') === panel) choose(panel, item);
});
panel.addEventListener('toggle', (event) => {
const open = event.newState === 'open';
const to = panel.dataset.direction ?? 'down';
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 |
|---|---|---|
plus |
system-outline-48-plus | |
microphone |
system-outline-188-microphone (edited by hand) | |
arrow-up |
system-outline-2754-arrow-up (edited by hand) | |
link |
system-outline-11-link (edited by hand) | |
folder |
system-outline-120-folder (edited by hand) | |
chevron-right |
system-outline-31-chevron-right (edited by hand) | |
bulb |
system-outline-36-bulb (edited by hand) | |
plug |
system-outline-452-plug (edited by hand) | |
magnifier |
system-outline-19-magnifier (edited by hand) | |
cross |
system-outline-38-cross |