Éléments
Dialog
Un dialog présente du contenu par-dessus le reste de l’app dans sa propre couche de fenêtre native : un view controller présenté sur iOS et Mac Catalyst, un Dialog sur Android, une couche au-dessus de toute la fenêtre sur les desktops et le web. Il sert aux parcours plein écran, aux sheets et aux boîtes de dialogue. Les gestes propres au système (glisser une sheet vers le bas, le bouton retour d’Android, Escape) fonctionnent et sont gérés nativement. En Rust, on en crée un avec nsh_base::dialog(), en TSX avec <Dialog> depuis nsh/base.
import { mount, signal, Text, View } from "nsh";
import { Button, Dialog, Switch } from "nsh/base";
import { button, buttonText, desktop, Inspector, Panel, Stage, tint, ui, wide } from "./palette";
const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors motion-reduce:transition-none`;
const thumb = desktop ? "size-4 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-4" : "size-5.5 rounded-full transition-transform motion-reduce:transition-none checked:translate-x-5";
function App() {
const card = signal(false);
const sheet = signal(false);
const backdropCloses = signal(true);
const cancels = signal(0);
const log = signal("–");
const openCard = () => (
<Button onPress={() => card.set(true)} {...button("primary")}>
<Text style={buttonText("primary")}>Open a card</Text>
</Button>
);
const openSheet = () => (
<Button onPress={() => sheet.set(true)} {...button()}>
<Text style={buttonText()}>Open as a page sheet</Text>
</Button>
);
const backdrop = () => (
<Switch
checked={backdropCloses()}
onChange={({ checked }) => backdropCloses.set(checked)}
class={wide() ? "flex-row items-center gap-2 self-center active:opacity-70" : "flex-row items-center gap-3 rounded-xl p-3 active:opacity-70"}
style={wide() ? undefined : ui().row}
trackClass={track}
trackStyle={backdropCloses() ? ui().accentFill : ui().inset}
thumbClass={thumb}
thumbStyle={backdropCloses() ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={{ color: ui().text, flex: wide() ? undefined : 1, fontSize: desktop ? 13 : undefined }}>closeOnBackdropPress {String(backdropCloses())}</Text>
</Switch>
);
const hint = desktop
? "Close with Escape or a click on the backdrop."
: "Close with a tap on the backdrop, a swipe down or the back button. On iOS the sheet is a system pageSheet.";
const state = () => `open: card ${String(card())} · sheet ${String(sheet())} · onCancel ${cancels()}×`;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>dialog</Text>
{() =>
wide() ? (
<>
<Text style={{ color: ui().muted }}>{hint}</Text>
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage gap={16}>
<View style={{ flexDirection: "row", justifyContent: "center", gap: 8 }}>
{openCard()}
{openSheet()}
</View>
{backdrop()}
</Stage>
<Inspector>
<Panel title="open · onCancel">
<Text style={{ fontSize: 13, color: ui().text }}>{state()}</Text>
</Panel>
<Panel title="onOpenChange({ open, reason }) · onOpen" grow>
<Text style={{ fontSize: 13, fontWeight: "600", color: ui().accent }}>{log()}</Text>
</Panel>
</Inspector>
</View>
</>
) : (
<View style={{ gap: 10 }}>
{openCard()}
{openSheet()}
{backdrop()}
<View style={{ ...ui().inset, borderRadius: 12, padding: 14, gap: 4 }}>
<Text style={{ color: ui().muted, fontSize: 13 }}>{state()}</Text>
<Text style={{ color: ui().accent, fontSize: 15, fontWeight: "600" }}>{log()}</Text>
</View>
<Text style={{ color: ui().faint, fontSize: 13 }}>{hint}</Text>
</View>
)
}
<Dialog
open={card()}
animation="fade"
closeOnBackdropPress={backdropCloses()}
onOpenChange={({ open, reason }) => {
card.set(open);
log.set(`onOpenChange({ open: ${open}, reason: "${reason}" })`);
}}
onCancel={() => cancels.update((n) => n + 1)}
onOpen={() => log.set("onOpen: card")}
backdropTestID="card-backdrop"
backdropStyle={{ backgroundColor: tint("#000000", 0.45) }}
style={{ ...ui().card, margin: "auto", width: 320, maxWidth: "90%", borderRadius: 18, padding: 20, gap: 12 }}
>
<Text style={{ color: ui().text, fontSize: 20, fontWeight: "700" }}>Delete this item?</Text>
<Text style={{ color: ui().muted }}>
{backdropCloses()
? "A press on the dimmed backdrop closes it."
: "The backdrop only calls onCancel: close with a button."}
</Text>
<View style={{ flexDirection: "row", gap: 8, justifyContent: desktop ? "flex-end" : undefined }}>
<Button
onPress={() => {
card.set(false);
log.set("closed by the app: Keep");
}}
{...button("secondary", { style: desktop ? {} : { flex: 1 } })}
>
<Text style={buttonText()}>Keep</Text>
</Button>
<Button
onPress={() => {
card.set(false);
log.set("closed by the app: Delete");
}}
{...button("danger", { style: desktop ? {} : { flex: 1 } })}
>
<Text style={buttonText("danger")}>Delete</Text>
</Button>
</View>
</Dialog>
<Dialog
open={sheet()}
presentation="pageSheet"
onOpenChange={({ open, reason }) => {
sheet.set(open);
log.set(`onOpenChange({ open: ${open}, reason: "${reason}" })`);
}}
onCancel={() => cancels.update((n) => n + 1)}
onOpen={() => log.set("onOpen: sheet")}
backdropStyle={{ backgroundColor: tint("#000000", 0.3) }}
// As tall as its content: a panel in the middle on the desktop, a
// sheet along the bottom edge on a phone.
style={
desktop
? { backgroundColor: ui().page, margin: "auto", width: 480, maxWidth: "90%", maxHeight: "90%", borderRadius: 16 }
: {
backgroundColor: ui().page,
marginTop: "auto",
width: "100%",
maxWidth: 560,
maxHeight: "90%",
alignSelf: "center",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}
}
>
<View style={{ padding: 20, paddingTop: "calc(env(safe-area-inset-top) + 20px)", paddingRight: "calc(env(safe-area-inset-right) + 20px)", paddingBottom: "calc(env(safe-area-inset-bottom) + 20px)", paddingLeft: "calc(env(safe-area-inset-left) + 20px)", gap: 12 }}>
<Text style={{ color: ui().text, fontSize: 22, fontWeight: "700" }}>A sheet</Text>
<Text style={{ color: ui().muted }}>
presentation="pageSheet": a system sheet on iOS that follows the finger when swiped down. Elsewhere the
dialog is a layer over the window, and this panel is the app's own style.
</Text>
<Button onPress={() => sheet.set(false)} {...button("primary", { align: "flex-end" })}>
<Text style={buttonText("primary")}>Close</Text>
</Button>
</View>
</Dialog>
</View>
);
}
mount(App);Le dialog gère son comportement et laisse son apparence à l’app : ses styles stylent la popup (la vue qui contient les enfants), et en TSX backdropClass / backdropStyle ajoutent un fond derrière elle.
use nsh_base::{button, dialog};
use nsh_core::prelude::*;
/// A blue button with a white label.
fn primary(label: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
button()
.on_press(on_press)
.items_center()
.rounded_lg()
.bg(rgb(0x2563eb))
.px_4()
.py_3()
.active(|s| s.opacity(0.7))
.child(text(label).font_weight(FontWeight::SEMIBOLD).text_color(white()))
}
fn example() -> impl IntoElement {
let open = signal(false);
view()
.flex_1()
.justify_center()
.p_6()
.child(primary("Open", move || open.set(true)))
.child(when(open, move || {
dialog()
.presentation(ModalPresentation::PageSheet)
.on_cancel(move || open.set(false))
.flex_1()
.bg(white())
.child(
view()
.flex_1()
.px_5()
.pt_safe_area_plus(px(20.0))
.pb_safe_area_plus(px(20.0))
.gap_3()
.child(text("A sheet").text_2xl().font_weight(FontWeight::BOLD))
.child(primary("Close", move || open.set(false))),
)
}))
}import { signal, Text, View } from "nsh";
import { Button, Dialog } from "nsh/base";
function Example() {
const open = signal(false);
return (
<View style={{ flex: 1, justifyContent: "center", padding: 24 }}>
<Button onPress={() => open.set(true)} class="items-center rounded-lg bg-blue-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">Open</Text>
</Button>
<Dialog open={open()} presentation="pageSheet" onOpenChange={(e) => open.set(e.open)} class="flex-1 bg-white">
<View
style={{
flex: 1,
gap: 12,
paddingHorizontal: 20,
paddingTop: "calc(env(safe-area-inset-top) + 20px)",
paddingBottom: "calc(env(safe-area-inset-bottom) + 20px)",
}}
>
<Text style={{ fontSize: 24, fontWeight: "700" }}>A sheet</Text>
<Button onPress={() => open.set(false)} class="items-center rounded-lg bg-blue-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">Close</Text>
</Button>
</View>
</Dialog>
</View>
);
}En TSX, le dialog est affiché tant que open n’est pas false et fermé lorsqu’il devient false. En Rust, il est présenté tant qu’il est dans la vue et retiré lorsqu’il la quitte, c’est pourquoi when le conserve. Dans les deux cas, il transmet à son contenu les insets de zone sûre du dialog, que lisent les variables env(safe-area-inset-*) et safeAreaInsets() (cx.insets() dans un composant Rust) à l’intérieur. Button, également issu de nsh/base, est une cible de pression stylée par l’app.
nsh_core::prelude possède aussi une ancienne fonction dialog, dépréciée : importez nsh_base::dialog par son nom, comme ci-dessus, et l’import explicite prend sa place. Pour l’alerte ou l’action sheet du système, utilisez AlertDialog.
#Props
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
open | dans la vue tant qu’il est ouvert (when(open, ..)) | boolean | true | Affiché tant que la valeur est true. |
animation | .animation(ModalAnimation::Fade) | "slide" | "fade" | "none" | "slide" | La manière dont il entre et sort. |
transitionShader | .transition_shader(TransitionShader::new(program)) | { source: string; uniforms?: Record<string, ShaderUniform>; duration?: number } | aucun | Il entre et sort via un shader WGSL à la place de son animation : shade(uv) lit ce qui est derrière sous le nom sample_from et le dialog sous le nom sample_to pendant que nsh.progress va de 0 à 1 sur duration millisecondes (350 par défaut), puis de 1 à 0 à la fermeture. Lorsque le système demande moins de mouvement, il utilise son propre animation. Voir Shader. |
presentation | .presentation(ModalPresentation::PageSheet) | "fullScreen" | "pageSheet" | "formSheet" | "fullScreen" | Sa forme sur iOS : fullScreen couvre l’écran avec un fond transparent ; pageSheet et formSheet sont des sheets système qui laissent visible ce qui est derrière (formSheet est une sheet centrée plus petite sur les grands écrans). |
closeOnEscape | .close_on_escape(..) | boolean | true | Escape et le bouton retour d’Android le ferment nativement. |
closeOnBackdropPress | .close_on_backdrop_press(..) | boolean | true | Un appui sur le fond le ferme. |
closeOnSwipe | .close_on_swipe(..) | boolean | true | Glisser une sheet vers le bas (iOS, navigateurs sur téléphone) la ferme nativement. |
statusBar | .status_bar(..) | { foreground?, hidden?, animated? } | aucun | iOS et Android : la barre d’état tant que le dialog est ouvert, par-dessus celle des pages (Status bar). |
onOpen | .on_open(..) | () => void | Il a fini d’apparaître. | |
onClose | .on_close(..) | () => void | Il a disparu : l’utilisateur l’a fermé, ou l’app l’a retiré. | |
onCancel | .on_cancel(..) | () => void | L’utilisateur a tenté de le fermer (Escape, retour, un appui sur le fond, un glissement vers le bas), que l’indicateur closeOn* l’ait autorisé ou non. | |
onOpenChange | .on_cancel(..) | (e: { open: boolean; reason: "cancel" | "backdropPress" | "swipe" }) => void | L’utilisateur l’a fermé : gardez open synchronisé ici. En Rust, on_cancel retire le dialog de la vue. | |
class, style | méthodes Styled | string, StyleProp<ViewStyle> | La popup : la vue qui contient les enfants. | |
backdropClass, backdropStyle | une vue enfant remplissant le dialog | string, StyleProp<ViewStyle> | Un fond qui remplit le dialog derrière la popup, présent dès que l’un des deux est défini. | |
children | .child(..) | JSX.Element | Le contenu. |
Clé ios | Rust | Type | Défaut | Description |
|---|---|---|---|---|
supportedOrientations | .supported_orientations(..) | ("portrait" | "portrait-upside-down" | "landscape" | "landscape-left" | "landscape-right")[] | celles du présentateur | Les orientations que le dialog autorise. |
Clé android | Rust | Type | Défaut | Description |
|---|---|---|---|---|
statusBarTranslucent | .status_bar_translucent(..) | boolean | false | Le contenu passe sous la barre d’état ; sinon il commence en dessous. |
#Ouverture et fermeture
L’état du dialog appartient à l’app. Lorsque l’utilisateur ferme le dialog (Escape, le bouton retour d’Android, un appui sur le fond, une sheet glissée vers le bas), celui-ci se ferme nativement et en informe l’app, qui met son état à jour en conséquence : onOpenChange({ open: false, reason }) en TSX, on_cancel en Rust. En TSX, tant que open reste true, le dialog reste fermé, et il se rouvre lorsque open passe de false à true.
Chaque indicateur closeOn* est lu nativement au moment de l’action de l’utilisateur, car le thread principal n’attend jamais le code de l’app. Pour demander une confirmation avant la fermeture, mettez les indicateurs à false : la tentative appelle alors onCancel, et l’app ferme elle-même le dialog (en TSX en passant open à false, en Rust en le retirant de la vue) :
dialog()
.presentation(ModalPresentation::PageSheet)
.close_on_escape(false)
.close_on_backdrop_press(false)
.close_on_swipe(false)
.on_cancel(move || ask_to_discard.set(true))
.flex_1()
.bg(white())
.child(form())<Dialog
open={open()}
presentation="pageSheet"
closeOnEscape={false}
closeOnBackdropPress={false}
closeOnSwipe={false}
onCancel={() => askToDiscard.set(true)}
class="flex-1 bg-white"
>
…
</Dialog>onCancel est appelé à chaque tentative, que les indicateurs autorisent la fermeture du dialog ou non.
#Un dialog avec un fond
Un dialog avec la présentation fullScreen par défaut affiche ce que l’app dessine : ici un fond assombri et une carte centrée par-dessus. Un appui sur le fond le ferme :
when(confirm, move || {
dialog()
.animation(ModalAnimation::Fade)
.on_cancel(move || confirm.set(false))
.flex_1()
.child(
// The backdrop: a press on it closes the dialog.
view()
.absolute()
.inset_0()
.bg(rgba(0x00000066))
.on_press(move || confirm.set(false)),
)
.child(
view()
.m(auto())
.w_80()
.gap_3()
.rounded_2xl()
.bg(white())
.p_5()
.child(text("Delete this item?").text_xl().font_weight(FontWeight::BOLD))
.child(
button()
.on_press(remove)
.items_center()
.rounded_lg()
.bg(rgb(0xdc2626))
.px_4()
.py_3()
.active(|s| s.opacity(0.7))
.child(text("Delete").font_weight(FontWeight::SEMIBOLD).text_color(white())),
),
)
})<Dialog
open={confirm()}
animation="fade"
onOpenChange={({ open }) => confirm.set(open)}
backdropClass="bg-black/40"
class="m-auto w-80 gap-3 rounded-2xl bg-white p-5"
>
<Text style={{ fontSize: 20, fontWeight: "700" }}>Delete this item?</Text>
<Button onPress={remove} class="items-center rounded-lg bg-red-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">Delete</Text>
</Button>
</Dialog>Pour l’alerte ou l’action sheet du système, utilisez AlertDialog depuis nsh/base.
#Méthodes de ref
Aucune.
#Notes par plateforme
- iOS et Mac Catalyst :
presentationchoisit la forme.fullScreenprésente sur tout l’écran avec un fond transparent ;pageSheetetformSheetsont des sheets système avec le fond du système, qui suivent le doigt lorsqu’on les glisse vers le bas (closeOnSwipe). - Android : le dialog remplit la fenêtre et affiche la popup et le fond de l’app. Le bouton retour s’adresse d’abord au dialog le plus haut, avant les stacks et les listeners
BackHandler. - Desktops (macOS, Windows, Linux, GPUI) et web : le dialog est une couche transparente au-dessus de toute la fenêtre, qui affiche la popup et le fond de l’app. Escape constitue une tentative de fermeture.
supportedOrientationsest propre à iOS ;statusBarTranslucentest propre à Android. Le contenu d’un dialog Android commence sous la barre d’état, sauf sistatusBarTranslucentest défini.