Say what each control is, so a screen reader can use one

The whole interface carried five accessible-* declarations in 14,482 lines of
markup, all of them on a single row of the colour mixer, and nothing said so.
Every other control — every button, every tick-box, every chip, and the slider
the develop panel builds thirty-six of for the mixer alone — reached AT-SPI
and TalkBack as an unnamed rectangle. NFR-A11Y-2 is not a polish item for a
user in that position; it is whether the application can be used at all.

The annotations go on the shared components rather than on the screens, which
is the same argument widgets.slint was written to make one layer down: a
control named where it is used is a control unnamed everywhere it is used
next. Twelve components now declare a role, a name and — where the control
does something — the action assistive technology invokes to do it. Three
screens were touched, and only where the component could not know the answer.

SliderTrack is the one that mattered most and the one that could not be fixed
from inside itself. It is handed four numbers and knows nothing about what
they mean, so it takes a `label` and a formatted `readout` and every wrapper
passes down what it was already drawing. A test asserts that every
instantiation does, because a track added without one announces "slider, 0.35"
and looks perfectly correct in a screenshot.

It also gains increment, decrement and set-value. A slider that can only be
dragged is a slider a pointer is a modifier for, which is the objection
ui-navigation D-N2 makes about hover-only affordances with the argument run
one step further; these three are what a screen reader drives a slider with,
and they commit as well as change — one nudge is a whole gesture, so a caller
that persists on `committed` must hear about it.

Two decisions worth recording because the obvious alternative is wrong:

`active` on Button and IconButton is deliberately not announced from the
component. It says a toggle is on and says nothing about whether a control
that is *off* is a toggle at all, so announcing it would report every button
in the application as an unpressed toggle. The four call sites that mean a
toggle say so themselves, which Slint permits because the role is inherited.

SwatchSlider's row-level role is removed rather than kept. It was the one
control that had a label, and now that the track underneath it has one too the
two would nest — a slider inside a slider, the outer holding the value and the
inner holding the actions that can change it. The row stands down and hands
the same strings to the control that owns the gesture.

The test reads the markup the way darkroom-android's manifest test reads its
XML: there is no accessibility tree without a window, so what it defends is
the failure that actually happens — a role or a name lost in a refactor, which
compiles, renders identically, and is invisible to everyone not using a screen
reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:22:32 +02:00
co-authored by Claude Opus 5
parent ef07e6ca3e
commit 8e4befa727
5 changed files with 657 additions and 10 deletions
@@ -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<Item = (usize, &str)> {
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<PathBuf> = 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()
}
+56 -10
View File
@@ -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 <ParamRow> 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(); }
}
+129
View File
@@ -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 <float> minimum;
in property <float> 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 <string> 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 <string> 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 <float> 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 <float> span: max(0.000001, root.maximum - root.minimum);
property <float> 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 <float> value;
in property <float> minimum;
in property <float> maximum;
/// What the number means. Handed straight to the entry, which is where the
/// accessibility tree wants it — see `Field.label`.
in property <string> 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;
+8
View File
@@ -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(); }
}
}
+147
View File
@@ -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: <state>;` —
/// which Slint permits because the role below is inherited.
in property <bool> 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 <string> 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 <string> label;
in property <bool> 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 <bool> 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 <bool> 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 <string> 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 <string> label;
in property <string> 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 <float> fraction: 0;
in property <bool> 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 <string> 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;