/** * HTMX-R (HTMX Reactive) * Pure HTML attribute-based reactive state management * * Extends HTMX's philosophy: Declarative, HTML-first, no inline JavaScript * State lives in HTML attributes, CSS handles reactivity * * Core attributes: * data-state-{key}="{value}" Declare state on a container (hx-ext="reactive") * data-when="{key}:{value}" Show element when state matches; hide otherwise * hx-state-set="{key}:{value}" Set state to a specific value on click * hx-state-toggle="{key}" Cycle state through values on click * hx-state-on-request="{key}:{value}" Set state when HTMX request begins * hx-state-on-response="{key}:{value}"Set state when HTMX response arrives * hx-state-on-error="{key}:{value}" Set state on HTMX error * hx-state-on-swap="{key}:{value}" Set state after HTMX DOM swap * hx-state-persist="true" Persist state to localStorage * hx-state-sync-url="{param}" Mirror state to URL query param * data-state-value="{key}" Sync input value from state (state → input) * * Binding extensions (v1.1): * hx-state-on-input="{key}" Mirror input.value to state on each keystroke * hx-state-on-input="{key}:length" Mirror input.value.length to state on each keystroke * data-state-text="{key}" Render state value as element textContent * data-class-when="{key}:{val}:{cls}" Add CSS classes when state matches; requires * data-class-default="{cls}" for the inactive state * * Interactive primitives (v1.2): * hx-state-popover="{key}" Click-to-toggle with outside-click/Escape dismiss * hx-state-on-hover="{key}" Set state true on mouseenter, false on mouseleave * hx-state-on-hover="{key}:{ms}" Same with delay (ms) before showing * data-key-nav="{key}" Arrow-key navigation through [data-key-nav-item] children * data-transition="{preset}" Animate data-when show/hide (fade|slide-down|slide-up|slide-left|slide-right|scale) * data-transition-duration="{ms}" Custom transition duration (default: 150ms) * data-when-transition="{key}:{value}" Like data-when but uses visibility+transform instead of display:none, * preserving CSS transitions. Requires data-transition on the same element. * * CSS property binding (v1.4): * hx-state-css-var="{key}:--{var}" Set CSS custom property on element when state changes * hx-state-css-var="{k1}:--{v1}, {k2}:--{v2}" Multiple bindings (comma-separated) */ (function() { 'use strict'; // HTMX-R Extension Definition htmx.defineExtension('reactive', { // Initialize extension onEvent: function(name, evt) { const element = evt.detail.elt; // Handle state changes on HTMX lifecycle events switch(name) { case 'htmx:beforeRequest': applyStateChange(element, 'hx-state-on-request'); break; case 'htmx:afterRequest': applyStateChange(element, 'hx-state-on-response'); break; case 'htmx:responseError': case 'htmx:sendError': applyStateChange(element, 'hx-state-on-error'); break; case 'htmx:afterSwap': applyStateChange(element, 'hx-state-on-swap'); break; } } }); // Apply state change from attribute function applyStateChange(element, attrName) { const stateChange = element.getAttribute(attrName); if (!stateChange) return; // Parse "key:value" format const [key, value] = stateChange.split(':').map(s => s.trim()); if (!key || !value) return; // Find state container const container = findStateContainer(element, key); if (!container) return; // Update state attribute container.setAttribute('data-state-' + key, value); // Persist to localStorage if enabled persistState(container, key, value); // Sync to URL if enabled syncToURL(container, key, value); // Dispatch custom event for state change container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key, value, element }, bubbles: true })); } // Find closest state container function findStateContainer(element, key) { return element.closest('[data-state-' + key + ']'); } // Persist state to localStorage function persistState(container, key, value) { if (container.getAttribute('hx-state-persist') !== 'true') return; try { const storageKey = 'htmx-r:' + key; localStorage.setItem(storageKey, value); } catch (e) { console.warn('HTMX-R: Failed to persist state to localStorage', e); } } // Sync state to URL function syncToURL(container, key, value) { const urlParam = container.getAttribute('hx-state-sync-url'); if (!urlParam) return; try { const url = new URL(window.location); url.searchParams.set(urlParam, value); window.history.replaceState({}, '', url); } catch (e) { console.warn('HTMX-R: Failed to sync state to URL', e); } } // Restore state from URL function restoreFromURL(container) { const urlParam = container.getAttribute('hx-state-sync-url'); if (!urlParam) return; try { const url = new URL(window.location); const value = url.searchParams.get(urlParam); if (value !== null) { // Find the state key by looking at data-state-* attributes Array.from(container.attributes).forEach(attr => { if (attr.name.startsWith('data-state-')) { const key = attr.name.replace('data-state-', ''); container.setAttribute('data-state-' + key, value); container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key, value, element: container }, bubbles: true })); } }); } } catch (e) { console.warn('HTMX-R: Failed to restore state from URL', e); } } // Restore state from localStorage function restoreState(container) { if (container.getAttribute('hx-state-persist') !== 'true') return; try { // Get all state attributes on this container Array.from(container.attributes).forEach(attr => { if (attr.name.startsWith('data-state-')) { const key = attr.name.replace('data-state-', ''); const storageKey = 'htmx-r:' + key; const saved = localStorage.getItem(storageKey); if (saved !== null) { container.setAttribute('data-state-' + key, saved); container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key, value: saved, element: container }, bubbles: true })); } } }); } catch (e) { console.warn('HTMX-R: Failed to restore state from localStorage', e); } } // Transitioning state tracker const transitioning = new WeakMap(); // Handle hx-state-set (direct state setting) document.addEventListener('click', function(e) { const setter = e.target.closest('[hx-state-set]'); if (!setter) return; const stateChange = setter.getAttribute('hx-state-set'); if (!stateChange) return; // Parse "key:value" format const [key, value] = stateChange.split(':').map(s => s.trim()); if (!key || !value) return; const container = findStateContainer(setter, key); if (!container) return; // Set state to specific value container.setAttribute('data-state-' + key, value); // Persist to localStorage if enabled persistState(container, key, value); // Sync to URL if enabled syncToURL(container, key, value); // Dispatch state change event container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key, value, element: setter }, bubbles: true })); }); // Handle state toggles document.addEventListener('click', function(e) { const toggle = e.target.closest('[hx-state-toggle]'); if (!toggle) return; const stateKey = toggle.getAttribute('hx-state-toggle'); if (!stateKey) return; const container = findStateContainer(toggle, stateKey); if (!container) return; // Prevent rapid clicks causing race conditions if (transitioning.get(container)) return; transitioning.set(container, true); const currentValue = container.getAttribute('data-state-' + stateKey); // Get toggle values (default: true/false) const valuesAttr = toggle.getAttribute('hx-state-values'); let values; if (valuesAttr) { values = valuesAttr.split(',').map(v => v.trim()); } else { values = ['true', 'false']; } // Toggle to next value (cycle through values) const currentIndex = values.indexOf(currentValue); const nextIndex = (currentIndex + 1) % values.length; const newValue = values[nextIndex]; // Update state container.setAttribute('data-state-' + stateKey, newValue); // Persist to localStorage if enabled persistState(container, stateKey, newValue); // Sync to URL if enabled syncToURL(container, stateKey, newValue); // Update checkbox state if toggle is a checkbox if (toggle.type === 'checkbox') { toggle.checked = (newValue === values[0]); } // Dispatch state change event container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key: stateKey, value: newValue, element: toggle }, bubbles: true })); // Clear transition flag after a microtask (allows CSS to update) requestAnimationFrame(() => transitioning.delete(container)); }); // Handle state changes on form inputs document.addEventListener('change', function(e) { const stateToggle = e.target.getAttribute('hx-state-toggle'); if (!stateToggle) return; const container = findStateContainer(e.target, stateToggle); if (!container) return; // Skip if click handler already processed this (prevents double-toggle on checkboxes) if (e.target.type === 'checkbox' && transitioning.get(container)) return; // For checkboxes with custom values, use those instead of true/false let value; if (e.target.type === 'checkbox') { const valuesAttr = e.target.getAttribute('hx-state-values'); if (valuesAttr) { const values = valuesAttr.split(',').map(v => v.trim()); value = e.target.checked ? values[0] : values[1]; } else { value = e.target.checked.toString(); } } else { value = e.target.value; } container.setAttribute('data-state-' + stateToggle, value); // Persist to localStorage if enabled persistState(container, stateToggle, value); // Sync to URL if enabled syncToURL(container, stateToggle, value); container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key: stateToggle, value, element: e.target }, bubbles: true })); }); // Sync form values with state (state → input, for data-state-value) document.addEventListener('htmx-r:state-change', function(e) { const { key, value } = e.detail; const container = e.target; // Update all inputs with data-state-value attribute const inputs = container.querySelectorAll('[data-state-value="' + key + '"]'); inputs.forEach(input => { if (input.type === 'checkbox') { input.checked = (value === 'true' || value === input.getAttribute('data-state-values')?.split(',')[0]); } else { input.value = value; } }); }); /** * Dynamic data-when handler * Instead of relying solely on CSS rules (which must be hardcoded per state * name + value combo), this listener dynamically shows/hides data-when * elements for ANY state name and value combination. */ document.addEventListener('htmx-r:state-change', function(e) { const { key, value } = e.detail; const container = e.target; // Find all data-when elements within this container that match this key const whenElements = container.querySelectorAll('[data-when^="' + key + ':"]'); whenElements.forEach(el => { const whenAttr = el.getAttribute('data-when'); const whenValue = whenAttr.substring(key.length + 1); // after "key:" // Skip elements being handled by the transition system if (el.hasAttribute('data-transition') || el.hasAttribute('data-htmxr-transitioning')) return; if (whenValue === value) { el.style.display = ''; el.removeAttribute('data-htmx-r-hidden'); } else { el.style.display = 'none'; el.setAttribute('data-htmx-r-hidden', 'true'); } }); }); // ── BINDING EXTENSIONS v1.1 ─────────────────────────────────────────── /** * hx-state-on-input — input → state binding (one-way, on each keystroke) * * Mirrors an input element's value (or a property of it) to a state key * whenever the user types. The state container must already declare * data-state-{key} as an ancestor. * * Usage: * hx-state-on-input="key" stores el.value as state key * hx-state-on-input="key:length" stores el.value.length as state key * hx-state-on-input="key:checked" stores el.checked (boolean inputs) * * Example — character counter: *
* * 0/17 *
*/ document.addEventListener('input', function(e) { const attr = e.target.getAttribute('hx-state-on-input'); if (!attr) return; const colonIdx = attr.indexOf(':'); const key = colonIdx === -1 ? attr.trim() : attr.slice(0, colonIdx).trim(); const prop = colonIdx === -1 ? null : attr.slice(colonIdx + 1).trim(); if (!key) return; const rawValue = e.target.value; const value = prop ? String(rawValue[prop] !== undefined ? rawValue[prop] : rawValue) : rawValue; const container = findStateContainer(e.target, key); if (!container) return; container.setAttribute('data-state-' + key, value); persistState(container, key, value); syncToURL(container, key, value); container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key, value, element: e.target }, bubbles: true })); }); /** * data-state-text — state → textContent binding * * Renders the current state value as the textContent of any element * marked with data-state-text="{key}". Updates live on every state change. * Initial value is set during initStateContainers via htmx-r:state-change. * * Usage: * 0 * * Pairs naturally with hx-state-on-input for character counters, * live computed values, and derived displays. */ document.addEventListener('htmx-r:state-change', function(e) { const { key, value } = e.detail; // Update all matching text elements anywhere in the document // (not just inside the container — allows text displays to live outside state scope) document.querySelectorAll('[data-state-text="' + key + '"]').forEach(function(el) { el.textContent = value; }); }); /** * hx-state-css-var — state → CSS custom property binding (v1.4) * * Sets one or more CSS custom properties on an element whenever a state key * changes. Enables fully declarative, state-driven CSS layout (grid columns, * sizing, theming) without any JavaScript class manipulation. * * Format: * hx-state-css-var="{key}:--{var-name}" * hx-state-css-var="{key}:--{var-name}, {key2}:--{var2}" (comma-separated) * * Example — adjustable grid columns driven by a range slider: * *
* * 5 cols * *
...
*
* * * The property is set directly on the element's inline style so it is scoped * to that subtree — descendants can inherit it via var(), siblings cannot. * * Multiple bindings on one element: * hx-state-css-var="cols:--grid-cols, gap:--grid-gap" */ document.addEventListener('htmx-r:state-change', function(e) { const { key, value } = e.detail; // Scope to the emitting state container so two independent widgets that // happen to share a key name don't bleed CSS vars into each other. const scope = e.detail.element || document; scope.querySelectorAll('[hx-state-css-var]').forEach(function(el) { el.getAttribute('hx-state-css-var').split(',').forEach(function(binding) { const sep = binding.trim().indexOf(':'); if (sep < 0) return; const bKey = binding.trim().slice(0, sep).trim(); const bVar = binding.trim().slice(sep + 1).trim(); if (bKey === key) { el.style.setProperty(bVar, value); } }); }); }); /** * data-class-when — state → CSS class binding * * Adds a set of CSS classes to an element when a state key matches a value, * and removes them (restoring data-class-default) when it does not. * Eliminates the need for Alpine.js :class bindings for active-state UI. * * Usage: * data-class-when="{key}:{value}:{class1} {class2} ..." * data-class-default="{class1} {class2} ..." (optional fallback classes) * * Example — condition toggle buttons (New / Used / CPO): *
* * *
* * Note: The element does NOT need to be inside the state container — * class updates are applied document-wide so overlay UIs work correctly. */ document.addEventListener('htmx-r:state-change', function(e) { const { key, value } = e.detail; // Find all elements with data-class-when starting with this key document.querySelectorAll('[data-class-when^="' + key + ':"]').forEach(function(el) { const attr = el.getAttribute('data-class-when'); // Parse format: "key:matchValue:class1 class2 ..." // Use indexOf to support colons inside class names (unlikely but safe) const firstColon = attr.indexOf(':'); const secondColon = attr.indexOf(':', firstColon + 1); if (firstColon === -1 || secondColon === -1) return; const whenKey = attr.slice(0, firstColon).trim(); const whenValue = attr.slice(firstColon + 1, secondColon).trim(); const activeClasses = attr.slice(secondColon + 1).trim().split(/\s+/).filter(Boolean); const defaultClasses = (el.getAttribute('data-class-default') || '').split(/\s+/).filter(Boolean); if (whenKey !== key) return; if (value === whenValue) { // Activate: remove default classes, add active classes defaultClasses.forEach(function(c) { el.classList.remove(c); }); activeClasses.forEach(function(c) { el.classList.add(c); }); } else { // Deactivate: remove active classes, restore default classes activeClasses.forEach(function(c) { el.classList.remove(c); }); defaultClasses.forEach(function(c) { el.classList.add(c); }); } }); }); // ── POPOVER / DROPDOWN (v1.2) ──────────────────────────────────────── /** * hx-state-popover — click-to-toggle floating content with outside-click dismiss * * Toggles a state key between "open" and "closed" on click. * Clicking outside the popover container (or pressing Escape) closes it. * This is the foundation for dropdowns, select menus, comboboxes, and popovers. * * Usage: *
* * *
* * The attribute goes on the trigger element. The container must declare * data-state-{key}="closed". Content shown via data-when="{key}:open". */ document.addEventListener('click', function(e) { var trigger = e.target.closest('[hx-state-popover]'); if (trigger) { e.stopPropagation(); var key = trigger.getAttribute('hx-state-popover').trim(); var container = findStateContainer(trigger, key); if (!container) return; var current = container.getAttribute('data-state-' + key); var next = current === 'open' ? 'closed' : 'open'; container.setAttribute('data-state-' + key, next); persistState(container, key, next); syncToURL(container, key, next); container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key: key, value: next, element: trigger }, bubbles: true })); return; } // Outside-click: close any open popovers var openContainers = document.querySelectorAll('[hx-state-popover]'); var seen = new Set(); openContainers.forEach(function(el) { var key = el.getAttribute('hx-state-popover').trim(); var container = findStateContainer(el, key); if (!container || seen.has(container)) return; seen.add(container); if (container.getAttribute('data-state-' + key) === 'open') { // Only close if click is outside the container if (!container.contains(e.target)) { container.setAttribute('data-state-' + key, 'closed'); persistState(container, key, 'closed'); syncToURL(container, key, 'closed'); container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key: key, value: 'closed', element: el }, bubbles: true })); } } }); }); // Escape key closes all open popovers document.addEventListener('keydown', function(e) { if (e.key !== 'Escape') return; var triggers = document.querySelectorAll('[hx-state-popover]'); var seen = new Set(); triggers.forEach(function(el) { var key = el.getAttribute('hx-state-popover').trim(); var container = findStateContainer(el, key); if (!container || seen.has(container)) return; seen.add(container); if (container.getAttribute('data-state-' + key) === 'open') { container.setAttribute('data-state-' + key, 'closed'); persistState(container, key, 'closed'); syncToURL(container, key, 'closed'); container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key: key, value: 'closed', element: el }, bubbles: true })); } }); }); // ── HOVER TRIGGER (v1.2) ──────────────────────────────────────────── /** * hx-state-on-hover="{key}" — set state on mouseenter, clear on mouseleave * * Sets the state key to "true" on mouseenter and "false" on mouseleave. * Perfect for tooltips, hover cards, and preview popups. * * Usage: *
* Hover me *
Tooltip content
*
* * Optional delay (ms) to prevent flicker: * hx-state-on-hover="tip:300" — 300ms delay before showing */ var hoverTimers = new WeakMap(); document.addEventListener('mouseenter', function(e) { var el = e.target.closest('[hx-state-on-hover]'); if (!el) return; var attr = el.getAttribute('hx-state-on-hover').trim(); var colonIdx = attr.indexOf(':'); var key = colonIdx === -1 ? attr : attr.slice(0, colonIdx).trim(); var delay = colonIdx === -1 ? 0 : parseInt(attr.slice(colonIdx + 1), 10) || 0; var container = findStateContainer(el, key); if (!container) return; // Clear any pending mouseleave timer var timers = hoverTimers.get(el) || {}; if (timers.leave) { clearTimeout(timers.leave); timers.leave = null; } var apply = function() { container.setAttribute('data-state-' + key, 'true'); persistState(container, key, 'true'); container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key: key, value: 'true', element: el }, bubbles: true })); }; if (delay > 0) { timers.enter = setTimeout(apply, delay); hoverTimers.set(el, timers); } else { apply(); } }, true); document.addEventListener('mouseleave', function(e) { var el = e.target.closest('[hx-state-on-hover]'); if (!el) return; var attr = el.getAttribute('hx-state-on-hover').trim(); var colonIdx = attr.indexOf(':'); var key = colonIdx === -1 ? attr : attr.slice(0, colonIdx).trim(); var container = findStateContainer(el, key); if (!container) return; var timers = hoverTimers.get(el) || {}; if (timers.enter) { clearTimeout(timers.enter); timers.enter = null; } // Small delay on leave to allow moving into tooltip content timers.leave = setTimeout(function() { container.setAttribute('data-state-' + key, 'false'); persistState(container, key, 'false'); container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key: key, value: 'false', element: el }, bubbles: true })); }, 100); hoverTimers.set(el, timers); }, true); // ── KEYBOARD NAVIGATION (v1.2) ────────────────────────────────────── /** * data-key-nav="{key}" — arrow key navigation through child items * * Tracks the active item index in a state key. Arrow Up/Down moves through * children marked with [data-key-nav-item]. Enter dispatches a click on * the active item. Home/End jump to first/last. * * Usage: *
*
Item 0
*
Item 1
*
Item 2
*
* * Works with data-class-when for highlighting the active item. * The container must be focusable (tabindex="0") or contain a focused input. */ document.addEventListener('keydown', function(e) { var nav = e.target.closest('[data-key-nav]'); if (!nav) return; var validKeys = ['ArrowUp', 'ArrowDown', 'Enter', 'Home', 'End']; if (validKeys.indexOf(e.key) === -1) return; var key = nav.getAttribute('data-key-nav').trim(); var items = nav.querySelectorAll('[data-key-nav-item]'); if (items.length === 0) return; var current = parseInt(nav.getAttribute('data-state-' + key), 10) || 0; var next = current; switch (e.key) { case 'ArrowDown': next = (current + 1) % items.length; e.preventDefault(); break; case 'ArrowUp': next = (current - 1 + items.length) % items.length; e.preventDefault(); break; case 'Home': next = 0; e.preventDefault(); break; case 'End': next = items.length - 1; e.preventDefault(); break; case 'Enter': if (items[current]) { items[current].click(); } e.preventDefault(); return; } if (next !== current) { var value = String(next); nav.setAttribute('data-state-' + key, value); persistState(nav, key, value); syncToURL(nav, key, value); nav.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key: key, value: value, element: nav }, bubbles: true })); // Scroll active item into view if (items[next] && items[next].scrollIntoView) { items[next].scrollIntoView({ block: 'nearest' }); } } }); // ── CSS TRANSITIONS (v1.2) ────────────────────────────────────────── /** * data-transition — animate data-when show/hide with CSS classes * * Instead of instant display:none/block, applies enter/leave transition * classes so elements can fade, slide, or scale in/out. * * Usage: *
* Animated content *
* * Built-in transition presets: * "fade" — opacity 0→1 / 1→0 * "slide-down" — translateY(-8px)→0 + opacity * "slide-up" — translateY(8px)→0 + opacity * "scale" — scale(0.95)→1 + opacity * * Custom classes (advanced): * data-transition-enter="opacity-0" * data-transition-enter-active="transition-opacity duration-200" * data-transition-enter-to="opacity-100" * data-transition-leave="opacity-100" * data-transition-leave-active="transition-opacity duration-200" * data-transition-leave-to="opacity-0" */ var transitionPresets = { 'fade': { enter: 'htmxr-fade-enter', enterActive: 'htmxr-fade-enter-active', leave: 'htmxr-fade-leave', leaveActive: 'htmxr-fade-leave-active' }, 'slide-down': { enter: 'htmxr-slide-down-enter', enterActive: 'htmxr-slide-down-enter-active', leave: 'htmxr-slide-down-leave', leaveActive: 'htmxr-slide-down-leave-active' }, 'slide-up': { enter: 'htmxr-slide-up-enter', enterActive: 'htmxr-slide-up-enter-active', leave: 'htmxr-slide-up-leave', leaveActive: 'htmxr-slide-up-leave-active' }, 'scale': { enter: 'htmxr-scale-enter', enterActive: 'htmxr-scale-enter-active', leave: 'htmxr-scale-leave', leaveActive: 'htmxr-scale-leave-active' }, 'slide-left': { enter: 'htmxr-slide-left-enter', enterActive: 'htmxr-slide-left-enter-active', leave: 'htmxr-slide-left-leave', leaveActive: 'htmxr-slide-left-leave-active' }, 'slide-right': { enter: 'htmxr-slide-right-enter', enterActive: 'htmxr-slide-right-enter-active', leave: 'htmxr-slide-right-leave', leaveActive: 'htmxr-slide-right-leave-active' } }; // Inject transition CSS once var transitionStyleId = 'htmxr-transition-styles'; if (!document.getElementById(transitionStyleId)) { var style = document.createElement('style'); style.id = transitionStyleId; style.textContent = /* fade */ '.htmxr-fade-enter { opacity: 0; }' + '.htmxr-fade-enter-active { transition: opacity var(--htmxr-duration, 150ms) ease-out; opacity: 1; }' + '.htmxr-fade-leave { opacity: 1; }' + '.htmxr-fade-leave-active { transition: opacity var(--htmxr-duration, 150ms) ease-in; opacity: 0; }' + /* slide-down */ '.htmxr-slide-down-enter { opacity: 0; transform: translateY(-8px); }' + '.htmxr-slide-down-enter-active { transition: opacity var(--htmxr-duration, 150ms) ease-out, transform var(--htmxr-duration, 150ms) ease-out; opacity: 1; transform: translateY(0); }' + '.htmxr-slide-down-leave { opacity: 1; transform: translateY(0); }' + '.htmxr-slide-down-leave-active { transition: opacity var(--htmxr-duration, 150ms) ease-in, transform var(--htmxr-duration, 150ms) ease-in; opacity: 0; transform: translateY(-8px); }' + /* slide-up */ '.htmxr-slide-up-enter { opacity: 0; transform: translateY(8px); }' + '.htmxr-slide-up-enter-active { transition: opacity var(--htmxr-duration, 150ms) ease-out, transform var(--htmxr-duration, 150ms) ease-out; opacity: 1; transform: translateY(0); }' + '.htmxr-slide-up-leave { opacity: 1; transform: translateY(0); }' + '.htmxr-slide-up-leave-active { transition: opacity var(--htmxr-duration, 150ms) ease-in, transform var(--htmxr-duration, 150ms) ease-in; opacity: 0; transform: translateY(8px); }' + /* scale */ '.htmxr-scale-enter { opacity: 0; transform: scale(0.95); }' + '.htmxr-scale-enter-active { transition: opacity var(--htmxr-duration, 150ms) ease-out, transform var(--htmxr-duration, 150ms) ease-out; opacity: 1; transform: scale(1); }' + '.htmxr-scale-leave { opacity: 1; transform: scale(1); }' + '.htmxr-scale-leave-active { transition: opacity var(--htmxr-duration, 150ms) ease-in, transform var(--htmxr-duration, 150ms) ease-in; opacity: 0; transform: scale(0.95); }' + /* slide-left (sidebar from left edge) */ '.htmxr-slide-left-enter { transform: translateX(-100%); }' + '.htmxr-slide-left-enter-active { transition: transform var(--htmxr-duration, 200ms) ease-out; transform: translateX(0); }' + '.htmxr-slide-left-leave { transform: translateX(0); }' + '.htmxr-slide-left-leave-active { transition: transform var(--htmxr-duration, 200ms) ease-in; transform: translateX(-100%); }' + /* slide-right (sidebar from right edge) */ '.htmxr-slide-right-enter { transform: translateX(100%); }' + '.htmxr-slide-right-enter-active { transition: transform var(--htmxr-duration, 200ms) ease-out; transform: translateX(0); }' + '.htmxr-slide-right-leave { transform: translateX(0); }' + '.htmxr-slide-right-leave-active { transition: transform var(--htmxr-duration, 200ms) ease-in; transform: translateX(100%); }'; document.head.appendChild(style); } // Override the data-when handler to support transitions // We patch the state-change listener to intercept data-when elements that have data-transition document.addEventListener('htmx-r:state-change', function(e) { var key = e.detail.key; var value = e.detail.value; var container = e.target; var whenElements = container.querySelectorAll('[data-when^="' + key + ':"][data-transition]'); whenElements.forEach(function(el) { var whenAttr = el.getAttribute('data-when'); var whenValue = whenAttr.substring(key.length + 1); var preset = el.getAttribute('data-transition'); var durationMs = parseInt(el.getAttribute('data-transition-duration'), 10) || 150; // Set CSS variable for duration el.style.setProperty('--htmxr-duration', durationMs + 'ms'); var classes = transitionPresets[preset]; if (!classes) return; // not a recognized preset, skip // Mark this element so the default data-when handler skips it el.setAttribute('data-htmxr-transitioning', 'true'); if (whenValue === value) { // ENTER: show with transition el.style.display = ''; el.removeAttribute('data-htmx-r-hidden'); // Apply enter start state el.classList.add(classes.enter); el.classList.remove(classes.leaveActive, classes.leave); requestAnimationFrame(function() { requestAnimationFrame(function() { el.classList.remove(classes.enter); el.classList.add(classes.enterActive); }); }); // Clean up after transition setTimeout(function() { el.classList.remove(classes.enterActive); el.removeAttribute('data-htmxr-transitioning'); }, durationMs + 20); } else if (el.style.display !== 'none' && !el.hasAttribute('data-htmx-r-hidden')) { // LEAVE: hide with transition (only if currently visible) el.classList.add(classes.leave); el.classList.remove(classes.enterActive, classes.enter); requestAnimationFrame(function() { requestAnimationFrame(function() { el.classList.remove(classes.leave); el.classList.add(classes.leaveActive); }); }); setTimeout(function() { el.style.display = 'none'; el.setAttribute('data-htmx-r-hidden', 'true'); el.classList.remove(classes.leaveActive); el.removeAttribute('data-htmxr-transitioning'); }, durationMs + 20); } }); }); // ── data-when-transition — visibility-based show/hide (v1.3) ───── /** * data-when-transition="{key}:{value}" — like data-when but transition-aware * * Uses visibility:hidden + pointer-events:none instead of display:none, * so CSS transitions on transform, opacity, etc. fire correctly. * Requires data-transition="{preset}" on the same element. * * This solves the fundamental problem with data-when + sidebars/drawers: * display:none cannot be animated with CSS transitions. * * Usage (mobile sidebar): * * * Behavior: * - When state matches: visibility:visible, pointer-events:auto, run enter transition * - When state doesn't match: run leave transition, then visibility:hidden, pointer-events:none * - Element stays in DOM layout (no reflow) — just invisible and non-interactive */ document.addEventListener('htmx-r:state-change', function(e) { var key = e.detail.key; var value = e.detail.value; var container = e.target; var els = container.querySelectorAll('[data-when-transition^="' + key + ':"]'); els.forEach(function(el) { var attr = el.getAttribute('data-when-transition'); var whenValue = attr.substring(key.length + 1); var preset = el.getAttribute('data-transition') || 'fade'; var durationMs = parseInt(el.getAttribute('data-transition-duration'), 10) || 200; var classes = transitionPresets[preset]; if (!classes) return; el.style.setProperty('--htmxr-duration', durationMs + 'ms'); if (whenValue === value) { // ENTER: make visible and animate in el.setAttribute('data-htmxr-wt-seen', ''); el.style.visibility = 'visible'; el.style.pointerEvents = 'auto'; el.classList.remove(classes.leaveActive, classes.leave); el.classList.add(classes.enter); requestAnimationFrame(function() { requestAnimationFrame(function() { el.classList.remove(classes.enter); el.classList.add(classes.enterActive); }); }); setTimeout(function() { el.classList.remove(classes.enterActive); }, durationMs + 20); } else { // LEAVE: hide immediately on first encounter (initial state sync on load) // so elements that start hidden don't flash visible then animate away. // On subsequent state changes, run the full leave transition. if (!el.hasAttribute('data-htmxr-wt-seen')) { el.style.visibility = 'hidden'; el.style.pointerEvents = 'none'; el.setAttribute('data-htmxr-wt-seen', ''); return; } el.classList.remove(classes.enterActive, classes.enter); el.classList.add(classes.leave); requestAnimationFrame(function() { requestAnimationFrame(function() { el.classList.remove(classes.leave); el.classList.add(classes.leaveActive); }); }); setTimeout(function() { el.style.visibility = 'hidden'; el.style.pointerEvents = 'none'; el.classList.remove(classes.leaveActive); }, durationMs + 20); } }); }); // ── INITIALIZATION ──────────────────────────────────────────────────── // Find all state containers in a subtree (universal — no hardcoded names) function findStateContainersIn(root) { const containers = []; const elements = root.querySelectorAll ? root.querySelectorAll('*') : []; elements.forEach(el => { for (let i = 0; i < el.attributes.length; i++) { if (el.attributes[i].name.startsWith('data-state-')) { containers.push(el); break; } } }); // Check root itself if (root.attributes) { for (let i = 0; i < root.attributes.length; i++) { if (root.attributes[i].name.startsWith('data-state-')) { containers.push(root); break; } } } return containers; } // Initialize state containers: restore persisted state and trigger sync function initStateContainers(containers) { containers.forEach(container => { // Restore from URL first (highest priority) restoreFromURL(container); // Then restore from localStorage restoreState(container); // Trigger initial state sync for all state attributes // This fires htmx-r:state-change for each key, which drives // data-when, data-state-text, data-class-when, and data-state-value Array.from(container.attributes).forEach(attr => { if (attr.name.startsWith('data-state-')) { const key = attr.name.replace('data-state-', ''); const value = attr.value; container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key, value, element: container }, bubbles: true })); } }); }); } // Initialize state-dependent elements on page load document.addEventListener('DOMContentLoaded', function() { initStateContainers(findStateContainersIn(document)); }); // Re-initialize after HTMX swaps (new content may have state containers) document.addEventListener('htmx:afterSettle', function(e) { const target = e.detail.target || e.target; initStateContainers(findStateContainersIn(target)); }); // Helper: Get/set state programmatically window.htmxR = { getState: function(element, key) { const container = findStateContainer(element, key); return container ? container.getAttribute('data-state-' + key) : null; }, setState: function(element, key, value) { const container = findStateContainer(element, key); if (container) { container.setAttribute('data-state-' + key, value); persistState(container, key, value); syncToURL(container, key, value); container.dispatchEvent(new CustomEvent('htmx-r:state-change', { detail: { key, value, element }, bubbles: true })); } } }; console.log('✓ HTMX-R (Reactive) v1.4 loaded — hx-state-css-var CSS property binding'); })();