mirror of
https://github.com/element-plus/element-plus.git
synced 2025-01-18 10:59:10 +08:00
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:
parent
5249b03bfb
commit
e1add47603
@ -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}`]"
|
||||
|
87
packages/hooks/__tests__/use-attrs.spec.ts
Normal file
87
packages/hooks/__tests__/use-attrs.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
@ -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'
|
||||
|
37
packages/hooks/use-attrs/index.ts
Normal file
37
packages/hooks/use-attrs/index.ts
Normal 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
|
||||
}
|
@ -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>
|
||||
|
@ -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')
|
||||
|
@ -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,
|
||||
|
@ -7,7 +7,6 @@
|
||||
underline && !disabled && 'is-underline'
|
||||
]"
|
||||
:href="disabled ? null : href"
|
||||
v-bind="$attrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i v-if="icon" :class="icon"></i>
|
||||
|
@ -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>,
|
||||
|
@ -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'
|
||||
|
Loading…
Reference in New Issue
Block a user