Skip to content

Commit 60b0cd1

Browse files
author
Roy Lin
committed
feat: project native accessibility states
1 parent 35b5a74 commit 60b0cd1

9 files changed

Lines changed: 652 additions & 15 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ object graph at the host boundary.
3434
and WAI-ARIA live-region updates through native assistive-technology APIs,
3535
independent visible and accessible labels with native `aria-label`
3636
projection, native descriptive accessibility metadata with field-level
37-
capability reporting,
37+
capability reporting, validated ARIA state tokens with field-level native
38+
capability reporting and exact AppKit/GTK4 projection where supported,
3839
decimal/percent/date/time formatting, versioned
3940
capability reporting, shared native event-source state machines, and
4041
accessibility conformance checks. Versioned
@@ -53,7 +54,8 @@ object graph at the host boundary.
5354
IME/dead-key conformance, measured overlay collision/arrow conformance and
5455
scroll locking, remaining native focus conformance, broader message catalogs,
5556
currency/unit parsing and formatting, date-range/time-zone formatting,
56-
complete native accessibility relationship/state and role-edge projection,
57+
complete native accessibility relationship and remaining state/role-edge
58+
projection,
5759
and real-platform assistive-technology fixtures are still in progress.
5860
- `.rsx` component source modules with imports, local Rust types, hook
5961
registrations, Rust selector/reducer expressions, and a final `rsx!(...)`

docs/architecture.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,9 +284,16 @@ ARIA state and live-region attributes including `aria-hidden`,
284284
`aria-autocomplete`, `aria-multiline`, `aria-current`, `aria-haspopup`,
285285
`aria-pressed`, `aria-live`, `aria-atomic`, `aria-busy`, `aria-relevant`, and
286286
`aria-modal` project into structured native accessibility state hints and stay
287-
available in metadata. `aria-hidden` is accessibility-only state and does not
288-
change native widget visibility, but `aria-hidden="true"` omits the subtree
289-
from rendered accessibility tree projection.
287+
available in metadata. State capabilities are reported field by field rather
288+
than as one aggregate claim. AppKit projects hidden and modal state through
289+
`NSAccessibility`. GTK4 projects hidden, autocomplete, multiline, popup
290+
presence, pressed, busy, and modal through `GtkAccessible`; popup subtype and
291+
`aria-current` remain portable. Generic WinUI direct-state gaps remain
292+
portable, while `ContentDialog` supplies native modal semantics. Invalid
293+
autocomplete, current, popup, and pressed tokens fail accessibility
294+
conformance. `aria-hidden` is accessibility-only state and does not change
295+
native widget visibility, but `aria-hidden="true"` omits the subtree from
296+
rendered accessibility tree projection.
290297
After the initial render, mounted live regions are diffed in stable tree order.
291298
`aria-relevant` selects additions, removals, and text changes;
292299
`aria-atomic="true"` emits the complete current accessible text; and

