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: 39 additions & 22 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,8 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
private formButtonEl: HTMLButtonElement | null = null;
private formEl: HTMLFormElement | null = null;
private inheritedAttributes: Attributes = {};
private didLoad = false;
private ariaWatcher?: AttributeWatcher;

@Element() el!: HTMLElement;

Expand Down Expand Up @@ -158,27 +165,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 @@ -223,6 +209,37 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf
this.inheritedAttributes = inheritAriaAttributes(this.el);
}

connectedCallback() {
Comment thread
Zac-Smucker-Bryan marked this conversation as resolved.
// Only run the initial snapshot once. On subsequent reconnects the
// host has already been stripped, so inheritAriaAttributes would
// return {} and overwrite previously captured values.

if (this.didLoad) {
this.startAriaWatcher();
}
}

componentDidLoad() {
this.didLoad = true;
this.startAriaWatcher();
}

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

private startAriaWatcher() {
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']
);
}

private get hasIconOnly() {
return !!this.el.querySelector('[slot="icon-only"]');
}
Expand Down
111 changes: 111 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,114 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
});

configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('button: aria attribute sync'), () => {
const watchedAriaAttributes = ['aria-checked', 'aria-label', 'aria-pressed', 'aria-description'];

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('should not sync aria-disabled from 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 aria-disabled="true">Button</ion-button>`, config);

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

// Initial inheritance moves the developer-provided value to native.
// The host's aria-disabled is subsequently owned by the disabled prop.
await expect(host).not.toHaveAttribute('aria-disabled');
await expect(nativeButton).toHaveAttribute('aria-disabled', 'true');
});

test('preserves inherited aria-label after 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-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, reattach, and force a render via a prop change.
await host.evaluate((el) => {
const parent = el.parentElement!;
parent.removeChild(el);
parent.appendChild(el);
(el as HTMLIonButtonElement).color = 'primary';
});

// Assert the original value survived
await expect(nativeButton).toHaveAttribute('aria-label', 'label');
});

test('syncs aria-label updates and removal after initial inheritance', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30626',
});

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

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

// Initial inheritance moves the value from the host to the native button.
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeButton).toHaveAttribute('aria-label', 'initial');

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

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

// Native MutationObserver behavior sees a real removal after a post-load write.
await host.evaluate((el) => el.removeAttribute('aria-label'));
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeButton).not.toHaveAttribute('aria-label');
});
});
});
37 changes: 35 additions & 2 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 { inheritAttributes, watchForAriaAttributeChanges, type AttributeWatcher } from '@utils/helpers';
import { createColorClasses, openURL } from '@utils/theme';

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

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

connectedCallback() {
Comment thread
Zac-Smucker-Bryan marked this conversation as resolved.
// Only run the initial snapshot once. On subsequent reconnects the
// host has already been stripped, so inheritAriaAttributes would
// return {} and overwrite previously captured values.

if (this.didLoad) {
this.startAriaWatcher();
}
}

componentDidLoad() {
this.didLoad = true;
this.startAriaWatcher();
}

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

private startAriaWatcher() {
this.ariaWatcher = watchForAriaAttributeChanges(
this.el,
(changed) => {
this.inheritedAriaAttributes = { ...this.inheritedAriaAttributes, ...changed };
forceUpdate(this);
},
['aria-disabled']
);
}

private isClickable(): boolean {
return this.href !== undefined || this.button;
}
Expand Down
94 changes: 94 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,97 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
});

configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('item: aria attribute sync'), () => {
test('native element updates aria-label when host attribute changes', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30626',
});

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

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

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

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

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

test('preserves inherited aria-label after 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, reattach, and force a render via a prop change.
await host.evaluate((itemEl) => {
const parent = itemEl.parentElement!;
parent.removeChild(itemEl);
parent.appendChild(itemEl);
(itemEl as HTMLIonButtonElement).color = 'primary';
});

// Assert the original value survived
await expect(nativeCard).toHaveAttribute('aria-label', 'label');
});

test('syncs aria-label updates and removal after initial inheritance', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/ionic-team/ionic-framework/issues/30626',
});
await page.setContent(
`
<ion-card button="true" aria-label="initial">Card</ion-card>
`,
config
);

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

// Initial inheritance moves the value from the host to the native button.
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeCard).toHaveAttribute('aria-label', 'initial');

// Post-load writes remain on the host and are synchronized to native
await host.evaluate((el) => el.setAttribute('aria-label', 'second'));
await expect(host).toHaveAttribute('aria-label');
await expect(nativeCard).toHaveAttribute('aria-label', 'second');

// An empty string is a valid ARIA attribute value and remains synchronized.
await host.evaluate((el) => el.setAttribute('aria-label', ''));
await expect(host).toHaveAttribute('aria-label');
await expect(nativeCard).toHaveAttribute('aria-label', '');

// Native MutationObserver behavior sees a real removal after a post-load write.
await host.evaluate((el) => el.removeAttribute('aria-label'));
await expect(host).not.toHaveAttribute('aria-label');
await expect(nativeCard).not.toHaveAttribute('aria-label');
});
});
});
Loading