@octanejs/transition-group 0.0.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.
- package/LICENSE +30 -0
- package/README.md +24 -0
- package/UPSTREAM.md +52 -0
- package/package.json +83 -0
- package/src/CSSTransition.tsrx +112 -0
- package/src/ReplaceTransition.tsrx +44 -0
- package/src/SwitchTransition.tsrx +105 -0
- package/src/Transition.tsrx +267 -0
- package/src/TransitionGroup.tsrx +70 -0
- package/src/TransitionGroupContext.ts +9 -0
- package/src/config.ts +3 -0
- package/src/index.ts +19 -0
- package/src/types.ts +77 -0
- package/src/utils/ChildMapping.ts +112 -0
- package/src/utils/reflow.ts +3 -0
- package/status.json +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2018, React Community
|
|
4
|
+
Forked from React (https://github.com/facebook/react) Copyright 2013-present, Facebook, Inc.
|
|
5
|
+
All rights reserved.
|
|
6
|
+
|
|
7
|
+
Redistribution and use in source and binary forms, with or without
|
|
8
|
+
modification, are permitted provided that the following conditions are met:
|
|
9
|
+
|
|
10
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
11
|
+
list of conditions and the following disclaimer.
|
|
12
|
+
|
|
13
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
and/or other materials provided with the distribution.
|
|
16
|
+
|
|
17
|
+
* Neither the name of the copyright holder nor the names of its
|
|
18
|
+
contributors may be used to endorse or promote products derived from
|
|
19
|
+
this software without specific prior written permission.
|
|
20
|
+
|
|
21
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
22
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
23
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
24
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
25
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
26
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
27
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
28
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
29
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
30
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @octanejs/transition-group
|
|
2
|
+
|
|
3
|
+
An Octane binding for [`react-transition-group@4.4.5`](https://github.com/reactjs/react-transition-group/tree/v4.4.5).
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
The package preserves React Transition Group's six public modules with native Octane components. Timed lifecycle transitions, CSS class phases, keyed group exits, and cloned keyed transitions have executable coverage.
|
|
8
|
+
|
|
9
|
+
The target public surface is:
|
|
10
|
+
|
|
11
|
+
- `Transition`
|
|
12
|
+
- `CSSTransition`
|
|
13
|
+
- `TransitionGroup`
|
|
14
|
+
- `SwitchTransition`
|
|
15
|
+
- `ReplaceTransition`
|
|
16
|
+
- `config`
|
|
17
|
+
|
|
18
|
+
Root imports and the matching documented per-component imports will be supported. See [UPSTREAM.md](./UPSTREAM.md) for provenance, current gaps, and intentional Octane constraints.
|
|
19
|
+
|
|
20
|
+
Use `nodeRef` for DOM-aware transitions. Octane intentionally has no `findDOMNode` equivalent. Pass dynamic `TransitionGroup` collections as an inspectable value, such as `children={items.map(...)}`.
|
|
21
|
+
|
|
22
|
+
## License
|
|
23
|
+
|
|
24
|
+
BSD-3-Clause. The pinned upstream notice is retained in [LICENSE](./LICENSE).
|
package/UPSTREAM.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Upstream provenance
|
|
2
|
+
|
|
3
|
+
This binding is pinned to [`react-transition-group` v4.4.5](https://github.com/reactjs/react-transition-group/tree/v4.4.5), commit `4cb51a9be0ebf508cb8f6506452097f7ebb874fe`. The pristine runtime oracle uses React 18.3.1 (upstream's supported peer range and the last React that still exposes `findDOMNode`); Octane package tests continue to use the workspace React 19 oracle.
|
|
4
|
+
|
|
5
|
+
The upstream project and this adapted package are licensed under BSD-3-Clause. The upstream notice is retained in `LICENSE` and `upstream/LICENSE`.
|
|
6
|
+
|
|
7
|
+
## Vendored evidence
|
|
8
|
+
|
|
9
|
+
`upstream/src` and `upstream/test` are byte-exact copies of the source and tests at the pinned tag. `audit/SHA256SUMS` records every retained artifact and `pnpm upstream:check` rejects drift. `pnpm upstream:verify` also checks `audit/upstream-test-dispositions.json`, `audit/case-crosswalk.json`, `audit/adapted-evidence.SHA256SUMS`, and `audit/adapted-case-contracts.json` so every upstream test artifact, case identity, adapted mapping, assertion body, and upstream probe fixture stays accounted for. File digests alone are not sufficient: the case-contract ledger pins per-case adapted/upstream assertion hashes and fixture exports against each cited upstream scenario, and negative controls refresh `adapted-evidence.SHA256SUMS` after mutation to prove the semantic contract—not a stale digest—rejects deleted assertions or weakened fixtures.
|
|
10
|
+
|
|
11
|
+
The vendored JavaScript is not published. The maintained implementation is under `src`. Adapted Octane tests are under `tests`. The strongest runtime oracle is the pristine Jest lane: `pnpm test:upstream` runs the pinned suite unchanged against React through the manifest `jest-full` executor and checks every identity against `audit/pristine-runtime.json`.
|
|
12
|
+
|
|
13
|
+
## DefinitelyTyped type-suite provenance
|
|
14
|
+
|
|
15
|
+
The React type oracle is [`@types/react-transition-group` 4.4.12](https://www.npmjs.com/package/@types/react-transition-group/v/4.4.12). Its canonical source is DefinitelyTyped repository path `types/react-transition-group`, pinned at commit `cccd2a9ffecb708ac0606faa7f81f0b7ec535bf9`; `typesPublisherContentHash` is `28d8cc9bfe6b15e0291a07b916505b49e5cf57cfd1f331db1d9ad49fd36bfd41`. The npm tarball omits its executable type test, so `upstream-types/react-transition-group-tests.tsx` and `upstream-types/tsconfig.definitelytyped.json` are byte-exact vendored copies from that commit. The pristine runner `upstream-types/tsconfig.json` retains DT's compiler options while resolving declarations from npm rather than vendored `.d.ts` files.
|
|
16
|
+
|
|
17
|
+
`pnpm test:type-parity` runs the pristine suite with `tsc`, then its one-for-one Octane adaptation in `typetests/react-transition-group-tests.tsx` with `tsrx-tsc`. `audit/type-parity.json` pins the permitted transformations and inventories both suites; its negative controls reject a skipped file, removed JSX assertion group, removed `@ts-expect-error`, or retargeted public import.
|
|
18
|
+
|
|
19
|
+
## Test-suite disposition
|
|
20
|
+
|
|
21
|
+
The pinned repository contains seven runtime suites plus four support files under `upstream/test`. Every artifact has a disposition in `audit/upstream-test-dispositions.json` (56 executable cases total). `audit/case-crosswalk.json` maps each upstream case to an adapted identity with a `// Per path:` citation, or records both findDOMNode cases as not-applicable. `audit/adapted-evidence.SHA256SUMS` pins adapted suite bodies and `tests/_fixtures/upstream-probes.tsrx`. `audit/adapted-case-contracts.json` records per-case assertion/fixture contracts for those mappings. Negative controls in `scripts/react-parity/react-transition-group-upstream-lib.test.mjs` reject a missing artifact disposition, a stale `caseCount`, a deleted suite, a removed case, adapted case drift, a missing citation, deleted assertions (after digest refresh), and fixture drift (after digest refresh). Adapted one-for-one coverage lives under `tests/upstream/` with `// Per path:` citations. Port-authored tests are classified in `audit/test-classifications.json`; type lanes follow the DefinitelyTyped suite under `audit/type-parity.json`.
|
|
22
|
+
|
|
23
|
+
| Upstream artifact | Disposition |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `test/Transition-test.js` | Pristine oracle + adapted in `tests/upstream/Transition.test.ts` (both findDOMNode cases are not applicable) |
|
|
26
|
+
| `test/CSSTransition-test.js` | Pristine oracle + adapted in `tests/upstream/CSSTransition.test.ts` |
|
|
27
|
+
| `test/CSSTransitionGroup-test.js` | Pristine oracle + adapted in `tests/upstream/TransitionGroup.test.ts` |
|
|
28
|
+
| `test/TransitionGroup-test.js` | Pristine oracle + adapted in `tests/upstream/TransitionGroup.test.ts` (StrictMode double-appear is divergence `react-transition-group-no-strict-double-appear`) |
|
|
29
|
+
| `test/SwitchTransition-test.js` | Pristine oracle + adapted in `tests/upstream/SwitchTransition.test.ts` |
|
|
30
|
+
| `test/ChildMapping-test.js` | Pristine oracle + adapted in `tests/upstream/ChildMapping.test.ts` |
|
|
31
|
+
| `test/SSR-test.js` | Pristine oracle + adapted in `tests/ssr/upstream-import.test.ts` |
|
|
32
|
+
| `test/setup.js`, `setupAfterEnv.js`, `utils.js`, `.eslintrc.yml` | Support artifacts for the pristine Jest runner |
|
|
33
|
+
|
|
34
|
+
## Public surface
|
|
35
|
+
|
|
36
|
+
The published React package exposes six root modules and the Octane package preserves each mapping:
|
|
37
|
+
|
|
38
|
+
| React import | Octane import |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `react-transition-group` | `@octanejs/transition-group` |
|
|
41
|
+
| `react-transition-group/Transition` | `@octanejs/transition-group/Transition` |
|
|
42
|
+
| `react-transition-group/CSSTransition` | `@octanejs/transition-group/CSSTransition` |
|
|
43
|
+
| `react-transition-group/TransitionGroup` | `@octanejs/transition-group/TransitionGroup` |
|
|
44
|
+
| `react-transition-group/SwitchTransition` | `@octanejs/transition-group/SwitchTransition` |
|
|
45
|
+
| `react-transition-group/ReplaceTransition` | `@octanejs/transition-group/ReplaceTransition` |
|
|
46
|
+
| `react-transition-group/config` | `@octanejs/transition-group/config` |
|
|
47
|
+
|
|
48
|
+
## Adaptation notes
|
|
49
|
+
|
|
50
|
+
Octane has no `ReactDOM.findDOMNode` equivalent. DOM-aware callbacks and `CSSTransition` therefore require `nodeRef`; callers that omit it still receive lifecycle timing and state transitions, but no inferred DOM node. This follows React Transition Group's recommended `nodeRef` path and avoids a legacy API that React Strict Mode deprecates.
|
|
51
|
+
|
|
52
|
+
Compiler-generated Octane children blocks are distinguished from genuine render props with `isChildrenBlock`. Introspective collection components should pass descriptor collections through the `children` prop (for example, `children={items.map(...)}`), so keys remain inspectable by `TransitionGroup`.
|
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/transition-group",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "BSD-3-Clause",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.22.2"
|
|
9
|
+
},
|
|
10
|
+
"description": "React Transition Group-compatible transition components for Octane.",
|
|
11
|
+
"author": {
|
|
12
|
+
"name": "Dominic Gannaway",
|
|
13
|
+
"email": "dg@domgan.com"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
21
|
+
"directory": "packages/transition-group"
|
|
22
|
+
},
|
|
23
|
+
"main": "src/index.ts",
|
|
24
|
+
"module": "src/index.ts",
|
|
25
|
+
"types": "src/index.ts",
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md",
|
|
30
|
+
"UPSTREAM.md",
|
|
31
|
+
"status.json"
|
|
32
|
+
],
|
|
33
|
+
"exports": {
|
|
34
|
+
".": "./src/index.ts",
|
|
35
|
+
"./Transition": "./src/Transition.tsrx",
|
|
36
|
+
"./CSSTransition": "./src/CSSTransition.tsrx",
|
|
37
|
+
"./TransitionGroup": "./src/TransitionGroup.tsrx",
|
|
38
|
+
"./SwitchTransition": "./src/SwitchTransition.tsrx",
|
|
39
|
+
"./ReplaceTransition": "./src/ReplaceTransition.tsrx",
|
|
40
|
+
"./config": "./src/config.ts"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"octane": "0.1.35"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@babel/core": "^7.28.0",
|
|
47
|
+
"@babel/preset-env": "^7.28.0",
|
|
48
|
+
"@babel/preset-react": "^7.27.1",
|
|
49
|
+
"@testing-library/react": "^14.3.1",
|
|
50
|
+
"@tsrx/react": "^0.2.56",
|
|
51
|
+
"@types/react": "^19.2.17",
|
|
52
|
+
"@types/react-dom": "^19.2.3",
|
|
53
|
+
"babel-jest": "30.4.1",
|
|
54
|
+
"babel-plugin-add-module-exports": "^1.0.4",
|
|
55
|
+
"dom-helpers": "^5.2.1",
|
|
56
|
+
"jest": "30.4.2",
|
|
57
|
+
"jest-environment-jsdom": "30.4.1",
|
|
58
|
+
"jest-environment-node": "30.4.1",
|
|
59
|
+
"prop-types": "^15.8.1",
|
|
60
|
+
"react": "^19.2.7",
|
|
61
|
+
"react-dom": "^19.2.7",
|
|
62
|
+
"react18": "npm:react@18.3.1",
|
|
63
|
+
"react-dom18": "npm:react-dom@18.3.1",
|
|
64
|
+
"react-transition-group": "4.4.5",
|
|
65
|
+
"typescript": "^5.9.3",
|
|
66
|
+
"vitest": "^4.1.10",
|
|
67
|
+
"@types/react-transition-group": "^4.4.12",
|
|
68
|
+
"@octanejs/testing-library": "0.1.32",
|
|
69
|
+
"octane": "0.1.35"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"test": "pnpm upstream:check && pnpm upstream:verify && pnpm test:upstream && vitest run --root ../.. --project transition-group --project transition-group-ssr",
|
|
73
|
+
"parity:inventory": "node ../../scripts/react-parity/react-transition-group-runtime-inventory.mjs",
|
|
74
|
+
"test:upstream": "jest --config tests/upstream-jest.config.cjs --rootDir upstream --runInBand --no-watchman",
|
|
75
|
+
"upstream:check": "node audit/check-upstream.mjs",
|
|
76
|
+
"upstream:verify": "node ../../scripts/react-parity/react-transition-group-upstream.mjs",
|
|
77
|
+
"parity:pristine-inventory": "node ../../scripts/react-parity/react-transition-group-pristine-runtime.mjs --write",
|
|
78
|
+
"typecheck": "tsrx-tsc --noEmit -p tsconfig.json && tsrx-tsc --noEmit -p typetests/tsconfig.json",
|
|
79
|
+
"test:type-parity": "tsc --noEmit -p upstream-types/tsconfig.json && tsrx-tsc --noEmit -p typetests/tsconfig.json",
|
|
80
|
+
"parity:types": "node ../../scripts/react-parity/react-transition-group-types.mjs",
|
|
81
|
+
"parity:types:write": "node ../../scripts/react-parity/react-transition-group-types.mjs --write"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useRef } from 'octane';
|
|
2
|
+
import Transition from './Transition.tsrx';
|
|
3
|
+
import type { CSSClassNames, CSSTransitionProps } from './types.ts';
|
|
4
|
+
import { forceReflow } from './utils/reflow.ts';
|
|
5
|
+
|
|
6
|
+
type TransitionType = 'appear' | 'enter' | 'exit';
|
|
7
|
+
type Phase = 'base' | 'active' | 'done';
|
|
8
|
+
type AppliedClasses = Record<TransitionType, Partial<Record<Phase, string>>>;
|
|
9
|
+
|
|
10
|
+
function classesFor(classNames: CSSClassNames | undefined, type: TransitionType) {
|
|
11
|
+
if (typeof classNames === 'string' || classNames === undefined) {
|
|
12
|
+
const prefix = classNames ? `${classNames}-` : '';
|
|
13
|
+
const base = `${prefix}${type}`;
|
|
14
|
+
return { base, active: `${base}-active`, done: `${base}-done` };
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
base: classNames[type],
|
|
18
|
+
active: classNames[`${type}Active`],
|
|
19
|
+
done: classNames[`${type}Done`],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function addClasses(node: Element | null | undefined, value: string | undefined) {
|
|
24
|
+
if (!node || !value) return;
|
|
25
|
+
for (const className of value.split(' ')) if (className) node.classList.add(className);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function removeClasses(node: Element | null | undefined, value: string | undefined) {
|
|
29
|
+
if (!node || !value) return;
|
|
30
|
+
for (const className of value.split(' ')) if (className) node.classList.remove(className);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function CSSTransition(props: CSSTransitionProps) @{
|
|
34
|
+
const applied = useRef<AppliedClasses>({ appear: {}, enter: {}, exit: {} });
|
|
35
|
+
const node = () => props.nodeRef?.current;
|
|
36
|
+
const resolveEnterArguments = (maybeNode?: Element | boolean, maybeAppearing?: boolean) =>
|
|
37
|
+
props.nodeRef
|
|
38
|
+
? { node: props.nodeRef.current, appearing: maybeNode === true }
|
|
39
|
+
: { node: maybeNode as Element | undefined, appearing: maybeAppearing === true };
|
|
40
|
+
|
|
41
|
+
const clear = (type: TransitionType) => {
|
|
42
|
+
const current = applied.current[type];
|
|
43
|
+
removeClasses(node(), current.base);
|
|
44
|
+
removeClasses(node(), current.active);
|
|
45
|
+
removeClasses(node(), current.done);
|
|
46
|
+
applied.current[type] = {};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const add = (type: TransitionType, phase: Phase) => {
|
|
50
|
+
let className = classesFor(props.classNames, type)[phase];
|
|
51
|
+
if (type === 'appear' && phase === 'done') {
|
|
52
|
+
const enterDone = classesFor(props.classNames, 'enter').done;
|
|
53
|
+
if (enterDone) className = className ? `${className} ${enterDone}` : enterDone;
|
|
54
|
+
}
|
|
55
|
+
if (phase === 'active' && node()) forceReflow(node()!);
|
|
56
|
+
if (className) {
|
|
57
|
+
applied.current[type][phase] = className;
|
|
58
|
+
addClasses(node(), className);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const {
|
|
63
|
+
children,
|
|
64
|
+
classNames: _classNames,
|
|
65
|
+
onEnter,
|
|
66
|
+
onEntering,
|
|
67
|
+
onEntered,
|
|
68
|
+
onExit,
|
|
69
|
+
onExiting,
|
|
70
|
+
onExited,
|
|
71
|
+
...transitionProps
|
|
72
|
+
} = props;
|
|
73
|
+
|
|
74
|
+
<Transition
|
|
75
|
+
{...transitionProps}
|
|
76
|
+
onEnter={(maybeNode, maybeAppearing) => {
|
|
77
|
+
const { appearing } = resolveEnterArguments(maybeNode, maybeAppearing);
|
|
78
|
+
clear('exit');
|
|
79
|
+
add(appearing ? 'appear' : 'enter', 'base');
|
|
80
|
+
onEnter?.(maybeNode, maybeAppearing);
|
|
81
|
+
}}
|
|
82
|
+
onEntering={(maybeNode, maybeAppearing) => {
|
|
83
|
+
const { appearing } = resolveEnterArguments(maybeNode, maybeAppearing);
|
|
84
|
+
add(appearing ? 'appear' : 'enter', 'active');
|
|
85
|
+
onEntering?.(maybeNode, maybeAppearing);
|
|
86
|
+
}}
|
|
87
|
+
onEntered={(maybeNode, maybeAppearing) => {
|
|
88
|
+
const { appearing } = resolveEnterArguments(maybeNode, maybeAppearing);
|
|
89
|
+
const type = appearing ? 'appear' : 'enter';
|
|
90
|
+
clear(type);
|
|
91
|
+
add(type, 'done');
|
|
92
|
+
onEntered?.(maybeNode, maybeAppearing);
|
|
93
|
+
}}
|
|
94
|
+
onExit={(maybeNode) => {
|
|
95
|
+
clear('appear');
|
|
96
|
+
clear('enter');
|
|
97
|
+
add('exit', 'base');
|
|
98
|
+
onExit?.(maybeNode);
|
|
99
|
+
}}
|
|
100
|
+
onExiting={(maybeNode) => {
|
|
101
|
+
add('exit', 'active');
|
|
102
|
+
onExiting?.(maybeNode);
|
|
103
|
+
}}
|
|
104
|
+
onExited={(maybeNode) => {
|
|
105
|
+
clear('exit');
|
|
106
|
+
add('exit', 'done');
|
|
107
|
+
onExited?.(maybeNode);
|
|
108
|
+
}}
|
|
109
|
+
>{children}</Transition>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default CSSTransition;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Children, cloneElement, type ElementDescriptor } from 'octane';
|
|
2
|
+
import TransitionGroup from './TransitionGroup.tsrx';
|
|
3
|
+
import type { ReplaceTransitionProps, TransitionCallbacks } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export function ReplaceTransition(props: ReplaceTransitionProps) @{
|
|
6
|
+
const [first, second] = Children.toArray(props.children) as ElementDescriptor[];
|
|
7
|
+
if (!first || !second) throw new Error('"children" must be exactly two transition components.');
|
|
8
|
+
|
|
9
|
+
const lifecycle = (child: ElementDescriptor, name: keyof TransitionCallbacks) => (
|
|
10
|
+
...args: unknown[]
|
|
11
|
+
) => {
|
|
12
|
+
(child.props[name] as ((...values: unknown[]) => void) | undefined)?.(...args);
|
|
13
|
+
(props[name] as ((...values: unknown[]) => void) | undefined)?.(undefined);
|
|
14
|
+
};
|
|
15
|
+
const {
|
|
16
|
+
children: _children,
|
|
17
|
+
in: inProp,
|
|
18
|
+
onEnter: _onEnter,
|
|
19
|
+
onEntering: _onEntering,
|
|
20
|
+
onEntered: _onEntered,
|
|
21
|
+
onExit: _onExit,
|
|
22
|
+
onExiting: _onExiting,
|
|
23
|
+
onExited: _onExited,
|
|
24
|
+
...groupProps
|
|
25
|
+
} = props;
|
|
26
|
+
const selected =
|
|
27
|
+
inProp
|
|
28
|
+
? cloneElement(first, {
|
|
29
|
+
key: 'first',
|
|
30
|
+
onEnter: lifecycle(first, 'onEnter'),
|
|
31
|
+
onEntering: lifecycle(first, 'onEntering'),
|
|
32
|
+
onEntered: lifecycle(first, 'onEntered'),
|
|
33
|
+
})
|
|
34
|
+
: cloneElement(second, {
|
|
35
|
+
key: 'second',
|
|
36
|
+
onEnter: lifecycle(second, 'onExit'),
|
|
37
|
+
onEntering: lifecycle(second, 'onExiting'),
|
|
38
|
+
onEntered: lifecycle(second, 'onExited'),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
<TransitionGroup {...groupProps} children={[selected]} />
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default ReplaceTransition;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloneElement,
|
|
3
|
+
isValidElement,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ElementDescriptor,
|
|
8
|
+
} from 'octane';
|
|
9
|
+
import { ENTERED, ENTERING, EXITING } from './Transition.tsrx';
|
|
10
|
+
import TransitionGroupContext from './TransitionGroupContext.ts';
|
|
11
|
+
import type { SwitchTransitionProps, TransitionCallbacks, TransitionStatus } from './types.ts';
|
|
12
|
+
|
|
13
|
+
export const modes = { out: 'out-in', in: 'in-out' } as const;
|
|
14
|
+
|
|
15
|
+
function different(a: ElementDescriptor | null, b: ElementDescriptor | null | undefined) {
|
|
16
|
+
if (a === b) return false;
|
|
17
|
+
return !(isValidElement(a) && isValidElement(b) && a.key != null && a.key === b.key);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function chain(element: ElementDescriptor, name: keyof TransitionCallbacks, callback: () => void) {
|
|
21
|
+
return (...args: unknown[]) => {
|
|
22
|
+
(element.props[name] as ((...values: unknown[]) => void) | undefined)?.(...args);
|
|
23
|
+
callback();
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function SwitchTransition(props: SwitchTransitionProps) @{
|
|
28
|
+
const appeared = useRef(false);
|
|
29
|
+
const status = useRef<TransitionStatus>(ENTERED);
|
|
30
|
+
const current = useRef<ElementDescriptor | null>(
|
|
31
|
+
props.children ? cloneElement(props.children, { in: true }) : null,
|
|
32
|
+
);
|
|
33
|
+
const [, rerender] = useState(0);
|
|
34
|
+
const pending = props.children ?? null;
|
|
35
|
+
const mode = props.mode ?? modes.out;
|
|
36
|
+
|
|
37
|
+
useLayoutEffect(() => {
|
|
38
|
+
appeared.current = true;
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
if (!pending) {
|
|
42
|
+
current.current = null;
|
|
43
|
+
status.current = ENTERED;
|
|
44
|
+
} else if (status.current === ENTERING && mode === modes.in) {
|
|
45
|
+
// Keep the in-out pair stable until the entering child finishes.
|
|
46
|
+
} else if (current.current && different(current.current, pending)) {
|
|
47
|
+
status.current = EXITING;
|
|
48
|
+
} else {
|
|
49
|
+
current.current = cloneElement(pending, { in: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let output: unknown = current.current;
|
|
53
|
+
if (status.current === EXITING && current.current && pending) {
|
|
54
|
+
if (mode === modes.out) {
|
|
55
|
+
output = cloneElement(current.current, {
|
|
56
|
+
in: false,
|
|
57
|
+
onExited: chain(current.current, 'onExited', () => {
|
|
58
|
+
current.current = null;
|
|
59
|
+
status.current = ENTERING;
|
|
60
|
+
rerender((value) => value + 1);
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
output = [
|
|
65
|
+
current.current,
|
|
66
|
+
cloneElement(pending, {
|
|
67
|
+
in: true,
|
|
68
|
+
onEntered: chain(pending, 'onEntered', () => {
|
|
69
|
+
status.current = ENTERING;
|
|
70
|
+
rerender((value) => value + 1);
|
|
71
|
+
}),
|
|
72
|
+
}),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
} else if (status.current === ENTERING && pending) {
|
|
76
|
+
if (mode === modes.out) {
|
|
77
|
+
output = cloneElement(pending, {
|
|
78
|
+
in: true,
|
|
79
|
+
onEntered: chain(pending, 'onEntered', () => {
|
|
80
|
+
current.current = cloneElement(pending, { in: true });
|
|
81
|
+
status.current = ENTERED;
|
|
82
|
+
rerender((value) => value + 1);
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
} else if (current.current) {
|
|
86
|
+
output = [
|
|
87
|
+
cloneElement(current.current, {
|
|
88
|
+
in: false,
|
|
89
|
+
onExited: chain(current.current, 'onExited', () => {
|
|
90
|
+
current.current = cloneElement(pending, { in: true });
|
|
91
|
+
status.current = ENTERED;
|
|
92
|
+
rerender((value) => value + 1);
|
|
93
|
+
}),
|
|
94
|
+
}),
|
|
95
|
+
cloneElement(pending, { in: true }),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
<TransitionGroupContext.Provider
|
|
101
|
+
value={{ isMounting: !appeared.current }}
|
|
102
|
+
>{output}</TransitionGroupContext.Provider>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default SwitchTransition;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
flushSync,
|
|
5
|
+
isChildrenBlock,
|
|
6
|
+
useContext,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type OctaneNode,
|
|
11
|
+
} from 'octane';
|
|
12
|
+
import config from './config.ts';
|
|
13
|
+
import TransitionGroupContext from './TransitionGroupContext.ts';
|
|
14
|
+
import type {
|
|
15
|
+
TransitionComponent,
|
|
16
|
+
TransitionProps,
|
|
17
|
+
TransitionStatus,
|
|
18
|
+
TransitionTimeout,
|
|
19
|
+
} from './types.ts';
|
|
20
|
+
import { forceReflow } from './utils/reflow.ts';
|
|
21
|
+
|
|
22
|
+
export const UNMOUNTED = 'unmounted';
|
|
23
|
+
export const EXITED = 'exited';
|
|
24
|
+
export const ENTERING = 'entering';
|
|
25
|
+
export const ENTERED = 'entered';
|
|
26
|
+
export const EXITING = 'exiting';
|
|
27
|
+
|
|
28
|
+
function getTimeouts(timeout: TransitionTimeout | undefined) {
|
|
29
|
+
if (typeof timeout === 'number' || timeout === undefined) {
|
|
30
|
+
return { appear: timeout, enter: timeout, exit: timeout };
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
appear: timeout.appear === undefined ? timeout.enter : timeout.appear,
|
|
34
|
+
enter: timeout.enter,
|
|
35
|
+
exit: timeout.exit,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function callEnterCallback(
|
|
40
|
+
callback: TransitionProps['onEnter'],
|
|
41
|
+
nodeRef: TransitionProps['nodeRef'],
|
|
42
|
+
node: Element | undefined,
|
|
43
|
+
appearing: boolean,
|
|
44
|
+
) {
|
|
45
|
+
if (nodeRef) (callback as ((isAppearing: boolean) => void) | undefined)?.(appearing);
|
|
46
|
+
else callback?.(node, appearing);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function TransitionImpl(props: TransitionProps) @{
|
|
50
|
+
const group = useContext(TransitionGroupContext);
|
|
51
|
+
const latestProps = useRef(props);
|
|
52
|
+
latestProps.current = props;
|
|
53
|
+
const initial = useRef<
|
|
54
|
+
{ status: TransitionStatus; appearStatus: TransitionStatus | null } | null
|
|
55
|
+
>(null);
|
|
56
|
+
if (initial.current === null) {
|
|
57
|
+
const appearing =
|
|
58
|
+
group && !group.isMounting ? props.enter !== false : props.appear === true;
|
|
59
|
+
if (props.in) {
|
|
60
|
+
initial.current = appearing
|
|
61
|
+
? { status: EXITED, appearStatus: ENTERING }
|
|
62
|
+
: { status: ENTERED, appearStatus: null };
|
|
63
|
+
} else {
|
|
64
|
+
initial.current = {
|
|
65
|
+
status: props.unmountOnExit || props.mountOnEnter ? UNMOUNTED : EXITED,
|
|
66
|
+
appearStatus: null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const [status, setStatus] = useState<TransitionStatus>(initial.current.status);
|
|
72
|
+
const mounted = useRef(false);
|
|
73
|
+
const generation = useRef(0);
|
|
74
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
75
|
+
const previousIn = useRef(props.in === true);
|
|
76
|
+
// Upstream calls onEntering/onExiting/onEntered/onExited from setState
|
|
77
|
+
// callbacks so the committed status is observable. Schedule those after the
|
|
78
|
+
// matching status commit instead of firing them synchronously with setState.
|
|
79
|
+
const pendingPhase = useRef<{ status: TransitionStatus; run: () => void } | null>(null);
|
|
80
|
+
|
|
81
|
+
const cancelPending = () => {
|
|
82
|
+
generation.current += 1;
|
|
83
|
+
pendingPhase.current = null;
|
|
84
|
+
if (timer.current !== null) {
|
|
85
|
+
clearTimeout(timer.current);
|
|
86
|
+
timer.current = null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const finishAfter = (timeout: number | undefined, done: () => void) => {
|
|
91
|
+
const run = generation.current;
|
|
92
|
+
let active = true;
|
|
93
|
+
const finish = () => {
|
|
94
|
+
if (!active || run !== generation.current || !mounted.current) return;
|
|
95
|
+
active = false;
|
|
96
|
+
timer.current = null;
|
|
97
|
+
done();
|
|
98
|
+
};
|
|
99
|
+
const live = latestProps.current;
|
|
100
|
+
const node = live.nodeRef?.current ?? undefined;
|
|
101
|
+
if (live.nodeRef && !node) {
|
|
102
|
+
timer.current = setTimeout(finish, 0);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (live.addEndListener) {
|
|
106
|
+
if (live.nodeRef) (live.addEndListener as (done: () => void) => void)(finish);
|
|
107
|
+
else if (node) (live.addEndListener as (node: Element, done: () => void) => void)(
|
|
108
|
+
node,
|
|
109
|
+
finish,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (active && timeout !== undefined) timer.current = setTimeout(finish, timeout);
|
|
113
|
+
else if (active && (!live.addEndListener || !node)) timer.current = setTimeout(finish, 0);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const commitStatus = (next: TransitionStatus, run: () => void) => {
|
|
117
|
+
pendingPhase.current = { status: next, run };
|
|
118
|
+
setStatus(next);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Final ENTERED/EXITED completions must run before the current timeout
|
|
122
|
+
// callback yields — otherwise fake-timer suites can fire a slower
|
|
123
|
+
// addEndListener in the same runAllTimers pass before onEntered.
|
|
124
|
+
const commitStatusNow = (next: TransitionStatus, run: () => void) => {
|
|
125
|
+
pendingPhase.current = { status: next, run };
|
|
126
|
+
flushSync(function applyStatus() {
|
|
127
|
+
setStatus(next);
|
|
128
|
+
});
|
|
129
|
+
if (pendingPhase.current && pendingPhase.current.status === next) {
|
|
130
|
+
const pending = pendingPhase.current;
|
|
131
|
+
pendingPhase.current = null;
|
|
132
|
+
pending.run();
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const performEnter = (mounting: boolean) => {
|
|
137
|
+
cancelPending();
|
|
138
|
+
const appearing = group ? group.isMounting : mounting;
|
|
139
|
+
const node = props.nodeRef?.current ?? undefined;
|
|
140
|
+
if (!mounting && props.enter === false || config.disabled) {
|
|
141
|
+
commitStatus(ENTERED, function afterEntered() {
|
|
142
|
+
const live = latestProps.current;
|
|
143
|
+
callEnterCallback(live.onEntered, live.nodeRef, node, appearing);
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
callEnterCallback(props.onEnter, props.nodeRef, node, appearing);
|
|
148
|
+
const timeouts = getTimeouts(props.timeout);
|
|
149
|
+
commitStatus(ENTERING, function afterEntering() {
|
|
150
|
+
const live = latestProps.current;
|
|
151
|
+
callEnterCallback(live.onEntering, live.nodeRef, node, appearing);
|
|
152
|
+
finishAfter(appearing ? timeouts.appear : timeouts.enter, function enterDone() {
|
|
153
|
+
commitStatusNow(ENTERED, function afterEntered() {
|
|
154
|
+
const live = latestProps.current;
|
|
155
|
+
callEnterCallback(live.onEntered, live.nodeRef, node, appearing);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const performExit = () => {
|
|
162
|
+
cancelPending();
|
|
163
|
+
const node = props.nodeRef?.current ?? undefined;
|
|
164
|
+
if (props.exit === false || config.disabled) {
|
|
165
|
+
commitStatus(EXITED, function afterExited() {
|
|
166
|
+
const live = latestProps.current;
|
|
167
|
+
live.onExited?.(live.nodeRef ? undefined : node);
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
props.onExit?.(props.nodeRef ? undefined : node);
|
|
172
|
+
commitStatus(EXITING, function afterExiting() {
|
|
173
|
+
const live = latestProps.current;
|
|
174
|
+
live.onExiting?.(live.nodeRef ? undefined : node);
|
|
175
|
+
finishAfter(getTimeouts(live.timeout).exit, function exitDone() {
|
|
176
|
+
commitStatusNow(EXITED, function afterExited() {
|
|
177
|
+
const live = latestProps.current;
|
|
178
|
+
live.onExited?.(live.nodeRef ? undefined : node);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
useLayoutEffect(() => {
|
|
185
|
+
mounted.current = true;
|
|
186
|
+
if (initial.current!.appearStatus === ENTERING) {
|
|
187
|
+
initial.current!.appearStatus = null;
|
|
188
|
+
performEnter(true);
|
|
189
|
+
}
|
|
190
|
+
return () => {
|
|
191
|
+
mounted.current = false;
|
|
192
|
+
cancelPending();
|
|
193
|
+
};
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
useLayoutEffect(() => {
|
|
197
|
+
const nextIn = props.in === true;
|
|
198
|
+
if (nextIn !== previousIn.current) {
|
|
199
|
+
if (nextIn) {
|
|
200
|
+
if (status === UNMOUNTED) {
|
|
201
|
+
setStatus(EXITED);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
previousIn.current = true;
|
|
205
|
+
if (props.unmountOnExit || props.mountOnEnter) {
|
|
206
|
+
const node = props.nodeRef?.current;
|
|
207
|
+
if (node) forceReflow(node);
|
|
208
|
+
}
|
|
209
|
+
performEnter(false);
|
|
210
|
+
} else if (status === ENTERING || status === ENTERED) {
|
|
211
|
+
previousIn.current = false;
|
|
212
|
+
performExit();
|
|
213
|
+
} else {
|
|
214
|
+
previousIn.current = false;
|
|
215
|
+
}
|
|
216
|
+
} else if (!nextIn && props.unmountOnExit && status === EXITED) {
|
|
217
|
+
setStatus(UNMOUNTED);
|
|
218
|
+
}
|
|
219
|
+
}, null);
|
|
220
|
+
|
|
221
|
+
useLayoutEffect(() => {
|
|
222
|
+
const pending = pendingPhase.current;
|
|
223
|
+
if (pending && pending.status === status) {
|
|
224
|
+
pendingPhase.current = null;
|
|
225
|
+
pending.run();
|
|
226
|
+
}
|
|
227
|
+
}, [status]);
|
|
228
|
+
|
|
229
|
+
let rendered: OctaneNode = null;
|
|
230
|
+
if (status !== UNMOUNTED) {
|
|
231
|
+
const {
|
|
232
|
+
children,
|
|
233
|
+
in: _in,
|
|
234
|
+
mountOnEnter: _mountOnEnter,
|
|
235
|
+
unmountOnExit: _unmountOnExit,
|
|
236
|
+
appear: _appear,
|
|
237
|
+
enter: _enter,
|
|
238
|
+
exit: _exit,
|
|
239
|
+
timeout: _timeout,
|
|
240
|
+
addEndListener: _addEndListener,
|
|
241
|
+
onEnter: _onEnter,
|
|
242
|
+
onEntering: _onEntering,
|
|
243
|
+
onEntered: _onEntered,
|
|
244
|
+
onExit: _onExit,
|
|
245
|
+
onExiting: _onExiting,
|
|
246
|
+
onExited: _onExited,
|
|
247
|
+
nodeRef: _nodeRef,
|
|
248
|
+
...childProps
|
|
249
|
+
} = props;
|
|
250
|
+
rendered = typeof children === 'function' && !isChildrenBlock(children)
|
|
251
|
+
? children(status, childProps)
|
|
252
|
+
: isChildrenBlock(children)
|
|
253
|
+
? children
|
|
254
|
+
: cloneElement(Children.only(children), childProps);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
<TransitionGroupContext.Provider value={null}>{rendered}</TransitionGroupContext.Provider>
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export const Transition = TransitionImpl as unknown as TransitionComponent;
|
|
261
|
+
Transition.UNMOUNTED = UNMOUNTED;
|
|
262
|
+
Transition.EXITED = EXITED;
|
|
263
|
+
Transition.ENTERING = ENTERING;
|
|
264
|
+
Transition.ENTERED = ENTERED;
|
|
265
|
+
Transition.EXITING = EXITING;
|
|
266
|
+
|
|
267
|
+
export default Transition;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createElement, useLayoutEffect, useRef, useState, type ElementDescriptor } from 'octane';
|
|
2
|
+
import TransitionGroupContext from './TransitionGroupContext.ts';
|
|
3
|
+
import type { TransitionGroupProps } from './types.ts';
|
|
4
|
+
import {
|
|
5
|
+
getChildMapping,
|
|
6
|
+
getInitialChildMapping,
|
|
7
|
+
getNextChildMapping,
|
|
8
|
+
type ChildMapping,
|
|
9
|
+
} from './utils/ChildMapping.ts';
|
|
10
|
+
|
|
11
|
+
export function TransitionGroup(props: TransitionGroupProps) @{
|
|
12
|
+
const mounted = useRef(false);
|
|
13
|
+
const first = useRef(true);
|
|
14
|
+
const mapping = useRef<ChildMapping>({});
|
|
15
|
+
const children = useRef(props.children);
|
|
16
|
+
const [isMounting, setIsMounting] = useState(true);
|
|
17
|
+
const [, rerender] = useState(0);
|
|
18
|
+
|
|
19
|
+
// Match upstream TransitionGroup.handleExited: read live children, forward
|
|
20
|
+
// onExited, then delete only if the key was not synchronously re-added.
|
|
21
|
+
// A sync re-add re-renders and rebuilds mapping; deleting afterward would
|
|
22
|
+
// drop that survivor and force a second enter on the next pass.
|
|
23
|
+
const handleExited = function handleExited(child: ElementDescriptor, node?: Element) {
|
|
24
|
+
if (String(child.key) in getChildMapping(children.current)) return;
|
|
25
|
+
(child.props.onExited as ((node?: Element) => void) | undefined)?.(node);
|
|
26
|
+
if (String(child.key) in getChildMapping(children.current)) return;
|
|
27
|
+
if (mounted.current) {
|
|
28
|
+
delete mapping.current[String(child.key)];
|
|
29
|
+
rerender(function bump(value: number) {
|
|
30
|
+
return value + 1;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (first.current) {
|
|
36
|
+
mapping.current = getInitialChildMapping(props, handleExited);
|
|
37
|
+
children.current = props.children;
|
|
38
|
+
first.current = false;
|
|
39
|
+
} else {
|
|
40
|
+
mapping.current = getNextChildMapping(props, mapping.current, handleExited);
|
|
41
|
+
children.current = props.children;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
mounted.current = true;
|
|
46
|
+
setIsMounting(false);
|
|
47
|
+
return () => {
|
|
48
|
+
mounted.current = false;
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
children: _children,
|
|
54
|
+
component = 'div',
|
|
55
|
+
childFactory = (child: ElementDescriptor) => child,
|
|
56
|
+
appear: _appear,
|
|
57
|
+
enter: _enter,
|
|
58
|
+
exit: _exit,
|
|
59
|
+
...wrapperProps
|
|
60
|
+
} = props;
|
|
61
|
+
const renderedChildren = Object.values(mapping.current).map(childFactory);
|
|
62
|
+
const output =
|
|
63
|
+
component === null
|
|
64
|
+
? renderedChildren
|
|
65
|
+
: createElement(component, wrapperProps, ...renderedChildren);
|
|
66
|
+
|
|
67
|
+
<TransitionGroupContext.Provider value={{ isMounting }}>{output}</TransitionGroupContext.Provider>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default TransitionGroup;
|
package/src/config.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { default as CSSTransition } from './CSSTransition.tsrx';
|
|
2
|
+
export { default as ReplaceTransition } from './ReplaceTransition.tsrx';
|
|
3
|
+
export { default as SwitchTransition } from './SwitchTransition.tsrx';
|
|
4
|
+
export { default as TransitionGroup } from './TransitionGroup.tsrx';
|
|
5
|
+
export { default as Transition } from './Transition.tsrx';
|
|
6
|
+
export { default as config } from './config.ts';
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
CSSClassNames,
|
|
10
|
+
CSSTransitionProps,
|
|
11
|
+
ReplaceTransitionProps,
|
|
12
|
+
SwitchTransitionProps,
|
|
13
|
+
TransitionCallbacks,
|
|
14
|
+
TransitionGroupProps,
|
|
15
|
+
TransitionNodeRef,
|
|
16
|
+
TransitionProps,
|
|
17
|
+
TransitionStatus,
|
|
18
|
+
TransitionTimeout,
|
|
19
|
+
} from './types.ts';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ComponentBody, ElementDescriptor, OctaneNode } from 'octane';
|
|
2
|
+
|
|
3
|
+
export type TransitionStatus = 'unmounted' | 'exited' | 'entering' | 'entered' | 'exiting';
|
|
4
|
+
export type TransitionTimeout = number | { appear?: number; enter?: number; exit?: number };
|
|
5
|
+
export type TransitionNodeRef = { current: HTMLElement | null };
|
|
6
|
+
|
|
7
|
+
export interface TransitionCallbacks {
|
|
8
|
+
onEnter?: (...args: any[]) => void;
|
|
9
|
+
onEntering?: (...args: any[]) => void;
|
|
10
|
+
onEntered?: (...args: any[]) => void;
|
|
11
|
+
onExit?: (...args: any[]) => void;
|
|
12
|
+
onExiting?: (...args: any[]) => void;
|
|
13
|
+
onExited?: (...args: any[]) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TransitionProps extends TransitionCallbacks {
|
|
17
|
+
children?:
|
|
18
|
+
| ElementDescriptor
|
|
19
|
+
| ((status: TransitionStatus, childProps?: Record<string, unknown>) => OctaneNode);
|
|
20
|
+
in?: boolean;
|
|
21
|
+
mountOnEnter?: boolean;
|
|
22
|
+
unmountOnExit?: boolean;
|
|
23
|
+
appear?: boolean;
|
|
24
|
+
enter?: boolean;
|
|
25
|
+
exit?: boolean;
|
|
26
|
+
timeout?: TransitionTimeout;
|
|
27
|
+
addEndListener?: (...args: any[]) => void;
|
|
28
|
+
nodeRef?: TransitionNodeRef;
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type TransitionComponent = ComponentBody<TransitionProps> & {
|
|
33
|
+
UNMOUNTED: 'unmounted';
|
|
34
|
+
EXITED: 'exited';
|
|
35
|
+
ENTERING: 'entering';
|
|
36
|
+
ENTERED: 'entered';
|
|
37
|
+
EXITING: 'exiting';
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type CSSClassNames =
|
|
41
|
+
| string
|
|
42
|
+
| {
|
|
43
|
+
appear?: string;
|
|
44
|
+
appearActive?: string;
|
|
45
|
+
appearDone?: string;
|
|
46
|
+
enter?: string;
|
|
47
|
+
enterActive?: string;
|
|
48
|
+
enterDone?: string;
|
|
49
|
+
exit?: string;
|
|
50
|
+
exitActive?: string;
|
|
51
|
+
exitDone?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export interface CSSTransitionProps extends TransitionProps {
|
|
55
|
+
classNames?: CSSClassNames;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TransitionGroupProps {
|
|
59
|
+
children?: OctaneNode;
|
|
60
|
+
component?: ComponentBody<any> | ((props: any) => unknown) | string | null;
|
|
61
|
+
childFactory?: (child: ElementDescriptor) => ElementDescriptor;
|
|
62
|
+
appear?: boolean;
|
|
63
|
+
enter?: boolean;
|
|
64
|
+
exit?: boolean;
|
|
65
|
+
[key: string]: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SwitchTransitionProps {
|
|
69
|
+
children?: ElementDescriptor | null;
|
|
70
|
+
mode?: 'out-in' | 'in-out';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ReplaceTransitionProps extends TransitionCallbacks {
|
|
74
|
+
children: OctaneNode;
|
|
75
|
+
in: boolean;
|
|
76
|
+
[key: string]: unknown;
|
|
77
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type ElementDescriptor,
|
|
6
|
+
type OctaneNode,
|
|
7
|
+
} from 'octane';
|
|
8
|
+
import type { TransitionGroupProps } from '../types.ts';
|
|
9
|
+
|
|
10
|
+
export type ChildMapping = Record<string, ElementDescriptor>;
|
|
11
|
+
|
|
12
|
+
export function getChildMapping(
|
|
13
|
+
children: OctaneNode,
|
|
14
|
+
mapFn?: (child: ElementDescriptor) => ElementDescriptor,
|
|
15
|
+
): ChildMapping {
|
|
16
|
+
const result = Object.create(null) as ChildMapping;
|
|
17
|
+
for (const child of Children.toArray(children)) {
|
|
18
|
+
if (!isValidElement(child)) continue;
|
|
19
|
+
const mapped = mapFn ? mapFn(child) : child;
|
|
20
|
+
result[String(mapped.key)] = mapped;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function mergeChildMappings(prev: ChildMapping = {}, next: ChildMapping = {}): ChildMapping {
|
|
26
|
+
const getValue = (key: string) => (key in next ? next[key] : prev[key]);
|
|
27
|
+
const pendingBefore: Record<string, string[]> = Object.create(null);
|
|
28
|
+
let pending: string[] = [];
|
|
29
|
+
for (const key of Object.keys(prev)) {
|
|
30
|
+
if (key in next) {
|
|
31
|
+
if (pending.length) {
|
|
32
|
+
pendingBefore[key] = pending;
|
|
33
|
+
pending = [];
|
|
34
|
+
}
|
|
35
|
+
} else pending.push(key);
|
|
36
|
+
}
|
|
37
|
+
const merged: ChildMapping = {};
|
|
38
|
+
for (const key of Object.keys(next)) {
|
|
39
|
+
for (const waiting of pendingBefore[key] ?? []) merged[waiting] = getValue(waiting);
|
|
40
|
+
merged[key] = getValue(key);
|
|
41
|
+
}
|
|
42
|
+
for (const key of pending) merged[key] = getValue(key);
|
|
43
|
+
return merged;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function inherited(
|
|
47
|
+
child: ElementDescriptor,
|
|
48
|
+
name: 'appear' | 'enter' | 'exit',
|
|
49
|
+
props: TransitionGroupProps,
|
|
50
|
+
) {
|
|
51
|
+
return props[name] == null ? child.props[name] : props[name];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getInitialChildMapping(
|
|
55
|
+
props: TransitionGroupProps,
|
|
56
|
+
onExited: (child: ElementDescriptor, node?: Element) => void,
|
|
57
|
+
) {
|
|
58
|
+
return getChildMapping(props.children, (child) =>
|
|
59
|
+
cloneElement(child, {
|
|
60
|
+
appear: inherited(child, 'appear', props),
|
|
61
|
+
enter: inherited(child, 'enter', props),
|
|
62
|
+
exit: inherited(child, 'exit', props),
|
|
63
|
+
in: true,
|
|
64
|
+
onExited: (node?: Element) => onExited(child, node),
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getNextChildMapping(
|
|
70
|
+
nextProps: TransitionGroupProps,
|
|
71
|
+
previous: ChildMapping,
|
|
72
|
+
onExited: (child: ElementDescriptor, node?: Element) => void,
|
|
73
|
+
) {
|
|
74
|
+
const next = getChildMapping(nextProps.children);
|
|
75
|
+
const children = mergeChildMappings(previous, next);
|
|
76
|
+
for (const key of Object.keys(children)) {
|
|
77
|
+
const child = children[key];
|
|
78
|
+
const hasPrevious = key in previous;
|
|
79
|
+
const hasNext = key in next;
|
|
80
|
+
const previousChild = previous[key];
|
|
81
|
+
const isLeaving = isValidElement(previousChild) && previousChild.props.in === false;
|
|
82
|
+
if (hasNext && (!hasPrevious || isLeaving)) {
|
|
83
|
+
children[key] = cloneElement(child, {
|
|
84
|
+
enter: inherited(child, 'enter', nextProps),
|
|
85
|
+
exit: inherited(child, 'exit', nextProps),
|
|
86
|
+
in: true,
|
|
87
|
+
onExited: (node?: Element) => onExited(child, node),
|
|
88
|
+
});
|
|
89
|
+
} else if (!hasNext && hasPrevious && !isLeaving) {
|
|
90
|
+
// Apply the group's current exit/enter flags when a child begins
|
|
91
|
+
// leaving so switching TransitionGroup.exit from false → true keeps
|
|
92
|
+
// the outgoing node for the exit transition (upstream only sets
|
|
93
|
+
// `in: false`, which leaves a stale exit=false from the prior enter).
|
|
94
|
+
// Do not rebind onExited: the enter-time handleExited wrapper must
|
|
95
|
+
// remain so TransitionGroup.handleExited can forward to the user's
|
|
96
|
+
// original callback instead of calling itself again.
|
|
97
|
+
children[key] = cloneElement(child, {
|
|
98
|
+
in: false,
|
|
99
|
+
enter: inherited(child, 'enter', nextProps),
|
|
100
|
+
exit: inherited(child, 'exit', nextProps),
|
|
101
|
+
});
|
|
102
|
+
} else if (hasNext && hasPrevious && isValidElement(previousChild)) {
|
|
103
|
+
children[key] = cloneElement(child, {
|
|
104
|
+
enter: inherited(child, 'enter', nextProps),
|
|
105
|
+
exit: inherited(child, 'exit', nextProps),
|
|
106
|
+
in: previousChild.props.in,
|
|
107
|
+
onExited: (node?: Element) => onExited(child, node),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return children;
|
|
112
|
+
}
|
package/status.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"upstream": {
|
|
3
|
+
"package": "react-transition-group",
|
|
4
|
+
"version": "4.4.5",
|
|
5
|
+
"commit": "4cb51a9be0ebf508cb8f6506452097f7ebb874fe"
|
|
6
|
+
},
|
|
7
|
+
"surface": "Transition, CSSTransition, TransitionGroup, SwitchTransition, ReplaceTransition, config, and their documented subpath exports.",
|
|
8
|
+
"divergences": [
|
|
9
|
+
"Octane does not implement ReactDOM.findDOMNode; DOM-aware callbacks and CSSTransition require nodeRef.",
|
|
10
|
+
"TransitionGroup collections must be supplied as inspectable descriptor values, normally with children={items.map(...)}; compiler-generated opaque children blocks cannot be enumerated.",
|
|
11
|
+
"The complete pinned upstream suite runs unchanged as the pristine Jest oracle; adapted Octane lanes cover the public export surface, DOM transition behavior, switch and replacement sequencing, keyed groups, nodeRef semantics, mount-on-enter sequencing, and server rendering, and are not yet a one-for-one port of every upstream case."
|
|
12
|
+
],
|
|
13
|
+
"ssr": "Transition state and wrapper markup render on the server; DOM class mutation begins only after client mount and requires nodeRef.",
|
|
14
|
+
"verified": "2026-08-09"
|
|
15
|
+
}
|