docs/react-aria-native.md

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -331,15 +331,26 @@ The first shared interaction milestone is available in the portable runtime:
331331
generic value-text overrides remain portable. Logical AppKit collection/tab
332332
items, GTK4 menu-model items, and the WinUI window wrapper report portable
333333
coverage instead of silently dropping unsupported native properties.
334+
- Accessibility state fields remain independent through native props, setter
335+
diffs, recording, accessibility-tree projection, and capability audits.
336+
AppKit projects `aria-hidden` and `aria-modal` through `NSAccessibility`.
337+
GTK4 projects hidden, autocomplete, multiline, popup presence, pressed,
338+
busy, and modal through `GtkAccessible`; the `aria-haspopup` subtype remains
339+
portable. WinUI retains generic direct-state fields portably, while a
340+
`ContentDialog` exposes modal semantics through its automation peer.
341+
Conformance rejects invalid autocomplete, current, popup, and pressed tokens
342+
before native adapters can reinterpret them. Logical AppKit collection/tab
343+
items and GTK4 menu-model items report portable state coverage explicitly.
334344
- Native IR capabilities are versioned. Every host exposes a feature manifest
335345
with unsupported, portable, or native support levels, role-specific
336346
overrides, and auditable capability issues. Protocol render responses carry
337347
both the manifest and concrete gaps.
338348
- Accessibility conformance validates names, focus uniqueness, selection and
339349
checked states, exclusive-container selection, relationships, and duplicate
340-
node identity. Invalid `aria-live` and `aria-relevant` values are reported as
341-
errors rather than silently changing announcement behavior. The same
342-
semantic tree assertions run against AppKit, GTK4, and WinUI planning
350+
node identity. Invalid `aria-autocomplete`, `aria-current`, `aria-haspopup`,
351+
`aria-pressed`, `aria-live`, and `aria-relevant` values are reported as
352+
errors rather than silently changing state or announcement behavior. The
353+
same semantic tree assertions run against AppKit, GTK4, and WinUI planning
343354
adapters. Mounted selection is the source of truth for accessible
344355
selected/checked state.
345356
- AppKit, GTK4, and WinUI use the same press and keyboard state machines. Their
@@ -376,6 +387,7 @@ existence of a platform object:
376387
| Live regions | Stable keyed updates implement WAI-ARIA `polite`/`assertive`, `atomic`, `relevant`, and ancestor `busy` semantics in the shared runtime. Implicit alert/status/log/output policies and nested boundaries are honored. AppKit, GTK4, and WinUI deliver the resulting typed message through their native assistive-technology APIs; headless and protocol hosts retain the same ordered command. |
377388
| Accessible names | `aria-label` is independent from visible native text and is projected through AppKit accessibility labels, GTK4 accessible label properties, and WinUI UI Automation names. Headless and protocol output retain the same computed name. The capability manifest conservatively reports portable-only coverage for AppKit logical list/tree and tab items, GTK4 `gio::MenuItem`, and the WinUI window wrapper. |
378389
| Accessible descriptions | Description, role-description, shortcut, and value-text metadata have independent executable capabilities. GTK4 projects all four. AppKit projects description, role description, and value text; WinUI projects description and shortcuts. Unsupported field/backend combinations and non-accessible logical wrappers remain available to headless/protocol consumers and produce explicit portable capability issues. |
390+
| Accessible states | Hidden, autocomplete, multiline, current, popup, pressed, live-region, busy, and modal semantics have independent executable capabilities. AppKit projects hidden and modal; GTK4 projects hidden, autocomplete, multiline, popup presence, pressed, busy, and modal. Live-region policy is evaluated by the shared runtime and delivered through each backend's native announcement channel. WinUI direct-state gaps, `aria-current`, the GTK4 popup subtype, and logical wrapper exceptions remain portable and auditable instead of being silently dropped. |
379391
| Native menu activation | AppKit and GTK4 menu items emit terminal press only because their menu models do not expose a mounted generic view event source. |
380392
| Hover and typed modality | View-backed widgets; explicit exceptions are reported for AppKit non-view wrappers/items, GTK4 menu items, and the WinUI window wrapper. |
381393
| Focus within | Portable runtime routing on AppKit, GTK4, WinUI, and headless hosts. Native blur/focus batches are linked with `relatedTarget`; direct focus callbacks remain target-only while focus-within callbacks run only when a subtree boundary is crossed. |
@@ -460,7 +472,7 @@ props:
460472
| P1 | Collections and selection | Complete IME/dead-key typeahead conformance and add real-platform fixtures for layout-aware page navigation. |
461473
| P1 | NumberField interaction | Add real-platform assistive-technology fixtures for localized labels and focused announcements. Group/input/button anatomy, localized stepper labels and role descriptions, focused native value announcements, minimum-anchored button and continuous press-and-hold stepping, Arrow/Page/Home/End keyboard semantics, focused vertical-wheel stepping with horizontal/zoom rejection and `isWheelDisabled`, mouse input-focus preservation, decimal-noise cleanup, boundary disabling, cancellation/re-entry, and native-default suppression are implemented. |
462474
| P1 | Internationalization | Expand message formatting beyond NumberField, and add currency/unit parsing and formatting plus date ranges/time zones. Reusable decimal/percent parsing and formatting, partial-input validation, locale-aware filtering, localized NumberField model/display conversion, and a 34-locale NumberField message catalog now build on inherited locale/direction. |
463-
| P1 | Accessibility conformance | Complete native relationship and state projection, close the documented logical-item/window accessible-name and description exceptions, fill the remaining AppKit shortcut and WinUI role-description/value-text gaps, and add real assistive-technology fixtures. Independent `aria-label` and field-level descriptive metadata projection, the typed announcement channel, WAI-ARIA live-region diff policy, and focused NumberField value announcements are implemented with explicit per-backend capabilities. |
475+
| P1 | Accessibility conformance | Complete native relationship projection, add exact native mappings for remaining state gaps such as `aria-current` and direct AppKit/WinUI autocomplete, multiline, popup, pressed, and busy semantics where platform APIs permit, close the documented logical-item/window name, description, state, and announcement exceptions, fill the remaining AppKit shortcut and WinUI role-description/value-text gaps, and add real assistive-technology fixtures. Independent `aria-label`, descriptive metadata, and state-field capability reporting, exact AppKit/GTK4 state projection where supported, validated ARIA state tokens, the typed announcement channel, WAI-ARIA live-region diff policy, and focused NumberField value announcements are implemented. |
464476
| P2 | Overlays | Complete measured boundary-driven collision and arrow projection, native scroll locking, configurable outside-interaction filters, multi-window layer coordination, and real-platform positioning conformance fixtures. |
465477
| P2 | Capability enforcement | Turn reported capability gaps into adapter policy and conformance gates where portable fallback is not sufficient. |
466478
| P2 | Environment style variants | Add native environment and ancestry evaluators for responsive/container, theme, group, peer, and structural selector variants. These remain preserved in the style IR but inactive at runtime today. |

