Browse Source

Merge branch 'next' of https://github.com/jdf2e/nutui into next

Drjnigfubo 3 years ago
parent
commit
f895a02be2

File diff suppressed because it is too large
+ 1195 - 0
src/packages/__VUE/calendar/__tests__/__snapshots__/calendar.spec.ts.snap


+ 119 - 0
src/packages/__VUE/calendar/__tests__/calendar.spec.ts

@@ -0,0 +1,119 @@
+import { mount, config } from '@vue/test-utils';
+import Calendar from '../index.vue';
+import { nextTick, toRefs, reactive } from 'vue';
+import Icon from '../../icon/index.vue';
+import PopUp from '../../popup/index.vue';
+import Button from '../../button/index.vue';
+import OverLay from '../../overLay/index.vue';
+import CalendarItem from '../../calendaritem/index.vue';
+
+beforeAll(() => {
+  config.global.components = {
+    [Icon.name]: Icon,
+    [PopUp.name]: PopUp,
+    [Button.name]: Button,
+    [OverLay.name]: OverLay,
+    [CalendarItem.name]: CalendarItem
+  };
+});
+test('show-title prop', async () => {
+  const wrapper = mount(Calendar, {
+    props: {
+      poppable: false,
+      defaultValue: '2022-03-18',
+      showTitle: true,
+      startDate: '2022-01-01',
+      endDate: '2022-12-31'
+    }
+  });
+  await nextTick();
+  expect(wrapper.find('.calendar-title').exists()).toBeTruthy();
+  await wrapper.setProps({ showTitle: false });
+  expect(wrapper.find('.calendar-title').exists()).toBeFalsy();
+});
+test('show-sub-title prop', async () => {
+  const wrapper = mount(Calendar, {
+    props: {
+      poppable: false,
+      defaultValue: '2022-03-18',
+      showSubTitle: true,
+      startDate: '2022-01-01',
+      endDate: '2022-12-31'
+    }
+  });
+  await nextTick();
+  expect(wrapper.find('.calendar-curr-month').exists()).toBeTruthy();
+  await wrapper.setProps({ showSubTitle: false });
+  expect(wrapper.find('.calendar-curr-month').exists()).toBeFalsy();
+});
+test('show-today prop', async () => {
+  const wrapper = mount(Calendar, {
+    props: {
+      poppable: false,
+      defaultValue: '2022-03-18',
+      showToday: true,
+      startDate: '2022-01-01',
+      endDate: '2022-12-31'
+    }
+  });
+  await nextTick();
+  expect(wrapper.find('.calendar-curr-tip-curr').exists()).toBeTruthy();
+  await wrapper.setProps({ showToday: false });
+  expect(wrapper.find('.calendar-curr-tip-curr').exists()).toBeFalsy();
+});
+
+test('should render slot correctly', async () => {
+  const wrapper = mount(Calendar, {
+    props: {
+      poppable: false,
+      defaultValue: '2022-03-18',
+      startDate: '2022-01-01',
+      endDate: '2022-12-31'
+    },
+    slots: {
+      btn: '<div class="d_div"> 最近七天</div>',
+      day: (date) => {
+        return `custom${date.date.day}`;
+      },
+      bottomInfo: (date) => `${date.date ? (date.date.day <= 10 ? '上旬' : date.date.day <= 20 ? '中旬' : '下旬') : ''}`
+    }
+  });
+  await nextTick();
+  expect(wrapper.find('.calendar-top-slot').html()).toContain('<div class="d_div"> 最近七天</div>');
+  expect(wrapper.find('.viewArea').html()).toMatchSnapshot();
+});
+
+test('select event when click item', async () => {
+  const wrapper = mount(Calendar, {
+    props: {
+      poppable: false,
+      defaultValue: '2022-03-18',
+      showToday: true,
+      isAutoBackFill: true,
+      startDate: '2022-01-01',
+      endDate: '2022-12-31'
+    }
+  });
+
+  await nextTick();
+  wrapper.findAll('.calendar-month-day')[15].trigger('click');
+  let arr: any = wrapper.emitted<[Date]>('select')![0][0];
+  expect(arr[3]).toEqual('2022-02-14');
+});
+test('choose event when click item', async () => {
+  const wrapper = mount(Calendar, {
+    props: {
+      poppable: false,
+      defaultValue: '2022-03-18',
+      showToday: true,
+      isAutoBackFill: true,
+      startDate: '2022-01-01',
+      endDate: '2022-12-31'
+    }
+  });
+
+  await nextTick();
+  wrapper.findAll('.calendar-month-day')[15].trigger('click');
+  let arr2: any = wrapper.emitted<[Date]>('choose')![0][0];
+  expect(arr2[3]).toEqual('2022-02-14');
+});

+ 1 - 0
src/packages/__VUE/calendar/index.taro.vue

@@ -62,6 +62,7 @@
     @select="select"
     :show-title="showTitle"
     :show-sub-title="showSubTitle"
+    :show-today="showToday"
   >
     <template v-slot:btn v-if="showTopBtn">
       <slot name="btn"> </slot>

+ 3 - 1
src/packages/__VUE/calendar/index.vue

@@ -22,13 +22,13 @@
       :start-text="startText"
       :end-text="endText"
       :default-value="defaultValue"
-      :show-today="showToday"
       :start-date="startDate"
       :end-date="endDate"
       @update="update"
       @close="close"
       @choose="choose"
       @select="select"
+      :show-today="showToday"
       :show-title="showTitle"
       :show-sub-title="showSubTitle"
     >
@@ -58,9 +58,11 @@
     :default-value="defaultValue"
     :start-date="startDate"
     :end-date="endDate"
+    @update="update"
     @close="close"
     @choose="choose"
     @select="select"
+    :show-today="showToday"
     :show-title="showTitle"
     :show-sub-title="showSubTitle"
   >

+ 2 - 1
src/packages/__VUE/fixednav/__tests__/fixednav.spec.ts

