diff --git a/packages/docs/components/DataBind/js-api.md b/packages/docs/components/DataBind/js-api.md index 1d479146..d67c8085 100644 --- a/packages/docs/components/DataBind/js-api.md +++ b/packages/docs/components/DataBind/js-api.md @@ -79,6 +79,29 @@ 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. 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 + +``` + +### `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 1f9c13be..652579ef 100644 --- a/packages/tests/Data/DataBind.spec.ts +++ b/packages/tests/Data/DataBind.spec.ts @@ -149,6 +149,99 @@ 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); + checkbox.toggle('open', 'closed'); + expect(checkbox.value).toBe(false); + + const radioElement = h('input', { type: 'radio', value: 'one' }); + const radio = new DataBind(radioElement); + radio.toggle('one', ''); + expect(radioElement.checked).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 bc43ee68..ac8cfacb 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); +} + type VirtualBinding = | { type: 'text'; expression: string } | { type: 'prop' | 'attr' | 'class' | 'style'; name: string; expression: string }; @@ -325,6 +343,34 @@ export class DataBind extends withGroup(Base, ' this.set(value, false); } + toggle(onValue: DataValue = true, offValue: DataValue = false) { + const isRadio = isInput(this.target) && this.target.type === 'radio'; + const hasCustomCheckboxValues = + isCheckbox(this.target) && + (typeof onValue !== 'boolean' || typeof offValue !== 'boolean'); + + if (isRadio || hasCustomCheckboxValues) { + this.$warn('The toggle() values can not be represented by this input.'); + return; + } + + 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;