Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Elementi

Menu

Un menu è un elenco di azioni che il suo trigger apre. Una sola struttura serve ogni dispositivo, presentata nel modo che ciascuno si aspetta:

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);
  • Su desktop e nei browser desktop, un popup in corrispondenza del trigger; un Submenu si apre a cascata accanto al proprio elemento quando il puntatore vi passa sopra o lo preme.
  • Su telefoni e tablet, uno sheet nella parte inferiore dello schermo sopra un backdrop; un Submenu mostra i propri elementi nello sheet al posto di quelli del menu, con una tua riga che riporta indietro.

La scelta di un elemento, una pressione all’esterno, Escape, il pulsante indietro di Android e il gesto di uscita di VoiceOver lo chiudono. ContextMenu è lo stesso menu aperto dal pulsante secondario (un clic destro) in corrispondenza del puntatore, oppure, sui touchscreen, da un dito tenuto premuto sul suo trigger.

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

Tutti e cinque provengono da nsh/base. Gli elementi raggiungono il loro menu da soli, quindi puoi disporli liberamente: gruppi in view, etichette, elementi che compaiono e scompaiono con If. popupClass applica lo stile al popup su desktop e allo sheet su telefono; uno stile che differisce tra i due può confrontare platform.idiom (nsh) con "desktop".

I menu i cui trigger sono elementi di un unico Toolbar formano una menu bar: con uno dei suoi menu aperto, il puntatore sopra il trigger di un altro menu apre quest’ultimo al posto del primo, e i tasti freccia si spostano tra i trigger.

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

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>

Su desktop il pulsante secondario lo apre dove si trova il puntatore. Sui touchscreen un dito tenuto premuto sul trigger lo apre come sheet; le pressioni proprie del trigger e le scroll view circostanti continuano a funzionare, e spostare il dito annulla la pressione prolungata.

#Elementi selezionabili

checked rende un elemento selezionabile: un’impostazione che il menu mostra (“Show sidebar”) oppure, con role="menuitemradio", una scelta all’interno di un gruppo (“Sort by”). Il valore è tuo: la scelta dell’elemento chiama onSelect, tu imposti il valore successivo e l’elemento lo mostra. Finché è selezionato, si applicano le sue classi checked: e checkedStyle, così come quelle del suo indicatore: la parte che precede il contenuto (indicatorClass, indicatorStyle) attorno al segno che gli assegni (indicator). Il segno viene creato una sola volta e conservato; le classi checked: dell’indicatore lo mostrano e lo nascondono, oppure lo fanno le classi group-checked: del segno stesso con group in indicatorClass. closeOnSelect={false} mantiene aperto il menu, così un’impostazione mostra subito il nuovo valore.

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

Un segno sul bordo finale va invece nel contenuto, con stile definito dalle classi group-checked: sotto group sull’elemento.

#Ingresso e uscita

Su desktop il popup compare e scompare con le tue transizioni, come quello di un Popover: popupStartingClass è il suo aspetto nel primo frame, popupEndingClass il suo aspetto una volta chiuso, che mantiene mentre le classi transition-* di popupClass sono in esecuzione, e onClose segue. I sottomenu assumono gli stessi aspetti e si chiudono insieme al proprio menu. Lo sheet dei telefoni scorre dentro e fuori da solo.

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

#Stato di apertura

Senza open, un menu mantiene il proprio stato (da defaultOpen per Menu). Con open, lo stato appartiene all’app, che lo modifica in onOpenChange({ open, reason }): "triggerPress", "select", "pressOutside", "backdropPress", "cancel", "contextMenu" o "longPress".

#Props

PropRustTipoPredefinitoDescrizione
open.open(..)booleannessunoAperto, controllato dall’app.
defaultOpen.default_open(..)booleanfalseAperto all’inizio, senza open.
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"Desktop: il lato del trigger su cui si posiziona.
align.align(..)"start" | "center" | "end""start"Desktop: come si allinea al trigger.
offset.offset(..)number0Desktop: lo spazio tra il trigger e il popup.
margin.margin(..)number0Desktop: la distanza che mantiene dai bordi della safe area.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>nessunoIl popup (desktop) o lo sheet (telefoni), e i popup dei sottomenu.
popupStartingClass, popupStartingStyle.popup_starting_class(..), .popup_starting_style(..)string, StyleProp<ViewStyle>nessunoDesktop: l’aspetto dei popup nel primo frame, sopra il loro.
popupEndingClass, popupEndingStyle.popup_ending_class(..), .popup_ending_style(..)string, StyleProp<ViewStyle>nessunoDesktop: l’aspetto dei popup una volta chiusi, mantenuto mentre le loro transizioni sono in esecuzione.
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>nessunoTelefoni: il backdrop dietro lo sheet.
trigger.trigger(..)JSX.ElementnessunoCiò che lo apre quando viene premuto. Finché il menu è aperto, si applicano le sue classi aria-expanded: e expandedStyle.
children.child(..)JSX.ElementnessunoGli elementi, creati a ogni apertura.

