669 lines
16 KiB
Plaintext
669 lines
16 KiB
Plaintext
<script lang="ts">
|
|
export default {
|
|
name: "LayDropdown",
|
|
inheritAttrs: false,
|
|
};
|
|
</script>
|
|
|
|
<script setup lang="ts">
|
|
import "./index.less";
|
|
import {
|
|
ComputedRef,
|
|
CSSProperties,
|
|
h,
|
|
inject,
|
|
reactive,
|
|
Ref,
|
|
toRefs,
|
|
useSlots,
|
|
Fragment,
|
|
cloneVNode,
|
|
useAttrs,
|
|
StyleValue,
|
|
PropType,
|
|
} from "vue";
|
|
import {
|
|
computed,
|
|
nextTick,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
provide,
|
|
ref,
|
|
shallowRef,
|
|
watch,
|
|
} from "vue";
|
|
import {
|
|
onClickOutside,
|
|
useResizeObserver,
|
|
useThrottleFn,
|
|
useWindowSize,
|
|
} from "@vueuse/core";
|
|
import {
|
|
dropdownInjectionKey,
|
|
DropdownPlacement,
|
|
ElementScrollRect,
|
|
DropdownContext,
|
|
} from "./interface";
|
|
import TeleportWrapper from "../_components/teleportWrapper.vue";
|
|
import { useFirstElement, isScrollElement, getScrollElements } from "./util";
|
|
import RenderFunction, { RenderFunc } from "../_components/renderFunction";
|
|
import { transformPlacement } from "./util";
|
|
|
|
export type DropdownTrigger = "click" | "hover" | "focus" | "contextMenu";
|
|
|
|
export interface DropdownProps {
|
|
visible?: boolean;
|
|
trigger?: DropdownTrigger | DropdownTrigger[];
|
|
placement?: DropdownPlacement;
|
|
disabled?: boolean;
|
|
autoFitPosition?: boolean;
|
|
autoFitWidth?: boolean;
|
|
autoFitMinWidth?: boolean;
|
|
updateAtScroll?: boolean;
|
|
autoFixPosition?: boolean;
|
|
clickToClose?: boolean;
|
|
blurToClose?: boolean;
|
|
clickOutsideToClose?: boolean;
|
|
contentOffset?: number;
|
|
mouseEnterDelay?: number;
|
|
mouseLeaveDelay?: number;
|
|
focusDelay?: number;
|
|
alignPoint?: boolean;
|
|
contentClass?: string | Array<string | object> | object;
|
|
contentStyle?: StyleValue;
|
|
popupContainer?: string | undefined;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<DropdownProps>(), {
|
|
visible: false,
|
|
trigger: "click",
|
|
disabled: false,
|
|
placement: "bottom-start",
|
|
autoFitPosition: true,
|
|
autoFitMinWidth: true,
|
|
autoFitWidth: false,
|
|
updateAtScroll: false,
|
|
autoFixPosition: true,
|
|
clickToClose: true,
|
|
blurToClose: true,
|
|
clickOutsideToClose: true,
|
|
contentOffset: 2,
|
|
mouseEnterDelay: 150,
|
|
mouseLeaveDelay: 150,
|
|
focusDelay: 150,
|
|
alignPoint: false,
|
|
popupContainer: "body",
|
|
});
|
|
|
|
const emit = defineEmits(["show", "hide"]);
|
|
|
|
const slots = useSlots();
|
|
const attrs = useAttrs();
|
|
const childrenRefs = new Set<Ref<HTMLElement>>();
|
|
const dropdownCtx = inject<DropdownContext | undefined>(
|
|
dropdownInjectionKey,
|
|
undefined
|
|
);
|
|
const { children, firstElement: dropdownRef } = useFirstElement();
|
|
//const dropdownRef = shallowRef<HTMLElement | undefined>();
|
|
const contentRef = shallowRef<HTMLElement | undefined>();
|
|
const contentStyle = ref<CSSProperties>({});
|
|
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
|
const mousePosition = reactive({
|
|
x: 0,
|
|
y: 0,
|
|
});
|
|
const { x: mouseLeft, y: mouseTop } = toRefs(mousePosition);
|
|
const openState = ref(false);
|
|
let scrollElements: HTMLElement[] | undefined;
|
|
|
|
const containerRef = computed(() =>
|
|
props.popupContainer
|
|
? document.querySelector<HTMLElement>(props.popupContainer) ?? document.body
|
|
: dropdownRef.value
|
|
) as ComputedRef<HTMLElement>;
|
|
|
|
const triggerMethods = computed(() =>
|
|
([] as Array<DropdownTrigger>).concat(props.trigger)
|
|
);
|
|
|
|
const computedPlacement = computed(() => {
|
|
return transformPlacement(props.placement);
|
|
});
|
|
|
|
let delayTimer = 0;
|
|
|
|
const cleanDelayTimer = () => {
|
|
if (delayTimer) {
|
|
window.clearTimeout(delayTimer);
|
|
delayTimer = 0;
|
|
}
|
|
};
|
|
|
|
const show = (delay?: number): void => {
|
|
if (props.disabled == false) {
|
|
changeVisible(true, delay);
|
|
emit("show");
|
|
}
|
|
};
|
|
|
|
const hide = (delay?: number): void => {
|
|
changeVisible(false, delay);
|
|
emit("hide");
|
|
};
|
|
|
|
const toggle = (): void => {
|
|
if (props.disabled == false)
|
|
if (openState.value) {
|
|
hide();
|
|
} else {
|
|
show();
|
|
}
|
|
};
|
|
|
|
const changeVisible = (visible: boolean, delay?: number) => {
|
|
if (visible === openState.value && delayTimer === 0) {
|
|
return;
|
|
}
|
|
const update = () => {
|
|
openState.value = visible;
|
|
nextTick(() => {
|
|
updateContentStyle();
|
|
});
|
|
};
|
|
|
|
if (delay) {
|
|
cleanDelayTimer();
|
|
if (visible !== openState.value) {
|
|
delayTimer = window.setTimeout(update, delay);
|
|
}
|
|
} else {
|
|
update();
|
|
}
|
|
};
|
|
|
|
const getElementScrollRect = (element: HTMLElement, containerRect: DOMRect) => {
|
|
const rect = element.getBoundingClientRect();
|
|
|
|
return {
|
|
top: rect.top,
|
|
bottom: rect.bottom,
|
|
left: rect.left,
|
|
right: rect.right,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
scrollTop: rect.top - containerRect.top,
|
|
scrollBottom: rect.bottom - containerRect.top,
|
|
scrollLeft: rect.left - containerRect.left,
|
|
scrollRight: rect.right - containerRect.left,
|
|
};
|
|
};
|
|
|
|
const getTriggerRect = () => {
|
|
return {
|
|
top: mouseTop.value,
|
|
bottom: mouseTop.value,
|
|
left: mouseLeft.value,
|
|
right: mouseLeft.value,
|
|
scrollTop: mouseTop.value,
|
|
scrollBottom: mouseTop.value,
|
|
scrollLeft: mouseLeft.value,
|
|
scrollRight: mouseLeft.value,
|
|
width: 0,
|
|
height: 0,
|
|
};
|
|
};
|
|
|
|
const updateContentStyle = () => {
|
|
if (!containerRef.value || !dropdownRef.value || !contentRef.value) {
|
|
return;
|
|
}
|
|
|
|
const containerRect = containerRef.value.getBoundingClientRect();
|
|
const triggerRect = props.alignPoint
|
|
? getTriggerRect()
|
|
: getElementScrollRect(dropdownRef.value, containerRect);
|
|
const contentRect = getElementScrollRect(contentRef.value, containerRect);
|
|
const { style } = getContentStyle(
|
|
computedPlacement.value,
|
|
triggerRect,
|
|
contentRect
|
|
);
|
|
|
|
if (props.autoFitMinWidth) {
|
|
style.minWidth = `${triggerRect.width}px`;
|
|
}
|
|
if (props.autoFitWidth) {
|
|
style.width = `${triggerRect.width}px`;
|
|
}
|
|
contentStyle.value = style;
|
|
|
|
if (props.autoFitPosition) {
|
|
nextTick(() => {
|
|
const triggerRect = props.alignPoint
|
|
? getTriggerRect()
|
|
: getElementScrollRect(dropdownRef.value as HTMLElement, containerRect);
|
|
const contentRect = getElementScrollRect(
|
|
contentRef.value as HTMLElement,
|
|
containerRect
|
|
);
|
|
let { top, left } = style;
|
|
top = Number(top.toString().replace("px", ""));
|
|
left = Number(left.toString().replace("px", ""));
|
|
const { top: fitTop, left: fitLeft } = getFitPlacement(
|
|
top,
|
|
left,
|
|
computedPlacement.value,
|
|
triggerRect,
|
|
contentRect
|
|
);
|
|
style.top = `${fitTop}px`;
|
|
style.left = `${fitLeft}px`;
|
|
contentStyle.value = {
|
|
...style,
|
|
};
|
|
});
|
|
}
|
|
};
|
|
|
|
const updateMousePosition = (e: MouseEvent) => {
|
|
if (props.alignPoint) {
|
|
const { pageX, pageY } = e;
|
|
mousePosition.x = pageX;
|
|
mousePosition.y = pageY;
|
|
}
|
|
};
|
|
|
|
const getContentStyle = (
|
|
placement: DropdownPlacement,
|
|
triggerRect: ElementScrollRect,
|
|
contentRect: ElementScrollRect,
|
|
{
|
|
customStyle = {},
|
|
}: {
|
|
customStyle?: CSSProperties;
|
|
} = {}
|
|
) => {
|
|
let { top, left } = getContentOffset(placement, triggerRect, contentRect);
|
|
const style = {
|
|
top: `${top}px`,
|
|
left: `${left}px`,
|
|
...customStyle,
|
|
};
|
|
return {
|
|
style,
|
|
};
|
|
};
|
|
|
|
const getPosition = (placement: DropdownPlacement) => {
|
|
if (["top", "top-start", "top-end"].includes(placement)) {
|
|
return "top";
|
|
}
|
|
if (["bottom", "bottom-start", "bottom-end"].includes(placement)) {
|
|
return "bottom";
|
|
}
|
|
if (["left", "left-start", "left-end"].includes(placement)) {
|
|
return "left";
|
|
}
|
|
if (["right", "right-start", "right-end"].includes(placement)) {
|
|
return "right";
|
|
}
|
|
return "bottom";
|
|
};
|
|
|
|
const getFitPlacement = (
|
|
top: number,
|
|
left: number,
|
|
placement: DropdownPlacement,
|
|
triggerRect: ElementScrollRect,
|
|
contentRect: ElementScrollRect
|
|
) => {
|
|
// FIXME 反转后仍溢出的场景
|
|
const position = getPosition(placement);
|
|
if (["top", "bottom"].includes(position)) {
|
|
// 溢出屏幕底部
|
|
if (contentRect.bottom > windowHeight.value) {
|
|
top = triggerRect.scrollTop - contentRect.height - props.contentOffset;
|
|
}
|
|
// 溢出屏幕顶部
|
|
if (contentRect.top < 0) {
|
|
top = triggerRect.scrollBottom + props.contentOffset;
|
|
}
|
|
// 溢出屏幕左边
|
|
if (contentRect.left < 0) {
|
|
left = left + (0 - contentRect.left);
|
|
}
|
|
// 溢出屏幕右边
|
|
if (contentRect.right > windowWidth.value) {
|
|
left = left - (contentRect.right - windowWidth.value);
|
|
}
|
|
}
|
|
if (["left", "right"].includes(position)) {
|
|
// 溢出屏幕底部
|
|
if (contentRect.bottom > windowHeight.value) {
|
|
top = top - (contentRect.bottom - windowHeight.value);
|
|
}
|
|
// 溢出屏幕顶部
|
|
if (contentRect.top < 0) {
|
|
top = top + (0 - contentRect.top);
|
|
}
|
|
// 溢出屏幕左边
|
|
if (contentRect.left < 0) {
|
|
left = triggerRect.scrollRight + props.contentOffset;
|
|
}
|
|
// 溢出屏幕右边
|
|
if (contentRect.right > windowWidth.value) {
|
|
left = triggerRect.scrollLeft - contentRect.width - props.contentOffset;
|
|
}
|
|
}
|
|
|
|
return {
|
|
top,
|
|
left,
|
|
};
|
|
};
|
|
|
|
const getContentOffset = (
|
|
placement: DropdownPlacement,
|
|
triggerRect: ElementScrollRect,
|
|
contentRect: ElementScrollRect
|
|
) => {
|
|
switch (placement) {
|
|
case "top":
|
|
return {
|
|
top: triggerRect.scrollTop - contentRect.height - props.contentOffset,
|
|
left:
|
|
triggerRect.scrollLeft +
|
|
Math.round((triggerRect.width - contentRect.width) / 2),
|
|
};
|
|
case "top-start":
|
|
return {
|
|
top: triggerRect.scrollTop - contentRect.height - props.contentOffset,
|
|
left: triggerRect.scrollLeft,
|
|
};
|
|
case "top-end":
|
|
return {
|
|
top: triggerRect.scrollTop - contentRect.height - props.contentOffset,
|
|
left: triggerRect.scrollRight - contentRect.width,
|
|
};
|
|
case "bottom":
|
|
return {
|
|
top: triggerRect.scrollBottom + props.contentOffset,
|
|
left:
|
|
triggerRect.scrollLeft +
|
|
Math.round((triggerRect.width - contentRect.width) / 2),
|
|
};
|
|
case "bottom-start":
|
|
return {
|
|
top: triggerRect.scrollBottom + props.contentOffset,
|
|
left: triggerRect.scrollLeft,
|
|
};
|
|
case "bottom-end":
|
|
return {
|
|
top: triggerRect.scrollBottom + props.contentOffset,
|
|
left: triggerRect.scrollRight - contentRect.width,
|
|
};
|
|
case "right":
|
|
return {
|
|
top:
|
|
triggerRect.scrollTop +
|
|
Math.round((triggerRect.height - contentRect.height) / 2),
|
|
left: triggerRect.scrollRight + props.contentOffset,
|
|
};
|
|
case "right-start":
|
|
return {
|
|
top: triggerRect.scrollTop,
|
|
left: triggerRect.scrollRight + props.contentOffset,
|
|
};
|
|
case "right-end":
|
|
return {
|
|
top: triggerRect.scrollBottom - contentRect.height,
|
|
left: triggerRect.scrollRight + props.contentOffset,
|
|
};
|
|
case "left":
|
|
return {
|
|
top:
|
|
triggerRect.scrollTop +
|
|
Math.round((triggerRect.height - contentRect.height) / 2),
|
|
left: triggerRect.scrollLeft - contentRect.width - props.contentOffset,
|
|
};
|
|
case "left-start":
|
|
return {
|
|
top: triggerRect.scrollTop,
|
|
left: triggerRect.scrollLeft - contentRect.width - props.contentOffset,
|
|
};
|
|
case "left-end":
|
|
return {
|
|
top: triggerRect.scrollBottom - contentRect.height,
|
|
left: triggerRect.scrollLeft - contentRect.width - props.contentOffset,
|
|
};
|
|
default:
|
|
return {
|
|
left: 0,
|
|
top: 0,
|
|
};
|
|
}
|
|
};
|
|
|
|
const handleScroll = useThrottleFn(() => {
|
|
if (openState.value) {
|
|
updateContentStyle();
|
|
}
|
|
}, 10);
|
|
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (props.disabled || (openState.value && !props.clickToClose)) {
|
|
return;
|
|
}
|
|
if (triggerMethods.value.includes("click")) {
|
|
updateMousePosition(e);
|
|
toggle();
|
|
}
|
|
};
|
|
|
|
const handleContextMenuClick = (e: MouseEvent) => {
|
|
if (props.disabled || (openState.value && !props.clickToClose)) {
|
|
return;
|
|
}
|
|
if (triggerMethods.value.includes("contextMenu")) {
|
|
e.preventDefault();
|
|
if (props.alignPoint) {
|
|
hide();
|
|
}
|
|
updateMousePosition(e);
|
|
toggle();
|
|
}
|
|
};
|
|
|
|
const handleMouseEnter = (e: MouseEvent) => {
|
|
if (props.disabled || !triggerMethods.value.includes("hover")) {
|
|
return;
|
|
}
|
|
show(props.mouseEnterDelay);
|
|
};
|
|
|
|
const handleMouseEnterWithContext = (e: MouseEvent) => {
|
|
if (!props.popupContainer) {
|
|
return;
|
|
}
|
|
dropdownCtx?.onMouseenter(e);
|
|
handleMouseEnter(e);
|
|
};
|
|
|
|
const handleMouseLeave = (e: MouseEvent) => {
|
|
if (props.disabled || !triggerMethods.value.includes("hover")) {
|
|
return;
|
|
}
|
|
hide(props.mouseLeaveDelay);
|
|
};
|
|
|
|
const handleMouseLeaveWithContext = (e: MouseEvent) => {
|
|
if (!props.popupContainer) {
|
|
return;
|
|
}
|
|
dropdownCtx?.onMouseleave(e);
|
|
handleMouseLeave(e);
|
|
};
|
|
|
|
const handleFocusin = () => {
|
|
if (props.disabled || !triggerMethods.value.includes("focus")) {
|
|
return;
|
|
}
|
|
show(props.focusDelay);
|
|
};
|
|
|
|
const handleFocusout = () => {
|
|
if (props.disabled || !triggerMethods.value.includes("focus")) {
|
|
return;
|
|
}
|
|
if (!props.blurToClose) {
|
|
return;
|
|
}
|
|
hide();
|
|
};
|
|
|
|
const handleContextHide = () => {
|
|
hide();
|
|
dropdownCtx?.hide();
|
|
};
|
|
|
|
const addChildRef = (ref: any) => {
|
|
childrenRefs.add(ref);
|
|
dropdownCtx?.addChildRef(ref);
|
|
};
|
|
const removeChildRef = (ref: any) => {
|
|
childrenRefs.delete(ref);
|
|
dropdownCtx?.removeChildRef(ref);
|
|
};
|
|
|
|
dropdownCtx?.addChildRef(contentRef);
|
|
|
|
const { stop: removeContentResizeObserver } = useResizeObserver(
|
|
contentRef,
|
|
() => {
|
|
if (openState.value && props.autoFixPosition) {
|
|
updateContentStyle();
|
|
}
|
|
}
|
|
);
|
|
|
|
const { stop: removeTriggerResizeObserver } = useResizeObserver(
|
|
dropdownRef,
|
|
() => {
|
|
if (openState.value && props.autoFixPosition) {
|
|
updateContentStyle();
|
|
}
|
|
}
|
|
);
|
|
|
|
onClickOutside(dropdownRef, (e) => {
|
|
if (
|
|
!props.clickOutsideToClose ||
|
|
!openState.value ||
|
|
dropdownRef.value?.contains(e.target as HTMLElement) ||
|
|
contentRef.value?.contains(e.target as HTMLElement)
|
|
) {
|
|
return;
|
|
}
|
|
for (const item of childrenRefs) {
|
|
if (item.value?.contains(e.target as HTMLElement)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
hide();
|
|
});
|
|
|
|
const onlyChildRenderFunc = () => {
|
|
const slotContent = slots.default ? slots.default() : [];
|
|
const transformedSlotContent = slotContent.map((vnode) =>
|
|
cloneVNode(
|
|
vnode,
|
|
{
|
|
onClick: handleClick,
|
|
onContextmenu: handleContextMenuClick,
|
|
onMouseenter: handleMouseEnter,
|
|
onMouseleave: handleMouseLeave,
|
|
onFocusin: handleFocusin,
|
|
onFocusout: handleFocusout,
|
|
...attrs,
|
|
},
|
|
true
|
|
)
|
|
);
|
|
|
|
children.value = transformedSlotContent;
|
|
return h(Fragment, children.value);
|
|
};
|
|
|
|
onMounted(() => {
|
|
if (props.updateAtScroll) {
|
|
scrollElements = getScrollElements(dropdownRef.value);
|
|
for (const item of scrollElements) {
|
|
item.addEventListener("scroll", handleScroll);
|
|
}
|
|
}
|
|
window.addEventListener("resize", handleScroll);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
dropdownCtx?.removeChildRef(contentRef);
|
|
if (scrollElements) {
|
|
for (const item of scrollElements) {
|
|
item.removeEventListener("scroll", handleScroll);
|
|
}
|
|
scrollElements = undefined;
|
|
}
|
|
removeContentResizeObserver();
|
|
removeTriggerResizeObserver();
|
|
window.removeEventListener("resize", handleScroll);
|
|
});
|
|
|
|
watch(
|
|
() => props.visible,
|
|
(newVal, oldVal) => {
|
|
openState.value = newVal;
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
provide(
|
|
dropdownInjectionKey,
|
|
reactive({
|
|
onMouseenter: handleMouseEnterWithContext,
|
|
onMouseleave: handleMouseLeaveWithContext,
|
|
addChildRef,
|
|
removeChildRef,
|
|
hide: handleContextHide,
|
|
})
|
|
);
|
|
|
|
provide("openState", openState);
|
|
|
|
defineExpose({ show, hide, toggle });
|
|
</script>
|
|
|
|
<template>
|
|
<RenderFunction
|
|
:renderFunc="onlyChildRenderFunc"
|
|
v-bind="$attrs"
|
|
></RenderFunction>
|
|
<TeleportWrapper :to="popupContainer" :disabled="disabled">
|
|
<div
|
|
v-if="openState"
|
|
ref="contentRef"
|
|
:class="[
|
|
'layui-dropdown-content',
|
|
'layui-anim',
|
|
'layui-anim-upbit',
|
|
props.contentClass,
|
|
]"
|
|
:style="[contentStyle, props.contentStyle ?? '']"
|
|
@mouseenter="handleMouseEnterWithContext"
|
|
@mouseleave="handleMouseLeaveWithContext"
|
|
>
|
|
<slot name="content"></slot>
|
|
</div>
|
|
</TeleportWrapper>
|
|
</template>
|