diff --git a/packages/angular-test-app/src/preview-examples/dropdown-submenu.ts b/packages/angular-test-app/src/preview-examples/dropdown-submenu.ts index ccae12e11dc..7b114d92b8a 100644 --- a/packages/angular-test-app/src/preview-examples/dropdown-submenu.ts +++ b/packages/angular-test-app/src/preview-examples/dropdown-submenu.ts @@ -14,9 +14,7 @@ import { Component } from '@angular/core'; template: ` Open - - - + diff --git a/packages/core/src/components/dropdown/dropdown.tsx b/packages/core/src/components/dropdown/dropdown.tsx index dbe2793615d..ab06fa42d25 100644 --- a/packages/core/src/components/dropdown/dropdown.tsx +++ b/packages/core/src/components/dropdown/dropdown.tsx @@ -147,14 +147,14 @@ export class Dropdown { return Array.from(this.hostElement.querySelectorAll('ix-dropdown-item')); } + get slotElement() { + return this.hostElement.shadowRoot.querySelector('slot'); + } + private addEventListenersFor(triggerEvent: DropdownTriggerEvent) { switch (triggerEvent) { case 'click': - if (this.closeBehavior === 'outside') { - this.triggerElement.addEventListener('click', this.openBind); - } else { - this.triggerElement.addEventListener('click', this.toggleBind); - } + this.triggerElement.addEventListener('click', this.openBind); break; case 'hover': @@ -277,8 +277,13 @@ export class Dropdown { @Listen('click', { target: 'window', }) - clickOutside(event: Event) { + clickOutside(event: PointerEvent) { const target = event.target as HTMLElement; + + if (event.defaultPrevented) { + return; + } + if ( this.show === false || this.closeBehavior === false || @@ -287,24 +292,27 @@ export class Dropdown { ) { return; } + + const clickInsideDropdown = this.isClickInsideDropdown(event); + switch (this.closeBehavior) { case 'outside': - if (!this.dropdownRef.contains(target)) { - this.close(event); + if (!clickInsideDropdown || this.anchor === target) { + this.close(); } break; case 'inside': - if (this.dropdownRef.contains(target) && this.hostElement !== target) { - this.close(event); + if (clickInsideDropdown && this.hostElement !== target) { + this.close(); } break; case 'both': if (this.hostElement !== target) { - this.close(event); + this.close(); } break; default: - this.close(event); + this.close(); } } @@ -330,42 +338,40 @@ export class Dropdown { return true; } - private toggle(event?: Event) { - event?.preventDefault(); + private toggle(event: Event) { + event.preventDefault(); if (this.isNestedDropdown(event.target as HTMLElement)) { - event?.stopPropagation(); + event.stopPropagation(); } - this.show = !this.show; - this.showChanged.emit(this.show); + const { defaultPrevented } = this.showChanged.emit(this.show); + + if (!defaultPrevented) { + this.show = !this.show; + } } - private open(event?: Event) { - event?.preventDefault(); + private open(event: Event) { + event.preventDefault(); if (this.isNestedDropdown(event.target as HTMLElement)) { - event?.stopPropagation(); + event.stopPropagation(); } - this.show = true; - this.showChanged.emit(true); - } + const { defaultPrevented } = this.showChanged.emit(true); - private close(event?: Event) { - if (event?.defaultPrevented) { - const target = event.target as HTMLElement; - if ( - target.contains(this.anchorElement) || - target.contains(this.triggerElement) || - target.shadowRoot.contains(this.anchorElement) || - target.shadowRoot.contains(this.triggerElement) - ) - return; + if (!defaultPrevented) { + this.show = true; } + } + + private close() { + const { defaultPrevented } = this.showChanged.emit(false); - this.show = false; - this.showChanged.emit(false); + if (!defaultPrevented) { + this.show = false; + } } private async applyDropdownPosition() { @@ -440,9 +446,7 @@ export class Dropdown { } async componentDidLoad() { - if (this.trigger) { - this.changedTrigger(this.trigger, null); - } + this.changedTrigger(this.trigger, null); } async componentDidRender() { @@ -459,6 +463,16 @@ export class Dropdown { } } + private isClickInsideDropdown(event: PointerEvent) { + const rect = this.dropdownRef.getBoundingClientRect(); + return ( + rect.top <= event.clientY && + event.clientY <= rect.top + rect.height && + rect.left <= event.clientX && + event.clientX <= rect.left + rect.width + ); + } + disconnectedCallback() { if (this.autoUpdateCleanup) { this.autoUpdateCleanup(); diff --git a/packages/core/src/components/dropdown/test/dropdown.ct.ts b/packages/core/src/components/dropdown/test/dropdown.ct.ts new file mode 100644 index 00000000000..0d7e53c3177 --- /dev/null +++ b/packages/core/src/components/dropdown/test/dropdown.ct.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2023 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. + */ + +/* + * SPDX-FileCopyrightText: 2023 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 { expect, Locator } from '@playwright/test'; +import { test } from '@utils/test'; + +test('renders', async ({ mount, page }) => { + await mount(` + + Test 1 + + + + Test 1 + + + + + + + + + + + + + + + + + + + + `); + + const sb1 = page.locator('ix-split-button').nth(0); + const sb2 = page.locator('ix-split-button').nth(1); + + const g1 = page.locator('ix-group').nth(0); + const g2 = page.locator('ix-group').nth(1); + + const sb1Dropdown = sb1.locator('ix-dropdown'); + const sb2Dropdown = sb2.locator('ix-dropdown'); + const g1Dropdown = g1.locator('ix-dropdown'); + const g2Dropdown = g2.locator('ix-dropdown'); + + await sb1 + .getByRole('button') + .filter({ hasText: 'context-menu' }) + .first() + .click(); + + await expectToBeVisible( + [sb1Dropdown, sb2Dropdown, g1Dropdown, g2Dropdown], + 0 + ); + + await sb2 + .getByRole('button') + .filter({ hasText: 'context-menu' }) + .first() + .click(); + + await expectToBeVisible( + [sb1Dropdown, sb2Dropdown, g1Dropdown, g2Dropdown], + 1 + ); + + await g2.getByRole('button').filter({ hasText: 'context-menu' }).click(); + + await expectToBeVisible( + [sb1Dropdown, sb2Dropdown, g1Dropdown, g2Dropdown], + 3 + ); +}); + +function expectToBeVisible(elements: Locator[], index: number) { + return Promise.all( + elements.map(async (element, i) => { + let ef = expect(element); + if (i !== index) { + ef = ef.not; + } + await ef.toBeVisible(); + }) + ); +} diff --git a/packages/core/src/components/pagination/test/pagination.ct.ts b/packages/core/src/components/pagination/test/pagination.ct.ts new file mode 100644 index 00000000000..10a446059fc --- /dev/null +++ b/packages/core/src/components/pagination/test/pagination.ct.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2023 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 { expect } from '@playwright/test'; +import { test } from '@utils/test'; + +test('renders', async ({ mount, page }) => { + await mount(` + + + `); + const element = page.locator('ix-pagination'); + + await expect(element).toHaveClass(/hydrated/); +}); + +test('advanced', async ({ mount, page }) => { + await mount(` + + + `); + const element = page.locator('ix-pagination[advanced]'); + + await expect(element).toHaveClass(/hydrated/); +}); + +test('open show number of page dropdown', async ({ mount, page }) => { + await mount(` + + + `); + const element = page.locator('ix-pagination[advanced]'); + + await element + .getByRole('button') + .filter({ hasText: 'chevron-down-small' }) + .click(); + + const dropdown = element.locator('ix-dropdown'); + + await expect(dropdown).toBeVisible(); +}); diff --git a/packages/core/src/components/select/select.tsx b/packages/core/src/components/select/select.tsx index 196fb563306..df352d281aa 100644 --- a/packages/core/src/components/select/select.tsx +++ b/packages/core/src/components/select/select.tsx @@ -314,12 +314,12 @@ export class Select { target: 'window', }) async onKeyDown(event: KeyboardEvent) { - if (!this.dropdownShow) { - return; + if (event.code === 'ArrowDown' || event.code === 'ArrowUp') { + this.onArrowNavigation(event, event.code); } - if (event.code === 'ArrowDown' || event.code === 'ArrowUp') { - this.onArrowNavigation(event); + if (!this.dropdownShow) { + return; } if (event.code === 'Enter' || event.code === 'NumpadEnter') { @@ -349,32 +349,47 @@ export class Select { } } - private onArrowNavigation(event: KeyboardEvent) { - event.stopPropagation(); + private onArrowNavigation( + event: KeyboardEvent, + key: 'ArrowDown' | 'ArrowUp' + ) { event.preventDefault(); + event.stopPropagation(); - const focusItem = this.items.find( - (item) => document.activeElement === item.querySelector('button') - ); - this.navigationItem = focusItem; + this.dropdownShow = true; - const selectItems = this.items.filter( - (i) => !i.classList.contains('d-none') - ); + const items = this.items.filter((i) => !i.classList.contains('d-none')); + if (this.navigationItem === undefined) { + this.applyFocusTo(items[0]); + return; + } - const index = selectItems.indexOf(this.navigationItem); + let indexOfNavigationItem = items.findIndex( + (item) => item === this.navigationItem + ); - if (event.code === 'ArrowDown' && index < selectItems.length - 1) { - this.navigationItem = selectItems[index + 1]; - } else if (event.code === 'ArrowUp' && index > 0) { - this.navigationItem = selectItems[index - 1]; + if (key === 'ArrowDown') { + indexOfNavigationItem++; + } else { + indexOfNavigationItem--; } - this.setHoverEffectForNavigatedSelectItem(); + const newFocusItem = items[indexOfNavigationItem]; + this.applyFocusTo(newFocusItem); } - private setHoverEffectForNavigatedSelectItem() { - this.navigationItem?.querySelector('button').focus(); + private applyFocusTo(element: HTMLIxSelectItemElement) { + if (!element) { + return; + } + this.navigationItem = element; + + setTimeout(() => { + element.shadowRoot + .querySelector('ix-dropdown-item') + .shadowRoot.querySelector('button') + .focus(); + }); } private filterItemsWithTypeahead() { diff --git a/packages/core/src/components/utils/shadow-dom.ts b/packages/core/src/components/utils/shadow-dom.ts index e0696287c69..503b8b3e2b9 100644 --- a/packages/core/src/components/utils/shadow-dom.ts +++ b/packages/core/src/components/utils/shadow-dom.ts @@ -26,3 +26,13 @@ export function hasSlottedElements(slot: any) { } return slot.assignedElements({ flatten: true }).length !== 0; } + +export function containsElement(target: Element, element: Element) { + const hasShadowDom = target.shadowRoot; + + if (hasShadowDom) { + target.contains(element) || target.shadowRoot.contains(element); + } + + return target.contains(element); +} diff --git a/packages/html-test-app/src/preview-examples/dropdown-submenu.html b/packages/html-test-app/src/preview-examples/dropdown-submenu.html index 2aa876a6c64..cfcca8edc08 100644 --- a/packages/html-test-app/src/preview-examples/dropdown-submenu.html +++ b/packages/html-test-app/src/preview-examples/dropdown-submenu.html @@ -13,9 +13,7 @@ Open - - - + diff --git a/packages/react-test-app/src/preview-examples/dropdown-submenu.tsx b/packages/react-test-app/src/preview-examples/dropdown-submenu.tsx index bb87c077b49..c42ba77698c 100644 --- a/packages/react-test-app/src/preview-examples/dropdown-submenu.tsx +++ b/packages/react-test-app/src/preview-examples/dropdown-submenu.tsx @@ -7,12 +7,7 @@ * LICENSE file in the root directory of this source tree. */ -import { - IxButton, - IxDropdown, - IxDropdownItem, - IxIcon, -} from '@siemens/ix-react'; +import { IxButton, IxDropdown, IxDropdownItem } from '@siemens/ix-react'; import React from 'react'; export default () => { @@ -20,9 +15,7 @@ export default () => { <> Open - - - +