-
Notifications
You must be signed in to change notification settings - Fork 13.3k
fix(button): sync aria attributes between host and native button #31264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
456c161
d2b73ee
1d0e016
7210eff
c014ca5
dab84cb
610c1a6
1c94795
31ae424
962baac
f142f36
8bea648
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,13 @@ | ||
| 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, | ||
| type Attributes, | ||
| } 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<void>; | ||
|
|
||
| /** | ||
| * This component is used within the `ion-input-password-toggle` component | ||
| * to toggle the visibility of the password input. | ||
| * These attributes need to update based on the state of the password input. | ||
| * Otherwise, the values will be stale. | ||
| * | ||
| * @param newValue | ||
| * @param _oldValue | ||
| * @param propName | ||
| */ | ||
| @Watch('aria-checked') | ||
| @Watch('aria-label') | ||
| @Watch('aria-pressed') | ||
| onAriaChanged(newValue: string, _oldValue: string, propName: string) { | ||
| this.inheritedAttributes = { | ||
| ...this.inheritedAttributes, | ||
| [propName]: newValue, | ||
| }; | ||
| forceUpdate(this); | ||
| } | ||
|
|
||
| /** | ||
| * This is responsible for rendering a hidden native | ||
| * button element inside the associated form. This allows | ||
|
|
@@ -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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this comment matches the code now. There's no snapshot in Stray blank line between the comment and the |
||
|
|
||
| if (this.didLoad) { | ||
| this.startAriaWatcher(); | ||
| } | ||
| } | ||
|
|
||
| componentDidLoad() { | ||
| this.didLoad = true; | ||
| this.startAriaWatcher(); | ||
| } | ||
|
|
||
| disconnectedCallback() { | ||
| this.ariaWatcher?.destroy(); | ||
| this.ariaWatcher = undefined; | ||
| } | ||
|
|
||
| private startAriaWatcher() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The One thing worth keeping though: the JSDoc on the |
||
| this.ariaWatcher = watchForAriaAttributeChanges( | ||
|
Zac-Smucker-Bryan marked this conversation as resolved.
|
||
| this.el, | ||
| (changed) => { | ||
| this.inheritedAttributes = { ...this.inheritedAttributes, ...changed }; | ||
| forceUpdate(this); | ||
| }, | ||
| ['aria-disabled'] | ||
| ); | ||
| } | ||
|
|
||
| private get hasIconOnly() { | ||
| return !!this.el.querySelector('[slot="icon-only"]'); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }) => { | ||
|
Zac-Smucker-Bryan marked this conversation as resolved.
|
||
| test.info().annotations.push({ | ||
| type: 'issue', | ||
| description: 'https://github.com/ionic-team/ionic-framework/issues/30626', | ||
| }); | ||
|
|
||
| await page.setContent(`<ion-button ${attr}="initial">Button</ion-button>`, config); | ||
|
|
||
| const host = page.locator('ion-button'); | ||
| const nativeButton = host.locator('button'); | ||
|
|
||
| await expect(nativeButton).toHaveAttribute(attr, 'initial'); | ||
|
|
||
| await host.evaluate((el, attr) => el.setAttribute(attr, 'updated'), attr); | ||
|
|
||
| await expect(nativeButton).toHaveAttribute(attr, 'updated'); | ||
| }); | ||
| } | ||
|
|
||
| test('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(`<ion-button aria-disabled="true">Button</ion-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'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one passes with the source reverted to The name reads a bit odd too, since the assertion is that the value does get inherited at load. |
||
| }); | ||
|
|
||
| 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( | ||
| ` | ||
| <div id="container"> | ||
| <ion-button aria-label="label">Button</ion-button> | ||
| </div> | ||
| `, | ||
| 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'); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this can fail. Reverting the four source files to That means the |
||
| }); | ||
|
|
||
| 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( | ||
| ` | ||
| <ion-button aria-label="initial">Button</ion-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'); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,12 @@ | ||
| 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, | ||
| type Attributes, | ||
| } from '@utils/helpers'; | ||
| import { createColorClasses, openURL } from '@utils/theme'; | ||
|
|
||
| import { getIonMode } from '../../global/ionic-global'; | ||
|
|
@@ -24,6 +28,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 +97,37 @@ export class Card implements ComponentInterface, AnchorInterface, ButtonInterfac | |
| this.inheritedAriaAttributes = inheritAttributes(this.el, ['aria-label']); | ||
| } | ||
|
|
||
| connectedCallback() { | ||
|
Zac-Smucker-Bryan marked this conversation as resolved.
|
||
| // 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'] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same ignore-list issue as the item watcher, and the same fix. Card has no Two small things while you're here. Card never renders |
||
| ); | ||
| } | ||
|
|
||
| private isClickable(): boolean { | ||
| return this.href !== undefined || this.button; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -32,3 +32,97 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { | |||||
| }); | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| configs({ directions: ['ltr'] }).forEach(({ title, config }) => { | ||||||
| test.describe(title('item: aria attribute sync'), () => { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Looks like this one survived the copy/paste cleanup from the earlier pass. It collides exactly with the item spec's describe block, so both come through as Same file, the detach/reattach test still names its evaluate arg |
||||||
| 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( | ||||||
| ` | ||||||
| <ion-card button="true" aria-label="label">Card</ion-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( | ||||||
| ` | ||||||
| <div id="container"> | ||||||
| <ion-card button="true" aria-label="label">Card</ion-card> | ||||||
| </div> | ||||||
| `, | ||||||
| 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( | ||||||
| ` | ||||||
| <ion-card button="true" aria-label="initial">Card</ion-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'); | ||||||
| }); | ||||||
| }); | ||||||
| }); | ||||||
Uh oh!
There was an error while loading. Please reload this page.