Skip to content

Commit 03dcc95

Browse files
committed
feat: add primitives module presence subpkg
1 parent 5eba970 commit 03dcc95

File tree

6 files changed

+276
-0
lines changed

6 files changed

+276
-0
lines changed

primitives/presence/package.json

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"name": "soybean-react-ui/presence",
3+
"version": "1.1.4",
4+
"license": "MIT",
5+
"homepage": "https://radix-ui.com/primitives",
6+
"repository": {
7+
"type": "git",
8+
"url": "git+https://github.com/radix-ui/primitives.git"
9+
},
10+
"bugs": {
11+
"url": "https://github.com/radix-ui/primitives/issues"
12+
},
13+
"publishConfig": {
14+
"main": "./dist/index.js",
15+
"module": "./dist/index.mjs",
16+
"types": "./dist/index.d.ts",
17+
"exports": {
18+
".": {
19+
"import": {
20+
"types": "./dist/index.d.mts",
21+
"default": "./dist/index.mjs"
22+
},
23+
"require": {
24+
"types": "./dist/index.d.ts",
25+
"default": "./dist/index.js"
26+
}
27+
}
28+
}
29+
},
30+
"sideEffects": false,
31+
"main": "./src/index.ts",
32+
"module": "./src/index.ts",
33+
"files": ["dist", "README.md"],
34+
"scripts": {
35+
"build": "radix-build",
36+
"clean": "rm -rf dist",
37+
"lint": "eslint --max-warnings 0 src",
38+
"typecheck": "tsc --noEmit"
39+
},
40+
"peerDependencies": {
41+
"@types/react": "*",
42+
"@types/react-dom": "*",
43+
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
44+
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
45+
},
46+
"peerDependenciesMeta": {
47+
"@types/react": {
48+
"optional": true
49+
},
50+
"@types/react-dom": {
51+
"optional": true
52+
}
53+
},
54+
"dependencies": {
55+
"soybean-react-ui/compose-refs": "workspace:*",
56+
"soybean-react-ui/slot": "workspace:*",
57+
"soybean-react-ui/use-layout-effect": "workspace:*"
58+
},
59+
"devDependencies": {
60+
"@types/react": "^19.0.7",
61+
"@types/react-dom": "^19.0.3",
62+
"eslint": "^9.18.0",
63+
"react": "^19.1.0",
64+
"react-dom": "^19.1.0",
65+
"typescript": "^5.7.3"
66+
},
67+
"source": "./src/index.ts"
68+
}

