mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-13 21:01:16 +08:00
129 lines
4.3 KiB
JavaScript
129 lines
4.3 KiB
JavaScript
import { computed, unref, watch } from 'vue';
|
|
import { useElementBounding, useWindowSize } from '@vueuse/core';
|
|
|
|
const FALLBACK_SIZE = 200;
|
|
const SAFE_MARGIN = 16;
|
|
const GAP = 8;
|
|
|
|
/**
|
|
* Auto-position a floating element based on available viewport space.
|
|
*
|
|
* @param {Ref} triggerRef - Trigger element ref
|
|
* @param {Ref} dropdownRef - Dropdown/popover element ref
|
|
* @param {Ref} enabled - Whether to calculate position
|
|
* @param {Object} options
|
|
* @param {Ref} [options.container] - Constraining container ref
|
|
* @param {number} [options.margin=16] - Min distance from viewport/container edges
|
|
* @param {string} [options.align='end'] - 'start' or 'end' (flips automatically for RTL)
|
|
*/
|
|
export function useDropdownPosition(
|
|
triggerRef,
|
|
dropdownRef,
|
|
enabled,
|
|
{ container = null, margin = SAFE_MARGIN, align = 'end' } = {}
|
|
) {
|
|
const trigger = useElementBounding(triggerRef);
|
|
const dropdown = useElementBounding(dropdownRef);
|
|
const bounds = useElementBounding(container);
|
|
const { width: winWidth, height: winHeight } = useWindowSize();
|
|
|
|
const isRTL = computed(
|
|
() => document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl'
|
|
);
|
|
|
|
// Whether to anchor to the left edge of the trigger
|
|
const anchorLeft = computed(() => (align === 'start') !== isRTL.value);
|
|
|
|
const verticalClass = computed(() => {
|
|
if (!unref(enabled)) return 'top-full mt-2';
|
|
const dh = dropdown.height.value || FALLBACK_SIZE;
|
|
const spaceBelow = winHeight.value - trigger.bottom.value;
|
|
const spaceAbove = trigger.top.value;
|
|
// Only flip above if it fits there; otherwise stay below (more room or equal)
|
|
if (spaceBelow >= dh + margin) return 'top-full mt-2';
|
|
if (spaceAbove >= dh + margin) return 'bottom-full mb-2';
|
|
return spaceBelow >= spaceAbove ? 'top-full mt-2' : 'bottom-full mb-2';
|
|
});
|
|
|
|
// Relative mode: Tailwind class + style for absolute-in-parent dropdowns
|
|
const position = computed(() => {
|
|
if (!unref(enabled)) return { class: 'top-full mt-2', style: {} };
|
|
|
|
const dw = dropdown.width.value || FALLBACK_SIZE;
|
|
const leftBound = container ? bounds.left.value : 0;
|
|
const rightBound = container ? bounds.right.value : winWidth.value;
|
|
const style = {};
|
|
|
|
if (anchorLeft.value) {
|
|
const available = rightBound - trigger.left.value;
|
|
const overflow = dw - available;
|
|
style.left = overflow > 0 ? `-${overflow}px` : '0px';
|
|
} else {
|
|
const available = trigger.right.value - leftBound;
|
|
const overflow = dw - available;
|
|
style.right = overflow > 0 ? `-${overflow}px` : '0px';
|
|
}
|
|
|
|
return { class: verticalClass.value, style };
|
|
});
|
|
|
|
// Fixed mode: styles for teleported popovers
|
|
const fixedPosition = computed(() => {
|
|
if (!unref(enabled)) return { class: 'fixed z-[9999]', style: {} };
|
|
|
|
const dh = dropdown.height.value || FALLBACK_SIZE;
|
|
const dw = dropdown.width.value || FALLBACK_SIZE;
|
|
const spaceBelow = winHeight.value - trigger.bottom.value;
|
|
const style = {};
|
|
|
|
// Vertical: prefer below, flip above only if it fits, else pick the larger side
|
|
const spaceAbove = trigger.top.value;
|
|
const placeAbove =
|
|
spaceBelow < dh + margin &&
|
|
(spaceAbove >= dh + margin || spaceAbove > spaceBelow);
|
|
|
|
if (placeAbove) {
|
|
style.bottom = `${winHeight.value - trigger.top.value + GAP}px`;
|
|
style.maxHeight = `${spaceAbove - GAP - margin}px`;
|
|
} else {
|
|
style.top = `${trigger.bottom.value + GAP}px`;
|
|
style.maxHeight = `${spaceBelow - GAP - margin}px`;
|
|
}
|
|
|
|
// Horizontal
|
|
if (anchorLeft.value) {
|
|
const left = trigger.left.value;
|
|
if (left + dw > winWidth.value - margin) {
|
|
style.right = `${margin}px`;
|
|
} else {
|
|
style.left = `${Math.max(margin, left)}px`;
|
|
}
|
|
} else {
|
|
const right = winWidth.value - trigger.right.value;
|
|
if (trigger.right.value - dw < margin) {
|
|
style.left = `${margin}px`;
|
|
} else {
|
|
style.right = `${right}px`;
|
|
}
|
|
}
|
|
|
|
return { class: 'fixed z-[9999]', style };
|
|
});
|
|
|
|
const updatePosition = () => {
|
|
trigger.update();
|
|
dropdown.update();
|
|
if (container) bounds.update();
|
|
};
|
|
|
|
// Update position when dropdown opens to ensure RTL state is current
|
|
watch(
|
|
() => unref(enabled),
|
|
isEnabled => {
|
|
if (isEnabled) updatePosition();
|
|
}
|
|
);
|
|
|
|
return { position, fixedPosition, updatePosition };
|
|
}
|