تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

Menu

القائمة هي لائحة من الإجراءات يفتحها مشغّلها. بنية واحدة تخدم كل الأجهزة، وتُعرض كما يتوقعها كل جهاز:

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

تأتي كلها الخمسة من nsh/base. تصل العناصر إلى قائمتها من تلقاء نفسها، لذا يمكنك ترتيبها بحرية: مجموعات داخل views، وعناوين، وعناصر تظهر وتختفي باستخدام If. يتولى popupClass تنسيق النافذة المنبثقة على أجهزة سطح المكتب والورقة السفلية على الهواتف؛ وإذا اختلف التنسيق بينهما فيمكنه مقارنة platform.idiom (nsh) مع "desktop".

القوائم التي تكون مشغّلاتها عناصر في Toolbar واحد تشكّل شريط قوائم: فعندما تكون إحدى قوائمه مفتوحة، يؤدي مرور المؤشر فوق مشغّل قائمة أخرى إلى فتح تلك القائمة بدلاً منها، وتنقل مفاتيح الأسهم بين المشغّلات.

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

#Context menus

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>

على أجهزة سطح المكتب يفتحها الزر الثانوي عند موضع المؤشر. وفي الشاشات اللمسية يفتحها إصبع مضغوط على المشغّل كورقة سفلية؛ وتبقى ضغطات المشغّل نفسه وشاشات التمرير المحيطة به تعمل، ويلغي تحريكُ الإصبع الضغطَ المطوّل.

#Checkable items

checked يجعل العنصر قابلاً للتحديد: إما إعداداً تعرضه القائمة (“إظهار الشريط الجانبي”)، أو، مع role="menuitemradio"، خياراً واحداً من مجموعة (“الترتيب حسب”). القيمة بيدك: اختيار العنصر يستدعي onSelect، فتضبط أنت القيمة التالية، ويعرضها العنصر. وما دام محدداً تُطبَّق فئات checked: وcheckedStyle الخاصة به، وكذلك فئات مؤشره: الجزء الذي يسبق محتواه (indicatorClass، indicatorStyle) المحيط بالعلامة التي تقدمها له (indicator). تُنشأ العلامة مرة واحدة وتبقى؛ وتُظهرها فئات checked: الخاصة بالمؤشر وتُخفيها، أو فئات group-checked: الخاصة بالعلامة نفسها مع group في indicatorClass. و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-checked: تحت group على العنصر.

#Entrance and exit

على أجهزة سطح المكتب تظهر النافذة المنبثقة وتختفي بانتقالاتك، كما في Popover: popupStartingClass هو مظهرها في الإطار الأول، وpopupEndingClass مظهرها بعد الإغلاق، وتحتفظ به أثناء تشغيل فئات transition-* الخاصة بـpopupClass، ثم يتبعه 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 state

بدون open، تحتفظ القائمة بحالتها الخاصة (من defaultOpen لـMenu). ومع open، يملك التطبيق الحالة ويغيّرها في onOpenChange({ open, reason }): "triggerPress"، "select"، "pressOutside"، "backdropPress"، "cancel"، "contextMenu" أو "longPress".

#Props

الخاصيةRustالنوعالافتراضيالوصف
open.open(..)booleanلا شيءمفتوحة، ويتحكم بها التطبيق.
defaultOpen.default_open(..)booleanfalseمفتوحة في البداية، بدون open.
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، بالإضافة إلى:

الخاصيةRustالنوعالافتراضيالوصف
disabled.disabled(..)booleanfalseلا يفعل الزر الثانوي ولا الأصابع المضغوطة شيئاً.
offset.offset(..)number0أجهزة سطح المكتب: الفجوة بين المؤشر والنافذة المنبثقة.
margin.margin(..)number0أجهزة سطح المكتب: المسافة التي تحافظ عليها من حواف المنطقة الآمنة.
الخاصيةRustالنوعالافتراضيالوصف
disabled.disabled(..)booleanfalseلا يمكن اختياره؛ وتُطبَّق أنماط disabled:.
checked.checked(..)booleanلا شيءمحدد أو غير محدد، مما يجعله عنصراً قابلاً للتحديد؛ ويملكه التطبيق.
checkedStyle.checked_style(..)StyleProp<ViewStyle>لا شيءنمطه أثناء التحديد، بفئات checked:.
role.role(..)AccessibilityRole"menuitemcheckbox" مع checked، وإلا "menuitem"ما يكونه لقارئات الشاشة؛ و"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:.
الخاصيةRustالنوعالافتراضيالوصف
trigger.trigger(..)JSX.Elementلا شيءالعنصر الذي يفتحها.
back.back(..)JSX.Elementلا شيءالهواتف: الصف الذي يعلو عناصرها ويعيد إلى العناصر المحيطة بها.
disabled.disabled(..)booleanfalseلا تُفتح.
offset.offset(..)number0أجهزة سطح المكتب: الفجوة بين القائمة المحيطة بها ونافذتها المنبثقة.
children.child(..)JSX.Elementلا شيءعناصرها.

MenuSeparator هو view تنسّقه أنت (class، style)، ويُعرض مع عناصر مستواه.

#Events

الحدث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(|| ..)اختير العنصر؛ ويطلب العنصر القابل للتحديد قيمته الأخرى.

#Accessibility

تُعد النافذة المنبثقة أو الورقة السفلية قائمة، وعناصرها عناصر قائمة لدى قارئات الشاشة؛ ويُعلِم المشغّل ما إذا كانت القائمة موسّعة. والعنصر القابل للتحديد عنصر قائمة من نوع خانة اختيار أو زر راديو، ويُقرأ كمحدد أو غير محدد؛ ومؤشره جزء من العنصر. في TSX يكون المشغّل هو العنصر الذي تمرره، أي Button أو view من عندك: فهو يتولى الضغطات وحالة التوسيع بنفسه، لذا تظهر فئات active: وactiveStyle الخاصة به أثناء الضغط عليه، ويعمل onPress الخاص به أيضاً. لا يظهر على الشاشة سوى عناصر المستوى المعروض، فتقرأ قارئات الشاشة ما هو موجود فيه.