Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
8bb6a9c
Redesign hx-live state access
scriptogre Aug 11, 2026
cbc8400
Document hx-live state access
scriptogre Aug 11, 2026
bcff682
Normalize live HTML attribute names
scriptogre Aug 11, 2026
3357fe1
Remove speculative hx-live tests
scriptogre Aug 11, 2026
917b23d
Add functional assignments to hx-live
scriptogre Aug 5, 2026
cf3f0b9
Clarify HTML attribute name tests
scriptogre Aug 11, 2026
3d57f7e
Remove public live attr helper
scriptogre Aug 11, 2026
2f5024c
Let extensions rewrite JavaScript expressions
scriptogre Aug 11, 2026
51e1155
Restore bare class state in hx-live
scriptogre Aug 11, 2026
ba90df7
Align hx-live state types
scriptogre Aug 11, 2026
0b9544e
Keep toggle in hx-live reference
scriptogre Aug 11, 2026
729b3c4
Simplify bare class rewriting
scriptogre Aug 11, 2026
bd3f082
Make hx-live usage progressive
scriptogre Aug 11, 2026
b007db8
Simplify hx-live usage wording
scriptogre Aug 11, 2026
4bfc864
Cover bare class parser edge cases
scriptogre Aug 11, 2026
1c2c9d5
Merge remote-tracking branch 'official/four-dev' into 4.0/hx-live-red…
scriptogre Aug 12, 2026
f35644f
Prevent overlapping async hx-live effects
scriptogre Aug 12, 2026
d3307c4
Name hx-live bindings and effects consistently
scriptogre Aug 12, 2026
dfbfc7a
Merge upstream/four-dev
scriptogre Aug 12, 2026
61d8606
Refine hx-live DOM state API
scriptogre Aug 14, 2026
3deb2d5
Preserve JSON-looking data strings
scriptogre Aug 14, 2026
fbd6a57
Use native boolean attribute toggling
scriptogre Aug 14, 2026
dcf9059
Cover hx-live class methods and types
scriptogre Aug 14, 2026
bb8c8aa
Type numeric HTML attributes
scriptogre Aug 14, 2026
e9fcf53
Keep empty hx-live queries composable
scriptogre Aug 14, 2026
2177e26
Keep empty take queries inert
scriptogre Aug 14, 2026
9e5dab8
Cover the hx-live take shortcut
scriptogre Aug 14, 2026
f241191
Unify hx-live class proxies
scriptogre Aug 14, 2026
243345d
Compact hx-live array method lookup
scriptogre Aug 14, 2026
96eaff4
Remove data attributes through attr delete
scriptogre Aug 14, 2026
203c665
Preserve typed toggle values
scriptogre Aug 14, 2026
b045783
Preserve native hidden state
scriptogre Aug 14, 2026
3f66d23
Cover current HTML boolean attributes
scriptogre Aug 14, 2026
c861b1d
Unify hx-live state proxies
scriptogre Aug 14, 2026
a5675cf
Tighten hx-live diagnostics
scriptogre Aug 14, 2026
4212989
Unify hx-live registration
scriptogre Aug 14, 2026
e1c3058
Simplify hx-live event waiting
scriptogre Aug 14, 2026
d652530
Merge remote-tracking branch 'official/four-dev' into 4.0/hx-live-red…
scriptogre Aug 14, 2026
9dfc60a
Merge branch '4.0/hx-live-redesign' of https://github.com/scriptogre/…
scriptogre Aug 14, 2026
761d322
Tighten hx-live documentation
scriptogre Aug 14, 2026
0382c4e
Support EventTarget in hx-live forEvent
scriptogre Aug 14, 2026
8c0d50f
Harden bare class expressions
scriptogre Aug 14, 2026
28c988b
Improve hx-live hot paths
scriptogre Aug 16, 2026
ff93c98
Merge remote-tracking branch 'origin/four-dev' into pr-3957
1cg Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
611 changes: 367 additions & 244 deletions src/ext/hx-live.js

Large diffs are not rendered by default.

