jfs-components 0.0.71 → 0.0.73

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 (141) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/lib/commonjs/components/AccordionCheckbox/AccordionCheckbox.js +239 -0
  3. package/lib/commonjs/components/BrandChip/BrandChip.js +149 -0
  4. package/lib/commonjs/components/CardAdvisory/CardAdvisory.js +2 -2
  5. package/lib/commonjs/components/CardBankAccount/CardBankAccount.js +213 -0
  6. package/lib/commonjs/components/CardFinancialCondition/CardFinancialCondition.js +213 -0
  7. package/lib/commonjs/components/CardInsight/CardInsight.js +166 -0
  8. package/lib/commonjs/components/Carousel/Carousel.js +9 -7
  9. package/lib/commonjs/components/CheckboxGroup/CheckboxGroup.js +67 -0
  10. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +125 -0
  11. package/lib/commonjs/components/CircularProgressBar/CircularProgressBar.js +56 -9
  12. package/lib/commonjs/components/CoverageBarComparison/CoverageBarComparison.js +272 -0
  13. package/lib/commonjs/components/CoverageRing/CoverageRing.js +141 -0
  14. package/lib/commonjs/components/DonutChart/DonutChart.js +309 -0
  15. package/lib/commonjs/components/DonutChartSummary/DonutChartSummary.js +155 -0
  16. package/lib/commonjs/components/HoldingsCard/HoldingsCard.js +2 -2
  17. package/lib/commonjs/components/InstitutionBadge/InstitutionBadge.js +132 -0
  18. package/lib/commonjs/components/LinearMeter/LinearMeter.js +9 -28
  19. package/lib/commonjs/components/LinearProgress/LinearProgress.js +68 -0
  20. package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +95 -0
  21. package/lib/commonjs/components/MonthlyStatusGrid/MonthlyStatusGrid.js +286 -0
  22. package/lib/commonjs/components/OTP/OTP.js +381 -37
  23. package/lib/commonjs/components/ProductOverview/ProductOverview.js +147 -0
  24. package/lib/commonjs/components/Radio/Radio.js +194 -0
  25. package/lib/commonjs/components/RadioButton/RadioButton.js +21 -188
  26. package/lib/commonjs/components/RangeTrack/RangeTrack.js +269 -0
  27. package/lib/commonjs/components/SavingsGoalSummary/SavingsGoalSummary.js +181 -0
  28. package/lib/commonjs/components/SegmentedTrack/SegmentedTrack.js +171 -0
  29. package/lib/commonjs/components/StatGroup/StatGroup.js +128 -0
  30. package/lib/commonjs/components/StatItem/StatItem.js +65 -35
  31. package/lib/commonjs/components/StrengthIndicator/StrengthIndicator.js +157 -0
  32. package/lib/commonjs/components/SummaryTile/SummaryTile.js +150 -0
  33. package/lib/commonjs/components/index.js +192 -1
  34. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  35. package/lib/commonjs/icons/registry.js +1 -1
  36. package/lib/commonjs/utils/index.js +7 -0
  37. package/lib/commonjs/utils/number-utils.js +57 -0
  38. package/lib/module/components/AccordionCheckbox/AccordionCheckbox.js +233 -0
  39. package/lib/module/components/BrandChip/BrandChip.js +143 -0
  40. package/lib/module/components/CardAdvisory/CardAdvisory.js +2 -2
  41. package/lib/module/components/CardBankAccount/CardBankAccount.js +208 -0
  42. package/lib/module/components/CardFinancialCondition/CardFinancialCondition.js +207 -0
  43. package/lib/module/components/CardInsight/CardInsight.js +161 -0
  44. package/lib/module/components/Carousel/Carousel.js +9 -7
  45. package/lib/module/components/CheckboxGroup/CheckboxGroup.js +62 -0
  46. package/lib/module/components/CheckboxItem/CheckboxItem.js +119 -0
  47. package/lib/module/components/CircularProgressBar/CircularProgressBar.js +56 -9
  48. package/lib/module/components/CoverageBarComparison/CoverageBarComparison.js +266 -0
  49. package/lib/module/components/CoverageRing/CoverageRing.js +136 -0
  50. package/lib/module/components/DonutChart/DonutChart.js +303 -0
  51. package/lib/module/components/DonutChartSummary/DonutChartSummary.js +150 -0
  52. package/lib/module/components/HoldingsCard/HoldingsCard.js +2 -2
  53. package/lib/module/components/InstitutionBadge/InstitutionBadge.js +127 -0
  54. package/lib/module/components/LinearMeter/LinearMeter.js +9 -28
  55. package/lib/module/components/LinearProgress/LinearProgress.js +63 -0
  56. package/lib/module/components/MetricLegendItem/MetricLegendItem.js +90 -0
  57. package/lib/module/components/MonthlyStatusGrid/MonthlyStatusGrid.js +281 -0
  58. package/lib/module/components/OTP/OTP.js +381 -38
  59. package/lib/module/components/ProductOverview/ProductOverview.js +142 -0
  60. package/lib/module/components/Radio/Radio.js +188 -0
  61. package/lib/module/components/RadioButton/RadioButton.js +20 -185
  62. package/lib/module/components/RangeTrack/RangeTrack.js +263 -0
  63. package/lib/module/components/SavingsGoalSummary/SavingsGoalSummary.js +175 -0
  64. package/lib/module/components/SegmentedTrack/SegmentedTrack.js +166 -0
  65. package/lib/module/components/StatGroup/StatGroup.js +123 -0
  66. package/lib/module/components/StatItem/StatItem.js +66 -36
  67. package/lib/module/components/StrengthIndicator/StrengthIndicator.js +152 -0
  68. package/lib/module/components/SummaryTile/SummaryTile.js +145 -0
  69. package/lib/module/components/index.js +28 -1
  70. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  71. package/lib/module/icons/registry.js +1 -1
  72. package/lib/module/utils/index.js +2 -1
  73. package/lib/module/utils/number-utils.js +53 -0
  74. package/lib/typescript/src/components/AccordionCheckbox/AccordionCheckbox.d.ts +71 -0
  75. package/lib/typescript/src/components/BrandChip/BrandChip.d.ts +43 -0
  76. package/lib/typescript/src/components/CardBankAccount/CardBankAccount.d.ts +79 -0
  77. package/lib/typescript/src/components/CardFinancialCondition/CardFinancialCondition.d.ts +50 -0
  78. package/lib/typescript/src/components/CardInsight/CardInsight.d.ts +48 -0
  79. package/lib/typescript/src/components/CheckboxGroup/CheckboxGroup.d.ts +41 -0
  80. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +56 -0
  81. package/lib/typescript/src/components/CircularProgressBar/CircularProgressBar.d.ts +11 -1
  82. package/lib/typescript/src/components/CoverageBarComparison/CoverageBarComparison.d.ts +105 -0
  83. package/lib/typescript/src/components/CoverageRing/CoverageRing.d.ts +90 -0
  84. package/lib/typescript/src/components/DonutChart/DonutChart.d.ts +117 -0
  85. package/lib/typescript/src/components/DonutChartSummary/DonutChartSummary.d.ts +103 -0
  86. package/lib/typescript/src/components/InstitutionBadge/InstitutionBadge.d.ts +30 -0
  87. package/lib/typescript/src/components/LinearProgress/LinearProgress.d.ts +17 -0
  88. package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +37 -0
  89. package/lib/typescript/src/components/MonthlyStatusGrid/MonthlyStatusGrid.d.ts +119 -0
  90. package/lib/typescript/src/components/OTP/OTP.d.ts +88 -2
  91. package/lib/typescript/src/components/ProductOverview/ProductOverview.d.ts +39 -0
  92. package/lib/typescript/src/components/Radio/Radio.d.ts +30 -0
  93. package/lib/typescript/src/components/RadioButton/RadioButton.d.ts +20 -28
  94. package/lib/typescript/src/components/RangeTrack/RangeTrack.d.ts +173 -0
  95. package/lib/typescript/src/components/SavingsGoalSummary/SavingsGoalSummary.d.ts +95 -0
  96. package/lib/typescript/src/components/SegmentedTrack/SegmentedTrack.d.ts +108 -0
  97. package/lib/typescript/src/components/StatGroup/StatGroup.d.ts +45 -0
  98. package/lib/typescript/src/components/StatItem/StatItem.d.ts +24 -7
  99. package/lib/typescript/src/components/StrengthIndicator/StrengthIndicator.d.ts +58 -0
  100. package/lib/typescript/src/components/SummaryTile/SummaryTile.d.ts +60 -0
  101. package/lib/typescript/src/components/index.d.ts +29 -2
  102. package/lib/typescript/src/icons/registry.d.ts +1 -1
  103. package/lib/typescript/src/utils/index.d.ts +1 -0
  104. package/lib/typescript/src/utils/number-utils.d.ts +29 -0
  105. package/package.json +1 -1
  106. package/src/components/AccordionCheckbox/AccordionCheckbox.tsx +323 -0
  107. package/src/components/BrandChip/BrandChip.tsx +235 -0
  108. package/src/components/CardAdvisory/CardAdvisory.tsx +2 -2
  109. package/src/components/CardBankAccount/CardBankAccount.tsx +295 -0
  110. package/src/components/CardFinancialCondition/CardFinancialCondition.tsx +366 -0
  111. package/src/components/CardInsight/CardInsight.tsx +239 -0
  112. package/src/components/Carousel/Carousel.tsx +14 -6
  113. package/src/components/CheckboxGroup/CheckboxGroup.tsx +86 -0
  114. package/src/components/CheckboxItem/CheckboxItem.tsx +174 -0
  115. package/src/components/CircularProgressBar/CircularProgressBar.tsx +74 -9
  116. package/src/components/CoverageBarComparison/CoverageBarComparison.tsx +378 -0
  117. package/src/components/CoverageRing/CoverageRing.tsx +225 -0
  118. package/src/components/DonutChart/DonutChart.tsx +503 -0
  119. package/src/components/DonutChartSummary/DonutChartSummary.tsx +256 -0
  120. package/src/components/HoldingsCard/HoldingsCard.tsx +2 -2
  121. package/src/components/InstitutionBadge/InstitutionBadge.tsx +216 -0
  122. package/src/components/LinearMeter/LinearMeter.tsx +9 -39
  123. package/src/components/LinearProgress/LinearProgress.tsx +92 -0
  124. package/src/components/MetricLegendItem/MetricLegendItem.tsx +167 -0
  125. package/src/components/MonthlyStatusGrid/MonthlyStatusGrid.tsx +438 -0
  126. package/src/components/OTP/OTP.tsx +476 -29
  127. package/src/components/ProductOverview/ProductOverview.tsx +236 -0
  128. package/src/components/Radio/Radio.tsx +227 -0
  129. package/src/components/RadioButton/RadioButton.tsx +23 -225
  130. package/src/components/RangeTrack/RangeTrack.tsx +394 -0
  131. package/src/components/SavingsGoalSummary/SavingsGoalSummary.tsx +269 -0
  132. package/src/components/SegmentedTrack/SegmentedTrack.tsx +268 -0
  133. package/src/components/StatGroup/StatGroup.tsx +169 -0
  134. package/src/components/StatItem/StatItem.tsx +117 -40
  135. package/src/components/StrengthIndicator/StrengthIndicator.tsx +205 -0
  136. package/src/components/SummaryTile/SummaryTile.tsx +251 -0
  137. package/src/components/index.ts +39 -2
  138. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  139. package/src/icons/registry.ts +1 -1
  140. package/src/utils/index.ts +1 -0
  141. package/src/utils/number-utils.ts +60 -0
