Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
64 changes: 24 additions & 40 deletions core/src/components/button/button.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, Watch, State, forceUpdate, h } from '@stencil/core';
import type { AttributeController } from '@utils/attribute-controller';
import { createAriaAttributeController } from '@utils/attribute-controller';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, hasShadowDom } from '@utils/helpers';
import { hasShadowDom } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import { createColorClasses, hostContext, openURL } from '@utils/theme';

Expand Down Expand Up @@ -34,7 +35,7 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
private inToolbar = false;
private formButtonEl: HTMLButtonElement | null = null;
private formEl: HTMLFormElement | null = null;
private inheritedAttributes: Attributes = {};
private ariaController?: AttributeController;

@Element() el!: HTMLElement;

Expand Down Expand Up @@ -158,27 +159,6 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
*/
@Event() ionBlur!: EventEmitter<void>;

/**
* This component is used within the `ion-input-password-toggle` component
* to toggle the visibility of the password input.
* These attributes need to update based on the state of the password input.
* Otherwise, the values will be stale.
*
* @param newValue
* @param _oldValue
* @param propName
*/
@Watch('aria-checked')
@Watch('aria-label')
@Watch('aria-pressed')
onAriaChanged(newValue: string, _oldValue: string, propName: string) {
this.inheritedAttributes = {
...this.inheritedAttributes,
[propName]: newValue,
};
forceUpdate(this);
}

/**
* This is responsible for rendering a hidden native
* button element inside the associated form. This allows
Expand Down Expand Up @@ -220,7 +200,24 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
this.inToolbar = !!this.el.closest('ion-buttons');
this.inListHeader = !!this.el.closest('ion-list-header');
this.inItem = !!this.el.closest('ion-item') || !!this.el.closest('ion-item-divider');
this.inheritedAttributes = inheritAriaAttributes(this.el);

/**
* The ARIA state has to stay live, since `ion-input-password-toggle` rewrites
* `aria-label` and `aria-pressed` on its `ion-button` on every toggle. We keep
* `aria-disabled` out of the watch because the `<Host>` below renders it from the
* `disabled` prop and those writes would clobber a developer's value, and `role` out
* because a post-load write stays on the host too, which would put the same role on
* two elements in the accessibility tree.
*/
this.ariaController = createAriaAttributeController(this.el, () => forceUpdate(this), ['aria-disabled', 'role']);
}

connectedCallback() {
this.ariaController?.init();
}

disconnectedCallback() {
this.ariaController?.destroy();
}

private get hasIconOnly() {
Expand Down Expand Up @@ -339,21 +336,8 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf

render() {
const mode = getIonMode(this);
const {
buttonType,
type,
disabled,
rel,
target,
size,
href,
color,
expand,
hasIconOnly,
shape,
strong,
inheritedAttributes,
} = this;
const { buttonType, type, disabled, rel, target, size, href, color, expand, hasIconOnly, shape, strong } = this;
const inheritedAttributes = this.ariaController?.attributes ?? {};
const finalSize = size === undefined && this.inItem ? 'small' : size;
const TagType = href === undefined ? 'button' : ('a' as any);
const attrs =
Expand Down
173 changes: 173 additions & 0 deletions core/src/components/button/test/a11y/button.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,176 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
});

/**
* Attribute syncing does not vary across modes or directions
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('button: aria attribute sync'), () => {
/**
* A sample rather than the full ARIA list, since they all go through the same
* membership check and looping every one of them only multiplies the run time.
*/
const ariaAttributes = ['aria-checked', 'aria-label', 'aria-pressed', 'aria-description'];

for (const attr of ariaAttributes) {
test(`should sync ${attr} to the native button when it changes on the host`, async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30626',
});

await page.setContent(`<ion-button ${attr}="initial">Button</ion-button>`, config);

const host = page.locator('ion-button');
const nativeButton = host.locator('button');

await expect(nativeButton).toHaveAttribute(attr, 'initial');

