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..1f9c13be 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,159 @@ 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 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': '', + '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..bc43ee68 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,34 @@ 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 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()); +} + /** * 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 +54,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 +220,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 +255,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[resolvePropertyName(this.target, 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