Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(action menu): keyboard accessibility omnibus #5031

Open
wants to merge 59 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
91b72f7
fix(menu): added rovingTabindexController and removed aria-activedesc…
nikkimk Jan 16, 2025
adc0d26
fix(menu): fixes firefox keyboard navigation and call stack issues
nikkimk Jan 16, 2025
97d88c3
test(menu): updates menu test based on changes
nikkimk Jan 16, 2025
d10ed4c
test(menu): adds click test for accessibility
nikkimk Jan 16, 2025
cb74cba
test(menu): menu itself should delegate focus to active item
nikkimk Jan 17, 2025
3876c36
fix(menu): fixed menu group navigation according WAI ARIA APG
nikkimk Jan 23, 2025
350e9ba
fix(reactive-controllers): complex element delegates focus to active …
nikkimk Jan 23, 2025
b7c750f
fix(menu): components should delegate focus
nikkimk Jan 27, 2025
68a298d
fix(menu): update keyboard nav to match WAI ARIA APG
nikkimk Jan 27, 2025
63e1dab
fix(menu): updated docs to reflect preferred accessible method
nikkimk Jan 28, 2025
b6ee246
fix(menu): selection and keyboard navigation according to APG
nikkimk Jan 29, 2025
ef99ebb
test(menu): updated to recommended a11y behavior from APG
nikkimk Jan 29, 2025
eb7e539
docs(menu): reverted docs but fixed typos
nikkimk Jan 29, 2025
36cc4a2
fix(menu): removed unused map
nikkimk Jan 29, 2025
9f5f16c
test(menu): updated test to match changed group id
nikkimk Jan 29, 2025
01be995
test(menu): keyboard selection is fixed
nikkimk Jan 29, 2025
c5a9514
test(menu): updated to reflect WAI ARIA APG
nikkimk Jan 29, 2025
a949c61
fix(picker): picker should delegate focus
nikkimk Jan 29, 2025
62147ea
fix(action-menu): fixed a11y error message
nikkimk Jan 29, 2025
8211077
fix(picker): according to APG ArrowDown should focus on first item
nikkimk Jan 30, 2025
66eb2d8
test(action-menu): updated tests for focus delegation and APG keyboar…
nikkimk Jan 30, 2025
7f6b363
fix(picker): ensure overlay is open before setting focus
nikkimk Jan 30, 2025
d4f7f93
fix(menu): removed extra focus
nikkimk Jan 30, 2025
291231f
Merge branch 'main' of github.com:adobe/spectrum-web-components into …
nikkimk Jan 30, 2025
3411413
chore: added changeset
nikkimk Jan 30, 2025
645342d
fix(picker): prevents the picker button from getting focus when menu …
nikkimk Jan 30, 2025
ed63038
fix(menu): fixes preventDefault behavior when Space is pressed
nikkimk Jan 31, 2025
a6032bf
fix(menu): accounted for action menus with no selection
nikkimk Jan 31, 2025
52b5340
fix(picker):sets focus on first selected item
nikkimk Jan 31, 2025
9b41026
fix(picker): focuses on 1st selected item when opened via kbd
nikkimk Jan 31, 2025
5d9391f
test(picker): updated tests based on APG kbd nav
nikkimk Jan 31, 2025
38d2600
fix(menu): when I menuitem with a submenu closes
nikkimk Feb 4, 2025
78c4bb3
fix(picker): leverages RTI for ArrowLeft/ArrowRight
nikkimk Feb 4, 2025
05a8bda
fix(picker): leverages RTI for ArrowLeft/ArrowRight
nikkimk Feb 4, 2025
74860db
test(picker): adjusted tests based on a11y requirements
nikkimk Feb 4, 2025
bc2fbf3
fix(picker): fixed accessibility warning
nikkimk Feb 4, 2025
5d56b17
fix(picker): resolves opening and closing tests
nikkimk Feb 5, 2025
ae33752
fix(menu): resolves test by setting focus after scrolling
nikkimk Feb 5, 2025
b5ec45e
fix(picker): resolves mobile test
nikkimk Feb 5, 2025
524f2b1
test(menu): updated tests for focus delegation
nikkimk Feb 5, 2025
947104b
fix(picker): ensures that slotted labels don't get a11y warning
nikkimk Feb 5, 2025
a56d711
fix(picker): resolves menu close issue
nikkimk Feb 5, 2025
46f5fb7
fix(menu): fixed prev and next item
nikkimk Feb 6, 2025
4aa538a
fix(picker): fixed left-right arrow keys
nikkimk Feb 6, 2025
d4ab737
Merge branch 'main' of github.com:adobe/spectrum-web-components into …
nikkimk Feb 6, 2025
b764a9e
test(tray): debugging VO/iOS tray
nikkimk Feb 7, 2025
004a093
Merge branch 'main' of github.com:adobe/spectrum-web-components into …
nikkimk Feb 7, 2025
82406d5
fix(menu): resolves submenu tests
nikkimk Feb 7, 2025
8d56d22
fix(menu): added some temporary debugging logic and comments
nikkimk Feb 11, 2025
ff9f716
test(action-menu): removed test that no longer applies to a11y
nikkimk Feb 11, 2025
5155cca
Merge branch 'main' of github.com:adobe/spectrum-web-components into …
nikkimk Feb 12, 2025
5d36670
fix(action-menu): fixed menu closing issue, added docs, and removed t…
nikkimk Feb 12, 2025
b367419
Merge branch 'nikkimk/fix-menu-a11y' of github.com:adobe/spectrum-web…
nikkimk Feb 12, 2025
5d81d96
test(action-menu): fixed test based on focus behavior when an item is…
nikkimk Feb 12, 2025
c3582d5
Merge branch 'main' of github.com:adobe/spectrum-web-components into …
nikkimk Feb 12, 2025
dfc14ec
merge: merge from main
nikkimk Feb 12, 2025
43a97b8
merge: merge from main
nikkimk Feb 12, 2025
10428d1
test(combobox): removing aria-activedescendant references until a com…
nikkimk Feb 12, 2025
6c7d3df
test(combobox): removing aria-activedescendant references until a com…
nikkimk Feb 12, 2025
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
16 changes: 16 additions & 0 deletions .changeset/lemon-points-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@spectrum-web-components/reactive-controllers': minor
'@spectrum-web-components/action-menu': minor
'@spectrum-web-components/picker': minor
'@spectrum-web-components/menu': minor
---

