From 3076fec7101c3307fb3a889547b5cb7bbb078a47 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:13:25 +0100 Subject: [PATCH] feat: quick-search fill modes + editable custom-field combobox + JS build gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick search (Ctrl+Shift+Q fill mode) - Enter / left-click → full autofill (username + Tab + password), like Ctrl+Shift+L. - Shift+Enter / right-click → username only (new Delphi username-only SendInput path via field=user; ExecuteAutofill AUsernameOnly param). - Ctrl+Enter / Ctrl+click → password only. - Copy mode (tray / palette) unchanged: Enter/left = password, Shift+Enter/right = username. - Clipboard fix: copy-then-minimise no longer wipes the just-copied password — MinimizeToTray takes an AClearClipboard flag (False on the quick-search copy path, driven by app/minimize?keepclip=1). The 30s auto-clear still guards it. - Right-click on a result row suppresses the native/custom context menu (preventDefault + stopPropagation). Editable custom-field combobox - Option-backed custom fields (card brand, expiry year/month, etc.) now render a custom editable combobox instead of a locked when the field declares an `options` array (set - // by entry templates for things like card brand or expiration month). - // Falls back to a plain otherwise. Storage shape unchanged — - // `field.value` still holds the chosen string. + // Value field. When the field declares an `options` array (entry + // templates: card brand, expiry year/month, etc.) we wrap the input in + // a CUSTOM editable combobox: an arrow button that drops a menu of ALL + // options (unlike a native , which filters to what's typed), + // while the input stays freely typeable for a value not in the list. + // Storage shape unchanged — `field.value` holds the string either way. let valueInput; - if (Array.isArray(field.options) && field.options.length > 0) { - valueInput = el('select', { class: 'so-input so-custom-value' }); - valueInput.appendChild(el('option', { value: '' }, '-- Select --')); + let valueSlot; // what actually goes into the row (input or combo wrap) + const hasOptions = Array.isArray(field.options) && field.options.length > 0; + valueInput = el('input', { + type: field.is_secret ? 'password' : 'text', + class: 'so-input so-custom-value', + placeholder: hasOptions ? 'Pick or type…' : 'Value', + autocomplete: field.is_secret ? 'new-password' : 'off', + spellcheck: 'false', + }); + valueInput.value = field.value || ''; + valueInput.addEventListener('input', () => { + field.value = valueInput.value; + soDirtyCheck(); + }); + if (hasOptions && !field.is_secret) { + const combo = el('div', { class: 'so-combo' }); + valueInput.classList.add('so-combo-input'); + const arrow = el('button', { + class: 'so-combo-arrow', type: 'button', tabindex: '-1', + title: 'Show options', + }); + arrow.appendChild(icon('i-chevron-down')); + const menu = el('div', { class: 'so-combo-menu is-hidden' }); field.options.forEach(opt => { - const o = el('option', { value: opt }, opt); - if (opt === (field.value || '')) o.selected = true; - valueInput.appendChild(o); + const item = el('div', { class: 'so-combo-item' }, opt); + // mousedown (not click) + preventDefault so the input doesn't + // blur-close the menu before we read the choice. + item.addEventListener('mousedown', ev => { + ev.preventDefault(); + ev.stopPropagation(); + valueInput.value = opt; + field.value = opt; + soDirtyCheck(); + menu.classList.add('is-hidden'); + }); + menu.appendChild(item); }); - valueInput.addEventListener('change', () => { - field.value = valueInput.value; - soDirtyCheck(); + arrow.addEventListener('click', ev => { + ev.stopPropagation(); + // Close any other open combo first, then toggle this one. + document.querySelectorAll('.so-combo-menu:not(.is-hidden)') + .forEach(m => { if (m !== menu) m.classList.add('is-hidden'); }); + menu.classList.toggle('is-hidden'); }); + combo.appendChild(valueInput); + combo.appendChild(arrow); + combo.appendChild(menu); + valueSlot = combo; } else { - valueInput = el('input', { - type: field.is_secret ? 'password' : 'text', - class: 'so-input so-custom-value', - placeholder: 'Value', - autocomplete: field.is_secret ? 'new-password' : 'off', - spellcheck: 'false', - }); - valueInput.value = field.value || ''; - valueInput.addEventListener('input', () => { - field.value = valueInput.value; - soDirtyCheck(); - }); + valueSlot = valueInput; } // Reveal eye — only meaningful for secret fields. @@ -4850,7 +4908,7 @@ function buildCustomFieldRow(field, idx, rerender) { }); row.appendChild(labelInput); - row.appendChild(valueInput); + row.appendChild(valueSlot); row.appendChild(eye); row.appendChild(secretBtn); row.appendChild(copyBtn); @@ -10804,6 +10862,13 @@ async function init() { let _slideoverMouseDownInside = false; document.addEventListener('mousedown', e => { _slideoverMouseDownInside = !!(e.target.closest && e.target.closest('.slideover')); + // Close any open custom combobox menu when the click lands outside + // a combo (the arrow toggle + item mousedown both stopPropagation, + // so this only fires for genuine outside clicks). + if (!(e.target.closest && e.target.closest('.so-combo'))) { + document.querySelectorAll('.so-combo-menu:not(.is-hidden)') + .forEach(m => m.classList.add('is-hidden')); + } }, true); document.addEventListener('click', e => { if (!$('#slideover').classList.contains('is-open')) return; @@ -11496,7 +11561,12 @@ async function init() { if (!sel) return; const id = parseInt(sel.dataset.id, 10); const entry = state.entries.find(x => x.id === id); - if (entry) quickSearchPickEntry(entry, e.shiftKey); + // Shift+Enter → username, Ctrl+Enter → password only, + // plain Enter → full (user + Tab + password). + const mode = e.shiftKey ? 'user' + : (e.ctrlKey || e.metaKey) ? 'pwd' + : 'full'; + if (entry) quickSearchPickEntry(entry, mode); } }); }