278 changes: 184 additions & 94 deletions src/htmx.d.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
/** Configures the hx-live extension. */
export interface HtmxLiveConfig {
/**
* Debounces `input` events by a number of milliseconds or an interval string.
* @default 100
*/
inputDebounce?: number | string;
/**
* Sets the short binding prefix (`':'` -> `:text`, `'hx:'` -> `hx:text`, `''`/`false` -> disabled).
* Alpine.js detection disables the default.
* @default ":"
*/
bindPrefix?: string | false;
/**
* Adds `$()` as a `q()` alias in `hx-live`, `:attr`, `hx-on`, `js:` attributes, and `hx-trigger` filters.
* @default false
*/
useDollar?: boolean;
export namespace HxLive {
/** Configures the hx-live extension. */
export interface Config {
/**
* Debounces `input` events by a number of milliseconds or an interval string.
* @default 100
*/
inputDebounce?: number | string;
/**
* Sets the short binding prefix (`':'` -> `:text`, `'hx:'` -> `hx:text`, `''`/`false` -> disabled).
* Alpine.js detection disables the default.
* @default ":"
*/
bindPrefix?: string | false;
/**
* Adds `$()` as a `q()` alias in `hx-live`, `:attr`, `hx-on`, `js:` attributes, and `hx-trigger` filters.
* @default false
*/
useDollar?: boolean;
}
}

export interface HtmxConfig {
Expand Down Expand Up @@ -139,7 +141,7 @@ export interface HtmxConfig {
*/
defaultSwapEmpty?: boolean;
/** Requires hx-live. */
live?: HtmxLiveConfig;
live?: HxLive.Config;
}

/** Context object passed to `htmx.swap()` */
Expand All @@ -166,79 +168,171 @@ export interface HtmxSwapContext {
anchor?: string;
}

export interface QProxy {
/** Number of matched elements. */
count: number;
/** Returns a plain array of the matched elements. */
arr(): Element[];
/**
* Re-runs the selector grammar with each matched element as the anchor.
* Supports `next`, `previous`, `closest`, `first`, `last`, and `in` scoping.
*/
q(selector: string): QProxy;
/**
* Get an attribute, class, or property from the first matched element.
* - `.foo`: class presence as `true`/`false`
* - `aria-*`: coerces `"true"`/`"false"` to boolean
* - boolean attrs (`hidden`, `disabled`, etc.): `true`/`false`
* - `value`, `checked`, `selected`: DOM property
* - anything else: `getAttribute(name)`
*/
attr(name: string): any;
/**
* Set an attribute, class, or property on all matched elements. Returns the proxy for chaining.
* - `.foo`: adds/removes class by truthiness
* - `'class'`: space-separated string or `{ className: condition }` object
* - `aria-*`: strings/numbers pass through; others coerce to `"true"`/`"false"`
* - `value`, `checked`, `selected`: syncs DOM property and HTML attribute
* - boolean attrs: truthy adds, falsy removes
* - anything else: `null`/`undefined`/`false` removes; otherwise sets as string
*/
attr(name: string, value: any): QProxy;
/**
* Move a class or attribute from sibling/scoped elements to all matched elements.
* @param scope - CSS selector, DOM node, or `{ from: string }`. Defaults to parent element.
*/
take(name: string, scope?: string | Node | { from: string }): QProxy;
/**
* Toggle (binary flip) or cycle (with `values`) a class or attribute on all matched elements.
* @param values - Pipe-delimited string (`'grid|list'`) or array to cycle through.
*/
toggle(name: string, values?: string | string[]): QProxy;
/**
* Dispatch a `CustomEvent` from all matched elements.
* @param bubbles - Defaults to `true`.
*/
trigger(type: string, detail?: any, bubbles?: boolean): QProxy;
/**
* Insert HTML relative to all matched elements.
* - `'before'`/`'after'`: sibling before/after
* - `'start'`/`'end'`: first/last child
*/
insert(pos: 'before' | 'after' | 'start' | 'end', html: string): QProxy;
/**
* Cascading `data-*` proxy. Reads/writes the closest ancestor with the matching `data-*` attribute.
* Values are JSON-parsed on read and JSON-serialized on write.
*/
data?: Record<string, any>;
/** Iterate over matched elements. */
[Symbol.iterator](): IterableIterator<Element>;
/** DOM property passthrough: reads from first element, writes to all. */
[key: string]: any;
export namespace HxLive {
/** Boolean class membership and class operations. */
export interface ClassProxy {
assign(classes: Record<string, any>): void;
add(...classes: string[]): void;
remove(...classes: string[]): void;
toggle(className: string, force?: boolean): boolean;
replace(oldClass: string, newClass: string): boolean;
contains(className: string): boolean;
[name: string]: any;
}

export type Updater<Current, Next = Current> = (current: Current) => Next;
type AriaWrite<T> = T | null | undefined | Updater<T | undefined, T | null | undefined>;
type AriaTristate = boolean | 'mixed' | 'undefined';
type AriaOptionalBoolean = boolean | 'undefined';
type AriaCurrent = boolean | 'page' | 'step' | 'location' | 'date' | 'time';
type AriaHasPopup = boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog';
type AriaInvalid = boolean | 'grammar' | 'spelling';
type AriaDropEffect = Array<'copy' | 'execute' | 'link' | 'move' | 'none' | 'popup'>;
type AriaRelevant = Array<'additions' | 'removals' | 'text' | 'all'>;

/** Typed WAI-ARIA 1.2 state. Assign a function to update the current value. */
export interface AriaProxy {
/** Strings and ID references. */
get activeDescendant(): string | undefined; set activeDescendant(value: AriaWrite<string>);
get details(): string | undefined; set details(value: AriaWrite<string>);
get errorMessage(): string | undefined; set errorMessage(value: AriaWrite<string>);
get keyShortcuts(): string | undefined; set keyShortcuts(value: AriaWrite<string>);
get label(): string | undefined; set label(value: AriaWrite<string>);
get placeholder(): string | undefined; set placeholder(value: AriaWrite<string>);
get roleDescription(): string | undefined; set roleDescription(value: AriaWrite<string>);
get valueText(): string | undefined; set valueText(value: AriaWrite<string>);

/** Booleans and states. */
get atomic(): boolean | undefined; set atomic(value: AriaWrite<boolean>);
get busy(): boolean | undefined; set busy(value: AriaWrite<boolean>);
get checked(): AriaTristate | undefined; set checked(value: AriaWrite<AriaTristate>);
get disabled(): boolean | undefined; set disabled(value: AriaWrite<boolean>);
get expanded(): AriaOptionalBoolean | undefined; set expanded(value: AriaWrite<AriaOptionalBoolean>);
get grabbed(): AriaOptionalBoolean | undefined; set grabbed(value: AriaWrite<AriaOptionalBoolean>);
get hidden(): AriaOptionalBoolean | undefined; set hidden(value: AriaWrite<AriaOptionalBoolean>);
get modal(): boolean | undefined; set modal(value: AriaWrite<boolean>);
get multiLine(): boolean | undefined; set multiLine(value: AriaWrite<boolean>);
get multiSelectable(): boolean | undefined; set multiSelectable(value: AriaWrite<boolean>);
get pressed(): AriaTristate | undefined; set pressed(value: AriaWrite<AriaTristate>);
get readOnly(): boolean | undefined; set readOnly(value: AriaWrite<boolean>);
get required(): boolean | undefined; set required(value: AriaWrite<boolean>);
get selected(): AriaOptionalBoolean | undefined; set selected(value: AriaWrite<AriaOptionalBoolean>);

/** Tokens. */
get autoComplete(): 'inline' | 'list' | 'both' | 'none' | undefined; set autoComplete(value: AriaWrite<'inline' | 'list' | 'both' | 'none'>);
get current(): AriaCurrent | undefined; set current(value: AriaWrite<AriaCurrent>);
get hasPopup(): AriaHasPopup | undefined; set hasPopup(value: AriaWrite<AriaHasPopup>);
get invalid(): AriaInvalid | undefined; set invalid(value: AriaWrite<AriaInvalid>);
get live(): 'assertive' | 'off' | 'polite' | undefined; set live(value: AriaWrite<'assertive' | 'off' | 'polite'>);
get orientation(): 'horizontal' | 'undefined' | 'vertical' | undefined; set orientation(value: AriaWrite<'horizontal' | 'undefined' | 'vertical'>);
get sort(): 'ascending' | 'descending' | 'none' | 'other' | undefined; set sort(value: AriaWrite<'ascending' | 'descending' | 'none' | 'other'>);

/** Integers and numbers. */
get colCount(): number | undefined; set colCount(value: AriaWrite<number>);
get colIndex(): number | undefined; set colIndex(value: AriaWrite<number>);
get colSpan(): number | undefined; set colSpan(value: AriaWrite<number>);
get level(): number | undefined; set level(value: AriaWrite<number>);
get posInSet(): number | undefined; set posInSet(value: AriaWrite<number>);
get rowCount(): number | undefined; set rowCount(value: AriaWrite<number>);
get rowIndex(): number | undefined; set rowIndex(value: AriaWrite<number>);
get rowSpan(): number | undefined; set rowSpan(value: AriaWrite<number>);
get setSize(): number | undefined; set setSize(value: AriaWrite<number>);
get valueMax(): number | undefined; set valueMax(value: AriaWrite<number>);
get valueMin(): number | undefined; set valueMin(value: AriaWrite<number>);
get valueNow(): number | undefined; set valueNow(value: AriaWrite<number>);

/** ID reference lists and token lists. */
get controls(): string[] | undefined; set controls(value: AriaWrite<string[]>);
get describedBy(): string[] | undefined; set describedBy(value: AriaWrite<string[]>);
get flowTo(): string[] | undefined; set flowTo(value: AriaWrite<string[]>);
get labelledBy(): string[] | undefined; set labelledBy(value: AriaWrite<string[]>);
get owns(): string[] | undefined; set owns(value: AriaWrite<string[]>);
get dropEffect(): AriaDropEffect | undefined; set dropEffect(value: AriaWrite<AriaDropEffect>);
get relevant(): AriaRelevant | undefined; set relevant(value: AriaWrite<AriaRelevant>);
}

export interface AttrProxy {
readonly class: ClassProxy & DOMTokenList;
[name: string]: any;
}

/** Typed application-defined `data-*` state. */
export interface DataProxy {
[name: string]: any;
}

/** State bags that resolve each key from the nearest owning element. */
export interface Scope {
/** Typed attributes from the nearest element carrying each attribute. */
readonly attr: AttrProxy;
/** Typed `data-*` values from the nearest element carrying each key. */
readonly data: DataProxy;
/** Typed ARIA values from the nearest element carrying each attribute. */
readonly aria: AriaProxy;
/** Class membership from the nearest element carrying each class. */
readonly class: ClassProxy;
}

export interface Query {
/** Number of matched elements. */
count: number;
/** Returns a plain array of the matched elements. */
arr(): Element[];
/**
* Re-runs the selector grammar with each matched element as the anchor.
* Supports `next`, `previous`, `closest`, `first`, `last`, and `in` scoping.
*/
q(selector: string): Query;
/** Typed attributes on the selected elements themselves. */
readonly attr: AttrProxy;
/** Typed `data-*` values on the selected elements themselves. */
readonly data: DataProxy;
/** Typed `aria-*` values on the selected elements themselves. */
readonly aria: AriaProxy;
/** Class state and `classList` methods on the selected elements themselves. */
readonly class: ClassProxy & DOMTokenList;
/** Typed state on the closest match for each selected element. */
readonly closest: Scope;
/**
* Move a class or attribute from sibling/scoped elements to all matched elements.
* @param scope - CSS selector, DOM node, or `{ from: string }`. Defaults to parent element.
*/
take(name: string, scope?: string | Node | { from: string }): Query;
/**
* Toggle (binary flip) or cycle (with `values`) a class or attribute on all matched elements.
* @param values - Pipe-delimited string (`'grid|list'`) or array to cycle through.
*/
toggle(name: string, ...values: any[]): Query;
/**
* Dispatch a `CustomEvent` from all matched elements.
* @param bubbles - Defaults to `true`.
*/
trigger(type: string, detail?: any, bubbles?: boolean): Query;
/**
* Insert HTML relative to all matched elements.
* - `'before'`/`'after'`: sibling before/after
* - `'start'`/`'end'`: first/last child
*/
insert(pos: 'before' | 'after' | 'start' | 'end', html: string): Query;
/** Iterate over matched elements. */
[Symbol.iterator](): IterableIterator<Element>;
/** DOM property passthrough: reads from first element, writes to all. */
[key: string]: any;
}
}

export interface HtmxLive {
export interface HxLive {
/**
* Returns a `QProxy` over elements matching a selector, element, or collection.
* Returns a query proxy over elements matching a selector, element, or collection.
* Directional keywords (`next`, `previous`, `closest`) only work inside `hx-live`/`hx-on` expressions.
*/
q(selector: string): QProxy;
q(element: Element): QProxy;
q(elements: Iterable<Element>): QProxy;
q(selector: string): HxLive.Query;
q(element: Element): HxLive.Query;
q(elements: Iterable<Element>): HxLive.Query;
/** Aliases `q()`. */
$(selector: string): QProxy;
$(element: Element): QProxy;
$(elements: Iterable<Element>): QProxy;
$(selector: string): HxLive.Query;
$(element: Element): HxLive.Query;
$(elements: Iterable<Element>): HxLive.Query;
/**
* Awaitable debounce: resolves after `ms` ms. Cancels any pending call on the same element.
*/
Expand All @@ -253,18 +347,14 @@ export interface HtmxLive {
/** Move a class or attribute from sibling/scoped elements to the target. */
take(target: string | Element | NodeList, name: string, scope?: string | Node | { from: string }): void;
/** Toggle or cycle a class or attribute on the target. */
toggle(target: string | Element | NodeList, name: string, values?: string | string[]): void;
/** Get an attribute, class, or property from the first matched element. See `QProxy.attr`. */
attr(target: string | Element | NodeList, name: string): any;
/** Set an attribute, class, or property on all matched elements. See `QProxy.attr`. */
attr(target: string | Element | NodeList, name: string, value: any): void;
toggle(target: string | Element | NodeList, name: string, ...values: any[]): void;
/**
* Resolves on the next matching event, timeout, or interval, whichever fires first.
* - `string`: event name on the current element
* - `string`: event name or interval
* - `number`: timeout in ms
* - `EventTarget`: redirects listeners to that target
*/
forEvent(...args: (string | number | EventTarget)[]): Promise<Event | null>;
forEvent(...args: (string | number | EventTarget)[]): Promise<Event | string | number>;
/**
* Resolves on the next animation frame. Useful to force a style recalc between two DOM writes.
*/
Expand Down Expand Up @@ -566,7 +656,7 @@ export interface Htmx {
/** Global htmx configuration */
config: HtmxConfig;
/** hx-live extension API, available when the extension is loaded */
live?: HtmxLive;
live?: HxLive;
/**
* Issues an htmx-style AJAX request programmatically.
* Returns a Promise that resolves after the response has been swapped into the DOM.
Expand Down
4 changes: 3 additions & 1 deletion src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,9 @@ var htmx = (() => {
let args = {}
Object.assign(args, this.__apiMethods(thisArg))
let scope = {};
this.__triggerExtensions(thisArg, "htmx:scope", { scope });
let detail = { scope, code };
this.__triggerExtensions(thisArg, "htmx:scope", detail);
code = detail.code;
Object.assign(args, scope);
Object.assign(args, obj)
let keys = Object.keys(args);
Expand Down
2 changes: 1 addition & 1 deletion test/manual/hx-live/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ <h1>hx-live playground</h1>

<!-- =================================================================== -->
<section>
<h2>1. Simple form: <code>:attr="expr"</code> — boolean attributes</h2>
<h2>1. Binding: <code>:attr="expr"</code> — boolean attributes</h2>
<p>
<label><input type="checkbox" id="lock"> Lock everything below</label>
</p>
Expand Down
Loading
Loading