feat(hooks): add useAttrs hook (#317)

* feat(hooks): add useAttrs hook

* feat(hooks): undo binding class and style automatically

* test: remove unused import statement
This commit is contained in:
Simona 2020-09-19 20:44:07 +08:00 committed by GitHub
parent 5249b03bfb
commit e1add47603
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 189 additions and 37 deletions

View File

@ -1,8 +1,5 @@
<template>
<div
v-bind="$attrs"
:class="['el-divider', `el-divider--${direction}`]"
>
<div :class="['el-divider', `el-divider--${direction}`]">
<div
v-if="$slots.default && direction !== 'vertical'"
:class="['el-divider__text', `is-${contentPosition}`]"

View File

@ -0,0 +1,87 @@
import { mount } from '@vue/test-utils'
import useAttrs from '../use-attrs'
import type { ComponentOptions } from 'vue'
const CLASS = 'a'
const WIDTH = '50px'
const TEST_KEY = 'test'
const TEST_VALUE = 'fake'
const handleClick = jest.fn()
const genComp = (
inheritAttrs = true,
excludeListeners = false,
excludesKeys: string[] = [],
) => {
return {
template: `
<div>
<span v-bind="attrs"></span>
</div>
`,
inheritAttrs,
props: {},
setup() {
const attrs = useAttrs(excludeListeners, excludesKeys)
return {
attrs,
}
},
}
}
const _mount = (Comp: ComponentOptions) => {
return mount({
components: { Comp },
template: `
<comp
class="${CLASS}"
style="width: ${WIDTH}"
${TEST_KEY}="${TEST_VALUE}"
@click="handleClick"
/>`,
methods: {
handleClick,
},
})
}
afterEach(() => {
handleClick.mockClear()
})
describe('useAttrs', () => {
test('class and style should not bind to child node', async () => {
const wrapper = _mount(genComp(true))
const container = wrapper.element as HTMLDivElement
const span = wrapper.find('span')
expect(wrapper.classes(CLASS)).toBe(true)
expect(container.style.width).toBe(WIDTH)
expect(span.classes(CLASS)).toBe(false)
expect(span.element.style.width).toBe('')
expect(span.attributes(TEST_KEY)).toBe(TEST_VALUE)
await span.trigger('click')
expect(handleClick).toBeCalledTimes(2)
})
test('excluded listeners should not bind to child node', async () => {
const wrapper = _mount(genComp(true, true))
const span = wrapper.find('span')
await span.trigger('click')
expect(handleClick).toBeCalledTimes(1)
})
test('excluded attributes should not bind to child node', async () => {
const wrapper = _mount(genComp(true, false, [TEST_KEY]))
const span = wrapper.find('span')
expect(span.attributes(TEST_KEY)).toBeUndefined()
})
})

View File

@ -1,3 +1,4 @@
export { default as useAttrs } from './use-attrs'
export { default as useEvents } from './use-events'
export { default as useLockScreen } from './use-lockscreen'
export { default as useRestoreActive } from './use-restore-active'

View File

@ -0,0 +1,37 @@
import {
getCurrentInstance,
reactive,
shallowRef,
watchEffect,
} from 'vue'
import { entries } from '@element-plus/utils/util'
const DEFAULT_EXCLUDE_KEYS = ['class', 'style']
const LISTENER_PREFIX = /^on[A-Z]/
export default (excludeListeners = false, excludeKeys: string[] = []) => {
const instance = getCurrentInstance()
const attrs = shallowRef({})
const allExcludeKeys = excludeKeys.concat(DEFAULT_EXCLUDE_KEYS)
// Since attrs are not reactive, make it reactive instead of doing in `onUpdated` hook for better performance
instance.attrs = reactive(instance.attrs)
watchEffect(() => {
const res = entries(instance.attrs)
.reduce((acm, [key, val]) => {
if (
!allExcludeKeys.includes(key) &&
!(excludeListeners && LISTENER_PREFIX.test(key))
) {
acm[key] = val
}
return acm
}, {})
attrs.value = res
})
return attrs
}

View File

@ -1,5 +1,9 @@
<template>
<div ref="container" class="el-image">
<div
ref="container"
:class="['el-image', $attrs.class]"
:style="$attrs.style"
>
<slot v-if="loading" name="placeholder">
<div class="el-image__placeholder"></div>
</slot>
@ -9,7 +13,7 @@
<img
v-else
class="el-image__inner"
v-bind="$attrs"
v-bind="attrs"
:src="src"
:style="imageStyle"
:class="{ 'el-image__inner--center': alignCenter, 'el-image__preview': preview }"
@ -32,10 +36,11 @@
import { defineComponent, computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { isString } from '@vue/shared'
import throttle from 'lodash/throttle'
import { useAttrs } from '@element-plus/hooks'
import isServer from '@element-plus/utils/isServer'
import { on, off, getScrollContainer, isInContainer } from '@element-plus/utils/dom'
import { t } from '@element-plus/locale'
import ImageViewer from './image-viewer'
import ImageViewer from './image-viewer.vue'
const isSupportObjectFit = () => document.documentElement.style.objectFit !== undefined
const isHtmlEle = e => e && e.nodeType === 1
@ -55,6 +60,7 @@ export default defineComponent({
components: {
ImageViewer,
},
inheritAttrs: false,
props: {
src: {
type: String,
@ -82,12 +88,13 @@ export default defineComponent({
},
},
emits: ['error'],
setup(props, { emit, attrs }) {
setup(props, { emit }) {
// init here
const attrs = useAttrs()
const hasLoadError = ref(false)
const loading = ref(true)
const imgWidth = ref(false)
const imgHeight = ref(false)
const imgWidth = ref(0)
const imgHeight = ref(0)
const showViewer = ref(false)
const container = ref<HTMLElement | null>(null)
const show = ref(props.lazy)
@ -158,6 +165,8 @@ export default defineComponent({
const loadImage = () => {
if (isServer) return
const attributes = attrs.value
// reset status
loading.value = true
hasLoadError.value = false
@ -168,15 +177,15 @@ export default defineComponent({
// bind html attrs
// so it can behave consistently
Object.keys(attrs)
Object.keys(attributes)
.forEach(key => {
const value = attrs[key]
const value = attributes[key]
img.setAttribute(key, value)
})
img.src = props.src
}
function handleLoad(e: Event, img: Any) {
function handleLoad(e: Event, img: HTMLImageElement) {
imgWidth.value = img.width
imgHeight.value = img.height
loading.value = false
@ -255,6 +264,7 @@ export default defineComponent({
})
return {
attrs,
loading,
hasLoadError,
showViewer,
@ -272,6 +282,3 @@ export default defineComponent({
},
})
</script>
<style>
</style>

View File

@ -36,15 +36,16 @@ describe('Input.vue', () => {
const inputElm = wrapper.find('input')
const vm = wrapper.vm as any
const nativeInput = inputElm.element
await inputElm.trigger('focus')
expect(inputElm.exists()).toBe(true)
expect(handleFocus).toHaveBeenCalled()
expect(wrapper.attributes('placeholder')).toBe('请输入内容')
expect(inputElm.element.value).toBe('input')
expect(wrapper.attributes('minlength')).toBe('3')
expect(wrapper.attributes('maxlength')).toBe('5')
expect(nativeInput.placeholder).toBe('请输入内容')
expect(nativeInput.value).toBe('input')
expect(nativeInput.minLength).toBe(3)
expect(nativeInput.maxLength).toBe(5)
vm.input = 'text'
await sleep()
@ -406,7 +407,7 @@ describe('Input.vue', () => {
await inputWrapper.trigger('compositionupdate')
await inputWrapper.trigger('input')
await inputWrapper.trigger('compositionend')
expect(handleInput).toBeCalled()
expect(handleInput).toBeCalledTimes(1)
// native input value is controlled
expect(vm.input).toEqual('a')
expect(nativeInput.value).toEqual('a')

View File

@ -11,8 +11,10 @@
'el-input-group--prepend': $slots.prepend,
'el-input--prefix': $slots.prefix || prefixIcon,
'el-input--suffix': $slots.suffix || suffixIcon || clearable || showPassword
}
},
$attrs.class
]"
:style="$attrs.style"
@mouseenter="hovering = true"
@mouseleave="hovering = false"
>
@ -25,7 +27,7 @@
v-if="type !== 'textarea'"
ref="input"
class="el-input__inner"
v-bind="$attrs"
v-bind="attrs"
:type="showPassword ? (passwordVisible ? 'text': 'password') : type"
:disabled="inputDisabled"
:readonly="readonly"
@ -79,7 +81,7 @@
v-else
ref="textarea"
class="el-textarea__inner"
v-bind="$attrs"
v-bind="attrs"
:tabindex="tabindex"
:disabled="inputDisabled"
:readonly="readonly"
@ -112,6 +114,7 @@ import {
onMounted,
onUpdated,
} from 'vue'
import { useAttrs } from '@element-plus/hooks'
import { UPDATE_MODEL_EVENT, VALIDATE_STATE_MAP } from '@element-plus/utils/constants'
import { isObject } from '@element-plus/utils/util'
import isServer from '@element-plus/utils/isServer'
@ -145,6 +148,8 @@ const PENDANT_MAP = {
export default defineComponent({
name: 'ElInput',
inheritAttrs: false,
props: {
modelValue: {
type: [String, Number],
@ -217,10 +222,11 @@ export default defineComponent({
},
},
emits: [UPDATE_MODEL_EVENT, 'change', 'focus', 'blur', 'clear'],
emits: [UPDATE_MODEL_EVENT, 'input', 'change', 'focus', 'blur', 'clear'],
setup(props, ctx) {
const instance = getCurrentInstance()
const attrs = useAttrs(true)
const elForm = inject<ElForm>('elForm', {} as any)
const elFormItem = inject<ElFormItem>('elFormItem', {} as any)
@ -319,15 +325,18 @@ export default defineComponent({
}
const handleInput = event => {
const { value } = event.target
// should not emit input during composition
// see: https://github.com/ElemeFE/element/issues/10516
if (isComposing.value) return
// hack for https://github.com/ElemeFE/element/issues/8548
// should remove the following line when we don't support IE
if (event.target.value === nativeInputValue.value) return
if (value === nativeInputValue.value) return
ctx.emit(UPDATE_MODEL_EVENT, event.target.value)
ctx.emit(UPDATE_MODEL_EVENT, value)
ctx.emit('input', value)
// ensure native input value is controlled
// see: https://github.com/ElemeFE/element/issues/12850
@ -442,6 +451,7 @@ export default defineComponent({
return {
input,
textarea,
attrs,
inputSize,
validateState,
validateIcon,

View File

@ -7,7 +7,6 @@
underline && !disabled && 'is-underline'
]"
:href="disabled ? null : href"
v-bind="$attrs"
@click="handleClick"
>
<i v-if="icon" :class="icon"></i>

View File

@ -1,5 +1,5 @@
import isServer from './isServer'
import { camelize } from './util'
import { camelize, isObject } from './util'
/* istanbul ignore next */
const trim = function(s: string) {
@ -127,23 +127,32 @@ export const getStyle = function(
export function setStyle(
element: HTMLElement,
styleName: CSSStyleDeclaration | string,
value: string,
value?: string,
): void {
if (!element || !styleName) return
if (typeof styleName === 'object') {
for (const prop in styleName) {
if (styleName.hasOwnProperty(prop)) {
setStyle(element, prop, styleName[prop])
}
}
if (isObject(styleName)) {
Object.keys(styleName).forEach(prop => {
setStyle(element, prop, styleName[prop])
})
} else {
styleName = camelize(styleName)
element.style[styleName] = value
}
}
export function removeStyle(element: HTMLElement, style: CSSStyleDeclaration | string) {
if (!element || !style) return
if (isObject(style)) {
Object.keys(style).forEach(prop => {
setStyle(element, prop, '')
})
} else {
setStyle(element, style, '')
}
}
export const isScroll = (
el: HTMLElement,
isVertical?: Nullable<boolean>,

View File

@ -164,4 +164,8 @@ export function entries<T>(obj: Hash<T>): [string, T][] {
.map((key: string) => ([key, obj[key]]))
}
export function isUndefined(val: any) {
return val === void 0
}
export { isVNode } from 'vue'