gpuikit/elements/form.rs
1//! Grouping and label association — and deliberately nothing else.
2//!
3//! This module is #164's answer, and its title is the whole of the scope:
4//! *grouping and label association, not form state*. There is no submission,
5//! no validation, no dirty tracking and no form-level value store here.
6//! Every one of those is a decision about the consuming application's
7//! architecture, and a toolkit that takes it is one people fight rather than
8//! use — the line Headless UI draws, and the one #164 argued for.
9//!
10//! What it does provide is two things a control genuinely cannot work out for
11//! itself:
12//!
13//! - **A group.** [`Fieldset`] draws a legend, a description and a
14//! *group-level* error — the summary that previously had nowhere to go,
15//! because [`Field`](crate::elements::field::Field)'s error is about one
16//! control — and announces itself as a named `Role::Group`.
17//! - **An ambient [`FormContext`].** A `Fieldset`'s `disabled` reaches every
18//! control inside it, and a `Field`'s label reaches the control beside it,
19//! without either being threaded through a prop by hand.
20//!
21//! # Why the context is ambient
22//!
23//! A `Field`'s child is an `AnyElement`: by the time the field has it, the
24//! control inside is opaque and no builder can reach it. Threading a prop
25//! would mean every container taking a `disabled` and every caller passing it
26//! down — which is the thing the cascade exists to remove, reintroduced one
27//! call site at a time.
28//!
29//! The mechanism is a thread-local stack, pushed around the child's layout and
30//! paint by [`WithFormContext`]. It works because gpui renders a subtree
31//! *inside* its ancestor's layout pass: `ViewElement::request_layout` calls
32//! `RenderOnce::render` and then lays out the result, and `Div` does the same
33//! for each of its children, so a whole descendant subtree is built while the
34//! ancestor's `request_layout` is on the Rust stack. The scope is opened in
35//! `prepaint` and `paint` too, since those walk the subtree again.
36//!
37//! A `gpui::Global` was considered and rejected: reading one needs `&mut App`,
38//! which puts a `cx` argument on [`disabled_here`] and takes the ambient value
39//! straight back to being threaded by hand.
40//!
41//! # Reading it from a control
42//!
43//! One line in `render`, before the element's fields are moved: bind
44//! [`disabled_here`]'s result and pass it down.
45//!
46//! `render` runs during `request_layout`, which is *inside* the scope. That is
47//! the only place a control should read it.
48//!
49//! ## The one rule about out-of-line draws
50//!
51//! **Read the ambient value in `render` and pass what you read into anything
52//! you draw out of line; never call [`disabled_here`] from inside a
53//! draw-deferring closure.** `Window::defer_draw` clones and restores gpui's
54//! *element-id* stack, not this one, so a closure that runs after the frame's
55//! layout has unwound reads an empty stack and gets `false`.
56//!
57//! This is narrower than "out-of-line draws are broken". A popover inside a
58//! disabled `Fieldset` reads `disabled_here` in its own `render` — inside the
59//! scope, correct — and captures the answer; only a read performed *within*
60//! the closure sees nothing. Hand-threading `disabled(true)` into every popup
61//! is not the fix, and would be the threading this design exists to remove.
62//!
63//! The case that genuinely cannot be handled this way is a closure that is
64//! built once and drawn against a *different* ambient value later — a panel
65//! cached across frames, or one whose content is produced by a callback the
66//! containing element stores rather than calls during layout. Such a callback
67//! has to be given the value as an argument, because there is no frame in
68//! which it and the group are on the stack together.
69
70use std::cell::RefCell;
71use std::collections::HashMap;
72use std::panic::Location;
73
74use crate::a11y::{A11y, Announce};
75use crate::layout::v_stack;
76use crate::theme::{ActiveTheme, ControlSize, Themeable};
77use crate::traits::accessible::Accessible;
78use crate::traits::control_sized::ControlSized;
79use crate::traits::disableable::Disableable;
80use gpui::{
81 AnyElement, App, Bounds, ElementId, FocusHandle, GlobalElementId, InspectorElementId,
82 InteractiveElement, IntoElement, LayoutId, ParentElement, Pixels, RenderOnce, Role,
83 SharedString, Styled, Window, div, prelude::FluentBuilder, rems,
84};
85
86/// What a group tells the controls inside it.
87///
88/// Three fields, each answering a question a control cannot answer alone:
89/// whether an enclosing group has disabled it, what an enclosing
90/// [`Field`](crate::elements::field::Field) calls it, and which focus handle
91/// that field's label clicks.
92#[derive(Clone, Default, PartialEq)]
93pub struct FormContext {
94 /// Whether an enclosing group has disabled everything inside it.
95 pub disabled: bool,
96 /// The accessible name an enclosing field publishes for the control beside
97 /// its label.
98 pub name: Option<SharedString>,
99 /// The handle a label click focuses, for the control that agrees to track
100 /// it.
101 pub focus_handle: Option<FocusHandle>,
102}
103
104impl std::fmt::Debug for FormContext {
105 /// `FocusHandle` is not `Debug`, and the useful fact about it here is
106 /// whether there is one at all.
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("FormContext")
109 .field("disabled", &self.disabled)
110 .field("name", &self.name)
111 .field("focus_handle", &self.focus_handle.is_some())
112 .finish()
113 }
114}
115
116impl FormContext {
117 /// An empty context — nothing disabled, nothing named.
118 pub fn new() -> Self {
119 Self::default()
120 }
121
122 /// Disable everything in this scope.
123 pub fn disabled(mut self, disabled: bool) -> Self {
124 self.disabled = disabled;
125 self
126 }
127
128 /// Publish an accessible name for the control in this scope.
129 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
130 self.name = Some(name.into());
131 self
132 }
133
134 /// Publish the focus handle a label click should land on.
135 pub fn focus_handle(mut self, handle: FocusHandle) -> Self {
136 self.focus_handle = Some(handle);
137 self
138 }
139
140 /// This context as it reads when nested inside `outer`.
141 ///
142 /// `disabled` is OR'd: an inner scope can disable, and cannot re-enable.
143 /// A `Fieldset` that says nothing about `disabled` inside a disabled one
144 /// is still disabled, and a `Field` cannot rescue a control from the group
145 /// around it — which is the only reading of "the group is disabled" that
146 /// is worth anything.
147 ///
148 /// `name` and `focus_handle` are *inherited when unset*: the nearest
149 /// enclosing answer wins, and a scope that has none passes the outer one
150 /// through.
151 pub fn over(mut self, outer: &FormContext) -> Self {
152 self.disabled |= outer.disabled;
153 if self.name.is_none() {
154 self.name = outer.name.clone();
155 }
156 if self.focus_handle.is_none() {
157 self.focus_handle = outer.focus_handle.clone();
158 }
159 self
160 }
161}
162
163thread_local! {
164 /// The open scopes, innermost last.
165 ///
166 /// Thread-local rather than global: gpui builds a window's element tree on
167 /// one thread, and a value that crossed threads would be a value two
168 /// windows could disagree about.
169 static SCOPES: RefCell<Vec<FormContext>> = const { RefCell::new(Vec::new()) };
170}
171
172/// An open scope, which closes when this is dropped.
173///
174/// A guard rather than a matched pair of calls: a child that panics mid-layout
175/// would otherwise leave the stack one deep for every element drawn after it,
176/// and the symptom — an unrelated control announcing itself disabled — would
177/// point nowhere near the cause.
178#[must_use = "the scope closes as soon as this guard is dropped"]
179pub struct FormScope {
180 _private: (),
181}
182
183impl Drop for FormScope {
184 fn drop(&mut self) {
185 SCOPES.with(|scopes| {
186 scopes.borrow_mut().pop();
187 });
188 }
189}
190
191/// Open a scope carrying `context`, nested inside whatever is already open.
192///
193/// Public because [`WithFormContext`] is not the only thing that could
194/// legitimately open one — a container that is a hand-written `Element`
195/// already can — but a control should never need to call it.
196pub fn push(context: FormContext) -> FormScope {
197 SCOPES.with(|scopes| {
198 let mut scopes = scopes.borrow_mut();
199 let nested = match scopes.last() {
200 Some(outer) => context.over(outer),
201 None => context,
202 };
203 scopes.push(nested);
204 });
205 FormScope { _private: () }
206}
207
208/// Run `f` inside a scope carrying `context`.
209pub fn scope<R>(context: FormContext, f: impl FnOnce() -> R) -> R {
210 let _guard = push(context);
211 f()
212}
213
214/// The innermost open [`FormContext`], if any.
215pub fn current() -> Option<FormContext> {
216 SCOPES.with(|scopes| scopes.borrow().last().cloned())
217}
218
219/// Whether a control with its own `own` disabled flag is disabled here.
220///
221/// The whole of a control's adoption cost. `own` wins where it is `true`, and
222/// the ambient value supplies the answer where it is not.
223pub fn disabled_here(own: bool) -> bool {
224 own || SCOPES.with(|scopes| scopes.borrow().last().is_some_and(|scope| scope.disabled))
225}
226
227/// The accessible name an enclosing group publishes, if any.
228///
229/// A control announces this only when it has no name of its own — its own
230/// visible text always wins, per `a11y`'s section 2.
231pub fn name_here() -> Option<SharedString> {
232 SCOPES.with(|scopes| scopes.borrow().last().and_then(|scope| scope.name.clone()))
233}
234
235/// The focus handle an enclosing label clicks, if any.
236///
237/// A control that tracks this is one whose label can be clicked to focus it.
238/// A control that does not simply leaves the click inert.
239pub fn focus_handle_here() -> Option<FocusHandle> {
240 SCOPES.with(|scopes| {
241 scopes
242 .borrow()
243 .last()
244 .and_then(|scope| scope.focus_handle.clone())
245 })
246}
247
248thread_local! {
249 /// One [`FocusHandle`] per field id, so that the handle a label publishes
250 /// in one frame is the handle a control tracked in the last one.
251 ///
252 /// `Window::use_keyed_state` — the obvious home for this — cannot be used:
253 /// it goes through `with_element_state`, which `debug_assert!`s that it is
254 /// called during prepaint or paint, and `RenderOnce::render` runs during
255 /// `request_layout`.
256 ///
257 /// **This never evicts.** Its size is the number of *distinct field ids
258 /// ever rendered on this thread*, not the number of fields on screen. For
259 /// a form whose ids are written down in the source that is a fixed, small
260 /// number. For a caller that derives a field id from a row, a record or a
261 /// task — the ordinary way an id gets made in a list-driven application —
262 /// it grows without limit, one `FocusHandle` per id, for the life of the
263 /// process.
264 ///
265 /// Evicting an entry whose only remaining reference is this map was
266 /// considered and not done: `FocusHandle` exposes no reference count, so
267 /// "nobody else holds this" is not a question that can be asked from here,
268 /// and dropping a handle a control still tracks would silently unfocus it.
269 /// The fix that is actually available is an explicit one:
270 /// [`clear_field_focus_handles`], which a long-lived list view calls when
271 /// its backing collection changes.
272 static FIELD_FOCUS_HANDLES: RefCell<HashMap<ElementId, FocusHandle>> =
273 RefCell::new(HashMap::new());
274}
275
276/// The focus handle belonging to the field with this id, minting one the first
277/// time it is asked for.
278///
279/// Stable across frames, which is the whole point: a label click focuses the
280/// same handle the control tracked when it was drawn.
281pub fn field_focus_handle(id: &ElementId, cx: &mut App) -> FocusHandle {
282 FIELD_FOCUS_HANDLES.with(|handles| {
283 let mut handles = handles.borrow_mut();
284 handles
285 .entry(id.clone())
286 .or_insert_with(|| cx.focus_handle())
287 .clone()
288 })
289}
290
291/// Drop every cached field focus handle on this thread.
292///
293/// The explicit eviction `FIELD_FOCUS_HANDLES` describes: a long-lived view
294/// that derives field ids from a changing collection (a row, a record, a task)
295/// calls this when that collection changes, so the map does not grow one handle
296/// per id ever seen for the life of the process. The next
297/// [`field_focus_handle`] for any id mints a fresh handle; call it only when no
298/// field currently on screen still needs its handle to stay put (e.g. between
299/// tearing down one list and building the next), since a control that is still
300/// tracking a dropped handle would be silently unfocused.
301pub fn clear_field_focus_handles() {
302 FIELD_FOCUS_HANDLES.with(|handles| handles.borrow_mut().clear());
303}
304
305/// Wraps one child so that everything drawn inside it sees `context`.
306///
307/// The one hand-written [`gpui::Element`] under `src/elements/`, and it is one
308/// because the scope has to be open across `request_layout`, `prepaint` and
309/// `paint` — three moments a `RenderOnce` cannot get between. It reports no id
310/// and no role, so it adds nothing to the accessibility tree and nothing to
311/// the element-id path: wrapping a subtree in it cannot change what any
312/// element inside it is called.
313pub struct WithFormContext {
314 context: FormContext,
315 child: AnyElement,
316}
317
318impl WithFormContext {
319 /// Wrap `child` so that it, and everything it draws, reads `context`.
320 pub fn new(context: FormContext, child: impl IntoElement) -> Self {
321 Self {
322 context,
323 child: child.into_any_element(),
324 }
325 }
326}
327
328impl IntoElement for WithFormContext {
329 type Element = Self;
330
331 fn into_element(self) -> Self::Element {
332 self
333 }
334}
335
336// `gpui::Element` rather than an imported `Element`: the trait's own `id(&self)`
337// would otherwise shadow `InteractiveElement::id(self, …)` on every `div()` in
338// this module.
339impl gpui::Element for WithFormContext {
340 type RequestLayoutState = ();
341 type PrepaintState = ();
342
343 fn id(&self) -> Option<ElementId> {
344 None
345 }
346
347 fn source_location(&self) -> Option<&'static Location<'static>> {
348 None
349 }
350
351 fn request_layout(
352 &mut self,
353 _id: Option<&GlobalElementId>,
354 _inspector_id: Option<&InspectorElementId>,
355 window: &mut Window,
356 cx: &mut App,
357 ) -> (LayoutId, ()) {
358 let _scope = push(self.context.clone());
359 (self.child.request_layout(window, cx), ())
360 }
361
362 fn prepaint(
363 &mut self,
364 _id: Option<&GlobalElementId>,
365 _inspector_id: Option<&InspectorElementId>,
366 _bounds: Bounds<Pixels>,
367 _request_layout: &mut (),
368 window: &mut Window,
369 cx: &mut App,
370 ) {
371 let _scope = push(self.context.clone());
372 self.child.prepaint(window, cx);
373 }
374
375 fn paint(
376 &mut self,
377 _id: Option<&GlobalElementId>,
378 _inspector_id: Option<&InspectorElementId>,
379 _bounds: Bounds<Pixels>,
380 _request_layout: &mut (),
381 _prepaint: &mut (),
382 window: &mut Window,
383 cx: &mut App,
384 ) {
385 let _scope = push(self.context.clone());
386 self.child.paint(window, cx);
387 }
388}
389
390/// A group of related controls, with a legend and a `disabled` that cascades.
391///
392/// # Example
393///
394/// ```
395/// # use gpui::{Context, IntoElement, Render, Window, div, prelude::*};
396/// use gpuikit::elements::field::field;
397/// use gpuikit::elements::form::fieldset;
398/// use gpuikit::traits::disableable::Disableable;
399/// use gpuikit::traits::labelable::Labelable;
400/// # struct D { editable: bool }
401/// # impl Render for D { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
402/// # let (street_input, city_input) = (div(), div());
403/// fieldset("billing")
404/// .legend("Billing address")
405/// .error("This address could not be verified")
406/// .disabled(!self.editable)
407/// .child(field("street").label("Street").child(street_input))
408/// .child(field("city").label("City").child(city_input))
409/// # }}
410/// # let mut tcx = gpui::TestAppContext::single();
411/// # tcx.update(gpuikit::init);
412/// # let _ = tcx.add_window_view(|_, _| D { editable: true });
413/// ```
414///
415/// The `disabled(!self.editable)` is the point: neither field, and neither
416/// control inside them, says anything about `disabled` at all.
417#[derive(IntoElement)]
418pub struct Fieldset {
419 id: ElementId,
420 legend: Option<SharedString>,
421 description: Option<SharedString>,
422 error: Option<SharedString>,
423 disabled: bool,
424 size: ControlSize,
425 children: Vec<AnyElement>,
426}
427
428impl Fieldset {
429 /// A fieldset with the given id.
430 ///
431 /// The id is required, and required to be unique among everything drawn in
432 /// a frame — see [`crate::element_id`]. It carries the group's
433 /// accessibility node.
434 pub fn new(id: impl Into<ElementId>) -> Self {
435 Self {
436 id: id.into(),
437 legend: None,
438 description: None,
439 error: None,
440 disabled: false,
441 size: ControlSize::default(),
442 children: Vec::new(),
443 }
444 }
445
446 /// The group's visible heading, which is also its accessible name.
447 pub fn legend(mut self, legend: impl Into<SharedString>) -> Self {
448 self.legend = Some(legend.into());
449 self
450 }
451
452 /// Help text for the group as a whole.
453 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
454 self.description = Some(description.into());
455 self
456 }
457
458 /// An error about the group rather than about one control in it.
459 ///
460 /// "These dates overlap" belongs to neither date field. Before this there
461 /// was nowhere to put it but one of the two, which said something untrue
462 /// about that control.
463 pub fn error(mut self, error: impl Into<SharedString>) -> Self {
464 self.error = Some(error.into());
465 self
466 }
467}
468
469/// Creates a new [`Fieldset`] with the given id.
470pub fn fieldset(id: impl Into<ElementId>) -> Fieldset {
471 Fieldset::new(id)
472}
473
474impl Disableable for Fieldset {
475 fn is_disabled(&self) -> bool {
476 self.disabled
477 }
478
479 /// Disables **everything inside the group**, through the ambient
480 /// [`FormContext`] — not only the legend and the error this element draws
481 /// itself. A control inside that reads [`disabled_here`] needs no
482 /// `disabled` of its own.
483 fn disabled(mut self, disabled: bool) -> Self {
484 self.disabled = disabled;
485 self
486 }
487}
488
489impl ControlSized for Fieldset {
490 fn control_size(mut self, size: ControlSize) -> Self {
491 self.size = size;
492 self
493 }
494}
495
496impl ParentElement for Fieldset {
497 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
498 self.children.extend(elements);
499 }
500}
501
502impl Accessible for Fieldset {
503 /// A named `Role::Group`. `Group` is read rather than operated, so it
504 /// needs neither a required name nor a focus decision — but a legend is a
505 /// name, and where there is one it is the group's.
506 fn a11y(&self) -> A11y {
507 let a11y = A11y::new(Role::Group);
508 match &self.legend {
509 Some(legend) => a11y.name(legend.clone()),
510 None => a11y,
511 }
512 }
513}
514
515impl RenderOnce for Fieldset {
516 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
517 let a11y = self.a11y();
518 let theme = cx.theme();
519 let metrics = theme.control(self.size);
520 // Its own `disabled`, over any group already around it: a fieldset
521 // inside a disabled fieldset is disabled, and dims its own legend to
522 // say so.
523 let disabled = disabled_here(self.disabled);
524
525 let legend = self.legend.map(|legend| {
526 div()
527 .text_size(metrics.text_size)
528 .font_weight(gpui::FontWeight::SEMIBOLD)
529 .text_color(if disabled {
530 theme.fg_disabled()
531 } else {
532 theme.fg()
533 })
534 .child(legend)
535 });
536
537 let description = self.description.map(|description| {
538 div()
539 .text_xs()
540 .text_color(if disabled {
541 theme.fg_disabled()
542 } else {
543 theme.fg_muted()
544 })
545 .child(description)
546 });
547
548 let error = self
549 .error
550 .map(|error| div().text_xs().text_color(theme.danger()).child(error));
551
552 // The legend, the description and the error sit *outside* the context
553 // wrapper. They are the group talking about itself, not controls in
554 // it, and an error that dimmed itself when the group was disabled
555 // would be least readable exactly when it mattered most.
556 v_stack()
557 .id(self.id)
558 .announce(a11y)
559 .gap(rems(0.5))
560 .when_some(legend, |this, legend| this.child(legend))
561 .when_some(description, |this, description| this.child(description))
562 .child(WithFormContext::new(
563 FormContext::new().disabled(disabled),
564 v_stack().gap(rems(0.75)).children(self.children),
565 ))
566 .when_some(error, |this, error| this.child(error))
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use crate::a11y::test_support::announced;
574 use crate::elements::checkbox::{Checkbox, checkbox};
575 use crate::elements::field::field;
576 use crate::traits::labelable::Labelable;
577 use gpui::{
578 AnyElement, AppContext, Bounds, Context, Modifiers, Render, TestAppContext,
579 VisualTestContext, px, size,
580 };
581
582 /// Draws whatever the test's closure builds, every frame.
583 ///
584 /// A real *view* rather than `VisualTestContext::draw`: registering a
585 /// mouse listener reads `Window::current_view`, which is only set while a
586 /// view renders. The same note `elements::button`'s and
587 /// `elements::sidebar`'s harnesses carry.
588 struct Harness {
589 build: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
590 }
591
592 impl Render for Harness {
593 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
594 (self.build)(window, cx)
595 }
596 }
597
598 fn draw(
599 cx: &mut TestAppContext,
600 build: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
601 ) -> &'static mut VisualTestContext {
602 let window = cx.open_window(size(px(600.), px(400.)), move |_window, _cx| Harness {
603 build: Box::new(build),
604 });
605 let cx = VisualTestContext::from_window(*std::ops::Deref::deref(&window), cx).into_mut();
606 cx.run_until_parked();
607 cx
608 }
609
610 fn bounds(cx: &mut VisualTestContext, selector: &'static str) -> Bounds<Pixels> {
611 cx.debug_bounds(selector)
612 .unwrap_or_else(|| panic!("`{selector}` was never laid out"))
613 }
614
615 fn click(cx: &mut VisualTestContext, selector: &'static str) {
616 let target = bounds(cx, selector).center();
617 cx.simulate_click(target, Modifiers::default());
618 cx.run_until_parked();
619 }
620
621 /// The state every read API has to survive: nothing open.
622 #[test]
623 fn an_empty_stack_reads_as_nothing() {
624 assert_eq!(current(), None);
625 assert!(!disabled_here(false));
626 assert!(disabled_here(true), "a control's own flag still wins");
627 assert_eq!(name_here(), None);
628 assert!(focus_handle_here().is_none());
629 }
630
631 #[test]
632 fn a_scope_is_visible_inside_it_and_not_after_it() {
633 scope(FormContext::new().disabled(true).name("Street"), || {
634 assert!(disabled_here(false));
635 assert_eq!(name_here().as_deref(), Some("Street"));
636 });
637
638 assert!(!disabled_here(false));
639 assert_eq!(name_here(), None);
640 }
641
642 /// The rule that makes "the group is disabled" mean anything.
643 #[test]
644 fn nesting_cannot_re_enable() {
645 scope(FormContext::new().disabled(true), || {
646 scope(FormContext::new().disabled(false), || {
647 assert!(
648 disabled_here(false),
649 "an inner scope saying nothing about disabled must not re-enable"
650 );
651 });
652 });
653 }
654
655 #[test]
656 fn a_name_is_inherited_until_something_nearer_answers() {
657 scope(FormContext::new().name("outer"), || {
658 scope(FormContext::new(), || {
659 assert_eq!(name_here().as_deref(), Some("outer"));
660 });
661 scope(FormContext::new().name("inner"), || {
662 assert_eq!(name_here().as_deref(), Some("inner"));
663 });
664 });
665 }
666
667 /// A child that panics mid-layout must not leave the stack one deep — the
668 /// failure that would make an unrelated control announce itself disabled.
669 #[test]
670 fn the_guard_closes_the_scope_through_a_panic() {
671 let panicked = std::panic::catch_unwind(|| {
672 scope(FormContext::new().disabled(true), || {
673 panic!("a child blew up mid-layout");
674 })
675 });
676
677 assert!(panicked.is_err());
678 assert_eq!(current(), None, "the guard popped the scope anyway");
679 assert!(!disabled_here(false));
680 }
681
682 #[gpui::test]
683 fn a_fieldset_announces_its_legend_as_a_group(cx: &mut TestAppContext) {
684 cx.update(crate::theme::init);
685 let cx = cx.add_empty_window();
686
687 let announced = cx.update(|window, cx| {
688 announced(fieldset("billing").legend("Billing address"), window, cx)
689 });
690
691 assert_eq!(announced.role, Some(Role::Group));
692 assert_eq!(announced.name(), Some("Billing address"));
693 assert_eq!(announced.id, Some(ElementId::Name("billing".into())));
694 }
695
696 /// `Role::Group` is read rather than operated, so a legend-less fieldset is
697 /// a nameless node rather than a `debug_assert!`.
698 #[gpui::test]
699 fn a_fieldset_without_a_legend_still_announces(cx: &mut TestAppContext) {
700 cx.update(crate::theme::init);
701 let cx = cx.add_empty_window();
702
703 let announced = cx.update(|window, cx| announced(fieldset("billing"), window, cx));
704
705 assert_eq!(announced.role, Some(Role::Group));
706 assert_eq!(announced.name(), None);
707 }
708
709 // ============================================================
710 // END TO END
711 // ============================================================
712 //
713 // Everything above proves the stack. These two draw a real window with a
714 // real `Checkbox` entity in it, because that is the only way to see the
715 // three read APIs actually land: the scope is opened by
716 // `WithFormContext` during layout and paint, and no unit test over a
717 // `Vec` can tell you whether the element tree ever entered it.
718
719 /// A checkbox inside a disabled fieldset, where **nothing between them
720 /// says `disabled`** — not the fieldset's field, not the checkbox.
721 ///
722 /// With no `aria_disabled` in gpui, the click action a node does not offer
723 /// is what tells the two apart; that is the same test `Button`'s
724 /// `a_disabled_button_offers_no_click_action` runs, one layer further out.
725 #[gpui::test]
726 fn a_group_disables_a_checkbox_that_says_nothing_about_it(cx: &mut TestAppContext) {
727 cx.update(crate::init);
728
729 let (enabled, disabled) = cx.update(|cx| {
730 (
731 cx.new(|_cx| checkbox("consent-open", false).label("Consent")),
732 cx.new(|_cx| checkbox("consent-locked", false).label("Consent")),
733 )
734 });
735 let (loose, grouped) = (enabled.clone(), disabled.clone());
736
737 let cx = draw(cx, move |_window, _cx| {
738 v_stack()
739 .child(
740 fieldset("open")
741 .legend("Open")
742 .child(field("consent-open").label("Consent").child(loose.clone())),
743 )
744 .child(
745 fieldset("locked").legend("Locked").disabled(true).child(
746 field("consent-locked")
747 .label("Consent")
748 .child(grouped.clone()),
749 ),
750 )
751 .into_any_element()
752 });
753
754 let hit = |handle: &gpui::Entity<Checkbox>, cx: &mut VisualTestContext| {
755 cx.update(|_window, cx| handle.read(cx).is_checked())
756 };
757
758 // The enabled one toggles.
759 click(cx, r#"gpuikit-checkbox-Name("consent-open")"#);
760 assert!(
761 hit(&enabled, cx),
762 "the checkbox outside the disabled group still toggles"
763 );
764
765 // The one in the disabled group does not, and it was never told.
766 click(cx, r#"gpuikit-checkbox-Name("consent-locked")"#);
767 assert!(
768 !hit(&disabled, cx),
769 "a checkbox inside a disabled fieldset must not toggle, even though neither it \
770 nor its field says `disabled`"
771 );
772 }
773
774 /// The other half of label association: a click on a `Field`'s label lands
775 /// focus on the control beside it, through the handle the field published
776 /// and the checkbox tracked.
777 #[gpui::test]
778 fn a_click_on_a_field_label_focuses_the_control_it_names(cx: &mut TestAppContext) {
779 cx.update(crate::init);
780
781 let control = cx.update(|cx| cx.new(|_cx| checkbox("consent", false).label("Consent")));
782 let drawn = control.clone();
783
784 let cx = draw(cx, move |_window, _cx| {
785 fieldset("billing")
786 .legend("Billing address")
787 .child(field("street").label("Street").child(drawn.clone()))
788 .into_any_element()
789 });
790
791 assert!(
792 cx.update(|window, cx| window.focused(cx)).is_none(),
793 "nothing is focused before the click"
794 );
795
796 click(cx, "gpuikit-field-label");
797
798 let focused = cx.update(|window, cx| window.focused(cx));
799 let expected =
800 cx.update(|_window, cx| field_focus_handle(&ElementId::Name("street".into()), cx));
801 assert_eq!(
802 focused,
803 Some(expected),
804 "the label click focused the handle the field published and the checkbox tracked"
805 );
806 assert!(
807 !control.read_with(cx, |checkbox, _| checkbox.is_checked()),
808 "focusing a control is not operating it"
809 );
810 }
811
812 /// Two calls for the same id hand back the same handle, which is what
813 /// makes a label click land on the control the last frame drew.
814 #[gpui::test]
815 fn a_field_focus_handle_is_stable_per_id(cx: &mut TestAppContext) {
816 let (first, second, other) = cx.update(|cx| {
817 let id = ElementId::Name("street".into());
818 let other = ElementId::Name("city".into());
819 (
820 field_focus_handle(&id, cx),
821 field_focus_handle(&id, cx),
822 field_focus_handle(&other, cx),
823 )
824 });
825
826 assert_eq!(first, second);
827 assert_ne!(first, other);
828 }
829
830 #[gpui::test]
831 fn clearing_field_focus_handles_mints_fresh_ones(cx: &mut TestAppContext) {
832 let id = ElementId::Name("clear_field_focus_handles_test".into());
833 let before = cx.update(|cx| field_focus_handle(&id, cx));
834 assert_eq!(
835 before,
836 cx.update(|cx| field_focus_handle(&id, cx)),
837 "the handle is stable until cleared"
838 );
839
840 clear_field_focus_handles();
841
842 let after = cx.update(|cx| field_focus_handle(&id, cx));
843 assert_ne!(
844 before, after,
845 "clearing evicts the cache, so the same id mints a new handle"
846 );
847 }
848}