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
23 changes: 23 additions & 0 deletions packages/docs/components/DataBind/js-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<button
data-component="Action"
data-option-target="DataBind(.state)"
data-option-effect="target.toggle('open', 'closed')">
Toggle
</button>
```

### `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.
93 changes: 93 additions & 0 deletions packages/tests/Data/DataBind.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']));
Expand Down
46 changes: 46 additions & 0 deletions packages/ui/Data/DataBind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -325,6 +343,34 @@ export class DataBind<T extends BaseProps = BaseProps> 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);
Comment thread
titouanmathis marked this conversation as resolved.
}

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;
Expand Down
Loading