All files / event / mod.ts

100.00% Branches 44/44
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
 
x6
x6
 
 
 
x6
x6
x24
x24
x18
x18
x18
x18
x42
x18
x18
x18
x6
x6
 
 
 
 
 
 
 
x6
x6
x6
x6
x6
x6
x6
x6
x63
x63
x63
x6
x6
x73
x74
x74
x700
x73
x102
x102
 
 
x139
x73
x82
x92
x368
x92
x547
x92
x93
x93
x92
x82
 
 
x73
 
x149
x179
x179
x149
x210
x210
x149
x160
x160
 
 
x214
x214
 
x283
x285
x285
x285
x285
x285
x285
x285
x285
x285
x285
 
x283
x1276
x319
x319
x317
x325
x325
x325
x2547
x214
 
x149
x151
x151
x151
x158
x151
x151
 
x149
x150
x150
x150
x201
x248
x248
x204
x204
x204
x204
x204
x204
x150
x150
 
x149
x150
x150
x150
x166
x166
x166
x166
x150
x150
 
 
x280
x414
x418
x418
x414
x415
x415
x414
x415
x415
x547
x280
 
 
x989
x745
x596
x149
x73
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 _event>>(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 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