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