Skip to main content

herogpui_components/
button.rs

1//! Button — port of `@heroui/button` (v3).
2//!
3//! v3 replaced v2's `variant` x `color` matrix with a single emphasis scale:
4//! `primary | secondary | tertiary | outline | ghost | danger | danger-soft`.
5//! There is no `color` or `radius` prop, `isLoading` became `isPending`, and
6//! v2's `startContent`/`endContent` slots are gone: icons are ordered
7//! [`ParentElement`] children around the label.
8
9use gpui::{
10    div, prelude::*, AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement,
11    ParentElement, Pixels, Refineable, RenderOnce, SharedString, Stateful, Styled, Window,
12};
13use herogpui_core::{element_id, Size, Variant};
14use herogpui_theme::ActiveTheme;
15
16use crate::a11y::{self, A11y as _};
17use crate::util;
18
19/// A press handler. `Arc` rather than `Box` because it is bound twice: the
20/// pointer's `on_click` and the keyboard's Enter/Space both run it.
21type OnPress = std::sync::Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
22
23/// Which edge of a [`crate::button_group::ButtonGroup`] a button sits on.
24///
25/// `.button-group .button` is `rounded-none`; the first member takes
26/// `rounded-s-3xl` and the last `rounded-e-3xl`, so a joined group has one
27/// outer radius rather than a rounded box per member. The press scale is also
28/// off inside a group (`.button-group .button:active { transform: none }`).
29#[derive(Clone, Copy, PartialEq, Eq, Debug)]
30pub enum GroupEdge {
31    /// First member: the leading corners are round.
32    Start,
33    /// Between two others: square on both ends.
34    Middle,
35    /// Last member: the trailing corners are round.
36    End,
37    /// The only member, so it keeps the full radius.
38    Only,
39}
40
41/// HeroUI Button.
42#[derive(IntoElement)]
43pub struct Button {
44    id: ElementId,
45    label: Option<SharedString>,
46    /// v3's `children`-as-a-function: handed `{isHovered, isPressed, isFocused,
47    /// isFocusVisible, isDisabled, isPending}` and drawn in place of the label.
48    content: Option<std::sync::Arc<dyn Fn(util::InteractiveState) -> AnyElement + 'static>>,
49    variant: Variant,
50    variant_is_set: bool,
51    size: Size,
52    size_is_set: bool,
53    full_width: bool,
54    /// Set by [`Button::full_width`]. ButtonGroup context supplies width as a
55    /// *default* (`button.tsx`: `finalFullWidth = fullWidth ??
56    /// context.fullWidth`), so this flag is what keeps an explicit child
57    /// `full_width(false)` from being overwritten by a full-width group.
58    full_width_is_set: bool,
59    is_icon_only: bool,
60    /// Set by [`crate::button_group::ButtonGroup`]: which end of the group this
61    /// button is, and whether the group stacks.
62    group_edge: Option<(GroupEdge, bool)>,
63    is_disabled: bool,
64    is_disabled_is_set: bool,
65    is_pending: bool,
66    children: Vec<AnyElement>,
67    on_press: Option<OnPress>,
68    /// The `sx` slot, refined over the root style at the end of render.
69    sx: Option<Box<gpui::StyleRefinement>>,
70    /// Set by [`Button::hover_bg`]: the fill the hover fade eases *to*, in
71    /// place of the variant's hover colour. Additive — unset, the fade behaves
72    /// exactly as it did before the builder existed.
73    hover_bg: Option<gpui::Hsla>,
74    /// The corner radius, in place of `--radius-3xl` (capped). Group edges and
75    /// the press scale still apply.
76    radius: Option<Pixels>,
77    /// Set by [`Button::width`]: the fixed pixel width.
78    width: Option<Pixels>,
79    /// Set by [`Button::min_width`]: the width floor.
80    min_width: Option<Pixels>,
81    /// Set by [`Button::height`]: the fixed pixel height.
82    height: Option<Pixels>,
83    /// Set by [`Button::padding_x`]: the horizontal inset.
84    padding_x: Option<Pixels>,
85    /// Set by [`Button::text_size`]: the label's font size.
86    text_size: Option<Pixels>,
87    /// Set by [`Button::font_weight`]: the label's font weight.
88    font_weight: Option<gpui::FontWeight>,
89    /// Set by [`Button::grow`]: `flex-1` plus `min-w-0`.
90    grow: bool,
91    recipes: Vec<SharedString>,
92}
93
94impl Button {
95    pub fn new(id: impl Into<ElementId>) -> Self {
96        Self {
97            id: id.into(),
98            label: None,
99            content: None,
100            variant: Variant::Primary,
101            variant_is_set: false,
102            size: Size::Md,
103            size_is_set: false,
104            full_width: false,
105            full_width_is_set: false,
106            is_icon_only: false,
107            group_edge: None,
108            is_disabled: false,
109            is_disabled_is_set: false,
110            is_pending: false,
111            children: Vec::new(),
112            on_press: None,
113            sx: None,
114            hover_bg: None,
115            radius: None,
116            width: None,
117            min_width: None,
118            height: None,
119            padding_x: None,
120            text_size: None,
121            font_weight: None,
122            grow: false,
123            recipes: Vec::new(),
124        }
125    }
126
127    /// v3's render function for a button's children, handed `isHovered`,
128    /// `isPressed`, `isFocused`, `isFocusVisible`, `isDisabled` and `isPending`.
129    ///
130    /// The hover and the press are a frame behind the pointer: gpui reports both
131    /// to a handler, so the render that draws them can only read what the last
132    /// frame recorded. The button's own hover and press styling does not go
133    /// through this -- it is applied by gpui in the same frame.
134    pub fn content(
135        mut self,
136        render: impl Fn(util::InteractiveState) -> AnyElement + 'static,
137    ) -> Self {
138        self.content = Some(std::sync::Arc::new(render));
139        self
140    }
141
142    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
143        self.label = Some(label.into());
144        self
145    }
146
147    /// The visual style.
148    ///
149    /// Setting this at the call site suppresses
150    /// [`herogpui_theme::ButtonStyle::variant`] from
151    /// every recipe on the button — the instance is the more specific source,
152    /// so it wins, the same way [`Button::hover_bg`] outranks a recipe's
153    /// `hover_bg`. The surprise is what goes with the variant: each one
154    /// derives its own hover shade, so
155    /// `.variant(Variant::Primary).recipe("accented")` keeps *primary's* hover
156    /// even when the recipe was written to change it through its variant. A
157    /// recipe that must change the hover names it with
158    /// [`herogpui_theme::ButtonStyle::hover_bg`], which is honoured whatever variant is in
159    /// force; a hover that should follow a whole role everywhere belongs on
160    /// the role instead, through
161    /// [`herogpui_theme::ThemeBuilder::role_hover`].
162    pub fn variant(mut self, variant: Variant) -> Self {
163        self.variant = variant;
164        self.variant_is_set = true;
165        self
166    }
167
168    pub fn size(mut self, size: Size) -> Self {
169        self.size = size;
170        self.size_is_set = true;
171        self
172    }
173
174    /// Fills the parent with `w-full`. An explicit [`Button::width`] is the
175    /// more specific source and wins when both are set.
176    pub fn full_width(mut self, v: bool) -> Self {
177        self.full_width = v;
178        self.full_width_is_set = true;
179        self
180    }
181
182    pub fn is_icon_only(mut self, v: bool) -> Self {
183        self.is_icon_only = v;
184        self
185    }
186
187    /// The button's fixed pixel width, in place of the content-fit ladder.
188    ///
189    /// Beats [`Button::full_width`] when both are set: `full_width` fills the
190    /// parent, a pixel width fixes the box, and the pixel width is the more
191    /// specific source. It also replaces the icon-only square. The pressed
192    /// skin keeps the fixed box instead of snapping back to the ladder, and a
193    /// matching `sx` width still refines the root last.
194    ///
195    /// Not a v3 prop; a per-component repository extension like
196    /// [`Button::radius`].
197    pub fn width(mut self, w: impl Into<Pixels>) -> Self {
198        self.width = Some(w.into());
199        self
200    }
201
202    /// The button's width floor, under the content-fit ladder, an explicit
203    /// [`Button::width`] and `grow`'s zero floor alike.
204    ///
205    /// Not a v3 prop; a per-component repository extension like
206    /// [`Button::radius`].
207    pub fn min_width(mut self, w: impl Into<Pixels>) -> Self {
208        self.min_width = Some(w.into());
209        self
210    }
211
212    /// The button's fixed pixel height, in place of the size ladder's control
213    /// height. Beats what [`Button::size`] derives; a matching `sx` height
214    /// still refines the root last, and the pressed skin keeps the fixed box.
215    ///
216    /// Not a v3 prop; a per-component repository extension like
217    /// [`Button::radius`].
218    pub fn height(mut self, h: impl Into<Pixels>) -> Self {
219        self.height = Some(h.into());
220        self
221    }
222
223    /// The button's horizontal inset, in place of the size ladder's `px-4`
224    /// (`px-3` on `--sm`). Beats what [`Button::size`] derives and feeds the
225    /// pressed skin's inset geometry; a matching `sx` padding still refines
226    /// the root last.
227    ///
228    /// Not a v3 prop; a per-component repository extension like
229    /// [`Button::radius`].
230    pub fn padding_x(mut self, p: impl Into<Pixels>) -> Self {
231        self.padding_x = Some(p.into());
232        self
233    }
234
235    /// The label's font size; unset keeps the size ladder's pair (`text-sm`,
236    /// stepping to `text-base` on `--lg`). Beats what [`Button::size`]
237    /// derives. A Tailwind step keeps its paired leading through
238    /// `util::leading_for`; other sizes keep the size step's leading, the
239    /// same convention [`crate::chip::Chip::text_size`] records.
240    ///
241    /// Not a v3 prop; a per-component repository extension like
242    /// [`Button::radius`].
243    pub fn text_size(mut self, size: impl Into<Pixels>) -> Self {
244        self.text_size = Some(size.into());
245        self
246    }
247
248    /// The label's font weight, in place of `.button`'s `font-medium`. Beats
249    /// what [`Button::size`] derives; a matching `sx` weight still refines the
250    /// root last.
251    ///
252    /// Not a v3 prop; a per-component repository extension like
253    /// [`Button::radius`].
254    pub fn font_weight(mut self, weight: gpui::FontWeight) -> Self {
255        self.font_weight = Some(weight);
256        self
257    }
258
259    /// Fills the row's free width: v3's `flex-1` plus `min-w-0`, the pair a
260    /// caller otherwise has to reach for `sx` to spell. The button shares the
261    /// free width of its flex parent instead of overflowing it, and may
262    /// compress below its own content width — exactly what the `min-w-0` half
263    /// is for.
264    ///
265    /// Coexists with [`Button::full_width`]: `full_width` pins the box to
266    /// 100% of the parent, `grow` shares whatever is left over after the
267    /// siblings.
268    ///
269    /// Not a v3 prop; a per-component repository extension like
270    /// [`Button::radius`].
271    pub fn grow(mut self, v: bool) -> Self {
272        self.grow = v;
273        self
274    }
275
276    /// The one slot for caller-owned low-level styling: GPUI's styling methods
277    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
278    /// applied to the button's root element after every value the variant and
279    /// the active theme chose, so they win. An overridden background also
280    /// replaces the hover fade's endpoints and an overridden pixel size the
281    /// press geometry, so the override holds across states.
282    pub fn sx(mut self, style: impl FnOnce(Div) -> Div) -> Self {
283        self.sx = Some(util::capture_sx(style));
284        self
285    }
286
287    /// The fill the hover fade eases *to*, in place of the variant's own hover
288    /// colour.
289    ///
290    /// The escape hatch for a caller-owned surface: `sx`'s background replaces
291    /// both of the fade's endpoints, because a fill that eased back to the
292    /// variant colour would paint over the override — so an `sx` background
293    /// alone is a button whose hover does not move. Naming the hover colour
294    /// restores the transition: the fade runs from the resting background (the
295    /// `sx` background when one is set, the variant's resting colour
296    /// otherwise) to `color`, over the same `transition-colors` timing every
297    /// other button uses. The press state is unaffected either way — v3's
298    /// `:active` is the opacity step [`apply_button_variant`] applies, not a
299    /// third colour.
300    ///
301    /// v3 has no such prop; on the web this is `className="hover:bg-…"`.
302    pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
303        self.hover_bg = Some(color.into());
304        self
305    }
306
307    /// The corner radius, in place of `--radius-3xl` (capped). Group edges and
308    /// the press scale still apply. Not a v3 prop; the removed v2 `radius`
309    /// prop is prohibited and this is a per-component repository extension.
310    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
311        self.radius = Some(radius.into());
312        self
313    }
314
315    /// Named theme overlay from [`herogpui_theme::ComponentThemes::button`].
316    /// Stackable; a missing name adds no override.
317    pub fn recipe(mut self, name: impl Into<SharedString>) -> Self {
318        self.recipes.push(name.into());
319        self
320    }
321
322    /// Joins this button to a group edge. Internal: a caller reaches it by
323    /// putting the button in a [`crate::button_group::ButtonGroup`].
324    pub(crate) fn group_edge(mut self, edge: GroupEdge, vertical: bool) -> Self {
325        self.group_edge = Some((edge, vertical));
326        self
327    }
328
329    /// Applies ButtonGroup context values only where the child did not set its
330    /// own prop, matching React's direct-child context precedence.
331    pub(crate) fn group_defaults(
332        mut self,
333        variant: Variant,
334        size: Size,
335        is_disabled: bool,
336        full_width: bool,
337    ) -> Self {
338        if !self.variant_is_set {
339            self.variant = variant;
340            self.variant_is_set = true;
341        }
342        if !self.size_is_set {
343            self.size = size;
344            self.size_is_set = true;
345        }
346        if !self.is_disabled_is_set {
347            self.is_disabled = is_disabled;
348        }
349        if !self.full_width_is_set {
350            self.full_width = full_width;
351        }
352        self
353    }
354
355    /// The member's resolved width after [`Self::group_defaults`]: an explicit
356    /// child value when one was set, the group's `fullWidth` otherwise.
357    pub(crate) fn is_full_width(&self) -> bool {
358        self.full_width
359    }
360
361    /// The member's resolved variant after [`Self::group_defaults`]: an
362    /// explicit child value when one was set, the group's otherwise.
363    /// ButtonGroup reads it for the member's `bg-current` separator colour.
364    pub(crate) fn resolved_variant(&self) -> Variant {
365        self.variant
366    }
367
368    pub fn is_disabled(mut self, v: bool) -> Self {
369        self.is_disabled = v;
370        self.is_disabled_is_set = true;
371        self
372    }
373
374    /// `isPending` — blocks presses and hover while retaining the tab stop and focus ring.
375    /// A `content` closure receives the pending state and owns any loading indicator.
376    pub fn is_pending(mut self, v: bool) -> Self {
377        self.is_pending = v;
378        self
379    }
380
381    pub fn on_press(
382        mut self,
383        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
384    ) -> Self {
385        self.on_press = Some(std::sync::Arc::new(handler));
386        self
387    }
388}
389
390impl ParentElement for Button {
391    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
392        self.children.extend(elements);
393    }
394}
395
396/// Paints a button's fill, border, text and interaction states for `variant`.
397///
398/// Shared with `ButtonGroup`, which propagates the same variant to its members.
399pub fn apply_button_variant(
400    el: Stateful<Div>,
401    variant: Variant,
402    interactive: bool,
403    cx: &App,
404) -> Stateful<Div> {
405    apply_variant(el, variant, interactive, true, cx)
406}
407
408/// The background pair `variant` eases between on hover, or `None` when the
409/// variant has no background to ease.
410///
411/// Used by [`Button`] to run v3's `transition-colors` through
412/// [`crate::anim::hover_fade`] instead of swapping the fill on one frame.
413pub fn button_hover_colors(variant: Variant, cx: &App) -> Option<(gpui::Hsla, gpui::Hsla)> {
414    let colors = cx.colors();
415    match variant {
416        Variant::Primary => Some((colors.accent.color, colors.accent.hover())),
417        Variant::Secondary => Some((colors.default.color, colors.default.hover())),
418        Variant::Tertiary => Some((colors.default.color, colors.default.hover())),
419        Variant::Outline => Some((gpui::transparent_black(), colors.default.color.alpha(0.6))),
420        Variant::Ghost => Some((gpui::transparent_black(), colors.default.color)),
421        Variant::Danger => Some((colors.danger.color, colors.danger.hover())),
422        Variant::DangerSoft => Some((colors.danger.soft(), colors.danger.soft_hover())),
423    }
424}
425
426/// The pinned `--button-bg-pressed` endpoint for each variant. HeroUI changes
427/// the background on press; it does not dim the whole button with opacity.
428fn button_pressed_background(variant: Variant, cx: &App) -> gpui::Hsla {
429    let colors = cx.colors();
430    match variant {
431        Variant::Primary => colors.accent.hover(),
432        Variant::Secondary | Variant::Tertiary => colors.default.hover(),
433        Variant::Outline | Variant::Ghost => colors.default.color,
434        Variant::Danger => colors.danger.hover(),
435        Variant::DangerSoft => colors.danger.soft_hover(),
436    }
437}
438
439/// [`apply_button_variant`], with `hover_bg` off when the caller is going to
440/// animate the background itself.
441fn apply_variant(
442    el: Stateful<Div>,
443    variant: Variant,
444    interactive: bool,
445    hover_bg: bool,
446    cx: &App,
447) -> Stateful<Div> {
448    let colors = cx.colors();
449    let layout = cx.layout();
450
451    match variant {
452        Variant::Primary => {
453            let base = colors.accent;
454            let el = el.text_color(base.foreground);
455            let el = if hover_bg { el.bg(base.color) } else { el };
456            if interactive {
457                el.when(hover_bg, |e| e.hover(move |s| s.bg(base.hover())))
458                    .active(move |s| s.bg(base.hover()))
459            } else {
460                el
461            }
462        }
463        // `secondary` is the neutral filled style: v3 maps the removed
464        // `bg-secondary` token to `bg-default`.
465        Variant::Secondary => {
466            let base = colors.default;
467            let el = el.text_color(colors.accent.soft_foreground(colors.foreground));
468            let el = if hover_bg { el.bg(base.color) } else { el };
469            if interactive {
470                el.when(hover_bg, |e| e.hover(move |s| s.bg(base.hover())))
471                    .active(move |s| s.bg(base.hover()))
472            } else {
473                el
474            }
475        }
476        Variant::Tertiary => {
477            let base = colors.default;
478            let fg = colors.foreground;
479            let el = if hover_bg {
480                el.bg(base.color).text_color(fg)
481            } else {
482                el.text_color(fg)
483            };
484            if interactive {
485                el.when(hover_bg, |e| e.hover(move |s| s.bg(base.hover())))
486                    .active(move |s| s.bg(base.hover()))
487            } else {
488                el
489            }
490        }
491        Variant::Outline => {
492            let base = colors.default;
493            let el = el
494                .border(layout.border_width)
495                .border_color(colors.border)
496                .text_color(base.foreground);
497            if interactive {
498                el.when(hover_bg, |e| e.hover(move |s| s.bg(base.color.alpha(0.6))))
499                    .active(move |s| s.bg(base.color))
500            } else {
501                el
502            }
503        }
504        Variant::Ghost => {
505            let base = colors.default;
506            let el = el.text_color(base.foreground);
507            if interactive {
508                el.when(hover_bg, |e| e.hover(move |s| s.bg(base.color)))
509                    .active(move |s| s.bg(base.color))
510            } else {
511                el
512            }
513        }
514        Variant::Danger => {
515            let base = colors.danger;
516            let el = el.text_color(base.foreground);
517            let el = if hover_bg { el.bg(base.color) } else { el };
518            if interactive {
519                el.when(hover_bg, |e| e.hover(move |s| s.bg(base.hover())))
520                    .active(move |s| s.bg(base.hover()))
521            } else {
522                el
523            }
524        }
525        Variant::DangerSoft => {
526            let base = colors.danger;
527            let el = el.text_color(base.soft_foreground(colors.foreground));
528            let el = if hover_bg { el.bg(base.soft()) } else { el };
529            if interactive {
530                el.when(hover_bg, |e| e.hover(move |s| s.bg(base.soft_hover())))
531                    .active(move |s| s.bg(base.soft_hover()))
532            } else {
533                el
534            }
535        }
536    }
537}
538
539/// Button's own type and spacing ladder, from `button.css`.
540///
541/// Only three things move across the sizes. `.button` sets `px-4 gap-2 text-sm`
542/// for every size; `.button--sm` narrows the padding to `px-3` and `.button--lg`
543/// steps the type up to `text-base` — neither touches the gap, and `--sm` does
544/// not touch the type. Reading a generic sm/md/lg ladder instead made the small
545/// button's label a step too small and the large button's padding and gap a
546/// step too wide.
547fn button_metrics(size: Size) -> ButtonMetrics {
548    let (text, line_height) = match size {
549        // `text-sm` / `text-base`, with Tailwind's paired line heights.
550        Size::Sm | Size::Md => (gpui::px(14.), gpui::px(20.)),
551        Size::Lg => (gpui::px(16.), gpui::px(24.)),
552    };
553    ButtonMetrics {
554        text,
555        line_height,
556        // `px-3` on `--sm`, `px-4` everywhere else.
557        padding_x: match size {
558            Size::Sm => gpui::px(12.),
559            Size::Md | Size::Lg => gpui::px(16.),
560        },
561        // `gap-2`, never overridden.
562        gap: gpui::px(8.),
563    }
564}
565
566struct ButtonMetrics {
567    text: Pixels,
568    line_height: Pixels,
569    padding_x: Pixels,
570    gap: Pixels,
571}
572
573/// The text colour `variant` paints, for child svgs that cannot inherit
574/// `text_color` from their parent.
575pub fn button_foreground(variant: Variant, cx: &App) -> gpui::Hsla {
576    let colors = cx.colors();
577    match variant {
578        Variant::Primary => colors.accent.foreground,
579        Variant::Secondary => colors.accent.soft_foreground(colors.foreground),
580        Variant::Tertiary => colors.foreground,
581        Variant::Outline | Variant::Ghost => colors.default.foreground,
582        Variant::Danger => colors.danger.foreground,
583        Variant::DangerSoft => colors.danger.soft_foreground(colors.foreground),
584    }
585}
586
587/// [`group_radius`] for any styled element — `ToggleButtonGroup` merges its
588/// members' corners the same way `.button-group` does.
589pub(crate) fn group_radius_any<T: Styled>(
590    el: T,
591    edge: Option<(GroupEdge, bool)>,
592    radius: Pixels,
593) -> T {
594    let Some((edge, vertical)) = edge else {
595        return el.rounded(radius);
596    };
597    match (edge, vertical) {
598        (GroupEdge::Only, _) => el.rounded(radius),
599        (GroupEdge::Start, false) => el.rounded_tl(radius).rounded_bl(radius),
600        (GroupEdge::End, false) => el.rounded_tr(radius).rounded_br(radius),
601        (GroupEdge::Start, true) => el.rounded_tl(radius).rounded_tr(radius),
602        (GroupEdge::End, true) => el.rounded_bl(radius).rounded_br(radius),
603        (GroupEdge::Middle, _) => el,
604    }
605}
606
607/// The border sides an outline group member drops, in gpui's per-side order.
608#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
609pub(crate) struct CollapsedSides {
610    pub left: bool,
611    pub right: bool,
612    pub top: bool,
613    pub bottom: bool,
614}
615
616/// Which borders an outline member's group position collapses, read off the
617/// pinned `button-group.css`: the horizontal sheet rows are
618/// `:first-child { border-e-0 }`, `:last-child { border-s-0 }` and a middle
619/// member (`:not(:first-child):not(:last-child)`) `border-x-0`; the vertical
620/// sheet mirrors them into the block axis with `border-b-0`, `border-t-0` and
621/// `border-y-0`. A lone member is `:first-child:last-child`, so both edge
622/// rules apply at once and its whole stacking-axis border collapses. Pure so
623/// every GroupEdge x orientation case can be table-tested against the pinned
624/// stylesheet.
625pub(crate) fn collapsed_border_sides(edge: GroupEdge, vertical: bool) -> CollapsedSides {
626    match (edge, vertical) {
627        (GroupEdge::Start, false) => CollapsedSides {
628            right: true,
629            ..Default::default()
630        },
631        (GroupEdge::End, false) => CollapsedSides {
632            left: true,
633            ..Default::default()
634        },
635        (GroupEdge::Middle | GroupEdge::Only, false) => CollapsedSides {
636            left: true,
637            right: true,
638            ..Default::default()
639        },
640        (GroupEdge::Start, true) => CollapsedSides {
641            bottom: true,
642            ..Default::default()
643        },
644        (GroupEdge::End, true) => CollapsedSides {
645            top: true,
646            ..Default::default()
647        },
648        (GroupEdge::Middle | GroupEdge::Only, true) => CollapsedSides {
649            top: true,
650            bottom: true,
651            ..Default::default()
652        },
653    }
654}
655
656/// Zeroes exactly the collapsed sides of an already-bordered element.
657fn apply_collapsed_sides<T: Styled>(el: T, sides: CollapsedSides) -> T {
658    let el = if sides.left { el.border_l_0() } else { el };
659    let el = if sides.right { el.border_r_0() } else { el };
660    let el = if sides.top { el.border_t_0() } else { el };
661    if sides.bottom {
662        el.border_b_0()
663    } else {
664        el
665    }
666}
667
668/// Applies `radius` to only the corners a group edge leaves round.
669fn group_radius(
670    el: Stateful<Div>,
671    edge: Option<(GroupEdge, bool)>,
672    radius: Pixels,
673) -> Stateful<Div> {
674    let Some((edge, vertical)) = edge else {
675        return el.rounded(radius);
676    };
677    match (edge, vertical) {
678        (GroupEdge::Only, _) => el.rounded(radius),
679        // Horizontal: the start edge rounds its left corners, the end edge its
680        // right ones. Vertical: top and bottom.
681        (GroupEdge::Start, false) => el.rounded_tl(radius).rounded_bl(radius),
682        (GroupEdge::End, false) => el.rounded_tr(radius).rounded_br(radius),
683        (GroupEdge::Start, true) => el.rounded_tl(radius).rounded_tr(radius),
684        (GroupEdge::End, true) => el.rounded_bl(radius).rounded_br(radius),
685        (GroupEdge::Middle, _) => el,
686    }
687}
688
689/// The one radius a focus-ring overlay can be drawn at, when there is one.
690///
691/// `util::focus_ring_overlay` builds its bands from a scalar radius, so it can
692/// only stand in for the shadow ring on a button -- or a `ToggleButton`, which
693/// groups the same way -- whose four corners resolve to the same value. That is the ungrouped button, a lone member (`Only`), and a
694/// `Middle` member, whose corners are all square; a `Start` or `End` member
695/// rounds one side and keeps the other flush against its neighbour, and an `sx`
696/// refinement can break the symmetry of any of them. Those cases return `None`
697/// and keep the spread-shadow ring, which dilates whatever per-corner shape the
698/// element already has.
699pub(crate) fn uniform_ring_radius(
700    edge: Option<(GroupEdge, bool)>,
701    radius: Pixels,
702    sx_corners: &gpui::Corners<Option<Pixels>>,
703) -> Option<Pixels> {
704    let base = match edge {
705        None | Some((GroupEdge::Only, _)) => radius,
706        Some((GroupEdge::Middle, _)) => gpui::px(0.),
707        Some((GroupEdge::Start | GroupEdge::End, _)) => return None,
708    };
709    let resolved = [
710        sx_corners.top_left,
711        sx_corners.top_right,
712        sx_corners.bottom_right,
713        sx_corners.bottom_left,
714    ]
715    .map(|corner| corner.unwrap_or(base));
716    resolved
717        .iter()
718        .all(|corner| *corner == resolved[0])
719        .then_some(resolved[0])
720}
721
722impl RenderOnce for Button {
723    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
724        // The handle that says whether this button holds the focus.
725        // `use_keyed_state` takes `cx` mutably, so it precedes the tokens.
726        let focus_handle = util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx);
727        // One keyed slot owns hover and press state for both render-prop and
728        // plain buttons. The hover fade and press ramp read it, while the
729        // final tracker installs the single event layer on the stable slot.
730        // Keeping the slot for plain buttons also lets their CSS press
731        // transition run without layering a second listener onto the fade.
732        let interaction = Some(util::interaction(
733            element_id::scoped(&self.id, "interaction"),
734            window,
735            cx,
736        ));
737        let layout = cx.layout();
738        let button_theme = cx.theme().components.button.resolve(&self.recipes);
739        if !self.variant_is_set {
740            if let Some(variant) = button_theme.variant {
741                self.variant = variant;
742            }
743        }
744        if !self.size_is_set {
745            if let Some(size) = button_theme.size {
746                self.size = size;
747            }
748        }
749        self.radius = self.radius.or(button_theme.radius);
750        if self.hover_bg.is_none() {
751            self.hover_bg = button_theme
752                .hover_bg
753                .map(|color| color.resolve(cx.colors()));
754        }
755        let theme_style = button_theme.style.map(Box::new);
756        let theme_bg = button_theme
757            .background
758            .map(|color| color.resolve(cx.colors()));
759        let theme_fg = button_theme
760            .foreground
761            .map(|color| color.resolve(cx.colors()));
762        let theme_hover_fg = button_theme
763            .hover_foreground
764            .map(|color| color.resolve(cx.colors()));
765        let theme_pressed_bg = button_theme
766            .pressed_bg
767            .map(|color| color.resolve(cx.colors()));
768        let theme_disabled_fg = button_theme
769            .disabled_foreground
770            .map(|color| color.resolve(cx.colors()));
771        // Copied out: `hover_fade` below takes `&mut App`, and holding the
772        // `layout` borrow across it would be a second borrow of `cx`.
773        let disabled_opacity = layout.disabled_opacity;
774        let focusable = !self.is_disabled;
775        let interactive = focusable && !self.is_pending;
776        if !interactive {
777            if let Some(slot) = &interaction {
778                if *slot.read(cx) != (false, false) {
779                    slot.update(cx, |state, _| *state = (false, false));
780                }
781            }
782        }
783        // v3's `transition-colors`: the fill eases rather than switching on the
784        // frame the pointer arrives. The variant then leaves the background
785        // alone so the two do not fight over it. `fade_endpoints` resolves
786        // which pair it eases: `hover_bg` names the hover end and the resting
787        // background (the `sx` one, else the variant's) becomes the other,
788        // while an `sx` background on its own replaces *both* endpoints —
789        // the fill the fade draws would otherwise paint the variant colour
790        // back over the override.
791        let sx_background = util::sx_background(&self.sx).or(theme_bg);
792        let instance_size = util::sx_pixel_size(&self.sx);
793        let theme_size = util::sx_pixel_size(&theme_style);
794        let sx_size = gpui::Size {
795            width: instance_size.width.or(theme_size.width),
796            height: instance_size.height.or(theme_size.height),
797        };
798        let sx_corners = util::sx_radius(&self.sx);
799        // The resting box, the hover fade's fill and the press box all take
800        // the same resolved corner, so it is resolved once.
801        let radius = self.radius.unwrap_or_else(|| util::control_radius(cx));
802        let fade = interactive
803            .then(|| button_hover_colors(self.variant, cx))
804            .and_then(|variant| util::fade_endpoints(variant, sx_background, self.hover_bg));
805
806        // The size ladder is the default; the instance text and padding
807        // builders replace their rung. An overridden size re-pairs its leading
808        // through `util::leading_for` when the value is a Tailwind step, and
809        // keeps the ladder's leading otherwise — the convention Chip's
810        // `text_size` records.
811        let derived = button_metrics(self.size);
812        let metrics = ButtonMetrics {
813            text: self.text_size.unwrap_or(derived.text),
814            line_height: self
815                .text_size
816                .and_then(util::leading_for)
817                .unwrap_or(derived.line_height),
818            padding_x: self.padding_x.unwrap_or(derived.padding_x),
819            gap: derived.gap,
820        };
821        // The resolved resting height: an instance builder beats the size
822        // ladder, and the matching `sx` height still refines the root last.
823        let height = self.height.unwrap_or_else(|| self.size.control_height());
824        // RAC's `Button` renders a native `<button>`, so upstream's role is
825        // implicit and its accessible name comes from the rendered children.
826        // A gpui text child carries no id, so it contributes no node and no
827        // name (see `a11y`), which is why the label is restated here.
828        let name = a11y::Name::maybe(self.label.clone());
829        let mut el = div()
830            .id(self.id.clone())
831            .a11y_named(a11y::Role::Button, &name)
832            .flex()
833            .flex_row()
834            .items_center()
835            .justify_center()
836            .flex_shrink_0()
837            // `button.css` declares no `overflow`: a label too long for the
838            // button spills, it is not cut. Clipping it here also gave the row
839            // an automatic minimum size of zero, which let the label collapse
840            // instead of overflowing.
841            .whitespace_nowrap()
842            .font_weight(self.font_weight.unwrap_or(gpui::FontWeight::MEDIUM))
843            .map(|e| group_radius(e, self.group_edge, radius))
844            .map(|e| util::round_sx_corners(e, &sx_corners))
845            .text_size(metrics.text)
846            .line_height(metrics.line_height)
847            .h(height);
848
849        el = if self.is_icon_only {
850            el.w(self.size.icon_control_size())
851        } else {
852            el.px(metrics.padding_x).gap(metrics.gap)
853        };
854
855        // An explicit pixel width is the more specific source: it wins over
856        // `full_width` and the icon-only square alike.
857        if let Some(width) = self.width {
858            el = el.w(width);
859        } else if self.full_width {
860            el = el.w_full();
861        }
862
863        if self.grow {
864            // `flex-1` plus `min-w-0` on the skin: inside a press slot it must
865            // fill that slot, and without one it is the row item itself. The
866            // same pair goes onto the slot further down, so the caller's row
867            // stretches whichever element it actually lays out.
868            el = el.flex_1().min_w(gpui::px(0.));
869        }
870
871        if let Some(min_width) = self.min_width {
872            el = el.min_w(min_width);
873        }
874
875        el = apply_variant(el, self.variant, interactive, fade.is_none(), cx);
876
877        // `button-group.css` collapses the borders an outline member shows
878        // toward its neighbours so a seam is the one composed separator
879        // hairline rather than two borders. `collapsed_border_sides` holds
880        // the per-case mapping; outside a group the full border stays.
881        if self.variant == Variant::Outline {
882            if let Some((edge, vertical)) = self.group_edge {
883                el = apply_collapsed_sides(el, collapsed_border_sides(edge, vertical));
884            }
885        }
886
887        // The fade's animated layer is glued under everything that follows: the
888        // colour transition lives on an inset fill *inside* the button, so the
889        // button's own element id — and with it the hover listener latch — never
890        // moves when the fill's animation restarts (see `anim::hover_fade`).
891        // The interaction slot is handed over when a `content` closure is set:
892        // `track_interaction` then owns `on_hover`, and the fade reads the hover
893        // bit the slot records instead of binding a second listener.
894        if let Some(colors) = fade {
895            let edge = self.group_edge;
896            el = crate::anim::hover_fade(
897                el,
898                element_id::scoped(&self.id, "fade"),
899                colors,
900                interaction.as_ref(),
901                None,
902                move |fill| {
903                    util::round_sx_corners(group_radius_any(fill, edge, radius), &sx_corners)
904                },
905                window,
906                cx,
907            );
908        }
909
910        if self.is_disabled || self.is_pending {
911            el = el.opacity(disabled_opacity);
912        }
913
914        if let Some(render) = self.content.clone() {
915            let (is_hovered, is_pressed) = if interactive {
916                interaction
917                    .as_ref()
918                    .map(|slot| *slot.read(cx))
919                    .unwrap_or_default()
920            } else {
921                (false, false)
922            };
923            let focused = focusable && focus_handle.is_focused(window);
924            el = el.child(render(util::InteractiveState {
925                is_hovered,
926                is_pressed,
927                is_focused: focused,
928                is_focus_visible: focused && util::focus_visible(cx),
929                is_selected: false,
930                is_disabled: self.is_disabled,
931                is_pending: self.is_pending,
932                is_indeterminate: false,
933            }));
934        } else if let Some(label) = self.label {
935            el = el.child(label.to_string());
936        }
937        el = el.children(self.children);
938
939        // v3's `[data-pressed]` press ramp. Applied last so the press geometry
940        // sits on top of whatever the variant did to padding.
941        //
942        // `button.css` declares the press as a transition
943        // (`transform 250ms var(--ease-smooth), background-color 100ms
944        // var(--ease-out)`), so the skin rides
945        // `pressed_with_background_ramp`: the colour track eases between the
946        // same resting fill the hover fade holds and the variant's
947        // `--button-bg-pressed` endpoint.
948        if interactive && self.group_edge.is_none() {
949            let press_scale = match self.size {
950                Size::Sm => crate::anim::PRESSED_SCALE_SUBTLE,
951                Size::Md => crate::anim::PRESSED_SCALE,
952                Size::Lg => crate::anim::PRESSED_SCALE_FIRM,
953            };
954            let press_box = crate::anim::PressBox {
955                // An `sx` pixel size keeps the press footprint at the
956                // overridden box instead of snapping back to the ladder; an
957                // instance builder sits between the two.
958                height: sx_size
959                    .height
960                    .or(self.height)
961                    .unwrap_or_else(|| self.size.control_height()),
962                padding_x: (!self.is_icon_only).then_some(metrics.padding_x),
963                width: sx_size
964                    .width
965                    .or(self.width)
966                    .or_else(|| self.is_icon_only.then(|| self.size.icon_control_size())),
967                // v3's `.button` is `w-fit` with no minimum, so a press has
968                // no floor to scale; a caller's `min_width` rides on the skin
969                // itself, which the press refinement never strips.
970                min_width: None,
971                text_size: metrics.text,
972                line_height: metrics.line_height,
973                gap: metrics.gap,
974                radius,
975                shrink_x: !self.full_width,
976                scale: press_scale,
977            };
978            if ActiveTheme::reduce_motion(cx) {
979                el = crate::anim::pressed_with_background(
980                    el,
981                    press_box,
982                    button_pressed_background(self.variant, cx),
983                    cx,
984                );
985            } else {
986                let press_endpoints =
987                    fade.map(|(idle, _)| (idle, button_pressed_background(self.variant, cx)));
988                el = crate::anim::pressed_with_background_ramp(
989                    el,
990                    press_box,
991                    press_endpoints,
992                    crate::anim::BUTTON_PRESS,
993                    interaction.as_ref(),
994                    window,
995                    cx,
996                );
997            }
998        }
999
1000        // When the press wrapper is present, `el` is now the stable press
1001        // slot — the element the caller's row actually lays out — so the
1002        // stretch pair lands here too. (On paths without a wrapper this
1003        // re-states what the skin above already carries.)
1004        if self.grow {
1005            el = el.flex_1().min_w(gpui::px(0.));
1006        }
1007
1008        if let Some(on_press) = self.on_press {
1009            if interactive {
1010                // gpui fires a *focused* element's click listeners on Enter and
1011                // Space with `ClickEvent::Keyboard`, which is React Aria's press
1012                // exactly -- so this one binding answers the pointer and the
1013                // keyboard, and the focus handle above is what switched the
1014                // second half on.
1015                el = el.on_click(move |ev: &ClickEvent, window, cx| on_press(ev, window, cx));
1016            }
1017        }
1018
1019        // The interaction tracking (hover, mouse/keyboard press bits the
1020        // `content` closure reads) belongs on the press slot: key events
1021        // dispatch along the focus path, which runs through the slot — the
1022        // skin is its child.
1023        if interactive {
1024            if let Some(slot) = &interaction {
1025                el = util::track_interaction(el, slot);
1026            }
1027        }
1028
1029        // `.button:focus-visible` is `status-focused`: a 2px ring, offset from
1030        // the button by another in the background colour. A disabled button is
1031        // not a tab stop, which is what `pointer-events-none` amounts to here.
1032        // The focus tracking lands on the press slot (the element `pressed`
1033        // returns) so keyboard activation and pointer activation answer on the
1034        // same element, and the ring draws around the resting footprint. The
1035        // pending/disabled dimming covers the label, which lives above the
1036        // skin.
1037        //
1038        // The ring is the overlay form wherever the button's four corners
1039        // resolve to one radius, because an overlay is crisp and concentric
1040        // where a spread shadow keeps the element's own corner. A grouped
1041        // member that rounds only the corners on its outer edge has no single
1042        // radius an overlay could take -- one bordered div carries one
1043        // `rounded()` per corner but the overlay's outer band is built from a
1044        // scalar -- so those stay on the shadow ring, which dilates whatever
1045        // per-corner shape the element already has.
1046        if focusable {
1047            el = match uniform_ring_radius(self.group_edge, radius, &sx_corners) {
1048                Some(ring_radius) => util::ring_overlay_if_focused(
1049                    el.track_focus(&focus_handle),
1050                    &focus_handle,
1051                    true,
1052                    ring_radius,
1053                    Vec::new(),
1054                    window,
1055                    cx,
1056                ),
1057                None => util::ring_if_focused(
1058                    el.track_focus(&focus_handle),
1059                    &focus_handle,
1060                    true,
1061                    Vec::new(),
1062                    window,
1063                    cx,
1064                ),
1065            };
1066        }
1067
1068        if self.is_disabled || self.is_pending {
1069            el = el.opacity(disabled_opacity);
1070        }
1071
1072        if let Some(style) = &theme_style {
1073            el.style().refine(style);
1074        }
1075        if let Some(foreground) = theme_fg {
1076            el = el.text_color(foreground);
1077        }
1078        if let Some(foreground) = theme_hover_fg {
1079            el = el.hover(move |style| style.text_color(foreground));
1080        }
1081        if let Some(background) = theme_pressed_bg {
1082            el = el.active(move |style| style.bg(background));
1083        }
1084        if (self.is_disabled || self.is_pending)
1085            && let Some(foreground) = theme_disabled_fg
1086        {
1087            el = el.text_color(foreground);
1088        }
1089        el = util::apply_sx(el, &self.sx);
1090        el.into_any_element()
1091    }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097
1098    #[test]
1099    fn explicit_corners_refine_group_edges_without_rounding_unnamed_seams() {
1100        let sx = gpui::Corners {
1101            top_right: Some(gpui::px(12.)),
1102            ..Default::default()
1103        };
1104        let mut skin = group_radius_any(div(), Some((GroupEdge::Start, false)), gpui::px(2.));
1105        skin = util::round_sx_corners(skin, &sx);
1106        let corners = &skin.style().corner_radii;
1107        assert_eq!(corners.top_left, Some(gpui::px(2.).into()));
1108        assert_eq!(corners.top_right, Some(gpui::px(12.).into()));
1109        assert_eq!(corners.bottom_left, Some(gpui::px(2.).into()));
1110        assert_eq!(
1111            corners.bottom_right, None,
1112            "the unmentioned attached edge stays square"
1113        );
1114    }
1115
1116    /// Stand-ins for the variant's own pair and the two overrides. Distinct
1117    /// values so every assertion below names which one it expected, rather
1118    /// than comparing a colour against itself.
1119    const VARIANT_IDLE: gpui::Hsla = gpui::Hsla {
1120        h: 0.0,
1121        s: 0.5,
1122        l: 0.5,
1123        a: 1.0,
1124    };
1125    const VARIANT_HOVER: gpui::Hsla = gpui::Hsla {
1126        h: 0.1,
1127        s: 0.5,
1128        l: 0.5,
1129        a: 1.0,
1130    };
1131    const SX: gpui::Hsla = gpui::Hsla {
1132        h: 0.2,
1133        s: 0.5,
1134        l: 0.5,
1135        a: 1.0,
1136    };
1137    const CUSTOM_HOVER: gpui::Hsla = gpui::Hsla {
1138        h: 0.3,
1139        s: 0.5,
1140        l: 0.5,
1141        a: 1.0,
1142    };
1143
1144    /// (a) A caller-owned surface *and* a hover colour: the fade eases between
1145    /// exactly those two, so the button no longer sits frozen on its override.
1146    #[test]
1147    fn hover_bg_eases_from_the_sx_background() {
1148        let endpoints = util::fade_endpoints(
1149            Some((VARIANT_IDLE, VARIANT_HOVER)),
1150            Some(SX),
1151            Some(CUSTOM_HOVER),
1152        );
1153
1154        assert_eq!(
1155            endpoints,
1156            Some((SX, CUSTOM_HOVER)),
1157            "the fade must rest on the sx background and ease to the named hover colour"
1158        );
1159        let (idle, hovered) = endpoints.unwrap();
1160        assert_ne!(
1161            idle, hovered,
1162            "the fade must not be frozen once hover_bg is set"
1163        );
1164    }
1165
1166    /// (b) The behaviour `hover_bg` is an escape hatch from: an `sx`
1167    /// background alone still pins both endpoints, so nothing repaints the
1168    /// variant colour over the override.
1169    #[test]
1170    fn sx_background_alone_still_freezes_both_endpoints() {
1171        assert_eq!(
1172            util::fade_endpoints(Some((VARIANT_IDLE, VARIANT_HOVER)), Some(SX), None),
1173            Some((SX, SX)),
1174            "an sx background with no hover_bg must hold across hover"
1175        );
1176    }
1177
1178    /// (c) No `sx`: the fade keeps the variant's resting colour and only the
1179    /// hover end is replaced.
1180    #[test]
1181    fn hover_bg_without_sx_eases_from_the_variant_resting_colour() {
1182        let variant = (VARIANT_IDLE, VARIANT_HOVER);
1183
1184        assert_eq!(
1185            util::fade_endpoints(Some(variant), None, Some(CUSTOM_HOVER)),
1186            Some((variant.0, CUSTOM_HOVER)),
1187            "the resting end must stay the variant's own colour"
1188        );
1189    }
1190
1191    /// With neither override the resolution is the identity, which is what
1192    /// keeps every existing button pixel-identical.
1193    #[test]
1194    fn no_override_passes_the_variant_pair_through() {
1195        let variant = (VARIANT_IDLE, VARIANT_HOVER);
1196
1197        assert_eq!(
1198            util::fade_endpoints(Some(variant), None, None),
1199            Some(variant)
1200        );
1201        assert_eq!(
1202            util::fade_endpoints(None, None, None),
1203            None,
1204            "a variant with no background to ease must stay unfaded"
1205        );
1206    }
1207
1208    #[test]
1209    fn group_defaults_preserve_explicit_child_props() {
1210        let button = Button::new("override")
1211            .variant(Variant::Outline)
1212            .size(Size::Lg)
1213            .is_disabled(false)
1214            .full_width(false)
1215            .group_defaults(Variant::Secondary, Size::Sm, true, true);
1216
1217        assert_eq!(button.variant, Variant::Outline);
1218        assert_eq!(button.size, Size::Lg);
1219        assert!(!button.is_disabled);
1220        assert!(!button.is_full_width());
1221    }
1222
1223    #[test]
1224    fn group_defaults_fill_unset_child_props() {
1225        let button =
1226            Button::new("inherited").group_defaults(Variant::Secondary, Size::Sm, true, true);
1227
1228        assert_eq!(button.variant, Variant::Secondary);
1229        assert_eq!(button.size, Size::Sm);
1230        assert!(button.is_disabled);
1231        assert!(button.is_full_width());
1232    }
1233
1234    /// `button-group.css` outline collapse, one row per GroupEdge x
1235    /// orientation, each naming the pinned selector that demands it.
1236    #[test]
1237    fn outline_collapse_table_matches_pinned_css() {
1238        let cases = [
1239            (
1240                GroupEdge::Start,
1241                false,
1242                CollapsedSides { right: true, ..Default::default() },
1243                ".button-group--horizontal .button--outline:first-child { border-e-0 }",
1244            ),
1245            (
1246                GroupEdge::End,
1247                false,
1248                CollapsedSides { left: true, ..Default::default() },
1249                ".button-group--horizontal .button--outline:last-child { border-s-0 }",
1250            ),
1251            (
1252                GroupEdge::Middle,
1253                false,
1254                CollapsedSides { left: true, right: true, ..Default::default() },
1255                ".button-group--horizontal .button--outline:not(:first-child):not(:last-child) { border-x-0 }",
1256            ),
1257            (
1258                GroupEdge::Start,
1259                true,
1260                CollapsedSides { bottom: true, ..Default::default() },
1261                ".button-group--vertical .button--outline:first-child { border-b-0 }",
1262            ),
1263            (
1264                GroupEdge::End,
1265                true,
1266                CollapsedSides { top: true, ..Default::default() },
1267                ".button-group--vertical .button--outline:last-child { border-t-0 }",
1268            ),
1269            (
1270                GroupEdge::Middle,
1271                true,
1272                CollapsedSides { top: true, bottom: true, ..Default::default() },
1273                ".button-group--vertical .button--outline:not(:first-child):not(:last-child) { border-y-0 }",
1274            ),
1275        ];
1276        for (edge, vertical, expected, selector) in cases {
1277            assert_eq!(
1278                collapsed_border_sides(edge, vertical),
1279                expected,
1280                "`{selector}` must collapse exactly these borders"
1281            );
1282        }
1283
1284        // A lone member is `:first-child:last-child`, so both edge rules
1285        // apply at once and its whole stacking-axis border collapses.
1286        assert_eq!(
1287            collapsed_border_sides(GroupEdge::Only, false),
1288            CollapsedSides {
1289                left: true,
1290                right: true,
1291                ..Default::default()
1292            },
1293            "a lone horizontal outline member matches :first-child:last-child, \
1294             so border-e-0 and border-s-0 both apply"
1295        );
1296        assert_eq!(
1297            collapsed_border_sides(GroupEdge::Only, true),
1298            CollapsedSides {
1299                top: true,
1300                bottom: true,
1301                ..Default::default()
1302            },
1303            "a lone vertical outline member matches :first-child:last-child, \
1304             so border-b-0 and border-t-0 both apply"
1305        );
1306    }
1307
1308    /// `button.tsx`: `finalFullWidth = fullWidth ?? context.fullWidth` — the
1309    /// child value wins in both directions, and an unset child inherits the
1310    /// context in both directions.
1311    #[test]
1312    fn group_defaults_full_width_precedence() {
1313        let inherit_false =
1314            Button::new("inherit-false").group_defaults(Variant::Primary, Size::Md, false, false);
1315        let inherit_true =
1316            Button::new("inherit-true").group_defaults(Variant::Primary, Size::Md, false, true);
1317        let override_false = Button::new("override-false")
1318            .full_width(false)
1319            .group_defaults(Variant::Primary, Size::Md, false, true);
1320        let override_true = Button::new("override-true")
1321            .full_width(true)
1322            .group_defaults(Variant::Primary, Size::Md, false, false);
1323
1324        assert!(!inherit_false.is_full_width());
1325        assert!(inherit_true.is_full_width());
1326        assert!(
1327            !override_false.is_full_width(),
1328            "an explicit child fullWidth=false must survive a full-width group context"
1329        );
1330        assert!(
1331            override_true.is_full_width(),
1332            "an explicit child fullWidth=true must survive a non-full group context"
1333        );
1334    }
1335}