Skip to content

Commit

Permalink
fix(action-group): update application/management of "role" on group a…
Browse files Browse the repository at this point in the history
…nd buttons

* fix(action-group): should have role="toolbar" as default, selected button without selects="single" or selected="multiple" should use aria-pressed

* docs(action-group): #3221 [a11y]: ActionGroup add accessibility example to docs

* docs(action-group): #3221 [a11y]: ActionGroup update docs example

* docs(action-group): #3221 Update README.md wording to clarify the developer responsibility
  • Loading branch information
majornista committed May 30, 2023
1 parent f244e2d commit 533873b
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 12 deletions.
131 changes: 129 additions & 2 deletions packages/action-group/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ When a selection can be made, it is a good idea to supply the group of options w

### Single

An `<sp-action-group selects="single">` will manage its `<sp-action-button>` children as "radio buttons" allowing the user to select a _single_ one of the buttons presented. The `<sp-action-button>` children will only be able to turn their `selected` value on while maintaining a single selection after an intial selection is made.
An `<sp-action-group selects="single">` will manage its `<sp-action-button>` children as "radio buttons" allowing the user to select a _single_ one of the buttons presented. The `<sp-action-button>` children will only be able to turn their `selected` value on while maintaining a single selection after an initial selection is made.

```html
<sp-action-group
Expand All @@ -117,7 +117,7 @@ An `<sp-action-group selects="single">` will manage its `<sp-action-button>` chi

### Multiple

An `<sp-action-group selects="multiple">` will manage its `<sp-action-button>` children as "chekboxes" allowing the user to select a _multiple_ of the buttons presented. The `<sp-action-button>` children will toggle their `selected` value on and off when clicked sucessively.
An `<sp-action-group selects="multiple">` will manage its `<sp-action-button>` children as "checkboxes" allowing the user to select a _multiple_ of the buttons presented. The `<sp-action-button>` children will toggle their `selected` value on and off when clicked successively.

```html
<sp-action-group
Expand Down Expand Up @@ -349,3 +349,130 @@ The `justified` attribute will cause the `<sp-action-group>` element to fill the
</sp-action-button>
</sp-action-group>
```

## Accessibility

The accessibility `role` for an `<sp-action-group>` element depends on the manner in which items are selected. By default, `<sp-action-group selects="single">` will have `role="radiogroup"`, because it manages its children as a "radio group", while `<sp-action-group>` or `<sp-action-group selects="multiple">` will have `role="toolbar"`, which makes sense for a group of buttons or checkboxes between which one can navigate using the arrow keys.

When more than one `<sp-action-group>` elements are combined together with in a toolbar, the `role` attribute for `<sp-action-group>` or `<sp-action-group selects="multiple">` should be overwritten using `role="group"` or `role="presentation"`, so that toolbars are not nested, as demonstrated in the following example of a hypothetical toolbar for formatting text within a rich text editor:

<script type="module">
import '@spectrum-web-components/divider/sp-divider.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-text-bold.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-text-italic.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-text-underline.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-text-align-left.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-text-align-center.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-text-align-justify.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-text-align-right.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-text-bulleted.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-text-numbered.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-copy.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-paste.js';
import '@spectrum-web-components/icons-workflow/icons/sp-icon-cut.js';
</script>

```html
<div
aria-label="Text Formatting"
role="toolbar"
style="height: 32px; display: flex; gap: 6px"
>
<sp-action-group
aria-label="Text Style"
selects="multiple"
role="group"
compact
emphasized
>
<sp-action-button label="Bold" value="bold">
<sp-icon-text-bold slot="icon"></sp-icon-text-bold>
</sp-action-button>
<sp-action-button label="Italic" value="italic">
<sp-icon-text-italic slot="icon"></sp-icon-text-italic>
</sp-action-button>
<sp-action-button label="Underline" value="underline">
<sp-icon-text-underline slot="icon"></sp-icon-text-underline>
</sp-action-button>
</sp-action-group>
<sp-divider
size="s"
style="align-self: stretch; height: auto;"
vertical
></sp-divider>
<sp-action-group
aria-label="Text Align"
selects="single"
compact
emphasized
>
<sp-action-button label="Left" value="left" selected>
<sp-icon-text-align-left slot="icon"></sp-icon-text-align-left>
</sp-action-button>
<sp-action-button label="Center" value="center">
<sp-icon-text-align-center slot="icon"></sp-icon-text-align-center>
</sp-action-button>
<sp-action-button label="Right" value="right">
<sp-icon-text-align-right slot="icon"></sp-icon-text-align-right>
</sp-action-button>
<sp-action-button label="Justify" value="justify">
<sp-icon-text-align-justify
slot="icon"
></sp-icon-text-align-justify>
</sp-action-button>
</sp-action-group>
<sp-divider
size="s"
style="align-self: stretch; height: auto;"
vertical
></sp-divider>
<sp-action-group
aria-label="List Style"
selects="multiple"
role="group"
compact
emphasized
>
<sp-action-button
label="Bulleted"
value="bulleted"
onclick="
/* makes mutually exclusive checkbox */
this.selected &&
requestAnimationFrame(() => this.parentElement.selected = []);
this.parentElement.selected = [];
"
>
<sp-icon-text-bulleted slot="icon"></sp-icon-text-bulleted>
</sp-action-button>
<sp-action-button
label="Numbering"
value="numbering"
onclick="
/* makes mutually exclusive checkbox */
this.selected &&
requestAnimationFrame(() => this.parentElement.selected = []);
this.parentElement.selected = [];
"
>
<sp-icon-text-numbered slot="icon"></sp-icon-text-numbered>
</sp-action-button>
</sp-action-group>
<sp-divider
size="s"
style="align-self: stretch; height: auto;"
vertical
></sp-divider>
<sp-action-group role="presentation" compact>
<sp-action-button disabled label="Copy" value="copy">
<sp-icon-copy slot="icon"></sp-icon-copy>
</sp-action-button>
<sp-action-button disabled label="Paste" value="paste">
<sp-icon-paste slot="icon"></sp-icon-paste>
</sp-action-button>
<sp-action-button disabled label="Cut" value="cut">
<sp-icon-cut slot="icon"></sp-icon-cut>
</sp-action-button>
</sp-action-group>
</div>
```
1 change: 1 addition & 0 deletions packages/action-group/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@lit-labs/observers": "^2.0.0",
"@spectrum-web-components/action-button": "^0.31.0",
"@spectrum-web-components/base": "^0.31.0",
"@spectrum-web-components/icons-workflow": "^0.31.0",
"@spectrum-web-components/reactive-controllers": "^0.31.0"
},
"devDependencies": {
Expand Down
24 changes: 17 additions & 7 deletions packages/action-group/src/ActionGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,10 @@ export class ActionGroup extends SizedMixin(SpectrumElement, {
selected.forEach((el) => {
el.selected = false;
el.tabIndex = -1;
el.setAttribute('aria-checked', 'false');
el.setAttribute(
!this.selects ? 'aria-pressed' : 'aria-checked',
'false'
);
});
}

Expand Down Expand Up @@ -221,6 +224,7 @@ export class ActionGroup extends SizedMixin(SpectrumElement, {
const options = this.buttons;
switch (this.selects) {
case 'single': {
// single behaves as a radio group
this.setAttribute('role', 'radiogroup');
const selections: ActionButton[] = [];
const updates = options.map(async (option) => {
Expand All @@ -245,7 +249,10 @@ export class ActionGroup extends SizedMixin(SpectrumElement, {
break;
}
case 'multiple': {
this.setAttribute('role', 'group');
// switching from single to multiple, remove role="radiogroup"
if (this.getAttribute('role') === 'radiogroup') {
this.removeAttribute('role');
}
const selection: string[] = [];
const selections: ActionButton[] = [];
const updates = options.map(async (option) => {
Expand Down Expand Up @@ -274,13 +281,12 @@ export class ActionGroup extends SizedMixin(SpectrumElement, {
const selections: ActionButton[] = [];
const updates = options.map(async (option) => {
await option.updateComplete;
option.setAttribute(
'aria-checked',
option.selected ? 'true' : 'false'
);
option.setAttribute('role', 'button');
if (option.selected) {
option.setAttribute('aria-pressed', 'true');
selections.push(option);
} else {
option.removeAttribute('aria-pressed');
}
});
if (applied) break;
Expand All @@ -295,10 +301,14 @@ export class ActionGroup extends SizedMixin(SpectrumElement, {
this.buttons.forEach((option) => {
option.setAttribute('role', 'button');
});
this.removeAttribute('role');
break;
}
}

// When no other role is defined, use role="toolbar", which is appropriate with roving tabindex.
if (!this.hasAttribute('role')) {
this.setAttribute('role', 'toolbar');
}
}

protected override render(): TemplateResult {
Expand Down
68 changes: 66 additions & 2 deletions packages/action-group/test/action-group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ describe('ActionGroup', () => {

await expect(el).to.be.accessible();
expect(el.getAttribute('aria-label')).to.equal('Default Group');
expect(el.hasAttribute('role')).to.be.false;
expect(el.getAttribute('role')).to.equal('toolbar');
expect(el.children[0].getAttribute('role')).to.equal('button');
});
it('applies `quiet` attribute to its children', async () => {
Expand Down Expand Up @@ -318,7 +318,7 @@ describe('ActionGroup', () => {
expect(el.getAttribute('aria-label')).to.equal(
'Selects Multiple Group'
);
expect(el.getAttribute('role')).to.equal('group');
expect(el.getAttribute('role')).to.equal('toolbar');
expect(el.children[0].getAttribute('role')).to.equal('checkbox');
});
it('loads [selects="multiple"] action-group w/ selection accessibly', async () => {
Expand Down Expand Up @@ -746,20 +746,54 @@ describe('ActionGroup', () => {
);

await elementUpdated(el);
expect(el.getAttribute('role')).to.equal('toolbar');
expect(el.selected.length).to.equal(2);

const firstButton = el.querySelector('.first') as ActionButton;
expect(firstButton.selected, 'first button selected').to.be.true;
expect(firstButton.hasAttribute('aria-checked')).to.be.false;
expect(
firstButton.getAttribute('aria-pressed'),
'first button aria-pressed'
).to.eq('true');
expect(firstButton.getAttribute('role'), 'first button role').to.eq(
'button'
);

const secondButton = el.querySelector('.second') as ActionButton;
expect(secondButton.selected, 'second button selected').to.be.true;
expect(secondButton.hasAttribute('aria-checked')).to.be.false;
expect(
secondButton.getAttribute('aria-pressed'),
'second button aria-pressed'
).to.eq('true');
expect(secondButton.getAttribute('role'), 'first button role').to.eq(
'button'
);

firstButton.click();
await elementUpdated(el);

expect(el.selected.length).to.equal(2);
expect(firstButton.selected, 'first button selected').to.be.true;
expect(firstButton.hasAttribute('aria-checked')).to.be.false;
expect(
firstButton.getAttribute('aria-pressed'),
'first button aria-pressed'
).to.eq('true');
expect(firstButton.getAttribute('role'), 'first button role').to.eq(
'button'
);

expect(secondButton.selected, 'second button selected').to.be.true;
expect(secondButton.hasAttribute('aria-checked')).to.be.false;
expect(
secondButton.getAttribute('aria-pressed'),
'second button aria-pressed'
).to.eq('true');
expect(secondButton.getAttribute('role'), 'first button role').to.eq(
'button'
);
});

it('will not change .selected state if event is prevented while [selects="multiple"]', async () => {
Expand Down Expand Up @@ -934,6 +968,36 @@ describe('ActionGroup', () => {
expect(secondElement.selected, 'second child selected').to.be.false;
});

it('accepts role attribute override', async () => {
const el = await fixture<ActionGroup>(
html`
<sp-action-group role="group">
<sp-action-button>Button</sp-action-button>
</sp-action-group>
`
);

// with a role of group, the role should not be overridden
await elementUpdated(el);
expect(el.getAttribute('role')).to.equal('group');

// setting selects to single should override role to radiogroup
el.setAttribute('selects', 'single');
await elementUpdated(el);
expect(el.getAttribute('role')).to.equal('radiogroup');

// setting selects to multiple should override role to toolbar
el.setAttribute('selects', 'multiple');
await elementUpdated(el);
expect(el.getAttribute('role')).to.equal('toolbar');

// by default, role should be toolbar
el.removeAttribute('role');
el.removeAttribute('selects');
await elementUpdated(el);
expect(el.getAttribute('role')).to.equal('toolbar');
});

const acceptKeyboardInput = async (el: ActionGroup): Promise<void> => {
const thirdElement = el.querySelector('.third') as ActionButton;

Expand Down
3 changes: 2 additions & 1 deletion projects/story-decorator/decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const swcThemeDecoratorWithConfig =
(story: () => TemplateResult) => {
if (!bundled) {
requestAnimationFrame(() => {
document.documentElement.setAttribute('lang', 'en');
const decorator = document.querySelector(
'sp-story-decorator'
) as HTMLElement;
Expand All @@ -41,7 +42,7 @@ export const swcThemeDecoratorWithConfig =
}
return html`
${themeStyles}
<sp-story-decorator>
<sp-story-decorator role="main">
${bundled ? story() : html``}
</sp-story-decorator>
`;
Expand Down

0 comments on commit 533873b

Please sign in to comment.