Menubar

A desktop-style bar of menus, such as File, Edit and View.

StableSince 1.0.0
On this page

Import

import { Menubar } from '@envsemble/envui';

Usage

Menubar.Root, named by its aria-label, holds a Menubar.Menu for each menu.

  • A Menubar.Menu takes its trigger's label, such as File, and its items as children: Menu.Item, Menu.Submenu, Menu.Separator and Menu's other parts, with their shortcuts.
  • disabled dims one menu's trigger, or on Menubar.Root every menu.
  • A menu opens on its own, or with open, defaultOpen and onOpenChange.
NotesMenubar.tsx
import { useRef, useState } from 'react';
import { Menu, Menubar, Column, Surface, Text, Textarea } from '@envsemble/envui';
import { IconContentCopy } from '@envsemble/envui/icons/ContentCopy';
import { IconHistory } from '@envsemble/envui/icons/History';
import { IconNoteAdd } from '@envsemble/envui/icons/NoteAdd';
import { IconSave } from '@envsemble/envui/icons/Save';

const recent = ['Roadmap', 'Interview notes', 'Launch checklist'];

export default function NotesMenubar() {
  const note = useRef<HTMLTextAreaElement>(null);
  // The field that had focus before the menubar took it, which Edit commands act on.
  const editTarget = useRef<Element | null>(null);
  const [lastCommand, setLastCommand] = useState('');
  const [wordWrap, setWordWrap] = useState(true);

  const field = () =>
    editTarget.current instanceof HTMLTextAreaElement ? editTarget.current : note.current;

  return (
    <Column gap={3} style={{ inlineSize: 'min(100%, 28rem)' }}>
      {/* Stands in for the application's window. */}
      <Surface background="default" padding={3}>
        <Column gap={3}>
          <Menubar.Root aria-label="Notes menu" accessKeys>
            <Menubar.Menu label="File" accessKey="F">
              <Menu.Item
                icon={<IconNoteAdd />}
                shortcut="Ctrl+N"
                onClick={() => setLastCommand('New note')}
              >
                New note
              </Menu.Item>
              <Menu.Submenu label="Open recent" icon={<IconHistory />}>
                {recent.map((name) => (
                  <Menu.Item key={name} onClick={() => setLastCommand(`Opened ${name}`)}>
                    {name}
                  </Menu.Item>
                ))}
              </Menu.Submenu>
              <Menu.Separator />
              <Menu.Item
                icon={<IconSave />}
                shortcut="Ctrl+S"
                onClick={() => setLastCommand('Saved')}
              >
                Save
              </Menu.Item>
            </Menubar.Menu>
            <Menubar.Menu
              label="Edit"
              accessKey="E"
              onOpenChange={(open, { previousFocus }) => {
                if (open) editTarget.current = previousFocus;
              }}
            >
              <Menu.Item
                icon={<IconContentCopy />}
                shortcut="Ctrl+C"
                onClick={() => {
                  const target = field();
                  if (!target) return;
                  const text = target.value.slice(target.selectionStart, target.selectionEnd);
                  void navigator.clipboard?.writeText(text).catch(() => undefined);
                  setLastCommand(text ? `Copied ${text.length} characters` : 'Nothing selected');
                }}
              >
                Copy
              </Menu.Item>
              <Menu.Item
                shortcut="Ctrl+A"
                onClick={() => {
                  field()?.select();
                  setLastCommand('Selected all');
                }}
              >
                Select all
              </Menu.Item>
            </Menubar.Menu>
            <Menubar.Menu label="View" accessKey="V">
              <Menu.CheckboxItem checked={wordWrap} onCheckedChange={setWordWrap}>
                Word wrap
              </Menu.CheckboxItem>
            </Menubar.Menu>
            <Menubar.Menu label="Help" accessKey="H">
              <Menu.Item onClick={() => setLastCommand('Keyboard shortcuts')}>
                Keyboard shortcuts
              </Menu.Item>
            </Menubar.Menu>
          </Menubar.Root>
          <Textarea
            ref={note}
            label="Note"
            rows={4}
            wrap={wordWrap ? 'soft' : 'off'}
            defaultValue="Ship the pricing page on Friday. Ask Sam Lee to review the copy."
          />
        </Column>
      </Surface>
      <Text variant="caption" emphasis="muted" role="status">
        {lastCommand}
      </Text>
    </Column>
  );
}

Commands that edit a field

The menubar returns focus to where it was after a command. onOpenChange(open, details) receives details.previousFocus, the element that had focus before the menubar took it, so an Edit command such as Copy acts on that field.

<Menubar.Menu
  label="Edit"
  onOpenChange={(open, { previousFocus }) => {
    if (open) editTarget.current = previousFocus;
  }}
>
  …
</Menubar.Menu>

Access keys

