gpuicn Components

Component 16 of 60

Dialog

A window overlaid on the primary content.

site/src/previews/dialog.rs

Starting the preview. GPUI compiles to WebAssembly, so the first load takes a few seconds. This browser has neither WebGPU nor WebGL 2, so the live preview cannot run. The code on this page still works. The preview did not start.

Dialog, running in the browser. The second tab shows the preview's own source, which includes its layout and demo state. For the smallest working example, see Usage below. Download the whole showcase for its imports and helpers.

Installation

After the one-time setup, run the command from your app root. The CLI writes the modules and keeps src/ui/mod.rs in step.

gpuicn add dialog

If you prefer to copy the files by hand, declare them yourself:

// src/ui/mod.rs
pub mod theme;
pub mod button;
pub mod dialog;

// src/main.rs
mod ui;

Usage

Put this in a view module and return example(handle, window, cx) from its render method.

use crate::ui::dialog::*;
use gpui_kit::{App, IntoElement, ParentElement, Window};

fn example(handle: &DialogHandle, window: &mut Window, cx: &mut App) -> impl IntoElement {
    let popup = dialog_popup("profile.popup", "Edit profile", cx)
        .child(dialog_title("profile.title", cx).child("Edit profile"))
        .child(dialog_action("profile.save", handle).label("Save"));
    gpui_kit::div()
        .child(dialog_trigger("profile.open", handle).label("Edit profile"))
        .child(dialog("profile", handle, popup, window, cx))
}

API reference

pub fn new(
        id: impl Into<ElementId>,
        action: DialogControlAction,
        anchor_key: &'static str,
    ) -> Self
pub fn variant(mut self, variant: ButtonVariant) -> Self

Sets the visual variant.

pub fn size(mut self, size: ButtonSize) -> Self

Sets the visual size.

pub fn disabled(mut self, disabled: bool) -> Self

Prevents activation and removes the button from tab order.

pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self

Sets the accessible name for icon or custom content.

pub fn label(mut self, label: impl Into<SharedString>) -> Self

Adds visible text and uses it as the accessible name.

pub fn dialog(
    id: impl Into<ElementId>,
    handle: &DialogHandle,
    popup: impl IntoElement,
    window: &mut Window,
    cx: &mut App,
) -> Dialog

Keep the host mounted while closed so it can restore focus after dismissal.

pub fn modal_viewport(
    id: impl Into<ElementId>,
    popup: impl IntoElement,
    window: &Window,
    cx: &App,
) -> Stateful<Div>

Keeps a modal's full content reachable even in a short window. The wheel reaches this viewport only through surfaces that use `block_mouse_except_scroll()`, as `dialog_popup` does; a custom surface that calls `occlude()` hides the wheel from it and stays clipped.

pub fn dialog_trigger(id: impl Into<ElementId>, handle: &DialogHandle) -> Button

A keyboard-accessible button opening the caller's handle.

pub fn dialog_backdrop(cx: &App) -> Div

Full-window modal backdrop.

pub fn dismiss_backdrop(cx: &App) -> Div

A backdrop that dismisses its dialog when pressed. The occluding backdrop hides the press from Kit's host, so it routes Cancel through the dialog's veto callback itself.

pub fn dialog_header(cx: &App) -> Div

Stacked dialog heading content.

pub fn dialog_footer(cx: &App) -> Div

Inset action footer.

pub fn dialog_popup(
    id: impl Into<ElementId>,
    label: impl Into<SharedString>,
    cx: &App,
) -> Stateful<Div>

Named popup surface with modal Tab traversal, including caller-owned inputs.

pub fn dialog_title(id: impl Into<ElementId>, cx: &App) -> Stateful<Div>

Medium-weight dialog title.

pub fn dialog_description(id: impl Into<ElementId>, cx: &App) -> Stateful<Div>

Muted dialog description.

pub fn dialog_close(id: impl Into<ElementId>, cx: &App) -> DialogControl

Icon-only close control; Kit routes Cancel through the owning dialog's veto callback.

pub fn dialog_action(id: impl Into<ElementId>, handle: &DialogHandle) -> Button

Create form actions with `Button`; close the handle after successful validation.

