UNPKG

veui

Version:

Baidu Enterprise UI for Vue.js.

1,037 lines (914 loc) 26 kB
import { findIndex, uniq, get, clamp, isPlainObject, isNumber } from 'lodash' import { resolveOffset } from './helper' /** * * @param {Element} element 参考元素 * @param {string} selectors 用来查找目标元素的选择器 * @returns {?Element} 查找到的元素 */ export function closest (element, selectors) { if (element.closest) { return element.closest(selectors) } // Polyfill from https://developer.mozilla.org/en-US/docs/Web/API/Element/closest let matches = (element.document || element.ownerDocument).querySelectorAll( selectors ) let i do { i = matches.length while (--i >= 0 && matches.item(i) !== element) {} } while (i < 0 && (element = element.parentElement)) return element } function testIndeterminate () { if (typeof document === 'undefined') { return null } let checkbox = document.createElement('input') checkbox.type = 'checkbox' checkbox.indeterminate = true document.body.appendChild(checkbox) checkbox.addEventListener('click', (event) => event.stopPropagation()) checkbox.click() let needPatch = !checkbox.checked checkbox.parentNode.removeChild(checkbox) return needPatch } // cache test result for repeated use let needIndeterminatePatch = testIndeterminate() // IE won't trigger change event for indeterminate checkboxes // Problem see http://stackoverflow.com/questions/33523130/ie-does-not-fire-change-event-on-indeterminate-checkbox-when-you-click-on-it // A more thorough compatibility fix here: export function patchIndeterminate (element) { if ( !needIndeterminatePatch || !element.tagName || element.tagName.toLowerCase() !== 'input' || !element.type || element.type.toLowerCase() !== 'checkbox' ) { return } // The indeterminate status will already be changed when click event is dispatched // so listen to mousedown events for all associated labels let indeterminate let label = closest(element, 'label') let target = label || element let targets = label ? [label] : [] if (element.id) { targets = [ target, ...document.querySelectorAll(`label[for="${element.id}"]`) ] } targets.forEach((target) => { target.addEventListener('mousedown', function () { indeterminate = element.indeterminate }) }) // Click on labels will also trigger change events for checkboxes element.addEventListener( 'click', function () { if (!indeterminate) { return } element.checked = !element.checked trigger(element, 'change') }, false ) } /** * 判断两个元素是否存在父子关系,与原生方法不同之处在于可以感知 * VEUI 中的 portal 机制。 * * @param {Element} parentElem 父元素 * @param {Element} childElem 子元素 * @param {boolean} followPortal 是否考虑 portal 嵌套关系 * @return {boolean} */ export function contains (parentElem, childElem, followPortal) { if (followPortal) { let portalEntry = getPortalEntry(childElem) return ( contains(parentElem, childElem) || (portalEntry ? contains(parentElem, portalEntry) || contains(parentElem, portalEntry, true) : false) ) } return parentElem.contains ? parentElem.contains(childElem) : document.body.contains.call(parentElem, childElem) } /** * 判断一个元素是否匹配某个指定的选择器。 * TODO: remove after dropping IE11 support * * @param {Element} elem 指定元素 * @param {string} selector 选择器字符串 * @return {boolean} 是否匹配 */ export function matches (elem, selector) { return (elem.matches || elem.msMatchesSelector).call(elem, selector) } /** * 获取离指定元素最近的可滚动的父级元素 * * @param {Element} elem 指定元素 * @param {Boolean} includeSelf 是否在自身可滚动时直接返回,默认为 `false` * @return {Element} 最近的可滚动父级元素 */ export function getScrollParent (elem, includeSelf = false) { if (!elem) { return null } let current = includeSelf ? elem : elem.parentNode if (!current) { return null } if (current.scrollHeight > current.clientHeight) { return current } return getScrollParent(current, false) } /** * 检查指定元素内容是否溢出 * * @param {Element} elem 指定元素 */ export function isOverflow (elem) { return ( elem.scrollHeight > elem.clientHeight || elem.scrollWidth > elem.clientWidth ) } function getScrollContainerOffset (elem, container) { if (!container) { return { top: elem.offsetTop, left: elem.offsetLeft } } let current = elem let top = 0 let left = 0 while (current && current !== container) { if (container.contains(current)) { top += current.offsetTop left += current.offsetLeft } else { top -= container.offsetTop left -= container.offsetLeft } current = current.offsetParent } return { top, left } } function getOverlayAncestor (elem) { let parent = elem while (parent && parent.tagName.toLowerCase() !== 'veui-x-overlay') { parent = parent.parentElement } return parent } /** * 如果指定元素不完全在滚动父级可视范围内,将其滚动到可视范围内 * * @param {Element} elem 指定元素 * @param {boolean} forceToTop 尝试强制滚动到容器顶部 */ export function scrollIntoView (elem, forceToTop) { if (!document.documentElement.contains(elem)) { return } const overlay = getOverlayAncestor(elem) let container = getScrollParent(elem) // 如果在 overlay 里面,那么滚动边界不能突破 overlay if (!container || (overlay && !overlay.contains(container))) { return } let { top } = getScrollContainerOffset(elem, container) if (forceToTop) { container.scrollTop = top return } let { offsetHeight: elHeight } = elem let { scrollTop, clientHeight: scrollportHeight } = container let clientTop = top - scrollTop let clientBottom = clientTop + elHeight // fully visible if (clientTop >= 0 && clientBottom <= scrollportHeight) { return } if (clientTop < 0) { container.scrollTop = top } else { container.scrollTop = top + elHeight - scrollportHeight } } const FOCUSABLE_SELECTOR = ` a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, [tabindex], [contentEditable=true]` function isPreventFocus (el) { return ( !matches(el, '[tabindex="-1"]') && (el.offsetWidth || el.offsetHeight || el.getClientRects().length) ) } export function isFocusable (el) { return matches(el, FOCUSABLE_SELECTOR) && isPreventFocus(el) } export function isInsideFocusable (el, context = document.body) { while (el && el !== context) { if (isFocusable(el)) { return true } el = el.parentNode } return false } /** * 获取目标元素下所有可以获取焦点的元素 * * @param {Element} elem 需要查找的目标元素 * @param {string=} selector 可选的用于查找的选择器 * @returns {Array.<Element>} 可以获取焦点的元素数组 */ export function getFocusable (elem, selector = FOCUSABLE_SELECTOR) { return [...elem.querySelectorAll(selector)].filter(isPreventFocus) } /** * 将焦点移入指定元素内的第一个可聚焦的元素 * * @param {Element} elem 需要查找的指定元素 * @param {number=} index 聚焦元素在可聚焦元素的位置 * @param {Boolean=} ignoreAutofocus 是否忽略 autofocus * @returns {Boolean} 是否找到可聚焦的元素 */ export function focusIn (elem, index = 0, ignoreAutofocus) { if (!ignoreAutofocus) { let auto = getFocusable(elem, '[data-autofocus]')[0] || getFocusable(elem, '[autofocus]')[0] if (auto) { focus(auto) return true } } let focusable = getFocusable(elem) if (index === 0) { let first = focusable[0] if (first) { focus(first) return true } } let count = focusable.length if (!count) { return false } focus(focusable[(index + count) % count]) return true } /** * 聚焦到前/后第指定个可聚焦元素 * * @param {HTMLElement} elem 起始元素 * @param {number} step 偏移量 */ function focusNav (elem, step) { let focusable = getFocusable(document.body) let index = findIndex(focusable, (el) => el === elem) if (index !== -1) { let next = focusable[index + step] if (next) { next.focus() } } } /** * 聚焦到上一个可聚焦元素 * * @param {HTMLElement} elem 起始元素 */ export function focusBefore (elem) { return focusNav(elem, -1) } /** * 聚焦到下一个可聚焦元素 * * @param {HTMLElement} elem 起始元素 */ export function focusAfter (elem) { return focusNav(elem, 1) } /** * 安全地 focus 一个元素/组件 * * @param {HTMLElement|Vue} elem 需要获取焦点的元素/组件 */ export function focus (elem) { if (!elem || typeof elem.focus !== 'function') { return } elem.focus({ preventScroll: true }) } let transformKey function getTransformKey () { if (transformKey) { return transformKey } transformKey = '-ms-transform' in document.documentElement.style ? 'msTransform' : 'transform' return transformKey } /** * 获取变换矩阵 * * @param {HTMLElement} el 目标元素 * @return {string} matrix 信息 */ export function getTransform (el) { return getComputedStyle(el)[getTransformKey()] } /** * 设置 transform * * @param {HTMLElement} el 目标元素 * @param {string} value 变换值 */ export function setTransform (el, value) { el.style[getTransformKey()] = value } /** * 切换指定元素的某个类名 * * @param {HTMLElement} el 目标元素 * @param {string} className 需要切换的类名 * @param {boolean} force 强制添加/删除,为 true 则添加,为 false 则删除 */ export function toggleClass (el, className, force) { if (el.classList) { return el.classList.toggle(className, force) } let klass = el.getAttribute('class') let klasses = uniq(klass.trim().split(/\s+/)) let index = findIndex(klasses, (k) => k === className) if (index !== -1) { if (force === true) { return } klasses.splice(index, 1) el.setAttribute('class', klasses.join(' ')) return } if (force === false) { return } el.setAttribute('class', klasses.concat([className]).join(' ')) } /** * 元素是否包含指定 class * * @param {Element} el 目标元素 * @param {string} className 需要检查的类名 * @returns {boolean} 是否包含指定类名 */ export function hasClass (el, className) { if (el.classList) { return el.classList.contains(className) } let klass = el.getAttribute('class') let klasses = klass.trim().split(/\s+/) return klasses.some((k) => k === className) } const NORMAL_LINE_HEIGHT = 1.4 /** * 获取元素的行高 px 数 * * @param {Element} el 目标元素 * @returns {number} 行高 px 数 */ export function getAbsoluteLineHeight (el) { let { lineHeight, fontSize } = getComputedStyle(el) let value = parseFloat(lineHeight) if (isNaN(value)) { // line-height: normal value = parseFloat(fontSize) * NORMAL_LINE_HEIGHT } return value } export const MOUSE_EVENTS = [ 'auxclick', 'click', 'contextmenu', 'dblclick', 'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseover', 'mouseout', 'mouseup', 'select', 'wheel' ] export const KEYBOARD_EVENTS = ['keydown', 'keypress', 'keyup'] export const FOCUS_EVENTS = ['focus', 'blur', 'focusin', 'focusout'] export const VALUE_EVENTS = ['input', 'change'] let realRaf export const raf = (cb) => { if (!realRaf) { realRaf = window.requestAnimationFrame || ((cb) => setTimeout(cb, 1000 / 60)) } return realRaf(cb) } export function getWindowRect () { let { innerHeight, innerWidth } = window return { top: 0, bottom: innerHeight, left: 0, right: innerWidth, height: innerHeight, width: innerWidth } } export function getBoundingRect (container) { if (container === window) { return getWindowRect() } return getStableBoundingClientRect(container) } function getClientSize (container) { if (container === window) { return { width: window.innerWidth, height: window.innerHeight } } return { width: container.clientWidth, height: container.clientHeight } } const linear = (time, duration, distance) => (time / duration) * distance const calcDistance = ( positions, { top: vTop }, { top: tTop, height: tHeight }, { clientTop, clientHeight } ) => { let [tPosition, vPosition] = Array.isArray(positions) ? positions : [positions, positions] return ( tTop + resolveOffset(tPosition, tHeight) - (vTop + clientTop + resolveOffset(vPosition, clientHeight)) ) } /** * 滚动行为的选项 * @typedef {Object} ScrollOptions * @property {number=} duration 滚动时间,单位ms * @property {Function=} beforeScroll 每次滚动之前的hook * @property {Function=} afterScroll 整个滚动完成之后的回调 * @property {Function=} timingFn 时间函数 */ /** * 在 viewport 中滚动 target 到指定位置 * @param {Array<number>|number} positions 位置百分数,如 0.5 等价于 [0.5, 0.5] * @param {Window|HTMLElement} viewport 视口元素 * @param {HTMLElement} target 被滚动的元素 * @param {ScrollOptions=} options 选项 */ export function scrollToAlign (viewport, target, options) { let isWindow = viewport === window let vRect = getBoundingRect(viewport) let tRect = getBoundingRect(target) let positions if (isPlainObject(options)) { positions = [options.targetPosition, options.viewportPosition] } else if (Array.isArray(options)) { positions = options options = {} } else if (typeof options === 'number' || typeof options === 'string') { positions = [options, options] options = {} } else { throw new Error( 'the third argument of `scrollToAlign` must be a object or an array or a number' ) } let distance = calcDistance( positions, vRect, tRect, isWindow ? { clientTop: 0, clientHeight: vRect.height } : viewport ) // 滚动的距离不要超出最大范围 let realViewport = isWindow ? document.documentElement : viewport let initScrollTop = realViewport.scrollTop if (initScrollTop + distance < 0) { distance = -initScrollTop } let maxScrollTop = realViewport.scrollHeight - vRect.height if (initScrollTop + distance > maxScrollTop) { distance = maxScrollTop - initScrollTop } doScroll(realViewport, [0, distance], options) } /** * 按根据坐标(left, top)来滚动,支持两种调用方式: * scrollTo(el, { left, top, ...}) * scrollTo(el, left, top) - 固定默认的 options * @param {Window|HTMLElement} viewport 视口元素 * @param {ScrollOptions=} options 选项 */ export function scrollTo (viewport, options) { let isWindow = viewport === window viewport = isWindow ? document.documentElement : viewport let { scrollLeft, scrollTop } = viewport let left let top if (isPlainObject(options)) { left = options.left top = options.top } else { left = options top = arguments[2] options = {} } if (!isNumber(left) || !isNumber(top)) { throw new Error('left and top must be numbers') } let distanceLeft = left - scrollLeft let distanceTop = top - scrollTop // 确保距离在能滚动的范围内 if (distanceLeft + scrollLeft < 0) { distanceLeft = -scrollLeft } let vRect = getClientSize(viewport) let maxDistanceX = viewport.scrollWidth - vRect.width if (scrollLeft + distanceLeft > maxDistanceX) { distanceLeft = maxDistanceX - scrollLeft } if (distanceTop + scrollTop < 0) { distanceTop = -scrollTop } let maxDistanceY = viewport.scrollHeight - vRect.height if (scrollTop + distanceTop > maxDistanceY) { distanceTop = maxDistanceY - scrollTop } doScroll(viewport, [distanceLeft, distanceTop], options) } /** * 根据距离来滚动 * @param {Window|HTMLElement} viewport 视口元素 * @param {[number, number]} distances - 水平/垂直方向滚动的距离 * @param {ScrollOptions=} options 选项 */ function doScroll ( viewport, [distanceX, distanceY], { duration = 200, timingFn = linear, beforeScroll, afterScroll } = {} ) { let startTime = null // 记下开始的滚动位置,step 中的距离都是基于该位置的 let { scrollLeft, scrollTop } = viewport const step = () => { let now = Date.now() if (!startTime) { startTime = now } let curTime = Math.min(now - startTime, duration) let stepX = !duration ? distanceX : timingFn(curTime, duration, distanceX) let stepY = !duration ? distanceY : timingFn(curTime, duration, distanceY) let newPos = [Math.round(scrollLeft + stepX), Math.round(scrollTop + stepY)] if (beforeScroll) beforeScroll(newPos) viewport.scrollLeft = newPos[0] viewport.scrollTop = newPos[1] if (curTime !== duration) { raf(step) } else if (afterScroll) { afterScroll(newPos) } } step() } /** * 计算指定元素在窗口中可视部分 * @param {HTMLElement|Window} elem 起始元素 * @param {Object=} elemRect elem 的 Rect,若提供了就少一次调用,减少 rect 的获取次数 */ export function getVisibleRect (elem, elemRect) { let rect = null let el = elem if (elem === window) { return getWindowRect() } while (el) { // safari scrollHeight bug if ( getComputedStyle(el).overflow !== 'visible' && el !== document.documentElement ) { let { clientHeight, clientWidth, scrollHeight, scrollWidth } = el let vScroll = scrollHeight > clientHeight let hScroll = scrollWidth > clientWidth if (vScroll || hScroll) { let { top, left } = el === elem && elemRect ? elemRect : el.getBoundingClientRect() if (vScroll) { let realTop = top + el.clientTop let realBottom = realTop + clientHeight rect = { ...(rect || {}), top: get(rect, 'top') == null ? realTop : Math.max(rect.top, realTop), bottom: get(rect, 'bottom') == null ? realBottom : Math.min(rect.bottom, realBottom) } } if (hScroll) { let realLeft = left + el.clientLeft let realRight = realLeft + el.clientWidth rect = { ...(rect || {}), left: get(rect, 'left') == null ? realLeft : Math.max(rect.left, realLeft), right: get(rect, 'right') == null ? realRight : Math.min(rect.right, realRight) } } } } el = el.parentElement } return rect } export function calcClip ( { top, right, bottom, left, width, height }, { top: vTop, right: vRight, bottom: vBottom, left: vLeft } ) { let clip = null if (vTop != null && (top < vTop || bottom > vBottom)) { clip = { top: clamp(vTop - top, 0, height), bottom: clamp(vBottom - top, 0, height), left: 0, right: width } } if (vLeft != null && (left < vLeft || right > vRight)) { clip = { left: clamp(vLeft - left, 0, width), right: clamp(vRight - left, 0, width), top: clip ? clip.top : 0, bottom: clip ? clip.bottom : height } } return clip } export function preventBackForward (el) { function handleWheel (e) { let maxX = el.scrollWidth - el.clientWidth let scrollTarget = el.scrollLeft + e.deltaX if (scrollTarget < 0 || scrollTarget > maxX) { e.preventDefault() } } el.addEventListener('wheel', handleWheel) return () => { el.removeEventListener('wheel', handleWheel) } } function camelCase (str) { return str.replace(/-([a-zA-Z])/g, (_, ch) => ch.toUpperCase()) } let probe = null export function cssSupports (prop, val) { if (typeof CSS !== 'undefined') { return CSS.supports(prop, typeof val === 'undefined' ? 'inherit' : val) } if (!probe) { probe = document.createElement('div') } let style = probe.style let p = camelCase(prop) if (typeof style[p] === 'undefined') { // prop is not supported return false } if (typeof val === 'undefined') { return true } style[p] = val let result = style[p] !== '' style[p] = '' return result } let scrollbarWidth = null export function getScrollbarWidth () { if (scrollbarWidth !== null) { return scrollbarWidth } let measurer = document.createElement('div') measurer.style.cssText = 'height:1px;overflow-y:scroll' document.body.appendChild(measurer) scrollbarWidth = measurer.offsetWidth - measurer.clientWidth document.body.removeChild(measurer) return scrollbarWidth } export function getElementScrollbarWidth (el, horizontal) { if (horizontal) { return el.offsetHeight - el.clientHeight } return el.offsetWidth - el.clientWidth } /** * 查找元素是否有绑定的 Portal 入口,如果有则返回该入口元素 * * @param {Element} element 起始的元素 * @returns {?Element} Portal 入口元素 */ export function getPortalEntry (element) { let el = element while (el) { if (el.__portal__) { return el.__portal__ } el = el.parentNode } return null } export function trigger (el, type) { let evt = document.createEvent('HTMLEvents') evt.initEvent(type, true, false) el.dispatchEvent(evt) } export function triggerCustom (el, type, detail) { let evt = document.createEvent('CustomEvent') evt.initCustomEvent(type, true, false, detail) el.dispatchEvent(evt) } export function isInsideTransformedContainer (el) { let current = el.parentElement while (current) { let styles = window.getComputedStyle(current) if (styles.transform !== 'none' || styles.transformStyle !== 'flat') { return true } current = current.parentElement } return false } export function addOnceEventListener (el, evt, listener) { function callback (...args) { remove() listener.apply(el, args) } function remove () { el.removeEventListener(evt, callback) } el.addEventListener(evt, callback) return remove } function getStableOffset (el, context) { let current = el let top = 0 let left = 0 while (current && current !== context) { if (!context || context.contains(current)) { top += current.offsetTop left += current.offsetLeft } else { top -= context.offsetTop left -= context.offsetLeft } current = current.offsetParent } return { top, left } } /** * Get accumulated scroll offsets from the starting element to a specified context element * @param {HTMLElement} el the starting element * @param {HTMLElement} context the context element * @returns {{top: number, left: number}} the accumulated scroll offsets */ export function getScrollOffset (el, context) { if (!context.contains(el)) { throw new Error('The context element must contain the starting element.') } let current = el let top = 0 let left = 0 while (current !== context) { current = current.parentElement top += current.scrollTop left += current.scrollLeft } return { top, left } } /** * To get relatively stable bounding client rect based on the offsetParent to * avoid interpolated values during transition. * @param {HTMLElement} el The target element * @returns {{top: number, right: number, bottom: number, left: number, width: number, height: number}} The bounding client rect */ export function getStableBoundingClientRect (el, context) { let { top, left } = getStableOffset(el, context) let { top: contextTop, left: contextLeft } = context ? context.getBoundingClientRect() : { top: 0, left: 0 } let scroll = getScrollOffset(el, context || document.documentElement) top += contextTop - scroll.top left += contextLeft - scroll.left let width = el.offsetWidth let height = el.offsetHeight return { width, height, top, right: left + width, bottom: top + height, left } } export const draggingStyle = 'user-select:none;-ms-user-select:none;-webkit-user-select:none;-moz-user-select:none;' + 'transition:none;animation:none;-ms-animation:none;-webkit-animation:none;-moz-animation:none' /** * Apply style temporarily and clean up later * * @param {HTMLElement} el the target element * @param {string} style the style string * @returns {Function} the remove function */ export function appendTemporaryStyle (el, style) { let originalStyle = el.getAttribute('style') el.setAttribute('style', originalStyle ? `${originalStyle};${style}` : style) return () => { if (originalStyle === null) { el.removeAttribute('style') } else { el.setAttribute('style', originalStyle) } } } let hasDragOffsetDeviation = null let __offsetY__ = 0 function testDrag (e) { __offsetY__ = e.offsetY } /** * Checks whether the browser triggers https://crbug.com/1297990 * @returns {boolean} true if we are unfortunate */ export function checkDragOffsetDeviation () { if (hasDragOffsetDeviation != null) { return hasDragOffsetDeviation } const el = document.createElement('div') const distanceToViewport = 1 el.style = `position:fixed;top:${distanceToViewport}px` document.body.appendChild(el) el.addEventListener('dragstart', testDrag) el.dispatchEvent( new DragEvent('dragstart', { clientX: 0, clientY: 0 }) ) el.removeEventListener('dragstart', testDrag) el.parentNode.removeChild(el) hasDragOffsetDeviation = __offsetY__ === -window.devicePixelRatio * distanceToViewport return hasDragOffsetDeviation }