Elementos
HoverCard
Una hover card muestra una vista previa de lo que señala su disparador sin salir de la página: el perfil detrás de una mención, la página detrás de un enlace, la cotización detrás de un símbolo bursátil. En escritorio y en los navegadores de escritorio se abre cuando el puntero ha permanecido sobre el disparador durante openDelay (600 ms), o cuando el foco del teclado llega al disparador. Permanece abierta mientras el puntero pasa del disparador a la tarjeta y viceversa, y mientras el foco del teclado está en el disparador o en un enlace o botón de la tarjeta, de modo que todo lo que contiene la tarjeta se puede usar tanto con el puntero como con el teclado. Se cierra closeDelay (300 ms) después de que el puntero haya salido de ambos y el foco no esté en ninguno de los dos. Escape y una pulsación fuera la cierran de inmediato. En teléfonos y tabletas, una pulsación sobre el disparador la abre y la cierra, y una pulsación fuera la cierra.
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 no dibuja nada por sí mismo: el disparador es tu elemento, y el popup y su contenido también son tuyos.
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>
);
}El disparador conserva su propio rol, sus pulsaciones y su aspecto: una vista con role="link" y su propio onPress sigue yendo adonde iría al pulsarla, y la tarjeta le añade una vista previa. El contenido se crea cada vez que se abre la tarjeta, por lo que puede cargar en ese momento lo que muestra. En teléfonos y tabletas, donde una pulsación abre la tarjeta, el disparador indica a los lectores de pantalla si su tarjeta está abierta. En una Toolbar el disparador puede ser uno de sus elementos; lo que muestra la tarjeta no lo es: sus botones y enlaces conservan sus propias paradas de Tab y el disabled de la barra de herramientas no las modifica.
#Retardos
openDelay evita que la tarjeta se abra de golpe cuando el puntero cruza el disparador de camino a otro sitio; closeDelay da tiempo al puntero para cruzar el espacio entre el disparador y la tarjeta. Ambos se expresan en milisegundos. El puntero y los retardos se gestionan de forma nativa, así que permanecer sobre un disparador y moverse entre él y la tarjeta no ejecutan nada de tu código hasta que la tarjeta se abre o se cierra. Un openDelay de Infinity deja la apertura en manos de la app (y de una pulsación en una pantalla táctil); un closeDelay de Infinity deja el cierre en manos de Escape, de una pulsación fuera y de la app.
<HoverCard openDelay={300} closeDelay={150} trigger={<Text>AAPL</Text>}>
<Quote symbol="AAPL" />
</HoverCard>
#Estado de apertura
Sin open, la tarjeta mantiene su propio estado, que empieza en defaultOpen. Con open, el estado pertenece a la app: cámbialo en onOpenChange({ open, reason }), que informa de cada solicitud de apertura o cierre junto con su motivo: "hover" (el puntero), "focus" (el foco del teclado), "triggerPress" (una pulsación en una pantalla táctil), "pressOutside" o "cancel" (Escape o atrás).
const open = signal(false);
<HoverCard open={open()} onOpenChange={(e) => open.set(e.open)} trigger={<Text>@ada</Text>}>
<Profile id="ada" />
</HoverCard>
#Entrada y salida
Una hover card aparece y desaparece con tus transiciones, como lo hace un Popover. popupStartingClass es el aspecto del popup en su primer fotograma, y las clases transition-* de popupClass lo llevan hasta su propio aspecto. popupEndingClass es su aspecto una vez cerrado: la tarjeta permanece, sin recibir el puntero, mientras se ejecutan esas transiciones, y luego desaparece, y a continuación llega onClose. Si el puntero vuelve a posarse sobre el disparador mientras desaparece, la tarjeta se recupera. Cuando el usuario pide menos movimiento, aparece y desaparece de inmediato.
<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>
Por defecto se coloca debajo del disparador (placement, align), en el lado contrario cuando no hay espacio, y se mantiene dentro del área segura. onPosition({ placement, bounds, available }) indica dónde se colocó.
#Props
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
open |
.open(..) |
boolean |
ninguno | Abierta, controlada por la app. |
defaultOpen |
.default_open(..) |
boolean |
false |
Abierta al inicio, sin open. |
openDelay |
.open_delay(..) |
number |
600 |
Milisegundos que el puntero permanece sobre el disparador, o que el foco permanece en él, antes de que se abra; Infinity: nunca por sí sola. |
closeDelay |
.close_delay(..) |
number |
300 |
Milisegundos que el puntero está lejos del disparador y de la tarjeta, con el foco en ninguno de los dos, antes de que se cierre; Infinity: nunca por sí sola. |
placement |
.placement(..) |
"top" | "bottom" | "left" | "right" |
"bottom" |
El lado del disparador en el que se coloca. |
align |
.align(..) |
"start" | "center" | "end" |
"center" |
Cómo se alinea con el disparador a lo largo de ese lado. |
offset |
.offset(..) |
number |
0 |
El espacio entre el disparador y la tarjeta. |
margin |
.margin(..) |
number |
0 |
La distancia que mantiene respecto a los bordes del área segura. |
popupClass, popupStyle |
.popup_class(..), .popup_style(..) |
string, StyleProp<ViewStyle> |
ninguno | El aspecto del popup, la tarjeta en la que está el contenido. |
popupStartingClass, popupStartingStyle |
.popup_starting_class(..), .popup_starting_style(..) |
string, StyleProp<ViewStyle> |
ninguno | El aspecto del popup en su primer fotograma, sobre el propio: sus transiciones parten de él. |
popupEndingClass, popupEndingStyle |
.popup_ending_class(..), .popup_ending_style(..) |
string, StyleProp<ViewStyle> |
ninguno | El aspecto del popup una vez cerrado, sobre el propio: permanece mientras se ejecutan sus transiciones. |
trigger |
.trigger(..) |
JSX.Element |
ninguno | Lo que previsualiza; la tarjeta se coloca con respecto a él. |
children |
.child(..) |
JSX.Element |
ninguno | El contenido, creado cada vez que se abre. |
#Eventos
| Evento | Rust | Descripción |
|---|---|---|
onOpenChange |
.on_open_change(|e| ..) |
Solicita abrirse o cerrarse, indicando el motivo: "hover", "focus", "triggerPress", "pressOutside" o "cancel". |
onOpen |
.on_open(|| ..) |
Se muestra. |
onClose |
.on_close(|| ..) |
Ha desaparecido, tras su salida; también cuando la app la cerró. |
onPosition |
.on_position(|e| ..) |
El popup se ha colocado: placement (el lado en el que se colocó), bounds (su marco), available (el espacio disponible en ese lado). |
#Accesibilidad
Una hover card añade comportamiento a su disparador: el disparador conserva su rol y su etiqueta, y lo que abre o hace al pulsarlo sigue igual. Los usuarios de teclado obtienen la tarjeta cuando el foco permanece en el disparador, y Escape la cierra. La tarjeta no toma el foco por sí misma, así que Tab sigue avanzando desde el disparador como antes. Los enlaces y botones de la tarjeta reciben el foco como en cualquier otro lugar, y la tarjeta permanece abierta mientras uno de ellos lo tiene; lo que importa en la tarjeta también debería poder alcanzarse desde la página a la que lleva el disparador.