diff --git a/CHANGELOG.md b/CHANGELOG.md index a65777ad..50e0ce14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - **DataBind:** sync late-mounted `immediate` keyed subscribers with the current scoped value ([#626](https://github.com/studiometa/ui/pull/626)) +- **DataBind:** announce the `data-bind:if` DOM change with the bubbling `dom-update` protocol event whose `wrap()` lets a listener or transitioner run it ([#634](https://github.com/studiometa/ui/pull/634)) - **Dialog:** make the `open` and `close` events extendable with `event.detail.waitUntil()` so any component can join the open and close choreography ([#627](https://github.com/studiometa/ui/pull/627)) ## [v1.10.0](https://github.com/studiometa/ui/compare/1.9.0..1.10.0) (2026-08-11) diff --git a/packages/docs/.vitepress/reference/public-contracts.ts b/packages/docs/.vitepress/reference/public-contracts.ts index 7e0bbe35..92317694 100644 --- a/packages/docs/.vitepress/reference/public-contracts.ts +++ b/packages/docs/.vitepress/reference/public-contracts.ts @@ -270,6 +270,22 @@ export const publicContractSymbols = [ href: '/reference/items/Disclosure/js-api', status: 'stable', }, + { + name: 'DomUpdateRunner', + kind: 'type', + package: 'npm:@studiometa/ui', + importPath: '@studiometa/ui', + href: '/reference/items/Fetch/js-api', + status: 'stable', + }, + { + name: 'DomUpdateTransitioner', + kind: 'type', + package: 'npm:@studiometa/ui', + importPath: '@studiometa/ui', + href: '/reference/items/Fetch/js-api', + status: 'stable', + }, { name: 'DraggableProps', kind: 'type', diff --git a/packages/docs/reference/items/DataBind/js-api.md b/packages/docs/reference/items/DataBind/js-api.md index 21be309a..5c0aaf58 100644 --- a/packages/docs/reference/items/DataBind/js-api.md +++ b/packages/docs/reference/items/DataBind/js-api.md @@ -93,6 +93,46 @@ Each insertion is a fresh clone of the template content, so components inside ar Use `data-bind:if` when the element must not exist in the DOM — a form control that must not submit, an expensive subtree, or content that must be absent from the accessibility tree. To show or hide an element in place, prefer the cheaper `data-bind:attr.hidden`, `data-bind:class.` or `data-bind:style.display` bindings, which keep the element and its state. +### Wrapping the DOM change with the `dom-update` event + +Before `data-bind:if` inserts or removes the template content, the component emits the bubbling `dom-update` protocol event — the shared announcement components use before an imminent DOM change. Its `detail` carries the new logical state as `isPresent` and a `wrap(runner)` function: a listener can call `wrap()` to substitute what runs the DOM change. The runner is either a function receiving an `apply()` callback that performs the actual insertion or removal, or a duck-typed transitioner exposing an `update(mutate)` method — like `MotionView` from `@studiometa/ui-motion` — whose `update()` receives the callback. + +```ts +interface DomUpdateTransitioner { + update(mutate: () => void | Promise): void | Promise; +} + +type DomUpdateRunner = ((apply: () => void) => void | Promise) | DomUpdateTransitioner; +``` + +- `wrap()` is only valid synchronously, while the event dispatches — later calls warn and are ignored. +- A single runner runs the change: the last `wrap()` call wins. +- The DOM change is never lost: without a runner it runs synchronously as before, and a rejected runner is reported with a warning before the change is applied anyway if the runner did not call `apply()`. + +Because the removal also goes through the runner, the removed nodes stay in the DOM until the runner calls `apply()` — this is what enables exit animations for removed template content. And because the event bubbles, an enclosing `MotionView` wraps any `dom-update` announced in its subtree with no wiring at all: + +```html +
+ +
+``` + +For cross-subtree topologies — when the transitioner does not enclose the template — an ancestor [`Action`](../Action/index.md) can catch the event and route it across the page, the same pattern as the [`Timer`](../Timer/index.md) events: + + +```html {4} + +``` + + ## Properties ### `value` diff --git a/packages/tests/Data/DataBind.spec.ts b/packages/tests/Data/DataBind.spec.ts index ef2142f8..f76d3752 100644 --- a/packages/tests/Data/DataBind.spec.ts +++ b/packages/tests/Data/DataBind.spec.ts @@ -284,9 +284,7 @@ describe('The DataBind component', () => { }); it('should reject mutation helpers on computed values and effects', () => { - const computed = new DataComputed( - h('div', { dataOptionCompute: 'value' }, ['current']), - ); + const computed = new DataComputed(h('div', { dataOptionCompute: 'value' }, ['current'])); computed.toggle('next', 'current'); expect(computed.value).toBe('current'); @@ -381,8 +379,7 @@ describe('The DataBind component', () => { }); const immediateElement = h('div', { dataOptionGroup: 'immediate-effect', - dataOptionEffect: - 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)', + dataOptionEffect: 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)', dataOptionImmediate: true, }); const passive = new DataEffect(passiveElement); @@ -403,8 +400,7 @@ describe('The DataBind component', () => { const source = new DataBind(h('div', { dataOptionGroup: 'lifecycle' })); const effectElement = h('div', { dataOptionGroup: 'lifecycle', - dataOptionEffect: - 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)', + dataOptionEffect: 'target.dataset.calls = String(Number(target.dataset.calls || 0) + 1)', }); const effect = new DataEffect(effectElement); await mount(source, effect); @@ -783,4 +779,170 @@ describe('The DataBind component', () => { expect(warnSpy).toHaveBeenCalledTimes(1); warnSpy.mockRestore(); }); + + it('should emit a bubbling dom-update event whose wrap runner defers the insertion', () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Hello

'; + const root = h('div', [template]); + const instance = new DataBind(template); + + let detail: DomUpdateDetail; + let apply: () => void; + root.addEventListener('dom-update', (event) => { + detail = (event as CustomEvent).detail; + detail.wrap((run) => { + apply = run; + }); + }); + + instance.set(true); + + expect(detail.isPresent).toBe(true); + expect(typeof detail.wrap).toBe('function'); + expect(root.querySelector('p')).toBeNull(); + + apply(); + expect(root.querySelector('p')).not.toBeNull(); + expect(template.nextElementSibling).toBe(root.querySelector('p')); + }); + + it('should keep removed template content in the DOM until the wrap runner applies', () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Bye

'; + const root = h('div', [template]); + const instance = new DataBind(template); + + instance.set(true); + expect(root.querySelector('p')).not.toBeNull(); + + let detail: DomUpdateDetail; + let apply: () => void; + root.addEventListener('dom-update', (event) => { + detail = (event as CustomEvent).detail; + detail.wrap((run) => { + apply = run; + }); + }); + + instance.set(false); + + // The exit-animation enabler: the content stays until the runner applies. + expect(detail.isPresent).toBe(false); + expect(root.querySelector('p')).not.toBeNull(); + + apply(); + expect(root.querySelector('p')).toBeNull(); + }); + + it('should defer the DOM change to a duck-typed transitioner registered through wrap', () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Hello

'; + const root = h('div', [template]); + const instance = new DataBind(template); + + let mutate: () => void; + const update = vi.fn((fn: () => void) => { + mutate = fn; + }); + root.addEventListener('dom-update', (event) => { + (event as CustomEvent).detail.wrap({ update }); + }); + + instance.set(true); + + expect(update).toHaveBeenCalledTimes(1); + expect(root.querySelector('p')).toBeNull(); + + mutate(); + expect(root.querySelector('p')).not.toBeNull(); + }); + + it('should ignore and warn on wrap calls after the dom-update event dispatched', () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Hello

'; + const root = h('div', [template]); + const instance = new DataBind(template); + // `$warn` is a prototype getter: shadow it on the instance to observe calls. + const warn = vi.fn(); + Object.defineProperty(instance, '$warn', { configurable: true, get: () => warn }); + + let wrap: DomUpdateDetail['wrap']; + root.addEventListener('dom-update', (event) => { + ({ wrap } = (event as CustomEvent).detail); + }); + + instance.set(true); + expect(root.querySelector('p')).not.toBeNull(); + + wrap(() => {}); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '`wrap` must be called synchronously while the `dom-update` event dispatches.', + ); + expect(root.querySelectorAll('p')).toHaveLength(1); + }); + + it('should warn and still apply the DOM change when the wrap runner rejects', async () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Hello

'; + const root = h('div', [template]); + const instance = new DataBind(template); + const warn = vi.fn(); + Object.defineProperty(instance, '$warn', { configurable: true, get: () => warn }); + + root.addEventListener('dom-update', (event) => { + (event as CustomEvent).detail.wrap(() => + Promise.reject(new Error('runner failed')), + ); + }); + + instance.set(true); + expect(root.querySelector('p')).toBeNull(); + + await nextTick(); + + expect(warn).toHaveBeenCalledTimes(1); + expect(root.querySelectorAll('p')).toHaveLength(1); + }); + + it('should keep the if bookkeeping consistent on rapid toggles with a deferring runner', () => { + const template = h('template', { 'data-bind:if': '' }); + template.innerHTML = '

Flash

'; + const root = h('div', [template]); + const instance = new DataBind(template); + + const applies: Array<() => void> = []; + const states: boolean[] = []; + root.addEventListener('dom-update', (event) => { + const { detail } = event as CustomEvent; + states.push(detail.isPresent); + detail.wrap((run) => { + applies.push(run); + }); + }); + + instance.set(true); + instance.set(false); + expect(states).toEqual([true, false]); + + // Toggling to the same logical state emits nothing. + instance.set(false); + expect(states).toEqual([true, false]); + + for (const apply of applies) { + apply(); + } + + expect(root.querySelectorAll('p')).toHaveLength(0); + expect(instance.__ifNodes).toBeUndefined(); + }); }); + +type DomUpdateDetail = { + isPresent: boolean; + wrap( + runner: + | ((apply: () => void) => void | Promise) + | { update(mutate: () => void | Promise): void | Promise }, + ): void; +}; diff --git a/packages/tests/barrel-exports/barrel-exports.spec.ts b/packages/tests/barrel-exports/barrel-exports.spec.ts index 599100dc..4e48f8ba 100644 --- a/packages/tests/barrel-exports/barrel-exports.spec.ts +++ b/packages/tests/barrel-exports/barrel-exports.spec.ts @@ -116,6 +116,8 @@ test('@studiometa/ui barrel export surface', () => { "DisclosureGroup [value]", "DisclosureGroupProps [type]", "DisclosureProps [type]", + "DomUpdateRunner [type]", + "DomUpdateTransitioner [type]", "Draggable [value]", "DraggableProps [type]", "Fetch [value]", diff --git a/packages/ui/src/Data/DataBind.ts b/packages/ui/src/Data/DataBind.ts index 985ac0c6..f2565091 100644 --- a/packages/ui/src/Data/DataBind.ts +++ b/packages/ui/src/Data/DataBind.ts @@ -16,6 +16,7 @@ import { writeControlValue, } from './formControl.js'; import { getCallback } from './utils.js'; +import { emitDomUpdate, runWrapped } from '../utils/dom-update.js'; export interface DataBindProps extends BaseProps { $options: { @@ -58,6 +59,7 @@ export class DataBind extends withGroup { static config: BaseConfig = { name: 'DataBind', + emits: ['dom-update'], options: { prop: String, immediate: Boolean, @@ -72,6 +74,7 @@ export class DataBind extends withGroup extends withGroup) + this.dataScope?.getChannel(this.group) ?? getDataChannel(this.$group as Set) ); } @@ -341,7 +343,15 @@ export class DataBind extends withGroup` content in the DOM. The * content is cloned and inserted after the template element when the value * is truthy, and removed when the value is falsy. Each insertion is a fresh - * clone, so any state held by the content is reset on every toggle. + * clone, so any state held by the content is reset on every toggle. Before + * the change runs, the bubbling `dom-update` protocol event exposes + * `event.detail.wrap(runner)` so any listener can substitute the function or + * transitioner that runs the DOM change — to wrap it in a view transition, + * for example, and give removed content an exit animation. Registration is + * only valid while the event dispatches — later calls warn and are ignored — + * and the last registered runner wins. A rejected runner is reported with a + * warning and never loses the change: the insert or removal runs anyway if + * the runner did not call `apply()`. * @private */ __applyIfBinding(isPresent: boolean) { @@ -354,15 +364,42 @@ export class DataBind extends withGroup { + if (this.__ifNodes) { + return; + } + const fragment = target.content.cloneNode(true) as DocumentFragment; + this.__ifNodes = [...fragment.childNodes]; + target.after(fragment); + } + : () => { + if (!this.__ifNodes) { + return; + } + for (const node of this.__ifNodes) { + node.remove(); + } + this.__ifNodes = undefined; + }; + + const runner = emitDomUpdate(this, { isPresent }); + + if (runner) { + // Intentionally not awaited: the reactive pipeline stays synchronous + // while the runner defers the DOM change. + runWrapped(this, runner, apply); + } else { + apply(); } } @@ -397,8 +434,7 @@ export class DataBind extends withGroup void | Promise): void | Promise; +} + +/** + * What `wrap()` accepts: a function receiving the `apply` callback, or a + * transitioner whose `update()` receives it. + */ +export type DomUpdateRunner = + | ((apply: () => void) => void | Promise) + | DomUpdateTransitioner; + +/** + * Emit the bubbling `dom-update` protocol event announcing an imminent DOM + * change, and return the runner registered by a listener through + * `detail.wrap()`, normalized to a function — or `null` when nobody wrapped. + * + * `wrap()` only accepts registrations synchronously while the event + * dispatches — later calls warn and are ignored — and keeps a single runner: + * the last call wins. + * + * The event is dispatched directly on the element instead of `$emit` because + * `Fetch` overrides `$emit` with a string-only signature that would mangle a + * `CustomEvent` instance. + */ +export function emitDomUpdate( + instance: Base, + detail: Record = {}, +): ((apply: () => void) => void | Promise) | null { + let runner: DomUpdateRunner | null = null; + let dispatching = true; + + function wrap(newRunner: DomUpdateRunner) { + if (!dispatching) { + instance.$warn( + '`wrap` must be called synchronously while the `dom-update` event dispatches.', + ); + return; + } + runner = newRunner; + } + + instance.$el.dispatchEvent( + new CustomEvent('dom-update', { detail: { ...detail, wrap }, bubbles: true }), + ); + dispatching = false; + + if (runner && typeof (runner as DomUpdateTransitioner).update === 'function') { + const transitioner = runner as DomUpdateTransitioner; + return (apply) => transitioner.update(apply); + } + + return runner as ((apply: () => void) => void | Promise) | null; +} + +/** + * Run a DOM change through a registered runner without ever losing it: when + * the runner throws or rejects, the change is applied directly and the error + * is reported through `$warn`. + */ +export async function runWrapped( + instance: Base, + runner: (apply: () => void) => void | Promise, + applyChange: () => void, +): Promise { + let applied = false; + function apply() { + applied = true; + applyChange(); + } + try { + await runner(apply); + } catch (error) { + instance.$warn('The `dom-update` runner rejected.', error); + if (!applied) { + apply(); + } + } +}