@vsreact/core 0.0.24 → 0.0.26
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/dist/hooks.d.ts +10 -0
- package/dist/hooks.js +15 -0
- package/dist/hostConfig.js +4 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/primitives.d.ts +34 -4
- package/dist/primitives.js +3 -1
- package/dist/transitions.d.ts +23 -0
- package/dist/transitions.js +205 -0
- package/dist/tw.js +81 -0
- package/package.json +1 -1
- package/src/gaps.test.tsx +205 -0
- package/src/hooks.ts +24 -0
- package/src/hostConfig.ts +4 -1
- package/src/index.ts +3 -1
- package/src/layout.test.tsx +19 -0
- package/src/primitives.tsx +12 -4
- package/src/transitions.ts +262 -0
- package/src/tw.test.ts +14 -0
- package/src/tw.ts +79 -0
package/dist/hooks.d.ts
CHANGED
|
@@ -22,6 +22,16 @@ export declare function useNativeEvent(name: string, handler: (payload: any) =>
|
|
|
22
22
|
* <Meter value={meter.level} />
|
|
23
23
|
*/
|
|
24
24
|
export declare function useNativeValue<T = any>(name: string, initial: T): T;
|
|
25
|
+
export interface RootSize {
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The editor's current size. The native side sends a "resize" event at
|
|
31
|
+
* mount (so this resolves on the first committed frame) and on every
|
|
32
|
+
* host resize — the foundation for resizable editors.
|
|
33
|
+
*/
|
|
34
|
+
export declare function useRootSize(): RootSize;
|
|
25
35
|
/**
|
|
26
36
|
* The value, but only after it has stopped changing for `delayMs` —
|
|
27
37
|
* classic input debouncing for expensive native calls:
|
package/dist/hooks.js
CHANGED
|
@@ -35,6 +35,21 @@ export function useNativeValue(name, initial) {
|
|
|
35
35
|
useNativeEvent(name, setValue);
|
|
36
36
|
return value;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The editor's current size. The native side sends a "resize" event at
|
|
40
|
+
* mount (so this resolves on the first committed frame) and on every
|
|
41
|
+
* host resize — the foundation for resizable editors.
|
|
42
|
+
*/
|
|
43
|
+
export function useRootSize() {
|
|
44
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
45
|
+
useNativeEvent("resize", (payload) => {
|
|
46
|
+
const width = Number(payload?.width);
|
|
47
|
+
const height = Number(payload?.height);
|
|
48
|
+
if (Number.isFinite(width) && Number.isFinite(height))
|
|
49
|
+
setSize((s) => (s.width === width && s.height === height ? s : { width, height }));
|
|
50
|
+
});
|
|
51
|
+
return size;
|
|
52
|
+
}
|
|
38
53
|
/**
|
|
39
54
|
* The value, but only after it has stopped changing for `delayMs` —
|
|
40
55
|
* classic input debouncing for expensive native calls:
|
package/dist/hostConfig.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// mutation ops; event props become handler-registry entries + listener lists.
|
|
3
3
|
import { DefaultEventPriority } from "react-reconciler/constants";
|
|
4
4
|
import { queueOp, flushOps, setHandlers, removeHandlers } from "./bridge";
|
|
5
|
+
import { commitProps, releaseNode } from "./transitions";
|
|
5
6
|
import { tw } from "./tw";
|
|
6
7
|
let nextId = 1;
|
|
7
8
|
const hostTypes = {
|
|
@@ -64,7 +65,8 @@ function normalizeProps(props) {
|
|
|
64
65
|
function applyProps(instance, props) {
|
|
65
66
|
const { payload, handlers } = normalizeProps(props);
|
|
66
67
|
setHandlers(instance.id, handlers);
|
|
67
|
-
|
|
68
|
+
// The transition engine queues the op — tweened when the style asks for it.
|
|
69
|
+
commitProps(instance.id, payload);
|
|
68
70
|
}
|
|
69
71
|
export const hostConfig = {
|
|
70
72
|
supportsMutation: true,
|
|
@@ -115,6 +117,7 @@ export const hostConfig = {
|
|
|
115
117
|
clearContainer: () => queueOp(["clearContainer"]),
|
|
116
118
|
detachDeletedInstance(instance) {
|
|
117
119
|
removeHandlers(instance.id);
|
|
120
|
+
releaseNode(instance.id);
|
|
118
121
|
},
|
|
119
122
|
getCurrentEventPriority: () => DefaultEventPriority,
|
|
120
123
|
getInstanceFromNode: () => null,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import "./runtime";
|
|
2
2
|
import "./bridge";
|
|
3
3
|
/** The SDK version — stamp it on support dumps and analytics. */
|
|
4
|
-
export declare const VERSION = "0.0.
|
|
4
|
+
export declare const VERSION = "0.0.26";
|
|
5
5
|
export { View, Text, Image, TextInput, NativeView, Svg, SvgPath } from "./primitives";
|
|
6
6
|
export type { CommonProps, TextProps, ImageProps, TextInputProps, NativeViewProps, SvgProps, SvgPathProps, } from "./primitives";
|
|
7
7
|
export { render, unmount } from "./render";
|
|
8
8
|
export { native } from "./native";
|
|
9
|
-
export { useNativeEvent, useNativeValue, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
|
|
9
|
+
export { useNativeEvent, useNativeValue, useRootSize, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
|
|
10
|
+
export type { RootSize } from "./hooks";
|
|
10
11
|
export { useOverlay, OverlayLayer } from "./overlay";
|
|
11
12
|
export { Tooltip, Modal } from "./popover";
|
|
12
13
|
export type { TooltipProps, ModalProps } from "./popover";
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import "./runtime";
|
|
4
4
|
import "./bridge";
|
|
5
5
|
/** The SDK version — stamp it on support dumps and analytics. */
|
|
6
|
-
export const VERSION = "0.0.
|
|
6
|
+
export const VERSION = "0.0.26";
|
|
7
7
|
export { View, Text, Image, TextInput, NativeView, Svg, SvgPath } from "./primitives";
|
|
8
8
|
export { render, unmount } from "./render";
|
|
9
9
|
export { native } from "./native";
|
|
10
|
-
export { useNativeEvent, useNativeValue, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
|
|
10
|
+
export { useNativeEvent, useNativeValue, useRootSize, useDebounced, useThrottled, usePrevious, useToggle, useInterval, useHover, useLayoutRect, } from "./hooks";
|
|
11
11
|
export { useOverlay, OverlayLayer } from "./overlay";
|
|
12
12
|
export { Tooltip, Modal } from "./popover";
|
|
13
13
|
export { Button } from "./button";
|
package/dist/primitives.d.ts
CHANGED
|
@@ -15,10 +15,11 @@ export interface LayoutRect {
|
|
|
15
15
|
width: number;
|
|
16
16
|
height: number;
|
|
17
17
|
}
|
|
18
|
-
/** Mouse-wheel payload.
|
|
19
|
-
positive = wheel up). */
|
|
18
|
+
/** Mouse-wheel payload. Deltas are JUCE's notch fraction (~0.1 per notch,
|
|
19
|
+
dy positive = wheel up; dx nonzero on trackpads/tilt wheels). */
|
|
20
20
|
export interface WheelEventPayload {
|
|
21
21
|
dy: number;
|
|
22
|
+
dx: number;
|
|
22
23
|
}
|
|
23
24
|
/** Web-style key event: key names match KeyboardEvent.key ("ArrowUp",
|
|
24
25
|
"Enter", "Escape", " ", "a"). */
|
|
@@ -38,8 +39,10 @@ export interface CommonProps {
|
|
|
38
39
|
className?: string;
|
|
39
40
|
style?: Style;
|
|
40
41
|
children?: ReactNode;
|
|
41
|
-
/** Resets the scroll offset of an overflow-
|
|
42
|
+
/** Resets the vertical scroll offset of an overflow-scroll container. */
|
|
42
43
|
scrollTop?: number;
|
|
44
|
+
/** Resets the horizontal scroll offset of an overflow-scroll container. */
|
|
45
|
+
scrollLeft?: number;
|
|
43
46
|
onClick?: () => void;
|
|
44
47
|
onDoubleClick?: () => void;
|
|
45
48
|
/** Wheel over this node (controls win the wheel over scroll containers). */
|
|
@@ -67,8 +70,34 @@ export interface CommonProps {
|
|
|
67
70
|
export declare function View(props: CommonProps): import("react").ReactElement<CommonProps, string | import("react").JSXElementConstructor<any>>;
|
|
68
71
|
export interface TextProps extends Omit<CommonProps, "children"> {
|
|
69
72
|
children?: ReactNode;
|
|
73
|
+
/** Opt this text into selection (drag to select, double-click for a word,
|
|
74
|
+
Ctrl/Cmd+C copies). Text is NOT selectable by default. Equivalent to
|
|
75
|
+
the `select-text` class / `userSelect: "text"` style key. */
|
|
76
|
+
selectable?: boolean;
|
|
70
77
|
}
|
|
71
|
-
export declare function Text(props: TextProps): import("react").ReactElement<
|
|
78
|
+
export declare function Text({ selectable, ...props }: TextProps): import("react").ReactElement<{
|
|
79
|
+
children?: ReactNode;
|
|
80
|
+
style?: Style | undefined;
|
|
81
|
+
className?: string | undefined;
|
|
82
|
+
scrollTop?: number | undefined;
|
|
83
|
+
scrollLeft?: number | undefined;
|
|
84
|
+
onClick?: (() => void) | undefined;
|
|
85
|
+
onDoubleClick?: (() => void) | undefined;
|
|
86
|
+
onWheel?: ((e: WheelEventPayload) => void) | undefined;
|
|
87
|
+
onMouseEnter?: (() => void) | undefined;
|
|
88
|
+
onMouseLeave?: (() => void) | undefined;
|
|
89
|
+
onMouseDown?: (() => void) | undefined;
|
|
90
|
+
onMouseUp?: (() => void) | undefined;
|
|
91
|
+
onDragStart?: ((e: DragEventPayload) => void) | undefined;
|
|
92
|
+
onDrag?: ((e: DragEventPayload) => void) | undefined;
|
|
93
|
+
onDragEnd?: ((e: DragEventPayload) => void) | undefined;
|
|
94
|
+
onMouseMove?: ((e: MouseMovePayload) => void) | undefined;
|
|
95
|
+
onKeyDown?: ((e: KeyEventPayload) => void) | undefined;
|
|
96
|
+
onKeyUp?: ((e: KeyEventPayload) => void) | undefined;
|
|
97
|
+
onFocus?: (() => void) | undefined;
|
|
98
|
+
onBlur?: (() => void) | undefined;
|
|
99
|
+
onLayout?: ((rect: LayoutRect) => void) | undefined;
|
|
100
|
+
}, string | import("react").JSXElementConstructor<any>>;
|
|
72
101
|
export interface ImageProps extends CommonProps {
|
|
73
102
|
src: string;
|
|
74
103
|
}
|
|
@@ -119,6 +148,7 @@ export declare function TextInput({ onChange, onSubmit, ...rest }: TextInputProp
|
|
|
119
148
|
style?: Style | undefined;
|
|
120
149
|
className?: string | undefined;
|
|
121
150
|
scrollTop?: number | undefined;
|
|
151
|
+
scrollLeft?: number | undefined;
|
|
122
152
|
onClick?: (() => void) | undefined;
|
|
123
153
|
onDoubleClick?: (() => void) | undefined;
|
|
124
154
|
onWheel?: ((e: WheelEventPayload) => void) | undefined;
|
package/dist/primitives.js
CHANGED
|
@@ -2,7 +2,9 @@ import { createElement } from "react";
|
|
|
2
2
|
export function View(props) {
|
|
3
3
|
return createElement("vs-view", props);
|
|
4
4
|
}
|
|
5
|
-
export function Text(props) {
|
|
5
|
+
export function Text({ selectable, ...props }) {
|
|
6
|
+
if (selectable)
|
|
7
|
+
props = { ...props, style: { userSelect: "text", ...props.style } };
|
|
6
8
|
return createElement("vs-text", props);
|
|
7
9
|
}
|
|
8
10
|
export function Image(props) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Style } from "./tw";
|
|
2
|
+
type Payload = Record<string, unknown> & {
|
|
3
|
+
style?: Style;
|
|
4
|
+
};
|
|
5
|
+
export type TransitionEasingName = "linear" | "ease-in" | "ease-out" | "ease-in-out";
|
|
6
|
+
/** "#rrggbb" or "#rrggbbaa" → [r,g,b,a]; undefined for anything else. */
|
|
7
|
+
export declare function parseHexColor(value: unknown): [number, number, number, number] | undefined;
|
|
8
|
+
export declare function lerpHexColor(from: string, to: string, t: number): string;
|
|
9
|
+
/** The animatable keys that differ between two styles, honoring the
|
|
10
|
+
transitionProperty group. Exported for tests. */
|
|
11
|
+
export declare function transitionKeys(prev: Style, next: Style, property: string): string[];
|
|
12
|
+
/** Interpolated patch for the animated keys at eased progress t. Exported
|
|
13
|
+
for tests. */
|
|
14
|
+
export declare function interpolateStyle(from: Style, to: Style, keys: string[], t: number): Style;
|
|
15
|
+
/** Style patch at loop phase t (0→1). Exported for tests. */
|
|
16
|
+
export declare const animationPresets: Record<string, (t: number) => Style>;
|
|
17
|
+
export declare const animationDurations: Record<string, number>;
|
|
18
|
+
/** hostConfig's single entry point: queue this commit's setProps, animated
|
|
19
|
+
when the style asks for it. */
|
|
20
|
+
export declare function commitProps(nodeId: number, payload: Payload): void;
|
|
21
|
+
/** Unmount cleanup — hostConfig.detachDeletedInstance. */
|
|
22
|
+
export declare function releaseNode(nodeId: number): void;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// CSS transitions and keyframe presets, driven from JS. When a commit changes
|
|
2
|
+
// a node whose style carries transitionDuration, the changed animatable keys
|
|
3
|
+
// tween from their *currently displayed* values instead of jumping; animate-*
|
|
4
|
+
// classes run keyframe loops. Every frame re-sends the node's full payload
|
|
5
|
+
// (setProps replaces wholesale) with the animated keys patched — the same
|
|
6
|
+
// traffic a useTween re-render produces, without the component code.
|
|
7
|
+
//
|
|
8
|
+
// Honest limit: native-side hover/active/focus style merges don't transition
|
|
9
|
+
// (they never round-trip through JS). Animate those with onMouseEnter state.
|
|
10
|
+
import { queueOp, flushOps } from "./bridge";
|
|
11
|
+
import { Easing } from "./animation";
|
|
12
|
+
const easings = {
|
|
13
|
+
linear: Easing.linear,
|
|
14
|
+
"ease-in": (t) => t * t * t,
|
|
15
|
+
"ease-out": Easing.outCubic,
|
|
16
|
+
"ease-in-out": Easing.inOutCubic,
|
|
17
|
+
};
|
|
18
|
+
/** Keys each transitionProperty group may animate. "colors" is any *Color/
|
|
19
|
+
color key; "all" is every animatable difference. */
|
|
20
|
+
const transformKeys = ["rotate", "scale", "translateX", "translateY"];
|
|
21
|
+
const defaultKeys = [
|
|
22
|
+
"color", "backgroundColor", "borderColor", "opacity",
|
|
23
|
+
...transformKeys, "blurRadius", "backdropBlurRadius",
|
|
24
|
+
];
|
|
25
|
+
function keysForProperty(property, candidates) {
|
|
26
|
+
switch (property) {
|
|
27
|
+
case "all":
|
|
28
|
+
return candidates;
|
|
29
|
+
case "colors":
|
|
30
|
+
return candidates.filter((k) => /color/i.test(k));
|
|
31
|
+
case "opacity":
|
|
32
|
+
return candidates.filter((k) => k === "opacity");
|
|
33
|
+
case "transform":
|
|
34
|
+
return candidates.filter((k) => transformKeys.includes(k));
|
|
35
|
+
case "none":
|
|
36
|
+
return [];
|
|
37
|
+
default:
|
|
38
|
+
return candidates.filter((k) => defaultKeys.includes(k));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// ── value interpolation ────────────────────────────────────────────────
|
|
42
|
+
/** "#rrggbb" or "#rrggbbaa" → [r,g,b,a]; undefined for anything else. */
|
|
43
|
+
export function parseHexColor(value) {
|
|
44
|
+
if (typeof value !== "string" || !/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(value))
|
|
45
|
+
return undefined;
|
|
46
|
+
const n = (i) => parseInt(value.slice(i, i + 2), 16);
|
|
47
|
+
return [n(1), n(3), n(5), value.length === 9 ? n(7) : 255];
|
|
48
|
+
}
|
|
49
|
+
export function lerpHexColor(from, to, t) {
|
|
50
|
+
const a = parseHexColor(from);
|
|
51
|
+
const b = parseHexColor(to);
|
|
52
|
+
const hex = (i) => Math.round(a[i] + (b[i] - a[i]) * t)
|
|
53
|
+
.toString(16)
|
|
54
|
+
.padStart(2, "0");
|
|
55
|
+
const alpha = a[3] !== 255 || b[3] !== 255 ? hex(3) : "";
|
|
56
|
+
return `#${hex(0)}${hex(1)}${hex(2)}${alpha}`;
|
|
57
|
+
}
|
|
58
|
+
function isAnimatablePair(from, to) {
|
|
59
|
+
if (typeof from === "number" && typeof to === "number")
|
|
60
|
+
return Number.isFinite(from) && Number.isFinite(to);
|
|
61
|
+
return parseHexColor(from) !== undefined && parseHexColor(to) !== undefined;
|
|
62
|
+
}
|
|
63
|
+
/** The animatable keys that differ between two styles, honoring the
|
|
64
|
+
transitionProperty group. Exported for tests. */
|
|
65
|
+
export function transitionKeys(prev, next, property) {
|
|
66
|
+
const candidates = Object.keys(next).filter((key) => prev[key] !== next[key] && isAnimatablePair(prev[key], next[key]));
|
|
67
|
+
return keysForProperty(property, candidates);
|
|
68
|
+
}
|
|
69
|
+
/** Interpolated patch for the animated keys at eased progress t. Exported
|
|
70
|
+
for tests. */
|
|
71
|
+
export function interpolateStyle(from, to, keys, t) {
|
|
72
|
+
const patch = {};
|
|
73
|
+
for (const key of keys) {
|
|
74
|
+
const a = from[key];
|
|
75
|
+
const b = to[key];
|
|
76
|
+
patch[key] =
|
|
77
|
+
typeof a === "number" && typeof b === "number"
|
|
78
|
+
? a + (b - a) * t
|
|
79
|
+
: lerpHexColor(String(a), String(b), t);
|
|
80
|
+
}
|
|
81
|
+
return patch;
|
|
82
|
+
}
|
|
83
|
+
// ── keyframe presets (animate-*) ───────────────────────────────────────
|
|
84
|
+
/** Style patch at loop phase t (0→1). Exported for tests. */
|
|
85
|
+
export const animationPresets = {
|
|
86
|
+
spin: (t) => ({ rotate: 360 * t }),
|
|
87
|
+
pulse: (t) => ({ opacity: t < 0.5 ? 1 - t : t }), // 1 → 0.5 → 1
|
|
88
|
+
bounce: (t) => {
|
|
89
|
+
// dip up and settle, roughly the web's bounce
|
|
90
|
+
const k = t < 0.5 ? Easing.outCubic(t * 2) : 1 - Easing.inOutCubic((t - 0.5) * 2);
|
|
91
|
+
return { translateY: `${(-25 * k).toFixed(2)}%` };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
export const animationDurations = {
|
|
95
|
+
spin: 1000,
|
|
96
|
+
pulse: 2000,
|
|
97
|
+
bounce: 1000,
|
|
98
|
+
};
|
|
99
|
+
const FRAME_MS = 16;
|
|
100
|
+
const lastStyles = new Map();
|
|
101
|
+
const activeTransitions = new Map();
|
|
102
|
+
const activeAnimations = new Map();
|
|
103
|
+
let driver;
|
|
104
|
+
function ensureDriver() {
|
|
105
|
+
if (driver !== undefined)
|
|
106
|
+
return;
|
|
107
|
+
driver = setInterval(tick, FRAME_MS);
|
|
108
|
+
}
|
|
109
|
+
function stopDriverIfIdle() {
|
|
110
|
+
if (driver !== undefined && activeTransitions.size === 0 && activeAnimations.size === 0) {
|
|
111
|
+
clearInterval(driver);
|
|
112
|
+
driver = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function send(nodeId, payload, patch) {
|
|
116
|
+
queueOp(["setProps", nodeId, { ...payload, style: { ...payload.style, ...patch } }]);
|
|
117
|
+
}
|
|
118
|
+
function tick() {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
for (const [nodeId, tr] of [...activeTransitions]) {
|
|
121
|
+
const t = Math.min(1, Math.max(0, (now - tr.start) / tr.duration));
|
|
122
|
+
const target = tr.payload.style ?? {};
|
|
123
|
+
send(nodeId, tr.payload, interpolateStyle(tr.from, target, tr.keys, tr.easing(t)));
|
|
124
|
+
if (t >= 1) {
|
|
125
|
+
activeTransitions.delete(nodeId);
|
|
126
|
+
lastStyles.set(nodeId, target);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const [nodeId, anim] of activeAnimations) {
|
|
130
|
+
const phase = ((now - anim.start) % anim.duration) / anim.duration;
|
|
131
|
+
send(nodeId, anim.payload, animationPresets[anim.name](phase));
|
|
132
|
+
}
|
|
133
|
+
flushOps();
|
|
134
|
+
stopDriverIfIdle();
|
|
135
|
+
}
|
|
136
|
+
/** The currently displayed style for a node (mid-flight interpolation when a
|
|
137
|
+
transition is running, else the last committed style). */
|
|
138
|
+
function displayedStyle(nodeId) {
|
|
139
|
+
const tr = activeTransitions.get(nodeId);
|
|
140
|
+
if (!tr)
|
|
141
|
+
return lastStyles.get(nodeId);
|
|
142
|
+
const t = Math.min(1, Math.max(0, (Date.now() - tr.start) / tr.duration));
|
|
143
|
+
return { ...tr.payload.style, ...interpolateStyle(tr.from, tr.payload.style ?? {}, tr.keys, tr.easing(t)) };
|
|
144
|
+
}
|
|
145
|
+
/** hostConfig's single entry point: queue this commit's setProps, animated
|
|
146
|
+
when the style asks for it. */
|
|
147
|
+
export function commitProps(nodeId, payload) {
|
|
148
|
+
const next = payload.style ?? {};
|
|
149
|
+
const prev = displayedStyle(nodeId);
|
|
150
|
+
lastStyles.set(nodeId, next);
|
|
151
|
+
// Keyframe preset loops (animate-*).
|
|
152
|
+
const name = typeof next.animationName === "string" ? next.animationName : undefined;
|
|
153
|
+
if (name !== undefined && animationPresets[name] !== undefined) {
|
|
154
|
+
const running = activeAnimations.get(nodeId);
|
|
155
|
+
const duration = typeof next.animationDuration === "number" && next.animationDuration > 0
|
|
156
|
+
? next.animationDuration
|
|
157
|
+
: animationDurations[name];
|
|
158
|
+
activeAnimations.set(nodeId, {
|
|
159
|
+
payload,
|
|
160
|
+
name,
|
|
161
|
+
duration,
|
|
162
|
+
// A re-render mid-loop keeps the phase; a fresh class starts at 0.
|
|
163
|
+
start: running !== undefined && running.name === name ? running.start : Date.now(),
|
|
164
|
+
});
|
|
165
|
+
ensureDriver();
|
|
166
|
+
queueOp(["setProps", nodeId, payload]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (activeAnimations.delete(nodeId))
|
|
170
|
+
stopDriverIfIdle();
|
|
171
|
+
// Property transitions.
|
|
172
|
+
const duration = typeof next.transitionDuration === "number" ? next.transitionDuration : 0;
|
|
173
|
+
const property = typeof next.transitionProperty === "string" ? next.transitionProperty : "default";
|
|
174
|
+
if (prev !== undefined && duration > 0 && property !== "none") {
|
|
175
|
+
const keys = transitionKeys(prev, next, property);
|
|
176
|
+
if (keys.length > 0) {
|
|
177
|
+
const from = {};
|
|
178
|
+
for (const key of keys)
|
|
179
|
+
from[key] = prev[key];
|
|
180
|
+
const delay = typeof next.transitionDelay === "number" ? next.transitionDelay : 0;
|
|
181
|
+
const easingName = String(next.transitionEasing ?? "ease-in-out");
|
|
182
|
+
activeTransitions.set(nodeId, {
|
|
183
|
+
payload,
|
|
184
|
+
from,
|
|
185
|
+
keys,
|
|
186
|
+
start: Date.now() + delay,
|
|
187
|
+
duration,
|
|
188
|
+
easing: easings[easingName] ?? easings["ease-in-out"],
|
|
189
|
+
});
|
|
190
|
+
ensureDriver();
|
|
191
|
+
// Land this commit at the old values so nothing jumps before frame 1.
|
|
192
|
+
send(nodeId, payload, from);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
activeTransitions.delete(nodeId);
|
|
197
|
+
queueOp(["setProps", nodeId, payload]);
|
|
198
|
+
}
|
|
199
|
+
/** Unmount cleanup — hostConfig.detachDeletedInstance. */
|
|
200
|
+
export function releaseNode(nodeId) {
|
|
201
|
+
lastStyles.delete(nodeId);
|
|
202
|
+
activeTransitions.delete(nodeId);
|
|
203
|
+
activeAnimations.delete(nodeId);
|
|
204
|
+
stopDriverIfIdle();
|
|
205
|
+
}
|
package/dist/tw.js
CHANGED
|
@@ -40,7 +40,13 @@ const staticClasses = {
|
|
|
40
40
|
"overflow-hidden": { overflow: "hidden" },
|
|
41
41
|
"overflow-visible": { overflow: "visible" },
|
|
42
42
|
"overflow-y-scroll": { overflow: "scroll" },
|
|
43
|
+
"overflow-x-scroll": { overflow: "scroll" },
|
|
43
44
|
"overflow-scroll": { overflow: "scroll" },
|
|
45
|
+
// overflow:"scroll" only engages the axes whose content actually
|
|
46
|
+
// overflows, so auto is the same key.
|
|
47
|
+
"overflow-auto": { overflow: "scroll" },
|
|
48
|
+
"overflow-y-auto": { overflow: "scroll" },
|
|
49
|
+
"overflow-x-auto": { overflow: "scroll" },
|
|
44
50
|
"w-full": { width: "100%" },
|
|
45
51
|
"h-full": { height: "100%" },
|
|
46
52
|
"text-left": { textAlign: "left" },
|
|
@@ -80,12 +86,42 @@ const staticClasses = {
|
|
|
80
86
|
"cursor-not-allowed": { cursor: "not-allowed" },
|
|
81
87
|
"pointer-events-none": { pointerEvents: "none" },
|
|
82
88
|
"pointer-events-auto": { pointerEvents: "auto" },
|
|
89
|
+
"select-text": { userSelect: "text" },
|
|
90
|
+
"select-none": { userSelect: "none" },
|
|
91
|
+
hidden: { display: "none" },
|
|
92
|
+
invisible: { visibility: "hidden" },
|
|
93
|
+
visible: { visibility: "visible" },
|
|
94
|
+
"origin-center": { transformOriginX: 50, transformOriginY: 50 },
|
|
95
|
+
"origin-top": { transformOriginX: 50, transformOriginY: 0 },
|
|
96
|
+
"origin-bottom": { transformOriginX: 50, transformOriginY: 100 },
|
|
97
|
+
"origin-left": { transformOriginX: 0, transformOriginY: 50 },
|
|
98
|
+
"origin-right": { transformOriginX: 100, transformOriginY: 50 },
|
|
99
|
+
"origin-top-left": { transformOriginX: 0, transformOriginY: 0 },
|
|
100
|
+
"origin-top-right": { transformOriginX: 100, transformOriginY: 0 },
|
|
101
|
+
"origin-bottom-left": { transformOriginX: 0, transformOriginY: 100 },
|
|
102
|
+
"origin-bottom-right": { transformOriginX: 100, transformOriginY: 100 },
|
|
83
103
|
"border-solid": { borderStyle: "solid" },
|
|
84
104
|
"border-dashed": { borderStyle: "dashed" },
|
|
85
105
|
"border-dotted": { borderStyle: "dotted" },
|
|
86
106
|
"object-contain": { objectFit: "contain" },
|
|
87
107
|
"object-cover": { objectFit: "cover" },
|
|
88
108
|
"object-fill": { objectFit: "fill" },
|
|
109
|
+
blur: { blurRadius: 8 },
|
|
110
|
+
"blur-none": { blurRadius: 0 },
|
|
111
|
+
"blur-sm": { blurRadius: 4 },
|
|
112
|
+
"blur-md": { blurRadius: 12 },
|
|
113
|
+
"blur-lg": { blurRadius: 16 },
|
|
114
|
+
"blur-xl": { blurRadius: 24 },
|
|
115
|
+
"blur-2xl": { blurRadius: 40 },
|
|
116
|
+
"blur-3xl": { blurRadius: 64 },
|
|
117
|
+
"backdrop-blur": { backdropBlurRadius: 8 },
|
|
118
|
+
"backdrop-blur-none": { backdropBlurRadius: 0 },
|
|
119
|
+
"backdrop-blur-sm": { backdropBlurRadius: 4 },
|
|
120
|
+
"backdrop-blur-md": { backdropBlurRadius: 12 },
|
|
121
|
+
"backdrop-blur-lg": { backdropBlurRadius: 16 },
|
|
122
|
+
"backdrop-blur-xl": { backdropBlurRadius: 24 },
|
|
123
|
+
"backdrop-blur-2xl": { backdropBlurRadius: 40 },
|
|
124
|
+
"backdrop-blur-3xl": { backdropBlurRadius: 64 },
|
|
89
125
|
shadow: { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
|
|
90
126
|
"shadow-sm": { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
|
|
91
127
|
"shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
|
|
@@ -106,6 +142,20 @@ const staticClasses = {
|
|
|
106
142
|
"bg-gradient-to-tl": { gradientType: "linear", gradientAngle: 315 },
|
|
107
143
|
"bg-gradient-radial": { gradientType: "radial" },
|
|
108
144
|
"bg-gradient-conic": { gradientType: "conic" },
|
|
145
|
+
transition: { transitionProperty: "default", transitionDuration: 150 },
|
|
146
|
+
"transition-all": { transitionProperty: "all", transitionDuration: 150 },
|
|
147
|
+
"transition-colors": { transitionProperty: "colors", transitionDuration: 150 },
|
|
148
|
+
"transition-opacity": { transitionProperty: "opacity", transitionDuration: 150 },
|
|
149
|
+
"transition-transform": { transitionProperty: "transform", transitionDuration: 150 },
|
|
150
|
+
"transition-none": { transitionProperty: "none" },
|
|
151
|
+
"ease-linear": { transitionEasing: "linear" },
|
|
152
|
+
"ease-in": { transitionEasing: "ease-in" },
|
|
153
|
+
"ease-out": { transitionEasing: "ease-out" },
|
|
154
|
+
"ease-in-out": { transitionEasing: "ease-in-out" },
|
|
155
|
+
"animate-spin": { animationName: "spin", animationDuration: 1000 },
|
|
156
|
+
"animate-pulse": { animationName: "pulse", animationDuration: 2000 },
|
|
157
|
+
"animate-bounce": { animationName: "bounce", animationDuration: 1000 },
|
|
158
|
+
"animate-none": { animationName: "none" },
|
|
109
159
|
};
|
|
110
160
|
const textSizes = {
|
|
111
161
|
xs: 12, sm: 14, base: 16, lg: 18, xl: 20, "2xl": 24, "3xl": 30, "4xl": 36,
|
|
@@ -309,6 +359,24 @@ function resolveClass(cls) {
|
|
|
309
359
|
const n = Number(rest);
|
|
310
360
|
return Number.isFinite(n) ? { opacity: n / 100 } : undefined;
|
|
311
361
|
}
|
|
362
|
+
case "blur": {
|
|
363
|
+
// Named sizes live in staticClasses; arbitrary is px: blur-[6].
|
|
364
|
+
if (rest.startsWith("[")) {
|
|
365
|
+
const radius = parseLength(rest);
|
|
366
|
+
if (typeof radius === "number")
|
|
367
|
+
return { blurRadius: radius };
|
|
368
|
+
}
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
case "backdrop": {
|
|
372
|
+
// backdrop-blur-[6] (named backdrop-blur-* sizes are static classes)
|
|
373
|
+
if (rest.startsWith("blur-[")) {
|
|
374
|
+
const radius = parseLength(rest.slice("blur-".length));
|
|
375
|
+
if (typeof radius === "number")
|
|
376
|
+
return { backdropBlurRadius: radius };
|
|
377
|
+
}
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
312
380
|
case "leading": {
|
|
313
381
|
if (rest.startsWith("[")) {
|
|
314
382
|
const px = parseLength(rest);
|
|
@@ -338,6 +406,19 @@ function resolveClass(cls) {
|
|
|
338
406
|
const n = Number(rest);
|
|
339
407
|
return Number.isFinite(n) ? { flex: n } : undefined;
|
|
340
408
|
}
|
|
409
|
+
case "duration": {
|
|
410
|
+
// Milliseconds, literal (duration-150, duration-[320]).
|
|
411
|
+
const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
412
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
|
|
413
|
+
? { transitionDuration: ms }
|
|
414
|
+
: undefined;
|
|
415
|
+
}
|
|
416
|
+
case "delay": {
|
|
417
|
+
const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
418
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
|
|
419
|
+
? { transitionDelay: ms }
|
|
420
|
+
: undefined;
|
|
421
|
+
}
|
|
341
422
|
case "z": {
|
|
342
423
|
const z = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
343
424
|
return typeof z === "number" && Number.isFinite(z) ? { zIndex: negate(z) } : undefined;
|
package/package.json
CHANGED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// 0.0.26 "honest gaps" round: selection opt-in, blur classes, transitions,
|
|
2
|
+
// animate-* presets, horizontal scroll surface.
|
|
3
|
+
|
|
4
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
|
|
7
|
+
const batches: unknown[][][] = [];
|
|
8
|
+
(globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
|
|
9
|
+
batches.push(JSON.parse(json));
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
import { render, unmount, View, Text } from "./index";
|
|
13
|
+
import { tw } from "./tw";
|
|
14
|
+
import {
|
|
15
|
+
animationDurations,
|
|
16
|
+
animationPresets,
|
|
17
|
+
interpolateStyle,
|
|
18
|
+
lerpHexColor,
|
|
19
|
+
parseHexColor,
|
|
20
|
+
transitionKeys,
|
|
21
|
+
} from "./transitions";
|
|
22
|
+
|
|
23
|
+
const allOps = () => batches.flat();
|
|
24
|
+
const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
|
|
25
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
unmount();
|
|
29
|
+
batches.length = 0;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("tw: 0.0.26 classes", () => {
|
|
33
|
+
test("overflow-x and auto variants map to overflow scroll", () => {
|
|
34
|
+
for (const cls of ["overflow-x-scroll", "overflow-x-auto", "overflow-auto", "overflow-y-auto"])
|
|
35
|
+
expect(tw(cls).style.overflow).toBe("scroll");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("select-text / select-none map to userSelect", () => {
|
|
39
|
+
expect(tw("select-text").style.userSelect).toBe("text");
|
|
40
|
+
expect(tw("select-none").style.userSelect).toBe("none");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("blur scale, arbitrary blur, and backdrop-blur", () => {
|
|
44
|
+
expect(tw("blur").style.blurRadius).toBe(8);
|
|
45
|
+
expect(tw("blur-sm").style.blurRadius).toBe(4);
|
|
46
|
+
expect(tw("blur-3xl").style.blurRadius).toBe(64);
|
|
47
|
+
expect(tw("blur-[6]").style.blurRadius).toBe(6);
|
|
48
|
+
expect(tw("backdrop-blur").style.backdropBlurRadius).toBe(8);
|
|
49
|
+
expect(tw("backdrop-blur-xl").style.backdropBlurRadius).toBe(24);
|
|
50
|
+
expect(tw("backdrop-blur-[10]").style.backdropBlurRadius).toBe(10);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("transition groups, duration, delay, easing", () => {
|
|
54
|
+
expect(tw("transition").style).toEqual({ transitionProperty: "default", transitionDuration: 150 });
|
|
55
|
+
expect(tw("transition-colors").style.transitionProperty).toBe("colors");
|
|
56
|
+
expect(tw("transition-none").style.transitionProperty).toBe("none");
|
|
57
|
+
expect(tw("duration-300").style.transitionDuration).toBe(300);
|
|
58
|
+
expect(tw("duration-[320]").style.transitionDuration).toBe(320);
|
|
59
|
+
expect(tw("delay-75").style.transitionDelay).toBe(75);
|
|
60
|
+
expect(tw("ease-out").style.transitionEasing).toBe("ease-out");
|
|
61
|
+
expect(tw("ease-in-out").style.transitionEasing).toBe("ease-in-out");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("animate presets carry name and default duration", () => {
|
|
65
|
+
expect(tw("animate-spin").style).toEqual({ animationName: "spin", animationDuration: 1000 });
|
|
66
|
+
expect(tw("animate-pulse").style.animationName).toBe("pulse");
|
|
67
|
+
expect(tw("animate-bounce").style.animationName).toBe("bounce");
|
|
68
|
+
expect(tw("animate-none").style.animationName).toBe("none");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("transitions: pure helpers", () => {
|
|
73
|
+
test("parseHexColor handles 6 and 8 digit hex only", () => {
|
|
74
|
+
expect(parseHexColor("#ff0080")).toEqual([255, 0, 128, 255]);
|
|
75
|
+
expect(parseHexColor("#ff008040")).toEqual([255, 0, 128, 64]);
|
|
76
|
+
expect(parseHexColor("red")).toBeUndefined();
|
|
77
|
+
expect(parseHexColor("#fff")).toBeUndefined();
|
|
78
|
+
expect(parseHexColor(12)).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("lerpHexColor midpoints and alpha promotion", () => {
|
|
82
|
+
expect(lerpHexColor("#000000", "#ffffff", 0.5)).toBe("#808080");
|
|
83
|
+
expect(lerpHexColor("#000000", "#ffffff", 0)).toBe("#000000");
|
|
84
|
+
expect(lerpHexColor("#00000000", "#000000", 1)).toBe("#000000ff");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("transitionKeys diffs only animatable changes in the group", () => {
|
|
88
|
+
const prev = { opacity: 0, backgroundColor: "#000000", width: 10, flexDirection: "row" };
|
|
89
|
+
const next = { opacity: 1, backgroundColor: "#ffffff", width: 20, flexDirection: "column" };
|
|
90
|
+
|
|
91
|
+
expect(transitionKeys(prev, next, "all").sort()).toEqual(["backgroundColor", "opacity", "width"]);
|
|
92
|
+
expect(transitionKeys(prev, next, "colors")).toEqual(["backgroundColor"]);
|
|
93
|
+
expect(transitionKeys(prev, next, "opacity")).toEqual(["opacity"]);
|
|
94
|
+
expect(transitionKeys(prev, next, "none")).toEqual([]);
|
|
95
|
+
// default group: opacity+colors+transform, not width
|
|
96
|
+
expect(transitionKeys(prev, next, "default").sort()).toEqual(["backgroundColor", "opacity"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("transitionKeys skips keys missing an endpoint or non-animatable", () => {
|
|
100
|
+
expect(transitionKeys({}, { opacity: 1 }, "all")).toEqual([]);
|
|
101
|
+
expect(transitionKeys({ translateX: "50%" }, { translateX: "0%" }, "all")).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("interpolateStyle blends numbers and colors", () => {
|
|
105
|
+
const patch = interpolateStyle(
|
|
106
|
+
{ opacity: 0, color: "#000000" },
|
|
107
|
+
{ opacity: 1, color: "#ffffff" },
|
|
108
|
+
["opacity", "color"],
|
|
109
|
+
0.5,
|
|
110
|
+
);
|
|
111
|
+
expect(patch.opacity).toBeCloseTo(0.5, 5);
|
|
112
|
+
expect(patch.color).toBe("#808080");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("presets: spin is linear rotation, pulse dips to 0.5, bounce returns home", () => {
|
|
116
|
+
expect(animationPresets.spin(0).rotate).toBe(0);
|
|
117
|
+
expect(animationPresets.spin(0.25).rotate).toBe(90);
|
|
118
|
+
expect(animationPresets.pulse(0).opacity).toBe(1);
|
|
119
|
+
expect(animationPresets.pulse(0.5).opacity).toBe(0.5);
|
|
120
|
+
expect(animationPresets.pulse(1).opacity).toBe(1);
|
|
121
|
+
expect(animationPresets.bounce(0).translateY).toBe("0.00%");
|
|
122
|
+
expect(String(animationPresets.bounce(0.5).translateY)).toBe("-25.00%");
|
|
123
|
+
expect(animationDurations.spin).toBe(1000);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("selection + scroll props through the bridge", () => {
|
|
128
|
+
test("<Text selectable> lands userSelect:'text' in the style payload", () => {
|
|
129
|
+
render(<Text selectable>copy me</Text>);
|
|
130
|
+
|
|
131
|
+
const create: any = opsNamed("create").find((op: any) => op[2] === "text");
|
|
132
|
+
const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
|
|
133
|
+
expect(props[2].style.userSelect).toBe("text");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("plain <Text> stays unselectable (no userSelect key)", () => {
|
|
137
|
+
render(<Text>plain</Text>);
|
|
138
|
+
|
|
139
|
+
const create: any = opsNamed("create").find((op: any) => op[2] === "text");
|
|
140
|
+
const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
|
|
141
|
+
expect(props[2].style.userSelect).toBeUndefined();
|
|
142
|
+
expect(props[2].selectable).toBeUndefined(); // consumed, not forwarded
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("scrollLeft passes through like scrollTop", () => {
|
|
146
|
+
render(<View className="overflow-x-scroll" scrollLeft={12} scrollTop={4} />);
|
|
147
|
+
|
|
148
|
+
const props: any = opsNamed("setProps")[0];
|
|
149
|
+
expect(props[2].scrollLeft).toBe(12);
|
|
150
|
+
expect(props[2].scrollTop).toBe(4);
|
|
151
|
+
expect(props[2].style.overflow).toBe("scroll");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("transition engine end-to-end", () => {
|
|
156
|
+
test("a style change with transitionDuration tweens instead of jumping", async () => {
|
|
157
|
+
function Fader() {
|
|
158
|
+
const [on, setOn] = useState(false);
|
|
159
|
+
return (
|
|
160
|
+
<View
|
|
161
|
+
onClick={() => setOn(true)}
|
|
162
|
+
style={{ opacity: on ? 1 : 0, transitionDuration: 60, transitionProperty: "all" }}
|
|
163
|
+
/>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
render(<Fader />);
|
|
168
|
+
|
|
169
|
+
const create: any = opsNamed("create")[0];
|
|
170
|
+
const nodeId = create[1];
|
|
171
|
+
|
|
172
|
+
(globalThis as Record<string, any>).__vsreact_dispatch(
|
|
173
|
+
JSON.stringify({ kind: "event", nodeId, type: "click" }),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
await sleep(160);
|
|
177
|
+
|
|
178
|
+
const styleOps = opsNamed("setProps")
|
|
179
|
+
.filter((op: any) => op[1] === nodeId)
|
|
180
|
+
.map((op: any) => op[2].style.opacity);
|
|
181
|
+
|
|
182
|
+
// The commit lands at the old value, frames climb, the last hits 1 exactly.
|
|
183
|
+
expect(styleOps[styleOps.length - 1]).toBe(1);
|
|
184
|
+
expect(styleOps.some((o: number) => o > 0 && o < 1)).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("animate-spin keeps emitting rotate frames until unmounted", async () => {
|
|
188
|
+
render(<View className="animate-spin" />);
|
|
189
|
+
|
|
190
|
+
const create: any = opsNamed("create")[0];
|
|
191
|
+
const nodeId = create[1];
|
|
192
|
+
|
|
193
|
+
await sleep(80);
|
|
194
|
+
unmount();
|
|
195
|
+
const count = opsNamed("setProps").filter(
|
|
196
|
+
(op: any) => op[1] === nodeId && typeof op[2].style.rotate === "number",
|
|
197
|
+
).length;
|
|
198
|
+
expect(count).toBeGreaterThan(2);
|
|
199
|
+
|
|
200
|
+
batches.length = 0;
|
|
201
|
+
await sleep(60);
|
|
202
|
+
// no frames after unmount
|
|
203
|
+
expect(opsNamed("setProps").filter((op: any) => op[1] === nodeId).length).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
});
|
package/src/hooks.ts
CHANGED
|
@@ -42,6 +42,30 @@ export function useNativeValue<T = any>(name: string, initial: T): T {
|
|
|
42
42
|
return value;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export interface RootSize {
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The editor's current size. The native side sends a "resize" event at
|
|
52
|
+
* mount (so this resolves on the first committed frame) and on every
|
|
53
|
+
* host resize — the foundation for resizable editors.
|
|
54
|
+
*/
|
|
55
|
+
export function useRootSize(): RootSize {
|
|
56
|
+
const [size, setSize] = useState<RootSize>({ width: 0, height: 0 });
|
|
57
|
+
|
|
58
|
+
useNativeEvent("resize", (payload) => {
|
|
59
|
+
const width = Number(payload?.width);
|
|
60
|
+
const height = Number(payload?.height);
|
|
61
|
+
|
|
62
|
+
if (Number.isFinite(width) && Number.isFinite(height))
|
|
63
|
+
setSize((s) => (s.width === width && s.height === height ? s : { width, height }));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return size;
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
/**
|
|
46
70
|
* The value, but only after it has stopped changing for `delayMs` —
|
|
47
71
|
* classic input debouncing for expensive native calls:
|
package/src/hostConfig.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { DefaultEventPriority } from "react-reconciler/constants";
|
|
5
5
|
import { queueOp, flushOps, setHandlers, removeHandlers, type EventHandler } from "./bridge";
|
|
6
|
+
import { commitProps, releaseNode } from "./transitions";
|
|
6
7
|
import { tw, type Style } from "./tw";
|
|
7
8
|
|
|
8
9
|
export interface Instance {
|
|
@@ -82,7 +83,8 @@ function normalizeProps(props: Record<string, unknown>): {
|
|
|
82
83
|
function applyProps(instance: Instance, props: Record<string, unknown>): void {
|
|
83
84
|
const { payload, handlers } = normalizeProps(props);
|
|
84
85
|
setHandlers(instance.id, handlers);
|
|
85
|
-
|
|
86
|
+
// The transition engine queues the op — tweened when the style asks for it.
|
|
87
|
+
commitProps(instance.id, payload);
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
export const hostConfig = {
|
|
@@ -157,6 +159,7 @@ export const hostConfig = {
|
|
|
157
159
|
|
|
158
160
|
detachDeletedInstance(instance: Instance): void {
|
|
159
161
|
removeHandlers(instance.id);
|
|
162
|
+
releaseNode(instance.id);
|
|
160
163
|
},
|
|
161
164
|
|
|
162
165
|
getCurrentEventPriority: () => DefaultEventPriority,
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import "./runtime";
|
|
|
4
4
|
import "./bridge";
|
|
5
5
|
|
|
6
6
|
/** The SDK version — stamp it on support dumps and analytics. */
|
|
7
|
-
export const VERSION = "0.0.
|
|
7
|
+
export const VERSION = "0.0.26";
|
|
8
8
|
|
|
9
9
|
export { View, Text, Image, TextInput, NativeView, Svg, SvgPath } from "./primitives";
|
|
10
10
|
export type {
|
|
@@ -21,6 +21,7 @@ export { native } from "./native";
|
|
|
21
21
|
export {
|
|
22
22
|
useNativeEvent,
|
|
23
23
|
useNativeValue,
|
|
24
|
+
useRootSize,
|
|
24
25
|
useDebounced,
|
|
25
26
|
useThrottled,
|
|
26
27
|
usePrevious,
|
|
@@ -29,6 +30,7 @@ export {
|
|
|
29
30
|
useHover,
|
|
30
31
|
useLayoutRect,
|
|
31
32
|
} from "./hooks";
|
|
33
|
+
export type { RootSize } from "./hooks";
|
|
32
34
|
export { useOverlay, OverlayLayer } from "./overlay";
|
|
33
35
|
export { Tooltip, Modal } from "./popover";
|
|
34
36
|
export type { TooltipProps, ModalProps } from "./popover";
|
package/src/layout.test.tsx
CHANGED
|
@@ -148,3 +148,22 @@ describe("Svg primitives (0.0.24)", () => {
|
|
|
148
148
|
expect(pathProps[2]).toMatchObject({ d: "M2 12 H22", fill: "none", stroke: "#00ff00", strokeWidth: 2 });
|
|
149
149
|
});
|
|
150
150
|
});
|
|
151
|
+
|
|
152
|
+
describe("useRootSize (0.0.25)", () => {
|
|
153
|
+
test("tracks resize events, ignores junk", async () => {
|
|
154
|
+
const { useRootSize } = require("./index");
|
|
155
|
+
let seen: any = null;
|
|
156
|
+
function Probe() {
|
|
157
|
+
seen = useRootSize();
|
|
158
|
+
return <View />;
|
|
159
|
+
}
|
|
160
|
+
render(<Probe />);
|
|
161
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
162
|
+
|
|
163
|
+
expect(seen).toEqual({ width: 0, height: 0 });
|
|
164
|
+
dispatch({ kind: "native", name: "resize", payload: { width: 793, height: 496 } });
|
|
165
|
+
expect(seen).toEqual({ width: 793, height: 496 });
|
|
166
|
+
dispatch({ kind: "native", name: "resize", payload: { width: "nope" } });
|
|
167
|
+
expect(seen).toEqual({ width: 793, height: 496 });
|
|
168
|
+
});
|
|
169
|
+
});
|
package/src/primitives.tsx
CHANGED
|
@@ -18,10 +18,11 @@ export interface LayoutRect {
|
|
|
18
18
|
height: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** Mouse-wheel payload.
|
|
22
|
-
positive = wheel up). */
|
|
21
|
+
/** Mouse-wheel payload. Deltas are JUCE's notch fraction (~0.1 per notch,
|
|
22
|
+
dy positive = wheel up; dx nonzero on trackpads/tilt wheels). */
|
|
23
23
|
export interface WheelEventPayload {
|
|
24
24
|
dy: number;
|
|
25
|
+
dx: number;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/** Web-style key event: key names match KeyboardEvent.key ("ArrowUp",
|
|
@@ -44,8 +45,10 @@ export interface CommonProps {
|
|
|
44
45
|
className?: string;
|
|
45
46
|
style?: Style;
|
|
46
47
|
children?: ReactNode;
|
|
47
|
-
/** Resets the scroll offset of an overflow-
|
|
48
|
+
/** Resets the vertical scroll offset of an overflow-scroll container. */
|
|
48
49
|
scrollTop?: number;
|
|
50
|
+
/** Resets the horizontal scroll offset of an overflow-scroll container. */
|
|
51
|
+
scrollLeft?: number;
|
|
49
52
|
onClick?: () => void;
|
|
50
53
|
onDoubleClick?: () => void;
|
|
51
54
|
/** Wheel over this node (controls win the wheel over scroll containers). */
|
|
@@ -77,9 +80,14 @@ export function View(props: CommonProps) {
|
|
|
77
80
|
|
|
78
81
|
export interface TextProps extends Omit<CommonProps, "children"> {
|
|
79
82
|
children?: ReactNode; // strings/numbers (and fragments of them)
|
|
83
|
+
/** Opt this text into selection (drag to select, double-click for a word,
|
|
84
|
+
Ctrl/Cmd+C copies). Text is NOT selectable by default. Equivalent to
|
|
85
|
+
the `select-text` class / `userSelect: "text"` style key. */
|
|
86
|
+
selectable?: boolean;
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
export function Text(props: TextProps) {
|
|
89
|
+
export function Text({ selectable, ...props }: TextProps) {
|
|
90
|
+
if (selectable) props = { ...props, style: { userSelect: "text", ...props.style } };
|
|
83
91
|
return createElement("vs-text", props);
|
|
84
92
|
}
|
|
85
93
|
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// CSS transitions and keyframe presets, driven from JS. When a commit changes
|
|
2
|
+
// a node whose style carries transitionDuration, the changed animatable keys
|
|
3
|
+
// tween from their *currently displayed* values instead of jumping; animate-*
|
|
4
|
+
// classes run keyframe loops. Every frame re-sends the node's full payload
|
|
5
|
+
// (setProps replaces wholesale) with the animated keys patched — the same
|
|
6
|
+
// traffic a useTween re-render produces, without the component code.
|
|
7
|
+
//
|
|
8
|
+
// Honest limit: native-side hover/active/focus style merges don't transition
|
|
9
|
+
// (they never round-trip through JS). Animate those with onMouseEnter state.
|
|
10
|
+
|
|
11
|
+
import { queueOp, flushOps } from "./bridge";
|
|
12
|
+
import { Easing, type EasingFn } from "./animation";
|
|
13
|
+
import type { Style, StyleValue } from "./tw";
|
|
14
|
+
|
|
15
|
+
type Payload = Record<string, unknown> & { style?: Style };
|
|
16
|
+
|
|
17
|
+
export type TransitionEasingName = "linear" | "ease-in" | "ease-out" | "ease-in-out";
|
|
18
|
+
|
|
19
|
+
const easings: Record<TransitionEasingName, EasingFn> = {
|
|
20
|
+
linear: Easing.linear,
|
|
21
|
+
"ease-in": (t) => t * t * t,
|
|
22
|
+
"ease-out": Easing.outCubic,
|
|
23
|
+
"ease-in-out": Easing.inOutCubic,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Keys each transitionProperty group may animate. "colors" is any *Color/
|
|
27
|
+
color key; "all" is every animatable difference. */
|
|
28
|
+
const transformKeys = ["rotate", "scale", "translateX", "translateY"];
|
|
29
|
+
const defaultKeys = [
|
|
30
|
+
"color", "backgroundColor", "borderColor", "opacity",
|
|
31
|
+
...transformKeys, "blurRadius", "backdropBlurRadius",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function keysForProperty(property: string, candidates: string[]): string[] {
|
|
35
|
+
switch (property) {
|
|
36
|
+
case "all":
|
|
37
|
+
return candidates;
|
|
38
|
+
case "colors":
|
|
39
|
+
return candidates.filter((k) => /color/i.test(k));
|
|
40
|
+
case "opacity":
|
|
41
|
+
return candidates.filter((k) => k === "opacity");
|
|
42
|
+
case "transform":
|
|
43
|
+
return candidates.filter((k) => transformKeys.includes(k));
|
|
44
|
+
case "none":
|
|
45
|
+
return [];
|
|
46
|
+
default:
|
|
47
|
+
return candidates.filter((k) => defaultKeys.includes(k));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── value interpolation ────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/** "#rrggbb" or "#rrggbbaa" → [r,g,b,a]; undefined for anything else. */
|
|
54
|
+
export function parseHexColor(value: unknown): [number, number, number, number] | undefined {
|
|
55
|
+
if (typeof value !== "string" || !/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(value)) return undefined;
|
|
56
|
+
const n = (i: number) => parseInt(value.slice(i, i + 2), 16);
|
|
57
|
+
return [n(1), n(3), n(5), value.length === 9 ? n(7) : 255];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function lerpHexColor(from: string, to: string, t: number): string {
|
|
61
|
+
const a = parseHexColor(from)!;
|
|
62
|
+
const b = parseHexColor(to)!;
|
|
63
|
+
const hex = (i: number) =>
|
|
64
|
+
Math.round(a[i] + (b[i] - a[i]) * t)
|
|
65
|
+
.toString(16)
|
|
66
|
+
.padStart(2, "0");
|
|
67
|
+
const alpha = a[3] !== 255 || b[3] !== 255 ? hex(3) : "";
|
|
68
|
+
return `#${hex(0)}${hex(1)}${hex(2)}${alpha}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isAnimatablePair(from: StyleValue | undefined, to: StyleValue | undefined): boolean {
|
|
72
|
+
if (typeof from === "number" && typeof to === "number") return Number.isFinite(from) && Number.isFinite(to);
|
|
73
|
+
return parseHexColor(from) !== undefined && parseHexColor(to) !== undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The animatable keys that differ between two styles, honoring the
|
|
77
|
+
transitionProperty group. Exported for tests. */
|
|
78
|
+
export function transitionKeys(prev: Style, next: Style, property: string): string[] {
|
|
79
|
+
const candidates = Object.keys(next).filter(
|
|
80
|
+
(key) => prev[key] !== next[key] && isAnimatablePair(prev[key], next[key]),
|
|
81
|
+
);
|
|
82
|
+
return keysForProperty(property, candidates);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Interpolated patch for the animated keys at eased progress t. Exported
|
|
86
|
+
for tests. */
|
|
87
|
+
export function interpolateStyle(from: Style, to: Style, keys: string[], t: number): Style {
|
|
88
|
+
const patch: Style = {};
|
|
89
|
+
for (const key of keys) {
|
|
90
|
+
const a = from[key];
|
|
91
|
+
const b = to[key];
|
|
92
|
+
patch[key] =
|
|
93
|
+
typeof a === "number" && typeof b === "number"
|
|
94
|
+
? a + (b - a) * t
|
|
95
|
+
: lerpHexColor(String(a), String(b), t);
|
|
96
|
+
}
|
|
97
|
+
return patch;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── keyframe presets (animate-*) ───────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** Style patch at loop phase t (0→1). Exported for tests. */
|
|
103
|
+
export const animationPresets: Record<string, (t: number) => Style> = {
|
|
104
|
+
spin: (t) => ({ rotate: 360 * t }),
|
|
105
|
+
pulse: (t) => ({ opacity: t < 0.5 ? 1 - t : t }), // 1 → 0.5 → 1
|
|
106
|
+
bounce: (t) => {
|
|
107
|
+
// dip up and settle, roughly the web's bounce
|
|
108
|
+
const k = t < 0.5 ? Easing.outCubic(t * 2) : 1 - Easing.inOutCubic((t - 0.5) * 2);
|
|
109
|
+
return { translateY: `${(-25 * k).toFixed(2)}%` };
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const animationDurations: Record<string, number> = {
|
|
114
|
+
spin: 1000,
|
|
115
|
+
pulse: 2000,
|
|
116
|
+
bounce: 1000,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ── the shared frame driver ────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
interface ActiveTransition {
|
|
122
|
+
payload: Payload;
|
|
123
|
+
from: Style;
|
|
124
|
+
keys: string[];
|
|
125
|
+
start: number;
|
|
126
|
+
duration: number;
|
|
127
|
+
easing: EasingFn;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface ActiveAnimation {
|
|
131
|
+
payload: Payload;
|
|
132
|
+
name: string;
|
|
133
|
+
start: number;
|
|
134
|
+
duration: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const FRAME_MS = 16;
|
|
138
|
+
const lastStyles = new Map<number, Style>();
|
|
139
|
+
const activeTransitions = new Map<number, ActiveTransition>();
|
|
140
|
+
const activeAnimations = new Map<number, ActiveAnimation>();
|
|
141
|
+
let driver: ReturnType<typeof setInterval> | undefined;
|
|
142
|
+
|
|
143
|
+
function ensureDriver(): void {
|
|
144
|
+
if (driver !== undefined) return;
|
|
145
|
+
driver = setInterval(tick, FRAME_MS);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stopDriverIfIdle(): void {
|
|
149
|
+
if (driver !== undefined && activeTransitions.size === 0 && activeAnimations.size === 0) {
|
|
150
|
+
clearInterval(driver);
|
|
151
|
+
driver = undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function send(nodeId: number, payload: Payload, patch: Style): void {
|
|
156
|
+
queueOp(["setProps", nodeId, { ...payload, style: { ...payload.style, ...patch } }]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function tick(): void {
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
|
|
162
|
+
for (const [nodeId, tr] of [...activeTransitions]) {
|
|
163
|
+
const t = Math.min(1, Math.max(0, (now - tr.start) / tr.duration));
|
|
164
|
+
const target = tr.payload.style ?? {};
|
|
165
|
+
send(nodeId, tr.payload, interpolateStyle(tr.from, target, tr.keys, tr.easing(t)));
|
|
166
|
+
|
|
167
|
+
if (t >= 1) {
|
|
168
|
+
activeTransitions.delete(nodeId);
|
|
169
|
+
lastStyles.set(nodeId, target);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const [nodeId, anim] of activeAnimations) {
|
|
174
|
+
const phase = ((now - anim.start) % anim.duration) / anim.duration;
|
|
175
|
+
send(nodeId, anim.payload, animationPresets[anim.name](phase));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
flushOps();
|
|
179
|
+
stopDriverIfIdle();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The currently displayed style for a node (mid-flight interpolation when a
|
|
183
|
+
transition is running, else the last committed style). */
|
|
184
|
+
function displayedStyle(nodeId: number): Style | undefined {
|
|
185
|
+
const tr = activeTransitions.get(nodeId);
|
|
186
|
+
if (!tr) return lastStyles.get(nodeId);
|
|
187
|
+
const t = Math.min(1, Math.max(0, (Date.now() - tr.start) / tr.duration));
|
|
188
|
+
return { ...tr.payload.style, ...interpolateStyle(tr.from, tr.payload.style ?? {}, tr.keys, tr.easing(t)) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** hostConfig's single entry point: queue this commit's setProps, animated
|
|
192
|
+
when the style asks for it. */
|
|
193
|
+
export function commitProps(nodeId: number, payload: Payload): void {
|
|
194
|
+
const next = payload.style ?? {};
|
|
195
|
+
const prev = displayedStyle(nodeId);
|
|
196
|
+
lastStyles.set(nodeId, next);
|
|
197
|
+
|
|
198
|
+
// Keyframe preset loops (animate-*).
|
|
199
|
+
const name = typeof next.animationName === "string" ? next.animationName : undefined;
|
|
200
|
+
|
|
201
|
+
if (name !== undefined && animationPresets[name] !== undefined) {
|
|
202
|
+
const running = activeAnimations.get(nodeId);
|
|
203
|
+
const duration =
|
|
204
|
+
typeof next.animationDuration === "number" && next.animationDuration > 0
|
|
205
|
+
? next.animationDuration
|
|
206
|
+
: animationDurations[name];
|
|
207
|
+
|
|
208
|
+
activeAnimations.set(nodeId, {
|
|
209
|
+
payload,
|
|
210
|
+
name,
|
|
211
|
+
duration,
|
|
212
|
+
// A re-render mid-loop keeps the phase; a fresh class starts at 0.
|
|
213
|
+
start: running !== undefined && running.name === name ? running.start : Date.now(),
|
|
214
|
+
});
|
|
215
|
+
ensureDriver();
|
|
216
|
+
queueOp(["setProps", nodeId, payload]);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (activeAnimations.delete(nodeId)) stopDriverIfIdle();
|
|
221
|
+
|
|
222
|
+
// Property transitions.
|
|
223
|
+
const duration = typeof next.transitionDuration === "number" ? next.transitionDuration : 0;
|
|
224
|
+
const property = typeof next.transitionProperty === "string" ? next.transitionProperty : "default";
|
|
225
|
+
|
|
226
|
+
if (prev !== undefined && duration > 0 && property !== "none") {
|
|
227
|
+
const keys = transitionKeys(prev, next, property);
|
|
228
|
+
|
|
229
|
+
if (keys.length > 0) {
|
|
230
|
+
const from: Style = {};
|
|
231
|
+
for (const key of keys) from[key] = prev[key];
|
|
232
|
+
|
|
233
|
+
const delay = typeof next.transitionDelay === "number" ? next.transitionDelay : 0;
|
|
234
|
+
const easingName = String(next.transitionEasing ?? "ease-in-out") as TransitionEasingName;
|
|
235
|
+
|
|
236
|
+
activeTransitions.set(nodeId, {
|
|
237
|
+
payload,
|
|
238
|
+
from,
|
|
239
|
+
keys,
|
|
240
|
+
start: Date.now() + delay,
|
|
241
|
+
duration,
|
|
242
|
+
easing: easings[easingName] ?? easings["ease-in-out"],
|
|
243
|
+
});
|
|
244
|
+
ensureDriver();
|
|
245
|
+
|
|
246
|
+
// Land this commit at the old values so nothing jumps before frame 1.
|
|
247
|
+
send(nodeId, payload, from);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
activeTransitions.delete(nodeId);
|
|
253
|
+
queueOp(["setProps", nodeId, payload]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Unmount cleanup — hostConfig.detachDeletedInstance. */
|
|
257
|
+
export function releaseNode(nodeId: number): void {
|
|
258
|
+
lastStyles.delete(nodeId);
|
|
259
|
+
activeTransitions.delete(nodeId);
|
|
260
|
+
activeAnimations.delete(nodeId);
|
|
261
|
+
stopDriverIfIdle();
|
|
262
|
+
}
|
package/src/tw.test.ts
CHANGED
|
@@ -262,3 +262,17 @@ describe("input & media classes (0.0.23)", () => {
|
|
|
262
262
|
expect(tw("cursor-grab").style).toEqual({ cursor: "grab" });
|
|
263
263
|
});
|
|
264
264
|
});
|
|
265
|
+
|
|
266
|
+
describe("layout & robustness classes (0.0.25)", () => {
|
|
267
|
+
test("hidden vs invisible", () => {
|
|
268
|
+
expect(tw("hidden").style).toEqual({ display: "none" });
|
|
269
|
+
expect(tw("invisible").style).toEqual({ visibility: "hidden" });
|
|
270
|
+
expect(tw("visible").style).toEqual({ visibility: "visible" });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("percent translate and transform origin", () => {
|
|
274
|
+
expect(tw("translate-x-1/2").style).toEqual({ translateX: "50%" });
|
|
275
|
+
expect(tw("-translate-y-1/2").style).toEqual({ translateY: "-50%" });
|
|
276
|
+
expect(tw("origin-top-left").style).toEqual({ transformOriginX: 0, transformOriginY: 0 });
|
|
277
|
+
});
|
|
278
|
+
});
|
package/src/tw.ts
CHANGED
|
@@ -51,7 +51,13 @@ const staticClasses: Record<string, Style> = {
|
|
|
51
51
|
"overflow-hidden": { overflow: "hidden" },
|
|
52
52
|
"overflow-visible": { overflow: "visible" },
|
|
53
53
|
"overflow-y-scroll": { overflow: "scroll" },
|
|
54
|
+
"overflow-x-scroll": { overflow: "scroll" },
|
|
54
55
|
"overflow-scroll": { overflow: "scroll" },
|
|
56
|
+
// overflow:"scroll" only engages the axes whose content actually
|
|
57
|
+
// overflows, so auto is the same key.
|
|
58
|
+
"overflow-auto": { overflow: "scroll" },
|
|
59
|
+
"overflow-y-auto": { overflow: "scroll" },
|
|
60
|
+
"overflow-x-auto": { overflow: "scroll" },
|
|
55
61
|
"w-full": { width: "100%" },
|
|
56
62
|
"h-full": { height: "100%" },
|
|
57
63
|
"text-left": { textAlign: "left" },
|
|
@@ -91,12 +97,42 @@ const staticClasses: Record<string, Style> = {
|
|
|
91
97
|
"cursor-not-allowed": { cursor: "not-allowed" },
|
|
92
98
|
"pointer-events-none": { pointerEvents: "none" },
|
|
93
99
|
"pointer-events-auto": { pointerEvents: "auto" },
|
|
100
|
+
"select-text": { userSelect: "text" },
|
|
101
|
+
"select-none": { userSelect: "none" },
|
|
102
|
+
hidden: { display: "none" },
|
|
103
|
+
invisible: { visibility: "hidden" },
|
|
104
|
+
visible: { visibility: "visible" },
|
|
105
|
+
"origin-center": { transformOriginX: 50, transformOriginY: 50 },
|
|
106
|
+
"origin-top": { transformOriginX: 50, transformOriginY: 0 },
|
|
107
|
+
"origin-bottom": { transformOriginX: 50, transformOriginY: 100 },
|
|
108
|
+
"origin-left": { transformOriginX: 0, transformOriginY: 50 },
|
|
109
|
+
"origin-right": { transformOriginX: 100, transformOriginY: 50 },
|
|
110
|
+
"origin-top-left": { transformOriginX: 0, transformOriginY: 0 },
|
|
111
|
+
"origin-top-right": { transformOriginX: 100, transformOriginY: 0 },
|
|
112
|
+
"origin-bottom-left": { transformOriginX: 0, transformOriginY: 100 },
|
|
113
|
+
"origin-bottom-right": { transformOriginX: 100, transformOriginY: 100 },
|
|
94
114
|
"border-solid": { borderStyle: "solid" },
|
|
95
115
|
"border-dashed": { borderStyle: "dashed" },
|
|
96
116
|
"border-dotted": { borderStyle: "dotted" },
|
|
97
117
|
"object-contain": { objectFit: "contain" },
|
|
98
118
|
"object-cover": { objectFit: "cover" },
|
|
99
119
|
"object-fill": { objectFit: "fill" },
|
|
120
|
+
blur: { blurRadius: 8 },
|
|
121
|
+
"blur-none": { blurRadius: 0 },
|
|
122
|
+
"blur-sm": { blurRadius: 4 },
|
|
123
|
+
"blur-md": { blurRadius: 12 },
|
|
124
|
+
"blur-lg": { blurRadius: 16 },
|
|
125
|
+
"blur-xl": { blurRadius: 24 },
|
|
126
|
+
"blur-2xl": { blurRadius: 40 },
|
|
127
|
+
"blur-3xl": { blurRadius: 64 },
|
|
128
|
+
"backdrop-blur": { backdropBlurRadius: 8 },
|
|
129
|
+
"backdrop-blur-none": { backdropBlurRadius: 0 },
|
|
130
|
+
"backdrop-blur-sm": { backdropBlurRadius: 4 },
|
|
131
|
+
"backdrop-blur-md": { backdropBlurRadius: 12 },
|
|
132
|
+
"backdrop-blur-lg": { backdropBlurRadius: 16 },
|
|
133
|
+
"backdrop-blur-xl": { backdropBlurRadius: 24 },
|
|
134
|
+
"backdrop-blur-2xl": { backdropBlurRadius: 40 },
|
|
135
|
+
"backdrop-blur-3xl": { backdropBlurRadius: 64 },
|
|
100
136
|
shadow: { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
|
|
101
137
|
"shadow-sm": { shadowColor: "#00000066", shadowRadius: 4, shadowOffsetY: 1 },
|
|
102
138
|
"shadow-md": { shadowColor: "#00000066", shadowRadius: 8, shadowOffsetY: 2 },
|
|
@@ -117,6 +153,20 @@ const staticClasses: Record<string, Style> = {
|
|
|
117
153
|
"bg-gradient-to-tl": { gradientType: "linear", gradientAngle: 315 },
|
|
118
154
|
"bg-gradient-radial": { gradientType: "radial" },
|
|
119
155
|
"bg-gradient-conic": { gradientType: "conic" },
|
|
156
|
+
transition: { transitionProperty: "default", transitionDuration: 150 },
|
|
157
|
+
"transition-all": { transitionProperty: "all", transitionDuration: 150 },
|
|
158
|
+
"transition-colors": { transitionProperty: "colors", transitionDuration: 150 },
|
|
159
|
+
"transition-opacity": { transitionProperty: "opacity", transitionDuration: 150 },
|
|
160
|
+
"transition-transform": { transitionProperty: "transform", transitionDuration: 150 },
|
|
161
|
+
"transition-none": { transitionProperty: "none" },
|
|
162
|
+
"ease-linear": { transitionEasing: "linear" },
|
|
163
|
+
"ease-in": { transitionEasing: "ease-in" },
|
|
164
|
+
"ease-out": { transitionEasing: "ease-out" },
|
|
165
|
+
"ease-in-out": { transitionEasing: "ease-in-out" },
|
|
166
|
+
"animate-spin": { animationName: "spin", animationDuration: 1000 },
|
|
167
|
+
"animate-pulse": { animationName: "pulse", animationDuration: 2000 },
|
|
168
|
+
"animate-bounce": { animationName: "bounce", animationDuration: 1000 },
|
|
169
|
+
"animate-none": { animationName: "none" },
|
|
120
170
|
};
|
|
121
171
|
|
|
122
172
|
const textSizes: Record<string, number> = {
|
|
@@ -317,6 +367,22 @@ function resolveClass(cls: string): Style | undefined {
|
|
|
317
367
|
const n = Number(rest);
|
|
318
368
|
return Number.isFinite(n) ? { opacity: n / 100 } : undefined;
|
|
319
369
|
}
|
|
370
|
+
case "blur": {
|
|
371
|
+
// Named sizes live in staticClasses; arbitrary is px: blur-[6].
|
|
372
|
+
if (rest.startsWith("[")) {
|
|
373
|
+
const radius = parseLength(rest);
|
|
374
|
+
if (typeof radius === "number") return { blurRadius: radius };
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
case "backdrop": {
|
|
379
|
+
// backdrop-blur-[6] (named backdrop-blur-* sizes are static classes)
|
|
380
|
+
if (rest.startsWith("blur-[")) {
|
|
381
|
+
const radius = parseLength(rest.slice("blur-".length));
|
|
382
|
+
if (typeof radius === "number") return { backdropBlurRadius: radius };
|
|
383
|
+
}
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
320
386
|
case "leading": {
|
|
321
387
|
if (rest.startsWith("[")) {
|
|
322
388
|
const px = parseLength(rest);
|
|
@@ -345,6 +411,19 @@ function resolveClass(cls: string): Style | undefined {
|
|
|
345
411
|
const n = Number(rest);
|
|
346
412
|
return Number.isFinite(n) ? { flex: n } : undefined;
|
|
347
413
|
}
|
|
414
|
+
case "duration": {
|
|
415
|
+
// Milliseconds, literal (duration-150, duration-[320]).
|
|
416
|
+
const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
417
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
|
|
418
|
+
? { transitionDuration: ms }
|
|
419
|
+
: undefined;
|
|
420
|
+
}
|
|
421
|
+
case "delay": {
|
|
422
|
+
const ms = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
423
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms >= 0
|
|
424
|
+
? { transitionDelay: ms }
|
|
425
|
+
: undefined;
|
|
426
|
+
}
|
|
348
427
|
case "z": {
|
|
349
428
|
const z = rest.startsWith("[") ? parseLength(rest) : Number(rest);
|
|
350
429
|
return typeof z === "number" && Number.isFinite(z) ? { zIndex: negate(z) } : undefined;
|