本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

HoverCard

HoverCard 可在不離開頁面的情況下預覽觸發元素所指向的內容:提及背後的個人檔案、連結背後的頁面、股票代號背後的報價。在桌面平台與桌面瀏覽器上,當指標在觸發元素上停留 openDelay(600 ms)後,或鍵盤焦點移至觸發元素時,它便會開啟。當指標從觸發元素移到卡片上再移回來,以及鍵盤焦點位於觸發元素或卡片中的連結或按鈕上時,它會保持開啟,因此卡片中的所有內容都能同時以指標和鍵盤操作。當指標離開兩者且焦點不在任何一者之中 closeDelay(300 ms)後,它便會關閉。按下 Escape 或在卡片外按壓會立即關閉。在手機與平板上,按壓觸發元素可開啟與關閉,在卡片外按壓則會關閉。

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

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

觸發元素保有自己的角色、按壓行為與外觀:帶有 role="link" 及自身 onPress 的 view 被按下時仍會前往原本的目的地,而卡片只是為它加上預覽。內容會在每次卡片開啟時產生,因此可以在那時才載入要顯示的資料。在手機與平板上,按壓會開啟卡片,此時觸發元素會告知螢幕閱讀器其卡片是否已開啟。在 Toolbar 中,觸發元素可以是其中一個項目;但卡片所顯示的內容不是:其中的按鈕與連結保有各自的 Tab 停駐點,而工具列的 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>

#進場與退場

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(..)booleanfalse未提供 open 時,初始是否開啟。
openDelay.open_delay(..)number600指標在觸發元素上停留,或焦點停留其上,直到開啟所需的毫秒數;Infinity:不會自行開啟。
closeDelay.close_delay(..)number300指標離開觸發元素與卡片,且焦點不在任一者之中,直到關閉所需的毫秒數;Infinity:不會自行關閉。
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"它出現在觸發元素的哪一側。
align.align(..)"start" | "center" | "end""center"它沿該側與觸發元素的對齊方式。
offset.offset(..)number0觸發元素與卡片之間的間距。
margin.margin(..)number0它與安全區域邊緣保持的距離。
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(該側的可用空間)。

#無障礙

HoverCard 是附加在觸發元素上的功能:觸發元素保有自己的角色與標籤,按壓時所開啟或執行的內容也維持不變。當焦點停留在觸發元素上時,鍵盤使用者會看到卡片,按下 Escape 即可關閉。卡片本身不會取得焦點,因此 Tab 仍會像以前一樣從觸發元素移往下一個元素。卡片中的連結與按鈕會如同其他地方一樣取得焦點,且當其中之一擁有焦點時,卡片會保持開啟;卡片中重要的內容,也應能從觸發元素所通往的頁面中取得。