@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,127 @@
1
+ import * as React from "@lynx-js/react";
2
+ import clsx from "clsx";
3
+ import { tagGroup, type TagGroupVariantProps } from "@seed-design/lynx-css/recipes/tag-group";
4
+ import {
5
+ tagGroupItem,
6
+ type TagGroupItemVariantProps,
7
+ } from "@seed-design/lynx-css/recipes/tag-group-item";
8
+
9
+ import type { LynxStyledElementProps, LynxTextRef, LynxViewRef } from "../../types";
10
+ import { toArray } from "../../utils/children";
11
+ import { createSlotRecipeContext } from "../../utils/create-slot-recipe-context";
12
+ import { splitMultipleVariantsProps } from "../../utils/split-multiple-variants-props";
13
+ import { useStyleProps, type StyleProps } from "../../utils/styled";
14
+
15
+ const { PropsProvider, useProps, ClassNamesProvider, useClassNames } =
16
+ createSlotRecipeContext(tagGroupItem);
17
+
18
+ ////////////////////////////////////////////////////////////////////////////////////
19
+
20
+ /**
21
+ * @platform Lynx
22
+ *
23
+ * 웹 대비 미지원 기능:
24
+ * - `truncate` prop: Lynx flex 모델에서는 item 단위 wrap만 가능해 웹 수준의
25
+ * "한 줄 유지 + label ellipsis" 조합을 재현할 수 없음
26
+ * - `asChild` prop: Lynx Slot 미지원
27
+ * - 아이콘 slot: Lynx 3.7 SVG 지원 후 검토 (Tier B)
28
+ */
29
+ export interface TagGroupRootProps
30
+ extends TagGroupVariantProps,
31
+ TagGroupItemVariantProps,
32
+ LynxStyledElementProps {
33
+ /**
34
+ * children 사이에 삽입되는 구분자. 기본값 `" · "`.
35
+ */
36
+ separator?: React.ReactNode;
37
+ }
38
+
39
+ export const TagGroupRoot = React.forwardRef<unknown, TagGroupRootProps>((props, ref) => {
40
+ const [{ tagGroup: tagGroupVariantProps, tagGroupItem: tagGroupItemVariantProps }, otherProps] =
41
+ splitMultipleVariantsProps(props, { tagGroup, tagGroupItem });
42
+ const { children, className, separator = " · ", ...nativeProps } = otherProps;
43
+ const classes = tagGroup(tagGroupVariantProps);
44
+
45
+ const visibleChildren = toArray(children);
46
+
47
+ return (
48
+ <PropsProvider value={tagGroupItemVariantProps}>
49
+ <view
50
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
51
+ className={clsx(classes.root, className)}
52
+ {...nativeProps}
53
+ >
54
+ {visibleChildren.map((child, index) => {
55
+ if (index === 0) return child;
56
+ // Lynx <text> separator는 개별 flex item이라 wrap이 발동하면 separator가
57
+ // 이전 row 끝에 남는다. separator와 뒤따르는 item을 하나의 flex-row wrapper로
58
+ // 묶어 같은 wrap 단위로 함께 이동시킨다.
59
+ return (
60
+ <view
61
+ key={(child as React.ReactElement).key ?? index}
62
+ className={classes.separatorWrapper}
63
+ >
64
+ <text className={classes.separator}>{separator}</text>
65
+ {child}
66
+ </view>
67
+ );
68
+ })}
69
+ </view>
70
+ </PropsProvider>
71
+ );
72
+ });
73
+ TagGroupRoot.displayName = "TagGroupRoot";
74
+
75
+ ////////////////////////////////////////////////////////////////////////////////////
76
+
77
+ /**
78
+ * @platform Lynx
79
+ *
80
+ * 웹 대비 미지원 기능:
81
+ * - `asChild` prop: Lynx Slot 미지원
82
+ */
83
+ export interface TagGroupItemProps
84
+ extends TagGroupItemVariantProps,
85
+ Pick<StyleProps, "flexShrink">,
86
+ LynxStyledElementProps {}
87
+
88
+ export const TagGroupItem = React.forwardRef<unknown, TagGroupItemProps>((props, ref) => {
89
+ const parentVariantProps = useProps();
90
+ const [localVariantProps, otherProps] = tagGroupItem.splitVariantProps(props);
91
+ const classes = tagGroupItem({ ...parentVariantProps, ...localVariantProps });
92
+ const { style, restProps } = useStyleProps(otherProps);
93
+ const { children, className, ...nativeProps } = restProps;
94
+
95
+ return (
96
+ <ClassNamesProvider value={classes}>
97
+ <view
98
+ {...(ref ? { ref: ref as LynxViewRef } : {})}
99
+ className={clsx(classes.root, className)}
100
+ style={style}
101
+ {...nativeProps}
102
+ >
103
+ {children}
104
+ </view>
105
+ </ClassNamesProvider>
106
+ );
107
+ });
108
+ TagGroupItem.displayName = "TagGroupItem";
109
+
110
+ ////////////////////////////////////////////////////////////////////////////////////
111
+
112
+ export interface TagGroupItemLabelProps extends LynxStyledElementProps {}
113
+
114
+ export const TagGroupItemLabel = React.forwardRef<unknown, TagGroupItemLabelProps>((props, ref) => {
115
+ const classes = useClassNames();
116
+ const { children, className, ...nativeProps } = props;
117
+ return (
118
+ <text
119
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
120
+ className={clsx(classes.label, className)}
121
+ {...nativeProps}
122
+ >
123
+ {children}
124
+ </text>
125
+ );
126
+ });
127
+ TagGroupItemLabel.displayName = "TagGroupItemLabel";
@@ -0,0 +1,10 @@
1
+ export {
2
+ TagGroupRoot,
3
+ TagGroupItem,
4
+ TagGroupItemLabel,
5
+ type TagGroupRootProps,
6
+ type TagGroupItemProps,
7
+ type TagGroupItemLabelProps,
8
+ } from "./TagGroup";
9
+
10
+ export * as TagGroup from "./TagGroup.namespace";
@@ -0,0 +1,64 @@
1
+ import { typography } from "@seed-design/lynx-css/vars/component";
2
+ import type { CSSProperties } from "@lynx-js/types";
3
+ import clsx from "clsx";
4
+ import * as React from "@lynx-js/react";
5
+
6
+ import {
7
+ handleColor,
8
+ handleFontSize,
9
+ handleFontWeight,
10
+ handleLineHeight,
11
+ type TextStyle,
12
+ type TextStyleProps,
13
+ } from "../../utils/styled";
14
+ import type { LynxStyledElementProps, LynxTextRef } from "../../types";
15
+
16
+ function capitalize<T extends string>(value: T): Capitalize<T> {
17
+ return (value.charAt(0).toUpperCase() + value.slice(1)) as Capitalize<T>;
18
+ }
19
+
20
+ function getTypographyStyle(textStyle: TextStyle | undefined) {
21
+ const key = `textStyle${capitalize(textStyle ?? "t5Regular")}`;
22
+ const value = typography[key as keyof typeof typography];
23
+ return value.enabled.root;
24
+ }
25
+
26
+ export interface TextProps extends TextStyleProps, LynxStyledElementProps {}
27
+
28
+ export const Text = React.forwardRef<unknown, TextProps>((props, ref) => {
29
+ const {
30
+ color,
31
+ textStyle,
32
+ fontSize,
33
+ lineHeight,
34
+ fontWeight,
35
+ align,
36
+ children,
37
+ className,
38
+ style,
39
+ ...nativeProps
40
+ } = props;
41
+ const typographyStyle = getTypographyStyle(textStyle);
42
+
43
+ return (
44
+ <text
45
+ {...(ref ? { ref: ref as LynxTextRef } : {})}
46
+ {...nativeProps}
47
+ className={clsx(className)}
48
+ style={
49
+ {
50
+ color: handleColor(color),
51
+ fontSize: handleFontSize(fontSize) ?? typographyStyle.fontSize,
52
+ lineHeight: handleLineHeight(lineHeight ?? fontSize) ?? typographyStyle.lineHeight,
53
+ fontWeight: handleFontWeight(fontWeight) ?? typographyStyle.fontWeight,
54
+ textAlign: align,
55
+ ...style,
56
+ } as CSSProperties
57
+ }
58
+ >
59
+ {children}
60
+ </text>
61
+ );
62
+ });
63
+
64
+ Text.displayName = "Text";
@@ -0,0 +1 @@
1
+ export { Text, type TextProps } from "./Text";
@@ -0,0 +1,11 @@
1
+ export * from "./ActionButton";
2
+ export * from "./Box";
3
+ export * from "./BottomSheet";
4
+ export * from "./Checkbox";
5
+ export * from "./Icon";
6
+ export * from "./ProgressCircle";
7
+ export * from "./RadioGroup";
8
+ export * from "./Stack";
9
+ export * from "./Switch";
10
+ export * from "./TagGroup";
11
+ export * from "./Text";
@@ -0,0 +1,4 @@
1
+ export * from "./useControllableState";
2
+ export * from "./useIconColor";
3
+ export * from "./usePressTap";
4
+ export * from "./useSafeArea";
@@ -0,0 +1,120 @@
1
+ import { act, renderHook } from "@lynx-js/react/testing-library";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { useControllableState } from "./useControllableState";
4
+
5
+ describe("useControllableState", () => {
6
+ describe("uncontrolled mode", () => {
7
+ it("initializes with defaultValue", () => {
8
+ const { result } = renderHook(() => useControllableState({ defaultValue: "initial" }));
9
+
10
+ expect(result.current[0]).toBe("initial");
11
+ });
12
+
13
+ it("updates value on setValue", () => {
14
+ const { result } = renderHook(() => useControllableState({ defaultValue: 0 }));
15
+
16
+ act(() => {
17
+ result.current[1](42);
18
+ });
19
+
20
+ expect(result.current[0]).toBe(42);
21
+ });
22
+
23
+ it("calls onChange when value changes", () => {
24
+ const onChange = vi.fn();
25
+ const { result } = renderHook(() => useControllableState({ defaultValue: "a", onChange }));
26
+
27
+ act(() => {
28
+ result.current[1]("b");
29
+ });
30
+
31
+ expect(onChange).toHaveBeenCalledWith("b");
32
+ expect(onChange).toHaveBeenCalledTimes(1);
33
+ });
34
+
35
+ it("does not call onChange when value is unchanged", () => {
36
+ const onChange = vi.fn();
37
+ const { result } = renderHook(() => useControllableState({ defaultValue: "a", onChange }));
38
+
39
+ act(() => {
40
+ result.current[1]("a");
41
+ });
42
+
43
+ expect(onChange).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it("calls onChange only once per unique value", () => {
47
+ const onChange = vi.fn();
48
+ const { result } = renderHook(() => useControllableState({ defaultValue: 0, onChange }));
49
+
50
+ act(() => {
51
+ result.current[1](1);
52
+ });
53
+ act(() => {
54
+ result.current[1](1);
55
+ });
56
+ act(() => {
57
+ result.current[1](2);
58
+ });
59
+
60
+ expect(onChange).toHaveBeenCalledTimes(2);
61
+ expect(onChange).toHaveBeenNthCalledWith(1, 1);
62
+ expect(onChange).toHaveBeenNthCalledWith(2, 2);
63
+ });
64
+ });
65
+
66
+ describe("controlled mode", () => {
67
+ it("uses the provided value", () => {
68
+ const { result } = renderHook(() =>
69
+ useControllableState({ value: "external", defaultValue: "default" }),
70
+ );
71
+
72
+ expect(result.current[0]).toBe("external");
73
+ });
74
+
75
+ it("calls onChange on setValue but does not update internal state", () => {
76
+ const onChange = vi.fn();
77
+ const { result } = renderHook(() =>
78
+ useControllableState({
79
+ value: "controlled",
80
+ defaultValue: "default",
81
+ onChange,
82
+ }),
83
+ );
84
+
85
+ act(() => {
86
+ result.current[1]("next");
87
+ });
88
+
89
+ expect(onChange).toHaveBeenCalledWith("next");
90
+ expect(result.current[0]).toBe("controlled");
91
+ });
92
+
93
+ it("reflects external value changes on rerender", () => {
94
+ const { result, rerender } = renderHook(
95
+ (props: { value: string }) =>
96
+ useControllableState({
97
+ value: props.value,
98
+ defaultValue: "default",
99
+ }),
100
+ { initialProps: { value: "first" } },
101
+ );
102
+
103
+ expect(result.current[0]).toBe("first");
104
+
105
+ rerender({ value: "second" });
106
+
107
+ expect(result.current[0]).toBe("second");
108
+ });
109
+ });
110
+
111
+ it("returns a stable setValue reference", () => {
112
+ const { result, rerender } = renderHook(() => useControllableState({ defaultValue: 0 }));
113
+
114
+ const setValueFirst = result.current[1];
115
+ rerender();
116
+ const setValueSecond = result.current[1];
117
+
118
+ expect(setValueFirst).toBe(setValueSecond);
119
+ });
120
+ });
@@ -0,0 +1,38 @@
1
+ import { useRef, useState } from "@lynx-js/react";
2
+ import { useMemoizedFn } from "@lynx-js/lynx-ui-common";
3
+
4
+ export interface UseControllableStateProps<T> {
5
+ value?: T;
6
+ defaultValue: T;
7
+ onChange?: (value: T) => void;
8
+ }
9
+
10
+ /**
11
+ * Controlled/uncontrolled state pattern for Lynx components.
12
+ *
13
+ * - When `value` is provided, the component is controlled.
14
+ * - When `value` is `undefined`, the component manages its own state
15
+ * starting from `defaultValue`.
16
+ * - `onChange` fires in both modes.
17
+ */
18
+ export function useControllableState<T>(
19
+ props: UseControllableStateProps<T>,
20
+ ): [T, (value: T) => void] {
21
+ const { value, defaultValue, onChange } = props;
22
+ const isControlled = value !== undefined;
23
+ const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
24
+ const currentValue = isControlled ? value : uncontrolledValue;
25
+ const prevValueRef = useRef(currentValue);
26
+
27
+ const setValue = useMemoizedFn((nextValue: T) => {
28
+ if (!isControlled) {
29
+ setUncontrolledValue(nextValue);
30
+ }
31
+ if (nextValue !== prevValueRef.current) {
32
+ onChange?.(nextValue);
33
+ prevValueRef.current = nextValue;
34
+ }
35
+ });
36
+
37
+ return [currentValue, setValue];
38
+ }
@@ -0,0 +1,13 @@
1
+ import { renderHook } from "@lynx-js/react/testing-library";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { useIconColor } from "./useIconColor";
5
+
6
+ describe("useIconColor", () => {
7
+ it("returns icon tint sync props", () => {
8
+ const { result } = renderHook(() => useIconColor([]));
9
+
10
+ expect(result.current).toHaveProperty("ref");
11
+ expect(result.current["main-thread:binduiappear"]).toBeDefined();
12
+ });
13
+ });
@@ -0,0 +1,67 @@
1
+ import { runOnMainThread, useEffect, useMainThreadRef } from "@lynx-js/react";
2
+ import type { MainThread } from "@lynx-js/types";
3
+ import type { DependencyList, RefObject } from "@lynx-js/react";
4
+
5
+ type IconElement = MainThread.Element & {
6
+ getComputedStyleProperty?: (name: string) => string;
7
+ getComputedCssProperty?: (name: string) => string;
8
+ };
9
+
10
+ export interface UseIconColorOptions {
11
+ sourceRef?: RefObject<MainThread.Element>;
12
+ }
13
+
14
+ // Lynx `<image>` 의 `tint-color` attribute 에 CSS variable 문자열을 직접 넣는 경로는
15
+ // 안정적으로 동작하지 않는다. CSS `color` 는 computed style 로 resolved color 를 읽을 수
16
+ // 있으므로 main-thread 에서 한 번 읽어 `tint-color` 로 mirror 한다.
17
+ function syncTintColor(targetRef: RefObject<IconElement>, sourceRef?: RefObject<IconElement>) {
18
+ "main thread";
19
+
20
+ const target = targetRef.current;
21
+ if (!target) return;
22
+
23
+ const source = sourceRef?.current ?? target;
24
+
25
+ let color: string | undefined;
26
+ if (typeof source.getComputedStyleProperty === "function") {
27
+ color = source.getComputedStyleProperty("color");
28
+ } else if (typeof source.getComputedCssProperty === "function") {
29
+ color = source.getComputedCssProperty("color");
30
+ }
31
+
32
+ if (color) {
33
+ target.setAttribute("tint-color", color);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Lynx `<image>` 의 tint color 를 recipe 의 CSS `color` 로부터 main-thread 에서 읽어
39
+ * `tint-color` attribute 로 mirror. `deps` 가 바뀌면 재동기화.
40
+ *
41
+ * ```tsx
42
+ * const iconColorProps = useIconColor([variant, disabled, loading]);
43
+ * return cloneElement(iconChild, iconColorProps);
44
+ * ```
45
+ */
46
+ export function useIconColor(
47
+ deps: DependencyList,
48
+ options?: UseIconColorOptions,
49
+ ): {
50
+ ref: RefObject<MainThread.Element>;
51
+ "main-thread:binduiappear": () => void;
52
+ } {
53
+ const ref = useMainThreadRef<IconElement>(null);
54
+ const sourceRef = options?.sourceRef as RefObject<IconElement> | undefined;
55
+
56
+ function syncOnUiAppear() {
57
+ "main thread";
58
+ syncTintColor(ref, sourceRef);
59
+ }
60
+
61
+ // deps 는 caller 가 책임.
62
+ useEffect(() => {
63
+ runOnMainThread(syncTintColor)(ref, sourceRef);
64
+ }, deps);
65
+
66
+ return { ref, "main-thread:binduiappear": syncOnUiAppear };
67
+ }
@@ -0,0 +1,129 @@
1
+ import { act, renderHook } from "@lynx-js/react/testing-library";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { usePressTap } from "./usePressTap";
4
+
5
+ const fakeEvent = {} as Parameters<ReturnType<typeof usePressTap>["bindtap"]>[0];
6
+
7
+ describe("usePressTap", () => {
8
+ describe("pressed state", () => {
9
+ it("starts as false", () => {
10
+ const { result } = renderHook(() => usePressTap());
11
+ expect(result.current.pressed).toBe(false);
12
+ });
13
+
14
+ it("becomes true on bindtouchstart", () => {
15
+ const { result } = renderHook(() => usePressTap());
16
+
17
+ act(() => {
18
+ result.current.bindtouchstart(fakeEvent);
19
+ });
20
+
21
+ expect(result.current.pressed).toBe(true);
22
+ });
23
+
24
+ it("becomes false on bindtouchend", () => {
25
+ const { result } = renderHook(() => usePressTap());
26
+
27
+ act(() => {
28
+ result.current.bindtouchstart(fakeEvent);
29
+ });
30
+ act(() => {
31
+ result.current.bindtouchend(fakeEvent);
32
+ });
33
+
34
+ expect(result.current.pressed).toBe(false);
35
+ });
36
+
37
+ it("becomes false on bindtouchcancel", () => {
38
+ const { result } = renderHook(() => usePressTap());
39
+
40
+ act(() => {
41
+ result.current.bindtouchstart(fakeEvent);
42
+ });
43
+ act(() => {
44
+ result.current.bindtouchcancel(fakeEvent);
45
+ });
46
+
47
+ expect(result.current.pressed).toBe(false);
48
+ });
49
+ });
50
+
51
+ describe("onTap", () => {
52
+ it("is called when bindtap fires", () => {
53
+ const onTap = vi.fn();
54
+ const { result } = renderHook(() => usePressTap({ onTap }));
55
+
56
+ act(() => {
57
+ result.current.bindtap(fakeEvent);
58
+ });
59
+
60
+ expect(onTap).toHaveBeenCalledTimes(1);
61
+ });
62
+
63
+ it("can be omitted without error", () => {
64
+ const { result } = renderHook(() => usePressTap());
65
+
66
+ expect(() => {
67
+ act(() => {
68
+ result.current.bindtap(fakeEvent);
69
+ });
70
+ }).not.toThrow();
71
+ });
72
+ });
73
+
74
+ describe("disabled", () => {
75
+ it("does not update pressed on bindtouchstart", () => {
76
+ const { result } = renderHook(() => usePressTap({ disabled: true }));
77
+
78
+ act(() => {
79
+ result.current.bindtouchstart(fakeEvent);
80
+ });
81
+
82
+ expect(result.current.pressed).toBe(false);
83
+ });
84
+
85
+ it("does not call onTap on bindtap", () => {
86
+ const onTap = vi.fn();
87
+ const { result } = renderHook(() => usePressTap({ disabled: true, onTap }));
88
+
89
+ act(() => {
90
+ result.current.bindtap(fakeEvent);
91
+ });
92
+
93
+ expect(onTap).not.toHaveBeenCalled();
94
+ });
95
+ });
96
+
97
+ describe("main-thread:bindtap", () => {
98
+ it("is included when enabled and mainThreadOnTap is provided", () => {
99
+ const mainThreadOnTap = () => {};
100
+ const { result } = renderHook(() => usePressTap({ mainThreadOnTap }));
101
+
102
+ expect(result.current["main-thread:bindtap"]).toBe(mainThreadOnTap);
103
+ });
104
+
105
+ it("is not included when mainThreadOnTap is omitted", () => {
106
+ const { result } = renderHook(() => usePressTap());
107
+
108
+ expect(result.current).not.toHaveProperty("main-thread:bindtap");
109
+ });
110
+
111
+ it("is not included when disabled is true", () => {
112
+ const mainThreadOnTap = () => {};
113
+ const { result } = renderHook(() => usePressTap({ disabled: true, mainThreadOnTap }));
114
+
115
+ expect(result.current).not.toHaveProperty("main-thread:bindtap");
116
+ });
117
+ });
118
+
119
+ it("returns stable handler references across renders", () => {
120
+ const { result, rerender } = renderHook(() => usePressTap());
121
+
122
+ const firstBindtap = result.current.bindtap;
123
+ const firstBindtouchstart = result.current.bindtouchstart;
124
+ rerender();
125
+
126
+ expect(result.current.bindtap).toBe(firstBindtap);
127
+ expect(result.current.bindtouchstart).toBe(firstBindtouchstart);
128
+ });
129
+ });
@@ -0,0 +1,58 @@
1
+ import { useState } from "@lynx-js/react";
2
+ import { useMemoizedFn } from "@lynx-js/lynx-ui-common";
3
+ import type { BaseTouchEvent, EventHandler, Target } from "@lynx-js/types";
4
+
5
+ import type { LynxTouchProps } from "../types";
6
+
7
+ type TouchHandler = EventHandler<BaseTouchEvent<Target>>;
8
+
9
+ export interface UsePressTapOptions {
10
+ disabled?: boolean;
11
+ onTap?: TouchHandler;
12
+ mainThreadOnTap?: LynxTouchProps["main-thread:bindtap"];
13
+ }
14
+
15
+ export interface UsePressTapReturn {
16
+ pressed: boolean;
17
+ bindtap: TouchHandler;
18
+ bindtouchstart: TouchHandler;
19
+ bindtouchend: TouchHandler;
20
+ bindtouchcancel: TouchHandler;
21
+ "main-thread:bindtap"?: LynxTouchProps["main-thread:bindtap"];
22
+ }
23
+
24
+ /**
25
+ * Hook that provides press/tap interaction state for Lynx elements.
26
+ *
27
+ * - Tracks `pressed` state via touch events.
28
+ * - When `disabled` is true, the element cannot become active and no tap fires.
29
+ * - If the element becomes disabled during a press, the active state is cleared.
30
+ * - `main-thread:bindtap` is conditionally included (only when enabled and provided).
31
+ */
32
+ export function usePressTap(options: UsePressTapOptions = {}): UsePressTapReturn {
33
+ const { disabled = false, onTap, mainThreadOnTap } = options;
34
+ const [pressed, setPressed] = useState(false);
35
+
36
+ const press = useMemoizedFn(() => {
37
+ if (disabled) return;
38
+ setPressed(true);
39
+ });
40
+
41
+ const reset = useMemoizedFn(() => {
42
+ setPressed(false);
43
+ });
44
+
45
+ const handleTap = useMemoizedFn((...args: Parameters<TouchHandler>) => {
46
+ if (disabled) return;
47
+ onTap?.(...args);
48
+ });
49
+
50
+ return {
51
+ pressed,
52
+ bindtap: handleTap,
53
+ bindtouchstart: press,
54
+ bindtouchend: reset,
55
+ bindtouchcancel: reset,
56
+ ...(!disabled && mainThreadOnTap ? { "main-thread:bindtap": mainThreadOnTap } : {}),
57
+ };
58
+ }