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