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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user