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}