Elements

Menu

A menu is a list of actions its trigger opens. One structure serves every device, presented as each expects:

el-menu.tsx
el-menu.tsxLive
import { type JSX, mount, signal, Text, View } from "nsh";
import { ContextMenu, Menu, MenuItem, MenuSeparator, type OpenChangeReason, Submenu } from "nsh/base";
import { button, buttonText, desktop, Inspector, MenuMark, menuIndicator, Panel, PopIn, type PopupSide, Stage, tint, ui, wide } from "./palette";

/** A sheet at the bottom on phones; on desktops the popup's box keeps no
 * look, its `Items` have it. */
const surface = () =>
  desktop ? undefined : { ...ui().card, borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingHorizontal: 10, paddingTop: 10, paddingBottom: 30 };

/** A menu's items: on desktops on a surface that grows out of the trigger's
 * side each time it opens (`PopIn`), on phones in the sheet that slides up. */
function Items(props: { side?: PopupSide; children: JSX.Element }) {
  if (!desktop) return props.children;
  return (
    <PopIn side={props.side} style={{ ...ui().card, borderRadius: 12, padding: 5, minWidth: 210 }}>
      {props.children}
    </PopIn>
  );
}

const backdrop = { backgroundColor: "rgba(0,0,0,0.45)" };
const rowHeight = () => (desktop ? 32 : 46);
const row = () => ({ flexDirection: "row" as const, alignItems: "center" as const, gap: 8, height: rowHeight(), paddingHorizontal: 10, borderRadius: 8 });

/**
 * One row. A checkable row (`checked`) leads with its mark: a check for a
 * setting, a dot for one choice of a group (`radio`), which fades in as the
 * app checks the item; rows of the same menu keep the mark's room so their
 * labels line up (`inset`).
 */
function Item(props: { label: string; onSelect?: () => void; checked?: boolean; radio?: boolean; inset?: boolean; disabled?: boolean; keepOpen?: boolean; danger?: boolean }) {
  return (
    <MenuItem
      onSelect={props.onSelect}
      disabled={props.disabled}
      checked={props.checked}
      role={props.radio ? "menuitemradio" : undefined}
      indicatorClass={menuIndicator}
      indicator={props.checked === undefined ? undefined : <MenuMark kind={props.radio ? "dot" : "check"} tint={ui().accent} />}
      closeOnSelect={!props.keepOpen}
      hoverStyle={{ backgroundColor: tint(ui().accent, 0.08), borderColor: tint(ui().accent, 0.24) }}
      activeStyle={{ backgroundColor: tint(ui().accent, 0.14), borderColor: tint(ui().accent, 0.34) }}
      disabledStyle={{ opacity: 0.35 }}
      style={{ ...row(), borderWidth: 1, borderColor: "transparent" }}
    >
      <If when={props.inset}>
        <View style={{ width: 16 }} />
      </If>
      <Text style={{ numberOfLines: 1, flexGrow: 1, flexShrink: 0, fontSize: 14, color: props.danger ? ui().down : ui().text }}>{props.label}</Text>
    </MenuItem>
  );
}

function Separator() {
  return <MenuSeparator style={{ height: 1, marginVertical: 4, marginHorizontal: 6, backgroundColor: ui().border }} />;
}

function Share(props: { onSelect: (what: string) => void }) {
  return (
    <Submenu
      offset={4}
      trigger={
        <View hoverStyle={{ backgroundColor: tint(ui().accent, 0.08), borderColor: tint(ui().accent, 0.24) }} activeStyle={{ backgroundColor: tint(ui().accent, 0.14), borderColor: tint(ui().accent, 0.34) }} style={{ ...row(), borderWidth: 1, borderColor: "transparent" }}>
          <Text style={{ numberOfLines: 1, flexGrow: 1, flexShrink: 0, fontSize: 14, color: ui().text }}>Share</Text>
          <Text style={{ fontSize: 14, color: ui().faint }}>›</Text>
        </View>
      }
      back={
        <View activeStyle={{ backgroundColor: tint(ui().accent, 0.14), borderColor: tint(ui().accent, 0.34) }} style={{ ...row(), borderWidth: 1, borderColor: "transparent" }}>
          <Text style={{ fontSize: 14, fontWeight: "600", color: ui().accent }}>‹ Share</Text>
        </View>
      }
    >
      <Items side="right">
        <Item label="Mail" onSelect={() => props.onSelect("Mail")} />
        <Item label="Messages" onSelect={() => props.onSelect("Messages")} />
        <Item label="Copy link" onSelect={() => props.onSelect("Copy link")} />
      </Items>
    </Submenu>
  );
}

