@luanlu/mk-motion 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/package.json +2 -4
  2. package/src/a11y/focus-trap.ts +64 -0
  3. package/src/a11y/keyboard.ts +43 -0
  4. package/src/components/alert/alert.css +111 -0
  5. package/src/components/alert/alert.ts +107 -0
  6. package/src/components/avatar/avatar.css +112 -0
  7. package/src/components/avatar/avatar.ts +175 -0
  8. package/src/components/breadcrumb/breadcrumb.css +31 -0
  9. package/src/components/breadcrumb/breadcrumb.ts +71 -0
  10. package/src/components/button/button.css +108 -0
  11. package/src/components/button/button.ts +140 -0
  12. package/src/components/card/card.css +52 -0
  13. package/src/components/card/card.ts +87 -0
  14. package/src/components/collapse/collapse.css +76 -0
  15. package/src/components/collapse/collapse.ts +168 -0
  16. package/src/components/dialog/dialog.css +78 -0
  17. package/src/components/dialog/dialog.ts +164 -0
  18. package/src/components/drawer/drawer.css +73 -0
  19. package/src/components/drawer/drawer.ts +131 -0
  20. package/src/components/empty/empty.css +36 -0
  21. package/src/components/empty/empty.ts +85 -0
  22. package/src/components/form/checkbox.css +56 -0
  23. package/src/components/form/checkbox.ts +119 -0
  24. package/src/components/form/radio.css +57 -0
  25. package/src/components/form/radio.ts +153 -0
  26. package/src/components/form/select.css +91 -0
  27. package/src/components/form/select.ts +174 -0
  28. package/src/components/form/slider.css +56 -0
  29. package/src/components/form/slider.ts +148 -0
  30. package/src/components/input/input.css +92 -0
  31. package/src/components/input/input.ts +162 -0
  32. package/src/components/layout/divider.css +32 -0
  33. package/src/components/layout/divider.ts +42 -0
  34. package/src/components/layout/row.css +64 -0
  35. package/src/components/layout/row.ts +57 -0
  36. package/src/components/layout/space.css +14 -0
  37. package/src/components/layout/space.ts +48 -0
  38. package/src/components/loading/loading.css +37 -0
  39. package/src/components/loading/loading.ts +46 -0
  40. package/src/components/menu/menu.css +121 -0
  41. package/src/components/menu/menu.ts +187 -0
  42. package/src/components/message/message.css +64 -0
  43. package/src/components/message/message.ts +96 -0
  44. package/src/components/popover/popover.css +73 -0
  45. package/src/components/popover/popover.ts +279 -0
  46. package/src/components/progress/progress.css +112 -0
  47. package/src/components/progress/progress.ts +171 -0
  48. package/src/components/steps/steps.css +127 -0
  49. package/src/components/steps/steps.ts +102 -0
  50. package/src/components/styles/components.css +28 -0
  51. package/src/components/styles/reset.css +24 -0
  52. package/src/components/styles/tokens.css +248 -0
  53. package/src/components/styles/variables.css +24 -0
  54. package/src/components/switch/switch.css +53 -0
  55. package/src/components/switch/switch.ts +103 -0
  56. package/src/components/table/table.css +192 -0
  57. package/src/components/table/table.ts +370 -0
  58. package/src/components/tabs/tabs.css +138 -0
  59. package/src/components/tabs/tabs.ts +211 -0
  60. package/src/components/tag/tag.css +123 -0
  61. package/src/components/tag/tag.ts +112 -0
  62. package/src/components/tooltip/tooltip.css +66 -0
  63. package/src/components/tooltip/tooltip.ts +185 -0
  64. package/src/core/animator.ts +124 -0
  65. package/src/core/timeline.ts +128 -0
  66. package/src/core/utils.ts +47 -0
  67. package/src/effects/glitch.ts +99 -0
  68. package/src/effects/particle.ts +134 -0
  69. package/src/effects/text-split.ts +95 -0
  70. package/src/effects/wave-text.ts +88 -0
  71. package/src/gesture/draggable.ts +130 -0
  72. package/src/gesture/spring.ts +152 -0
  73. package/src/index.ts +162 -0
  74. package/src/interactive/coverflow.ts +100 -0
  75. package/src/interactive/cursor-trail.ts +113 -0
  76. package/src/interactive/flip-card.ts +114 -0
  77. package/src/interactive/magnetic.ts +121 -0
  78. package/src/micro/hover-lift.ts +94 -0
  79. package/src/micro/ripple.ts +130 -0
  80. package/src/motion/component-motion.ts +177 -0
  81. package/src/presets/index.ts +69 -0
  82. package/src/scroll/scroll-trigger.ts +104 -0
  83. package/src/styles/animations.css +135 -0
  84. package/src/styles/element-plus.css +174 -0
  85. package/src/text/count-up.ts +108 -0
  86. package/src/text/typewriter.ts +109 -0
  87. package/src/theme/dark.css +19 -0
  88. package/src/theme/light.css +19 -0
  89. package/src/theme/theme.ts +65 -0
  90. package/src/transitions/blur-reveal.ts +92 -0
  91. package/src/transitions/collapse.ts +112 -0
  92. package/src/transitions/lazy-image.ts +87 -0
  93. package/src/transitions/list.ts +75 -0
  94. package/src/transitions/loading.ts +95 -0
  95. package/src/transitions/parallax.ts +60 -0
  96. package/src/transitions/shimmer.ts +105 -0
  97. package/src/transitions/toast.ts +151 -0
  98. package/src/types.d.ts +4 -0
