@vobs/transition 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/package.json +29 -0
- package/src/driver.ts +204 -0
- package/src/index.ts +16 -0
- package/src/transition.test.ts +306 -0
- package/src/transition.ts +356 -0
- package/src/types.ts +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vobs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @vobs/transition
|
|
2
|
+
|
|
3
|
+
Enter/leave transitions for vobs nodes: a CSS class lifecycle with optional inline from/to styles, cancellation when state flips mid-animation, `prefers-reduced-motion` support, and keyed list transitions.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/transition
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { state } from '@vobs/reactivity'
|
|
15
|
+
import { createComponent, createDOMRenderer, createVobs, setRenderer } from '@vobs/vobs'
|
|
16
|
+
import { Transition } from '@vobs/transition'
|
|
17
|
+
|
|
18
|
+
setRenderer(createDOMRenderer())
|
|
19
|
+
|
|
20
|
+
const show = state(true)
|
|
21
|
+
|
|
22
|
+
const app = createVobs({
|
|
23
|
+
render: () => createComponent(Transition, {
|
|
24
|
+
get show() { return show.value },
|
|
25
|
+
name: 'fade',
|
|
26
|
+
duration: 120,
|
|
27
|
+
appear: true,
|
|
28
|
+
onAfterLeave: () => console.log('gone'),
|
|
29
|
+
children: 'Content'
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
app.mount(document.getElementById('app')!)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`name: 'fade'` drives `fade-enter-from`/`-active`/`-to` and `fade-leave-*` classes. During leave the node stays mounted until the animation ends, so owners and disposals survive until then; toggling `show` back before the leave finishes cancels it (`onLeaveCancelled`) and keeps the same node. When `prefers-reduced-motion` matches, enter and leave complete immediately.
|
|
37
|
+
|
|
38
|
+
## API
|
|
39
|
+
|
|
40
|
+
| Signature | Description |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `Transition(props: TransitionProps)` | Wraps a single child. Omit `show` to render and enter once; `appear` animates the initial visible branch. `enter`/`leave` phases accept `{ duration, easing, from, to }` inline styles, restored after `transitionend` (or the shared `duration`). Callbacks: `onBeforeEnter`, `onEnter`, `onAfterEnter`, `onEnterCancelled`, and the matching leave hooks. `reducedMotion` respects the media query by default in DOM environments. |
|
|
43
|
+
| `TransitionGroup<Item>(props: TransitionGroupProps<Item>)` | Keyed list transitions via `items` + `keyOf` + `renderItem`. Leaving items are removed only after their leave completes; `renderItem` receives a refreshed index after reorders; duplicate keys throw before the DOM is committed. A stable `children` array is also supported. |
|
|
44
|
+
| `createCSSTransitionDriver()` / `cssTransitionDriver` | The built-in CSS driver. Pass a custom `TransitionDriver` — `run(node, phase, options, done)` returning a `TransitionRun` with `cancel()` — through the `driver` option for JS-driven animation. |
|
|
45
|
+
|
|
46
|
+
SSR renders the current visible branch without touching the DOM and without emitting transition classes.
|
|
47
|
+
|
|
48
|
+
## Types
|
|
49
|
+
|
|
50
|
+
TransitionStyle, TransitionPhase, TransitionPhaseName, TransitionRun, TransitionDriver, TransitionDriverOptions, TransitionCallbacks, TransitionOptions, TransitionChildren, TransitionProps, TransitionGroupProps, TransitionStatus
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"files": [
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
7
|
+
],
|
|
8
|
+
"name": "@vobs/transition",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"description": "Optional DOM transition lifecycle primitives for Vobs.",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "src/index.ts",
|
|
13
|
+
"types": "src/index.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@vobs/reactivity": "1.0.0",
|
|
19
|
+
"@vobs/runtime": "1.0.0",
|
|
20
|
+
"@vobs/vobs": "1.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@vobs/ssr": "1.0.0",
|
|
24
|
+
"@vobs/dom": "1.0.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "vitest --environment jsdom"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/driver.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { getRenderer, isVobsFragment, type VobsNode } from '@vobs/vobs'
|
|
2
|
+
import type {
|
|
3
|
+
TransitionDriver,
|
|
4
|
+
TransitionDriverOptions,
|
|
5
|
+
TransitionOptions,
|
|
6
|
+
TransitionPhase,
|
|
7
|
+
TransitionRun,
|
|
8
|
+
TransitionStatus,
|
|
9
|
+
TransitionStyle
|
|
10
|
+
} from './types'
|
|
11
|
+
|
|
12
|
+
export function runTransition(
|
|
13
|
+
node: VobsNode,
|
|
14
|
+
status: Extract<TransitionStatus, 'entering' | 'leaving'>,
|
|
15
|
+
options: TransitionDriverOptions,
|
|
16
|
+
done: () => void
|
|
17
|
+
): TransitionRun {
|
|
18
|
+
const elements = getTransitionElements(node)
|
|
19
|
+
if (elements.length === 0 || options.reducedMotion || !options.css && !hasStyles(options.phase)) {
|
|
20
|
+
done()
|
|
21
|
+
return { cancel() {} }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const phase = options.phase
|
|
25
|
+
const classes = options.css ? phaseClasses(options.name, status) : []
|
|
26
|
+
const restores = elements.map(element => applyInitialState(element, phase, classes, options.duration))
|
|
27
|
+
let finished = false
|
|
28
|
+
let frame: number | undefined
|
|
29
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
30
|
+
const ended = new Set<Element>()
|
|
31
|
+
|
|
32
|
+
const finish = (): void => {
|
|
33
|
+
if (finished) return
|
|
34
|
+
finished = true
|
|
35
|
+
if (frame !== undefined) cancelFrame(frame)
|
|
36
|
+
if (timeout !== undefined) clearTimeout(timeout)
|
|
37
|
+
for (const element of elements) {
|
|
38
|
+
element.removeEventListener('transitionend', onEnd)
|
|
39
|
+
element.removeEventListener('animationend', onEnd)
|
|
40
|
+
}
|
|
41
|
+
for (const restore of restores) restore()
|
|
42
|
+
done()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const onEnd = (event: Event): void => {
|
|
46
|
+
const target = event.target
|
|
47
|
+
if (!(target instanceof Element) || !elements.includes(target)) return
|
|
48
|
+
ended.add(target)
|
|
49
|
+
if (ended.size === elements.length) finish()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
frame = scheduleFrame(() => {
|
|
53
|
+
if (finished) return
|
|
54
|
+
for (const element of elements) applyTargetState(element, phase, classes)
|
|
55
|
+
if (options.duration <= 0) {
|
|
56
|
+
finish()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
for (const element of elements) {
|
|
60
|
+
element.addEventListener('transitionend', onEnd)
|
|
61
|
+
element.addEventListener('animationend', onEnd)
|
|
62
|
+
}
|
|
63
|
+
timeout = setTimeout(finish, options.duration + 50)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
cancel(): void {
|
|
68
|
+
if (finished) return
|
|
69
|
+
finished = true
|
|
70
|
+
if (frame !== undefined) cancelFrame(frame)
|
|
71
|
+
if (timeout !== undefined) clearTimeout(timeout)
|
|
72
|
+
for (const element of elements) {
|
|
73
|
+
element.removeEventListener('transitionend', onEnd)
|
|
74
|
+
element.removeEventListener('animationend', onEnd)
|
|
75
|
+
}
|
|
76
|
+
for (const restore of restores) restore()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const cssTransitionDriver: TransitionDriver = {
|
|
82
|
+
run(node, phase, options, done) {
|
|
83
|
+
return runTransition(node, phase === 'enter' ? 'entering' : 'leaving', options, done)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createCSSTransitionDriver(): TransitionDriver {
|
|
88
|
+
return cssTransitionDriver
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function asTransitionElement(node: unknown): Element | null {
|
|
92
|
+
if (!node || typeof node !== 'object') return null
|
|
93
|
+
const candidate = node as Partial<Element>
|
|
94
|
+
return candidate.classList && typeof candidate.addEventListener === 'function' ? candidate as Element : null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveReducedMotion(options: TransitionOptions): boolean {
|
|
98
|
+
if (options.reducedMotion === true) return true
|
|
99
|
+
if (options.reducedMotion === false || typeof window === 'undefined' || !window.matchMedia) return false
|
|
100
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getTransitionElements(node: VobsNode): Element[] {
|
|
104
|
+
const element = asTransitionElement(node)
|
|
105
|
+
if (element) return [element]
|
|
106
|
+
if (!isVobsFragment(node)) return []
|
|
107
|
+
|
|
108
|
+
const elements: Element[] = []
|
|
109
|
+
const renderer = getRenderer()
|
|
110
|
+
let current = renderer.nextSibling(node.start)
|
|
111
|
+
while (current && current !== node.end) {
|
|
112
|
+
const child = asTransitionElement(current)
|
|
113
|
+
if (child) elements.push(child)
|
|
114
|
+
current = renderer.nextSibling(current)
|
|
115
|
+
}
|
|
116
|
+
return elements
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hasStyles(phase: TransitionPhase | undefined): boolean {
|
|
120
|
+
return Boolean(phase?.from || phase?.to)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function phaseClasses(prefix: string, status: TransitionStatus): readonly string[] {
|
|
124
|
+
const phase = status === 'entering' ? 'enter' : 'leave'
|
|
125
|
+
return [`${prefix}-${phase}-from`, `${prefix}-${phase}-active`, `${prefix}-${phase}-to`]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function applyInitialState(
|
|
129
|
+
element: Element,
|
|
130
|
+
phase: TransitionPhase | undefined,
|
|
131
|
+
classes: readonly string[],
|
|
132
|
+
duration: number
|
|
133
|
+
): () => void {
|
|
134
|
+
const [from, active] = classes
|
|
135
|
+
if (from) element.classList.add(from)
|
|
136
|
+
if (active) element.classList.add(active)
|
|
137
|
+
const restoreStyles = applyInlineStyles(element, phase, duration)
|
|
138
|
+
return () => {
|
|
139
|
+
for (const className of classes) element.classList.remove(className)
|
|
140
|
+
restoreStyles()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function applyTargetState(element: Element, phase: TransitionPhase | undefined, classes: readonly string[]): void {
|
|
145
|
+
const [from, _active, to] = classes
|
|
146
|
+
if (from) element.classList.remove(from)
|
|
147
|
+
if (to) element.classList.add(to)
|
|
148
|
+
applyStyles(element, phase?.to)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function applyInlineStyles(element: Element, phase: TransitionPhase | undefined, duration: number): () => void {
|
|
152
|
+
const style = supportsInlineStyle(element) ? element.style : undefined
|
|
153
|
+
if (!style) return () => {}
|
|
154
|
+
const previous = new Map<string, string>()
|
|
155
|
+
const remember = (styles: TransitionStyle | undefined): void => {
|
|
156
|
+
for (const name of Object.keys(styles ?? {})) {
|
|
157
|
+
const property = stylePropertyName(name)
|
|
158
|
+
if (!previous.has(property)) previous.set(property, style.getPropertyValue(property))
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
remember(phase?.from)
|
|
162
|
+
remember(phase?.to)
|
|
163
|
+
if (hasStyles(phase) && duration > 0) {
|
|
164
|
+
previous.set('transition-property', style.getPropertyValue('transition-property'))
|
|
165
|
+
previous.set('transition-duration', style.getPropertyValue('transition-duration'))
|
|
166
|
+
previous.set('transition-timing-function', style.getPropertyValue('transition-timing-function'))
|
|
167
|
+
style.setProperty('transition-property', 'all')
|
|
168
|
+
style.setProperty('transition-duration', `${Math.max(0, duration)}ms`)
|
|
169
|
+
if (phase?.easing) style.setProperty('transition-timing-function', phase.easing)
|
|
170
|
+
}
|
|
171
|
+
applyStyles(element, phase?.from)
|
|
172
|
+
return () => {
|
|
173
|
+
for (const [name, value] of previous) style.setProperty(name, value)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function applyStyles(element: Element, styles: TransitionStyle | undefined): void {
|
|
178
|
+
if (!styles) return
|
|
179
|
+
const style = supportsInlineStyle(element) ? element.style : undefined
|
|
180
|
+
if (!style) return
|
|
181
|
+
for (const [name, value] of Object.entries(styles)) {
|
|
182
|
+
const property = stylePropertyName(name)
|
|
183
|
+
if (value === null || value === undefined) style.removeProperty(property)
|
|
184
|
+
else style.setProperty(property, String(value))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function stylePropertyName(name: string): string {
|
|
189
|
+
return name.startsWith('--') ? name : name.replace(/[A-Z]/gu, letter => `-${letter.toLowerCase()}`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function supportsInlineStyle(element: Element): element is Element & { style: CSSStyleDeclaration } {
|
|
193
|
+
return 'style' in element && Boolean((element as Partial<Element & { style: CSSStyleDeclaration }>).style)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function scheduleFrame(callback: () => void): number {
|
|
197
|
+
if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback)
|
|
198
|
+
return setTimeout(callback, 16) as unknown as number
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function cancelFrame(handle: number): void {
|
|
202
|
+
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle)
|
|
203
|
+
else clearTimeout(handle as unknown as ReturnType<typeof setTimeout>)
|
|
204
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { Transition, TransitionGroup } from './transition'
|
|
2
|
+
export { createCSSTransitionDriver, cssTransitionDriver } from './driver'
|
|
3
|
+
export type {
|
|
4
|
+
TransitionCallbacks,
|
|
5
|
+
TransitionChildren,
|
|
6
|
+
TransitionDriver,
|
|
7
|
+
TransitionDriverOptions,
|
|
8
|
+
TransitionGroupProps,
|
|
9
|
+
TransitionOptions,
|
|
10
|
+
TransitionPhase,
|
|
11
|
+
TransitionPhaseName,
|
|
12
|
+
TransitionProps,
|
|
13
|
+
TransitionRun,
|
|
14
|
+
TransitionStatus,
|
|
15
|
+
TransitionStyle
|
|
16
|
+
} from './types'
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { onDispose, state } from '@vobs/reactivity'
|
|
3
|
+
import { createComponent, createElement, createText, createVobs, insertBefore, setRenderer } from '@vobs/vobs'
|
|
4
|
+
import { createDOMRenderer } from '@vobs/dom'
|
|
5
|
+
import { renderToString } from '@vobs/ssr'
|
|
6
|
+
import { Transition, TransitionGroup } from './transition'
|
|
7
|
+
import type { TransitionGroupProps } from './types'
|
|
8
|
+
|
|
9
|
+
describe('@vobs/transition', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.useFakeTimers()
|
|
12
|
+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 16) as unknown as number)
|
|
13
|
+
vi.stubGlobal('cancelAnimationFrame', (handle: number) => clearTimeout(handle as unknown as ReturnType<typeof setTimeout>))
|
|
14
|
+
setRenderer(createDOMRenderer())
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.useRealTimers()
|
|
19
|
+
vi.unstubAllGlobals()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('在节点插入后执行 enter class 生命周期并清理 class', () => {
|
|
23
|
+
const stages: string[] = []
|
|
24
|
+
const container = document.createElement('main')
|
|
25
|
+
const app = createVobs({
|
|
26
|
+
render: () => createComponent(Transition, {
|
|
27
|
+
name: 'fade',
|
|
28
|
+
duration: 0,
|
|
29
|
+
appear: true,
|
|
30
|
+
onBeforeEnter: () => stages.push('before-enter'),
|
|
31
|
+
onEnter: () => stages.push('enter'),
|
|
32
|
+
onAfterEnter: () => stages.push('after-enter'),
|
|
33
|
+
children: () => textNode('visible')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
app.mount(container)
|
|
37
|
+
|
|
38
|
+
const node = container.querySelector('span')!
|
|
39
|
+
expect(node.classList.contains('fade-enter-from')).toBe(true)
|
|
40
|
+
expect(node.classList.contains('fade-enter-active')).toBe(true)
|
|
41
|
+
expect(stages).toEqual(['before-enter', 'enter'])
|
|
42
|
+
|
|
43
|
+
vi.advanceTimersByTime(16)
|
|
44
|
+
expect(node.className).toBe('')
|
|
45
|
+
expect(stages).toEqual(['before-enter', 'enter', 'after-enter'])
|
|
46
|
+
app.destroy()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('使用 from/to 样式,并在 transitionend 后恢复原始 inline style', () => {
|
|
50
|
+
const container = document.createElement('main')
|
|
51
|
+
const app = createVobs({
|
|
52
|
+
render: () => createComponent(Transition, {
|
|
53
|
+
appear: true,
|
|
54
|
+
name: 'slide',
|
|
55
|
+
enter: {
|
|
56
|
+
duration: 120,
|
|
57
|
+
easing: 'ease-out',
|
|
58
|
+
from: { opacity: 0, transform: 'translateY(-4px)' },
|
|
59
|
+
to: { opacity: 1, transform: 'translateY(0)' }
|
|
60
|
+
},
|
|
61
|
+
children: () => textNode('visible')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
app.mount(container)
|
|
65
|
+
|
|
66
|
+
const node = container.querySelector('span')!
|
|
67
|
+
expect(node.classList.contains('slide-enter-from')).toBe(true)
|
|
68
|
+
expect(node.style.opacity).toBe('0')
|
|
69
|
+
expect(node.style.transform).toBe('translateY(-4px)')
|
|
70
|
+
|
|
71
|
+
vi.advanceTimersByTime(16)
|
|
72
|
+
expect(node.classList.contains('slide-enter-to')).toBe(true)
|
|
73
|
+
expect(node.style.opacity).toBe('1')
|
|
74
|
+
expect(node.style.transitionDuration).toBe('120ms')
|
|
75
|
+
node.dispatchEvent(new Event('transitionend'))
|
|
76
|
+
|
|
77
|
+
expect(node.className).toBe('')
|
|
78
|
+
expect(node.style.opacity).toBe('')
|
|
79
|
+
expect(node.style.transform).toBe('')
|
|
80
|
+
expect(node.style.transitionDuration).toBe('')
|
|
81
|
+
app.destroy()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('leave 完成前保留节点与 Owner,完成后才移除', () => {
|
|
85
|
+
const shown = state(true)
|
|
86
|
+
const stages: string[] = []
|
|
87
|
+
const disposals: string[] = []
|
|
88
|
+
const container = document.createElement('main')
|
|
89
|
+
const app = createVobs({
|
|
90
|
+
render: () => createComponent(Transition, {
|
|
91
|
+
get show() { return shown.value },
|
|
92
|
+
name: 'fade',
|
|
93
|
+
duration: 120,
|
|
94
|
+
onBeforeLeave: () => stages.push('before-leave'),
|
|
95
|
+
onAfterLeave: () => stages.push('after-leave'),
|
|
96
|
+
children: () => createComponent(DisposableContent, { onDispose: () => disposals.push('disposed') })
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
app.mount(container)
|
|
100
|
+
vi.advanceTimersByTime(16)
|
|
101
|
+
|
|
102
|
+
shown.value = false
|
|
103
|
+
app.update()
|
|
104
|
+
const node = container.querySelector('span')!
|
|
105
|
+
expect(node.classList.contains('fade-leave-from')).toBe(true)
|
|
106
|
+
expect(container.querySelector('span')).toBe(node)
|
|
107
|
+
expect(disposals).toEqual([])
|
|
108
|
+
|
|
109
|
+
vi.advanceTimersByTime(16)
|
|
110
|
+
expect(node.classList.contains('fade-leave-to')).toBe(true)
|
|
111
|
+
node.dispatchEvent(new Event('transitionend'))
|
|
112
|
+
|
|
113
|
+
expect(container.querySelector('span')).toBeNull()
|
|
114
|
+
expect(stages).toEqual(['before-leave', 'after-leave'])
|
|
115
|
+
expect(disposals).toEqual(['disposed'])
|
|
116
|
+
app.destroy()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('遵循 prefers-reduced-motion 并立即完成 enter 和 leave', () => {
|
|
120
|
+
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({ matches: true }))
|
|
121
|
+
const shown = state(true)
|
|
122
|
+
const stages: string[] = []
|
|
123
|
+
const container = document.createElement('main')
|
|
124
|
+
const app = createVobs({
|
|
125
|
+
render: () => createComponent(Transition, {
|
|
126
|
+
get show() { return shown.value },
|
|
127
|
+
appear: true,
|
|
128
|
+
name: 'fade',
|
|
129
|
+
duration: 120,
|
|
130
|
+
onAfterEnter: () => stages.push('after-enter'),
|
|
131
|
+
onAfterLeave: () => stages.push('after-leave'),
|
|
132
|
+
children: () => textNode('visible')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
app.mount(container)
|
|
136
|
+
|
|
137
|
+
const node = container.querySelector('span')!
|
|
138
|
+
expect(node.className).toBe('')
|
|
139
|
+
expect(stages).toEqual(['after-enter'])
|
|
140
|
+
|
|
141
|
+
shown.value = false
|
|
142
|
+
app.update()
|
|
143
|
+
expect(container.querySelector('span')).toBeNull()
|
|
144
|
+
expect(stages).toEqual(['after-enter', 'after-leave'])
|
|
145
|
+
app.destroy()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('show 在 leave 期间恢复时取消离开并保持同一节点', () => {
|
|
149
|
+
const shown = state(true)
|
|
150
|
+
const events: string[] = []
|
|
151
|
+
const container = document.createElement('main')
|
|
152
|
+
const app = createVobs({
|
|
153
|
+
render: () => createComponent(Transition, {
|
|
154
|
+
get show() { return shown.value },
|
|
155
|
+
name: 'fade',
|
|
156
|
+
duration: 120,
|
|
157
|
+
onLeaveCancelled: () => events.push('leave-cancelled'),
|
|
158
|
+
children: () => textNode('visible')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
app.mount(container)
|
|
162
|
+
vi.advanceTimersByTime(16)
|
|
163
|
+
const node = container.querySelector('span')!
|
|
164
|
+
|
|
165
|
+
shown.value = false
|
|
166
|
+
app.update()
|
|
167
|
+
shown.value = true
|
|
168
|
+
app.update()
|
|
169
|
+
|
|
170
|
+
expect(container.querySelector('span')).toBe(node)
|
|
171
|
+
expect(events).toEqual(['leave-cancelled'])
|
|
172
|
+
expect(node.classList.contains('fade-leave-from')).toBe(false)
|
|
173
|
+
vi.advanceTimersByTime(16)
|
|
174
|
+
app.destroy()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('TransitionGroup 以 key 追踪条目并在 leave 后删除', () => {
|
|
178
|
+
const people = state([
|
|
179
|
+
{ id: 'ada', name: 'Ada' },
|
|
180
|
+
{ id: 'lin', name: 'Lin' }
|
|
181
|
+
])
|
|
182
|
+
const container = document.createElement('main')
|
|
183
|
+
const app = createVobs({
|
|
184
|
+
render: () => {
|
|
185
|
+
const list = createElement('ul')
|
|
186
|
+
insertBefore(list, createComponent(People, {
|
|
187
|
+
get items() { return people.value },
|
|
188
|
+
keyOf: person => person.id,
|
|
189
|
+
renderItem: person => textNode(person.name),
|
|
190
|
+
duration: 0
|
|
191
|
+
}), null)
|
|
192
|
+
return list
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
app.mount(container)
|
|
196
|
+
vi.advanceTimersByTime(16)
|
|
197
|
+
expect(container.querySelectorAll('span')).toHaveLength(2)
|
|
198
|
+
|
|
199
|
+
people.value = [{ id: 'lin', name: 'Lin' }]
|
|
200
|
+
app.update()
|
|
201
|
+
expect(container.querySelectorAll('span')).toHaveLength(2)
|
|
202
|
+
vi.advanceTimersByTime(16)
|
|
203
|
+
expect([...container.querySelectorAll('span')].map(node => node.textContent)).toEqual(['Lin'])
|
|
204
|
+
app.destroy()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('重复 key 在列表提交前报错并保留上一轮 DOM', () => {
|
|
208
|
+
const people = state([
|
|
209
|
+
{ id: 'ada', name: 'Ada' },
|
|
210
|
+
{ id: 'lin', name: 'Lin' }
|
|
211
|
+
])
|
|
212
|
+
const container = document.createElement('main')
|
|
213
|
+
const app = createVobs({
|
|
214
|
+
render: () => createComponent(People, {
|
|
215
|
+
get items() { return people.value },
|
|
216
|
+
keyOf: person => person.id,
|
|
217
|
+
renderItem: person => textNode(person.name),
|
|
218
|
+
duration: 0
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
app.mount(container)
|
|
222
|
+
|
|
223
|
+
people.value = [
|
|
224
|
+
{ id: 'ada', name: 'Ada 2' },
|
|
225
|
+
{ id: 'ada', name: 'Ada 3' }
|
|
226
|
+
]
|
|
227
|
+
expect(() => app.update()).toThrow('重复 key')
|
|
228
|
+
expect([...container.querySelectorAll('span')].map(node => node.textContent)).toEqual(['Ada', 'Lin'])
|
|
229
|
+
app.destroy()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('renderItem 使用 index 时在重排后刷新索引', () => {
|
|
233
|
+
const people = state([
|
|
234
|
+
{ id: 'ada', name: 'Ada' },
|
|
235
|
+
{ id: 'lin', name: 'Lin' }
|
|
236
|
+
])
|
|
237
|
+
const container = document.createElement('main')
|
|
238
|
+
const app = createVobs({
|
|
239
|
+
render: () => createComponent(People, {
|
|
240
|
+
get items() { return people.value },
|
|
241
|
+
keyOf: person => person.id,
|
|
242
|
+
renderItem: (person, index) => textNode(`${person.name}:${index}`),
|
|
243
|
+
duration: 0
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
app.mount(container)
|
|
247
|
+
|
|
248
|
+
people.value = [...people.value].reverse()
|
|
249
|
+
app.update()
|
|
250
|
+
expect([...container.querySelectorAll('span')].map(node => node.textContent)).toEqual(['Lin:0', 'Ada:1'])
|
|
251
|
+
app.destroy()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('SSR 不访问 DOM,并直接输出当前可见分支', () => {
|
|
255
|
+
const visible = renderToString(() => createComponent(Transition, {
|
|
256
|
+
show: true,
|
|
257
|
+
name: 'fade',
|
|
258
|
+
children: () => textNode('server content')
|
|
259
|
+
}))
|
|
260
|
+
const hidden = renderToString(() => createComponent(Transition, {
|
|
261
|
+
show: false,
|
|
262
|
+
children: () => textNode('hidden content')
|
|
263
|
+
}))
|
|
264
|
+
|
|
265
|
+
expect(visible).toContain('server content')
|
|
266
|
+
expect(visible).not.toContain('fade-enter')
|
|
267
|
+
expect(hidden).not.toContain('hidden content')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('children 数组入口可以保持稳定节点并执行 leave', () => {
|
|
271
|
+
const first = textNode('first')
|
|
272
|
+
const second = textNode('second')
|
|
273
|
+
const children = state<readonly Element[]>([first, second])
|
|
274
|
+
const container = document.createElement('main')
|
|
275
|
+
const app = createVobs({
|
|
276
|
+
render: () => createComponent(TransitionGroup, {
|
|
277
|
+
get children() { return children.value },
|
|
278
|
+
duration: 0
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
app.mount(container)
|
|
282
|
+
expect(container.querySelectorAll('span')).toHaveLength(2)
|
|
283
|
+
|
|
284
|
+
children.value = [second]
|
|
285
|
+
app.update()
|
|
286
|
+
expect(container.querySelectorAll('span')).toHaveLength(2)
|
|
287
|
+
vi.advanceTimersByTime(16)
|
|
288
|
+
expect([...container.querySelectorAll('span')].map(node => node.textContent)).toEqual(['second'])
|
|
289
|
+
app.destroy()
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
function textNode(value: string): Element {
|
|
294
|
+
const node = createElement('span')
|
|
295
|
+
insertBefore(node, createText(value), null)
|
|
296
|
+
return node
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function DisposableContent(props: { readonly onDispose: () => void }): Element {
|
|
300
|
+
onDispose(props.onDispose)
|
|
301
|
+
return textNode('visible')
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function People(props: TransitionGroupProps<{ id: string, name: string }>) {
|
|
305
|
+
return TransitionGroup(props)
|
|
306
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { createOwner, effect, onDispose, state, type Owner, type Signal } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
createFragment,
|
|
4
|
+
createText,
|
|
5
|
+
insertBefore,
|
|
6
|
+
removeChild,
|
|
7
|
+
type VobsFragment,
|
|
8
|
+
type VobsNode
|
|
9
|
+
} from '@vobs/vobs'
|
|
10
|
+
import { createBlock } from '@vobs/runtime'
|
|
11
|
+
import { asTransitionElement, cssTransitionDriver, resolveReducedMotion } from './driver'
|
|
12
|
+
import type {
|
|
13
|
+
TransitionChildren,
|
|
14
|
+
TransitionDriver,
|
|
15
|
+
TransitionGroupProps,
|
|
16
|
+
TransitionOptions,
|
|
17
|
+
TransitionProps,
|
|
18
|
+
TransitionStatus
|
|
19
|
+
} from './types'
|
|
20
|
+
|
|
21
|
+
interface TransitionEntry {
|
|
22
|
+
node: VobsNode
|
|
23
|
+
status: TransitionStatus
|
|
24
|
+
run?: { readonly cancel: () => void }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GroupEntry extends TransitionEntry {
|
|
28
|
+
readonly key: unknown
|
|
29
|
+
readonly owner: Owner
|
|
30
|
+
readonly item: Signal<unknown>
|
|
31
|
+
rawItem: unknown
|
|
32
|
+
index: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Delays branch disposal until the leave lifecycle has completed. It accepts
|
|
37
|
+
* Any rendered root is accepted; the default CSS driver animates element roots
|
|
38
|
+
* inside a Vobs fragment and leaves non-DOM renderers to complete immediately.
|
|
39
|
+
*/
|
|
40
|
+
export function Transition(props: TransitionProps = {}): VobsFragment {
|
|
41
|
+
return createFragment((parent, anchor) => {
|
|
42
|
+
let current: TransitionEntry | undefined
|
|
43
|
+
let disposed = false
|
|
44
|
+
let initialized = false
|
|
45
|
+
|
|
46
|
+
const stop = effect(() => {
|
|
47
|
+
const visible = readProp<boolean>(props, 'show', true) !== false
|
|
48
|
+
const initial = !initialized
|
|
49
|
+
initialized = true
|
|
50
|
+
if (visible) {
|
|
51
|
+
if (current) {
|
|
52
|
+
if (current.status === 'leaving') startEnter(current, props, true)
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const node = createBlock(() => resolveChildren(readProp(props, 'children', undefined)))
|
|
57
|
+
if (!node) return
|
|
58
|
+
current = { node, status: 'entering' }
|
|
59
|
+
insertBefore(parent, node, anchor)
|
|
60
|
+
startEnter(current, props, !initial || readProp<boolean>(props, 'appear', false))
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (current && current.status !== 'leaving') {
|
|
65
|
+
const leaving = current
|
|
66
|
+
startLeave(current, props, () => {
|
|
67
|
+
if (current?.node !== leaving.node) return
|
|
68
|
+
current = undefined
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Keep an owner-scoped cleanup so an outer branch/app removal never leaves
|
|
74
|
+
// an animation timer or DOM event listener behind.
|
|
75
|
+
onDispose(() => {
|
|
76
|
+
disposed = true
|
|
77
|
+
stop.dispose()
|
|
78
|
+
current?.run?.cancel()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
function startLeave(entry: TransitionEntry, options: TransitionOptions, afterLeave: () => void): void {
|
|
82
|
+
startLeaveEntry(entry, options, () => {
|
|
83
|
+
if (disposed || current?.node !== entry.node) return
|
|
84
|
+
removeChild(parent, entry.node)
|
|
85
|
+
afterLeave()
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Transition a keyed collection. `items`, `keyOf` and `renderItem` are
|
|
93
|
+
* explicit so lifecycle ownership remains stable without compiler-only magic.
|
|
94
|
+
*/
|
|
95
|
+
export function TransitionGroup<Item>(props: TransitionGroupProps<Item>): VobsFragment {
|
|
96
|
+
return createFragment((parent, anchor) => {
|
|
97
|
+
const entries = new Map<unknown, GroupEntry>()
|
|
98
|
+
let disposed = false
|
|
99
|
+
let initialized = false
|
|
100
|
+
const stop = effect(() => {
|
|
101
|
+
const nextEntries: GroupEntry[] = []
|
|
102
|
+
const initial = !initialized
|
|
103
|
+
initialized = true
|
|
104
|
+
const descriptors = readGroupDescriptors(props)
|
|
105
|
+
validateGroupDescriptors(descriptors)
|
|
106
|
+
const nextKeys = new Set(descriptors.map(descriptor => descriptor.key))
|
|
107
|
+
|
|
108
|
+
for (let index = 0; index < descriptors.length; index++) {
|
|
109
|
+
const descriptor = descriptors[index]
|
|
110
|
+
const { item, key } = descriptor
|
|
111
|
+
|
|
112
|
+
let entry = entries.get(key)
|
|
113
|
+
if (entry) {
|
|
114
|
+
const changed = !Object.is(entry.rawItem, item)
|
|
115
|
+
const tracksIndex = descriptor.render.length >= 2
|
|
116
|
+
if (changed || tracksIndex && entry.index !== index) refreshGroupEntry(parent, entry, descriptor)
|
|
117
|
+
entry.index = index
|
|
118
|
+
if (entry.status === 'leaving') startEnter(entry, props, true)
|
|
119
|
+
} else {
|
|
120
|
+
entry = createGroupEntry(descriptor)
|
|
121
|
+
entries.set(key, entry)
|
|
122
|
+
insertBefore(parent, entry.node, anchor)
|
|
123
|
+
startEnter(entry, props, !initial || readProp<boolean>(props, 'appear', false))
|
|
124
|
+
}
|
|
125
|
+
nextEntries.push(entry)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const [key, entry] of [...entries]) {
|
|
129
|
+
if (nextKeys.has(key)) continue
|
|
130
|
+
if (entry.status !== 'leaving') {
|
|
131
|
+
startLeaveEntry(entry, props, () => {
|
|
132
|
+
if (disposed || entries.get(key) !== entry) return
|
|
133
|
+
removeChild(parent, entry.node)
|
|
134
|
+
entry.owner.dispose()
|
|
135
|
+
entries.delete(key)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let reference: VobsNode | null = anchor
|
|
141
|
+
for (let index = nextEntries.length - 1; index >= 0; index--) {
|
|
142
|
+
insertBefore(parent, nextEntries[index].node, reference)
|
|
143
|
+
reference = nextEntries[index].node
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
onDispose(() => {
|
|
148
|
+
disposed = true
|
|
149
|
+
stop.dispose()
|
|
150
|
+
for (const entry of entries.values()) entry.run?.cancel()
|
|
151
|
+
entries.clear()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function validateGroupDescriptors(descriptors: readonly GroupDescriptor[]): void {
|
|
157
|
+
const seen = new Set<unknown>()
|
|
158
|
+
for (const descriptor of descriptors) {
|
|
159
|
+
if (seen.has(descriptor.key)) throw new Error(`TransitionGroup: 检测到重复 key: ${String(descriptor.key)}`)
|
|
160
|
+
seen.add(descriptor.key)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function createGroupEntry(descriptor: GroupDescriptor): GroupEntry {
|
|
165
|
+
const owner = createOwner()
|
|
166
|
+
let itemSignal!: Signal<unknown>
|
|
167
|
+
let node!: VobsNode
|
|
168
|
+
try {
|
|
169
|
+
owner.run(() => {
|
|
170
|
+
itemSignal = state(descriptor.item)
|
|
171
|
+
node = renderGroupNode(itemSignal, descriptor, owner)
|
|
172
|
+
})
|
|
173
|
+
} catch (error) {
|
|
174
|
+
owner.dispose()
|
|
175
|
+
throw error
|
|
176
|
+
}
|
|
177
|
+
if (!node) {
|
|
178
|
+
owner.dispose()
|
|
179
|
+
throw new Error('TransitionGroup: renderItem 必须返回节点')
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
key: descriptor.key,
|
|
183
|
+
node,
|
|
184
|
+
owner,
|
|
185
|
+
item: itemSignal,
|
|
186
|
+
rawItem: descriptor.item,
|
|
187
|
+
index: descriptor.index,
|
|
188
|
+
status: 'entering'
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function refreshGroupEntry(parent: Node, entry: GroupEntry, descriptor: GroupDescriptor): void {
|
|
193
|
+
entry.run?.cancel()
|
|
194
|
+
entry.run = undefined
|
|
195
|
+
entry.item.value = descriptor.item
|
|
196
|
+
const previousNode = entry.node
|
|
197
|
+
const nextNode = renderGroupNode(entry.item, descriptor, entry.owner)
|
|
198
|
+
entry.node = nextNode
|
|
199
|
+
entry.rawItem = descriptor.item
|
|
200
|
+
entry.index = descriptor.index
|
|
201
|
+
removeChild(parent, previousNode)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderGroupNode(signal: Signal<unknown>, descriptor: GroupDescriptor, owner: Owner): VobsNode {
|
|
205
|
+
const node = owner.run(() => createBlock(() => resolveChildren(descriptor.render(toReactiveItem(signal, descriptor.item), descriptor.index))))
|
|
206
|
+
if (!node) throw new Error('TransitionGroup: renderItem 必须返回节点')
|
|
207
|
+
return node
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface GroupDescriptor {
|
|
211
|
+
readonly key: unknown
|
|
212
|
+
readonly item: unknown
|
|
213
|
+
readonly index: number
|
|
214
|
+
readonly render: (item: unknown, index: number) => TransitionChildren
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readGroupDescriptors<Item>(props: TransitionGroupProps<Item>): GroupDescriptor[] {
|
|
218
|
+
const items = readProp<readonly Item[] | undefined>(props, 'items', undefined)
|
|
219
|
+
if (items !== undefined) {
|
|
220
|
+
const keyOf = readProp<TransitionGroupProps<Item>['keyOf']>(props, 'keyOf', undefined)
|
|
221
|
+
const renderItem = readProp<TransitionGroupProps<Item>['renderItem']>(props, 'renderItem', undefined)
|
|
222
|
+
if (typeof keyOf !== 'function' || typeof renderItem !== 'function') {
|
|
223
|
+
throw new Error('TransitionGroup: 提供 items 时必须同时提供 keyOf 和 renderItem')
|
|
224
|
+
}
|
|
225
|
+
return items.map((item, index) => ({
|
|
226
|
+
key: keyOf(item, index),
|
|
227
|
+
item,
|
|
228
|
+
index,
|
|
229
|
+
render: renderItem as (value: unknown, position: number) => TransitionChildren
|
|
230
|
+
}))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const children: unknown[] = []
|
|
234
|
+
flattenChildren(readProp(props, 'children', undefined), children)
|
|
235
|
+
return children.map((child, index) => ({
|
|
236
|
+
key: typeof child === 'object' && child !== null ? child : index,
|
|
237
|
+
item: child,
|
|
238
|
+
index,
|
|
239
|
+
render: value => value as TransitionChildren
|
|
240
|
+
}))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function flattenChildren(value: unknown, result: unknown[]): void {
|
|
244
|
+
const resolved = typeof value === 'function' ? value() : value
|
|
245
|
+
if (resolved === null || resolved === undefined || resolved === false) return
|
|
246
|
+
if (Array.isArray(resolved)) {
|
|
247
|
+
for (const child of resolved) flattenChildren(child, result)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
result.push(resolved)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function startEnter(entry: TransitionEntry, options: TransitionOptions, animate: boolean): void {
|
|
254
|
+
const element = asTransitionElement(entry.node)
|
|
255
|
+
if (entry.status === 'leaving') {
|
|
256
|
+
entry.run?.cancel()
|
|
257
|
+
invoke(options.onLeaveCancelled, element)
|
|
258
|
+
}
|
|
259
|
+
entry.status = 'entering'
|
|
260
|
+
if (!animate) {
|
|
261
|
+
entry.status = 'entered'
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
invoke(options.onBeforeEnter, element)
|
|
265
|
+
invoke(options.onEnter, element)
|
|
266
|
+
const run = runWithDriver(entry.node, 'enter', options, () => {
|
|
267
|
+
if (entry.status !== 'entering') return
|
|
268
|
+
entry.run = undefined
|
|
269
|
+
entry.status = 'entered'
|
|
270
|
+
invoke(options.onAfterEnter, element)
|
|
271
|
+
})
|
|
272
|
+
if (entry.status === 'entering') entry.run = run
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function startLeaveEntry(entry: TransitionEntry, options: TransitionOptions, done: () => void): void {
|
|
276
|
+
if (entry.status === 'leaving') return
|
|
277
|
+
if (entry.status === 'entering') {
|
|
278
|
+
entry.run?.cancel()
|
|
279
|
+
invoke(options.onEnterCancelled, asTransitionElement(entry.node))
|
|
280
|
+
} else {
|
|
281
|
+
entry.run?.cancel()
|
|
282
|
+
}
|
|
283
|
+
entry.status = 'leaving'
|
|
284
|
+
const element = asTransitionElement(entry.node)
|
|
285
|
+
invoke(options.onBeforeLeave, element)
|
|
286
|
+
invoke(options.onLeave, element)
|
|
287
|
+
let active = true
|
|
288
|
+
const run = runWithDriver(entry.node, 'leave', options, () => {
|
|
289
|
+
active = false
|
|
290
|
+
if (entry.status !== 'leaving') return
|
|
291
|
+
entry.run = undefined
|
|
292
|
+
invoke(options.onAfterLeave, element)
|
|
293
|
+
done()
|
|
294
|
+
})
|
|
295
|
+
if (active && entry.status === 'leaving') entry.run = run
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function runWithDriver(
|
|
299
|
+
node: VobsNode,
|
|
300
|
+
phase: 'enter' | 'leave',
|
|
301
|
+
options: TransitionOptions,
|
|
302
|
+
done: () => void
|
|
303
|
+
): { readonly cancel: () => void } {
|
|
304
|
+
const driver = readProp<TransitionDriver>(options, 'driver', cssTransitionDriver)
|
|
305
|
+
const transitionPhase = phase === 'enter' ? options.enter : options.leave
|
|
306
|
+
const duration = Math.max(0, transitionPhase?.duration ?? options.duration ?? 0)
|
|
307
|
+
return driver.run(node, phase, {
|
|
308
|
+
name: readProp<string>(options, 'name', 'v'),
|
|
309
|
+
phase: transitionPhase,
|
|
310
|
+
duration,
|
|
311
|
+
css: readProp<boolean>(options, 'css', true),
|
|
312
|
+
reducedMotion: resolveReducedMotion(options)
|
|
313
|
+
}, done)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function resolveChildren(value: TransitionChildren): VobsNode | null {
|
|
317
|
+
const resolved = typeof value === 'function' ? resolveChildren(value()) : value
|
|
318
|
+
if (resolved === null || resolved === undefined || resolved === false) return null
|
|
319
|
+
if (typeof resolved === 'string' || typeof resolved === 'number') return createText(String(resolved))
|
|
320
|
+
if (Array.isArray(resolved)) {
|
|
321
|
+
return createFragment((parent, anchor) => {
|
|
322
|
+
for (const child of resolved) {
|
|
323
|
+
const node = resolveChildren(child)
|
|
324
|
+
if (node) insertBefore(parent, node, anchor)
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
return resolved as VobsNode
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function readProp<T>(props: object, name: string, fallback: T): T {
|
|
332
|
+
const value = Reflect.get(props, name)
|
|
333
|
+
return (value === undefined ? fallback : value) as T
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function invoke(callback: ((element: Element) => void) | undefined, element: Element | null): void {
|
|
337
|
+
if (callback && element) callback(element)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function toReactiveItem<Item>(signal: Signal<Item>, initialValue: Item): Item {
|
|
341
|
+
if (typeof initialValue !== 'object' || initialValue === null) return initialValue
|
|
342
|
+
return new Proxy(initialValue as object, {
|
|
343
|
+
get(_target, property, receiver) {
|
|
344
|
+
return Reflect.get(signal.value as object, property, receiver)
|
|
345
|
+
},
|
|
346
|
+
has(_target, property) {
|
|
347
|
+
return property in (signal.value as object)
|
|
348
|
+
},
|
|
349
|
+
ownKeys() {
|
|
350
|
+
return Reflect.ownKeys(signal.value as object)
|
|
351
|
+
},
|
|
352
|
+
getOwnPropertyDescriptor(_target, property) {
|
|
353
|
+
return Object.getOwnPropertyDescriptor(signal.value as object, property)
|
|
354
|
+
}
|
|
355
|
+
}) as Item
|
|
356
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { VobsNode } from '@vobs/vobs'
|
|
2
|
+
|
|
3
|
+
export type TransitionStyle = Readonly<Record<string, string | number | null | undefined>>
|
|
4
|
+
|
|
5
|
+
export interface TransitionPhase {
|
|
6
|
+
readonly duration?: number
|
|
7
|
+
readonly easing?: string
|
|
8
|
+
readonly from?: TransitionStyle
|
|
9
|
+
readonly to?: TransitionStyle
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type TransitionPhaseName = 'enter' | 'leave'
|
|
13
|
+
|
|
14
|
+
export interface TransitionRun {
|
|
15
|
+
readonly cancel: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TransitionDriverOptions {
|
|
19
|
+
readonly name: string
|
|
20
|
+
readonly phase: TransitionPhase | undefined
|
|
21
|
+
readonly duration: number
|
|
22
|
+
readonly css: boolean
|
|
23
|
+
readonly reducedMotion: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TransitionDriver {
|
|
27
|
+
run(
|
|
28
|
+
node: VobsNode,
|
|
29
|
+
phase: TransitionPhaseName,
|
|
30
|
+
options: TransitionDriverOptions,
|
|
31
|
+
done: () => void
|
|
32
|
+
): TransitionRun
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TransitionCallbacks {
|
|
36
|
+
readonly onBeforeEnter?: (element: Element) => void
|
|
37
|
+
readonly onEnter?: (element: Element) => void
|
|
38
|
+
readonly onAfterEnter?: (element: Element) => void
|
|
39
|
+
readonly onEnterCancelled?: (element: Element) => void
|
|
40
|
+
readonly onBeforeLeave?: (element: Element) => void
|
|
41
|
+
readonly onLeave?: (element: Element) => void
|
|
42
|
+
readonly onAfterLeave?: (element: Element) => void
|
|
43
|
+
readonly onLeaveCancelled?: (element: Element) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TransitionOptions extends TransitionCallbacks {
|
|
47
|
+
/** CSS class prefix. `fade` produces `fade-enter-from`, `fade-leave-to`, etc. */
|
|
48
|
+
readonly name?: string
|
|
49
|
+
/** Applies CSS lifecycle classes. Defaults to true. */
|
|
50
|
+
readonly css?: boolean
|
|
51
|
+
/** Shared fallback duration in milliseconds when a phase has no duration. */
|
|
52
|
+
readonly duration?: number
|
|
53
|
+
readonly enter?: TransitionPhase
|
|
54
|
+
readonly leave?: TransitionPhase
|
|
55
|
+
readonly driver?: TransitionDriver
|
|
56
|
+
/** Respects `prefers-reduced-motion` by default in DOM environments. */
|
|
57
|
+
readonly reducedMotion?: boolean
|
|
58
|
+
/** Animates the initial visible branch when true. */
|
|
59
|
+
readonly appear?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type TransitionChildren =
|
|
63
|
+
| VobsNode
|
|
64
|
+
| string
|
|
65
|
+
| number
|
|
66
|
+
| null
|
|
67
|
+
| undefined
|
|
68
|
+
| false
|
|
69
|
+
| readonly TransitionChildren[]
|
|
70
|
+
| (() => TransitionChildren)
|
|
71
|
+
|
|
72
|
+
export interface TransitionProps extends TransitionOptions {
|
|
73
|
+
/** Omit `show` to render and enter once. */
|
|
74
|
+
readonly show?: boolean
|
|
75
|
+
readonly children?: TransitionChildren
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface TransitionGroupProps<Item = unknown> extends TransitionOptions {
|
|
79
|
+
/** Explicit keyed input is the stable API for reactive lists. */
|
|
80
|
+
readonly items?: readonly Item[]
|
|
81
|
+
readonly keyOf?: (item: Item, index: number) => unknown
|
|
82
|
+
readonly renderItem?: (item: Item, index: number) => TransitionChildren
|
|
83
|
+
/** Compatibility input for a stable children array; explicit keys are preferred. */
|
|
84
|
+
readonly children?: TransitionChildren
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type TransitionStatus = 'entering' | 'entered' | 'leaving'
|