@@ -51,9 +51,10 @@ describe('FixedNav', () => {
     expect(_html1.exists()).toBe(true);
     const _html = wrapper.find('.nut-fixednav__btn');
     expect(_html.html()).toContain('展开');
+    console.log(_html.html(), '00000');
     wrapper.find('.nut-fixednav__btn').trigger('click');
     await nextTick();
-    // expect(_html.html()).toContain('收起');
+    expect(wrapper.emitted('update:visible')![0]).toEqual([true]);
   });
 
   test('should be displayed after setting the position', () => {

+ 385 - 0
src/packages/__VUE/form/__test__/index.spec.ts

@@ -0,0 +1,385 @@
+import { config, DOMWrapper, mount } from '@vue/test-utils';
+import Form from '../index.vue';
+import NutCellGroup from '../../cellgroup/index.vue';
+import FormItem from '../../formitem/index.vue';
+import NutCell from '../../cell/index.vue';
+import NutIcon from '../../icon/index.vue';
+import NutButton from '../../button/index.vue';
+import NutTextarea from '../../textarea/index.vue';
+import NutSwitch from '../../switch/index.vue';
+import NutCheckbox from '../../checkbox/index.vue';
+// import NutRadio from '../../radio/index.vue';
+// import NutRadioGroup from '../../radiogroup/index.vue';
+import NutRate from '../../rate/index.vue';
+import NutInputnumber from '../../inputnumber/index.vue';
+import NutRange from '../../range/index.vue';
+import NutUploader from '../../uploader/index.vue';
+import NutAddress from '../../address/index.vue';
+import NutElevator from '../../elevator/index.vue';
+import NutProgress from '../../progress/index.vue';
+import NutPopup from '../../popup/index.vue';
+import { nextTick, toRefs, ref, reactive } from 'vue';
+
+beforeAll(() => {
+  config.global.components = {
+    NutCellGroup,
+    FormItem,
+    NutCell,
+    NutIcon,
+    NutButton,
+    NutTextarea,
+    NutSwitch,
+    NutCheckbox,
+    // NutRadio,
+    // NutRadioGroup,
+    NutRate,
+    NutInputnumber,
+    NutRange,
+    NutUploader,
+    NutAddress,
+    NutElevator,
+    NutProgress,
+    NutPopup
+  };
+});
+
+afterAll(() => {
+  config.global.components = {};
+});
+
+describe('Form', () => {
+  test('base Form', () => {
+    const wrapper = mount(Form);
+    const rate = wrapper.find('.nut-form');
+    expect(rate.exists()).toBe(true);
+  });
+
+  test('base Form usage', async () => {
+    const wrapper = mount({
+      components: {
+        'nut-form': Form,
+        'nut-form-item': FormItem
+      },
+      template: `
+      <nut-form>
+        <nut-form-item label="姓名">
+          <input class="nut-input-text" placeholder="请输入姓名" type="text" />
+        </nut-form-item>
+        <nut-form-item label="年龄">
+          <input class="nut-input-text" placeholder="请输入年龄" type="text" />
+        </nut-form-item>
+        <nut-form-item label="联系电话">
+          <input class="nut-input-text" placeholder="请输入联系电话" type="text" />
+        </nut-form-item>
+        <nut-form-item label="地址">
+          <input class="nut-input-text" placeholder="请输入地址" type="text" />
+        </nut-form-item>
+        <nut-form-item label="备注">
+          <nut-textarea placeholder="请输入备注" type="text" />
+        </nut-form-item>
+      </nut-form>
+      `
+    });
+    await nextTick();
+    const form = wrapper.find('.nut-cell-group__warp');
+    expect(form.exists()).toBe(true);
+    const formitem = wrapper.findAll('.nut-form-item');
+    expect(formitem.length).toBe(5);
+  });
+
+  test('base Dynamic Form', async () => {
+    const wrapper = mount({
+      components: {
+        'nut-form': Form,
+        'nut-form-item': FormItem
+      },
+      template: `
+      <nut-form :model-value="dynamicForm.state" ref="dynamicRefForm">
+        <nut-form-item label="姓名" :show-error-message="false" :show-error-line="false" error-message-align="center" body-align="center" label-width="90px" prop="name" required :rules="[{ required: true, message: '请填写姓名' }]">
+          <input class="nut-input-text" v-model="dynamicForm.state.name" placeholder="请输入姓名" type="text" />
+        </nut-form-item>
+        <nut-form-item
+          :label="'联系方式' + index"
+          :prop="'tels.' + index + '.value'"
+          required
+          :rules="[{ required: true, message: '请填写联系方式' + index }]"
+          :key="item.key"
+          v-for="(item, index) in dynamicForm.state.tels"
+        >
+          <input class="nut-input-text" v-model="item.value" :placeholder="'请输入联系方式' + index" type="text" />
+        </nut-form-item>
+        <nut-cell>
+          <nut-button class="add" size="small" style="margin-right: 10px" @click="dynamicForm.methods.add">添加</nut-button>
+          <nut-button class="remove" size="small" style="margin-right: 10px" @click="dynamicForm.methods.remove">删除</nut-button>
+          <nut-button class="submit" type="primary" style="margin-right: 10px" size="small" @click="dynamicForm.methods.submit"
+            >提交
+          </nut-button>
+          <nut-button class="reset" size="small" @click="dynamicForm.methods.reset">重置提示状态</nut-button>
+        </nut-cell>
+      </nut-form>
+      `,
+      setup() {
+        const dynamicRefForm = ref<any>(null);
+        const dynamicForm = {
+          state: reactive({
+            name: '',
+            tels: new Array({
+              key: 1,
+              value: ''
+            })
+          }),
+          methods: {
+            submit() {
+              dynamicRefForm.value.validate().then(({ valid, errors }: any) => {
+                if (valid) {
+                  console.log('success', dynamicForm);
+                } else {
+                  console.log('error submit!!', errors);
+                }
+              });
+            },
+            reset() {
+              dynamicRefForm.value.reset();
+            },
+            remove() {
+              dynamicForm.state.tels.splice(dynamicForm.state.tels.length - 1, 1);
+            },
+            add() {
+              let newIndex = dynamicForm.state.tels.length;
+              dynamicForm.state.tels.push({
+                key: Date.now(),
+                value: ''
+              });
+            }
+          }
+        };
+        return {
+          dynamicForm,
+          dynamicRefForm
+        };
+      }
+    });
+    await nextTick();
+    const formitem = wrapper.findAll('.nut-form-item');
+    expect(formitem.length).toBe(2);
+    const form1 = wrapper.find('.nut-cell__title');
+    expect(form1.classes()).toContain('required');
+    expect((form1.element as HTMLElement).style.textAlign).toEqual('left');
+    expect((form1.element as HTMLElement).style.width).toEqual('90px');
+    const form2 = wrapper.find('.nut-form-item__body__slots');
+    expect((form2.element as HTMLElement).style.textAlign).toEqual('center');
+    const form4 = wrapper.find('.submit');
+    form4.trigger('click');
+    await nextTick();
+    const form3 = wrapper.find('.nut-form-item__body__tips');
+    expect(form3.exists()).toBe(true);
+    const form5 = wrapper.find('.nut-form-item.error.line::before');
+    expect(form5.exists()).toBe(false);
+    await nextTick();
+    const form6 = wrapper.find('.add');
+    form6.trigger('click');
+    setTimeout(() => {
+      expect(formitem.length).toBe(3);
+    }, 0);
+    const form7 = wrapper.find('.remove');
+    form7.trigger('click');
+    setTimeout(() => {
+      expect(formitem.length).toBe(1);
+    }, 0);
+    const form8 = wrapper.find('.reset');
+    form8.trigger('click');
+    setTimeout(() => {
+      expect(form3.exists()).toBe(false);
+    }, 0);
+  });
+
+  test('base Form verification', async () => {
+    const wrapper = mount({
+      components: {
+        'nut-form': Form,
+        'nut-form-item': FormItem
+      },
+      template: `
+      <nut-form :model-value="formData" ref="ruleForm">
+        <nut-form-item label="姓名" prop="name" required :rules="[{ required: true, message: '请填写姓名' }]">
+          <input
+            class="nut-input-text"
+            v-model="formData.name"
+            placeholder="请输入姓名,blur 事件校验"
+            type="text"
+          />
+        </nut-form-item>
+        <nut-cell>
+          <nut-button class="submit" type="primary" size="small" style="margin-right: 10px" @click="submit">提交</nut-button>
+          <nut-button class="resets" size="small" @click="reset">重置提示状态</nut-button>
+        </nut-cell>
+      </nut-form>
+      `,
+      setup() {
+        const formData = reactive({
+          name: ''
+        });
+        const ruleForm = ref<any>(null);
+        const submit = () => {
+          ruleForm.value.validate().then(({ valid, errors }: any) => {
+            if (valid) {
+              console.log('success', formData);
+            } else {
+              console.log('error submit!!!', errors);
+            }
+          });
+        };
+        const reset = () => {
+          ruleForm.value.reset();
+        };
+        return {
+          ruleForm,
+          formData,
+          submit,
+          reset
+        };
+      }
+    });
+
+    // .nut-input-text
+    const formitem: DOMWrapper<Element> = wrapper.find('.nut-input-text');
+    expect(formitem.exists()).toBe(true);
+    expect(formitem.attributes().type).toBe('text');
+    const formitem2: DOMWrapper<Element> = wrapper.find('.nut-form-item__body__tips');
+    const formitem3: DOMWrapper<Element> = wrapper.find('.submit');
+    formitem3.trigger('click');
+    await nextTick();
+    expect(formitem3.exists()).toBe(true);
+    const formitem1: DOMWrapper<Element> = wrapper.find('.resets');
+
+    formitem1.trigger('click');
+    expect(formitem2.exists()).toBe(false);
+  });
+
+  test('base Form Type', async () => {
+    const wrapper = mount({
+      components: {
+        'nut-form': Form,
+        'nut-form-item': FormItem
+      },
+      template: `
+      <nut-form>
+      <nut-form-item label="开关">
+        <nut-switch v-model="formData2.switch"></nut-switch>
+      </nut-form-item>
+      <nut-form-item label="复选框">
+        <nut-checkbox v-model="formData2.checkbox">复选框</nut-checkbox>
+      </nut-form-item>
+      <nut-form-item label="单选按钮">
+        <nut-radiogroup direction="horizontal" v-model="formData2.radio">
+          <nut-radio label="1">选项1</nut-radio>
+          <nut-radio disabled label="2">选项2</nut-radio>
+          <nut-radio label="3">选项3</nut-radio>
+        </nut-radiogroup>
+      </nut-form-item>
+      <nut-form-item label="评分">
+        <nut-rate v-model="formData2.rate" />
+      </nut-form-item>
+      <nut-form-item label="步进器">
+        <nut-inputnumber v-model="formData2.number" />
+      </nut-form-item>
+      <nut-form-item label="滑块">
+        <nut-range hidden-tag v-model="formData2.range"></nut-range>
+      </nut-form-item>
+      <nut-form-item label="文件上传">
+        <nut-uploader url="http://服务地址" v-model:file-list="formData2.defaultFileList" maximum="3" multiple>
+        </nut-uploader>
+      </nut-form-item>
+      <nut-form-item label="地址">
+        <input
+          class="nut-input-text"
+          v-model="formData2.address"
+          readonly
+          placeholder="请选择地址"
+          type="text"
+        />
+        <!-- nut-address -->
+        <nut-address
+          v-model:visible="addressModule.state.show"
+          :province="addressModule.state.province"
+          :city="addressModule.state.city"
+          :country="addressModule.state.country"
+          :town="addressModule.state.town"
+          custom-address-title="请选择所在地区"
+        ></nut-address>
+      </nut-form-item>
+    </nut-form>
+      `,
+      setup() {
+        const formData2 = reactive({
+          switch: false,
+          checkbox: false,
+          radio: 0,
+          number: 0,
+          rate: 3,
+          range: 30,
+          address: '',
+          defaultFileList: [
+            {
+              name: '文件1.png',
+              url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+              status: 'success',
+              message: '上传成功',
+              type: 'image'
+            },
+            {
+              name: '文件2.png',
+              url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+              status: 'uploading',
+              message: '上传中...',
+              type: 'image'
+            }
+          ]
+        });
+
+        const addressModule = reactive({
+          state: {
+            show: false,
+            province: [
+              { id: 1, name: '北京' },
+              { id: 2, name: '广西' },
+              { id: 3, name: '江西' },
+              { id: 4, name: '四川' }
+            ],
+            city: [
+              { id: 7, name: '朝阳区' },
+              { id: 8, name: '崇文区' },
+              { id: 9, name: '昌平区' },
+              { id: 6, name: '石景山区' }
+            ],
+            country: [
+              { id: 3, name: '八里庄街道' },
+              { id: 9, name: '北苑' },
+              { id: 4, name: '常营乡' }
+            ],
+            town: []
+          }
+        });
+        return {
+          formData2,
+          addressModule
+        };
+      }
+    });
+    await nextTick();
+    const formitem = wrapper.findAll('.nut-form-item');
+    expect(formitem.length).toBe(8);
+    const formitem1 = wrapper.find('.nut-switch');
+    expect(formitem1.exists()).toBe(true);
+    const formitem2 = wrapper.find('.nut-checkbox');
+    expect(formitem2.exists()).toBe(true);
+    const formitem3 = wrapper.find('.nut-rate');
+    expect(formitem3.exists()).toBe(true);
+    const formitem4 = wrapper.find('.nut-inputnumber');
+    expect(formitem4.exists()).toBe(true);
+    const formitem5 = wrapper.find('.nut-range');
+    expect(formitem5.exists()).toBe(true);
+    const formitem6 = wrapper.find('.nut-uploader');
+    expect(formitem6.exists()).toBe(true);
+  });
+});

+ 23 - 78
src/packages/__VUE/input/__tests__/input.spec.ts

@@ -28,11 +28,6 @@ test('should emit blur event when input is blur', () => {
   wrapper.find('input').trigger('blur');
   expect(wrapper.emitted('blur')).toBeTruthy();
 });
-test('should emit change event when input is change', () => {
-  const wrapper = mount(Input);
-  wrapper.find('input').trigger('input');
-  expect(wrapper.emitted('change')).toBeTruthy();
-});
 
 test('should render clear icon when using clearable prop', async () => {
   const wrapper = mount(Input, {
@@ -41,20 +36,23 @@ test('should render clear icon when using clearable prop', async () => {
       modelValue: 'test'
     }
   });
-
-  const clearBtn = wrapper.find('.nut-textinput-clear');
   const input = wrapper.find('input');
   await input.trigger('focus');
-  // expect(wrapper.find('.nut-textinput-clear').exists()).toBeTruthy();
-
-  wrapper.find('.nut-textinput-clear').trigger('click');
+  // expect(wrapper.find('.nut-input-clear').exists()).toBeTruthy();
+  wrapper.find('.nut-input-clear').trigger('click');
   // expect((wrapper.emitted('update:modelValue') as any)[0][0]).toEqual('');
   // expect((wrapper.emitted('handleClear') as any)[0][0]).toBeTruthy();
 });
-test('should clear  when event clear', () => {
-  const wrapper = mount(Input, { props: { modelValue: 3 } });
+
+test('should clear when event clear', () => {
+  const wrapper = mount(Input, {
+    props: {
+      clearable: true,
+      modelValue: 'test'
+    }
+  });
   const input = wrapper.find('input');
-  const clear = wrapper.find('.nut-textinput-clear');
+  const clear = wrapper.find('.nut-input-clear');
   wrapper.find('input').trigger('input');
   clear.trigger('click');
   expect(clear.exists()).toBe(true);
@@ -71,28 +69,18 @@ test('should format input value when type is number', () => {
     }
   });
   const input = wrapper.find('input');
+
   input.element.value = '1';
   input.trigger('input');
   expect((wrapper.emitted('change') as any)[0][0]).toEqual('1');
 
-  input.element.value = '1.2';
+  input.element.value = '1.1.';
   input.trigger('input');
-  expect((wrapper.emitted('change') as any)[1][0]).toEqual('12');
+  expect((wrapper.emitted('change') as any)[1][0]).toEqual('1.1');
 
-  // input.element.value = '111qwe';
-  // input.trigger('input');
-  // expect((wrapper.emitted('change') as any)[1][0]).toEqual('111');
-});
-test('should format input value when type is number', () => {
-  const wrapper = mount(Input, {
-    props: {
-      type: 'number',
-      modelValue: '123abc'
-    }
-  });
-  const input = wrapper.find('input');
-  input.trigger('blur');
-  expect((wrapper.emitted('blur') as any)[0][0]).toEqual('');
+  input.element.value = '111qwe';
+  input.trigger('input');
+  expect((wrapper.emitted('change') as any)[2][0]).toEqual('111');
 });
 
 // 测试小数
@@ -104,29 +92,14 @@ test('should format input value when type is digit', () => {
     }
   });
   const input = wrapper.find('input');
+
   input.element.value = '1';
   input.trigger('input');
   expect((wrapper.emitted('change') as any)[0][0]).toEqual('1');
 
-  input.element.value = '1.2';
-  input.trigger('input');
-  expect((wrapper.emitted('change') as any)[1][0]).toEqual('1.2');
-});
-
-test('should limit maxlength of input value when using maxlength prop', async () => {
-  const wrapper = mount(Input, {
-    props: {
-      maxLength: 3,
-      modelValue: '1234'
-    }
-  });
-
-  const input = wrapper.find('input');
-  input.trigger('input');
-  expect((wrapper.emitted('change') as any)[0][0]).toEqual('123');
-  input.element.value = '1234';
-  input.trigger('input');
-  expect((wrapper.emitted('change') as any)[0][0]).toEqual('123');
+  // input.element.value = '1.1';
+  // input.trigger('input');
+  // expect((wrapper.emitted('change') as any)[1][0]).toEqual('11');
 });
 
 test('should no label', () => {
@@ -150,11 +123,11 @@ test('should label', () => {
 test('should require', () => {
   const wrapper = mount(Input, {
     props: {
-      requireShow: true
+      required: true
     }
   });
   const input = wrapper.find('.nut-input');
-  expect(input.classes()).toContain('nut-input-require');
+  expect(input.classes()).toContain('nut-input-required');
 });
 
 test('should disabled', () => {
@@ -176,31 +149,3 @@ test('should readonly', () => {
   const input = wrapper.find('input');
   expect(input.attributes('readonly')).toBe('');
 });
-
-test('should text-align left', () => {
-  const wrapper = mount(Input, {
-    props: {
-      textAlign: 'center'
-    }
-  });
-  const input = wrapper.find('input').element;
-  expect(input.style.textAlign).toEqual('center');
-});
-
-test('should render clear icon when using clearable prop', async () => {
-  const wrapper = mount(Input, {
-    props: {
-      clearable: true,
-      modelValue: 'test'
-    }
-  });
-
-  const clearBtn = wrapper.find('.nut-textinput-clear');
-  const input = wrapper.find('input');
-  await input.trigger('focus');
-  // expect(wrapper.find('.nut-textinput-clear').exists()).toBeTruthy();
-
-  wrapper.find('.nut-textinput-clear').trigger('click');
-  // expect((wrapper.emitted('update:modelValue') as any)[0][0]).toEqual('');
-  // expect((wrapper.emitted('handleClear') as any)[0][0]).toBeTruthy();
-});

+ 3 - 0
src/packages/__VUE/input/index.scss

@@ -81,6 +81,9 @@ textarea {
     color: #c8c9cc;
     cursor: pointer;
   }
+  .nut-button {
+    margin-left: 10px;
+  }
   &.nut-input-required {
     &::before {
       position: absolute;

+ 344 - 87
src/packages/__VUE/input/index.taro.vue

@@ -1,79 +1,139 @@
 <template>
   <view :class="classes">
-    <view v-if="label" class="nut-input-label">
-      <view class="label-string">{{ label }}</view>
+    <view v-if="leftIcon && leftIcon.length > 0" class="nut-input-left-icon" @click="onClickLeftIcon">
+      <nut-icon :name="leftIcon" :size="leftIconSize"></nut-icon>
     </view>
-    <view v-if="readonly" class="nut-input__text--readonly">
-      {{ modelValue }}
-    </view>
-    <input
-      v-else
-      class="input-text"
-      :style="styles"
-      :type="type"
-      :maxlength="maxLength"
-      :placeholder="placeholder"
-      :disabled="disabled || readonly"
-      :readonly="readonly"
-      :value="modelValue"
-      @input="valueChange"
-      @focus="valueFocus"
-      @blur="valueBlur"
-    />
     <view
-      @click="handleClear"
-      class="nut-textinput-clear"
-      v-if="clearable && !readonly"
-      v-show="active && modelValue.length > 0"
+      v-if="label"
+      class="nut-input-label"
+      :class="labelClass"
+      :style="{
+        width: `${labelWidth}px`,
+        textAlign: labelAlign
+      }"
     >
-      <nut-icon name="close-little" size="12px"></nut-icon>
+      <view class="label-string">
+        {{ label }}
+        {{ colon ? ':' : '' }}
+      </view>
+    </view>
+    <view class="nut-input-value">
+      <view class="nut-input-inner" @click="onClickInput">
+        <textarea
+          v-if="type == 'textarea'"
+          class="input-text"
+          ref="inputRef"
+          :style="stylesTextarea"
+          :maxlength="maxLength"
+          :placeholder="placeholder"
+          :disabled="disabled"
+          :readonly="readonly"
+          :value="modelValue"
+          :formatTrigger="formatTrigger"
+          :autofocus="autofocus"
+          @input="onInput"
+          @focus="onFocus"
+          @blur="onBlur"
+        />
+        <input
+          v-else
+          class="input-text"
+          ref="inputRef"
+          :style="styles"
+          :type="inputType(type)"
+          :maxNum="maxNum"
+          :placeholder="placeholder"
+          :disabled="disabled"
+          :readonly="readonly"
+          :value="modelValue"
+          :formatTrigger="formatTrigger"
+          :autofocus="autofocus"
+          @input="onInput"
+          @focus="onFocus"
+          @blur="onBlur"
+        />
+        <nut-icon
+          class="nut-input-clear"
+          v-if="clearable && !readonly"
+          v-show="active && modelValue.length > 0"
+          :name="clearIcon"
+          :size="clearSize"
+          @click="clear"
+        >
+        </nut-icon>
+        <view v-if="rightIcon && rightIcon.length > 0" class="nut-input-right-icon" @click="onClickRightIcon">
+          <nut-icon :name="rightIcon" :size="rightIconSize"></nut-icon>
+        </view>
+        <slot v-if="$slots.button" name="button" class="nut-input-button"></slot>
+      </view>
+      <view v-if="showWordLimit && maxNum" class="nut-input-word-limit">
+        <span class="nut-input-word-num">{{ modelValue ? modelValue.length : 0 }}</span
+        >/{{ maxNum }}
+      </view>
+      <view
+        v-if="errorMessage"
+        class="nut-input-error-message"
+        :style="{
+          textAlign: errorMessageAlign
+        }"
+      >
+        {{ errorMessage }}
+      </view>
     </view>
   </view>
 </template>
 <script lang="ts">
-import { ref, computed } from 'vue';
+import { PropType, ref, reactive, computed, onMounted, watch, nextTick, inject } from 'vue';
 import { createComponent } from '../../utils/create';
-function trimExtraChar(value: string, char: string, regExp: RegExp) {
-  const index = value.indexOf(char);
-
-  if (index === -1) {
-    return value;
-  }
-
-  if (char === '-' && index !== 0) {
-    return value.slice(0, index);
-  }
-
-  return value.slice(0, index + 1) + value.slice(index).replace(regExp, '');
-}
-
-function formatNumber(value: string, allowDot = true, allowMinus = true) {
-  if (allowDot) {
-    value = trimExtraChar(value, '.', /\./g);
-  } else {
-    value = value.replace(/\./g, '');
-  }
-
-  if (allowMinus) {
-    value = trimExtraChar(value, '-', /-/g);
-  } else {
-    value = value.replace(/-/, '');
-  }
-
-  const regExp = allowDot ? /[^-0-9.]/g : /[^-0-9]/g;
-
-  return value.replace(regExp, '');
-}
+import { formatNumber } from './util';
 
 const { componentName, create } = createComponent('input');
 interface Events {
-  eventName: 'change' | 'focus' | 'blur' | 'clear' | 'update:modelValue';
+  eventName: 'focus' | 'blur' | 'clear' | 'change' | 'update:modelValue';
   params: (string | number | Event)[];
 }
+export type InputAlignType = 'left' | 'center' | 'right'; // text-align
+export type InputFormatTrigger = 'onChange' | 'onBlur'; // onChange: 在输入时执行格式化 ; onBlur: 在失焦时执行格式化
+export type InputType =
+  | 'tel'
+  | 'url'
+  | 'date'
+  | 'file'
+  | 'text'
+  | 'time'
+  | 'week'
+  | 'color'
+  | 'digit'
+  | 'email'
+  | 'image'
+  | 'month'
+  | 'radio'
+  | 'range'
+  | 'reset'
+  | 'button'
+  | 'hidden'
+  | 'number'
+  | 'search'
+  | 'submit'
+  | 'checkbox'
+  | 'password'
+  | 'textarea'
+  | 'datetime-local';
+
+export type InputRule = {
+  pattern?: RegExp;
+  message?: string;
+  required?: boolean;
+};
+
 export default create({
   props: {
-    type: {
+    ref: {
       type: String,
+      default: ''
+    },
+    type: {
+      type: String as PropType<InputType>,
       default: 'text'
     },
     modelValue: {
@@ -88,7 +148,31 @@ export default create({
       type: String,
       default: ''
     },
-    requireShow: {
+    labelClass: {
+      type: String,
+      default: ''
+    },
+    labelWidth: {
+      type: [String, Number],
+      default: '80'
+    },
+    labelAlign: {
+      type: String as PropType<InputAlignType>,
+      default: 'left'
+    },
+    colon: {
+      type: Boolean,
+      default: false
+    },
+    inputAlign: {
+      type: String,
+      default: 'left'
+    },
+    center: {
+      type: Boolean,
+      default: false
+    },
+    required: {
       type: Boolean,
       default: false
     },
@@ -100,93 +184,266 @@ export default create({
       type: Boolean,
       default: false
     },
-    textAlign: {
+    error: {
+      type: Boolean,
+      default: false
+    },
+    maxNum: {
+      type: [String, Number],
+      default: ''
+    },
+    leftIcon: {
       type: String,
-      default: 'left'
+      default: ''
     },
-    maxLength: {
+    leftIconSize: {
       type: [String, Number],
-      default: '99999999'
+      default: ''
+    },
+    rightIcon: {
+      type: String,
+      default: ''
+    },
+    rightIconSize: {
+      type: [String, Number],
+      default: ''
     },
     clearable: {
       type: Boolean,
+      default: false
+    },
+    clearIcon: {
+      type: String,
+      default: 'mask-close'
+    },
+    clearSize: {
+      type: [String, Number],
+      default: '14'
+    },
+    border: {
+      type: Boolean,
       default: true
     },
-    hasBorder: {
+    formatTrigger: {
+      type: String as PropType<InputFormatTrigger>,
+      default: 'onChange'
+    },
+    formatter: {
+      type: Function as PropType<(value: string) => string>,
+      default: null
+    },
+    rules: {
+      type: Array as PropType<InputRule>,
+      default: []
+    },
+    errorMessage: {
+      type: String,
+      default: ''
+    },
+    errorMessageAlign: {
+      type: String as PropType<InputAlignType>,
+      default: ''
+    },
+    rows: {
+      type: [String, Number],
+      default: null
+    },
+    showWordLimit: {
       type: Boolean,
       default: true
+    },
+    autofocus: {
+      type: Boolean,
+      default: false
     }
   },
 
-  emits: ['change', 'update:modelValue', 'blur', 'focus', 'clear'],
+  emits: [
+    'update:modelValue',
+    'change',
+    'blur',
+    'focus',
+    'clear',
+    'keypress',
+    'click-input',
+    'click-left-icon',
+    'click-right-icon'
+  ],
 
-  setup(props, { emit }) {
+  setup(props, { emit, slots }) {
     const active = ref(false);
 
+    const inputRef = ref<HTMLInputElement>();
+    const customValue = ref<() => unknown>();
+    const getModelValue = () => String(props.modelValue ?? '');
+    // const form = inject('form');
+
+    const state = reactive({
+      focused: false,
+      validateFailed: false, // 校验失败
+      validateMessage: '' // 校验信息
+    });
+
     const classes = computed(() => {
       const prefixCls = componentName;
       return {
         [prefixCls]: true,
+        center: props.center,
         [`${prefixCls}-disabled`]: props.disabled,
-        [`${prefixCls}-require`]: props.requireShow,
-        [`${prefixCls}-border`]: props.hasBorder
+        [`${prefixCls}-required`]: props.required,
+        [`${prefixCls}-error`]: props.error,
+        [`${prefixCls}-border`]: props.border
       };
     });
 
     const styles = computed(() => {
       return {
-        textAlign: props.textAlign
+        textAlign: props.inputAlign
+      };
+    });
+    const stylesTextarea = computed(() => {
+      return {
+        textAlign: props.inputAlign,
+        height: Number(props.rows) * 24 + 'px'
       };
     });
 
-    const valueChange = (event: Event) => {
-      const input = event.target as HTMLInputElement;
-      let val = input.value;
+    const inputType = (type: string) => {
+      if (type === 'number') {
+        return 'text';
+      } else if (type === 'digit') {
+        return 'tel';
+      } else {
+        return type;
+      }
+    };
 
-      if (props.maxLength && val.length > Number(props.maxLength)) {
-        val = val.slice(0, Number(props.maxLength));
+    const formValue = computed(() => {
+      if (customValue.value && slots.input) {
+        return customValue.value();
       }
+      return props.modelValue;
+    });
+
+    // const inputmode = computed(() => {
+    //   return props.type === 'digit' ? 'decimal' : props.type === 'number' ? 'numeric' : 'text';
+    // });
+
+    const onInput = (event: Event) => {
+      const input = event.target as HTMLInputElement;
+      let value = input.value;
+
+      // if (!event.target!.composing) {
+      //   updateValue((event.target as HTMLInputElement).value);
+      // }
+      updateValue(value);
+    };
+
+    const blur = () => inputRef.value?.blur();
+    const focus = () => inputRef.value?.focus();
+
+    const updateValue = (value: string, trigger: InputFormatTrigger = 'onChange') => {
       if (props.type === 'digit') {
-        val = formatNumber(val, true);
+        value = formatNumber(value, false, false);
       }
       if (props.type === 'number') {
-        val = formatNumber(val, false);
+        // console.log('value', value)
+        value = formatNumber(value, true, true);
+      }
+
+      if (props.formatter && trigger === props.formatTrigger) {
+        value = props.formatter(value);
+      }
+
+      // if (props.maxNum && value.length > Number(props.maxNum)) {
+      //   value = value.slice(0, Number(props.maxNum));
+      // }
+
+      if (inputRef.value && inputRef.value.value !== value) {
+        inputRef.value.value = value;
+      }
+
+      if (value !== props.modelValue) {
+        emit('update:modelValue', value);
+        emit('change', value);
       }
-      emit('update:modelValue', val, event);
-      emit('change', val, event);
     };
 
-    const valueFocus = (event: Event) => {
+    const onFocus = (event: Event) => {
       const input = event.target as HTMLInputElement;
       let value = input.value;
       active.value = true;
       emit('focus', value, event);
+      // if (getProp('readonly')) {
+      //   blur();
+      // }
     };
 
-    const valueBlur = (event: Event) => {
+    const onBlur = (event: Event) => {
       setTimeout(() => {
         active.value = false;
-      }, 0);
+      }, 200);
+
+      // if (getProp('readonly')) {
+      //   return;
+      // }
 
       const input = event.target as HTMLInputElement;
       let value = input.value;
+      if (props.maxNum && value.length > Number(props.maxNum)) {
+        value = value.slice(0, Number(props.maxNum));
+      }
+      updateValue(getModelValue(), 'onBlur');
       emit('blur', value, event);
     };
 
-    const handleClear = (event: Event) => {
+    const clear = (event: Event) => {
       emit('update:modelValue', '', event);
       emit('change', '', event);
-      emit('clear', '');
+      emit('clear', '', event);
+    };
+
+    const resetValidation = () => {
+      if (state.validateFailed) {
+        state.validateFailed = false;
+        state.validateMessage = '';
+      }
+    };
+
+    const onClickInput = (event: MouseEvent) => {
+      emit('click-input', event);
     };
 
+    const onClickLeftIcon = (event: MouseEvent) => emit('click-left-icon', event);
+
+    const onClickRightIcon = (event: MouseEvent) => emit('click-right-icon', event);
+
+    watch(
+      () => props.modelValue,
+      () => {
+        updateValue(getModelValue());
+        resetValidation();
+      }
+    );
+
+    onMounted(() => {
+      updateValue(getModelValue(), props.formatTrigger);
+    });
+
     return {
+      inputRef,
       active,
       classes,
       styles,
-      valueChange,
-      valueFocus,
-      valueBlur,
-      handleClear
+      stylesTextarea,
+      inputType,
+      onInput,
+      onFocus,
+      onBlur,
+      clear,
+      onClickInput,
+      onClickLeftIcon,
+      onClickRightIcon
     };
   }
 });

+ 1 - 84
src/packages/__VUE/input/index.vue

@@ -124,7 +124,6 @@ export type InputRule = {
   pattern?: RegExp;
   message?: string;
   required?: boolean;
-  validator?: FieldRuleValidator; // 通过函数进行校验
 };
 
 export default create({
@@ -277,7 +276,7 @@ export default create({
     const inputRef = ref<HTMLInputElement>();
     const customValue = ref<() => unknown>();
     const getModelValue = () => String(props.modelValue ?? '');
-    const form = inject('form');
+    // const form = inject('form');
 
     const state = reactive({
       focused: false,
@@ -370,18 +369,6 @@ export default create({
       }
     };
 
-    // const limitValueLength = (value: string) => {
-    //   const { maxNum } = props;
-    //   if (isDef(maxNum) && getStringLength(value) > maxNum) {
-    //     const modelValue = getModelValue();
-    //     if (modelValue && getStringLength(modelValue) === +maxNum) {
-    //       return modelValue;
-    //     }
-    //     return cutString(value, +maxLength);
-    //   }
-    //   return value;
-    // };
-
     const onFocus = (event: Event) => {
       const input = event.target as HTMLInputElement;
       let value = input.value;
@@ -408,7 +395,6 @@ export default create({
       }
       updateValue(getModelValue(), 'onBlur');
       emit('blur', value, event);
-      validateWithTrigger('onBlur');
     };
 
     const clear = (event: Event) => {
@@ -417,42 +403,6 @@ export default create({
       emit('clear', '', event);
     };
 
-    // const runRules = (rules: InputRule[]) =>
-    //   rules.reduce(
-    //     (promise, rule) =>
-    //       promise.then(() => {
-    //         console.log('promise', promise, 'rule', rule)
-    //         if (state.validateFailed) {
-    //           return;
-    //         }
-
-    //         let { value } = formValue;
-
-    //         // if (rule.formatter) {
-    //         //   value = rule.formatter(value, rule);
-    //         // }
-
-    //         if (!runSyncRule(value, rule)) {
-    //           state.validateFailed = true;
-    //           state.validateMessage = getRuleMessage(value, rule);
-    //           return;
-    //         }
-
-    //         if (rule.validator) {
-    //           return runRuleValidator(value, rule).then((result) => {
-    //             if (result && typeof result === 'string') {
-    //               state.validateFailed = true;
-    //               state.validateMessage = result;
-    //             } else if (result === false) {
-    //               state.validateFailed = true;
-    //               state.validateMessage = getRuleMessage(value, rule);
-    //             }
-    //           });
-    //         }
-    //       }),
-    //     Promise.resolve()
-    //   );
-
     const resetValidation = () => {
       if (state.validateFailed) {
         state.validateFailed = false;
@@ -460,37 +410,6 @@ export default create({
       }
     };
 
-    // const validate = (rules: any) =>
-    //   new Promise<void>((resolve) => {
-    //     console.log('rules122', form.props.rules)
-    //     resetValidation();
-    //     if (rules) {
-    //       console.log('rules233', rules)
-    //       runRules(rules).then(() => {
-    //         if (state.validateFailed) {
-    //           resolve({
-    //             // name: rules,
-    //             message: state.validateMessage
-    //           });
-    //         } else {
-    //           resolve();
-    //         }
-    //       });
-    //     } else {
-    //       resolve();
-    //     }
-    //   });
-
-    const validateWithTrigger = (trigger: InputFormatTrigger) => {
-      if (form && form.props.rules) {
-        const rules = form.props.rules;
-        if (rules) {
-          // console.log('rules', rules)
-          // validate(rules);
-        }
-      }
-    };
-
     const onClickInput = (event: MouseEvent) => {
       emit('click-input', event);
     };
@@ -504,7 +423,6 @@ export default create({
       () => {
         updateValue(getModelValue());
         resetValidation();
-        validateWithTrigger('onChange');
       }
     );
 
@@ -519,7 +437,6 @@ export default create({
       styles,
       stylesTextarea,
       inputType,
-      // inputmode,
       onInput,
       onFocus,
       onBlur,

+ 59 - 0
src/packages/__VUE/notify/__test__/function.spec.ts

@@ -0,0 +1,59 @@
+import { NotifyFunction } from './notify';
+
+function sleep(delay = 0): Promise<void> {
+  return new Promise((resolve) => {
+    setTimeout(resolve, delay);
+  });
+}
+
+describe('function notify', () => {
+  test('show text notify', async () => {
+    NotifyFunction.text('基础用法', {
+      duration: 500,
+      color: '#ad0000',
+      background: '#ffe1e1'
+    });
+    let textNotify = document.querySelector('.nut-notify') as HTMLElement;
+    expect(textNotify.innerHTML).toContain('基础用法');
+    expect(textNotify.style.color).toEqual('rgb(173, 0, 0)');
+    expect(textNotify.style.background).toEqual('rgb(255, 225, 225)');
+    await sleep(500);
+    expect(textNotify.style.display).toEqual('');
+  });
+  test('show primary notify', async () => {
+    NotifyFunction.primary('主要通知', {
+      duration: 500
+    });
+    let textNotify1 = document.querySelector('.nut-notify--primary') as HTMLElement;
+    expect(textNotify1.innerHTML).toContain('主要通知');
+    await sleep(500);
+    expect(textNotify1.style.display).toEqual('');
+  });
+  test('show success notify', async () => {
+    NotifyFunction.success('成功通知', {
+      duration: 500
+    });
+    let textNotify1 = document.querySelector('.nut-notify--success') as HTMLElement;
+    expect(textNotify1.innerHTML).toContain('成功通知');
+    await sleep(500);
+    expect(textNotify1.style.display).toEqual('');
+  });
+  test('show danger notify', async () => {
+    NotifyFunction.danger('危险通知', {
+      duration: 500
+    });
+    let textNotify1 = document.querySelector('.nut-notify--danger') as HTMLElement;
+    expect(textNotify1.innerHTML).toContain('危险通知');
+    await sleep(500);
+    expect(textNotify1.style.display).toEqual('');
+  });
+  test('show warn notify', async () => {
+    NotifyFunction.warn('警告通知', {
+      duration: 500
+    });
+    let textNotify1 = document.querySelector('.nut-notify--warning') as HTMLElement;
+    expect(textNotify1.innerHTML).toContain('警告通知');
+    await sleep(500);
+    expect(textNotify1.style.display).toEqual('');
+  });
+});

+ 118 - 0
src/packages/__VUE/notify/__test__/notify.ts

@@ -0,0 +1,118 @@
+import { createVNode, defineComponent, render, App } from 'vue';
+import Notify from '../index.vue';
+const defaultOptions = {
+  type: 'base',
+  showPopup: false,
+  msg: '',
+  color: undefined,
+  background: undefined,
+  duration: 3000,
+  className: '',
+  onClosed: null,
+  onClick: null,
+  onOpened: null,
+  textTimer: null,
+  unmount: null
+};
+
+let idsMap: string[] = [];
+let optsMap: any[] = [];
+const clearNotify = (id?: string) => {
+  if (id) {
+    const container = document.getElementById(id);
+    optsMap = optsMap.filter((item) => item.id !== id);
+    idsMap = idsMap.filter((item) => item !== id);
+    if (container) {
+      document.body.removeChild(container);
+    }
+  } else {
+    idsMap.forEach((item) => {
+      const container = document.getElementById(item);
+      if (container) {
+        document.body.removeChild(container);
+      }
+    });
+    optsMap = [];
+    idsMap = [];
+  }
+};
+
+const updateNotify = (opts: any) => {
+  const container = document.getElementById(opts.id);
+  if (container) {
+    const currentOpt = optsMap.find((item) => item.id === opts.id);
+    if (currentOpt) {
+      opts = { ...defaultOptions, ...currentOpt, ...opts };
+    } else {
+      opts = { ...defaultOptions, ...opts };
+    }
+    const instance: any = createVNode(Notify, opts);
+    render(instance, container);
+    return instance.component.ctx;
+  }
+};
+
+const mountNotify = (opts: any) => {
+  opts.unmount = clearNotify;
+  let _id;
+  if (opts.id) {
+    _id = opts.id;
+    if (idsMap.find((item) => item === opts.id)) {
+      return updateNotify(opts);
+    }
+  } else {
+    _id = new Date().getTime() + '';
+  }
+  opts = { ...defaultOptions, ...opts };
+  opts.id = _id;
+  idsMap.push(opts.id);
+  optsMap.push(opts);
+  const container = document.createElement('view');
+  container.id = opts.id;
+  const instance: any = createVNode(Notify, opts);
+  render(instance, container);
+  document.body.appendChild(container);
+  setTimeout(() => {
+    instance.showPopup = true;
+  }, 0);
+  return instance.component.ctx;
+};
+
+const errorMsg = (msg: string) => {
+  if (!msg) {
+    console.warn('[NutUI Notify]: msg不能为空');
+    return;
+  }
+};
+
+export const NotifyFunction = {
+  text(msg: string, obj = {}) {
+    errorMsg(msg);
+    return mountNotify({ ...obj, msg });
+  },
+  primary(msg: string, obj = {}) {
+    errorMsg(msg);
+    return mountNotify({ ...obj, msg, type: 'primary' });
+  },
+  success(msg: string, obj = {}) {
+    errorMsg(msg);
+    return mountNotify({ ...obj, msg, type: 'success' });
+  },
+  danger(msg: string, obj = {}) {
+    errorMsg(msg);
+    return mountNotify({ ...obj, msg, type: 'danger' });
+  },
+  warn(msg: string, obj = {}) {
+    errorMsg(msg);
+    return mountNotify({ ...obj, msg, type: 'warning' });
+  },
+  hide() {
+    clearNotify();
+  },
+  install(app: App): void {
+    app.config.globalProperties.$notify = NotifyFunction;
+  }
+};
+
+export default NotifyFunction;
+export { Notify };

+ 8 - 7
src/packages/__VUE/notify/demo.vue

@@ -10,14 +10,11 @@
       <nut-cell is-Link @click="warningNotify('警告通知')">警告通知</nut-cell>
     </nut-cell-group>
     <nut-cell-group title="自定义样式">
-      <nut-cell is-Link @click="cusBgNotify('自定义背景色和字体颜色')">
-        自定义背景色和字体颜色
-      </nut-cell>
+      <nut-cell is-Link @click="cusBgNotify('自定义背景色和字体颜色')"> 自定义背景色和字体颜色 </nut-cell>
     </nut-cell-group>
     <nut-cell-group title="自定义时长">
-      <nut-cell is-Link @click="timeNotify('自定义时长')">
-        自定义时长
-      </nut-cell>
+      <nut-cell is-Link @click="timeNotify('自定义时长')"> 自定义时长 </nut-cell>
+      <nut-cell is-Link @click="positionNotify('自定义位置')"> 自定义位置 </nut-cell>
     </nut-cell-group>
   </div>
 </template>
@@ -56,6 +53,9 @@ export default createDemo({
     const timeNotify = (msg: string) => {
       Notify.text(msg, { duration: 10000 });
     };
+    const positionNotify = (msg: string) => {
+      Notify.text(msg, { position: 'bottom' });
+    };
     return {
       baseNotify,
       primaryNotify,
@@ -63,7 +63,8 @@ export default createDemo({
       errorNotify,
       warningNotify,
       cusBgNotify,
-      timeNotify
+      timeNotify,
+      positionNotify
     };
   }
 });

+ 9 - 0
src/packages/__VUE/notify/index.scss

@@ -7,6 +7,15 @@
   transition: transform 0.3s;
   z-index: 9999;
 }
+.popup-bottom {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  width: 100%;
+  overflow-y: auto;
+  transition: transform 0.3s;
+  z-index: 9999;
+}
 
 .nut-fade-enter-active {
   transition: opacity 1s;

+ 6 - 1
src/packages/__VUE/notify/index.vue

@@ -1,7 +1,7 @@
 <template>
   <Transition name="toast-fade" @after-leave="onAfterLeave">
     <view
-      :class="['popup-top', 'nut-notify', `nut-notify--${type}`, [className]]"
+      :class="[`popup-${position}`, 'nut-notify', `nut-notify--${type}`, [className]]"
       :style="{ color: color, background: background }"
       v-show="state.mounted"
       @click="clickCover"
@@ -47,6 +47,10 @@ export default create({
       type: Boolean,
       default: false
     },
+    position: {
+      type: String,
+      default: 'top'
+    },
     onClose: Function,
     onClick: Function,
     unmount: Function
@@ -59,6 +63,7 @@ export default create({
     });
     onMounted(() => {
       state.mounted = true;
+      console.log(props.className);
     });
 
     const clickCover = () => {

+ 67 - 0
src/packages/__VUE/swipe/__tests__/swipe.spec.ts

@@ -0,0 +1,67 @@
+import { config, DOMWrapper, mount } from '@vue/test-utils';
+import Swipe from '../index.vue';
+import { nextTick } from 'vue';
+import NutButton from '../../button/index.vue';
+import NutCell from '../../cell/index.vue';
+
+beforeAll(() => {
+  config.global.components = {
+    NutButton,
+    NutCell
+  };
+});
+
+afterAll(() => {
+  config.global.components = {};
+});
+
+test('base swipe', () => {
+  const wrapper = mount(Swipe);
+  const swipe: DOMWrapper<Element> = wrapper.find('.nut-swipe');
+  expect(swipe.exists()).toBe(true);
+});
+
+test('base swipe props disabled', async () => {
+  const wrapper = mount(Swipe, {
+    props: {
+      disabled: false
+    },
+    slots: {
+      right: `<nut-button shape="square" style="height: 100%" type="danger"
+      >删除</nut-button>`
+    }
+  });
+  await nextTick();
+  const swipe1: DOMWrapper<Element> = wrapper.find('.nut-swipe__right');
+  const swipe2: DOMWrapper<Element> = wrapper.find('.nut-button');
+  expect(swipe1.exists()).toBe(true);
+  expect(swipe1.text()).toBe('删除');
+  expect(swipe2.exists()).toBe(true);
+});
+test('base swipe Slots', async () => {
+  const wrapper = mount(Swipe, {
+    slots: {
+      left: `<nut-button shape="square" style="height: 100%" type="success"
+      >选择</nut-button>`
+    }
+  });
+  await nextTick();
+  const swipe: DOMWrapper<Element> = wrapper.find('.nut-swipe__left');
+  const swipe2: DOMWrapper<Element> = wrapper.find('.nut-button');
+  expect(swipe.exists()).toBe(true);
+  expect(swipe.text()).toBe('选择');
+  expect(swipe2.exists()).toBe(true);
+});
+test('base swipe content', async () => {
+  const wrapper = mount(Swipe, {
+    slots: {
+      default: '<nut-cell round-radius="0" desc="左滑右滑都可以哦" />'
+    }
+  });
+  await nextTick();
+  const swipe2: DOMWrapper<Element> = wrapper.find('.nut-swipe__content');
+  const swipe3: DOMWrapper<Element> = wrapper.find('.nut-cell');
+  expect(swipe2.exists()).toBe(true);
+  expect(swipe2.text()).toBe('左滑右滑都可以哦');
+  expect(swipe3.exists()).toBe(true);
+});

+ 191 - 0
src/packages/__VUE/tabs/__tests__/index.spec.ts

@@ -0,0 +1,191 @@
+import { config, DOMWrapper, mount } from '@vue/test-utils';
+import Tabs from '../index.vue';
+import TabPane from './../../tabpane/index.vue';
+import { nextTick, reactive } from 'vue';
+import NutIcon from '../../icon/index.vue';
+
+beforeAll(() => {
+  config.global.components = {
+    NutIcon
+  };
+});
+
+afterAll(() => {
+  config.global.components = {};
+});
+
+test('base Tabs', () => {
+  const wrapper = mount(Tabs);
+  const rate = wrapper.find('.nut-tabs');
+  expect(rate.exists()).toBe(true);
+});
+
+test('base tabs props', async () => {
+  const wrapper = mount(Tabs, {
+    props: {
+      'v-model': 0,
+      background: '#f5f5f5',
+      color: '#f5f5f5',
+      direction: 'horizontal',
+      type: 'smile',
+      size: 'large',
+      'title-scroll': true
+    },
+    components: {
+      'nut-tabs': Tabs,
+      'nut-tabpane': TabPane
+    }
+  });
+  await nextTick();
+  const stepItem = wrapper.find('.nut-tabs__titles');
+  expect((stepItem.element as HTMLElement).style.background).toEqual('rgb(245, 245, 245)');
+  const _stepItem = wrapper.findAll('.horizontal');
+  expect(_stepItem.length).toBe(1);
+  const _stepItem1 = wrapper.findAll('.nut-tabs__titles')[0];
+  expect(_stepItem1.classes()).toContain('smile');
+  const _stepItem2 = wrapper.findAll('.nut-tabs__titles')[0];
+  expect(_stepItem2.classes()).toContain('large');
+  const _stepItem3 = wrapper.findAll('.nut-tabs__titles')[0];
+  expect(_stepItem3.classes()).toContain('scrollable');
+});
+
+test('base other props', async () => {
+  const wrapper = mount(Tabs, {
+    props: {
+      'animated-time': 500,
+      'title-gutter': '20px'
+    },
+    components: {
+      'nut-tabs': Tabs,
+      'nut-tabpane': TabPane
+    }
+  });
+  await nextTick();
+  const stepItem = wrapper.find('.nut-tabs__content');
+  expect((stepItem.element as HTMLElement).style.transitionDuration).toEqual('500ms');
+  setTimeout(() => {
+    const stepItem1 = wrapper.find('.nut-tabs__titles-item');
+    expect((stepItem1.element as HTMLElement).style.marginLeft).toEqual('20px');
+  }, 0);
+});
+
+test('base Tabs Slots', async () => {
+  const wrapper = mount({
+    components: {
+      'nut-tabs': Tabs,
+      'nut-tabpane': TabPane
+    },
+    template: `
+    <nut-tabs v-model="state.tab7value">
+      <template v-slot:titles>
+        <div
+          class="nut-tabs__titles-item"
+          @click="state.tab7value = item.paneKey"
+          :class="{ active: state.tab7value == item.paneKey }"
+          :key="item.paneKey"
+          v-for="item in state.list6"
+        >
+          <nut-icon v-if="item.icon" :name="item.icon" />
+          <span class="nut-tabs__titles-item__text">{{ item.title }}</span>
+          <span class="nut-tabs__titles-item__line"></span>
+        </div>
+      </template>
+      <nut-tabpane v-for="item in state.list6" :pane-key="item.paneKey">
+        {{ item.title }}
+      </nut-tabpane>
+    </nut-tabs>
+    `,
+    setup() {
+      const state = reactive({
+        tab7value: 'c1',
+        list6: [
+          {
+            title: '自定义 1',
+            paneKey: 'c1',
+            icon: 'dongdong'
+          },
+          {
+            title: '自定义 2',
+            paneKey: 'c2',
+            icon: 'JD'
+          },
+          {
+            title: '自定义 3',
+            paneKey: 'c3'
+          }
+        ]
+      });
+      return { state };
+    }
+  });
+  await nextTick();
+  const tab1 = wrapper.find('.nut-tabs__titles');
+  expect(tab1.exists()).toBe(true);
+  const tab2 = wrapper.findAll('.nut-tabs__titles-item');
+  expect(tab2.length).toBe(3);
+  const tab3 = wrapper.findAll('.nut-tabs__titles-item__text');
+  expect(tab3[0].html()).toContain('自定义 1');
+  expect(tab3[1].html()).toContain('自定义 2');
+  expect(tab3[2].html()).toContain('自定义 3');
+  const tab4 = wrapper.find('.nut-tabs__content');
+  expect(tab4.exists()).toBe(true);
+});
+
+test('base Tabpane Props', async () => {
+  const wrapper = mount({
+    components: {
+      'nut-tabs': Tabs,
+      'nut-tabpane': TabPane
+    },
+    template: `
+    <nut-tabs v-model="state.tab2value">
+      <nut-tabpane title="Tab 1" pane-key="0"> </nut-tabpane>
+      <nut-tabpane title="Tab 2" pane-key="1" :disabled="true"> Tab 2 </nut-tabpane>
+      <nut-tabpane title="Tab 3" pane-key="2"> Tab 3 </nut-tabpane>
+    </nut-tabs>
+    `,
+    setup() {
+      const state = reactive({
+        tab2value: '0'
+      });
+      return { state };
+    }
+  });
+  await nextTick();
+  const tab = wrapper.findAll('.nut-tabs__titles-item');
+  expect(tab.length).toBe(3);
+  const tab1 = wrapper.findAll('.nut-tabs__titles-item')[1];
+  expect(tab1.classes()).toContain('disabled');
+  const tab2 = wrapper.findAll('.nut-tabs__titles-item')[0];
+  expect(tab2.classes()).toContain('active');
+  const tab3 = wrapper.findAll('.nut-tabs__titles-item__text');
+  expect(tab3[0].html()).toContain('Tab 1');
+});
+
+test('base click', async () => {
+  const wrapper = mount({
+    components: {
+      'nut-tabs': Tabs,
+      'nut-tabpane': TabPane
+    },
+    template: `
+    <nut-tabs v-model="state.tab1value">
+      <nut-tabpane title="Tab 1"> Tab 1 </nut-tabpane>
+      <nut-tabpane title="Tab 2"> Tab 2 </nut-tabpane>
+      <nut-tabpane title="Tab 3"> Tab 3 </nut-tabpane>
+    </nut-tabs>
+    `,
+    setup() {
+      const state = reactive({
+        tab1value: '0'
+      });
+      return { state };
+    }
+  });
+  await nextTick();
+  const tab = wrapper.find('.nut-tabs__titles-item');
+  expect(tab.classes()).toContain('active');
+  tab.trigger('click');
+  const tab1 = wrapper.find('.nut-tabs__content');
+  expect((tab1.element as HTMLElement).style.transform).toEqual('translate3d(-0%, 0, 0)');
+});

+ 146 - 0
src/packages/__VUE/uploader/__tests__/index.spec.ts

@@ -0,0 +1,146 @@
+import { config, DOMWrapper, mount } from '@vue/test-utils';
+import Uploader from '../index.vue';
+import { nextTick, ref } from 'vue';
+import NutIcon from '../../icon/index.vue';
+import NutProgress from '../../progress/index.vue';
+
+beforeAll(() => {
+  config.global.components = {
+    NutIcon,
+    NutProgress
+  };
+});
+
+afterAll(() => {
+  config.global.components = {};
+});
+
+test('should render base uploader and type', async () => {
+  const wrapper = mount(Uploader);
+  let up_load = wrapper.find('.nut-uploader');
+  expect(up_load.exists()).toBe(true);
+  let up_load1 = wrapper.find('.nut-uploader__input');
+  expect(up_load1.attributes().type).toBe('file');
+});
+test('should render base uploader props', async () => {
+  const wrapper = mount(Uploader, {
+    props: {
+      'auto-upload': true,
+      capture: true,
+      name: 'files',
+      accept: '.jpg',
+      maximize: '1024 * 50',
+      maximum: 2
+    }
+  });
+  let toast = wrapper.find('.nut-uploader__input');
+  expect(toast.attributes().capture).toBe('camera');
+  expect(toast.attributes().name).toBe('files');
+  expect(toast.attributes().accept).toBe('.jpg');
+  expect(toast.exists()).toBe(true);
+  toast.trigger('click');
+  expect(wrapper.emitted('change'));
+  let toast1 = wrapper.find('.nut-uploader__upload');
+  expect(wrapper.emitted('oversize'));
+  expect(toast1.exists()).toBe(true);
+});
+test('should render base uploader other props', async () => {
+  const wrapper = mount(Uploader, {
+    props: {
+      'is-deletable': true,
+      'file-list': [
+        {
+          name: '文件1.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'success',
+          message: '上传成功',
+          type: 'image'
+        },
+        {
+          name: '文件2.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'error',
+          message: '上传失败',
+          type: 'image'
+        },
+        {
+          name: '文件3.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'uploading',
+          message: '上传中...',
+          type: 'image'
+        }
+      ],
+      headers: {},
+      multiple: true,
+      'is-preview': false,
+      'upload-icon': 'dongdong',
+      'upload-icon-size': '20px'
+    }
+  });
+  await nextTick();
+  let toast = wrapper.find('.nutui-iconfont');
+  expect(toast.exists()).toBe(true);
+  let toast4 = wrapper.find('.close');
+  expect(toast4.exists()).toBe(true);
+  toast4.trigger('click');
+  expect(wrapper.emitted('delete')).toBeTruthy();
+  let toast1 = wrapper.findAll('.nut-uploader__preview');
+  expect(toast1.length).toBe(3);
+  let toast2 = wrapper.find('.nut-uploader__preview-img__c');
+  expect(toast2.exists()).toBe(true);
+  toast2.trigger('click');
+  expect(wrapper.emitted('file-item-click')).toBeTruthy();
+  expect(toast2.attributes().src).toBe(
+    'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif'
+  );
+  let toast3 = wrapper.find('.nut-icon-dongdong');
+  expect(toast3.exists()).toBe(true);
+  expect((toast3.element as HTMLElement).style.fontSize).toEqual('20px');
+  expect((toast3.element as HTMLElement).style.width).toEqual('20px');
+  expect((toast3.element as HTMLElement).style.height).toEqual('20px');
+});
+test('should render base uploader list', async () => {
+  const wrapper = mount(Uploader, {
+    props: {
+      'upload-icon': 'dongdong',
+      'list-type': 'list',
+      'file-list': [
+        {
+          name: '文件1.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'success',
+          message: '上传成功',
+          type: 'image'
+        },
+        {
+          name: '文件2.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'error',
+          message: '上传失败',
+          type: 'image'
+        },
+        {
+          name: '文件3.png',
+          url: 'https://m.360buyimg.com/babel/jfs/t1/164410/22/25162/93384/616eac6cE6c711350/0cac53c1b82e1b05.gif',
+          status: 'uploading',
+          message: '上传中...',
+          type: 'image'
+        }
+      ]
+    }
+  });
+  await nextTick();
+  let toast3 = wrapper.find('.list');
+  expect(toast3.exists()).toBe(true);
+});
+
+test('should render base uploader props disabled', async () => {
+  const wrapper = mount(Uploader, {
+    props: {
+      disabled: true
+    }
+  });
+  let up_load1 = wrapper.find('.nut-uploader__input');
+  expect(up_load1.attributes().disabled).toBe('');
+});

+ 60 - 0
src/packages/__VUE/video/__tests__/video.spec.ts

@@ -0,0 +1,60 @@
+import { mount } from '@vue/test-utils';
+import Video from '../index.vue';
+
+describe('Video', () => {
+  test('base Video', () => {
+    const wrapper = mount(Video);
+    const rate = wrapper.find('.nut-video');
+    expect(rate.exists()).toBe(true);
+  });
+  test('should be displayed after setting the source src and type', () => {
+    const wrapper = mount(Video, {
+      props: {
+        source: {
+          src: 'xxx.mp4',
+          type: 'video/mp4'
+        },
+        options: {
+          controls: true
+        }
+      }
+    });
+    expect(wrapper.html()).toContain('src');
+    expect(wrapper.html()).toContain('type');
+    expect(wrapper.html()).toContain('controls');
+  });
+  test('should be displayed after setting the options autoplay and muted and loop', () => {
+    const wrapper = mount(Video, {
+      props: {
+        options: {
+          autoplay: true,
+          muted: true,
+          loop: true
+        }
+      }
+    });
+    expect(wrapper.html()).toContain('autoplay');
+    expect(wrapper.html()).toContain('muted');
+    expect(wrapper.html()).toContain('loop');
+  });
+  test('should be displayed after setting the options poster and playsinline', () => {
+    const wrapper = mount(Video, {
+      props: {
+        options: {
+          poster: 'xxx.png',
+          playsinline: true
+        }
+      }
+    });
+    expect(wrapper.html()).toContain('xxx.png');
+    expect(wrapper.html()).toContain('playsinline');
+  });
+  test('should be displayed after setting the click', () => {
+    const wrapper = mount(Video);
+    const _html1 = wrapper.find('.show-control');
+    expect(_html1.exists()).toBe(true);
+    wrapper.find('.control-play-btn').trigger('click');
+    const _html2 = wrapper.find('.hide-control');
+    expect(_html2.exists()).toBe(false);
+  });
+});

+ 1 - 1
src/packages/__VUE/video/index.vue

@@ -146,7 +146,7 @@ export default create({
     watch(
       props.options,
       (newValue) => {
-        state.state.isMuted = newValue.muted ? newValue.muted : false;
+        state.state.isMuted = newValue ? newValue.muted : false;
       },
       { immediate: true }
     );