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