@thomas-siegfried/tapout 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1205 -1205
  3. package/package.json +55 -55
  4. package/src/applyBindings.ts +372 -372
  5. package/src/arrayToDomMapping.ts +300 -300
  6. package/src/attributeInterpolationMarkup.ts +91 -91
  7. package/src/bindingContext.ts +207 -207
  8. package/src/bindingEvent.ts +198 -198
  9. package/src/bindingProvider.ts +260 -260
  10. package/src/bindings.ts +963 -963
  11. package/src/compareArrays.ts +122 -122
  12. package/src/componentBinding.ts +318 -318
  13. package/src/componentDecorator.ts +62 -62
  14. package/src/components.ts +367 -367
  15. package/src/computed.ts +439 -439
  16. package/src/configure.ts +52 -52
  17. package/src/controlFlowBindings.ts +206 -206
  18. package/src/core.ts +60 -60
  19. package/src/decorators.ts +407 -407
  20. package/src/dependencyDetection.ts +76 -76
  21. package/src/disposable.ts +36 -36
  22. package/src/domData.ts +42 -42
  23. package/src/domNodeDisposal.ts +94 -94
  24. package/src/effects.ts +29 -29
  25. package/src/event.ts +173 -173
  26. package/src/expressionRewriting.ts +219 -219
  27. package/src/extenders.ts +102 -102
  28. package/src/filters.ts +91 -91
  29. package/src/index.ts +150 -150
  30. package/src/interpolationMarkup.ts +130 -130
  31. package/src/memoization.ts +71 -71
  32. package/src/namespacedBindings.ts +132 -132
  33. package/src/observable.ts +48 -48
  34. package/src/observableArray.ts +397 -397
  35. package/src/options.ts +25 -25
  36. package/src/selectExtensions.ts +68 -68
  37. package/src/slotBinding.ts +84 -84
  38. package/src/subscribable.ts +266 -266
  39. package/src/tasks.ts +79 -79
  40. package/src/templateEngine.ts +108 -108
  41. package/src/templateRendering.ts +399 -399
  42. package/src/templateRewriting.ts +94 -94
  43. package/src/templateSources.ts +134 -134
  44. package/src/utils.ts +123 -123
  45. package/src/utilsDom.ts +87 -87
  46. package/src/virtualElements.ts +153 -153
  47. package/src/wireParams.ts +49 -49
