Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Éléments

Menu

Un menu est une liste d’actions qu’ouvre son déclencheur. Une même structure sert tous les appareils, présentée comme chacun l’attend :

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);
  • Sur les ordinateurs de bureau et dans les navigateurs de bureau, une popup au niveau du déclencheur ; un Submenu s’ouvre en cascade à côté de son élément lorsque le pointeur le survole ou appuie dessus.
  • Sur les téléphones et les tablettes, une feuille en bas de l’écran au-dessus d’un fond ; un Submenu affiche ses éléments dans la feuille à la place de ceux du menu, avec une ligne de votre choix pour revenir en arrière.

Choisir un élément, un appui à l’extérieur, Échap, le bouton retour d’Android et le geste d’échappement de VoiceOver le ferment. ContextMenu est le même menu ouvert par le bouton secondaire (un clic droit) à l’emplacement du pointeur, ou par un doigt maintenu sur son déclencheur sur les écrans tactiles.

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

Les cinq proviennent de nsh/base. Les éléments rejoignent leur menu d’eux-mêmes, vous pouvez donc les organiser librement : groupes dans des vues, libellés, éléments qui apparaissent et disparaissent avec If. popupClass stylise la popup sur les ordinateurs de bureau et la feuille sur les téléphones ; un style qui diffère entre les deux peut comparer platform.idiom (nsh) avec "desktop".

#Barres de menus

Les menus dont les déclencheurs sont des éléments d’un même Toolbar forment une barre de menus : lorsqu’un de ses menus est ouvert, le pointeur passant sur le déclencheur d’un autre menu ouvre celui-ci à la place, et les touches fléchées se déplacent entre les déclencheurs.

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

Sur les ordinateurs de bureau, le bouton secondaire l’ouvre à l’emplacement du pointeur. Sur les écrans tactiles, un doigt maintenu sur le déclencheur l’ouvre sous forme de feuille ; les appuis propres au déclencheur et les vues défilantes environnantes continuent de fonctionner, et déplacer le doigt annule le maintien.

#Éléments cochables

checked rend un élément cochable : un réglage que le menu affiche (« Afficher la barre latérale ») ou, avec role="menuitemradio", un choix parmi un groupe (« Trier par »). La valeur vous appartient : choisir l’élément appelle onSelect, vous définissez la valeur suivante, et l’élément l’affiche. Tant qu’il est coché, ses classes checked: et checkedStyle s’appliquent, ainsi que celles de son indicateur : la partie située avant son contenu (indicatorClass, indicatorStyle) autour de la marque que vous lui donnez (indicator). La marque est créée une seule fois puis conservée ; les classes checked: de l’indicateur l’affichent et la masquent, ou bien les classes group-checked: propres à la marque avec group dans indicatorClass. closeOnSelect={false} garde le menu ouvert, de sorte qu’un réglage montre sa nouvelle valeur immédiatement.

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

Une marque au bord de fin se place plutôt dans le contenu, stylisée avec les classes group-checked: sous group sur l’élément.

#Entrée et sortie

Sur les ordinateurs de bureau, la popup apparaît et disparaît avec vos transitions, comme celle d’un Popover : popupStartingClass est son aspect dans sa première image, popupEndingClass son aspect une fois fermée, qu’elle conserve pendant l’exécution des classes transition-* de popupClass, puis onClose suit. Les sous-menus reprennent les mêmes aspects et se ferment avec leur menu. La feuille des téléphones glisse d’elle-même à l’entrée et à la sortie.

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

#État d’ouverture

Sans open, un menu garde son propre état (à partir de defaultOpen pour Menu). Avec open, l’app en est propriétaire et le modifie dans onOpenChange({ open, reason }) : "triggerPress", "select", "pressOutside", "backdropPress", "cancel", "contextMenu" ou "longPress".

#Props

PropRustTypeDéfautDescription
open.open(..)booleanaucunOuvert, contrôlé par l’app.
defaultOpen.default_open(..)booleanfalseOuvert au départ, sans open.
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"Ordinateurs de bureau : le côté du déclencheur où il se place.
align.align(..)"start" | "center" | "end""start"Ordinateurs de bureau : son alignement par rapport au déclencheur.
offset.offset(..)number0Ordinateurs de bureau : l’écart entre le déclencheur et la popup.
margin.margin(..)number0Ordinateurs de bureau : la distance qu’il garde par rapport aux bords de la zone sûre.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>aucunLa popup (ordinateurs de bureau) ou la feuille (téléphones), et les popups des sous-menus.
popupStartingClass, popupStartingStyle.popup_starting_class(..), .popup_starting_style(..)string, StyleProp<ViewStyle>aucunOrdinateurs de bureau : l’aspect des popups dans leur première image, par-dessus le leur.
popupEndingClass, popupEndingStyle.popup_ending_class(..), .popup_ending_style(..)string, StyleProp<ViewStyle>aucunOrdinateurs de bureau : l’aspect des popups une fois fermées, conservé pendant l’exécution de leurs transitions.
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>aucunTéléphones : le fond derrière la feuille.
trigger.trigger(..)JSX.ElementaucunCe qui l’ouvre lorsqu’on appuie dessus. Tant que le menu est ouvert, ses classes aria-expanded: et expandedStyle s’appliquent.
children.child(..)JSX.ElementaucunLes éléments, créés à chaque ouverture.

