diff --git a/.eslintrc.json b/.eslintrc.json index ace91c2..a048fed 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -25,7 +25,7 @@ }, "overrides": [ { - "files": ["*.test.*", "*.spec.*"], + "files": ["*.test.*", "*.spec.*", "*.bench.*"], "rules": { "@typescript-eslint/no-explicit-any": "off" } diff --git a/packages/alpinets/src/utils/on-old.ts b/packages/alpinets/src/utils/on-old.ts new file mode 100644 index 0000000..7647fa3 --- /dev/null +++ b/packages/alpinets/src/utils/on-old.ts @@ -0,0 +1,247 @@ +import type { ElementWithXAttributes } from '../types'; +import { debounce } from './debounce'; +import { camelCase, dotSyntax, kebabCase } from './stringTransformers'; +import { throttle } from './throttle'; + +export const on = ( + el: ElementWithXAttributes, + event: string, + modifiers: string[], + callback: EventHandler, +) => { + let listenerTarget: ElementWithXAttributes | Window | Document = el; + + let handler: EventHandler = (e: Event) => callback(e); + + const options = { + passive: false, + capture: false, + }; + + // This little helper allows us to add functionality to the listener's + // handler more flexibly in a "middleware" style. + const wrapHandler = + ( + callback: EventHandler, + wrapper: (next: EventHandler, event: Event) => void, + ): EventHandler => + (e) => + wrapper(callback, e); + + if (modifiers.includes('dot')) event = dotSyntax(event); + if (modifiers.includes('camel')) event = camelCase(event); + if (modifiers.includes('passive')) options.passive = true; + if (modifiers.includes('capture')) options.capture = true; + if (modifiers.includes('window')) listenerTarget = window; + if (modifiers.includes('document')) listenerTarget = document; + + if (modifiers.includes('debounce')) { + const nextModifier = + modifiers[modifiers.indexOf('debounce') + 1] || 'invalid-wait'; + const wait = isNumeric(nextModifier.split('ms')[0]) + ? Number(nextModifier.split('ms')[0]) + : 250; + + handler = debounce(handler, wait); + } + + if (modifiers.includes('throttle')) { + const nextModifier = + modifiers[modifiers.indexOf('throttle') + 1] || 'invalid-limit'; + const limit = isNumeric(nextModifier.split('ms')[0]) + ? Number(nextModifier.split('ms')[0]) + : 250; + + handler = throttle(handler, limit); + } + + if (modifiers.includes('prevent')) + handler = wrapHandler(handler, (next, e) => { + e.preventDefault(); + next(e); + }); + + if (modifiers.includes('stop')) + handler = wrapHandler(handler, (next, e) => { + e.stopPropagation(); + next(e); + }); + + if (modifiers.includes('once')) { + handler = wrapHandler(handler, (next, e) => { + next(e); + + listenerTarget.removeEventListener(event, handler, options); + }); + } + + if (modifiers.includes('away') || modifiers.includes('outside')) { + listenerTarget = document; + + handler = wrapHandler(handler, (next, e) => { + if (el.contains(e.target as Node)) return; + + if ((e.target as Node).isConnected === false) return; + + if (el.offsetWidth < 1 && el.offsetHeight < 1) return; + + // Additional check for special implementations like x-collapse + // where the element doesn't have display: none + if (el._x_isShown === false) return; + + next(e); + }); + } + + if (modifiers.includes('self')) + handler = wrapHandler(handler, (next, e) => { + e.target === el && next(e); + }); + + // Handle :keydown and :keyup listeners. + if (isKeyEvent(event) || isClickEvent(event)) { + handler = wrapHandler(handler, (next, e) => { + if (isListeningForASpecificKeyThatHasntBeenPressed(e, modifiers)) { + return; + } + + next(e); + }); + } + + listenerTarget.addEventListener(event, handler, options); + + return () => { + listenerTarget.removeEventListener(event, handler, options); + }; +}; + +type EventHandler = (event: Event) => void; + +export const isNumeric = (subject: unknown): subject is number => + !Array.isArray(subject) && !isNaN(Number(subject)); + +const isKeyEvent = (event: string): event is 'keydown' | 'keyup' => + ['keydown', 'keyup'].includes(event); + +const isClickEvent = (event: string) => { + return ['contextmenu', 'click', 'mouse'].some((i) => event.includes(i)); +}; + +const isListeningForASpecificKeyThatHasntBeenPressed = ( + e: Event, + modifiers: string[], +) => { + let keyModifiers = modifiers.filter( + (mod) => + ![ + 'window', + 'document', + 'prevent', + 'stop', + 'once', + 'capture', + 'self', + 'away', + 'outside', + 'passive', + ].includes(mod), + ); + + if (keyModifiers.includes('debounce')) { + const debounceIndex = keyModifiers.indexOf('debounce'); + keyModifiers.splice( + debounceIndex, + isNumeric( + (keyModifiers[debounceIndex + 1] || 'invalid-wait').split('ms')[0], + ) + ? 2 + : 1, + ); + } + if (keyModifiers.includes('throttle')) { + const throttleIndex = keyModifiers.indexOf('throttle'); + keyModifiers.splice( + throttleIndex, + isNumeric( + (keyModifiers[throttleIndex + 1] || 'invalid-wait').split('ms')[0], + ) + ? 2 + : 1, + ); + } + + // If no modifier is specified, we'll call it a press. + if (keyModifiers.length === 0) return false; + + // If one is passed, AND it matches the key pressed, we'll call it a press. + if ( + keyModifiers.length === 1 && + keyToModifiers((e as KeyboardEvent).key).includes(keyModifiers[0]) + ) + return false; + + // The user is listening for key combinations. + const systemKeyModifiers = ['ctrl', 'shift', 'alt', 'meta', 'cmd', 'super']; + const selectedSystemKeyModifiers = systemKeyModifiers.filter((modifier) => + keyModifiers.includes(modifier), + ); + + keyModifiers = keyModifiers.filter( + (i) => !selectedSystemKeyModifiers.includes(i), + ); + + if (selectedSystemKeyModifiers.length > 0) { + const activelyPressedKeyModifiers = selectedSystemKeyModifiers.filter( + (modifier) => { + // Alias "cmd" and "super" to "meta" + if (modifier === 'cmd' || modifier === 'super') modifier = 'meta'; + + return e[`${modifier}Key`]; + }, + ); + + // If all the modifiers selected are pressed, ... + if ( + activelyPressedKeyModifiers.length === selectedSystemKeyModifiers.length + ) { + // AND the event is a click. It's a pass. + if (isClickEvent(e.type)) return false; + // AND the remaining key is pressed as well. It's a press. + if (keyToModifiers((e as KeyboardEvent).key).includes(keyModifiers[0])) + return false; + } + } + + // We'll call it NOT a valid keypress. + return true; +}; + +const keyToModifiers = (key: string): string[] => { + if (!key) return []; + + key = kebabCase(key); + + const modifierToKeyMap = { + ctrl: 'control', + slash: '/', + space: ' ', + spacebar: ' ', + cmd: 'meta', + esc: 'escape', + up: 'arrow-up', + down: 'arrow-down', + left: 'arrow-left', + right: 'arrow-right', + period: '.', + equal: '=', + minus: '-', + underscore: '_', + }; + + modifierToKeyMap[key] = key; + + return Object.entries(modifierToKeyMap) + .map(([modifier, keytype]) => (keytype === key ? modifier : false)) + .filter((mod: string | false): mod is string => Boolean(mod)); +}; diff --git a/packages/alpinets/src/utils/on.ts b/packages/alpinets/src/utils/on.ts index 91dba8e..200d158 100644 --- a/packages/alpinets/src/utils/on.ts +++ b/packages/alpinets/src/utils/on.ts @@ -1,116 +1,158 @@ -import { ElementWithXAttributes } from '../types'; +import type { ElementWithXAttributes } from '../types'; import { debounce } from './debounce'; import { camelCase, dotSyntax, kebabCase } from './stringTransformers'; import { throttle } from './throttle'; +const callWith = + unknown>(ev: Event) => + (fn: T) => + fn(ev); export const on = ( el: ElementWithXAttributes, event: string, modifiers: string[], callback: EventHandler, ) => { - let listenerTarget: ElementWithXAttributes | Window | Document = el; + const listener: ListenerInfo = { + event, + target: el, + filters: [], + handler(e) { + const caller = callWith(e); + if (listener.filters.every(caller)) { + listener.cleanups.forEach(caller); + callback(e); + } + }, + modifiers, + cleanups: [], + options: { + passive: false, + capture: false, + }, + }; - let handler: EventHandler = (e: Event) => callback(e); + for (const mod of modifiers) modifierOptions[mod]?.(listener); + + // Handle :keydown and :keyup listeners. + if (isKeyEvent(event) || isClickEvent(event)) { + keyedEvent(listener); + } + + listener.target.addEventListener( + listener.event, + listener.handler, + listener.options, + ); - const options = { - passive: false, - capture: false, + return () => { + listener.target.removeEventListener( + listener.event, + listener.handler, + listener.options, + ); }; +}; - // This little helper allows us to add functionality to the listener's - // handler more flexibly in a "middleware" style. - const wrapHandler = - ( - callback: EventHandler, - wrapper: (next: EventHandler, event: Event) => void, - ): EventHandler => - (e) => - wrapper(callback, e); - - if (modifiers.includes('dot')) event = dotSyntax(event); - if (modifiers.includes('camel')) event = camelCase(event); - if (modifiers.includes('passive')) options.passive = true; - if (modifiers.includes('capture')) options.capture = true; - if (modifiers.includes('window')) listenerTarget = window; - if (modifiers.includes('document')) listenerTarget = document; - if (modifiers.includes('prevent')) - handler = wrapHandler(handler, (next, e) => { - e.preventDefault(); - next(e); - }); - if (modifiers.includes('stop')) - handler = wrapHandler(handler, (next, e) => { - e.stopPropagation(); - next(e); - }); - if (modifiers.includes('self')) - handler = wrapHandler(handler, (next, e) => { - e.target === el && next(e); - }); - - if (modifiers.includes('away') || modifiers.includes('outside')) { - listenerTarget = document; - - handler = wrapHandler(handler, (next, e) => { - if (el.contains(e.target as Node)) return; - - if ((e.target as Node).isConnected === false) return; - - if (el.offsetWidth < 1 && el.offsetHeight < 1) return; - - // Additional check for special implementations like x-collapse - // where the element doesn't have display: none - if (el._x_isShown === false) return; - - next(e); - }); - } +type ListenerInfo = { + event: string; + target: ElementWithXAttributes | Window | Document; + filters: ((e: Event) => boolean)[]; + handler(e: Event): void; + modifiers: string[]; + cleanups: ((e: Event) => void)[]; + options: { + passive: boolean; + capture: boolean; + }; +}; - if (modifiers.includes('once')) { - handler = wrapHandler(handler, (next, e) => { - next(e); +const dot = (listener: ListenerInfo) => { + listener.event = dotSyntax(listener.event); + return listener; +}; - listenerTarget.removeEventListener(event, handler, options); - }); - } +const camel = (listener: ListenerInfo) => { + listener.event = camelCase(listener.event); + return listener; +}; - // Handle :keydown and :keyup listeners. - handler = wrapHandler(handler, (next, e) => { - if (isKeyEvent(event)) { - if (isListeningForASpecificKeyThatHasntBeenPressed(e, modifiers)) { - return; - } - } +const passive = (listener: ListenerInfo) => { + listener.options.passive = true; + return listener; +}; + +const capture = (listener: ListenerInfo) => { + listener.options.capture = true; + return listener; +}; + +const hasWindow = (listener: ListenerInfo) => + (listener.target = (listener.target as Element).ownerDocument?.defaultView); +const hasDocument = (listener: ListenerInfo) => + (listener.target = (listener.target as Element).ownerDocument); + +const debounceListener = (listener: ListenerInfo, wait: number = 250) => { + listener.handler = debounce(listener.handler, wait); + return listener; +}; - next(e); +const throttleListener = (listener: ListenerInfo, limit: number = 250) => { + listener.handler = throttle(listener.handler, limit); + return listener; +}; + +const self = (listener: ListenerInfo) => { + listener.filters.push(isSelf); + return listener; +}; + +const isSelf = (e: Event) => e.target === e.currentTarget; + +const outside = (listener: ListenerInfo) => { + listener.filters.push(isAwayOutside.bind(null, listener.target)); + hasDocument(listener); + return listener; +}; + +const once = (listener: ListenerInfo) => { + listener.cleanups.push(() => { + listener.target.removeEventListener( + listener.event, + listener.handler, + listener.options, + ); }); + return listener; +}; - if (modifiers.includes('debounce')) { - const nextModifier = - modifiers[modifiers.indexOf('debounce') + 1] || 'invalid-wait'; - const wait = isNumeric(nextModifier.split('ms')[0]) - ? Number(nextModifier.split('ms')[0]) - : 250; +const isAwayOutside = (el: ElementWithXAttributes, e: Event) => { + if (el.contains(e.target as Node)) return false; - handler = debounce(handler, wait); - } + if ((e.target as Node).isConnected === false) return false; - if (modifiers.includes('throttle')) { - const nextModifier = - modifiers[modifiers.indexOf('throttle') + 1] || 'invalid-limit'; - const limit = isNumeric(nextModifier.split('ms')[0]) - ? Number(nextModifier.split('ms')[0]) - : 250; + if (el.offsetWidth < 1 && el.offsetHeight < 1) return false; - handler = throttle(handler, limit); - } + // Additional check for special implementations like x-collapse + // where the element doesn't have display: none + if (el._x_isShown === false) return false; - listenerTarget.addEventListener(event, handler, options); + return true; +}; - return () => { - listenerTarget.removeEventListener(event, handler, options); - }; +const hasPrevent = (listener: ListenerInfo) => listener.cleanups.push(prevent); + +const hasStop = (listener: ListenerInfo) => listener.cleanups.push(stop); + +const stop = (e: Event) => e.stopPropagation(); + +const prevent = (e: Event) => e.preventDefault(); + +const keyedEvent = (listener: ListenerInfo) => { + listener.filters.push( + (e) => + !isListeningForASpecificKeyThatHasntBeenPressed(e, listener.modifiers), + ); }; type EventHandler = (event: Event) => void; @@ -121,15 +163,28 @@ export const isNumeric = (subject: unknown): subject is number => const isKeyEvent = (event: string): event is 'keydown' | 'keyup' => ['keydown', 'keyup'].includes(event); +const isClickEvent = (event: string) => { + return ['contextmenu', 'click', 'mouse'].some((i) => event.includes(i)); +}; + const isListeningForASpecificKeyThatHasntBeenPressed = ( e: Event, modifiers: string[], ) => { let keyModifiers = modifiers.filter( (mod) => - !['window', 'document', 'prevent', 'stop', 'once', 'capture'].includes( - mod, - ), + ![ + 'window', + 'document', + 'prevent', + 'stop', + 'once', + 'capture', + 'self', + 'away', + 'outside', + 'passive', + ].includes(mod), ); if (keyModifiers.includes('debounce')) { @@ -189,6 +244,8 @@ const isListeningForASpecificKeyThatHasntBeenPressed = ( if ( activelyPressedKeyModifiers.length === selectedSystemKeyModifiers.length ) { + // AND the event is a click. It's a pass. + if (isClickEvent(e.type)) return false; // AND the remaining key is pressed as well. It's a press. if (keyToModifiers((e as KeyboardEvent).key).includes(keyModifiers[0])) return false; @@ -227,3 +284,20 @@ const keyToModifiers = (key: string): string[] => { .map(([modifier, keytype]) => (keytype === key ? modifier : false)) .filter((mod: string | false): mod is string => Boolean(mod)); }; + +const modifierOptions = { + dot, + camel, + passive, + capture, + window: hasWindow, + document: hasDocument, + debounce: debounceListener, + throttle: throttleListener, + self, + away: outside, + outside, + once, + prevent: hasPrevent, + stop: hasStop, +}; diff --git a/packages/alpinets/tests/directives/x-on.bench.ts b/packages/alpinets/tests/directives/x-on.bench.ts new file mode 100644 index 0000000..32fc5d1 --- /dev/null +++ b/packages/alpinets/tests/directives/x-on.bench.ts @@ -0,0 +1,87 @@ +import { on } from '../../src/utils/on'; +import { on as oldOn } from '../../src/utils/on-old'; +import { bench } from 'vitest'; + +describe('x-on Handler Creation', () => { + bench( + 'New', + () => { + const el = { + addEventListener() {}, + }; + on( + el as any, + 'click', + ['prevent', 'stop', 'self', 'dot', 'camel', 'once'], + (_e) => {}, + ); + }, + { iterations: 1, time: 160 }, + ); + bench( + 'Old', + () => { + const el = { + addEventListener() {}, + }; + oldOn( + el as any, + 'click', + ['prevent', 'stop', 'self', 'dot', 'camel', 'once'], + (_e) => {}, + ); + }, + { iterations: 1, time: 160 }, + ); +}); +describe('x-on Handler Execution', () => { + const event = { + type: 'click', + target: 'hello', + currentTarget: 'hello', + stopPropagation() {}, + preventDefault() {}, + }; + const oldel = { + handler: null as (e: typeof event) => void | null, + addEventListener(event: string, handler: (e: typeof event) => void) { + this.handler = handler; + }, + removeEventListener() {}, + }; + oldOn( + oldel as any, + 'click', + ['prevent', 'stop', 'self', 'dot', 'camel', 'once'], + (_e) => {}, + ); + + const newel = { + handler: null as (e: typeof event) => void | null, + addEventListener(event: string, handler: (e: typeof event) => void) { + this.handler = handler; + }, + removeEventListener() {}, + }; + on( + newel as any, + 'click', + ['prevent', 'stop', 'self', 'dot', 'camel', 'once'], + (_e) => {}, + ); + + bench( + 'Old', + () => { + oldel.handler!(event); + }, + { iterations: 1, time: 160 }, + ); + bench( + 'New', + () => { + newel.handler!(event); + }, + { iterations: 1, time: 160 }, + ); +}); diff --git a/packages/alpinets/tests/directives/x-on.test.ts b/packages/alpinets/tests/directives/x-on.test.ts index 4a18095..63f82a7 100644 --- a/packages/alpinets/tests/directives/x-on.test.ts +++ b/packages/alpinets/tests/directives/x-on.test.ts @@ -257,7 +257,9 @@ describe('x-on modifiers', () => { expect($('[x-text]').textContent).toBe('0'); await click('button'); expect($('[x-text]').textContent).toBe('0'); - await sleep(500); + await sleep(200); + expect($('[x-text]').textContent).toBe('0'); + await sleep(100); expect($('[x-text]').textContent).toBe('1'); }); it('can be throttled', async () => { @@ -476,6 +478,36 @@ describe('@click modifiers', () => { await click('div'); expect($('span').style.display).toBe('none'); }); + it('allows system key modifiers', async () => { + const { $, click } = await render( + undefined, + ` +
+ > + + +
+ `, + ); + expect($('[x-text]').textContent).toBe('0'); + await click('button', { ctrlKey: true, bubbles: true }); + expect($('[x-text]').textContent).toBe('1'); + await click('button', { shiftKey: true, bubbles: true }); + expect($('[x-text]').textContent).toBe('10'); + await click('button', { metaKey: true, bubbles: true }); + expect($('[x-text]').textContent).toBe('110100'); + await click('button', { altKey: true, bubbles: true }); + expect($('[x-text]').textContent).toBe('1000'); + await click('button', { + ctrlKey: true, + shiftKey: true, + metaKey: true, + altKey: true, + bubbles: true, + }); + expect($('[x-text]').textContent).toBe('111111'); + }); }); describe('@window / @document modifiers', () => { it('listens on the window', async () => { diff --git a/size.json b/size.json index 41c0557..21c4239 100644 --- a/size.json +++ b/size.json @@ -1,12 +1,12 @@ { "alpinets": { "minified": { - "pretty": "39.8 kB", - "raw": 39829 + "pretty": "40.1 kB", + "raw": 40114 }, "brotli": { - "pretty": "13.5 kB", - "raw": 13534 + "pretty": "13.7 kB", + "raw": 13711 } }, "anchor": { diff --git a/test-utils/render.ts b/test-utils/render.ts index 35d786a..4d1b509 100644 --- a/test-utils/render.ts +++ b/test-utils/render.ts @@ -9,6 +9,7 @@ import { IKeyboardEventInit, InputEvent, KeyboardEvent, + MouseEvent, Window, } from 'happy-dom'; @@ -46,10 +47,12 @@ export const render = async ( $: window.document.querySelector.bind(window.document), $$: window.document.querySelectorAll.bind(window.document), happyDOM: window.happyDOM, - click: async (selector: string) => { - ( - window.document.querySelector(selector) as unknown as HTMLElement - ).click(); + click: async (selector: string, options?: MouseEventInit) => { + const target = window.document.querySelector( + selector, + ) as unknown as HTMLElement; + if (!options) target.click(); + else target.dispatchEvent(new MouseEvent('click', options)); await window.happyDOM.whenAsyncComplete(); }, type: async ( @@ -126,7 +129,7 @@ type RenderReturn = { $: typeof window.document.querySelector; $$: typeof window.document.querySelectorAll; happyDOM: Window['happyDOM']; - click: (selector: string) => Promise; + click: (selector: string, options?: MouseEventInit) => Promise; type: ( selector: string, value: string,