sveltelean Primitives
Versionv0.2.0 GitHub

Example

File menu
Open with the button, then ArrowDown and ArrowUp, Home and End, type a letter, Enter or Space; Escape closes and returns focus to the button. The checkbox item toggles aria-checked, the radio items are exclusive inside their group, the disabled item is skipped.

Last slean:select on the example: No selection yet.

Why this implementation exists

A menu button is two things. Visibility, light dismiss, Escape, the top layer, focus return and aria-expanded are the Popover API's job, and the browser does them. Desktop-style keyboard behavior inside the menu is not: arrow keys between items, Home and End, typeahead, Space on a link item, closing on selection, and the checked state of checkbox and radio items. Svelte Lean adds only that second part, as one behavior shared by every menu on the page, and calls hidePopover() to close. This is the composition ADR 0001 aims at: native visibility plus a small keyboard behavior, instead of JavaScript visibility, dismiss, portal, focus, positioning and keyboard.

The browser owns

  • showing and hiding the popover, the top layer and light dismiss
  • aria-expanded on the invoker and focus return for an auto popover
  • Enter on a focused <button> or <a> item (native activation)
  • placement, through CSS anchor positioning in the stylesheet

Svelte Lean owns

  • focus on open, ArrowUp and ArrowDown, Home and End, typeahead, Space, Escape and Tab
  • aria-checked of checkbox and radio items and the cancelable slean:select event
  • closeMenu(), menuItems() and menuInvoker()
  • development validation of the markup
  • menu.css and the contract

Usage

With the Vite plugin, every static data-slean="menu" gets import '@svelte-lean/primitives/menu/register' appended to its compiled module; without the plugin, write that import yourself once. menu.css imports popover.css itself, because a menu is a popover: the surface, the placement and the open transition live there.

the manual escape hatch
// Any module that runs once on the client, for example the root layout. Safe on the server,
// idempotent, and the same module the plugin injects.
import '@svelte-lean/primitives/menu/register';
app.css or +layout.svelte
import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/menu.css'; /* imports popover.css: the surface and placement live there */

Use a menu for a list of actions or settings attached to a button, with desktop-style keyboard behavior: file menus, row actions, view options. Site navigation is a <nav> with links, not a menu. The one option keeps focus on the invoker when the popover opens:

data-slean-autofocus
<!-- Keep focus on the invoker when the popover opens: -->
<div id="file-menu" popover role="menu" aria-labelledby="file-button" data-slean="menu" data-slean-autofocus="none">

Anatomy

PartElementdata-slean-partRequiredNotes
root<div id="…" popover role="menu" aria-labelledby="<invoker id>" data-slean="menu">–yespopover (auto) gives the top layer, light dismiss and Escape. Option: data-slean-autofocus="first" (default) or "none".
invoker<button type="button" id="…" popovertarget="<menu id>" aria-haspopup="menu">–yesaria-labelledby on the menu points at it, which names the menu and lets the behavior find it.
item<button type="button"> or <a href> with role="menuitem", "menuitemcheckbox" or "menuitemradio"itemnoItems are found by role; data-slean-part="item" is a styling hook only. Checkbox and radio items carry aria-checked.
group<div role="group" aria-label="…">–noScopes exclusive radio items.
separator<div role="separator"> or <hr role="separator">–noA rule between items.
label<span>labelstyles onlyA group heading.
shortcut<span>shortcutstyles onlyA trailing hint inside an item; it does nothing by itself.
submenu-trigger<button>submenu-triggerstyles onlyDraws a chevron. Submenus are not implemented.

Runtime profile

The bytes in the runtime block are the production build of @svelte-lean/primitives/menu/register including the shared kernel, from packages/primitives/artifacts/size.json (1932 B brotli · 2162 B gzip). The kernel is shared: a page with tabs and menu builds to one Svelte Lean chunk of 2351 B brotli in the tabs-and-menu fixture, against 1521 B brotli for tabs alone. The three listeners (click, keydown and toggle in the capture phase) serve every menu on the page; one typeahead buffer exists for the whole page and is created on first use. There is no per-root state, no observer and no layout read; the proof page shows the listener account of this site, where tabs and menu share the click and keydown entries.

Accessibility contract

  • Root: popover, role="menu", an id and aria-labelledby pointing at the invoker, which names the menu and lets the behavior find the invoker. Invoker: aria-haspopup="menu"; aria-expanded comes from popovertarget.
  • Items are <button type="button"> or <a href> with role="menuitem", menuitemcheckbox or menuitemradio; checkbox and radio items carry aria-checked, radio items are grouped by the nearest role="group". Separators are role="separator".
  • Focus: on open the first enabled item receives focus (or nothing with data-slean-autofocus="none"). On close (Escape, selection, Tab) the platform returns focus to the invoker for an auto popover; the behavior also focuses the invoker it finds through aria-labelledby when focus is not there, which covers popover="manual".
  • Disabled: data-slean-disabled or aria-disabled="true" on the root skips routing. An item with disabled is not focusable; with aria-disabled="true" it is focusable, skipped by arrows, Home, End and typeahead, and its click is prevented.
  • Editable elements inside the root keep their keys; only Escape and Tab still close the menu. Vertical only; no horizontal keys, nothing to flip under RTL.
  • Development validation warns about: a root without popover, role="menu" or id; items that are not <button> or <a>; checkbox or radio items without aria-checked; data-slean-part="item" without a menuitem role.

Keyboard

