import { Libs } from "../utils/libs"; import { Refresher } from "../services/refresher"; import { PlaceHolder } from "./placeholder"; import { Directive } from "./directive"; import { Popup } from "./popup/popup"; import { SearchBox } from "./searchbox"; import { Effector } from "../services/effector"; import { iEvents } from "../utils/ievents"; import { ModelManager } from "../core/model-manager"; import { RecyclerView } from "../core/base/recyclerview"; import { AccessoryBox } from "./accessorybox"; import { SearchController } from "../core/search-controller"; import { SelectObserver } from "../services/select-observer"; import { DatasetObserver } from "../services/dataset-observer"; import { MixedAdapter } from "../adapter/mixed-adapter"; import { GroupModel } from "../models/group-model"; import { OptionModel } from "../models/option-model"; import { Lifecycle } from "../core/base/lifecycle"; import { LifecycleState } from "../types/core/base/lifecycle.type"; import type { SelectiveOptions } from "../types/utils/selective.type"; import { IEventToken, IEventCallback } from "../types/utils/ievents.type"; import { MixedItem } from "../types/core/base/mixed-adapter.type"; import { BinderMap } from "../types/utils/istorage.type"; import { ContainerRuntime, SelectBoxAction, SelectBoxTags, } from "../types/components/searchbox.type"; import { AjaxConfig } from "../types/core/search-controller.type"; import { Selective } from "../utils/selective"; import { VirtualRecyclerView } from "../core/base/virtual-recyclerview"; import type { PluginContext, SelectivePlugin, } from "../types/plugins/plugin.type"; /** * SelectBox * * Root coordinator component that enhances a native `` remains the canonical form element and is moved into the SelectBox DOM wrapper. * - `ModelManager` owns adapter + recyclerview instances and exposes a resource model list. * - `Popup` hosts the list UI (adapter ↔ recycler/view) and emits adapter property changes. * - `SearchBox` emits external events (search/navigation/enter/esc), which drive adapter navigation and search. * * ### Lifecycle (Strict FSM) * This class uses explicit state guards (`this.state !== ...`) to enforce a strict sequence: * - `NEW` → {@link init} (creates subcomponents and runtime wiring) → `INITIALIZED` * - {@link mount} (inserts wrapper and relocates `` element via binder map). * * Provides feature flags (multiple/disabled/readonly/visible/virtualScroll/ajax/autoclose…), * a11y ids (e.g. `SEID_LIST`, `SEID_HOLDER`) and user callbacks under `options.on`. * * @internal */ private options?: SelectiveOptions; /** * Manager that owns model resources and bridges the Adapter ↔ RecyclerView pipeline. * * The configured adapter is {@link MixedAdapter}. The recyclerview implementation is chosen * based on `options.virtualScroll` (standard {@link RecyclerView} vs {@link VirtualRecyclerView}). * * @internal */ private optionModelManager?: ModelManager; /** * Whether the popup/list UI is currently open. * * This is authoritative for the action API (`getAction().isOpen`) and open/close guards. * * @internal */ private isOpen: boolean = false; /** * Tracks whether an initial AJAX load has been performed at least once. * Used to avoid redundant initial fetches on open. * * @internal */ private hasLoadedOnce: boolean = false; /** * Tracks whether the instance is in "pre-search" mode (a search is about to happen). * Used as a hint to perform AJAX refresh on open. * * @internal */ private isBeforeSearch: boolean = false; /** * Tracks whether {@link deInit} has already run. * * This guards teardown work (including plugin lifecycle hooks) from running more than once * when {@link deInit} is called separately before {@link destroy}. * * @internal */ private hasDeInitialized: boolean = false; /** * Selective context (global helper / registry). * * Used to locate the instance wrapper via `Selective.find(...)` and to close other open instances. */ public Selective?: Selective; /** * Registered plugins for this SelectBox instance. */ private plugins: SelectivePlugin[] = []; /** * Cached plugin context for this SelectBox instance. */ private pluginContext?: PluginContext; /** * Creates a {@link SelectBox} bound to a native ``. * - Wire controller/service flows: * - search events → {@link SearchController} → adapter updates → popup resize/highlight resets * - adapter selection changes → action API {@link SelectBoxAction.change} with trigger rules * - Connect observers for two-way synchronization: * - {@link SelectObserver} for option changes in ``. * - Moves the `` * element and its dataset-based runtime flags. * * - {@link SelectObserver}: * - On change, re-parses the select into resources and refreshes the selection mask. * - {@link DatasetObserver}: * - On change, mirrors dataset flags into runtime properties: * `disabled` / `readonly` / `visible` * * @param selectObserver - Observer tracking select option/value mutations. * @param datasetObserver - Observer tracking dataset attribute changes. * @param select - The enhanced native select element. * @param optionModelManager - Model manager to update from parsed select. * @internal */ private setupObservers( selectObserver: SelectObserver, datasetObserver: DatasetObserver, select: HTMLSelectElement, optionModelManager: ModelManager, ): void { selectObserver.connect(); selectObserver.onChanged = (sel) => { optionModelManager.updateModel(Libs.parseSelectToArray(sel)); this.getAction()?.refreshMask(); }; datasetObserver.connect(); datasetObserver.onChanged = (dataset) => { if (Libs.string2Boolean(dataset.disabled) !== this.isDisabled) { this.isDisabled = Libs.string2Boolean(dataset.disabled); } if (Libs.string2Boolean(dataset.readonly) !== this.isReadOnly) { this.isReadOnly = Libs.string2Boolean(dataset.readonly); } if (Libs.string2Boolean(dataset.visible) !== this.isVisible) { this.isVisible = Libs.string2Boolean(dataset.visible ?? "1"); } }; } /** * Disconnects observers associated with this instance. * * This is used during {@link destroy} to ensure external DOM observers are stopped, * preventing memory leaks and unintended background updates. */ public deInit(): void { if (this.hasDeInitialized) { return; } const c: any = this.container ?? {}; const { selectObserver, datasetObserver } = c; if (this.plugins.length) { this.runPluginHook("destroy", (plugin) => plugin.destroy?.()); } this.plugins = []; this.pluginContext = null; if (selectObserver?.disconnect) selectObserver.disconnect(); if (datasetObserver?.disconnect) datasetObserver.disconnect(); this.hasDeInitialized = true; } /** * Lifecycle: `destroy` (teardown stage). * * Strict FSM / idempotency: * - No-ops when already in {@link LifecycleState.DESTROYED}. * * Responsibilities: * - Disconnect observers. * - Destroy composed child components/controllers. * - Remove wrapper DOM from the document. * - Clear references to enable garbage collection. * * @override */ public override destroy(): void { if (this.is(LifecycleState.DESTROYED)) { return; } // Disconnect observers this.deInit(); // Destroy child components const container = this.container; container.searchController.destroy(); container.directive.destroy(); container.popup.destroy(); container.accessorybox.destroy(); container.placeholder.destroy(); container.searchbox.destroy(); this.optionModelManager.destroy(); // Remove from DOM this.node?.remove(); // Clear all references this.container = {}; this.node = null; this.options = null; this.optionModelManager = null; this.Selective = null; this.oldValue = null; this.isOpen = false; this.hasLoadedOnce = false; this.isBeforeSearch = false; // Call parent lifecycle destroy super.destroy(); } /** * Builds and returns an imperative action API for controlling this SelectBox instance. * * The returned object is a "facade" used by external consumers (and internal wiring) to: * - read/write selection values (`value`, `valueArray`, `setValue`, `selectAll`, `deSelectAll`) * - control popup visibility (`open`, `close`, `toggle`) * - refresh mask/placeholder (`refreshMask`) * - attach event callbacks (`on`) * - configure AJAX (`ajax`, `loadAjax`) * * ### Triggering contract (external vs internal) * Many methods accept a `trigger`/`canTrigger` boolean which controls whether: * - `beforeChange` / `change` callbacks are invoked via {@link iEvents.callEvent} * - native DOM `"change"` is fired on the underlying select * * This mirrors the library convention of distinguishing user-visible change events from * internal/non-trigger state synchronization. * * ### Side effects * - Mutates `OptionModel.selectedNonTrigger` flags to update selection. * - Writes to the native select value for single-select mode. * - Updates UI mask and accessory box, and requests popup resizing where needed. * - Applies a11y attributes to `ViewPanel` on open/close. * * No-ops: * - Returns `null` when the binder map is missing for the current target element. * * @returns An action facade for controlling this instance, or `null` if not bound. */ public getAction?(): SelectBoxAction { const container = this.container; const superThis = this; const getInstance = () => { return this.Selective.find(container.targetElement); }; const bindedMap = Libs.getBinderMap(container.targetElement); if (!bindedMap) return null; const bindedOptions = bindedMap.options; const resp: Partial & Record = { get targetElement() { return container.targetElement; }, get placeholder() { return container.placeholder.get(); }, set placeholder(value: string) { container.placeholder?.set(value); container.searchbox?.setPlaceHolder(value); }, get oldValue() { return superThis.oldValue; }, set value(value: any) { this.setValue(null, value, true); }, get value() { const item_list = this.valueArray as string[]; const valLength = item_list.length; return valLength > 1 ? item_list : valLength === 0 ? "" : item_list[0]; }, get valueArray() { const item_list: string[] = []; superThis.getModelOption().forEach((m) => { if (m.selected) item_list.push(m.value); }); return item_list; }, get valueString() { const customDelimiter = bindedOptions.customDelimiter; const item_list = this.valueArray as string[]; return item_list.join(customDelimiter); }, get valueOptions() { const item_list: OptionModel[] = []; superThis.getModelOption(true).forEach((m) => { item_list.push(m); }); return item_list; }, get mask() { const item_list: string[] = []; superThis.getModelOption(true).forEach((m) => { item_list.push(m.mask); }); return item_list; }, get valueText() { const item_list: string[] = []; superThis.getModelOption(true).forEach((m) => { item_list.push(m.text); }); const valLength = item_list.length; return valLength > 1 ? item_list : valLength === 0 ? "" : item_list[0]; }, get isOpen() { return superThis.isOpen; }, getParent(_evtToken?: IEventCallback) { return container.view.parentElement; }, valueDataset( _evtToken?: IEventCallback, strDataset: string = null, isArray: boolean = false, ) { var item_list = []; superThis.getModelOption(true).forEach((m) => { item_list.push( strDataset ? m.dataset[strDataset] : m.dataset, ); }); if (!isArray) { if (item_list.length == 0) { return ""; } else if (item_list.length == 1) { return item_list[0]; } } return item_list; }, selectAll(_evtToken?: IEventCallback, trigger: boolean = true) { if (bindedOptions.multiple && bindedOptions.maxSelected > 0) { if ( superThis.getModelOption().length > bindedOptions.maxSelected ) return; } if (this.disabled || this.readonly || !bindedOptions.multiple) return; if (trigger) { const beforeChangeToken = iEvents.callEvent( [getInstance()], ...bindedOptions.on.beforeChange, ); if (beforeChangeToken.isCancel) return; superThis.oldValue = this.value; } superThis.getModelOption().forEach((m) => { m.selectedNonTrigger = true; }); this.change(false, trigger); }, deSelectAll(_evtToken?: IEventCallback, trigger: boolean = true) { if (this.disabled || this.readonly || !bindedOptions.multiple) return; if (trigger) { const beforeChangeToken = iEvents.callEvent( [getInstance()], ...bindedOptions.on.beforeChange, ); if (beforeChangeToken.isCancel) return; superThis.oldValue = this.value; } superThis.getModelOption().forEach((m) => { m.selectedNonTrigger = false; }); this.change(false, trigger); }, deSelectByDataset( _evtToken?: IEventCallback, dataset?: any, trigger: boolean = true, ) { if (dataset) { superThis.getModelOption().forEach((optionModel) => { if (optionModel.dataset) { for (let searchKey in dataset) { let value = dataset[searchKey]; !Array.isArray(value) && (value = [value]); if ( value.includes( optionModel.dataset[searchKey], ) ) { optionModel.selectedNonTrigger = false; } } } }); this.change(false, trigger); } }, setValue( _evtToken?: IEventCallback, value?: any, trigger: boolean = true, force: boolean = false, ) { if (!Array.isArray(value)) value = [value]; value = value.filter((v: any) => v !== "" && v != null); if (value.length === 0) { superThis .getModelOption() .forEach((m) => (m.selectedNonTrigger = false)); this.change(false, trigger); return; } if (bindedOptions.multiple && bindedOptions.maxSelected > 0) { if (value.length > bindedOptions.maxSelected) { console.warn( `Cannot select more than ${bindedOptions.maxSelected} items`, ); return; } } if (!force && (this.disabled || this.readonly)) return; // AJAX: load missing values if (container.searchController?.isAjax?.()) { container.searchController.resetPagination(); superThis.hasLoadedOnce = false; const { missing } = container.searchController.checkMissingValues(value); if (missing.length > 0) { (async () => { if (bindedOptions.loadingfield) container.popup?.showLoading?.(); try { const result = await container.searchController.loadByValues( missing, ); if (result.success && result.items.length > 0) { result.items.forEach((it: any) => { if ( missing.includes(it.value) || missing.includes(it.text) ) it.selected = true; }); container.searchController.applyAjaxResult?.( result.items, false, false, ); setTimeout(() => { container.searchController.resetPagination(); this.change(false, trigger); }, 200); } else if (missing.length > 0) { console.warn( `Could not load ${missing.length} values:`, missing, ); setTimeout(() => { container.searchController.resetPagination(); this.change(false, trigger); }, 200); } } catch (error) { console.error( "Error loading missing values:", error, ); } finally { if (bindedOptions.loadingfield) container.popup?.hideLoading?.(); } })(); return; } } if (trigger) { const beforeChangeToken = iEvents.callEvent( [getInstance()], ...bindedOptions.on.beforeChange, ); if (beforeChangeToken.isCancel) return; superThis.oldValue = this.value; } superThis.getModelOption().forEach((m) => { m.selectedNonTrigger = value.some((v: any) => v == m.value); }); if (!bindedOptions.multiple && value.length > 0) { container.targetElement.value = value[0]; } this.change(false, trigger); }, load() { if ( (!superThis.hasLoadedOnce || superThis.isBeforeSearch) && bindedOptions?.ajax ) { container.searchController.resetPagination(); container.popup.showLoading(); superThis.hasLoadedOnce = true; superThis.isBeforeSearch = false; setTimeout(() => { if (!container.popup || !container.searchController) return; container.searchController .search("") .then(() => container.popup?.triggerResize?.()) .catch((err: unknown) => console.error("Initial ajax load error:", err), ); }, bindedOptions.animationtime); container.popup.load(); } else { container.popup.load(); } }, open() { if (superThis.isOpen) return; const findAnother = superThis.Selective?.find?.(); if (findAnother && !findAnother.isEmpty) { const closeToken: IEventToken = findAnother.close(); if (closeToken.isCancel) return; } if (this.disabled) { return; } const beforeShowToken = iEvents.callEvent( [getInstance()], ...bindedOptions.on.beforeShow, ); if (beforeShowToken.isCancel) { return; } superThis.isOpen = true; container.directive.setDropdown(true); const adapter: MixedAdapter = container.popup.optionAdapter; const selectedOption = adapter.getSelectedItem(); if (selectedOption) { adapter.setHighlight(selectedOption, false); } else { adapter.resetHighlight(); } this.load(); container.popup.open( () => { setTimeout(() => { if (selectedOption) { adapter.setHighlight(selectedOption, bindedOptions.autoscroll); } }, 100); }, !container.popup.loadingState.isVisible, ); container.searchbox.show(); const ViewPanel: HTMLElement = container.tags.ViewPanel; ViewPanel.setAttribute("aria-expanded", "true"); ViewPanel.setAttribute( "aria-controls", bindedOptions.SEID_LIST, ); ViewPanel.setAttribute("aria-haspopup", "listbox"); ViewPanel.setAttribute( "aria-labelledby", bindedOptions.SEID_HOLDER, ); if (bindedOptions.multiple) { ViewPanel.setAttribute("aria-multiselectable", "true"); } iEvents.callEvent([getInstance()], ...bindedOptions.on.show); if (superThis.pluginContext) { superThis.runPluginHook("onOpen", (plugin) => plugin.onOpen?.(superThis.pluginContext), ); } return; }, close() { if (!superThis.isOpen) return; const beforeCloseToken = iEvents.callEvent( [getInstance()], ...bindedOptions.on.beforeClose, ); if (beforeCloseToken.isCancel) return; superThis.isOpen = false; container.directive.setDropdown(false); container.popup.close(() => { container.searchbox.clear(false); }); container.searchbox.hide(); container.tags.ViewPanel.setAttribute("aria-expanded", "false"); iEvents.callEvent([getInstance()], ...bindedOptions.on.close); if (superThis.pluginContext) { superThis.runPluginHook("onClose", (plugin) => plugin.onClose?.(superThis.pluginContext), ); } return; }, toggle() { if (superThis.isOpen) this.close(); else this.open(); }, change(_evtToken?: IEventCallback, canTrigger: boolean = true) { if (canTrigger) { if ( bindedOptions.multiple && bindedOptions.maxSelected > 0 ) { if ( this.valueArray.length > bindedOptions.maxSelected ) { this.setValue(null, this.oldValue, false, true); } } if (this.disabled || this.readonly) { this.setValue(null, this.oldValue, false, true); return; } const beforeChangeToken = iEvents.callEvent( [getInstance(), this.value], ...bindedOptions.on.beforeChange, ); if (beforeChangeToken.isCancel) { this.setValue(null, this.oldValue, false); return; } } this.refreshMask(); container.accessorybox.setModelData(this.valueOptions); if (canTrigger) { if (container.targetElement) iEvents.trigger(container.targetElement, "change"); iEvents.callEvent( [getInstance(), this.value], ...bindedOptions.on.change, ); if (superThis.options?.autoclose) this.close(); } // Trigger update lifecycle if (superThis.is(LifecycleState.MOUNTED)) { superThis.update(); } if (superThis.pluginContext && superThis.optionModelManager) { const resources = superThis.optionModelManager.getResources(); superThis.runPluginHook("onChange", (plugin) => plugin.onChange?.( this.value, resources.modelList, resources.adapter, superThis.pluginContext, ), ); } }, refreshMask() { let mask = bindedOptions.placeholder; if ( !bindedOptions.multiple && superThis.getModelOption().length > 0 ) { mask = this.mask[0]; } mask ??= bindedOptions.placeholder; container.placeholder.set(mask, false); container.searchbox.setPlaceHolder(mask); }, on( _evtToken: IEventCallback, evtName: string, handle: (...args: any[]) => any, ) { if (!bindedOptions.on[evtName]) bindedOptions.on[evtName] = []; bindedOptions.on[evtName].push(handle); }, ajax(_evtToken: IEventCallback, obj: AjaxConfig) { if (obj.keepSelected == undefined) { obj.keepSelected = superThis.options.keepSelected; } container.searchController.setAjax(obj); }, loadAjax(_evtToken: IEventCallback) { return new Promise((resove, reject) => { container.popup.showLoading(); container.searchController.resetPagination(); superThis.hasLoadedOnce = true; superThis.isBeforeSearch = false; if (!container.popup || !container.searchController) { resove(getInstance()); } else { container.searchController .search("") .then(() => { container.popup?.triggerResize?.(); setTimeout(() => { resove(getInstance()); }, 60); }) .catch((err: unknown) => { console.error("Initial ajax load error:", err); reject(err); }); } }); }, }; // mirror properties: disabled / readonly / visible this.createSymProp(resp, "disabled", "isDisabled"); this.createSymProp(resp, "readonly", "isReadOnly"); this.createSymProp(resp, "visible", "isVisible"); return resp as SelectBoxAction; } /** * Defines a mirrored facade property on an arbitrary object. * * This helper is used when building the {@link SelectBoxAction} facade to expose * `disabled` / `readonly` / `visible` as ergonomic properties while keeping them * synchronized with the underlying {@link SelectBox} runtime state. * * ### Behavior * - Getter proxies the current runtime value from `this[privateProp]`. * - Setter coerces the incoming value to boolean and writes it to `this[privateProp]`. * - Additionally reflects the value onto `targetElement.dataset[prop]` when available, * allowing external dataset observers (and DOM tooling) to observe state changes. * * ### Side effects * - Mutates the action facade object via `Object.defineProperty`. * - Mutates DOM dataset on the underlying `