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

feat(core/icon-button): implement a11y features for icon-button #502

Merged
merged 19 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions packages/angular/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1021,14 +1021,14 @@ export declare interface IxGroupItem extends Components.IxGroupItem {


@ProxyCmp({
inputs: ['color', 'disabled', 'ghost', 'icon', 'loading', 'outline', 'oval', 'size', 'type', 'variant']
inputs: ['a11yLabel', 'color', 'disabled', 'ghost', 'icon', 'loading', 'outline', 'oval', 'size', 'type', 'variant']
})
@Component({
selector: 'ix-icon-button',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
inputs: ['color', 'disabled', 'ghost', 'icon', 'loading', 'outline', 'oval', 'size', 'type', 'variant'],
inputs: ['a11yLabel', 'color', 'disabled', 'ghost', 'icon', 'loading', 'outline', 'oval', 'size', 'type', 'variant'],
})
export class IxIconButton {
protected el: HTMLElement;
Expand Down
21 changes: 21 additions & 0 deletions packages/core/component-doc.json
Original file line number Diff line number Diff line change
Expand Up @@ -5491,6 +5491,27 @@
]
},
"props": [
{
"name": "a11yLabel",
"type": "string",
"mutable": false,
"attr": "a11y-label",
"reflectToAttr": false,
"docs": "Accessibility label for the icon button\nWill be set as aria-label on the nested HTML button element",
"docsTags": [
{
"name": "since",
"text": "2.1.0"
}
],
"values": [
{
"type": "string"
}
],
"optional": false,
"required": false
},
{
"name": "color",
"type": "string",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,11 @@ export namespace Components {
"text": string;
}
interface IxIconButton {
/**
* Accessibility label for the icon button Will be set as aria-label on the nested HTML button element
* @since 2.1.0
*/
"a11yLabel": string;
/**
* Color of icon in button
*/
Expand Down Expand Up @@ -4619,6 +4624,11 @@ declare namespace LocalJSX {
"text"?: string;
}
interface IxIconButton {
/**
* Accessibility label for the icon button Will be set as aria-label on the nested HTML button element
* @since 2.1.0
*/
"a11yLabel"?: string;
/**
* Color of icon in button
*/
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/components/icon-button/icon-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Component, Element, h, Host, Prop } from '@stencil/core';
import { BaseButtonProps } from '../button/base-button';
import { ButtonVariant } from '../button/button';
import { BaseIconButton } from '../icon-button/base-icon-button';
import { getFallbackLabelFromIconName } from '../utils/a11y';

export type IconButtonVariant = ButtonVariant;

Expand All @@ -22,6 +23,14 @@ export type IconButtonVariant = ButtonVariant;
export class IconButton {
@Element() hostElement: HTMLIxIconButtonElement;

/**
* Accessibility label for the icon button
* Will be set as aria-label on the nested HTML button element
*
* @since 2.1.0
*/
@Prop({ attribute: 'a11y-label' }) a11yLabel: string;

/**
* Variant of button
*/
Expand Down Expand Up @@ -109,6 +118,11 @@ export class IconButton {

render() {
const baseButtonProps: BaseButtonProps = {
ariaAttributes: {
'aria-label': this.a11yLabel
? this.a11yLabel
: getFallbackLabelFromIconName(this.icon),
},
variant: this.variant,
outline: this.outline,
ghost: this.ghost,
Expand Down
146 changes: 146 additions & 0 deletions packages/core/src/components/icon-button/test/icon-button.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,150 @@ describe('icon-button', () => {
expect(btn).toBeDefined();
expect(shadowButton).toBeNull();
});

describe('a11y', () => {
it('should have a fallback icon aria name', async () => {
const page = await newSpecPage({
components: [IconButton],
html: `<ix-icon-button icon="rocket"></ix-icon-button>`,
});

await page.waitForChanges();

const button = page.doc
.querySelector('ix-icon-button')
.shadowRoot.querySelector('button');

expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toBe('Rocket');
});

it('should have a fallback icon aria name without fill postfix', async () => {
const page = await newSpecPage({
components: [IconButton],
html: `<ix-icon-button icon="about-filled"></ix-icon-button>`,
});

await page.waitForChanges();

const button = page.doc
.querySelector('ix-icon-button')
.shadowRoot.querySelector('button');

expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toBe('About');
});

it('should have a fallback icon aria name', async () => {
const page = await newSpecPage({
components: [IconButton],
html: `<ix-icon-button icon="about-battery-filled"></ix-icon-button>`,
});

await page.waitForChanges();

const button = page.doc
.querySelector('ix-icon-button')
.shadowRoot.querySelector('button');

expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toBe('About Battery');
});

it('should have a fallback icon aria name without numbers inside name', async () => {
const page = await newSpecPage({
components: [IconButton],
html: `<ix-icon-button icon="battery100-percentage"></ix-icon-button>`,
});

await page.waitForChanges();

const button = page.doc
.querySelector('ix-icon-button')
.shadowRoot.querySelector('button');

expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toBe('Battery Percentage');
});

it('should have a fallback icon aria name without numbers between', async () => {
const page = await newSpecPage({
components: [IconButton],
html: `<ix-icon-button icon="battery-100-percentage"></ix-icon-button>`,
});

await page.waitForChanges();

const button = page.doc
.querySelector('ix-icon-button')
.shadowRoot.querySelector('button');

expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toBe('Battery 100 Percentage');
});

it('should have a fallback icon with multiple dashes', async () => {
const page = await newSpecPage({
components: [IconButton],
html: `<ix-icon-button icon="text-circle-rectangle-filled"></ix-icon-button>`,
});

await page.waitForChanges();

const button = page.doc
.querySelector('ix-icon-button')
.shadowRoot.querySelector('button');

expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toBe('Text Circle Rectangle');
});

it('should have an aria label', async () => {
const page = await newSpecPage({
components: [IconButton],
html: `<ix-icon-button a11y-label="some label"></ix-icon-button>`,
});

await page.waitForChanges();

const button = page.doc
.querySelector('ix-icon-button')
.shadowRoot.querySelector('button');

expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toBe('some label');
});

it('should have an unknown aria label with an URL', async () => {
const page = await newSpecPage({
components: [IconButton],
html: `<ix-icon-button icon="https://someurl.com/test.svg"></ix-icon-button>`,
});

await page.waitForChanges();

const button = page.doc
.querySelector('ix-icon-button')
.shadowRoot.querySelector('button');

expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toBe('Unknown');
});

it('should have an unknown aria label with an base64 encoded SVG', async () => {
const page = await newSpecPage({
components: [IconButton],
html: `<ix-icon-button icon="data:image/svg+xml"></ix-icon-button>`,
});

await page.waitForChanges();

const button = page.doc
.querySelector('ix-icon-button')
.shadowRoot.querySelector('button');

expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toBe('Unknown');
});
});
});
45 changes: 45 additions & 0 deletions packages/core/src/components/utils/a11y.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,53 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import { isHttpUrl, isSvgDataUrl } from './condition-checks';

export const a11yBoolean = (value: boolean) => (value ? 'true' : 'false');

const kebabCaseToUpperCaseSentence = (kebabCase: string) => {
const withoutFilledSuffix = kebabCase.replace('-filled', '');
const words = withoutFilledSuffix.split('-');
const sentence = words
.map((word) => {
const trimWord = word.trim();
const digitLessWord = trimWord.replace(/\d+/g, '');

if (digitLessWord.length === 0) {
return trimWord;
}

return digitLessWord;
})
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');

return sentence;
};

export const getFallbackLabelFromIconName = (iconName: string) => {
if (!iconName) {
return 'Unknown';
}

if (isHttpUrl(iconName)) {
return 'Unknown';
}

if (isSvgDataUrl(iconName)) {
return 'Unknown';
}

const label = kebabCaseToUpperCaseSentence(iconName);

if (label.length === 0) {
return 'Unknown';
}

return label;
};

export const a11yHostAttributes = (
hostElement: HTMLElement,
ignoreAttributes: A11yAttributeName[] = []
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/components/utils/condition-checks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2023 Siemens AG
*
* SPDX-License-Identifier: MIT
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
export const isHttpUrl = (link: string) => {
if (!link) {
return false;
}

let url: URL;

try {
url = new URL(link);
} catch (e) {
return false;
}

return url.protocol === 'http:' || url.protocol === 'https:';
};

export const isSvgDataUrl = (url: string) => {
if (!url) {
return false;
}

if (typeof url !== 'string') {
return false;
}

return url.startsWith('data:image/svg+xml');
};
1 change: 1 addition & 0 deletions packages/vue/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ export const IxGroupItem = /*@__PURE__*/ defineContainer<JSX.IxGroupItem>('ix-gr


export const IxIconButton = /*@__PURE__*/ defineContainer<JSX.IxIconButton>('ix-icon-button', defineIxIconButton, [
'a11yLabel',
'variant',
'outline',
'ghost',
Expand Down