src/accessibility/conformance.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ pub enum AccessibilityIssueCode {
2323
InvalidMultipleState,
2424
MultipleSelectedItems,
2525
EmptyRelationship,
26+
InvalidAutocomplete,
27+
InvalidCurrent,
28+
InvalidHasPopup,
29+
InvalidPressed,
2630
InvalidLiveRegionPoliteness,
2731
InvalidLiveRegionRelevant,
2832
}
@@ -155,6 +159,58 @@ fn validate_node<'a>(
155159
});
156160
}
157161
}
162+
if node
163+
.state
164+
.autocomplete
165+
.as_deref()
166+
.is_some_and(|value| !is_valid_autocomplete(value))
167+
{
168+
push_error(
169+
issues,
170+
node,
171+
AccessibilityIssueCode::InvalidAutocomplete,
172+
"aria-autocomplete must be none, inline, list, or both".to_string(),
173+
);
174+
}
175+
if node
176+
.state
177+
.current
178+
.as_deref()
179+
.is_some_and(|value| !is_valid_current(value))
180+
{
181+
push_error(
182+
issues,
183+
node,
184+
AccessibilityIssueCode::InvalidCurrent,
185+
"aria-current must be false, true, page, step, location, date, or time".to_string(),
186+
);
187+
}
188+
if node
189+
.state
190+
.has_popup
191+
.as_deref()
192+
.is_some_and(|value| !is_valid_has_popup(value))
193+
{
194+
push_error(
195+
issues,
196+
node,
197+
AccessibilityIssueCode::InvalidHasPopup,
198+
"aria-haspopup must be false, true, menu, listbox, tree, grid, or dialog".to_string(),
199+
);
200+
}
201+
if node
202+
.state
203+
.pressed
204+
.as_deref()
205+
.is_some_and(|value| !is_valid_pressed(value))
206+
{
207+
push_error(
208+
issues,
209+
node,
210+
AccessibilityIssueCode::InvalidPressed,
211+
"aria-pressed must be false, true, or mixed".to_string(),
212+
);
213+
}
158214
if node
159215
.state
160216
.live
@@ -187,6 +243,34 @@ fn validate_node<'a>(
187243
}
188244
}
189245

