Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Éléments

HoverCard

Une carte de survol prévisualise ce vers quoi pointe son déclencheur sans quitter la page : le profil derrière une mention, la page derrière un lien, la cotation derrière un symbole boursier. Sur les ordinateurs de bureau et dans les navigateurs de bureau, elle s’ouvre lorsque le pointeur est resté immobile sur le déclencheur pendant openDelay (600 ms), ou dès que le focus clavier arrive sur le déclencheur. Elle reste ouverte tant que le pointeur passe du déclencheur à la carte et inversement, et tant que le focus clavier se trouve sur le déclencheur ou sur un lien ou un bouton de la carte ; tout le contenu de la carte est ainsi utilisable aussi bien au pointeur qu’au clavier. Elle se ferme closeDelay (300 ms) après que le pointeur a quitté les deux et que le focus n’est dans aucun des deux. Échap et un appui à l’extérieur la ferment immédiatement. Sur les téléphones et les tablettes, un appui sur le déclencheur l’ouvre et la ferme, et un appui à l’extérieur la ferme.

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 de nsh/base ne dessine rien par lui-même : le déclencheur est votre élément, la fenêtre contextuelle et son contenu vous appartiennent.

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

Le déclencheur conserve son propre rôle, ses appuis et son apparence : une vue avec role="link" et son propre onPress va toujours là où elle va lorsqu’on appuie dessus, et la carte lui ajoute un aperçu. Le contenu est créé à chaque ouverture de la carte, il peut donc charger à ce moment-là ce qu’il affiche. Sur les téléphones et les tablettes, où un appui ouvre la carte, le déclencheur indique aux lecteurs d’écran si sa carte est ouverte. Dans une Toolbar, le déclencheur peut être l’un de ses éléments ; en revanche, ce que la carte affiche ne l’est pas : ses boutons et ses liens conservent leurs propres arrêts de tabulation et le disabled de la barre d’outils ne les modifie pas.

#Délais

openDelay évite que la carte ne s’ouvre brusquement lorsque le pointeur traverse le déclencheur en se dirigeant ailleurs ; closeDelay laisse au pointeur le temps de franchir l’espace entre le déclencheur et la carte. Les deux sont exprimés en millisecondes. Le pointeur et les délais sont suivis nativement : rester sur un déclencheur ou se déplacer entre lui et la carte n’exécute aucun de vos codes tant que la carte ne s’ouvre ni ne se ferme. Un openDelay de Infinity laisse l’ouverture à l’application (et à un appui sur un écran tactile) ; un closeDelay de Infinity laisse la fermeture à Échap, à un appui à l’extérieur et à l’application.

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

#État d’ouverture

Sans open, la carte gère son propre état, à partir de defaultOpen. Avec open, l’application en est responsable : modifiez-le dans onOpenChange({ open, reason }), qui signale chaque demande d’ouverture ou de fermeture avec sa raison : "hover" (le pointeur), "focus" (le focus clavier), "triggerPress" (un appui sur un écran tactile), "pressOutside" ou "cancel" (Échap ou retour).

const open = signal(false);

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

#Entrée et sortie

Une carte de survol apparaît et disparaît avec vos transitions, comme le fait un Popover. popupStartingClass est l’apparence de la fenêtre contextuelle dans sa première image, et les classes transition-* de popupClass la mènent vers sa propre apparence. popupEndingClass est son apparence une fois fermée : la carte reste, sans recevoir le pointeur, pendant l’exécution de ces transitions, puis disparaît, et onClose suit. Si l’on s’arrête de nouveau sur le déclencheur pendant qu’elle disparaît, elle est rétablie. Lorsque l’utilisateur demande moins d’animations, elle apparaît et disparaît instantanément.

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

Par défaut, elle se place sous le déclencheur (placement, align), de l’autre côté s’il n’y a pas de place, et reste dans la zone sûre. onPosition({ placement, bounds, available }) indique où elle a été placée.

#Propriétés

PropRustTypeDéfautDescription
open.open(..)booleanaucunOuverture, contrôlée par l’application.
defaultOpen.default_open(..)booleanfalseOuverte au départ, sans open.
openDelay.open_delay(..)number600Millisecondes pendant lesquelles le pointeur reste sur le déclencheur, ou le focus reste sur lui, avant l’ouverture ; Infinity : jamais d’ouverture automatique.
closeDelay.close_delay(..)number300Millisecondes pendant lesquelles le pointeur est éloigné du déclencheur et de la carte, le focus n’étant dans aucun des deux, avant la fermeture ; Infinity : jamais de fermeture automatique.
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"Le côté du déclencheur où elle se place.
align.align(..)"start" | "center" | "end""center"Son alignement sur le déclencheur le long de ce côté.
offset.offset(..)number0L’écart entre le déclencheur et la carte.
margin.margin(..)number0La distance qu’elle garde par rapport aux bords de la zone sûre.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>aucunL’apparence de la fenêtre contextuelle, la carte dans laquelle se trouve le contenu.
popupStartingClass, popupStartingStyle.popup_starting_class(..), .popup_starting_style(..)string, StyleProp<ViewStyle>aucunL’apparence de la fenêtre contextuelle dans sa première image, par-dessus la sienne : ses transitions partent de là.
popupEndingClass, popupEndingStyle.popup_ending_class(..), .popup_ending_style(..)string, StyleProp<ViewStyle>aucunL’apparence de la fenêtre contextuelle une fois fermée, par-dessus la sienne : elle reste pendant l’exécution de ses transitions.
trigger.trigger(..)JSX.ElementaucunCe qu’elle prévisualise ; elle est positionnée par rapport à lui.
children.child(..)JSX.ElementaucunLe contenu, créé à chaque ouverture.

#Événements

ÉvénementRustDescription
onOpenChange.on_open_change(|e| ..)Demande l’ouverture ou la fermeture, avec la raison : "hover", "focus", "triggerPress", "pressOutside" ou "cancel".
onOpen.on_open(|| ..)Elle est affichée.
onClose.on_close(|| ..)Elle a disparu, après sa sortie ; également lorsque l’application l’a fermée.
onPosition.on_position(|e| ..)La fenêtre contextuelle a été placée : placement (le côté où elle s’est placée), bounds (son cadre), available (l’espace disponible de ce côté).

#Accessibilité

Une carte de survol s’ajoute à son déclencheur : celui-ci conserve son rôle et son libellé, et ce qu’il ouvre ou fait lorsqu’on appuie dessus reste identique. Les utilisateurs du clavier obtiennent la carte lorsque le focus reste sur le déclencheur, et Échap la ferme. La carte ne prend pas le focus d’elle-même : Tab quitte donc le déclencheur comme auparavant. Les liens et les boutons de la carte reçoivent le focus comme partout ailleurs, et la carte reste ouverte tant que l’un d’eux l’a ; ce qui compte dans la carte doit aussi être accessible depuis la page vers laquelle mène le déclencheur.