immerser-react 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/dist/immerser-react.cjs +1 -0
- package/dist/immerser-react.d.ts +184 -0
- package/dist/immerser-react.js +293 -0
- package/package.json +73 -0
- package/src/ImmerserLayer.tsx +49 -0
- package/src/ImmerserPager.tsx +88 -0
- package/src/ImmerserProvider.tsx +167 -0
- package/src/ImmerserRoot.tsx +54 -0
- package/src/ImmerserSolid.tsx +41 -0
- package/src/ImmerserSynchroLink.tsx +57 -0
- package/src/context/immerser-config-context.ts +10 -0
- package/src/context/immerser-context.ts +10 -0
- package/src/context/immerser-synchro-context.ts +14 -0
- package/src/context/use-immerser-config-context.ts +16 -0
- package/src/context/use-immerser-context.ts +16 -0
- package/src/index.ts +6 -0
- package/src/types.ts +30 -0
- package/src/utils/assign-ref.ts +16 -0
- package/src/utils/render-solids-for-layer.tsx +24 -0
- package/src/utils/throw-outside-provider-error.ts +6 -0
- package/src/utils/use-immerser-registry.ts +58 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CroppedFullAbsoluteStyles, NotInteractiveStyles } from 'immerser';
|
|
2
|
+
import { useLayoutEffect, useRef, type HTMLAttributes } from 'react';
|
|
3
|
+
|
|
4
|
+
import type { DeniedStyleProp } from './types';
|
|
5
|
+
import { renderSolidsForLayer } from './utils/render-solids-for-layer';
|
|
6
|
+
import { useImmerserConfigContext } from './context/use-immerser-config-context';
|
|
7
|
+
|
|
8
|
+
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'style'> & DeniedStyleProp;
|
|
9
|
+
|
|
10
|
+
const maskStyle = {
|
|
11
|
+
...CroppedFullAbsoluteStyles,
|
|
12
|
+
} satisfies React.CSSProperties;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Renders the fixed root container and the per-layer mask structure driven by the core controller.
|
|
16
|
+
* Direct children must be `ImmerserSolid` or `ImmerserPager` so each layer can receive its own solid classnames.
|
|
17
|
+
* Fragments and wrapper components are not accepted as direct children.
|
|
18
|
+
* In React mode, the core measures layer masks and moves their transitions; React owns the mask markup itself.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export const ImmerserRoot = ({ children, style: _style, ...rest }: Props) => {
|
|
23
|
+
const { layerIds, registerMaskInner, setRendererRootNode, solidClassnamesByLayerId } =
|
|
24
|
+
useImmerserConfigContext('ImmerserRoot');
|
|
25
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
26
|
+
|
|
27
|
+
useLayoutEffect(() => {
|
|
28
|
+
setRendererRootNode(rootRef.current);
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
setRendererRootNode(null);
|
|
32
|
+
};
|
|
33
|
+
}, [setRendererRootNode]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div ref={rootRef} {...rest} data-immerser="" style={NotInteractiveStyles}>
|
|
37
|
+
{layerIds.map((layerId, layerIndex) => (
|
|
38
|
+
<div
|
|
39
|
+
key={layerId}
|
|
40
|
+
aria-hidden={layerIndex === 0 ? undefined : true}
|
|
41
|
+
data-immerser-layer-id={layerId}
|
|
42
|
+
data-immerser-mask=""
|
|
43
|
+
style={maskStyle}
|
|
44
|
+
>
|
|
45
|
+
<div ref={(node) => registerMaskInner(layerId, node)} data-immerser-mask-inner="" style={maskStyle}>
|
|
46
|
+
{renderSolidsForLayer(children, solidClassnamesByLayerId[layerId])}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
ImmerserRoot.displayName = 'ImmerserRoot';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
|
|
2
|
+
import { InteractiveStyles } from 'immerser';
|
|
3
|
+
import type { DeniedStyleProp } from './types';
|
|
4
|
+
import { useImmerserConfigContext } from './context/use-immerser-config-context';
|
|
5
|
+
|
|
6
|
+
type Props<T extends ElementType = 'div'> = {
|
|
7
|
+
/** Solid id used to read the matching classname from each layer configuration. */
|
|
8
|
+
name: string;
|
|
9
|
+
/** Element or component used to render the solid inside `ImmerserRoot`; defaults to `div`. */
|
|
10
|
+
as?: T;
|
|
11
|
+
/** Interactive content rendered inside every layer mask. Position it with your own CSS. */
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
} & Omit<ComponentPropsWithoutRef<T>, 'as' | 'children' | 'name' | 'style'> &
|
|
14
|
+
DeniedStyleProp;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Declares content positioned inside `ImmerserRoot`, usually absolutely positioned within that root.
|
|
18
|
+
* React renders a copy into each mask and applies layer-specific classnames by solid name.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
22
|
+
export const ImmerserSolid = <T extends ElementType = 'div'>({
|
|
23
|
+
as,
|
|
24
|
+
children,
|
|
25
|
+
className,
|
|
26
|
+
name,
|
|
27
|
+
style: _style,
|
|
28
|
+
...rest
|
|
29
|
+
}: Props<T>) => {
|
|
30
|
+
useImmerserConfigContext('ImmerserSolid');
|
|
31
|
+
|
|
32
|
+
const Component = as ?? 'div';
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Component {...rest} className={className} data-immerser-solid={name} style={InteractiveStyles}>
|
|
36
|
+
{children}
|
|
37
|
+
</Component>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
ImmerserSolid.displayName = 'ImmerserSolid';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { useContext, type ComponentPropsWithoutRef, type MouseEvent } from 'react';
|
|
3
|
+
|
|
4
|
+
import { ImmerserSynchroContext } from './context/immerser-synchro-context';
|
|
5
|
+
import type { DeniedStyleProp } from './types';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
/** Classname applied to generated copies of this link while any one of them is hovered. */
|
|
9
|
+
hoverClassName: string;
|
|
10
|
+
/** Stable hover group id for this source link. `ImmerserRoot` copies the link into every layer mask,
|
|
11
|
+
* so use the same `synchroId` for links that should share hover state,
|
|
12
|
+
* and different values for independent hover groups. */
|
|
13
|
+
synchroId: string;
|
|
14
|
+
} & Omit<ComponentPropsWithoutRef<'a'>, 'style'> &
|
|
15
|
+
DeniedStyleProp;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Anchor with synchronized hover state across layer clones.
|
|
19
|
+
* One source link is rendered into multiple layer-mask copies;
|
|
20
|
+
* `synchroId` keeps only those generated copies in the same hover group.
|
|
21
|
+
* This mirrors the core `data-immerser-synchro-hover` feature without relying on cloned DOM event wiring.
|
|
22
|
+
*
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
export const ImmerserSynchroLink = ({
|
|
26
|
+
className,
|
|
27
|
+
hoverClassName,
|
|
28
|
+
onMouseEnter,
|
|
29
|
+
onMouseLeave,
|
|
30
|
+
synchroId,
|
|
31
|
+
...rest
|
|
32
|
+
}: Props) => {
|
|
33
|
+
const { activeSynchroId, setActiveSynchroId } = useContext(ImmerserSynchroContext);
|
|
34
|
+
|
|
35
|
+
function handleMouseEnter(event: MouseEvent<HTMLAnchorElement>) {
|
|
36
|
+
setActiveSynchroId(synchroId);
|
|
37
|
+
onMouseEnter?.(event);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function handleMouseLeave(event: MouseEvent<HTMLAnchorElement>) {
|
|
41
|
+
setActiveSynchroId((currentSynchroId) => (currentSynchroId === synchroId ? null : currentSynchroId));
|
|
42
|
+
onMouseLeave?.(event);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<a
|
|
47
|
+
{...rest}
|
|
48
|
+
className={classNames(className, {
|
|
49
|
+
[hoverClassName]: activeSynchroId === synchroId,
|
|
50
|
+
})}
|
|
51
|
+
onMouseEnter={handleMouseEnter}
|
|
52
|
+
onMouseLeave={handleMouseLeave}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
ImmerserSynchroLink.displayName = 'ImmerserSynchroLink';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ImmerserConfigContextValue } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Static render configuration shared by the provider with layer, solid and pager components.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export const ImmerserConfigContext = createContext<ImmerserConfigContextValue | null>(null);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ImmerserContextValue } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Active layer index reported by the core controller, or null before a provider is mounted.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export const ImmerserContext = createContext<ImmerserContextValue | null>(null);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createContext, type Dispatch, type SetStateAction } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared hover group state used to keep duplicated interactive solids visually in sync.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export const ImmerserSynchroContext = createContext<{
|
|
9
|
+
activeSynchroId: string | null;
|
|
10
|
+
setActiveSynchroId: Dispatch<SetStateAction<string | null>>;
|
|
11
|
+
}>({
|
|
12
|
+
activeSynchroId: null,
|
|
13
|
+
setActiveSynchroId: () => {},
|
|
14
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
import { ImmerserConfigContext } from './immerser-config-context';
|
|
4
|
+
import type { ImmerserConfigContextValue } from '../types';
|
|
5
|
+
import { throwOutsideProviderError } from '../utils/throw-outside-provider-error';
|
|
6
|
+
|
|
7
|
+
/** @internal */
|
|
8
|
+
export const useImmerserConfigContext = (componentName: string) => {
|
|
9
|
+
const context = useContext(ImmerserConfigContext);
|
|
10
|
+
|
|
11
|
+
if (!context) {
|
|
12
|
+
throwOutsideProviderError(componentName);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return context as ImmerserConfigContextValue;
|
|
16
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
import { ImmerserContext } from './immerser-context';
|
|
4
|
+
import type { ImmerserContextValue } from '../types';
|
|
5
|
+
import { throwOutsideProviderError } from '../utils/throw-outside-provider-error';
|
|
6
|
+
|
|
7
|
+
/** @internal */
|
|
8
|
+
export const useImmerserContext = (componentName: string) => {
|
|
9
|
+
const context = useContext(ImmerserContext);
|
|
10
|
+
|
|
11
|
+
if (context === null) {
|
|
12
|
+
throwOutsideProviderError(componentName);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return context as ImmerserContextValue;
|
|
16
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { ImmerserProvider } from './ImmerserProvider';
|
|
2
|
+
export { ImmerserRoot } from './ImmerserRoot';
|
|
3
|
+
export { ImmerserLayer } from './ImmerserLayer';
|
|
4
|
+
export { ImmerserPager } from './ImmerserPager';
|
|
5
|
+
export { ImmerserSolid } from './ImmerserSolid';
|
|
6
|
+
export { ImmerserSynchroLink } from './ImmerserSynchroLink';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SolidClassnamesByLayerId } from 'immerser';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
export type DeniedStyleProp = {
|
|
5
|
+
/**
|
|
6
|
+
* Inline styles are not supported by Immerser React components.
|
|
7
|
+
* The adapter reserves the style attribute for technical Immerser styles.
|
|
8
|
+
*/
|
|
9
|
+
style?: never;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** @internal */
|
|
13
|
+
export type ImmerserContextValue = number;
|
|
14
|
+
|
|
15
|
+
/** @internal */
|
|
16
|
+
export type ImmerserConfigContextValue = {
|
|
17
|
+
layerIds: string[];
|
|
18
|
+
registerLayer: (id: string) => void;
|
|
19
|
+
unregisterLayer: (id: string) => void;
|
|
20
|
+
registerMaskInner: (id: string, node: HTMLElement | null) => void;
|
|
21
|
+
setRendererRootNode: (node: HTMLDivElement | null) => void;
|
|
22
|
+
solidClassnamesByLayerId: SolidClassnamesByLayerId;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** @internal */
|
|
26
|
+
export type SolidElementProps = {
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import ImmerserController from 'immerser';
|
|
2
|
+
import { ForwardedRef } from 'react';
|
|
3
|
+
|
|
4
|
+
/** @internal */
|
|
5
|
+
export const assignRef = (ref: ForwardedRef<ImmerserController | null>, value: ImmerserController | null) => {
|
|
6
|
+
if (!ref) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof ref === 'function') {
|
|
11
|
+
ref(value);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
ref.current = value;
|
|
16
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { Children, cloneElement, isValidElement, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { ImmerserPager } from '../ImmerserPager';
|
|
5
|
+
import { ImmerserSolid } from '../ImmerserSolid';
|
|
6
|
+
import type { SolidElementProps } from '../types';
|
|
7
|
+
|
|
8
|
+
/** @internal */
|
|
9
|
+
export const renderSolidsForLayer = (children: ReactNode, solidClassnames: Record<string, string> = {}): ReactNode =>
|
|
10
|
+
Children.map(children, (child) => {
|
|
11
|
+
if (child === null || child === undefined || child === false) {
|
|
12
|
+
return child;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!isValidElement<SolidElementProps>(child) || (child.type !== ImmerserSolid && child.type !== ImmerserPager)) {
|
|
16
|
+
throw new Error('ImmerserRoot accepts only ImmerserSolid or ImmerserPager as direct children.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const name = child.props.name ?? 'pager';
|
|
20
|
+
|
|
21
|
+
return cloneElement(child, {
|
|
22
|
+
className: classNames(child.props.className, solidClassnames[name]),
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tracks layer ids, mask inner nodes and readiness for the external renderer DOM.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export const useImmerserRegistry = () => {
|
|
9
|
+
const maskInnerNodesRef = useRef(new Map<string, HTMLElement>());
|
|
10
|
+
const [layerIds, setLayerIds] = useState<string[]>([]);
|
|
11
|
+
const [, setReadyVersion] = useState(0);
|
|
12
|
+
|
|
13
|
+
const isReady =
|
|
14
|
+
layerIds.length > 0 &&
|
|
15
|
+
maskInnerNodesRef.current.size === layerIds.length &&
|
|
16
|
+
layerIds.every((layerId) => maskInnerNodesRef.current.has(layerId));
|
|
17
|
+
|
|
18
|
+
const registerLayer = useCallback((id: string) => {
|
|
19
|
+
setLayerIds((currentLayerIds) => {
|
|
20
|
+
if (currentLayerIds.includes(id)) {
|
|
21
|
+
return currentLayerIds;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return [...currentLayerIds, id];
|
|
25
|
+
});
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
const unregisterLayer = useCallback((id: string) => {
|
|
29
|
+
setLayerIds((currentLayerIds) => currentLayerIds.filter((layerId) => layerId !== id));
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const registerMaskInner = useCallback((id: string, node: HTMLElement | null) => {
|
|
33
|
+
if (node) {
|
|
34
|
+
if (maskInnerNodesRef.current.get(id) === node) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
maskInnerNodesRef.current.set(id, node);
|
|
39
|
+
} else {
|
|
40
|
+
if (!maskInnerNodesRef.current.has(id)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
maskInnerNodesRef.current.delete(id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setReadyVersion((version) => version + 1);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
isReady,
|
|
52
|
+
layerIds,
|
|
53
|
+
maskInnerNodesRef,
|
|
54
|
+
registerLayer,
|
|
55
|
+
unregisterLayer,
|
|
56
|
+
registerMaskInner,
|
|
57
|
+
};
|
|
58
|
+
};
|