diff --git a/packages/components-dev/dropdown/module.ts b/packages/components-dev/dropdown/module.ts
index d14cd0595..aab4f41fa 100644
--- a/packages/components-dev/dropdown/module.ts
+++ b/packages/components-dev/dropdown/module.ts
@@ -16,6 +16,7 @@ import {
DropdownOpenByArrowDownExample,
DropdownOverviewExample,
DropdownRecursiveTemplateExample,
+ DropdownSafeAreaExample,
DropdownWithFilterExample,
DropdownWithFooterExample,
DropdownXPositionExample
@@ -33,6 +34,7 @@ import { DevThemeToggle } from '../theme-toggle';
DropdownLazyloadDataExample,
DropdownOpenByArrowDownExample,
DropdownRecursiveTemplateExample,
+ DropdownSafeAreaExample,
DropdownWithFilterExample,
DropdownWithFooterExample,
DropdownXPositionExample
@@ -47,6 +49,9 @@ import { DevThemeToggle } from '../theme-toggle';
+
+
+
diff --git a/packages/components-dev/dropdown/template.html b/packages/components-dev/dropdown/template.html
index 61b9222b7..b9117e307 100644
--- a/packages/components-dev/dropdown/template.html
+++ b/packages/components-dev/dropdown/template.html
@@ -147,10 +147,10 @@
-
+
-
-
+
+
diff --git a/packages/components/core/overlay/safe-area.spec.ts b/packages/components/core/overlay/safe-area.spec.ts
new file mode 100644
index 000000000..4d6b0c0cb
--- /dev/null
+++ b/packages/components/core/overlay/safe-area.spec.ts
@@ -0,0 +1,74 @@
+import { KbqTriangle, getSafeTriangleVertices, isPointInRect, isPointInTriangle } from './safe-area';
+
+const rect = (left: number, top: number, right: number, bottom: number): DOMRect =>
+ ({ left, top, right, bottom, width: right - left, height: bottom - top, x: left, y: top }) as DOMRect;
+
+describe('isPointInRect', () => {
+ const target = rect(100, 100, 200, 200);
+
+ it('should be true for a point inside the rect', () => {
+ expect(isPointInRect({ x: 150, y: 150 }, target)).toBe(true);
+ });
+
+ it('should be true for a point exactly on an edge', () => {
+ expect(isPointInRect({ x: 100, y: 150 }, target)).toBe(true);
+ expect(isPointInRect({ x: 200, y: 150 }, target)).toBe(true);
+ });
+
+ it('should be false for a point outside the rect', () => {
+ expect(isPointInRect({ x: 50, y: 50 }, target)).toBe(false);
+ expect(isPointInRect({ x: 250, y: 150 }, target)).toBe(false);
+ });
+});
+
+describe('isPointInTriangle', () => {
+ const triangle: KbqTriangle = { a: { x: 0, y: 0 }, b: { x: 100, y: 0 }, c: { x: 0, y: 100 } };
+
+ it('should be true for a point inside the triangle', () => {
+ expect(isPointInTriangle({ x: 10, y: 10 }, triangle)).toBe(true);
+ });
+
+ it('should be true for a point exactly on an edge', () => {
+ expect(isPointInTriangle({ x: 50, y: 0 }, triangle)).toBe(true);
+ });
+
+ it('should be true for a vertex', () => {
+ expect(isPointInTriangle(triangle.a, triangle)).toBe(true);
+ });
+
+ it('should be false for a point outside the triangle', () => {
+ expect(isPointInTriangle({ x: 60, y: 60 }, triangle)).toBe(false);
+ expect(isPointInTriangle({ x: -10, y: -10 }, triangle)).toBe(false);
+ });
+});
+
+describe('getSafeTriangleVertices', () => {
+ it('should use the left edge when the submenu opens to the right of the origin', () => {
+ const origin = { x: 90, y: 50 };
+ const target = rect(100, 0, 300, 200);
+
+ expect(getSafeTriangleVertices(origin, target)).toEqual({
+ a: origin,
+ b: { x: 100, y: 0 },
+ c: { x: 100, y: 200 }
+ });
+ });
+
+ it('should use the right edge when the submenu opens to the left of the origin', () => {
+ const origin = { x: 310, y: 50 };
+ const target = rect(0, 0, 300, 200);
+
+ expect(getSafeTriangleVertices(origin, target)).toEqual({
+ a: origin,
+ b: { x: 300, y: 0 },
+ c: { x: 300, y: 200 }
+ });
+ });
+
+ it('should pick the nearer edge when the origin is directly above the panel', () => {
+ const target = rect(0, 100, 200, 300);
+
+ expect(getSafeTriangleVertices({ x: 190, y: 50 }, target).b).toEqual({ x: 200, y: 100 });
+ expect(getSafeTriangleVertices({ x: 10, y: 50 }, target).b).toEqual({ x: 0, y: 100 });
+ });
+});
diff --git a/packages/components/core/overlay/safe-area.ts b/packages/components/core/overlay/safe-area.ts
new file mode 100644
index 000000000..a79684089
--- /dev/null
+++ b/packages/components/core/overlay/safe-area.ts
@@ -0,0 +1,64 @@
+/**
+ * A simple (x, y) coordinate. Picked from the DOM's own `DOMPointReadOnly` rather than hand-rolled, so
+ * a plain `{ x, y }` literal (e.g. from a `MouseEvent`) satisfies it without constructing a `DOMPoint` —
+ * `DOMPoint` isn't implemented in every runtime (e.g. jsdom).
+ * @docs-private
+ */
+export type KbqPoint = Pick;
+
+/**
+ * A triangle described by its three vertices.
+ * @docs-private
+ */
+export interface KbqTriangle {
+ a: KbqPoint;
+ b: KbqPoint;
+ c: KbqPoint;
+}
+
+/**
+ * Whether `point` lies within (or on the edge of) `rect`.
+ * @docs-private
+ */
+export function isPointInRect(point: KbqPoint, rect: DOMRect): boolean {
+ return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom;
+}
+
+/**
+ * Whether `point` lies within (or on the edge of) `triangle`.
+ *
+ * Uses the sign of the cross product of each triangle edge with the point: the point is inside
+ * only if it's consistently on the same side of all three edges.
+ * @docs-private
+ */
+export function isPointInTriangle(point: KbqPoint, triangle: KbqTriangle): boolean {
+ const { a, b, c } = triangle;
+
+ const sign = (p1: KbqPoint, p2: KbqPoint, p3: KbqPoint): number =>
+ (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
+
+ const d1 = sign(point, a, b);
+ const d2 = sign(point, b, c);
+ const d3 = sign(point, c, a);
+
+ const hasNegative = d1 < 0 || d2 < 0 || d3 < 0;
+ const hasPositive = d1 > 0 || d2 > 0 || d3 > 0;
+
+ return !(hasNegative && hasPositive);
+}
+
+/**
+ * Builds the "safe triangle" connecting `origin` (typically the pointer position where it left a
+ * trigger) to the top and bottom corners of `targetRect` (typically a submenu panel) that are nearest
+ * to `origin` — the submenu can open on either side of its trigger, so the nearest edge is picked by
+ * comparing distances rather than assuming a fixed side.
+ * @docs-private
+ */
+export function getSafeTriangleVertices(origin: KbqPoint, targetRect: DOMRect): KbqTriangle {
+ const nearX =
+ Math.abs(targetRect.left - origin.x) <= Math.abs(targetRect.right - origin.x)
+ ? targetRect.left
+ : targetRect.right;
+
+ return { a: origin, b: { x: nearX, y: targetRect.top }, c: { x: nearX, y: targetRect.bottom } };
+}
diff --git a/packages/components/core/public-api.ts b/packages/components/core/public-api.ts
index 5a9db679a..6acbe394d 100644
--- a/packages/components/core/public-api.ts
+++ b/packages/components/core/public-api.ts
@@ -17,6 +17,7 @@ export * from './overlay/auto-hide-scroll-strategy';
export * from './overlay/overlay-position-map';
export * from './overlay/panel-height';
export * from './overlay/panel-width';
+export * from './overlay/safe-area';
export * from './overlay/shadow-dom-overlay-container';
export * from './pop-up/index';
export * from './select/index';
diff --git a/packages/components/dropdown/dropdown-trigger.directive.ts b/packages/components/dropdown/dropdown-trigger.directive.ts
index 29ca59fb3..7c3bc26a8 100644
--- a/packages/components/dropdown/dropdown-trigger.directive.ts
+++ b/packages/components/dropdown/dropdown-trigger.directive.ts
@@ -32,6 +32,7 @@ import {
defaultOffsetY,
DOWN_ARROW,
ENTER,
+ getSafeTriangleVertices,
kbqGetPanelWidthOrigin,
KbqPanelWidthOrigin,
KbqResolvedPanelWidth,
@@ -122,6 +123,7 @@ const positionMap = {
// attribute themselves.
'[attr.aria-expanded]': 'opened',
'(mousedown)': 'handleMousedown($event)',
+ '(mouseleave)': 'handleMouseLeave($event)',
'(keydown)': 'handleKeydown($event)',
'(click)': 'handleClick($event)'
},
@@ -372,6 +374,21 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy, KbqSibli
}
}
+ /**
+ * Starts safe-area protection when the pointer leaves a trigger whose submenu is open, so a
+ * sibling item crossed on the way to the submenu doesn't prematurely close it.
+ */
+ handleMouseLeave(event: MouseEvent): void {
+ if (!this.isNested() || !this._opened || !this.isBrowser || !this.parent.safeArea() || !this.overlayRef) {
+ return;
+ }
+
+ const panelRect = this.overlayRef.overlayElement.getBoundingClientRect();
+ const triangle = getSafeTriangleVertices({ x: event.clientX, y: event.clientY }, panelRect);
+
+ this.parent.activateSafeArea(this.dropdownItemInstance, triangle, panelRect, () => this.close());
+ }
+
/** Handles key presses on the trigger. */
handleKeydown(event: KeyboardEvent) {
const keyCode = event.keyCode;
@@ -429,6 +446,10 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy, KbqSibli
this.lastDestroyReason = reason;
+ if (this.isNested()) {
+ this.parent.deactivateSafeArea();
+ }
+
this.dropdown.resetActiveItem();
this.closeSubscription.unsubscribe();
@@ -671,7 +692,13 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy, KbqSibli
const hover = this.parent
? this.parent.hovered().pipe(
filter((active) => active !== this.dropdownItemInstance),
- filter(() => this._opened)
+ filter(() => this._opened),
+ // While a safe area protects this dropdown, closing is driven by the safe area
+ // itself instead: either it resolves on its own (see `handleMouseLeave()`), or a
+ // forced switch to a different nested trigger closes this one via `onExit` — always
+ // before the new trigger opens, so the two don't race over animation state shared by
+ // triggers pointing at the same dropdown (see `KbqDropdown.activateSafeArea()`).
+ filter(() => !this.parent.isSafeAreaActive())
)
: observableOf();
@@ -691,16 +718,27 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy, KbqSibli
return;
}
- this.hoverSubscription = this.parent
- .hovered()
+ this.hoverSubscription = merge(
+ this.parent.hovered().pipe(
+ filter((active) => active === this.dropdownItemInstance && !active.disabled),
+ // While a *different* trigger's safe area is active, opening is deferred to
+ // `onSwitchTarget()` below, which only fires once that trigger has actually closed.
+ // Hovering the trigger that owns the active safe area (coming back to it) still
+ // passes through immediately — `open()` is a no-op there, but it lets the safe area be
+ // cancelled below.
+ filter(() => !this.parent.isSafeAreaActive() || this.parent.isSafeAreaOwner(this.dropdownItemInstance))
+ ),
+ this.parent.onSwitchTarget().pipe(filter((active) => active === this.dropdownItemInstance))
+ )
// Since we might have multiple competing triggers for the same dropdown (e.g. a nested dropdown
// with different data and triggers), we have to delay it by a tick to ensure that
// it won't be closed immediately after it is opened.
- .pipe(
- filter((active) => active === this.dropdownItemInstance && !active.disabled),
- delay(0, asapScheduler)
- )
+ .pipe(delay(0, asapScheduler))
.subscribe(() => {
+ // Coming back to this trigger cancels any safe-area protection left over from a
+ // previous `mouseleave`.
+ this.parent.deactivateSafeArea();
+
this.openedBy = 'mouse';
// If the same dropdown is used between multiple triggers, it might still be animating
diff --git a/packages/components/dropdown/dropdown.component.ts b/packages/components/dropdown/dropdown.component.ts
index 6c9d36ead..7e656be1c 100644
--- a/packages/components/dropdown/dropdown.component.ts
+++ b/packages/components/dropdown/dropdown.component.ts
@@ -3,6 +3,8 @@ import { FocusOrigin } from '@angular/cdk/a11y';
import { Direction } from '@angular/cdk/bidi';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { DOWN_ARROW, UP_ARROW } from '@angular/cdk/keycodes';
+import { normalizePassiveListenerOptions } from '@angular/cdk/platform';
+import { DOCUMENT } from '@angular/common';
import {
AfterContentInit,
ChangeDetectionStrategy,
@@ -21,6 +23,7 @@ import {
TemplateRef,
ViewChild,
ViewEncapsulation,
+ booleanAttribute,
computed,
contentChild,
inject,
@@ -34,12 +37,16 @@ import {
KbqPanelMaxWidth,
KbqPanelMinWidth,
KbqPanelWidth,
+ KbqPoint,
+ KbqTriangle,
LEFT_ARROW,
- RIGHT_ARROW
+ RIGHT_ARROW,
+ isPointInRect,
+ isPointInTriangle
} from '@koobiq/components/core';
import { KbqFormField } from '@koobiq/components/form-field';
-import { Observable, Subject, Subscription, merge } from 'rxjs';
-import { startWith, switchMap, take } from 'rxjs/operators';
+import { Observable, Subject, Subscription, merge, timer } from 'rxjs';
+import { filter, map, startWith, switchMap, take, takeUntil } from 'rxjs/operators';
import { kbqDropdownAnimations } from './dropdown-animations';
import { KbqDropdownContent } from './dropdown-content.directive';
import { throwKbqDropdownInvalidPositionX, throwKbqDropdownInvalidPositionY } from './dropdown-errors';
@@ -53,6 +60,16 @@ import {
KbqDropdownPositionY
} from './dropdown.types';
+/** Options for binding a passive event listener. */
+const passiveEventListenerOptions = normalizePassiveListenerOptions({ passive: true }) as EventListenerOptions;
+
+/**
+ * Grace period before switching to a different nested trigger hovered while a safe area is
+ * protecting the currently open submenu. Without it, sweeping the pointer down a long list of
+ * nested triggers on the way to the submenu would flicker each row's submenu open and closed.
+ */
+export const NESTED_HOVER_SWITCH_DELAY = 50;
+
@Directive({
selector: '[kbqDropdownStaticContent]'
})
@@ -89,6 +106,7 @@ export class KbqDropdownFooter {}
export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, OnDestroy {
private elementRef = inject>(ElementRef);
private ngZone = inject(NgZone);
+ private document = inject(DOCUMENT);
private defaultOptions = inject(KBQ_DROPDOWN_DEFAULT_OPTIONS);
private readonly search = contentChild(KbqFormField);
@@ -259,6 +277,13 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit,
{ transform: numberAttribute }
);
+ /**
+ * Whether nested dropdowns opened from this dropdown's items use a "safe area": while the
+ * pointer moves from a trigger toward its open submenu, sibling items it crosses over on the way
+ * don't prematurely close the submenu.
+ */
+ readonly safeArea = input(this.defaultOptions.safeArea ?? true, { transform: booleanAttribute });
+
/**
* `panelMinWidth` rendered as a CSS length for the `--kbq-dropdown-size-container-width-min`
* token, so the panel's CSS `min-width` floor tracks the input — mirroring how `panelMaxWidth`
@@ -298,6 +323,28 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit,
/** Subscription to tab events on the dropdown panel */
private tabSubscription = Subscription.EMPTY;
+ /** Cleans up the safe-area `mousemove` listener. `null` when no safe area is being tracked. */
+ private safeAreaCleanup: (() => void) | null = null;
+
+ /** The nested trigger currently protected by the active safe area, if any. */
+ private safeAreaOwner: KbqDropdownItem | null = null;
+
+ /**
+ * The most recently hovered item, tracked independently of the safe area so that leaving it
+ * straight onto a sibling's nested trigger can switch to it immediately instead of waiting for a
+ * `mouseenter` that already happened.
+ */
+ private currentHovered: KbqDropdownItem | null = null;
+
+ /** Emits when the pointer reaches the panel the active safe area protects. */
+ private readonly panelReached = new Subject();
+
+ /** Emits the sibling trigger that should open once a forced switch has closed the current one. */
+ private readonly switchTarget = new Subject();
+
+ /** Watches for a forced switch to a sibling trigger while a safe area is active. */
+ private switchTargetSubscription = Subscription.EMPTY;
+
ngOnInit() {
this.setPositionClasses();
}
@@ -324,12 +371,18 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit,
.subscribe((focusedItem) => this.keyManager.updateActiveItem(focusedItem as KbqDropdownItem));
this.search()?.inOverlay.set(true);
+
+ // Internal and completes with the items on destroy, so no explicit unsubscribe is needed.
+ this.hovered().subscribe((item) => (this.currentHovered = item));
}
ngOnDestroy() {
this.directDescendantItems.destroy();
this.tabSubscription.unsubscribe();
this.closed.complete();
+ this.deactivateSafeArea();
+ this.panelReached.complete();
+ this.switchTarget.complete();
}
/** Stream that emits whenever the hovered dropdown item changes. */
@@ -342,6 +395,115 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit,
) as Observable;
}
+ /**
+ * Tracks the pointer against `triangle` to protect `owner`'s open submenu, closing it via
+ * `onExit` if the pointer leaves the triangle without first reaching `panelRect`. Meanwhile, if a
+ * different nested trigger is hovered, `switchTarget()` emits it — immediately if it's outside
+ * the triangle, otherwise after a grace period (see `NESTED_HOVER_SWITCH_DELAY`). Replaces any
+ * safe area already being tracked.
+ * @docs-private
+ */
+ activateSafeArea(owner: KbqDropdownItem, triangle: KbqTriangle, panelRect: DOMRect, onExit: () => void): void {
+ this.deactivateSafeArea();
+ this.safeAreaOwner = owner;
+
+ this.safeAreaCleanup = this.ngZone.runOutsideAngular(() => {
+ const listener = (event: MouseEvent) => {
+ const point: KbqPoint = { x: event.clientX, y: event.clientY };
+
+ if (isPointInRect(point, panelRect)) {
+ this.panelReached.next();
+ this.deactivateSafeArea();
+
+ return;
+ }
+
+ if (!isPointInTriangle(point, triangle)) {
+ const switchTo =
+ this.currentHovered?.isNested && this.currentHovered !== owner && !this.currentHovered.disabled
+ ? this.currentHovered
+ : null;
+
+ this.deactivateSafeArea();
+
+ this.ngZone.run(() => {
+ onExit();
+
+ if (switchTo) {
+ this.switchTarget.next(switchTo);
+ }
+ });
+ }
+ };
+
+ this.document.addEventListener('mousemove', listener, passiveEventListenerOptions);
+
+ return () => this.document.removeEventListener('mousemove', listener, passiveEventListenerOptions);
+ });
+
+ this.switchTargetSubscription = this.hovered()
+ .pipe(
+ filter((active) => active.isNested && active !== owner && !active.disabled),
+ switchMap((active) =>
+ timer(NESTED_HOVER_SWITCH_DELAY).pipe(
+ map(() => active),
+ takeUntil(this.panelReached)
+ )
+ )
+ )
+ .subscribe((active) => {
+ this.deactivateSafeArea();
+ onExit();
+ this.switchTarget.next(active);
+ });
+ }
+
+ /**
+ * Stops tracking the current safe area, if any.
+ * @docs-private
+ */
+ deactivateSafeArea(): void {
+ this.safeAreaCleanup?.();
+ this.safeAreaCleanup = null;
+ this.safeAreaOwner = null;
+ this.switchTargetSubscription.unsubscribe();
+ this.switchTargetSubscription = Subscription.EMPTY;
+ }
+
+ /**
+ * Whether a safe area is currently being tracked.
+ * @docs-private
+ */
+ isSafeAreaActive(): boolean {
+ return this.safeAreaCleanup !== null;
+ }
+
+ /**
+ * Whether `item` is the nested trigger currently protected by an active safe area.
+ * @docs-private
+ */
+ isSafeAreaOwner(item: KbqDropdownItem): boolean {
+ return this.safeAreaOwner === item;
+ }
+
+ /**
+ * Stream that emits when the pointer reaches the panel the active safe area protects (as opposed
+ * to leaving the safe area, which closes the panel instead).
+ * @docs-private
+ */
+ onPanelReached(): Observable {
+ return this.panelReached.asObservable();
+ }
+
+ /**
+ * Stream that emits the sibling trigger that should open once a forced safe-area switch has
+ * closed the trigger it was protecting.
+ * @docs-private
+ */
+ onSwitchTarget(): Observable {
+ return this.switchTarget.asObservable();
+ }
+
/** Handle a keyboard event from the dropdown, delegating to the appropriate action. */
handleKeydown(event: KeyboardEvent) {
const keyCode = event.keyCode;
diff --git a/packages/components/dropdown/dropdown.en.md b/packages/components/dropdown/dropdown.en.md
index fadb4b77b..b4fc8ff27 100644
--- a/packages/components/dropdown/dropdown.en.md
+++ b/packages/components/dropdown/dropdown.en.md
@@ -28,6 +28,17 @@ You can place auxiliary elements in the footer: [buttons](en/components/button),
+### Safe Area
+
+This mechanism prevents an open nested submenu from closing prematurely while the pointer is moving.
+The submenu stays open even if the pointer touches sibling items along the way, as long as the
+movement stays within the designated area. It can be configured in two ways:
+
+- locally — for a specific nested dropdown through the `safeArea` property;
+- globally — through `KBQ_DROPDOWN_DEFAULT_OPTIONS`.
+
+
+
### Navigation Wrap
A "cyclic navigation" mode where reaching one end of the list loops back to the other end.
diff --git a/packages/components/dropdown/dropdown.ru.md b/packages/components/dropdown/dropdown.ru.md
index 6f7ed0a32..629987e06 100644
--- a/packages/components/dropdown/dropdown.ru.md
+++ b/packages/components/dropdown/dropdown.ru.md
@@ -28,6 +28,17 @@
+### Безопасная зона
+
+Механизм предотвращает преждевременное закрытие вложенного меню при движении указателя мыши.
+Меню остаётся открытым, даже если указатель задевает соседние элементы — при условии, что движение происходит в пределах отведённой области.
+Настроить можно двумя способами:
+
+- локально — для конкретного вложенного меню с помощью свойства `safeArea`;
+- глобально — через `KBQ_DROPDOWN_DEFAULT_OPTIONS`.
+
+
+
### Циклическая навигация
Режим "циклической навигации" по списку,
diff --git a/packages/components/dropdown/dropdown.spec.ts b/packages/components/dropdown/dropdown.spec.ts
index d6807e925..ef039b77a 100644
--- a/packages/components/dropdown/dropdown.spec.ts
+++ b/packages/components/dropdown/dropdown.spec.ts
@@ -54,7 +54,8 @@ import {
KbqDropdownPanel,
KbqDropdownPositionX,
KbqDropdownPositionY,
- KbqDropdownTrigger
+ KbqDropdownTrigger,
+ NESTED_HOVER_SWITCH_DELAY
} from './index';
const PANEL_SELECTOR = '.kbq-dropdown__panel';
@@ -1404,6 +1405,265 @@ describe('KbqDropdown', () => {
expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(1);
}));
+ describe('safe area', () => {
+ /**
+ * Opens the level-one nested dropdown with `safeArea` enabled and mocks its overlay
+ * pane's rect to a fixed, predictable box (jsdom otherwise reports an all-zero rect).
+ */
+ const openLevelOneWithSafeArea = (): HTMLElement => {
+ compileTestComponent();
+ instance.safeAreaEnabled = true;
+ fixture.detectChanges();
+
+ instance.rootTriggerEl().nativeElement.click();
+ fixture.detectChanges();
+
+ const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseenter');
+ fixture.detectChanges();
+ tick();
+ fixture.detectChanges();
+
+ const overlayPanes = overlay.querySelectorAll('.cdk-overlay-pane');
+ const nestedPane = overlayPanes[overlayPanes.length - 1] as HTMLElement;
+
+ jest.spyOn(nestedPane, 'getBoundingClientRect').mockReturnValue({
+ left: 300,
+ right: 500,
+ top: 50,
+ bottom: 250,
+ width: 200,
+ height: 200,
+ x: 300,
+ y: 50,
+ toJSON: () => ({})
+ } as DOMRect);
+
+ return levelOneTrigger;
+ };
+
+ it('should close immediately on a sibling hover when disabled (default)', fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTriggerEl().nativeElement.click();
+ fixture.detectChanges();
+
+ const items = Array.from(overlay.querySelectorAll(`${PANEL_SELECTOR} ${ITEM_SELECTOR}`));
+ const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseenter');
+ fixture.detectChanges();
+ tick();
+
+ expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(2);
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseleave', 100, 100);
+ dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter');
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(1);
+ }));
+
+ it('should keep the submenu open while a sibling crossed en route is hovered', fakeAsync(() => {
+ const levelOneTrigger = openLevelOneWithSafeArea();
+ const items = Array.from(overlay.querySelectorAll(`${PANEL_SELECTOR} ${ITEM_SELECTOR}`));
+
+ // Leaves roughly level with the panel's top, heading toward it.
+ dispatchMouseEvent(levelOneTrigger, 'mouseleave', 100, 100);
+ fixture.detectChanges();
+
+ // Crossing the next sibling row on the way to the submenu no longer closes it.
+ dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter');
+ fixture.detectChanges();
+ tick();
+
+ expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(2);
+
+ // Still heading toward the submenu (inside the triangle formed by the leave point and
+ // the panel's near-top/near-bottom corners at x=300).
+ dispatchMouseEvent(document, 'mousemove', 200, 125);
+ fixture.detectChanges();
+ tick();
+
+ expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(2);
+ }));
+
+ it('should close once the pointer leaves the safe area', fakeAsync(() => {
+ const levelOneTrigger = openLevelOneWithSafeArea();
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseleave', 100, 100);
+ fixture.detectChanges();
+
+ // Well outside the triangle — the user gave up on the submenu.
+ dispatchMouseEvent(document, 'mousemove', 150, 400);
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(1);
+ }));
+
+ it('should keep the submenu open and stop tracking once the pointer reaches the panel', fakeAsync(() => {
+ const levelOneTrigger = openLevelOneWithSafeArea();
+ const items = Array.from(overlay.querySelectorAll(`${PANEL_SELECTOR} ${ITEM_SELECTOR}`));
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseleave', 100, 100);
+ fixture.detectChanges();
+
+ dispatchMouseEvent(document, 'mousemove', 400, 150);
+ fixture.detectChanges();
+ tick();
+
+ expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(2);
+
+ // Tracking stopped once the pointer reached the panel, so a sibling hover now closes
+ // the submenu immediately again, same as when the triangle was never activated.
+ dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter');
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(1);
+ }));
+
+ it('should not switch to another nested trigger before the grace period elapses', fakeAsync(() => {
+ const levelOneTrigger = openLevelOneWithSafeArea();
+
+ instance.showLazy = true;
+ fixture.detectChanges();
+
+ const lazyTrigger = overlay.querySelector('#lazy-trigger')! as HTMLElement;
+
+ // Leaves heading toward level-one's submenu, arming its triangle, but lands directly
+ // on another nested trigger while still well short of the grace period.
+ dispatchMouseEvent(levelOneTrigger, 'mouseleave', 100, 100);
+ dispatchMouseEvent(lazyTrigger, 'mouseenter');
+ fixture.detectChanges();
+ tick(NESTED_HOVER_SWITCH_DELAY - 10);
+ fixture.detectChanges();
+
+ expect(instance.levelOneTrigger().opened).toBe(true);
+ expect(instance.lazyTrigger().opened).toBe(false);
+ }));
+
+ it('should switch to another nested trigger once the grace period elapses without the pointer reaching the panel', fakeAsync(() => {
+ const levelOneTrigger = openLevelOneWithSafeArea();
+
+ instance.showLazy = true;
+ fixture.detectChanges();
+
+ const lazyTrigger = overlay.querySelector('#lazy-trigger')! as HTMLElement;
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseleave', 100, 100);
+ dispatchMouseEvent(lazyTrigger, 'mouseenter');
+ fixture.detectChanges();
+ tick(NESTED_HOVER_SWITCH_DELAY);
+ fixture.detectChanges();
+
+ expect(instance.levelOneTrigger().opened).toBe(false);
+ expect(instance.lazyTrigger().opened).toBe(true);
+ expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(2);
+ }));
+
+ it('should not switch to another nested trigger if the pointer reaches the panel within the grace period', fakeAsync(() => {
+ const levelOneTrigger = openLevelOneWithSafeArea();
+
+ instance.showLazy = true;
+ fixture.detectChanges();
+
+ const lazyTrigger = overlay.querySelector('#lazy-trigger')! as HTMLElement;
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseleave', 100, 100);
+ dispatchMouseEvent(lazyTrigger, 'mouseenter');
+ fixture.detectChanges();
+
+ // The pointer actually reaches the protected panel before the grace period elapses —
+ // the hover on the other trigger was just a graze, so the switch must not happen.
+ dispatchMouseEvent(document, 'mousemove', 400, 150);
+ fixture.detectChanges();
+ tick(500);
+ fixture.detectChanges();
+
+ expect(instance.levelOneTrigger().opened).toBe(true);
+ expect(instance.lazyTrigger().opened).toBe(false);
+ expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(2);
+ }));
+
+ it('should switch to another nested trigger immediately when it lies outside the safe area', fakeAsync(() => {
+ const levelOneTrigger = openLevelOneWithSafeArea();
+
+ instance.showLazy = true;
+ fixture.detectChanges();
+
+ const lazyTrigger = overlay.querySelector('#lazy-trigger')! as HTMLElement;
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseleave', 100, 100);
+ dispatchMouseEvent(lazyTrigger, 'mouseenter');
+ fixture.detectChanges();
+
+ // The pointer lands on the other trigger well outside the triangle — a real intent
+ // change, not a graze — so the switch must not wait for the grace period.
+ dispatchMouseEvent(document, 'mousemove', 150, 400);
+ fixture.detectChanges();
+ tick();
+ fixture.detectChanges();
+
+ expect(instance.levelOneTrigger().opened).toBe(false);
+ expect(instance.lazyTrigger().opened).toBe(true);
+ expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(2);
+ }));
+
+ it('should not switch to a disabled nested trigger hovered outside the safe area', fakeAsync(() => {
+ const levelOneTrigger = openLevelOneWithSafeArea();
+
+ instance.showLazy = true;
+ fixture.detectChanges();
+
+ const lazyTriggerItem = fixture.debugElement
+ .queryAll(By.directive(KbqDropdownItem))
+ .find((item) => item.nativeElement.id === 'lazy-trigger')!;
+
+ lazyTriggerItem.componentInstance.disabled = true;
+ fixture.detectChanges();
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseleave', 100, 100);
+ // Invoke the handler directly since the fake events are flaky on disabled elements.
+ lazyTriggerItem.componentInstance.handleMouseEnter();
+ fixture.detectChanges();
+
+ dispatchMouseEvent(document, 'mousemove', 150, 400);
+ fixture.detectChanges();
+ tick(500);
+ fixture.detectChanges();
+
+ expect(instance.levelOneTrigger().opened).toBe(false);
+ expect(instance.lazyTrigger().opened).toBe(false);
+ }));
+
+ it('should not switch to a disabled nested trigger grazed inside the safe area', fakeAsync(() => {
+ const levelOneTrigger = openLevelOneWithSafeArea();
+
+ instance.showLazy = true;
+ fixture.detectChanges();
+
+ const lazyTriggerItem = fixture.debugElement
+ .queryAll(By.directive(KbqDropdownItem))
+ .find((item) => item.nativeElement.id === 'lazy-trigger')!;
+
+ lazyTriggerItem.componentInstance.disabled = true;
+ fixture.detectChanges();
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseleave', 100, 100);
+ // Invoke the handler directly since the fake events are flaky on disabled elements.
+ lazyTriggerItem.componentInstance.handleMouseEnter();
+ fixture.detectChanges();
+ tick(NESTED_HOVER_SWITCH_DELAY);
+ fixture.detectChanges();
+
+ expect(instance.levelOneTrigger().opened).toBe(true);
+ expect(instance.lazyTrigger().opened).toBe(false);
+ }));
+ });
+
it('should open and close a nested dropdown with arrow keys in ltr', fakeAsync(() => {
compileTestComponent();
instance.rootTriggerEl().nativeElement.click();
@@ -2097,6 +2357,21 @@ describe('KbqDropdown default overrides', () => {
});
});
+describe('KbqDropdown safe area default override', () => {
+ it('should honor a `safeArea: true` default without setting the input explicitly', () => {
+ TestBed.configureTestingModule({
+ imports: [KbqDropdownModule, NoopAnimationsModule, SimpleDropdown],
+ providers: [{ provide: KBQ_DROPDOWN_DEFAULT_OPTIONS, useValue: { safeArea: true } }]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(SimpleDropdown);
+
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.dropdown().safeArea()).toBe(true);
+ });
+});
+
@Component({
imports: [KbqDropdownModule],
template: `
@@ -2279,7 +2554,12 @@ class CustomDropdown {
Toggle alternate dropdown
-
+