Skip to content

Commit

Permalink
Refactor Action to handle multiple events
Browse files Browse the repository at this point in the history
Fix #298
  • Loading branch information
titouanmathis committed Sep 20, 2024
1 parent 72366ea commit b739f2b
Show file tree
Hide file tree
Showing 2 changed files with 190 additions and 81 deletions.
108 changes: 27 additions & 81 deletions packages/ui/atoms/Action/Action.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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<string, Function>();

/**
* Action class.
*/
Expand All @@ -31,101 +23,55 @@ export class Action<T extends BaseProps = BaseProps> extends Base<ActionProps &
default: 'click',
},
target: String,
selector: String,
effect: String,
},
};

get event() {
const [event] = this.$options.on.split('.', 1);
return event;
}

get modifiers() {
return this.$options.on.split('.').slice(1);
}

get effect() {
const { effect } = this.$options;
if (!effectCache.has(effect)) {
effectCache.set(effect, new Function('ctx', 'event', 'target', 'action', `return ${effect}`));
}
return effectCache.get(effect);
}

get targets(): Array<Record<string, Base>> {
const { target } = this.$options;
/**
* @private
*/
__actionEvents: Set<ActionEvent<Action>>;

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<Record<string, Base>>;
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();
}
}
}
163 changes: 163 additions & 0 deletions packages/ui/atoms/Action/ActionEvent.ts
Original file line number Diff line number Diff line change
@@ -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<string, Function>();

export type Modifiers = 'prevent' | 'stop' | 'once' | 'passive' | 'capture';

export class ActionEvent<T extends Base> {
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;
this.effectDefinition = effect;
}

/**
* 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<Record<string, Base>>;

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);
}
}

0 comments on commit b739f2b

Please sign in to comment.