veui
Version:
Baidu Enterprise UI for Vue.js.
1,037 lines (914 loc) • 26 kB
JavaScript
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
}