import QtQuick import QtQuick.Controls import qs.Commons // Searchable single-select dropdown. Same trigger shape as Dropdown, but // the popup leads with an embedded TextField that filters the option // list in real time. Use for pickers with enough options that scanning // is friction (e.g. bar settings "+ Add widget"). // // Filtering is case-insensitive substring against each option's label. // Options can be string[] or [{ value, label, description? }] — the same // shape Dropdown accepts. The filter clears whenever the popup closes. // // Keyboard: Tab to focus the trigger, Enter/Space opens (search focused // immediately). Down arrow from the search jumps to the first match; // Up from the first match returns to the search. Enter selects, Esc // closes (and clears the filter). Item { id: root property string label: "" property string value: "" property var options: [] property string placeholderText: "Search..." property string emptyText: "No matches" property color foreground: Color.foreground property color background: Color.popups.background property color popupBorder: Color.popups.border property color accent: Color.accent property string fontFamily: "JetBrainsMono Nerd Font" property int rowHeight: 28 property int popupRowHeight: 28 property int popupMinHeight: 220 property bool showLabel: true signal changed(string value) function optionValue(o) { return (o && typeof o === "object") ? String(o.value) : String(o) } function optionLabel(o) { return (o && typeof o === "object") ? String(o.label) : String(o) } function optionDescription(o) { return (o && typeof o === "object" && o.description) ? String(o.description) : "" } function currentLabel() { for (var i = 0; i < options.length; i++) { if (optionValue(options[i]) === value) return optionLabel(options[i]) } return value } property var filtered: options function recomputeFiltered() { var q = searchField.text.toLowerCase() if (!q) { filtered = options; return } var out = [] for (var i = 0; i < options.length; i++) { var lbl = optionLabel(options[i]).toLowerCase() var desc = optionDescription(options[i]).toLowerCase() if (lbl.indexOf(q) !== -1 || desc.indexOf(q) !== -1) out.push(options[i]) } filtered = out } onOptionsChanged: recomputeFiltered() implicitWidth: 260 implicitHeight: showLabel && label !== "" ? rowHeight + 18 : rowHeight Column { anchors.fill: parent spacing: 4 Text { visible: root.showLabel && root.label !== "" text: root.label color: Qt.darker(root.foreground, 1.4) font.family: root.fontFamily font.pixelSize: 10 font.bold: true } Rectangle { id: trigger width: parent.width height: root.rowHeight radius: Style.cornerRadius color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, trigger.activeFocus ? 0.08 : 0.04) border.color: trigger.activeFocus ? Style.focusBorderColor : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.4) border.width: trigger.activeFocus ? Style.focusBorderWidth : 1 activeFocusOnTab: true Keys.onPressed: function(event) { if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space || event.key === Qt.Key_Down) { popup.opened ? popup.close() : popup.open() event.accepted = true } else if (event.key === Qt.Key_Escape && popup.opened) { popup.close(); event.accepted = true } } Text { anchors.left: parent.left anchors.right: chevron.left anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: 10 anchors.rightMargin: 6 text: root.currentLabel() || root.placeholderText color: root.currentLabel() ? root.foreground : Qt.darker(root.foreground, 1.5) font.family: root.fontFamily font.pixelSize: 12 elide: Text.ElideRight } Text { id: chevron anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.rightMargin: 8 text: "󰅀" color: Qt.darker(root.foreground, 1.2) font.family: root.fontFamily font.pixelSize: 12 } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { trigger.forceActiveFocus() popup.opened ? popup.close() : popup.open() } } Popup { id: popup x: 0 y: trigger.height + 2 width: trigger.width implicitHeight: Math.max(root.popupMinHeight, Math.min(root.filtered.length * root.popupRowHeight + 50, root.popupRowHeight * 6 + 50)) padding: 1 focus: true background: Rectangle { color: root.background border.color: root.popupBorder border.width: 1 radius: Style.cornerRadius } onOpened: { searchField.text = "" root.recomputeFiltered() Qt.callLater(function() { searchField.forceActiveFocus() }) } onClosed: searchField.text = "" contentItem: Column { spacing: 0 Item { width: parent.width height: 38 TextField { id: searchField anchors.fill: parent anchors.margins: 6 placeholderText: root.placeholderText foreground: root.foreground accent: root.accent font.family: root.fontFamily font.pixelSize: 12 onTextChanged: { root.recomputeFiltered() if (resultList.count > 0) resultList.currentIndex = 0 } Keys.onPressed: function(event) { if (event.key === Qt.Key_Escape) { popup.close(); event.accepted = true } else if (event.key === Qt.Key_Down) { if (resultList.count > 0) { resultList.currentIndex = 0 resultList.forceActiveFocus() } event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { if (resultList.count > 0) { resultList.currentIndex = 0 resultList.selectCurrent() } event.accepted = true } } } } Rectangle { width: parent.width height: 1 color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) } Item { width: parent.width height: popup.height - 38 - 2 - 1 Text { anchors.centerIn: parent visible: resultList.count === 0 text: root.emptyText color: Qt.darker(root.foreground, 1.6) font.family: root.fontFamily font.pixelSize: 12 } ListView { id: resultList anchors.fill: parent clip: true boundsBehavior: Flickable.StopAtBounds model: root.filtered currentIndex: -1 keyNavigationEnabled: false function selectCurrent() { if (currentIndex < 0 || currentIndex >= root.filtered.length) return var v = root.optionValue(root.filtered[currentIndex]) root.value = v root.changed(v) popup.close() } Keys.onPressed: function(event) { if (event.key === Qt.Key_Escape) { popup.close(); event.accepted = true } else if (event.key === Qt.Key_Down || event.text === "j") { if (resultList.currentIndex >= resultList.count - 1) { event.accepted = true; return } resultList.currentIndex = resultList.currentIndex + 1 event.accepted = true } else if (event.key === Qt.Key_Up || event.text === "k") { if (resultList.currentIndex <= 0) { searchField.forceActiveFocus() event.accepted = true; return } resultList.currentIndex = resultList.currentIndex - 1 event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { resultList.selectCurrent(); event.accepted = true } } delegate: Rectangle { required property var modelData required property int index width: resultList.width height: root.popupRowHeight color: index === resultList.currentIndex ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.14) : "transparent" Column { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: 10 anchors.rightMargin: 10 spacing: 1 Text { text: root.optionLabel(modelData) color: index === resultList.currentIndex ? root.accent : root.foreground font.family: root.fontFamily font.pixelSize: 12 elide: Text.ElideRight width: parent.width } Text { visible: text !== "" text: root.optionDescription(modelData) color: Qt.darker(root.foreground, 1.5) font.family: root.fontFamily font.pixelSize: 10 elide: Text.ElideRight width: parent.width } } MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onPositionChanged: resultList.currentIndex = parent.index onClicked: resultList.selectCurrent() } } } } } } } } }