herogpui_components/button_group.rs
1//! ButtonGroup — port of `@heroui/button-group` (v3).
2//!
3//! Joined buttons sharing one outer radius. The pinned root hands `variant`,
4//! `size`, `isDisabled` and `fullWidth` to its direct `Button` children through
5//! context as *defaults* (`button.tsx` merges `prop ?? context.prop`), so a
6//! member's own explicit value — including `isDisabled={false}`,
7//! `fullWidth={false}` and any Button variant, `outline` included — always
8//! wins. Members reached through [`ButtonGroup::button`] are those typed direct
9//! children.
10
11use gpui::{
12 div, prelude::*, px, AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce,
13 Styled, Window,
14};
15use herogpui_core::{Orientation, Size, Variant};
16
17use crate::{
18 a11y::{self, A11y as _},
19 button::Button,
20 util,
21};
22
23/// The variant whose foreground the separator drawn inside a member's slot
24/// takes. v3 composes `ButtonGroup.Separator` as a child of the member that
25/// follows the seam and paints it `bg-current`, so the hairline inherits that
26/// member's currentColor — its own variant once
27/// [`Button::group_defaults`](crate::button::Button::group_defaults) has
28/// resolved it — rather than one group-wide colour. A type-erased child
29/// receives no context in v3 either, so its slot falls back to the group's
30/// variant.
31pub(crate) fn separator_variant(member: Option<Variant>, group: Variant) -> Variant {
32 member.unwrap_or(group)
33}
34
35/// HeroUI ButtonGroup.
36#[derive(IntoElement)]
37pub struct ButtonGroup {
38 variant: Variant,
39 size: Size,
40 /// Whether a `ButtonGroup.Separator` is drawn before each member after the
41 /// first. v3 composes it as a child of the member that follows it, so this
42 /// is the slot flag rather than a documented prop. Defaults to none, as a
43 /// group without `Separator` children draws no dividers.
44 separators: bool,
45 is_disabled: bool,
46 orientation: Orientation,
47 full_width: bool,
48 /// Buttons added through [`ButtonGroup::button`], which inherit the
49 /// group's `variant`, `size`, `isDisabled` and `fullWidth` unless they set
50 /// their own.
51 buttons: Vec<Button>,
52 children: Vec<AnyElement>,
53 id: Option<ElementId>,
54 /// The `sx` slot, refined over the root style at the end of render.
55 sx: Option<Box<gpui::StyleRefinement>>,
56}
57
58impl ButtonGroup {
59 /// `isDisabled` — disables every member.
60 pub fn is_disabled(mut self, v: bool) -> Self {
61 self.is_disabled = v;
62 self
63 }
64
65 /// `orientation` — a vertical group stacks its buttons.
66 pub fn orientation(mut self, orientation: Orientation) -> Self {
67 self.orientation = orientation;
68 self
69 }
70
71 pub fn new() -> Self {
72 Self {
73 variant: Variant::Primary,
74 size: Size::Md,
75 separators: false,
76 is_disabled: false,
77 orientation: Orientation::Horizontal,
78 full_width: false,
79 buttons: Vec::new(),
80 children: Vec::new(),
81 id: None,
82 sx: None,
83 }
84 }
85
86 /// Names this group so it can report `role="group"`. Unnamed groups
87 /// produce no AccessKit node — a constant id would fold every instance
88 /// into one.
89 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
90 self.id = Some(id.into());
91 self
92 }
93
94 /// The variant every member inherits unless that button sets its own.
95 pub fn variant(mut self, variant: Variant) -> Self {
96 self.variant = variant;
97 self
98 }
99
100 pub fn size(mut self, size: Size) -> Self {
101 self.size = size;
102 self
103 }
104
105 pub fn full_width(mut self, v: bool) -> Self {
106 self.full_width = v;
107 self
108 }
109
110 /// Adds a button that inherits the group's `variant` and `size`.
111 pub fn button(mut self, button: Button) -> Self {
112 self.buttons.push(button);
113 self
114 }
115
116 /// `ButtonGroup.Separator` — the hairline before each member after the
117 /// first.
118 ///
119 /// v3 composes it as a child of whichever member should show one, and
120 /// `ButtonGroupRoot` synthesizes none of its own, so this port spells the
121 /// composition as a flag. Defaults to false: a group only draws dividers
122 /// when its example composes them.
123 pub fn separators(mut self, v: bool) -> Self {
124 self.separators = v;
125 self
126 }
127
128 /// The one slot for caller-owned low-level styling: GPUI's styling methods
129 /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
130 /// applied to the group's root element after every value the orientation
131 /// and the full-width layout chose, so they win.
132 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
133 self.sx = Some(util::capture_sx(style));
134 self
135 }
136}
137
138impl Default for ButtonGroup {
139 fn default() -> Self {
140 Self::new()
141 }
142}
143
144impl ParentElement for ButtonGroup {
145 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
146 self.children.extend(elements);
147 }
148}
149
150impl RenderOnce for ButtonGroup {
151 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
152 let vertical = !self.orientation.is_horizontal();
153 // `.button-group` is `inline-flex items-center justify-center gap-0`
154 // and nothing else -- no border, no radius, no background. Each member
155 // keeps its own fill, and the outer corners come from the first and last
156 // of them. This used to draw a bordered, radius-clipped box with the
157 // members overlapped by -1px, which is not a shape v3 has.
158 // A vertical column that kept `items-center` would leave every member
159 // at its own content width, stacking mixed-width labels into a
160 // staircase instead of one joined control, so the column stretches its
161 // members' cross axis and each slot below stretches the member inside
162 // it. Members with a definite width (icon-only) keep it, exactly as
163 // they keep `w-fit` under v3's `items-center`.
164 let mut el = div().flex().justify_center();
165 el = if vertical {
166 el.flex_col().items_stretch()
167 } else {
168 el.flex_row().items_center()
169 };
170 if self.full_width {
171 el = el.w_full();
172 }
173
174 let variant = self.variant;
175 let size = self.size;
176 let disabled = self.is_disabled;
177 let inherited: Vec<Button> = self.buttons;
178 let extra: Vec<AnyElement> = self.children;
179 let total = inherited.len() + extra.len();
180
181 // `.button-group__separator` is `bg-current opacity-15`, 1px by 50% of
182 // the member, sitting one pixel before its leading edge. `bg-current`
183 // resolves per member — `separator_variant` below decides which.
184 let separator_radius = util::hairline_radius(cx);
185 let separators = self.separators;
186
187 let edge = move |i: usize| {
188 if total <= 1 {
189 crate::button::GroupEdge::Only
190 } else if i == 0 {
191 crate::button::GroupEdge::Start
192 } else if i + 1 == total {
193 crate::button::GroupEdge::End
194 } else {
195 crate::button::GroupEdge::Middle
196 }
197 };
198
199 // The edge and each member's resolved width have to reach the `Button`
200 // before it is erased to an `AnyElement`, so the members are built
201 // index-first. The width is v3's `finalFullWidth = fullWidth ??
202 // context.fullWidth`: only a member that resolves to full width takes
203 // a stretch slot, which is what frees an explicit child
204 // `full_width(false)` from the group's equal division. Each slot also
205 // carries the member's resolved variant — the colour its separator
206 // inherits — or `None` for a type-erased child, which receives no
207 // context in v3 either.
208 let mut members: Vec<(bool, Option<Variant>, AnyElement)> = Vec::with_capacity(total);
209 for (i, b) in inherited.into_iter().enumerate() {
210 let b = b.group_defaults(variant, size, disabled, self.full_width);
211 let member_full = b.is_full_width();
212 let slot_variant = b.resolved_variant();
213 let el = b.group_edge(edge(i), vertical).into_any_element();
214 members.push((member_full, Some(slot_variant), el));
215 }
216 members.extend(extra.into_iter().map(|child| (false, None, child)));
217
218 let mut wrapped: Vec<gpui::Div> = Vec::with_capacity(total);
219 for (i, (member_full, slot_variant, child)) in members.into_iter().enumerate() {
220 let mut slot = div().relative().child(child);
221 // Names the laid-out slot for behaviour tests (`debug_bounds`);
222 // a no-op outside test-support.
223 slot = slot.debug_selector(move || format!("button-group-slot-{i}"));
224 if vertical {
225 // The root's stretch gives the slot the column width; this
226 // hands it on to a member whose own width is still auto.
227 slot = slot.flex().flex_col().items_stretch();
228 } else if member_full {
229 slot = slot.flex_1();
230 }
231 if separators && i > 0 {
232 let separator_color =
233 crate::button::button_foreground(separator_variant(slot_variant, variant), cx)
234 .alpha(0.15);
235 slot = slot.child(
236 div()
237 .absolute()
238 .bg(separator_color)
239 .rounded(separator_radius)
240 .map(|s| {
241 if vertical {
242 s.left(gpui::relative(0.25))
243 .top(px(-1.))
244 .w(gpui::relative(0.5))
245 .h(px(1.))
246 } else {
247 s.left(px(-1.))
248 .top(gpui::relative(0.25))
249 .w(px(1.))
250 .h(gpui::relative(0.5))
251 }
252 }),
253 );
254 }
255 wrapped.push(slot);
256 }
257 el = el.children(wrapped);
258 el = util::apply_sx(el, &self.sx);
259 match self.id {
260 Some(id) => el.id(id).a11y(a11y::Role::Group).into_any_element(),
261 None => el.into_any_element(),
262 }
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn separators_are_explicit_composition() {
272 assert!(
273 !ButtonGroup::new().separators,
274 "v3 groups without a Separator child must not synthesize dividers"
275 );
276 assert!(ButtonGroup::new().separators(true).separators);
277 }
278
279 #[test]
280 fn outline_variant_reaches_grouped_buttons() {
281 assert_eq!(
282 ButtonGroup::new().variant(Variant::Outline).variant,
283 Variant::Outline,
284 "pinned ButtonGroup source accepts Button's outline variant"
285 );
286 }
287
288 /// v3's separator is `bg-current`, so the hairline composed into a
289 /// member's slot takes that member's resolved variant foreground — the
290 /// very property `Button::group_defaults` computes — and a type-erased
291 /// child's slot falls back to the group variant.
292 #[test]
293 fn separator_variant_follows_its_member_slot() {
294 assert_eq!(
295 separator_variant(Some(Variant::Danger), Variant::Secondary),
296 Variant::Danger,
297 "a typed member's resolved variant owns its separator colour"
298 );
299 assert_eq!(
300 separator_variant(Some(Variant::Outline), Variant::Primary),
301 Variant::Outline
302 );
303 assert_eq!(
304 separator_variant(None, Variant::Secondary),
305 Variant::Secondary,
306 "a type-erased child receives no context and falls back to the group variant"
307 );
308 }
309}