Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] feat(Popup): support popup plugin #3900

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/popup/_example/container.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<t-space size="large">
<t-popup content="触发元素的父元素是组件根元素,通过 CSSSelector 定义" attach="#myPopup">
<t-popup content="触发元素的父元素是组件根元素,通过 CSSSelector 定义" attach="#myPopup" trigger="click">
<div id="myPopup">
<t-button variant="outline">父元素为组件本身</t-button>
</div>
Expand Down
65 changes: 65 additions & 0 deletions src/popup/_example/plugin.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<template>
<t-space size="large">
<t-button variant="outline" class="trigger-element1" @click="handleElement1">已渲染的节点1</t-button>
<t-button variant="outline" class="trigger-element2" @click="handleElement2"
>通过Plugin打开,并修改不同浮层的配置</t-button
>
<div>
<span>这里是一个日志查询的例子,在很长的日志内容中,日志内容存在换行的情况,可以点击链接进行日志查询操作</span>
<a class="trigger-element3" style="color: var(--td-text-color-brand)" @click="handleCreatePopupOffset"
>点击此链接,会打开浮层进行跳转操作</a
>
</div>
</t-space>
</template>

<script setup lang="jsx">
import { PopupPlugin } from 'tdesign-vue-next';

function handleElement1() {
PopupPlugin('.trigger-element1', '渲染文本内容', {
showArrow: true,
trigger: 'hover',
destroyOnClose: true,
});
}

function handleElement2() {
PopupPlugin('.trigger-element2', '渲染文本的内容', {
placement: 'right',
showArrow: false,
});
}

function handleCreatePopupOffset() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
PopupPlugin('.trigger-element3', (h) => <div>透传popperOptions,在offset里控制节点位置</div>, {
placement: 'bottom',
attach: '.trigger-element3',
showArrow: true,
popperOptions: {
modifiers: [
{
name: 'offset',
trigger: 'click',
options: {
offset: ({ reference }) => {
const target = document.querySelector('.trigger-element3');
let { lineHeight } = getComputedStyle(target);
if (lineHeight === 'normal') {
const temp = document.createElement('div');
temp.innerText = 't';
document.body.appendChild(temp);
lineHeight = `${temp.offsetHeight}px`;
document.body.removeChild(temp);
}
const isBreakLine = reference.height > parseInt(lineHeight, 10);
return isBreakLine ? [null, -reference.height + 10] : [0, 0];
},
},
},
],
},
});
}
</script>
2 changes: 2 additions & 0 deletions src/popup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { TdPopupProps } from './type';
import './style';

export * from './type';
export { default as PopupPlugin } from './plugin';

export type PopupProps = TdPopupProps;

export const Popup = withInstall(_Popup);
Expand Down
167 changes: 167 additions & 0 deletions src/popup/overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { defineComponent, ref, watch, Transition, computed, onBeforeMount } from 'vue';
import isFunction from 'lodash/isFunction';
import isObject from 'lodash/isObject';
import debounce from 'lodash/debounce';
import isString from 'lodash/isString';

import { useCommonClassName, usePrefixClass } from '../hooks/useConfig';
import { useTNodeJSX } from '../hooks';
import { off, on, once } from '../utils/dom';
import { getTriggerType } from './utils';

import props from './props';

import type { PopupTriggerEvent } from './type';

