feat(ext/popup): hide popout-to-tab button in fullscreen forms

The ⤴ popout button is meaningless when the form is already in
vault.html — gate it on !isInTab(). Affects all seven type forms plus
the type-selection screen. Regression tests cover both popup (button
present) and fullscreen (button absent) contexts via it.each across
all 7 forms.

Plan 2026-04-30 fullscreen UX phase 1 task 7.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-30 21:01:47 -04:00
parent 05b1fae9f4
commit 71ad91592d
10 changed files with 106 additions and 14 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, popOutToTab } from '../../shared/state';
import { navigate, getState, setState, escapeHtml, popOutToTab, isInTab } from '../../shared/state';
import type { Item, ItemType } from '../../shared/types';
const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string }> = [
@@ -58,7 +58,7 @@ function renderTypeSelection(app: HTMLElement): void {
<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>
${isInTab() ? '' : '<button class="btn" id="popout-btn" title="Open in tab">⤴</button>'}
</div>
<div class="type-select-list">
${TYPE_OPTIONS.map((opt) => `

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../../../shared/state', () => ({
sendMessage: vi.fn(),
getState: () => ({ newType: 'login', generatorDefaults: null, error: null, loading: false, vaultSettings: null, entries: [] }),
setState: vi.fn(),
navigate: vi.fn(),
escapeHtml: (s: string) => s,
popOutToTab: vi.fn(),
isInTab: () => true, // FULLSCREEN context
openVaultTab: vi.fn(),
registerHost: vi.fn(),
}));
vi.mock('../../generator-panel', () => ({
openGeneratorPanel: vi.fn(),
closeGeneratorPanel: vi.fn(),
isGeneratorPanelOpen: () => false,
}));
import * as login from '../login';
import * as secureNote from '../secure-note';
import * as identity from '../identity';
import * as card from '../card';
import * as key from '../key';
import * as totp from '../totp';
import * as documentType from '../document';
const forms: Array<[string, (app: HTMLElement, mode: 'add' | 'edit', existing: null) => void]> = [
['login', login.renderForm],
['secure-note', secureNote.renderForm],
['identity', identity.renderForm],
['card', card.renderForm],
['key', key.renderForm],
['totp', totp.renderForm],
['document', documentType.renderForm],
];
describe('popout-to-tab button (fullscreen context)', () => {
beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });
it.each(forms)('%s form does NOT render the popout button', (_name, render) => {
render(document.getElementById('app')!, 'add', null);
expect(document.getElementById('popout-btn')).toBeNull();
});
});

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../../../shared/state', () => ({
sendMessage: vi.fn(),
getState: () => ({ newType: 'login', generatorDefaults: null, error: null, loading: false, vaultSettings: null, entries: [] }),
setState: vi.fn(),
navigate: vi.fn(),
escapeHtml: (s: string) => s,
popOutToTab: vi.fn(),
isInTab: () => false, // POPUP context
openVaultTab: vi.fn(),
registerHost: vi.fn(),
}));
vi.mock('../../generator-panel', () => ({
openGeneratorPanel: vi.fn(),
closeGeneratorPanel: vi.fn(),
isGeneratorPanelOpen: () => false,
}));
import * as login from '../login';
import * as secureNote from '../secure-note';
import * as identity from '../identity';
import * as card from '../card';
import * as key from '../key';
import * as totp from '../totp';
import * as documentType from '../document';
const forms: Array<[string, (app: HTMLElement, mode: 'add' | 'edit', existing: null) => void]> = [
['login', login.renderForm],
['secure-note', secureNote.renderForm],
['identity', identity.renderForm],
['card', card.renderForm],
['key', key.renderForm],
['totp', totp.renderForm],
['document', documentType.renderForm],
];
describe('popout-to-tab button (popup context)', () => {
beforeEach(() => { document.body.innerHTML = '<div id="app"></div>'; });
it.each(forms)('%s form renders the popout button', (_name, render) => {
render(document.getElementById('app')!, 'add', null);
expect(document.getElementById('popout-btn')).not.toBeNull();
});
});

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, popOutToTab } from '../../../shared/state';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../../shared/state';
import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
import type { Item, ItemId, ManifestEntry, CardKind, Section, AttachmentRef } from '../../../shared/types';
import {
@@ -177,7 +177,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<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>
${isInTab() ? '' : '<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 ${REQUIRED_PILL_HTML}</label>

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, popOutToTab } from '../../../shared/state';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../../shared/state';
import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
@@ -88,7 +88,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<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>
${isInTab() ? '' : '<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">

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, popOutToTab } from '../../../shared/state';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../../shared/state';
import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
@@ -137,7 +137,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<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>
${isInTab() ? '' : '<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 ${REQUIRED_PILL_HTML}</label>

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, popOutToTab } from '../../../shared/state';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../../shared/state';
import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types';
import {
@@ -126,7 +126,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<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>
${isInTab() ? '' : '<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 ${REQUIRED_PILL_HTML}</label>

View File

@@ -247,7 +247,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<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>
${isInTab() ? '' : '<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 ${REQUIRED_PILL_HTML}</label>

View File

@@ -115,7 +115,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<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>
${isInTab() ? '' : '<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 ${REQUIRED_PILL_HTML}</label>

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, popOutToTab } from '../../../shared/state';
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../../shared/state';
import { REQUIRED_PILL_HTML } from '../../../shared/glyphs';
import type { Item, ItemId, ManifestEntry, Section, TotpKind, AttachmentRef } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32';
@@ -216,7 +216,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<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>
${isInTab() ? '' : '<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 ${REQUIRED_PILL_HTML}</label>