Skip to main content

herogpui_components/
alert_dialog.rs

1//! AlertDialog — port of `@heroui/alert-dialog` (v3).
2//!
3//! A modal for critical confirmations. Unlike [`Modal`](crate::modal::Modal) it
4//! is not dismissible by clicking the backdrop, it always announces a title and
5//! description, and it renders a confirm/cancel action pair unless the caller
6//! composes their own `AlertDialog.Footer` children.
7
8use std::sync::Arc;
9
10use gpui::{
11    div, prelude::*, px, AnyElement, App, ClickEvent, IntoElement, ParentElement, Pixels,
12    RenderOnce, SharedString, Styled, Window,
13};
14use herogpui_core::{element_id, Backdrop, Color, Size, Variant};
15use herogpui_theme::ActiveTheme;
16
17use crate::{
18    a11y::{self, A11y as _},
19    button::Button,
20    icons,
21    modal::{ModalPlacement, OnOpenChange},
22    util,
23};
24
25/// AlertDialog width preset (`size`) — `xs | sm | md | lg | cover`.
26///
27/// There is no `full` here: a critical confirmation should never fill the
28/// viewport edge to edge.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30pub enum AlertDialogSize {
31    /// Extra small width.
32    Xs,
33    /// Small width.
34    Sm,
35    /// Medium width.
36    #[default]
37    Md,
38    /// Large width.
39    Lg,
40    /// Nearly fills the viewport, keeping a margin.
41    Cover,
42}
43
44impl AlertDialogSize {
45    /// Every size, in display order.
46    pub const ALL: [AlertDialogSize; 5] = [
47        AlertDialogSize::Xs,
48        AlertDialogSize::Sm,
49        AlertDialogSize::Md,
50        AlertDialogSize::Lg,
51        AlertDialogSize::Cover,
52    ];
53
54    /// `max-w-xs` … `max-w-lg` from `.alert-dialog__dialog--*`, which is
55    /// Tailwind's scale: 20rem, 24rem, 28rem, 32rem. `Cover` is `w-full`.
56    fn max_width(self) -> Option<Pixels> {
57        match self {
58            AlertDialogSize::Xs => Some(px(320.)),
59            AlertDialogSize::Sm => Some(px(384.)),
60            AlertDialogSize::Md => Some(px(448.)),
61            AlertDialogSize::Lg => Some(px(512.)),
62            AlertDialogSize::Cover => None,
63        }
64    }
65
66    /// The display name of this size.
67    pub fn label(self) -> &'static str {
68        match self {
69            AlertDialogSize::Xs => "Xs",
70            AlertDialogSize::Sm => "Sm",
71            AlertDialogSize::Md => "Md",
72            AlertDialogSize::Lg => "Lg",
73            AlertDialogSize::Cover => "Cover",
74        }
75    }
76}
77
78type OnAction = Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
79
80/// `AlertDialog.CloseTrigger` — the dialog's close slot, `absolute end-4
81/// top-4`.
82///
83/// v3 spells visibility by composing or omitting the part: a composed
84/// [`AlertDialogCloseTrigger`] renders in the slot and an omitted one leaves
85/// it bare panel padding — there is no `hideCloseButton` and no automatic
86/// stand-in. The part is v3's wired `CloseButton` (`slot="close"`), always
87/// wired to `onOpenChange(false)` alone — never to [`AlertDialog::on_cancel`]
88/// — and closing regardless of `is_dismissible` or
89/// `is_keyboard_dismiss_disabled`, like v3's composed trigger. Custom
90/// `children` only replace the button's glyph — the press still runs the
91/// dialog's close action. Without an `on_open_change` — or composed outside
92/// an [`AlertDialog`] — the part draws nothing.
93pub struct AlertDialogCloseTrigger {
94    on_dismiss: Option<crate::modal::OnClose>,
95    /// The id of the dialog this trigger was pulled out of; see
96    /// [`crate::modal::CloseTriggerPart::wire`].
97    owner: Option<gpui::ElementId>,
98    /// This trigger's index within its dialog; see
99    /// [`crate::modal::CloseTriggerPart::wire`].
100    slot: usize,
101    children: Vec<AnyElement>,
102}
103
104impl AlertDialogCloseTrigger {
105    /// Creates a close trigger with no children.
106    pub fn new() -> Self {
107        Self {
108            on_dismiss: None,
109            owner: None,
110            slot: 0,
111            children: Vec::new(),
112        }
113    }
114}
115
116crate::modal::close_trigger_part!(AlertDialogCloseTrigger, "close-trigger");
117
118/// `.alert-dialog__icon--{status}`: the disc's background, the glyph colour
119/// and the glyph. `--default` uses the plain `bg-default text-foreground`
120/// pair with the info glyph, not a soft mix; the status roles use their soft
121/// background and `RoleColor::soft_foreground(..)`, upstream's role/foreground
122/// `color-mix` token. The glyphs follow upstream's icon map:
123/// info for `default` and `accent`, then success, warning and danger.
124fn icon_presentation(
125    status: Color,
126    colors: &herogpui_theme::ThemeColors,
127) -> (gpui::Hsla, gpui::Hsla, &'static str) {
128    match status {
129        Color::Default => (colors.default.color, colors.foreground, icons::INFO_CIRCLE),
130        Color::Accent => (
131            colors.accent.soft(),
132            colors.accent.soft_foreground(colors.foreground),
133            icons::INFO_CIRCLE,
134        ),
135        Color::Success => (
136            colors.success.soft(),
137            colors.success.soft_foreground(colors.foreground),
138            icons::CHECK_CIRCLE,
139        ),
140        Color::Warning => (
141            colors.warning.soft(),
142            colors.warning.soft_foreground(colors.foreground),
143            icons::WARNING_TRIANGLE,
144        ),
145        Color::Danger => (
146            colors.danger.soft(),
147            colors.danger.soft_foreground(colors.foreground),
148            icons::CIRCLE_EXCLAMATION,
149        ),
150    }
151}
152
153/// HeroUI AlertDialog (controlled).
154#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
155#[derive(IntoElement)]
156pub struct AlertDialog {
157    /// Keys this dialog's own state; see [`AlertDialog::id`].
158    id: gpui::ElementId,
159    is_open: bool,
160    title: SharedString,
161    description: Option<SharedString>,
162    size: AlertDialogSize,
163    backdrop: Backdrop,
164    placement: ModalPlacement,
165    /// `status` on `AlertDialog.Icon`. `None` renders no icon, matching a v3
166    /// dialog that does not compose one.
167    status: Option<Color>,
168    is_dismissible: bool,
169    is_keyboard_dismiss_disabled: bool,
170    on_open_change: Option<OnOpenChange>,
171    confirm_label: SharedString,
172    cancel_label: SharedString,
173    /// Composed `AlertDialog.Footer` children. Any composed child retires the
174    /// built-in cancel/confirm pair: v3's footer is caller-composed, which is
175    /// where a danger or pending confirm is spelled.
176    footer: Vec<AnyElement>,
177    children: Vec<AnyElement>,
178    on_confirm: Option<OnAction>,
179    on_cancel: Option<OnAction>,
180    /// The panel's corner radius, in place of the owning `container_radius`
181    /// helper. The panel's entry zoom interpolates the same value.
182    radius: Option<Pixels>,
183    /// The `sx` slot, refined over the root style at the end of render.
184    sx: Option<Box<gpui::StyleRefinement>>,
185}
186
187impl AlertDialog {
188    /// The element id this dialog's state is keyed by.
189    ///
190    /// Not a v3 prop: gpui needs an explicit id, and the exit phase, the focus
191    /// handle and the drag offset are all keyed by it. Two dialogs on screen
192    /// with the same key share all three -- which is what
193    /// `HEROGPUI_OPEN_OVERLAYS=1` puts on screen.
194    pub fn id(mut self, id: impl Into<gpui::ElementId>) -> Self {
195        self.id = id.into();
196        self
197    }
198
199    /// Creates a closed alert dialog with the given title.
200    pub fn new(title: impl Into<SharedString>) -> Self {
201        Self {
202            id: gpui::ElementId::Name("alert-dialog".into()),
203            is_open: false,
204            title: title.into(),
205            description: None,
206            size: AlertDialogSize::default(),
207            backdrop: Backdrop::Opaque,
208            placement: ModalPlacement::default(),
209            status: None,
210            // v3 defaults an alert dialog to non-dismissible: the user has to
211            // pick one of the two actions.
212            is_dismissible: false,
213            // v3 defaults this to true for an alert dialog.
214            is_keyboard_dismiss_disabled: true,
215            on_open_change: None,
216            confirm_label: "Confirm".into(),
217            cancel_label: "Cancel".into(),
218            footer: Vec::new(),
219            children: Vec::new(),
220            on_confirm: None,
221            on_cancel: None,
222            radius: None,
223            sx: None,
224        }
225    }
226
227    /// Sets whether the dialog is open (v3 `isOpen`).
228    pub fn is_open(mut self, v: bool) -> Self {
229        self.is_open = v;
230        self
231    }
232
233    /// Sets the description text.
234    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
235        self.description = Some(text.into());
236        self
237    }
238
239    /// Sets the width preset (v3 `size`).
240    pub fn size(mut self, size: AlertDialogSize) -> Self {
241        self.size = size;
242        self
243    }
244
245    /// `placement` on `AlertDialog.Container`.
246    pub fn placement(mut self, placement: ModalPlacement) -> Self {
247        self.placement = placement;
248        self
249    }
250
251    /// `status` on `AlertDialog.Icon` — shows the status glyph in that colour.
252    pub fn status(mut self, status: Color) -> Self {
253        self.status = Some(status);
254        self
255    }
256
257    /// `isKeyboardDismissDisabled` — Escape is disabled by default here, so
258    /// pass `false` to allow it.
259    pub fn is_keyboard_dismiss_disabled(mut self, v: bool) -> Self {
260        self.is_keyboard_dismiss_disabled = v;
261        self
262    }
263
264    /// `isDismissable` — allows dismissal by clicking the backdrop. The close
265    /// slot is not the backdrop: like v3's composed `AlertDialog.CloseTrigger`,
266    /// the composed part renders and closes regardless of this flag, reporting
267    /// through [`AlertDialog::on_open_change`] alone.
268    pub fn is_dismissible(mut self, v: bool) -> Self {
269        self.is_dismissible = v;
270        self
271    }
272
273    /// `onOpenChange` — fires with `false` when the dialog is dismissed.
274    pub fn on_open_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
275        self.on_open_change = Some(Arc::new(f));
276        self
277    }
278
279    /// Sets the backdrop style.
280    pub fn backdrop(mut self, backdrop: Backdrop) -> Self {
281        self.backdrop = backdrop;
282        self
283    }
284
285    /// The panel's corner radius, in place of the owning `container_radius`
286    /// helper. The panel's entry zoom interpolates the same value, so the
287    /// animation never parts company with the painted shape; the status icon's
288    /// `rounded-3xl` tile inside is an inner part and keeps its own. Not a v3
289    /// prop; the removed v2 `radius` prop is prohibited and this is a
290    /// per-component repository extension.
291    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
292        self.radius = Some(radius.into());
293        self
294    }
295
296    /// The one slot for caller-owned low-level styling: GPUI's styling methods
297    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
298    /// applied to the dialog's root element after every value the size, the
299    /// placement and the active theme chose, so they win.
300    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
301        util::refine_sx(&mut self.sx, style);
302        self
303    }
304
305    /// Sets the label of the confirm button.
306    pub fn confirm_label(mut self, text: impl Into<SharedString>) -> Self {
307        self.confirm_label = text.into();
308        self
309    }
310
311    /// Sets the label of the cancel button.
312    pub fn cancel_label(mut self, text: impl Into<SharedString>) -> Self {
313        self.cancel_label = text.into();
314        self
315    }
316
317    /// Composes `AlertDialog.Footer` — the caller-owned action row.
318    ///
319    /// v3 has no built-in confirm/cancel pair: the footer buttons are
320    /// composed, which is where a danger or pending confirm is spelled
321    /// (`variant="danger"`, `is_pending` on the composed `Button`). Any
322    /// composed child retires the built-in pair and owns its own close
323    /// reporting; repeated calls append in order.
324    pub fn footer_child(mut self, el: impl IntoElement) -> Self {
325        self.footer.push(el.into_any_element());
326        self
327    }
328
329    /// The confirm action on the built-in footer pair. The pair renders only
330    /// when the caller composes no footer children: a composed
331    /// `AlertDialog.Footer` retires it whole and owns its own close
332    /// reporting, so `on_confirm` does nothing there — see
333    /// [`AlertDialog::footer_child`].
334    pub fn on_confirm(
335        mut self,
336        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
337    ) -> Self {
338        self.on_confirm = Some(Arc::new(handler));
339        self
340    }
341
342    /// The cancel action on the built-in footer pair. The pair renders only
343    /// when the caller composes no footer children, and a close trigger is
344    /// never a cancel: `on_cancel` fires from the built-in cancel button
345    /// alone — see [`AlertDialog::footer_child`].
346    pub fn on_cancel(
347        mut self,
348        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
349    ) -> Self {
350        self.on_cancel = Some(Arc::new(handler));
351        self
352    }
353}
354
355impl ParentElement for AlertDialog {
356    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
357        self.children.extend(elements);
358    }
359}
360
361impl RenderOnce for AlertDialog {
362    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
363        // v3 keeps a closing panel on screen for its `[data-exiting]` run.
364        let (phase, dismissal_token) = util::overlay_scope(
365            window,
366            cx,
367            crate::modal::dialog_key(&self.id, "phase"),
368            self.is_open,
369            true,
370        );
371        if phase == util::OverlayPhase::Closed {
372            // Every close path lands here, including a caller flipping
373            // `is_open`, so the focus goes back from one place.
374            crate::modal::release_dialog_focus(&self.id, window, cx);
375            return div().into_any_element();
376        }
377        let exiting = phase == util::OverlayPhase::Exiting;
378
379        // Escape has to reach the overlay, and key events only travel to the
380        // focused element and its ancestors. Claiming focus while nothing
381        // inside holds it makes Escape work immediately; once a field inside
382        // takes focus the event still bubbles up to here.
383        let focus =
384            window.use_keyed_state(crate::modal::dialog_key(&self.id, "focus"), cx, |_, cx| {
385                cx.focus_handle()
386            });
387        let focus_handle = focus.read(cx).clone();
388        crate::modal::claim_dialog_focus(&self.id, &focus_handle, window, cx);
389
390        let colors = cx.colors();
391        let layout = cx.layout();
392
393        // Every close slot reports through `onOpenChange(false)` alone — never
394        // through `onCancel`. v3's `slot="close"` buttons carry RAC's
395        // `state.close()` as the slot's own `onPress`, and a consumer handler
396        // chains *after* it: `AlertDialog.CloseTrigger` (the composed part in
397        // the close slot), the `ModalOverlay`'s outside-press dismissal and
398        // its Escape dismissal are all plain closes with no action of their
399        // own. `isDismissable` only gates the scrim.
400        let close_action: Option<crate::modal::OnClose> =
401            self.on_open_change
402                .clone()
403                .map(|open_change| -> crate::modal::OnClose {
404                    util::shared(
405                        move |_: &crate::modal::DismissReason,
406                              window: &mut Window,
407                              cx: &mut App| {
408                            open_change(&false, window, cx);
409                        },
410                    )
411                });
412
413        // v3 composes the close trigger as a child part. Pull every composed
414        // `AlertDialogCloseTrigger` out of the dialog's children and the
415        // footer row — so neither slot swallows it — and hand each the close
416        // path above to wire the default `CloseButton` with.
417        let composed_footer = !self.footer.is_empty();
418        let mut close_triggers = crate::modal::take_close_triggers::<AlertDialogCloseTrigger>(
419            &mut self.children,
420            close_action.clone(),
421            &self.id,
422            0,
423        );
424        close_triggers.extend(
425            crate::modal::take_close_triggers::<AlertDialogCloseTrigger>(
426                &mut self.footer,
427                close_action.clone(),
428                &self.id,
429                close_triggers.len(),
430            ),
431        );
432
433        // `.alert-dialog__container` is `p-4 sm:p-10`, so its content box —
434        // the box v3's `max-h-full` dialog resolves against — is the viewport
435        // less 80px. Absolute pixels because gpui resolves a percentage
436        // max height against the parent *content box*, which is exactly the
437        // budget this number names.
438        let panel_max_h = window.viewport_size().height - px(80.);
439        // The dialog's own `p-6` takes 48 of that before the body sees any.
440        let body_max_h = panel_max_h - px(48.);
441
442        // The panel's corner, resolved once: the zoom below has to interpolate
443        // the same value the panel paints.
444        let radius = self.radius.unwrap_or_else(|| util::container_radius(cx));
445
446        // `.alert-dialog__dialog` has no gap: the spacing between the header,
447        // the body and the footer comes from v3's `+` rules (mt-2, mt-5), so
448        // each part carries its own top margin instead.
449        let mut panel = div()
450            // `alert-dialog/alert-dialog.js` is the one dialog in v3 that
451            // names its own role: it passes `role: "alertdialog"` to the RAC
452            // `Dialog`, and `react-aria/.../dialog/useDialog.js` passes that
453            // through. That role is also what makes the body the dialog's
454            // description upstream — `useDialog` points `aria-describedby` at
455            // the content id only `when role === 'alertdialog'` — so the
456            // description text joins the name here rather than being a node.
457            .id(element_id::scoped(&self.id, "dialog"))
458            .a11y_named(
459                a11y::Role::AlertDialog,
460                &a11y::Name::labelled(self.title.clone()).described(self.description.clone()),
461            )
462            .relative()
463            .flex()
464            .flex_col()
465            .w_full()
466            .when_some(self.size.max_width(), |e, w| e.max_w(w))
467            .max_h(panel_max_h)
468            // `.alert-dialog__dialog` is `overflow-clip`: a long body scrolls
469            // inside the body slot below instead of pushing the footer out of
470            // the container. gpui 0.2.2 has no `clip` overflow; `hidden` is
471            // its clip equivalent here.
472            .overflow_hidden()
473            // `--cover` is `h-full min-h-full w-full`: the panel fills the
474            // container's content box outright instead of hugging its content.
475            .when(self.size == AlertDialogSize::Cover, |e| {
476                e.h(panel_max_h).min_h(panel_max_h)
477            })
478            .p(px(24.))
479            .rounded(radius)
480            .bg(colors.overlay.background)
481            // v3 gives a floating panel no border; dark mode's inset hairline is
482            // what separates it from the page.
483            .when_some(layout.overlay_hairline, |el, hairline| {
484                el.border(layout.border_width).border_color(hairline)
485            })
486            .text_color(colors.overlay.foreground)
487            .when(!layout.overlay_shadow.is_empty(), |e| {
488                e.shadow(layout.overlay_shadow.clone())
489            })
490            .child({
491                // `.alert-dialog__header` is `flex flex-col gap-3`, and the icon
492                // is a *child* of it: `size-10 rounded-3xl` above the heading,
493                // not a disc floating in the corner.
494                let mut header = div().flex().flex_col().gap(px(12.));
495                if let Some(status) = self.status {
496                    let (bg, fg, glyph) = icon_presentation(status, colors);
497                    header = header.child(
498                        div()
499                            .flex()
500                            .items_center()
501                            .justify_center()
502                            .flex_shrink_0()
503                            .size(px(40.))
504                            .rounded(util::control_radius(cx))
505                            .bg(bg)
506                            .child(
507                                gpui::svg()
508                                    .size(px(20.))
509                                    .path(glyph)
510                                    // svg() never inherits text colour.
511                                    .text_color(fg),
512                            ),
513                    );
514                }
515                header.child(
516                    div()
517                        .text_size(px(16.))
518                        .line_height(px(24.))
519                        .font_weight(gpui::FontWeight::MEDIUM)
520                        .child(self.title.to_string()),
521                )
522            });
523
524        // `.alert-dialog__body` is one scrolling slot that holds the
525        // description and any composed children: `min-h-0 flex-1 scrollbar`,
526        // `text-sm leading-[1.43] text-muted`, `mt-2` after the header, and
527        // the `-m-[3px] my-0 p-[3px]` that lets the text run 3px under the
528        // browser's scrollbar. gpui reserves gutter space only through
529        // `scrollbar_width` and paints none on a plain div, so the
530        // compensation translates to the margins and padding alone. The
531        // budget is the panel cap minus the dialog's `p-6`; a scroll
532        // container in an auto-height flex column measures as zero (the
533        // modal learned this the hard way), so this is a *max* height and
534        // the header and the footer claim their own space first.
535        let has_description = self.description.is_some();
536        if has_description || !self.children.is_empty() {
537            let mut body = div()
538                .id(element_id::scoped(&self.id, "body"))
539                .mt(px(8.))
540                .mx(px(-3.))
541                .p(px(3.))
542                .w_full()
543                .min_h_0()
544                .max_h(body_max_h)
545                .overflow_y_scroll()
546                // v3 spells the body `flex-1`: inside a `--cover` dialog's
547                // fixed height it stretches and pins the footer to the bottom
548                // edge. In a content-sized panel that spelling measures the
549                // scroller as zero (the modal's lesson), so it only applies
550                // when the panel has a definite height.
551                .when(self.size == AlertDialogSize::Cover, |e| e.flex_1())
552                .text_size(px(14.))
553                .line_height(px(20.))
554                .text_color(colors.muted);
555            if let Some(description) = &self.description {
556                body = body.child(description.to_string());
557            }
558            if !self.children.is_empty() {
559                body = body.child(
560                    div()
561                        .when(has_description, |e| e.mt(px(8.)))
562                        .flex()
563                        .flex_col()
564                        .gap(px(8.))
565                        .children(self.children),
566                );
567            }
568            panel = panel.child(body);
569        }
570
571        // Cancel first, confirm last — the destructive action is furthest from
572        // the reading position. `composed_footer` was measured before the
573        // trigger pull: a footer that held only a close trigger still retires
574        // the built-in pair, but the pull also leaves nothing to render in
575        // the row, and an empty `mt-5` row draws nothing but a phantom 20px
576        // gap — so the row is spelled only when it has content.
577        let mut actions = div()
578            .flex()
579            .flex_row()
580            .items_center()
581            .justify_end()
582            .gap(px(8.))
583            // `+ .alert-dialog__footer` is `mt-5`.
584            .mt(px(20.));
585        if !composed_footer {
586            // The built-in footer pair stands in for v3's composed
587            // `Button slot="close"` compositions, and is retired whole the
588            // moment the caller composes their own footer. RAC chains the
589            // slot's own `onPress` — `state.close()`, the owner's
590            // `onOpenChange(false)` — *before* a consumer `onPress`, so the
591            // close is reported first and the action callback runs after.
592            // The dialog is controlled: the owner, not the button, decides
593            // the next render.
594            let cancel_action = match (self.on_open_change.clone(), self.on_cancel.clone()) {
595                (None, None) => None,
596                (open_change, action) => Some(util::shared(
597                    move |ev: &ClickEvent, window: &mut Window, cx: &mut App| {
598                        if let Some(f) = &open_change {
599                            f(&false, window, cx);
600                        }
601                        if let Some(f) = &action {
602                            f(ev, window, cx);
603                        }
604                    },
605                )),
606            };
607            let confirm_action = match (self.on_open_change.clone(), self.on_confirm.clone()) {
608                (None, None) => None,
609                (open_change, action) => Some(util::shared(
610                    move |ev: &ClickEvent, window: &mut Window, cx: &mut App| {
611                        if let Some(f) = &open_change {
612                            f(&false, window, cx);
613                        }
614                        if let Some(f) = &action {
615                            f(ev, window, cx);
616                        }
617                    },
618                )),
619            };
620
621            let mut cancel = Button::new("alert-dialog-cancel")
622                .label(self.cancel_label.clone())
623                .variant(Variant::Tertiary)
624                .size(Size::Md);
625            if let Some(action) = cancel_action {
626                cancel = cancel.on_press(move |ev, window, cx| action(ev, window, cx));
627            }
628
629            let mut confirm = Button::new("alert-dialog-confirm")
630                .label(self.confirm_label.clone())
631                .variant(Variant::Primary)
632                .size(Size::Md);
633            if let Some(action) = confirm_action {
634                confirm = confirm.on_press(move |ev, window, cx| action(ev, window, cx));
635            }
636
637            actions = actions.child(cancel).child(confirm);
638            panel = panel.child(actions);
639        } else if !self.footer.is_empty() {
640            panel = panel.child(actions.children(self.footer));
641        }
642
643        let backdrop_bg = match self.backdrop {
644            Backdrop::Opaque => colors.backdrop,
645            Backdrop::Blur => colors.backdrop.alpha(colors.backdrop.a * 0.6),
646            Backdrop::Transparent => gpui::transparent_black(),
647        };
648
649        // `.alert-dialog__close-trigger` is `absolute end-4 top-4`. v3 renders
650        // a close affordance only where the caller composes the part; an
651        // omitted trigger leaves the spot bare panel padding.
652        for trigger in close_triggers {
653            panel = panel.child(div().absolute().top(px(16.)).right(px(16.)).child(trigger));
654        }
655
656        // Backdrop dismissal lives on the **panel**, exactly as in the modal
657        // and the drawer: gpui has no hitbox occlusion, so a click on the
658        // full-window backdrop would fire for a press on this panel too.
659        // `on_mouse_down_out` reads the panel's own bounds instead, so it
660        // only fires for a press on the dimmed region around the panel.
661        // `is_dismissible` is the whole gate — the close slot above is not —
662        // and the exit phase gets none: the dialog is already closing.
663        let dismiss: Option<crate::modal::OnClose> = if self.is_dismissible {
664            close_action.clone()
665        } else {
666            None
667        };
668        let panel = match (dismiss, exiting) {
669            (Some(on_dismiss), false) => util::dismiss_on_press_outside_with_token(
670                panel,
671                dismissal_token.clone(),
672                move |window, cx| {
673                    on_dismiss(&crate::modal::DismissReason::Backdrop, window, cx);
674                    util::DismissResult::Handled
675                },
676            ),
677            _ => panel,
678        };
679
680        // Escape is the `ModalOverlay`'s own dismissal, so it is a plain
681        // close too: `onOpenChange(false)`, never `onCancel`.
682        let keyboard_dismiss: Option<crate::modal::OnClose> = if self.is_keyboard_dismiss_disabled {
683            None
684        } else {
685            close_action.clone()
686        };
687
688        // `.alert-dialog__backdrop`, whose variants are the `Backdrop` enum.
689        // A bare scrim, like the modal's and the drawer's: the panel's
690        // `on_mouse_down_out` owns backdrop dismissal, and a second listener
691        // here would only double-report.
692        let backdrop = div()
693            .id(element_id::scoped(&self.id, "backdrop"))
694            .absolute()
695            .inset_0()
696            .bg(backdrop_bg);
697        // v3 fades the backdrop in alongside the panel, and out with it.
698        let backdrop = if exiting {
699            crate::anim::exiting(
700                backdrop,
701                "alert-dialog-backdrop-out",
702                crate::anim::ZoomBox::default(),
703                crate::anim::Motion::BACKDROP_OUT,
704                cx,
705            )
706        } else {
707            crate::anim::entering(
708                backdrop,
709                "alert-dialog-backdrop-anim",
710                crate::anim::Motion::BACKDROP_IN,
711                cx,
712            )
713        };
714
715        // `Tab` cycles the dialog's own controls; see `util::trap_tab`.
716        let mut overlay = util::trap_tab(
717            div()
718                .id(element_id::scoped(&self.id, "root"))
719                .absolute()
720                .inset_0()
721                .flex()
722                // `.alert-dialog__container` is `p-4 sm:p-10`.
723                .p(px(40.))
724                .track_focus(&focus_handle),
725            &focus_handle,
726        )
727        .when(
728            matches!(
729                self.placement,
730                ModalPlacement::Center | ModalPlacement::Auto
731            ),
732            |e| e.items_center().justify_center(),
733        )
734        .when(self.placement == ModalPlacement::Top, |e| {
735            e.items_start().justify_center()
736        })
737        .when(self.placement == ModalPlacement::Bottom, |e| {
738            e.items_end().justify_center()
739        })
740        .child(backdrop)
741        .child({
742            let mut zoom = crate::anim::ZoomBox::panel(px(24.), radius).padding_x(px(24.));
743            // The zoom scales a known box; `Cover` has no width of its own and
744            // the `ZoomBox` carries no height, so there is nothing to hand it —
745            // its enter/exit zoom rides the padding, radius and fade alone.
746            if let Some(w) = self.size.max_width() {
747                zoom = zoom.sized(w);
748            }
749            let (slide_x, slide_y) = crate::modal::placement_entry_offset(self.placement);
750            zoom.slide_x = (slide_x != 0.0).then(|| px(slide_x));
751            zoom.slide_y = (slide_y != 0.0).then(|| px(slide_y));
752            if exiting {
753                crate::anim::exiting(
754                    panel,
755                    "alert-dialog-panel-out",
756                    zoom,
757                    crate::anim::Motion::PANEL_OUT,
758                    cx,
759                )
760            } else {
761                crate::anim::entering_zoom(
762                    panel,
763                    "alert-dialog-panel",
764                    zoom,
765                    crate::anim::Motion::PANEL_IN,
766                    cx,
767                )
768            }
769        });
770        if let Some(on_escape) = keyboard_dismiss {
771            overlay =
772                util::dismiss_on_escape_with_token(overlay, dismissal_token, move |window, cx| {
773                    on_escape(&crate::modal::DismissReason::Escape, window, cx);
774                    util::DismissResult::Handled
775                });
776        }
777        overlay = util::apply_sx(overlay, &self.sx);
778        util::window_overlay(overlay, window).into_any_element()
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    /// `Hsla` carries no `PartialEq`; compare channel-wise.
787    fn assert_same_color(a: gpui::Hsla, b: gpui::Hsla) {
788        assert!((a.h - b.h).abs() < 1e-4, "{a:?} != {b:?}");
789        assert!((a.s - b.s).abs() < 1e-4, "{a:?} != {b:?}");
790        assert!((a.l - b.l).abs() < 1e-4, "{a:?} != {b:?}");
791        assert!((a.a - b.a).abs() < 1e-4, "{a:?} != {b:?}");
792    }
793
794    #[test]
795    fn icon_presentation_maps_every_status() {
796        let colors = herogpui_theme::ThemeColors::light();
797        // One row per status, with upstream's icon map: `AlertDialog.Icon`
798        // picks info for `default` and `accent`, then the success, warning
799        // and danger glyphs.
800        let expected = [
801            (Color::Default, icons::INFO_CIRCLE),
802            (Color::Accent, icons::INFO_CIRCLE),
803            (Color::Success, icons::CHECK_CIRCLE),
804            (Color::Warning, icons::WARNING_TRIANGLE),
805            (Color::Danger, icons::CIRCLE_EXCLAMATION),
806        ];
807        for (status, glyph) in expected {
808            let (bg, fg, actual) = icon_presentation(status, &colors);
809            assert_eq!(actual, glyph, "{status:?} must use the upstream glyph");
810            let role = match status {
811                Color::Default => {
812                    // `.alert-dialog__icon--default` is `bg-default
813                    // text-foreground` with the info glyph — not
814                    // `--default-soft`, not a role colour.
815                    assert_same_color(bg, colors.default.color);
816                    assert_same_color(fg, colors.foreground);
817                    continue;
818                }
819                Color::Accent => &colors.accent,
820                Color::Success => &colors.success,
821                Color::Warning => &colors.warning,
822                Color::Danger => &colors.danger,
823            };
824            assert_same_color(bg, role.soft());
825            assert_same_color(fg, role.soft_foreground(colors.foreground));
826        }
827    }
828}
829
830crate::util::impl_component_styled!(AlertDialog);