@mlola-ui/scene 1.0.8 → 1.0.9

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.
@@ -0,0 +1,3 @@
1
+ export * from "./primitives.tsx";
2
+ export * from "./spatial-toggle.tsx";
3
+ export * from "./stage.tsx";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./primitives.js";
2
+ export * from "./spatial-toggle.js";
3
+ export * from "./stage.js";
@@ -0,0 +1,19 @@
1
+ import * as React from "react";
2
+ export interface MeshGradientProps extends React.HTMLAttributes<HTMLDivElement> {
3
+ animated?: boolean;
4
+ blur?: number;
5
+ }
6
+ export declare const MeshGradient: React.ForwardRefExoticComponent<MeshGradientProps & React.RefAttributes<HTMLDivElement>>;
7
+ export interface CardStack3DItem {
8
+ title: string;
9
+ subtitle: string;
10
+ tag: string;
11
+ }
12
+ export interface CardStack3DProps extends React.HTMLAttributes<HTMLDivElement> {
13
+ cards: CardStack3DItem[];
14
+ }
15
+ export declare function CardStack3D({ cards, className, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: CardStack3DProps): React.JSX.Element;
16
+ export interface DeviceFrame3DProps extends React.HTMLAttributes<HTMLDivElement> {
17
+ tilt?: boolean;
18
+ }
19
+ export declare function DeviceFrame3D({ children, className, tilt, style, ...props }: DeviceFrame3DProps): React.JSX.Element;
@@ -0,0 +1,71 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { useReducedMotion } from "./use-reduced-motion.js";
5
+ export const MeshGradient = React.forwardRef(({ animated = true, blur = 50, style, ...props }, ref) => {
6
+ const reducedMotion = useReducedMotion();
7
+ const shouldAnimate = animated && !reducedMotion;
8
+ return (_jsx("div", { ...props, ref: ref, "aria-hidden": "true", style: {
9
+ position: "absolute",
10
+ inset: 0,
11
+ pointerEvents: "none",
12
+ overflow: "hidden",
13
+ backgroundImage: `
14
+ radial-gradient(circle at 18% 25%, color-mix(in srgb, var(--ml-primary) 22%, transparent), transparent 55%),
15
+ radial-gradient(circle at 82% 70%, color-mix(in srgb, var(--ml-primary, var(--primary)) 14%, transparent), transparent 60%),
16
+ radial-gradient(circle at 50% 90%, color-mix(in srgb, var(--ml-accent, var(--accent)) 30%, transparent), transparent 70%)
17
+ `,
18
+ filter: `blur(${Math.max(0, Math.min(160, Number.isFinite(blur) ? blur : 50))}px)`,
19
+ animation: shouldAnimate ? "float-3d 14s ease-in-out infinite" : undefined,
20
+ ...style,
21
+ } }));
22
+ });
23
+ MeshGradient.displayName = "MeshGradient";
24
+ function joinClasses(...values) {
25
+ return values.filter(Boolean).join(" ");
26
+ }
27
+ export function CardStack3D({ cards, className = "", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }) {
28
+ const [active, setActive] = React.useState(false);
29
+ const reducedMotion = useReducedMotion();
30
+ return (_jsx("div", { ...props, tabIndex: props.tabIndex ?? 0, "aria-label": props["aria-label"] ?? "Card stack", onPointerEnter: (event) => {
31
+ onPointerEnter?.(event);
32
+ if (event.pointerType !== "touch")
33
+ setActive(true);
34
+ }, onPointerLeave: (event) => {
35
+ onPointerLeave?.(event);
36
+ setActive(false);
37
+ }, onFocus: (event) => {
38
+ onFocus?.(event);
39
+ setActive(true);
40
+ }, onBlur: (event) => {
41
+ onBlur?.(event);
42
+ if (!event.currentTarget.contains(event.relatedTarget))
43
+ setActive(false);
44
+ }, className: joinClasses("ml-scene-card-stack", className), children: cards.slice(0, 3).map((card, index) => {
45
+ const layer = 2 - index;
46
+ const fanDown = active ? layer * 18 : layer * 6;
47
+ const fanScale = active ? 1 - layer * 0.03 : 1 - layer * 0.04;
48
+ const rotateZ = active ? (index - 1) * 2.5 : 0;
49
+ return (_jsxs("div", { className: "ml-scene-card-stack-item", style: {
50
+ transform: `translate3d(0, ${fanDown}px, ${index * 12}px) rotateZ(${rotateZ}deg) scale(${fanScale})`,
51
+ transformOrigin: "top center",
52
+ zIndex: index + 10,
53
+ boxShadow: active
54
+ ? "0 18px 38px -10px color-mix(in srgb, var(--foreground) 14%, transparent), 0 0 0 1px color-mix(in srgb, var(--border) 90%, transparent)"
55
+ : "0 8px 20px -6px color-mix(in srgb, var(--foreground) 8%, transparent), 0 0 0 1px color-mix(in srgb, var(--border) 70%, transparent)",
56
+ transition: reducedMotion
57
+ ? "none"
58
+ : "transform 300ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1)",
59
+ }, children: [_jsxs("div", { className: "ml-scene-card-stack-header", children: [_jsx("span", { className: "ml-scene-card-stack-tag", children: card.tag }), _jsxs("span", { className: "ml-scene-card-stack-index", children: ["0", index + 1] })] }), _jsxs("div", { children: [_jsx("p", { className: "ml-scene-card-stack-title", children: card.title }), _jsx("p", { className: "ml-scene-card-stack-subtitle", children: card.subtitle })] })] }, `${card.title}-${index}`));
60
+ }) }));
61
+ }
62
+ export function DeviceFrame3D({ children, className = "", tilt = true, style, ...props }) {
63
+ const reducedMotion = useReducedMotion();
64
+ return (_jsx("div", { ...props, className: joinClasses("ml-scene-device-frame", className), style: {
65
+ background: "linear-gradient(145deg, oklch(35% 0.02 260), oklch(18% 0.015 260))",
66
+ boxShadow: "0 30px 70px -15px oklch(0% 0 0 / 0.5), inset 0 1px 2px oklch(100% 0 0 / 0.2)",
67
+ transform: tilt && !reducedMotion ? "rotateX(1deg) rotateY(-2deg)" : undefined,
68
+ transformStyle: "preserve-3d",
69
+ ...style,
70
+ }, children: _jsxs("div", { className: "ml-scene-device-screen", children: [_jsx("div", { className: "ml-scene-device-notch", children: _jsx("span", { className: "ml-scene-device-notch-dot", "aria-hidden": "true" }) }), _jsx("div", { className: "ml-scene-device-content", children: children })] }) }));
71
+ }
@@ -0,0 +1,14 @@
1
+ import * as React from "react";
2
+ export interface SpatialToggleProps {
3
+ checked?: boolean;
4
+ onChange?: (checked: boolean) => void;
5
+ label?: React.ReactNode;
6
+ className?: string;
7
+ disabled?: boolean;
8
+ id?: string;
9
+ name?: string;
10
+ }
11
+ export declare const SpatialToggle: React.ForwardRefExoticComponent<SpatialToggleProps & React.RefAttributes<HTMLButtonElement>>;
12
+ /** @deprecated Use SpatialToggle. Kept as a source-compatible scene export. */
13
+ export declare const Toggle3D: React.ForwardRefExoticComponent<SpatialToggleProps & React.RefAttributes<HTMLButtonElement>>;
14
+ export type Toggle3DProps = SpatialToggleProps;
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { useReducedMotion } from "./use-reduced-motion.js";
5
+ function joinClasses(...values) {
6
+ return values.filter(Boolean).join(" ");
7
+ }
8
+ export const SpatialToggle = React.forwardRef(({ checked = false, onChange, label, className = "", disabled = false, id, name, }, ref) => {
9
+ const reducedMotion = useReducedMotion();
10
+ return (_jsxs("span", { className: joinClasses("ml-scene-spatial-toggle", className), children: [_jsx("button", { ref: ref, id: id, name: name, type: "button", role: "switch", "aria-checked": checked, "aria-label": typeof label === "string" ? label : undefined, disabled: disabled, onClick: () => onChange?.(!checked), className: "ml-scene-spatial-switch", style: {
11
+ background: checked
12
+ ? "var(--ml-primary)"
13
+ : "var(--ml-background-subtle)",
14
+ transition: reducedMotion ? "none" : "background 240ms ease",
15
+ }, children: _jsx("span", { "aria-hidden": "true", className: "ml-scene-spatial-thumb", style: {
16
+ transform: checked ? "translate3d(20px, 0, 0)" : "translate3d(0, 0, 0)",
17
+ transition: reducedMotion
18
+ ? "none"
19
+ : "transform 300ms cubic-bezier(0.16, 1, 0.3, 1)",
20
+ } }) }), label ? (_jsx("span", { "aria-hidden": "true", className: "ml-scene-spatial-label", children: label })) : null] }));
21
+ });
22
+ SpatialToggle.displayName = "SpatialToggle";
23
+ /** @deprecated Use SpatialToggle. Kept as a source-compatible scene export. */
24
+ export const Toggle3D = SpatialToggle;
@@ -0,0 +1,12 @@
1
+ import * as React from "react";
2
+ export interface Stage3DProps extends React.HTMLAttributes<HTMLDivElement> {
3
+ perspective?: number;
4
+ interactive?: boolean;
5
+ maxRotationX?: number;
6
+ maxRotationY?: number;
7
+ }
8
+ export declare const Stage3D: React.ForwardRefExoticComponent<Stage3DProps & React.RefAttributes<HTMLDivElement>>;
9
+ export interface FloatingLayerProps extends React.HTMLAttributes<HTMLDivElement> {
10
+ depth?: number;
11
+ }
12
+ export declare const FloatingLayer: React.ForwardRefExoticComponent<FloatingLayerProps & React.RefAttributes<HTMLDivElement>>;
package/dist/stage.js ADDED
@@ -0,0 +1,90 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { useReducedMotion } from "./use-reduced-motion.js";
5
+ function finite(value, fallback) {
6
+ return Number.isFinite(value) ? value : fallback;
7
+ }
8
+ export const Stage3D = React.forwardRef(({ perspective = 1200, interactive = true, maxRotationX = 12, maxRotationY = 14, children, style, onPointerMove, onPointerLeave, onPointerCancel, ...props }, forwardedRef) => {
9
+ const reducedMotion = useReducedMotion();
10
+ const stageRef = React.useRef(null);
11
+ const contentRef = React.useRef(null);
12
+ const frameRef = React.useRef(null);
13
+ const setRef = React.useCallback((node) => {
14
+ stageRef.current = node;
15
+ if (typeof forwardedRef === "function")
16
+ forwardedRef(node);
17
+ else if (forwardedRef)
18
+ forwardedRef.current = node;
19
+ }, [forwardedRef]);
20
+ const renderRotation = React.useCallback((x, y, active) => {
21
+ const content = contentRef.current;
22
+ if (!content)
23
+ return;
24
+ if (frameRef.current !== null)
25
+ cancelAnimationFrame(frameRef.current);
26
+ if (typeof requestAnimationFrame === "undefined")
27
+ return;
28
+ frameRef.current = requestAnimationFrame(() => {
29
+ content.style.transform = `rotateX(${x.toFixed(2)}deg) rotateY(${y.toFixed(2)}deg)`;
30
+ content.style.transition = active
31
+ ? "transform 80ms linear"
32
+ : "transform 360ms cubic-bezier(0.16, 1, 0.3, 1)";
33
+ frameRef.current = null;
34
+ });
35
+ }, []);
36
+ React.useEffect(() => () => {
37
+ if (frameRef.current !== null)
38
+ cancelAnimationFrame(frameRef.current);
39
+ }, []);
40
+ React.useEffect(() => {
41
+ if (reducedMotion || !interactive)
42
+ renderRotation(0, 0, false);
43
+ }, [interactive, reducedMotion, renderRotation]);
44
+ const reset = () => renderRotation(0, 0, false);
45
+ return (_jsx("div", { ...props, ref: setRef, onPointerMove: (event) => {
46
+ onPointerMove?.(event);
47
+ const stage = stageRef.current;
48
+ if (event.defaultPrevented ||
49
+ !interactive ||
50
+ reducedMotion ||
51
+ event.pointerType === "touch" ||
52
+ !stage)
53
+ return;
54
+ const rect = stage.getBoundingClientRect();
55
+ if (rect.width <= 0 || rect.height <= 0)
56
+ return;
57
+ const px = (event.clientX - rect.left) / rect.width - 0.5;
58
+ const py = (event.clientY - rect.top) / rect.height - 0.5;
59
+ renderRotation(-py * finite(maxRotationX, 12), px * finite(maxRotationY, 14), true);
60
+ }, onPointerLeave: (event) => {
61
+ onPointerLeave?.(event);
62
+ reset();
63
+ }, onPointerCancel: (event) => {
64
+ onPointerCancel?.(event);
65
+ reset();
66
+ }, style: {
67
+ perspective: `${Math.max(1, finite(perspective, 1200))}px`,
68
+ transformStyle: "preserve-3d",
69
+ ...style,
70
+ }, children: _jsx("div", { ref: contentRef, style: {
71
+ width: "100%",
72
+ height: "100%",
73
+ transform: "rotateX(0deg) rotateY(0deg)",
74
+ transformStyle: "preserve-3d",
75
+ transition: reducedMotion
76
+ ? "none"
77
+ : "transform 360ms cubic-bezier(0.16, 1, 0.3, 1)",
78
+ willChange: interactive && !reducedMotion ? "transform" : undefined,
79
+ }, children: children }) }));
80
+ });
81
+ Stage3D.displayName = "Stage3D";
82
+ export const FloatingLayer = React.forwardRef(({ depth = 30, children, style, ...props }, ref) => {
83
+ const safeDepth = Math.max(-1000, Math.min(1000, finite(depth, 30)));
84
+ return (_jsx("div", { ...props, ref: ref, style: {
85
+ transform: `translateZ(${safeDepth}px)`,
86
+ transformStyle: "preserve-3d",
87
+ ...style,
88
+ }, children: children }));
89
+ });
90
+ FloatingLayer.displayName = "FloatingLayer";
@@ -0,0 +1 @@
1
+ export declare function useReducedMotion(): boolean;
@@ -0,0 +1,12 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ const query = "(prefers-reduced-motion: reduce)";
4
+ export function useReducedMotion() {
5
+ return React.useSyncExternalStore((listener) => {
6
+ if (typeof window === "undefined")
7
+ return () => { };
8
+ const media = window.matchMedia(query);
9
+ media.addEventListener("change", listener);
10
+ return () => media.removeEventListener("change", listener);
11
+ }, () => typeof window !== "undefined" && window.matchMedia(query).matches, () => false);
12
+ }
package/package.json CHANGED
@@ -1,21 +1,35 @@
1
1
  {
2
2
  "name": "@mlola-ui/scene",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Optional lightweight spatial presentation for Mlola UI",
5
- "main": "./src/index.ts",
6
- "types": "./src/index.ts",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
7
  "sideEffects": [
8
8
  "./src/scene.css"
9
9
  ],
10
10
  "files": [
11
- "src"
11
+ "dist",
12
+ "src/scene.css"
12
13
  ],
13
14
  "exports": {
14
- ".": "./src/index.ts",
15
- "./stage": "./src/stage.tsx",
16
- "./primitives": "./src/primitives.tsx",
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./stage": {
20
+ "types": "./dist/stage.d.ts",
21
+ "default": "./dist/stage.js"
22
+ },
23
+ "./primitives": {
24
+ "types": "./dist/primitives.d.ts",
25
+ "default": "./dist/primitives.js"
26
+ },
17
27
  "./scene.css": "./src/scene.css"
18
28
  },
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.json",
31
+ "prepack": "npm run build"
32
+ },
19
33
  "peerDependencies": {
20
34
  "react": ">=18",
21
35
  "react-dom": ">=18"
package/src/index.ts DELETED
@@ -1,3 +0,0 @@
1
- export * from "./primitives";
2
- export * from "./spatial-toggle";
3
- export * from "./stage";
@@ -1,156 +0,0 @@
1
- "use client";
2
-
3
- import * as React from "react";
4
- import { useReducedMotion } from "./use-reduced-motion";
5
-
6
- export interface MeshGradientProps extends React.HTMLAttributes<HTMLDivElement> {
7
- animated?: boolean;
8
- blur?: number;
9
- }
10
-
11
- export const MeshGradient = React.forwardRef<HTMLDivElement, MeshGradientProps>(
12
- ({ animated = true, blur = 50, style, ...props }, ref) => {
13
- const reducedMotion = useReducedMotion();
14
- const shouldAnimate = animated && !reducedMotion;
15
- return (
16
- <div
17
- {...props}
18
- ref={ref}
19
- aria-hidden="true"
20
- style={{
21
- position: "absolute",
22
- inset: 0,
23
- pointerEvents: "none",
24
- overflow: "hidden",
25
- backgroundImage: `
26
- radial-gradient(circle at 18% 25%, color-mix(in srgb, var(--ml-primary) 22%, transparent), transparent 55%),
27
- radial-gradient(circle at 82% 70%, color-mix(in srgb, var(--ml-primary, var(--primary)) 14%, transparent), transparent 60%),
28
- radial-gradient(circle at 50% 90%, color-mix(in srgb, var(--ml-accent, var(--accent)) 30%, transparent), transparent 70%)
29
- `,
30
- filter: `blur(${Math.max(0, Math.min(160, Number.isFinite(blur) ? blur : 50))}px)`,
31
- animation: shouldAnimate ? "float-3d 14s ease-in-out infinite" : undefined,
32
- ...style,
33
- }}
34
- />
35
- );
36
- }
37
- );
38
- MeshGradient.displayName = "MeshGradient";
39
-
40
- export interface CardStack3DItem {
41
- title: string;
42
- subtitle: string;
43
- tag: string;
44
- }
45
-
46
- export interface CardStack3DProps extends React.HTMLAttributes<HTMLDivElement> {
47
- cards: CardStack3DItem[];
48
- }
49
-
50
- function joinClasses(...values: Array<string | false | null | undefined>) {
51
- return values.filter(Boolean).join(" ");
52
- }
53
-
54
- export function CardStack3D({
55
- cards,
56
- className = "",
57
- onPointerEnter,
58
- onPointerLeave,
59
- onFocus,
60
- onBlur,
61
- ...props
62
- }: CardStack3DProps) {
63
- const [active, setActive] = React.useState(false);
64
- const reducedMotion = useReducedMotion();
65
- return (
66
- <div
67
- {...props}
68
- tabIndex={props.tabIndex ?? 0}
69
- aria-label={props["aria-label"] ?? "Card stack"}
70
- onPointerEnter={(event) => {
71
- onPointerEnter?.(event);
72
- if (event.pointerType !== "touch") setActive(true);
73
- }}
74
- onPointerLeave={(event) => {
75
- onPointerLeave?.(event);
76
- setActive(false);
77
- }}
78
- onFocus={(event) => {
79
- onFocus?.(event);
80
- setActive(true);
81
- }}
82
- onBlur={(event) => {
83
- onBlur?.(event);
84
- if (!event.currentTarget.contains(event.relatedTarget)) setActive(false);
85
- }}
86
- className={joinClasses("ml-scene-card-stack", className)}
87
- >
88
- {cards.slice(0, 3).map((card, index) => {
89
- const layer = 2 - index;
90
- const fanDown = active ? layer * 18 : layer * 6;
91
- const fanScale = active ? 1 - layer * 0.03 : 1 - layer * 0.04;
92
- const rotateZ = active ? (index - 1) * 2.5 : 0;
93
- return (
94
- <div
95
- key={`${card.title}-${index}`}
96
- className="ml-scene-card-stack-item"
97
- style={{
98
- transform: `translate3d(0, ${fanDown}px, ${index * 12}px) rotateZ(${rotateZ}deg) scale(${fanScale})`,
99
- transformOrigin: "top center",
100
- zIndex: index + 10,
101
- boxShadow: active
102
- ? "0 18px 38px -10px color-mix(in srgb, var(--foreground) 14%, transparent), 0 0 0 1px color-mix(in srgb, var(--border) 90%, transparent)"
103
- : "0 8px 20px -6px color-mix(in srgb, var(--foreground) 8%, transparent), 0 0 0 1px color-mix(in srgb, var(--border) 70%, transparent)",
104
- transition: reducedMotion
105
- ? "none"
106
- : "transform 300ms cubic-bezier(0.16, 1, 0.3, 1), box-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1)",
107
- }}
108
- >
109
- <div className="ml-scene-card-stack-header">
110
- <span className="ml-scene-card-stack-tag">{card.tag}</span>
111
- <span className="ml-scene-card-stack-index">0{index + 1}</span>
112
- </div>
113
- <div>
114
- <p className="ml-scene-card-stack-title">{card.title}</p>
115
- <p className="ml-scene-card-stack-subtitle">{card.subtitle}</p>
116
- </div>
117
- </div>
118
- );
119
- })}
120
- </div>
121
- );
122
- }
123
-
124
- export interface DeviceFrame3DProps extends React.HTMLAttributes<HTMLDivElement> {
125
- tilt?: boolean;
126
- }
127
-
128
- export function DeviceFrame3D({
129
- children,
130
- className = "",
131
- tilt = true,
132
- style,
133
- ...props
134
- }: DeviceFrame3DProps) {
135
- const reducedMotion = useReducedMotion();
136
- return (
137
- <div
138
- {...props}
139
- className={joinClasses("ml-scene-device-frame", className)}
140
- style={{
141
- background: "linear-gradient(145deg, oklch(35% 0.02 260), oklch(18% 0.015 260))",
142
- boxShadow: "0 30px 70px -15px oklch(0% 0 0 / 0.5), inset 0 1px 2px oklch(100% 0 0 / 0.2)",
143
- transform: tilt && !reducedMotion ? "rotateX(1deg) rotateY(-2deg)" : undefined,
144
- transformStyle: "preserve-3d",
145
- ...style,
146
- }}
147
- >
148
- <div className="ml-scene-device-screen">
149
- <div className="ml-scene-device-notch">
150
- <span className="ml-scene-device-notch-dot" aria-hidden="true" />
151
- </div>
152
- <div className="ml-scene-device-content">{children}</div>
153
- </div>
154
- </div>
155
- );
156
- }
@@ -1,78 +0,0 @@
1
- "use client";
2
-
3
- import * as React from "react";
4
- import { useReducedMotion } from "./use-reduced-motion";
5
-
6
- export interface SpatialToggleProps {
7
- checked?: boolean;
8
- onChange?: (checked: boolean) => void;
9
- label?: React.ReactNode;
10
- className?: string;
11
- disabled?: boolean;
12
- id?: string;
13
- name?: string;
14
- }
15
-
16
- function joinClasses(...values: Array<string | false | null | undefined>) {
17
- return values.filter(Boolean).join(" ");
18
- }
19
-
20
- export const SpatialToggle = React.forwardRef<HTMLButtonElement, SpatialToggleProps>(
21
- (
22
- {
23
- checked = false,
24
- onChange,
25
- label,
26
- className = "",
27
- disabled = false,
28
- id,
29
- name,
30
- },
31
- ref
32
- ) => {
33
- const reducedMotion = useReducedMotion();
34
- return (
35
- <span className={joinClasses("ml-scene-spatial-toggle", className)}>
36
- <button
37
- ref={ref}
38
- id={id}
39
- name={name}
40
- type="button"
41
- role="switch"
42
- aria-checked={checked}
43
- aria-label={typeof label === "string" ? label : undefined}
44
- disabled={disabled}
45
- onClick={() => onChange?.(!checked)}
46
- className="ml-scene-spatial-switch"
47
- style={{
48
- background: checked
49
- ? "var(--ml-primary)"
50
- : "var(--ml-background-subtle)",
51
- transition: reducedMotion ? "none" : "background 240ms ease",
52
- }}
53
- >
54
- <span
55
- aria-hidden="true"
56
- className="ml-scene-spatial-thumb"
57
- style={{
58
- transform: checked ? "translate3d(20px, 0, 0)" : "translate3d(0, 0, 0)",
59
- transition: reducedMotion
60
- ? "none"
61
- : "transform 300ms cubic-bezier(0.16, 1, 0.3, 1)",
62
- }}
63
- />
64
- </button>
65
- {label ? (
66
- <span aria-hidden="true" className="ml-scene-spatial-label">
67
- {label}
68
- </span>
69
- ) : null}
70
- </span>
71
- );
72
- }
73
- );
74
- SpatialToggle.displayName = "SpatialToggle";
75
-
76
- /** @deprecated Use SpatialToggle. Kept as a source-compatible scene export. */
77
- export const Toggle3D = SpatialToggle;
78
- export type Toggle3DProps = SpatialToggleProps;
package/src/stage.tsx DELETED
@@ -1,154 +0,0 @@
1
- "use client";
2
-
3
- import * as React from "react";
4
- import { useReducedMotion } from "./use-reduced-motion";
5
-
6
- function finite(value: number, fallback: number): number {
7
- return Number.isFinite(value) ? value : fallback;
8
- }
9
-
10
- export interface Stage3DProps extends React.HTMLAttributes<HTMLDivElement> {
11
- perspective?: number;
12
- interactive?: boolean;
13
- maxRotationX?: number;
14
- maxRotationY?: number;
15
- }
16
-
17
- export const Stage3D = React.forwardRef<HTMLDivElement, Stage3DProps>(
18
- (
19
- {
20
- perspective = 1200,
21
- interactive = true,
22
- maxRotationX = 12,
23
- maxRotationY = 14,
24
- children,
25
- style,
26
- onPointerMove,
27
- onPointerLeave,
28
- onPointerCancel,
29
- ...props
30
- },
31
- forwardedRef
32
- ) => {
33
- const reducedMotion = useReducedMotion();
34
- const stageRef = React.useRef<HTMLDivElement>(null);
35
- const contentRef = React.useRef<HTMLDivElement>(null);
36
- const frameRef = React.useRef<number | null>(null);
37
- const setRef = React.useCallback(
38
- (node: HTMLDivElement | null) => {
39
- stageRef.current = node;
40
- if (typeof forwardedRef === "function") forwardedRef(node);
41
- else if (forwardedRef) forwardedRef.current = node;
42
- },
43
- [forwardedRef]
44
- );
45
-
46
- const renderRotation = React.useCallback((x: number, y: number, active: boolean) => {
47
- const content = contentRef.current;
48
- if (!content) return;
49
- if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
50
- if (typeof requestAnimationFrame === "undefined") return;
51
- frameRef.current = requestAnimationFrame(() => {
52
- content.style.transform = `rotateX(${x.toFixed(2)}deg) rotateY(${y.toFixed(2)}deg)`;
53
- content.style.transition = active
54
- ? "transform 80ms linear"
55
- : "transform 360ms cubic-bezier(0.16, 1, 0.3, 1)";
56
- frameRef.current = null;
57
- });
58
- }, []);
59
-
60
- React.useEffect(
61
- () => () => {
62
- if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
63
- },
64
- []
65
- );
66
-
67
- React.useEffect(() => {
68
- if (reducedMotion || !interactive) renderRotation(0, 0, false);
69
- }, [interactive, reducedMotion, renderRotation]);
70
-
71
- const reset = () => renderRotation(0, 0, false);
72
-
73
- return (
74
- <div
75
- {...props}
76
- ref={setRef}
77
- onPointerMove={(event) => {
78
- onPointerMove?.(event);
79
- const stage = stageRef.current;
80
- if (
81
- event.defaultPrevented ||
82
- !interactive ||
83
- reducedMotion ||
84
- event.pointerType === "touch" ||
85
- !stage
86
- ) return;
87
- const rect = stage.getBoundingClientRect();
88
- if (rect.width <= 0 || rect.height <= 0) return;
89
- const px = (event.clientX - rect.left) / rect.width - 0.5;
90
- const py = (event.clientY - rect.top) / rect.height - 0.5;
91
- renderRotation(
92
- -py * finite(maxRotationX, 12),
93
- px * finite(maxRotationY, 14),
94
- true
95
- );
96
- }}
97
- onPointerLeave={(event) => {
98
- onPointerLeave?.(event);
99
- reset();
100
- }}
101
- onPointerCancel={(event) => {
102
- onPointerCancel?.(event);
103
- reset();
104
- }}
105
- style={{
106
- perspective: `${Math.max(1, finite(perspective, 1200))}px`,
107
- transformStyle: "preserve-3d",
108
- ...style,
109
- }}
110
- >
111
- <div
112
- ref={contentRef}
113
- style={{
114
- width: "100%",
115
- height: "100%",
116
- transform: "rotateX(0deg) rotateY(0deg)",
117
- transformStyle: "preserve-3d",
118
- transition: reducedMotion
119
- ? "none"
120
- : "transform 360ms cubic-bezier(0.16, 1, 0.3, 1)",
121
- willChange: interactive && !reducedMotion ? "transform" : undefined,
122
- }}
123
- >
124
- {children}
125
- </div>
126
- </div>
127
- );
128
- }
129
- );
130
- Stage3D.displayName = "Stage3D";
131
-
132
- export interface FloatingLayerProps extends React.HTMLAttributes<HTMLDivElement> {
133
- depth?: number;
134
- }
135
-
136
- export const FloatingLayer = React.forwardRef<HTMLDivElement, FloatingLayerProps>(
137
- ({ depth = 30, children, style, ...props }, ref) => {
138
- const safeDepth = Math.max(-1000, Math.min(1000, finite(depth, 30)));
139
- return (
140
- <div
141
- {...props}
142
- ref={ref}
143
- style={{
144
- transform: `translateZ(${safeDepth}px)`,
145
- transformStyle: "preserve-3d",
146
- ...style,
147
- }}
148
- >
149
- {children}
150
- </div>
151
- );
152
- }
153
- );
154
- FloatingLayer.displayName = "FloatingLayer";
@@ -1,18 +0,0 @@
1
- "use client";
2
-
3
- import * as React from "react";
4
-
5
- const query = "(prefers-reduced-motion: reduce)";
6
-
7
- export function useReducedMotion(): boolean {
8
- return React.useSyncExternalStore(
9
- (listener) => {
10
- if (typeof window === "undefined") return () => {};
11
- const media = window.matchMedia(query);
12
- media.addEventListener("change", listener);
13
- return () => media.removeEventListener("change", listener);
14
- },
15
- () => typeof window !== "undefined" && window.matchMedia(query).matches,
16
- () => false
17
- );
18
- }