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