Diese Seite wurde maschinell übersetzt und kann Fehler enthalten. Das englische Original lesen

Elemente

HoverCard

Eine Hover Card zeigt eine Vorschau dessen, worauf ihr Trigger verweist, ohne dass die Seite verlassen wird: das Profil hinter einer Erwähnung, die Seite hinter einem Link, den Kurs hinter einem Ticker. Auf Desktops und in Desktop-Browsern öffnet sie sich, sobald der Mauszeiger openDelay (600 ms) auf dem Trigger verweilt hat oder der Tastaturfokus auf den Trigger gelangt. Sie bleibt geöffnet, während sich der Mauszeiger vom Trigger auf die Karte und zurück bewegt, sowie solange der Tastaturfokus auf dem Trigger oder auf einem Link oder Button in der Karte liegt. So lässt sich alles in der Karte gleichermaßen mit Mauszeiger und Tastatur bedienen. Sie schließt sich closeDelay (300 ms), nachdem der Mauszeiger beide verlassen hat und der Fokus in keinem von beiden liegt. Escape und ein Klick außerhalb schließen sie sofort. Auf Smartphones und Tablets öffnet und schließt ein Tippen auf den Trigger die Karte, ein Tippen außerhalb schließt sie.

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 aus nsh/base zeichnet selbst nichts: Der Trigger ist Ihr Element, das Popup und sein Inhalt stammen ebenfalls von Ihnen.

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

Der Trigger behält seine eigene Rolle, seine Interaktionen und sein Aussehen: Ein View mit role="link" und eigenem onPress führt beim Drücken weiterhin dorthin, wohin er auch sonst führt, und die Karte ergänzt ihn um eine Vorschau. Der Inhalt wird bei jedem Öffnen der Karte neu erzeugt und kann daher erst dann laden, was er anzeigt. Auf Smartphones und Tablets, wo ein Tippen die Karte öffnet, teilt der Trigger Screenreadern mit, ob seine Karte geöffnet ist. In einer Toolbar kann der Trigger eines ihrer Elemente sein; was die Karte zeigt, gehört dagegen nicht dazu: Ihre Buttons und Links behalten ihre eigenen Tab-Stopps, und das disabled der Toolbar lässt sie unverändert.

#Verzögerungen

openDelay verhindert, dass die Karte aufblitzt, wenn der Mauszeiger den Trigger nur auf dem Weg woandershin überquert; closeDelay gibt dem Mauszeiger Zeit, die Lücke zwischen Trigger und Karte zu überqueren. Beide Werte sind in Millisekunden angegeben. Mauszeiger und Verzögerungen werden nativ verfolgt, sodass das Verweilen auf einem Trigger und das Bewegen zwischen ihm und der Karte keinen Ihrer Codes ausführen, bis sich die Karte öffnet oder schließt. Ein openDelay von Infinity überlässt das Öffnen der App (und einem Tippen auf einem Touchscreen); ein closeDelay von Infinity überlässt das Schließen Escape, einem Klick außerhalb und der App.

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

#Zustand „geöffnet“

Ohne open verwaltet die Karte ihren Zustand selbst, beginnend bei defaultOpen. Mit open gehört der Zustand der App: Ändern Sie ihn in onOpenChange({ open, reason }), das jede Anfrage zum Öffnen oder Schließen samt Grund meldet: "hover" (der Mauszeiger), "focus" (der Tastaturfokus), "triggerPress" (ein Tippen auf einem Touchscreen), "pressOutside" oder "cancel" (Escape oder Zurück).

const open = signal(false);

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

#Ein- und Ausblenden

Eine Hover Card erscheint und verschwindet mit Ihren Übergängen, wie es auch ein Popover tut. popupStartingClass ist das Aussehen des Popups im ersten Frame, und die transition-*-Klassen von popupClass führen es zu seinem eigentlichen Aussehen. popupEndingClass ist sein Aussehen im geschlossenen Zustand: Die Karte bleibt bestehen und nimmt keine Mauszeigereingaben an, solange diese Übergänge laufen, dann verschwindet sie, und onClose folgt. Verweilt der Mauszeiger erneut auf dem Trigger, während sie verschwindet, holt er sie zurück. Wenn der Nutzer weniger Bewegung wünscht, erscheint und verschwindet sie sofort.

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

