元素

Popover

弹出框把内容显示在触发元素旁边、其他一切之上:详情、一个小表单、一个取色器。按下触发元素会打开和关闭它;外部按压、Escape、Android 返回键和 VoiceOver 的退出手势会关闭它。它放在触发元素的某一侧,空间不够时换到另一侧,始终位于安全区之内,并在触发元素滚动时跟随它。它自己不绘制任何东西:弹出层和其中的内容都属于应用。

el-popover.tsx
el-popover.tsx运行中
import { mount, signal, Text, View } from "nsh";
import { Popover } from "nsh/base";
import { desktop, Inspector, Log, Segmented, size, Stage, ui, wide } from "./palette";

type Placement = "top" | "bottom" | "left" | "right";

function App() {
  const placement = signal<Placement>("bottom");
  const offset = signal(8);
  const events = signal<string[]>([]);
  const presses = signal(0);
  const placements = () => (
    <Segmented label="placement" options={["top", "bottom", "left", "right"] as Placement[]} value={placement()} onChange={(v) => placement.set(v)} />
  );
  const offsets = () => <Segmented label="offset" options={[0, 8, 16]} value={offset()} onChange={(v) => offset.set(v)} />;
  const popover = () => (
    <Popover
      placement={placement()}
      offset={offset()}
      margin={12}
      popupClass="w-56 gap-2 rounded-xl p-4"
      popupStyle={ui().card}
      onOpenChange={(open, reason) => events.set([`${open ? "opened" : "closed"} · ${reason}`, ...events()].slice(0, 4))}
      trigger={
        <View
          activeStyle={{ opacity: 0.7 }}
          style={{ ...ui().accentFill, borderRadius: desktop ? 8 : 10, paddingHorizontal: desktop ? 14 : 18, height: desktop ? size.control : 40, justifyContent: "center" }}
        >
          <Text style={{ fontSize: desktop ? 14 : 15, fontWeight: "600", color: ui().onAccent }}>Order #1024</Text>
        </View>
      }
    >
      <Text style={{ fontSize: 15, fontWeight: "600", color: ui().text }}>Shipped yesterday</Text>
      <Text style={{ fontSize: 13, color: ui().muted }}>Arriving Friday. The trigger, a press outside and Escape close this.</Text>
      <View style={{ flexDirection: "row", gap: 6, alignItems: "center" }}>
        <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: ui().up }} />
        <Text style={{ fontSize: 12, color: ui().up }}>In transit</Text>
      </View>
    </Popover>
  );
  const count = () => (
    <View
      onPress={() => presses.update((n) => n + 1)}
      activeStyle={{ opacity: 0.7 }}
      style={
        wide()
          ? { ...ui().raised, alignSelf: "center", borderRadius: 8, height: size.control, paddingHorizontal: 12, justifyContent: "center" }
          : { ...ui().raised, borderRadius: 10, height: 40, alignItems: "center", justifyContent: "center" }
      }
    >
      <Text style={{ fontSize: desktop ? 13 : 14, fontWeight: "500", color: ui().text }}>Count · pressed {presses()}×</Text>
    </View>
  );
  const log = () => <Log title="onOpenChange(open, reason)" lines={events()} minHeight={92} />;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>popover</Text>
      <Text style={{ color: ui().muted }}>
        {desktop
          ? "Open it, then click “Count” below: one click closes it and presses the button"
          : "Open it, then tap “Count” below: the tap only closes it"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            <Stage gap={24}>
              <View style={{ height: 280, alignItems: "center", justifyContent: "center" }}>{popover()}</View>
              {count()}
            </Stage>
            <Inspector>
              {placements()}
              {offsets()}
              {log()}
            </Inspector>
          </View>
        ) : (
          <>
            <View style={{ flexDirection: "row", gap: 12 }}>
              <View style={{ flex: 3 }}>{placements()}</View>
              <View style={{ flex: 2 }}>{offsets()}</View>
            </View>
            <View style={{ flex: 1, minHeight: 200, alignItems: "center", justifyContent: "center" }}>{popover()}</View>
            {count()}
            {log()}
          </>
        )
      }
    </View>
  );
}