await host.evaluate((el, attr) => el.setAttribute(attr, 'updated'), attr);

await expect(nativeButton).toHaveAttribute(attr, 'updated');
});
}

test('should not sync aria-disabled from the host', async ({ page }) => {
await page.setContent(`<ion-button aria-disabled="true">Button</ion-button>`, config);

const host = page.locator('ion-button');
const nativeButton = host.locator('button');

// The developer-provided value is still copied to the native button at load.
await expect(nativeButton).toHaveAttribute('aria-disabled', 'true');

// The host's `aria-disabled` belongs to the `disabled` prop from here on, so later
// writes to it must not reach the native button. We write `aria-label` in the same
// batch as a barrier, since once that lands the sync has run.
await host.evaluate((el) => {
el.setAttribute('aria-disabled', 'false');
el.setAttribute('aria-label', 'barrier');
});
await expect(nativeButton).toHaveAttribute('aria-label', 'barrier');
await expect(nativeButton).toHaveAttribute('aria-disabled', 'true');

// Toggling disabled makes the component write and then clear aria-disabled on the
// host. Neither write should reach the native button.
await host.evaluate((el: HTMLIonButtonElement) => {
el.disabled = true;
el.setAttribute('aria-label', 'disabled');
});
await expect(nativeButton).toHaveAttribute('aria-label', 'disabled');
await expect(nativeButton).toHaveAttribute('aria-disabled', 'true');

await host.evaluate((el: HTMLIonButtonElement) => {
el.disabled = false;
el.setAttribute('aria-label', 'enabled');
});
await expect(nativeButton).toHaveAttribute('aria-label', 'enabled');
await expect(nativeButton).toHaveAttribute('aria-disabled', 'true');
Comment thread
ShaneK marked this conversation as resolved.
});

test('should not sync role from the host', async ({ page }) => {
await page.setContent(`<ion-button role="switch">Button</ion-button>`, config);

const host = page.locator('ion-button');
const nativeButton = host.locator('button');

// The initial copy moves role onto the native button, as it always has.
await expect(nativeButton).toHaveAttribute('role', 'switch');

// A later write is only read, so it stays on the host. Copying it as well would put
// the same role on both elements, and two of that role in the accessibility tree.
await host.evaluate((el) => {
el.setAttribute('role', 'checkbox');
el.setAttribute('aria-label', 'barrier');
});
await expect(nativeButton).toHaveAttribute('aria-label', 'barrier');
await expect(nativeButton).toHaveAttribute('role', 'switch');
});

test('should keep syncing after the button is detached and reattached', async ({ page }) => {
await page.setContent(
`
<div id="container">
<ion-button aria-description="described">Button</ion-button>
</div>
`,
config
);

const host = page.locator('ion-button');
const nativeButton = host.locator('button');

await expect(nativeButton).toHaveAttribute('aria-description', 'described');

await host.evaluate((el) => {
const parent = el.parentElement!;
parent.removeChild(el);
parent.appendChild(el);
});
await page.waitForChanges();

// The value captured at load survives the move.
await expect(nativeButton).toHaveAttribute('aria-description', 'described');

// Updates made after the move must still reach the native button.
await host.evaluate((el) => el.setAttribute('aria-description', 'updated'));
await expect(nativeButton).toHaveAttribute('aria-description', 'updated');

// So must one made while it was detached, when nothing is watching.
await host.evaluate((el) => {
const parent = el.parentElement!;
parent.removeChild(el);
el.setAttribute('aria-description', 'while detached');
parent.appendChild(el);
});
await expect(nativeButton).toHaveAttribute('aria-description', 'while detached');
});

