All files / model / mod.ts

98.41% Branches 62/63
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
 
x4
x4
x4
 
 
 
x4
x4
x20
x12
x12
x16
x16
x12
x12
x12
x12
x12
x4
x4
 
 
x4
x4
x4
x4
x4
x4
x4
x42
x42
x42
x4
x4
x64
x65
x65
x492
x1058
x64
x64
x64
 
 
x64
x93
x152
x152
x152
 
x152
x158
x158
 
x152
x185
x185
 
x309
x157
x157
x160
x168
x160
x160
x160
x157
 
 
x152
x169
x185
x185
x169
x152
x93
x93
 
 
x64
x93
x123
x123
x123
 
x258
x135
x135
x137
x140
x142
x142
x140
x135
x145
x145
x135
x146
x146
x136
x136
x136
 
x123
x125
x126
x126
x125
 
x123
x127
x123
x861
x426
x93
x93
 
 
x64
x93
 
x93
x95
x95
 
x93
x564
x94
 
x744
x93
 
x123
x64
x4
 
 
x12
 
 
x4
x94
x94
x199
x105
x105
x105
x109
x109
x105
x105
x94
x173
x173
x94
x94
x94
 
 
x4
x309
x203
x207
x207
x203
x213
x213
x203
x211
x211
x203
x206
x206
x278
x103
x103
x103





























































































































I

















































// 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 = {
  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 Directive<WeakMap<HTMLElement, WeakMap<HTMLElement, { model: Record<"read" | "sync", Nullable<callback>>; event: Nullable<string>; init: boolean }>>, typeof typings> & { default: string }

/** 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: Modifiers<typeof _model>) {
  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]
}