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:
+56
-10
@@ -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(); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user