diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ca4a7c..332a6428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +## [v1.0.0-alpha.8](https://github.com/studiometa/ui/compare/1.0.0-alpha.7..1.0.0-alpha.8) (2024-09-25) + +### Added + +- **Action:** add support for handling multiple events ([#298](https://github.com/studiometa/ui/issues/298), [#299](https://github.com/studiometa/ui/pull/299), [b739f2b](https://github.com/studiometa/ui/commit/b739f2b)) + +### Changed + +- ⚠️ **DataBind:** rename the `name` option to `group` ([#288](https://github.com/studiometa/ui/issues/288), [#297](https://github.com/studiometa/ui/pull/297), [5ea37c9](https://github.com/studiometa/ui/commit/5ea37c9)) + ## [v1.0.0-alpha.7](https://github.com/studiometa/ui/compare/1.0.0-alpha.6..1.0.0-alpha.7) (2024-09-10) ### Fixed diff --git a/composer.json b/composer.json index 9013125f..86690b91 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "studiometa/ui", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "description": "A set of opiniated, unstyled and accessible components.", "license": "MIT", "require": { diff --git a/package-lock.json b/package-lock.json index 74a11b24..0bcafaff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@studiometa/ui-workspace", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@studiometa/ui-workspace", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "hasInstallScript": true, "workspaces": [ "packages/*" @@ -18766,7 +18766,7 @@ }, "packages/docs": { "name": "@studiometa/ui-docs", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "dependencies": { "@studiometa/js-toolkit": "^3.0.0-alpha.10" }, @@ -18786,7 +18786,7 @@ }, "packages/playground": { "name": "@studiometa/ui-playground", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "dependencies": { "@studiometa/playground": "0.1.4", "copy-webpack-plugin": "^12.0.2" @@ -18794,7 +18794,7 @@ }, "packages/tests": { "name": "@studiometa/ui-tests", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "license": "MIT", "dependencies": { "@studiometa/ui": "file:../ui", @@ -18805,7 +18805,7 @@ }, "packages/ui": { "name": "@studiometa/ui", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "license": "MIT", "dependencies": { "@studiometa/js-toolkit": "^3.0.0-alpha.10", diff --git a/package.json b/package.json index 60e646da..a8172708 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@studiometa/ui-workspace", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "private": true, "workspaces": [ "packages/*" diff --git a/packages/docs/components/atoms/Action/index.md b/packages/docs/components/atoms/Action/index.md index 4b74dabb..f7439d1e 100644 --- a/packages/docs/components/atoms/Action/index.md +++ b/packages/docs/components/atoms/Action/index.md @@ -52,3 +52,12 @@ The `Target` component is a companion of the `Action` component that can be used :html="() => import('./stories/counter/app.twig')" :script="() => import('./stories/counter/app.js?raw')" /> + +### Listening to multiple events + +The advanced HTML [option `on:[.]`](./js-api.html#on-event-modifier) can be used to listen to multiple events on a single `Action` component. + + diff --git a/packages/docs/components/atoms/Action/js-api.md b/packages/docs/components/atoms/Action/js-api.md index e1de09f0..acccd4b7 100644 --- a/packages/docs/components/atoms/Action/js-api.md +++ b/packages/docs/components/atoms/Action/js-api.md @@ -159,3 +159,23 @@ This can be useful to destructure the first `ctx` parameter and make a direct re ::: warning Advanced pattern The pattern described above with multiple components as targets is an advanced pattern that should be used with care, as it adds complexity to the DOM that might not be necessary. ::: + +### `on:[.]` + +- Type: `string` +- Format: `[[()] -> ]` + +This option can be used to combine the [`on`](#on), [`target`](#target) and [`effect`](#effect) options into one single attributes. This option can be used to attach multiple events to a single `Action` component. + +```html {3} + +``` + +::: warning Virtual option +This is a virtual option, meaning that it can be used in HTML but will not be present in the `$options` property of the component in JavaScript. +::: diff --git a/packages/docs/components/atoms/Action/stories/multiple-events/app.js b/packages/docs/components/atoms/Action/stories/multiple-events/app.js new file mode 100644 index 00000000..60792baf --- /dev/null +++ b/packages/docs/components/atoms/Action/stories/multiple-events/app.js @@ -0,0 +1,14 @@ +import { Base, createApp } from '@studiometa/js-toolkit'; +import { Action, Transition } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'App', + components: { + Action, + Transition, + }, + }; +} + +export default createApp(App, document.body); diff --git a/packages/docs/components/atoms/Action/stories/multiple-events/app.twig b/packages/docs/components/atoms/Action/stories/multiple-events/app.twig new file mode 100644 index 00000000..5eba26ce --- /dev/null +++ b/packages/docs/components/atoms/Action/stories/multiple-events/app.twig @@ -0,0 +1,8 @@ + diff --git a/packages/docs/components/atoms/DataBind/data-bind-js-api.md b/packages/docs/components/atoms/DataBind/data-bind-js-api.md index 99e73b5f..bbb864a8 100644 --- a/packages/docs/components/atoms/DataBind/data-bind-js-api.md +++ b/packages/docs/components/atoms/DataBind/data-bind-js-api.md @@ -14,12 +14,12 @@ The `DataBind` component can be used to keep a value in sync between multiple DO - Type: `string` - Default: `'textContent'` -### `name` +### `group` - Type: `string` - Default: `''` -The `name` option is used to group instances together. All related instances will be updated when the value changes. +The `group` option is used to group instances together. All related instances will be updated when the value changes. When using it with multiple checkboxes or select multiple, use the `[]` suffix to push each selected value in an array. See the [checkboxes example](/components/atoms/DataBind/examples.html#checkboxes) for more details on how this works. @@ -41,7 +41,7 @@ The targeted DOM element. - Type: `boolean` - Readonly -Wether new values should be pushed to an array instead of a single value. This is enabled by adding the `[]` suffix to the [`name` option](#name). +Wether new values should be pushed to an array instead of a single value. This is enabled by adding the `[]` suffix to the [`group` option](#group). ## Methods diff --git a/packages/docs/components/atoms/DataBind/data-model-js-api.md b/packages/docs/components/atoms/DataBind/data-model-js-api.md index 91a51bbe..e1e402a7 100644 --- a/packages/docs/components/atoms/DataBind/data-model-js-api.md +++ b/packages/docs/components/atoms/DataBind/data-model-js-api.md @@ -7,7 +7,7 @@ outline: deep The `DataModel` component have the same public API as the [`DataBind` component](./data-bind-js-api.html). -This component will [dispatch](#dispatch) its current value to all other related instances sharing the same name when the `input` event is triggered on its root element. +This component will [dispatch](#dispatch) its current value to all other related instances within the same group when the `input` event is triggered on its root element. ## Methods diff --git a/packages/docs/components/atoms/DataBind/stories/basic.twig b/packages/docs/components/atoms/DataBind/stories/basic.twig index 305718f8..a450a04a 100644 --- a/packages/docs/components/atoms/DataBind/stories/basic.twig +++ b/packages/docs/components/atoms/DataBind/stories/basic.twig @@ -1,16 +1,16 @@
+ data-option-group="msg" + data-option-effect="target.classList.toggle('bg-red-400', value.length > 15)" + class="flex flex-col gap-4 bg-opacity-50">

