feat(ext/popup): rewrite generator as inline panel with ✨ trigger
The popover (which clipped off the popup edge) becomes an inline panel that mounts inside the form (login.ts) or settings section (settings-vault.ts). Trigger button is ✨ with aria-expanded toggling. Action row varies by context: fill-field has cancel+use; configure- defaults has only the save-default link. Escape key closes the panel. Tests adapted to new API; 3 new tests for aria-expanded, auto-generate, and Escape behavior. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
/// Inline generator popover — anchored to a "gen" button, renders a
|
||||
/// live preview that updates as knobs change (150ms debounce). Single
|
||||
/// underlying GeneratorRequest; kind toggle swaps between Random +
|
||||
/// BIP39 knob sets. Actions: use / save-as-default / reset / cancel.
|
||||
/// Inline generator panel — mounts inside a parent element (form root or
|
||||
/// settings section). Trigger button gets aria-expanded toggled. Preview
|
||||
/// updates live as knobs change (150ms debounce). Kind toggle swaps
|
||||
/// between Random + BIP39 knob sets. Action row varies by context:
|
||||
/// fill-field shows cancel+use; configure-defaults shows only save-default.
|
||||
|
||||
import { sendMessage } from '../popup';
|
||||
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
||||
@@ -74,38 +75,52 @@ function requestFromKnobs(knobs: UiKnobs): GeneratorRequest {
|
||||
};
|
||||
}
|
||||
|
||||
let activePopover: {
|
||||
host: HTMLElement;
|
||||
cleanup: () => void;
|
||||
} | null = null;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
export type GeneratorPanelContext = 'fill-field' | 'configure-defaults';
|
||||
|
||||
export interface OpenPopoverOpts {
|
||||
anchor: HTMLElement;
|
||||
export interface OpenPanelOpts {
|
||||
parent: HTMLElement; // mount target (form root or settings section)
|
||||
trigger: HTMLElement; // ✨ button (aria-expanded gets toggled here)
|
||||
initial: GeneratorRequest;
|
||||
onPicked: (value: string) => void;
|
||||
context: GeneratorPanelContext;
|
||||
onPicked?: (value: string) => void; // required when context === 'fill-field'
|
||||
}
|
||||
|
||||
export function openGeneratorPopover(opts: OpenPopoverOpts): void {
|
||||
closeGeneratorPopover();
|
||||
let activePanel: {
|
||||
host: HTMLElement;
|
||||
trigger: HTMLElement;
|
||||
cleanup: () => void;
|
||||
} | null = null;
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function openGeneratorPanel(opts: OpenPanelOpts): void {
|
||||
closeGeneratorPanel();
|
||||
|
||||
const knobs = knobsFromRequest(opts.initial);
|
||||
let currentPreview = '';
|
||||
|
||||
const host = document.createElement('div');
|
||||
host.className = 'generator-popover';
|
||||
document.body.appendChild(host);
|
||||
host.className = 'gen-panel';
|
||||
opts.parent.appendChild(host);
|
||||
|
||||
// Position below anchor
|
||||
const rect = opts.anchor.getBoundingClientRect();
|
||||
host.style.top = `${rect.bottom + 6}px`;
|
||||
host.style.left = `${rect.left}px`;
|
||||
opts.trigger.setAttribute('aria-expanded', 'true');
|
||||
|
||||
const render = (): void => {
|
||||
host.innerHTML = buildInnerHtml(knobs);
|
||||
wireInner();
|
||||
refreshPreview();
|
||||
const escHandler = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') closeGeneratorPanel();
|
||||
};
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
const cleanup = (): void => {
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
if (debounceTimer !== null) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
opts.trigger.setAttribute('aria-expanded', 'false');
|
||||
host.remove();
|
||||
};
|
||||
|
||||
activePanel = { host, trigger: opts.trigger, cleanup };
|
||||
|
||||
const refreshPreview = (): void => {
|
||||
if (debounceTimer !== null) clearTimeout(debounceTimer);
|
||||
@@ -119,7 +134,7 @@ export function openGeneratorPopover(opts: OpenPopoverOpts): void {
|
||||
if (resp.ok) {
|
||||
const d = resp.data as { password?: string; passphrase?: string };
|
||||
currentPreview = d.password ?? d.passphrase ?? '';
|
||||
const el = host.querySelector('.gen-preview__value');
|
||||
const el = host.querySelector('.preview__value');
|
||||
if (el) el.textContent = currentPreview;
|
||||
updateValidation();
|
||||
}
|
||||
@@ -132,8 +147,6 @@ export function openGeneratorPopover(opts: OpenPopoverOpts): void {
|
||||
const noClass = knobs.kind === 'random'
|
||||
&& !(knobs.lower || knobs.upper || knobs.digits || knobs.symbols);
|
||||
useBtn.disabled = noClass;
|
||||
const note = host.querySelector('.gen-validation');
|
||||
if (note) (note as HTMLElement).style.display = noClass ? 'block' : 'none';
|
||||
};
|
||||
|
||||
const wireInner = (): void => {
|
||||
@@ -192,144 +205,126 @@ export function openGeneratorPopover(opts: OpenPopoverOpts): void {
|
||||
});
|
||||
});
|
||||
|
||||
host.querySelector('.gen-preview__regen')?.addEventListener('click', () => {
|
||||
host.querySelector('.preview__regen')?.addEventListener('click', () => {
|
||||
refreshPreview();
|
||||
});
|
||||
|
||||
host.querySelector('#gen-use')?.addEventListener('click', () => {
|
||||
opts.onPicked(currentPreview);
|
||||
closeGeneratorPopover();
|
||||
opts.onPicked?.(currentPreview);
|
||||
closeGeneratorPanel();
|
||||
});
|
||||
|
||||
host.querySelector('#gen-cancel')?.addEventListener('click', () => {
|
||||
closeGeneratorPanel();
|
||||
});
|
||||
|
||||
host.querySelector('#gen-save-default')?.addEventListener('click', async () => {
|
||||
const getResp = await sendMessage({ type: 'get_vault_settings' });
|
||||
if (!getResp.ok) return;
|
||||
const vs = (getResp.data as { settings: VaultSettings }).settings;
|
||||
const updated: VaultSettings = { ...vs, generator_defaults: requestFromKnobs(knobs) };
|
||||
await sendMessage({ type: 'update_vault_settings', settings: updated });
|
||||
const btn = host.querySelector('#gen-save-default') as HTMLButtonElement | null;
|
||||
if (btn) {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'saved';
|
||||
setTimeout(() => { if (btn.textContent === 'saved') btn.textContent = original; }, 1500);
|
||||
const link = host.querySelector('#gen-save-default') as HTMLElement | null;
|
||||
const settingsResp = await sendMessage({ type: 'get_vault_settings' });
|
||||
if (!settingsResp.ok) return;
|
||||
const settings = (settingsResp.data as { settings: VaultSettings }).settings;
|
||||
settings.generator_defaults = requestFromKnobs(knobs);
|
||||
const updateResp = await sendMessage({ type: 'update_vault_settings', settings });
|
||||
if (!updateResp.ok) return;
|
||||
if (link) {
|
||||
link.querySelector('.save-link__toast')?.remove();
|
||||
const toast = document.createElement('span');
|
||||
toast.className = 'save-link__toast';
|
||||
toast.textContent = '✓ saved';
|
||||
link.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 1500);
|
||||
}
|
||||
});
|
||||
|
||||
host.querySelector('#gen-reset')?.addEventListener('click', async () => {
|
||||
const getResp = await sendMessage({ type: 'get_vault_settings' });
|
||||
if (!getResp.ok) return;
|
||||
const vs = (getResp.data as { settings: VaultSettings }).settings;
|
||||
Object.assign(knobs, knobsFromRequest(vs.generator_defaults));
|
||||
render();
|
||||
});
|
||||
|
||||
host.querySelector('#gen-cancel')?.addEventListener('click', () => closeGeneratorPopover());
|
||||
host.querySelector('#gen-close')?.addEventListener('click', () => closeGeneratorPopover());
|
||||
};
|
||||
|
||||
const onOutsideClick = (e: MouseEvent) => {
|
||||
if (!host.contains(e.target as Node) && e.target !== opts.anchor) {
|
||||
closeGeneratorPopover();
|
||||
}
|
||||
const render = (): void => {
|
||||
host.innerHTML = buildInnerHtml(knobs, opts.context);
|
||||
wireInner();
|
||||
refreshPreview();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeGeneratorPopover();
|
||||
};
|
||||
|
||||
const cleanup = (): void => {
|
||||
document.removeEventListener('click', onOutsideClick, true);
|
||||
document.removeEventListener('keydown', onEsc);
|
||||
host.remove();
|
||||
};
|
||||
|
||||
activePopover = { host, cleanup };
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', onOutsideClick, true);
|
||||
document.addEventListener('keydown', onEsc);
|
||||
}, 0);
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
export function closeGeneratorPopover(): void {
|
||||
if (activePopover === null) return;
|
||||
if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; }
|
||||
activePopover.cleanup();
|
||||
activePopover = null;
|
||||
export function closeGeneratorPanel(): void {
|
||||
if (activePanel === null) return;
|
||||
activePanel.cleanup();
|
||||
activePanel = null;
|
||||
}
|
||||
|
||||
export function isGeneratorPanelOpen(): boolean {
|
||||
return activePanel !== null;
|
||||
}
|
||||
|
||||
// --- HTML builders ---
|
||||
|
||||
function buildInnerHtml(knobs: UiKnobs): string {
|
||||
function buildInnerHtml(knobs: UiKnobs, context: GeneratorPanelContext): string {
|
||||
const actionRow = context === 'fill-field'
|
||||
? `<button class="save-link" id="gen-save-default" type="button">↑ save these as default</button>
|
||||
<button class="btn" id="gen-cancel" type="button">cancel</button>
|
||||
<button class="btn btn-primary" id="gen-use" type="button">use</button>`
|
||||
: `<button class="save-link" id="gen-save-default" type="button">↑ save these as default</button>`;
|
||||
|
||||
return `
|
||||
<div class="gen-header">
|
||||
<span class="gen-title">generate</span>
|
||||
<button type="button" id="gen-close" class="gen-close">×</button>
|
||||
</div>
|
||||
<div class="gen-row">
|
||||
<span class="gen-row__label">kind</span>
|
||||
<div class="gen-toggle-group">
|
||||
<button id="gen-kind-random" class="${knobs.kind === 'random' ? 'active' : ''}">Random</button>
|
||||
<button id="gen-kind-bip39" class="${knobs.kind === 'bip39' ? 'active' : ''}">BIP39</button>
|
||||
</div>
|
||||
<div class="panel-toggle">
|
||||
<button id="gen-kind-random" type="button" class="${knobs.kind === 'random' ? 'active' : ''}">Random</button>
|
||||
<button id="gen-kind-bip39" type="button" class="${knobs.kind === 'bip39' ? 'active' : ''}">BIP39</button>
|
||||
</div>
|
||||
${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)}
|
||||
<div class="gen-preview">
|
||||
<span class="gen-preview__value"></span>
|
||||
<button type="button" class="gen-preview__regen" title="regenerate">↻</button>
|
||||
<div class="preview">
|
||||
<span class="preview__value"></span>
|
||||
<button type="button" class="preview__regen" title="regenerate">↻</button>
|
||||
</div>
|
||||
${knobs.kind === 'random'
|
||||
? `<p class="gen-validation" style="display:none;color:#ab2b20;font-size:10px;margin:4px 0 0;">pick at least one character class</p>`
|
||||
: ''}
|
||||
<div class="gen-actions">
|
||||
<button type="button" class="btn" id="gen-reset">reset to defaults</button>
|
||||
<button type="button" class="btn" id="gen-save-default">save as default</button>
|
||||
<button type="button" class="btn" id="gen-cancel">cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="gen-use">use this value</button>
|
||||
<div class="actions">
|
||||
${actionRow}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildRandomKnobs(k: UiKnobs): string {
|
||||
return `
|
||||
<div class="gen-row">
|
||||
<span class="gen-row__label">length</span>
|
||||
<input type="range" id="gen-length" min="8" max="64" value="${k.length}" class="gen-slider">
|
||||
<span id="gen-length-val">${k.length}</span>
|
||||
<div class="knob">
|
||||
<span class="knob__label">length</span>
|
||||
<input type="range" id="gen-length" min="8" max="64" value="${k.length}" class="knob__slider">
|
||||
<span class="knob__value" id="gen-length-val">${k.length}</span>
|
||||
</div>
|
||||
<div class="gen-check-grid">
|
||||
<div class="classes">
|
||||
<label><input type="checkbox" id="gen-lower" ${k.lower ? 'checked' : ''}> lowercase</label>
|
||||
<label><input type="checkbox" id="gen-digits" ${k.digits ? 'checked' : ''}> digits</label>
|
||||
<label><input type="checkbox" id="gen-upper" ${k.upper ? 'checked' : ''}> uppercase</label>
|
||||
<label><input type="checkbox" id="gen-digits" ${k.digits ? 'checked' : ''}> digits</label>
|
||||
<label><input type="checkbox" id="gen-symbols" ${k.symbols ? 'checked' : ''}> symbols</label>
|
||||
</div>
|
||||
<div class="gen-row">
|
||||
<span class="gen-row__label">symbols</span>
|
||||
<div class="gen-toggle-group">
|
||||
<button data-symbol-charset="safe_only" class="${k.symbolCharset === 'safe_only' ? 'active' : ''}">safe</button>
|
||||
<button data-symbol-charset="extended" class="${k.symbolCharset === 'extended' ? 'active' : ''}">extended</button>
|
||||
<details class="more">
|
||||
<summary>more ▾</summary>
|
||||
<div class="more__advanced">
|
||||
<div class="knob">
|
||||
<span class="knob__label">symbols</span>
|
||||
<div class="panel-toggle" style="flex:1;">
|
||||
<button data-symbol-charset="safe_only" type="button" class="${k.symbolCharset === 'safe_only' ? 'active' : ''}">safe</button>
|
||||
<button data-symbol-charset="extended" type="button" class="${k.symbolCharset === 'extended' ? 'active' : ''}">extended</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildBip39Knobs(k: UiKnobs): string {
|
||||
const sepChip = (label: string, sep: string) => `
|
||||
<button data-separator="${sep}" class="${k.separator === sep ? 'active' : ''}">${label}</button>
|
||||
<button data-separator="${sep}" type="button" class="${k.separator === sep ? 'active' : ''}">${label}</button>
|
||||
`;
|
||||
const capChip = (label: string, val: string) => `
|
||||
<button data-capitalization="${val}" class="${k.capitalization === val ? 'active' : ''}">${label}</button>
|
||||
<button data-capitalization="${val}" type="button" class="${k.capitalization === val ? 'active' : ''}">${label}</button>
|
||||
`;
|
||||
return `
|
||||
<div class="gen-row">
|
||||
<span class="gen-row__label">words</span>
|
||||
<input type="range" id="gen-word-count" min="3" max="12" value="${k.wordCount}" class="gen-slider">
|
||||
<span id="gen-word-count-val">${k.wordCount}</span>
|
||||
<div class="knob">
|
||||
<span class="knob__label">words</span>
|
||||
<input type="range" id="gen-word-count" min="3" max="12" value="${k.wordCount}" class="knob__slider">
|
||||
<span class="knob__value" id="gen-word-count-val">${k.wordCount}</span>
|
||||
</div>
|
||||
<div class="gen-row">
|
||||
<span class="gen-row__label">separator</span>
|
||||
<div class="gen-toggle-group">
|
||||
<div class="knob" style="align-items:flex-start;">
|
||||
<span class="knob__label">separator</span>
|
||||
<div class="panel-toggle" style="flex:1;">
|
||||
${sepChip('space', ' ')}
|
||||
${sepChip('-', '-')}
|
||||
${sepChip('_', '_')}
|
||||
@@ -337,9 +332,9 @@ function buildBip39Knobs(k: UiKnobs): string {
|
||||
${sepChip(':', ':')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="gen-row">
|
||||
<span class="gen-row__label">case</span>
|
||||
<div class="gen-toggle-group">
|
||||
<div class="knob" style="align-items:flex-start;">
|
||||
<span class="knob__label">case</span>
|
||||
<div class="panel-toggle" style="flex:1;">
|
||||
${capChip('lower', 'lower')}
|
||||
${capChip('upper', 'upper')}
|
||||
${capChip('first', 'first_of_each')}
|
||||
|
||||
Reference in New Issue
Block a user