All files / event / mod.ts

100.00% Branches 74/74
100.00% Functions 4/4
100.00% Lines 132/132
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
 
x6
x6
 
 
 
x6
x6
x6
x6
x6
x6
x6
x6
x6
x6
x6
x6
x6
x6
 
 
 
 
 
 
 
x6
x6
x6
x6
x6
x6
x6
x6
x57
x57
x57
x6
x6
x69
x1
x1
x74
x69
x31
x31
 
 
x68
x69
x9
x10
x10
x10
x14
x10
x1
x1
x10
x9
 
 
x69
 
x78
x30
x30
x78
x63
x63
x78
x11
x11
 
 
x67
x67
 
x70
x2
x2
x2
x2
x2
x2
x2
x2
x2
x2
 
x70
x37
x37
x37
x34
x8
x8
x8
x70
x67
 
x78
x2
x2
x2
x5
x2
x2
 
x78
x1
x1
x1
x50
x47
x47
x3
x3
x3
x3
x3
x3
x1
x1
 
x78
x1
x1
x1
x15
x15
x15
x15
x1
x1
 
 
x67
x135
x4
x4
x135
x1
x1
x135
x1
x1
x134
x67
 
 
x78
x78
x78
x78
x69
x6
 
 
 
 
 
 
 
x6



































































































































































// Imports
import { type Cache, type Callback, type Directive, Phase } from "@mizu/internal/engine"
import { keyboard } from "./keyboard.ts"
export type * from "@mizu/internal/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 this>>(this.name, new WeakMap())
    }
  },
  async execute(renderer, element, { cache, attributes, context, state }) {
    if (!renderer.isHtmlElement(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 const satisfies Directive<{
  Name: RegExp
  Cache: WeakMap<HTMLElement, WeakMap<Attr, Map<string, { target: EventTarget; listener: EventListener }>>>
  Typings: typeof typings
  Default: true
}>

/** Default exports. */
export default _event