{
this.updateSelection();
}}
>
-
(this.customItemsContainerElement = ref!)}>
- {this.isAddItemVisible() ? (
-
{
- e.preventDefault();
- e.stopPropagation();
- this.emitAddItem(this.inputFilterText);
- }}
- onFocus={() => (this.navigationItem = this.addItemElement)}
- ref={(ref) => {
- this.addItemElement = ref!;
- }}
- >
- ) : null}
- {this.isDropdownEmpty && !this.editable ? (
+ {this.isDropdownEmpty && !this.editable && (
- ) : (
- ''
)}
diff --git a/packages/core/src/components/select/test/select-controller.ts b/packages/core/src/components/select/test/select-controller.ts
new file mode 100644
index 00000000000..d2cdfe4f2b0
--- /dev/null
+++ b/packages/core/src/components/select/test/select-controller.ts
@@ -0,0 +1,105 @@
+/*
+ * SPDX-FileCopyrightText: 2025 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 { Locator, expect } from '@playwright/test';
+
+export function selectController(select: Locator) {
+ const input = select.locator('input');
+ const dropdown = select.locator('ix-dropdown');
+ const dropdownChevron = select.locator('ix-icon-button');
+
+ const isDropdownVisible = async () => {
+ const element = await dropdown.elementHandle();
+ if (!element) {
+ throw new Error('Dropdown has no open handle');
+ }
+ await element.waitForElementState('stable');
+ await expect(dropdown).toBeVisible();
+ };
+
+ return {
+ getDropdownLocator() {
+ return dropdown;
+ },
+ getInputLocator() {
+ return input;
+ },
+ async clickDropdownChevron() {
+ await dropdownChevron.click();
+ await isDropdownVisible();
+ },
+ async fillInput(text: string) {
+ await input.fill(text);
+ },
+ async focusInput() {
+ await input.click();
+ await expect(input).toBeFocused();
+ },
+ async arrowDown(skipDropdownCheck = false) {
+ if (!skipDropdownCheck) {
+ await isDropdownVisible();
+ }
+ await select.page().keyboard.press('ArrowDown', { delay: 50 });
+ },
+ async arrowUp(skipDropdownCheck = false) {
+ if (!skipDropdownCheck) {
+ await isDropdownVisible();
+ }
+ await select.page().keyboard.press('ArrowUp', { delay: 50 });
+ },
+ async pressEnter() {
+ await select.page().keyboard.press('Enter');
+ },
+ async getDropdownItemsLocator(onlyVisible = false) {
+ let selector = 'ix-select-item';
+
+ if (onlyVisible) {
+ selector += ':not([hidden])';
+ }
+
+ await isDropdownVisible();
+ return select.locator(selector).all();
+ },
+ async getFocusDropdownItemLocator() {
+ await isDropdownVisible();
+
+ const focusDropdownItem = select.locator(
+ 'ix-select-item[has-visual-focus]'
+ );
+ return focusDropdownItem;
+ },
+
+ async getAddItemDropdownItemLocator() {
+ await isDropdownVisible();
+
+ const addItem = select.locator('ix-dropdown-item.add-item');
+ const addItemHandle = await addItem.elementHandle();
+
+ if (!addItemHandle) {
+ throw new Error('Dropdown has no open handle');
+ }
+ await addItemHandle.waitForElementState('stable');
+ return addItem;
+ },
+
+ async getItemCheckedLocator() {
+ await isDropdownVisible();
+ const itemChecked = select.locator('ix-select-item .checkmark');
+ const itemCheckedHandle = await itemChecked.elementHandle();
+
+ if (!itemCheckedHandle) {
+ throw new Error('Dropdown has no open handle');
+ }
+
+ expect(itemCheckedHandle.waitForElementState('stable'));
+
+ return itemChecked;
+ },
+ };
+}
diff --git a/packages/core/src/components/select/test/select-keyboard.ct.ts b/packages/core/src/components/select/test/select-keyboard.ct.ts
index c54ad49560b..e0d9d4514d4 100644
--- a/packages/core/src/components/select/test/select-keyboard.ct.ts
+++ b/packages/core/src/components/select/test/select-keyboard.ct.ts
@@ -6,99 +6,9 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
-import { expect, Locator } from '@playwright/test';
+import { expect } from '@playwright/test';
import { test } from '@utils/test';
-
-function selectController(select: Locator) {
- const input = select.locator('input');
- const dropdown = select.locator('ix-dropdown');
- const dropdownChevron = select.locator('ix-icon-button');
-
- const dropdownVisible = async () => {
- const element = await dropdown.elementHandle();
- if (!element) {
- throw new Error('Dropdown has no open handle');
- }
- await element.waitForElementState('stable');
- await expect(dropdown).toBeVisible();
- };
-
- return {
- async clickDropdownChevron() {
- await dropdownChevron.click();
- await dropdownVisible();
- },
- async fillInput(text: string) {
- await input.fill(text);
- },
- async focusInput() {
- await input.click();
- await expect(input).toBeFocused();
- },
- async arrowDown(skipDropdownCheck = false) {
- if (!skipDropdownCheck) {
- await dropdownVisible();
- }
- await select.page().keyboard.press('ArrowDown', { delay: 50 });
- },
- async arrowUp(skipDropdownCheck = false) {
- if (!skipDropdownCheck) {
- await dropdownVisible();
- }
- await select.page().keyboard.press('ArrowUp', { delay: 50 });
- },
- async pressEnter() {
- await select.page().keyboard.press('Enter');
- },
- async getDropdownItemsLocator(onlyVisible = false) {
- let selector = 'ix-select-item';
-
- if (onlyVisible) {
- selector += ':not(.display-none)';
- }
-
- await dropdownVisible();
- return select.locator(selector).all();
- },
- async getFocusDropdownItemLocator() {
- await dropdownVisible();
-
- const focusDropdownItem = select.locator(
- 'ix-select-item .dropdown-item:focus-visible'
- );
- return focusDropdownItem;
- },
-
- async getAddItemDropdownItemLocator() {
- await dropdownVisible();
-
- const addItem = dropdown.locator('ix-dropdown-item.add-item');
- const addItemHandle = await addItem.elementHandle();
-
- if (!addItemHandle) {
- throw new Error('Dropdown has no open handle');
- }
- await addItemHandle.waitForElementState('stable');
- return addItem;
- },
-
- async getItemCheckedLocator() {
- await dropdownVisible();
- const itemChecked = select.locator('ix-select-item .checkmark');
- const itemCheckedHandle = await itemChecked.elementHandle();
-
- if (!itemCheckedHandle) {
- throw new Error('Dropdown has no open handle');
- }
-
- expect(itemCheckedHandle.waitForElementState('stable'));
-
- return itemChecked;
- },
- };
-}
-
-test.describe.configure({ mode: 'serial' });
+import { selectController } from './select-controller';
test.describe('arrow key navigation', () => {
test.describe('ArrowDown', () => {
@@ -121,7 +31,7 @@ test.describe('arrow key navigation', () => {
const focusItem = await selectCtrl.getFocusDropdownItemLocator();
expect(dropdownItems).toHaveLength(2);
- await expect(focusItem).toBeFocused();
+ await expect(focusItem).toHaveClass(/outline-visible/);
await expect(focusItem).toHaveText('Item 2');
});
@@ -137,6 +47,8 @@ test.describe('arrow key navigation', () => {
await selectCtrl.focusInput();
await selectCtrl.fillInput('New Item');
+
+ await selectCtrl.arrowDown();
await selectCtrl.pressEnter();
await selectCtrl.clickDropdownChevron();
@@ -149,7 +61,7 @@ test.describe('arrow key navigation', () => {
const focusItem = await selectCtrl.getFocusDropdownItemLocator();
expect(dropdownItems).toHaveLength(3);
- await expect(focusItem).toBeFocused();
+ await expect(focusItem).toHaveAttribute('has-visual-focus');
await expect(focusItem).toHaveText('New Item');
});
@@ -170,7 +82,7 @@ test.describe('arrow key navigation', () => {
expect(visibleDropdownItems).toHaveLength(0);
const addItem = await selectCtrl.getAddItemDropdownItemLocator();
- await expect(addItem).toBeFocused();
+ await expect(addItem).toHaveAttribute('has-visual-focus');
await expect(addItem).toHaveText('New Item');
});
@@ -182,6 +94,7 @@ test.describe('arrow key navigation', () => {
const selectCtrl = selectController(page.locator('ix-select'));
await selectCtrl.focusInput();
await selectCtrl.fillInput('New Item');
+ await selectCtrl.arrowDown();
await selectCtrl.pressEnter();
await selectCtrl.clickDropdownChevron();
await selectCtrl.arrowDown();
@@ -190,7 +103,7 @@ test.describe('arrow key navigation', () => {
expect(items).toHaveLength(1);
const focusItem = await selectCtrl.getFocusDropdownItemLocator();
- await expect(focusItem).toBeFocused();
+ await expect(focusItem).toHaveAttribute('has-visual-focus');
await expect(focusItem).toHaveText('New Item');
});
@@ -214,7 +127,7 @@ test.describe('arrow key navigation', () => {
await expect(
await selectCtrl.getAddItemDropdownItemLocator()
- ).toBeFocused();
+ ).toHaveAttribute('has-visual-focus');
});
test('dynamic item -> add item', async ({ mount, page }) => {
@@ -227,6 +140,7 @@ test.describe('arrow key navigation', () => {
const selectCtrl = selectController(page.locator('ix-select'));
await selectCtrl.focusInput();
await selectCtrl.fillInput('Item 2');
+ await selectCtrl.arrowDown();
await selectCtrl.pressEnter();
await selectCtrl.clickDropdownChevron();
@@ -241,7 +155,7 @@ test.describe('arrow key navigation', () => {
await expect(
await selectCtrl.getAddItemDropdownItemLocator()
- ).toBeFocused();
+ ).toHaveAttribute('has-visual-focus');
});
test('wrap - dynamic item -> slot', async ({ mount, page }) => {
@@ -254,6 +168,7 @@ test.describe('arrow key navigation', () => {
const selectCtrl = selectController(page.locator('ix-select'));
await selectCtrl.focusInput();
await selectCtrl.fillInput('Item 2');
+ await selectCtrl.arrowDown();
await selectCtrl.pressEnter();
await selectCtrl.clickDropdownChevron();
@@ -265,15 +180,23 @@ test.describe('arrow key navigation', () => {
const itemsBeforeNavigation = await selectCtrl.getDropdownItemsLocator();
await expect(itemsBeforeNavigation.at(1)!).toHaveText('Item 2');
- await expect(itemsBeforeNavigation.at(0)!).not.toBeFocused();
- await expect(itemsBeforeNavigation.at(1)!).toBeFocused();
+ await expect(itemsBeforeNavigation.at(0)!).not.toHaveAttribute(
+ 'has-visual-focus'
+ );
+ await expect(itemsBeforeNavigation.at(1)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
await selectCtrl.arrowDown();
const itemsAfterNavigation = await selectCtrl.getDropdownItemsLocator();
await expect(itemsAfterNavigation.at(0)!).toHaveText('Item 1');
- await expect(itemsAfterNavigation.at(0)!).toBeFocused();
- await expect(itemsAfterNavigation.at(1)!).not.toBeFocused();
+ await expect(itemsAfterNavigation.at(0)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
+ await expect(itemsAfterNavigation.at(1)!).not.toHaveAttribute(
+ 'has-visual-focus'
+ );
});
test('wrap - add item -> slot', async ({ mount, page }) => {
@@ -292,12 +215,14 @@ test.describe('arrow key navigation', () => {
await selectCtrl.arrowDown();
await selectCtrl.arrowDown();
- await expect(addItem).toBeFocused();
+ await expect(addItem).toHaveAttribute('has-visual-focus');
await selectCtrl.arrowDown();
const itemsAfterNavigation = await selectCtrl.getDropdownItemsLocator();
- await expect(itemsAfterNavigation.at(0)!).toBeFocused();
+ await expect(itemsAfterNavigation.at(0)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
await expect(itemsAfterNavigation.at(0)!).toHaveText('Item 1');
});
@@ -309,6 +234,7 @@ test.describe('arrow key navigation', () => {
const selectCtrl = selectController(page.locator('ix-select'));
await selectCtrl.focusInput();
await selectCtrl.fillInput('Item 1');
+ await selectCtrl.arrowDown();
await selectCtrl.pressEnter();
await selectCtrl.clickDropdownChevron();
@@ -322,12 +248,14 @@ test.describe('arrow key navigation', () => {
await selectCtrl.arrowDown();
await selectCtrl.arrowDown();
- await expect(addItem).toBeFocused();
+ await expect(addItem).toHaveAttribute('has-visual-focus');
await selectCtrl.arrowDown();
const itemsAfterNavigation = await selectCtrl.getDropdownItemsLocator();
- await expect(itemsAfterNavigation.at(0)!).toBeFocused();
+ await expect(itemsAfterNavigation.at(0)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
await expect(itemsAfterNavigation.at(0)!).toHaveText('Item 1');
});
});
@@ -343,22 +271,27 @@ test.describe('arrow key navigation', () => {
const selectCtrl = selectController(page.locator('ix-select'));
await selectCtrl.focusInput();
await selectCtrl.fillInput('I');
+ await selectCtrl.arrowDown();
+ await selectCtrl.arrowDown();
await selectCtrl.pressEnter();
await selectCtrl.clickDropdownChevron();
- await selectCtrl.getItemCheckedLocator();
await selectCtrl.arrowDown();
await selectCtrl.arrowDown();
const itemsBeforeNavigation = await selectCtrl.getDropdownItemsLocator();
- await expect(itemsBeforeNavigation.at(1)!).toBeFocused();
+ await expect(itemsBeforeNavigation.at(1)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
await expect(itemsBeforeNavigation.at(1)!).toHaveText('I');
await selectCtrl.arrowUp();
const itemsAfterNavigation = await selectCtrl.getDropdownItemsLocator();
- await expect(itemsAfterNavigation.at(0)!).toBeFocused();
+ await expect(itemsAfterNavigation.at(0)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
await expect(itemsAfterNavigation.at(0)!).toHaveText('Item 1');
});
@@ -378,12 +311,14 @@ test.describe('arrow key navigation', () => {
await selectCtrl.arrowDown();
await selectCtrl.arrowDown();
- await expect(addItem).toBeFocused();
+ await expect(addItem).toHaveAttribute('has-visual-focus');
await selectCtrl.arrowUp();
const itemsAfterNavigation = await selectCtrl.getDropdownItemsLocator();
- await expect(itemsAfterNavigation.at(0)!).toBeFocused();
+ await expect(itemsAfterNavigation.at(0)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
await expect(itemsAfterNavigation.at(0)!).toHaveText('Item 1');
});
@@ -395,10 +330,10 @@ test.describe('arrow key navigation', () => {
const selectCtrl = selectController(page.locator('ix-select'));
await selectCtrl.focusInput();
await selectCtrl.fillInput('Item 1');
+ await selectCtrl.arrowDown();
await selectCtrl.pressEnter();
await selectCtrl.clickDropdownChevron();
- await selectCtrl.getItemCheckedLocator();
await selectCtrl.fillInput('');
await selectCtrl.fillInput('I');
@@ -408,12 +343,14 @@ test.describe('arrow key navigation', () => {
await selectCtrl.arrowDown();
await selectCtrl.arrowDown();
- await expect(addItem).toBeFocused();
+ await expect(addItem).toHaveAttribute('has-visual-focus');
await selectCtrl.arrowUp();
const itemsAfterNavigation = await selectCtrl.getDropdownItemsLocator();
- await expect(itemsAfterNavigation.at(0)!).toBeFocused();
+ await expect(itemsAfterNavigation.at(0)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
await expect(itemsAfterNavigation.at(0)!).toHaveText('Item 1');
});
@@ -427,6 +364,7 @@ test.describe('arrow key navigation', () => {
const selectCtrl = selectController(page.locator('ix-select'));
await selectCtrl.focusInput();
await selectCtrl.fillInput('Item 2');
+ await selectCtrl.arrowDown();
await selectCtrl.pressEnter();
await selectCtrl.clickDropdownChevron();
@@ -437,15 +375,23 @@ test.describe('arrow key navigation', () => {
const itemsBeforeNavigation = await selectCtrl.getDropdownItemsLocator();
await expect(itemsBeforeNavigation.at(0)!).toHaveText('Item 1');
- await expect(itemsBeforeNavigation.at(0)!).toBeFocused();
- await expect(itemsBeforeNavigation.at(1)!).not.toBeFocused();
+ await expect(itemsBeforeNavigation.at(0)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
+ await expect(itemsBeforeNavigation.at(1)!).not.toHaveAttribute(
+ 'has-visual-focus'
+ );
await selectCtrl.arrowUp();
const itemsAfterNavigation = await selectCtrl.getDropdownItemsLocator();
await expect(itemsAfterNavigation.at(1)!).toHaveText('Item 2');
- await expect(itemsAfterNavigation.at(0)!).not.toBeFocused();
- await expect(itemsAfterNavigation.at(1)!).toBeFocused();
+ await expect(itemsAfterNavigation.at(0)!).not.toHaveAttribute(
+ 'has-visual-focus'
+ );
+ await expect(itemsAfterNavigation.at(1)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
});
test('wrap - slot -> add-item', async ({ mount, page }) => {
@@ -465,11 +411,13 @@ test.describe('arrow key navigation', () => {
const itemsBeforeNavigation = await selectCtrl.getDropdownItemsLocator();
await expect(itemsBeforeNavigation.at(0)!).toHaveText('Item 1');
- await expect(itemsBeforeNavigation.at(0)!).toBeFocused();
+ await expect(itemsBeforeNavigation.at(0)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
await selectCtrl.arrowUp();
- await expect(addItem).toBeFocused();
+ await expect(addItem).toHaveAttribute('has-visual-focus');
});
test('wrap - dynamic item -> add item', async ({ mount, page }) => {
@@ -480,10 +428,10 @@ test.describe('arrow key navigation', () => {
const selectCtrl = selectController(page.locator('ix-select'));
await selectCtrl.focusInput();
await selectCtrl.fillInput('Item 1');
+ await selectCtrl.arrowDown();
await selectCtrl.pressEnter();
await selectCtrl.clickDropdownChevron();
- await selectCtrl.getItemCheckedLocator();
await selectCtrl.fillInput('');
await selectCtrl.fillInput('I');
@@ -493,12 +441,14 @@ test.describe('arrow key navigation', () => {
await selectCtrl.arrowDown();
const itemsAfterNavigation = await selectCtrl.getDropdownItemsLocator();
- await expect(itemsAfterNavigation.at(0)!).toBeFocused();
+ await expect(itemsAfterNavigation.at(0)!).toHaveAttribute(
+ 'has-visual-focus'
+ );
await expect(itemsAfterNavigation.at(0)!).toHaveText('Item 1');
await selectCtrl.arrowUp();
- await expect(addItem).toBeFocused();
+ await expect(addItem).toHaveAttribute('has-visual-focus');
});
});
});
diff --git a/packages/core/src/components/select/test/select.ct.ts b/packages/core/src/components/select/test/select.ct.ts
index 41c247b55a7..f6552d3c0df 100644
--- a/packages/core/src/components/select/test/select.ct.ts
+++ b/packages/core/src/components/select/test/select.ct.ts
@@ -8,6 +8,7 @@
*/
import { expect } from '@playwright/test';
import { getFormValue, preventFormSubmission, test } from '@utils/test';
+import { selectController } from './select-controller';
test('renders', async ({ mount, page }) => {
await mount(`
@@ -189,26 +190,32 @@ test('filter', async ({ mount, page }) => {
await expect(item_abc).toBeVisible();
});
-test('open filtered dropdown on input', async ({ mount, page }) => {
+test('open filtered dropdown on input arrow down', async ({ mount, page }) => {
await mount(`
-
- Test
- Test
-
- `);
- const select = page.locator('ix-select');
- const input = select.locator('input');
- await select.evaluate((select: HTMLIxSelectElement) => (select.value = []));
+
+ Test
+ Test
+
+ `);
+ const selectCtrl = selectController(page.locator('ix-select'));
+
+ await selectCtrl.focusInput();
+ await page.keyboard.press('ArrowDown');
+
+ await expect(selectCtrl.getDropdownLocator()).toBeVisible();
- await input.focus();
await page.keyboard.press('Escape');
- const dropdown = select.locator('ix-dropdown');
- await expect(dropdown).not.toBeVisible();
+ await expect(selectCtrl.getDropdownLocator()).not.toBeVisible();
- await input.fill('1');
+ await selectCtrl.fillInput('1');
+ await expect(selectCtrl.getDropdownLocator()).toBeVisible();
- const item1 = page.getByRole('button', { name: 'Item 1' });
- const item2 = page.getByRole('button', { name: 'Item 2' });
+ const item1 = page
+ .locator('ix-select')
+ .getByRole('button', { name: 'Item 1' });
+ const item2 = page
+ .locator('ix-select')
+ .getByRole('button', { name: 'Item 2' });
await expect(item1).toBeVisible();
await expect(item2).not.toBeVisible();
@@ -304,57 +311,34 @@ test('type in a novel item name in editable mode, click outside and reopen the s
page,
}) => {
await mount(`
-
- Test
- Test
- Test
-
-
outside
- `);
+
+
+
+
+
+
outside
+ `);
- const selectElement = page.locator('ix-select');
- const btnElement = page.locator('ix-button');
- await expect(selectElement).toHaveClass(/hydrated/);
- await expect(btnElement).toBeVisible();
+ const selectCtrl = selectController(page.locator('ix-select'));
+ const externalButton = page.getByText('outside');
- await page.locator('[data-select-dropdown]').click();
- await page.getByTestId('input').fill('test');
+ await selectCtrl.clickDropdownChevron();
+ await selectCtrl.fillInput('test');
- const add = page.getByRole('button', { name: 'test' });
- await expect(add).toBeVisible();
+ const addNewItem = await selectCtrl.getAddItemDropdownItemLocator();
+ await expect(addNewItem).toBeVisible();
- await btnElement.click();
- const inputValue = await page.getByTestId('input').inputValue();
+ await externalButton.click();
- expect(inputValue).toBe('Item 2');
+ await expect(selectCtrl.getDropdownLocator()).not.toBeVisible();
+ await expect(selectCtrl.getInputLocator()).toHaveValue('Item 2');
- await page.locator('[data-select-dropdown]').click();
+ await selectCtrl.clickDropdownChevron();
- await expect(page.getByRole('button', { name: 'Item 1' })).toBeVisible();
- await expect(page.getByRole('button', { name: 'Item 2' })).toBeVisible();
- await expect(page.getByRole('button', { name: 'Item 3' })).toBeVisible();
-});
-
-test('type in a novel item name and click outside', async ({ mount, page }) => {
- await mount(`
-
- Test
- Test
- Test
-
-
outside
- `);
-
- const selectElement = page.locator('ix-select');
- await expect(selectElement).toHaveClass(/hydrated/);
-
- await page.locator('[data-select-dropdown]').click();
- await page.getByTestId('input').fill('test');
-
- await page.keyboard.press('Enter');
- const inputValue = await page.getByTestId('input').inputValue();
-
- expect(inputValue).toBe('Item 2');
+ const items = await selectCtrl.getDropdownItemsLocator();
+ await expect(items[0]).toHaveText('Item 1');
+ await expect(items[1]).toHaveText('Item 2');
+ await expect(items[2]).toHaveText('Item 3');
});
test('check if clear button visible in disabled', async ({ mount, page }) => {
@@ -384,26 +368,22 @@ test('type in a novel item name in multiple mode, click outside', async ({
page,
}) => {
await mount(`
-
- Test
- Test
- Test
-
-
outside
+
+
+
+
+
+
outside
`);
- const selectElement = page.locator('ix-select');
- const btnElement = page.locator('ix-button');
- await expect(selectElement).toHaveClass(/hydrated/);
- await expect(btnElement).toBeVisible();
-
- await page.locator('[data-select-dropdown]').click();
- await page.getByTestId('input').fill('test');
+ const selectCtrl = selectController(page.locator('ix-select'));
+ const externalButton = page.getByText('outside');
- await btnElement.click();
- const inputValue = await page.getByTestId('input').inputValue();
+ await selectCtrl.fillInput('test');
- expect(inputValue).toBe('');
+ await externalButton.click();
+ await expect(selectCtrl.getDropdownLocator()).not.toBeVisible();
+ await expect(selectCtrl.getInputLocator()).toHaveValue('');
});
test('pass object as value and check if it is selectable', async ({
@@ -511,19 +491,21 @@ test.describe('Events', () => {
const itemText = 'test';
await mount(`
`);
const select = page.locator('ix-select');
+ await expect(select).toHaveClass(/hydrated/);
+
const itemAdded = select.evaluate((elm) => {
- return new Promise
((resolve) => {
+ return new Promise((resolve) => {
elm.addEventListener('addItem', (e: Event) =>
resolve((e as CustomEvent).detail)
);
});
});
- const input = page.locator('input');
- await input.focus();
- await input.fill(itemText);
- await page.keyboard.press('Enter');
- await expect(select).toHaveClass(/hydrated/);
+ const selectCtrl = selectController(select);
+ await selectCtrl.fillInput(itemText);
+ await selectCtrl.arrowDown();
+ await selectCtrl.pressEnter();
+
expect(await itemAdded).toBe(itemText);
});
@@ -566,8 +548,8 @@ test('async set content and check input value', async ({ mount, page }) => {
if (select) {
await new Promise((resolve) => setTimeout(resolve, 1000));
select.innerHTML = `
- Test
- Test
+
+
`;
}
});
@@ -580,45 +562,46 @@ test.describe('Enter selection with non-existing and existing items', () => {
test('editable', async ({ mount, page }) => {
await mount(`
- Test
- Test
+
+
`);
- const selectElement = page.locator('ix-select');
- const input = selectElement.locator('input');
+ const selectCtrl = selectController(page.locator('ix-select'));
- await input.fill('Item 1');
+ await selectCtrl.fillInput('Item 1');
+ await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
- await expect(input).toHaveValue('Item 1');
+ await expect(selectCtrl.getInputLocator()).toHaveValue('Item 1');
- await input.fill('Item 3');
+ await selectCtrl.fillInput('Item 3');
+ await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
-
- await expect(input).toHaveValue('Item 3');
+ await expect(selectCtrl.getInputLocator()).toHaveValue('Item 3');
});
test('non-editable', async ({ mount, page }) => {
await mount(`
- Test
- Test
+
+
`);
- const selectElement = page.locator('ix-select');
- const input = selectElement.locator('input');
-
- await input.fill('Item 1');
- await page.keyboard.press('Enter');
-
- await expect(input).toHaveValue('Item 1');
+ const selectCtrl = selectController(page.locator('ix-select'));
- await input.fill('Item 3');
- await page.keyboard.press('Enter');
+ await selectCtrl.fillInput('Item 1');
+ await selectCtrl.arrowDown();
+ await selectCtrl.pressEnter();
+ await expect(selectCtrl.getDropdownLocator()).not.toBeVisible();
+ await expect(selectCtrl.getInputLocator()).toHaveValue('Item 1');
- await expect(input).toHaveValue('Item 1');
+ await selectCtrl.fillInput('Item 3');
+ await selectCtrl.arrowDown();
+ await selectCtrl.pressEnter();
+ await expect(selectCtrl.getDropdownLocator()).not.toBeVisible();
+ await expect(selectCtrl.getInputLocator()).toHaveValue('Item 1');
});
});
@@ -774,51 +757,55 @@ test('should not show add icon when spaces are entered at the start of input', a
mount,
page,
}) => {
- await mount(`
+ await mount(`
+
- `);
-
- const select = page.locator('ix-select');
- const input = select.locator('input');
-
- await input.fill(' Item 1');
+
+ `);
- const addItem = select.locator('.add-item');
- await expect(addItem).toHaveCount(0);
+ const selectCtrl = selectController(page.locator('ix-select'));
+ await selectCtrl.fillInput(' Item 1');
+ await expect(await selectCtrl.getAddItemDropdownItemLocator()).toBeHidden();
});
test('should not show add icon when only spaces are entered in input', async ({
mount,
page,
}) => {
- await mount(`
+ await mount(`
+
`);
- const select = page.locator('ix-select');
- const input = select.locator('input');
-
- await input.fill(' ');
-
- const addItem = select.locator('.add-item');
- await expect(addItem).toHaveCount(0);
+ const selectCtrl = selectController(page.locator('ix-select'));
+ await selectCtrl.fillInput(' ');
+ await expect(await selectCtrl.getAddItemDropdownItemLocator()).toBeHidden();
});
test('should trim the value before saving', async ({ mount, page }) => {
- await mount(`
+ await mount(`
+
`);
- const select = page.locator('ix-select');
- const input = select.locator('Input');
+ const selectCtrl = selectController(page.locator('ix-select'));
+ const inputLocator = selectCtrl.getInputLocator();
- await input.fill(' Item 7 ');
- await input.press('Enter');
+ await selectCtrl.getInputLocator().focus();
+ await selectCtrl.fillInput(' Item 7');
+ await expect(selectCtrl.getDropdownLocator()).toHaveClass(/show/);
+ await page.keyboard.press('ArrowDown');
+
+ await expect(await selectCtrl.getFocusDropdownItemLocator()).toHaveText(
+ 'Item 7'
+ );
+ await page.keyboard.press('Enter');
- await expect(input).toHaveValue('Item 7');
+ await expect(selectCtrl.getDropdownLocator()).not.toHaveClass(/show/);
+ await expect(inputLocator).toHaveValue('Item 7');
});
test('should preserve spaces within input and show add icon', async ({
diff --git a/packages/core/src/components/split-button/split-button.scss b/packages/core/src/components/split-button/split-button.scss
index 35e0ab8c1be..f1ed35b11b9 100644
--- a/packages/core/src/components/split-button/split-button.scss
+++ b/packages/core/src/components/split-button/split-button.scss
@@ -41,5 +41,24 @@
border-right-width: 0;
border-left-width: 0.125rem;
border-bottom-width: 0.125rem;
+
+ margin-right: 0.125rem;
+ }
+}
+
+:host(.btn-group) {
+ position: relative;
+ display: inline-block;
+ vertical-align: middle;
+
+ > ix-button:nth-child(1),
+ > ix-icon-button:nth-child(1) {
+ width: calc(100% - 2rem);
+ --ix-button-border-radius-right: 0;
+ }
+
+ > ix-icon-button:nth-child(2) {
+ width: 2rem;
+ --ix-button-border-radius-left: 0;
}
}
diff --git a/packages/core/src/components/split-button/split-button.tsx b/packages/core/src/components/split-button/split-button.tsx
index 53bcb6cf4f6..5824d4a67be 100644
--- a/packages/core/src/components/split-button/split-button.tsx
+++ b/packages/core/src/components/split-button/split-button.tsx
@@ -15,13 +15,16 @@ import {
h,
Host,
Prop,
- State,
} from '@stencil/core';
import { AlignedPlacement } from '../dropdown/placement';
import { iconContextMenu } from '@siemens/ix-icons/icons';
import { CloseBehavior } from '../dropdown/dropdown-controller';
import type { SplitButtonVariant } from './split-button.types';
import { makeRef } from '../utils/make-ref';
+import {
+ FocusVisibleUtility,
+ addFocusVisibleListener,
+} from '../utils/focus-visible-listener';
@Component({
tag: 'ix-split-button',
@@ -94,14 +97,25 @@ export class SplitButton {
*/
@Prop() placement: AlignedPlacement = 'bottom-start';
- @State() toggle = false;
-
/**
* Button clicked
*/
@Event() buttonClick!: EventEmitter;
private readonly triggerElementRef = makeRef();
+ private focusVisibleUtility?: FocusVisibleUtility;
+
+ connectedCallback() {
+ if (this.focusVisibleUtility) {
+ this.focusVisibleUtility.destroy();
+ }
+
+ this.focusVisibleUtility = addFocusVisibleListener(this.hostElement);
+ }
+
+ disconnectedCallback() {
+ this.focusVisibleUtility?.destroy();
+ }
private get isDisabledButton() {
return this.disabled || this.disableButton;
@@ -131,37 +145,47 @@ export class SplitButton {
};
return (
-
-
- {this.label ? (
- this.buttonClick.emit(e)}
- aria-label={this.ariaLabelButton}
- >
- {this.label}
-
- ) : (
- this.buttonClick.emit(e)}
- aria-label={this.ariaLabelButton}
- >
- )}
+ {
+ this.focusVisibleUtility?.setFocus([]);
+ }}
+ >
+ {this.label ? (
+ this.buttonClick.emit(e)}
+ aria-label={this.ariaLabelButton}
+ >
+ {this.label}
+
+ ) : (
this.buttonClick.emit(e)}
+ aria-label={this.ariaLabelButton}
>
-
-
+ )}
+
{
+ const triggerElement = this.triggerElementRef.current;
+ if (triggerElement) {
+ // Binding via @State variable does not work as expected for tabindex
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ triggerElement.tabIndex = (show ? -1 : undefined) as any;
+ }
+ }}
>
diff --git a/packages/core/src/components/utils/focus-visible-listener.ts b/packages/core/src/components/utils/focus-visible-listener.ts
new file mode 100644
index 00000000000..0d36eecf9db
--- /dev/null
+++ b/packages/core/src/components/utils/focus-visible-listener.ts
@@ -0,0 +1,183 @@
+/*
+ * SPDX-FileCopyrightText: 2025 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.
+ */
+
+/**
+ * Originally based on Ionic's focus-visible utility.
+ * Fork of https://github.com/ionic-team/ionic-framework/blob/c37e2a5d9e765cf48d768061c9d453a13b187e13/core/src/utils/focus-visible.ts
+ */
+
+const IX_FOCUSED = 'ix-focused';
+const IX_FOCUSABLE = 'ix-focusable';
+const FOCUS_KEYS = [
+ 'Tab',
+ 'ArrowDown',
+ 'Space',
+ 'Escape',
+ ' ',
+ 'Shift',
+ 'Enter',
+ 'ArrowLeft',
+ 'ArrowRight',
+ 'ArrowUp',
+ 'Home',
+ 'End',
+];
+
+export interface FocusVisibleUtility {
+ destroy: () => void;
+ setFocus: (elements: Element[]) => void;
+}
+
+export function queryElements(
+ dropdownElement: HTMLElement | undefined,
+ query: string
+) {
+ if (!dropdownElement) {
+ return [];
+ }
+
+ let items: HTMLElement[] = [];
+ // Collect items from slots if they exist
+ if (dropdownElement.querySelectorAll('slot').length > 0) {
+ const slotElements = Array.from(dropdownElement.querySelectorAll('slot'));
+ items = slotElements.flatMap((slot) =>
+ Array.from(
+ slot.assignedElements({ flatten: true }) as HTMLElement[]
+ ).flatMap((el) => {
+ // Check if the assigned element itself matches the query
+ if (el.matches && el.matches(query)) {
+ return [el];
+ }
+ // Otherwise, query its children
+ return Array.from(
+ el.querySelectorAll(query) as NodeListOf
+ );
+ })
+ );
+ } else {
+ // No slots, query directly on dropdownElement
+ items = Array.from(
+ dropdownElement.querySelectorAll(query) as NodeListOf
+ );
+ }
+
+ return items;
+}
+
+export const addFocusVisibleListener = (
+ rootEl?: HTMLElement
+): FocusVisibleUtility => {
+ let currentFocus: Element[] = [];
+ let keyboardMode = true;
+
+ const ref = rootEl ? rootEl.shadowRoot! : document;
+ const root = rootEl ? rootEl : document.body;
+
+ const setFocus = (elements: Element[]) => {
+ currentFocus.forEach((el) => el.classList.remove(IX_FOCUSED));
+ elements.forEach((el) => el.classList.add(IX_FOCUSED));
+ currentFocus = elements;
+ };
+ const pointerDown = () => {
+ keyboardMode = false;
+ setFocus([]);
+ };
+
+ const onKeydown = (ev: Event) => {
+ keyboardMode = FOCUS_KEYS.includes((ev as KeyboardEvent).key);
+ if (!keyboardMode) {
+ setFocus([]);
+ }
+ };
+ const onFocusin = (ev: Event) => {
+ if (keyboardMode && ev.composedPath !== undefined) {
+ const toFocus = ev.composedPath().filter((el): el is Element => {
+ return el instanceof Element && el.classList.contains(IX_FOCUSABLE);
+ }) as Element[];
+ setFocus(toFocus);
+ }
+ };
+ const onFocusout = () => {
+ if (ref.activeElement === root) {
+ setFocus([]);
+ }
+ };
+
+ ref.addEventListener('keydown', onKeydown);
+ ref.addEventListener('focusin', onFocusin);
+ ref.addEventListener('focusout', onFocusout);
+ ref.addEventListener('touchstart', pointerDown, { passive: true });
+ ref.addEventListener('mousedown', pointerDown);
+
+ const destroy = () => {
+ ref.removeEventListener('keydown', onKeydown);
+ ref.removeEventListener('focusin', onFocusin);
+ ref.removeEventListener('focusout', onFocusout);
+ ref.removeEventListener('touchstart', pointerDown);
+ ref.removeEventListener('mousedown', pointerDown);
+ };
+
+ return {
+ destroy,
+ setFocus,
+ };
+};
+
+export const focusFirstDescendant = <
+ R extends HTMLElement,
+ T extends HTMLElement,
+>(
+ ref: R,
+ fallbackElement?: T
+) => {
+ const inputs = queryElements(ref, focusableQueryString);
+ const firstInput = inputs.length > 0 ? inputs[0] : null;
+
+ focusElementInContext(firstInput, fallbackElement ?? ref);
+};
+
+export const focusLastDescendant = <
+ R extends HTMLElement,
+ T extends HTMLElement,
+>(
+ ref: R,
+ fallbackElement?: T
+) => {
+ const inputs = queryElements(ref, focusableQueryString);
+ const lastInput = inputs.length > 0 ? inputs[inputs.length - 1] : null;
+
+ focusElementInContext(lastInput, fallbackElement ?? ref);
+};
+
+export const focusElementInContext = (
+ hostToFocus: HTMLElement | null | undefined,
+ fallbackElement: T
+) => {
+ let elementToFocus = hostToFocus;
+
+ const shadowRoot = hostToFocus?.shadowRoot;
+ if (shadowRoot) {
+ elementToFocus =
+ shadowRoot.querySelector(focusableQueryString) ||
+ hostToFocus;
+ }
+
+ if (elementToFocus) {
+ elementToFocus.focus();
+ } else {
+ fallbackElement.focus();
+ }
+};
+
+export const focusableQueryString = `[tabindex]:not([tabindex^="-"]):not([hidden]):not([disabled]), .ix-focusable:not([hidden]):not([disabled]), .ix-focusable[disabled="false"]:not([hidden]), ${[
+ 'ix-dropdown-item',
+ 'ix-select-item',
+]
+ .map((tag) => `${tag}:not([hidden]):not([disabled])`)
+ .join(', ')}`;
diff --git a/packages/core/src/index.html b/packages/core/src/index.html
index a4ffa455d6b..f39ea377d3f 100644
--- a/packages/core/src/index.html
+++ b/packages/core/src/index.html
@@ -19,8 +19,15 @@
diff --git a/packages/core/src/setup.ts b/packages/core/src/setup.ts
index ad6228788f9..5efa392c5b4 100644
--- a/packages/core/src/setup.ts
+++ b/packages/core/src/setup.ts
@@ -7,6 +7,7 @@
* LICENSE file in the root directory of this source tree.
*/
import { setPlatformHelpers } from '@stencil/core';
+import { addFocusVisibleListener } from './components/utils/focus-visible-listener';
export function handlePlatformHelpers(config: IxConfig) {
const platformHelpers: Pick = {};
@@ -44,4 +45,7 @@ export type IxConfig = {
export default async function (config?: IxConfig) {
handlePlatformHelpers(config || {});
+
+ // Initialize focus visible listener to manage focus styles on focusable ix elements
+ addFocusVisibleListener();
}
diff --git a/packages/core/tsconfig.lib.json b/packages/core/tsconfig.lib.json
index 892971c81f0..1fc195d0cb5 100644
--- a/packages/core/tsconfig.lib.json
+++ b/packages/core/tsconfig.lib.json
@@ -5,10 +5,10 @@
"allowUnreachableCode": false,
"declaration": true,
"experimentalDecorators": true,
- "lib": ["dom", "es2017"],
- "moduleResolution": "node",
+ "lib": ["dom", "es2022"],
+ "moduleResolution": "bundler",
"module": "esnext",
- "target": "es2017",
+ "target": "es2022",
"noUnusedLocals": true,
"noUnusedParameters": true,
"jsx": "react",
diff --git a/packages/react/src/components.server.ts b/packages/react/src/components.server.ts
index 6eb9808f7e9..59cfe80f335 100644
--- a/packages/react/src/components.server.ts
+++ b/packages/react/src/components.server.ts
@@ -664,7 +664,10 @@ export const IxDrawer: StencilReactComponent =
serializeShadowRoot,
});
-export type IxDropdownEvents = { onShowChanged: EventName> };
+export type IxDropdownEvents = {
+ onShowChange: EventName>,
+ onShowChanged: EventName>
+};
export const IxDropdown: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ix-dropdown',
@@ -677,6 +680,7 @@ export const IxDropdown: StencilReactComponent,
clientModule: clientComponents.IxDropdownItem as ReactWebComponent,
@@ -1579,6 +1585,7 @@ export const IxSelect: StencilReactComponent =
label: 'label',
ariaLabelChevronDownIconButton: 'aria-label-chevron-down-icon-button',
ariaLabelClearIconButton: 'aria-label-clear-icon-button',
+ ariaLabelAddItem: 'aria-label-add-item',
warningText: 'warning-text',
infoText: 'info-text',
invalidText: 'invalid-text',
@@ -1614,7 +1621,8 @@ export const IxSelectItem: StencilReactComponent,
clientModule: clientComponents.IxSelectItem as ReactWebComponent,
diff --git a/packages/react/src/components.ts b/packages/react/src/components.ts
index 9d819492a12..778912c1307 100644
--- a/packages/react/src/components.ts
+++ b/packages/react/src/components.ts
@@ -479,14 +479,20 @@ export const IxDrawer: StencilReactComponent =
defineCustomElement: defineIxDrawer
});
-export type IxDropdownEvents = { onShowChanged: EventName> };
+export type IxDropdownEvents = {
+ onShowChange: EventName>,
+ onShowChanged: EventName>
+};
export const IxDropdown: StencilReactComponent = /*@__PURE__*/ createComponent({
tagName: 'ix-dropdown',
elementClass: IxDropdownElement,
// @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
react: React,
- events: { onShowChanged: 'showChanged' } as IxDropdownEvents,
+ events: {
+ onShowChange: 'showChange',
+ onShowChanged: 'showChanged'
+ } as IxDropdownEvents,
defineCustomElement: defineIxDropdown
});
diff --git a/packages/vue/src/components.ts b/packages/vue/src/components.ts
index 7022b55c69a..28684e09ef7 100644
--- a/packages/vue/src/components.ts
+++ b/packages/vue/src/components.ts
@@ -523,14 +523,19 @@ export const IxDropdown: StencilVueComponent = /*@__PURE__*/ def
'placement',
'positioningStrategy',
'header',
+ 'disableFocusHandling',
'offset',
'overwriteDropdownStyle',
'discoverAllSubmenus',
'ignoreRelatedSubmenu',
'suppressOverflowBehavior',
- 'showChanged'
+ 'showChange',
+ 'showChanged',
+ 'experimentalRequestFocus'
], [
- 'showChanged'
+ 'showChange',
+ 'showChanged',
+ 'experimentalRequestFocus'
]);
@@ -558,8 +563,10 @@ export const IxDropdownItem: StencilVueComponent = /*@__PURE
'hover',
'disabled',
'checked',
+ 'suppressFocus',
'isSubMenu',
'suppressChecked',
+ 'hasVisualFocus',
'itemClick'
], [
'itemClick'
@@ -1154,6 +1161,7 @@ export const IxSelect: StencilVueComponent
'label',
'ariaLabelChevronDownIconButton',
'ariaLabelClearIconButton',
+ 'ariaLabelAddItem',
'warningText',
'infoText',
'invalidText',
@@ -1193,6 +1201,7 @@ export const IxSelectItem: StencilVueComponent = /*@__PURE__*/
'value',
'selected',
'hover',
+ 'hasVisualFocus',
'itemClick'
], [
'itemClick'