Browse Source

feat: 完善popup

杨凯旋 5 years ago
parent
commit
09848c9e73

+ 104 - 47
src/packages/popup/__test__/popup.spec.js

@@ -1,52 +1,109 @@
-import { mount } from '@vue/test-utils'
-import popup from '../popup.vue'
+import { mount } from '@vue/test-utils';
+import Popup ,{popupProps} from '../popup.vue';
+import { overlayProps } from '../overlay/overlay.vue';
 import Vue from 'vue';
-import overlay from "../overlay.vue";
-import Icon from '../../icon/icon.vue';
-describe('Menu.vue',() => {
-    const wrapper = mount(popup, {
-        
-    });
+let wrapper,allProps = {}; 
+Object.assign(allProps,overlayProps,popupProps)
+function getProps() {
+  let obj = {};
+
+  Object.keys(allProps).forEach(res => {
+    if (res !== 'value') {
+      obj[res] = this[res];
+    }
+  });
+  return obj;
+}
+describe('popup.vue', () => {
+  afterEach(function() {
+    wrapper.destroy();
+  });
+  const component = {
+    template: `<div>
+                <popup v-model='popupVal' v-bind="maskProps" ></popup>
+            </div>`,
+    components: {
+      Popup
+    },
+    data() {
+      return {
+        maskProps: getProps.apply(this),
+        popupVal: this.value
+      };
+    },
+    watch: {
+      value(v) {
+        this.popupVal = v;
+      }
+    },
+    props: { ...allProps, closeable: Boolean }
+  };
+
+    it('1.render popup && overlay', async function() {
+      wrapper = mount(component, { propsData: { value: true } });
+      wrapper.setProps({ value: true });
+      await Vue.nextTick();
+      expect(wrapper.find('.popup-box').isVisible()).toBe(true);
+      expect(wrapper.find('.nut-mask').isVisible()).toBe(true);
 
-    it('1.判断是否显示内容',() => {
-        wrapper.setData({
-            value:true
-        }); 
-        return Vue.nextTick().then(function() {
-            expect(wrapper.classes()).toContain('popup-box')
-        }) 
     });
-    it('2.判断弹出位置',() => {
-        wrapper.setData({ 
-            position:"top"
-        }); 
-        return Vue.nextTick().then(function() {
-            expect(wrapper.classes()).toContain('popup-top')
-        }) 
+
+    it('2.test props overlay', async function() {
+      wrapper = mount(component, { propsData: { value: true, overlay: false } });
+      await Vue.nextTick();
+      expect(wrapper.contains('.nut-mask')).toBe(false);
+
     });
-    it('3.判断是否有关闭图标',() => {
-        wrapper.setData({ 
-            closeable:true
-        });  Icon
-        let i = wrapper.find('.nutui-popup__close-icon')
-        return Vue.nextTick().then(function() {
-            expect(i.is(Icon)).toBe(true)
-        }) 
+
+    it('3.test props lockScroll', async function() {
+      const wrapper1 = mount(component, { propsData: { value: true } });
+      const wrapper2 = mount(component, { propsData: { value: true } });
+      expect(document.body.classList.contains('nut-overflow-hidden')).toBe(true);
+
+      wrapper1.destroy();
+      await Vue.nextTick();
+      expect(document.body.classList.contains('nut-overflow-hidden')).toBe(true);
+      wrapper2.destroy();
+      await Vue.nextTick();
+      expect(document.body.classList.contains('nut-overflow-hidden')).toBe(false);
+
     });
-    // it('4.判断点击关闭按钮',() => {
-    //     wrapper.setData({ 
-    //         closeable:true
-    //     }); 
-    //     let i = wrapper.find('.nutui-popup__close-icon');
-    //     i.trigger('click')
-    //     console.log()
-    //     return Vue.nextTick().then(function() {
-    //         setTimeout(()=>{
-    //             expect(wrapper.contains(popup)).toBe(false)
-    //         },wrapper.duration*1000)           
-    //     }) 
-    // });
-
- 
- 
-});
+
+  it('4.test closeOnClickOverlay', async function() {
+    wrapper = mount(component, { propsData: { value: true, closeOnClickOverlay: true } });
+    await Vue.nextTick();
+    wrapper.find('.nut-mask').trigger('click');
+    await Vue.nextTick();
+    const duration = wrapper.vm.duration * 1500; 
+    await new Promise(resolve => {
+      setTimeout(() => resolve(), duration);
+    }); 
+    
+    expect(wrapper.find('.nut-mask').isVisible() || wrapper.find('.popup-box').isVisible()).toBe(false); 
+  });
+  it('5.test closeOnClickOverlay', async function() {
+    wrapper = mount(component, { propsData: { value: true, closeable: true } });
+    await Vue.nextTick();
+    let i = wrapper.find('.nutui-popup__close-icon');
+    i.trigger('click');
+    await Vue.nextTick();
+    await new Promise(resolve => {
+      setTimeout(() => resolve(), wrapper.duration * 1000);
+    });  
+     
+    expect(wrapper.find('.popup-box').isVisible()).toBe(false);
+  });
+
+   
+  it('6.test top ', async function()  {
+      wrapper = mount(component, { propsData: { value: true, position:"top"}});
+      await Vue.nextTick();  
+      expect(wrapper.find('.popup-box').classes()).toContain('popup-top')
+  });
+  it('7. zindex ',async function()  {
+    wrapper = mount(component, { propsData: { value: true,zIndex:999}});
+      const  wrapper2 = mount(component, { propsData: { value: true}});
+      await Vue.nextTick();  
+      expect(wrapper.find('.popup-box').vm.zIndex).toBe(999) 
+  });
+});

+ 116 - 22
src/packages/popup/demo.vue

@@ -2,43 +2,137 @@
   <div>
     <h2 class="title">基本用法</h2>
     <div>
-      <nut-cell isLink title="展示弹出层" :showIcon="true" @click.native="showBasic = true"> </nut-cell>
+        <nut-cell
+        isLink
+        title="展示弹出层"
+        :showIcon="true"
+        @click.native="showBasic = true"
+        >
+        </nut-cell>
     </div>
-    <nut-popup :style="{ padding: '30px 50px' }" v-model="showBasic">正文</nut-popup>
+    <nut-popup :style="{ padding: '30px 50px' }"   v-model="showBasic" >正文</nut-popup>
 
     <h2 class="title">弹出位置</h2>
     <div>
-      <nut-cell isLink title="顶部弹出" :showIcon="true" @click.native="showTop = true"> </nut-cell>
-      <nut-popup position="top" v-model="showTop" :style="{ height: '20%' }"> </nut-popup>
-      <nut-cell isLink title="底部弹出" :showIcon="true" @click.native="showBottom = true"> </nut-cell>
-      <nut-popup v-model="showBottom" position="bottom" :style="{ height: '20%' }"> </nut-popup>
-      <nut-cell isLink title="左侧弹出" :showIcon="true" @click.native="showLeft = true"> </nut-cell>
-      <nut-popup :style="{ width: '20%', height: '100%' }" v-model="showLeft" position="left"></nut-popup>
-      <nut-cell isLink title="右侧弹出" :showIcon="true" @click.native="showRight = true"> </nut-cell>
-      <nut-popup position="right" v-model="showRight" :style="{ width: '20%', height: '100%' }"></nut-popup>
+        <nut-cell
+        isLink
+        title="顶部弹出"
+        :showIcon="true"
+        @click.native="showTop = true"
+        >
+        </nut-cell>
+        <nut-popup position="top" v-model="showTop" :style="{ height: '20%' }">
+        </nut-popup>
+        <nut-cell
+        isLink
+        title="底部弹出"
+        :showIcon="true"
+        @click.native="showBottom = true"
+        >
+        </nut-cell>
+        <nut-popup
+        v-model="showBottom"
+        position="bottom"
+        :style="{ height: '20%' }"
+        >
+        </nut-popup>
+        <nut-cell
+        isLink
+        title="左侧弹出"
+        :showIcon="true"
+        @click.native="showLeft = true"
+        >
+        </nut-cell>
+        <nut-popup
+        :style="{ width: '20%', height: '100%' }"
+        v-model="showLeft"
+        position="left"
+        ></nut-popup>
+        <nut-cell
+        isLink
+        title="右侧弹出"
+        :showIcon="true"
+        @click.native="showRight = true"
+        >
+        </nut-cell>
+        <nut-popup
+        position="right"
+        v-model="showRight"
+        :style="{ width: '20%', height: '100%' }"
+        ></nut-popup>
     </div>
     <h2 class="title">关闭图标</h2>
     <div>
-      <nut-cell isLink title="关闭图标" :showIcon="true" @click.native="showIcon = true"> </nut-cell>
-      <nut-popup position="bottom" closeable v-model="showIcon" :style="{ height: '20%' }"></nut-popup>
+        <nut-cell
+        isLink
+        title="关闭图标"
+        :showIcon="true"
+        @click.native="showIcon = true">
+        </nut-cell>
+        <nut-popup
+        position="bottom"
+        closeable 
+        v-model="showIcon"
+        :style="{ height: '20%' }"
+        ></nut-popup>
 
-      <nut-cell isLink title="图标位置" :showIcon="true" @click.native="showIconPosition = true"> </nut-cell>
-      <nut-popup position="bottom" closeable close-icon-position="top-left" v-model="showIconPosition" :style="{ height: '20%' }"></nut-popup>
+        <nut-cell
+        isLink
+        title="图标位置"
+        :showIcon="true"
+        @click.native="showIconPosition = true">
+        </nut-cell>
+        <nut-popup
+        position="bottom"
+        closeable 
+        close-icon-position="top-left"
+        v-model="showIconPosition"
+        :style="{ height: '20%' }"
+        ></nut-popup>
 
-      <nut-cell isLink title="自定义图标" :showIcon="true" @click.native="showCloseIcon = true"> </nut-cell>
-      <nut-popup position="bottom" closeable close-icon="tick" v-model="showCloseIcon" :style="{ height: '20%' }"></nut-popup>
+
+        <nut-cell
+        isLink
+        title="自定义图标"
+        :showIcon="true"
+        @click.native="showCloseIcon = true">
+        </nut-cell>
+        <nut-popup
+        position="bottom"
+        closeable 
+        close-icon="tick"
+        v-model="showCloseIcon"
+        :style="{ height: '20%' }"
+        ></nut-popup>
     </div>
 
     <h2 class="title">圆角弹框</h2>
     <div>
-      <nut-cell isLink title="圆角弹框" :showIcon="true" @click.native="showRound = true"> </nut-cell>
-      <nut-popup round v-model="showRound" position="bottom" :style="{ height: '20%' }"></nut-popup>
+        <nut-cell
+        isLink
+        title="圆角弹框"
+        :showIcon="true"
+        @click.native="showRound = true"
+        >
+        </nut-cell>
+        <nut-popup
+        round
+        v-model="showRound"
+        position="bottom"
+        :style="{ height: '20%' }"
+        ></nut-popup>
     </div>
     <h2 class="title">指定挂载节点</h2>
     <div>
-      <nut-cell isLink title="指定挂载节点" :showIcon="true" @click.native="getContainer = true"> </nut-cell>
+        <nut-cell
+        isLink
+        title="指定挂载节点"
+        :showIcon="true"
+        @click.native="getContainer = true"
+        >
+        </nut-cell>
     </div>
-    <nut-popup :style="{ padding: '30px 50px' }" get-container="body" v-model="getContainer">body</nut-popup>
+    <nut-popup :style="{ padding: '30px 50px' }"  get-container="body"  v-model="getContainer" >body</nut-popup>
   </div>
 </template>
 <script>
@@ -55,10 +149,10 @@ export default {
       showRound: false,
       showIconPosition: false,
       showCloseIcon: false,
-      getContainer: false
+      getContainer:false
     };
   },
-  methods: {
+  methods: {     
     show() {
       this.isShow = true;
     }

+ 4 - 2
src/packages/popup/index.js

@@ -1,8 +1,10 @@
-import Popup from './popup.vue';
-import './popup.scss';
+import Popup from "./popup.vue";
+import "./popup.scss";
+import { overlayProps ,getProps} from "./overlay/overlay-manager";
 
 Popup.install = function(Vue) {
   Vue.component(Popup.name, Popup);
 };
 
 export default Popup;
+export {overlayProps, getProps};

+ 0 - 31
src/packages/popup/overlay.vue

@@ -1,31 +0,0 @@
-<template>
-  <transition name="popup-fade">
-    <div
-      @touchmove.stop="touchmove"
-      :style="{ animationDuration: `${duration}s`, customStyle }"
-      v-show="show"
-      class="popup-bg nut-mask"
-      :class="className"
-    ></div>
-  </transition>
-</template>
-<script>
-export default {
-  name: 'nut-popup-mask',
-
-  props: {
-    lockScroll: { type: Boolean, default: true },
-    show: { type: Boolean, default: false },
-    duration: Number,
-    className: { type: String, default: '' },
-    customStyle: { type: String, default: '' }
-  },
-  methods: {
-    touchmove(e) {
-      if (this.lockScroll) {
-        e.preventDefault();
-      }
-    }
-  }
-};
-</script>

+ 140 - 0
src/packages/popup/overlay/overlay-manager.js

@@ -0,0 +1,140 @@
+import Vue from "vue";
+import overlayComponent from "./overlay.vue";
+
+let modalStack = [];
+let _zIndex = 2000;
+let overlay;
+const overlayManager = { 
+
+  lockCount: 0,
+
+  get zIndex() {
+    return ++_zIndex;
+  },
+  get topStack() {
+    return modalStack[modalStack.length - 1];
+  },
+
+  updateOverlay() {
+    const {  clickHandle, topStack } = overlayManager;
+    if (!overlay) {
+      overlay = mount(overlayComponent, {
+        nativeOn: {
+          click: clickHandle,
+        },
+      });
+    } 
+ 
+    if (topStack) {
+      const { vm, config } = topStack;
+      const el = vm.$el;
+      el && el.parentNode && el.parentNode.nodeType !== 11
+        ? el.parentNode.appendChild(overlay.$el)
+        : document.body.appendChild(overlay.$el);
+      
+      Object.assign(overlay, config, {
+        value: true,
+      }); 
+    } else { 
+      overlay.value = false;
+    }
+  },
+
+  //打开遮罩层
+  openModal(vm, config) {
+    let { zIndex, duration, className, customStyle } = config;
+
+    modalStack.push({
+      vm,
+      config: {
+        zIndex,
+        duration,
+        className,
+        customStyle,
+      },
+    });
+
+    overlayManager.updateOverlay();
+  },
+
+  clickHandle() {
+    const { topStack } = overlayManager;
+    
+    //防止多次点击
+    if (modalStack.length && topStack.vm.closeOnClickOverlay) { 
+      topStack.vm.$emit("click-overlay");
+      topStack.vm.close();
+    }
+  },
+
+  closeOverlay(vm) {
+    if (modalStack.length) {
+      if (overlayManager.topStack.vm === vm) {
+        modalStack.pop();
+        overlayManager.updateOverlay();
+      } else {
+        modalStack = modalStack.filter((item) => item.vm !== vm);
+      }
+    }
+  },
+};
+
+const overlayProps = {
+  value: {
+    type: Boolean,
+    default: false,
+  },
+  overlay: {
+    type: Boolean,
+    default: true,
+  },
+  lockScroll: {
+    type: Boolean,
+    default: true,
+  },
+  duration: {
+    type: Number,
+    default: 0.3,
+  },
+  closeOnClickOverlay: {
+    type: Boolean,
+    default: true,
+  },
+  overlayClass: {
+    type: String,
+    default: "",
+  },
+  overlayStyle: {
+    type: String,
+    default: "",
+  },
+  zIndex: {
+    type: Number
+  },
+};
+
+function mount(Component, data) {
+
+  const instance = new Vue({ 
+    props: Component.props,
+    render(h) {    
+      return h(Component, {
+        props:this.$props,
+        ...data,
+      });
+    },
+  }).$mount();
+  return instance;
+}
+
+function getProps(){
+  
+  if(!this)return {}
+  let obj = {};
+    Object.keys(overlayProps).forEach(res=>{
+        obj[res] = this[res]
+  }) 
+  return obj
+}
+
+export  {overlayManager ,overlayProps, getProps};

+ 58 - 0
src/packages/popup/overlay/overlay.vue

@@ -0,0 +1,58 @@
+<template>
+  <transition name="popup-fade">
+    <div
+      @touchmove.stop="touchmove"
+      :style="{ animationDuration: `${duration}s`, overlayStyle, zIndex }"
+      v-show="value"
+      class="popup-bg nut-mask"
+      :class="overlayClass"
+    ></div>
+  </transition>
+</template>
+<script>
+const overlayProps = {
+  value: {
+    type: Boolean,
+    default: false,
+  },
+  overlay: {
+    type: Boolean,
+    default: true,
+  },
+  lockScroll: {
+    type: Boolean,
+    default: true,
+  },
+  duration: {
+    type: Number,
+    default: 0.3,
+  },
+  closeOnClickOverlay: {
+    type: Boolean,
+    default: true,
+  },
+  overlayClass: {
+    type: String,
+    default: "",
+  },
+  overlayStyle: {
+    type: String,
+    default: "",
+  },
+  zIndex: {
+    type: Number
+  },
+};
+export { overlayProps };
+export default {
+  name: "nut-popup-overlay",
+  props: overlayProps,
+  methods: {
+    touchmove(e) {
+      if (this.lockScroll) {
+        e.preventDefault();
+      }
+    },
+  },
+};
+</script>

+ 120 - 116
src/packages/popup/popup.vue

@@ -23,93 +23,80 @@
 </template>
 <script>
 import Vue from 'vue';
-import overlay from './overlay.vue';
 import Icon from '../icon/icon.vue';
+import touchMixins from '../../mixins/touch.js';
+import { overlayManager, overlayProps } from './overlay/overlay-manager.js';
+import { on, off } from '../../utils/event';
 import '../icon/icon.scss';
+
+const overflowScrollReg = /scroll|auto/i;
+const popupProps = {
+  position: {
+    type: String,
+    default: 'center'
+  },
+
+  transition: String,
+
+  closeable: {
+    type: Boolean,
+    default: false
+  },
+  closeIconPosition: {
+    type: String,
+    default: 'top-right'
+  },
+  closeIcon: {
+    type: String,
+    default: 'cross'
+  },
+
+  closeOnClickOverlay: {
+    type: Boolean,
+    default: true
+  },
+
+  destroyOnClose: {
+    type: Boolean,
+    default: false
+  },
+  getContainer: String,
+  round: {
+    type: Boolean,
+    default: false
+  }
+};
 export default {
   name: 'nut-popup',
+  mixins: [touchMixins],
   components: {
     icon: Icon
   },
   props: {
-    value: {
-      type: Boolean,
-      default: false
-    },
-    position: {
-      type: String,
-      default: 'center'
-    },
-    duration: {
-      type: Number,
-      default: 0.3
-    },
-    transition: String,
-    overlay: {
-      type: Boolean,
-      default: true
-    },
-    closeable: {
-      type: Boolean,
-      default: false
-    },
-    closeIconPosition: {
-      type: String,
-      default: 'top-right'
-    },
-    closeIcon: {
-      type: String,
-      default: 'cross'
-    },
-    lockScroll: {
-      type: Boolean,
-      default: true
-    },
-    closeOnClickOverlay: {
-      type: Boolean,
-      default: true
-    },
-    overlayClass: {
-      type: String,
-      default: ''
-    },
-    overlayStyle: {
-      type: String,
-      default: ''
-    },
-    destroyOnClose: {
-      type: Boolean,
-      default: false
-    },
-    getContainer: String,
-    round: {
-      type: Boolean,
-      default: false
-    }
+    ...overlayProps,
+    ...popupProps
   },
   created() {
     this.transition ? (this.transitionName = this.transition) : (this.transitionName = `popup-slide-${this.position}`);
   },
   mounted() {
-    this.mountOverlay();
-    if (this.getContainer) {
-      this.portal();
-    }
     if (this.value) {
       this.open();
     }
   },
+  beforeDestroy() {
+    this.close();
+  },
   watch: {
     value(val) {
       const type = val ? 'open' : 'close';
-      if (this.overlay) {
-        this[type]();
-      }
+      this[type]();
     },
     position(val) {
       val === 'center' ? (this.transitionName = 'popup-fade') : (this.transitionName = `popup-slide-${this.position}`);
     },
-    getContainer: 'portal'
+    getContainer: 'portal',
+    overlay: 'renderOverlay'
   },
   data() {
     return {
@@ -123,75 +110,91 @@ export default {
       return this.duration ? this.duration + 's' : 'initial';
     }
   },
+
   methods: {
-    mountOverlay() {
-      if (!this.overlayInstant) {
-        this.overlayInstant = this.mount(overlay, {
-          duration: this.duration,
-          nativeOn: {
-            click: () => {
-              this.$emit('click-overlay', this);
-              if (this.closeOnClickOverlay) {
-                this.$emit('input', false);
-              }
-            }
-          }
-        });
+    open() {
+      if (this.opened) {
+        return;
       }
-    },
-    mount(Component, data) {
-      const instance = new Vue({
-        el: document.createElement('div'),
-        props: Component.props,
-        render(h) {
-          return h(Component, {
-            props: this.$props,
-            ...data
-          });
+
+      this.opened = true;
+      this.$emit('open');
+
+      const { duration, overlayClass, overlayStyle, lockScroll, closeOnClickOverlay } = this;
+      const config = {
+        zIndex: this.zIndex ? this.zIndex : overlayManager.zIndex,
+        duration,
+        overlayClass,
+        overlayStyle,
+        lockScroll,
+        closeOnClickOverlay
+      };
+
+      this.renderOverlay(config);
+
+      if (this.lockScroll) {
+        on(document, 'touchstart', this.touchStart);
+        on(document, 'touchmove', this.onTouchMove);
+
+        if (!overlayManager.lockCount) {
+          document.body.classList.add('nut-overflow-hidden');
         }
-      });
-      instance.duration = this.duration;
-      instance.lockScroll = this.lockScroll;
-      instance.className = this.overlayClass;
-      instance.customStyle = this.overlayStyle;
-      const el = this.$refs.popupBox;
-      if (el && el.parentNode) {
-        el.parentNode.insertBefore(instance.$el, el);
-      } else {
-        document.body.appendChild(instance.$el);
+        overlayManager.lockCount++;
       }
-      return instance;
+
+      this.$el.style.zIndex = this.zIndex ? this.zIndex + 1 : overlayManager.zIndex;
     },
+    renderOverlay(config) {
+      if (!this.value) {
+        return;
+      }
 
-    open() {
-      if (!this.overlayInstant) {
-        this.mountOverlay();
+      if (this.overlay) {
+        overlayManager.openModal(this, config);
       } else {
-        this.overlayInstant.show = true;
-        this.showSlot = true;
+        overlayManager.closeOverlay(this);
       }
+    },
+    onTouchMove(event) {
+      this.touchMove(event);
+      const el = this.getScroller(event.target);
+      const { scrollHeight, offsetHeight, scrollTop } = el ? el : this.$el;
 
-      if (this.lockScroll && !this.locked) {
-        document.body.classList.add('nut-overflow-hidden');
-        this.locked = true;
+      if ((this.deltaY > 0 && scrollTop === 0) || (this.deltaY < 0 && scrollTop + offsetHeight >= scrollHeight)) {
+        //event.preventDefault();
       }
+    },
+    getScroller(el) {
+      let node = el;
+      while (node && node.tagName !== 'HTML' && node.nodeType === 1) {
+        const { overflowY } = window.getComputedStyle(node);
 
-      this.$emit('open', this);
+        if (overflowScrollReg.test(overflowY)) {
+          return node;
+        }
+
+        node = node.parentNode;
+      }
     },
     close() {
-      this.overlayInstant.show = false;
-      if (this.destroyOnClose) {
-        setTimeout(() => {
-          this.showSlot = false;
-        }, this.duration * 1000);
+      if (!this.opened) {
+        return;
       }
-
-      if (this.lockScroll && this.locked) {
-        document.body.classList.remove('nut-overflow-hidden');
-        this.locked = false;
+      this.$emit('close');
+      this.opened = false;
+      if (this.lockScroll) {
+        overlayManager.lockCount--;
+        off(document, 'touchstart', this.touchStart);
+        off(document, 'touchmove', this.onTouchMove);
+        if (!overlayManager.lockCount) {
+          document.body.classList.remove('nut-overflow-hidden');
+        }
       }
-      this.$emit('close', this);
+
+      overlayManager.closeOverlay(this);
+      this.$emit('input', false);
     },
+
     getElement(selector) {
       return document.querySelector(selector);
     },
@@ -212,4 +215,5 @@ export default {
     }
   }
 };
+export  {popupProps}
 </script>