react-native-ai-animations 0.0.0-stage → 0.8.1

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 (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +226 -2
  3. package/lib/module/brand/index.js +4 -0
  4. package/lib/module/brand/index.js.map +1 -0
  5. package/lib/module/brand/spinning-mark.js +96 -0
  6. package/lib/module/brand/spinning-mark.js.map +1 -0
  7. package/lib/module/chat/effort-bar.js +853 -0
  8. package/lib/module/chat/effort-bar.js.map +1 -0
  9. package/lib/module/chat/index.js +8 -0
  10. package/lib/module/chat/index.js.map +1 -0
  11. package/lib/module/chat/question-card.js +720 -0
  12. package/lib/module/chat/question-card.js.map +1 -0
  13. package/lib/module/chat/reasoning.js +38 -0
  14. package/lib/module/chat/reasoning.js.map +1 -0
  15. package/lib/module/chat/status-row.js +140 -0
  16. package/lib/module/chat/status-row.js.map +1 -0
  17. package/lib/module/chat/thinking-block.js +255 -0
  18. package/lib/module/chat/thinking-block.js.map +1 -0
  19. package/lib/module/index.js +5 -0
  20. package/lib/module/index.js.map +1 -0
  21. package/lib/module/internal/color.js +15 -0
  22. package/lib/module/internal/color.js.map +1 -0
  23. package/lib/module/internal/use-elapsed.js +20 -0
  24. package/lib/module/internal/use-elapsed.js.map +1 -0
  25. package/lib/module/package.json +1 -0
  26. package/lib/module/skia/dot-orb.js +256 -0
  27. package/lib/module/skia/dot-orb.js.map +1 -0
  28. package/lib/module/skia/index.js +8 -0
  29. package/lib/module/skia/index.js.map +1 -0
  30. package/lib/module/skia/liquid-orb.js +303 -0
  31. package/lib/module/skia/liquid-orb.js.map +1 -0
  32. package/lib/module/skia/matrix-orb.js +272 -0
  33. package/lib/module/skia/matrix-orb.js.map +1 -0
  34. package/lib/module/skia/media-generating.js +524 -0
  35. package/lib/module/skia/media-generating.js.map +1 -0
  36. package/lib/module/skia/voice-wave.js +278 -0
  37. package/lib/module/skia/voice-wave.js.map +1 -0
  38. package/lib/module/text/index.js +6 -0
  39. package/lib/module/text/index.js.map +1 -0
  40. package/lib/module/text/shimmer-text.js +122 -0
  41. package/lib/module/text/shimmer-text.js.map +1 -0
  42. package/lib/module/text/spoken-text.js +85 -0
  43. package/lib/module/text/spoken-text.js.map +1 -0
  44. package/lib/module/text/streaming-text.js +135 -0
  45. package/lib/module/text/streaming-text.js.map +1 -0
  46. package/lib/module/theme/index.js +5 -0
  47. package/lib/module/theme/index.js.map +1 -0
  48. package/lib/module/theme/palette.js +45 -0
  49. package/lib/module/theme/palette.js.map +1 -0
  50. package/lib/module/theme/provider.js +65 -0
  51. package/lib/module/theme/provider.js.map +1 -0
  52. package/lib/typescript/package.json +1 -0
  53. package/lib/typescript/src/brand/index.d.ts +2 -0
  54. package/lib/typescript/src/brand/index.d.ts.map +1 -0
  55. package/lib/typescript/src/brand/spinning-mark.d.ts +21 -0
  56. package/lib/typescript/src/brand/spinning-mark.d.ts.map +1 -0
  57. package/lib/typescript/src/chat/effort-bar.d.ts +42 -0
  58. package/lib/typescript/src/chat/effort-bar.d.ts.map +1 -0
  59. package/lib/typescript/src/chat/index.d.ts +6 -0
  60. package/lib/typescript/src/chat/index.d.ts.map +1 -0
  61. package/lib/typescript/src/chat/question-card.d.ts +73 -0
  62. package/lib/typescript/src/chat/question-card.d.ts.map +1 -0
  63. package/lib/typescript/src/chat/reasoning.d.ts +20 -0
  64. package/lib/typescript/src/chat/reasoning.d.ts.map +1 -0
  65. package/lib/typescript/src/chat/status-row.d.ts +20 -0
  66. package/lib/typescript/src/chat/status-row.d.ts.map +1 -0
  67. package/lib/typescript/src/chat/thinking-block.d.ts +35 -0
  68. package/lib/typescript/src/chat/thinking-block.d.ts.map +1 -0
  69. package/lib/typescript/src/index.d.ts +3 -0
  70. package/lib/typescript/src/index.d.ts.map +1 -0
  71. package/lib/typescript/src/internal/color.d.ts +2 -0
  72. package/lib/typescript/src/internal/color.d.ts.map +1 -0
  73. package/lib/typescript/src/internal/use-elapsed.d.ts +3 -0
  74. package/lib/typescript/src/internal/use-elapsed.d.ts.map +1 -0
  75. package/lib/typescript/src/skia/dot-orb.d.ts +16 -0
  76. package/lib/typescript/src/skia/dot-orb.d.ts.map +1 -0
  77. package/lib/typescript/src/skia/index.d.ts +6 -0
  78. package/lib/typescript/src/skia/index.d.ts.map +1 -0
  79. package/lib/typescript/src/skia/liquid-orb.d.ts +20 -0
  80. package/lib/typescript/src/skia/liquid-orb.d.ts.map +1 -0
  81. package/lib/typescript/src/skia/matrix-orb.d.ts +12 -0
  82. package/lib/typescript/src/skia/matrix-orb.d.ts.map +1 -0
  83. package/lib/typescript/src/skia/media-generating.d.ts +59 -0
  84. package/lib/typescript/src/skia/media-generating.d.ts.map +1 -0
  85. package/lib/typescript/src/skia/voice-wave.d.ts +17 -0
  86. package/lib/typescript/src/skia/voice-wave.d.ts.map +1 -0
  87. package/lib/typescript/src/text/index.d.ts +4 -0
  88. package/lib/typescript/src/text/index.d.ts.map +1 -0
  89. package/lib/typescript/src/text/shimmer-text.d.ts +13 -0
  90. package/lib/typescript/src/text/shimmer-text.d.ts.map +1 -0
  91. package/lib/typescript/src/text/spoken-text.d.ts +15 -0
  92. package/lib/typescript/src/text/spoken-text.d.ts.map +1 -0
  93. package/lib/typescript/src/text/streaming-text.d.ts +16 -0
  94. package/lib/typescript/src/text/streaming-text.d.ts.map +1 -0
  95. package/lib/typescript/src/theme/index.d.ts +3 -0
  96. package/lib/typescript/src/theme/index.d.ts.map +1 -0
  97. package/lib/typescript/src/theme/palette.d.ts +33 -0
  98. package/lib/typescript/src/theme/palette.d.ts.map +1 -0
  99. package/lib/typescript/src/theme/provider.d.ts +13 -0
  100. package/lib/typescript/src/theme/provider.d.ts.map +1 -0
  101. package/package.json +133 -4
  102. package/src/brand/index.ts +5 -0
  103. package/src/brand/spinning-mark.tsx +118 -0
  104. package/src/chat/effort-bar.tsx +748 -0
  105. package/src/chat/index.ts +45 -0
  106. package/src/chat/question-card.tsx +649 -0
  107. package/src/chat/reasoning.ts +30 -0
  108. package/src/chat/status-row.tsx +127 -0
  109. package/src/chat/thinking-block.tsx +250 -0
  110. package/src/index.ts +2 -0
  111. package/src/internal/color.ts +18 -0
  112. package/src/internal/use-elapsed.ts +21 -0
  113. package/src/skia/dot-orb.tsx +248 -0
  114. package/src/skia/index.ts +34 -0
  115. package/src/skia/liquid-orb.tsx +274 -0
  116. package/src/skia/matrix-orb.tsx +337 -0
  117. package/src/skia/media-generating.tsx +389 -0
  118. package/src/skia/voice-wave.tsx +215 -0
  119. package/src/text/index.ts +3 -0
  120. package/src/text/shimmer-text.tsx +140 -0
  121. package/src/text/spoken-text.tsx +82 -0
  122. package/src/text/streaming-text.tsx +144 -0
  123. package/src/theme/index.ts +11 -0
  124. package/src/theme/palette.ts +62 -0
  125. package/src/theme/provider.tsx +54 -0
@@ -0,0 +1,45 @@
1
+ export {
2
+ StatusRow,
3
+ type StatusRowIconMotion,
4
+ type StatusRowProps,
5
+ } from "./status-row";
6
+ export {
7
+ ThinkingBlock,
8
+ type ThinkingBlockLabels,
9
+ type ThinkingBlockProps,
10
+ type ThinkingStep,
11
+ type ThinkingStepStatus,
12
+ } from "./thinking-block";
13
+ export {
14
+ QuestionCard,
15
+ QuestionSheet,
16
+ type Question,
17
+ type QuestionAnswer,
18
+ type QuestionCardIcons,
19
+ type QuestionCardLabels,
20
+ type QuestionCardHandle,
21
+ type QuestionCardProps,
22
+ type QuestionOption,
23
+ type QuestionOptionState,
24
+ } from "./question-card";
25
+ export {
26
+ EffortBar,
27
+ EffortBars,
28
+ EffortSegmented,
29
+ EffortSlider,
30
+ EffortTrack,
31
+ type EffortBarProps,
32
+ type EffortBarVariant,
33
+ type EffortLevel,
34
+ type EffortSelection,
35
+ type EffortStyleProps,
36
+ } from "./effort-bar";
37
+ export {
38
+ DEFAULT_REASONING_EFFORT,
39
+ REASONING_EFFORTS,
40
+ reasoningBudget,
41
+ reasoningIndex,
42
+ reasoningRequest,
43
+ type ReasoningEffort,
44
+ type ReasoningLevel,
45
+ } from "./reasoning";
@@ -0,0 +1,649 @@
1
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import {
3
+ type LayoutChangeEvent,
4
+ Pressable,
5
+ Text,
6
+ TextInput,
7
+ View,
8
+ type StyleProp,
9
+ type TextStyle,
10
+ type ViewStyle,
11
+ } from "react-native";
12
+ import Animated, {
13
+ Easing,
14
+ FadeIn,
15
+ FadeOut,
16
+ interpolateColor,
17
+ useAnimatedStyle,
18
+ useReducedMotion,
19
+ useSharedValue,
20
+ withSequence,
21
+ withSpring,
22
+ withTiming,
23
+ ZoomIn,
24
+ ZoomOut,
25
+ } from "react-native-reanimated";
26
+ import { scheduleOnRN } from "react-native-worklets";
27
+ import { withAlpha } from "../internal/color";
28
+ import { type MotionPalette } from "../theme/palette";
29
+ import { useMotionPalette } from "../theme/provider";
30
+
31
+ const EASE_OUT = Easing.bezier(0.22, 1, 0.36, 1);
32
+ const ENTER_SPRING = { damping: 24, stiffness: 220, mass: 0.9 };
33
+ const HEIGHT_SPRING = { damping: 28, stiffness: 260, mass: 0.9 };
34
+ const CHIP_SPRING = { damping: 14, stiffness: 420, mass: 0.6 };
35
+ const PAGE_OUT = 110;
36
+ const PAGE_IN = 240;
37
+ const PAGE_SHIFT = 12;
38
+ const ADVANCE_DELAY = 260;
39
+ const CHIP = 26;
40
+
41
+ const DEFAULT_TITLE_STYLE: TextStyle = { fontSize: 19, lineHeight: 25, fontWeight: "500" };
42
+ const DEFAULT_OPTION_STYLE: TextStyle = { fontSize: 16, lineHeight: 22 };
43
+ const DEFAULT_META_STYLE: TextStyle = { fontSize: 13, lineHeight: 18, fontWeight: "500" };
44
+
45
+ export type QuestionOption = string | { label: string; description?: string };
46
+
47
+ export type Question = {
48
+ key?: string;
49
+ title: string;
50
+ kind?: "single" | "multiple";
51
+ options: QuestionOption[];
52
+ allowCustom?: boolean;
53
+ };
54
+
55
+ export type QuestionAnswer = {
56
+ question: string;
57
+ selected: number[];
58
+ labels: string[];
59
+ custom?: string;
60
+ };
61
+
62
+ export type QuestionCardLabels = {
63
+ progress: (current: number, total: number) => string;
64
+ next: string;
65
+ submit: string;
66
+ customPlaceholder: string;
67
+ sent: string;
68
+ };
69
+
70
+ export type QuestionCardIcons = {
71
+ previous?: ReactNode;
72
+ next?: ReactNode;
73
+ close?: ReactNode;
74
+ check?: ReactNode;
75
+ custom?: ReactNode;
76
+ send?: ReactNode;
77
+ sent?: ReactNode;
78
+ };
79
+
80
+ export type QuestionOptionState = { index: number; selected: boolean; kind: "single" | "multiple" };
81
+
82
+ export type QuestionCardHandle = {
83
+ select: (index: number) => void;
84
+ goTo: (page: number) => void;
85
+ submit: () => void;
86
+ };
87
+
88
+ export type QuestionCardProps = {
89
+ questions: Question[];
90
+ variant?: "card" | "sheet";
91
+ labels?: Partial<QuestionCardLabels>;
92
+ icons?: QuestionCardIcons;
93
+ numbered?: boolean;
94
+ autoAdvance?: boolean;
95
+ autoSubmit?: boolean;
96
+ showSentState?: boolean;
97
+ onSubmit: (answers: QuestionAnswer[]) => void;
98
+ onClose?: () => void;
99
+ onAnswerChange?: (answers: QuestionAnswer[]) => void;
100
+ onHaptic?: (kind: "select" | "page" | "submit") => void;
101
+ renderOption?: (option: { label: string; description?: string }, state: QuestionOptionState) => ReactNode;
102
+ fontFamily?: string;
103
+ titleStyle?: StyleProp<TextStyle>;
104
+ optionStyle?: StyleProp<TextStyle>;
105
+ metaStyle?: StyleProp<TextStyle>;
106
+ colors?: Partial<MotionPalette>;
107
+ style?: StyleProp<ViewStyle>;
108
+ };
109
+
110
+ const DEFAULT_LABELS: QuestionCardLabels = {
111
+ progress: (current, total) => `${current} of ${total}`,
112
+ next: "Continue",
113
+ submit: "Send",
114
+ customPlaceholder: "Type your answer...",
115
+ sent: "Answers sent",
116
+ };
117
+
118
+ type Draft = { selected: number[]; custom: string };
119
+
120
+ const emptyDraft = (): Draft => ({ selected: [], custom: "" });
121
+
122
+ function normalize(option: QuestionOption) {
123
+ return typeof option === "string" ? { label: option } : option;
124
+ }
125
+
126
+ function isAnswered(draft: Draft | undefined) {
127
+ return !!draft && (draft.selected.length > 0 || draft.custom.trim().length > 0);
128
+ }
129
+
130
+ function buildAnswers(questions: Question[], drafts: Draft[]): QuestionAnswer[] {
131
+ return questions.map((question, index) => {
132
+ const draft = drafts[index] ?? emptyDraft();
133
+ const options = question.options.map(normalize);
134
+ const custom = draft.custom.trim();
135
+ return {
136
+ question: question.title,
137
+ selected: draft.selected,
138
+ labels: draft.selected.map((position) => options[position]?.label ?? ""),
139
+ ...(custom ? { custom } : {}),
140
+ };
141
+ });
142
+ }
143
+
144
+ export const QuestionCard = forwardRef<QuestionCardHandle, QuestionCardProps>(function QuestionCard({
145
+ questions,
146
+ variant = "card",
147
+ labels,
148
+ icons,
149
+ numbered = true,
150
+ autoAdvance = true,
151
+ autoSubmit = true,
152
+ showSentState = true,
153
+ onSubmit,
154
+ onClose,
155
+ onAnswerChange,
156
+ onHaptic,
157
+ renderOption,
158
+ fontFamily,
159
+ titleStyle: titleStyleProp = DEFAULT_TITLE_STYLE,
160
+ optionStyle: optionStyleProp = DEFAULT_OPTION_STYLE,
161
+ metaStyle: metaStyleProp = DEFAULT_META_STYLE,
162
+ colors,
163
+ style,
164
+ }, ref) {
165
+ const font = fontFamily ? { fontFamily } : null;
166
+ const titleStyle = [titleStyleProp, font];
167
+ const optionStyle = [optionStyleProp, font];
168
+ const metaStyle = [metaStyleProp, font];
169
+ const palette = useMotionPalette(colors);
170
+ const reduceMotion = useReducedMotion();
171
+ const copy = { ...DEFAULT_LABELS, ...labels };
172
+ const [page, setPage] = useState(0);
173
+ const [shown, setShown] = useState(0);
174
+ const [drafts, setDrafts] = useState<Draft[]>(() => questions.map(emptyDraft));
175
+ const [sent, setSent] = useState(false);
176
+ const draftsRef = useRef(drafts);
177
+ draftsRef.current = drafts;
178
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
179
+
180
+ const enter = useSharedValue(reduceMotion ? 1 : 0);
181
+ const contentOpacity = useSharedValue(1);
182
+ const contentShift = useSharedValue(0);
183
+ const height = useSharedValue(0);
184
+ const measured = useRef(0);
185
+
186
+ useEffect(() => {
187
+ enter.value = reduceMotion ? 1 : withSpring(1, ENTER_SPRING);
188
+ }, [enter, reduceMotion]);
189
+
190
+ useEffect(
191
+ () => () => {
192
+ if (timer.current) clearTimeout(timer.current);
193
+ },
194
+ [],
195
+ );
196
+
197
+ const signature = questions.map((item) => `${item.title}:${item.options.length}`).join("|");
198
+ const lastSignature = useRef(signature);
199
+ useEffect(() => {
200
+ if (lastSignature.current === signature) return;
201
+ lastSignature.current = signature;
202
+ setPage(0);
203
+ setShown(0);
204
+ setSent(false);
205
+ setDrafts(questions.map(emptyDraft));
206
+ }, [questions, signature]);
207
+
208
+ useEffect(() => {
209
+ if (page === shown) return;
210
+ const direction = page > shown ? 1 : -1;
211
+ if (reduceMotion) {
212
+ setShown(page);
213
+ return;
214
+ }
215
+ contentShift.value = withTiming(-direction * PAGE_SHIFT, { duration: PAGE_OUT, easing: Easing.in(Easing.quad) });
216
+ contentOpacity.value = withTiming(0, { duration: PAGE_OUT, easing: Easing.in(Easing.quad) }, (finished) => {
217
+ if (finished) scheduleOnRN(setShown, page);
218
+ });
219
+ }, [contentOpacity, contentShift, page, reduceMotion, shown]);
220
+
221
+ const firstShown = useRef(true);
222
+ useEffect(() => {
223
+ if (firstShown.current) {
224
+ firstShown.current = false;
225
+ return;
226
+ }
227
+ if (reduceMotion) return;
228
+ contentShift.value = contentShift.value < 0 ? PAGE_SHIFT : -PAGE_SHIFT;
229
+ contentShift.value = withTiming(0, { duration: PAGE_IN, easing: EASE_OUT });
230
+ contentOpacity.value = withTiming(1, { duration: PAGE_IN, easing: EASE_OUT });
231
+ }, [contentOpacity, contentShift, reduceMotion, shown]);
232
+
233
+ const question = questions[shown];
234
+ const kind = question?.kind ?? "single";
235
+ const draft = drafts[shown] ?? emptyDraft();
236
+ const options = useMemo(() => question?.options.map(normalize) ?? [], [question]);
237
+ const last = shown === questions.length - 1;
238
+ const hairline = withAlpha(palette.text, 0.08);
239
+
240
+ const commit = (next: Draft[]) => {
241
+ draftsRef.current = next;
242
+ setDrafts(next);
243
+ onAnswerChange?.(buildAnswers(questions, next));
244
+ };
245
+
246
+ const goTo = (target: number) => {
247
+ if (target < 0 || target >= questions.length || target === page) return;
248
+ if (timer.current) clearTimeout(timer.current);
249
+ onHaptic?.("page");
250
+ setPage(target);
251
+ };
252
+
253
+ const submit = () => {
254
+ const current = draftsRef.current;
255
+ if (!current.every(isAnswered)) return;
256
+ onHaptic?.("submit");
257
+ onSubmit(buildAnswers(questions, current));
258
+ if (showSentState) setSent(true);
259
+ };
260
+
261
+ const select = (index: number) => {
262
+ onHaptic?.("select");
263
+ const selected =
264
+ kind === "single"
265
+ ? [index]
266
+ : draft.selected.includes(index)
267
+ ? draft.selected.filter((value) => value !== index)
268
+ : [...draft.selected, index].sort((a, b) => a - b);
269
+ commit(
270
+ drafts.map((item, position) =>
271
+ position === shown ? { selected, custom: kind === "single" ? "" : item.custom } : item,
272
+ ),
273
+ );
274
+ if (kind !== "single") return;
275
+ if (timer.current) clearTimeout(timer.current);
276
+ if (!last && autoAdvance) {
277
+ timer.current = setTimeout(() => goTo(shown + 1), ADVANCE_DELAY);
278
+ } else if (last && autoSubmit) {
279
+ timer.current = setTimeout(submit, ADVANCE_DELAY);
280
+ }
281
+ };
282
+
283
+ const setCustom = (value: string) => {
284
+ commit(
285
+ drafts.map((item, position) =>
286
+ position === shown ? { selected: kind === "single" && value ? [] : item.selected, custom: value } : item,
287
+ ),
288
+ );
289
+ };
290
+
291
+ const sendCustom = () => {
292
+ if (!draft.custom.trim()) return;
293
+ if (last) submit();
294
+ else goTo(shown + 1);
295
+ };
296
+
297
+ useImperativeHandle(ref, () => ({ select, goTo, submit }));
298
+
299
+ const handleLayout = (event: LayoutChangeEvent) => {
300
+ const next = Math.ceil(event.nativeEvent.layout.height);
301
+ if (next === measured.current) return;
302
+ const initial = measured.current === 0;
303
+ measured.current = next;
304
+ height.value = initial || reduceMotion ? next : withSpring(next, HEIGHT_SPRING);
305
+ };
306
+
307
+ const shellStyle = useAnimatedStyle(() => ({
308
+ opacity: Math.min(1, enter.value * 1.6),
309
+ transform: [{ translateY: (1 - enter.value) * 22 }, { scale: 0.985 + enter.value * 0.015 }],
310
+ }));
311
+ const clipStyle = useAnimatedStyle(() => (height.value > 0 ? { height: height.value } : {}));
312
+ const contentStyle = useAnimatedStyle(() => ({
313
+ opacity: contentOpacity.value,
314
+ transform: [{ translateX: contentShift.value }],
315
+ }));
316
+
317
+ const radius = variant === "card" ? 26 : 0;
318
+ const container: ViewStyle =
319
+ variant === "card"
320
+ ? {
321
+ borderRadius: radius,
322
+ borderCurve: "continuous",
323
+ backgroundColor: palette.card,
324
+ borderWidth: 1,
325
+ borderColor: withAlpha(palette.text, 0.06),
326
+ shadowColor: "#000000",
327
+ shadowOpacity: 0.08,
328
+ shadowRadius: 22,
329
+ shadowOffset: { width: 0, height: 8 },
330
+ }
331
+ : {};
332
+ const sheet = variant === "sheet";
333
+ const padding: ViewStyle = sheet
334
+ ? { paddingHorizontal: 20, paddingTop: 12, paddingBottom: 16 }
335
+ : { paddingHorizontal: 16, paddingTop: 12, paddingBottom: 12 };
336
+ const showFooter = kind === "multiple" && draft.selected.length > 0;
337
+
338
+ return (
339
+ <Animated.View style={[container, shellStyle, style]}>
340
+ <Animated.View style={[{ overflow: "hidden", borderRadius: radius }, sheet ? null : clipStyle]}>
341
+ <View onLayout={sheet ? undefined : handleLayout} style={[padding, sheet ? null : { position: "absolute", top: 0, left: 0, right: 0 }]}>
342
+ {sent ? (
343
+ <Animated.View
344
+ entering={reduceMotion ? undefined : FadeIn.duration(260).easing(EASE_OUT)}
345
+ style={{ flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 10 }}
346
+ >
347
+ <Animated.View
348
+ entering={reduceMotion ? undefined : ZoomIn.springify().damping(14).stiffness(320)}
349
+ style={{ width: CHIP, height: CHIP, borderRadius: CHIP / 2, alignItems: "center", justifyContent: "center", backgroundColor: palette.success }}
350
+ >
351
+ {icons?.sent ?? <Text style={{ color: palette.accentContrast, fontSize: 13, fontWeight: "700" }}>✓</Text>}
352
+ </Animated.View>
353
+ <Text style={[optionStyle, { color: palette.text, fontWeight: "500" }]}>{copy.sent}</Text>
354
+ </Animated.View>
355
+ ) : question ? (
356
+ <>
357
+ <View style={{ flexDirection: "row", alignItems: "center", minHeight: 32 }}>
358
+ {questions.length > 1 ? (
359
+ <View style={{ flexDirection: "row", alignItems: "center", marginLeft: -8 }}>
360
+ <IconButton label="Previous question" disabled={page === 0} onPress={() => goTo(page - 1)}>
361
+ {icons?.previous ?? <Text style={[metaStyle, { color: palette.textSecondary, fontSize: 18, fontWeight: "400" }]}>‹</Text>}
362
+ </IconButton>
363
+ <Text style={[metaStyle, { color: palette.textSecondary, fontVariant: ["tabular-nums"] }]}>
364
+ {copy.progress(page + 1, questions.length)}
365
+ </Text>
366
+ <IconButton
367
+ label="Next question"
368
+ disabled={page === questions.length - 1 || !isAnswered(drafts[page])}
369
+ onPress={() => goTo(page + 1)}
370
+ >
371
+ {icons?.next ?? <Text style={[metaStyle, { color: palette.textSecondary, fontSize: 18, fontWeight: "400" }]}>›</Text>}
372
+ </IconButton>
373
+ </View>
374
+ ) : null}
375
+ <View style={{ flex: 1 }} />
376
+ {onClose ? (
377
+ <View style={{ marginRight: -8 }}>
378
+ <IconButton label="Close" onPress={onClose}>
379
+ {icons?.close ?? <Text style={[metaStyle, { color: palette.textSecondary, fontSize: 18, fontWeight: "400" }]}>×</Text>}
380
+ </IconButton>
381
+ </View>
382
+ ) : null}
383
+ </View>
384
+
385
+ <Animated.View style={contentStyle}>
386
+ <Text accessibilityRole="header" style={[titleStyle, { color: palette.text, marginTop: 4, marginBottom: 8 }]}>
387
+ {question.title}
388
+ </Text>
389
+ <View accessibilityRole={kind === "single" ? "radiogroup" : undefined}>
390
+ {options.map((option, index) => (
391
+ <OptionRow
392
+ key={`${shown}-${index}`}
393
+ label={option.label}
394
+ description={option.description}
395
+ index={index}
396
+ kind={kind}
397
+ selected={draft.selected.includes(index)}
398
+ divider={index > 0}
399
+ numbered={numbered}
400
+ palette={palette}
401
+ hairline={hairline}
402
+ optionStyle={optionStyle}
403
+ metaStyle={metaStyle}
404
+ checkIcon={icons?.check}
405
+ animate={!reduceMotion}
406
+ renderOption={renderOption}
407
+ onPress={() => select(index)}
408
+ />
409
+ ))}
410
+ {question.allowCustom !== false ? (
411
+ <View
412
+ style={{
413
+ flexDirection: "row",
414
+ alignItems: "center",
415
+ gap: 12,
416
+ minHeight: 50,
417
+ marginHorizontal: -8,
418
+ paddingHorizontal: 8,
419
+ }}
420
+ >
421
+ {options.length ? (
422
+ <View style={{ position: "absolute", top: 0, left: numbered ? CHIP + 20 : 8, right: 8, height: 1, backgroundColor: hairline }} />
423
+ ) : null}
424
+ {numbered ? (
425
+ <View style={{ width: CHIP, height: CHIP, borderRadius: 8, alignItems: "center", justifyContent: "center", backgroundColor: palette.surface }}>
426
+ {icons?.custom ?? <Text style={[metaStyle, { color: palette.textSecondary }]}>✎</Text>}
427
+ </View>
428
+ ) : null}
429
+ <TextInput
430
+ value={draft.custom}
431
+ onChangeText={setCustom}
432
+ placeholder={copy.customPlaceholder}
433
+ placeholderTextColor={palette.textTertiary}
434
+ returnKeyType={last ? "send" : "next"}
435
+ onSubmitEditing={sendCustom}
436
+ style={[optionStyle, { flex: 1, color: palette.text, paddingVertical: 12 }]}
437
+ />
438
+ {draft.custom.trim() ? (
439
+ <Animated.View
440
+ entering={reduceMotion ? undefined : ZoomIn.springify().damping(15).stiffness(340)}
441
+ exiting={reduceMotion ? undefined : ZoomOut.duration(140)}
442
+ >
443
+ <Pressable
444
+ onPress={sendCustom}
445
+ accessibilityRole="button"
446
+ accessibilityLabel={last ? copy.submit : copy.next}
447
+ hitSlop={6}
448
+ style={({ pressed }) => ({
449
+ width: 32,
450
+ height: 32,
451
+ borderRadius: 16,
452
+ alignItems: "center",
453
+ justifyContent: "center",
454
+ backgroundColor: palette.accent,
455
+ opacity: pressed ? 0.8 : 1,
456
+ })}
457
+ >
458
+ {icons?.send ?? <Text style={{ color: palette.accentContrast, fontSize: 16, fontWeight: "700" }}>↑</Text>}
459
+ </Pressable>
460
+ </Animated.View>
461
+ ) : null}
462
+ </View>
463
+ ) : null}
464
+ </View>
465
+ {showFooter ? (
466
+ <Animated.View
467
+ entering={reduceMotion ? undefined : FadeIn.duration(200).easing(EASE_OUT)}
468
+ exiting={reduceMotion ? undefined : FadeOut.duration(120)}
469
+ style={{ alignItems: "flex-end", paddingTop: 8 }}
470
+ >
471
+ <Pressable
472
+ onPress={() => (last ? submit() : goTo(shown + 1))}
473
+ accessibilityRole="button"
474
+ style={({ pressed }) => ({
475
+ paddingHorizontal: 18,
476
+ height: 38,
477
+ borderRadius: 19,
478
+ justifyContent: "center",
479
+ backgroundColor: palette.text,
480
+ opacity: pressed ? 0.85 : 1,
481
+ })}
482
+ >
483
+ <Text style={[metaStyle, { color: palette.background, fontSize: 15, fontWeight: "600" }]}>
484
+ {last ? copy.submit : copy.next}
485
+ </Text>
486
+ </Pressable>
487
+ </Animated.View>
488
+ ) : null}
489
+ </Animated.View>
490
+ </>
491
+ ) : null}
492
+ </View>
493
+ </Animated.View>
494
+ </Animated.View>
495
+ );
496
+ });
497
+
498
+ function IconButton({ label, disabled, onPress, children }: { label: string; disabled?: boolean; onPress: () => void; children: ReactNode }) {
499
+ return (
500
+ <Pressable
501
+ onPress={onPress}
502
+ disabled={disabled}
503
+ hitSlop={6}
504
+ accessibilityRole="button"
505
+ accessibilityLabel={label}
506
+ accessibilityState={{ disabled: !!disabled }}
507
+ style={{ width: 30, height: 30, alignItems: "center", justifyContent: "center", opacity: disabled ? 0.28 : 1 }}
508
+ >
509
+ {children}
510
+ </Pressable>
511
+ );
512
+ }
513
+
514
+ type OptionRowProps = {
515
+ label: string;
516
+ description?: string;
517
+ index: number;
518
+ kind: "single" | "multiple";
519
+ selected: boolean;
520
+ divider: boolean;
521
+ numbered: boolean;
522
+ palette: MotionPalette;
523
+ hairline: string;
524
+ optionStyle: StyleProp<TextStyle>;
525
+ metaStyle: StyleProp<TextStyle>;
526
+ checkIcon?: ReactNode;
527
+ animate: boolean;
528
+ renderOption?: QuestionCardProps["renderOption"];
529
+ onPress: () => void;
530
+ };
531
+
532
+ function OptionRow({
533
+ label,
534
+ description,
535
+ index,
536
+ kind,
537
+ selected,
538
+ divider,
539
+ numbered,
540
+ palette,
541
+ hairline,
542
+ optionStyle,
543
+ metaStyle,
544
+ checkIcon,
545
+ animate,
546
+ renderOption,
547
+ onPress,
548
+ }: OptionRowProps) {
549
+ const pop = useSharedValue(1);
550
+ const fill = useSharedValue(selected ? 1 : 0);
551
+ const pressed = useSharedValue(0);
552
+ const rest = withAlpha(palette.text, 0);
553
+ const press = withAlpha(palette.text, 0.05);
554
+
555
+ useEffect(() => {
556
+ fill.value = animate ? withTiming(selected ? 1 : 0, { duration: 180, easing: EASE_OUT }) : selected ? 1 : 0;
557
+ if (selected && animate) pop.value = withSequence(withTiming(0.86, { duration: 70 }), withSpring(1, CHIP_SPRING));
558
+ }, [animate, fill, pop, selected]);
559
+
560
+ const chipStyle = useAnimatedStyle(() => ({ transform: [{ scale: pop.value }] }));
561
+ const chipFill = useAnimatedStyle(() => ({ opacity: fill.value }));
562
+ const rowStyle = useAnimatedStyle(() => ({ backgroundColor: interpolateColor(pressed.value, [0, 1], [rest, press]) }));
563
+
564
+ return (
565
+ <Pressable
566
+ onPress={onPress}
567
+ onPressIn={() => {
568
+ pressed.value = withTiming(1, { duration: 80 });
569
+ }}
570
+ onPressOut={() => {
571
+ pressed.value = withTiming(0, { duration: 220 });
572
+ }}
573
+ accessibilityRole={kind === "single" ? "radio" : "checkbox"}
574
+ accessibilityState={{ checked: selected }}
575
+ accessibilityLabel={label}
576
+ >
577
+ <Animated.View
578
+ style={[
579
+ {
580
+ flexDirection: "row",
581
+ alignItems: "center",
582
+ gap: 12,
583
+ minHeight: 50,
584
+ paddingVertical: 10,
585
+ marginHorizontal: -8,
586
+ paddingHorizontal: 8,
587
+ borderRadius: 12,
588
+ },
589
+ rowStyle,
590
+ ]}
591
+ >
592
+ {divider ? (
593
+ <View style={{ position: "absolute", top: 0, left: numbered ? CHIP + 20 : 8, right: 8, height: 1, backgroundColor: hairline }} />
594
+ ) : null}
595
+ {renderOption ? (
596
+ renderOption({ label, description }, { index, selected, kind })
597
+ ) : (
598
+ <>
599
+ {numbered ? (
600
+ <Animated.View
601
+ style={[
602
+ { width: CHIP, height: CHIP, borderRadius: 8, alignItems: "center", justifyContent: "center", backgroundColor: palette.surface },
603
+ chipStyle,
604
+ ]}
605
+ >
606
+ <Animated.View
607
+ style={[{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, borderRadius: 8, backgroundColor: palette.text }, chipFill]}
608
+ />
609
+ <Text style={[metaStyle, { color: selected ? palette.background : palette.textSecondary, fontVariant: ["tabular-nums"] }]}>
610
+ {index + 1}
611
+ </Text>
612
+ </Animated.View>
613
+ ) : null}
614
+ <View style={{ flex: 1 }}>
615
+ <Text style={[optionStyle, { color: palette.text }]}>{label}</Text>
616
+ {description ? (
617
+ <Text style={[metaStyle, { color: palette.textSecondary, marginTop: 1, fontWeight: "400" }]}>{description}</Text>
618
+ ) : null}
619
+ </View>
620
+ {kind === "multiple" ? (
621
+ <View
622
+ style={{
623
+ width: 22,
624
+ height: 22,
625
+ borderRadius: 11,
626
+ borderWidth: selected ? 0 : 1.5,
627
+ borderColor: withAlpha(palette.text, 0.22),
628
+ backgroundColor: selected ? palette.text : "transparent",
629
+ alignItems: "center",
630
+ justifyContent: "center",
631
+ }}
632
+ >
633
+ {selected ? (
634
+ <Animated.View entering={animate ? ZoomIn.springify().damping(14).stiffness(380) : undefined}>
635
+ {checkIcon ?? <Text style={{ color: palette.background, fontSize: 12, fontWeight: "700" }}>✓</Text>}
636
+ </Animated.View>
637
+ ) : null}
638
+ </View>
639
+ ) : null}
640
+ </>
641
+ )}
642
+ </Animated.View>
643
+ </Pressable>
644
+ );
645
+ }
646
+
647
+ export const QuestionSheet = forwardRef<QuestionCardHandle, Omit<QuestionCardProps, "variant">>(function QuestionSheet(props, ref) {
648
+ return <QuestionCard {...props} ref={ref} variant="sheet" />;
649
+ });