目录

如何在前端弹出来一个框

目录

目前 Cyber Terminal 前端的基础样式解决方案是tailwindcss,配合 daisy UI 和我自己封装的一套 RxUI 勉强凑合着用。daisy UI 的设计理念是纯 CSS 实现,不掺杂任何的 JavaScript 代码,我挺喜欢这种实现方式,纯 CSS 实现的用户界面比掺杂了 JS 的界面总是让人更放心一点。但是 CSS 终究是没有 JS 强大的,它只是一套静态布局系统,这就导致了一系列的用户体验问题。

比如说一个简单的弹出框。在 daisy UI 的解决方案中,他们使用了元素的 focus 状态,配合 CSS 选择器来显示弹出元素。这乍一听好像挺符合设计思想的,但是用的时候就出现了一堆问题。为了保证元素正确加载,在未显示的情况下,弹出元素上设置的并不是 display: none,而是 visibility: hidden,这就导致弹出元素即使在未显示的状态下也占据了实际空间的,只是不可见而已,可能会在某些情况下打乱布局。

比如,我想要实现一个可滚动的 Table 组件,在表格的每一列上我都放置了一系列操作按钮,对于比较危险的操作,例如删除,会有一个弹出框让用户进行二次确认。这个时候问题就来了,由于弹出框在未显示的情况下也是占据空间的,最后一列上的弹出框就会继续向下拓展,就导致了表格滚动到最后一列后还能继续向下滚动一段距离,看起来很奇怪。

问题还不止这一点,由于 CSS 没有类似于 floating 的功能,元素是无法探查可视边界的。Table 组件默认可滚动,导致内部元素的溢出行为是clip,于是把溢出窗口的对话框一起给切了。不只是对话框,还有 tooltip 之类的东西,会变成这个样子:

https://files.catbox.moe/rccxzh.png

嘛……虽然应该没人拿宽度这么巧的设备打CTF……但是这个行为太蠢了,我写的时候得时时刻刻注意着弹出位置,放左边溢出了,放右边也溢出了,放下边好消息是没溢出,坏消息是给滚动条撑起来了……

https://files.catbox.moe/0up7lu.jpg

于是我就开始找解决方案,找着找着找到了Microsoft在油管上发的Fluent UI Design相关视频。他们最终选了 popper.js 作为弹出式组件的解决方案。但是……这个组件只有 React 框架的集成方案,Vue 的几个第三方集成方案都不太好使了。

还是自己写吧……

最终选用了 Floating UI 作为实现方案,按照svelte的生命周期简单包装了一下。相关API参考都在这里了:

import { computePosition, autoUpdate, offset, shift, flip } from '@floating-ui/dom'

/** Placement https://floating-ui.com/docs/computePosition#placement */
export type Direction = 'top' | 'bottom' | 'left' | 'right'
export type Placement = Direction | `${Direction}-start` | `${Direction}-end`

// Options & Middleware
export interface Middleware {
  // Required ---
  /** Offset middleware settings: https://floating-ui.com/docs/offset */
  offset?: number | Record<string, unknown>
  /** Shift middleware settings: https://floating-ui.com/docs/shift */
  shift?: Record<string, unknown>
  /** Flip middleware settings: https://floating-ui.com/docs/flip */
  flip?: Record<string, unknown>
  // Optional ---
  /** Size middleware settings: https://floating-ui.com/docs/size */
  size?: Record<string, unknown>
  /** Auto Placement middleware settings: https://floating-ui.com/docs/autoPlacement */
  autoPlacement?: Record<string, unknown>
  /** Hide middleware settings: https://floating-ui.com/docs/hide */
  hide?: Record<string, unknown>
  /** Inline middleware settings: https://floating-ui.com/docs/inline */
  inline?: Record<string, unknown>
}

export interface PopupSettings {
  /** Provide the event type. */
  event: 'click' | 'hover' | 'focus-blur' | 'focus-click'
  /** Match the popup data value `data-popup="targetNameHere"` */
  target: string
  /** Set the placement position. Defaults 'bottom'. */
  placement?: Placement
  /** Query elements that close the popup when clicked. Defaults `'a[href], button'`. */
  closeQuery?: string
  /** Optional callback function that reports state change. */
  state?: (event: { state: boolean }) => void
  /** Provide Floating UI middleware settings. */
  middleware?: Middleware
}

