Elementi
HoverCard
Una hover card mostra l’anteprima di ciò a cui punta il suo trigger senza uscire dalla pagina: il profilo dietro una menzione, la pagina dietro un link, la quotazione dietro un ticker. Sui desktop e nei browser desktop si apre quando il puntatore si è fermato
sul trigger per openDelay (600 ms), oppure quando il focus da tastiera arriva sul trigger. Resta aperta mentre il
puntatore si sposta dal trigger alla card e viceversa, e finché il focus da tastiera è sul trigger o su un link o
un pulsante nella card, così tutto ciò che si trova nella card è utilizzabile sia con il puntatore sia con la tastiera. Si chiude
closeDelay (300 ms) dopo che il puntatore ha lasciato entrambi e il focus non è in nessuno dei due. Escape e una pressione all’esterno la chiudono
subito. Su smartphone e
tablet una pressione sul trigger la apre e la chiude, e una pressione all’esterno la chiude.
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 di nsh/base non disegna nulla di suo: il trigger è il tuo elemento, il
popup e il suo contenuto sono tuoi.
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."))
}import { Text } from "nsh";
import { HoverCard } from "nsh/base";
function Mention() {
return (
<HoverCard
offset={8}
popupClass="w-72 gap-2 rounded-xl border border-border bg-popover p-4 shadow-lg"
trigger={<Text class="text-blue-600">@ada</Text>}
>
<Text class="font-semibold">Ada Lovelace</Text>
<Text>Writes the first programs for the Analytical Engine.</Text>
</HoverCard>
);
}Il trigger mantiene il proprio ruolo, le proprie pressioni e il proprio aspetto: una view con role="link" e un proprio onPress va comunque dove andrebbe
quando viene premuta, e la card le aggiunge un’anteprima. Il contenuto viene creato ogni volta che la card si apre, quindi può caricare ciò che mostra in quel momento. Su
smartphone e tablet, dove una pressione apre la card, il trigger indica agli screen reader se la sua card è aperta. In una
Toolbar il trigger può essere uno dei suoi elementi; ciò che la card mostra no: i suoi pulsanti e link mantengono
i propri Tab stop e il disabled della toolbar li lascia invariati.
#Ritardi
openDelay evita che la card compaia di colpo mentre il puntatore attraversa il trigger diretto altrove; closeDelay
dà al puntatore il tempo di attraversare lo spazio tra il trigger e la card. Entrambi sono in millisecondi. Il puntatore e i
ritardi sono seguiti in modo nativo, quindi fermarsi su un trigger e spostarsi tra questo e la card non esegue alcun codice tuo finché
la card non si apre o non si chiude. Un openDelay pari a Infinity lascia l’apertura all’app (e a una pressione su un touch screen); un
closeDelay pari a Infinity lascia la chiusura a Escape, a una pressione all’esterno e all’app.
<HoverCard openDelay={300} closeDelay={150} trigger={<Text>AAPL</Text>}>
<Quote symbol="AAPL" />
</HoverCard>
#Stato di apertura
Senza open, la card mantiene un proprio stato, che parte da defaultOpen. Con open, lo stato appartiene all’app: modificalo in
onOpenChange({ open, reason }), che segnala ogni richiesta di apertura o chiusura con il suo motivo: "hover" (il
puntatore), "focus" (il focus da tastiera), "triggerPress" (una pressione su un touch screen), "pressOutside" o
"cancel" (Escape o indietro).
const open = signal(false);
<HoverCard open={open()} onOpenChange={(e) => open.set(e.open)} trigger={<Text>@ada</Text>}>
<Profile id="ada" />
</HoverCard>
#Ingresso e uscita
Una hover card compare e scompare con le tue transizioni, come fa un Popover. popupStartingClass è
l’aspetto del popup nel suo primo frame, e le classi transition-* di popupClass lo portano al suo aspetto effettivo.
popupEndingClass è il suo aspetto una volta chiuso: la card resta, senza ricevere input dal puntatore, mentre quelle transizioni sono in corso, poi se ne va,
e onClose segue. Fermarsi di nuovo sul trigger mentre sta scomparendo la riporta indietro. Quando l’utente chiede meno animazioni,
appare e scompare subito.
<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>
Per impostazione predefinita si posiziona sotto il trigger (placement, align), dall’altro lato quando non c’è spazio, e resta
all’interno della safe area. onPosition({ placement, bounds, available }) indica dove è stata posizionata.
#Props
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
open | .open(..) | boolean | nessuno | Aperta, controllata dall’app. |
defaultOpen | .default_open(..) | boolean | false | Aperta all’inizio, senza open. |
openDelay | .open_delay(..) | number | 600 | Millisecondi in cui il puntatore resta sul trigger, o il focus resta su di esso, prima che si apra; Infinity: mai da sola. |
closeDelay | .close_delay(..) | number | 300 | Millisecondi in cui il puntatore è lontano dal trigger e dalla card, con il focus in nessuno dei due, prima che si chiuda; Infinity: mai da sola. |
placement | .placement(..) | "top" | "bottom" | "left" | "right" | "bottom" | Il lato del trigger su cui si posiziona. |
align | .align(..) | "start" | "center" | "end" | "center" | Come si allinea al trigger lungo quel lato. |
offset | .offset(..) | number | 0 | Lo spazio tra il trigger e la card. |
margin | .margin(..) | number | 0 | La distanza che mantiene dai bordi della safe area. |
popupClass, popupStyle | .popup_class(..), .popup_style(..) | string, StyleProp<ViewStyle> | nessuno | L’aspetto del popup, la card in cui si trova il contenuto. |
popupStartingClass, popupStartingStyle | .popup_starting_class(..), .popup_starting_style(..) | string, StyleProp<ViewStyle> | nessuno | L’aspetto del popup nel suo primo frame, sovrapposto al proprio: le sue transizioni partono da qui. |
popupEndingClass, popupEndingStyle | .popup_ending_class(..), .popup_ending_style(..) | string, StyleProp<ViewStyle> | nessuno | L’aspetto del popup una volta chiuso, sovrapposto al proprio: resta finché le sue transizioni sono in corso. |
trigger | .trigger(..) | JSX.Element | nessuno | Ciò di cui mostra l’anteprima; il popup viene posizionato rispetto a esso. |
children | .child(..) | JSX.Element | nessuno | Il contenuto, creato ogni volta che si apre. |
#Eventi
| Evento | Rust | Descrizione |
|---|---|---|
onOpenChange | .on_open_change(|e| ..) | Richiede di aprirsi o chiudersi, con il motivo: "hover", "focus", "triggerPress", "pressOutside" o "cancel". |
onOpen | .on_open(|| ..) | Viene mostrata. |
onClose | .on_close(|| ..) | Scompare, dopo la sua uscita; anche quando è stata chiusa dall’app. |
onPosition | .on_position(|e| ..) | Il popup è stato posizionato: placement (il lato su cui si è posizionato), bounds (il suo frame), available (lo spazio disponibile su quel lato). |
#Accessibilità
Una hover card aggiunge qualcosa al proprio trigger: il trigger mantiene il proprio ruolo e la propria etichetta, e ciò che apre o fa quando viene premuto resta invariato. Gli utenti da tastiera ottengono la card quando il focus si ferma sul trigger, ed Escape la chiude. La card non prende il focus per sé, quindi Tab prosegue dal trigger come prima. I link e i pulsanti nella card ricevono il focus come altrove, e la card resta aperta finché uno di essi lo possiede; ciò che è importante nella card dovrebbe essere raggiungibile anche dalla pagina a cui porta il trigger.