/** A group's title: not an item, nothing to press. */
function Label(props: { text: string }) {
  return <Text style={{ fontSize: 12, fontWeight: "600", color: ui().faint, paddingHorizontal: 10, paddingTop: 6, paddingBottom: 2 }}>{props.text}</Text>;
}

const SORTS = ["Name", "Date modified", "Size"];

function App() {
  const last = signal("—");
  const reasons = signal<string[]>([]);
  const hidden = signal(false);
  const sidebar = signal(true);
  const minimap = signal(false);
  const sort = signal("Name");
  const pinned = signal(false);
  const changed = (name: string) => ({ open, reason }: { open: boolean; reason: OpenChangeReason }) =>
    reasons.set([`${name} ${open ? "opened" : "closed"} · ${reason}`, ...reasons()].slice(0, 8));
  const choose = (what: string) => last.set(what);
  const menu = () => (
    <Menu
      offset={4}
      margin={8}
      popupStyle={surface()}
      backdropStyle={backdrop}
      onOpenChange={changed("File")}
      trigger={
        <View {...button("secondary", { align: "flex-start" })}>
          <Text style={buttonText()}>File ▾</Text>
        </View>
      }
    >
      <Items>
        <Item label="New" onSelect={() => choose("New")} />
        <Item label="Open…" onSelect={() => choose("Open…")} />
        <Item label="Paste" disabled />
        <Separator />
        <Share onSelect={(what) => choose(`Share › ${what}`)} />
        <Separator />
        <Item label="Delete" danger onSelect={() => choose("Delete")} />
      </Items>
    </Menu>
  );
  const view = () => (
    <Menu
      offset={4}
      margin={8}
      popupStyle={surface()}
      backdropStyle={backdrop}
      onOpenChange={changed("View")}
      trigger={
        <View {...button("secondary", { align: "flex-start" })}>
          <Text style={buttonText()}>View ▾</Text>
        </View>
      }
    >
      <Items>
        {/* Settings keep the menu open: each shows its new value at once. */}
        <Item label="Show sidebar" checked={sidebar()} keepOpen onSelect={() => sidebar.set(!sidebar())} />
        <Item label="Show minimap" checked={minimap()} keepOpen onSelect={() => minimap.set(!minimap())} />
        <Item label="Show hidden files" checked={hidden()} keepOpen onSelect={() => hidden.set(!hidden())} />
        <Separator />
        <Label text="Sort by" />
        {/* One choice of a group: choosing closes the menu. */}
        <Each of={SORTS}>{(by) => <Item label={by()} radio checked={sort() === by()} onSelect={() => sort.set(by())} />}</Each>
      </Items>
    </Menu>
  );
  const context = () => (
    <ContextMenu
      margin={8}
      popupStyle={surface()}
      backdropStyle={backdrop}
      onOpenChange={changed("Context")}
      trigger={
        <View
          style={{
            height: wide() ? 220 : 150,
            borderRadius: 14,
            borderWidth: 2,
            borderStyle: "dashed",
            borderColor: ui().faint,
            alignItems: "center",
            justifyContent: "center",
            gap: 4,
          }}
        >
          <Text style={{ fontSize: 15, fontWeight: "600", color: ui().text }}>{pinned() ? "ContextMenu · pinned" : "ContextMenu"}</Text>
          <Text style={{ fontSize: 13, color: ui().muted }}>{desktop ? "Right click here" : "Hold a finger here"}</Text>
        </View>
      }
    >
      <Items>
        <Item label="Pinned" checked={pinned()} onSelect={() => pinned.set(!pinned())} />
        <Item label="Copy" inset onSelect={() => choose("Copy")} />
        <Item label="Rename" inset onSelect={() => choose("Rename")} />
        <Separator />
        <Item label="Delete" inset danger onSelect={() => choose("Delete")} />
      </Items>
    </ContextMenu>
  );
  const panels = () => (
    <>
      <Panel title="onSelect · checked">
        <Text style={{ fontSize: 13, color: ui().text }}>{last()}</Text>
        <Text style={{ fontSize: 13, color: ui().muted }}>
          sidebar {sidebar() ? "on" : "off"} · minimap {minimap() ? "on" : "off"} · hidden files {hidden() ? "shown" : "hidden"} · by {sort()} · {pinned() ? "pinned" : "not pinned"}
        </Text>
      </Panel>
      <Panel title="onOpenChange({ open, reason })" grow>
        <View style={{ gap: 2 }}>
          <Each of={reasons()}>{(e, i) => <Text style={{ fontSize: 12, color: i() === 0 ? ui().text : ui().faint }}>{e()}</Text>}</Each>
        </View>
      </Panel>
    </>
  );
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>menu</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click File; hover Share to open its submenu. View has settings and a choice of one. Right click the box below" : "Tap File: a sheet; Share shows its items in place. View has settings and a choice of one"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            <Stage>
              <View style={{ flexDirection: "row", gap: 8 }}>
                {menu()}
                {view()}
              </View>
              {context()}
            </Stage>
            <Inspector>{panels()}</Inspector>
          </View>
        ) : (
          <>
            <View style={{ flexDirection: "row", gap: 8 }}>
              {menu()}
              {view()}
            </View>
            {context()}
            {panels()}
          </>
        )
      }
    </View>
  );
}

