Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/docs/components/DataBind/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>` | Assigns the DOM property. |
| `data-bind:attr.<name>` | Removes the attribute for `false`, `null`, or `undefined`; writes an empty attribute for `true`; otherwise writes the stringified value. |
| `data-bind:class.<name>` | Toggles the class according to the result's boolean value. |
| `data-bind:style.<name>` | 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
<input
id="current-tab"
type="hidden"
value="overview"
data-component="DataBind"
data-option-group="tab"
data-option-immediate />

<button
data-component="Action DataBind"
data-option-group="tab"
data-on:click="DataBind(#current-tab) -> target.value = 'overview'"
data-bind:class.is-active="value === 'overview'"
data-bind:attr.aria-selected="String(value === 'overview')">
Overview
</button>
<button
data-component="Action DataBind"
data-option-group="tab"
data-on:click="DataBind(#current-tab) -> target.value = 'details'"
data-bind:class.is-active="value === 'details'"
data-bind:attr.aria-selected="String(value === 'details')">
Details
</button>

<section
data-component="DataBind"
data-option-group="tab"
data-bind:attr.hidden="value !== 'overview'">
Overview panel
</section>
<section
data-component="DataBind"
data-option-group="tab"
data-bind:attr.hidden="value !== 'details'">
Details panel
</section>
```

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.
Expand Down
157 changes: 156 additions & 1 deletion packages/tests/Data/DataBind.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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': '`<strong>${value}</strong>`',
});
const instance = new DataBind(div);

instance.set('Content');

expect(div.innerHTML).toBe('<strong>Content</strong>');
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');
});
});
108 changes: 105 additions & 3 deletions packages/ui/Data/DataBind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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<T extends BaseProps = BaseProps> extends withGroup(Base, 'data:')<DataBindProps & T> {
export class DataBind<T extends BaseProps = BaseProps> extends withGroup(Base, 'data:')<
DataBindProps & T
> {
static config: BaseConfig = {
name: 'DataBind',
options: {
Expand All @@ -31,6 +54,35 @@ export class DataBind<T extends BaseProps = BaseProps> 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) {
Expand Down Expand Up @@ -168,12 +220,17 @@ export class DataBind<T extends BaseProps = BaseProps> 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) {
Expand All @@ -198,6 +255,51 @@ export class DataBind<T extends BaseProps = BaseProps> 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
Expand Down
Loading