mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 11:40:02 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user