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:
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user