Merge: say what each control is, so a screen reader can use one
NFR-A11Y-2 went from five accessible-* declarations in the whole interface -- all five on the colour mixer's swatch row -- to seventy-three, on twelve shared components and four screens. The one that mattered is SliderTrack: the most-used control in the application, until now unnamed, and now carrying a label, a formatted readout and increment/decrement/set-value, so it is adjustable rather than merely readable. Fixing the shared components covered library.slint's twenty-seven buttons and eleven chips without editing that file at all. NFR-A11Y-1 is scaffolded and one screen of nine is converted -- 38 @tr() calls, about a tenth of the interface's strings. Slint's translate() returns the original when no bundle is active, so a converted string and a literal behave identically today and each remaining screen is an independent commit. Two accessibility defects are recorded rather than fixed, as TD-6 and TD-7 with measurements: ink-faint reaches 4.5:1 on no surface (3.97 at best) and rule reaches 3:1 on none. Fixing either re-derives the palette beside a photograph, which wants a screenshot and an opinion. Verified: fmt, clippy -D warnings, 563 tests including a new integration test that walks the markup and fails on an unnamed control. 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(); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
|
||||
+75
-31
@@ -7,6 +7,30 @@ import { Check } from "controls.slint";
|
||||
// Deliberately separate from AppWindow. It is the first thing a user sees
|
||||
// with no library configured, and the place they return to in order to sign
|
||||
// out or switch account (FR-NC-1, FR-NC-4).
|
||||
//
|
||||
// **The first screen converted to `@tr()`** (NFR-A11Y-1), and this one first
|
||||
// because it is the one a user cannot get past: an interface they cannot read
|
||||
// is unusable here in a way it is not in a preferences page they could ignore.
|
||||
// The mechanism, the extraction command and the reason a build with no
|
||||
// translation behaves identically are in `build.rs`.
|
||||
//
|
||||
// Four kinds of string are deliberately *not* wrapped, and the distinction is
|
||||
// worth stating because "wrap every literal" is the obvious rule and the wrong
|
||||
// one:
|
||||
//
|
||||
// - **"DarkRoom".** A product name, the same in every language. Translating
|
||||
// it invites a translator to answer the question, and there is no answer.
|
||||
// - **Example values** — `https://cloud.example.com`, `/home/you/Pictures`,
|
||||
// the app-password mask. These are shapes rather than sentences; a
|
||||
// translated hostname would teach the wrong format.
|
||||
// - **`".."`**, the row that leads out of a folder. A filesystem convention,
|
||||
// not a word.
|
||||
// - **`"/"`**, the path separator.
|
||||
//
|
||||
// The headings are wrapped and carry their own capitals — `PanelHeading` draws
|
||||
// what it is given — so a translator supplies "SERVEUR" rather than "Serveur".
|
||||
// That is a real cost of styling in the string, and it is recorded here rather
|
||||
// than discovered by the first person to translate the screen.
|
||||
|
||||
// This screen's buttons are form actions in a single stacked column, not
|
||||
// chrome beside a photograph — they are given the full `touch-target` height
|
||||
@@ -17,11 +41,21 @@ component FormButton inherits Button {
|
||||
}
|
||||
|
||||
// One folder in the picker. The whole row is the target, not just the text.
|
||||
//
|
||||
// A `Rectangle` with a `TouchArea` over it is a button as far as the user is
|
||||
// concerned and a decorated box as far as the platform is, so the name reaches
|
||||
// a screen reader — the `Value` below is a `Text` — while the fact that it can
|
||||
// be entered does not. Saying so is what makes the picker navigable rather
|
||||
// than merely readable (NFR-A11Y-2).
|
||||
component FolderRow inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> is-parent: false;
|
||||
callback clicked();
|
||||
|
||||
accessible-role: button;
|
||||
accessible-label: root.label;
|
||||
accessible-action-default => { root.clicked(); }
|
||||
|
||||
height: Theme.touch-target;
|
||||
background: touch.has-hover ? Theme.surface-raised : transparent;
|
||||
border-radius: 3px;
|
||||
@@ -141,8 +175,8 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
Label {
|
||||
text: root.signed-in
|
||||
? "Connected"
|
||||
: "Connect a Nextcloud account, or open a folder";
|
||||
? @tr("Connected")
|
||||
: @tr("Connect a Nextcloud account, or open a folder");
|
||||
body: true;
|
||||
}
|
||||
}
|
||||
@@ -153,29 +187,36 @@ export component LaunchScreen inherits Rectangle {
|
||||
if !root.signed-in && root.login-url == "": VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
PanelHeading { text: "SERVER"; }
|
||||
PanelHeading { text: @tr("SERVER"); }
|
||||
|
||||
server-input := Field {
|
||||
// The `PanelHeading` above each of these entries names
|
||||
// it on screen and names nothing at all as far as the
|
||||
// accessibility tree is concerned — Slint associates a
|
||||
// heading with the control below it only if something
|
||||
// says so. `Field.label` is that something, so the word
|
||||
// is written twice on purpose.
|
||||
label: @tr("Server address");
|
||||
text: root.server-url;
|
||||
placeholder: "https://cloud.example.com";
|
||||
accepted(url) => { root.sign-in(url); }
|
||||
}
|
||||
|
||||
if !root.can-remember: Caption {
|
||||
text: "No system keyring found — you will need to sign in each time.";
|
||||
text: @tr("No system keyring found — you will need to sign in each time.");
|
||||
warn: true;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
FormButton {
|
||||
text: root.busy ? "Connecting…" : "Sign in";
|
||||
text: root.busy ? @tr("Connecting…") : @tr("Sign in");
|
||||
primary: true;
|
||||
enabled: !root.busy && server-input.text != "";
|
||||
clicked => { root.sign-in(server-input.text); }
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
|
||||
text: @tr("Sign-in happens in your browser. DarkRoom never sees your password.");
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
@@ -193,7 +234,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
background: Theme.rule;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Caption { text: "or"; }
|
||||
Caption { text: @tr("or"); }
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
@@ -201,14 +242,16 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
PanelHeading { text: "USERNAME"; }
|
||||
PanelHeading { text: @tr("USERNAME"); }
|
||||
user-input := Field {
|
||||
label: @tr("Username");
|
||||
text: "";
|
||||
placeholder: "your Nextcloud username";
|
||||
placeholder: @tr("your Nextcloud username");
|
||||
}
|
||||
|
||||
PanelHeading { text: "APP PASSWORD"; }
|
||||
PanelHeading { text: @tr("APP PASSWORD"); }
|
||||
pass-input := Field {
|
||||
label: @tr("App password");
|
||||
text: "";
|
||||
placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx";
|
||||
secret: true;
|
||||
@@ -218,7 +261,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
|
||||
FormButton {
|
||||
text: root.busy ? "Connecting…" : "Connect directly";
|
||||
text: root.busy ? @tr("Connecting…") : @tr("Connect directly");
|
||||
enabled: !root.busy
|
||||
&& server-input.text != ""
|
||||
&& user-input.text != ""
|
||||
@@ -233,7 +276,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.";
|
||||
text: @tr("Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.");
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
@@ -251,7 +294,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
background: Theme.rule;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Caption { text: "or"; }
|
||||
Caption { text: @tr("or"); }
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
@@ -259,21 +302,22 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
PanelHeading { text: "FOLDER"; }
|
||||
PanelHeading { text: @tr("FOLDER"); }
|
||||
folder-input := Field {
|
||||
label: @tr("Library folder");
|
||||
text: root.folder-path;
|
||||
placeholder: "/home/you/Pictures";
|
||||
accepted(path) => { root.use-folder(path); }
|
||||
}
|
||||
|
||||
FormButton {
|
||||
text: "Open folder";
|
||||
text: @tr("Open folder");
|
||||
enabled: !root.busy && folder-input.text != "";
|
||||
clicked => { root.use-folder(folder-input.text); }
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.";
|
||||
text: @tr("Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.");
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
@@ -282,7 +326,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
if root.login-url != "": VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
PanelHeading { text: "APPROVE IN YOUR BROWSER"; }
|
||||
PanelHeading { text: @tr("APPROVE IN YOUR BROWSER"); }
|
||||
|
||||
Panel {
|
||||
Label {
|
||||
@@ -294,18 +338,18 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
|
||||
FormButton {
|
||||
text: "Copy link";
|
||||
text: @tr("Copy link");
|
||||
clicked => { root.copy-login-url(); }
|
||||
}
|
||||
|
||||
Caption { text: "Waiting for approval…"; }
|
||||
Caption { text: @tr("Waiting for approval…"); }
|
||||
}
|
||||
|
||||
// --- folder picker ---
|
||||
if root.signed-in && root.browsing: VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
PanelHeading { text: "CHOOSE LIBRARY FOLDER"; }
|
||||
PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); }
|
||||
|
||||
// Current location, so it is always clear what
|
||||
// "Use this folder" would select.
|
||||
@@ -326,7 +370,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
border-color: Theme.rule;
|
||||
|
||||
if root.browse-loading: Caption {
|
||||
text: "Loading…";
|
||||
text: @tr("Loading…");
|
||||
horizontal-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -357,7 +401,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
|
||||
if !root.browse-loading && root.browse-entries.length == 0
|
||||
&& root.browse-path != "": Caption {
|
||||
text: "No subfolders here";
|
||||
text: @tr("No subfolders here");
|
||||
horizontal-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -367,12 +411,12 @@ export component LaunchScreen inherits Rectangle {
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
FormButton {
|
||||
text: "Cancel";
|
||||
text: @tr("Cancel");
|
||||
horizontal-stretch: 1;
|
||||
clicked => { root.browse-cancel(); }
|
||||
}
|
||||
FormButton {
|
||||
text: "Use this folder";
|
||||
text: @tr("Use this folder");
|
||||
primary: true;
|
||||
horizontal-stretch: 1;
|
||||
clicked => { root.browse-confirm(); }
|
||||
@@ -384,22 +428,22 @@ export component LaunchScreen inherits Rectangle {
|
||||
if root.signed-in && !root.browsing: VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
PanelHeading { text: "ACCOUNT"; }
|
||||
PanelHeading { text: @tr("ACCOUNT"); }
|
||||
Value { text: root.account; }
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
PanelHeading { text: "LIBRARY FOLDER"; }
|
||||
PanelHeading { text: @tr("LIBRARY FOLDER"); }
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Value {
|
||||
text: root.library-root == "" ? "(not chosen)" : root.library-root;
|
||||
text: root.library-root == "" ? @tr("(not chosen)") : root.library-root;
|
||||
placeholder: root.library-root == "";
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
FormButton {
|
||||
text: "Choose…";
|
||||
text: @tr("Choose…");
|
||||
width: 110px;
|
||||
clicked => { root.choose-folder(); }
|
||||
}
|
||||
@@ -407,7 +451,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
PanelHeading { text: "SCAN FOR"; }
|
||||
PanelHeading { text: @tr("SCAN FOR"); }
|
||||
|
||||
for label[i] in root.format-labels: Check {
|
||||
label: label;
|
||||
@@ -418,13 +462,13 @@ export component LaunchScreen inherits Rectangle {
|
||||
Rectangle { height: Theme.gap; }
|
||||
|
||||
FormButton {
|
||||
text: root.busy ? "Scanning…" : "Open library";
|
||||
text: root.busy ? @tr("Scanning…") : @tr("Open library");
|
||||
primary: true;
|
||||
enabled: !root.busy && root.library-root != "";
|
||||
clicked => { root.open-library(); }
|
||||
}
|
||||
FormButton {
|
||||
text: "Sign out";
|
||||
text: @tr("Sign out");
|
||||
clicked => { root.sign-out(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,26 @@ export component ToolRail inherits Rectangle {
|
||||
root.picked(entry.on ? ViewMode.photo : tool.mode);
|
||||
}
|
||||
|
||||
// **The word below is drawn; this is what makes it a control.**
|
||||
// Without these the rail reaches a screen reader as four pieces of
|
||||
// static text — the labels get through, because a `Text` announces
|
||||
// itself, and nothing says any of them can be pressed. That is the
|
||||
// develop view's primary navigation reduced to a caption.
|
||||
//
|
||||
// `checkable` unconditionally, unlike `Button`'s: a rail entry is
|
||||
// always a held-or-not state, so an unheld one should say "not
|
||||
// pressed" rather than pass for an ordinary button. The action
|
||||
// repeats the click handler rather than calling it, because a
|
||||
// `TouchArea`'s `clicked` is raised by the pointer and cannot be
|
||||
// raised from here.
|
||||
accessible-role: button;
|
||||
accessible-label: tool.label;
|
||||
accessible-checkable: true;
|
||||
accessible-checked: entry.on;
|
||||
accessible-action-default => {
|
||||
root.picked(entry.on ? ViewMode.photo : tool.mode);
|
||||
}
|
||||
|
||||
// The lit tile, and the only marker there is. Inset from the
|
||||
// rail's edges so the run of four reads as four things rather than
|
||||
// as one striped column.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user