mount(App);
  • On the desktops and in desktop browsers, a popup at the trigger; a Submenu cascades beside its item as the pointer comes over it or presses it.
  • On phones and tablets, a sheet at the bottom of the screen over a backdrop; a Submenu shows its items in the sheet in place of the menu’s, with a row of yours leading back.

Choosing an item, a press outside, Escape, the Android back button and VoiceOver’s escape gesture close it. ContextMenu is the same menu opened by the secondary button (a right click) at the pointer, or by a finger held on its trigger on touch screens.

Return, Space or Down on the focused trigger opens the menu with the keyboard focus on its first enabled item, and Up with it on its last; the arrow keys then move between the items. A press opens it with no item focused. On the desktops and in a terminal, Right, Return or Space on a Submenu’s item opens it with the keyboard focus on its first enabled item; Left on one of its items, or Escape, closes it and puts the focus back on its item.

use nsh_base::{menu, menu_item, menu_separator, submenu};
use nsh_core::prelude::*;

fn item(label: &'static str, on_select: impl Fn() + 'static) -> impl IntoElement {
    menu_item()
        .on_select(on_select)
        .px_3()
        .py_2()
        .rounded_md()
        .child(text(label))
}

fn file_menu() -> impl IntoElement {
    menu()
        .offset(px(4.0))
        .trigger(view().px_3().py_2().child(text("File")))
        .child(item("New", || log::info!("new")))
        .child(item("Open…", || log::info!("open")))
        .child(menu_separator().h(px(1.0)).my_1().bg(rgb(0xe5e7eb)))
        .child(
            submenu()
                .trigger(view().px_3().py_2().child(text("Share ›")))
                .back(view().px_3().py_2().child(text("‹ Back")))
                .child(item("Mail", || log::info!("mail")))
                .child(item("Copy link", || log::info!("link"))),
        )
}

All five come from nsh/base. The items reach their menu on their own, so you arrange them freely: groups in views, labels, items that come and go with If. popupClass styles the popup on desktops and the sheet on phones; a style that differs between them can compare platform.idiom (nsh) with "desktop".

Menus whose triggers are items of one Toolbar form a menu bar: with one of its menus open, the pointer over another menu’s trigger opens that menu in its place, and the arrow keys move between the triggers.

