feat(ui): expand reusable component foundation
- add Base UI and shadcn primitives for overlays, forms, navigation, feedback, and data display - add optimized DOM ripple hooks with ref composition and integrate the behavior into interactive controls - add AnchoredSurface SVG geometry and arrow-aware popover, dropdown, and tooltip rendering - extend responsive breakpoint and style utilities, declare component dependencies, and remove the redundant package ESLint config
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
const DESKTOP_BREAKPOINT = 1024
|
||||
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`
|
||||
const DESKTOP_QUERY = `(min-width: ${DESKTOP_BREAKPOINT}px)`
|
||||
const BREAKPOINT_CLASS_PREFIX = "ui:"
|
||||
|
||||
export type Breakpoint = "mobile" | "tablet" | "desktop"
|
||||
|
||||
const breakpointClasses: Breakpoint[] = ["mobile", "tablet", "desktop"]
|
||||
const breakpointClassNames = breakpointClasses.map(
|
||||
(breakpoint) => `${BREAKPOINT_CLASS_PREFIX}${breakpoint}`
|
||||
)
|
||||
|
||||
let breakpointMediaQueries:
|
||||
| {
|
||||
mobile: MediaQueryList
|
||||
desktop: MediaQueryList
|
||||
}
|
||||
| undefined
|
||||
|
||||
function getBreakpointMediaQueries() {
|
||||
breakpointMediaQueries ??= {
|
||||
mobile: window.matchMedia(MOBILE_QUERY),
|
||||
desktop: window.matchMedia(DESKTOP_QUERY),
|
||||
}
|
||||
|
||||
return breakpointMediaQueries
|
||||
}
|
||||
|
||||
function subscribeToBreakpoint(listener: VoidFunction) {
|
||||
const { mobile, desktop } = getBreakpointMediaQueries()
|
||||
|
||||
mobile.addEventListener("change", listener)
|
||||
desktop.addEventListener("change", listener)
|
||||
|
||||
return () => {
|
||||
mobile.removeEventListener("change", listener)
|
||||
desktop.removeEventListener("change", listener)
|
||||
}
|
||||
}
|
||||
|
||||
function getBreakpointSnapshot(): Breakpoint {
|
||||
const { mobile, desktop } = getBreakpointMediaQueries()
|
||||
|
||||
if (mobile.matches) {
|
||||
return "mobile"
|
||||
}
|
||||
|
||||
if (desktop.matches) {
|
||||
return "desktop"
|
||||
}
|
||||
|
||||
return "tablet"
|
||||
}
|
||||
|
||||
function getServerBreakpointSnapshot(): Breakpoint {
|
||||
return "desktop"
|
||||
}
|
||||
|
||||
function applyBreakpointClass(breakpoint: Breakpoint) {
|
||||
for (const className of breakpointClasses) {
|
||||
document.documentElement.classList.toggle(
|
||||
`${BREAKPOINT_CLASS_PREFIX}${className}`,
|
||||
className === breakpoint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function getBreakpointInitializationScript() {
|
||||
return `(() => {
|
||||
const root = document.documentElement;
|
||||
const breakpoint =
|
||||
window.matchMedia(${JSON.stringify(MOBILE_QUERY)}).matches
|
||||
? "mobile"
|
||||
: window.matchMedia(${JSON.stringify(DESKTOP_QUERY)}).matches
|
||||
? "desktop"
|
||||
: "tablet";
|
||||
root.classList.remove(${breakpointClassNames
|
||||
.map((className) => JSON.stringify(className))
|
||||
.join(", ")});
|
||||
root.classList.add(${JSON.stringify(BREAKPOINT_CLASS_PREFIX)} + breakpoint);
|
||||
})();`
|
||||
}
|
||||
|
||||
export function useBreakpoint() {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToBreakpoint,
|
||||
getBreakpointSnapshot,
|
||||
getServerBreakpointSnapshot
|
||||
)
|
||||
}
|
||||
|
||||
export function useIsMobile() {
|
||||
return useBreakpoint() === "mobile"
|
||||
}
|
||||
|
||||
export function useIsTablet() {
|
||||
return useBreakpoint() === "tablet"
|
||||
}
|
||||
|
||||
export function useIsDesktop() {
|
||||
return useBreakpoint() === "desktop"
|
||||
}
|
||||
|
||||
export function BreakpointReactor() {
|
||||
const breakpoint = useBreakpoint()
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
applyBreakpointClass(breakpoint)
|
||||
|
||||
return () => {
|
||||
document.documentElement.classList.remove(...breakpointClassNames)
|
||||
}
|
||||
}, [breakpoint])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function useOnBreakpoint(listener: (breakpoint: Breakpoint) => void) {
|
||||
const breakpoint = useBreakpoint()
|
||||
const onBreakpoint = React.useEffectEvent(listener)
|
||||
const previousBreakpoint = React.useRef(breakpoint)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousBreakpoint.current === breakpoint) {
|
||||
return
|
||||
}
|
||||
|
||||
previousBreakpoint.current = breakpoint
|
||||
onBreakpoint(breakpoint)
|
||||
}, [breakpoint])
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useIsMobile } from "./use-breakpoint"
|
||||
@@ -0,0 +1,356 @@
|
||||
import * as React from "react"
|
||||
import { useMergedRefs } from "@base-ui/utils/useMergedRefs"
|
||||
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
export interface UseRippleOptions {
|
||||
/**
|
||||
* 是否禁用水波纹
|
||||
*/
|
||||
disabled?: boolean
|
||||
|
||||
/**
|
||||
* 动画时长,单位毫秒
|
||||
*/
|
||||
duration?: number
|
||||
|
||||
/**
|
||||
* Tailwind 水波纹颜色类名
|
||||
*
|
||||
* 例如:
|
||||
* bg-current
|
||||
* bg-white/40
|
||||
* bg-black/20
|
||||
* bg-blue-500/30
|
||||
*/
|
||||
rippleClassName?: string
|
||||
|
||||
/**
|
||||
* 长按时达到的最大透明度
|
||||
*
|
||||
* 水波纹会从该值的三分之一逐渐加深到该值。
|
||||
*/
|
||||
opacity?: number
|
||||
|
||||
/**
|
||||
* 动画缓动函数
|
||||
*/
|
||||
easing?: string
|
||||
}
|
||||
|
||||
export function useRipple<T extends HTMLElement = HTMLElement>(
|
||||
options: UseRippleOptions = {}
|
||||
): React.RefCallback<T> {
|
||||
const optionsRef = React.useRef(options)
|
||||
const cleanupRef = React.useRef<() => void>(() => {})
|
||||
|
||||
// 始终使用最新配置,但不需要重新绑定事件
|
||||
optionsRef.current = options
|
||||
|
||||
const rippleRef = React.useCallback((element: T | null) => {
|
||||
// ref 指向其他元素或组件卸载时,清理旧监听
|
||||
cleanupRef.current()
|
||||
cleanupRef.current = () => {}
|
||||
|
||||
if (!element) {
|
||||
return
|
||||
}
|
||||
|
||||
const holder = document.createElement("span")
|
||||
|
||||
holder.setAttribute("aria-hidden", "true")
|
||||
holder.className = cn(
|
||||
"pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"
|
||||
)
|
||||
|
||||
element.appendChild(holder)
|
||||
|
||||
type ActiveRipple = {
|
||||
pointerId: number
|
||||
ripple: HTMLSpanElement
|
||||
expansionAnimation: Animation
|
||||
pressAnimation: Animation
|
||||
fadeAnimation?: Animation
|
||||
released: boolean
|
||||
fadeDuration: number
|
||||
easing: string
|
||||
maxOpacity: number
|
||||
}
|
||||
|
||||
const activeRipples = new Set<ActiveRipple>()
|
||||
const pressedRipples = new Map<number, ActiveRipple>()
|
||||
let releaseListenersAttached = false
|
||||
|
||||
const detachReleaseListeners = () => {
|
||||
if (!releaseListenersAttached) {
|
||||
return
|
||||
}
|
||||
|
||||
releaseListenersAttached = false
|
||||
window.removeEventListener("pointerup", handlePointerEnd)
|
||||
window.removeEventListener("pointercancel", handlePointerEnd)
|
||||
window.removeEventListener("blur", handleWindowBlur)
|
||||
}
|
||||
|
||||
const removeRipple = (activeRipple: ActiveRipple) => {
|
||||
if (!activeRipples.has(activeRipple)) {
|
||||
return
|
||||
}
|
||||
|
||||
activeRipples.delete(activeRipple)
|
||||
|
||||
if (pressedRipples.get(activeRipple.pointerId) === activeRipple) {
|
||||
pressedRipples.delete(activeRipple.pointerId)
|
||||
}
|
||||
|
||||
activeRipple.ripple.remove()
|
||||
}
|
||||
|
||||
const cancelRipple = (activeRipple: ActiveRipple) => {
|
||||
if (!activeRipples.has(activeRipple)) {
|
||||
return
|
||||
}
|
||||
|
||||
activeRipples.delete(activeRipple)
|
||||
|
||||
if (pressedRipples.get(activeRipple.pointerId) === activeRipple) {
|
||||
pressedRipples.delete(activeRipple.pointerId)
|
||||
}
|
||||
|
||||
activeRipple.expansionAnimation.cancel()
|
||||
activeRipple.pressAnimation.cancel()
|
||||
activeRipple.fadeAnimation?.cancel()
|
||||
activeRipple.ripple.remove()
|
||||
|
||||
if (pressedRipples.size === 0) {
|
||||
detachReleaseListeners()
|
||||
}
|
||||
}
|
||||
|
||||
const releaseRipple = (pointerId: number) => {
|
||||
const activeRipple = pressedRipples.get(pointerId)
|
||||
|
||||
if (!activeRipple || activeRipple.released) {
|
||||
return
|
||||
}
|
||||
|
||||
pressedRipples.delete(pointerId)
|
||||
activeRipple.released = true
|
||||
|
||||
const computedOpacity = Number.parseFloat(
|
||||
getComputedStyle(activeRipple.ripple).opacity
|
||||
)
|
||||
const currentOpacity = Number.isFinite(computedOpacity)
|
||||
? computedOpacity
|
||||
: activeRipple.maxOpacity
|
||||
|
||||
/*
|
||||
* 扩散和淡出分别控制 transform 与 opacity:
|
||||
* 长按时扩散停留在最终状态,松开后立即淡出,
|
||||
* 快速点击时则会一边继续扩散一边淡出。
|
||||
*/
|
||||
activeRipple.pressAnimation.cancel()
|
||||
activeRipple.ripple.style.opacity = `${currentOpacity}`
|
||||
|
||||
const fadeAnimation = activeRipple.ripple.animate(
|
||||
[{ opacity: currentOpacity }, { opacity: 0 }],
|
||||
{
|
||||
duration: activeRipple.fadeDuration,
|
||||
easing: activeRipple.easing,
|
||||
fill: "forwards",
|
||||
}
|
||||
)
|
||||
|
||||
activeRipple.fadeAnimation = fadeAnimation
|
||||
fadeAnimation.finished.then(
|
||||
() => removeRipple(activeRipple),
|
||||
() => removeRipple(activeRipple)
|
||||
)
|
||||
|
||||
if (pressedRipples.size === 0) {
|
||||
detachReleaseListeners()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerEnd = (event: PointerEvent) => {
|
||||
releaseRipple(event.pointerId)
|
||||
}
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
for (const pointerId of Array.from(pressedRipples.keys())) {
|
||||
releaseRipple(pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
const attachReleaseListeners = () => {
|
||||
if (releaseListenersAttached) {
|
||||
return
|
||||
}
|
||||
|
||||
releaseListenersAttached = true
|
||||
window.addEventListener("pointerup", handlePointerEnd, {
|
||||
passive: true,
|
||||
})
|
||||
window.addEventListener("pointercancel", handlePointerEnd, {
|
||||
passive: true,
|
||||
})
|
||||
window.addEventListener("blur", handleWindowBlur)
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const {
|
||||
disabled = false,
|
||||
duration = 600,
|
||||
rippleClassName = "bg-current",
|
||||
opacity = 0.3,
|
||||
easing = "cubic-bezier(0.2, 0, 0, 1)",
|
||||
} = optionsRef.current
|
||||
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// 原生 button、input 等 disabled 状态
|
||||
if (element.matches(":disabled")) {
|
||||
return
|
||||
}
|
||||
|
||||
// div、a、li 等元素可使用 aria-disabled
|
||||
if (element.getAttribute("aria-disabled") === "true") {
|
||||
return
|
||||
}
|
||||
|
||||
// 鼠标只响应左键
|
||||
if (event.pointerType === "mouse" && event.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
* 某些情况下 React 更新或外部代码可能移除了 holder。
|
||||
* 点击时自动重新挂载。
|
||||
*/
|
||||
if (holder.parentElement !== element) {
|
||||
element.appendChild(holder)
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const x = event.clientX - rect.left
|
||||
const y = event.clientY - rect.top
|
||||
|
||||
// 最远的横向距离和纵向距离必然组成最远角
|
||||
const radius = Math.hypot(
|
||||
Math.max(x, rect.width - x),
|
||||
Math.max(y, rect.height - y)
|
||||
)
|
||||
|
||||
const size = radius * 2
|
||||
const ripple = document.createElement("span")
|
||||
const existingRipple = pressedRipples.get(event.pointerId)
|
||||
|
||||
if (existingRipple) {
|
||||
cancelRipple(existingRipple)
|
||||
}
|
||||
|
||||
ripple.className = cn(
|
||||
"pointer-events-none absolute rounded-full",
|
||||
rippleClassName
|
||||
)
|
||||
|
||||
const resolvedDuration = Number.isFinite(duration)
|
||||
? Math.max(1, duration)
|
||||
: 600
|
||||
const maxOpacity = Number.isFinite(opacity)
|
||||
? Math.min(1, Math.max(0, opacity))
|
||||
: 0.3
|
||||
const initialOpacity = maxOpacity / 3
|
||||
|
||||
Object.assign(ripple.style, {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
left: `${x - radius}px`,
|
||||
top: `${y - radius}px`,
|
||||
opacity: `${initialOpacity}`,
|
||||
color: getComputedStyle(element).color,
|
||||
willChange: "transform, opacity",
|
||||
})
|
||||
|
||||
holder.appendChild(ripple)
|
||||
|
||||
const expansionAnimation = ripple.animate(
|
||||
[
|
||||
{
|
||||
transform: "scale(0)",
|
||||
},
|
||||
{
|
||||
transform: "scale(1)",
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: resolvedDuration,
|
||||
easing,
|
||||
fill: "forwards",
|
||||
}
|
||||
)
|
||||
const pressAnimation = ripple.animate(
|
||||
[{ opacity: initialOpacity }, { opacity: maxOpacity }],
|
||||
{
|
||||
duration: resolvedDuration,
|
||||
easing,
|
||||
fill: "forwards",
|
||||
}
|
||||
)
|
||||
|
||||
const activeRipple: ActiveRipple = {
|
||||
pointerId: event.pointerId,
|
||||
ripple,
|
||||
expansionAnimation,
|
||||
pressAnimation,
|
||||
released: false,
|
||||
fadeDuration: resolvedDuration,
|
||||
easing,
|
||||
maxOpacity,
|
||||
}
|
||||
|
||||
activeRipples.add(activeRipple)
|
||||
pressedRipples.set(event.pointerId, activeRipple)
|
||||
attachReleaseListeners()
|
||||
}
|
||||
|
||||
element.addEventListener("pointerdown", handlePointerDown, {
|
||||
passive: true,
|
||||
})
|
||||
|
||||
cleanupRef.current = () => {
|
||||
element.removeEventListener("pointerdown", handlePointerDown)
|
||||
detachReleaseListeners()
|
||||
|
||||
for (const activeRipple of activeRipples) {
|
||||
cancelRipple(activeRipple)
|
||||
}
|
||||
|
||||
holder.remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
cleanupRef.current()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return rippleRef
|
||||
}
|
||||
|
||||
export function useRippleRef<T extends HTMLElement = HTMLElement>(
|
||||
ref?: React.Ref<T>,
|
||||
options: UseRippleOptions = {}
|
||||
) {
|
||||
const rippleRef = useRipple<T>(options)
|
||||
|
||||
return useMergedRefs(ref, rippleRef)
|
||||
}
|
||||
Reference in New Issue
Block a user