All files / model / mod.ts

100.00% Branches 57/57
100.00% Lines 146/146
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
 
x4
x4
x4
 
 
 
x4
x4
x20
x12
x12
x16
x16
x12
x12
x12
x12
x12
x4
x4
 
 
x4
x4
x4
x4
x4
x4
x40
x40
x40
x4
x4
x62
x63
x63
x476
x1014
x62
x62
 
 
x62
x89
x146
x146
x146
 
x146
x152
x152
 
x146
x179
x179
 
x297
x151
x151
x154
x162
x154
x154
x154
x151
 
 
x146
x161
x175
x175
x161
x146
x89
x89
 
 
x62
x89
x118
x118
x118
 
x248
x130
x130
x132
x135
x137
x137
x135
x130
x140
x140
x130
x141
x141
x131
x131
x131
 
x118
x120
x121
x121
x120
 
x118
x122
x118
x952
x408
x89
x89
 
 
x62
x89
 
x89
x90
x90
 
x89
x630
x90
 
x712
x89
 
x119
x62
x4
 
 
x12
 
 
x4
x91
x91
x193
x102
x102
x102
x106
x106
x102
x102
x91
x167
x167
x91
x91
x91
 
 
x4
x300
x197
x201
x201
x197
x207
x207
x197
x205
x205
x197
x200
x200
x269
x100
x100
x100













































































































































































// Imports
import { type Arg, type Arrayable, type Cache, type callback, type Directive, type Modifiers, 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_value = {
  name: /^::(?<value>)$/,
  prefix: "::",
  phase: Phase.ATTRIBUTE_MODEL_VALUE,
  typings,
  init(renderer) {
    if (!renderer.cache(this.name)) {
      renderer.cache<Cache<typeof _model_value>>(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

    // Setup model sync callback (model > value)
    if (!cached.model.sync) {
      cached.model.sync = async function () {
        const model = await renderer.evaluate(input, attribute.value, 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, 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}=${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)
      }
      // Auto-initialize value if missing
      if (parsed.modifiers.value && (input.getAttribute("value"))) {
        await renderer.evaluate(input, `${attribute.value}??=${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 Directive<WeakMap<HTMLElement, WeakMap<HTMLElement, { model: Record<"read" | "sync", Nullable<callback>>; event: Nullable<string>; init: boolean }>>, typeof typings>

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

/** 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: Modifiers<typeof _model_value>) {
  const parsed = [value].flat().map((value) => {
    if ((modifiers.nullish) && (!value)) {
      return null
    }
    if (modifiers.number) {
      return Number(value)
    }
    if (modifiers.boolean) {
      return !(value.length && /^(?:[Ff]alse|FALSE|[Nn]o|NO|[Oo]ff|OFF)$/.test(value))
    }
    if (modifiers.string) {
      return `${value}`
    }
    return value
  })
  return Array.isArray(value) ? parsed : parsed[0]
}