Skip to content

Commit

Permalink
feat(Popup): support plugin usage (#2219)
Browse files Browse the repository at this point in the history
* feat(Popup): support plugin usage

* chore: update snapshot

* chore: popup plugin docs
  • Loading branch information
uyarn authored Mar 9, 2023
1 parent 66eabaa commit 9952c61
Show file tree
Hide file tree
Showing 11 changed files with 1,613 additions and 3,372 deletions.
28 changes: 28 additions & 0 deletions src/popup/_example/plugin.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<template>
<t-space>
<t-button variant="outline" @click="handleElement1" class="trigger-element1">已渲染的节点1</t-button>
<t-button variant="outline" @click="handleElement2" class="trigger-element2">已渲染的节点2</t-button>
</t-space>
</template>

<script lang="jsx">
export default {
methods: {
handleElement1() {
this.$popup('.trigger-element1', '渲染文本内容', {
placement: 'bottom',
showArrow: true,
trigger: 'hover',
destroyOnClose: true,
});
},
handleElement2() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
this.$popup('.trigger-element2', (h) => <div>渲染的是DOM节点</div>, {
placement: 'top',
trigger: 'click',
});
},
},
};
</script>
8 changes: 4 additions & 4 deletions src/popup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import './style';

export type PopupProps = TdPopupProps;
export * from './type';
export * from './plugin';

export const Popup = withInstall(mapProps(
['visible'],
{ model: { prop: 'visible', event: 'visible-change' } },
)(_Popup));
export const Popup = withInstall(
mapProps(['visible'], { model: { prop: 'visible', event: 'visible-change' } })(_Popup),
);

export default Popup;
210 changes: 210 additions & 0 deletions src/popup/plugin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import Vue, { VNode } from 'vue';
import { createPopper, Instance } from '@popperjs/core';
import {
getAttach, once, on, off,
} from '../utils/dom';
import props from './props';
import { getClassPrefixMixins } from '../config-provider/config-receiver';
import { renderTNodeJSX } from '../utils/render-tnode';
import { getPopperPlacement, triggers } from './utils';
import mixins from '../utils/mixins';

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

export interface PopupPluginApi {
config: TdPopupProps;
}
const classPrefixMixins = getClassPrefixMixins('popup');

let popperInstance: Instance;
let overlayInstance: HTMLElement;
let timeout: NodeJS.Timeout;
let triggerEl: HTMLElement;

const triggerType = (triggerProps: string): Record<(typeof triggers)[number], boolean> => triggers.reduce(
(map, trigger) => ({
...map,
[trigger]: triggerProps.includes(trigger),
}),
{} as any,
);

const Overlay = mixins(classPrefixMixins).extend({
name: 'TPopupOverlay',
data() {
return {
visibleState: false,
contentClicked: false,
};
},
props: {
...props,
triggerEl: HTMLElement,
},
computed: {
hasTrigger(): Record<(typeof triggers)[number], boolean> {
return triggerType(this.trigger);
},
overlayClasses(): ClassName {
return [
`${this.componentName}__content`,
{
[`${this.componentName}__content--text`]: this.content === 'string',
[`${this.componentName}__content--arrow`]: this.showArrow,
[this.commonStatusClassName.disabled]: this.disabled,
},
this.overlayInnerClassName,
];
},
},

methods: {
handleDocumentClick(e: Event): void {
if (triggerEl?.contains(e.target as Node)) return;
if (this.contentClicked) {
setTimeout(() => {
this.contentClicked = false;
});
} else {
if (this.destroyOnClose) {
this.visibleState = false;
}
popperInstance?.destroy();
popperInstance = null;
triggerEl = null;
}
},
handleMouseLeave(): void {
if (this.destroyOnClose) {
this.visibleState = false;
}
popperInstance?.destroy();
popperInstance = null;
},
handleMouseEnter(): void {
clearTimeout(timeout);
},
},
created() {
this.visibleState = true;
},
mounted() {
setTimeout(() => {
on(document, 'click', this.handleDocumentClick);
});
},
beforeDestroy() {
off(document, 'click', this.handleDocumentClick);
},
render(h): VNode {
const content = renderTNodeJSX(this, 'content');

const hidePopup = this.hideEmptyPopup && ['', undefined, null].includes(content);
const {
handleMouseLeave, handleMouseEnter, visibleState, hasTrigger,
} = this;
const renderNode = h(
'div',
{
class: [this.componentName, this.overlayClassName],
ref: 'popper',
style: [
hidePopup && { visibility: 'hidden', pointerEvents: 'none' },
{ zIndex: this.zIndex },
this.overlayStyle,
],
on: {
mousedown: () => {
this.contentClicked = true;
},
...(hasTrigger.hover && {
mouseenter: handleMouseEnter,
mouseleave: handleMouseLeave,
}),
},
},
[
h(
'div',
{
ref: 'overlay',
class: this.overlayClasses,
style: this.overlayInnerStyle,
},
[content, this.showArrow && h('div', { class: `${this.componentName}__arrow` })],
),
],
);
return visibleState ? (
<transition slot="content" name={`${this.componentName}--animation`} appear>
{renderNode}
</transition>
) : null;
},
});

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

