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/7] 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/7] 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/7] 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/7] 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)); From a906acbf83860a597e5bef13688feca109913493 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 5 Aug 2026 11:35:02 -0500 Subject: [PATCH 5/7] fix(helper): update Mutation Observer and add removeAttribute intercept to helper Change disconnect to destroy to match other ionic conventions Update onChange to accept null values Add support for removeAttribute, including if null values triggered --- core/src/utils/helpers.ts | 44 ++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index 4bfea2d180d..cb8935b4484 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -191,7 +191,7 @@ export const inheritAriaAttributes = (el: HTMLElement, ignoreList?: string[]) => }; export interface AttributeWatcher { - disconnect: () => void; + destroy: () => void; } /** @@ -203,15 +203,22 @@ export interface AttributeWatcher { export const watchAttributes = ( el: HTMLElement, attributes: string[], - onChange: (changed: { [k: string]: string }) => void + onChange: (changed: { [k: string]: string | null }) => 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: () => {} }; + return { destroy: () => {} }; } + // Keep a reference to the browser's original implementation. + // removeAttribute is patched below because MutationObserver cannot + // observe removeAttribute() calls once inheritAttributes() has + // already stripped the attribute from the host. In that case the + // browser performs no DOM mutation and emits no MutationRecord. + const originalRemoveAttribute = el.removeAttribute.bind(el); + // Set up mutation observer to observe attribute changes const observer = new MutationObserver((mutations) => { const changed: { [k: string]: string } = {}; @@ -223,11 +230,12 @@ export const watchAttributes = ( 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)); + // Use the original implementation here. Calling the patched + // removeAttribute would recursively invoke onChange() with + // { [name]: null }, even though we are only stripping the host + // after synchronizing a new value. + Object.keys(changed).forEach((name) => originalRemoveAttribute(name)); onChange(changed); } }); @@ -235,8 +243,24 @@ export const watchAttributes = ( // Watch for attribute changes on this element observer.observe(el, { attributes: true, attributeFilter: attributes }); - // Stop watching, called by `disconnectedCallback` - return { disconnect: () => observer.disconnect() }; + // Intercept removeAttribute so we can notify consumers when an + // already-synced attribute is explicitly cleared. + el.removeAttribute = (name: string) => { + if (attributes.includes(name)) { + originalRemoveAttribute(name); + onChange({ [name]: null }); + return; + } + originalRemoveAttribute(name); + }; + + // Stop watching. Call this from `disconnectedCallback`. + return { + destroy: () => { + observer.disconnect(); + el.removeAttribute = originalRemoveAttribute; + }, + }; }; /** @@ -250,7 +274,7 @@ export const watchAttributes = ( */ export const watchForAriaAttributeChanges = ( el: HTMLElement, - onChange: (changed: { [k: string]: string }) => void, + onChange: (changed: { [k: string]: string | null }) => void, ignoreList?: string[] ): AttributeWatcher => { let attributesToWatch = ariaAttributes; From 4f7868636e9816266998d30831a31072df83fdb6 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 5 Aug 2026 11:37:40 -0500 Subject: [PATCH 6/7] fix(button): Add helper to ion-item and ion-card, move inheritedAriaAttributes to connectedCallback Import helper to ion-item and ion-card move inheritedAriaAttributes to connectedCallback in these components to preserve helper call order --- core/src/components/button/button.tsx | 22 ++++++++++++++-------- core/src/components/card/card.tsx | 19 ++++++++++++++++--- core/src/components/item/item.tsx | 23 +++++++++++++++++++---- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index 94c29c16bbc..67daa2480d1 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -205,17 +205,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'); + } + + 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. 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 + * 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( this.el, (changed) => { @@ -226,9 +233,8 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf ); } - // Prevents disconnectedCallback() { - this.ariaWatcher?.disconnect(); + this.ariaWatcher?.destroy(); this.ariaWatcher = undefined; } diff --git a/core/src/components/card/card.tsx b/core/src/components/card/card.tsx index 68e21d0ba5a..17510a5eeb1 100644 --- a/core/src/components/card/card.tsx +++ b/core/src/components/card/card.tsx @@ -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'; @@ -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; /** @@ -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; } diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index 9e268e972f8..0916f62e825 100644 --- a/core/src/components/item/item.tsx +++ b/core/src/components/item/item.tsx @@ -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'; @@ -34,6 +34,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac private labelColorStyles = {}; private itemStyles = new Map(); private inheritedAriaAttributes: Attributes = {}; + private ariaWatcher?: AttributeWatcher; @Element() el!: HTMLIonItemElement; @@ -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() { From b585cd55b0d2ced959fef4b938551cc3283cb0ff Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 5 Aug 2026 11:39:57 -0500 Subject: [PATCH 7/7] test(button): Add e2e tests Update tests for ion-button with annotations Add tests for removeAttribute and attribute sync to ion-button, ion-card, and ion-item --- .../components/button/test/a11y/button.e2e.ts | 78 +++++++++++++++- .../src/components/card/test/a11y/card.e2e.ts | 74 +++++++++++++++ .../src/components/item/test/a11y/item.e2e.ts | 91 +++++++++++++++++++ 3 files changed, 240 insertions(+), 3 deletions(-) diff --git a/core/src/components/button/test/a11y/button.e2e.ts b/core/src/components/button/test/a11y/button.e2e.ts index 6cdd065828f..79674ef0197 100644 --- a/core/src/components/button/test/a11y/button.e2e.ts +++ b/core/src/components/button/test/a11y/button.e2e.ts @@ -152,13 +152,16 @@ 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. + // 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 }) => { + 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'); @@ -173,6 +176,10 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { } 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(`Button`, config); const host = page.locator('ion-button'); @@ -180,5 +187,70 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { await expect(nativeButton).not.toHaveAttribute('aria-disabled', 'true'); }); + + test('aria sync survives detach and reattach', async ({ page }) => { + await page.setContent( + ` +
+ Button +
+ `, + 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( + ` + 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'); + }); }); }); diff --git a/core/src/components/card/test/a11y/card.e2e.ts b/core/src/components/card/test/a11y/card.e2e.ts index 6902037f998..ebb0a7967df 100644 --- a/core/src/components/card/test/a11y/card.e2e.ts +++ b/core/src/components/card/test/a11y/card.e2e.ts @@ -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( + ` +
+ Card +
+ `, + 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( + ` + 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'); + }); + }); +}); diff --git a/core/src/components/item/test/a11y/item.e2e.ts b/core/src/components/item/test/a11y/item.e2e.ts index 20536beb71a..72581a7af54 100644 --- a/core/src/components/item/test/a11y/item.e2e.ts +++ b/core/src/components/item/test/a11y/item.e2e.ts @@ -153,3 +153,94 @@ configs({ directions: ['ltr'] }).forEach(({ config, screenshot, title }) => { }); }); }); + +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( + ` + Item + `, + config + ); + + const host = page.locator('ion-item'); + const nativeItem = host.locator('[part="native"]'); + + await expect(nativeItem).toHaveAttribute('aria-label', 'label'); + + await host.evaluate((el) => el.setAttribute('aria-label', 'updated')); + + await expect(nativeItem).toHaveAttribute('aria-label', 'updated'); + }); + + test('aria-label sync survives detach and reattach', async ({ page }) => { + await page.setContent( + ` +
+ Item +
+ `, + config + ); + + const host = page.locator('ion-item'); + const nativeItem = host.locator('[part="native"]'); + + await expect(nativeItem).toHaveAttribute('aria-label', 'label'); + + await host.evaluate((itemEl) => { + const parent = itemEl.parentElement!; + parent.removeChild(itemEl); + parent.appendChild(itemEl); + }); + + await host.evaluate((el) => el.setAttribute('aria-label', 'updated')); + await expect(nativeItem).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( + ` + Button + `, + config + ); + + const host = page.locator('ion-item'); + 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'); + }); + }); +});