/**
* Quick Panel Search Input
*
* A scope-aware search input component with:
* - PRD-defined scope prefixes (t/b/h/c/>)
* - Scope chip for visual indication and cycling
* - XSS-safe rendering (textContent/value only)
* - IME composition handling
* - Disposer-based cleanup
*/
import { Disposer } from '@/entrypoints/web-editor-v2/utils/disposables';
import {
DEFAULT_SCOPE,
QUICK_PANEL_SCOPES,
normalizeQuickPanelScope,
parseScopePrefixedQuery,
type QuickPanelScope,
} from '../core/types';
// ============================================================
// Types
// ============================================================
export interface SearchInputState {
scope: QuickPanelScope;
query: string;
}
export interface SearchInputOptions {
/** Container to mount the search input */
container: HTMLElement;
/** Initial scope. Default: 'all' */
initialScope?: QuickPanelScope;
/** Initial query string */
initialQuery?: string;
/** Input placeholder. Default: 'Search...' */
placeholder?: string;
/** Auto-focus on mount. Default: true */
autoFocus?: boolean;
/**
* Available scopes for cycling.
* Default: all known scopes
*/
availableScopes?: readonly QuickPanelScope[];
/** Called when state changes (scope or query) */
onChange?: (state: SearchInputState) => void;
/** Called when scope changes */
onScopeChange?: (scope: QuickPanelScope) => void;
/** Called when query changes */
onQueryChange?: (query: string) => void;
/** Called when clear button is clicked */
onClear?: () => void;
}
export interface SearchInputManager {
/** Root DOM element */
root: HTMLDivElement;
/** Input element */
input: HTMLInputElement;
/** Get current state */
getState: () => SearchInputState;
/** Set scope programmatically */
setScope: (scope: QuickPanelScope, options?: { emit?: boolean }) => void;
/** Set query programmatically */
setQuery: (query: string, options?: { emit?: boolean }) => void;
/** Clear the input */
clear: (options?: { emit?: boolean }) => void;
/** Focus the input */
focus: () => void;
/** Clean up resources */
dispose: () => void;
}
// ============================================================
// Helpers
// ============================================================
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function safeFocus(el: HTMLElement): void {
try {
el.focus();
} catch {
// Best-effort
}
}
function buildScopeCycleList(input: readonly QuickPanelScope[] | undefined): QuickPanelScope[] {
const defaultList: QuickPanelScope[] = [
'all',
'tabs',
'bookmarks',
'history',
'content',
'commands',
];
const list = (input?.length ? [...input] : defaultList).map((s) => normalizeQuickPanelScope(s));
// Ensure 'all' exists as a stable fallback
if (!list.includes('all')) {
list.unshift('all');
}
// De-duplicate while preserving order
const seen = new Set<QuickPanelScope>();
return list.filter((s) => {
if (seen.has(s)) return false;
seen.add(s);
return true;
});
}
// ============================================================
// Main Factory
// ============================================================
/**
* Create a Quick Panel search input component.
*
* @example
* ```typescript
* const searchInput = createSearchInput({
* container: headerSearchMount,
* initialScope: 'all',
* onChange: ({ scope, query }) => {
* controller.search(scope, query);
* },
* });
*
* // Programmatically set scope
* searchInput.setScope('tabs');
*
* // Cleanup
* searchInput.dispose();
* ```
*/
export function createSearchInput(options: SearchInputOptions): SearchInputManager {
const disposer = new Disposer();
const scopes = buildScopeCycleList(options.availableScopes);
let disposed = false;
let isComposing = false;
let state: SearchInputState = {
scope: normalizeQuickPanelScope(options.initialScope, DEFAULT_SCOPE),
query: (options.initialQuery ?? '').trim(),
};
// --------------------------------------------------------
// DOM Construction
// --------------------------------------------------------
const root = document.createElement('div');
root.className = 'qp-search';
const brand = document.createElement('div');
brand.className = 'qp-brand';
brand.textContent = '\u2726'; // Star symbol
const scopeBtn = document.createElement('button');
scopeBtn.type = 'button';
scopeBtn.className = 'qp-scope-chip ac-btn ac-focus-ring';
scopeBtn.setAttribute('aria-label', 'Switch search scope');
const input = document.createElement('input');
input.type = 'text';
input.className = 'qp-search-input ac-focus-ring';
input.placeholder = options.placeholder?.trim() || 'Search\u2026';
input.setAttribute('autocomplete', 'off');
input.setAttribute('spellcheck', 'false');
input.setAttribute('aria-label', 'Quick Panel search');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'qp-icon-btn ac-btn ac-focus-ring';
clearBtn.textContent = '\u00D7'; // ×
clearBtn.setAttribute('aria-label', 'Clear search');
root.append(brand, scopeBtn, input, clearBtn);
options.container.append(root);
disposer.add(() => root.remove());
// --------------------------------------------------------
// Rendering
// --------------------------------------------------------
function renderScopeChip(): void {
const def = QUICK_PANEL_SCOPES[state.scope];
const prefixHint = def.prefix ? def.prefix.trim() : '';
scopeBtn.textContent = '';
const iconEl = document.createElement('span');
iconEl.className = 'qp-scope-chip__icon';
iconEl.textContent = def.icon;
const labelEl = document.createElement('span');
labelEl.className = 'qp-scope-chip__label';
labelEl.textContent = def.label;
scopeBtn.append(iconEl, labelEl);
if (prefixHint) {
const prefixEl = document.createElement('span');
prefixEl.className = 'qp-scope-chip__prefix';
prefixEl.textContent = prefixHint;
scopeBtn.append(prefixEl);
}
}
function renderClearButton(): void {
clearBtn.hidden = !isNonEmptyString(input.value);
}
function render(): void {
renderScopeChip();
renderClearButton();
}
// --------------------------------------------------------
// State Change Emission
// --------------------------------------------------------
function emit(): void {
try {
options.onChange?.({ ...state });
} catch {
// Best-effort
}
try {
options.onScopeChange?.(state.scope);
} catch {
// Best-effort
}
try {
options.onQueryChange?.(state.query);
} catch {
// Best-effort
}
}
// --------------------------------------------------------
// State Mutators
// --------------------------------------------------------
function setScope(next: QuickPanelScope, opts: { emit?: boolean } = {}): void {
if (disposed) return;
const normalized = normalizeQuickPanelScope(next, DEFAULT_SCOPE);
if (state.scope === normalized) return;
// Only allow scopes in the cycle list
if (!scopes.includes(normalized)) return;
state = { ...state, scope: normalized };
render();
if (opts.emit !== false) emit();
}
function setQuery(nextQuery: string, opts: { emit?: boolean } = {}): void {
if (disposed) return;
const q = (nextQuery ?? '').trim();
if (state.query === q && input.value === q) {
render();
return;
}
state = { ...state, query: q };
input.value = q;
render();
if (opts.emit !== false) emit();
}
function clear(opts: { emit?: boolean } = {}): void {
if (disposed) return;
setQuery('', { emit: false });
try {
options.onClear?.();
} catch {
// Best-effort
}
if (opts.emit !== false) emit();
}
// --------------------------------------------------------
// Prefix Parsing
// --------------------------------------------------------
function applyPrefixParsing(): void {
if (disposed) return;
if (isComposing) return;
const parsed = parseScopePrefixedQuery(input.value, state.scope);
if (parsed.consumedPrefix) {
// Apply scope change if available
if (scopes.includes(parsed.scope) && parsed.scope !== state.scope) {
setScope(parsed.scope, { emit: false });
}
// Consume the prefix from the visible input
if (input.value !== parsed.query) {
input.value = parsed.query;
// Move caret to end after rewrite for predictable UX
try {
input.setSelectionRange(input.value.length, input.value.length);
} catch {
// Ignore
}
}
}
// Always update query state from current input value
setQuery(input.value, { emit: false });
emit();
}
// --------------------------------------------------------
// Event Handlers
// --------------------------------------------------------
disposer.listen(input, 'compositionstart', () => {
isComposing = true;
});
disposer.listen(input, 'compositionend', () => {
isComposing = false;
applyPrefixParsing();
});
disposer.listen(input, 'input', () => {
applyPrefixParsing();
});
disposer.listen(clearBtn, 'click', () => {
clear();
safeFocus(input);
});
disposer.listen(scopeBtn, 'click', () => {
const idx = scopes.indexOf(state.scope);
const next = scopes[(idx >= 0 ? idx + 1 : 0) % scopes.length] ?? 'all';
setScope(next);
safeFocus(input);
});
// --------------------------------------------------------
// Initialization
// --------------------------------------------------------
input.value = state.query;
render();
if (options.autoFocus !== false) {
safeFocus(input);
}
// --------------------------------------------------------
// Public API
// --------------------------------------------------------
return {
root,
input,
getState: () => ({ ...state }),
setScope,
setQuery,
clear,
focus: () => safeFocus(input),
dispose: () => {
if (disposed) return;
disposed = true;
disposer.dispose();
},
};
}