export default defineComponent({
props: {
...props,
triggerElement: Object,
},
setup(props, { expose }) {
const prefixCls = usePrefixClass('popup');
const renderTNodeJSX = useTNodeJSX();

const { STATUS: commonCls } = useCommonClassName();
const overlayEl = ref<HTMLElement>(null);
const popperEl = ref<HTMLElement>(null);
const overlayVisible = ref(false);

let showTimeout: NodeJS.Timeout;
let hideTimeout: NodeJS.Timeout;

const delay = computed(() => {
const delay = props.trigger !== 'hover' ? [0, 0] : [].concat(props.delay ?? [250, 150]);
return {
show: delay[0],
hide: delay[1] ?? delay[0],
};
});

watch(
() => overlayVisible.value,
(visible: boolean) => {
if (visible) {
on(document, 'mousedown', onDocumentMouseDown, true);
if (props.trigger === 'focus') {
once(props.triggerElement as HTMLElement, 'keydown', (ev: KeyboardEvent) => {
const code = typeof process !== 'undefined' && process.env?.TEST ? '27' : 'Escape';
if (ev.code === code) {
hide(ev);
}
});
}
return;
}
off(document, 'mousedown', onDocumentMouseDown, true);
},
);

const getOverlayStyle = () => {
const { overlayStyle } = props;

if (!props.triggerElement || !overlayEl.value) return;
if (isFunction(overlayStyle)) {
return overlayStyle(props.triggerElement as HTMLElement, overlayEl.value);
}
if (isObject(overlayStyle)) {
return overlayStyle;
}
};
const onDocumentMouseDown = (ev: MouseEvent) => {
const isClickContent = popperEl.value?.contains(ev.target as Node);
const isClickTriggerElement = (props.triggerElement as HTMLElement)?.contains(ev.target as Node);
if (isClickContent || isClickTriggerElement) return;

hide(ev);
};

const hide = (ev?: PopupTriggerEvent) => {
clearAllTimeout();
hideTimeout = setTimeout(() => {
overlayVisible.value = false;
props.onVisibleChange?.(false, ev ? { trigger: getTriggerType(ev), e: ev } : null);
}, delay.value.hide);
};

const show = (ev?: PopupTriggerEvent) => {
clearAllTimeout();
showTimeout = setTimeout(() => {
overlayVisible.value = true;
props.onVisibleChange?.(true, ev ? { trigger: getTriggerType(ev), e: ev } : null);
}, delay.value.show);
};

function clearAllTimeout() {
clearTimeout(showTimeout);
clearTimeout(hideTimeout);
}

const handleOnScroll = (e: WheelEvent) => {
const { scrollTop, clientHeight, scrollHeight } = e.target as HTMLDivElement;

const debounceOnScrollBottom = debounce((e) => props.onScrollToBottom?.({ e }), 100);

if (clientHeight + Math.floor(scrollTop) === scrollHeight) {
debounceOnScrollBottom(e);
}
props.onScroll?.({ e });
};

const onOverlayClick = (e: MouseEvent) => {
props.onOverlayClick?.({ e });
};
expose({
hide,
show,
});

onBeforeMount(() => {
overlayVisible.value = true;
});

return {
prefixCls,
commonCls,
popperEl,
overlayEl,
getOverlayStyle,
handleOnScroll,
onOverlayClick,
renderTNodeJSX,
overlayVisible,
};
},
render() {
const content = this.renderTNodeJSX('content');
const hidePopup = this.hideEmptyPopup && ['', undefined, null].includes(content);

return this.overlayVisible ? (
<Transition name={`${this.prefixCls}--animation${this.expandAnimation ? '-expand' : ''}`} appear>
<div
class={[this.prefixCls, this.overlayClassName]}
ref="popperEl"
style={[{ zIndex: this.zIndex }, this.getOverlayStyle(), hidePopup && { visibility: 'hidden' }]}
onClick={this.onOverlayClick}
>
<div
class={[
`${this.prefixCls}__content`,
{
[`${this.prefixCls}__content--text`]: isString(this.content),
[`${this.prefixCls}__content--arrow`]: this.showArrow,
[this.commonCls.disabled]: this.disabled,
},
this.overlayInnerClassName,
]}
ref="overlayEl"
onScroll={this.handleOnScroll}
>
{content}
{this.showArrow && <div class={`${this.prefixCls}__arrow`} />}
</div>
</div>
</Transition>
) : null;
},
});
71 changes: 71 additions & 0 deletions src/popup/plugin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Plugin, App, createApp } from 'vue';
import { createPopper, Instance } from '@popperjs/core';
import { getAttach } from '../utils/dom';
import { getPopperPlacement } from './utils';

import popupOverlayComponent from './overlay';

import type { TNode } from '../common';
import type { TdPopupProps } from './type';

export interface PopupPluginApi {
config: TdPopupProps;
}

export type PopupPluginMethod = (
triggerEl: string | HTMLElement,
content: TNode,
popupProps?: TdPopupProps,
) => Instance;

export type PopupPluginType = Plugin & PopupPluginMethod;

let popperInstance: Instance;
let overlayInstance: HTMLElement;
let triggerEl: HTMLElement | Element;

const removeOverlayInstance = () => {
if (overlayInstance) {
overlayInstance.remove();
overlayInstance = null;
}
if (popperInstance) {
popperInstance.destroy();
popperInstance = null;
}
};

export const createPopupPlugin: PopupPluginMethod = (trigger, content, popupProps) => {
const currentTriggerEl = getAttach(trigger);

triggerEl = currentTriggerEl;
removeOverlayInstance();

const overlayAttach = getAttach(popupProps?.attach || 'body');

const popupDom = document.createElement('div');
popupDom.style.cssText = 'position: absolute; top: 0px; left: 0px; width: 100%';

const overlayInstance = createApp(popupOverlayComponent, {
...popupProps,
content,
triggerElement: triggerEl,
}).mount(popupDom).$el;

overlayAttach.appendChild(popupDom);

popperInstance = createPopper(triggerEl, overlayInstance, {
placement: getPopperPlacement(popupProps?.placement || ('top' as TdPopupProps['placement'])),
...popupProps?.popperOptions,
});
return popperInstance;
};

const PopupPlugin: PopupPluginType = createPopupPlugin as PopupPluginType;

PopupPlugin.install = (app: App) => {
// eslint-disable-next-line no-param-reassign
app.config.globalProperties.$popup = createPopupPlugin;
};

export default PopupPlugin;
9 changes: 9 additions & 0 deletions src/popup/popup.en-US.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
:: BASE_DOC ::

### Use Popup through plugin

Use Popup through the plugin method, which is used to render the Popup in an existing node scenario. No matter how it is invoked, it will only be mounted on one node, to reduce the rendering nodes of Popup on the page.

- `PopupPlugin(triggerElement, content, popupProps)`

{{ plugin }}


## API
### Popup Props

Expand Down
8 changes: 8 additions & 0 deletions src/popup/popup.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
:: BASE_DOC ::

### 通过插件方式调用Popup

通过插件方式调用Popup,用于将Popup渲染在已有节点的场景,同时该方式不论如何调用都只会挂载在一个节点上,用于减少页面上的Popup的渲染节点。

- `PopupPlugin(triggerElement, content, popupProps)`

{{ plugin }}

## FAQ

### 为什么在 Popup 中无法使用样式穿透?
Expand Down