Skip to content
This repository was archived by the owner on Jan 13, 2025. It is now read-only.

Commit f838c6e

Browse files
joyzhongcopybara-github
authored andcommitted
feat(iconbutton): Add icon button variant which supports toggling aria label.
BREAKING_CHANGE: Adds `getAttr` method to MDCIconButtonToggleAdapter. PiperOrigin-RevId: 306841114
1 parent 490fbdc commit f838c6e

File tree

6 files changed

+120
-6
lines changed

6 files changed

+120
-6
lines changed

packages/mdc-icon-button/README.md

+18
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,24 @@ The icon button toggle can be used with `img` tags.
129129
</button>
130130
```
131131

132+
### Icon button toggle with toggled aria label
133+
134+
Some designs may call for the aria label to change depending on the icon button
135+
state. In this case, specify the `data-aria-label-on` (aria label in on state)
136+
and `aria-data-label-off` (aria label in off state) attributes, and omit the
137+
`aria-pressed` attribute.
138+
139+
```html
140+
<button id="add-to-favorites"
141+
class="mdc-icon-button"
142+
aria-label="Add to favorites"
143+
data-aria-label-on="Remove from favorites"
144+
data-aria-label-off="Add to favorites">
145+
<i class="material-icons mdc-icon-button__icon mdc-icon-button__icon--on">favorite</i>
146+
<i class="material-icons mdc-icon-button__icon">favorite_border</i>
147+
</button>
148+
```
149+
132150
## API
133151

134152
### CSS classes

packages/mdc-icon-button/adapter.ts

+3
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ export interface MDCIconButtonToggleAdapter {
3737

3838
hasClass(className: string): boolean;
3939

40+
/** Returns the given attribute value on the root element. */
41+
getAttr(attrName: string): string|null;
42+
4043
setAttr(attrName: string, attrValue: string): void;
4144

4245
notifyChange(evtData: MDCIconButtonToggleEventDetail): void;

packages/mdc-icon-button/component.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,14 @@ export class MDCIconButtonToggle extends MDCComponent<MDCIconButtonToggleFoundat
5757
const adapter: MDCIconButtonToggleAdapter = {
5858
addClass: (className) => this.root_.classList.add(className),
5959
hasClass: (className) => this.root_.classList.contains(className),
60-
notifyChange: (evtData) => this.emit<MDCIconButtonToggleEventDetail>(strings.CHANGE_EVENT, evtData),
60+
notifyChange: (evtData) => {
61+
this.emit<MDCIconButtonToggleEventDetail>(
62+
strings.CHANGE_EVENT, evtData);
63+
},
6164
removeClass: (className) => this.root_.classList.remove(className),
62-
setAttr: (attrName, attrValue) => this.root_.setAttribute(attrName, attrValue),
65+
getAttr: (attrName) => this.root_.getAttribute(attrName),
66+
setAttr: (attrName, attrValue) =>
67+
this.root_.setAttribute(attrName, attrValue),
6368
};
6469
return new MDCIconButtonToggleFoundation(adapter);
6570
}

packages/mdc-icon-button/constants.ts

+3
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export const cssClasses = {
2727
};
2828

2929
export const strings = {
30+
ARIA_LABEL: 'aria-label',
3031
ARIA_PRESSED: 'aria-pressed',
32+
DATA_ARIA_LABEL_OFF: 'data-aria-label-off',
33+
DATA_ARIA_LABEL_ON: 'data-aria-label-on',
3134
CHANGE_EVENT: 'MDCIconButtonToggle:change',
3235
};

packages/mdc-icon-button/foundation.ts

+30-2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ import {MDCIconButtonToggleAdapter} from './adapter';
2626
import {cssClasses, strings} from './constants';
2727

2828
export class MDCIconButtonToggleFoundation extends MDCFoundation<MDCIconButtonToggleAdapter> {
29+
/**
30+
* Whether the icon button has an aria label that changes depending on
31+
* toggled state.
32+
*/
33+
private hasToggledAriaLabel: boolean = false;
34+
2935
static get cssClasses() {
3036
return cssClasses;
3137
}
@@ -40,6 +46,7 @@ export class MDCIconButtonToggleFoundation extends MDCFoundation<MDCIconButtonTo
4046
hasClass: () => false,
4147
notifyChange: () => undefined,
4248
removeClass: () => undefined,
49+
getAttr: () => null,
4350
setAttr: () => undefined,
4451
};
4552
}
@@ -49,7 +56,19 @@ export class MDCIconButtonToggleFoundation extends MDCFoundation<MDCIconButtonTo
4956
}
5057

5158
init() {
52-
this.adapter_.setAttr(strings.ARIA_PRESSED, `${this.isOn()}`);
59+
const ariaLabelOn = this.adapter_.getAttr(strings.DATA_ARIA_LABEL_ON);
60+
const ariaLabelOff = this.adapter_.getAttr(strings.DATA_ARIA_LABEL_OFF);
61+
if (ariaLabelOn && ariaLabelOff) {
62+
if (this.adapter_.getAttr(strings.ARIA_PRESSED) !== null) {
63+
throw new Error(
64+
'MDCIconButtonToggleFoundation: Button should not set ' +
65+
'`aria-pressed` if it has a toggled aria label.');
66+
}
67+
68+
this.hasToggledAriaLabel = true;
69+
} else {
70+
this.adapter_.setAttr(strings.ARIA_PRESSED, String(this.isOn()));
71+
}
5372
}
5473

5574
handleClick() {
@@ -62,13 +81,22 @@ export class MDCIconButtonToggleFoundation extends MDCFoundation<MDCIconButtonTo
6281
}
6382

6483
toggle(isOn: boolean = !this.isOn()) {
84+
// Toggle UI based on state.
6585
if (isOn) {
6686
this.adapter_.addClass(cssClasses.ICON_BUTTON_ON);
6787
} else {
6888
this.adapter_.removeClass(cssClasses.ICON_BUTTON_ON);
6989
}
7090

71-
this.adapter_.setAttr(strings.ARIA_PRESSED, `${isOn}`);
91+
// Toggle aria attributes based on state.
92+
if (this.hasToggledAriaLabel) {
93+
const ariaLabel = isOn ?
94+
this.adapter_.getAttr(strings.DATA_ARIA_LABEL_ON) :
95+
this.adapter_.getAttr(strings.DATA_ARIA_LABEL_OFF);
96+
this.adapter_.setAttr(strings.ARIA_LABEL, ariaLabel || '');
97+
} else {
98+
this.adapter_.setAttr(strings.ARIA_PRESSED, `${isOn}`);
99+
}
72100
}
73101
}
74102

packages/mdc-icon-button/test/foundation.test.ts

+59-2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe('MDCIconButtonToggleFoundation', () => {
4242
'addClass',
4343
'removeClass',
4444
'hasClass',
45+
'getAttr',
4546
'setAttr',
4647
'notifyChange',
4748
]);
@@ -53,15 +54,15 @@ describe('MDCIconButtonToggleFoundation', () => {
5354
return {foundation, mockAdapter};
5455
};
5556

56-
it(`isOn is false if hasClass(${cssClasses.ICON_BUTTON_ON}) returns false`,
57+
it(`#isOn is false if hasClass(${cssClasses.ICON_BUTTON_ON}) returns false`,
5758
() => {
5859
const {foundation, mockAdapter} = setupTest();
5960
mockAdapter.hasClass.withArgs(cssClasses.ICON_BUTTON_ON)
6061
.and.returnValue(false);
6162
expect(foundation.isOn()).toBe(false);
6263
});
6364

