chatwoot/app/javascript/dashboard/composables/useDropdownPosition.js

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 };
}