Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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