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:
Maofeng
2026-07-29 21:22:31 +08:00
parent cf8a7631e2
commit 7b9270ef67
68 changed files with 9209 additions and 144 deletions
+134
View File
@@ -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])
}
+1
View File
@@ -0,0 +1 @@
export { useIsMobile } from "./use-breakpoint"
+356
View File
@@ -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)
}