diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index e83db0ea00..14a3976d97 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -817,7 +817,7 @@ export declare interface IxDrawer extends Components.IxDrawer { @ProxyCmp({ - inputs: ['anchor', 'closeBehavior', 'enableTopLayer', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'trigger'], + inputs: ['anchor', 'closeBehavior', 'container', 'enableTopLayer', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'trigger'], methods: ['updatePosition'] }) @Component({ @@ -825,7 +825,7 @@ export declare interface IxDrawer extends Components.IxDrawer { changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['anchor', 'closeBehavior', 'enableTopLayer', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'trigger'], + inputs: ['anchor', 'closeBehavior', 'container', 'enableTopLayer', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'trigger'], outputs: ['showChanged'], standalone: false }) diff --git a/packages/angular/standalone/src/components.ts b/packages/angular/standalone/src/components.ts index e49ac03d8f..f8881f4b77 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -918,7 +918,7 @@ export declare interface IxDrawer extends Components.IxDrawer { @ProxyCmp({ defineCustomElementFn: defineIxDropdown, - inputs: ['anchor', 'closeBehavior', 'enableTopLayer', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'trigger'], + inputs: ['anchor', 'closeBehavior', 'container', 'enableTopLayer', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'trigger'], methods: ['updatePosition'] }) @Component({ @@ -926,7 +926,7 @@ export declare interface IxDrawer extends Components.IxDrawer { changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['anchor', 'closeBehavior', 'enableTopLayer', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'trigger'], + inputs: ['anchor', 'closeBehavior', 'container', 'enableTopLayer', 'header', 'placement', 'positioningStrategy', 'show', 'suppressAutomaticPlacement', 'trigger'], outputs: ['showChanged'], }) export class IxDropdown { diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 93367cd85f..65678cda2b 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -1342,6 +1342,11 @@ export namespace Components { * @default 'both' */ "closeBehavior": CloseBehavior; + /** + * Define a container element to constrain dropdown within. + * @since 4.3.0 + */ + "container"?: ElementReference; /** * @default false */ @@ -7313,6 +7318,11 @@ declare namespace LocalJSX { * @default 'both' */ "closeBehavior"?: CloseBehavior; + /** + * Define a container element to constrain dropdown within. + * @since 4.3.0 + */ + "container"?: ElementReference; /** * @default false */ diff --git a/packages/core/src/components/dropdown/dropdown-controller.ts b/packages/core/src/components/dropdown/dropdown-controller.ts index 9372d0c722..5145f4e1b3 100644 --- a/packages/core/src/components/dropdown/dropdown-controller.ts +++ b/packages/core/src/components/dropdown/dropdown-controller.ts @@ -95,7 +95,10 @@ class DropdownController { } present(dropdown: DropdownInterface) { - if (!dropdown.isPresent() && dropdown.willPresent?.()) { + if ( + !dropdown.isPresent() && + (!dropdown.willPresent || dropdown.willPresent()) + ) { this.submenuIds[dropdown.getId()] = dropdown.getAssignedSubmenuIds(); dropdown.present(); } @@ -112,7 +115,10 @@ class DropdownController { } dismiss(dropdown: DropdownInterface) { - if (dropdown.isPresent() && dropdown.willDismiss?.()) { + if ( + dropdown.isPresent() && + (!dropdown.willDismiss || dropdown.willDismiss()) + ) { this.dismissChildren(dropdown.getId()); dropdown.dismiss(); delete this.submenuIds[dropdown.getId()]; diff --git a/packages/core/src/components/dropdown/dropdown.scss b/packages/core/src/components/dropdown/dropdown.scss index ac92019acb..d67def6e48 100644 --- a/packages/core/src/components/dropdown/dropdown.scss +++ b/packages/core/src/components/dropdown/dropdown.scss @@ -38,6 +38,7 @@ $dropdown-offset: 3rem; :host(.overflow) { max-height: calc(50vh - $dropdown-offset); overflow-y: auto; + overscroll-behavior: contain; } :host(:not(.show)) { @@ -58,12 +59,9 @@ $dropdown-offset: 3rem; box-shadow: var(--theme-shadow-4); overflow-x: visible; overflow-y: visible; - inset: unset; - color-scheme: inherit; color: var(--theme-color-std-text); - box-sizing: border-box; *, diff --git a/packages/core/src/components/dropdown/dropdown.tsx b/packages/core/src/components/dropdown/dropdown.tsx index 552a374284..9a3c2c8c15 100644 --- a/packages/core/src/components/dropdown/dropdown.tsx +++ b/packages/core/src/components/dropdown/dropdown.tsx @@ -15,6 +15,7 @@ import { inline, offset, shift, + limitShift, } from '@floating-ui/dom'; import { Component, @@ -130,6 +131,12 @@ export class Dropdown implements ComponentInterface, DropdownInterface { /** @internal */ @Prop() suppressOverflowBehavior = false; + /** + * Define a container element to constrain dropdown within. + * @since 4.3.0 + */ + @Prop() container?: ElementReference; + /** * Enable Popover API rendering for top-layer positioning. * @@ -206,6 +213,7 @@ export class Dropdown implements ComponentInterface, DropdownInterface { this.autoUpdateCleanup(); this.autoUpdateCleanup = undefined; } + this.removeVisibilityListeners(); } getAssignedSubmenuIds() { @@ -363,6 +371,30 @@ export class Dropdown implements ComponentInterface, DropdownInterface { } } + private autoCloseTimeout?: ReturnType; + private containerElement?: HTMLElement | null; + + private visibilityHandler?: () => void; + private scrollableParent?: HTMLElement; + + private findScrollableParent( + element: HTMLElement | null + ): HTMLElement | null { + let el = element; + while (el) { + const style = globalThis.getComputedStyle(el); + const overflowY = style.overflowY; + if ( + (overflowY === 'auto' || overflowY === 'scroll') && + el.scrollHeight > el.clientHeight + ) { + return el; + } + el = el.parentElement; + } + return null; + } + @Watch('show') async changedShow(newShow: boolean) { if (!newShow) { @@ -371,8 +403,39 @@ export class Dropdown implements ComponentInterface, DropdownInterface { if (this.enableTopLayer) { await this.hideDropdownAsync(); } + this.clearAutoCloseTimeout(); + this.removeVisibilityListeners(); return; } + if (!this.enableTopLayer) { + await this.resolveContainerElement(); + this.removeVisibilityListeners(); + this.scrollableParent = + this.findScrollableParent(this.hostElement.parentElement) || undefined; + + this.visibilityHandler = () => { + if (this.isDropdownFullyNotVisible()) { + this.dismiss(); + } + }; + + window.addEventListener('scroll', this.visibilityHandler, true); + window.addEventListener('resize', this.visibilityHandler, true); + if (this.scrollableParent) { + this.scrollableParent.addEventListener( + 'scroll', + this.visibilityHandler, + true + ); + } + + this.clearAutoCloseTimeout(); + this.autoCloseTimeout = setTimeout(() => { + if (this.isDropdownFullyNotVisible()) { + this.dismiss(); + } + }, 300); + } this.arrowFocusController = new ArrowFocusController( this.dropdownItems, @@ -399,11 +462,59 @@ export class Dropdown implements ComponentInterface, DropdownInterface { } } + private removeVisibilityListeners() { + if (this.visibilityHandler) { + window.removeEventListener('scroll', this.visibilityHandler, true); + window.removeEventListener('resize', this.visibilityHandler, true); + if (this.scrollableParent) { + this.scrollableParent.removeEventListener( + 'scroll', + this.visibilityHandler, + true + ); + this.scrollableParent = undefined; + } + this.visibilityHandler = undefined; + } + } + + private isDropdownFullyNotVisible(): boolean { + const rect = this.hostElement.getBoundingClientRect(); + const vw = window.innerWidth || document.documentElement.clientWidth; + const vh = window.innerHeight || document.documentElement.clientHeight; + return ( + rect.bottom <= 0 || rect.top >= vh || rect.right <= 0 || rect.left >= vw + ); + } + + private clearAutoCloseTimeout() { + if (this.autoCloseTimeout) { + clearTimeout(this.autoCloseTimeout); + this.autoCloseTimeout = undefined; + } + } + @Watch('trigger') changedTrigger(newTriggerValue: ElementReference) { this.registerListener(newTriggerValue); } + @Watch('container') + async changedContainer() { + await this.resolveContainerElement(); + await this.applyDropdownPosition(); + } + + private async resolveContainerElement() { + if (this.container) { + this.containerElement = (await findElement( + this.container + )) as HTMLElement; + } else { + this.containerElement = null; + } + } + private destroyAutoUpdate() { if (this.autoUpdateCleanup) { this.autoUpdateCleanup(); @@ -477,8 +588,9 @@ export class Dropdown implements ComponentInterface, DropdownInterface { const referenceElement = this.anchorElement; const isSubmenu = this.isAnchorSubmenu(); + const useAbsolute = !!this.containerElement; + let targetElement: HTMLElement = this.hostElement; - let strategy: 'fixed' | 'absolute' = this.positioningStrategy; if (this.enableTopLayer) { const dialog = await this.dialogRef.waitForCurrent(); @@ -486,11 +598,10 @@ export class Dropdown implements ComponentInterface, DropdownInterface { return; } targetElement = dialog; - strategy = 'fixed'; } let positionConfig: Partial = { - strategy, + strategy: useAbsolute ? 'absolute' : this.positioningStrategy, middleware: [], }; @@ -505,7 +616,9 @@ export class Dropdown implements ComponentInterface, DropdownInterface { positionConfig.middleware = [ ...(positionConfig.middleware?.filter(Boolean) || []), inline(), - shift(), + shift({ + limiter: this.containerElement ? limitShift() : undefined, + }), ]; if (this.offset) { @@ -523,13 +636,36 @@ export class Dropdown implements ComponentInterface, DropdownInterface { targetElement, positionConfig ); - Object.assign(targetElement.style, { - top: '0', - left: '0', - transform: `translate(${Math.round(computeResponse.x)}px,${Math.round( - computeResponse.y - )}px)`, - }); + let x = Math.round(computeResponse.x); + let y = Math.round(computeResponse.y); + + if (useAbsolute && this.containerElement) { + if ( + this.hostElement.parentElement !== this.containerElement && + this.hostElement.isConnected && + this.containerElement.isConnected + ) { + this.containerElement.appendChild(this.hostElement); + } + Object.assign(this.hostElement.style, { + position: 'absolute', + top: `${y}px`, + left: `${x}px`, + transform: '', + zIndex: `var(--theme-z-index-dropdown)`, + pointerEvents: 'auto', + }); + } else { + Object.assign(targetElement.style, { + top: '0', + left: '0', + transform: `translate(${Math.round(computeResponse.x)}px,${Math.round( + computeResponse.y + )}px)`, + position: this.positioningStrategy, + zIndex: `var(--theme-z-index-dropdown)`, + }); + } if (this.overwriteDropdownStyle) { const overwriteStyle = await this.overwriteDropdownStyle({ @@ -569,11 +705,13 @@ export class Dropdown implements ComponentInterface, DropdownInterface { } this.changedTrigger(this.trigger); + await this.resolveContainerElement(); } async componentDidRender() { await this.applyDropdownPosition(); await this.resolveAnchorElement(); + await this.resolveContainerElement(); } private isTriggerElement(element: HTMLElement) { diff --git a/packages/core/src/components/dropdown/test/dropdown.ct.ts b/packages/core/src/components/dropdown/test/dropdown.ct.ts index 96ed4ecf7d..94a1ff2b56 100644 --- a/packages/core/src/components/dropdown/test/dropdown.ct.ts +++ b/packages/core/src/components/dropdown/test/dropdown.ct.ts @@ -884,3 +884,24 @@ regressionTest( await expect(dynamicItem).not.toHaveAttribute('disabled'); } ); + +regressionTest( + 'auto-close when dropdown is fully not visible', + async ({ mount, page }) => { + await mount(` + Open + + + + `); + const trigger = page.locator('#trigger'); + const dropdown = page.locator('#dropdown'); + await trigger.click(); + await expect(dropdown).toBeVisible(); + await dropdown.evaluate((el: HTMLElement) => { + el.style.transform = 'translate(-10000px, -10000px)'; + }); + await page.waitForTimeout(350); + await expect(dropdown).not.toBeVisible(); + } +); diff --git a/packages/react/src/components.server.ts b/packages/react/src/components.server.ts index 657d47a8ae..7e52222b46 100644 --- a/packages/react/src/components.server.ts +++ b/packages/react/src/components.server.ts @@ -690,6 +690,7 @@ export const IxDropdown: StencilReactComponent, diff --git a/packages/vue/src/components.ts b/packages/vue/src/components.ts index ed464e3c96..35274d6c3b 100644 --- a/packages/vue/src/components.ts +++ b/packages/vue/src/components.ts @@ -536,6 +536,7 @@ export const IxDropdown: StencilVueComponent = /*@__PURE__*/ def 'discoverAllSubmenus', 'ignoreRelatedSubmenu', 'suppressOverflowBehavior', + 'container', 'enableTopLayer', 'showChanged' ], [