Skip to content

Commit

Permalink
fix(tabs): adds a11y roles for tablist/tab
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 532843567
  • Loading branch information
material-web-copybara authored and copybara-github committed May 17, 2023
1 parent 77f0b82 commit 0da80a0
Show file tree
Hide file tree
Showing 4 changed files with 52 additions and 38 deletions.
20 changes: 15 additions & 5 deletions tabs/demo/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ const styles = css`
md-tabs[variant~="vertical"].scrolling {
block-size: 50vh;
}`;
}
.controls {
height: 48px;
}
`;

const primary: MaterialStoryInit<StoryKnobs> = {
name: 'Primary Tabs',
Expand Down Expand Up @@ -379,7 +384,7 @@ const dynamic: MaterialStoryInit<StoryKnobs> = {
}

return html`
<div>
<div class="controls">
<md-standard-icon-button @click=${
addTab}><md-icon>add</md-icon></md-standard-icon-button>
<md-standard-icon-button @click=${
Expand Down Expand Up @@ -413,9 +418,14 @@ function getTabContentGenerator(knobs: StoryKnobs) {
const contentKnob = knobs.content;
const useIcon = contentKnob !== 'label';
const useLabel = contentKnob !== 'icon';
return (icon: string, label: string) => html`
${useIcon ? html`<md-icon slot="icon">${icon}</md-icon>` : nothing}
${useLabel ? html`${label}` : nothing}`;
return (icon: string, label: string) => {
const iconTemplate =
html`<md-icon aria-hidden="true" slot="icon">${icon}</md-icon>`;
return html`
${useIcon ? iconTemplate : nothing}
${useLabel ? html`${label}` : nothing}
`;
};
}

/** Tabs stories. */
Expand Down
10 changes: 7 additions & 3 deletions tabs/lib/_tab.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@

@include focus-ring.theme(
(
shape: 8px,
offset: -7px,
// desired border-radius is 8px and it's internally calc'd as sum
// of shape + offset (-7 + 15)
shape: 15px,
offset: -7px
)
);
}
Expand Down Expand Up @@ -114,7 +116,9 @@
$_content-padding: 8px;
// tabs are naturally sized up to their max height.
max-height: calc(var(--_container-height) + 2 * $_content-padding);
padding: $_content-padding;
// min-height of touch target
min-height: 48px;
padding: $_content-padding calc(2 * $_content-padding);
gap: 4px;
}