64-
it(`isOn is true if hasClass(${cssClasses.ICON_BUTTON_ON}) returns true`,
65+
it(`#isOn is true if hasClass(${cssClasses.ICON_BUTTON_ON}) returns true`,
6566
() => {
6667
const {foundation, mockAdapter} = setupTest();
6768
mockAdapter.hasClass.withArgs(cssClasses.ICON_BUTTON_ON)
@@ -133,4 +134,60 @@ describe('MDCIconButtonToggleFoundation', () => {
133134
.toHaveBeenCalledWith(strings.ARIA_PRESSED, 'false');
134135
expect(mockAdapter.setAttr).toHaveBeenCalledTimes(1);
135136
});
137+
138+
describe('Variant with toggled aria label', () => {
139+
it('#init throws an error if `aria-label-on` and `aria-label-off` are ' +
140+
'set, but `aria-pressed` is also set',
141+
() => {
142+
const {foundation, mockAdapter} = setupTest();
143+
144+
mockAdapter.getAttr.withArgs(strings.DATA_ARIA_LABEL_ON)
145+
.and.returnValue('on label');
146+
mockAdapter.getAttr.withArgs(strings.DATA_ARIA_LABEL_OFF)
147+
.and.returnValue('off label');
148+
mockAdapter.getAttr.withArgs(strings.ARIA_PRESSED)
149+
.and.returnValue('false');
150+
151+
expect(foundation.init).toThrow();
152+
});
153+
154+
it('#toggle sets aria label correctly when toggled on', () => {
155+
const {foundation, mockAdapter} = initWithToggledAriaLabel({isOn: false});
156+
157+
mockAdapter.getAttr.withArgs(strings.DATA_ARIA_LABEL_ON)
158+
.and.returnValue('on label');
159+
mockAdapter.getAttr.withArgs(strings.DATA_ARIA_LABEL_OFF)
160+
.and.returnValue('off label');
161+
foundation.toggle(true);
162+
expect(mockAdapter.setAttr)
163+
.toHaveBeenCalledWith(strings.ARIA_LABEL, 'on label');
164+
});
165+
166+
it('#toggle sets aria label correctly when toggled off', () => {
167+
const {foundation, mockAdapter} = initWithToggledAriaLabel({isOn: false});
168+
169+
mockAdapter.getAttr.withArgs(strings.DATA_ARIA_LABEL_ON)
170+
.and.returnValue('on label');
171+
mockAdapter.getAttr.withArgs(strings.DATA_ARIA_LABEL_OFF)
172+
.and.returnValue('off label');
173+
foundation.toggle(false);
174+
expect(mockAdapter.setAttr)
175+
.toHaveBeenCalledWith(strings.ARIA_LABEL, 'off label');
176+
});
177+
178+
const initWithToggledAriaLabel = ({isOn}: {isOn: boolean}) => {
179+
const {foundation, mockAdapter} = setupTest();
180+
181+
mockAdapter.getAttr.withArgs(strings.DATA_ARIA_LABEL_ON)
182+
.and.returnValue('on label');
183+
mockAdapter.getAttr.withArgs(strings.DATA_ARIA_LABEL_OFF)
184+
.and.returnValue('off label');
185+
mockAdapter.getAttr.withArgs(strings.ARIA_PRESSED).and.returnValue(null);
186+
mockAdapter.hasClass.withArgs(cssClasses.ICON_BUTTON_ON)
187+
.and.returnValue(isOn);
188+
foundation.init();
189+
190+
return {foundation, mockAdapter};
191+
};
192+
});
136193
});

0 commit comments

Comments
 (0)