Installed source, 528 lines
//! Nova modal surfaces using Kit's dialog host and caller-owned handles.
#[path = "modal_focus.rs"]
#[doc(hidden)]
pub mod modal_focus;
use super::{
    button::{Button, ButtonSize, ButtonVariant},
    theme::{
        UiTheme, presence,
        primitives::{surface, text},
    },
};
use gpui_icons::{LucideIcon, lucide};
pub use gpui_kit::base::{Dialog, DialogChangeReason, DialogHandle};
use gpui_kit::{
    AnyElement, App, Div, ElementId, FontWeight, InteractiveElement as _, IntoElement, MouseButton,
    ParentElement, SharedString, Stateful, StatefulInteractiveElement as _, Styled, Window, div,
    px,
};

#[derive(Clone, Copy)]
#[doc(hidden)]
pub enum DialogControlAction {
    Cancel,
    Confirm,
}

/// A styled dialog button that routes its action through its own dialog tree.
#[derive(IntoElement)]
pub struct DialogControl {
    button: Button,
    action: DialogControlAction,
    anchor_key: &'static str,
    anchor_id: ElementId,
}

impl DialogControl {
    #[doc(hidden)]
    pub fn new(
        id: impl Into<ElementId>,
        action: DialogControlAction,
        anchor_key: &'static str,
    ) -> Self {
        let id = id.into();
        Self {
            button: Button::new(id.clone()),
            action,
            anchor_key,
            anchor_id: id,
        }
    }

    /// Sets the visual variant.
    pub fn variant(mut self, variant: ButtonVariant) -> Self {
        self.button = self.button.variant(variant);
        self
    }

    /// Sets the visual size.
    pub fn size(mut self, size: ButtonSize) -> Self {
        self.button = self.button.size(size);
        self
    }

    /// Prevents activation and removes the button from tab order.
    pub fn disabled(mut self, disabled: bool) -> Self {
        self.button = self.button.disabled(disabled);
        self
    }

    /// Sets the accessible name for icon or custom content.
    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
        self.button = self.button.aria_label(label);
        self
    }

    /// Adds visible text and uses it as the accessible name.
    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
        self.button = self.button.label(label);
        self
    }
}

impl ParentElement for DialogControl {
    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
        self.button.extend(elements);
    }
}

impl Styled for DialogControl {
    fn style(&mut self) -> &mut gpui_kit::StyleRefinement {
        self.button.style()
    }
}

impl gpui_kit::RenderOnce for DialogControl {
    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
        let anchor = window
            .use_keyed_state((self.anchor_id, self.anchor_key), cx, |_, cx| {
                cx.focus_handle()
            })
            .read(cx)
            .clone();
        let dispatch_from = anchor.clone();
        let action = self.action;

        self.button
            .child(div().absolute().size_0().track_focus(&anchor))
            .on_click(move |_, window, cx| match action {
                DialogControlAction::Cancel => {
                    dispatch_from.dispatch_action(&gpui_kit::base::actions::Cancel, window, cx)
                }
                DialogControlAction::Confirm => dispatch_from.dispatch_action(
                    &gpui_kit::base::actions::Confirm { secondary: false },
                    window,
                    cx,
                ),
            })
    }
}

/// Keep the host mounted while closed so it can restore focus after dismissal.
pub fn dialog(
    id: impl Into<ElementId>,
    handle: &DialogHandle,
    popup: impl IntoElement,
    window: &mut Window,
    cx: &mut App,
) -> Dialog {
    let id = id.into();
    let open = handle.is_open();
    let focus = modal_focus::prepare(id.clone(), open, window, cx);
    let scope = modal_focus::ModalFocus::new(id.clone());
    // Kit removes the popup when the handle closes, so only the entrance is
    // animated: a fade with a short rise, as in shadcn's `zoom-in-95`.
    let entrance = presence((id.clone(), "presence"), open, window, cx).progress;
    let t = UiTheme::read(cx).clone();
    let popup = scope
        .trap(div(), true)
        .id(id.clone())
        .track_focus(&focus)
        .w_full()
        .max_w(t.space(96.))
        .relative()
        .top(t.space(2.) * (1. - entrance))
        .opacity(entrance)
        .on_key_down(|event, window, cx| {
            if event.keystroke.key == "escape" {
                window.dispatch_action(Box::new(gpui_kit::base::actions::Cancel), cx);
                cx.stop_propagation();
            }
        })
        .child(scope.boundary(false))
        .child(scope.boundary(true))
        .child(modal_viewport((id.clone(), "viewport"), popup, window, cx));
    Dialog::new(cx)
        .handle(handle.clone())
        .close_on_escape(false)
        .flex()
        .items_center()
        .justify_center()
        .p(t.space(4.))
        .backdrop(dismiss_backdrop(cx).opacity(entrance))
        .popup(popup)
        // Enter in an arbitrary child must not dismiss an unfinished form.
        .on_ok(|_, _, _| false)
}
/// Keeps a modal's full content reachable even in a short window.
///
/// The wheel reaches this viewport only through surfaces that use
/// `block_mouse_except_scroll()`, as `dialog_popup` does; a custom surface
/// that calls `occlude()` hides the wheel from it and stays clipped.
#[doc(hidden)]
pub fn modal_viewport(
    id: impl Into<ElementId>,
    popup: impl IntoElement,
    window: &Window,
    cx: &App,
) -> Stateful<Div> {
    div()
        .id(id)
        .max_h((window.viewport_size().height - UiTheme::read(cx).space(8.)).max(px(0.)))
        .overflow_y_scroll()
        .child(popup)
}

