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,
DropdownSafeAreaExample,
DropdownWithFilterExample,
DropdownWithFooterExample,
DropdownXPositionExample
Expand All @@ -33,6 +34,7 @@ import { DevThemeToggle } from '../theme-toggle';
DropdownLazyloadDataExample,
DropdownOpenByArrowDownExample,
DropdownRecursiveTemplateExample,
DropdownSafeAreaExample,
DropdownWithFilterExample,
DropdownWithFooterExample,
DropdownXPositionExample
Expand All @@ -47,6 +49,9 @@ import { DevThemeToggle } from '../theme-toggle';
<dropdown-nested-example />
<hr />

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

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

Expand Down
6 changes: 3 additions & 3 deletions packages/components-dev/dropdown/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,10 @@
<i kbq-icon="kbq-chevron-down-s_16"></i>
</button>

<kbq-dropdown #appDropdownWithNested="kbqDropdown">
<kbq-dropdown #appDropdownWithNested="kbqDropdown" [safeArea]="true">
<button kbq-dropdown-item [kbqDropdownTriggerFor]="appDropdownNested">1 level (1)</button>
<button kbq-dropdown-item>1 level (2)</button>
<button kbq-dropdown-item>1 level (3)</button>
<button kbq-dropdown-item [kbqDropdownTriggerFor]="appDropdownNested">1 level (2)</button>
<button kbq-dropdown-item [kbqDropdownTriggerFor]="appDropdownNested">1 level (3)</button>
</kbq-dropdown>

<kbq-dropdown #appDropdownNested="kbqDropdown">
Expand Down
74 changes: 74 additions & 0 deletions packages/components/core/overlay/safe-area.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
64 changes: 64 additions & 0 deletions packages/components/core/overlay/safe-area.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-area';
export * from './overlay/shadow-dom-overlay-container';
export * from './pop-up/index';
export * from './select/index';
Expand Down
52 changes: 45 additions & 7 deletions 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-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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand All @@ -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
Expand Down
Loading