From 8b71b23ca94ad9f75bf149db53cb4d639cbed18d Mon Sep 17 00:00:00 2001 From: Nikita Guryev Date: Mon, 17 Aug 2026 17:05:54 +0300 Subject: [PATCH] feat(dropdown): added safe triangle for submenu (#DS-2446) --- packages/components-dev/dropdown/module.ts | 5 + .../core/overlay/safe-triangle.spec.ts | 74 +++++++++ .../components/core/overlay/safe-triangle.ts | 64 ++++++++ packages/components/core/public-api.ts | 1 + .../dropdown/dropdown-trigger.directive.ts | 34 +++- .../components/dropdown/dropdown.component.ts | 73 ++++++++- packages/components/dropdown/dropdown.en.md | 11 ++ packages/components/dropdown/dropdown.ru.md | 11 ++ packages/components/dropdown/dropdown.spec.ts | 145 +++++++++++++++++- .../components/dropdown/dropdown.types.ts | 8 + .../dropdown-safe-triangle-example.ts | 54 +++++++ .../components/dropdown/index.ts | 3 + tools/public_api_guard/components/core.api.md | 22 +++ .../components/dropdown.api.md | 9 +- 14 files changed, 510 insertions(+), 4 deletions(-) create mode 100644 packages/components/core/overlay/safe-triangle.spec.ts create mode 100644 packages/components/core/overlay/safe-triangle.ts create mode 100644 packages/docs-examples/components/dropdown/dropdown-safe-triangle/dropdown-safe-triangle-example.ts diff --git a/packages/components-dev/dropdown/module.ts b/packages/components-dev/dropdown/module.ts index d14cd0595f..24f3912111 100644 --- a/packages/components-dev/dropdown/module.ts +++ b/packages/components-dev/dropdown/module.ts @@ -16,6 +16,7 @@ import { DropdownOpenByArrowDownExample, DropdownOverviewExample, DropdownRecursiveTemplateExample, + DropdownSafeTriangleExample, DropdownWithFilterExample, DropdownWithFooterExample, DropdownXPositionExample @@ -33,6 +34,7 @@ import { DevThemeToggle } from '../theme-toggle'; DropdownLazyloadDataExample, DropdownOpenByArrowDownExample, DropdownRecursiveTemplateExample, + DropdownSafeTriangleExample, DropdownWithFilterExample, DropdownWithFooterExample, DropdownXPositionExample @@ -47,6 +49,9 @@ import { DevThemeToggle } from '../theme-toggle';
+ +
+
diff --git a/packages/components/core/overlay/safe-triangle.spec.ts b/packages/components/core/overlay/safe-triangle.spec.ts new file mode 100644 index 0000000000..9b79ac3936 --- /dev/null +++ b/packages/components/core/overlay/safe-triangle.spec.ts @@ -0,0 +1,74 @@ +import { KbqTriangle, getSafeTriangleVertices, isPointInRect, isPointInTriangle } from './safe-triangle'; + +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-triangle.ts b/packages/components/core/overlay/safe-triangle.ts new file mode 100644 index 0000000000..a79684089a --- /dev/null +++ b/packages/components/core/overlay/safe-triangle.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 5a9db679ad..64d3ac5272 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-triangle'; 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 29ca59fb34..0df079acc0 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-triangle 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.safeTriangle() || !this.overlayRef) { + return; + } + + const panelRect = this.overlayRef.overlayElement.getBoundingClientRect(); + const triangle = getSafeTriangleVertices({ x: event.clientX, y: event.clientY }, panelRect); + + this.parent.activateSafeTriangle(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.deactivateSafeTriangle(); + } + this.dropdown.resetActiveItem(); this.closeSubscription.unsubscribe(); @@ -671,7 +692,10 @@ 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), + // A protected safe triangle handles closing itself once the pointer actually + // leaves it — see `handleMouseLeave()`. + filter(() => !this.parent.isSafeTriangleActive()) ) : observableOf(); @@ -698,9 +722,17 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy, KbqSibli // it won't be closed immediately after it is opened. .pipe( filter((active) => active === this.dropdownItemInstance && !active.disabled), + // Suppress opening while a sibling's safe triangle is being protected — otherwise this + // dropdown could open before the protected one has had a chance to close, leaving two + // submenus open at once. + filter(() => !this.parent.isSafeTriangleActive()), delay(0, asapScheduler) ) .subscribe(() => { + // Coming back to this trigger cancels any safe-triangle protection left over from a + // previous `mouseleave`. + this.parent.deactivateSafeTriangle(); + 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 6c9d36ead9..5199784bc9 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,8 +37,12 @@ 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'; @@ -53,6 +60,9 @@ import { KbqDropdownPositionY } from './dropdown.types'; +/** Options for binding a passive event listener. */ +const passiveEventListenerOptions = normalizePassiveListenerOptions({ passive: true }) as EventListenerOptions; + @Directive({ selector: '[kbqDropdownStaticContent]' }) @@ -89,6 +99,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 +270,13 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, { transform: numberAttribute } ); + /** + * Whether nested dropdowns opened from this dropdown's items use a "safe triangle": 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 safeTriangle = input(this.defaultOptions.safeTriangle ?? false, { 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 +316,9 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, /** Subscription to tab events on the dropdown panel */ private tabSubscription = Subscription.EMPTY; + /** Cleans up the safe-triangle `mousemove` listener. `null` when no triangle is being tracked. */ + private safeTriangleCleanup: (() => void) | null = null; + ngOnInit() { this.setPositionClasses(); } @@ -330,6 +351,7 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, this.directDescendantItems.destroy(); this.tabSubscription.unsubscribe(); this.closed.complete(); + this.deactivateSafeTriangle(); } /** Stream that emits whenever the hovered dropdown item changes. */ @@ -342,6 +364,55 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, ) as Observable; } + /** + * Starts tracking the pointer against `triangle` (see `getSafeTriangleVertices`). While the + * pointer stays inside the triangle, the submenu it protects is kept open; `onExit` runs once the + * pointer either leaves the triangle or, failing that protection, lands inside `panelRect` (the + * submenu itself, which needs no further tracking). Replaces any triangle already being tracked. + * @docs-private + */ + activateSafeTriangle(triangle: KbqTriangle, panelRect: DOMRect, onExit: () => void): void { + this.deactivateSafeTriangle(); + + this.safeTriangleCleanup = this.ngZone.runOutsideAngular(() => { + const listener = (event: MouseEvent) => { + const point: KbqPoint = { x: event.clientX, y: event.clientY }; + + if (isPointInRect(point, panelRect)) { + this.deactivateSafeTriangle(); + + return; + } + + if (!isPointInTriangle(point, triangle)) { + this.deactivateSafeTriangle(); + this.ngZone.run(onExit); + } + }; + + this.document.addEventListener('mousemove', listener, passiveEventListenerOptions); + + return () => this.document.removeEventListener('mousemove', listener, passiveEventListenerOptions); + }); + } + + /** + * Stops tracking the current safe triangle, if any. + * @docs-private + */ + deactivateSafeTriangle(): void { + this.safeTriangleCleanup?.(); + this.safeTriangleCleanup = null; + } + + /** + * Whether a safe triangle is currently being tracked. + * @docs-private + */ + isSafeTriangleActive(): boolean { + return this.safeTriangleCleanup !== null; + } + /** 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 fadb4b77be..c2d20ba89d 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 `safeTriangle` 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 6f7ed0a322..246db9ddb0 100644 --- a/packages/components/dropdown/dropdown.ru.md +++ b/packages/components/dropdown/dropdown.ru.md @@ -28,6 +28,17 @@ +### Безопасная зона + +Механизм предотвращает преждевременное закрытие вложенного меню при движения указателя мыши. +Меню остаётся открытым, даже если указатель задевает соседние элементы — при условии, что движение происходит в пределах отведённой области. +Настроить можно двумя способами: + +- локально — для конкретного вложенного меню с помощью свойства `safeTriangle`; +- глобально — через `KBQ_DROPDOWN_DEFAULT_OPTIONS`. + + + ### Циклическая навигация Режим "циклической навигации" по списку, diff --git a/packages/components/dropdown/dropdown.spec.ts b/packages/components/dropdown/dropdown.spec.ts index d6807e9255..65270176cd 100644 --- a/packages/components/dropdown/dropdown.spec.ts +++ b/packages/components/dropdown/dropdown.spec.ts @@ -1404,6 +1404,127 @@ describe('KbqDropdown', () => { expect(overlay.querySelectorAll(PANEL_SELECTOR).length).toBe(1); })); + describe('safe triangle', () => { + /** + * Opens the level-one nested dropdown with `safeTriangle` enabled and mocks its overlay + * pane's rect to a fixed, predictable box (jsdom otherwise reports an all-zero rect). + */ + const openLevelOneWithSafeTriangle = (): HTMLElement => { + compileTestComponent(); + instance.safeTriangleEnabled = 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 = openLevelOneWithSafeTriangle(); + 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 triangle', fakeAsync(() => { + const levelOneTrigger = openLevelOneWithSafeTriangle(); + + 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 = openLevelOneWithSafeTriangle(); + 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 open and close a nested dropdown with arrow keys in ltr', fakeAsync(() => { compileTestComponent(); instance.rootTriggerEl().nativeElement.click(); @@ -2097,6 +2218,21 @@ describe('KbqDropdown default overrides', () => { }); }); +describe('KbqDropdown safe triangle default override', () => { + it('should honor a `safeTriangle: true` default without setting the input explicitly', () => { + TestBed.configureTestingModule({ + imports: [KbqDropdownModule, NoopAnimationsModule, SimpleDropdown], + providers: [{ provide: KBQ_DROPDOWN_DEFAULT_OPTIONS, useValue: { safeTriangle: true } }] + }).compileComponents(); + + const fixture = TestBed.createComponent(SimpleDropdown); + + fixture.detectChanges(); + + expect(fixture.componentInstance.dropdown().safeTriangle()).toBe(true); + }); +}); + @Component({ imports: [KbqDropdownModule], template: ` @@ -2279,7 +2415,12 @@ class CustomDropdown { Toggle alternate dropdown - + + + + + + + + + + + + + + + + + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'layout-column layout-align-center-center layout-gap-s' + } +}) +export class DropdownSafeTriangleExample { + /** + * 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 triangle) behavior. + */ + readonly safeTriangle = signal(true); +} diff --git a/packages/docs-examples/components/dropdown/index.ts b/packages/docs-examples/components/dropdown/index.ts index 14e80cab18..df1c116a14 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 { DropdownSafeTriangleExample } from './dropdown-safe-triangle/dropdown-safe-triangle-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, + DropdownSafeTriangleExample, DropdownWithFilterExample, DropdownWithFooterExample, DropdownXPositionExample @@ -35,6 +37,7 @@ const EXAMPLES = [ DropdownLazyloadDataExample, DropdownOpenByArrowDownExample, DropdownRecursiveTemplateExample, + DropdownSafeTriangleExample, DropdownWithFilterExample, DropdownXPositionExample ]; diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 59ec202267..d2c3952d54 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 1f59cae8bd..5e61aa29ab 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 { + activateSafeTriangle(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; + deactivateSafeTriangle(): void; direction: Direction; focusFirstItem(origin?: FocusOrigin): void; handleKeydown(event: KeyboardEvent): void; @@ -87,6 +90,7 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, readonly hasSearch: i0.Signal; hovered(): Observable; isAnimating: boolean; + isSafeTriangleActive(): boolean; items: QueryList; lazyContent: KbqDropdownContent; // (undocumented) @@ -113,6 +117,7 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, parent: KbqDropdownPanel | undefined; resetActiveItem(): void; resetAnimation(): void; + readonly safeTriangle: i0.InputSignalWithTransform; setPositionClasses(posX?: KbqDropdownPositionX, posY?: KbqDropdownPositionY): void; startAnimation(): void; templateRef: TemplateRef; @@ -123,7 +128,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 +163,7 @@ export interface KbqDropdownDefaultOptions { panelMaxWidth?: KbqPanelMaxWidth; panelMinWidth?: KbqPanelMinWidth; panelWidth?: KbqPanelWidth; + safeTriangle?: boolean; xPosition: KbqDropdownPositionX; yPosition: KbqDropdownPositionY; } @@ -289,6 +295,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;