From 27a8fbb6f20f1d8a547d991ea875309a097c82c2 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Wed, 15 Jul 2026 17:28:31 +0200 Subject: [PATCH 1/2] 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 2/2] 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