sveltelean Primitives
Versionv0.2.0 GitHub

Example

An editor's menus

Open a menu with a click or ArrowDown.

Tab to File, then ArrowRight and ArrowLeft along the bar, ArrowDown to open a menu, and ArrowRight inside it to move to the next menu. With the pointer, open one menu and move across the bar: the open menu follows.

Why this implementation exists

The platform already has most of a menubar: each menu is a popover opened by its item’s popovertarget, with light dismiss, Escape, focus return and the top layer, and the Menu primitive gives each menu its keys. What makes a row of menus a menubar in the WAI-ARIA pattern is the movement between them: one tab stop for the bar, the side arrows along it, and the side arrows inside an open menu moving to the neighbouring menu.

None of that needs memory. The open menu is the popover that matches :popover-open, the focusable item is the one with tabindex="0", and the menus are found through their items’ popovertarget. Three shared listeners (keydown, focusin, pointerover) cover every bar on the page.

The browser owns

  • each menu: showing, light dismiss, Escape, focus return and the top layer (popovertarget)
  • Enter and Space on an item open its menu (native button activation)
  • the expanded state of each item, exposed from popovertarget

Svelte Lean owns

  • one tab stop for the bar; the side arrows, Home and End between its items; RTL
  • ArrowDown to open a menu; the side arrows inside a menu to move to the neighbouring one
  • switching the open menu when the pointer moves to another item
  • the menu behavior inside each menu (the menu contract)
  • menubar.css: the bar, its items and the item whose menu is open

Usage

Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the registration is injected for every static data-slean="menubar"; without it, import the register module once.

+layout.svelte or any client module
import '@svelte-lean/primitives/menubar/register';
stylesheets
import '@svelte-lean/styles/popover.css';
import '@svelte-lean/styles/menu.css';
import '@svelte-lean/styles/menubar.css';

Render a role="menubar" list of role="none" items, each holding a role="menuitem" button with popovertarget and, right after it, its menu: a data-slean="menu" popover labelled by the item. Give the first item tabindex="0" and the others -1.

Listen to slean:select on the bar for the chosen item of any menu; checkbox and radio items keep the menu contract.

Anatomy

PartElementdata-slean-partRequiredNotes
root<ul role="menubar" aria-label="…" data-slean="menubar">–yesNo options.
item<button role="menuitem" popovertarget="…" tabindex="0|-1">–yesInside <li role="none">, before its menu; one has tabindex="0".
menu<div popover role="menu" aria-labelledby="…" data-slean="menu">–yesThe menu contract, labelled by its item.

Runtime profile

The menubar registers keydown, focusin and pointerover handlers with the shared router; each menu registers the menu behavior's own. A thousand bars keep one listener per type (tests/menubar.test.ts).

Accessibility contract

  • role="menubar" named by aria-label; each item a menuitem whose expanded state the platform exposes from popovertarget; each menu labelled by its item.
  • One tab stop for the bar; the side arrows, Home and End move along it, mirrored under dir="rtl".
  • ArrowDown, Enter or Space opens a menu and focus moves to its first item; Escape closes it and focus returns to the item.
  • A menubar is for application commands. Site navigation is a <nav> with links.

Keyboard

KeyWhenResult
ArrowRight/ArrowLeftfocus on the barNext or previous item, wrapping; swapped under dir="rtl"
Home/Endfocus on the barFirst or last item
ArrowDown/Enter/Spacefocus on an itemOpens its menu; focus moves to the first menu item
ArrowRight/ArrowLeftfocus in a menuCloses it and opens the next or previous menu
Escapefocus in a menuCloses it; focus returns to its item

Platform features

FeatureBaselineOutside the target
Popover APIBaseline 2024 (Chrome 114, Safari 17, Firefox 125)The menus render inline; the bar still lists its items
:has()Baseline 2023The item whose menu is open is not drawn pressed

Without JavaScript

Each item opens its menu natively through popovertarget; the menus' keys and the bar's arrows need the runtime, and Tab reaches only the first item.

Server rendering

Render the bar with one item at tabindex 0 and every menu closed.

Before hydration

Before the behaviors load, a click opens a menu (native) but the arrow keys do nothing. The first key after hydration works on the server's markup.

Styling

menubar.css lays the items out in a bar and draws the item whose menu is open as pressed, with :has() on the list item; the menus are drawn by popover.css and menu.css. The tokens it reads:

TokenDefault (light)Applies to
--slean-space-10.25rempadding
--slean-bordervar(--slean-neutral-6)border
--slean-radius-md0.625remborder-radius
--slean-surfaceoklch(100% 0 0)background
--slean-control-height-sm2remmin-block-size
--slean-space-30.75rempadding-inline
--slean-radius-sm0.375remborder-radius
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-font-weight-medium500font-weight
--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-accent-softoklch(95% 0.03 258)background
--slean-accent-soft-fgoklch(42% 0.17 258)color
--slean-control-height-md2.25remmin-block-size

State selectors the stylesheet targets, all from the platform or ARIA: :focus-visible, :hover, :popover-open, [aria-disabled="true"], [role="menu"], [role="menuitem"].

Compatibility notes

The Popover API is Baseline 2024 and :has() Baseline 2023. Submenus and mnemonics are not part of the primitive.

Testing

Source