Skip to main content

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}