@oxyhq/bloom 0.27.0 → 0.28.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 (253) hide show
  1. package/lib/commonjs/animated-check/AnimatedCheck.js +162 -0
  2. package/lib/commonjs/animated-check/AnimatedCheck.js.map +1 -0
  3. package/lib/commonjs/animated-check/index.js +13 -0
  4. package/lib/commonjs/animated-check/index.js.map +1 -0
  5. package/lib/commonjs/dialog/placement.js +9 -7
  6. package/lib/commonjs/dialog/placement.js.map +1 -1
  7. package/lib/commonjs/fill/index.js.map +1 -1
  8. package/lib/commonjs/hooks/index.js +20 -0
  9. package/lib/commonjs/hooks/index.js.map +1 -1
  10. package/lib/commonjs/hooks/useGutters.js +106 -0
  11. package/lib/commonjs/hooks/useGutters.js.map +1 -0
  12. package/lib/commonjs/hooks/useHaptics.js +69 -0
  13. package/lib/commonjs/hooks/useHaptics.js.map +1 -0
  14. package/lib/commonjs/hooks/useInteractionState.js +8 -2
  15. package/lib/commonjs/hooks/useInteractionState.js.map +1 -1
  16. package/lib/commonjs/index.js +155 -63
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/index.web.js +155 -63
  19. package/lib/commonjs/index.web.js.map +1 -1
  20. package/lib/commonjs/media-inset-border/MediaInsetBorder.js +50 -0
  21. package/lib/commonjs/media-inset-border/MediaInsetBorder.js.map +1 -0
  22. package/lib/commonjs/media-inset-border/index.js +13 -0
  23. package/lib/commonjs/media-inset-border/index.js.map +1 -0
  24. package/lib/commonjs/motion/ScreenTransition.js +46 -0
  25. package/lib/commonjs/motion/ScreenTransition.js.map +1 -0
  26. package/lib/commonjs/motion/index.js +32 -0
  27. package/lib/commonjs/motion/index.js.map +1 -0
  28. package/lib/commonjs/motion/motion.js +93 -0
  29. package/lib/commonjs/motion/motion.js.map +1 -0
  30. package/lib/commonjs/pressable-scale/PressableScale.js +75 -0
  31. package/lib/commonjs/pressable-scale/PressableScale.js.map +1 -0
  32. package/lib/commonjs/pressable-scale/index.js +13 -0
  33. package/lib/commonjs/pressable-scale/index.js.map +1 -0
  34. package/lib/commonjs/pressable-with-hover/PressableWithHover.js +36 -0
  35. package/lib/commonjs/pressable-with-hover/PressableWithHover.js.map +1 -0
  36. package/lib/commonjs/pressable-with-hover/index.js +13 -0
  37. package/lib/commonjs/pressable-with-hover/index.js.map +1 -0
  38. package/lib/commonjs/styles/breakpoints.js +24 -0
  39. package/lib/commonjs/styles/breakpoints.js.map +1 -0
  40. package/lib/commonjs/styles/index.js +7 -0
  41. package/lib/commonjs/styles/index.js.map +1 -1
  42. package/lib/commonjs/subtle-hover/SubtleHover.js +69 -0
  43. package/lib/commonjs/subtle-hover/SubtleHover.js.map +1 -0
  44. package/lib/commonjs/subtle-hover/index.js +13 -0
  45. package/lib/commonjs/subtle-hover/index.js.map +1 -0
  46. package/lib/commonjs/theme/build-theme.js +2 -0
  47. package/lib/commonjs/theme/build-theme.js.map +1 -1
  48. package/lib/commonjs/theme/gradients.js +39 -0
  49. package/lib/commonjs/theme/gradients.js.map +1 -0
  50. package/lib/commonjs/theme/index.js +7 -0
  51. package/lib/commonjs/theme/index.js.map +1 -1
  52. package/lib/module/animated-check/AnimatedCheck.js +157 -0
  53. package/lib/module/animated-check/AnimatedCheck.js.map +1 -0
  54. package/lib/module/animated-check/index.js +4 -0
  55. package/lib/module/animated-check/index.js.map +1 -0
  56. package/lib/module/dialog/placement.js +9 -7
  57. package/lib/module/dialog/placement.js.map +1 -1
  58. package/lib/module/fill/index.js.map +1 -1
  59. package/lib/module/hooks/index.js +2 -0
  60. package/lib/module/hooks/index.js.map +1 -1
  61. package/lib/module/hooks/useGutters.js +103 -0
  62. package/lib/module/hooks/useGutters.js.map +1 -0
  63. package/lib/module/hooks/useHaptics.js +65 -0
  64. package/lib/module/hooks/useHaptics.js.map +1 -0
  65. package/lib/module/hooks/useInteractionState.js +10 -3
  66. package/lib/module/hooks/useInteractionState.js.map +1 -1
  67. package/lib/module/index.js +10 -1
  68. package/lib/module/index.js.map +1 -1
  69. package/lib/module/index.web.js +10 -1
  70. package/lib/module/index.web.js.map +1 -1
  71. package/lib/module/media-inset-border/MediaInsetBorder.js +47 -0
  72. package/lib/module/media-inset-border/MediaInsetBorder.js.map +1 -0
  73. package/lib/module/media-inset-border/index.js +4 -0
  74. package/lib/module/media-inset-border/index.js.map +1 -0
  75. package/lib/module/motion/ScreenTransition.js +40 -0
  76. package/lib/module/motion/ScreenTransition.js.map +1 -0
  77. package/lib/module/motion/index.js +5 -0
  78. package/lib/module/motion/index.js.map +1 -0
  79. package/lib/module/motion/motion.js +87 -0
  80. package/lib/module/motion/motion.js.map +1 -0
  81. package/lib/module/pressable-scale/PressableScale.js +72 -0
  82. package/lib/module/pressable-scale/PressableScale.js.map +1 -0
  83. package/lib/module/pressable-scale/index.js +4 -0
  84. package/lib/module/pressable-scale/index.js.map +1 -0
  85. package/lib/module/pressable-with-hover/PressableWithHover.js +31 -0
  86. package/lib/module/pressable-with-hover/PressableWithHover.js.map +1 -0
  87. package/lib/module/pressable-with-hover/index.js +4 -0
  88. package/lib/module/pressable-with-hover/index.js.map +1 -0
  89. package/lib/module/styles/breakpoints.js +20 -0
  90. package/lib/module/styles/breakpoints.js.map +1 -0
  91. package/lib/module/styles/index.js +1 -0
  92. package/lib/module/styles/index.js.map +1 -1
  93. package/lib/module/subtle-hover/SubtleHover.js +66 -0
  94. package/lib/module/subtle-hover/SubtleHover.js.map +1 -0
  95. package/lib/module/subtle-hover/index.js +4 -0
  96. package/lib/module/subtle-hover/index.js.map +1 -0
  97. package/lib/module/theme/build-theme.js +2 -0
  98. package/lib/module/theme/build-theme.js.map +1 -1
  99. package/lib/module/theme/gradients.js +35 -0
  100. package/lib/module/theme/gradients.js.map +1 -0
  101. package/lib/module/theme/index.js +1 -0
  102. package/lib/module/theme/index.js.map +1 -1
  103. package/lib/typescript/commonjs/animated-check/AnimatedCheck.d.ts +18 -0
  104. package/lib/typescript/commonjs/animated-check/AnimatedCheck.d.ts.map +1 -0
  105. package/lib/typescript/commonjs/animated-check/index.d.ts +3 -0
  106. package/lib/typescript/commonjs/animated-check/index.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/dialog/placement.d.ts +8 -7
  108. package/lib/typescript/commonjs/dialog/placement.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/fill/index.d.ts +2 -2
  110. package/lib/typescript/commonjs/fill/index.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/hooks/index.d.ts +4 -0
  112. package/lib/typescript/commonjs/hooks/index.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/hooks/useGutters.d.ts +32 -0
  114. package/lib/typescript/commonjs/hooks/useGutters.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/hooks/useHaptics.d.ts +28 -0
  116. package/lib/typescript/commonjs/hooks/useHaptics.d.ts.map +1 -0
  117. package/lib/typescript/commonjs/hooks/useInteractionState.d.ts +6 -0
  118. package/lib/typescript/commonjs/hooks/useInteractionState.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/index.d.ts +16 -0
  120. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/index.web.d.ts +16 -0
  122. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  123. package/lib/typescript/commonjs/media-inset-border/MediaInsetBorder.d.ts +26 -0
  124. package/lib/typescript/commonjs/media-inset-border/MediaInsetBorder.d.ts.map +1 -0
  125. package/lib/typescript/commonjs/media-inset-border/index.d.ts +3 -0
  126. package/lib/typescript/commonjs/media-inset-border/index.d.ts.map +1 -0
  127. package/lib/typescript/commonjs/motion/ScreenTransition.d.ts +33 -0
  128. package/lib/typescript/commonjs/motion/ScreenTransition.d.ts.map +1 -0
  129. package/lib/typescript/commonjs/motion/index.d.ts +4 -0
  130. package/lib/typescript/commonjs/motion/index.d.ts.map +1 -0
  131. package/lib/typescript/commonjs/motion/motion.d.ts +24 -0
  132. package/lib/typescript/commonjs/motion/motion.d.ts.map +1 -0
  133. package/lib/typescript/commonjs/pressable-scale/PressableScale.d.ts +21 -0
  134. package/lib/typescript/commonjs/pressable-scale/PressableScale.d.ts.map +1 -0
  135. package/lib/typescript/commonjs/pressable-scale/index.d.ts +3 -0
  136. package/lib/typescript/commonjs/pressable-scale/index.d.ts.map +1 -0
  137. package/lib/typescript/commonjs/pressable-with-hover/PressableWithHover.d.ts +18 -0
  138. package/lib/typescript/commonjs/pressable-with-hover/PressableWithHover.d.ts.map +1 -0
  139. package/lib/typescript/commonjs/pressable-with-hover/index.d.ts +3 -0
  140. package/lib/typescript/commonjs/pressable-with-hover/index.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/styles/breakpoints.d.ts +18 -0
  142. package/lib/typescript/commonjs/styles/breakpoints.d.ts.map +1 -0
  143. package/lib/typescript/commonjs/styles/index.d.ts +2 -0
  144. package/lib/typescript/commonjs/styles/index.d.ts.map +1 -1
  145. package/lib/typescript/commonjs/subtle-hover/SubtleHover.d.ts +32 -0
  146. package/lib/typescript/commonjs/subtle-hover/SubtleHover.d.ts.map +1 -0
  147. package/lib/typescript/commonjs/subtle-hover/index.d.ts +3 -0
  148. package/lib/typescript/commonjs/subtle-hover/index.d.ts.map +1 -0
  149. package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -1
  150. package/lib/typescript/commonjs/theme/gradients.d.ts +12 -0
  151. package/lib/typescript/commonjs/theme/gradients.d.ts.map +1 -0
  152. package/lib/typescript/commonjs/theme/index.d.ts +2 -1
  153. package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
  154. package/lib/typescript/commonjs/theme/types.d.ts +11 -0
  155. package/lib/typescript/commonjs/theme/types.d.ts.map +1 -1
  156. package/lib/typescript/module/animated-check/AnimatedCheck.d.ts +18 -0
  157. package/lib/typescript/module/animated-check/AnimatedCheck.d.ts.map +1 -0
  158. package/lib/typescript/module/animated-check/index.d.ts +3 -0
  159. package/lib/typescript/module/animated-check/index.d.ts.map +1 -0
  160. package/lib/typescript/module/dialog/placement.d.ts +8 -7
  161. package/lib/typescript/module/dialog/placement.d.ts.map +1 -1
  162. package/lib/typescript/module/fill/index.d.ts +2 -2
  163. package/lib/typescript/module/fill/index.d.ts.map +1 -1
  164. package/lib/typescript/module/hooks/index.d.ts +4 -0
  165. package/lib/typescript/module/hooks/index.d.ts.map +1 -1
  166. package/lib/typescript/module/hooks/useGutters.d.ts +32 -0
  167. package/lib/typescript/module/hooks/useGutters.d.ts.map +1 -0
  168. package/lib/typescript/module/hooks/useHaptics.d.ts +28 -0
  169. package/lib/typescript/module/hooks/useHaptics.d.ts.map +1 -0
  170. package/lib/typescript/module/hooks/useInteractionState.d.ts +6 -0
  171. package/lib/typescript/module/hooks/useInteractionState.d.ts.map +1 -1
  172. package/lib/typescript/module/index.d.ts +16 -0
  173. package/lib/typescript/module/index.d.ts.map +1 -1
  174. package/lib/typescript/module/index.web.d.ts +16 -0
  175. package/lib/typescript/module/index.web.d.ts.map +1 -1
  176. package/lib/typescript/module/media-inset-border/MediaInsetBorder.d.ts +26 -0
  177. package/lib/typescript/module/media-inset-border/MediaInsetBorder.d.ts.map +1 -0
  178. package/lib/typescript/module/media-inset-border/index.d.ts +3 -0
  179. package/lib/typescript/module/media-inset-border/index.d.ts.map +1 -0
  180. package/lib/typescript/module/motion/ScreenTransition.d.ts +33 -0
  181. package/lib/typescript/module/motion/ScreenTransition.d.ts.map +1 -0
  182. package/lib/typescript/module/motion/index.d.ts +4 -0
  183. package/lib/typescript/module/motion/index.d.ts.map +1 -0
  184. package/lib/typescript/module/motion/motion.d.ts +24 -0
  185. package/lib/typescript/module/motion/motion.d.ts.map +1 -0
  186. package/lib/typescript/module/pressable-scale/PressableScale.d.ts +21 -0
  187. package/lib/typescript/module/pressable-scale/PressableScale.d.ts.map +1 -0
  188. package/lib/typescript/module/pressable-scale/index.d.ts +3 -0
  189. package/lib/typescript/module/pressable-scale/index.d.ts.map +1 -0
  190. package/lib/typescript/module/pressable-with-hover/PressableWithHover.d.ts +18 -0
  191. package/lib/typescript/module/pressable-with-hover/PressableWithHover.d.ts.map +1 -0
  192. package/lib/typescript/module/pressable-with-hover/index.d.ts +3 -0
  193. package/lib/typescript/module/pressable-with-hover/index.d.ts.map +1 -0
  194. package/lib/typescript/module/styles/breakpoints.d.ts +18 -0
  195. package/lib/typescript/module/styles/breakpoints.d.ts.map +1 -0
  196. package/lib/typescript/module/styles/index.d.ts +2 -0
  197. package/lib/typescript/module/styles/index.d.ts.map +1 -1
  198. package/lib/typescript/module/subtle-hover/SubtleHover.d.ts +32 -0
  199. package/lib/typescript/module/subtle-hover/SubtleHover.d.ts.map +1 -0
  200. package/lib/typescript/module/subtle-hover/index.d.ts +3 -0
  201. package/lib/typescript/module/subtle-hover/index.d.ts.map +1 -0
  202. package/lib/typescript/module/theme/build-theme.d.ts.map +1 -1
  203. package/lib/typescript/module/theme/gradients.d.ts +12 -0
  204. package/lib/typescript/module/theme/gradients.d.ts.map +1 -0
  205. package/lib/typescript/module/theme/index.d.ts +2 -1
  206. package/lib/typescript/module/theme/index.d.ts.map +1 -1
  207. package/lib/typescript/module/theme/types.d.ts +11 -0
  208. package/lib/typescript/module/theme/types.d.ts.map +1 -1
  209. package/package.json +72 -1
  210. package/src/__tests__/AnimatedCheck.test.tsx +31 -0
  211. package/src/__tests__/MediaInsetBorder.test.tsx +36 -0
  212. package/src/__tests__/Motion.test.tsx +58 -0
  213. package/src/__tests__/PressableScale.test.tsx +29 -0
  214. package/src/__tests__/PressableWithHover.test.tsx +48 -0
  215. package/src/__tests__/SubtleHover.test.tsx +42 -0
  216. package/src/__tests__/theme-gradients.test.ts +31 -0
  217. package/src/__tests__/theme.test.ts +3 -0
  218. package/src/__tests__/useGutters.test.ts +58 -0
  219. package/src/__tests__/useHaptics.test.tsx +58 -0
  220. package/src/__tests__/useInteractionState.test.ts +27 -0
  221. package/src/animated-check/AnimatedCheck.stories.tsx +60 -0
  222. package/src/animated-check/AnimatedCheck.tsx +173 -0
  223. package/src/animated-check/index.ts +2 -0
  224. package/src/dialog/placement.ts +10 -7
  225. package/src/fill/index.tsx +2 -2
  226. package/src/hooks/index.ts +4 -0
  227. package/src/hooks/useGutters.ts +99 -0
  228. package/src/hooks/useHaptics.tsx +69 -0
  229. package/src/hooks/useInteractionState.ts +8 -2
  230. package/src/index.ts +20 -0
  231. package/src/index.web.ts +20 -0
  232. package/src/media-inset-border/MediaInsetBorder.stories.tsx +60 -0
  233. package/src/media-inset-border/MediaInsetBorder.tsx +72 -0
  234. package/src/media-inset-border/index.ts +2 -0
  235. package/src/motion/Motion.stories.tsx +99 -0
  236. package/src/motion/ScreenTransition.tsx +71 -0
  237. package/src/motion/index.ts +3 -0
  238. package/src/motion/motion.ts +75 -0
  239. package/src/pressable-scale/PressableScale.stories.tsx +71 -0
  240. package/src/pressable-scale/PressableScale.tsx +103 -0
  241. package/src/pressable-scale/index.ts +2 -0
  242. package/src/pressable-with-hover/PressableWithHover.stories.tsx +43 -0
  243. package/src/pressable-with-hover/PressableWithHover.tsx +43 -0
  244. package/src/pressable-with-hover/index.ts +2 -0
  245. package/src/styles/breakpoints.ts +18 -0
  246. package/src/styles/index.ts +2 -0
  247. package/src/subtle-hover/SubtleHover.stories.tsx +72 -0
  248. package/src/subtle-hover/SubtleHover.tsx +90 -0
  249. package/src/subtle-hover/index.ts +2 -0
  250. package/src/theme/build-theme.ts +2 -0
  251. package/src/theme/gradients.ts +61 -0
  252. package/src/theme/index.ts +2 -1
  253. package/src/theme/types.ts +13 -0
