Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions framework/core/js/src/common/components/SelectDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,29 @@ export default class SelectDropdown<CustomAttrs extends ISelectDropdownAttrs = I
this.attrs.caretIcon ? <Icon name={this.attrs.caretIcon} className="Button-caret" /> : null,
];
}

/**
* Which option is in effect is otherwise conveyed only by a class on the item
* and by the toggle button borrowing that option's label — neither of which a
* screen reader announces while moving through the menu.
*
* `aria-current` rather than `aria-selected`: the latter is only valid on
* roles this menu does not claim, and claiming them would mean implementing
* the whole listbox keyboard contract. Unselected items are left without the
* attribute rather than given `"false"`, since only one item can be current
* and the absence says as much.
*/
getMenu(items: Mithril.Vnode<any, any>[]): Mithril.Vnode<any, any> {
items.forEach((item) => {
// Each item arrives wrapped in the list element `listItems` built around
// it, so the option itself is the wrapper's child.
const option = (Array.isArray(item?.children) ? item.children[0] : item?.children) as Mithril.Children;

if (isActive(option)) {
item.attrs = { ...item.attrs, 'aria-current': 'true' };
}
});

return super.getMenu(items);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,87 @@ describe('SelectDropdown displays as expected', () => {
expect(select).toContainRaw('Option C');
});

/**
* Which option is selected is shown visually by a class on the item, and by
* the toggle button taking that option's label. Neither reaches a screen
* reader, so the menu read as an undifferentiated list of options with no
* indication of which one was in effect (flarum/framework#3362).
*
* `aria-current` is used rather than `aria-selected`, which is only valid on
* roles this menu does not claim (`option`, `tab`, `row`, `gridcell`,
* `treeitem`). Giving it those roles would mean honouring the whole listbox
* keyboard contract; `aria-current` is valid anywhere and says what is meant —
* the current item within a set.
*/
it('marks the selected option for assistive technology', () => {
const buttons = [
m('button', { className: 'button-1', active: false }, 'Option A'),
m('button', { className: 'button-2', active: true }, 'Option B'),
m('button', { className: 'button-3', active: false }, 'Option C'),
];

const select = mq(
m(
SelectDropdown,
{
label: 'Select the option',
defaultLabel: 'Select an option',
},
buttons
)
);

const current = select.rootEl.querySelectorAll('[aria-current="true"]');

expect(current).toHaveLength(1);
expect(current[0].textContent).toContain('Option B');
});

/**
* Mithril omits an attribute set to `false` entirely, so the unselected items
* must not be given `aria-current` at all rather than `aria-current="false"`.
*/
it('does not mark unselected options', () => {
const buttons = [
m('button', { className: 'button-1', active: false }, 'Option A'),
m('button', { className: 'button-2', active: true }, 'Option B'),
];

const select = mq(
m(
SelectDropdown,
{
label: 'Select the option',
defaultLabel: 'Select an option',
},
buttons
)
);

// Every item carrying the attribute at all is the selected one.
expect(select.rootEl.querySelectorAll('[aria-current]')).toHaveLength(1);
});

/**
* A menu with nothing selected must not claim a current item.
*/
it('marks nothing when no option is selected', () => {
const buttons = [m('button', { className: 'button-1' }, 'Option A'), m('button', { className: 'button-2' }, 'Option B')];

const select = mq(
m(
SelectDropdown,
{
label: 'Select the option',
defaultLabel: 'Select an option',
},
buttons
)
);

expect(select.rootEl.querySelectorAll('[aria-current]')).toHaveLength(0);
});

it('uses active button as label', () => {
const buttons = [
m('button', { className: 'button-1', active: false }, 'Option A'),
Expand Down
Loading