Skip to content

Commit

Permalink
New helper JS for dropdown positioning
Browse files Browse the repository at this point in the history
  • Loading branch information
distantnative committed Dec 19, 2023
1 parent 103e9e5 commit a48ef22
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 101 deletions.
113 changes: 15 additions & 98 deletions panel/src/components/Dropdowns/DropdownContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
<dialog
v-if="isOpen"
ref="dropdown"
:data-align-x="axis.x"
:data-align-y="axis.y"
:data-theme="theme"
:style="{
top: position.y + 'px',
Expand Down Expand Up @@ -33,7 +31,7 @@
</template>

<script>
import Vue from "vue";
import { position } from "@/helpers/dropdown.js";
let OpenDropdown = null;
Expand All @@ -44,7 +42,6 @@ export default {
props: {
/**
* @deprecated 4.0.0 Use `align-x` instead
* @todo rename `axis` data to `align` when removed
*/
align: {
type: String
Expand Down Expand Up @@ -106,14 +103,7 @@ export default {
],
data() {
return {
axis: {
x: this.alignX,
y: this.alignY
},
position: {
x: 0,
y: 0
},
position: { x: 0, y: 0 },
isOpen: false,
items: [],
opener: null
Expand Down Expand Up @@ -223,86 +213,26 @@ export default {
});
},
async setPosition() {
// reset to the alignment defaults
// before running position calculation
this.axis = {
x: this.alignX ?? this.align,
y: this.alignY
// remember the current scroll position as it will be 0
// after the modal is opened
const scroll = {
x: window.scrollX,
y: window.scrollY
};
if (this.axis.x === "right") {
this.axis.x = "end";
} else if (this.axis.x === "left") {
this.axis.x = "start";
}
// flip x axis for RTL languages
if (this.$panel.direction === "rtl") {
if (this.axis.x === "start") {
this.axis.x = "end";
} else if (this.axis.x === "end") {
this.axis.x = "start";
}
}
// drill down to the element of a component
if (this.opener instanceof Vue) {
this.opener = this.opener.$el;
}
// get the dimensions of the opening button
const opener = this.opener.getBoundingClientRect();
// set the default position
// and take scroll position into consideration
this.position.x = opener.left + window.scrollX + opener.width;
this.position.y = opener.top + window.scrollY + opener.height;
// open the modal after the default positioning has been applied
if (this.$el.open !== true) {
this.$el.showModal();
await this.$nextTick();
}
// as we just set style.top, wait one tick before measuring dropdownRect
await this.$nextTick();
// get the dimensions of the open dropdown
const rect = this.$el.getBoundingClientRect();
const safeSpace = 10;
// Horizontal: check if dropdown is outside of viewport
// and adapt alignment if necessary
if (this.axis.x === "end") {
if (opener.left - rect.width < safeSpace) {
this.axis.x = "start";
}
} else if (
opener.left + rect.width > window.innerWidth - safeSpace &&
rect.width + safeSpace < rect.left
) {
this.axis.x = "end";
}
if (this.axis.x === "start") {
this.position.x = this.position.x - opener.width;
}
// Vertical: check if dropdown is outside of viewport
// and adapt alignment if necessary
if (this.axis.y === "top") {
if (rect.height + safeSpace > rect.top) {
this.axis.y = "bottom";
}
} else if (
opener.top + rect.height > window.innerHeight - safeSpace &&
rect.height + safeSpace < rect.top
) {
this.axis.y = "top";
}
if (this.axis.y === "top") {
this.position.y = this.position.y - opener.height;
}
this.position = position(
this.opener,
this.$el,
this.alignX ?? this.align,
this.alignY,
scroll
);
},
resetPosition() {
this.position = { x: 0, y: 0 };
Expand All @@ -329,8 +259,6 @@ export default {
}
.k-dropdown-content {
--dropdown-x: 0;
--dropdown-y: 0;
position: absolute;
inset-block-start: 0;
inset-inline-start: initial; /* reset this, so that `left` is authoritative */
Expand All @@ -342,22 +270,11 @@ export default {
color: var(--dropdown-color-text);
box-shadow: var(--dropdown-shadow);
text-align: start;
transform: translate(var(--dropdown-x), var(--dropdown-y));
}
.k-dropdown-content::backdrop {
background: none;
}
.k-dropdown-content[data-align-x="end"] {
--dropdown-x: -100%;
}
.k-dropdown-content[data-align-x="center"] {
--dropdown-x: -50%;
}
.k-dropdown-content[data-align-y="top"] {
--dropdown-y: -100%;
}
.k-dropdown-content hr {
margin: 0.5rem 0;
height: 1px;
Expand Down
3 changes: 0 additions & 3 deletions panel/src/components/Forms/Blocks/BlockOptions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,4 @@ export default {
.k-block-options > .k-button:not(:last-of-type) {
border-inline-end: 1px solid var(--color-background);
}
.k-block-options .k-dropdown-content {
margin-top: 0.5rem;
}
</style>
159 changes: 159 additions & 0 deletions panel/src/helpers/dropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import Vue from "vue";

/**
* Checks if the dropdown element is outside of the viewport
* horizontally and adapts the alignment if necessary
*
* @param {DOMRect} opener
* @param {DOMRect} dropdown
* @param {String} align "start"|"center"|"end"
* @param {Number} scroll
* @param {Number} safeSpace
* @returns {String}
*/
export function containX(opener, dropdown, align, scroll, safeSpace = 20) {
if (align === "end") {
if (opener.x - scroll - dropdown.width < safeSpace) {
// when aligning to end, cut off at the left edge
return "start";
}
} else if (
opener.x - scroll + dropdown.width + safeSpace > window.innerWidth &&
dropdown.width + safeSpace < opener.x - scroll
) {
// when aligning to start, cut off at the right edge
// (but also ensuring that it won't be cut off
// at the left edge when aligning to end)
return "end";
}

return align;
}

/**
* Checks if the dropdown element is outside of the viewport
* vertically and adapts the alignment if necessary
*
* @param {DOMRect} opener
* @param {DOMRect} dropdown
* @param {String} align "start"|"center"|"end"
* @param {Number} scroll
* @param {Number} safeSpace
* @returns {String}
*/
export function containY(opener, dropdown, align, scroll, safeSpace = 20) {
if (align === "top") {
// when aligning upwards, but cut off at the top edge
if (dropdown.height + safeSpace > opener.y - scroll) {
return "bottom";
}

return "top";
}

// when aligning downwards, butcut off at the bottom edge
if (opener.y - scroll + dropdown.height + safeSpace > window.innerHeight) {
// ensure that it won't be cut off at the top edge when aligning upwards
if (dropdown.height + safeSpace < opener.y - scroll) {
return "top";
}
}

return "bottom";
}

/**
* Normalize alignment string to "start"|"center"|"end"
* and flip x axis for RTL languages
*
* @param {String} align
* @returns {String}
*/
export function normalizeAlignX(align) {
if (align === "right") {
align = "end";
} else if (align === "left") {
align = "start";
}

// flip x axis for RTL languages
if (window.panel.direction === "rtl") {
if (align === "start") {
align = "end";
} else if (align === "end") {
align = "start";
}
}

return align;
}

/**
* Gets the x position of the dropdown element
* (to be used with `left` CSS property on dropdown)
*
* @param {DOMRect} opener
* @param {DOMRect} dropdown
* @param {String} align "start"|"center"|"end"
* @returns {Number}
*/
export function positionX(opener, dropdown, align) {
if (align === "center") {
return opener.x + opener.width / 2 - dropdown.width / 2;
}

if (align === "end") {
return opener.x + opener.width - dropdown.width;
}

return opener.x;
}

/**
* Gets the y position of the dropdown element
* (to be used with `top` CSS property on dropdown)
*
* @param {DOMRect} opener
* @param {DOMRect} dropdown
* @param {String} align "start"|"center"|"end"
* @returns {Number}
*/
export function positionY(opener, dropdown, align) {
if (align === "top") {
return opener.y - dropdown.height;
}

return opener.y + opener.height;
}

/**
* Returns x and y position of the dropdown element
* (to be used with `left` and `top` CSS properties on fixed element)
*
* @param {Element} opener
* @param {Element} dropdown
* @param {String} x "start"|"center"|"end"
* @param {String} y "top"|"bottom"
* @param {Object} scroll
* @returns {Object}
*/
export function position(opener, dropdown, x, y, scroll) {
// drill down to the element of a component
if (opener instanceof Vue) {
opener = opener.$el;
}

// get the dimensions of the opening button and dropdown element
opener = opener.getBoundingClientRect();
dropdown = dropdown.getBoundingClientRect();

// adapt aligment to contain dropdown element in viewport
x = normalizeAlignX(x);
x = containX(opener, dropdown, x, scroll.x);
y = containY(opener, dropdown, y, scroll.y);

return {
x: positionX(opener, dropdown, x),
y: positionY(opener, dropdown, y)
};
}

0 comments on commit a48ef22

Please sign in to comment.