test('should sync updates, empty values and removals after the initial copy', async ({ page }) => {
await page.setContent(`<ion-button aria-description="initial">Button</ion-button>`, config);

const host = page.locator('ion-button');
const nativeButton = host.locator('button');

// The initial copy moves the value from the host to the native button.
await expect(host).not.toHaveAttribute('aria-description');
await expect(nativeButton).toHaveAttribute('aria-description', 'initial');

// Post-load writes stay on the host and are copied to the native button.
await host.evaluate((el) => el.setAttribute('aria-description', 'second'));
await expect(host).toHaveAttribute('aria-description', 'second');
await expect(nativeButton).toHaveAttribute('aria-description', 'second');

// An empty string is a valid ARIA attribute value.
await host.evaluate((el) => el.setAttribute('aria-description', ''));
await expect(nativeButton).toHaveAttribute('aria-description', '');

// A removal of a post-load write does reach the native button.
await host.evaluate((el) => el.removeAttribute('aria-description'));
await expect(host).not.toHaveAttribute('aria-description');
await expect(nativeButton).not.toHaveAttribute('aria-description');
});

test('should keep a value from the initial markup when the host attribute is removed', async ({ page }) => {
await page.setContent(`<ion-button aria-label="initial">Button</ion-button>`, config);

const host = page.locator('ion-button');
const nativeButton = host.locator('button');

await expect(nativeButton).toHaveAttribute('aria-label', 'initial');

// The initial copy already took the attribute off the host, so removing it there
// changes nothing and the native button keeps the copied value. Setting an empty
// value is how you clear one of these.
await host.evaluate((el) => el.removeAttribute('aria-label'));

// Force a render and wait for it, otherwise the assertion passes on a button that
// never re-rendered at all.
await host.evaluate((el: HTMLIonButtonElement) => (el.color = 'primary'));
await expect(host).toHaveClass(/ion-color-primary/);

await expect(nativeButton).toHaveAttribute('aria-label', 'initial');
});
});
});
26 changes: 20 additions & 6 deletions core/src/components/card/card.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { ComponentInterface } from '@stencil/core';
import { Element, Component, Host, Prop, h } from '@stencil/core';
import { Element, Component, Host, Prop, h, forceUpdate } from '@stencil/core';
import type { AttributeController } from '@utils/attribute-controller';
import { createAttributeController } from '@utils/attribute-controller';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAttributes } from '@utils/helpers';
import { createColorClasses, openURL } from '@utils/theme';

import { getIonMode } from '../../global/ionic-global';
Expand All @@ -23,7 +23,7 @@ import type { RouterDirection } from '../router/utils/interface';
shadow: true,
})
export class Card implements ComponentInterface, AnchorInterface, ButtonInterface {
private inheritedAriaAttributes: Attributes = {};
private ariaController?: AttributeController;

@Element() el!: HTMLElement;
/**
Expand Down Expand Up @@ -88,7 +88,20 @@ export class Card implements ComponentInterface, AnchorInterface, ButtonInterfac
@Prop() target: string | undefined;

componentWillLoad() {
this.inheritedAriaAttributes = inheritAttributes(this.el, ['aria-label']);
/**
* Only the initial copy takes the attribute off the host, so an `aria-label` written
* after load stays on the host too. That's harmless here, because unlike `ion-item`
* the card host renders no role of its own, so nothing reads the leftover copy.
*/
this.ariaController = createAttributeController(this.el, ['aria-label'], () => forceUpdate(this));
Comment thread
ShaneK marked this conversation as resolved.
}

connectedCallback() {
Comment thread
Zac-Smucker-Bryan marked this conversation as resolved.
this.ariaController?.init();
}

disconnectedCallback() {
this.ariaController?.destroy();
}

private isClickable(): boolean {
Expand All @@ -101,7 +114,8 @@ export class Card implements ComponentInterface, AnchorInterface, ButtonInterfac
if (!clickable) {
return [<slot></slot>];
}
const { href, routerAnimation, routerDirection, inheritedAriaAttributes } = this;
const { href, routerAnimation, routerDirection } = this;
const inheritedAriaAttributes = this.ariaController?.attributes ?? {};
const TagType = clickable ? (href === undefined ? 'button' : 'a') : ('div' as any);
const attrs =
TagType === 'button'
Expand Down
Loading
Loading