mirror of
https://github.com/element-plus/element-plus.git
synced 2024-11-27 02:01:15 +08:00
### feat: add dialog (#197)
* Add overlay component; Dialog component almost done * feat(dialog): add use-lockscreen * feat(dialog): coding completed awaiting tests * feat(dialog): finish writing test cases * fix test failures * Address PR comments * fallback some changes
This commit is contained in:
parent
90ff286ac0
commit
ef92b6c11c
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,3 +11,4 @@ yarn-error.log
|
||||
storybook-static
|
||||
coverage/
|
||||
website-dist
|
||||
website/play/
|
||||
|
187
packages/dialog/__tests__/dialog.spec.ts
Normal file
187
packages/dialog/__tests__/dialog.spec.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Dialog from '../src/index'
|
||||
|
||||
const AXIOM = 'Rem is the best girl'
|
||||
|
||||
const _mount = ({
|
||||
slots,
|
||||
...rest
|
||||
}: Indexable<any>) => {
|
||||
return mount(Dialog, {
|
||||
slots: {
|
||||
default: AXIOM,
|
||||
...slots,
|
||||
},
|
||||
...rest,
|
||||
})
|
||||
}
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
describe('Dialog.vue', () => {
|
||||
test('render test', () => {
|
||||
const wrapper = _mount({
|
||||
slots: {
|
||||
default: AXIOM,
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toEqual(AXIOM)
|
||||
})
|
||||
|
||||
test('dialog should have a title when title has been given', () => {
|
||||
const HEADER = 'I am header'
|
||||
let wrapper = _mount({
|
||||
slots: {
|
||||
header: HEADER,
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.el-dialog__header').text()).toBe(HEADER)
|
||||
|
||||
wrapper = _mount({
|
||||
props: {
|
||||
title: HEADER,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.el-dialog__header').text()).toBe(HEADER)
|
||||
})
|
||||
|
||||
test('dialog should have a footer when footer has been given', () => {
|
||||
const wrapper = _mount({
|
||||
slots: {
|
||||
footer: AXIOM,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.el-dialog__footer').exists()).toBe(true)
|
||||
expect(wrapper.find('.el-dialog__footer').text()).toBe(AXIOM)
|
||||
})
|
||||
|
||||
test('should append dialog to body when appendToBody is true', () => {
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
appendToBody: true,
|
||||
},
|
||||
})
|
||||
expect(document.body.firstElementChild.classList.contains('el-overlay')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('should center dialog', () => {
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
center: true,
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.el-dialog--center').exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('should show close button', () => {
|
||||
const wrapper = _mount({})
|
||||
expect(wrapper.find('.el-dialog__close').exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('should close dialog when click on close button', async () => {
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
modelValue: true,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('.el-dialog__headerbtn').trigger('click')
|
||||
expect(wrapper.vm.visible).toBe(false)
|
||||
})
|
||||
|
||||
describe('mask related', () => {
|
||||
test('should not have overlay mask when mask is false', () => {
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
mask: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.el-overlay').exists()).toBe(false)
|
||||
})
|
||||
|
||||
test('should close the modal when clicking on mask when `closeOnClickModal` is true', async () => {
|
||||
const wrapper = _mount({})
|
||||
|
||||
expect(wrapper.find('.el-overlay').exists()).toBe(true)
|
||||
|
||||
await wrapper.find('.el-overlay').trigger('click')
|
||||
expect(wrapper.vm.visible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('life cycles', () => {
|
||||
test('should call before close', async () => {
|
||||
const beforeClose = jest.fn()
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
beforeClose,
|
||||
},
|
||||
})
|
||||
|
||||
wrapper.vm.handleClose()
|
||||
expect(beforeClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should not close dialog when user cancelled', () => {
|
||||
const beforeClose = jest.fn().mockImplementation((hide: (cancel: boolean) => void) => hide(true))
|
||||
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
beforeClose,
|
||||
modelValue: true,
|
||||
},
|
||||
})
|
||||
|
||||
wrapper.vm.handleClose()
|
||||
expect(beforeClose).toHaveBeenCalled()
|
||||
expect(wrapper.vm.visible).toBe(true)
|
||||
})
|
||||
|
||||
test('should open and close with delay', async () => {
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
openDelay: 200,
|
||||
closeDelay: 200,
|
||||
modelValue: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.visible).toBe(false)
|
||||
|
||||
await wrapper.setProps({
|
||||
modelValue: true,
|
||||
})
|
||||
|
||||
expect(wrapper.vm.visible).toBe(false)
|
||||
|
||||
jest.runOnlyPendingTimers()
|
||||
|
||||
expect(wrapper.vm.visible).toBe(true)
|
||||
})
|
||||
|
||||
test('should destroy on close', async () => {
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
modelValue: true,
|
||||
destroyOnClose: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.vm.visible).toBe(true)
|
||||
|
||||
wrapper.vm.handleClose()
|
||||
await wrapper.setProps({
|
||||
// manually setting this prop because that Transition is not available in testing,
|
||||
// updating model value event was emitted via transition hooks.
|
||||
modelValue: false,
|
||||
})
|
||||
await nextTick()
|
||||
expect(wrapper.html()).toBe('<!---->')
|
||||
})
|
||||
})
|
||||
})
|
5
packages/dialog/doc/index.stories.ts
Normal file
5
packages/dialog/doc/index.stories.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default {
|
||||
title: 'Dialog',
|
||||
}
|
||||
|
||||
|
5
packages/dialog/index.ts
Normal file
5
packages/dialog/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { App } from 'vue'
|
||||
import Dialog from './src/index.vue'
|
||||
export default (app: App): void => {
|
||||
app.component(Dialog.name, Dialog)
|
||||
}
|
12
packages/dialog/package.json
Normal file
12
packages/dialog/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@element-plus/dialog",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/test-utils": "^2.0.0-beta.0"
|
||||
}
|
||||
}
|
14
packages/dialog/src/dialog.d.ts
vendored
Normal file
14
packages/dialog/src/dialog.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
export interface UseDialogProps {
|
||||
beforeClose?: (close: (shouldCancel: boolean) => void) => void
|
||||
closeOnClickModal: boolean
|
||||
closeOnPressEscape: boolean
|
||||
closeDelay: number
|
||||
destroyOnClose: boolean
|
||||
fullscreen: boolean
|
||||
lockScroll: boolean
|
||||
modelValue: boolean
|
||||
openDelay: number
|
||||
top: string
|
||||
width: string
|
||||
zIndex?: number
|
||||
}
|
215
packages/dialog/src/index.ts
Normal file
215
packages/dialog/src/index.ts
Normal file
@ -0,0 +1,215 @@
|
||||
// TODO: Replace this file with single .ts file after styles merged
|
||||
import {
|
||||
defineComponent,
|
||||
Transition,
|
||||
Teleport,
|
||||
h,
|
||||
withDirectives,
|
||||
vShow,
|
||||
} from 'vue'
|
||||
|
||||
import { TrapFocus } from '@element-plus/directives'
|
||||
import { isValidWidthUnit } from '@element-plus/utils/validators'
|
||||
|
||||
import ElOverlay from '@element-plus/overlay'
|
||||
import {
|
||||
default as useDialog,
|
||||
CLOSE_EVENT,
|
||||
CLOSED_EVENT,
|
||||
OPEN_EVENT,
|
||||
OPENED_EVENT,
|
||||
UPDATE_MODEL_EVENT,
|
||||
} from './useDialog'
|
||||
|
||||
import type { PropType, SetupContext } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ElDialog',
|
||||
props: {
|
||||
appendToBody: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
beforeClose: {
|
||||
type: Function as PropType<(...args: any[]) => unknown>,
|
||||
},
|
||||
destroyOnClose: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
center: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
closeOnClickModal: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
closeOnPressEscape: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
fullscreen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
lockScroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
modal: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
openDelay: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
closeDelay: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
top: {
|
||||
type: String,
|
||||
default: '15vh',
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '50%',
|
||||
validator: isValidWidthUnit,
|
||||
},
|
||||
zIndex: {
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
OPEN_EVENT,
|
||||
OPENED_EVENT,
|
||||
CLOSE_EVENT,
|
||||
CLOSED_EVENT,
|
||||
UPDATE_MODEL_EVENT,
|
||||
],
|
||||
setup(props, ctx) {
|
||||
// init here
|
||||
return useDialog(props, ctx as SetupContext)
|
||||
},
|
||||
|
||||
render() {
|
||||
if (this.destroyOnClose && !this.modelValue) {
|
||||
return null
|
||||
}
|
||||
const { $slots } = this
|
||||
const closeBtn = this.showClose
|
||||
? h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'el-dialog__headerbtn',
|
||||
ariaLabel: 'close',
|
||||
onClick: this.handleClose,
|
||||
},
|
||||
h('i', { class: 'el-dialog__close el-icon el-icon-close' }),
|
||||
)
|
||||
: null
|
||||
const header = h(
|
||||
'div',
|
||||
{
|
||||
class: 'el-dialog__header',
|
||||
},
|
||||
[
|
||||
$slots.header
|
||||
? $slots.header()
|
||||
: h('span', { class: 'el-dialog__title' }, this.title),
|
||||
closeBtn,
|
||||
],
|
||||
)
|
||||
|
||||
const body = h(
|
||||
'div',
|
||||
{
|
||||
class: 'el-dialog__body',
|
||||
},
|
||||
$slots.default?.(),
|
||||
)
|
||||
|
||||
const footer = $slots.footer
|
||||
? h('div', { class: 'el-dialog__footer' }, $slots.footer())
|
||||
: null
|
||||
|
||||
const dialog = h(
|
||||
'div',
|
||||
{
|
||||
ariaModal: true,
|
||||
ariaLabel: this.title || 'dialog',
|
||||
class: [
|
||||
'el-dialog',
|
||||
{
|
||||
'is-fullscreen': this.fullscreen,
|
||||
'el-dialog--center': this.center,
|
||||
},
|
||||
this.customClass,
|
||||
],
|
||||
ref: 'dialogRef',
|
||||
role: 'dialog',
|
||||
style: this.style,
|
||||
onClick: (e: MouseEvent) => e.stopPropagation(),
|
||||
},
|
||||
[header, body, footer],
|
||||
)
|
||||
|
||||
const trappedDialog = withDirectives(dialog, [[TrapFocus]])
|
||||
const overlay = withDirectives(
|
||||
h(
|
||||
ElOverlay,
|
||||
{
|
||||
mask: this.modal,
|
||||
onClick: this.onModalClick,
|
||||
zIndex: this.zIndex,
|
||||
},
|
||||
{
|
||||
default: () => trappedDialog,
|
||||
},
|
||||
),
|
||||
[[vShow, this.visible]],
|
||||
)
|
||||
|
||||
const renderer = h(
|
||||
Transition,
|
||||
{
|
||||
name: 'dialog-fade',
|
||||
onAfterEnter: this.afterEnter,
|
||||
onAfterLeave: this.afterLeave,
|
||||
},
|
||||
{
|
||||
default: () => overlay,
|
||||
},
|
||||
)
|
||||
|
||||
if (this.appendToBody) {
|
||||
return h(
|
||||
Teleport,
|
||||
{
|
||||
to: 'body',
|
||||
},
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
return renderer
|
||||
},
|
||||
})
|
160
packages/dialog/src/useDialog.ts
Normal file
160
packages/dialog/src/useDialog.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { computed, ref, watch, nextTick, onMounted } from 'vue'
|
||||
|
||||
import isServer from '@element-plus/utils/isServer'
|
||||
import { UPDATE_MODEL_EVENT } from '@element-plus/utils/constants'
|
||||
import PopupManager from '@element-plus/utils/popup-manager'
|
||||
import { clearTimer } from '@element-plus/utils/util'
|
||||
import { useLockScreen, useRestoreActive, useModal } from '@element-plus/hooks'
|
||||
|
||||
import type { UseDialogProps } from './dialog'
|
||||
import type { SetupContext } from '@vue/runtime-core'
|
||||
|
||||
export const CLOSE_EVENT = 'close'
|
||||
export const OPEN_EVENT = 'open'
|
||||
export const CLOSED_EVENT = 'closed'
|
||||
export const OPENED_EVENT = 'opened'
|
||||
export { UPDATE_MODEL_EVENT }
|
||||
|
||||
export default function(props: UseDialogProps, ctx: SetupContext) {
|
||||
const visible = ref(false)
|
||||
const closed = ref(false)
|
||||
const dialogRef = ref(null)
|
||||
const openTimer = ref<TimeoutHandle>(null)
|
||||
const closeTimer = ref<TimeoutHandle>(null)
|
||||
const zIndex = ref(props.zIndex || PopupManager.nextZIndex())
|
||||
const modalRef = ref<HTMLElement>(null)
|
||||
|
||||
const style = computed(() => {
|
||||
const style = {} as CSSStyleDeclaration
|
||||
if (!props.fullscreen) {
|
||||
style.marginTop = props.top
|
||||
if (props.width) {
|
||||
style.width = props.width
|
||||
}
|
||||
}
|
||||
style.zIndex = String(zIndex.value + 1)
|
||||
return style
|
||||
})
|
||||
|
||||
function afterEnter() {
|
||||
ctx.emit(OPENED_EVENT)
|
||||
}
|
||||
|
||||
function afterLeave() {
|
||||
ctx.emit(CLOSED_EVENT)
|
||||
ctx.emit(UPDATE_MODEL_EVENT, false)
|
||||
}
|
||||
|
||||
function open() {
|
||||
clearTimer(closeTimer)
|
||||
clearTimer(openTimer)
|
||||
if (props.openDelay && props.openDelay > 0) {
|
||||
openTimer.value = window.setTimeout(() => {
|
||||
openTimer.value = null
|
||||
doOpen()
|
||||
}, props.openDelay)
|
||||
} else {
|
||||
doOpen()
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
// if (this.willClose && !this.willClose()) return;
|
||||
clearTimer(openTimer)
|
||||
clearTimer(closeTimer)
|
||||
|
||||
if (props.closeDelay && props.closeDelay > 0) {
|
||||
closeTimer.value = window.setTimeout(() => {
|
||||
closeTimer.value = null
|
||||
doClose()
|
||||
}, props.closeDelay)
|
||||
} else {
|
||||
doClose()
|
||||
}
|
||||
}
|
||||
|
||||
function hide(shouldCancel: boolean) {
|
||||
if (shouldCancel) return
|
||||
closed.value = true
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (props.beforeClose) {
|
||||
props.beforeClose(hide)
|
||||
} else {
|
||||
hide(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onModalClick() {
|
||||
if (props.closeOnClickModal) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
function doOpen() {
|
||||
if (isServer) {
|
||||
return
|
||||
}
|
||||
|
||||
// if (props.willOpen?.()) {
|
||||
// return
|
||||
// }
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function doClose() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
if (props.lockScroll) {
|
||||
useLockScreen(visible)
|
||||
}
|
||||
|
||||
if (props.closeOnPressEscape) {
|
||||
useModal({
|
||||
handleClose,
|
||||
}, visible)
|
||||
}
|
||||
|
||||
useRestoreActive(visible)
|
||||
|
||||
watch(() => props.modelValue, val => {
|
||||
if (val) {
|
||||
closed.value = false
|
||||
open()
|
||||
ctx.emit(OPEN_EVENT)
|
||||
// this.$el.addEventListener('scroll', this.updatePopper)
|
||||
nextTick(() => {
|
||||
dialogRef.value.scrollTop = 0
|
||||
})
|
||||
} else {
|
||||
// this.$el.removeEventListener('scroll', this.updatePopper
|
||||
close()
|
||||
if (!closed.value) {
|
||||
ctx.emit(CLOSE_EVENT)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue) {
|
||||
visible.value = true
|
||||
open()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
afterEnter,
|
||||
afterLeave,
|
||||
handleClose,
|
||||
onModalClick,
|
||||
closed,
|
||||
dialogRef,
|
||||
style,
|
||||
modalRef,
|
||||
visible,
|
||||
zIndex,
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import * as Aria from '@element-plus/utils/aria'
|
||||
|
||||
@ -108,7 +109,7 @@ describe('v-trap-focus', () => {
|
||||
expect(document.activeElement).toBe(wrapper.find('.button-1').element)
|
||||
})
|
||||
|
||||
test('should prevent tab event when there is only one element', async () => {
|
||||
test('should focus on the only focusable element', async () => {
|
||||
wrapper = _mount(`
|
||||
<div v-trap-focus>
|
||||
<button />
|
||||
@ -118,7 +119,7 @@ describe('v-trap-focus', () => {
|
||||
await wrapper.find('button').trigger('keydown', {
|
||||
code: 'Tab',
|
||||
})
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
expect(document.activeElement).toBe(wrapper.find('button').element)
|
||||
})
|
||||
|
||||
test('should update focusable list when children changes', async () => {
|
||||
@ -152,6 +153,8 @@ describe('v-trap-focus', () => {
|
||||
show: true,
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.element[FOCUSABLE_CHILDREN].length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
@ -1,2 +1,2 @@
|
||||
export { default as ClickOutside } from './click-outside'
|
||||
|
||||
export { default as TrapFocus } from './trap-focus'
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { on, off } from '@element-plus/utils/dom'
|
||||
import { obtainAllFocusableElements, EVENT_CODE } from '@element-plus/utils/aria'
|
||||
|
||||
@ -20,6 +21,9 @@ const TrapFocus: ObjectDirective = {
|
||||
if (focusableElement.length > 0 && e.code === EVENT_CODE.tab) {
|
||||
if (focusableElement.length === 1) {
|
||||
e.preventDefault()
|
||||
if (document.activeElement !== focusableElement[0]) {
|
||||
focusableElement[0].focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
const goingBackward = e.shiftKey
|
||||
@ -48,7 +52,9 @@ const TrapFocus: ObjectDirective = {
|
||||
on(document, 'keydown', el[TRAP_FOCUS_HANDLER])
|
||||
},
|
||||
updated(el: ITrapFocusElement) {
|
||||
el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el)
|
||||
nextTick(() => {
|
||||
el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el)
|
||||
})
|
||||
},
|
||||
unmounted(el: ITrapFocusElement) {
|
||||
off(document, 'keydown', el[TRAP_FOCUS_HANDLER])
|
||||
|
@ -31,6 +31,7 @@ import ElTooltip from '@element-plus/tooltip'
|
||||
import ElSlider from '@element-plus/slider'
|
||||
import ElInput from '@element-plus/input'
|
||||
import ElTransfer from '@element-plus/transfer'
|
||||
import ElDialog from '@element-plus/dialog'
|
||||
|
||||
export {
|
||||
ElAlert,
|
||||
@ -64,6 +65,7 @@ export {
|
||||
ElSlider,
|
||||
ElInput,
|
||||
ElTransfer,
|
||||
ElDialog,
|
||||
}
|
||||
|
||||
export default function install(app: App): void {
|
||||
@ -99,4 +101,5 @@ export default function install(app: App): void {
|
||||
ElSlider(app)
|
||||
ElInput(app)
|
||||
ElTransfer(app)
|
||||
ElDialog(app)
|
||||
}
|
||||
|
15
packages/hooks/__tests__/use-lockscreen.spec.ts
Normal file
15
packages/hooks/__tests__/use-lockscreen.spec.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { hasClass } from '@element-plus/utils/dom'
|
||||
import useLockScreen from '../use-lockscreen'
|
||||
|
||||
describe('useLockScreen', () => {
|
||||
test('should lock screen when trigger is true', async () => {
|
||||
const _ref = ref(false)
|
||||
const cls = 'el-popup-parent--hidden'
|
||||
useLockScreen(_ref)
|
||||
expect(hasClass(document.body, cls)).toBe(false)
|
||||
_ref.value = true
|
||||
await nextTick()
|
||||
expect(hasClass(document.body, cls)).toBe(true)
|
||||
})
|
||||
})
|
33
packages/hooks/__tests__/use-modal.spec.ts
Normal file
33
packages/hooks/__tests__/use-modal.spec.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { EVENT_CODE } from '@element-plus/utils/aria'
|
||||
|
||||
import useModal from '../use-modal'
|
||||
|
||||
describe('useModal', () => {
|
||||
test('should work when ref value changed', async () => {
|
||||
const visible = ref(false)
|
||||
const handleClose = jest.fn()
|
||||
|
||||
useModal(
|
||||
{
|
||||
handleClose,
|
||||
},
|
||||
visible,
|
||||
)
|
||||
|
||||
expect(handleClose).not.toHaveBeenCalled()
|
||||
|
||||
visible.value = true
|
||||
await nextTick()
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
code: EVENT_CODE.esc,
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
visible.value = false
|
||||
await nextTick()
|
||||
document.dispatchEvent(event)
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
25
packages/hooks/__tests__/use-restore-active.spec.ts
Normal file
25
packages/hooks/__tests__/use-restore-active.spec.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { ref, nextTick } from 'vue'
|
||||
import useRestoreActive from '../use-restore-active'
|
||||
|
||||
describe('useRestoreActive', () => {
|
||||
it('should restore active element', async () => {
|
||||
const visible = ref(false)
|
||||
useRestoreActive(visible)
|
||||
|
||||
const btn1 = document.createElement('button')
|
||||
const btn2 = document.createElement('button')
|
||||
document.body.appendChild(btn1)
|
||||
document.body.appendChild(btn2)
|
||||
btn1.focus()
|
||||
expect(document.activeElement).toBe(btn1)
|
||||
visible.value = true
|
||||
await nextTick()
|
||||
btn2.focus()
|
||||
expect(document.activeElement).toBe(btn2)
|
||||
visible.value = false
|
||||
await nextTick()
|
||||
expect(document.activeElement).toBe(btn1)
|
||||
|
||||
})
|
||||
})
|
||||
|
@ -1 +1,4 @@
|
||||
export { default as useEvents } from './use-events'
|
||||
export { default as useLockScreen } from './use-lockscreen'
|
||||
export { default as useRestoreActive } from './use-restore-active'
|
||||
export { default as useModal } from './use-modal'
|
||||
|
61
packages/hooks/use-lockscreen/index.ts
Normal file
61
packages/hooks/use-lockscreen/index.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { ref, watch, isRef } from 'vue'
|
||||
|
||||
import getScrollBarWidth from '@element-plus/utils/scrollbar-width'
|
||||
import throwError from '@element-plus/utils/error'
|
||||
import {
|
||||
addClass,
|
||||
removeClass,
|
||||
hasClass,
|
||||
getStyle,
|
||||
} from '@element-plus/utils/dom'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Hook that monitoring the ref value to lock or unlock the screen.
|
||||
* When the trigger became true, it assumes modal is now opened and vice versa.
|
||||
* @param trigger {Ref<boolean>}
|
||||
*/
|
||||
export default (trigger: Ref<boolean>) => {
|
||||
if (!isRef(trigger)) {
|
||||
throwError(
|
||||
'[useLockScreen]',
|
||||
'You need to pass a ref param to this function',
|
||||
)
|
||||
}
|
||||
let scrollBarWidth = 0
|
||||
let withoutHiddenClass = false
|
||||
let bodyPaddingRight = '0'
|
||||
let computedBodyPaddingRight = 0
|
||||
watch(trigger, val => {
|
||||
if (val) {
|
||||
withoutHiddenClass = !hasClass(document.body, 'el-popup-parent--hidden')
|
||||
if (withoutHiddenClass) {
|
||||
bodyPaddingRight = document.body.style.paddingRight
|
||||
computedBodyPaddingRight = parseInt(
|
||||
getStyle(document.body, 'paddingRight'),
|
||||
10,
|
||||
)
|
||||
}
|
||||
scrollBarWidth = getScrollBarWidth()
|
||||
const bodyHasOverflow =
|
||||
document.documentElement.clientHeight < document.body.scrollHeight
|
||||
const bodyOverflowY = getStyle(document.body, 'overflowY')
|
||||
if (
|
||||
scrollBarWidth > 0 &&
|
||||
(bodyHasOverflow || bodyOverflowY === 'scroll') &&
|
||||
withoutHiddenClass
|
||||
) {
|
||||
document.body.style.paddingRight =
|
||||
computedBodyPaddingRight + scrollBarWidth + 'px'
|
||||
}
|
||||
addClass(document.body, 'el-popup-parent--hidden')
|
||||
} else {
|
||||
if (withoutHiddenClass) {
|
||||
document.body.style.paddingRight = bodyPaddingRight
|
||||
removeClass(document.body, 'el-popup-parent--hidden')
|
||||
}
|
||||
withoutHiddenClass = true
|
||||
}
|
||||
})
|
||||
}
|
43
packages/hooks/use-modal/index.ts
Normal file
43
packages/hooks/use-modal/index.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { watch } from 'vue'
|
||||
import { on } from '@element-plus/utils/dom'
|
||||
import { EVENT_CODE } from '@element-plus/utils/aria'
|
||||
import isServer from '@element-plus/utils/isServer'
|
||||
|
||||
import type { Ref, ComputedRef } from 'vue'
|
||||
|
||||
type ModalInstance = {
|
||||
handleClose: () => void
|
||||
};
|
||||
|
||||
const modalStack: ModalInstance[] = []
|
||||
|
||||
const closeModal = (e: KeyboardEvent) => {
|
||||
if (modalStack.length === 0) return
|
||||
if (e.code === EVENT_CODE.esc) {
|
||||
const topModal = modalStack[modalStack.length - 1]
|
||||
topModal.handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
export default (
|
||||
instance: ModalInstance,
|
||||
visibleRef: Ref<boolean> | ComputedRef,
|
||||
) => {
|
||||
watch(
|
||||
() => visibleRef.value,
|
||||
val => {
|
||||
if (val) {
|
||||
modalStack.push(instance)
|
||||
} else {
|
||||
modalStack.splice(
|
||||
modalStack.findIndex(modal => modal === instance),
|
||||
1,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!isServer) {
|
||||
on(document, 'keydown', closeModal)
|
||||
}
|
24
packages/hooks/use-restore-active/index.ts
Normal file
24
packages/hooks/use-restore-active/index.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { isRef, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* This method provides dialogable components the ability to restore previously activated element before
|
||||
* the dialog gets opened
|
||||
*/
|
||||
export default (toggle: Ref<boolean>, initialFocus?: Ref<HTMLElement>) => {
|
||||
let previousActive: HTMLElement
|
||||
watch(() => toggle.value, val => {
|
||||
if (val) {
|
||||
previousActive = document.activeElement as HTMLElement
|
||||
if (isRef(initialFocus)) {
|
||||
initialFocus.value.focus?.()
|
||||
}
|
||||
} else {
|
||||
if (process.env.NODE_ENV === 'testing') {
|
||||
previousActive.focus.call(previousActive)
|
||||
} else {
|
||||
previousActive.focus()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
46
packages/overlay/__tests__/overlay.spec.ts
Normal file
46
packages/overlay/__tests__/overlay.spec.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Overlay from '../src/index.vue'
|
||||
|
||||
const AXIOM = 'Rem is the best girl'
|
||||
|
||||
describe('Overlay.vue', () => {
|
||||
test('render test', async () => {
|
||||
const wrapper = mount(Overlay, {
|
||||
slots: {
|
||||
default: AXIOM,
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toEqual(AXIOM)
|
||||
const testClass = 'test-class'
|
||||
await wrapper.setProps({
|
||||
overlayClass: testClass,
|
||||
})
|
||||
|
||||
expect(wrapper.find(`.${testClass}`)).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should emit click event', async () => {
|
||||
const wrapper = mount(Overlay, {
|
||||
slots: {
|
||||
default: AXIOM,
|
||||
},
|
||||
})
|
||||
await wrapper.find('.el-overlay').trigger('click')
|
||||
expect(wrapper.emitted()).toBeTruthy()
|
||||
})
|
||||
|
||||
test('no mask', async () => {
|
||||
const wrapper = mount(Overlay, {
|
||||
slots: {
|
||||
default: AXIOM,
|
||||
},
|
||||
})
|
||||
const selector = '.el-overlay'
|
||||
expect(wrapper.find(selector).exists()).toBe(true)
|
||||
|
||||
await wrapper.setProps({
|
||||
mask: false,
|
||||
})
|
||||
expect(wrapper.find(selector).exists()).toBe(false)
|
||||
})
|
||||
})
|
3
packages/overlay/index.ts
Normal file
3
packages/overlay/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import ElOverlay from './src/index.vue'
|
||||
|
||||
export default ElOverlay
|
12
packages/overlay/package.json
Normal file
12
packages/overlay/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@element-plus/overlay",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/test-utils": "^2.0.0-beta.0"
|
||||
}
|
||||
}
|
57
packages/overlay/src/index.vue
Normal file
57
packages/overlay/src/index.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<script lang='ts'>
|
||||
import { defineComponent, h } from 'vue'
|
||||
export default defineComponent({
|
||||
name: 'ElOverlay',
|
||||
props: {
|
||||
mask: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
overlayClass: {
|
||||
type: String,
|
||||
},
|
||||
zIndex: {
|
||||
type: Number,
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { slots, emit }) {
|
||||
const onMaskClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
// init here
|
||||
return () => {
|
||||
return props.mask
|
||||
? h(
|
||||
'div',
|
||||
{
|
||||
class: ['el-overlay', props.overlayClass],
|
||||
style: {
|
||||
zIndex: props.zIndex,
|
||||
},
|
||||
onClick: onMaskClick,
|
||||
},
|
||||
slots.default?.(),
|
||||
)
|
||||
: slots.default?.()
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.el-overlay-root {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.el-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
</style>
|
@ -2,7 +2,7 @@ import { computed, Fragment, getCurrentInstance, ref, onMounted, onBeforeUnmount
|
||||
import { debounce } from 'lodash'
|
||||
import { createPopper } from '@popperjs/core'
|
||||
|
||||
import { generateId } from '@element-plus/utils/util'
|
||||
import { generateId, clearTimer } from '@element-plus/utils/util'
|
||||
import { addClass } from '@element-plus/utils/dom'
|
||||
import throwError from '@element-plus/utils/error'
|
||||
|
||||
@ -10,19 +10,11 @@ import { default as useEvents } from '@element-plus/hooks/use-events'
|
||||
|
||||
import useModifier from './useModifier'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import type { IPopperOptions, RefElement, PopperInstance } from './popper'
|
||||
|
||||
export const DEFAULT_TRIGGER = ['hover']
|
||||
export const UPDATE_VALUE_EVENT = 'updateValue'
|
||||
|
||||
const clearTimer = (timer: Ref<Nullable<NodeJS.Timeout>>) => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value)
|
||||
}
|
||||
timer.value = null
|
||||
}
|
||||
|
||||
const getTrigger = () => {
|
||||
const { subTree: { children } } = getCurrentInstance()
|
||||
// SubTree is formed by <slot name="trigger"/><popper />
|
||||
|
@ -1,7 +1,7 @@
|
||||
@import "mixins/mixins";
|
||||
@import "mixins/utils";
|
||||
@import "common/var";
|
||||
@import "common/popup";
|
||||
@import 'mixins/mixins';
|
||||
@import 'mixins/utils';
|
||||
@import 'common/var';
|
||||
@import 'common/popup';
|
||||
|
||||
@include b(dialog) {
|
||||
position: relative;
|
||||
@ -50,7 +50,8 @@
|
||||
color: $--color-info;
|
||||
}
|
||||
|
||||
&:focus, &:hover {
|
||||
&:focus,
|
||||
&:hover {
|
||||
.el-dialog__close {
|
||||
color: $--color-primary;
|
||||
}
|
||||
@ -93,13 +94,22 @@
|
||||
}
|
||||
|
||||
.dialog-fade-enter-active {
|
||||
animation: dialog-fade-in .3s;
|
||||
animation: modal-fade-in 0.3s !important;
|
||||
.el-dialog {
|
||||
animation: dialog-fade-in 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-fade-leave-active {
|
||||
animation: dialog-fade-out .3s;
|
||||
animation: modal-fade-out 0.3s;
|
||||
.el-dialog {
|
||||
animation: dialog-fade-out 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@keyframes dialog-fade-in {
|
||||
0% {
|
||||
transform: translate3d(0, -20px, 0);
|
||||
@ -121,3 +131,22 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ export const EVENT_CODE = {
|
||||
right: 'ArrowRight',
|
||||
top: 'ArrowTop',
|
||||
down: 'ArrowDown',
|
||||
esc: 'Esc',
|
||||
esc: 'Escape',
|
||||
delete: 'Delete',
|
||||
backspace: 'Backspace',
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import isServer from './isServer'
|
||||
|
||||
import { isObject, capitalize, hyphenate, looseEqual, extend, camelize } from '@vue/shared'
|
||||
import { isEmpty, castArray, isEqual } from 'lodash'
|
||||
|
||||
import type { AnyFunction } from './types'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const { hasOwnProperty } = Object.prototype
|
||||
|
||||
@ -127,6 +129,11 @@ export function rafThrottle<T extends AnyFunction<any>>(fn: T): AnyFunction<void
|
||||
|
||||
export const objToArray = castArray
|
||||
|
||||
export const clearTimer = (timer: Ref<TimeoutHandle>) => {
|
||||
clearTimeout(timer.value)
|
||||
timer.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Generating a random int in range (0, max - 1)
|
||||
* @param max {number}
|
||||
|
4
packages/utils/validators.ts
Normal file
4
packages/utils/validators.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const isValidWidthUnit = (val: string) =>
|
||||
['px', 'rem', 'em', 'vw', '%', 'vmin', 'vmax'].some(unit =>
|
||||
val.endsWith(unit),
|
||||
)
|
@ -10,7 +10,7 @@
|
||||
"target": "es6",
|
||||
"sourceMap": true,
|
||||
"lib": [
|
||||
"es2020", "dom"
|
||||
"es2020", "DOM"
|
||||
],
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
|
8
typings/vue-shim.d.ts
vendored
8
typings/vue-shim.d.ts
vendored
@ -9,8 +9,12 @@ declare module '*.vue' {
|
||||
|
||||
declare type Nullable<T> = T | null;
|
||||
|
||||
declare type CustomizedHTMLElement<T> = HTMLElement & T;
|
||||
declare type CustomizedHTMLElement<T> = HTMLElement & T
|
||||
|
||||
declare type Indexable<T> = {
|
||||
[key: string]: T
|
||||
};
|
||||
}
|
||||
|
||||
declare type Hash<T> = Indexable<T>
|
||||
|
||||
declare type TimeoutHandle = ReturnType<typeof global.setTimeout>
|
||||
|
@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-button>{{ value1 }}</el-button>
|
||||
<el-button @click="visible = true">{{ value1 }}</el-button>
|
||||
<el-dialog v-model="visible" :destroy-on-close="false">
|
||||
dialog content
|
||||
<el-button @click="innerVisible = true">
|
||||
inner button
|
||||
</el-button>
|
||||
<el-dialog v-model="innerVisible" destroy-on-close>
|
||||
I am inner dialog
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -9,6 +18,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
value1: 'TEST',
|
||||
visible: false,
|
||||
innerVisible: false,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user