diff --git a/packages/angular-test-app/src/app/app-routing.module.ts b/packages/angular-test-app/src/app/app-routing.module.ts index a0e3f7befa4..dc6200ce36d 100644 --- a/packages/angular-test-app/src/app/app-routing.module.ts +++ b/packages/angular-test-app/src/app/app-routing.module.ts @@ -44,7 +44,11 @@ import Datetimepicker from 'src/preview-examples/datetimepicker'; import Drawer from 'src/preview-examples/drawer'; import DrawerFullHeight from 'src/preview-examples/drawer-full-height'; import Dropdown from 'src/preview-examples/dropdown'; +import DropdownButton from 'src/preview-examples/dropdown-button'; +import DropdownButtonIcon from 'src/preview-examples/dropdown-button-icon'; import DropdownIcon from 'src/preview-examples/dropdown-icon'; +import DropdownQuickActions from 'src/preview-examples/dropdown-quick-actions'; +import DropdownSubmenu from 'src/preview-examples/dropdown-submenu'; import EmptyState from 'src/preview-examples/empty-state'; import EmptyStateCompact from 'src/preview-examples/empty-state-compact'; import EmptyStateCompactBreak from 'src/preview-examples/empty-state-compact-break'; @@ -293,9 +297,13 @@ const routes: Routes = [ path: 'drawer', component: Drawer, }, + { path: 'dropdown-button', component: DropdownButton }, + { path: 'dropdown-button-icon', component: DropdownButtonIcon }, { path: 'dropdown-icon', component: DropdownIcon }, { path: 'dropdown', component: Dropdown }, + { path: 'dropdown-quick-actions', component: DropdownQuickActions }, + { path: 'dropdown-submenu', component: DropdownSubmenu }, { path: 'event-list-compact', component: EventListCompact }, { path: 'event-list-custom-item-height', diff --git a/packages/angular-test-app/src/app/app.module.ts b/packages/angular-test-app/src/app/app.module.ts index 7c383117ddb..f842413a9ec 100644 --- a/packages/angular-test-app/src/app/app.module.ts +++ b/packages/angular-test-app/src/app/app.module.ts @@ -51,7 +51,11 @@ import Datetimepicker from 'src/preview-examples/datetimepicker'; import Drawer from 'src/preview-examples/drawer'; import DrawerFullHeight from 'src/preview-examples/drawer-full-height'; import Dropdown from 'src/preview-examples/dropdown'; +import DropdownButton from 'src/preview-examples/dropdown-button'; +import DropdownButtonIcon from 'src/preview-examples/dropdown-button-icon'; import DropdownIcon from 'src/preview-examples/dropdown-icon'; +import DropdownQuickActions from 'src/preview-examples/dropdown-quick-actions'; +import DropdownSubmenu from 'src/preview-examples/dropdown-submenu'; import EmptyState from 'src/preview-examples/empty-state'; import EmptyStateCompact from 'src/preview-examples/empty-state-compact'; import EmptyStateCompactBreak from 'src/preview-examples/empty-state-compact-break'; @@ -182,8 +186,12 @@ import { NavigationTestComponent } from './components/navigation-test.component' Datetimepicker, DrawerFullHeight, Drawer, + DropdownButton, + DropdownButtonIcon, DropdownIcon, Dropdown, + DropdownQuickActions, + DropdownSubmenu, EventListCompact, EventListCustomItemHeight, EventListSelected, diff --git a/packages/angular-test-app/src/preview-examples/dropdown-button-icon.ts b/packages/angular-test-app/src/preview-examples/dropdown-button-icon.ts index c4639ecf85f..956e2941b24 100644 --- a/packages/angular-test-app/src/preview-examples/dropdown-button-icon.ts +++ b/packages/angular-test-app/src/preview-examples/dropdown-button-icon.ts @@ -28,4 +28,4 @@ import { Component } from '@angular/core'; `, }) -export class Dropdown {} +export default class Dropdown {} diff --git a/packages/angular-test-app/src/preview-examples/dropdown-button.ts b/packages/angular-test-app/src/preview-examples/dropdown-button.ts index cfef4fe52e3..d8ee8a02574 100644 --- a/packages/angular-test-app/src/preview-examples/dropdown-button.ts +++ b/packages/angular-test-app/src/preview-examples/dropdown-button.ts @@ -43,4 +43,4 @@ import { Component } from '@angular/core'; `, }) -export class Dropdown {} +export default class Dropdown {} diff --git a/packages/core/component-doc.json b/packages/core/component-doc.json index 9ead03512d7..88434ba7a71 100644 --- a/packages/core/component-doc.json +++ b/packages/core/component-doc.json @@ -5024,9 +5024,15 @@ "name": "closeBehavior", "type": "\"both\" | \"inside\" | \"outside\" | boolean", "complexType": { - "original": "'inside' | 'outside' | 'both' | boolean", + "original": "CloseBehaviour", "resolved": "\"both\" | \"inside\" | \"outside\" | boolean", - "references": {} + "references": { + "CloseBehaviour": { + "location": "import", + "path": "dropdown-controller", + "id": "src/components/dropdown/dropdown-controller.ts::CloseBehaviour" + } + } }, "mutable": false, "attr": "close-behavior", @@ -5288,7 +5294,13 @@ "styles": [], "slots": [], "parts": [], - "listeners": [] + "listeners": [ + { + "event": "ix-assign-sub-menu", + "capture": false, + "passive": false + } + ] }, { "dirPath": "src/components/dropdown-button", @@ -16831,21 +16843,16 @@ "docstring": "", "path": "src/components/datetime-picker/datetime-picker.tsx" }, - "src/components/dropdown/placement.ts::AlignedPlacement": { - "declaration": "\"bottom-start\" | \"top-start\" | \"top-end\" | \"right-start\" | \"right-end\" | \"bottom-end\" | \"left-start\" | \"left-end\"", - "docstring": "", - "path": "src/components/dropdown/placement.ts" - }, - "src/components/dropdown/dropdown.tsx::DropdownTriggerEvent": { - "declaration": "export type DropdownTriggerEvent = 'click' | 'hover' | 'focus';", - "docstring": "", - "path": "src/components/dropdown/dropdown.tsx" - }, "src/components/dropdown-button/dropdown-button.tsx::DropdownButtonVariant": { "declaration": "export type ButtonVariant = 'primary' | 'secondary';", "docstring": "", "path": "src/components/dropdown-button/dropdown-button.tsx" }, + "src/components/dropdown/placement.ts::AlignedPlacement": { + "declaration": "\"bottom-start\" | \"top-start\" | \"top-end\" | \"right-start\" | \"right-end\" | \"bottom-end\" | \"left-start\" | \"left-end\"", + "docstring": "", + "path": "src/components/dropdown/placement.ts" + }, "src/components/empty-state/empty-state.tsx::EmptyStateLayout": { "declaration": "export type EmptyStateLayout = 'large' | 'compact' | 'compactBreak';", "docstring": "", @@ -16936,6 +16943,11 @@ "docstring": "", "path": "src/components/category-filter/input-state.ts" }, + "src/components/dropdown/dropdown-controller.ts::CloseBehaviour": { + "declaration": "export type CloseBehaviour = 'inside' | 'outside' | 'both' | boolean;", + "docstring": "", + "path": "src/components/dropdown/dropdown-controller.ts" + }, "src/components/flip-tile/flip-tile-state.ts::FlipTileState": { "declaration": "export enum FlipTileState {\n None = 'none',\n Info = 'info',\n Warning = 'warning',\n Alarm = 'alarm',\n Primary = 'primary',\n}", "docstring": "", diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 65b9c1a689f..a20cdb428cf 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -23,8 +23,8 @@ import { DateTimeCardCorners } from "./components/date-time-card/date-time-card" import { DateChangeEvent } from "./components/date-picker/date-picker"; import { DateTimeCardCorners as DateTimeCardCorners1 } from "./components/date-time-card/date-time-card"; import { DateTimeDateChangeEvent, DateTimeSelectEvent } from "./components/datetime-picker/datetime-picker"; +import { CloseBehaviour } from "./components/dropdown/dropdown-controller"; import { AlignedPlacement, Side } from "./components/dropdown/placement"; -import { DropdownTriggerEvent } from "./components/dropdown/dropdown"; import { DropdownButtonVariant } from "./components/dropdown-button/dropdown-button"; import { EmptyStateLayout } from "./components/empty-state/empty-state"; import { FlipTileState } from "./components/flip-tile/flip-tile-state"; @@ -60,8 +60,8 @@ export { DateTimeCardCorners } from "./components/date-time-card/date-time-card" export { DateChangeEvent } from "./components/date-picker/date-picker"; export { DateTimeCardCorners as DateTimeCardCorners1 } from "./components/date-time-card/date-time-card"; export { DateTimeDateChangeEvent, DateTimeSelectEvent } from "./components/datetime-picker/datetime-picker"; +export { CloseBehaviour } from "./components/dropdown/dropdown-controller"; export { AlignedPlacement, Side } from "./components/dropdown/placement"; -export { DropdownTriggerEvent } from "./components/dropdown/dropdown"; export { DropdownButtonVariant } from "./components/dropdown-button/dropdown-button"; export { EmptyStateLayout } from "./components/empty-state/empty-state"; export { FlipTileState } from "./components/flip-tile/flip-tile-state"; @@ -784,7 +784,7 @@ export namespace Components { /** * Controls if the dropdown will be closed in response to a click event depending on the position of the event relative to the dropdown. */ - "closeBehavior": 'inside' | 'outside' | 'both' | boolean; + "closeBehavior": CloseBehaviour; /** * An optional header shown at the top of the dropdown */ @@ -822,10 +822,6 @@ export namespace Components { * Define an element that triggers the dropdown. A trigger can either be a string that will be interpreted as id attribute or a DOM element. */ "trigger": string | HTMLElement | Promise; - /** - * Define one or more events to open dropdown - */ - "triggerEvent": DropdownTriggerEvent | DropdownTriggerEvent[]; /** * Update position of dropdown */ @@ -4725,7 +4721,7 @@ declare namespace LocalJSX { /** * Controls if the dropdown will be closed in response to a click event depending on the position of the event relative to the dropdown. */ - "closeBehavior"?: 'inside' | 'outside' | 'both' | boolean; + "closeBehavior"?: CloseBehaviour; /** * An optional header shown at the top of the dropdown */ @@ -4767,10 +4763,6 @@ declare namespace LocalJSX { * Define an element that triggers the dropdown. A trigger can either be a string that will be interpreted as id attribute or a DOM element. */ "trigger"?: string | HTMLElement | Promise; - /** - * Define one or more events to open dropdown - */ - "triggerEvent"?: DropdownTriggerEvent | DropdownTriggerEvent[]; } /** * @since 1.3.0 diff --git a/packages/core/src/components/dropdown/dropdown-controller.ts b/packages/core/src/components/dropdown/dropdown-controller.ts new file mode 100644 index 00000000000..f9ee17eab9d --- /dev/null +++ b/packages/core/src/components/dropdown/dropdown-controller.ts @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: 2024 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 { HTMLStencilElement } from '@stencil/core/internal'; + +export interface IxComponent { + hostElement: HTMLStencilElement; +} + +export type CloseBehaviour = 'inside' | 'outside' | 'both' | boolean; + +export interface DropdownInterface extends IxComponent { + closeBehavior: CloseBehaviour; + + getAssignedSubmenuIds(): string[]; + getId(): string; + + isPresent(): boolean; + + willPresent?(): boolean; + willDismiss?(): boolean; + + present(): void; + dismiss(): void; +} + +type DropdownRule = Record; + +class DropdownController { + private dropdowns = new Set(); + private dropdownRules: DropdownRule = {}; + + private isWindowListenerActive = false; + + connected(dropdown: DropdownInterface) { + if (!this.isWindowListenerActive) { + this.addOverlayListeners(); + } + this.dropdowns.add(dropdown); + } + + disconnected(dropdown: DropdownInterface) { + this.dropdowns.delete(dropdown); + } + + present(dropdown: DropdownInterface) { + this.dropdownRules[dropdown.getId()] = dropdown.getAssignedSubmenuIds(); + if (!dropdown.isPresent() && dropdown.willPresent()) { + dropdown.present(); + this.dismissPath(dropdown.getId()); + } + } + + dismiss(dropdown: DropdownInterface) { + if (dropdown.isPresent() && dropdown.willDismiss()) { + dropdown.dismiss(); + } + } + + dismissAll() { + for (const dropdown of this.dropdowns) { + if ( + dropdown.closeBehavior === 'inside' || + dropdown.closeBehavior === false + ) { + continue; + } + + this.dismiss(dropdown); + } + } + + dismissPath(uid: string) { + let path = this.buildComposedPath(uid, []); + + for (const dropdown of this.dropdowns) { + if ( + dropdown.closeBehavior !== 'inside' && + dropdown.closeBehavior !== false && + !path.includes(dropdown.getId()) + ) { + this.dismiss(dropdown); + } + } + } + + private buildComposedPath(id: string, path: string[]): string[] { + if (this.dropdownRules[id]) { + path.push(id); + } + + for (const ruleKey of Object.keys(this.dropdownRules)) { + if (this.dropdownRules[ruleKey].includes(id)) { + return this.buildComposedPath(ruleKey, path); + } + } + + return path; + } + + private addOverlayListeners() { + this.isWindowListenerActive = true; + + window.addEventListener('click', () => { + this.dismissAll(); + }); + + window.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Escape') { + this.dismissAll(); + } + }); + } +} + +export const addDisposableEventListener = ( + element: Element | Window | Document, + eventType: string, + callback: EventListenerOrEventListenerObject +) => { + element.addEventListener(eventType, callback); + + return () => { + element.removeEventListener(eventType, callback); + }; +}; + +export const addDisposableEventListenerAsArray = ( + listener: { + element: Element | Window | Document; + eventType: string; + callback: EventListenerOrEventListenerObject; + }[] +) => { + const disposables = listener.map(({ callback, element, eventType }) => + addDisposableEventListener(element, eventType, callback) + ); + + return () => disposables.forEach((dispose) => dispose()); +}; + +export const dropdownController = new DropdownController(); diff --git a/packages/core/src/components/dropdown/dropdown.tsx b/packages/core/src/components/dropdown/dropdown.tsx index de7d3d611e1..6d14a239ebe 100644 --- a/packages/core/src/components/dropdown/dropdown.tsx +++ b/packages/core/src/components/dropdown/dropdown.tsx @@ -23,17 +23,20 @@ import { EventEmitter, h, Host, + Listen, Method, Prop, Watch, } from '@stencil/core'; -import { OnListener } from '../utils/listener'; +import { ComponentInterface } from '@stencil/core/internal'; +import { + addDisposableEventListener, + CloseBehaviour, + dropdownController, + DropdownInterface, +} from './dropdown-controller'; import { AlignedPlacement } from './placement'; -/** - * @internal - */ -export type DropdownTriggerEvent = 'click' | 'hover' | 'focus'; let sequenceId = 0; @Component({ @@ -41,7 +44,7 @@ let sequenceId = 0; styleUrl: 'dropdown.scss', shadow: true, }) -export class Dropdown { +export class Dropdown implements ComponentInterface, DropdownInterface { @Element() hostElement!: HTMLIxDropdownElement; /** @@ -70,7 +73,7 @@ export class Dropdown { /** * Controls if the dropdown will be closed in response to a click event depending on the position of the event relative to the dropdown. */ - @Prop() closeBehavior: 'inside' | 'outside' | 'both' | boolean = 'both'; + @Prop() closeBehavior: CloseBehaviour = 'both'; /** * Placement of the dropdown @@ -98,12 +101,6 @@ export class Dropdown { alignmentAxis?: number; }; - /** - * Define one or more events to open dropdown - * @internal - */ - @Prop() triggerEvent: DropdownTriggerEvent | DropdownTriggerEvent[] = 'click'; - /** * @internal */ @@ -123,87 +120,95 @@ export class Dropdown { private anchorElement?: Element; private dropdownRef: HTMLElement; + private localUId = `dropdown-${sequenceId++}`; + private assignedSubmenu: string[] = []; - private toggleBind: any; - private openBind: any; - private focusInBind: any; - private focusOutBind: any; + connectedCallback(): void { + dropdownController.connected(this); + } - private localUId = `dropdown-${sequenceId++}`; + @Listen('ix-assign-sub-menu') + cacheSubmenuId({ detail }: CustomEvent) { + this.assignedSubmenu.push(detail); + } - constructor() { - this.toggleBind = this.toggle.bind(this); - this.openBind = this.open.bind(this); - this.focusInBind = this.focusIn.bind(this); - this.focusOutBind = this.focusOut.bind(this); + disconnectedCallback() { + this.disposeListener?.(); + dropdownController.disconnected(this); + if (this.autoUpdateCleanup) { + this.autoUpdateCleanup(); + } } - get dropdownItems() { - return Array.from(this.hostElement.querySelectorAll('ix-dropdown-item')); + getAssignedSubmenuIds() { + return this.assignedSubmenu; } - get slotElement() { - return this.hostElement.shadowRoot.querySelector('slot'); + isPresent() { + return this.show; } - private hasFocusTrigger() { - return ( - Array.isArray(this.triggerEvent) && - this.triggerEvent.indexOf('focus') != -1 - ); + present() { + this.show = true; } - private addEventListenersFor(triggerEvent: DropdownTriggerEvent) { - switch (triggerEvent) { - case 'click': - if (this.hasFocusTrigger()) { - // Delay mouse handler registration to prevent events from immediately closing dropdown again - this.triggerElement.addEventListener('focusin', this.focusInBind); - this.triggerElement.addEventListener('focusout', this.focusOutBind); - } else { - this.triggerElement.addEventListener('click', this.toggleBind); - } - break; + dismiss() { + this.show = false; + } - case 'hover': - this.triggerElement.addEventListener('mouseenter', this.openBind); - break; + getId() { + return this.localUId; + } - case 'focus': - this.triggerElement.addEventListener('focusin', this.openBind); - break; - } + willDismiss() { + const { defaultPrevented } = this.showChanged.emit(false); + return !defaultPrevented; + } - this.triggerElement.setAttribute('data-ix-dropdown-trigger', this.localUId); + willPresent() { + const { defaultPrevented } = this.showChanged.emit(true); + return !defaultPrevented; } - private removeEventListenersFor( - triggerEvent: DropdownTriggerEvent, - triggerElement: Element - ) { - switch (triggerEvent) { - case 'click': - if (this.hasFocusTrigger()) { - this.triggerElement.removeEventListener('focusin', this.focusInBind); - this.triggerElement.removeEventListener( - 'focusout', - this.focusOutBind - ); - } else { - triggerElement.removeEventListener('click', this.toggleBind); - } - break; + get dropdownItems() { + return Array.from(this.hostElement.querySelectorAll('ix-dropdown-item')); + } + + get slotElement() { + return this.hostElement.shadowRoot.querySelector('slot'); + } - case 'hover': - triggerElement.removeEventListener('mouseenter', this.openBind); - break; + private disposeListener?: Function; - case 'focus': - triggerElement.removeEventListener('focusin', this.openBind); - break; - } + private addEventListenersFor() { + this.disposeListener?.(); - this.triggerElement.removeAttribute('data-ix-dropdown-trigger'); + const stopEventDispatching = (event: Event) => { + // Prevent default and stop event bubbling to window, otherwise controller will close all dropdowns + if (this.triggerElement.hasAttribute('data-ix-dropdown-trigger')) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + const toggleController = () => { + if (!this.isPresent()) { + dropdownController.present(this); + } else { + dropdownController.dismiss(this); + dropdownController.dismissPath(this.getId()); + } + }; + + this.disposeListener = addDisposableEventListener( + this.triggerElement, + 'click', + (event) => { + stopEventDispatching(event); + toggleController(); + } + ); + this.triggerElement.setAttribute('data-ix-dropdown-trigger', this.localUId); } private async registerListener( @@ -211,26 +216,16 @@ export class Dropdown { ) { this.triggerElement = await this.resolveElement(element); if (this.triggerElement) { - if (Array.isArray(this.triggerEvent)) { - this.triggerEvent.forEach((triggerEvent) => { - this.addEventListenersFor(triggerEvent); - }); - } else { - this.addEventListenersFor(this.triggerEvent); - } - } - } + this.addEventListenersFor(); - private async unregisterListener( - element: string | HTMLElement | Promise - ) { - const trigger = await this.resolveElement(element); - if (Array.isArray(this.triggerEvent)) { - this.triggerEvent.forEach((triggerEvent) => { - this.removeEventListenersFor(triggerEvent, trigger); - }); - } else { - this.removeEventListenersFor(this.triggerEvent, trigger); + this.triggerElement.dispatchEvent( + new CustomEvent('ix-assign-sub-menu', { + bubbles: true, + composed: false, + cancelable: true, + detail: this.localUId, + }) + ); } } @@ -279,58 +274,8 @@ export class Dropdown { } @Watch('trigger') - changedTrigger( - newTriggerValue: string | HTMLElement | Promise, - oldTriggerValue: string | HTMLElement | Promise - ) { - if (newTriggerValue) { - this.registerListener(newTriggerValue); - } - - if (oldTriggerValue) { - this.unregisterListener(oldTriggerValue); - } - } - - @OnListener('click', (self) => self.show) - clickOutside(event: PointerEvent) { - const target = event.target as HTMLElement; - - if (event.defaultPrevented) { - return; - } - - if (this.show === false || this.closeBehavior === false) { - return; - } - - const clickInsideDropdown = this.isClickInsideDropdown(event); - switch (this.closeBehavior) { - case 'outside': - if (!clickInsideDropdown) { - this.close(); - } - break; - case 'inside': - if (clickInsideDropdown) { - this.close(); - } - break; - case 'both': - if (this.hostElement !== target) { - this.close(); - } - break; - default: - this.close(); - } - } - - @OnListener('keydown', (self) => self.show) - keydown(event: KeyboardEvent) { - if (event.code === 'Escape') { - this.close(); - } + changedTrigger(newTriggerValue: string | HTMLElement | Promise) { + this.registerListener(newTriggerValue); } private isAnchorSubmenu() { @@ -342,50 +287,6 @@ export class Dropdown { return true; } - private toggle(event: Event) { - const target = event.target as HTMLElement; - - if (this.isDropdownInsideAnotherDropdown(target)) { - event.preventDefault(); - } - - const { defaultPrevented } = this.showChanged.emit(!this.show); - - if (!defaultPrevented) { - this.show = !this.show; - } - } - - private open(event: Event) { - const target = event.target as HTMLElement; - - if (this.isDropdownInsideAnotherDropdown(target)) { - event.preventDefault(); - } - - const { defaultPrevented } = this.showChanged.emit(true); - - if (!defaultPrevented) { - this.show = true; - } - } - - private close() { - const { defaultPrevented } = this.showChanged.emit(false); - - if (!defaultPrevented) { - this.show = false; - } - } - - private focusIn() { - this.triggerElement.addEventListener('mousedown', this.toggleBind); - } - - private focusOut() { - this.triggerElement.removeEventListener('mousedown', this.toggleBind); - } - private async applyDropdownPosition() { if (!this.anchorElement) { return; @@ -457,28 +358,22 @@ export class Dropdown { ); } - private isDropdownInsideAnotherDropdown(element: HTMLElement) { - return ( - element.hasAttribute('data-ix-dropdown-trigger') && - !element.dispatchEvent( - new CustomEvent('check-nested-dropdown', { - bubbles: true, - composed: true, - cancelable: true, - }) - ) - ); - } - async componentDidLoad() { - this.changedTrigger(this.trigger, null); + this.changedTrigger(this.trigger); // Event listener to check if a dropdown is inside another dropdown - // cancellation of the event will prevent the closing of the parent dropdown - this.hostElement.addEventListener('check-nested-dropdown', (e) => { - e.preventDefault(); - e.stopPropagation(); - }); + // Cancellation of the event will prevent the closing of the parent dropdown + this.hostElement.addEventListener( + 'check-nested-dropdown', + (event: CustomEvent) => { + if (event.detail === this.localUId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + } + ); } async componentDidRender() { @@ -495,19 +390,13 @@ 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 - ); - } + private onDropdownClick(event: PointerEvent) { + event.preventDefault(); + event.stopPropagation(); - disconnectedCallback() { - if (this.autoUpdateCleanup) { - this.autoUpdateCleanup(); + if (this.closeBehavior === 'inside' || this.closeBehavior === 'both') { + dropdownController.dismiss(this); + dropdownController.dismissAll(); } } @@ -535,6 +424,7 @@ export class Dropdown { position: this.positioningStrategy, }} role="list" + onClick={(event: PointerEvent) => this.onDropdownClick(event)} >
{this.header && } diff --git a/packages/core/src/components/dropdown/test/dropdown.ct.ts b/packages/core/src/components/dropdown/test/dropdown.ct.ts index 86280efaacd..83ea7649882 100644 --- a/packages/core/src/components/dropdown/test/dropdown.ct.ts +++ b/packages/core/src/components/dropdown/test/dropdown.ct.ts @@ -15,7 +15,7 @@ * 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 { ElementHandle, expect, Locator, Page } from '@playwright/test'; import { test } from '@utils/test'; test('renders', async ({ mount, page }) => { @@ -92,7 +92,289 @@ function expectToBeVisible(elements: Locator[], index: number) { ); } -test.describe('nested dropdown tests', () => { +test('trigger toggles', async ({ mount, page }) => { + await mount(`Open + + + + + `); + + await page.locator('ix-button').click(); + const dropdown = page.locator('.dropdown-menu'); + await expect(dropdown).toHaveClass(/show/); + await expect(dropdown).toBeVisible(); + + await page.locator('ix-button').click(); + const after = page.locator('.dropdown-menu'); + await expect(after).not.toHaveClass(/show/); + await expect(dropdown).not.toBeVisible(); +}); + +test.describe('Close behavior', () => { + function mountDropdown( + mount: (selector: string) => Promise>, + config: { + closeBehavior: string | boolean; + } + ) { + const closeBehavior = config.closeBehavior + ? `close-behavior="${config.closeBehavior}"` + : ''; + + return mount(` + Trigger + + Item 1 + Item 2 + Item 3 + + `); + } + + let triggerButton: Locator; + let dropdownLevel1: Locator; + + let dropdownLevel1_Item1: Locator; + + 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'); + } + + test(' = both', async ({ mount, page }) => { + await mountDropdown(mount, { + closeBehavior: 'both', + }); + + setupTest(page); + + await triggerButton.click(); + await expect(dropdownLevel1).toBeVisible(); + + await page.mouse.click(400, 200); + await expect(dropdownLevel1).not.toBeVisible(); + + await triggerButton.click(); + await expect(dropdownLevel1).toBeVisible(); + + await dropdownLevel1_Item1.click(); + await expect(dropdownLevel1).not.toBeVisible(); + }); + + test(' = inside', async ({ mount, page }) => { + await mountDropdown(mount, { + closeBehavior: 'inside', + }); + + setupTest(page); + + await triggerButton.click(); + await expect(dropdownLevel1).toBeVisible(); + + await page.mouse.click(400, 200); + await expect(dropdownLevel1).toBeVisible(); + + await dropdownLevel1_Item1.click(); + await expect(dropdownLevel1).not.toBeVisible(); + }); + + test(' = outside', async ({ mount, page }) => { + await mountDropdown(mount, { + closeBehavior: 'outside', + }); + + setupTest(page); + + await triggerButton.click(); + await expect(dropdownLevel1).toBeVisible(); + + await page.mouse.click(400, 200); + await expect(dropdownLevel1).not.toBeVisible(); + + await triggerButton.click(); + await expect(dropdownLevel1).toBeVisible(); + + await dropdownLevel1_Item1.click(); + await expect(dropdownLevel1).toBeVisible(); + }); + + test(' = false', async ({ mount, page }) => { + await mountDropdown(mount, { + // Disable close behavior + closeBehavior: false, + }); + + // Have to be provided via javascript, otherwise the component parse the value as a string. + await page + .locator('ix-dropdown') + .evaluate((dropdown: any) => (dropdown.closeBehavior = false)); + + setupTest(page); + + await triggerButton.click(); + await expect(dropdownLevel1).toBeVisible(); + + await page.mouse.click(400, 200); + await expect(dropdownLevel1).toBeVisible(); + + await triggerButton.click(); + await expect(dropdownLevel1).not.toBeVisible(); + + await triggerButton.click(); + await expect(dropdownLevel1).toBeVisible(); + + await dropdownLevel1_Item1.click({ + force: true, + }); + await expect(dropdownLevel1).toBeVisible(); + }); +}); + +test.describe('Nested dropdowns 1/2', () => { + function mountDropdown( + mount: (selector: string) => Promise> + ) { + const html = String.raw; + + return mount(html` + Trigger 1 + + Item 1 + Item 2 + Item 3 + + + + Item 1.1 + Item 1.2 + Item 1.3 + + + + Item 3.1 + Item 3.2 + Item 3.3 + + + + Item 3.3.1 + Item 3.3.2 + Item 3.3.3 + + + Trigger 5 + + Item 1 + Item 2 + Item 3 + + + + Item 1 + Item 2 + Item 3 + + `); + } + + let triggerDropdown1: Locator; + let triggerDropdown2: Locator; + let triggerDropdown3: Locator; + let triggerDropdown4: Locator; + let triggerDropdown5: Locator; + + let dropdown1: Locator; + let dropdown2: Locator; + let dropdown3: Locator; + let dropdown4: Locator; + let dropdown5: Locator; + + function setupTest(page: Page) { + triggerDropdown1 = page.locator('#trigger-dropdown-1'); + triggerDropdown2 = page.locator('#trigger-dropdown-2'); + triggerDropdown3 = page.locator('#trigger-dropdown-3'); + triggerDropdown4 = page.locator('#trigger-dropdown-4'); + triggerDropdown5 = page.locator('#trigger-dropdown-5'); + + dropdown1 = page.locator('#dropdown-1'); + dropdown2 = page.locator('#dropdown-2'); + dropdown3 = page.locator('#dropdown-3'); + dropdown4 = page.locator('#dropdown-4'); + dropdown5 = page.locator('#dropdown-5'); + } + + test('close neighbor sub menu', async ({ mount, page }) => { + await mountDropdown(mount); + setupTest(page); + + await triggerDropdown1.click(); + await expect(dropdown1).toBeVisible(); + + await triggerDropdown3.click(); + await expect(dropdown3).toBeVisible(); + + await triggerDropdown5.click(); + await expect(dropdown5).toBeVisible(); + await expect(dropdown1).not.toBeVisible(); + await expect(dropdown3).not.toBeVisible(); + }); + + test('close assigned submenu', async ({ mount, page }) => { + await mountDropdown(mount); + + setupTest(page); + + await triggerDropdown1.click(); + await expect(dropdown1).toBeVisible(); + + await triggerDropdown2.click(); + await expect(dropdown2).toBeVisible(); + + await triggerDropdown3.click(); + await expect(dropdown2).not.toBeVisible(); + await expect(dropdown3).toBeVisible(); + + await triggerDropdown4.click(); + await expect(dropdown4).toBeVisible(); + + await triggerDropdown3.click(); + await expect(dropdown3).not.toBeVisible(); + await expect(dropdown4).not.toBeVisible(); + }); + + test('close by Escape', async ({ mount, page }) => { + await mountDropdown(mount); + + setupTest(page); + + await triggerDropdown1.click(); + await expect(dropdown1).toBeVisible(); + + await triggerDropdown2.click(); + await expect(dropdown2).toBeVisible(); + + await triggerDropdown3.click(); + await expect(dropdown2).not.toBeVisible(); + await expect(dropdown3).toBeVisible(); + + await triggerDropdown4.click(); + await expect(dropdown4).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(dropdown1).not.toBeVisible(); + await expect(dropdown2).not.toBeVisible(); + await expect(dropdown3).not.toBeVisible(); + await expect(dropdown4).not.toBeVisible(); + }); +}); + +test.describe('nested dropdown 2/2', () => { const button1Text = 'Triggerbutton1'; const button2Text = 'Triggerbutton2'; @@ -116,22 +398,3 @@ test.describe('nested dropdown tests', () => { await expect(nestedDropdownItem).toHaveClass(/hydrated/); }); }); - -test('trigger toggles', async ({ mount, page }) => { - await mount(`Open - - - - - `); - - await page.locator('ix-button').click(); - const dropdown = page.locator('.dropdown-menu'); - await expect(dropdown).toHaveClass(/show/); - await expect(dropdown).toBeVisible(); - - await page.locator('ix-button').click(); - const after = page.locator('.dropdown-menu'); - await expect(after).not.toHaveClass(/show/); - await expect(dropdown).not.toBeVisible(); -}); diff --git a/packages/core/src/components/toast/toast-utils.ts b/packages/core/src/components/toast/toast-utils.ts index 5c40a125995..ea77452bbd8 100644 --- a/packages/core/src/components/toast/toast-utils.ts +++ b/packages/core/src/components/toast/toast-utils.ts @@ -27,7 +27,7 @@ export function getToastContainer() { const [container] = containerList; if (containerList.length > 1) { console.warn( - 'Multiple toast container are found. Only there first is used.' + 'Multiple toast containers were found. Only the first one will be used.' ); return container; } diff --git a/packages/core/src/tests/dropdown/dropdown.e2e.ts b/packages/core/src/tests/dropdown/dropdown.e2e.ts index 03971a7b33c..b790c345a9f 100644 --- a/packages/core/src/tests/dropdown/dropdown.e2e.ts +++ b/packages/core/src/tests/dropdown/dropdown.e2e.ts @@ -38,17 +38,6 @@ regressionTest.describe('dropdown', () => { }); }); - regressionTest('tigger events', async ({ page }) => { - await page.goto('dropdown/trigger-events'); - - await page.locator('input').focus(); - await page.waitForSelector('.dropdown-menu.show'); - - expect(await page.screenshot({ fullPage: true })).toMatchSnapshot({ - maxDiffPixelRatio: 0.05, - }); - }); - regressionTest('disabled', async ({ page }) => { await page.goto('dropdown/disabled'); diff --git a/packages/core/src/tests/dropdown/dropdown.e2e.ts-snapshots/dropdown-tigger-events-1-chromium---theme-classic-dark-linux.png b/packages/core/src/tests/dropdown/dropdown.e2e.ts-snapshots/dropdown-tigger-events-1-chromium---theme-classic-dark-linux.png deleted file mode 100644 index 89a0f943643..00000000000 Binary files a/packages/core/src/tests/dropdown/dropdown.e2e.ts-snapshots/dropdown-tigger-events-1-chromium---theme-classic-dark-linux.png and /dev/null differ diff --git a/packages/core/src/tests/dropdown/dropdown.e2e.ts-snapshots/dropdown-tigger-events-1-chromium---theme-classic-light-linux.png b/packages/core/src/tests/dropdown/dropdown.e2e.ts-snapshots/dropdown-tigger-events-1-chromium---theme-classic-light-linux.png deleted file mode 100644 index d268575fe02..00000000000 Binary files a/packages/core/src/tests/dropdown/dropdown.e2e.ts-snapshots/dropdown-tigger-events-1-chromium---theme-classic-light-linux.png and /dev/null differ diff --git a/packages/core/src/tests/dropdown/trigger-events/index.html b/packages/core/src/tests/dropdown/trigger-events/index.html deleted file mode 100644 index c766cec1d9b..00000000000 --- a/packages/core/src/tests/dropdown/trigger-events/index.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - Stencil Component Starter - - - - - - - - - - - diff --git a/packages/vue/src/components.ts b/packages/vue/src/components.ts index 055f5f0cd90..af1c466df4f 100644 --- a/packages/vue/src/components.ts +++ b/packages/vue/src/components.ts @@ -359,7 +359,6 @@ export const IxDropdown = /*@__PURE__*/ defineContainer('ix-drop 'positioningStrategy', 'header', 'offset', - 'triggerEvent', 'overwriteDropdownStyle', 'showChanged' ]);