#ContextMenu

open, popupClass / popupStyle, les aspects de départ et de fin, backdropClass / backdropStyle, trigger et children comme pour Menu, et :

PropRustTypeDéfautDescription
disabled.disabled(..)booleanfalseLe bouton secondaire et les doigts maintenus ne font rien.
offset.offset(..)number0Ordinateurs de bureau : l’écart entre le pointeur et la popup.
margin.margin(..)number0Ordinateurs de bureau : la distance qu’il garde par rapport aux bords de la zone sûre.
PropRustTypeDéfautDescription
disabled.disabled(..)booleanfalseNon sélectionnable ; les styles disabled: s’appliquent.
checked.checked(..)booleanaucunCoché ou non, ce qui en fait un élément cochable ; appartient à l’app.
checkedStyle.checked_style(..)StyleProp<ViewStyle>aucunSon style lorsqu’il est coché, sous forme de classes checked:.
role.role(..)AccessibilityRole"menuitemcheckbox" avec checked, sinon "menuitem"Ce qu’il est pour les lecteurs d’écran ; "menuitemradio" pour un choix parmi un groupe.
indicatorClass, indicatorStyle.indicator_class(..), .indicator_style(..)string, StyleProp<ViewStyle>aucunL’indicateur, la partie située avant son contenu ; ses classes checked: et disabled: suivent l’élément.
indicator.indicator(..)JSX.ElementaucunLa marque à l’intérieur de l’indicateur.
closeOnSelect.close_on_select(..)booleantrueLe choisir ferme le menu.
accessibilityLabel.accessibility_label(..)stringaucunCe que disent les lecteurs d’écran, lorsque le contenu ne suffit pas.
children, class, style.child(..), méthodes de styleaucunSon contenu et son aspect, avec les états hover:, active: et checked:.
PropRustTypeDéfautDescription
trigger.trigger(..)JSX.ElementaucunL’élément qui l’ouvre.
back.back(..)JSX.ElementaucunTéléphones : la ligne au-dessus de ses éléments qui ramène aux éléments du niveau englobant.
disabled.disabled(..)booleanfalseIl ne s’ouvre pas.
offset.offset(..)number0Ordinateurs de bureau : l’écart entre le menu qui l’entoure et sa popup.
children.child(..)JSX.ElementaucunSes éléments.

MenuSeparator est une vue que vous stylisez (class, style), affichée avec les éléments de son niveau.

#Événements

ÉvénementRustDescription
onOpenChange (Menu, ContextMenu).on_open_change(|e| ..)Il s’est ouvert ou fermé, avec la raison.
onOpen (Menu, ContextMenu).on_open(|| ..)Il est affiché ; sous forme de feuille, une fois entré.
onClose (Menu, ContextMenu).on_close(|| ..)Il a disparu : sous forme de feuille une fois sorti, sous forme de popup après sa sortie. Également lorsque l’app l’a fermé.
onPosition (Menu, ContextMenu).on_position(|e| ..)Ordinateurs de bureau : la popup a été placée : placement (le côté où elle s’est placée), bounds, available.
onCancel (Menu, ContextMenu).on_cancel(|| ..)L’utilisateur a tenté de le fermer sans rien choisir (Échap ou retour, un appui à l’extérieur ou sur le fond).
onSelect (MenuItem).on_select(|| ..)L’élément a été choisi ; un élément cochable demande son autre valeur.

#Accessibilité

La popup ou la feuille est un menu et ses éléments sont des éléments de menu pour les lecteurs d’écran ; le déclencheur indique si le menu est déployé. Un élément cochable est un élément de menu de type case à cocher ou bouton radio, lu comme coché ou non coché ; son indicateur fait partie de l’élément. En TSX, le déclencheur est l’élément que vous passez, un Button ou une vue de votre cru : il prend lui-même en charge les appuis et l’état déployé, de sorte que ses propres classes active: et activeStyle s’appliquent pendant l’appui, et que son propre onPress s’exécute aussi. Seuls les éléments du niveau affiché sont à l’écran, les lecteurs d’écran lisent donc ce qui s’y trouve.