Skip to main content

herogpui_components/
toggle_button.rs

1//! ToggleButton & ToggleButtonGroup — port of `@heroui/toggle-button` v3.
2//!
3//! Toggle between selected/unselected. Supports all button variants, sizes,
4//! icon-only, controlled `isSelected` and group selection modes.
5
6use gpui::{
7    div, prelude::*, px, AnyElement, App, ClickEvent, ElementId, IntoElement, ParentElement,
8    Pixels, RenderOnce, SharedString, Styled, Window,
9};
10use herogpui_core::{element_id, Orientation as SelectionOrientation, SelectionMode, Size};
11use herogpui_theme::ActiveTheme;
12
13use crate::a11y::{self, A11y as _};
14
15// ---------------------------------------------------------------------------
16// ToggleButton
17// ---------------------------------------------------------------------------
18
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum ToggleVariant {
21    #[default]
22    Default,
23    Ghost,
24}
25
26#[derive(Clone, Default)]
27struct ToggleGroupFocusState {
28    last_key: Option<SharedString>,
29    was_inside: bool,
30    restore_on_entry: bool,
31    edge_exit: bool,
32}
33
34#[derive(IntoElement)]
35pub struct ToggleButton {
36    id: ElementId,
37    /// Selection key inside a group. Defaults to the element id, so a group can
38    /// namespace its ids without breaking selection.
39    key: Option<SharedString>,
40    label: Option<SharedString>,
41    /// v3's `children`-as-a-function: handed the interactive state, `isSelected`
42    /// included, and drawn in place of the label.
43    content: Option<std::sync::Arc<dyn Fn(crate::util::InteractiveState) -> AnyElement + 'static>>,
44    variant: ToggleVariant,
45    size: Size,
46    /// Whether the child explicitly set `size`. HeroUI's group context only
47    /// supplies a size when the child did not override it.
48    size_explicit: bool,
49    /// `isSelected` — `None` leaves the button holding the state, seeded from
50    /// `defaultSelected`.
51    is_selected: Option<bool>,
52    default_selected: bool,
53    is_icon_only: bool,
54    /// Supplied by a toggle group so it can navigate its typed children
55    /// without falling through to the window-wide tab order.
56    group_focus_handle: Option<gpui::FocusHandle>,
57    /// Set by [`ToggleButtonGroup`] when it selects one member at a time.
58    /// `useToggleButtonGroupItem` swaps the member's role from `button` to
59    /// `radio` and its `aria-pressed` for `aria-checked` in exactly that case.
60    group_single_selection: bool,
61    /// Set by [`ToggleButtonGroup`]: which end of the group this member is,
62    /// and whether the group stacks. `.toggle-button-group .toggle-button` is
63    /// `rounded-none` with the outer radius on the first and last member.
64    group_edge: Option<(crate::button::GroupEdge, bool)>,
65    is_disabled: bool,
66    disabled_explicit: bool,
67    children: Vec<AnyElement>,
68    /// Set by [`ToggleButton::hover_bg`]: the fill the hover fade eases *to*.
69    hover_bg: Option<gpui::Hsla>,
70    /// The corner radius, in place of the owning helper. Group edges, the
71    /// hover fade and the press scale still apply.
72    radius: Option<Pixels>,
73    /// `Arc` for the same reason as `on_change`: the pointer and the keyboard
74    /// each hold it.
75    on_press: Option<std::sync::Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
76    /// `Arc` rather than `Box`: the handler is bound twice, once for the
77    /// pointer and once for Enter and Space.
78    on_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
79    /// The `sx` slot, refined over the root style at the end of render.
80    sx: Option<Box<gpui::StyleRefinement>>,
81}
82
83impl ToggleButton {
84    /// Overrides the selection key, which otherwise mirrors the element id.
85    pub fn key(mut self, key: impl Into<SharedString>) -> Self {
86        self.key = Some(key.into());
87        self
88    }
89
90    /// The member's key inside a [`ToggleButtonGroup`].
91    pub fn selection_key(&self) -> SharedString {
92        self.key.clone().unwrap_or_else(|| match &self.id {
93            ElementId::Name(name) => name.clone(),
94            other => other.to_string().into(),
95        })
96    }
97
98    pub fn new(id: impl Into<ElementId>) -> Self {
99        Self {
100            content: None,
101            id: id.into(),
102            key: None,
103            label: None,
104            variant: ToggleVariant::Default,
105            size: Size::Md,
106            size_explicit: false,
107            is_selected: None,
108            default_selected: false,
109            is_icon_only: false,
110            group_focus_handle: None,
111            group_single_selection: false,
112            group_edge: None,
113            is_disabled: false,
114            disabled_explicit: false,
115            children: Vec::new(),
116            on_press: None,
117            on_change: None,
118            sx: None,
119            hover_bg: None,
120            radius: None,
121        }
122    }
123
124    /// v3's render function for the button's children, handed `isHovered`,
125    /// `isPressed`, `isFocused`, `isFocusVisible` and `isSelected`. The hover and
126    /// the press are a frame behind the pointer -- gpui reports both to a
127    /// handler, not to the render that draws them.
128    pub fn content(
129        mut self,
130        render: impl Fn(crate::util::InteractiveState) -> AnyElement + 'static,
131    ) -> Self {
132        self.content = Some(std::sync::Arc::new(render));
133        self
134    }
135
136    pub fn label(mut self, l: impl Into<SharedString>) -> Self {
137        self.label = Some(l.into());
138        self
139    }
140
141    pub fn variant(mut self, v: ToggleVariant) -> Self {
142        self.variant = v;
143        self
144    }
145
146    pub fn size(mut self, s: Size) -> Self {
147        self.size = s;
148        self.size_explicit = true;
149        self
150    }
151
152    /// The fill the hover fade eases to, in place of the variant's hover
153    /// colour. The fade runs from the resting background — the `sx` background
154    /// when one is set, the variant's resting colour otherwise — so this is the
155    /// Button contract, on a toggle. v3 has no such prop.
156    pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
157        self.hover_bg = Some(color.into());
158        self
159    }
160
161    /// The corner radius, in place of the owning `control_radius` helper.
162    /// Group edges, the hover fade and the press scale still apply. Not a v3
163    /// prop; the removed v2 `radius` prop is prohibited and this is a
164    /// per-component repository extension.
165    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
166        self.radius = Some(radius.into());
167        self
168    }
169
170    /// The one slot for caller-owned low-level styling: GPUI's styling methods
171    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
172    /// applied to the button's root element after every value the variant, the
173    /// size and the active theme chose, so they win.
174    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
175        self.sx = Some(crate::util::capture_sx(style));
176        self
177    }
178
179    fn group_size(mut self, size: Size) -> Self {
180        if !self.size_explicit {
181            self.size = size;
182        }
183        self
184    }
185
186    pub fn is_selected(mut self, v: bool) -> Self {
187        self.is_selected = Some(v);
188        self
189    }
190
191    /// `defaultSelected` — the uncontrolled initial state.
192    ///
193    /// Only consulted when `isSelected` is not supplied; the button then owns
194    /// the state and toggles itself. A group always drives selection, so this
195    /// is for a standalone toggle.
196    pub fn default_selected(mut self, v: bool) -> Self {
197        self.default_selected = v;
198        self
199    }
200
201    pub fn is_icon_only(mut self, v: bool) -> Self {
202        self.is_icon_only = v;
203        self
204    }
205
206    /// Joins this toggle to a group edge. Internal: a caller reaches it by
207    /// putting the toggle in a [`ToggleButtonGroup`].
208    pub(crate) fn group_edge(mut self, edge: crate::button::GroupEdge, vertical: bool) -> Self {
209        self.group_edge = Some((edge, vertical));
210        self
211    }
212
213    fn group_focus_handle(mut self, handle: gpui::FocusHandle) -> Self {
214        self.group_focus_handle = Some(handle);
215        self
216    }
217
218    fn group_managed(mut self) -> Self {
219        self.on_change = None;
220        self
221    }
222
223    /// `useToggleButtonGroupItem` reads the group's selection mode, so the
224    /// member has to be told which one it is in.
225    fn group_selection_mode(mut self, mode: SelectionMode) -> Self {
226        self.group_single_selection = mode == SelectionMode::Single;
227        self
228    }
229
230    fn group_on_press(
231        mut self,
232        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
233    ) -> Self {
234        let child = self.on_press.take();
235        self.on_change = None;
236        self.on_press = Some(std::sync::Arc::new(move |event, window, cx| {
237            handler(event, window, cx);
238            if let Some(child) = &child {
239                child(event, window, cx);
240            }
241        }));
242        self
243    }
244
245    pub fn is_disabled(mut self, v: bool) -> Self {
246        self.is_disabled = v;
247        self.disabled_explicit = true;
248        self
249    }
250
251    fn group_disabled(mut self, v: bool) -> Self {
252        if !self.disabled_explicit {
253            self.is_disabled = v;
254        }
255        self
256    }
257
258    pub fn child(mut self, el: impl IntoElement) -> Self {
259        self.children.push(el.into_any_element());
260        self
261    }
262
263    /// `onChange` — reports the selection the press moves to.
264    ///
265    /// Fires alongside [`ToggleButton::on_press`]; use whichever shape suits.
266    pub fn on_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
267        self.on_change = Some(std::sync::Arc::new(f));
268        self
269    }
270
271    pub fn on_press(mut self, f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
272        self.on_press = Some(std::sync::Arc::new(f));
273        self
274    }
275}
276
277impl ParentElement for ToggleButton {
278    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
279        self.children.extend(elements);
280    }
281}
282
283impl RenderOnce for ToggleButton {
284    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
285        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
286        let (is_selected, own) = crate::util::controlled(
287            window,
288            cx,
289            element_id::scoped(&self.id, "selected"),
290            self.is_selected,
291            self.default_selected,
292        );
293
294        // `.toggle-button:focus-visible` is `status-focused`.
295        let focus_handle = self.group_focus_handle.clone().unwrap_or_else(|| {
296            crate::util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx)
297        });
298        // Where the hover and press a `content` closure is handed come from.
299        let interaction = self.content.as_ref().map(|_| {
300            crate::util::interaction(element_id::scoped(&self.id, "interaction"), window, cx)
301        });
302        let colors = cx.colors().clone();
303        let sem = colors.accent;
304        let disabled_opacity = cx.layout().disabled_opacity;
305        let radius = self
306            .radius
307            .unwrap_or_else(|| crate::util::control_radius(cx));
308        let is_grouped = self.group_edge.is_some();
309
310        let sx_background = crate::util::sx_background(&self.sx);
311        let sx_corners = crate::util::sx_radius(&self.sx);
312        let fade = (!self.is_disabled)
313            .then(|| {
314                let idle = if is_selected {
315                    sem.soft()
316                } else {
317                    match self.variant {
318                        ToggleVariant::Default => colors.default.color,
319                        ToggleVariant::Ghost => gpui::transparent_black(),
320                    }
321                };
322                let hover = if is_selected {
323                    colors.accent.soft_hover()
324                } else {
325                    match self.variant {
326                        ToggleVariant::Default => colors.default.hover(),
327                        ToggleVariant::Ghost => colors.default.color,
328                    }
329                };
330                (idle, hover)
331            })
332            .and_then(|pair| crate::util::fade_endpoints(Some(pair), sx_background, self.hover_bg));
333
334        // `useToggleButton` is `useButton` plus `aria-pressed`. Inside a
335        // single-selection group, `useToggleButtonGroupItem` overwrites both:
336        // `role = 'radio'`, `aria-checked`, and `delete aria-pressed`.
337        let single = self.group_single_selection;
338        let name = a11y::Name::maybe(self.label.clone());
339        let mut el = div()
340            .id(self.id.clone())
341            .map(|e| {
342                if single {
343                    e.a11y_named(a11y::Role::RadioButton, &name)
344                        .a11y_checked(is_selected, false)
345                } else {
346                    e.a11y_named(a11y::Role::Button, &name)
347                        .a11y_pressed(is_selected)
348                }
349            })
350            .flex()
351            .items_center()
352            .justify_center()
353            // `.toggle-button` is not `overflow-hidden` -- `Button`, whose box
354            // this one mirrors, is not either. The clip that used to be here
355            // only kept the hover fade's fill inside the rounded box on the
356            // patched renderer; the fill carries the same `group_radius_any`
357            // corners as the box now, and dropping the clip is what lets the
358            // focus ring below be the overlay, which hangs outside the box.
359            .whitespace_nowrap()
360            .flex_shrink_0()
361            .font_weight(gpui::FontWeight::MEDIUM)
362            .when(is_selected, |e| {
363                let e = if fade.is_none() { e.bg(sem.soft()) } else { e };
364                e.text_color(sem.soft_foreground(colors.foreground))
365            })
366            .when(!is_selected, |e| match self.variant {
367                ToggleVariant::Default => {
368                    let e = if fade.is_none() {
369                        e.bg(colors.default.color)
370                    } else {
371                        e
372                    };
373                    e.text_color(colors.foreground)
374                }
375                ToggleVariant::Ghost => {
376                    let e = if fade.is_none() {
377                        e.bg(gpui::transparent_black())
378                    } else {
379                        e
380                    };
381                    e.text_color(colors.default.foreground)
382                }
383            });
384
385        // sizing — kept in locals so the press geometry below scales exactly
386        // what was applied here.
387        // `.toggle-button` is `h-10 md:h-9` with `--sm` at `h-9 md:h-8` and
388        // `--lg` at `h-11 md:h-10`: 32 / 36 / 40 on a desktop, the same pair as
389        // `.button`. This had them a step too tall.
390        let (height, pad_x, gap, press_scale) = match self.size {
391            Size::Sm => (px(32.), px(12.), px(8.), crate::anim::PRESSED_SCALE_SUBTLE),
392            Size::Md => (px(36.), px(16.), px(8.), crate::anim::PRESSED_SCALE),
393            Size::Lg => (px(40.), px(16.), px(8.), crate::anim::PRESSED_SCALE_FIRM),
394        };
395        let (text, line) = match self.size {
396            Size::Sm | Size::Md => (px(14.), px(20.)),
397            Size::Lg => (px(16.), px(24.)),
398        };
399        el = el.h(height).text_size(text).line_height(line);
400        el = if self.is_icon_only {
401            el.w(height)
402        } else {
403            el.px(pad_x).gap(gap)
404        };
405
406        el = crate::button::group_radius_any(el, self.group_edge, radius);
407        el = crate::util::round_sx_corners(el, &sx_corners);
408
409        if let Some(colors) = fade {
410            let edge = self.group_edge;
411            el = crate::anim::hover_fade(
412                el,
413                element_id::scoped(&self.id, "fade"),
414                colors,
415                interaction.as_ref(),
416                None,
417                move |fill| {
418                    crate::util::round_sx_corners(
419                        crate::button::group_radius_any(fill, edge, radius),
420                        &sx_corners,
421                    )
422                },
423                window,
424                cx,
425            );
426        }
427
428        if self.is_disabled {
429            el = el.opacity(disabled_opacity);
430        }
431
432        if let Some(render) = self.content.clone() {
433            let (is_hovered, is_pressed) = interaction
434                .as_ref()
435                .map(|slot| *slot.read(cx))
436                .unwrap_or_default();
437            let focused = focus_handle.is_focused(window);
438            el = el.child(render(crate::util::InteractiveState {
439                is_hovered,
440                is_pressed,
441                is_focused: focused,
442                is_focus_visible: focused && crate::util::focus_visible(cx),
443                is_selected,
444                is_disabled: self.is_disabled,
445                is_pending: false,
446                is_indeterminate: false,
447            }));
448        } else if let Some(label) = self.label {
449            el = el.child(label.to_string());
450        }
451        // The interaction tracking belongs on the press slot, like Button:
452        // key events dispatch along the focus path, which runs through the
453        // slot the focus handle below tracks.
454        el = el.children(self.children);
455
456        // The press wrap (or the disabled dimming, which must cover the label
457        // above the skin too) comes after every visual child: children added
458        // after `pressed` land on the slot and fight the skin for width.
459        if !self.is_disabled {
460            let hover_bg = if is_selected {
461                colors.accent.soft_hover()
462            } else {
463                match self.variant {
464                    ToggleVariant::Default => colors.default.hover(),
465                    ToggleVariant::Ghost => colors.default.color,
466                }
467            };
468            el = crate::util::cursor_interactive(el, cx);
469            if fade.is_none() {
470                el = el.hover(move |s| s.bg(hover_bg));
471            }
472            // v3 documents ToggleButton's pressed state as including the same
473            // size-specific scale. Group members suppress it so the attached
474            // control never opens gaps between buttons while pressed.
475            //
476            // Standalone, the press rides `pressed_with_background_ramp`:
477            // `toggle-button.css` declares the press as a transition
478            // (`transform 250ms var(--ease-smooth), background-color 100ms
479            // var(--ease-out)`), so the fill eases between the resting
480            // colour the hover fade holds and the pressed endpoint.
481            let press_endpoints = fade.map(|(idle, _)| (idle, hover_bg));
482            if is_grouped {
483                el = el.active(move |style| style.bg(hover_bg));
484            } else {
485                el = crate::anim::pressed_with_background_ramp(
486                    el,
487                    crate::anim::PressBox {
488                        height,
489                        padding_x: (!self.is_icon_only).then_some(pad_x),
490                        width: self.is_icon_only.then_some(height),
491                        min_width: None,
492                        text_size: text,
493                        line_height: line,
494                        gap,
495                        radius,
496                        shrink_x: true,
497                        scale: press_scale,
498                    },
499                    press_endpoints,
500                    crate::anim::BUTTON_PRESS,
501                    interaction.as_ref(),
502                    window,
503                    cx,
504                );
505            }
506        }
507
508        if let Some(slot) = &interaction {
509            el = crate::util::track_interaction(el, slot);
510        }
511
512        if !self.is_disabled
513            && (self.on_press.is_some() || self.on_change.is_some() || own.is_some())
514        {
515            let on_press = self.on_press;
516            let on_change = self.on_change;
517            let next = !is_selected;
518            el = el.on_click(move |ev, w, cx| {
519                // Uncontrolled: flip our own copy, or a standalone toggle could
520                // never change.
521                if let Some(held) = &own {
522                    held.update(cx, |v, cx| {
523                        *v = next;
524                        cx.notify();
525                    });
526                }
527                if let Some(cb) = &on_change {
528                    cb(&next, w, cx);
529                }
530                if let Some(cb) = &on_press {
531                    cb(ev, w, cx);
532                }
533            });
534        }
535
536        if self.is_disabled {
537            return crate::util::apply_sx(el, &self.sx);
538        }
539        // The ring is the overlay form wherever the four corners resolve to
540        // one radius, as `Button` does it: an overlay is crisp and concentric
541        // where a spread shadow keeps the element's own corner. A grouped
542        // `Start` or `End` member rounds only its outer edge and an `sx`
543        // refinement can break the symmetry of any member, and the overlay's
544        // outer band is built from a scalar, so those keep the shadow ring,
545        // which dilates whatever per-corner shape the element already has.
546        let el = match crate::button::uniform_ring_radius(self.group_edge, radius, &sx_corners) {
547            Some(ring_radius) => crate::util::ring_overlay_if_focused(
548                el.track_focus(&focus_handle),
549                &focus_handle,
550                !is_grouped,
551                ring_radius,
552                Vec::new(),
553                window,
554                cx,
555            ),
556            None => crate::util::ring_if_focused(
557                el.track_focus(&focus_handle),
558                &focus_handle,
559                !is_grouped,
560                Vec::new(),
561                window,
562                cx,
563            ),
564        };
565        crate::util::apply_sx(el, &self.sx)
566    }
567}
568
569// ---------------------------------------------------------------------------
570// ToggleButtonGroup
571// ---------------------------------------------------------------------------
572
573#[derive(IntoElement)]
574pub struct ToggleButtonGroup {
575    id: ElementId,
576    selected: Vec<SharedString>,
577    default_selected: Vec<SharedString>,
578    /// `Vec` alone cannot distinguish controlled empty from uncontrolled.
579    is_controlled: bool,
580    selection_mode: SelectionMode,
581    size: Size,
582    is_disabled: bool,
583    is_detached: bool,
584    /// Whether a `ToggleButtonGroup.Separator` sits before each member after
585    /// the first. v3 composes it as a child, and hides it when detached.
586    separators: bool,
587    is_vertical: bool,
588    disallow_empty_selection: bool,
589    full_width: bool,
590    children: Vec<ToggleButton>,
591    on_change: Option<std::sync::Arc<dyn Fn(&[SharedString], &mut Window, &mut App) + 'static>>,
592    /// The `sx` slot, refined over the root style at the end of render.
593    sx: Option<Box<gpui::StyleRefinement>>,
594}
595
596impl ToggleButtonGroup {
597    /// `orientation` — lays the group out along the given axis.
598    pub fn orientation(mut self, orientation: SelectionOrientation) -> Self {
599        self.is_vertical = orientation == SelectionOrientation::Vertical;
600        self
601    }
602
603    /// `disallowEmptySelection` — keeps at least one member selected.
604    pub fn disallow_empty_selection(mut self, v: bool) -> Self {
605        self.disallow_empty_selection = v;
606        self
607    }
608
609    /// `onSelectionChange` — the v3 name for [`ToggleButtonGroup::on_change`].
610    pub fn on_selection_change(
611        self,
612        handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
613    ) -> Self {
614        self.on_change(handler)
615    }
616
617    pub fn new(id: impl Into<ElementId>) -> Self {
618        Self {
619            id: id.into(),
620            selected: Vec::new(),
621            default_selected: Vec::new(),
622            is_controlled: false,
623            selection_mode: SelectionMode::Single,
624            size: Size::Md,
625            is_disabled: false,
626            is_detached: false,
627            separators: false,
628            is_vertical: false,
629            disallow_empty_selection: false,
630            full_width: false,
631            children: Vec::new(),
632            on_change: None,
633            sx: None,
634        }
635    }
636
637    pub fn selection_mode(mut self, m: SelectionMode) -> Self {
638        self.selection_mode = m;
639        self
640    }
641
642    /// `size` — inherited by children that do not set their own size.
643    pub fn size(mut self, size: Size) -> Self {
644        self.size = size;
645        self
646    }
647
648    /// `isDisabled` — disables every child, matching React Aria's group state.
649    pub fn is_disabled(mut self, v: bool) -> Self {
650        self.is_disabled = v;
651        self
652    }
653
654    /// `ToggleButtonGroup.Separator` — the hairline between members.
655    pub fn separators(mut self, v: bool) -> Self {
656        self.separators = v;
657        self
658    }
659
660    pub fn is_detached(mut self, v: bool) -> Self {
661        self.is_detached = v;
662        self
663    }
664
665    pub fn full_width(mut self, v: bool) -> Self {
666        self.full_width = v;
667        self
668    }
669
670    /// The one slot for caller-owned low-level styling: GPUI's styling methods
671    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
672    /// applied to the group's root element after every value the orientation and
673    /// the active theme chose, so they win. It restyles the row the members sit
674    /// in; each member keeps its own `sx` slot.
675    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
676        self.sx = Some(crate::util::capture_sx(style));
677        self
678    }
679
680    pub fn selected_keys(
681        mut self,
682        keys: impl IntoIterator<Item = impl Into<SharedString>>,
683    ) -> Self {
684        self.selected = keys.into_iter().map(Into::into).collect();
685        self.is_controlled = true;
686        self
687    }
688
689    /// `defaultSelectedKeys` — seeds the group's own selection state.
690    pub fn default_selected_keys(
691        mut self,
692        keys: impl IntoIterator<Item = impl Into<SharedString>>,
693    ) -> Self {
694        self.default_selected = keys.into_iter().map(Into::into).collect();
695        self
696    }
697
698    pub fn on_change(
699        mut self,
700        f: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
701    ) -> Self {
702        self.on_change = Some(std::sync::Arc::new(f));
703        self
704    }
705
706    pub fn child_toggle(mut self, btn: ToggleButton) -> Self {
707        self.children.push(btn);
708        self
709    }
710}
711
712impl RenderOnce for ToggleButtonGroup {
713    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
714        let (selected, selection_own) = crate::util::controlled(
715            window,
716            cx,
717            element_id::scoped(&self.id, "selected"),
718            self.is_controlled.then_some(self.selected),
719            self.default_selected,
720        );
721        // `.toggle-button-group` is `inline-flex items-center justify-center
722        // gap-0`; `--detached` is `gap-1` and restores each member's full
723        // radius.
724        let gap = if self.is_detached { px(4.) } else { px(0.) };
725        // `useToggleButtonGroup` starts from `useToolbar` — whose role is
726        // `toolbar` and which always reports `aria-orientation` — and replaces
727        // the role with `radiogroup` when the group selects one member.
728        let orientation = if self.is_vertical {
729            SelectionOrientation::Vertical
730        } else {
731            SelectionOrientation::Horizontal
732        };
733        let group_role = if self.selection_mode == SelectionMode::Single {
734            a11y::Role::RadioGroup
735        } else {
736            a11y::Role::Toolbar
737        };
738        let mut row = div()
739            .id(self.id.clone())
740            .a11y(group_role)
741            .a11y_orientation(orientation)
742            .flex()
743            .items_center()
744            .justify_center()
745            .when(self.is_vertical, |r| r.flex_col())
746            .when(!self.is_vertical, |r| r.flex_row())
747            .gap(gap)
748            .when(self.full_width, |r| r.w_full());
749        let total = self.children.len();
750        let separators = self.separators && !self.is_detached;
751        let is_vertical = self.is_vertical;
752        let full_width = self.full_width;
753        // `.toggle-button-group__separator` is `bg-current opacity-15`, 1px by
754        // half the member, one pixel before its leading edge. This used to be a
755        // 20px line in `--separator` with a 2px margin, which is neither the
756        // colour nor the geometry v3 draws.
757        let separator_color = cx.colors().foreground.alpha(0.15);
758        let separator_radius = crate::util::hairline_radius(cx);
759
760        let mode = self.selection_mode;
761        let disallow_empty = self.disallow_empty_selection;
762
763        let mut children = self
764            .children
765            .into_iter()
766            .map(|button| {
767                button
768                    .group_managed()
769                    .group_selection_mode(mode)
770                    .group_disabled(self.is_disabled)
771                    .group_size(self.size)
772            })
773            .collect::<Vec<_>>();
774        let members = children
775            .iter()
776            .filter(|button| !button.is_disabled)
777            .map(|button| {
778                (
779                    button.selection_key(),
780                    crate::util::tab_stop_handle(
781                        element_id::scoped(
782                            &element_id::scoped(
783                                &element_id::scoped(&self.id, "member"),
784                                format!("{:?}", button.id),
785                            ),
786                            "focus",
787                        ),
788                        window,
789                        cx,
790                    ),
791                )
792            })
793            .collect::<Vec<_>>();
794        let focus_state =
795            window.use_keyed_state(element_id::scoped(&self.id, "focus-state"), cx, |_, _| {
796                ToggleGroupFocusState::default()
797            });
798        let current = members
799            .iter()
800            .position(|(_, handle)| handle.is_focused(window));
801        let snapshot = focus_state.read(cx).clone();
802        if let Some(current) = current {
803            let mut effective = current;
804            if !snapshot.was_inside && snapshot.restore_on_entry {
805                if let Some(last) = &snapshot.last_key {
806                    if let Some(restored) = members.iter().position(|(key, _)| key == last) {
807                        effective = restored;
808                        if restored != current {
809                            window.focus(&members[restored].1, cx);
810                        }
811                    }
812                }
813            }
814            let key = members[effective].0.clone();
815            focus_state.update(cx, |state, _| {
816                state.last_key = Some(key);
817                state.was_inside = true;
818                state.restore_on_entry = false;
819                state.edge_exit = false;
820            });
821        } else if snapshot.was_inside {
822            focus_state.update(cx, |state, _| {
823                state.was_inside = false;
824                state.restore_on_entry = !state.edge_exit;
825                state.edge_exit = false;
826            });
827        } else if snapshot.edge_exit {
828            focus_state.update(cx, |state, _| state.edge_exit = false);
829        }
830
831        let vertical = self.is_vertical;
832        let key_focuses = members
833            .iter()
834            .map(|(_, handle)| handle.clone())
835            .collect::<Vec<_>>();
836        let key_focus_state = focus_state.clone();
837        row = row.on_key_down(move |event: &gpui::KeyDownEvent, window, cx| {
838            let movement = match (vertical, event.keystroke.key.as_str()) {
839                (false, "right") | (true, "down") => Some("next"),
840                (false, "left") | (true, "up") => Some("prev"),
841                _ => None,
842            };
843
844            if let Some(movement) = movement {
845                // Pinned `useToolbar` omits the nested group's handler when an
846                // ancestor toolbar owns the arrows. GPUI bubbles key handlers,
847                // so mark the exit and let that ancestor handle this key.
848                if window
849                    .context_stack()
850                    .iter()
851                    .any(|context| context.contains("Toolbar"))
852                {
853                    key_focus_state.update(cx, |state, _| state.edge_exit = true);
854                    window.refresh();
855                    return;
856                }
857                // Cross-axis arrows return above unconsumed. The group owns
858                // every key on its own axis, including one that cannot move at
859                // an edge, matching the pinned `useToolbar` handler.
860                cx.stop_propagation();
861                let Some(index) = key_focuses
862                    .iter()
863                    .position(|handle| handle.is_focused(window))
864                else {
865                    return;
866                };
867                let next = if movement == "next" {
868                    index
869                        .checked_add(1)
870                        .filter(|next| *next < key_focuses.len())
871                } else {
872                    index.checked_sub(1)
873                };
874                if let Some(next) = next {
875                    window.focus(&key_focuses[next], cx);
876                }
877                return;
878            }
879
880            if matches!(
881                event.keystroke.key.as_str(),
882                "up" | "down" | "left" | "right"
883            ) {
884                // A cross-axis arrow may belong to an enclosing toolbar. If it
885                // moves focus out, do not restore the inner group's last item.
886                key_focus_state.update(cx, |state, _| state.edge_exit = true);
887                window.refresh();
888                return;
889            }
890
891            if event.keystroke.key != "tab" {
892                return;
893            }
894            // `useToolbar` moves to the edge and deliberately leaves Tab
895            // unconsumed. The app root then performs its ordinary one step,
896            // which exits the whole group instead of walking another member.
897            let edge = if event.keystroke.modifiers.shift {
898                key_focuses.first()
899            } else {
900                key_focuses.last()
901            };
902            if let Some(edge) = edge {
903                window.focus(edge, cx);
904            }
905        });
906
907        let mut member_focuses = members.into_iter().map(|(_, handle)| handle);
908        for (i, btn) in children.drain(..).enumerate() {
909            // Reflect the group's selection into the child, and let the child
910            // report the next selection back through the group's callback.
911            let key = btn.selection_key();
912            let is_selected = selected.iter().any(|k| k == &key);
913            let mut btn = if btn.is_disabled {
914                btn.is_selected(is_selected)
915            } else {
916                btn.group_focus_handle(
917                    member_focuses
918                        .next()
919                        .expect("every enabled toggle has a group focus handle"),
920                )
921                .is_selected(is_selected)
922            };
923
924            if self.on_change.is_some() || selection_own.is_some() {
925                let on_change = self.on_change.clone();
926                let own = selection_own.clone();
927                let current = selected.clone();
928                let key = key.clone();
929                btn = btn.group_on_press(move |_, window, cx| {
930                    let next =
931                        crate::selection::next_selection(&current, &key, mode, disallow_empty);
932                    if let Some(held) = &own {
933                        let held_next = next.clone();
934                        held.update(cx, |value, cx| {
935                            *value = held_next;
936                            cx.notify();
937                        });
938                    }
939                    if let Some(change) = &on_change {
940                        change(&next, window, cx);
941                    }
942                });
943            }
944
945            // The edge decides which corners stay round, so it has to reach the
946            // `ToggleButton` before it becomes an element.
947            let edge = if self.is_detached || total <= 1 {
948                crate::button::GroupEdge::Only
949            } else if i == 0 {
950                crate::button::GroupEdge::Start
951            } else if i + 1 == total {
952                crate::button::GroupEdge::End
953            } else {
954                crate::button::GroupEdge::Middle
955            };
956            let mut slot = div()
957                .relative()
958                .child(btn.group_edge(edge, is_vertical))
959                .when(full_width, |sl| sl.flex_1());
960            if separators && i > 0 {
961                slot = slot.child(
962                    div()
963                        .absolute()
964                        .bg(separator_color)
965                        .rounded(separator_radius)
966                        .map(|sep| {
967                            if is_vertical {
968                                sep.left(gpui::relative(0.25))
969                                    .top(px(-1.))
970                                    .w(gpui::relative(0.5))
971                                    .h(px(1.))
972                            } else {
973                                sep.left(px(-1.))
974                                    .top(gpui::relative(0.25))
975                                    .w(px(1.))
976                                    .h(gpui::relative(0.5))
977                            }
978                        }),
979                );
980            }
981            row = row.child(slot);
982        }
983
984        row = crate::util::apply_sx(row, &self.sx);
985        row
986    }
987}
988
989/// Separator element for ToggleButtonGroup — visual divider.
990#[derive(IntoElement)]
991pub struct ToggleSeparator;
992
993impl RenderOnce for ToggleSeparator {
994    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
995        div().w(px(1.)).h(px(20.)).bg(cx.colors().separator)
996    }
997}
998
999#[cfg(test)]
1000mod tests {
1001    use super::*;
1002
1003    #[test]
1004    fn selection_key_defaults_to_the_element_id() {
1005        // A group namespaces its child ids, so selection has to fall back to
1006        // the id when no explicit key is given.
1007        let plain = ToggleButton::new("bold");
1008        assert_eq!(plain.selection_key().as_ref(), "bold");
1009        let keyed = ToggleButton::new("grp-bold").key("bold");
1010        assert_eq!(keyed.selection_key().as_ref(), "bold");
1011    }
1012
1013    #[test]
1014    fn group_separators_are_explicit_composition() {
1015        assert!(
1016            !ToggleButtonGroup::new("plain").separators,
1017            "v3 groups without a Separator child must not synthesize dividers"
1018        );
1019        assert!(
1020            ToggleButtonGroup::new("separated")
1021                .separators(true)
1022                .separators
1023        );
1024    }
1025}