@oxyhq/bloom 0.19.0 → 0.20.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 (173) hide show
  1. package/lib/commonjs/benefit-list/index.js +119 -0
  2. package/lib/commonjs/benefit-list/index.js.map +1 -0
  3. package/lib/commonjs/benefit-list/types.js +6 -0
  4. package/lib/commonjs/benefit-list/types.js.map +1 -0
  5. package/lib/commonjs/button/Button.web.js +1 -1
  6. package/lib/commonjs/connection-dots/ConnectionDots.js +112 -0
  7. package/lib/commonjs/connection-dots/ConnectionDots.js.map +1 -0
  8. package/lib/commonjs/connection-dots/ConnectionDots.web.js +116 -0
  9. package/lib/commonjs/connection-dots/ConnectionDots.web.js.map +1 -0
  10. package/lib/commonjs/connection-dots/ConnectionDotsBase.js +55 -0
  11. package/lib/commonjs/connection-dots/ConnectionDotsBase.js.map +1 -0
  12. package/lib/commonjs/connection-dots/index.js +13 -0
  13. package/lib/commonjs/connection-dots/index.js.map +1 -0
  14. package/lib/commonjs/connection-dots/index.web.js +13 -0
  15. package/lib/commonjs/connection-dots/index.web.js.map +1 -0
  16. package/lib/commonjs/connection-dots/types.js +6 -0
  17. package/lib/commonjs/connection-dots/types.js.map +1 -0
  18. package/lib/commonjs/design-tokens/color-roles.js +112 -0
  19. package/lib/commonjs/design-tokens/color-roles.js.map +1 -0
  20. package/lib/commonjs/design-tokens/index.js +89 -0
  21. package/lib/commonjs/design-tokens/index.js.map +1 -0
  22. package/lib/commonjs/design-tokens/scales.js +168 -0
  23. package/lib/commonjs/design-tokens/scales.js.map +1 -0
  24. package/lib/commonjs/design-tokens/shadows.js +48 -0
  25. package/lib/commonjs/design-tokens/shadows.js.map +1 -0
  26. package/lib/commonjs/design-tokens/shadows.native.js +53 -0
  27. package/lib/commonjs/design-tokens/shadows.native.js.map +1 -0
  28. package/lib/commonjs/design-tokens/tailwind-preset.js +139 -0
  29. package/lib/commonjs/design-tokens/tailwind-preset.js.map +1 -0
  30. package/lib/commonjs/design-tokens/theme-css.js +98 -0
  31. package/lib/commonjs/design-tokens/theme-css.js.map +1 -0
  32. package/lib/commonjs/index.js +208 -93
  33. package/lib/commonjs/index.js.map +1 -1
  34. package/lib/commonjs/index.web.js +223 -108
  35. package/lib/commonjs/index.web.js.map +1 -1
  36. package/lib/commonjs/text-field/index.js +156 -0
  37. package/lib/commonjs/text-field/index.js.map +1 -1
  38. package/lib/module/benefit-list/index.js +114 -0
  39. package/lib/module/benefit-list/index.js.map +1 -0
  40. package/lib/module/benefit-list/types.js +4 -0
  41. package/lib/module/benefit-list/types.js.map +1 -0
  42. package/lib/module/button/Button.web.js +1 -1
  43. package/lib/module/connection-dots/ConnectionDots.js +107 -0
  44. package/lib/module/connection-dots/ConnectionDots.js.map +1 -0
  45. package/lib/module/connection-dots/ConnectionDots.web.js +111 -0
  46. package/lib/module/connection-dots/ConnectionDots.web.js.map +1 -0
  47. package/lib/module/connection-dots/ConnectionDotsBase.js +50 -0
  48. package/lib/module/connection-dots/ConnectionDotsBase.js.map +1 -0
  49. package/lib/module/connection-dots/index.js +4 -0
  50. package/lib/module/connection-dots/index.js.map +1 -0
  51. package/lib/module/connection-dots/index.web.js +4 -0
  52. package/lib/module/connection-dots/index.web.js.map +1 -0
  53. package/lib/module/connection-dots/types.js +4 -0
  54. package/lib/module/connection-dots/types.js.map +1 -0
  55. package/lib/module/design-tokens/color-roles.js +108 -0
  56. package/lib/module/design-tokens/color-roles.js.map +1 -0
  57. package/lib/module/design-tokens/index.js +27 -0
  58. package/lib/module/design-tokens/index.js.map +1 -0
  59. package/lib/module/design-tokens/scales.js +164 -0
  60. package/lib/module/design-tokens/scales.js.map +1 -0
  61. package/lib/module/design-tokens/shadows.js +43 -0
  62. package/lib/module/design-tokens/shadows.js.map +1 -0
  63. package/lib/module/design-tokens/shadows.native.js +48 -0
  64. package/lib/module/design-tokens/shadows.native.js.map +1 -0
  65. package/lib/module/design-tokens/tailwind-preset.js +136 -0
  66. package/lib/module/design-tokens/tailwind-preset.js.map +1 -0
  67. package/lib/module/design-tokens/theme-css.js +94 -0
  68. package/lib/module/design-tokens/theme-css.js.map +1 -0
  69. package/lib/module/index.js +6 -1
  70. package/lib/module/index.js.map +1 -1
  71. package/lib/module/index.web.js +6 -1
  72. package/lib/module/index.web.js.map +1 -1
  73. package/lib/module/text-field/index.js +158 -2
  74. package/lib/module/text-field/index.js.map +1 -1
  75. package/lib/typescript/commonjs/benefit-list/index.d.ts +6 -0
  76. package/lib/typescript/commonjs/benefit-list/index.d.ts.map +1 -0
  77. package/lib/typescript/commonjs/benefit-list/types.d.ts +35 -0
  78. package/lib/typescript/commonjs/benefit-list/types.d.ts.map +1 -0
  79. package/lib/typescript/commonjs/connection-dots/ConnectionDots.d.ts +4 -0
  80. package/lib/typescript/commonjs/connection-dots/ConnectionDots.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/connection-dots/ConnectionDots.web.d.ts +14 -0
  82. package/lib/typescript/commonjs/connection-dots/ConnectionDots.web.d.ts.map +1 -0
  83. package/lib/typescript/commonjs/connection-dots/ConnectionDotsBase.d.ts +18 -0
  84. package/lib/typescript/commonjs/connection-dots/ConnectionDotsBase.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/connection-dots/index.d.ts +3 -0
  86. package/lib/typescript/commonjs/connection-dots/index.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/connection-dots/index.web.d.ts +3 -0
  88. package/lib/typescript/commonjs/connection-dots/index.web.d.ts.map +1 -0
  89. package/lib/typescript/commonjs/connection-dots/types.d.ts +41 -0
  90. package/lib/typescript/commonjs/connection-dots/types.d.ts.map +1 -0
  91. package/lib/typescript/commonjs/design-tokens/color-roles.d.ts +102 -0
  92. package/lib/typescript/commonjs/design-tokens/color-roles.d.ts.map +1 -0
  93. package/lib/typescript/commonjs/design-tokens/index.d.ts +28 -0
  94. package/lib/typescript/commonjs/design-tokens/index.d.ts.map +1 -0
  95. package/lib/typescript/commonjs/design-tokens/scales.d.ts +152 -0
  96. package/lib/typescript/commonjs/design-tokens/scales.d.ts.map +1 -0
  97. package/lib/typescript/commonjs/design-tokens/shadows.d.ts +34 -0
  98. package/lib/typescript/commonjs/design-tokens/shadows.d.ts.map +1 -0
  99. package/lib/typescript/commonjs/design-tokens/shadows.native.d.ts +19 -0
  100. package/lib/typescript/commonjs/design-tokens/shadows.native.d.ts.map +1 -0
  101. package/lib/typescript/commonjs/design-tokens/tailwind-preset.d.ts +59 -0
  102. package/lib/typescript/commonjs/design-tokens/tailwind-preset.d.ts.map +1 -0
  103. package/lib/typescript/commonjs/design-tokens/theme-css.d.ts +39 -0
  104. package/lib/typescript/commonjs/design-tokens/theme-css.d.ts.map +1 -0
  105. package/lib/typescript/commonjs/index.d.ts +6 -0
  106. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/index.web.d.ts +6 -0
  108. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/text-field/index.d.ts +14 -1
  110. package/lib/typescript/commonjs/text-field/index.d.ts.map +1 -1
  111. package/lib/typescript/module/benefit-list/index.d.ts +6 -0
  112. package/lib/typescript/module/benefit-list/index.d.ts.map +1 -0
  113. package/lib/typescript/module/benefit-list/types.d.ts +35 -0
  114. package/lib/typescript/module/benefit-list/types.d.ts.map +1 -0
  115. package/lib/typescript/module/connection-dots/ConnectionDots.d.ts +4 -0
  116. package/lib/typescript/module/connection-dots/ConnectionDots.d.ts.map +1 -0
  117. package/lib/typescript/module/connection-dots/ConnectionDots.web.d.ts +14 -0
  118. package/lib/typescript/module/connection-dots/ConnectionDots.web.d.ts.map +1 -0
  119. package/lib/typescript/module/connection-dots/ConnectionDotsBase.d.ts +18 -0
  120. package/lib/typescript/module/connection-dots/ConnectionDotsBase.d.ts.map +1 -0
  121. package/lib/typescript/module/connection-dots/index.d.ts +3 -0
  122. package/lib/typescript/module/connection-dots/index.d.ts.map +1 -0
  123. package/lib/typescript/module/connection-dots/index.web.d.ts +3 -0
  124. package/lib/typescript/module/connection-dots/index.web.d.ts.map +1 -0
  125. package/lib/typescript/module/connection-dots/types.d.ts +41 -0
  126. package/lib/typescript/module/connection-dots/types.d.ts.map +1 -0
  127. package/lib/typescript/module/design-tokens/color-roles.d.ts +102 -0
  128. package/lib/typescript/module/design-tokens/color-roles.d.ts.map +1 -0
  129. package/lib/typescript/module/design-tokens/index.d.ts +28 -0
  130. package/lib/typescript/module/design-tokens/index.d.ts.map +1 -0
  131. package/lib/typescript/module/design-tokens/scales.d.ts +152 -0
  132. package/lib/typescript/module/design-tokens/scales.d.ts.map +1 -0
  133. package/lib/typescript/module/design-tokens/shadows.d.ts +34 -0
  134. package/lib/typescript/module/design-tokens/shadows.d.ts.map +1 -0
  135. package/lib/typescript/module/design-tokens/shadows.native.d.ts +19 -0
  136. package/lib/typescript/module/design-tokens/shadows.native.d.ts.map +1 -0
  137. package/lib/typescript/module/design-tokens/tailwind-preset.d.ts +59 -0
  138. package/lib/typescript/module/design-tokens/tailwind-preset.d.ts.map +1 -0
  139. package/lib/typescript/module/design-tokens/theme-css.d.ts +39 -0
  140. package/lib/typescript/module/design-tokens/theme-css.d.ts.map +1 -0
  141. package/lib/typescript/module/index.d.ts +6 -0
  142. package/lib/typescript/module/index.d.ts.map +1 -1
  143. package/lib/typescript/module/index.web.d.ts +6 -0
  144. package/lib/typescript/module/index.web.d.ts.map +1 -1
  145. package/lib/typescript/module/text-field/index.d.ts +14 -1
  146. package/lib/typescript/module/text-field/index.d.ts.map +1 -1
  147. package/package.json +50 -1
  148. package/src/__tests__/BenefitList.test.tsx +108 -0
  149. package/src/__tests__/ConnectionDots.test.tsx +86 -0
  150. package/src/__tests__/TextFieldFloatingLabel.test.tsx +93 -0
  151. package/src/__tests__/design-tokens.test.ts +169 -0
  152. package/src/benefit-list/BenefitList.stories.tsx +50 -0
  153. package/src/benefit-list/index.tsx +133 -0
  154. package/src/benefit-list/types.ts +36 -0
  155. package/src/button/Button.web.tsx +1 -1
  156. package/src/connection-dots/ConnectionDots.stories.tsx +54 -0
  157. package/src/connection-dots/ConnectionDots.tsx +139 -0
  158. package/src/connection-dots/ConnectionDots.web.tsx +122 -0
  159. package/src/connection-dots/ConnectionDotsBase.tsx +62 -0
  160. package/src/connection-dots/index.ts +2 -0
  161. package/src/connection-dots/index.web.ts +2 -0
  162. package/src/connection-dots/types.ts +41 -0
  163. package/src/design-tokens/color-roles.ts +110 -0
  164. package/src/design-tokens/index.ts +45 -0
  165. package/src/design-tokens/scales.ts +134 -0
  166. package/src/design-tokens/shadows.native.ts +44 -0
  167. package/src/design-tokens/shadows.ts +42 -0
  168. package/src/design-tokens/tailwind-preset.ts +139 -0
  169. package/src/design-tokens/theme-css.ts +99 -0
  170. package/src/index.ts +33 -0
  171. package/src/index.web.ts +33 -0
  172. package/src/text-field/TextField.stories.tsx +46 -0
  173. package/src/text-field/index.tsx +212 -1
