diff --git a/css/style.css b/css/style.css index f70b8c5..8ebc40e 100644 --- a/css/style.css +++ b/css/style.css @@ -1592,11 +1592,16 @@ input[type="range"]::-webkit-slider-thumb { margin-top: 6px; } .so-custom-row { + /* label | value | eye | copy | row menu */ display: grid; - grid-template-columns: 1fr 1.4fr auto auto auto auto; + grid-template-columns: 1fr 1.4fr auto auto auto; gap: 4px; align-items: center; } +/* The row menu is absolutely positioned inside its wrap; without this the + slideover clips it or it lands under the next row. */ +.so-custom-row .entry-kebab-wrap { position: relative; } +.so-custom-row .entry-kebab-menu { right: 0; } .so-custom-row .so-input { padding: 4px 8px; font-size: 12px; @@ -1644,21 +1649,13 @@ input[type="range"]::-webkit-slider-thumb { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .so-combo-item:hover { background: var(--accent); color: #fff; } -.so-custom-secret-toggle { - background: var(--bg); - border: 1px solid var(--border); - border-radius: 4px; - width: 28px; height: 28px; - cursor: pointer; - color: var(--text-dim); - font-size: 13px; - line-height: 1; - padding: 0; -} -.so-custom-secret-toggle.is-on { +/* The eye carries the secret state itself — a second toggle beside it read as + a duplicate of the reveal. Flipping the flag lives in the row menu. */ +.so-custom-row .icon-btn.is-secret { background: var(--accent-soft); color: var(--accent); - border-color: var(--accent); + border: 1px solid var(--accent); + border-radius: 4px; } .so-custom-add { margin-top: 8px; diff --git a/js/app.js b/js/app.js index 3ea3c1b..21105bf 100644 --- a/js/app.js +++ b/js/app.js @@ -4275,38 +4275,21 @@ function buildCustomFieldRow(field, idx, rerender) { if (valueInput.tagName !== 'INPUT') return; valueInput.type = valueInput.type === 'password' ? 'text' : 'password'; }); - if (!field.is_secret || (valueInput.tagName !== 'INPUT' && !field.multiline)) - eye.style.visibility = 'hidden'; - // Secret-flag toggle (chip-style) — flips both the storage flag and - // the visible input type. - const secretBtn = el('button', { - class: 'so-custom-secret-toggle' + (field.is_secret ? ' is-on' : ''), - type: 'button', - title: field.is_secret - ? 'Stored as secret — masked every time it is opened. Click to make it public.' - : 'Public field — shown in clear. Click to store it as a secret.', - }, field.is_secret ? '🔒' : '👁'); - secretBtn.addEventListener('click', ev => { - ev.stopPropagation(); - field.is_secret = !field.is_secret; - // Update this row in place. rerender() rebuilds every row from scratch - // (list.innerHTML = ''), which the user sees as the panel flashing — - // and it would drop the focus and the auto-sized height too. - secretBtn.classList.toggle('is-on', field.is_secret); - secretBtn.textContent = field.is_secret ? '🔒' : '👁'; - secretBtn.title = field.is_secret - ? 'Stored as secret — masked every time it is opened. Click to make it public.' - : 'Public field — shown in clear. Click to store it as a secret.'; + // The secret flag no longer has a button of its own: two adjacent toggles + // that both masked the value read as duplicates. The eye alone carries the + // state (cyan ring = stored as secret) and flipping it lives in the row + // menu, one deliberate step away from the everyday reveal. + function applySecretState() { + eye.classList.toggle('is-secret', !!field.is_secret); + eye.style.visibility = + (!field.is_secret || (valueInput.tagName !== 'INPUT' && !field.multiline)) + ? 'hidden' : ''; if (field.multiline) valueInput.classList.toggle('is-masked', field.is_secret); else if (valueInput.tagName === 'INPUT') valueInput.type = field.is_secret ? 'password' : 'text'; - eye.style.visibility = - (!field.is_secret || (valueInput.tagName !== 'INPUT' && !field.multiline)) - ? 'hidden' : ''; - soDirtyCheck(); - }); + } const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy value' }); @@ -4320,22 +4303,56 @@ function buildCustomFieldRow(field, idx, rerender) { toast('Copied' + clipClearSuffix()); }); - const delBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', - title: 'Remove field' }); - delBtn.appendChild(icon('i-x')); - delBtn.addEventListener('click', ev => { + // Row menu — reuses the card kebab classes, so the document-level + // "click outside .entry-kebab-wrap closes it" handler covers it for free. + const kebabWrap = el('div', { class: 'entry-kebab-wrap' }); + const kebabBtn = el('button', { class: 'entry-kebab', type: 'button', + title: 'Field options' }); + kebabBtn.appendChild(icon('i-more')); + const kebabMenu = el('div', { class: 'entry-kebab-menu' }); + kebabBtn.addEventListener('click', ev => { + ev.stopPropagation(); + $$('.entry-kebab-menu.is-open').forEach(m => { + if (m !== kebabMenu) m.classList.remove('is-open'); + }); + kebabMenu.classList.toggle('is-open'); + }); + + const secretItem = el('button', { class: 'kebab-item', type: 'button' }); + const paintSecretItem = () => { + secretItem.textContent = field.is_secret ? 'Make public' : 'Store as secret'; + }; + paintSecretItem(); + secretItem.addEventListener('click', ev => { + ev.stopPropagation(); + field.is_secret = !field.is_secret; + // In place, never rerender(): rebuilding the list flashes the panel and + // drops both the focus and a multiline field's auto-sized height. + applySecretState(); + paintSecretItem(); + kebabMenu.classList.remove('is-open'); + soDirtyCheck(); + }); + kebabMenu.appendChild(secretItem); + + const delItem = el('button', { class: 'kebab-item is-danger', type: 'button' }, + 'Remove field'); + delItem.addEventListener('click', ev => { ev.stopPropagation(); soState.customFields.splice(idx, 1); rerender(); soDirtyCheck(); }); + kebabMenu.appendChild(delItem); + kebabWrap.appendChild(kebabBtn); + kebabWrap.appendChild(kebabMenu); row.appendChild(labelInput); row.appendChild(valueSlot); row.appendChild(eye); - row.appendChild(secretBtn); row.appendChild(copyBtn); - row.appendChild(delBtn); + row.appendChild(kebabWrap); + applySecretState(); return row; }