Merge pull request #40 from dougborg/investigate/dropdown-dismiss

Fix suggestion dropdown dismiss on tap outside
This commit is contained in:
stopflock
2026-02-09 18:17:53 -06:00
committed by GitHub
3 changed files with 76 additions and 133 deletions

View File

@@ -144,6 +144,7 @@ const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render o
// NSI (Name Suggestion Index) configuration // NSI (Name Suggestion Index) configuration
const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful
const int kNSIMaxSuggestions = 10; // Maximum number of tag value suggestions to fetch and display
// Map interaction configuration // Map interaction configuration
const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0) const double kNodeDoubleTapZoomDelta = 1.0; // How much to zoom in when double-tapping nodes (was 1.0)

View File

@@ -84,8 +84,7 @@ class NSIService {
} }
} }
// Limit to top 10 suggestions for UI performance if (suggestions.length >= kNSIMaxSuggestions) break;
if (suggestions.length >= 10) break;
} }
return suggestions; return suggestions;

View File

@@ -25,33 +25,26 @@ class NSITagValueField extends StatefulWidget {
class _NSITagValueFieldState extends State<NSITagValueField> { class _NSITagValueFieldState extends State<NSITagValueField> {
late TextEditingController _controller; late TextEditingController _controller;
List<String> _suggestions = [];
bool _showingSuggestions = false;
final LayerLink _layerLink = LayerLink();
late OverlayEntry _overlayEntry;
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
List<String> _suggestions = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = TextEditingController(text: widget.initialValue); _controller = TextEditingController(text: widget.initialValue);
_loadSuggestions(); _loadSuggestions();
_focusNode.addListener(_onFocusChanged);
_controller.addListener(_onTextChanged);
} }
@override @override
void didUpdateWidget(NSITagValueField oldWidget) { void didUpdateWidget(NSITagValueField oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// If the tag key changed, reload suggestions // If the tag key changed, reload suggestions
if (oldWidget.tagKey != widget.tagKey) { if (oldWidget.tagKey != widget.tagKey) {
_hideSuggestions(); // Hide old suggestions immediately
_suggestions.clear(); _suggestions.clear();
_loadSuggestions(); // Load new suggestions for new key _loadSuggestions();
} }
// If the initial value changed, update the controller // If the initial value changed, update the controller
if (oldWidget.initialValue != widget.initialValue) { if (oldWidget.initialValue != widget.initialValue) {
_controller.text = widget.initialValue; _controller.text = widget.initialValue;
@@ -62,38 +55,17 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();
_focusNode.dispose(); _focusNode.dispose();
_hideSuggestions();
super.dispose(); super.dispose();
} }
/// Get filtered suggestions based on current text input (case-sensitive)
List<String> _getFilteredSuggestions() {
final currentText = _controller.text;
if (currentText.isEmpty) {
return _suggestions;
}
return _suggestions
.where((suggestion) => suggestion.contains(currentText))
.toList();
}
/// Handle text changes to update suggestion filtering
void _onTextChanged() {
if (_showingSuggestions) {
// Update the overlay with filtered suggestions
_updateSuggestionsOverlay();
}
}
void _loadSuggestions() async { void _loadSuggestions() async {
if (widget.tagKey.trim().isEmpty) return; if (widget.tagKey.trim().isEmpty) return;
try { try {
final suggestions = await NSIService().getAllSuggestions(widget.tagKey); final suggestions = await NSIService().getAllSuggestions(widget.tagKey);
if (mounted) { if (mounted) {
setState(() { setState(() {
_suggestions = suggestions.take(10).toList(); // Limit to 10 suggestions _suggestions = suggestions;
}); });
} }
} catch (e) { } catch (e) {
@@ -106,52 +78,71 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
} }
} }
void _onFocusChanged() { InputDecoration _buildDecoration({required bool showDropdownIcon}) {
final filteredSuggestions = _getFilteredSuggestions(); return InputDecoration(
if (_focusNode.hasFocus && filteredSuggestions.isNotEmpty && !widget.readOnly) { hintText: widget.hintText,
_showSuggestions(); border: const OutlineInputBorder(),
} else { isDense: true,
_hideSuggestions(); suffixIcon: showDropdownIcon
} ? Icon(
Icons.arrow_drop_down,
color: _focusNode.hasFocus
? Theme.of(context).primaryColor
: Colors.grey,
)
: null,
);
} }
void _showSuggestions() { @override
final filteredSuggestions = _getFilteredSuggestions(); Widget build(BuildContext context) {
if (_showingSuggestions || filteredSuggestions.isEmpty) return; if (widget.readOnly) {
return TextField(
_overlayEntry = _buildSuggestionsOverlay(filteredSuggestions); controller: _controller,
Overlay.of(context).insert(_overlayEntry); focusNode: _focusNode,
setState(() { readOnly: true,
_showingSuggestions = true; decoration: _buildDecoration(showDropdownIcon: false),
}); );
}
/// Update the suggestions overlay with current filtered suggestions
void _updateSuggestionsOverlay() {
final filteredSuggestions = _getFilteredSuggestions();
if (filteredSuggestions.isEmpty) {
_hideSuggestions();
return;
} }
if (_showingSuggestions) {
// Remove current overlay and create new one with filtered suggestions
_overlayEntry.remove();
_overlayEntry = _buildSuggestionsOverlay(filteredSuggestions);
Overlay.of(context).insert(_overlayEntry);
}
}
/// Build the suggestions overlay with the given suggestions list return RawAutocomplete<String>(
OverlayEntry _buildSuggestionsOverlay(List<String> suggestions) { textEditingController: _controller,
return OverlayEntry( focusNode: _focusNode,
builder: (context) => Positioned( optionsBuilder: (TextEditingValue textEditingValue) {
width: 250, // Slightly wider to fit more content in refine tags if (_suggestions.isEmpty) return const Iterable<String>.empty();
child: CompositedTransformFollower( if (textEditingValue.text.isEmpty) return _suggestions;
link: _layerLink, return _suggestions
showWhenUnlinked: false, .where((s) => s.contains(textEditingValue.text));
offset: const Offset(0.0, 35.0), // Below the text field },
onSelected: (String selection) {
widget.onChanged(selection);
},
fieldViewBuilder: (
BuildContext context,
TextEditingController controller,
FocusNode focusNode,
VoidCallback onFieldSubmitted,
) {
return TextField(
controller: controller,
focusNode: focusNode,
decoration: _buildDecoration(
showDropdownIcon: _suggestions.isNotEmpty,
),
onChanged: (value) {
widget.onChanged(value);
},
onSubmitted: (_) => onFieldSubmitted(),
);
},
optionsViewBuilder: (
BuildContext context,
AutocompleteOnSelected<String> onSelected,
Iterable<String> options,
) {
final optionList = options.toList(growable: false);
return Align(
alignment: Alignment.topLeft,
child: Material( child: Material(
elevation: 4.0, elevation: 4.0,
borderRadius: BorderRadius.circular(8.0), borderRadius: BorderRadius.circular(8.0),
@@ -160,68 +151,20 @@ class _NSITagValueFieldState extends State<NSITagValueField> {
child: ListView.builder( child: ListView.builder(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
shrinkWrap: true, shrinkWrap: true,
itemCount: suggestions.length, itemCount: optionList.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final suggestion = suggestions[index]; final option = optionList[index];
return ListTile( return ListTile(
dense: true, dense: true,
title: Text(suggestion, style: const TextStyle(fontSize: 14)), title: Text(option, style: const TextStyle(fontSize: 14)),
onTap: () => _selectSuggestion(suggestion), onTap: () => onSelected(option),
); );
}, },
), ),
), ),
), ),
), );
), },
); );
} }
}
void _hideSuggestions() {
if (!_showingSuggestions) return;
_overlayEntry.remove();
setState(() {
_showingSuggestions = false;
});
}
void _selectSuggestion(String suggestion) {
_controller.text = suggestion;
widget.onChanged(suggestion);
_hideSuggestions();
}
@override
Widget build(BuildContext context) {
final filteredSuggestions = _getFilteredSuggestions();
return CompositedTransformTarget(
link: _layerLink,
child: TextField(
controller: _controller,
focusNode: _focusNode,
readOnly: widget.readOnly,
decoration: InputDecoration(
hintText: widget.hintText,
border: const OutlineInputBorder(),
isDense: true,
suffixIcon: _suggestions.isNotEmpty && !widget.readOnly
? Icon(
Icons.arrow_drop_down,
color: _showingSuggestions ? Theme.of(context).primaryColor : Colors.grey,
)
: null,
),
onChanged: widget.readOnly ? null : (value) {
widget.onChanged(value);
},
onTap: () {
if (!widget.readOnly && filteredSuggestions.isNotEmpty) {
_showSuggestions();
}
},
),
);
}
}