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>
This commit is contained in:
adlee-was-taken
2026-04-24 18:10:09 -04:00
parent 553d9d7ca9
commit 3264cccb60
3 changed files with 395 additions and 1 deletions

View File

@@ -6,7 +6,7 @@
/// copy click handlers on any rendered rows.
import { escapeHtml } from '../popup';
import type { Item } from '../../shared/types';
import type { Item, Section, Field, FieldValue } from '../../shared/types';
export interface RowOpts {
label: string;
@@ -156,3 +156,175 @@ export function renderSections(item: Item, idPrefix: string): string {
});
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 };
});
});
}