@seed-design/lynx-react 0.2.0-alpha.2 → 0.2.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 (186) hide show
  1. package/lib/_virtual/_rolldown/runtime.js +13 -0
  2. package/lib/components/ActionButton/ActionButton.d.ts +26 -32
  3. package/lib/components/ActionButton/ActionButton.d.ts.map +1 -1
  4. package/lib/components/ActionButton/ActionButton.jsx +139 -120
  5. package/lib/components/ActionButton/index.d.ts +1 -1
  6. package/lib/components/AppBar/AppBar.d.ts +35 -0
  7. package/lib/components/AppBar/AppBar.d.ts.map +1 -0
  8. package/lib/components/AppBar/AppBar.jsx +132 -0
  9. package/lib/components/AppBar/AppBar.namespace.d.ts +2 -0
  10. package/lib/components/AppBar/AppBar.namespace.d.ts.map +1 -0
  11. package/lib/components/AppBar/AppBar.namespace.js +15 -0
  12. package/lib/components/AppBar/context.d.ts +13 -0
  13. package/lib/components/AppBar/context.d.ts.map +1 -0
  14. package/lib/components/AppBar/context.js +12 -0
  15. package/lib/components/AppBar/index.d.ts +3 -0
  16. package/lib/components/AppBar/index.d.ts.map +1 -0
  17. package/lib/components/AppBar/useAppBar.d.ts +15 -0
  18. package/lib/components/AppBar/useAppBar.d.ts.map +1 -0
  19. package/lib/components/AppBar/useAppBar.js +72 -0
  20. package/lib/components/Badge/Badge.d.ts +14 -0
  21. package/lib/components/Badge/Badge.d.ts.map +1 -0
  22. package/lib/components/Badge/Badge.jsx +15 -0
  23. package/lib/components/Badge/index.d.ts +2 -0
  24. package/lib/components/Badge/index.d.ts.map +1 -0
  25. package/lib/components/BottomSheet/BottomSheet.d.ts +22 -27
  26. package/lib/components/BottomSheet/BottomSheet.d.ts.map +1 -1
  27. package/lib/components/BottomSheet/BottomSheet.jsx +127 -142
  28. package/lib/components/BottomSheet/BottomSheet.namespace.d.ts +1 -1
  29. package/lib/components/BottomSheet/BottomSheet.namespace.js +18 -1
  30. package/lib/components/BottomSheet/index.d.ts +2 -2
  31. package/lib/components/Box/Box.d.ts +20 -0
  32. package/lib/components/Box/Box.d.ts.map +1 -0
  33. package/lib/components/Box/Box.jsx +14 -0
  34. package/lib/components/Box/index.d.ts +2 -0
  35. package/lib/components/Box/index.d.ts.map +1 -0
  36. package/lib/components/Checkbox/Checkbox.d.ts +14 -30
  37. package/lib/components/Checkbox/Checkbox.d.ts.map +1 -1
  38. package/lib/components/Checkbox/Checkbox.jsx +107 -98
  39. package/lib/components/Checkbox/Checkbox.namespace.d.ts +1 -1
  40. package/lib/components/Checkbox/Checkbox.namespace.js +12 -1
  41. package/lib/components/Checkbox/index.d.ts +2 -2
  42. package/lib/components/Icon/Icon.d.ts +37 -0
  43. package/lib/components/Icon/Icon.d.ts.map +1 -0
  44. package/lib/components/Icon/Icon.jsx +143 -0
  45. package/lib/components/Icon/index.d.ts +2 -0
  46. package/lib/components/Icon/index.d.ts.map +1 -0
  47. package/lib/components/ProgressCircle/ProgressCircle.d.ts +4 -8
  48. package/lib/components/ProgressCircle/ProgressCircle.d.ts.map +1 -1
  49. package/lib/components/ProgressCircle/ProgressCircle.jsx +255 -260
  50. package/lib/components/ProgressCircle/ProgressCircle.namespace.d.ts +1 -1
  51. package/lib/components/ProgressCircle/ProgressCircle.namespace.js +9 -1
  52. package/lib/components/ProgressCircle/index.d.ts +2 -2
  53. package/lib/components/RadioGroup/RadioGroup.d.ts +12 -28
  54. package/lib/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  55. package/lib/components/RadioGroup/RadioGroup.jsx +134 -118
  56. package/lib/components/RadioGroup/RadioGroup.namespace.d.ts +1 -1
  57. package/lib/components/RadioGroup/RadioGroup.namespace.js +12 -1
  58. package/lib/components/RadioGroup/index.d.ts +2 -2
  59. package/lib/components/Stack/Stack.d.ts +33 -0
  60. package/lib/components/Stack/Stack.d.ts.map +1 -0
  61. package/lib/components/Stack/Stack.jsx +39 -0
  62. package/lib/components/Stack/index.d.ts +2 -0
  63. package/lib/components/Stack/index.d.ts.map +1 -0
  64. package/lib/components/Switch/Switch.d.ts +8 -14
  65. package/lib/components/Switch/Switch.d.ts.map +1 -1
  66. package/lib/components/Switch/Switch.jsx +82 -56
  67. package/lib/components/Switch/Switch.namespace.d.ts +1 -1
  68. package/lib/components/Switch/Switch.namespace.js +11 -1
  69. package/lib/components/Switch/index.d.ts +2 -2
  70. package/lib/components/TagGroup/TagGroup.d.ts +8 -14
  71. package/lib/components/TagGroup/TagGroup.d.ts.map +1 -1
  72. package/lib/components/TagGroup/TagGroup.jsx +42 -39
  73. package/lib/components/TagGroup/TagGroup.namespace.d.ts +1 -1
  74. package/lib/components/TagGroup/TagGroup.namespace.js +10 -1
  75. package/lib/components/TagGroup/index.d.ts +2 -2
  76. package/lib/components/Text/Text.d.ts +7 -0
  77. package/lib/components/Text/Text.d.ts.map +1 -0
  78. package/lib/components/Text/Text.jsx +28 -0
  79. package/lib/components/Text/index.d.ts +2 -0
  80. package/lib/components/Text/index.d.ts.map +1 -0
  81. package/lib/components/index.d.ts +13 -7
  82. package/lib/components/index.d.ts.map +1 -1
  83. package/lib/hooks/index.d.ts +4 -3
  84. package/lib/hooks/index.d.ts.map +1 -1
  85. package/lib/hooks/{use-controllable-state.d.ts → useControllableState.d.ts} +1 -1
  86. package/lib/hooks/useControllableState.d.ts.map +1 -0
  87. package/lib/hooks/useControllableState.js +27 -0
  88. package/lib/hooks/useIconColor.d.ts +19 -0
  89. package/lib/hooks/useIconColor.d.ts.map +1 -0
  90. package/lib/hooks/useIconColor.js +38 -0
  91. package/lib/hooks/{use-press-tap.d.ts → usePressTap.d.ts} +12 -9
  92. package/lib/hooks/usePressTap.d.ts.map +1 -0
  93. package/lib/hooks/usePressTap.js +35 -0
  94. package/lib/hooks/useSafeArea.d.ts +19 -0
  95. package/lib/hooks/useSafeArea.d.ts.map +1 -0
  96. package/lib/hooks/useSafeArea.js +33 -0
  97. package/lib/index.d.ts +4 -3
  98. package/lib/index.d.ts.map +1 -1
  99. package/lib/index.js +29 -3
  100. package/lib/types.d.ts +51 -0
  101. package/lib/types.d.ts.map +1 -0
  102. package/lib/utils/children.d.ts +3 -0
  103. package/lib/utils/children.d.ts.map +1 -0
  104. package/lib/utils/children.js +8 -0
  105. package/lib/utils/create-slot-recipe-context.d.ts +13 -12
  106. package/lib/utils/create-slot-recipe-context.d.ts.map +1 -1
  107. package/lib/utils/create-slot-recipe-context.jsx +88 -70
  108. package/lib/utils/get-seed-class-name.d.ts +6 -7
  109. package/lib/utils/get-seed-class-name.d.ts.map +1 -1
  110. package/lib/utils/get-seed-class-name.js +26 -37
  111. package/lib/utils/index.d.ts +4 -4
  112. package/lib/utils/resolve-recipe-token.js +45 -43
  113. package/lib/utils/split-multiple-variants-props.js +32 -32
  114. package/lib/utils/styled.d.ts +83 -0
  115. package/lib/utils/styled.d.ts.map +1 -0
  116. package/lib/utils/styled.js +144 -0
  117. package/package.json +9 -9
  118. package/src/components/ActionButton/ActionButton.test.tsx +167 -0
  119. package/src/components/ActionButton/ActionButton.tsx +210 -168
  120. package/src/components/AppBar/AppBar.namespace.ts +18 -0
  121. package/src/components/AppBar/AppBar.test.tsx +270 -0
  122. package/src/components/AppBar/AppBar.tsx +288 -0
  123. package/src/components/AppBar/context.ts +27 -0
  124. package/src/components/AppBar/index.ts +21 -0
  125. package/src/components/AppBar/useAppBar.ts +100 -0
  126. package/src/components/Badge/Badge.test.tsx +54 -0
  127. package/src/components/Badge/Badge.tsx +32 -0
  128. package/src/components/Badge/index.ts +1 -0
  129. package/src/components/BottomSheet/BottomSheet.test.tsx +219 -0
  130. package/src/components/BottomSheet/BottomSheet.tsx +120 -83
  131. package/src/components/Box/Box.test.tsx +85 -0
  132. package/src/components/Box/Box.tsx +39 -0
  133. package/src/components/Box/index.ts +1 -0
  134. package/src/components/Checkbox/Checkbox.tsx +108 -117
  135. package/src/components/Icon/Icon.tsx +265 -0
  136. package/src/components/Icon/index.ts +8 -0
  137. package/src/components/ProgressCircle/ProgressCircle.tsx +39 -25
  138. package/src/components/RadioGroup/RadioGroup.tsx +100 -118
  139. package/src/components/Stack/Stack.tsx +97 -0
  140. package/src/components/Stack/index.ts +1 -0
  141. package/src/components/Switch/Switch.tsx +61 -52
  142. package/src/components/TagGroup/TagGroup.tsx +27 -25
  143. package/src/components/Text/Text.tsx +64 -0
  144. package/src/components/Text/index.ts +1 -0
  145. package/src/components/index.ts +6 -0
  146. package/src/hooks/index.ts +4 -3
  147. package/src/hooks/{__tests__/use-controllable-state.test.ts → useControllableState.test.ts} +1 -1
  148. package/src/hooks/useIconColor.test.ts +13 -0
  149. package/src/hooks/useIconColor.ts +67 -0
  150. package/src/hooks/{__tests__/use-press-tap.test.ts → usePressTap.test.ts} +1 -1
  151. package/src/hooks/{use-press-tap.ts → usePressTap.ts} +14 -10
  152. package/src/hooks/useSafeArea.test.ts +83 -0
  153. package/src/hooks/useSafeArea.ts +56 -0
  154. package/src/index.ts +1 -0
  155. package/src/types.ts +76 -0
  156. package/src/utils/children.ts +13 -0
  157. package/src/utils/create-slot-recipe-context.test.tsx +96 -0
  158. package/src/utils/create-slot-recipe-context.tsx +55 -15
  159. package/src/utils/get-seed-class-name.ts +7 -12
  160. package/src/utils/{__tests__/split-multiple-variants-props.test.ts → split-multiple-variants-props.test.ts} +1 -1
  161. package/src/utils/styled.ts +442 -0
  162. package/lib/components/ActionButton/index.js +0 -1
  163. package/lib/components/BottomSheet/index.js +0 -2
  164. package/lib/components/Checkbox/index.js +0 -2
  165. package/lib/components/ProgressCircle/index.js +0 -2
  166. package/lib/components/RadioGroup/index.js +0 -2
  167. package/lib/components/Switch/index.js +0 -2
  168. package/lib/components/TagGroup/index.js +0 -2
  169. package/lib/components/index.js +0 -7
  170. package/lib/hooks/index.js +0 -3
  171. package/lib/hooks/use-controllable-state.d.ts.map +0 -1
  172. package/lib/hooks/use-controllable-state.js +0 -27
  173. package/lib/hooks/use-icon-color.d.ts +0 -15
  174. package/lib/hooks/use-icon-color.d.ts.map +0 -1
  175. package/lib/hooks/use-icon-color.js +0 -37
  176. package/lib/hooks/use-press-tap.d.ts.map +0 -1
  177. package/lib/hooks/use-press-tap.js +0 -35
  178. package/lib/utils/index.js +0 -4
  179. package/src/components/ActionButton/__tests__/ActionButton.test.tsx +0 -35
  180. package/src/components/BottomSheet/__tests__/BottomSheet.test.tsx +0 -119
  181. package/src/components/__tests__/ProgressCircle.test.ts +0 -33
  182. package/src/components/__tests__/exports.test.ts +0 -38
  183. package/src/components/__tests__/switch.test.ts +0 -34
  184. package/src/hooks/use-icon-color.ts +0 -51
  185. package/src/utils/__tests__/create-slot-recipe-context.test.tsx +0 -228
  186. /package/src/hooks/{use-controllable-state.ts → useControllableState.ts} +0 -0
