feat(ext/login): add surface flag for two-column fullscreen form
renderForm() takes an optional { surface: 'popup' | 'fullscreen' }
parameter. When 'fullscreen', the Identity and Credentials field
groups render as glass cards inside a .form-grid (two columns,
stacks at <=720px). Popup keeps its single-column layout.
This commit is contained in:
@@ -41,7 +41,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
||||
const type: ItemType = existing?.type ?? state.newType ?? 'login';
|
||||
|
||||
switch (type) {
|
||||
case 'login': return login.renderForm(app, mode, existing);
|
||||
case 'login': return login.renderForm(app, mode, existing, { surface: isInTab() ? 'fullscreen' : 'popup' });
|
||||
case 'secure_note': return secureNote.renderForm(app, mode, existing);
|
||||
case 'identity': return identity.renderForm(app, mode, existing);
|
||||
case 'card': return card.renderForm(app, mode, existing);
|
||||
|
||||
@@ -63,6 +63,40 @@ describe('login form smart inputs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderForm surface flag', () => {
|
||||
let app: HTMLElement;
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
app = document.getElementById('app')!;
|
||||
(globalThis as any).chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})),
|
||||
set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: vi.fn(),
|
||||
},
|
||||
};
|
||||
vi.mocked(sendMessage).mockReset();
|
||||
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { groups: [] } });
|
||||
});
|
||||
|
||||
it('renders single-column when surface is "popup" (default)', () => {
|
||||
renderForm(app, 'add', null);
|
||||
expect(app.querySelector('.form-grid')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders two-column .form-grid wrapper when surface is "fullscreen"', () => {
|
||||
renderForm(app, 'add', null, { surface: 'fullscreen' });
|
||||
const grid = app.querySelector('.form-grid');
|
||||
expect(grid).toBeTruthy();
|
||||
expect(grid!.querySelector('[data-form-section="identity"]')).toBeTruthy();
|
||||
expect(grid!.querySelector('[data-form-section="credentials"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login save shape', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app"></div>';
|
||||
|
||||
@@ -235,7 +235,20 @@ function startTotpTicker(id: ItemId): void {
|
||||
// Form (add / edit)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
|
||||
export interface RenderFormOptions {
|
||||
surface?: 'popup' | 'fullscreen';
|
||||
/** When true, renderForm skips its own save/cancel buttons (caller provides them in a sticky bar). */
|
||||
externalActions?: boolean;
|
||||
}
|
||||
|
||||
export function renderForm(
|
||||
app: HTMLElement,
|
||||
mode: 'add' | 'edit',
|
||||
existing: Item | null,
|
||||
opts: RenderFormOptions = {}
|
||||
): void {
|
||||
const surface = opts.surface ?? 'popup';
|
||||
const externalActions = opts.externalActions ?? false;
|
||||
const state = getState();
|
||||
const existingCore = (existing?.core.type === 'login')
|
||||
? (existing.core as LoginCore & { type: 'login' })
|
||||
@@ -254,14 +267,11 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
: [];
|
||||
let attachmentsDraft: AttachmentRef[] = existing?.attachments ?? [];
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
|
||||
const titleFieldHtml = `
|
||||
<div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
|
||||
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>`;
|
||||
|
||||
const urlFieldHtml = `
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-url">url</label>
|
||||
<div class="inline-row">
|
||||
@@ -269,11 +279,17 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
|
||||
</div>
|
||||
<div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const groupFieldHtml = `
|
||||
<div class="form-group"><label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>`;
|
||||
|
||||
const usernameFieldHtml = `
|
||||
<div class="form-group"><label class="label" for="f-username">username</label>
|
||||
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>
|
||||
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>`;
|
||||
|
||||
const passwordFieldHtml = `
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-password">password</label>
|
||||
<div class="inline-row">
|
||||
@@ -285,8 +301,9 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
<div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
|
||||
<div class="strength-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const totpFieldHtml = `
|
||||
<div class="form-group">
|
||||
<label class="label" for="f-totp">totp secret (base32)</label>
|
||||
<div class="inline-row">
|
||||
@@ -302,10 +319,34 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
<div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
|
||||
<div id="totp-qr-error" class="totp-qr-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
<div class="form-group"><label class="label" for="f-group">group</label>
|
||||
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
|
||||
const identityHtml = `
|
||||
<div data-form-section="identity" class="${surface === 'fullscreen' ? 'glass form-col' : ''}">
|
||||
${surface === 'fullscreen' ? '<div class="col-header">Identity</div>' : ''}
|
||||
${titleFieldHtml}
|
||||
${urlFieldHtml}
|
||||
${groupFieldHtml}
|
||||
</div>`;
|
||||
|
||||
const credentialsHtml = `
|
||||
<div data-form-section="credentials" class="${surface === 'fullscreen' ? 'glass form-col' : ''}">
|
||||
${surface === 'fullscreen' ? '<div class="col-header">Credentials</div>' : ''}
|
||||
${usernameFieldHtml}
|
||||
${passwordFieldHtml}
|
||||
${totpFieldHtml}
|
||||
</div>`;
|
||||
|
||||
const sectionsHtml = surface === 'fullscreen'
|
||||
? `<div class="form-grid">${identityHtml}${credentialsHtml}</div>`
|
||||
: `${identityHtml}${credentialsHtml}`;
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="pad">
|
||||
${surface === 'popup' ? renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' }) : ''}
|
||||
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
|
||||
|
||||
${sectionsHtml}
|
||||
|
||||
<div class="form-group">
|
||||
<div class="notes-with-toggle">
|
||||
@@ -317,10 +358,12 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
|
||||
|
||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
||||
${externalActions ? '' : `
|
||||
<div class="form-actions">
|
||||
<button class="btn" id="cancel-btn">cancel</button>
|
||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -433,7 +476,7 @@ function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; e
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
||||
export async function saveLogin(mode: 'add' | 'edit', existing: Item | null, sectionsDraft: Section[], attachmentsDraft: AttachmentRef[]): Promise<void> {
|
||||
const state = getState();
|
||||
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
|
||||
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
|
||||
|
||||
@@ -1580,3 +1580,28 @@ textarea {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* Phase 2B: two-column form grid for fullscreen login */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.form-col {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.col-header {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
border-bottom: 1px solid var(--border-mid);
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user