Skip to content
4 changes: 2 additions & 2 deletions packages/angular/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,15 +817,15 @@ 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({
selector: 'ix-dropdown',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// 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
})
Expand Down
4 changes: 2 additions & 2 deletions packages/angular/standalone/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,15 +918,15 @@ 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({
selector: 'ix-dropdown',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '<ng-content></ng-content>',
// 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 {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/components/dropdown/dropdown-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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()];
Expand Down
4 changes: 1 addition & 3 deletions packages/core/src/components/dropdown/dropdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ $dropdown-offset: 3rem;
:host(.overflow) {
max-height: calc(50vh - $dropdown-offset);
overflow-y: auto;
overscroll-behavior: contain;
}

:host(:not(.show)) {
Expand All @@ -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;

*,
Expand Down
160 changes: 149 additions & 11 deletions packages/core/src/components/dropdown/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
inline,
offset,
shift,
limitShift,
} from '@floating-ui/dom';
import {
Component,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -206,6 +213,7 @@ export class Dropdown implements ComponentInterface, DropdownInterface {
this.autoUpdateCleanup();
this.autoUpdateCleanup = undefined;
}
this.removeVisibilityListeners();
}

getAssignedSubmenuIds() {
Expand Down Expand Up @@ -363,6 +371,30 @@ export class Dropdown implements ComponentInterface, DropdownInterface {
}
}

private autoCloseTimeout?: ReturnType<typeof setTimeout>;
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) {
Expand All @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -477,20 +588,20 @@ 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();
if (!dialog) {
return;
}
targetElement = dialog;
strategy = 'fixed';
}

let positionConfig: Partial<ComputePositionConfig> = {
strategy,
strategy: useAbsolute ? 'absolute' : this.positioningStrategy,
middleware: [],
};

Expand All @@ -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) {
Expand All @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/components/dropdown/test/dropdown.ct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
<ix-button id="trigger">Open</ix-button>
<ix-dropdown id="dropdown" trigger="trigger">
<ix-dropdown-item label="Item 1"></ix-dropdown-item>
</ix-dropdown>
`);
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();
}
);
1 change: 1 addition & 0 deletions packages/react/src/components.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,7 @@ export const IxDropdown: StencilReactComponent<IxDropdownElement, IxDropdownEven
discoverAllSubmenus: 'discover-all-submenus',
ignoreRelatedSubmenu: 'ignore-related-submenu',
suppressOverflowBehavior: 'suppress-overflow-behavior',
container: 'container',
enableTopLayer: 'enable-top-layer'
},
hydrateModule: import('@siemens/ix/hydrate') as Promise<HydrateModule>,
Expand Down
1 change: 1 addition & 0 deletions packages/vue/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ export const IxDropdown: StencilVueComponent<JSX.IxDropdown> = /*@__PURE__*/ def
'discoverAllSubmenus',
'ignoreRelatedSubmenu',
'suppressOverflowBehavior',
'container',
'enableTopLayer',
'showChanged'
], [
Expand Down
Loading