use gpui::{
actions, App, FocusHandle, InteractiveElement, KeyBinding, Orientation, Role, SharedString,
StatefulInteractiveElement, Toggled,
};
#[derive(Clone, Debug, PartialEq)]
pub struct A11y {
role: Role,
name: Option<SharedString>,
description: Option<SharedString>,
toggled: Option<Toggled>,
selected: Option<bool>,
expanded: Option<bool>,
value: Option<A11yValue>,
orientation: Option<Orientation>,
level: Option<usize>,
position_in_set: Option<usize>,
size_of_set: Option<usize>,
active_descendant: bool,
focus: Focus,
}
#[derive(Clone, Debug, Default, PartialEq)]
enum Focus {
#[default]
Undecided,
Takes(Option<FocusHandle>),
Declines(SharedString),
}
#[derive(Clone, Debug, PartialEq)]
pub enum A11yValue {
Text(SharedString),
Number {
value: f64,
min: f64,
max: f64,
step: f64,
},
}
impl A11y {
pub fn new(role: Role) -> Self {
Self {
role,
name: None,
description: None,
toggled: None,
selected: None,
expanded: None,
value: None,
orientation: None,
level: None,
position_in_set: None,
size_of_set: None,
active_descendant: false,
focus: Focus::Undecided,
}
}
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn toggled(mut self, toggled: Toggled) -> Self {
self.toggled = Some(toggled);
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = Some(selected);
self
}
pub fn expanded(mut self, expanded: bool) -> Self {
self.expanded = Some(expanded);
self
}
pub fn text_value(mut self, value: impl Into<SharedString>) -> Self {
self.value = Some(A11yValue::Text(value.into()));
self
}
pub fn number_value(mut self, value: f64, min: f64, max: f64, step: f64) -> Self {
self.value = Some(A11yValue::Number {
value,
min,
max,
step,
});
self
}
pub fn orientation(mut self, orientation: Orientation) -> Self {
self.orientation = Some(orientation);
self
}
pub fn level(mut self, level: usize) -> Self {
self.level = Some(level);
self
}
pub fn position_in_set(mut self, position: usize) -> Self {
self.position_in_set = Some(position);
self
}
pub fn size_of_set(mut self, size: usize) -> Self {
self.size_of_set = Some(size);
self
}
pub fn active_descendant(mut self, active_descendant: bool) -> Self {
self.active_descendant = active_descendant;
self
}
pub fn focusable(mut self) -> Self {
self.focus = Focus::Takes(None);
self
}
pub fn focus_handle(mut self, handle: FocusHandle) -> Self {
self.focus = Focus::Takes(Some(handle));
self
}
pub fn not_focusable(mut self, why: impl Into<SharedString>) -> Self {
self.focus = Focus::Declines(why.into());
self
}
pub fn role(&self) -> Role {
self.role
}
pub fn accessible_name(&self) -> Option<&SharedString> {
self.name.as_ref()
}
pub fn is_missing_a_required_name(&self) -> bool {
role_requires_a_name(self.role)
&& !self
.name
.as_ref()
.is_some_and(|name| !name.trim().is_empty())
}
pub fn is_active_descendant(&self) -> bool {
self.active_descendant
}
pub fn is_focusable(&self) -> bool {
matches!(self.focus, Focus::Takes(_))
}
pub fn focus_declined_because(&self) -> Option<&SharedString> {
match &self.focus {
Focus::Declines(why) => Some(why),
_ => None,
}
}
pub fn is_missing_a_focus_decision(&self) -> bool {
role_requires_keyboard_focus(self.role) && matches!(self.focus, Focus::Undecided)
}
}
pub fn role_requires_a_name(role: Role) -> bool {
matches!(
role,
Role::Button
| Role::DefaultButton
| Role::CheckBox
| Role::Switch
| Role::RadioButton
| Role::Link
| Role::MenuItem
| Role::MenuItemCheckBox
| Role::MenuItemRadio
| Role::ListBoxOption
| Role::Tab
| Role::TreeItem
| Role::Slider
| Role::Splitter
| Role::SpinButton
| Role::ComboBox
| Role::EditableComboBox
| Role::TextInput
| Role::MultilineTextInput
| Role::SearchInput
| Role::NumberInput
| Role::PasswordInput
| Role::DateInput
| Role::ProgressIndicator
| Role::Meter
| Role::Dialog
| Role::AlertDialog
| Role::Image
)
}
pub fn role_requires_keyboard_focus(role: Role) -> bool {
matches!(
role,
Role::Button
| Role::DefaultButton
| Role::CheckBox
| Role::Switch
| Role::RadioButton
| Role::Link
| Role::Slider
| Role::Splitter
| Role::SpinButton
| Role::ComboBox
| Role::EditableComboBox
| Role::TextInput
| Role::MultilineTextInput
| Role::SearchInput
| Role::NumberInput
| Role::PasswordInput
| Role::DateInput
)
}
actions!(
a11y,
[
FocusNext,
FocusPrevious,
]
);
pub fn bind_focus_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("tab", FocusNext, None),
KeyBinding::new("shift-tab", FocusPrevious, None),
]);
}
pub trait FocusNavigation: InteractiveElement {
fn moves_focus_on_tab(self) -> Self {
self.on_action(|_: &FocusNext, window, cx| window.focus_next(cx))
.on_action(|_: &FocusPrevious, window, cx| window.focus_prev(cx))
}
}
impl<E: InteractiveElement> FocusNavigation for E {}
pub const ELEMENTS_WITHOUT_A_ROLE: &[(&str, &str)] = &[
(
"accordion",
"would be a set of Group headers with `expanded`, once Collapsible answers the same \
question — the two share a mechanism and should be adopted together",
),
(
"alert",
"would be Role::Alert (or AlertDialog for the modal shape), which needs the live-region \
decision this convention has not taken",
),
(
"aspect_ratio",
"a layout wrapper with no semantics of its own: it would announce nothing, and saying so \
needs a `Role::GenericContainer` escape gpui rejects outright",
),
(
"avatar",
"would be Role::Image with a required name, and section 1's `Img` escape hatch says it \
first needs a `div().id(…)` around the image",
),
(
"badge",
"decorative text beside the thing it counts; it wants an `aria-describedby` relationship \
gpui has no builder for, not a role of its own",
),
(
"breadcrumb",
"would be Role::Navigation around a Role::List of Role::Link, so it needs the composite \
roles and the link naming rule together",
),
(
"button_group",
"would be Role::Group with an orientation, and its children are already Buttons — it is \
waiting on nothing but its turn behind `icon_button`",
),
(
"card",
"a surface, not a control: it would be Role::Group, and only once it can be named by its \
own header rather than by an argument",
),
(
"checkbox",
"would be Role::CheckBox with `toggled` and a required name, and is next after \
`icon_button` in section 6's order",
),
(
"collapsible",
"would report `expanded` on its trigger the way SidebarTrigger does; adopted together \
with `accordion`, which shares the mechanism",
),
(
"context_menu",
"would be Role::Menu over Role::MenuItem rows, which are composite-item roles — they \
need the roving-focus convention `role_requires_keyboard_focus` names",
),
(
"empty",
"an empty-state illustration with a heading and a message: it would announce a \
Role::Group named by its own heading, which is the naming rule `card` is also waiting on",
),
(
"icon_button",
"would be Role::Button with the name as a required constructor argument, which is the \
breaking change section 2 describes and the first of section 6's rollout",
),
(
"input",
"would be Role::TextInput and its siblings with a `value`; it owns its own FocusHandle \
already, which is exactly what `A11y::focus_handle` takes",
),
(
"kbd",
"renders a key name as decoration; it would want accesskit's `keyboard_shortcut` on the \
control it describes, not a role of its own",
),
(
"label",
"would be Role::Label, and gpui already mints one for `text!` — adopting it needs the \
duplicate-node question section 1 raises answered first",
),
(
"list",
"would be Role::List over Role::ListItem, and its selectable rows are composite-item \
roles waiting on the roving-focus convention",
),
(
"loading_indicator",
"would be Role::ProgressIndicator with no numeric value, which needs the \
indeterminate-progress decision `progress` also wants",
),
(
"popover",
"would be a named Role::Group with `expanded` on its trigger; it owns focus by hand \
today and adopting it means moving that onto `A11y::focus_handle`",
),
(
"progress",
"would be Role::ProgressIndicator with a bounded number value; the indeterminate case \
has no answer yet, and `A11yValue::Number` requires all four bounds",
),
(
"radio_group",
"would be Role::RadioGroup over Role::RadioButton rows, the clearest case of the \
roving-focus convention this crate does not have",
),
(
"scroll_area",
"a scroll container gpui already describes through its own scroll properties; a role \
here would add a node without adding information",
),
(
"separator",
"would be Role::Splitter with an orientation and no interaction, which is the one \
`splitter.rs` already reports — the two need reconciling before either moves",
),
(
"slider",
"would be Role::Slider with a bounded number value and a required name, which is the \
`A11yValue::Number` case section 3 was written for",
),
(
"switch",
"would be Role::Switch with `toggled` and a required name; adopted alongside `checkbox` \
and `toggle`, which share the shape",
),
(
"table",
"its own module docs already say it: gpui has no `aria_sort` builder, and a table needs \
derived cell ids before its cells can carry roles at all — section 6 puts it last",
),
(
"tabs",
"would be Role::TabList over Role::Tab, composite-item roles that need the roving-focus \
convention before a per-item rule can be right",
),
(
"text_field",
"would be Role::TextInput with a `value` and a required name; it owns a FocusHandle \
already, so adopting it is a move onto `A11y::focus_handle`",
),
(
"textarea",
"would be Role::MultilineTextInput; same shape as `text_field`, and the two should be \
adopted in one change so their focus handling matches",
),
(
"toast",
"would be Role::Alert in a live region, which is the live-region decision `alert` is \
also waiting on — both should be taken once",
),
(
"toggle",
"would be Role::Button with `toggled`, or Role::Switch depending on the answer \
`checkbox` and `switch` settle between them",
),
(
"toggle_group",
"would be Role::Group over toggles, so it cannot be adopted before `toggle` has decided \
what one of its children announces",
),
(
"tooltip",
"accesskit has a `tooltip` property on the described control rather than a role, and \
this crate's tooltip is an `AnyView` with no string to read — see section 2",
),
(
"typography",
"would be Role::Heading with a `level` and Role::Paragraph; gpui mints label nodes for \
text already, so this needs the duplicate-node question answered with `label`",
),
];
pub trait Announce: StatefulInteractiveElement {
#[track_caller]
fn announce(self, a11y: A11y) -> Self {
debug_assert!(
!a11y.is_missing_a_required_name(),
"{:?} announces itself by name, and this one has none. Give it the element's \
own visible text where it has any, or take the name as a constructor argument \
where it does not — see `a11y`'s module docs, section 2. Not the tooltip.",
a11y.role(),
);
debug_assert!(
!a11y.is_missing_a_focus_decision(),
"{:?} is a control a keyboard user operates, and this one says nothing about \
keyboard focus. Call `.focusable()` (or `.focus_handle(handle)` if the element \
owns one), or `.not_focusable(\"why\")` if it genuinely stays out of the tab \
order — see `a11y`'s module docs, section 4. Announcing a role a keyboard \
cannot reach is the defect that section exists for.",
a11y.role(),
);
let A11y {
role,
name,
description,
toggled,
selected,
expanded,
value,
orientation,
level,
position_in_set,
size_of_set,
active_descendant,
focus,
} = a11y;
let mut element = self.role(role);
match focus {
Focus::Takes(None) => {
element = element.focusable().tab_stop(true).moves_focus_on_tab();
}
Focus::Takes(Some(handle)) => {
element = element
.track_focus(&handle.tab_stop(true))
.moves_focus_on_tab();
}
Focus::Undecided | Focus::Declines(_) => {}
}
if let Some(name) = name {
element = element.aria_label(name);
}
if let Some(description) = description {
element = element.aria_description(description);
}
if let Some(toggled) = toggled {
element = element.aria_toggled(toggled);
}
if let Some(selected) = selected {
element = element.aria_selected(selected);
}
if let Some(expanded) = expanded {
element = element.aria_expanded(expanded);
}
match value {
Some(A11yValue::Text(text)) => element = element.aria_value(text),
Some(A11yValue::Number {
value,
min,
max,
step,
}) => {
element = element
.aria_numeric_value(value)
.aria_min_numeric_value(min)
.aria_max_numeric_value(max)
.aria_numeric_value_step(step);
}
None => {}
}
if let Some(orientation) = orientation {
element = element.aria_orientation(orientation);
}
if let Some(level) = level {
element = element.aria_level(level);
}
if let Some(position) = position_in_set {
element = element.aria_position_in_set(position);
}
if let Some(size) = size_of_set {
element = element.aria_size_of_set(size);
}
if active_descendant {
element = element.aria_active_descendant();
}
element
}
}
impl<E: StatefulInteractiveElement> Announce for E {}
#[cfg(test)]
pub(crate) mod test_support {
use gpui::{accesskit, App, Element, ElementId, IntoElement, RenderOnce, Role, Window};
pub(crate) struct Announced {
pub(crate) id: Option<ElementId>,
pub(crate) role: Option<Role>,
pub(crate) node: Option<accesskit::Node>,
}
impl Announced {
pub(crate) fn name(&self) -> Option<&str> {
self.node.as_ref().and_then(|node| node.label())
}
pub(crate) fn supports(&self, action: accesskit::Action) -> bool {
self.node
.as_ref()
.is_some_and(|node| node.supports_action(action))
}
}
pub(crate) fn announced(
component: impl RenderOnce,
window: &mut Window,
cx: &mut App,
) -> Announced {
announced_element(component.render(window, cx).into_element())
}
pub(crate) fn announced_element(element: impl Element) -> Announced {
let id = element.id();
let role = element.a11y_role();
let node = match (&id, role) {
(Some(_), Some(role)) => {
let mut node = accesskit::Node::new(role);
element.write_a11y_info(&mut node);
Some(node)
}
_ => None,
};
Announced { id, role, node }
}
}
#[cfg(test)]
mod tests {
use super::test_support::announced_element;
use super::*;
use gpui::{accesskit, div, Orientation, Role, Toggled};
use std::fs;
use std::path::{Path, PathBuf};
#[test]
fn a_role_and_a_name_reach_the_node() {
let announced = announced_element(
div()
.id("save")
.announce(A11y::new(Role::Button).name("Save").focusable()),
);
assert_eq!(announced.role, Some(Role::Button));
assert_eq!(announced.name(), Some("Save"));
}
#[test]
fn an_element_that_does_not_announce_has_no_node() {
let announced = announced_element(div().id("plain"));
assert_eq!(announced.role, None);
assert!(
announced.node.is_none(),
"an element with no role is not in the tree at all"
);
}
#[test]
fn an_image_announces_nothing_however_hard_it_is_asked() {
let announced = announced_element(
gpui::img(SharedString::from("nothing.png"))
.announce(A11y::new(Role::Image).name("A picture of nothing in particular")),
);
assert!(announced.id.is_none(), "no id was ever required of it");
assert_eq!(
announced.role, None,
"`Img` does not report the role it was given — wrap it in a `div().id(…)`"
);
assert!(announced.node.is_none());
}
#[test]
fn every_state_field_reaches_the_node() {
let announced = announced_element(
div().id("everything").announce(
A11y::new(Role::Slider)
.name("Volume")
.description("Playback volume")
.toggled(Toggled::Mixed)
.selected(true)
.expanded(false)
.number_value(70., 0., 100., 5.)
.orientation(Orientation::Horizontal)
.level(2)
.position_in_set(3)
.size_of_set(8)
.focusable(),
),
);
let node = announced.node.expect("a slider with an id is a node");
assert_eq!(node.label(), Some("Volume"));
assert_eq!(node.description(), Some("Playback volume"));
assert_eq!(node.toggled(), Some(Toggled::Mixed));
assert_eq!(node.is_selected(), Some(true));
assert_eq!(node.is_expanded(), Some(false));
assert_eq!(node.numeric_value(), Some(70.));
assert_eq!(node.min_numeric_value(), Some(0.));
assert_eq!(node.max_numeric_value(), Some(100.));
assert_eq!(node.numeric_value_step(), Some(5.));
assert_eq!(node.orientation(), Some(Orientation::Horizontal));
assert_eq!(node.level(), Some(2));
assert_eq!(node.position_in_set(), Some(3));
assert_eq!(node.size_of_set(), Some(8));
}
#[test]
fn an_active_descendant_is_declared_rather_than_read_back() {
let quiet = A11y::new(Role::ListBoxOption).name("Option 2");
assert!(
!quiet.is_active_descendant(),
"a row claims the active descendant only when it is told to"
);
let claiming = quiet.clone().active_descendant(true);
assert!(claiming.is_active_descendant());
assert!(
!claiming
.clone()
.active_descendant(false)
.is_active_descendant(),
"`false` is how a row stops claiming it, and there is no third state"
);
assert_eq!(
quiet,
claiming.clone().active_descendant(false),
"not claiming it and having stopped claiming it are the same announcement"
);
let announced = announced_element(
div()
.id("row")
.announce(claiming.position_in_set(2).size_of_set(3)),
);
let node = announced
.node
.expect("a listbox option with an id is a node");
assert_eq!(node.label(), Some("Option 2"));
assert_eq!(node.position_in_set(), Some(2));
assert_eq!(node.size_of_set(), Some(3));
}
#[test]
fn a_text_value_reaches_the_node() {
let announced = announced_element(
div().id("timezone").announce(
A11y::new(Role::ComboBox)
.name("Timezone")
.text_value("UTC")
.focusable(),
),
);
let node = announced.node.expect("a combo box with an id is a node");
assert_eq!(node.label(), Some("Timezone"), "the name is not the value");
assert_eq!(node.value(), Some("UTC"));
}
#[test]
fn a_click_listener_is_what_offers_the_click_action() {
let inert = announced_element(
div().id("inert").announce(
A11y::new(Role::Button)
.name("Save")
.not_focusable("this one is only here to have no click listener"),
),
);
assert!(!inert.supports(accesskit::Action::Click));
}
#[test]
fn the_name_rule_covers_the_roles_that_are_nothing_without_one() {
for role in [
Role::Button,
Role::DefaultButton,
Role::CheckBox,
Role::Switch,
Role::RadioButton,
Role::Link,
] {
assert!(
role_requires_a_name(role),
"{role:?} is a single control whose whole announcement is name + role + \
state, so without a name a screen reader reads out its state and never \
says what it acts on"
);
}
for role in [
Role::MenuItem,
Role::MenuItemCheckBox,
Role::MenuItemRadio,
Role::ListBoxOption,
Role::Tab,
Role::TreeItem,
] {
assert!(
role_requires_a_name(role),
"{role:?} is an item inside a composite: the composite is named once and \
every item still has to say which one it is, so a nameless item is an \
unidentifiable row in an otherwise navigable list"
);
}
for role in [
Role::Slider,
Role::SpinButton,
Role::ProgressIndicator,
Role::Meter,
] {
assert!(
role_requires_a_name(role),
"{role:?} announces a number, and a number with no name is a quantity of \
nothing — the name is the only part that says what is being measured"
);
}
assert!(
role_requires_a_name(Role::Splitter),
"a divider between two panes has no visible text of its own to borrow a name \
from, so its name is a constructor argument — see `src/elements/splitter.rs`"
);
for role in [
Role::ComboBox,
Role::EditableComboBox,
Role::TextInput,
Role::MultilineTextInput,
Role::SearchInput,
Role::NumberInput,
Role::PasswordInput,
Role::DateInput,
] {
assert!(
role_requires_a_name(role),
"{role:?} takes or chooses a value, and its own contents are the value \
rather than the label, so the name is the only thing that says what is \
being typed or chosen"
);
}
for role in [Role::Dialog, Role::AlertDialog] {
assert!(
role_requires_a_name(role),
"{role:?} takes over the screen, and its name is what a screen reader \
announces on arrival to say what was interrupted for"
);
}
assert!(
role_requires_a_name(Role::Image),
"an image's name is its alternative text, which is the whole of what a screen \
reader has to go on"
);
for role in [Role::Complementary, Role::Document] {
assert!(
!role_requires_a_name(role),
"{role:?} is named by what it contains, so a name is welcome but not \
compulsory — see this function's own docs"
);
}
assert!(
!role_requires_a_name(Role::ListBox),
"`src/elements/select.rs` argues this exclusion in writing: the listbox is \
named by the trigger beside it, so forcing a name on it would make every \
select announce its label twice"
);
}
#[test]
fn a_required_name_is_absent_blank_or_given() {
assert!(A11y::new(Role::Button).is_missing_a_required_name());
assert!(A11y::new(Role::Button)
.name(" ")
.is_missing_a_required_name());
assert!(!A11y::new(Role::Button)
.name("Save")
.is_missing_a_required_name());
assert!(!A11y::new(Role::Document).is_missing_a_required_name());
assert_eq!(
A11y::new(Role::Button).name("Save").accessible_name(),
Some(&"Save".into())
);
assert_eq!(A11y::new(Role::Button).role(), Role::Button);
}
#[test]
#[should_panic(expected = "announces itself by name")]
fn a_nameless_button_is_a_bug() {
let _ = div().id("nameless").announce(A11y::new(Role::Button));
}
#[test]
#[should_panic(expected = "announces itself by name")]
fn an_empty_name_is_no_name() {
let _ = div().id("blank").announce(A11y::new(Role::Button).name(""));
}
#[test]
fn no_element_calls_gpuis_a11y_builders_directly() {
let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
let files = rust_files(&src);
assert!(
files.len() > 20,
"the scan found only {} source file(s) under {}, so it is not \
guarding anything — check how the source tree is being located \
before trusting a green result here",
files.len(),
src.display()
);
for file in files {
if file == src.join("a11y.rs") {
continue;
}
let source = fs::read_to_string(&file).expect("source file is readable");
let relative = file
.strip_prefix(&src)
.unwrap_or(&file)
.display()
.to_string();
for (line, text) in a11y_builder_calls(&source) {
offenders.push(format!(" src/{relative}:{line}: {text}"));
}
}
assert!(
offenders.is_empty(),
"these elements call gpui's accessibility builders directly:\n{}\n\n\
Build a `crate::a11y::A11y` instead and apply it with `.announce(a11y)`, so that \
one place decides what an element announces. If `A11y` has no field for what you \
need, add the field and apply it in `Announce::announce` — that is the intended \
move, not a local call. See the `a11y` module docs.",
offenders.join("\n")
);
}
#[test]
fn the_focus_rule_covers_the_roles_a_keyboard_operates() {
for role in [Role::Button, Role::Link] {
assert!(
role_requires_keyboard_focus(role),
"{role:?} is activated by a keystroke on the control itself, and a \
keystroke only ever reaches the focused element"
);
}
assert!(
role_requires_keyboard_focus(Role::DefaultButton),
"a dialog's Enter key resolves to the default button, so it is the most \
focus-requiring control in the set"
);
for role in [Role::CheckBox, Role::Switch, Role::RadioButton] {
assert!(
role_requires_keyboard_focus(role),
"{role:?} holds a state the keyboard changes with Space, and that toggle \
is a keystroke that has to land on the control"
);
}
for role in [Role::Slider, Role::SpinButton] {
assert!(
role_requires_keyboard_focus(role),
"{role:?} moves its value with the arrow keys, which go to the focused \
element and nowhere else"
);
}
assert!(
role_requires_keyboard_focus(Role::Splitter),
"a splitter is a standalone control owning one tab stop whose arrow keys move \
the divider — `Slider`'s shape — so it declares focus on its `A11y` like every \
other keyboard-operable control rather than through a raw `tab_index`"
);
for role in [Role::ComboBox, Role::EditableComboBox] {
assert!(
role_requires_keyboard_focus(role),
"{role:?} delivers its popup's arrow keys to the trigger, so the trigger \
holding focus is what makes the list operable at all"
);
}
for role in [
Role::TextInput,
Role::MultilineTextInput,
Role::SearchInput,
Role::NumberInput,
Role::PasswordInput,
Role::DateInput,
] {
assert!(
role_requires_keyboard_focus(role),
"{role:?} is typed into, and typing is nothing but the focused element's \
keystrokes"
);
}
for role in [
Role::MenuItem,
Role::MenuItemCheckBox,
Role::MenuItemRadio,
Role::ListBoxOption,
Role::Tab,
Role::TreeItem,
] {
assert!(
!role_requires_keyboard_focus(role),
"{role:?} is an arrow-key target inside a composite that owns the one tab \
stop, so making each item a tab stop is the mistake the ARIA authoring \
practices call out — these join the list with a roving-focus convention"
);
}
for role in [Role::Complementary, Role::Document, Role::Group] {
assert!(
!role_requires_keyboard_focus(role),
"{role:?} is a landmark or container: it is read, not operated"
);
}
}
#[test]
fn a_focus_decision_is_taken_declined_or_missing() {
let undecided = A11y::new(Role::Button).name("Save");
assert!(!undecided.is_focusable());
assert_eq!(undecided.focus_declined_because(), None);
assert!(undecided.is_missing_a_focus_decision());
let takes = undecided.clone().focusable();
assert!(takes.is_focusable());
assert!(!takes.is_missing_a_focus_decision());
let declines = undecided.clone().not_focusable("it is disabled");
assert!(!declines.is_focusable());
assert_eq!(
declines.focus_declined_because(),
Some(&"it is disabled".into())
);
assert!(
!declines.is_missing_a_focus_decision(),
"the assertion is against silence, not against \"no\""
);
assert!(!A11y::new(Role::Complementary).is_missing_a_focus_decision());
}
#[test]
#[should_panic(expected = "says nothing about keyboard focus")]
fn a_button_that_says_nothing_about_focus_is_a_bug() {
let _ = div()
.id("mute")
.announce(A11y::new(Role::Button).name("Save"));
}
#[test]
fn a_declined_control_still_announces() {
let announced = announced_element(
div().id("off").announce(
A11y::new(Role::Button)
.name("Save")
.not_focusable("it is disabled"),
),
);
assert_eq!(announced.role, Some(Role::Button));
assert_eq!(announced.name(), Some("Save"));
}
#[gpui::test]
fn a_supplied_handle_is_made_a_tab_stop(cx: &mut gpui::TestAppContext) {
let handle = cx.update(|cx| cx.focus_handle());
assert!(!handle.tab_stop, "gpui mints handles that are not stops");
let a11y = A11y::new(Role::Button)
.name("Save")
.focus_handle(handle.clone());
assert!(a11y.is_focusable());
let _ = div().id("save").announce(a11y);
}
#[test]
fn every_element_module_declares_a_role() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let elements =
fs::read_to_string(root.join("src/elements.rs")).expect("src/elements.rs is readable");
let modules: Vec<String> = elements
.lines()
.filter_map(|line| {
line.trim()
.strip_prefix("pub mod ")?
.strip_suffix(';')
.map(str::to_string)
})
.collect();
assert!(
modules.len() > 20,
"only {} element module(s) found in src/elements.rs, so this guards nothing — \
check how the source tree is being located before trusting a green result",
modules.len(),
);
let mut silent = Vec::new();
let mut adopted = Vec::new();
for module in &modules {
let source = fs::read_to_string(root.join(format!("src/elements/{module}.rs")))
.unwrap_or_else(|error| panic!("src/elements/{module}.rs is unreadable: {error}"));
let declares = source
.lines()
.any(|line| line.contains("Accessible for") && line.contains("impl"));
let excused = ELEMENTS_WITHOUT_A_ROLE
.iter()
.any(|(name, _)| name == module);
if !declares && !excused {
silent.push(module.clone());
}
if declares && excused {
adopted.push(module.clone());
}
}
assert!(
silent.is_empty(),
"these element modules neither implement `Accessible` nor say why not: {}\n\n\
Adopt the module into `crate::a11y` — one `impl Accessible`, one `.announce(…)` \
— or add it to `ELEMENTS_WITHOUT_A_ROLE` with a reason saying what it would \
announce or what has to exist first.",
silent.join(", "),
);
assert!(
adopted.is_empty(),
"these element modules implement `Accessible` but are still excused in \
`ELEMENTS_WITHOUT_A_ROLE`: {}. Delete their entries — the list only shrinks.",
adopted.join(", "),
);
for (module, reason) in ELEMENTS_WITHOUT_A_ROLE {
assert!(
modules.iter().any(|name| name == module),
"`ELEMENTS_WITHOUT_A_ROLE` excuses `{module}`, which src/elements.rs declares \
no `pub mod` for"
);
assert!(
reason.len() >= 40,
"`{module}`'s reason is {} characters. A reason says what the module would \
announce or what has to exist first — \"not done yet\" is not one",
reason.len(),
);
}
}
fn rust_files(dir: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir).expect("source directory is readable") {
let path = entry.expect("directory entry is readable").path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|ext| ext == "rs") {
files.push(path);
}
}
}
files.sort();
files
}
fn a11y_builder_calls(source: &str) -> Vec<(usize, String)> {
let mut hits = Vec::new();
let mut in_test_module = false;
for (index, line) in source.lines().enumerate() {
if in_test_module {
if line == "}" {
in_test_module = false;
}
continue;
}
if line.starts_with("#[cfg(test)]") {
in_test_module = true;
continue;
}
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
if trimmed.contains(".role(") || trimmed.contains(".aria_") {
hits.push((index + 1, trimmed.to_string()));
}
}
hits
}
#[test]
fn the_scan_reads_builder_calls_and_not_their_lookalikes() {
let source = r#"
fn render(self) -> impl IntoElement {
div()
.id(self.id)
.role(Role::Button)
.aria_label("Save")
.aria_expanded(true)
}
fn fine(self) -> impl IntoElement {
// .role(Role::Button) in a comment
//! and in a doc comment: `.aria_label`
div().id(self.id).announce(self.a11y())
}
impl Element for Run {
fn a11y_role(&self) -> Option<Role> {
Some(self.role.a11y_role())
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_test_may_build_an_element_by_hand() {
div().id("left").role(Role::Button);
}
}
"#;
assert_eq!(
a11y_builder_calls(source)
.into_iter()
.map(|(line, _)| line)
.collect::<Vec<_>>(),
vec![5, 6, 7]
);
}
}