diff --git a/.github/workflows/cross-repo-test.yml b/.github/workflows/cross-repo-test.yml index b387439..2aa99e8 100644 --- a/.github/workflows/cross-repo-test.yml +++ b/.github/workflows/cross-repo-test.yml @@ -123,7 +123,8 @@ jobs: LVT_PATH=$(realpath ../lvt) # Test each working example - for example in counter chat todos graceful-shutdown testing/01_basic; do + # Note: graceful-shutdown and testing/01_basic were removed from examples repo + for example in counter chat todos; do echo "Testing: $example" cd "$example" diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9b4cc..fd4ef37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to @livetemplate/client will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- feat: `lvt-el:{method}:on:{event}` now supports any native DOM event as trigger (click, focusin, focusout, mouseenter, mouseleave, keydown, etc.) — no server round-trip, CSP-safe +- feat: `lvt-fx:{effect}:on:{event}` supports DOM event triggers (e.g., `lvt-fx:highlight:on:click="flash"`) and lifecycle triggers (e.g., `lvt-fx:highlight:on:success="flash"`) + +## [v0.8.17] - 2026-04-05 + +### Changes + +- fix: form.name DOM shadowing + skip File objects in FormData parsing (58cf0c2) + ## [v0.8.16] - 2026-04-04 ### Changes diff --git a/VERSION b/VERSION index ac7dffa..9bba175 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.16 +0.8.17 diff --git a/dom/directives.ts b/dom/directives.ts index 1a89564..257f12d 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -1,65 +1,277 @@ +import { isDOMEventTrigger, SYNTHETIC_TRIGGERS } from "./reactive-attributes"; + +// ─── Trigger parsing for lvt-fx: attributes ───────────────────────────────── + +const FX_LIFECYCLE_SET = new Set(["pending", "success", "error", "done"]); + /** - * Apply scroll directives on elements with lvt-fx:scroll attributes. - * Configuration read from CSS custom properties: - * --lvt-scroll-behavior: auto | smooth (default: auto) - * --lvt-scroll-threshold: (default: 100) + * Parse a lvt-fx:{effect}[:on:[{action}:]{trigger}] attribute name. + * Returns the trigger type or null for implicit (no :on:). */ -const VALID_SCROLL_BEHAVIORS = new Set(["auto", "smooth", "instant"]); +function parseFxTrigger(attrName: string): { trigger: string | null; actionName?: string } { + // Check for :on: suffix pattern + const onMatch = attrName.match(/^lvt-fx:\w+:on:(.+)$/i); + if (!onMatch) return { trigger: null }; // implicit trigger + + const parts = onMatch[1].split(":"); + if (parts.length === 1) { + return { trigger: parts[0].toLowerCase() }; + } + // action-scoped: lvt-fx:highlight:on:save:success + return { + trigger: parts[parts.length - 1].toLowerCase(), + actionName: parts.slice(0, -1).join(":"), + }; +} -export function handleScrollDirectives(rootElement: Element): void { - const scrollElements = rootElement.querySelectorAll("[lvt-fx\\:scroll]"); - - scrollElements.forEach((element) => { - const htmlElement = element as HTMLElement; - const mode = htmlElement.getAttribute("lvt-fx:scroll"); - const computed = getComputedStyle(htmlElement); - const rawBehavior = computed.getPropertyValue("--lvt-scroll-behavior").trim(); - const behavior: ScrollBehavior = VALID_SCROLL_BEHAVIORS.has(rawBehavior) - ? (rawBehavior as ScrollBehavior) - : "auto"; - const threshold = parseInt( - computed.getPropertyValue("--lvt-scroll-threshold").trim() || "100", - 10 +/** + * Set up DOM event listeners for lvt-fx: attributes with :on:{event} triggers. + * Called after each DOM update to handle new elements. + * + * @param scanRoot - Element subtree to scan for new fx attributes. + * @param registryRoot - Element to store listener registry on (always the wrapper). + * Defaults to scanRoot for backwards compatibility. + */ +export function setupFxDOMEventTriggers(scanRoot: Element, registryRoot?: Element): void { + const registry = registryRoot || scanRoot; + const fxListenersKey = "__lvtFxDirectListeners"; + // Prune stale entries from elements replaced by morphdom + const fxListeners: Array<{ el: Element; event: string; handler: EventListener; guardKey: string }> = + ((registry as any)[fxListenersKey] || []).filter( + (entry: { el: Element }) => entry.el.isConnected ); - if (!mode) return; + const processEl = (el: Element) => { + for (const attr of el.attributes) { + if (!attr.name.startsWith("lvt-fx:")) continue; + const parsed = parseFxTrigger(attr.name); + if (!parsed.trigger) continue; // implicit — handled by normal directive flow + if (FX_LIFECYCLE_SET.has(parsed.trigger)) continue; // lifecycle — handled by event listeners + if (SYNTHETIC_TRIGGERS.has(parsed.trigger)) continue; // click-away etc. + + // It's a DOM event trigger + const listenerKey = `__lvt_fx_${attr.name}`; + if ((el as any)[listenerKey]) continue; // already attached + + const effect = attr.name.match(/^lvt-fx:(\w+)/i)?.[1]; + if (!effect) continue; + + const attrNameCapture = attr.name; + const listener = () => { + if (!el.hasAttribute(attrNameCapture)) return; // attr removed by morphdom + const currentValue = el.getAttribute(attrNameCapture) || ""; + applyFxEffect(el as HTMLElement, effect, currentValue); + }; + el.addEventListener(parsed.trigger, listener); + (el as any)[listenerKey] = listener; + fxListeners.push({ el, event: parsed.trigger, handler: listener, guardKey: listenerKey }); + } + }; + + // Process scan root element itself then descendants (avoids spreading NodeList) + processEl(scanRoot); + scanRoot.querySelectorAll("*").forEach(processEl); + + (registry as any)[fxListenersKey] = fxListeners; +} + +/** + * Remove direct DOM event listeners registered by setupFxDOMEventTriggers. + * Call on disconnect to prevent stale listeners across reconnects. + */ +export function teardownFxDOMEventTriggers(rootElement: Element): void { + const fxListenersKey = "__lvtFxDirectListeners"; + const listeners: Array<{ el: Element; event: string; handler: EventListener; guardKey: string }> | undefined = + (rootElement as any)[fxListenersKey]; + if (listeners) { + listeners.forEach(({ el, event, handler, guardKey }) => { + el.removeEventListener(event, handler); + delete (el as any)[guardKey]; // Clear per-element marker so re-attach works on reconnect + }); + delete (rootElement as any)[fxListenersKey]; + } +} + +/** + * Process lvt-fx: attributes triggered by a lifecycle event. + */ +export function processFxLifecycleAttributes( + rootElement: Element, + lifecycle: string, + actionName?: string, +): void { + const processEl = (el: Element) => { + for (const attr of el.attributes) { + if (!attr.name.startsWith("lvt-fx:")) continue; + const parsed = parseFxTrigger(attr.name); + if (!parsed.trigger || !FX_LIFECYCLE_SET.has(parsed.trigger)) continue; + if (parsed.trigger !== lifecycle) continue; + if (parsed.actionName && parsed.actionName !== actionName) continue; + + const effect = attr.name.match(/^lvt-fx:(\w+)/i)?.[1]; + if (!effect) continue; + + applyFxEffect(el as HTMLElement, effect, attr.value); + } + }; + processEl(rootElement); + rootElement.querySelectorAll("*").forEach(processEl); +} + +/** + * Apply a visual effect to an element. + */ +function applyFxEffect(htmlElement: HTMLElement, effect: string, config: string): void { + const computed = getComputedStyle(htmlElement); + + switch (effect) { + case "highlight": { + // Skip if already mid-highlight to prevent stale originalBackground capture. + // Intentionally rate-limits to one highlight per element — overlapping triggers + // (rapid clicks, DOM updates during animation) are coalesced rather than stacked. + if ((htmlElement as any).__lvtHighlighting) break; + (htmlElement as any).__lvtHighlighting = true; + + const duration = parseInt( + computed.getPropertyValue("--lvt-highlight-duration").trim() || "500", 10 + ); + const color = computed.getPropertyValue("--lvt-highlight-color").trim() || "#ffc107"; + const originalBackground = htmlElement.style.backgroundColor; + const originalTransition = htmlElement.style.transition; + + htmlElement.style.transition = `background-color ${duration}ms ease-out`; + htmlElement.style.backgroundColor = color; - switch (mode) { - case "bottom": - htmlElement.scrollTo({ - top: htmlElement.scrollHeight, - behavior, - }); - break; - - case "bottom-sticky": { - const isNearBottom = - htmlElement.scrollHeight - - htmlElement.scrollTop - - htmlElement.clientHeight <= - threshold; - if (isNearBottom) { - htmlElement.scrollTo({ - top: htmlElement.scrollHeight, - behavior, - }); + setTimeout(() => { + if (!htmlElement.isConnected) { + htmlElement.style.backgroundColor = originalBackground; + htmlElement.style.transition = originalTransition; + (htmlElement as any).__lvtHighlighting = false; + return; + } + htmlElement.style.backgroundColor = originalBackground; + setTimeout(() => { + if (htmlElement.isConnected) htmlElement.style.transition = originalTransition; + (htmlElement as any).__lvtHighlighting = false; + }, duration); + }, 50); + break; + } + case "animate": { + const duration = parseInt( + computed.getPropertyValue("--lvt-animate-duration").trim() || "300", 10 + ); + const animation = config || "fade"; + htmlElement.style.setProperty("--lvt-animate-duration", `${duration}ms`); + + switch (animation) { + case "fade": + htmlElement.style.animation = `lvt-fade-in var(--lvt-animate-duration) ease-out`; + break; + case "slide": + htmlElement.style.animation = `lvt-slide-in var(--lvt-animate-duration) ease-out`; + break; + case "scale": + htmlElement.style.animation = `lvt-scale-in var(--lvt-animate-duration) ease-out`; + break; + default: + console.warn(`Unknown lvt-fx:animate mode: ${animation}`); + } + htmlElement.addEventListener("animationend", () => { + htmlElement.style.animation = ""; + }, { once: true }); + break; + } + case "scroll": { + const rawBehavior = computed.getPropertyValue("--lvt-scroll-behavior").trim(); + const behavior: ScrollBehavior = VALID_SCROLL_BEHAVIORS.has(rawBehavior) + ? (rawBehavior as ScrollBehavior) : "auto"; + const threshold = parseInt( + computed.getPropertyValue("--lvt-scroll-threshold").trim() || "100", 10 + ); + const mode = config || "bottom"; + + switch (mode) { + case "bottom": + htmlElement.scrollTo({ top: htmlElement.scrollHeight, behavior }); + break; + case "bottom-sticky": { + const isNearBottom = htmlElement.scrollHeight - htmlElement.scrollTop - htmlElement.clientHeight <= threshold; + if (isNearBottom) htmlElement.scrollTo({ top: htmlElement.scrollHeight, behavior }); + break; } - break; + case "top": + htmlElement.scrollTo({ top: 0, behavior }); + break; + case "preserve": + break; + default: + console.warn(`Unknown lvt-fx:scroll mode: ${mode}`); } + break; + } + default: + console.warn(`Unknown lvt-fx effect: ${effect}`); + } +} + +/** + * Set up document-level lifecycle listeners for lvt-fx: attributes with :on:{lifecycle}. + * Called once per wrapper at connect time. Scoped to the provided root element so + * multiple LiveTemplateClient instances on the same page don't cross-fire effects. + * Stores listener references on the element for teardown via teardownFxLifecycleListeners. + */ +export function setupFxLifecycleListeners(rootElement: Element): void { + const guardKey = "__lvtFxLifecycleSetup"; + if ((rootElement as any)[guardKey]) return; + (rootElement as any)[guardKey] = true; + + const listeners: Array<{ event: string; handler: EventListener }> = []; + const lifecycles = ["pending", "success", "error", "done"]; + lifecycles.forEach(lifecycle => { + const handler = (e: Event) => { + const customEvent = e as CustomEvent; + const actionName = customEvent.detail?.action; + processFxLifecycleAttributes(rootElement, lifecycle, actionName); + }; + document.addEventListener(`lvt:${lifecycle}`, handler, true); + listeners.push({ event: `lvt:${lifecycle}`, handler }); + }); + (rootElement as any).__lvtFxLifecycleListeners = listeners; +} - case "top": - htmlElement.scrollTo({ - top: 0, - behavior, - }); - break; +/** + * Remove document-level lifecycle listeners registered by setupFxLifecycleListeners. + * Call on disconnect to prevent listener accumulation across reconnects. + */ +export function teardownFxLifecycleListeners(rootElement: Element): void { + const listeners: Array<{ event: string; handler: EventListener }> | undefined = + (rootElement as any).__lvtFxLifecycleListeners; + if (listeners) { + listeners.forEach(({ event, handler }) => { + document.removeEventListener(event, handler, true); + }); + delete (rootElement as any).__lvtFxLifecycleListeners; + } + delete (rootElement as any).__lvtFxLifecycleSetup; +} - case "preserve": - break; +// ─── Implicit-trigger directive handlers (fire on every DOM update) ────────── - default: - console.warn(`Unknown lvt-fx:scroll mode: ${mode}`); - } +/** + * Apply scroll directives on elements with lvt-fx:scroll attributes. + * Only processes attributes WITHOUT :on: suffix (implicit trigger). + * Configuration read from CSS custom properties: + * --lvt-scroll-behavior: auto | smooth (default: auto) + * --lvt-scroll-threshold: (default: 100) + */ +const VALID_SCROLL_BEHAVIORS = new Set(["auto", "smooth", "instant"]); + +export function handleScrollDirectives(rootElement: Element): void { + rootElement.querySelectorAll("[lvt-fx\\:scroll]").forEach((element) => { + const mode = element.getAttribute("lvt-fx:scroll"); + if (!mode) return; + applyFxEffect(element as HTMLElement, "scroll", mode); }); } @@ -70,33 +282,10 @@ export function handleScrollDirectives(rootElement: Element): void { * --lvt-highlight-color: (default: #ffc107) */ export function handleHighlightDirectives(rootElement: Element): void { - const highlightElements = rootElement.querySelectorAll("[lvt-fx\\:highlight]"); - - highlightElements.forEach((element) => { + rootElement.querySelectorAll("[lvt-fx\\:highlight]").forEach((element) => { const mode = element.getAttribute("lvt-fx:highlight"); - const computed = getComputedStyle(element); - const duration = parseInt( - computed.getPropertyValue("--lvt-highlight-duration").trim() || "500", - 10 - ); - const color = computed.getPropertyValue("--lvt-highlight-color").trim() || "#ffc107"; - if (!mode) return; - - const htmlElement = element as HTMLElement; - const originalBackground = htmlElement.style.backgroundColor; - const originalTransition = htmlElement.style.transition; - - htmlElement.style.transition = `background-color ${duration}ms ease-out`; - htmlElement.style.backgroundColor = color; - - setTimeout(() => { - htmlElement.style.backgroundColor = originalBackground; - - setTimeout(() => { - htmlElement.style.transition = originalTransition; - }, duration); - }, 50); + applyFxEffect(element as HTMLElement, "highlight", mode); }); } @@ -106,48 +295,16 @@ export function handleHighlightDirectives(rootElement: Element): void { * --lvt-animate-duration: (default: 300) */ export function handleAnimateDirectives(rootElement: Element): void { - const animateElements = rootElement.querySelectorAll("[lvt-fx\\:animate]"); - - animateElements.forEach((element) => { + rootElement.querySelectorAll("[lvt-fx\\:animate]").forEach((element) => { const animation = element.getAttribute("lvt-fx:animate"); - const computed = getComputedStyle(element); - const duration = parseInt( - computed.getPropertyValue("--lvt-animate-duration").trim() || "300", - 10 - ); - if (!animation) return; - - const htmlElement = element as HTMLElement; - - htmlElement.style.setProperty("--lvt-animate-duration", `${duration}ms`); - - switch (animation) { - case "fade": - htmlElement.style.animation = `lvt-fade-in var(--lvt-animate-duration) ease-out`; - break; - - case "slide": - htmlElement.style.animation = `lvt-slide-in var(--lvt-animate-duration) ease-out`; - break; - - case "scale": - htmlElement.style.animation = `lvt-scale-in var(--lvt-animate-duration) ease-out`; - break; - - default: - console.warn(`Unknown lvt-fx:animate mode: ${animation}`); - } - - htmlElement.addEventListener( - "animationend", - () => { - htmlElement.style.animation = ""; - }, - { once: true } - ); + applyFxEffect(element as HTMLElement, "animate", animation); }); + ensureAnimateKeyframes(); +} + +function ensureAnimateKeyframes(): void { if (!document.getElementById("lvt-animate-styles")) { const style = document.createElement("style"); style.id = "lvt-animate-styles"; @@ -157,24 +314,12 @@ export function handleAnimateDirectives(rootElement: Element): void { to { opacity: 1; } } @keyframes lvt-slide-in { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } } @keyframes lvt-scale-in { - from { - opacity: 0; - transform: scale(0.95); - } - to { - opacity: 1; - transform: scale(1); - } + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } } `; document.head.appendChild(style); diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 882f4ad..16311c2 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -1,6 +1,6 @@ import { debounce, throttle } from "../utils/rate-limit"; import { lvtSelector } from "../utils/lvt-selector"; -import { executeAction, type ReactiveAction } from "./reactive-attributes"; +import { executeAction, processElementInteraction, isDOMEventTrigger, type ReactiveAction } from "./reactive-attributes"; import type { Logger } from "../utils/logger"; // Methods supported by click-away, derived from ReactiveAction values @@ -14,6 +14,9 @@ const CLICK_AWAY_METHOD_MAP: Record = { }; const CLICK_AWAY_METHODS = Object.keys(CLICK_AWAY_METHOD_MAP); +// Non-bubbling events need direct attachment rather than wrapper delegation +const NON_BUBBLING = new Set(["mouseenter", "mouseleave", "focus", "blur"]); + export interface EventDelegationContext { getWrapperElement(): Element | null; getRateLimitedHandlers(): WeakMap>; @@ -581,6 +584,127 @@ export class EventDelegator { document.addEventListener("click", listener); } + /** + * Sets up event listeners for lvt-el:*:on:{event} attributes where {event} + * is a native DOM event (not a lifecycle state or synthetic trigger). + * + * Scans scanRoot (or the full wrapper if omitted) for elements with these + * attributes. Attaches direct listeners for non-bubbling events (mouseenter, + * mouseleave) and delegated listeners on the wrapper for bubbling events + * (click, focusin, focusout, etc.). + * + * Bubbling delegation uses closest-match semantics: if both a child and parent + * have the same trigger, only the child's action fires. This differs from native + * event bubbling and prevents unintended double-firing in nested structures. + * + * Called during connect and after each DOM update to handle new elements. + * + * @param scanRoot - Subtree to scan for new attributes. Defaults to full wrapper. + * Pass the updated element after a DOM patch to avoid a full rescan. + */ + setupDOMEventTriggerDelegation(scanRoot?: Element): void { + const wrapperElement = this.context.getWrapperElement(); + if (!wrapperElement) return; + + const wrapperId = wrapperElement.getAttribute("data-lvt-id"); + if (!wrapperId) return; + // Track which bubbling events we've already delegated at wrapper level + const delegatedKey = `__lvt_el_delegated_${wrapperId}`; + const delegated: Set = (wrapperElement as any)[delegatedKey] || new Set(); + + // Track all listeners (direct + delegated) on wrapper for teardown + // Prune stale entries from elements replaced by morphdom + const listenersKey = `__lvt_el_listeners_${wrapperId}`; + const allListeners: Array<{ el: Element; event: string; handler: EventListener; guardKey?: string }> = + ((wrapperElement as any)[listenersKey] || []).filter( + (entry: { el: Element }) => entry.el.isConnected + ); + + // Scan the provided subtree (or full wrapper) for lvt-el:*:on:{event} attributes. + // Process root then descendants (avoids spreading NodeList into array). + const root = scanRoot || wrapperElement; + const processEl = (el: Element) => { + const triggers = new Set(); + for (const attr of el.attributes) { + if (!attr.name.startsWith("lvt-el:")) continue; + const match = attr.name.match(/^lvt-el:\w+:on:([a-z-]+)$/i); + if (!match) continue; + const trigger = match[1].toLowerCase(); + if (!isDOMEventTrigger(trigger)) continue; + triggers.add(trigger); + } + + for (const trigger of triggers) { + if (NON_BUBBLING.has(trigger)) { + // Direct attachment for non-bubbling events + const key = `__lvt_el_${trigger}`; + if ((el as any)[key]) continue; // already attached + const listener = () => processElementInteraction(el, trigger); + el.addEventListener(trigger, listener); + (el as any)[key] = listener; + allListeners.push({ el, event: trigger, handler: listener, guardKey: key }); + } else if (!delegated.has(trigger)) { + // Delegated listener on wrapper for bubbling events. + // Walks from target to wrapper, processing only the closest matching element. + const escaped = trigger.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const triggerPattern = new RegExp(`^lvt-el:\\w+:on:${escaped}$`, "i"); + const handler = (e: Event) => { + let target = e.target as Element | null; + while (target && target !== wrapperElement) { + let hasMatch = false; + for (const a of target.attributes) { + if (triggerPattern.test(a.name)) { hasMatch = true; break; } + } + if (hasMatch) { + processElementInteraction(target, trigger); + return; // Stop at closest match + } + target = target.parentElement; + } + // Also check wrapper itself + if (target === wrapperElement) { + processElementInteraction(wrapperElement, trigger); + } + }; + wrapperElement.addEventListener(trigger, handler); + delegated.add(trigger); + allListeners.push({ el: wrapperElement, event: trigger, handler }); + } + } + }; + processEl(root); + root.querySelectorAll("*").forEach(processEl); + + (wrapperElement as any)[listenersKey] = allListeners; + (wrapperElement as any)[delegatedKey] = delegated; + } + + /** + * Remove delegated DOM event trigger listeners added by setupDOMEventTriggerDelegation. + * Call on disconnect to prevent stale listeners firing on a disconnected component. + */ + teardownDOMEventTriggerDelegation(): void { + const wrapperElement = this.context.getWrapperElement(); + if (!wrapperElement) return; + + const wrapperId = wrapperElement.getAttribute("data-lvt-id"); + if (!wrapperId) return; + + const listenersKey = `__lvt_el_listeners_${wrapperId}`; + const listeners: Array<{ el: Element; event: string; handler: EventListener; guardKey?: string }> | undefined = + (wrapperElement as any)[listenersKey]; + if (listeners) { + listeners.forEach(({ el, event, handler, guardKey }) => { + el.removeEventListener(event, handler); + if (guardKey) delete (el as any)[guardKey]; + }); + delete (wrapperElement as any)[listenersKey]; + } + + const delegatedKey = `__lvt_el_delegated_${wrapperId}`; + delete (wrapperElement as any)[delegatedKey]; + } + /** * Sets up focus trapping for elements with lvt-focus-trap attribute. * Focus is trapped within the element, cycling through focusable elements diff --git a/dom/reactive-attributes.ts b/dom/reactive-attributes.ts index 3b504ae..565bc37 100644 --- a/dom/reactive-attributes.ts +++ b/dom/reactive-attributes.ts @@ -1,20 +1,21 @@ /** - * Reactive Attributes - Declarative DOM actions triggered by LiveTemplate lifecycle events. + * Reactive Attributes - Declarative DOM actions triggered by lifecycle events or interactions. * - * Attribute Pattern: lvt-el:{method}:on:[{action}:]{state|interaction}="param" + * Attribute Pattern: lvt-el:{method}:on:{trigger}="param" * - * States (lifecycle): - * - pending: Action started, waiting for server response - * - success: Action completed successfully - * - error: Action completed with validation errors - * - done: Action completed (regardless of success/error) + * Trigger types: * - * Interactions: - * - click-away: Click outside the element (handled by setupClickAwayDelegation) + * 1. Lifecycle states (server action request-response cycle): + * - pending, success, error, done + * - Supports action scoping: lvt-el:reset:on:create-todo:success * - * Trigger Scope: - * - Unscoped: lvt-el:reset:on:success (any action) - * - Action-scoped: lvt-el:reset:on:create-todo:success (specific action only) + * 2. Native DOM events (client-side, no server round-trip): + * - Any browser event: click, focusin, focusout, mouseenter, mouseleave, keydown, etc. + * - No action scoping (fires on the element's own event) + * + * 3. Synthetic interactions (client-side): + * - click-away: Click outside the element + * - No action scoping * * Methods: * - reset: Calls form.reset() @@ -45,6 +46,13 @@ export interface ReactiveBinding { const LIFECYCLE_EVENTS: LifecycleEvent[] = ["pending", "success", "error", "done"]; const LIFECYCLE_SET = new Set(LIFECYCLE_EVENTS); +/** + * Reserved trigger keywords that are NOT native DOM events. + * click-away is a synthetic interaction handled by setupClickAwayDelegation. + * Everything else that's not a lifecycle state is treated as a native DOM event. + */ +export const SYNTHETIC_TRIGGERS = new Set(["click-away"]); + // Lowercase method names → canonical ReactiveAction const METHOD_MAP: Record = { reset: "reset", @@ -79,8 +87,10 @@ export function parseReactiveAttribute( if (!action) return null; const eventPart = newMatch[2]; - // Skip interaction triggers (click-away) — handled by click-away delegation - if (eventPart === "click-away") return null; + // Skip synthetic triggers (click-away) — handled by setupClickAwayDelegation + if (SYNTHETIC_TRIGGERS.has(eventPart)) return null; + // Skip native DOM event triggers — handled by setupDOMEventTriggerDelegation + if (!LIFECYCLE_SET.has(eventPart) && !eventPart.includes(":")) return null; const segments = eventPart.split(":"); const lastSegment = segments[segments.length - 1]; @@ -223,6 +233,33 @@ export function processReactiveAttributes( }); } +/** + * Process all lvt-el:*:on:{trigger} attributes on an element for a given trigger. + */ +export function processElementInteraction(element: Element, trigger: string): void { + for (const attr of element.attributes) { + const match = attr.name.match(/^lvt-el:(\w+):on:([a-z-]+)$/i); + if (!match) continue; + if (match[2].toLowerCase() !== trigger) continue; + + const methodKey = match[1].toLowerCase(); + const action = METHOD_MAP[methodKey]; + if (!action) continue; + + executeAction(element, action, attr.value); + } +} + +/** + * Checks if a trigger name is a DOM event (not lifecycle or synthetic). + * Intentionally open — accepts any string to support both native DOM events + * and custom events (e.g., lvt-el:addClass:on:my-custom-event). A typo + * silently registers a listener that never fires; no allowlist is enforced. + */ +export function isDOMEventTrigger(trigger: string): boolean { + return !LIFECYCLE_SET.has(trigger) && !SYNTHETIC_TRIGGERS.has(trigger); +} + /** * Set up document-level event listeners for reactive attributes. */ diff --git a/livetemplate-client.ts b/livetemplate-client.ts index 0080a5d..d52633a 100644 --- a/livetemplate-client.ts +++ b/livetemplate-client.ts @@ -13,6 +13,10 @@ import { handleScrollDirectives, handleToastDirectives, setupToastClickOutside, + setupFxDOMEventTriggers, + teardownFxDOMEventTriggers, + setupFxLifecycleListeners, + teardownFxLifecycleListeners, } from "./dom/directives"; import { EventDelegator } from "./dom/event-delegation"; import { LinkInterceptor } from "./dom/link-interceptor"; @@ -371,6 +375,9 @@ export class LiveTemplateClient { // Set up click-away delegation this.eventDelegator.setupClickAwayDelegation(); + // Set up DOM event trigger delegation for lvt-el:*:on:{event} attributes + this.eventDelegator.setupDOMEventTriggerDelegation(); + // Set up click-outside listener for client-managed toast stack setupToastClickOutside(); @@ -386,6 +393,9 @@ export class LiveTemplateClient { // Set up reactive attribute listeners for lvt-el:*:on:* attributes setupReactiveAttributeListeners(); + // Set up lifecycle listeners for lvt-fx:*:on:{lifecycle} attributes + setupFxLifecycleListeners(this.wrapperElement); + // Initialize focus tracking this.focusManager.attach(this.wrapperElement); @@ -406,6 +416,11 @@ export class LiveTemplateClient { this.formLifecycleManager.reset(); this.loadingIndicator.hide(); this.formDisabler.enable(this.wrapperElement); + this.eventDelegator.teardownDOMEventTriggerDelegation(); + if (this.wrapperElement) { + teardownFxDOMEventTriggers(this.wrapperElement); + teardownFxLifecycleListeners(this.wrapperElement); + } } /** @@ -796,15 +811,22 @@ export class LiveTemplateClient { // Restore focus to previously focused element this.focusManager.restoreFocusedElement(); - // Handle scroll directives + // Handle scroll directives (implicit trigger only) handleScrollDirectives(element); - // Handle highlight directives + // Handle highlight directives (implicit trigger only) handleHighlightDirectives(element); - // Handle animate directives + // Handle animate directives (implicit trigger only) handleAnimateDirectives(element); + // Set up DOM event triggers for lvt-fx: attributes with :on:{event} + // Registry always lives on wrapperElement so teardown can find all entries + setupFxDOMEventTriggers(element, this.wrapperElement || undefined); + + // Re-scan updated subtree for lvt-el:*:on:{event} DOM triggers + this.eventDelegator.setupDOMEventTriggerDelegation(element); + // Handle toast trigger directives (ephemeral client-side toasts) handleToastDirectives(element); diff --git a/package-lock.json b/package-lock.json index d8134dd..553db8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@livetemplate/client", - "version": "0.8.16", + "version": "0.8.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@livetemplate/client", - "version": "0.8.16", + "version": "0.8.17", "license": "MIT", "dependencies": { "@types/morphdom": "^2.3.0", diff --git a/package.json b/package.json index a21ac68..e317659 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@livetemplate/client", - "version": "0.8.16", + "version": "0.8.17", "description": "TypeScript client for LiveTemplate tree-based updates", "main": "dist/livetemplate-client.js", "browser": "dist/livetemplate-client.browser.js", diff --git a/tests/directives.test.ts b/tests/directives.test.ts index e8dc518..7621a53 100644 --- a/tests/directives.test.ts +++ b/tests/directives.test.ts @@ -2,6 +2,7 @@ import { handleScrollDirectives, handleHighlightDirectives, handleAnimateDirectives, + setupFxDOMEventTriggers, } from "../dom/directives"; describe("handleScrollDirectives", () => { @@ -281,3 +282,53 @@ describe("handleAnimateDirectives", () => { expect(target.style.animation).toBe(""); }); }); + +describe("setupFxDOMEventTriggers", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("attaches highlight effect on click trigger", () => { + const target = document.createElement("div"); + target.setAttribute("lvt-fx:highlight:on:click", "flash"); + document.body.appendChild(target); + + setupFxDOMEventTriggers(document.body); + target.click(); + + expect(target.style.backgroundColor).not.toBe(""); + }); + + it("does not fire for implicit trigger (no :on:)", () => { + const target = document.createElement("div"); + target.setAttribute("lvt-fx:highlight", "flash"); + document.body.appendChild(target); + + setupFxDOMEventTriggers(document.body); + target.click(); + + expect(target.style.backgroundColor).toBe(""); + }); + + it("does not fire for lifecycle trigger", () => { + const target = document.createElement("div"); + target.setAttribute("lvt-fx:highlight:on:success", "flash"); + document.body.appendChild(target); + + setupFxDOMEventTriggers(document.body); + target.click(); + + expect(target.style.backgroundColor).toBe(""); + }); + + it("attaches mouseenter trigger for highlight", () => { + const target = document.createElement("div"); + target.setAttribute("lvt-fx:highlight:on:mouseenter", "flash"); + document.body.appendChild(target); + + setupFxDOMEventTriggers(document.body); + target.dispatchEvent(new MouseEvent("mouseenter")); + + expect(target.style.backgroundColor).not.toBe(""); + }); +}); diff --git a/tests/reactive-attributes.test.ts b/tests/reactive-attributes.test.ts index 8d51ffa..e81bd27 100644 --- a/tests/reactive-attributes.test.ts +++ b/tests/reactive-attributes.test.ts @@ -4,6 +4,8 @@ import { matchesEvent, processReactiveAttributes, setupReactiveAttributeListeners, + processElementInteraction, + isDOMEventTrigger, type ReactiveBinding, type LifecycleEvent, } from "../dom/reactive-attributes"; @@ -103,10 +105,18 @@ describe("Reactive Attributes", () => { }); }); - describe("click-away interaction keyword", () => { + describe("interaction triggers (non-lifecycle)", () => { it("returns null for click-away (handled by click-away delegation)", () => { expect(parseReactiveAttribute("lvt-el:removeclass:on:click-away", "open")).toBeNull(); }); + + it("returns null for native DOM event triggers (handled by DOM event delegation)", () => { + expect(parseReactiveAttribute("lvt-el:toggleclass:on:click", "open")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:addclass:on:focusin", "open")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:removeclass:on:focusout", "open")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:addclass:on:mouseenter", "visible")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:removeclass:on:mouseleave", "visible")).toBeNull(); + }); }); describe("invalid attribute parsing", () => { @@ -459,4 +469,81 @@ describe("Reactive Attributes", () => { expect(div.classList.contains("success-state")).toBe(true); }); }); + + describe("isDOMEventTrigger", () => { + it("returns false for lifecycle states", () => { + expect(isDOMEventTrigger("pending")).toBe(false); + expect(isDOMEventTrigger("success")).toBe(false); + expect(isDOMEventTrigger("error")).toBe(false); + expect(isDOMEventTrigger("done")).toBe(false); + }); + + it("returns false for synthetic triggers", () => { + expect(isDOMEventTrigger("click-away")).toBe(false); + }); + + it("returns true for native DOM events", () => { + expect(isDOMEventTrigger("click")).toBe(true); + expect(isDOMEventTrigger("focusin")).toBe(true); + expect(isDOMEventTrigger("focusout")).toBe(true); + expect(isDOMEventTrigger("mouseenter")).toBe(true); + expect(isDOMEventTrigger("mouseleave")).toBe(true); + expect(isDOMEventTrigger("keydown")).toBe(true); + expect(isDOMEventTrigger("input")).toBe(true); + }); + }); + + describe("processElementInteraction", () => { + it("adds class on matching trigger", () => { + const div = document.createElement("div"); + div.setAttribute("lvt-el:addClass:on:click", "open"); + document.body.appendChild(div); + + processElementInteraction(div, "click"); + expect(div.classList.contains("open")).toBe(true); + }); + + it("removes class on matching trigger", () => { + const div = document.createElement("div"); + div.classList.add("open"); + div.setAttribute("lvt-el:removeClass:on:focusout", "open"); + document.body.appendChild(div); + + processElementInteraction(div, "focusout"); + expect(div.classList.contains("open")).toBe(false); + }); + + it("toggles class on matching trigger", () => { + const div = document.createElement("div"); + div.setAttribute("lvt-el:toggleClass:on:click", "open"); + document.body.appendChild(div); + + processElementInteraction(div, "click"); + expect(div.classList.contains("open")).toBe(true); + processElementInteraction(div, "click"); + expect(div.classList.contains("open")).toBe(false); + }); + + it("ignores non-matching trigger", () => { + const div = document.createElement("div"); + div.setAttribute("lvt-el:addClass:on:click", "open"); + document.body.appendChild(div); + + processElementInteraction(div, "mouseenter"); + expect(div.classList.contains("open")).toBe(false); + }); + + it("handles multiple triggers on same element", () => { + const div = document.createElement("div"); + div.setAttribute("lvt-el:addClass:on:mouseenter", "visible"); + div.setAttribute("lvt-el:removeClass:on:mouseleave", "visible"); + document.body.appendChild(div); + + processElementInteraction(div, "mouseenter"); + expect(div.classList.contains("visible")).toBe(true); + + processElementInteraction(div, "mouseleave"); + expect(div.classList.contains("visible")).toBe(false); + }); + }); });