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);