feat(ext/popup): field-row + concealed-row + signature-block helpers
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user