const triggerPopupPlugin = (trigger: string, content: TNode, popupProps: TdPopupProps) => {
const hasTrigger = triggerType(popupProps.trigger || 'hover');
const currentTriggerEl = getAttach(trigger);
if (triggerEl && hasTrigger.click) {
return;
}
triggerEl = currentTriggerEl;
removeOverlayInstance();

let attach = getAttach(popupProps.attach);

const delay = [].concat(popupProps.delay ?? [250, 150]);
const closeDelay = delay[1] ?? delay[0];
if (attach === document.body) {
// don't allow mount on body directly
const popupDom = document.createElement('div');
document.body.appendChild(popupDom);
attach = popupDom;
}

overlayInstance = new Overlay({
propsData: {
...popupProps,
content,
triggerEl,
},
}).$mount(attach).$el as HTMLElement;

if (hasTrigger.hover) {
const mouseoutEvent = () => {
timeout = setTimeout(removeOverlayInstance, closeDelay);
};
once(triggerEl, 'mouseleave', mouseoutEvent);
} else if (hasTrigger.focus) {
const focusoutEvent = () => {
timeout = setTimeout(removeOverlayInstance, closeDelay);
};
once(triggerEl, 'focusout', focusoutEvent);
}

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

Vue.prototype.$popup = triggerPopupPlugin;

declare module 'vue/types/vue' {
interface Vue {
$popup: PopupPluginApi;
}
}
10 changes: 10 additions & 0 deletions src/popup/popup.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,13 @@ name | params | description
scroll | `(context: { e: WheelEvent })` | \-
scroll-to-bottom | `(context: { e: WheelEvent })` | \-
visible-change | `(visible: boolean, context: PopupVisibleChangeContext)` | [see more ts definition](https://github.com/Tencent/tdesign-vue/tree/develop/src/popup/type.ts)。<br/>`interface PopupVisibleChangeContext { e?: PopupTriggerEvent; trigger?: PopupTriggerSource }`<br/><br/>`type PopupTriggerEvent = MouseEvent \| FocusEvent \| KeyboardEvent`<br/><br/>`type PopupTriggerSource = 'document' \| 'trigger-element-click' \| 'trigger-element-hover' \| 'trigger-element-blur' \| 'trigger-element-focus' \| 'trigger-element-mousedown' \| 'context-menu' \| 'keydown-esc'`<br/>

### PopupPlugin

support `this.$popup`

name | params | default | description
-- | -- | -- | --
content | String / Slot / Function | - | required。Typescript:`string \| TNode`[see more ts definition](https://github.com/Tencent/tdesign-vue/blob/develop/src/common.ts)
popupProps | Object | - | \-
triggerElement | String | - | required
18 changes: 18 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的渲染节点。

使用方式:`this.$popup(triggerElement, content, popupProps)`

{{ plugin }}

## API
### Popup Props

Expand Down Expand Up @@ -35,3 +43,13 @@ onVisibleChange | Function | | TS 类型:`(visible: boolean, context: PopupVi
scroll | `(context: { e: WheelEvent })` | 下拉选项滚动事件
scroll-to-bottom | `(context: { e: WheelEvent })` | 下拉滚动触底事件,常用于滚动到底执行具体业务逻辑
visible-change | `(visible: boolean, context: PopupVisibleChangeContext)` | 当浮层隐藏或显示时触发,`trigger=document` 表示点击非浮层元素触发;`trigger=context-menu` 表示右击触发。[详细类型定义](https://github.com/Tencent/tdesign-vue/tree/develop/src/popup/type.ts)。<br/>`interface PopupVisibleChangeContext { e?: PopupTriggerEvent; trigger?: PopupTriggerSource }`<br/><br/>`type PopupTriggerEvent = MouseEvent \| FocusEvent \| KeyboardEvent`<br/><br/>`type PopupTriggerSource = 'document' \| 'trigger-element-click' \| 'trigger-element-hover' \| 'trigger-element-blur' \| 'trigger-element-focus' \| 'trigger-element-mousedown' \| 'context-menu' \| 'keydown-esc'`<br/>

### PopupPlugin

同时也支持 `this.$popup`

参数名称 | 参数类型 | 参数默认值 | 参数说明
-- | -- | -- | --
content | String / Slot / Function | - | 必需。气泡框的内容。TS 类型:`string \| TNode`[通用类型定义](https://github.com/Tencent/tdesign-vue/blob/develop/src/common.ts)
popupProps | Object | - | 透传气泡框/浮层的属性
triggerElement | String | - | 必需。触发气泡框/浮层的元素,传入选择器即可
29 changes: 3 additions & 26 deletions src/popup/popup.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { VNodeDirective } from 'vue';
import { createPopper, Placement } from '@popperjs/core';
import { createPopper } from '@popperjs/core';
import { on, off, once } from '../utils/dom';
import { renderTNodeJSX, renderContent } from '../utils/render-tnode';
import { getIEVersion } from '../utils/helper';
Expand All @@ -10,32 +10,12 @@ import Container from './container';
import { getClassPrefixMixins } from '../config-provider/config-receiver';
import mixins from '../utils/mixins';
import { emitEvent } from '../utils/event';
import { getPopperPlacement, attachListeners, triggers } from './utils';

const classPrefixMixins = getClassPrefixMixins('popup');

const triggers = ['click', 'hover', 'focus', 'context-menu'] as const;
const injectionKey = '__T_POPUP';

function getPopperPlacement(placement: TdPopupProps['placement']) {
return placement.replace(/-(left|top)$/, '-start').replace(/-(right|bottom)$/, '-end') as Placement;
}

function attachListeners(elm: Element) {
const offs: Array<() => void> = [];
return {
add<K extends keyof HTMLElementEventMap>(type: K, listener: (ev: HTMLElementEventMap[K]) => void) {
on(elm, type, listener);
offs.push(() => {
off(elm, type, listener);
});
},
clean() {
offs.forEach((handler) => handler?.());
offs.length = 0;
},
};
}

export default mixins(classPrefixMixins).extend({
name: 'TPopup',

Expand Down Expand Up @@ -88,7 +68,7 @@ export default mixins(classPrefixMixins).extend({
this.overlayInnerClassName,
];
},
hasTrigger(): Record<typeof triggers[number], boolean> {
hasTrigger(): Record<(typeof triggers)[number], boolean> {
return triggers.reduce(
(map, trigger) => ({
...map,
Expand Down Expand Up @@ -201,7 +181,6 @@ export default mixins(classPrefixMixins).extend({
this.popper.update();
return;
}

this.popper = createPopper(triggerEl, popperEl, {
modifiers:
getIEVersion() > 9
Expand Down Expand Up @@ -380,7 +359,6 @@ export default mixins(classPrefixMixins).extend({
const ref = renderContent(this, 'default', 'triggerElement');
const content = renderTNodeJSX(this, 'content');
const hidePopup = this.hideEmptyPopup && ['', undefined, null].includes(content);

const overlay = visible || !destroyOnClose
? h(
'div',
Expand Down Expand Up @@ -429,7 +407,6 @@ export default mixins(classPrefixMixins).extend({
],
)
: null;

return (
<Container
ref="container"
Expand Down
2 changes: 1 addition & 1 deletion src/popup/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default {
},
/** 浮层出现位置 */
placement: {
type: String,
type: String as PropType<TdPopupProps['placement']>,
default: 'top',
},
/** popper 初始化配置,详情参考 https://popper.js.org/docs/ */
Expand Down
2 changes: 2 additions & 0 deletions src/popup/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import { TNode, ClassName, Styles, AttachNode } from '../common';

export type PopupMethod = (triggerElement: string, content: string | TNode, popupProps?: object) => void;

export interface TdPopupProps {
/**
* 制定挂载节点。数据类型为 String 时,会被当作选择器处理,进行节点查询。示例:'body' 或 () => document.body
Expand Down
Loading

0 comments on commit 9952c61

Please sign in to comment.