@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 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'