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.
- package/CHANGELOG.md +60 -0
- package/lib/commonjs/components/AccordionCheckbox/AccordionCheckbox.js +239 -0
- package/lib/commonjs/components/BrandChip/BrandChip.js +149 -0
- package/lib/commonjs/components/CardAdvisory/CardAdvisory.js +2 -2
- package/lib/commonjs/components/CardBankAccount/CardBankAccount.js +213 -0
- package/lib/commonjs/components/CardFinancialCondition/CardFinancialCondition.js +213 -0
- package/lib/commonjs/components/CardInsight/CardInsight.js +166 -0
- package/lib/commonjs/components/Carousel/Carousel.js +9 -7
- package/lib/commonjs/components/CheckboxGroup/CheckboxGroup.js +67 -0
- package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +125 -0
- package/lib/commonjs/components/CircularProgressBar/CircularProgressBar.js +56 -9
- package/lib/commonjs/components/CoverageBarComparison/CoverageBarComparison.js +272 -0
- package/lib/commonjs/components/CoverageRing/CoverageRing.js +141 -0
- package/lib/commonjs/components/DonutChart/DonutChart.js +309 -0
- package/lib/commonjs/components/DonutChartSummary/DonutChartSummary.js +155 -0
- package/lib/commonjs/components/HoldingsCard/HoldingsCard.js +2 -2
- package/lib/commonjs/components/InstitutionBadge/InstitutionBadge.js +132 -0
- package/lib/commonjs/components/LinearMeter/LinearMeter.js +9 -28
- package/lib/commonjs/components/LinearProgress/LinearProgress.js +68 -0
- package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +95 -0
- package/lib/commonjs/components/MonthlyStatusGrid/MonthlyStatusGrid.js +286 -0
- package/lib/commonjs/components/OTP/OTP.js +381 -37
- package/lib/commonjs/components/ProductOverview/ProductOverview.js +147 -0
- package/lib/commonjs/components/Radio/Radio.js +194 -0
- package/lib/commonjs/components/RadioButton/RadioButton.js +21 -188
- package/lib/commonjs/components/RangeTrack/RangeTrack.js +269 -0
- package/lib/commonjs/components/SavingsGoalSummary/SavingsGoalSummary.js +181 -0
- package/lib/commonjs/components/SegmentedTrack/SegmentedTrack.js +171 -0
- package/lib/commonjs/components/StatGroup/StatGroup.js +128 -0
- package/lib/commonjs/components/StatItem/StatItem.js +65 -35
- package/lib/commonjs/components/StrengthIndicator/StrengthIndicator.js +157 -0
- package/lib/commonjs/components/SummaryTile/SummaryTile.js +150 -0
- package/lib/commonjs/components/index.js +192 -1
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/commonjs/utils/index.js +7 -0
- package/lib/commonjs/utils/number-utils.js +57 -0
- package/lib/module/components/AccordionCheckbox/AccordionCheckbox.js +233 -0
- package/lib/module/components/BrandChip/BrandChip.js +143 -0
- package/lib/module/components/CardAdvisory/CardAdvisory.js +2 -2
- package/lib/module/components/CardBankAccount/CardBankAccount.js +208 -0
- package/lib/module/components/CardFinancialCondition/CardFinancialCondition.js +207 -0
- package/lib/module/components/CardInsight/CardInsight.js +161 -0
- package/lib/module/components/Carousel/Carousel.js +9 -7
- package/lib/module/components/CheckboxGroup/CheckboxGroup.js +62 -0
- package/lib/module/components/CheckboxItem/CheckboxItem.js +119 -0
- package/lib/module/components/CircularProgressBar/CircularProgressBar.js +56 -9
- package/lib/module/components/CoverageBarComparison/CoverageBarComparison.js +266 -0
- package/lib/module/components/CoverageRing/CoverageRing.js +136 -0
- package/lib/module/components/DonutChart/DonutChart.js +303 -0
- package/lib/module/components/DonutChartSummary/DonutChartSummary.js +150 -0
- package/lib/module/components/HoldingsCard/HoldingsCard.js +2 -2
- package/lib/module/components/InstitutionBadge/InstitutionBadge.js +127 -0
- package/lib/module/components/LinearMeter/LinearMeter.js +9 -28
- package/lib/module/components/LinearProgress/LinearProgress.js +63 -0
- package/lib/module/components/MetricLegendItem/MetricLegendItem.js +90 -0
- package/lib/module/components/MonthlyStatusGrid/MonthlyStatusGrid.js +281 -0
- package/lib/module/components/OTP/OTP.js +381 -38
- package/lib/module/components/ProductOverview/ProductOverview.js +142 -0
- package/lib/module/components/Radio/Radio.js +188 -0
- package/lib/module/components/RadioButton/RadioButton.js +20 -185
- package/lib/module/components/RangeTrack/RangeTrack.js +263 -0
- package/lib/module/components/SavingsGoalSummary/SavingsGoalSummary.js +175 -0
- package/lib/module/components/SegmentedTrack/SegmentedTrack.js +166 -0
- package/lib/module/components/StatGroup/StatGroup.js +123 -0
- package/lib/module/components/StatItem/StatItem.js +66 -36
- package/lib/module/components/StrengthIndicator/StrengthIndicator.js +152 -0
- package/lib/module/components/SummaryTile/SummaryTile.js +145 -0
- package/lib/module/components/index.js +28 -1
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/registry.js +1 -1
- package/lib/module/utils/index.js +2 -1
- package/lib/module/utils/number-utils.js +53 -0
- package/lib/typescript/src/components/AccordionCheckbox/AccordionCheckbox.d.ts +71 -0
- package/lib/typescript/src/components/BrandChip/BrandChip.d.ts +43 -0
- package/lib/typescript/src/components/CardBankAccount/CardBankAccount.d.ts +79 -0
- package/lib/typescript/src/components/CardFinancialCondition/CardFinancialCondition.d.ts +50 -0
- package/lib/typescript/src/components/CardInsight/CardInsight.d.ts +48 -0
- package/lib/typescript/src/components/CheckboxGroup/CheckboxGroup.d.ts +41 -0
- package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +56 -0
- package/lib/typescript/src/components/CircularProgressBar/CircularProgressBar.d.ts +11 -1
- package/lib/typescript/src/components/CoverageBarComparison/CoverageBarComparison.d.ts +105 -0
- package/lib/typescript/src/components/CoverageRing/CoverageRing.d.ts +90 -0
- package/lib/typescript/src/components/DonutChart/DonutChart.d.ts +117 -0
- package/lib/typescript/src/components/DonutChartSummary/DonutChartSummary.d.ts +103 -0
- package/lib/typescript/src/components/InstitutionBadge/InstitutionBadge.d.ts +30 -0
- package/lib/typescript/src/components/LinearProgress/LinearProgress.d.ts +17 -0
- package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +37 -0
- package/lib/typescript/src/components/MonthlyStatusGrid/MonthlyStatusGrid.d.ts +119 -0
- package/lib/typescript/src/components/OTP/OTP.d.ts +88 -2
- package/lib/typescript/src/components/ProductOverview/ProductOverview.d.ts +39 -0
- package/lib/typescript/src/components/Radio/Radio.d.ts +30 -0
- package/lib/typescript/src/components/RadioButton/RadioButton.d.ts +20 -28
- package/lib/typescript/src/components/RangeTrack/RangeTrack.d.ts +173 -0
- package/lib/typescript/src/components/SavingsGoalSummary/SavingsGoalSummary.d.ts +95 -0
- package/lib/typescript/src/components/SegmentedTrack/SegmentedTrack.d.ts +108 -0
- package/lib/typescript/src/components/StatGroup/StatGroup.d.ts +45 -0
- package/lib/typescript/src/components/StatItem/StatItem.d.ts +24 -7
- package/lib/typescript/src/components/StrengthIndicator/StrengthIndicator.d.ts +58 -0
- package/lib/typescript/src/components/SummaryTile/SummaryTile.d.ts +60 -0
- package/lib/typescript/src/components/index.d.ts +29 -2
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/lib/typescript/src/utils/index.d.ts +1 -0
- package/lib/typescript/src/utils/number-utils.d.ts +29 -0
- package/package.json +1 -1
- package/src/components/AccordionCheckbox/AccordionCheckbox.tsx +323 -0
- package/src/components/BrandChip/BrandChip.tsx +235 -0
- package/src/components/CardAdvisory/CardAdvisory.tsx +2 -2
- package/src/components/CardBankAccount/CardBankAccount.tsx +295 -0
- package/src/components/CardFinancialCondition/CardFinancialCondition.tsx +366 -0
- package/src/components/CardInsight/CardInsight.tsx +239 -0
- package/src/components/Carousel/Carousel.tsx +14 -6
- package/src/components/CheckboxGroup/CheckboxGroup.tsx +86 -0
- package/src/components/CheckboxItem/CheckboxItem.tsx +174 -0
- package/src/components/CircularProgressBar/CircularProgressBar.tsx +74 -9
- package/src/components/CoverageBarComparison/CoverageBarComparison.tsx +378 -0
- package/src/components/CoverageRing/CoverageRing.tsx +225 -0
- package/src/components/DonutChart/DonutChart.tsx +503 -0
- package/src/components/DonutChartSummary/DonutChartSummary.tsx +256 -0
- package/src/components/HoldingsCard/HoldingsCard.tsx +2 -2
- package/src/components/InstitutionBadge/InstitutionBadge.tsx +216 -0
- package/src/components/LinearMeter/LinearMeter.tsx +9 -39
- package/src/components/LinearProgress/LinearProgress.tsx +92 -0
- package/src/components/MetricLegendItem/MetricLegendItem.tsx +167 -0
- package/src/components/MonthlyStatusGrid/MonthlyStatusGrid.tsx +438 -0
- package/src/components/OTP/OTP.tsx +476 -29
- package/src/components/ProductOverview/ProductOverview.tsx +236 -0
- package/src/components/Radio/Radio.tsx +227 -0
- package/src/components/RadioButton/RadioButton.tsx +23 -225
- package/src/components/RangeTrack/RangeTrack.tsx +394 -0
- package/src/components/SavingsGoalSummary/SavingsGoalSummary.tsx +269 -0
- package/src/components/SegmentedTrack/SegmentedTrack.tsx +268 -0
- package/src/components/StatGroup/StatGroup.tsx +169 -0
- package/src/components/StatItem/StatItem.tsx +117 -40
- package/src/components/StrengthIndicator/StrengthIndicator.tsx +205 -0
- package/src/components/SummaryTile/SummaryTile.tsx +251 -0
- package/src/components/index.ts +39 -2
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/icons/registry.ts +1 -1
- package/src/utils/index.ts +1 -0
- 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
|
-
// ---
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
233
|
-
modes={
|
|
658
|
+
status={overrideStatus ?? supportTextStatus ?? 'Neutral'}
|
|
659
|
+
modes={supportModes}
|
|
234
660
|
/>
|
|
235
661
|
)
|
|
236
662
|
}
|
|
237
|
-
return <>{cloneChildrenWithModes(React.Children.toArray(supportText),
|
|
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
|
-
{
|
|
717
|
+
{renderSupportArea()}
|
|
271
718
|
</Pressable>
|
|
272
719
|
)
|
|
273
720
|
}
|