primitives/presence/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
'use client';
2+
3+
export { Presence, Root } from './presence';
4+
5+
export type { PresenceProps } from './presence';
6+
7+
export { usePresence } from './use-presence';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as React from 'react';
2+
import type { Ref } from 'react';
3+
import { useComposedRefs } from 'soybean-react-ui/compose-refs';
4+
import { getElementRef } from 'soybean-react-ui/slot';
5+
6+
import { usePresence } from './use-presence';
7+
8+
interface PresenceProps {
9+
children: React.ReactElement | ((props: { present: boolean }) => React.ReactElement);
10+
present: boolean;
11+
}
12+
13+
const Presence: React.FC<PresenceProps> = props => {
14+
const { children, present } = props;
15+
16+
const presence = usePresence(present);
17+
18+
const child = (
19+
typeof children === 'function' ? children({ present: presence.isPresent }) : React.Children.only(children)
20+
) as React.ReactElement<{ ref?: React.Ref<HTMLElement> }>;
21+
22+
const ref = useComposedRefs(presence.ref, getElementRef(child));
23+
24+
const forceMount = typeof children === 'function';
25+
26+
return forceMount || presence.isPresent ? React.cloneElement(child, { ref: ref as Ref<HTMLElement> }) : null;
27+
};
28+
29+
Presence.displayName = 'Presence';
30+
31+
const Root = Presence;
32+
33+
export {
34+
Presence,
35+
//
36+
Root
37+
};
38+
export type { PresenceProps };
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import * as React from 'react';
2+
import { useLayoutEffect } from 'soybean-react-ui/use-layout-effect';
3+
4+
import { useStateMachine } from './use-state-machine';
5+
6+
export function usePresence(present: boolean) {
7+
const [node, setNode] = React.useState<HTMLElement>();
8+
const stylesRef = React.useRef<CSSStyleDeclaration | null>(null);
9+
const prevPresentRef = React.useRef(present);
10+
const prevAnimationNameRef = React.useRef<string>('none');
11+
const initialState = present ? 'mounted' : 'unmounted';
12+
const [state, send] = useStateMachine(initialState, {
13+
mounted: {
14+
ANIMATION_OUT: 'unmountSuspended',
15+
UNMOUNT: 'unmounted'
16+
},
17+
unmounted: {
18+
MOUNT: 'mounted'
19+
},
20+
unmountSuspended: {
21+
ANIMATION_END: 'unmounted',
22+
MOUNT: 'mounted'
23+
}
24+
});
25+
26+
React.useEffect(() => {
27+
const currentAnimationName = getAnimationName(stylesRef.current);
28+
prevAnimationNameRef.current = state === 'mounted' ? currentAnimationName : 'none';
29+
}, [state]);
30+
31+
useLayoutEffect(() => {
32+
const styles = stylesRef.current;
33+
const wasPresent = prevPresentRef.current;
34+
const hasPresentChanged = wasPresent !== present;
35+
36+
if (hasPresentChanged) {
37+
const prevAnimationName = prevAnimationNameRef.current;
38+
const currentAnimationName = getAnimationName(styles);
39+
40+
if (present) {
41+
send('MOUNT');
42+
} else if (currentAnimationName === 'none' || styles?.display === 'none') {
43+
// If there is no exit animation or the element is hidden, animations won't run
44+
// so we unmount instantly
45+
send('UNMOUNT');
46+
} else {
47+
/**
48+
* When `present` changes to `false`, we check changes to animation-name to
49+
* determine whether an animation has started. We chose this approach (reading
50+
* computed styles) because there is no `animationrun` event and `animationstart`
51+
* fires after `animation-delay` has expired which would be too late.
52+
*/
53+
const isAnimating = prevAnimationName !== currentAnimationName;
54+
55+
if (wasPresent && isAnimating) {
56+
send('ANIMATION_OUT');
57+
} else {
58+
send('UNMOUNT');
59+
}
60+
}
61+
62+
prevPresentRef.current = present;
63+
}
64+
}, [present, send]);
65+
66+
useLayoutEffect(() => {
67+
if (node) {
68+
let timeoutId: number;
69+
const ownerWindow = node.ownerDocument.defaultView ?? window;
70+
/**
71+
* Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel`
72+
* event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we
73+
* make sure we only trigger ANIMATION_END for the currently active animation.
74+
*/
75+
const handleAnimationEnd = (event: AnimationEvent) => {
76+
const currentAnimationName = getAnimationName(stylesRef.current);
77+
const isCurrentAnimation = currentAnimationName.includes(event.animationName);
78+
if (event.target === node && isCurrentAnimation) {
79+
// With React 18 concurrency this update is applied a frame after the
80+
// animation ends, creating a flash of visible content. By setting the
81+
// animation fill mode to "forwards", we force the node to keep the
82+
// styles of the last keyframe, removing the flash.
83+
//
84+
// Previously we flushed the update via ReactDom.flushSync, but with
85+
// exit animations this resulted in the node being removed from the
86+
// DOM before the synthetic animationEnd event was dispatched, meaning
87+
// user-provided event handlers would not be called.
88+
// https://github.com/radix-ui/primitives/pull/1849
89+
send('ANIMATION_END');
90+
if (!prevPresentRef.current) {
91+
const currentFillMode = node.style.animationFillMode;
92+
node.style.animationFillMode = 'forwards';
93+
// Reset the style after the node had time to unmount (for cases
94+
// where the component chooses not to unmount). Doing this any
95+
// sooner than `setTimeout` (e.g. with `requestAnimationFrame`)
96+
// still causes a flash.
97+
timeoutId = ownerWindow.setTimeout(() => {
98+
if (node.style.animationFillMode === 'forwards') {
99+
node.style.animationFillMode = currentFillMode;
100+
}
101+
});
102+
}
103+
}
104+
};
105+
const handleAnimationStart = (event: AnimationEvent) => {
106+
if (event.target === node) {
107+
// if animation occurred, store its name as the previous animation.
108+
prevAnimationNameRef.current = getAnimationName(stylesRef.current);
109+
}
110+
};
111+
node.addEventListener('animationstart', handleAnimationStart);
112+
node.addEventListener('animationcancel', handleAnimationEnd);
113+
node.addEventListener('animationend', handleAnimationEnd);
114+
return () => {
115+
ownerWindow.clearTimeout(timeoutId);
116+
node.removeEventListener('animationstart', handleAnimationStart);
117+
node.removeEventListener('animationcancel', handleAnimationEnd);
118+
node.removeEventListener('animationend', handleAnimationEnd);
119+
};
120+
}
121+
// Transition to the unmounted state if the node is removed prematurely.
122+
// We avoid doing so during cleanup as the node may change but still exist.
123+
send('ANIMATION_END');
124+
return () => {};
125+
}, [node, send]);
126+
127+
return {
128+
isPresent: ['mounted', 'unmountSuspended'].includes(state),
129+
ref: React.useCallback((newNode: HTMLElement) => {
130+
stylesRef.current = newNode ? getComputedStyle(newNode) : null;
131+
setNode(newNode);
132+
}, [])
133+
};
134+
}
135+
136+
/* ----------------------------------------------------------------------------------------------- */
137+
138+
function getAnimationName(styles: CSSStyleDeclaration | null) {
139+
return styles?.animationName || 'none';
140+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as React from 'react';
2+
3+
type Machine<S> = { [k: string]: { [k: string]: S } };
4+
type MachineState<T> = keyof T;
5+
type MachineEvent<T> = keyof UnionToIntersection<T[keyof T]>;
6+
7+
// 🤯 https://fettblog.eu/typescript-union-to-intersection/
8+
type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never;
9+
10+
export function useStateMachine<M>(initialState: MachineState<M>, machine: M & Machine<MachineState<M>>) {
11+
return React.useReducer((state: MachineState<M>, event: MachineEvent<M>): MachineState<M> => {
12+
const nextState = (machine[state] as any)[event];
13+
return nextState ?? state;
14+
}, initialState);
15+
}

primitives/presence/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"outDir": "dist"
5+
},
6+
"include": ["src"],
7+
"exclude": ["node_modules", "dist"]
8+
}

0 commit comments

Comments
 (0)