diff --git a/package.json b/package.json index 32fba9b95737a..4f71251974865 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@storybook/addon-storysource": "^5.3.19", "@storybook/html": "^5.3.19", "@types/jest": "^26.0.7", + "@types/lodash-es": "^4.17.3", "@typescript-eslint/eslint-plugin": "^3.7.0", "@typescript-eslint/parser": "^3.7.0", "@vue/compiler-sfc": "^3.0.0-rc.1", @@ -30,9 +31,9 @@ "eslint": "^7.5.0", "eslint-plugin-vue": "^7.0.0-beta.0", "husky": "^4.2.5", - "lint-staged": "^10.2.11", "jest": "^24.1.0", "lerna": "^3.22.1", + "lint-staged": "^10.2.11", "ts-jest": "^26.1.3", "ts-loader": "^8.0.1", "typescript": "^3.9.7", @@ -63,5 +64,8 @@ "description": "A Component Library for Vue3.0", "main": "index.js", "repository": "git@github.com:element-plus/element-plus.git", - "license": "MIT" + "license": "MIT", + "dependencies": { + "lodash-es": "^4.17.15" + } } diff --git a/packages/utils/click-outside.ts b/packages/utils/click-outside.ts new file mode 100644 index 0000000000000..2b13be69e8b65 --- /dev/null +++ b/packages/utils/click-outside.ts @@ -0,0 +1,83 @@ +import {DirectiveBinding, VNode} from 'vue' +import isServer from './isServer' +import { on } from './dom' + +const nodeList = [] +const ctx = '@@clickoutsideContext' + +let startClick +let seed = 0 + +!isServer && on(document, 'mousedown', e => (startClick = e)) + +!isServer && + on(document, 'mouseup', (e: Event) => { + nodeList.forEach(node => node[ctx].documentHandler(e, startClick)) + }) + +function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding, vnode: VNode) { + return function(mouseup = {}, mousedown = {}) { + if ( + !vnode || + !vnode.component || + !mouseup.target || + !mousedown.target || + el.contains(mouseup.target) || + el.contains(mousedown.target) || + el === mouseup.target || + (vnode.component.popperElm && + (vnode.component.popperElm.contains(mouseup.target) || + vnode.component.popperElm.contains(mousedown.target))) + ) + return + + if ( + binding.value && + el[ctx].methodName && + vnode.component[el[ctx].methodName] + ) { + vnode.component[el[ctx].methodName]() + } else { + el[ctx].bindingFn && el[ctx].bindingFn() + } + } +} + +/** + * v-clickoutside + * @desc 点击元素外面才会触发的事件 + * @example + * ```vue + *
+ * ``` + */ +export default { + mounted(el: HTMLElement, binding: DirectiveBinding, vnode: VNode):void { + nodeList.push(el) + const id = seed++ + el[ctx] = { + id, + documentHandler: createDocumentHandler(el, binding, vnode), + methodName: binding.arg, + bindingFn: binding.value, + } + }, + + updated(el: HTMLElement, binding: DirectiveBinding, vnode: VNode): void { + el[ctx].documentHandler = createDocumentHandler(el, binding, vnode) + el[ctx].methodName = binding.arg + el[ctx].bindingFn = binding.value + }, + + unmounted(el: HTMLElement): void { + const len = nodeList.length + + for (let i = 0; i < len; i++) { + if (nodeList[i][ctx].id === el[ctx].id) { + nodeList.splice(i, 1) + break + } + } + delete el[ctx] + }, +} diff --git a/packages/utils/dom.ts b/packages/utils/dom.ts new file mode 100644 index 0000000000000..f3c4d4ba66a8f --- /dev/null +++ b/packages/utils/dom.ts @@ -0,0 +1,223 @@ +import isServer from './isServer' + +const SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g +const MOZ_HACK_REGEXP = /^moz([A-Z])/ + +/* istanbul ignore next */ +const trim = function(s: string) { + return (s || '').replace(/^[\s\uFEFF]+|[\s\uFEFF]+$/g, '') +} +/* istanbul ignore next */ +const camelCase = function(name: string) { + return name + .replace(SPECIAL_CHARS_REGEXP, function(_, __, letter, offset) { + return offset ? letter.toUpperCase() : letter + }) + .replace(MOZ_HACK_REGEXP, 'Moz$1') +} + +/* istanbul ignore next */ +export const on = (function() { + // Since Vue3 does not support < IE11, we don't need to support it as well. + if (!isServer) { + return function( + element: HTMLElement | Document, + event: string, + handler: EventListenerOrEventListenerObject, + ) { + if (element && event && handler) { + element.addEventListener(event, handler, false) + } + } + } +})() + +/* istanbul ignore next */ +export const off = (function() { + if (!isServer) { + return function( + element: HTMLElement, + event: string, + handler: EventListenerOrEventListenerObject, + ) { + if (element && event) { + element.removeEventListener(event, handler, false) + } + } + } +})() + +/* istanbul ignore next */ +export const once = function( + el: HTMLElement, + event: string, + fn: EventListener, +) { + var listener = function() { + if (fn) { + fn.apply(this, arguments) + } + off(el, event, listener) + } + on(el, event, listener) +} + +/* istanbul ignore next */ +export function hasClass(el: HTMLElement, cls: string) { + if (!el || !cls) return false + if (cls.indexOf(' ') !== -1) + throw new Error('className should not contain space.') + if (el.classList) { + return el.classList.contains(cls) + } else { + return (' ' + el.className + ' ').indexOf(' ' + cls + ' ') > -1 + } +} + +/* istanbul ignore next */ +export function addClass(el: HTMLElement, cls: string) { + if (!el) return + let curClass = el.className + const classes = (cls || '').split(' ') + + for (let i = 0, j = classes.length; i < j; i++) { + const clsName = classes[i] + if (!clsName) continue + + if (el.classList) { + el.classList.add(clsName) + } else if (!hasClass(el, clsName)) { + curClass += ' ' + clsName + } + } + if (!el.classList) { + el.className = curClass + } +} + +/* istanbul ignore next */ +export function removeClass(el: HTMLElement, cls: string) { + if (!el || !cls) return + const classes = cls.split(' ') + let curClass = ' ' + el.className + ' ' + + for (let i = 0, j = classes.length; i < j; i++) { + const clsName = classes[i] + if (!clsName) continue + + if (el.classList) { + el.classList.remove(clsName) + } else if (hasClass(el, clsName)) { + curClass = curClass.replace(' ' + clsName + ' ', ' ') + } + } + if (!el.classList) { + el.className = trim(curClass) + } +} + +/* istanbul ignore next */ +// Here I want to use the type CSSStyleDeclaration, but the definition for CSSStyleDeclaration +// has { [index: number]: string } in its type annotation, which does not satisfiy the method +// camelCase(s: string) +// Same as the return type +export const getStyle = function( + element: HTMLElement, + styleName: string, +): string { + if (isServer) return + if (!element || !styleName) return null + styleName = camelCase(styleName) + if (styleName === 'float') { + styleName = 'cssFloat' + } + try { + const computed = document.defaultView.getComputedStyle(element, '') + return element.style[styleName] || computed ? computed[styleName] : null + } catch (e) { + return element.style[styleName] + } +} + +/* istanbul ignore next */ +export function setStyle( + element: HTMLElement, + styleName: CSSStyleDeclaration | string, + value: string, +) { + if (!element || !styleName) return + + if (typeof styleName === 'object') { + for (const prop in styleName) { + if (styleName.hasOwnProperty(prop)) { + setStyle(element, prop, styleName[prop]) + } + } + } else { + styleName = camelCase(styleName) + + element.style[styleName] = value + } +} + +export const isScroll = (el: HTMLElement, isVertical?: Nullable) => { + if (isServer) return + + const determinedDirection = isVertical !== null || isVertical !== undefined + const overflow = determinedDirection + ? isVertical + ? getStyle(el, 'overflow-y') + : getStyle(el, 'overflow-x') + : getStyle(el, 'overflow') + + return overflow.match(/(scroll|auto)/) +} + +export const getScrollContainer = ( + el: HTMLElement, + isVertical?: Nullable, +) => { + if (isServer) return + el.classList + let parent: HTMLElement = el + while (parent) { + if ([window, document, document.documentElement].includes(parent)) { + return window + } + if (isScroll(parent, isVertical)) { + return parent + } + parent = parent.parentNode as HTMLElement + } + + return parent +} + +export const isInContainer = (el: HTMLElement, container: HTMLElement) => { + if (isServer || !el || !container) return false + + const elRect = el.getBoundingClientRect() + let containerRect: Partial + + if ( + [window, document, document.documentElement, null, undefined].includes( + container, + ) + ) { + containerRect = { + top: 0, + right: window.innerWidth, + bottom: window.innerHeight, + left: 0, + } + } else { + containerRect = container.getBoundingClientRect() + } + + return ( + elRect.top < containerRect.bottom && + elRect.bottom > containerRect.top && + elRect.right > containerRect.left && + elRect.left < containerRect.right + ) +} diff --git a/packages/utils/isDef.ts b/packages/utils/isDef.ts new file mode 100644 index 0000000000000..38fca34c7298c --- /dev/null +++ b/packages/utils/isDef.ts @@ -0,0 +1,7 @@ +export function isDef(val) { + return val !== undefined && val !== null +} +export function isKorean(text: string) { + const reg = /([(\uAC00-\uD7AF)|(\u3130-\u318F)])+/gi + return reg.test(text) +} diff --git a/packages/utils/isServer.ts b/packages/utils/isServer.ts new file mode 100644 index 0000000000000..eff99860b625e --- /dev/null +++ b/packages/utils/isServer.ts @@ -0,0 +1 @@ +export default typeof window === undefined diff --git a/packages/utils/merge.ts b/packages/utils/merge.ts new file mode 100644 index 0000000000000..4b9b296b8d2f9 --- /dev/null +++ b/packages/utils/merge.ts @@ -0,0 +1,7 @@ +export default function(target: Object, ...args: Array) { + target = { ...target } + for (let i = 0; i < args.length; i++) { + Object.assign(target, args[i]) + } + return target +} diff --git a/packages/utils/resize-event.ts b/packages/utils/resize-event.ts new file mode 100644 index 0000000000000..67be84c0244c7 --- /dev/null +++ b/packages/utils/resize-event.ts @@ -0,0 +1,49 @@ +import ResizeObserver from 'resize-observer-polyfill' +import isServer from './isServer' + +type ResizableElement = CustomizedHTMLElement<{ + __resizeListeners__: Array; + __ro__: ResizeObserver; +}>; + +/* istanbul ignore next */ +const resizeHandler = function(entries: ResizeObserverEntry[]) { + for (const entry of entries) { + const listeners = + (entry.target as ResizableElement).__resizeListeners__ || [] + if (listeners.length) { + listeners.forEach(fn => { + fn() + }) + } + } +} + +/* istanbul ignore next */ +export const addResizeListener = function( + element: ResizableElement, + fn: Function, +) { + if (isServer) return + if (!element.__resizeListeners__) { + element.__resizeListeners__ = [] + element.__ro__ = new ResizeObserver(resizeHandler) + element.__ro__.observe(element) + } + element.__resizeListeners__.push(fn) +} + +/* istanbul ignore next */ +export const removeResizeListener = function( + element: ResizableElement, + fn: Function, +) { + if (!element || !element.__resizeListeners__) return + element.__resizeListeners__.splice( + element.__resizeListeners__.indexOf(fn), + 1, + ) + if (!element.__resizeListeners__.length) { + element.__ro__.disconnect() + } +} diff --git a/packages/utils/scroll-into-view.ts b/packages/utils/scroll-into-view.ts new file mode 100644 index 0000000000000..0d7b93c2b8ade --- /dev/null +++ b/packages/utils/scroll-into-view.ts @@ -0,0 +1,36 @@ +import isServer from './isServer' + +export default function scrollIntoView( + container: HTMLElement, + selected: HTMLElement, +) { + if (isServer) return + + if (!selected) { + container.scrollTop = 0 + return + } + + const offsetParents = [] + let pointer = selected.offsetParent + while ( + pointer !== null && + container !== pointer && + container.contains(pointer) + ) { + offsetParents.push(pointer) + pointer = (pointer as HTMLElement).offsetParent + } + const top = + selected.offsetTop + + offsetParents.reduce((prev, curr) => prev + curr.offsetTop, 0) + const bottom = top + selected.offsetHeight + const viewRectTop = container.scrollTop + const viewRectBottom = viewRectTop + container.clientHeight + + if (top < viewRectTop) { + container.scrollTop = top + } else if (bottom > viewRectBottom) { + container.scrollTop = bottom - container.clientHeight + } +} diff --git a/packages/utils/scrollbar-width.ts b/packages/utils/scrollbar-width.ts new file mode 100644 index 0000000000000..634bf7ba76bdc --- /dev/null +++ b/packages/utils/scrollbar-width.ts @@ -0,0 +1,29 @@ +import isServer from './isServer' + +let scrollBarWidth: number + +export default function() { + if (isServer) return 0 + if (scrollBarWidth !== undefined) return scrollBarWidth + + const outer = document.createElement('div') + outer.className = 'el-scrollbar__wrap' + outer.style.visibility = 'hidden' + outer.style.width = '100px' + outer.style.position = 'absolute' + outer.style.top = '-9999px' + document.body.appendChild(outer) + + const widthNoScroll = outer.offsetWidth + outer.style.overflow = 'scroll' + + const inner = document.createElement('div') + inner.style.width = '100%' + outer.appendChild(inner) + + const widthWithScroll = inner.offsetWidth + outer.parentNode.removeChild(outer) + scrollBarWidth = widthNoScroll - widthWithScroll + + return scrollBarWidth +} diff --git a/packages/utils/util.ts b/packages/utils/util.ts new file mode 100644 index 0000000000000..20802ad15d5f9 --- /dev/null +++ b/packages/utils/util.ts @@ -0,0 +1,151 @@ +import isServer from './isServer' +import { isObject, castArray, isEmpty, isEqual, capitalize } from 'lodash-es' + +const { hasOwnProperty } = Object.prototype + +type Any = Record | unknown + +export function hasOwn(obj: Any, key: string): boolean { + return hasOwnProperty.call(obj, key) +} + +function extend(to: T, _from: K): T & K { + return Object.assign(to, _from) +} + +export function toObject(arr: Array): Record { + const res = {} + for (let i = 0; i < arr.length; i++) { + if (arr[i]) { + extend(res, arr[i]) + } + } + return res +} + +export const getValueByPath = (obj: Any, paths = ''): unknown => { + let ret: unknown = obj + paths.split('.').map(path => { + ret = ret?.[path] + }) + return ret +} + +export function getPropByPath(obj: Any, path: string, strict: boolean): { + o: unknown, + k: string, + v: Nullable, +} { + // we can't use any here, the only option here is unknown + let tempObj: unknown = obj + path = path.replace(/\[(\w+)\]/g, '.$1') + path = path.replace(/^\./, '') + + const keyArr = path.split('.') + let i = 0 + for (i; i < keyArr.length - 1; i++) { + if (!tempObj && !strict) break + const key = keyArr[i] + tempObj = tempObj?.[key] + if (!tempObj && strict) { + throw new Error('please transfer a valid prop path to form item!') + } + } + return { + o: tempObj, + k: keyArr[i], + v: tempObj?.[keyArr[i]], + } +} + +/** + * Generate random number in range [0, 1000] + * Maybe replace with [uuid](https://www.npmjs.com/package/uuid) + */ +export const generateId = (): number => Math.floor(Math.random() * 10000) + +// use isEqual instead +// export const valueEquals + + +export const escapeRegexpString = (value = ''): string=> + String(value).replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') + +// Use native Array.find, Array.findIndex instead + +// coerce truthy value to array +export const coerceTruthyValueToArray = castArray + +export const isIE = function(): boolean { + return !isServer && !isNaN(Number(document.DOCUMENT_NODE)) +} + +export const isEdge = function(): boolean { + return !isServer && navigator.userAgent.indexOf('Edge') > -1 +} + +export const isFirefox = function(): boolean { + return ( + !isServer && !!window.navigator.userAgent.match(/firefox/i) + ) +} + +export const autoprefixer = function(style: CSSStyleDeclaration): CSSStyleDeclaration { + if (typeof style !== 'object') return style + const rules = ['transform', 'transition', 'animation'] + const prefixes = ['ms-', 'webkit-'] + rules.forEach(rule => { + const value = style[rule] + if (rule && value) { + prefixes.forEach(prefix => { + style[prefix + rule] = value + }) + } + }) + return style +} + +export const kebabCase = function(str: string): string { + const hyphenateRE = /([^-])([A-Z])/g + return str + .replace(hyphenateRE, '$1-$2') + .replace(hyphenateRE, '$1-$2') + .toLowerCase() +} + +export const looseEqual = function(a: T, b: K): boolean { + const isObjectA = isObject(a) + const isObjectB = isObject(b) + if (isObjectA && isObjectB) { + return JSON.stringify(a) === JSON.stringify(b) + } else if (!isObjectA && !isObjectB) { + return String(a) === String(b) + } else { + return false + } +} + +// reexport from lodash +export { + isEmpty, + isEqual, + capitalize, +} + +export function rafThrottle(fn: (args: Record) => unknown): (...args: any[]) => any { + let locked = false + return function(...args) { + if (locked) return + locked = true + window.requestAnimationFrame(() => { + fn.apply(this, args) + locked = false + }) + } +} + +export const objToArray = castArray + +export const isVNode = (node: unknown): boolean => { + return node !== null && typeof node === 'object' && hasOwn(node, 'componentOptions') +} diff --git a/typings/vue-shim.d.ts b/typings/vue-shim.d.ts index c6c5eaa6ab70a..0f9330f090984 100644 --- a/typings/vue-shim.d.ts +++ b/typings/vue-shim.d.ts @@ -1,8 +1,12 @@ declare module '*.vue' { import { Component, ComponentPublicInstance } from 'vue' const _default: Component & { - // eslint-disable-next + // eslint-disable-next-line new (): ComponentPublicInstance } export default _default } + +declare type Nullable = T | null; + +declare type CustomizedHTMLElement = HTMLElement & T; diff --git a/yarn.lock b/yarn.lock index f29c4b18b3865..a5f7ab806cc79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2710,6 +2710,18 @@ "@types/koa-compose" "*" "@types/node" "*" +"@types/lodash-es@^4.17.3": + version "4.17.3" + resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.3.tgz#87eb0b3673b076b8ee655f1890260a136af09a2d" + integrity sha512-iHI0i7ZAL1qepz1Y7f3EKg/zUMDwDfTzitx+AlHhJJvXwenP682ZyGbgPSc5Ej3eEAKVbNWKFuwOadCj5vBbYQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.158" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.158.tgz#b38ea8b6fe799acd076d7a8d7ab71c26ef77f785" + integrity sha512-InCEXJNTv/59yO4VSfuvNrZHt7eeNtWQEgnieIA+mIC+MOWM9arOWG2eQ8Vhk6NbOre6/BidiXhkZYeDY9U35w== + "@types/lru-cache@^5.1.0": version "5.1.0" resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" @@ -9126,6 +9138,11 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash-es@^4.17.15: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" + integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== + lodash._reinterpolate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"