@@ -1,119 +0,0 @@
1
- import { fireEvent, render } from "@lynx-js/react/testing-library";
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
-
4
- const sheetMocks = vi.hoisted(() => ({
5
- contentProps: [] as Array<Record<string, unknown>>,
6
- rootRef: {
7
- open: vi.fn(),
8
- },
9
- }));
10
-
11
- vi.mock("@lynx-js/lynx-ui-sheet", async () => {
12
- const React = await vi.importActual<typeof import("@lynx-js/react")>("@lynx-js/react");
13
-
14
- const SheetRoot = React.forwardRef<unknown, { children?: React.ReactNode }>((props, ref) => {
15
- React.useImperativeHandle(ref, () => sheetMocks.rootRef);
16
- return <>{props.children}</>;
17
- });
18
- SheetRoot.displayName = "MockSheetRoot";
19
-
20
- const SheetView = React.forwardRef<unknown, { children?: React.ReactNode }>((props) => {
21
- return <>{props.children}</>;
22
- });
23
- SheetView.displayName = "MockSheetView";
24
-
25
- const SheetContent = React.forwardRef<unknown, Record<string, unknown>>((props) => {
26
- sheetMocks.contentProps.push(props);
27
- return <>{props.children as React.ReactNode}</>;
28
- });
29
- SheetContent.displayName = "MockSheetContent";
30
-
31
- const passthrough = (displayName: string) => {
32
- const Component = React.forwardRef<unknown, { children?: React.ReactNode }>((props) => {
33
- return <>{props.children}</>;
34
- });
35
- Component.displayName = displayName;
36
- return Component;
37
- };
38
-
39
- return {
40
- SheetBackdrop: passthrough("MockSheetBackdrop"),
41
- SheetContent,
42
- SheetHandle: passthrough("MockSheetHandle"),
43
- SheetRoot,
44
- SheetView,
45
- };
46
- });
47
-
48
- import * as BottomSheet from "../BottomSheet.namespace";
49
-
50
- describe("BottomSheet", () => {
51
- beforeEach(() => {
52
- sheetMocks.contentProps = [];
53
- sheetMocks.rootRef.open.mockClear();
54
- });
55
-
56
- it("skips motion-engine animations when Root has skipAnimation", () => {
57
- render(
58
- <BottomSheet.Root skipAnimation>
59
- <BottomSheet.Content />
60
- </BottomSheet.Root>,
61
- );
62
-
63
- expect(sheetMocks.contentProps.at(-1)).toMatchObject({
64
- snapAnimation: { type: "tween", duration: 0 },
65
- enterAnimation: { type: "tween", duration: 0 },
66
- exitAnimation: { type: "tween", duration: 0 },
67
- });
68
- });
69
-
70
- it("preserves user-provided animations when Root has skipAnimation", () => {
71
- const snapAnimation = { type: "tween", duration: 120 };
72
- const enterAnimation = { type: "tween", duration: 140 };
73
- const exitAnimation = { type: "tween", duration: 90 };
74
-
75
- render(
76
- <BottomSheet.Root skipAnimation>
77
- <BottomSheet.Content
78
- snapAnimation={snapAnimation}
79
- enterAnimation={enterAnimation}
80
- exitAnimation={exitAnimation}
81
- />
82
- </BottomSheet.Root>,
83
- );
84
-
85
- expect(sheetMocks.contentProps.at(-1)).toMatchObject({
86
- snapAnimation,
87
- enterAnimation,
88
- exitAnimation,
89
- });
90
- });
91
-
92
- it("opens with default animation when Root does not skip animation", () => {
93
- const { getByText } = render(
94
- <BottomSheet.Root>
95
- <BottomSheet.Trigger>
96
- <text>Open sheet</text>
97
- </BottomSheet.Trigger>
98
- </BottomSheet.Root>,
99
- );
100
-
101
- fireEvent.tap(getByText("Open sheet").parentElement!);
102
-
103
- expect(sheetMocks.rootRef.open).toHaveBeenCalledWith();
104
- });
105
-
106
- it("opens without animation when Trigger is tapped inside a skipping Root", () => {
107
- const { getByText } = render(
108
- <BottomSheet.Root skipAnimation>
109
- <BottomSheet.Trigger>
110
- <text>Open sheet</text>
111
- </BottomSheet.Trigger>
112
- </BottomSheet.Root>,
113
- );
114
-
115
- fireEvent.tap(getByText("Open sheet").parentElement!);
116
-
117
- expect(sheetMocks.rootRef.open).toHaveBeenCalledWith({ animate: false });
118
- });
119
- });
@@ -1,33 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
-
6
- const currentDir = dirname(fileURLToPath(import.meta.url));
7
- const progressCircleSource = readFileSync(
8
- join(currentDir, "..", "ProgressCircle", "ProgressCircle.tsx"),
9
- "utf8",
10
- );
11
-
12
- function readEffectDependencies(marker: string): string {
13
- const markerIndex = progressCircleSource.indexOf(marker);
14
- expect(markerIndex).toBeGreaterThanOrEqual(0);
15
-
16
- const dependencyStart = progressCircleSource.indexOf("}, [", markerIndex);
17
- expect(dependencyStart).toBeGreaterThanOrEqual(0);
18
-
19
- const dependencyEnd = progressCircleSource.indexOf("]);", dependencyStart);
20
- expect(dependencyEnd).toBeGreaterThanOrEqual(0);
21
-
22
- return progressCircleSource.slice(dependencyStart, dependencyEnd);
23
- }
24
-
25
- describe("ProgressCircle", () => {
26
- it("restarts indeterminate animation when size changes", () => {
27
- expect(readEffectDependencies("runOnMainThread(startLoop)")).toContain("numSize");
28
- });
29
-
30
- it("restarts determinate transition when size changes", () => {
31
- expect(readEffectDependencies("runOnMainThread(startAnimation)")).toContain("numSize");
32
- });
33
- });
@@ -1,38 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
-
6
- const currentDir = dirname(fileURLToPath(import.meta.url));
7
- const componentsDir = join(currentDir, "..");
8
-
9
- function readComponentFile(componentName: string, fileName: string): string {
10
- return readFileSync(join(componentsDir, componentName, fileName), "utf8");
11
- }
12
-
13
- describe("components public exports", () => {
14
- it("exposes compound components through namespace exports", () => {
15
- for (const componentName of ["Switch", "Checkbox", "RadioGroup", "TagGroup"]) {
16
- expect(readComponentFile(componentName, "index.ts")).toContain(
17
- `export * as ${componentName} from "./${componentName}.namespace";`,
18
- );
19
- expect(readComponentFile(componentName, `${componentName}.namespace.ts`)).toContain(
20
- " as Root",
21
- );
22
- }
23
- });
24
-
25
- it("keeps ProgressCircle namespace usage while exposing flat slots", () => {
26
- const index = readComponentFile("ProgressCircle", "index.ts");
27
- const implementation = readComponentFile("ProgressCircle", "ProgressCircle.tsx");
28
- const namespace = readComponentFile("ProgressCircle", "ProgressCircle.namespace.ts");
29
-
30
- expect(index).toContain('export * as ProgressCircle from "./ProgressCircle.namespace";');
31
- expect(index).toContain("ProgressCircleRoot");
32
- expect(index).toContain("ProgressCircleRange");
33
- expect(namespace).toContain("ProgressCircleRoot as Root");
34
- expect(namespace).toContain("ProgressCircleRange as Range");
35
- expect(implementation).toContain("export const ProgressCircleRoot");
36
- expect(implementation).toContain("export const ProgressCircleRange");
37
- });
38
- });
@@ -1,34 +0,0 @@
1
- import { switchmark } from "@seed-design/lynx-css/recipes/switchmark";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { describe, expect, it } from "vitest";
6
-
7
- const currentDir = dirname(fileURLToPath(import.meta.url));
8
- const lynxCssRecipesDir = join(currentDir, "..", "..", "..", "..", "lynx-css", "recipes");
9
- const switchCss = readFileSync(join(lynxCssRecipesDir, "switch.css"), "utf8");
10
- const switchmarkCss = readFileSync(join(lynxCssRecipesDir, "switchmark.css"), "utf8");
11
-
12
- describe("Switch", () => {
13
- it.each(["16", "24", "32"] as const)(
14
- "includes selected thumb class for size %s",
15
- (size) => {
16
- const classNames = switchmark({
17
- tone: "brand",
18
- size,
19
- checked: true,
20
- disabled: false,
21
- });
22
-
23
- expect(classNames.thumb.split(" ")).toContain(
24
- `seed-switchmark__thumb--size_${size}-checked_true`,
25
- );
26
- },
27
- );
28
-
29
- it("aligns Lynx switch content with flex center instead of margin compensation", () => {
30
- expect(switchCss).toContain("align-items: center");
31
- expect(switchCss).not.toContain("--switchmark-margin-top");
32
- expect(switchmarkCss).not.toContain("--switchmark-margin-top");
33
- });
34
- });
@@ -1,51 +0,0 @@
1
- import { runOnMainThread, useEffect, useMainThreadRef } from "@lynx-js/react";
2
- import type { MainThread } from "@lynx-js/types";
3
- import type { DependencyList, RefObject } from "react";
4
-
5
- type IconElement = MainThread.Element & {
6
- getComputedStyleProperty?: (name: string) => string;
7
- getComputedCssProperty?: (name: string) => string;
8
- };
9
-
10
- // Lynx `<image>` 의 `tint-color` attribute 는 concrete color(hex/rgb) 만 받는다. CSS
11
- // `color: var(--seed-color-...)` 를 slot 에 걸면 `getComputedStyleProperty("color")` 가
12
- // resolved hex 를 돌려주므로 main-thread 에서 한번 읽어 `tint-color` 로 mirror 한다.
13
- function syncTintColor(ref: RefObject<IconElement>) {
14
- "main thread";
15
-
16
- const el = ref.current;
17
- if (!el) return;
18
-
19
- let color: string | undefined;
20
- if (typeof el.getComputedStyleProperty === "function") {
21
- color = el.getComputedStyleProperty("color");
22
- } else if (typeof el.getComputedCssProperty === "function") {
23
- color = el.getComputedCssProperty("color");
24
- }
25
-
26
- if (color) {
27
- el.setAttribute("tint-color", color);
28
- }
29
- }
30
-
31
- /**
32
- * Lynx `<image>` 의 tint color 를 recipe 의 CSS `color` 로부터 main-thread 에서 읽어
33
- * `tint-color` attribute 로 mirror. `deps` 가 바뀌면 재동기화.
34
- *
35
- * ```tsx
36
- * const { ref } = useIconColor([variant, disabled, loading]);
37
- * return cloneElement(iconChild, { ref });
38
- * ```
39
- */
40
- export function useIconColor(deps: DependencyList): {
41
- ref: RefObject<MainThread.Element>;
42
- } {
43
- const ref = useMainThreadRef<IconElement>(null);
44
-
45
- // deps 는 caller 가 책임.
46
- useEffect(() => {
47
- runOnMainThread(syncTintColor)(ref);
48
- }, deps);
49
-
50
- return { ref };
51
- }
@@ -1,228 +0,0 @@
1
- import { forwardRef } from "@lynx-js/react";
2
- import { render, renderHook } 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 MockReceived = { props?: Record<string, unknown>; refValue?: unknown };
30
-
31
- function createMock(name: string) {
32
- const received: MockReceived = {};
33
- const Mock = forwardRef<{ tag: "mock" }, Record<string, unknown>>((props, ref) => {
34
- received.props = props;
35
- received.refValue = ref;
36
- const { children } = props as { children?: unknown };
37
- return <>{children as never}</>;
38
- });
39
- Mock.displayName = name;
40
- return { Mock, received };
41
- }
42
-
43
- describe("createSlotRecipeContext", () => {
44
- describe("useClassNames", () => {
45
- it("throws when used outside a ClassNamesProvider", () => {
46
- const { useClassNames } = createSlotRecipeContext(mockSlotRecipe);
47
-
48
- expect(() => renderHook(() => useClassNames())).toThrow(
49
- /useClassNames must be used within a ClassNamesProvider/,
50
- );
51
- });
52
-
53
- it("returns the provided classnames inside ClassNamesProvider", () => {
54
- const { useClassNames, ClassNamesProvider } = createSlotRecipeContext(mockSlotRecipe);
55
- const value: MockClassnames = { root: "custom-root", label: "custom-label" };
56
-
57
- const { result } = renderHook(() => useClassNames(), {
58
- wrapper: ({ children }) => (
59
- <ClassNamesProvider value={value}>{children}</ClassNamesProvider>
60
- ),
61
- });
62
-
63
- expect(result.current).toEqual(value);
64
- });
65
- });
66
-
67
- describe("useProps", () => {
68
- it("returns null outside of PropsProvider", () => {
69
- const { useProps } = createSlotRecipeContext(mockSlotRecipe);
70
- const { result } = renderHook(() => useProps());
71
-
72
- expect(result.current).toBeNull();
73
- });
74
-
75
- it("returns the provided value inside PropsProvider", () => {
76
- const { useProps, PropsProvider } = createSlotRecipeContext(mockSlotRecipe);
77
- const { result } = renderHook(() => useProps(), {
78
- wrapper: ({ children }) => (
79
- <PropsProvider value={{ variant: "b" }}>{children}</PropsProvider>
80
- ),
81
- });
82
-
83
- expect(result.current).toEqual({ variant: "b" });
84
- });
85
- });
86
-
87
- describe("withRootProvider", () => {
88
- it("injects classnames into context without applying className on the root element", () => {
89
- const { Mock: Root, received: rootReceived } = createMock("Root");
90
- const { Mock: Child, received: childReceived } = createMock("Child");
91
- const { withRootProvider, withContext } = createSlotRecipeContext(mockSlotRecipe);
92
-
93
- const StyledRoot = withRootProvider<MockVariantProps>(Root);
94
- const StyledLabel = withContext<{ tag: "mock" }, Record<string, unknown>>(Child, "label");
95
-
96
- render(
97
- <StyledRoot variant="b">
98
- <StyledLabel />
99
- </StyledRoot>,
100
- );
101
-
102
- expect(rootReceived.props).not.toHaveProperty("className");
103
- expect(childReceived.props?.className).toBe("label-b");
104
- });
105
- });
106
-
107
- describe("withProvider", () => {
108
- it("applies slot className to the provider element and injects context", () => {
109
- const { Mock: Root, received: rootReceived } = createMock("Root");
110
- const { Mock: Child, received: childReceived } = createMock("Child");
111
- const { withProvider, withContext } = createSlotRecipeContext(mockSlotRecipe);
112
-
113
- const StyledRoot = withProvider<{ tag: "mock" }, MockVariantProps>(Root, "root");
114
- const StyledLabel = withContext<{ tag: "mock" }, Record<string, unknown>>(Child, "label");
115
-
116
- render(
117
- <StyledRoot variant="a">
118
- <StyledLabel />
119
- </StyledRoot>,
120
- );
121
-
122
- expect(rootReceived.props?.className).toBe("root-a");
123
- expect(childReceived.props?.className).toBe("label-a");
124
- });
125
-
126
- it("merges user-provided className with the slot className", () => {
127
- const { Mock: Root, received: rootReceived } = createMock("Root");
128
- const { withProvider } = createSlotRecipeContext(mockSlotRecipe);
129
- type StyledProps = MockVariantProps & { className?: string };
130
- const StyledRoot = withProvider<{ tag: "mock" }, StyledProps>(Root, "root");
131
-
132
- render(<StyledRoot variant="a" className="extra" />);
133
-
134
- expect(rootReceived.props?.className).toBe("root-a extra");
135
- });
136
-
137
- it("forwards the ref to the provider element", () => {
138
- const { Mock: Root, received } = createMock("Root");
139
- const { withProvider } = createSlotRecipeContext(mockSlotRecipe);
140
- const StyledRoot = withProvider<{ tag: "mock" }, MockVariantProps>(Root, "root");
141
- const refFn = (node: { tag: "mock" } | null) => {
142
- received.refValue = node;
143
- };
144
-
145
- render(<StyledRoot ref={refFn} variant="a" />);
146
-
147
- expect(received.refValue).toBe(refFn);
148
- });
149
-
150
- it("prefers innerProps over PropsProvider and defaultProps", () => {
151
- const { Mock: Root, received } = createMock("Root");
152
- const { withProvider, PropsProvider } = createSlotRecipeContext(mockSlotRecipe);
153
- const StyledRoot = withProvider<{ tag: "mock" }, MockVariantProps>(Root, "root", {
154
- defaultProps: { variant: "a" },
155
- });
156
-
157
- render(
158
- <PropsProvider value={{ variant: "a" }}>
159
- <StyledRoot variant="b" />
160
- </PropsProvider>,
161
- );
162
-
163
- expect(received.props?.className).toBe("root-b");
164
- });
165
- });
166
-
167
- describe("withContext", () => {
168
- it("reads slot className from ClassNamesProvider", () => {
169
- const { Mock: Label, received } = createMock("Label");
170
- const { ClassNamesProvider, withContext } = createSlotRecipeContext(mockSlotRecipe);
171
- const StyledLabel = withContext<{ tag: "mock" }, Record<string, unknown>>(Label, "label");
172
- const classNames: MockClassnames = { root: "r", label: "custom-label" };
173
-
174
- render(
175
- <ClassNamesProvider value={classNames}>
176
- <StyledLabel />
177
- </ClassNamesProvider>,
178
- );
179
-
180
- expect(received.props?.className).toBe("custom-label");
181
- });
182
-
183
- it("merges user-provided className with slot className", () => {
184
- const { Mock: Label, received } = createMock("Label");
185
- const { ClassNamesProvider, withContext } = createSlotRecipeContext(mockSlotRecipe);
186
- type StyledProps = { className?: string };
187
- const StyledLabel = withContext<{ tag: "mock" }, StyledProps>(Label, "label");
188
-
189
- render(
190
- <ClassNamesProvider value={{ root: "r", label: "l" }}>
191
- <StyledLabel className="extra" />
192
- </ClassNamesProvider>,
193
- );
194
-
195
- expect(received.props?.className).toBe("l extra");
196
- });
197
-
198
- it("passes through native props like data attributes", () => {
199
- const { Mock: Label, received } = createMock("Label");
200
- const { ClassNamesProvider, withContext } = createSlotRecipeContext(mockSlotRecipe);
201
- type StyledProps = { "data-testid"?: string };
202
- const StyledLabel = withContext<{ tag: "mock" }, StyledProps>(Label, "label");
203
-
204
- render(
205
- <ClassNamesProvider value={{ root: "r", label: "l" }}>
206
- <StyledLabel data-testid="hello" />
207
- </ClassNamesProvider>,
208
- );
209
-
210
- expect(received.props?.["data-testid"]).toBe("hello");
211
- });
212
-
213
- it("renders without a slot className when slot argument is omitted", () => {
214
- const { Mock: Label, received } = createMock("Label");
215
- const { ClassNamesProvider, withContext } = createSlotRecipeContext(mockSlotRecipe);
216
- const StyledLabel = withContext<{ tag: "mock" }, Record<string, unknown>>(Label);
217
-
218
- render(
219
- <ClassNamesProvider value={{ root: "r", label: "l" }}>
220
- <StyledLabel />
221
- </ClassNamesProvider>,
222
- );
223
-
224
- // clsx(undefined, undefined) → ""
225
- expect(received.props?.className).toBe("");
226
- });
227
- });
228
- });