Skip to content
Open
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
61 changes: 38 additions & 23 deletions core/src/components/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, Watch, State, forceUpdate, h } from '@stencil/core';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, hasShadowDom } from '@utils/helpers';
import {
inheritAriaAttributes,
hasShadowDom,
watchForAriaAttributeChanges,
type AttributeWatcher,
} from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import { createColorClasses, hostContext, openURL } from '@utils/theme';

Expand Down Expand Up @@ -35,6 +40,7 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
private formButtonEl: HTMLButtonElement | null = null;
private formEl: HTMLFormElement | null = null;
private inheritedAttributes: Attributes = {};
private ariaWatcher?: AttributeWatcher;

@Element() el!: HTMLElement;

Expand Down Expand Up @@ -158,27 +164,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 +205,37 @@ 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);
}

connectedCallback() {
/**
* Must run before watchForAriaAttributeChanges: it calls removeAttribute
* internally to strip the host's initial values, and that call must
* happen before removeAttribute is patched below — otherwise this
* strip would itself be treated as an external removal.
*/
this.inheritedAttributes = inheritAriaAttributes(this.el, ['aria-disabled']);

/**
* Keeps inherited ARIA attributes in sync with the host element for the
* lifetime of the component, not just at initial load. `aria-disabled` is excluded here
* (and from the initial inheritAriaAttributes call above) because button.tsx sets
* it itself on Host based on the `disabled` prop.
*/

this.ariaWatcher = watchForAriaAttributeChanges(
Comment thread
Zac-Smucker-Bryan marked this conversation as resolved.
this.el,
(changed) => {
this.inheritedAttributes = { ...this.inheritedAttributes, ...changed };
forceUpdate(this);
},
['aria-disabled']
);
}

disconnectedCallback() {
Comment thread
Zac-Smucker-Bryan marked this conversation as resolved.
this.ariaWatcher?.destroy();
this.ariaWatcher = undefined;
}

private get hasIconOnly() {
Expand Down
106 changes: 106 additions & 0 deletions core/src/components/button/test/a11y/button.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AxeBuilder from '@axe-core/playwright';
import { expect } from '@playwright/test';
import { ariaAttributes } from '@utils/helpers';
import { configs, test } from '@utils/test/playwright';

configs({ directions: ['ltr'], palettes: ['light', 'dark'] }).forEach(({ title, config }) => {
Expand Down Expand Up @@ -148,3 +149,108 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
});

configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('button: aria attribute sync'), () => {
// aria-disabled is excluded because button.tsx manages it internally via the `disabled` prop.
const watchedAriaAttributes = ariaAttributes.filter((attr) => attr !== 'aria-disabled');

for (const attr of watchedAriaAttributes) {
test(`native button updates ${attr} when host attribute changes`, async ({ page }) => {
Comment thread
Zac-Smucker-Bryan marked this conversation as resolved.
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('does not sync aria-disabled, since button.tsx manages it internally', async ({ page }) => {
test
.info()
.annotations.push({ type: 'issue', description: 'https://github.com/ionic-team/ionic-framework/issues/30626' });

await page.setContent(`<ion-button aria-disabled="true">Button</ion-button>`, config);

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

await expect(nativeButton).not.toHaveAttribute('aria-disabled', 'true');
});

test('aria sync survives detach and reattach', async ({ page }) => {
await page.setContent(
`
<div id="container">
<ion-button aria-label="label">Button</ion-button>
</div>
`,
config
);

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

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

// Detach and reattach
await host.evaluate((buttonEl) => {
const parent = buttonEl.parentElement!;
parent.removeChild(buttonEl);
parent.appendChild(buttonEl);
});

await host.evaluate((el) => el.setAttribute('aria-label', 'updated'));
await expect(nativeButton).toHaveAttribute('aria-label', 'updated');
});

test('helper strips host attribute and syncs native element through set, empty, and remove', async ({ page }) => {
page.on('console', (msg) => {
console.log(`[browser] ${msg.type()}: ${msg.text()}`);
});

await page.setContent(
`
<ion-button aria-label="initial">Button</ion-button>
`,
config
);

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

// Initial load: inheritAriaAttributes should have stripped aria-label
// from the host and copied it onto the native button.
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeButton).toHaveAttribute('aria-label', 'initial');

// Setting a new value on the host: watcher should capture it, sync it
// to native, and re-strip it from the host.
await host.evaluate((el) => el.setAttribute('aria-label', 'second'));
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeButton).toHaveAttribute('aria-label', 'second');

// Setting to empty string: empty string is a valid, non-null value.
await host.evaluate((el) => el.setAttribute('aria-label', ''));
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeButton).toHaveAttribute('aria-label', '');

// Removing the attribute directly: the patched removeAttribute should
// fire onChange with null, which should remove aria-label from native
// and host.
await host.evaluate((el) => el.removeAttribute('aria-label'));
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeButton).not.toHaveAttribute('aria-label');
});
});
});
19 changes: 16 additions & 3 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 { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAttributes } from '@utils/helpers';
import type { Attributes, AttributeWatcher } from '@utils/helpers';
import { inheritAttributes, watchAttributes } from '@utils/helpers';
import { createColorClasses, openURL } from '@utils/theme';

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

