このページは機械翻訳のため、誤りを含む場合があります。 英語の原文を読む

要素

Menu

メニューは、トリガーが開くアクションの一覧です。1つの構造であらゆるデバイスに対応し、それぞれのデバイスが期待する形で表示されます。

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);
  • デスクトップとデスクトップブラウザでは、トリガーの位置にポップアップとして表示されます。Submenu は、ポインターが項目の上に来るか項目を押すと、その項目の横にカスケード表示されます。
  • スマートフォンとタブレットでは、バックドロップの上、画面下部にシートとして表示されます。Submenu は、メニュー自身の項目の代わりに自身の項目をシート内に表示し、先頭には元の階層へ戻るための行(あなたが用意するもの)が置かれます。

項目の選択、外側の押下、Escape、Android の戻るボタン、VoiceOver のエスケープジェスチャーのいずれでも閉じます。ContextMenu は同じメニューを、セカンダリボタン(右クリック)でポインターの位置に、またはタッチスクリーンではトリガーを指で長押しして開くものです。

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

5つすべて nsh/base から提供されます。項目は自動的にメニューへ届くので、自由に配置できます。ビューによるグループ化、ラベル、If で現れたり消えたりする項目などです。popupClass はデスクトップではポップアップを、スマートフォンではシートをスタイリングします。両者で異なるスタイルにしたい場合は、platform.idiom(nsh)を "desktop" と比較できます。

#メニューバー

トリガーが1つの Toolbar の項目であるメニューは、メニューバーを構成します。いずれかのメニューが開いている間は、別のメニューのトリガー上にポインターを置くとそのメニューが代わりに開き、矢印キーでトリガー間を移動できます。

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

デスクトップでは、セカンダリボタンでポインターの位置に開きます。タッチスクリーンでは、トリガーを指で長押しするとシートとして開きます。トリガー自身の押下や周囲のスクロールビューは引き続き動作し、指を動かすと長押しはキャンセルされます。

#チェック可能な項目

checked を指定すると、項目がチェック可能になります。メニューが示す設定(「サイドバーを表示」)や、role="menuitemradio" を併用したグループ内の1つの選択肢(「並べ替え」)に使います。値はあなたが管理します。項目を選択すると onSelect が呼ばれ、あなたが次の値を設定すると、項目がそれを表示します。チェックされている間は、項目の checked: クラスと checkedStyle が適用され、インジケーターのものも同様に適用されます。インジケーターとは内容の前に置かれる部分(indicatorClass、indicatorStyle)で、あなたが指定するマーク(indicator)を囲みます。マークは一度だけ作られて保持されます。マークの表示と非表示は、インジケーターの checked: クラスで切り替えるか、indicatorClass 内の group とマーク自身の group-checked: クラスで切り替えます。closeOnSelect={false} を指定するとメニューが開いたままになるため、設定の新しい値がすぐに表示されます。

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

末尾側のマークは、代わりに内容の中に置き、項目の group の下で group-checked: クラスを使ってスタイリングします。

#表示と終了のトランジション

デスクトップでは、ポップアップは Popover と同様に、あなたのトランジションに合わせて現れたり消えたりします。popupStartingClass は最初のフレームでの見た目、popupEndingClass は閉じた後の見た目で、popupClass の transition-* クラスが実行されている間はこの見た目が保たれ、その後に onClose が続きます。サブメニューも同じ見た目を受け取り、親のメニューとともに閉じます。スマートフォンのシートは、独自にスライドして出入りします。

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

#開閉状態

open がない場合、メニューは自身の状態を保持します(Menu 向けの defaultOpen から)。open がある場合は、アプリが状態を所有し、onOpenChange({ open, reason }) で変更します。対象は "triggerPress"、"select"、"pressOutside"、"backdropPress"、"cancel"、"contextMenu"、"longPress" です。

#Props

PropRust型デフォルト説明
open.open(..)booleanなし開いているかどうか。アプリが制御します。
defaultOpen.default_open(..)booleanfalseopen を使わない場合に、最初から開いているかどうか。
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"デスクトップ: トリガーのどちら側に配置するか。
align.align(..)"start" | "center" | "end""start"デスクトップ: トリガーとの位置合わせの方法。
offset.offset(..)number0デスクトップ: トリガーとポップアップの間隔。
margin.margin(..)number0デスクトップ: セーフエリアの端から保つ距離。
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>なしポップアップ(デスクトップ)またはシート(スマートフォン)、およびサブメニューのポップアップ。
popupStartingClass, popupStartingStyle.popup_starting_class(..), .popup_starting_style(..)string, StyleProp<ViewStyle>なしデスクトップ: ポップアップの最初のフレームでの見た目。ポップアップ自身のスタイルに重ねて適用されます。
popupEndingClass, popupEndingStyle.popup_ending_class(..), .popup_ending_style(..)string, StyleProp<ViewStyle>なしデスクトップ: ポップアップが閉じた後の見た目。トランジションの実行中は保たれます。
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>なしスマートフォン: シートの背後のバックドロップ。
trigger.trigger(..)JSX.Elementなし押されたときにメニューを開く要素。メニューが開いている間は、その aria-expanded: クラスと expandedStyle が適用されます。
children.child(..)JSX.Elementなし項目。メニューが開くたびに作られます。