@@ -1,4 +1,4 @@
1
- import React, { useState, useRef, useCallback, useEffect } from 'react'
1
+ import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -12,8 +12,310 @@ import {
12
12
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
13
13
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
14
14
  import SupportText, { type SupportTextProps } from '../SupportText/SupportText'
15
+ import Button from '../Button/Button'
15
16
  import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils'
16
17
 
18
+ // Default mode overrides for the resend Button. Per design: a small,
19
+ // low-emphasis, neutral-appearance button. Consumers can override any of
20
+ // these via OTPResendConfig.resendButtonModes.
21
+ const DEFAULT_RESEND_BUTTON_MODES = {
22
+ AppearanceBrand: 'Neutral',
23
+ 'Button / Size': 'S',
24
+ Emphasis: 'Low',
25
+ } as const
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // useOtpResend — headless state machine for the resend countdown.
29
+ //
30
+ // State machine: counting -> ready -> sending -> counting -> ...
31
+ //
32
+ // counting : timer is ticking down; resend button disabled.
33
+ // ready : timer elapsed; resend button enabled.
34
+ // sending : an in-flight onResend() promise is pending; button shows
35
+ // a transient "sending" label, prevents double-fire, and
36
+ // restarts the countdown only on resolve.
37
+ //
38
+ // Designed as a hook so consumers can render any UI they want and still
39
+ // reuse the timing/lifecycle logic. The OTPResend component below is the
40
+ // opinionated default.
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export type OtpResendState = 'counting' | 'ready' | 'sending'
44
+
45
+ export type UseOtpResendOptions = {
46
+ /** Countdown length in seconds. Defaults to 30. */
47
+ durationSeconds?: number | undefined
48
+ /** Called when the user requests a resend. May return a Promise; while pending, state is 'sending'. */
49
+ onResend?: (() => void | Promise<void>) | undefined
50
+ /** Whether the countdown should auto-start on mount. Defaults to true. */
51
+ autoStart?: boolean | undefined
52
+ }
53
+
54
+ export type UseOtpResendReturn = {
55
+ state: OtpResendState
56
+ /** Seconds remaining in the current countdown (0 when not counting). */
57
+ secondsLeft: number
58
+ /** True while state === 'ready'. */
59
+ canResend: boolean
60
+ /** True while state === 'sending'. */
61
+ isSending: boolean
62
+ /**
63
+ * Trigger a resend. No-op unless state === 'ready'. Awaits onResend()
64
+ * before restarting the countdown. Re-throws any onResend rejection
65
+ * so callers can surface the failure (state returns to 'ready').
66
+ */
67
+ resend: () => Promise<void>
68
+ /** Manually start (or restart) the countdown. */
69
+ restart: (durationSeconds?: number) => void
70
+ /** Stop the countdown and move to 'ready' (without invoking onResend). */
71
+ skip: () => void
72
+ }
73
+
74
+ /**
75
+ * Headless hook that drives an OTP resend countdown.
76
+ *
77
+ * The hook is intentionally UI-agnostic: it returns just enough state
78
+ * (`state`, `secondsLeft`, plus `resend()` / `restart()` / `skip()`) so
79
+ * consumers can render their own button, support text, etc. The bundled
80
+ * `OTPResend` component is the canonical UI on top of this hook.
81
+ */
82
+ export function useOtpResend({
83
+ durationSeconds = 30,
84
+ onResend,
85
+ autoStart = true,
86
+ }: UseOtpResendOptions = {}): UseOtpResendReturn {
87
+ // Initial state: if we're auto-starting, we begin in 'counting' with
88
+ // the full duration; otherwise we go straight to 'ready' (handy for
89
+ // flows where a previous screen already triggered the SMS and we
90
+ // mount with the timer already elapsed).
91
+ const [state, setState] = useState<OtpResendState>(autoStart ? 'counting' : 'ready')
92
+ const [secondsLeft, setSecondsLeft] = useState<number>(autoStart ? durationSeconds : 0)
93
+
94
+ // Keep a ref to the latest onResend so the resend() callback is stable
95
+ // even if consumers pass an inline arrow function on every render.
96
+ const onResendRef = useRef(onResend)
97
+ useEffect(() => {
98
+ onResendRef.current = onResend
99
+ }, [onResend])
100
+
101
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
102
+
103
+ const stopTicker = useCallback(() => {
104
+ if (intervalRef.current !== null) {
105
+ clearInterval(intervalRef.current)
106
+ intervalRef.current = null
107
+ }
108
+ }, [])
109
+
110
+ const startTicker = useCallback(
111
+ (seconds: number) => {
112
+ stopTicker()
113
+ const initial = Math.max(0, Math.floor(seconds))
114
+ if (initial === 0) {
115
+ setState('ready')
116
+ setSecondsLeft(0)
117
+ return
118
+ }
119
+ setState('counting')
120
+ setSecondsLeft(initial)
121
+ intervalRef.current = setInterval(() => {
122
+ setSecondsLeft((prev) => {
123
+ if (prev <= 1) {
124
+ stopTicker()
125
+ setState('ready')
126
+ return 0
127
+ }
128
+ return prev - 1
129
+ })
130
+ }, 1000)
131
+ },
132
+ [stopTicker],
133
+ )
134
+
135
+ // Auto-start once on mount if requested. We deliberately omit
136
+ // `durationSeconds` from the dependency array so changing the prop
137
+ // mid-flight does not silently reset the countdown — consumers should
138
+ // call `restart()` explicitly when they want a new duration.
139
+ useEffect(() => {
140
+ if (autoStart) startTicker(durationSeconds)
141
+ return stopTicker
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, [])
144
+
145
+ const resend = useCallback(async () => {
146
+ // Guard against double-fire and out-of-state taps (e.g. an
147
+ // accessibility re-tap while we're already in 'sending').
148
+ if (state !== 'ready') return
149
+ try {
150
+ setState('sending')
151
+ const result = onResendRef.current?.()
152
+ if (result && typeof (result as Promise<void>).then === 'function') {
153
+ await result
154
+ }
155
+ startTicker(durationSeconds)
156
+ } catch (e) {
157
+ // Surface the failure — the consumer can decide whether to
158
+ // show a toast, retry, etc. We move back to 'ready' so the
159
+ // user can try again immediately.
160
+ setState('ready')
161
+ throw e
162
+ }
163
+ }, [state, durationSeconds, startTicker])
164
+
165
+ const restart = useCallback(
166
+ (next?: number) => {
167
+ startTicker(next ?? durationSeconds)
168
+ },
169
+ [durationSeconds, startTicker],
170
+ )
171
+
172
+ const skip = useCallback(() => {
173
+ stopTicker()
174
+ setState('ready')
175
+ setSecondsLeft(0)
176
+ }, [stopTicker])
177
+
178
+ return {
179
+ state,
180
+ secondsLeft,
181
+ canResend: state === 'ready',
182
+ isSending: state === 'sending',
183
+ resend,
184
+ restart,
185
+ skip,
186
+ }
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // OTPResend — opinionated UI built on useOtpResend.
191
+ //
192
+ // Renders a SupportText line that shows "Resend OTP in {n}s" while the
193
+ // countdown is active, and turns into a tappable "Resend" button once the
194
+ // countdown elapses. Re-tapping is debounced via the hook's state machine.
195
+ //
196
+ // Exported as a standalone so consumers can position it anywhere in their
197
+ // layout (it does not need to live inside <OTP />). When passed via
198
+ // <OTP resend={...} />, OTP renders this internally in the support area.
199
+ // ---------------------------------------------------------------------------
200
+
201
+ export type OTPResendConfig = {
202
+ /** Countdown length in seconds. Defaults to 30. */
203
+ durationSeconds?: number
204
+ /** Called when the user taps the resend button. May return a Promise. */
205
+ onResend?: () => void | Promise<void>
206
+ /** Whether the countdown should auto-start on mount. Defaults to true. */
207
+ autoStart?: boolean
208
+ /** Format the countdown line. Defaults to (s) => `Resend OTP in ${s}s`. */
209
+ formatCountdown?: (secondsLeft: number) => string
210
+ /** Label shown in the 'sending' state. Defaults to "Sending…". */
211
+ sendingLabel?: string
212
+ /** Label shown when ready to resend. Defaults to "Resend". */
213
+ resendLabel?: string
214
+ /** SupportText status applied while counting. Defaults to "Loading" (clock icon). */
215
+ countdownStatus?: SupportTextProps['status']
216
+ /**
217
+ * Mode overrides for the resend Button (in the 'ready' state). Merged
218
+ * on top of the component default of `{ AppearanceBrand: 'Neutral',
219
+ * 'Button / Size': 'S', Emphasis: 'Low' }`. Use this to swap to a
220
+ * different brand/size/emphasis without touching the rest of the UI.
221
+ */
222
+ resendButtonModes?: Record<string, any>
223
+ }
224
+
225
+ export type OTPResendProps = OTPResendConfig & {
226
+ modes?: Record<string, any>
227
+ style?: StyleProp<ViewStyle>
228
+ }
229
+
230
+ const defaultFormatCountdown = (s: number) => `Resend OTP in ${s}s`
231
+
232
+ export function OTPResend({
233
+ durationSeconds = 30,
234
+ onResend,
235
+ autoStart = true,
236
+ formatCountdown,
237
+ sendingLabel = 'Sending…',
238
+ resendLabel = 'Resend',
239
+ countdownStatus = 'Loading',
240
+ resendButtonModes,
241
+ modes: propModes = EMPTY_MODES,
242
+ style,
243
+ }: OTPResendProps) {
244
+ const { modes: globalModes } = useTokens()
245
+ const modes = useMemo(() => ({ ...globalModes, ...propModes }), [globalModes, propModes])
246
+
247
+ // The Button gets the consumer's modes layered first, then the
248
+ // component-default brand/size/emphasis trio, and finally any caller
249
+ // overrides via `resendButtonModes`. Spreading in this order lets
250
+ // consumers (a) keep theming like Color Mode propagating through,
251
+ // (b) rely on the small/low/neutral defaults out of the box, and
252
+ // (c) selectively override e.g. `Button / Size: 'M'` without losing
253
+ // the other defaults.
254
+ const resolvedButtonModes = useMemo(
255
+ () => ({ ...modes, ...DEFAULT_RESEND_BUTTON_MODES, ...resendButtonModes }),
256
+ [modes, resendButtonModes],
257
+ )
258
+
259
+ const { state, secondsLeft, canResend, resend } = useOtpResend({
260
+ durationSeconds,
261
+ onResend,
262
+ autoStart,
263
+ })
264
+
265
+ const formatter = formatCountdown ?? defaultFormatCountdown
266
+
267
+ // counting → static SupportText. Not a Pressable: tapping the
268
+ // countdown should not do anything (it's an inert status line).
269
+ if (state === 'counting') {
270
+ return (
271
+ <SupportText
272
+ label={formatter(secondsLeft)}
273
+ status={countdownStatus}
274
+ modes={modes}
275
+ style={style}
276
+ />
277
+ )
278
+ }
279
+
280
+ // sending → static SupportText with a transient "Sending…" label so
281
+ // the user has clear feedback that their tap was received and we
282
+ // don't accept another tap until the resend completes.
283
+ if (state === 'sending') {
284
+ return (
285
+ <SupportText
286
+ label={sendingLabel}
287
+ status={countdownStatus}
288
+ modes={modes}
289
+ style={style}
290
+ />
291
+ )
292
+ }
293
+
294
+ // ready → render a real Button (no leading/trailing icon — design
295
+ // calls for a clean text-only pill once the countdown elapses). The
296
+ // Button handles its own pressed/hover states from tokens, so we
297
+ // don't need to layer extra opacity etc. on top.
298
+ return (
299
+ <Button
300
+ label={resendLabel}
301
+ modes={resolvedButtonModes}
302
+ disabled={!canResend}
303
+ onPress={() => {
304
+ // Swallow rejections here — the hook re-throws so callers
305
+ // wiring useOtpResend directly can react, but at the
306
+ // component boundary we never want an unhandled promise.
307
+ resend().catch(() => {})
308
+ }}
309
+ accessibilityLabel={resendLabel}
310
+ style={style}
311
+ />
312
+ )
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // OTP
317
+ // ---------------------------------------------------------------------------
318
+
17
319
  export type OTPProps = {
18
320
  /** Number of OTP digits. Defaults to 6. */
19
321
  length?: number
@@ -29,7 +331,7 @@ export type OTPProps = {
29
331
  onComplete?: (value: string) => void
30
332
  /** Whether the OTP input is disabled. */
31
333
  isDisabled?: boolean
32
- /** Whether the OTP input is in an invalid/error state. */
334
+ /** Whether the OTP input is in an invalid/error state. Drives underline colour and (optionally) the error message. */
33
335
  isInvalid?: boolean
34
336
  /** Regex pattern to filter allowed characters. Defaults to digits only. */
35
337
  allowedPattern?: RegExp
@@ -43,10 +345,38 @@ export type OTPProps = {
43
345
  supportText?: React.ReactNode
44
346
  /** SupportText status when using the string shorthand. */
45
347
  supportTextStatus?: SupportTextProps['status']
348
+ /**
349
+ * Convenience: shown beneath the slots **only** while `isInvalid` is true.
350
+ * Renders with status="Error" automatically. When omitted, the regular
351
+ * `supportText` is shown instead (with status promoted to Error).
352
+ */
353
+ errorMessage?: string
354
+ /**
355
+ * When provided, replaces the support area with a managed countdown that
356
+ * decays into a "Resend" button. Hidden while `isInvalid` is true so the
357
+ * error message can take precedence.
358
+ */
359
+ resend?: OTPResendConfig
360
+ /**
361
+ * Enable native one-time-code auto-fill on the underlying TextInput.
362
+ * On iOS this surfaces the SMS code in the QuickType bar above the
363
+ * keyboard (system-managed; no library required). On Android this sets
364
+ * `autoComplete="one-time-code"`, which the OS can auto-fill from
365
+ * notifications when the host app is wired up to the SMS Retriever or
366
+ * SMS User Consent APIs. Defaults to true.
367
+ */
368
+ enableSmsAutofill?: boolean
46
369
  }
47
370
 
48
371
  const DIGITS_ONLY = /^\d*$/
49
372
 
373
+ // How long the underline takes to fade in when a slot becomes
374
+ // "highlighted" (filled or actively focused), and to fade out when a slot
375
+ // reverts to idle (e.g. on backspace). The asymmetric durations make the
376
+ // "fades back" cue intentional without being sluggish.
377
+ const SLOT_FADE_IN_MS = 120
378
+ const SLOT_FADE_OUT_MS = 220
379
+
50
380
  function OTP({
51
381
  length = 6,
52
382
  value: controlledValue,
@@ -62,9 +392,12 @@ function OTP({
62
392
  style,
63
393
  supportText,
64
394
  supportTextStatus,
395
+ errorMessage,
396
+ resend,
397
+ enableSmsAutofill = true,
65
398
  }: OTPProps) {
66
399
  const { modes: globalModes } = useTokens()
67
- const modes = { ...globalModes, ...propModes }
400
+ const modes = useMemo(() => ({ ...globalModes, ...propModes }), [globalModes, propModes])
68
401
 
69
402
  const isControlled = controlledValue !== undefined
70
403
  const [internalValue, setInternalValue] = useState(defaultValue)
@@ -73,8 +406,8 @@ function OTP({
73
406
  const inputRef = useRef<RNTextInput>(null)
74
407
  const [isFocused, setIsFocused] = useState(false)
75
408
 
409
+ // --- Caret blink (unchanged) ---
76
410
  const caretAnim = useRef(new Animated.Value(1)).current
77
-
78
411
  useEffect(() => {
79
412
  if (!isFocused) return
80
413
  const blink = Animated.loop(
@@ -128,7 +461,6 @@ function OTP({
128
461
 
129
462
  const slotWidth = Number(getVariableByName('pinSlot/width', modes)) || 48
130
463
  const slotGap = Number(getVariableByName('pinSlot/gap', modes)) || 8
131
- // digit/color has no state variants in Figma — resolved once from the Output collection
132
464
  const digitColor = (getVariableByName('pinSlot/digit/color', modes) as string) || '#000000'
133
465
  const digitFontSize = Number(getVariableByName('pinSlot/digit/fontSize', modes)) || 24
134
466
  const digitFontFamily = (getVariableByName('pinSlot/digit/fontFamily', modes) as string) || 'JioType Var'
@@ -137,17 +469,82 @@ function OTP({
137
469
  const underlineHeight = Number(getVariableByName('pinSlot/underline/height', modes)) || 2
138
470
  const underlineRadius = Number(getVariableByName('pinSlot/underline/radius', modes)) || 1
139
471
 
140
- // --- State-driven slot modes ---
141
- // Collection name in Figma is "Input/PINSlot States" (double space before States).
142
- // Only PinSlot/underline/color (capital P/S) lives in this collection with Idle/Active/Error modes.
143
- // isInvalid takes priority over active focus; the component maps semantic state → token mode
144
- // internally so consumers never need to know the collection key name.
145
- const getSlotModes = (isActiveSlot: boolean): Record<string, any> => {
146
- if (isInvalid) return { ...modes, 'Input/PINSlot States': 'Error' }
147
- if (isActiveSlot && isFocused) return { ...modes, 'Input/PINSlot States': 'Active' }
148
- return { ...modes, 'Input/PINSlot States': 'Idle' }
472
+ // --- Resolve the three underline colors ONCE per render. We then
473
+ // animate between idle active per-slot using interpolation; the
474
+ // error color is applied directly (no animation) when isInvalid.
475
+ const idleUnderlineColor = useMemo(
476
+ () =>
477
+ (getVariableByName('PinSlot/underline/color', {
478
+ ...modes,
479
+ 'Input/PINSlot States': 'Idle',
480
+ }) as string) || '#303338',
481
+ [modes],
482
+ )
483
+ const activeUnderlineColor = useMemo(
484
+ () =>
485
+ (getVariableByName('PinSlot/underline/color', {
486
+ ...modes,
487
+ 'Input/PINSlot States': 'Active',
488
+ }) as string) || '#5d00b5',
489
+ [modes],
490
+ )
491
+ const errorUnderlineColor = useMemo(
492
+ () =>
493
+ (getVariableByName('PinSlot/underline/color', {
494
+ ...modes,
495
+ 'Input/PINSlot States': 'Error',
496
+ }) as string) || '#d92d20',
497
+ [modes],
498
+ )
499
+
500
+ // --- Per-slot underline highlight animations ---
501
+ //
502
+ // Each slot owns one Animated.Value in [0, 1]. 0 = Idle color, 1 =
503
+ // Active color. We re-target on every value/focus change:
504
+ //
505
+ // highlighted = isFilled || (isActiveSlot && isFocused)
506
+ //
507
+ // Filled slots stay lit, so adding a digit instantly recruits its
508
+ // slot into the lit cohort. Deleting a digit transitions that slot
509
+ // (which is no longer the active slot — the cursor moved back) from
510
+ // 1 → 0, producing the "fades back" cue the design calls for.
511
+ //
512
+ // We use useNativeDriver:false because backgroundColor cannot run on
513
+ // the native driver (it's a JS-thread layout property). The overhead
514
+ // is fine here: at most `length` (≤ ~8) values transitioning briefly
515
+ // on each keystroke.
516
+ const slotAnimsRef = useRef<Animated.Value[]>([])
517
+ if (slotAnimsRef.current.length !== length) {
518
+ const next: Animated.Value[] = []
519
+ for (let i = 0; i < length; i++) {
520
+ const existing = slotAnimsRef.current[i]
521
+ // Initialize fresh slots to match their *current* highlight
522
+ // target. This avoids a flash on first mount when consumers
523
+ // pass a non-empty defaultValue (slots would otherwise fade
524
+ // in from 0 even though they're already filled).
525
+ const initial = i < currentValue.length ? 1 : 0
526
+ next.push(existing ?? new Animated.Value(initial))
527
+ }
528
+ slotAnimsRef.current = next
149
529
  }
150
530
 
531
+ useEffect(() => {
532
+ const anims = slotAnimsRef.current
533
+ const filledLen = currentValue.length
534
+ for (let i = 0; i < length; i++) {
535
+ const slotAnim = anims[i]
536
+ if (!slotAnim) continue
537
+ const isFilled = i < filledLen
538
+ const isActiveSlot = i === filledLen && filledLen < length && isFocused
539
+ const target = isFilled || isActiveSlot ? 1 : 0
540
+ Animated.timing(slotAnim, {
541
+ toValue: target,
542
+ duration: target === 1 ? SLOT_FADE_IN_MS : SLOT_FADE_OUT_MS,
543
+ useNativeDriver: false,
544
+ }).start()
545
+ }
546
+ }, [currentValue, isFocused, length])
547
+
151
548
  // --- Styles ---
152
549
  const containerStyle: ViewStyle = {
153
550
  flexDirection: 'column',
@@ -169,12 +566,6 @@ function OTP({
169
566
  const isActiveSlot = index === currentValue.length && currentValue.length < length
170
567
  const isFilled = char !== undefined
171
568
 
172
- // Underline color is the only state-sensitive token (lives in "Input/PINSlot States" collection).
173
- // Note: token name is "PinSlot/underline/color" (capital P/S) — different from the static
174
- // "pinSlot/underline/color" in the Output collection.
175
- const slotModes = getSlotModes(isActiveSlot)
176
- const underlineColor = (getVariableByName('PinSlot/underline/color', slotModes) as string) || '#303338'
177
-
178
569
  const slotStyle: ViewStyle = {
179
570
  width: slotWidth,
180
571
  flexDirection: 'column',
@@ -193,12 +584,23 @@ function OTP({
193
584
  minWidth: '100%' as any,
194
585
  }
195
586
 
196
- const underlineStyle: ViewStyle = {
587
+ // Pull the per-slot animated value (always exists by this point —
588
+ // we resync the ref array above before render).
589
+ const slotAnim = slotAnimsRef.current[index] ?? new Animated.Value(0)
590
+ const interpolatedColor = slotAnim.interpolate({
591
+ inputRange: [0, 1],
592
+ outputRange: [idleUnderlineColor, activeUnderlineColor],
593
+ })
594
+
595
+ const underlineStyle = {
197
596
  width: slotWidth,
198
597
  height: underlineHeight,
199
598
  borderRadius: underlineRadius,
200
- backgroundColor: underlineColor,
201
- }
599
+ // Error state takes precedence and snaps directly to the
600
+ // error color — animating *to* an error feels less urgent
601
+ // than the snap, and the design uses an instant transition.
602
+ backgroundColor: isInvalid ? errorUnderlineColor : interpolatedColor,
603
+ } as Animated.WithAnimatedObject<ViewStyle>
202
604
 
203
605
  return (
204
606
  <View key={index} style={slotStyle}>
@@ -218,23 +620,60 @@ function OTP({
218
620
  <Text style={[digitStyle, { color: 'transparent' }]}>{'\u00A0'}</Text>
219
621
  )}
220
622
  </View>
221
- <View style={underlineStyle} />
623
+ <Animated.View style={underlineStyle} />
222
624
  </View>
223
625
  )
224
626
  }
225
627
 
226
- const renderSupportText = () => {
628
+ // --- Support area rendering ---
629
+ //
630
+ // Priority:
631
+ // 1. isInvalid → errorMessage (with Error status). Falls back to
632
+ // `supportText` if errorMessage isn't provided, promoting its
633
+ // status to Error.
634
+ // 2. resend (and !isInvalid) → managed countdown / button.
635
+ // 3. supportText → user's static support text.
636
+ // 4. nothing.
637
+ //
638
+ // This split keeps validation a parent concern (the component never
639
+ // tries to "know" what valid means) while still giving consumers a
640
+ // turnkey error UI when they flip `isInvalid`.
641
+ //
642
+ // While `isInvalid` is true we also inject `Status: 'Error'` into the
643
+ // mode set forwarded to the support area. This lets the SupportText
644
+ // (and any nested icon) resolve error-themed Figma tokens — foreground
645
+ // color, icon color, etc. — without consumers having to thread the
646
+ // collection mode in by hand.
647
+ const supportModes = useMemo(
648
+ () => (isInvalid ? { ...modes, Status: 'Error' } : modes),
649
+ [modes, isInvalid],
650
+ )
651
+
652
+ const renderStaticSupportText = (overrideStatus?: SupportTextProps['status']) => {
227
653
  if (!supportText) return null
228
654
  if (typeof supportText === 'string') {
229
655
  return (
230
656
  <SupportText
231
657
  label={supportText}
232
- status={supportTextStatus ?? (isInvalid ? 'Error' : 'Neutral')}
233
- modes={modes}
658
+ status={overrideStatus ?? supportTextStatus ?? 'Neutral'}
659
+ modes={supportModes}
234
660
  />
235
661
  )
236
662
  }
237
- return <>{cloneChildrenWithModes(React.Children.toArray(supportText), modes)}</>
663
+ return <>{cloneChildrenWithModes(React.Children.toArray(supportText), supportModes)}</>
664
+ }
665
+
666
+ const renderSupportArea = () => {
667
+ if (isInvalid) {
668
+ if (errorMessage) {
669
+ return <SupportText label={errorMessage} status="Error" modes={supportModes} />
670
+ }
671
+ return renderStaticSupportText('Error')
672
+ }
673
+ if (resend) {
674
+ return <OTPResend {...resend} modes={supportModes} />
675
+ }
676
+ return renderStaticSupportText()
238
677
  }
239
678
 
240
679
  return (
@@ -255,6 +694,14 @@ function OTP({
255
694
  onFocus={() => setIsFocused(true)}
256
695
  onBlur={() => setIsFocused(false)}
257
696
  caretHidden
697
+ // Cross-platform native one-time-code autofill. iOS reads
698
+ // `textContentType="oneTimeCode"` to surface SMS codes in
699
+ // the QuickType bar (no library needed). Android reads
700
+ // `autoComplete="one-time-code"` (the canonical RN value;
701
+ // also accepted as a hint by the SMS Retriever / SMS User
702
+ // Consent APIs that the host app wires up natively).
703
+ textContentType={enableSmsAutofill ? 'oneTimeCode' : 'none'}
704
+ autoComplete={enableSmsAutofill ? 'one-time-code' : 'off'}
258
705
  style={{
259
706
  position: 'absolute',
260
707
  width: 1,
@@ -267,7 +714,7 @@ function OTP({
267
714
  <View style={slotWrapStyle}>
268
715
  {Array.from({ length }, (_, i) => renderSlot(i))}
269
716
  </View>
270
- {renderSupportText()}
717
+ {renderSupportArea()}
271
718
  </Pressable>
272
719
  )
273
720
  }