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