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);
+ });
+ }
}
}