Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Elementos

Menu

Un menú es una lista de acciones que abre su disparador. Una sola estructura sirve para todos los dispositivos, presentada como cada uno espera:

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);
  • En los escritorios y en los navegadores de escritorio, un popup junto al disparador; un Submenu se despliega en cascada junto a su elemento cuando el puntero pasa sobre él o lo presiona.
  • En teléfonos y tablets, una hoja en la parte inferior de la pantalla sobre un fondo; un Submenu muestra sus elementos en la hoja en lugar de los del menú, con una fila tuya que lleva de vuelta.

Elegir un elemento, una pulsación fuera, Escape, el botón de retroceso de Android y el gesto de escape de VoiceOver lo cierran. ContextMenu es el mismo menú abierto con el botón secundario (un clic derecho) en la posición del puntero, o con un dedo mantenido sobre su disparador en las pantallas táctiles.

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

Los cinco provienen de nsh/base. Los elementos llegan a su menú por sí solos, de modo que puedes organizarlos libremente: grupos en vistas, etiquetas, elementos que aparecen y desaparecen con If. popupClass da estilo al popup en los escritorios y a la hoja en los teléfonos; un estilo que difiera entre ambos puede comparar platform.idiom (nsh) con "desktop".

#Barras de menú

Los menús cuyos disparadores son elementos de un mismo Toolbar forman una barra de menú: con uno de sus menús abierto, el puntero sobre el disparador de otro menú abre ese menú en su lugar, y las teclas de flecha se mueven entre los disparadores.

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

#Menús contextuales

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>

En los escritorios, el botón secundario lo abre donde está el puntero. En las pantallas táctiles, un dedo mantenido sobre el disparador lo abre como una hoja; las pulsaciones propias del disparador y las vistas de desplazamiento a su alrededor siguen funcionando, y mover el dedo cancela la pulsación mantenida.

#Elementos marcables

checked hace que un elemento sea marcable: un ajuste que el menú muestra (“Mostrar barra lateral”) o, con role="menuitemradio", una opción de un grupo (“Ordenar por”). El valor es tuyo: al elegir el elemento se llama a onSelect, tú estableces el siguiente valor y el elemento lo muestra. Mientras está marcado, se aplican sus clases checked: y checkedStyle, y también las de su indicador: la parte anterior a su contenido (indicatorClass, indicatorStyle) que rodea la marca que le das (indicator). La marca se crea una sola vez y se conserva; las clases checked: del indicador la muestran y la ocultan, o bien las clases group-checked: de la propia marca con group en indicatorClass. closeOnSelect={false} mantiene el menú abierto, de modo que un ajuste muestra su nuevo valor de inmediato.

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

Una marca en el borde final va en el contenido, con estilo mediante clases group-checked: bajo group en el elemento.

#Entrada y salida

En los escritorios, el popup aparece y desaparece con tus transiciones, como las de un Popover: popupStartingClass es su aspecto en el primer fotograma, popupEndingClass su aspecto una vez cerrado, que conserva mientras se ejecutan las clases transition-* de popupClass, y a continuación viene onClose. Los submenús adoptan los mismos aspectos y se cierran con su menú. La hoja de los teléfonos entra y sale deslizándose por sí sola.

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

#Estado de apertura

Sin open, un menú mantiene su propio estado (a partir de defaultOpen para Menu). Con open, la app es su propietaria y lo cambia en onOpenChange({ open, reason }): "triggerPress", "select", "pressOutside", "backdropPress", "cancel", "contextMenu" o "longPress".

#Props

Prop Rust Tipo Predeterminado Descripción
open .open(..) boolean ninguno Abierto, controlado por la app.
defaultOpen .default_open(..) boolean false Abierto al inicio, sin open.
placement .placement(..) "top" | "bottom" | "left" | "right" "bottom" Escritorios: el lado del disparador en el que se coloca.
align .align(..) "start" | "center" | "end" "start" Escritorios: cómo se alinea con el disparador.
offset .offset(..) number 0 Escritorios: el espacio entre el disparador y el popup.
margin .margin(..) number 0 Escritorios: la distancia que mantiene respecto a los bordes del área segura.
popupClass, popupStyle .popup_class(..), .popup_style(..) string, StyleProp<ViewStyle> ninguno El popup (escritorios) o la hoja (teléfonos), y los popups de los submenús.
popupStartingClass, popupStartingStyle .popup_starting_class(..), .popup_starting_style(..) string, StyleProp<ViewStyle> ninguno Escritorios: el aspecto de los popups en su primer fotograma, sobre el suyo propio.
popupEndingClass, popupEndingStyle .popup_ending_class(..), .popup_ending_style(..) string, StyleProp<ViewStyle> ninguno Escritorios: el aspecto de los popups una vez cerrados, conservado mientras se ejecutan sus transiciones.
backdropClass, backdropStyle .backdrop_class(..), .backdrop_style(..) string, StyleProp<ViewStyle> ninguno Teléfonos: el fondo detrás de la hoja.
trigger .trigger(..) JSX.Element ninguno Lo que lo abre al presionarlo. Mientras el menú está abierto, se aplican sus clases aria-expanded: y expandedStyle.
children .child(..) JSX.Element ninguno Los elementos, creados cada vez que se abre.

