From 132c4356ade153833efff2269cfe415923e4d3b0 Mon Sep 17 00:00:00 2001 From: Lukas Maurer Date: Fri, 8 Dec 2023 13:59:40 +0100 Subject: [PATCH] feat(core/dropdown): allow trigger to toggle dropdown (#872) --- packages/angular/src/components.ts | 4 +- packages/core/component-doc.json | 34 +++++++++++++++ packages/core/src/components.d.ts | 10 +++++ .../category-filter/category-filter.tsx | 14 +++++-- .../dropdown-button/dropdown-button.tsx | 7 ++++ .../core/src/components/dropdown/dropdown.tsx | 41 ++++++++++++++++--- .../components/dropdown/test/dropdown.ct.ts | 19 +++++++++ .../src/components/select/test/select.ct.ts | 2 - packages/vue/src/components.ts | 1 + 9 files changed, 118 insertions(+), 14 deletions(-) diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index 370025bc559..f8d802589a3 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -649,14 +649,14 @@ export declare interface IxDropdown extends Components.IxDropdown { @ProxyCmp({ - inputs: ['disabled', 'ghost', 'icon', 'label', 'outline', 'placement', 'variant'] + inputs: ['closeBehavior', 'disabled', 'ghost', 'icon', 'label', 'outline', 'placement', 'variant'] }) @Component({ selector: 'ix-dropdown-button', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['disabled', 'ghost', 'icon', 'label', 'outline', 'placement', 'variant'], + inputs: ['closeBehavior', 'disabled', 'ghost', 'icon', 'label', 'outline', 'placement', 'variant'], }) export class IxDropdownButton { protected el: HTMLElement; diff --git a/packages/core/component-doc.json b/packages/core/component-doc.json index 6d28552fc57..2058aa59ed7 100644 --- a/packages/core/component-doc.json +++ b/packages/core/component-doc.json @@ -3865,6 +3865,40 @@ ] }, "props": [ + { + "name": "closeBehavior", + "type": "\"both\" | \"inside\" | \"outside\" | boolean", + "mutable": false, + "attr": "close-behavior", + "reflectToAttr": false, + "docs": "Controls if the dropdown will be closed in response to a click event depending on the position of the event relative to the dropdown.", + "docsTags": [ + { + "name": "since", + "text": "2.1.0" + } + ], + "default": "'both'", + "values": [ + { + "value": "both", + "type": "string" + }, + { + "value": "inside", + "type": "string" + }, + { + "value": "outside", + "type": "string" + }, + { + "type": "boolean" + } + ], + "optional": false, + "required": false + }, { "name": "disabled", "type": "boolean", diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 8116b89c06f..ffeed895835 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -893,6 +893,11 @@ export namespace Components { * @since 1.3.0 */ interface IxDropdownButton { + /** + * Controls if the dropdown will be closed in response to a click event depending on the position of the event relative to the dropdown. + * @since 2.1.0 + */ + "closeBehavior": 'inside' | 'outside' | 'both' | boolean; /** * Disable button */ @@ -4346,6 +4351,11 @@ declare namespace LocalJSX { * @since 1.3.0 */ interface IxDropdownButton { + /** + * Controls if the dropdown will be closed in response to a click event depending on the position of the event relative to the dropdown. + * @since 2.1.0 + */ + "closeBehavior"?: 'inside' | 'outside' | 'both' | boolean; /** * Disable button */ diff --git a/packages/core/src/components/category-filter/category-filter.tsx b/packages/core/src/components/category-filter/category-filter.tsx index e890993970b..98088179ef9 100644 --- a/packages/core/src/components/category-filter/category-filter.tsx +++ b/packages/core/src/components/category-filter/category-filter.tsx @@ -31,6 +31,7 @@ import { LogicalFilterOperator } from './logical-filter-operator'; export class CategoryFilter { private readonly ID_CUSTOM_FILTER_VALUE = 'CW_CUSTOM_FILTER_VALUE'; + @State() showDropdown: boolean; @State() private textInput?: HTMLInputElement; private formElement?: HTMLFormElement; private isScrollStateDirty: boolean; @@ -241,6 +242,7 @@ export class CategoryFilter { break; case 'ArrowDown': + this.showDropdown = true; this.focusNextItem(); e.preventDefault(); break; @@ -389,7 +391,8 @@ export class CategoryFilter { this.categoryChanged.emit(category); } - private resetFilter() { + private resetFilter(e: Event) { + e.stopPropagation(); this.closeDropdown(); this.filterTokens = []; this.emitFilterEvent(); @@ -621,7 +624,7 @@ export class CategoryFilter { private getResetButton() { return ( this.resetFilter()} + onClick={(e) => this.resetFilter(e)} class={{ 'reset-button': true, 'hide-reset-button': @@ -680,6 +683,7 @@ export class CategoryFilter { e.stopPropagation()} onCloseClick={() => this.removeToken(index)} > {this.getFilterChipLabel(value)} @@ -706,6 +710,7 @@ export class CategoryFilter { this.disabled || this.category !== undefined, }} + name="category-filter-input" disabled={this.disabled} readonly={this.readonly} ref={(el) => (this.textInput = el)} @@ -722,10 +727,11 @@ export class CategoryFilter { '' ) : ( {this.renderDropdownContent()} diff --git a/packages/core/src/components/dropdown-button/dropdown-button.tsx b/packages/core/src/components/dropdown-button/dropdown-button.tsx index 7188c55bf48..9e08b44742b 100644 --- a/packages/core/src/components/dropdown-button/dropdown-button.tsx +++ b/packages/core/src/components/dropdown-button/dropdown-button.tsx @@ -54,6 +54,12 @@ export class DropdownButton { */ @Prop() icon: string; + /** + * Controls if the dropdown will be closed in response to a click event depending on the position of the event relative to the dropdown. + * @since 2.1.0 + */ + @Prop() closeBehavior: 'inside' | 'outside' | 'both' | boolean = 'both'; + /** * Placement of the dropdown * @@ -130,6 +136,7 @@ export class DropdownButton { class="dropdown" trigger={this.dropdownAnchor} placement={this.placement} + closeBehavior={this.closeBehavior} > diff --git a/packages/core/src/components/dropdown/dropdown.tsx b/packages/core/src/components/dropdown/dropdown.tsx index 1b1a4aa6694..ccd93824752 100644 --- a/packages/core/src/components/dropdown/dropdown.tsx +++ b/packages/core/src/components/dropdown/dropdown.tsx @@ -134,15 +134,19 @@ export class Dropdown { private toggleBind: any; private openBind: any; + private focusInBind: any; + private focusOutBind: any; private localUId = `dropdown-${sequenceId++}-${new Date().valueOf()}`; 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); if (dropdownDisposer.has(this.localUId)) { - console.warn('Dropdown with duplicated id detected'); + console.warn('Dropdown with duplicated ID detected'); } dropdownDisposer.set(this.localUId, { @@ -192,10 +196,23 @@ export class Dropdown { return this.hostElement.shadowRoot.querySelector('slot'); } + private hasFocusTrigger() { + return ( + Array.isArray(this.triggerEvent) && + this.triggerEvent.indexOf('focus') != -1 + ); + } + private addEventListenersFor(triggerEvent: DropdownTriggerEvent) { switch (triggerEvent) { case 'click': - this.triggerElement.addEventListener('click', this.openBind); + 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; case 'hover': @@ -214,8 +231,12 @@ export class Dropdown { ) { switch (triggerEvent) { case 'click': - if (this.closeBehavior === 'outside') { - triggerElement.removeEventListener('click', this.openBind); + if (this.hasFocusTrigger()) { + this.triggerElement.removeEventListener('focusin', this.focusInBind); + this.triggerElement.removeEventListener( + 'focusout', + this.focusOutBind + ); } else { triggerElement.removeEventListener('click', this.toggleBind); } @@ -361,7 +382,7 @@ export class Dropdown { @OnListener('keydown', (self) => self.show) keydown(event: KeyboardEvent) { - if (this.show === true && event.code === 'Escape') { + if (event.code === 'Escape') { this.close(); } } @@ -386,7 +407,7 @@ export class Dropdown { event.stopPropagation(); } - const { defaultPrevented } = this.showChanged.emit(this.show); + const { defaultPrevented } = this.showChanged.emit(!this.show); if (!defaultPrevented) { this.show = !this.show; @@ -415,6 +436,14 @@ export class Dropdown { } } + private focusIn() { + this.triggerElement.addEventListener('mousedown', this.toggleBind); + } + + private focusOut() { + this.triggerElement.removeEventListener('mousedown', this.toggleBind); + } + private async applyDropdownPosition() { if (!this.anchorElement) { return; diff --git a/packages/core/src/components/dropdown/test/dropdown.ct.ts b/packages/core/src/components/dropdown/test/dropdown.ct.ts index 2c784fa1950..697c5374e62 100644 --- a/packages/core/src/components/dropdown/test/dropdown.ct.ts +++ b/packages/core/src/components/dropdown/test/dropdown.ct.ts @@ -124,3 +124,22 @@ 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/select/test/select.ct.ts b/packages/core/src/components/select/test/select.ct.ts index 6d9aacf180c..01c0ba53522 100644 --- a/packages/core/src/components/select/test/select.ct.ts +++ b/packages/core/src/components/select/test/select.ct.ts @@ -108,9 +108,7 @@ test('multiple selection', async ({ mount, page }) => { const item1 = element.locator('ix-select-item').nth(0); const item3 = element.locator('ix-select-item').nth(2); await item1.click(); - await page.locator('[data-select-dropdown]').click(); await item3.click(); - await page.locator('[data-select-dropdown]').click(); await expect(item1.locator('ix-icon')).toBeVisible(); await expect(item3.locator('ix-icon')).toBeVisible(); diff --git a/packages/vue/src/components.ts b/packages/vue/src/components.ts index b7c7b3158f5..8bd9db7a943 100644 --- a/packages/vue/src/components.ts +++ b/packages/vue/src/components.ts @@ -343,6 +343,7 @@ export const IxDropdownButton = /*@__PURE__*/ defineContainer