import type { Placement, Strategy, OffsetOptions, RootBoundary, Boundary, ReferenceElement } from '@floating-ui/dom'
import { computePosition, autoUpdate, flip, offset, shift, arrow, hide, limitShift } from '@floating-ui/dom'
interface IFloatOption {
reference: null | ReferenceElement
popper: null | HTMLElement
show: boolean
autoUpdate: boolean
strategy: Strategy
placement: Placement
* 1、只传入 number, 代表主轴上的偏移。
* 2、crossAxis,alignmentAxis 都是副轴上的偏移, 区别是:
* crossAxis 固定向副轴的正方向偏移;
* alignmentAxis 在副轴上,根据placement的后段决定偏移。
* 比如 top的副轴为水平方向。 指定alignmentAxis=20的话, top-start时,向右20, top-end时 向左20。
*/
offset: OffsetOptions
arrowVisible: boolean
* 在floating 内部, 计算所有 [...clippingAncestors, rootBoundary] 的rect 大小
* 'viewport' 时,访问的是 window.visualViewport, 其 width是不带滚动条的宽度。
*/
rootBoundary: RootBoundary
boundary: Boundary
boundaryPadding: number
syncHide: boolean
autoHide: boolean
gpuAcceleration: boolean
animate: boolean
animateName: string
* true时, 显示popper时,才body.append; 隐藏时popper.remove。 boundary为 body.
* false时, 显示popper, 修改style.display='block', 隐藏修改 display:none boundary为 最近的relative元素 */
appendToBody: boolean
customClass: string
flipAble: boolean
shiftAble: boolean
_last: Partial<IFloatOption> & {
arrowInserted?: boolean
arrowEl: HTMLElement
timestamp: number
}
* show 事件:如果useFloating时,show=true, 那么监听不到第一次show事件。 因为第一次show事件在usFloating内部就已经触发了
* hide 事件:在动画结束后触发。【是否增加hiding 事件?】
* update 事件: 每次定位完后触发。 该事件触发频繁,已观察到有以下情况:
* 在 autoUpdate 时,会频繁触发。 比如切换显示,elementResize /IntersectionObserver 事件发生,内部会进入2次
* 在reference 不可见时,每一秒会触发一次 update
* */
_events: { show: Function[]; hide: Function[]; update: Function[] }
}
const defaultOption: Partial<IFloatOption> = {
reference: null,
popper: null,
show: false,
autoUpdate: true,
strategy: 'absolute',
placement: 'bottom',
offset: 6,
arrowVisible: true,
rootBoundary: 'viewport',
boundary: 'clippingAncestors',
boundaryPadding: 5,
syncHide: true,
autoHide: false,
gpuAcceleration: false,
animate: true,
animateName: 'fade-in-linear',
appendToBody: false,
customClass: '',
flipAble: true,
shiftAble: true
}
const oppositeSidesMap = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }
const toMs = (s: string) => {
if (s === 'auto') return 0
return Number(s.slice(0, -1).replace(',', '.')) * 1000
}
const getTransitionInfo = (el: HTMLElement) => {
const styles = window.getComputedStyle(el)
let timeout = toMs(styles.transitionDelay) + toMs(styles.transitionDuration)
if (timeout) return timeout
timeout = toMs(styles.animationDelay) + toMs(styles.animationDuration)
if (timeout) return timeout
return 0
}
const applyClass = (el: HTMLElement, classes: string, force: boolean) => {
classes.split(' ').forEach((c) => c && el.classList.toggle(c, force))
}
const updatePopper = (state: IFloatOption) => {
const middleware = [offset(state.offset)]
state.flipAble &&
middleware.push(
flip({
rootBoundary: state.rootBoundary,
boundary: state.boundary,
padding: state.boundaryPadding
})
)
state.shiftAble && middleware.push(shift({ limiter: limitShift() }))
state.arrowVisible &&
middleware.push(
arrow({
element: state.popper!.querySelector('.tiny-popper__arrow')!,
padding: 8
})
)
middleware.push(hide())
computePosition(state.reference!, state.popper!, {
placement: state.placement,
strategy: state.strategy,
middleware
}).then(({ x, y, placement, strategy, middlewareData }) => {
if (state.autoHide && state._last.show) {
const timestamp = new Date().getTime()
if (timestamp > state._last.timestamp + 300) {
state.show = false
return
}
}
const finalStyles: Record<string, string> = {}
Object.assign(finalStyles, {
position: strategy
})
if (state.gpuAcceleration) {
Object.assign(finalStyles, {
transform: `translate(${x}px,${y}px)`,
left: '0',
top: '0'
})
} else {
Object.assign(finalStyles, {
left: `${x}px`,
top: `${y}px`
})
}
if (state.syncHide) {
if (middlewareData.hide) {
Object.assign(finalStyles, {
visibility: middlewareData.hide.referenceHidden ? 'hidden' : 'visible'
})
}
}
Object.assign(state.popper!.style, finalStyles)
if (state._last.customClass && state._last.customClass !== state.customClass) {
applyClass(state.popper!, state._last.customClass, false)
}
if (state.customClass && state._last?.customClass !== state.customClass) {
applyClass(state.popper!, state.customClass, true)
state._last.customClass = state.customClass
}
if (state.arrowVisible) {
const { x: arrowX, y: arrowY } = middlewareData.arrow
const arrowElement = state._last.arrowEl!
const staticSide = oppositeSidesMap[placement.split('-')[0]]
const arrowStyle = {
left: arrowX !== null ? `${arrowX}px` : '',
top: arrowY !== null ? `${arrowY}px` : '',
right: '',
bottom: '',
[staticSide]: '-4px',
display: 'block'
}
Object.assign(arrowElement.style, arrowStyle)
} else {
if (state._last!.arrowInserted) {
state._last!.arrowEl.style.display = 'none'
}
}
emit(state, 'update', { x, y, placement, strategy, middlewareData })
})
}
const autoUpdatePopper = (state: IFloatOption) => {
return autoUpdate(state.reference!, state.popper!, () => {
updatePopper(state)
})
}
const appendPopper = (state: IFloatOption) => {
if (state._last.show && state._last.popper === state.popper) return
if (state._last.popper && state._last.popper !== state.popper) {
if (state._last.appendToBody) {
state._last.popper.remove()
} else {
state._last.popper.style.display = 'none'
}
state._last.arrowInserted = false
state._last.arrowEl = null as unknown as HTMLElement
}
if (state.popper) {
if (state.appendToBody) {
document.body.append(state.popper)
} else {
state.popper.style.display = 'block'
}
if (!state._last!.arrowInserted) {
const arrowEl = document.createElement('div')
arrowEl.className = 'tiny-popper__arrow'
state.popper.append(arrowEl)
state._last!.arrowInserted = true
state._last!.arrowEl = arrowEl
}
if (state.animate) {
const enterName = `${state.animateName}-enter-from`
const activeName = `${state.animateName}-enter-active`
state.popper.classList.add(enterName, activeName)
setTimeout(() => {
state.popper!.classList.remove(enterName)
}, 0)
const timeout = getTransitionInfo(state.popper)
setTimeout(() => {
state.popper!.classList.remove(activeName)
}, timeout)
}
emit(state, 'show')
}
}
const closePopper = (state: IFloatOption) => {
if (!state._last.show) return
if (state.popper) {
if (state.animate && state.animateName) {
const leaveName = `${state.animateName}-leave-to`
const activeName = `${state.animateName}-leave-active`
state.popper.classList.add(leaveName, activeName)
const timeout = getTransitionInfo(state.popper)
setTimeout(() => {
state.popper!.classList.remove(leaveName, activeName)
if (state.appendToBody) {
state.popper!.remove()
} else {
state.popper!.style.display = 'none'
}
emit(state, 'hide')
}, timeout)
} else {
if (state.appendToBody) {
state.popper.remove()
} else {
state.popper.style.display = 'none'
}
emit(state, 'hide')
}
}
}
const emit = (state: IFloatOption, eventName: string, params?: any) => {
state._events[eventName].forEach((cb) => cb(params))
}
const virtualEl = (x: number, y: number, w = 0, h = 0) => ({
getBoundingClientRect() {
return {
width: 0,
height: 0,
x,
y,
top: y,
left: x,
right: x + w,
bottom: y + h
}
}
})
export const useFloating =
({ reactive, watch, markRaw, onBeforeUnmount }) =>
(option: Partial<IFloatOption> = {}) => {
const state = reactive(option) as IFloatOption
let cleanup: null | (() => void) = null
Object.keys(defaultOption).forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(state, key)) {
state[key] = defaultOption[key]
}
})
state._last = markRaw({}) as any
state._events = markRaw({ show: [], hide: [], update: [] })
const watchState = () => {
if (state.popper && state.reference) {
if (state.show) {
appendPopper(state)
if (state.autoUpdate) {
cleanup && cleanup()
cleanup = autoUpdatePopper(state)
} else {
updatePopper(state)
}
}
else {
cleanup && cleanup()
closePopper(state)
}
}
else {
cleanup && cleanup()
closePopper(state)
}
state._last.popper = state.popper
state._last.reference = state.reference
state._last.show = (state.show && state.popper && state.reference) as boolean
state._last.appendToBody = state.appendToBody
state._last.timestamp = new Date().getTime()
}
watch(state, watchState, { immediate: true })
const on = (eventName, cb) => state._events[eventName].push(cb)
const off = (eventName, cb) => (state._events[eventName] = state._events[eventName].filter((i) => i !== cb))
onBeforeUnmount(() => {
cleanup && cleanup()
closePopper(state)
})
return { state, on, off, virtualEl, forceUpdate: watchState }
}