@seed-design/lynx-react 0.6.2 → 0.7.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 (236) hide show
  1. package/lib/components/Accordion/Accordion.d.ts.map +1 -1
  2. package/lib/components/Accordion/Accordion.jsx +24 -14
  3. package/lib/components/ActionButton/ActionButton.d.ts +1 -1
  4. package/lib/components/ActionButton/ActionButton.d.ts.map +1 -1
  5. package/lib/components/ActionButton/ActionButton.jsx +12 -4
  6. package/lib/components/AppBar/AppBar.d.ts.map +1 -1
  7. package/lib/components/AppBar/AppBar.jsx +9 -8
  8. package/lib/components/Badge/Badge.d.ts.map +1 -1
  9. package/lib/components/Badge/Badge.jsx +2 -1
  10. package/lib/components/BottomSheet/BottomSheet.d.ts.map +1 -1
  11. package/lib/components/BottomSheet/BottomSheet.jsx +9 -9
  12. package/lib/components/Box/Box.d.ts.map +1 -1
  13. package/lib/components/Box/Box.jsx +2 -1
  14. package/lib/components/Callout/Callout.d.ts +2 -2
  15. package/lib/components/Callout/Callout.d.ts.map +1 -1
  16. package/lib/components/Callout/Callout.jsx +20 -10
  17. package/lib/components/Checkbox/Checkbox.d.ts +4 -2
  18. package/lib/components/Checkbox/Checkbox.d.ts.map +1 -1
  19. package/lib/components/Checkbox/Checkbox.jsx +33 -17
  20. package/lib/components/Chip/Chip.d.ts +1 -1
  21. package/lib/components/Chip/Chip.d.ts.map +1 -1
  22. package/lib/components/Chip/Chip.jsx +36 -16
  23. package/lib/components/Count/Count.d.ts +7 -0
  24. package/lib/components/Count/Count.d.ts.map +1 -0
  25. package/lib/components/Count/Count.jsx +19 -0
  26. package/lib/components/Count/index.d.ts +2 -0
  27. package/lib/components/Count/index.d.ts.map +1 -0
  28. package/lib/components/Divider/Divider.d.ts.map +1 -1
  29. package/lib/components/Divider/Divider.jsx +2 -1
  30. package/lib/components/Field/Field.d.ts.map +1 -1
  31. package/lib/components/Field/Field.jsx +11 -15
  32. package/lib/components/Icon/Icon.d.ts.map +1 -1
  33. package/lib/components/Icon/Icon.jsx +4 -3
  34. package/lib/components/InputButton/InputButton.d.ts +40 -0
  35. package/lib/components/InputButton/InputButton.d.ts.map +1 -0
  36. package/lib/components/InputButton/InputButton.jsx +118 -0
  37. package/lib/components/InputButton/InputButton.namespace.d.ts +2 -0
  38. package/lib/components/InputButton/InputButton.namespace.d.ts.map +1 -0
  39. package/lib/components/InputButton/InputButton.namespace.js +16 -0
  40. package/lib/components/InputButton/index.d.ts +3 -0
  41. package/lib/components/InputButton/index.d.ts.map +1 -0
  42. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.d.ts.map +1 -1
  43. package/lib/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.jsx +12 -8
  44. package/lib/components/List/List.d.ts +51 -0
  45. package/lib/components/List/List.d.ts.map +1 -0
  46. package/lib/components/List/List.jsx +176 -0
  47. package/lib/components/List/List.namespace.d.ts +2 -0
  48. package/lib/components/List/List.namespace.d.ts.map +1 -0
  49. package/lib/components/List/List.namespace.js +18 -0
  50. package/lib/components/List/index.d.ts +3 -0
  51. package/lib/components/List/index.d.ts.map +1 -0
  52. package/lib/components/MannerTemp/MannerTemp.d.ts.map +1 -1
  53. package/lib/components/MannerTemp/MannerTemp.jsx +3 -2
  54. package/lib/components/MannerTempBadge/MannerTempBadge.d.ts.map +1 -1
  55. package/lib/components/MannerTempBadge/MannerTempBadge.jsx +2 -1
  56. package/lib/components/Menu/Menu.d.ts +69 -0
  57. package/lib/components/Menu/Menu.d.ts.map +1 -0
  58. package/lib/components/Menu/Menu.jsx +520 -0
  59. package/lib/components/Menu/Menu.namespace.d.ts +2 -0
  60. package/lib/components/Menu/Menu.namespace.d.ts.map +1 -0
  61. package/lib/components/Menu/Menu.namespace.js +21 -0
  62. package/lib/components/Menu/index.d.ts +3 -0
  63. package/lib/components/Menu/index.d.ts.map +1 -0
  64. package/lib/components/NotificationBadge/NotificationBadge.d.ts.map +1 -1
  65. package/lib/components/NotificationBadge/NotificationBadge.jsx +3 -2
  66. package/lib/components/PageBanner/PageBanner.d.ts +5 -5
  67. package/lib/components/PageBanner/PageBanner.d.ts.map +1 -1
  68. package/lib/components/PageBanner/PageBanner.jsx +25 -14
  69. package/lib/components/ProgressCircle/ProgressCircle.d.ts +2 -2
  70. package/lib/components/ProgressCircle/ProgressCircle.d.ts.map +1 -1
  71. package/lib/components/ProgressCircle/ProgressCircle.jsx +4 -3
  72. package/lib/components/RadioGroup/RadioGroup.d.ts +5 -3
  73. package/lib/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  74. package/lib/components/RadioGroup/RadioGroup.jsx +22 -9
  75. package/lib/components/ReactionButton/ReactionButton.d.ts +20 -0
  76. package/lib/components/ReactionButton/ReactionButton.d.ts.map +1 -0
  77. package/lib/components/ReactionButton/ReactionButton.jsx +88 -0
  78. package/lib/components/ReactionButton/index.d.ts +2 -0
  79. package/lib/components/ReactionButton/index.d.ts.map +1 -0
  80. package/lib/components/ScaleFeedback/ScaleFeedback.d.ts +11 -0
  81. package/lib/components/ScaleFeedback/ScaleFeedback.d.ts.map +1 -0
  82. package/lib/components/ScaleFeedback/ScaleFeedback.jsx +16 -0
  83. package/lib/components/ScaleFeedback/index.d.ts +2 -0
  84. package/lib/components/ScaleFeedback/index.d.ts.map +1 -0
  85. package/lib/components/SegmentedControl/SegmentedControl.d.ts +1 -0
  86. package/lib/components/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  87. package/lib/components/SegmentedControl/SegmentedControl.jsx +23 -10
  88. package/lib/components/SelectBox/SelectBox.d.ts.map +1 -1
  89. package/lib/components/SelectBox/SelectBox.jsx +23 -13
  90. package/lib/components/Stack/Stack.d.ts.map +1 -1
  91. package/lib/components/Stack/Stack.jsx +2 -1
  92. package/lib/components/SwipeableMenuSheet/SwipeableMenuSheet.d.ts +85 -0
  93. package/lib/components/SwipeableMenuSheet/SwipeableMenuSheet.d.ts.map +1 -0
  94. package/lib/components/SwipeableMenuSheet/SwipeableMenuSheet.jsx +256 -0
  95. package/lib/components/SwipeableMenuSheet/SwipeableMenuSheet.namespace.d.ts +2 -0
  96. package/lib/components/SwipeableMenuSheet/SwipeableMenuSheet.namespace.d.ts.map +1 -0
  97. package/lib/components/SwipeableMenuSheet/SwipeableMenuSheet.namespace.js +24 -0
  98. package/lib/components/SwipeableMenuSheet/index.d.ts +3 -0
  99. package/lib/components/SwipeableMenuSheet/index.d.ts.map +1 -0
  100. package/lib/components/Switch/Switch.d.ts +27 -3
  101. package/lib/components/Switch/Switch.d.ts.map +1 -1
  102. package/lib/components/Switch/Switch.jsx +22 -9
  103. package/lib/components/Tabs/Tabs.d.ts +4 -2
  104. package/lib/components/Tabs/Tabs.d.ts.map +1 -1
  105. package/lib/components/Tabs/Tabs.jsx +37 -22
  106. package/lib/components/TagGroup/TagGroup.d.ts.map +1 -1
  107. package/lib/components/TagGroup/TagGroup.jsx +4 -3
  108. package/lib/components/Text/Text.d.ts.map +1 -1
  109. package/lib/components/Text/Text.jsx +2 -1
  110. package/lib/components/TextField/TextField.d.ts.map +1 -1
  111. package/lib/components/TextField/TextField.jsx +35 -23
  112. package/lib/components/index.d.ts +7 -0
  113. package/lib/components/index.d.ts.map +1 -1
  114. package/lib/components/private/Positioning.d.ts +69 -0
  115. package/lib/components/private/Positioning.d.ts.map +1 -0
  116. package/lib/components/private/Positioning.js +109 -0
  117. package/lib/components/private/TrackPositioning.d.ts +22 -0
  118. package/lib/components/private/TrackPositioning.d.ts.map +1 -0
  119. package/lib/contexts/ScaleFeedbackContentContext.d.ts +4 -0
  120. package/lib/contexts/ScaleFeedbackContentContext.d.ts.map +1 -0
  121. package/lib/contexts/ScaleFeedbackContentContext.js +6 -0
  122. package/lib/contexts/index.d.ts +2 -0
  123. package/lib/contexts/index.d.ts.map +1 -0
  124. package/lib/hooks/index.d.ts +1 -0
  125. package/lib/hooks/index.d.ts.map +1 -1
  126. package/lib/hooks/useIconColor.d.ts.map +1 -1
  127. package/lib/hooks/useIconColor.js +8 -3
  128. package/lib/hooks/usePressTap.d.ts +4 -3
  129. package/lib/hooks/usePressTap.d.ts.map +1 -1
  130. package/lib/hooks/useScaleFeedback.d.ts +48 -0
  131. package/lib/hooks/useScaleFeedback.d.ts.map +1 -0
  132. package/lib/hooks/useScaleFeedback.js +108 -0
  133. package/lib/index.d.ts +1 -0
  134. package/lib/index.d.ts.map +1 -1
  135. package/lib/index.js +17 -3
  136. package/lib/node_modules/.bun/@floating-ui_core@1.8.0/node_modules/@floating-ui/core/dist/floating-ui.core.js +493 -0
  137. package/lib/node_modules/.bun/@floating-ui_utils@0.2.12/node_modules/@floating-ui/utils/dist/floating-ui.utils.js +114 -0
  138. package/lib/utils/calculate-scale-feedback.d.ts +5 -0
  139. package/lib/utils/calculate-scale-feedback.d.ts.map +1 -0
  140. package/lib/utils/calculate-scale-feedback.js +13 -0
  141. package/lib/utils/compose-event-handlers.d.ts +5 -0
  142. package/lib/utils/compose-event-handlers.d.ts.map +1 -0
  143. package/lib/utils/compose-event-handlers.js +16 -0
  144. package/lib/utils/create-slot-recipe-context.d.ts.map +1 -1
  145. package/lib/utils/create-slot-recipe-context.jsx +6 -16
  146. package/lib/utils/index.d.ts +1 -0
  147. package/lib/utils/index.d.ts.map +1 -1
  148. package/lib/utils/merge-props.d.ts +15 -0
  149. package/lib/utils/merge-props.d.ts.map +1 -0
  150. package/lib/utils/merge-props.js +36 -0
  151. package/lib/utils/merge-refs.d.ts +8 -0
  152. package/lib/utils/merge-refs.d.ts.map +1 -0
  153. package/lib/utils/merge-refs.js +38 -0
  154. package/package.json +6 -5
  155. package/src/components/Accordion/Accordion.tsx +36 -27
  156. package/src/components/ActionButton/ActionButton.tsx +27 -12
  157. package/src/components/AppBar/AppBar.tsx +17 -18
  158. package/src/components/Badge/Badge.tsx +2 -2
  159. package/src/components/BottomSheet/BottomSheet.tsx +9 -17
  160. package/src/components/Box/Box.tsx +2 -2
  161. package/src/components/Callout/Callout.tsx +39 -22
  162. package/src/components/Checkbox/Checkbox.test.tsx +44 -0
  163. package/src/components/Checkbox/Checkbox.tsx +52 -21
  164. package/src/components/Chip/Chip.test.tsx +50 -1
  165. package/src/components/Chip/Chip.tsx +75 -31
  166. package/src/components/Count/Count.tsx +35 -0
  167. package/src/components/Count/index.ts +1 -0
  168. package/src/components/Divider/Divider.tsx +2 -2
  169. package/src/components/Field/Field.tsx +15 -28
  170. package/src/components/Icon/Icon.test.tsx +4 -6
  171. package/src/components/Icon/Icon.tsx +14 -7
  172. package/src/components/InputButton/InputButton.namespace.ts +20 -0
  173. package/src/components/InputButton/InputButton.test.tsx +82 -0
  174. package/src/components/InputButton/InputButton.tsx +295 -0
  175. package/src/components/InputButton/index.ts +2 -0
  176. package/src/components/KeyboardAvoidingScrollView/KeyboardAvoidingScrollView.tsx +16 -21
  177. package/src/components/List/List.namespace.ts +24 -0
  178. package/src/components/List/List.test.tsx +162 -0
  179. package/src/components/List/List.tsx +476 -0
  180. package/src/components/List/index.ts +3 -0
  181. package/src/components/MannerTemp/MannerTemp.tsx +3 -4
  182. package/src/components/MannerTempBadge/MannerTempBadge.tsx +2 -2
  183. package/src/components/Menu/Menu.namespace.ts +26 -0
  184. package/src/components/Menu/Menu.tsx +883 -0
  185. package/src/components/Menu/index.ts +31 -0
  186. package/src/components/NotificationBadge/NotificationBadge.tsx +4 -9
  187. package/src/components/PageBanner/PageBanner.test.tsx +3 -0
  188. package/src/components/PageBanner/PageBanner.tsx +54 -45
  189. package/src/components/ProgressCircle/ProgressCircle.test.tsx +48 -0
  190. package/src/components/ProgressCircle/ProgressCircle.tsx +22 -3
  191. package/src/components/RadioGroup/RadioGroup.test.tsx +87 -0
  192. package/src/components/RadioGroup/RadioGroup.tsx +50 -14
  193. package/src/components/ReactionButton/ReactionButton.test.tsx +122 -0
  194. package/src/components/ReactionButton/ReactionButton.tsx +139 -0
  195. package/src/components/ReactionButton/index.ts +1 -0
  196. package/src/components/ScaleFeedback/ScaleFeedback.test.tsx +41 -0
  197. package/src/components/ScaleFeedback/ScaleFeedback.tsx +32 -0
  198. package/src/components/ScaleFeedback/index.ts +1 -0
  199. package/src/components/SegmentedControl/SegmentedControl.test.tsx +15 -3
  200. package/src/components/SegmentedControl/SegmentedControl.tsx +35 -14
  201. package/src/components/SelectBox/SelectBox.test.tsx +39 -1
  202. package/src/components/SelectBox/SelectBox.tsx +27 -16
  203. package/src/components/Stack/Stack.tsx +2 -2
  204. package/src/components/SwipeableMenuSheet/SwipeableMenuSheet.namespace.ts +37 -0
  205. package/src/components/SwipeableMenuSheet/SwipeableMenuSheet.test.tsx +258 -0
  206. package/src/components/SwipeableMenuSheet/SwipeableMenuSheet.tsx +655 -0
  207. package/src/components/SwipeableMenuSheet/index.ts +43 -0
  208. package/src/components/Switch/Switch.test.tsx +57 -0
  209. package/src/components/Switch/Switch.tsx +60 -16
  210. package/src/components/Tabs/Tabs.test.tsx +19 -0
  211. package/src/components/Tabs/Tabs.tsx +72 -40
  212. package/src/components/TagGroup/TagGroup.tsx +4 -6
  213. package/src/components/Text/Text.tsx +2 -2
  214. package/src/components/TextField/TextField.tsx +60 -54
  215. package/src/components/index.ts +7 -0
  216. package/src/components/private/Positioning.test.ts +235 -0
  217. package/src/components/private/Positioning.ts +223 -0
  218. package/src/components/private/TrackPositioning.test.ts +58 -0
  219. package/src/components/private/TrackPositioning.ts +37 -0
  220. package/src/contexts/ScaleFeedbackContentContext.ts +4 -0
  221. package/src/contexts/index.ts +1 -0
  222. package/src/hooks/index.ts +1 -0
  223. package/src/hooks/useIconColor.test.ts +4 -4
  224. package/src/hooks/useIconColor.ts +13 -6
  225. package/src/hooks/usePressTap.ts +4 -3
  226. package/src/hooks/useScaleFeedback.integration.test.tsx +85 -0
  227. package/src/hooks/useScaleFeedback.test.ts +50 -0
  228. package/src/hooks/useScaleFeedback.ts +199 -0
  229. package/src/index.ts +1 -0
  230. package/src/utils/calculate-scale-feedback.ts +12 -0
  231. package/src/utils/compose-event-handlers.ts +16 -0
  232. package/src/utils/create-slot-recipe-context.tsx +6 -7
  233. package/src/utils/index.ts +1 -0
  234. package/src/utils/merge-props.test.tsx +152 -0
  235. package/src/utils/merge-props.ts +62 -0
  236. package/src/utils/merge-refs.ts +44 -0
