Files
relicario/extension/src/popup/components/fields.ts
adlee-was-taken 3264cccb60 feat(ext/popup): renderSectionsEditor + wireSectionsEditor helpers
Adds the collapsible custom-fields editor (disclosure toggle, add/remove
sections + fields, in-place label/value mutation). Module-level helpers
only: caller owns the sectionsDraft and triggers rerender on structural
changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:10:09 -04:00

331 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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';
import type { Item, Section, Field, FieldValue } from '../../shared/types';
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);
});
});
}
/// Render an Item's sections as read-only field rows. Each section with
/// ≥1 field emits a header (if named) or thin separator (if anonymous)
/// plus field rows via renderRow / renderConcealedRow. Sections with
/// 0 fields are skipped. Fields with unsupported kinds are silently
/// skipped (β₂ supports text, password, concealed only).
///
/// `idPrefix` uniquifies concealed-row IDs (`${idPrefix}-s{i}-f{j}`)
/// so multiple typed-item detail views rendered in sequence don't
/// collide on wireFieldHandlers lookups.
export function renderSections(item: Item, idPrefix: string): string {
let out = '';
item.sections.forEach((section, sIdx) => {
const visibleFields = section.fields.filter(
(f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed',
);
if (visibleFields.length === 0) return;
if (section.name) {
out += `<div class="section-header">${escapeHtml(section.name)}</div>`;
} else {
out += `<hr class="section-separator">`;
}
visibleFields.forEach((field, fIdx) => {
if (field.value.kind === 'text') {
out += renderRow({ label: field.label, value: field.value.value, copyable: true });
} else if (field.value.kind === 'password' || field.value.kind === 'concealed') {
out += renderConcealedRow({
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
label: field.label,
value: field.value.value,
});
}
});
});
return out;
}
/// 16-char hex FieldId. crypto.getRandomValues for 8 bytes.
export function generateFieldId(): string {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
}
function makeField(kind: 'text' | 'password' | 'concealed'): Field {
const value: FieldValue = { kind, value: '' };
return {
id: generateFieldId(),
label: 'new field',
kind,
value,
hidden_by_default: kind !== 'text',
};
}
/// Render the collapsible custom-sections editor. Returns HTML for the
/// disclosure toggle + body. The expanded-state is owned externally
/// (via a module-scope flag in the caller); this helper reads it as
/// the `expanded` parameter.
export function renderSectionsEditor(sections: Section[], expanded: boolean): string {
const sectionCount = sections.length;
const fieldCount = sections.reduce((sum, s) => sum + s.fields.length, 0);
const sectionLabel = sectionCount === 1 ? '1 section' : `${sectionCount} sections`;
const fieldLabel = fieldCount === 1 ? '1 field' : `${fieldCount} fields`;
const summary = sectionCount === 0 && fieldCount === 0
? 'no custom fields'
: `${sectionLabel}, ${fieldLabel}`;
const body = sections.map((section, sIdx) => renderSectionBlock(section, sIdx)).join('');
return `
<div class="disclosure" data-expanded="${expanded ? 'true' : 'false'}">
<button type="button" class="disclosure__toggle">▾ custom sections & fields (${escapeHtml(summary)})</button>
<div class="disclosure__body">
${body}
<button type="button" class="add-section">+ add section</button>
</div>
</div>
`;
}
function renderSectionBlock(section: Section, sIdx: number): string {
const nameDisplay = section.name
? `<span class="name">${escapeHtml(section.name)}</span>`
: `<span class="name anon">(anonymous)</span>`;
const fieldsHtml = section.fields.map((f, fIdx) => renderEditorField(f, sIdx, fIdx)).join('');
return `
<div class="section-editor" data-section-idx="${sIdx}">
<div class="section-editor__head">
${nameDisplay}
<span class="actions">
<button type="button" data-rename-section="${sIdx}">rename</button>
<button type="button" data-remove-section="${sIdx}">× remove section</button>
</span>
</div>
${fieldsHtml}
<div class="section-editor__add">
<button type="button" data-add-field="text" data-section-idx="${sIdx}">+ text</button>
<button type="button" data-add-field="password" data-section-idx="${sIdx}">+ password</button>
<button type="button" data-add-field="concealed" data-section-idx="${sIdx}">+ concealed</button>
</div>
</div>
`;
}
function renderEditorField(field: Field, sIdx: number, fIdx: number): string {
const valueStr = (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed')
? field.value.value
: '';
const inputType = field.kind === 'text' ? 'text' : 'password';
const key = `${sIdx}-${fIdx}`;
return `
<div class="section-editor__field">
<input type="text" data-field-label="${key}" value="${escapeHtml(field.label)}" placeholder="label">
<input type="${inputType}" data-field-value-input="${key}" value="${escapeHtml(valueStr)}" placeholder="value">
<button type="button" class="delete-field" data-delete-field="${key}">×</button>
</div>
`;
}
/// Wire click + input handlers on a rendered sections-editor. Mutations
/// happen in place on `sectionsDraft`. `rerender` is called after any
/// structural change (add/remove) to regenerate the disclosure body;
/// label/value edits do NOT trigger rerender (would steal focus).
export function wireSectionsEditor(
scope: HTMLElement,
sectionsDraft: Section[],
rerender: () => void,
): void {
const toggle = scope.querySelector('.disclosure__toggle') as HTMLButtonElement | null;
const disclosure = scope.querySelector('.disclosure') as HTMLElement | null;
toggle?.addEventListener('click', () => {
if (!disclosure) return;
const expanded = disclosure.getAttribute('data-expanded') === 'true';
disclosure.setAttribute('data-expanded', expanded ? 'false' : 'true');
});
scope.querySelector('.add-section')?.addEventListener('click', () => {
sectionsDraft.push({ name: undefined, fields: [] });
rerender();
});
scope.querySelectorAll<HTMLButtonElement>('[data-rename-section]').forEach((btn) => {
btn.addEventListener('click', () => {
const sIdx = Number(btn.dataset.renameSection);
const current = sectionsDraft[sIdx]?.name ?? '';
const name = window.prompt('Section name (empty for none):', current);
if (name === null) return;
const trimmed = name.trim();
sectionsDraft[sIdx].name = trimmed || undefined;
rerender();
});
});
scope.querySelectorAll<HTMLButtonElement>('[data-remove-section]').forEach((btn) => {
btn.addEventListener('click', () => {
const sIdx = Number(btn.dataset.removeSection);
const name = sectionsDraft[sIdx]?.name ?? '(anonymous)';
if (!window.confirm(`Remove section "${name}" and all its fields?`)) return;
sectionsDraft.splice(sIdx, 1);
rerender();
});
});
scope.querySelectorAll<HTMLButtonElement>('[data-add-field]').forEach((btn) => {
btn.addEventListener('click', () => {
const sIdx = Number(btn.dataset.sectionIdx);
const kind = btn.dataset.addField as 'text' | 'password' | 'concealed';
sectionsDraft[sIdx].fields.push(makeField(kind));
rerender();
});
});
scope.querySelectorAll<HTMLButtonElement>('[data-delete-field]').forEach((btn) => {
btn.addEventListener('click', () => {
const [sIdxStr, fIdxStr] = (btn.dataset.deleteField ?? '0-0').split('-');
const sIdx = Number(sIdxStr);
const fIdx = Number(fIdxStr);
sectionsDraft[sIdx].fields.splice(fIdx, 1);
rerender();
});
});
scope.querySelectorAll<HTMLInputElement>('[data-field-label]').forEach((input) => {
input.addEventListener('input', () => {
const [sIdxStr, fIdxStr] = (input.dataset.fieldLabel ?? '0-0').split('-');
const sIdx = Number(sIdxStr);
const fIdx = Number(fIdxStr);
if (sectionsDraft[sIdx]?.fields[fIdx]) {
sectionsDraft[sIdx].fields[fIdx].label = input.value;
}
});
});
scope.querySelectorAll<HTMLInputElement>('[data-field-value-input]').forEach((input) => {
input.addEventListener('input', () => {
const [sIdxStr, fIdxStr] = (input.dataset.fieldValueInput ?? '0-0').split('-');
const sIdx = Number(sIdxStr);
const fIdx = Number(fIdxStr);
const field = sectionsDraft[sIdx]?.fields[fIdx];
if (!field) return;
const kind = field.value.kind as 'text' | 'password' | 'concealed';
field.value = { kind, value: input.value };
});
});
}