Skip to content

Commit 16a419c

Browse files
committed
feat(popup): added more ways to control the popup position
- Preferred directions can now be a function - You can now specify a positionModifier to modify the resulting position.
1 parent 9f739d6 commit 16a419c

File tree

2 files changed

+201
-137
lines changed

2 files changed

+201
-137
lines changed

src/components/LibPopup/LibPopup.vue

+168-137
Original file line numberDiff line numberDiff line change
@@ -44,26 +44,29 @@ import { onMounted, type PropType, ref, useAttrs, watch , type HTMLAttributes }
4444
import { getFallbackId, type LinkableByIdProps,type TailwindClassProp } from "../shared/props.js"
4545

4646
import { twMerge } from "../../helpers/twMerge.js"
47-
import { castType } from "@alanscodelog/utils"
47+
import { castType } from "@alanscodelog/utils/castType.js"
48+
import { isArray } from "@alanscodelog/utils/isArray.js"
49+
import type { IPopupReference, PopupPosition, PopupPositioner, PopupPositionModifier, PopupSpaceInfo } from "../../types.js"
4850

4951
const fallbackId = getFallbackId()
5052
// eslint-disable-next-line no-use-before-define
5153
const props = withDefaults(defineProps<Props>(), {
5254
useBackdrop: true,
53-
preferredHorizontal: () => ["center", "right", "left", "either"],
54-
preferredVertical: () => ["top", "bottom", "either"],
55+
// vue is getting confused when the prop type can also be a function
56+
preferredHorizontal: () => ["center", "right", "left", "either"] as any as ["center", "right", "left", "either"],
57+
preferredVertical: () => ["top", "bottom", "either"] as any as ["top", "bottom", "either"],
5558
onlyShiftIfOpen: false,
5659
})
5760
const $attrs = useAttrs()
5861
defineOptions({ name: "lib-popup" })
5962

60-
type ElementLike = { getBoundingClientRect: () => DOMRect }
63+
6164
// todo, can we have transitions?
6265
const dialogEl = ref<HTMLDialogElement | null>(null)
63-
const popupEl = ref<ElementLike | null>(null)
64-
const buttonEl = ref<ElementLike | null>(null)
66+
const popupEl = ref<IPopupReference | null>(null)
67+
const buttonEl = ref<IPopupReference | null>(null)
6568

66-
const pos = ref<{ x: number, y: number, maxWidth?: number, maxHeight?: number }>({} as any)
69+
const pos = ref<PopupPosition>({} as any)
6770
const modelValue = defineModel<boolean>({ default: false })
6871
let isOpen = false
6972

