Skip to main content

herogpui_components/
tabs.rs

1//! Tabs — port of `@heroui/tabs`.
2
3use std::{cell::Cell, rc::Rc, time::Duration};
4
5use gpui::{
6    prelude::*, px, Animation, AnimationExt, AnyElement, App, InteractiveElement, IntoElement,
7    RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
8};
9use herogpui_core::{element_id, Orientation};
10use herogpui_theme::ActiveTheme;
11
12use crate::a11y::{self, A11y as _};
13
14/// Tab bar style (`variant`).
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum TabsVariant {
17    /// Filled indicator behind the selected tab.
18    #[default]
19    Primary,
20    /// Underline indicator beneath the selected tab.
21    Secondary,
22}
23
24impl TabsVariant {
25    /// Every variant, in display order.
26    pub const ALL: [TabsVariant; 2] = [TabsVariant::Primary, TabsVariant::Secondary];
27
28    /// The display name of this variant.
29    pub fn label(self) -> &'static str {
30        match self {
31            TabsVariant::Primary => "Primary",
32            TabsVariant::Secondary => "Secondary",
33        }
34    }
35}
36
37/// Content alignment inside each tab (`align`), independent of orientation.
38/// Start and end follow the port's left-to-right layout.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum TabsAlign {
41    /// Aligns tab content to the start.
42    Start,
43    /// Centers tab content.
44    #[default]
45    Center,
46    /// Aligns tab content to the end.
47    End,
48}
49
50impl TabsAlign {
51    /// Every alignment, in display order.
52    pub const ALL: [Self; 3] = [Self::Start, Self::Center, Self::End];
53
54    /// The display name of this alignment.
55    pub fn label(self) -> &'static str {
56        match self {
57            Self::Start => "Start",
58            Self::Center => "Center",
59            Self::End => "End",
60        }
61    }
62
63    fn apply<T: Styled>(self, tab: T) -> T {
64        match self {
65            Self::Start => tab.justify_start().text_left(),
66            Self::Center => tab.justify_center().text_center(),
67            Self::End => tab.justify_end().text_right(),
68        }
69    }
70}
71
72/// `.tabs__separator` transitions its opacity for 150ms with `--ease-smooth`.
73const SEPARATOR_TRANSITION_MS: u64 = 150;
74/// `.tabs__indicator` transitions translate, width and height for 250ms with
75/// `--ease-out-fluid`.
76const INDICATOR_TRANSITION_MS: u64 = 250;
77/// v3's vertical-list tab floor (`.tabs__list[data-orientation="vertical"]`
78/// sizes each tab `min-w-20`): a vertical tab never narrows below 80px, so a
79/// short label still leaves the list a usable width.
80const VERTICAL_TAB_MIN_WIDTH: gpui::Pixels = px(80.);
81
82#[derive(Clone, Copy, Debug, Default, PartialEq)]
83struct IndicatorRect {
84    x: gpui::Pixels,
85    y: gpui::Pixels,
86    width: gpui::Pixels,
87    height: gpui::Pixels,
88}
89
90#[derive(Clone)]
91struct IndicatorMotion {
92    target: IndicatorRect,
93    generation: usize,
94    from: IndicatorRect,
95    rect: Rc<Cell<IndicatorRect>>,
96}
97
98struct IndicatorMotionFrame {
99    id: gpui::ElementId,
100    generation: usize,
101    from: IndicatorRect,
102    to: IndicatorRect,
103    rect: Rc<Cell<IndicatorRect>>,
104    animate: bool,
105}
106
107impl IndicatorMotionFrame {
108    fn render(self, indicator: gpui::Div) -> AnyElement {
109        if !self.animate {
110            self.rect.set(self.to);
111            return place_indicator(indicator, self.to).into_any_element();
112        }
113
114        let rect = self.rect;
115        let from = self.from;
116        let to = self.to;
117        indicator
118            .with_animation(
119                element_id::indexed(&self.id, "slide", self.generation),
120                Animation::new(Duration::from_millis(INDICATOR_TRANSITION_MS))
121                    .with_easing(|t| crate::anim::Curve::OutFluid.at(t)),
122                move |indicator, delta| {
123                    let next = IndicatorRect {
124                        x: from.x + (to.x - from.x) * delta,
125                        y: from.y + (to.y - from.y) * delta,
126                        width: from.width + (to.width - from.width) * delta,
127                        height: from.height + (to.height - from.height) * delta,
128                    };
129                    rect.set(next);
130                    place_indicator(indicator, next)
131                },
132            )
133            .into_any_element()
134    }
135}
136
137fn place_indicator(indicator: gpui::Div, rect: IndicatorRect) -> gpui::Div {
138    indicator
139        .left(rect.x)
140        .top(rect.y)
141        .w(rect.width)
142        .h(rect.height)
143}
144
145fn indicator_motion(
146    id: gpui::ElementId,
147    target: IndicatorRect,
148    window: &mut Window,
149    cx: &mut App,
150) -> IndicatorMotionFrame {
151    let state = window.use_keyed_state(id.clone(), cx, |_, _| IndicatorMotion {
152        target,
153        generation: 0,
154        from: target,
155        rect: Rc::new(Cell::new(target)),
156    });
157    let mut current = state.read(cx).clone();
158    if current.target != target {
159        current.target = target;
160        current.generation = current.generation.wrapping_add(1);
161        current.from = current.rect.get();
162        state.update(cx, |stored, _| *stored = current.clone());
163    }
164    if ActiveTheme::reduce_motion(cx) && current.rect.get() != target {
165        current.from = target;
166        current.rect.set(target);
167        state.update(cx, |stored, _| *stored = current.clone());
168    }
169    IndicatorMotionFrame {
170        id,
171        generation: current.generation,
172        from: current.from,
173        to: target,
174        rect: current.rect,
175        animate: current.generation != 0
176            && !ActiveTheme::reduce_motion(cx)
177            && current.from != target,
178    }
179}
180
181#[derive(Clone, Debug, Default)]
182struct TabsGeometry {
183    list: Option<gpui::Bounds<gpui::Pixels>>,
184    tabs: Vec<(SharedString, gpui::Bounds<gpui::Pixels>)>,
185}
186
187fn indicator_target(
188    geometry: &TabsGeometry,
189    selected_key: &SharedString,
190    vertical: bool,
191    secondary: bool,
192) -> Option<IndicatorRect> {
193    let list = geometry.list?;
194    let tab = geometry
195        .tabs
196        .iter()
197        .find_map(|(key, bounds)| (key == selected_key).then_some(*bounds))?;
198    let x = tab.origin.x - list.origin.x;
199    let y = tab.origin.y - list.origin.y;
200    Some(if secondary && vertical {
201        IndicatorRect {
202            x: px(0.),
203            y,
204            width: px(2.),
205            height: tab.size.height,
206        }
207    } else if secondary {
208        IndicatorRect {
209            x,
210            y: list.size.height - px(2.),
211            width: tab.size.width,
212            height: px(2.),
213        }
214    } else {
215        IndicatorRect {
216            x,
217            y,
218            width: tab.size.width,
219            height: tab.size.height,
220        }
221    })
222}
223
224fn scroll_tab_into_view(
225    scroll: &gpui::ScrollHandle,
226    geometry: &TabsGeometry,
227    key: &SharedString,
228    vertical: bool,
229) {
230    let Some(tab) = geometry
231        .tabs
232        .iter()
233        .find_map(|(held, bounds)| (held == key).then_some(*bounds))
234    else {
235        return;
236    };
237    let viewport = scroll.bounds();
238    let at = scroll.offset();
239    let next = if vertical {
240        let before = viewport.top() - tab.top();
241        let after = tab.bottom() - viewport.bottom();
242        if before > px(0.) && after > px(0.) {
243            if before <= after {
244                gpui::point(at.x, at.y + before)
245            } else {
246                gpui::point(at.x, at.y - after)
247            }
248        } else if before > px(0.) {
249            gpui::point(at.x, at.y + viewport.top() - tab.top())
250        } else if after > px(0.) {
251            gpui::point(at.x, at.y - (tab.bottom() - viewport.bottom()))
252        } else {
253            return;
254        }
255    } else {
256        let before = viewport.left() - tab.left();
257        let after = tab.right() - viewport.right();
258        if before > px(0.) && after > px(0.) {
259            if before <= after {
260                gpui::point(at.x + before, at.y)
261            } else {
262                gpui::point(at.x - after, at.y)
263            }
264        } else if before > px(0.) {
265            gpui::point(at.x + viewport.left() - tab.left(), at.y)
266        } else if after > px(0.) {
267            gpui::point(at.x - (tab.right() - viewport.right()), at.y)
268        } else {
269            return;
270        }
271    };
272    scroll.set_offset(next);
273}
274
275#[derive(Clone)]
276struct SeparatorMotion {
277    hidden: bool,
278    generation: usize,
279    from: f32,
280    opacity: Rc<Cell<f32>>,
281}
282
283struct SeparatorMotionFrame {
284    id: gpui::ElementId,
285    generation: usize,
286    from: f32,
287    to: f32,
288    opacity: Rc<Cell<f32>>,
289    animate: bool,
290}
291
292impl SeparatorMotionFrame {
293    fn render(self, separator: gpui::Div) -> AnyElement {
294        if !self.animate {
295            self.opacity.set(self.to);
296            return separator.opacity(self.to).into_any_element();
297        }
298
299        let opacity = self.opacity;
300        let from = self.from;
301        let to = self.to;
302        separator
303            .with_animation(
304                element_id::indexed(&self.id, "fade", self.generation),
305                Animation::new(Duration::from_millis(SEPARATOR_TRANSITION_MS))
306                    .with_easing(|t| crate::anim::Curve::Smooth.at(t)),
307                move |separator, delta| {
308                    let next = from + (to - from) * delta;
309                    opacity.set(next);
310                    separator.opacity(next)
311                },
312            )
313            .into_any_element()
314    }
315}
316
317fn separator_motion(
318    id: gpui::ElementId,
319    hidden: bool,
320    window: &mut Window,
321    cx: &mut App,
322) -> SeparatorMotionFrame {
323    let target = if hidden { 0. } else { 1. };
324    let state = window.use_keyed_state(id.clone(), cx, |_, _| SeparatorMotion {
325        hidden,
326        generation: 0,
327        from: target,
328        opacity: Rc::new(Cell::new(target)),
329    });
330    let mut current = state.read(cx).clone();
331    if current.hidden != hidden {
332        current.hidden = hidden;
333        current.generation = current.generation.wrapping_add(1);
334        current.from = current.opacity.get();
335        state.update(cx, |stored, _| *stored = current.clone());
336    }
337    if ActiveTheme::reduce_motion(cx) && (current.opacity.get() - target).abs() > f32::EPSILON {
338        current.from = target;
339        current.opacity.set(target);
340        state.update(cx, |stored, _| *stored = current.clone());
341    }
342    SeparatorMotionFrame {
343        id,
344        generation: current.generation,
345        from: current.from,
346        to: target,
347        opacity: current.opacity,
348        animate: current.generation != 0
349            && !ActiveTheme::reduce_motion(cx)
350            && (current.from - target).abs() > f32::EPSILON,
351    }
352}
353
354/// When arrow-key focus changes become selection.
355#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
356pub enum KeyboardActivation {
357    /// Select as focus moves, which is React Aria's default.
358    #[default]
359    Automatic,
360    /// Move focus only; Enter or Space selects the focused tab.
361    Manual,
362}
363
364/// One tab: key + label + panel content.
365#[must_use = "builder methods return a new value; pass it on to its component"]
366#[non_exhaustive]
367pub struct TabItem {
368    /// Stable key identifying the tab.
369    pub key: SharedString,
370    /// Visible text and accessible name of the tab.
371    pub label: SharedString,
372    /// Replacement content for the segment trigger itself; the label text is
373    /// not rendered when it is set. `label` stays the tab's accessible name —
374    /// an svg or icon div child carries no accessible name of its own.
375    pub trigger: Option<AnyElement>,
376    /// Content shown in the panel when the tab is selected.
377    pub content: Option<AnyElement>,
378    /// `Tabs.Tab.isDisabled` — removes this tab from activation and the roving
379    /// keyboard stops without disabling its siblings.
380    pub is_disabled: bool,
381    /// `<Tabs.Separator />` inside this tab. v3 made the hairline between
382    /// segments opt-in per tab in 3.0.0-beta.12, replacing the automatic
383    /// pseudo-element and the `hideSeparator` prop it deleted.
384    pub separator: bool,
385    /// Fixed segment width; `None` keeps the content-hugging box. Set with
386    /// [`TabItem::width`].
387    pub width: Option<gpui::Pixels>,
388    /// Fixed segment height; `None` keeps the size step's tab height. Set
389    /// with [`TabItem::height`].
390    pub height: Option<gpui::Pixels>,
391    /// Fixed horizontal padding; `None` keeps the size step's tab padding.
392    /// Set with [`TabItem::padding_x`].
393    pub padding_x: Option<gpui::Pixels>,
394}
395
396impl TabItem {
397    /// Creates a tab from a key and a label.
398    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
399        Self {
400            key: key.into(),
401            label: label.into(),
402            trigger: None,
403            content: None,
404            is_disabled: false,
405            separator: false,
406            width: None,
407            height: None,
408            padding_x: None,
409        }
410    }
411
412    /// Renders this element as the segment trigger instead of the label text.
413    ///
414    /// This is how the icon-only segmented controls in the reference
415    /// application are built: every trigger stays the same tab box with its
416    /// id, hit area, hover, focus ring and keyboard activation, while the
417    /// content becomes an icon. The trigger is presentational — keep it a
418    /// plain `svg()` or `div()` so presses keep reaching the tab — and
419    /// `svg()` does not inherit the tab's `text_color`, so set it explicitly.
420    /// `label` is still the accessible name; an icon child carries none.
421    pub fn trigger(mut self, el: impl IntoElement) -> Self {
422        self.trigger = Some(el.into_any_element());
423        self
424    }
425
426    /// Sets the panel content of the tab.
427    pub fn content(mut self, el: impl IntoElement) -> Self {
428        self.content = Some(el.into_any_element());
429        self
430    }
431
432    /// Sets whether the tab is disabled (v3 `Tabs.Tab.isDisabled`).
433    pub fn is_disabled(mut self, value: bool) -> Self {
434        self.is_disabled = value;
435        self
436    }
437
438    /// Composes a `Tabs.Separator` into this tab: a hairline before it.
439    pub fn separator(mut self) -> Self {
440        self.separator = true;
441        self
442    }
443
444    /// Fixes the segment's width in both variants, beating the box the tab
445    /// would otherwise hug its content into. This is the reference capture
446    /// toolbar's `TabsTrigger className="size-8"`: a square 32px segment
447    /// whose icon is narrower than the box. The sliding indicator measures
448    /// the fixed box, so its geometry follows the override exactly.
449    ///
450    /// An explicit width also wins over [`Tabs::full_width`] for this tab
451    /// alone — its siblings still divide the remaining width equally — and
452    /// replaces the vertical list's 80px minimum, which would otherwise
453    /// clamp a narrow segment back up.
454    ///
455    /// Not a v3 prop; it stands in for the `className` a caller passes to
456    /// `Tabs.Tab`.
457    pub fn width(mut self, w: impl Into<gpui::Pixels>) -> Self {
458        self.width = Some(w.into());
459        self
460    }
461
462    /// Fixes the segment's height in both variants, replacing the size
463    /// step's tab height (32px on the pinned `Md`, 28px on `Sm`) instead of
464    /// flooring it. The box stays fixed, so a wrapped label keeps painting
465    /// its extra lines past it exactly as it does at the pinned height.
466    ///
467    /// Not a v3 prop; it stands in for the `className` a caller passes to
468    /// `Tabs.Tab`.
469    pub fn height(mut self, h: impl Into<gpui::Pixels>) -> Self {
470        self.height = Some(h.into());
471        self
472    }
473
474    /// Replaces the size step's horizontal padding (`px-4` on the pinned
475    /// `Md` box, `px-3` on `Sm`). `0` means no horizontal padding — the
476    /// fixed icon segment whose whole box is already sized by
477    /// [`TabItem::width`] and [`TabItem::height`].
478    ///
479    /// Not a v3 prop; it stands in for the `className` a caller passes to
480    /// `Tabs.Tab`.
481    pub fn padding_x(mut self, p: impl Into<gpui::Pixels>) -> Self {
482        self.padding_x = Some(p.into());
483        self
484    }
485}
486
487type OnChange = std::sync::Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
488
489#[derive(Clone)]
490struct TabFocusState {
491    key: SharedString,
492    selected_key: SharedString,
493    enabled_keys: Vec<SharedString>,
494}
495
496/// HeroGPUI-only compact step for [`Tabs`].
497///
498/// | Metric (pixels) | Sm | Md (pinned default) |
499/// | --- | --- | --- |
500/// | Tab height / horizontal padding | 28 / 12 | 32 / 16 |
501/// | Label text / line height | 12 / 16 | 14 / 20 |
502///
503/// Both steps have zero vertical tab padding; the whole tab box is its hitbox.
504/// Primary tabs and their full-box indicator keep `control_radius`, with a 4px
505/// list padding that [`Tabs::list_padding`] overrides. Secondary tabs stay
506/// square with a 2px accent indicator and no list padding. The content panel
507/// keeps 8px padding. Horizontal tabs adjoin
508/// and can scroll; `full_width` divides the available width equally. Vertical
509/// lists retain a 4px gap and each tab's 80px minimum width. Explicit `sx`
510/// pixel corners refine the indicator independently of its default shape.
511///
512/// v3.2.4 has no Tabs `size` prop (the box is `h-8 px-4 text-sm` through
513/// classes), so this is additive: `Md` is byte-identical to the pinned box and
514/// `Sm` is HeroGPUI's own 28px step. The secondary underline keeps its 2px
515/// thickness at both steps. Not a v3 prop.
516#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
517pub enum TabsSize {
518    /// The small size.
519    Sm,
520    /// The medium size.
521    #[default]
522    Md,
523}
524
525impl TabsSize {
526    /// Every size, in display order.
527    pub const ALL: [TabsSize; 2] = [Self::Sm, Self::Md];
528
529    /// `(height, horizontal padding, label text)` for this step; the label
530    /// leading follows [`crate::util::leading_for`].
531    fn metrics(self) -> (gpui::Pixels, gpui::Pixels, gpui::Pixels) {
532        match self {
533            Self::Sm => (px(28.), px(12.), px(12.)),
534            Self::Md => (px(32.), px(16.), px(14.)),
535        }
536    }
537
538    /// The display name of this size.
539    pub fn label(self) -> &'static str {
540        match self {
541            Self::Sm => "Small",
542            Self::Md => "Medium",
543        }
544    }
545}
546
547/// HeroUI Tabs (controlled).
548#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
549#[derive(IntoElement)]
550pub struct Tabs {
551    id: gpui::ElementId,
552    items: Vec<TabItem>,
553    /// `selectedKey` — `None` leaves the tabs holding the selection, seeded
554    /// from `defaultSelectedKey`.
555    selected_key: Option<SharedString>,
556    default_selected_key: Option<SharedString>,
557    variant: TabsVariant,
558    align: TabsAlign,
559    is_disabled: bool,
560    /// The compact step; `Md` is the pinned default.
561    size: TabsSize,
562    /// The tab labels' font size, in place of the size step's.
563    text_size: Option<gpui::Pixels>,
564    full_width: bool,
565    orientation: Orientation,
566    keyboard_activation: KeyboardActivation,
567    on_selection_change: Option<OnChange>,
568    /// Corner radius override for the list container and the indicator.
569    radius: Option<gpui::Pixels>,
570    /// List container fill override; the primary tray is `bg-default` by
571    /// default and the secondary tray is transparent.
572    list_bg: Option<gpui::Hsla>,
573    /// Indicator fill override; the primary segment token and the secondary
574    /// accent line are the defaults.
575    indicator_bg: Option<gpui::Hsla>,
576    /// Gates the indicator's `shadow-surface`; the default keeps it.
577    indicator_shadow: bool,
578    /// The primary tray's uniform inset override; the pinned default is 4px.
579    list_padding: Option<gpui::Pixels>,
580    /// A vertical list's per-tab width floor; the pinned default is 80px.
581    vertical_tab_min_width: Option<gpui::Pixels>,
582    /// Gates the unselected-tab hover wash; the default keeps it.
583    hover_fill: bool,
584    /// The `sx` slot, refined over the root style at the end of render.
585    sx: Option<Box<gpui::StyleRefinement>>,
586}
587
588impl Tabs {
589    /// `orientation` — a vertical tab list stacks its tabs.
590    pub fn orientation(mut self, orientation: Orientation) -> Self {
591        self.orientation = orientation;
592        self
593    }
594
595    /// Aligns content within each tab. The default is center. This does not
596    /// reposition the list, its indicator, panel content, or nested tabs.
597    pub fn align(mut self, align: TabsAlign) -> Self {
598        self.align = align;
599        self
600    }
601
602    /// React Aria's inherited `keyboardActivation`.
603    pub fn keyboard_activation(mut self, activation: KeyboardActivation) -> Self {
604        self.keyboard_activation = activation;
605        self
606    }
607
608    /// `selectedKey` — drives the tabs from outside; without it they hold
609    /// their own selection, seeded positionally by [`Tabs::new`].
610    pub fn selected_key(mut self, key: impl Into<SharedString>) -> Self {
611        self.selected_key = Some(key.into());
612        self
613    }
614
615    /// `defaultSelectedKey` — the uncontrolled initial tab, also accepted
616    /// positionally by [`Tabs::new`].
617    ///
618    /// Only consulted when `selectedKey` is not supplied; the tabs then own the
619    /// selection and switch themselves on press.
620    pub fn default_selected_key(mut self, key: impl Into<SharedString>) -> Self {
621        self.default_selected_key = Some(key.into());
622        self
623    }
624
625    /// The positional key is `defaultSelectedKey`, not `selectedKey`: seeding
626    /// the *controlled* prop leaves the tabs unable to switch themselves, so
627    /// every demo that passed a literal was inert. Pass
628    /// [`Tabs::selected_key`] to drive them from outside.
629    pub fn new(
630        id: impl Into<gpui::ElementId>,
631        items: Vec<TabItem>,
632        default_selected_key: impl Into<SharedString>,
633    ) -> Self {
634        Self {
635            id: id.into(),
636            items,
637            selected_key: None,
638            default_selected_key: Some(default_selected_key.into()),
639            variant: TabsVariant::Primary,
640            align: TabsAlign::default(),
641            is_disabled: false,
642            full_width: false,
643            size: TabsSize::default(),
644            text_size: None,
645            orientation: Orientation::Horizontal,
646            keyboard_activation: KeyboardActivation::Automatic,
647            on_selection_change: None,
648            radius: None,
649            list_bg: None,
650            indicator_bg: None,
651            indicator_shadow: true,
652            list_padding: None,
653            vertical_tab_min_width: None,
654            hover_fill: true,
655            sx: None,
656        }
657    }
658
659    /// Sets the visual variant (v3 `variant`).
660    pub fn variant(mut self, v: TabsVariant) -> Self {
661        self.variant = v;
662        self
663    }
664
665    /// Sets whether the whole tab list is disabled (v3 `isDisabled`).
666    pub fn is_disabled(mut self, v: bool) -> Self {
667        self.is_disabled = v;
668        self
669    }
670
671    /// Sets the compact step. `Md` is the default and byte-identical to the
672    /// pinned box; `Sm` is a 28px box with 12px padding and a 12px label
673    /// (16px leading). Not a v3 prop.
674    pub fn size(mut self, size: TabsSize) -> Self {
675        self.size = size;
676        self
677    }
678
679    /// The tab labels' font size, in place of the size step's. A 12/14/16px size
680    /// takes v3's leading pair (16/20/24); any other keeps the 20px leading.
681    /// Each tab keeps its fixed height and inline padding, so a larger type wants a matching `TabItem::height`. Not a v3 prop: v3 sets it with a class on `Tabs.Tab`.
682    pub fn text_size(mut self, size: impl Into<gpui::Pixels>) -> Self {
683        self.text_size = Some(size.into());
684        self
685    }
686
687    /// v3's `<Tabs.List className="w-full">` with `<Tabs.Trigger
688    /// className="flex-1">`: the list fills its parent and the tabs divide that
689    /// width equally instead of hugging their labels.
690    ///
691    /// Horizontal only. A vertical list already stretches its tabs across the
692    /// full width and sizes itself from the tallest content, so there is
693    /// nothing for this to change and it is ignored there.
694    pub fn full_width(mut self, v: bool) -> Self {
695        self.full_width = v;
696        self
697    }
698
699    /// Overrides the corner radius of the list container and the sliding
700    /// indicator: both take this exact value instead of the derived container
701    /// radius and the `control_radius` pill. The reference capture toolbar
702    /// sets `rounded-3xl` on both to read as one segmented control. The tab
703    /// boxes, their hover fades and their focus rings keep the pinned radius.
704    ///
705    /// Not a v3 prop; it stands in for the `className` the reference passes
706    /// to `Tabs.ListContainer` and `Tabs.Indicator`.
707    pub fn radius(mut self, radius: impl Into<gpui::Pixels>) -> Self {
708        self.radius = Some(radius.into());
709        self
710    }
711
712    /// Overrides the list container's fill. The primary tray is `bg-default`
713    /// and the secondary tray transparent by default; the reference
714    /// segmented control tints the tray with `bg-muted-foreground/10`.
715    ///
716    /// Not a v3 prop; it stands in for the `className` the reference passes
717    /// to `Tabs.ListContainer`.
718    pub fn list_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
719        self.list_bg = Some(color.into());
720        self
721    }
722
723    /// Overrides the indicator's fill in both variants — the primary segment
724    /// token and the secondary accent line. The reference segmented control
725    /// tints the indicator with `bg-muted-foreground/25`.
726    ///
727    /// Not a v3 prop; it stands in for the `className` the reference passes
728    /// to `Tabs.Indicator`.
729    pub fn indicator_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
730        self.indicator_bg = Some(color.into());
731        self
732    }
733
734    /// `false` drops the indicator's surface shadow, and the pre-measurement
735    /// active-tab fallback's, so an overridden indicator reads flat the way
736    /// the reference's `shadow-none` does. The default keeps the pinned
737    /// `shadow-surface`.
738    pub fn indicator_shadow(mut self, v: bool) -> Self {
739        self.indicator_shadow = v;
740        self
741    }
742
743    /// Overrides the primary tray's inset on every side. The pinned default
744    /// is the `p-1` the stylesheet puts on `.tabs__list`; the reference
745    /// capture toolbar removes it with `Tabs.List className="p-0"` so its
746    /// fixed 32px segments tile the tray edge to edge.
747    ///
748    /// Primary only: the Secondary tray has no inset of its own — v3's
749    /// secondary block already replaces the list's `p-1` with `p-0` — so
750    /// there is nothing for this to override there and the builder is
751    /// ignored in that variant.
752    ///
753    /// Not a v3 prop; it stands in for the `className` a caller passes to
754    /// `Tabs.List`.
755    pub fn list_padding(mut self, padding: impl Into<gpui::Pixels>) -> Self {
756        self.list_padding = Some(padding.into());
757        self
758    }
759
760    /// Replaces the width floor every tab of a vertical list keeps (v3's
761    /// `min-w-20`, 80px), so a narrow icon rail can go below it or a wide
762    /// one can set a larger floor. Only the vertical orientation reads it —
763    /// a horizontal list has no per-tab floor — and a tab's own
764    /// [`TabItem::width`] still wins over it. Not a v3 prop; it stands in for
765    /// the `className` a caller passes to `Tabs.Tab`.
766    pub fn vertical_tab_min_width(mut self, width: impl Into<gpui::Pixels>) -> Self {
767        self.vertical_tab_min_width = Some(width.into());
768        self
769    }
770
771    /// `false` drops the unselected-tab hover wash and replaces it with a
772    /// text-colour change: a hovered unselected tab keeps its resting fill
773    /// and its label moves to the theme foreground, while the selected
774    /// fill, the indicator, the focus rings and every tab's activation stay
775    /// exactly as they are. The wash is the port's translation of the
776    /// stylesheet's `opacity-70` dim on a hovered unselected tab — the same
777    /// tray-dimmed overlay for `list_bg` and a root `sx` background alike —
778    /// so with it off, none is painted regardless of what the tray resolves
779    /// to. The overflow chevrons keep their own hover dim. The default
780    /// keeps the wash.
781    ///
782    /// A `trigger` svg still needs its own `text_color`: gpui svgs do not
783    /// inherit the tab's, so this hover cannot recolour an icon the caller
784    /// painted explicitly. Unset the svg colour only when the rest colour
785    /// is acceptable; otherwise the caller owns both rest and hover.
786    ///
787    /// Not a v3 prop; v3's hover dim is stylesheet work, and turning it off
788    /// there is className work on the tab too.
789    pub fn hover_fill(mut self, v: bool) -> Self {
790        self.hover_fill = v;
791        self
792    }
793
794    /// `onSelectionChange` — reports the key of the tab the press moves to.
795    pub fn on_selection_change(
796        mut self,
797        f: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
798    ) -> Self {
799        self.on_selection_change = Some(std::sync::Arc::new(f));
800        self
801    }
802
803    /// The one slot for caller-owned low-level styling: GPUI's styling methods
804    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
805    /// applied to the tabs' root element after every value the variant and the
806    /// active theme chose, so they win.
807    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
808        crate::util::refine_sx(&mut self.sx, style);
809        self
810    }
811}
812
813impl RenderOnce for Tabs {
814    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
815        let base = self.id.clone();
816        // Only the `debug_selector` below still needs the id as a string, and
817        // it keeps that string's exact original spelling.
818        let base_id = format!("{:?}", self.id);
819
820        // `selectedKey` wins; without it the tabs hold the selection, seeded
821        // from `defaultSelectedKey` (falling back to the first tab so something
822        // is always active). `controlled` takes `cx` mutably, so it precedes
823        // the theme tokens.
824        let first_enabled = self
825            .items
826            .iter()
827            .find(|item| !item.is_disabled)
828            .or_else(|| self.items.first())
829            .map(|item| item.key.clone());
830        let fallback = self
831            .default_selected_key
832            .clone()
833            .or(first_enabled.clone())
834            .unwrap_or_default();
835        // One handle for the list: a tab list is one tab stop and the focused
836        // tab claims it, which is how the stop roves. Flipping a handle's
837        // `tab_stop` cannot do that -- it is fixed where the handle is made.
838        let list_focus =
839            crate::util::tab_stop_handle(element_id::scoped(&base, "focus"), window, cx);
840        let (mut selected_key, selection_own) = crate::util::controlled(
841            window,
842            cx,
843            element_id::scoped(&base, "selected"),
844            self.selected_key.clone(),
845            fallback,
846        );
847        // Pinned `useTabListState` repairs an invalid uncontrolled default and
848        // a selection whose item disappears from the collection. Use the
849        // repaired key in this frame as well as storing it, so the replacement
850        // panel and roving stop are never absent for a frame.
851        if self.selected_key.is_none() && !self.items.iter().any(|item| item.key == selected_key) {
852            if let (Some(next), Some(held)) = (first_enabled.clone(), selection_own.as_ref()) {
853                selected_key = next.clone();
854                held.update(cx, |value, cx| {
855                    *value = next.clone();
856                    cx.notify();
857                });
858                if let Some(cb) = &self.on_selection_change {
859                    cb(&next, window, cx);
860                }
861            }
862        }
863
864        // React Aria keeps the roving focus key separate from selectedKey.
865        // That distinction matters for controlled tabs: arrow keys move focus
866        // and report each newly focused key without mutating the selection the
867        // caller still owns.
868        let focus_seed = self
869            .items
870            .iter()
871            .find(|item| item.key == selected_key && !item.is_disabled)
872            .map(|item| item.key.clone())
873            .or(first_enabled)
874            .unwrap_or_else(|| selected_key.clone());
875        let enabled_keys: Vec<SharedString> = self
876            .items
877            .iter()
878            .filter(|item| !item.is_disabled)
879            .map(|item| item.key.clone())
880            .collect();
881        let focus_state =
882            window.use_keyed_state(element_id::scoped(&base, "focused-key"), cx, |_, _| {
883                TabFocusState {
884                    key: focus_seed.clone(),
885                    selected_key: selected_key.clone(),
886                    enabled_keys: enabled_keys.clone(),
887                }
888            });
889        let mut focus_now = focus_state.read(cx).clone();
890        let selection_changed = focus_now.selected_key != selected_key;
891        let focus_valid = enabled_keys.contains(&focus_now.key);
892        if selection_changed && !list_focus.is_focused(window) {
893            focus_now.key = focus_seed;
894        } else if !focus_valid {
895            let old_index = focus_now
896                .enabled_keys
897                .iter()
898                .position(|key| key == &focus_now.key);
899            focus_now.key = old_index
900                .and_then(|index| {
901                    focus_now.enabled_keys[index + 1..]
902                        .iter()
903                        .find(|key| enabled_keys.contains(key))
904                        .or_else(|| {
905                            focus_now.enabled_keys[..index]
906                                .iter()
907                                .rev()
908                                .find(|key| enabled_keys.contains(key))
909                        })
910                        .cloned()
911                })
912                .unwrap_or(focus_seed);
913        }
914        if selection_changed || focus_now.enabled_keys != enabled_keys {
915            focus_now.selected_key = selected_key.clone();
916            focus_now.enabled_keys = enabled_keys;
917            focus_state.update(cx, |state, _| *state = focus_now.clone());
918        }
919        let focused_key = focus_now.key;
920
921        // `.tabs__list-container__scroller` is the box `.tabs__list` scrolls
922        // inside; the handle is what says how far it has, which is what decides
923        // whether each chevron shows.
924        let scroll = window
925            .use_keyed_state(element_id::scoped(&base, "scroll"), cx, |_, _| {
926                gpui::ScrollHandle::new()
927            })
928            .read(cx)
929            .clone();
930
931        // The two chevrons' visibility, measured a frame ago; `use_keyed_state`
932        // takes `cx` mutably, so it precedes the theme borrow.
933        let arrows = window.use_keyed_state(element_id::scoped(&base, "arrows"), cx, |_, _| {
934            (false, false)
935        });
936        let vertical = self.orientation == Orientation::Vertical;
937        let vertical_tab_min_width = self
938            .vertical_tab_min_width
939            .unwrap_or(VERTICAL_TAB_MIN_WIDTH);
940        // `full_width` is a horizontal contract: equal shares of the list's
941        // width. A vertical list's tabs are already `w_full`.
942        let stretch = self.full_width && !vertical;
943        let secondary = self.variant == TabsVariant::Secondary;
944        // The instance overrides refine the theme's resolved values; the
945        // reference segmented control uses all of them at once.
946        let radius_override = self.radius;
947        let list_bg = self.list_bg;
948        let indicator_bg = self.indicator_bg;
949        let indicator_shadow = self.indicator_shadow;
950        let hover_fill = self.hover_fill;
951        let list_padding = self.list_padding;
952        let (tab_h, tab_padding_x, tab_text) = self.size.metrics();
953        let tab_text = self.text_size.unwrap_or(tab_text);
954        let geometry = window.use_keyed_state(element_id::scoped(&base, "geometry"), cx, |_, _| {
955            TabsGeometry::default()
956        });
957        let keyboard_focus = list_focus.is_focused(window) && crate::util::focus_visible(cx);
958        let focus_presence =
959            window.use_keyed_state(element_id::scoped(&base, "keyboard-focus"), cx, |_, _| {
960                false
961            });
962        if *focus_presence.read(cx) != keyboard_focus {
963            focus_presence.update(cx, |focused, _| *focused = keyboard_focus);
964            if keyboard_focus {
965                scroll_tab_into_view(&scroll, geometry.read(cx), &focused_key, vertical);
966            }
967        }
968        let indicator_frame =
969            indicator_target(geometry.read(cx), &selected_key, vertical, secondary).map(|target| {
970                indicator_motion(
971                    element_id::scoped(&base, "indicator-motion"),
972                    target,
973                    window,
974                    cx,
975                )
976            });
977        let active_idx = self.items.iter().position(|item| item.key == selected_key);
978        let active_has_content =
979            active_idx.is_some_and(|index| self.items[index].content.is_some());
980        let panel_focus =
981            crate::util::tab_stop_handle(element_id::scoped(&base, "panel-focus"), window, cx);
982        let recover_replaced_panel_focus =
983            selection_changed && panel_focus.contains_focused(window, cx);
984        let panel_focus = active_has_content.then_some(panel_focus);
985        let recovery_focus = window
986            .use_keyed_state(element_id::scoped(&base, "focus-recovery"), cx, |_, cx| {
987                cx.focus_handle()
988            })
989            .read(cx)
990            .clone();
991        if recover_replaced_panel_focus {
992            window.focus(&recovery_focus, cx);
993        }
994        let has_enabled_tab = !self.is_disabled && self.items.iter().any(|item| !item.is_disabled);
995        let mut separator_motions = self
996            .items
997            .iter()
998            .enumerate()
999            .map(|(index, item)| {
1000                (self.variant == TabsVariant::Primary && index > 0 && item.separator).then(|| {
1001                    let hidden =
1002                        item.key == selected_key || self.items[index - 1].key == selected_key;
1003                    separator_motion(
1004                        element_id::scoped(
1005                            &element_id::scoped(&base, "separator"),
1006                            item.key.clone(),
1007                        ),
1008                        hidden,
1009                        window,
1010                        cx,
1011                    )
1012                })
1013            })
1014            .collect::<Vec<_>>();
1015
1016        let colors = cx.colors().clone();
1017        let layout = cx.layout().clone();
1018        let tabs_hover_opacity = layout.tabs_hover_opacity;
1019        // The pinned stylesheet dims unselected tabs and overflow arrows to
1020        // `tabs_hover_opacity`. GPUI cannot animate an element's opacity
1021        // without moving its listener path, so a same-colour overlay is faded
1022        // over the finished content while the stable tab/arrow element keeps
1023        // focus and pointer ownership. The overlay's colour is the resolved
1024        // tray fill — a state fill fades from the resting background, which
1025        // `list_bg` overrides and a root `sx` background refines last, the
1026        // same resting value `scrollbar.rs` resolves — not the stock token
1027        // those overrides replaced.
1028        let sx_background = crate::util::sx_background(&self.sx);
1029        let tray = if secondary {
1030            sx_background.or(list_bg).unwrap_or(colors.background)
1031        } else {
1032            sx_background.or(list_bg).unwrap_or(colors.default.color)
1033        };
1034        let tab_overlay = tray.alpha(1.0 - tabs_hover_opacity);
1035        let hover_fg = colors.foreground;
1036        let key_stops: Vec<usize> = self
1037            .items
1038            .iter()
1039            .enumerate()
1040            .filter_map(|(index, item)| (!item.is_disabled).then_some(index))
1041            .collect();
1042        let key_keys: Vec<SharedString> = self.items.iter().map(|item| item.key.clone()).collect();
1043
1044        // `.tabs__list` is `w-max min-w-full`: it grows with its content, which is
1045        // what lets the scroller overflow -- a shrinking row always fits and
1046        // never scrolls.
1047        // `react-aria/dist/private/tabs/useTabList.mjs` is
1048        // `role: 'tablist'` with `'aria-orientation': orientation`, always
1049        // present. The list had no id of its own, and a role on an element
1050        // with no `GlobalElementId` produces no node at all.
1051        let mut list = gpui::div()
1052            .id(element_id::scoped(&base, "tablist"))
1053            .a11y(a11y::Role::TabList)
1054            .a11y_orientation(self.orientation)
1055            .relative()
1056            .flex()
1057            .flex_shrink_0()
1058            // A vertical list is a flex item beside the panel; releasing its
1059            // cross-axis min-content width is what lets long labels wrap.
1060            // Keep the zero shrink on the column's main axis so a bounded
1061            // vertical scroller still retains its intrinsic content height.
1062            .when(vertical, |list| list.h_auto().min_w(px(0.)))
1063            .child({
1064                let measured = geometry.clone();
1065                gpui::canvas(
1066                    move |bounds, _window, cx| {
1067                        if measured.read(cx).list != Some(bounds) {
1068                            measured.update(cx, |geometry, cx| {
1069                                geometry.list = Some(bounds);
1070                                cx.notify();
1071                            });
1072                        }
1073                    },
1074                    |_, _, _, _| {},
1075                )
1076                .absolute()
1077                .inset_0()
1078            });
1079        if vertical {
1080            list = list.flex_col().items_start().gap(px(4.));
1081        } else if stretch {
1082            // `w_full`, not `min_w_full`: the stretched row is exactly the
1083            // viewport, so the scroller keeps no horizontal range and the
1084            // equal shares never turn into overflow.
1085            list = list.w_full();
1086        } else {
1087            list = list.min_w_full();
1088        }
1089
1090        // v3 keeps two indicator styles: `primary` fills a segment behind the
1091        // selected tab, `secondary` underlines it.
1092        let sx_corners = crate::util::sx_radius(&self.sx);
1093        let indicator_ready = indicator_frame.is_some();
1094        if let Some(frame) = indicator_frame {
1095            // `.tabs__indicator` is the absolute `rounded-3xl bg-segment
1096            // shadow-surface` pill; Secondary flattens it into an accent line.
1097            let mut indicator = gpui::div()
1098                .absolute()
1099                .debug_selector(|| format!("{base_id}-indicator"));
1100            indicator = if !secondary {
1101                indicator
1102                    .rounded(crate::util::control_radius(cx))
1103                    .when_some(radius_override, |indicator, radius| {
1104                        indicator.rounded(radius)
1105                    })
1106                    .bg(indicator_bg.unwrap_or(colors.segment.background))
1107                    .when(
1108                        indicator_shadow && !layout.surface_shadow.is_empty(),
1109                        |indicator| indicator.shadow(layout.surface_shadow.clone()),
1110                    )
1111            } else {
1112                indicator.bg(indicator_bg.unwrap_or(colors.accent.color))
1113            };
1114            // The overrides refine the resolved defaults; `sx` corners refine
1115            // the result independently of the chosen radius.
1116            indicator = crate::util::round_sx_corners(indicator, &sx_corners);
1117            list = list.child(frame.render(indicator));
1118        }
1119        let measure_tab = |key: SharedString| {
1120            let measured = geometry.clone();
1121            gpui::canvas(
1122                move |bounds, _window, cx| {
1123                    let current = measured
1124                        .read(cx)
1125                        .tabs
1126                        .iter()
1127                        .find_map(|(held, bounds)| (held == &key).then_some(*bounds));
1128                    if current != Some(bounds) {
1129                        measured.update(cx, |geometry, cx| {
1130                            if let Some((_, held)) =
1131                                geometry.tabs.iter_mut().find(|(held, _)| held == &key)
1132                            {
1133                                *held = bounds;
1134                            } else {
1135                                geometry.tabs.push((key.clone(), bounds));
1136                            }
1137                            cx.notify();
1138                        });
1139                    }
1140                },
1141                |_, _, _, _| {},
1142            )
1143            .absolute()
1144            .inset_0()
1145        };
1146        // A constrained label slot releases its min-content width so normal
1147        // whitespace wraps inside the tab share; the tab keeps its fixed
1148        // `h-8` box and the wrapped lines paint past it like the stylesheet's
1149        // default overflow.
1150        let label_constrained = vertical || stretch;
1151        let tab_label = move |label: SharedString| {
1152            gpui::div()
1153                .min_w(px(0.))
1154                .when(label_constrained, |slot| slot.flex_1())
1155                .whitespace_normal()
1156                .child(label.to_string())
1157        };
1158        match self.variant {
1159            TabsVariant::Primary => {
1160                // `.tabs__list` is `p-1` and nothing else: the tabs sit
1161                // shoulder to shoulder, with no gap between them.
1162                list = list.p(px(4.));
1163                // `list_padding` refines the pinned inset; Primary only, the
1164                // secondary tray has no inset of its own to override.
1165                list = list.when_some(list_padding, |list, inset| list.p(inset));
1166                // `trigger` is single-use, so the loop consumes it out of each
1167                // item; the panel below reads `content` the same way.
1168                for (index, item) in self.items.iter_mut().enumerate() {
1169                    let active = item.key == selected_key;
1170                    let focused = item.key == focused_key;
1171                    let disabled = self.is_disabled || item.is_disabled;
1172                    let mut tab = gpui::div()
1173                        .id(element_id::scoped(
1174                            &element_id::scoped(&base, "tab"),
1175                            item.key.clone(),
1176                        ))
1177                        // `useTab.mjs`: `role: 'tab'` with
1178                        // `'aria-selected': isSelected`. Its
1179                        // `'aria-controls': isSelected ? tabPanelId :
1180                        // undefined` half needs an id graph gpui does not
1181                        // have, and `aria-disabled` has no builder; both are
1182                        // recorded omissions in `crate::a11y`. A gpui text
1183                        // child carries no name, so the label is restated.
1184                        .a11y_named(a11y::Role::Tab, &a11y::Name::labelled(item.label.clone()))
1185                        .a11y_selected(active)
1186                        .relative()
1187                        .when(!disabled && focused, |t| t.track_focus(&list_focus))
1188                        // `.tabs__tab` is `h-8 px-4 rounded-3xl text-sm
1189                        // font-medium` — `h-8` is a fixed box, not a floor.
1190                        // Upstream ships no truncate, nowrap or overflow
1191                        // utility on the tab, so a wrapped label keeps its
1192                        // extra lines and paints them past the pill the way
1193                        // CSS `overflow: visible` does; gpui paints every
1194                        // wrapped line too, giving the same rendering. Those
1195                        // lines stay pointer-inert here, while a browser
1196                        // hit-tests painted text as the tab.
1197                        .h(item.height.unwrap_or(tab_h))
1198                        .px(item.padding_x.unwrap_or(tab_padding_x))
1199                        // A fixed per-tab width beats every stretch source:
1200                        // the vertical list's `w_full` and its 80px minimum,
1201                        // and `full_width`'s equal shares below, whose
1202                        // `flex_1` basis of zero would ignore the width.
1203                        .when(vertical && item.width.is_none(), |t| {
1204                            t.w_full().min_w(vertical_tab_min_width)
1205                        })
1206                        .when_some(item.width, |t, w| t.w(w))
1207                        // Stretched tabs take an equal share: `flex_1` zeroes
1208                        // the flex basis and `min_w(0)` releases the label's
1209                        // min-content floor, so every share is the same and
1210                        // none of them widens the row.
1211                        .when(stretch && item.width.is_none(), |t| t.flex_1().min_w(px(0.)))
1212                        .when(!stretch || item.width.is_some(), |t| t.flex_shrink_0())
1213                        .flex()
1214                        .items_center()
1215                        .map(|tab| self.align.apply(tab))
1216                        .rounded(crate::util::control_radius(cx))
1217                        .text_size(tab_text)
1218                        .line_height(
1219                            crate::util::leading_for(tab_text).unwrap_or(px(20.)),
1220                        )
1221                        .font_weight(gpui::FontWeight::MEDIUM)
1222                        .when(!disabled, |t| t.cursor(crate::util::interactive_cursor(cx)))
1223                        // `status-disabled` is `--disabled-opacity`.
1224                        .when(disabled, |t| t.opacity(cx.layout().disabled_opacity));
1225                    tab = tab.child(measure_tab(item.key.clone()));
1226                    // `.tabs__separator` is the absolute `w-px h-1/2
1227                    // rounded-sm` hairline inside the tab that carries it. It
1228                    // turns into `h-px w-[90%]` in a vertical list and hides
1229                    // beside the selected segment.
1230                    if index > 0 && item.separator {
1231                        let separator = gpui::div()
1232                            .absolute()
1233                            .rounded(crate::util::hairline_radius(cx))
1234                            .bg(colors.muted.alpha(0.25));
1235                        let separator = if vertical {
1236                            separator
1237                                .left(gpui::relative(0.05))
1238                                .top(px(0.))
1239                                .w(gpui::relative(0.9))
1240                                .h(cx.layout().border_width)
1241                        } else {
1242                            separator
1243                                .left(px(0.))
1244                                .top(gpui::relative(0.25))
1245                                .w(cx.layout().border_width)
1246                                .h(gpui::relative(0.5))
1247                        };
1248                        let separator = separator_motions[index]
1249                            .take()
1250                            .expect("primary separators have motion state")
1251                            .render(separator);
1252                        tab = tab.child(separator);
1253                    }
1254                    if active {
1255                        tab = tab
1256                            .text_color(colors.segment.foreground)
1257                            .font_weight(gpui::FontWeight::MEDIUM)
1258                            // Before the indicator has measured geometry this
1259                            // active tab paints its fill itself, so it reads
1260                            // through the same overrides the indicator will.
1261                            .when(!indicator_ready, |tab| {
1262                                tab.bg(indicator_bg.unwrap_or(colors.segment.background))
1263                                    .when(
1264                                        indicator_shadow
1265                                            && !layout.surface_shadow.is_empty(),
1266                                        |tab| tab.shadow(layout.surface_shadow.clone()),
1267                                    )
1268                            });
1269                    } else {
1270                        tab = tab.text_color(colors.muted);
1271                    }
1272                    if !disabled {
1273                        // A tab list is one stop and the arrows move within
1274                        // it. Automatic activation selects as focus moves;
1275                        // manual activation waits for a press.
1276                        let key_stops = key_stops.clone();
1277                        let key_keys = key_keys.clone();
1278                        let key_cb = self.on_selection_change.clone();
1279                        let key_own = selection_own.clone();
1280                        let key_focus = focus_state.clone();
1281                        let key_geometry = geometry.clone();
1282                        let key_scroll = scroll.clone();
1283                        let automatic = self.keyboard_activation == KeyboardActivation::Automatic;
1284                        tab = tab.on_key_down(move |event, window, cx| {
1285                            let key = match (vertical, event.keystroke.key.as_str()) {
1286                                (_, "right") | (true, "down") => "down",
1287                                (_, "left") | (true, "up") => "up",
1288                                (_, other @ ("home" | "end")) => other,
1289                                _ => return,
1290                            };
1291                            // The list owns its axis and Home/End. Cross-axis
1292                            // keys returned above remain available to an
1293                            // enclosing scroller or navigation control.
1294                            cx.stop_propagation();
1295                            let crate::list_nav::Move::To(next) =
1296                                crate::list_nav::resolve(&key_stops, Some(index), key, true)
1297                            else {
1298                                return;
1299                            };
1300                            let Some(next_key) = key_keys.get(next).cloned() else {
1301                                return;
1302                            };
1303                            key_focus.update(cx, |state, cx| {
1304                                state.key = next_key.clone();
1305                                cx.notify();
1306                            });
1307                            scroll_tab_into_view(
1308                                &key_scroll,
1309                                key_geometry.read(cx),
1310                                &next_key,
1311                                vertical,
1312                            );
1313                            if automatic {
1314                                if let Some(held) = &key_own {
1315                                    let next_key = next_key.clone();
1316                                    held.update(cx, |v, cx| {
1317                                        *v = next_key;
1318                                        cx.notify();
1319                                    });
1320                                }
1321                                if let Some(f) = &key_cb {
1322                                    f(&next_key, window, cx);
1323                                }
1324                            }
1325                            // No refocusing: the next render has the newly
1326                            // focused tab claim the list's handle.
1327                        });
1328                        let key = item.key.clone();
1329                        let cb = self.on_selection_change.clone();
1330                        let own = selection_own.clone();
1331                        let focus = focus_state.clone();
1332                        let list_focus_for_click = list_focus.clone();
1333                        let list_focus_after_pointer = list_focus_for_click.clone();
1334                        let select: Rc<dyn Fn(&mut Window, &mut App)> =
1335                            Rc::new(move |window, cx| {
1336                                window.focus(&list_focus_for_click, cx);
1337                                focus.update(cx, |state, cx| {
1338                                    state.key = key.clone();
1339                                    cx.notify();
1340                                });
1341                                // Uncontrolled: move our own selection, or pressing
1342                                // a tab would do nothing.
1343                                if let Some(held) = &own {
1344                                    held.update(cx, |v, cx| {
1345                                        *v = key.clone();
1346                                        cx.notify();
1347                                    });
1348                                }
1349                                if let Some(f) = &cb {
1350                                    f(&key, window, cx);
1351                                }
1352                            });
1353                        let pointer_select = select.clone();
1354                        tab = tab
1355                            .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
1356                                pointer_select(window, cx);
1357                                let list_focus = list_focus_after_pointer.clone();
1358                                window.defer(cx, move |window, cx| window.focus(&list_focus, cx));
1359                            })
1360                            .on_click(move |event, window, cx| {
1361                                if matches!(event, gpui::ClickEvent::Keyboard(_)) {
1362                                    select(window, cx);
1363                                }
1364                            });
1365                    }
1366                    // `.tab:focus-visible` is `status-focused`. The ring is an
1367                    // overlay child rather than a spread shadow so its corner
1368                    // stays concentric with the tab's own `control_radius`; a
1369                    // shadow would keep the tab's radius at a band two pixels
1370                    // further out and blur the edge. The tab is the element
1371                    // that both carries the rounding and shows the list's
1372                    // focus, and `anim::hover_fade` below refines that same
1373                    // element rather than wrapping it, so the overlay rides
1374                    // along untouched.
1375                    let mut tab = crate::util::with_focus_ring_overlay(
1376                        tab,
1377                        focused
1378                            && list_focus.is_focused(window)
1379                            && crate::util::focus_visible(cx)
1380                            && !disabled,
1381                        true,
1382                        crate::util::control_radius(cx),
1383                        Vec::new(),
1384                        cx,
1385                    );
1386                    // The trigger element replaces the label text when one is
1387                    // set; `label` keeps naming the tab through the
1388                    // `a11y_named` restatement above either way.
1389                    tab = match item.trigger.take() {
1390                        Some(trigger) => tab.child(trigger),
1391                        None => tab.child(tab_label(item.label.clone())),
1392                    };
1393                    if !active && !disabled {
1394                        if hover_fill {
1395                            let radius = crate::util::control_radius(cx);
1396                            tab = crate::anim::hover_fade(
1397                                tab,
1398                                element_id::scoped(&base, format!("tab-hover-{}", item.key)),
1399                                (colors.default.color.alpha(0.0), tab_overlay),
1400                                None,
1401                                None,
1402                                move |fill| fill.rounded(radius),
1403                                window,
1404                                cx,
1405                            );
1406                        } else {
1407                            tab = tab.hover(move |style| style.text_color(hover_fg));
1408                        }
1409                    }
1410                    if focused && !disabled {
1411                        tab = crate::util::record_focus_bounds(tab, &list_focus, window, cx);
1412                    }
1413                    list = list.child(tab);
1414                }
1415            }
1416            TabsVariant::Secondary => {
1417                // `.tabs--secondary` gives the container a trailing-axis
1418                // border: bottom when horizontal, start when vertical.
1419                // `trigger` is single-use, so the loop consumes it out of each
1420                // item, as in the primary branch.
1421                for (index, item) in self.items.iter_mut().enumerate() {
1422                    let active = item.key == selected_key;
1423                    let focused = item.key == focused_key;
1424                    let disabled = self.is_disabled || item.is_disabled;
1425                    let mut tab = gpui::div()
1426                        .id(element_id::scoped(
1427                            &element_id::scoped(&base, "tab"),
1428                            item.key.clone(),
1429                        ))
1430                        // `useTab.mjs`: `role: 'tab'` with
1431                        // `'aria-selected': isSelected`. Its
1432                        // `'aria-controls': isSelected ? tabPanelId :
1433                        // undefined` half needs an id graph gpui does not
1434                        // have, and `aria-disabled` has no builder; both are
1435                        // recorded omissions in `crate::a11y`. A gpui text
1436                        // child carries no name, so the label is restated.
1437                        .a11y_named(a11y::Role::Tab, &a11y::Name::labelled(item.label.clone()))
1438                        .a11y_selected(active)
1439                        .relative()
1440                        .when(!disabled && focused, |t| t.track_focus(&list_focus))
1441                        // The same fixed `h-8 px-4 text-sm` box,
1442                        // `rounded-none`, with the indicator as a 2px bar
1443                        // along the bottom; wrapped label lines paint past it
1444                        // as in the primary variant.
1445                        .h(item.height.unwrap_or(tab_h))
1446                        .px(item.padding_x.unwrap_or(tab_padding_x))
1447                        // The same fixed-width precedence as the primary
1448                        // branch: a per-tab width beats the vertical `w_full`
1449                        // and its 80px minimum, and beats the stretch share
1450                        // whose `flex_1` basis of zero would ignore it.
1451                        .when(vertical && item.width.is_none(), |t| {
1452                            t.w_full().min_w(vertical_tab_min_width)
1453                        })
1454                        .when_some(item.width, |t, w| t.w(w))
1455                        // Stretched tabs take an equal share: `flex_1` zeroes
1456                        // the flex basis and `min_w(0)` releases the label's
1457                        // min-content floor, so every share is the same and
1458                        // none of them widens the row.
1459                        .when(stretch && item.width.is_none(), |t| t.flex_1().min_w(px(0.)))
1460                        .when(!stretch || item.width.is_some(), |t| t.flex_shrink_0())
1461                        .flex()
1462                        .items_center()
1463                        .map(|tab| self.align.apply(tab))
1464                        .text_size(tab_text)
1465                        .line_height(
1466                            crate::util::leading_for(tab_text).unwrap_or(px(20.)),
1467                        )
1468                        .font_weight(gpui::FontWeight::MEDIUM)
1469                        .when(!indicator_ready && vertical, |t| t.border_l_2())
1470                        .when(!indicator_ready && !vertical, |t| t.border_b_2())
1471                        .when(!disabled, |t| t.cursor(crate::util::interactive_cursor(cx)))
1472                        // `status-disabled` is `--disabled-opacity`.
1473                        .when(disabled, |t| t.opacity(cx.layout().disabled_opacity));
1474                    tab = tab.child(measure_tab(item.key.clone()));
1475                    tab = if active {
1476                        tab.text_color(colors.foreground)
1477                            .font_weight(gpui::FontWeight::MEDIUM)
1478                            // Same pre-measurement fallback as the primary
1479                            // branch: the underline honors `indicator_bg`.
1480                            .when(!indicator_ready, |tab| {
1481                                tab.border_color(indicator_bg.unwrap_or(colors.accent.color))
1482                            })
1483                    } else {
1484                        tab.text_color(colors.muted).when(!indicator_ready, |tab| {
1485                            tab.border_color(gpui::transparent_black())
1486                        })
1487                    };
1488                    if !disabled {
1489                        // A tab list is one stop and the arrows move within
1490                        // it. Automatic activation selects as focus moves;
1491                        // manual activation waits for a press.
1492                        let key_stops = key_stops.clone();
1493                        let key_keys = key_keys.clone();
1494                        let key_cb = self.on_selection_change.clone();
1495                        let key_own = selection_own.clone();
1496                        let key_focus = focus_state.clone();
1497                        let key_geometry = geometry.clone();
1498                        let key_scroll = scroll.clone();
1499                        let automatic = self.keyboard_activation == KeyboardActivation::Automatic;
1500                        tab = tab.on_key_down(move |event, window, cx| {
1501                            let key = match (vertical, event.keystroke.key.as_str()) {
1502                                (_, "right") | (true, "down") => "down",
1503                                (_, "left") | (true, "up") => "up",
1504                                (_, other @ ("home" | "end")) => other,
1505                                _ => return,
1506                            };
1507                            // The list owns its axis and Home/End. Cross-axis
1508                            // keys returned above remain available to an
1509                            // enclosing scroller or navigation control.
1510                            cx.stop_propagation();
1511                            let crate::list_nav::Move::To(next) =
1512                                crate::list_nav::resolve(&key_stops, Some(index), key, true)
1513                            else {
1514                                return;
1515                            };
1516                            let Some(next_key) = key_keys.get(next).cloned() else {
1517                                return;
1518                            };
1519                            key_focus.update(cx, |state, cx| {
1520                                state.key = next_key.clone();
1521                                cx.notify();
1522                            });
1523                            scroll_tab_into_view(
1524                                &key_scroll,
1525                                key_geometry.read(cx),
1526                                &next_key,
1527                                vertical,
1528                            );
1529                            if automatic {
1530                                if let Some(held) = &key_own {
1531                                    let next_key = next_key.clone();
1532                                    held.update(cx, |v, cx| {
1533                                        *v = next_key;
1534                                        cx.notify();
1535                                    });
1536                                }
1537                                if let Some(f) = &key_cb {
1538                                    f(&next_key, window, cx);
1539                                }
1540                            }
1541                            // No refocusing: the next render has the newly
1542                            // focused tab claim the list's handle.
1543                        });
1544                        let key = item.key.clone();
1545                        let cb = self.on_selection_change.clone();
1546                        let own = selection_own.clone();
1547                        let focus = focus_state.clone();
1548                        let list_focus_for_click = list_focus.clone();
1549                        let list_focus_after_pointer = list_focus_for_click.clone();
1550                        let select: Rc<dyn Fn(&mut Window, &mut App)> =
1551                            Rc::new(move |window, cx| {
1552                                window.focus(&list_focus_for_click, cx);
1553                                focus.update(cx, |state, cx| {
1554                                    state.key = key.clone();
1555                                    cx.notify();
1556                                });
1557                                // Uncontrolled: move our own selection, or pressing
1558                                // a tab would do nothing.
1559                                if let Some(held) = &own {
1560                                    held.update(cx, |v, cx| {
1561                                        *v = key.clone();
1562                                        cx.notify();
1563                                    });
1564                                }
1565                                if let Some(f) = &cb {
1566                                    f(&key, window, cx);
1567                                }
1568                            });
1569                        let pointer_select = select.clone();
1570                        tab = tab
1571                            .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| {
1572                                pointer_select(window, cx);
1573                                let list_focus = list_focus_after_pointer.clone();
1574                                window.defer(cx, move |window, cx| window.focus(&list_focus, cx));
1575                            })
1576                            .on_click(move |event, window, cx| {
1577                                if matches!(event, gpui::ClickEvent::Keyboard(_)) {
1578                                    select(window, cx);
1579                                }
1580                            });
1581                    }
1582                    // `.tab:focus-visible` is `status-focused`, painted as an
1583                    // overlay child. A secondary tab is `rounded-none`, so the
1584                    // ring's own corner is the square one the zero radius
1585                    // implies; the overlay is still the crisper of the two,
1586                    // since the shadow variant needs a non-zero blur. As in
1587                    // the primary branch the tab carries both the rounding and
1588                    // the list's focus, and `anim::hover_fade` refines it in
1589                    // place rather than wrapping it.
1590                    let mut tab = crate::util::with_focus_ring_overlay(
1591                        tab,
1592                        focused
1593                            && list_focus.is_focused(window)
1594                            && crate::util::focus_visible(cx)
1595                            && !disabled,
1596                        true,
1597                        px(0.),
1598                        Vec::new(),
1599                        cx,
1600                    );
1601                    // As in the primary branch: the trigger element replaces
1602                    // the label text; `label` keeps naming the tab.
1603                    tab = match item.trigger.take() {
1604                        Some(trigger) => tab.child(trigger),
1605                        None => tab.child(tab_label(item.label.clone())),
1606                    };
1607                    if !active && !disabled {
1608                        if hover_fill {
1609                            let radius = crate::util::control_radius(cx);
1610                            tab = crate::anim::hover_fade(
1611                                tab,
1612                                element_id::scoped(&base, format!("tab-hover-{}", item.key)),
1613                                (colors.background.alpha(0.0), tab_overlay),
1614                                None,
1615                                None,
1616                                move |fill| fill.rounded(radius),
1617                                window,
1618                                cx,
1619                            );
1620                        } else {
1621                            tab = tab.hover(move |style| style.text_color(hover_fg));
1622                        }
1623                    }
1624                    if focused && !disabled {
1625                        tab = crate::util::record_focus_bounds(tab, &list_focus, window, cx);
1626                    }
1627                    list = list.child(tab);
1628                }
1629            }
1630        }
1631
1632        if let Some(panel_focus) = panel_focus.clone() {
1633            list = list.on_key_down(move |event, window, cx| {
1634                if event.keystroke.key != "tab" || event.keystroke.modifiers.shift {
1635                    return;
1636                }
1637                cx.stop_propagation();
1638                crate::util::set_focus_visible(true, cx);
1639                // The wrapper is a real stop so reverse traversal can reach a
1640                // plain panel. Probe one stop past it: a child stays inside;
1641                // otherwise reclaim the wrapper as the correct destination.
1642                window.focus_next(cx);
1643                if panel_focus.is_focused(window) {
1644                    window.focus_next(cx);
1645                }
1646                if !panel_focus.contains_focused(window, cx) {
1647                    window.focus(&panel_focus, cx);
1648                }
1649            });
1650        }
1651
1652        // Active panel
1653        let mut items = self.items;
1654        // `.tabs__list-container` is `relative`, holds the scroller, and hangs
1655        // the two `size-4` chevrons off its edges -- `hidden` until there is
1656        // something to scroll to in that direction (`start-1`/`end-1`, centred
1657        // on the cross axis).
1658        let (before, after) = *arrows.read(cx);
1659        // `.tabs__list-container__scroll-prev` and
1660        // `.tabs__list-container__scroll-next` are `size-4` circles at the
1661        // edges, shown only when there is something that way to scroll to.
1662        let mut arrow =
1663            |id: &str, icon: &'static str, direction: f32, handle: gpui::ScrollHandle| {
1664                let arrow_id = element_id::scoped(&base, id);
1665                let arrow = gpui::div()
1666                    .id(arrow_id.clone())
1667                    // gpui has no hitbox occlusion, so a chevron floating over
1668                    // the list hands its click to the tab underneath as well.
1669                    // v3's chevron is `z-2` above the `z-index: 1` tabs exactly
1670                    // so it takes the press; `occlude` stops the hit test at
1671                    // the button, which is that on-top layer.
1672                    .occlude()
1673                    .size(px(16.))
1674                    .flex()
1675                    .items_center()
1676                    .justify_center()
1677                    .rounded_full()
1678                    .cursor(crate::util::interactive_cursor(cx))
1679                    .text_color(colors.foreground)
1680                    .child(
1681                        gpui::svg()
1682                            .size(px(12.))
1683                            .path(icon)
1684                            .text_color(colors.foreground),
1685                    )
1686                    .on_click(move |_, window, _| {
1687                        let at = handle.offset();
1688                        let viewport = handle.bounds().size;
1689                        let step = if vertical {
1690                            viewport.height * 0.8
1691                        } else {
1692                            viewport.width * 0.8
1693                        };
1694                        let delta = step * direction;
1695                        let next = if vertical {
1696                            gpui::point(at.x, at.y + delta)
1697                        } else {
1698                            gpui::point(at.x + delta, at.y)
1699                        };
1700                        handle.set_offset(next);
1701                        window.refresh();
1702                    });
1703                let arrow = crate::anim::hover_fade(
1704                    arrow,
1705                    element_id::scoped(&arrow_id, "hover-fade"),
1706                    (
1707                        if secondary {
1708                            colors.background.alpha(0.0)
1709                        } else {
1710                            colors.default.color.alpha(0.0)
1711                        },
1712                        tab_overlay,
1713                    ),
1714                    None,
1715                    None,
1716                    move |fill| fill.rounded_full(),
1717                    window,
1718                    cx,
1719                );
1720                // Keep the occlusion on the outer animated node as well as the
1721                // inner control so the list tab underneath cannot win a hit test.
1722                arrow.absolute().occlude()
1723            };
1724        let container_radius = layout.radius_lg() * 2.5;
1725        // `list_bg` and `radius` refine the resolved tray fill and corner on
1726        // both variants; the reference segmented control sets all three of
1727        // fill, radius and indicator treatment this way.
1728        let container = gpui::div()
1729            .relative()
1730            .when(!secondary, |c| {
1731                c.bg(list_bg.unwrap_or(colors.default.color))
1732                    .rounded(container_radius.min(px(32.)))
1733                    .when_some(radius_override, |c, radius| c.rounded(radius))
1734            })
1735            .when(secondary, |c| {
1736                c.border_color(colors.border)
1737                    .when_some(list_bg, |c, bg| c.bg(bg))
1738                    .when_some(radius_override, |c, radius| c.rounded(radius))
1739                    .when(vertical, |c| c.border_l_1())
1740                    .when(!vertical, |c| c.border_b_1())
1741            })
1742            // A scroller only overflows if it is bounded: without `w_full` the
1743            // box grows to fit every tab and nothing ever scrolls.
1744            .when(!vertical, |c| c.w_full())
1745            // In a vertical row the list container is the horizontal flex
1746            // item beside the panel. Allow that item to shrink below a long
1747            // label's max-content width while keeping the list column's
1748            // vertical main-axis sizing intact.
1749            .when(vertical, |c| c.h_full().min_w(px(0.)).flex_shrink(1.))
1750            .child(
1751                crate::scroll_shadow::ScrollShadow::new(element_id::scoped(
1752                    &base, "scroller",
1753                ))
1754                    .orientation(if vertical {
1755                        Orientation::Vertical
1756                    } else {
1757                        Orientation::Horizontal
1758                    })
1759                    .size(px(64.))
1760                    .gap(px(0.))
1761                    .hide_scroll_bar(true)
1762                    .scroll_handle(scroll.clone())
1763                    .fill_axis(true)
1764                    .when(vertical, |shadow| shadow.unbounded_h())
1765                    .sx(move |root| {
1766                        if vertical {
1767                            root.h_full()
1768                        } else {
1769                            root.w_full()
1770                        }
1771                    })
1772                    .child(list),
1773            )
1774            .child({
1775                // `max_offset` is written during prepaint, so the render that
1776                // decided whether to draw an arrow read the frame before. This
1777                // canvas reads it in place and stores what it found; the entity
1778                // update is what asks for the frame that draws them.
1779                let measured = arrows;
1780                let handle = scroll.clone();
1781                gpui::canvas(
1782                    move |_bounds, _window, cx| {
1783                        let offset = handle.offset();
1784                        let max = handle.max_offset();
1785                        let next = if vertical {
1786                            (
1787                                f32::from(offset.y) < -0.5,
1788                                f32::from(offset.y) - 0.5 > -f32::from(max.y),
1789                            )
1790                        } else {
1791                            (
1792                                f32::from(offset.x) < -0.5,
1793                                f32::from(offset.x) - 0.5 > -f32::from(max.x),
1794                            )
1795                        };
1796                        if *measured.read(cx) != next {
1797                            measured.update(cx, |flags, cx| {
1798                                *flags = next;
1799                                cx.notify();
1800                            });
1801                        }
1802                    },
1803                    |_, _, _, _| {},
1804                )
1805                .absolute()
1806                .size(px(0.))
1807            })
1808            .when(before, |c| {
1809                let a = arrow(
1810                    "scroll-prev",
1811                    if vertical {
1812                        crate::icons::CHEVRON_UP
1813                    } else {
1814                        crate::icons::CHEVRON_LEFT
1815                    },
1816                    1.,
1817                    scroll.clone(),
1818                );
1819                c.child(if vertical {
1820                    a.top(px(4.)).left(gpui::relative(0.5)).ml(px(-8.))
1821                } else {
1822                    // `start-1 top-1/2 -translate-y-1/2`.
1823                    a.left(px(4.)).top(gpui::relative(0.5)).mt(px(-8.))
1824                })
1825            })
1826            .when(after, |c| {
1827                let a = arrow(
1828                    "scroll-next",
1829                    if vertical {
1830                        crate::icons::CHEVRON_DOWN
1831                    } else {
1832                        crate::icons::CHEVRON_RIGHT
1833                    },
1834                    -1.,
1835                    scroll.clone(),
1836                );
1837                c.child(if vertical {
1838                    a.bottom(px(4.)).left(gpui::relative(0.5)).ml(px(-8.))
1839                } else {
1840                    a.right(px(4.)).top(gpui::relative(0.5)).mt(px(-8.))
1841                })
1842            });
1843
1844        // `.tabs` is `flex gap-2`: horizontal tabs stack their panel below;
1845        // vertical tabs place it beside the list.
1846        let mut el = gpui::div()
1847            .track_focus(&recovery_focus)
1848            .flex()
1849            .when(vertical, |root| root.flex_row().h_full())
1850            .when(!vertical, |root| root.flex_col())
1851            .gap(px(8.))
1852            .child(container);
1853
1854        if let Some(idx) = active_idx {
1855            // `useTabPanel.mjs` labels the panel by the selected tab
1856            // (`aria-labelledby: generateId(state, state.selectedKey,
1857            // 'tab')`); with no id graph the tab's own text is inlined.
1858            let panel_name = a11y::Name::labelled(items[idx].label.clone());
1859            if let Some(content) = items.swap_remove(idx).content {
1860                let panel_focus = panel_focus.expect("a rendered panel has a focus handle");
1861                let panel_focus_for_keys = panel_focus.clone();
1862                let panel_focus_for_key_up = panel_focus.clone();
1863                let list_focus_for_panel = list_focus;
1864                let probe_on_key_up = !has_enabled_tab;
1865                // `.tabs__panel` is `w-full p-2`, with `mt-4` horizontally or
1866                // `ms-4` vertically.
1867                el = el.child(
1868                    gpui::div()
1869                        .w_full()
1870                        .p(px(8.))
1871                        .when(vertical, |panel| panel.ml(px(16.)))
1872                        .when(!vertical, |panel| panel.mt(px(16.)))
1873                        .track_focus(&panel_focus)
1874                        .on_key_down(move |event, window, cx| {
1875                            if event.keystroke.key != "tab" {
1876                                return;
1877                            }
1878                            if event.keystroke.modifiers.shift {
1879                                cx.stop_propagation();
1880                                crate::util::set_focus_visible(true, cx);
1881                                // Skip the wrapper only from a real child stop.
1882                                // A programmatically focused non-stop descendant
1883                                // should land on it when moving backward.
1884                                let skip_panel = !panel_focus_for_keys.is_focused(window)
1885                                    && window.focused(cx).is_some_and(|handle| handle.tab_stop);
1886                                window.focus_prev(cx);
1887                                if skip_panel && panel_focus_for_keys.is_focused(window) {
1888                                    window.focus_prev(cx);
1889                                    if has_enabled_tab && !list_focus_for_panel.is_focused(window) {
1890                                        window.focus(&list_focus_for_panel, cx);
1891                                    }
1892                                }
1893                            } else if probe_on_key_up
1894                                && event.is_held
1895                                && panel_focus_for_keys.is_focused(window)
1896                            {
1897                                // The first held repeat arrives before key-up
1898                                // has removed the synthetic wrapper stop. Walk
1899                                // the child it stands in for and then one real
1900                                // step, skipping the wrapper again on wrap.
1901                                cx.stop_propagation();
1902                                window.focus_next(cx);
1903                                if panel_focus_for_keys.contains_focused(window, cx)
1904                                    && !panel_focus_for_keys.is_focused(window)
1905                                {
1906                                    window.focus_next(cx);
1907                                    if panel_focus_for_keys.is_focused(window) {
1908                                        window.focus_next(cx);
1909                                    }
1910                                }
1911                            }
1912                        })
1913                        .on_key_up(move |event, window, cx| {
1914                            if !probe_on_key_up
1915                                || event.keystroke.key != "tab"
1916                                || event.keystroke.modifiers.shift
1917                                || !panel_focus_for_key_up.is_focused(window)
1918                            {
1919                                return;
1920                            }
1921                            // An all-disabled list has no focused tab whose
1922                            // key-down can run the forward probe. Tab key-up is
1923                            // delivered to the wrapper it just reached.
1924                            window.focus_next(cx);
1925                            if !panel_focus_for_key_up.contains_focused(window, cx) {
1926                                window.focus(&panel_focus_for_key_up, cx);
1927                            }
1928                        })
1929                        .child(content)
1930                        // Stated after the layout chain rather than inside it:
1931                        // `.shots/design_audit.py` reads `.tabs__panel`'s
1932                        // padding through a character-windowed regex anchored
1933                        // on the comment above the box, and an id plus a role
1934                        // pushed the `.p(..)` out of that window.
1935                        .id(element_id::scoped(&base, "tabpanel"))
1936                        .a11y_named(a11y::Role::TabPanel, &panel_name),
1937                );
1938            }
1939        }
1940
1941        crate::util::apply_sx(el, &self.sx)
1942    }
1943}
1944
1945#[cfg(test)]
1946mod tabs_size_tests {
1947    use super::*;
1948
1949    #[test]
1950    fn md_is_the_pinned_geometry_and_sm_scales_together() {
1951        assert_eq!(TabsSize::default(), TabsSize::Md);
1952        assert_eq!(TabsSize::Md.metrics(), (px(32.), px(16.), px(14.)));
1953        assert_eq!(TabsSize::Sm.metrics(), (px(28.), px(12.), px(12.)));
1954        assert_eq!(
1955            crate::util::leading_for(TabsSize::Sm.metrics().2),
1956            Some(px(16.))
1957        );
1958    }
1959}
1960
1961crate::util::impl_component_styled!(Tabs);