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 { mount, signal, Text, View } from "nsh";
import { ContextMenu, Menu, MenuItem, MenuSeparator, type OpenChangeReason, Submenu } from "nsh/base";
import { desktop, Inspector, Panel, size, Stage, tint, ui, wide } from "./palette";

/** A popup at the trigger on desktops, a sheet at the bottom on phones. */
const surface = () =>
  desktop
    ? { ...ui().card, borderRadius: 12, padding: 5, minWidth: 210 }
    : { ...ui().card, borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingHorizontal: 10, paddingTop: 10, paddingBottom: 30 };

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 });

function Item(props: { label: string; onSelect?: () => void; checked?: boolean; disabled?: boolean; keepOpen?: boolean; danger?: boolean }) {
  return (
    <MenuItem
      onSelect={props.onSelect}
      disabled={props.disabled}
      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" }}
    >
      <Text style={{ flex: 1, fontSize: 14, color: props.danger ? ui().down : ui().text }}>{props.label}</Text>
      {/* The check at the trailing edge. */}
      <If when={props.checked !== undefined}>
        <Text style={{ width: 14, fontSize: 13, textAlign: "right", color: ui().accent }}>{props.checked ? "✓" : ""}</Text>
      </If>
    </MenuItem>
  );
}

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

function Share(props: { onSelect: (what: string) => void }) {
  return (
    <Submenu
      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={{ flex: 1, 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>
      }
    >
      <Item label="Mail" onSelect={() => props.onSelect("Mail")} />
      <Item label="Messages" onSelect={() => props.onSelect("Messages")} />
      <Item label="Copy link" onSelect={() => props.onSelect("Copy link")} />
    </Submenu>
  );
}

function App() {
  const last = signal("—");
  const reasons = signal<string[]>([]);
  const hidden = signal(false);
  const changed = (name: string) => (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
          activeStyle={{ opacity: 0.7 }}
          hoverStyle={{ opacity: 0.85 }}
          style={{
            ...ui().raised,
            alignSelf: "flex-start",
            borderRadius: desktop ? 8 : 10,
            paddingHorizontal: desktop ? 12 : 16,
            height: desktop ? size.control : 38,
            justifyContent: "center",
          }}
        >
          <Text style={{ fontSize: desktop ? 13 : 14, fontWeight: "600", color: ui().text }}>File ▾</Text>
        </View>
      }
    >
      <Item label="New" onSelect={() => choose("New")} />
      <Item label="Open…" onSelect={() => choose("Open…")} />
      <Item label="Paste" disabled />
      <Separator />
      <Share onSelect={(what) => choose(`Share › ${what}`)} />
      <Item label="Show hidden files" checked={hidden()} keepOpen onSelect={() => hidden.set(!hidden())} />
      <Separator />
      <Item label="Delete" danger onSelect={() => choose("Delete")} />
    </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 }}>ContextMenu</Text>
          <Text style={{ fontSize: 13, color: ui().muted }}>{desktop ? "Right click here" : "Hold a finger here"}</Text>
        </View>
      }
    >
      <Item label="Copy" onSelect={() => choose("Copy")} />
      <Item label="Rename" onSelect={() => choose("Rename")} />
      <Share onSelect={(what) => choose(`Share › ${what}`)} />
      <Separator />
      <Item label="Delete" danger onSelect={() => choose("Delete")} />
    </ContextMenu>
  );
  const panels = () => (
    <>
      <Panel title="onSelect">
        <Text style={{ fontSize: 13, color: ui().text }}>
          {last()} · hidden files {hidden() ? "shown" : "hidden"}
        </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. Right click the box below it" : "Tap File: a sheet; Share shows its items in place"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            <Stage>
              {menu()}
              {context()}
            </Stage>
            <Inspector>{panels()}</Inspector>
          </View>
        ) : (
          <>
            {menu()}
            {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.

use nsh_base::{menu, menu_item, menu_separator, submenu};
use nsh_core::ui::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/device) with "desktop".

#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.

#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.
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>nonePhones: the backdrop behind the sheet.
trigger.trigger(..)JSX.ElementnoneWhat opens it when pressed.
children.child(..)JSX.ElementnoneThe items, made each time it opens.

#ContextMenu

open, popupClass / popupStyle, 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.
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: and active: 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.
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. Also when the app closed it.
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.

#Accessibility

The popup or sheet is a menu and its items menu items for screen readers; the trigger tells whether the menu is expanded. Only the items of the level shown are on screen, so screen readers read what is there.