@@ -0,0 +1,133 @@
1
+ import React, { memo } from 'react';
2
+ import {
3
+ View,
4
+ type StyleProp,
5
+ type TextStyle,
6
+ type ViewStyle,
7
+ } from 'react-native';
8
+
9
+ import { useTheme } from '../theme/use-theme';
10
+ import { atoms as a } from '../styles';
11
+ import { bloomShadowStyle } from '../design-tokens/shadows';
12
+ import { TYPOGRAPHY, RADIUS, SPACING, BORDER_WIDTH } from '../design-tokens/scales';
13
+ import { Text } from '../typography';
14
+ import type { BenefitRowProps, BenefitListProps } from './types';
15
+
16
+ /** Side of the rounded `fill-secondary` icon square. */
17
+ const ICON_SQUARE_SIZE = 36;
18
+
19
+ /**
20
+ * `BenefitRow` — an icon in a rounded `fill-secondary` square paired with a
21
+ * `text-secondary` caption (the `caption` type role). The building block of
22
+ * {@link BenefitList} (the "icon + caption row" of the consent reference), but
23
+ * usable standalone.
24
+ *
25
+ * Token roles → runtime theme colors:
26
+ * - icon square bg → `fill-secondary` (`--muted`) → `theme.colors.backgroundTertiary`
27
+ * - caption color → `text-secondary` (`--muted-foreground`) → `theme.colors.textSecondary`
28
+ * - caption type → `caption` role (size 11 / line-height 14)
29
+ */
30
+ const BenefitRowComponent: React.FC<BenefitRowProps> = ({
31
+ icon,
32
+ label,
33
+ children,
34
+ accessibilityLabel,
35
+ className,
36
+ style,
37
+ textStyle,
38
+ }) => {
39
+ const theme = useTheme();
40
+ const content = children ?? label;
41
+ const a11y =
42
+ accessibilityLabel ?? (typeof content === 'string' ? content : undefined);
43
+
44
+ const rowStyle: StyleProp<ViewStyle> = [
45
+ a.flex_row,
46
+ a.align_center,
47
+ { gap: SPACING['space-12'] },
48
+ style,
49
+ ];
50
+
51
+ const squareStyle: ViewStyle = {
52
+ width: ICON_SQUARE_SIZE,
53
+ height: ICON_SQUARE_SIZE,
54
+ borderRadius: RADIUS['radius-12'],
55
+ alignItems: 'center',
56
+ justifyContent: 'center',
57
+ backgroundColor: theme.colors.backgroundTertiary,
58
+ };
59
+
60
+ const captionStyle: StyleProp<TextStyle> = [
61
+ {
62
+ fontSize: TYPOGRAPHY.caption.size,
63
+ lineHeight: TYPOGRAPHY.caption.lineHeight,
64
+ fontWeight: TYPOGRAPHY.caption.weight,
65
+ color: theme.colors.textSecondary,
66
+ },
67
+ a.flex_1,
68
+ textStyle,
69
+ ];
70
+
71
+ return (
72
+ <View
73
+ {...(className ? ({ className } as Record<string, string>) : {})}
74
+ style={rowStyle}
75
+ accessibilityLabel={a11y}
76
+ {...(a11y ? { accessibilityRole: 'text' as const } : {})}>
77
+ <View style={squareStyle} accessibilityElementsHidden aria-hidden>
78
+ {icon}
79
+ </View>
80
+ <Text style={captionStyle}>{content}</Text>
81
+ </View>
82
+ );
83
+ };
84
+
85
+ export const BenefitRow = memo(BenefitRowComponent);
86
+ BenefitRow.displayName = 'BenefitRow';
87
+
88
+ /**
89
+ * `BenefitList` — a bordered card that stacks {@link BenefitRow}s with consistent
90
+ * spacing. The "3 icon + caption rows in a card" from the consent reference.
91
+ *
92
+ * Token roles → runtime values:
93
+ * - card bg → `fill` role (`--card`) → `theme.colors.card`
94
+ * - hairline → `border-image` role (`--border`) → `theme.colors.border` at
95
+ * the `border-hairline` (0.5px) width
96
+ * - radius → `radius-20`
97
+ * - elevation → `shadow-s` (platform-correct via `bloomShadowStyle`)
98
+ */
99
+ const BenefitListComponent: React.FC<BenefitListProps> = ({
100
+ children,
101
+ accessibilityLabel,
102
+ className,
103
+ style,
104
+ }) => {
105
+ const theme = useTheme();
106
+
107
+ const cardStyle: StyleProp<ViewStyle> = [
108
+ {
109
+ backgroundColor: theme.colors.card,
110
+ borderRadius: RADIUS['radius-20'],
111
+ borderWidth: BORDER_WIDTH.hairline,
112
+ borderColor: theme.colors.border,
113
+ padding: SPACING['space-16'],
114
+ gap: SPACING['space-16'],
115
+ },
116
+ bloomShadowStyle('s'),
117
+ style,
118
+ ];
119
+
120
+ return (
121
+ <View
122
+ {...(className ? ({ className } as Record<string, string>) : {})}
123
+ style={cardStyle}
124
+ accessibilityLabel={accessibilityLabel}>
125
+ {children}
126
+ </View>
127
+ );
128
+ };
129
+
130
+ export const BenefitList = memo(BenefitListComponent);
131
+ BenefitList.displayName = 'BenefitList';
132
+
133
+ export type { BenefitRowProps, BenefitListProps } from './types';
@@ -0,0 +1,36 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, TextStyle, ViewStyle } from 'react-native';
3
+
4
+ export interface BenefitRowProps {
5
+ /**
6
+ * Leading icon — rendered inside a rounded `fill-secondary` square. Pass a
7
+ * Bloom icon element (e.g. `<Icons.Lock size="sm" />`) or any node.
8
+ */
9
+ icon: ReactNode;
10
+ /**
11
+ * Caption text. Provide either `label` or `children` (string or nodes).
12
+ * Rendered as `text-secondary` at the `caption` type role.
13
+ */
14
+ label?: string;
15
+ /** Caption content as children (alternative to `label`). */
16
+ children?: ReactNode;
17
+ /** Accessibility label for the row (defaults to the text content). */
18
+ accessibilityLabel?: string;
19
+ /** NativeWind className passthrough on the row container. */
20
+ className?: string;
21
+ /** Style passthrough on the row container. */
22
+ style?: StyleProp<ViewStyle>;
23
+ /** Style passthrough on the caption text. */
24
+ textStyle?: StyleProp<TextStyle>;
25
+ }
26
+
27
+ export interface BenefitListProps {
28
+ /** `BenefitRow`s (or any nodes) stacked with consistent spacing. */
29
+ children: ReactNode;
30
+ /** Accessibility label for the card. */
31
+ accessibilityLabel?: string;
32
+ /** NativeWind className passthrough on the card container. */
33
+ className?: string;
34
+ /** Style passthrough on the card container. */
35
+ style?: StyleProp<ViewStyle>;
36
+ }
@@ -89,7 +89,7 @@ const BLOOM_BUTTON_CSS = `
89
89
  margin: 0;
90
90
  border: none;
91
91
  background: transparent;
92
- font-family: inherit;
92
+ font-family: var(--bloom-font-sans, inherit);
93
93
  text-decoration: none;
94
94
  cursor: pointer;
95
95
  user-select: none;
@@ -0,0 +1,54 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import type { Meta, StoryObj } from '@storybook/react-vite';
4
+
5
+ import { ConnectionDots } from './index';
6
+ import { IconCircle } from '../icon-circle';
7
+ import { Person_Stroke2_Corner0_Rounded as PersonIcon } from '../icons/Person';
8
+ import { Lock_Stroke2_Corner0_Rounded as LockIcon } from '../icons/Lock';
9
+
10
+ const meta: Meta<typeof ConnectionDots> = {
11
+ title: 'Components/ConnectionDots',
12
+ component: ConnectionDots,
13
+ };
14
+
15
+ export default meta;
16
+
17
+ type Story = StoryObj<typeof ConnectionDots>;
18
+
19
+ export const Basic: Story = {
20
+ render: () => (
21
+ <View style={{ padding: 24 }}>
22
+ <ConnectionDots
23
+ left={<IconCircle icon={PersonIcon} size="lg" />}
24
+ right={<IconCircle icon={LockIcon} size="lg" />}
25
+ accessibilityLabel="Connecting your account"
26
+ />
27
+ </View>
28
+ ),
29
+ };
30
+
31
+ export const ManyDots: Story = {
32
+ render: () => (
33
+ <View style={{ padding: 24 }}>
34
+ <ConnectionDots
35
+ left={<IconCircle icon={PersonIcon} size="lg" />}
36
+ right={<IconCircle icon={LockIcon} size="lg" />}
37
+ dotCount={10}
38
+ dotSize={5}
39
+ />
40
+ </View>
41
+ ),
42
+ };
43
+
44
+ export const ReducedMotion: Story = {
45
+ render: () => (
46
+ <View style={{ padding: 24 }}>
47
+ <ConnectionDots
48
+ left={<IconCircle icon={PersonIcon} size="lg" />}
49
+ right={<IconCircle icon={LockIcon} size="lg" />}
50
+ reducedMotion
51
+ />
52
+ </View>
53
+ ),
54
+ };
@@ -0,0 +1,139 @@
1
+ import React, { memo, useEffect } from 'react';
2
+ import Animated, {
3
+ Easing,
4
+ interpolate,
5
+ type SharedValue,
6
+ useAnimatedStyle,
7
+ useDerivedValue,
8
+ useReducedMotion,
9
+ useSharedValue,
10
+ withRepeat,
11
+ withTiming,
12
+ } from 'react-native-reanimated';
13
+
14
+ import { useTheme } from '../theme/use-theme';
15
+ import { ConnectionDotsBase } from './ConnectionDotsBase';
16
+ import type { ConnectionDotsProps } from './types';
17
+
18
+ const DEFAULT_DOT_COUNT = 6;
19
+ const DEFAULT_DOT_SIZE = 6;
20
+ const WAVE_DURATION = 1400;
21
+ /** Opacity floor of a dot at the trough of the wave. */
22
+ const DOT_DIM = 0.25;
23
+
24
+ interface ShimmerDotProps {
25
+ /** 0..1 phase offset of this dot within the wave. */
26
+ phase: number;
27
+ color: string;
28
+ size: number;
29
+ /** Shared 0..1 driver advanced once per loop for the whole row. */
30
+ driver: SharedValue<number>;
31
+ }
32
+
33
+ /**
34
+ * One dot. Its opacity is the shared driver phase-shifted by `phase`, mapped
35
+ * through a triangle wave so the bright crest sweeps left→right across the row
36
+ * (the "ellipsis shimmer").
37
+ */
38
+ const ShimmerDot: React.FC<ShimmerDotProps> = ({ phase, color, size, driver }) => {
39
+ const opacity = useDerivedValue(() => {
40
+ // wrap into 0..1 then fold into a 0→1→0 triangle so the crest is a moving band
41
+ const t = (driver.value + phase) % 1;
42
+ const wave = 1 - Math.abs(t * 2 - 1);
43
+ return interpolate(wave, [0, 1], [DOT_DIM, 1]);
44
+ });
45
+
46
+ const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
47
+
48
+ return (
49
+ <Animated.View
50
+ style={[
51
+ {
52
+ width: size,
53
+ height: size,
54
+ borderRadius: size / 2,
55
+ backgroundColor: color,
56
+ },
57
+ animatedStyle,
58
+ ]}
59
+ />
60
+ );
61
+ };
62
+
63
+ /**
64
+ * Native `ConnectionDots` — a consent/linking header rendering
65
+ * `left · (animated ellipsis dots) · right`. The dots run a looping opacity wave
66
+ * in the brand color (`fill-brand` → `theme.colors.primary`). Reduced-motion
67
+ * (Reanimated's `useReducedMotion`, or the `reducedMotion` prop) renders the
68
+ * dots static at full opacity.
69
+ */
70
+ const ConnectionDotsComponent: React.FC<ConnectionDotsProps> = ({
71
+ left,
72
+ right,
73
+ dotCount = DEFAULT_DOT_COUNT,
74
+ dotSize = DEFAULT_DOT_SIZE,
75
+ accessibilityLabel,
76
+ className,
77
+ style,
78
+ reducedMotion,
79
+ }) => {
80
+ const theme = useTheme();
81
+ const systemReduceMotion = useReducedMotion();
82
+ const reduce = reducedMotion ?? systemReduceMotion;
83
+ const color = theme.colors.primary;
84
+
85
+ const driver = useSharedValue(0);
86
+
87
+ useEffect(() => {
88
+ if (reduce) {
89
+ driver.value = 0;
90
+ return;
91
+ }
92
+ driver.value = withRepeat(
93
+ withTiming(1, { duration: WAVE_DURATION, easing: Easing.linear }),
94
+ -1,
95
+ false,
96
+ );
97
+ // driver is a stable shared-value ref; withRepeat/withTiming/Easing are
98
+ // module-level constants from a static import.
99
+ // eslint-disable-next-line react-hooks/exhaustive-deps
100
+ }, [reduce]);
101
+
102
+ const count = Math.max(1, Math.floor(dotCount));
103
+
104
+ const dots = reduce
105
+ ? Array.from({ length: count }, (_, i) => (
106
+ <Animated.View
107
+ key={i}
108
+ style={{
109
+ width: dotSize,
110
+ height: dotSize,
111
+ borderRadius: dotSize / 2,
112
+ backgroundColor: color,
113
+ }}
114
+ />
115
+ ))
116
+ : Array.from({ length: count }, (_, i) => (
117
+ <ShimmerDot
118
+ key={i}
119
+ phase={i / count}
120
+ color={color}
121
+ size={dotSize}
122
+ driver={driver}
123
+ />
124
+ ));
125
+
126
+ return (
127
+ <ConnectionDotsBase
128
+ left={left}
129
+ right={right}
130
+ dots={dots}
131
+ accessibilityLabel={accessibilityLabel}
132
+ className={className}
133
+ style={style}
134
+ />
135
+ );
136
+ };
137
+
138
+ export const ConnectionDots = memo(ConnectionDotsComponent);
139
+ ConnectionDots.displayName = 'ConnectionDots';
@@ -0,0 +1,122 @@
1
+ import React, { memo, useEffect, useState } from 'react';
2
+ import { View, type ViewStyle } from 'react-native';
3
+
4
+ import { useTheme } from '../theme/use-theme';
5
+ import { ConnectionDotsBase } from './ConnectionDotsBase';
6
+ import type { ConnectionDotsProps } from './types';
7
+
8
+ const DEFAULT_DOT_COUNT = 6;
9
+ const DEFAULT_DOT_SIZE = 6;
10
+ const WAVE_DURATION_MS = 1400;
11
+
12
+ const KEYFRAMES_ID = 'bloom-connection-dots-keyframes';
13
+ const WAVE_ANIMATION_NAME = 'bloomConnectionDotsWave';
14
+
15
+ /**
16
+ * CSS keyframes powering the ellipsis shimmer. Mirrors the native Reanimated
17
+ * triangle wave: each dot's opacity rises to 1 and falls back to a dim floor,
18
+ * with a per-dot `animation-delay` sweeping the bright crest left→right.
19
+ *
20
+ * A `prefers-reduced-motion: reduce` media query pins every dot to full opacity,
21
+ * matching the native `useReducedMotion` branch — defence-in-depth alongside the
22
+ * JS `matchMedia` gate below (which also stops emitting the `animation` prop).
23
+ */
24
+ export const BLOOM_CONNECTION_DOTS_CSS = `
25
+ @keyframes ${WAVE_ANIMATION_NAME} { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }
26
+ @media (prefers-reduced-motion: reduce) {
27
+ [data-bloom-connection-dot] { animation: none !important; opacity: 1 !important; }
28
+ }
29
+ `;
30
+
31
+ function useKeyframes(): void {
32
+ useEffect(() => {
33
+ if (typeof document === 'undefined') return;
34
+ if (document.getElementById(KEYFRAMES_ID)) return;
35
+ const style = document.createElement('style');
36
+ style.id = KEYFRAMES_ID;
37
+ style.textContent = BLOOM_CONNECTION_DOTS_CSS;
38
+ document.head.appendChild(style);
39
+ }, []);
40
+ }
41
+
42
+ /** Read the OS `prefers-reduced-motion` preference reactively. */
43
+ function usePrefersReducedMotion(): boolean {
44
+ const [reduced, setReduced] = useState(false);
45
+
46
+ useEffect(() => {
47
+ if (typeof window === 'undefined' || !window.matchMedia) return;
48
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
49
+ setReduced(mq.matches);
50
+ const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
51
+ mq.addEventListener('change', onChange);
52
+ return () => mq.removeEventListener('change', onChange);
53
+ }, []);
54
+
55
+ return reduced;
56
+ }
57
+
58
+ /**
59
+ * Web fork of `./ConnectionDots`. The native variant runs the opacity wave with
60
+ * `react-native-reanimated`, which cannot ship to a web bundle. This fork drives
61
+ * the same motion with a CSS `@keyframes` animation (injected once into
62
+ * `<head>`) and a per-dot `animation-delay`. Brand color comes from the
63
+ * `fill-brand` role (`theme.colors.primary`). Reduced-motion (the `reducedMotion`
64
+ * prop, the JS `matchMedia` gate, or the CSS media query) renders static dots.
65
+ */
66
+ const ConnectionDotsComponent: React.FC<ConnectionDotsProps> = ({
67
+ left,
68
+ right,
69
+ dotCount = DEFAULT_DOT_COUNT,
70
+ dotSize = DEFAULT_DOT_SIZE,
71
+ accessibilityLabel,
72
+ className,
73
+ style,
74
+ reducedMotion,
75
+ }) => {
76
+ const theme = useTheme();
77
+ useKeyframes();
78
+ const systemReduceMotion = usePrefersReducedMotion();
79
+ const reduce = reducedMotion ?? systemReduceMotion;
80
+ const color = theme.colors.primary;
81
+
82
+ const count = Math.max(1, Math.floor(dotCount));
83
+
84
+ const dots = Array.from({ length: count }, (_, i) => {
85
+ const base: ViewStyle = {
86
+ width: dotSize,
87
+ height: dotSize,
88
+ borderRadius: dotSize / 2,
89
+ backgroundColor: color,
90
+ };
91
+ const animated: ViewStyle = reduce
92
+ ? { opacity: 1 }
93
+ : ({
94
+ // `animation` / `animationDelay` are real CSS props on web;
95
+ // react-native-web passes them through to the DOM node.
96
+ animation: `${WAVE_ANIMATION_NAME} ${WAVE_DURATION_MS}ms linear infinite`,
97
+ animationDelay: `${(i / count) * WAVE_DURATION_MS}ms`,
98
+ } as ViewStyle);
99
+ return (
100
+ <View
101
+ key={i}
102
+ // marker attribute lets the reduced-motion media query target dots
103
+ {...({ 'data-bloom-connection-dot': '' } as Record<string, string>)}
104
+ style={[base, animated]}
105
+ />
106
+ );
107
+ });
108
+
109
+ return (
110
+ <ConnectionDotsBase
111
+ left={left}
112
+ right={right}
113
+ dots={dots}
114
+ accessibilityLabel={accessibilityLabel}
115
+ className={className}
116
+ style={style}
117
+ />
118
+ );
119
+ };
120
+
121
+ export const ConnectionDots = memo(ConnectionDotsComponent);
122
+ ConnectionDots.displayName = 'ConnectionDots';
@@ -0,0 +1,62 @@
1
+ import React from 'react';
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ import { atoms as a, tokens } from '../styles';
5
+ import type { ConnectionDotsProps } from './types';
6
+
7
+ export interface ConnectionDotsBaseProps
8
+ extends Pick<
9
+ ConnectionDotsProps,
10
+ 'left' | 'right' | 'accessibilityLabel' | 'className' | 'style'
11
+ > {
12
+ /** The platform-specific animated dots row (built by the native/web fork). */
13
+ dots: React.ReactNode;
14
+ }
15
+
16
+ /**
17
+ * Shared layout + accessibility chrome for {@link ConnectionDots}:
18
+ *
19
+ * leftSlot · (animated ellipsis dots) · rightSlot
20
+ *
21
+ * The dots themselves are supplied by the platform fork (`ConnectionDots.tsx`
22
+ * native / `ConnectionDots.web.tsx` web) because the shimmer animation impl
23
+ * differs (Reanimated vs CSS). This base owns the row layout, the slot wrappers,
24
+ * and the single accessibility label that stands in for the decorative dots.
25
+ */
26
+ export function ConnectionDotsBase({
27
+ left,
28
+ right,
29
+ dots,
30
+ accessibilityLabel = 'Connecting',
31
+ className,
32
+ style,
33
+ }: ConnectionDotsBaseProps) {
34
+ const rowStyle: StyleProp<ViewStyle> = [
35
+ a.flex_row,
36
+ a.align_center,
37
+ a.justify_center,
38
+ { gap: tokens.space.md },
39
+ style,
40
+ ];
41
+
42
+ return (
43
+ <View
44
+ {...(className ? ({ className } as Record<string, string>) : {})}
45
+ style={rowStyle}>
46
+ <View style={[a.align_center, a.justify_center]}>{left}</View>
47
+
48
+ {/* The dots are decorative; the whole cluster carries ONE a11y label
49
+ (e.g. "Connecting") so screen readers announce the link relationship
50
+ without reading each dot. The left/right slots keep their own a11y. */}
51
+ <View
52
+ accessibilityRole="image"
53
+ accessibilityLabel={accessibilityLabel}
54
+ aria-label={accessibilityLabel}
55
+ style={[a.flex_row, a.align_center, { gap: tokens.space.xs }]}>
56
+ {dots}
57
+ </View>
58
+
59
+ <View style={[a.align_center, a.justify_center]}>{right}</View>
60
+ </View>
61
+ );
62
+ }
@@ -0,0 +1,2 @@
1
+ export { ConnectionDots } from './ConnectionDots';
2
+ export type { ConnectionDotsProps } from './types';
@@ -0,0 +1,2 @@
1
+ export { ConnectionDots } from './ConnectionDots.web';
2
+ export type { ConnectionDotsProps } from './types';
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+
4
+ export interface ConnectionDotsProps {
5
+ /**
6
+ * Left-hand content — typically a logo or avatar of the first party in a
7
+ * link/consent flow. Rendered as-is.
8
+ */
9
+ left: ReactNode;
10
+ /**
11
+ * Right-hand content — typically a logo or avatar of the second party.
12
+ * Rendered as-is.
13
+ */
14
+ right: ReactNode;
15
+ /**
16
+ * Number of ellipsis dots animating between the two slots.
17
+ * @default 6
18
+ */
19
+ dotCount?: number;
20
+ /**
21
+ * Diameter of each dot in px.
22
+ * @default 6
23
+ */
24
+ dotSize?: number;
25
+ /**
26
+ * Accessibility label for the whole connection band. Read by screen readers in
27
+ * place of the (decorative) dots.
28
+ * @default 'Connecting'
29
+ */
30
+ accessibilityLabel?: string;
31
+ /** NativeWind className passthrough on the outer row. */
32
+ className?: string;
33
+ /** Style passthrough on the outer row. */
34
+ style?: StyleProp<ViewStyle>;
35
+ /**
36
+ * Force-disable the shimmer regardless of the OS reduced-motion setting.
37
+ * (When omitted, the component reads the platform reduced-motion preference
38
+ * and renders the dots statically when it is on.)
39
+ */
40
+ reducedMotion?: boolean;
41
+ }