Skip to main content

herogpui_components/
radio_group.rs

1//! RadioGroup — port of `@heroui/radio`.
2
3use std::{cell::RefCell, rc::Rc};
4
5use gpui::{prelude::*, px, App, IntoElement, Pixels, RenderOnce, SharedString, Styled, Window};
6use herogpui_core::{element_id, Color, FieldVariant, Orientation};
7use herogpui_theme::ActiveTheme;
8
9use crate::a11y::{self, A11y as _};
10
11/// One radio's visible label, submitted value, and local disabled state.
12#[must_use = "builder methods return a new value; pass the option to its component"]
13#[derive(Clone)]
14pub struct RadioOption {
15    label: SharedString,
16    value: SharedString,
17    is_disabled: bool,
18    description: Option<SharedString>,
19    error_message: Option<SharedString>,
20}
21
22impl RadioOption {
23    /// Creates an option from a label; the submitted value defaults to the label.
24    pub fn new(label: impl Into<SharedString>) -> Self {
25        let label = label.into();
26        Self {
27            value: label.clone(),
28            label,
29            is_disabled: false,
30            description: None,
31            error_message: None,
32        }
33    }
34
35    /// `value` — submitted and reported independently of the visible label.
36    pub fn value(mut self, value: impl Into<SharedString>) -> Self {
37        self.value = value.into();
38        self
39    }
40
41    /// `isDisabled` — disables this option only.
42    pub fn is_disabled(mut self, value: bool) -> Self {
43        self.is_disabled = value;
44        self
45    }
46
47    /// `Description` — help text below this option's clickable content.
48    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
49        self.description = Some(text.into());
50        self
51    }
52
53    /// `FieldError` — validation text below this option's clickable content.
54    pub fn error_message(mut self, text: impl Into<SharedString>) -> Self {
55        self.error_message = Some(text.into());
56        self
57    }
58}
59
60impl From<SharedString> for RadioOption {
61    fn from(label: SharedString) -> Self {
62        Self::new(label)
63    }
64}
65
66impl From<String> for RadioOption {
67    fn from(label: String) -> Self {
68        Self::new(label)
69    }
70}
71
72impl From<&str> for RadioOption {
73    fn from(label: &str) -> Self {
74        Self::new(label.to_owned())
75    }
76}
77
78/// Field state handed to the root `Radio` and `Radio.Indicator` renderers.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct RadioOptionState {
82    /// Whether this radio is selected.
83    pub is_selected: bool,
84    /// Whether this radio is disabled.
85    pub is_disabled: bool,
86    /// Whether this radio is read-only.
87    pub is_read_only: bool,
88    /// Whether this radio is invalid.
89    pub is_invalid: bool,
90    /// Whether this radio is required.
91    pub is_required: bool,
92}
93
94/// HeroGPUI-only compact size for a [`RadioGroup`].
95///
96/// | Metric (pixels) | Sm | Md (pinned default) |
97/// | --- | --- | --- |
98/// | Control / selected dot / pressed dot | 14 / 5 / 8 | 16 / 6 / 8 |
99/// | Label text / line height | 12 / 16 | 14 / 20 |
100/// | Control-to-label gap / supporting-text indent | 10 / 24 | 12 / 28 |
101///
102/// Both steps have zero row padding and no extra minimum hitbox: each clickable
103/// row includes its control and label and grows with caller content. Options
104/// remain 16px apart in either orientation; horizontal groups wrap. Supporting
105/// text remains 12/16 with a 4px vertical gap. Control and dot keep `key_radius`;
106/// `radius` overrides only the control. Press scales the control by 0.95 when
107/// motion is enabled; the selected dot's pressed size remains 8px in both steps.
108///
109/// v3.2.4 removed the field `size` prop, so this is additive: `Md` is
110/// byte-identical to the pinned default and `Sm` is HeroGPUI's own 14px step.
111/// Not a v3 prop.
112#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
113pub enum RadioSize {
114    /// The small size.
115    Sm,
116    /// The medium size.
117    #[default]
118    Md,
119}
120
121impl RadioSize {
122    /// Every size, in display order.
123    pub const ALL: [RadioSize; 2] = [Self::Sm, Self::Md];
124
125    /// `(control, dot, label text, row gap)` for this step.
126    fn metrics(self) -> (Pixels, Pixels, Pixels, Pixels) {
127        match self {
128            Self::Sm => (px(14.), px(5.), px(12.), px(10.)),
129            Self::Md => (px(16.), px(6.), px(14.), px(12.)),
130        }
131    }
132
133    /// The display name of this size.
134    pub fn label(self) -> &'static str {
135        match self {
136            Self::Sm => "Small",
137            Self::Md => "Medium",
138        }
139    }
140}
141
142/// HeroUI RadioGroup.
143#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
144#[derive(IntoElement)]
145pub struct RadioGroup {
146    /// `name` — the name this control submits under; read back by
147    /// [`Self::form_field`].
148    name: Option<SharedString>,
149    id: gpui::ElementId,
150    options: Vec<RadioOption>,
151    /// Mirrors the current value, validity, successful state, focus and reset
152    /// behavior for a live [`crate::form::FormField`].
153    form_state: Rc<RefCell<crate::form::LiveFormFieldState>>,
154    /// `Radio`'s `children`-as-a-function: handed the option and its state.
155    option_content:
156        Option<std::sync::Arc<dyn Fn(&SharedString, RadioOptionState) -> gpui::AnyElement>>,
157    /// `Radio.Indicator` children — replaces the built-in dot per option.
158    indicator: Option<std::sync::Arc<dyn Fn(&SharedString, RadioOptionState) -> gpui::AnyElement>>,
159    /// The `<Description>` v3 composes inside a `<Radio>`, per option and in the
160    /// same order. `.radio` is `flex flex-col gap-1` around its content and this
161    /// text, indented under the label by `ps-7`.
162    descriptions: Vec<Option<SharedString>>,
163    /// The group's own `<Label>`, `<Description>` and `<FieldError>`. v3
164    /// composes all three inside `<RadioGroup>` -- every documented example
165    /// opens with `<Label>Plan selection</Label>` -- and a monolithic group takes
166    /// them as props, the way `CheckboxGroup` does.
167    label: Option<SharedString>,
168    description: Option<SharedString>,
169    error_message: Option<SharedString>,
170    selected: Option<usize>,
171    /// Whether `value` was supplied. `Option<usize>` cannot distinguish
172    /// "controlled, nothing selected" from "uncontrolled" on its own.
173    is_controlled: bool,
174    default_value: Option<usize>,
175    orientation: Orientation,
176    is_disabled: bool,
177    variant: FieldVariant,
178    is_invalid: bool,
179    is_required: bool,
180    is_read_only: bool,
181    on_change: Option<std::sync::Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>>,
182    /// The compact step; `Md` is the pinned default.
183    size: RadioSize,
184    /// The option labels' font size, in place of the size step's.
185    text_size: Option<Pixels>,
186    /// The control circle's corner radius, in place of the owning `key_radius`
187    /// helper. The control's pressed box follows it; the selected dot inside
188    /// keeps its own.
189    radius: Option<Pixels>,
190    /// Expands the root to the available width.
191    full_width: bool,
192    /// The `sx` slot, refined over the root style at the end of render.
193    sx: Option<Box<gpui::StyleRefinement>>,
194}
195
196impl RadioGroup {
197    /// Sets the field variant (v3 `variant`).
198    pub fn variant(mut self, variant: FieldVariant) -> Self {
199        self.variant = variant;
200        self
201    }
202
203    /// Sets the invalid state (v3 `isInvalid`).
204    pub fn is_invalid(mut self, v: bool) -> Self {
205        self.is_invalid = v;
206        self
207    }
208
209    /// Sets the required state (v3 `isRequired`).
210    pub fn is_required(mut self, v: bool) -> Self {
211        self.is_required = v;
212        self
213    }
214
215    /// `isReadOnly` — the value is shown but cannot be changed.
216    pub fn is_read_only(mut self, v: bool) -> Self {
217        self.is_read_only = v;
218        self
219    }
220
221    /// `Radio`'s root render function — handed the option's label and v3's
222    /// field state: selected, disabled, read-only, invalid and required.
223    pub fn option_content(
224        mut self,
225        render: impl Fn(&SharedString, RadioOptionState) -> gpui::AnyElement + 'static,
226    ) -> Self {
227        self.option_content = Some(std::sync::Arc::new(render));
228        self
229    }
230
231    /// `Radio.Indicator` — draws each option's indicator from its field state.
232    pub fn indicator(
233        mut self,
234        render: impl Fn(&SharedString, RadioOptionState) -> gpui::AnyElement + 'static,
235    ) -> Self {
236        self.indicator = Some(std::sync::Arc::new(render));
237        self
238    }
239
240    /// The per-option descriptions, in the order the options were given. v3
241    /// writes one `<Description>` inside each `<Radio>`; a monolithic group
242    /// takes the column instead.
243    pub fn descriptions<T: Into<SharedString>>(
244        mut self,
245        text: impl IntoIterator<Item = Option<T>>,
246    ) -> Self {
247        self.descriptions = text.into_iter().map(|opt| opt.map(Into::into)).collect();
248        self
249    }
250
251    /// Creates a radio group from an element id and its options.
252    pub fn new(id: impl Into<gpui::ElementId>, options: Vec<RadioOption>) -> Self {
253        Self {
254            name: None,
255            id: id.into(),
256            options,
257            form_state: Rc::new(RefCell::new(crate::form::LiveFormFieldState {
258                value: crate::form::FormValue::Text(SharedString::default()),
259                is_invalid: false,
260                is_successful: true,
261                focus: None,
262                restore: None,
263            })),
264            option_content: None,
265            indicator: None,
266            descriptions: Vec::new(),
267            label: None,
268            description: None,
269            error_message: None,
270            selected: None,
271            is_controlled: false,
272            default_value: None,
273            orientation: Orientation::Vertical,
274            is_disabled: false,
275            variant: FieldVariant::Primary,
276            is_invalid: false,
277            is_required: false,
278            is_read_only: false,
279            on_change: None,
280            size: RadioSize::default(),
281            text_size: None,
282            radius: None,
283            full_width: false,
284            sx: None,
285        }
286    }
287
288    /// The `<Label>` v3 composes inside the group.
289    pub fn label(mut self, text: impl Into<SharedString>) -> Self {
290        self.label = Some(text.into());
291        self
292    }
293
294    /// The `<Description>` v3 composes inside the group, below its options.
295    pub fn description(mut self, text: impl Into<SharedString>) -> Self {
296        self.description = Some(text.into());
297        self
298    }
299
300    /// The `<FieldError>` v3 composes inside the group; supplying it also marks
301    /// the group invalid, as every other field in this port does.
302    pub fn error_message(mut self, text: impl Into<SharedString>) -> Self {
303        self.error_message = Some(text.into());
304        self
305    }
306
307    /// `name` — the name this control submits under.
308    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
309        self.name = Some(name.into());
310        self
311    }
312
313    /// The `Form` field this control submits, when it has a `name`.
314    ///
315    /// v3 discovers a field through the DOM; gpui gives a child no way to reach
316    /// its ancestor, so the control hands the pair over instead. Borrows, so the
317    /// control is still yours to place:
318    ///
319    /// ```
320    /// # use gpui::{prelude::*, Window};
321    /// # use herogpui_components::{Form, RadioGroup, RadioOption};
322    /// # struct Demo;
323    /// # impl Render for Demo {
324    /// #     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
325    /// #         let form = Form::new();
326    /// #         let control = RadioGroup::new("plan", vec![RadioOption::new("Pro")]).name("plan");
327    /// let field = control.form_field();
328    /// form.field(field.unwrap()).child(control)
329    /// #     }
330    /// # }
331    /// # let mut tcx = gpui::TestAppContext::single();
332    /// # tcx.update(herogpui_theme::ThemeProvider::init);
333    /// # let _ = tcx.add_window_view(|_, _| Demo);
334    /// ```
335    pub fn form_field(&self) -> Option<crate::form::FormField> {
336        let name = self.name.clone()?;
337        let selected = if self.is_controlled {
338            self.selected
339        } else {
340            self.default_value
341        };
342        {
343            let mut state = self.form_state.borrow_mut();
344            state.value = crate::form::FormValue::Text(
345                selected
346                    .and_then(|index| self.options.get(index))
347                    .map(|option| option.value.clone())
348                    .unwrap_or_default(),
349            );
350            state.is_invalid = self.is_invalid
351                || self.error_message.is_some()
352                || self
353                    .options
354                    .iter()
355                    .any(|option| option.error_message.is_some());
356            state.is_successful = !self.is_disabled;
357        }
358        Some(
359            crate::form::FormField::live(name, self.form_state.clone())
360                .is_required(self.is_required),
361        )
362    }
363
364    /// `value` — the selected option's value. Supplying it makes the group controlled.
365    pub fn value(mut self, value: impl AsRef<str>) -> Self {
366        self.selected = self
367            .options
368            .iter()
369            .position(|option| option.value == value.as_ref());
370        self.form_state.borrow_mut().value = crate::form::FormValue::Text(
371            self.selected
372                .and_then(|index| self.options.get(index))
373                .map(|option| option.value.clone())
374                .unwrap_or_default(),
375        );
376        self.is_controlled = true;
377        self
378    }
379
380    /// `defaultValue` — the uncontrolled initial selection.
381    ///
382    /// Only consulted when `value` is not supplied; the group then owns the
383    /// selection and a press moves it.
384    pub fn default_value(mut self, value: impl AsRef<str>) -> Self {
385        self.default_value = self
386            .options
387            .iter()
388            .position(|option| option.value == value.as_ref());
389        if !self.is_controlled {
390            self.form_state.borrow_mut().value = crate::form::FormValue::Text(
391                self.default_value
392                    .and_then(|index| self.options.get(index))
393                    .map(|option| option.value.clone())
394                    .unwrap_or_default(),
395            );
396        }
397        self
398    }
399
400    /// Sets the layout direction of the options (v3 `orientation`).
401    pub fn orientation(mut self, o: Orientation) -> Self {
402        self.orientation = o;
403        self
404    }
405
406    /// Sets the disabled state (v3 `isDisabled`).
407    pub fn is_disabled(mut self, v: bool) -> Self {
408        self.is_disabled = v;
409        self
410    }
411
412    /// Sets the handler called with the newly selected value (v3 `onChange`).
413    pub fn on_change(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
414        self.on_change = Some(std::sync::Arc::new(f));
415        self
416    }
417
418    /// Sets the compact step. `Md` is the default and byte-identical to the
419    /// pinned control; `Sm` is a 14px control with a 5px dot, 12px label text
420    /// (16px leading) and a 10px row gap. Not a v3 prop.
421    pub fn size(mut self, size: RadioSize) -> Self {
422        self.size = size;
423        self
424    }
425
426    /// The option labels' font size, in place of the size step's. A 12/14/16px size
427    /// takes v3's leading pair (16/20/24); any other keeps the 20px leading.
428    /// The control circle, its dot, the gap and the descriptions keep the size step, so the override changes the text and its line box only. Not a v3 prop: v3 sets it with a class on `Radio.Content`.
429    pub fn text_size(mut self, size: impl Into<Pixels>) -> Self {
430        self.text_size = Some(size.into());
431        self
432    }
433
434    /// The radio control's corner radius, in place of the owning `key_radius`
435    /// helper: the control circle takes it and its pressed box scales the same
436    /// value instead of snapping back to the helper, while the selected dot
437    /// inside is an inner part and keeps its own. Not a v3 prop; the removed
438    /// v2 `radius` prop is prohibited and this is a per-component repository
439    /// extension.
440    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
441        self.radius = Some(radius.into());
442        self
443    }
444
445    /// `fullWidth` — expands the root to the available width without
446    /// redistributing the children.
447    pub fn full_width(mut self, v: bool) -> Self {
448        self.full_width = v;
449        self
450    }
451
452    /// The one slot for caller-owned low-level styling: GPUI's styling methods
453    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
454    /// applied to the radio group's root element after every value the
455    /// orientation and the active theme chose, so they win.
456    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
457        crate::util::refine_sx(&mut self.sx, style);
458        self
459    }
460}
461
462impl RenderOnce for RadioGroup {
463    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
464        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
465        let (selected, own) = crate::util::controlled(
466            window,
467            cx,
468            element_id::scoped(&self.id, "value"),
469            self.is_controlled.then_some(self.selected),
470            self.default_value,
471        );
472        let is_invalid = self.is_invalid
473            || self.error_message.is_some()
474            || self
475                .options
476                .iter()
477                .any(|option| option.error_message.is_some());
478        let selected_value = selected
479            .and_then(|index| self.options.get(index))
480            .map(|option| option.value.clone())
481            .unwrap_or_default();
482        {
483            let mut state = self.form_state.borrow_mut();
484            state.value = crate::form::FormValue::Text(selected_value);
485            state.is_invalid = is_invalid;
486            state.is_successful = !self.is_disabled;
487        }
488
489        let reset_own = own.clone();
490        let reset_state = Rc::downgrade(&self.form_state);
491        let reset_change = self.is_controlled.then(|| self.on_change.clone()).flatten();
492        let reset_index = self.default_value;
493        let reset_value = reset_index
494            .and_then(|index| self.options.get(index))
495            .map(|option| option.value.clone())
496            .unwrap_or_default();
497        self.form_state.borrow_mut().restore = (reset_own.is_some() || reset_change.is_some())
498            .then(|| {
499                crate::util::shared(move |window: &mut Window, cx: &mut App| {
500                    if let Some(state) = reset_state.upgrade() {
501                        state.borrow_mut().value =
502                            crate::form::FormValue::Text(reset_value.clone());
503                    }
504                    if let Some(held) = &reset_own {
505                        held.update(cx, |selected, cx| {
506                            *selected = reset_index;
507                            cx.notify();
508                        });
509                    }
510                    if let Some(on_change) = &reset_change {
511                        on_change(&reset_value, window, cx);
512                    }
513                }) as std::sync::Arc<dyn Fn(&mut Window, &mut App)>
514            });
515
516        // *One* handle for the whole group, because a radio group is one tab
517        // stop. Which row claims it is what moves: a roving tab stop cannot be
518        // done by flipping a handle's `tab_stop`, since that is fixed where the
519        // handle is made. `use_keyed_state` takes `cx` mutably, so it precedes
520        // the theme.
521        // Every per-option id hangs off the group id as structure, so a
522        // per-option key costs an `Arc` clone rather than a fresh `String`.
523        let id_prefix = self.id.clone();
524        let group_focus =
525            crate::util::tab_stop_handle(element_id::scoped(&id_prefix, "focus"), window, cx);
526        self.form_state.borrow_mut().focus = Some(group_focus.clone());
527
528        // A radio group is *one* tab stop: Tab moves past the whole group and
529        // the arrows choose within it, which is the ARIA radio-group pattern
530        // React Aria implements. The stop is the selected option, or the first
531        // when nothing is selected yet.
532        //
533        // `Radio.isDisabled` options are left out of `stops` -- the list the
534        // arrows and Home/End walk -- so the cursor never lands on one. The
535        // tab stop skips them too: a stop resting on a disabled option has no
536        // row to claim the group's handle (AGENTS.md's roving tab stop), which
537        // would take the whole group out of the tab order. So with nothing
538        // selected and the first option disabled, the group is still reachable
539        // by Tab, on the first *enabled* option. With every option disabled
540        // `stops` is empty, no row tracks the handle, and the group leaves the
541        // tab order exactly as the group-wide `is_disabled` does.
542        // `Arc` because every enabled option's key handler captures the whole
543        // list: a plain clone per option was O(n^2) per frame.
544        let stops: std::sync::Arc<Vec<usize>> = std::sync::Arc::new(
545            (0..self.options.len())
546                .filter(|i| !self.options[*i].is_disabled)
547                .collect(),
548        );
549        let initial_focus_index = selected
550            .filter(|i| stops.contains(i))
551            .or_else(|| stops.first().copied())
552            .unwrap_or(0);
553        // Actual focus and selection diverge in a read-only group: React
554        // Aria's arrow handler focuses the next input first, then its stately
555        // setter rejects the value change. Keep that roving cursor separately
556        // from `selected`, keyed by the component id so two groups cannot
557        // share it.
558        let cursor =
559            window.use_keyed_state(element_id::scoped(&id_prefix, "cursor"), cx, move |_, _| {
560                initial_focus_index
561            });
562        let held_cursor = *cursor.read(cx);
563        let cursor_index = if group_focus.is_focused(window) && stops.contains(&held_cursor) {
564            held_cursor
565        } else {
566            initial_focus_index
567        };
568
569        // One hover/press slot per option. The row's press grows the selected
570        // indicator from 6px to 8px.
571        let interaction: Vec<crate::util::Interaction> = (0..self.options.len())
572            .map(|i| {
573                crate::util::interaction(
574                    element_id::scoped(&element_id::indexed(&id_prefix, "opt", i), "interaction"),
575                    window,
576                    cx,
577                )
578            })
579            .collect();
580
581        let sem = *cx.role(Color::Accent);
582        let colors = cx.colors().clone();
583        let layout = cx.layout().clone();
584        // `.radio__control` is `size-4 rounded-lg` — a rounded square, not a
585        // circle — and `.radio__indicator` fills it at `rounded-lg` too.
586        // The selected dot is the indicator scaled to `0.4286` of the 16px
587        // control, which v3's own comment rounds to 6px. (8px is its *pressed*
588        // size, `scale: 0.5714`.)
589        let (circle, dot, text, gap) = self.size.metrics();
590        // The override reaches the option labels only; the control circle's
591        // press box keeps the size step's metrics below.
592        let label_text = self.text_size.unwrap_or(text);
593
594        // `.radio-group` spaces its options with `mt-4` when vertical and
595        // `gap-4` when horizontal — 16px either way.
596        let mut group = match self.orientation {
597            Orientation::Horizontal => gpui::div().flex().items_center().flex_wrap().gap(px(16.)),
598            Orientation::Vertical => gpui::div().flex().flex_col().gap(px(16.)),
599        };
600        // `.radio-group--secondary` is not a panel: it only repaints the
601        // *control* with `--default` and drops its shadow. This used to wrap the
602        // whole group in a padded `surface_secondary` card, which v3 has no rule
603        // for.
604        let control_bg = match self.variant {
605            FieldVariant::Primary => colors.field.background,
606            FieldVariant::Secondary => colors.default.color,
607        };
608        let control_shadow = (self.variant == FieldVariant::Primary
609            && !layout.field_shadow.is_empty())
610        .then(|| layout.field_shadow.clone());
611
612        // The control circle's radius, resolved once: the pressed box below
613        // scales the same value instead of snapping back to the helper.
614        let control_radius = self.radius.unwrap_or_else(|| crate::util::key_radius(cx));
615
616        let option_values: std::sync::Arc<Vec<SharedString>> = std::sync::Arc::new(
617            self.options
618                .iter()
619                .map(|option| option.value.clone())
620                .collect(),
621        );
622        for (i, option) in self.options.into_iter().enumerate() {
623            let label = option.label;
624            let value = option.value;
625            let description = option
626                .description
627                .or_else(|| self.descriptions.get(i).and_then(|text| text.clone()));
628            let error_message = option.error_message;
629            let is_selected = selected == Some(i);
630            let option_invalid =
631                self.is_invalid || self.error_message.is_some() || error_message.is_some();
632            // `Radio.isDisabled` — the option's own switch, beside the
633            // group-wide `is_disabled`: dimmed (`status-disabled`'s opacity,
634            // v3's "reduced opacity, no pointer events"), no pointer
635            // affordance, no click handler and no place in the tab order or
636            // the arrow navigation.
637            let row_disabled = self.is_disabled || option.is_disabled;
638            let (is_hovered, is_pressed) = interaction
639                .get(i)
640                .map(|slot| *slot.read(cx))
641                .unwrap_or_default();
642            let option_state = RadioOptionState {
643                is_selected,
644                is_disabled: row_disabled,
645                is_read_only: self.is_read_only,
646                is_invalid: option_invalid,
647                is_required: self.is_required,
648            };
649            // `.radio__control` uses the field border width and shadow from the
650            // active theme. Unselected hover changes its fill; selected hover
651            // keeps `bg-accent` and only changes the border, matching
652            // `radio.css` where `bg-accent-hover` is reserved for press.
653            let hover_bg = match self.variant {
654                FieldVariant::Primary => colors.field.hover(),
655                FieldVariant::Secondary => colors.default.hover(),
656            };
657            let mut circle_el = gpui::div()
658                .id(element_id::scoped(
659                    &element_id::indexed(&id_prefix, "opt", i),
660                    "control",
661                ))
662                .flex()
663                .items_center()
664                .justify_center()
665                .size(circle)
666                .rounded(control_radius)
667                .flex_shrink_0()
668                .border(layout.field_border_width)
669                // HeroUI removes the field border from the selected control;
670                // the accent fill owns that edge. An enabled custom theme can
671                // expose a nonzero field border, so keep the selected state
672                // transparent instead of letting the base border show through.
673                .border_color(if is_selected {
674                    gpui::transparent_black()
675                } else {
676                    colors.field.border
677                })
678                .bg(if is_selected { sem.color } else { control_bg })
679                .when(is_hovered && !row_disabled, |el| {
680                    el.border_color(colors.field.border_hover())
681                        .when(!is_selected, |el| el.bg(hover_bg))
682                })
683                .when_some(control_shadow.clone(), |el, shadows| el.shadow(shadows));
684
685            if let Some(render) = &self.indicator {
686                circle_el = circle_el.child(render(&label, option_state));
687            } else if is_selected {
688                circle_el = circle_el.child(
689                    gpui::div()
690                        .size(if is_pressed { px(8.) } else { dot })
691                        .rounded(crate::util::key_radius(cx))
692                        .bg(sem.foreground),
693                );
694            }
695            // `status-invalid-field` is a 1px danger outline over whatever the
696            // control already paints — it does not replace the fill, and v3
697            // applies it whether or not the option is selected.
698            if option_invalid {
699                circle_el = circle_el.border_1().border_color(colors.danger.color);
700            }
701
702            // v3 focuses the radio and rings `.radio__control`: the row takes the
703            // focus, the control shows it.
704            let focused = i == cursor_index
705                && group_focus.is_focused(window)
706                && crate::util::focus_visible(cx);
707            // The ring is an overlay child on the control itself, not on the
708            // press skin `anim::pressed_with_background` builds below: the
709            // control is the element that carries `control_radius` and the one
710            // v3 rings, and the press refinement lands on that same element, so
711            // the overlay scales with it. Concentric by construction, where the
712            // spread shadow repeated the control's radius two pixels out.
713            let circle_el = crate::util::with_focus_ring_overlay(
714                circle_el,
715                focused && !row_disabled,
716                true,
717                control_radius,
718                control_shadow.clone().unwrap_or_default(),
719                cx,
720            );
721
722            // `.radio__control[data-pressed]` is `scale-95`, and a checked one
723            // also fills with `bg-accent-hover`. A disabled option cannot be
724            // pressed, so it skips the animation like a read-only one.
725            let circle_el = if row_disabled || self.is_read_only {
726                circle_el
727            } else {
728                // The pressed fill rides inside the press refinement, which
729                // owns the scale; a chained `.active` would replace it.
730                let pressed_fill = sem.hover();
731                if is_selected {
732                    crate::anim::pressed_with_background(
733                        circle_el,
734                        crate::anim::PressBox {
735                            height: circle,
736                            padding_x: None,
737                            width: Some(circle),
738                            min_width: None,
739                            text_size: text,
740                            line_height: text,
741                            gap: px(0.),
742                            radius: control_radius,
743                            shrink_x: true,
744                            scale: crate::anim::PRESSED_SCALE_DEEP,
745                        },
746                        pressed_fill,
747                        cx,
748                    )
749                } else {
750                    crate::anim::pressed(
751                        circle_el,
752                        crate::anim::PressBox {
753                            height: circle,
754                            padding_x: None,
755                            width: Some(circle),
756                            min_width: None,
757                            text_size: text,
758                            line_height: text,
759                            gap: px(0.),
760                            radius: control_radius,
761                            shrink_x: true,
762                            scale: crate::anim::PRESSED_SCALE_DEEP,
763                        },
764                        cx,
765                    )
766                }
767            };
768
769            // Each option is a native `<input type="radio">` upstream, so its
770            // role is `radio` and `aria-checked` follows the selection.
771            let option_name = a11y::Name::field(
772                Some(&label),
773                description.as_ref(),
774                &crate::validation::resolve(option_invalid, &[], None, error_message.clone()),
775            );
776            let mut row = gpui::div()
777                .id(element_id::indexed(&id_prefix, "opt", i))
778                .a11y_named(a11y::Role::RadioButton, &option_name)
779                .a11y_checked(is_selected, false)
780                .when(!row_disabled && i == cursor_index, |r| {
781                    r.track_focus(&group_focus)
782                })
783                .flex()
784                .items_center()
785                .gap(gap)
786                .text_size(label_text)
787                .line_height(crate::util::leading_for(label_text).unwrap_or(px(20.)))
788                .font_weight(gpui::FontWeight::MEDIUM)
789                .text_color(colors.foreground)
790                .when(!row_disabled && !self.is_read_only, |r| {
791                    r.cursor(crate::util::interactive_cursor(cx))
792                })
793                .when(row_disabled, |r| r.opacity(layout.disabled_opacity))
794                .child(circle_el)
795                .child(match &self.option_content {
796                    Some(render) => render(&label, option_state),
797                    None => label.into_any_element(),
798                });
799            if !row_disabled && !self.is_read_only {
800                if let Some(slot) = interaction.get(i) {
801                    row = crate::util::track_interaction(row, slot);
802                }
803            }
804
805            if !row_disabled {
806                let on_change = self.on_change.clone();
807                let own = own.clone();
808                let read_only = self.is_read_only;
809                // The arrows always take focus with them. In a mutable group
810                // they also select; read-only keeps the cursor movement and
811                // rejects only that second step, matching the pinned hooks.
812                let key_change = on_change.clone();
813                let key_own = own.clone();
814                let key_stops = stops.clone();
815                let key_cursor = cursor.clone();
816                let key_values = option_values.clone();
817                let key_form_state = self.form_state.clone();
818                row = row.on_key_down(move |event, window, cx| {
819                    let key = match event.keystroke.key.as_str() {
820                        "down" | "right" => "down",
821                        "up" | "left" => "up",
822                        _ => return,
823                    };
824                    // `useRadioGroup` owns all four arrows, but has no Home or
825                    // End shortcut. Leave those and every other key available
826                    // to the enclosing surface.
827                    cx.stop_propagation();
828                    let crate::list_nav::Move::To(next) =
829                        crate::list_nav::resolve(&key_stops, Some(i), key, true)
830                    else {
831                        return;
832                    };
833                    key_cursor.update(cx, |v, cx| {
834                        *v = next;
835                        cx.notify();
836                    });
837                    if !read_only {
838                        if let Some(held) = &key_own {
839                            key_form_state.borrow_mut().value =
840                                crate::form::FormValue::Text(key_values[next].clone());
841                            held.update(cx, |v, cx| {
842                                *v = Some(next);
843                                cx.notify();
844                            });
845                        }
846                        if let Some(f) = &key_change {
847                            f(&key_values[next], window, cx);
848                        }
849                    }
850                });
851                let click_cursor = cursor.clone();
852                let click_focus = group_focus.clone();
853                let click_form_state = self.form_state.clone();
854                row = row.on_click(move |_, window, cx| {
855                    window.focus(&click_focus, cx);
856                    click_cursor.update(cx, |v, cx| {
857                        *v = i;
858                        cx.notify();
859                    });
860                    if !read_only {
861                        // Uncontrolled: move our own selection, or pressing a
862                        // radio would do nothing.
863                        if let Some(held) = &own {
864                            click_form_state.borrow_mut().value =
865                                crate::form::FormValue::Text(value.clone());
866                            held.update(cx, |v, cx| {
867                                *v = Some(i);
868                                cx.notify();
869                            });
870                        }
871                        if let Some(f) = &on_change {
872                            f(&value, window, cx);
873                        }
874                    }
875                });
876            }
877
878            if !row_disabled && i == cursor_index {
879                row = crate::util::record_focus_bounds(row, &group_focus, window, cx);
880            }
881            // `.radio` is `flex flex-col gap-1` around its content and the
882            // description, which `ps-7` indents under the label -- the control
883            // plus the content gap.
884            match (error_message, description) {
885                (Some(message), _) => {
886                    group = group.child(
887                        gpui::div().flex().flex_col().gap(px(4.)).child(row).child(
888                            gpui::div()
889                                .pl(circle + gap)
890                                .child(crate::field::ErrorMessage::new(message)),
891                        ),
892                    );
893                }
894                (None, Some(text)) => {
895                    group = group.child(
896                        gpui::div().flex().flex_col().gap(px(4.)).child(row).child(
897                            gpui::div()
898                                .pl(circle + gap)
899                                .child(crate::field::Description::new(text)),
900                        ),
901                    );
902                }
903                (None, None) => group = group.child(row),
904            }
905        }
906
907        // `.radio` is `flex flex-col gap-1`, and the group's own label,
908        // description and error are its siblings. v3 marks `isRequired` on the
909        // Label rather than adding a line of its own, which is what
910        // `field::Label` draws.
911        // `useRadioGroup` is `role="radiogroup"` with `aria-orientation`
912        // always present (it defaults to `vertical`), named and described
913        // through `useField`.
914        let group_name = a11y::Name::field(
915            self.label.as_ref(),
916            self.description.as_ref(),
917            &crate::validation::resolve(is_invalid, &[], None, self.error_message.clone()),
918        );
919        let mut root = gpui::div()
920            .id(self.id.clone())
921            .a11y_named(a11y::Role::RadioGroup, &group_name)
922            .a11y_orientation(self.orientation)
923            .flex()
924            .flex_col()
925            .gap(px(4.));
926        if self.full_width {
927            root = root.w_full();
928        }
929        if let Some(label) = &self.label {
930            root = root.child(
931                crate::field::Label::new(label.clone())
932                    .is_required(self.is_required)
933                    .is_disabled(self.is_disabled)
934                    .is_invalid(is_invalid),
935            );
936        }
937        // v3's order, from its own examples: the group's `<Description>` sits
938        // between the label and the options, and its `<FieldError>` after them.
939        // (A *field*'s description is replaced by its error; `radio-group.css`
940        // has no rule hiding this one, so both can show.)
941        if let Some(description) = &self.description {
942            root = root.child(crate::field::Description::new(description.clone()));
943        }
944        root = root.child(group);
945        let error = is_invalid.then(|| self.error_message.clone()).flatten();
946        if let Some(error) = crate::anim::field_error_panel(&self.id, error, window, cx) {
947            root = root.child(error);
948        }
949        root = crate::util::apply_sx(root, &self.sx);
950        root
951    }
952}
953
954#[cfg(test)]
955mod tests {
956    use super::*;
957
958    /// Render wraps the walk list and the value list in `Arc` so every enabled
959    /// option's key handler clones the pointer, not the Vec.
960    #[test]
961    fn option_key_handlers_share_walk_and_value_lists() {
962        let source = include_str!("radio_group.rs")
963            .split("#[cfg(test)]")
964            .next()
965            .expect("the implementation section is always present");
966        assert!(
967            source.contains("let stops: std::sync::Arc<Vec<usize>> = std::sync::Arc::new("),
968            "the walk list must be one Arc shared by every enabled option"
969        );
970        assert!(
971            source.contains(
972                "let option_values: std::sync::Arc<Vec<SharedString>> = std::sync::Arc::new("
973            ),
974            "the value list must be one Arc shared by every enabled option"
975        );
976        assert!(
977            source.contains("let key_stops = stops.clone();"),
978            "each enabled option must clone the shared walk list"
979        );
980        assert!(
981            source.contains("let key_values = option_values.clone();"),
982            "each enabled option must clone the shared value list"
983        );
984
985        // The clones above are `Arc::clone`: two handles to one allocation.
986        let stops: std::sync::Arc<Vec<usize>> = std::sync::Arc::new(vec![0, 1, 2]);
987        let values: std::sync::Arc<Vec<SharedString>> =
988            std::sync::Arc::new(vec![SharedString::from("v0")]);
989        let walk = std::sync::Arc::clone(&stops);
990        let list = std::sync::Arc::clone(&values);
991        assert!(
992            std::sync::Arc::ptr_eq(&walk, &stops) && std::sync::Arc::ptr_eq(&list, &values),
993            "Arc clones of the walk and value lists must share pointer identity"
994        );
995    }
996
997    #[test]
998    fn hover_background_respects_selection_and_variant() {
999        let source = include_str!("radio_group.rs")
1000            .split("#[cfg(test)]")
1001            .next()
1002            .expect("the implementation section is always present");
1003        assert!(source.contains("let hover_bg = match self.variant"));
1004        assert!(source.contains("FieldVariant::Primary => colors.field.hover()"));
1005        assert!(source.contains("FieldVariant::Secondary => colors.default.hover()"));
1006        assert!(source.contains(".when(is_hovered && !row_disabled"));
1007        assert!(source.contains(".when(!is_selected, |el| el.bg(hover_bg))"));
1008        assert!(source.contains("colors.field.border_hover()"));
1009        assert!(source.contains("if is_selected {\n                    gpui::transparent_black()"));
1010    }
1011}
1012
1013#[cfg(test)]
1014mod radio_size_tests {
1015    use super::*;
1016
1017    #[test]
1018    fn md_is_the_pinned_geometry_and_sm_scales_together() {
1019        assert_eq!(RadioSize::default(), RadioSize::Md);
1020        assert_eq!(RadioSize::Md.metrics(), (px(16.), px(6.), px(14.), px(12.)));
1021        assert_eq!(RadioSize::Sm.metrics(), (px(14.), px(5.), px(12.), px(10.)));
1022        assert_eq!(
1023            crate::util::leading_for(RadioSize::Sm.metrics().2),
1024            Some(px(16.))
1025        );
1026    }
1027}
1028
1029crate::util::impl_component_styled!(RadioGroup);