KeyWhenResult
Enter/Spacefocus on the invokerToggles the popover (native); the first enabled item receives focus
ArrowDown/ArrowUpfocus inside the menuNext or previous enabled item, wrapping
Home/Endfocus inside the menuFirst or last enabled item
a characterfocus inside the menuTypeahead over item text; a repeated character cycles; the buffer resets after the typeahead timeout
Enterfocus on an itemNative activation of the button or link; its click selects
Spacefocus on an itemClicks the item without scrolling the page, so link items activate too
Escapefocus inside the menuHides the popover and focuses the invoker
Tab/Shift+Tabfocus inside the menuHides the popover; sequential navigation continues from the invoker
ArrowLeft/ArrowRightfocus inside the menuNot handled (reserved for submenus)

Keys with Alt, Ctrl or Meta are ignored, except Escape and Tab. On the invoker, Enter and Space toggle the popover natively; ArrowDown on the invoker is not handled because the invoker is outside the root. The typeahead timeout is the default of createTypeahead() in @svelte-lean/core.

Platform features

FeatureBaselineOutside the target
Popover API: popover, popovertarget, :popover-open, ToggleEvent, hidePopover()Newly available since April 2024 (Chrome 114, Safari 17, Firefox 125)The attribute is ignored and the menu renders inline
CSS anchor positioning: position-areaChrome 125 and Safari 26; not Baseline at the time of writingThe menu keeps the platform default: centered in the top layer
ARIA menu roles (menu, menuitem, menuitemcheckbox, menuitemradio, group, separator)ARIA 1.2, widely supported by assistive technologyNot applicable within the support policy

Without JavaScript

With the Popover API the menu opens and closes, link items work, button items do nothing and movement inside is Tab-based. Without Popover API support the attribute is ignored and the content renders inline. Nothing is hidden by this: an unregistered menu is a popover with buttons in it.

Server rendering

Static HTML; the menu is closed on load and the initial aria-checked values are authored. The register module is safe to import on the server: without a document the registration waits until a browser runtime exists (packages/primitives/tests/ssr.test.ts).

Before hydration

The popover opens before any script because the browser owns visibility; keyboard movement and the checked state need the register module, which evaluates before the component code once the scripts arrive. Hydration attaches nothing to a menu root: the toggle listener is on the document, installed when the behavior registered, and the first open is the first work done for a root.

Styling

menu.css styles the rows by role, the separator, the group label and the shortcut hint, the checked mark on [aria-checked="true"], an inset focus ring for :focus-visible, and the submenu chevron (styling only; submenus are not implemented). The surface, placement and open transition come from popover.css. The tokens it reads and the states it targets:

TokenDefault (light)Applies to
--slean-space-10.25rempadding, padding-block, margin-block, margin-inline
--slean-space-20.5remgap, padding-inline, padding-inline-start, inset-inline-start
--slean-control-height-sm2remmin-block-size
--slean-radius-sm0.375remborder-radius
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-mutedvar(--slean-neutral-3)background
--slean-focus-ringvar(--slean-focus-ring-width) solid var(--slean-focus-ring-color)outline
--slean-focus-ring-width2pxoutline-offset
--slean-icon-checkurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
--slean-bordervar(--slean-neutral-6)background
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-xs0.75remfont-size
--slean-font-weight-medium500font-weight
--slean-space-41rempadding-inline-start
--slean-icon-chevron-righturl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
--slean-dangeroklch(55% 0.2 25)color
--slean-danger-softoklch(95.5% 0.03 25)background
--slean-danger-soft-fgoklch(45% 0.18 25)color

State selectors the stylesheet targets, all from the platform or ARIA: :dir(rtl), :disabled, :focus-visible, :hover, [aria-checked="true"], [aria-disabled="true"], [aria-expanded="true"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="separator"].

Variant attributes: data-variant (danger).

Controlled integration

What exists today: the root dispatches a bubbling, cancelable slean:select CustomEvent after the checked state is updated and before the popover is hidden, with detail of type MenuSelectDetail (item, value, checked); preventDefault() keeps the menu open. For <a> items the navigation follows the click as usual; cancel the click event to stop it. closeMenu(), menuItems() and menuInvoker() from @svelte-lean/primitives/menu are the helpers. An application listens with addEventListener, as the status line under the example does.

file-menu.svelte
<script lang="ts">
	import { closeMenu, type MenuSelectDetail } from '@svelte-lean/primitives/menu';

	let root: HTMLElement;
	let wrap = $state(false);

	$effect(() => {
		// slean:select bubbles from the root after aria-checked is updated and before the
		// popover is hidden; preventDefault() keeps the menu open.
		const onSelect = (event: Event) => {
			const { value, checked } = (event as CustomEvent<MenuSelectDetail>).detail;
			if (value === 'wrap') {
				wrap = checked === true;
				event.preventDefault();
			}
		};
		root.addEventListener('slean:select', onSelect);
		return () => root.removeEventListener('slean:select', onSelect);
	});
</script>

<div id="file-menu" popover role="menu" aria-labelledby="file-button" data-slean="menu" bind:this={root}>
	…
</div>
<button type="button" onclick={() => closeMenu(root)}>Close the menu from outside</button>

Note A Svelte adapter is not built. Submenus, menubars, context menus (right click) and ArrowDown on the invoker are not implemented; each would be documented in the contract before it is built.

Compatibility notes

The menu depends on the Popover API (Baseline newly available since April 2024) for visibility and on CSS anchor positioning (Chrome 125, Safari 26; not Baseline) for placement, with the same fallbacks as Popover: inline content without the API, centered placement without anchor positioning. The behavior needs ES2022 output and composedPath(). Tested in Chromium; Firefox and WebKit runs do not exist yet. The policy is on Browser support.

Testing

Source