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

العناصر

HoverCard

تعرض بطاقة التمرير (hover card) معاينة لما يشير إليه المشغّل (trigger) دون مغادرة الصفحة: الملف الشخصي خلف إشارة، أو الصفحة خلف رابط، أو السعر خلف رمز سهم. على أجهزة سطح المكتب وفي متصفحاتها تُفتح البطاقة بعد أن يستقر المؤشر على المشغّل مدة openDelay (600 مللي ثانية)، أو بمجرد انتقال تركيز لوحة المفاتيح إلى المشغّل. وتبقى مفتوحة بينما ينتقل المؤشر من المشغّل إلى البطاقة والعكس، وما دام تركيز لوحة المفاتيح على المشغّل أو على رابط أو زر داخل البطاقة، بحيث يمكن استخدام كل ما في البطاقة بالمؤشر وبلوحة المفاتيح على حد سواء. وتُغلق بعد closeDelay (300 مللي ثانية) من مغادرة المؤشر للمشغّل والبطاقة معًا وخروج التركيز منهما. ويغلقها الضغط على Escape أو الضغط خارجها فورًا. أما على الهواتف والأجهزة اللوحية فيفتحها الضغط على المشغّل ويغلقها، ويغلقها الضغط خارجها.

el-hover-card.tsx
el-hover-card.tsxLive
import { type JSX, mount, signal, Text, View } from "nsh";
import { HoverCard, type OpenChangeReason } from "nsh/base";
import { desktop, focusRing, Inspector, Log, PopIn, type PopupSide, Segmented, Stage, tint, ui, wide } from "./palette";

type Placement = PopupSide;

/** A card's surface, the look the app gives it; `PopIn` brings it in from the trigger's side. */
const surface = (width: number) => ({ ...ui().card, width, borderRadius: 12, padding: 14, gap: 10 });

/** The popup's box fades on its way out (its ending class, kept while the transition runs). */
const exit = { popupClass: "transition-opacity duration-150 ease-in motion-reduce:transition-none", popupEndingClass: "opacity-0" };

