import { cloneWithBindings } from './metadata' import { ValueElement } from './xin-types' export const dispatch = ( target: Element, type: string, // change/input must bubble (and compose) so bind()'s delegated document.body // listener sees them — a component bound like an via bindings.value // reports edits through its `change` event, and a non-bubbling event never // reached the delegated fromDOM handler. Defaults preserve non-bubbling // dispatch for internal events like 'resize'. { bubbles = false, composed = false }: EventInit = {} ): void => { const event = new Event(type, { bubbles, composed }) target.dispatchEvent(event) } const valueType = (element: Element): string => { if (element instanceof HTMLInputElement) { return element.type } else if ( element instanceof HTMLSelectElement && element.hasAttribute('multiple') ) { return 'multi-select' } else { return 'other' } } // DOM controls speak string; state speaks typed values. The binding layer is // the type boundary, in two layers (H-6, decided 2026-07-17): // 1. typed-control reads (below): a control that DECLARES its type is read // natively — number/range as numbers, the date family as Dates, time as // ms-since-midnight — independent of state. This keeps the FIRST write // correctly typed when state is still undefined (deeply-async pattern), // and keeps getValue honest as a standalone utility. // 2. state-driven coercion (bind.ts handleChange): for controls that don't // declare a type, the type state currently holds is authoritative. // Empty/partial entry never fabricates a value: NaN/null falls back to the // control's raw string. const pad = (n: number, len = 2): string => String(n).padStart(len, '0') // the string serialization each date-family control expects. type=date / // month use UTC fields (matching valueAsDate semantics); datetime-local is // local time per spec. const dateToControlString = (type: string, d: Date): string => { switch (type) { case 'date': return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad( d.getUTCDate() )}` case 'month': return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}` case 'datetime-local': return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad( d.getDate() )}T${pad(d.getHours())}:${pad(d.getMinutes())}` default: return d.toISOString() } } const msToTimeString = (ms: number): string => { const seconds = Math.floor(ms / 1000) const hhmm = `${pad(Math.floor(seconds / 3600))}:${pad( Math.floor(seconds / 60) % 60 )}` // platform normalization omits zero seconds return seconds % 60 === 0 ? hhmm : `${hhmm}:${pad(seconds % 60)}` } const timeStringToMs = (value: string): number => { const match = value.match(/^(\d\d):(\d\d)(?::(\d\d)(?:\.(\d+))?)?$/) if (match == null) return NaN const [, h, m, sec, frac] = match return ( (Number(h) * 60 + Number(m)) * 60000 + Number(sec ?? 0) * 1000 + Number(frac ?? 0) ) } // True while a BINDING is writing state into the DOM (bindings.value.toDOM // === setValue). A contract violation on this path must be REPORTED, never // thrown: the throw lands inside the global dispatch loop and strands every // element bound after it — and under the documented bind-before-data // pattern it fires with no user error present at all (setValue writes '' or // undefined while state hasn't arrived). Direct programmatic writes still // throw: that IS the developer's own mistake, caught immediately. let bindingWriteDepth = 0 export const isBindingWrite = (): boolean => bindingWriteDepth > 0 export const setValue = (element: Element, newValue: any): void => { bindingWriteDepth++ try { setValueInner(element, newValue) } finally { bindingWriteDepth-- } } const setValueInner = (element: Element, newValue: any): void => { const type = valueType(element) switch (type) { case 'radio': // String() the state side: numeric state 5 must check value="5" ;(element as HTMLInputElement).checked = newValue != null && (element as HTMLInputElement).value === String(newValue) break case 'checkbox': ;(element as HTMLInputElement).checked = !!newValue break case 'date': case 'datetime-local': case 'month': case 'week': { const input = element as HTMLInputElement if (newValue == null || newValue === '') { // clear the field — new Date(null) used to render 1970-01-01 input.value = '' } else if (newValue instanceof Date || typeof newValue === 'number') { const d = newValue instanceof Date ? newValue : new Date(newValue) if (type === 'week') { // no portable string serialization for ISO weeks — lean on the // platform, and leave the field alone where it isn't supported try { input.valueAsDate = d } catch (_e) { // ignore: setter unsupported in this environment } } else { input.value = dateToControlString(type, d) } } else { input.value = String(newValue) } break } case 'time': { const input = element as HTMLInputElement if (newValue == null || newValue === '') { input.value = '' } else if (typeof newValue === 'number') { input.value = msToTimeString(newValue) } else if (newValue instanceof Date) { input.value = `${pad(newValue.getHours())}:${pad( newValue.getMinutes() )}:${pad(newValue.getSeconds())}` } else { input.value = String(newValue) } break } case 'multi-select': for (const option of Array.from( (element as HTMLSelectElement).querySelectorAll('option') ) as HTMLOptionElement[]) { // accept the natural array-of-selected-values shape as well as the // PickMap; undefined (bound before data exists) selects nothing // instead of throwing inside the observer flush option.selected = Array.isArray(newValue) ? newValue.map(String).includes(option.value) : newValue != null ? !!newValue[option.value] : false } break default: // binding before the data exists must render an empty control, not // the literal string "undefined" ;(element as HTMLInputElement).value = newValue == null ? '' : newValue } } interface PickMap { [key: string]: boolean } export const getValue = (element: ValueElement): any => { const type = valueType(element) switch (type) { case 'radio': { const radio = element.parentElement?.querySelector( `[name="${element.name}"]:checked` ) as HTMLInputElement return radio != null ? radio.value : null } case 'checkbox': return (element as HTMLInputElement).checked case 'number': case 'range': { const input = element as HTMLInputElement const n = typeof input.valueAsNumber === 'number' ? input.valueAsNumber : Number(input.value) return Number.isNaN(n) ? input.value : n } case 'date': case 'datetime-local': case 'month': case 'week': { const input = element as HTMLInputElement const d = input.valueAsDate ?? (input.value !== '' ? new Date(input.value) : null) return d != null && !Number.isNaN(d.getTime()) ? d : input.value } case 'time': { const input = element as HTMLInputElement let n = typeof input.valueAsNumber === 'number' ? input.valueAsNumber : NaN if (Number.isNaN(n)) n = timeStringToMs(input.value) return Number.isNaN(n) ? input.value : n } case 'multi-select': return Array.from(element.querySelectorAll('option')).reduce( (map: PickMap, option: HTMLOptionElement): PickMap => { map[option.value] = option.selected return map }, {} ) default: return element.value } } const { ResizeObserver } = globalThis export const resizeObserver = ResizeObserver != null ? new ResizeObserver((entries) => { for (const entry of entries) { const element = entry.target dispatch(element, 'resize') } }) : { observe() {}, unobserve() {}, } /** * Append ALREADY-CLASSIFIED content. Both callers run their items through * `applyPositional` first, so by here everything is a Node or a string — * which is why the parameter is not `ContentType`. It used to be, and that * was only harmless while `ContentType` happened to be that narrow: widening * `ContentPart` to match what `content` arrays really accept (values, boxed * proxies, null) immediately made this a type error, correctly, because this * function cannot handle those and is never given them. */ export type ResolvedContent = Node | string | Array export const appendContentToElement = ( elt: Element | ShadowRoot | null | undefined, content: ResolvedContent | null | undefined, cloneElements = true ): void => { if (elt != null && content != null) { if (typeof content === 'string') { elt.textContent = content } else if (Array.isArray(content)) { content.forEach((node) => { elt.append( node instanceof Node && cloneElements ? cloneWithBindings(node) : node ) }) } else if (content instanceof Node) { elt.append(cloneElements ? cloneWithBindings(content) : content) } else { throw new Error('expect text content or document node') } } }