@seed-design/lynx-react 0.1.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.
Files changed (168) hide show
  1. package/lib/_virtual/_rolldown/runtime.js +13 -0
  2. package/lib/components/ActionButton/ActionButton.d.ts +44 -0
  3. package/lib/components/ActionButton/ActionButton.d.ts.map +1 -0
  4. package/lib/components/ActionButton/ActionButton.jsx +158 -0
  5. package/lib/components/ActionButton/index.d.ts +2 -0
  6. package/lib/components/ActionButton/index.d.ts.map +1 -0
  7. package/lib/components/BottomSheet/BottomSheet.d.ts +87 -0
  8. package/lib/components/BottomSheet/BottomSheet.d.ts.map +1 -0
  9. package/lib/components/BottomSheet/BottomSheet.jsx +150 -0
  10. package/lib/components/BottomSheet/BottomSheet.namespace.d.ts +2 -0
  11. package/lib/components/BottomSheet/BottomSheet.namespace.d.ts.map +1 -0
  12. package/lib/components/BottomSheet/BottomSheet.namespace.js +18 -0
  13. package/lib/components/BottomSheet/index.d.ts +3 -0
  14. package/lib/components/BottomSheet/index.d.ts.map +1 -0
  15. package/lib/components/Box/Box.d.ts +20 -0
  16. package/lib/components/Box/Box.d.ts.map +1 -0
  17. package/lib/components/Box/Box.jsx +14 -0
  18. package/lib/components/Box/index.d.ts +2 -0
  19. package/lib/components/Box/index.d.ts.map +1 -0
  20. package/lib/components/Checkbox/Checkbox.d.ts +35 -0
  21. package/lib/components/Checkbox/Checkbox.d.ts.map +1 -0
  22. package/lib/components/Checkbox/Checkbox.jsx +130 -0
  23. package/lib/components/Checkbox/Checkbox.namespace.d.ts +2 -0
  24. package/lib/components/Checkbox/Checkbox.namespace.d.ts.map +1 -0
  25. package/lib/components/Checkbox/Checkbox.namespace.js +12 -0
  26. package/lib/components/Checkbox/index.d.ts +3 -0
  27. package/lib/components/Checkbox/index.d.ts.map +1 -0
  28. package/lib/components/Icon/Icon.d.ts +37 -0
  29. package/lib/components/Icon/Icon.d.ts.map +1 -0
  30. package/lib/components/Icon/Icon.jsx +143 -0
  31. package/lib/components/Icon/index.d.ts +2 -0
  32. package/lib/components/Icon/index.d.ts.map +1 -0
  33. package/lib/components/ProgressCircle/ProgressCircle.d.ts +25 -0
  34. package/lib/components/ProgressCircle/ProgressCircle.d.ts.map +1 -0
  35. package/lib/components/ProgressCircle/ProgressCircle.jsx +281 -0
  36. package/lib/components/ProgressCircle/ProgressCircle.namespace.d.ts +2 -0
  37. package/lib/components/ProgressCircle/ProgressCircle.namespace.d.ts.map +1 -0
  38. package/lib/components/ProgressCircle/ProgressCircle.namespace.js +9 -0
  39. package/lib/components/ProgressCircle/index.d.ts +3 -0
  40. package/lib/components/ProgressCircle/index.d.ts.map +1 -0
  41. package/lib/components/RadioGroup/RadioGroup.d.ts +34 -0
  42. package/lib/components/RadioGroup/RadioGroup.d.ts.map +1 -0
  43. package/lib/components/RadioGroup/RadioGroup.jsx +160 -0
  44. package/lib/components/RadioGroup/RadioGroup.namespace.d.ts +2 -0
  45. package/lib/components/RadioGroup/RadioGroup.namespace.d.ts.map +1 -0
  46. package/lib/components/RadioGroup/RadioGroup.namespace.js +12 -0
  47. package/lib/components/RadioGroup/index.d.ts +3 -0
  48. package/lib/components/RadioGroup/index.d.ts.map +1 -0
  49. package/lib/components/Stack/Stack.d.ts +33 -0
  50. package/lib/components/Stack/Stack.d.ts.map +1 -0
  51. package/lib/components/Stack/Stack.jsx +39 -0
  52. package/lib/components/Stack/index.d.ts +2 -0
  53. package/lib/components/Stack/index.d.ts.map +1 -0
  54. package/lib/components/Switch/Switch.d.ts +21 -0
  55. package/lib/components/Switch/Switch.d.ts.map +1 -0
  56. package/lib/components/Switch/Switch.jsx +101 -0
  57. package/lib/components/Switch/Switch.namespace.d.ts +2 -0
  58. package/lib/components/Switch/Switch.namespace.d.ts.map +1 -0
  59. package/lib/components/Switch/Switch.namespace.js +11 -0
  60. package/lib/components/Switch/index.d.ts +3 -0
  61. package/lib/components/Switch/index.d.ts.map +1 -0
  62. package/lib/components/TagGroup/TagGroup.d.ts +34 -0
  63. package/lib/components/TagGroup/TagGroup.d.ts.map +1 -0
  64. package/lib/components/TagGroup/TagGroup.jsx +57 -0
  65. package/lib/components/TagGroup/TagGroup.namespace.d.ts +2 -0
  66. package/lib/components/TagGroup/TagGroup.namespace.d.ts.map +1 -0
  67. package/lib/components/TagGroup/TagGroup.namespace.js +10 -0
  68. package/lib/components/TagGroup/index.d.ts +3 -0
  69. package/lib/components/TagGroup/index.d.ts.map +1 -0
  70. package/lib/components/Text/Text.d.ts +7 -0
  71. package/lib/components/Text/Text.d.ts.map +1 -0
  72. package/lib/components/Text/Text.jsx +28 -0
  73. package/lib/components/Text/index.d.ts +2 -0
  74. package/lib/components/Text/index.d.ts.map +1 -0
  75. package/lib/components/index.d.ts +12 -0
  76. package/lib/components/index.d.ts.map +1 -0
  77. package/lib/hooks/index.d.ts +5 -0
  78. package/lib/hooks/index.d.ts.map +1 -0
  79. package/lib/hooks/useControllableState.d.ts +15 -0
  80. package/lib/hooks/useControllableState.d.ts.map +1 -0
  81. package/lib/hooks/useControllableState.js +27 -0
  82. package/lib/hooks/useIconColor.d.ts +19 -0
  83. package/lib/hooks/useIconColor.d.ts.map +1 -0
  84. package/lib/hooks/useIconColor.js +38 -0
  85. package/lib/hooks/usePressTap.d.ts +27 -0
  86. package/lib/hooks/usePressTap.d.ts.map +1 -0
  87. package/lib/hooks/usePressTap.js +35 -0
  88. package/lib/hooks/useSafeArea.d.ts +19 -0
  89. package/lib/hooks/useSafeArea.d.ts.map +1 -0
  90. package/lib/hooks/useSafeArea.js +33 -0
  91. package/lib/index.d.ts +5 -0
  92. package/lib/index.d.ts.map +1 -0
  93. package/lib/index.js +26 -0
  94. package/lib/types.d.ts +33 -0
  95. package/lib/types.d.ts.map +1 -0
  96. package/lib/utils/children.d.ts +3 -0
  97. package/lib/utils/children.d.ts.map +1 -0
  98. package/lib/utils/children.js +8 -0
  99. package/lib/utils/create-slot-recipe-context.d.ts +27 -0
  100. package/lib/utils/create-slot-recipe-context.d.ts.map +1 -0
  101. package/lib/utils/create-slot-recipe-context.jsx +94 -0
  102. package/lib/utils/get-seed-class-name.d.ts +22 -0
  103. package/lib/utils/get-seed-class-name.d.ts.map +1 -0
  104. package/lib/utils/get-seed-class-name.js +28 -0
  105. package/lib/utils/index.d.ts +5 -0
  106. package/lib/utils/index.d.ts.map +1 -0
  107. package/lib/utils/resolve-recipe-token.d.ts +38 -0
  108. package/lib/utils/resolve-recipe-token.d.ts.map +1 -0
  109. package/lib/utils/resolve-recipe-token.js +49 -0
  110. package/lib/utils/split-multiple-variants-props.d.ts +29 -0
  111. package/lib/utils/split-multiple-variants-props.d.ts.map +1 -0
  112. package/lib/utils/split-multiple-variants-props.js +34 -0
  113. package/lib/utils/styled.d.ts +83 -0
  114. package/lib/utils/styled.d.ts.map +1 -0
  115. package/lib/utils/styled.js +144 -0
  116. package/package.json +54 -0
  117. package/src/components/ActionButton/ActionButton.test.tsx +137 -0
  118. package/src/components/ActionButton/ActionButton.tsx +340 -0
  119. package/src/components/ActionButton/index.ts +1 -0
  120. package/src/components/BottomSheet/BottomSheet.namespace.ts +24 -0
  121. package/src/components/BottomSheet/BottomSheet.test.tsx +153 -0
  122. package/src/components/BottomSheet/BottomSheet.tsx +397 -0
  123. package/src/components/BottomSheet/index.ts +27 -0
  124. package/src/components/Box/Box.test.tsx +85 -0
  125. package/src/components/Box/Box.tsx +39 -0
  126. package/src/components/Box/index.ts +1 -0
  127. package/src/components/Checkbox/Checkbox.namespace.ts +12 -0
  128. package/src/components/Checkbox/Checkbox.tsx +282 -0
  129. package/src/components/Checkbox/index.ts +14 -0
  130. package/src/components/Icon/Icon.tsx +265 -0
  131. package/src/components/Icon/index.ts +8 -0
  132. package/src/components/ProgressCircle/ProgressCircle.namespace.ts +5 -0
  133. package/src/components/ProgressCircle/ProgressCircle.tsx +424 -0
  134. package/src/components/ProgressCircle/index.ts +9 -0
  135. package/src/components/RadioGroup/RadioGroup.namespace.ts +12 -0
  136. package/src/components/RadioGroup/RadioGroup.tsx +320 -0
  137. package/src/components/RadioGroup/index.ts +14 -0
  138. package/src/components/Stack/Stack.tsx +97 -0
  139. package/src/components/Stack/index.ts +1 -0
  140. package/src/components/Switch/Switch.namespace.ts +10 -0
  141. package/src/components/Switch/Switch.tsx +200 -0
  142. package/src/components/Switch/index.ts +12 -0
  143. package/src/components/TagGroup/TagGroup.namespace.ts +8 -0
  144. package/src/components/TagGroup/TagGroup.tsx +127 -0
  145. package/src/components/TagGroup/index.ts +10 -0
  146. package/src/components/Text/Text.tsx +64 -0
  147. package/src/components/Text/index.ts +1 -0
  148. package/src/components/index.ts +11 -0
  149. package/src/hooks/index.ts +4 -0
  150. package/src/hooks/useControllableState.test.ts +120 -0
  151. package/src/hooks/useControllableState.ts +38 -0
  152. package/src/hooks/useIconColor.test.ts +13 -0
  153. package/src/hooks/useIconColor.ts +67 -0
  154. package/src/hooks/usePressTap.test.ts +129 -0
  155. package/src/hooks/usePressTap.ts +58 -0
  156. package/src/hooks/useSafeArea.test.ts +83 -0
  157. package/src/hooks/useSafeArea.ts +56 -0
  158. package/src/index.ts +4 -0
  159. package/src/types.ts +45 -0
  160. package/src/utils/children.ts +13 -0
  161. package/src/utils/create-slot-recipe-context.test.tsx +96 -0
  162. package/src/utils/create-slot-recipe-context.tsx +187 -0
  163. package/src/utils/get-seed-class-name.ts +47 -0
  164. package/src/utils/index.ts +4 -0
  165. package/src/utils/resolve-recipe-token.ts +47 -0
  166. package/src/utils/split-multiple-variants-props.test.ts +61 -0
  167. package/src/utils/split-multiple-variants-props.ts +59 -0
  168. package/src/utils/styled.ts +442 -0
