Skip to content

Commit

Permalink
feat(event): add @event directive
Browse files Browse the repository at this point in the history
  • Loading branch information
lowlighter committed Oct 8, 2024
1 parent 37d3c49 commit b949dd7
Show file tree
Hide file tree
Showing 8 changed files with 781 additions and 1 deletion.
7 changes: 7 additions & 0 deletions @mizu/event/deno.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@mizu/event",
"version": "0.1.0",
"exports": {
".": "./mod.ts"
}
}
70 changes: 70 additions & 0 deletions @mizu/event/keyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Keyboard expression matcher.
*
* Multiple keys can be combined into a single combination using a plus sign (`+`).
* Multiple combinations can be combined into a single expression using a comma (`,`).
* Spaces are always trimmed.
*
* The following aliases are supported:
* - `alt` when `Alt` is pressed
* - `ctrl` when `Control` is pressed
* - `shift` when `Shift` is pressed
* - `meta` when `Meta` is pressed
* - `space` for `Space` key
* - `key` for any key except `Alt`, `Control`, `Shift` and `Meta`
*
* If the event is not a {@link https://developer.mozilla.org/docs/Web/API/KeyboardEvent | KeyboardEvent}, the function will return `false`.
*
* {@link https://developer.mozilla.org/docs/Web/API/UI_Events/Keyboard_event_key_values | Reference}
*
* @example
* ```ts
* import { Window } from "@mizu/mizu/core/vdom"
* const { KeyboardEvent } = new Window()
*
* const check = keyboard("a,ctrl+b")
* console.assert(check(new KeyboardEvent("keydown", {key: "a"})))
* console.assert(check(new KeyboardEvent("keydown", {key: "b", ctrlKey: true})))
* console.assert(!check(new KeyboardEvent("keydown", {key: "c"})))
* ```
*
* @author Simon Lecoq (lowlighter)
* @license MIT
*/
export function keyboard(keys: string) {
const combinations = keys.split(",").map((combination) => combination.split("+").map((key) => key.trim().toLowerCase()))
return function (event: KeyboardEvent) {
if (!/^key(?:down|press|up)$/.test(event.type)) {
return false
}
return combinations.some((combination) => {
for (const key of combination) {
switch (key) {
case "alt":
case "ctrl":
case "shift":
case "meta":
if (!event[`${key}Key`]) {
return false
}
break
case "space":
if (event.key !== " ") {
return false
}
break
case "key":
if (/^(?:alt|ctrl|shift|meta)$/i.test(event.key)) {
return false
}
break
default:
if (event.key.toLowerCase() !== key) {
return false
}
}
}
return true
})
}
}
56 changes: 56 additions & 0 deletions @mizu/event/keyboard_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test, type testing } from "@libs/testing"
import { Window } from "@mizu/mizu/core/vdom"
import { keyboard } from "./keyboard.ts"
const { KeyboardEvent, MouseEvent } = new Window()

test()(`{@event} keyboard() handles single keys`, () => {
const fn = keyboard("a")
expect(fn(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
expect(fn(new KeyboardEvent("keydown", { key: "b" }))).toBe(false)
})

test()(`{@event} keyboard() handles combination keys`, () => {
for (const key of ["ctrl", "shift", "meta", "alt"] as const) {
const fn = keyboard(`${key}+a`)
expect(fn(new KeyboardEvent("keydown", { key: "a", [`${key}Key`]: true }))).toBe(true)
expect(fn(new KeyboardEvent("keydown", { key: "a" }))).toBe(false)
expect(fn(new KeyboardEvent("keydown", { key: "b", [`${key}Key`]: true }))).toBe(false)
}
})

test()(`{@event} keyboard() handles "key" wildcard`, () => {
const fn = keyboard("key")
expect(fn(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
expect(fn(new KeyboardEvent("keydown", { key: "b" }))).toBe(true)
expect(fn(new KeyboardEvent("keydown", { key: "ctrl" }))).toBe(false)
expect(fn(new KeyboardEvent("keydown", { key: "shift" }))).toBe(false)
expect(fn(new KeyboardEvent("keydown", { key: "meta" }))).toBe(false)
expect(fn(new KeyboardEvent("keydown", { key: "alt" }))).toBe(false)
})

test()(`{@event} keyboard() handles "key" wildcard within combinations`, () => {
const fn = keyboard("alt+key")
expect(fn(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false)
expect(fn(new KeyboardEvent("keydown", { key: "x", altKey: true }))).toBe(true)
expect(fn(new KeyboardEvent("keydown", { key: "x", altKey: false }))).toBe(false)
})

test()(`{@event} keyboard() handles "space" alias`, () => {
const fn = keyboard("space")
expect(fn(new KeyboardEvent("keydown", { key: " " }))).toBe(true)
expect(fn(new KeyboardEvent("keydown", { key: "a" }))).toBe(false)
expect(fn(new KeyboardEvent("keydown", { key: "" }))).toBe(false)
})

test()(`{@event} keyboard() handles multiple combinations`, () => {
const fn = keyboard("a, b, ctrl+c")
expect(fn(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
expect(fn(new KeyboardEvent("keydown", { key: "b" }))).toBe(true)
expect(fn(new KeyboardEvent("keydown", { key: "c" }))).toBe(false)
expect(fn(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(true)
})

test()(`{@event} keyboard() does not react on non KeyboardEvent`, () => {
const fn = keyboard("dead")
expect(fn(new MouseEvent("click") as testing)).toBe(false)
})
101 changes: 101 additions & 0 deletions @mizu/event/mod.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<mizu-directive id="event" directory="event">
<code #name><span class="hljs-keyword">@</span><span class="hljs-meta underline-dotted">event</span><wbr /><span class="muted">="listener"</span></code>
<p #description>
Listen for a dispatched <code><a href="https://developer.mozilla.org/docs/web/api/event">Event</a></code>.
</p>
<code #example *skip>
<button @click="this.value = 'Clicked!'">
<!--Not clicked yet.-->
</button>
</code>
<mizu-note #note>
Multiple listeners can be attached in a single directive using the empty shorthand <code><span class="hljs-keyword">@</span><span class="muted">="object"</span></code>
<i>(e.g. <code>@="{ foo() {}, bar() {} }"</code>)</i>.
<ul>
<li>Modifiers are applied to all specified listeners in the directive <i>(e.g. <code>@.prevent="{}"</code>)</i>.</li>
<li>Tags may be specified to use this syntax multiple times which can be useful to attach listeners with different modifiers <i>(e.g. <code>@[1]="{}" @[2].prevent="{}"</code>)</i>.</li>
<li>As HTML attributes are case-insensitive, it is currently the only way to listen for events with uppercase letters or illegal attribute characters <i>(e.g. <code>@="{ FooBar() {}, Foobar() {} }"</code>)</i>.</li>
</ul>
</mizu-note>
<mizu-note #note>
To listen for events with dots <code>.</code> in their names, surround them by brackets <code>{</code>
<code>}</code>
<i>(e.g. <code>@{my.event}</code>)</i>.
</mizu-note>
<mizu-variable #variable name="$event">
<span #type>Event</span>
<i class="muted">(in <code>listener</code> only)</i> The dispatched <a href="https://developer.mozilla.org/docs/web/api/event"><code>Event</code></a>.
</mizu-variable>
<mizu-modifier #modifier>
Optional tag that can be used to attach multiple listeners to the same event <i>(e.g. <code>@click[1]</code>, <code>@click[2]</code>, etc.)</i>.
</mizu-modifier>
<mizu-modifier #modifier name="prevent">
Call <a href="https://developer.mozilla.org/docs/Web/API/Event/preventDefault"><code>event.preventDefault()</code></a> when triggered.
</mizu-modifier>
<mizu-modifier #modifier name="stop">
Call <a href="https://developer.mozilla.org/docs/Web/API/Event/stopPropagation"><code>event.stopPropagation()</code></a> when triggered.
</mizu-modifier>
<mizu-modifier #modifier name="once">
Register listener with <a href="https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener#once"><code>{ once: true }</code></a>.
</mizu-modifier>
<mizu-modifier #modifier name="passive">
Register listener with <a href="https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener#passive"><code>{ passive: true }</code></a>.
</mizu-modifier>
<mizu-modifier #modifier name="capture">
Register listener with <a href="https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener#capture"><code>{ capture: true }</code></a>.
</mizu-modifier>
<mizu-modifier #modifier name="self">
Listener is triggered only if <a href="https://developer.mozilla.org/docs/Web/API/Event/target"><code>event.target</code></a> is the element itself.
</mizu-modifier>
<mizu-modifier #modifier name="attach">
Change where the listener is attached to (using <a href="https://developer.mozilla.org/docs/Web/API/Window"><code>window</code></a> or <a href="https://developer.mozilla.org/docs/Web/API/Document"><code>document</code></a> can be useful to create global listeners).
</mizu-modifier>
<mizu-modifier #modifier name="throttle">
Prevent listener from being called more than once during the specified time frame.
</mizu-modifier>
<mizu-modifier #modifier name="debounce">
Prevent listener from executing until the specified time frame has passed without any activity.
</mizu-modifier>
<mizu-modifier #modifier name="keys">
Specify which keys must be pressed for the listener to trigger when receiving a <a href="https://developer.mozilla.org/docs/Web/API/KeyboardEvent"><code>KeyboardEvent</code></a>.
<ul>
<li>
The syntax for keys constraints is defined as follows:
<ul>
<li>Expression is case-insensitive.</li>
<li>
A combination can be defined using a <q>plus sign <code>+</code></q> between each key <i>(e.g. <code>@keypress.keys[ctrl+space]</code>)</i>.
</li>
<li>
Multiple key combinations can be specified by separating them with a <q>comma <code>,</code></q>
<i>(e.g. <code>@keypress.keys[ctrl+space,shift+space]</code>)</i>.
</li>
</ul>
</li>
<li>
The following keys and aliases are supported:
<ul>
<li>
<code>alt</code> for <code>"Alt"</code>.
</li>
<li>
<code>ctrl</code> for <code>"Control"</code>.
</li>
<li>
<code>shift</code> for <code>"Shift"</code>.
</li>
<li>
<code>meta</code> for <code>"Meta"</code>.
</li>
<li>
<code>space</code> for <code>" "</code>.
</li>
<li>
<code>key</code> for any key except <code>"Alt"</code>, <code>"Control"</code>, <code>"Shift"</code> and <code>"Meta"</code>.
</li>
<li>Any value possibly returned by <a href="https://developer.mozilla.org/docs/Web/API/UI_Events/Keyboard_event_key_values"><code>event.key</code></a>.</li>
</ul>
</li>
</ul>
</mizu-modifier>
</mizu-directive>
161 changes: 161 additions & 0 deletions @mizu/event/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Imports
import { type Cache, type callback, type Directive, Phase } from "@mizu/mizu/core/engine"
import { keyboard } from "./keyboard.ts"
export type * from "@mizu/mizu/core/engine"

/** `@event` typings. */
export const typings = {
modifiers: {
throttle: { type: Date, default: 250 },
debounce: { type: Date, default: 250 },
keys: { type: String },
prevent: { type: Boolean },
stop: { type: Boolean },
self: { type: Boolean },
attach: { type: String, allowed: ["element", "window", "document"] },
passive: { type: Boolean },
once: { type: Boolean },
capture: { type: Boolean },
},
} as const

/**
* `@event` directive.
*
* @internal `_event` Force the directive to use specified event name rather than the attribute name, provided that a single attribute was passed.
* @internal `_callback` Force the directive to use specified callback rather than the attribute value.
*/
export const _event = {
name: /^@(?<event>)/,
prefix: "@",
phase: Phase.INTERACTIVITY,
default: "null",
typings,
multiple: true,
init(renderer) {
if (!renderer.cache(this.name)) {
renderer.cache<Cache<typeof _event>>(this.name, new WeakMap())
}
},
async execute(renderer, element, { cache, attributes, context, state }) {
if (renderer.isComment(element)) {
return
}
const parsed = attributes.map((attribute) => renderer.parseAttribute(attribute, this.typings, { prefix: this.prefix, modifiers: true }))
if ((arguments[2]._event) && (arguments[2].attributes.length === 1)) {
parsed[0].name = arguments[2]._event
}

// Handle shorthand listeners attachment
const shorthands = parsed.filter(({ name }) => !name.length)
if (shorthands.length) {
for (const shorthand of shorthands) {
const [attribute] = parsed.splice(parsed.indexOf(shorthand), 1)
const value = await renderer.evaluate(element, attribute.value, { context, state })
if (typeof value === "object") {
parsed.unshift(...Object.entries(value ?? {}).map(([name, value]) => ({ ...attribute, name, value })))
} else {
renderer.warn(`[${this.name}] empty shorthand expects an object but got ${typeof value}, ignoring`, element)
}
}
}

// Attach listeners
for (const { name: event, value: expression, modifiers, attribute } of parsed) {
// Ensure listener is not duplicated
if (!cache.has(element)) {
cache.set(element, new WeakMap())
}
if (!cache.get(element)!.has(attribute)) {
cache.get(element)!.set(attribute, new Map())
}
if (cache.get(element)!.get(attribute)!.has(event)) {
continue
}

// Create callback
const _callback = arguments[2]._callback
let callback = function (event: Event) {
// Ignore and remove expired listeners
if (!element.hasAttribute(attribute.name)) {
const registered = cache.get(element)?.get(attribute)?.get(event.type)
if (registered) {
registered.target.removeEventListener(event.type, registered.listener)
}
cache.get(element)?.get(attribute)?.delete(event.type)
if (!cache.get(element)?.get(attribute)?.size) {
cache.get(element)?.delete(attribute)
}
return
}
// Execute callback
if (_callback) {
_callback(event, { attribute, expression })
return
}
if (typeof (expression as string | callback) === "function") {
;(expression as unknown as callback)(event)
return
}
renderer.evaluate(element, `${expression || _event.default}`, { context, state: { ...state, $event: event }, args: [event] })
} as callback
// Apply keyboard modifiers to callback
if (modifiers.keys) {
const check = keyboard(modifiers.keys)
callback = ((callback) =>
function (event: KeyboardEvent) {
return check(event) ? callback(...arguments) : false
})(callback)
}
// Apply throttle modifier to callback
if (modifiers.throttle) {
let throttled = false
callback = ((callback) =>
function () {
if (throttled) {
return false
}
throttled = true
try {
return callback(...arguments)
} finally {
setTimeout(() => throttled = false, modifiers.throttle)
}
})(callback)
}
// Apply debounce modifier to callback
if (modifiers.debounce) {
let timeout = NaN
callback = ((callback) =>
function () {
const args = arguments
clearTimeout(timeout)
timeout = setTimeout(() => callback(...args), modifiers.debounce)
return false
})(callback)
}

// Create listener
const listener = function (event: Event) {
if (modifiers.prevent) {
event.preventDefault()
}
if (modifiers.stop) {
event.stopPropagation()
}
if (modifiers.self && event.target !== element) {
return
}
return callback(event)
}

// Attach listener
const target = { window: renderer.window, document: renderer.document }[modifiers.attach as string] ?? element
target.addEventListener(event, listener, { passive: modifiers.passive, once: modifiers.once, capture: modifiers.capture })
cache.get(element)?.get(attribute)?.set(event, { target, listener })
}
},
} as Directive<WeakMap<HTMLElement, WeakMap<Attr, Map<string, { target: EventTarget; listener: EventListener }>>>, typeof typings> & { typings: typeof typings; execute: NonNullable<Directive["execute"]> }

/** Default exports. */
export default _event
Loading

0 comments on commit b949dd7

Please sign in to comment.