Skip to main content

herogpui_components/
modal.rs

1//! Modal — port of `@heroui/modal`.
2//!
3//! Covers the window with a dimmed backdrop and a centered panel when `is_open`,
4//! including when composed inside a clipped or positioned container.
5
6use gpui::{
7    prelude::*, px, AnyElement, App, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
8    Styled, Window,
9};
10use herogpui_core::{element_id, Backdrop};
11use herogpui_theme::ActiveTheme;
12
13use crate::a11y::{self, A11y as _};
14
15/// Modal width preset (`size`) — `xs | sm | md | lg | cover | full`.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum ModalSize {
18    /// Extra small width.
19    Xs,
20    /// Small width.
21    Sm,
22    /// Medium width.
23    #[default]
24    Md,
25    /// Large width.
26    Lg,
27    /// Nearly fills the viewport, keeping a margin.
28    Cover,
29    /// Fills the viewport edge to edge.
30    Full,
31}
32
33impl ModalSize {
34    /// Every size, in display order.
35    pub const ALL: [ModalSize; 6] = [
36        ModalSize::Xs,
37        ModalSize::Sm,
38        ModalSize::Md,
39        ModalSize::Lg,
40        ModalSize::Cover,
41        ModalSize::Full,
42    ];
43
44    /// `max-w-xs` … `max-w-lg` from `.modal__dialog--*`, which is Tailwind's
45    /// scale: 20rem, 24rem, 28rem, 32rem. `Cover` and `Full` are `w-full`
46    /// instead, so the width comes from the container.
47    fn max_width(self) -> Option<Pixels> {
48        match self {
49            ModalSize::Xs => Some(px(320.)),
50            ModalSize::Sm => Some(px(384.)),
51            ModalSize::Md => Some(px(448.)),
52            ModalSize::Lg => Some(px(512.)),
53            ModalSize::Cover | ModalSize::Full => None,
54        }
55    }
56
57    /// The display name of this size.
58    pub fn label(self) -> &'static str {
59        match self {
60            ModalSize::Xs => "Xs",
61            ModalSize::Sm => "Sm",
62            ModalSize::Md => "Md",
63            ModalSize::Lg => "Lg",
64            ModalSize::Cover => "Cover",
65            ModalSize::Full => "Full",
66        }
67    }
68}
69
70/// Vertical placement (`placement`).
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
72pub enum ModalPlacement {
73    /// `"auto"` — centred on desktop; v3 only switches to a sheet on mobile.
74    #[default]
75    Auto,
76    /// Vertically centered.
77    Center,
78    /// Anchored toward the top.
79    Top,
80    /// Anchored toward the bottom.
81    Bottom,
82}
83
84/// HeroUI's desktop entry translation for a placed modal or alert dialog.
85///
86/// The pinned sheet uses `slide-in-from-top-1` and `slide-in-from-bottom-1`
87/// (four pixels) for the explicit top and bottom placements. `Auto` is
88/// centered on the desktop breakpoint and `Center` has no translation. The
89/// sign is expressed in GPUI's relative-offset coordinates: a top placement
90/// starts four pixels toward the trigger side and a bottom placement starts
91/// four pixels toward the trigger side before settling into its slot.
92pub(crate) fn placement_entry_offset(placement: ModalPlacement) -> (f32, f32) {
93    match placement {
94        ModalPlacement::Top => (0.0, 4.0),
95        ModalPlacement::Bottom => (0.0, -4.0),
96        ModalPlacement::Auto | ModalPlacement::Center => (0.0, 0.0),
97    }
98}
99
100/// `scroll` — whether overflow scrolls inside the dialog or moves the whole
101/// container.
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
103pub enum ModalScroll {
104    /// The body scrolls; the dialog stays put.
105    #[default]
106    Inside,
107    /// The dialog grows and the surrounding container scrolls.
108    Outside,
109}
110
111/// Why an overlay dialog asked to close — the payload of [`Modal::on_close`]
112/// and `Drawer::on_close`.
113///
114/// Every dismissal path reports its own reason, so a caller can, say, confirm
115/// unsaved changes on a backdrop press but not on an explicit close button.
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117#[non_exhaustive]
118pub enum DismissReason {
119    /// A composed close trigger (`ModalCloseTrigger`, `DrawerCloseTrigger`)
120    /// was pressed, by pointer or keyboard.
121    CloseButton,
122    /// Escape was pressed while the dialog was the topmost overlay.
123    Escape,
124    /// A press landed outside the panel, on the backdrop.
125    Backdrop,
126    /// A drawer was dragged past its dismissal threshold.
127    Drag,
128}
129
130/// The dismissal callback shape shared by the dialog family.
131pub type OnClose = std::sync::Arc<dyn Fn(&DismissReason, &mut Window, &mut App) + 'static>;
132
133/// `onOpenChange` — every overlay reports dismissal through this shape.
134pub type OnOpenChange = std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
135
136/// HeroUI Modal (controlled).
137#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
138#[derive(IntoElement)]
139pub struct Modal {
140    /// Keys this dialog's own state; see [`Modal::id`].
141    id: gpui::ElementId,
142    is_open: bool,
143    title: Option<SharedString>,
144    icon: Option<SharedString>,
145    icon_color: Option<herogpui_core::Color>,
146    size: ModalSize,
147    backdrop: Backdrop,
148    placement: ModalPlacement,
149    is_dismissible: bool,
150    is_keyboard_dismiss_disabled: bool,
151    scroll: ModalScroll,
152    on_open_change: Option<OnOpenChange>,
153    body: Vec<AnyElement>,
154    footer: Vec<AnyElement>,
155    on_close: Option<OnClose>,
156    /// The panel's corner radius, in place of the owning `container_radius`
157    /// helper. `Full` paints no radius at all, override or not.
158    radius: Option<Pixels>,
159    /// The `sx` slot, refined over the root style at the end of render.
160    sx: Option<Box<gpui::StyleRefinement>>,
161}
162
163/// The key one dialog's piece of state lives under: `id`'s named child `part`.
164///
165/// Shared by the three dialogs so they cannot spell it differently. A thin
166/// wrapper over [`element_id::scoped`] kept for that single spelling; see the
167/// `element_id` module for why the part is structure rather than a `format!`.
168pub(crate) fn dialog_key(id: &gpui::ElementId, part: &'static str) -> gpui::ElementId {
169    element_id::scoped(id, part)
170}
171
172/// Claims the focus for an open dialog, remembering what held it before.
173///
174/// v3 restores the focus when a dialog closes. The trigger is the caller's
175/// element, rendered outside the component, so the dialog cannot reach it --
176/// but it does not need to: whatever held the focus when the dialog opened is
177/// what has to get it back, trigger or not. `Window::focused` names it, and the
178/// handle is parked in the dialog's own keyed state until it closes.
179///
180/// Claiming only while nothing inside already holds the focus is what makes
181/// Escape reach the overlay on the first frame without stealing the ring from a
182/// field the user has since moved into.
183pub(crate) fn claim_dialog_focus(
184    id: &gpui::ElementId,
185    focus_handle: &gpui::FocusHandle,
186    window: &mut Window,
187    cx: &mut App,
188) {
189    let restore = window.use_keyed_state(dialog_key(id, "focus-return"), cx, |_, _| {
190        None::<gpui::FocusHandle>
191    });
192    if focus_handle.contains_focused(window, cx) {
193        return;
194    }
195    // The frame the dialog takes the focus is the only one that can still see
196    // who had it; every later frame would report the dialog itself.
197    let previous = window.focused(cx).filter(|held| held != focus_handle);
198    if previous.is_some() && restore.read(cx).is_none() {
199        restore.update(cx, |slot, _| *slot = previous);
200    }
201    window.focus(focus_handle, cx);
202}
203
204/// Hands the focus back to whatever held it before this dialog opened.
205///
206/// Called from every dismissal path. Does nothing when the dialog never took
207/// the focus from anything, which is the controlled-open case.
208pub(crate) fn release_dialog_focus(id: &gpui::ElementId, window: &mut Window, cx: &mut App) {
209    let restore = window.use_keyed_state(dialog_key(id, "focus-return"), cx, |_, _| {
210        None::<gpui::FocusHandle>
211    });
212    let previous = restore.read(cx).clone();
213    if let Some(previous) = previous {
214        restore.update(cx, |slot, _| *slot = None);
215        window.focus(&previous, cx);
216    }
217}
218
219/// The one contract every dialog's close-trigger part implements so the
220/// composing dialog can hand it the dismissal path through an `AnyElement`.
221///
222/// `owner` is the dialog's own id and `slot` the trigger's index within that
223/// dialog, both handed out during extraction: the built-in `CloseButton` keys
224/// its tab-stop state under the id built from the pair, and the two parts are
225/// both needed. Without `slot`, two triggers in one dialog share a focus
226/// handle; without `owner`, the first trigger of every dialog on screen shares
227/// one — the anonymous wrappers around the triggers push nothing onto gpui's
228/// element-id path, so the id it mints is the whole path.
229pub(crate) trait CloseTriggerPart: 'static {
230    fn wire(&mut self, on_dismiss: Option<OnClose>, owner: gpui::ElementId, slot: usize);
231}
232
233/// Pulls the composed close-trigger parts out of one of a dialog's child
234/// vectors — the body children or the footer row — wiring each with the
235/// dialog's dismissal path and a unique slot index (starting at
236/// `first_slot`), and returns them for the caller to render in the
237/// `absolute end-4 top-4` slot.
238///
239/// A part left among the children would render inside the body slot — under
240/// the body scroller's clip and scroll — instead of pinned to the panel, and
241/// one left in the footer row would render as an ordinary footer child.
242/// Shared by the three dialogs so they cannot spell composition differently.
243pub(crate) fn take_close_triggers<T: CloseTriggerPart>(
244    children: &mut Vec<AnyElement>,
245    on_dismiss: Option<OnClose>,
246    owner: &gpui::ElementId,
247    first_slot: usize,
248) -> Vec<AnyElement> {
249    let mut taken = Vec::new();
250    children.retain_mut(|child| {
251        if let Some(part) = child.downcast_mut::<T>() {
252            part.wire(on_dismiss.clone(), owner.clone(), first_slot + taken.len());
253            taken.push(std::mem::replace(child, gpui::div().into_any_element()));
254            false
255        } else {
256            true
257        }
258    });
259    taken
260}
261
262/// The three dialogs compose the same close-trigger part shape — v3's wired
263/// `CloseButton` (`slot="close"`) in the `absolute end-4 top-4` slot, custom
264/// children standing in for the button's glyph — so this macro spells the
265/// impls all three share. Each dialog keeps its own public part struct and
266/// inherent `impl` (with `new`) because [`take_close_triggers`] downcast-matches
267/// the distinct types. The struct carries `owner: Option<gpui::ElementId>`
268/// and `slot: usize` fields, wired during extraction, and the button's id is
269/// the owning dialog's id with `$button_part` and the slot hung off it: the
270/// anonymous wrappers around the triggers push nothing onto gpui's
271/// element-id path, so an id that is not derived from both would key the
272/// CloseButton's tab-stop state at a path another trigger also owns and share
273/// one focus handle with it.
274macro_rules! close_trigger_part {
275    ($part:ident, $button_part:expr) => {
276        impl Default for $part {
277            fn default() -> Self {
278                Self::new()
279            }
280        }
281
282        impl gpui::ParentElement for $part {
283            fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
284                self.children.extend(elements);
285            }
286        }
287
288        impl crate::modal::CloseTriggerPart for $part {
289            fn wire(
290                &mut self,
291                on_dismiss: Option<crate::modal::OnClose>,
292                owner: gpui::ElementId,
293                slot: usize,
294            ) {
295                self.on_dismiss = on_dismiss;
296                self.owner = Some(owner);
297                self.slot = slot;
298            }
299        }
300
301        impl gpui::Element for $part {
302            type RequestLayoutState = gpui::AnyElement;
303            type PrepaintState = ();
304
305            fn id(&self) -> Option<gpui::ElementId> {
306                None
307            }
308
309            fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
310                None
311            }
312
313            fn request_layout(
314                &mut self,
315                _: Option<&gpui::GlobalElementId>,
316                _: Option<&gpui::InspectorElementId>,
317                window: &mut gpui::Window,
318                cx: &mut gpui::App,
319            ) -> (gpui::LayoutId, Self::RequestLayoutState) {
320                let children = std::mem::take(&mut self.children);
321                // v3's part is always the wired `CloseButton`: custom children
322                // only replace its glyph, and the press still runs the
323                // dialog's close action. With no dismissal callback to wire
324                // the part draws nothing at all.
325                let mut inner = match (self.on_dismiss.take(), self.owner.take()) {
326                    (Some(on_dismiss), Some(owner)) => {
327                        let button = crate::close_button::CloseButton::new(
328                            herogpui_core::element_id::indexed(&owner, $button_part, self.slot),
329                        )
330                        .on_press(move |_, window, cx| {
331                            on_dismiss(&crate::modal::DismissReason::CloseButton, window, cx)
332                        });
333                        if children.is_empty() {
334                            button.into_any_element()
335                        } else {
336                            button
337                                .icon(gpui::div().children(children))
338                                .into_any_element()
339                        }
340                    }
341                    _ => gpui::div().into_any_element(),
342                };
343                let layout = inner.request_layout(window, cx);
344                (layout, inner)
345            }
346
347            fn prepaint(
348                &mut self,
349                _: Option<&gpui::GlobalElementId>,
350                _: Option<&gpui::InspectorElementId>,
351                _: gpui::Bounds<gpui::Pixels>,
352                state: &mut Self::RequestLayoutState,
353                window: &mut gpui::Window,
354                cx: &mut gpui::App,
355            ) -> Self::PrepaintState {
356                state.prepaint(window, cx);
357            }
358
359            fn paint(
360                &mut self,
361                _: Option<&gpui::GlobalElementId>,
362                _: Option<&gpui::InspectorElementId>,
363                _: gpui::Bounds<gpui::Pixels>,
364                state: &mut Self::RequestLayoutState,
365                _: &mut Self::PrepaintState,
366                window: &mut gpui::Window,
367                cx: &mut gpui::App,
368            ) {
369                state.paint(window, cx);
370            }
371        }
372
373        impl gpui::IntoElement for $part {
374            type Element = Self;
375
376            fn into_element(self) -> Self {
377                self
378            }
379        }
380    };
381}
382pub(crate) use close_trigger_part;
383
384/// `Modal.CloseTrigger` — the modal's close slot, `absolute end-4 top-4`.
385///
386/// v3 spells visibility by composing or omitting the part: a composed
387/// [`ModalCloseTrigger`] renders in the slot and an omitted one leaves it
388/// bare panel padding — there is no `hideCloseButton` and no automatic
389/// stand-in. The part is v3's wired `CloseButton` (`slot="close"`), always
390/// wired to the modal's dismissal paths ([`Modal::on_close`] plus
391/// [`Modal::on_open_change`], the same report Escape and the backdrop make)
392/// and closing regardless of `is_dismissible`, like v3's composed trigger.
393/// Custom `children` only replace the button's glyph — the press still runs
394/// the modal's close action. With neither dismissal callback on the modal —
395/// or composed outside a [`Modal`] — the part draws nothing.
396pub struct ModalCloseTrigger {
397    on_dismiss: Option<OnClose>,
398    /// The id of the dialog this trigger was pulled out of; see
399    /// [`CloseTriggerPart::wire`].
400    owner: Option<gpui::ElementId>,
401    /// This trigger's index within its dialog; see [`CloseTriggerPart::wire`].
402    slot: usize,
403    children: Vec<AnyElement>,
404}
405
406impl ModalCloseTrigger {
407    /// Creates a close trigger with no children.
408    pub fn new() -> Self {
409        Self {
410            on_dismiss: None,
411            owner: None,
412            slot: 0,
413            children: Vec::new(),
414        }
415    }
416}
417
418crate::close_trigger_part!(ModalCloseTrigger, "close-trigger");
419
420impl Modal {
421    /// The element id this dialog's state is keyed by.
422    ///
423    /// Not a v3 prop: gpui needs an explicit id, and the phase, the focus handle
424    /// and the drag offset are all keyed by it. Two dialogs on screen with the
425    /// same key share all three.
426    pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
427        self.id = id.into();
428        self
429    }
430
431    /// Creates a closed modal.
432    pub fn new() -> Self {
433        Self {
434            id: gpui::ElementId::Name("modal".into()),
435            is_open: false,
436            title: None,
437            icon: None,
438            icon_color: None,
439            size: ModalSize::Md,
440            backdrop: Backdrop::Opaque,
441            placement: ModalPlacement::Center,
442            is_dismissible: true,
443            is_keyboard_dismiss_disabled: false,
444            scroll: ModalScroll::default(),
445            on_open_change: None,
446            body: Vec::new(),
447            footer: Vec::new(),
448            on_close: None,
449            radius: None,
450            sx: None,
451        }
452    }
453
454    /// Sets whether the modal is open (v3 `isOpen`).
455    pub fn is_open(mut self, v: bool) -> Self {
456        self.is_open = v;
457        self
458    }
459
460    /// Sets the title text.
461    pub fn title(mut self, t: impl Into<SharedString>) -> Self {
462        self.title = Some(t.into());
463        self
464    }
465
466    /// `Modal.Icon` — the glyph above the heading, drawn in a `size-10
467    /// rounded-3xl` box. v3 composes it as a child part and tints it with a
468    /// class (`bg-default text-foreground`); this takes the asset path.
469    pub fn icon(mut self, path: impl Into<SharedString>) -> Self {
470        self.icon = Some(path.into());
471        self
472    }
473
474    /// The role colour of that box. Absent is v3's own default, `bg-default`.
475    pub fn icon_color(mut self, color: herogpui_core::Color) -> Self {
476        self.icon_color = Some(color);
477        self
478    }
479
480    /// Sets the width preset (v3 `size`).
481    pub fn size(mut self, s: ModalSize) -> Self {
482        self.size = s;
483        self
484    }
485
486    /// Whether clicking the backdrop closes the modal (`isDismissable`).
487    pub fn is_dismissible(mut self, v: bool) -> Self {
488        self.is_dismissible = v;
489        self
490    }
491
492    /// Sets the backdrop style.
493    pub fn backdrop(mut self, b: Backdrop) -> Self {
494        self.backdrop = b;
495        self
496    }
497
498    /// Sets the vertical placement (v3 `placement`).
499    pub fn placement(mut self, p: ModalPlacement) -> Self {
500        self.placement = p;
501        self
502    }
503
504    /// The panel's corner radius, in place of the owning `container_radius`
505    /// helper. `Full` paints no radius at all, override or not. Not a v3
506    /// prop; the removed v2 `radius` prop is prohibited and this is a
507    /// per-component repository extension.
508    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
509        self.radius = Some(radius.into());
510        self
511    }
512
513    /// The one slot for caller-owned low-level styling: GPUI's styling methods
514    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
515    /// applied to the modal's root element — the full-window overlay the panel
516    /// and the backdrop sit in — after every value the size, the placement and
517    /// the active theme chose, so they win.
518    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
519        crate::util::refine_sx(&mut self.sx, style);
520        self
521    }
522
523    /// `scroll` — `Inside` keeps the dialog fixed and scrolls its body;
524    /// `Outside` lets the dialog grow and scrolls the container.
525    pub fn scroll(mut self, scroll: ModalScroll) -> Self {
526        self.scroll = scroll;
527        self
528    }
529
530    /// `onOpenChange` — fires with `false` on every dismissal path, alongside
531    /// [`Modal::on_close`].
532    pub fn on_open_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
533        self.on_open_change = Some(std::sync::Arc::new(f));
534        self
535    }
536
537    /// Sets whether the keyboard cannot dismiss the modal (v3 `isKeyboardDismissDisabled`).
538    pub fn is_keyboard_dismiss_disabled(mut self, v: bool) -> Self {
539        self.is_keyboard_dismiss_disabled = v;
540        self
541    }
542
543    /// Adds a child to the footer row (ModalFooter).
544    pub fn footer_child(mut self, el: impl IntoElement) -> Self {
545        self.footer.push(el.into_any_element());
546        self
547    }
548
549    /// Enables the dismissal paths (`onClose`): the composed close trigger,
550    /// Escape and the backdrop all report through it, alongside
551    /// [`Modal::on_open_change`]. The [`DismissReason`] says which path fired.
552    pub fn on_close(mut self, f: impl Fn(&DismissReason, &mut Window, &mut App) + 'static) -> Self {
553        self.on_close = Some(std::sync::Arc::new(f));
554        self
555    }
556}
557
558impl Default for Modal {
559    fn default() -> Self {
560        Self::new()
561    }
562}
563
564impl ParentElement for Modal {
565    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
566        self.body.extend(elements);
567    }
568}
569
570impl RenderOnce for Modal {
571    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
572        // v3 keeps a closing panel on screen for its `[data-exiting]` run.
573        let (phase, dismissal_token) = crate::util::overlay_scope(
574            window,
575            cx,
576            dialog_key(&self.id, "phase"),
577            self.is_open,
578            true,
579        );
580        if phase == crate::util::OverlayPhase::Closed {
581            // Every close path lands here -- a dismissal callback, Escape, a
582            // backdrop press, or a caller flipping `is_open` -- so this is the
583            // one place that can hand the focus back whatever closed it.
584            release_dialog_focus(&self.id, window, cx);
585            return gpui::div().into_any_element();
586        }
587        let exiting = phase == crate::util::OverlayPhase::Exiting;
588
589        // Escape has to reach the overlay, and key events only travel to the
590        // focused element and its ancestors. Claiming focus while nothing
591        // inside holds it makes Escape work immediately; once a field inside
592        // takes focus the event still bubbles up to here.
593        let focus =
594            window.use_keyed_state(dialog_key(&self.id, "focus"), cx, |_, cx| cx.focus_handle());
595        let focus_handle = focus.read(cx).clone();
596        claim_dialog_focus(&self.id, &focus_handle, window, cx);
597
598        let colors = cx.colors();
599
600        // Every dismissal path reports through both callbacks, so a caller can
601        // use either without losing events.
602        let dismiss: Option<OnClose> = match (self.on_close.clone(), self.on_open_change.clone()) {
603            (None, None) => None,
604            (close, open_change) => Some(crate::util::shared(
605                move |reason: &DismissReason, window: &mut Window, cx: &mut App| {
606                    if let Some(f) = &close {
607                        f(reason, window, cx);
608                    }
609                    if let Some(f) = &open_change {
610                        f(&false, window, cx);
611                    }
612                },
613            )),
614        };
615        let keyboard_dismiss = if self.is_keyboard_dismiss_disabled {
616            None
617        } else {
618            dismiss.clone()
619        };
620
621        // v3 composes the close trigger as a child part. Pull every composed
622        // `ModalCloseTrigger` out of the body children and the footer row —
623        // so neither slot swallows it — and hand each the modal's dismissal
624        // paths to wire the default `CloseButton` with, regardless of
625        // `is_dismissible`.
626        let mut close_triggers =
627            take_close_triggers::<ModalCloseTrigger>(&mut self.body, dismiss.clone(), &self.id, 0);
628        close_triggers.extend(take_close_triggers::<ModalCloseTrigger>(
629            &mut self.footer,
630            dismiss.clone(),
631            &self.id,
632            close_triggers.len(),
633        ));
634
635        // `.modal__header` is `flex flex-col gap-3` and carries no padding of
636        // its own: the dialog's `p-6` is the whole inset. The heading is
637        // `text-base font-medium`.
638        // `.modal__icon` is `size-10 rounded-3xl`, a child of the header above
639        // the heading -- not a disc in the corner.
640        let icon = self.icon.as_ref().map(|path| {
641            let (bg, fg) = match self.icon_color {
642                Some(color) => {
643                    let role = cx.role(color);
644                    (role.soft(), role.soft_foreground(colors.foreground))
645                }
646                None => (colors.default.color, colors.foreground),
647            };
648            gpui::div()
649                .flex()
650                .items_center()
651                .justify_center()
652                .flex_shrink_0()
653                .size(px(40.))
654                .rounded(crate::util::control_radius(cx))
655                .bg(bg)
656                .child(gpui::svg().size(px(20.)).path(path.clone()).text_color(fg))
657        });
658        let header = if self.title.is_some() || icon.is_some() {
659            Some(
660                gpui::div()
661                    .flex()
662                    .flex_col()
663                    .gap(px(12.))
664                    .children(icon)
665                    .when_some(self.title.as_ref(), |el, title| {
666                        el.child(
667                            gpui::div()
668                                .text_size(px(16.))
669                                // `.modal__heading` is `text-base`, paired
670                                // with a 24px leading. `Drawer` and
671                                // `AlertDialog` always had the pair; without
672                                // it this inherited the shell's 20px.
673                                .line_height(px(24.))
674                                .font_weight(gpui::FontWeight::MEDIUM)
675                                .child(title.to_string()),
676                        )
677                    }),
678            )
679        } else {
680            None
681        };
682
683        let has_header = header.is_some();
684        let has_body = !self.body.is_empty();
685        // Inside scrolling fits the container's content box: p-10 keeps
686        // 40px of scrim around the panel; Full removes that padding.
687        let scroll_inside = self.scroll == ModalScroll::Inside;
688        let full = self.size == ModalSize::Full;
689        let panel_max = window.viewport_size().height - if full { px(0.) } else { px(80.) };
690        let inside_body_max = panel_max - px(48.);
691        // The panel radius: an instance override replaces the helper's value,
692        // and `Full` paints none either way. Read off `self` before the body
693        // and the footer are moved into the panel.
694        let radius = self.radius;
695        let panel_radius = radius.unwrap_or_else(|| crate::util::container_radius(cx));
696        // `.modal__dialog`: w-full, a per-size max width, and p-6.
697        let panel = gpui::div()
698            .relative()
699            .flex()
700            .flex_col()
701            .w_full()
702            .when(
703                self.scroll == ModalScroll::Outside
704                    && matches!(
705                        self.placement,
706                        ModalPlacement::Center | ModalPlacement::Auto
707                    ),
708                gpui::Styled::my_auto,
709            )
710            .when(
711                self.scroll == ModalScroll::Outside && self.placement == ModalPlacement::Bottom,
712                gpui::Styled::mt_auto,
713            )
714            .when_some(self.size.max_width(), |e, w| e.max_w(w))
715            .when(
716                scroll_inside && matches!(self.size, ModalSize::Cover | ModalSize::Full),
717                |e| e.h_full().min_h_full(),
718            )
719            .when(self.scroll == ModalScroll::Outside, |e| e.flex_shrink_0())
720            .p(px(24.))
721            .when(self.scroll == ModalScroll::Inside, |e| e.max_h(panel_max))
722            .bg(colors.overlay.background)
723            .text_color(colors.foreground)
724            .when(!full, |e| {
725                e.rounded(panel_radius)
726                    .shadow(cx.layout().overlay_shadow.clone())
727            })
728            .overflow_hidden()
729            .when_some(header, gpui::ParentElement::child)
730            .when(has_body, |panel| {
731                panel.child(
732                    gpui::div()
733                        .id(element_id::scoped(&self.id, "body"))
734                        .flex()
735                        .flex_col()
736                        .gap(px(10.))
737                        // `.modal__header + .modal__body` is `mt-2`.
738                        .when(has_header, |b| b.mt(px(8.)))
739                        .text_size(px(14.))
740                        // `leading-[1.43]` on `text-sm`.
741                        .line_height(px(20.))
742                        .text_color(colors.muted)
743                        // `.modal__body` is `-m-[3px] my-0 overflow-visible
744                        // p-[3px]`: `my-0` zeroes the vertical margins, so
745                        // unlike the horizontal pair the 3px padding is 6px
746                        // of real body height -- the panel-height delta the
747                        // matched captures measured -- and AlertDialog and
748                        // Drawer carry the same compensation.
749                        .mx(px(-3.))
750                        .p(px(3.))
751                        // v3 spells the body `min-h-0 flex-1` and scrolls it
752                        // inside `.modal__dialog--scroll-inside`'s max height.
753                        // There is no equivalent here: a gpui scroll container
754                        // in an auto-height flex column measures as *zero*, so
755                        // that spelling made every default modal draw its
756                        // heading and its footer with nothing between them.
757                        // `Outside` keeps the working arrangement: the body is
758                        // content-sized and the container scrolls. `Inside`
759                        // caps the panel within the scrim and scrolls the
760                        // body itself within that budget, and the budget is a
761                        // *max* height, so a header and a footer still sit
762                        // between the body and the panel's edges.
763                        .when(scroll_inside, |b| {
764                            b.max_h(inside_body_max).overflow_y_scroll()
765                        })
766                        .children(self.body),
767                )
768            });
769
770        // `modal/modal.js` renders RAC `Modal`/`ModalOverlay` around a
771        // `Dialog`, and `react-aria/.../dialog/useDialog.js` defaults that
772        // dialog to `role="dialog"`, named by the composed `Heading` through
773        // `aria-labelledby` — inlined here as the title text. Nothing marks it
774        // as modal: `useDialog` deliberately sets no `aria-modal` (a WebKit
775        // focus bug it documents inline), and `useModal` makes the rest of the
776        // page `aria-hidden` instead; this port's `util::trap_tab` is the same
777        // containment by other means, and there is no node attribute for it
778        // either way.
779        //
780        // Stated *after* the layout chain on purpose: `design_audit.py` reads
781        // `.modal__dialog`'s `p-6` with a pattern anchored on
782        // `let panel = gpui::div()` followed straight by `.relative()`, and an
783        // `.id(..).a11y_named(..)` wedged in there makes that metric
784        // unreadable. Widening another audit's reader to fit this call would
785        // be the wrong repair.
786        let panel = panel
787            .id(element_id::scoped(&self.id, "dialog"))
788            .a11y_named(a11y::Role::Dialog, &a11y::Name::maybe(self.title.clone()));
789
790        // `.modal__footer` is `flex-row items-center justify-end gap-2` with no
791        // border: the separator this used to draw is not in v3's sheet.
792        let mut panel = if self.footer.is_empty() {
793            panel
794        } else {
795            panel.child(
796                gpui::div()
797                    .flex()
798                    .items_center()
799                    .justify_end()
800                    .gap(px(8.))
801                    // `+ .modal__footer` is `mt-5` after either sibling.
802                    .when(has_header || has_body, |f| f.mt(px(20.)))
803                    .children(self.footer),
804            )
805        };
806
807        // `.modal__close-trigger` is `absolute end-4 top-4`, outside the header.
808        // v3 renders a close affordance only where the caller composes the
809        // part; an omitted trigger leaves the spot bare panel padding.
810        for trigger in close_triggers {
811            panel = panel.child(
812                gpui::div()
813                    .absolute()
814                    .top(px(16.))
815                    .right(px(16.))
816                    .child(trigger),
817            );
818        }
819
820        // Backdrop dismissal lives on the **panel**, not on the backdrop.
821        // `on_mouse_down_out` uses the panel's bounds, so presses on its
822        // children do not also dismiss through the full-window backdrop.
823        // `is_dismissible` gates it, and the exit phase gets none: the dialog
824        // is already closing.
825        let panel = if self.is_dismissible && !exiting {
826            if let Some(on_close) = dismiss.clone() {
827                crate::util::dismiss_on_press_outside_with_token(
828                    panel,
829                    dismissal_token.clone(),
830                    move |window, cx| {
831                        on_close(&DismissReason::Backdrop, window, cx);
832                        crate::util::DismissResult::Handled
833                    },
834                )
835            } else {
836                panel
837            }
838        } else {
839            panel
840        };
841
842        // Backdrop — v3 variants: opaque / blur / transparent
843        // gpui has no backdrop-filter, so `Blur` renders a lighter scrim than
844        // `Opaque` to keep the layering readable.
845        let backdrop_bg = match self.backdrop {
846            Backdrop::Opaque => colors.backdrop,
847            Backdrop::Blur => colors.backdrop.alpha(colors.backdrop.a * 0.6),
848            Backdrop::Transparent => gpui::transparent_black(),
849        };
850        // `Tab` cycles the dialog's own controls: v3 documents that, and gpui's
851        // tab order is the whole window's, so the dialog has to keep it.
852        let mut overlay = crate::util::trap_tab(
853            gpui::div()
854                // `overflow_y_scroll` needs a stateful element, so the id is set
855                // unconditionally and only the overflow is conditional.
856                .id(element_id::scoped(&self.id, "scroll"))
857                .track_focus(&focus_handle),
858            &focus_handle,
859        )
860        .absolute()
861        .inset_0()
862        .flex()
863        // `.modal__container` is `p-4 sm:p-10`.
864        .p(px(40.))
865        .when(full, |e| e.p(px(0.)))
866        // `Outside` scrolls here -- the dialog grows and this container moves.
867        // `Inside` has the body's own scroller instead; keeping this one would
868        // put two scroll containers under the pointer, and a wheel over the
869        // body would move the whole dialog while the body scrolled beneath it
870        // -- the scrim comes up under the pointer and the next press dismisses
871        // the modal. See `.modal__body`'s comment.
872        .when(self.scroll == ModalScroll::Outside, |e| {
873            // v3 scrolls the top-aligned backdrop and positions the dialog
874            // within it with auto margins. Centering an oversized flex child
875            // gives it a negative origin that no scroll offset can reach.
876            e.flex_col().items_center().justify_start().overflow_y_scroll()
877        })
878        .when(
879            self.scroll == ModalScroll::Inside
880                && matches!(
881                    self.placement,
882                    ModalPlacement::Center | ModalPlacement::Auto
883                ),
884            |e| e.items_center().justify_center(),
885        )
886        .when(
887            self.scroll == ModalScroll::Inside && self.placement == ModalPlacement::Top,
888            |e| e.items_start().justify_center(),
889        )
890        .when(
891            self.scroll == ModalScroll::Inside
892                && self.placement == ModalPlacement::Bottom,
893            |e| e.items_end().justify_center(),
894        );
895        if let Some(on_escape) = keyboard_dismiss {
896            overlay = crate::util::dismiss_on_escape_with_token(
897                overlay,
898                dismissal_token,
899                move |window, cx| {
900                    on_escape(&DismissReason::Escape, window, cx);
901                    crate::util::DismissResult::Handled
902                },
903            );
904        }
905        // `.modal__backdrop` is a bare scrim — `--opaque`/`--blur`/
906        // `--transparent` are the Backdrop enum above: it must look dimmed
907        // but never grab presses, because the panel's `on_mouse_down_out`
908        // owns backdrop dismissal (see above), which also keeps the press out
909        // of the panel's own controls. v3 fades it in alongside the panel
910        // (`.backdrop[data-entering]`).
911        let scrim = gpui::div()
912            .id(element_id::scoped(&self.id, "backdrop"))
913            .absolute()
914            .inset_0()
915            .bg(backdrop_bg);
916        overlay = overlay.child(if exiting {
917            crate::anim::exiting(
918                scrim,
919                "modal-backdrop-out",
920                crate::anim::ZoomBox::default(),
921                crate::anim::Motion::BACKDROP_OUT,
922                cx,
923            )
924        } else {
925            crate::anim::entering(
926                scrim,
927                "modal-backdrop-anim",
928                crate::anim::Motion::BACKDROP_IN,
929                cx,
930            )
931        });
932        let zoom = crate::anim::ZoomBox {
933            // Fixed-width presets scale geometrically. `Cover` and `Full` have
934            // no width here; Full also has no radius, so its zoom-100 rule is
935            // the shared fade with no geometric interpolation.
936            width: self.size.max_width(),
937            radius: (!full).then_some(panel_radius),
938            slide_x: (!full && placement_entry_offset(self.placement).0 != 0.0)
939                .then(|| px(placement_entry_offset(self.placement).0)),
940            slide_y: (!full && placement_entry_offset(self.placement).1 != 0.0)
941                .then(|| px(placement_entry_offset(self.placement).1)),
942            ..Default::default()
943        };
944        overlay = overlay.child(if exiting {
945            crate::anim::exiting(
946                panel,
947                "modal-panel-out",
948                zoom,
949                crate::anim::Motion::PANEL_OUT,
950                cx,
951            )
952        } else {
953            crate::anim::entering_zoom(
954                panel,
955                "modal-panel",
956                zoom,
957                crate::anim::Motion::PANEL_IN,
958                cx,
959            )
960        });
961
962        overlay = crate::util::apply_sx(overlay, &self.sx);
963        crate::util::window_overlay(overlay, window).into_any_element()
964    }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::{placement_entry_offset, ModalPlacement};
970
971    #[test]
972    fn placement_entry_offsets_follow_the_painted_side() {
973        assert_eq!(placement_entry_offset(ModalPlacement::Top), (0.0, 4.0));
974        assert_eq!(placement_entry_offset(ModalPlacement::Bottom), (0.0, -4.0));
975        assert_eq!(placement_entry_offset(ModalPlacement::Auto), (0.0, 0.0));
976        assert_eq!(placement_entry_offset(ModalPlacement::Center), (0.0, 0.0));
977    }
978}
979
980crate::util::impl_component_styled!(Modal);