All files / model / mod.ts

99.01% Branches 100/101
100.00% Functions 6/6
100.00% Lines 148/148
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
 
x4
x4
x4
 
 
 
x4
x4
x4
x4
x4
x4
x4
x4
x4
x4
x4
x4
x4
x4
 
 
x4
x4
x4
x4
x4
x4
x4
x38
x38
x38
x4
x4
x60
x1
x1
x59
x60
x60
x60
x60
 
 
x60
x29
x59
x59
x59
 
x59
x6
x6
 
x59
x33
x33
 
x59
x5
x5
x3
x8
x3
x3
x3
x5
 
 
x59
x17
x16
x16
x17
x59
x29
x29
 
 
x60
x29
x30
x30
x30
 
x30
x12
x12
x2
x3
x2
x2
x3
x12
x10
x10
x12
x11
x11
x1
x1
x1
 
x30
x2
x1
x1
x2
 
x30
x4
x30
x30
x19
x29
x29
 
 
x60
x29
 
x29
x2
x2
 
x29
x1
x1
 
x29
x29
 
x59
x60
x4
 
 
 
 
 
 
 
x4
 
 
x4
x90
x90
x90
x11
x11
x11
x4
x4
x11
x11
x90
x79
x79
x90
x90
x90
 
 
x4
x99
x100
x4
x4
x100
x10
x10
x100
x8
x8
x100
x3
x3
x75
x99
x99
x99





























































































































I






















































// Imports
import { type Arg, type Arrayable, type Cache, type Callback, type Directive, type InferAttrTypings, type Nullable, Phase } from "@mizu/internal/engine"
import { equal } from "@std/assert"
import { _event } from "@mizu/event"
export type * from "@mizu/internal/engine"

/** `::value` typings. */
export const typings = {
  modifiers: {
    event: { type: String, default: "input", enforce: true },
    name: { type: Boolean },
    value: { type: Boolean },
    throttle: { type: Date, default: 250 },
    debounce: { type: Date, default: 250 },
    keys: { type: String },
    nullish: { type: Boolean },
    boolean: { type: Boolean },
    number: { type: Boolean },
    string: { type: Boolean },
  },
} as const

/** `::value` directive. */
export const _model = {
  name: /^::(?<value>)$/,
  prefix: "::",
  phase: Phase.ATTRIBUTE_MODEL_VALUE,
  typings,
  default: "value",
  init(renderer) {
    if (!renderer.cache(this.name)) {
      renderer.cache<Cache<typeof _model>>(this.name, new WeakMap())
    }
  },
  async execute(renderer, element, { attributes: [attribute], cache, ...options }) {
    if (!renderer.isHtmlElement(element)) {
      return
    }
    const parsed = renderer.parseAttribute(attribute, this.typings, { prefix: this.prefix, modifiers: true })
    const cached = cache.get(element)?.get(element) || cache.set(element, new WeakMap([[element, { model: { read: null, sync: null }, event: null, init: false }]])).get(element)!.get(element)!
    const input = element as HTMLInputElement
    const type = input.tagName === "INPUT" ? input.getAttribute("type") : input.tagName === "SELECT" ? input.tagName : null
    const defaults = this.default

    // Setup model sync callback (model > value)
    if (!cached.model.sync) {
      cached.model.sync = async function () {
        const model = await renderer.evaluate(input, attribute.value || defaults, options)
        const value = parse(read(input), parsed.modifiers)
        switch (type) {
          // Radio: checked if model is equal to value
          case "radio":
            input.checked = equal(model, value)
            break
          // Checkbox: checked if model includes value
          case "checkbox":
            input.checked = Array.isArray(model) ? model.includes(value) : false
            break
          // Select: special handling for multiple select
          case "SELECT": {
            const select = element as HTMLSelectElement
            if (select.multiple) {
              Array.from(select.options).forEach((option) => {
                option.selected = Array.isArray(model) ? model.includes(parse(option.value, parsed.modifiers)) : false
              })
              break
            }
          }
          // falls through
          // Default: sync value with model if different (defaults to "")
          default:
            if (!equal(model, value)) {
              input.value = `${model ?? ""}`
            }
            break
        }
      }
    }

    // Setup model read callback (value > model)
    if (!cached.model.read) {
      cached.model.read = async function () {
        const model = await renderer.evaluate(input, attribute.value || defaults, options)
        let value = parse(read(input), parsed.modifiers)
        switch (type) {
          // Checkbox: toggle value in model
          case "checkbox": {
            const array = Array.isArray(model) ? model : []
            if (!input.checked) {
              for (let i = array.length - 1; i >= 0; i--) {
                if (array[i] === value) {
                  array.splice(i, 1)
                }
              }
            } else if (!array.includes(value)) {
              array.push(value)
            }
            if (array === model) {
              return
            }
            value = array
            break
          }
          // Radio: clean value if last value unchecked
          case "radio":
            if ((equal(model, value)) && (!input.checked)) {
              value = undefined as unknown as typeof value
            }
            break
          // Number: convert value to number
          case "number":
            value = parse(Number(value) as unknown as Arg<typeof parse>, parsed.modifiers)
        }
        await renderer.evaluate(input, `${attribute.value || defaults}=${renderer.internal("value")}`, { ...options, state: { ...options.state, [renderer.internal("value")]: value } })
        input.dispatchEvent(new renderer.window.Event("::", { bubbles: true }))
      }
    }

    // Prevent initialization from running multiple times
    if (!cached.init) {
      cached.init = true
      // Auto-assign name attribute if missing
      if (parsed.modifiers.name && (!input.hasAttribute("name"))) {
        input.setAttribute("name", attribute.value || defaults)
      }
      // Auto-initialize value if missing
      if (parsed.modifiers.value && (input.getAttribute("value"))) {
        await renderer.evaluate(input, `${attribute.value || defaults}??=${renderer.internal("value")}`, { ...options, state: { ...options.state, [renderer.internal("value")]: parse(read(input), parsed.modifiers) } })
      }
      // Setup event listener
      await _event.execute.call(this, renderer, element, { ...arguments[2], attributes: [attribute], _event: parsed.modifiers.event, _callback: cached.model.read })
    }

    await cached.model.sync()
  },
} as const satisfies Directive<{
  Name: RegExp
  Cache: WeakMap<HTMLElement, WeakMap<HTMLElement, { model: Record<"read" | "sync", Nullable<Callback>>; event: Nullable<string>; init: boolean }>>
  Typings: typeof typings
  Default: true
}>

/** Default exports. */
export default [_model]

/** Read input value. */
function read(input: HTMLElement) {
  let value = null as Nullable<Arrayable<string>>
  switch (input.tagName) {
    case "SELECT": {
      const select = input as HTMLSelectElement
      value = Array.from(select.selectedOptions).map((option) => option.value)
      if (!select.multiple) {
        value = (value as string[])[0]
      }
      break
    }
    default:
      value = (input as HTMLInputElement).value
      break
  }
  return value
}

/** Parse input value. */
function parse(value: ReturnType<typeof read>, modifiers: InferAttrTypings<typeof typings>["modifiers"]) {
  const parsed = [value].flat().map((value) => {
    if ((modifiers.nullish) && (!value)) {
      return null
    }
    if (modifiers.number) {
      return Number(value)
    }
    if (modifiers.boolean) {
      return Boolean(value) && !(value.length && /^(?:[Ff]alse|FALSE|[Nn]|[Nn]o|NO|[Oo]ff|OFF)$/.test(value))
    }
    if (modifiers.string) {
      return String(value)
    }
    return value
  })
  return Array.isArray(value) ? parsed : parsed[0]
}