العناصر
Popover
يعرض Popover محتوى بجوار العنصر الذي يشغّله وفوق كل ما عداه: تفاصيل، أو نموذجًا صغيرًا، أو أداة لاختيار الألوان. يفتحه الضغط على المشغّل ويغلقه، كما يغلقه الضغط خارجه، أو Escape، أو زر الرجوع في Android، أو إيماءة الهروب في VoiceOver. يوضع على أحد جوانب المشغّل، وينتقل إلى الجانب الآخر إذا لم تتوفر مساحة، ويبقى داخل المنطقة الآمنة، ويتبع المشغّل أثناء التمرير. لا يرسم شيئًا بنفسه: النافذة المنبثقة ومحتواها من عمل التطبيق.
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.")),
)
}import { Text, View } from "nsh";
import { Popover } from "nsh/base";
function Details() {
return (
<Popover
offset={6}
margin={8}
popupClass="w-60 gap-1 rounded-lg bg-white p-3 shadow-lg"
trigger={
<View class="rounded-md bg-gray-200 px-3 py-2">
<Text>Details</Text>
</View>
}
>
<Text class="font-semibold">Order #1024</Text>
<Text>Shipped yesterday, arriving Friday.</Text>
</Popover>
);
}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(..) | boolean | false | مفتوح في البداية، بدون open. |
placement | .placement(..) | "top" | "bottom" | "left" | "right" | "bottom" | جانب المشغّل الذي يوضع عليه. |
align | .align(..) | "start" | "center" | "end" | "center" | كيفية محاذاته للمشغّل على طول ذلك الجانب. |
offset | .offset(..) | number | 0 | الفجوة بين المشغّل والنافذة المنبثقة. |
margin | .margin(..) | number | 0 | المسافة التي يحافظ عليها من حواف المنطقة الآمنة. |
closeOnEscape | .close_on_escape(..) | boolean | true | Escape وزر الرجوع في Android يغلقانه. |
closeOnPressOutside | .close_on_press_outside(..) | boolean | true | الضغط خارجه يغلقه. |
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 الخاص به
أيضًا.