Skip to main content

herogpui_components/
checkbox.rs

1//! Checkbox — port of `@heroui/checkbox`.
2
3use std::{cell::RefCell, rc::Rc, time::Duration};
4
5use gpui::{
6    prelude::*, px, AnimationExt, AnyElement, App, IntoElement, ParentElement, Pixels, RenderOnce,
7    StatefulInteractiveElement, Styled, Window,
8};
9use herogpui_core::{element_id, Color};
10use herogpui_theme::ActiveTheme;
11
12use crate::a11y::{self, A11y as _};
13use crate::anim::Tween;
14
15/// Field state handed to Checkbox's children and indicator render functions.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct CheckboxState {
19    /// Whether the checkbox is selected.
20    pub is_selected: bool,
21    /// Whether the checkbox shows the indeterminate dash.
22    pub is_indeterminate: bool,
23    /// Whether the checkbox is disabled.
24    pub is_disabled: bool,
25    /// Whether the checkbox is read-only.
26    pub is_read_only: bool,
27    /// Whether the checkbox is invalid.
28    pub is_invalid: bool,
29    /// Whether the checkbox is required.
30    pub is_required: bool,
31}
32
33// ---------------------------------------------------------------------------
34// Selection motion
35// ---------------------------------------------------------------------------
36//
37// The pinned v3.2.4 stylesheet animates a tick in three layers, each with its
38// own duration and curve. `.checkbox__control::before` — the accent fill —
39// runs `scale 100ms var(--ease-linear)` from `scale-70` and
40// `opacity 200ms var(--ease-linear)` from `opacity-0`, over a
41// `background-color 200ms var(--ease-out)` that the control's own background
42// shares while indeterminate. The checkmark SVG carries `strokeDasharray: 22`
43// and jumps `strokeDashoffset` from 66 (hidden) to 44 (drawn) in its JSX;
44// selected, that offset runs `150ms linear` after `15ms`, and unselecting
45// falls back to the base `transition-all duration-200`. gpui has no
46// stroke-dashoffset, but its `PathBuilder` strokes a polyline, so the mark is
47// drawn on a canvas up to the same revealed fraction.
48
49/// `opacity 200ms var(--ease-linear)` on `.checkbox__control::before`.
50const FILL_FADE_MS: u64 = 200;
51/// `scale 100ms var(--ease-linear)` on the same pseudo-element, from
52/// `scale-70`.
53const FILL_SCALE_MS: u64 = 100;
54/// `scale-70` — the fill's resting scale.
55const FILL_REST_SCALE: f32 = 0.7;
56/// `background-color 200ms var(--ease-out)` — the colour ease both animated
57/// background layers ride: the fill's hover swap and the control's own
58/// indeterminate/pressed change.
59const FILL_BG_MS: u64 = 200;
60/// Selected, the checkmark draws over `stroke-dashoffset 150ms linear` after a
61/// `15ms` delay.
62const CHECK_DRAW_MS: u64 = 150;
63const CHECK_DRAW_DELAY_MS: u64 = 15;
64/// Unselected it undraws over the base `transition-all duration-200`.
65const CHECK_UNDRAW_MS: u64 = 200;
66
67/// The pinned checkmark geometry: viewBox `0 0 17 18` and polyline
68/// `1 9 7 14 15 4` from `@heroui/react` 3.2.4's `checkbox.js`, stroked at the
69/// `stroke-[2.5px]` its stylesheet lays on the checkmark slot — the JSX
70/// itself says 2. The dash reveal needs exactly this polyline — its length
71/// stays under the 22-unit `strokeDasharray`, so the drawn state shows the
72/// whole stroke — and the checkbox therefore strokes it on a canvas rather
73/// than rendering the shared 24-unit `icons::CHECK` asset, whose tip the
74/// 22-unit dash would clip.
75const CHECK_VIEWBOX: (f32, f32) = (17., 18.);
76const CHECK_POLYLINE: [(f32, f32); 3] = [(1., 9.), (7., 14.), (15., 4.)];
77const CHECK_STROKE: f32 = 2.5;
78/// How much stroke the CSS slide uncovers: `strokeDashoffset` runs 66 → 44,
79/// so the visible dash grows by 22 units — overshooting this ~20.6-unit
80/// polyline, whose tip the drawn state clamps at.
81const CHECK_DASH_UNITS: f32 = 22.;
82
83/// Animate the painted fill itself: GPUI's overflow clip is rectangular,
84/// so rounding a transparent parent does not round its background child.
85fn fill_layer(
86    id: &gpui::ElementId,
87    opacity: Tween<f32>,
88    scale: Tween<f32>,
89    reduce_motion: bool,
90    radius: Pixels,
91    box_px: Pixels,
92    background: Tween<gpui::Hsla>,
93) -> AnyElement {
94    let (opacity_to, scale_to) = (opacity.target(), scale.target());
95    let color_to = background.target();
96    let base = gpui::div().absolute();
97    if !opacity.animates(reduce_motion)
98        && !scale.animates(reduce_motion)
99        && !background.animates(reduce_motion)
100    {
101        opacity.settle();
102        scale.settle();
103        background.settle();
104        let inset = box_px * (1.0 - scale_to) / 2.0;
105        return base
106            .left(inset)
107            .top(inset)
108            .right(inset)
109            .bottom(inset)
110            .rounded(radius * scale_to)
111            .opacity(opacity_to)
112            .bg(color_to)
113            .into_any_element();
114    }
115
116    let color_from = background.from();
117    let color = background.value();
118    let colored = base.with_animation(
119        element_id::indexed(id, "fill-color", background.generation()),
120        gpui::Animation::new(Duration::from_millis(FILL_BG_MS))
121            .with_easing(|t| crate::anim::Curve::Out.at(t)),
122        move |el, delta| {
123            let value = if delta >= 1.0 {
124                color_to
125            } else {
126                herogpui_core::mix_oklab(color_from, color_to, delta)
127            };
128            color.set(value);
129            el.bg(value)
130        },
131    );
132    let scale_from = scale.from();
133    let scale_value = scale.value();
134    let scaled = colored.with_animation(
135        element_id::indexed(id, "fill-scale", scale.generation()),
136        gpui::Animation::new(Duration::from_millis(FILL_SCALE_MS))
137            .with_easing(|t| crate::anim::Curve::Linear.at(t)),
138        move |el, delta| {
139            let value = scale_from + (scale_to - scale_from) * delta;
140            scale_value.set(value);
141            let inset = box_px * (1.0 - value) / 2.0;
142            el.map_element(|fill| {
143                fill.left(inset)
144                    .top(inset)
145                    .right(inset)
146                    .bottom(inset)
147                    .rounded(radius * value)
148            })
149        },
150    );
151    let opacity_from = opacity.from();
152    let opacity_value = opacity.value();
153    scaled
154        .with_animation(
155            element_id::indexed(id, "fill-fade", opacity.generation()),
156            gpui::Animation::new(Duration::from_millis(FILL_FADE_MS))
157                .with_easing(|t| crate::anim::Curve::Linear.at(t)),
158            move |el, delta| {
159                let value = opacity_from + (opacity_to - opacity_from) * delta;
160                opacity_value.set(value);
161                el.map_element(|colored| colored.map_element(|fill| fill.opacity(value)))
162            },
163        )
164        .into_any_element()
165}
166
167/// A background layer riding `background-color 200ms var(--ease-out)` — an
168/// OKLab ease between generations, a plain fill otherwise. Its animation id
169/// belongs to a listener-free child, keeping the control's input path stable.
170fn easing_bg_layer(
171    id: &gpui::ElementId,
172    tag: &'static str,
173    target: gpui::Hsla,
174    radius: Pixels,
175    window: &mut Window,
176    cx: &mut App,
177) -> AnyElement {
178    let reduce_motion = ActiveTheme::reduce_motion(cx);
179    let mut tween = Tween::keyed(id, tag, target, window, cx);
180    tween.snap_if_reduced(reduce_motion);
181    let base = gpui::div().absolute().inset_0().rounded(radius);
182    if !tween.animates(reduce_motion) {
183        tween.settle();
184        return base.bg(target).into_any_element();
185    }
186    let (from, to) = (tween.from(), tween.target());
187    let color = tween.value();
188    base.with_animation(
189        element_id::indexed(id, tag, tween.generation()),
190        gpui::Animation::new(Duration::from_millis(FILL_BG_MS))
191            .with_easing(|t| crate::anim::Curve::Out.at(t)),
192        move |el, delta| {
193            let next = if delta >= 1.0 {
194                to
195            } else {
196                herogpui_core::mix_oklab(from, to, delta)
197            };
198            color.set(next);
199            el.bg(next)
200        },
201    )
202    .into_any_element()
203}
204
205/// The checkmark canvas, stroked up to the current drawn fraction. The
206/// animation id changes with the generation, which restarts the reveal from
207/// the rendered fraction after an interrupted turn. Selected, the draw rides
208/// the CSS `150ms linear` after its `15ms` delay; unselecting undraws over
209/// the base `duration-200` on Tailwind's default transition curve.
210fn check_layer(
211    id: &gpui::ElementId,
212    tween: Tween<f32>,
213    reduce_motion: bool,
214    size: Pixels,
215    color: gpui::Hsla,
216) -> AnyElement {
217    let progress = tween.value();
218    let canvas = gpui::canvas(
219        |bounds, _, _| bounds,
220        move |bounds, _, window, _| paint_check_stroke(bounds, progress.get(), color, window),
221    )
222    .size(size);
223    if !tween.animates(reduce_motion) {
224        tween.settle();
225        return canvas.into_any_element();
226    }
227
228    let (from, to) = (tween.from(), tween.target());
229    let (duration, easing): (u64, Box<dyn Fn(f32) -> f32>) = if to > from {
230        let total = (CHECK_DRAW_DELAY_MS + CHECK_DRAW_MS) as f32;
231        let delay = CHECK_DRAW_DELAY_MS as f32;
232        let span = CHECK_DRAW_MS as f32;
233        (
234            CHECK_DRAW_DELAY_MS + CHECK_DRAW_MS,
235            Box::new(move |t: f32| ((t * total - delay) / span).clamp(0., 1.)),
236        )
237    } else {
238        (
239            CHECK_UNDRAW_MS,
240            Box::new(crate::anim::tailwind_default_ease()),
241        )
242    };
243    let progress = tween.value();
244    canvas
245        .with_animation(
246            element_id::indexed(id, "check-draw", tween.generation()),
247            gpui::Animation::new(Duration::from_millis(duration)).with_easing(easing),
248            move |el, delta| {
249                progress.set(from + (to - from) * delta);
250                el
251            },
252        )
253        .into_any_element()
254}
255
256/// One butt-capped stroke between two points; the discs
257/// [`paint_check_stroke`] paints over the shared ends are what make the caps
258/// and the join read round.
259fn stroke_segment(
260    a: gpui::Point<Pixels>,
261    b: gpui::Point<Pixels>,
262    width: Pixels,
263    color: gpui::Hsla,
264    window: &mut Window,
265) {
266    let mut builder = gpui::PathBuilder::stroke(width);
267    builder.move_to(a);
268    builder.line_to(b);
269    if let Ok(path) = builder.build() {
270        window.paint_path(path, color);
271    }
272}
273
274/// Strokes the leading fraction of the pinned upstream polyline — the drawing
275/// end of the CSS `stroke-dashoffset` slide, which reveals the check from its
276/// start point through the elbow to the tip.
277fn paint_check_stroke(
278    bounds: gpui::Bounds<Pixels>,
279    progress: f32,
280    color: gpui::Hsla,
281    window: &mut Window,
282) {
283    if progress <= 0.0 {
284        return;
285    }
286    let progress = progress.min(1.0);
287    let (view_w, view_h) = CHECK_VIEWBOX;
288    let scale = (f32::from(bounds.size.width) / view_w).min(f32::from(bounds.size.height) / view_h);
289    let origin = gpui::point(
290        bounds.origin.x + (bounds.size.width - px(view_w * scale)) / 2.0,
291        bounds.origin.y + (bounds.size.height - px(view_h * scale)) / 2.0,
292    );
293    let map = |point: (f32, f32)| {
294        gpui::point(
295            origin.x + px(point.0 * scale),
296            origin.y + px(point.1 * scale),
297        )
298    };
299
300    let [start, elbow, end] = CHECK_POLYLINE;
301    let first = segment_length(start, elbow);
302    let total = first + segment_length(elbow, end);
303    // The CSS slide uncovers `CHECK_DASH_UNITS` of arc length, overshooting
304    // the polyline; the drawn state simply sits at the tip.
305    let reveal = (progress * CHECK_DASH_UNITS).min(total);
306    let past_elbow = reveal > first;
307    let tip = if past_elbow {
308        lerp_point(elbow, end, (reveal - first) / segment_length(elbow, end))
309    } else {
310        lerp_point(start, elbow, reveal / first)
311    };
312
313    // `stroke-linejoin="round"` and `stroke-linecap="round"`: gpui's stroke
314    // builder miters and butt-caps with no way to change either, and a disc
315    // painted over a miter leaves the spike showing past it. The segments are
316    // therefore stroked disconnected and the elbow gets its own disc once the
317    // reveal passes it — the same completion `ProgressCircle` paints for its
318    // arc ends.
319    let stroke_w = px(CHECK_STROKE * scale);
320    stroke_segment(
321        map(start),
322        if past_elbow { map(elbow) } else { map(tip) },
323        stroke_w,
324        color,
325        window,
326    );
327    if past_elbow {
328        stroke_segment(map(elbow), map(tip), stroke_w, color, window);
329    }
330    let cap_radius = stroke_w / 2.0;
331    let caps = [
332        Some(map(start)),
333        past_elbow.then(|| map(elbow)),
334        Some(map(tip)),
335    ];
336    for center in caps.into_iter().flatten() {
337        crate::util::paint_disc(center, cap_radius, color, window);
338    }
339}
340
341fn segment_length(a: (f32, f32), b: (f32, f32)) -> f32 {
342    ((b.0 - a.0) * (b.0 - a.0) + (b.1 - a.1) * (b.1 - a.1)).sqrt()
343}
344
345fn lerp_point(a: (f32, f32), b: (f32, f32), t: f32) -> (f32, f32) {
346    (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
347}
348
349/// HeroGPUI-only compact size for a [`Checkbox`].
350///
351/// | Metric (pixels) | Sm | Md (pinned default) |
352/// | --- | --- | --- |
353/// | Control / indicator / check canvas | 14 / 10 / 8 | 16 / 12 / 10 |
354/// | Label text / line height | 12 / 16 | 14 / 20 |
355/// | Control-to-label gap / supporting-text indent | 12 / 26 | 12 / 28 |
356/// | Indeterminate dash width / height | 10 / 2 | 12 / 2 |
357///
358/// Both steps have zero row padding and no extra minimum hitbox: the clickable
359/// row contains the control and caller content, growing for taller content.
360/// Supporting text remains 12/16 with a 4px vertical gap. The control keeps
361/// `mark_radius` unless `radius` overrides it; `is_round` uses half the control
362/// width. Indicator fill motion and press behavior are unchanged. A Checkbox
363/// has no orientation setting; CheckboxGroup owns its option layout.
364///
365/// v3.2.4 removed the field `size` prop (the control is `size-4` through
366/// Tailwind), so this is additive: `Md` is byte-identical to the pinned
367/// default and `Sm` is HeroGPUI's own 14px step. Not a v3 prop.
368#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
369pub enum CheckboxSize {
370    /// The small step (14px control).
371    Sm,
372    /// The medium step; the pinned default.
373    #[default]
374    Md,
375}
376
377impl CheckboxSize {
378    /// Every size, in declaration order.
379    pub const ALL: [CheckboxSize; 2] = [Self::Sm, Self::Md];
380
381    /// `(control, indicator, label text)` for this step.
382    fn metrics(self) -> (Pixels, Pixels, Pixels) {
383        match self {
384            Self::Sm => (px(14.), px(10.), px(12.)),
385            Self::Md => (px(16.), px(12.), px(14.)),
386        }
387    }
388
389    /// The size's display name.
390    pub fn label(self) -> &'static str {
391        match self {
392            Self::Sm => "Small",
393            Self::Md => "Medium",
394        }
395    }
396}
397
398/// HeroUI Checkbox.
399#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
400#[derive(IntoElement)]
401pub struct Checkbox {
402    /// `value` — what this control submits when checked. HTML's default is
403    /// `"on"`.
404    value: Option<gpui::SharedString>,
405    /// `validationBehavior` — carried on this control's form field.
406    validation_behavior: crate::form::ValidationBehavior,
407    /// `name` — the name this control submits under; read back by
408    /// [`Self::form_field`].
409    name: Option<gpui::SharedString>,
410    id: gpui::ElementId,
411    /// `isSelected` — `None` leaves the component holding the state, seeded
412    /// from `defaultSelected`.
413    checked: Option<bool>,
414    default_checked: bool,
415    is_indeterminate: bool,
416    is_disabled: bool,
417    is_read_only: bool,
418    is_required: bool,
419    /// `validate` — run by the component, not the caller.
420    validate: Option<crate::validation::Validator<bool>>,
421    /// `validationErrors` — messages from a server round-trip.
422    validation_errors: Vec<gpui::SharedString>,
423    is_invalid: bool,
424    variant: herogpui_core::FieldVariant,
425    /// `Checkbox.Indicator` children — v3 swaps the glyph per field state,
426    /// which is its "Custom Indicator" example.
427    indicator: Option<Box<dyn Fn(CheckboxState) -> AnyElement + 'static>>,
428    /// Checkbox root children render function, handed the live field state.
429    content: Option<Box<dyn Fn(CheckboxState) -> AnyElement + 'static>>,
430    /// A round control instead of `rounded-md`. v3's "Full Rounded" example
431    /// does it with `className="rounded-full"` on `Checkbox.Control`.
432    is_round: bool,
433    /// The control fill while hovered, in place of `--accent-hover`.
434    hover_bg: Option<gpui::Hsla>,
435    /// The control's corner radius, in place of the owning `mark_radius`
436    /// helper. `is_round` still forces the circle.
437    radius: Option<Pixels>,
438    /// The compact step; `Md` is the pinned default.
439    size: CheckboxSize,
440    /// The label's font size, in place of the size step's.
441    text_size: Option<Pixels>,
442    description: Option<gpui::SharedString>,
443    /// The plain text of the label, when the caller had one.
444    ///
445    /// `label` takes an arbitrary element, and a gpui text child carries no
446    /// element id, so it contributes no accessibility node and no accessible
447    /// name. A [`CheckboxGroup`] composes its option labels into elements but
448    /// still knows the string, and passes it here so the box is named.
449    label_text: Option<gpui::SharedString>,
450    error_message: Option<gpui::SharedString>,
451    children: Vec<AnyElement>,
452    on_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
453    form_state: Rc<RefCell<crate::form::LiveFormFieldState>>,
454    form_focus_target: Option<Rc<RefCell<crate::form::LiveFormFieldState>>>,
455    /// The `sx` slot, refined over the root style at the end of render.
456    sx: Option<Box<gpui::StyleRefinement>>,
457}
458
459impl Checkbox {
460    /// `isReadOnly` — shows the value but refuses changes.
461    /// `validate` — returns the message to show, or `None` when the state is fine.
462    ///
463    /// The component runs it and surfaces the result.
464    pub fn validate(mut self, f: impl Fn(&bool) -> Option<gpui::SharedString> + 'static) -> Self {
465        self.validate = Some(std::sync::Arc::new(f));
466        self
467    }
468
469    /// `validationErrors` — messages produced elsewhere, shown ahead of
470    /// whatever `validate` returns.
471    pub fn validation_errors(
472        mut self,
473        errors: impl IntoIterator<Item = impl Into<gpui::SharedString>>,
474    ) -> Self {
475        self.validation_errors = errors.into_iter().map(Into::into).collect();
476        self
477    }
478
479    /// `isRequired` — marks the label as required.
480    pub fn is_required(mut self, v: bool) -> Self {
481        self.is_required = v;
482        self
483    }
484
485    /// `isInvalid` — draws the control in the danger role.
486    pub fn is_invalid(mut self, v: bool) -> Self {
487        self.is_invalid = v;
488        self
489    }
490
491    /// `Checkbox.Indicator` — draws the mark yourself from the field state.
492    pub fn indicator(mut self, render: impl Fn(CheckboxState) -> AnyElement + 'static) -> Self {
493        self.indicator = Some(Box::new(render));
494        self
495    }
496
497    /// Checkbox root children render function, handed the live field state.
498    /// Replaces labels and extended static children when set.
499    pub fn content(mut self, render: impl Fn(CheckboxState) -> AnyElement + 'static) -> Self {
500        self.content = Some(Box::new(render));
501        self
502    }
503
504    /// A fully round control, which v3's "Full Rounded" example asks for with
505    /// `rounded-full` on `Checkbox.Control`.
506    pub fn is_round(mut self, v: bool) -> Self {
507        self.is_round = v;
508        self
509    }
510
511    /// The control fill while hovered, in place of `--accent-hover`. The
512    /// scale/fade Tween and the pressed target are unchanged.
513    pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
514        self.hover_bg = Some(color.into());
515        self
516    }
517
518    /// The control's corner radius, in place of the owning `mark_radius`
519    /// helper. It replaces the helper's value only: `is_round` keeps its
520    /// documented circle either way, and the control's animated fill layers
521    /// follow the resolved value so they stay inside the corners. Not a v3
522    /// prop; the removed v2 `radius` prop is prohibited and this is a
523    /// per-component repository extension.
524    pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
525        self.radius = Some(radius.into());
526        self
527    }
528
529    /// Sets the compact step. `Md` is the default and byte-identical to the
530    /// pinned control; `Sm` is a 14px control with a 10px indicator and 12px
531    /// label text (16px leading). Not a v3 prop.
532    pub fn size(mut self, size: CheckboxSize) -> Self {
533        self.size = size;
534        self
535    }
536
537    /// The label font size, in place of the size step's. A 12/14/16px size
538    /// takes v3's leading pair (16/20/24); any other keeps the 20px leading.
539    /// The control box, its mark and the description 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 `Checkbox.Content`.
540    pub fn text_size(mut self, size: impl Into<Pixels>) -> Self {
541        self.text_size = Some(size.into());
542        self
543    }
544
545    /// `variant` — `Secondary` drops the shadow for use on a surface.
546    pub fn variant(mut self, variant: herogpui_core::FieldVariant) -> Self {
547        self.variant = variant;
548        self
549    }
550
551    /// The one slot for caller-owned low-level styling: GPUI's styling methods
552    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
553    /// applied to the checkbox's root element after every value the variant and
554    /// the active theme chose, so they win.
555    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
556        crate::util::refine_sx(&mut self.sx, style);
557        self
558    }
559
560    /// Sets whether the checkbox is read-only.
561    pub fn is_read_only(mut self, v: bool) -> Self {
562        self.is_read_only = v;
563        self
564    }
565
566    /// Creates an unchecked checkbox with the given element id.
567    pub fn new(id: impl Into<gpui::ElementId>) -> Self {
568        Self {
569            value: None,
570            validation_behavior: crate::form::ValidationBehavior::Native,
571            name: None,
572            id: id.into(),
573            checked: None,
574            default_checked: false,
575            is_indeterminate: false,
576            is_disabled: false,
577            is_read_only: false,
578            is_required: false,
579            validate: None,
580            validation_errors: Vec::new(),
581            is_invalid: false,
582            variant: herogpui_core::FieldVariant::Primary,
583            indicator: None,
584            content: None,
585            is_round: false,
586            hover_bg: None,
587            radius: None,
588            size: CheckboxSize::default(),
589            text_size: None,
590            description: None,
591            label_text: None,
592            error_message: None,
593            children: Vec::new(),
594            on_change: None,
595            form_state: Rc::new(RefCell::new(crate::form::LiveFormFieldState {
596                value: crate::form::FormValue::Flag(false),
597                is_invalid: false,
598                is_successful: true,
599                focus: None,
600                restore: None,
601            })),
602            form_focus_target: None,
603            sx: None,
604        }
605    }
606
607    /// `value` — what this control submits when checked.
608    ///
609    /// An HTML checkbox submits `"on"` unless told otherwise; this is that
610    /// override, and it is read by [`Self::form_field`].
611    pub fn value(mut self, value: impl Into<gpui::SharedString>) -> Self {
612        self.value = Some(value.into());
613        self
614    }
615
616    /// `validationBehavior` — `Allow` shows the message without blocking form
617    /// submission. Carried on the [`Self::form_field`] this control produces.
618    pub fn validation_behavior(mut self, behavior: crate::form::ValidationBehavior) -> Self {
619        self.validation_behavior = behavior;
620        self
621    }
622
623    /// `name` — the name this control submits under.
624    pub fn name(mut self, name: impl Into<gpui::SharedString>) -> Self {
625        self.name = Some(name.into());
626        self
627    }
628
629    /// The `Form` field this control submits, when it has a `name`.
630    ///
631    /// v3 discovers a field through the DOM; gpui gives a child no way to reach
632    /// its ancestor, so the control hands the pair over instead. Borrows, so the
633    /// control is still yours to place:
634    ///
635    /// ```
636    /// # use gpui::{prelude::*, Window};
637    /// # use herogpui_components::{Checkbox, Form};
638    /// # struct Demo;
639    /// # impl Render for Demo {
640    /// #     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
641    /// #         let form = Form::new();
642    /// #         let control = Checkbox::new("terms").name("terms");
643    /// let field = control.form_field();
644    /// form.field(field.unwrap()).child(control)
645    /// #     }
646    /// # }
647    /// # let mut tcx = gpui::TestAppContext::single();
648    /// # tcx.update(herogpui_theme::ThemeProvider::init);
649    /// # let _ = tcx.add_window_view(|_, _| Demo);
650    /// ```
651    pub fn form_field(&self) -> Option<crate::form::FormField> {
652        let name = self.name.clone()?;
653        let checked = self.checked.unwrap_or(self.default_checked);
654        let validity = crate::validation::resolve(
655            self.is_invalid,
656            &self.validation_errors,
657            self.validate.as_ref().and_then(|f| f(&checked)),
658            self.error_message.clone(),
659        );
660        {
661            let mut state = self.form_state.borrow_mut();
662            state.value = match (&self.value, checked) {
663                (Some(value), true) => crate::form::FormValue::Text(value.clone()),
664                _ => crate::form::FormValue::Flag(checked),
665            };
666            state.is_invalid = validity.is_invalid;
667            state.is_successful = !self.is_disabled;
668        }
669        Some(
670            crate::form::FormField::live(name, self.form_state.clone())
671                .is_required(self.is_required)
672                .validation_behavior(self.validation_behavior),
673        )
674    }
675
676    /// `isSelected` — the controlled state; `None` leaves the component
677    /// holding it, seeded from `defaultSelected`.
678    pub fn is_selected(mut self, v: bool) -> Self {
679        self.checked = Some(v);
680        self
681    }
682
683    /// `defaultSelected` — the uncontrolled initial state.
684    ///
685    /// Only consulted when `checked` is not supplied; the component then owns
686    /// the state and toggles itself on click.
687    pub fn default_selected(mut self, v: bool) -> Self {
688        self.default_checked = v;
689        self
690    }
691
692    /// Shows a dash instead of the check (`isIndeterminate`).
693    pub fn is_indeterminate(mut self, v: bool) -> Self {
694        self.is_indeterminate = v;
695        self
696    }
697
698    /// Sets whether the checkbox is disabled.
699    pub fn is_disabled(mut self, v: bool) -> Self {
700        self.is_disabled = v;
701        self
702    }
703
704    /// Label content.
705    pub fn label(mut self, el: impl IntoElement) -> Self {
706        self.children.push(el.into_any_element());
707        self
708    }
709
710    /// `Description` — help text below and aligned with the label.
711    pub fn description(mut self, text: impl Into<gpui::SharedString>) -> Self {
712        self.description = Some(text.into());
713        self
714    }
715
716    /// The label's plain text, for the accessibility node. See the
717    /// `label_text` field for why an element-typed label is not enough.
718    fn a11y_label(mut self, text: impl Into<gpui::SharedString>) -> Self {
719        self.label_text = Some(text.into());
720        self
721    }
722
723    /// `FieldError` — fallback validation text below and aligned with the label.
724    pub fn error_message(mut self, text: impl Into<gpui::SharedString>) -> Self {
725        self.error_message = Some(text.into());
726        self
727    }
728
729    /// Sets the handler called with the new selected state when the checkbox is toggled.
730    pub fn on_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
731        self.on_change = Some(std::sync::Arc::new(f));
732        self
733    }
734
735    fn form_focus_target(mut self, state: Rc<RefCell<crate::form::LiveFormFieldState>>) -> Self {
736        self.form_focus_target = Some(state);
737        self
738    }
739}
740
741impl ParentElement for Checkbox {
742    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
743        self.children.extend(elements);
744    }
745}
746
747impl RenderOnce for Checkbox {
748    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
749        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
750        let (checked, own) = crate::util::controlled(
751            window,
752            cx,
753            element_id::scoped(&self.id, "checked"),
754            self.checked,
755            self.default_checked,
756        );
757        let reset_own = own.clone();
758        let reset_state = Rc::downgrade(&self.form_state);
759        let reset_value = self.value.clone();
760        let reset_change = self
761            .checked
762            .is_some()
763            .then(|| self.on_change.clone())
764            .flatten();
765        self.form_state.borrow_mut().restore = (reset_own.is_some() || reset_change.is_some())
766            .then(|| {
767                let default_checked = self.default_checked;
768                let reset_state = reset_state.clone();
769                crate::util::shared(move |window: &mut Window, cx: &mut App| {
770                    if let Some(state) = reset_state.upgrade() {
771                        state.borrow_mut().value = match (&reset_value, default_checked) {
772                            (Some(value), true) => crate::form::FormValue::Text(value.clone()),
773                            _ => crate::form::FormValue::Flag(default_checked),
774                        };
775                    }
776                    if let Some(held) = &reset_own {
777                        held.update(cx, |checked, cx| {
778                            *checked = default_checked;
779                            cx.notify();
780                        });
781                    }
782                    if let Some(on_change) = &reset_change {
783                        on_change(&default_checked, window, cx);
784                    }
785                }) as std::sync::Arc<dyn Fn(&mut Window, &mut App)>
786            });
787
788        // v3 order: the controlled flag, then server errors, then `validate`.
789        let validity = crate::validation::resolve(
790            self.is_invalid,
791            &self.validation_errors,
792            self.validate.as_ref().and_then(|f| f(&checked)),
793            self.error_message.clone(),
794        );
795        {
796            let mut state = self.form_state.borrow_mut();
797            state.value = match (&self.value, checked) {
798                (Some(value), true) => crate::form::FormValue::Text(value.clone()),
799                _ => crate::form::FormValue::Flag(checked),
800            };
801            state.is_invalid = validity.is_invalid;
802            state.is_successful = !self.is_disabled;
803        }
804
805        // v3 focuses the checkbox and rings `.checkbox__control`, so the two sit
806        // on different elements: the row takes the focus, the box shows it.
807        // `use_keyed_state` takes `cx` mutably, so it precedes the theme.
808        let focus_handle =
809            crate::util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx);
810        self.form_state.borrow_mut().focus = Some(focus_handle.clone());
811        if let Some(target) = &self.form_focus_target {
812            target.borrow_mut().focus = Some(focus_handle.clone());
813        }
814        // The role's colours, copied out so the keyed-state calls below can
815        // take `cx` mutably. `isInvalid` outranks the colour role, as it does
816        // on every field: the danger role is chosen here and nowhere else.
817        let (accent_color, accent_hover, accent_foreground) = {
818            let sem = if validity.is_invalid {
819                cx.role(Color::Danger)
820            } else {
821                cx.role(Color::Accent)
822            };
823            (sem.color, sem.hover(), sem.foreground)
824        };
825
826        // Two marks, two targets: the CSS fill lights for any checked box —
827        // `data-selected` has no indeterminate escape — while the dash
828        // replaces the checkmark outright, so only a plain tick draws itself.
829        let fill_visible = checked;
830        let check_visible = checked && !self.is_indeterminate;
831
832        // The fill's hover colour and the indeterminate press read the same
833        // one-frame-late hover/press slot the switch track reads. A disabled
834        // box does not track the pointer, and a slot gone stale — disabled
835        // under the pointer or a held button — reads off, which is itself
836        // visible as the ease back off the hover accent. Read-only still
837        // hovers; only `is_disabled` guards.
838        let interaction =
839            crate::util::interaction(element_id::scoped(&self.id, "interaction"), window, cx);
840        let (is_hovered, is_pressed) = if self.is_disabled {
841            (false, false)
842        } else {
843            *interaction.read(cx)
844        };
845
846        // `.checkbox__control` is `size-4`, `.checkbox__indicator` `size-3`
847        // around a `size-2.5` checkmark, and `.checkbox__content` `text-sm`.
848        // The `Sm` step scales all three together; `Md` is pinned.
849        let (box_px, icon_px, text) = self.size.metrics();
850        let text = self.text_size.unwrap_or(text);
851        let control_radius = if self.is_round {
852            // `rounded-full` on the control: the fill matches it, the way the
853            // control's `overflow-hidden` clips the pseudo-element upstream.
854            box_px / 2.0
855        } else {
856            // An instance radius replaces the helper's value here and only
857            // here: `is_round` above keeps its documented circle either way.
858            self.radius.unwrap_or_else(|| crate::util::mark_radius(cx))
859        };
860
861        // Tween targets, read out before the keyed-state calls take `cx`
862        // mutably. Selected paints the accent on the `::before` fill over the
863        // control's resting background; indeterminate moves the control's own
864        // `bg-accent` — pressed, its `bg-accent-hover` — with the fill still
865        // mounted underneath.
866        let control_bg_target = if self.is_indeterminate {
867            if is_pressed {
868                accent_hover
869            } else {
870                accent_color
871            }
872        } else {
873            match self.variant {
874                herogpui_core::FieldVariant::Primary => cx.colors().field.background,
875                herogpui_core::FieldVariant::Secondary => cx.colors().default.color,
876            }
877        };
878        let fill_hover = self.hover_bg.unwrap_or(accent_hover);
879        let fill_bg_target = if is_hovered { fill_hover } else { accent_color };
880
881        // The motion slots; every `use_keyed_state` here needs `cx` mutably.
882        let reduce_motion = ActiveTheme::reduce_motion(cx);
883        let mut fill_opacity =
884            Tween::keyed(&self.id, "fill-fade", f32::from(fill_visible), window, cx);
885        let mut fill_scale = Tween::keyed(
886            &self.id,
887            "fill-scale",
888            if fill_visible { 1.0 } else { FILL_REST_SCALE },
889            window,
890            cx,
891        );
892        let mut check_stroke = Tween::keyed(
893            &self.id,
894            "check-motion",
895            f32::from(check_visible),
896            window,
897            cx,
898        );
899        fill_opacity.snap_if_reduced(reduce_motion);
900        fill_scale.snap_if_reduced(reduce_motion);
901        check_stroke.snap_if_reduced(reduce_motion);
902        let mut fill_background = Tween::keyed(&self.id, "fill-bg", fill_bg_target, window, cx);
903        fill_background.snap_if_reduced(reduce_motion);
904        let control_background = easing_bg_layer(
905            &self.id,
906            "control-bg",
907            control_bg_target,
908            control_radius,
909            window,
910            cx,
911        );
912
913        let checkbox_state = CheckboxState {
914            is_selected: checked,
915            is_indeterminate: self.is_indeterminate,
916            is_disabled: self.is_disabled,
917            is_read_only: self.is_read_only,
918            is_invalid: validity.is_invalid,
919            is_required: self.is_required,
920        };
921
922        // Stateful because the hover/press tracking arms its listeners on it:
923        // the fill's `bg-accent-hover` on hover and the control's
924        // `bg-accent-hover` while indeterminate and pressed both read the slot
925        // above. The id derives from the row's, the same way the checked and
926        // focus slots derive theirs, so nothing collides.
927        let mut boxel = gpui::div()
928            .id(element_id::scoped(&self.id, "control"))
929            .flex()
930            .items_center()
931            .justify_center()
932            .size(box_px)
933            .rounded(control_radius)
934            // `.checkbox__control` is `overflow-hidden`, clipping both layers
935            // below to the control's corners. The clip is not spelled here:
936            // vanilla gpui clips to the box's *rectangle*, so both layers
937            // carry `control_radius` themselves anyway (the resting fill
938            // through `easing_bg_layer`, the checked fill through
939            // `fill_layer`, which is additionally inset by its scale), and the
940            // mark is centred well inside. Dropping it is what lets the focus
941            // ring be the overlay below, which hangs outside the box.
942            .flex_shrink_0()
943            // The control itself keeps its resting background in every state:
944            // the accent a selected or indeterminate box paints rides the
945            // listener-free layers below, which is what makes the swap a
946            // `background-color` transition the eye can follow.
947            .bg(match self.variant {
948                herogpui_core::FieldVariant::Primary => cx.colors().field.background,
949                herogpui_core::FieldVariant::Secondary => cx.colors().default.color,
950            });
951
952        // `Primary` carries the field shadow; `Secondary` is the flat variant
953        // meant for use on a surface. Held as a list rather than applied,
954        // because the focus ring is applied to the same slot and `shadow()`
955        // replaces: a focused checkbox would otherwise lose its shadow.
956        let box_shadow: Vec<gpui::BoxShadow> =
957            if self.variant == herogpui_core::FieldVariant::Primary {
958                cx.layout().field_shadow.clone()
959            } else {
960                Vec::new()
961            };
962
963        // `status-invalid-field` draws a 1px danger outline over the fill, and
964        // v3 applies it only while the box is neither selected nor
965        // indeterminate.
966        if validity.is_invalid && !fill_visible && !self.is_indeterminate {
967            boxel = boxel.border_1().border_color(cx.colors().danger.color);
968        }
969
970        // The hover/press listeners feeding the slot above. A disabled box
971        // shows its state but does not react, so it does not track.
972        if !self.is_disabled {
973            boxel = crate::util::track_interaction(boxel, &interaction);
974        }
975
976        // The two animated backgrounds, then the mark: all listener-free, so
977        // the ids their animations change never touch the interactive element.
978        boxel = boxel.child(control_background);
979        boxel = boxel.child(fill_layer(
980            &self.id,
981            fill_opacity,
982            fill_scale,
983            reduce_motion,
984            control_radius,
985            box_px,
986            fill_background,
987        ));
988
989        // A caller-drawn indicator replaces both marks, the way
990        // `Checkbox.Indicator`'s render prop does.
991        if let Some(render) = &self.indicator {
992            boxel = boxel.child(render(checkbox_state));
993        } else if self.is_indeterminate {
994            boxel = boxel.child(
995                gpui::div()
996                    .w(icon_px)
997                    .h(px(2.))
998                    .rounded_full()
999                    .bg(accent_foreground),
1000            );
1001        } else {
1002            // The checkmark: a canvas stroke of the pinned upstream polyline,
1003            // revealed from its start point exactly as the CSS
1004            // `stroke-dashoffset` slide reveals it. The svg asset this
1005            // replaces could not animate a stroke — and draws nothing at all
1006            // where no asset source is installed, as in the tests. The CSS
1007            // marks the svg `size-2.5` inside the `size-3` indicator, a 2px
1008            // inset the canvas keeps at both size steps: 10px centred in 12px
1009            // for `Md`, 8px in 10px for `Sm`.
1010            boxel = boxel.child(
1011                gpui::div()
1012                    .size(icon_px)
1013                    .flex()
1014                    .items_center()
1015                    .justify_center()
1016                    .child(check_layer(
1017                        &self.id,
1018                        check_stroke,
1019                        reduce_motion,
1020                        icon_px - px(2.),
1021                        accent_foreground,
1022                    )),
1023            );
1024        }
1025
1026        let boxel = crate::util::with_focus_ring_overlay(
1027            boxel,
1028            !self.is_disabled && focus_handle.is_focused(window) && crate::util::focus_visible(cx),
1029            true,
1030            control_radius,
1031            box_shadow,
1032            cx,
1033        );
1034
1035        let children = self
1036            .content
1037            .map_or(self.children, |render| vec![render(checkbox_state)]);
1038        // `useCheckbox` renders a native `<input type="checkbox">`, whose role
1039        // is `checkbox`, and sets the DOM `indeterminate` property — which is
1040        // what makes a checkbox report `aria-checked="mixed"`.
1041        let name = a11y::Name::field(
1042            self.label_text.as_ref(),
1043            self.description.as_ref(),
1044            &validity,
1045        );
1046        let row = gpui::div()
1047            .id(self.id.clone())
1048            .a11y_named(a11y::Role::CheckBox, &name)
1049            .a11y_checked(checked, self.is_indeterminate)
1050            .when(!self.is_disabled, |el| el.track_focus(&focus_handle))
1051            .flex()
1052            .items_center()
1053            // `.checkbox__content` is `gap-3`.
1054            .gap(px(12.))
1055            .when(!self.is_disabled && !self.is_read_only, |r| {
1056                r.cursor(crate::util::interactive_cursor(cx))
1057            })
1058            .children(
1059                std::iter::once(boxel.into_any_element())
1060                    .chain(children)
1061                    .chain(self.is_required.then(|| {
1062                        gpui::div()
1063                            .text_color(cx.colors().danger.color)
1064                            .child("*")
1065                            .into_any_element()
1066                    })),
1067            )
1068            .text_size(text)
1069            .line_height(crate::util::leading_for(text).unwrap_or(px(20.)))
1070            .font_weight(gpui::FontWeight::MEDIUM)
1071            .text_color(cx.colors().foreground);
1072
1073        let row = if self.is_disabled {
1074            row
1075        } else {
1076            crate::util::record_focus_bounds(row, &focus_handle, window, cx)
1077        };
1078        let content = if !self.is_disabled
1079            && !self.is_read_only
1080            && (self.on_change.is_some() || own.is_some())
1081        {
1082            let on_change = self.on_change;
1083            row.on_click(move |event, window, cx| {
1084                if matches!(
1085                    event,
1086                    gpui::ClickEvent::Keyboard(event)
1087                        if event.button == gpui::KeyboardButton::Enter
1088                ) {
1089                    return;
1090                }
1091                // Uncontrolled: flip our own copy, or nothing could ever
1092                // change it.
1093                if let Some(held) = &own {
1094                    held.update(cx, |v, cx| {
1095                        *v = !checked;
1096                        cx.notify();
1097                    });
1098                }
1099                if let Some(cb) = &on_change {
1100                    cb(&!checked, window, cx);
1101                }
1102            })
1103            .into_any_element()
1104        } else {
1105            row.into_any_element()
1106        };
1107
1108        let message = validity.first();
1109        let mut root = gpui::div()
1110            .flex()
1111            .flex_col()
1112            .items_start()
1113            .gap(px(4.))
1114            .when(self.is_disabled, |r| {
1115                r.opacity(cx.layout().disabled_opacity)
1116            })
1117            .child(content);
1118        if let Some(message) = message {
1119            root = root.child(
1120                gpui::div()
1121                    .w_full()
1122                    .pl(box_px + px(12.))
1123                    .child(crate::field::ErrorMessage::new(message)),
1124            );
1125        } else if let Some(description) = self.description {
1126            root = root.child(
1127                gpui::div()
1128                    .w_full()
1129                    .pl(box_px + px(12.))
1130                    .child(crate::field::Description::new(description)),
1131            );
1132        }
1133        root = crate::util::apply_sx(root, &self.sx);
1134        root.into_any_element()
1135    }
1136}
1137
1138// ---------------------------------------------------------------------------
1139// CheckboxGroup
1140// ---------------------------------------------------------------------------
1141
1142/// One option in a [`CheckboxGroup`].
1143#[must_use = "builder methods return a new value; pass the option to its component"]
1144#[derive(Clone)]
1145pub struct CheckboxOption {
1146    key: gpui::SharedString,
1147    label: gpui::SharedString,
1148    description: Option<gpui::SharedString>,
1149    is_disabled: bool,
1150}
1151
1152impl CheckboxOption {
1153    /// Creates an option with the given key and label.
1154    pub fn new(key: impl Into<gpui::SharedString>, label: impl Into<gpui::SharedString>) -> Self {
1155        Self {
1156            key: key.into(),
1157            label: label.into(),
1158            description: None,
1159            is_disabled: false,
1160        }
1161    }
1162
1163    /// Sets the description text shown below the label.
1164    pub fn description(mut self, text: impl Into<gpui::SharedString>) -> Self {
1165        self.description = Some(text.into());
1166        self
1167    }
1168
1169    /// Sets whether the option is disabled.
1170    pub fn is_disabled(mut self, v: bool) -> Self {
1171        self.is_disabled = v;
1172        self
1173    }
1174
1175    /// The option's key.
1176    pub fn key(&self) -> &gpui::SharedString {
1177        &self.key
1178    }
1179}
1180
1181type OnGroupChange =
1182    std::sync::Arc<dyn Fn(&std::collections::HashSet<gpui::SharedString>, &mut Window, &mut App)>;
1183
1184/// CheckboxGroup — port of `@heroui/checkbox-group` (v3).
1185///
1186/// A set of checkboxes sharing a label, orientation, validation state and
1187/// selected-value set.
1188#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
1189#[derive(IntoElement)]
1190pub struct CheckboxGroup {
1191    /// `name` — the name this control submits under; read back by
1192    /// [`Self::form_field`].
1193    name: Option<gpui::SharedString>,
1194    id: gpui::ElementId,
1195    options: Vec<CheckboxOption>,
1196    label: Option<gpui::SharedString>,
1197    description: Option<gpui::SharedString>,
1198    error_message: Option<gpui::SharedString>,
1199    value: Option<std::collections::HashSet<gpui::SharedString>>,
1200    default_value: std::collections::HashSet<gpui::SharedString>,
1201    orientation: herogpui_core::Orientation,
1202    variant: herogpui_core::FieldVariant,
1203    is_disabled: bool,
1204    is_read_only: bool,
1205    is_invalid: bool,
1206    is_required: bool,
1207    on_change: Option<OnGroupChange>,
1208    form_state: Rc<RefCell<crate::form::LiveFormFieldState>>,
1209    /// The `sx` slot, refined over the root style at the end of render.
1210    sx: Option<Box<gpui::StyleRefinement>>,
1211}
1212
1213impl CheckboxGroup {
1214    /// Creates a group with the given element id and options.
1215    pub fn new(id: impl Into<gpui::ElementId>, options: Vec<CheckboxOption>) -> Self {
1216        Self {
1217            name: None,
1218            id: id.into(),
1219            options,
1220            label: None,
1221            description: None,
1222            error_message: None,
1223            value: None,
1224            default_value: std::collections::HashSet::new(),
1225            orientation: herogpui_core::Orientation::Vertical,
1226            variant: herogpui_core::FieldVariant::Primary,
1227            is_disabled: false,
1228            is_read_only: false,
1229            is_invalid: false,
1230            is_required: false,
1231            on_change: None,
1232            form_state: Rc::new(RefCell::new(crate::form::LiveFormFieldState {
1233                value: crate::form::FormValue::Keys(Vec::new()),
1234                is_invalid: false,
1235                is_successful: true,
1236                focus: None,
1237                restore: None,
1238            })),
1239            sx: None,
1240        }
1241    }
1242
1243    /// `name` — the name this control submits under.
1244    pub fn name(mut self, name: impl Into<gpui::SharedString>) -> Self {
1245        self.name = Some(name.into());
1246        self
1247    }
1248
1249    /// The `Form` field this control submits, when it has a `name`.
1250    ///
1251    /// v3 discovers a field through the DOM; gpui gives a child no way to reach
1252    /// its ancestor, so the control hands the pair over instead. Borrows, so the
1253    /// control is still yours to place:
1254    ///
1255    /// ```
1256    /// # use gpui::{prelude::*, Window};
1257    /// # use herogpui_components::{CheckboxGroup, CheckboxOption, Form};
1258    /// # struct Demo;
1259    /// # impl Render for Demo {
1260    /// #     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1261    /// #         let form = Form::new();
1262    /// #         let control = CheckboxGroup::new("langs", vec![CheckboxOption::new("rust", "Rust")])
1263    /// #             .name("langs");
1264    /// let field = control.form_field();
1265    /// form.field(field.unwrap()).child(control)
1266    /// #     }
1267    /// # }
1268    /// # let mut tcx = gpui::TestAppContext::single();
1269    /// # tcx.update(herogpui_theme::ThemeProvider::init);
1270    /// # let _ = tcx.add_window_view(|_, _| Demo);
1271    /// ```
1272    pub fn form_field(&self) -> Option<crate::form::FormField> {
1273        let name = self.name.clone()?;
1274        let selected = self.value.as_ref().unwrap_or(&self.default_value);
1275        let values = self
1276            .options
1277            .iter()
1278            .filter(|option| {
1279                !self.is_disabled && !option.is_disabled && selected.contains(&option.key)
1280            })
1281            .map(|option| option.key.clone())
1282            .collect();
1283        {
1284            let mut state = self.form_state.borrow_mut();
1285            state.value = crate::form::FormValue::Keys(values);
1286            state.is_invalid = self.is_invalid || self.error_message.is_some();
1287            state.is_successful = !self.is_disabled;
1288            state.focus = None;
1289        }
1290        Some(
1291            crate::form::FormField::live(name, self.form_state.clone())
1292                .is_required(self.is_required),
1293        )
1294    }
1295
1296    /// Sets the group label.
1297    pub fn label(mut self, text: impl Into<gpui::SharedString>) -> Self {
1298        self.label = Some(text.into());
1299        self
1300    }
1301
1302    /// Sets the group description.
1303    pub fn description(mut self, text: impl Into<gpui::SharedString>) -> Self {
1304        self.description = Some(text.into());
1305        self
1306    }
1307
1308    /// Sets the error message shown when the group is invalid.
1309    pub fn error_message(mut self, text: impl Into<gpui::SharedString>) -> Self {
1310        self.error_message = Some(text.into());
1311        self
1312    }
1313
1314    /// `value` — the selected keys, controlled.
1315    pub fn value(mut self, keys: impl IntoIterator<Item = gpui::SharedString>) -> Self {
1316        self.value = Some(keys.into_iter().collect());
1317        self
1318    }
1319
1320    /// `defaultValue` — the uncontrolled initial selection.
1321    ///
1322    /// Only consulted when `value` is not supplied; the group then owns the
1323    /// selection and each checkbox toggles its own key in it.
1324    pub fn default_value(mut self, keys: impl IntoIterator<Item = gpui::SharedString>) -> Self {
1325        self.default_value = keys.into_iter().collect();
1326        self
1327    }
1328
1329    /// Sets the layout direction of the options.
1330    pub fn orientation(mut self, orientation: herogpui_core::Orientation) -> Self {
1331        self.orientation = orientation;
1332        self
1333    }
1334
1335    /// Sets the field variant.
1336    pub fn variant(mut self, variant: herogpui_core::FieldVariant) -> Self {
1337        self.variant = variant;
1338        self
1339    }
1340
1341    /// The one slot for caller-owned low-level styling: GPUI's styling methods
1342    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
1343    /// applied to the group's root element after every value the variant and the
1344    /// active theme chose, so they win.
1345    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
1346        crate::util::refine_sx(&mut self.sx, style);
1347        self
1348    }
1349
1350    /// Sets whether the group is disabled.
1351    pub fn is_disabled(mut self, v: bool) -> Self {
1352        self.is_disabled = v;
1353        self
1354    }
1355
1356    /// Sets whether the group is invalid.
1357    pub fn is_invalid(mut self, v: bool) -> Self {
1358        self.is_invalid = v;
1359        self
1360    }
1361
1362    /// `isReadOnly` — every option shows its state but cannot be toggled.
1363    pub fn is_read_only(mut self, v: bool) -> Self {
1364        self.is_read_only = v;
1365        self
1366    }
1367
1368    /// Sets whether the group is required.
1369    pub fn is_required(mut self, v: bool) -> Self {
1370        self.is_required = v;
1371        self
1372    }
1373
1374    /// Called with the complete selection after any box is toggled.
1375    pub fn on_change(
1376        mut self,
1377        handler: impl Fn(&std::collections::HashSet<gpui::SharedString>, &mut Window, &mut App)
1378            + 'static,
1379    ) -> Self {
1380        self.on_change = Some(std::sync::Arc::new(handler));
1381        self
1382    }
1383}
1384
1385impl RenderOnce for CheckboxGroup {
1386    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1387        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
1388        let (value, own) = crate::util::controlled(
1389            window,
1390            cx,
1391            element_id::scoped(&self.id, "value"),
1392            self.value.clone(),
1393            self.default_value.clone(),
1394        );
1395        let reset_own = own.clone();
1396        let reset_state = Rc::downgrade(&self.form_state);
1397        let reset_options = self.options.clone();
1398        let reset_change = self
1399            .value
1400            .is_some()
1401            .then(|| self.on_change.clone())
1402            .flatten();
1403        self.form_state.borrow_mut().restore = (reset_own.is_some() || reset_change.is_some())
1404            .then(|| {
1405                let default_value = self.default_value.clone();
1406                let reset_state = reset_state.clone();
1407                let reset_options = reset_options.clone();
1408                crate::util::shared(move |window: &mut Window, cx: &mut App| {
1409                    if let Some(state) = reset_state.upgrade() {
1410                        state.borrow_mut().value = crate::form::FormValue::Keys(
1411                            reset_options
1412                                .iter()
1413                                .filter(|option| {
1414                                    default_value.contains(&option.key) && !option.is_disabled
1415                                })
1416                                .map(|option| option.key.clone())
1417                                .collect(),
1418                        );
1419                    }
1420                    if let Some(held) = &reset_own {
1421                        held.update(cx, |value, cx| {
1422                            *value = default_value.clone();
1423                            cx.notify();
1424                        });
1425                    }
1426                    if let Some(on_change) = &reset_change {
1427                        on_change(&default_value, window, cx);
1428                    }
1429                }) as std::sync::Arc<dyn Fn(&mut Window, &mut App)>
1430            });
1431        let form_values = self
1432            .options
1433            .iter()
1434            .filter(|option| {
1435                !self.is_disabled && !option.is_disabled && value.contains(&option.key)
1436            })
1437            .map(|option| option.key.clone())
1438            .collect();
1439
1440        let colors = cx.colors();
1441        let is_invalid = self.is_invalid || self.error_message.is_some();
1442        {
1443            let mut state = self.form_state.borrow_mut();
1444            state.value = crate::form::FormValue::Keys(form_values);
1445            state.is_invalid = is_invalid;
1446            state.is_successful = !self.is_disabled;
1447            state.focus = None;
1448        }
1449        let first_enabled = self
1450            .options
1451            .iter()
1452            .position(|option| !self.is_disabled && !option.is_disabled);
1453
1454        // `useCheckboxGroup` is `role="group"`, named and described through
1455        // `useField` from the group's own label, description and messages.
1456        let group_name = a11y::Name::field(
1457            self.label.as_ref(),
1458            self.description.as_ref(),
1459            &crate::validation::resolve(self.is_invalid, &[], None, self.error_message.clone()),
1460        );
1461        let mut root = gpui::div()
1462            .id(self.id.clone())
1463            .a11y_named(a11y::Role::Group, &group_name)
1464            .flex()
1465            .flex_col()
1466            .gap(px(16.));
1467
1468        if let Some(label) = &self.label {
1469            root = root.child(
1470                crate::field::Label::new(label.clone())
1471                    .is_required(self.is_required)
1472                    .is_disabled(self.is_disabled)
1473                    .is_invalid(is_invalid),
1474            );
1475        }
1476
1477        // `.checkbox-group` gives each option `mt-4`.
1478        let mut list = gpui::div().flex().gap(px(16.));
1479        list = match self.orientation {
1480            herogpui_core::Orientation::Vertical => list.flex_col(),
1481            herogpui_core::Orientation::Horizontal => list.flex_row().flex_wrap(),
1482        };
1483
1484        for (index, option) in self.options.iter().enumerate() {
1485            let key = option.key.clone();
1486            let checked = value.contains(&key);
1487            let disabled = self.is_disabled || option.is_disabled;
1488
1489            let mut label_el = gpui::div()
1490                .flex()
1491                .flex_col()
1492                // `.checkbox` is `gap-1` between its content and description.
1493                .gap(px(4.))
1494                .child(gpui::div().child(option.label.to_string()));
1495            if let Some(description) = &option.description {
1496                label_el = label_el.child(
1497                    gpui::div()
1498                        .text_size(px(12.))
1499                        .line_height(px(16.))
1500                        .font_weight(gpui::FontWeight::NORMAL)
1501                        .text_color(colors.muted)
1502                        .child(description.to_string()),
1503                );
1504            }
1505
1506            let selection = value.clone();
1507            let on_change = self.on_change.clone();
1508            let own = own.clone();
1509            let mut checkbox = Checkbox::new(element_id::indexed(&self.id, "opt", index))
1510                .is_selected(checked)
1511                .is_disabled(disabled)
1512                .is_read_only(self.is_read_only)
1513                .is_invalid(is_invalid)
1514                .variant(self.variant)
1515                .label(label_el)
1516                .a11y_label(option.label.clone())
1517                .on_change(move |_next, window, cx| {
1518                    let mut set = selection.clone();
1519                    if !set.remove(&key) {
1520                        set.insert(key.clone());
1521                    }
1522                    // Uncontrolled: keep the new set, or ticking a box would
1523                    // do nothing.
1524                    if let Some(held) = &own {
1525                        held.update(cx, |v, cx| {
1526                            *v = set.clone();
1527                            cx.notify();
1528                        });
1529                    }
1530                    if let Some(cb) = &on_change {
1531                        cb(&set, window, cx);
1532                    }
1533                });
1534            if first_enabled == Some(index) {
1535                checkbox = checkbox.form_focus_target(self.form_state.clone());
1536            }
1537            list = list.child(checkbox);
1538        }
1539
1540        root = root.child(list);
1541
1542        let error = is_invalid.then(|| self.error_message.clone()).flatten();
1543        if let Some(error) = crate::anim::field_error_panel(&self.id, error, window, cx) {
1544            root = root.child(error);
1545        } else if let Some(description) = self.description {
1546            root = root.child(crate::field::Description::new(description));
1547        }
1548
1549        root = crate::util::apply_sx(root, &self.sx);
1550        root
1551    }
1552}
1553
1554#[cfg(test)]
1555mod size_tests {
1556    use super::*;
1557
1558    #[test]
1559    fn md_is_the_pinned_geometry_and_sm_scales_together() {
1560        assert_eq!(CheckboxSize::default(), CheckboxSize::Md);
1561        assert_eq!(CheckboxSize::Md.metrics(), (px(16.), px(12.), px(14.)));
1562        assert_eq!(CheckboxSize::Sm.metrics(), (px(14.), px(10.), px(12.)));
1563        assert_eq!(
1564            crate::util::leading_for(CheckboxSize::Sm.metrics().2),
1565            Some(px(16.))
1566        );
1567    }
1568}
1569
1570crate::util::impl_component_styled!(Checkbox, CheckboxGroup);