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 and the Android back button 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.