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