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.

el-popover.tsx
el-popover.tsxLive
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 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

PropRustTypeDefaultDescription
open.open(..)booleannoneOpen, controlled by the app.
defaultOpen.default_open(..)booleanfalseOpen 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(..)number0The gap between the trigger and the popup.
margin.margin(..)number0The distance it keeps from the edges of the safe area.
closeOnEscape.close_on_escape(..)booleantrueEscape and the Android back button close it.
closeOnPressOutside.close_on_press_outside(..)booleantrueA press outside closes it.
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>noneThe look of the popup, the box the content is in.
trigger.trigger(..)JSX.ElementnoneWhat opens it; it is placed against it.
children.child(..)JSX.ElementnoneThe content.

#Events

EventRustDescription
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.