Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 193 additions & 36 deletions lib/src/widgets/yaru_autocomplete.dart
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';

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Comment on lines +123 to +125

Copy link
Copy Markdown
Contributor

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 YaruAutocomplete as optional fields. Perhaps also the announce text direction.

} 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 YaruAutocomplete, maybe as a callback since the string needs to know about the result count. The translations we use in our apps have support for plurals, so in the ideal case we wouldn't need to do this special handling when using translated strings. Example: https://github.com/canonical/desktop-security-center/blob/main/packages/security_center/lib/l10n/app_en.arb#L35

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use a YaruAutocomplete field for these TextDirections so they can be overridden.

);
}
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,
Expand All @@ -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,
Expand All @@ -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)),
);
},
);
},
),
),
),
);
Expand Down
Loading