Chrome Extension Form Handling — Best Practices
58 min readForm Handling Patterns for Chrome Extensions
Practical patterns for building forms in Chrome extension popups, options pages, and content scripts. Covers auto-save, validation, wizards, dynamic fields, import/export, state persistence, cross-context sync, and secure credential storage.
Table of Contents
- Options Page Form with Auto-Save
- Form Validation with ARIA Error States
- Multi-Step Wizard in Popup
- Dynamic Form Fields from Storage Schema
- Import/Export Settings via File Input
- Form State Persistence Across Popup Reopens
- Synced Form Between Popup and Options Page
- Password/API Key Input with Secure Storage
- Summary Table
Pattern 1: Options Page Form with Auto-Save
Auto-saving eliminates the need for a “Save” button and gives users immediate feedback. The key is debouncing writes and showing a save indicator.
HTML Structure
<!-- options.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="options.css">
</head>
<body>
<div class="options-container">
<h1>Extension Settings</h1>
<form id="settings-form">
<div class="field">
<label for="site-url">Target Site URL</label>
<input type="url" id="site-url" name="siteUrl" placeholder="https://example.com">
<span class="save-indicator" aria-live="polite"></span>
</div>
<div class="field">
<label for="refresh-interval">Refresh Interval (seconds)</label>
<input type="number" id="refresh-interval" name="refreshInterval" min="5" max="3600" value="30">
<span class="save-indicator" aria-live="polite"></span>
</div>
<div class="field">
<label>
<input type="checkbox" id="auto-start" name="autoStart">
Start automatically on browser launch
</label>
</div>
<div class="field">
<label for="theme">Theme</label>
<select id="theme" name="theme">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</form>
</div>
<script type="module" src="options.js"></script>
</body>
</html>
TypeScript Auto-Save Logic
import { createStorage, defineSchema } from '@theluckystrike/webext-storage';
const schema = defineSchema({
siteUrl: 'string',
refreshInterval: 'number',
autoStart: 'boolean',
theme: 'string',
});
const storage = createStorage(schema, 'sync');
// Debounce utility
function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number): T {
let timer: ReturnType<typeof setTimeout>;
return ((...args: unknown[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
}) as T;
}
// Show save indicator next to a field
function showSaved(input: HTMLElement): void {
const indicator = input.closest('.field')?.querySelector('.save-indicator');
if (!indicator) return;
indicator.textContent = 'Saved';
indicator.classList.add('visible');
setTimeout(() => {
indicator.textContent = '';
indicator.classList.remove('visible');
}, 1500);
}
// Extract value based on input type
function getFieldValue(input: HTMLInputElement | HTMLSelectElement): string | number | boolean {
if (input instanceof HTMLInputElement && input.type === 'checkbox') {
return input.checked;
}
if (input instanceof HTMLInputElement && input.type === 'number') {
return parseInt(input.value, 10);
}
return input.value;
}
// Debounced save for each field
const saveField = debounce(async (name: string, value: unknown, input: HTMLElement) => {
await storage.set(name as keyof typeof schema, value as never);
showSaved(input);
}, 400);
// Attach listeners to all form controls
function initAutoSave(): void {
const form = document.getElementById('settings-form') as HTMLFormElement;
form.querySelectorAll<HTMLInputElement | HTMLSelectElement>('input, select').forEach((input) => {
const event = input.type === 'checkbox' ? 'change' : 'input';
input.addEventListener(event, () => {
saveField(input.name, getFieldValue(input), input);
});
});
}
// Load saved values on page open
async function loadSettings(): Promise<void> {
const form = document.getElementById('settings-form') as HTMLFormElement;
const siteUrl = await storage.get('siteUrl');
const refreshInterval = await storage.get('refreshInterval');
const autoStart = await storage.get('autoStart');
const theme = await storage.get('theme');
if (siteUrl) (form.querySelector('[name="siteUrl"]') as HTMLInputElement).value = siteUrl;
if (refreshInterval) (form.querySelector('[name="refreshInterval"]') as HTMLInputElement).value = String(refreshInterval);
if (autoStart !== undefined) (form.querySelector('[name="autoStart"]') as HTMLInputElement).checked = Boolean(autoStart);
if (theme) (form.querySelector('[name="theme"]') as HTMLSelectElement).value = theme;
}
document.addEventListener('DOMContentLoaded', async () => {
await loadSettings();
initAutoSave();
});
CSS for Save Indicator
.save-indicator {
font-size: 12px;
color: #00c853;
opacity: 0;
transition: opacity 0.3s ease;
margin-left: 8px;
}
.save-indicator.visible {
opacity: 1;
}
.field {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
Key takeaway: Debounce at 300-500ms for text inputs, fire immediately on checkbox/select changes.
Pattern 2: Form Validation with ARIA Error States
Accessible form validation requires proper ARIA attributes so screen readers announce errors. Never rely on color alone.
Validation Infrastructure
interface ValidationRule {
test: (value: string) => boolean;
message: string;
}
interface FieldConfig {
element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
rules: ValidationRule[];
}
class FormValidator {
private fields: Map<string, FieldConfig> = new Map();
private errorContainer: Map<string, HTMLElement> = new Map();
addField(name: string, element: HTMLInputElement, rules: ValidationRule[]): void {
this.fields.set(name, { element, rules });
// Create error container with ARIA linkage
const errorEl = document.createElement('div');
errorEl.id = `${name}-error`;
errorEl.className = 'field-error';
errorEl.setAttribute('role', 'alert');
errorEl.setAttribute('aria-live', 'assertive');
element.setAttribute('aria-describedby', errorEl.id);
element.parentElement?.appendChild(errorEl);
this.errorContainer.set(name, errorEl);
// Validate on blur and input
element.addEventListener('blur', () => this.validateField(name));
element.addEventListener('input', () => {
if (element.getAttribute('aria-invalid') === 'true') {
this.validateField(name); // Re-validate only if already showing error
}
});
}
validateField(name: string): boolean {
const config = this.fields.get(name);
const errorEl = this.errorContainer.get(name);
if (!config || !errorEl) return true;
const value = config.element.value;
const errors: string[] = [];
for (const rule of config.rules) {
if (!rule.test(value)) {
errors.push(rule.message);
}
}
if (errors.length > 0) {
config.element.setAttribute('aria-invalid', 'true');
config.element.classList.add('input-error');
errorEl.textContent = errors[0]; // Show first error
errorEl.style.display = 'block';
return false;
}
config.element.setAttribute('aria-invalid', 'false');
config.element.classList.remove('input-error');
errorEl.textContent = '';
errorEl.style.display = 'none';
return true;
}
validateAll(): boolean {
let valid = true;
for (const name of this.fields.keys()) {
if (!this.validateField(name)) {
valid = false;
}
}
// Focus the first invalid field
if (!valid) {
const firstInvalid = document.querySelector<HTMLElement>('[aria-invalid="true"]');
firstInvalid?.focus();
}
return valid;
}
}
Usage Example
const validator = new FormValidator();
validator.addField('apiEndpoint', document.getElementById('api-endpoint') as HTMLInputElement, [
{
test: (v) => v.length > 0,
message: 'API endpoint is required',
},
{
test: (v) => /^https:\/\//.test(v),
message: 'Must start with https://',
},
{
test: (v) => {
try { new URL(v); return true; } catch { return false; }
},
message: 'Must be a valid URL',
},
]);
validator.addField('maxResults', document.getElementById('max-results') as HTMLInputElement, [
{
test: (v) => v.length > 0,
message: 'Max results is required',
},
{
test: (v) => !isNaN(Number(v)) && Number(v) >= 1 && Number(v) <= 100,
message: 'Must be a number between 1 and 100',
},
]);
Error Styling
.input-error {
border-color: #d32f2f !important;
box-shadow: 0 0 0 2px rgba(211, 47, 47, 0.2);
}
.field-error {
color: #d32f2f;
font-size: 12px;
margin-top: 4px;
display: none;
}
/* High contrast mode support */
@media (forced-colors: active) {
.input-error {
outline: 2px solid Mark;
}
.field-error {
color: Mark;
}
}
Key takeaway: Use aria-invalid, aria-describedby, and role="alert" so screen readers announce errors without visual-only cues.
Pattern 3: Multi-Step Wizard in Popup
Chrome extension popups have limited space. A wizard breaks complex setup into manageable steps.
Wizard Controller
interface WizardStep {
id: string;
title: string;
validate?: () => boolean | Promise<boolean>;
}
class PopupWizard {
private steps: WizardStep[];
private currentIndex = 0;
private container: HTMLElement;
private data: Record<string, unknown> = {};
constructor(container: HTMLElement, steps: WizardStep[]) {
this.container = container;
this.steps = steps;
this.render();
}
private render(): void {
const step = this.steps[this.currentIndex];
// Progress bar
const progress = this.container.querySelector('.wizard-progress') as HTMLElement;
if (progress) {
const pct = ((this.currentIndex + 1) / this.steps.length) * 100;
progress.style.width = `${pct}%`;
progress.setAttribute('aria-valuenow', String(pct));
progress.setAttribute('aria-valuetext',
`Step ${this.currentIndex + 1} of ${this.steps.length}: ${step.title}`);
}
// Show current step, hide others
this.steps.forEach((s, i) => {
const panel = document.getElementById(`step-${s.id}`);
if (panel) {
panel.hidden = i !== this.currentIndex;
panel.setAttribute('aria-hidden', String(i !== this.currentIndex));
}
});
// Update step title
const titleEl = this.container.querySelector('.wizard-title');
if (titleEl) titleEl.textContent = step.title;
// Button states
const backBtn = this.container.querySelector('.wizard-back') as HTMLButtonElement;
const nextBtn = this.container.querySelector('.wizard-next') as HTMLButtonElement;
if (backBtn) backBtn.disabled = this.currentIndex === 0;
if (nextBtn) {
nextBtn.textContent = this.currentIndex === this.steps.length - 1 ? 'Finish' : 'Next';
}
}
async next(): Promise<void> {
const step = this.steps[this.currentIndex];
// Validate current step before advancing
if (step.validate) {
const valid = await step.validate();
if (!valid) return;
}
// Collect data from current step
this.collectStepData(step.id);
if (this.currentIndex < this.steps.length - 1) {
this.currentIndex++;
this.render();
} else {
await this.finish();
}
}
back(): void {
if (this.currentIndex > 0) {
this.currentIndex--;
this.render();
}
}
private collectStepData(stepId: string): void {
const panel = document.getElementById(`step-${stepId}`);
if (!panel) return;
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement>('input, select, textarea')
.forEach((el) => {
if (el.name) {
this.data[el.name] = el.type === 'checkbox'
? (el as HTMLInputElement).checked
: el.value;
}
});
}
private async finish(): Promise<void> {
this.collectStepData(this.steps[this.currentIndex].id);
// Save all collected data
await chrome.storage.sync.set({ wizardData: this.data, setupComplete: true });
// Notify background
chrome.runtime.sendMessage({ type: 'SETUP_COMPLETE', data: this.data });
// Show success
this.container.innerHTML = `
<div class="wizard-complete">
<h2>Setup Complete</h2>
<p>Your extension is ready to use.</p>
</div>`;
}
getData(): Record<string, unknown> {
return { ...this.data };
}
}
HTML Skeleton
<div class="wizard" role="group" aria-label="Setup wizard">
<div class="wizard-progress-track">
<div class="wizard-progress" role="progressbar"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="33"></div>
</div>
<h2 class="wizard-title"></h2>
<div id="step-account" class="wizard-step">
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="plan">Plan</label>
<select id="plan" name="plan">
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
</div>
<div id="step-preferences" class="wizard-step" hidden>
<label for="language">Language</label>
<select id="language" name="language">
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
</select>
<label>
<input type="checkbox" name="notifications" checked>
Enable notifications
</label>
</div>
<div id="step-confirm" class="wizard-step" hidden>
<p>Review your settings and click Finish.</p>
<div id="summary"></div>
</div>
<div class="wizard-actions">
<button class="wizard-back">Back</button>
<button class="wizard-next">Next</button>
</div>
</div>
Initialization
const wizard = new PopupWizard(document.querySelector('.wizard')!, [
{
id: 'account',
title: 'Account Setup',
validate: () => {
const email = (document.getElementById('email') as HTMLInputElement).value;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
},
},
{ id: 'preferences', title: 'Preferences' },
{
id: 'confirm',
title: 'Confirm',
validate: () => {
// Populate summary before user sees it
const data = wizard.getData();
const summary = document.getElementById('summary')!;
summary.innerHTML = Object.entries(data)
.map(([k, v]) => `<div><strong>${k}:</strong> ${v}</div>`)
.join('');
return true;
},
},
]);
document.querySelector('.wizard-next')!.addEventListener('click', () => wizard.next());
document.querySelector('.wizard-back')!.addEventListener('click', () => wizard.back());
Key takeaway: Validate each step before advancing; collect data incrementally so nothing is lost if the popup closes.
Pattern 4: Dynamic Form Fields from Storage Schema
Generate forms automatically from a schema definition. Useful for extensions with many configurable options.
Schema-Driven Form Generator
interface FieldSchema {
key: string;
label: string;
type: 'text' | 'number' | 'boolean' | 'select' | 'color' | 'range';
default: unknown;
options?: { value: string; label: string }[]; // For select type
min?: number;
max?: number;
step?: number;
description?: string;
group?: string;
}
const settingsSchema: FieldSchema[] = [
{
key: 'highlightColor',
label: 'Highlight Color',
type: 'color',
default: '#ffff00',
group: 'Appearance',
},
{
key: 'fontSize',
label: 'Font Size',
type: 'range',
default: 14,
min: 10,
max: 24,
step: 1,
description: 'Base font size in pixels',
group: 'Appearance',
},
{
key: 'enableOverlay',
label: 'Enable overlay on pages',
type: 'boolean',
default: true,
group: 'Behavior',
},
{
key: 'maxItems',
label: 'Maximum Items',
type: 'number',
default: 50,
min: 1,
max: 500,
group: 'Behavior',
},
{
key: 'displayMode',
label: 'Display Mode',
type: 'select',
default: 'compact',
options: [
{ value: 'compact', label: 'Compact' },
{ value: 'detailed', label: 'Detailed' },
{ value: 'minimal', label: 'Minimal' },
],
group: 'Appearance',
},
{
key: 'customCssSelector',
label: 'Custom CSS Selector',
type: 'text',
default: '',
description: 'CSS selector for content injection target',
group: 'Advanced',
},
];
function generateForm(schema: FieldSchema[], container: HTMLElement): void {
// Group fields
const groups = new Map<string, FieldSchema[]>();
for (const field of schema) {
const group = field.group || 'General';
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(field);
}
for (const [groupName, fields] of groups) {
const fieldset = document.createElement('fieldset');
const legend = document.createElement('legend');
legend.textContent = groupName;
fieldset.appendChild(legend);
for (const field of fields) {
const wrapper = document.createElement('div');
wrapper.className = 'dynamic-field';
wrapper.innerHTML = createFieldHTML(field);
fieldset.appendChild(wrapper);
}
container.appendChild(fieldset);
}
}
function createFieldHTML(field: FieldSchema): string {
const desc = field.description
? `<small id="${field.key}-desc" class="field-desc">${field.description}</small>`
: '';
const ariaDesc = field.description ? `aria-describedby="${field.key}-desc"` : '';
switch (field.type) {
case 'boolean':
return `
<label class="toggle-label">
<input type="checkbox" name="${field.key}" ${ariaDesc}
${field.default ? 'checked' : ''}>
${field.label}
</label>${desc}`;
case 'select':
return `
<label for="${field.key}">${field.label}</label>
<select id="${field.key}" name="${field.key}" ${ariaDesc}>
${field.options!.map((o) =>
`<option value="${o.value}" ${o.value === field.default ? 'selected' : ''}>${o.label}</option>`
).join('')}
</select>${desc}`;
case 'range':
return `
<label for="${field.key}">${field.label}: <output id="${field.key}-output">${field.default}</output></label>
<input type="range" id="${field.key}" name="${field.key}" ${ariaDesc}
min="${field.min}" max="${field.max}" step="${field.step}" value="${field.default}"
oninput="document.getElementById('${field.key}-output').value = this.value">
${desc}`;
case 'color':
return `
<label for="${field.key}">${field.label}</label>
<input type="color" id="${field.key}" name="${field.key}" ${ariaDesc}
value="${field.default}">
${desc}`;
case 'number':
return `
<label for="${field.key}">${field.label}</label>
<input type="number" id="${field.key}" name="${field.key}" ${ariaDesc}
min="${field.min ?? ''}" max="${field.max ?? ''}" value="${field.default}">
${desc}`;
default:
return `
<label for="${field.key}">${field.label}</label>
<input type="text" id="${field.key}" name="${field.key}" ${ariaDesc}
value="${field.default}">
${desc}`;
}
}
Loading and Saving Dynamic Fields
import { createStorage, defineSchema } from '@theluckystrike/webext-storage';
async function loadDynamicDefaults(
schema: FieldSchema[],
form: HTMLFormElement
): Promise<void> {
const saved = await chrome.storage.sync.get(
schema.map((f) => f.key)
);
for (const field of schema) {
const value = saved[field.key] ?? field.default;
const el = form.elements.namedItem(field.key) as HTMLInputElement | HTMLSelectElement;
if (!el) continue;
if (el instanceof HTMLInputElement && el.type === 'checkbox') {
el.checked = Boolean(value);
} else {
el.value = String(value);
}
// Update range outputs
if (field.type === 'range') {
const output = document.getElementById(`${field.key}-output`);
if (output) output.textContent = String(value);
}
}
}
function attachDynamicAutoSave(schema: FieldSchema[], form: HTMLFormElement): void {
for (const field of schema) {
const el = form.elements.namedItem(field.key) as HTMLInputElement | HTMLSelectElement;
if (!el) continue;
el.addEventListener('change', async () => {
let value: unknown;
if (el instanceof HTMLInputElement && el.type === 'checkbox') {
value = el.checked;
} else if (field.type === 'number' || field.type === 'range') {
value = Number(el.value);
} else {
value = el.value;
}
await chrome.storage.sync.set({ [field.key]: value });
});
}
}
Key takeaway: Define settings as structured data, generate the UI automatically, and you never have to update HTML when adding a new option.
Pattern 5: Import/Export Settings via File Input
Let users back up and restore their extension settings with JSON files.
Export Function
async function exportSettings(): Promise<void> {
// Gather all storage areas
const syncData = await chrome.storage.sync.get(null);
const localData = await chrome.storage.local.get(null);
const exportPayload = {
version: chrome.runtime.getManifest().version,
exportDate: new Date().toISOString(),
sync: syncData,
local: localData,
};
const blob = new Blob(
[JSON.stringify(exportPayload, null, 2)],
{ type: 'application/json' }
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `extension-settings-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
Import Function with Validation
interface SettingsExport {
version: string;
exportDate: string;
sync: Record<string, unknown>;
local: Record<string, unknown>;
}
function isValidExport(data: unknown): data is SettingsExport {
if (typeof data !== 'object' || data === null) return false;
const obj = data as Record<string, unknown>;
return (
typeof obj.version === 'string' &&
typeof obj.exportDate === 'string' &&
typeof obj.sync === 'object' &&
typeof obj.local === 'object'
);
}
async function importSettings(file: File): Promise<{ success: boolean; message: string }> {
try {
const text = await file.text();
const data = JSON.parse(text);
if (!isValidExport(data)) {
return { success: false, message: 'Invalid settings file format.' };
}
// Version compatibility check
const currentVersion = chrome.runtime.getManifest().version;
const [importMajor] = data.version.split('.');
const [currentMajor] = currentVersion.split('.');
if (importMajor !== currentMajor) {
return {
success: false,
message: `Incompatible version: file is v${data.version}, extension is v${currentVersion}.`,
};
}
// Sanitize: remove keys that should not be imported
const blockedKeys = ['installDate', 'userId', 'authToken'];
for (const key of blockedKeys) {
delete data.sync[key];
delete data.local[key];
}
// Apply settings
await chrome.storage.sync.clear();
await chrome.storage.sync.set(data.sync);
await chrome.storage.local.set(data.local);
return {
success: true,
message: `Imported settings from ${new Date(data.exportDate).toLocaleDateString()}.`,
};
} catch (err) {
return { success: false, message: `Import failed: ${(err as Error).message}` };
}
}
File Input UI
<div class="import-export">
<button id="export-btn" type="button">Export Settings</button>
<label for="import-file" class="file-label">
Import Settings
<input type="file" id="import-file" accept=".json" hidden>
</label>
<div id="import-status" role="status" aria-live="polite"></div>
</div>
document.getElementById('export-btn')!.addEventListener('click', exportSettings);
document.getElementById('import-file')!.addEventListener('change', async (e) => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const statusEl = document.getElementById('import-status')!;
statusEl.textContent = 'Importing...';
const result = await importSettings(file);
statusEl.textContent = result.message;
statusEl.className = result.success ? 'status-success' : 'status-error';
if (result.success) {
// Reload the form with new values
setTimeout(() => location.reload(), 1000);
}
// Reset input so the same file can be re-selected
input.value = '';
});
Key takeaway: Always validate the import file structure, check version compatibility, and exclude sensitive keys like auth tokens.
Pattern 6: Form State Persistence Across Popup Reopens
When a popup closes, all DOM state is lost. Save in-progress form data so users can resume.
Auto-Persist Controller
import { createStorage, defineSchema } from '@theluckystrike/webext-storage';
const draftStorage = createStorage(
defineSchema({ popupDraft: 'string' }),
'local'
);
class FormPersistence {
private form: HTMLFormElement;
private storageKey: string;
private saveTimer: ReturnType<typeof setTimeout> | null = null;
constructor(form: HTMLFormElement, storageKey = 'popupDraft') {
this.form = form;
this.storageKey = storageKey;
}
// Serialize all form fields
private serialize(): Record<string, unknown> {
const data: Record<string, unknown> = {};
const formData = new FormData(this.form);
for (const [key, value] of formData.entries()) {
data[key] = value;
}
// Checkboxes that are unchecked don't appear in FormData
this.form.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
if (cb.name) data[cb.name] = cb.checked;
});
// Track which step the user is on (for wizards)
const activeStep = this.form.querySelector('.wizard-step:not([hidden])');
if (activeStep) data.__activeStep = activeStep.id;
// Track scroll position within the popup
data.__scrollY = document.documentElement.scrollTop;
return data;
}
// Restore form fields from saved data
private restore(data: Record<string, unknown>): void {
for (const [key, value] of Object.entries(data)) {
if (key.startsWith('__')) continue; // Skip metadata
const el = this.form.elements.namedItem(key);
if (!el) continue;
if (el instanceof HTMLInputElement && el.type === 'checkbox') {
el.checked = Boolean(value);
} else if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) {
el.value = String(value);
}
}
// Restore scroll position
if (typeof data.__scrollY === 'number') {
requestAnimationFrame(() => {
document.documentElement.scrollTop = data.__scrollY as number;
});
}
}
// Start watching for changes
watch(): void {
const events = ['input', 'change'];
events.forEach((event) => {
this.form.addEventListener(event, () => this.scheduleSave(), { passive: true });
});
}
private scheduleSave(): void {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.save(), 150);
}
async save(): Promise<void> {
const data = this.serialize();
await draftStorage.set('popupDraft', JSON.stringify({
key: this.storageKey,
data,
timestamp: Date.now(),
}));
}
async load(): Promise<boolean> {
const raw = await draftStorage.get('popupDraft');
if (!raw) return false;
try {
const parsed = JSON.parse(raw);
if (parsed.key !== this.storageKey) return false;
// Expire drafts after 24 hours
if (Date.now() - parsed.timestamp > 24 * 60 * 60 * 1000) {
await this.clear();
return false;
}
this.restore(parsed.data);
return true;
} catch {
return false;
}
}
async clear(): Promise<void> {
await draftStorage.set('popupDraft', '');
}
}
// Usage
const form = document.getElementById('my-form') as HTMLFormElement;
const persistence = new FormPersistence(form, 'feedback-form');
document.addEventListener('DOMContentLoaded', async () => {
const restored = await persistence.load();
if (restored) {
// Show a subtle "draft restored" indicator
const notice = document.createElement('div');
notice.className = 'draft-notice';
notice.textContent = 'Draft restored';
notice.setAttribute('role', 'status');
form.prepend(notice);
setTimeout(() => notice.remove(), 2000);
}
persistence.watch();
});
// Clear draft on successful submit
form.addEventListener('submit', async (e) => {
e.preventDefault();
// ... handle submission ...
await persistence.clear();
});
Key takeaway: Serialize the full form state (including checkboxes and scroll position) on every change, and restore it when the popup reopens. Expire stale drafts.
Pattern 7: Synced Form Between Popup and Options Page
When both the popup and options page modify the same settings, they must stay synchronized in real time.
Shared Storage Layer
import { createStorage, defineSchema } from '@theluckystrike/webext-storage';
import { createMessenger } from '@theluckystrike/webext-messaging';
// Shared schema used by both popup and options
const settingsSchema = defineSchema({
enabled: 'boolean',
targetUrl: 'string',
refreshRate: 'number',
theme: 'string',
});
type SettingsKey = keyof typeof settingsSchema;
const storage = createStorage(settingsSchema, 'sync');
const messenger = createMessenger();
// Listen for storage changes and update the form
function watchStorageChanges(form: HTMLFormElement): void {
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync') return;
for (const [key, { newValue }] of Object.entries(changes)) {
const el = form.elements.namedItem(key);
if (!el) continue;
// Avoid triggering change events that would cause loops
if (el instanceof HTMLInputElement && el.type === 'checkbox') {
if (el.checked !== Boolean(newValue)) {
el.checked = Boolean(newValue);
flashField(el);
}
} else if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement) {
if (el.value !== String(newValue)) {
el.value = String(newValue);
flashField(el);
}
}
}
});
}
// Visual feedback when a field updates from another context
function flashField(el: HTMLElement): void {
el.classList.add('field-synced');
setTimeout(() => el.classList.remove('field-synced'), 600);
}
Popup Controller
// popup.ts
async function initPopupForm(): Promise<void> {
const form = document.getElementById('quick-settings') as HTMLFormElement;
// Load current values
const enabled = await storage.get('enabled');
const targetUrl = await storage.get('targetUrl');
const theme = await storage.get('theme');
(form.elements.namedItem('enabled') as HTMLInputElement).checked = Boolean(enabled);
(form.elements.namedItem('targetUrl') as HTMLInputElement).value = targetUrl || '';
(form.elements.namedItem('theme') as HTMLSelectElement).value = theme || 'system';
// Save on change
form.addEventListener('change', async () => {
const fd = new FormData(form);
await storage.set('enabled', (form.elements.namedItem('enabled') as HTMLInputElement).checked);
await storage.set('targetUrl', fd.get('targetUrl') as string);
await storage.set('theme', fd.get('theme') as string);
});
// Watch for changes from options page
watchStorageChanges(form);
}
initPopupForm();
Options Page Controller
// options.ts - same pattern, expanded UI
async function initOptionsForm(): Promise<void> {
const form = document.getElementById('full-settings') as HTMLFormElement;
// Load all settings
for (const key of Object.keys(settingsSchema) as SettingsKey[]) {
const value = await storage.get(key);
const el = form.elements.namedItem(key);
if (!el) continue;
if (el instanceof HTMLInputElement && el.type === 'checkbox') {
el.checked = Boolean(value);
} else if (el instanceof HTMLInputElement && el.type === 'number') {
el.value = String(value ?? '');
} else if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement) {
el.value = String(value ?? '');
}
}
// Debounced auto-save
let saveTimer: ReturnType<typeof setTimeout>;
form.addEventListener('input', () => {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => saveAllFields(form), 300);
});
form.addEventListener('change', () => saveAllFields(form));
watchStorageChanges(form);
}
async function saveAllFields(form: HTMLFormElement): Promise<void> {
for (const key of Object.keys(settingsSchema) as SettingsKey[]) {
const el = form.elements.namedItem(key);
if (!el) continue;
if (el instanceof HTMLInputElement && el.type === 'checkbox') {
await storage.set(key, el.checked as never);
} else if (el instanceof HTMLInputElement && el.type === 'number') {
await storage.set(key, Number(el.value) as never);
} else if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement) {
await storage.set(key, el.value as never);
}
}
}
initOptionsForm();
Sync Animation CSS
.field-synced {
animation: sync-pulse 0.6s ease;
}
@keyframes sync-pulse {
0% { box-shadow: 0 0 0 0 rgba(0, 200, 83, 0.4); }
50% { box-shadow: 0 0 0 4px rgba(0, 200, 83, 0.2); }
100% { box-shadow: 0 0 0 0 rgba(0, 200, 83, 0); }
}
Key takeaway: Use chrome.storage.onChanged to detect changes from other contexts. Compare values before updating DOM to avoid feedback loops.
Pattern 8: Password/API Key Input with Secure Storage
API keys and passwords need special handling – mask display, use chrome.storage.session when available, and never log credentials.
Secure Input Component
interface SecureFieldOptions {
inputId: string;
storageKey: string;
placeholder?: string;
validateFormat?: (value: string) => boolean;
}
class SecureInput {
private input: HTMLInputElement;
private toggleBtn: HTMLButtonElement;
private storageKey: string;
private isRevealed = false;
private validateFormat?: (value: string) => boolean;
constructor(options: SecureFieldOptions) {
this.storageKey = options.storageKey;
this.validateFormat = options.validateFormat;
const container = document.getElementById(options.inputId)!.parentElement!;
this.input = document.getElementById(options.inputId) as HTMLInputElement;
this.input.type = 'password';
this.input.autocomplete = 'off';
this.input.setAttribute('spellcheck', 'false');
this.input.setAttribute('autocorrect', 'off');
this.input.setAttribute('data-lpignore', 'true'); // Disable LastPass
if (options.placeholder) this.input.placeholder = options.placeholder;
// Toggle visibility button
this.toggleBtn = document.createElement('button');
this.toggleBtn.type = 'button';
this.toggleBtn.className = 'toggle-visibility';
this.toggleBtn.textContent = 'Show';
this.toggleBtn.setAttribute('aria-label', 'Toggle password visibility');
this.toggleBtn.addEventListener('click', () => this.toggle());
container.appendChild(this.toggleBtn);
}
private toggle(): void {
this.isRevealed = !this.isRevealed;
this.input.type = this.isRevealed ? 'text' : 'password';
this.toggleBtn.textContent = this.isRevealed ? 'Hide' : 'Show';
// Auto-hide after 5 seconds
if (this.isRevealed) {
setTimeout(() => {
this.isRevealed = false;
this.input.type = 'password';
this.toggleBtn.textContent = 'Show';
}, 5000);
}
}
async save(): Promise<{ success: boolean; error?: string }> {
const value = this.input.value.trim();
if (!value) {
return { success: false, error: 'Value cannot be empty.' };
}
if (this.validateFormat && !this.validateFormat(value)) {
return { success: false, error: 'Invalid format.' };
}
// Use session storage if available (cleared when browser closes)
// Fall back to local storage with a note that it persists
try {
if (chrome.storage.session) {
await chrome.storage.session.set({ [this.storageKey]: value });
} else {
await chrome.storage.local.set({ [this.storageKey]: value });
}
return { success: true };
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
async load(): Promise<void> {
let result: Record<string, unknown> = {};
if (chrome.storage.session) {
result = await chrome.storage.session.get(this.storageKey);
}
if (!result[this.storageKey]) {
result = await chrome.storage.local.get(this.storageKey);
}
if (result[this.storageKey]) {
this.input.value = result[this.storageKey] as string;
this.input.placeholder = 'Key saved (hidden)';
}
}
async clear(): Promise<void> {
this.input.value = '';
if (chrome.storage.session) {
await chrome.storage.session.remove(this.storageKey);
}
await chrome.storage.local.remove(this.storageKey);
}
}
Background Script: Secure Key Retrieval
// background.ts - Retrieve API key for use in fetch calls
async function getApiKey(key: string): Promise<string | null> {
// Check session first (preferred, ephemeral)
if (chrome.storage.session) {
const session = await chrome.storage.session.get(key);
if (session[key]) return session[key] as string;
}
// Fall back to local
const local = await chrome.storage.local.get(key);
return (local[key] as string) || null;
}
// Use in API calls
async function callExternalApi(endpoint: string): Promise<unknown> {
const apiKey = await getApiKey('apiKey');
if (!apiKey) {
// Prompt user to enter key
chrome.action.openPopup();
throw new Error('API key not configured');
}
const response = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
// Key is invalid, clear it
await chrome.storage.session?.remove('apiKey');
await chrome.storage.local.remove('apiKey');
throw new Error('Invalid API key');
}
return response.json();
}
HTML for Secure Input
<div class="secure-field">
<label for="api-key">API Key</label>
<div class="input-group">
<input type="password" id="api-key" autocomplete="off">
<!-- Toggle button inserted by SecureInput -->
</div>
<div class="field-actions">
<button id="save-key" type="button">Save Key</button>
<button id="clear-key" type="button" class="danger">Remove Key</button>
</div>
<div id="key-status" role="status" aria-live="polite"></div>
</div>
const secureKey = new SecureInput({
inputId: 'api-key',
storageKey: 'apiKey',
placeholder: 'sk-...',
validateFormat: (v) => v.startsWith('sk-') && v.length > 20,
});
// Load existing key on popup open
secureKey.load();
document.getElementById('save-key')!.addEventListener('click', async () => {
const result = await secureKey.save();
const status = document.getElementById('key-status')!;
status.textContent = result.success ? 'Key saved securely.' : result.error!;
status.className = result.success ? 'status-success' : 'status-error';
});
document.getElementById('clear-key')!.addEventListener('click', async () => {
if (confirm('Remove the saved API key?')) {
await secureKey.clear();
document.getElementById('key-status')!.textContent = 'Key removed.';
}
});
Key takeaway: Use chrome.storage.session for credentials when possible (it clears on browser close). Auto-hide revealed passwords after a timeout. Never log or sync credentials.
Summary Table
| Pattern | Problem Solved | Key API / Technique | Complexity |
|---|---|---|---|
| Options Auto-Save | User forgets to click “Save” | chrome.storage.sync + debounce |
Low |
| ARIA Validation | Inaccessible error messages | aria-invalid, aria-describedby, role="alert" |
Medium |
| Multi-Step Wizard | Complex setup in small popup | Step controller + per-step validation | Medium |
| Dynamic Form Fields | Maintaining forms as settings grow | Schema-driven generation | Medium |
| Import/Export | Settings backup and restore | Blob + File API + JSON validation |
Low |
| State Persistence | Popup closes mid-edit | chrome.storage.local + FormData serialization |
Medium |
| Synced Forms | Popup and options page conflict | chrome.storage.onChanged listener |
Medium |
| Secure Input | API keys exposed in storage | chrome.storage.session + input masking |
High |
Further Reading
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.