diff --git a/packages/components/dropdown/dropdown-trigger.directive.ts b/packages/components/dropdown/dropdown-trigger.directive.ts index 5b380a508..c1804be31 100644 --- a/packages/components/dropdown/dropdown-trigger.directive.ts +++ b/packages/components/dropdown/dropdown-trigger.directive.ts @@ -592,21 +592,17 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy { } } + const resolvedOffsetY = this.offsetY ?? (overlayY === 'top' ? offsetY : -offsetY); + const resolvedFallbackOffsetY = this.offsetY ?? (overlayFallbackY === 'top' ? offsetY : -offsetY); + positionStrategy.withPositions([ - { - originX, - originY, - overlayX, - overlayY, - offsetY: this.offsetY ?? offsetY, - offsetX: this.offsetX ?? -offsetX - }, + { originX, originY, overlayX, overlayY, offsetY: resolvedOffsetY, offsetX: this.offsetX ?? -offsetX }, { originX: originFallbackX, originY, overlayX: overlayFallbackX, overlayY, - offsetY: this.offsetY ?? offsetY, + offsetY: resolvedOffsetY, offsetX: this.offsetX ?? offsetX }, { @@ -614,7 +610,7 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy { originY: originFallbackY, overlayX, overlayY: overlayFallbackY, - offsetY: this.offsetY ?? -offsetY, + offsetY: resolvedFallbackOffsetY, offsetX: this.offsetX ?? -offsetX }, { @@ -622,7 +618,7 @@ export class KbqDropdownTrigger implements AfterContentInit, OnDestroy { originY: originFallbackY, overlayX: overlayFallbackX, overlayY: overlayFallbackY, - offsetY: this.offsetY ?? -offsetY, + offsetY: resolvedFallbackOffsetY, offsetX: this.offsetX ?? -offsetX } ]); diff --git a/packages/components/dropdown/dropdown.spec.ts b/packages/components/dropdown/dropdown.spec.ts index 917e4e0e8..3e9a8ace8 100644 --- a/packages/components/dropdown/dropdown.spec.ts +++ b/packages/components/dropdown/dropdown.spec.ts @@ -1,6 +1,6 @@ import { FocusMonitor } from '@angular/cdk/a11y'; import { Direction, Directionality } from '@angular/cdk/bidi'; -import { Overlay, OverlayContainer } from '@angular/cdk/overlay'; +import { FlexibleConnectedPositionStrategy, Overlay, OverlayContainer } from '@angular/cdk/overlay'; import { ScrollDispatcher } from '@angular/cdk/scrolling'; import { ChangeDetectionStrategy, @@ -31,6 +31,7 @@ import { TAB, createKeyboardEvent, createMouseEvent, + defaultOffsetY, dispatchEvent, dispatchFakeEvent, dispatchKeyboardEvent, @@ -961,11 +962,83 @@ describe('KbqDropdown', () => { expect(Math.floor(overlayRect.top)).toBe(Math.floor(triggerRect.bottom)); }); + it('should open above trigger when yPosition is above and there is space above', () => { + const fixture = createComponent(PositionedDropdown); + + fixture.detectChanges(); + const trigger = fixture.componentInstance.triggerEl().nativeElement; + + // Push trigger down so it has space to open above + trigger.style.position = 'fixed'; + trigger.style.top = '600px'; + trigger.style.left = '100px'; + + fixture.componentInstance.trigger().open(); + fixture.detectChanges(); + + const overlayPane = getOverlayPane(); + const triggerRect = trigger.getBoundingClientRect(); + const overlayRect = overlayPane.getBoundingClientRect(); + + // Panel is above: overlay bottom should not exceed trigger top + expect(Math.floor(overlayRect.bottom)).toBeLessThanOrEqual(Math.floor(triggerRect.top)); + }); + function getOverlayPane(): HTMLElement { return overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement; } }); + describe('y-position offsetY', () => { + afterEach(() => jest.restoreAllMocks()); + + it('should pass negative offsetY for primary positions when yPosition is above', () => { + const withPositionsSpy = jest.spyOn(FlexibleConnectedPositionStrategy.prototype, 'withPositions'); + const fixture = createComponent(PositionedDropdown); // yPosition='above' by default + + fixture.detectChanges(); + fixture.componentInstance.trigger().open(); + fixture.detectChanges(); + + const positions = withPositionsSpy.mock.calls[0][0]; + + // Primary positions: overlayY='bottom' means panel is above trigger. + // offsetY must be negative to push the panel UP and create a gap (not overlap). + expect(positions[0].overlayY).toBe('bottom'); + expect(positions[0].offsetY).toBe(-defaultOffsetY); + expect(positions[1].overlayY).toBe('bottom'); + expect(positions[1].offsetY).toBe(-defaultOffsetY); + + // Fallback positions: overlayY='top' means panel is below trigger. + // offsetY must be positive to push the panel DOWN and create a gap. + expect(positions[2].overlayY).toBe('top'); + expect(positions[2].offsetY).toBe(defaultOffsetY); + expect(positions[3].overlayY).toBe('top'); + expect(positions[3].offsetY).toBe(defaultOffsetY); + }); + + it('should pass positive offsetY for primary positions when yPosition is below', () => { + const withPositionsSpy = jest.spyOn(FlexibleConnectedPositionStrategy.prototype, 'withPositions'); + const fixture = createComponent(SimpleDropdown); // yPosition='below' by default + + fixture.detectChanges(); + fixture.componentInstance.trigger().open(); + fixture.detectChanges(); + + const positions = withPositionsSpy.mock.calls[0][0]; + + // Primary positions: overlayY='top' means panel is below trigger. + // offsetY must be positive to push the panel DOWN and create a gap. + expect(positions[0].overlayY).toBe('top'); + expect(positions[0].offsetY).toBe(defaultOffsetY); + + // Fallback positions: overlayY='bottom' means panel is above trigger. + // offsetY must be negative to push the panel UP and create a gap. + expect(positions[2].overlayY).toBe('bottom'); + expect(positions[2].offsetY).toBe(-defaultOffsetY); + }); + }); + describe('overlapping trigger', () => { /** * This test class is used to create components containing a dropdown.