(dropdown): 支持渲染到 body

This commit is contained in:
sight 2022-07-03 00:55:13 +08:00
parent 18edd93618
commit 7ed837fe7d
3 changed files with 179 additions and 59 deletions

View File

@ -3,8 +3,8 @@
display: inline-block; display: inline-block;
} }
.layui-dropdown-content,
.layui-dropdown dl { .layui-dropdown dl {
display: none;
position: absolute; position: absolute;
z-index: 899; z-index: 899;
background-color: #fff; background-color: #fff;
@ -14,6 +14,7 @@
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1); box-shadow: 0 2px 12px 0 rgba(0, 0, 0, .1);
} }
.layui-dropdown-content > .layui-dropdown-menu,
.layui-dropdown dl > .layui-dropdown-menu { .layui-dropdown dl > .layui-dropdown-menu {
border-radius: var(--global-border-radius); border-radius: var(--global-border-radius);
} }
@ -22,16 +23,20 @@
display: block; display: block;
} }
.layui-dropdown-content .layui-menu,
.layui-dropdown .layui-menu { .layui-dropdown .layui-menu {
position: relative; position: relative;
background-color: #fff; background-color: #fff;
} }
.layui-dropdown-content .layui-menu li,
.layui-dropdown-content .layui-menu-body-title a,
.layui-dropdown .layui-menu li, .layui-dropdown .layui-menu li,
.layui-dropdown .layui-menu-body-title a { .layui-dropdown .layui-menu-body-title a {
padding: 5px 15px; padding: 5px 15px;
} }
.layui-dropdown-content .layui-menu li,
.layui-dropdown .layui-menu li { .layui-dropdown .layui-menu li {
position: relative; position: relative;
display: flex; display: flex;
@ -42,10 +47,12 @@
cursor: pointer; cursor: pointer;
} }
.layui-dropdown-content .layui-menu li:hover,
.layui-dropdown .layui-menu li:hover { .layui-dropdown .layui-menu li:hover {
background-color: var(--global-neutral-color-2); background-color: var(--global-neutral-color-2);
} }
.layui-dropdown-content .layui-menu-body-title,
.layui-dropdown .layui-menu-body-title { .layui-dropdown .layui-menu-body-title {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -59,6 +66,7 @@
margin-left: 15px; margin-left: 15px;
} }
.layui-dropdown-content .layui-line-horizontal,
.layui-dropdown .layui-line-horizontal{ .layui-dropdown .layui-line-horizontal{
margin: 0px; margin: 0px;
border-color: #EEEEEE; border-color: #EEEEEE;

View File

@ -6,7 +6,7 @@ export default {
<script setup lang="ts"> <script setup lang="ts">
import "./index.less"; import "./index.less";
import type { CSSProperties } from "vue"; import { CSSProperties, inject, reactive, Ref } from "vue";
import { import {
computed, computed,
nextTick, nextTick,
@ -24,7 +24,12 @@ import {
useThrottleFn, useThrottleFn,
useWindowSize, useWindowSize,
} from "@vueuse/core"; } from "@vueuse/core";
import type { DropdownPlacement } from "./interface"; import {
dropdownInjectionKey,
DropdownPlacement,
ElementScrollRect,
DropdownContext,
} from "./interface";
export type DropdownTrigger = "click" | "hover" | "focus" | "contextMenu"; export type DropdownTrigger = "click" | "hover" | "focus" | "contextMenu";
@ -47,6 +52,7 @@ export interface LayDropdownProps {
mouseLeaveDelay?: number; mouseLeaveDelay?: number;
focusDelay?: number; focusDelay?: number;
alignPoint?: boolean; alignPoint?: boolean;
renderToBody?: boolean;
} }
const props = withDefaults(defineProps<LayDropdownProps>(), { const props = withDefaults(defineProps<LayDropdownProps>(), {
@ -67,8 +73,11 @@ const props = withDefaults(defineProps<LayDropdownProps>(), {
mouseLeaveDelay: 150, mouseLeaveDelay: 150,
focusDelay: 150, focusDelay: 150,
alignPoint: false, alignPoint: false,
renderToBody: false,
}); });
const childrenRefs = new Set<Ref<HTMLElement>>();
const dropdownCtx = inject<DropdownContext>(dropdownInjectionKey, undefined);
const dropdownRef = shallowRef<HTMLElement | undefined>(); const dropdownRef = shallowRef<HTMLElement | undefined>();
const contentRef = shallowRef<HTMLElement | undefined>(); const contentRef = shallowRef<HTMLElement | undefined>();
const contentStyle = ref<CSSProperties>({}); const contentStyle = ref<CSSProperties>({});
@ -133,12 +142,34 @@ const changeVisible = (visible: boolean, delay?: number) => {
} }
}; };
const containerRef = computed(() =>
props.renderToBody ? document.body : dropdownRef.value
);
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 updateContentStyle = () => { const updateContentStyle = () => {
if (!dropdownRef.value || !contentRef.value) { if (!containerRef.value || !dropdownRef.value || !contentRef.value) {
return; return;
} }
const triggerRect = dropdownRef.value!.getBoundingClientRect(); const containerRect = containerRef.value.getBoundingClientRect();
const contentRect = contentRef.value.getBoundingClientRect(); const triggerRect = getElementScrollRect(dropdownRef.value, containerRect);
const contentRect = getElementScrollRect(contentRef.value, containerRect);
const { style } = getContentStyle( const { style } = getContentStyle(
props.placement, props.placement,
triggerRect, triggerRect,
@ -156,8 +187,14 @@ const updateContentStyle = () => {
if (props.autoFitPosition) { if (props.autoFitPosition) {
nextTick(() => { nextTick(() => {
const triggerRect = dropdownRef.value!.getBoundingClientRect(); const triggerRect = getElementScrollRect(
const contentRect = contentRef.value!.getBoundingClientRect(); dropdownRef.value as HTMLElement,
containerRect
);
const contentRect = getElementScrollRect(
contentRef.value as HTMLElement,
containerRect
);
let { top, left } = style; let { top, left } = style;
top = Number(top.toString().replace("px", "")); top = Number(top.toString().replace("px", ""));
left = Number(left.toString().replace("px", "")); left = Number(left.toString().replace("px", ""));
@ -179,8 +216,8 @@ const updateContentStyle = () => {
const getContentStyle = ( const getContentStyle = (
placement: DropdownPlacement, placement: DropdownPlacement,
triggerRect: DOMRect, triggerRect: ElementScrollRect,
contentRect: DOMRect, contentRect: ElementScrollRect,
isAlignPoint?: boolean, isAlignPoint?: boolean,
{ {
customStyle = {}, customStyle = {},
@ -224,19 +261,19 @@ const getFitPlacement = (
top: number, top: number,
left: number, left: number,
placement: DropdownPlacement, placement: DropdownPlacement,
triggerRect: DOMRect, triggerRect: ElementScrollRect,
contentRect: DOMRect contentRect: ElementScrollRect
) => { ) => {
// FIXME // FIXME
const position = getPosition(placement); const position = getPosition(placement);
if (["top", "bottom"].includes(position)) { if (["top", "bottom"].includes(position)) {
// //
if (contentRect.bottom > windowHeight.value) { if (contentRect.bottom > windowHeight.value) {
top = -contentRect.height - props.contentOffset; top = triggerRect.scrollTop - contentRect.height - props.contentOffset;
} }
// //
if (contentRect.top < 0) { if (contentRect.top < 0) {
top = triggerRect.height + props.contentOffset; top = triggerRect.scrollBottom + props.contentOffset;
} }
// //
if (contentRect.left < 0) { if (contentRect.left < 0) {
@ -258,11 +295,11 @@ const getFitPlacement = (
} }
// //
if (contentRect.left < 0) { if (contentRect.left < 0) {
left = triggerRect.width + props.contentOffset; left = triggerRect.scrollRight + props.contentOffset;
} }
// //
if (contentRect.right > windowWidth.value) { if (contentRect.right > windowWidth.value) {
left = -(contentRect.width + props.contentOffset); left = triggerRect.scrollLeft - contentRect.width - props.contentOffset;
} }
} }
@ -274,8 +311,8 @@ const getFitPlacement = (
const getContentOffset = ( const getContentOffset = (
placement: DropdownPlacement, placement: DropdownPlacement,
triggerRect: DOMRect, triggerRect: ElementScrollRect,
contentRect: DOMRect, contentRect: ElementScrollRect,
isAlignPoint?: boolean isAlignPoint?: boolean
) => { ) => {
if (isAlignPoint) { if (isAlignPoint) {
@ -287,63 +324,71 @@ const getContentOffset = (
switch (placement) { switch (placement) {
case "top": case "top":
return { return {
top: -contentRect.height - props.contentOffset, top: triggerRect.scrollTop - contentRect.height - props.contentOffset,
left: -(contentRect.width - triggerRect.width) / 2, left:
triggerRect.scrollLeft +
Math.round((triggerRect.width - contentRect.width) / 2),
}; };
case "top-left": case "top-left":
return { return {
top: -contentRect.height - props.contentOffset, top: triggerRect.scrollTop - contentRect.height - props.contentOffset,
left: 0, left: triggerRect.scrollLeft,
}; };
case "top-right": case "top-right":
return { return {
top: -contentRect.height - props.contentOffset, top: triggerRect.scrollTop - contentRect.height - props.contentOffset,
left: -(contentRect.width - triggerRect.width), left: triggerRect.scrollRight - contentRect.width,
}; };
case "bottom": case "bottom":
return { return {
top: triggerRect.height + props.contentOffset, top: triggerRect.scrollBottom + props.contentOffset,
left: -(contentRect.width - triggerRect.width) / 2, left:
triggerRect.scrollLeft +
Math.round((triggerRect.width - contentRect.width) / 2),
}; };
case "bottom-left": case "bottom-left":
return { return {
top: triggerRect.height + props.contentOffset, top: triggerRect.scrollBottom + props.contentOffset,
left: 0, left: triggerRect.scrollLeft,
}; };
case "bottom-right": case "bottom-right":
return { return {
top: triggerRect.height + props.contentOffset, top: triggerRect.scrollBottom + props.contentOffset,
left: -(contentRect.width - triggerRect.width), left: triggerRect.scrollRight - contentRect.width,
}; };
case "right": case "right":
return { return {
top: -(contentRect.height - triggerRect.height) / 2, top:
left: triggerRect.width + props.contentOffset, triggerRect.scrollTop +
Math.round((triggerRect.height - contentRect.height) / 2),
left: triggerRect.scrollRight + props.contentOffset,
}; };
case "right-top": case "right-top":
return { return {
top: 0, top: triggerRect.scrollTop,
left: triggerRect.width + props.contentOffset, left: triggerRect.scrollRight + props.contentOffset,
}; };
case "right-bottom": case "right-bottom":
return { return {
top: -(contentRect.height - triggerRect.height), top: triggerRect.scrollBottom - contentRect.height,
left: triggerRect.width + props.contentOffset, left: triggerRect.scrollRight + props.contentOffset,
}; };
case "left": case "left":
return { return {
top: -(contentRect.height - triggerRect.height) / 2, top:
left: -(contentRect.width + props.contentOffset), triggerRect.scrollTop +
Math.round((triggerRect.height - contentRect.height) / 2),
left: triggerRect.scrollLeft - contentRect.width - props.contentOffset,
}; };
case "left-top": case "left-top":
return { return {
top: 0, top: triggerRect.scrollTop,
left: -(contentRect.width + props.contentOffset), left: triggerRect.scrollLeft - contentRect.width - props.contentOffset,
}; };
case "left-bottom": case "left-bottom":
return { return {
top: -(contentRect.height - triggerRect.height), top: triggerRect.scrollBottom - contentRect.height,
left: -(contentRect.width + props.contentOffset), left: triggerRect.scrollLeft - contentRect.width - props.contentOffset,
}; };
default: default:
return { return {
@ -376,7 +421,7 @@ const handleScroll = useThrottleFn(() => {
if (openState.value) { if (openState.value) {
updateContentStyle(); updateContentStyle();
} }
}, 250); }, 10);
const handleClick = () => { const handleClick = () => {
if (props.disabled || (openState.value && !props.clickToClose)) { if (props.disabled || (openState.value && !props.clickToClose)) {
@ -387,7 +432,7 @@ const handleClick = () => {
} }
}; };
const handleContextMenuClick = (e: Event) => { const handleContextMenuClick = (e: MouseEvent) => {
if (props.disabled || (openState.value && !props.clickToClose)) { if (props.disabled || (openState.value && !props.clickToClose)) {
return; return;
} }
@ -400,20 +445,30 @@ const handleContextMenuClick = (e: Event) => {
} }
}; };
const handleMouseEnter = () => { const handleMouseEnter = (e: MouseEvent) => {
if (props.disabled || !triggerMethods.value.includes("hover")) { if (props.disabled || !triggerMethods.value.includes("hover")) {
return; return;
} }
open(props.mouseEnterDelay); open(props.mouseEnterDelay);
}; };
const handleMouseLeave = () => { const handleMouseEnterWithContext = (e: MouseEvent) => {
dropdownCtx?.onMouseenter(e);
handleMouseEnter(e);
};
const handleMouseLeave = (e: MouseEvent) => {
if (props.disabled || !triggerMethods.value.includes("hover")) { if (props.disabled || !triggerMethods.value.includes("hover")) {
return; return;
} }
hide(props.mouseLeaveDelay); hide(props.mouseLeaveDelay);
}; };
const handleMouseLeaveWithContext = (e: MouseEvent) => {
dropdownCtx?.onMouseleave(e);
handleMouseLeave(e);
};
const handleFocusin = () => { const handleFocusin = () => {
if (props.disabled || !triggerMethods.value.includes("focus")) { if (props.disabled || !triggerMethods.value.includes("focus")) {
return; return;
@ -431,6 +486,27 @@ const handleFocusout = () => {
hide(); hide();
}; };
const addChildRef = (ref: any) => {
childrenRefs.add(ref);
dropdownCtx?.addChildRef(ref);
};
const removeChildRef = (ref: any) => {
childrenRefs.delete(ref);
dropdownCtx?.removeChildRef(ref);
};
provide(
dropdownInjectionKey,
reactive({
onMouseenter: handleMouseEnterWithContext,
onMouseleave: handleMouseLeaveWithContext,
addChildRef,
removeChildRef,
})
);
dropdownCtx?.addChildRef(contentRef);
const { stop: removeContentResizeObserver } = useResizeObserver( const { stop: removeContentResizeObserver } = useResizeObserver(
contentRef, contentRef,
() => { () => {
@ -449,14 +525,22 @@ const { stop: removeTriggerResizeObserver } = useResizeObserver(
} }
); );
onClickOutside(dropdownRef, () => { onClickOutside(dropdownRef, (e) => {
if (props.clickOutsideToClose) { if (
hide(); !props.clickOutsideToClose ||
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();
}); });
let scrollElements: HTMLElement[] | undefined; let scrollElements: HTMLElement[] | undefined;
onMounted(() => { onMounted(() => {
if (props.updateAtScroll) { if (props.updateAtScroll) {
scrollElements = getScrollElements(dropdownRef.value); scrollElements = getScrollElements(dropdownRef.value);
@ -467,6 +551,7 @@ onMounted(() => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
dropdownCtx?.removeChildRef(contentRef);
if (scrollElements) { if (scrollElements) {
for (const item of scrollElements) { for (const item of scrollElements) {
item.removeEventListener("scroll", handleScroll); item.removeEventListener("scroll", handleScroll);
@ -509,8 +594,8 @@ defineExpose({ open, hide, toggle });
<div <div
ref="dropdownRef" ref="dropdownRef"
class="layui-dropdown" class="layui-dropdown"
@mouseenter="handleMouseEnter()" @mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave()" @mouseleave="handleMouseLeave"
@focusin="handleFocusin()" @focusin="handleFocusin()"
@focusout="handleFocusout()" @focusout="handleFocusout()"
:class="{ 'layui-dropdown-up': openState }" :class="{ 'layui-dropdown-up': openState }"
@ -518,12 +603,17 @@ defineExpose({ open, hide, toggle });
<div @click="handleClick()" @contextmenu="handleContextMenuClick"> <div @click="handleClick()" @contextmenu="handleContextMenuClick">
<slot></slot> <slot></slot>
</div> </div>
<dl <Teleport :to="containerRef" :disabled="!renderToBody">
ref="contentRef" <dl
class="layui-anim layui-anim-upbit" v-show="openState"
:style="contentStyle" ref="contentRef"
> class="layui-dropdown-content layui-anim layui-anim-upbit"
<slot name="content"></slot> :style="contentStyle"
</dl> @mouseenter="handleMouseEnterWithContext"
@mouseleave="handleMouseLeaveWithContext"
>
<slot name="content"></slot>
</dl>
</Teleport>
</div> </div>
</template> </template>

View File

@ -12,3 +12,25 @@ export type DropdownPlacement =
| "left" | "left"
| "left-top" | "left-top"
| "left-bottom"; | "left-bottom";
export interface ElementScrollRect {
top: number;
bottom: number;
left: number;
right: number;
scrollTop: number;
scrollBottom: number;
scrollLeft: number;
scrollRight: number;
width: number;
height: number;
}
export interface DropdownContext {
onMouseenter: Function;
onMouseleave: Function;
addChildRef: Function;
removeChildRef: Function;
}
export const dropdownInjectionKey = Symbol("dropdownInjectKey");