@seed-design/lynx-react 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/Field/Field.d.ts +47 -0
- package/lib/components/Field/Field.d.ts.map +1 -0
- package/lib/components/Field/Field.jsx +121 -0
- package/lib/components/Field/Field.namespace.d.ts +2 -0
- package/lib/components/Field/Field.namespace.d.ts.map +1 -0
- package/lib/components/Field/Field.namespace.js +16 -0
- package/lib/components/Field/context.d.ts +19 -0
- package/lib/components/Field/context.d.ts.map +1 -0
- package/lib/components/Field/context.js +10 -0
- package/lib/components/Field/index.d.ts +3 -0
- package/lib/components/Field/index.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts +42 -0
- package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.jsx +140 -0
- package/lib/components/KeyboardAvoidingScrollView/context.d.ts +17 -0
- package/lib/components/KeyboardAvoidingScrollView/context.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/context.js +12 -0
- package/lib/components/KeyboardAvoidingScrollView/engine.d.ts +50 -0
- package/lib/components/KeyboardAvoidingScrollView/engine.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/engine.js +259 -0
- package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts +50 -0
- package/lib/components/KeyboardAvoidingScrollView/geometry.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/geometry.js +90 -0
- package/lib/components/KeyboardAvoidingScrollView/index.d.ts +2 -0
- package/lib/components/KeyboardAvoidingScrollView/index.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts +11 -0
- package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/keyboard-event-source.js +43 -0
- package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts +34 -0
- package/lib/components/KeyboardAvoidingScrollView/native-driver.d.ts.map +1 -0
- package/lib/components/KeyboardAvoidingScrollView/native-driver.js +133 -0
- package/lib/components/ProgressCircle/ProgressCircle.d.ts.map +1 -1
- package/lib/components/ProgressCircle/ProgressCircle.jsx +36 -5
- package/lib/components/Stack/Stack.d.ts.map +1 -1
- package/lib/components/Stack/Stack.jsx +10 -1
- package/lib/components/TagGroup/TagGroup.d.ts +2 -1
- package/lib/components/TagGroup/TagGroup.d.ts.map +1 -1
- package/lib/components/TagGroup/TagGroup.jsx +2 -1
- package/lib/components/TextField/TextField.d.ts +131 -0
- package/lib/components/TextField/TextField.d.ts.map +1 -0
- package/lib/components/TextField/TextField.jsx +434 -0
- package/lib/components/TextField/TextField.namespace.d.ts +2 -0
- package/lib/components/TextField/TextField.namespace.d.ts.map +1 -0
- package/lib/components/TextField/TextField.namespace.js +14 -0
- package/lib/components/TextField/context.d.ts +22 -0
- package/lib/components/TextField/context.d.ts.map +1 -0
- package/lib/components/TextField/context.js +7 -0
- package/lib/components/TextField/index.d.ts +4 -0
- package/lib/components/TextField/index.d.ts.map +1 -0
- package/lib/components/TextField/useTextFieldWithGraphemes.d.ts +24 -0
- package/lib/components/TextField/useTextFieldWithGraphemes.d.ts.map +1 -0
- package/lib/components/TextField/useTextFieldWithGraphemes.js +47 -0
- package/lib/components/index.d.ts +3 -0
- package/lib/components/index.d.ts.map +1 -1
- package/lib/index.js +7 -1
- package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_grapheme_data.js +8 -0
- package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/_incb_data.js +14 -0
- package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/core.js +68 -0
- package/lib/node_modules/.bun/unicode-segmenter@0.14.5/node_modules/unicode-segmenter/grapheme.js +175 -0
- package/package.json +7 -5
- package/src/components/Field/Field.namespace.ts +19 -0
- package/src/components/Field/Field.test.tsx +81 -0
- package/src/components/Field/Field.tsx +252 -0
- package/src/components/Field/context.ts +26 -0
- package/src/components/Field/index.ts +21 -0
- package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.test.tsx +267 -0
- package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.tsx +299 -0
- package/src/components/KeyboardAvoidingScrollView/context.ts +24 -0
- package/src/components/KeyboardAvoidingScrollView/engine.test.ts +451 -0
- package/src/components/KeyboardAvoidingScrollView/engine.ts +417 -0
- package/src/components/KeyboardAvoidingScrollView/geometry.test.ts +315 -0
- package/src/components/KeyboardAvoidingScrollView/geometry.ts +194 -0
- package/src/components/KeyboardAvoidingScrollView/index.ts +5 -0
- package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.test.ts +122 -0
- package/src/components/KeyboardAvoidingScrollView/keyboard-event-source.ts +75 -0
- package/src/components/KeyboardAvoidingScrollView/native-driver.test.ts +223 -0
- package/src/components/KeyboardAvoidingScrollView/native-driver.ts +248 -0
- package/src/components/ProgressCircle/ProgressCircle.test.tsx +58 -0
- package/src/components/ProgressCircle/ProgressCircle.tsx +42 -5
- package/src/components/Stack/Stack.test.tsx +67 -0
- package/src/components/Stack/Stack.tsx +15 -1
- package/src/components/TagGroup/TagGroup.test.tsx +73 -0
- package/src/components/TagGroup/TagGroup.tsx +4 -2
- package/src/components/TextField/TextField.namespace.ts +16 -0
- package/src/components/TextField/TextField.test.tsx +1052 -0
- package/src/components/TextField/TextField.tsx +954 -0
- package/src/components/TextField/context.ts +33 -0
- package/src/components/TextField/index.ts +23 -0
- package/src/components/TextField/useTextFieldWithGraphemes.test.tsx +80 -0
- package/src/components/TextField/useTextFieldWithGraphemes.ts +78 -0
- package/src/components/index.ts +3 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { NodesRef } from "@lynx-js/types";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
lynxKeyboardAvoidingNativeDriver,
|
|
6
|
+
normalizeScrollMetrics,
|
|
7
|
+
resolveKeyboardOcclusionTop,
|
|
8
|
+
} from "./native-driver";
|
|
9
|
+
|
|
10
|
+
interface TestGlobal {
|
|
11
|
+
SystemInfo?: {
|
|
12
|
+
pixelHeight?: number;
|
|
13
|
+
pixelRatio?: number;
|
|
14
|
+
platform?: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const originalSystemInfo = (globalThis as TestGlobal).SystemInfo;
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
const global = globalThis as TestGlobal;
|
|
22
|
+
if (originalSystemInfo === undefined) {
|
|
23
|
+
delete global.SystemInfo;
|
|
24
|
+
} else {
|
|
25
|
+
global.SystemInfo = originalSystemInfo;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function createNode(onInvoke?: (options: Parameters<NodesRef["invoke"]>[0]) => void) {
|
|
30
|
+
const exec = vi.fn();
|
|
31
|
+
const query = { exec } as unknown as ReturnType<NodesRef["invoke"]>;
|
|
32
|
+
const invoke = vi.fn((options: Parameters<NodesRef["invoke"]>[0]) => {
|
|
33
|
+
onInvoke?.(options);
|
|
34
|
+
return query;
|
|
35
|
+
});
|
|
36
|
+
const setNativeProps = vi.fn(() => query);
|
|
37
|
+
const node = { invoke, setNativeProps } as unknown as NodesRef;
|
|
38
|
+
|
|
39
|
+
return { exec, invoke, node, setNativeProps };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("normalizeScrollMetrics", () => {
|
|
43
|
+
it("normalizes the Android default-path response", () => {
|
|
44
|
+
expect(
|
|
45
|
+
normalizeScrollMetrics(
|
|
46
|
+
{
|
|
47
|
+
scrollY: 120,
|
|
48
|
+
scrollRange: 560,
|
|
49
|
+
},
|
|
50
|
+
"Android",
|
|
51
|
+
400,
|
|
52
|
+
),
|
|
53
|
+
).toEqual({ offsetY: 120, maxOffsetY: 560 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("prefers the iOS maxScrollOffset over the legacy content-size range", () => {
|
|
57
|
+
expect(
|
|
58
|
+
normalizeScrollMetrics(
|
|
59
|
+
{
|
|
60
|
+
scrollY: 120,
|
|
61
|
+
scrollRange: 960,
|
|
62
|
+
maxScrollOffset: 560,
|
|
63
|
+
},
|
|
64
|
+
"iOS",
|
|
65
|
+
400,
|
|
66
|
+
),
|
|
67
|
+
).toEqual({ offsetY: 120, maxOffsetY: 560 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("derives an iOS max offset from a legacy content-size range", () => {
|
|
71
|
+
expect(
|
|
72
|
+
normalizeScrollMetrics(
|
|
73
|
+
{
|
|
74
|
+
scrollY: 120,
|
|
75
|
+
scrollRange: 960,
|
|
76
|
+
},
|
|
77
|
+
"iOS",
|
|
78
|
+
400,
|
|
79
|
+
),
|
|
80
|
+
).toEqual({ offsetY: 120, maxOffsetY: 560 });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("normalizes the Android new-architecture response", () => {
|
|
84
|
+
expect(
|
|
85
|
+
normalizeScrollMetrics(
|
|
86
|
+
{
|
|
87
|
+
scrollTop: 120,
|
|
88
|
+
scrollHeight: 560,
|
|
89
|
+
},
|
|
90
|
+
"Android",
|
|
91
|
+
400,
|
|
92
|
+
),
|
|
93
|
+
).toEqual({ offsetY: 120, maxOffsetY: 560 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("derives an iOS max offset from new-architecture content height", () => {
|
|
97
|
+
expect(
|
|
98
|
+
normalizeScrollMetrics(
|
|
99
|
+
{
|
|
100
|
+
scrollTop: 120,
|
|
101
|
+
scrollHeight: 960,
|
|
102
|
+
},
|
|
103
|
+
"iOS",
|
|
104
|
+
400,
|
|
105
|
+
),
|
|
106
|
+
).toEqual({ offsetY: 120, maxOffsetY: 560 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns null for incomplete or non-finite responses", () => {
|
|
110
|
+
expect(normalizeScrollMetrics({ scrollY: 10 }, "Android", 400)).toBeNull();
|
|
111
|
+
expect(
|
|
112
|
+
normalizeScrollMetrics({ scrollY: Number.NaN, scrollRange: 10 }, "Android", 400),
|
|
113
|
+
).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("clamps negative offsets and ranges", () => {
|
|
117
|
+
expect(
|
|
118
|
+
normalizeScrollMetrics(
|
|
119
|
+
{
|
|
120
|
+
scrollY: -20,
|
|
121
|
+
scrollRange: -10,
|
|
122
|
+
},
|
|
123
|
+
"Android",
|
|
124
|
+
400,
|
|
125
|
+
),
|
|
126
|
+
).toEqual({ offsetY: 0, maxOffsetY: 0 });
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("resolveKeyboardOcclusionTop", () => {
|
|
131
|
+
it("uses the Lynx root bottom for Android relative keyboard height", () => {
|
|
132
|
+
expect(
|
|
133
|
+
resolveKeyboardOcclusionTop({ visible: true, height: 280 }, "Android", 844, {
|
|
134
|
+
top: 44,
|
|
135
|
+
bottom: 800,
|
|
136
|
+
}),
|
|
137
|
+
).toBe(520);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("uses the physical screen height in CSS pixels for iOS", () => {
|
|
141
|
+
expect(
|
|
142
|
+
resolveKeyboardOcclusionTop({ visible: true, height: 280 }, "iOS", 844, {
|
|
143
|
+
top: 44,
|
|
144
|
+
bottom: 800,
|
|
145
|
+
}),
|
|
146
|
+
).toBe(564);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns null for hidden or invalid keyboard geometry", () => {
|
|
150
|
+
expect(
|
|
151
|
+
resolveKeyboardOcclusionTop({ visible: false, height: 280 }, "iOS", 844, {
|
|
152
|
+
top: 0,
|
|
153
|
+
bottom: 844,
|
|
154
|
+
}),
|
|
155
|
+
).toBeNull();
|
|
156
|
+
expect(
|
|
157
|
+
resolveKeyboardOcclusionTop({ visible: true, height: 0 }, "Android", 844, {
|
|
158
|
+
top: 0,
|
|
159
|
+
bottom: 844,
|
|
160
|
+
}),
|
|
161
|
+
).toBeNull();
|
|
162
|
+
expect(
|
|
163
|
+
resolveKeyboardOcclusionTop({ visible: true, height: 280 }, "Android", 844, null),
|
|
164
|
+
).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("lynxKeyboardAvoidingNativeDriver", () => {
|
|
169
|
+
it("executes native spacer and absolute scroll mutations", () => {
|
|
170
|
+
const { exec, invoke, node, setNativeProps } = createNode();
|
|
171
|
+
|
|
172
|
+
lynxKeyboardAvoidingNativeDriver.setSpacerHeight(node, -10);
|
|
173
|
+
lynxKeyboardAvoidingNativeDriver.scrollTo(node, -20, false);
|
|
174
|
+
|
|
175
|
+
expect(setNativeProps).toHaveBeenCalledWith({ height: "0px" });
|
|
176
|
+
expect(invoke).toHaveBeenCalledWith({
|
|
177
|
+
method: "scrollTo",
|
|
178
|
+
params: {
|
|
179
|
+
offset: 0,
|
|
180
|
+
smooth: false,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
expect(exec).toHaveBeenCalledTimes(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("measures nodes in screen coordinates and executes the query", async () => {
|
|
187
|
+
const { exec, invoke, node } = createNode((options) => {
|
|
188
|
+
options.success?.({ top: 120, bottom: 280 });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await expect(lynxKeyboardAvoidingNativeDriver.measure(node)).resolves.toEqual({
|
|
192
|
+
top: 120,
|
|
193
|
+
bottom: 280,
|
|
194
|
+
});
|
|
195
|
+
expect(invoke).toHaveBeenCalledWith(
|
|
196
|
+
expect.objectContaining({
|
|
197
|
+
method: "boundingClientRect",
|
|
198
|
+
params: {
|
|
199
|
+
relativeTo: "screen",
|
|
200
|
+
androidEnableTransformProps: true,
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
expect(exec).toHaveBeenCalledOnce();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("normalizes getScrollInfo using the runtime platform", async () => {
|
|
208
|
+
(globalThis as TestGlobal).SystemInfo = {
|
|
209
|
+
platform: "iOS",
|
|
210
|
+
pixelHeight: 844,
|
|
211
|
+
pixelRatio: 1,
|
|
212
|
+
};
|
|
213
|
+
const { exec, node } = createNode((options) => {
|
|
214
|
+
options.success?.({ scrollY: 120, scrollRange: 960 });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await expect(lynxKeyboardAvoidingNativeDriver.getScrollMetrics(node, 400)).resolves.toEqual({
|
|
218
|
+
offsetY: 120,
|
|
219
|
+
maxOffsetY: 560,
|
|
220
|
+
});
|
|
221
|
+
expect(exec).toHaveBeenCalledOnce();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { NodesRef, PlatformType } from "@lynx-js/types";
|
|
2
|
+
|
|
3
|
+
import type { VerticalRect } from "./geometry";
|
|
4
|
+
|
|
5
|
+
export type KeyboardAvoidingPlatform = Extract<PlatformType, "Android" | "iOS">;
|
|
6
|
+
|
|
7
|
+
export interface RawKeyboardState {
|
|
8
|
+
visible: boolean;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface KeyboardOcclusion {
|
|
13
|
+
visible: boolean;
|
|
14
|
+
topInScreenPx: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ScrollMetrics {
|
|
18
|
+
offsetY: number;
|
|
19
|
+
maxOffsetY: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RawScrollInfo {
|
|
23
|
+
scrollY?: number;
|
|
24
|
+
scrollRange?: number;
|
|
25
|
+
maxScrollOffset?: number;
|
|
26
|
+
scrollTop?: number;
|
|
27
|
+
scrollHeight?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SystemInfoLike {
|
|
31
|
+
pixelHeight?: number;
|
|
32
|
+
pixelRatio?: number;
|
|
33
|
+
platform?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface KeyboardAvoidingNativeDriver {
|
|
37
|
+
measure(node: NodesRef): Promise<VerticalRect | null>;
|
|
38
|
+
resolveKeyboardOcclusion(state: RawKeyboardState): Promise<KeyboardOcclusion | null>;
|
|
39
|
+
setSpacerHeight(node: NodesRef, height: number): void;
|
|
40
|
+
waitForLayout(): Promise<void>;
|
|
41
|
+
getScrollMetrics(node: NodesRef, viewportHeight: number): Promise<ScrollMetrics | null>;
|
|
42
|
+
scrollTo(node: NodesRef, offset: number, smooth: boolean): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isFiniteNumber(value: unknown): value is number {
|
|
46
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeNonNegative(value: number): number {
|
|
50
|
+
return Math.max(0, value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeRect(value: unknown): VerticalRect | null {
|
|
54
|
+
if (typeof value !== "object" || value === null) return null;
|
|
55
|
+
|
|
56
|
+
const rect = value as { top?: unknown; bottom?: unknown };
|
|
57
|
+
if (!isFiniteNumber(rect.top) || !isFiniteNumber(rect.bottom)) return null;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
top: rect.top,
|
|
61
|
+
bottom: Math.max(rect.top, rect.bottom),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeScrollMetrics(
|
|
66
|
+
raw: RawScrollInfo,
|
|
67
|
+
platform: KeyboardAvoidingPlatform,
|
|
68
|
+
viewportHeight: number,
|
|
69
|
+
): ScrollMetrics | null {
|
|
70
|
+
const offsetY = isFiniteNumber(raw.scrollY)
|
|
71
|
+
? raw.scrollY
|
|
72
|
+
: isFiniteNumber(raw.scrollTop)
|
|
73
|
+
? raw.scrollTop
|
|
74
|
+
: null;
|
|
75
|
+
|
|
76
|
+
if (offsetY === null) return null;
|
|
77
|
+
|
|
78
|
+
const normalizedViewportHeight = normalizeNonNegative(viewportHeight);
|
|
79
|
+
let maxOffsetY: number | null = null;
|
|
80
|
+
|
|
81
|
+
if (isFiniteNumber(raw.maxScrollOffset)) {
|
|
82
|
+
maxOffsetY = raw.maxScrollOffset;
|
|
83
|
+
} else if (isFiniteNumber(raw.scrollRange)) {
|
|
84
|
+
maxOffsetY = platform === "iOS" ? raw.scrollRange - normalizedViewportHeight : raw.scrollRange;
|
|
85
|
+
} else if (isFiniteNumber(raw.scrollHeight)) {
|
|
86
|
+
maxOffsetY =
|
|
87
|
+
platform === "iOS" ? raw.scrollHeight - normalizedViewportHeight : raw.scrollHeight;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (maxOffsetY === null) return null;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
offsetY: normalizeNonNegative(offsetY),
|
|
94
|
+
maxOffsetY: normalizeNonNegative(maxOffsetY),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveKeyboardOcclusionTop(
|
|
99
|
+
state: RawKeyboardState,
|
|
100
|
+
platform: KeyboardAvoidingPlatform,
|
|
101
|
+
screenHeight: number,
|
|
102
|
+
rootRect: VerticalRect | null,
|
|
103
|
+
): number | null {
|
|
104
|
+
if (!state.visible || !isFiniteNumber(state.height) || state.height <= 0) return null;
|
|
105
|
+
|
|
106
|
+
if (platform === "Android") {
|
|
107
|
+
return rootRect ? rootRect.bottom - state.height : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return isFiniteNumber(screenHeight) && screenHeight > 0 ? screenHeight - state.height : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getSystemInfo(): {
|
|
114
|
+
platform: KeyboardAvoidingPlatform;
|
|
115
|
+
screenHeight: number;
|
|
116
|
+
} | null {
|
|
117
|
+
const value = (globalThis as typeof globalThis & { SystemInfo?: SystemInfoLike }).SystemInfo;
|
|
118
|
+
if (value?.platform !== "Android" && value?.platform !== "iOS") return null;
|
|
119
|
+
if (!isFiniteNumber(value.pixelHeight) || !isFiniteNumber(value.pixelRatio)) return null;
|
|
120
|
+
if (value.pixelHeight <= 0 || value.pixelRatio <= 0) return null;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
platform: value.platform,
|
|
124
|
+
screenHeight: value.pixelHeight / value.pixelRatio,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function measureNode(node: NodesRef): Promise<VerticalRect | null> {
|
|
129
|
+
"background only";
|
|
130
|
+
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
try {
|
|
133
|
+
node
|
|
134
|
+
.invoke({
|
|
135
|
+
method: "boundingClientRect",
|
|
136
|
+
params: {
|
|
137
|
+
relativeTo: "screen",
|
|
138
|
+
androidEnableTransformProps: true,
|
|
139
|
+
},
|
|
140
|
+
success: (value) => resolve(normalizeRect(value)),
|
|
141
|
+
fail: () => resolve(null),
|
|
142
|
+
})
|
|
143
|
+
.exec();
|
|
144
|
+
} catch {
|
|
145
|
+
resolve(null);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function measureRoot(): Promise<VerticalRect | null> {
|
|
151
|
+
"background only";
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
return measureNode(lynx.createSelectorQuery().selectRoot());
|
|
155
|
+
} catch {
|
|
156
|
+
return Promise.resolve(null);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getScrollMetrics(
|
|
161
|
+
node: NodesRef,
|
|
162
|
+
platform: KeyboardAvoidingPlatform,
|
|
163
|
+
viewportHeight: number,
|
|
164
|
+
): Promise<ScrollMetrics | null> {
|
|
165
|
+
"background only";
|
|
166
|
+
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
try {
|
|
169
|
+
node
|
|
170
|
+
.invoke({
|
|
171
|
+
method: "getScrollInfo",
|
|
172
|
+
success: (value) => {
|
|
173
|
+
if (typeof value !== "object" || value === null) {
|
|
174
|
+
resolve(null);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
resolve(normalizeScrollMetrics(value as RawScrollInfo, platform, viewportHeight));
|
|
179
|
+
},
|
|
180
|
+
fail: () => resolve(null),
|
|
181
|
+
})
|
|
182
|
+
.exec();
|
|
183
|
+
} catch {
|
|
184
|
+
resolve(null);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export const lynxKeyboardAvoidingNativeDriver: KeyboardAvoidingNativeDriver = {
|
|
190
|
+
measure: measureNode,
|
|
191
|
+
async resolveKeyboardOcclusion(state) {
|
|
192
|
+
"background only";
|
|
193
|
+
|
|
194
|
+
const systemInfo = getSystemInfo();
|
|
195
|
+
if (!systemInfo || !state.visible) return null;
|
|
196
|
+
|
|
197
|
+
const rootRect = systemInfo.platform === "Android" ? await measureRoot() : null;
|
|
198
|
+
const topInScreenPx = resolveKeyboardOcclusionTop(
|
|
199
|
+
state,
|
|
200
|
+
systemInfo.platform,
|
|
201
|
+
systemInfo.screenHeight,
|
|
202
|
+
rootRect,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return topInScreenPx === null
|
|
206
|
+
? null
|
|
207
|
+
: {
|
|
208
|
+
visible: true,
|
|
209
|
+
topInScreenPx,
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
setSpacerHeight(node, height) {
|
|
213
|
+
"background only";
|
|
214
|
+
|
|
215
|
+
// 동기 실행 실패는 engine까지 전달한다. engine이 transaction을 중단하되
|
|
216
|
+
// 적용 상태를 확정하지 않아 다음 invalidation에서 재시도할 수 있다.
|
|
217
|
+
node.setNativeProps({ height: `${normalizeNonNegative(height)}px` }).exec();
|
|
218
|
+
},
|
|
219
|
+
waitForLayout() {
|
|
220
|
+
"background only";
|
|
221
|
+
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
requestAnimationFrame(() => resolve());
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
async getScrollMetrics(node, viewportHeight) {
|
|
227
|
+
"background only";
|
|
228
|
+
|
|
229
|
+
const systemInfo = getSystemInfo();
|
|
230
|
+
if (!systemInfo) return null;
|
|
231
|
+
|
|
232
|
+
return getScrollMetrics(node, systemInfo.platform, viewportHeight);
|
|
233
|
+
},
|
|
234
|
+
scrollTo(node, offset, smooth) {
|
|
235
|
+
"background only";
|
|
236
|
+
|
|
237
|
+
// 동기 실행 실패는 engine의 transaction 경계에서 fail-soft 처리한다.
|
|
238
|
+
node
|
|
239
|
+
.invoke({
|
|
240
|
+
method: "scrollTo",
|
|
241
|
+
params: {
|
|
242
|
+
offset: normalizeNonNegative(offset),
|
|
243
|
+
smooth,
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
.exec();
|
|
247
|
+
},
|
|
248
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import { render } from "@lynx-js/react/testing-library";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { ProgressCircle } from "./index";
|
|
6
|
+
|
|
7
|
+
vi.mock("@lynx-js/react", async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal<typeof import("@lynx-js/react")>();
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
runOnMainThread: () => () => undefined,
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function getRenderedRoot() {
|
|
17
|
+
const root = elementTree.root;
|
|
18
|
+
|
|
19
|
+
if (!root) {
|
|
20
|
+
throw new Error("Expected Lynx render root to exist.");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return root;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("ProgressCircle", () => {
|
|
27
|
+
it("renders a visible indeterminate range before the main-thread animation starts", () => {
|
|
28
|
+
render(
|
|
29
|
+
<ProgressCircle.Root>
|
|
30
|
+
<ProgressCircle.Range />
|
|
31
|
+
</ProgressCircle.Root>,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const root = getRenderedRoot();
|
|
35
|
+
const range = root.querySelector<HTMLElement>(".seed-progress-circle__range");
|
|
36
|
+
const caps = root.querySelectorAll<HTMLElement>(".seed-progress-circle__cap");
|
|
37
|
+
const animationContainer = range?.parentElement;
|
|
38
|
+
|
|
39
|
+
expect(range).toBeInTheDocument();
|
|
40
|
+
expect(animationContainer?.style.transform).not.toBe("rotate(0deg)");
|
|
41
|
+
expect(range?.style.clipPath).toMatch(/^path\("M 20 20 L 20 -1 A 21 21/);
|
|
42
|
+
expect(caps).toHaveLength(2);
|
|
43
|
+
expect(caps[1]?.style.left).not.toBe(caps[0]?.style.left);
|
|
44
|
+
expect(caps[1]?.style.top).not.toBe(caps[0]?.style.top);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps the determinate range at the requested initial value", () => {
|
|
48
|
+
render(
|
|
49
|
+
<ProgressCircle.Root minValue={0} maxValue={100} value={40}>
|
|
50
|
+
<ProgressCircle.Range />
|
|
51
|
+
</ProgressCircle.Root>,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const range = getRenderedRoot().querySelector<HTMLElement>(".seed-progress-circle__range");
|
|
55
|
+
|
|
56
|
+
expect(range?.style.clipPath).toMatch(/^path\("M 20 20 L 20 -1 A 21 21/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -45,6 +45,37 @@ function computeRingGeometry(numSize: number) {
|
|
|
45
45
|
return { halfSize, innerR, ringCenterR, capSize };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function bgCubicBezier(rawT: number, x1: number, y1: number, x2: number, y2: number): number {
|
|
49
|
+
const t = Math.max(0, Math.min(1, rawT));
|
|
50
|
+
const cx = 3 * x1;
|
|
51
|
+
const bx = 3 * (x2 - x1) - cx;
|
|
52
|
+
const ax = 1 - cx - bx;
|
|
53
|
+
let currentT = t;
|
|
54
|
+
for (let i = 0; i < 8; i++) {
|
|
55
|
+
const currentX = ((ax * currentT + bx) * currentT + cx) * currentT - t;
|
|
56
|
+
const currentDx = (3 * ax * currentT + 2 * bx) * currentT + cx;
|
|
57
|
+
if (Math.abs(currentDx) < 1e-6) break;
|
|
58
|
+
currentT = currentT - currentX / currentDx;
|
|
59
|
+
}
|
|
60
|
+
const cy = 3 * y1;
|
|
61
|
+
const by = 3 * (y2 - y1) - cy;
|
|
62
|
+
const ay = 1 - cy - by;
|
|
63
|
+
return ((ay * currentT + by) * currentT + cy) * currentT;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bgSampleIndeterminate(t: number) {
|
|
67
|
+
const containerEased = bgCubicBezier(t, 0.35, 0.25, 0.65, 0.75);
|
|
68
|
+
const headEased = bgCubicBezier(t, 0.35, 0, 0.65, 1);
|
|
69
|
+
const tailEased = bgCubicBezier(t, 0.35, 0, 0.65, 0.6);
|
|
70
|
+
|
|
71
|
+
const headLength = headEased <= 0.75 ? (headEased / 0.75) * 360 : 360;
|
|
72
|
+
const tailOffset = tailEased <= 1 / 3 ? 0 : ((tailEased - 1 / 3) / (2 / 3)) * 360;
|
|
73
|
+
const arcLength = Math.max(0, headLength - tailOffset);
|
|
74
|
+
const containerDeg = containerEased * 360 + tailOffset;
|
|
75
|
+
|
|
76
|
+
return { containerDeg, arcLength };
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
function bgPieClipPath(size: number, angleDeg: number): string | undefined {
|
|
49
80
|
if (angleDeg >= 360) return undefined;
|
|
50
81
|
if (angleDeg <= 0) return 'path("M 0 0 Z")';
|
|
@@ -113,6 +144,7 @@ function sampleIndeterminate(t: number) {
|
|
|
113
144
|
// --- Animation constants ---
|
|
114
145
|
|
|
115
146
|
const INDETERMINATE_DURATION = 1200;
|
|
147
|
+
const INDETERMINATE_INITIAL_PHASE = 1 / 6;
|
|
116
148
|
const TRANSITION_DURATION = 300;
|
|
117
149
|
|
|
118
150
|
////////////////////////////////////////////////////////////////////////////////////
|
|
@@ -335,7 +367,10 @@ function IndeterminateRange({ numSize, classes }: { numSize: number; classes: Cl
|
|
|
335
367
|
if (!startTs) startTs = Number(ts);
|
|
336
368
|
|
|
337
369
|
const elapsed = ts - startTs;
|
|
338
|
-
const t =
|
|
370
|
+
const t =
|
|
371
|
+
((elapsed + INDETERMINATE_DURATION * INDETERMINATE_INITIAL_PHASE) %
|
|
372
|
+
INDETERMINATE_DURATION) /
|
|
373
|
+
INDETERMINATE_DURATION;
|
|
339
374
|
const state = sampleIndeterminate(t);
|
|
340
375
|
|
|
341
376
|
containerEl?.setStyleProperty("transform", `rotate(${state.containerDeg}deg)`);
|
|
@@ -373,7 +408,9 @@ function IndeterminateRange({ numSize, classes }: { numSize: number; classes: Cl
|
|
|
373
408
|
};
|
|
374
409
|
}, [numSize]);
|
|
375
410
|
|
|
376
|
-
const
|
|
411
|
+
const initialState = bgSampleIndeterminate(INDETERMINATE_INITIAL_PHASE);
|
|
412
|
+
const initialClipPath = bgPieClipPath(numSize, initialState.arcLength);
|
|
413
|
+
const initialHeadRad = (initialState.arcLength * Math.PI) / 180;
|
|
377
414
|
|
|
378
415
|
return (
|
|
379
416
|
<view
|
|
@@ -382,7 +419,7 @@ function IndeterminateRange({ numSize, classes }: { numSize: number; classes: Cl
|
|
|
382
419
|
position: "absolute",
|
|
383
420
|
width: `${numSize}px`,
|
|
384
421
|
height: `${numSize}px`,
|
|
385
|
-
transform:
|
|
422
|
+
transform: `rotate(${initialState.containerDeg}deg)`,
|
|
386
423
|
}}
|
|
387
424
|
>
|
|
388
425
|
<view
|
|
@@ -412,8 +449,8 @@ function IndeterminateRange({ numSize, classes }: { numSize: number; classes: Cl
|
|
|
412
449
|
style={{
|
|
413
450
|
width: `${capSize}px`,
|
|
414
451
|
height: `${capSize}px`,
|
|
415
|
-
left: `${halfSize - capSize / 2}px`,
|
|
416
|
-
top: `${halfSize - ringCenterR - capSize / 2}px`,
|
|
452
|
+
left: `${halfSize + ringCenterR * Math.sin(initialHeadRad) - capSize / 2}px`,
|
|
453
|
+
top: `${halfSize - ringCenterR * Math.cos(initialHeadRad) - capSize / 2}px`,
|
|
417
454
|
}}
|
|
418
455
|
/>
|
|
419
456
|
</view>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import { render } from "@lynx-js/react/testing-library";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { HStack, VStack } from "./Stack";
|
|
6
|
+
|
|
7
|
+
function getRenderedRoot() {
|
|
8
|
+
const root = elementTree.root;
|
|
9
|
+
|
|
10
|
+
if (!root) {
|
|
11
|
+
throw new Error("Expected Lynx render root to exist.");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const stack = root.querySelector<HTMLElement>("view");
|
|
15
|
+
|
|
16
|
+
if (!stack) {
|
|
17
|
+
throw new Error("Expected Stack root view to exist.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return stack;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("Stack", () => {
|
|
24
|
+
it("maps a VStack gap token to row-gap", () => {
|
|
25
|
+
render(<VStack gap="x3" />);
|
|
26
|
+
|
|
27
|
+
expect(getRenderedRoot()).toHaveStyle({
|
|
28
|
+
rowGap: "var(--seed-dimension-x3)",
|
|
29
|
+
});
|
|
30
|
+
expect(getRenderedRoot().style.getPropertyValue("gap")).toBe("");
|
|
31
|
+
expect(getRenderedRoot().style.getPropertyValue("column-gap")).toBe("");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("maps an HStack gap token to column-gap", () => {
|
|
35
|
+
render(<HStack gap="x4" />);
|
|
36
|
+
|
|
37
|
+
expect(getRenderedRoot()).toHaveStyle({
|
|
38
|
+
columnGap: "var(--seed-dimension-x4)",
|
|
39
|
+
});
|
|
40
|
+
expect(getRenderedRoot().style.getPropertyValue("gap")).toBe("");
|
|
41
|
+
expect(getRenderedRoot().style.getPropertyValue("row-gap")).toBe("");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps a numeric zero gap value", () => {
|
|
45
|
+
render(<VStack gap={0} />);
|
|
46
|
+
|
|
47
|
+
expect(getRenderedRoot()).toHaveStyle({ rowGap: "0px" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps an arbitrary string gap value", () => {
|
|
51
|
+
render(<HStack gap="1.5rem" />);
|
|
52
|
+
|
|
53
|
+
expect(getRenderedRoot()).toHaveStyle({ columnGap: "1.5rem" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps style gap precedence over the gap prop", () => {
|
|
57
|
+
render(<VStack gap="x3" style={{ gap: "20px" }} />);
|
|
58
|
+
|
|
59
|
+
expect(getRenderedRoot()).toHaveStyle({ rowGap: "20px" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps an axis longhand in style above the gap prop", () => {
|
|
63
|
+
render(<VStack gap="x3" style={{ rowGap: "24px" }} />);
|
|
64
|
+
|
|
65
|
+
expect(getRenderedRoot()).toHaveStyle({ rowGap: "24px" });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -54,11 +54,25 @@ function getStackProps(props: StackProps) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function useStackStyleProps(props: StackProps, flexDirection: "column" | "row") {
|
|
57
|
-
|
|
57
|
+
const stackStyleProps = useStyleProps({
|
|
58
58
|
display: "flex",
|
|
59
59
|
flexDirection,
|
|
60
60
|
...getStackProps(props),
|
|
61
61
|
});
|
|
62
|
+
// Lynx의 gap 단축 속성 파서는 var(...)를 0px로 확정하므로 축별 longhand를 사용합니다.
|
|
63
|
+
const { gap, ...style } = stackStyleProps.style;
|
|
64
|
+
|
|
65
|
+
if (gap == null) {
|
|
66
|
+
return stackStyleProps;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
...stackStyleProps,
|
|
71
|
+
style: {
|
|
72
|
+
[flexDirection === "column" ? "rowGap" : "columnGap"]: gap,
|
|
73
|
+
...style,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
function renderStackView(
|