要素
HoverCard
ホバーカードは、ページを離れることなく、トリガーが指し示す先をプレビューします。メンションの先にあるプロフィール、リンクの先にあるページ、ティッカーの先にある相場などが例です。デスクトップおよびデスクトップブラウザでは、ポインターがトリガー上に openDelay(600 ms)の間とどまったとき、またはキーボードフォーカスがトリガーに移ったときに開きます。ポインターがトリガーからカードへ、またカードからトリガーへ移動している間、およびキーボードフォーカスがトリガー、またはカード内のリンクやボタンにある間は開いたままになるため、カード内のすべてをポインターでもキーボードでも操作できます。ポインターが両方から離れ、フォーカスもどちらにもない状態が closeDelay(300 ms)続くと閉じます。Escape とカード外の押下は、ただちに閉じます。スマートフォンとタブレットでは、トリガーを押すと開閉し、カード外を押すと閉じます。
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);nsh/base の HoverCard は、それ自体では何も描画しません。トリガーはあなたの要素であり、ポップアップとその内容もあなたのものです。
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>
);
}トリガーは、それ自体のロール、押下の動作、見た目をそのまま保ちます。role="link" と独自の onPress を持つビューは、押されたときに従来どおりの遷移先へ移動し、カードはそこにプレビューを追加するだけです。内容はカードが開くたびに作られるため、表示するものをそのときに読み込めます。押下でカードが開くスマートフォンとタブレットでは、トリガーがカードの開閉状態をスクリーンリーダーに伝えます。Toolbar では、トリガーをそのアイテムの 1 つにできます。ただしカードの中身はアイテムではありません。カード内のボタンやリンクはそれぞれ独自の Tab ストップを保ち、Toolbar の disabled はそれらに手を加えません。
#遅延
openDelay は、ポインターが別の場所へ向かう途中でトリガーを横切ったときに、カードが一瞬だけ開いてしまうのを防ぎます。closeDelay は、ポインターがトリガーとカードの間のすき間を渡るための猶予を与えます。どちらもミリ秒単位です。ポインターと遅延はネイティブ側で追跡されるため、トリガー上でとどまる操作や、トリガーとカードの間の移動では、カードが開閉するまであなたのコードは実行されません。openDelay を Infinity にすると、開く操作はアプリに委ねられます(タッチスクリーンでの押下も同様です)。closeDelay を Infinity にすると、閉じる操作は 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>
#開始と終了のアニメーション
ホバーカードは、Popover と同様に、トランジションとともに現れ、消えます。popupStartingClass は最初のフレームでのポップアップの見た目であり、popupClass の transition-* クラスがそこから本来の見た目へ移行させます。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(..) | boolean | false | open を使わない場合の、最初の開閉状態。 |
openDelay | .open_delay(..) | number | 600 | カードが開くまでに、ポインターがトリガー上にとどまる、またはフォーカスがトリガーにある時間(ミリ秒)。Infinity: 自動では開きません。 |
closeDelay | .close_delay(..) | number | 300 | カードが閉じるまでに、ポインターがトリガーとカードの両方から離れ、フォーカスもどちらにもない時間(ミリ秒)。Infinity: 自動では閉じません。 |
placement | .placement(..) | "top" | "bottom" | "left" | "right" | "bottom" | カードを表示するトリガーの辺。 |
align | .align(..) | "start" | "center" | "end" | "center" | その辺に沿ったトリガーとの揃え方。 |
offset | .offset(..) | number | 0 | トリガーとカードの間隔。 |
margin | .margin(..) | number | 0 | セーフエリアの端から保つ距離。 |
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 はこれまでどおりトリガーから先へ進みます。カード内のリンクとボタンは、他の場所と同様にフォーカスを受け取り、そのいずれかがフォーカスを持っている間、カードは開いたままです。カード内の重要な情報は、トリガーの遷移先のページからも到達できるようにしてください。