Expand Down
23 changes: 15 additions & 8 deletions tabs/lib/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {MdRipple} from '../../ripple/ripple.js';
/**
* An element that can select items.
*/
export interface SelectionGroupElement extends HTMLElement {
export interface Tabs extends HTMLElement {
selected?: number;
selectedItem?: Tab;
previousSelectedItem?: Tab;
Expand Down Expand Up @@ -55,15 +55,20 @@ export class Tab extends LitElement {
@property({reflect: true}) variant: Variant = 'primary';

/**
* Whether or not the item is `disabled`.
* Whether or not the tab is `disabled`.
*/
@property({type: Boolean, reflect: true}) disabled = false;

/**
* Whether or not the item is `selected`.
* Whether or not the tab is `selected`.
**/
@property({type: Boolean, reflect: true}) selected = false;

/**
* Whether or not the tab is `focusable`.
*/
@property({type: Boolean}) focusable = false;

/**
* Whether or not the icon renders inline with label or stacked vertically.
*/
Expand Down Expand Up @@ -102,7 +107,10 @@ export class Tab extends LitElement {
};
return html`
<button
class="button md3-button"
class="button"
role="tab"
.tabIndex=${this.focusable && !this.disabled ? 0 : -1}
aria-selected=${this.selected ? 'true' : 'false'}
?disabled=${this.disabled}
aria-label=${this.ariaLabel || nothing}
${ripple(this.getRipple)}
Expand Down Expand Up @@ -144,8 +152,8 @@ export class Tab extends LitElement {
return html`<md-ripple ?disabled="${this.disabled}"></md-ripple>`;
};

private get selectionGroup() {
return this.parentElement as SelectionGroupElement;
private get tabs() {
return this.parentElement as Tabs;
}

private animateSelected() {
Expand All @@ -166,8 +174,7 @@ export class Tab extends LitElement {
const from: Keyframe = {};
const isVertical = this.variant.includes('vertical');
const fromRect =
(this.selectionGroup?.previousSelectedItem?.indicator
.getBoundingClientRect() ??
(this.tabs?.previousSelectedItem?.indicator.getBoundingClientRect() ??
({} as DOMRect));
const fromPos = isVertical ? fromRect.top : fromRect.left;
const fromExtent = isVertical ? fromRect.height : fromRect.width;
Expand Down
37 changes: 15 additions & 22 deletions tabs/lib/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {property, queryAssignedElements, state} from 'lit/decorators.js';
import {Tab, Variant} from './tab.js';

const NAVIGATION_KEYS = new Map([
['default', new Set(['Home', 'End', 'Space'])],
['default', new Set(['Home', 'End'])],
['horizontal', new Set(['ArrowLeft', 'ArrowRight'])],
['vertical', new Set(['ArrowUp', 'ArrowDown'])]
]);
Expand Down Expand Up @@ -110,6 +110,11 @@ export class Tabs extends LitElement {
}
}

override connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'tablist');
}

// focus item on keydown and optionally select it
private readonly handleKeydown = async (event: KeyboardEvent) => {
const {key} = event;
Expand All @@ -124,31 +129,24 @@ export class Tabs extends LitElement {
const focused = this.focusedItem ?? this.selectedItem;
const itemCount = this.items.length;
const isPrevKey = key === 'ArrowLeft' || key === 'ArrowUp';
const isNextKey = key === 'ArrowRight' || key === 'ArrowDown';
if (key === 'Home') {
indexToFocus = 0;
} else if (key === 'End') {
indexToFocus = itemCount - 1;
} else if (key === 'Space') {
indexToFocus = this.items.indexOf(focused);
} else if (isPrevKey || isNextKey) {
const d = (this.items.indexOf(focused) || 0) +
(isPrevKey ? -1 :
isNextKey ? 1 :
0);
indexToFocus = d < 0 ? itemCount - 1 : d % itemCount;
} else {
const focusedIndex = this.items.indexOf(focused) || 0;
indexToFocus = focusedIndex + (isPrevKey ? -1 : 1);
indexToFocus =
indexToFocus < 0 ? itemCount - 1 : indexToFocus % itemCount;
}
const itemToFocus =
this.findFocusableItem(indexToFocus, key === 'End' || isPrevKey);
indexToFocus = this.items.indexOf(itemToFocus!);
if (itemToFocus !== null && itemToFocus !== focused) {
const shouldSelect = this.selectOnFocus || key === 'Space';
if (shouldSelect) {
this.selected = indexToFocus;
}
this.updateFocusableItem(itemToFocus);
itemToFocus.focus();
if (shouldSelect) {
if (this.selectOnFocus) {
this.selected = indexToFocus;
await this.dispatchInteraction();
}
}
Expand Down Expand Up @@ -260,13 +258,8 @@ export class Tabs extends LitElement {
}

private updateFocusableItem(focusableItem: HTMLElement|null) {
const tabIndex = 'tabindex';
for (const item of this.items) {
if (item === focusableItem) {
item.removeAttribute(tabIndex);
} else {
item.setAttribute(tabIndex, '-1');
}
item.focusable = item === focusableItem;
}
}

Expand Down Expand Up @@ -294,7 +287,7 @@ export class Tabs extends LitElement {
}
}

private handleSlotChange(e: Event) {
private handleSlotChange() {
this.itemsDirty = true;
}

Expand Down

0 comments on commit 0da80a0

Please sign in to comment.