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'
], [