#ContextMenu

open, popupClass / popupStyle, los aspectos inicial y final, backdropClass / backdropStyle, trigger y children como en Menu, y además:

Prop Rust Tipo Predeterminado Descripción
disabled .disabled(..) boolean false El botón secundario y los dedos mantenidos no hacen nada.
offset .offset(..) number 0 Escritorios: el espacio entre el puntero y el popup.
margin .margin(..) number 0 Escritorios: la distancia que mantiene respecto a los bordes del área segura.
Prop Rust Tipo Predeterminado Descripción
disabled .disabled(..) boolean false No se puede elegir; se aplican los estilos disabled:.
checked .checked(..) boolean ninguno Marcado o no, lo que lo convierte en un elemento marcable; es propiedad de la app.
checkedStyle .checked_style(..) StyleProp<ViewStyle> ninguno Su estilo mientras está marcado, como clases checked:.
role .role(..) AccessibilityRole "menuitemcheckbox" con checked, si no "menuitem" Qué es para los lectores de pantalla; "menuitemradio" para una opción de un grupo.
indicatorClass, indicatorStyle .indicator_class(..), .indicator_style(..) string, StyleProp<ViewStyle> ninguno El indicador, la parte anterior a su contenido; sus clases checked: y disabled: siguen al elemento.
indicator .indicator(..) JSX.Element ninguno La marca dentro del indicador.
closeOnSelect .close_on_select(..) boolean true Elegirlo cierra el menú.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla, cuando el contenido no basta.
children, class, style .child(..), métodos de estilo ninguno Su contenido y aspecto, con estados hover:, active: y checked:.
Prop Rust Tipo Predeterminado Descripción
trigger .trigger(..) JSX.Element ninguno El elemento que lo abre.
back .back(..) JSX.Element ninguno Teléfonos: la fila sobre sus elementos que regresa a los elementos que lo rodean.
disabled .disabled(..) boolean false No se abre.
offset .offset(..) number 0 Escritorios: el espacio entre el menú que lo rodea y su popup.
children .child(..) JSX.Element ninguno Sus elementos.

MenuSeparator es una vista a la que das estilo (class, style), mostrada junto con los elementos de su nivel.

#Eventos

Evento Rust Descripción
onOpenChange (Menu, ContextMenu) .on_open_change(|e| ..) Se abrió o se cerró, con el motivo.
onOpen (Menu, ContextMenu) .on_open(|| ..) Se muestra; como hoja, una vez que entró.
onClose (Menu, ContextMenu) .on_close(|| ..) Ya no está: como hoja, una vez que salió; como popup, tras su salida. También cuando la app lo cerró.
onPosition (Menu, ContextMenu) .on_position(|e| ..) Escritorios: el popup fue colocado: placement (el lado en el que se colocó), bounds, available.
onCancel (Menu, ContextMenu) .on_cancel(|| ..) El usuario intentó cerrarlo sin elegir (Escape o retroceso, una pulsación fuera o sobre el fondo).
onSelect (MenuItem) .on_select(|| ..) Se eligió el elemento; un elemento marcable solicita su otro valor.

#Accesibilidad

Para los lectores de pantalla, el popup o la hoja es un menú y sus elementos son elementos de menú; el disparador indica si el menú está expandido. Un elemento marcable es un elemento de menú de tipo casilla o de opción, leído como marcado o no marcado; su indicador forma parte del elemento. En TSX el disparador es el elemento que pasas, un Button o una vista propia: recibe por sí mismo las pulsaciones y el estado expandido, de modo que sus propias clases active: y activeStyle se muestran mientras está presionado, y su propio onPress también se ejecuta. Solo los elementos del nivel mostrado están en pantalla, así que los lectores de pantalla leen lo que hay allí.