<Toolbar accessibilityLabel="Menu bar">
  <Menu trigger={<View><Text>File</Text></View>}>{/* … */}</Menu>
  <Menu trigger={<View><Text>Edit</Text></View>}>{/* … */}</Menu>
</Toolbar>

#Context menus

import { ContextMenu, MenuItem } from "nsh/base";

<ContextMenu
  popupClass="min-w-40 rounded-lg bg-white p-1 shadow-lg"
  backdropClass="bg-black/40"
  trigger={<PhotoTile photo={photo} />}
>
  <MenuItem onSelect={() => share(photo)}><Text>Share</Text></MenuItem>
  <MenuItem onSelect={() => remove(photo)}><Text>Delete</Text></MenuItem>
</ContextMenu>

On the desktops the secondary button opens it where the pointer is. On touch screens a finger held on the trigger opens it as a sheet; the trigger’s own presses and the scroll views around it keep working, and moving the finger cancels the hold.

#Checkable items

checked makes an item checkable: a setting the menu shows (“Show sidebar”) or, with role="menuitemradio", one choice of a group (“Sort by”). The value is yours: choosing the item calls onSelect, you set the next value, and the item shows it. While it is checked, its checked: classes and checkedStyle apply, and so do those of its indicator: the part before its content (indicatorClass, indicatorStyle) around the mark you give it (indicator). The mark is made once and kept; the indicator’s checked: classes show and hide it, or the mark’s own group-checked: classes with group in indicatorClass. closeOnSelect={false} keeps the menu open, so a setting shows its new value at once.

use nsh_base::{menu, menu_item};
use nsh_core::{element::StyleRefinement, prelude::*};

fn view_menu(sidebar: Signal<bool>, sort: Signal<&'static str>) -> impl IntoElement {
    // The indicator part shows the mark while the item is checked.
    let part = |s: StyleRefinement| s.w(px(16.0)).opacity(0.0).checked(|s| s.opacity(1.0));
    let by = move |label: &'static str| {
        menu_item()
            .role(AccessibilityRole::MenuItemRadio)
            .checked(move || Some(sort.get() == label))
            .indicator_style(part)
            .indicator(text("✓"))
            .on_select(move || sort.set(label))
            .flex_row()
            .gap_2()
            .px_3()
            .py_2()
            .child(text(label))
    };
    menu()
        .trigger(view().px_3().py_2().child(text("View")))
        .child(
            menu_item()
                .checked(move || Some(sidebar.get()))
                .close_on_select(false)
                .indicator_style(part)
                .indicator(text("✓"))
                .on_select(move || sidebar.set(!sidebar.get_untracked()))
                .flex_row()
                .gap_2()
                .px_3()
                .py_2()
                .child(text("Show sidebar")),
        )
        .child(by("Name"))
        .child(by("Date modified"))
}

A mark at the trailing edge goes in the content instead, styled with group-checked: classes under group on the item.

#Entrance and exit

On the desktops the popup comes and goes with your transitions, as a Popover’s: popupStartingClass is its look in its first frame, popupEndingClass its look once closed, which it keeps while the transition-* classes of popupClass run, and onClose follows. Submenus take the same looks and close with their menu. The sheet of phones slides in and out on its own.

<Menu
  popupClass="min-w-40 rounded-lg bg-white p-1 shadow-lg transition-all duration-100"
  popupStartingClass="opacity-0 -translate-y-1"
  popupEndingClass="opacity-0"
  trigger={<Text>File</Text>}
>
  <MenuItem onSelect={newFile}><Text>New</Text></MenuItem>
</Menu>

#Open state

Without open, a menu keeps its own state (from defaultOpen for Menu). With open, the app owns it and changes it in onOpenChange({ open, reason }): "triggerPress", "select", "pressOutside", "backdropPress", "cancel", "contextMenu" or "longPress".

#Props