@@ -0,0 +1,173 @@
1
+ import React, { forwardRef, useCallback, useImperativeHandle, useMemo } from 'react';
2
+ import { StyleSheet, Text } from 'react-native';
3
+ import Animated, {
4
+ Easing,
5
+ useAnimatedProps,
6
+ useSharedValue,
7
+ withDelay,
8
+ withTiming,
9
+ } from 'react-native-reanimated';
10
+
11
+ import { getSvgModule } from '../avatar/svg-module';
12
+ import { useTheme } from '../theme';
13
+
14
+ /** The resolved `react-native-svg` module (non-null branch). */
15
+ type SvgModule = NonNullable<ReturnType<typeof getSvgModule>>;
16
+
17
+ /** Square viewBox the ring + check are drawn on. */
18
+ const VIEW_BOX = 52;
19
+ /** Ring radius within the viewBox. */
20
+ const CIRCLE_RADIUS = 24;
21
+ /** Ring circumference (2·π·r ≈ 150.8) rounded up so the dash fully hides it. */
22
+ const CIRCLE_LENGTH = 166;
23
+ /** Approximate path length of the check stroke. */
24
+ const CHECK_LENGTH = 48;
25
+ /** The check glyph, drawn on the 52×52 canvas. */
26
+ const CHECK_PATH = 'M14.1 27.2l7.1 7.2 16.7-16.8';
27
+ const STROKE_WIDTH = 4;
28
+ /** Ring draw-on duration (ms). */
29
+ const CIRCLE_DURATION = 500;
30
+ /** Check draw-on duration (ms), started once the ring completes. */
31
+ const CHECK_DURATION = 300;
32
+
33
+ export interface AnimatedCheckRef {
34
+ /** Replays the draw-on animation from the start. */
35
+ play: () => void;
36
+ }
37
+
38
+ export interface AnimatedCheckProps {
39
+ /** Width and height in px. Defaults to `24`. */
40
+ size?: number;
41
+ /** Stroke color of the ring and check. Defaults to the theme success color. */
42
+ color?: string;
43
+ }
44
+
45
+ // `Animated.createAnimatedComponent` must be called once per underlying
46
+ // component (calling it per-render would remount and break the animation).
47
+ // react-native-svg is a lazy/optional peer, so we build the animated wrappers
48
+ // the first time it resolves and cache them at module scope.
49
+ function buildAnimatedSvg(svg: SvgModule) {
50
+ return {
51
+ Circle: Animated.createAnimatedComponent(svg.Circle),
52
+ Path: Animated.createAnimatedComponent(svg.Path),
53
+ };
54
+ }
55
+
56
+ let animatedSvgCache: ReturnType<typeof buildAnimatedSvg> | null = null;
57
+
58
+ function getAnimatedSvg(svg: SvgModule): ReturnType<typeof buildAnimatedSvg> {
59
+ if (!animatedSvgCache) animatedSvgCache = buildAnimatedSvg(svg);
60
+ return animatedSvgCache;
61
+ }
62
+
63
+ interface AnimatedCheckSvgProps {
64
+ size: number;
65
+ color: string;
66
+ svg: SvgModule;
67
+ }
68
+
69
+ /** The animated SVG implementation, rendered only when react-native-svg exists. */
70
+ const AnimatedCheckSvg = forwardRef<AnimatedCheckRef, AnimatedCheckSvgProps>(
71
+ function AnimatedCheckSvg({ size, color, svg }, ref) {
72
+ const { default: Svg } = svg;
73
+ const { Circle, Path } = getAnimatedSvg(svg);
74
+
75
+ const circleProgress = useSharedValue(0);
76
+ const checkProgress = useSharedValue(0);
77
+
78
+ const circleAnimatedProps = useAnimatedProps(() => ({
79
+ strokeDashoffset: CIRCLE_LENGTH - circleProgress.value * CIRCLE_LENGTH,
80
+ }));
81
+ const checkAnimatedProps = useAnimatedProps(() => ({
82
+ strokeDashoffset: CHECK_LENGTH - CHECK_LENGTH * checkProgress.value,
83
+ }));
84
+
85
+ const play = useCallback(() => {
86
+ circleProgress.value = 0;
87
+ checkProgress.value = 0;
88
+ circleProgress.value = withTiming(1, {
89
+ duration: CIRCLE_DURATION,
90
+ easing: Easing.linear,
91
+ });
92
+ checkProgress.value = withDelay(
93
+ CIRCLE_DURATION,
94
+ withTiming(1, { duration: CHECK_DURATION, easing: Easing.linear }),
95
+ );
96
+ }, [circleProgress, checkProgress]);
97
+
98
+ useImperativeHandle(ref, () => ({ play }), [play]);
99
+
100
+ return (
101
+ <Svg fill="none" viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`} width={size} height={size}>
102
+ <Circle
103
+ animatedProps={circleAnimatedProps}
104
+ cx={VIEW_BOX / 2}
105
+ cy={VIEW_BOX / 2}
106
+ r={CIRCLE_RADIUS}
107
+ fill="none"
108
+ stroke={color}
109
+ strokeWidth={STROKE_WIDTH}
110
+ strokeDasharray={CIRCLE_LENGTH}
111
+ />
112
+ <Path
113
+ animatedProps={checkAnimatedProps}
114
+ d={CHECK_PATH}
115
+ stroke={color}
116
+ strokeWidth={STROKE_WIDTH}
117
+ strokeDasharray={CHECK_LENGTH}
118
+ />
119
+ </Svg>
120
+ );
121
+ },
122
+ );
123
+
124
+ interface StaticCheckProps {
125
+ size: number;
126
+ color: string;
127
+ }
128
+
129
+ /**
130
+ * Static fallback rendered when react-native-svg is unavailable (e.g. a web
131
+ * bundle that omits the optional peer). Draws a plain Unicode check so the
132
+ * component still communicates "done"; `play()` is a no-op.
133
+ */
134
+ const StaticCheck = forwardRef<AnimatedCheckRef, StaticCheckProps>(
135
+ function StaticCheck({ size, color }, ref) {
136
+ useImperativeHandle(ref, () => ({ play: () => undefined }), []);
137
+ return (
138
+ <Text
139
+ accessibilityElementsHidden
140
+ style={[styles.staticCheck, { width: size, fontSize: size * 0.8, lineHeight: size, color }]}
141
+ >
142
+ {'✓'}
143
+ </Text>
144
+ );
145
+ },
146
+ );
147
+
148
+ /**
149
+ * A draw-on checkmark: a ring strokes on, then the check strokes in. Expose a
150
+ * ref and call `ref.current?.play()` to (re)play — e.g. on a successful submit.
151
+ * Ported from Bluesky's `AnimatedCheck`.
152
+ */
153
+ export const AnimatedCheck = forwardRef<AnimatedCheckRef, AnimatedCheckProps>(
154
+ function AnimatedCheck({ size = 24, color }, ref) {
155
+ const theme = useTheme();
156
+ const resolvedColor = color ?? theme.colors.success;
157
+ const svg = useMemo(() => getSvgModule(), []);
158
+
159
+ if (!svg) {
160
+ return <StaticCheck ref={ref} size={size} color={resolvedColor} />;
161
+ }
162
+ return <AnimatedCheckSvg ref={ref} size={size} color={resolvedColor} svg={svg} />;
163
+ },
164
+ );
165
+
166
+ AnimatedCheck.displayName = 'AnimatedCheck';
167
+
168
+ const styles = StyleSheet.create({
169
+ staticCheck: {
170
+ textAlign: 'center',
171
+ fontWeight: '700',
172
+ },
173
+ });
@@ -0,0 +1,2 @@
1
+ export { AnimatedCheck } from './AnimatedCheck';
2
+ export type { AnimatedCheckProps, AnimatedCheckRef } from './AnimatedCheck';
@@ -1,6 +1,8 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useWindowDimensions } from 'react-native';
3
3
 
4
+ import { BREAKPOINTS } from '../styles/breakpoints';
5
+
4
6
  /**
5
7
  * Anchor a `<Dialog>` can render against.
6
8
  *
@@ -31,14 +33,15 @@ export type ResponsiveDialogPlacement =
31
33
  };
32
34
 
33
35
  /**
34
- * Tailwind-default breakpoints (px). A responsive `placement` map resolves to
35
- * the value for the largest key whose min-width is `<=` the current viewport
36
- * width, falling back to `base`.
36
+ * Tailwind-default breakpoints (px), re-exported from the canonical
37
+ * {@link BREAKPOINTS} source. A responsive `placement` map resolves to the value
38
+ * for the largest key whose min-width is `<=` the current viewport width,
39
+ * falling back to `base`.
37
40
  */
38
- export const BREAKPOINT_SM = 640;
39
- export const BREAKPOINT_MD = 768;
40
- export const BREAKPOINT_LG = 1024;
41
- export const BREAKPOINT_XL = 1280;
41
+ export const BREAKPOINT_SM = BREAKPOINTS.sm;
42
+ export const BREAKPOINT_MD = BREAKPOINTS.md;
43
+ export const BREAKPOINT_LG = BREAKPOINTS.lg;
44
+ export const BREAKPOINT_XL = BREAKPOINTS.xl;
42
45
 
43
46
  /** Ordered breakpoint table, widest first, so the first match wins. */
44
47
  const RESPONSIVE_KEYS = [
@@ -1,12 +1,12 @@
1
1
  import React, { memo } from 'react';
2
- import { StyleSheet, View, type ViewStyle } from 'react-native';
2
+ import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
3
3
 
4
4
  const FillComponent = function Fill({
5
5
  children,
6
6
  style,
7
7
  }: {
8
8
  children?: React.ReactNode;
9
- style?: ViewStyle | ViewStyle[];
9
+ style?: StyleProp<ViewStyle>;
10
10
  }) {
11
11
  return (
12
12
  <View style={[fillStyles.base, style]}>
@@ -3,3 +3,7 @@ export { useDelayedLoading } from './useDelayedLoading';
3
3
  export { useThrottledValue } from './useThrottledValue';
4
4
  export { usePressAnimation } from './usePressAnimation';
5
5
  export { mergeRefs } from './mergeRefs';
6
+ export { useHaptics, BloomHapticsProvider } from './useHaptics';
7
+ export type { HapticStrength, BloomHapticsProviderProps } from './useHaptics';
8
+ export { useGutters } from './useGutters';
9
+ export type { Gutter, Gutters } from './useGutters';
@@ -0,0 +1,99 @@
1
+ import { useMemo } from 'react';
2
+ import { useWindowDimensions } from 'react-native';
3
+
4
+ import { BREAKPOINTS } from '../styles/breakpoints';
5
+ import { space } from '../styles/tokens';
6
+
7
+ /**
8
+ * A named gutter size, or `0` for no padding on that edge.
9
+ *
10
+ * - `'compact'` — tight gutter (dense lists, chips).
11
+ * - `'base'` — the default page gutter.
12
+ * - `'wide'` — roomy gutter (marketing / hero surfaces).
13
+ */
14
+ export type Gutter = 'compact' | 'base' | 'wide' | 0;
15
+
16
+ /** Resolved edge padding in px — one value per side, ready to spread onto a style. */
17
+ export interface Gutters {
18
+ paddingTop: number;
19
+ paddingRight: number;
20
+ paddingBottom: number;
21
+ paddingLeft: number;
22
+ }
23
+
24
+ /** The active viewport tier: the widest matched breakpoint, or `'base'` below `sm`. */
25
+ type GutterTier = 'base' | 'sm' | 'md' | 'lg' | 'xl';
26
+
27
+ /**
28
+ * Per-size gutter padding (px) at each viewport tier. Padding steps up around
29
+ * tablet width (`md`), mirroring the design-system scale: phones get a tighter
30
+ * gutter, wider screens a roomier one. Values come from the shared `space`
31
+ * tokens so gutters stay on the spacing grid.
32
+ */
33
+ const GUTTER_SCALE: Record<Exclude<Gutter, 0>, Record<GutterTier, number>> = {
34
+ compact: { base: space.sm, sm: space.sm, md: space.md, lg: space.md, xl: space.md },
35
+ base: { base: space.lg, sm: space.lg, md: space.xl, lg: space.xl, xl: space.xl },
36
+ wide: { base: space.xl, sm: space.xl, md: space._3xl, lg: space._3xl, xl: space._3xl },
37
+ };
38
+
39
+ /** Widest-first breakpoint table so the first match wins. */
40
+ const TIERS: ReadonlyArray<readonly [Exclude<GutterTier, 'base'>, number]> = [
41
+ ['xl', BREAKPOINTS.xl],
42
+ ['lg', BREAKPOINTS.lg],
43
+ ['md', BREAKPOINTS.md],
44
+ ['sm', BREAKPOINTS.sm],
45
+ ];
46
+
47
+ function resolveTier(width: number): GutterTier {
48
+ for (const [tier, minWidth] of TIERS) {
49
+ if (width >= minWidth) return tier;
50
+ }
51
+ return 'base';
52
+ }
53
+
54
+ function resolveEdge(size: Gutter, tier: GutterTier): number {
55
+ return size === 0 ? 0 : GUTTER_SCALE[size][tier];
56
+ }
57
+
58
+ /**
59
+ * Expand a 1/2/4-arg CSS-shorthand gutter list into `[top, right, bottom, left]`.
60
+ * `??` (not `||`) so an explicit `0` is preserved rather than falling through.
61
+ */
62
+ function normalizeShorthand(gutters: Gutter[]): [Gutter, Gutter, Gutter, Gutter] {
63
+ const top = gutters[0] ?? 0;
64
+ const right = gutters[1] ?? top;
65
+ const bottom = gutters[2] ?? top;
66
+ const left = gutters[3] ?? right;
67
+ return [top, right, bottom, left];
68
+ }
69
+
70
+ /**
71
+ * Breakpoint-aware gutter padding. Give it CSS-shorthand gutter sizes and it
72
+ * returns resolved `padding*` values for the current viewport width, recomputed
73
+ * as the window crosses a breakpoint.
74
+ *
75
+ * @example
76
+ * ```tsx
77
+ * const gutters = useGutters(['base']); // uniform
78
+ * const gutters = useGutters([0, 'wide']); // no vertical, wide horizontal
79
+ * const gutters = useGutters(['compact', 'base', 0, 'base']); // t / r / b / l
80
+ * <View style={gutters} />
81
+ * ```
82
+ */
83
+ export function useGutters(gutters: [Gutter]): Gutters;
84
+ export function useGutters(gutters: [Gutter, Gutter]): Gutters;
85
+ export function useGutters(gutters: [Gutter, Gutter, Gutter, Gutter]): Gutters;
86
+ export function useGutters(gutters: Gutter[]): Gutters {
87
+ const { width } = useWindowDimensions();
88
+ const [top, right, bottom, left] = normalizeShorthand(gutters);
89
+
90
+ return useMemo<Gutters>(() => {
91
+ const tier = resolveTier(width);
92
+ return {
93
+ paddingTop: resolveEdge(top, tier),
94
+ paddingRight: resolveEdge(right, tier),
95
+ paddingBottom: resolveEdge(bottom, tier),
96
+ paddingLeft: resolveEdge(left, tier),
97
+ };
98
+ }, [width, top, right, bottom, left]);
99
+ }
@@ -0,0 +1,69 @@
1
+ import React, { createContext, useCallback, useContext, useMemo } from 'react';
2
+ import { Platform } from 'react-native';
3
+
4
+ import { lazyRequire } from '../utils/lazy-require';
5
+
6
+ /**
7
+ * Lazily loads `expo-haptics`, returning `null` when it is not installed. The
8
+ * dependency is an optional peer, so calls degrade to a no-op when absent.
9
+ */
10
+ const getHapticsModule = lazyRequire<typeof import('expo-haptics')>('expo-haptics');
11
+
12
+ /** Impact strength requested by a caller. */
13
+ export type HapticStrength = 'light' | 'medium' | 'heavy';
14
+
15
+ interface BloomHapticsContextValue {
16
+ enabled: boolean;
17
+ }
18
+
19
+ const BloomHapticsContext = createContext<BloomHapticsContextValue>({ enabled: true });
20
+
21
+ export interface BloomHapticsProviderProps {
22
+ /** Globally enable or disable haptics for the subtree. Defaults to `true`. */
23
+ enabled?: boolean;
24
+ children?: React.ReactNode;
25
+ }
26
+
27
+ /**
28
+ * Optional provider that toggles haptic feedback for its subtree — e.g. to honour
29
+ * a user "reduce haptics" preference. `useHaptics` defaults to enabled when no
30
+ * provider is present.
31
+ */
32
+ export function BloomHapticsProvider({ enabled = true, children }: BloomHapticsProviderProps) {
33
+ const value = useMemo(() => ({ enabled }), [enabled]);
34
+ return <BloomHapticsContext.Provider value={value}>{children}</BloomHapticsContext.Provider>;
35
+ }
36
+
37
+ BloomHapticsProvider.displayName = 'BloomHapticsProvider';
38
+
39
+ /**
40
+ * Returns a function that fires a haptic impact. The returned callback no-ops on
41
+ * web, when haptics are disabled via {@link BloomHapticsProvider}, or when the
42
+ * optional `expo-haptics` peer is not installed.
43
+ *
44
+ * `strength` defaults to `'light'`. Android is clamped to the light impact style
45
+ * regardless of `strength` — its medium/heavy motors read as too strong for UI
46
+ * feedback (tuned to match iOS perceived intensity).
47
+ */
48
+ export function useHaptics(): (strength?: HapticStrength) => void {
49
+ const { enabled } = useContext(BloomHapticsContext);
50
+
51
+ return useCallback(
52
+ (strength: HapticStrength = 'light') => {
53
+ if (!enabled || Platform.OS === 'web') return;
54
+
55
+ const haptics = getHapticsModule();
56
+ if (!haptics) return;
57
+
58
+ const { ImpactFeedbackStyle } = haptics;
59
+ let style = ImpactFeedbackStyle.Light;
60
+ if (Platform.OS !== 'android') {
61
+ if (strength === 'medium') style = ImpactFeedbackStyle.Medium;
62
+ else if (strength === 'heavy') style = ImpactFeedbackStyle.Heavy;
63
+ }
64
+
65
+ void haptics.impactAsync(style);
66
+ },
67
+ [enabled],
68
+ );
69
+ }
@@ -1,5 +1,11 @@
1
- import { useCallback, useState } from 'react';
1
+ import { useCallback, useMemo, useState } from 'react';
2
2
 
3
+ /**
4
+ * Tracks a single boolean interaction flag (hovered / focused / pressed).
5
+ *
6
+ * Returns a memoized `{ state, onIn, onOut }` with stable handler identities,
7
+ * suitable for spreading onto Pressable-like components.
8
+ */
3
9
  export function useInteractionState() {
4
10
  const [state, setState] = useState(false);
5
11
 
@@ -10,7 +16,7 @@ export function useInteractionState() {
10
16
  setState(false);
11
17
  }, []);
12
18
 
13
- return { state, onIn, onOut };
19
+ return useMemo(() => ({ state, onIn, onOut }), [state, onIn, onOut]);
14
20
  }
15
21
 
16
22
  /**
package/src/index.ts CHANGED
@@ -40,6 +40,10 @@ export type {
40
40
  export { useInteractionState } from './hooks/useInteractionState';
41
41
  export { useDelayedLoading } from './hooks/useDelayedLoading';
42
42
  export { useThrottledValue } from './hooks/useThrottledValue';
43
+ export { useHaptics, BloomHapticsProvider } from './hooks/useHaptics';
44
+ export type { HapticStrength, BloomHapticsProviderProps } from './hooks/useHaptics';
45
+ export { useGutters } from './hooks/useGutters';
46
+ export type { Gutter, Gutters } from './hooks/useGutters';
43
47
 
44
48
  // Icons
45
49
  export * as Icons from './icons';
@@ -97,12 +101,28 @@ export * as Typography from './typography';
97
101
  export * as Skeleton from './skeleton';
98
102
  export * as Grid from './grid';
99
103
  export { Fill } from './fill';
104
+ export { MediaInsetBorder } from './media-inset-border';
105
+ export type { MediaInsetBorderProps } from './media-inset-border';
100
106
  export { IconCircle } from './icon-circle';
101
107
  export { ConnectionDots } from './connection-dots';
102
108
  export type { ConnectionDotsProps } from './connection-dots';
103
109
  export { BenefitRow, BenefitList } from './benefit-list';
104
110
  export type { BenefitRowProps, BenefitListProps } from './benefit-list';
105
111
 
112
+ // Interaction primitives
113
+ export { PressableScale } from './pressable-scale';
114
+ export type { PressableScaleProps } from './pressable-scale';
115
+ export { PressableWithHover } from './pressable-with-hover';
116
+ export type { PressableWithHoverProps } from './pressable-with-hover';
117
+ export { SubtleHover } from './subtle-hover';
118
+ export type { SubtleHoverProps } from './subtle-hover';
119
+
120
+ // Motion presets (Reanimated enter/exit + directional screen transition)
121
+ export { ScaleAndFadeIn, ScaleAndFadeOut, ShrinkAndPop, ScreenTransition } from './motion';
122
+ export type { ScreenTransitionProps, ScreenTransitionDirection } from './motion';
123
+ export { AnimatedCheck } from './animated-check';
124
+ export type { AnimatedCheckProps, AnimatedCheckRef } from './animated-check';
125
+
106
126
  // Form components
107
127
  export * from './text-field';
108
128
  export * from './segmented-control';
package/src/index.web.ts CHANGED
@@ -45,6 +45,10 @@ export type {
45
45
  export { useInteractionState } from './hooks/useInteractionState';
46
46
  export { useDelayedLoading } from './hooks/useDelayedLoading';
47
47
  export { useThrottledValue } from './hooks/useThrottledValue';
48
+ export { useHaptics, BloomHapticsProvider } from './hooks/useHaptics';
49
+ export type { HapticStrength, BloomHapticsProviderProps } from './hooks/useHaptics';
50
+ export { useGutters } from './hooks/useGutters';
51
+ export type { Gutter, Gutters } from './hooks/useGutters';
48
52
 
49
53
  // Icons
50
54
  export * as Icons from './icons';
@@ -102,12 +106,28 @@ export * as Typography from './typography';
102
106
  export * as Skeleton from './skeleton';
103
107
  export * as Grid from './grid';
104
108
  export { Fill } from './fill';
109
+ export { MediaInsetBorder } from './media-inset-border';
110
+ export type { MediaInsetBorderProps } from './media-inset-border';
105
111
  export { IconCircle } from './icon-circle';
106
112
  export { ConnectionDots } from './connection-dots/index.web';
107
113
  export type { ConnectionDotsProps } from './connection-dots/index.web';
108
114
  export { BenefitRow, BenefitList } from './benefit-list';
109
115
  export type { BenefitRowProps, BenefitListProps } from './benefit-list';
110
116
 
117
+ // Interaction primitives
118
+ export { PressableScale } from './pressable-scale';
119
+ export type { PressableScaleProps } from './pressable-scale';
120
+ export { PressableWithHover } from './pressable-with-hover';
121
+ export type { PressableWithHoverProps } from './pressable-with-hover';
122
+ export { SubtleHover } from './subtle-hover';
123
+ export type { SubtleHoverProps } from './subtle-hover';
124
+
125
+ // Motion presets (Reanimated enter/exit + directional screen transition)
126
+ export { ScaleAndFadeIn, ScaleAndFadeOut, ShrinkAndPop, ScreenTransition } from './motion';
127
+ export type { ScreenTransitionProps, ScreenTransitionDirection } from './motion';
128
+ export { AnimatedCheck } from './animated-check';
129
+ export type { AnimatedCheckProps, AnimatedCheckRef } from './animated-check';
130
+
111
131
  // Form components
112
132
  export * from './text-field';
113
133
  export * from './segmented-control';
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import { Image, View } from 'react-native';
3
+ import type { Meta, StoryObj } from '@storybook/react-vite';
4
+
5
+ import { MediaInsetBorder } from './index';
6
+
7
+ const meta: Meta<typeof MediaInsetBorder> = {
8
+ title: 'Components/MediaInsetBorder',
9
+ component: MediaInsetBorder,
10
+ argTypes: {
11
+ opaque: { control: 'boolean' },
12
+ },
13
+ };
14
+
15
+ export default meta;
16
+
17
+ type Story = StoryObj<typeof MediaInsetBorder>;
18
+
19
+ const SAMPLE_URI =
20
+ 'https://images.unsplash.com/photo-1526779259212-939e64788e3c?w=400&q=80';
21
+
22
+ export const OverImage: Story = {
23
+ render: (args) => (
24
+ <View style={{ width: 240, height: 160, borderRadius: 8, overflow: 'hidden' }}>
25
+ <Image source={{ uri: SAMPLE_URI }} style={{ width: '100%', height: '100%' }} />
26
+ <MediaInsetBorder {...args} />
27
+ </View>
28
+ ),
29
+ };
30
+
31
+ export const OverFlatSurface: Story = {
32
+ render: (args) => (
33
+ <View
34
+ style={{
35
+ width: 240,
36
+ height: 160,
37
+ borderRadius: 8,
38
+ backgroundColor: '#e5e7eb',
39
+ }}
40
+ >
41
+ <MediaInsetBorder {...args} />
42
+ </View>
43
+ ),
44
+ };
45
+
46
+ export const Opaque: Story = {
47
+ args: { opaque: true },
48
+ render: (args) => (
49
+ <View
50
+ style={{
51
+ width: 240,
52
+ height: 160,
53
+ borderRadius: 8,
54
+ backgroundColor: '#e5e7eb',
55
+ }}
56
+ >
57
+ <MediaInsetBorder {...args} />
58
+ </View>
59
+ ),
60
+ };
@@ -0,0 +1,72 @@
1
+ import React from 'react';
2
+ import {
3
+ PixelRatio,
4
+ Platform,
5
+ StyleSheet,
6
+ type StyleProp,
7
+ type ViewStyle,
8
+ } from 'react-native';
9
+
10
+ import { Fill } from '../fill';
11
+ import { borderRadius } from '../styles/tokens';
12
+ import { useTheme } from '../theme';
13
+
14
+ /**
15
+ * A 1px (hairline) inset border reads too heavy on high-DPI web displays, so we
16
+ * drop to 0.5px there. On native the platform hairline is already correct, and
17
+ * `PixelRatio` is only consulted on web.
18
+ */
19
+ const HAIRLINE_WIDTH =
20
+ Platform.OS === 'web' && PixelRatio.get() > 1 ? 0.5 : StyleSheet.hairlineWidth;
21
+
22
+ export interface MediaInsetBorderProps {
23
+ /**
24
+ * Render a solid, full-contrast border instead of the default softened one.
25
+ * Use where the inset border must line up with adjacent opaque borders, such
26
+ * as external link-preview cards.
27
+ */
28
+ opaque?: boolean;
29
+ /**
30
+ * Extra style merged last. Override `borderRadius` here to match the corner
31
+ * radius of the surrounding media box.
32
+ */
33
+ style?: StyleProp<ViewStyle>;
34
+ children?: React.ReactNode;
35
+ }
36
+
37
+ /**
38
+ * A theme-aware hairline border painted just inside a media box (image, avatar,
39
+ * embed) to separate it from the surrounding background. Absolutely positioned
40
+ * to fill its parent (built on {@link Fill}) and non-interactive.
41
+ */
42
+ export function MediaInsetBorder({ opaque, style, children }: MediaInsetBorderProps) {
43
+ const theme = useTheme();
44
+
45
+ return (
46
+ <Fill
47
+ style={[
48
+ styles.base,
49
+ { borderColor: theme.colors.border },
50
+ !opaque && styles.soft,
51
+ style,
52
+ ]}
53
+ >
54
+ {children}
55
+ </Fill>
56
+ );
57
+ }
58
+
59
+ MediaInsetBorder.displayName = 'MediaInsetBorder';
60
+
61
+ const styles = StyleSheet.create({
62
+ base: {
63
+ borderRadius: borderRadius.sm,
64
+ borderWidth: HAIRLINE_WIDTH,
65
+ pointerEvents: 'none',
66
+ },
67
+ // Non-opaque: same border color at reduced opacity so it reads as a subtle,
68
+ // lower-contrast separator rather than a hard edge.
69
+ soft: {
70
+ opacity: 0.6,
71
+ },
72
+ });
@@ -0,0 +1,2 @@
1
+ export { MediaInsetBorder } from './MediaInsetBorder';
2
+ export type { MediaInsetBorderProps } from './MediaInsetBorder';