@@ -0,0 +1,108 @@
1
+ export interface CountUpOptions {
2
+ duration?: number // 动画总时长毫秒
3
+ easing?: 'linear' | 'easeOut' | 'easeInOut'
4
+ decimals?: number // 小数位
5
+ prefix?: string // 前缀
6
+ suffix?: string // 后缀
7
+ separator?: string // 千分位分隔符
8
+ onUpdate?: (value: number) => void
9
+ onComplete?: () => void
10
+ }
11
+
12
+ const DEFAULT_COUNT_OPTS: Required<Omit<CountUpOptions, 'onUpdate' | 'onComplete'>> = {
13
+ duration: 2000,
14
+ easing: 'easeOut',
15
+ decimals: 0,
16
+ prefix: '',
17
+ suffix: '',
18
+ separator: ',',
19
+ }
20
+
21
+ export class CountUp {
22
+ private element: HTMLElement
23
+ private rafId: number | null = null
24
+
25
+ constructor(element: HTMLElement | string) {
26
+ this.element =
27
+ typeof element === 'string'
28
+ ? document.querySelector(element)!
29
+ : element
30
+
31
+ if (!this.element) {
32
+ throw new Error('CountUp: element not found')
33
+ }
34
+ }
35
+
36
+ animateTo(
37
+ endValue: number,
38
+ startValue: number = 0,
39
+ options: CountUpOptions = {}
40
+ ): Promise<void> {
41
+ const opts = { ...DEFAULT_COUNT_OPTS, ...options }
42
+ const startTime = performance.now()
43
+
44
+ return new Promise((resolve) => {
45
+ const tick = (now: number) => {
46
+ const elapsed = now - startTime
47
+ const progress = Math.min(elapsed / opts.duration, 1)
48
+ const eased = this.applyEasing(progress, opts.easing)
49
+ const current = startValue + (endValue - startValue) * eased
50
+
51
+ this.render(current, opts)
52
+ opts.onUpdate?.(current)
53
+
54
+ if (progress < 1) {
55
+ this.rafId = requestAnimationFrame(tick)
56
+ } else {
57
+ this.render(endValue, opts)
58
+ opts.onComplete?.()
59
+ resolve()
60
+ }
61
+ }
62
+
63
+ this.rafId = requestAnimationFrame(tick)
64
+ })
65
+ }
66
+
67
+ stop(): void {
68
+ if (this.rafId) {
69
+ cancelAnimationFrame(this.rafId)
70
+ this.rafId = null
71
+ }
72
+ }
73
+
74
+ private render(value: number, opts: CountUpOptions): void {
75
+ let formatted = value.toFixed(opts.decimals!)
76
+
77
+ // 千分位
78
+ if (opts.separator) {
79
+ const parts = formatted.split('.')
80
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, opts.separator!)
81
+ formatted = parts.join('.')
82
+ }
83
+
84
+ this.element.textContent = `${opts.prefix}${formatted}${opts.suffix}`
85
+ }
86
+
87
+ private applyEasing(t: number, easing: string): number {
88
+ switch (easing) {
89
+ case 'easeOut':
90
+ return 1 - Math.pow(1 - t, 3)
91
+ case 'easeInOut':
92
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
93
+ default:
94
+ return t
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 快捷方法
101
+ */
102
+ export function countUp(
103
+ element: HTMLElement | string,
104
+ endValue: number,
105
+ options?: CountUpOptions
106
+ ): Promise<void> {
107
+ return new CountUp(element).animateTo(endValue, 0, options)
108
+ }
@@ -0,0 +1,109 @@
1
+ export interface TypewriterOptions {
2
+ speed?: number // 每个字间隔毫秒
3
+ cursor?: boolean // 是否显示闪烁光标
4
+ cursorChar?: string // 光标字符
5
+ onComplete?: () => void
6
+ }
7
+
8
+ const DEFAULT_TYPE_OPTS: Required<Omit<TypewriterOptions, 'onComplete'>> = {
9
+ speed: 80,
10
+ cursor: true,
11
+ cursorChar: '|',
12
+ }
13
+
14
+ export class Typewriter {
15
+ private element: HTMLElement
16
+ private originalText: string = ''
17
+ private timer: ReturnType<typeof setTimeout> | null = null
18
+ private running = false
19
+
20
+ constructor(element: HTMLElement | string) {
21
+ this.element =
22
+ typeof element === 'string'
23
+ ? document.querySelector(element)!
24
+ : element
25
+
26
+ if (!this.element) {
27
+ throw new Error('Typewriter: element not found')
28
+ }
29
+
30
+ this.originalText = this.element.textContent ?? ''
31
+ }
32
+
33
+ async start(options: TypewriterOptions = {}): Promise<void> {
34
+ if (this.running) return
35
+ this.running = true
36
+
37
+ const opts = { ...DEFAULT_TYPE_OPTS, ...options }
38
+ const text = this.originalText || this.element.textContent || ''
39
+ this.element.textContent = ''
40
+
41
+ if (opts.cursor) {
42
+ this.element.style.borderRight = `2px solid currentColor`
43
+ this.element.style.paddingRight = '2px'
44
+ this.startCursorBlink()
45
+ }
46
+
47
+ return new Promise((resolve) => {
48
+ let i = 0
49
+ const typeNext = () => {
50
+ if (!this.running) {
51
+ resolve()
52
+ return
53
+ }
54
+
55
+ if (i < text.length) {
56
+ this.element.textContent += text.charAt(i)
57
+ i++
58
+ this.timer = setTimeout(typeNext, opts.speed)
59
+ } else {
60
+ this.running = false
61
+ opts.onComplete?.()
62
+ resolve()
63
+ }
64
+ }
65
+ typeNext()
66
+ })
67
+ }
68
+
69
+ stop(): void {
70
+ this.running = false
71
+ if (this.timer) {
72
+ clearTimeout(this.timer)
73
+ this.timer = null
74
+ }
75
+ }
76
+
77
+ reset(): void {
78
+ this.stop()
79
+ this.element.textContent = this.originalText
80
+ this.element.style.borderRight = 'none'
81
+ this.element.style.paddingRight = '0'
82
+ }
83
+
84
+ private startCursorBlink(): void {
85
+ let visible = true
86
+ const blink = () => {
87
+ if (!this.running) {
88
+ this.element.style.borderRightColor = 'transparent'
89
+ return
90
+ }
91
+ this.element.style.borderRightColor = visible ? 'currentColor' : 'transparent'
92
+ visible = !visible
93
+ setTimeout(blink, 530)
94
+ }
95
+ blink()
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 快捷方法
101
+ */
102
+ export function typewrite(
103
+ element: HTMLElement | string,
104
+ options?: TypewriterOptions
105
+ ): Promise<void> {
106
+ const tw = new Typewriter(element)
107
+ tw.reset()
108
+ return tw.start(options)
109
+ }
@@ -0,0 +1,19 @@
1
+ [data-mk-theme="dark"] {
2
+ --mk-bg: #09090b;
3
+ --mk-surface: #12121a;
4
+ --mk-surface-hover: #1a1a25;
5
+ --mk-border: #1f1f2e;
6
+ --mk-border-hover: #2e2e42;
7
+
8
+ --mk-primary: #6366f1;
9
+ --mk-primary-hover: #818cf8;
10
+ --mk-primary-active: #4f46e5;
11
+
12
+ --mk-success: #22c55e;
13
+ --mk-warning: #f59e0b;
14
+ --mk-danger: #ef4444;
15
+
16
+ --mk-text: #f8fafc;
17
+ --mk-text-secondary: #94a3b8;
18
+ --mk-text-tertiary: #64748b;
19
+ }
@@ -0,0 +1,19 @@
1
+ [data-mk-theme="light"] {
2
+ --mk-bg: #ffffff;
3
+ --mk-surface: #f8fafc;
4
+ --mk-surface-hover: #f1f5f9;
5
+ --mk-border: #e2e8f0;
6
+ --mk-border-hover: #cbd5e1;
7
+
8
+ --mk-primary: #6366f1;
9
+ --mk-primary-hover: #4f46e5;
10
+ --mk-primary-active: #4338ca;
11
+
12
+ --mk-success: #22c55e;
13
+ --mk-warning: #f59e0b;
14
+ --mk-danger: #ef4444;
15
+
16
+ --mk-text: #0f172a;
17
+ --mk-text-secondary: #475569;
18
+ --mk-text-tertiary: #94a3b8;
19
+ }
@@ -0,0 +1,65 @@
1
+ export interface ThemeConfig {
2
+ bg?: string
3
+ surface?: string
4
+ 'surface-hover'?: string
5
+ border?: string
6
+ 'border-hover'?: string
7
+ primary?: string
8
+ 'primary-hover'?: string
9
+ 'primary-active'?: string
10
+ success?: string
11
+ warning?: string
12
+ danger?: string
13
+ text?: string
14
+ 'text-secondary'?: string
15
+ 'text-tertiary'?: string
16
+ }
17
+
18
+ export class ThemeManager {
19
+ private currentTheme: 'light' | 'dark' = 'dark'
20
+ private listeners = new Set<(theme: 'light' | 'dark') => void>()
21
+
22
+ constructor() {
23
+ // 默认暗色
24
+ document.documentElement.setAttribute('data-mk-theme', 'dark')
25
+ }
26
+
27
+ setTheme(theme: 'light' | 'dark'): void {
28
+ this.currentTheme = theme
29
+ document.documentElement.setAttribute('data-mk-theme', theme)
30
+ this.listeners.forEach((fn) => fn(theme))
31
+ }
32
+
33
+ toggle(): void {
34
+ this.setTheme(this.currentTheme === 'dark' ? 'light' : 'dark')
35
+ }
36
+
37
+ getTheme(): 'light' | 'dark' {
38
+ return this.currentTheme
39
+ }
40
+
41
+ applyCustom(config: ThemeConfig): void {
42
+ const root = document.documentElement
43
+ Object.entries(config).forEach(([key, value]) => {
44
+ root.style.setProperty(`--mk-${key}`, value)
45
+ })
46
+ }
47
+
48
+ resetCustom(): void {
49
+ const root = document.documentElement
50
+ const props = [
51
+ 'bg', 'surface', 'surface-hover', 'border', 'border-hover',
52
+ 'primary', 'primary-hover', 'primary-active',
53
+ 'success', 'warning', 'danger',
54
+ 'text', 'text-secondary', 'text-tertiary',
55
+ ]
56
+ props.forEach((p) => root.style.removeProperty(`--mk-${p}`))
57
+ }
58
+
59
+ onChange(fn: (theme: 'light' | 'dark') => void): () => void {
60
+ this.listeners.add(fn)
61
+ return () => this.listeners.delete(fn)
62
+ }
63
+ }
64
+
65
+ export const theme = new ThemeManager()
@@ -0,0 +1,92 @@
1
+ export interface BlurRevealOptions {
2
+ duration?: number
3
+ blurStart?: number // 初始模糊像素
4
+ stagger?: number // 子元素间隔
5
+ childSelector?: string // 子元素选择器
6
+ }
7
+
8
+ const DEFAULT_BLUR: Required<Omit<BlurRevealOptions, 'childSelector'>> = {
9
+ duration: 800,
10
+ blurStart: 10,
11
+ stagger: 100,
12
+ }
13
+
14
+ /**
15
+ * 元素从模糊到清晰的渐入效果
16
+ */
17
+ export function blurReveal(
18
+ element: HTMLElement | string,
19
+ options: BlurRevealOptions = {}
20
+ ): Promise<void> {
21
+ const el =
22
+ typeof element === 'string'
23
+ ? document.querySelector<HTMLElement>(element)!
24
+ : element
25
+
26
+ if (!el) throw new Error('blurReveal: element not found')
27
+
28
+ const opts = { ...DEFAULT_BLUR, ...options }
29
+
30
+ el.style.filter = `blur(${opts.blurStart}px)`
31
+ el.style.opacity = '0'
32
+ el.style.transition = `filter ${opts.duration}ms ease, opacity ${opts.duration}ms ease`
33
+
34
+ // 强制重排
35
+ el.offsetHeight
36
+
37
+ return new Promise((resolve) => {
38
+ el.style.filter = 'blur(0px)'
39
+ el.style.opacity = '1'
40
+
41
+ setTimeout(() => {
42
+ el.style.transition = ''
43
+ resolve()
44
+ }, opts.duration)
45
+ })
46
+ }
47
+
48
+ /**
49
+ * 子元素依次模糊渐入
50
+ */
51
+ export function blurRevealChildren(
52
+ element: HTMLElement | string,
53
+ options: BlurRevealOptions = {}
54
+ ): Promise<void> {
55
+ const el =
56
+ typeof element === 'string'
57
+ ? document.querySelector<HTMLElement>(element)!
58
+ : element
59
+
60
+ if (!el) throw new Error('blurRevealChildren: element not found')
61
+
62
+ const opts = { ...DEFAULT_BLUR, ...options }
63
+ const children = opts.childSelector
64
+ ? Array.from(el.querySelectorAll(opts.childSelector))
65
+ : Array.from(el.children)
66
+
67
+ children.forEach((child) => {
68
+ const c = child as HTMLElement
69
+ c.style.filter = `blur(${opts.blurStart}px)`
70
+ c.style.opacity = '0'
71
+ c.style.transition = `filter ${opts.duration}ms ease, opacity ${opts.duration}ms ease`
72
+ })
73
+
74
+ el.offsetHeight
75
+
76
+ return new Promise((resolve) => {
77
+ let done = 0
78
+ children.forEach((child, i) => {
79
+ setTimeout(() => {
80
+ const c = child as HTMLElement
81
+ c.style.filter = 'blur(0px)'
82
+ c.style.opacity = '1'
83
+
84
+ setTimeout(() => {
85
+ c.style.transition = ''
86
+ done++
87
+ if (done >= children.length) resolve()
88
+ }, opts.duration)
89
+ }, i * opts.stagger)
90
+ })
91
+ })
92
+ }
@@ -0,0 +1,112 @@
1
+ export interface CollapseOptions {
2
+ duration?: number
3
+ easing?: string
4
+ }
5
+
6
+ const DEFAULT_COLLAPSE: Required<CollapseOptions> = {
7
+ duration: 300,
8
+ easing: 'ease-in-out',
9
+ }
10
+
11
+ /**
12
+ * 高度展开动画(模拟 Vue 的 collapse-transition)
13
+ */
14
+ export function expand(
15
+ element: HTMLElement | string,
16
+ options: CollapseOptions = {}
17
+ ): Promise<void> {
18
+ const el =
19
+ typeof element === 'string'
20
+ ? document.querySelector<HTMLElement>(element)!
21
+ : element
22
+
23
+ if (!el) throw new Error('expand: element not found')
24
+
25
+ const opts = { ...DEFAULT_COLLAPSE, ...options }
26
+
27
+ el.style.overflow = 'hidden'
28
+ el.style.display = ''
29
+ const fullHeight = el.scrollHeight
30
+
31
+ el.style.height = '0px'
32
+ el.style.opacity = '0'
33
+ el.style.transition = `height ${opts.duration}ms ${opts.easing}, opacity ${opts.duration}ms ${opts.easing}`
34
+
35
+ return new Promise((resolve) => {
36
+ requestAnimationFrame(() => {
37
+ el.style.height = `${fullHeight}px`
38
+ el.style.opacity = '1'
39
+
40
+ setTimeout(() => {
41
+ el.style.height = ''
42
+ el.style.overflow = ''
43
+ el.style.transition = ''
44
+ el.style.opacity = ''
45
+ resolve()
46
+ }, opts.duration)
47
+ })
48
+ })
49
+ }
50
+
51
+ /**
52
+ * 高度折叠动画
53
+ */
54
+ export function collapse(
55
+ element: HTMLElement | string,
56
+ options: CollapseOptions = {}
57
+ ): Promise<void> {
58
+ const el =
59
+ typeof element === 'string'
60
+ ? document.querySelector<HTMLElement>(element)!
61
+ : element
62
+
63
+ if (!el) throw new Error('collapse: element not found')
64
+
65
+ const opts = { ...DEFAULT_COLLAPSE, ...options }
66
+ const fullHeight = el.scrollHeight
67
+
68
+ el.style.overflow = 'hidden'
69
+ el.style.height = `${fullHeight}px`
70
+ el.style.transition = `height ${opts.duration}ms ${opts.easing}, opacity ${opts.duration}ms ${opts.easing}`
71
+
72
+ return new Promise((resolve) => {
73
+ requestAnimationFrame(() => {
74
+ el.style.height = '0px'
75
+ el.style.opacity = '0'
76
+
77
+ setTimeout(() => {
78
+ el.style.display = 'none'
79
+ el.style.height = ''
80
+ el.style.overflow = ''
81
+ el.style.transition = ''
82
+ el.style.opacity = ''
83
+ resolve()
84
+ }, opts.duration)
85
+ })
86
+ })
87
+ }
88
+
89
+ /**
90
+ * 切换展开/折叠
91
+ */
92
+ export async function toggleCollapse(
93
+ element: HTMLElement | string,
94
+ options?: CollapseOptions
95
+ ): Promise<boolean> {
96
+ const el =
97
+ typeof element === 'string'
98
+ ? document.querySelector<HTMLElement>(element)!
99
+ : element
100
+
101
+ if (!el) throw new Error('toggleCollapse: element not found')
102
+
103
+ const isHidden = getComputedStyle(el).display === 'none' || el.offsetHeight === 0
104
+
105
+ if (isHidden) {
106
+ await expand(el, options)
107
+ return true
108
+ } else {
109
+ await collapse(el, options)
110
+ return false
111
+ }
112
+ }
@@ -0,0 +1,87 @@
1
+ export interface LazyImageOptions {
2
+ threshold?: number
3
+ rootMargin?: string
4
+ blur?: boolean // 加载前是否模糊
5
+ placeholder?: string // 占位背景色
6
+ }
7
+
8
+ const DEFAULT_LAZY: Required<LazyImageOptions> = {
9
+ threshold: 0.1,
10
+ rootMargin: '50px',
11
+ blur: true,
12
+ placeholder: '#1e293b',
13
+ }
14
+
15
+ /**
16
+ * 图片懒加载:进入视口时加载,并带渐入动画
17
+ */
18
+ export function lazyImage(
19
+ imgElement: HTMLImageElement | string,
20
+ options: LazyImageOptions = {}
21
+ ): () => void {
22
+ const img =
23
+ typeof imgElement === 'string'
24
+ ? document.querySelector<HTMLImageElement>(imgElement)!
25
+ : imgElement
26
+
27
+ if (!img) throw new Error('lazyImage: image not found')
28
+
29
+ const opts = { ...DEFAULT_LAZY, ...options }
30
+ const realSrc = img.dataset.src
31
+
32
+ if (!realSrc) {
33
+ console.warn('lazyImage: img 缺少 data-src 属性')
34
+ return () => {}
35
+ }
36
+
37
+ img.style.background = opts.placeholder
38
+ img.style.transition = 'opacity 0.6s ease, filter 0.6s ease'
39
+
40
+ if (opts.blur) {
41
+ img.style.filter = 'blur(8px)'
42
+ img.style.opacity = '0.5'
43
+ } else {
44
+ img.style.opacity = '0'
45
+ }
46
+
47
+ const observer = new IntersectionObserver(
48
+ (entries) => {
49
+ entries.forEach((entry) => {
50
+ if (entry.isIntersecting) {
51
+ const image = entry.target as HTMLImageElement
52
+ const src = image.dataset.src!
53
+
54
+ const temp = new Image()
55
+ temp.onload = () => {
56
+ image.src = src
57
+ image.style.opacity = '1'
58
+ image.style.filter = 'blur(0px)'
59
+ image.style.background = ''
60
+ }
61
+ temp.onerror = () => {
62
+ image.style.opacity = '1'
63
+ image.style.filter = 'blur(0px)'
64
+ }
65
+ temp.src = src
66
+
67
+ observer.unobserve(image)
68
+ }
69
+ })
70
+ },
71
+ { threshold: opts.threshold, rootMargin: opts.rootMargin }
72
+ )
73
+
74
+ observer.observe(img)
75
+
76
+ return () => observer.disconnect()
77
+ }
78
+
79
+ /**
80
+ * 批量处理页面内所有懒加载图片
81
+ */
82
+ export function lazyImages(selector: string = 'img[data-src]'): () => void {
83
+ const images = document.querySelectorAll<HTMLImageElement>(selector)
84
+ const cleaners = Array.from(images).map((img) => lazyImage(img))
85
+
86
+ return () => cleaners.forEach((fn) => fn())
87
+ }
@@ -0,0 +1,75 @@
1
+ export interface ListStaggerOptions {
2
+ stagger?: number // 每项间隔毫秒
3
+ duration?: number // 单个动画时长
4
+ animation?: 'fadeUp' | 'fadeDown' | 'fadeLeft' | 'fadeRight' | 'zoom'
5
+ }
6
+
7
+ const DEFAULT_LIST: Required<ListStaggerOptions> = {
8
+ stagger: 60,
9
+ duration: 400,
10
+ animation: 'fadeUp',
11
+ }
12
+
13
+ const PRESETS: Record<string, { from: string; to: string }> = {
14
+ fadeUp: {
15
+ from: 'opacity:0;transform:translate3d(0,16px,0)',
16
+ to: 'opacity:1;transform:translate3d(0,0,0)',
17
+ },
18
+ fadeDown: {
19
+ from: 'opacity:0;transform:translate3d(0,-16px,0)',
20
+ to: 'opacity:1;transform:translate3d(0,0,0)',
21
+ },
22
+ fadeLeft: {
23
+ from: 'opacity:0;transform:translate3d(16px,0,0)',
24
+ to: 'opacity:1;transform:translate3d(0,0,0)',
25
+ },
26
+ fadeRight: {
27
+ from: 'opacity:0;transform:translate3d(-16px,0,0)',
28
+ to: 'opacity:1;transform:translate3d(0,0,0)',
29
+ },
30
+ zoom: {
31
+ from: 'opacity:0;transform:scale3d(0.9,0.9,0.9)',
32
+ to: 'opacity:1;transform:scale3d(1,1,1)',
33
+ },
34
+ }
35
+
36
+ /**
37
+ * 列表项依次入场动画(类似 Element Plus Table 行入场)
38
+ */
39
+ export function listStagger(
40
+ container: HTMLElement | string,
41
+ options: ListStaggerOptions = {}
42
+ ): Promise<void> {
43
+ const el =
44
+ typeof container === 'string'
45
+ ? document.querySelector<HTMLElement>(container)!
46
+ : container
47
+
48
+ if (!el) throw new Error('listStagger: container not found')
49
+
50
+ const opts = { ...DEFAULT_LIST, ...options }
51
+ const preset = PRESETS[opts.animation]
52
+ const items = Array.from(el.children) as HTMLElement[]
53
+
54
+ items.forEach((item) => {
55
+ item.style.cssText += preset.from
56
+ })
57
+
58
+ el.offsetHeight // 强制重排
59
+
60
+ return new Promise((resolve) => {
61
+ let done = 0
62
+ items.forEach((item, i) => {
63
+ setTimeout(() => {
64
+ item.style.transition = `all ${opts.duration}ms cubic-bezier(0.25, 0.1, 0.25, 1)`
65
+ item.style.cssText += preset.to
66
+
67
+ setTimeout(() => {
68
+ item.style.transition = ''
69
+ done++
70
+ if (done >= items.length) resolve()
71
+ }, opts.duration)
72
+ }, i * opts.stagger)
73
+ })
74
+ })
75
+ }