Skip to content
Merged
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
89 changes: 80 additions & 9 deletions src/utils/calculate-position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,55 @@ export type CalculatePosition = (
options: CalculatePositionOptions,
) => CalculatePositionResult;

type GetViewDataResult = {
scroll: { left: number; top: number };
triggerLeft: number;
triggerTop: number;
triggerWidth: number;
triggerHeight: number;
dropdownHeight: number;
dropdownWidth: number;
viewportWidth: number;
viewportBottom: number;
};

type GetViewData = (
trigger: Element,
content: HTMLElement,
) => GetViewDataResult;

const getViewData: GetViewData = (trigger, content) => {
const scroll = {
left: window.scrollX,
top: window.scrollY,
};
const {
left: triggerLeft,
top: triggerTop,
width: triggerWidth,
height: triggerHeight,
} = trigger.getBoundingClientRect();
const { height: dropdownHeight, width: dropdownWidth } =
content.getBoundingClientRect();
const viewportWidth = document.body.clientWidth || window.innerWidth;
const viewportBottom = scroll.top + window.innerHeight;

return {
scroll,
// The properties top and left of the trigger client rectangle need to be absolute to
// the top left corner of the document as the value it's compared to is also the total
// height and not only the viewport height (window client height + scroll offset).
triggerLeft: triggerLeft + window.scrollX,
triggerTop: triggerTop + window.scrollY,
triggerWidth,
triggerHeight,
dropdownHeight,
dropdownWidth,
viewportWidth,
viewportBottom,
};
};

export function calculateWormholedPosition(
trigger: HTMLElement,
content: HTMLElement,
Expand All @@ -47,13 +96,16 @@ export function calculateWormholedPosition(
}: CalculatePositionOptions,
): CalculatePositionResult {
// Collect information about all the involved DOM elements
const scroll = { left: window.pageXOffset, top: window.pageYOffset };
let { left: triggerLeft, top: triggerTop } = trigger.getBoundingClientRect();
const { width: triggerWidth, height: triggerHeight } =
trigger.getBoundingClientRect();
const { height: dropdownHeight } = content.getBoundingClientRect();
let { width: dropdownWidth } = content.getBoundingClientRect();
const viewportWidth = document.body.clientWidth || window.innerWidth;
const viewData = getViewData(trigger, content);
const {
scroll,
triggerWidth,
triggerHeight,
dropdownHeight,
viewportWidth,
viewportBottom,
} = viewData;
let { triggerLeft, triggerTop, dropdownWidth } = viewData;
const style: CalculatePositionResultStyle = {};

// Apply containers' offset
Expand Down Expand Up @@ -170,7 +222,6 @@ export function calculateWormholedPosition(
} else if (verticalPosition === 'below') {
style.top = triggerTopWithScroll + triggerHeight;
} else {
const viewportBottom = scroll.top + window.innerHeight;
const enoughRoomBelow =
triggerTopWithScroll + triggerHeight + dropdownHeight < viewportBottom;
const enoughRoomAbove = triggerTop > dropdownHeight;
Expand Down Expand Up @@ -237,8 +288,28 @@ export function calculateInPlacePosition(
positionData.verticalPosition = verticalPosition;
dropdownRect = dropdownRect || content.getBoundingClientRect();
positionData.style.top = -dropdownRect.height;
} else {
} else if (verticalPosition === 'below') {
positionData.verticalPosition = 'below';
} else {
// Automatically determine if there is enough space above or below
const { triggerTop, triggerHeight, dropdownHeight, viewportBottom } =
getViewData(trigger, content);

const enoughRoomBelow =
triggerTop + triggerHeight + dropdownHeight < viewportBottom;
const enoughRoomAbove = triggerTop > dropdownHeight;

if (enoughRoomBelow) {
verticalPosition = 'below';
} else if (enoughRoomAbove) {
verticalPosition = 'above';
dropdownRect = dropdownRect || content.getBoundingClientRect();
positionData.style.top = -dropdownRect.height;
} else {
// Not enough space above or below
verticalPosition = 'below';
}
positionData.verticalPosition = verticalPosition;
}
return positionData;
}
Expand Down
106 changes: 106 additions & 0 deletions tests/integration/components/basic-dropdown-test.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2078,4 +2078,110 @@ module('Integration | Component | basic-dropdown', function (hooks) {
.dom(shadowRoot?.querySelector('#dropdown-is-opened'))
.doesNotExist('The dropdown is closed again');
});

test<ExtendedTestContext>('It adds the proper class above to trigger and content when it receives `renderInPlace={{true}}` and @verticalPosition="auto"', async function (assert) {
assert.expect(2);

await render(
<template>
{{! template-lint-disable no-inline-styles no-forbidden-elements }}
{{! #ember-testing is by default 200% width/height & has a scale. For this test we need to reset it }}
<style>
#ember-testing {
width: 100%;
height: 100%;
transform: none;
overflow: hidden;
}
</style>
<div style="position: relative; width: 100%; height: 100vh">
<div style="position: absolute; bottom: 0">
<HostWrapper>
<BasicDropdown
@renderInPlace={{true}}
@verticalPosition="auto"
as |dropdown|
>
<dropdown.Trigger>Press me</dropdown.Trigger>
<dropdown.Content><h3>Content of the dropdown</h3></dropdown.Content>
</BasicDropdown>
</HostWrapper>
</div>
</div>
</template>,
);

await click(
getRootNode(this.element).querySelector(
'.ember-basic-dropdown-trigger',
) as HTMLElement,
);

assert
.dom('.ember-basic-dropdown-trigger', getRootNode(this.element))
.hasClass(
'ember-basic-dropdown-trigger--above',
'The proper class has been added',
);

assert
.dom('.ember-basic-dropdown-content', getRootNode(this.element))
.hasClass(
'ember-basic-dropdown-content--above',
'The proper class has been added',
);
});

test<ExtendedTestContext>('It adds the proper class below to trigger and content when it receives `renderInPlace={{true}}` and @verticalPosition="auto"', async function (assert) {
assert.expect(2);

await render(
<template>
{{! template-lint-disable no-inline-styles no-forbidden-elements }}
{{! #ember-testing is by default width/height 200% & has a scale. For this test we need to reset it }}
<style>
#ember-testing {
width: 100%;
height: 100%;
transform: none;
overflow: hidden;
}
</style>
<div style="position: relative; width: 100%; height: 100vh">
<div style="position: absolute; top: 0">
<HostWrapper>
<BasicDropdown
@renderInPlace={{true}}
@verticalPosition="auto"
as |dropdown|
>
<dropdown.Trigger>Press me</dropdown.Trigger>
<dropdown.Content><h3>Content of the dropdown</h3></dropdown.Content>
</BasicDropdown>
</HostWrapper>
</div>
</div>
</template>,
);

await click(
getRootNode(this.element).querySelector(
'.ember-basic-dropdown-trigger',
) as HTMLElement,
);

assert
.dom('.ember-basic-dropdown-trigger', getRootNode(this.element))
.hasClass(
'ember-basic-dropdown-trigger--below',
'The proper class has been added',
);

assert
.dom('.ember-basic-dropdown-content', getRootNode(this.element))
.hasClass(
'ember-basic-dropdown-content--below',
'The proper class has been added',
);
});
});