Hello world @@ -18,7 +18,7 @@

Lengh: 11

diff --git a/packages/docs/components/atoms/DataBind/stories/checkbox.twig b/packages/docs/components/atoms/DataBind/stories/checkbox.twig index 403a48e7..368a1cde 100644 --- a/packages/docs/components/atoms/DataBind/stories/checkbox.twig +++ b/packages/docs/components/atoms/DataBind/stories/checkbox.twig @@ -2,22 +2,22 @@ -

+

false

The checkboxes are not checked diff --git a/packages/docs/components/atoms/DataBind/stories/checkboxes.twig b/packages/docs/components/atoms/DataBind/stories/checkboxes.twig index aaf929c0..f4ec4463 100644 --- a/packages/docs/components/atoms/DataBind/stories/checkboxes.twig +++ b/packages/docs/components/atoms/DataBind/stories/checkboxes.twig @@ -2,34 +2,34 @@ -

 

+

 

[]
diff --git a/packages/docs/components/atoms/DataBind/stories/compute-example.twig b/packages/docs/components/atoms/DataBind/stories/compute-example.twig index d3df4188..276b0929 100644 --- a/packages/docs/components/atoms/DataBind/stories/compute-example.twig +++ b/packages/docs/components/atoms/DataBind/stories/compute-example.twig @@ -1,7 +1,7 @@

Count: - 10 + 10

Double: - + 20