export function popup(triggerNode: HTMLElement, args: PopupSettings) {
  // Local State
  const popupState = {
    open: false,
    // eslint-disable-next-line @typescript-eslint/no-empty-function
    autoUpdateCleanup: () => {},
  }
  const focusableAllowedList = ':is(a[href], button, input, textarea, select, details, [tabindex]):not([tabindex="-1"])'
  let focusablePopupElements: HTMLElement[]
  // Elements
  let elemPopup: HTMLElement

  function setDomElements(): void {
    elemPopup = document.querySelector(`[data-popup="${args.target}"]`) ?? document.createElement('div')
    Object.assign(elemPopup.style, {
      position: 'absolute',
      opacity: '0',
      top: '0',
      left: '0',
      scale: '0.95',
      display: 'none',
    })
    elemPopup.classList.add('transition-all', 'duration-100', 'ease-in-out')
  }
  setDomElements() // init

  // Render Floating UI Popup
  function render(): void {
    // Error handling for required Floating UI modules
    if (!elemPopup) throw new Error(`The data-popup="${args.target}" element was not found.`)
    if (!computePosition) throw new Error(`Floating UI 'computePosition' not found for data-popup="${args.target}".`)
    if (!offset) throw new Error(`Floating UI 'offset' not found for data-popup="${args.target}".`)
    if (!shift) throw new Error(`Floating UI 'shift' not found for data-popup="${args.target}".`)
    if (!flip) throw new Error(`Floating UI 'flip' not found for data-popup="${args.target}".`)

    // Floating UI Compute Position
    // https://floating-ui.com/docs/computePosition
    computePosition(triggerNode, elemPopup, {
      placement: args.placement ?? 'bottom',

      // Middleware - NOTE: the order matters:
      // https://floating-ui.com/docs/middleware#ordering
      middleware: [
        // https://floating-ui.com/docs/offset
        offset(args.middleware?.offset ?? 8),
        // https://floating-ui.com/docs/shift
        shift(args.middleware?.shift ?? { padding: 8 }),
        // https://floating-ui.com/docs/flip
        flip(args.middleware?.flip),
      ],
    }).then(({ x, y }) => {
      Object.assign(elemPopup.style, {
        left: `${x}px`,
        top: `${y}px`,
      })
    })
  }

  // State Handlers
  function open(): void {
    if (!elemPopup) return
    // Set open state to on
    popupState.open = true
    // Return the current state
    if (args.state) args.state({ state: popupState.open })
    // Update render settings
    render()
    // Update the DOM
    elemPopup.style.display = 'block'
    setTimeout(() => {
      elemPopup.style.opacity = '1'
      elemPopup.style.scale = '1'
    }, 10)
    elemPopup.style.pointerEvents = 'auto'
    // Trigger Floating UI autoUpdate (open only)
    // https://floating-ui.com/docs/autoUpdate
    popupState.autoUpdateCleanup = autoUpdate(triggerNode, elemPopup, render)
    // Focus the first focusable element within the popup
    focusablePopupElements = Array.from(elemPopup?.querySelectorAll(focusableAllowedList))
  }
  function close(callback?: () => void): void {
    if (!elemPopup) return
    // Set transition duration
    const cssTransitionDuration =
      parseFloat(window.getComputedStyle(elemPopup).transitionDuration.replace('s', '')) * 1000
    // Set open state to off
    popupState.open = false
    // Return the current state
    if (args.state) args.state({ state: popupState.open })
    // Update the DOM
    elemPopup.style.opacity = '0'
    elemPopup.style.scale = '0.95'
    setTimeout(() => {
      elemPopup.style.display = 'none'
    }, cssTransitionDuration)
    elemPopup.style.pointerEvents = 'none'
    // Cleanup Floating UI autoUpdate (close only)
    if (popupState.autoUpdateCleanup) popupState.autoUpdateCleanup()
    // Trigger callback
    if (callback) callback()
  }

  // Event Handlers
  function toggle(): void {
    popupState.open === false ? open() : close()
  }
  function onWindowClick(event: Event): void {
    // Return if the popup is not yet open
    if (popupState.open === false) return
    // Return if click is the trigger element
    if (triggerNode.contains(event.target as Node)) return
    // If click it outside the popup
    if (elemPopup && elemPopup.contains(event.target as Node) === false) {
      close()
      return
    }
    // Handle Close Query State
    const closeQueryString: string = args.closeQuery === undefined ? 'a[href], button' : args.closeQuery
    const closableMenuElements = elemPopup?.querySelectorAll(closeQueryString)
    closableMenuElements?.forEach((elem) => {
      if (elem.contains(event.target as Node)) close()
    })
  }

  // Keyboard Interactions for A11y
  const onWindowKeyDown = (event: KeyboardEvent): void => {
    if (popupState.open === false) return
    // Handle keys
    const key: string = event.key
    // On Esc key
    if (key === 'Escape') {
      event.preventDefault()
      triggerNode.focus()
      close()
      return
    }
    // On Tab or ArrowDown key
    const triggerMenuFocused: boolean = popupState.open && document.activeElement === triggerNode
    if (
      triggerMenuFocused &&
      (key === 'ArrowDown' || key === 'Tab') &&
      focusableAllowedList.length > 0 &&
      focusablePopupElements.length > 0
    ) {
      event.preventDefault()
      focusablePopupElements[0].focus()
    }
  }

  // Event Listeners
  switch (args.event) {
    case 'click':
      triggerNode.addEventListener('click', toggle, true)
      window.addEventListener('click', onWindowClick, true)
      break
    case 'hover':
      triggerNode.addEventListener('mouseover', open, true)
      triggerNode.addEventListener('mouseleave', () => close(), true)
      break
    case 'focus-blur':
      triggerNode.addEventListener('focus', toggle, true)
      triggerNode.addEventListener('blur', () => close(), true)
      break
    case 'focus-click':
      triggerNode.addEventListener('focus', open, true)
      window.addEventListener('click', onWindowClick, true)
      break
    default:
      throw new Error(`Event value of '${args.event}' is not supported.`)
  }
  window.addEventListener('keydown', onWindowKeyDown, true)

  // Render popup on initialization
  render()

  // Lifecycle
  return {
    update(newArgs: PopupSettings) {
      close(() => {
        args = newArgs
        render()
        setDomElements()
      })
    },
    destroy() {
      // Trigger Events
      triggerNode.removeEventListener('click', toggle, true)
      triggerNode.removeEventListener('mouseover', open, true)
      triggerNode.removeEventListener('mouseleave', () => close(), true)
      triggerNode.removeEventListener('focus', toggle, true)
      triggerNode.removeEventListener('focus', open, true)
      triggerNode.removeEventListener('blur', () => close(), true)
      // Window Events
      window.removeEventListener('click', onWindowClick, true)
      window.removeEventListener('keydown', onWindowKeyDown, true)
    },
  }
}