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;