/// 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
).
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 = `${escapeHtml(value)}`;
} else if (href) {
valueHtml = `${escapeHtml(value)}`;
} else {
valueHtml = escapeHtml(value);
}
const actions = copyable
? ``
: '';
return `
${escapeHtml(label)}
${valueHtml}
${actions}
`;
}
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 `
${escapeHtml(label)}
${escapeHtml(placeholder)}
`;
}
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 `
${opts.children}
`;
}
/// 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('[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('[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 += `${escapeHtml(section.name)}`;
} else {
out += `
`;
}
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 `
${body}
`;
}
function renderSectionBlock(section: Section, sIdx: number): string {
const nameDisplay = section.name
? `${escapeHtml(section.name)}`
: `(anonymous)`;
const fieldsHtml = section.fields.map((f, fIdx) => renderEditorField(f, sIdx, fIdx)).join('');
return `
${nameDisplay}
${fieldsHtml}
`;
}
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 `
`;
}
/// 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('[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('[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('[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('[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('[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('[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 };
});
});
}