요소
Popover
A popover shows content next to its trigger, on top of everything else: details, a small form, a color picker. Pressing the trigger opens and closes it; a press outside, Escape, the Android back button and VoiceOver’s escape gesture close it. It is placed on a side of the trigger, moves to the other side when there is no room, stays inside the safe area and follows the trigger as it scrolls. It draws nothing of its own: the popup and its content are the app’s.
import { mount, signal, Text, View } from "nsh";
import { Popover } from "nsh/base";
import { button, buttonText, desktop, Inspector, Log, PopIn, Segmented, 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}
onOpenChange={({ open, reason }) => events.set([`${open ? "opened" : "closed"} · ${reason}`, ...events()].slice(0, 4))}
trigger={
<View {...button("accent")}>
<Text style={buttonText("accent")}>Order #1024</Text>
</View>
}
>
{/* The panel grows out of the trigger's side each time it opens. */}
<PopIn side={placement()} class="w-56 gap-2 rounded-xl p-4" style={ui().card}>
<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>
</PopIn>
</Popover>
);
const count = () => (
<View
onPress={() => presses.update((n) => n + 1)}
{...button("secondary", { align: "center" })}
>
<Text style={buttonText()}>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::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.")),
)
}import { Text, View } from "nsh";
import { Popover } from "nsh/base";
function Details() {
return (
<Popover
offset={6}
margin={8}
popupClass="w-60 gap-1 rounded-lg bg-white p-3 shadow-lg"
trigger={
<View class="rounded-md bg-gray-200 px-3 py-2">
<Text>Details</Text>
</View>
}
>
<Text class="font-semibold">Order #1024</Text>
<Text>Shipped yesterday, arriving Friday.</Text>
</Popover>
);
}Popover comes from nsh/base. Its content is made each time it opens, so state inside it
starts fresh.
#Open state
Without open, the popover keeps its own state, starting from defaultOpen. With open, the app owns it: change it
in onOpenChange({ open, reason }), which reports every request to open or close with its reason ("triggerPress",
"pressOutside", "cancel").
const open = signal(false);
<Popover open={open()} onOpenChange={(e) => open.set(e.open)} trigger={<Text>Filters</Text>}>
<FilterForm onApply={() => open.set(false)} />
</Popover>
closeOnEscape={false} keeps it open on Escape and back (onCancel still hears them, to ask first);
closeOnPressOutside={false} keeps it open on presses outside.
#Phones and desktops
A popover is anchored to its trigger on every device: what it shows belongs next to what opened it.
- On the desktops and in desktop browsers, a press outside closes it and goes on to what is under the pointer, so one click both closes the popover and presses the button it landed on.
- On phones and tablets, a press outside only closes it: a transparent backdrop takes the press, as the system’s popovers do.
For a list of actions use a Menu, which becomes a sheet on phones; for a panel use a Sheet.
#Entrance and exit
A popover comes and goes with your transitions. popupStartingClass is the popup’s look in its first frame, and the
transition-* classes of popupClass carry it to its own look: its entrance. popupEndingClass is its look once
closed: the popover stays, taking no presses, while those transitions run, then goes, and onClose follows. Opened
again on its way out, it returns to its look from where it is. popupStartingStyle and popupEndingStyle do the same
with inline styles. When the user asks for less motion, it appears and goes at once.
<Popover
popupClass="rounded-lg bg-white p-3 shadow-lg transition-all duration-150 ease-out"
popupStartingClass="opacity-0 scale-95"
popupEndingClass="opacity-0 scale-95"
onPosition={({ placement }) => side.set(placement)}
trigger={<Text>Details</Text>}
>
<OrderSummary />
</Popover>
onPosition({ placement, bounds, available }) says where the popup went: the side of the trigger (the other one when
placement had no room), its frame and the room on that side, so an arrow or an entrance can point at the trigger.
#Props
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
open | .open(..) | boolean | none | Open, controlled by the app. |
defaultOpen | .default_open(..) | boolean | false | Open at first, without open. |
placement | .placement(..) | "top" | "bottom" | "left" | "right" | "bottom" | The side of the trigger it goes on. |
align | .align(..) | "start" | "center" | "end" | "center" | How it lines up with the trigger along that side. |
offset | .offset(..) | number | 0 | The gap between the trigger and the popup. |
margin | .margin(..) | number | 0 | The distance it keeps from the edges of the safe area. |
closeOnEscape | .close_on_escape(..) | boolean | true | Escape and the Android back button close it. |
closeOnPressOutside | .close_on_press_outside(..) | boolean | true | A press outside closes it. |
popupClass, popupStyle | .popup_class(..), .popup_style(..) | string, StyleProp<ViewStyle> | none | The look of the popup, the box the content is in. |
popupStartingClass, popupStartingStyle | .popup_starting_class(..), .popup_starting_style(..) | string, StyleProp<ViewStyle> | none | The popup’s look in its first frame, over its own: its transitions run from it. |
popupEndingClass, popupEndingStyle | .popup_ending_class(..), .popup_ending_style(..) | string, StyleProp<ViewStyle> | none | The popup’s look once closed, over its own: it stays while its transitions run. |
trigger | .trigger(..) | JSX.Element | none | What opens it; it is placed against it. While the popover is open its aria-expanded: classes and expandedStyle apply. |
children | .child(..) | JSX.Element | none | The content. |
#Events
| Event | Rust | Description |
|---|---|---|
onOpenChange | .on_open_change(|e| ..) | It opened or closed, with why: "triggerPress", "pressOutside" or "cancel". |
onOpen | .on_open(|| ..) | It is shown. |
onClose | .on_close(|| ..) | It is gone, after its exit; also when the app closed it. |
onCancel | .on_cancel(|| ..) | The user tried to close it (Escape or back, a press outside), whether or not it closed. |
onPosition | .on_position(|e| ..) | The popup was placed: placement (the side it went on), bounds (its frame), available (the room on that side). |
#Accessibility
The trigger tells screen readers whether the popover is expanded. Escape and the back button close it, as users expect
of a popup. In TSX the trigger is the element you pass, a Button or a view of your own: it takes the presses and the
expanded state itself, so its own active: classes and activeStyle show while it is pressed, and its own onPress
runs too.