@@ -0,0 +1,199 @@
1
+ import {
2
+ runOnMainThread,
3
+ runOnBackground,
4
+ useEffect,
5
+ useGlobalProps,
6
+ useMainThreadRef,
7
+ useRef,
8
+ } from "@lynx-js/react";
9
+ import type { RefObject } from "@lynx-js/react";
10
+ import type { MainThread } from "@lynx-js/types";
11
+ import {
12
+ feedbackScaleDuration,
13
+ feedbackScaleTimingFunction,
14
+ } from "@seed-design/lynx-css/scale-feedback";
15
+
16
+ import type { LynxViewProps } from "../types";
17
+ import { calculateScaleFeedback, isReducedMotion } from "../utils/calculate-scale-feedback";
18
+
19
+ type MainThreadLayoutChangeHandler = NonNullable<LynxViewProps["main-thread:bindlayoutchange"]>;
20
+ type MainThreadTouchHandler = NonNullable<LynxViewProps["main-thread:bindtouchstart"]>;
21
+
22
+ interface SeedMotionGlobalProps {
23
+ motion?: unknown;
24
+ }
25
+
26
+ interface ScaleFeedbackElement extends MainThread.Element {
27
+ getComputedStyleProperty(styleName: string): string;
28
+ }
29
+
30
+ export interface UseScaleFeedbackOptions {
31
+ /** Disables Scale Feedback while the target is not interactive. */
32
+ disabled?: boolean;
33
+ /** Runs on the Background Thread immediately after Main Thread touchstart. */
34
+ onTouchStart?: () => void;
35
+ /** Runs on the Background Thread immediately after Main Thread touchend. */
36
+ onTouchEnd?: () => void;
37
+ /** Runs on the Background Thread immediately after Main Thread touchcancel. */
38
+ onTouchCancel?: () => void;
39
+ }
40
+
41
+ export interface ScaleFeedbackTriggerProps {
42
+ "main-thread:bindtouchstart": MainThreadTouchHandler;
43
+ "main-thread:bindtouchend": MainThreadTouchHandler;
44
+ "main-thread:bindtouchcancel": MainThreadTouchHandler;
45
+ }
46
+
47
+ /** The target's transform is owned by Scale Feedback; use a separate layer for other transforms. */
48
+ export interface ScaleFeedbackTargetProps {
49
+ flatten: false;
50
+ "main-thread:ref": NonNullable<LynxViewProps["main-thread:ref"]>;
51
+ "main-thread:bindlayoutchange": MainThreadLayoutChangeHandler;
52
+ }
53
+
54
+ export interface UseScaleFeedbackReturn {
55
+ /** Spread onto the element that receives touch events. */
56
+ scaleFeedbackTriggerProps: ScaleFeedbackTriggerProps;
57
+ /** Spread onto the element that is measured and scaled. */
58
+ scaleFeedbackTargetProps: ScaleFeedbackTargetProps;
59
+ }
60
+
61
+ function runScaleFeedback(
62
+ targetRef: RefObject<ScaleFeedbackElement | null>,
63
+ animationRef: RefObject<MainThread.Animation | null>,
64
+ scale: number,
65
+ duration: number,
66
+ ) {
67
+ "main thread";
68
+
69
+ const target = targetRef.current;
70
+ if (!target) return;
71
+
72
+ let currentTransform = "scale(1)";
73
+ try {
74
+ currentTransform = target.getComputedStyleProperty("transform") || currentTransform;
75
+ } catch {
76
+ // Some non-native runtimes expose the method but cannot evaluate it.
77
+ }
78
+
79
+ animationRef.current?.cancel();
80
+ animationRef.current = target.animate(
81
+ [{ transform: currentTransform }, { transform: `scale(${scale})` }],
82
+ {
83
+ duration,
84
+ easing: feedbackScaleTimingFunction,
85
+ fill: "forwards",
86
+ },
87
+ );
88
+ }
89
+
90
+ function readScaleFeedbackScale(target: ScaleFeedbackElement | null): number | null {
91
+ "main thread";
92
+
93
+ if (!target) return null;
94
+
95
+ let width = 0;
96
+ let height = 0;
97
+ try {
98
+ // Retry at touchstart when no valid layout measurement is cached,
99
+ // including targets that initially reported a 0x0 layout.
100
+ width = Number.parseFloat(target.getComputedStyleProperty("width"));
101
+ height = Number.parseFloat(target.getComputedStyleProperty("height"));
102
+ } catch {
103
+ return null;
104
+ }
105
+
106
+ if (width <= 0 || height <= 0 || Number.isNaN(width) || Number.isNaN(height)) return null;
107
+
108
+ return calculateScaleFeedback(width, height);
109
+ }
110
+
111
+ /**
112
+ * Applies SEED Scale Feedback on the Lynx Main Thread.
113
+ *
114
+ * Spread `scaleFeedbackTriggerProps` onto the element that receives touch
115
+ * events and `scaleFeedbackTargetProps` onto the element that should scale.
116
+ * Applying both objects to one element creates Self Scale; applying them to
117
+ * separate elements creates Content Scale.
118
+ *
119
+ * The target's transform is owned by this hook. Use a separate content layer
120
+ * when the element already uses translate, rotate, or another transform.
121
+ *
122
+ * Supported on Lynx Engine 3.9 and later. Missing or unknown
123
+ * `GlobalProps.motion` values preserve the default motion; only the exact
124
+ * `"reduced"` value disables scaling.
125
+ */
126
+ export function useScaleFeedback(options: UseScaleFeedbackOptions = {}): UseScaleFeedbackReturn {
127
+ const { disabled = false, onTouchStart, onTouchEnd, onTouchCancel } = options;
128
+ const globalProps = useGlobalProps() as SeedMotionGlobalProps | undefined;
129
+ const reducedMotion = isReducedMotion(globalProps?.motion);
130
+ const targetRef = useMainThreadRef<ScaleFeedbackElement | null>(null);
131
+ const animationRef = useMainThreadRef<MainThread.Animation>(null);
132
+ const scaleRef = useMainThreadRef<number | null>(null);
133
+ const hasMountedRef = useRef(false);
134
+
135
+ function handleLayoutChange(event: MainThread.LayoutChangeEvent) {
136
+ "main thread";
137
+
138
+ targetRef.current = event.currentTarget as ScaleFeedbackElement;
139
+ const width = event.detail?.width ?? event.params?.width ?? 0;
140
+ const height = event.detail?.height ?? event.params?.height ?? 0;
141
+ if (width > 0 && height > 0) {
142
+ scaleRef.current = calculateScaleFeedback(width, height);
143
+ } else {
144
+ scaleRef.current = null;
145
+ }
146
+ targetRef.current?.setStyleProperty("transform-origin", "center center");
147
+ }
148
+
149
+ function handleTouchStart() {
150
+ "main thread";
151
+
152
+ if (!disabled && !reducedMotion) {
153
+ const scale = scaleRef.current ?? readScaleFeedbackScale(targetRef.current);
154
+ if (scale !== null) {
155
+ scaleRef.current = scale;
156
+ runScaleFeedback(targetRef, animationRef, scale, feedbackScaleDuration);
157
+ }
158
+ }
159
+ if (onTouchStart) runOnBackground(onTouchStart)();
160
+ }
161
+
162
+ function handleTouchEnd() {
163
+ "main thread";
164
+
165
+ runScaleFeedback(targetRef, animationRef, 1, feedbackScaleDuration);
166
+ if (onTouchEnd) runOnBackground(onTouchEnd)();
167
+ }
168
+
169
+ function handleTouchCancel() {
170
+ "main thread";
171
+
172
+ runScaleFeedback(targetRef, animationRef, 1, feedbackScaleDuration);
173
+ if (onTouchCancel) runOnBackground(onTouchCancel)();
174
+ }
175
+
176
+ useEffect(() => {
177
+ if (!hasMountedRef.current) {
178
+ hasMountedRef.current = true;
179
+ return;
180
+ }
181
+ if (!disabled && !reducedMotion) return;
182
+
183
+ runOnMainThread(runScaleFeedback)(targetRef, animationRef, 1, 0);
184
+ }, [disabled, reducedMotion]);
185
+
186
+ return {
187
+ scaleFeedbackTriggerProps: {
188
+ "main-thread:bindtouchstart": handleTouchStart,
189
+ "main-thread:bindtouchend": handleTouchEnd,
190
+ "main-thread:bindtouchcancel": handleTouchCancel,
191
+ },
192
+ scaleFeedbackTargetProps: {
193
+ // Preserve a native view for transform animations instead of allowing Lynx to flatten it.
194
+ flatten: false,
195
+ "main-thread:ref": targetRef,
196
+ "main-thread:bindlayoutchange": handleLayoutChange,
197
+ },
198
+ };
199
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./components";
2
+ export * from "./contexts";
2
3
  export * from "./hooks";
3
4
  export type * from "./types";
4
5
  export * from "./utils";
@@ -0,0 +1,12 @@
1
+ /** Calculates SEED's fixed-distance scale ratio directly on the Main Thread. */
2
+ export function calculateScaleFeedback(width: number, height: number): number {
3
+ "main thread";
4
+
5
+ const basis = Math.max(height, width / 4, 24);
6
+ return basis > 0 ? (basis - 2) / basis : 1;
7
+ }
8
+
9
+ /** Treats unknown values as default motion and matches only the exact reduced value. */
10
+ export function isReducedMotion(value: unknown): boolean {
11
+ return value === "reduced";
12
+ }
@@ -0,0 +1,16 @@
1
+ type Handler = (...args: any[]) => unknown;
2
+
3
+ export function composeEventHandlers(first: Handler, second: Handler): Handler {
4
+ return (...args) => {
5
+ first(...args);
6
+ second(...args);
7
+ };
8
+ }
9
+
10
+ export function composeMainThreadEventHandlers(first: Handler, second: Handler): Handler {
11
+ return (...args) => {
12
+ "main thread";
13
+ first(...args);
14
+ second(...args);
15
+ };
16
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  RefAttributes,
9
9
  } from "@lynx-js/react";
10
10
  import clsx from "clsx";
11
+ import { mergeProps } from "./merge-props";
11
12
 
12
13
  type SlotRecipe<
13
14
  Props extends Record<string, string | boolean | undefined>,
@@ -105,7 +106,7 @@ export function createSlotRecipeContext<
105
106
  const { defaultProps } = options ?? {};
106
107
 
107
108
  const StyledComponent = (innerProps: any) => {
108
- const props = { ...(defaultProps ?? {}), ...useProps(), ...innerProps } as Props &
109
+ const props = mergeProps(defaultProps ?? {}, useProps() ?? {}, innerProps) as Props &
109
110
  Record<string, unknown>;
110
111
 
111
112
  restoreDefaultProps(props, defaultProps);
@@ -139,7 +140,7 @@ export function createSlotRecipeContext<
139
140
  const { defaultProps } = options ?? {};
140
141
 
141
142
  const StyledComponent = forwardRef<any, any>((innerProps, ref) => {
142
- const props: any = { ...(defaultProps ?? {}), ...useProps(), ...innerProps };
143
+ const props: any = mergeProps(defaultProps ?? {}, useProps() ?? {}, innerProps);
143
144
 
144
145
  restoreDefaultProps(props, defaultProps);
145
146
 
@@ -152,8 +153,7 @@ export function createSlotRecipeContext<
152
153
  <ClassNamesProvider value={classNames}>
153
154
  <PropsProvider value={variantProps}>
154
155
  <Component
155
- {...(ref ? { ref } : {})}
156
- {...otherProps}
156
+ {...mergeProps(ref ? { ref } : {}, otherProps)}
157
157
  className={clsx(slotClassName, userClassName)}
158
158
  />
159
159
  </PropsProvider>
@@ -177,7 +177,7 @@ export function createSlotRecipeContext<
177
177
  const { defaultProps } = options ?? {};
178
178
 
179
179
  const StyledComponent = forwardRef<any, any>((innerProps, ref) => {
180
- const props: any = { ...(defaultProps ?? {}), ...innerProps };
180
+ const props: any = mergeProps(defaultProps ?? {}, innerProps);
181
181
 
182
182
  restoreDefaultProps(props, defaultProps);
183
183
 
@@ -187,8 +187,7 @@ export function createSlotRecipeContext<
187
187
 
188
188
  return (
189
189
  <Component
190
- {...(ref ? { ref } : {})}
191
- {...props}
190
+ {...mergeProps(ref ? { ref } : {}, props)}
192
191
  className={clsx(slotClassName, userClassName)}
193
192
  />
194
193
  );
@@ -2,3 +2,4 @@ export * from "./create-slot-recipe-context";
2
2
  export * from "./get-seed-class-name";
3
3
  export * from "./resolve-recipe-token";
4
4
  export * from "./split-multiple-variants-props";
5
+ export * from "./merge-props";
@@ -0,0 +1,152 @@
1
+ import { createRef, runOnBackground, useMainThreadRef } from "@lynx-js/react";
2
+ import { fireEvent, render, waitSchedule } from "@lynx-js/react/testing-library";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { mergeProps } from "./merge-props";
5
+
6
+ describe("mergeProps", () => {
7
+ it("preserves props and chains only identical event keys, user first", () => {
8
+ const calls: string[] = [];
9
+ const base = {
10
+ className: "seed",
11
+ style: { width: "10px", color: "red" },
12
+ bindtap: () => calls.push("base"),
13
+ };
14
+ const user = {
15
+ className: "user",
16
+ style: { color: "blue" },
17
+ bindtap: () => {
18
+ calls.push("user");
19
+ return false;
20
+ },
21
+ };
22
+ const props = mergeProps(base, user, { className: undefined });
23
+ props.bindtap();
24
+ expect(calls).toEqual(["user", "base"]);
25
+ expect(props.className).toBe("seed user");
26
+ expect(props.style).toEqual({ width: "10px", color: "blue" });
27
+ expect(base.style.color).toBe("red");
28
+ expect(mergeProps({ bindtap: base.bindtap }, { catchtap: user.bindtap })).toEqual({
29
+ bindtap: base.bindtap,
30
+ catchtap: user.bindtap,
31
+ });
32
+ expect(mergeProps({ ref: null })).toEqual({});
33
+ const ref = createRef();
34
+ expect(mergeProps({ ref }, { ref: null }).ref).toBe(ref);
35
+ expect(mergeProps({ "main-thread:ref": ref })["main-thread:ref"]).toBe(ref);
36
+ });
37
+
38
+ it("preserves three refs across replacement and unmount", async () => {
39
+ const ref = createRef<unknown>();
40
+ const cleanup = vi.fn();
41
+ const first = vi.fn(() => cleanup);
42
+ const next = vi.fn();
43
+ const observer = vi.fn();
44
+ function Example({ callback }: { callback: (value: unknown) => void | (() => void) }) {
45
+ return <view {...mergeProps({ ref }, { ref: callback }, { ref: observer })} />;
46
+ }
47
+ const { rerender, unmount } = render(<Example callback={first} />);
48
+ await waitSchedule();
49
+ expect(ref.current).toBeTruthy();
50
+ expect(first).toHaveBeenLastCalledWith(ref.current);
51
+ expect(observer).toHaveBeenLastCalledWith(ref.current);
52
+ rerender(<Example callback={next} />);
53
+ await waitSchedule();
54
+ expect(cleanup).toHaveBeenCalledTimes(1);
55
+ expect(observer.mock.calls[1]).toEqual([null]);
56
+ expect(observer).toHaveBeenLastCalledWith(ref.current);
57
+ unmount();
58
+ await waitSchedule();
59
+ expect(ref.current).toBeNull();
60
+ expect(next).toHaveBeenLastCalledWith(null);
61
+ expect(observer).toHaveBeenLastCalledWith(null);
62
+ expect(observer).toHaveBeenCalledTimes(4);
63
+ });
64
+
65
+ it("composes compiled Main Thread handlers and refreshes captures", async () => {
66
+ function Example({ report }: { report: (value: string) => void }) {
67
+ function base() {
68
+ "main thread";
69
+ runOnBackground(report)("base");
70
+ }
71
+ function user() {
72
+ "main thread";
73
+ runOnBackground(report)("user");
74
+ }
75
+ return (
76
+ <view
77
+ id="target"
78
+ {...mergeProps(
79
+ { "main-thread:bindtouchstart": base },
80
+ { "main-thread:bindtouchstart": user },
81
+ )}
82
+ />
83
+ );
84
+ }
85
+ const report = vi.fn();
86
+ const next = vi.fn();
87
+ const { container, rerender } = render(<Example report={report} />, {
88
+ enableMainThread: true,
89
+ enableBackgroundThread: true,
90
+ });
91
+ await waitSchedule();
92
+ fireEvent.touchstart(container.querySelector("#target")!, {});
93
+ await waitSchedule();
94
+ expect(report.mock.calls).toEqual([["user"], ["base"]]);
95
+ rerender(<Example report={next} />);
96
+ await waitSchedule();
97
+ fireEvent.touchstart(container.querySelector("#target")!, {});
98
+ await waitSchedule();
99
+ expect(next.mock.calls).toEqual([["user"], ["base"]]);
100
+ expect(report).toHaveBeenCalledTimes(2);
101
+ });
102
+
103
+ it("composes three Main Thread refs and runs cleanup in source order", async () => {
104
+ function Example({ report, show = true }: { report: (value: string) => void; show?: boolean }) {
105
+ const target = useMainThreadRef(null);
106
+ const status = useMainThreadRef("initial");
107
+ function callback() {
108
+ "main thread";
109
+ status.current = target.current ? "attached" : "missing";
110
+ return () => {
111
+ status.current = target.current === null ? "cleaned" : "uncleared";
112
+ };
113
+ }
114
+ function inspect() {
115
+ "main thread";
116
+ runOnBackground(report)(status.current);
117
+ }
118
+ function observer(value: unknown) {
119
+ "main thread";
120
+ status.current += value ? ":observed" : ":detached";
121
+ }
122
+ return (
123
+ <view>
124
+ <view id="inspect" main-thread:bindtap={inspect} />
125
+ {show && (
126
+ <view
127
+ {...mergeProps(
128
+ { "main-thread:ref": target },
129
+ { "main-thread:ref": callback },
130
+ { "main-thread:ref": observer },
131
+ )}
132
+ />
133
+ )}
134
+ </view>
135
+ );
136
+ }
137
+ const report = vi.fn();
138
+ const { container, rerender } = render(<Example report={report} />, {
139
+ enableMainThread: true,
140
+ enableBackgroundThread: true,
141
+ });
142
+ await waitSchedule();
143
+ fireEvent.tap(container.querySelector("#inspect")!);
144
+ await waitSchedule();
145
+ expect(report.mock.calls).toEqual([["attached:observed"]]);
146
+ rerender(<Example report={report} show={false} />);
147
+ await waitSchedule();
148
+ fireEvent.tap(container.querySelector("#inspect")!);
149
+ await waitSchedule();
150
+ expect(report.mock.calls).toEqual([["attached:observed"], ["cleaned:detached"]]);
151
+ });
152
+ });
@@ -0,0 +1,62 @@
1
+ import clsx from "clsx";
2
+ import { composeEventHandlers, composeMainThreadEventHandlers } from "./compose-event-handlers";
3
+ import { mergeRefs, mergeMainThreadRefs, type Ref } from "./merge-refs";
4
+
5
+ type Props = Record<string, any>;
6
+ type Merge<A, B> = Omit<A, keyof B> & {
7
+ [K in keyof B]: undefined extends B[K]
8
+ ? Exclude<B[K], undefined> | (K extends keyof A ? A[K] : undefined)
9
+ : B[K];
10
+ };
11
+ type Merged<T extends object[], Result = {}> = T extends [
12
+ infer First extends object,
13
+ ...infer Rest extends object[],
14
+ ]
15
+ ? Merged<Rest, Merge<Result, First>>
16
+ : Result;
17
+
18
+ const nativeEvent = /^(?:main-thread:)?(?:(?:capture|global)-)?(?:bind|catch)[a-z]/;
19
+ const callbackEvent = /^on[A-Z]/;
20
+
21
+ /**
22
+ * Merges props without changing event threads or propagation modes.
23
+ * Same-key handlers run last-source first, matching SEED React. Return values
24
+ * and preventDefault do not cancel another handler. Ref cleanup is preserved.
25
+ */
26
+ export function mergeProps<T extends object[]>(...sources: T): Merged<T> {
27
+ const result: Props = {};
28
+ const refs: Ref<unknown>[] = [];
29
+ const mainThreadRefs: Ref<unknown>[] = [];
30
+ for (const source of sources) {
31
+ for (const [key, value] of Object.entries(source)) {
32
+ if (value === undefined) continue;
33
+ const previous = result[key];
34
+ if (key === "ref" || key === "main-thread:ref") {
35
+ if (value == null) continue;
36
+ const targetRefs = key === "ref" ? refs : mainThreadRefs;
37
+ targetRefs.push(value as Ref<unknown>);
38
+ } else if (key === "className") {
39
+ result[key] = clsx(previous, value as string);
40
+ } else if (key === "style" && typeof value === "object" && value !== null) {
41
+ result[key] = { ...(typeof previous === "object" ? previous : {}), ...value };
42
+ } else if (nativeEvent.test(key) || callbackEvent.test(key)) {
43
+ if (previous && value) {
44
+ const compose = key.startsWith("main-thread:")
45
+ ? composeMainThreadEventHandlers
46
+ : composeEventHandlers;
47
+ result[key] = compose(value as any, previous);
48
+ } else {
49
+ result[key] = value;
50
+ }
51
+ } else {
52
+ result[key] = value;
53
+ }
54
+ }
55
+ }
56
+ if (refs.length) result["ref"] = refs.length === 1 ? refs[0] : mergeRefs(...refs);
57
+ if (mainThreadRefs.length) {
58
+ result["main-thread:ref"] =
59
+ mainThreadRefs.length === 1 ? mainThreadRefs[0] : mergeMainThreadRefs(...mainThreadRefs);
60
+ }
61
+ return result as Merged<T>;
62
+ }
@@ -0,0 +1,44 @@
1
+ type Cleanup = void | (() => void);
2
+ export type Ref<T> = { current: T | null } | ((value: T | null) => Cleanup);
3
+
4
+ function setRef<T>(ref: Ref<T>, value: T | null): Cleanup {
5
+ if (typeof ref === "function") return ref(value);
6
+ ref.current = value;
7
+ }
8
+
9
+ function setMainThreadRef<T>(ref: Ref<T>, value: T | null): Cleanup {
10
+ "main thread";
11
+ if (typeof ref === "function") return ref(value);
12
+ ref.current = value;
13
+ }
14
+
15
+ export function mergeRefs<T>(...refs: Ref<T>[]): Ref<T> {
16
+ return (value) => {
17
+ const cleanups = refs.map((ref) => setRef(ref, value));
18
+ return () => {
19
+ refs.forEach((ref, index) => {
20
+ const cleanup = cleanups[index];
21
+ if (cleanup) cleanup();
22
+ else setRef(ref, null);
23
+ });
24
+ };
25
+ };
26
+ }
27
+
28
+ export function mergeMainThreadRefs<T>(...refs: Ref<T>[]): Ref<T> {
29
+ return (value) => {
30
+ "main thread";
31
+ const cleanups: Cleanup[] = [];
32
+ // Indexed access preserves the captured array in the Main Thread compiler.
33
+ for (let index = 0; index < refs.length; index++) {
34
+ cleanups.push(setMainThreadRef(refs[index], value));
35
+ }
36
+ return () => {
37
+ for (let index = 0; index < refs.length; index++) {
38
+ const cleanup = cleanups[index];
39
+ if (cleanup) cleanup();
40
+ else setMainThreadRef(refs[index], null);
41
+ }
42
+ };
43
+ };
44
+ }