#ContextMenu

open、popupClass / popupStyle、開始時と終了時の見た目、backdropClass / backdropStyle、trigger、children は Menu と同じで、さらに次があります。

PropRust型デフォルト説明
disabled.disabled(..)booleanfalseセカンダリボタンや長押しの指に反応しません。
offset.offset(..)number0デスクトップ: ポインターとポップアップの間隔。
margin.margin(..)number0デスクトップ: セーフエリアの端から保つ距離。
PropRust型デフォルト説明
disabled.disabled(..)booleanfalse選択できません。disabled: のスタイルが適用されます。
checked.checked(..)booleanなしチェックされているかどうか。指定すると項目がチェック可能になります。アプリが管理します。
checkedStyle.checked_style(..)StyleProp<ViewStyle>なしチェックされている間のスタイル。checked: クラスとして指定します。
role.role(..)AccessibilityRolechecked がある場合は "menuitemcheckbox"、それ以外は "menuitem"スクリーンリーダーに対する項目の役割。グループ内の1つの選択肢には "menuitemradio" を指定します。
indicatorClass, indicatorStyle.indicator_class(..), .indicator_style(..)string, StyleProp<ViewStyle>なしインジケーター(内容の前の部分)。その checked: クラスと disabled: クラスは項目に従います。
indicator.indicator(..)JSX.Elementなしインジケーター内のマーク。
closeOnSelect.close_on_select(..)booleantrue選択するとメニューが閉じます。
accessibilityLabel.accessibility_label(..)stringなし内容だけでは足りない場合に、スクリーンリーダーが読み上げる内容。
children, class, style.child(..), スタイルメソッドなし項目の内容と見た目。hover:、active:、checked: の各状態を持ちます。
PropRust型デフォルト説明
trigger.trigger(..)JSX.Elementなしサブメニューを開く項目。
back.back(..)JSX.Elementなしスマートフォン: 項目の上に置かれ、周囲の項目へ戻るための行。
disabled.disabled(..)booleanfalse開きません。
offset.offset(..)number0デスクトップ: 周囲のメニューとサブメニューのポップアップの間隔。
children.child(..)JSX.Elementなしサブメニューの項目。

MenuSeparator はスタイリングできるビュー(class、style)で、同じ階層の項目とともに表示されます。

#イベント

イベントRust説明
onOpenChange(Menu, ContextMenu).on_open_change(|e| ..)開閉されたときに、その理由とともに呼ばれます。
onOpen(Menu, ContextMenu).on_open(|| ..)表示されたとき。シートの場合は、表示のアニメーションが終わったとき。
onClose(Menu, ContextMenu).on_close(|| ..)消えたとき。シートの場合は消えた後、ポップアップの場合は終了トランジションの後。アプリが閉じた場合にも呼ばれます。
onPosition(Menu, ContextMenu).on_position(|e| ..)デスクトップ: ポップアップが配置されたとき。placement(配置された側)、bounds、available。
onCancel(Menu, ContextMenu).on_cancel(|| ..)ユーザーが項目を選ばずに閉じようとしたとき(Escape または戻る操作、外側やバックドロップの押下)。
onSelect(MenuItem).on_select(|| ..)項目が選択されたとき。チェック可能な項目では、もう一方の値が渡されます。

#アクセシビリティ

スクリーンリーダーに対して、ポップアップまたはシートはメニュー、その項目はメニュー項目として扱われます。トリガーは、メニューが展開されているかどうかを伝えます。チェック可能な項目はチェックボックスまたはラジオのメニュー項目となり、チェック済みか未チェックかが読み上げられ、そのインジケーターは項目の一部です。TSX では、トリガーはあなたが渡す要素(Button またはあなた自身のビュー)です。押下と展開状態はトリガー自身が受け取るため、トリガー自身の active: クラスと activeStyle は押されている間に適用され、トリガー自身の onPress も実行されます。画面上にあるのは表示中の階層の項目だけなので、スクリーンリーダーはそこにあるものを読み上げます。