import { MixedAdapter } from "../adapter/mixed-adapter";
import { Lifecycle } from "../core/base/lifecycle";
import { ModelManager } from "../core/model-manager";
import { OptionModel } from "../models/option-model";
import { SelectBoxAction } from "../types/components/searchbox.type";
import { LifecycleState } from "../types/core/base/lifecycle.type";
import { MixedItem } from "../types/core/base/mixed-adapter.type";
import { MountViewResult } from "../types/utils/libs.type";
import { SelectiveOptions } from "../types/utils/selective.type";
import { iEvents } from "../utils/ievents";
import { Libs } from "../utils/libs";
/**
* Accessory container that renders "selected chips" for multi-select mode.
*
* This component is a small DOM-driven helper that sits next to the Select UI mask and
* visualizes current selections as removable chips. It does not own selection state by itself;
* instead, it delegates deselection actions back to the {@link ModelManager} and underlying models.
*
* ### Responsibility
* - Create a lightweight DOM container (single root node) for chips.
* - Position the container relative to the Select UI mask (top or bottom insertion).
* - Render the current selection set as removable chips.
* - Dispatch deselect actions back into the selection pipeline:
* - pre-change hook via `modelManager.triggerChanging("select")`
* - then mutate the model (`OptionModel.selected = false`) to produce external selection events.
* - Show/hide based on configuration (`accessoryVisible`, `multiple`) and chip count.
*
* ### Lifecycle (Strict FSM & idempotency)
* - Construction optionally calls {@link initialize} and transitions `NEW → INITIALIZED` via {@link init}.
* - {@link setRoot} binds DOM anchors, inserts the node into the mask container, then calls {@link mount}.
* - {@link setModelData} re-renders chips and calls {@link update} (guarded: only after mounted).
* - {@link destroy} removes the DOM node, clears references, and transitions to `DESTROYED`.
*
* No-ops / guards:
* - `init()` is guarded to only run in `NEW`.
* - `mount()` is guarded to only run in `INITIALIZED`.
* - `update()` is guarded to only emit once mounted.
*
* ### Event / callback flow
* - Chip remove click:
* 1) prevents default
* 2) awaits `modelManager.triggerChanging("select")` (pre-change pipeline)
* 3) sets `modelData.selected = false` (external selection semantic)
* - After rendering chips, triggers `window` `"resize"` via {@link iEvents.trigger} to allow
* popup/layout logic to recompute geometry.
*
* ### DOM & a11y side effects
* - Creates a root `
` with classes `seui-accessorybox hide`.
* - Stops `mouseup` propagation on the root to avoid "outside click" behaviors.
* - Each chip has:
* - a `` with `aria-label`/`title` for screen readers and tooltips,
* - a content `` rendered via `innerHTML` from {@link OptionModel.text}.
* - Visibility is controlled via `"hide"` class.
*
* @extends Lifecycle
* @see {@link ModelManager}
* @see {@link OptionModel}
*/
export class AccessoryBox extends Lifecycle {
/**
* Mounted structure returned by the node mounting helper.
* Contains the root element (`view`) and any tag handles (if present).
*/
private nodeMounted?: MountViewResult;
/**
* Root DOM element of the accessory box (hidden by default).
* Created during {@link init} and removed during {@link destroy}.
*/
private node?: HTMLDivElement;
/**
* Component configuration (texts, behavior, placement).
* This component reads:
* - `accessoryStyle` ("top" or default bottom)
* - `accessoryVisible` (enable/disable)
* - `multiple` (multi-select mode)
* - `textAccessoryDeselect` (a11y label prefix)
*/
private options?: SelectiveOptions;
/**
* The Select UI mask element used as the positioning reference.
* Provided by {@link setRoot}.
*/
private selectUIMask?: HTMLDivElement;
/**
* Parent container that hosts both the Select UI mask and the accessory box.
* Computed from `selectUIMask.parentElement`.
*/
private parentMask?: HTMLDivElement;
/**
* ModelManager used to run selection pipelines and coordinate state updates.
* This component does not own selection state; it delegates to the model layer.
*/
private modelManager?: ModelManager;
/**
* Current selected option models rendered as chips.
* This is a cached snapshot used for show/hide decisions and re-rendering.
*/
private modelDatas: OptionModel[] = [];
/**
* Creates an AccessoryBox and optionally initializes it with configuration.
*
* @param {SelectiveOptions} [options=null] - Configuration controlling placement/visibility and texts.
*/
public constructor(options?: SelectiveOptions) {
super();
if (options) this.initialize(options);
}
/**
* Internal SelectBox action bridge used by the accessory box to communicate
* with the parent SelectBox instance.
*
* This reference is intentionally lightweight and acts as a direct integration
* point between chip interactions and the main Select component behavior.
*
* Current responsibilities:
* - Read runtime interaction state:
* - `readonly`
* - `disabled`
* - Trigger synchronized change propagation through:
* - `change(null, true)`
*
* Usage flow:
* - When a chip remove button is clicked:
* 1) AccessoryBox checks `readonly` / `disabled`
* 2) Updates `modelData.selectedNonTrigger = false`
* 3) Delegates the final change pipeline back to the SelectBox instance
* through `internalInstance.change(...)`
*
* Notes:
* - This property is optional because the AccessoryBox can exist before the
* parent SelectBox finishes wiring dependencies.
* - The instance is not owned by AccessoryBox and therefore is not destroyed
* during {@link destroy}.
* - Intended for internal framework communication only.
*/
public internalInstance?: SelectBoxAction = null;
/**
* Stores options and starts lifecycle initialization.
*
* Note: This does not attach the node into the DOM. DOM insertion occurs in {@link setRoot}
* after the Select UI mask is available.
*
* @param {SelectiveOptions} options - Configuration object for the accessory box.
* @returns {void}
*/
private initialize(options: SelectiveOptions): void {
this.options = options;
this.init(); // Trigger lifecycle initialization
}
/**
* Initializes the accessory box DOM structure.
*
* Guarded: runs only when state is `NEW`.
*
* Side effects:
* - Creates the root node with base classes (`seui-accessorybox`, `hide`).
* - Stops `mouseup` propagation to avoid outside-click handlers reacting to chip interactions.
*
* @returns {void}
* @override
*/
public init(): void {
if (this.state !== LifecycleState.NEW) return;
this.nodeMounted = Libs.mountNode({
AccessoryBox: {
tag: {
node: "div",
classList: ["seui-accessorybox", "hide"],
onmouseup: (evt: MouseEvent) => {
// Prevent outside listeners from reacting to chip clicks
evt.stopPropagation();
},
},
},
});
this.node = this.nodeMounted.view as HTMLDivElement;
super.init(); // Mark as INITIALIZED
}
/**
* Binds the component to the Select UI mask and inserts the accessory node into the DOM.
*
* - Captures the mask and its parent container.
* - Calls {@link refreshLocation} to place the node either before or after the mask.
* - Transitions to `MOUNTED` by calling {@link mount}.
*
* @param {HTMLDivElement} selectUIMask - The overlay/mask element of the main Select UI.
* @returns {void}
*/
public setRoot(selectUIMask: HTMLDivElement): void {
this.selectUIMask = selectUIMask;
this.parentMask = selectUIMask.parentElement as HTMLDivElement | null;
this.refreshLocation();
this.mount();
}
/**
* Lifecycle mount (guarded).
*
* This component can only be mounted after {@link init} has completed (`INITIALIZED`).
* No-op otherwise.
*
* @returns {void}
* @override
*/
public mount(): void {
if (!this.is(LifecycleState.INITIALIZED)) {
return;
}
super.mount();
}
/**
* Positions the accessory box relative to the Select UI mask.
*
* Placement:
* - When `options.accessoryStyle === "top"`: insert before the mask.
* - Otherwise: insert after the mask (before `mask.nextSibling`).
*
* No-op if the DOM anchors or {@link options} are not available.
*
* @returns {void}
*/
public refreshLocation(): void {
if (
!this.parentMask ||
!this.node ||
!this.selectUIMask ||
!this.options
)
return;
const ref =
this.options.accessoryStyle === "top"
? this.selectUIMask
: (this.selectUIMask.nextSibling as ChildNode);
this.parentMask.insertBefore(this.node, ref);
}
/**
* Assigns the {@link ModelManager} used to run selection pipelines and mutate selection state.
*
* @param {ModelManager} modelManager - Model manager controlling option state.
* @returns {void}
*/
public setModelManager(
modelManager: ModelManager,
): void {
this.modelManager = modelManager;
}
/**
* Re-renders chips for the given selected options.
*
* Rendering behavior:
* - Clears previous chips (`node.replaceChildren()`).
* - When `options.multiple === true` and `modelDatas.length > 0`:
* - mounts a chip per option with:
* - a `` that deselects the option,
* - a content span rendered from `OptionModel.text` (HTML preserved).
* - Otherwise, normalizes to an empty list.
*
* Deselect click flow:
* 1) `preventDefault()`
* 2) `await modelManager.triggerChanging("select")` (pre-change pipeline; no-op if manager is absent)
* 3) `modelData.selected = false` (external selection semantics)
*
* Post-render side effects:
* - Calls {@link refreshDisplay} to toggle visibility.
* - Emits lifecycle {@link update} (guarded).
* - Triggers a global `"resize"` event to allow layout/popup recalculation.
*
* @param {OptionModel[]} modelDatas - Selected options to render.
* @returns {void}
*
* @remarks
* The chip label uses `innerHTML` and therefore assumes `modelData.text` is trusted/sanitized upstream
* when HTML rendering is enabled.
*/
public setModelData(modelDatas: OptionModel[]): void {
if (!this.node || !this.options) return;
this.node.replaceChildren();
const superThis = this;
if (modelDatas.length > 0 && this.options.multiple) {
modelDatas.forEach((modelData) => {
Libs.mountNode(
{
AccessoryItem: {
tag: { node: "div", classList: ["accessory-item"] },
child: {
Button: {
tag: {
node: "span",
classList: ["accessory-item-button"],
role: "button",
ariaLabel: `${this.options!.textAccessoryDeselect}${modelData.textContent}`,
title: `${this.options!.textAccessoryDeselect}${modelData.textContent}`,
onclick: async (evt: MouseEvent) => {
evt.preventDefault();
const instance = superThis.internalInstance;
if (instance.readonly || instance.disabled) {
return;
}
modelData.selectedNonTrigger = false;
instance?.change(null, true);
},
},
},
Content: {
tag: {
node: "span",
classList: ["accessory-item-content"],
innerHTML: modelData.text,
},
},
},
},
},
this.node,
);
});
} else {
modelDatas = [];
}
this.modelDatas = modelDatas;
this.refreshDisplay();
this.update(); // lifecycle UPDATE
iEvents.trigger(window, "resize");
}
/**
* Lifecycle update (guarded).
*
* Only emits updates after the component is mounted. This keeps the FSM strict and prevents
* update hooks from running before the node is attached to the DOM.
*
* @returns {void}
* @override
*/
public update(): void {
if (this.state !== LifecycleState.MOUNTED) return;
super.update();
}
/**
* Applies display rules based on configuration and current selection count.
*
* Visible when all are true:
* - `options.accessoryVisible`
* - `options.multiple`
* - `modelDatas.length > 0`
*
* @returns {void}
*/
private refreshDisplay(): void {
if (
this.options?.accessoryVisible &&
this.modelDatas.length > 0 &&
this.options.multiple
) {
this.show();
} else {
this.hide();
}
}
/**
* Shows the accessory box by removing the `"hide"` CSS class.
*
* @returns {void}
*/
private show(): void {
this.node?.classList.remove("hide");
}
/**
* Hides the accessory box by applying the `"hide"` CSS class.
*
* @returns {void}
*/
private hide(): void {
this.node?.classList.add("hide");
}
/**
* Destroys the accessory box and releases owned resources.
*
* Behavior:
* - Idempotent: returns early if already `DESTROYED`.
* - Removes the root DOM node.
* - Clears references (options, anchors, manager) and cached model data.
* - Completes lifecycle teardown via `super.destroy()`.
*
* @returns {void}
* @override
*/
public destroy(): void {
if (this.state === LifecycleState.DESTROYED) return;
// Clean up DOM
this.node?.remove();
// Clear references
this.nodeMounted = null;
this.node = null;
this.options = null;
this.selectUIMask = null;
this.parentMask = null;
this.modelManager = null;
this.modelDatas = [];
super.destroy();
}
}