Skip to main content

herogpui_components/
a11y.rs

1//! What each control tells assistive technology, stated once per control.
2//!
3//! HeroUI v3 owns almost none of this itself: every in-scope component
4//! delegates to React Aria Components 1.20.0, which delegates to React Aria
5//! 3.51.0's hooks, and the hooks are where the role and the aria attributes
6//! are decided. So the contract this module carries is *their* contract, read
7//! out of the pinned packages under `web/node_modules` — for example
8//! `react-aria/dist/private/toggle/useToggle.js` is what says a checkbox's
9//! `aria-required` appears only when the field is required, and
10//! `.../progress/useProgressBar.js` is what says an indeterminate bar drops
11//! `aria-valuenow` while keeping `aria-valuemin`/`aria-valuemax`.
12//!
13//! # Shape
14//!
15//! Three pieces, in the order a call site meets them:
16//!
17//! 1. [`Name`] — the accessible name and description. The web builds these by
18//!    *reference* (`aria-labelledby`, `aria-describedby` pointing at the
19//!    rendered `<Label>`, `<Description>` and `<FieldError>` nodes); gpui takes
20//!    literal strings, so [`Name::field`] performs the join React Aria's
21//!    `useField` performs with ids, once, for every field in the port.
22//! 2. [`Range`] — the numeric range a slider, meter, progress bar or spin
23//!    button reports. Clamping, the indeterminate case and the value text live
24//!    here rather than in four components.
25//! 3. [`A11y`] — the extension trait that writes 1 and 2 onto an element.
26//!
27//! # Why an extension trait, and why it is not on plain `div()`
28//!
29//! [`A11y`] extends [`StatefulInteractiveElement`], which gpui implements only
30//! for elements that already carry an `.id(..)`. That is not an accident of
31//! convenience: gpui derives an AccessKit `NodeId` by hashing the element's
32//! `GlobalElementId`, so an element with no id produces no node at all and a
33//! role set on it is silently dropped (`window/a11y.rs` logs
34//! "focused element has no accessibility node"). Hanging the helpers off
35//! `StatefulInteractiveElement` means a decorative wrapper cannot accidentally
36//! acquire a contract, and a control that wants one has to have earned an id
37//! first — which, per [`herogpui_core::element_id`], must be derived from the
38//! caller's id rather than a constant, or two instances collide into one node.
39//!
40//! The role method is spelled [`A11y::a11y`] rather than gpui's own `role`
41//! because `role` is already taken twice in this workspace —
42//! `ThemeBuilder::role` and `ActiveTheme::role` are colour roles — and
43//! `.shots/a11y_audit.py` has to be able to tell a parity claim from a colour
44//! lookup by reading the source.
45//!
46//! # Deliberate omissions
47//!
48//! gpui-pre 0.3.3's `StatefulInteractiveElement` exposes 25 accessibility
49//! builders and no more. React Aria sets these attributes that have no
50//! counterpart there, so this port does not carry them:
51//!
52//! | Upstream attribute | Where React Aria sets it | Why it is omitted |
53//! |---|---|---|
54//! | `aria-disabled` | `useLink`, `useRadioGroup`, `useNumberField` group, pending `Button` | No gpui builder. A disabled control here leaves the tab order instead (`docs/agents/components.md`), which is the observable half. |
55//! | `aria-invalid` | every field hook, when invalid | No gpui builder. The message text still reaches the node through [`Name::field`]. |
56//! | `aria-required` | every field hook, when `isRequired` | No gpui builder. The visible `*` in [`crate::field::Label`] is the port's only marker. |
57//! | `aria-readonly` | `useToggle`, `useRadioGroup`, `useSpinButton` | No gpui builder. |
58//! | `aria-errormessage` | every field hook | No gpui builder, and React Aria itself notes it is unsupported by VoiceOver/NVDA and duplicates it into `aria-describedby`, which [`Name::field`] does carry. |
59//! | `aria-labelledby` / `aria-describedby` | everywhere | gpui has no id-reference graph; the strings are inlined instead. |
60//! | `aria-controls` | `useNumberField`'s stepper buttons, `useToggle`, `useDisclosure`, `useOverlayTrigger` | No gpui builder and no id graph to point at. A trigger can say it is expanded ([`A11y::a11y_expanded`]) but not what it expanded. |
61//! | `aria-roledescription` | `useNumberField`'s input ("number field") | No gpui builder. |
62//! | `aria-live="off"` | `useSlider`'s output | No gpui builder; gpui announces nothing live, so the suppression is moot. |
63//! | `role="meter progressbar"` | `useMeter` | AccessKit roles are a single enum; the fallback half of upstream's pair exists only for browsers that do not implement `meter`. `accesskit::Role::Meter` is the half that is true. |
64//! | `role="spinbutton"` | `useSpinButton` | `useNumberField` deletes it again (`role: null`) before it reaches the DOM, so the port must not add it. |
65//! | `aria-modal="false"` | `useToast`'s `toastProps` | No gpui builder, and `accesskit::Role` is a single enum with no modality flag: `AlertDialog` is the role, and whether it traps focus is not something the node can say. `useDialog` deliberately sets no `aria-modal` at all (a WebKit bug), so a modal and a non-modal dialog are the same node upstream too. |
66//! | `role="alert"` + `aria-atomic` | `useToast`'s `contentProps` | The point of that inner node is the live announcement. gpui exposes no live-region builder at all, so the port would be claiming an announcement it cannot make; the toast card's own `alertdialog` node carries the text instead. |
67//! | `aria-haspopup` | `useOverlayTrigger`, `useMenuItem`'s submenu rows | No gpui builder. |
68//! | `aria-hidden` | `useDisclosure`'s collapsed panel, `useToast`'s hidden content | No gpui builder. A collapsed panel leaves the element tree here instead, which is the stronger version of the same thing. |
69//! | `role="presentation"` | `useMenuSection`'s heading | No gpui builder, and none is needed: an element with no role already produces no node (`window/a11y.rs`), which is what `presentation` asks for. |
70//! | `role="separator"` | `useSeparator` | AccessKit 0.24 has no `Role::Separator`. `Role::Splitter` is a pane splitter, not a rule (it is what [`crate::resizable::ResizablePanelGroup`]'s handles report). [`crate::separator::Separator`] takes an optional `.id()` so a later AccessKit bump can claim the role; until then the row stays `PENDING` rather than lying. |
71//! | `aria-multiselectable` | `useListBox`, `useGridList`, `useGrid` — `selectionMode === 'multiple' ? 'true' : undefined` | No gpui builder. Whether a collection takes more than one selection is not something an AccessKit node can say here; each row's [`A11y::a11y_selected`] still reports its own state. |
72//! | `aria-sort` | `useTableColumnHeader` — `isSortedColumn ? sortDirection : 'none'` on a sortable column | No gpui builder. Upstream itself drops it on Android Talkback (`!isAndroid()`) and puts the sort order into `aria-describedby` instead, which is the half [`Name`] can carry. |
73//! | `aria-autocomplete="list"` | `useComboBox`, `useAutocomplete` | No gpui builder. |
74//! | `aria-live` / `aria-atomic` / `aria-relevant` | `useTagGroup`'s grid (`'aria-live': isFocusWithin ? 'polite' : 'off'`) | gpui exposes no live-region builder at all, the same reason the toast's inner `role="alert"` node is omitted. |
75//! | `aria-colspan` | `useGridCell` | No gpui builder; this port's table has no spanning cells to describe either. |
76//!
77//! | `aria-colspan` | `useGridCell` | No gpui builder; this port's table has no spanning cells to describe either. |
78//! | `aria-current="page"` | `Pagination`'s active page, `Breadcrumbs`' last crumb | No gpui builder on vanilla `gpui-pre`: AccessKit 0.24 defines the state but gpui publishes no setter and no node propagation for it. The active page keeps its pressed/disabled visual state and its name; only the current-page announcement is missing. Carried before as a local renderer fork (`docs/upstream/retired-patches/`), now an upstream PR item instead. |
79//!
80//! Every one of these is a "gpui has no equivalent" omission in the sense
81//! `docs/agents/parity.md` requires: checked against the pinned gpui source,
82//! not assumed.
83//!
84//! # The one omission that is not gpui's fault: a composed trigger
85//!
86//! React Aria puts the trigger half of an overlay contract —
87//! `aria-expanded`, `aria-haspopup`, `aria-controls` — onto the *caller's own
88//! button*, by injecting props through React context: RAC's `MenuTrigger`,
89//! `DialogTrigger` and `Disclosure` all hand `buttonProps` down to whatever
90//! `<Button>` the caller composed inside them. gpui has no equivalent: an
91//! element is built by its owner and a parent cannot reach into a child
92//! element it was handed as an `AnyElement`.
93//!
94//! So the rule in this port is *who owns the element*:
95//!
96//! * [`crate::accordion::Accordion`] builds its own trigger row, so it states
97//!   `Role::Button` and [`A11y::a11y_expanded`] there.
98//! * [`crate::disclosure::Disclosure`], [`crate::dropdown::Dropdown`],
99//!   [`crate::popover::Popover`] and [`crate::tooltip::Tooltip`] take the
100//!   trigger from the caller — a [`crate::button::Button`], or any element at
101//!   all. Their triggers therefore report whatever that element reports and no
102//!   expansion state. Wrapping the caller's element in a second node with a
103//!   button role would report the trigger twice, which is worse than reporting
104//!   it once without `aria-expanded`.
105//!
106//! Closing that gap is not an accessibility change: it needs an expanded-state
107//! prop on `Button` that HeroUI v3 does not document (v3's `Button` has no
108//! such prop either — RAC injects it), so it would be invented API.
109//!
110//! ## What the same rule costs the pickers
111//!
112//! Wave 3 met the sharper form of it. `useComboBox` does not decorate a
113//! trigger button — it turns the **text input itself** into the widget:
114//! `role: 'combobox'`, `aria-expanded`, `aria-controls`, `aria-autocomplete`
115//! and `aria-activedescendant` all land on `inputProps`, which RAC's
116//! `ComboBox` hands to whatever `<Input>` is composed inside it.
117//!
118//! In this port [`crate::combo_box::ComboBox`] and
119//! [`crate::autocomplete::Autocomplete`] *construct* their text field, but
120//! they construct it as a [`crate::input::Input`] value and call its `render`;
121//! the role is decided inside `Input::render` from its `InputType`, and there
122//! is no prop that overrides it. So the field keeps `Role::TextInput` /
123//! `Role::SearchInput` — which is what it is — and the combobox half of the
124//! contract is stated on the parts the picker does own:
125//!
126//! * the popup list, which is a real `role="listbox"` upstream too, and its
127//!   rows, which are real `role="option"`s;
128//! * the highlighted row, through [`A11y::a11y_active_descendant`] — gpui puts
129//!   that relation on the descendant rather than the container, so it is the
130//!   one piece of `useComboBox`'s input contract that survives not owning the
131//!   input;
132//! * [`crate::select::Select`]'s and `Autocomplete`'s trigger, which those
133//!   components build themselves and which is a `<button>` upstream
134//!   (`useSelect` derives it from `useMenuTrigger`), so it states
135//!   `Role::Button` and [`A11y::a11y_expanded`].
136//!
137//! What is lost is `role="combobox"` on the field and its `aria-expanded`.
138//! Both need either a role override on `Input` — invented API, since v3's
139//! `Input` has no such prop; RAC injects it through `ComboBoxContext` — or a
140//! parent that can modify an element it was handed, which gpui does not have.
141//!
142//! # Wave 4: status and content
143//!
144//! Most of v3's display surface — `Alert`, `Avatar`, `Badge`, `Card`, `Form`,
145//! `Kbd`, `ScrollShadow`, `Skeleton`, `Surface`, `Typography` — imports no
146//! RAC primitive and authors no `role`. A node there would be an invention.
147//! [`crate::spinner::Spinner`] is the exception: `spinner/spinner.js`
148//! hard-codes `role: "status"` / `aria-label="Loading"` on the root span,
149//! which is why it is the one status node under contract. [`crate::separator::Separator`]
150//! is a real AccessKit gap (RAC `Separator` + `useSeparator`, no
151//! `Role::Separator` in 0.24), not an id-less constructor.
152//!
153//! # Wave 5: calendars and overlay-backed fields
154//!
155//! The calendar family and the colour/date fields that compose it. Each
156//! states the role its React Aria hook reports:
157//!
158//! - [`crate::calendar::Calendar`] / [`crate::range_calendar::RangeCalendar`]:
159//!   `Role::Application` on the root (`useCalendarBase`), `Role::Grid` on
160//!   each month (`useCalendarGrid`), `Role::Button` plus `aria-selected` on
161//!   each day (the pressable half of `useCalendarCell`'s `gridcell`+`button`
162//!   pair — this port draws one circle), and `Role::Button` named
163//!   "Previous"/"Next" on the nav.
164//! - [`crate::date_picker::DateField`] / [`crate::time_field::TimeField`]:
165//!   `Role::Group` on the box, `Role::TextInput` on each segment
166//!   (`useDateSegment` rewrites the spinbutton into a textbox).
167//! - [`crate::date_picker::DatePicker`] / `DateRangePicker`: `Role::Group` on
168//!   the field and `Role::Button` plus `aria-expanded` on the trigger they
169//!   build themselves.
170//! - Colour: `Role::Group` on [`crate::color_picker::ColorArea`], `Role::Slider`
171//!   on [`crate::color_picker::ColorSlider`], `Role::TextInput` on
172//!   [`crate::color_picker::ColorField`], `Role::RadioGroup` /
173//!   `Role::RadioButton` on [`crate::color_picker::ColorSwatchPicker`], and
174//!   `Role::Button` plus `aria-expanded` / `Role::Dialog` on
175//!   [`crate::color_picker::ColorPicker`], and `Role::Image` on a named
176//!   [`crate::color_picker::ColorSwatch`].
177//!
178//! Groups that take no required id — [`crate::button_group::ButtonGroup`],
179//! [`crate::input_group::InputGroup`], [`crate::progress::ProgressCircle`],
180//! [`crate::field::Fieldset`], [`crate::toast::ToastViewport`] — report a
181//! node only when the caller names them, the same shape as [`crate::toolbar::Toolbar`].
182
183use gpui::{SharedString, StatefulInteractiveElement};
184
185pub use gpui::accesskit::{AriaCurrent, Role, Toggled};
186
187use crate::validation::Validity;
188
189/// A control's accessible name and description.
190///
191/// React Aria's `useField` builds `aria-describedby` by concatenating the
192/// description node's id and the field-error node's id
193/// (`react-aria/dist/private/label/useField.js`); the resulting accessible
194/// description is those two texts, in that order. gpui takes the text
195/// directly, so [`Self::field`] performs the same concatenation on the strings.
196#[derive(Clone, Debug, Default, PartialEq, Eq)]
197pub struct Name {
198    label: Option<SharedString>,
199    description: Option<SharedString>,
200}
201
202impl Name {
203    /// No accessible name of its own — the node is named by its contents.
204    pub fn none() -> Self {
205        Self::default()
206    }
207
208    /// A control named by a literal string.
209    pub fn labelled(label: impl Into<SharedString>) -> Self {
210        Self {
211            label: Some(label.into()),
212            description: None,
213        }
214    }
215
216    /// A control named by an optional string, unnamed when it is absent.
217    pub fn maybe(label: Option<impl Into<SharedString>>) -> Self {
218        Self {
219            label: label.map(Into::into),
220            description: None,
221        }
222    }
223
224    /// The name and description a field's own anatomy already computed.
225    ///
226    /// `label` is the visible [`crate::field::Label`]'s text — React Aria
227    /// points `aria-labelledby` at that element, so its text *is* the name.
228    /// `description` is the [`crate::field::Description`]'s text, and the
229    /// validation messages join it exactly when the field is invalid, which is
230    /// exactly when React Aria's `FieldError` renders and contributes its id to
231    /// `aria-describedby`.
232    pub fn field(
233        label: Option<&SharedString>,
234        description: Option<&SharedString>,
235        validity: &Validity,
236    ) -> Self {
237        let errors = if validity.is_invalid {
238            validity.joined()
239        } else {
240            String::new()
241        };
242        let described = match (description.map(SharedString::as_ref), errors.as_str()) {
243            (None, "") => None,
244            (Some(d), "") => Some(SharedString::from(d.to_owned())),
245            (None, e) => Some(SharedString::from(e.to_owned())),
246            (Some(d), e) => Some(SharedString::from(format!("{d} {e}"))),
247        };
248        Self {
249            label: label.cloned(),
250            description: described,
251        }
252    }
253
254    /// Replaces the description, for the controls that carry one without a
255    /// validation story (a menu subtitle, a stepper hint).
256    pub fn described(mut self, description: Option<impl Into<SharedString>>) -> Self {
257        self.description = description.map(Into::into);
258        self
259    }
260
261    /// Prefixes the name, the way `useNumberField` names its steppers
262    /// "Increase `{label}`" rather than pointing at the field's label alone.
263    pub fn prefixed(&self, prefix: &str) -> Self {
264        Self {
265            label: Some(match &self.label {
266                Some(label) => SharedString::from(format!("{prefix} {label}")),
267                None => SharedString::from(prefix.to_owned()),
268            }),
269            description: None,
270        }
271    }
272
273    /// The accessible name, if any.
274    pub fn label(&self) -> Option<&SharedString> {
275        self.label.as_ref()
276    }
277
278    /// The accessible description, if any.
279    pub fn description(&self) -> Option<&SharedString> {
280        self.description.as_ref()
281    }
282
283    /// Whether this names anything at all. A control with no name is a control
284    /// a screen reader announces by role only, which React Aria warns about in
285    /// development (`useToggle`: "you must specify an aria-label").
286    pub fn is_empty(&self) -> bool {
287        self.label.is_none() && self.description.is_none()
288    }
289}
290
291/// The numeric range a range-shaped control reports.
292///
293/// `useProgressBar` clamps the value into the range before reporting it, keeps
294/// `aria-valuemin`/`aria-valuemax` unconditionally, and drops
295/// `aria-valuenow`/`aria-valuetext` when the control is indeterminate. All four
296/// range-shaped controls in this port inherit that from it — a progress bar and
297/// circle directly, a meter through `useMeter`, a slider thumb through
298/// `useSliderThumb`'s `<input type="range">` — so the rule is written here once.
299#[derive(Clone, Debug, PartialEq)]
300pub struct Range {
301    min: f64,
302    max: f64,
303    value: Option<f64>,
304    step: Option<f64>,
305    text: Option<SharedString>,
306}
307
308impl Range {
309    /// A determinate range. `value` is clamped into `[min, max]`, as
310    /// `useProgressBar` clamps it; an inverted range reports `value` untouched
311    /// rather than panicking in `f64::clamp`.
312    pub fn new(min: f64, max: f64, value: f64) -> Self {
313        let value = if min <= max {
314            value.clamp(min, max)
315        } else {
316            value
317        };
318        Self {
319            min,
320            max,
321            value: Some(value),
322            step: None,
323            text: None,
324        }
325    }
326
327    /// An indeterminate range: the bounds still report, the value does not.
328    pub fn indeterminate(min: f64, max: f64) -> Self {
329        Self {
330            min,
331            max,
332            value: None,
333            step: None,
334            text: None,
335        }
336    }
337
338    /// `aria-valuetext` — the human-readable rendering of the value, which
339    /// upstream fills with the formatted number (a percentage for a progress
340    /// bar, `state.getThumbValueLabel(index)` for a slider thumb).
341    pub fn text(mut self, text: Option<impl Into<SharedString>>) -> Self {
342        self.text = text.map(Into::into);
343        self
344    }
345
346    /// The `step` a spin button or slider thumb advances by.
347    pub fn step(mut self, step: f64) -> Self {
348        self.step = Some(step);
349        self
350    }
351
352    /// The minimum value of the range.
353    pub fn min(&self) -> f64 {
354        self.min
355    }
356
357    /// The maximum value of the range.
358    pub fn max(&self) -> f64 {
359        self.max
360    }
361
362    /// The current value, if known.
363    pub fn value(&self) -> Option<f64> {
364        self.value
365    }
366
367    /// The keyboard step size, if any.
368    pub fn step_size(&self) -> Option<f64> {
369        self.step
370    }
371
372    /// The human-readable value text, if any.
373    pub fn value_text(&self) -> Option<&SharedString> {
374        self.text.as_ref()
375    }
376}
377
378/// Writes a control's parity contract onto the element that carries its id.
379///
380/// Implemented for every [`StatefulInteractiveElement`], which is to say for
381/// every element that has an `.id(..)` and can therefore produce an AccessKit
382/// node at all. See the module docs for why that bound is the point.
383pub trait A11y: StatefulInteractiveElement + Sized {
384    /// The control's role. This is the one call `.shots/a11y_audit.py` looks
385    /// for, so a component that has a role upstream must spell it here.
386    fn a11y(self, role: Role) -> Self {
387        self.role(role)
388    }
389
390    /// The role together with the name, for the common case.
391    fn a11y_named(self, role: Role, name: &Name) -> Self {
392        self.a11y(role).a11y_name(name)
393    }
394
395    /// `aria-label` and `aria-describedby`'s resolved text.
396    fn a11y_name(mut self, name: &Name) -> Self {
397        if let Some(label) = name.label() {
398            self = self.aria_label(label.clone());
399        }
400        if let Some(description) = name.description() {
401            self = self.aria_description(description.clone());
402        }
403        self
404    }
405
406    /// `aria-checked` for a checkbox, switch or radio.
407    ///
408    /// `useCheckbox` sets the DOM `indeterminate` property, which is what makes
409    /// a native checkbox report `aria-checked="mixed"`; mixed wins over the
410    /// checked flag, exactly as it does in the browser.
411    fn a11y_checked(self, checked: bool, indeterminate: bool) -> Self {
412        self.aria_toggled(match (indeterminate, checked) {
413            (true, _) => Toggled::Mixed,
414            (false, true) => Toggled::True,
415            (false, false) => Toggled::False,
416        })
417    }
418
419    /// `aria-pressed` for a toggle button (`useToggleButton`).
420    fn a11y_pressed(self, pressed: bool) -> Self {
421        self.aria_toggled(if pressed {
422            Toggled::True
423        } else {
424            Toggled::False
425        })
426    }
427
428    /// `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext`
429    /// / `aria-valuestep`, with the indeterminate case handled by [`Range`].
430    fn a11y_range(mut self, range: &Range) -> Self {
431        self = self
432            .aria_min_numeric_value(range.min())
433            .aria_max_numeric_value(range.max());
434        if let Some(value) = range.value() {
435            self = self.aria_numeric_value(value);
436            // `useProgressBar` drops `aria-valuetext` together with
437            // `aria-valuenow`: an indeterminate control has no value to word.
438            if let Some(text) = range.value_text() {
439                self = self.aria_value(text.clone());
440            }
441        }
442        if let Some(step) = range.step_size() {
443            self = self.aria_numeric_value_step(step);
444        }
445        self
446    }
447
448    /// `aria-expanded` for a trigger that owns a collapsible region.
449    ///
450    /// `react-aria/dist/private/disclosure/useDisclosure.js` puts it on the
451    /// disclosure's trigger button beside `aria-controls`;
452    /// `.../overlays/useOverlayTrigger.js` and `.../menu/useMenuItem.js` put it
453    /// on an overlay trigger and a submenu row. Only the flag ports: the
454    /// `aria-controls` half of every one of those pairs needs an id graph gpui
455    /// does not have (see the module's omission table), so the port states that
456    /// the trigger is expanded without being able to say what it expanded.
457    fn a11y_expanded(self, expanded: bool) -> Self {
458        self.aria_expanded(expanded)
459    }
460
461    /// `aria-orientation`, translated from the port's own v3 prop enum so a
462    /// call site never has to name two orientation types at once.
463    fn a11y_orientation(self, orientation: herogpui_core::Orientation) -> Self {
464        self.aria_orientation(match orientation {
465            herogpui_core::Orientation::Horizontal => gpui::accesskit::Orientation::Horizontal,
466            herogpui_core::Orientation::Vertical => gpui::accesskit::Orientation::Vertical,
467        })
468    }
469
470    /// A text input's current text and its placeholder.
471    fn a11y_text(mut self, value: &str, placeholder: Option<&SharedString>) -> Self {
472        self = self.aria_value(SharedString::from(value.to_owned()));
473        if let Some(placeholder) = placeholder {
474            self = self.aria_placeholder(placeholder.clone());
475        }
476        self
477    }
478
479    /// `aria-selected` for a collection member.
480    ///
481    /// `react-aria/dist/private/listbox/useOption.mjs` writes it as
482    /// `state.selectionManager.selectionMode !== 'none' ? isSelected :
483    /// undefined`, and `.../grid/useGridRow.mjs` and
484    /// `.../gridlist/useGridListItem.mjs` guard it the same way. The guard is
485    /// the caller's, because only the caller knows the mode; what is written
486    /// here is the flag itself.
487    fn a11y_selected(self, selected: bool) -> Self {
488        self.aria_selected(selected)
489    }
490
491    /// The current page/step/location value for navigation landmarks.
492    ///
493    /// Deliberately unwritten: vanilla `gpui-pre` publishes no `aria_current`
494    /// builder and no AccessKit node propagation for it (see the omissions
495    /// table at the top of this module). The method is kept as a no-op anchor
496    /// so call sites and the upstream-PR re-application have one place to
497    /// touch: when gpui gains the builder, restore the forwarding line and
498    /// remove the `aria-current` row from the omissions table.
499    fn a11y_current(self, _current: AriaCurrent) -> Self {
500        self
501    }
502
503    /// `aria-posinset` / `aria-setsize`, from a **zero-based** index.
504    ///
505    /// Upstream sets this pair only under virtualization —
506    /// `useOption.mjs`'s `if (isVirtualized) { optionProps['aria-posinset'] =
507    /// index + 1; optionProps['aria-setsize'] = getItemCount(...) }` — because
508    /// that is exactly when the rendered rows are a window onto a longer
509    /// collection and the position can no longer be counted from the tree.
510    /// The port's virtual list paths are the same situation, so the guard
511    /// ports with the attribute. `index + 1` is applied here so no call site
512    /// has to remember that ARIA counts from one.
513    fn a11y_set_position(self, index: usize, size: usize) -> Self {
514        self.aria_position_in_set(index + 1).aria_size_of_set(size)
515    }
516
517    /// `aria-level`, from a **zero-based** depth.
518    ///
519    /// `.../table/useTableRow.mjs` and `.../gridlist/useGridListItem.mjs`
520    /// write `'aria-level': node.level + 1` on a tree row, so the same `+ 1`
521    /// happens here.
522    fn a11y_level(self, depth: usize) -> Self {
523        self.aria_level(depth + 1)
524    }
525
526    /// `aria-rowcount` / `aria-colcount` on a grid.
527    ///
528    /// `react-aria/dist/private/grid/useGrid.mjs` sets both only
529    /// `if (isVirtualized)`, and `.../table/useTable.mjs` then replaces the
530    /// row count with `state.collection.size +
531    /// state.collection.headerRows.length` — the header rows count, because
532    /// they are rows of the grid. Both numbers are the *whole* collection's,
533    /// not the rendered window's; a caller that cannot know the total must
534    /// not call this.
535    fn a11y_grid_size(self, rows: usize, columns: usize) -> Self {
536        self.aria_row_count(rows).aria_column_count(columns)
537    }
538
539    /// `aria-rowindex` on a row, from a **zero-based** index.
540    ///
541    /// `.../grid/useGridRow.mjs`: `if (isVirtualized) rowProps['aria-rowindex']
542    /// = node.index + 1; // aria-rowindex is 1 based`.
543    fn a11y_row_index(self, index: usize) -> Self {
544        self.aria_row_index(index + 1)
545    }
546
547    /// `aria-colindex` on a cell, from a **zero-based** index.
548    ///
549    /// `.../grid/useGridCell.mjs`: `'aria-colindex': node.colIndex != null ?
550    /// node.colIndex + 1 : undefined`. Unlike the row index this is *not*
551    /// gated on virtualization — a table collection always knows a cell's
552    /// column.
553    fn a11y_column_index(self, index: usize) -> Self {
554        self.aria_column_index(index + 1)
555    }
556
557    /// The element assistive technology should treat as focused while an
558    /// ancestor holds the real focus.
559    ///
560    /// This is the one place where gpui's shape is the *inverse* of the web's
561    /// and the port is better off for it. `useComboBox.js` puts
562    /// `aria-activedescendant` on the **input** — `'aria-activedescendant':
563    /// focusedItem ? getItemId(state, focusedItem.key) : undefined` — pointing
564    /// at a row id, which needs both an id graph and ownership of the input.
565    /// gpui-pre 0.3.3's `aria_active_descendant` takes no argument and is set
566    /// on the descendant itself (`elements/div.rs`: "Unlike the web's
567    /// container-side `aria-activedescendant`, this is set on the descendant;
568    /// GPUI honors it only when a focused ancestor is present in the tree, so
569    /// it is safe to set unconditionally on the selected child"). A collection
570    /// that owns its rows can therefore state the relation even when it does
571    /// not own the element upstream would have written it on.
572    fn a11y_active_descendant(self) -> Self {
573        self.aria_active_descendant()
574    }
575}
576
577impl<E: StatefulInteractiveElement> A11y for E {}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::validation::resolve;
583
584    fn s(v: &str) -> SharedString {
585        SharedString::from(v.to_owned())
586    }
587
588    #[test]
589    fn a_clean_field_describes_itself_with_its_description_alone() {
590        let name = Name::field(
591            Some(&s("Email")),
592            Some(&s("Work address")),
593            &resolve(false, &[], None, None),
594        );
595        assert_eq!(name.label(), Some(&s("Email")));
596        assert_eq!(name.description(), Some(&s("Work address")));
597    }
598
599    /// `useField` concatenates the description id and the error id, in that
600    /// order, so the announced description is both texts in that order.
601    #[test]
602    fn an_invalid_field_appends_every_message_after_the_description() {
603        let validity = resolve(false, &[s("Already taken")], Some(s("Too short")), None);
604        let name = Name::field(Some(&s("Email")), Some(&s("Work address")), &validity);
605        assert_eq!(
606            name.description(),
607            Some(&s("Work address Already taken Too short"))
608        );
609
610        // No description of its own: the messages are the whole description.
611        let name = Name::field(Some(&s("Email")), None, &validity);
612        assert_eq!(name.description(), Some(&s("Already taken Too short")));
613    }
614
615    /// `FieldError` renders only while the field is invalid, so a stale
616    /// message must not reach the node once validity is restored.
617    #[test]
618    fn a_valid_field_announces_no_message() {
619        let mut validity = resolve(false, &[s("Already taken")], None, None);
620        validity.is_invalid = false;
621        let name = Name::field(None, Some(&s("Work address")), &validity);
622        assert_eq!(name.description(), Some(&s("Work address")));
623    }
624
625    #[test]
626    fn an_unnamed_undescribed_field_is_empty() {
627        let name = Name::field(None, None, &resolve(false, &[], None, None));
628        assert!(name.is_empty());
629        assert!(!Name::labelled("Close").is_empty());
630        assert!(Name::none().is_empty());
631        assert!(Name::maybe(None::<SharedString>).is_empty());
632    }
633
634    /// `useNumberField` names its steppers "Increase {label}", falling back to
635    /// the bare verb when the field has no label at all.
636    #[test]
637    fn a_stepper_prefixes_the_fields_name() {
638        let field = Name::labelled("Quantity");
639        assert_eq!(
640            field.prefixed("Increase").label(),
641            Some(&s("Increase Quantity"))
642        );
643        assert_eq!(
644            Name::none().prefixed("Increase").label(),
645            Some(&s("Increase"))
646        );
647    }
648
649    /// `useProgressBar` clamps before reporting, so a caller's out-of-range
650    /// value never reaches the node.
651    #[test]
652    fn a_range_clamps_its_value_the_way_use_progress_bar_does() {
653        assert_eq!(Range::new(0., 100., 150.).value(), Some(100.));
654        assert_eq!(Range::new(0., 100., -5.).value(), Some(0.));
655        assert_eq!(Range::new(0., 100., 42.).value(), Some(42.));
656        // An inverted range would panic in `f64::clamp`; report it untouched.
657        assert_eq!(Range::new(10., 0., 5.).value(), Some(5.));
658    }
659
660    #[test]
661    #[allow(clippy::float_cmp)]
662    fn an_indeterminate_range_keeps_its_bounds_and_drops_its_value() {
663        let range = Range::indeterminate(0., 100.).text(Some("ignored"));
664        assert_eq!(range.min(), 0.);
665        assert_eq!(range.max(), 100.);
666        assert_eq!(range.value(), None);
667        // The text is still stored, but `a11y_range` writes it only beside a
668        // value, so an indeterminate control never announces one.
669        assert_eq!(range.value_text(), Some(&s("ignored")));
670    }
671
672    #[test]
673    fn a_range_carries_its_step_and_value_text() {
674        let range = Range::new(1., 10., 4.).step(0.5).text(Some("4 items"));
675        assert_eq!(range.step_size(), Some(0.5));
676        assert_eq!(range.value_text(), Some(&s("4 items")));
677        assert_eq!(Range::new(1., 10., 4.).step_size(), None);
678    }
679}