Elements
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 { 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.")),
)
}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={(next) => open.set(next)} 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.
#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. |
trigger | .trigger(..) | JSX.Element | none | What opens it; it is placed against it. |
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, 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. |
#Accessibility
The trigger tells screen readers whether the popover is expanded. Escape and the back button close it, as users expect of a popup.