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}