// text-form-values.ts import { HsonAttrs, HsonNode } from "../../../core/types.js"; import { ELEM_OBJ_ARR, ELEM_TAG, LEAF_NODES, STR_TAG, VAL_TAG } from "../../../core/constants.js"; import { is_Node } from "../../../core/node-guards.js"; import { make_string } from "../../../core/stringify.js"; import { _throw_transform_err } from "../../transform/utils/sys-utils/throw-transform-err.utils.js"; import { get_el_for_node } from "../utils/node-map-helpers.js"; import { make_leaf } from "../../transform/parsers/parse-tokens.js"; import { Primitive } from "../../../core/types.js"; import { CREATE_NODE } from "../../../core/factories.js"; import { LiveTree } from "../livetree.js"; import { LiveFormApi } from "../../../types/livetree-internals.types.js"; import { ensure_node_attrs } from "../../../core/node-storage.js"; import { delegate_document_text_mutation_if_bound, document_binding_for_node, } from "../lifecycle/document-binding-state.js"; import { release_node_parent, release_subtree_ownership } from "../lifecycle/graph-ownership.js"; import { dispose_node_deep } from "../utils/dispose-node.js"; import { reconcile_browser_realization_children } from "../../../internal/browser-realization/browser-realization-dom.js"; import { default_livetree_runtime, runtime_for_node } from "../runtime/livetree-runtime.js"; /** * Options for form state writers that mirror to the DOM when available. */ export type SetNodeFormOpts = Readonly<{ // default should be "silent" (missing DOM is normal pre-mount) silent?: boolean; // for callers that want the old strict behavior strict?: boolean; }>; export type LiveTextApi = Readonly<{ set: (value: Primitive) => TOwner; add: (value: Primitive) => TOwner; overwrite: (value: Primitive) => TOwner; insert: (ix: number, value: Primitive) => TOwner; get: () => string; }>; // treat $_attrs as a simple dictionary type AttrDict = Record; // central DOM form-control narrowing type FormEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; /* ------------------------------------------------------------------------------------------------ * Internal helpers * ---------------------------------------------------------------------------------------------- */ // leaf tags // const isLeafNode = (tag: string): boolean => LEAF_NODES.includes(tag); const isElemObjArr = (tag: string): boolean => ELEM_OBJ_ARR.includes(tag); function ensureVsn(node: HsonNode): HsonNode { // find first VSN child const found = node.$_content.find((c): c is HsonNode => is_Node(c) && isElemObjArr(c.$_tag)); if (found) return found; // create bucket; prefer `_hson_elem` as the generic container const bucket = CREATE_NODE({ $_tag: ELEM_TAG, $_content: node.$_content, // move existing content under the bucket }); node.$_content = [bucket]; return bucket; } // function make_dom_leaf(_leaf: HsonNode, value: Primitive): Text { // return document.createTextNode(value === null ? "" : String(value)); // } function ensure_attrs(node: HsonNode): AttrDict { return ensure_node_attrs(node) as unknown as AttrDict; } function form_control_kind(el: Element): "input" | "textarea" | "select" | undefined { const tag = el.tagName.toLowerCase(); return tag === "input" || tag === "textarea" || tag === "select" ? tag : undefined; } function resolve_form_control(el: Element): HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null { if (form_control_kind(el) !== undefined) return el as FormEl; // If mapping gives a wrapper element, look for the first real control inside. const inner = el.querySelector("input,textarea,select"); if (!inner) return null; return form_control_kind(inner) === undefined ? null : inner as FormEl; } function form_el_for_node(node: HsonNode): FormEl | null { const el = get_el_for_node(node); if (!el) return null; return form_control_kind(el) === undefined ? null : el as FormEl; } // optional strictness helper function throw_missing_el(node: HsonNode, source: string): never { const quid = node.$_meta?.quid ?? ""; _throw_transform_err( `missing element for node (tag=${node.$_tag}, quid=${quid})`, source, make_string(node), ); } export function get_node_text_content(node: HsonNode): string { let out = ""; const walk = (n: HsonNode): void => { for (const child of n.$_content ?? []) { if (!is_Node(child)) { if (child !== null && child !== undefined) out += String(child); continue; } if (child.$_tag === STR_TAG || child.$_tag === VAL_TAG) { const first = child.$_content?.[0]; if (first !== null && first !== undefined) out += String(first); continue; } walk(child); } }; walk(node); return out; } /* ------------------------------------------------------------------------------------------------ * Form state: value / checked / selected * ---------------------------------------------------------------------------------------------- */ /** * Set a form value on the node and mirror it to the DOM when mounted. * * By default, missing DOM elements are ignored (attrs are canonical). * * @param node - The Hson node to update. * @param value - Form value string to store. * @param opts - Optional flags controlling missing DOM behavior. * @returns void. */ export function set_form_value(node: HsonNode, value: string, opts?: SetNodeFormOpts): void { const binding = document_binding_for_node(node); if (binding !== undefined) { binding.delegateAttrs({ kind: "set", name: "value", value }); const el = form_el_for_node(node); if (!el && (opts?.strict || opts?.silent === false)) { throw_missing_el(node, "setNodeFormValue"); } return; } const attrs = ensure_attrs(node); attrs.value = value; const el = form_el_for_node(node); if (!el) { if (opts?.strict) throw_missing_el(node, "setNodeFormValue"); if (opts?.silent === false) throw_missing_el(node, "setNodeFormValue"); return; } // always mirror when we can const ctl = resolve_form_control(el as Element); if (ctl) { ctl.value = value; } } /** * Read a form value, preferring DOM when mounted. * * @param node - The Hson node to read from. * @returns The current form value (empty string if missing). */ export function get_form_value(node: HsonNode): string { const el = get_el_for_node(node); if (el) { const ctl = resolve_form_control(el as Element); if (ctl) return ctl.value ?? ""; } const attrs = (node.$_attrs as unknown as AttrDict | undefined); const raw = attrs?.value; return raw == null ? "" : String(raw); } /** * Set the checked state for checkbox/radio inputs and mirror to the DOM. * * @param node - The Hson node to update. * @param checked - New checked state. * @param opts - Optional flags controlling missing DOM behavior. * @returns void. */ export function set_input_checked(node: HsonNode, checked: boolean, opts?: SetNodeFormOpts): void { const binding = document_binding_for_node(node); if (binding !== undefined) { binding.delegateAttrs({ kind: "set", name: "checked", value: checked }); const el = form_el_for_node(node); if (!el && (opts?.strict || opts?.silent === false)) { throw_missing_el(node, "setNodeFormChecked"); } return; } const attrs = ensure_attrs(node); attrs.checked = checked; const el = form_el_for_node(node); if (!el) { if (opts?.strict) throw_missing_el(node, "setNodeFormChecked"); if (opts?.silent === false) throw_missing_el(node, "setNodeFormChecked"); return; } if (el && form_control_kind(el) === "input") { (el as HTMLInputElement).checked = checked; } } /** * Read the checked state for checkbox/radio inputs, preferring DOM when mounted. * * @param node - The Hson node to read from. * @returns True when checked, otherwise false. */ export function get_input_checked(node: HsonNode): boolean { const el = form_el_for_node(node); if (el && form_control_kind(el) === "input") return !!(el as HTMLInputElement).checked; const attrs = (node.$_attrs as unknown as AttrDict | undefined); const raw = attrs?.checked; if (typeof raw === "boolean") return raw; if (typeof raw === "string") return raw === "true"; if (typeof raw === "number") return raw !== 0; return false; } /** * Set selected state for a , preferring DOM when mounted. * * @param node - The Hson node to read from. * @returns The selected value string or array of values for multi-select. */ export function get_input_selected(node: HsonNode): string | readonly string[] { const el = form_el_for_node(node); if (el && form_control_kind(el) === "select") { const select = el as HTMLSelectElement; if (select.multiple) { return Array.from(select.selectedOptions).map(o => o.value); } return select.value ?? ""; } const attrs = (node.$_attrs as unknown as AttrDict | undefined); const values = attrs?.values; if (Array.isArray(values)) { return values.map(v => String(v)); } const raw = attrs?.value; return raw == null ? "" : String(raw); } export function make_form_api( tree: TTree, ): LiveFormApi { return { setValue: (value, opts) => { set_form_value(tree.node, value, opts); return tree; }, getValue: () => { return get_form_value(tree.node); }, getChecked: () => { return get_input_checked(tree.node); }, setChecked: (value, opts) => { set_input_checked(tree.node, value, opts); return tree; }, getSelected: () => { return get_input_selected(tree.node); }, setSelected: (value, opts) => { set_input_selected(tree.node, value, opts); return tree; }, }; } function primitive_to_text(value: Primitive): string { return value === null ? "" : String(value); } // -----------------------------------------------------------------------------//.. IR helpers // ----------------------------------------------------------------------------- function isLeafTag(tag: unknown): boolean { return tag === STR_TAG || tag === VAL_TAG; } // -----------------------------------------------------------------------------//.. Text operations // ----------------------------------------------------------------------------- /** * Replace ONLY the text leaves (_hson_str/_hson_val) under this node. * Keeps non-leaf content untouched. * * DOM: reconciles the shared browser realization, including Hson boundary markers. */ export function set_node_text_content(node: HsonNode, value: Primitive): void { if (delegate_document_text_mutation_if_bound(node, { kind: "set", value })) return; const text = primitive_to_text(value); const leaf = make_leaf(text); // always edit inside the VSN bucket const bucket = ensureVsn(node); let replaced = false; const next = [] as typeof bucket.$_content; for (const child of bucket.$_content) { if (is_Node(child) && isLeafTag(child.$_tag)) { if (!replaced) { next.push(leaf); replaced = true; } continue; } next.push(child); } if (!replaced) next.unshift(leaf); bucket.$_content = next; // --- DOM projection (CHANGED): Text nodes only --- if (get_el_for_node(node) === undefined) return; reconcile_browser_realization_children(node, runtime_for_node(node) ?? default_livetree_runtime()); } /** * Append another text leaf to $_content (non-destructive). * * DOM: appends a Text node to the host element. */ export function add_node_text_content(node: HsonNode, value: Primitive): void { if (delegate_document_text_mutation_if_bound(node, { kind: "add", value })) return; const text = primitive_to_text(value); const leaf = make_leaf(text); // always edit inside the VSN bucket const bucket = ensureVsn(node); bucket.$_content.push(leaf); const host = get_el_for_node(node); if (!host) return; reconcile_browser_realization_children(node, runtime_for_node(node) ?? default_livetree_runtime()); } /** * Insert a text leaf at a specific $_content index. * Index counts all items in the VSN bucket $_content. */ export function insert_node_text_leaf(node: HsonNode, index: number, value: Primitive): void { if (delegate_document_text_mutation_if_bound(node, { kind: "insert", index, value })) return; const text = primitive_to_text(value); const leaf = make_leaf(text); // always edit inside the VSN bucket const bucket = ensureVsn(node); const len = bucket.$_content.length; const ix = Number.isFinite(index) ? Math.max(0, Math.min(len, Math.floor(index))) : len; bucket.$_content.splice(ix, 0, leaf); const host = get_el_for_node(node); if (!host) return; reconcile_browser_realization_children(node, runtime_for_node(node) ?? default_livetree_runtime()); } /** * Destructive overwrite: replace ALL content with one leaf; mirror to DOM using textContent. */ export function overwrite_node_text_content(node: HsonNode, value: Primitive): void { if (delegate_document_text_mutation_if_bound(node, { kind: "overwrite", value })) return; const text = primitive_to_text(value); const leaf = make_leaf(text); // always overwrite the VSN bucket, not node.$_content const bucket = ensureVsn(node); const displacedRoots = bucket.$_content.filter(is_Node); bucket.$_content = [leaf]; for (const root of displacedRoots) release_node_parent(root, bucket); for (const root of displacedRoots) { dispose_node_deep(root); release_subtree_ownership(root); } const el = get_el_for_node(node); if (!el) return; reconcile_browser_realization_children(node, runtime_for_node(node) ?? default_livetree_runtime()); } export function make_text_api( tree: TTree, ): LiveTextApi { return { set: (value) => { set_node_text_content(tree.node, value); return tree; }, add: (value) => { add_node_text_content(tree.node, value); return tree; }, overwrite: (value) => { overwrite_node_text_content(tree.node, value); return tree; }, insert: (ix, value) => { insert_node_text_leaf(tree.node, ix, value); return tree; }, get: () => { return get_node_text_content(tree.node); }, }; }