From 495ca01bb5dfb2ba32eb7554c7b1030e37fdb117 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Fri, 10 Jul 2026 16:53:53 -0500 Subject: [PATCH 1/4] fix(button): sync aria description between host and native button Adds @Watch('aria-description') to button.tsx before onAriaChanged --- core/src/components/button/button.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index a1e7f72bf01..59e46bd2a3a 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -171,6 +171,7 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf @Watch('aria-checked') @Watch('aria-label') @Watch('aria-pressed') + @Watch('aria-description') onAriaChanged(newValue: string, _oldValue: string, propName: string) { this.inheritedAttributes = { ...this.inheritedAttributes, From a7941a182dcbd0651115444be512fb0d5f95c5de Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Fri, 10 Jul 2026 16:57:48 -0500 Subject: [PATCH 2/4] test(button): add e2e test for aria-description sync Set aria description and both buttons should match. Update aria description on host button, and both buttons should still match. --- .../components/button/test/a11y/button.e2e.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/core/src/components/button/test/a11y/button.e2e.ts b/core/src/components/button/test/a11y/button.e2e.ts index 585c0b5853d..fb712c99abe 100644 --- a/core/src/components/button/test/a11y/button.e2e.ts +++ b/core/src/components/button/test/a11y/button.e2e.ts @@ -148,3 +148,32 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { }); }); }); + +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('button: aria description updates'), () => { + test('native button updates aria-description 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( + ` + Button + `, + config + ); + + const host = page.locator('ion-button'); + const nativeButton = host.locator('button'); + + await expect(nativeButton).toHaveAttribute('aria-description', '0'); + + await host.evaluate((el) => { + el.setAttribute('aria-description', '1'); + }); + + await expect(nativeButton).toHaveAttribute('aria-description', '1'); + }); + }); +}); From 35a82d78616ea0af214dc33effe89e3fcdaf1c0c Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Sat, 25 Jul 2026 16:12:11 -0500 Subject: [PATCH 3/4] fix(button): sync aria attributes to native button reactively Previously, ARIA attributes inherited from the host were only captured once at componentWillLoad. Attributes set or changed after initial load (e.g. by ion-input-password-toggle updating aria-label/aria-pressed as visibility toggles) were not reflected onto the native button, causing screen readers to announce stale values unless a watch decorator was used for each attribute. Adds watchAttributes/watchForAriaAttributeChanges to helpers.ts, which use a MutationObserver to keep inherited ARIA attributes in sync for the lifetime of the component. Replaces the previous per-attribute @Watch decorators with this more general mechanism. Update Button.tsx to reflect this and use these new helpers. Add tests to test syncing all attributes. Fixes #30626 --- core/src/components/button/button.tsx | 51 ++++++------- .../components/button/test/a11y/button.e2e.ts | 41 ++++++----- core/src/utils/helpers.ts | 72 ++++++++++++++++++- 3 files changed, 121 insertions(+), 43 deletions(-) diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index 59e46bd2a3a..af3886277e4 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -2,7 +2,7 @@ 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'; @@ -35,6 +35,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; @@ -158,28 +159,6 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf */ @Event() ionBlur!: EventEmitter; - /** - * 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') - @Watch('aria-description') - 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 @@ -221,7 +200,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() { diff --git a/core/src/components/button/test/a11y/button.e2e.ts b/core/src/components/button/test/a11y/button.e2e.ts index fb712c99abe..6cdd065828f 100644 --- a/core/src/components/button/test/a11y/button.e2e.ts +++ b/core/src/components/button/test/a11y/button.e2e.ts @@ -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 }) => { @@ -150,30 +151,34 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { }); configs({ directions: ['ltr'] }).forEach(({ title, config }) => { - test.describe(title('button: aria description updates'), () => { - test('native button updates aria-description when host attribute changes', async ({ page }) => { - test.info().annotations.push({ - type: 'issue', - description: 'https://github.com/ionic-team/ionic-framework/issues/30626', - }); + 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'); - await page.setContent( - ` - Button - `, - config - ); + for (const attr of watchedAriaAttributes) { + test(`native button updates ${attr} when host attribute changes`, async ({ page }) => { + await page.setContent(`Button`, config); - const host = page.locator('ion-button'); - const nativeButton = host.locator('button'); + const host = page.locator('ion-button'); + const nativeButton = host.locator('button'); + + await expect(nativeButton).toHaveAttribute(attr, 'initial'); - await expect(nativeButton).toHaveAttribute('aria-description', '0'); + await host.evaluate((el, attr) => el.setAttribute(attr, 'updated'), attr); - await host.evaluate((el) => { - el.setAttribute('aria-description', '1'); + await expect(nativeButton).toHaveAttribute(attr, 'updated'); }); + } + + test('does not sync aria-disabled, since button.tsx manages it internally', async ({ page }) => { + await page.setContent(`Button`, config); + + const host = page.locator('ion-button'); + const nativeButton = host.locator('button'); - await expect(nativeButton).toHaveAttribute('aria-description', '1'); + await expect(nativeButton).not.toHaveAttribute('aria-disabled', 'true'); }); }); }); diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index e32956c35eb..2ba5ffcaa71 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -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', @@ -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); }; From 4ed0c4ff3ba200733c5c6a5e29a89e89dbf9b97e Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Mon, 27 Jul 2026 09:09:01 -0500 Subject: [PATCH 4/4] npm run lint.fix --- core/src/components/button/button.tsx | 7 ++++++- core/src/utils/helpers.ts | 16 ++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index af3886277e4..94c29c16bbc 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -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, watchForAriaAttributeChanges, type AttributeWatcher } from '@utils/helpers'; +import { + inheritAriaAttributes, + hasShadowDom, + watchForAriaAttributeChanges, + type AttributeWatcher, +} from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import { createColorClasses, hostContext, openURL } from '@utils/theme'; diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index 2ba5ffcaa71..4bfea2d180d 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -205,13 +205,13 @@ export const watchAttributes = ( 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: () => {} }; - } - + 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 } = {}; @@ -224,7 +224,7 @@ export const watchAttributes = ( changed[name] = value; } - // If attribute changes, re-strip so the value doesn't live on both host + // 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));