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/quick_open_overlay.dart';
|
||||||
export 'src/clide_tappable.dart';
|
export 'src/clide_tappable.dart';
|
||||||
export 'src/clide_text.dart';
|
export 'src/clide_text.dart';
|
||||||
|
export 'src/clide_typeahead.dart';
|
||||||
export 'src/clide_tooltip.dart';
|
export 'src/clide_tooltip.dart';
|
||||||
export 'src/spacing.dart';
|
export 'src/spacing.dart';
|
||||||
export 'src/typography.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