@Element() el!: HTMLElement;
/**
Expand Down Expand Up @@ -91,6 +92,18 @@ export class Card implements ComponentInterface, AnchorInterface, ButtonInterfac
this.inheritedAriaAttributes = inheritAttributes(this.el, ['aria-label']);
}

connectedCallback() {
this.ariaWatcher = watchAttributes(this.el, ['aria-label'], (changed) => {
this.inheritedAriaAttributes = { ...this.inheritedAriaAttributes, ...changed };
forceUpdate(this);
});
}

disconnectedCallback() {
this.ariaWatcher?.destroy();
this.ariaWatcher = undefined;
}

private isClickable(): boolean {
return this.href !== undefined || this.button;
}
Expand Down
74 changes: 74 additions & 0 deletions core/src/components/card/test/a11y/card.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,77 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
});

configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('card: aria attribute sync'), () => {
test('aria sync survives detach and reattach', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30626',
});

await page.setContent(
`
<div id="container">
<ion-card button="true" aria-label="label">Card</ion-card>
</div>
`,
config
);

const host = page.locator('ion-card');
const nativeCard = host.locator('[part="native"]');

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

// Detach and reattach
await host.evaluate((cardEl) => {
const parent = cardEl.parentElement!;
parent.removeChild(cardEl);
parent.appendChild(cardEl);
});

await host.evaluate((el) => el.setAttribute('aria-label', 'updated'));
await expect(nativeCard).toHaveAttribute('aria-label', 'updated');
});

test('helper strips host attribute and syncs native element through set, empty, and remove', async ({ page }) => {
page.on('console', (msg) => {
console.log(`[browser] ${msg.type()}: ${msg.text()}`);
});

await page.setContent(
`
<ion-card button="true" aria-label="initial">Button</ion-button>
`,
config
);

const host = page.locator('ion-card');
const nativeButton = host.locator('[part="native"]');

// Initial load: inheritAriaAttributes should have stripped aria-label
// from the host and copied it onto the native element.
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeButton).toHaveAttribute('aria-label', 'initial');

// Setting a new value on the host: watcher should capture it, sync it
// to native, and re-strip it from the host.
await host.evaluate((el) => el.setAttribute('aria-label', 'second'));
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeButton).toHaveAttribute('aria-label', 'second');

// Setting to empty string: empty string is a valid, non-null value.
await host.evaluate((el) => el.setAttribute('aria-label', ''));
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeButton).toHaveAttribute('aria-label', '');

// Removing the attribute directly: the patched removeAttribute should
// fire onChange with null, which should remove aria-label from native
// and host.
await host.evaluate((el) => el.removeAttribute('aria-label'));
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeButton).not.toHaveAttribute('aria-label');
});
});
});
23 changes: 19 additions & 4 deletions core/src/components/item/item.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Listen, Prop, State, Watch, forceUpdate, h } from '@stencil/core';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAttributes, raf } from '@utils/helpers';
import type { Attributes, AttributeWatcher } from '@utils/helpers';
import { inheritAttributes, watchAttributes, raf } from '@utils/helpers';
import { createColorClasses, hostContext, openURL } from '@utils/theme';
import { chevronForward } from 'ionicons/icons';

Expand Down Expand Up @@ -34,6 +34,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
private labelColorStyles = {};
private itemStyles = new Map<string, CssClassMap>();
private inheritedAriaAttributes: Attributes = {};
private ariaWatcher?: AttributeWatcher;

@Element() el!: HTMLIonItemElement;

Expand Down Expand Up @@ -164,12 +165,26 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
}
}

componentWillLoad() {}

connectedCallback() {
this.hasStartEl();
}

componentWillLoad() {
// Must run before watchForAriaAttributeChanges: it calls removeAttribute
// internally to strip the host's initial values, and that call must
// happen before removeAttribute is patched below — otherwise this
// strip would itself be treated as an external removal.
this.inheritedAriaAttributes = inheritAttributes(this.el, ['aria-label']);

this.ariaWatcher = watchAttributes(this.el, ['aria-label'], (changed) => {
this.inheritedAriaAttributes = { ...this.inheritedAriaAttributes, ...changed };
forceUpdate(this);
});
}

disconnectedCallback() {
this.ariaWatcher?.destroy();
this.ariaWatcher = undefined;
}

componentDidLoad() {
Expand Down
Loading