function App() {
  const openDelay = signal(600);
  const closeDelay = signal(300);
  const placement = signal<Placement>("bottom");
  const events = signal<string[]>([]);
  const following = signal(false);
  const price = signal(227.52);
  let timer: ReturnType<typeof setInterval> | undefined;
  const log = (name: string) => ({ open, reason }: { open: boolean; reason: OpenChangeReason }) =>
    events.set([`${name} ${open ? "opened" : "closed"} · ${reason}`, ...events()].slice(0, 6));
  const shared = () => ({ ...exit, openDelay: openDelay(), closeDelay: closeDelay(), placement: placement(), offset: 8, margin: 12 });
  // Running text in a wrapping row: one piece per word, so a line breaks
  // between words, and the row's gap is the space between them.
  const words = (s: string) => s.split(" ").map((w) => <Text style={{ fontSize: 15, color: ui().text }}>{w}</Text>);

  // A mention: the person's card, with a button that works because the
  // pointer can move into the card.
  const mention = () => (
    <HoverCard {...shared()} onOpenChange={log("@ada")} trigger={
      <View role="link" onPress={() => events.set(["onPress /people/ada", ...events()].slice(0, 6))} focusStyle={focusRing(1)} style={{ borderRadius: 4, borderWidth: 1, borderColor: "transparent", paddingHorizontal: 2, marginHorizontal: -3 }} hoverStyle={{ backgroundColor: tint(ui().accent, 0.14) }}>
        <Text style={{ fontSize: 15, fontWeight: "600", color: ui().accent }}>@ada</Text>
      </View>
    }>
      <PopIn side={placement()} style={surface(260)}>
        <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
          <View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: tint(ui().accent, 0.18), alignItems: "center", justifyContent: "center" }}>
            <Text style={{ fontSize: 15, fontWeight: "600", color: ui().accent }}>AL</Text>
          </View>
          <View style={{ flex: 1, gap: 2 }}>
            <Text style={{ fontSize: 15, fontWeight: "600", color: ui().text }}>Ada Lovelace</Text>
            <Text style={{ fontSize: 12, color: ui().muted }}>Analyst · Engines</Text>
          </View>
        </View>
        <Text style={{ fontSize: 13, lineHeight: 18, color: ui().text }}>Writes the first programs for the Analytical Engine.</Text>
        <View
          onPress={() => following.set(!following())}
          style={{ height: 30, borderRadius: 8, alignItems: "center", justifyContent: "center", ...(following() ? ui().raised : ui().accentFill), transitionProperty: "background-color", transitionDuration: 150 }}
        >
          <Text style={{ fontSize: 13, fontWeight: "500", color: following() ? ui().text : ui().onAccent }}>{following() ? "Following" : "Follow"}</Text>
        </View>
      </PopIn>
    </HoverCard>
  );

  // A link with a preview of the page behind it.
  const preview = () => (
    <HoverCard {...shared()} onOpenChange={log("taffy")} trigger={
      <View role="link" onPress={() => events.set(["onPress github.com/DioxusLabs/taffy", ...events()].slice(0, 6))} focusStyle={focusRing(1)} style={{ borderBottomWidth: 1, borderBottomColor: tint(ui().accent, 0.4) }}>
        <Text style={{ fontSize: 15, color: ui().accent }}>Taffy</Text>
      </View>
    }>
      <PopIn side={placement()} style={{ ...surface(280), padding: 0, gap: 0, overflow: "hidden" }}>
        <View style={{ height: 64, backgroundImage: "linear-gradient(135deg, #3b82f6, #8b5cf6)" }} />
        <View style={{ padding: 12, gap: 4 }}>
          <Text style={{ fontSize: 11, color: ui().faint }}>github.com</Text>
          <Text style={{ fontSize: 14, fontWeight: "600", color: ui().text }}>taffy: flexbox, grid and block layout</Text>
          <Text style={{ fontSize: 12, color: ui().muted }}>A high-performance UI layout library written in Rust.</Text>
        </View>
      </PopIn>
    </HoverCard>
  );

  // A ticker whose quote ticks while its card is open.
  const ticker = () => (
    <HoverCard
      {...shared()}
      onOpenChange={log("$AAPL")}
      onOpen={() => (timer = setInterval(() => price.set(Math.round((price() + (Math.random() - 0.48) * 0.6) * 100) / 100), 900))}
      onClose={() => clearInterval(timer)}
      trigger={
        <View focusable focusStyle={focusRing(1)} style={{ paddingHorizontal: 8, height: 26, borderRadius: 13, justifyContent: "center", borderWidth: 1, borderColor: tint(ui().up, 0.3), backgroundColor: tint(ui().up, 0.1) }}>
          <Text style={{ fontSize: 13, fontWeight: "600", color: ui().up }}>$AAPL ▲</Text>
        </View>
      }
    >
      <PopIn side={placement()} style={surface(220)}>
        <Text style={{ fontSize: 12, color: ui().muted }}>Apple Inc. · live</Text>
        <Text style={{ fontSize: 26, fontWeight: "600", color: ui().text, fontVariant: ["tabular-nums"] }}>{price().toFixed(2)}</Text>
        <Text style={{ fontSize: 13, color: price() >= 224.1 ? ui().up : ui().down, fontVariant: ["tabular-nums"] }}>
          {price() >= 224.1 ? "+" : ""}
          {(price() - 224.1).toFixed(2)} today
        </Text>
      </PopIn>
    </HoverCard>
  );

  const stage = (): JSX.Element => (
    <View style={{ gap: 18, alignItems: "flex-start" }}>
      <View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 4, rowGap: 6 }}>
        {words("Ask")}
        {mention()}
        {words("about the layout engine,")}
        {/* The period stays with the link. */}
        <View style={{ flexDirection: "row", alignItems: "center" }}>
          {preview()}
          {words(".")}
        </View>
      </View>
      <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
        {words("Today:")}
        {ticker()}
      </View>
    </View>
  );
  const controls = () => (
    <>
      <Segmented label="openDelay" options={[0, 300, 600, 1200]} value={openDelay()} onChange={(v) => openDelay.set(v)} />
      <Segmented label="closeDelay" options={[0, 300, 1000]} value={closeDelay()} onChange={(v) => closeDelay.set(v)} />
      <Segmented label="placement" options={["top", "bottom", "left", "right"] as Placement[]} value={placement()} onChange={(v) => placement.set(v)} />
    </>
  );
  const changes = () => <Log title="onOpenChange({ open, reason })" lines={events()} minHeight={110} />;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>hover card</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Rest the pointer on a mention, a link or a ticker, then move into the card" : "Tap a mention, a link or a ticker; tap outside to close"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            <Stage gap={24}>{stage()}</Stage>
            <Inspector>
              {controls()}
              {changes()}
            </Inspector>
          </View>
        ) : (
          <>
            {controls()}
            <View style={{ flex: 1, minHeight: 220, justifyContent: "center" }}>{stage()}</View>
            {changes()}
          </>
        )
      }
    </View>
  );
}

mount(App);

HoverCard من nsh/base لا يرسم شيئًا بنفسه: المشغّل هو عنصرك، والنافذة المنبثقة ومحتواها من عندك.

use nsh_base::hover_card;
use nsh_core::prelude::*;

fn mention() -> impl IntoElement {
    hover_card()
        .offset(px(8.0))
        .popup_class("w-72 gap-2 rounded-xl border border-border bg-popover p-4 shadow-lg")
        .trigger(text("@ada").text_color(rgb(0x2563eb)))
        .child(text("Ada Lovelace").font_weight(FontWeight::SEMIBOLD))
        .child(text("Writes the first programs for the Analytical Engine."))
}

