feat(ext/popup): field-row + concealed-row + signature-block helpers
This commit is contained in:
117
extension/src/popup/components/__tests__/fields.test.ts
Normal file
117
extension/src/popup/components/__tests__/fields.test.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
renderRow,
|
||||||
|
renderConcealedRow,
|
||||||
|
renderSignatureBlock,
|
||||||
|
wireFieldHandlers,
|
||||||
|
} from '../fields';
|
||||||
|
|
||||||
|
describe('renderRow', () => {
|
||||||
|
it('plain row contains label + value', () => {
|
||||||
|
const html = renderRow({ label: 'username', value: 'alice' });
|
||||||
|
expect(html).toContain('username');
|
||||||
|
expect(html).toContain('alice');
|
||||||
|
expect(html).toContain('field-row');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copyable row exposes a copy action', () => {
|
||||||
|
const html = renderRow({ label: 'email', value: 'alice@example.com', copyable: true });
|
||||||
|
expect(html).toContain('data-field-action="copy"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('href row wraps value in an external anchor', () => {
|
||||||
|
const html = renderRow({ label: 'url', value: 'https://example.com', href: 'https://example.com' });
|
||||||
|
expect(html).toContain('href="https://example.com"');
|
||||||
|
expect(html).toContain('target="_blank"');
|
||||||
|
expect(html).toContain('rel="noopener noreferrer"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('monospace flag toggles the monospace class', () => {
|
||||||
|
const html = renderRow({ label: 'fingerprint', value: 'AB:CD', monospace: true });
|
||||||
|
expect(html).toContain('monospace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiline value renders inside a <pre>', () => {
|
||||||
|
const html = renderRow({ label: 'address', value: '1 Main\n2 Main', multiline: true });
|
||||||
|
expect(html).toContain('<pre');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes HTML in value and label', () => {
|
||||||
|
const html = renderRow({ label: '<script>x</script>', value: '"&<>' });
|
||||||
|
expect(html).not.toContain('<script>');
|
||||||
|
expect(html).toContain('&');
|
||||||
|
expect(html).toContain('<');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderConcealedRow', () => {
|
||||||
|
it('initial state hides the value behind a placeholder', () => {
|
||||||
|
const html = renderConcealedRow({ id: 'pw1', label: 'password', value: 'hunter2' });
|
||||||
|
expect(html).toContain('data-field-id="pw1"');
|
||||||
|
expect(html).toContain('data-revealed="false"');
|
||||||
|
expect(html).toContain('••••');
|
||||||
|
// Plaintext is in a data attribute on the row, NOT in the visible textContent.
|
||||||
|
expect(html).not.toMatch(/>hunter2</);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes show + copy actions', () => {
|
||||||
|
const html = renderConcealedRow({ id: 'pw1', label: 'password', value: 'hunter2' });
|
||||||
|
expect(html).toContain('data-field-action="reveal"');
|
||||||
|
expect(html).toContain('data-field-action="copy"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiline concealed shows char count when hidden', () => {
|
||||||
|
const html = renderConcealedRow({ id: 'k1', label: 'key', value: 'abcdefghij', multiline: true });
|
||||||
|
expect(html).toContain('•••• (10 chars)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderSignatureBlock', () => {
|
||||||
|
it('default accent is blue', () => {
|
||||||
|
const html = renderSignatureBlock({ children: '<p>hi</p>' });
|
||||||
|
expect(html).toContain('sig-block--blue');
|
||||||
|
expect(html).toContain('<p>hi</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors accent prop', () => {
|
||||||
|
expect(renderSignatureBlock({ accent: 'green', children: '' })).toContain('sig-block--green');
|
||||||
|
expect(renderSignatureBlock({ accent: 'amber', children: '' })).toContain('sig-block--amber');
|
||||||
|
expect(renderSignatureBlock({ accent: 'red', children: '' })).toContain('sig-block--red');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wireFieldHandlers', () => {
|
||||||
|
it('reveal toggle flips data-revealed and swaps placeholder for plaintext', () => {
|
||||||
|
document.body.innerHTML = renderConcealedRow({
|
||||||
|
id: 'pw1',
|
||||||
|
label: 'password',
|
||||||
|
value: 'hunter2',
|
||||||
|
});
|
||||||
|
wireFieldHandlers(document.body);
|
||||||
|
const row = document.querySelector('[data-field-id="pw1"]') as HTMLElement;
|
||||||
|
const revealBtn = row.querySelector('[data-field-action="reveal"]') as HTMLButtonElement;
|
||||||
|
const valueEl = row.querySelector('[data-field-role="value"]') as HTMLElement;
|
||||||
|
expect(row.getAttribute('data-revealed')).toBe('false');
|
||||||
|
expect(valueEl.textContent).toContain('••••');
|
||||||
|
revealBtn.click();
|
||||||
|
expect(row.getAttribute('data-revealed')).toBe('true');
|
||||||
|
expect(valueEl.textContent).toBe('hunter2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy button writes the row value to the clipboard', async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText },
|
||||||
|
});
|
||||||
|
document.body.innerHTML = renderRow({
|
||||||
|
label: 'email',
|
||||||
|
value: 'alice@example.com',
|
||||||
|
copyable: true,
|
||||||
|
});
|
||||||
|
wireFieldHandlers(document.body);
|
||||||
|
const copyBtn = document.querySelector('[data-field-action="copy"]') as HTMLButtonElement;
|
||||||
|
copyBtn.click();
|
||||||
|
expect(writeText).toHaveBeenCalledWith('alice@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
119
extension/src/popup/components/fields.ts
Normal file
119
extension/src/popup/components/fields.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/// Field rendering primitives used by every typed-item detail view.
|
||||||
|
///
|
||||||
|
/// Pure functions that return HTML strings. Caller is responsible for
|
||||||
|
/// mounting the strings into the DOM (typically via `app.innerHTML = ...`).
|
||||||
|
/// After mounting, call `wireFieldHandlers(scope)` once to bind reveal +
|
||||||
|
/// copy click handlers on any rendered rows.
|
||||||
|
|
||||||
|
import { escapeHtml } from '../popup';
|
||||||
|
|
||||||
|
export interface RowOpts {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
copyable?: boolean;
|
||||||
|
href?: string;
|
||||||
|
monospace?: boolean;
|
||||||
|
multiline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain label/value row. Optional copy button, optional anchor wrap,
|
||||||
|
/// optional monospace styling, optional multiline (renders in a <pre>).
|
||||||
|
export function renderRow(opts: RowOpts): string {
|
||||||
|
const { label, value, copyable, href, monospace, multiline } = opts;
|
||||||
|
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
||||||
|
let valueHtml: string;
|
||||||
|
if (multiline) {
|
||||||
|
valueHtml = `<pre>${escapeHtml(value)}</pre>`;
|
||||||
|
} else if (href) {
|
||||||
|
valueHtml = `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(value)}</a>`;
|
||||||
|
} else {
|
||||||
|
valueHtml = escapeHtml(value);
|
||||||
|
}
|
||||||
|
const actions = copyable
|
||||||
|
? `<button type="button" data-field-action="copy" data-field-value="${escapeHtml(value)}">copy</button>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-row__label">${escapeHtml(label)}</span>
|
||||||
|
<span class="${valueClass}" data-field-role="value">${valueHtml}</span>
|
||||||
|
<span class="field-row__actions">${actions}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConcealedRowOpts {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
monospace?: boolean;
|
||||||
|
multiline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concealed row — value rendered hidden until the user clicks "show".
|
||||||
|
/// Plaintext is stored in `data-field-value` on the row element and copied
|
||||||
|
/// to the visible value span on reveal. Copy button always copies plaintext.
|
||||||
|
export function renderConcealedRow(opts: ConcealedRowOpts): string {
|
||||||
|
const { id, label, value, monospace, multiline } = opts;
|
||||||
|
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
|
||||||
|
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
||||||
|
return `
|
||||||
|
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}">
|
||||||
|
<span class="field-row__label">${escapeHtml(label)}</span>
|
||||||
|
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
|
||||||
|
<span class="field-row__actions">
|
||||||
|
<button type="button" data-field-action="reveal">show</button>
|
||||||
|
<button type="button" data-field-action="copy" data-field-value="${escapeHtml(value)}">copy</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureBlockOpts {
|
||||||
|
accent?: 'blue' | 'green' | 'amber' | 'red';
|
||||||
|
children: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container for the type-specific signature panel. `children` is HTML
|
||||||
|
/// the caller has already produced (and escaped where needed).
|
||||||
|
export function renderSignatureBlock(opts: SignatureBlockOpts): string {
|
||||||
|
const accent = opts.accent ?? 'blue';
|
||||||
|
return `
|
||||||
|
<div class="sig-block sig-block--${accent}">${opts.children}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire reveal-toggle + copy click handlers within `scope`. Idempotent —
|
||||||
|
/// safe to call multiple times against the same scope.
|
||||||
|
export function wireFieldHandlers(scope: HTMLElement): void {
|
||||||
|
scope.querySelectorAll<HTMLButtonElement>('[data-field-action="reveal"]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const row = btn.closest('[data-field-id]') as HTMLElement | null;
|
||||||
|
if (!row) return;
|
||||||
|
const valueEl = row.querySelector('[data-field-role="value"]') as HTMLElement | null;
|
||||||
|
if (!valueEl) return;
|
||||||
|
const revealed = row.getAttribute('data-revealed') === 'true';
|
||||||
|
const plaintext = row.getAttribute('data-field-value') ?? '';
|
||||||
|
const multiline = row.getAttribute('data-field-multiline') === 'true';
|
||||||
|
if (revealed) {
|
||||||
|
const placeholder = multiline ? `•••• (${plaintext.length} chars)` : '••••';
|
||||||
|
valueEl.textContent = placeholder;
|
||||||
|
row.setAttribute('data-revealed', 'false');
|
||||||
|
btn.textContent = 'show';
|
||||||
|
} else {
|
||||||
|
valueEl.textContent = plaintext;
|
||||||
|
row.setAttribute('data-revealed', 'true');
|
||||||
|
btn.textContent = 'hide';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll<HTMLButtonElement>('[data-field-action="copy"]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const value = btn.getAttribute('data-field-value') ?? '';
|
||||||
|
try { await navigator.clipboard.writeText(value); } catch { /* swallow — UX is the visual flash below */ }
|
||||||
|
const original = btn.textContent;
|
||||||
|
btn.textContent = 'copied';
|
||||||
|
setTimeout(() => { if (btn.textContent === 'copied') btn.textContent = original; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -459,3 +459,52 @@ textarea {
|
|||||||
border-color: #3fb950;
|
border-color: #3fb950;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- field-row + signature-block helpers (β₁) --- */
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 1fr auto;
|
||||||
|
gap: 8px 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row__label { color: #8b949e; }
|
||||||
|
.field-row__value { color: #c9d1d9; word-break: break-word; }
|
||||||
|
.field-row__value.monospace { font-family: "SF Mono", "JetBrains Mono", monospace; }
|
||||||
|
.field-row__value pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: "SF Mono", "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
.field-row__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
.field-row__actions button {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.field-row__actions button:hover { color: #c9d1d9; }
|
||||||
|
|
||||||
|
.sig-block {
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
border-left: 3px solid #1f6feb;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.sig-block--blue { border-left-color: #1f6feb; }
|
||||||
|
.sig-block--green { border-left-color: #3fb950; }
|
||||||
|
.sig-block--amber { border-left-color: #d29922; }
|
||||||
|
.sig-block--red { border-left-color: #f85149; }
|
||||||
|
|||||||
Reference in New Issue
Block a user