refactor(ext/vault): extract vault-sidebar.ts with debounced search (Plan C Phase 4)

Moves the sidebar column out of vault.ts/vault-shell.ts into vault-sidebar.ts:
its markup (now incl. an empty #vault-status-slot footer for Phase 6), the
category nav rendering, nav-button wiring, and search. The search input gains
an 80ms trailing-edge debounce (P2 fix — it re-filtered on every keystroke).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-31 15:39:26 -04:00
parent 51255b3583
commit 9049512e0d
4 changed files with 186 additions and 155 deletions

View File

@@ -0,0 +1,174 @@
// Vault-tab sidebar column: its static markup, the category nav rendering,
// nav-button wiring, and the (now debounced) search input. Each function
// receives the VaultController (`ctx`) and reaches sibling concerns through it;
// pure helpers come from vault-context. Imports only from shared/ and
// vault-context — never from vault-shell or vault.ts.
import type { ItemType } from '../shared/types';
import {
GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_HISTORY, GLYPH_LOCK,
} from '../shared/glyphs';
import {
type VaultController, typeIcon, typeLabel, getFilteredEntries,
} from './vault-context';
const SEARCH_DEBOUNCE_MS = 80;
// ---------------------------------------------------------------------------
// Sidebar markup
// ---------------------------------------------------------------------------
export function renderSidebarShell(): string {
return `
<div class="vault-sidebar">
<div class="vault-sidebar__header">
<img class="brand-logo" src="icons/relicario-logo.svg" alt="">
<span class="brand">Relicario</span>
</div>
<div class="vault-sidebar__search">
<input type="text" id="vault-search" placeholder="/ search…" />
</div>
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
<div class="vault-sidebar__nav">
<button class="vault-sidebar__nav-item vault-sidebar__nav-item--primary" data-nav="add" title="New item">+ new item</button>
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
<button class="vault-sidebar__nav-item" data-nav="devices" title="Devices">${GLYPH_DEVICES} <span class="vault-sidebar__nav-label">devices</span></button>
<button class="vault-sidebar__nav-item" data-nav="settings" title="Settings">${GLYPH_SETTINGS} <span class="vault-sidebar__nav-label">settings</span></button>
<button class="vault-sidebar__nav-item" data-nav="history" title="History">${GLYPH_HISTORY} <span class="vault-sidebar__nav-label">history</span></button>
<button class="vault-sidebar__nav-item" data-nav="lock" title="Lock">${GLYPH_LOCK} <span class="vault-sidebar__nav-label">lock</span></button>
</div>
<div class="vault-sidebar__footer">
<!-- Phase 6 (Dev-C Task 6.3) wires the sync-status indicator into this slot. -->
<div id="vault-status-slot"></div>
</div>
</div>`;
}
// ---------------------------------------------------------------------------
// Sidebar wiring
// ---------------------------------------------------------------------------
export function wireSidebar(ctx: VaultController): void {
// Search (debounced — trailing edge)
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
let searchTimer: number | undefined;
searchInput?.addEventListener('input', () => {
if (searchTimer !== undefined) clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
ctx.state.searchQuery = searchInput.value;
renderSidebarCategories(ctx);
ctx.renderListPane();
}, SEARCH_DEBOUNCE_MS);
});
// Nav buttons
document.querySelectorAll('.vault-sidebar__nav-item').forEach((btn) => {
btn.addEventListener('click', async () => {
const nav = (btn as HTMLElement).dataset.nav;
if (nav === 'lock') {
await ctx.sendMessage({ type: 'lock' });
ctx.state.unlocked = false;
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.state.entries = [];
ctx.render();
return;
}
if (nav === 'add') {
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.state.newType = null;
ctx.state.drawerOpen = false;
ctx.closeDrawer();
ctx.openTypePanel();
return;
}
if (nav === 'trash' || nav === 'devices' || nav === 'settings' || nav === 'history') {
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.state.newType = null;
ctx.state.drawerOpen = false;
ctx.state.view = nav;
ctx.setHash(nav);
ctx.applyShellViewClass();
ctx.renderPane();
return;
}
});
});
// Global "/" shortcut to focus search; Esc to close drawer
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !isEditableTarget(e.target)) {
e.preventDefault();
searchInput?.focus();
return;
}
if (e.key === 'Escape' && ctx.state.drawerOpen) {
ctx.closeDrawer();
ctx.renderListPane();
}
});
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (target.isContentEditable) return true;
return false;
}
// ---------------------------------------------------------------------------
// Sidebar category nav
// ---------------------------------------------------------------------------
export function renderSidebarCategories(ctx: VaultController): void {
const container = document.getElementById('vault-categories');
if (!container) return;
const filtered = getFilteredEntries(ctx.state);
const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp'];
const allCount = filtered.length;
const isAllActive = !ctx.state.activeGroup && ctx.state.view === 'list';
let html = `
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-group="">
<span class="vault-category-row__icon">◈</span>
<span class="vault-category-row__label vault-sidebar__category-label">All items</span>
<span class="vault-category-row__count vault-sidebar__category-count">${allCount}</span>
</button>
`;
for (const t of typeOrder) {
const count = filtered.filter(([, e]) => e.type === t).length;
// Always show Login (staple type); hide other types when empty.
if (count === 0 && t !== 'login') continue;
const isActive = ctx.state.activeGroup === t;
html += `
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-group="${t}">
<span class="vault-category-row__icon">${typeIcon(t)}</span>
<span class="vault-category-row__label vault-sidebar__category-label">${typeLabel(t)}</span>
<span class="vault-category-row__count vault-sidebar__category-count">${count}</span>
</button>
`;
}
container.innerHTML = html;
container.querySelectorAll<HTMLButtonElement>('.vault-category-row').forEach((btn) => {
btn.addEventListener('click', () => {
ctx.state.activeGroup = btn.dataset.group || null;
ctx.state.drawerOpen = false;
ctx.state.selectedId = null;
ctx.state.selectedItem = null;
ctx.state.view = 'list';
ctx.setHash('list');
ctx.applyShellViewClass();
renderSidebarCategories(ctx);
ctx.renderListPane();
ctx.closeDrawer();
});
});
}