diff --git a/ui/dr-ui/tests/ui_controls_are_accessible.rs b/ui/dr-ui/tests/ui_controls_are_accessible.rs new file mode 100644 index 0000000..15d7ada --- /dev/null +++ b/ui/dr-ui/tests/ui_controls_are_accessible.rs @@ -0,0 +1,317 @@ +// TRACES: NFR-A11Y-2 +//! Every shared control says what it is, and every slider says what it edits. +//! +//! NFR-A11Y-2 asks that controls expose names, roles and values to AT-SPI and +//! TalkBack. Almost none of that is Rust: it is `accessible-*` properties in +//! Slint markup, which no unit test can observe by running the interface — +//! there is no accessibility tree without a window, and CI has no display. +//! +//! So this reads the markup, the way `darkroom-android`'s manifest test reads +//! the XML that aapt2 owns and rustc never sees. The property being defended +//! is not "the screen reader announces the right words", which needs a device +//! and a person; it is the thing that silently regresses instead — **a control +//! losing its role or its name in an ordinary refactor**, which compiles, +//! renders identically, passes every other test, and is invisible to anyone +//! not using a screen reader. That failure has already happened once here: the +//! whole application had five `accessible-*` lines in 14,482 lines of markup, +//! all of them on one row of the colour mixer, and nothing said so. +//! +//! ## What is checked, and what deliberately is not +//! +//! Two properties, both structural: +//! +//! 1. **Named components declare the roles they promise.** A `Button` that +//! stops declaring `accessible-role` stops existing for a screen reader +//! entirely, because Slint rejects every other `accessible-*` property that +//! is not accompanied by a role — so the role is the load-bearing one and +//! the rest fail with it. +//! +//! 2. **Every `SliderTrack` instantiation supplies a `label`.** This is the +//! one that catches a *new* control rather than a broken old one. The track +//! cannot name itself — it has no idea what number it is dragging — so an +//! unnamed one announces "slider, 0.35" and is the single most-used control +//! in the application. A track added without a label is the exact mistake +//! this file exists to make loud. +//! +//! Not checked: whether the words are good, whether they are translated, +//! whether contrast passes, or whether Android exposes any of it — spike S13 +//! is still unrun and no test on this side of it can answer that. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// A component that must carry particular `accessible-*` properties, and the +/// properties it must carry. +/// +/// Only the ones whose absence is a behavioural loss are listed. `Button` must +/// declare `accessible-action-default` because without it a screen reader can +/// read the button and not press it, which is a control that exists and cannot +/// be used; it need not declare `accessible-description`, which is a nicety. +struct Promise { + file: &'static str, + component: &'static str, + properties: &'static [&'static str], +} + +const PROMISES: &[Promise] = &[ + Promise { + file: "widgets.slint", + component: "Button", + properties: &["accessible-role", "accessible-label", "accessible-action-default"], + }, + Promise { + file: "widgets.slint", + component: "IconButton", + properties: &["accessible-role", "accessible-label", "accessible-action-default"], + }, + Promise { + file: "widgets.slint", + component: "FilterChip", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-checked", + "accessible-action-default", + ], + }, + Promise { + file: "widgets.slint", + component: "Section", + properties: &["accessible-role", "accessible-label", "accessible-expanded"], + }, + Promise { + file: "widgets.slint", + component: "ProgressBar", + properties: &["accessible-role", "accessible-label", "accessible-value"], + }, + // The label goes on the `TextInput` inside, not on the box around it — + // Slint gives that element its role, so the name belongs beside it. + Promise { + file: "widgets.slint", + component: "Field", + properties: &["accessible-label", "accessible-placeholder-text"], + }, + Promise { + file: "controls.slint", + component: "SliderTrack", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-value", + "accessible-value-minimum", + "accessible-value-maximum", + "accessible-action-increment", + "accessible-action-decrement", + "accessible-action-set-value", + ], + }, + Promise { + file: "controls.slint", + component: "Check", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-checked", + "accessible-action-default", + ], + }, + Promise { + file: "controls.slint", + component: "ChoiceChip", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-checked", + "accessible-action-default", + ], + }, +]; + +fn ui_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("ui") +} + +fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) +} + +/// The body of one top-level component declaration, or `None` if the file +/// declares no such component. +/// +/// Every component in these files is declared at column zero, so the block +/// runs from its header to the next header or to the end of the file. A +/// looser rule than brace matching and a stricter one than "somewhere in the +/// file": the point is that a property found for `Button` really is inside +/// `Button` and not inside the component below it. +fn component_body<'a>(source: &'a str, name: &str) -> Option<&'a str> { + let header_starts = |line: &str| { + line.starts_with("component ") || line.starts_with("export component ") + }; + + let mut start = None; + for (offset, line) in line_offsets(source) { + if !header_starts(line) { + continue; + } + // `component Foo inherits Bar {` — the word after `component`. + let declared = line + .trim_start_matches("export ") + .trim_start_matches("component ") + .split_whitespace() + .next(); + + match start { + None if declared == Some(name) => start = Some(offset), + Some(from) => return Some(&source[from..offset]), + None => {} + } + } + start.map(|from| &source[from..]) +} + +/// Each line of `source` with its byte offset. +fn line_offsets(source: &str) -> impl Iterator { + let mut offset = 0; + source.lines().map(move |line| { + let at = offset; + offset += line.len() + 1; + (at, line) + }) +} + +/// Whether a block sets a property — the name at the start of a line, followed +/// by `:` for a property or `=>` for a callback. +/// +/// Anchored at the start of the trimmed line so that a *mention* in the prose +/// above a component does not count. These files carry more comment than code, +/// and several of those comments name the very properties asserted here; a +/// substring search would pass on the strength of an explanation of the thing +/// that had been deleted, which is the failure `ui_names_no_operation.rs` +/// documents at length and the android manifest test strips comments to avoid. +fn sets(block: &str, property: &str) -> bool { + block.lines().any(|line| { + let line = line.trim_start(); + line.strip_prefix(property) + .is_some_and(|rest| rest.starts_with(':') || rest.trim_start().starts_with("=>")) + }) +} + +#[test] +fn shared_controls_declare_their_roles() { + let ui = ui_dir(); + let mut failures = Vec::new(); + + for promise in PROMISES { + let path = ui.join(promise.file); + let source = read(&path); + + let Some(body) = component_body(&source, promise.component) else { + failures.push(format!( + " {} declares no component `{}` — it was renamed or removed, and \ + this test then checks nothing", + promise.file, promise.component + )); + continue; + }; + + for property in promise.properties { + if !sets(body, property) { + failures.push(format!( + " {}: `{}` does not set `{property}`", + promise.file, promise.component + )); + } + } + } + + assert!( + failures.is_empty(), + "\n\nNFR-A11Y-2: controls expose names, roles and values to the platform \ + accessibility layer.\n\n{}\n\n\ + Slint rejects every `accessible-*` property that is not accompanied by an \ + `accessible-role`, so the role is what makes the control exist for AT-SPI \ + and TalkBack at all, and the actions are what make it usable rather than \ + merely readable.\n\n\ + These live on the shared components rather than at the call sites \ + deliberately: a control annotated where it is used is a control unnamed \ + everywhere it is used next. See the preamble to widgets.slint.\n", + failures.join("\n") + ); +} + +#[test] +fn every_slider_track_is_named() { + let ui = ui_dir(); + let mut files: Vec = fs::read_dir(&ui) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", ui.display())) + .map(|entry| entry.expect("read dir entry").path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("slint")) + .collect(); + files.sort(); + + let mut instantiations = 0usize; + let mut unnamed = Vec::new(); + + for path in &files { + let source = read(path); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + + // The declaration itself, in controls.slint, is not an instantiation. + for (offset, line) in line_offsets(&source) { + if line.trim_start() != "SliderTrack {" { + continue; + } + instantiations += 1; + + let block = &source[offset..offset + block_len(&source[offset..])]; + if !sets(block, "label") { + let number = source[..offset].lines().count() + 1; + unnamed.push(format!(" {name}:{number}")); + } + } + } + + // A scan that found nothing passes for the wrong reason. Four tracks are + // instantiated today — the develop panel's three shapes and the settings + // page's row — and the floor sits below that and well above zero, so + // removing one is a code change and not a silent scan failure. + assert!( + instantiations >= 3, + "found only {instantiations} `SliderTrack` instantiations — the scan is \ + matching nothing, and a scan over nothing passes" + ); + + assert!( + unnamed.is_empty(), + "\n\nNFR-A11Y-2: a slider that does not say what it adjusts.\n\n{}\n\n\ + `SliderTrack` cannot name itself — it is handed four numbers and knows \ + nothing about what they mean — so a track without a `label` announces as \ + \"slider, 0.35\". The develop column holds thirty-six of them in the \ + colour mixer alone, which is thirty-six controls a screen reader cannot \ + tell apart.\n\n\ + Pass the same string the row is already drawing above the track.\n", + unnamed.join("\n") + ); +} + +/// The length of the brace-delimited block beginning at the start of `rest`, +/// including both braces. +/// +/// Braces inside string literals are not handled, and do not occur inside a +/// `SliderTrack` instantiation — the only strings there are labels and units. +fn block_len(rest: &str) -> usize { + let mut depth = 0usize; + for (i, c) in rest.char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return i + 1; + } + } + _ => {} + } + } + rest.len() +} diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index 17dbaf1..ae2a438 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -140,6 +140,18 @@ component GroupHeading inherits Rectangle { width: 34px; visible: root.has-reset; + // A `Caption` is a `Text`, which Slint announces as text — so + // without this the reset reads as the word "reset" sitting beside + // the heading rather than as something that can be pressed. + accessible-role: button; + accessible-label: "Reset"; + accessible-enabled: root.has-reset; + accessible-action-default => { + if (root.has-reset) { + root.reset(); + } + } + reset-touch := TouchArea { width: 100%; height: max(parent.height, Theme.touch-target); @@ -202,6 +214,18 @@ component ParamSlider inherits Rectangle { modified: root.data.value != root.data.default-value; SliderTrack { + // The same two strings `ControlRow` is drawing above the track. + // Drawn text and announced text are separate channels — Slint + // associates neither with the control on its own — so the label a + // sighted user reads and the label a screen reader hears come from + // one expression each, rather than the second being left empty. + label: root.data.param-label; + readout: Readout.of(root.data); + // The descriptor's declared precision, as a step: a parameter in + // whole units nudges by one and one in stops by a hundredth, + // which is the same quantum the readout is rounded to. + step: root.data.precision == 0 ? 1.0 : 0.01; + value: root.data.value; default-value: root.data.default-value; minimum: root.data.minimum; @@ -245,12 +269,18 @@ component FacetHeading inherits Rectangle { // most of a screen of scrolling with the band name absent from every row of it // anyway. // -// **The name is not thrown away, it moves.** It is the row's accessible label, -// so a screen reader says "Orange" where the eye reads the colour, and the -// catalogue in `labels.rs` is where the mapping is written down for anyone who -// cannot separate two squares by eye. A row identified by colour *alone* +// **The name is not thrown away, it moves.** It becomes the track's accessible +// label, so a screen reader says "Orange" where the eye reads the colour, and +// the catalogue in `labels.rs` is where the mapping is written down for anyone +// who cannot separate two squares by eye. A row identified by colour *alone* // would be a control some photographers could not use, which is why the // spoken name is part of the design and not an afterthought. +// +// It used to be announced on this row, with the track below it silent. Now +// that every `SliderTrack` names itself (NFR-A11Y-2) the two would nest — a +// slider inside a slider, the outer one carrying the value and the inner one +// carrying the actions that can change it — so the row stands down and hands +// the same three strings to the control that owns the gesture. component SwatchSlider inherits Rectangle { in property data; callback changed(float); @@ -264,12 +294,6 @@ component SwatchSlider inherits Rectangle { // and the gestures behind it (FR-UI-3). height: Theme.touch-target / 2 + 4px; - accessible-role: slider; - accessible-label: root.data.param-label; - accessible-value: Readout.of(root.data); - accessible-value-minimum: root.data.minimum; - accessible-value-maximum: root.data.maximum; - HorizontalLayout { padding-left: Theme.gap-sm; spacing: Theme.gap-sm; @@ -284,6 +308,8 @@ component SwatchSlider inherits Rectangle { SliderTrack { horizontal-stretch: 1; + label: root.data.param-label; + readout: Readout.of(root.data); value: root.data.value; default-value: root.data.default-value; minimum: root.data.minimum; @@ -346,6 +372,10 @@ component PlainSlider inherits Rectangle { modified: root.value != root.default-value; SliderTrack { + label: root.label; + readout: (Math.round(root.value * 10) / 10) + root.unit; + step: 0.1; + value: root.value; default-value: root.default-value; minimum: root.minimum; @@ -575,16 +605,26 @@ export component GeometryPanel inherits Rectangle { // Rotation and flips. Icons rather than labels: four controls // named in words would wrap the 280px column, and each of // these shows its own result. + // + // The words are still written, once each, as `label` — the width + // argument is about the column, and a screen reader has no column. + // The two flips also declare themselves checkable, which + // `IconButton` cannot do on its own: `active` says a toggle is on + // and says nothing at all about whether an inactive control is a + // toggle that is off, so only these call sites know that the two + // rotations are not toggles and these two are. HorizontalLayout { spacing: Theme.gap-sm; IconButton { icon: "rotate-ccw"; + label: "Rotate left"; enabled: root.enabled; clicked => { root.rotate(-1); } } IconButton { icon: "rotate-cw"; + label: "Rotate right"; enabled: root.enabled; clicked => { root.rotate(1); } } @@ -593,13 +633,19 @@ export component GeometryPanel inherits Rectangle { IconButton { icon: "flip-h"; + label: "Flip horizontally"; active: root.flip-h; + accessible-checkable: true; + accessible-checked: root.flip-h; enabled: root.enabled; clicked => { root.flip-h-toggled(); } } IconButton { icon: "flip-v"; + label: "Flip vertically"; active: root.flip-v; + accessible-checkable: true; + accessible-checked: root.flip-v; enabled: root.enabled; clicked => { root.flip-v-toggled(); } } diff --git a/ui/dr-ui/ui/controls.slint b/ui/dr-ui/ui/controls.slint index 0282927..c3b1296 100644 --- a/ui/dr-ui/ui/controls.slint +++ b/ui/dr-ui/ui/controls.slint @@ -22,6 +22,16 @@ // primitives take plain numbers and strings, and the ParamRow-shaped wrappers // stay in the panel that owns the model. This is the constraint that makes the // file reusable, so it is worth stating rather than merely observing. +// +// **Accessibility follows the same rule as behaviour** (NFR-A11Y-2, and see +// widgets.slint's preamble for the two Slint constraints that shape it): a +// control's role, and the actions assistive technology can invoke on it, are +// written once here. Its *name* is the one thing that cannot be — a track has +// no idea what number it is dragging — so every primitive below takes a +// `label`, and the wrappers that do know pass it down. An unnamed control is +// the failure mode that matters: a screen reader announcing "slider, 0.35" for +// each of the thirty-six controls in the colour mixer has told the user +// nothing at all. import { Theme } from "theme.slint"; import { Icon, Label, Value, Caption, Field } from "widgets.slint"; @@ -49,6 +59,30 @@ export component SliderTrack inherits Rectangle { in property minimum; in property maximum; + /// What this track adjusts. The track's accessible name. + /// + /// Every wrapper already draws this word somewhere — `ControlRow` puts it + /// above, `FieldRow` puts it above and to the left — and none of those + /// placements associates it with the control as far as the platform is + /// concerned. `SwatchSlider` is the case that makes the point: it draws no + /// word at all, only a coloured square, and its name has *always* had to + /// travel this way. + in property label; + /// The value as the user should hear it, already formatted. + /// + /// Empty falls back to the raw number, which is right for a track whose + /// caller has nothing better; a caller with a declared precision and a + /// unit hands over what it is drawing, so "+1.25 EV" is announced rather + /// than "1.2500000298". + /// + /// A string rather than a float for the reason `ControlRow.readout` gives: + /// precision belongs to whoever owns the value, and a control that rounded + /// on its own would announce a parameter one way and draw it another. + in property readout; + /// How far one assistive-technology nudge moves the value. Zero takes a + /// hundredth of the range, which is the resolution a drag has anyway. + in property step: 0; + /// Live, once per movement. For anything that should follow the drag: a /// readout, a preview, the image itself. callback changed(float); @@ -76,6 +110,47 @@ export component SliderTrack inherits Rectangle { // by nothing and put every position at infinity. property span: max(0.000001, root.maximum - root.minimum); + property nudge: root.step > 0 ? root.step : root.span / 100; + + // **One nudge is a whole gesture, so it commits.** + // + // The two callbacks exist because a drag is many movements and one + // decision (see `committed` above). An arrow key pressed once is both at + // the same time: there is no stream to debounce and no release to wait + // for, so a nudge that only fired `changed` would move the photograph and + // never be saved by any caller that listens for the end of a drag — which + // is every settings-shaped caller in the application. + function move-to(v: float) { + root.changed(clamp(v, root.minimum, root.maximum)); + root.committed(clamp(v, root.minimum, root.maximum)); + } + + // **The only route to this control that is not a pointer.** ui-navigation + // D-N2 rules out hover as the sole affordance; a control reachable only by + // dragging it is the same objection with the pointer itself as the + // modifier. These three actions are what AT-SPI and TalkBack drive a + // slider with, and they are what makes the track adjustable rather than + // merely readable. + accessible-role: slider; + accessible-label: root.label; + // Both arms of the ternary must be strings — the empty concatenation is + // what makes the fallback one. + accessible-value: root.readout != "" ? root.readout : (root.value + ""); + accessible-value-minimum: root.minimum; + accessible-value-maximum: root.maximum; + accessible-value-step: root.nudge; + accessible-action-increment => { root.move-to(root.value + root.nudge); } + accessible-action-decrement => { root.move-to(root.value - root.nudge); } + accessible-action-set-value(v) => { + // `is-float()` for the reason `NumberField` gives at length: + // `to-float()` answers 0 for a string it could not parse, and a + // screen reader handing over "abc" would silently set the exposure to + // zero rather than reject the entry. + if (v.is-float()) { + root.move-to(v.to-float()); + } + } + // **Why hover, and not the drag itself.** // // A Flickable does not merely compete for a gesture, it *withholds* the @@ -258,6 +333,9 @@ export component NumberField inherits Rectangle { in property value; in property minimum; in property maximum; + /// What the number means. Handed straight to the entry, which is where the + /// accessibility tree wants it — see `Field.label`. + in property label; /// Decimal places shown, and the precision an entry is held to. Zero for a /// count, two for a value in stops — the same figure a parameter /// descriptor declares. @@ -303,6 +381,7 @@ export component NumberField inherits Rectangle { field := Field { width: 100%; height: 100%; + label: root.label; text <=> root.text; // Committed on Enter *and* on losing focus, matching `TextRow`: Enter // alone loses the edit the moment the user clicks the next control, @@ -344,6 +423,21 @@ export component Check inherits Rectangle { callback toggled(bool); + // The hint becomes the description rather than part of the name. It exists + // to say what a setting *costs* — "location is stripped", "upscaling is + // off" — which is the second thing a reader wants and never the first, and + // a name that carried it would read the whole sentence back on every pass + // through the page. + accessible-role: checkbox; + accessible-label: root.label; + accessible-description: root.hint; + accessible-checkable: true; + accessible-checked: root.checked; + accessible-action-default => { + root.checked = !root.checked; + root.toggled(root.checked); + } + height: max(row.preferred-height, Theme.control-height); touch := TouchArea { @@ -408,6 +502,31 @@ export component ChoiceChip inherits Rectangle { callback clicked(); + // `radio-button` and not `button`, because single-selection over a fixed + // list is what a radio button *is* — and the difference is audible: a + // reader announcing "radio button, selected" has told the user that + // picking another one will unpick this, which "button, pressed" has not. + // It is the same distinction the prose above draws against `FilterChip`, + // said in the vocabulary the platform already has a word for. + // + // **No `radio-group` around them.** The role exists, and the natural home + // for it — `Segmented` — is a `VerticalLayout` rather than the plain root + // Slint's own `RadioGroupBase` carries it on, and `Segmented` delegates to + // `ChipGrid` for a wrapped set, so the group would either sit on a layout + // element or be declared twice and nest. The group's name still reaches a + // reader: `FieldRow` draws it as a `Text`, which Slint exposes on its own, + // immediately before the chips in traversal order. + accessible-role: radio-button; + accessible-label: root.label; + accessible-enabled: root.enabled; + accessible-checkable: true; + accessible-checked: root.selected; + accessible-action-default => { + if (root.enabled) { + root.clicked(); + } + } + height: Theme.control-height; // Wide enough that a one-word label is still a comfortable target, which // is what `control-min-width` exists for — but chips sit several to a row, @@ -612,6 +731,7 @@ export component TextRow inherits VerticalLayout { field := Field { width: 100%; + label: root.label; text <=> root.text; placeholder: root.placeholder; // Committed on Enter *and* on losing focus. Enter alone loses @@ -755,6 +875,14 @@ export component SliderRow inherits VerticalLayout { // tall where the track is half of one. y: (parent.height - self.height) / 2; + label: root.label; + // A declared precision is a declared step — the argument above, + // reused. The nudge an assistive technology makes is therefore the + // same quantum a drag snaps to, so arrowing to a value and + // dragging to it produce the same number rather than two that + // differ in the last place. + step: 1.0 / root.step-factor; + value: root.live; default-value: root.default-value; minimum: root.minimum; @@ -768,6 +896,7 @@ export component SliderRow inherits VerticalLayout { NumberField { // Follows the drag, so the number and the handle never disagree. value: root.live; + label: root.label; minimum: root.minimum; maximum: root.maximum; precision: root.precision; diff --git a/ui/dr-ui/ui/identity.slint b/ui/dr-ui/ui/identity.slint index 7899f75..ace70f6 100644 --- a/ui/dr-ui/ui/identity.slint +++ b/ui/dr-ui/ui/identity.slint @@ -155,12 +155,19 @@ component FaceCell inherits Rectangle { // judgement, and the two are never conflated. A // rejection is remembered, so the face is not suggested // for that person again. + // The gesture note above is the whole label: a tick and a cross + // are only "confirm" and "reject" to someone who can see the + // suggestion they sit beside, and `IconButton`'s fallback would + // announce them as "check" and "cross" — two icon names that say + // nothing about which person is being ruled on. if !face.confirmed: IconButton { icon: "check"; + label: "Confirm this face"; clicked => { root.confirm(); } } if !face.confirmed: IconButton { icon: "cross"; + label: "Reject this face"; clicked => { root.reject(); } } if face.confirmed: Text { @@ -750,6 +757,7 @@ export component IdentityScreen inherits Rectangle { Rectangle { } IconButton { icon: "rotate-cw"; + label: "Recheck coverage"; clicked => { root.check-coverage(); } } } diff --git a/ui/dr-ui/ui/widgets.slint b/ui/dr-ui/ui/widgets.slint index 18c32a6..e7ef6c2 100644 --- a/ui/dr-ui/ui/widgets.slint +++ b/ui/dr-ui/ui/widgets.slint @@ -13,6 +13,21 @@ // `surface` is; only this file says what a *panel heading* is — and until it // did, four screens each re-derived one, which is exactly how a single accent // colour reached forty call sites with no single place to change it. +// +// **The same argument decides where accessibility lives** (NFR-A11Y-2). What +// AT-SPI and TalkBack are handed — a role, a name, a value, and an action they +// can invoke — is a property of *what a control is*, not of where it happens +// to be used, so it is declared once here and at the call site only where the +// call site knows something the component cannot. A button annotated in forty +// places is a button unnamed in thirty-nine of them, which is the failure this +// file was written to stop, one layer down. +// +// Two Slint rules shape what that can look like. `accessible-role` must be a +// *constant* — a ternary over a runtime property is a compile error — so a +// component that would need two roles is two components. And every other +// `accessible-*` property is rejected unless a role is set beside it or on the +// element it inherits from; that inheritance is what lets a call site add +// `accessible-checkable` to a `Button` whose role was set here. import { Theme } from "theme.slint"; import { Icon } from "icons.slint"; @@ -42,10 +57,31 @@ export component Button inherits Rectangle { /// Sustained state — a toggle that is currently on, not a press. The same /// meaning [`IconButton`] gives it, so a labelled toggle and an icon /// toggle read alike. + /// + /// **Deliberately not exposed to assistive technology from here.** Only + /// the call site knows whether a button that is currently *not* active is + /// a toggle that is off or an ordinary button that has no such state, and + /// announcing every button in the application as an unpressed toggle is + /// worse than announcing none of them. A call site that means a toggle + /// says so — `accessible-checkable: true; accessible-checked: ;` — + /// which Slint permits because the role below is inherited. in property active: false; callback clicked(); + accessible-role: button; + accessible-label: root.text; + accessible-enabled: root.enabled; + // The action a screen reader invokes, and it goes around the TouchArea + // rather than through it — so the `enabled` gate the TouchArea applies to + // a pointer has to be applied again here, or a disabled button would be + // pressable by exactly the users who cannot see that it is greyed out. + accessible-action-default => { + if (root.enabled) { + root.clicked(); + } + } + height: Theme.control-height; // A minimum rather than a fixed width: callers that set `width` or hand // this to a stretching layout still win, and a long label is not clipped. @@ -108,12 +144,40 @@ export component Button inherits Rectangle { export component IconButton inherits Rectangle { /// A name from the [`Icon`] vocabulary. in property icon; + /// What the drawing means, in words. + /// + /// A `Button` gets its accessible name for nothing, out of the text it was + /// already drawing. This control draws no text at all, so the name has to + /// be given — and an icon button without one is not a degraded experience + /// for a screen-reader user, it is an unusable one. + /// + /// Left empty it falls back to the icon's own name, which is a bad label + /// ("chevron-right") and still a better answer than silence. That is the + /// bargain `labels::resolve` already strikes for a key nobody has + /// catalogued, and it is struck here for the same reason: a control added + /// today should be reachable before someone has written its word. + in property label; in property enabled: true; /// Sustained state — a toggle that is currently on, not a press. + /// + /// Not announced from here, for the reason [`Button`]'s copy of this + /// property gives: the component cannot tell an off toggle from a button + /// with no state, so the call site that means a toggle sets + /// `accessible-checkable: true` and `accessible-checked` beside its + /// `active`. in property active: false; callback clicked(); + accessible-role: button; + accessible-label: root.label != "" ? root.label : root.icon; + accessible-enabled: root.enabled; + accessible-action-default => { + if (root.enabled) { + root.clicked(); + } + } + width: Theme.control-height; height: Theme.control-height; horizontal-stretch: 0; @@ -178,6 +242,21 @@ export component FilterChip inherits Rectangle { callback clicked(); + // Unlike [`Button`], this one *can* say it is a toggle without asking the + // call site, because being one is the whole of what distinguishes it — + // "it is *state*, not an action", two paragraphs up. So `checkable` is + // unconditional and an inactive chip announces as unpressed rather than + // as an ordinary button. + // + // The count is not folded into the name. It is drawn as a `Text`, which + // Slint already exposes as its own node, so a reader reaches it by moving + // one step further rather than by hearing a bare number glued to a word. + accessible-role: button; + accessible-label: root.label; + accessible-checkable: true; + accessible-checked: root.active; + accessible-action-default => { root.clicked(); } + height: Theme.control-height - 4px; // A floor on a content-sized chip, expressed as one property: Slint rejects // `width` and `min-width` together, and the floor is what keeps a chip @@ -304,6 +383,15 @@ export component Section inherits Rectangle { /// Whether this section's contents can be reset at all. in property has-reset: true; + accessible-role: groupbox; + accessible-label: root.title; + accessible-expandable: true; + accessible-expanded: root.expanded; + accessible-action-expand => { + root.expanded = !root.expanded; + root.toggled(root.expanded); + } + background: transparent; // Own height comes from the layout below, so a collapsed section shrinks // to its header. @@ -372,6 +460,22 @@ export component Section inherits Rectangle { Rectangle { width: 28px; + // The reset is drawn only under the pointer, which + // ui-navigation.md D-N2 rules out as the *only* route to + // a control. Announcing it whenever it is live gives a + // screen reader the route the ink withholds — so this is + // not a translation of the visual affordance so much as + // the honest version of it, and the gate is `has-reset && + // modified` rather than the hover the `Text` below adds. + accessible-role: button; + accessible-label: "Reset"; + accessible-enabled: root.has-reset && root.modified; + accessible-action-default => { + if (root.has-reset && root.modified) { + root.op-reset(); + } + } + reset-touch := TouchArea { // Sits after `header-touch` in the tree, so it takes // the press first and the section does not toggle out @@ -598,6 +702,20 @@ export component Panel inherits Rectangle { // token is for. export component Field inherits Rectangle { in-out property text; + /// What this entry is for, in words. + /// + /// The visible caption belongs to whatever row wraps the field — `TextRow` + /// draws one above, the launch screen draws one beside — and none of those + /// is a thing Slint associates with the entry on its own. So the name is + /// carried a second time, here, where the accessibility tree can attach it + /// to the control the user is actually typing into. + /// + /// That second copy is not redundant even where the caption renders. It is + /// the *only* copy where the caption does not: `TextRow`'s label draws + /// behind its own field on the settings page and has done since 0.9.0, so + /// a sighted user reading that page today has less to go on than a screen + /// reader does. + in property label; in property placeholder; /// Masks the entry, for a credential that should not be readable over the /// user's shoulder. The placeholder still shows while the field is empty. @@ -655,6 +773,13 @@ export component Field inherits Rectangle { input := TextInput { text <=> root.text; edited => { root.edited(self.text); } + // Slint gives a TextInput its role, its value, its enabled state and + // its set-value action for free; the name and the placeholder are the + // two it cannot guess. They go on the entry rather than on the box + // around it so there is one node in the tree and not a nameless + // rectangle wrapping a nameless input. + accessible-label: root.label; + accessible-placeholder-text: root.placeholder; color: Theme.ink; font-size: Theme.text; vertical-alignment: center; @@ -677,6 +802,12 @@ export component Field inherits Rectangle { x: Theme.gap; height: 100%; visible: input.text == ""; + // Drawn text, not content. The same words already reach the tree as + // the entry's `accessible-placeholder-text`, where a reader can + // announce them as a prompt rather than as a value the field holds — + // which is what a second text node beside an empty entry would look + // like. `lineedit-base.slint` in Slint's own widgets does exactly this. + accessible-role: none; } } @@ -693,6 +824,22 @@ export component Field inherits Rectangle { export component ProgressBar inherits Rectangle { in property fraction: 0; in property indeterminate: false; + /// What is progressing. A bar with no name announces as "progress + /// indicator, 40%", which says how far along an unnamed something is. + in property label; + + // An indeterminate bar reports no value at all rather than 0%. It has one + // — the sweep — but it is not a position, and a reader that announced 0% + // for a directory walk that is half done would be stating a falsehood in + // the one place the interface was careful not to (see the two modes + // above). Silence is the honest answer to "how far". + accessible-role: progress-indicator; + accessible-label: root.label; + accessible-value: root.indeterminate + ? "" + : Math.round(clamp(root.fraction, 0, 1) * 100) + "%"; + accessible-value-minimum: 0; + accessible-value-maximum: 100; height: 3px; background: Theme.rule;