Apply aria-controls on non-descendant buttons

Set the `aria-control` attribute server-side in `CheckAllComponent` and
client-side in `CheckAllController` (belt and braces approach).

Also introduces `attributeTokenList` helper function to ease working
with HTML attributes that accept a space-delimited list of tokens.
This commit is contained in:
Alexander Brandon Coles
2025-11-26 14:15:33 -03:00
parent 9e353482b2
commit d6a7ef23d9
6 changed files with 223 additions and 3 deletions
@@ -33,25 +33,38 @@ module OpenProject
class CheckAllComponent < ApplicationComponent
include Primer::AttributesHelper
attr_reader :checkable_id
CHECKABLE_CONTROLLER_SELECTOR = "[data-controller~='checkable']"
renders_one :check_all, ->(text: I18n.t(:button_check_all), **system_arguments) {
action = use_outlet? ? "check-all#checkAll:stop" : "checkable#checkAll:stop"
controls = checkable_id if use_outlet?
system_arguments[:data] = merge_data(
system_arguments, {
data: { action: }
}
)
system_arguments[:aria] = merge_aria(
system_arguments, { aria: { controls: } }
)
Primer::Beta::Button.new(scheme: :link, **system_arguments).with_content(text)
}
renders_one :uncheck_all, ->(text: I18n.t(:button_uncheck_all), **system_arguments) {
action = use_outlet? ? "check-all#uncheckAll:stop" : "checkable#uncheckAll:stop"
controls = checkable_id if use_outlet?
system_arguments[:data] = merge_data(
system_arguments, {
data: { action: }
}
)
system_arguments[:aria] = merge_aria(
system_arguments, { aria: { controls: } }
)
Primer::Beta::Button.new(scheme: :link, **system_arguments).with_content(text)
}
@@ -30,6 +30,7 @@ import {
toggleElement,
toggleElementByClass,
toggleElementByVisibility,
attributeTokenList,
} from './dom-helpers';
describe('dom-helpers', () => {
@@ -184,4 +185,86 @@ describe('dom-helpers', () => {
expect(element.style.getPropertyValue('visibility')).toBe('hidden');
});
});
describe('attributeTokenList', () => {
let el:HTMLElement;
const attr = 'aria-describedby';
beforeEach(() => {
el = document.createElement('div');
});
it('mimics DOMTokenList over an attribute', () => {
const list = attributeTokenList(el, attr);
expect(list.contains('a')).toBeFalse();
expect(el.getAttribute(attr)).toBeNull();
list.add('a', 'b');
expect(list.contains('a')).toBeTrue();
expect(list.contains('b')).toBeTrue();
expect(el.getAttribute(attr)).toBe('a b');
// adding duplicates is idempotent
list.add('a');
expect(el.getAttribute(attr)).toBe('a b');
// remove works
list.remove('a');
expect(list.contains('a')).toBeFalse();
expect(el.getAttribute(attr)).toBe('b');
// toggle without force flips presence and returns the new state
expect(list.toggle('b')).toBeFalse(); // removed
expect(el.getAttribute(attr)).toBe('');
expect(list.toggle('c')).toBeTrue(); // added
expect(el.getAttribute(attr)).toBe('c');
// forced toggle honors force
expect(list.toggle('x', true)).toBeTrue();
expect(list.contains('x')).toBeTrue();
expect(list.toggle('x', false)).toBeFalse();
expect(list.contains('x')).toBeFalse();
// replace swaps tokens and returns true when old exists
expect(list.replace('c', 'd')).toBeTrue();
expect(list.contains('c')).toBeFalse();
expect(list.contains('d')).toBeTrue();
// iterator yields tokens
expect([...list]).toEqual(['d']);
// value accessor updates attribute
list.value = 'e f';
expect(el.getAttribute(attr)).toBe('e f');
expect(list.contains('e')).toBeTrue();
expect(list.contains('f')).toBeTrue();
});
it('replace on non-existent token returns false and does not change tokens', () => {
const list = attributeTokenList(el, attr);
list.add('a', 'b');
expect(list.replace('x', 'y')).toBeFalse();
expect([...list]).toEqual(['a', 'b']);
expect(el.getAttribute(attr)).toBe('a b');
});
it('iterates empty list and value setter overwrites tokens', () => {
const list = attributeTokenList(el, attr);
// Initially empty
expect([...list]).toEqual([]);
// Setting value directly replaces tokens
list.value = 'm n ';
expect([...list]).toEqual(['m', 'n']);
expect(el.getAttribute(attr)).toBe('m n');
});
});
});
@@ -143,3 +143,77 @@ export function ensureId(el:HTMLElement, prefix = 'el'):string {
}
return el.id;
}
/**
* Returns a `DOMTokenList`-like facade for an arbitrary attribute.
*
* Mimics `element.classList` for space-separated attributes (e.g.
* `aria-describedby`). It reads and writes the underlying attribute and
* supports `contains`, `add`, `remove`, `toggle`, `replace`, iteration, and a
* `.value` accessor.
*
* @example
* ```ts
* const tokens = attributeTokenList(el, 'aria-describedby');
* tokens.add('hint-1', 'hint-2'); // sets attribute to "hint-1 hint-2"
* ```
*
* @param element Target element whose attribute holds space-separated tokens.
* @param attribute Attribute name to manage (e.g. "aria-describedby").
* @returns A `DOMTokenList`-like object bound to the given attribute.
*/
export function attributeTokenList(element:HTMLElement, attribute:string):DOMTokenList {
const getTokens = () =>
(element.getAttribute(attribute) ?? '')
.trim()
.split(/\s+/)
.filter(Boolean);
const setTokens = (tokens:string[]) =>
element.setAttribute(attribute, tokens.join(' '));
const list = Object.create(DOMTokenList.prototype) as DOMTokenList;
list.contains = (token:string):boolean => getTokens().includes(token);
list.add = (...tokens:string[]):void => {
const set = new Set(getTokens());
tokens.forEach(t => set.add(t));
setTokens([...set]);
};
list.remove = (...tokens:string[]):void => {
setTokens(getTokens().filter(t => !tokens.includes(t)));
};
list.toggle = (token:string, force?:boolean):boolean => {
const exists = list.contains(token);
const shouldAdd = force ?? !exists;
if (shouldAdd) {
list.add(token);
} else {
list.remove(token);
}
return shouldAdd;
};
list.replace = (oldToken:string, newToken:string):boolean => {
if (!list.contains(oldToken)) return false;
list.remove(oldToken);
list.add(newToken);
return true;
};
// Keep value updated
Object.defineProperty(list, 'value', {
get: () => element.getAttribute(attribute) ?? '',
set: (v:string) => setTokens(v.trim().split(/\s+/).filter(Boolean))
});
// Iterable support
list[Symbol.iterator] = function* ():IterableIterator<string> {
yield* getTokens();
} as () => IterableIterator<string>;
return list;
}
@@ -82,13 +82,14 @@ describe('CheckAllController', () => {
});
describe('with checkable controller', () => {
it('toggles checkboxes', async () => {
beforeEach(async () => {
appendTemplate(checkableTemplate);
appendTemplate(checkAllTemplate);
// Allow Stimulus to connect controllers and resolve outlets
await new Promise((resolve) => setTimeout(resolve, 0));
});
it('toggles checkboxes', () => {
const inputs = Array.from(document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'));
expect(inputs).toHaveSize(3);
@@ -102,6 +103,33 @@ describe('CheckAllController', () => {
expect(inputs.every((i) => !i.checked)).toBeTrue();
});
it('applies aria-controls for connected outlet', () => {
const checkAllEl = document.querySelector('[data-controller="check-all"]')!;
expect(checkAllEl).toBeDefined();
const ariaControls = checkAllEl.getAttribute('aria-controls');
expect(ariaControls).toBeTruthy();
expect(ariaControls!.split(/\s+/)).toContain('checkables');
});
it('removes aria-controls entry when outlet disconnects', async () => {
const checkAllEl = document.querySelector('[data-controller="check-all"]')!;
const ariaBefore = checkAllEl.getAttribute('aria-controls') ?? '';
// Scenarios with connected checkable outlets
expect(ariaBefore.split(/\s+/)).toContain('checkables');
// Remove the outlet element to trigger outlet disconnect
document.getElementById('checkables')!.remove();
await new Promise((resolve) => setTimeout(resolve, 0));
const ariaAfter = checkAllEl.getAttribute('aria-controls') ?? '';
expect(ariaAfter.split(/\s+/)).not.toContain('checkables');
});
});
afterEach(() => {
@@ -30,12 +30,16 @@
import { Controller } from '@hotwired/stimulus';
import CheckableController from './checkable.controller';
import { attributeTokenList, ensureId } from 'core-app/shared/helpers/dom-helpers';
type ExtractElement<T> = T extends Controller<infer U> ? U : never;
type CheckableElement = ExtractElement<CheckableController>;
/**
* A Stimulus Controller providing behavior for "Check all" / "Uncheck all"
* links and buttons.
*
* This Controller does not provide functionality to toggle checkboxes itself,
* This controller does not provide functionality to toggle checkboxes itself,
* but rather uses outlets to communicate (and delegate to) instances of
* {@link CheckableController}. This is designed for scenarios where the "Check
* all" links and buttons are outside scope of a `CheckableController`, i.e. in
@@ -43,6 +47,8 @@ import CheckableController from './checkable.controller';
*
* @see https://stimulus.hotwired.dev/reference/outlets
*
* This controller also handles setting `aria-controls` on its HTML element.
*
* Rather than using targets, it is up to the implementer to "wire up" events
* using descriptors. This is designed for maximum flexibility.
*
@@ -59,6 +65,14 @@ export default class CheckAllController extends Controller<HTMLElement> {
declare readonly checkableOutlets:CheckableController[];
checkableOutletConnected(_outlet:CheckableController, element:CheckableElement) {
attributeTokenList(this.element, 'aria-controls').add(ensureId(element));
}
checkableOutletDisconnected(_outlet:CheckableController, element:CheckableElement) {
attributeTokenList(this.element, 'aria-controls').remove(element.id);
}
/**
*
* @param event
@@ -70,6 +70,14 @@ RSpec.describe OpenProject::Common::CheckAllComponent, type: :component do
expect(button["data-action"]).to include "check-all#uncheckAll:stop"
end
end
it "applies aria-controls attribute to 'Check all'" do
expect(rendered_component).to have_button "Check all", aria: { controls: "foo" }
end
it "applies aria-controls attribute to 'Uncheck all'" do
expect(rendered_component).to have_button "Uncheck all", aria: { controls: "foo" }
end
end
context "when :checkable_id is nil" do