diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index a1e7f72bf01..2e3c994cb62 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 } 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 +40,8 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf private formButtonEl: HTMLButtonElement | null = null; private formEl: HTMLFormElement | null = null; private inheritedAttributes: Attributes = {}; + private didLoad = false; + private ariaWatcher?: AttributeWatcher; @Element() el!: HTMLElement; @@ -158,27 +165,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') - 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 @@ -223,6 +209,37 @@ export class Button implements ComponentInterface, AnchorInterface, ButtonInterf this.inheritedAttributes = inheritAriaAttributes(this.el); } + connectedCallback() { + // Only run the initial snapshot once. On subsequent reconnects the + // host has already been stripped, so inheritAriaAttributes would + // return {} and overwrite previously captured values. + + if (this.didLoad) { + this.startAriaWatcher(); + } + } + + componentDidLoad() { + this.didLoad = true; + this.startAriaWatcher(); + } + + disconnectedCallback() { + this.ariaWatcher?.destroy(); + this.ariaWatcher = undefined; + } + + private startAriaWatcher() { + this.ariaWatcher = watchForAriaAttributeChanges( + this.el, + (changed) => { + this.inheritedAttributes = { ...this.inheritedAttributes, ...changed }; + forceUpdate(this); + }, + ['aria-disabled'] + ); + } + private get hasIconOnly() { return !!this.el.querySelector('[slot="icon-only"]'); } diff --git a/core/src/components/button/test/a11y/button.e2e.ts b/core/src/components/button/test/a11y/button.e2e.ts index 585c0b5853d..011e92b1401 100644 --- a/core/src/components/button/test/a11y/button.e2e.ts +++ b/core/src/components/button/test/a11y/button.e2e.ts @@ -148,3 +148,114 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { }); }); }); + +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('button: aria attribute sync'), () => { + const watchedAriaAttributes = ['aria-checked', 'aria-label', 'aria-pressed', 'aria-description']; + + for (const attr of watchedAriaAttributes) { + test(`native button updates ${attr} when host attribute changes`, async ({ page }) => { + 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(attr, 'initial'); + + await host.evaluate((el, attr) => el.setAttribute(attr, 'updated'), attr); + + await expect(nativeButton).toHaveAttribute(attr, 'updated'); + }); + } + + test('should not sync aria-disabled from the host', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30626', + }); + + await page.setContent(`Button`, config); + + const host = page.locator('ion-button'); + const nativeButton = host.locator('button'); + + // Initial inheritance moves the developer-provided value to native. + // The host's aria-disabled is subsequently owned by the disabled prop. + await expect(host).not.toHaveAttribute('aria-disabled'); + await expect(nativeButton).toHaveAttribute('aria-disabled', 'true'); + }); + + test('preserves inherited aria-label after detach and reattach', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30626', + }); + + await page.setContent( + ` +
+ Button +
+ `, + config + ); + + const host = page.locator('ion-button'); + const nativeButton = host.locator('button'); + + await expect(nativeButton).toHaveAttribute('aria-label', 'label'); + + // Detach, reattach, and force a render via a prop change. + await host.evaluate((el) => { + const parent = el.parentElement!; + parent.removeChild(el); + parent.appendChild(el); + (el as HTMLIonButtonElement).color = 'primary'; + }); + + // Assert the original value survived + await expect(nativeButton).toHaveAttribute('aria-label', 'label'); + }); + + test('syncs aria-label updates and removal after initial inheritance', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30626', + }); + + await page.setContent( + ` + Button + `, + config + ); + + const host = page.locator('ion-button'); + const nativeButton = host.locator('button'); + + // Initial inheritance moves the value from the host to the native button. + await expect(host).not.toHaveAttribute('aria-label'); + await expect(nativeButton).toHaveAttribute('aria-label', 'initial'); + + // Post-load writes remain on the host and are synchronized to native + await host.evaluate((el) => el.setAttribute('aria-label', 'second')); + await expect(host).toHaveAttribute('aria-label'); + await expect(nativeButton).toHaveAttribute('aria-label', 'second'); + + // An empty string is a valid ARIA attribute value and remains synchronized. + await host.evaluate((el) => el.setAttribute('aria-label', '')); + await expect(host).toHaveAttribute('aria-label'); + await expect(nativeButton).toHaveAttribute('aria-label', ''); + + // Native MutationObserver behavior sees a real removal after a post-load write. + await host.evaluate((el) => el.removeAttribute('aria-label')); + await expect(host).not.toHaveAttribute('aria-label'); + await expect(nativeButton).not.toHaveAttribute('aria-label'); + }); + }); +}); diff --git a/core/src/components/card/card.tsx b/core/src/components/card/card.tsx index 68e21d0ba5a..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 } from '@stencil/core'; +import { Element, Component, Host, Prop, h, forceUpdate } from '@stencil/core'; import type { AnchorInterface, ButtonInterface } from '@utils/element-interface'; import type { Attributes } from '@utils/helpers'; -import { inheritAttributes } from '@utils/helpers'; +import { inheritAttributes, watchForAriaAttributeChanges, type AttributeWatcher } from '@utils/helpers'; import { createColorClasses, openURL } from '@utils/theme'; import { getIonMode } from '../../global/ionic-global'; @@ -24,6 +24,8 @@ import type { RouterDirection } from '../router/utils/interface'; }) export class Card implements ComponentInterface, AnchorInterface, ButtonInterface { private inheritedAriaAttributes: Attributes = {}; + private didLoad = false; + private ariaWatcher?: AttributeWatcher; @Element() el!: HTMLElement; /** @@ -91,6 +93,37 @@ export class Card implements ComponentInterface, AnchorInterface, ButtonInterfac this.inheritedAriaAttributes = inheritAttributes(this.el, ['aria-label']); } + connectedCallback() { + // Only run the initial snapshot once. On subsequent reconnects the + // host has already been stripped, so inheritAriaAttributes would + // return {} and overwrite previously captured values. + + if (this.didLoad) { + this.startAriaWatcher(); + } + } + + componentDidLoad() { + this.didLoad = true; + this.startAriaWatcher(); + } + + disconnectedCallback() { + this.ariaWatcher?.destroy(); + this.ariaWatcher = undefined; + } + + private startAriaWatcher() { + this.ariaWatcher = watchForAriaAttributeChanges( + this.el, + (changed) => { + this.inheritedAriaAttributes = { ...this.inheritedAriaAttributes, ...changed }; + forceUpdate(this); + }, + ['aria-disabled'] + ); + } + private isClickable(): boolean { return this.href !== undefined || this.button; } diff --git a/core/src/components/card/test/a11y/card.e2e.ts b/core/src/components/card/test/a11y/card.e2e.ts index 6902037f998..25e65142769 100644 --- a/core/src/components/card/test/a11y/card.e2e.ts +++ b/core/src/components/card/test/a11y/card.e2e.ts @@ -32,3 +32,97 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { }); }); }); + +configs({ directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('item: aria attribute sync'), () => { + test('native element updates aria-label when host attribute changes', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30626', + }); + + await page.setContent( + ` + Card + `, + config + ); + + const host = page.locator('ion-card'); + const nativeCard = host.locator('[part="native"]'); + + await expect(nativeCard).toHaveAttribute('aria-label', 'label'); + + await host.evaluate((el) => el.setAttribute('aria-label', 'updated')); + + await expect(nativeCard).toHaveAttribute('aria-label', 'updated'); + }); + + test('preserves inherited aria-label after detach and reattach', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30626', + }); + + await page.setContent( + ` +
+ Card +
+ `, + config + ); + + const host = page.locator('ion-card'); + const nativeCard = host.locator('[part="native"]'); + + await expect(nativeCard).toHaveAttribute('aria-label', 'label'); + + // Detach, reattach, and force a render via a prop change. + await host.evaluate((itemEl) => { + const parent = itemEl.parentElement!; + parent.removeChild(itemEl); + parent.appendChild(itemEl); + (itemEl as HTMLIonButtonElement).color = 'primary'; + }); + + // Assert the original value survived + await expect(nativeCard).toHaveAttribute('aria-label', 'label'); + }); + + test('syncs aria-label updates and removal after initial inheritance', async ({ page }) => { + test.info().annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30626', + }); + await page.setContent( + ` + Card + `, + config + ); + + const host = page.locator('ion-card'); + const nativeCard = host.locator('[part="native"]'); + + // Initial inheritance moves the value from the host to the native button. + await expect(host).not.toHaveAttribute('aria-label'); + await expect(nativeCard).toHaveAttribute('aria-label', 'initial'); + + // Post-load writes remain on the host and are synchronized to native + await host.evaluate((el) => el.setAttribute('aria-label', 'second')); + await expect(host).toHaveAttribute('aria-label'); + await expect(nativeCard).toHaveAttribute('aria-label', 'second'); + + // An empty string is a valid ARIA attribute value and remains synchronized. + await host.evaluate((el) => el.setAttribute('aria-label', '')); + await expect(host).toHaveAttribute('aria-label'); + await expect(nativeCard).toHaveAttribute('aria-label', ''); + + // Native MutationObserver behavior sees a real removal after a post-load write. + await host.evaluate((el) => el.removeAttribute('aria-label')); + await expect(host).not.toHaveAttribute('aria-label'); + await expect(nativeCard).not.toHaveAttribute('aria-label'); + }); + }); +}); diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index 45eea6867d3..aa5746e250d 100644 --- a/core/src/components/item/item.tsx +++ b/core/src/components/item/item.tsx @@ -2,7 +2,7 @@ import type { ComponentInterface } 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 } from '@utils/helpers'; -import { inheritAttributes, raf } from '@utils/helpers'; +import { inheritAttributes, raf, watchForAriaAttributeChanges, type AttributeWatcher } from '@utils/helpers'; import { createColorClasses, hostContext, openURL } from '@utils/theme'; import { chevronForward } from 'ionicons/icons'; @@ -38,6 +38,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac private inheritedAriaAttributes: Attributes = {}; private indicatorControlObserver?: MutationObserver; private didLoad = false; + private ariaWatcher?: AttributeWatcher; @Element() el!: HTMLIonItemElement; @@ -179,6 +180,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac if (this.didLoad) { this.watchForIndicatorControls(); this.updateInteractivityOnSlotChange(); + this.startAriaWatcher(); } } @@ -195,6 +197,7 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac }); this.watchForIndicatorControls(); + this.startAriaWatcher(); this.didLoad = true; } @@ -203,6 +206,20 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac 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() { diff --git a/core/src/components/item/test/a11y/item.e2e.ts b/core/src/components/item/test/a11y/item.e2e.ts index 20536beb71a..2014239078a 100644 --- a/core/src/components/item/test/a11y/item.e2e.ts +++ b/core/src/components/item/test/a11y/item.e2e.ts @@ -153,3 +153,97 @@ 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('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( + ` +
+ Item +
+ `, + config + ); + + const host = page.locator('ion-item'); + 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( + ` + Item + `, + config + ); + + const host = page.locator('ion-item'); + 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(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(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(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(nativeItem).not.toHaveAttribute('aria-label'); + }); + }); +}); diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index 9c6052b466f..e2f7442036f 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -191,6 +191,75 @@ export const inheritAriaAttributes = (el: HTMLElement, ignoreList?: string[]) => return inheritAttributes(el, attributesToInherit); }; +export interface AttributeWatcher { + destroy: () => void; +} + +/** + * Watches an element for changes to a given set of attributes and calls + * onChange whenever one changes. Returns null when an attribute is removed. + * Call destroy() from disconnectedCallback to stop watching. + */ +export const watchAttributes = ( + el: HTMLElement, + attributes: string[], + 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 { destroy: () => {} }; + } + + const observer = new MutationObserver((mutations) => { + 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; + + // 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) { + onChange(changed); + } + }); + + observer.observe(el, { + attributes: true, + attributeFilter: attributes, + attributeOldValue: true, + }); + + return { destroy: () => 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. + * + * Call this in connectedCallback, alongside the initial inheritAriaAttributes + * call, and call destroy() on the returned watcher in disconnectedCallback. + */ +export const watchForAriaAttributeChanges = ( + el: HTMLElement, + onChange: (changed: { [k: string]: string | null }) => 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); };