/// A keyboard-accessible button opening the caller's handle.
pub fn dialog_trigger(id: impl Into<ElementId>, handle: &DialogHandle) -> Button {
    let handle = handle.clone();
    Button::new(id)
        .variant(ButtonVariant::Outline)
        .on_click(move |_, window, cx| handle.open(window, cx))
}
/// Full-window modal backdrop.
pub fn dialog_backdrop(cx: &App) -> Div {
    div()
        .absolute()
        .inset_0()
        .bg(UiTheme::read(cx).colors.overlay)
        .occlude()
}
/// A backdrop that dismisses its dialog when pressed. The occluding backdrop
/// hides the press from Kit's host, so it routes Cancel through the dialog's
/// veto callback itself.
#[doc(hidden)]
pub fn dismiss_backdrop(cx: &App) -> Div {
    dialog_backdrop(cx).on_mouse_down(MouseButton::Left, |_, window, cx| {
        window.dispatch_action(Box::new(gpui_kit::base::actions::Cancel), cx);
        cx.stop_propagation();
    })
}
/// Stacked dialog heading content.
pub fn dialog_header(cx: &App) -> Div {
    div().flex().flex_col().gap(UiTheme::read(cx).space(2.))
}
/// Inset action footer.
pub fn dialog_footer(cx: &App) -> Div {
    let t = UiTheme::read(cx);
    div()
        .mx(-t.space(4.))
        .mb(-t.space(4.))
        .flex()
        .justify_end()
        .gap(t.space(2.))
        .rounded_b(t.radius.xl)
        .border_t_1()
        .border_color(t.colors.border)
        .bg(t.colors.muted.opacity(0.5))
        .p(t.space(4.))
}
/// Named popup surface with modal Tab traversal, including caller-owned inputs.
pub fn dialog_popup(
    id: impl Into<ElementId>,
    label: impl Into<SharedString>,
    cx: &App,
) -> Stateful<Div> {
    let t = UiTheme::read(cx);
    let id = id.into();
    let popup = div()
        .id(id)
        .aria_label(label)
        // Blocks clicks and hover behind the surface but lets the wheel reach
        // the host's `modal_viewport`, which `occlude` would hide it from.
        .block_mouse_except_scroll()
        .w_full()
        .min_w(px(0.))
        .max_w(t.space(96.))
        .flex()
        .flex_col()
        .gap(t.space(4.))
        .p(t.space(4.));
    // The host's viewport scrolls a tall dialog; the surface itself never does,
    // so there is one scroll container and the footer stays with the content.
    surface::modal(popup, t)
}
/// Medium-weight dialog title.
pub fn dialog_title(id: impl Into<ElementId>, cx: &App) -> Stateful<Div> {
    let t = UiTheme::read(cx);
    div()
        .id(id)
        .role(gpui_kit::Role::Heading)
        .aria_level(2)
        .font_family(t.fonts.heading.clone())
        .font_weight(FontWeight::MEDIUM)
        .text_size(t.text(16.))
        .line_height(t.text(16.))
        .text_color(t.colors.popover_foreground)
}
/// Muted dialog description.
pub fn dialog_description(id: impl Into<ElementId>, cx: &App) -> Stateful<Div> {
    let t = UiTheme::read(cx);
    text::muted(div().id(id), t)
}
/// Icon-only close control; Kit routes Cancel through the owning dialog's veto callback.
pub fn dialog_close(id: impl Into<ElementId>, cx: &App) -> DialogControl {
    let t = UiTheme::read(cx);
    DialogControl::new(
        id,
        DialogControlAction::Cancel,
        "gpuicn-dialog-close-anchor",
    )
    .aria_label("Close")
    .variant(ButtonVariant::Ghost)
    .size(ButtonSize::IconSm)
    .absolute()
    .top(t.space(2.))
    .right(t.space(2.))
    .child(
        lucide(LucideIcon::X)
            .size(t.space(4.))
            .text_color(t.colors.popover_foreground),
    )
}
/// Create form actions with `Button`; close the handle after successful validation.
pub fn dialog_action(id: impl Into<ElementId>, handle: &DialogHandle) -> Button {
    let handle = handle.clone();
    Button::new(id).on_click(move |_, window, cx| handle.close(window, cx))
}

