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