diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index fc58c4e9c..e957370c8 100644 --- a/src/ext/hx-live.js +++ b/src/ext/hx-live.js @@ -96,7 +96,7 @@ * attr('.active', cond) // add/remove class * attr('class', 'foo bar') // multi-class string * attr('class', { active: cond }) // multi-class object - * attr('aria-expanded', open) // ARIA: always "true"/"false" + * attr('aria-expanded', open) // ARIA: raw string value * attr('value', 'hello') // sync DOM property + attribute * attr('contenteditable', false) // "false", not removed * attr('data-x', null) // remove attribute @@ -112,7 +112,7 @@ if (!e) return undefined; if (isClass) return e.classList.contains(name.slice(1)); if (isMultiClass) return e.getAttribute('class'); - if (isAria) return e.getAttribute(name) === 'true'; + if (isAria) return e.getAttribute(name); if (BOOLEAN_ATTRS.has(name)) return e.hasAttribute(name); if (isPropAttr) return e[name]; return e.getAttribute(name); @@ -126,13 +126,8 @@ } else if (isMultiClass) { applyMultiClass(e, value); } else if (isAria) { - // Strings and numbers pass through (e.g. aria-current="page", - // aria-pressed="mixed", aria-valuenow="50"). Other values coerce - // to "true"/"false". Never removed. - let attrVal = (typeof value === 'string' || typeof value === 'number') - ? String(value) - : (value ? 'true' : 'false'); - e.setAttribute(name, attrVal); + if (value == null) e.removeAttribute(name); + else e.setAttribute(name, String(value)); } else if (isPropAttr) { if (value === false || value == null) { e[name] = (typeof e[name] === 'boolean') ? false : ''; @@ -192,6 +187,86 @@ return s.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); } + let booleanAria = new Set([ + 'atomic', + 'busy', + 'checked', + 'current', + 'disabled', + 'expanded', + 'grabbed', + 'haspopup', + 'hidden', + 'invalid', + 'modal', + 'multiline', + 'multiselectable', + 'pressed', + 'readonly', + 'required', + 'selected' + ]); + let integerAria = new Set([ + 'colcount', + 'colindex', + 'colspan', + 'level', + 'posinset', + 'rowcount', + 'rowindex', + 'rowspan', + 'setsize' + ]); + let numberAria = new Set([ + 'valuemax', + 'valuemin', + 'valuenow' + ]); + let listAria = new Set([ + 'controls', + 'describedby', + 'dropeffect', + 'flowto', + 'labelledby', + 'owns', + 'relevant' + ]); + + function makeAriaProxy(elt, cascades = true) { + let findOwner = name => cascades + ? elt.closest('[' + name + ']') + : elt.hasAttribute(name) ? elt : null; + return new Proxy({}, { + get: (_, prop) => { + if (typeof prop !== 'string') return undefined; + let key = prop.toLowerCase(); + let name = 'aria-' + key; + let value = findOwner(name)?.getAttribute(name); + if (booleanAria.has(key) && (value === 'true' || value === 'false')) return value === 'true'; + let number = Number(value); + let validNumber = numberAria.has(key) || (integerAria.has(key) && Number.isInteger(number)); + if (validNumber && value?.trim() && Number.isFinite(number)) return number; + if (listAria.has(key) && value != null) return value.trim() ? value.trim().split(/\s+/) : []; + return value; + }, + set: (_, prop, value) => { + if (typeof prop !== 'string') return false; + let key = prop.toLowerCase(); + let name = 'aria-' + key; + let target = findOwner(name) || elt; + if (value == null) target.removeAttribute(name); + else target.setAttribute(name, listAria.has(key) && Array.isArray(value) ? value.join(' ') : String(value)); + return true; + }, + deleteProperty: (_, prop) => { + if (typeof prop !== 'string') return false; + let name = 'aria-' + prop.toLowerCase(); + findOwner(name)?.removeAttribute(name); + return true; + } + }); + } + // `data.foo` reads/writes to closest ancestor with `data-foo`. // `has` trap lets `hx-on:click="with (data) { x++; y-- }"` work: data-* keys // bind to the proxy, all other identifiers fall through to outer scope. @@ -470,6 +545,7 @@ }; if (p === 'data') return elts[0] ? makeDataProxy(elts[0]) : undefined; if (arrayMethods.has(p)) return elts[p].bind(elts); + if (p === 'aria') return elts[0] ? makeAriaProxy(elts[0], false) : undefined; let v = elts[0]?.[p]; if (typeof v === 'function') return (...a) => elts.map(e => e[p](...a))[0]; if (v && typeof v === 'object') return qProxy(elts.map(e => e[p])); @@ -670,7 +746,8 @@ matches: (sel) => elt.matches(sel), style: elt.style, classList: elt.classList, - data: makeDataProxy(elt) + data: makeDataProxy(elt), + aria: makeAriaProxy(elt) }); if (htmx.config.live?.useDollar) detail.scope.$ = detail.scope.q; } diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index 6c00b2062..00a48fb28 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -453,6 +453,7 @@ describe('hx-live extension', function () { it('q returns 0-count proxy when no match', function() { let proxy = htmx.live.q('.does-not-exist-anywhere'); proxy.count.should.equal(0); + assert.isUndefined(proxy.aria); }); it('q(element) wraps a single element', function() { @@ -1057,10 +1058,21 @@ describe('hx-live extension', function () { htmx.live.attr('#b', 'disabled').should.equal(false); }); - it('attr() getter: ARIA returns boolean from "true"/"false"', function() { - playground().innerHTML = '
'; - htmx.live.attr('#a', 'aria-expanded').should.equal(true); - htmx.live.attr('#b', 'aria-expanded').should.equal(false); + it('attr() getter: ARIA returns raw strings or null', function() { + playground().innerHTML = ` +
+ +
+
+
+
+ `; + htmx.live.attr('#a', 'aria-expanded').should.equal('true'); + htmx.live.attr('#b', 'aria-expanded').should.equal('false'); + htmx.live.attr('#c', 'aria-current').should.equal('page'); + htmx.live.attr('#d', 'aria-valuenow').should.equal('50'); + htmx.live.attr('#e', 'aria-controls').should.equal('menu help'); + assert.isNull(htmx.live.attr('#f', 'aria-label')); }); it('attr() getter: .class returns boolean (has class)', function() { @@ -1102,16 +1114,15 @@ describe('hx-live extension', function () { playground().querySelector('#a').hasAttribute('disabled').should.equal(false); }); - it('attr() setter: ARIA writes "true"/"false", never removes', function() { + it('attr() setter: ARIA stringifies values and null removes', function() { playground().innerHTML = '
'; let div = playground().querySelector('#a'); htmx.live.attr('#a', 'aria-expanded', true); div.getAttribute('aria-expanded').should.equal('true'); htmx.live.attr('#a', 'aria-expanded', false); div.getAttribute('aria-expanded').should.equal('false'); - // null/undefined also writes "false". ARIA is never removed. htmx.live.attr('#a', 'aria-expanded', null); - div.getAttribute('aria-expanded').should.equal('false'); + div.hasAttribute('aria-expanded').should.equal(false); }); it('attr() setter: aria-* strings and numbers pass through', function() { @@ -1298,6 +1309,248 @@ describe('hx-live extension', function () { assert.isFunction(htmx.live.attr); }); + // ------------------------------------------------------------------------- + // cascading ARIA proxy + // ------------------------------------------------------------------------- + + it('aria.foo reacts to the closest ARIA state', async function() { + playground().innerHTML = ` +
+
+ +
+
+ `; + htmx.process(playground()); + let button = playground().querySelector('button'); + button.disabled.should.equal(false); + button.click(); + await htmx.timeout(5); + button.disabled.should.equal(true); + playground().querySelector('form').getAttribute('aria-busy').should.equal('true'); + playground().querySelector('section').getAttribute('aria-busy').should.equal('true'); + }); + + it('q().aria uses only its first match', function() { + playground().innerHTML = ` +
+
+
+ `; + let aria = htmx.live.q('#form').aria; + aria.checked.should.equal(false); + assert.isUndefined(aria.busy); + assert.isUndefined(aria.controls); + assert.isTrue(delete aria.label); + + let ownerAria = htmx.live.q('#form').q('closest [aria-busy]').aria; + ownerAria.busy.should.equal(false); + ownerAria.busy = true; + aria.checked = true; + aria.busy = false; + + playground().querySelector('form').getAttribute('aria-checked').should.equal('true'); + playground().querySelector('form').getAttribute('aria-busy').should.equal('false'); + playground().querySelector('section').getAttribute('aria-busy').should.equal('true'); + + aria.checked = null; + delete ownerAria.busy; + playground().querySelector('form').hasAttribute('aria-checked').should.equal(false); + playground().querySelector('section').hasAttribute('aria-busy').should.equal(false); + }); + + it('returns every boolean-like ARIA attribute as a boolean', function() { + playground().innerHTML = '
'; + let values = { + atomic: true, + busy: false, + checked: true, + current: false, + disabled: true, + expanded: false, + grabbed: true, + hasPopup: false, + hidden: true, + invalid: false, + modal: true, + multiline: false, + multiselectable: true, + pressed: false, + readonly: true, + required: false, + selected: true + }; + let state = playground().querySelector('#booleans'); + for (let [name, value] of Object.entries(values)) { + state.setAttribute('aria-' + name.toLowerCase(), String(value)); + } + let aria = htmx.live.q(state).aria; + for (let [name, value] of Object.entries(values)) { + aria[name].should.equal(value); + } + }); + + it('returns every numeric ARIA attribute as a number', function() { + playground().innerHTML = '
'; + let values = { + colCount: 3, + colIndex: 2, + colSpan: 1, + level: 4, + posInSet: 5, + rowCount: 6, + rowIndex: 7, + rowSpan: 2, + setSize: 8, + valueMax: 100, + valueMin: 0, + valueNow: 51.5 + }; + let state = playground().querySelector('#state'); + for (let [name, value] of Object.entries(values)) { + let attributeValue = name === 'valueNow' ? ' 51.5 ' : String(value); + state.setAttribute('aria-' + name.toLowerCase(), attributeValue); + } + let aria = htmx.live.q(state).aria; + for (let [name, value] of Object.entries(values)) { + aria[name].should.equal(value); + } + }); + + it('preserves missing and invalid numeric ARIA values', function() { + playground().innerHTML = ` +
+
+ `; + let aria = htmx.live.q('#invalid-numbers').aria; + aria.colSpan.should.equal('1.5'); + aria.level.should.equal('many'); + aria.valueMax.should.equal(''); + aria.valueMin.should.equal('Infinity'); + aria.valueNow.should.equal('unknown'); + assert.isUndefined(aria.rowCount); + }); + + it('does not coerce string ARIA attributes that look typed', function() { + playground().innerHTML = ` +
+
+ `; + let aria = htmx.live.q('#strings').aria; + aria.description.should.equal('true'); + aria.label.should.equal('false'); + aria.valueText.should.equal('51'); + aria.activeDescendant.should.equal('item'); + aria.details.should.equal('details'); + aria.errorMessage.should.equal('error'); + }); + + it('preserves non-boolean ARIA tokens', function() { + playground().innerHTML = '
'; + let aria = htmx.live.q('#tokens').aria; + aria.checked.should.equal('mixed'); + aria.current.should.equal('page'); + aria.invalid.should.equal('spelling'); + }); + + it('returns ARIA list attributes as arrays and joins array writes', function() { + playground().innerHTML = '
'; + let values = { + controls: ['menu', 'help'], + describedBy: ['hint', 'error'], + dropEffect: ['copy', 'move'], + flowTo: ['next', 'later'], + labelledBy: ['title', 'subtitle'], + owns: ['item-1', 'item-2'], + relevant: ['additions', 'text'] + }; + let state = playground().querySelector('#lists'); + for (let [name, value] of Object.entries(values)) { + state.setAttribute('aria-' + name.toLowerCase(), value.join(' ')); + } + state.setAttribute('aria-controls', ' menu help '); + let aria = htmx.live.q(state).aria; + for (let [name, value] of Object.entries(values)) { + aria[name].should.deep.equal(value); + } + + aria.controls = ['dialog', 'help']; + aria.relevant = []; + aria.owns = 'item-3 item-4'; + state.getAttribute('aria-controls').should.equal('dialog help'); + state.getAttribute('aria-relevant').should.equal(''); + state.getAttribute('aria-owns').should.equal('item-3 item-4'); + aria.relevant.should.deep.equal([]); + aria.owns.should.deep.equal(['item-3', 'item-4']); + }); + + it('writes missing ARIA attributes on this and deletes the closest match', function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let button = playground().querySelector('button'); + button.click(); + playground().querySelector('div').getAttribute('aria-valuenow').should.equal('51'); + playground().querySelector('div').hasAttribute('aria-current').should.equal(false); + button.getAttribute('aria-label').should.equal('Save'); + }); + + it('q(this).aria only accesses the current element', function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let button = playground().querySelector('button'); + button.click(); + window.__localState.should.deep.equal([undefined, true]); + window.__localAfter.should.equal(false); + button.getAttribute('aria-busy').should.equal('false'); + playground().querySelector('section').getAttribute('aria-busy').should.equal('true'); + delete window.__localState; + delete window.__localAfter; + }); + + it('q(this).aria preserves application element properties after await', async function() { + playground().innerHTML = ` +
+ +
+ `; + htmx.process(playground()); + let button = playground().querySelector('button'); + let applicationState = { owner: 'app' }; + button.aria = applicationState; + button.click(); + await htmx.timeout(10); + window.__sameThis.should.equal(true); + window.__closestId.should.equal('owner'); + button.aria.should.equal(applicationState); + button.getAttribute('aria-busy').should.equal('true'); + delete window.__sameThis; + delete window.__closestId; + }); + // ------------------------------------------------------------------------- // cascading data proxy // ------------------------------------------------------------------------- @@ -1704,7 +1957,7 @@ describe('hx-live extension', function () { btn.hasAttribute('disabled').should.equal(true); }); - it(':aria-expanded writes "true"/"false", never removes', async function() { + it(':aria-expanded writes boolean strings', async function() { playground().innerHTML = ` diff --git a/www/src/content/extensions/06-hx-live.md b/www/src/content/extensions/06-hx-live.md index 1680fedde..7fa6a7400 100644 --- a/www/src/content/extensions/06-hx-live.md +++ b/www/src/content/extensions/06-hx-live.md @@ -242,11 +242,15 @@ attr('.active') // has class .active? attr('.active', q('#src').checked) // add/remove class attr('class', 'foo bar') // multi-class string attr('class', { active: matches('.tab') }) // multi-class object -attr('aria-expanded', matches('.open')) // any aria-*: writes "true"/"false" +attr('aria-expanded') // raw string or null +attr('aria-expanded', false) // write "false" +attr('aria-expanded', null) // remove attr('value', 'hello') // value/checked/selected: syncs property + attribute attr('data-x', null) // remove ``` +Use [`aria.*`](#aria) to read booleans, numbers, and lists. + ### `toggle(name, values?)` Toggle (no `values`) or cycle (with `values`) a class or attribute on this element. @@ -272,6 +276,126 @@ take('aria-current', 'nav a') // become the current nav item take('.active') // implicit scope: parent element's subtree ``` +### `aria` + +Read and write ARIA attributes on this element or an ancestor: + +```html +
+ + Busy +
+``` + +Both `aria.busy` expressions use `aria-busy` on the div. If no element has the attribute, a write adds it to the current element: + +```html + + + + + +``` + +Use bare `aria` for shared state. Use `q()` for one element: + +```js +aria.busy // closest aria-busy, starting at this +q(this).aria.busy // aria-busy on this +q('#form').aria.busy // aria-busy on the selected form +``` + +Each form uses the same value rules. You can use these values as booleans, numbers, and arrays: + +```html + + +
...
+

