Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
34 changes: 33 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,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();

Expand All @@ -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
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 });

/**
* `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.
* @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
Loading