PropRustTypeDefaultDescription
open.open(..)booleannoneOpen, controlled by the app.
defaultOpen.default_open(..)booleanfalseOpen at first, without open.
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"Desktops: the side of the trigger it goes on.
align.align(..)"start" | "center" | "end""start"Desktops: how it lines up with the trigger.
offset.offset(..)number0Desktops: the gap between the trigger and the popup.
margin.margin(..)number0Desktops: the distance it keeps from the edges of the safe area.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>noneThe popup (desktops) or the sheet (phones), and the submenus’ popups.
popupStartingClass, popupStartingStyle.popup_starting_class(..), .popup_starting_style(..)string, StyleProp<ViewStyle>noneDesktops: the popups’ look in their first frame, over their own.
popupEndingClass, popupEndingStyle.popup_ending_class(..), .popup_ending_style(..)string, StyleProp<ViewStyle>noneDesktops: the popups’ look once closed, kept while their transitions run.
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>nonePhones: the backdrop behind the sheet.
trigger.trigger(..)JSX.ElementnoneWhat opens it when pressed. While the menu is open its aria-expanded: classes and expandedStyle apply.
children.child(..)JSX.ElementnoneThe items, made each time it opens.

#ContextMenu

open, popupClass / popupStyle, the starting and ending looks, backdropClass / backdropStyle, trigger and children as for Menu, and:

PropRustTypeDefaultDescription
disabled.disabled(..)booleanfalseThe secondary button and held fingers do nothing.
offset.offset(..)number0Desktops: the gap between the pointer and the popup.
margin.margin(..)number0Desktops: the distance it keeps from the edges of the safe area.
PropRustTypeDefaultDescription
disabled.disabled(..)booleanfalseNot chosen; disabled: styles apply.
checked.checked(..)booleannoneChecked or not, which makes it a checkable item; owned by the app.
checkedStyle.checked_style(..)StyleProp<ViewStyle>noneIts style while checked, as checked: classes.
role.role(..)AccessibilityRole"menuitemcheckbox" with checked, else "menuitem"What it is for screen readers; "menuitemradio" for one choice of a group.
indicatorClass, indicatorStyle.indicator_class(..), .indicator_style(..)string, StyleProp<ViewStyle>noneThe indicator, the part before its content; its checked: and disabled: classes follow the item.
indicator.indicator(..)JSX.ElementnoneThe mark inside the indicator.
closeOnSelect.close_on_select(..)booleantrueChoosing it closes the menu.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say, when the content is not enough.
children, class, style.child(..), style methodsnoneIts content and look, with hover:, active: and checked: states.
PropRustTypeDefaultDescription
trigger.trigger(..)JSX.ElementnoneThe item that opens it.
back.back(..)JSX.ElementnonePhones: the row above its items that returns to the items around it.
disabled.disabled(..)booleanfalseIt does not open.
offset.offset(..)number0Desktops: the gap between the menu around it and its popup.
children.child(..)JSX.ElementnoneIts items.

MenuSeparator is a view you style (class, style), shown with the items of its level.

#Events

EventRustDescription
onOpenChange (Menu, ContextMenu).on_open_change(|e| ..)It opened or closed, with why.
onOpen (Menu, ContextMenu).on_open(|| ..)It is shown; as a sheet, once it came in.
onClose (Menu, ContextMenu).on_close(|| ..)It is gone: as a sheet once it went, as a popup after its exit. Also when the app closed it.
onPosition (Menu, ContextMenu).on_position(|e| ..)Desktops: the popup was placed: placement (the side it went on), bounds, available.
onCancel (Menu, ContextMenu).on_cancel(|| ..)The user tried to close it without choosing (Escape or back, a press outside or on the backdrop).
onSelect (MenuItem).on_select(|| ..)The item was chosen; a checkable item asks for its other value.

#Accessibility

The popup or sheet is a menu and its items menu items for screen readers; the trigger tells whether the menu is expanded. A checkable item is a checkbox or radio menu item, read as checked or not checked; its indicator is part of the item. In TSX the trigger is the element you pass, a Button or a view of your own: it takes the presses and the expanded state itself, so its own active: classes and activeStyle show while it is pressed, and its own onPress runs too. Only the items of the level shown are on screen, so screen readers read what is there.