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