feat(ext/popup): add pop-out to tab for forms

Forms can now be opened in a full browser tab via the ⤴ button,
solving Chrome's popup closure on file picker interaction. Deep
linking via URL params preserves view, item type, and item ID.

Also removes the unused dropdown picker code from item-list.ts.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-27 01:32:39 -04:00
parent 39db697ce5
commit c59e6892d8
10 changed files with 99 additions and 111 deletions

View File

@@ -1,7 +1,7 @@
/// Typed-item add/edit form dispatcher. Each type's renderForm lives in
/// its own module under ./types/. Document stays "coming soon" until γ.
import { navigate, getState, setState, escapeHtml } from '../popup';
import { navigate, getState, setState, escapeHtml, popOutToTab } from '../popup';
import type { Item, ItemType } from '../../shared/types';
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [
@@ -57,6 +57,8 @@ function renderTypeSelection(app: HTMLElement): void {
<div style="display:flex; align-items:center; gap:12px; margin-bottom:16px;">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">new item</h3>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
<div class="type-select-list">
${TYPE_OPTIONS.map((opt) => `
@@ -70,6 +72,7 @@ function renderTypeSelection(app: HTMLElement): void {
`;
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
document.querySelectorAll<HTMLButtonElement>('[data-type]').forEach((btn) => {
btn.addEventListener('click', () => {

View File

@@ -223,102 +223,6 @@ function handleListKeydown(e: KeyboardEvent): void {
}
// ----------------------------------------------------------------------
// New-item type picker popover
// ----------------------------------------------------------------------
const NEW_TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; disabled?: boolean; tooltip?: string }> = [
{ type: 'login', icon: '🔑', label: 'login' },
{ type: 'secure_note', icon: '📝', label: 'secure note' },
{ type: 'identity', icon: '🪪', label: 'identity' },
{ type: 'card', icon: '💳', label: 'card' },
{ type: 'key', icon: '🗝', label: 'key' },
{ type: 'totp', icon: '⏱', label: 'totp' },
{ type: 'document', icon: '📄', label: 'document' },
];
function showNewTypePicker(anchor: HTMLElement): void {
document.querySelectorAll('.new-type-picker').forEach((el) => el.remove());
const picker = document.createElement('div');
picker.className = 'new-type-picker';
Object.assign(picker.style, {
position: 'absolute',
background: '#161b22',
border: '1px solid #30363d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
padding: '4px',
minWidth: '160px',
maxHeight: '280px',
overflowY: 'auto',
zIndex: '999999',
fontSize: '12px',
});
const rect = anchor.getBoundingClientRect();
const dropdownHeight = 220; // approx height for 7 items
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceBelow < dropdownHeight && rect.top > dropdownHeight) {
picker.style.bottom = `${window.innerHeight - rect.top + 4}px`;
} else {
picker.style.top = `${rect.bottom + 4}px`;
}
picker.style.left = `${rect.left}px`;
for (const opt of NEW_TYPE_OPTIONS) {
const row = document.createElement('div');
Object.assign(row.style, {
padding: '6px 10px',
cursor: opt.disabled ? 'not-allowed' : 'pointer',
color: opt.disabled ? '#484f58' : '#c9d1d9',
borderRadius: '4px',
display: 'flex', alignItems: 'center', gap: '8px',
});
if (opt.tooltip) row.title = opt.tooltip;
const iconSpan = document.createElement('span');
Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', display: 'inline-block', textAlign: 'center' });
iconSpan.textContent = opt.icon;
const labelSpan = document.createElement('span');
labelSpan.textContent = opt.label;
row.appendChild(iconSpan);
row.appendChild(labelSpan);
if (!opt.disabled) {
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
row.addEventListener('click', (ev) => {
ev.stopPropagation();
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
setState({ newType: opt.type });
navigate('add');
});
}
picker.appendChild(row);
}
document.body.appendChild(picker);
const closeOnOutside = (ev: MouseEvent) => {
if (!picker.contains(ev.target as Node)) {
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
}
};
const closeOnEsc = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
}
};
setTimeout(() => {
document.addEventListener('click', closeOnOutside);
document.addEventListener('keydown', closeOnEsc);
}, 0);
}
// ----------------------------------------------------------------------
// Settings picker popover (device vs vault)
// ----------------------------------------------------------------------

View File

@@ -1,7 +1,7 @@
/// Card: number / holder / expiry MonthYear / cvv / pin / kind.
/// Detail view has a styled card-silhouette signature block.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import type { Item, ItemId, ManifestEntry, CardKind, Section, AttachmentRef } from '../../../shared/types';
import {
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
@@ -173,7 +173,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new card' : 'edit card'}</div>
<div style="display:flex; align-items:center; margin-bottom:16px;">
<div class="detail-title">${mode === 'add' ? 'new card' : 'edit card'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Amex Gold"></div>
@@ -235,6 +239,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveCard(mode, existing, sectionsDraft, attachmentsDraft);
});

View File

@@ -2,7 +2,7 @@
/// notes/tags + optional supplementary attachments.
/// Primary attachment is referenced by ID from the item's attachments array.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
renderSectionsEditor, wireSectionsEditor,
@@ -84,7 +84,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${isEdit ? 'edit document' : 'new document'}</div>
<div style="display:flex; align-items:center; margin-bottom:16px;">
<div class="detail-title">${isEdit ? 'edit document' : 'new document'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group">
<label class="label" for="f-title">title <span class="req">*</span></label>
@@ -180,6 +184,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
setState({ error: null });
navigate(isEdit ? 'detail' : 'list');
});
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveDocument(mode, existing, primaryId, attachmentsDraft, sectionsDraft);

View File

@@ -1,7 +1,7 @@
/// Identity: full_name, address (multiline), phone, email, date_of_birth.
/// Detail view shows a "profile card" signature block + plain rows.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
renderRow, renderSignatureBlock, wireFieldHandlers, renderSections,
@@ -133,7 +133,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new identity' : 'edit identity'}</div>
<div style="display:flex; align-items:center; margin-bottom:16px;">
<div class="detail-title">${mode === 'add' ? 'new identity' : 'edit identity'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Aaron Lee · personal"></div>
@@ -190,6 +194,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveIdentity(mode, existing, sectionsDraft, attachmentsDraft);
});

View File

@@ -2,7 +2,7 @@
/// Form's key_material textarea uses CSS text-security to mask characters
/// since <textarea type="password"> isn't a thing.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
@@ -122,7 +122,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new key' : 'edit key'}</div>
<div style="display:flex; align-items:center; margin-bottom:16px;">
<div class="detail-title">${mode === 'add' ? 'new key' : 'edit key'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="github ssh"></div>
@@ -189,6 +193,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveKey(mode, existing, sectionsDraft, attachmentsDraft);
});

View File

@@ -1,7 +1,7 @@
/// Login type detail + form. Reference implementation for the shared
/// field helpers introduced in Slice 2.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import type { Item, ItemId, LoginCore, ManifestEntry, Section, TotpConfig, AttachmentRef } from '../../../shared/types';
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32';
@@ -243,7 +243,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
<div style="display:flex; align-items:center; margin-bottom:16px;">
<div class="detail-title">${mode === 'add' ? 'new login' : 'edit login'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
@@ -327,6 +331,8 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveLogin(mode, existing, sectionsDraft, attachmentsDraft);
});