package/src/bindings.ts CHANGED
@@ -1,963 +1,963 @@
1
- import { unwrapObservable } from './utils.js';
2
- import { parseHtmlFragment } from './utilsDom.js';
3
- import { bindingHandlers } from './bindingProvider.js';
4
- import type { BindingHandler, AllBindingsAccessor } from './bindingProvider.js';
5
- import { allowedVirtualElementBindings, virtualFirstChild, virtualNextSibling, virtualSetChildren, virtualEmptyNode } from './virtualElements.js';
6
- import { addDisposeCallback, removeNode } from './domNodeDisposal.js';
7
- import { twoWayBindings, writeValueToProperty } from './expressionRewriting.js';
8
- import { Computed, PureComputed } from './computed.js';
9
- import { Observable } from './observable.js';
10
- import { isObservableArray } from './observableArray.js';
11
- import { ignore, isInitial, getDependenciesCount } from './dependencyDetection.js';
12
- import { bindingEvent } from './bindingEvent.js';
13
- import * as selectExtensions from './selectExtensions.js';
14
-
15
- // ---- Shared DOM Utilities ----
16
-
17
- function triggerEvent(element: Node, eventType: string): void {
18
- const EventCtor = ((element.ownerDocument?.defaultView) as unknown as typeof globalThis)?.Event ?? Event;
19
- element.dispatchEvent(new EventCtor(eventType, { bubbles: true, cancelable: true }));
20
- }
21
-
22
-
23
- function setTextContent(element: Node, textContent: unknown): void {
24
- let value = unwrapObservable(textContent);
25
- if (value === null || value === undefined) value = '';
26
-
27
- const innerTextNode = virtualFirstChild(element);
28
- if (!innerTextNode || innerTextNode.nodeType !== 3 || virtualNextSibling(innerTextNode)) {
29
- const doc = (element as Element).ownerDocument ?? (element as Document);
30
- virtualSetChildren(element, [doc.createTextNode(String(value))]);
31
- } else {
32
- (innerTextNode as Text).data = String(value);
33
- }
34
- }
35
-
36
- function emptyDomNode(node: Node): void {
37
- while (node.firstChild) {
38
- removeNode(node.firstChild);
39
- }
40
- }
41
-
42
- function setHtml(node: Node, html: unknown): void {
43
- html = unwrapObservable(html);
44
-
45
- if (node.nodeType === 8) {
46
- if (html != null) {
47
- const parsedNodes = parseHtmlFragment('' + html, node.ownerDocument ?? undefined);
48
- virtualSetChildren(node, parsedNodes);
49
- } else {
50
- virtualEmptyNode(node);
51
- }
52
- } else {
53
- emptyDomNode(node);
54
- if (html === null || html === undefined) return;
55
- const el = node as Element;
56
- el.innerHTML = typeof html === 'string' ? html : String(html);
57
- }
58
- }
59
-
60
- const CSS_CLASS_REGEX = /\S+/g;
61
- const CLASSES_WRITTEN_KEY = '__tapout__cssValue';
62
-
63
- function toggleDomNodeCssClass(node: Element, classNames: string | null | undefined, shouldHaveClass: boolean): void {
64
- if (!classNames) return;
65
-
66
- const matches = classNames.match(CSS_CLASS_REGEX);
67
- if (!matches) return;
68
-
69
- if (typeof node.classList === 'object') {
70
- const fn = shouldHaveClass ? 'add' : 'remove';
71
- for (const cls of matches) {
72
- node.classList[fn](cls);
73
- }
74
- } else if (typeof (node.className as unknown as { baseVal?: string })?.baseVal === 'string') {
75
- // SVG element
76
- toggleClassPropertyString(node.className as unknown as { baseVal: string }, 'baseVal', matches, shouldHaveClass);
77
- } else {
78
- toggleClassPropertyString(node as unknown as Record<string, string>, 'className', matches, shouldHaveClass);
79
- }
80
- }
81
-
82
- function toggleClassPropertyString(
83
- obj: Record<string, string>,
84
- prop: string,
85
- classNames: string[],
86
- shouldHaveClass: boolean,
87
- ): void {
88
- const current: string[] = (obj[prop] || '').match(CSS_CLASS_REGEX)?.slice() ?? [];
89
- for (const cls of classNames) {
90
- const idx = current.indexOf(cls);
91
- if (shouldHaveClass && idx === -1) {
92
- current.push(cls);
93
- } else if (!shouldHaveClass && idx !== -1) {
94
- current.splice(idx, 1);
95
- }
96
- }
97
- obj[prop] = current.join(' ');
98
- }
99
-
100
- function setElementName(element: Element, name: string): void {
101
- (element as HTMLInputElement).name = name;
102
- }
103
-
104
- // ---- Task 17: One-way display bindings ----
105
-
106
- const textHandler: BindingHandler = {
107
- init() {
108
- return { controlsDescendantBindings: true };
109
- },
110
- update(node, valueAccessor) {
111
- setTextContent(node, valueAccessor());
112
- },
113
- };
114
-
115
- const htmlHandler: BindingHandler = {
116
- init() {
117
- return { controlsDescendantBindings: true };
118
- },
119
- update(node, valueAccessor) {
120
- setHtml(node, valueAccessor());
121
- },
122
- };
123
-
124
- const visibleHandler: BindingHandler = {
125
- update(node, valueAccessor) {
126
- const el = node as HTMLElement;
127
- const value = unwrapObservable(valueAccessor());
128
- const isCurrentlyVisible = el.style.display !== 'none';
129
- if (value && !isCurrentlyVisible) {
130
- el.style.display = '';
131
- } else if (!value && isCurrentlyVisible) {
132
- el.style.display = 'none';
133
- }
134
- },
135
- };
136
-
137
- const hiddenHandler: BindingHandler = {
138
- update(node, valueAccessor) {
139
- visibleHandler.update!(node, () => !unwrapObservable(valueAccessor()), {} as never, undefined, {} as never);
140
- },
141
- };
142
-
143
- // ---- Task 18: Attribute bindings ----
144
-
145
- const attrHandler: BindingHandler = {
146
- update(node, valueAccessor) {
147
- const el = node as Element;
148
- const value = unwrapObservable(valueAccessor()) as Record<string, unknown> | null;
149
- if (!value) return;
150
-
151
- for (const attrName of Object.keys(value)) {
152
- let attrValue = unwrapObservable(value[attrName]);
153
-
154
- const prefixLen = attrName.indexOf(':');
155
- const namespace = 'lookupNamespaceURI' in el && prefixLen > 0
156
- ? (el as Element).lookupNamespaceURI(attrName.substring(0, prefixLen))
157
- : null;
158
-
159
- const toRemove = attrValue === false || attrValue === null || attrValue === undefined;
160
-
161
- if (toRemove) {
162
- namespace
163
- ? el.removeAttributeNS(namespace, attrName)
164
- : el.removeAttribute(attrName);
165
- } else {
166
- const strValue = String(attrValue);
167
- namespace
168
- ? el.setAttributeNS(namespace, attrName, strValue)
169
- : el.setAttribute(attrName, strValue);
170
- }
171
-
172
- if (attrName === 'name') {
173
- setElementName(el, toRemove ? '' : String(attrValue));
174
- }
175
- }
176
- },
177
- };
178
-
179
- const classHandler: BindingHandler = {
180
- update(node, valueAccessor) {
181
- const el = node as Element;
182
- const value = String(unwrapObservable(valueAccessor()) ?? '').trim();
183
- const previousValue = (el as unknown as Record<string, string>)[CLASSES_WRITTEN_KEY];
184
- toggleDomNodeCssClass(el, previousValue, false);
185
- (el as unknown as Record<string, string>)[CLASSES_WRITTEN_KEY] = value;
186
- toggleDomNodeCssClass(el, value, true);
187
- },
188
- };
189
-
190
- const cssHandler: BindingHandler = {
191
- update(node, valueAccessor) {
192
- const el = node as Element;
193
- const value = unwrapObservable(valueAccessor());
194
- if (value !== null && typeof value === 'object') {
195
- for (const className of Object.keys(value as Record<string, unknown>)) {
196
- const shouldHaveClass = !!unwrapObservable((value as Record<string, unknown>)[className]);
197
- toggleDomNodeCssClass(el, className, shouldHaveClass);
198
- }
199
- } else {
200
- classHandler.update!(node, valueAccessor, {} as never, undefined, {} as never);
201
- }
202
- },
203
- };
204
-
205
- const styleHandler: BindingHandler = {
206
- update(node, valueAccessor) {
207
- const el = node as HTMLElement;
208
- const value = unwrapObservable(valueAccessor() || {}) as Record<string, unknown>;
209
-
210
- for (const styleName of Object.keys(value)) {
211
- let styleValue = unwrapObservable(value[styleName]);
212
-
213
- if (styleValue === null || styleValue === undefined || styleValue === false) {
214
- styleValue = '';
215
- }
216
-
217
- if (/^--/.test(styleName)) {
218
- el.style.setProperty(styleName, String(styleValue));
219
- } else {
220
- const camelCased = styleName.replace(/-(\w)/g, (_all, letter: string) => letter.toUpperCase());
221
-
222
- const previousStyle = (el.style as unknown as Record<string, string>)[camelCased];
223
- (el.style as unknown as Record<string, string>)[camelCased] = String(styleValue);
224
-
225
- if (
226
- String(styleValue) !== previousStyle &&
227
- (el.style as unknown as Record<string, string>)[camelCased] === previousStyle &&
228
- !isNaN(styleValue as number)
229
- ) {
230
- (el.style as unknown as Record<string, string>)[camelCased] = styleValue + 'px';
231
- }
232
- }
233
- }
234
- },
235
- };
236
-
237
- // ---- Task 19: Form state bindings ----
238
-
239
- const enableHandler: BindingHandler = {
240
- update(node, valueAccessor) {
241
- const el = node as HTMLElement;
242
- const value = unwrapObservable(valueAccessor());
243
- if (value && (el as HTMLInputElement).disabled) {
244
- el.removeAttribute('disabled');
245
- } else if (!value && !(el as HTMLInputElement).disabled) {
246
- (el as HTMLInputElement).disabled = true;
247
- }
248
- },
249
- };
250
-
251
- const disableHandler: BindingHandler = {
252
- update(node, valueAccessor) {
253
- enableHandler.update!(node, () => !unwrapObservable(valueAccessor()), {} as never, undefined, {} as never);
254
- },
255
- };
256
-
257
- let uniqueNameIndex = 0;
258
-
259
- const uniqueNameHandler: BindingHandler = {
260
- init(node, valueAccessor) {
261
- if (valueAccessor()) {
262
- const name = 'tap_unique_' + (++uniqueNameIndex);
263
- setElementName(node as Element, name);
264
- }
265
- },
266
- };
267
-
268
- // ---- Task 20: Event bindings ----
269
-
270
- const eventHandler: BindingHandler = {
271
- init(node, valueAccessor, allBindings, viewModel, bindingContext) {
272
- const el = node as HTMLElement;
273
- const eventsToHandle = (valueAccessor() || {}) as Record<string, unknown>;
274
- for (const eventName of Object.keys(eventsToHandle)) {
275
- el.addEventListener(eventName, function (event: Event) {
276
- const handlerFunction = (valueAccessor() as Record<string, Function>)[eventName];
277
- if (!handlerFunction) return;
278
-
279
- let handlerReturnValue: unknown;
280
- try {
281
- const data = bindingContext.$data;
282
- handlerReturnValue = handlerFunction.call(data, data, event);
283
- } finally {
284
- if (handlerReturnValue !== true) {
285
- event.preventDefault();
286
- }
287
- }
288
-
289
- const bubble = allBindings.get(eventName + 'Bubble') !== false;
290
- if (!bubble) {
291
- event.stopPropagation();
292
- }
293
- });
294
- }
295
- },
296
- };
297
-
298
- function makeEventHandlerShortcut(eventName: string): BindingHandler {
299
- return {
300
- init(node, valueAccessor, allBindings, viewModel, bindingContext) {
301
- const newValueAccessor = () => {
302
- const result: Record<string, unknown> = {};
303
- result[eventName] = valueAccessor();
304
- return result;
305
- };
306
- return eventHandler.init!(node, newValueAccessor, allBindings, viewModel, bindingContext);
307
- },
308
- };
309
- }
310
-
311
- const clickHandler = makeEventHandlerShortcut('click');
312
-
313
- const submitHandler: BindingHandler = {
314
- init(node, valueAccessor, _allBindings, _viewModel, bindingContext) {
315
- if (typeof valueAccessor() !== 'function') {
316
- throw new Error('The value for a submit binding must be a function');
317
- }
318
- (node as HTMLElement).addEventListener('submit', function (event: Event) {
319
- let handlerReturnValue: unknown;
320
- const value = valueAccessor() as Function;
321
- try {
322
- handlerReturnValue = value.call(bindingContext.$data, node);
323
- } finally {
324
- if (handlerReturnValue !== true) {
325
- event.preventDefault();
326
- }
327
- }
328
- });
329
- },
330
- };
331
-
332
- // ---- Task 21: value binding ----
333
-
334
- const valueHandler: BindingHandler = {
335
- init(node, valueAccessor, allBindings) {
336
- const el = node as HTMLElement & { value: string; type?: string; selectedIndex?: number; options?: HTMLOptionsCollection; multiple?: boolean; size?: number };
337
- const tagName = (el.tagName || '').toLowerCase();
338
- const isInputElement = tagName === 'input';
339
-
340
- if (isInputElement && (el.type === 'checkbox' || el.type === 'radio')) {
341
- checkedValueHandler.update!(node, valueAccessor, allBindings, undefined, {} as never);
342
- return;
343
- }
344
-
345
- let eventsToCatch: string[] = [];
346
- const requestedEventsToCatch = allBindings.get('valueUpdate');
347
- let elementValueBeforeEvent: unknown = null;
348
-
349
- if (requestedEventsToCatch) {
350
- if (typeof requestedEventsToCatch === 'string') {
351
- eventsToCatch = [requestedEventsToCatch];
352
- } else {
353
- eventsToCatch = [...new Set(requestedEventsToCatch as string[])];
354
- }
355
- const changeIdx = eventsToCatch.indexOf('change');
356
- if (changeIdx >= 0) eventsToCatch.splice(changeIdx, 1);
357
- }
358
-
359
- const valueUpdateHandler = () => {
360
- elementValueBeforeEvent = null;
361
- const modelValue = valueAccessor();
362
- const elementValue = selectExtensions.readValue(el);
363
- writeValueToProperty(modelValue, allBindings, 'value', elementValue);
364
- };
365
-
366
- for (const eventName of eventsToCatch) {
367
- let handler = valueUpdateHandler;
368
- if (eventName.startsWith('after')) {
369
- const actualHandler = () => {
370
- elementValueBeforeEvent = selectExtensions.readValue(el);
371
- setTimeout(valueUpdateHandler, 0);
372
- };
373
- el.addEventListener(eventName.substring(5), actualHandler);
374
- continue;
375
- }
376
- el.addEventListener(eventName, handler);
377
- }
378
-
379
- let updateFromModelComputed: Computed<void> | null = null;
380
-
381
- const updateFromModel = () => {
382
- let newValue = unwrapObservable(valueAccessor());
383
- const elementValue = selectExtensions.readValue(el);
384
-
385
- if (elementValueBeforeEvent !== null && newValue === elementValueBeforeEvent) {
386
- setTimeout(updateFromModel, 0);
387
- return;
388
- }
389
-
390
- const valueHasChanged = newValue !== elementValue;
391
-
392
- if (valueHasChanged || elementValue === undefined) {
393
- if (tagName === 'select') {
394
- const allowUnset = allBindings.get('valueAllowUnset');
395
- selectExtensions.writeValue(el, newValue, !!allowUnset);
396
- if (!allowUnset && newValue !== selectExtensions.readValue(el)) {
397
- ignore(valueUpdateHandler);
398
- }
399
- } else {
400
- selectExtensions.writeValue(el, newValue);
401
- }
402
- }
403
- };
404
-
405
- if (tagName === 'select') {
406
- el.addEventListener('change', () => {
407
- if (updateFromModelComputed) valueUpdateHandler();
408
- });
409
- bindingEvent.subscribe(node, bindingEvent.childrenComplete, () => {
410
- if (!updateFromModelComputed) {
411
- updateFromModelComputed = new Computed(updateFromModel);
412
- addDisposeCallback(node, () => updateFromModelComputed!.dispose());
413
- } else if (allBindings.get('valueAllowUnset')) {
414
- updateFromModel();
415
- } else {
416
- valueUpdateHandler();
417
- }
418
- }, null, { notifyImmediately: true });
419
- } else {
420
- el.addEventListener('change', valueUpdateHandler);
421
- const comp = new Computed(updateFromModel);
422
- addDisposeCallback(node, () => comp.dispose());
423
- }
424
- },
425
- update() {},
426
- };
427
-
428
- // ---- Task 22: textInput binding ----
429
-
430
- const textInputHandler: BindingHandler = {
431
- init(node, valueAccessor, allBindings) {
432
- const el = node as HTMLInputElement | HTMLTextAreaElement;
433
- let previousElementValue = el.value;
434
- let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
435
- let elementValueBeforeEvent: string | undefined;
436
- let ourUpdate = false;
437
-
438
- const updateModel = (_event?: Event) => {
439
- clearTimeout(timeoutHandle);
440
- elementValueBeforeEvent = timeoutHandle = undefined;
441
-
442
- const elementValue = el.value;
443
- if (previousElementValue !== elementValue) {
444
- previousElementValue = elementValue;
445
- writeValueToProperty(valueAccessor(), allBindings, 'textInput', elementValue);
446
- }
447
- };
448
-
449
- const deferUpdateModel = (_event?: Event) => {
450
- if (!timeoutHandle) {
451
- elementValueBeforeEvent = el.value;
452
- timeoutHandle = setTimeout(updateModel, 4);
453
- }
454
- };
455
-
456
- const updateView = () => {
457
- let modelValue = unwrapObservable(valueAccessor()) as string;
458
-
459
- if (modelValue === null || modelValue === undefined) {
460
- modelValue = '';
461
- }
462
-
463
- if (elementValueBeforeEvent !== undefined && modelValue === elementValueBeforeEvent) {
464
- setTimeout(updateView, 4);
465
- return;
466
- }
467
-
468
- if (el.value !== modelValue) {
469
- ourUpdate = true;
470
- el.value = modelValue;
471
- ourUpdate = false;
472
- previousElementValue = el.value;
473
- }
474
- };
475
-
476
- el.addEventListener('input', updateModel);
477
- el.addEventListener('change', updateModel);
478
- el.addEventListener('blur', updateModel);
479
-
480
- const viewComputed = new Computed(updateView);
481
- addDisposeCallback(node, () => viewComputed.dispose());
482
- },
483
- };
484
-
485
- const textinputAliasHandler: BindingHandler = {
486
- preprocess(value, _name, addBinding) {
487
- addBinding('textInput', value!);
488
- },
489
- };
490
-
491
- // ---- Task 23: checked / checkedValue bindings ----
492
-
493
- function addOrRemoveItem<T>(array: T[], value: T, included: boolean): void {
494
- const existingIndex = array.indexOf(value);
495
- if (included && existingIndex < 0) {
496
- array.push(value);
497
- } else if (!included && existingIndex >= 0) {
498
- array.splice(existingIndex, 1);
499
- }
500
- }
501
-
502
- const checkedHandler: BindingHandler = {
503
- after: ['value', 'attr'],
504
- init(node, valueAccessor, allBindings) {
505
- const el = node as HTMLInputElement;
506
- const isCheckbox = el.type === 'checkbox';
507
- const isRadio = el.type === 'radio';
508
-
509
- if (!isCheckbox && !isRadio) return;
510
-
511
- const checkedValue = new PureComputed(() => {
512
- if (allBindings.has('checkedValue')) {
513
- return unwrapObservable(allBindings.get('checkedValue'));
514
- } else if (useElementValue) {
515
- if (allBindings.has('value')) {
516
- return unwrapObservable(allBindings.get('value'));
517
- } else {
518
- return el.value;
519
- }
520
- }
521
- });
522
-
523
- const rawValue = valueAccessor();
524
- const valueIsArray = isCheckbox && (unwrapObservable(rawValue) instanceof Array);
525
- const rawValueIsNonArrayObservable = !(valueIsArray && (rawValue as unknown[]).push && (rawValue as unknown[]).splice);
526
- const useElementValue = isRadio || valueIsArray;
527
- let oldElemValue: unknown = valueIsArray ? checkedValue.peek() : undefined;
528
-
529
- if (isRadio && !el.name) {
530
- uniqueNameHandler.init!(node, () => true, {} as AllBindingsAccessor, undefined, {} as never);
531
- }
532
-
533
- function updateModel() {
534
- const isChecked = el.checked;
535
- let elemValue = checkedValue.peek();
536
-
537
- if (isInitial()) return;
538
-
539
- if (!isChecked && (isRadio || getDependenciesCount())) return;
540
-
541
- const modelValue = ignore(valueAccessor);
542
- if (valueIsArray) {
543
- const writableValue = rawValueIsNonArrayObservable
544
- ? (modelValue as Observable<unknown[]>).peek()
545
- : modelValue as unknown[];
546
- const saveOldValue = oldElemValue;
547
- oldElemValue = elemValue;
548
-
549
- if (saveOldValue !== elemValue) {
550
- if (isChecked) {
551
- addOrRemoveItem(writableValue as unknown[], elemValue, true);
552
- addOrRemoveItem(writableValue as unknown[], saveOldValue, false);
553
- }
554
- } else {
555
- addOrRemoveItem(writableValue as unknown[], elemValue, isChecked);
556
- }
557
-
558
- if (rawValueIsNonArrayObservable && isWritableObs(modelValue)) {
559
- (modelValue as Observable<unknown>).set(writableValue);
560
- }
561
- } else {
562
- if (isCheckbox) {
563
- if (elemValue === undefined) {
564
- elemValue = isChecked;
565
- } else if (!isChecked) {
566
- elemValue = undefined;
567
- }
568
- }
569
- writeValueToProperty(modelValue, allBindings, 'checked', elemValue, true);
570
- }
571
- }
572
-
573
- function updateView() {
574
- const modelValue = unwrapObservable(valueAccessor());
575
- const elemValue = checkedValue.peek();
576
-
577
- if (valueIsArray) {
578
- el.checked = modelValue != null &&
579
- (modelValue as unknown[]).indexOf(elemValue) >= 0;
580
- oldElemValue = elemValue;
581
- } else if (isCheckbox && elemValue === undefined) {
582
- el.checked = !!modelValue;
583
- } else {
584
- el.checked = (checkedValue.peek() === modelValue);
585
- }
586
- }
587
-
588
- const updateModelComputed = new Computed(updateModel);
589
- addDisposeCallback(node, () => updateModelComputed.dispose());
590
- el.addEventListener('click', updateModel);
591
-
592
- const updateViewComputed = new Computed(updateView);
593
- addDisposeCallback(node, () => updateViewComputed.dispose());
594
- },
595
- };
596
-
597
- const checkedValueHandler: BindingHandler = {
598
- update(node, valueAccessor) {
599
- (node as HTMLInputElement).value = unwrapObservable(valueAccessor()) as string;
600
- },
601
- };
602
-
603
- // ---- Task 24: hasfocus / hasFocus bindings ----
604
-
605
- const HASFOCUS_UPDATING = '__tapout_hasfocusUpdating';
606
- const HASFOCUS_LAST_VALUE = '__tapout_hasfocusLastValue';
607
-
608
- const hasfocusHandler: BindingHandler = {
609
- init(node, valueAccessor, allBindings) {
610
- const el = node as HTMLElement & Record<string, unknown>;
611
-
612
- const handleElementFocusChange = (isFocused: boolean) => {
613
- el[HASFOCUS_UPDATING] = true;
614
- const ownerDoc = el.ownerDocument;
615
- if (ownerDoc && 'activeElement' in ownerDoc) {
616
- try {
617
- isFocused = (ownerDoc.activeElement === el);
618
- } catch {
619
- isFocused = false;
620
- }
621
- }
622
- const modelValue = valueAccessor();
623
- writeValueToProperty(modelValue, allBindings, 'hasfocus', isFocused, true);
624
- el[HASFOCUS_LAST_VALUE] = isFocused;
625
- el[HASFOCUS_UPDATING] = false;
626
- };
627
-
628
- const handleFocusIn = () => handleElementFocusChange(true);
629
- const handleFocusOut = () => handleElementFocusChange(false);
630
-
631
- el.addEventListener('focus', handleFocusIn);
632
- el.addEventListener('focusin', handleFocusIn);
633
- el.addEventListener('blur', handleFocusOut);
634
- el.addEventListener('focusout', handleFocusOut);
635
-
636
- el[HASFOCUS_LAST_VALUE] = false;
637
- },
638
- update(node, valueAccessor) {
639
- const el = node as HTMLElement & Record<string, unknown>;
640
- const value = !!unwrapObservable(valueAccessor());
641
-
642
- if (!el[HASFOCUS_UPDATING] && el[HASFOCUS_LAST_VALUE] !== value) {
643
- if (value) {
644
- el.focus();
645
- } else {
646
- el.blur();
647
- }
648
- }
649
- },
650
- };
651
-
652
- // ---- Task 25: selectedOptions binding ----
653
-
654
- const selectedOptionsHandler: BindingHandler = {
655
- init(node, valueAccessor, allBindings) {
656
- const el = node as HTMLSelectElement;
657
-
658
- if ((el.tagName || '').toLowerCase() !== 'select') {
659
- throw new Error('selectedOptions binding applies only to SELECT elements');
660
- }
661
-
662
- function updateFromView() {
663
- const value = valueAccessor();
664
- const valueToWrite: unknown[] = [];
665
- const options = el.getElementsByTagName('option');
666
- for (let i = 0; i < options.length; i++) {
667
- if ((options[i] as HTMLOptionElement).selected) {
668
- valueToWrite.push(selectExtensions.readValue(options[i]));
669
- }
670
- }
671
- writeValueToProperty(value, allBindings, 'selectedOptions', valueToWrite);
672
- }
673
-
674
- function updateFromModel() {
675
- const newValue = unwrapObservable(valueAccessor()) as unknown[] | null;
676
- const previousScrollTop = el.scrollTop;
677
-
678
- if (newValue && typeof (newValue as unknown[]).length === 'number') {
679
- const options = el.getElementsByTagName('option');
680
- for (let i = 0; i < options.length; i++) {
681
- const optEl = options[i] as HTMLOptionElement;
682
- const isSelected = (newValue as unknown[]).indexOf(selectExtensions.readValue(optEl)) >= 0;
683
- if (optEl.selected !== isSelected) {
684
- optEl.selected = isSelected;
685
- }
686
- }
687
- }
688
-
689
- el.scrollTop = previousScrollTop;
690
- }
691
-
692
- let updateFromModelComputed: Computed<void> | null = null;
693
- bindingEvent.subscribe(node, bindingEvent.childrenComplete, () => {
694
- if (!updateFromModelComputed) {
695
- el.addEventListener('change', updateFromView);
696
- updateFromModelComputed = new Computed(updateFromModel);
697
- addDisposeCallback(node, () => updateFromModelComputed!.dispose());
698
- } else {
699
- updateFromView();
700
- }
701
- }, null, { notifyImmediately: true });
702
- },
703
- update() {},
704
- };
705
-
706
- // ---- Task 26: options binding ----
707
-
708
- const CAPTION_PLACEHOLDER = {};
709
-
710
- const optionsHandler: BindingHandler = {
711
- init(node) {
712
- const el = node as HTMLSelectElement;
713
- if ((el.tagName || '').toLowerCase() !== 'select') {
714
- throw new Error('options binding applies only to SELECT elements');
715
- }
716
- while (el.options.length > 0) {
717
- el.removeChild(el.options[0]);
718
- }
719
- return { controlsDescendantBindings: true };
720
- },
721
- update(node, valueAccessor, allBindings) {
722
- const el = node as HTMLSelectElement;
723
-
724
- function selectedOptions(): HTMLOptionElement[] {
725
- return Array.from(el.options).filter(o => o.selected);
726
- }
727
-
728
- const selectWasPreviouslyEmpty = el.length === 0;
729
- const multiple = el.multiple;
730
- const previousScrollTop = (!selectWasPreviouslyEmpty && multiple) ? el.scrollTop : null;
731
- let unwrappedArray = unwrapObservable(valueAccessor()) as unknown[];
732
- const valueAllowUnset = allBindings.get('valueAllowUnset') && allBindings.has('value');
733
- const includeDestroyed = allBindings.get('optionsIncludeDestroyed');
734
- let filteredArray: unknown[] | undefined;
735
- let previousSelectedValues: unknown[] = [];
736
-
737
- if (!valueAllowUnset) {
738
- if (multiple) {
739
- previousSelectedValues = selectedOptions().map(o => selectExtensions.readValue(o));
740
- } else if (el.selectedIndex >= 0) {
741
- previousSelectedValues.push(selectExtensions.readValue(el.options[el.selectedIndex]));
742
- }
743
- }
744
-
745
- if (unwrappedArray) {
746
- if (!Array.isArray(unwrappedArray)) {
747
- unwrappedArray = [unwrappedArray];
748
- }
749
-
750
- filteredArray = (unwrappedArray as unknown[]).filter((item: unknown) => {
751
- return includeDestroyed || item === undefined || item === null ||
752
- !unwrapObservable((item as Record<string, unknown>)?.['_destroy']);
753
- });
754
-
755
- if (allBindings.has('optionsCaption')) {
756
- const captionValue = unwrapObservable(allBindings.get('optionsCaption'));
757
- if (captionValue !== null && captionValue !== undefined) {
758
- filteredArray.unshift(CAPTION_PLACEHOLDER);
759
- }
760
- }
761
- }
762
-
763
- function applyToObject(object: unknown, predicate: unknown, defaultValue: unknown): unknown {
764
- if (typeof predicate === 'function') return (predicate as Function)(object);
765
- if (typeof predicate === 'string') return (object as Record<string, unknown>)[predicate];
766
- return defaultValue;
767
- }
768
-
769
- while (el.options.length > 0) {
770
- el.removeChild(el.options[0]);
771
- }
772
-
773
- if (filteredArray) {
774
- for (let i = 0; i < filteredArray.length; i++) {
775
- const arrayEntry = filteredArray[i];
776
- const option = el.ownerDocument.createElement('option') as HTMLOptionElement;
777
-
778
- if (arrayEntry === CAPTION_PLACEHOLDER) {
779
- option.textContent = String(unwrapObservable(allBindings.get('optionsCaption')) ?? '');
780
- selectExtensions.writeValue(option, undefined);
781
- } else {
782
- const optionValue = applyToObject(arrayEntry, allBindings.get('optionsValue'), arrayEntry);
783
- selectExtensions.writeValue(option, unwrapObservable(optionValue));
784
-
785
- const optionText = applyToObject(arrayEntry, allBindings.get('optionsText'), optionValue);
786
- option.textContent = String(unwrapObservable(optionText) ?? '');
787
- }
788
-
789
- el.appendChild(option);
790
-
791
- if (previousSelectedValues.length) {
792
- const isSelected = previousSelectedValues.indexOf(selectExtensions.readValue(option)) >= 0;
793
- option.selected = isSelected;
794
- }
795
-
796
- if (allBindings.has('optionsAfterRender') && typeof allBindings.get('optionsAfterRender') === 'function') {
797
- ignore(() => {
798
- (allBindings.get('optionsAfterRender') as Function)(
799
- option,
800
- arrayEntry !== CAPTION_PLACEHOLDER ? arrayEntry : undefined,
801
- );
802
- });
803
- }
804
- }
805
- }
806
-
807
- if (!valueAllowUnset) {
808
- let selectionChanged: boolean;
809
- if (multiple) {
810
- selectionChanged = previousSelectedValues.length > 0 && selectedOptions().length < previousSelectedValues.length;
811
- } else {
812
- selectionChanged = (previousSelectedValues.length > 0 && el.selectedIndex >= 0)
813
- ? (selectExtensions.readValue(el.options[el.selectedIndex]) !== previousSelectedValues[0])
814
- : (previousSelectedValues.length > 0 || el.selectedIndex >= 0);
815
- }
816
-
817
- if (selectionChanged) {
818
- ignore(() => triggerEvent(el, 'change'));
819
- }
820
- }
821
-
822
- if (valueAllowUnset || isInitial()) {
823
- bindingEvent.notify(node, bindingEvent.childrenComplete);
824
- }
825
- },
826
- };
827
-
828
- function isWritableObs(value: unknown): value is Observable<unknown> | Computed<unknown> {
829
- if (value instanceof Observable) return true;
830
- if (value instanceof Computed) return value.hasWriteFunction;
831
- return false;
832
- }
833
-
834
- // ---- Key event bindings (keydown / keyup with key + modifier filtering) ----
835
-
836
- const KEY_ALIASES: Record<string, string> = {
837
- enter: 'Enter', tab: 'Tab', esc: 'Escape', escape: 'Escape',
838
- space: ' ', delete: 'Delete', backspace: 'Backspace',
839
- up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight',
840
- };
841
-
842
- const MODIFIER_KEYS = new Set(['ctrl', 'alt', 'shift', 'meta']);
843
-
844
- const MODIFIER_PROPS: Record<string, string> = {
845
- ctrl: 'ctrlKey', alt: 'altKey', shift: 'shiftKey', meta: 'metaKey',
846
- };
847
-
848
- function createKeyFilteredHandler(eventType: string, name: string): BindingHandler {
849
- const parts = name.split('.');
850
- const modifiers: string[] = [];
851
- let keyName: string | undefined;
852
-
853
- for (const part of parts) {
854
- if (MODIFIER_KEYS.has(part)) {
855
- modifiers.push(part);
856
- } else if (!keyName) {
857
- keyName = KEY_ALIASES[part] ?? part;
858
- }
859
- }
860
-
861
- return {
862
- init(node, valueAccessor, allBindings, _viewModel, bindingContext) {
863
- const el = node as HTMLElement;
864
- el.addEventListener(eventType, function (event: Event) {
865
- const ke = event as KeyboardEvent;
866
- if (keyName && ke.key !== keyName) return;
867
- for (const mod of modifiers) {
868
- if (!ke[MODIFIER_PROPS[mod] as keyof KeyboardEvent]) return;
869
- }
870
-
871
- const callback = valueAccessor();
872
- if (typeof callback !== 'function') return;
873
-
874
- let handlerReturnValue: unknown;
875
- try {
876
- const data = bindingContext.$data;
877
- handlerReturnValue = callback.call(data, data, event);
878
- } finally {
879
- if (handlerReturnValue !== true) {
880
- event.preventDefault();
881
- }
882
- }
883
-
884
- const bubble = allBindings.get(eventType + 'Bubble') !== false;
885
- if (!bubble) {
886
- event.stopPropagation();
887
- }
888
- });
889
- },
890
- };
891
- }
892
-
893
- const keydownHandler = makeEventHandlerShortcut('keydown');
894
- keydownHandler.getNamespacedHandler = function (name, _ns, _namespacedName) {
895
- return createKeyFilteredHandler('keydown', name);
896
- };
897
-
898
- const keyupHandler = makeEventHandlerShortcut('keyup');
899
- keyupHandler.getNamespacedHandler = function (name, _ns, _namespacedName) {
900
- return createKeyFilteredHandler('keyup', name);
901
- };
902
-
903
- const enterHandler = createKeyFilteredHandler('keydown', 'enter');
904
-
905
- // ---- modal: toggles <dialog> showModal/close based on observable boolean ----
906
-
907
- const modalHandler: BindingHandler = {
908
- init(element, valueAccessor) {
909
- const dlg = element as HTMLDialogElement;
910
- const value = unwrapObservable(valueAccessor());
911
- if (value) dlg.showModal();
912
- },
913
- update(element, valueAccessor) {
914
- const dlg = element as HTMLDialogElement;
915
- const value = unwrapObservable(valueAccessor());
916
- if (value) {
917
- if (!dlg.open) dlg.showModal();
918
- } else {
919
- if (dlg.open) dlg.close();
920
- }
921
- },
922
- };
923
-
924
- // ---- Registration ----
925
-
926
- bindingHandlers['text'] = textHandler;
927
- bindingHandlers['html'] = htmlHandler;
928
- bindingHandlers['visible'] = visibleHandler;
929
- bindingHandlers['hidden'] = hiddenHandler;
930
- bindingHandlers['attr'] = attrHandler;
931
- bindingHandlers['class'] = classHandler;
932
- bindingHandlers['css'] = cssHandler;
933
- bindingHandlers['style'] = styleHandler;
934
- bindingHandlers['enable'] = enableHandler;
935
- bindingHandlers['disable'] = disableHandler;
936
- bindingHandlers['uniqueName'] = uniqueNameHandler;
937
-
938
- bindingHandlers['event'] = eventHandler;
939
- bindingHandlers['click'] = clickHandler;
940
- bindingHandlers['submit'] = submitHandler;
941
- bindingHandlers['value'] = valueHandler;
942
- bindingHandlers['textInput'] = textInputHandler;
943
- bindingHandlers['textinput'] = textinputAliasHandler;
944
- bindingHandlers['checked'] = checkedHandler;
945
- bindingHandlers['checkedValue'] = checkedValueHandler;
946
- bindingHandlers['hasfocus'] = hasfocusHandler;
947
- bindingHandlers['hasFocus'] = hasfocusHandler;
948
- bindingHandlers['selectedOptions'] = selectedOptionsHandler;
949
- bindingHandlers['options'] = optionsHandler;
950
- bindingHandlers['keydown'] = keydownHandler;
951
- bindingHandlers['keyup'] = keyupHandler;
952
- bindingHandlers['enter'] = enterHandler;
953
- bindingHandlers['modal'] = modalHandler;
954
-
955
- twoWayBindings['value'] = true;
956
- twoWayBindings['textInput'] = true;
957
- twoWayBindings['checked'] = true;
958
- twoWayBindings['hasfocus'] = true;
959
- twoWayBindings['hasFocus'] = 'hasfocus';
960
- twoWayBindings['selectedOptions'] = true;
961
-
962
- allowedVirtualElementBindings['text'] = true;
963
- allowedVirtualElementBindings['html'] = true;
1
+ import { unwrapObservable } from './utils.js';
2
+ import { parseHtmlFragment } from './utilsDom.js';
3
+ import { bindingHandlers } from './bindingProvider.js';
4
+ import type { BindingHandler, AllBindingsAccessor } from './bindingProvider.js';
5
+ import { allowedVirtualElementBindings, virtualFirstChild, virtualNextSibling, virtualSetChildren, virtualEmptyNode } from './virtualElements.js';
6
+ import { addDisposeCallback, removeNode } from './domNodeDisposal.js';
7
+ import { twoWayBindings, writeValueToProperty } from './expressionRewriting.js';
8
+ import { Computed, PureComputed } from './computed.js';
9
+ import { Observable } from './observable.js';
10
+ import { isObservableArray } from './observableArray.js';
11
+ import { ignore, isInitial, getDependenciesCount } from './dependencyDetection.js';
12
+ import { bindingEvent } from './bindingEvent.js';
13
+ import * as selectExtensions from './selectExtensions.js';
14
+
15
+ // ---- Shared DOM Utilities ----
16
+
17
+ function triggerEvent(element: Node, eventType: string): void {
18
+ const EventCtor = ((element.ownerDocument?.defaultView) as unknown as typeof globalThis)?.Event ?? Event;
19
+ element.dispatchEvent(new EventCtor(eventType, { bubbles: true, cancelable: true }));
20
+ }
21
+
22
+
23
+ function setTextContent(element: Node, textContent: unknown): void {
24
+ let value = unwrapObservable(textContent);
25
+ if (value === null || value === undefined) value = '';
26
+
27
+ const innerTextNode = virtualFirstChild(element);
28
+ if (!innerTextNode || innerTextNode.nodeType !== 3 || virtualNextSibling(innerTextNode)) {
29
+ const doc = (element as Element).ownerDocument ?? (element as Document);
30
+ virtualSetChildren(element, [doc.createTextNode(String(value))]);
31
+ } else {
32
+ (innerTextNode as Text).data = String(value);
33
+ }
34
+ }
35
+
36
+ function emptyDomNode(node: Node): void {
37
+ while (node.firstChild) {
38
+ removeNode(node.firstChild);
39
+ }
40
+ }
41
+
42
+ function setHtml(node: Node, html: unknown): void {
43
+ html = unwrapObservable(html);
44
+
45
+ if (node.nodeType === 8) {
46
+ if (html != null) {
47
+ const parsedNodes = parseHtmlFragment('' + html, node.ownerDocument ?? undefined);
48
+ virtualSetChildren(node, parsedNodes);
49
+ } else {
50
+ virtualEmptyNode(node);
51
+ }
52
+ } else {
53
+ emptyDomNode(node);
54
+ if (html === null || html === undefined) return;
55
+ const el = node as Element;
56
+ el.innerHTML = typeof html === 'string' ? html : String(html);
57
+ }
58
+ }
59
+
60
+ const CSS_CLASS_REGEX = /\S+/g;
61
+ const CLASSES_WRITTEN_KEY = '__tapout__cssValue';
62
+
63
+ function toggleDomNodeCssClass(node: Element, classNames: string | null | undefined, shouldHaveClass: boolean): void {
64
+ if (!classNames) return;
65
+
66
+ const matches = classNames.match(CSS_CLASS_REGEX);
67
+ if (!matches) return;
68
+
69
+ if (typeof node.classList === 'object') {
70
+ const fn = shouldHaveClass ? 'add' : 'remove';
71
+ for (const cls of matches) {
72
+ node.classList[fn](cls);
73
+ }
74
+ } else if (typeof (node.className as unknown as { baseVal?: string })?.baseVal === 'string') {
75
+ // SVG element
76
+ toggleClassPropertyString(node.className as unknown as { baseVal: string }, 'baseVal', matches, shouldHaveClass);
77
+ } else {
78
+ toggleClassPropertyString(node as unknown as Record<string, string>, 'className', matches, shouldHaveClass);
79
+ }
80
+ }
81
+
82
+ function toggleClassPropertyString(
83
+ obj: Record<string, string>,
84
+ prop: string,
85
+ classNames: string[],
86
+ shouldHaveClass: boolean,
87
+ ): void {
88
+ const current: string[] = (obj[prop] || '').match(CSS_CLASS_REGEX)?.slice() ?? [];
89
+ for (const cls of classNames) {
90
+ const idx = current.indexOf(cls);
91
+ if (shouldHaveClass && idx === -1) {
92
+ current.push(cls);
93
+ } else if (!shouldHaveClass && idx !== -1) {
94
+ current.splice(idx, 1);
95
+ }
96
+ }
97
+ obj[prop] = current.join(' ');
98
+ }
99
+
100
+ function setElementName(element: Element, name: string): void {
101
+ (element as HTMLInputElement).name = name;
102
+ }
103
+
104
+ // ---- Task 17: One-way display bindings ----
105
+
106
+ const textHandler: BindingHandler = {
107
+ init() {
108
+ return { controlsDescendantBindings: true };
109
+ },
110
+ update(node, valueAccessor) {
111
+ setTextContent(node, valueAccessor());
112
+ },
113
+ };
114
+
115
+ const htmlHandler: BindingHandler = {
116
+ init() {
117
+ return { controlsDescendantBindings: true };
118
+ },
119
+ update(node, valueAccessor) {
120
+ setHtml(node, valueAccessor());
121
+ },
122
+ };
123
+
124
+ const visibleHandler: BindingHandler = {
125
+ update(node, valueAccessor) {
126
+ const el = node as HTMLElement;
127
+ const value = unwrapObservable(valueAccessor());
128
+ const isCurrentlyVisible = el.style.display !== 'none';
129
+ if (value && !isCurrentlyVisible) {
130
+ el.style.display = '';
131
+ } else if (!value && isCurrentlyVisible) {
132
+ el.style.display = 'none';
133
+ }
134
+ },
135
+ };
136
+
137
+ const hiddenHandler: BindingHandler = {
138
+ update(node, valueAccessor) {
139
+ visibleHandler.update!(node, () => !unwrapObservable(valueAccessor()), {} as never, undefined, {} as never);
140
+ },
141
+ };
142
+
143
+ // ---- Task 18: Attribute bindings ----
144
+
145
+ const attrHandler: BindingHandler = {
146
+ update(node, valueAccessor) {
147
+ const el = node as Element;
148
+ const value = unwrapObservable(valueAccessor()) as Record<string, unknown> | null;
149
+ if (!value) return;
150
+
151
+ for (const attrName of Object.keys(value)) {
152
+ let attrValue = unwrapObservable(value[attrName]);
153
+
154
+ const prefixLen = attrName.indexOf(':');
155
+ const namespace = 'lookupNamespaceURI' in el && prefixLen > 0
156
+ ? (el as Element).lookupNamespaceURI(attrName.substring(0, prefixLen))
157
+ : null;
158
+
159
+ const toRemove = attrValue === false || attrValue === null || attrValue === undefined;
160
+
161
+ if (toRemove) {
162
+ namespace
163
+ ? el.removeAttributeNS(namespace, attrName)
164
+ : el.removeAttribute(attrName);
165
+ } else {
166
+ const strValue = String(attrValue);
167
+ namespace
168
+ ? el.setAttributeNS(namespace, attrName, strValue)
169
+ : el.setAttribute(attrName, strValue);
170
+ }
171
+
172
+ if (attrName === 'name') {
173
+ setElementName(el, toRemove ? '' : String(attrValue));
174
+ }
175
+ }
176
+ },
177
+ };
178
+
179
+ const classHandler: BindingHandler = {
180
+ update(node, valueAccessor) {
181
+ const el = node as Element;
182
+ const value = String(unwrapObservable(valueAccessor()) ?? '').trim();
183
+ const previousValue = (el as unknown as Record<string, string>)[CLASSES_WRITTEN_KEY];
184
+ toggleDomNodeCssClass(el, previousValue, false);
185
+ (el as unknown as Record<string, string>)[CLASSES_WRITTEN_KEY] = value;
186
+ toggleDomNodeCssClass(el, value, true);
187
+ },
188
+ };
189
+
190
+ const cssHandler: BindingHandler = {
191
+ update(node, valueAccessor) {
192
+ const el = node as Element;
193
+ const value = unwrapObservable(valueAccessor());
194
+ if (value !== null && typeof value === 'object') {
195
+ for (const className of Object.keys(value as Record<string, unknown>)) {
196
+ const shouldHaveClass = !!unwrapObservable((value as Record<string, unknown>)[className]);
197
+ toggleDomNodeCssClass(el, className, shouldHaveClass);
198
+ }
199
+ } else {
200
+ classHandler.update!(node, valueAccessor, {} as never, undefined, {} as never);
201
+ }
202
+ },
203
+ };
204
+
205
+ const styleHandler: BindingHandler = {
206
+ update(node, valueAccessor) {
207
+ const el = node as HTMLElement;
208
+ const value = unwrapObservable(valueAccessor() || {}) as Record<string, unknown>;
209
+
210
+ for (const styleName of Object.keys(value)) {
211
+ let styleValue = unwrapObservable(value[styleName]);
212
+
213
+ if (styleValue === null || styleValue === undefined || styleValue === false) {
214
+ styleValue = '';
215
+ }
216
+
217
+ if (/^--/.test(styleName)) {
218
+ el.style.setProperty(styleName, String(styleValue));
219
+ } else {
220
+ const camelCased = styleName.replace(/-(\w)/g, (_all, letter: string) => letter.toUpperCase());
221
+
222
+ const previousStyle = (el.style as unknown as Record<string, string>)[camelCased];
223
+ (el.style as unknown as Record<string, string>)[camelCased] = String(styleValue);
224
+
225
+ if (
226
+ String(styleValue) !== previousStyle &&
227
+ (el.style as unknown as Record<string, string>)[camelCased] === previousStyle &&
228
+ !isNaN(styleValue as number)
229
+ ) {
230
+ (el.style as unknown as Record<string, string>)[camelCased] = styleValue + 'px';
231
+ }
232
+ }
233
+ }
234
+ },
235
+ };
236
+
237
+ // ---- Task 19: Form state bindings ----
238
+
239
+ const enableHandler: BindingHandler = {
240
+ update(node, valueAccessor) {
241
+ const el = node as HTMLElement;
242
+ const value = unwrapObservable(valueAccessor());
243
+ if (value && (el as HTMLInputElement).disabled) {
244
+ el.removeAttribute('disabled');
245
+ } else if (!value && !(el as HTMLInputElement).disabled) {
246
+ (el as HTMLInputElement).disabled = true;
247
+ }
248
+ },
249
+ };
250
+
251
+ const disableHandler: BindingHandler = {
252
+ update(node, valueAccessor) {
253
+ enableHandler.update!(node, () => !unwrapObservable(valueAccessor()), {} as never, undefined, {} as never);
254
+ },
255
+ };
256
+
257
+ let uniqueNameIndex = 0;
258
+
259
+ const uniqueNameHandler: BindingHandler = {
260
+ init(node, valueAccessor) {
261
+ if (valueAccessor()) {
262
+ const name = 'tap_unique_' + (++uniqueNameIndex);
263
+ setElementName(node as Element, name);
264
+ }
265
+ },
266
+ };
267
+
268
+ // ---- Task 20: Event bindings ----
269
+
270
+ const eventHandler: BindingHandler = {
271
+ init(node, valueAccessor, allBindings, viewModel, bindingContext) {
272
+ const el = node as HTMLElement;
273
+ const eventsToHandle = (valueAccessor() || {}) as Record<string, unknown>;
274
+ for (const eventName of Object.keys(eventsToHandle)) {
275
+ el.addEventListener(eventName, function (event: Event) {
276
+ const handlerFunction = (valueAccessor() as Record<string, Function>)[eventName];
277
+ if (!handlerFunction) return;
278
+
279
+ let handlerReturnValue: unknown;
280
+ try {
281
+ const data = bindingContext.$data;
282
+ handlerReturnValue = handlerFunction.call(data, data, event);
283
+ } finally {
284
+ if (handlerReturnValue !== true) {
285
+ event.preventDefault();
286
+ }
287
+ }
288
+
289
+ const bubble = allBindings.get(eventName + 'Bubble') !== false;
290
+ if (!bubble) {
291
+ event.stopPropagation();
292
+ }
293
+ });
294
+ }
295
+ },
296
+ };
297
+
298
+ function makeEventHandlerShortcut(eventName: string): BindingHandler {
299
+ return {
300
+ init(node, valueAccessor, allBindings, viewModel, bindingContext) {
301
+ const newValueAccessor = () => {
302
+ const result: Record<string, unknown> = {};
303
+ result[eventName] = valueAccessor();
304
+ return result;
305
+ };
306
+ return eventHandler.init!(node, newValueAccessor, allBindings, viewModel, bindingContext);
307
+ },
308
+ };
309
+ }
310
+
311
+ const clickHandler = makeEventHandlerShortcut('click');
312
+
313
+ const submitHandler: BindingHandler = {
314
+ init(node, valueAccessor, _allBindings, _viewModel, bindingContext) {
315
+ if (typeof valueAccessor() !== 'function') {
316
+ throw new Error('The value for a submit binding must be a function');
317
+ }
318
+ (node as HTMLElement).addEventListener('submit', function (event: Event) {
319
+ let handlerReturnValue: unknown;
320
+ const value = valueAccessor() as Function;
321
+ try {
322
+ handlerReturnValue = value.call(bindingContext.$data, node);
323
+ } finally {
324
+ if (handlerReturnValue !== true) {
325
+ event.preventDefault();
326
+ }
327
+ }
328
+ });
329
+ },
330
+ };
331
+
332
+ // ---- Task 21: value binding ----
333
+
334
+ const valueHandler: BindingHandler = {
335
+ init(node, valueAccessor, allBindings) {
336
+ const el = node as HTMLElement & { value: string; type?: string; selectedIndex?: number; options?: HTMLOptionsCollection; multiple?: boolean; size?: number };
337
+ const tagName = (el.tagName || '').toLowerCase();
338
+ const isInputElement = tagName === 'input';
339
+
340
+ if (isInputElement && (el.type === 'checkbox' || el.type === 'radio')) {
341
+ checkedValueHandler.update!(node, valueAccessor, allBindings, undefined, {} as never);
342
+ return;
343
+ }
344
+
345
+ let eventsToCatch: string[] = [];
346
+ const requestedEventsToCatch = allBindings.get('valueUpdate');
347
+ let elementValueBeforeEvent: unknown = null;
348
+
349
+ if (requestedEventsToCatch) {
350
+ if (typeof requestedEventsToCatch === 'string') {
351
+ eventsToCatch = [requestedEventsToCatch];
352
+ } else {
353
+ eventsToCatch = [...new Set(requestedEventsToCatch as string[])];
354
+ }
355
+ const changeIdx = eventsToCatch.indexOf('change');
356
+ if (changeIdx >= 0) eventsToCatch.splice(changeIdx, 1);
357
+ }
358
+
359
+ const valueUpdateHandler = () => {
360
+ elementValueBeforeEvent = null;
361
+ const modelValue = valueAccessor();
362
+ const elementValue = selectExtensions.readValue(el);
363
+ writeValueToProperty(modelValue, allBindings, 'value', elementValue);
364
+ };
365
+
366
+ for (const eventName of eventsToCatch) {
367
+ let handler = valueUpdateHandler;
368
+ if (eventName.startsWith('after')) {
369
+ const actualHandler = () => {
370
+ elementValueBeforeEvent = selectExtensions.readValue(el);
371
+ setTimeout(valueUpdateHandler, 0);
372
+ };
373
+ el.addEventListener(eventName.substring(5), actualHandler);
374
+ continue;
375
+ }
376
+ el.addEventListener(eventName, handler);
377
+ }
378
+
379
+ let updateFromModelComputed: Computed<void> | null = null;
380
+
381
+ const updateFromModel = () => {
382
+ let newValue = unwrapObservable(valueAccessor());
383
+ const elementValue = selectExtensions.readValue(el);
384
+
385
+ if (elementValueBeforeEvent !== null && newValue === elementValueBeforeEvent) {
386
+ setTimeout(updateFromModel, 0);
387
+ return;
388
+ }
389
+
390
+ const valueHasChanged = newValue !== elementValue;
391
+
392
+ if (valueHasChanged || elementValue === undefined) {
393
+ if (tagName === 'select') {
394
+ const allowUnset = allBindings.get('valueAllowUnset');
395
+ selectExtensions.writeValue(el, newValue, !!allowUnset);
396
+ if (!allowUnset && newValue !== selectExtensions.readValue(el)) {
397
+ ignore(valueUpdateHandler);
398
+ }
399
+ } else {
400
+ selectExtensions.writeValue(el, newValue);
401
+ }
402
+ }
403
+ };
404
+
405
+ if (tagName === 'select') {
406
+ el.addEventListener('change', () => {
407
+ if (updateFromModelComputed) valueUpdateHandler();
408
+ });
409
+ bindingEvent.subscribe(node, bindingEvent.childrenComplete, () => {
410
+ if (!updateFromModelComputed) {
411
+ updateFromModelComputed = new Computed(updateFromModel);
412
+ addDisposeCallback(node, () => updateFromModelComputed!.dispose());
413
+ } else if (allBindings.get('valueAllowUnset')) {
414
+ updateFromModel();
415
+ } else {
416
+ valueUpdateHandler();
417
+ }
418
+ }, null, { notifyImmediately: true });
419
+ } else {
420
+ el.addEventListener('change', valueUpdateHandler);
421
+ const comp = new Computed(updateFromModel);
422
+ addDisposeCallback(node, () => comp.dispose());
423
+ }
424
+ },
425
+ update() {},
426
+ };
427
+
428
+ // ---- Task 22: textInput binding ----
429
+
430
+ const textInputHandler: BindingHandler = {
431
+ init(node, valueAccessor, allBindings) {
432
+ const el = node as HTMLInputElement | HTMLTextAreaElement;
433
+ let previousElementValue = el.value;
434
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
435
+ let elementValueBeforeEvent: string | undefined;
436
+ let ourUpdate = false;
437
+
438
+ const updateModel = (_event?: Event) => {
439
+ clearTimeout(timeoutHandle);
440
+ elementValueBeforeEvent = timeoutHandle = undefined;
441
+
442
+ const elementValue = el.value;
443
+ if (previousElementValue !== elementValue) {
444
+ previousElementValue = elementValue;
445
+ writeValueToProperty(valueAccessor(), allBindings, 'textInput', elementValue);
446
+ }
447
+ };
448
+
449
+ const deferUpdateModel = (_event?: Event) => {
450
+ if (!timeoutHandle) {
451
+ elementValueBeforeEvent = el.value;
452
+ timeoutHandle = setTimeout(updateModel, 4);
453
+ }
454
+ };
455
+
456
+ const updateView = () => {
457
+ let modelValue = unwrapObservable(valueAccessor()) as string;
458
+
459
+ if (modelValue === null || modelValue === undefined) {
460
+ modelValue = '';
461
+ }
462
+
463
+ if (elementValueBeforeEvent !== undefined && modelValue === elementValueBeforeEvent) {
464
+ setTimeout(updateView, 4);
465
+ return;
466
+ }
467
+
468
+ if (el.value !== modelValue) {
469
+ ourUpdate = true;
470
+ el.value = modelValue;
471
+ ourUpdate = false;
472
+ previousElementValue = el.value;
473
+ }
474
+ };
475
+
476
+ el.addEventListener('input', updateModel);
477
+ el.addEventListener('change', updateModel);
478
+ el.addEventListener('blur', updateModel);
479
+
480
+ const viewComputed = new Computed(updateView);
481
+ addDisposeCallback(node, () => viewComputed.dispose());
482
+ },
483
+ };
484
+
485
+ const textinputAliasHandler: BindingHandler = {
486
+ preprocess(value, _name, addBinding) {
487
+ addBinding('textInput', value!);
488
+ },
489
+ };
490
+
491
+ // ---- Task 23: checked / checkedValue bindings ----
492
+
493
+ function addOrRemoveItem<T>(array: T[], value: T, included: boolean): void {
494
+ const existingIndex = array.indexOf(value);
495
+ if (included && existingIndex < 0) {
496
+ array.push(value);
497
+ } else if (!included && existingIndex >= 0) {
498
+ array.splice(existingIndex, 1);
499
+ }
500
+ }
501
+
502
+ const checkedHandler: BindingHandler = {
503
+ after: ['value', 'attr'],
504
+ init(node, valueAccessor, allBindings) {
505
+ const el = node as HTMLInputElement;
506
+ const isCheckbox = el.type === 'checkbox';
507
+ const isRadio = el.type === 'radio';
508
+
509
+ if (!isCheckbox && !isRadio) return;
510
+
511
+ const checkedValue = new PureComputed(() => {
512
+ if (allBindings.has('checkedValue')) {
513
+ return unwrapObservable(allBindings.get('checkedValue'));
514
+ } else if (useElementValue) {
515
+ if (allBindings.has('value')) {
516
+ return unwrapObservable(allBindings.get('value'));
517
+ } else {
518
+ return el.value;
519
+ }
520
+ }
521
+ });
522
+
523
+ const rawValue = valueAccessor();
524
+ const valueIsArray = isCheckbox && (unwrapObservable(rawValue) instanceof Array);
525
+ const rawValueIsNonArrayObservable = !(valueIsArray && (rawValue as unknown[]).push && (rawValue as unknown[]).splice);
526
+ const useElementValue = isRadio || valueIsArray;
527
+ let oldElemValue: unknown = valueIsArray ? checkedValue.peek() : undefined;
528
+
529
+ if (isRadio && !el.name) {
530
+ uniqueNameHandler.init!(node, () => true, {} as AllBindingsAccessor, undefined, {} as never);
531
+ }
532
+
533
+ function updateModel() {
534
+ const isChecked = el.checked;
535
+ let elemValue = checkedValue.peek();
536
+
537
+ if (isInitial()) return;
538
+
539
+ if (!isChecked && (isRadio || getDependenciesCount())) return;
540
+
541
+ const modelValue = ignore(valueAccessor);
542
+ if (valueIsArray) {
543
+ const writableValue = rawValueIsNonArrayObservable
544
+ ? (modelValue as Observable<unknown[]>).peek()
545
+ : modelValue as unknown[];
546
+ const saveOldValue = oldElemValue;
547
+ oldElemValue = elemValue;
548
+
549
+ if (saveOldValue !== elemValue) {
550
+ if (isChecked) {
551
+ addOrRemoveItem(writableValue as unknown[], elemValue, true);
552
+ addOrRemoveItem(writableValue as unknown[], saveOldValue, false);
553
+ }
554
+ } else {
555
+ addOrRemoveItem(writableValue as unknown[], elemValue, isChecked);
556
+ }
557
+
558
+ if (rawValueIsNonArrayObservable && isWritableObs(modelValue)) {
559
+ (modelValue as Observable<unknown>).set(writableValue);
560
+ }
561
+ } else {
562
+ if (isCheckbox) {
563
+ if (elemValue === undefined) {
564
+ elemValue = isChecked;
565
+ } else if (!isChecked) {
566
+ elemValue = undefined;
567
+ }
568
+ }
569
+ writeValueToProperty(modelValue, allBindings, 'checked', elemValue, true);
570
+ }
571
+ }
572
+
573
+ function updateView() {
574
+ const modelValue = unwrapObservable(valueAccessor());
575
+ const elemValue = checkedValue.peek();
576
+
577
+ if (valueIsArray) {
578
+ el.checked = modelValue != null &&
579
+ (modelValue as unknown[]).indexOf(elemValue) >= 0;
580
+ oldElemValue = elemValue;
581
+ } else if (isCheckbox && elemValue === undefined) {
582
+ el.checked = !!modelValue;
583
+ } else {
584
+ el.checked = (checkedValue.peek() === modelValue);
585
+ }
586
+ }
587
+
588
+ const updateModelComputed = new Computed(updateModel);
589
+ addDisposeCallback(node, () => updateModelComputed.dispose());
590
+ el.addEventListener('click', updateModel);
591
+
592
+ const updateViewComputed = new Computed(updateView);
593
+ addDisposeCallback(node, () => updateViewComputed.dispose());
594
+ },
595
+ };
596
+
597
+ const checkedValueHandler: BindingHandler = {
598
+ update(node, valueAccessor) {
599
+ (node as HTMLInputElement).value = unwrapObservable(valueAccessor()) as string;
600
+ },
601
+ };
602
+
603
+ // ---- Task 24: hasfocus / hasFocus bindings ----
604
+
605
+ const HASFOCUS_UPDATING = '__tapout_hasfocusUpdating';
606
+ const HASFOCUS_LAST_VALUE = '__tapout_hasfocusLastValue';
607
+
608
+ const hasfocusHandler: BindingHandler = {
609
+ init(node, valueAccessor, allBindings) {
610
+ const el = node as HTMLElement & Record<string, unknown>;
611
+
612
+ const handleElementFocusChange = (isFocused: boolean) => {
613
+ el[HASFOCUS_UPDATING] = true;
614
+ const ownerDoc = el.ownerDocument;
615
+ if (ownerDoc && 'activeElement' in ownerDoc) {
616
+ try {
617
+ isFocused = (ownerDoc.activeElement === el);
618
+ } catch {
619
+ isFocused = false;
620
+ }
621
+ }
622
+ const modelValue = valueAccessor();
623
+ writeValueToProperty(modelValue, allBindings, 'hasfocus', isFocused, true);
624
+ el[HASFOCUS_LAST_VALUE] = isFocused;
625
+ el[HASFOCUS_UPDATING] = false;
626
+ };
627
+
628
+ const handleFocusIn = () => handleElementFocusChange(true);
629
+ const handleFocusOut = () => handleElementFocusChange(false);
630
+
631
+ el.addEventListener('focus', handleFocusIn);
632
+ el.addEventListener('focusin', handleFocusIn);
633
+ el.addEventListener('blur', handleFocusOut);
634
+ el.addEventListener('focusout', handleFocusOut);
635
+
636
+ el[HASFOCUS_LAST_VALUE] = false;
637
+ },
638
+ update(node, valueAccessor) {
639
+ const el = node as HTMLElement & Record<string, unknown>;
640
+ const value = !!unwrapObservable(valueAccessor());
641
+
642
+ if (!el[HASFOCUS_UPDATING] && el[HASFOCUS_LAST_VALUE] !== value) {
643
+ if (value) {
644
+ el.focus();
645
+ } else {
646
+ el.blur();
647
+ }
648
+ }
649
+ },
650
+ };
651
+
652
+ // ---- Task 25: selectedOptions binding ----
653
+
654
+ const selectedOptionsHandler: BindingHandler = {
655
+ init(node, valueAccessor, allBindings) {
656
+ const el = node as HTMLSelectElement;
657
+
658
+ if ((el.tagName || '').toLowerCase() !== 'select') {
659
+ throw new Error('selectedOptions binding applies only to SELECT elements');
660
+ }
661
+
662
+ function updateFromView() {
663
+ const value = valueAccessor();
664
+ const valueToWrite: unknown[] = [];
665
+ const options = el.getElementsByTagName('option');
666
+ for (let i = 0; i < options.length; i++) {
667
+ if ((options[i] as HTMLOptionElement).selected) {
668
+ valueToWrite.push(selectExtensions.readValue(options[i]));
669
+ }
670
+ }
671
+ writeValueToProperty(value, allBindings, 'selectedOptions', valueToWrite);
672
+ }
673
+
674
+ function updateFromModel() {
675
+ const newValue = unwrapObservable(valueAccessor()) as unknown[] | null;
676
+ const previousScrollTop = el.scrollTop;
677
+
678
+ if (newValue && typeof (newValue as unknown[]).length === 'number') {
679
+ const options = el.getElementsByTagName('option');
680
+ for (let i = 0; i < options.length; i++) {
681
+ const optEl = options[i] as HTMLOptionElement;
682
+ const isSelected = (newValue as unknown[]).indexOf(selectExtensions.readValue(optEl)) >= 0;
683
+ if (optEl.selected !== isSelected) {
684
+ optEl.selected = isSelected;
685
+ }
686
+ }
687
+ }
688
+
689
+ el.scrollTop = previousScrollTop;
690
+ }
691
+
692
+ let updateFromModelComputed: Computed<void> | null = null;
693
+ bindingEvent.subscribe(node, bindingEvent.childrenComplete, () => {
694
+ if (!updateFromModelComputed) {
695
+ el.addEventListener('change', updateFromView);
696
+ updateFromModelComputed = new Computed(updateFromModel);
697
+ addDisposeCallback(node, () => updateFromModelComputed!.dispose());
698
+ } else {
699
+ updateFromView();
700
+ }
701
+ }, null, { notifyImmediately: true });
702
+ },
703
+ update() {},
704
+ };
705
+
706
+ // ---- Task 26: options binding ----
707
+
708
+ const CAPTION_PLACEHOLDER = {};
709
+
710
+ const optionsHandler: BindingHandler = {
711
+ init(node) {
712
+ const el = node as HTMLSelectElement;
713
+ if ((el.tagName || '').toLowerCase() !== 'select') {
714
+ throw new Error('options binding applies only to SELECT elements');
715
+ }
716
+ while (el.options.length > 0) {
717
+ el.removeChild(el.options[0]);
718
+ }
719
+ return { controlsDescendantBindings: true };
720
+ },
721
+ update(node, valueAccessor, allBindings) {
722
+ const el = node as HTMLSelectElement;
723
+
724
+ function selectedOptions(): HTMLOptionElement[] {
725
+ return Array.from(el.options).filter(o => o.selected);
726
+ }
727
+
728
+ const selectWasPreviouslyEmpty = el.length === 0;
729
+ const multiple = el.multiple;
730
+ const previousScrollTop = (!selectWasPreviouslyEmpty && multiple) ? el.scrollTop : null;
731
+ let unwrappedArray = unwrapObservable(valueAccessor()) as unknown[];
732
+ const valueAllowUnset = allBindings.get('valueAllowUnset') && allBindings.has('value');
733
+ const includeDestroyed = allBindings.get('optionsIncludeDestroyed');
734
+ let filteredArray: unknown[] | undefined;
735
+ let previousSelectedValues: unknown[] = [];
736
+
737
+ if (!valueAllowUnset) {
738
+ if (multiple) {
739
+ previousSelectedValues = selectedOptions().map(o => selectExtensions.readValue(o));
740
+ } else if (el.selectedIndex >= 0) {
741
+ previousSelectedValues.push(selectExtensions.readValue(el.options[el.selectedIndex]));
742
+ }
743
+ }
744
+
745
+ if (unwrappedArray) {
746
+ if (!Array.isArray(unwrappedArray)) {
747
+ unwrappedArray = [unwrappedArray];
748
+ }
749
+
750
+ filteredArray = (unwrappedArray as unknown[]).filter((item: unknown) => {
751
+ return includeDestroyed || item === undefined || item === null ||
752
+ !unwrapObservable((item as Record<string, unknown>)?.['_destroy']);
753
+ });
754
+
755
+ if (allBindings.has('optionsCaption')) {
756
+ const captionValue = unwrapObservable(allBindings.get('optionsCaption'));
757
+ if (captionValue !== null && captionValue !== undefined) {
758
+ filteredArray.unshift(CAPTION_PLACEHOLDER);
759
+ }
760
+ }
761
+ }
762
+
763
+ function applyToObject(object: unknown, predicate: unknown, defaultValue: unknown): unknown {
764
+ if (typeof predicate === 'function') return (predicate as Function)(object);
765
+ if (typeof predicate === 'string') return (object as Record<string, unknown>)[predicate];
766
+ return defaultValue;
767
+ }
768
+
769
+ while (el.options.length > 0) {
770
+ el.removeChild(el.options[0]);
771
+ }
772
+
773
+ if (filteredArray) {
774
+ for (let i = 0; i < filteredArray.length; i++) {
775
+ const arrayEntry = filteredArray[i];
776
+ const option = el.ownerDocument.createElement('option') as HTMLOptionElement;
777
+
778
+ if (arrayEntry === CAPTION_PLACEHOLDER) {
779
+ option.textContent = String(unwrapObservable(allBindings.get('optionsCaption')) ?? '');
780
+ selectExtensions.writeValue(option, undefined);
781
+ } else {
782
+ const optionValue = applyToObject(arrayEntry, allBindings.get('optionsValue'), arrayEntry);
783
+ selectExtensions.writeValue(option, unwrapObservable(optionValue));
784
+
785
+ const optionText = applyToObject(arrayEntry, allBindings.get('optionsText'), optionValue);
786
+ option.textContent = String(unwrapObservable(optionText) ?? '');
787
+ }
788
+
789
+ el.appendChild(option);
790
+
791
+ if (previousSelectedValues.length) {
792
+ const isSelected = previousSelectedValues.indexOf(selectExtensions.readValue(option)) >= 0;
793
+ option.selected = isSelected;
794
+ }
795
+
796
+ if (allBindings.has('optionsAfterRender') && typeof allBindings.get('optionsAfterRender') === 'function') {
797
+ ignore(() => {
798
+ (allBindings.get('optionsAfterRender') as Function)(
799
+ option,
800
+ arrayEntry !== CAPTION_PLACEHOLDER ? arrayEntry : undefined,
801
+ );
802
+ });
803
+ }
804
+ }
805
+ }
806
+
807
+ if (!valueAllowUnset) {
808
+ let selectionChanged: boolean;
809
+ if (multiple) {
810
+ selectionChanged = previousSelectedValues.length > 0 && selectedOptions().length < previousSelectedValues.length;
811
+ } else {
812
+ selectionChanged = (previousSelectedValues.length > 0 && el.selectedIndex >= 0)
813
+ ? (selectExtensions.readValue(el.options[el.selectedIndex]) !== previousSelectedValues[0])
814
+ : (previousSelectedValues.length > 0 || el.selectedIndex >= 0);
815
+ }
816
+
817
+ if (selectionChanged) {
818
+ ignore(() => triggerEvent(el, 'change'));
819
+ }
820
+ }
821
+
822
+ if (valueAllowUnset || isInitial()) {
823
+ bindingEvent.notify(node, bindingEvent.childrenComplete);
824
+ }
825
+ },
826
+ };
827
+
828
+ function isWritableObs(value: unknown): value is Observable<unknown> | Computed<unknown> {
829
+ if (value instanceof Observable) return true;
830
+ if (value instanceof Computed) return value.hasWriteFunction;
831
+ return false;
832
+ }
833
+
834
+ // ---- Key event bindings (keydown / keyup with key + modifier filtering) ----
835
+
836
+ const KEY_ALIASES: Record<string, string> = {
837
+ enter: 'Enter', tab: 'Tab', esc: 'Escape', escape: 'Escape',
838
+ space: ' ', delete: 'Delete', backspace: 'Backspace',
839
+ up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight',
840
+ };
841
+
842
+ const MODIFIER_KEYS = new Set(['ctrl', 'alt', 'shift', 'meta']);
843
+
844
+ const MODIFIER_PROPS: Record<string, string> = {
845
+ ctrl: 'ctrlKey', alt: 'altKey', shift: 'shiftKey', meta: 'metaKey',
846
+ };
847
+
848
+ function createKeyFilteredHandler(eventType: string, name: string): BindingHandler {
849
+ const parts = name.split('.');
850
+ const modifiers: string[] = [];
851
+ let keyName: string | undefined;
852
+
853
+ for (const part of parts) {
854
+ if (MODIFIER_KEYS.has(part)) {
855
+ modifiers.push(part);
856
+ } else if (!keyName) {
857
+ keyName = KEY_ALIASES[part] ?? part;
858
+ }
859
+ }
860
+
861
+ return {
862
+ init(node, valueAccessor, allBindings, _viewModel, bindingContext) {
863
+ const el = node as HTMLElement;
864
+ el.addEventListener(eventType, function (event: Event) {
865
+ const ke = event as KeyboardEvent;
866
+ if (keyName && ke.key !== keyName) return;
867
+ for (const mod of modifiers) {
868
+ if (!ke[MODIFIER_PROPS[mod] as keyof KeyboardEvent]) return;
869
+ }
870
+
871
+ const callback = valueAccessor();
872
+ if (typeof callback !== 'function') return;
873
+
874
+ let handlerReturnValue: unknown;
875
+ try {
876
+ const data = bindingContext.$data;
877
+ handlerReturnValue = callback.call(data, data, event);
878
+ } finally {
879
+ if (handlerReturnValue !== true) {
880
+ event.preventDefault();
881
+ }
882
+ }
883
+
884
+ const bubble = allBindings.get(eventType + 'Bubble') !== false;
885
+ if (!bubble) {
886
+ event.stopPropagation();
887
+ }
888
+ });
889
+ },
890
+ };
891
+ }
892
+
893
+ const keydownHandler = makeEventHandlerShortcut('keydown');
894
+ keydownHandler.getNamespacedHandler = function (name, _ns, _namespacedName) {
895
+ return createKeyFilteredHandler('keydown', name);
896
+ };
897
+
898
+ const keyupHandler = makeEventHandlerShortcut('keyup');
899
+ keyupHandler.getNamespacedHandler = function (name, _ns, _namespacedName) {
900
+ return createKeyFilteredHandler('keyup', name);
901
+ };
902
+
903
+ const enterHandler = createKeyFilteredHandler('keydown', 'enter');
904
+
905
+ // ---- modal: toggles <dialog> showModal/close based on observable boolean ----
906
+
907
+ const modalHandler: BindingHandler = {
908
+ init(element, valueAccessor) {
909
+ const dlg = element as HTMLDialogElement;
910
+ const value = unwrapObservable(valueAccessor());
911
+ if (value) dlg.showModal();
912
+ },
913
+ update(element, valueAccessor) {
914
+ const dlg = element as HTMLDialogElement;
915
+ const value = unwrapObservable(valueAccessor());
916
+ if (value) {
917
+ if (!dlg.open) dlg.showModal();
918
+ } else {
919
+ if (dlg.open) dlg.close();
920
+ }
921
+ },
922
+ };
923
+
924
+ // ---- Registration ----
925
+
926
+ bindingHandlers['text'] = textHandler;
927
+ bindingHandlers['html'] = htmlHandler;
928
+ bindingHandlers['visible'] = visibleHandler;
929
+ bindingHandlers['hidden'] = hiddenHandler;
930
+ bindingHandlers['attr'] = attrHandler;
931
+ bindingHandlers['class'] = classHandler;
932
+ bindingHandlers['css'] = cssHandler;
933
+ bindingHandlers['style'] = styleHandler;
934
+ bindingHandlers['enable'] = enableHandler;
935
+ bindingHandlers['disable'] = disableHandler;
936
+ bindingHandlers['uniqueName'] = uniqueNameHandler;
937
+
938
+ bindingHandlers['event'] = eventHandler;
939
+ bindingHandlers['click'] = clickHandler;
940
+ bindingHandlers['submit'] = submitHandler;
941
+ bindingHandlers['value'] = valueHandler;
942
+ bindingHandlers['textInput'] = textInputHandler;
943
+ bindingHandlers['textinput'] = textinputAliasHandler;
944
+ bindingHandlers['checked'] = checkedHandler;
945
+ bindingHandlers['checkedValue'] = checkedValueHandler;
946
+ bindingHandlers['hasfocus'] = hasfocusHandler;
947
+ bindingHandlers['hasFocus'] = hasfocusHandler;
948
+ bindingHandlers['selectedOptions'] = selectedOptionsHandler;
949
+ bindingHandlers['options'] = optionsHandler;
950
+ bindingHandlers['keydown'] = keydownHandler;
951
+ bindingHandlers['keyup'] = keyupHandler;
952
+ bindingHandlers['enter'] = enterHandler;
953
+ bindingHandlers['modal'] = modalHandler;
954
+
955
+ twoWayBindings['value'] = true;
956
+ twoWayBindings['textInput'] = true;
957
+ twoWayBindings['checked'] = true;
958
+ twoWayBindings['hasfocus'] = true;
959
+ twoWayBindings['hasFocus'] = 'hasfocus';
960
+ twoWayBindings['selectedOptions'] = true;
961
+
962
+ allowedVirtualElementBindings['text'] = true;
963
+ allowedVirtualElementBindings['html'] = true;