-
Notifications
You must be signed in to change notification settings - Fork 54
fix(a11y): improve YaruAutocomplete accessibility and focus traversal #1077
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,8 @@ | ||
| import 'dart:async'; | ||
| import 'package:flutter/material.dart'; | ||
| import 'package:flutter/scheduler.dart'; | ||
| import 'package:flutter/semantics.dart'; | ||
| import 'package:flutter/services.dart'; | ||
|
|
||
| import 'yaru_border_container.dart'; | ||
|
|
||
|
|
@@ -67,13 +70,27 @@ class YaruAutocomplete<T extends Object> extends StatefulWidget { | |
| class _YaruAutocompleteState<T extends Object> | ||
| extends State<YaruAutocomplete<T>> { | ||
| double? _optionsWidth; | ||
| bool _hasNavigated = false; | ||
| Iterable<T> _latestOptions = const []; | ||
| FocusNode? _internalFocusNode; | ||
| FocusOnKeyEventCallback? _originalOnKeyEvent; | ||
| Object? _activeSearchToken; | ||
|
|
||
| @override | ||
| void initState() { | ||
| super.initState(); | ||
| _updateOptionsWidth(); | ||
| } | ||
|
|
||
| @override | ||
| void dispose() { | ||
| _internalFocusNode?.removeListener(_onFocusChanged); | ||
| if (_internalFocusNode != null) { | ||
| _internalFocusNode!.onKeyEvent = _originalOnKeyEvent; | ||
| } | ||
| super.dispose(); | ||
| } | ||
|
|
||
| @override | ||
| void didUpdateWidget(YaruAutocomplete<T> oldWidget) { | ||
| super.didUpdateWidget(oldWidget); | ||
|
|
@@ -86,6 +103,7 @@ class _YaruAutocompleteState<T extends Object> | |
| _optionsWidth = widget.optionsWidth; | ||
| if (_optionsWidth == null) { | ||
| WidgetsBinding.instance.addPostFrameCallback((_) { | ||
| if (!mounted) return; | ||
| final width = (context.findRenderObject() as RenderBox?)?.size.width; | ||
| if (_optionsWidth != width) { | ||
| setState(() => _optionsWidth = width); | ||
|
|
@@ -94,14 +112,112 @@ class _YaruAutocompleteState<T extends Object> | |
| } | ||
| } | ||
|
|
||
| void _onFocusChanged() { | ||
| if (_internalFocusNode?.hasFocus == true) { | ||
| _hasNavigated = false; | ||
| } | ||
| } | ||
|
|
||
| void _announceResultsCount(int count, String text) { | ||
| if (text.isEmpty) { | ||
| SemanticsService.announce('Input cleared', TextDirection.ltr); | ||
| } else if (count == 0) { | ||
| SemanticsService.announce('No options found', TextDirection.ltr); | ||
| } else { | ||
| if (count == 1 && _latestOptions.isNotEmpty) { | ||
| if (widget.displayStringForOption(_latestOptions.first) == text) { | ||
| return; | ||
| } | ||
| } | ||
| final message = count == 1 | ||
| ? '1 option available' | ||
| : '$count options available'; | ||
|
Comment on lines
+132
to
+134
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This text should also be able to be passed in as an optional field for |
||
| SemanticsService.announce(message, TextDirection.ltr); | ||
| } | ||
| } | ||
|
|
||
| FutureOr<Iterable<T>> _wrappedOptionsBuilder( | ||
| TextEditingValue textEditingValue, | ||
| ) { | ||
| _hasNavigated = false; | ||
|
|
||
| final Object currentToken = Object(); | ||
| _activeSearchToken = currentToken; | ||
|
|
||
| final result = widget.optionsBuilder(textEditingValue); | ||
| if (result is Future<Iterable<T>>) { | ||
| return result.then((options) { | ||
| if (mounted && _activeSearchToken == currentToken) { | ||
| _latestOptions = options; | ||
| _announceResultsCount(options.length, textEditingValue.text); | ||
| } | ||
| return options; | ||
| }); | ||
| } else { | ||
| _latestOptions = result; | ||
| _announceResultsCount(result.length, textEditingValue.text); | ||
| return result; | ||
| } | ||
| } | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| return RawAutocomplete<T>( | ||
| displayStringForOption: widget.displayStringForOption, | ||
| fieldViewBuilder: widget.fieldViewBuilder, | ||
| fieldViewBuilder: (context, controller, focusNode, onSubmitted) { | ||
| if (_internalFocusNode != focusNode) { | ||
| _internalFocusNode?.removeListener(_onFocusChanged); | ||
|
|
||
| if (_internalFocusNode != null) { | ||
| _internalFocusNode!.onKeyEvent = _originalOnKeyEvent; | ||
| } | ||
|
|
||
| _internalFocusNode = focusNode; | ||
| _internalFocusNode?.addListener(_onFocusChanged); | ||
|
|
||
| _originalOnKeyEvent = _internalFocusNode?.onKeyEvent; | ||
|
|
||
| _internalFocusNode?.onKeyEvent = (node, event) { | ||
| if (event is KeyDownEvent) { | ||
| if (event.logicalKey == LogicalKeyboardKey.arrowDown) { | ||
| if (!_hasNavigated) { | ||
| _hasNavigated = true; | ||
| if (_latestOptions.isNotEmpty) { | ||
| final option = _latestOptions.first; | ||
| SemanticsService.announce( | ||
| widget.displayStringForOption(option), | ||
| TextDirection.ltr, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should use a |
||
| ); | ||
| } | ||
| return KeyEventResult.handled; | ||
| } | ||
| } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { | ||
| _hasNavigated = true; | ||
| } | ||
| } | ||
| return _originalOnKeyEvent?.call(node, event) ?? | ||
| KeyEventResult.ignored; | ||
| }; | ||
| } | ||
|
|
||
| return widget.fieldViewBuilder( | ||
| context, | ||
| controller, | ||
| focusNode, | ||
| onSubmitted, | ||
| ); | ||
| }, | ||
| initialValue: widget.initialValue, | ||
| optionsBuilder: widget.optionsBuilder, | ||
| onSelected: widget.onSelected, | ||
| optionsBuilder: _wrappedOptionsBuilder, | ||
| onSelected: (T option) { | ||
| widget.onSelected?.call(option); | ||
| SchedulerBinding.instance.addPostFrameCallback((_) { | ||
| if (!mounted) return; | ||
| if (_internalFocusNode != null && !_internalFocusNode!.hasFocus) { | ||
| _internalFocusNode!.requestFocus(); | ||
| } | ||
| }); | ||
| }, | ||
| optionsViewBuilder: (context, onSelected, options) { | ||
| return _YaruAutocompleteOptions<T>( | ||
| displayStringForOption: widget.displayStringForOption, | ||
|
|
@@ -115,7 +231,7 @@ class _YaruAutocompleteState<T extends Object> | |
| } | ||
| } | ||
|
|
||
| class _YaruAutocompleteOptions<T extends Object> extends StatelessWidget { | ||
| class _YaruAutocompleteOptions<T extends Object> extends StatefulWidget { | ||
| const _YaruAutocompleteOptions({ | ||
| super.key, | ||
| required this.displayStringForOption, | ||
|
|
@@ -131,43 +247,84 @@ class _YaruAutocompleteOptions<T extends Object> extends StatelessWidget { | |
| final double? optionsWidth; | ||
| final double maxOptionsHeight; | ||
|
|
||
| @override | ||
| State<_YaruAutocompleteOptions<T>> createState() => | ||
| _YaruAutocompleteOptionsState<T>(); | ||
| } | ||
|
|
||
| class _YaruAutocompleteOptionsState<T extends Object> | ||
| extends State<_YaruAutocompleteOptions<T>> { | ||
| int _lastAnnouncedIndex = 0; | ||
|
|
||
| @override | ||
| void didUpdateWidget(covariant _YaruAutocompleteOptions<T> oldWidget) { | ||
| super.didUpdateWidget(oldWidget); | ||
| if (widget.options != oldWidget.options) { | ||
| _lastAnnouncedIndex = 0; | ||
| } | ||
| } | ||
|
|
||
| @override | ||
| void didChangeDependencies() { | ||
| super.didChangeDependencies(); | ||
|
|
||
| final highlighted = AutocompleteHighlightedOption.of(context); | ||
|
|
||
| if (highlighted != _lastAnnouncedIndex) { | ||
| _lastAnnouncedIndex = highlighted; | ||
|
|
||
| if (highlighted >= 0 && highlighted < widget.options.length) { | ||
| final option = widget.options.elementAt(highlighted); | ||
| SemanticsService.announce( | ||
| widget.displayStringForOption(option), | ||
| TextDirection.ltr, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| final theme = Theme.of(context); | ||
| return Align( | ||
| alignment: Alignment.topLeft, | ||
| child: YaruBorderContainer( | ||
| width: optionsWidth, | ||
| clipBehavior: Clip.antiAlias, | ||
| color: theme.menuTheme.style?.backgroundColor?.resolve({}), | ||
| constraints: BoxConstraints(maxHeight: maxOptionsHeight), | ||
| child: ListView.builder( | ||
| padding: EdgeInsets.zero, | ||
| shrinkWrap: true, | ||
| itemCount: options.length, | ||
| itemBuilder: (context, index) { | ||
| return Builder( | ||
| builder: (context) { | ||
| final highlighted = AutocompleteHighlightedOption.of(context); | ||
| if (index == highlighted) { | ||
| SchedulerBinding.instance.addPostFrameCallback((_) { | ||
| Scrollable.ensureVisible(context, alignment: 0.5); | ||
| }); | ||
| } | ||
| final option = options.elementAt(index); | ||
| return MenuItemButton( | ||
| requestFocusOnHover: false, | ||
| onPressed: () => onSelected(option), | ||
| style: MenuItemButton.styleFrom( | ||
| backgroundColor: index == highlighted | ||
| ? Theme.of(context).focusColor | ||
| : null, | ||
| ), | ||
| child: Text(displayStringForOption(option)), | ||
| ); | ||
| }, | ||
| ); | ||
| }, | ||
| child: ExcludeFocus( | ||
| child: YaruBorderContainer( | ||
| width: widget.optionsWidth, | ||
| clipBehavior: Clip.antiAlias, | ||
| color: theme.menuTheme.style?.backgroundColor?.resolve({}), | ||
| constraints: BoxConstraints(maxHeight: widget.maxOptionsHeight), | ||
| child: ListView.builder( | ||
| padding: EdgeInsets.zero, | ||
| shrinkWrap: true, | ||
| itemCount: widget.options.length, | ||
| itemBuilder: (context, index) { | ||
| return Builder( | ||
| builder: (context) { | ||
| final highlighted = AutocompleteHighlightedOption.of(context); | ||
| if (index == highlighted) { | ||
| SchedulerBinding.instance.addPostFrameCallback((_) { | ||
| if (context.mounted) { | ||
| Scrollable.ensureVisible(context, alignment: 0.5); | ||
| } | ||
| }); | ||
| } | ||
| final option = widget.options.elementAt(index); | ||
|
|
||
| return MenuItemButton( | ||
| requestFocusOnHover: false, | ||
| onPressed: () => widget.onSelected(option), | ||
| style: MenuItemButton.styleFrom( | ||
| backgroundColor: index == highlighted | ||
| ? Theme.of(context).focusColor | ||
| : null, | ||
| ), | ||
| child: Text(widget.displayStringForOption(option)), | ||
| ); | ||
| }, | ||
| ); | ||
| }, | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These strings should be able to be passed in to the
YaruAutocompleteas optional fields. Perhaps also the announce text direction.