Elements
Sheet
A sheet is a panel at an edge of the screen over a backdrop that covers the rest: filters, a share panel, a side drawer. A press on the backdrop, Escape and the Android back button close it. It shows on top of everything in its window, the same on every device, and draws nothing of its own: the panel and the backdrop are the app’s.
import { mount, signal, Text, View } from "nsh";
import { Sheet } from "nsh/base";
import { desktop, Inspector, Log, Panel, size, Stage, ui, wide } from "./palette";
type Side = "bottom" | "top" | "left" | "right";
/** The panel's corners and size across, by the edge it is on. */
function panel(side: Side) {
const r = 20;
const across = side === "left" || side === "right" ? { width: wide() ? 320 : 280 } : {};
const corners = {
bottom: { borderTopLeftRadius: r, borderTopRightRadius: r, paddingBottom: 32 },
top: { borderBottomLeftRadius: r, borderBottomRightRadius: r, paddingTop: 32 },
left: { borderTopRightRadius: r, borderBottomRightRadius: r },
right: { borderTopLeftRadius: r, borderBottomLeftRadius: r },
}[side];
return { ...ui().card, padding: 20, gap: 12, ...across, ...corners };
}
function App() {
const open = signal(false);
const side = signal<Side>("bottom");
const events = signal<string[]>([]);
const log = (e: string) => events.set([e, ...events()].slice(0, 6));
const show = (s: Side) => {
side.set(s);
open.set(true);
log(`open · side ${s}`);
};
/** A button that opens the sheet at side `s`. */
const opener = (s: Side) => (
<View
onPress={() => show(s)}
activeStyle={{ opacity: 0.7 }}
style={{
...(s === side() ? ui().accentFill : ui().raised),
height: wide() ? size.control : 42,
paddingHorizontal: wide() ? 12 : 0,
borderRadius: wide() ? 8 : 10,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ fontSize: wide() ? 13 : 14, fontWeight: "600", color: s === side() ? ui().onAccent : ui().text }}>side="{s}"</Text>
</View>
);
const changes = () => <Log title="onOpenChange(open, reason) · onCancel" lines={events()} minHeight={130} />;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>sheet</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Open a side; click the backdrop or press Escape to close it" : "Open a side; tap the backdrop or Android's back to close it"}
</Text>
{() =>
wide() ? (
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
{/* Each button at the edge its sheet comes from. */}
<Stage width={360} gap={12}>
<View style={{ alignItems: "center" }}>{opener("top")}</View>
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
{opener("left")}
{opener("right")}
</View>
<View style={{ alignItems: "center" }}>{opener("bottom")}</View>
</Stage>
<Inspector>
<Panel title="open · side">
<Text style={{ fontSize: 13, color: ui().text }}>
{open() ? "open" : "closed"} · {side()}
</Text>
</Panel>
{changes()}
</Inspector>
</View>
) : (
<>
<View style={{ flex: 1, minHeight: 200, alignItems: "center", justifyContent: "center" }}>
<View style={{ width: 240, gap: 8 }}>
<Each of={["top", "left", "right", "bottom"] as Side[]}>{(s) => opener(s())}</Each>
</View>
</View>
{changes()}
</>
)
}
<Sheet
open={open()}
side={side()}
onOpenChange={(next, reason) => {
open.set(next);
log(`onOpenChange · ${next ? "opened" : "closed"} · ${reason}`);
}}
onCancel={() => log("onCancel")}
popupStyle={panel(side())}
backdropStyle={{ backgroundColor: "rgba(0,0,0,0.45)" }}
>
<Text style={{ fontSize: 17, fontWeight: "700", color: ui().text }}>Filters</Text>
<Text style={{ fontSize: 14, color: ui().muted }}>
A panel at the {side()} edge. {desktop ? "The backdrop and Escape close it." : "The backdrop and Android's back close it."}
</Text>
<View
onPress={() => {
open.set(false);
log("Done pressed");
}}
activeStyle={{ opacity: 0.7 }}
style={
wide()
? { ...ui().primary, alignSelf: "flex-end", height: size.control, paddingHorizontal: 14, borderRadius: 8, justifyContent: "center" }
: { ...ui().primary, height: 42, borderRadius: 10, alignItems: "center", justifyContent: "center" }
}
>
<Text style={{ fontSize: wide() ? 13 : 14, fontWeight: "600", color: ui().onPrimary }}>Done</Text>
</View>
</Sheet>
</View>
);
}
mount(App);use nsh_base::{SheetEvent, sheet};
use nsh_core::ui::prelude::*;
fn filters(open: Signal<bool>) -> impl IntoElement {
sheet()
.open(move || Some(open.get()))
.on_open_change(move |e: &SheetEvent| {
if let SheetEvent::OpenChange(next, _) = e {
open.set(*next);
}
})
.child(
view()
.gap_3()
.p_6()
.rounded_t(px(16.0))
.bg(white())
.child(text("Filters").font_weight(FontWeight::SEMIBOLD))
.child(view().on_press(move || open.set(false)).child(text("Done"))),
)
}import { signal, Text, View } from "nsh";
import { Sheet } from "nsh/base";
function Filters() {
const open = signal(false);
return (
<>
<View onPress={() => open.set(true)}><Text>Filters</Text></View>
<Sheet
open={open()}
onOpenChange={(next) => open.set(next)}
popupClass="gap-3 rounded-t-2xl bg-white p-6"
backdropClass="bg-black/40"
>
<Text class="font-semibold">Filters</Text>
<View onPress={() => open.set(false)}><Text>Done</Text></View>
</Sheet>
</>
);
}Sheet comes from nsh/base. Its content is made each time it opens. side puts it at
the "bottom" (the default) or "top" of the screen, as wide as the screen, or at the "left" or "right", as tall
as the screen; its size across is the panel’s, from its content or popupClass.
#Open state
Without open, the sheet keeps its own state, starting from defaultOpen. With open, the app owns it: change it in
onOpenChange(open, reason), which reports each request to close with its reason ("backdropPress", "cancel").
onOpen and onClose follow it in and out, whoever opened or closed it.
closeOnEscape={false} keeps it open on Escape and back, closeOnBackdropPress={false} on presses on the backdrop;
onCancel still hears them, to ask first.
#Native sheets
A sheet is drawn by NSH in the app’s window, styled by the app. For the platform’s own presented sheet (a page sheet
on iOS), use a Dialog with presentation="pageSheet".
#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. |
side | .side(..) | "top" | "bottom" | "left" | "right" | "bottom" | The edge it is on. |
closeOnEscape | .close_on_escape(..) | boolean | true | Escape and the Android back button close it. |
closeOnBackdropPress | .close_on_backdrop_press(..) | boolean | true | A press on the backdrop closes it. |
popupClass, popupStyle | .popup_class(..), .popup_style(..) | string, StyleProp<ViewStyle> | none | The look of the panel. |
backdropClass, backdropStyle | .backdrop_class(..), .backdrop_style(..) | string, StyleProp<ViewStyle> | none | The look of the backdrop, such as a translucent color. |
children | .child(..) | JSX.Element | none | The content. |
#Events
| Event | Rust | Description |
|---|---|---|
onOpenChange | .on_open_change(|e| ..) | It closed, with why: "backdropPress" or "cancel". |
onOpen | .on_open(|| ..) | It came in: it is all there. |
onClose | .on_close(|| ..) | It went: it is all gone. Also when the app closed it. |
onCancel | .on_cancel(|| ..) | The user tried to close it (Escape or back, a press on the backdrop), whether or not it closed. |