#ContextMenu

open, popupClass / popupStyle, gli aspetti iniziale e finale, backdropClass / backdropStyle, trigger e children come per Menu, e:

PropRustTipoPredefinitoDescrizione
disabled.disabled(..)booleanfalseIl pulsante secondario e i diti tenuti premuti non fanno nulla.
offset.offset(..)number0Desktop: lo spazio tra il puntatore e il popup.
margin.margin(..)number0Desktop: la distanza che mantiene dai bordi della safe area.
PropRustTipoPredefinitoDescrizione
disabled.disabled(..)booleanfalseNon selezionabile con la scelta; si applicano gli stili disabled:.
checked.checked(..)booleannessunoSelezionato o no, il che lo rende un elemento selezionabile; appartiene all’app.
checkedStyle.checked_style(..)StyleProp<ViewStyle>nessunoIl suo stile mentre è selezionato, come classi checked:.
role.role(..)AccessibilityRole"menuitemcheckbox" con checked, altrimenti "menuitem"Ciò che rappresenta per gli screen reader; "menuitemradio" per una scelta all’interno di un gruppo.
indicatorClass, indicatorStyle.indicator_class(..), .indicator_style(..)string, StyleProp<ViewStyle>nessunoL’indicatore, la parte che precede il contenuto; le sue classi checked: e disabled: seguono l’elemento.
indicator.indicator(..)JSX.ElementnessunoIl segno all’interno dell’indicatore.
closeOnSelect.close_on_select(..)booleantrueSceglierlo chiude il menu.
accessibilityLabel.accessibility_label(..)stringnessunoCiò che dicono gli screen reader, quando il contenuto non basta.
children, class, style.child(..), metodi di stilenessunoIl suo contenuto e il suo aspetto, con gli stati hover:, active: e checked:.
PropRustTipoPredefinitoDescrizione
trigger.trigger(..)JSX.ElementnessunoL’elemento che lo apre.
back.back(..)JSX.ElementnessunoTelefoni: la riga sopra i suoi elementi che riporta agli elementi circostanti.
disabled.disabled(..)booleanfalseNon si apre.
offset.offset(..)number0Desktop: lo spazio tra il menu che lo contiene e il suo popup.
children.child(..)JSX.ElementnessunoI suoi elementi.

MenuSeparator è una view a cui applichi uno stile (class, style), mostrata insieme agli elementi del suo livello.

#Eventi

EventoRustDescrizione
onOpenChange (Menu, ContextMenu).on_open_change(|e| ..)Si è aperto o chiuso, con il motivo.
onOpen (Menu, ContextMenu).on_open(|| ..)È visibile; come sheet, dopo essere entrato.
onClose (Menu, ContextMenu).on_close(|| ..)Non c’è più: come sheet dopo essere uscito, come popup dopo la sua uscita. Anche quando l’app lo ha chiuso.
onPosition (Menu, ContextMenu).on_position(|e| ..)Desktop: il popup è stato posizionato: placement (il lato su cui è andato), bounds, available.
onCancel (Menu, ContextMenu).on_cancel(|| ..)L’utente ha provato a chiuderlo senza scegliere (Escape o indietro, una pressione all’esterno o sul backdrop).
onSelect (MenuItem).on_select(|| ..)L’elemento è stato scelto; un elemento selezionabile richiede il suo altro valore.

#Accessibilità

Il popup o lo sheet è un menu e i suoi elementi sono elementi di menu per gli screen reader; il trigger indica se il menu è espanso. Un elemento selezionabile è un elemento di menu di tipo checkbox o radio, letto come selezionato o non selezionato; il suo indicatore fa parte dell’elemento. In TSX il trigger è l’elemento che passi, un Button o una view tua: riceve da sé le pressioni e lo stato di espansione, quindi le sue classi active: e activeStyle si applicano mentre è premuto, e viene eseguito anche il suo onPress. Sono a schermo solo gli elementi del livello mostrato, quindi gli screen reader leggono ciò che è presente.