Skip to main content

herogpui_components/
close_button.rs

1//! CloseButton — port of `@heroui/close-button`.
2//!
3//! A button for dismissing dialogs, modals and inline content. Mirrors the
4//! React API: `variant`, `isDisabled`, `onPress` and a custom-icon slot that
5//! replaces the default close glyph.
6
7use gpui::{
8    div, prelude::*, px, AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement,
9    ParentElement, Pixels, RenderOnce, Styled, Window,
10};
11use herogpui_core::element_id;
12use herogpui_theme::ActiveTheme;
13
14use crate::a11y::{self, A11y as _};
15use crate::icons;
16
17/// Visual variant of a close button. React exposes a single `default` variant.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub enum CloseButtonVariant {
20    #[default]
21    /// The default close button style.
22    Default,
23}
24
25/// A press handler. `Arc` rather than `Box` because it is bound twice: the
26/// pointer's `on_click` and the keyboard's Enter/Space both run it.
27type OnPress = std::sync::Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
28
29/// HeroUI CloseButton.
30#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
31#[derive(IntoElement)]
32pub struct CloseButton {
33    id: ElementId,
34    is_disabled: bool,
35    /// Replaces the default close glyph (`children` in React).
36    icon: Option<AnyElement>,
37    /// v3's `children`-as-a-function: handed the interactive state and drawn in
38    /// place of the default content.
39    content: Option<std::sync::Arc<dyn Fn(crate::util::InteractiveState) -> AnyElement + 'static>>,
40
41    on_press: Option<OnPress>,
42    /// The `sx` slot, refined over the root style at the end of render.
43    sx: Option<Box<gpui::StyleRefinement>>,
44    /// Set by [`CloseButton::hover_bg`]: the fill the hover fade eases *to*.
45    /// Additive — unset, the fade behaves exactly as it did before.
46    hover_bg: Option<gpui::Hsla>,
47    /// The corner radius, in place of the owning `small_radius` helper. The
48    /// pressed box scales it along with the box.
49    radius: Option<Pixels>,
50}
51
52impl CloseButton {
53    /// Creates a close button with the given id.
54    pub fn new(id: impl Into<ElementId>) -> Self {
55        Self {
56            id: id.into(),
57            is_disabled: false,
58            icon: None,
59            content: None,
60            on_press: None,
61            sx: None,
62            hover_bg: None,
63            radius: None,
64        }
65    }
66
67    /// The one slot for caller-owned low-level styling: GPUI's styling methods
68    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
69    /// applied to the close button's root element after every value the active
70    /// theme chose, so they win.
71    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
72        crate::util::refine_sx(&mut self.sx, style);
73        self
74    }
75
76    /// The fill the hover fade eases to, in place of `--default-hover`.
77    ///
78    /// The fade runs from the resting background — the `sx` background when one
79    /// is set, the close button's own `--default` otherwise — to `color`, over
80    /// the same `transition-colors` timing the stock button uses. v3 has no
81    /// such prop; on the web this is `className="hover:bg-…"`.
82    pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
83        self.hover_bg = Some(color.into());
84        self
85    }
86
87    /// Sets whether the button is disabled.
88    pub fn is_disabled(mut self, v: bool) -> Self {
89        self.is_disabled = v;
90        self
91    }
92
93    /// The corner radius, in place of the owning `small_radius` helper. The
94    /// box is the button's whole shape, so the pressed box scales the same
95    /// value rather than snapping back to the helper. Not a v3 prop; the
96    /// removed v2 `radius` prop is prohibited and this is a per-component
97    /// repository extension.
98    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
99        self.radius = Some(radius.into());
100        self
101    }
102
103    /// Supplies a custom icon in place of the default close glyph.
104    /// v3's render function for the button's children, handed `isHovered`,
105    /// `isPressed`, `isFocused` and `isDisabled`. The hover and press are a
106    /// frame behind the pointer, because gpui reports both to a handler.
107    pub fn content(
108        mut self,
109        render: impl Fn(crate::util::InteractiveState) -> AnyElement + 'static,
110    ) -> Self {
111        self.content = Some(std::sync::Arc::new(render));
112        self
113    }
114
115    /// Replaces the default close icon.
116    pub fn icon(mut self, icon: impl IntoElement) -> Self {
117        self.icon = Some(icon.into_any_element());
118        self
119    }
120
121    /// Sets the handler run when the button is pressed.
122    pub fn on_press(
123        mut self,
124        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
125    ) -> Self {
126        self.on_press = Some(std::sync::Arc::new(handler));
127        self
128    }
129}
130
131impl RenderOnce for CloseButton {
132    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
133        // `.close-button:focus-visible` is `status-focused`. The handle has to be
134        // read before the theme tokens: `use_keyed_state` takes `cx` mutably.
135        let focus_handle =
136            crate::util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx);
137        let interaction = self.content.as_ref().map(|_| {
138            crate::util::interaction(element_id::scoped(&self.id, "interaction"), window, cx)
139        });
140        if self.is_disabled {
141            if let Some(slot) = &interaction {
142                if *slot.read(cx) != (false, false) {
143                    slot.update(cx, |state, _| *state = (false, false));
144                }
145            }
146        }
147
148        let colors = cx.colors().clone();
149        // The box is the button's whole shape, so the press-scale derivation
150        // below multiplies this resolved value rather than the helper's.
151        let radius = self.radius.unwrap_or_else(|| crate::util::small_radius(cx));
152        let sx_corners = crate::util::sx_radius(&self.sx);
153        let disabled_opacity = cx.layout().disabled_opacity;
154        // `.close-button` is `h-6 p-1` with a `size-4` glyph.
155        let (box_size, icon_size) = (px(24.), px(16.));
156        // The `sx` slot refines the root, so its background is the resting
157        // value the fade must hold; an explicit `hover_bg` eases from it.
158        let sx_background = crate::util::sx_background(&self.sx);
159        let idle_bg = sx_background.unwrap_or(colors.default.color);
160        let hover_end = self.hover_bg.unwrap_or(colors.default.hover());
161        let fade = (!self.is_disabled)
162            .then_some((colors.default.color, colors.default.hover()))
163            .and_then(|pair| crate::util::fade_endpoints(Some(pair), sx_background, self.hover_bg));
164
165        let mut el = div()
166            .id(self.id.clone())
167            // `close-button.js` hard-codes `aria-label="Close"` on the RAC
168            // `Button`, because the default child is an icon with no text.
169            .a11y_named(a11y::Role::Button, &a11y::Name::labelled(crate::i18n::ui_string(crate::i18n::UiString::Close, cx)))
170            .debug_selector({
171                let id = self.id.clone();
172                move || format!("{id:?}")
173            })
174            .flex()
175            .items_center()
176            .justify_center()
177            .flex_shrink_0()
178            .size(box_size)
179            .p(px(4.))
180            .rounded(radius)
181            .map(|el| crate::util::round_sx_corners(el, &sx_corners))
182            .when(fade.is_none(), |e| e.bg(idle_bg))
183            .text_color(colors.muted);
184
185        if let Some(fade_colors) = fade {
186            el = crate::anim::hover_fade(
187                el,
188                element_id::scoped(&self.id, "fade"),
189                fade_colors,
190                interaction.as_ref(),
191                None,
192                move |fill| crate::util::round_sx_corners(fill.rounded(radius), &sx_corners),
193                window,
194                cx,
195            );
196        }
197
198        if self.is_disabled {
199            el = el.opacity(disabled_opacity);
200        } else if fade.is_none() {
201            el = el.hover(move |s| s.bg(hover_end));
202        }
203
204        el = match (self.content.clone(), self.icon) {
205            (Some(render), _) => {
206                let (is_hovered, is_pressed) = interaction
207                    .as_ref()
208                    .map(|slot| *slot.read(cx))
209                    .unwrap_or_default();
210                let is_focused = !self.is_disabled && focus_handle.is_focused(window);
211                el.child(render(crate::util::InteractiveState {
212                    is_hovered,
213                    is_pressed,
214                    is_focused,
215                    is_focus_visible: is_focused && crate::util::focus_visible(cx),
216                    is_selected: false,
217                    is_disabled: self.is_disabled,
218                    is_pending: false,
219                    is_indeterminate: false,
220                }))
221            }
222            (None, Some(icon)) => el.child(icon),
223            (None, None) => el.child(
224                gpui::svg()
225                    .debug_selector({
226                        let id = self.id.clone();
227                        move || format!("{id:?}-icon")
228                    })
229                    .size(icon_size)
230                    .flex_shrink_0()
231                    // `.close-button svg` is `-mx-0.5 my-0.5` (2px at the 16px
232                    // root). Symmetric margins on a centred 16px child in the
233                    // 16px `p-1` content box cancel, but they are representable
234                    // on `svg()` through Styled and belong on the default glyph.
235                    .mx(px(-2.))
236                    .my(px(2.))
237                    .path(icons::CLOSE)
238                    .text_color(colors.muted),
239            ),
240        };
241        if !self.is_disabled {
242            // `.close-button--default:active, &[data-pressed="true"]` is
243            // `transform: scale(0.93)` about `origin-center`, riding
244            // `transition: transform 250ms var(--ease-out-quart)`. The pinned
245            // sheet's pressed state changes no background, so this is the one
246            // animated track. gpui has no div-level scale, so the press
247            // shrinks the 24px skin about its centre inside a stable slot —
248            // the same geometry `anim::pressed` uses — and the ramp eases it
249            // the way the stylesheet's transition does. Reduced motion snaps
250            // to the scaled endpoint, matching
251            // `motion-reduce:transition-none`, which removes the timing but
252            // keeps the transform. The wrap comes after every visual child:
253            // children added after it land on the slot and fight the skin for
254            // its width.
255            const PRESS_SCALE: f32 = 0.93;
256            let pressed_radius = px(f32::from(radius) * PRESS_SCALE);
257            debug_assert!(pressed_radius <= radius);
258            // Reapply the caller's corner refinement on the skin immediately
259            // before the press wrapper. This keeps the painted skin, hover
260            // fill and the press slot on the same resolved shape.
261            el = crate::util::round_sx_corners(el, &sx_corners);
262            el = crate::anim::pressed_with_background_ramp(
263                el,
264                crate::anim::PressBox {
265                    height: box_size,
266                    padding_x: None,
267                    width: Some(box_size),
268                    min_width: None,
269                    text_size: icon_size,
270                    line_height: icon_size,
271                    gap: px(0.),
272                    radius,
273                    shrink_x: true,
274                    scale: PRESS_SCALE,
275                },
276                None,
277                crate::anim::CLOSE_BUTTON_PRESS,
278                interaction.as_ref(),
279                window,
280                cx,
281            );
282        }
283
284        if !self.is_disabled {
285            // The interaction tracking (hover, mouse/keyboard press) belongs
286            // on the press slot, like Button: the handlers see the whole
287            // resting footprint, and the skin below is its listener-free
288            // visual child. The ramp wired its own tracking already unless a
289            // `content` closure's slot exists.
290            el = crate::util::cursor_interactive(el, cx);
291            if let Some(slot) = &interaction {
292                el = crate::util::track_interaction(el, slot);
293            }
294        }
295
296        if let Some(on_press) = self.on_press {
297            if !self.is_disabled {
298                el = el.on_click(move |ev: &ClickEvent, window, cx| on_press(ev, window, cx));
299            }
300        }
301
302        if self.is_disabled {
303            let root = div()
304                .size(box_size)
305                .flex()
306                .flex_shrink_0()
307                .items_center()
308                .justify_center()
309                .child(el);
310            return crate::util::apply_sx(root, &self.sx);
311        }
312        // `.close-button:focus-visible` is `status-focused`: the ring and the
313        // focus tracking land on the press slot — the element the ramp
314        // returned — so keyboard activation and pointer activation answer on
315        // the same element and the ring draws around the resting footprint.
316        // The press slot is both the element the ramp rounded to `radius` and
317        // the one that takes the focus, so the overlay goes there: it stays
318        // concentric with the resting footprint while the skin inside it
319        // scales under a press.
320        let el = crate::util::ring_overlay_if_focused(
321            el.track_focus(&focus_handle),
322            &focus_handle,
323            true,
324            radius,
325            Vec::new(),
326            window,
327            cx,
328        );
329        let el = crate::util::record_focus_bounds(el, &focus_handle, window, cx);
330        let root = div()
331            .size(box_size)
332            .flex()
333            .flex_shrink_0()
334            .items_center()
335            .justify_center()
336            .child(el);
337        crate::util::apply_sx(root, &self.sx)
338    }
339}
340
341crate::util::impl_component_styled!(CloseButton);