@@ -0,0 +1,83 @@
1
+ import { renderHook } from "@lynx-js/react/testing-library";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { useSafeArea } from "./useSafeArea";
5
+
6
+ interface TestLynxGlobal {
7
+ lynx?: {
8
+ __globalProps?: {
9
+ safeAreaInsetTop?: number;
10
+ safeAreaInsetBottom?: number;
11
+ };
12
+ };
13
+ lynxTestingEnv?: {
14
+ backgroundThread: {
15
+ globalThis: TestLynxGlobal;
16
+ };
17
+ mainThread: {
18
+ globalThis: TestLynxGlobal;
19
+ };
20
+ };
21
+ }
22
+
23
+ function setGlobalProps(
24
+ globalProps: NonNullable<NonNullable<TestLynxGlobal["lynx"]>["__globalProps"]>,
25
+ ) {
26
+ const lynxTestingEnv = (globalThis as TestLynxGlobal).lynxTestingEnv;
27
+
28
+ if (!lynxTestingEnv) {
29
+ throw new Error("Expected Lynx testing environment globals to be available.");
30
+ }
31
+
32
+ const globals = [
33
+ globalThis as TestLynxGlobal,
34
+ lynxTestingEnv.backgroundThread.globalThis,
35
+ lynxTestingEnv.mainThread.globalThis,
36
+ ];
37
+
38
+ for (const global of globals) {
39
+ global.lynx = {
40
+ ...global.lynx,
41
+ __globalProps: globalProps,
42
+ };
43
+ }
44
+ }
45
+
46
+ describe("useSafeArea", () => {
47
+ it("falls back to Lynx env values when host flat global props are missing", () => {
48
+ const { result } = renderHook(() => useSafeArea());
49
+
50
+ expect(result.current).toEqual({
51
+ safeAreaInsetTop: "env(safe-area-inset-top)",
52
+ safeAreaInsetBottom: "env(safe-area-inset-bottom)",
53
+ });
54
+ });
55
+
56
+ it("uses flat globalProps values before env fallback", () => {
57
+ setGlobalProps({
58
+ safeAreaInsetTop: 48,
59
+ safeAreaInsetBottom: 35,
60
+ });
61
+
62
+ const { result } = renderHook(() => useSafeArea());
63
+
64
+ expect(result.current).toEqual({
65
+ safeAreaInsetTop: "48px",
66
+ safeAreaInsetBottom: "35px",
67
+ });
68
+ });
69
+
70
+ it("falls back to Lynx env values when host flat global props are zero", () => {
71
+ setGlobalProps({
72
+ safeAreaInsetTop: 0,
73
+ safeAreaInsetBottom: 0,
74
+ });
75
+
76
+ const { result } = renderHook(() => useSafeArea());
77
+
78
+ expect(result.current).toEqual({
79
+ safeAreaInsetTop: "env(safe-area-inset-top)",
80
+ safeAreaInsetBottom: "env(safe-area-inset-bottom)",
81
+ });
82
+ });
83
+ });
@@ -0,0 +1,56 @@
1
+ import { useMemo } from "@lynx-js/react";
2
+
3
+ type SafeAreaEdge = "top" | "bottom";
4
+
5
+ declare module "@lynx-js/types" {
6
+ interface GlobalProps {
7
+ safeAreaInsetTop?: number;
8
+ safeAreaInsetBottom?: number;
9
+ }
10
+ }
11
+
12
+ const safeAreaProp: Record<SafeAreaEdge, "safeAreaInsetTop" | "safeAreaInsetBottom"> = {
13
+ top: "safeAreaInsetTop",
14
+ bottom: "safeAreaInsetBottom",
15
+ };
16
+
17
+ function getSafeAreaEnvInset(edge: SafeAreaEdge): string {
18
+ return `env(safe-area-inset-${edge})`;
19
+ }
20
+
21
+ function normalizeSafeAreaValue(value: number | undefined): string | undefined {
22
+ if (typeof value !== "number" || value <= 0 || !Number.isFinite(value)) {
23
+ return undefined;
24
+ }
25
+
26
+ return `${value}px`;
27
+ }
28
+
29
+ function resolveSafeAreaInset(edge: SafeAreaEdge, value: number | undefined): string {
30
+ return normalizeSafeAreaValue(value) ?? getSafeAreaEnvInset(edge);
31
+ }
32
+
33
+ export interface UseSafeAreaReturn {
34
+ safeAreaInsetTop: string;
35
+ safeAreaInsetBottom: string;
36
+ }
37
+
38
+ /**
39
+ * Lynx 앱에서 top/bottom safe area inset 값을 반환합니다.
40
+ *
41
+ * `lynx.__globalProps.safeAreaInsetTop`과 `safeAreaInsetBottom`을 우선 사용하고,
42
+ * host가 값을 제공하지 않으면 Lynx CSS `env(safe-area-inset-*)` 값을 fallback으로
43
+ * 반환합니다.
44
+ */
45
+ export function useSafeArea(): UseSafeAreaReturn {
46
+ const safeAreaInsetTop = lynx.__globalProps?.[safeAreaProp.top];
47
+ const safeAreaInsetBottom = lynx.__globalProps?.[safeAreaProp.bottom];
48
+
49
+ return useMemo(
50
+ () => ({
51
+ safeAreaInsetTop: resolveSafeAreaInset("top", safeAreaInsetTop),
52
+ safeAreaInsetBottom: resolveSafeAreaInset("bottom", safeAreaInsetBottom),
53
+ }),
54
+ [safeAreaInsetTop, safeAreaInsetBottom],
55
+ );
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./components";
2
+ export * from "./hooks";
3
+ export type * from "./types";
4
+ export * from "./utils";
package/src/types.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { ReactNode, Ref } from "@lynx-js/react";
2
+ import type {
3
+ CSSProperties,
4
+ IntrinsicElements,
5
+ MainThread,
6
+ NodesRef,
7
+ TextProps,
8
+ ViewProps,
9
+ } from "@lynx-js/types";
10
+
11
+ export type LynxViewProps = IntrinsicElements["view"] & ViewProps;
12
+ export type LynxTextProps = IntrinsicElements["text"] & TextProps;
13
+ export type LynxViewRef = Ref<NodesRef>;
14
+ export type LynxTextRef = Ref<NodesRef>;
15
+ export type LynxMainThreadRef = Ref<MainThread.Element>;
16
+ export type LynxStyle = CSSProperties;
17
+
18
+ export interface LynxElementProps {
19
+ children?: ReactNode;
20
+ className?: LynxViewProps["className"];
21
+ }
22
+
23
+ export interface LynxStyledElementProps extends LynxElementProps {
24
+ style?: LynxStyle;
25
+ }
26
+
27
+ export interface LynxPressableProps {
28
+ bindtap?: LynxViewProps["bindtap"];
29
+ "main-thread:bindtap"?: LynxViewProps["main-thread:bindtap"];
30
+ }
31
+
32
+ export interface LynxTouchProps {
33
+ bindtap?: LynxViewProps["bindtap"];
34
+ bindtouchstart?: LynxViewProps["bindtouchstart"];
35
+ bindtouchend?: LynxViewProps["bindtouchend"];
36
+ bindtouchcancel?: LynxViewProps["bindtouchcancel"];
37
+ "main-thread:bindtap"?: LynxViewProps["main-thread:bindtap"];
38
+ }
39
+
40
+ export interface LynxIconElementProps {
41
+ className?: IntrinsicElements["image"]["className"];
42
+ style?: LynxStyle;
43
+ ref?: LynxMainThreadRef;
44
+ "main-thread:binduiappear"?: IntrinsicElements["image"]["main-thread:binduiappear"];
45
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "@lynx-js/react";
2
+
3
+ export function toArray(children: ReactNode): ReactNode[] {
4
+ if (children == null || typeof children === "boolean") {
5
+ return [];
6
+ }
7
+
8
+ if (Array.isArray(children)) {
9
+ return children.flatMap(toArray);
10
+ }
11
+
12
+ return [children];
13
+ }
@@ -0,0 +1,96 @@
1
+ import { forwardRef } from "@lynx-js/react";
2
+ import { render } from "@lynx-js/react/testing-library";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { createSlotRecipeContext } from "./create-slot-recipe-context";
6
+
7
+ type MockVariantProps = {
8
+ variant?: "a" | "b";
9
+ };
10
+
11
+ type MockClassnames = {
12
+ root: string;
13
+ label: string;
14
+ };
15
+
16
+ const mockSlotRecipe = Object.assign(
17
+ (props?: MockVariantProps): MockClassnames => ({
18
+ root: `root-${props?.variant ?? "a"}`,
19
+ label: `label-${props?.variant ?? "a"}`,
20
+ }),
21
+ {
22
+ splitVariantProps: <T extends MockVariantProps>(props: T) => {
23
+ const { variant, ...rest } = props;
24
+ return [{ variant }, rest] as [MockVariantProps, Omit<T, keyof MockVariantProps>];
25
+ },
26
+ },
27
+ );
28
+
29
+ type MockProps = {
30
+ children?: unknown;
31
+ className?: string;
32
+ };
33
+
34
+ type MockReceived = { props?: MockProps };
35
+
36
+ function createMock(name: string) {
37
+ const received: MockReceived = {};
38
+ const Mock = forwardRef<{ tag: "mock" }, MockProps>((props) => {
39
+ received.props = props;
40
+ return <>{props.children as never}</>;
41
+ });
42
+ Mock.displayName = name;
43
+ return { Mock, received };
44
+ }
45
+
46
+ describe("createSlotRecipeContext", () => {
47
+ it("rejects intrinsic native tags to avoid Lynx BackgroundSnapshot errors", () => {
48
+ const { withRootProvider, withProvider, withContext } = createSlotRecipeContext(mockSlotRecipe);
49
+
50
+ expect(() => withRootProvider<MockVariantProps>("view")).toThrow(
51
+ /literal JSX.*BackgroundSnapshot/,
52
+ );
53
+ expect(() => withProvider<unknown, MockVariantProps>("view", "root")).toThrow(
54
+ /literal JSX.*BackgroundSnapshot/,
55
+ );
56
+ expect(() => withContext<unknown, Record<string, unknown>>("text", "label")).toThrow(
57
+ /literal JSX.*BackgroundSnapshot/,
58
+ );
59
+ });
60
+
61
+ it("injects root classnames into context without applying className on the root element", () => {
62
+ const { Mock: Root, received: rootReceived } = createMock("Root");
63
+ const { Mock: Child, received: childReceived } = createMock("Child");
64
+ const { withRootProvider, withContext } = createSlotRecipeContext(mockSlotRecipe);
65
+
66
+ const StyledRoot = withRootProvider<MockVariantProps & MockProps>(Root);
67
+ const StyledLabel = withContext<{ tag: "mock" }, MockProps>(Child, "label");
68
+
69
+ render(
70
+ <StyledRoot variant="b">
71
+ <StyledLabel />
72
+ </StyledRoot>,
73
+ );
74
+
75
+ expect(rootReceived.props).not.toHaveProperty("className");
76
+ expect(childReceived.props?.className).toBe("label-b");
77
+ });
78
+
79
+ it("applies and merges provider slot classnames", () => {
80
+ const { Mock: Root, received: rootReceived } = createMock("Root");
81
+ const { Mock: Child, received: childReceived } = createMock("Child");
82
+ const { withProvider, withContext } = createSlotRecipeContext(mockSlotRecipe);
83
+
84
+ const StyledRoot = withProvider<{ tag: "mock" }, MockVariantProps & MockProps>(Root, "root");
85
+ const StyledLabel = withContext<{ tag: "mock" }, MockProps>(Child, "label");
86
+
87
+ render(
88
+ <StyledRoot variant="a" className="extra">
89
+ <StyledLabel className="child-extra" />
90
+ </StyledRoot>,
91
+ );
92
+
93
+ expect(rootReceived.props?.className).toBe("root-a extra");
94
+ expect(childReceived.props?.className).toBe("label-a child-extra");
95
+ });
96
+ });
@@ -0,0 +1,187 @@
1
+ import { createContext, forwardRef, useContext } from "@lynx-js/react";
2
+ import type {
3
+ ElementType,
4
+ ForwardRefExoticComponent,
5
+ PropsWithoutRef,
6
+ ReactElement,
7
+ ReactNode,
8
+ RefAttributes,
9
+ } from "@lynx-js/react";
10
+ import clsx from "clsx";
11
+
12
+ type SlotRecipe<
13
+ Props extends Record<string, string | boolean | undefined>,
14
+ Classnames extends Record<string, string>,
15
+ > = ((props?: Props) => Classnames) & {
16
+ splitVariantProps: <T extends Props>(props: T) => [Props, Omit<T, keyof Props>];
17
+ };
18
+
19
+ function assertNotIntrinsicComponent(Component: ElementType<any>, caller: string) {
20
+ if (typeof Component !== "string") return;
21
+
22
+ throw new Error(
23
+ `${caller} cannot wrap intrinsic tag "${Component}" in Lynx. Native <view>/<text> slots must be rendered as literal JSX in the component file; passing intrinsic strings through createSlotRecipeContext can cause BackgroundSnapshot not found errors.`,
24
+ );
25
+ }
26
+
27
+ export function createSlotRecipeContext<
28
+ Props extends Record<string, string | boolean | undefined>,
29
+ Classnames extends Record<string, string>,
30
+ >(
31
+ recipe: SlotRecipe<Props, Classnames>,
32
+ ): {
33
+ ClassNamesProvider: (props: { children: ReactNode; value: Classnames }) => ReactElement;
34
+ PropsProvider: (props: { children: ReactNode; value: Props }) => ReactElement;
35
+ useClassNames: () => Classnames;
36
+ useProps: () => Props | null;
37
+ withRootProvider: <P>(
38
+ Component: ElementType<any>,
39
+ options?: {
40
+ defaultProps?: Partial<P>;
41
+ },
42
+ ) => ForwardRefExoticComponent<PropsWithoutRef<P>>;
43
+ withProvider: <T, P>(
44
+ Component: ElementType<any>,
45
+ slot: keyof Classnames,
46
+ options?: {
47
+ defaultProps?: Partial<P>;
48
+ },
49
+ ) => ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
50
+ withContext: <T, P>(
51
+ Component: ElementType<any>,
52
+ slot?: keyof Classnames,
53
+ options?: {
54
+ defaultProps?: Partial<P>;
55
+ },
56
+ ) => ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;
57
+ } {
58
+ const ClassNamesContext = createContext<Classnames | null>(null);
59
+ const PropsContext = createContext<Props | null>(null);
60
+
61
+ const ClassNamesProvider = ({ children, value }: { children: ReactNode; value: Classnames }) => {
62
+ return <ClassNamesContext.Provider value={value}>{children}</ClassNamesContext.Provider>;
63
+ };
64
+
65
+ const PropsProvider = ({ children, value }: { children: ReactNode; value: Props }) => {
66
+ return <PropsContext.Provider value={value}>{children}</PropsContext.Provider>;
67
+ };
68
+
69
+ function useClassNames() {
70
+ const context = useContext(ClassNamesContext);
71
+ if (context === null) {
72
+ throw new Error(
73
+ "useClassNames must be used within a ClassNamesProvider. Did you forget to wrap your component in a ClassNamesProvider?",
74
+ );
75
+ }
76
+
77
+ return context;
78
+ }
79
+
80
+ function useProps() {
81
+ return useContext(PropsContext);
82
+ }
83
+
84
+ const withRootProvider = <P,>(
85
+ Component: ElementType<any>,
86
+ options?: {
87
+ defaultProps?: Partial<P>;
88
+ },
89
+ ): ForwardRefExoticComponent<PropsWithoutRef<P>> => {
90
+ assertNotIntrinsicComponent(Component, "withRootProvider");
91
+ const { defaultProps } = options ?? {};
92
+
93
+ const StyledComponent = (innerProps: any) => {
94
+ const props = { ...(defaultProps ?? {}), ...useProps(), ...innerProps } as Props &
95
+ Record<string, unknown>;
96
+ const [variantProps, otherProps] = recipe.splitVariantProps(props);
97
+ const classNames = recipe(variantProps);
98
+
99
+ return (
100
+ <ClassNamesProvider value={classNames}>
101
+ <PropsProvider value={variantProps}>
102
+ <Component {...otherProps} />
103
+ </PropsProvider>
104
+ </ClassNamesProvider>
105
+ );
106
+ };
107
+
108
+ // @ts-expect-error — assigning displayName to a plain function component
109
+ StyledComponent.displayName = Component.displayName || Component.name;
110
+
111
+ return StyledComponent as any;
112
+ };
113
+
114
+ const withProvider = <T, P>(
115
+ Component: ElementType<any>,
116
+ slot: keyof Classnames,
117
+ options?: {
118
+ defaultProps?: Partial<P>;
119
+ },
120
+ ): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>> => {
121
+ assertNotIntrinsicComponent(Component, "withProvider");
122
+ const { defaultProps } = options ?? {};
123
+
124
+ const StyledComponent = forwardRef<any, any>((innerProps, ref) => {
125
+ const props: any = { ...(defaultProps ?? {}), ...useProps(), ...innerProps };
126
+ const [variantProps, otherProps] = recipe.splitVariantProps(props as Props);
127
+ const classNames = recipe(variantProps);
128
+ const slotClassName: string | undefined = classNames[slot];
129
+ const userClassName: string | undefined = props["className"];
130
+
131
+ return (
132
+ <ClassNamesProvider value={classNames}>
133
+ <PropsProvider value={variantProps}>
134
+ <Component
135
+ {...(ref ? { ref } : {})}
136
+ {...otherProps}
137
+ className={clsx(slotClassName, userClassName)}
138
+ />
139
+ </PropsProvider>
140
+ </ClassNamesProvider>
141
+ );
142
+ });
143
+
144
+ StyledComponent.displayName = (Component as any).displayName || (Component as any).name;
145
+
146
+ return StyledComponent as any;
147
+ };
148
+
149
+ const withContext = <T, P>(
150
+ Component: ElementType<any>,
151
+ slot?: keyof Classnames,
152
+ options?: {
153
+ defaultProps?: Partial<P>;
154
+ },
155
+ ): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>> => {
156
+ assertNotIntrinsicComponent(Component, "withContext");
157
+ const { defaultProps } = options ?? {};
158
+
159
+ const StyledComponent = forwardRef<any, any>((innerProps, ref) => {
160
+ const props: any = { ...(defaultProps ?? {}), ...innerProps };
161
+ const classNames = useClassNames();
162
+ const slotClassName: string | undefined = slot ? classNames[slot] : undefined;
163
+ const userClassName: string | undefined = props["className"];
164
+
165
+ return (
166
+ <Component
167
+ {...(ref ? { ref } : {})}
168
+ {...props}
169
+ className={clsx(slotClassName, userClassName)}
170
+ />
171
+ );
172
+ });
173
+
174
+ StyledComponent.displayName = (Component as any).displayName || (Component as any).name;
175
+ return StyledComponent as any;
176
+ };
177
+
178
+ return {
179
+ ClassNamesProvider,
180
+ PropsProvider,
181
+ useClassNames,
182
+ useProps,
183
+ withRootProvider,
184
+ withProvider,
185
+ withContext,
186
+ };
187
+ }
@@ -0,0 +1,47 @@
1
+ // Lynx 런타임 전역 변수 타입 선언
2
+ // 실제 런타임에서는 Lynx 엔진이 제공하며, 빌드 시에는 @lynx-js/types가 정의함
3
+ declare const lynx: {
4
+ __globalProps?: Record<string, unknown>;
5
+ };
6
+
7
+ export type ColorMode = "system" | "light-only" | "dark-only";
8
+
9
+ export interface GetSeedClassNameOptions {
10
+ colorMode?: ColorMode;
11
+ }
12
+
13
+ /**
14
+ * Lynx 앱의 root `<page>` 요소에 적용할 SEED Design className을 반환한다.
15
+ *
16
+ * 테마(dark/light)를 자동 감지하여 렌더링 전에 올바른 CSS 변수가 적용되도록 한다.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * import { getSeedClassName } from "@seed-design/lynx-react";
21
+ *
22
+ * root.render(
23
+ * <page className={getSeedClassName({ colorMode: "system" })}>
24
+ * <App />
25
+ * </page>
26
+ * );
27
+ * ```
28
+ */
29
+ export function getSeedClassName(options?: GetSeedClassNameOptions): string {
30
+ const { colorMode = "system" } = options ?? {};
31
+
32
+ // Theme — lynx.__globalProps.theme에서 시스템 테마 읽기
33
+ const systemTheme = lynx?.__globalProps?.["theme"] as string | undefined;
34
+ let themeClass: string;
35
+ if (colorMode === "light-only") {
36
+ themeClass = "seed-user-color-scheme-light";
37
+ } else if (colorMode === "dark-only") {
38
+ themeClass = "seed-user-color-scheme-dark";
39
+ } else {
40
+ themeClass =
41
+ systemTheme?.toLowerCase() === "dark"
42
+ ? "seed-user-color-scheme-dark"
43
+ : "seed-user-color-scheme-light";
44
+ }
45
+
46
+ return themeClass;
47
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./create-slot-recipe-context";
2
+ export * from "./get-seed-class-name";
3
+ export * from "./resolve-recipe-token";
4
+ export * from "./split-multiple-variants-props";
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Rootage vars 객체에서 `path` 를 따라 token 값(주로 `"var(--seed-...)"` 스트링)을 꺼낸다.
3
+ *
4
+ * 쓰임새: Lynx `<image>` 의 `tint-color` / `width` / `height` 처럼 CSS 만으로는
5
+ * override 할 수 없거나(아이콘 패키지가 inline style 을 박는 경우 등) React 가
6
+ * recipe 와 같은 소스에서 꺼내 inline attribute 로 주입해야 하는 속성을 다룰 때.
7
+ *
8
+ * ```ts
9
+ * import { actionButton as actionButtonVars } from "@seed-design/lynx-css/vars/component";
10
+ * import { capitalize, resolveRecipeToken } from "@seed-design/lynx-react/utils/resolve-recipe-token";
11
+ *
12
+ * // variant × state × slot → color
13
+ * const color = resolveRecipeToken(actionButtonVars, [
14
+ * `variant${capitalize(variant)}`,
15
+ * state, // "enabled" | "disabled" | "loading" | "pressed"
16
+ * "prefixIcon",
17
+ * "color",
18
+ * ]);
19
+ *
20
+ * // size × layout → icon size
21
+ * const size = resolveRecipeToken(actionButtonVars, [
22
+ * `size${capitalize(size)}Layout${capitalize(layout)}`,
23
+ * "enabled",
24
+ * "prefixIcon",
25
+ * "size",
26
+ * ]);
27
+ * ```
28
+ *
29
+ * recipe token 경로가 컴포넌트마다 조금씩 다르므로(variant/size/layout 조합 여부 등)
30
+ * 이 유틸은 "vars + path" 조합만 제공하고, 각 컴포넌트가 자기 path 를 만들어 호출한다.
31
+ */
32
+ export function resolveRecipeToken(vars: unknown, path: readonly string[]): string | undefined {
33
+ let cursor: unknown = vars;
34
+ for (const key of path) {
35
+ if (cursor == null || typeof cursor !== "object") return undefined;
36
+ cursor = (cursor as Record<string, unknown>)[key];
37
+ }
38
+ return typeof cursor === "string" ? cursor : undefined;
39
+ }
40
+
41
+ /**
42
+ * 첫 글자 대문자. recipe variant key 조립용
43
+ * (e.g. `variant` + `brandSolid` → `variantBrandSolid`).
44
+ */
45
+ export function capitalize<T extends string>(value: T): Capitalize<T> {
46
+ return (value.charAt(0).toUpperCase() + value.slice(1)) as Capitalize<T>;
47
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { splitMultipleVariantsProps } from "./split-multiple-variants-props";
3
+
4
+ function makeRecipe<V extends Record<string, unknown>>(variantKeys: (keyof V)[]) {
5
+ return {
6
+ splitVariantProps: (props: Record<string, unknown>) => {
7
+ const variantProps: Record<string, unknown> = {};
8
+ const rest: Record<string, unknown> = {};
9
+ for (const key in props) {
10
+ if (variantKeys.includes(key as keyof V)) {
11
+ variantProps[key] = props[key];
12
+ } else {
13
+ rest[key] = props[key];
14
+ }
15
+ }
16
+ return [variantProps, rest] as [V, Omit<Record<string, unknown>, keyof V>];
17
+ },
18
+ };
19
+ }
20
+
21
+ describe("splitMultipleVariantsProps", () => {
22
+ it("splits props across disjoint recipes", () => {
23
+ const a = makeRecipe<{ size: string }>(["size"]);
24
+ const b = makeRecipe<{ tone: string }>(["tone"]);
25
+
26
+ const [{ a: av, b: bv }, rest] = splitMultipleVariantsProps(
27
+ { size: "32", tone: "brand", onTap: () => {}, children: "x" },
28
+ { a, b },
29
+ );
30
+
31
+ expect(av).toEqual({ size: "32" });
32
+ expect(bv).toEqual({ tone: "brand" });
33
+ expect(rest).toEqual({ onTap: expect.any(Function), children: "x" });
34
+ });
35
+
36
+ it("puts shared variant keys into every recipe bucket", () => {
37
+ const a = makeRecipe<{ size: string }>(["size"]);
38
+ const b = makeRecipe<{ size: string; tone: string }>(["size", "tone"]);
39
+
40
+ const [{ a: av, b: bv }, rest] = splitMultipleVariantsProps(
41
+ { size: "32", tone: "brand", disabled: true },
42
+ { a, b },
43
+ );
44
+
45
+ expect(av).toEqual({ size: "32" });
46
+ expect(bv).toEqual({ size: "32", tone: "brand" });
47
+ expect(rest).toEqual({ disabled: true });
48
+ });
49
+
50
+ it("returns empty variant buckets when no recipe claims a prop", () => {
51
+ const a = makeRecipe<{ size: string }>(["size"]);
52
+
53
+ const [{ a: av }, rest] = splitMultipleVariantsProps(
54
+ { onTap: () => {}, className: "x" },
55
+ { a },
56
+ );
57
+
58
+ expect(av).toEqual({});
59
+ expect(rest).toEqual({ onTap: expect.any(Function), className: "x" });
60
+ });
61
+ });