a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Reactive Attributes & Event Modifiers

* bump to 0.2.0

* fix minification

+1152 -85
+4 -23
ROADMAP.md
··· 14 14 | | ✓ | [Backend Integration & HTTP Actions](#backend-integration--http-actions) | 15 15 | | ✓ | [Proxy-Based Reactivity Enhancements](#proxy-based-reactivity-enhancements) | 16 16 | v0.1.0 | ✓ | [Markup Based Reactivity](#markup-based-reactivity) | 17 - | v0.2.0 | | [Reactive Attributes & Event Modifiers](#reactive-attributes--event-modifiers) | 17 + | v0.2.0 | ✓ | [Reactive Attributes & Event Modifiers](#reactive-attributes--event-modifiers) | 18 18 | v0.3.0 | | [Global State](#global-state) | 19 19 | v0.4.0 | | [Animation & Transitions](#animation--transitions) | 20 20 | v0.5.0 | | [Persistence & Offline](#persistence--offline) | ··· 74 74 - Separate reactive() function for objects/arrays to gives users choice 75 75 - Keep .get()/.set() - explicitness is valuable for understanding reactivity (include in docs) 76 76 77 - ## To-Do 78 - 79 77 ### Reactive Attributes & Event Modifiers 80 78 81 79 **Goal:** Extend Volt.js with expressive attribute patterns and event options for fine-grained control. 82 80 **Outcome:** Volt.js supports rich declarative behaviors and event semantics built entirely on standard DOM APIs. 83 - **Deliverables:** 84 - - ✓ `data-volt-show` - toggles element visibility via CSS rather than DOM removal (complements `data-volt-if`) 85 - - ✓ `data-volt-style` - binds inline styles to reactive expressions 86 - - ✓ `data-volt-skip` - marks elements or subtrees to exclude from Volt’s reactive parsing 87 - - ✓ `data-volt-cloak` - hides content until the Volt runtime initializes 88 - - Event options for `data-volt-on-*` attributes: 89 - - `.prevent` - calls `preventDefault()` on the event 90 - - `.stop` - stops propagation 91 - - `.self` - triggers only when the event target is the bound element 92 - - `.window` - attaches the listener to `window` 93 - - `.document` - attaches the listener to `document` 94 - - `.once` - runs the handler a single time 95 - - `.debounce` - defers handler execution (optional milliseconds) 96 - - `.throttle` - limits handler frequency (optional milliseconds) 97 - - `.passive` - adds a passive event listener for scroll/touch performance 98 - - Input options for `data-volt-bind` and `data-volt-model`: 99 - - `.number` - coerces values to numbers 100 - - `.trim` - removes surrounding whitespace 101 - - `.lazy` - syncs only on `change` instead of `input` 102 - - `.debounce` - delays updates to reduce jitter 81 + **Summary:** Introduced expressive attribute patterns and event modifiers for precise DOM and input control, for fine-grained declarative behavior entirely through standard DOM APIs. 82 + 83 + ## To-Do 103 84 104 85 ### Global State 105 86
+2 -2
lib/deno.json
··· 1 1 { 2 2 "name": "@voltx/core", 3 - "version": "0.1.0", 3 + "version": "0.2.0", 4 4 "exports": "./src/index.ts", 5 5 "imports": { 6 6 "$core/": "./src/core/", ··· 10 10 "$vebug": "./src/debug.ts", 11 11 "$volt": "./src/index.ts" 12 12 } 13 - } 13 + }
+2
lib/eslint.config.js
··· 23 23 "no-undef": "off", 24 24 "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], 25 25 "@typescript-eslint/no-explicit-any": "off", 26 + // Covered by unicorn 27 + "@typescript-eslint/no-this-alias": "off", 26 28 "unicorn/prefer-ternary": "off", 27 29 "no-console": "off", 28 30 "unicorn/filename-case": ["warn", {
+7 -6
lib/package.json
··· 1 1 { 2 2 "name": "voltx.js", 3 - "version": "0.1.0", 3 + "version": "0.2.0", 4 4 "description": "A lightweight reactive framework for declarative UIs", 5 5 "type": "module", 6 6 "author": "Owais Jamil", 7 7 "license": "MIT", 8 8 "repository": { "type": "git", "url": "https://github.com/stormlightlabs/volt.git", "directory": "lib" }, 9 9 "keywords": ["reactive", "signals", "framework", "ui", "declarative", "html", "dom", "frontend"], 10 - "main": "./dist/volt.js", 11 - "module": "./dist/volt.js", 10 + "main": "./dist/voltx.js", 11 + "module": "./dist/voltx.js", 12 12 "types": "./dist/index.d.ts", 13 13 "exports": { 14 - ".": { "types": "./dist/index.d.ts", "import": "./dist/volt.js" }, 14 + ".": { "types": "./dist/index.d.ts", "import": "./dist/voltx.js" }, 15 15 "./debug": { "types": "./dist/debug.d.ts", "import": "./dist/debug.js" }, 16 16 "./css": "./dist/volt.css", 17 17 "./package.json": "./package.json" ··· 21 21 "dev": "vite", 22 22 "build": "pnpm build:lib && pnpm build:css", 23 23 "build:lib": "tsc -p tsconfig.build.json && vite build --mode lib", 24 - "build:css": "postcss src/styles/index.css -o dist/volt.css", 25 - "build:css:min": "postcss src/styles/index.css -o dist/volt.min.css --env production", 24 + "build:css": "postcss src/styles/index.css -o dist/voltx.css", 25 + "build:css:min": "postcss src/styles/index.css -o dist/voltx.min.css --env production", 26 26 "preview": "vite preview", 27 27 "test": "vitest", 28 28 "test:run": "vitest run", ··· 40 40 "cssnano": "^7.1.1", 41 41 "dprint": "^0.50.2", 42 42 "jsdom": "^27.0.0", 43 + "oxc": "^1.0.1", 43 44 "postcss": "^8.5.6", 44 45 "postcss-cli": "^11.0.1", 45 46 "postcss-import": "^16.1.1",
+142 -34
lib/src/core/binder.ts
··· 3 3 */ 4 4 5 5 import type { Optional } from "$types/helpers"; 6 - import type { BindingContext, CleanupFunction, PluginContext, Scope, Signal } from "$types/volt"; 6 + import type { 7 + BindingContext, 8 + CleanupFunction, 9 + FormControlElement, 10 + Modifier, 11 + PluginContext, 12 + Scope, 13 + Signal, 14 + } from "$types/volt"; 7 15 import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 8 16 import { evaluate, extractDeps } from "./evaluator"; 9 17 import { bindDelete, bindGet, bindPatch, bindPost, bindPut } from "./http"; 10 18 import { execGlobalHooks, notifyBindingCreated, notifyElementMounted, notifyElementUnmounted } from "./lifecycle"; 19 + import { debounce, getModifierValue, hasModifier, parseModifiers, throttle } from "./modifiers"; 11 20 import { getPlugin } from "./plugin"; 12 21 import { findScopedSignal, isNil } from "./shared"; 13 22 ··· 93 102 */ 94 103 function bindAttribute(ctx: BindingContext, name: string, value: string): void { 95 104 if (name.startsWith("on-")) { 96 - const eventName = name.slice(3); 97 - bindEvent(ctx, eventName, value); 105 + const eventSpec = name.slice(3); 106 + const { baseName: eventName, modifiers } = parseModifiers(eventSpec); 107 + bindEvent(ctx, eventName, value, modifiers); 98 108 return; 99 109 } 100 110 101 - if (name.startsWith("bind:")) { 102 - const attrName = name.slice(5); 103 - bindAttr(ctx, attrName, value); 111 + if (name.startsWith("bind:") || name.startsWith("bind-")) { 112 + const attrSpec = name.slice(5); 113 + const { baseName: attrName, modifiers } = parseModifiers(attrSpec); 114 + bindAttr(ctx, attrName, value, modifiers); 104 115 return; 105 116 } 106 117 107 - switch (name) { 118 + const { baseName, modifiers } = parseModifiers(name); 119 + 120 + switch (baseName) { 108 121 case "text": { 109 122 bindText(ctx, value); 110 123 break; ··· 126 139 break; 127 140 } 128 141 case "model": { 129 - bindModel(ctx, value); 142 + bindModel(ctx, value, modifiers); 130 143 break; 131 144 } 132 145 case "for": { ··· 154 167 break; 155 168 } 156 169 default: { 157 - const plugin = getPlugin(name); 170 + const plugin = getPlugin(baseName); 158 171 if (plugin) { 159 172 const pluginContext = createPluginCtx(ctx); 160 173 try { 161 174 plugin(pluginContext, value); 162 175 } catch (error) { 163 - console.error(`Error in plugin "${name}":`, error); 176 + console.error(`Error in plugin "${baseName}":`, error); 164 177 } 165 178 } else { 166 - console.warn(`Unknown binding: data-volt-${name}`); 179 + console.warn(`Unknown binding: data-volt-${baseName}`); 167 180 } 168 181 } 169 182 } ··· 200 213 201 214 update(); 202 215 203 - const dependencies = extractDeps(expr, ctx.scope); 204 - for (const dependency of dependencies) { 205 - const unsubscribe = dependency.subscribe(update); 216 + const deps = extractDeps(expr, ctx.scope); 217 + for (const dep of deps) { 218 + const unsubscribe = dep.subscribe(update); 206 219 ctx.cleanups.push(unsubscribe); 207 220 } 208 221 } ··· 307 320 } 308 321 309 322 /** 310 - * Bind data-volt-on-* to attach event listeners. 323 + * Bind data-volt-on-* to attach event listeners with support for modifiers. 311 324 * Provides $el and $event in the scope for the event handler. 325 + * 326 + * Supported modifiers: 327 + * - .prevent - calls preventDefault() 328 + * - .stop - calls stopPropagation() 329 + * - .self - only trigger if event.target === element 330 + * - .window - attach listener to window 331 + * - .document - attach listener to document 332 + * - .once - run handler only once 333 + * - .debounce[.ms] - debounce handler (default 300ms) 334 + * - .throttle[.ms] - throttle handler (default 300ms) 335 + * - .passive - add passive event listener 312 336 */ 313 - function bindEvent(ctx: BindingContext, eventName: string, expr: string): void { 314 - const handler = (event: Event) => { 337 + function bindEvent(ctx: BindingContext, eventName: string, expr: string, modifiers: Modifier[] = []): void { 338 + const executeHandler = (event: Event) => { 315 339 const eventScope: Scope = { ...ctx.scope, $el: ctx.element, $event: event }; 316 340 317 341 try { ··· 324 348 } 325 349 }; 326 350 327 - ctx.element.addEventListener(eventName, handler); 351 + let wrappedExecute = executeHandler; 352 + 353 + if (hasModifier(modifiers, "debounce")) { 354 + const wait = getModifierValue(modifiers, "debounce", 300); 355 + const debouncedExecute = debounce(executeHandler, wait); 356 + wrappedExecute = debouncedExecute as typeof executeHandler; 357 + ctx.cleanups.push(() => debouncedExecute.cancel()); 358 + } else if (hasModifier(modifiers, "throttle")) { 359 + const wait = getModifierValue(modifiers, "throttle", 300); 360 + const throttledExecute = throttle(executeHandler, wait); 361 + wrappedExecute = throttledExecute as typeof executeHandler; 362 + ctx.cleanups.push(() => throttledExecute.cancel()); 363 + } 364 + 365 + const handler = (event: Event) => { 366 + if (hasModifier(modifiers, "self") && event.target !== ctx.element) { 367 + return; 368 + } 369 + 370 + if (hasModifier(modifiers, "prevent")) { 371 + event.preventDefault(); 372 + } 373 + 374 + if (hasModifier(modifiers, "stop")) { 375 + event.stopPropagation(); 376 + } 377 + 378 + wrappedExecute(event); 379 + }; 380 + 381 + const target = hasModifier(modifiers, "window") 382 + ? globalThis 383 + : (hasModifier(modifiers, "document") ? document : ctx.element); 384 + 385 + const options: AddEventListenerOptions = {}; 386 + if (hasModifier(modifiers, "once")) { 387 + options.once = true; 388 + } 389 + if (hasModifier(modifiers, "passive")) { 390 + options.passive = true; 391 + } 392 + 393 + target.addEventListener(eventName, handler, options); 328 394 329 395 ctx.cleanups.push(() => { 330 - ctx.element.removeEventListener(eventName, handler); 396 + target.removeEventListener(eventName, handler, options); 331 397 }); 332 398 } 333 399 334 400 /** 335 - * Bind data-volt-model for two-way data binding on form elements. 401 + * Bind data-volt-model for two-way data binding on form elements with support for modifiers. 336 402 * Syncs the signal value with the input value bidirectionally. 403 + * 404 + * Supported modifiers: 405 + * - .number - coerces values to numbers 406 + * - .trim - removes surrounding whitespace 407 + * - .lazy - syncs on 'change' instead of 'input' 408 + * - .debounce[.ms] - debounces signal updates (default 300ms) 337 409 */ 338 - function bindModel(context: BindingContext, signalPath: string): void { 410 + function bindModel(context: BindingContext, signalPath: string, modifiers: Modifier[] = []): void { 339 411 const signal = findScopedSignal(context.scope, signalPath); 340 412 if (!signal) { 341 413 console.error(`Signal "${signalPath}" not found for data-volt-model`); 342 414 return; 343 415 } 344 416 345 - const element = context.element as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; 417 + const element = context.element as FormControlElement; 346 418 const type = element instanceof HTMLInputElement ? element.type : null; 347 419 const initialValue = signal.get(); 348 420 setElementValue(element, initialValue, type); ··· 353 425 }); 354 426 context.cleanups.push(unsubscribe); 355 427 356 - const eventName = type === "checkbox" || type === "radio" ? "change" : "input"; 428 + const isLazy = hasModifier(modifiers, "lazy"); 429 + const isNumber = hasModifier(modifiers, "number"); 430 + const isTrim = hasModifier(modifiers, "trim"); 357 431 358 - const handler = () => { 359 - const value = getElementValue(element, type); 432 + const defaultEventName = type === "checkbox" || type === "radio" ? "change" : "input"; 433 + const eventName = isLazy ? "change" : defaultEventName; 434 + 435 + const baseHandler = () => { 436 + let value = getElementValue(element, type); 437 + 438 + if (typeof value === "string") { 439 + if (isTrim) { 440 + value = value.trim(); 441 + } 442 + if (isNumber) { 443 + value = value === "" ? Number.NaN : Number(value); 444 + } 445 + } 446 + 360 447 (signal as Signal<unknown>).set(value); 361 448 }; 362 449 450 + let handler = baseHandler; 451 + 452 + if (hasModifier(modifiers, "debounce")) { 453 + const wait = getModifierValue(modifiers, "debounce", 300); 454 + const debouncedHandler = debounce(baseHandler, wait); 455 + handler = debouncedHandler as typeof baseHandler; 456 + context.cleanups.push(() => debouncedHandler.cancel()); 457 + } 458 + 363 459 element.addEventListener(eventName, handler); 364 460 context.cleanups.push(() => { 365 461 element.removeEventListener(eventName, handler); 366 462 }); 367 463 } 368 464 369 - function setElementValue( 370 - el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, 371 - value: unknown, 372 - type: string | null, 373 - ): void { 465 + function setElementValue(el: FormControlElement, value: unknown, type: string | null): void { 374 466 if (el instanceof HTMLInputElement) { 375 467 switch (type) { 376 468 case "checkbox": { ··· 397 489 } 398 490 } 399 491 400 - function getElementValue(el: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement, type: string | null): unknown { 492 + function getElementValue(el: FormControlElement, type: string | null): unknown { 401 493 if (el instanceof HTMLInputElement) { 402 494 if (type === "checkbox") { 403 495 return el.checked; ··· 420 512 } 421 513 422 514 /** 423 - * Bind data-volt-bind:attr for generic attribute binding. 515 + * Bind data-volt-bind:attr for generic attribute binding with support for modifiers. 424 516 * Updates any HTML attribute reactively based on expression value. 517 + * 518 + * Supported modifiers: 519 + * - .number - coerces values to numbers 520 + * - .trim - removes surrounding whitespace 425 521 */ 426 - function bindAttr(ctx: BindingContext, attrName: string, expr: string): void { 522 + function bindAttr(ctx: BindingContext, attrName: string, expr: string, modifiers: Modifier[] = []): void { 523 + const isNumber = hasModifier(modifiers, "number"); 524 + const isTrim = hasModifier(modifiers, "trim"); 525 + 427 526 const update = () => { 428 - const value = evaluate(expr, ctx.scope); 527 + let value = evaluate(expr, ctx.scope); 528 + 529 + if (typeof value === "string") { 530 + if (isTrim) { 531 + value = value.trim(); 532 + } 533 + if (isNumber) { 534 + value = value === "" ? Number.NaN : Number(value); 535 + } 536 + } 429 537 430 538 const booleanAttrs = new Set([ 431 539 "disabled",
+177
lib/src/core/modifiers.ts
··· 1 + /** 2 + * Modifier utilities for event and input bindings 3 + * 4 + * Provides parsing and application of modifiers like .prevent, .stop, .debounce, etc. 5 + */ 6 + 7 + import type { Optional, Timer } from "$types/helpers"; 8 + import type { Modifier, ParsedAttribute, TimedFunction } from "$types/volt"; 9 + 10 + /** 11 + * Parse attribute name to extract base name and modifiers. 12 + * 13 + * Modifiers are separated by dashes only when the entire string uses dash-case (e.g., from dataset). 14 + * This allows attribute names to contain dashes (like aria-label) while still supporting modifiers. 15 + * 16 + * Examples: 17 + * - "click-prevent-stop" -> {baseName: "click", modifiers: [{name: "prevent"}, {name: "stop"}]} 18 + * - "aria-label" -> {baseName: "aria-label", modifiers: []} (no modifiers detected) 19 + * - "input-debounce500" -> {baseName: "input", modifiers: [{name: "debounce", value: 500}]} 20 + * 21 + * @param attrName - The attribute name with potential modifiers 22 + * @returns Parsed attribute with base name and modifiers array 23 + */ 24 + export function parseModifiers(attrName: string): ParsedAttribute { 25 + const parts = attrName.split("-"); 26 + 27 + if (parts.length === 1) { 28 + return { baseName: attrName, modifiers: [] }; 29 + } 30 + 31 + const baseName = parts[0]; 32 + const modifiers: Modifier[] = []; 33 + const KNOWN_MODIFIERS = new Set([ 34 + "prevent", 35 + "stop", 36 + "self", 37 + "window", 38 + "document", 39 + "once", 40 + "debounce", 41 + "throttle", 42 + "passive", 43 + "number", 44 + "trim", 45 + "lazy", 46 + ]); 47 + 48 + let i = 1; 49 + while (i < parts.length) { 50 + const part = parts[i]; 51 + 52 + const numMatch = /^([a-zA-Z]+)(\d+)$/.exec(part); 53 + if (numMatch && KNOWN_MODIFIERS.has(numMatch[1])) { 54 + modifiers.push({ name: numMatch[1], value: Number(numMatch[2]) }); 55 + i++; 56 + } else if (KNOWN_MODIFIERS.has(part)) { 57 + if (i + 1 < parts.length) { 58 + const numValue = Number(parts[i + 1]); 59 + if (!Number.isNaN(numValue)) { 60 + modifiers.push({ name: part, value: numValue }); 61 + i += 2; 62 + continue; 63 + } 64 + } 65 + modifiers.push({ name: part }); 66 + i++; 67 + } else { 68 + break; 69 + } 70 + } 71 + 72 + if (modifiers.length === 0) { 73 + return { baseName: attrName, modifiers: [] }; 74 + } 75 + 76 + return { baseName, modifiers }; 77 + } 78 + 79 + /** 80 + * Check if a modifier is present in the modifiers array 81 + */ 82 + export function hasModifier(modifiers: Modifier[], name: string): boolean { 83 + return modifiers.some((m) => m.name === name); 84 + } 85 + 86 + /** 87 + * Get a modifier's value or return a default 88 + */ 89 + export function getModifierValue(modifiers: Modifier[], name: string, defaultValue: number): number { 90 + const modifier = modifiers.find((m) => m.name === name); 91 + return modifier?.value ?? defaultValue; 92 + } 93 + 94 + /** 95 + * Create a debounced version of a function. 96 + * Delays execution until after the specified wait time has elapsed since the last call. 97 + * 98 + * @param fn - Function to debounce 99 + * @param wait - Milliseconds to wait before executing 100 + * @returns Debounced function with cleanup method 101 + */ 102 + export function debounce<T extends unknown[], R>(fn: (...args: T) => R, wait: number): TimedFunction<T> { 103 + let timeoutId: Optional<Timer>; 104 + 105 + const debounced = function(this: unknown, ...args: T) { 106 + if (timeoutId !== undefined) { 107 + clearTimeout(timeoutId); 108 + } 109 + 110 + timeoutId = setTimeout(() => { 111 + timeoutId = undefined; 112 + fn.apply(this, args); 113 + }, wait); 114 + }; 115 + 116 + debounced.cancel = () => { 117 + if (timeoutId !== undefined) { 118 + clearTimeout(timeoutId); 119 + timeoutId = undefined; 120 + } 121 + }; 122 + 123 + return debounced; 124 + } 125 + 126 + /** 127 + * Create a throttled version of a function. 128 + * Limits execution to at most once per specified wait time. 129 + * 130 + * @param fn - Function to throttle 131 + * @param wait - Milliseconds to wait between executions 132 + * @returns Throttled function with cleanup method 133 + */ 134 + export function throttle<T extends unknown[], R>(fn: (...args: T) => R, wait: number): TimedFunction<T> { 135 + let timeoutId: Optional<Timer>; 136 + let lastExecutionTime = 0; 137 + let pendingArgs: Optional<T>; 138 + let pendingThis: unknown; 139 + 140 + const throttled = function(this: unknown, ...args: T) { 141 + const now = Date.now(); 142 + const timeSinceLastExecution = now - lastExecutionTime; 143 + 144 + pendingArgs = args; 145 + // eslint-disable-next-line unicorn/no-this-assignment 146 + pendingThis = this; 147 + 148 + if (timeSinceLastExecution >= wait) { 149 + lastExecutionTime = now; 150 + fn.apply(this, args); 151 + pendingArgs = undefined; 152 + pendingThis = undefined; 153 + } else if (timeoutId === undefined) { 154 + const remainingTime = wait - timeSinceLastExecution; 155 + timeoutId = setTimeout(() => { 156 + timeoutId = undefined; 157 + lastExecutionTime = Date.now(); 158 + if (pendingArgs !== undefined) { 159 + fn.apply(pendingThis, pendingArgs); 160 + pendingArgs = undefined; 161 + pendingThis = undefined; 162 + } 163 + }, remainingTime); 164 + } 165 + }; 166 + 167 + throttled.cancel = () => { 168 + if (timeoutId !== undefined) { 169 + clearTimeout(timeoutId); 170 + timeoutId = undefined; 171 + } 172 + pendingArgs = undefined; 173 + pendingThis = undefined; 174 + }; 175 + 176 + return throttled; 177 + }
+17
lib/src/types/volt.d.ts
··· 325 325 export type SignalType = "signal" | "computed" | "reactive"; 326 326 327 327 export type SignalMetadata = { id: string; type: SignalType; name?: string; createdAt: number; stackTrace?: string }; 328 + 329 + export type FormControlElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; 330 + 331 + /** 332 + * Throttle/Debounced function 333 + */ 334 + export type TimedFunction<A extends unknown[]> = ((...args: A) => void) & { cancel: () => void }; 335 + 336 + /** 337 + * Represents a parsed modifier with optional numeric value 338 + */ 339 + export type Modifier = { name: string; value?: number }; 340 + 341 + /** 342 + * Result of parsing an attribute name with modifiers 343 + */ 344 + export type ParsedAttribute = { baseName: string; modifiers: Modifier[] };
+410
lib/test/core/event-modifiers.test.ts
··· 1 + import { mount } from "$core/binder"; 2 + import { signal } from "$core/signal"; 3 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 + 5 + describe("event modifiers", () => { 6 + beforeEach(() => { 7 + vi.useFakeTimers(); 8 + }); 9 + 10 + afterEach(() => { 11 + vi.restoreAllMocks(); 12 + }); 13 + 14 + describe(".prevent modifier", () => { 15 + it("calls preventDefault on the event", () => { 16 + const form = document.createElement("form"); 17 + form.dataset.voltOnSubmit = "handleSubmit"; 18 + form.dataset.voltOnSubmitPrevent = ""; 19 + 20 + const handleSubmit = vi.fn(); 21 + mount(form, { handleSubmit }); 22 + 23 + const submitEvent = new Event("submit", { cancelable: true }); 24 + form.dispatchEvent(submitEvent); 25 + 26 + expect(submitEvent.defaultPrevented).toBe(true); 27 + expect(handleSubmit).toHaveBeenCalled(); 28 + }); 29 + 30 + it("works with inline expressions", () => { 31 + const button = document.createElement("button"); 32 + button.dataset.voltOnClickPrevent = "count.set(count.get() + 1)"; 33 + 34 + const count = signal(0); 35 + mount(button, { count }); 36 + 37 + const clickEvent = new MouseEvent("click", { cancelable: true }); 38 + button.dispatchEvent(clickEvent); 39 + 40 + expect(clickEvent.defaultPrevented).toBe(true); 41 + expect(count.get()).toBe(1); 42 + }); 43 + }); 44 + 45 + describe(".stop modifier", () => { 46 + it("calls stopPropagation on the event", () => { 47 + const div = document.createElement("div"); 48 + const button = document.createElement("button"); 49 + button.dataset.voltOnClickStop = "handleClick"; 50 + div.append(button); 51 + 52 + const handleClick = vi.fn(); 53 + const handleDivClick = vi.fn(); 54 + 55 + mount(button, { handleClick }); 56 + div.addEventListener("click", handleDivClick); 57 + 58 + button.click(); 59 + 60 + expect(handleClick).toHaveBeenCalled(); 61 + expect(handleDivClick).not.toHaveBeenCalled(); 62 + }); 63 + }); 64 + 65 + describe(".self modifier", () => { 66 + it("only triggers when event.target is the bound element", () => { 67 + const div = document.createElement("div"); 68 + const span = document.createElement("span"); 69 + div.dataset.voltOnClickSelf = "handleClick"; 70 + div.append(span); 71 + 72 + const handleClick = vi.fn(); 73 + mount(div, { handleClick }); 74 + 75 + span.click(); 76 + expect(handleClick).not.toHaveBeenCalled(); 77 + 78 + div.click(); 79 + expect(handleClick).toHaveBeenCalled(); 80 + }); 81 + }); 82 + 83 + describe(".once modifier", () => { 84 + it("only triggers the handler once", () => { 85 + const button = document.createElement("button"); 86 + button.dataset.voltOnClickOnce = "handleClick"; 87 + 88 + const handleClick = vi.fn(); 89 + mount(button, { handleClick }); 90 + 91 + button.click(); 92 + expect(handleClick).toHaveBeenCalledTimes(1); 93 + 94 + button.click(); 95 + expect(handleClick).toHaveBeenCalledTimes(1); 96 + }); 97 + }); 98 + 99 + describe(".passive modifier", () => { 100 + it("adds passive event listener", () => { 101 + const div = document.createElement("div"); 102 + div.dataset.voltOnScrollPassive = "handleScroll"; 103 + 104 + const handleScroll = vi.fn(); 105 + const addEventListenerSpy = vi.spyOn(div, "addEventListener"); 106 + 107 + mount(div, { handleScroll }); 108 + 109 + expect(addEventListenerSpy).toHaveBeenCalledWith("scroll", expect.any(Function), { passive: true }); 110 + }); 111 + }); 112 + 113 + describe(".window modifier", () => { 114 + it("attaches listener to window", () => { 115 + const button = document.createElement("button"); 116 + button.dataset.voltOnResizeWindow = "handleResize"; 117 + 118 + const handleResize = vi.fn(); 119 + const cleanup = mount(button, { handleResize }); 120 + 121 + globalThis.dispatchEvent(new Event("resize")); 122 + expect(handleResize).toHaveBeenCalled(); 123 + 124 + cleanup(); 125 + }); 126 + 127 + it("still provides $el context", () => { 128 + const button = document.createElement("button"); 129 + button.id = "test-button"; 130 + button.dataset.voltOnClickWindow = "elementId.set($el.id)"; 131 + 132 + const elementId = signal(""); 133 + const cleanup = mount(button, { elementId }); 134 + 135 + globalThis.dispatchEvent(new MouseEvent("click")); 136 + expect(elementId.get()).toBe("test-button"); 137 + 138 + cleanup(); 139 + }); 140 + }); 141 + 142 + describe(".document modifier", () => { 143 + it("attaches listener to document", () => { 144 + const button = document.createElement("button"); 145 + button.dataset.voltOnClickDocument = "handleClick"; 146 + 147 + const handleClick = vi.fn(); 148 + const cleanup = mount(button, { handleClick }); 149 + 150 + document.dispatchEvent(new MouseEvent("click")); 151 + expect(handleClick).toHaveBeenCalled(); 152 + 153 + cleanup(); 154 + }); 155 + 156 + it("still provides $el context", () => { 157 + const button = document.createElement("button"); 158 + button.id = "doc-button"; 159 + button.dataset.voltOnKeydownDocument = "elementId.set($el.id)"; 160 + 161 + const elementId = signal(""); 162 + const cleanup = mount(button, { elementId }); 163 + 164 + document.dispatchEvent(new KeyboardEvent("keydown")); 165 + expect(elementId.get()).toBe("doc-button"); 166 + 167 + cleanup(); 168 + }); 169 + }); 170 + 171 + describe(".debounce modifier", () => { 172 + it("debounces handler with default delay (300ms)", () => { 173 + const input = document.createElement("input"); 174 + input.dataset.voltOnInputDebounce = "handleInput"; 175 + 176 + const handleInput = vi.fn(); 177 + mount(input, { handleInput }); 178 + 179 + input.dispatchEvent(new Event("input")); 180 + expect(handleInput).not.toHaveBeenCalled(); 181 + 182 + vi.advanceTimersByTime(299); 183 + expect(handleInput).not.toHaveBeenCalled(); 184 + 185 + vi.advanceTimersByTime(1); 186 + expect(handleInput).toHaveBeenCalledTimes(1); 187 + }); 188 + 189 + it("supports custom debounce delay", () => { 190 + const input = document.createElement("input"); 191 + input.dataset.voltOnInputDebounce500 = "handleInput"; 192 + 193 + const handleInput = vi.fn(); 194 + mount(input, { handleInput }); 195 + 196 + input.dispatchEvent(new Event("input")); 197 + vi.advanceTimersByTime(499); 198 + expect(handleInput).not.toHaveBeenCalled(); 199 + 200 + vi.advanceTimersByTime(1); 201 + expect(handleInput).toHaveBeenCalledTimes(1); 202 + }); 203 + 204 + it("resets timer on subsequent events", () => { 205 + const input = document.createElement("input"); 206 + input.dataset.voltOnInputDebounce100 = "handleInput"; 207 + 208 + const handleInput = vi.fn(); 209 + mount(input, { handleInput }); 210 + 211 + input.dispatchEvent(new Event("input")); 212 + vi.advanceTimersByTime(50); 213 + 214 + input.dispatchEvent(new Event("input")); 215 + vi.advanceTimersByTime(50); 216 + expect(handleInput).not.toHaveBeenCalled(); 217 + 218 + vi.advanceTimersByTime(50); 219 + expect(handleInput).toHaveBeenCalledTimes(1); 220 + }); 221 + 222 + it("cancels pending debounced calls on cleanup", () => { 223 + const input = document.createElement("input"); 224 + input.dataset.voltOnInputDebounce100 = "handleInput"; 225 + 226 + const handleInput = vi.fn(); 227 + const cleanup = mount(input, { handleInput }); 228 + 229 + input.dispatchEvent(new Event("input")); 230 + vi.advanceTimersByTime(50); 231 + 232 + cleanup(); 233 + 234 + vi.advanceTimersByTime(100); 235 + expect(handleInput).not.toHaveBeenCalled(); 236 + }); 237 + }); 238 + 239 + describe(".throttle modifier", () => { 240 + it("throttles handler with default delay (300ms)", () => { 241 + const button = document.createElement("button"); 242 + button.dataset.voltOnClickThrottle = "handleClick"; 243 + 244 + const handleClick = vi.fn(); 245 + mount(button, { handleClick }); 246 + 247 + button.click(); 248 + expect(handleClick).toHaveBeenCalledTimes(1); 249 + 250 + button.click(); 251 + expect(handleClick).toHaveBeenCalledTimes(1); 252 + 253 + vi.advanceTimersByTime(300); 254 + expect(handleClick).toHaveBeenCalledTimes(2); 255 + }); 256 + 257 + it("supports custom throttle delay", () => { 258 + const button = document.createElement("button"); 259 + button.dataset.voltOnClickThrottle100 = "handleClick"; 260 + 261 + const handleClick = vi.fn(); 262 + mount(button, { handleClick }); 263 + 264 + button.click(); 265 + expect(handleClick).toHaveBeenCalledTimes(1); 266 + 267 + button.click(); 268 + vi.advanceTimersByTime(100); 269 + expect(handleClick).toHaveBeenCalledTimes(2); 270 + }); 271 + 272 + it("executes immediately on first call", () => { 273 + const button = document.createElement("button"); 274 + button.dataset.voltOnClickThrottle100 = "handleClick"; 275 + 276 + const handleClick = vi.fn(); 277 + mount(button, { handleClick }); 278 + 279 + button.click(); 280 + expect(handleClick).toHaveBeenCalledTimes(1); 281 + }); 282 + 283 + it("cancels pending throttled calls on cleanup", () => { 284 + const button = document.createElement("button"); 285 + button.dataset.voltOnClickThrottle100 = "handleClick"; 286 + 287 + const handleClick = vi.fn(); 288 + const cleanup = mount(button, { handleClick }); 289 + 290 + button.click(); 291 + expect(handleClick).toHaveBeenCalledTimes(1); 292 + 293 + button.click(); 294 + cleanup(); 295 + 296 + vi.advanceTimersByTime(100); 297 + expect(handleClick).toHaveBeenCalledTimes(1); 298 + }); 299 + }); 300 + 301 + describe("modifier combinations", () => { 302 + it("combines .prevent and .stop", () => { 303 + const form = document.createElement("form"); 304 + const button = document.createElement("button"); 305 + button.type = "submit"; 306 + button.dataset.voltOnClickPreventStop = "handleClick"; 307 + form.append(button); 308 + 309 + const handleClick = vi.fn(); 310 + const handleFormSubmit = vi.fn(); 311 + 312 + mount(button, { handleClick }); 313 + form.addEventListener("click", handleFormSubmit); 314 + 315 + const clickEvent = new MouseEvent("click", { cancelable: true, bubbles: true }); 316 + button.dispatchEvent(clickEvent); 317 + 318 + expect(clickEvent.defaultPrevented).toBe(true); 319 + expect(handleClick).toHaveBeenCalled(); 320 + expect(handleFormSubmit).not.toHaveBeenCalled(); 321 + }); 322 + 323 + it("combines .self and .prevent", () => { 324 + const div = document.createElement("div"); 325 + const span = document.createElement("span"); 326 + div.dataset.voltOnClickSelfPrevent = "handleClick"; 327 + div.append(span); 328 + 329 + const handleClick = vi.fn(); 330 + mount(div, { handleClick }); 331 + 332 + const spanEvent = new MouseEvent("click", { cancelable: true, bubbles: true }); 333 + span.dispatchEvent(spanEvent); 334 + expect(handleClick).not.toHaveBeenCalled(); 335 + expect(spanEvent.defaultPrevented).toBe(false); 336 + 337 + const divEvent = new MouseEvent("click", { cancelable: true }); 338 + div.dispatchEvent(divEvent); 339 + expect(handleClick).toHaveBeenCalled(); 340 + expect(divEvent.defaultPrevented).toBe(true); 341 + }); 342 + 343 + it("combines .debounce with .prevent", () => { 344 + const form = document.createElement("form"); 345 + form.dataset.voltOnSubmitDebounce100Prevent = "handleSubmit"; 346 + 347 + const handleSubmit = vi.fn(); 348 + mount(form, { handleSubmit }); 349 + 350 + const submitEvent = new Event("submit", { cancelable: true }); 351 + form.dispatchEvent(submitEvent); 352 + 353 + expect(submitEvent.defaultPrevented).toBe(true); 354 + expect(handleSubmit).not.toHaveBeenCalled(); 355 + 356 + vi.advanceTimersByTime(100); 357 + expect(handleSubmit).toHaveBeenCalledTimes(1); 358 + }); 359 + }); 360 + 361 + describe("cleanup", () => { 362 + it("removes event listeners on unmount", () => { 363 + const button = document.createElement("button"); 364 + button.dataset.voltOnClick = "handleClick"; 365 + 366 + const handleClick = vi.fn(); 367 + const cleanup = mount(button, { handleClick }); 368 + 369 + button.click(); 370 + expect(handleClick).toHaveBeenCalledTimes(1); 371 + 372 + cleanup(); 373 + 374 + button.click(); 375 + expect(handleClick).toHaveBeenCalledTimes(1); 376 + }); 377 + 378 + it("removes window event listeners on unmount", () => { 379 + const button = document.createElement("button"); 380 + button.dataset.voltOnResizeWindow = "handleResize"; 381 + 382 + const handleResize = vi.fn(); 383 + const cleanup = mount(button, { handleResize }); 384 + 385 + globalThis.dispatchEvent(new Event("resize")); 386 + expect(handleResize).toHaveBeenCalledTimes(1); 387 + 388 + cleanup(); 389 + 390 + globalThis.dispatchEvent(new Event("resize")); 391 + expect(handleResize).toHaveBeenCalledTimes(1); 392 + }); 393 + 394 + it("removes document event listeners on unmount", () => { 395 + const button = document.createElement("button"); 396 + button.dataset.voltOnClickDocument = "handleClick"; 397 + 398 + const handleClick = vi.fn(); 399 + const cleanup = mount(button, { handleClick }); 400 + 401 + document.dispatchEvent(new MouseEvent("click")); 402 + expect(handleClick).toHaveBeenCalledTimes(1); 403 + 404 + cleanup(); 405 + 406 + document.dispatchEvent(new MouseEvent("click")); 407 + expect(handleClick).toHaveBeenCalledTimes(1); 408 + }); 409 + }); 410 + });
+360
lib/test/core/input-modifiers.test.ts
··· 1 + import { mount } from "$core/binder"; 2 + import { signal } from "$core/signal"; 3 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 + 5 + describe("input modifiers", () => { 6 + beforeEach(() => { 7 + vi.useFakeTimers(); 8 + }); 9 + 10 + afterEach(() => { 11 + vi.restoreAllMocks(); 12 + }); 13 + 14 + describe("data-volt-model modifiers", () => { 15 + describe(".number modifier", () => { 16 + it("coerces string values to numbers", () => { 17 + const input = document.createElement("input"); 18 + input.type = "text"; 19 + input.dataset.voltModelNumber = "count"; 20 + 21 + const count = signal(0); 22 + mount(input, { count }); 23 + 24 + input.value = "42"; 25 + input.dispatchEvent(new Event("input")); 26 + 27 + expect(count.get()).toBe(42); 28 + }); 29 + 30 + it("handles empty strings as NaN", () => { 31 + const input = document.createElement("input"); 32 + input.type = "text"; 33 + input.dataset.voltModelNumber = "value"; 34 + 35 + const value = signal(0); 36 + mount(input, { value }); 37 + 38 + input.value = ""; 39 + input.dispatchEvent(new Event("input")); 40 + 41 + expect(Number.isNaN(value.get())).toBe(true); 42 + }); 43 + 44 + it("handles decimal numbers", () => { 45 + const input = document.createElement("input"); 46 + input.type = "text"; 47 + input.dataset.voltModelNumber = "price"; 48 + 49 + const price = signal(0); 50 + mount(input, { price }); 51 + 52 + input.value = "19.99"; 53 + input.dispatchEvent(new Event("input")); 54 + 55 + expect(price.get()).toBe(19.99); 56 + }); 57 + }); 58 + 59 + describe(".trim modifier", () => { 60 + it("trims whitespace from string values", () => { 61 + const input = document.createElement("input"); 62 + input.type = "text"; 63 + input.dataset.voltModelTrim = "name"; 64 + 65 + const name = signal(""); 66 + mount(input, { name }); 67 + 68 + input.value = " John Doe "; 69 + input.dispatchEvent(new Event("input")); 70 + 71 + expect(name.get()).toBe("John Doe"); 72 + }); 73 + 74 + it("handles strings with only whitespace", () => { 75 + const input = document.createElement("input"); 76 + input.type = "text"; 77 + input.dataset.voltModelTrim = "value"; 78 + 79 + const value = signal(""); 80 + mount(input, { value }); 81 + 82 + input.value = " "; 83 + input.dispatchEvent(new Event("input")); 84 + 85 + expect(value.get()).toBe(""); 86 + }); 87 + }); 88 + 89 + describe(".lazy modifier", () => { 90 + it("syncs on change event instead of input", () => { 91 + const input = document.createElement("input"); 92 + input.type = "text"; 93 + input.dataset.voltModelLazy = "value"; 94 + 95 + const value = signal(""); 96 + mount(input, { value }); 97 + 98 + input.value = "test"; 99 + input.dispatchEvent(new Event("input")); 100 + expect(value.get()).toBe(""); 101 + 102 + input.dispatchEvent(new Event("change")); 103 + expect(value.get()).toBe("test"); 104 + }); 105 + 106 + it("works with checkboxes", () => { 107 + const input = document.createElement("input"); 108 + input.type = "checkbox"; 109 + input.dataset.voltModelLazy = "checked"; 110 + 111 + const checked = signal(false); 112 + mount(input, { checked }); 113 + 114 + input.checked = true; 115 + input.dispatchEvent(new Event("change")); 116 + 117 + expect(checked.get()).toBe(true); 118 + }); 119 + }); 120 + 121 + describe(".debounce modifier", () => { 122 + it("debounces signal updates with default delay (300ms)", () => { 123 + const input = document.createElement("input"); 124 + input.type = "text"; 125 + input.dataset.voltModelDebounce = "search"; 126 + 127 + const search = signal(""); 128 + mount(input, { search }); 129 + 130 + input.value = "hello"; 131 + input.dispatchEvent(new Event("input")); 132 + 133 + expect(search.get()).toBe(""); 134 + 135 + vi.advanceTimersByTime(299); 136 + expect(search.get()).toBe(""); 137 + 138 + vi.advanceTimersByTime(1); 139 + expect(search.get()).toBe("hello"); 140 + }); 141 + 142 + it("supports custom debounce delay", () => { 143 + const input = document.createElement("input"); 144 + input.type = "text"; 145 + input.dataset.voltModelDebounce500 = "search"; 146 + 147 + const search = signal(""); 148 + mount(input, { search }); 149 + 150 + input.value = "test"; 151 + input.dispatchEvent(new Event("input")); 152 + 153 + vi.advanceTimersByTime(499); 154 + expect(search.get()).toBe(""); 155 + 156 + vi.advanceTimersByTime(1); 157 + expect(search.get()).toBe("test"); 158 + }); 159 + 160 + it("resets timer on subsequent inputs", () => { 161 + const input = document.createElement("input"); 162 + input.type = "text"; 163 + input.dataset.voltModelDebounce100 = "value"; 164 + 165 + const value = signal(""); 166 + mount(input, { value }); 167 + 168 + input.value = "a"; 169 + input.dispatchEvent(new Event("input")); 170 + vi.advanceTimersByTime(50); 171 + 172 + input.value = "ab"; 173 + input.dispatchEvent(new Event("input")); 174 + vi.advanceTimersByTime(50); 175 + 176 + expect(value.get()).toBe(""); 177 + 178 + vi.advanceTimersByTime(50); 179 + expect(value.get()).toBe("ab"); 180 + }); 181 + 182 + it("cancels pending updates on cleanup", () => { 183 + const input = document.createElement("input"); 184 + input.type = "text"; 185 + input.dataset.voltModelDebounce100 = "value"; 186 + 187 + const value = signal(""); 188 + const cleanup = mount(input, { value }); 189 + 190 + input.value = "test"; 191 + input.dispatchEvent(new Event("input")); 192 + 193 + vi.advanceTimersByTime(50); 194 + cleanup(); 195 + 196 + vi.advanceTimersByTime(100); 197 + expect(value.get()).toBe(""); 198 + }); 199 + }); 200 + 201 + describe("modifier combinations", () => { 202 + it("combines .number and .trim", () => { 203 + const input = document.createElement("input"); 204 + input.type = "text"; 205 + input.dataset.voltModelNumberTrim = "value"; 206 + 207 + const value = signal(0); 208 + mount(input, { value }); 209 + 210 + input.value = " 42 "; 211 + input.dispatchEvent(new Event("input")); 212 + 213 + expect(value.get()).toBe(42); 214 + }); 215 + 216 + it("combines .trim and .lazy", () => { 217 + const input = document.createElement("input"); 218 + input.type = "text"; 219 + input.dataset.voltModelTrimLazy = "value"; 220 + 221 + const value = signal(""); 222 + mount(input, { value }); 223 + 224 + input.value = " test "; 225 + input.dispatchEvent(new Event("input")); 226 + expect(value.get()).toBe(""); 227 + 228 + input.dispatchEvent(new Event("change")); 229 + expect(value.get()).toBe("test"); 230 + }); 231 + 232 + it("combines .number and .debounce", () => { 233 + const input = document.createElement("input"); 234 + input.type = "text"; 235 + input.dataset.voltModelNumberDebounce100 = "value"; 236 + 237 + const value = signal(0); 238 + mount(input, { value }); 239 + 240 + input.value = "123"; 241 + input.dispatchEvent(new Event("input")); 242 + 243 + expect(value.get()).toBe(0); 244 + 245 + vi.advanceTimersByTime(100); 246 + expect(value.get()).toBe(123); 247 + }); 248 + 249 + it("combines .trim, .number, and .debounce", () => { 250 + const input = document.createElement("input"); 251 + input.type = "text"; 252 + input.dataset.voltModelTrimNumberDebounce100 = "value"; 253 + 254 + const value = signal(0); 255 + mount(input, { value }); 256 + 257 + input.value = " 456 "; 258 + input.dispatchEvent(new Event("input")); 259 + 260 + expect(value.get()).toBe(0); 261 + 262 + vi.advanceTimersByTime(100); 263 + expect(value.get()).toBe(456); 264 + }); 265 + }); 266 + }); 267 + 268 + describe("data-volt-bind modifiers", () => { 269 + describe(".number modifier", () => { 270 + it("coerces attribute values to numbers", () => { 271 + const div = document.createElement("div"); 272 + div.dataset.voltBindValueNumber = "count"; 273 + 274 + const count = signal(42); 275 + mount(div, { count }); 276 + 277 + expect(div.getAttribute("value")).toBe("42"); 278 + 279 + count.set(100); 280 + expect(div.getAttribute("value")).toBe("100"); 281 + }); 282 + 283 + it("handles string expressions with .number", () => { 284 + const div = document.createElement("div"); 285 + div.dataset.voltBindPriceNumber = "' 123 '"; 286 + 287 + mount(div, {}); 288 + 289 + expect(div.getAttribute("price")).toBe("123"); 290 + }); 291 + }); 292 + 293 + describe(".trim modifier", () => { 294 + it("trims attribute values", () => { 295 + const div = document.createElement("div"); 296 + div.dataset.voltBindTitleTrim = "title"; 297 + 298 + const title = signal(" Hello World "); 299 + mount(div, { title }); 300 + 301 + expect(div.getAttribute("title")).toBe("Hello World"); 302 + }); 303 + 304 + it("handles expressions that evaluate to strings", () => { 305 + const div = document.createElement("div"); 306 + div.dataset.voltBindNameTrim = "' test '"; 307 + 308 + mount(div, {}); 309 + 310 + expect(div.getAttribute("name")).toBe("test"); 311 + }); 312 + }); 313 + 314 + describe("modifier combinations", () => { 315 + it("combines .trim and .number", () => { 316 + const div = document.createElement("div"); 317 + div.dataset.voltBindValueTrimNumber = "value"; 318 + 319 + const value = signal(" 42 "); 320 + mount(div, { value }); 321 + 322 + expect(div.getAttribute("value")).toBe("42"); 323 + }); 324 + }); 325 + }); 326 + 327 + describe("signal synchronization", () => { 328 + it("updates input value when signal changes", () => { 329 + const input = document.createElement("input"); 330 + input.type = "text"; 331 + input.dataset.voltModelNumber = "count"; 332 + 333 + const count = signal(10); 334 + mount(input, { count }); 335 + 336 + expect(input.value).toBe("10"); 337 + 338 + count.set(20); 339 + expect(input.value).toBe("20"); 340 + }); 341 + 342 + it("maintains two-way binding with .number modifier", () => { 343 + const input = document.createElement("input"); 344 + input.type = "text"; 345 + input.dataset.voltModelNumber = "value"; 346 + 347 + const value = signal(5); 348 + mount(input, { value }); 349 + 350 + expect(input.value).toBe("5"); 351 + 352 + value.set(10); 353 + expect(input.value).toBe("10"); 354 + 355 + input.value = "15"; 356 + input.dispatchEvent(new Event("input")); 357 + expect(value.get()).toBe(15); 358 + }); 359 + }); 360 + });
+17 -15
lib/vite.config.ts
··· 1 1 import path from "node:path"; 2 2 import { fileURLToPath } from "node:url"; 3 - import { defineConfig } from "vite"; 3 + import { type BuildEnvironmentOptions, defineConfig } from "vite"; 4 4 import { type ViteUserConfig } from "vitest/config"; 5 - 6 5 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 7 6 8 7 const test: ViteUserConfig["test"] = { ··· 21 20 }, 22 21 }; 23 22 23 + const buildOptions = (mode: string): BuildEnvironmentOptions => ({ 24 + minify: mode === "lib" ? "oxc" : true, 25 + ...(mode === "lib" 26 + ? { 27 + lib: { 28 + entry: { voltx: path.resolve(__dirname, "src/index.ts"), debug: path.resolve(__dirname, "src/debug.ts") }, 29 + name: "VoltX", 30 + formats: ["es"], 31 + }, 32 + rolldownOptions: { output: { assetFileNames: "voltx.[ext]", minify: true } }, 33 + } 34 + : {}), 35 + }); 36 + 24 37 export default defineConfig(({ mode }) => ({ 25 38 resolve: { 26 39 alias: { ··· 32 45 "$vebug": path.resolve(__dirname, "./src/debug.ts"), 33 46 }, 34 47 }, 35 - build: mode === "lib" 36 - ? { 37 - lib: { 38 - entry: { 39 - volt: path.resolve(__dirname, "src/index.ts"), 40 - debug: path.resolve(__dirname, "src/debug.ts"), 41 - }, 42 - name: "Volt", 43 - formats: ["es"], 44 - }, 45 - rolldownOptions: { output: { assetFileNames: "volt.[ext]" } }, 46 - } 47 - : undefined, 48 + build: buildOptions(mode), 48 49 test, 50 + plugins: [], 49 51 }));
+14 -5
pnpm-lock.yaml
··· 37 37 version: 8.46.1(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3) 38 38 vitest: 39 39 specifier: ^3.2.4 40 - version: 3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)(yaml@2.8.1) 40 + version: 3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0)(terser@5.44.0)(yaml@2.8.1) 41 41 42 42 dev: 43 43 dependencies: ··· 93 93 jsdom: 94 94 specifier: ^27.0.0 95 95 version: 27.0.0(postcss@8.5.6) 96 + oxc: 97 + specifier: ^1.0.1 98 + version: 1.0.1 96 99 postcss: 97 100 specifier: ^8.5.6 98 101 version: 8.5.6 ··· 945 948 resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} 946 949 engines: {node: ^18.0.0 || >=20.0.0} 947 950 peerDependencies: 948 - vite: ^5.0.0 || ^6.0.0 951 + vite: npm:rolldown-vite@7.1.14 949 952 vue: ^3.2.25 950 953 951 954 '@vitest/coverage-v8@3.2.4': ··· 964 967 resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} 965 968 peerDependencies: 966 969 msw: ^2.4.9 967 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 970 + vite: npm:rolldown-vite@7.1.14 968 971 peerDependenciesMeta: 969 972 msw: 970 973 optional: true ··· 2047 2050 resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} 2048 2051 engines: {node: '>= 0.8.0'} 2049 2052 2053 + oxc@1.0.1: 2054 + resolution: {integrity: sha512-MJ18y2Ekl329i3zdZpRVOqFdEUjoRKC1+uy1f4kuRp9ygindCVVUIhhKxwyAhTsWt3jIV8UczKtlTwWWahcaWQ==} 2055 + hasBin: true 2056 + 2050 2057 p-limit@3.1.0: 2051 2058 resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} 2052 2059 engines: {node: '>=10'} ··· 3699 3706 std-env: 3.10.0 3700 3707 test-exclude: 7.0.1 3701 3708 tinyrainbow: 2.0.0 3702 - vitest: 3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)(yaml@2.8.1) 3709 + vitest: 3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0)(terser@5.44.0)(yaml@2.8.1) 3703 3710 transitivePeerDependencies: 3704 3711 - supports-color 3705 3712 ··· 4878 4885 type-check: 0.4.0 4879 4886 word-wrap: 1.2.5 4880 4887 4888 + oxc@1.0.1: {} 4889 + 4881 4890 p-limit@3.1.0: 4882 4891 dependencies: 4883 4892 yocto-queue: 0.1.0 ··· 5643 5652 - universal-cookie 5644 5653 - yaml 5645 5654 5646 - vitest@3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)(yaml@2.8.1): 5655 + vitest@3.2.4(@types/node@24.8.1)(esbuild@0.25.11)(jiti@2.6.1)(jsdom@27.0.0)(terser@5.44.0)(yaml@2.8.1): 5647 5656 dependencies: 5648 5657 '@types/chai': 5.2.2 5649 5658 '@vitest/expect': 3.2.4