diff --git a/lib/widgets/src/clide_typeahead.dart b/lib/widgets/src/clide_typeahead.dart new file mode 100644 index 00000000..d01cd8fd --- /dev/null +++ b/lib/widgets/src/clide_typeahead.dart @@ -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 suggestions; + + /// Called with the raw suggestion value when a row is chosen. + final ValueChanged 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 createState() => _ClideTypeaheadState(); +} + +class _ClideTypeaheadState extends State { + 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)), + ], + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 7c6fbc91..0f43a0a8 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -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'; diff --git a/test/widgets/src/clide_typeahead_test.dart b/test/widgets/src/clide_typeahead_test.dart new file mode 100644 index 00000000..cec1e781 --- /dev/null +++ b/test/widgets/src/clide_typeahead_test.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 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); + }); + }); +}