diff --git a/packages/docs/components/atoms/DataBind/stories/effect-example.twig b/packages/docs/components/atoms/DataBind/stories/effect-example.twig index 6dca5939..12ef4614 100644 --- a/packages/docs/components/atoms/DataBind/stories/effect-example.twig +++ b/packages/docs/components/atoms/DataBind/stories/effect-example.twig @@ -1,7 +1,7 @@

Count: - 50 + 50

diff --git a/packages/docs/components/atoms/DataBind/stories/select-multiple.twig b/packages/docs/components/atoms/DataBind/stories/select-multiple.twig index 0d84a7b7..9aa56291 100644 --- a/packages/docs/components/atoms/DataBind/stories/select-multiple.twig +++ b/packages/docs/components/atoms/DataBind/stories/select-multiple.twig @@ -1,6 +1,6 @@
-

+


 
diff --git a/packages/docs/components/atoms/DataBind/stories/select.twig b/packages/docs/components/atoms/DataBind/stories/select.twig index bb646e61..8ac99aeb 100644 --- a/packages/docs/components/atoms/DataBind/stories/select.twig +++ b/packages/docs/components/atoms/DataBind/stories/select.twig @@ -1,8 +1,8 @@
- -

+

diff --git a/packages/docs/package.json b/packages/docs/package.json index a326e113..430f6385 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@studiometa/ui-docs", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "private": true, "type": "module", "scripts": { diff --git a/packages/playground/package.json b/packages/playground/package.json index b0765013..999cd9d6 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,6 +1,6 @@ { "name": "@studiometa/ui-playground", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "private": true, "type": "module", "scripts": { diff --git a/packages/tests/__utils__/components.ts b/packages/tests/__utils__/components.ts new file mode 100644 index 00000000..b155f003 --- /dev/null +++ b/packages/tests/__utils__/components.ts @@ -0,0 +1,19 @@ +import { Base } from '@studiometa/js-toolkit'; + +export class Foo extends Base { + static config = { + name: 'Foo', + }; +} + +export class Bar extends Base { + static config = { + name: 'Bar', + }; +} + +export class Baz extends Base { + static config = { + name: 'Baz', + }; +} diff --git a/packages/tests/__utils__/index.ts b/packages/tests/__utils__/index.ts index 4f92debf..caf28e9e 100644 --- a/packages/tests/__utils__/index.ts +++ b/packages/tests/__utils__/index.ts @@ -1,3 +1,4 @@ +export * from './components.js'; export * from './h.js'; export * from './lifecycle.js'; export * from './mockIntersectionObserver.js'; diff --git a/packages/tests/atoms/Action.spec.ts b/packages/tests/atoms/Action.spec.ts index 335474f7..39008556 100644 --- a/packages/tests/atoms/Action.spec.ts +++ b/packages/tests/atoms/Action.spec.ts @@ -61,67 +61,6 @@ async function getContext({ } describe('The Action component', () => { - it('should define a callable effect property based on the effect option', async () => { - const { action, spy, reset } = await getContext(); - action.effect('foo'); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith('foo'); - await reset(); - }); - - it('should return a callable function from the effect property', async () => { - const { action, spy, reset } = await getContext({ - effect: '(...args) => console.log(...args);', - }); - const fn = action.effect('foo'); - expect(spy).toHaveBeenCalledTimes(0); - expect(typeof fn).toBe('function'); - fn('bar'); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenLastCalledWith('bar'); - await reset(); - }); - - it('should resolve targets to self if option is not set', async () => { - const { action, reset } = await getContext({ - target: '', - }); - expect(action.targets).toEqual([{ Action: action }]); - await reset(); - }) - - it('should resolve single target', async () => { - const { action, foo, reset } = await getContext({ - target: 'Foo', - }); - expect(action.targets).toEqual([{ Foo: foo }]); - await reset(); - }); - - it('should resolve multiple targets', async () => { - const { action, foo, bar, reset } = await getContext({ - target: 'Foo Bar', - }); - expect(action.targets).toEqual([{ Foo: foo }, { Bar: bar }]); - await reset(); - }); - - it('should resolve targets with selectors', async () => { - const { action, foo, bar, reset } = await getContext({ - target: "Foo(.foo) Bar([class*='bar'])", - }); - expect(action.targets).toEqual([{ Foo: foo }, { Bar: bar }]); - await reset(); - }); - - it('should fail to resolve targets silently when the target string can not be parsed', async () => { - const { action, foo, bar, reset } = await getContext({ - target: '1234 &#', - }); - expect(action.targets).toEqual([]); - await reset(); - }); - it('should react on click by default', async () => { const { action, foo, fooFn, reset } = await getContext({ target: 'Foo', @@ -151,7 +90,8 @@ describe('The Action component', () => { }); const event = new Event('click'); action.$el.dispatchEvent(event); - expect(fooFn).toHaveBeenCalledWith(action.targets[0], event, foo, action); + const [target] = Array.from(action.actionEvents)[0].targets + expect(fooFn).toHaveBeenCalledWith(target, event, foo, action); await reset(); }); @@ -164,59 +104,39 @@ describe('The Action component', () => { const spy = vi.spyOn(foo, '$update'); const event = new Event('click'); action.$el.dispatchEvent(event); - expect(spy).toHaveBeenCalledWith(action.targets[0], action.targets[0], event, event, foo, foo); + const [target] = Array.from(action.actionEvents)[0].targets + expect(spy).toHaveBeenCalledWith(target, target, event, event, foo, foo); spy.mockRestore(); await reset(); }); - it('should prevent default and stop propagation if modifiers specified', async () => { - const { action, reset } = await getContext({ - target: 'Foo', - on: 'click.prevent.stop', - effect: 'target.fn()', + it('should listen to advanced configured events', async () => { + const div = h('div', { + id: 'bar', + 'data-option-on:click': 'target.$el.id = "foo"', }); - expect(action.event).toBe('click'); - expect(action.modifiers).toEqual(['prevent', 'stop']); - const event = new Event('click'); - const preventSpy = vi.spyOn(event, 'preventDefault'); - const stopSpy = vi.spyOn(event, 'stopPropagation'); - action.$el.dispatchEvent(event); - expect(preventSpy).toHaveBeenCalledTimes(1); - expect(stopSpy).toHaveBeenCalledTimes(1); - await reset(); - }); - - it('should configure the addEventListener options if modifiers specified', async () => { - const { action, reset } = await getContext({ - target: 'Foo', - on: 'click.capture.once.passive', - effect: 'target.fn()', - }); - expect(action.event).toBe('click'); - expect(action.modifiers).toEqual(['capture', 'once', 'passive']); - const event = new Event('click'); - action.$el.dispatchEvent(event); - const addEventSpy = vi.spyOn(action.$el, 'addEventListener'); - await destroy(action); - await mount(action); - expect(addEventSpy).toHaveBeenCalledTimes(1); - expect(addEventSpy).toHaveBeenCalledWith('click', action, { - capture: true, - once: true, - passive: true, - }); - await reset(); + const action = new Action(div); + await mount(action) + expect(action.$el.id).toBe('bar'); + action.$el.dispatchEvent(new Event('click')); + expect(action.$el.id).toBe('foo'); }); - it('should warn when the effect throws an error', async () => { - const { action, foo, reset } = await getContext({ - target: 'Foo', - effect: 'target.undefinedMethod()', + it('should work with multiline effects', async () => { + const div = h('div', { + id: 'bar', + 'data-option-on:click': ` + Action(#bar) + -> + target.$el.id = true + ? "foo" + : "bar" + `, }); - const spy = vi.spyOn(action, '$warn'); + const action = new Action(div); + await mount(action) + expect(action.$el.id).toBe('bar'); action.$el.dispatchEvent(new Event('click')); - expect(spy).toHaveBeenCalledTimes(1); - spy.mockRestore(); - await reset(); + expect(action.$el.id).toBe('foo'); }); }); diff --git a/packages/tests/atoms/ActionEvent.spec.ts b/packages/tests/atoms/ActionEvent.spec.ts new file mode 100644 index 00000000..f3132229 --- /dev/null +++ b/packages/tests/atoms/ActionEvent.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, vi, expect, afterEach } from 'vitest'; +import { Action, Target } from '@studiometa/ui'; +import { ActionEvent } from '#private/atoms/Action/ActionEvent.js'; +import { h, mount, destroy, Foo } from '#test-utils'; + +describe('The Action component', () => { + it('should define a callable effect property based on the effect option', async () => { + const spy = vi.spyOn(console, 'log'); + spy.mockImplementation(() => {}); + const actionEvent = new ActionEvent(new Action(h('div')), 'click', 'console.log(ctx)'); + actionEvent.effect('foo'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('foo'); + spy.mockRestore(); + }); + + it('should return a callable function from the effect property', async () => { + const spy = vi.spyOn(console, 'log'); + spy.mockImplementation(() => {}); + const actionEvent = new ActionEvent( + new Action(h('div')), + 'click', + '(...args) => console.log(...args)', + ); + const callback = actionEvent.effect(); + expect(typeof callback).toBe('function'); + callback('foo'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('foo'); + spy.mockRestore(); + }); + + it('should resolve targets to self if option is not set', async () => { + const action = new Action(h('div')); + const actionEvent = new ActionEvent(action, 'click', '(...args) => console.log(...args)'); + expect(actionEvent.targets).toEqual([{ Action: action }]); + }); + + it('should resolve single target', async () => { + const action = new Action(h('div')); + const target = new Target(h('div')); + await mount(action, target); + const actionEvent = new ActionEvent(action, 'click', 'Target -> target'); + expect(actionEvent.targets).toEqual([{ Target: target }]); + await destroy(action, target); + }); + + it('should resolve multiple targets', async () => { + const action = new Action(h('div')); + const target = new Target(h('div')); + const foo = new Foo(h('div')); + await mount(action, target, foo); + const actionEvent = new ActionEvent(action, 'click', 'Target Foo -> target'); + expect(actionEvent.targets).toEqual([{ Target: target }, { Foo: foo }]); + await destroy(action, target, foo); + }); + + it('should resolve targets with selectors', async () => { + const action = new Action(h('div')); + const targetA = new Target(h('div', { id: 'a' })); + const targetB = new Target(h('div')); + await mount(action, targetA, targetB); + const actionEvent = new ActionEvent(action, 'click', 'Target(#a) -> target'); + expect(actionEvent.targets).toEqual([{ Target: targetA }]); + await destroy(action, targetA, targetB); + }); + + it.todo( + 'should fail to resolve targets silently when the target string can not be parsed', + async () => { + // @todo + }, + ); + + it('should prevent default and stop propagation if modifiers specified', async () => { + const action = new Action(h('div')); + await mount(action); + const actionEvent = new ActionEvent(action, 'click.prevent.stop', 'target'); + actionEvent.attachEvent(); + const event = new Event('click'); + const preventSpy = vi.spyOn(event, 'preventDefault'); + const stopSpy = vi.spyOn(event, 'stopPropagation'); + action.$el.dispatchEvent(event); + expect(preventSpy).toHaveBeenCalledTimes(1); + expect(stopSpy).toHaveBeenCalledTimes(1); + await destroy(action); + preventSpy.mockRestore(); + stopSpy.mockRestore(); + }); + + it('should configure the addEventListener options if modifiers specified', async () => { + const action = new Action(h('div')); + await mount(action); + const actionEvent = new ActionEvent(action, 'click.capture.once.passive', 'target'); + const addEventSpy = vi.spyOn(action.$el, 'addEventListener'); + actionEvent.attachEvent(); + expect(addEventSpy).toHaveBeenCalledTimes(1); + expect(addEventSpy).toHaveBeenCalledWith('click', actionEvent, { + capture: true, + once: true, + passive: true, + }); + await destroy(action); + addEventSpy.mockRestore(); + }); + + it('should warn when the effect throws an error', async () => { + const action = new Action(h('div')); + const warnSpy = vi.spyOn(action, '$warn'); + const actionEvent = new ActionEvent(action, 'click', '() => consol.log()'); + actionEvent.attachEvent(); + action.$el.dispatchEvent(new Event('click')); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/tests/atoms/DataBind.spec.ts b/packages/tests/atoms/DataBind.spec.ts index 687201bd..a50d5c3a 100644 --- a/packages/tests/atoms/DataBind.spec.ts +++ b/packages/tests/atoms/DataBind.spec.ts @@ -46,8 +46,8 @@ describe('The DataBind component', () => { }); it('should set the checked property of multiple checkbox', async () => { - const inputA = h('input', { type: 'checkbox', value: 'foo', dataOptionName: 'checkbox[]' }); - const inputB = h('input', { type: 'checkbox', value: 'bar', dataOptionName: 'checkbox[]' }); + const inputA = h('input', { type: 'checkbox', value: 'foo', dataOptionGroup: 'checkbox[]' }); + const inputB = h('input', { type: 'checkbox', value: 'bar', dataOptionGroup: 'checkbox[]' }); const instanceA = new DataBind(inputA); const instanceB = new DataBind(inputB); @@ -80,7 +80,7 @@ describe('The DataBind component', () => { it('should select an option of a select', () => { const optionA = h('option', { value: 'foo' }, ['Foo']); const optionB = h('option', { value: 'bar' }, ['Bar']); - const select = h('select', { dataOptionName: 'select' }, [optionA, optionB]); + const select = h('select', { dataOptionGroup: 'select' }, [optionA, optionB]); const instance = new DataBind(select); expect(optionA.selected).toBe(true); expect(optionB.selected).toBe(false); @@ -95,7 +95,7 @@ describe('The DataBind component', () => { const optionA = h('option', { value: 'foo', selected: true }, ['Foo']); const optionB = h('option', { value: 'bar' }, ['Bar']); const optionC = h('option', { value: 'baz' }, ['Baz']); - const select = h('select', { multiple: true, dataOptionName: 'select[]' }, [ + const select = h('select', { multiple: true, dataOptionGroup: 'select[]' }, [ optionA, optionB, optionC, @@ -149,13 +149,13 @@ describe('The DataBind component', () => { }); it('should dispatch value to other instances', async () => { - const instance1 = new DataBind(h('div', { dataOptionName: 'a' }, ['foo'])); - const instance2 = new DataBind(h('div', { dataOptionName: 'a' }, ['foo'])); + const instance1 = new DataBind(h('div', { dataOptionGroup: 'a' }, ['foo'])); + const instance2 = new DataBind(h('div', { dataOptionGroup: 'a' }, ['foo'])); const instance3 = new DataComputed( - h('div', { dataOptionName: 'a', dataOptionCompute: 'value + value' }, ['foofoo']), + h('div', { dataOptionGroup: 'a', dataOptionCompute: 'value + value' }, ['foofoo']), ); const instance4 = new DataEffect( - h('div', { dataOptionName: 'a', dataOptionEffect: 'target.id = value', id: 'foo' }), + h('div', { dataOptionGroup: 'a', dataOptionEffect: 'target.id = value', id: 'foo' }), ); await mount(instance1, instance2, instance3, instance4); @@ -177,13 +177,13 @@ describe('The DataBind component', () => { type: 'checkbox', value: 'foo', checked: '', - dataOptionName: 'checkbox[]', + dataOptionGroup: 'checkbox[]', }); const inputB = h('input', { type: 'checkbox', value: 'bar', checked: '', - dataOptionName: 'checkbox[]', + dataOptionGroup: 'checkbox[]', }); fragment.append(inputA, inputB); @@ -197,7 +197,7 @@ describe('The DataBind component', () => { expect(instanceA.value).toEqual(['foo', 'bar']); inputB.replaceWith( - h('input', { type: 'checkbox', value: 'bar', dataOptionName: 'checkbox[]' }), + h('input', { type: 'checkbox', value: 'bar', dataOptionGroup: 'checkbox[]' }), ); expect(inputA.isConnected).toBe(true); diff --git a/packages/tests/atoms/DataModel.spec.ts b/packages/tests/atoms/DataModel.spec.ts index a34f5fd8..d2b06d0c 100644 --- a/packages/tests/atoms/DataModel.spec.ts +++ b/packages/tests/atoms/DataModel.spec.ts @@ -29,10 +29,10 @@ describe('The DataModel component', () => { }); it('should set value for multiple checkboxes with the same name', async () => { - const checkboxA1 = h('input', { type: 'checkbox', value: 'a', dataOptionName: 'check[]' }); - const checkboxA2 = h('input', { type: 'checkbox', value: 'a', dataOptionName: 'check[]' }); - const checkboxB1 = h('input', { type: 'checkbox', value: 'b', dataOptionName: 'check[]' }); - const checkboxB2 = h('input', { type: 'checkbox', value: 'b', dataOptionName: 'check[]' }); + const checkboxA1 = h('input', { type: 'checkbox', value: 'a', dataOptionGroup: 'check[]' }); + const checkboxA2 = h('input', { type: 'checkbox', value: 'a', dataOptionGroup: 'check[]' }); + const checkboxB1 = h('input', { type: 'checkbox', value: 'b', dataOptionGroup: 'check[]' }); + const checkboxB2 = h('input', { type: 'checkbox', value: 'b', dataOptionGroup: 'check[]' }); const instanceA1 = new DataModel(checkboxA1); const instanceA2 = new DataModel(checkboxA2); const instanceB1 = new DataModel(checkboxB1); diff --git a/packages/tests/package.json b/packages/tests/package.json index 1649b790..41a3b071 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -1,6 +1,6 @@ { "name": "@studiometa/ui-tests", - "version": "1.0.0-alpha.7", + "version": "1.0.0-alpha.8", "private": true, "type": "module", "scripts": { @@ -15,6 +15,7 @@ "vitest": "2.0.5" }, "imports": { - "#test-utils": "./__utils__/index.ts" + "#test-utils": "./__utils__/index.ts", + "#private/*": "../ui/*" } } diff --git a/packages/ui/atoms/Action/Action.ts b/packages/ui/atoms/Action/Action.ts index ca0974c2..468395e5 100644 --- a/packages/ui/atoms/Action/Action.ts +++ b/packages/ui/atoms/Action/Action.ts @@ -1,6 +1,6 @@ -import { Base, getInstances } from '@studiometa/js-toolkit'; -import { isFunction } from '@studiometa/js-toolkit/utils'; +import { Base } from '@studiometa/js-toolkit'; import type { BaseProps, BaseConfig } from '@studiometa/js-toolkit'; +import { ActionEvent } from './ActionEvent.js'; export interface ActionProps extends BaseProps { $options: { @@ -11,14 +11,6 @@ export interface ActionProps extends BaseProps { }; } -/** - * Extract component name and an optional additional selector from a string. - * @type {RegExp} - */ -const TARGET_REGEX = /([a-zA-Z]+)(\((.*)\))?/; - -const effectCache = new Map(); - /** * Action class. */ @@ -31,101 +23,55 @@ export class Action extends Base> { - const { target } = this.$options; + /** + * @private + */ + __actionEvents: Set>; - if (!target) { - return [{ [this.__config.name]: this }]; + get actionEvents() { + if (this.__actionEvents) { + return this.__actionEvents; } - const parts = target.split(' ').map((part) => { - const [, name, , selector] = part.match(TARGET_REGEX) ?? []; - return [name, selector]; - }); - - const targets = [] as Array>; + const { on } = this.$options; + this.__actionEvents = new Set(); - for (const instance of getInstances()) { - const { name } = instance.__config; - - for (const part of parts) { - const shouldPush = - part[0] === name && (!part[1] || (part[1] && instance.$el.matches(part[1]))); - if (shouldPush) { - targets.push({ [instance.$options.name]: instance }); - } + // @ts-ignore + for (const attribute of this.$el.attributes) { + if (attribute.name.includes('on:')) { + const name = attribute.name.split('on:').pop(); + this.__actionEvents.add(new ActionEvent(this, name, attribute.value)); } } - return targets; - } - - /** - * Run method on targeted components - */ - handleEvent(event: Event) { - const { targets, effect, modifiers } = this; - - if (modifiers.includes('prevent')) { - event.preventDefault(); + if (on) { + const { target, effect } = this.$options; + const effectDefinition = target ? `${target}${ActionEvent.effectSeparator}${effect}` : effect; + this.__actionEvents.add(new ActionEvent(this, on, effectDefinition)); } - if (modifiers.includes('stop')) { - event.stopPropagation(); - } - - for (const target of targets) { - try { - const [currentTarget] = Object.values(target).flat(); - const value = effect(target, event, currentTarget, this); - if (typeof value === 'function') { - value(target, event, currentTarget, this); - } - } catch (err) { - this.$warn(err); - } - } + return this.__actionEvents; } /** * Mounted */ mounted() { - const { event, modifiers } = this; - - this.$el.addEventListener(event, this, { - capture: modifiers.includes('capture'), - once: modifiers.includes('once'), - passive: modifiers.includes('passive'), - }); + for (const actionEvent of this.actionEvents) { + actionEvent.attachEvent(); + } } /** * Destroyed */ destroyed() { - this.$el.removeEventListener(this.$options.on, this); + for (const actionEvent of this.actionEvents) { + actionEvent.detachEvent(); + } } } diff --git a/packages/ui/atoms/Action/ActionEvent.ts b/packages/ui/atoms/Action/ActionEvent.ts new file mode 100644 index 00000000..254dbf49 --- /dev/null +++ b/packages/ui/atoms/Action/ActionEvent.ts @@ -0,0 +1,163 @@ +import { getInstances } from '@studiometa/js-toolkit'; +import type { Base } from '@studiometa/js-toolkit'; +import { isFunction } from '@studiometa/js-toolkit/utils'; + +/** + * Extract component name and an optional additional selector from a string. + * @type {RegExp} + */ +const TARGET_REGEX = /([a-zA-Z]+)(\((.*)\))?/; + +const effectCache = new Map(); + +export type Modifiers = 'prevent' | 'stop' | 'once' | 'passive' | 'capture'; + +export class ActionEvent { + static modifierSeparator = '.'; + static targetSeparator = ' '; + static effectSeparator = '->'; + + /** + * The Action instance. + */ + action: T; + + /** + * The event to listen to. + */ + event: string; + + /** + * The modifiers to apply to the event. + */ + modifiers: Modifiers[]; + + /** + * Target definition. + * Ex: `Target Target(.selector)`. + */ + targetDefinition: string; + + /** + * The content of the effect callback function. + */ + effectDefinition: string; + + /** + * Class constructor. + * @param {T} action The parent Action instance. + * @param {string} eventDefinition The event with its modifiers: `click.prevent.stop` + * @param {string} effectDefinition The target and effect definition: `Target(.selector)->target.$destroy()` + */ + constructor(action: T, eventDefinition: string, effectDefinition: string) { + this.action = action; + const [event, ...modifiers] = eventDefinition.split(ActionEvent.modifierSeparator); + this.event = event; + this.modifiers = modifiers as Modifiers[]; + + let effect = effectDefinition; + let targetDefinition = ''; + + if (effect.includes(ActionEvent.effectSeparator)) { + [targetDefinition, effect] = effect.split(ActionEvent.effectSeparator); + } + + this.targetDefinition = targetDefinition.trim(); + this.effectDefinition = effect.trim(); + } + + /** + * Get the generated function for the defined effect. + */ + get effect() { + const { effectDefinition } = this; + + if (!effectCache.has(effectDefinition)) { + effectCache.set( + effectDefinition, + new Function('ctx', 'event', 'target', 'action', 'self', `return ${effectDefinition}`), + ); + } + + return effectCache.get(effectDefinition) as Function; + } + + /** + * Get the targets object for the defined targets string. + */ + get targets() { + const { targetDefinition } = this; + + if (!targetDefinition) { + return [{ Action: this.action }]; + } + + // Extract component's names and selectors. + const parts = targetDefinition.split(ActionEvent.targetSeparator).map((part) => { + const [, name, , selector] = part.match(TARGET_REGEX) ?? []; + return [name, selector]; + }); + + const targets = [] as Array>; + + for (const instance of getInstances()) { + const { name } = instance.__config; + + for (const part of parts) { + const shouldPush = + part[0] === name && (!part[1] || (part[1] && instance.$el.matches(part[1]))); + if (shouldPush) { + targets.push({ [instance.__config.name]: instance }); + } + } + } + + return targets; + } + + /** + * Handle the defined event and trigger the effect for each defined target. + */ + handleEvent(event: Event) { + const { targets, effect, modifiers } = this; + + if (modifiers.includes('prevent')) { + event.preventDefault(); + } + + if (modifiers.includes('stop')) { + event.stopPropagation(); + } + + for (const target of targets) { + try { + const [currentTarget] = Object.values(target).flat(); + const value = effect(target, event, currentTarget, this.action, this.action); + if (isFunction(value)) { + value(target, event, currentTarget, this.action, this.action); + } + } catch (err) { + this.action.$warn(err); + } + } + } + + /** + * Bind the defined event to the given Action instance root element. + */ + attachEvent() { + const { event, modifiers } = this; + this.action.$el.addEventListener(event, this, { + capture: modifiers.includes('capture'), + once: modifiers.includes('once'), + passive: modifiers.includes('passive'), + }); + } + + /** + * Unbind the event from the given Action instance root element. + */ + detachEvent() { + this.action.$el.removeEventListener(this.event, this); + } +} diff --git a/packages/ui/atoms/Data/DataBind.ts b/packages/ui/atoms/Data/DataBind.ts index 7e1c83c5..8723498f 100644 --- a/packages/ui/atoms/Data/DataBind.ts +++ b/packages/ui/atoms/Data/DataBind.ts @@ -8,7 +8,7 @@ const groups = new Map>(); export interface DataBindProps extends BaseProps { $options: { prop: string; - name: string; + group: string; }; } @@ -17,14 +17,14 @@ export class DataBind extends Base extends Base extends DataBind extends DataBind