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

العناصر

Popover

يعرض Popover محتوى بجوار العنصر الذي يشغّله وفوق كل ما عداه: تفاصيل، أو نموذجًا صغيرًا، أو أداة لاختيار الألوان. يفتحه الضغط على المشغّل ويغلقه، كما يغلقه الضغط خارجه، أو Escape، أو زر الرجوع في Android، أو إيماءة الهروب في VoiceOver. يوضع على أحد جوانب المشغّل، وينتقل إلى الجانب الآخر إذا لم تتوفر مساحة، ويبقى داخل المنطقة الآمنة، ويتبع المشغّل أثناء التمرير. لا يرسم شيئًا بنفسه: النافذة المنبثقة ومحتواها من عمل التطبيق.

el-popover.tsx
el-popover.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Popover } from "nsh/base";
import { button, buttonText, desktop, Inspector, Log, PopIn, Segmented, Stage, ui, wide } from "./palette";

type Placement = "top" | "bottom" | "left" | "right";

function App() {
  const placement = signal<Placement>("bottom");
  const offset = signal(8);
  const events = signal<string[]>([]);
  const presses = signal(0);
  const placements = () => (
    <Segmented label="placement" options={["top", "bottom", "left", "right"] as Placement[]} value={placement()} onChange={(v) => placement.set(v)} />
  );
  const offsets = () => <Segmented label="offset" options={[0, 8, 16]} value={offset()} onChange={(v) => offset.set(v)} />;
  const popover = () => (
    <Popover
      placement={placement()}
      offset={offset()}
      margin={12}
      onOpenChange={({ open, reason }) => events.set([`${open ? "opened" : "closed"} · ${reason}`, ...events()].slice(0, 4))}
      trigger={
        <View {...button("accent")}>
          <Text style={buttonText("accent")}>Order #1024</Text>
        </View>
      }
    >
      {/* The panel grows out of the trigger's side each time it opens. */}
      <PopIn side={placement()} class="w-56 gap-2 rounded-xl p-4" style={ui().card}>
        <Text style={{ fontSize: 15, fontWeight: "600", color: ui().text }}>Shipped yesterday</Text>
        <Text style={{ fontSize: 13, color: ui().muted }}>Arriving Friday. The trigger, a press outside and Escape close this.</Text>
        <View style={{ flexDirection: "row", gap: 6, alignItems: "center" }}>
          <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: ui().up }} />
          <Text style={{ fontSize: 12, color: ui().up }}>In transit</Text>
        </View>
      </PopIn>
    </Popover>
  );
  const count = () => (
    <View
      onPress={() => presses.update((n) => n + 1)}
      {...button("secondary", { align: "center" })}
    >
      <Text style={buttonText()}>Count · pressed {presses()}×</Text>
    </View>
  );
  const log = () => <Log title="onOpenChange({ open, reason })" lines={events()} minHeight={92} />;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>popover</Text>
      <Text style={{ color: ui().muted }}>
        {desktop
          ? "Open it, then click “Count” below: one click closes it and presses the button"
          : "Open it, then tap “Count” below: the tap only closes it"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            <Stage gap={24}>
              <View style={{ height: 280, alignItems: "center", justifyContent: "center" }}>{popover()}</View>
              {count()}
            </Stage>
            <Inspector>
              {placements()}
              {offsets()}
              {log()}
            </Inspector>
          </View>
        ) : (
          <>
            <View style={{ flexDirection: "row", gap: 12 }}>
              <View style={{ flex: 3 }}>{placements()}</View>
              <View style={{ flex: 2 }}>{offsets()}</View>
            </View>
            <View style={{ flex: 1, minHeight: 200, alignItems: "center", justifyContent: "center" }}>{popover()}</View>
            {count()}
            {log()}
          </>
        )
      }
    </View>
  );
}

mount(App);
use nsh_base::{OpenChangeReason, PopoverEvent, popover};
use nsh_core::prelude::*;

fn details() -> impl IntoElement {
    popover()
        .offset(px(6.0))
        .margin(px(8.0))
        .trigger(view().px_3().py_2().rounded_md().bg(rgb(0xe5e7eb)).child(text("Details")))
        .on_open_change(|e: &PopoverEvent| {
            if let PopoverEvent::OpenChange(open, OpenChangeReason::PressOutside) = e {
                log::info!("closed by a press outside: {open}");
            }
        })
        .child(
            view()
                .w(px(240.0))
                .gap_1()
                .p_3()
                .rounded_lg()
                .bg(white())
                .shadow_lg()
                .child(text("Order #1024").font_weight(FontWeight::SEMIBOLD))
                .child(text("Shipped yesterday, arriving Friday.")),
        )
}

Popover يأتي من nsh/base. يُنشأ محتواه في كل مرة يُفتح فيها، لذا فإن الحالة داخله تبدأ من جديد.

