import { Libs } from "../utils/libs"; import { MountViewResult } from "../types/utils/libs.type"; import { NavigateHandler, SearchBoxTags, SearchHandler, } from "../types/components/searchbox.type"; import { SelectiveOptions } from "../types/utils/selective.type"; import { Lifecycle } from "../core/base/lifecycle"; import { LifecycleState } from "../types/core/base/lifecycle.type"; /** * SearchBox * * DOM-driven, headless-friendly search input used by the Select UI to filter and * navigate option lists. This component owns a small DOM subtree and exposes * callback hooks for the host/controller layer to implement filtering, highlight, * and commit/cancel behaviors. * * ### Responsibility * - Render a `` wrapped by a container element. * - Apply ARIA attributes used by the surrounding listbox/popup integration. * - Convert DOM events into typed callbacks: * - text input changes → {@link onSearch} * - keyboard navigation (ArrowUp/ArrowDown/Tab) → {@link onNavigate} * - commit (Enter) → {@link onEnter} * - cancel (Escape) → {@link onEsc} * - Provide imperative UI helpers: * - {@link show}/{@link hide} (visibility + focus/readOnly behavior) * - {@link clear} (reset query and optionally trigger the search hook) * - {@link setPlaceHolder} (safe placeholder update) * - {@link setActiveDescendant} (ARIA highlight binding) * * ### Lifecycle (Strict FSM) * - Constructed in `NEW`. * - If options are provided, {@link initialize} creates DOM and calls `init()` * → transitions to `INITIALIZED`. * - This class does not override `update()`: runtime changes are performed via * its imperative methods (e.g., {@link show}, {@link clear}, {@link setPlaceHolder}). * - {@link destroy} is terminal: removes DOM references and ends lifecycle. * Subsequent calls become no-ops once {@link LifecycleState.DESTROYED}. * * ### Event Model / Ownership * - This component does **not** own filtering logic or selection state. * - All "meaningful actions" are emitted outward through callbacks (external events). * - It also performs event containment (`stopPropagation`) to avoid parent-level * handlers (e.g., popup/list container) from intercepting interactions. * * ### a11y / DOM Side Effects * - Writes ARIA attributes such as `aria-controls`, `aria-autocomplete`, and * `aria-activedescendant` onto the input element. * - Intercepts keyboard events and may call `preventDefault()` for navigation keys. * * @extends Lifecycle */ export class SearchBox extends Lifecycle { /** * Creates a new {@link SearchBox}. * * If `options` is provided, initialization is performed immediately (DOM is created * and `init()` is called). If `options` is `null`, the instance stays in `NEW` until * initialized elsewhere. * * @param options - Configuration such as placeholder, accessibility IDs, and flags. */ constructor(options?: SelectiveOptions) { super(); this.options = options; if (options) this.initialize(options); } /** * The mount result returned by {@link Libs.mountNode}. * * Provides typed access to created DOM tags (e.g., `SearchInput`) and the root view. * `null` before initialization and after destruction. * * @internal */ private nodeMounted?: MountViewResult; /** * Root container node of this component. * * Created during {@link initialize} and removed during {@link destroy}. * Visibility is controlled by adding/removing the `hide` class. */ public node?: HTMLDivElement; /** * The `` element used to capture user queries. * * Cached for imperative operations (focus, placeholder updates, ARIA updates). * `null` before initialization and after destruction. * * @internal */ private SearchInput?: HTMLInputElement; /** * External "search changed" hook. * * Invoked when the user edits text (via the `input` event) and the edit is not * part of a handled control-key sequence (e.g., ArrowUp/Down/Tab/Enter/Escape). * * Ownership: * - Implementations typically filter adapter/model state and refresh the list. */ public onSearch?: SearchHandler; /** * Options snapshot used for behavior toggles and attributes. * * Key fields typically consumed here: * - `placeholder`: initial placeholder string * - `searchable`: toggles readOnly + focus behavior on {@link show} * - `SEID_LIST`: used as `aria-controls` value to bind to listbox container * * Cleared during {@link destroy}. * * @internal */ private options?: SelectiveOptions; /** * External navigation hook for list traversal. * * Called with: * - `+1` for forward (ArrowDown / Tab) * - `-1` for backward (ArrowUp) * * Typical consumers update highlight/active option in Adapter/RecyclerView. */ public onNavigate?: NavigateHandler; /** * External "commit" hook (Enter key). * * Typical consumers confirm selection of the highlighted option or submit the current state. */ public onEnter?: () => void; /** * External "cancel" hook (Escape key). * * Typical consumers close the popup, clear highlight, or reset interaction mode. */ public onEsc?: () => void; /** * Initializes DOM, ARIA attributes, and interaction listeners. * * DOM structure (conceptually): * - Root: `div.seui-searchbox.hide` * - Child: `input[type="search"].seui-searchbox-input` * * Accessibility attributes set on the input: * - `role="searchbox"`: announces search field semantics * - `aria-controls=options.SEID_LIST`: points to the list container (listbox) * - `aria-autocomplete="list"`: indicates suggestion results are list-driven * * Interaction model: * - Mouse down/up: stops propagation to prevent container/popup listeners from interfering. * - Keydown: * - ArrowDown / Tab → emits {@link onNavigate}(+1) * - ArrowUp → emits {@link onNavigate}(-1) * - Enter → emits {@link onEnter}() * - Escape → emits {@link onEsc}() * Control keys are treated as "internal control events" and do not produce {@link onSearch} * via the `input` listener (guarded by `isControlKey`). * - Input: * - Emits {@link onSearch}(value, true) for text edits that are not control-key sequences. * * Side effects: * - Creates DOM nodes via {@link Libs.mountNode}. * - Attaches event listeners to the input element. * - Transitions lifecycle via `init()` at the end. * * @param options - Configuration including placeholder and listbox id used by `aria-controls`. * @internal */ private initialize(options: SelectiveOptions): void { this.nodeMounted = Libs.mountNode>({ SearchBox: { tag: { node: "div", classList: ["seui-searchbox", "hide"] }, child: { SearchInput: { tag: { id: Libs.randomString(), node: "input", type: "search", classList: ["seui-searchbox-input"], placeholder: options.placeholder, role: "searchbox", ariaControls: options.SEID_LIST, ariaAutocomplete: "list", }, }, }, }, }); this.node = this.nodeMounted.view as HTMLDivElement; this.SearchInput = this.nodeMounted.tags.SearchInput; let isControlKey = false; const inputEl = this.nodeMounted.tags.SearchInput; // Prevent parent listeners (e.g., popup container) from intercepting mouse interactions. inputEl.addEventListener("mousedown", (e: MouseEvent) => { e.stopPropagation(); }); inputEl.addEventListener("mouseup", (e: MouseEvent) => { e.stopPropagation(); }); // Keyboard handling: navigation, commit, and cancel. // Control-key sequences are tracked to avoid emitting onSearch from the subsequent input event. inputEl.addEventListener("keydown", (e: KeyboardEvent) => { isControlKey = false; if (e.key === "ArrowDown" || e.key === "Tab") { e.preventDefault(); e.stopPropagation(); isControlKey = true; this.onNavigate?.(1); } else if (e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); isControlKey = true; this.onNavigate?.(-1); } else if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); isControlKey = true; this.onEnter?.(); } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); isControlKey = true; this.onEsc?.(); } // Ensure events don't bubble to container-level listeners. e.stopPropagation(); }); // Text edits (ignore those attributable to control-key flows). inputEl.addEventListener("input", () => { if (isControlKey) return; this.onSearch?.(inputEl.value, true); }); this.init(); } /** * Shows the search box and prepares the input for interaction. * * Behavior: * - Removes the `hide` class from the root node. * - Toggles `readOnly` according to `options.searchable`. * - When searchable, schedules a focus on the next animation frame. * * No-ops if not initialized (missing {@link node}, {@link SearchInput}, or {@link options}). * * DOM side effects: * - May change focus. * - Mutates `readOnly` on the input element. */ public show(): void { if (!this.node || !this.SearchInput || !this.options) return; this.node.classList.remove("hide"); this.SearchInput.readOnly = !this.options.searchable; if (this.options.searchable) { requestAnimationFrame(() => { this.SearchInput?.focus(); }); } } /** * Hides the search box by adding the `hide` class to the root node. * * No-ops if {@link node} is `null`. */ public hide(): void { if (!this.node) return; this.node.classList.add("hide"); } /** * Clears the current query and optionally notifies the host via {@link onSearch}. * * This method always resets the input's value to an empty string. * The `isTrigger` flag is forwarded to {@link onSearch} and can be used by the host * to differentiate external (programmatic) clearing from user-driven changes. * * No-ops if the component has not been initialized ({@link nodeMounted} is `null`). * * @param isTrigger - Whether to invoke {@link onSearch} with an empty string. Defaults to `true`. */ public clear(isTrigger: boolean = true): void { if (!this.nodeMounted) return; this.nodeMounted.tags.SearchInput.value = ""; this.onSearch?.("", isTrigger); } /** * Updates the input's placeholder text. * * Safety: * - HTML is stripped via {@link Libs.stripHtml} to avoid rendering markup in an attribute. * * No-ops if {@link SearchInput} is `null`. * * @param value - New placeholder text (may contain markup, which will be stripped). */ public setPlaceHolder(value: string): void { if (!this.SearchInput) return; this.SearchInput.placeholder = Libs.stripHtml(value); } /** * Sets `aria-activedescendant` to reflect the currently highlighted option in the list. * * This is typically used in conjunction with keyboard navigation to keep assistive * technologies informed about the active/highlighted item without moving DOM focus away * from the search input. * * No-ops if {@link SearchInput} is `null`. * * @param id - DOM id of the active option element. */ public setActiveDescendant(id: string): void { if (!this.SearchInput) return; this.SearchInput.setAttribute("aria-activedescendant", id); } /** * Disposes DOM resources and terminates the lifecycle. * * Strict FSM / idempotency: * - If already {@link LifecycleState.DESTROYED}, returns immediately. * * Side effects: * - Removes the root DOM node from the document (if present). * - Clears references to DOM nodes and callbacks to enable garbage collection. * - Delegates to `super.destroy()` to finalize lifecycle transition. * * @override */ public override destroy(): void { if (this.is(LifecycleState.DESTROYED)) { return; } this.node?.remove(); this.nodeMounted = null; this.node = null; this.SearchInput = null; this.onSearch = null; this.options = null; this.onNavigate = null; this.onEnter = null; this.onEsc = null; super.destroy(); } }