diff --git a/src/uix/adom/active-dom.svelte.ts b/src/uix/adom/active-dom.svelte.ts index 203b72d01..704810ebc 100644 --- a/src/uix/adom/active-dom.svelte.ts +++ b/src/uix/adom/active-dom.svelte.ts @@ -7,6 +7,7 @@ import { type Breakpoints, type ResponsiveProp } from '$uix/lib/dom' +import { applyChange, removeAttrs, type StructuralChange } from './apply.js' import { initViewportTracking, viewport } from './viewport.svelte.js' export type ActiveDomProps = { @@ -20,6 +21,8 @@ export type ActiveDom = { resolve(value: ResponsiveProp | undefined): T | undefined isAtLeast(breakpoint: Breakpoint): boolean matches(breakpoint: Breakpoint): boolean + apply(change: StructuralChange): void + remove(target: HTMLElement, names: readonly string[]): void } const viewportReadonly: { readonly width: number } = { @@ -52,6 +55,12 @@ export function createActiveDom(props: ActiveDomProps = {}): ActiveDom { }, matches(breakpoint: Breakpoint): boolean { return currentBreakpoint.current === breakpoint + }, + apply(change: StructuralChange): void { + applyChange(change) + }, + remove(target: HTMLElement, names: readonly string[]): void { + removeAttrs(target, names) } } } diff --git a/src/uix/adom/apply.test.ts b/src/uix/adom/apply.test.ts new file mode 100644 index 000000000..5142b3e4a --- /dev/null +++ b/src/uix/adom/apply.test.ts @@ -0,0 +1,75 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it } from 'vitest' + +import { applyChange, removeAttrs } from './apply' + +describe('apply', () => { + let target: HTMLElement + + beforeEach(() => { + document.body.innerHTML = '' + target = document.createElement('div') + document.body.appendChild(target) + }) + + it('writes string values via setAttribute', () => { + applyChange({ target, attrs: { 'data-state': 'open', role: 'dialog' } }) + expect(target.getAttribute('data-state')).toBe('open') + expect(target.getAttribute('role')).toBe('dialog') + }) + + it('writes number values stringified', () => { + applyChange({ target, attrs: { 'aria-level': 2, tabindex: -1 } }) + expect(target.getAttribute('aria-level')).toBe('2') + expect(target.getAttribute('tabindex')).toBe('-1') + }) + + it('writes true as empty string (presence flag)', () => { + applyChange({ target, attrs: { 'data-disabled': true } }) + expect(target.hasAttribute('data-disabled')).toBe(true) + expect(target.getAttribute('data-disabled')).toBe('') + }) + + it('removes attribute on false / null / undefined', () => { + target.setAttribute('data-disabled', '') + target.setAttribute('aria-hidden', 'true') + target.setAttribute('data-loading', 'true') + + applyChange({ + target, + attrs: { 'data-disabled': false, 'aria-hidden': null, 'data-loading': undefined } + }) + + expect(target.hasAttribute('data-disabled')).toBe(false) + expect(target.hasAttribute('aria-hidden')).toBe(false) + expect(target.hasAttribute('data-loading')).toBe(false) + }) + + it('removes attrs by name', () => { + target.setAttribute('data-event', 'announce') + target.setAttribute('data-event-phase', 'active') + target.setAttribute('data-state', 'open') + + removeAttrs(target, ['data-event', 'data-event-phase']) + + expect(target.hasAttribute('data-event')).toBe(false) + expect(target.hasAttribute('data-event-phase')).toBe(false) + expect(target.getAttribute('data-state')).toBe('open') + }) + + it('mixes write and remove in a single change', () => { + target.setAttribute('data-loading', '') + + applyChange({ + target, + attrs: { + 'data-state': 'open', + 'data-loading': false + } + }) + + expect(target.getAttribute('data-state')).toBe('open') + expect(target.hasAttribute('data-loading')).toBe(false) + }) +}) diff --git a/src/uix/adom/apply.ts b/src/uix/adom/apply.ts new file mode 100644 index 000000000..6298e5c08 --- /dev/null +++ b/src/uix/adom/apply.ts @@ -0,0 +1,48 @@ +/** + * `apply` / `remove` — the only mutation surface of `ActiveDom`. + * + * `ActiveDom` doesn't know Morfo, Sema, Soma, or Eidos. It receives instructions + * already resolved by upper layers and applies them to the DOM. Nothing else. + * + * Contract: + * - `string | number` → `setAttribute(name, String(value))` + * - `true` → `setAttribute(name, '')` (presence flag, e.g. `data-disabled`) + * - `false | null | undefined` → `removeAttribute(name)` + * + * No event dispatch, no batching, no observation. Pure mutations. + */ + +import { isBrowser } from '$uix/lib/dom' + +export type DomAttrValue = string | number | boolean | null | undefined + +export type StructuralChange = { + target: HTMLElement + attrs: Record +} + +function writeAttr(target: HTMLElement, name: string, value: DomAttrValue): void { + if (value === false || value === null || value === undefined) { + target.removeAttribute(name) + return + } + if (value === true) { + target.setAttribute(name, '') + return + } + target.setAttribute(name, String(value)) +} + +export function applyChange(change: StructuralChange): void { + if (!isBrowser) return + for (const name in change.attrs) { + writeAttr(change.target, name, change.attrs[name]) + } +} + +export function removeAttrs(target: HTMLElement, names: readonly string[]): void { + if (!isBrowser) return + for (const name of names) { + target.removeAttribute(name) + } +} diff --git a/src/uix/adom/index.ts b/src/uix/adom/index.ts index 58881bc83..9f496cd2c 100644 --- a/src/uix/adom/index.ts +++ b/src/uix/adom/index.ts @@ -1,4 +1,5 @@ export * from './active-dom.svelte.js' +export * from './apply.js' export * from './body-scroll-lock.svelte.js' export * from './dom-context.svelte.js' export * from './roving-focus-group.svelte.js' diff --git a/src/uix/sema/emit.test.ts b/src/uix/sema/emit.test.ts new file mode 100644 index 000000000..416068545 --- /dev/null +++ b/src/uix/sema/emit.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { createActiveDom } from '$uix/adom' +import { SemanticEngine } from './engine' + +type Frame = () => void + +function setupRafQueue() { + const queue: Frame[] = [] + const raf = vi.fn((cb: Frame) => { + queue.push(cb) + return queue.length + }) as unknown as typeof window.requestAnimationFrame + + const flush = () => { + const pending = queue.splice(0, queue.length) + for (const cb of pending) cb() + } + + return { queue, raf, flush } +} + +describe('SemanticEngine.emit', () => { + let target: HTMLElement + let originalRaf: typeof window.requestAnimationFrame + let rafQueue: ReturnType + + beforeEach(() => { + document.body.innerHTML = '' + target = document.createElement('div') + document.body.appendChild(target) + + originalRaf = window.requestAnimationFrame + rafQueue = setupRafQueue() + window.requestAnimationFrame = rafQueue.raf + ;(globalThis as { requestAnimationFrame?: typeof window.requestAnimationFrame }).requestAnimationFrame = + rafQueue.raf + }) + + afterEach(() => { + window.requestAnimationFrame = originalRaf + ;(globalThis as { requestAnimationFrame?: typeof window.requestAnimationFrame }).requestAnimationFrame = + originalRaf + }) + + it('throws when constructed without a dom', async () => { + const engine = new SemanticEngine() + await expect( + engine.emit({ target, name: 'announce', family: 'alert', intent: 'risk' }) + ).rejects.toThrow(/requires `dom`/) + }) + + it('writes signal attrs synchronously, before the frame elapses', () => { + const dom = createActiveDom() + const engine = new SemanticEngine({ dom }) + + void engine.emit({ target, name: 'announce', family: 'alert', intent: 'risk' }) + + expect(target.getAttribute('data-event')).toBe('announce') + expect(target.getAttribute('data-event-phase')).toBe('active') + expect(target.getAttribute('data-event-family')).toBe('alert') + expect(target.getAttribute('data-intent')).toBe('risk') + expect(target.getAttribute('data-event-id')).toMatch(/^sig-\d+$/) + }) + + it('resolves only after one rAF', async () => { + const dom = createActiveDom() + const engine = new SemanticEngine({ dom }) + + let resolved = false + const promise = engine.emit({ target, name: 'announce' }).then(() => { + resolved = true + }) + + await Promise.resolve() + expect(resolved).toBe(false) + + rafQueue.flush() + await promise + expect(resolved).toBe(true) + }) + + it('keeps signal in DOM during the hold window after resolving', async () => { + const dom = createActiveDom() + const engine = new SemanticEngine({ dom }) + + await Promise.all([engine.emit({ target, name: 'announce', hold: 2 }), rafQueue.flush()]) + + expect(target.getAttribute('data-event')).toBe('announce') + // hold=2: cleanup scheduled with 2 frames; first tick decrements to 1 + rafQueue.flush() + expect(target.getAttribute('data-event')).toBe('announce') + // second tick decrements to 0 and cleans up + rafQueue.flush() + expect(target.hasAttribute('data-event')).toBe(false) + expect(target.hasAttribute('data-event-phase')).toBe(false) + expect(target.hasAttribute('data-event-id')).toBe(false) + }) + + it('default hold is 1 frame', async () => { + const dom = createActiveDom() + const engine = new SemanticEngine({ dom }) + + const promise = engine.emit({ target, name: 'announce' }) + rafQueue.flush() + await promise + + expect(target.getAttribute('data-event')).toBe('announce') + rafQueue.flush() + expect(target.hasAttribute('data-event')).toBe(false) + }) + + it('omits family/intent attrs when not provided', async () => { + const dom = createActiveDom() + const engine = new SemanticEngine({ dom }) + + const promise = engine.emit({ target, name: 'open' }) + expect(target.hasAttribute('data-event-family')).toBe(false) + expect(target.hasAttribute('data-intent')).toBe(false) + rafQueue.flush() + await promise + }) +}) diff --git a/src/uix/sema/engine.ts b/src/uix/sema/engine.ts index d5c7aa966..d53a6997e 100644 --- a/src/uix/sema/engine.ts +++ b/src/uix/sema/engine.ts @@ -1,6 +1,7 @@ import { DEV } from 'esm-env' import { normalizeSemaEvent } from './event' +import type { SemanticSignal } from './signal' import type { SemaAttrWrite, SemaCause, @@ -14,8 +15,39 @@ import type { SemaScope } from './types' +import type { ActiveDom } from '$uix/adom' import type { PartRef } from '../lib/types' +export interface SemanticEngineOpts { + /** + * DOM service injected by construction. Required for `emit()`. The legacy + * `publish()` path still works without it for backward compatibility while + * MorfoRuntime is being rolled out. + */ + dom?: ActiveDom +} + +function nextFrame(): Promise { + return new Promise((resolve) => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => resolve()) + } else { + setTimeout(resolve, 0) + } + }) +} + +function buildSignalAttrs(signal: SemanticSignal, id: string): Record { + const attrs: Record = { + 'data-event': signal.name, + 'data-event-id': id, + 'data-event-phase': 'active' + } + if (signal.family) attrs['data-event-family'] = signal.family + if (signal.intent) attrs['data-intent'] = signal.intent + return attrs +} + export interface SemanticEventDecl { name: string target: PartRef @@ -98,7 +130,73 @@ function resolvePartElement( export class SemanticEngine { private readonly subscribers = new Set() + private readonly dom: ActiveDom | undefined private nextId = 0 + private nextSignalId = 0 + + constructor(opts: SemanticEngineOpts = {}) { + this.dom = opts.dom + } + + /** + * Emit a perceptual signal. Resolves after the signal is written to the DOM + * **and** has had one animation frame to be observed by Eidos / CSS. The + * signal is then held for `signal.hold` extra frames (default 1) before + * being cleared. + * + * Sequence: + * 1. write `data-event*` via `dom.apply` + * 2. await 1 rAF + * 3. resolve the Promise (caller can now `dom.apply(structuralChange)`) + * 4. hold N frames + * 5. clear `data-event*` + * + * The cleanup is fire-and-forget: it doesn't block the resolution, and an + * error in the caller's structural change after `await` does not affect it. + */ + async emit(signal: SemanticSignal): Promise { + if (!this.dom) { + throw new Error( + '[semantic] SemanticEngine.emit requires `dom` to be passed in the constructor.' + ) + } + + const id = `sig-${this.nextSignalId++}` + const attrs = buildSignalAttrs(signal, id) + const dom = this.dom + + dom.apply({ target: signal.target, attrs }) + await nextFrame() + + const hold = Math.max(1, signal.hold ?? 1) + const names = Object.keys(attrs) + this.scheduleCleanup(signal.target, names, hold) + } + + private scheduleCleanup(target: HTMLElement, names: readonly string[], holdFrames: number): void { + const dom = this.dom + if (!dom) return + + let remaining = holdFrames + const tick = () => { + remaining -= 1 + if (remaining <= 0) { + dom.remove(target, names) + return + } + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(tick) + } else { + setTimeout(tick, 16) + } + } + + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(tick) + } else { + setTimeout(tick, 16) + } + } publish( contract: SemanticComponentContract, diff --git a/src/uix/sema/exports.ts b/src/uix/sema/exports.ts index 8b9eb6574..702fb9207 100644 --- a/src/uix/sema/exports.ts +++ b/src/uix/sema/exports.ts @@ -38,9 +38,12 @@ export type { SemanticComponentContract, SemanticPublishContext, PublishedSemanticEvent, - SemanticEventFilter + SemanticEventFilter, + SemanticEngineOpts } from './engine' export { SemanticEngine } from './engine' +export type { SemanticSignal } from './signal' + export { SemaInvariantError, validateSemaEvent, validateSemaIntentBinding } from './validation' diff --git a/src/uix/sema/signal.ts b/src/uix/sema/signal.ts new file mode 100644 index 000000000..16aeb760d --- /dev/null +++ b/src/uix/sema/signal.ts @@ -0,0 +1,31 @@ +/** + * SemanticSignal — runtime payload that the provider (or MorfoRuntime) hands + * to `EngineSemantic.emit(...)`. Distinct from `SemaEvent`, which describes + * the *declaration* of an event in a morfo file (`{ family, intent }`). + * + * `SemanticSignal` describes the actual occurrence: a target, a name, the + * resolved family/intent, and an optional hold duration in animation frames. + */ + +import type { SemaFamily, SemaIntent } from './types' + +export interface SemanticSignal { + /** DOM target where the perceptual signal is reflected. */ + target: HTMLElement + + /** Event name as declared in `morfo.events[].name` (e.g. `'close-cancel'`). */ + name: string + + /** Resolved intent for valenced families. Omit for transitional families. */ + intent?: SemaIntent + + /** Resolved family. When omitted, only `data-event` and `data-event-id` are written. */ + family?: SemaFamily + + /** + * Frames the signal stays in the DOM after the Promise resolves. + * Defaults to 1. Components with longer perceptual windows can extend it + * (e.g. announce events that need to "linger" for AT or Eidos animations). + */ + hold?: number +}