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
55 changes: 32 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,31 @@ 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);
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. This replaces the
* previous approach of manually re-declaring @Watch for each aria attribute
* that could change post-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(
this.el,
(changed) => {
this.inheritedAttributes = { ...this.inheritedAttributes, ...changed };
forceUpdate(this);
},
['aria-disabled']
);
}

// Prevents
disconnectedCallback() {
this.ariaWatcher?.disconnect();
this.ariaWatcher = undefined;
}

private get hasIconOnly() {
Expand Down
34 changes: 34 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,36 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
});
});
});

configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('button: aria attribute sync'), () => {
// Mirrors the ignoreList passed to inheritAriaAttributes/watchForAriaAttributeChanges
// in button.tsx. 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 }) => {
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 }) => {
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');
});
});
});
72 changes: 71 additions & 1 deletion core/src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export const inheritAttributes = (el: HTMLElement, attributes: string[] = []) =>
* Removed deprecated attributes.
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes
*/
const ariaAttributes = [
export const ariaAttributes = [
'role',
'aria-activedescendant',
'aria-atomic',
Expand Down Expand Up @@ -190,6 +190,76 @@ export const inheritAriaAttributes = (el: HTMLElement, ignoreList?: string[]) =>
return inheritAttributes(el, attributesToInherit);
};

export interface AttributeWatcher {
disconnect: () => void;
}

/**
* Watches an element for changes to a given set of attributes and calls
* onChange whenever one of them is set. Because inheritAttributes() strips
* the attribute from the host as it reads it, any subsequent mutation is
* just checked that the new value isn't null.
*/
export const watchAttributes = (
el: HTMLElement,
attributes: string[],
onChange: (changed: { [k: string]: string }) => void
): AttributeWatcher => {
if (typeof MutationObserver === 'undefined') {
// Not available in Stencil's mock-doc test environment (used by
// `stencil test --spec`), and, as a defensive fallback, environments
// without native MutationObserver support.
return { disconnect: () => {} };
}

// Set up mutation observer to observe attribute changes
const observer = new MutationObserver((mutations) => {
const changed: { [k: string]: string } = {};
for (const mutation of mutations) {
if (mutation.type !== 'attributes' || !mutation.attributeName) continue;
const name = mutation.attributeName;
if (!attributes.includes(name)) continue;
const value = el.getAttribute(name);
if (value === null) continue;
changed[name] = value;
}

// If attribute changes, re-strip so the value doesn't live on both host
// and native element.
if (Object.keys(changed).length > 0) {
Object.keys(changed).forEach((name) => el.removeAttribute(name));
onChange(changed);
}
});

// Watch for attribute changes on this element
observer.observe(el, { attributes: true, attributeFilter: attributes });

// Stop watching, called by `disconnectedCallback`
return { disconnect: () => observer.disconnect() };
};

/**
* Watches an element for changes to ARIA attributes (and `role`) and invokes
* a callback whenever one is set externally, so that inherited ARIA state
* stays in sync for the lifetime of the component — not just at initial load.
*
* This should be called once in componentWillLoad, alongside the initial
* call to inheritAriaAttributes, and the returned AttributeWatcher must be
* disconnected in disconnectedCallback to avoid leaking the observer.
*/
export const watchForAriaAttributeChanges = (
el: HTMLElement,
onChange: (changed: { [k: string]: string }) => void,
ignoreList?: string[]
): AttributeWatcher => {
let attributesToWatch = ariaAttributes;
if (ignoreList && ignoreList.length > 0) {
attributesToWatch = attributesToWatch.filter((attr) => !ignoreList.includes(attr));
}
return watchAttributes(el, attributesToWatch, onChange);
};

export const addEventListener = (el: any, eventName: string, callback: any, opts?: any) => {
return el.addEventListener(eventName, callback, opts);
};
Expand Down