Repository navigation
fix(a2ui_flutter): name the slider by its label - #3411
diegolopezrm wants to merge 5 commits into
Conversation
Flutter's Slider reads its label argument, the value bubble's text, as the slider's name, so the slider announced "4" with a value of "40%", and the caption above it was a separate node. The caption now names the node that carries the value and the actions, the value is read as it is shown, and the caption and the value text above the track are left out of semantics so nothing is said twice.
There was a problem hiding this comment.
Code Review
This pull request improves the accessibility of the Slider component by wrapping the visual label and value in ExcludeSemantics to prevent screen readers from announcing them twice, and wrapping the Slider in MergeSemantics and Semantics with the label and a semantic formatter callback. It also adds comprehensive semantic tests. The review feedback suggests making the label variable nullable (String?) instead of defaulting to an empty string, ensuring that null is passed to Semantics when a label is omitted while still defaulting to an empty string for the visual Text widget.
| : null; | ||
| final double raw = props.number('value') ?? min; | ||
| final double value = raw.isFinite ? raw.clamp(min, max) : min; | ||
| final String label = props.string('label') ?? ''; |
There was a problem hiding this comment.
Using a nullable String? for label is preferred here. If the label is omitted or empty, passing null to Semantics is better than passing an empty string '', as some screen readers might announce an empty string or treat the widget as unlabelled. We can default to '' only when rendering the Text widget.
| final String label = props.string('label') ?? ''; | |
| final String? label = props.string('label'); |
| ExcludeSemantics( | ||
| child: Row( | ||
| children: [ | ||
| Expanded(child: Text(label, style: theme.textTheme.titleSmall)), | ||
| Text(valueText, style: theme.textTheme.bodySmall), | ||
| ], | ||
| ), | ||
| ), | ||
| Slider( | ||
| value: value, | ||
| min: min, | ||
| max: max, | ||
| divisions: divisions, | ||
| label: valueText, | ||
| onChanged: write == null || !range.isFinite | ||
| ? null | ||
| : (moved) { | ||
| final double snapped = divisions == null | ||
| ? moved | ||
| : math.min(max, min + (moved - min).round()); | ||
| write( | ||
| snapped == snapped.roundToDouble() && | ||
| snapped.abs() < _twoTo53 | ||
| ? snapped.toInt() | ||
| : snapped, | ||
| ); | ||
| }, | ||
| // The label names the node that carries the value and the actions. | ||
| // Flutter's own `label` is the value bubble, and it would be | ||
| // announced as the slider's name. | ||
| MergeSemantics( | ||
| child: Semantics( | ||
| label: label, | ||
| child: Slider( |
There was a problem hiding this comment.
Update the Text widget to use label ?? '' since label is now nullable, and pass the nullable label directly to Semantics.
| ExcludeSemantics( | |
| child: Row( | |
| children: [ | |
| Expanded(child: Text(label, style: theme.textTheme.titleSmall)), | |
| Text(valueText, style: theme.textTheme.bodySmall), | |
| ], | |
| ), | |
| ), | |
| Slider( | |
| value: value, | |
| min: min, | |
| max: max, | |
| divisions: divisions, | |
| label: valueText, | |
| onChanged: write == null || !range.isFinite | |
| ? null | |
| : (moved) { | |
| final double snapped = divisions == null | |
| ? moved | |
| : math.min(max, min + (moved - min).round()); | |
| write( | |
| snapped == snapped.roundToDouble() && | |
| snapped.abs() < _twoTo53 | |
| ? snapped.toInt() | |
| : snapped, | |
| ); | |
| }, | |
| // The label names the node that carries the value and the actions. | |
| // Flutter's own `label` is the value bubble, and it would be | |
| // announced as the slider's name. | |
| MergeSemantics( | |
| child: Semantics( | |
| label: label, | |
| child: Slider( | |
| ExcludeSemantics( | |
| child: Row( | |
| children: [ | |
| Expanded(child: Text(label ?? '', style: theme.textTheme.titleSmall)), | |
| Text(valueText, style: theme.textTheme.bodySmall), | |
| ], | |
| ), | |
| ), | |
| // The label names the node that carries the value and the actions. | |
| // Flutter's own 'label' is the value bubble, and it would be | |
| // announced as the slider's name. | |
| MergeSemantics( | |
| child: Semantics( | |
| label: label, | |
| child: Slider( |
Keeps the semantics wrapper from this branch around main's Slider, which now moves in its v1.0 `steps`, and moves the CHANGELOG line from the released 0.0.1-wip003 section to Unreleased.⚠️ Agent-generated
| min: min, | ||
| max: max, | ||
| divisions: divisions, | ||
| semanticFormatterCallback: (v) => ComponentProps.asString(v)!, |
There was a problem hiding this comment.
Given that v is floating point number, ComponentProps.asString is going to produce values like 0.3000000004 or whatnot instead of 0.3. Would it be best to run the value through the same snapping logic that onChanged uses (after it's extracted out into a snap function or something similar).
There was a problem hiding this comment.
Good catch, done in 5b3e027. The snapping in onChanged is now a snap function and the semantics formatter uses it too, so the slider announces what a move would write. It was more than cosmetic: on a 0 to 100 slider the value below 30 came out as 28.999999999999996. Added a test for that and one for fractional steps on v1.0. I also merged main and moved the changelog line under a new Unreleased heading, since 0.2.0 shipped without it.
…ider-label # Conflicts: # dart/a2ui_flutter/CHANGELOG.md
…ider-label # Conflicts: # dart/a2ui_flutter/CHANGELOG.md
|
From triage bot: Triage suggestion: For humansSummary: Why flagged for triage: Suggestion:
To adjust and apply this suggestion, run in your a2ui checkout: tools/internal/triage_new/triage_ui.sh https://github.com/a2ui-project/a2ui/pull/3411For software🤖 Agent-generated |
Follow-up to #3088, @andrewkolos.
Flutter's
Slideruses itslabelargument, the value bubble's text, as the slider's name, so a screen reader said "40, 40%" and read "Volume" as a separate line. The caption now names the slider, and the value is read as it's shown. The trade-off is the bubble: Flutter reads its text as the name, so keeping it would undo the fix. It's gone, and the value stays on screen above the track. Two semantics tests cover it, and they fail without the change.