-
-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
37d3c49
commit b949dd7
Showing
8 changed files
with
781 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"name": "@mizu/event", | ||
"version": "0.1.0", | ||
"exports": { | ||
".": "./mod.ts" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.