@seed-design/lynx-react 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/Accordion/Accordion.d.ts +56 -0
- package/lib/components/Accordion/Accordion.d.ts.map +1 -0
- package/lib/components/Accordion/Accordion.jsx +210 -0
- package/lib/components/Accordion/Accordion.namespace.d.ts +2 -0
- package/lib/components/Accordion/Accordion.namespace.d.ts.map +1 -0
- package/lib/components/Accordion/Accordion.namespace.js +17 -0
- package/lib/components/Accordion/index.d.ts +3 -0
- package/lib/components/Accordion/index.d.ts.map +1 -0
- package/lib/components/ActionButton/ActionButton.jsx +6 -6
- package/lib/components/AppBar/AppBar.jsx +1 -1
- package/lib/components/Badge/Badge.jsx +1 -1
- package/lib/components/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/lib/components/BottomSheet/BottomSheet.jsx +6 -3
- package/lib/components/Box/Box.jsx +1 -1
- package/lib/components/Callout/Callout.d.ts +32 -0
- package/lib/components/Callout/Callout.d.ts.map +1 -0
- package/lib/components/Callout/Callout.jsx +113 -0
- package/lib/components/Callout/Callout.namespace.d.ts +2 -0
- package/lib/components/Callout/Callout.namespace.d.ts.map +1 -0
- package/lib/components/Callout/Callout.namespace.js +13 -0
- package/lib/components/Callout/index.d.ts +3 -0
- package/lib/components/Callout/index.d.ts.map +1 -0
- package/lib/components/Checkbox/Checkbox.d.ts.map +1 -1
- package/lib/components/Checkbox/Checkbox.jsx +36 -5
- package/lib/components/Chip/Chip.d.ts +66 -0
- package/lib/components/Chip/Chip.d.ts.map +1 -0
- package/lib/components/Chip/Chip.jsx +159 -0
- package/lib/components/Chip/Chip.namespace.d.ts +2 -0
- package/lib/components/Chip/Chip.namespace.d.ts.map +1 -0
- package/lib/components/Chip/Chip.namespace.js +16 -0
- package/lib/components/Chip/index.d.ts +3 -0
- package/lib/components/Chip/index.d.ts.map +1 -0
- package/lib/components/Divider/Divider.d.ts +31 -0
- package/lib/components/Divider/Divider.d.ts.map +1 -0
- package/lib/components/Divider/Divider.jsx +26 -0
- package/lib/components/Divider/index.d.ts +2 -0
- package/lib/components/Divider/index.d.ts.map +1 -0
- package/lib/components/Field/Field.jsx +1 -1
- package/lib/components/Icon/Icon.jsx +2 -2
- package/lib/components/ProgressCircle/ProgressCircle.d.ts.map +1 -1
- package/lib/components/ProgressCircle/ProgressCircle.jsx +38 -7
- package/lib/components/RadioGroup/RadioGroup.jsx +2 -2
- package/lib/components/Stack/Stack.d.ts.map +1 -1
- package/lib/components/Stack/Stack.jsx +11 -2
- package/lib/components/Switch/Switch.jsx +2 -2
- package/lib/components/Tabs/Tabs.d.ts +59 -0
- package/lib/components/Tabs/Tabs.d.ts.map +1 -0
- package/lib/components/Tabs/Tabs.jsx +406 -0
- package/lib/components/Tabs/Tabs.namespace.d.ts +2 -0
- package/lib/components/Tabs/Tabs.namespace.d.ts.map +1 -0
- package/lib/components/Tabs/Tabs.namespace.js +14 -0
- package/lib/components/Tabs/index.d.ts +3 -0
- package/lib/components/Tabs/index.d.ts.map +1 -0
- 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 +4 -3
- package/lib/components/Text/Text.jsx +2 -2
- package/lib/components/TextField/TextField.d.ts +20 -2
- package/lib/components/TextField/TextField.d.ts.map +1 -1
- package/lib/components/TextField/TextField.jsx +25 -9
- package/lib/components/index.d.ts +5 -0
- package/lib/components/index.d.ts.map +1 -1
- package/lib/hooks/useIconColor.d.ts +2 -1
- package/lib/hooks/useIconColor.d.ts.map +1 -1
- package/lib/hooks/useIconColor.js +29 -5
- package/lib/hooks/usePressTap.d.ts.map +1 -1
- package/lib/hooks/usePressTap.js +1 -0
- package/lib/index.js +14 -5
- package/lib/utils/create-slot-recipe-context.jsx +1 -1
- package/package.json +3 -3
- package/src/components/Accordion/Accordion.namespace.ts +22 -0
- package/src/components/Accordion/Accordion.test.tsx +129 -0
- package/src/components/Accordion/Accordion.tsx +453 -0
- package/src/components/Accordion/index.ts +24 -0
- package/src/components/BottomSheet/BottomSheet.test.tsx +24 -0
- package/src/components/BottomSheet/BottomSheet.tsx +14 -2
- package/src/components/Callout/Callout.namespace.ts +14 -0
- package/src/components/Callout/Callout.test.tsx +188 -0
- package/src/components/Callout/Callout.tsx +272 -0
- package/src/components/Callout/index.ts +16 -0
- package/src/components/Checkbox/Checkbox.test.tsx +176 -0
- package/src/components/Checkbox/Checkbox.tsx +36 -2
- package/src/components/Chip/Chip.namespace.ts +20 -0
- package/src/components/Chip/Chip.test.tsx +113 -0
- package/src/components/Chip/Chip.tsx +373 -0
- package/src/components/Chip/index.ts +22 -0
- package/src/components/Divider/Divider.test.tsx +73 -0
- package/src/components/Divider/Divider.tsx +76 -0
- package/src/components/Divider/index.ts +1 -0
- package/src/components/Icon/Icon.test.tsx +129 -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/Tabs/Tabs.namespace.ts +16 -0
- package/src/components/Tabs/Tabs.test.tsx +181 -0
- package/src/components/Tabs/Tabs.tsx +706 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/TagGroup/TagGroup.test.tsx +73 -0
- package/src/components/TagGroup/TagGroup.tsx +4 -2
- package/src/components/TextField/TextField.test.tsx +163 -35
- package/src/components/TextField/TextField.tsx +61 -19
- package/src/components/index.ts +5 -0
- package/src/hooks/useIconColor.ts +43 -5
- package/src/hooks/usePressTap.test.ts +13 -0
- package/src/hooks/usePressTap.ts +1 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as React from "@lynx-js/react";
|
|
2
|
+
|
|
3
|
+
import type { LynxAccessibilityProps, LynxStyledElementProps } from "../../types";
|
|
4
|
+
import type { StyleProps } from "../../utils/styled";
|
|
5
|
+
import { Box } from "../Box";
|
|
6
|
+
|
|
7
|
+
export interface DividerProps
|
|
8
|
+
extends Omit<LynxStyledElementProps, "children">,
|
|
9
|
+
LynxAccessibilityProps {
|
|
10
|
+
/**
|
|
11
|
+
* @default "stroke.neutralMuted"
|
|
12
|
+
*/
|
|
13
|
+
color?: StyleProps["borderColor"];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @default 1
|
|
17
|
+
*/
|
|
18
|
+
thickness?: StyleProps["borderWidth"];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @default "horizontal"
|
|
22
|
+
*/
|
|
23
|
+
orientation?: "horizontal" | "vertical";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Adds 16px of spacing to both ends of the divider.
|
|
27
|
+
* @default false
|
|
28
|
+
*/
|
|
29
|
+
inset?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @platform Lynx
|
|
34
|
+
*
|
|
35
|
+
* 웹의 `<hr>` 대신 native `<view>`를 렌더링합니다. 구분선은 기본적으로 접근성
|
|
36
|
+
* 요소로 노출되며 `accessibility-role-description="separator"`를 사용합니다.
|
|
37
|
+
* 장식용 구분선은 `accessibility-element={false}`로 접근성 트리에서 제외하세요.
|
|
38
|
+
*/
|
|
39
|
+
export const Divider = React.forwardRef<unknown, DividerProps>((props, ref) => {
|
|
40
|
+
const {
|
|
41
|
+
color = "stroke.neutralMuted",
|
|
42
|
+
thickness = 1,
|
|
43
|
+
orientation = "horizontal",
|
|
44
|
+
inset = false,
|
|
45
|
+
style,
|
|
46
|
+
"accessibility-element": accessibilityElement = true,
|
|
47
|
+
"accessibility-role-description": accessibilityRoleDescription = "separator",
|
|
48
|
+
...nativeProps
|
|
49
|
+
} = props;
|
|
50
|
+
|
|
51
|
+
const isHorizontal = orientation === "horizontal";
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<Box
|
|
55
|
+
{...nativeProps}
|
|
56
|
+
ref={ref}
|
|
57
|
+
borderColor={color}
|
|
58
|
+
borderWidth={0}
|
|
59
|
+
borderBottomWidth={isHorizontal ? thickness : 0}
|
|
60
|
+
borderRightWidth={isHorizontal ? 0 : thickness}
|
|
61
|
+
accessibility-element={accessibilityElement}
|
|
62
|
+
accessibility-role-description={accessibilityRoleDescription}
|
|
63
|
+
style={{
|
|
64
|
+
width: isHorizontal ? (inset ? "calc(100% - 32px)" : "100%") : undefined,
|
|
65
|
+
height: isHorizontal ? undefined : inset ? "calc(100% - 32px)" : "100%",
|
|
66
|
+
marginLeft: inset && isHorizontal ? "16px" : undefined,
|
|
67
|
+
marginRight: inset && isHorizontal ? "16px" : undefined,
|
|
68
|
+
marginTop: inset && !isHorizontal ? "16px" : undefined,
|
|
69
|
+
marginBottom: inset && !isHorizontal ? "16px" : undefined,
|
|
70
|
+
...style,
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
Divider.displayName = "Divider";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Divider, type DividerProps } from "./Divider";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as React from "@lynx-js/react";
|
|
2
|
+
import { act, render, waitSchedule } from "@lynx-js/react/testing-library";
|
|
3
|
+
import type { MainThread } from "@lynx-js/types";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import type { LynxIconElementProps } from "../../types";
|
|
7
|
+
import { InternalIcon } from "./Icon";
|
|
8
|
+
|
|
9
|
+
const TestIcon = React.forwardRef<
|
|
10
|
+
MainThread.Element,
|
|
11
|
+
LynxIconElementProps & { children?: React.ReactNode }
|
|
12
|
+
>((props, ref) => {
|
|
13
|
+
const { children, ...nativeProps } = props;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<image {...nativeProps} {...(ref ? { "main-thread:ref": ref } : {})}>
|
|
17
|
+
{children}
|
|
18
|
+
</image>
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
TestIcon.displayName = "TestIcon";
|
|
22
|
+
|
|
23
|
+
function getSourceAndImage() {
|
|
24
|
+
const source = elementTree.root?.querySelector<HTMLElement>("view");
|
|
25
|
+
const image = source?.querySelector<HTMLElement>("image");
|
|
26
|
+
|
|
27
|
+
if (!source || !image) {
|
|
28
|
+
throw new Error("Expected icon source and image elements to exist.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { source, image };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function installMainThreadStyleMocks(getComputedColor: () => string) {
|
|
35
|
+
let nextFrameId = 1;
|
|
36
|
+
const frames = new Map<number, FrameRequestCallback>();
|
|
37
|
+
|
|
38
|
+
lynxTestingEnv.mainThread.globalThis["SystemInfo"] = {
|
|
39
|
+
...SystemInfo,
|
|
40
|
+
lynxSdkVersion: "3.5",
|
|
41
|
+
};
|
|
42
|
+
lynxTestingEnv.mainThread.globalThis["__GetComputedStyleByKey"] = getComputedColor;
|
|
43
|
+
lynxTestingEnv.mainThread.globalThis["requestAnimationFrame"] = (
|
|
44
|
+
callback: FrameRequestCallback,
|
|
45
|
+
) => {
|
|
46
|
+
const frameId = nextFrameId;
|
|
47
|
+
nextFrameId += 1;
|
|
48
|
+
frames.set(frameId, callback);
|
|
49
|
+
return frameId;
|
|
50
|
+
};
|
|
51
|
+
lynxTestingEnv.mainThread.globalThis["cancelAnimationFrame"] = (frameId: number) => {
|
|
52
|
+
frames.delete(frameId);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const runNextFrame = (timestamp = 16) => {
|
|
56
|
+
const nextFrame = frames.entries().next().value;
|
|
57
|
+
if (!nextFrame) {
|
|
58
|
+
throw new Error("Expected a scheduled animation frame.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const [frameId, callback] = nextFrame;
|
|
62
|
+
frames.delete(frameId);
|
|
63
|
+
lynxTestingEnv.switchToMainThread();
|
|
64
|
+
act(() => {
|
|
65
|
+
callback(timestamp);
|
|
66
|
+
});
|
|
67
|
+
lynxTestingEnv.switchToBackgroundThread();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return { frames, runNextFrame };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("InternalIcon", () => {
|
|
74
|
+
it("reads the updated computed color in the next frame", async () => {
|
|
75
|
+
let computedColor = "rgb(134, 139, 148)";
|
|
76
|
+
const { frames, runNextFrame } = installMainThreadStyleMocks(() => computedColor);
|
|
77
|
+
|
|
78
|
+
const renderIcon = (dependency: string) => (
|
|
79
|
+
<InternalIcon icon={<TestIcon />} className={dependency} deps={[dependency]} />
|
|
80
|
+
);
|
|
81
|
+
const { rerender } = render(renderIcon("unchecked"), {
|
|
82
|
+
enableMainThread: true,
|
|
83
|
+
enableBackgroundThread: true,
|
|
84
|
+
});
|
|
85
|
+
await waitSchedule();
|
|
86
|
+
|
|
87
|
+
const { image } = getSourceAndImage();
|
|
88
|
+
expect(frames.size).toBe(1);
|
|
89
|
+
runNextFrame();
|
|
90
|
+
|
|
91
|
+
expect(image.getAttribute("tint-color")).toBe("rgb(134, 139, 148)");
|
|
92
|
+
|
|
93
|
+
rerender(renderIcon("checked"));
|
|
94
|
+
await waitSchedule();
|
|
95
|
+
|
|
96
|
+
expect(frames.size).toBe(1);
|
|
97
|
+
expect(image.getAttribute("tint-color")).toBe("rgb(134, 139, 148)");
|
|
98
|
+
|
|
99
|
+
computedColor = "rgb(255, 102, 0)";
|
|
100
|
+
runNextFrame();
|
|
101
|
+
|
|
102
|
+
expect(image.getAttribute("tint-color")).toBe("rgb(255, 102, 0)");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("coalesces pending frame syncs across rapid dependency changes", async () => {
|
|
106
|
+
let computedColor = "rgb(134, 139, 148)";
|
|
107
|
+
const { frames, runNextFrame } = installMainThreadStyleMocks(() => computedColor);
|
|
108
|
+
const renderIcon = (dependency: string) => (
|
|
109
|
+
<InternalIcon icon={<TestIcon />} className={dependency} deps={[dependency]} />
|
|
110
|
+
);
|
|
111
|
+
const { rerender } = render(renderIcon("unchecked"), {
|
|
112
|
+
enableMainThread: true,
|
|
113
|
+
enableBackgroundThread: true,
|
|
114
|
+
});
|
|
115
|
+
await waitSchedule();
|
|
116
|
+
|
|
117
|
+
rerender(renderIcon("checked"));
|
|
118
|
+
await waitSchedule();
|
|
119
|
+
rerender(renderIcon("unchecked-again"));
|
|
120
|
+
await waitSchedule();
|
|
121
|
+
|
|
122
|
+
expect(frames.size).toBe(1);
|
|
123
|
+
|
|
124
|
+
computedColor = "rgb(73, 80, 88)";
|
|
125
|
+
runNextFrame();
|
|
126
|
+
|
|
127
|
+
expect(getSourceAndImage().image.getAttribute("tint-color")).toBe("rgb(73, 80, 88)");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -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(
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export {
|
|
2
|
+
TabsCarousel as Carousel,
|
|
3
|
+
TabsCarouselCamera as CarouselCamera,
|
|
4
|
+
TabsContent as Content,
|
|
5
|
+
TabsIndicator as Indicator,
|
|
6
|
+
TabsList as List,
|
|
7
|
+
TabsRoot as Root,
|
|
8
|
+
TabsTrigger as Trigger,
|
|
9
|
+
type TabsCarouselCameraProps as CarouselCameraProps,
|
|
10
|
+
type TabsCarouselProps as CarouselProps,
|
|
11
|
+
type TabsContentProps as ContentProps,
|
|
12
|
+
type TabsIndicatorProps as IndicatorProps,
|
|
13
|
+
type TabsListProps as ListProps,
|
|
14
|
+
type TabsRootProps as RootProps,
|
|
15
|
+
type TabsTriggerProps as TriggerProps,
|
|
16
|
+
} from "./Tabs";
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import { fireEvent, render } from "@lynx-js/react/testing-library";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import * as Tabs from "./Tabs.namespace";
|
|
6
|
+
|
|
7
|
+
function BasicTabs(props: {
|
|
8
|
+
value?: string;
|
|
9
|
+
defaultValue?: string;
|
|
10
|
+
onValueChange?: (value: string) => void;
|
|
11
|
+
}) {
|
|
12
|
+
return (
|
|
13
|
+
<Tabs.Root {...props}>
|
|
14
|
+
<Tabs.List>
|
|
15
|
+
<Tabs.Trigger value="one">첫 번째</Tabs.Trigger>
|
|
16
|
+
<Tabs.Trigger value="two">두 번째</Tabs.Trigger>
|
|
17
|
+
<Tabs.Indicator />
|
|
18
|
+
</Tabs.List>
|
|
19
|
+
<Tabs.Content value="one">첫 번째 콘텐츠</Tabs.Content>
|
|
20
|
+
<Tabs.Content value="two">두 번째 콘텐츠</Tabs.Content>
|
|
21
|
+
</Tabs.Root>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getTrigger(container: HTMLElement, label: string) {
|
|
26
|
+
const trigger = Array.from(container.querySelectorAll<HTMLElement>(".seed-tabs__trigger")).find(
|
|
27
|
+
(element) => element.textContent === label,
|
|
28
|
+
);
|
|
29
|
+
if (!trigger) throw new Error(`Expected trigger for ${label} to exist.`);
|
|
30
|
+
return trigger;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Tabs", () => {
|
|
34
|
+
it("renders triggers inside the list content layout container", () => {
|
|
35
|
+
const { container } = render(<BasicTabs defaultValue="one" />);
|
|
36
|
+
const list = container.querySelector(".seed-tabs__list");
|
|
37
|
+
const listContent = list?.querySelector(":scope > .seed-tabs__listContent");
|
|
38
|
+
|
|
39
|
+
expect(listContent).not.toBeNull();
|
|
40
|
+
expect(listContent?.querySelectorAll(".seed-tabs__trigger")).toHaveLength(2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("changes an uncontrolled value when a trigger is tapped", () => {
|
|
44
|
+
const onValueChange = vi.fn();
|
|
45
|
+
const { container } = render(<BasicTabs defaultValue="one" onValueChange={onValueChange} />);
|
|
46
|
+
|
|
47
|
+
const first = getTrigger(container, "첫 번째");
|
|
48
|
+
const second = getTrigger(container, "두 번째");
|
|
49
|
+
|
|
50
|
+
expect(first).toHaveAttribute("accessibility-value", "selected");
|
|
51
|
+
expect(second).toHaveAttribute("accessibility-value", "not selected");
|
|
52
|
+
|
|
53
|
+
fireEvent.tap(second);
|
|
54
|
+
|
|
55
|
+
expect(onValueChange).toHaveBeenCalledWith("two");
|
|
56
|
+
expect(second).toHaveAttribute("accessibility-value", "selected");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does not select a disabled trigger", () => {
|
|
60
|
+
const onValueChange = vi.fn();
|
|
61
|
+
const { container } = render(
|
|
62
|
+
<Tabs.Root defaultValue="one" onValueChange={onValueChange}>
|
|
63
|
+
<Tabs.List>
|
|
64
|
+
<Tabs.Trigger value="one">첫 번째</Tabs.Trigger>
|
|
65
|
+
<Tabs.Trigger value="two" disabled>
|
|
66
|
+
두 번째
|
|
67
|
+
</Tabs.Trigger>
|
|
68
|
+
</Tabs.List>
|
|
69
|
+
</Tabs.Root>,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const disabled = getTrigger(container, "두 번째");
|
|
73
|
+
fireEvent.tap(disabled);
|
|
74
|
+
|
|
75
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
76
|
+
expect(disabled).toHaveAttribute("accessibility-traits", "disabled");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps controlled selection until the value prop changes", () => {
|
|
80
|
+
const onValueChange = vi.fn();
|
|
81
|
+
const { container, rerender } = render(<BasicTabs value="one" onValueChange={onValueChange} />);
|
|
82
|
+
|
|
83
|
+
const second = getTrigger(container, "두 번째");
|
|
84
|
+
fireEvent.tap(second);
|
|
85
|
+
|
|
86
|
+
expect(onValueChange).toHaveBeenCalledWith("two");
|
|
87
|
+
expect(second).toHaveAttribute("accessibility-value", "not selected");
|
|
88
|
+
|
|
89
|
+
rerender(<BasicTabs value="two" onValueChange={onValueChange} />);
|
|
90
|
+
expect(second).toHaveAttribute("accessibility-value", "selected");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("keeps carousel contents as views without a carousel camera", () => {
|
|
94
|
+
const { container } = render(
|
|
95
|
+
<Tabs.Root defaultValue="one">
|
|
96
|
+
<Tabs.List>
|
|
97
|
+
<Tabs.Trigger value="one">첫 번째</Tabs.Trigger>
|
|
98
|
+
</Tabs.List>
|
|
99
|
+
<Tabs.Carousel>
|
|
100
|
+
<Tabs.Content value="one">첫 번째 콘텐츠</Tabs.Content>
|
|
101
|
+
</Tabs.Carousel>
|
|
102
|
+
</Tabs.Root>,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(container.querySelector("viewpager-item")).toBeNull();
|
|
106
|
+
expect(container.querySelector(".seed-tabs__content")).not.toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders carousel camera contents as native viewpager items", () => {
|
|
110
|
+
const { container } = render(
|
|
111
|
+
<Tabs.Root defaultValue="one">
|
|
112
|
+
<Tabs.List>
|
|
113
|
+
<Tabs.Trigger value="one">첫 번째</Tabs.Trigger>
|
|
114
|
+
<Tabs.Trigger value="two">두 번째</Tabs.Trigger>
|
|
115
|
+
</Tabs.List>
|
|
116
|
+
<Tabs.Carousel swipeable>
|
|
117
|
+
<Tabs.CarouselCamera>
|
|
118
|
+
<Tabs.Content value="one">첫 번째 콘텐츠</Tabs.Content>
|
|
119
|
+
<Tabs.Content value="two">두 번째 콘텐츠</Tabs.Content>
|
|
120
|
+
</Tabs.CarouselCamera>
|
|
121
|
+
</Tabs.Carousel>
|
|
122
|
+
</Tabs.Root>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const pager = container.querySelector("viewpager");
|
|
126
|
+
expect(pager).not.toBeNull();
|
|
127
|
+
expect(pager?.querySelectorAll("viewpager-item")).toHaveLength(2);
|
|
128
|
+
expect(pager).toHaveAttribute("enable-scroll");
|
|
129
|
+
expect(pager).toHaveAttribute("ios-gesture-offset", "32");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("supports a custom iOS back gesture edge width", () => {
|
|
133
|
+
const { container } = render(
|
|
134
|
+
<Tabs.Root defaultValue="one">
|
|
135
|
+
<Tabs.List>
|
|
136
|
+
<Tabs.Trigger value="one">첫 번째</Tabs.Trigger>
|
|
137
|
+
</Tabs.List>
|
|
138
|
+
<Tabs.Carousel swipeable iosBackGestureEdgeWidth={48}>
|
|
139
|
+
<Tabs.CarouselCamera>
|
|
140
|
+
<Tabs.Content value="one">첫 번째 콘텐츠</Tabs.Content>
|
|
141
|
+
</Tabs.CarouselCamera>
|
|
142
|
+
</Tabs.Carousel>
|
|
143
|
+
</Tabs.Root>,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(container.querySelector("viewpager")).toHaveAttribute("ios-gesture-offset", "48");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("maps pager indexes from content order when a disabled trigger has no content", () => {
|
|
150
|
+
const { container } = render(
|
|
151
|
+
<Tabs.Root defaultValue="two">
|
|
152
|
+
<Tabs.List>
|
|
153
|
+
<Tabs.Trigger value="one">첫 번째</Tabs.Trigger>
|
|
154
|
+
<Tabs.Trigger value="disabled" disabled>
|
|
155
|
+
비활성
|
|
156
|
+
</Tabs.Trigger>
|
|
157
|
+
<Tabs.Trigger value="two">두 번째</Tabs.Trigger>
|
|
158
|
+
</Tabs.List>
|
|
159
|
+
<Tabs.Carousel swipeable>
|
|
160
|
+
<Tabs.CarouselCamera>
|
|
161
|
+
<Tabs.Content value="one">첫 번째 콘텐츠</Tabs.Content>
|
|
162
|
+
<Tabs.Content value="two">두 번째 콘텐츠</Tabs.Content>
|
|
163
|
+
</Tabs.CarouselCamera>
|
|
164
|
+
</Tabs.Carousel>
|
|
165
|
+
</Tabs.Root>,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const pager = container.querySelector("viewpager");
|
|
169
|
+
expect(pager).toHaveAttribute("initial-select-index", "1");
|
|
170
|
+
expect(getTrigger(container, "두 번째")).toHaveAttribute("accessibility-value", "selected");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("exposes tab semantics through Lynx accessibility attributes", () => {
|
|
174
|
+
const { container } = render(<BasicTabs defaultValue="one" />);
|
|
175
|
+
const first = getTrigger(container, "첫 번째");
|
|
176
|
+
|
|
177
|
+
expect(first).toHaveAttribute("accessibility-role-description", "tab");
|
|
178
|
+
expect(first).toHaveAttribute("accessibility-label", "첫 번째");
|
|
179
|
+
expect(first).toHaveAttribute("accessibility-traits", "selected");
|
|
180
|
+
});
|
|
181
|
+
});
|