Skip to content

Commit

Permalink
Merge pull request #299 from studiometa/feature/action-multiple-events
Browse files Browse the repository at this point in the history
[Feature] Action: add support for multiple events
  • Loading branch information
titouanmathis authored Sep 20, 2024
2 parents 72366ea + cf29c5b commit 3b05192
Show file tree
Hide file tree
Showing 13 changed files with 396 additions and 193 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### 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))
Expand Down
9 changes: 9 additions & 0 deletions packages/docs/components/atoms/Action/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<event>[.<modifier>]`](./js-api.html#on-event-modifier) can be used to listen to multiple events on a single `Action` component.

<PreviewPlayground
:html="() => import('./stories/multiple-events/app.twig')"
:script="() => import('./stories/multiple-events/app.js?raw')"
/>
20 changes: 20 additions & 0 deletions packages/docs/components/atoms/Action/js-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<event>[.<modifier>]`

- Type: `string`
- Format: `[<name>[(<selector>)] -> ]<effect>`

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}
<button
data-component="Action"
data-option-on:click.stop="target.$el.textContent = 'Clicked'"
data-option-on:mouseenter="target.$el.textContent = 'Hovered'">
Hover and click me
</button>
```

::: 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.
:::
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<button
data-component="Action"
data-option-on:click.stop="target.$el.textContent = 'Clicked'"
data-option-on:mouseenter="target.$el.textContent = 'Hovered'"
data-option-on:mouseleave="target.$el.textContent = 'Hover and click me'"
class="px-4 py-2 rounded bg-blue-400 dark:bg-blue-600">
Hover and click me
</button>
19 changes: 19 additions & 0 deletions packages/tests/__utils__/components.ts
Original file line number Diff line number Diff line change
@@ -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',
};
}
1 change: 1 addition & 0 deletions packages/tests/__utils__/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './components.js';
export * from './h.js';
export * from './lifecycle.js';
export * from './mockIntersectionObserver.js';
Expand Down
122 changes: 12 additions & 110 deletions packages/tests/atoms/Action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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();
});

Expand All @@ -164,59 +104,21 @@ 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()',
});
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();
});

it('should warn when the effect throws an error', async () => {
const { action, foo, reset } = await getContext({
target: 'Foo',
effect: 'target.undefinedMethod()',
it('should listen to advanced configured events', async () => {
const div = h('div', {
id: 'bar',
'data-option-on:click': 'target.$el.id = "foo"',
});
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');
});
});
116 changes: 116 additions & 0 deletions packages/tests/atoms/ActionEvent.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 2 additions & 1 deletion packages/tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"vitest": "2.0.5"
},
"imports": {
"#test-utils": "./__utils__/index.ts"
"#test-utils": "./__utils__/index.ts",
"#private/*": "../ui/*"
}
}
Loading

0 comments on commit 3b05192

Please sign in to comment.