From 357a7f9e32bf5e229b3bff1e57b352f81b0dc376 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 15 Jul 2026 16:38:58 +0200 Subject: [PATCH 01/20] Add scoped data groups --- packages/docs/components/Action/js-api.md | 2 + packages/docs/components/DataBind/js-api.md | 17 +- .../docs/components/DataComputed/js-api.md | 2 +- packages/docs/components/DataEffect/js-api.md | 2 +- packages/docs/components/DataScope/index.md | 30 +++ packages/eslint-plugin-ui/src/utils/ast.ts | 1 + packages/tests/Action/Action.spec.ts | 45 +++- packages/tests/Data/DataScope.spec.ts | 194 ++++++++++++++++++ packages/tests/index.spec.ts | 1 + packages/ui/Action/ActionEvent.ts | 6 + packages/ui/Data/DataBind.ts | 110 +++++++++- packages/ui/Data/DataComputed.ts | 5 +- packages/ui/Data/DataEffect.ts | 5 +- packages/ui/Data/DataModel.ts | 7 +- packages/ui/Data/DataScope.ts | 135 ++++++++++++ packages/ui/Data/index.ts | 2 + packages/ui/Data/utils.ts | 2 +- 17 files changed, 544 insertions(+), 22 deletions(-) create mode 100644 packages/docs/components/DataScope/index.md create mode 100644 packages/tests/Data/DataScope.spec.ts create mode 100644 packages/ui/Data/DataScope.ts diff --git a/packages/docs/components/Action/js-api.md b/packages/docs/components/Action/js-api.md index fda961cd..e2331f4b 100644 --- a/packages/docs/components/Action/js-api.md +++ b/packages/docs/components/Action/js-api.md @@ -45,6 +45,8 @@ Modifiers can be chained with a `.` as separator: Use this option to define the components that should be used as targets to the [effect callback](#effect). Multiple components can be defined by using a single space as delimiter. +When an Action is inside a [`DataScope`](../DataScope/index.md), targets are limited to components in that same nearest scope. Actions outside a `DataScope` keep resolving targets globally. + ::: info Name definition The `Action` component will use the `name` property defined in the static `config` object of each class to resolve components on the page. diff --git a/packages/docs/components/DataBind/js-api.md b/packages/docs/components/DataBind/js-api.md index 0e802160..1d479146 100644 --- a/packages/docs/components/DataBind/js-api.md +++ b/packages/docs/components/DataBind/js-api.md @@ -28,14 +28,21 @@ If the option is explicitly set with the `data-option-prop` attribute, it will o - Type: `boolean` - Default: `false` -Use the `data-option-immediate` attribute on a `DataBind` component to propage its value on mount to other components in the same group. +Use the `data-option-immediate` attribute on a `DataBind` component to propagate its value on mount to other components in the same group. Immediate keyed values inside a [`DataScope`](../DataScope/index.md) are collected before subscribers are notified. ### `group` - Type: `string` - Default: `''` -The `group` option is used to group instances together. All related instances will be updated when the value changes. +The `group` option is used to group instances together. All related instances will be updated when the value changes. Inside a [`DataScope`](../DataScope/index.md), an omitted group inherits the scope's group and remains isolated from other scopes. + +### `key` + +- Type: `string` +- Default: the native form control `name`, when scoped + +A keyed value updates only bindings with the same key while notifying unkeyed subscribers. Keys are local to a `DataScope`; unscoped bindings preserve scalar group behavior. When using it with multiple checkboxes or select multiple, use the `[]` suffix to push each selected value in an array. See the [checkboxes example](/components/DataBind/examples.md#checkboxes) for more details on how this works. @@ -61,13 +68,15 @@ Wether new values should be pushed to an array instead of a single value. This i ## Methods -### `set(value: string | boolean | string[], dispatch = true)` +### `set(value: DataValue, dispatch = true)` Set the value for the current instance and dispatch it to others if the second parameter `dispatch` is set to `true` (default). +`DataValue` accepts `boolean`, `string`, `string[]`, `number`, `Date`, `null`, or `undefined`. + **Params** -- `value` (`string | boolean | string[]`): the value to set +- `value` (`DataValue`): the value to set - `dispatch` (`boolean`, default to `true`): wether to dispatch the value to other related instances or not ### `get()` diff --git a/packages/docs/components/DataComputed/js-api.md b/packages/docs/components/DataComputed/js-api.md index 00630a98..c0e93e7d 100644 --- a/packages/docs/components/DataComputed/js-api.md +++ b/packages/docs/components/DataComputed/js-api.md @@ -14,7 +14,7 @@ The `DataComputed` component extends the [`DataBind` component](../DataBind/js-a - Type: `string` - Default: `''` -Use this option to define a piece of JavaScript code to transform the value before it is updated on the target. The `value` and `target` variables can be used to access both the current value of the binding and the DOM element targeted by the changes. +Use this option to define a piece of JavaScript code to transform the value before it is updated on the target. The `value` and `target` variables can be used to access both the current value of the binding and the DOM element targeted by the changes. Inside a `DataScope`, the third `$data` argument is a frozen snapshot of all keyed values in the resolved group. **Example** diff --git a/packages/docs/components/DataEffect/js-api.md b/packages/docs/components/DataEffect/js-api.md index 36765726..2d911447 100644 --- a/packages/docs/components/DataEffect/js-api.md +++ b/packages/docs/components/DataEffect/js-api.md @@ -14,7 +14,7 @@ The `DataEffect` component extends the [`DataBind` component](../DataBind/js-api - Type: `string` - Default: `''` -Use this option to define a piece of JavaScript code to be executed when the value changes. The `value` and `target` variables can be used to access both the current value of the binding and the DOM element targeted by the changes. +Use this option to define a piece of JavaScript code to be executed when the value changes. The `value` and `target` variables can be used to access both the current value of the binding and the DOM element targeted by the changes. Inside a `DataScope`, the third `$data` argument is a frozen snapshot of all keyed values in the resolved group. **Example** diff --git a/packages/docs/components/DataScope/index.md b/packages/docs/components/DataScope/index.md new file mode 100644 index 00000000..e7c2dc14 --- /dev/null +++ b/packages/docs/components/DataScope/index.md @@ -0,0 +1,30 @@ +--- +badges: [JS] +--- + +# DataScope + +Use `DataScope` to isolate Data groups and Action targets inside a reusable widget. Descendant Data components inherit the scope's group unless they define their own `data-option-group`; nested scopes use the nearest boundary. + +```js [app.js] +import { registerComponent } from '@studiometa/js-toolkit'; +import { DataComputed, DataModel, DataScope } from '@studiometa/ui'; + +registerComponent(DataScope); +registerComponent(DataModel); +registerComponent(DataComputed); +``` + +```html [index.html] +
+ + + + Ada Lovelace + +
+``` + +The `group` option defaults to `default`. Keys resolve from `data-option-key`, then from the native form control `name`. Computed and effect callbacks receive the group's frozen `$data` snapshot as their third argument. + +Scope membership is resolved when a Data component is initialized. Moving a mounted component between scopes, or changing its group or key dynamically, is not supported. diff --git a/packages/eslint-plugin-ui/src/utils/ast.ts b/packages/eslint-plugin-ui/src/utils/ast.ts index dcf83c1d..e4d23064 100644 --- a/packages/eslint-plugin-ui/src/utils/ast.ts +++ b/packages/eslint-plugin-ui/src/utils/ast.ts @@ -36,6 +36,7 @@ export const UI_COMPONENT_NAMES = new Set([ 'DataComputed', 'DataEffect', 'DataModel', + 'DataScope', 'Draggable', 'Figure', 'FigureVideo', diff --git a/packages/tests/Action/Action.spec.ts b/packages/tests/Action/Action.spec.ts index 8e73ba2c..6bc6fbc1 100644 --- a/packages/tests/Action/Action.spec.ts +++ b/packages/tests/Action/Action.spec.ts @@ -1,6 +1,6 @@ import { describe, it, vi, expect } from 'vitest'; import { Base } from '@studiometa/js-toolkit'; -import { Action } from '@studiometa/ui'; +import { Action, DataScope } from '@studiometa/ui'; import { h, mount, destroy } from '#test-utils'; async function getContext({ @@ -147,4 +147,47 @@ describe('The Action component', () => { action.$el.dispatchEvent(new Event('click')); expect(action.$el.id).toBe('foo'); }); + + it('should only target instances in the nearest DataScope', async () => { + const fn = vi.fn(); + class Foo extends Base { + static config = { name: 'Foo' }; + + run() { + fn(this); + } + } + + const scopeRoot = h('div'); + const actionRoot = h('button', { + dataOptionTarget: 'Foo', + dataOptionEffect: 'target.run()', + }); + const localRoot = h('div'); + const nestedScopeRoot = h('div'); + const nestedRoot = h('div'); + nestedScopeRoot.append(nestedRoot); + scopeRoot.append(actionRoot, localRoot, nestedScopeRoot); + + const siblingScopeRoot = h('div'); + const siblingRoot = h('div'); + siblingScopeRoot.append(siblingRoot); + const globalRoot = h('div'); + + const scope = new DataScope(scopeRoot); + const nestedScope = new DataScope(nestedScopeRoot); + const siblingScope = new DataScope(siblingScopeRoot); + const action = new Action(actionRoot); + const local = new Foo(localRoot); + const nested = new Foo(nestedRoot); + const sibling = new Foo(siblingRoot); + const global = new Foo(globalRoot); + await mount(scope, nestedScope, siblingScope, action, local, nested, sibling, global); + + actionRoot.dispatchEvent(new Event('click')); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith(local); + + await destroy(scope, nestedScope, siblingScope, action, local, nested, sibling, global); + }); }); diff --git a/packages/tests/Data/DataScope.spec.ts b/packages/tests/Data/DataScope.spec.ts new file mode 100644 index 00000000..452734e7 --- /dev/null +++ b/packages/tests/Data/DataScope.spec.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; +import { DataBind, DataComputed, DataEffect, DataModel, DataScope } from '@studiometa/ui'; +import { nextTick } from '@studiometa/js-toolkit/utils'; +import { destroy, hConnected as h, mount } from '#test-utils'; + +describe('The DataScope component', () => { + it('should inherit its default group while allowing explicit overrides', async () => { + const root = h('div'); + const inheritedAElement = h('div'); + const inheritedBElement = h('div'); + const overriddenElement = h('div', { dataOptionGroup: 'other' }); + root.append(inheritedAElement, inheritedBElement, overriddenElement); + + const scope = new DataScope(root); + const inheritedA = new DataBind(inheritedAElement); + const inheritedB = new DataBind(inheritedBElement); + const overridden = new DataBind(overriddenElement); + await mount(scope, inheritedA, inheritedB, overridden); + + expect(scope.$options.group).toBe('default'); + expect(inheritedA.group).toBe('default'); + expect(inheritedA.$group).toBe(inheritedB.$group); + expect(overridden.group).toBe('other'); + expect(overridden.$group).not.toBe(inheritedA.$group); + + await destroy(scope, inheritedA, inheritedB, overridden); + }); + + it('should isolate sibling scopes and use the nearest nested scope', async () => { + const outerRoot = h('div', { dataOptionGroup: 'shared' }); + const outerElement = h('div'); + const nestedRoot = h('div', { dataOptionGroup: 'shared' }); + const nestedElement = h('div'); + nestedRoot.append(nestedElement); + outerRoot.append(outerElement, nestedRoot); + + const siblingRoot = h('div', { dataOptionGroup: 'shared' }); + const siblingElement = h('div'); + siblingRoot.append(siblingElement); + + const outerScope = new DataScope(outerRoot); + const nestedScope = new DataScope(nestedRoot); + const siblingScope = new DataScope(siblingRoot); + const outer = new DataBind(outerElement); + const nested = new DataBind(nestedElement); + const sibling = new DataBind(siblingElement); + await mount(outerScope, nestedScope, siblingScope, outer, nested, sibling); + + expect(outer.$group).not.toBe(nested.$group); + expect(outer.$group).not.toBe(sibling.$group); + expect(nested.dataScope).toBe(nestedScope); + + outer.set('outer'); + expect(nested.value).toBe(''); + expect(sibling.value).toBe(''); + + await destroy(outerScope, nestedScope, siblingScope, outer, nested, sibling); + }); + + it('should keep keyed values independent and synchronize equal keys', async () => { + const root = h('div', { dataOptionGroup: 'person' }); + const firstInput = h('input', { name: 'first', value: 'Ada' }); + const lastInput = h('input', { name: 'last', value: 'Lovelace' }); + const firstOutput = h('div', { dataOptionKey: 'first' }); + root.append(firstInput, lastInput, firstOutput); + + const scope = new DataScope(root); + const first = new DataModel(firstInput); + const last = new DataModel(lastInput); + const output = new DataBind(firstOutput); + await mount(scope, first, last, output); + + firstInput.value = 'Grace'; + firstInput.dispatchEvent(new Event('input')); + expect(first.value).toBe('Grace'); + expect(output.value).toBe('Grace'); + expect(last.value).toBe('Lovelace'); + expect(first.$data).toEqual({ first: 'Grace' }); + expect(Object.isFrozen(first.$data)).toBe(true); + + await destroy(scope, first, last, output); + }); + + it('should recompute unkeyed subscribers for every keyed update', async () => { + const root = h('div', { dataOptionGroup: 'values' }); + const firstInput = h('input', { + name: 'first', + value: 'A', + dataOptionImmediate: true, + }); + const lastInput = h('input', { + name: 'last', + value: 'B', + dataOptionImmediate: true, + }); + const computedElement = h('div', { + dataOptionCompute: '$data.first + $data.last', + }); + root.append(firstInput, lastInput, computedElement); + + const scope = new DataScope(root); + const first = new DataModel(firstInput); + const last = new DataModel(lastInput); + const computed = new DataComputed(computedElement); + await mount(scope, first, last, computed); + await nextTick(); + expect(computed.value).toBe('AB'); + + firstInput.value = 'AB'; + firstInput.dispatchEvent(new Event('input')); + expect(computed.value).toBe('ABB'); + + await destroy(scope, first, last, computed); + }); + + it('should hydrate all immediate keyed sources before notifying subscribers', async () => { + const root = h('div', { dataOptionGroup: 'person' }); + const firstInput = h('input', { + name: 'first', + value: 'Ada', + dataOptionImmediate: true, + }); + const lastInput = h('input', { + name: 'last', + value: 'Lovelace', + dataOptionImmediate: true, + }); + const computedElement = h('div', { + dataOptionCompute: '$data.first + " " + $data.last', + }); + const effectElement = h('div', { + dataOptionEffect: + 'target.dataset.values = (target.dataset.values || "") + $data.first + " " + $data.last + "|"; target.dataset.frozen = Object.isFrozen($data)', + }); + root.append(firstInput, lastInput, computedElement, effectElement); + + const scope = new DataScope(root); + const first = new DataModel(firstInput); + const last = new DataModel(lastInput); + const computed = new DataComputed(computedElement); + const effect = new DataEffect(effectElement); + await mount(scope, first, last, computed, effect); + await nextTick(); + + expect(computed.value).toBe('Ada Lovelace'); + expect(effectElement.dataset.values?.split('|').filter(Boolean)).toEqual([ + 'Ada Lovelace', + 'Ada Lovelace', + ]); + expect(effectElement.dataset.frozen).toBe('true'); + + await destroy(scope, first, last, computed, effect); + }); + + it('should ignore immediate sources destroyed before hydration', async () => { + const root = h('div', { dataOptionGroup: 'person' }); + const input = h('input', { + name: 'first', + value: 'Ada', + dataOptionImmediate: true, + }); + const effectElement = h('div', { + dataOptionEffect: 'target.dataset.called = "true"', + }); + root.append(input, effectElement); + + const scope = new DataScope(root); + const source = new DataModel(input); + const effect = new DataEffect(effectElement); + await mount(scope, source, effect); + await destroy(source); + await nextTick(); + + expect(scope.getData('person')).toEqual({}); + expect(effectElement.dataset.called).toBeUndefined(); + + await destroy(scope, effect); + }); + + it('should preserve value and target callback arguments with scoped data', () => { + const root = h('div'); + const computedElement = h('div', { + dataOptionCompute: 'value + target.dataset.suffix + Object.isFrozen($data)', + dataSuffix: '-target-', + }); + root.append(computedElement); + + new DataScope(root); + const computed = new DataComputed(computedElement); + computed.set('value'); + + expect(computed.value).toBe('value-target-true'); + }); +}); diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index 151da8b6..3a47264b 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -21,6 +21,7 @@ test('components exports', () => { "DataComputed", "DataEffect", "DataModel", + "DataScope", "Draggable", "Fetch", "Figure", diff --git a/packages/ui/Action/ActionEvent.ts b/packages/ui/Action/ActionEvent.ts index 156e3df1..d6388ce6 100644 --- a/packages/ui/Action/ActionEvent.ts +++ b/packages/ui/Action/ActionEvent.ts @@ -1,6 +1,7 @@ import { getInstances } from '@studiometa/js-toolkit'; import type { Base } from '@studiometa/js-toolkit'; import { isFunction } from '@studiometa/js-toolkit/utils'; +import { getDataScope } from '../Data/DataScope.js'; /** * Extract component name and an optional additional selector from a string. @@ -129,10 +130,15 @@ export class ActionEvent { }); const targets = [] as Array>; + const actionScope = getDataScope(this.action.$el); for (const instance of getInstances()) { const { name } = instance.__config; + if (actionScope && getDataScope(instance.$el) !== actionScope) { + continue; + } + for (const part of parts) { const shouldPush = part[0] === name && (!part[1] || (part[1] && instance.$el.matches(part[1]))); diff --git a/packages/ui/Data/DataBind.ts b/packages/ui/Data/DataBind.ts index 894e436d..fd47a1d4 100644 --- a/packages/ui/Data/DataBind.ts +++ b/packages/ui/Data/DataBind.ts @@ -1,15 +1,20 @@ import { Base, withGroup } from '@studiometa/js-toolkit'; import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; import { isArray, nextTick } from '@studiometa/js-toolkit/utils'; +import { DataScope, getDataScope } from './DataScope.js'; +import type { DataValue } from './DataScope.js'; import { isInput, isCheckbox, isSelect } from './utils.js'; export interface DataBindProps extends BaseProps { $options: { prop: string; immediate: boolean; + key: string; }; } +const EMPTY_DATA = Object.freeze({}); + /** * DataBind class. * @link https://ui.studiometa.dev/components/DataBind/ @@ -20,9 +25,30 @@ export class DataBind extends withGroup(Base, ' options: { prop: String, immediate: Boolean, + key: String, }, }; + private __dataScopeResolved = false; + private __dataScope?: DataScope; + + get dataScope() { + if (!this.__dataScopeResolved) { + this.__dataScope = getDataScope(this.$el); + this.__dataScopeResolved = true; + } + + return this.__dataScope; + } + + get group() { + return this.$options.group || this.dataScope?.$options.group || ''; + } + + override get $group() { + return this.dataScope?.getGroup(this.group) ?? super.$group; + } + /** * @deprecated Use the `$group` getter instead. */ @@ -30,8 +56,33 @@ export class DataBind extends withGroup(Base, ' return this.$group as Set; } + get dataKey() { + if (!this.dataScope) { + return ''; + } + + if (this.$options.key) { + return this.$options.key; + } + + const { target } = this; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLTextAreaElement + ) { + return target.name; + } + + return ''; + } + + get $data() { + return this.dataScope?.getData(this.group) ?? EMPTY_DATA; + } + get multiple() { - return this.$options.group.endsWith('[]'); + return this.group.endsWith('[]'); } get target() { @@ -66,7 +117,7 @@ export class DataBind extends withGroup(Base, ' this.set(value); } - get() { + get(): DataValue { const { target, multiple } = this; if (isSelect(target)) { @@ -88,9 +139,13 @@ export class DataBind extends withGroup(Base, ' if (isCheckbox(target)) { if (multiple) { - const values = new Set(); + const values = new Set(); for (const instance of this.relatedInstances) { - if (isCheckbox(instance.target) && instance.target.checked) { + if ( + (!this.dataKey || instance.dataKey === this.dataKey) && + isCheckbox(instance.target) && + instance.target.checked + ) { values.add(instance.target.value); } } @@ -103,7 +158,12 @@ export class DataBind extends withGroup(Base, ' return target[this.prop]; } - set(value: boolean | string | string[], dispatch = true) { + set(value: DataValue, dispatch = true) { + if (dispatch && this.dataScope && this.dataKey) { + this.__dispatchScopedValue(value); + return; + } + const { target, multiple, relatedInstances } = this; if (dispatch) { @@ -138,11 +198,43 @@ export class DataBind extends withGroup(Base, ' target[this.prop] = value; } + /** + * Publish a keyed value to the scoped group and synchronize matching subscribers. + * @internal + */ + __dispatchScopedValue(value: DataValue, updateData = true) { + const { dataScope, dataKey, relatedInstances } = this; + + if (!dataScope || !dataKey) { + this.set(value); + return; + } + + if (updateData) { + dataScope.setValue(this.group, dataKey, value); + } + + for (const instance of relatedInstances) { + if (instance !== this && (!instance.dataKey || instance.dataKey === dataKey)) { + instance.set(value, false); + } + } + + this.set(value, false); + } + mounted() { - if (this.$options.immediate) { - nextTick().then(() => { - this.set(this.get()); - }); + if (!this.$options.immediate) { + return; } + + if (this.dataScope && this.dataKey) { + this.dataScope.hydrate(this.group, this); + return; + } + + nextTick().then(() => { + this.set(this.get()); + }); } } diff --git a/packages/ui/Data/DataComputed.ts b/packages/ui/Data/DataComputed.ts index dd3cf926..91451625 100644 --- a/packages/ui/Data/DataComputed.ts +++ b/packages/ui/Data/DataComputed.ts @@ -1,6 +1,7 @@ import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; import { DataBind } from './DataBind.js'; import type { DataBindProps } from './DataBind.js'; +import type { DataValue } from './DataScope.js'; import { getCallback } from './utils.js'; export interface DataComputedProps extends DataBindProps { @@ -22,11 +23,11 @@ export class DataComputed extends DataBind extends DataBind extends DataBind; + values: Map; + data: Readonly>; + hydration: Set; + hydrationPending: boolean; +} + +const EMPTY_DATA = Object.freeze({}); + +/** + * Define a local boundary and a default group for descendant Data components. + * @link https://ui.studiometa.dev/components/DataScope/ + */ +export class DataScope extends Base { + static config: BaseConfig = { + name: 'DataScope', + options: { + group: { + type: String, + default: 'default', + }, + }, + }; + + private groups = new Map(); + + private getRecord(group: string) { + let record = this.groups.get(group); + + if (!record) { + record = { + instances: new Set(), + values: new Map(), + data: EMPTY_DATA, + hydration: new Set(), + hydrationPending: false, + }; + this.groups.set(group, record); + } + + for (const instance of record.instances) { + if (!instance.$el.isConnected) { + record.instances.delete(instance); + } + } + + return record; + } + + getGroup(group: string) { + return this.getRecord(group).instances; + } + + getData(group: string) { + return this.getRecord(group).data; + } + + setValue(group: string, key: string, value: DataValue) { + const record = this.getRecord(group); + record.values.set(key, value); + record.data = Object.freeze(Object.fromEntries(record.values)); + } + + hydrate(group: string, instance: DataScopeMember) { + const record = this.getRecord(group); + record.hydration.add(instance); + + if (record.hydrationPending) { + return; + } + + record.hydrationPending = true; + nextTick().then(() => { + const sources = new Map(); + + if (this.$isMounted) { + for (const source of record.hydration) { + if (source.$isMounted && source.$el.isConnected && source.dataKey) { + sources.set(source.dataKey, source); + record.values.set(source.dataKey, source.get()); + } + } + } + + record.hydration.clear(); + record.hydrationPending = false; + record.data = Object.freeze(Object.fromEntries(record.values)); + + for (const source of sources.values()) { + source.__dispatchScopedValue(source.get(), false); + } + }); + } +} + +type ElementWithInstances = HTMLElement & { + __base__?: Map; +}; + +/** + * Find the nearest DataScope mounted on the given element or one of its ancestors. + * @internal + */ +export function getDataScope(element: HTMLElement): DataScope | undefined { + let current: ElementWithInstances | null = element; + + while (current) { + const scope = current.__base__?.get(DataScope.config.name); + if (scope && scope !== 'terminated') { + return scope as DataScope; + } + current = current.parentElement; + } + + return undefined; +} diff --git a/packages/ui/Data/index.ts b/packages/ui/Data/index.ts index 0455c769..728e2a8b 100644 --- a/packages/ui/Data/index.ts +++ b/packages/ui/Data/index.ts @@ -2,3 +2,5 @@ export * from './DataBind.js'; export * from './DataComputed.js'; export * from './DataEffect.js'; export * from './DataModel.js'; +export { DataScope } from './DataScope.js'; +export type { DataScopeProps, DataValue } from './DataScope.js'; diff --git a/packages/ui/Data/utils.ts b/packages/ui/Data/utils.ts index 25c65aec..2f756300 100644 --- a/packages/ui/Data/utils.ts +++ b/packages/ui/Data/utils.ts @@ -16,7 +16,7 @@ export function getCallback(name: string, code: string): Function { const key = code + name; if (!callbacks.has(key)) { - callbacks.set(key, new Function('value', 'target', code)); + callbacks.set(key, new Function('value', 'target', '$data', code)); } return callbacks.get(key); From 029cb32deab4ecad221dc252dc9119ccc7e652cf Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 15 Jul 2026 16:49:21 +0200 Subject: [PATCH 02/20] Remove destroyed scoped values --- packages/tests/Data/DataScope.spec.ts | 30 +++++++++++++++++++++++++++ packages/ui/Data/DataBind.ts | 6 ++++++ packages/ui/Data/DataScope.ts | 15 ++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/packages/tests/Data/DataScope.spec.ts b/packages/tests/Data/DataScope.spec.ts index 452734e7..20fdc1e0 100644 --- a/packages/tests/Data/DataScope.spec.ts +++ b/packages/tests/Data/DataScope.spec.ts @@ -81,6 +81,36 @@ describe('The DataScope component', () => { await destroy(scope, first, last, output); }); + it('should remove keyed data when its last source is destroyed', async () => { + const root = h('div', { dataOptionGroup: 'person' }); + const primaryInput = h('input', { + name: 'first', + value: 'Ada', + dataOptionImmediate: true, + }); + const secondaryInput = h('input', { + name: 'first', + value: 'Ada', + dataOptionImmediate: true, + }); + root.append(primaryInput, secondaryInput); + + const scope = new DataScope(root); + const primary = new DataModel(primaryInput); + const secondary = new DataModel(secondaryInput); + await mount(scope, primary, secondary); + await nextTick(); + expect(scope.getData('person')).toEqual({ first: 'Ada' }); + + await destroy(primary); + expect(scope.getData('person')).toEqual({ first: 'Ada' }); + + await destroy(secondary); + expect(scope.getData('person')).toEqual({}); + + await destroy(scope); + }); + it('should recompute unkeyed subscribers for every keyed update', async () => { const root = h('div', { dataOptionGroup: 'values' }); const firstInput = h('input', { diff --git a/packages/ui/Data/DataBind.ts b/packages/ui/Data/DataBind.ts index fd47a1d4..2127d34f 100644 --- a/packages/ui/Data/DataBind.ts +++ b/packages/ui/Data/DataBind.ts @@ -237,4 +237,10 @@ export class DataBind extends withGroup(Base, ' this.set(this.get()); }); } + + destroyed() { + if (this.dataScope && this.dataKey) { + this.dataScope.deleteValue(this.group, this.dataKey); + } + } } diff --git a/packages/ui/Data/DataScope.ts b/packages/ui/Data/DataScope.ts index cd7eb93e..bc563bec 100644 --- a/packages/ui/Data/DataScope.ts +++ b/packages/ui/Data/DataScope.ts @@ -80,6 +80,21 @@ export class DataScope extends Base instance.dataKey === key, + ); + + if (remainingSource) { + record.values.set(key, remainingSource.get()); + } else { + record.values.delete(key); + } + + record.data = Object.freeze(Object.fromEntries(record.values)); + } + hydrate(group: string, instance: DataScopeMember) { const record = this.getRecord(group); record.hydration.add(instance); From 7d58c48ec0f42170b22fa0abd6fb775adb328943 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 15 Jul 2026 16:55:40 +0200 Subject: [PATCH 03/20] Protect scoped data snapshots --- packages/tests/Data/DataScope.spec.ts | 29 ++++++++++++++++++++ packages/ui/Data/DataScope.ts | 38 ++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/tests/Data/DataScope.spec.ts b/packages/tests/Data/DataScope.spec.ts index 20fdc1e0..36029fdc 100644 --- a/packages/tests/Data/DataScope.spec.ts +++ b/packages/tests/Data/DataScope.spec.ts @@ -111,6 +111,35 @@ describe('The DataScope component', () => { await destroy(scope); }); + it('should clone and freeze mutable snapshot values', () => { + const scope = new DataScope(h('div')); + const items = ['one']; + const date = new Date('2026-01-01'); + + scope.setValue('values', 'items', items); + scope.setValue('values', 'date', date); + const data = scope.getData('values'); + + expect(data.items).toEqual(items); + expect(data.items).not.toBe(items); + expect(Object.isFrozen(data.items)).toBe(true); + expect(data.date).toEqual(date); + expect(data.date).not.toBe(date); + expect(Object.isFrozen(data.date)).toBe(true); + + items.push('two'); + date.setFullYear(2030); + (data.date as Date).setFullYear(2031); + scope.setValue('values', 'other', 'value'); + + expect(data.items).toEqual(['one']); + expect(scope.getData('values')).toEqual({ + items: ['one'], + date: new Date('2026-01-01'), + other: 'value', + }); + }); + it('should recompute unkeyed subscribers for every keyed update', async () => { const root = h('div', { dataOptionGroup: 'values' }); const firstInput = h('input', { diff --git a/packages/ui/Data/DataScope.ts b/packages/ui/Data/DataScope.ts index bc563bec..28b92419 100644 --- a/packages/ui/Data/DataScope.ts +++ b/packages/ui/Data/DataScope.ts @@ -26,6 +26,32 @@ interface DataScopeGroup { const EMPTY_DATA = Object.freeze({}); +function cloneValue(value: DataValue): DataValue { + if (Array.isArray(value)) { + return [...value]; + } + + if (value instanceof Date) { + return new Date(value.getTime()); + } + + return value; +} + +function createSnapshot(values: Map): Readonly> { + const entries = Array.from(values, ([key, value]) => { + const snapshotValue = cloneValue(value); + + if (Array.isArray(snapshotValue) || snapshotValue instanceof Date) { + Object.freeze(snapshotValue); + } + + return [key, snapshotValue] as const; + }); + + return Object.freeze(Object.fromEntries(entries)); +} + /** * Define a local boundary and a default group for descendant Data components. * @link https://ui.studiometa.dev/components/DataScope/ @@ -76,8 +102,8 @@ export class DataScope extends Base extends Base extends Base Date: Wed, 15 Jul 2026 17:09:34 +0200 Subject: [PATCH 04/20] Add virtual data bindings --- packages/docs/components/DataBind/index.md | 62 +++++++++ packages/tests/Data/DataBind.spec.ts | 145 ++++++++++++++++++++- packages/ui/Data/DataBind.ts | 95 +++++++++++++- 3 files changed, 298 insertions(+), 4 deletions(-) diff --git a/packages/docs/components/DataBind/index.md b/packages/docs/components/DataBind/index.md index 6618a4fd..e44b3a5a 100644 --- a/packages/docs/components/DataBind/index.md +++ b/packages/docs/components/DataBind/index.md @@ -41,6 +41,68 @@ registerComponent(DataBind); ::: +### Multiple virtual bindings + +Use virtual `data-bind:*` attributes to update several parts of an element from the same value: + +| Syntax | Behavior | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `data-bind:prop.` | Assigns the DOM property. | +| `data-bind:attr.` | Removes the attribute for `false`, `null`, or `undefined`; writes an empty attribute for `true`; otherwise writes the stringified value. | +| `data-bind:class.` | Toggles the class according to the result's boolean value. | +| `data-bind:style.` | Clears the style for `false`, `null`, or `undefined`; otherwise writes the stringified value. | +| `data-bind:text` | Assigns `textContent`. | + +A non-empty attribute value is a JavaScript expression with access to `value`, `target`, and `$data`. An empty attribute passes through the current value. When an element has one or more virtual bindings, they replace the default single `textContent` or property update. Bindings are read when first used; changing their attributes afterward is not supported. + +Use kebab-case for camel-cased DOM properties because HTML attribute names are case-insensitive, for example `data-bind:prop.tab-index` targets `tabIndex`. + +For ARIA attributes, explicitly stringify booleans when `"false"` must remain present, for example `data-bind:attr.aria-selected="String(value === 'overview')"`. + +The following Tabs-like controls keep their labels while updating state and panels from the `tab` group: + +```html + + + + + +
+ Overview panel +
+
+ Details panel +
+``` + +Expression errors are reported without interrupting updates to the other bindings, matching `DataComputed` and `DataEffect` behavior. + ### Advanced usage with computed and effects The whole family of `Data...` components can be used to create reactivity in your HTML with only a few `data-...` attributes. diff --git a/packages/tests/Data/DataBind.spec.ts b/packages/tests/Data/DataBind.spec.ts index f1a7c5b9..54081f50 100644 --- a/packages/tests/Data/DataBind.spec.ts +++ b/packages/tests/Data/DataBind.spec.ts @@ -1,5 +1,5 @@ import { it, describe, expect, vi } from 'vitest'; -import { DataBind, DataComputed, DataEffect } from '@studiometa/ui'; +import { Action, DataBind, DataComputed, DataEffect, DataScope } from '@studiometa/ui'; import { nextTick } from '@studiometa/js-toolkit/utils'; import { destroy, hConnected as h, mount } from '#test-utils'; @@ -246,4 +246,147 @@ describe('The DataBind component', () => { await nextTick(); expect(bind2.value).toBe('foo'); }); + + it('should apply multiple virtual prop, attr, class, style and text bindings', () => { + const button = h( + 'button', + { + 'data-bind:prop.disabled': '!value', + 'data-bind:prop.tab-index': 'value ? 0 : -1', + 'data-bind:attr.aria-pressed': 'String(Boolean(value))', + 'data-bind:class.is-active': 'value', + 'data-bind:style.display': 'value ? "block" : "none"', + 'data-bind:text': '`Selected: ${value}`', + }, + ['Original label'], + ); + const instance = new DataBind(button); + + instance.set(true); + + expect(button.disabled).toBe(false); + expect(button.tabIndex).toBe(0); + expect(button.getAttribute('aria-pressed')).toBe('true'); + expect(button.classList.contains('is-active')).toBe(true); + expect(button.style.display).toBe('block'); + expect(button.textContent).toBe('Selected: true'); + }); + + it('should pass the raw value through empty virtual bindings', () => { + const div = h('div', { + 'data-bind:prop.title': '', + 'data-bind:attr.data-value': '', + 'data-bind:class.selected': '', + 'data-bind:style.--state': '', + 'data-bind:text': '', + }); + const instance = new DataBind(div); + + instance.set('visible'); + + expect(div.title).toBe('visible'); + expect(div.dataset.value).toBe('visible'); + expect(div.classList.contains('selected')).toBe(true); + expect(div.style.getPropertyValue('--state')).toBe('visible'); + expect(div.textContent).toBe('visible'); + }); + + it('should use scoped data in virtual binding expressions', () => { + const root = h('div', { dataOptionGroup: 'tabs' }); + const button = h('button', { + 'data-bind:attr.aria-selected': '$data.active === value', + 'data-bind:class.is-active': '$data.active === value', + }); + root.append(button); + const scope = new DataScope(root); + const instance = new DataBind(button); + scope.setValue('tabs', 'active', 'details'); + + instance.set('details'); + + expect(button.getAttribute('aria-selected')).toBe(''); + expect(button.classList.contains('is-active')).toBe(true); + }); + + it('should remove attributes and clear styles according to binding semantics', () => { + const div = h('div', { + 'data-bind:attr.hidden': 'value', + 'data-bind:style.--state': 'value', + }); + const instance = new DataBind(div); + + instance.set(true); + expect(div.getAttribute('hidden')).toBe(''); + expect(div.style.getPropertyValue('--state')).toBe('true'); + + for (const value of [false, null, undefined]) { + instance.set(value); + expect(div.hasAttribute('hidden')).toBe(false); + expect(div.style.getPropertyValue('--state')).toBe(''); + } + + instance.set(0); + expect(div.getAttribute('hidden')).toBe('0'); + expect(div.style.getPropertyValue('--state')).toBe('0'); + }); + + it('should fail quietly when a virtual binding expression throws', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const div = h('div', { + 'data-bind:attr.title': 'missing.value', + 'data-bind:text': '`Value: ${value}`', + }); + const instance = new DataBind(div); + + expect(() => instance.set('foo')).not.toThrow(); + expect(div.hasAttribute('title')).toBe(false); + expect(div.textContent).toBe('Value: foo'); + expect(consoleError).toHaveBeenCalledOnce(); + consoleError.mockRestore(); + }); + + it('should update virtual subscribers when their legacy value equals the dispatched value', async () => { + const source = new DataBind(h('div', { dataOptionGroup: 'equal' }, ['foo'])); + const button = h( + 'button', + { + dataOptionGroup: 'equal', + 'data-bind:attr.data-value': 'value', + }, + ['foo'], + ); + const subscriber = new DataBind(button); + await mount(source, subscriber); + + source.set('foo'); + + expect(button.dataset.value).toBe('foo'); + expect(button.textContent).toBe('foo'); + }); + + it('should coexist with Action virtual events without option collisions', async () => { + const button = h('button', { + 'data-on:click': 'target.$el.dataset.clicked = "true"', + 'data-bind:class.is-active': 'value', + }); + const action = new Action(button); + const bind = new DataBind(button); + await mount(action, bind); + + bind.set(true); + button.dispatchEvent(new Event('click')); + + expect(button.classList.contains('is-active')).toBe(true); + expect(button.dataset.clicked).toBe('true'); + }); + + it('should preserve the legacy single-property behavior without virtual bindings', () => { + const button = h('button', { dataOptionProp: 'disabled' }, ['Label']); + const instance = new DataBind(button); + + instance.set(true); + + expect(button.disabled).toBe(true); + expect(button.textContent).toBe('Label'); + }); }); diff --git a/packages/ui/Data/DataBind.ts b/packages/ui/Data/DataBind.ts index 2127d34f..9f25f668 100644 --- a/packages/ui/Data/DataBind.ts +++ b/packages/ui/Data/DataBind.ts @@ -3,7 +3,7 @@ import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; import { isArray, nextTick } from '@studiometa/js-toolkit/utils'; import { DataScope, getDataScope } from './DataScope.js'; import type { DataValue } from './DataScope.js'; -import { isInput, isCheckbox, isSelect } from './utils.js'; +import { getCallback, isInput, isCheckbox, isSelect } from './utils.js'; export interface DataBindProps extends BaseProps { $options: { @@ -15,11 +15,21 @@ export interface DataBindProps extends BaseProps { const EMPTY_DATA = Object.freeze({}); +type VirtualBinding = + | { type: 'text'; expression: string } + | { type: 'prop' | 'attr' | 'class' | 'style'; name: string; expression: string }; + +function camelize(value: string) { + return value.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} + /** * DataBind class. * @link https://ui.studiometa.dev/components/DataBind/ */ -export class DataBind extends withGroup(Base, 'data:') { +export class DataBind extends withGroup(Base, 'data:')< + DataBindProps & T +> { static config: BaseConfig = { name: 'DataBind', options: { @@ -31,6 +41,35 @@ export class DataBind extends withGroup(Base, ' private __dataScopeResolved = false; private __dataScope?: DataScope; + private __virtualBindings?: VirtualBinding[]; + + get virtualBindings() { + if (!this.__virtualBindings) { + this.__virtualBindings = []; + + for (const attribute of this.$el.attributes) { + if (attribute.name === 'data-bind:text') { + this.__virtualBindings.push({ type: 'text', expression: attribute.value }); + continue; + } + + const match = attribute.name.match(/^data-bind:(prop|attr|class|style)\.(.+)$/); + if (match) { + this.__virtualBindings.push({ + type: match[1] as 'prop' | 'attr' | 'class' | 'style', + name: match[2], + expression: attribute.value, + }); + } + } + } + + return this.__virtualBindings; + } + + get hasVirtualBindings() { + return this.virtualBindings.length > 0; + } get dataScope() { if (!this.__dataScopeResolved) { @@ -168,12 +207,17 @@ export class DataBind extends withGroup(Base, ' if (dispatch) { for (const instance of relatedInstances) { - if (instance !== this && instance.value !== value) { + if (instance !== this && (instance.hasVirtualBindings || instance.value !== value)) { instance.set(value, false); } } } + if (this.hasVirtualBindings) { + this.applyVirtualBindings(value); + return; + } + if (isSelect(target)) { // @ts-ignore for (const option of target.options) { @@ -198,6 +242,51 @@ export class DataBind extends withGroup(Base, ' target[this.prop] = value; } + private applyVirtualBindings(value: DataValue) { + for (const binding of this.virtualBindings) { + let result: unknown = value; + + if (binding.expression) { + try { + result = getCallback(this.group, `return ${binding.expression};`)( + value, + this.target, + this.$data, + ); + } catch (error) { + // @todo better handling of errors? + console.error('Failed', error); + continue; + } + } + + switch (binding.type) { + case 'prop': + this.target[camelize(binding.name)] = result; + break; + case 'attr': + if (result === false || result === null || result === undefined) { + this.target.removeAttribute(binding.name); + } else { + this.target.setAttribute(binding.name, result === true ? '' : String(result)); + } + break; + case 'class': + this.target.classList.toggle(binding.name, Boolean(result)); + break; + case 'style': + this.target.style.setProperty( + binding.name, + result === false || result === null || result === undefined ? '' : String(result), + ); + break; + case 'text': + this.target.textContent = result as string | null; + break; + } + } + } + /** * Publish a keyed value to the scoped group and synchronize matching subscribers. * @internal From b798bb1c386cdd8af9ffa1ac3c3b5e81efc18aa2 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 15 Jul 2026 17:14:45 +0200 Subject: [PATCH 05/20] Resolve acronym property bindings --- packages/tests/Data/DataBind.spec.ts | 12 ++++++++++++ packages/ui/Data/DataBind.ts | 19 ++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/tests/Data/DataBind.spec.ts b/packages/tests/Data/DataBind.spec.ts index 54081f50..1f9c13be 100644 --- a/packages/tests/Data/DataBind.spec.ts +++ b/packages/tests/Data/DataBind.spec.ts @@ -272,6 +272,18 @@ describe('The DataBind component', () => { expect(button.textContent).toBe('Selected: true'); }); + it('should resolve acronym-cased DOM properties', () => { + const div = h('div', { + 'data-bind:prop.inner-html': '`${value}`', + }); + const instance = new DataBind(div); + + instance.set('Content'); + + expect(div.innerHTML).toBe('Content'); + expect((div as HTMLElement & { innerHtml?: string }).innerHtml).toBeUndefined(); + }); + it('should pass the raw value through empty virtual bindings', () => { const div = h('div', { 'data-bind:prop.title': '', diff --git a/packages/ui/Data/DataBind.ts b/packages/ui/Data/DataBind.ts index 9f25f668..bc43ee68 100644 --- a/packages/ui/Data/DataBind.ts +++ b/packages/ui/Data/DataBind.ts @@ -19,8 +19,21 @@ type VirtualBinding = | { type: 'text'; expression: string } | { type: 'prop' | 'attr' | 'class' | 'style'; name: string; expression: string }; -function camelize(value: string) { - return value.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +function resolvePropertyName(target: HTMLElement, name: string) { + const normalizedName = name.replaceAll('-', '').toLowerCase(); + let current: object | null = target; + + while (current) { + const property = Object.getOwnPropertyNames(current).find( + (candidate) => candidate.toLowerCase() === normalizedName, + ); + if (property) { + return property; + } + current = Object.getPrototypeOf(current); + } + + return name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); } /** @@ -262,7 +275,7 @@ export class DataBind extends withGroup(Base, ' switch (binding.type) { case 'prop': - this.target[camelize(binding.name)] = result; + this.target[resolvePropertyName(this.target, binding.name)] = result; break; case 'attr': if (result === false || result === null || result === undefined) { From 27a8fbb6f20f1d8a547d991ea875309a097c82c2 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 15 Jul 2026 17:28:31 +0200 Subject: [PATCH 06/20] Add data mutation helpers --- packages/docs/components/DataBind/js-api.md | 21 +++++ packages/tests/Data/DataBind.spec.ts | 88 ++++++++++++++++++++- packages/ui/Data/DataBind.ts | 36 +++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/packages/docs/components/DataBind/js-api.md b/packages/docs/components/DataBind/js-api.md index 1d479146..0e778a68 100644 --- a/packages/docs/components/DataBind/js-api.md +++ b/packages/docs/components/DataBind/js-api.md @@ -79,6 +79,27 @@ Set the value for the current instance and dispatch it to others if the second p - `value` (`DataValue`): the value to set - `dispatch` (`boolean`, default to `true`): wether to dispatch the value to other related instances or not +### `toggle(onValue = true, offValue = false)` + +Toggle between two values and dispatch the result to the group. Custom values can describe disclosure state without repeating comparison logic in an Action: + +```html + +``` + +### `increment(step = 1)` + +Convert the current value to a number, increment it by `step`, and dispatch the result. A non-numeric current value starts at `0`. Pass a negative step to decrement. + +### `cycle(values)` + +Select and dispatch the value following the current value in the given array. The method wraps to the first value; an unknown current value also selects the first value. An empty array does nothing. + ### `get()` Get the value for the current instance. diff --git a/packages/tests/Data/DataBind.spec.ts b/packages/tests/Data/DataBind.spec.ts index f1a7c5b9..218981f6 100644 --- a/packages/tests/Data/DataBind.spec.ts +++ b/packages/tests/Data/DataBind.spec.ts @@ -1,5 +1,5 @@ import { it, describe, expect, vi } from 'vitest'; -import { DataBind, DataComputed, DataEffect } from '@studiometa/ui'; +import { Action, DataBind, DataComputed, DataEffect, DataScope } from '@studiometa/ui'; import { nextTick } from '@studiometa/js-toolkit/utils'; import { destroy, hConnected as h, mount } from '#test-utils'; @@ -149,6 +149,92 @@ describe('The DataBind component', () => { expect(spySet).toHaveBeenLastCalledWith('bar'); }); + it('should toggle between default and custom values', () => { + const checkbox = new DataBind(h('input', { type: 'checkbox' })); + checkbox.toggle(); + expect(checkbox.value).toBe(true); + checkbox.toggle(); + expect(checkbox.value).toBe(false); + + const state = new DataBind(h('div')); + state.toggle('open', 'closed'); + expect(state.value).toBe('open'); + state.toggle('open', 'closed'); + expect(state.value).toBe('closed'); + + const numericState = new DataBind(h('input', { type: 'text' })); + numericState.toggle(1, 0); + expect(numericState.value).toBe('1'); + numericState.toggle(1, 0); + expect(numericState.value).toBe('0'); + }); + + it('should mutate scoped values from an Action', async () => { + const root = h('div', { dataOptionGroup: 'disclosure' }); + const stateElement = h('div', { class: 'state', dataOptionKey: 'state' }); + const button = h('button', { + dataOptionTarget: 'DataBind(.state)', + dataOptionEffect: "target.toggle('open', 'closed')", + }); + root.append(stateElement, button); + + const scope = new DataScope(root); + const state = new DataBind(stateElement); + const action = new Action(button); + await mount(scope, state, action); + + button.dispatchEvent(new Event('click')); + expect(state.value).toBe('open'); + expect(state.$data).toEqual({ state: 'open' }); + + button.dispatchEvent(new Event('click')); + expect(state.value).toBe('closed'); + expect(state.$data).toEqual({ state: 'closed' }); + + await destroy(scope, state, action); + }); + + it('should increment numeric values and recover from non-numeric values', () => { + const instance = new DataBind(h('div', ['2'])); + + instance.increment(); + expect(instance.value).toBe('3'); + instance.increment(-2); + expect(instance.value).toBe('1'); + instance.value = 'invalid'; + instance.increment(5); + expect(instance.value).toBe('5'); + }); + + it('should cycle through values', () => { + const instance = new DataBind(h('div', ['one'])); + const values = ['one', 'two', 'three']; + + instance.cycle(values); + expect(instance.value).toBe('two'); + instance.cycle(values); + expect(instance.value).toBe('three'); + instance.cycle(values); + expect(instance.value).toBe('one'); + + instance.value = 'unknown'; + instance.cycle(values); + expect(instance.value).toBe('one'); + instance.cycle([]); + expect(instance.value).toBe('one'); + + const numeric = new DataBind(h('input', { type: 'text', value: '1' })); + numeric.cycle([1, 2, 3]); + expect(numeric.value).toBe('2'); + + const array = new DataBind(h('div', ['one,two'])); + array.cycle([ + ['one', 'two'], + ['three', 'four'], + ]); + expect(array.value).toBe('three,four'); + }); + it('should dispatch value to other instances', async () => { const instance1 = new DataBind(h('div', { dataOptionGroup: 'a' }, ['foo'])); const instance2 = new DataBind(h('div', { dataOptionGroup: 'a' }, ['foo'])); diff --git a/packages/ui/Data/DataBind.ts b/packages/ui/Data/DataBind.ts index 2127d34f..9ed43b3c 100644 --- a/packages/ui/Data/DataBind.ts +++ b/packages/ui/Data/DataBind.ts @@ -15,6 +15,24 @@ export interface DataBindProps extends BaseProps { const EMPTY_DATA = Object.freeze({}); +function valuesEqual(left: DataValue, right: DataValue) { + if (Object.is(left, right)) { + return true; + } + + if (left instanceof Date && right instanceof Date) { + return left.getTime() === right.getTime(); + } + + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && left.every((value, index) => value === right[index]) + ); + } + + return String(left) === String(right); +} + /** * DataBind class. * @link https://ui.studiometa.dev/components/DataBind/ @@ -223,6 +241,24 @@ export class DataBind extends withGroup(Base, ' this.set(value, false); } + toggle(onValue: DataValue = true, offValue: DataValue = false) { + this.set(valuesEqual(this.value, onValue) ? offValue : onValue); + } + + increment(step = 1) { + const value = Number(this.value); + this.set((Number.isNaN(value) ? 0 : value) + step); + } + + cycle(values: readonly DataValue[]) { + if (values.length === 0) { + return; + } + + const index = values.findIndex((value) => valuesEqual(value, this.value)); + this.set(values[(index + 1) % values.length]); + } + mounted() { if (!this.$options.immediate) { return; From ad143cd731e7e5c10813125e0721b7cbe2456169 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 15 Jul 2026 17:35:02 +0200 Subject: [PATCH 07/20] Guard unsupported toggle targets --- packages/docs/components/DataBind/js-api.md | 4 +++- packages/tests/Data/DataBind.spec.ts | 7 +++++++ packages/ui/Data/DataBind.ts | 10 ++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/docs/components/DataBind/js-api.md b/packages/docs/components/DataBind/js-api.md index 0e778a68..d67c8085 100644 --- a/packages/docs/components/DataBind/js-api.md +++ b/packages/docs/components/DataBind/js-api.md @@ -81,7 +81,9 @@ Set the value for the current instance and dispatch it to others if the second p ### `toggle(onValue = true, offValue = false)` -Toggle between two values and dispatch the result to the group. Custom values can describe disclosure state without repeating comparison logic in an Action: +Toggle between two values and dispatch the result to the group. Single checkboxes support the default boolean values; custom values require a target that can represent them without coercing them to `checked`. Radio inputs are not supported. + +Custom values can describe disclosure state without repeating comparison logic in an Action: ```html