يحتفظ المشغّل بدوره وضغطاته ومظهره: فالعنصر الذي له role="link" وonPress خاص به ينتقل إلى الوجهة نفسها عند الضغط عليه، وتضيف البطاقة معاينة إليه. يُنشأ المحتوى في كل مرة تُفتح فيها البطاقة، فيمكنه تحميل ما يعرضه حينها. وعلى الهواتف والأجهزة اللوحية، حيث يفتح الضغط البطاقة، يُعلم المشغّل قارئات الشاشة بما إذا كانت بطاقته مفتوحة. وفي Toolbar يمكن أن يكون المشغّل أحد عناصرها، أما ما تعرضه البطاقة فليس منها: فأزراره وروابطه تحتفظ بمحطات Tab الخاصة بها، ولا يغيّرها disabled الخاص بـ Toolbar.

#التأخيرات

يمنع openDelay البطاقة من الانفتاح المفاجئ عندما يعبر المؤشر المشغّل في طريقه إلى مكان آخر، ويمنح closeDelay المؤشر وقتًا لعبور الفجوة بين المشغّل والبطاقة. وكلاهما بالمللي ثانية. يجري تتبع المؤشر والتأخيرات أصليًا، فلا يُنفَّذ أي من شيفرتك أثناء استقرار المؤشر على المشغّل أو تنقله بينه وبين البطاقة إلى أن تُفتح البطاقة أو تُغلق. وقيمة Infinity لـ openDelay تترك الفتح للتطبيق (وللضغط على الشاشة اللمسية)، وقيمة Infinity لـ closeDelay تترك الإغلاق لـ Escape والضغط خارج البطاقة وللتطبيق.

<HoverCard openDelay={300} closeDelay={150} trigger={<Text>AAPL</Text>}>
  <Quote symbol="AAPL" />
</HoverCard>

#حالة الفتح

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

const open = signal(false);

<HoverCard open={open()} onOpenChange={(e) => open.set(e.open)} trigger={<Text>@ada</Text>}>
  <Profile id="ada" />
</HoverCard>

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

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

<HoverCard
  popupClass="rounded-xl bg-popover p-4 shadow-lg transition-all duration-150 ease-out"
  popupStartingClass="opacity-0 scale-95"
  popupEndingClass="opacity-0 scale-95"
  trigger={<Text>@ada</Text>}
>
  <Profile id="ada" />
</HoverCard>

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

#الخصائص

الخاصيةRustالنوعالافتراضيالوصف
open.open(..)booleanلا شيءمفتوحة، ويتحكم بها التطبيق.
defaultOpen.default_open(..)booleanfalseمفتوحة في البداية، بدون open.
openDelay.open_delay(..)number600المللي ثواني التي يستقر فيها المؤشر على المشغّل، أو يبقى التركيز عليه، قبل أن تُفتح البطاقة؛ Infinity: لا تُفتح من تلقاء نفسها أبدًا.
closeDelay.close_delay(..)number300المللي ثواني التي يبتعد فيها المؤشر عن المشغّل والبطاقة، مع خروج التركيز منهما، قبل أن تُغلق؛ Infinity: لا تُغلق من تلقاء نفسها أبدًا.
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"جانب المشغّل الذي تظهر عليه.
align.align(..)"start" | "center" | "end""center"كيفية محاذاتها للمشغّل على طول ذلك الجانب.
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>لا شيءمظهر النافذة المنبثقة عند الإغلاق، فوق مظهرها الخاص: يبقى أثناء تشغيل انتقالاتها.
trigger.trigger(..)JSX.Elementلا شيءما تعرض معاينته؛ وتوضع البطاقة بالنسبة إليه.
children.child(..)JSX.Elementلا شيءالمحتوى، ويُنشأ في كل مرة تُفتح فيها البطاقة.

#الأحداث

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

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

تضيف بطاقة التمرير إلى مشغّلها دون أن تغيّره: يحتفظ المشغّل بدوره وتسميته، ويبقى ما يفتحه أو يفعله عند الضغط عليه كما هو. يحصل مستخدمو لوحة المفاتيح على البطاقة عندما يستقر التركيز على المشغّل، ويغلقها Escape. لا تأخذ البطاقة تركيزًا لنفسها، فيواصل Tab الانتقال من المشغّل كما كان. وتأخذ الروابط والأزرار داخل البطاقة التركيز كما في أي مكان آخر، وتبقى البطاقة مفتوحة ما دام أحدها يملكه؛ وينبغي أن يكون كل ما هو مهم في البطاقة متاحًا أيضًا من الصفحة التي يقود إليها المشغّل.