#حالة الفتح

بدون open، يحتفظ Popover بحالته الخاصة، بادئًا من defaultOpen. مع open، يتولى التطبيق ملكية الحالة: غيّرها في onOpenChange({ open, reason })، الذي يبلّغ عن كل طلب فتح أو إغلاق مع سببه ("triggerPress"، "pressOutside"، "cancel").

const open = signal(false);

<Popover open={open()} onOpenChange={(e) => open.set(e.open)} trigger={<Text>Filters</Text>}>
  <FilterForm onApply={() => open.set(false)} />
</Popover>

closeOnEscape={false} يُبقيه مفتوحًا عند Escape والرجوع (ومع ذلك يظل onCancel يسمعهما ليسأل أولًا)؛ closeOnPressOutside={false} يُبقيه مفتوحًا عند الضغط خارجه.

#الهواتف وأجهزة سطح المكتب

يرتبط Popover بمشغّله على كل جهاز: ما يعرضه يخص العنصر الذي فتحه ويجب أن يكون بجواره.

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

لقائمة من الإجراءات استخدم Menu، الذي يتحول إلى sheet على الهواتف؛ وللوحة استخدم Sheet.

#الدخول والخروج

يظهر Popover ويختفي مع انتقالاتك. popupStartingClass هو مظهر النافذة المنبثقة في إطارها الأول، وفئات transition-* في popupClass تنقلها إلى مظهرها الخاص: أي دخولها. popupEndingClass هو مظهرها بعد الإغلاق: يبقى Popover دون أن يستقبل أي ضغطات أثناء تشغيل هذه الانتقالات، ثم يزول، ويتبع ذلك onClose. إذا فُتح من جديد أثناء خروجه، فإنه يعود إلى مظهره انطلاقًا من موضعه الحالي. popupStartingStyle وpopupEndingStyle يؤديان الدور نفسه بأنماط مضمّنة. عندما يطلب المستخدم حركة أقل، يظهر ويختفي فورًا.

<Popover
  popupClass="rounded-lg bg-white p-3 shadow-lg transition-all duration-150 ease-out"
  popupStartingClass="opacity-0 scale-95"
  popupEndingClass="opacity-0 scale-95"
  onPosition={({ placement }) => side.set(placement)}
  trigger={<Text>Details</Text>}
>
  <OrderSummary />
</Popover>

onPosition({ placement, bounds, available }) يخبرك أين ذهبت النافذة المنبثقة: جانب المشغّل (الجانب الآخر إذا لم تتوفر مساحة في placement)، وإطارها، والمساحة المتاحة على ذلك الجانب، بحيث يمكن لسهم أو لحركة دخول أن يشير إلى المشغّل.

#الخصائص

الخاصيةRustالنوعالقيمة الافتراضيةالوصف
open.open(..)booleanلا شيءمفتوح، ويتحكم به التطبيق.
defaultOpen.default_open(..)booleanfalseمفتوح في البداية، بدون open.
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"جانب المشغّل الذي يوضع عليه.
align.align(..)"start" | "center" | "end""center"كيفية محاذاته للمشغّل على طول ذلك الجانب.
offset.offset(..)number0الفجوة بين المشغّل والنافذة المنبثقة.
margin.margin(..)number0المسافة التي يحافظ عليها من حواف المنطقة الآمنة.
closeOnEscape.close_on_escape(..)booleantrueEscape وزر الرجوع في Android يغلقانه.
closeOnPressOutside.close_on_press_outside(..)booleantrueالضغط خارجه يغلقه.
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>لا شيءمظهر النافذة المنبثقة بعد الإغلاق، فوق مظهرها الخاص: تبقى أثناء تشغيل انتقالاتها.
trigger.trigger(..)JSX.Elementلا شيءما يفتحه؛ ويوضع Popover بالنسبة إليه. وما دام مفتوحًا تُطبَّق فئات aria-expanded: وexpandedStyle الخاصة به.
children.child(..)JSX.Elementلا شيءالمحتوى.

#الأحداث

الحدثRustالوصف
onOpenChange.on_open_change(|e| ..)فُتح أو أُغلق، مع السبب: "triggerPress" أو "pressOutside" أو "cancel".
onOpen.on_open(|| ..)ظهر.
onClose.on_close(|| ..)زال، بعد خروجه؛ وكذلك عندما أغلقه التطبيق.
onCancel.on_cancel(|| ..)حاول المستخدم إغلاقه (Escape أو الرجوع، أو الضغط خارجه)، سواء أُغلق أم لا.
onPosition.on_position(|e| ..)وُضعت النافذة المنبثقة: placement (الجانب الذي وُضعت عليه)، bounds (إطارها)، available (المساحة المتاحة على ذلك الجانب).

#إمكانية الوصول

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