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
x75
x76
x76
x720
x75
x106
x106
 
 
x143
x75
x84
x94
x376
x94
x557
x94
x95
x95
x94
x84
 
 
x75
 
x153
x183
x183
x153
x216
x216
x153
x164
x164
 
 
x220
x220
 
x290
x292
x292
x292
x292
x292
x292
x292
x292
x292
x292
 
x290
x1308
x327
x327
x324
x332
x332
x332
x2610
x220
 
x153
x155
x155
x155
x162
x155
x155
 
x153
x154
x154
x154
x205
x252
x252
x208
x208
x208
x208
x208
x208
x154
x154
 
x153
x154
x154
x154
x170
x170
x170
x170
x154
x154
 
 
x288
x423
x427
x427
x423
x424
x424
x423
x424
x424
x557
x288
 
 
x1017
x765
x612
x153
x75
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