mount(App);
use nsh_base::{OpenChangeReason, PopoverEvent, popover};
use nsh_core::ui::prelude::*;

fn details() -> impl IntoElement {
    popover()
        .offset(px(6.0))
        .margin(px(8.0))
        .trigger(view().px_3().py_2().rounded_md().bg(rgb(0xe5e7eb)).child(text("Details")))
        .on_open_change(|e: &PopoverEvent| {
            if let PopoverEvent::OpenChange(open, OpenChangeReason::PressOutside) = e {
                log::info!("closed by a press outside: {open}");
            }
        })
        .child(
            view()
                .w(px(240.0))
                .gap_1()
                .p_3()
                .rounded_lg()
                .bg(white())
                .shadow_lg()
                .child(text("Order #1024").font_weight(FontWeight::SEMIBOLD))
                .child(text("Shipped yesterday, arriving Friday.")),
        )
}

Popover 来自 nsh/base。它的内容在每次打开时创建,所以其中的状态每次都从头开始。

#打开状态

没有 open 时,弹出框自己保存状态,初始值为 defaultOpen。有 open 时,状态由应用持有:在 onOpenChange(open, reason) 中修改它,每次打开或关闭的请求都会带着原因("triggerPress"、"pressOutside"、"cancel")报告给它。

const open = signal(false);

<Popover open={open()} onOpenChange={(next) => open.set(next)} trigger={<Text>Filters</Text>}>
  <FilterForm onApply={() => open.set(false)} />
</Popover>

closeOnEscape={false} 让它在 Escape 和返回键时保持打开(onCancel 仍会收到它们,可以先询问用户);closeOnPressOutside={false} 让它在外部按压时保持打开。

#手机与桌面

在每种设备上,弹出框都锚定在触发元素上:它显示的内容属于打开它的那个元素旁边。

  • 在桌面平台和桌面浏览器中,外部按压会关闭它,并继续传给指针下方的内容,所以一次单击既关闭弹出框,又按下了落点处的按钮。
  • 在手机和平板上,外部按压只会关闭它:一个透明的背景层接住这次按压,与系统的弹出框一致。

一组操作请使用 Menu,它在手机上会变成 sheet;面板请使用 Sheet。

#属性

属性Rust类型默认值说明
open.open(..)boolean无是否打开,由应用控制。
defaultOpen.default_open(..)booleanfalse没有 open 时,初始是否打开。
placement.placement(..)"top" | "bottom" | "left" | "right""bottom"它位于触发元素的哪一侧。
align.align(..)"start" | "center" | "end""center"它沿这一侧如何与触发元素对齐。
offset.offset(..)number0触发元素与弹出层之间的间距。
margin.margin(..)number0它与安全区边缘保持的距离。
closeOnEscape.close_on_escape(..)booleantrueEscape 和 Android 返回键会关闭它。
closeOnPressOutside.close_on_press_outside(..)booleantrue外部按压会关闭它。
popupClass、popupStylepopup_class、popup_stylestring、StyleProp<ViewStyle>无弹出层(装着内容的盒子)的外观。
trigger.trigger(..)JSX.Element无打开它的元素;它相对这个元素放置。
children.child(..)JSX.Element无内容。

#事件

事件Rust说明
onOpenChange.on_open_change(|e| ..)它打开或关闭了,附带原因:"triggerPress"、"pressOutside" 或 "cancel"。
onOpen.on_open(|| ..)它已显示。
onClose.on_close(|| ..)它已消失,应用关闭它时也会触发。
onCancel.on_cancel(|| ..)用户尝试关闭它(Escape 或返回键、外部按压),无论它是否关闭。

#无障碍

触发元素会告诉屏幕阅读器弹出框是否已展开。Escape 和返回键会关闭它,与用户对弹出层的预期一致。