Used WAI ARIA Authoring Practices Guide (APG) to make accessibility improvements for `<sp-action-menu>`, `<sp-menu>`, and `<sp-picker>`, including:
- Numpad keys now work with `<sp-picker>` and `<sp-action-menu>`
-`<sp-action-menu>`'s `<sp-menu-item>` elements can now be read by a screen reader ([#4556](https://github.com/adobe/spectrum-web-components/issues/4556))
- `<sp-menu-item>` href can now be clicked by a screen reader ([#4997](https://github.com/adobe/spectrum-web-components/issues/4997))
- Opening a `<sp-action-menu>`, `<sp-menu>`, and `<sp-picker>` with a keyboard now sets focus on an item within the menu. ([#4557](https://github.com/adobe/spectrum-web-components/issues/4557))

See the following APG examples for more information:
- [Navigation Menu Example](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-navigation/)
- [Editor Menubar Example](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-editor/)
28 changes: 28 additions & 0 deletions packages/action-menu/src/ActionMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,32 @@ export class ActionMenu extends ObserveSlotPresence(
}
super.update(changedProperties);
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action Menu was throwing the no label warning for a test that used the label-only slot, so I decided to override Picker's accessible label check and warning with a warning specific to action menu.

protected override hasAccessibleLabel(): boolean {
return (
!!this.label ||
!!this.getAttribute('aria-label') ||
!!this.getAttribute('aria-labelledby') ||
!!this.appliedLabel ||
this.hasLabel ||
this.labelOnly
);
}

protected override warnNoLabel(): void {
window.__swc.warn(
this,
`<${this.localName}> needs one of the following to be accessible:`,
'https://opensource.adobe.com/spectrum-web-components/components/action-menu/#accessibility',
{
type: 'accessibility',
issues: [
`an <sp-field-label> element with a \`for\` attribute referencing the \`id\` of the \`<${this.localName}>\`, or`,
'value supplied to the "label" attribute, which will be displayed visually as placeholder text',
'text content supplied in a <span> with slot="label", or, text content supplied in a <span> with slot="label-only"',
'which will also be displayed visually as placeholder text.',
],
}
);
}
}
24 changes: 9 additions & 15 deletions packages/action-menu/test/action-menu-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,10 @@ describe('Action Menu - Groups', () => {
groupsWithSelects({ onChange: () => {} })
);

const firstGroup = el.querySelector('sp-menu-group') as HTMLElement;
const firstItem = el.querySelector('sp-menu-item') as MenuItem;

expect(firstItem.focused).to.be.false;
expect(document.activeElement === firstGroup).to.be.false;
expect(document.activeElement === firstItem).to.be.false;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Menus and Groups should delegate focus to their active item, so the active element should be the item, not the group.


const opened = oneEvent(el, 'sp-opened');
el.focus();
Expand All @@ -39,7 +38,7 @@ describe('Action Menu - Groups', () => {

expect(firstItem.focused).to.be.true;
expect(
document.activeElement === firstGroup,
document.activeElement === firstItem,
document.activeElement?.localName
).to.be.true;
});
Expand All @@ -57,15 +56,15 @@ describe('Action Menu - Groups', () => {
'sp-menu-item'
) as MenuItem;

expect(firstItem.selected).to.be.false;
expect(firstItem.selected, 'before opening: first item selected?').to.be.false;

let opened = oneEvent(el, 'sp-opened');
el.focus();
await sendKeys({
press: 'ArrowDown',
});
await opened;
expect(el.open).to.be.true;
expect(el.open, 'first opened: open?').to.be.true;

await sendKeys({
press: 'ArrowUp',
Expand All @@ -81,8 +80,8 @@ describe('Action Menu - Groups', () => {
await elementUpdated(el);
await elementUpdated(firstItem);

expect(el.open).to.be.false;
expect(firstItem.selected).to.be.true;
expect(el.open, 'first closed: open?').to.be.false;
expect(firstItem.selected, 'after select: first item selected?').to.be.true;
expect(document.activeElement === el, document.activeElement?.localName)
.to.be.true;

Expand All @@ -91,12 +90,7 @@ describe('Action Menu - Groups', () => {
press: 'ArrowDown',
});
await opened;
expect(el.open).to.be.true;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When opened, menu should focus on the first selected item, so we don't need to ArrowUp to focus on it


await sendKeys({
press: 'ArrowUp',
});
await elementUpdated(el);
expect(el.open, 'reopened: open?').to.be.true;

closed = oneEvent(el, 'sp-closed');
await sendKeys({
Expand All @@ -107,7 +101,7 @@ describe('Action Menu - Groups', () => {
await elementUpdated(el);
await elementUpdated(firstItem);

expect(el.open).to.be.false;
expect(firstItem.selected).to.be.false;
expect(el.open, 'reclosed: open?').to.be.false;
expect(firstItem.selected, 'after deselect: first item selected?').to.be.false;
});
});
152 changes: 46 additions & 106 deletions packages/action-menu/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,56 +436,6 @@ export const testActionMenu = (mode: 'sync' | 'async'): void => {

expect(firstRect).to.deep.equal(secondRect);
});
it('opens and selects in a single pointer button interaction', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Menu hover was removed for better a11y experience, so this test is no longer needed

const el = await actionMenuFixture();
const thirdItem = el.querySelector(
'sp-menu-item:nth-of-type(3)'
) as MenuItem;
const boundingRect = el.button.getBoundingClientRect();

expect(el.value).to.not.equal(thirdItem.value);
const opened = oneEvent(el, 'sp-opened');
await sendMouse({
steps: [
{
type: 'move',
position: [
boundingRect.x + boundingRect.width / 2,
boundingRect.y + boundingRect.height / 2,
],
},
{
type: 'down',
},
],
});
await opened;

const thirdItemRect = thirdItem.getBoundingClientRect();
const closed = oneEvent(el, 'sp-closed');
let selected = '';
el.addEventListener('change', (event: Event) => {
selected = (event.target as ActionMenu).value;
});
await sendMouse({
steps: [
{
type: 'move',
position: [
thirdItemRect.x + thirdItemRect.width / 2,
thirdItemRect.y + thirdItemRect.height / 2,
],
},
{
type: 'up',
},
],
});
await closed;

expect(el.open).to.be.false;
expect(selected).to.equal(thirdItem.value);
});
it('has attribute aria-describedby', async () => {
const name = 'sp-picker';
const description = 'Rendering a Picker';
Expand Down Expand Up @@ -581,94 +531,84 @@ export const testActionMenu = (mode: 'sync' | 'async'): void => {
'initially selected item should maintain selection'
).to.be.true;
});
it('allows top-level selection state to change', async () => {
let selected = true;
const handleChange = (
event: Event & { target: ActionMenu }
): void => {
if (event.target.value === 'test') {
selected = !selected;

event.target.updateComplete.then(() => {
event.target.value = selected ? 'test' : '';
});
}
};
it('does not alter submenu selection when top-level menu items are selected', async () => {
const root = await fixture<ActionMenu>(html`
<sp-action-menu label="More Actions" @change=${handleChange}>
<sp-menu-item>One</sp-menu-item>
<sp-menu-item selected value="test" id="root-selected-item">
Two
</sp-menu-item>
<sp-menu-item id="item-with-submenu">
B should be selected
<sp-menu slot="submenu">
<sp-menu-item>A</sp-menu-item>
<sp-menu-item selected id="sub-selected-item">
<sp-action-menu id="actionmenu" label="More Actions" debugging>
<sp-menu-item id="item-1">One</sp-menu-item>
<sp-menu-item id="item-2">
Two, with B selected
<sp-menu slot="submenu" id="menu-2" selects="single" debugging>
<sp-menu-item id="item-2a" selected>A</sp-menu-item>
<sp-menu-item id="item-2b">
B
</sp-menu-item>
<sp-menu-item>C</sp-menu-item>
</sp-menu>
</sp-menu-item>
</sp-action-menu>
`);

const unselectedItem = root.querySelector(
'sp-menu-item'
const item1 = root.querySelector(
'#item-1'
) as MenuItem;
const selectedItem = root.querySelector(
'#root-selected-item'
const item2 = root.querySelector(
'#item-2'
) as MenuItem;
const itemA = root.querySelector(
'#item-2a'
) as MenuItem;
const itemB = root.querySelector(
'#item-2b'
) as MenuItem;

expect(unselectedItem.textContent).to.include('One');
expect(unselectedItem.selected).to.be.false;
expect(selectedItem.textContent).to.include('Two');
expect(selectedItem.selected).to.be.true;

let opened = oneEvent(root, 'sp-opened');

expect(item1.selected, 'before opening: item1 selected?').to.be.false;
expect(item2.selected, 'before opening: item2 selected?').to.be.false;
expect(itemA.selected, 'before opening: itemA selected?').to.be.true;
expect(item2.selected, 'before opening: itemB selected?').to.be.false;
root.click();
await opened;

expect(root.open, 'after clicking open: open?').to.be.true;

// close by clicking selected
// (with event listener: should set selected = false)
let closed = oneEvent(root, 'sp-closed');
selectedItem.click();
item1.click();
await closed;

expect(root.open).to.be.false;
expect(item1.selected, 'after clicking item1: item1 selected?').to.be.false;
expect(itemA.selected, 'after clicking item1: itemA selected?').to.be.true;
expect(root.open, 'after clicking item1: open?').to.be.false;

opened = oneEvent(root, 'sp-opened');
root.click();
await opened;

// close by clicking unselected
// (no event listener: should remain selected = false)
expect(root.open, 'after reopening: open?').to.be.true;

closed = oneEvent(root, 'sp-closed');
unselectedItem.click();
itemB.click();
root.close();
await closed;

expect(item1.selected, 'after clicking itemB: item1 selected?').to.be.false;
expect(item2.selected, 'after clicking itemB: item2 selected?').to.be.false;
expect(itemA.selected, 'after clicking itemB: itemA selected?').to.be.false;
expect(itemB.selected, 'after clicking itemB: itemB selected?').to.be.true;
expect(root.open, 'after clicking itemB: open?').to.be.false;

opened = oneEvent(root, 'sp-opened');
root.click();
await opened;

expect(unselectedItem.textContent).to.include('One');
expect(unselectedItem.selected).to.be.false;
expect(selectedItem.textContent).to.include('Two');
expect(selectedItem.selected).to.be.false;

// close by clicking selected
// (with event listener: should set selected = false)
expect(root.open, 'after reopening: open?').to.be.true;

closed = oneEvent(root, 'sp-closed');
selectedItem.click();
item2.click();
await closed;

opened = oneEvent(root, 'sp-opened');
root.click();
await opened;

expect(unselectedItem.textContent).to.include('One');
expect(unselectedItem.selected).to.be.false;
expect(selectedItem.textContent).to.include('Two');
expect(selectedItem.selected).to.be.true;
expect(item2.selected, 'after clicking item2: item2 selected?').to.be.false;
expect(itemB.selected, 'after clicking item2: itemB selected?').to.be.true;
expect(root.open, 'after clicking item2: open?').to.be.false;
});
it('shows tooltip', async function () {
const openSpy = spy();
Expand Down
Loading
Loading