Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

Elemente

Menü

Ein Menü ist eine Liste von Aktionen, die sein Auslöser öffnet. Eine Struktur dient jedem Gerät, dargestellt so, wie es jedes erwartet:

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);
  • Auf Desktops und in Desktop-Browsern erscheint ein Popup am Auslöser; ein Submenu öffnet sich kaskadierend neben seinem Eintrag, sobald der Zeiger darüber fährt oder darauf drückt.
  • Auf Smartphones und Tablets erscheint ein Sheet am unteren Bildschirmrand über einem Backdrop; ein Submenu zeigt seine Einträge im Sheet anstelle derjenigen des Menüs, mit einer Zeile von dir an erster Stelle, die zurückführt.

Das Auswählen eines Eintrags, ein Druck außerhalb, Escape, die Zurück-Taste von Android und die Escape-Geste von VoiceOver schließen es. ContextMenu ist dasselbe Menü, geöffnet durch die sekundäre Taste (einen Rechtsklick) am Zeiger oder auf Touchscreens durch einen gehaltenen Finger auf seinem Auslöser.

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"))),
        )
}

Alle fünf stammen aus nsh/base. Die Einträge finden ihr Menü von selbst, sodass du sie frei anordnen kannst: Gruppen in Views, Beschriftungen, Einträge, die mit If erscheinen und verschwinden. popupClass gestaltet das Popup auf Desktops und das Sheet auf Smartphones; ein Stil, der sich zwischen beiden unterscheidet, kann platform.idiom (nsh) mit "desktop" vergleichen.

#Menüleisten

Menüs, deren Auslöser Einträge derselben Toolbar sind, bilden eine Menüleiste: Ist eines ihrer Menüs geöffnet, öffnet der Zeiger über dem Auslöser eines anderen Menüs dieses an seiner Stelle, und die Pfeiltasten wechseln zwischen den Auslösern.

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

#Kontextmenüs

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>

Auf Desktops öffnet die sekundäre Taste es dort, wo sich der Zeiger befindet. Auf Touchscreens öffnet es ein auf dem Auslöser gehaltener Finger als Sheet; die eigenen Drücke des Auslösers und die umgebenden Scroll-Views funktionieren weiter, und eine Bewegung des Fingers bricht das Halten ab.

#Auswählbare Einträge

checked macht einen Eintrag auswählbar: eine Einstellung, die das Menü anzeigt („Seitenleiste anzeigen“), oder, mit role="menuitemradio", eine Wahl aus einer Gruppe („Sortieren nach“). Der Wert gehört dir: Das Auswählen des Eintrags ruft onSelect auf, du setzt den nächsten Wert, und der Eintrag zeigt ihn an. Solange er ausgewählt ist, gelten seine checked:-Klassen und checkedStyle sowie die seines Indikators: des Teils vor seinem Inhalt (indicatorClass, indicatorStyle) um die Markierung, die du ihm gibst (indicator). Die Markierung wird einmal erzeugt und beibehalten; die checked:-Klassen des Indikators blenden sie ein und aus, oder die eigenen group-checked:-Klassen der Markierung mit group in indicatorClass. closeOnSelect={false} hält das Menü offen, sodass eine Einstellung ihren neuen Wert sofort zeigt.

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

Eine Markierung am hinteren Rand gehört stattdessen in den Inhalt und wird mit group-checked:-Klassen unter group am Eintrag gestaltet.

#Ein- und Ausblenden

Auf Desktops erscheint und verschwindet das Popup mit deinen Übergängen, wie bei einem Popover: popupStartingClass ist sein Aussehen im ersten Frame, popupEndingClass sein Aussehen im geschlossenen Zustand, das es beibehält, solange die transition-*-Klassen von popupClass laufen, und onClose folgt. Untermenüs übernehmen dieselben Aussehen und schließen mit ihrem Menü. Das Sheet auf Smartphones gleitet von selbst ein und aus.

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

#Offener Zustand

Ohne open verwaltet ein Menü seinen Zustand selbst (ab defaultOpen für Menu). Mit open gehört der Zustand der App, und sie ändert ihn in onOpenChange({ open, reason }): "triggerPress", "select", "pressOutside", "backdropPress", "cancel", "contextMenu" oder "longPress".

#Props

Prop Rust Typ Standard Beschreibung
open .open(..) boolean keiner Geöffnet, von der App gesteuert.
defaultOpen .default_open(..) boolean false Anfangs geöffnet, ohne open.
placement .placement(..) "top" | "bottom" | "left" | "right" "bottom" Desktops: die Seite des Auslösers, auf der es erscheint.
align .align(..) "start" | "center" | "end" "start" Desktops: wie es sich am Auslöser ausrichtet.
offset .offset(..) number 0 Desktops: der Abstand zwischen Auslöser und Popup.
margin .margin(..) number 0 Desktops: der Abstand, den es zu den Rändern des sicheren Bereichs einhält.
popupClass, popupStyle .popup_class(..), .popup_style(..) string, StyleProp<ViewStyle> keiner Das Popup (Desktops) oder das Sheet (Smartphones) sowie die Popups der Untermenüs.
popupStartingClass, popupStartingStyle .popup_starting_class(..), .popup_starting_style(..) string, StyleProp<ViewStyle> keiner Desktops: das Aussehen der Popups im ersten Frame, über ihrem eigenen.
popupEndingClass, popupEndingStyle .popup_ending_class(..), .popup_ending_style(..) string, StyleProp<ViewStyle> keiner Desktops: das Aussehen der Popups im geschlossenen Zustand, beibehalten, solange ihre Übergänge laufen.
backdropClass, backdropStyle .backdrop_class(..), .backdrop_style(..) string, StyleProp<ViewStyle> keiner Smartphones: der Backdrop hinter dem Sheet.
trigger .trigger(..) JSX.Element keiner Was es beim Drücken öffnet. Solange das Menü geöffnet ist, gelten seine aria-expanded:-Klassen und expandedStyle.
children .child(..) JSX.Element keiner Die Einträge, bei jedem Öffnen neu erzeugt.

