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