@@ -87,20 +90,29 @@ const getVeilBoundingRect = (el: HTMLElement): Omit<DOMRect, "toJSON"> => {
8790
right: 0,
8891
}
8992
}
90-
let lastButtonElPos: DOMRect | undefined
93+
let lastButtonElPos: ReturnType<IPopupReference["getBoundingClientRect"]> | undefined
9194
const recompute = (force: boolean = false): void => {
9295
requestAnimationFrame(() => {
93-
const allAreCenterScreen = props.preferredHorizontal[0] === "center-screen" && props.preferredVertical[0] === "center-screen"
94-
if ((!buttonEl.value && !allAreCenterScreen) || !popupEl.value || !dialogEl.value) {
96+
const horzHasCenterScreen = isArray(props.preferredHorizontal)
97+
&& props.preferredHorizontal[0] === "center-screen"
98+
const vertHasCenterScreen = isArray(props.preferredVertical)
99+
&& props.preferredVertical[0] === "center-screen"
100+
101+
const canBePositionedWithoutButton =
102+
(horzHasCenterScreen || typeof props.preferredHorizontal === "function")
103+
&& (vertHasCenterScreen || typeof props.preferredVertical === "function")
104+
105+
if (!popupEl.value || !dialogEl.value || (!buttonEl.value && !canBePositionedWithoutButton)) {
95106
pos.value = {} as any
96107
return
97108
}
98-
const finalPos: { x: number, y: number, maxWidth?: number, maxHeight?: number } = {} as any
99-
100109
const el = buttonEl.value?.getBoundingClientRect()
101110
const veil = getVeilBoundingRect(props.useBackdrop ? dialogEl.value : document.body)
102111
const popup = popupEl.value.getBoundingClientRect()
103112

113+
let finalPos: { x: number, y: number, maxWidth?: number, maxHeight?: number } = {} as any
114+
115+
104116
if (!force && modelValue.value && props.onlyShiftIfOpen && buttonEl.value && lastButtonElPos) {
105117
const shiftX = buttonEl.value.getBoundingClientRect().x - lastButtonElPos.x
106118
const shiftY = buttonEl.value.getBoundingClientRect().y - lastButtonElPos.y
@@ -138,146 +150,157 @@ const recompute = (force: boolean = false): void => {
138150
const { preferredHorizontal, preferredVertical } = props
139151
let maxWidth: number | undefined
140152
let maxHeight: number | undefined
141-
/* eslint-disable no-labels */
142-
outerloop:
143-
for (const type of preferredHorizontal) {
144-
switch (type) {
145-
case "center-screen":
146-
if (popup.width < veil.width) {
147-
finalPos.x = (veil.width / 2) - (popup.width / 2)
148-
} else {
149-
finalPos.x = 0
150-
maxWidth = finalPos.x
151-
}
152-
break
153-
case "center-most":
154-
case "center":
155-
castType<DOMRect>(el)
156-
if (space.leftFromCenter >= (popup.width / 2) &&
153+
if (typeof preferredHorizontal === "function") {
154+
finalPos.x = preferredHorizontal(el, popup, veil, space)
155+
} else {
156+
/* eslint-disable no-labels */
157+
outerloop:
158+
for (const type of preferredHorizontal) {
159+
switch (type) {
160+
case "center-screen":
161+
if (popup.width < veil.width) {
162+
finalPos.x = (veil.width / 2) - (popup.width / 2)
163+
} else {
164+
finalPos.x = 0
165+
maxWidth = finalPos.x
166+
}
167+
break
168+
case "center-most":
169+
case "center":
170+
castType<DOMRect>(el)
171+
if (space.leftFromCenter >= (popup.width / 2) &&
157172
space.rightFromCenter >= (popup.width / 2)) {
158-
finalPos.x = el.x + (el.width / 2) - (popup.width / 2)
159-
break outerloop
160-
}
161-
// todo temp fix when it's too wide, will prefer left
162-
if (((space.rightFromCenter + space.leftFromCenter) <= popup.width)) {
163-
finalPos.x = 0
164-
break outerloop
165-
}
166-
if (type === "center-most") {
167-
if (space.leftFromCenter < space.rightFromCenter) {
168-
finalPos.x = el.x + (el.width / 2) - space.leftFromCenter; break outerloop
173+
finalPos.x = el.x + (el.width / 2) - (popup.width / 2)
174+
break outerloop
175+
}
176+
// todo temp fix when it's too wide, will prefer left
177+
if (((space.rightFromCenter + space.leftFromCenter) <= popup.width)) {
178+
finalPos.x = 0
179+
break outerloop
180+
}
181+
if (type === "center-most") {
182+
if (space.leftFromCenter < space.rightFromCenter) {
183+
finalPos.x = el.x + (el.width / 2) - space.leftFromCenter; break outerloop
184+
} else {
185+
finalPos.x = el.x + (el.width / 2) + space.rightFromCenter - popup.width; break outerloop
186+
}
187+
}
188+
break
189+
case "left-most":
190+
castType<DOMRect>(el)
191+
if (space.left >= popup.width) {
192+
finalPos.x = el.x - popup.width; break outerloop
169193
} else {
170-
finalPos.x = el.x + (el.width / 2) + space.rightFromCenter - popup.width; break outerloop
194+
finalPos.x = 0; break outerloop
195+
}
196+
case "right-most":
197+
castType<DOMRect>(el)
198+
if (space.right >= popup.width) {
199+
finalPos.x = el.x + el.width; break outerloop
200+
} else {
201+
finalPos.x = veil.x + veil.width - popup.width; break outerloop
171202
}
172-
}
173-
break
174-
case "left-most":
175-
castType<DOMRect>(el)
176-
if (space.left >= popup.width) {
177-
finalPos.x = el.x - popup.width; break outerloop
178-
} else {
179-
finalPos.x = 0; break outerloop
180-
}
181-
case "right-most":
182-
castType<DOMRect>(el)
183-
if (space.right >= popup.width) {
184-
finalPos.x = el.x + el.width; break outerloop
185-
} else {
186-
finalPos.x = veil.x + veil.width - popup.width; break outerloop
187-
}
188203

189-
case "right":
190-
castType<DOMRect>(el)
191-
if (space.right >= popup.width) {
192-
finalPos.x = el.x; break outerloop
193-
}
194-
break
195-
case "left":
196-
castType<DOMRect>(el)
197-
if (space.left >= popup.width) {
198-
finalPos.x = (el.x + el.width) - popup.width; break outerloop
199-
}
200-
break
201-
case "either": {
202-
castType<DOMRect>(el)
203-
if (space.right >= space.left) {
204-
finalPos.x = el.x; break outerloop
205-
} else {
206-
finalPos.x = (el.x + el.width) - popup.width
207-
break outerloop
204+
case "right":
205+
castType<DOMRect>(el)
206+
if (space.right >= popup.width) {
207+
finalPos.x = el.x; break outerloop
208+
}
209+
break
210+
case "left":
211+
castType<DOMRect>(el)
212+
if (space.left >= popup.width) {
213+
finalPos.x = (el.x + el.width) - popup.width; break outerloop
214+
}
215+
break
216+
case "either": {
217+
castType<DOMRect>(el)
218+
if (space.right >= space.left) {
219+
finalPos.x = el.x; break outerloop
220+
} else {
221+
finalPos.x = (el.x + el.width) - popup.width
222+
break outerloop
223+
}
208224
}
209225
}
210226
}
211227
}
212-
outerloop:
213-
for (const type of preferredVertical) {
214-
switch (type) {
215-
case "center-screen":
216-
if (popup.height < veil.height) {
217-
finalPos.y = (veil.height / 2) - (popup.height / 2)
218-
} else {
219-
finalPos.y = 0
220-
maxHeight = finalPos.y
221-
}
222-
break
223-
case "top":
224-
castType<DOMRect>(el)
225-
if (space.top >= popup.height) {
226-
finalPos.y = el.y - popup.height; break outerloop
227-
}
228-
break
229-
case "bottom":
230-
castType<DOMRect>(el)
231-
if (space.bottom >= popup.height) {
232-
finalPos.y = el.y + el.height; break outerloop
233-
}
234-
break
235-
case "top-most":
236-
castType<DOMRect>(el)
237-
if (space.top >= popup.height) {
238-
finalPos.y = el.y - popup.height; break outerloop
239-
} else {
240-
finalPos.y = 0; break outerloop
241-
}
242-
case "bottom-most":
243-
castType<DOMRect>(el)
244-
if (space.bottom >= popup.height) {
245-
finalPos.y = el.y + el.height; break outerloop
246-
} else {
247-
finalPos.y = veil.y + veil.height - popup.height; break outerloop
248-
}
249-
case "center-most":
250-
case "center":
251-
castType<DOMRect>(el)
252-
if (space.topFromCenter >= (popup.height / 2) &&
253-
space.bottomFromCenter >= (popup.height / 2)) {
254-
finalPos.y = el.y + (el.height / 2) - (popup.height / 2)
255-
break outerloop
256-
}
257-
// todo temp fix when it's too wide, will prefer the top
258-
if (((space.bottomFromCenter + space.topFromCenter) <= popup.height)) {
259-
finalPos.y = 0
260-
break outerloop
261-
}
262-
if (type === "center-most") {
263-
if (space.topFromCenter < space.bottomFromCenter) {
264-
finalPos.y = el.y + (el.height / 2) - space.topFromCenter; break outerloop
228+
if (typeof preferredVertical === "function") {
229+
finalPos.y = preferredVertical(el, popup, veil, space)
230+
} else {
231+
outerloop:
232+
for (const type of preferredVertical) {
233+
switch (type) {
234+
case "center-screen":
235+
if (popup.height < veil.height) {
236+
finalPos.y = (veil.height / 2) - (popup.height / 2)
237+
} else {
238+
finalPos.y = 0
239+
maxHeight = finalPos.y
240+
}
241+
break
242+
case "top":
243+
castType<DOMRect>(el)
244+
if (space.top >= popup.height) {
245+
finalPos.y = el.y - popup.height; break outerloop
246+
}
247+
break
248+
case "bottom":
249+
castType<DOMRect>(el)
250+
if (space.bottom >= popup.height) {
251+
finalPos.y = el.y + el.height; break outerloop
252+
}
253+
break
254+
case "top-most":
255+
castType<DOMRect>(el)
256+
if (space.top >= popup.height) {
257+
finalPos.y = el.y - popup.height; break outerloop
265258
} else {
266-
finalPos.y = el.y + (el.height / 2) + space.bottomFromCenter - popup.height; break outerloop
259+
finalPos.y = 0; break outerloop
267260
}
261+
case "bottom-most":
262+
castType<DOMRect>(el)
263+
if (space.bottom >= popup.height) {
264+
finalPos.y = el.y + el.height; break outerloop
265+
} else {
266+
finalPos.y = veil.y + veil.height - popup.height; break outerloop
267+
}
268+
case "center-most":
269+
case "center":
270+
castType<DOMRect>(el)
271+
if (space.topFromCenter >= (popup.height / 2) &&
272+
space.bottomFromCenter >= (popup.height / 2)) {
273+
finalPos.y = el.y + (el.height / 2) - (popup.height / 2)
274+
break outerloop
275+
}
276+
// todo temp fix when it's too wide, will prefer the top
277+
if (((space.bottomFromCenter + space.topFromCenter) <= popup.height)) {
278+
finalPos.y = 0
279+
break outerloop
280+
}
281+
if (type === "center-most") {
282+
if (space.topFromCenter < space.bottomFromCenter) {
283+
finalPos.y = el.y + (el.height / 2) - space.topFromCenter; break outerloop
284+
} else {
285+
finalPos.y = el.y + (el.height / 2) + space.bottomFromCenter - popup.height; break outerloop
286+
}
287+
}
288+
break
289+
case "either": {
290+
castType<DOMRect>(el)
291+
if (space.top >= space.bottom) {
292+
finalPos.y = el.y - popup.height; break outerloop
293+
} else { finalPos.y = el.y + el.height; break outerloop }
268294
}
269-
break
270-
case "either": {
271-
castType<DOMRect>(el)
272-
if (space.top >= space.bottom) {
273-
finalPos.y = el.y - popup.height; break outerloop
274-
} else { finalPos.y = el.y + el.height; break outerloop }
275295
}
276296
}
277297
}
278298
finalPos.maxWidth = maxWidth ?? undefined
279299
finalPos.maxHeight = maxHeight ?? undefined
280300
/* eslint-enable no-labels */
301+
if (props.modifyPosition) {
302+
finalPos = props.modifyPosition(finalPos, el, popup, veil, space)
303+
}
281304
pos.value = finalPos
282305
lastButtonElPos = el
283306
})
@@ -382,10 +405,14 @@ type RealProps =
382405
* There is also the `center-screen` position, which centers the popup on the screen.
383406
*
384407
* These last two (`*-most` and `center-screen`) are greedy, they will always find a position that fits. Positions listed after are ignored.
408+
*
409+
* You can also specify a function instead which is given some additional information regarding the space around the button reference element. It should a number for the x position (or y, if preferredVertical).
410+
*
411+
* If you only need to slightn the position, you can use the `modifyPosition` option instead.
385412
*/
386-
preferredHorizontal?: ("center" | "right" | "left" | "either" | "center-screen" | "right-most" | "left-most" | "center-most")[]
413+
preferredHorizontal?: ("center" | "right" | "left" | "either" | "center-screen" | "right-most" | "left-most" | "center-most")[] | PopupPositioner
387414
/** See `preferredHorizontal`. */
388-
preferredVertical?: ("top" | "bottom" | "center" | "either" | "center-screen" | "top-most" | "bottom-most" | "center-most")[]
415+
preferredVertical?: ("top" | "bottom" | "center" | "either" | "center-screen" | "top-most" | "bottom-most" | "center-most")[] | PopupPositioner
389416
/**
390417
* When the user scrolls or resizes, normally the entire popup position is recomputed, taking into account the preferred positioning.
391418
*
@@ -394,6 +421,10 @@ type RealProps =
394421
* Set this to true to only shift the popup depending on how much the button element moved and avoid recalculating the best position.
395422
*/
396423
avoidRepositioning?: boolean
424+
/**
425+
* Allows modifying the calculated position, to for example, clamp it.
426+
*/
427+
modifyPosition?: PopupPositionModifier
397428
}
398429

399430
interface Props

0 commit comments

Comments
 (0)