From 456c161cb7cc5de9ac3836add32f87fe8d916f9a Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Fri, 10 Jul 2026 16:53:53 -0500 Subject: [PATCH 01/11] 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 d2b73eedb2406748585b27fce53b6f9b8602f8e1 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Fri, 10 Jul 2026 16:57:48 -0500 Subject: [PATCH 02/11] 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 1d0e0161c50c8318742b2f18efd0160032ffd284 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Sat, 25 Jul 2026 16:12:11 -0500 Subject: [PATCH 03/11] 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 9c6052b466f..a0703ff35fd 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -122,7 +122,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', @@ -191,6 +191,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 7210eff90b6bae1e93f5389de7b25c509ca5360f Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Mon, 27 Jul 2026 09:09:01 -0500 Subject: [PATCH 04/11] 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 a0703ff35fd..d16a88fdcb0 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -206,13 +206,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 } = {}; @@ -225,7 +225,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 c014ca59ca1a2569f5d41416ed8ad46308554fb7 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 5 Aug 2026 11:35:02 -0500 Subject: [PATCH 05/11] 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 d16a88fdcb0..643da0d90e2 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -192,7 +192,7 @@ export const inheritAriaAttributes = (el: HTMLElement, ignoreList?: string[]) => }; export interface AttributeWatcher { - disconnect: () => void; + destroy: () => void; } /** @@ -204,15 +204,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 } = {}; @@ -224,11 +231,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); } }); @@ -236,8 +244,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; + }, + }; }; /** @@ -251,7 +275,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 dab84cb002b5f22ff2a933f7f798aab28c376f43 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 5 Aug 2026 11:37:40 -0500 Subject: [PATCH 06/11] 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 | 120 ++++---------------------- 3 files changed, 49 insertions(+), 112 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 45eea6867d3..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 { Build, Component, Element, Host, Listen, Prop, State, Watch, forceUpdate, h } 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'; @@ -10,8 +10,6 @@ import { getIonMode } from '../../global/ionic-global'; import type { AnimationBuilder, Color, CssClassMap, StyleEventDetail } from '../../interface'; import type { RouterDirection } from '../router/utils/interface'; -const INDICATOR_CONTROL_SELECTOR = 'ion-checkbox, ion-radio, ion-toggle'; - /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * @@ -36,15 +34,13 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac private labelColorStyles = {}; private itemStyles = new Map(); private inheritedAriaAttributes: Attributes = {}; - private indicatorControlObserver?: MutationObserver; - private didLoad = false; + private ariaWatcher?: AttributeWatcher; @Element() el!: HTMLIonItemElement; @State() multipleInputs = false; @State() focusable = true; @State() isInteractive = false; - @State() hasSlottedIndicatorControl = false; /** * The color to use from your application's color palette. @@ -169,40 +165,34 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac } } + componentWillLoad() {} + connectedCallback() { this.hasStartEl(); - /** - * `componentDidLoad` doesn't run again when the item is moved, so re-arm the - * observer and re-read the light DOM, which may have changed while detached. - */ - if (this.didLoad) { - this.watchForIndicatorControls(); - this.updateInteractivityOnSlotChange(); - } + // 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); + }); } - componentWillLoad() { - this.inheritedAriaAttributes = inheritAttributes(this.el, ['aria-label']); + disconnectedCallback() { + this.ariaWatcher?.destroy(); + this.ariaWatcher = undefined; } componentDidLoad() { raf(() => { this.setMultipleInputs(); this.setIsInteractive(); - this.setHasSlottedIndicatorControl(); this.focusable = this.isFocusable(); }); - - this.watchForIndicatorControls(); - this.didLoad = true; - } - - disconnectedCallback() { - if (this.indicatorControlObserver) { - this.indicatorControlObserver.disconnect(); - this.indicatorControlObserver = undefined; - } } private totalNestedInputs() { @@ -247,61 +237,10 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac this.isInteractive = covers.length > 0 || inputs.length > 0 || clickables.length > 0; } - /** - * `slotchange` only fires for nodes assigned directly to a slot, so a control - * inside a slotted wrapper (`
`) never reaches - * `updateInteractivityOnSlotChange`. The light DOM is observed instead. - * - * The callback runs the whole handler because a control below a wrapper can also - * make the item multi-input, which is what decides whether the controls draw - * their own indicator at all. - * - * `:host(:has())` would avoid the observer, but the `:has()` fallback in - * `core.scss` is still open as FW-6106 and it's unreliable for slotted content - * in Android WebView. Worth revisiting when FW-6106 closes. - */ - private watchForIndicatorControls() { - if (!Build.isBrowser || typeof MutationObserver === 'undefined') { - return; - } - - // `Node.moveBefore` relocates the item without either callback firing, so - // never leave a previous observer behind - this.indicatorControlObserver?.disconnect(); - - this.indicatorControlObserver = new MutationObserver((records) => { - // The subtree observer also fires for text and hidden input churn, so only - // re-read the DOM when a control was added or removed - if (records.some(touchesIndicatorControl)) { - this.updateInteractivityOnSlotChange(); - } - }); - this.indicatorControlObserver.observe(this.el, { childList: true, subtree: true }); - } - - // These controls paint a focus indicator that overhangs their own bounds, and - // only the default slot is clipped, so only a control there needs extra room. - private setHasSlottedIndicatorControl() { - const controls = this.el.querySelectorAll(INDICATOR_CONTROL_SELECTOR); - - this.hasSlottedIndicatorControl = Array.from(controls).some((control) => { - // The control isn't always a direct child, so walk up to the element the item - // slots, which is the one carrying the slot name. - let slotted: HTMLElement | null = control; - - while (slotted !== null && slotted.parentElement !== this.el) { - slotted = slotted.parentElement; - } - - return slotted !== null && !slotted.getAttribute('slot'); - }); - } - // slot change listener updates state to reflect how/if item should be interactive private updateInteractivityOnSlotChange = () => { this.setIsInteractive(); this.setMultipleInputs(); - this.setHasSlottedIndicatorControl(); }; // If the item contains an input including a checkbox, datetime, select, or radio @@ -437,13 +376,6 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac const firstInteractiveNeedsPointerCursor = firstInteractive !== undefined && !['ION-INPUT', 'ION-TEXTAREA'].includes(firstInteractive.tagName); - /** - * A control in a single-input item defers its indicator to the item, so there's - * nothing to clip and nothing to make room for. It draws its own indicator in a - * multi-input item, and in a clickable item, which is a second tab stop. - */ - const slottedIndicatorNeedsRoom = this.hasSlottedIndicatorControl && (multipleInputs || this.isClickable()); - return ( { - if (node.nodeType !== Node.ELEMENT_NODE) { - return false; - } - - const el = node as Element; - - return el.matches(INDICATOR_CONTROL_SELECTOR) || el.querySelector(INDICATOR_CONTROL_SELECTOR) !== null; -}; - -const touchesIndicatorControl = (record: MutationRecord): boolean => - Array.from(record.addedNodes).some(isIndicatorControl) || Array.from(record.removedNodes).some(isIndicatorControl); From 610c1a6e7bb2e5849c0eafa207787e3d5555e1e6 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 5 Aug 2026 11:39:57 -0500 Subject: [PATCH 07/11] 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'); + }); + }); +}); From 1c94795b44decf6d1bff71114fb0798fac6d1dee Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 26 Aug 2026 13:47:09 -0500 Subject: [PATCH 08/11] fix(helper): simplify Mutation Observer for aria attribute watching --- core/src/utils/helpers.ts | 63 ++++++++++++--------------------------- 1 file changed, 19 insertions(+), 44 deletions(-) diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index 643da0d90e2..e2f7442036f 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -122,7 +122,7 @@ export const inheritAttributes = (el: HTMLElement, attributes: string[] = []) => * Removed deprecated attributes. * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes */ -export const ariaAttributes = [ +const ariaAttributes = [ 'role', 'aria-activedescendant', 'aria-atomic', @@ -197,9 +197,8 @@ export interface AttributeWatcher { /** * 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. + * onChange whenever one changes. Returns null when an attribute is removed. + * Call destroy() from disconnectedCallback to stop watching. */ export const watchAttributes = ( el: HTMLElement, @@ -213,55 +212,32 @@ export const watchAttributes = ( 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 } = {}; + const changed: { [k: string]: string | null } = {}; + 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; + + // getAttribute returns null when the attribute was removed — + // passed through to onChange so consumers can clear the value + // from the native element. + changed[name] = el.getAttribute(name); } + if (Object.keys(changed).length > 0) { - // 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); } }); - // Watch for attribute changes on this element - observer.observe(el, { attributes: true, attributeFilter: attributes }); - - // 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); - }; + observer.observe(el, { + attributes: true, + attributeFilter: attributes, + attributeOldValue: true, + }); - // Stop watching. Call this from `disconnectedCallback`. - return { - destroy: () => { - observer.disconnect(); - el.removeAttribute = originalRemoveAttribute; - }, - }; + return { destroy: () => observer.disconnect() }; }; /** @@ -269,9 +245,8 @@ export const watchAttributes = ( * 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. + * Call this in connectedCallback, alongside the initial inheritAriaAttributes + * call, and call destroy() on the returned watcher in disconnectedCallback. */ export const watchForAriaAttributeChanges = ( el: HTMLElement, From 31ae4245cecf4aa2355e67a2625089b4bca13414 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 26 Aug 2026 13:49:12 -0500 Subject: [PATCH 09/11] fix(button): sync aria attributes to native button reactively on button, item, and card --- core/src/components/button/button.tsx | 38 ++++---- core/src/components/card/card.tsx | 32 ++++-- core/src/components/item/item.tsx | 135 ++++++++++++++++++++++---- 3 files changed, 163 insertions(+), 42 deletions(-) diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index 67daa2480d1..2e3c994cb62 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -40,6 +40,7 @@ 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; @@ -205,24 +206,30 @@ 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']); + // Only run the initial snapshot once. On subsequent reconnects the + // host has already been stripped, so inheritAriaAttributes would + // return {} and overwrite previously captured values. - /** - * 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. - */ + 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) => { @@ -233,11 +240,6 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf ); } - disconnectedCallback() { - this.ariaWatcher?.destroy(); - this.ariaWatcher = undefined; - } - private get hasIconOnly() { return !!this.el.querySelector('[slot="icon-only"]'); } diff --git a/core/src/components/card/card.tsx b/core/src/components/card/card.tsx index 17510a5eeb1..fd584677130 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, forceUpdate } from '@stencil/core'; import type { AnchorInterface, ButtonInterface } from '@utils/element-interface'; -import type { Attributes, AttributeWatcher } from '@utils/helpers'; -import { inheritAttributes, watchAttributes } from '@utils/helpers'; +import type { Attributes } from '@utils/helpers'; +import { inheritAttributes, watchForAriaAttributeChanges, type AttributeWatcher } 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 didLoad = false; private ariaWatcher?: AttributeWatcher; @Element() el!: HTMLElement; @@ -93,10 +94,18 @@ export class Card implements ComponentInterface, AnchorInterface, ButtonInterfac } connectedCallback() { - this.ariaWatcher = watchAttributes(this.el, ['aria-label'], (changed) => { - this.inheritedAriaAttributes = { ...this.inheritedAriaAttributes, ...changed }; - forceUpdate(this); - }); + // 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() { @@ -104,6 +113,17 @@ export class Card implements ComponentInterface, AnchorInterface, ButtonInterfac 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; } diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index 0916f62e825..aa5746e250d 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 { Build, Component, Element, Host, Listen, Prop, State, Watch, forceUpdate, h } from '@stencil/core'; import type { AnchorInterface, ButtonInterface } from '@utils/element-interface'; -import type { Attributes, AttributeWatcher } from '@utils/helpers'; -import { inheritAttributes, watchAttributes, raf } from '@utils/helpers'; +import type { Attributes } from '@utils/helpers'; +import { inheritAttributes, raf, watchForAriaAttributeChanges, type AttributeWatcher } from '@utils/helpers'; import { createColorClasses, hostContext, openURL } from '@utils/theme'; import { chevronForward } from 'ionicons/icons'; @@ -10,6 +10,8 @@ import { getIonMode } from '../../global/ionic-global'; import type { AnimationBuilder, Color, CssClassMap, StyleEventDetail } from '../../interface'; import type { RouterDirection } from '../router/utils/interface'; +const INDICATOR_CONTROL_SELECTOR = 'ion-checkbox, ion-radio, ion-toggle'; + /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * @@ -34,6 +36,8 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac private labelColorStyles = {}; private itemStyles = new Map(); private inheritedAriaAttributes: Attributes = {}; + private indicatorControlObserver?: MutationObserver; + private didLoad = false; private ariaWatcher?: AttributeWatcher; @Element() el!: HTMLIonItemElement; @@ -41,6 +45,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac @State() multipleInputs = false; @State() focusable = true; @State() isInteractive = false; + @State() hasSlottedIndicatorControl = false; /** * The color to use from your application's color palette. @@ -165,34 +170,56 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac } } - componentWillLoad() {} - connectedCallback() { this.hasStartEl(); - // 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); - }); + /** + * `componentDidLoad` doesn't run again when the item is moved, so re-arm the + * observer and re-read the light DOM, which may have changed while detached. + */ + if (this.didLoad) { + this.watchForIndicatorControls(); + this.updateInteractivityOnSlotChange(); + this.startAriaWatcher(); + } } - disconnectedCallback() { - this.ariaWatcher?.destroy(); - this.ariaWatcher = undefined; + componentWillLoad() { + this.inheritedAriaAttributes = inheritAttributes(this.el, ['aria-label']); } componentDidLoad() { raf(() => { this.setMultipleInputs(); this.setIsInteractive(); + this.setHasSlottedIndicatorControl(); this.focusable = this.isFocusable(); }); + + this.watchForIndicatorControls(); + this.startAriaWatcher(); + this.didLoad = true; + } + + disconnectedCallback() { + if (this.indicatorControlObserver) { + this.indicatorControlObserver.disconnect(); + this.indicatorControlObserver = undefined; + } + + this.ariaWatcher?.destroy(); + this.ariaWatcher = undefined; + } + + private startAriaWatcher() { + this.ariaWatcher = watchForAriaAttributeChanges( + this.el, + (changed) => { + this.inheritedAriaAttributes = { ...this.inheritedAriaAttributes, ...changed }; + forceUpdate(this); + }, + ['aria-disabled'] + ); } private totalNestedInputs() { @@ -237,10 +264,61 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac this.isInteractive = covers.length > 0 || inputs.length > 0 || clickables.length > 0; } + /** + * `slotchange` only fires for nodes assigned directly to a slot, so a control + * inside a slotted wrapper (`
`) never reaches + * `updateInteractivityOnSlotChange`. The light DOM is observed instead. + * + * The callback runs the whole handler because a control below a wrapper can also + * make the item multi-input, which is what decides whether the controls draw + * their own indicator at all. + * + * `:host(:has())` would avoid the observer, but the `:has()` fallback in + * `core.scss` is still open as FW-6106 and it's unreliable for slotted content + * in Android WebView. Worth revisiting when FW-6106 closes. + */ + private watchForIndicatorControls() { + if (!Build.isBrowser || typeof MutationObserver === 'undefined') { + return; + } + + // `Node.moveBefore` relocates the item without either callback firing, so + // never leave a previous observer behind + this.indicatorControlObserver?.disconnect(); + + this.indicatorControlObserver = new MutationObserver((records) => { + // The subtree observer also fires for text and hidden input churn, so only + // re-read the DOM when a control was added or removed + if (records.some(touchesIndicatorControl)) { + this.updateInteractivityOnSlotChange(); + } + }); + this.indicatorControlObserver.observe(this.el, { childList: true, subtree: true }); + } + + // These controls paint a focus indicator that overhangs their own bounds, and + // only the default slot is clipped, so only a control there needs extra room. + private setHasSlottedIndicatorControl() { + const controls = this.el.querySelectorAll(INDICATOR_CONTROL_SELECTOR); + + this.hasSlottedIndicatorControl = Array.from(controls).some((control) => { + // The control isn't always a direct child, so walk up to the element the item + // slots, which is the one carrying the slot name. + let slotted: HTMLElement | null = control; + + while (slotted !== null && slotted.parentElement !== this.el) { + slotted = slotted.parentElement; + } + + return slotted !== null && !slotted.getAttribute('slot'); + }); + } + // slot change listener updates state to reflect how/if item should be interactive private updateInteractivityOnSlotChange = () => { this.setIsInteractive(); this.setMultipleInputs(); + this.setHasSlottedIndicatorControl(); }; // If the item contains an input including a checkbox, datetime, select, or radio @@ -376,6 +454,13 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac const firstInteractiveNeedsPointerCursor = firstInteractive !== undefined && !['ION-INPUT', 'ION-TEXTAREA'].includes(firstInteractive.tagName); + /** + * A control in a single-input item defers its indicator to the item, so there's + * nothing to clip and nothing to make room for. It draws its own indicator in a + * multi-input item, and in a clickable item, which is a second tab stop. + */ + const slottedIndicatorNeedsRoom = this.hasSlottedIndicatorControl && (multipleInputs || this.isClickable()); + return ( { + if (node.nodeType !== Node.ELEMENT_NODE) { + return false; + } + + const el = node as Element; + + return el.matches(INDICATOR_CONTROL_SELECTOR) || el.querySelector(INDICATOR_CONTROL_SELECTOR) !== null; +}; + +const touchesIndicatorControl = (record: MutationRecord): boolean => + Array.from(record.addedNodes).some(isIndicatorControl) || Array.from(record.removedNodes).some(isIndicatorControl); From 962baac7ddc1c1703a95ab23906c2d57343769fd Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 26 Aug 2026 13:50:00 -0500 Subject: [PATCH 10/11] test(button): Fix e2e tests for button, item, and card changes --- .../components/button/test/a11y/button.e2e.ts | 63 ++++++++------- .../src/components/card/test/a11y/card.e2e.ts | 80 ++++++++++++------- .../src/components/item/test/a11y/item.e2e.ts | 39 ++++----- 3 files changed, 105 insertions(+), 77 deletions(-) diff --git a/core/src/components/button/test/a11y/button.e2e.ts b/core/src/components/button/test/a11y/button.e2e.ts index 79674ef0197..011e92b1401 100644 --- a/core/src/components/button/test/a11y/button.e2e.ts +++ b/core/src/components/button/test/a11y/button.e2e.ts @@ -1,6 +1,5 @@ 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 }) => { @@ -152,8 +151,7 @@ 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'); + 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 }) => { @@ -175,20 +173,29 @@ 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' }); + 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(`Button`, config); const host = page.locator('ion-button'); const nativeButton = host.locator('button'); - await expect(nativeButton).not.toHaveAttribute('aria-disabled', 'true'); + // 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('aria sync survives detach and reattach', async ({ page }) => { + 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( `
@@ -203,20 +210,22 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { await expect(nativeButton).toHaveAttribute('aria-label', 'label'); - // Detach and reattach - await host.evaluate((buttonEl) => { - const parent = buttonEl.parentElement!; - parent.removeChild(buttonEl); - parent.appendChild(buttonEl); + // 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'; }); - await host.evaluate((el) => el.setAttribute('aria-label', 'updated')); - await expect(nativeButton).toHaveAttribute('aria-label', 'updated'); + // Assert the original value survived + await expect(nativeButton).toHaveAttribute('aria-label', 'label'); }); - 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()}`); + 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( @@ -229,25 +238,21 @@ configs({ directions: ['ltr'] }).forEach(({ title, 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. + // 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'); - // Setting a new value on the host: watcher should capture it, sync it - // to native, and re-strip it from the host. + // Post-load writes remain on the host and are synchronized to native await host.evaluate((el) => el.setAttribute('aria-label', 'second')); - await expect(host).not.toHaveAttribute('aria-label'); + await expect(host).toHaveAttribute('aria-label'); await expect(nativeButton).toHaveAttribute('aria-label', 'second'); - // Setting to empty string: empty string is a valid, non-null value. + // An empty string is a valid ARIA attribute value and remains synchronized. await host.evaluate((el) => el.setAttribute('aria-label', '')); - await expect(host).not.toHaveAttribute('aria-label'); + await expect(host).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. + // 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'); diff --git a/core/src/components/card/test/a11y/card.e2e.ts b/core/src/components/card/test/a11y/card.e2e.ts index ebb0a7967df..3d9a2be9f04 100644 --- a/core/src/components/card/test/a11y/card.e2e.ts +++ b/core/src/components/card/test/a11y/card.e2e.ts @@ -34,8 +34,8 @@ 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.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', @@ -43,37 +43,61 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { await page.setContent( ` -
- Card -
- `, + Card + `, config ); const host = page.locator('ion-card'); - const nativeCard = host.locator('[part="native"]'); + const nativeItem = 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 expect(nativeItem).toHaveAttribute('aria-label', 'label'); await host.evaluate((el) => el.setAttribute('aria-label', 'updated')); - await expect(nativeCard).toHaveAttribute('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()}`); + 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( ` - Button +
+ Card +
+ `, + config + ); + + const host = page.locator('ion-card'); + const nativeItem = host.locator('[part="native"]'); + + await expect(nativeItem).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(nativeItem).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( + ` + Card `, config ); @@ -81,25 +105,21 @@ configs({ directions: ['ltr'] }).forEach(({ title, 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. + // 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'); - // Setting a new value on the host: watcher should capture it, sync it - // to native, and re-strip it from the host. + // Post-load writes remain on the host and are synchronized to native await host.evaluate((el) => el.setAttribute('aria-label', 'second')); - await expect(host).not.toHaveAttribute('aria-label'); + await expect(host).toHaveAttribute('aria-label'); await expect(nativeButton).toHaveAttribute('aria-label', 'second'); - // Setting to empty string: empty string is a valid, non-null value. + // An empty string is a valid ARIA attribute value and remains synchronized. await host.evaluate((el) => el.setAttribute('aria-label', '')); - await expect(host).not.toHaveAttribute('aria-label'); + await expect(host).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. + // 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'); diff --git a/core/src/components/item/test/a11y/item.e2e.ts b/core/src/components/item/test/a11y/item.e2e.ts index 72581a7af54..01f06991870 100644 --- a/core/src/components/item/test/a11y/item.e2e.ts +++ b/core/src/components/item/test/a11y/item.e2e.ts @@ -179,7 +179,12 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { await expect(nativeItem).toHaveAttribute('aria-label', 'updated'); }); - test('aria-label sync survives detach and reattach', async ({ page }) => { + 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( `
@@ -194,24 +199,26 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { await expect(nativeItem).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'; }); - await host.evaluate((el) => el.setAttribute('aria-label', 'updated')); - await expect(nativeItem).toHaveAttribute('aria-label', 'updated'); + // Assert the original value survived + await expect(nativeItem).toHaveAttribute('aria-label', 'label'); }); - 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()}`); + 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( ` - Button + Item `, config ); @@ -219,25 +226,21 @@ configs({ directions: ['ltr'] }).forEach(({ title, 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. + // 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'); - // Setting a new value on the host: watcher should capture it, sync it - // to native, and re-strip it from the host. + // Post-load writes remain on the host and are synchronized to native await host.evaluate((el) => el.setAttribute('aria-label', 'second')); - await expect(host).not.toHaveAttribute('aria-label'); + await expect(host).toHaveAttribute('aria-label'); await expect(nativeButton).toHaveAttribute('aria-label', 'second'); - // Setting to empty string: empty string is a valid, non-null value. + // An empty string is a valid ARIA attribute value and remains synchronized. await host.evaluate((el) => el.setAttribute('aria-label', '')); - await expect(host).not.toHaveAttribute('aria-label'); + await expect(host).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. + // 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'); From f142f3604cd7dcc4fac9aef6ccef85f187550065 Mon Sep 17 00:00:00 2001 From: Zac-Smucker-Bryan Date: Wed, 26 Aug 2026 15:09:28 -0500 Subject: [PATCH 11/11] fix(test): Update Item and Card native test variable Consistency based on element being tested --- .../src/components/card/test/a11y/card.e2e.ts | 22 +++++++++---------- .../src/components/item/test/a11y/item.e2e.ts | 10 ++++----- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/core/src/components/card/test/a11y/card.e2e.ts b/core/src/components/card/test/a11y/card.e2e.ts index 3d9a2be9f04..25e65142769 100644 --- a/core/src/components/card/test/a11y/card.e2e.ts +++ b/core/src/components/card/test/a11y/card.e2e.ts @@ -49,13 +49,13 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { ); const host = page.locator('ion-card'); - const nativeItem = host.locator('[part="native"]'); + const nativeCard = host.locator('[part="native"]'); - await expect(nativeItem).toHaveAttribute('aria-label', 'label'); + await expect(nativeCard).toHaveAttribute('aria-label', 'label'); await host.evaluate((el) => el.setAttribute('aria-label', 'updated')); - await expect(nativeItem).toHaveAttribute('aria-label', 'updated'); + await expect(nativeCard).toHaveAttribute('aria-label', 'updated'); }); test('preserves inherited aria-label after detach and reattach', async ({ page }) => { @@ -74,9 +74,9 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { ); const host = page.locator('ion-card'); - const nativeItem = host.locator('[part="native"]'); + const nativeCard = host.locator('[part="native"]'); - await expect(nativeItem).toHaveAttribute('aria-label', 'label'); + await expect(nativeCard).toHaveAttribute('aria-label', 'label'); // Detach, reattach, and force a render via a prop change. await host.evaluate((itemEl) => { @@ -87,7 +87,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { }); // Assert the original value survived - await expect(nativeItem).toHaveAttribute('aria-label', 'label'); + await expect(nativeCard).toHaveAttribute('aria-label', 'label'); }); test('syncs aria-label updates and removal after initial inheritance', async ({ page }) => { @@ -103,26 +103,26 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { ); const host = page.locator('ion-card'); - const nativeButton = host.locator('[part="native"]'); + 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(nativeButton).toHaveAttribute('aria-label', 'initial'); + 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(nativeButton).toHaveAttribute('aria-label', 'second'); + 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(nativeButton).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(nativeButton).not.toHaveAttribute('aria-label'); + await expect(nativeCard).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 01f06991870..2014239078a 100644 --- a/core/src/components/item/test/a11y/item.e2e.ts +++ b/core/src/components/item/test/a11y/item.e2e.ts @@ -224,26 +224,26 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { ); const host = page.locator('ion-item'); - const nativeButton = host.locator('[part="native"]'); + const nativeItem = 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(nativeButton).toHaveAttribute('aria-label', 'initial'); + await expect(nativeItem).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'); + await expect(nativeItem).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', ''); + await expect(nativeItem).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'); + await expect(nativeItem).not.toHaveAttribute('aria-label'); }); }); });