add ClideTypeahead — shared field-anchored suggestion list (T-288)

The slash and @ typeaheads are near-duplicate caret-anchored completion
surfaces. Per the amended D-88 they share ClideTypeahead (not ClideMenu): the
host owns text parsing + completion; ClideTypeahead owns the anchored overlay +
suggestion list, driven by a suggestions list. Unlike a menu it does not
capture focus or install a barrier — the text field keeps focus — and an
optional nav controller drives the highlight from the field's key handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 22:27:42 +02:00
co-authored by Claude Opus 4.8
parent 318e09e748
commit 3f27dab187
3 changed files with 160 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
/// Field-anchored suggestion list for text typeaheads (D-88).
///
/// The host owns the text parsing + completion (where the `@`/`/` token is, how
/// to filter, how to rewrite the text on select); `ClideTypeahead` owns the
/// anchored overlay + the suggestion list. It is driven by [suggestions] —
/// non-empty shows the popover above the field, empty hides it. Unlike a menu,
/// it does NOT capture focus or install a tap-away barrier: the text field keeps
/// focus (you're still typing), and the host closes it on text change / blur /
/// Esc. Pass [navController] to drive the highlight from the field's own key
/// handler (the slash typeahead does this while the EditableText keeps focus);
/// omit it for a mouse-only list (the @-mention).
library;
import 'package:clide/widgets/src/clide_anchored.dart';
import 'package:clide/widgets/src/clide_menu.dart';
import 'package:flutter/widgets.dart';
class ClideTypeahead extends StatefulWidget {
const ClideTypeahead({
super.key,
required this.child,
required this.suggestions,
required this.onSelect,
this.navController,
this.maxWidth = 320,
this.formatLabel,
});
/// The text field (the anchor). Suggestions float above it.
final Widget child;
/// Current suggestions; empty hides the popover.
final List<String> suggestions;
/// Called with the raw suggestion value when a row is chosen.
final ValueChanged<String> onSelect;
/// External highlight controller — drive it from the field's key handler for
/// keyboard nav while the field keeps focus. Null = mouse-only (hover) list.
final ClideMenuListController? navController;
final double maxWidth;
/// Maps a raw suggestion to its display label (e.g. `'/$cmd'`, `'@$name'`).
final String Function(String value)? formatLabel;
@override
State<ClideTypeahead> createState() => _ClideTypeaheadState();
}
class _ClideTypeaheadState extends State<ClideTypeahead> {
final ClideOverlayController _overlay = ClideOverlayController();
@override
void initState() {
super.initState();
_scheduleSync();
}
@override
void didUpdateWidget(ClideTypeahead old) {
super.didUpdateWidget(old);
_scheduleSync();
}
// Drive open/close off the suggestion list, post-frame — opening inserts an
// OverlayEntry, which is illegal during the parent's build.
void _scheduleSync() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (widget.suggestions.isEmpty) {
_overlay.close();
} else {
_overlay.open();
}
});
}
@override
void dispose() {
_overlay.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ClideAnchoredOverlay(
controller: _overlay,
side: ClideAnchorSide.above,
align: ClideAnchorAlign.start,
offset: const Offset(0, -4),
barrier: false, // closes on text change / blur, not a tap-away barrier
captureFocus: false, // the text field keeps focus
dismissOnEscape: false, // the host routes Esc
anchor: widget.child,
overlayBuilder: (ctx, ctrl) => ClideMenu(
autofocus: false,
hoverHighlight: widget.navController == null,
controller: widget.navController,
minWidth: 0,
maxWidth: widget.maxWidth,
entries: [
for (final s in widget.suggestions) ClideMenuItem(label: widget.formatLabel?.call(s) ?? s, onSelect: () => widget.onSelect(s)),
],
),
);
}
}
+1
View File
@@ -35,6 +35,7 @@ export 'src/multitab_pane.dart';
export 'src/quick_open_overlay.dart';
export 'src/clide_tappable.dart';
export 'src/clide_text.dart';
export 'src/clide_typeahead.dart';
export 'src/clide_tooltip.dart';
export 'src/spacing.dart';
export 'src/typography.dart';
@@ -0,0 +1,51 @@
/// Tests for ClideTypeahead (D-88): suggestion-driven anchored list that keeps
/// focus on the field, selects on tap, and hides when suggestions go empty.
library;
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart' show Alignment, SizedBox, StateSetter, StatefulBuilder;
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
group('ClideTypeahead', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
testWidgets('shows suggestions, selecting fires onSelect, empty hides', (tester) async {
var picked = '';
List<String> sugg = ['alice', 'bob'];
late StateSetter setOuter;
await tester.pumpWidget(anchoredHarness(
f,
StatefulBuilder(
builder: (ctx, setState) {
setOuter = setState;
return ClideTypeahead(
suggestions: sugg,
onSelect: (v) => picked = v,
formatLabel: (n) => '@$n',
child: const SizedBox(width: 200, height: 24, child: ClideText('field')),
);
},
),
alignment: Alignment.topLeft,
));
await tester.pumpAndSettle();
expect(find.text('@alice'), findsOneWidget);
expect(find.text('@bob'), findsOneWidget);
await tester.tap(find.text('@bob'));
await tester.pumpAndSettle();
expect(picked, 'bob');
// Host clears suggestions → popover hides.
setOuter(() => sugg = const []);
await tester.pumpAndSettle();
expect(find.text('@alice'), findsNothing);
});
});
}