With accessKeys on Menubar.Root, holding Alt underlines each menu's accessKey, a letter of its label such as F for File.

  • Alt with the letter opens that menu.
  • Alt alone moves focus to the menubar and back.
  • They are off by default; turn them on for one menubar per window.
  • A menubar in an inert region, such as behind a modal dialog, takes none from outside it.

In a title bar

In a window that draws its own title bar, a TopBar with titleBar, put the menubar in its start, as the app frame pattern does in an installed app's window, and the InTitleBar example does.

<TopBar
  titleBar
  title="Roadmap.md"
  start={
    <Menubar.Root aria-label="Application menu">
      <Menubar.Menu label="File">
        <Menu.Item shortcut="Ctrl+N">New document</Menu.Item>
      </Menubar.Menu>
    </Menubar.Root>
  }
/>

Composition

The menubar is env-menubar, with a trigger, env-menubar-trigger, for each menu, whose access key is env-menubar-access-key. Each menu's popup is the shared popup surface, env-menu; className, style and ref on Menubar.Menu reach it, and the trigger is the menubar's own. The arrow keys between menus follow the text direction.

Customize

Its menus are frosted, as every popup is: --env-popup-opacity, --env-popup-blur and --env-popup-shadow, set on a theme, change it, as Popover describes.

Accessibility

You provide

  • An aria-label, such as Application menu.
  • The keys behind every shortcut, and commands that act on previousFocus where they edit a field.

envUI provides

  • A menubar whose triggers are menuitems with aria-haspopup and aria-expanded.
  • aria-keyshortcuts on triggers with access keys, such as Alt+F.
  • Focus that roves between triggers and returns to the earlier element after a command.

Props

Menubar.Root

An application menubar: a row of small text buttons that open menus, such as File, Edit, View and Help. Once a menu is open, hovering another trigger or pressing ← and → switches to its menu. Opening a menu neither locks scrolling nor shifts the page.

PropTypeDescription
aria-labelrequired
string
Accessible name of the menubar, such as Application menu.
childrenrequired
ReactNode
Menubar.Menu elements.
accessKeysDefault false
boolean
Desktop access keys, off by default: holding Alt underlines each menu's accessKey, Alt with that letter opens the menu, and Alt alone moves focus to the menubar and back. Turn it on for one menubar per window.
disabledDefault false
boolean
Disables every menu of the menubar.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

Menubar.Menu

One menu of a Menubar.Root: a small text trigger and the menu that opens below it. After a command, focus returns to where it was before the menubar took it, as in a desktop application.

PropTypeDescription
labelrequired
ReactNode
The trigger's text, such as File.
accessKey
string
A letter of the label, such as F for File. With Menubar.Root's accessKeys, holding Alt underlines it and Alt with the letter opens the menu.
childrenrequired
ReactNode
The menu's items: Menu.Item, Menu.Submenu, Menu.Separator and the other menu parts.
aria-label
string
Accessible name of the menu; by default the label, when it is a string.
disabledDefault false
boolean
The trigger stays in place, dimmed, but doesn't open its menu.
open
boolean
Controlled visibility.
defaultOpenDefault false
boolean
Whether an uncontrolled menu starts open; closed by default.
onOpenChange
(open: boolean, details: MenuFocusDetails) => void
Called when the menu opens or closes, with the element that had focus before the menubar took it.
className
string
Class of the menu popup.
ref
Ref<HTMLDivElement>
The menu popup's ref; the trigger is the menubar's own.
style
CSSProperties
The menu popup's inline style.

Also accepts the native props of <div>, such as ref, className and aria-* attributes.

Part names

Each part's class and state attributes are public and versioned, for an application's CSS to style.

env-menubar
The menu bar.
env-menubar-access-key
The underlined access key in a trigger's label.
env-menubar-label
A trigger's label.
env-menubar-trigger
A button that opens a menu.
data-access-key
Any value, or present on env-menubar-trigger
data-access-keys-shown
Present
data-disabled
Any value, or present on env-menubar-trigger
data-popup-open
Any value, or present on env-menubar-trigger

Changelog

1.0.0

The parts are members of the Menubar namespace: Menubar is Menubar.Root, MenubarMenu is Menubar.Menu, and MenubarMenuOpenChangeDetails is Menubar.MenuFocusDetails. Menubar.Menu's popup is the shared popup surface and takes ref and style; the arrow keys between menus follow the text direction.

1.0.0-prerelease.6

Colors under the pointer and while pressed change with --env-motion-ease-enter, rather than easing in slowly, so hover and press show from the first frame.

A menu opens over --env-motion-duration-medium, fading in and growing from 98 % with --env-motion-ease-enter, rather than growing from 96 % over the slow duration, so it shows from its first frames rather than late.

1.0.0-prerelease.5

A Menubar's menus are frosted: the popup's color lets a little of what is behind through, blurred, with a soft shadow and no outline edge but a faint hairline in dark, and is solid with its edge at high contrast and with reduced transparency, with Menu's new rows.