#[cfg(test)]
mod tests {
    use std::{cell::Cell, rc::Rc};

    use gpui_kit::{Context, FocusHandle, Render, TestAppContext, point};

    use super::*;

    struct DialogControlHarness {
        dialog_focus: FocusHandle,
        retained_focus: FocusHandle,
        handle: DialogHandle,
        cancel_count: Rc<Cell<usize>>,
    }

    impl Render for DialogControlHarness {
        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
            let cancel_count = self.cancel_count.clone();
            div()
                .child(div().absolute().size_0().track_focus(&self.retained_focus))
                .child(
                    Dialog::new(cx)
                        .handle(self.handle.clone())
                        .focus_handle(self.dialog_focus.clone())
                        .on_cancel(move |_, _, _| {
                            cancel_count.set(cancel_count.get() + 1);
                            true
                        })
                        .popup(
                            DialogControl::new(
                                "cancel",
                                DialogControlAction::Cancel,
                                "dialog-control-test-anchor",
                            )
                            .size_full()
                            .label("Cancel"),
                        ),
                )
        }
    }

    #[gpui_kit::test]
    fn dialog_control_routes_from_its_own_anchor(cx: &mut TestAppContext) {
        cx.update(super::super::theme::init);
        let handle = DialogHandle::new(true);
        let cancel_count = Rc::new(Cell::new(0));
        let (view, cx) = cx.add_window_view({
            let handle = handle.clone();
            let cancel_count = cancel_count.clone();
            move |_, cx| DialogControlHarness {
                dialog_focus: cx.focus_handle(),
                retained_focus: cx.focus_handle(),
                handle,
                cancel_count,
            }
        });
        cx.update(|window, cx| {
            let retained_focus = view.read(cx).retained_focus.clone();
            retained_focus.focus(window, cx);
            window.draw(cx).clear(cx);
        });

        cx.simulate_click(point(px(20.), px(20.)), Default::default());
        cx.run_until_parked();

        assert_eq!(cancel_count.get(), 1);
        assert!(!handle.is_open());
    }

    struct TwoDialogControlHarness {
        first_focus: FocusHandle,
        second_focus: FocusHandle,
        first_handle: DialogHandle,
        second_handle: DialogHandle,
        first_count: Rc<Cell<usize>>,
        second_count: Rc<Cell<usize>>,
    }

    impl Render for TwoDialogControlHarness {
        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
            let first_count = self.first_count.clone();
            let second_count = self.second_count.clone();
            div()
                .child(
                    Dialog::new(cx)
                        .handle(self.first_handle.clone())
                        .focus_handle(self.first_focus.clone())
                        .on_cancel(move |_, _, _| {
                            first_count.set(first_count.get() + 1);
                            true
                        })
                        .popup(
                            DialogControl::new(
                                "first-cancel",
                                DialogControlAction::Cancel,
                                "shared-dialog-control-anchor",
                            )
                            .absolute()
                            .left_0()
                            .top_0()
                            .w(px(40.))
                            .h(px(40.))
                            .label("First"),
                        ),
                )
                .child(
                    Dialog::new(cx)
                        .handle(self.second_handle.clone())
                        .focus_handle(self.second_focus.clone())
                        .on_cancel(move |_, _, _| {
                            second_count.set(second_count.get() + 1);
                            true
                        })
                        .popup(
                            DialogControl::new(
                                "second-cancel",
                                DialogControlAction::Cancel,
                                "shared-dialog-control-anchor",
                            )
                            .absolute()
                            .left(px(60.))
                            .top_0()
                            .w(px(40.))
                            .h(px(40.))
                            .label("Second"),
                        ),
                )
        }
    }

    #[gpui_kit::test]
    fn dialog_controls_keep_distinct_anchors_in_one_window(cx: &mut TestAppContext) {
        cx.update(super::super::theme::init);
        let first_handle = DialogHandle::new(true);
        let second_handle = DialogHandle::new(true);
        let first_count = Rc::new(Cell::new(0));
        let second_count = Rc::new(Cell::new(0));
        let (_, cx) = cx.add_window_view({
            let first_handle = first_handle.clone();
            let second_handle = second_handle.clone();
            let first_count = first_count.clone();
            let second_count = second_count.clone();
            move |_, cx| TwoDialogControlHarness {
                first_focus: cx.focus_handle(),
                second_focus: cx.focus_handle(),
                first_handle,
                second_handle,
                first_count,
                second_count,
            }
        });
        cx.update(|window, cx| window.draw(cx).clear(cx));

        cx.simulate_click(point(px(80.), px(20.)), Default::default());
        cx.run_until_parked();

        assert_eq!(first_count.get(), 0);
        assert_eq!(second_count.get(), 1);
        assert!(first_handle.is_open());
        assert!(!second_handle.is_open());
    }

    struct TallDialog {
        handle: DialogHandle,
    }

    impl Render for TallDialog {
        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
            let popup = dialog_popup("tall-popup", "Preferences", cx)
                .child(div().h(px(1200.)).flex_shrink_0().child("Settings"))
                .child(
                    div()
                        .h(px(32.))
                        .flex_shrink_0()
                        .debug_selector(|| "tall-footer".into())
                        .child("Save"),
                );
            div()
                .size_full()
                .child(
                    div().id("page").size_full().overflow_y_scroll().child(
                        div()
                            .h(px(4000.))
                            .debug_selector(|| "page-content".into())
                            .child("Page"),
                    ),
                )
                .child(dialog("tall", &self.handle, popup, window, cx))
        }
    }

    #[gpui_kit::test]
    fn a_tall_dialog_scrolls_under_the_pointer(cx: &mut TestAppContext) {
        use gpui_kit::{ScrollDelta, ScrollEvent};
        cx.update(super::super::theme::init);
        let handle = DialogHandle::new(true);
        let (_, cx) = cx.add_window_view({
            let handle = handle.clone();
            move |_, _| TallDialog { handle }
        });
        cx.update(|window, cx| window.draw(cx).clear(cx));
        let viewport = cx.update(|window, _| window.viewport_size());
        let footer = cx.debug_bounds("tall-footer").unwrap();
        assert!(
            footer.bottom() > viewport.height,
            "the footer starts below the fold"
        );

        let page = cx.debug_bounds("page-content").unwrap();
        let center = point(viewport.width / 2., viewport.height / 2.);
        cx.simulate_mouse_move(center, None, Default::default());
        cx.simulate_event(ScrollEvent {
            position: center,
            delta: ScrollDelta::Pixels(point(px(0.), px(-1400.))),
            ..Default::default()
        });
        cx.update(|window, cx| window.draw(cx).clear(cx));
        let footer = cx.debug_bounds("tall-footer").unwrap();
        assert!(
            footer.bottom() <= viewport.height,
            "the wheel over the surface scrolls the dialog: footer at {footer:?}"
        );
        assert_eq!(
            cx.debug_bounds("page-content").unwrap(),
            page,
            "the backdrop keeps the wheel from the page behind the dialog"
        );
    }
}

Platform notes

Visual reference: shadcn/ui 4.19.0, Neutral Nova. Implementation: crates/ui/registry/dialog/dialog.rs, using GPUI Kit 0.6.4.

A retained Kit DialogHandle owns visibility. Keep the host mounted while closed so it can restore focus. Tab and Shift+Tab wrap through popup controls, Escape and backdrop press request cancellation, and Enter activates the focused control. The application closes the handle after a successful save. Nested modal behavior and OS screen-reader operation still need platform validation.

The Rust source and Usage example define the supported API. Browser DOM/CSS behavior, exact exit animations and cross-platform screen-reader parity are not implied by the native component. See migration qualification for the checks completed on this revision.