Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/components-dev/dropdown/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DropdownOpenByArrowDownExample,
DropdownOverviewExample,
DropdownRecursiveTemplateExample,
DropdownSafeTriangleExample,
DropdownWithFilterExample,
DropdownWithFooterExample,
DropdownXPositionExample
Expand All @@ -33,6 +34,7 @@ import { DevThemeToggle } from '../theme-toggle';
DropdownLazyloadDataExample,
DropdownOpenByArrowDownExample,
DropdownRecursiveTemplateExample,
DropdownSafeTriangleExample,
DropdownWithFilterExample,
DropdownWithFooterExample,
DropdownXPositionExample
Expand All @@ -47,6 +49,9 @@ import { DevThemeToggle } from '../theme-toggle';
<dropdown-nested-example />
<hr />

<dropdown-safe-triangle-example />
<hr />

<dropdown-disabled-example />
<hr />

Expand Down
74 changes: 74 additions & 0 deletions packages/components/core/overlay/safe-triangle.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
64 changes: 64 additions & 0 deletions packages/components/core/overlay/safe-triangle.ts
Original file line number Diff line number Diff line change
@@ -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<DOMPointReadOnly, 'x' | 'y'>;

/**
* 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 } };
}
1 change: 1 addition & 0 deletions packages/components/core/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
32 changes: 31 additions & 1 deletion packages/components/dropdown/dropdown-trigger.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
defaultOffsetY,
DOWN_ARROW,
ENTER,
getSafeTriangleVertices,
kbqGetPanelWidthOrigin,
KbqPanelWidthOrigin,
KbqResolvedPanelWidth,
Expand Down Expand Up @@ -122,6 +123,7 @@ const positionMap = {
// attribute themselves.
'[attr.aria-expanded]': 'opened',
'(mousedown)': 'handleMousedown($event)',
'(mouseleave)': 'handleMouseLeave($event)',
'(keydown)': 'handleKeydown($event)',
'(click)': 'handleClick($event)'
},
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -671,7 +692,12 @@ 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 only guards against closing while the pointer grazes a
// plain sibling row en route to the submenu (it handles closing itself once the
// pointer actually leaves it — see `handleMouseLeave()`). Hovering another *nested*
// trigger is a real intent change and should switch immediately.
filter((active) => active.isNested || !this.parent.isSafeTriangleActive())
)
: observableOf();

Expand Down Expand Up @@ -701,6 +727,10 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy, KbqSibli
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
Expand Down
73 changes: 72 additions & 1 deletion packages/components/dropdown/dropdown.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,6 +23,7 @@ import {
TemplateRef,
ViewChild,
ViewEncapsulation,
booleanAttribute,
computed,
contentChild,
inject,
Expand All @@ -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';
Expand All @@ -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]'
})
Expand Down Expand Up @@ -89,6 +99,7 @@ export class KbqDropdownFooter {}
export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit, OnDestroy {
private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private ngZone = inject(NgZone);
private document = inject(DOCUMENT);
private defaultOptions = inject<KbqDropdownDefaultOptions>(KBQ_DROPDOWN_DEFAULT_OPTIONS);

private readonly search = contentChild(KbqFormField);
Expand Down Expand Up @@ -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 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rmnturov по умолчанию это поведение должно быть включено?


/**
* `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`
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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. */
Expand All @@ -342,6 +364,55 @@ export class KbqDropdown implements AfterContentInit, KbqDropdownPanel, OnInit,
) as Observable<KbqDropdownItem>;
}

/**
* 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.
Comment thread
NikGurev marked this conversation as resolved.
Outdated
* @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;
Expand Down
Loading