View File

@@ -1,7 +1,7 @@
/// SecureNote: a single multiline body field. Concealed by default in the
/// detail view; the form is just a big <textarea>.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
renderConcealedRow, renderSignatureBlock, wireFieldHandlers, renderSections,
@@ -111,7 +111,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new secure note' : 'edit secure note'}</div>
<div style="display:flex; align-items:center; margin-bottom:16px;">
<div class="detail-title">${mode === 'add' ? 'new secure note' : 'edit secure note'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="My recovery codes"></div>
@@ -160,6 +164,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveSecureNote(mode, existing, sectionsDraft, attachmentsDraft);
});

View File

@@ -2,7 +2,7 @@
/// signature block with a thin SVG countdown ring; form has a kind toggle
/// (TOTP vs Steam Guard) and a single secret input.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../popup';
import type { Item, ItemId, ManifestEntry, Section, TotpKind, AttachmentRef } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32';
import {
@@ -212,7 +212,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
const renderInner = (): string => `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
<div style="display:flex; align-items:center; margin-bottom:16px;">
<div class="detail-title">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
<span style="flex:1;"></span>
<button class="btn" id="popout-btn" title="Open in tab">⤴</button>
</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title <span class="req">*</span></label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
@@ -328,6 +332,7 @@ function wireFormButtons(mode: 'add' | 'edit', existing: Item | null, sectionsDr
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('popout-btn')?.addEventListener('click', popOutToTab);
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveTotp(mode, existing, sectionsDraft, attachmentsDraft);
});

View File

@@ -28,6 +28,29 @@ export function escapeHtml(str: string): string {
.replace(/'/g, '&#39;');
}
// --- Pop out to tab ---
export function popOutToTab(): void {
const state = getState();
const params = new URLSearchParams();
params.set('view', state.view);
if (state.newType) params.set('type', state.newType);
if (state.selectedId) params.set('id', state.selectedId);
chrome.tabs.create({ url: `popup.html?${params.toString()}` });
window.close();
}
function parseUrlParams(): { view?: View; type?: string; id?: string } | null {
const params = new URLSearchParams(window.location.search);
const view = params.get('view');
if (!view) return null;
return {
view: view as View,
type: params.get('type') ?? undefined,
id: params.get('id') ?? undefined,
};
}
// --- State ---
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history';
@@ -214,6 +237,28 @@ async function init(): Promise<void> {
currentState.vaultSettings = vs;
currentState.generatorDefaults = vs.generator_defaults;
}
// Check URL params for deep linking (when opened in tab)
const urlParams = parseUrlParams();
if (urlParams) {
currentState.entries = listData.items;
if (urlParams.view === 'add' && urlParams.type) {
currentState.newType = urlParams.type as import('../shared/types').ItemType;
navigate('add');
return;
}
if ((urlParams.view === 'edit' || urlParams.view === 'detail') && urlParams.id) {
// Fetch the item
const itemResp = await sendMessage({ type: 'get_item', id: urlParams.id });
if (itemResp.ok) {
currentState.selectedId = urlParams.id;
currentState.selectedItem = (itemResp.data as { item: Item }).item;
navigate(urlParams.view);
return;
}
}
}
navigate('list', { entries: listData.items });
return;
}