#ContextMenu

open, popupClass / popupStyle, die Anfangs- und Endaussehen, backdropClass / backdropStyle, trigger und children wie bei Menu, und:

Prop Rust Typ Standard Beschreibung
disabled .disabled(..) boolean false Die sekundäre Taste und gehaltene Finger bewirken nichts.
offset .offset(..) number 0 Desktops: der Abstand zwischen Zeiger und Popup.
margin .margin(..) number 0 Desktops: der Abstand, den es zu den Rändern des sicheren Bereichs einhält.
Prop Rust Typ Standard Beschreibung
disabled .disabled(..) boolean false Nicht auswählbar; disabled:-Stile gelten.
checked .checked(..) boolean keiner Ausgewählt oder nicht, was ihn zu einem auswählbaren Eintrag macht; gehört der App.
checkedStyle .checked_style(..) StyleProp<ViewStyle> keiner Sein Stil im ausgewählten Zustand, als checked:-Klassen.
role .role(..) AccessibilityRole "menuitemcheckbox" mit checked, sonst "menuitem" Was er für Screenreader ist; "menuitemradio" für eine Wahl aus einer Gruppe.
indicatorClass, indicatorStyle .indicator_class(..), .indicator_style(..) string, StyleProp<ViewStyle> keiner Der Indikator, der Teil vor seinem Inhalt; seine checked:- und disabled:-Klassen folgen dem Eintrag.
indicator .indicator(..) JSX.Element keiner Die Markierung im Indikator.
closeOnSelect .close_on_select(..) boolean true Das Auswählen schließt das Menü.
accessibilityLabel .accessibility_label(..) string keiner Was Screenreader sagen, wenn der Inhalt nicht ausreicht.
children, class, style .child(..), Stilmethoden keiner Sein Inhalt und Aussehen, mit den Zuständen hover:, active: und checked:.
Prop Rust Typ Standard Beschreibung
trigger .trigger(..) JSX.Element keiner Der Eintrag, der es öffnet.
back .back(..) JSX.Element keiner Smartphones: die Zeile über seinen Einträgen, die zu den umgebenden Einträgen zurückführt.
disabled .disabled(..) boolean false Es öffnet sich nicht.
offset .offset(..) number 0 Desktops: der Abstand zwischen dem umgebenden Menü und seinem Popup.
children .child(..) JSX.Element keiner Seine Einträge.

MenuSeparator ist eine View, die du gestaltest (class, style) und die mit den Einträgen ihrer Ebene angezeigt wird.

#Events

Event Rust Beschreibung
onOpenChange (Menu, ContextMenu) .on_open_change(|e| ..) Es wurde geöffnet oder geschlossen, mit dem Grund.
onOpen (Menu, ContextMenu) .on_open(|| ..) Es wird angezeigt; als Sheet, sobald es eingeglitten ist.
onClose (Menu, ContextMenu) .on_close(|| ..) Es ist verschwunden: als Sheet, sobald es hinausgeglitten ist, als Popup nach seinem Ausblenden. Auch dann, wenn die App es geschlossen hat.
onPosition (Menu, ContextMenu) .on_position(|e| ..) Desktops: das Popup wurde platziert: placement (die Seite, auf der es erschien), bounds, available.
onCancel (Menu, ContextMenu) .on_cancel(|| ..) Der Nutzer hat versucht, es zu schließen, ohne etwas auszuwählen (Escape oder Zurück, ein Druck außerhalb oder auf den Backdrop).
onSelect (MenuItem) .on_select(|| ..) Der Eintrag wurde ausgewählt; ein auswählbarer Eintrag fragt nach seinem anderen Wert.

#Barrierefreiheit

Für Screenreader sind das Popup oder Sheet ein Menü und seine Einträge Menüeinträge; der Auslöser gibt an, ob das Menü aufgeklappt ist. Ein auswählbarer Eintrag ist ein Checkbox- oder Radio-Menüeintrag und wird als ausgewählt oder nicht ausgewählt vorgelesen; sein Indikator gehört zum Eintrag. In TSX ist der Auslöser das Element, das du übergibst, ein Button oder eine eigene View: Es übernimmt die Drücke und den Aufklappzustand selbst, sodass seine eigenen active:-Klassen und activeStyle während des Drückens gelten und sein eigenes onPress ebenfalls läuft. Nur die Einträge der gerade angezeigten Ebene sind auf dem Bildschirm, sodass Screenreader vorlesen, was dort vorhanden ist.