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 - + + + + + + + + + + + + + + + + + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'layout-column layout-align-center-center layout-gap-s' + } +}) +export class DropdownSafeAreaExample { + /** + * Enabled by default so the effect is visible immediately — move the pointer diagonally from + * "Share" toward its submenu, crossing "Save" on the way, and the submenu stays open. Toggle off + * to compare against the default (no safe area) behavior. + */ + readonly safeArea = signal(true); +} diff --git a/packages/docs-examples/components/dropdown/index.ts b/packages/docs-examples/components/dropdown/index.ts index 14e80cab1..07995a188 100644 --- a/packages/docs-examples/components/dropdown/index.ts +++ b/packages/docs-examples/components/dropdown/index.ts @@ -7,6 +7,7 @@ import { DropdownNestedExample } from './dropdown-nested/dropdown-nested-example import { DropdownOpenByArrowDownExample } from './dropdown-open-by-arrow-down/dropdown-open-by-arrow-down-example'; import { DropdownOverviewExample } from './dropdown-overview/dropdown-overview-example'; import { DropdownRecursiveTemplateExample } from './dropdown-recursive-template/dropdown-recursive-template-example'; +import { DropdownSafeAreaExample } from './dropdown-safe-area/dropdown-safe-area-example'; import { DropdownWithFilterExample } from './dropdown-with-filter/dropdown-with-filter-example'; import { DropdownWithFooterExample } from './dropdown-with-footer/dropdown-with-footer-example'; import { DropdownXPositionExample } from './dropdown-x-position/dropdown-x-position-example'; @@ -20,6 +21,7 @@ export { DropdownOpenByArrowDownExample, DropdownOverviewExample, DropdownRecursiveTemplateExample, + DropdownSafeAreaExample, DropdownWithFilterExample, DropdownWithFooterExample, DropdownXPositionExample @@ -35,6 +37,7 @@ const EXAMPLES = [ DropdownLazyloadDataExample, DropdownOpenByArrowDownExample, DropdownRecursiveTemplateExample, + DropdownSafeAreaExample, DropdownWithFilterExample, DropdownXPositionExample ]; diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 59ec20226..d2c3952d5 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -1024,6 +1024,9 @@ export const getNodesWithoutComments: (nodes: NodeList) => Node[]; // @public export function getOptionScrollPosition(optionIndex: number, optionHeight: number, currentScrollPosition: number, panelHeight: number): number; +// @public +export function getSafeTriangleVertices(origin: KbqPoint, targetRect: DOMRect): KbqTriangle; + // @public export function getSelectAllState(adapter: KbqSelectAllAdapter): KbqPseudoCheckboxState; @@ -1115,6 +1118,12 @@ export function isNumberKey(input: KeyboardEvent): boolean; // @public (undocumented) export function isNumpadKey(input: KeyboardEvent): boolean; +// @public +export function isPointInRect(point: KbqPoint, rect: DOMRect): boolean; + +// @public +export function isPointInTriangle(point: KbqPoint, triangle: KbqTriangle): boolean; + // @public (undocumented) export function isRightBracket(event: KeyboardEvent): boolean; @@ -3372,6 +3381,9 @@ export interface KbqParentPopup { closedStream: Observable; } +// @public +export type KbqPoint = Pick; + // @public (undocumented) export abstract class KbqPopUp implements OnDestroy { // (undocumented) @@ -4038,6 +4050,16 @@ export interface KbqToggleSelectAllOptions { allowDeselect?: boolean; } +// @public +export interface KbqTriangle { + // (undocumented) + a: KbqPoint; + // (undocumented) + b: KbqPoint; + // (undocumented) + c: KbqPoint; +} + // @public (undocumented) export interface KbqUnitSystem { // (undocumented) diff --git a/tools/public_api_guard/components/dropdown.api.md b/tools/public_api_guard/components/dropdown.api.md index 1f59cae8b..e8844a1dd 100644 --- a/tools/public_api_guard/components/dropdown.api.md +++ b/tools/public_api_guard/components/dropdown.api.md @@ -25,6 +25,7 @@ import { KbqPanelWidth } from '@koobiq/components/core'; import { KbqPanelWidthOrigin } from '@koobiq/components/core'; import { KbqSiblingPopup } from '@koobiq/components/core'; import { KbqTitleTextRef } from '@koobiq/components/core'; +import { KbqTriangle } from '@koobiq/components/core'; import { Observable } from 'rxjs'; import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; @@ -71,6 +72,7 @@ export const KBQ_DROPDOWN_SCROLL_STRATEGY_FACTORY_PROVIDER: { // @public (undocumented) export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, OnDestroy { + activateSafeArea(owner: KbqDropdownItem, triangle: KbqTriangle, panelRect: DOMRect, onExit: () => void): void; animationDone: Subject; backdropClass: string; classList: { @@ -79,6 +81,7 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, // (undocumented) close(): void; readonly closed: EventEmitter; + deactivateSafeArea(): void; direction: Direction; focusFirstItem(origin?: FocusOrigin): void; handleKeydown(event: KeyboardEvent): void; @@ -87,6 +90,8 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, readonly hasSearch: i0.Signal; hovered(): Observable; isAnimating: boolean; + isSafeAreaActive(): boolean; + isSafeAreaOwner(item: KbqDropdownItem): boolean; items: QueryList; lazyContent: KbqDropdownContent; // (undocumented) @@ -100,6 +105,8 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, onAnimationDone(event: AnimationEvent_2): void; // (undocumented) onAnimationStart(event: AnimationEvent_2): void; + onPanelReached(): Observable; + onSwitchTarget(): Observable; get overlapTriggerX(): boolean; set overlapTriggerX(value: boolean); get overlapTriggerY(): boolean; @@ -113,6 +120,7 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, parent: KbqDropdownPanel | undefined; resetActiveItem(): void; resetAnimation(): void; + readonly safeArea: i0.InputSignalWithTransform; setPositionClasses(posX?: KbqDropdownPositionX, posY?: KbqDropdownPositionY): void; startAnimation(): void; templateRef: TemplateRef; @@ -123,7 +131,7 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, get yPosition(): KbqDropdownPositionY; set yPosition(value: KbqDropdownPositionY); // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -158,6 +166,7 @@ export interface KbqDropdownDefaultOptions { panelMaxWidth?: KbqPanelMaxWidth; panelMinWidth?: KbqPanelMinWidth; panelWidth?: KbqPanelWidth; + safeArea?: boolean; xPosition: KbqDropdownPositionX; yPosition: KbqDropdownPositionY; } @@ -289,6 +298,7 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy, KbqSibli handleClick(event: MouseEvent): void; handleKeydown(event: KeyboardEvent): void; handleMousedown(event: MouseEvent): void; + handleMouseLeave(event: MouseEvent): void; get isAttached(): boolean; // (undocumented) protected readonly isBrowser: boolean; @@ -321,6 +331,9 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy, KbqSibli static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export const NESTED_HOVER_SWITCH_DELAY = 50; + // @public (undocumented) export const NESTED_PANEL_LEFT_PADDING = 8;