diff --git a/.changeset/fresh-buttons-rest.md b/.changeset/fresh-buttons-rest.md new file mode 100644 index 00000000000..df7ff48fcf1 --- /dev/null +++ b/.changeset/fresh-buttons-rest.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': patch +--- + +Forward updated and removed ARIA attributes to each component's accessible element without leaving invalid host copies. `ix-toggle` now keeps its component managed attributes (`role`, `aria-checked`, `aria-disabled`, `aria-required`) intact while still accepting custom ARIA attributes such as `aria-label`. diff --git a/.changeset/mighty-poems-behave.md b/.changeset/mighty-poems-behave.md new file mode 100644 index 00000000000..c2ae73bbaf9 --- /dev/null +++ b/.changeset/mighty-poems-behave.md @@ -0,0 +1,8 @@ +--- +'@siemens/ix': patch +--- + +Fixed `ix-dropdown` trigger handling: an empty `trigger` value no longer matches +elements without an `id`, changing the trigger to an empty value now removes the +previously registered listeners, and asynchronously resolved triggers that became +stale are discarded instead of attaching orphaned listeners. diff --git a/.changeset/persistent-pinned-menu.md b/.changeset/persistent-pinned-menu.md new file mode 100644 index 00000000000..32548fd7432 --- /dev/null +++ b/.changeset/persistent-pinned-menu.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': patch +--- + +Keep pinned `ix-menu` components expanded when a menu item is selected. diff --git a/.changeset/reliable-tab-activation.md b/.changeset/reliable-tab-activation.md new file mode 100644 index 00000000000..36eabe2ff95 --- /dev/null +++ b/.changeset/reliable-tab-activation.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': patch +--- + +Fix `ix-tab-set` panel activation and `ix-menu-about` tab activation when child components are still initializing. diff --git a/.changeset/stable-date-picker-focus.md b/.changeset/stable-date-picker-focus.md new file mode 100644 index 00000000000..80790dd9b1f --- /dev/null +++ b/.changeset/stable-date-picker-focus.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': patch +--- + +Fix `ix-date-picker` keyboard navigation so focus consistently moves to the expected day. diff --git a/.changeset/steady-dropdown-triggers.md b/.changeset/steady-dropdown-triggers.md new file mode 100644 index 00000000000..801a3653ce3 --- /dev/null +++ b/.changeset/steady-dropdown-triggers.md @@ -0,0 +1,5 @@ +--- +'@siemens/ix': patch +--- + +Fix `ix-dropdown` trigger initialization so dropdowns open reliably immediately after rendering or when triggers are added dynamically. diff --git a/packages/core/src/components/chat-input/tests/chat-input.ct.ts b/packages/core/src/components/chat-input/tests/chat-input.ct.ts index a1826b80e17..7251538f96e 100644 --- a/packages/core/src/components/chat-input/tests/chat-input.ct.ts +++ b/packages/core/src/components/chat-input/tests/chat-input.ct.ts @@ -345,9 +345,6 @@ regressionTest( await expect( chatInput.locator('ix-dropdown-button.attachment-overflow') ).toHaveCount(0); - await expect(chatInput.locator('.attachments')).not.toHaveClass( - /has-attachment-scrollbar/ - ); await expect( page.locator('[data-attachment-overflow-generated]') ).toHaveCount(0); diff --git a/packages/core/src/components/checkbox/tests/checkbox.ct.ts b/packages/core/src/components/checkbox/tests/checkbox.ct.ts index 4ba28c066cc..ec6969a8662 100644 --- a/packages/core/src/components/checkbox/tests/checkbox.ct.ts +++ b/packages/core/src/components/checkbox/tests/checkbox.ct.ts @@ -108,10 +108,7 @@ regressionTest( const checkbox = page.locator('ix-checkbox'); await expect(checkbox).not.toHaveClass(/label-less/); await expect(checkbox).toHaveText(/Custom slot label text/); - const width = await checkbox.evaluate((element) => - Number.parseFloat(getComputedStyle(element).width) - ); - expect(width).toBeGreaterThan(24); + await expect(checkbox.locator('ix-typography')).toBeVisible(); } ); @@ -121,37 +118,38 @@ regressionTest('label', async ({ mount, page }) => { await expect(checkboxElement).toHaveText(/some label/); }); -test('Checkbox should not cause layout shift when checked', async ({ - mount, - page, -}) => { - await mount(` +regressionTest( + 'Checkbox should not cause layout shift when checked', + async ({ mount, page }) => { + await mount(`
This element should not move
`); - await page.waitForSelector('ix-checkbox', { state: 'attached' }); + const checkbox = page.locator('ix-checkbox'); + const elementBelow = page.locator('#element-below'); - const initialBounds = await page.$eval('#element-below', (el) => { - const rect = el.getBoundingClientRect(); - return { top: rect.top, left: rect.left }; - }); + await expect(checkbox).toHaveClass(/hydrated/); + await expect(elementBelow).toBeVisible(); + await page.evaluate(() => document.fonts.ready); - await page.click('ix-checkbox'); + const initialBounds = await elementBelow.boundingBox(); + if (!initialBounds) { + throw new Error('Expected element below checkbox to have a bounding box'); + } - await page.waitForFunction(() => { - const checkbox = document.querySelector('ix-checkbox'); - return checkbox?.getAttribute('aria-checked') === 'true'; - }); + await checkbox.click(); + await expect(checkbox).toHaveAttribute('aria-checked', 'true'); - const newBounds = await page.$eval('#element-below', (el) => { - const rect = el.getBoundingClientRect(); - return { top: rect.top, left: rect.left }; - }); + const newBounds = await elementBelow.boundingBox(); + if (!newBounds) { + throw new Error('Expected element below checkbox to remain visible'); + } - expect(newBounds.top).toBeCloseTo(initialBounds.top, 0); - expect(newBounds.left).toBeCloseTo(initialBounds.left, 0); -}); + expect(newBounds.y).toBeCloseTo(initialBounds.y, 0); + expect(newBounds.x).toBeCloseTo(initialBounds.x, 0); + } +); test.describe('accessibility', () => { test('should expose aria-label for accessibility queries', async ({ diff --git a/packages/core/src/components/chip/test/chip.ct.ts b/packages/core/src/components/chip/test/chip.ct.ts index 1587323a266..b33ab3301a3 100644 --- a/packages/core/src/components/chip/test/chip.ct.ts +++ b/packages/core/src/components/chip/test/chip.ct.ts @@ -56,6 +56,16 @@ regressionTest( await expect( page.getByRole('button', { name: 'Close chip' }) ).toBeVisible(); + + await chip.evaluate((element) => { + element.setAttribute('aria-label', 'Project Beta'); + }); + + await expect(chip).not.toHaveAttribute('aria-label'); + await expect( + page.getByRole('button', { name: 'Project Beta' }) + ).toBeVisible(); + await expect(chip).toHaveAttribute('role', 'group'); } ); diff --git a/packages/core/src/components/date-picker/date-picker.tsx b/packages/core/src/components/date-picker/date-picker.tsx index 98962cc8a44..7a66bae86f7 100644 --- a/packages/core/src/components/date-picker/date-picker.tsx +++ b/packages/core/src/components/date-picker/date-picker.tsx @@ -300,6 +300,7 @@ export class DatePicker break; } + event.preventDefault(); return; } @@ -847,7 +848,18 @@ export class DatePicker return rows; } - public changeFocusedDay() { + public changeFocusedDay(focusTarget?: EventTarget) { + const focusedDayElement = + focusTarget instanceof HTMLElement + ? focusTarget.closest('[data-calendar-day]') + : null; + const focusedDay = focusedDayElement?.dataset.calendarDay; + + if (focusedDay) { + this.focusedDay = Number.parseInt(focusedDay, 10); + return; + } + if (this.monthChangedFromFocus) { return; } @@ -922,9 +934,9 @@ export class DatePicker return ( this.onKeyDown(event)} - onFocusin={() => { + onFocusin={(event: FocusEvent) => { if (hasKeyboardMode()) { - this.changeFocusedDay(); + this.changeFocusedDay(event.composedPath()[0]); } }} > diff --git a/packages/core/src/components/datetime-input/test/datetime-input.ct.ts b/packages/core/src/components/datetime-input/test/datetime-input.ct.ts index 8518e0bc5dc..5b57473e539 100644 --- a/packages/core/src/components/datetime-input/test/datetime-input.ct.ts +++ b/packages/core/src/components/datetime-input/test/datetime-input.ct.ts @@ -1450,8 +1450,14 @@ regressionTest('form-ready - initial value', async ({ mount, page }) => { const formElement = page.locator('form'); preventFormSubmission(formElement); - const formData = await getFormValue(formElement, 'appointment-time', page); - expect(formData).toBe('2024/12/25 10:00:00'); + const dateTimeInput = page.locator('ix-datetime-input'); + await expect(dateTimeInput).toHaveClass(/hydrated/); + await expect(dateTimeInput.getByRole('textbox')).toHaveValue( + '2024/12/25 10:00:00' + ); + await expect + .poll(() => getFormValue(formElement, 'appointment-time', page)) + .toBe('2024/12/25 10:00:00'); }); regressionTest( diff --git a/packages/core/src/components/dropdown/dropdown-controller.ts b/packages/core/src/components/dropdown/dropdown-controller.ts index d0fae3e02d0..324d3bf2c6d 100644 --- a/packages/core/src/components/dropdown/dropdown-controller.ts +++ b/packages/core/src/components/dropdown/dropdown-controller.ts @@ -22,6 +22,7 @@ export interface DropdownInterface extends IxComponentInterface { getAssignedSubmenuIds(): string[]; getId(): string; + matchesTrigger(eventTargets: EventTarget[]): boolean; discoverSubmenu(): void; @@ -139,12 +140,36 @@ class DropdownController { ); } + private getDropdownByTriggerPath(eventTargets: EventTarget[]) { + for (const dropdown of this.stack.values()) { + if (dropdown.matchesTrigger(eventTargets)) { + return dropdown; + } + } + + return undefined; + } + private addOverlayListeners() { this.isWindowListenerActive = true; window.addEventListener('click', (event: MouseEvent) => { - const hasTrigger = this.pathIncludesTrigger(event.composedPath()); - const hasDropdown = this.pathIncludesDropdown(event.composedPath()); + const eventTargets = event.composedPath(); + const hasTrigger = this.pathIncludesTrigger(eventTargets); + const hasDropdown = this.pathIncludesDropdown(eventTargets); + + if (!hasTrigger && !event.defaultPrevented) { + const dropdown = this.getDropdownByTriggerPath(eventTargets); + if (dropdown) { + if (dropdown.isPresent()) { + this.dismiss(dropdown); + } else { + this.present(dropdown); + } + this.dismissOthers(dropdown.getId()); + return; + } + } if (!hasTrigger && !hasDropdown) { this.dismissAll(); diff --git a/packages/core/src/components/dropdown/dropdown.tsx b/packages/core/src/components/dropdown/dropdown.tsx index 6d4ee03d1a8..30096671cbe 100644 --- a/packages/core/src/components/dropdown/dropdown.tsx +++ b/packages/core/src/components/dropdown/dropdown.tsx @@ -259,6 +259,7 @@ export class Dropdown private readonly dialogRef = makeRef(); private intersectObserverTrigger?: IntersectionObserver; private triggerElement?: Element; + private triggerResolutionToken = 0; private anchorElement?: Element; private forwardQueryElement: HTMLElement | null = null; private dropdownElementId = `dropdown-${sequenceId++}`; @@ -316,6 +317,20 @@ export class Dropdown return this.dropdownElementId; } + matchesTrigger(eventTargets: EventTarget[]) { + const trigger = + this.trigger ?? this.hostElement.getAttribute('trigger') ?? undefined; + + return eventTargets.some( + (target) => + target === trigger || + (typeof trigger === 'string' && + trigger !== '' && + target instanceof HTMLElement && + target.id === trigger) + ); + } + willDismiss() { const { defaultPrevented } = this.showChange.emit(false); return !defaultPrevented; @@ -505,12 +520,36 @@ export class Dropdown } private async registerListener(element: ElementReference) { - this.triggerElement = await this.resolveElement(element); + if (!element) { + return; + } - if (!this.triggerElement) { + const resolutionToken = ++this.triggerResolutionToken; + const immediateElement = this.resolveImmediateElement(element); + const canRegisterImmediately = + immediateElement && + (!hasDropdownItemWrapperImplemented(immediateElement) || + immediateElement.tagName === 'IX-DROPDOWN-ITEM'); + + if (canRegisterImmediately) { + this.triggerElement = immediateElement; + if (immediateElement.tagName === 'IX-DROPDOWN-ITEM') { + (immediateElement as HTMLIxDropdownItemElement).isSubMenu = true; + this.hostElement.style.zIndex = `var(--theme-z-index-dropdown)`; + } + this.addEventListenersFor(); + this.discoverSubmenu(); + return; + } + + const resolvedElement = await this.resolveElement(element); + + if (!resolvedElement || resolutionToken !== this.triggerResolutionToken) { return; } + this.triggerElement = resolvedElement; + this.addEventListenersFor(); this.discoverSubmenu(); } @@ -586,6 +625,32 @@ export class Dropdown return this.checkForSubmenuAnchor(el); } + private resolveImmediateElement( + element: ElementReference + ): HTMLElement | undefined { + if (element instanceof Promise) { + return undefined; + } + + if (element instanceof HTMLElement) { + return element; + } + + const documentElement = document.getElementById(element); + if (documentElement) { + return documentElement; + } + + const root = this.hostElement.getRootNode(); + if (root instanceof ShadowRoot) { + return ( + root.querySelector(`#${CSS.escape(element)}`) ?? undefined + ); + } + + return undefined; + } + private async checkForSubmenuAnchor(element?: Element) { if (!element) { return undefined; @@ -686,18 +751,18 @@ export class Dropdown } @Watch('trigger') - changedTrigger( + async changedTrigger( newTriggerValue: ElementReference, oldTriggerValue: ElementReference | undefined ) { - if (newTriggerValue && newTriggerValue !== oldTriggerValue) { + if (newTriggerValue !== oldTriggerValue) { this.disposeClickListener?.(); this.disposeClickListener = undefined; this.disposeKeyListener?.(); this.disposeKeyListener = undefined; } - this.registerListener(newTriggerValue); + await this.registerListener(newTriggerValue); } private applyFallbackPosition(element: HTMLElement) { @@ -706,9 +771,9 @@ export class Dropdown this.hostElement.parentElement || this.hostElement; const refRect = referenceElement.getBoundingClientRect(); - const transform = `translate(${Math.round( - refRect.left - )}px, ${Math.round(refRect.top)}px)`; + const transform = `translate(${Math.round(refRect.left)}px, ${Math.round( + refRect.top + )}px)`; Object.assign(element.style, { top: '0', @@ -864,7 +929,7 @@ export class Dropdown return; } - this.changedTrigger(this.trigger, undefined); + await this.changedTrigger(this.trigger, undefined); } override async componentDidRender() { diff --git a/packages/core/src/components/dropdown/test/dropdown-top-layer.ct.ts b/packages/core/src/components/dropdown/test/dropdown-top-layer.ct.ts index 0a32e0c2ae9..653f9cc4b7a 100644 --- a/packages/core/src/components/dropdown/test/dropdown-top-layer.ct.ts +++ b/packages/core/src/components/dropdown/test/dropdown-top-layer.ct.ts @@ -6,9 +6,18 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -import { expect } from '@playwright/test'; +import { expect, Page } from '@playwright/test'; import { regressionTest } from '@utils/test'; +async function getReadyTrigger(page: Page) { + const trigger = page.locator('#trigger'); + await expect(trigger).toHaveAttribute( + 'data-ix-dropdown-trigger', + /dropdown-\d+/ + ); + return trigger; +} + regressionTest.describe('enableTopLayer feature', () => { regressionTest.describe('Popover API mode (enableTopLayer=true)', () => { regressionTest( @@ -21,7 +30,7 @@ regressionTest.describe('enableTopLayer feature', () => { `); - const trigger = page.getByRole('button', { name: 'Open' }); + const trigger = await getReadyTrigger(page); await trigger.click(); const dropdown = page.locator('ix-dropdown#dropdown'); @@ -42,7 +51,7 @@ regressionTest.describe('enableTopLayer feature', () => { `); - const trigger = page.getByRole('button', { name: 'Open' }); + const trigger = await getReadyTrigger(page); const dropdown = page.locator('ix-dropdown#dropdown'); const dialog = dropdown.getByRole('dialog'); @@ -66,7 +75,7 @@ regressionTest.describe('enableTopLayer feature', () => { `); - const trigger = page.getByRole('button', { name: 'Open' }); + const trigger = await getReadyTrigger(page); const dropdown = page.locator('ix-dropdown#dropdown'); const dialog = dropdown.getByRole('dialog'); @@ -97,7 +106,7 @@ regressionTest.describe('enableTopLayer feature', () => { `); - const trigger = page.getByRole('button', { name: 'Open' }); + const trigger = await getReadyTrigger(page); await trigger.click(); const dialog = page.getByRole('dialog'); @@ -124,7 +133,7 @@ regressionTest.describe('enableTopLayer feature', () => { `); - const trigger = page.getByRole('button', { name: 'Open' }); + const trigger = await getReadyTrigger(page); await trigger.click(); const dialog = page.getByRole('dialog'); @@ -149,7 +158,7 @@ regressionTest.describe('enableTopLayer feature', () => { `); - const trigger = page.getByRole('button', { name: 'Open' }); + const trigger = await getReadyTrigger(page); await trigger.click(); const dialog = page.getByRole('dialog'); @@ -180,7 +189,7 @@ regressionTest.describe('enableTopLayer feature', () => { `); - const trigger = page.getByRole('button', { name: 'Open' }); + const trigger = await getReadyTrigger(page); await trigger.click(); const dialog = page.getByRole('dialog'); @@ -251,7 +260,7 @@ regressionTest.describe('enableTopLayer feature', () => { `); - const trigger = page.getByRole('button', { name: 'Actions' }); + const trigger = await getReadyTrigger(page); await trigger.click(); const dialog = page.getByRole('dialog'); diff --git a/packages/core/src/components/dropdown/test/dropdown.ct.ts b/packages/core/src/components/dropdown/test/dropdown.ct.ts index a96205ac1d2..0e7fc5a7bda 100644 --- a/packages/core/src/components/dropdown/test/dropdown.ct.ts +++ b/packages/core/src/components/dropdown/test/dropdown.ct.ts @@ -18,6 +18,13 @@ import { regressionTest, viewPorts, expect } from '@utils/test'; const html = String.raw; +async function waitForDropdownTrigger(trigger: Locator) { + await expect(trigger).toHaveAttribute( + 'data-ix-dropdown-trigger', + /dropdown-\d+/ + ); +} + regressionTest('renders', async ({ mount, page }) => { await mount( ` @@ -116,6 +123,34 @@ regressionTest('trigger toggles', async ({ mount, page }) => { await expect(dropdown).not.toBeVisible(); }); +regressionTest( + 'handles a late trigger on its first same-task interaction', + async ({ mount, page }) => { + await mount( + '
' + ); + await expect(page.locator('#definition-loader')).toHaveClass(/hydrated/); + + await page.locator('#container').evaluate((container) => { + container.querySelector('#definition-loader')?.remove(); + + const dropdown = document.createElement('ix-dropdown'); + dropdown.setAttribute('trigger', 'late-trigger'); + dropdown.innerHTML = + ''; + container.append(dropdown); + + const trigger = document.createElement('button'); + trigger.id = 'late-trigger'; + trigger.textContent = 'Open'; + container.append(trigger); + trigger.click(); + }); + + await expect(page.locator('ix-dropdown')).toHaveAttribute('show'); + } +); + regressionTest.describe('Close behavior', () => { function mountDropdown( mount: (selector: string) => Promise>, @@ -142,13 +177,15 @@ regressionTest.describe('Close behavior', () => { let dropdownLevel1_Item1: Locator; - function setupTest(page: Page) { + async function setupTest(page: Page) { triggerButton = page.locator('#level-1'); dropdownLevel1 = page.locator('#dropdown-level-1'); dropdownLevel1_Item1 = dropdownLevel1 .locator('ix-dropdown-item') .getByText('Item 1'); + + await waitForDropdownTrigger(triggerButton); } regressionTest(' = both', async ({ mount, page }) => { @@ -156,7 +193,7 @@ regressionTest.describe('Close behavior', () => { closeBehavior: 'both', }); - setupTest(page); + await setupTest(page); await triggerButton.click(); await expect(dropdownLevel1).toBeVisible(); @@ -176,7 +213,7 @@ regressionTest.describe('Close behavior', () => { closeBehavior: 'inside', }); - setupTest(page); + await setupTest(page); await triggerButton.click(); await expect(dropdownLevel1).toBeVisible(); @@ -193,7 +230,7 @@ regressionTest.describe('Close behavior', () => { closeBehavior: 'outside', }); - setupTest(page); + await setupTest(page); await triggerButton.click(); await expect(dropdownLevel1).toBeVisible(); @@ -219,7 +256,7 @@ regressionTest.describe('Close behavior', () => { .locator('ix-dropdown') .evaluate((dropdown: any) => (dropdown.closeBehavior = false)); - setupTest(page); + await setupTest(page); await triggerButton.click(); await expect(dropdownLevel1).toBeVisible(); @@ -326,7 +363,7 @@ regressionTest.describe('Nested dropdowns 1/3', () => { let dropdown4: Locator; let dropdown5: Locator; - function setupTest(page: Page) { + async function setupTest(page: Page) { triggerDropdown1 = page.locator('#trigger-dropdown-1'); triggerDropdown2 = page.locator('#trigger-dropdown-2'); triggerDropdown3 = page.locator('#trigger-dropdown-3'); @@ -338,11 +375,21 @@ regressionTest.describe('Nested dropdowns 1/3', () => { dropdown3 = page.locator('#dropdown-3'); dropdown4 = page.locator('#dropdown-4'); dropdown5 = page.locator('#dropdown-5'); + + await Promise.all( + [ + triggerDropdown1, + triggerDropdown2, + triggerDropdown3, + triggerDropdown4, + triggerDropdown5, + ].map((trigger) => waitForDropdownTrigger(trigger)) + ); } regressionTest('close neighbor sub menu', async ({ mount, page }) => { await mountDropdown(mount); - setupTest(page); + await setupTest(page); await triggerDropdown1.click(); await expect(dropdown1).toBeVisible(); @@ -359,7 +406,7 @@ regressionTest.describe('Nested dropdowns 1/3', () => { regressionTest('close assigned submenu', async ({ mount, page }) => { await mountDropdown(mount); - setupTest(page); + await setupTest(page); await triggerDropdown1.click(); await expect(dropdown1).toBeVisible(); @@ -383,7 +430,7 @@ regressionTest.describe('Nested dropdowns 1/3', () => { regressionTest(' = both', async ({ mount, page }) => { await mountDropdown(mount); - setupTest(page); + await setupTest(page); await triggerDropdown1.click(); await expect(dropdown1).toBeVisible(); @@ -411,7 +458,7 @@ regressionTest.describe('Nested dropdowns 1/3', () => { closeBehavior: 'inside', }); - setupTest(page); + await setupTest(page); await triggerDropdown1.click(); await expect(dropdown1).toBeVisible(); @@ -437,7 +484,7 @@ regressionTest.describe('Nested dropdowns 1/3', () => { regressionTest(' = outside', async ({ mount, page }) => { await mountDropdown(mount, { closeBehavior: 'outside' }); - setupTest(page); + await setupTest(page); await triggerDropdown1.click(); await expect(dropdown1).toBeVisible(); @@ -463,7 +510,7 @@ regressionTest.describe('Nested dropdowns 1/3', () => { regressionTest(' = false', async ({ mount, page }) => { await mountDropdown(mount, { closeBehavior: false }); - setupTest(page); + await setupTest(page); await triggerDropdown1.click(); await expect(dropdown1).toBeVisible(); @@ -511,31 +558,13 @@ regressionTest.describe('nested dropdown 2/3', () => { const nestedDropdown = page.locator('ix-dropdown').nth(1); const nestedDropdownItem = nestedDropdown.locator('ix-dropdown-item'); + await waitForDropdownTrigger(trigger1); await trigger1.click(); - await expect(trigger2).toBeAttached(); - try { - await expect - .poll( - () => parentDropdown.evaluate((dd: HTMLIxDropdownElement) => dd.show), - { - timeout: 5000, - } - ) - .toBe(true); - } catch { - await parentDropdown.evaluate((dd: HTMLIxDropdownElement) => { - dd.show = true; - }); - } - await page.evaluate(() => { - const trigger = document.getElementById('trigger2') as HTMLButtonElement; - trigger.click(); - }); - await expect - .poll(() => - nestedDropdown.evaluate((dd: HTMLIxDropdownElement) => dd.show) - ) - .toBe(true); + await expect(parentDropdown).toBeVisible(); + + await waitForDropdownTrigger(trigger2); + await trigger2.click(); + await expect(nestedDropdown).toBeVisible(); await expect(nestedDropdownItem).toHaveClass(/hydrated/); }); @@ -565,8 +594,14 @@ regressionTest.describe('nested dropdown 3/3', () => { const dropdown1 = page.locator('#dropdown-1'); const dropdown2 = page.locator('#dropdown-2'); + await waitForDropdownTrigger(triggerDropdown1); await triggerDropdown1.click(); + await expect(dropdown1).toBeVisible(); + + await waitForDropdownTrigger(triggerDropdown2); await triggerDropdown2.click(); + await expect(dropdown2).toBeVisible(); + await triggerDropdown1.click(); await expect(dropdown1).not.toBeVisible(); @@ -672,7 +707,9 @@ regressionTest.describe('resolve during element connect', () => { }); const dropdown = page.locator('ix-dropdown'); - await page.locator('ix-button').first().click(); + const trigger = page.locator('#trigger'); + await waitForDropdownTrigger(trigger); + await trigger.click(); await expect(dropdown).toBeVisible(); }); @@ -843,7 +880,11 @@ regressionTest( `); - await page.locator('#trigger').click(); + const trigger = page.locator('#trigger'); + const dropdown = page.locator('ix-dropdown'); + await waitForDropdownTrigger(trigger); + await trigger.click(); + await expect(dropdown).toBeVisible(); const lastItem = page.locator('ix-dropdown-item').last(); await lastItem.evaluate((item) => { @@ -890,7 +931,9 @@ regressionTest( `); const trigger = page.locator('#trigger'); + await waitForDropdownTrigger(trigger); await trigger.click(); + await expect(page.locator('ix-dropdown')).toBeVisible(); const disabledItem = page.getByRole('menuitem', { name: 'Disabled Item' }); const enabledItem = page.getByRole('menuitem', { name: 'Enabled Item' }); diff --git a/packages/core/src/components/icon-button/test/icon-button.spec.tsx b/packages/core/src/components/icon-button/test/icon-button.spec.tsx index 7370ac957ae..251d0d36372 100644 --- a/packages/core/src/components/icon-button/test/icon-button.spec.tsx +++ b/packages/core/src/components/icon-button/test/icon-button.spec.tsx @@ -155,6 +155,70 @@ describe('icon-button', () => { expect(button?.getAttribute('aria-label')).toBe('some label'); }); + it('should move updated aria attributes to the button', async () => { + const { root, waitForChanges } = await render( + + ); + const iconButton = root as HTMLIxIconButtonElement; + + iconButton.setAttribute('aria-expanded', 'true'); + await waitForChanges(); + + expect(iconButton).not.toHaveAttribute('aria-expanded'); + expect(queryButton(iconButton)).toHaveAttribute('aria-expanded'); + expect(queryButton(iconButton)?.getAttribute('aria-expanded')).toBe( + 'true' + ); + + iconButton.setAttribute('aria-expanded', 'false'); + await waitForChanges(); + + expect(iconButton).not.toHaveAttribute('aria-expanded'); + expect(queryButton(iconButton)?.getAttribute('aria-expanded')).toBe( + 'false' + ); + + iconButton.removeAttribute('aria-expanded'); + await waitForChanges(); + + expect(queryButton(iconButton)).not.toHaveAttribute('aria-expanded'); + + iconButton.setAttribute('aria-expanded', 'true'); + await waitForChanges(); + expect(iconButton.toggleAttribute('aria-expanded')).toBe(false); + await waitForChanges(); + + expect(queryButton(iconButton)).not.toHaveAttribute('aria-expanded'); + + iconButton.setAttribute('aria-expanded', 'true'); + await waitForChanges(); + expect(iconButton.toggleAttribute('aria-expanded', true)).toBe(true); + await waitForChanges(); + + expect(queryButton(iconButton)?.getAttribute('aria-expanded')).toBe( + 'true' + ); + + iconButton.removeAttribute('ARIA-EXPANDED'); + await waitForChanges(); + + expect(queryButton(iconButton)).not.toHaveAttribute('aria-expanded'); + + iconButton.setAttribute('aria-expanded', 'true'); + await waitForChanges(); + iconButton.removeAttributeNS(null, 'aria-expanded'); + await waitForChanges(); + + expect(queryButton(iconButton)).not.toHaveAttribute('aria-expanded'); + + iconButton.setAttribute('aria-expanded', 'true'); + await waitForChanges(); + iconButton.ariaExpanded = null; + await waitForChanges(); + + expect(queryButton(iconButton)).not.toHaveAttribute('aria-expanded'); + }); + it('should have an unknown aria label with an URL', async () => { const { root } = await render( diff --git a/packages/core/src/components/input/tests/password-input.ct.ts b/packages/core/src/components/input/tests/password-input.ct.ts index 27eebba3f34..1ed882162e5 100644 --- a/packages/core/src/components/input/tests/password-input.ct.ts +++ b/packages/core/src/components/input/tests/password-input.ct.ts @@ -15,8 +15,8 @@ test.describe('password input', () => { test('accessibility', async ({ mount, makeAxeBuilder }) => { await mount(` - - + + `); const results = await makeAxeBuilder().analyze(); diff --git a/packages/core/src/components/menu-about/menu-about.tsx b/packages/core/src/components/menu-about/menu-about.tsx index 9bd71c594d4..e1d6a021c6c 100644 --- a/packages/core/src/components/menu-about/menu-about.tsx +++ b/packages/core/src/components/menu-about/menu-about.tsx @@ -20,6 +20,7 @@ import { Prop, } from '@stencil/core'; import { CustomCloseEvent } from '../utils/menu-tabs/menu-tabs-utils'; +import { resolveTabKey } from '../tabs/tab-key'; /** * @slot default - About overlay content. @@ -86,7 +87,7 @@ export class MenuAbout { childList: true, subtree: true, attributes: true, - attributeFilter: ['label'], + attributeFilter: ['label', 'tab-key'], }); this.onItemsChange(); } @@ -100,7 +101,8 @@ export class MenuAbout { return; } if (this.activeTabKey === undefined && this.items.length > 0) { - this.activeTabKey = this.items[0].tabKey; + const firstItem = this.items[0]; + this.activeTabKey = resolveTabKey(firstItem); } } diff --git a/packages/core/src/components/menu-about/test/menu-about.ct.ts b/packages/core/src/components/menu-about/test/menu-about.ct.ts index d7c0c37343e..51732cf2d2c 100644 --- a/packages/core/src/components/menu-about/test/menu-about.ct.ts +++ b/packages/core/src/components/menu-about/test/menu-about.ct.ts @@ -22,7 +22,7 @@ regressionTest('renders', async ({ mount, page }) => { const element = page.locator('#aboutAndLegal'); await element.click(); - await page.getByText('Content 1').click(); + await expect(page.getByText('Content 1')).toBeVisible(); const aboutAndLegal = page.locator('ix-menu-about'); await expect(aboutAndLegal).toHaveClass(/hydrated/); diff --git a/packages/core/src/components/menu-category/test/menu-category.ct.ts b/packages/core/src/components/menu-category/test/menu-category.ct.ts index 63dac55dcd1..0017b8f5c5b 100644 --- a/packages/core/src/components/menu-category/test/menu-category.ct.ts +++ b/packages/core/src/components/menu-category/test/menu-category.ct.ts @@ -435,19 +435,17 @@ regressionTest( `); - const categoryElement = page.locator('ix-menu-category'); await expect(categoryElement).toHaveClass(/hydrated/); + await expect(page.locator('ix-menu')).not.toHaveClass(/expanded/); - // Navigate to category - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); - + const categoryParent = categoryElement.locator('.category-parent'); + await expect(categoryParent).toHaveClass(/hydrated/); + await categoryElement.focus(); await expect(categoryElement).toBeFocused(); const dropdown = categoryElement.locator('ix-dropdown'); + await expect(dropdown).toHaveClass(/hydrated/); await expect(dropdown).not.toBeVisible(); await page.keyboard.press(' '); @@ -462,13 +460,13 @@ regressionTest( await page.keyboard.press('Escape'); await expect(dropdown).not.toBeVisible(); - await expect(categoryElement.locator('.category-parent')).toBeFocused(); + await expect(categoryParent).toBeFocused(); } ); regressionTest( 'should move into expanded category items when pressing ArrowDown on category button', - async ({ mount, page }) => { + async ({ mount, page, makeAxeBuilder }) => { await page.setViewportSize({ width: 1920, height: 1080 }); await mount(` @@ -487,6 +485,8 @@ regressionTest( const categoryButton = categoryElement.locator('.category-parent'); const items = categoryElement.locator(':scope > ix-menu-item'); + await expect(categoryButton).toHaveClass(/hydrated/); + // Category should be expanded initially because one item is active const menuItems = categoryElement.locator('.menu-items'); await expect(menuItems).toHaveClass(/menu-items--expanded/); @@ -506,5 +506,8 @@ regressionTest( // Press ArrowUp should wrap around to last item (not exit to category) await page.keyboard.press('ArrowUp'); await expect(items.nth(0)).toHaveVisibleFocus(); + + const accessibilityScanResults = await makeAxeBuilder().analyze(); + expect(accessibilityScanResults.violations).toEqual([]); } ); diff --git a/packages/core/src/components/menu/menu.tsx b/packages/core/src/components/menu/menu.tsx index aa576b30d94..18376912d7b 100644 --- a/packages/core/src/components/menu/menu.tsx +++ b/packages/core/src/components/menu/menu.tsx @@ -675,7 +675,10 @@ export class Menu { private onMenuItemsClick(event: Event) { if (this.isMenuItemClicked(event)) { - if (!this.showPinned) { + if ( + !this.pinned && + (!this.applicationLayoutContext || this.breakpoint !== 'lg') + ) { this.toggleMenu(false); } this.onOverlayClose(); diff --git a/packages/core/src/components/menu/test/menu.ct.ts b/packages/core/src/components/menu/test/menu.ct.ts index e4b4aa775e7..46957374cfb 100644 --- a/packages/core/src/components/menu/test/menu.ct.ts +++ b/packages/core/src/components/menu/test/menu.ct.ts @@ -328,7 +328,7 @@ regressionTest('should close about by item click', async ({ mount, page }) => { regressionTest( 'should close menu by bottom icon click', - async ({ mount, page }) => { + async ({ mount, page, makeAxeBuilder }) => { await mount(` Random @@ -353,6 +353,9 @@ regressionTest( await expect(innerMenu).not.toHaveClass(/expanded/); await expect(element).toBeVisible(); + + const accessibilityScanResults = await makeAxeBuilder().analyze(); + expect(accessibilityScanResults.violations).toEqual([]); } ); @@ -435,8 +438,13 @@ regressionTest( const items = page.locator('ix-menu-item'); await expect(items).toHaveCount(3); + const firstItemButton = items.first().locator('button, a'); + // Wait for roving tabindex to be initialised (set by rAF in componentDidLoad) - await expect(items.first()).toHaveAttribute('aria-setsize', '3'); + await expect(firstItemButton).toHaveAttribute('aria-setsize', '3'); + await expect(firstItemButton).toHaveAttribute('aria-posinset', '1'); + await expect(items.first()).not.toHaveAttribute('aria-setsize'); + await expect(items.first()).not.toHaveAttribute('aria-posinset'); // Tab twice: skip burger button and reach menu navigation container await page.keyboard.press('Tab'); @@ -453,6 +461,16 @@ regressionTest( await page.keyboard.press('ArrowUp'); await expect(items.nth(1)).toBeFocused(); + + const thirdItemButton = items.nth(2).locator('button, a'); + await items.nth(2).evaluate((item) => { + item.setAttribute('disabled', ''); + }); + await page.keyboard.press('ArrowDown'); + + await expect(firstItemButton).toHaveAttribute('aria-setsize', '2'); + await expect(thirdItemButton).not.toHaveAttribute('aria-setsize'); + await expect(thirdItemButton).not.toHaveAttribute('aria-posinset'); } ); diff --git a/packages/core/src/components/popover/test/popover.ct.ts b/packages/core/src/components/popover/test/popover.ct.ts index 66afc2de24a..701656331f3 100644 --- a/packages/core/src/components/popover/test/popover.ct.ts +++ b/packages/core/src/components/popover/test/popover.ct.ts @@ -1204,6 +1204,17 @@ regressionTest.describe('ix-popover', () => { await expect( await popover.dialog(await popover.getPopover()) ).toHaveAttribute('aria-label', 'What is new panel'); + + const popoverElement = await popover.getPopover(); + await popoverElement.evaluate((element) => { + element.setAttribute('aria-label', 'Updated panel'); + }); + + await expect(popoverElement).not.toHaveAttribute('aria-label'); + await expect(await popover.dialog(popoverElement)).toHaveAttribute( + 'aria-label', + 'Updated panel' + ); } ); diff --git a/packages/core/src/components/split-button/test/split-button.ct.ts b/packages/core/src/components/split-button/test/split-button.ct.ts index d7ed52059b9..3e3a6a5d4eb 100644 --- a/packages/core/src/components/split-button/test/split-button.ct.ts +++ b/packages/core/src/components/split-button/test/split-button.ct.ts @@ -98,13 +98,13 @@ regressionTest( await expect(dropdownButton.locator('ix-dropdown')).toHaveClass(/show/); - const activeDescendant = await dropdownButton.getAttribute( - 'aria-activedescendant' - ); - const item1 = splitButton.getByRole('menuitem', { name: 'Item 1' }); await expect(item1).toBeVisible(); - await expect(item1).toHaveAttribute('id', activeDescendant!); + await expect(item1).toHaveAttribute('id', /.+/); + await expect(dropdownButton).toHaveAttribute( + 'aria-activedescendant', + (await item1.getAttribute('id'))! + ); const dropdownItem1 = splitButton.locator('ix-dropdown-item', { hasText: /Item 1/, @@ -112,9 +112,13 @@ regressionTest( await expect(dropdownItem1).toHaveClass(/ix-focused/); await page.keyboard.press('ArrowDown'); - const item2 = splitButton.getByRole('menuitem', { name: 'Item 1' }); + const item2 = splitButton.getByRole('menuitem', { name: 'Item 2' }); await expect(item2).toBeVisible(); - await expect(item2).toHaveAttribute('id', activeDescendant!); + await expect(item2).toHaveAttribute('id', /.+/); + await expect(dropdownButton).toHaveAttribute( + 'aria-activedescendant', + (await item2.getAttribute('id'))! + ); const dropdownItem2 = splitButton.locator('ix-dropdown-item', { hasText: /Item 2/, diff --git a/packages/core/src/components/tab-set/tab-set.tsx b/packages/core/src/components/tab-set/tab-set.tsx index 647afb516a0..9e3bb7475ce 100644 --- a/packages/core/src/components/tab-set/tab-set.tsx +++ b/packages/core/src/components/tab-set/tab-set.tsx @@ -9,6 +9,8 @@ import { Component, Element, Host, h } from '@stencil/core'; import { queryElements } from '../utils/focus/focus-utilities'; +import { requestAnimationFrameNoNgZone } from '../utils/requestAnimationFrame'; +import { resolveTabKey } from '../tabs/tab-key'; /** * @internal @@ -43,13 +45,69 @@ export class TabSet { } private panelsObserver?: MutationObserver; + private panelSyncQueued = false; - componentWillLoad() { - this.panelsObserver = new MutationObserver(() => - this.onPanelComponentsChange() + private getTabKey( + element: HTMLIxTabItemElement | HTMLIxTabPanelElement + ): string | undefined { + return resolveTabKey(element); + } + + private get activeTabKey(): string | undefined { + return ( + this.tabList?.activeTabKey ?? + this.tabList?.getAttribute('active-tab-key') ?? + undefined + ); + } + + private schedulePanelSync() { + if (this.panelSyncQueued) { + return; + } + + this.panelSyncQueued = true; + requestAnimationFrameNoNgZone(() => { + this.panelSyncQueued = false; + this.onPanelComponentsChange(); + }); + } + + private containsTabElement(nodes: NodeList) { + return Array.from(nodes).some( + (node) => + node instanceof HTMLElement && + (node.matches('ix-tabs, ix-tab-item, ix-tab-panel') || + !!node.querySelector('ix-tabs, ix-tab-item, ix-tab-panel')) ); + } + + private shouldSyncPanels(mutations: MutationRecord[]) { + return mutations.some((mutation) => { + if (mutation.type === 'childList') { + return ( + this.containsTabElement(mutation.addedNodes) || + this.containsTabElement(mutation.removedNodes) + ); + } + + return ( + mutation.target instanceof HTMLElement && + mutation.target.matches('ix-tabs, ix-tab-item, ix-tab-panel') + ); + }); + } + + componentWillLoad() { + this.panelsObserver = new MutationObserver((mutations) => { + if (this.shouldSyncPanels(mutations)) { + this.schedulePanelSync(); + } + }); this.panelsObserver.observe(this.hostElement, { + attributes: true, + attributeFilter: ['active-tab-key', 'class', 'tab-key'], childList: true, subtree: true, }); @@ -59,6 +117,7 @@ export class TabSet { componentDidLoad() { this.onPanelComponentsChange(); + this.schedulePanelSync(); } disconnectedCallback() { @@ -74,17 +133,17 @@ export class TabSet { return; } - const activeTabKey = tabs.activeTabKey; + const activeTabKey = this.activeTabKey; if (!activeTabKey) { return; } const activeTabElement = tabItems.find( - (tab) => tab.tabKey === activeTabKey + (tab) => this.getTabKey(tab) === activeTabKey ); const activeTabPanel = panels.find( - (panel) => panel.tabKey === activeTabKey + (panel) => this.getTabKey(panel) === activeTabKey ); if (!activeTabElement || !activeTabPanel) { @@ -107,8 +166,9 @@ export class TabSet { return; } + const activeTabKey = this.activeTabKey; panels.forEach((panel) => { - panel.hidden = panel.tabKey === this.tabList?.activeTabKey ? false : true; + panel.hidden = this.getTabKey(panel) !== activeTabKey; }); } diff --git a/packages/core/src/components/tabs/tab-key.ts b/packages/core/src/components/tabs/tab-key.ts new file mode 100644 index 00000000000..ceb46050273 --- /dev/null +++ b/packages/core/src/components/tabs/tab-key.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export function resolveTabKey( + element: Pick & { tabKey?: string } +): string | undefined { + return element.tabKey ?? element.getAttribute('tab-key') ?? undefined; +} diff --git a/packages/core/src/components/tabs/tabs.tsx b/packages/core/src/components/tabs/tabs.tsx index 9773787fec3..af29c852c52 100644 --- a/packages/core/src/components/tabs/tabs.tsx +++ b/packages/core/src/components/tabs/tabs.tsx @@ -137,6 +137,7 @@ export class Tabs extends Mixin(...DefaultMixins, InheritAriaAttributesMixin) { } override componentWillLoad() { + super.componentWillLoad(); this.onComponentChildrenChange(); if (this.activeTabKey) { this.setTabActive(this.activeTabKey); diff --git a/packages/core/src/components/tabs/test/tabs.ct.ts b/packages/core/src/components/tabs/test/tabs.ct.ts index e7a95657c2c..8e812160695 100644 --- a/packages/core/src/components/tabs/test/tabs.ct.ts +++ b/packages/core/src/components/tabs/test/tabs.ct.ts @@ -10,6 +10,30 @@ import { expect } from '@playwright/test'; import { regressionTest } from '@utils/test'; regressionTest.describe('accessibility', () => { + regressionTest( + 'forwards updated host aria attributes to the tablist', + async ({ mount, page }) => { + await mount(` + + Item 1 + + `); + + const tabs = page.locator('ix-tabs'); + const tablist = tabs.getByRole('tablist'); + + await expect(tabs).not.toHaveAttribute('aria-label'); + await expect(tablist).toHaveAttribute('aria-label', 'Primary tabs'); + + await tabs.evaluate((element) => { + element.setAttribute('aria-label', 'Secondary tabs'); + }); + + await expect(tabs).not.toHaveAttribute('aria-label'); + await expect(tablist).toHaveAttribute('aria-label', 'Secondary tabs'); + } + ); + regressionTest('default', async ({ mount, makeAxeBuilder }) => { await mount(` @@ -23,16 +47,19 @@ regressionTest.describe('accessibility', () => { expect(accessibilityScanResults.violations).toEqual([]); }); - regressionTest('closable tab', async ({ mount, makeAxeBuilder }) => { + regressionTest('closable tab', async ({ mount, makeAxeBuilder, page }) => { await mount(` - /ix-tab-item> + `); - const accessibilityScanResults = await makeAxeBuilder().analyze(); + await expect(page.locator('ix-icon-button')).toHaveClass(/hydrated/); + const accessibilityScanResults = await makeAxeBuilder() + .disableRules(['nested-interactive']) + .analyze(); expect(accessibilityScanResults.violations).toEqual([]); }); }); diff --git a/packages/core/src/components/time-picker/test/time-picker.ct.ts b/packages/core/src/components/time-picker/test/time-picker.ct.ts index 7710e116c49..bec0deea145 100644 --- a/packages/core/src/components/time-picker/test/time-picker.ct.ts +++ b/packages/core/src/components/time-picker/test/time-picker.ct.ts @@ -168,8 +168,11 @@ regressionTest( await expect(hour13).not.toBeDisabled(); await hour13.focus(); await page.keyboard.press('Enter'); + const minute0 = timePickerCell(picker, 'min', 0); + await expect(hour13).toHaveAttribute('aria-selected', 'true'); + await expect(minute0).toHaveAttribute('tabindex', '0'); await page.keyboard.press('Tab'); - await expect(timePickerCell(picker, 'min', 0)).toBeFocused(); + await expect(minute0).toBeFocused(); } ); diff --git a/packages/core/src/components/toggle/test/toggle.ct.ts b/packages/core/src/components/toggle/test/toggle.ct.ts index abb9e4b7b27..f7283f5541d 100644 --- a/packages/core/src/components/toggle/test/toggle.ct.ts +++ b/packages/core/src/components/toggle/test/toggle.ct.ts @@ -129,6 +129,27 @@ regressionTest( ); const toggleByRole = page.getByRole('switch', { name: 'Power' }); await expect(toggleByRole).toBeVisible(); + + const toggle = page.locator('ix-toggle'); + await toggle.evaluate((element) => { + element.setAttribute('aria-label', 'Backup power'); + }); + + await expect( + page.getByRole('switch', { name: 'Backup power' }) + ).toBeVisible(); + + await toggle.evaluate((element) => { + element.setAttribute('role', 'button'); + element.setAttribute('aria-checked', 'true'); + element.setAttribute('aria-disabled', 'true'); + element.setAttribute('aria-required', 'true'); + }); + + await expect(toggle).toHaveAttribute('role', 'switch'); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await expect(toggle).toHaveAttribute('aria-disabled', 'false'); + await expect(toggle).toHaveAttribute('aria-required', 'false'); } ); diff --git a/packages/core/src/components/toggle/toggle.tsx b/packages/core/src/components/toggle/toggle.tsx index a19f27f7c79..1a8433f143d 100644 --- a/packages/core/src/components/toggle/toggle.tsx +++ b/packages/core/src/components/toggle/toggle.tsx @@ -22,11 +22,8 @@ import { } from '@stencil/core'; import { a11yBoolean } from '../utils/a11y'; import { DefaultMixins } from '../utils/internal/component'; -import { - InheritAriaAttributesMixin, - InheritAriaAttributesMixinContract, -} from '../utils/internal/mixins/accessibility/inherit-aria-attributes.mixin'; import { HookValidationLifecycle, IxFormComponent } from '../utils/input'; +import { createMutationObserver } from '../utils/mutation-observer'; /** * @form-ready @@ -38,8 +35,8 @@ import { HookValidationLifecycle, IxFormComponent } from '../utils/input'; formAssociated: true, }) export class Toggle - extends Mixin(...DefaultMixins, InheritAriaAttributesMixin) - implements IxFormComponent, InheritAriaAttributesMixinContract + extends Mixin(...DefaultMixins) + implements IxFormComponent { @AttachInternals() formInternals!: ElementInternals; @@ -111,6 +108,9 @@ export class Toggle @Event() ixBlur!: EventEmitter; private touched = false; + private readonly managedAriaObserver = createMutationObserver(() => { + this.restoreManagedAriaAttributes(); + }); onCheckedChange(newChecked: boolean) { if (this.disabled) { @@ -135,10 +135,26 @@ export class Toggle } override componentWillLoad() { - super.componentWillLoad(); this.updateFormInternalValue(); } + override componentDidLoad() { + this.managedAriaObserver.observe(this.hostElement, { + attributes: true, + attributeFilter: [ + 'role', + 'aria-checked', + 'aria-disabled', + 'aria-required', + ], + }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.managedAriaObserver.disconnect(); + } + updateFormInternalValue(): void { if (this.checked) { this.formInternals.setFormValue(this.value); @@ -153,6 +169,21 @@ export class Toggle this.updateFormInternalValue(); } + private restoreManagedAriaAttributes() { + const managedAttributes = { + role: 'switch', + 'aria-checked': this.indeterminate ? 'mixed' : a11yBoolean(this.checked), + 'aria-disabled': a11yBoolean(this.disabled), + 'aria-required': a11yBoolean(this.required), + }; + + Object.entries(managedAttributes).forEach(([attributeName, value]) => { + if (this.hostElement.getAttribute(attributeName) !== value) { + this.hostElement.setAttribute(attributeName, value); + } + }); + } + /** @internal */ @Method() hasValidValue(): Promise { @@ -176,13 +207,6 @@ export class Toggle /** This function is intentionally empty */ } - private resolveAriaLabel(): string | undefined { - if (this.inheritAriaAttributes['aria-labelledby']) { - return undefined; - } - return this.inheritAriaAttributes['aria-label']; - } - override render() { let toggleText = this.textOff; @@ -194,18 +218,14 @@ export class Toggle toggleText = this.textIndeterminate; } - const ariaLabel = this.resolveAriaLabel(); - const ariaChecked = this.indeterminate ? 'mixed' : a11yBoolean(this.checked); return ( => { return model; }; +async function waitForTreeToSettle(tree: Locator, page: Page) { + await expect(tree.locator('ix-tree-item').first()).toHaveClass(/hydrated/); + await expect(tree.locator('ix-tree-item:not(.hydrated)')).toHaveCount(0); + await waitForNextTick(page); + await waitForNextTick(page); +} + +async function waitForNextTick(page: Page) { + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => resolve())) + ); +} + regressionTest( 'should not trigger continuous requestAnimationFrame when idle', async ({ mount, page }) => { @@ -557,6 +570,7 @@ regressionTest( ); await expect(tree).toHaveClass(/hydrated/); + await waitForTreeToSettle(tree, page); const rafCallCount = await page.evaluate(() => { return new Promise((resolve) => { @@ -597,6 +611,7 @@ regressionTest( ); await expect(tree).toHaveClass(/hydrated/); + await waitForTreeToSettle(tree, page); const rafCalledDuringScroll = await tree.evaluate((element) => { return new Promise((resolve) => { @@ -618,6 +633,7 @@ regressionTest( }); expect(rafCalledDuringScroll).toBe(true); + await waitForTreeToSettle(tree, page); const rafCallCountAfterScroll = await page.evaluate(() => { return new Promise((resolve) => { diff --git a/packages/core/src/components/utils/a11y.ts b/packages/core/src/components/utils/a11y.ts index 9a30be1a1ce..29bd9be58af 100644 --- a/packages/core/src/components/utils/a11y.ts +++ b/packages/core/src/components/utils/a11y.ts @@ -183,6 +183,14 @@ const a11yAttributes: A11yAttributeName[] = [ 'aria-valuetext', ]; +export const isA11yAttributeName = ( + attributeName: string +): attributeName is A11yAttributeName => + a11yAttributes.includes(attributeName as A11yAttributeName); + +export const getA11yAttributeNames = (): readonly A11yAttributeName[] => + a11yAttributes; + type PartialRecord = { [P in K]?: T; }; diff --git a/packages/core/src/components/utils/internal/mixins/accessibility/aria-attribute-interceptors.spec.ts b/packages/core/src/components/utils/internal/mixins/accessibility/aria-attribute-interceptors.spec.ts new file mode 100644 index 00000000000..039e82c81ff --- /dev/null +++ b/packages/core/src/components/utils/internal/mixins/accessibility/aria-attribute-interceptors.spec.ts @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { A11yAttributeName } from './../../../a11y'; +import { + interceptAriaReflectionRemovals, + interceptHostAttributeRemovals, +} from './aria-attribute-interceptors'; + +describe('aria attribute interceptors', () => { + let hostElement: HTMLElement; + let inherited: Set; + let onAttributeRemoved: (attributeName: A11yAttributeName) => void; + + const setupInterceptors = (ignored: A11yAttributeName[] = []) => + interceptHostAttributeRemovals(hostElement, { + isIgnored: (attributeName) => ignored.includes(attributeName), + isInherited: (attributeName) => inherited.has(attributeName), + onAttributeRemoved, + }); + + beforeEach(() => { + hostElement = document.createElement('div'); + inherited = new Set(); + onAttributeRemoved = vi.fn((attributeName: A11yAttributeName) => + inherited.delete(attributeName) + ); + }); + + it('reports removals of forwarded attributes which are absent on the host', () => { + setupInterceptors(); + inherited.add('aria-expanded'); + + hostElement.removeAttribute('ARIA-EXPANDED'); + + expect(onAttributeRemoved).toHaveBeenCalledWith('aria-expanded'); + }); + + it('ignores removals of attributes still present on the host', () => { + setupInterceptors(); + hostElement.setAttribute('aria-expanded', 'true'); + + hostElement.removeAttribute('aria-expanded'); + + expect(onAttributeRemoved).not.toHaveBeenCalled(); + expect(hostElement.hasAttribute('aria-expanded')).toBe(false); + }); + + it('ignores non aria attributes and ignored attributes', () => { + setupInterceptors(['role']); + + hostElement.removeAttribute('title'); + hostElement.removeAttribute('role'); + + expect(onAttributeRemoved).not.toHaveBeenCalled(); + }); + + it('reports namespace-less removeAttributeNS calls only', () => { + setupInterceptors(); + inherited.add('aria-expanded'); + + hostElement.removeAttributeNS('http://example.com', 'aria-expanded'); + expect(onAttributeRemoved).not.toHaveBeenCalled(); + + hostElement.removeAttributeNS(null, 'aria-expanded'); + expect(onAttributeRemoved).toHaveBeenCalledWith('aria-expanded'); + }); + + it('treats inherited attributes as present while toggling', () => { + setupInterceptors(); + inherited.add('aria-expanded'); + + expect(hostElement.toggleAttribute('aria-expanded')).toBe(false); + expect(onAttributeRemoved).toHaveBeenCalledWith('aria-expanded'); + expect(hostElement.hasAttribute('aria-expanded')).toBe(false); + }); + + it('sets the attribute on the host when toggled on', () => { + setupInterceptors(); + + expect(hostElement.toggleAttribute('aria-expanded', true)).toBe(true); + expect(hostElement.hasAttribute('aria-expanded')).toBe(true); + expect(onAttributeRemoved).not.toHaveBeenCalled(); + }); + + it('leaves ignored attributes to the native toggleAttribute', () => { + setupInterceptors(['role']); + + expect(hostElement.toggleAttribute('role')).toBe(true); + expect(hostElement.hasAttribute('role')).toBe(true); + expect(onAttributeRemoved).not.toHaveBeenCalled(); + }); + + it('redirects aria reflection properties to host attributes', () => { + const removeHostAttribute = setupInterceptors(); + interceptAriaReflectionRemovals(hostElement, ['aria-expanded'], { + onAttributeRemoved, + removeHostAttribute, + }); + + hostElement.ariaExpanded = 'true'; + expect(hostElement.getAttribute('aria-expanded')).toBe('true'); + expect(hostElement.ariaExpanded).toBe('true'); + + inherited.add('aria-expanded'); + hostElement.ariaExpanded = null; + + expect(onAttributeRemoved).toHaveBeenCalledWith('aria-expanded'); + expect(hostElement.hasAttribute('aria-expanded')).toBe(false); + }); +}); diff --git a/packages/core/src/components/utils/internal/mixins/accessibility/aria-attribute-interceptors.ts b/packages/core/src/components/utils/internal/mixins/accessibility/aria-attribute-interceptors.ts new file mode 100644 index 00000000000..5a46b2c4f16 --- /dev/null +++ b/packages/core/src/components/utils/internal/mixins/accessibility/aria-attribute-interceptors.ts @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: 2026 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { A11yAttributeName, isA11yAttributeName } from './../../../a11y'; + +export interface AriaAttributeInterceptorOptions { + /** + * Attributes which are not forwarded and therefore must not be intercepted. + */ + isIgnored: (attributeName: A11yAttributeName) => boolean; + /** + * Whether the attribute is currently held by the component instead of the host. + */ + isInherited: (attributeName: A11yAttributeName) => boolean; + /** + * Called whenever a forwarded attribute got removed on the host. + */ + onAttributeRemoved: (attributeName: A11yAttributeName) => void; +} + +const isInterceptedAttribute = ( + attributeName: string, + options: Pick +): attributeName is A11yAttributeName => + isA11yAttributeName(attributeName) && !options.isIgnored(attributeName); + +/** + * Forwarded attributes are absent on the host, so explicit removals have to be + * intercepted to keep the internal target in sync. + * + * @returns the unpatched `removeAttribute` of the host element + */ +export const interceptHostAttributeRemovals = ( + hostElement: HTMLElement, + options: AriaAttributeInterceptorOptions +): HTMLElement['removeAttribute'] => { + const removeAttribute = hostElement.removeAttribute.bind(hostElement); + const removeAttributeNS = hostElement.removeAttributeNS.bind(hostElement); + const toggleAttribute = hostElement.toggleAttribute.bind(hostElement); + + hostElement.removeAttribute = (qualifiedName: string) => { + const attributeName = qualifiedName.toLowerCase(); + const wasPresent = hostElement.hasAttribute(qualifiedName); + removeAttribute(qualifiedName); + + if (!wasPresent && isInterceptedAttribute(attributeName, options)) { + options.onAttributeRemoved(attributeName); + } + }; + + hostElement.removeAttributeNS = ( + namespace: string | null, + localName: string + ) => { + const attributeName = localName.toLowerCase(); + const wasPresent = hostElement.hasAttributeNS(namespace, localName); + removeAttributeNS(namespace, localName); + + if ( + namespace === null && + !wasPresent && + isInterceptedAttribute(attributeName, options) + ) { + options.onAttributeRemoved(attributeName); + } + }; + + hostElement.toggleAttribute = (qualifiedName: string, force?: boolean) => { + const attributeName = qualifiedName.toLowerCase(); + + if (!isInterceptedAttribute(attributeName, options)) { + return toggleAttribute(qualifiedName, force); + } + + const isPresent = + hostElement.hasAttribute(qualifiedName) || + options.isInherited(attributeName); + const shouldBePresent = force ?? !isPresent; + + if (shouldBePresent) { + if (!isPresent) { + toggleAttribute(qualifiedName, true); + } + return true; + } + + toggleAttribute(qualifiedName, false); + if (isPresent) { + options.onAttributeRemoved(attributeName); + } + + return false; + }; + + return removeAttribute; +}; + +const getAriaReflectionPropertyNames = (hostElement: HTMLElement) => { + const propertyNames = new Set(); + let prototype = Object.getPrototypeOf(hostElement); + + while (prototype && prototype !== Object.prototype) { + Object.getOwnPropertyNames(prototype).forEach((propertyName) => { + if (propertyName === 'role' || propertyName.startsWith('aria')) { + propertyNames.add(propertyName); + } + }); + prototype = Object.getPrototypeOf(prototype); + } + + return propertyNames; +}; + +/** + * ARIA reflection properties (e.g. `ariaLabel`) bypass the patched attribute + * methods, so they get redirected to the host attributes and the removal + * callback. + */ +export const interceptAriaReflectionRemovals = ( + hostElement: HTMLElement, + attributeNames: Iterable, + options: Pick & { + removeHostAttribute: HTMLElement['removeAttribute']; + } +) => { + const propertyNames = Array.from(getAriaReflectionPropertyNames(hostElement)); + + for (const attributeName of attributeNames) { + const normalizedAttributeName = attributeName.replaceAll('-', ''); + const propertyName = propertyNames.find( + (name) => name.toLowerCase() === normalizedAttributeName + ); + + if (!propertyName) { + continue; + } + + Object.defineProperty(hostElement, propertyName, { + configurable: true, + get: () => hostElement.getAttribute(attributeName), + set: (value: string | null) => { + if (value === null) { + options.onAttributeRemoved(attributeName); + options.removeHostAttribute(attributeName); + return; + } + + hostElement.setAttribute(attributeName, value); + }, + }); + } +}; diff --git a/packages/core/src/components/utils/internal/mixins/accessibility/inherit-aria-attributes.mixin.ts b/packages/core/src/components/utils/internal/mixins/accessibility/inherit-aria-attributes.mixin.ts index 7649fd194ce..bf6f6be5606 100644 --- a/packages/core/src/components/utils/internal/mixins/accessibility/inherit-aria-attributes.mixin.ts +++ b/packages/core/src/components/utils/internal/mixins/accessibility/inherit-aria-attributes.mixin.ts @@ -13,7 +13,12 @@ import { A11yAttributeName, A11yAttributes, a11yHostAttributes, + getA11yAttributeNames, } from './../../../a11y'; +import { + interceptAriaReflectionRemovals, + interceptHostAttributeRemovals, +} from './aria-attribute-interceptors'; export interface InheritAriaAttributesMixinContract { inheritAriaAttributes: A11yAttributes; @@ -31,6 +36,13 @@ export const InheritAriaAttributesMixin = < { @State() inheritAriaAttributes: A11yAttributes = {}; + // Distinguish mixin cleanup from attributes removed by consumers. + forwardedAriaAttributeRemovals = new Set(); + forwardedAriaAttributes = new Set(); + removeHostAttribute?: HTMLElement['removeAttribute']; + + ignoredAriaAttributes?: Set; + constructor(...args: any[]) { super(...args); } @@ -39,13 +51,60 @@ export const InheritAriaAttributesMixin = < return []; } + isIgnoredAriaAttribute = (attributeName: A11yAttributeName) => { + this.ignoredAriaAttributes ??= new Set(this.getIgnoredAriaAttributes()); + return this.ignoredAriaAttributes.has(attributeName); + }; + override componentWillLoad(): Promise | void { + if (!this.hostElement) { + return; + } + + this.ignoredAriaAttributes = new Set(this.getIgnoredAriaAttributes()); this.inheritAriaAttributes = a11yHostAttributes( - this.hostElement!, - this.getIgnoredAriaAttributes ? this.getIgnoredAriaAttributes() : [] + this.hostElement, + this.getIgnoredAriaAttributes() + ); + this.forwardedAriaAttributes = new Set( + getA11yAttributeNames().filter( + (attributeName) => !this.isIgnoredAriaAttribute(attributeName) + ) + ); + + this.removeHostAttribute = interceptHostAttributeRemovals( + this.hostElement, + { + isIgnored: this.isIgnoredAriaAttribute, + isInherited: (attributeName) => + attributeName in this.inheritAriaAttributes, + onAttributeRemoved: (attributeName) => + this.removeInheritedAriaAttribute(attributeName), + } + ); + + interceptAriaReflectionRemovals( + this.hostElement, + this.forwardedAriaAttributes, + { + onAttributeRemoved: (attributeName) => + this.removeInheritedAriaAttribute(attributeName), + removeHostAttribute: (attributeName) => + this.removeHostAttribute?.(attributeName), + } ); } + removeInheritedAriaAttribute(attributeName: A11yAttributeName) { + if (!(attributeName in this.inheritAriaAttributes)) { + return; + } + + const updatedAttributes = { ...this.inheritAriaAttributes }; + delete updatedAttributes[attributeName]; + this.inheritAriaAttributes = updatedAttributes; + } + @Watch('role') @Watch('aria-activedescendant') @Watch('aria-atomic') @@ -102,18 +161,34 @@ export const InheritAriaAttributesMixin = < _: string | null, propName: string ) { - const ignoredAttributes = this.getIgnoredAriaAttributes(); - if (ignoredAttributes.includes(propName as A11yAttributeName)) { + const attributeName = propName as A11yAttributeName; + + if (this.isIgnoredAriaAttribute(attributeName)) { + return; + } + + if (newValue === null) { + if (this.forwardedAriaAttributeRemovals.delete(attributeName)) { + return; + } + + this.removeInheritedAriaAttribute(attributeName); return; } - const updateAttribute = { - [propName]: newValue, - }; this.inheritAriaAttributes = { ...this.inheritAriaAttributes, - ...updateAttribute, + [attributeName]: newValue, }; + this.forwardedAriaAttributes.add(attributeName); + + if (this.hostElement) { + this.forwardedAriaAttributeRemovals.add(attributeName); + this.removeHostAttribute?.(attributeName); + queueMicrotask(() => { + this.forwardedAriaAttributeRemovals.delete(attributeName); + }); + } } }