246+
fn is_valid_autocomplete(value: &str) -> bool {
247+
matches!(
248+
value.trim().to_ascii_lowercase().as_str(),
249+
"none" | "inline" | "list" | "both"
250+
)
251+
}
252+
253+
fn is_valid_current(value: &str) -> bool {
254+
matches!(
255+
value.trim().to_ascii_lowercase().as_str(),
256+
"false" | "true" | "page" | "step" | "location" | "date" | "time"
257+
)
258+
}
259+
260+
fn is_valid_has_popup(value: &str) -> bool {
261+
matches!(
262+
value.trim().to_ascii_lowercase().as_str(),
263+
"false" | "true" | "menu" | "listbox" | "tree" | "grid" | "dialog"
264+
)
265+
}
266+
267+
fn is_valid_pressed(value: &str) -> bool {
268+
matches!(
269+
value.trim().to_ascii_lowercase().as_str(),
270+
"false" | "true" | "mixed"
271+
)
272+
}
273+
190274
fn is_valid_live_politeness(value: &str) -> bool {
191275
matches!(
192276
value.trim().to_ascii_lowercase().as_str(),

src/accessibility/conformance_tests.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,25 @@ fn conformance_rejects_invalid_live_region_tokens() {
9898
.iter()
9999
.any(|issue| issue.code == AccessibilityIssueCode::InvalidLiveRegionRelevant));
100100
}
101+
102+
#[test]
103+
fn conformance_rejects_invalid_accessibility_state_tokens() {
104+
let mut stateful = node(AccessibilityRole::Button);
105+
stateful.label = Some("Stateful".to_string());
106+
stateful.state.autocomplete = Some("automatic".to_string());
107+
stateful.state.current = Some("yesterday".to_string());
108+
stateful.state.has_popup = Some("sheet".to_string());
109+
stateful.state.pressed = Some("indeterminate".to_string());
110+
111+
let report = AccessibilityConformanceReport::validate(&stateful);
112+
113+
assert!(!report.is_conformant());
114+
for code in [
115+
AccessibilityIssueCode::InvalidAutocomplete,
116+
AccessibilityIssueCode::InvalidCurrent,
117+
AccessibilityIssueCode::InvalidHasPopup,
118+
AccessibilityIssueCode::InvalidPressed,
119+
] {
120+
assert!(report.issues.iter().any(|issue| issue.code == code));
121+
}
122+
}

src/appkit_native/update.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ impl AppKitNativeSurface {
7575
NativeWidgetSetter::SetAccessibilityDescription(value) => {
7676
set_appkit_accessibility_description(&handle.widget, value);
7777
}
78+
NativeWidgetSetter::SetAccessibilityState(value) => {
79+
set_appkit_accessibility_state(&handle.widget, value);
80+
}
7881
NativeWidgetSetter::SetWindowResizable(value) => {
7982
if let AppKitOsWidget::Window(window) = &handle.widget {
8083
let mut style = window.styleMask();
@@ -610,7 +613,6 @@ impl AppKitNativeSurface {
610613
| NativeWidgetSetter::SetHtmlCollection(_)
611614
| NativeWidgetSetter::SetAccessibilityRelationships(_)
612615
| NativeWidgetSetter::SetAccessibilityStructure(_)
613-
| NativeWidgetSetter::SetAccessibilityState(_)
614616
| NativeWidgetSetter::SetWebStyle(_)
615617
| NativeWidgetSetter::SetEvents(_)
616618
| NativeWidgetSetter::SetMetadata(_) => {}
@@ -668,3 +670,30 @@ fn set_appkit_accessibility_description(
668670
}
669671
}
670672
}
673+
674+
fn set_appkit_accessibility_state(
675+
widget: &AppKitOsWidget,
676+
value: &crate::accessibility::AccessibilityStateProps,
677+
) {
678+
macro_rules! apply {
679+
($target:expr) => {{
680+
let target = $target;
681+
target.setAccessibilityHidden(value.hidden.unwrap_or(false));
682+
target.setAccessibilityModal(value.modal.unwrap_or(false));
683+
}};
684+
}
685+
686+
match widget {
687+
AppKitOsWidget::Window(window) => apply!(window),
688+
AppKitOsWidget::Panel(panel) => apply!(panel),
689+
AppKitOsWidget::Popover(state) => apply!(&state.popover),
690+
AppKitOsWidget::Menu(menu) => apply!(menu),
691+
AppKitOsWidget::MenuItem(item) => apply!(item),
692+
AppKitOsWidget::ComboBoxItem(_) | AppKitOsWidget::TabViewItem(_) => {}
693+
_ => {
694+
if let Some(view) = widget.as_view() {
695+
apply!(view);
696+
}
697+
}
698+
}
699+
}

0 commit comments

Comments
 (0)