migrate the @-mention typeahead onto ClideTypeahead (T-288)
Both the sidebar and the full-pane chat composers hand-rolled the same LayerLink + OverlayEntry + _showOverlay/_removeOverlay + _AtOverlay. Replace both with ClideTypeahead driven by the suggestion list; delete _AtOverlay and the per-copy overlay plumbing. The text parsing/completion (activeAtQuery, filterAtNames, completeAt, parseAtTag) and the Esc handler stay in the hosts. Behaviour is unchanged (the popover now uses the shared dropdown styling); team_chat_sidebar_test stays green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,8 +45,6 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
|||||||
StreamSubscription<void>? _sub;
|
StreamSubscription<void>? _sub;
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _focusNode = FocusNode(debugLabel: 'team-chat-sidebar');
|
final _focusNode = FocusNode(debugLabel: 'team-chat-sidebar');
|
||||||
final _layerLink = LayerLink();
|
|
||||||
OverlayEntry? _overlay;
|
|
||||||
List<String> _suggestions = const [];
|
List<String> _suggestions = const [];
|
||||||
AtQuery? _activeQuery;
|
AtQuery? _activeQuery;
|
||||||
|
|
||||||
@@ -62,7 +60,6 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_sub?.cancel();
|
_sub?.cancel();
|
||||||
_removeOverlay();
|
|
||||||
_controller.removeListener(_onTextChanged);
|
_controller.removeListener(_onTextChanged);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
@@ -93,28 +90,6 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
|||||||
_suggestions = newSuggestions;
|
_suggestions = newSuggestions;
|
||||||
_activeQuery = query;
|
_activeQuery = query;
|
||||||
});
|
});
|
||||||
if (newSuggestions.isEmpty) {
|
|
||||||
_removeOverlay();
|
|
||||||
} else {
|
|
||||||
_showOverlay();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showOverlay() {
|
|
||||||
_removeOverlay();
|
|
||||||
final entry = OverlayEntry(
|
|
||||||
builder: (_) => _AtOverlay(
|
|
||||||
layerLink: _layerLink,
|
|
||||||
suggestions: _suggestions,
|
|
||||||
onSelect: _completeName,
|
|
||||||
));
|
|
||||||
_overlay = entry;
|
|
||||||
Overlay.of(context).insert(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _removeOverlay() {
|
|
||||||
_overlay?.remove();
|
|
||||||
_overlay = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _completeName(String name) {
|
void _completeName(String name) {
|
||||||
@@ -125,7 +100,6 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
|||||||
text: result.text,
|
text: result.text,
|
||||||
selection: TextSelection.collapsed(offset: result.cursor),
|
selection: TextSelection.collapsed(offset: result.cursor),
|
||||||
);
|
);
|
||||||
_removeOverlay();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_suggestions = const [];
|
_suggestions = const [];
|
||||||
_activeQuery = null;
|
_activeQuery = null;
|
||||||
@@ -138,12 +112,10 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
|||||||
final parsed = parseAtTag(text);
|
final parsed = parseAtTag(text);
|
||||||
widget.model.postAsUser(parsed.body.isEmpty ? text : parsed.body, toName: parsed.recipient);
|
widget.model.postAsUser(parsed.body.isEmpty ? text : parsed.body, toName: parsed.recipient);
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
_removeOverlay();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||||
_removeOverlay();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_suggestions = const [];
|
_suggestions = const [];
|
||||||
_activeQuery = null;
|
_activeQuery = null;
|
||||||
@@ -199,8 +171,10 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
|||||||
_ChatRow(key: ValueKey(msg.at.microsecondsSinceEpoch), message: msg, tokens: tokens),
|
_ChatRow(key: ValueKey(msg.at.microsecondsSinceEpoch), message: msg, tokens: tokens),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
// Quick-post composer.
|
// Quick-post composer.
|
||||||
CompositedTransformTarget(
|
ClideTypeahead(
|
||||||
link: _layerLink,
|
suggestions: _suggestions,
|
||||||
|
onSelect: _completeName,
|
||||||
|
formatLabel: (n) => '@$n',
|
||||||
child: Focus(
|
child: Focus(
|
||||||
onKeyEvent: _handleKeyEvent,
|
onKeyEvent: _handleKeyEvent,
|
||||||
child: _ChatInputField(
|
child: _ChatInputField(
|
||||||
@@ -243,9 +217,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
|||||||
StreamSubscription<void>? _sub;
|
StreamSubscription<void>? _sub;
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _focusNode = FocusNode(debugLabel: 'team-chat-pane');
|
final _focusNode = FocusNode(debugLabel: 'team-chat-pane');
|
||||||
final _layerLink = LayerLink();
|
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
OverlayEntry? _overlay;
|
|
||||||
List<String> _suggestions = const [];
|
List<String> _suggestions = const [];
|
||||||
AtQuery? _activeQuery;
|
AtQuery? _activeQuery;
|
||||||
bool _interrupt = false;
|
bool _interrupt = false;
|
||||||
@@ -274,7 +246,6 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_sub?.cancel();
|
_sub?.cancel();
|
||||||
_removeOverlay();
|
|
||||||
_controller.removeListener(_onTextChanged);
|
_controller.removeListener(_onTextChanged);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
@@ -306,28 +277,6 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
|||||||
_suggestions = newSuggestions;
|
_suggestions = newSuggestions;
|
||||||
_activeQuery = query;
|
_activeQuery = query;
|
||||||
});
|
});
|
||||||
if (newSuggestions.isEmpty) {
|
|
||||||
_removeOverlay();
|
|
||||||
} else {
|
|
||||||
_showOverlay();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showOverlay() {
|
|
||||||
_removeOverlay();
|
|
||||||
final entry = OverlayEntry(
|
|
||||||
builder: (_) => _AtOverlay(
|
|
||||||
layerLink: _layerLink,
|
|
||||||
suggestions: _suggestions,
|
|
||||||
onSelect: _completeName,
|
|
||||||
));
|
|
||||||
_overlay = entry;
|
|
||||||
Overlay.of(context).insert(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _removeOverlay() {
|
|
||||||
_overlay?.remove();
|
|
||||||
_overlay = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _completeName(String name) {
|
void _completeName(String name) {
|
||||||
@@ -338,7 +287,6 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
|||||||
text: result.text,
|
text: result.text,
|
||||||
selection: TextSelection.collapsed(offset: result.cursor),
|
selection: TextSelection.collapsed(offset: result.cursor),
|
||||||
);
|
);
|
||||||
_removeOverlay();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_suggestions = const [];
|
_suggestions = const [];
|
||||||
_activeQuery = null;
|
_activeQuery = null;
|
||||||
@@ -355,12 +303,10 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
|||||||
interrupt: _interrupt,
|
interrupt: _interrupt,
|
||||||
);
|
);
|
||||||
_controller.clear();
|
_controller.clear();
|
||||||
_removeOverlay();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||||
_removeOverlay();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_suggestions = const [];
|
_suggestions = const [];
|
||||||
_activeQuery = null;
|
_activeQuery = null;
|
||||||
@@ -453,8 +399,10 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// Input field.
|
// Input field.
|
||||||
CompositedTransformTarget(
|
ClideTypeahead(
|
||||||
link: _layerLink,
|
suggestions: _suggestions,
|
||||||
|
onSelect: _completeName,
|
||||||
|
formatLabel: (n) => '@$n',
|
||||||
child: Focus(
|
child: Focus(
|
||||||
onKeyEvent: _handleKeyEvent,
|
onKeyEvent: _handleKeyEvent,
|
||||||
child: _ChatInputField(
|
child: _ChatInputField(
|
||||||
@@ -572,65 +520,3 @@ class _ChatInputField extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @-completion overlay, attached via [CompositedTransformTarget] /
|
|
||||||
/// [CompositedTransformFollower] so it tracks the input field.
|
|
||||||
class _AtOverlay extends StatelessWidget {
|
|
||||||
const _AtOverlay({
|
|
||||||
required this.layerLink,
|
|
||||||
required this.suggestions,
|
|
||||||
required this.onSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
final LayerLink layerLink;
|
|
||||||
final List<String> suggestions;
|
|
||||||
final void Function(String name) onSelect;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final tokens = ClideTheme.of(context).surface;
|
|
||||||
return Positioned(
|
|
||||||
// Overlay is attached relative to the layerLink; height is open so the
|
|
||||||
// follower drives layout. The CompositedTransformFollower handles x/y.
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
child: CompositedTransformFollower(
|
|
||||||
link: layerLink,
|
|
||||||
showWhenUnlinked: false,
|
|
||||||
offset: const Offset(0, -4),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.bottomLeft,
|
|
||||||
child: Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 180, maxHeight: 140),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: tokens.panelBackground,
|
|
||||||
border: Border.all(color: tokens.panelBorder),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
shrinkWrap: true,
|
|
||||||
children: [
|
|
||||||
for (final name in suggestions)
|
|
||||||
Semantics(
|
|
||||||
button: true,
|
|
||||||
label: '@$name',
|
|
||||||
excludeSemantics: true,
|
|
||||||
onTap: () => onSelect(name),
|
|
||||||
child: ClideTappable(
|
|
||||||
onTap: () => onSelect(name),
|
|
||||||
builder: (ctx, hovered, _) => Container(
|
|
||||||
color: hovered ? tokens.globalFocus.withAlpha(20) : null,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
||||||
child: ClideText('@$name', fontSize: clideFontSmall, color: tokens.globalForeground),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user