Skip to content

Add typed aria.* access to hx-live - #3928

Open
scriptogre wants to merge 2 commits into
bigskysoftware:four-devfrom
scriptogre:4.0/hx-live-aria-proxy
Open

Add typed aria.* access to hx-live#3928
scriptogre wants to merge 2 commits into
bigskysoftware:four-devfrom
scriptogre:4.0/hx-live-aria-proxy

Conversation

@scriptogre

@scriptogre scriptogre commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Why

Before:

<div aria-busy="false">
    <button hx-on:click="
        let state = q('closest [aria-busy]')
        state.attr('aria-busy', !state.attr('aria-busy'))
    ">Toggle</button>
</div>

After:

<div aria-busy="false">
    <button hx-on:click="aria.busy = !aria.busy">Toggle</button>
</div>

Changes

  1. Read and write ARIA attributes in three ways:

    aria.busy            // this element, then its ancestors
    q(this).aria.busy       // this element only
    q('#form').aria.busy // #form only
  2. Read each value as its ARIA type:

    aria.busy     // false
    aria.valueNow // 50
    aria.controls // ['menu', 'help']
  3. Use attr() to read or write the exact DOM text:

    attr('aria-busy')        // 'false'
    attr('aria-valuenow')    // '50'
    attr('aria-controls')    // 'menu help'
    attr('aria-busy', false) // write 'false'
    attr('aria-busy', null)  // remove aria-busy
  4. Use null or delete to remove an ARIA attribute:

    aria.busy = null
    delete aria.busy

Related to #3931. Either PR can merge first.

@scriptogre
scriptogre force-pushed the 4.0/hx-live-aria-proxy branch 21 times, most recently from b2e741f to 33b015d Compare July 30, 2026 18:28
@scriptogre scriptogre changed the title Add cascading ARIA state to hx-live Add cascading aria.* to hx-live Jul 30, 2026
@scriptogre
scriptogre force-pushed the 4.0/hx-live-aria-proxy branch 2 times, most recently from 010b990 to c6ef0a1 Compare July 30, 2026 20:10
@scriptogre scriptogre changed the title Add cascading aria.* to hx-live Add cascading aria.* to hx-live Jul 30, 2026
@scriptogre scriptogre added htmx 4 Issues specific to htmx version 4 hx-live labels Jul 30, 2026
@scriptogre
scriptogre force-pushed the 4.0/hx-live-aria-proxy branch 2 times, most recently from 6fb0bae to 68ce0ac Compare July 30, 2026 20:37
@scriptogre
scriptogre force-pushed the 4.0/hx-live-aria-proxy branch from 68ce0ac to e0a6eb9 Compare July 30, 2026 20:48
@scriptogre scriptogre changed the title Add cascading aria.* to hx-live Add aria.* in hx-live Jul 30, 2026
@scriptogre
scriptogre force-pushed the 4.0/hx-live-aria-proxy branch from e0a6eb9 to 1909a74 Compare July 30, 2026 22:45
@scriptogre scriptogre changed the title Add aria.* in hx-live Add typed aria.* access to hx-live Jul 30, 2026
@MichaelWest22

Copy link
Copy Markdown
Collaborator

dedicating 10% more code length for additional Aria support may not be worth the investment maybe. could maybe simplify the aria data access only like: four-dev...MichaelWest22:aria-hx-live . setting things like aria busy in the example is something that is ideally done via htmx events during active requests and I don't think the parent proxy access is worth the extra complexity to add. For data parent access proxy is really useful but I don't think this will see as much practical use. With htmx we should be encouraging people to use native semantic element tags which by design do not need almost any custom aria handling which is why I don't think there is a lot of demand for it. Each proxy object we add to the dynamic expressions also has a performance cost and slow down LLM's writing the code.

@scriptogre

scriptogre commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

@MichaelWest22 I agree that native HTML should come first. The useful cases for aria.* are widgets where HTML has no native state model.

For example, tabs:

<div role="tablist">
    <button
        role="tab"
        aria-selected="true"
        hx-on:click="
            q('[role=tab]').aria.selected = false
            this.aria.selected = true
        "
    >
        Profile
    </button>

    <button
        role="tab"
        aria-selected="false"
        hx-on:click="
            q('[role=tab]').aria.selected = false
            this.aria.selected = true
        "
    >
        Security
    </button>
</div>

There is no native HTML attribute for “this tab is selected”. Here, aria-selected becomes the single source of truth for accessibility, CSS, hx-live, and tests.

A sortable table is another strong case:

<th aria-sort="ascending">
    <button hx-on:click="
        aria.sort =
            aria.sort === 'ascending'
                ? 'descending'
                : 'ascending'
    ">
        Name
    </button>
</th>

HTML has table semantics, but no native sort-direction state. aria-sort expresses exactly that state.

A combobox also needs state that native <input> cannot hold:

<div
    role="combobox"
    aria-expanded="false"
    aria-activedescendant=""
>
    <input
        hx-on:focus="aria.expanded = true"
        hx-on:keydown.escape="
            aria.expanded = false
            aria.activeDescendant = null
        "
    >

    <ul role="listbox" :hidden="!aria.expanded">
        <li
            id="open-file"
            role="option"
            :aria-selected="aria.activeDescendant === this.id"
            hx-on:mousemove="aria.activeDescendant = this.id"
        >
            Open file
        </li>
    </ul>
</div>

The parent lookup matters here because the option updates state owned by the combobox root:

aria.activeDescendant = this.id

instead of:

q('closest [role=combobox]')
    .attr('aria-activedescendant', this.id)

That is the main value for me: ARIA becomes the actual DOM state, not a second copy next to .active or data-*.

One concern: I do not think generic JSON.parse() is safe for typed ARIA values. ARIA is not JSON, and the type depends on the attribute:

<div aria-valuenow="50"></div> <!-- number -->
<div aria-label="50"></div>    <!-- string -->
<div aria-controls="50"></div> <!-- ID list -->

Generic parsing returns the number 50 for all three. I would keep strings, or parse by ARIA attribute type.

@scriptogre

scriptogre commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Truth is that for the examples above, you could also use take() or toggle().

The main idea with aria.* is simpler: ARIA values just behave like normal JS values.

aria.busy            // boolean
aria.valueNow        // number
aria.controls        // array
aria.current         // string/token

So you can write:

aria.valueNow += 10
aria.controls.push('help')
aria.expanded = !aria.expanded

without doing Number(), split(), join(), or comparing "true" strings.

aria.* just gives ARIA the same nice typed-state feel as data.*.

I think in many cases you'd better reach for e.g. aria-expanded than a custom data-open, and that's a mental model I would like to encourage with hx-live, as you'd use some more generic slots for these UI states in a way that also doubles as better accesibility. If that makes sense.

And this change makes it possible to do that with an easy API where the DOM just assembles itself as it should, while you do don't have to work with strings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

htmx 4 Issues specific to htmx version 4 hx-live

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants