diff --git a/src/ext/hx-live.js b/src/ext/hx-live.js index fc58c4e9c..7bd007d89 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,81 @@ 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) { + return new Proxy({}, { + get: (_, prop) => { + if (typeof prop !== 'string') return undefined; + let key = prop.toLowerCase(); + let name = 'aria-' + key; + let value = elt.hasAttribute(name) ? elt.getAttribute(name) : undefined; + 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; + if (value == null) elt.removeAttribute(name); + else elt.setAttribute(name, listAria.has(key) && Array.isArray(value) ? value.join(' ') : String(value)); + return true; + }, + deleteProperty: (_, prop) => { + if (typeof prop !== 'string') return false; + elt.removeAttribute('aria-' + prop.toLowerCase()); + 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 +540,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]) : 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 +741,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..084071360 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,247 @@ describe('hx-live extension', function () { assert.isFunction(htmx.live.attr); }); + // ------------------------------------------------------------------------- + // cascading ARIA proxy + // ------------------------------------------------------------------------- + + it('aria.foo drives a binding on the same element', 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); + button.getAttribute('aria-busy').should.equal('true'); + }); + + it('q("closest [aria-*]") shares ARIA state with an ancestor', function() { + playground().innerHTML = ` +...
+ + +``` + +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 +581,7 @@ For a single inline section, native [`