...

+ +
+``` + +After one click: + +```html + +
+``` + +Use either form to remove an attribute: + +```js +aria.current = null +delete aria.current +``` + +#### Value types + +hx-live uses the value types from [WAI-ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/). + +**Boolean** + +- `aria-atomic` +- `aria-busy` +- `aria-checked` +- `aria-current` +- `aria-disabled` +- `aria-expanded` +- `aria-grabbed` +- `aria-haspopup` +- `aria-hidden` +- `aria-invalid` +- `aria-modal` +- `aria-multiline` +- `aria-multiselectable` +- `aria-pressed` +- `aria-readonly` +- `aria-required` +- `aria-selected` + +**Number** + +- `aria-colcount` +- `aria-colindex` +- `aria-colspan` +- `aria-level` +- `aria-posinset` +- `aria-rowcount` +- `aria-rowindex` +- `aria-rowspan` +- `aria-setsize` +- `aria-valuemax` +- `aria-valuemin` +- `aria-valuenow` + +**Token list (`string[]`)** + +- `aria-dropeffect` +- `aria-relevant` + +**ID reference list (`string[]`)** + +- `aria-controls` +- `aria-describedby` +- `aria-flowto` +- `aria-labelledby` +- `aria-owns` + +All other `aria-*` attributes remain strings. + +You can use `aria.*` in `hx-live`, bindings, `hx-on`, `js:` attribute values, and `hx-trigger` filters. + ### `data` Read or write `data-*` attributes on the closest ancestor that has them. Lets components share state up the tree. @@ -455,7 +579,7 @@ For a single inline section, native [`
`](https://developer.mozilla.org/
- + ``` **Toggle button.**