Standardmäßig erscheint sie unterhalb des Triggers (placement, align), auf der gegenüberliegenden Seite, wenn dort kein Platz ist, und bleibt innerhalb des sicheren Bereichs. onPosition({ placement, bounds, available }) gibt an, wohin sie gelangt ist.

#Props

Prop Rust Typ Standard Beschreibung
open .open(..) boolean keiner Geöffnet, von der App gesteuert.
defaultOpen .default_open(..) boolean false Anfangs geöffnet, ohne open.
openDelay .open_delay(..) number 600 Millisekunden, die der Mauszeiger auf dem Trigger verweilt oder der Fokus auf ihm bleibt, bevor sich die Karte öffnet; Infinity: öffnet sich nie von selbst.
closeDelay .close_delay(..) number 300 Millisekunden, die der Mauszeiger vom Trigger und von der Karte entfernt ist und der Fokus in keinem von beiden liegt, bevor sich die Karte schließt; Infinity: schließt sich nie von selbst.
placement .placement(..) "top" | "bottom" | "left" | "right" "bottom" Die Seite des Triggers, auf der sie erscheint.
align .align(..) "start" | "center" | "end" "center" Wie sie sich entlang dieser Seite am Trigger ausrichtet.
offset .offset(..) number 0 Der Abstand zwischen Trigger und Karte.
margin .margin(..) number 0 Der Abstand, den sie zu den Rändern des sicheren Bereichs einhält.
popupClass, popupStyle .popup_class(..), .popup_style(..) string, StyleProp<ViewStyle> keiner Das Aussehen des Popups, der Karte, in der der Inhalt liegt.
popupStartingClass, popupStartingStyle .popup_starting_class(..), .popup_starting_style(..) string, StyleProp<ViewStyle> keiner Das Aussehen des Popups im ersten Frame, über sein eigenes gelegt: Seine Übergänge laufen von hier aus.
popupEndingClass, popupEndingStyle .popup_ending_class(..), .popup_ending_style(..) string, StyleProp<ViewStyle> keiner Das Aussehen des Popups im geschlossenen Zustand, über sein eigenes gelegt: Es bleibt bestehen, solange seine Übergänge laufen.
trigger .trigger(..) JSX.Element keiner Das, wovon sie eine Vorschau zeigt; sie wird daran ausgerichtet.
children .child(..) JSX.Element keiner Der Inhalt, der bei jedem Öffnen neu erzeugt wird.

#Ereignisse

Ereignis Rust Beschreibung
onOpenChange .on_open_change(|e| ..) Fordert das Öffnen oder Schließen an, samt Grund: "hover", "focus", "triggerPress", "pressOutside" oder "cancel".
onOpen .on_open(|| ..) Sie wird angezeigt.
onClose .on_close(|| ..) Sie ist verschwunden, nach ihrem Ausblenden; auch dann, wenn die App sie geschlossen hat.
onPosition .on_position(|e| ..) Das Popup wurde platziert: placement (die Seite, auf der es erschien), bounds (sein Frame), available (der verfügbare Platz auf dieser Seite).

#Barrierefreiheit

Eine Hover Card ergänzt ihren Trigger: Der Trigger behält seine Rolle und sein Label, und was er beim Drücken öffnet oder auslöst, bleibt unverändert. Tastaturnutzer erhalten die Karte, wenn der Fokus auf dem Trigger ruht, und Escape schließt sie. Die Karte übernimmt selbst keinen Fokus, sodass Tab vom Trigger aus wie bisher weiterspringt. Links und Buttons in der Karte erhalten den Fokus wie überall sonst, und die Karte bleibt geöffnet, solange einer von ihnen ihn hat; was in der Karte wichtig ist, sollte zudem von der Seite aus erreichbar sein, zu der der Trigger führt.