panelui-native 0.46.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +5 -1
  2. package/lib/module/components/accordion/index.js +32 -4
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +83 -15
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/button-group/index.js +186 -0
  7. package/lib/module/components/button-group/index.js.map +1 -0
  8. package/lib/module/components/color-picker/index.js +110 -1
  9. package/lib/module/components/color-picker/index.js.map +1 -1
  10. package/lib/module/components/date-time-picker/index.js +23 -5
  11. package/lib/module/components/date-time-picker/index.js.map +1 -1
  12. package/lib/module/components/fab/index.js +514 -0
  13. package/lib/module/components/fab/index.js.map +1 -0
  14. package/lib/module/components/markdown-editor/index.js +406 -0
  15. package/lib/module/components/markdown-editor/index.js.map +1 -0
  16. package/lib/module/components/markdown-editor/markdown-transforms.js +243 -0
  17. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -0
  18. package/lib/module/components/questionnaire/index.js +1312 -0
  19. package/lib/module/components/questionnaire/index.js.map +1 -0
  20. package/lib/module/components/tabs/index.js +94 -18
  21. package/lib/module/components/tabs/index.js.map +1 -1
  22. package/lib/module/components/time-picker/index.js +34 -6
  23. package/lib/module/components/time-picker/index.js.map +1 -1
  24. package/lib/module/components/tree/index.js +500 -0
  25. package/lib/module/components/tree/index.js.map +1 -0
  26. package/lib/module/icons/index.js +217 -0
  27. package/lib/module/icons/index.js.map +1 -1
  28. package/lib/module/index.js +6 -1
  29. package/lib/module/index.js.map +1 -1
  30. package/lib/typescript/src/components/accordion/index.d.ts +21 -0
  31. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  32. package/lib/typescript/src/components/button/index.d.ts +21 -0
  33. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/button-group/index.d.ts +212 -0
  35. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -0
  36. package/lib/typescript/src/components/color-picker/index.d.ts +82 -1
  37. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
  38. package/lib/typescript/src/components/date-time-picker/index.d.ts +7 -1
  39. package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/fab/index.d.ts +285 -0
  41. package/lib/typescript/src/components/fab/index.d.ts.map +1 -0
  42. package/lib/typescript/src/components/markdown-editor/index.d.ts +102 -0
  43. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -0
  44. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +76 -0
  45. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -0
  46. package/lib/typescript/src/components/questionnaire/index.d.ts +336 -0
  47. package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/tabs/index.d.ts +9 -0
  49. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/time-picker/index.d.ts +19 -1
  51. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/tree/index.d.ts +125 -0
  53. package/lib/typescript/src/components/tree/index.d.ts.map +1 -0
  54. package/lib/typescript/src/icons/index.d.ts +22 -0
  55. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  56. package/lib/typescript/src/index.d.ts +8 -3
  57. package/lib/typescript/src/index.d.ts.map +1 -1
  58. package/package.json +1 -1
  59. package/src/components/accordion/index.tsx +48 -6
  60. package/src/components/button/index.tsx +97 -15
  61. package/src/components/button-group/index.tsx +199 -0
  62. package/src/components/color-picker/index.tsx +140 -3
  63. package/src/components/date-time-picker/index.tsx +39 -3
  64. package/src/components/fab/index.tsx +583 -0
  65. package/src/components/markdown-editor/index.tsx +526 -0
  66. package/src/components/markdown-editor/markdown-transforms.ts +228 -0
  67. package/src/components/questionnaire/index.tsx +1615 -0
  68. package/src/components/tabs/index.tsx +100 -28
  69. package/src/components/time-picker/index.tsx +42 -6
  70. package/src/components/tree/index.tsx +564 -0
  71. package/src/icons/index.tsx +154 -0
  72. package/src/index.ts +77 -1
@@ -0,0 +1,1615 @@
1
+ /**
2
+ * Questionnaire — one question at a time, with progress, validation and a way
3
+ * back.
4
+ *
5
+ * Unlike `Steps`, which reflects a flow the app owns, Questionnaire *owns* the
6
+ * flow: it holds the answers, decides which question is current, gates the way
7
+ * forward on the current one being answered, and reports the whole set back
8
+ * when it is done. The caller supplies the questions and does something with
9
+ * the answers; everything between those two points belongs here.
10
+ *
11
+ * ```tsx
12
+ * <Questionnaire items={items} onSubmit={(answers) => save(answers)}>
13
+ * <Questionnaire.Title>Project setup</Questionnaire.Title>
14
+ * <Questionnaire.Progress />
15
+ * <Questionnaire.Item name="direction" required>
16
+ * <Questionnaire.Question>What should we build next?</Questionnaire.Question>
17
+ * <Questionnaire.Description>Choose one, or write your own.</Questionnaire.Description>
18
+ * <Questionnaire.Choices>
19
+ * <Questionnaire.Choice value="delegation" label="Delegation" />
20
+ * <Questionnaire.Choice value="prompts" label="Question prompts" />
21
+ * <Questionnaire.Input placeholder="Type another answer…" />
22
+ * </Questionnaire.Choices>
23
+ * <Questionnaire.Error />
24
+ * </Questionnaire.Item>
25
+ * <Questionnaire.Footer>
26
+ * <Questionnaire.Back />
27
+ * <Questionnaire.Next />
28
+ * <Questionnaire.Submit />
29
+ * </Questionnaire.Footer>
30
+ * </Questionnaire>
31
+ * ```
32
+ *
33
+ * ## Why it draws its own Frame
34
+ *
35
+ * A survey is a widget, not a paragraph: it wants a boundary, a title strip and
36
+ * a footer that stays put while the middle changes. That is exactly `Frame`, so
37
+ * the root renders one rather than leaving every caller to assemble the same
38
+ * shell. `Frame.Panel`'s `overflow-hidden` also does the clipping the sliding
39
+ * question needs, for free. Pass `frame={false}` to drop it — for a
40
+ * questionnaire inside a `BottomSheet` or a card that already draws a border.
41
+ *
42
+ * ## Why the root reads its children instead of collecting registrations
43
+ *
44
+ * Only the active question is mounted, so an unmounted one cannot report that
45
+ * it exists — and without knowing the full set there is no total to count
46
+ * against, no "is this the last one", and no way to disable a question the
47
+ * user has not reached. So the root inspects its children once per render and
48
+ * reads `name`, `required`, `multiple` and `disabled` straight off the
49
+ * elements. React elements carry their props before anything renders them,
50
+ * which makes the whole set knowable without mounting any of it.
51
+ *
52
+ * That same pass sorts the parts into the shell: the title and progress go to
53
+ * the header strip, the footer to a section at the bottom of the panel, and
54
+ * everything else is a question.
55
+ *
56
+ * ## Answers are one record, the way a form would submit them
57
+ *
58
+ * `answers[name]` is a string for a single-answer question and an array for a
59
+ * `multiple` one. A freeform answer lands under the same name — it is another
60
+ * answer to the same question, not a separate field — which is why the text
61
+ * input shows whatever value does not match one of the question's own choices.
62
+ * Picking a choice and typing therefore replace each other, without either one
63
+ * having to know the other exists.
64
+ *
65
+ * ## What blocks the way forward
66
+ *
67
+ * A required question blocks until it has an answer. An optional one never
68
+ * blocks. `Questionnaire.Skip` does not unblock anything, then — it *records*
69
+ * that the question was deliberately left out, moving its status from
70
+ * `unanswered` to `skipped` so the app can tell the two apart. Making an
71
+ * optional question demand an explicit skip would trap anyone who did not
72
+ * render the Skip button, and a question that cannot be ignored is not
73
+ * optional.
74
+ */
75
+ import {
76
+ Children,
77
+ cloneElement,
78
+ createContext,
79
+ forwardRef,
80
+ isValidElement,
81
+ useCallback,
82
+ useContext,
83
+ useEffect,
84
+ useMemo,
85
+ useRef,
86
+ useState,
87
+ type ReactElement,
88
+ type ReactNode,
89
+ } from 'react';
90
+ import {
91
+ AccessibilityInfo,
92
+ findNodeHandle,
93
+ Pressable,
94
+ View,
95
+ type LayoutChangeEvent,
96
+ type TextInput,
97
+ type ViewProps,
98
+ } from 'react-native';
99
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
100
+ import Animated, {
101
+ Easing,
102
+ FadeOut,
103
+ runOnJS,
104
+ useAnimatedStyle,
105
+ useSharedValue,
106
+ withSpring,
107
+ withTiming,
108
+ type EntryExitAnimationFunction,
109
+ } from 'react-native-reanimated';
110
+ import { tv } from 'tailwind-variants';
111
+ import { useCSSVariable } from 'uniwind';
112
+ import { CheckIcon, ChevronLeftIcon, ChevronRightIcon } from '../../icons';
113
+ import { Text, textChildren } from '../../primitives/text';
114
+ import { cn } from '../../utils/cn';
115
+ import { Button, type ButtonProps } from '../button';
116
+ import { Frame } from '../frame';
117
+ import { Input, type InputProps } from '../input';
118
+
119
+ /** How long the body takes to settle into the next question's height. */
120
+ const HEIGHT_DURATION = 240;
121
+
122
+ /** How long the arriving question takes to slide and fade in. */
123
+ const ENTER_DURATION = 220;
124
+
125
+ /**
126
+ * How long the leaving one takes to fade out — shorter, so it is gone before
127
+ * the arriving one is fully in and the two are never both legible.
128
+ */
129
+ const EXIT_DURATION = 140;
130
+
131
+ /** How long a progress pip takes to fill once its question has been reached. */
132
+ const PIP_DURATION = 260;
133
+
134
+ /** How far across the body a swipe has to travel before it commits. */
135
+ const SWIPE_FRACTION = 0.25;
136
+
137
+ /** Where the sliding question starts from and exits to, as a fraction of width. */
138
+ const SLIDE_FRACTION = 0.35;
139
+
140
+ const EASE = Easing.out(Easing.cubic);
141
+
142
+ /** What `Questionnaire.Error` says when the caller gives it no message. */
143
+ const DEFAULT_ERROR = 'Choose an answer to continue.';
144
+
145
+ const questionnaireVariants = tv({
146
+ slots: {
147
+ body: 'overflow-hidden',
148
+ // Absolute, so the question measures its own height rather than being
149
+ // clamped by the animated box above it — and so the question leaving and
150
+ // the one arriving overlap instead of stacking during the transition.
151
+ slide: 'absolute inset-x-0 top-0',
152
+ pane: 'gap-4',
153
+ choices: 'gap-2.5',
154
+ footer: 'flex-row items-center gap-2',
155
+ error: 'text-sm text-destructive',
156
+ },
157
+ variants: {
158
+ /**
159
+ * With the frame off, only the vertical rhythm is the questionnaire's —
160
+ * the container it was placed in is already holding it off the edges, and
161
+ * insetting it again would inset it twice.
162
+ */
163
+ framed: {
164
+ true: { pane: 'p-4', footer: 'p-3' },
165
+ false: { pane: 'py-4', footer: 'pt-3' },
166
+ },
167
+ },
168
+ defaultVariants: {
169
+ framed: true,
170
+ },
171
+ });
172
+
173
+ const choiceVariants = tv({
174
+ slots: {
175
+ row: 'w-full flex-row items-start gap-3 rounded-xl border border-border bg-card px-3.5 py-3',
176
+ /*
177
+ * `mt-px` against a `leading-snug` first line: the indicator is centred on
178
+ * the label's cap height rather than on its line box, which is where the
179
+ * eye reads the two as being on the same line.
180
+ */
181
+ indicator:
182
+ 'mt-px h-5 w-5 shrink-0 items-center justify-center border border-input bg-background',
183
+ label: 'text-base font-medium leading-snug text-foreground',
184
+ description: 'text-sm leading-snug text-muted-foreground',
185
+ shortcut:
186
+ 'mt-px h-5 min-w-[22px] shrink-0 items-center justify-center rounded-md border border-border bg-muted px-1',
187
+ shortcutLabel: 'text-[11px] font-medium tabular-nums text-muted-foreground',
188
+ },
189
+ variants: {
190
+ /** A disc for one-of, a rounded square for many-of — the usual grammar. */
191
+ multiple: {
192
+ true: { indicator: 'rounded-md' },
193
+ false: { indicator: 'rounded-full' },
194
+ },
195
+ selected: {
196
+ true: {
197
+ row: 'border-primary bg-accent',
198
+ // The badge follows the row rather than staying grey against a filled
199
+ // surface, where it would read as the one part that did not respond.
200
+ shortcut: 'border-primary/40 bg-primary/10',
201
+ shortcutLabel: 'text-primary',
202
+ },
203
+ },
204
+ disabled: {
205
+ true: { row: 'opacity-50' },
206
+ },
207
+ /** The question failed validation, so every answer in it reads as at fault. */
208
+ invalid: {
209
+ true: { row: 'border-destructive' },
210
+ },
211
+ },
212
+ defaultVariants: {
213
+ multiple: false,
214
+ },
215
+ });
216
+
217
+ /**
218
+ * How many questions can be drawn as pips before the count is the clearer
219
+ * thing. Past this they stop being countable at a glance and become a texture.
220
+ */
221
+ const MAX_PIPS = 8;
222
+
223
+ /** Where a question stands: never touched, answered, or deliberately left out. */
224
+ export type QuestionnaireItemStatus = 'unanswered' | 'answered' | 'skipped';
225
+
226
+ /** Which key each answer is badged with. */
227
+ export type QuestionnaireShortcutMode = 'letters' | 'numbers';
228
+
229
+ /** Every answer given so far, keyed by question name. */
230
+ export type QuestionnaireAnswers = Record<string, string | string[]>;
231
+
232
+ /**
233
+ * A question, described rather than rendered. Pass these as `items` so the
234
+ * questionnaire knows its full set before any of it mounts — which is what
235
+ * makes a conditional question countable and a total meaningful.
236
+ */
237
+ export interface QuestionnaireItemDefinition {
238
+ /** Unique name — the key this question's answer is stored under. */
239
+ name: string;
240
+ /** Blocks the way forward until it has an answer. */
241
+ required?: boolean;
242
+ /** Accepts more than one answer, so its answer is an array. */
243
+ multiple?: boolean;
244
+ /** Left out of the count and never navigated to. */
245
+ disabled?: boolean;
246
+ }
247
+
248
+ /** The whole set, as everything downstream sees it. */
249
+ interface ResolvedItem extends QuestionnaireItemDefinition {
250
+ element: ReactElement<QuestionnaireItemProps>;
251
+ onStatusChange?: (status: QuestionnaireItemStatus) => void;
252
+ }
253
+
254
+ interface QuestionnaireContextValue {
255
+ /** One-based position of the active question among the enabled ones. */
256
+ current: number;
257
+ /** How many questions are enabled. */
258
+ total: number;
259
+ first: boolean;
260
+ last: boolean;
261
+ activeName: string | null;
262
+ activeItem: ResolvedItem | null;
263
+ answers: QuestionnaireAnswers;
264
+ statusOf: (name: string) => QuestionnaireItemStatus;
265
+ invalid: ReadonlySet<string>;
266
+ setAnswer: (name: string, value: string | string[] | undefined) => void;
267
+ toggleAnswer: (name: string, value: string, multiple: boolean) => void;
268
+ goNext: () => void;
269
+ goBack: () => void;
270
+ skip: () => void;
271
+ submit: () => void;
272
+ shortcuts: QuestionnaireShortcutMode | null;
273
+ /** Whether the root drew the frame, which decides who owns the insets. */
274
+ framed: boolean;
275
+ /** The active question is required and has no answer, so the way on is shut. */
276
+ blocked: boolean;
277
+ }
278
+
279
+ const QuestionnaireContext = createContext<QuestionnaireContextValue | null>(null);
280
+
281
+ function useQuestionnaire(component: string): QuestionnaireContextValue {
282
+ const context = useContext(QuestionnaireContext);
283
+ if (!context) {
284
+ throw new Error(`${component} must be used within a <Questionnaire>`);
285
+ }
286
+ return context;
287
+ }
288
+
289
+ interface QuestionnaireItemContextValue {
290
+ name: string;
291
+ required: boolean;
292
+ multiple: boolean;
293
+ invalid: boolean;
294
+ /** Every fixed value this question offers — what tells a typed answer apart. */
295
+ choiceValues: ReadonlySet<string>;
296
+ }
297
+
298
+ const QuestionnaireItemContext = createContext<QuestionnaireItemContextValue | null>(null);
299
+
300
+ function useQuestionnaireItem(component: string): QuestionnaireItemContextValue {
301
+ const context = useContext(QuestionnaireItemContext);
302
+ if (!context) {
303
+ throw new Error(`${component} must be used within a <Questionnaire.Item>`);
304
+ }
305
+ return context;
306
+ }
307
+
308
+ /** The shortcut `Questionnaire.Choices` hands the choice it wraps. */
309
+ const ShortcutContext = createContext<string | null>(null);
310
+
311
+ /** True when the question has an answer of some kind. */
312
+ function isAnswered(value: string | string[] | undefined): boolean {
313
+ if (Array.isArray(value)) return value.length > 0;
314
+ return typeof value === 'string' && value.trim().length > 0;
315
+ }
316
+
317
+ /** The letter or number an answer at this position is badged with. */
318
+ function shortcutAt(mode: QuestionnaireShortcutMode, index: number): string | null {
319
+ if (mode === 'numbers') return index < 9 ? String(index + 1) : null;
320
+ return index < 26 ? String.fromCharCode(65 + index) : null;
321
+ }
322
+
323
+ export interface QuestionnaireProps extends Omit<ViewProps, 'children'> {
324
+ className?: string;
325
+ /**
326
+ * The full set of questions, in order. Optional: without it the order and
327
+ * the totals come from the `Questionnaire.Item` children instead. Pass it
328
+ * when a question is conditional, since a question the user has not reached
329
+ * still has to be counted — or not counted, if it no longer applies.
330
+ */
331
+ items?: readonly QuestionnaireItemDefinition[];
332
+ /** Controlled active question, by name. */
333
+ item?: string;
334
+ /** Which question to open on. Defaults to the first enabled one. */
335
+ defaultItem?: string;
336
+ /** Called with the name of the question being moved to. */
337
+ onItemChange?: (name: string) => void;
338
+ /** Controlled answers. */
339
+ answers?: QuestionnaireAnswers;
340
+ /** Answers to start with — for resuming a part-finished questionnaire. */
341
+ defaultAnswers?: QuestionnaireAnswers;
342
+ /** Called with the whole set every time any answer changes. */
343
+ onAnswersChange?: (answers: QuestionnaireAnswers) => void;
344
+ /** Called with every answer once the last question validates. */
345
+ onSubmit?: (answers: QuestionnaireAnswers) => void;
346
+ /**
347
+ * Badge every answer with a letter (`A`, `B`, `C`) or a number (`1`, `2`,
348
+ * `3`). Disabled answers are skipped rather than taking a badge with them.
349
+ *
350
+ * The badge is an affordance, not a binding: React Native surfaces hardware
351
+ * key events only to a focused text field, so nothing here can listen for
352
+ * the key itself.
353
+ */
354
+ shortcuts?: QuestionnaireShortcutMode;
355
+ /**
356
+ * Let a horizontal drag move between questions. Going forward is gated on
357
+ * the same answer the button is, so a swipe off an unanswered required
358
+ * question springs back and shows its error.
359
+ */
360
+ swipeable?: boolean;
361
+ /**
362
+ * Draw the surrounding `Frame`. Turn it off to place the questionnaire in a
363
+ * sheet, a dialog or a card that already draws its own boundary.
364
+ */
365
+ frame?: boolean;
366
+ children?: ReactNode;
367
+ }
368
+
369
+ function QuestionnaireRoot({
370
+ className,
371
+ items,
372
+ item: itemProp,
373
+ defaultItem,
374
+ onItemChange,
375
+ answers: answersProp,
376
+ defaultAnswers,
377
+ onAnswersChange,
378
+ onSubmit,
379
+ shortcuts,
380
+ swipeable = true,
381
+ frame = true,
382
+ children,
383
+ ...props
384
+ }: QuestionnaireProps) {
385
+ /*
386
+ * One pass over the children does two jobs: it sorts the parts into the
387
+ * shell's three regions, and it reads the questions' props off the elements
388
+ * so the set is known without mounting any of it.
389
+ */
390
+ const { titleNode, progressNode, footerNode, elements } = useMemo(() => {
391
+ let title: ReactNode = null;
392
+ let progress: ReactNode = null;
393
+ let footer: ReactNode = null;
394
+ const found: ReactElement<QuestionnaireItemProps>[] = [];
395
+
396
+ Children.forEach(children, (child) => {
397
+ if (!isValidElement(child)) return;
398
+ if (child.type === QuestionnaireTitle) title = child;
399
+ else if (child.type === QuestionnaireProgress) progress = child;
400
+ else if (child.type === QuestionnaireFooter) footer = child;
401
+ else if (child.type === QuestionnaireItem) {
402
+ found.push(child as ReactElement<QuestionnaireItemProps>);
403
+ }
404
+ });
405
+
406
+ return { titleNode: title, progressNode: progress, footerNode: footer, elements: found };
407
+ }, [children]);
408
+
409
+ /*
410
+ * `items` decides the order when it is given, because a question that has
411
+ * not rendered still has to hold its place in the count. A question's own
412
+ * props win over the definition wherever both say something, so a
413
+ * conditional `disabled` can be computed at the point it is rendered.
414
+ */
415
+ const resolved = useMemo<ResolvedItem[]>(() => {
416
+ const byName = new Map(elements.map((element) => [element.props.name, element]));
417
+
418
+ const merge = (
419
+ definition: QuestionnaireItemDefinition,
420
+ element: ReactElement<QuestionnaireItemProps> | undefined
421
+ ): ResolvedItem | null => {
422
+ if (!element) return null;
423
+ const p = element.props;
424
+ return {
425
+ name: definition.name,
426
+ required: p.required ?? definition.required,
427
+ multiple: p.multiple ?? definition.multiple,
428
+ disabled: p.disabled ?? definition.disabled,
429
+ onStatusChange: p.onStatusChange,
430
+ element,
431
+ };
432
+ };
433
+
434
+ if (items?.length) {
435
+ return items
436
+ .map((definition) => merge(definition, byName.get(definition.name)))
437
+ .filter((entry): entry is ResolvedItem => entry !== null);
438
+ }
439
+
440
+ return elements.map((element) => ({
441
+ name: element.props.name,
442
+ required: element.props.required,
443
+ multiple: element.props.multiple,
444
+ disabled: element.props.disabled,
445
+ onStatusChange: element.props.onStatusChange,
446
+ element,
447
+ }));
448
+ }, [items, elements]);
449
+
450
+ /** The ones that count: disabled questions are neither shown nor tallied. */
451
+ const enabled = useMemo(() => resolved.filter((entry) => !entry.disabled), [resolved]);
452
+
453
+ const [internalItem, setInternalItem] = useState<string | null>(defaultItem ?? null);
454
+ const [internalAnswers, setInternalAnswers] = useState<QuestionnaireAnswers>(
455
+ () => defaultAnswers ?? {}
456
+ );
457
+ const [skipped, setSkipped] = useState<ReadonlySet<string>>(() => new Set());
458
+ const [invalid, setInvalid] = useState<ReadonlySet<string>>(() => new Set());
459
+
460
+ const isItemControlled = itemProp !== undefined;
461
+ const isAnswersControlled = answersProp !== undefined;
462
+ const answers = isAnswersControlled ? answersProp : internalAnswers;
463
+
464
+ /*
465
+ * Falling back to the first enabled question rather than storing it means a
466
+ * questionnaire whose first question becomes disabled moves off it by
467
+ * itself, instead of sitting on a question it has been told not to show.
468
+ */
469
+ const requested = isItemControlled ? itemProp : internalItem;
470
+ const activeIndex = Math.max(
471
+ 0,
472
+ enabled.findIndex((entry) => entry.name === requested)
473
+ );
474
+ const activeItem = enabled[activeIndex] ?? null;
475
+ const activeName = activeItem?.name ?? null;
476
+
477
+ const total = enabled.length;
478
+ const current = total === 0 ? 0 : activeIndex + 1;
479
+ const first = activeIndex === 0;
480
+ const last = total === 0 || activeIndex === total - 1;
481
+
482
+ const statusOf = useCallback(
483
+ (name: string): QuestionnaireItemStatus => {
484
+ if (isAnswered(answers[name])) return 'answered';
485
+ if (skipped.has(name)) return 'skipped';
486
+ return 'unanswered';
487
+ },
488
+ [answers, skipped]
489
+ );
490
+
491
+ /*
492
+ * Reported from here rather than from the question itself: only the active
493
+ * question is mounted, and skipping one is immediately followed by leaving
494
+ * it, so an effect inside it would be racing its own unmount.
495
+ */
496
+ const emitStatus = useCallback(
497
+ (name: string, status: QuestionnaireItemStatus) => {
498
+ resolved.find((entry) => entry.name === name)?.onStatusChange?.(status);
499
+ },
500
+ [resolved]
501
+ );
502
+
503
+ const commitAnswers = useCallback(
504
+ (next: QuestionnaireAnswers) => {
505
+ if (!isAnswersControlled) setInternalAnswers(next);
506
+ onAnswersChange?.(next);
507
+ },
508
+ [isAnswersControlled, onAnswersChange]
509
+ );
510
+
511
+ const setAnswer = useCallback(
512
+ (name: string, value: string | string[] | undefined) => {
513
+ const next = { ...answers };
514
+ if (value === undefined || (Array.isArray(value) && value.length === 0)) {
515
+ delete next[name];
516
+ } else {
517
+ next[name] = value;
518
+ }
519
+
520
+ commitAnswers(next);
521
+
522
+ // Answering clears both a recorded skip and a failed validation: the
523
+ // reason for either has just stopped being true.
524
+ setSkipped((previous) => {
525
+ if (!previous.has(name)) return previous;
526
+ const copy = new Set(previous);
527
+ copy.delete(name);
528
+ return copy;
529
+ });
530
+ setInvalid((previous) => {
531
+ if (!previous.has(name)) return previous;
532
+ const copy = new Set(previous);
533
+ copy.delete(name);
534
+ return copy;
535
+ });
536
+
537
+ emitStatus(name, isAnswered(next[name]) ? 'answered' : 'unanswered');
538
+ },
539
+ [answers, commitAnswers, emitStatus]
540
+ );
541
+
542
+ const toggleAnswer = useCallback(
543
+ (name: string, value: string, multiple: boolean) => {
544
+ if (!multiple) {
545
+ // Pressing the selected answer again clears it, which is the only way
546
+ // to undo an answer to an optional question without a Skip button.
547
+ setAnswer(name, answers[name] === value ? undefined : value);
548
+ return;
549
+ }
550
+
551
+ const currentValue = answers[name];
552
+ const list = Array.isArray(currentValue) ? currentValue : [];
553
+ setAnswer(
554
+ name,
555
+ list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value]
556
+ );
557
+ },
558
+ [answers, setAnswer]
559
+ );
560
+
561
+ const moveTo = useCallback(
562
+ (index: number) => {
563
+ const target = enabled[index];
564
+ if (!target) return;
565
+ if (!isItemControlled) setInternalItem(target.name);
566
+ onItemChange?.(target.name);
567
+ },
568
+ [enabled, isItemControlled, onItemChange]
569
+ );
570
+
571
+ /** A required question is the only thing that blocks. */
572
+ const validate = useCallback(
573
+ (entry: ResolvedItem | null): boolean => {
574
+ if (!entry || !entry.required) return true;
575
+ if (isAnswered(answers[entry.name])) return true;
576
+ setInvalid((previous) => new Set(previous).add(entry.name));
577
+ return false;
578
+ },
579
+ [answers]
580
+ );
581
+
582
+ const goNext = useCallback(() => {
583
+ if (!validate(activeItem)) return;
584
+ moveTo(activeIndex + 1);
585
+ }, [validate, activeItem, moveTo, activeIndex]);
586
+
587
+ // Going back never validates: the way out of a question you cannot answer
588
+ // must not be the same door you came in by.
589
+ const goBack = useCallback(() => moveTo(activeIndex - 1), [moveTo, activeIndex]);
590
+
591
+ const skip = useCallback(() => {
592
+ if (!activeItem || activeItem.required) return;
593
+ setAnswer(activeItem.name, undefined);
594
+ setSkipped((previous) => new Set(previous).add(activeItem.name));
595
+ emitStatus(activeItem.name, 'skipped');
596
+ if (!last) moveTo(activeIndex + 1);
597
+ }, [activeItem, setAnswer, emitStatus, last, moveTo, activeIndex]);
598
+
599
+ const submit = useCallback(() => {
600
+ const failed = enabled.filter((entry) => entry.required && !isAnswered(answers[entry.name]));
601
+ if (failed.length > 0) {
602
+ setInvalid((previous) => {
603
+ const copy = new Set(previous);
604
+ failed.forEach((entry) => copy.add(entry.name));
605
+ return copy;
606
+ });
607
+ // Take them to the first question that is missing an answer rather than
608
+ // leaving them on the last one wondering which of the others it was.
609
+ const index = enabled.findIndex((entry) => entry.name === failed[0]!.name);
610
+ if (index !== activeIndex) moveTo(index);
611
+ return;
612
+ }
613
+ onSubmit?.(answers);
614
+ }, [enabled, answers, activeIndex, moveTo, onSubmit]);
615
+
616
+ /*
617
+ * Whether the way on is shut, as against whether it has been *tried* — the
618
+ * error under the question needs somebody to have pressed the button, but
619
+ * the button's own look must not, or it would read as ready right up until
620
+ * it refused.
621
+ */
622
+ const blocked = !!activeItem?.required && !isAnswered(answers[activeItem.name]);
623
+
624
+ const context = useMemo<QuestionnaireContextValue>(
625
+ () => ({
626
+ current,
627
+ total,
628
+ first,
629
+ last,
630
+ activeName,
631
+ activeItem,
632
+ answers,
633
+ statusOf,
634
+ invalid,
635
+ setAnswer,
636
+ toggleAnswer,
637
+ goNext,
638
+ goBack,
639
+ skip,
640
+ submit,
641
+ shortcuts: shortcuts ?? null,
642
+ framed: frame,
643
+ blocked,
644
+ }),
645
+ [
646
+ current,
647
+ total,
648
+ first,
649
+ last,
650
+ activeName,
651
+ activeItem,
652
+ answers,
653
+ statusOf,
654
+ invalid,
655
+ setAnswer,
656
+ toggleAnswer,
657
+ goNext,
658
+ goBack,
659
+ skip,
660
+ submit,
661
+ shortcuts,
662
+ frame,
663
+ blocked,
664
+ ]
665
+ );
666
+
667
+ const body = (
668
+ <QuestionnaireBody
669
+ activeName={activeName}
670
+ activeItem={activeItem}
671
+ activeIndex={activeIndex}
672
+ framed={frame}
673
+ swipeable={swipeable}
674
+ canAdvance={!last}
675
+ canRetreat={!first}
676
+ onNext={goNext}
677
+ onBack={goBack}
678
+ />
679
+ );
680
+
681
+ /*
682
+ * More room above and below than a Frame header takes by default. That
683
+ * default is set for a line of text, and the progress indicator is a 4px
684
+ * bar — against the same padding it reads as pinned to the top edge rather
685
+ * than sitting on the strip.
686
+ *
687
+ * With no title, the progress centres rather than staying hard right. Right
688
+ * is where it belongs when it is the trailing half of a pair; on its own at
689
+ * the end of an otherwise empty strip it reads as something left over
690
+ * rather than as the strip's subject.
691
+ */
692
+ const header = (
693
+ <Frame.Header className={cn('pb-3.5 pt-3', !titleNode && 'justify-center')}>
694
+ {titleNode}
695
+ {progressNode ? <Frame.Action>{progressNode}</Frame.Action> : null}
696
+ </Frame.Header>
697
+ );
698
+
699
+ return (
700
+ <QuestionnaireContext.Provider value={context}>
701
+ {frame ? (
702
+ <Frame className={className} {...props}>
703
+ {header}
704
+ <Frame.Panel dividers={false}>
705
+ {body}
706
+ {footerNode ? <Frame.Section divided>{footerNode}</Frame.Section> : null}
707
+ </Frame.Panel>
708
+ </Frame>
709
+ ) : (
710
+ <View className={cn('w-full', className)} {...props}>
711
+ {titleNode || progressNode ? (
712
+ // The same rule as the framed strip: paired with a title it sits
713
+ // at the trailing edge, alone it centres.
714
+ <View
715
+ className={cn(
716
+ 'flex-row items-center gap-3 pb-3',
717
+ titleNode ? 'justify-between' : 'justify-center'
718
+ )}
719
+ >
720
+ {titleNode}
721
+ {progressNode}
722
+ </View>
723
+ ) : null}
724
+ {body}
725
+ {footerNode}
726
+ </View>
727
+ )}
728
+ </QuestionnaireContext.Provider>
729
+ );
730
+ }
731
+ QuestionnaireRoot.displayName = 'Questionnaire';
732
+
733
+ interface QuestionnaireBodyProps {
734
+ activeName: string | null;
735
+ activeItem: ResolvedItem | null;
736
+ /** Position of the active question, which is what says which way a move went. */
737
+ activeIndex: number;
738
+ framed: boolean;
739
+ swipeable: boolean;
740
+ canAdvance: boolean;
741
+ canRetreat: boolean;
742
+ onNext: () => void;
743
+ onBack: () => void;
744
+ }
745
+
746
+ /**
747
+ * The middle of the questionnaire: the one mounted question, the height it
748
+ * animates to, and the drag that moves between them.
749
+ */
750
+ function QuestionnaireBody({
751
+ activeName,
752
+ activeItem,
753
+ activeIndex,
754
+ framed,
755
+ swipeable,
756
+ canAdvance,
757
+ canRetreat,
758
+ onNext,
759
+ onBack,
760
+ }: QuestionnaireBodyProps) {
761
+ const slots = questionnaireVariants({ framed });
762
+
763
+ const [width, setWidth] = useState(0);
764
+ // -1 means nothing has been measured yet, so the first question takes its
765
+ // height outright instead of growing into it from nothing.
766
+ const height = useSharedValue(-1);
767
+ const drag = useSharedValue(0);
768
+ const paneRef = useRef<View>(null);
769
+
770
+ /*
771
+ * Until the first question has been measured the pane stays in the flow, so
772
+ * the body has a real height from the moment it first lays out. Absolute
773
+ * from the start would measure zero on that first pass — the container's
774
+ * only child would contribute nothing to it — and anything sizing itself to
775
+ * this content would take the zero and keep it. A sheet set to wrap its
776
+ * content is exactly that, and it would open around a question nobody can
777
+ * see. Once the height is known the pane goes absolute, which is what lets
778
+ * the question leaving and the one arriving overlap.
779
+ */
780
+ const [measured, setMeasured] = useState(false);
781
+
782
+ /*
783
+ * Which way the question slides in from, worked out from the move itself
784
+ * rather than from whatever triggered it. A button, a swipe and a caller
785
+ * setting `item` directly are all the same move to the reader, and only the
786
+ * change in position says which direction it went.
787
+ *
788
+ * Read during render, not in an effect: the arriving question's animation is
789
+ * fixed when it mounts, and an effect runs after that — which would leave
790
+ * every transition playing the direction of the one before it.
791
+ */
792
+ const previousIndex = useRef(activeIndex);
793
+ const direction: 1 | -1 = activeIndex >= previousIndex.current ? 1 : -1;
794
+ const navigated = useRef(false);
795
+
796
+ useEffect(() => {
797
+ if (activeIndex !== previousIndex.current) {
798
+ previousIndex.current = activeIndex;
799
+ navigated.current = true;
800
+ }
801
+ }, [activeIndex]);
802
+
803
+ const navigate = useCallback(
804
+ (delta: 1 | -1) => {
805
+ if (delta === 1) onNext();
806
+ else onBack();
807
+ },
808
+ [onNext, onBack]
809
+ );
810
+
811
+ useEffect(() => {
812
+ drag.value = 0;
813
+ }, [activeName, drag]);
814
+
815
+ // Move the reader onto the question that just arrived, so a screen reader
816
+ // reads the new question rather than leaving focus where the button was.
817
+ useEffect(() => {
818
+ if (!navigated.current) return;
819
+ const node = paneRef.current;
820
+ if (!node) return;
821
+ const tag = findNodeHandle(node);
822
+ if (tag != null) AccessibilityInfo.setAccessibilityFocus(tag);
823
+ }, [activeName]);
824
+
825
+ const onPaneLayout = useCallback(
826
+ (event: LayoutChangeEvent) => {
827
+ const next = event.nativeEvent.layout.height;
828
+ if (next <= 0) return;
829
+ if (measured) {
830
+ height.value = withTiming(next, { duration: HEIGHT_DURATION, easing: EASE });
831
+ return;
832
+ }
833
+ // The first measurement is taken outright: there is no previous height
834
+ // to travel from, and animating in from nothing is a question that
835
+ // unfurls on arrival rather than one that is simply there.
836
+ height.value = next;
837
+ setMeasured(true);
838
+ },
839
+ [measured, height]
840
+ );
841
+
842
+ const heightStyle = useAnimatedStyle(() =>
843
+ height.value < 0 ? {} : { height: height.value }
844
+ );
845
+
846
+ const dragStyle = useAnimatedStyle(() => ({
847
+ transform: [{ translateX: drag.value }],
848
+ }));
849
+
850
+ const pan = useMemo(
851
+ () =>
852
+ Gesture.Pan()
853
+ // A vertical scroll must always win: the questionnaire sits in a page
854
+ // that scrolls, and a drag that is even slightly vertical belongs to it.
855
+ .activeOffsetX([-12, 12])
856
+ .failOffsetY([-8, 8])
857
+ .enabled(swipeable && width > 0)
858
+ .onUpdate((event) => {
859
+ const forward = event.translationX < 0;
860
+ if ((forward && !canAdvance) || (!forward && !canRetreat)) {
861
+ // Nowhere to go this way — let it move a little so the drag is
862
+ // acknowledged, then stop.
863
+ drag.value = event.translationX * 0.2;
864
+ return;
865
+ }
866
+ drag.value = event.translationX;
867
+ })
868
+ .onEnd((event) => {
869
+ const threshold = width * SWIPE_FRACTION;
870
+ if (event.translationX < -threshold && canAdvance) {
871
+ runOnJS(navigate)(1);
872
+ } else if (event.translationX > threshold && canRetreat) {
873
+ runOnJS(navigate)(-1);
874
+ }
875
+ // Springs back either way. When the move is taken the question is
876
+ // replaced outright, so this only ever shows on a move that was not.
877
+ drag.value = withSpring(0, { damping: 20, stiffness: 220, mass: 0.6 });
878
+ }),
879
+ [swipeable, width, canAdvance, canRetreat, drag, navigate]
880
+ );
881
+
882
+ /*
883
+ * The question arrives from the side it is coming from, over a fraction of
884
+ * the width rather than the whole of it — this is a widget in a page, not a
885
+ * screen, and a slide the full width of it reads as the page moving.
886
+ *
887
+ * Written out rather than assembled from the stock builders because those
888
+ * carry an initial opacity and nothing else: the distance is the part worth
889
+ * controlling here, and none of them lets it be set.
890
+ */
891
+ const offset = width > 0 ? width * SLIDE_FRACTION : 60;
892
+ const entering = useCallback<EntryExitAnimationFunction>(() => {
893
+ 'worklet';
894
+ return {
895
+ initialValues: { opacity: 0, transform: [{ translateX: direction * offset }] },
896
+ animations: {
897
+ opacity: withTiming(1, { duration: ENTER_DURATION, easing: EASE }),
898
+ transform: [{ translateX: withTiming(0, { duration: ENTER_DURATION, easing: EASE }) }],
899
+ },
900
+ };
901
+ }, [direction, offset]);
902
+
903
+ const exiting = FadeOut.duration(EXIT_DURATION);
904
+
905
+ return (
906
+ <GestureDetector gesture={pan}>
907
+ <Animated.View
908
+ style={heightStyle}
909
+ className={slots.body()}
910
+ onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
911
+ >
912
+ {/*
913
+ * Two views, not one: the entering animation drives this one's
914
+ * transform, so the drag needs a view of its own underneath it rather
915
+ * than a second transform on the same style the animation is writing.
916
+ */}
917
+ <Animated.View
918
+ key={activeName ?? '__empty__'}
919
+ entering={measured ? entering : undefined}
920
+ exiting={exiting}
921
+ className={measured ? slots.slide() : 'w-full'}
922
+ >
923
+ <Animated.View style={dragStyle}>
924
+ <View ref={paneRef} onLayout={onPaneLayout} className={slots.pane()}>
925
+ {activeItem?.element ?? null}
926
+ </View>
927
+ </Animated.View>
928
+ </Animated.View>
929
+ </Animated.View>
930
+ </GestureDetector>
931
+ );
932
+ }
933
+
934
+ export interface QuestionnaireTitleProps extends ViewProps {
935
+ className?: string;
936
+ children?: ReactNode;
937
+ }
938
+
939
+ /**
940
+ * Names the questionnaire as a whole, in the frame's header strip. The
941
+ * current question's own prompt is `Questionnaire.Question`.
942
+ */
943
+ function QuestionnaireTitle({ className, children, ...props }: QuestionnaireTitleProps) {
944
+ return (
945
+ <View className={cn('min-w-0 flex-1', className)} {...props}>
946
+ {textChildren(children, (text) => (
947
+ <Text size="sm" muted numberOfLines={1}>
948
+ {text}
949
+ </Text>
950
+ ))}
951
+ </View>
952
+ );
953
+ }
954
+ QuestionnaireTitle.displayName = 'Questionnaire.Title';
955
+
956
+ /** What a custom progress indicator is told about where the reader is. */
957
+ export interface QuestionnaireProgressState {
958
+ /** One-based position of the active question. */
959
+ current: number;
960
+ /** How many questions are enabled. */
961
+ total: number;
962
+ first: boolean;
963
+ last: boolean;
964
+ }
965
+
966
+ /** How the position is drawn. */
967
+ export type QuestionnaireProgressVariant = 'pips' | 'numbers' | 'count';
968
+
969
+ export interface QuestionnaireProgressProps {
970
+ className?: string;
971
+ /**
972
+ * `pips` is a bar per question, filled up to the one being asked and widened
973
+ * on it. `numbers` counts them out instead, which is what you want when the
974
+ * reader will be sent back to a particular question. `count` is the plain
975
+ * `Question 2 of 5`.
976
+ *
977
+ * `pips` and `numbers` fall back to `count` past eight questions, where
978
+ * neither is countable at a glance any more.
979
+ */
980
+ variant?: QuestionnaireProgressVariant;
981
+ /**
982
+ * Replace the indicator entirely. Given a function, it is called with the
983
+ * position — for a bar, a row of dots, or a percentage.
984
+ */
985
+ children?: ReactNode | ((state: QuestionnaireProgressState) => ReactNode);
986
+ }
987
+
988
+ /**
989
+ * One question's worth of the track. Filled once it has been reached, and the
990
+ * one being asked is drawn wider than the rest so the reader's place in the
991
+ * set is legible without counting.
992
+ */
993
+ function ProgressPip({ filled, active }: { filled: boolean; active: boolean }) {
994
+ const fill = useSharedValue(filled ? 1 : 0);
995
+
996
+ useEffect(() => {
997
+ fill.value = withTiming(filled ? 1 : 0, { duration: PIP_DURATION, easing: EASE });
998
+ }, [filled, fill]);
999
+
1000
+ const fillStyle = useAnimatedStyle(() => ({ opacity: fill.value }));
1001
+
1002
+ return (
1003
+ <View
1004
+ className={cn(
1005
+ 'h-1 overflow-hidden rounded-full bg-border',
1006
+ active ? 'w-5' : 'w-2.5'
1007
+ )}
1008
+ >
1009
+ <Animated.View style={fillStyle} className="h-full w-full rounded-full bg-primary" />
1010
+ </View>
1011
+ );
1012
+ }
1013
+
1014
+ /**
1015
+ * The same position, counted out. A number says which question this is in a
1016
+ * way a bar cannot — worth it where the reader is going to be asked to go back
1017
+ * to one of them, since a bar gives them nothing to go back *to*.
1018
+ */
1019
+ function ProgressNumber({
1020
+ value,
1021
+ done,
1022
+ active,
1023
+ }: {
1024
+ value: number;
1025
+ done: boolean;
1026
+ active: boolean;
1027
+ }) {
1028
+ return (
1029
+ <View
1030
+ className={cn(
1031
+ 'h-5 w-5 items-center justify-center rounded-full border',
1032
+ active
1033
+ ? 'border-primary bg-primary'
1034
+ : done
1035
+ ? 'border-primary/40 bg-primary/10'
1036
+ : 'border-border bg-muted'
1037
+ )}
1038
+ >
1039
+ <Text
1040
+ className={cn(
1041
+ 'text-[11px] font-medium tabular-nums',
1042
+ active
1043
+ ? 'text-primary-foreground'
1044
+ : done
1045
+ ? 'text-primary'
1046
+ : 'text-muted-foreground'
1047
+ )}
1048
+ >
1049
+ {value}
1050
+ </Text>
1051
+ </View>
1052
+ );
1053
+ }
1054
+
1055
+ /** Where the reader is in the set, announced as a progress bar. */
1056
+ function QuestionnaireProgress({
1057
+ className,
1058
+ variant = 'pips',
1059
+ children,
1060
+ }: QuestionnaireProgressProps) {
1061
+ const { current, total, first, last } = useQuestionnaire('Questionnaire.Progress');
1062
+ const label = `Question ${current} of ${total}`;
1063
+
1064
+ /*
1065
+ * Marks while they can still be counted, the count itself once they cannot.
1066
+ * Twenty of either is a texture rather than a number, and the text says the
1067
+ * same thing in less room.
1068
+ */
1069
+ const drawable = variant !== 'count' && total > 0 && total <= MAX_PIPS;
1070
+
1071
+ const fallback = drawable ? (
1072
+ <View className={cn('flex-row items-center', variant === 'numbers' ? 'gap-1.5' : 'gap-1')}>
1073
+ {Array.from({ length: total }, (_, index) =>
1074
+ variant === 'numbers' ? (
1075
+ <ProgressNumber
1076
+ key={index}
1077
+ value={index + 1}
1078
+ done={index < current - 1}
1079
+ active={index === current - 1}
1080
+ />
1081
+ ) : (
1082
+ <ProgressPip key={index} filled={index < current} active={index === current - 1} />
1083
+ )
1084
+ )}
1085
+ </View>
1086
+ ) : (
1087
+ <Text size="sm" muted className="tabular-nums">
1088
+ {label}
1089
+ </Text>
1090
+ );
1091
+
1092
+ const content =
1093
+ typeof children === 'function' ? children({ current, total, first, last }) : (children ?? fallback);
1094
+
1095
+ return (
1096
+ <View
1097
+ accessibilityRole="progressbar"
1098
+ accessibilityLabel="Questionnaire progress"
1099
+ accessibilityValue={{ min: 1, max: Math.max(total, 1), now: current, text: label }}
1100
+ className={className}
1101
+ >
1102
+ {textChildren(content, (text) => (
1103
+ <Text size="sm" muted className="tabular-nums">
1104
+ {text}
1105
+ </Text>
1106
+ ))}
1107
+ </View>
1108
+ );
1109
+ }
1110
+ QuestionnaireProgress.displayName = 'Questionnaire.Progress';
1111
+
1112
+ export interface QuestionnaireItemProps extends Omit<ViewProps, 'children'> {
1113
+ className?: string;
1114
+ /** Unique name — the key this question's answer is stored under. */
1115
+ name: string;
1116
+ /** Blocks the way forward until it has an answer. */
1117
+ required?: boolean;
1118
+ /** Accepts more than one answer, so its answer is an array. */
1119
+ multiple?: boolean;
1120
+ /** Left out of the count and never navigated to. */
1121
+ disabled?: boolean;
1122
+ /** Mark the question at fault from a validator of your own. */
1123
+ invalid?: boolean;
1124
+ /** Called whenever this question moves between unanswered, answered and skipped. */
1125
+ onStatusChange?: (status: QuestionnaireItemStatus) => void;
1126
+ children?: ReactNode;
1127
+ }
1128
+
1129
+ /**
1130
+ * One question. Only the active one is mounted, so anything it holds is built
1131
+ * when it is reached and thrown away when it is left.
1132
+ */
1133
+ function QuestionnaireItem({
1134
+ className,
1135
+ name,
1136
+ required,
1137
+ multiple,
1138
+ invalid: invalidProp,
1139
+ children,
1140
+ // Read by the root off this element rather than used here.
1141
+ disabled: _disabled,
1142
+ onStatusChange: _onStatusChange,
1143
+ ...props
1144
+ }: QuestionnaireItemProps) {
1145
+ const { invalid: invalidNames } = useQuestionnaire('Questionnaire.Item');
1146
+
1147
+ /*
1148
+ * Collected so a typed answer can be told from a chosen one: whatever the
1149
+ * question is holding that is not one of these values came from the text
1150
+ * field, and that is what puts it back in the field on the way back.
1151
+ */
1152
+ const choiceValues = useMemo(() => {
1153
+ const values = new Set<string>();
1154
+ const walk = (node: ReactNode) => {
1155
+ Children.forEach(node, (child) => {
1156
+ if (!isValidElement(child)) return;
1157
+ if (child.type === QuestionnaireChoice) {
1158
+ values.add((child.props as QuestionnaireChoiceProps).value);
1159
+ return;
1160
+ }
1161
+ walk((child.props as { children?: ReactNode }).children);
1162
+ });
1163
+ };
1164
+ walk(children);
1165
+ return values;
1166
+ }, [children]);
1167
+
1168
+ const context = useMemo<QuestionnaireItemContextValue>(
1169
+ () => ({
1170
+ name,
1171
+ required: !!required,
1172
+ multiple: !!multiple,
1173
+ invalid: !!invalidProp || invalidNames.has(name),
1174
+ choiceValues,
1175
+ }),
1176
+ [name, required, multiple, invalidProp, invalidNames, choiceValues]
1177
+ );
1178
+
1179
+ return (
1180
+ <QuestionnaireItemContext.Provider value={context}>
1181
+ <View
1182
+ accessibilityRole={multiple ? undefined : 'radiogroup'}
1183
+ className={cn('gap-4', className)}
1184
+ {...props}
1185
+ >
1186
+ {textChildren(children)}
1187
+ </View>
1188
+ </QuestionnaireItemContext.Provider>
1189
+ );
1190
+ }
1191
+ QuestionnaireItem.displayName = 'Questionnaire.Item';
1192
+
1193
+ export interface QuestionnaireQuestionProps extends ViewProps {
1194
+ className?: string;
1195
+ children?: ReactNode;
1196
+ }
1197
+
1198
+ /** The question being asked. */
1199
+ function QuestionnaireQuestion({ className, children, ...props }: QuestionnaireQuestionProps) {
1200
+ return (
1201
+ <View className={className} {...props}>
1202
+ {textChildren(children, (text) => (
1203
+ // `text-pretty` rather than a hard wrap: a question is a sentence, and
1204
+ // the one thing worse than two lines is a second line holding one word.
1205
+ <Text size="xl" weight="semibold" className="text-pretty leading-snug">
1206
+ {text}
1207
+ </Text>
1208
+ ))}
1209
+ </View>
1210
+ );
1211
+ }
1212
+ QuestionnaireQuestion.displayName = 'Questionnaire.Question';
1213
+
1214
+ export interface QuestionnaireDescriptionProps extends ViewProps {
1215
+ className?: string;
1216
+ children?: ReactNode;
1217
+ }
1218
+
1219
+ /** A line under the question — what to consider, or that it can be skipped. */
1220
+ function QuestionnaireDescription({
1221
+ className,
1222
+ children,
1223
+ ...props
1224
+ }: QuestionnaireDescriptionProps) {
1225
+ return (
1226
+ // Pulled up against the question it belongs to: the pane's gap is the
1227
+ // distance between one part and the next, and these two are one part.
1228
+ <View className={cn('-mt-2.5', className)} {...props}>
1229
+ {textChildren(children, (text) => (
1230
+ <Text size="sm" muted className="leading-snug">
1231
+ {text}
1232
+ </Text>
1233
+ ))}
1234
+ </View>
1235
+ );
1236
+ }
1237
+ QuestionnaireDescription.displayName = 'Questionnaire.Description';
1238
+
1239
+ export interface QuestionnaireChoicesProps extends Omit<ViewProps, 'children'> {
1240
+ className?: string;
1241
+ children?: ReactNode;
1242
+ }
1243
+
1244
+ /**
1245
+ * The answers to a question. It hands each choice its shortcut badge, counting
1246
+ * only the ones that can be picked so a disabled answer does not take a letter
1247
+ * out of the sequence with it.
1248
+ */
1249
+ function QuestionnaireChoices({ className, children, ...props }: QuestionnaireChoicesProps) {
1250
+ const { shortcuts } = useQuestionnaire('Questionnaire.Choices');
1251
+ const slots = questionnaireVariants();
1252
+
1253
+ const badged = useMemo(() => {
1254
+ if (!shortcuts) return children;
1255
+ let index = 0;
1256
+ return Children.map(children, (child) => {
1257
+ if (!isValidElement(child) || child.type !== QuestionnaireChoice) return child;
1258
+ if ((child.props as QuestionnaireChoiceProps).disabled) return child;
1259
+ const key = shortcutAt(shortcuts, index++);
1260
+ if (!key) return child;
1261
+ return (
1262
+ <ShortcutContext.Provider key={key} value={key}>
1263
+ {child}
1264
+ </ShortcutContext.Provider>
1265
+ );
1266
+ });
1267
+ }, [children, shortcuts]);
1268
+
1269
+ return (
1270
+ <View className={slots.choices({ className })} {...props}>
1271
+ {textChildren(badged)}
1272
+ </View>
1273
+ );
1274
+ }
1275
+ QuestionnaireChoices.displayName = 'Questionnaire.Choices';
1276
+
1277
+ export interface QuestionnaireChoiceProps {
1278
+ className?: string;
1279
+ /** The value recorded when this answer is picked. */
1280
+ value: string;
1281
+ /** The answer itself. */
1282
+ label?: string;
1283
+ /** A line under the label, for an answer that needs explaining. */
1284
+ description?: string;
1285
+ disabled?: boolean;
1286
+ children?: ReactNode;
1287
+ }
1288
+
1289
+ /**
1290
+ * One fixed answer — the whole row is the target, with the indicator reading
1291
+ * as confirmation rather than as the thing to aim at.
1292
+ */
1293
+ const QuestionnaireChoice = forwardRef<View, QuestionnaireChoiceProps>(
1294
+ ({ className, value, label, description, disabled, children }, ref) => {
1295
+ const { answers, toggleAnswer } = useQuestionnaire('Questionnaire.Choice');
1296
+ const item = useQuestionnaireItem('Questionnaire.Choice');
1297
+ const shortcut = useContext(ShortcutContext);
1298
+
1299
+ const answer = answers[item.name];
1300
+ const selected = Array.isArray(answer) ? answer.includes(value) : answer === value;
1301
+
1302
+ const progress = useSharedValue(selected ? 1 : 0);
1303
+
1304
+ useEffect(() => {
1305
+ progress.value = selected
1306
+ ? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 })
1307
+ : withTiming(0, { duration: 120 });
1308
+ }, [selected, progress]);
1309
+
1310
+ const markStyle = useAnimatedStyle(() => ({
1311
+ opacity: progress.value,
1312
+ transform: [{ scale: progress.value }],
1313
+ }));
1314
+
1315
+ const checkColor = useCSSVariable('--color-primary-foreground');
1316
+ const slots = choiceVariants({
1317
+ multiple: item.multiple,
1318
+ selected,
1319
+ disabled: !!disabled,
1320
+ invalid: item.invalid && !selected,
1321
+ });
1322
+
1323
+ const labelled = label ? (
1324
+ <Text className={slots.label()}>{label}</Text>
1325
+ ) : (
1326
+ textChildren(children, (text) => <Text className={slots.label()}>{text}</Text>)
1327
+ );
1328
+
1329
+ return (
1330
+ <Pressable
1331
+ ref={ref}
1332
+ accessibilityRole={item.multiple ? 'checkbox' : 'radio'}
1333
+ accessibilityState={{
1334
+ checked: item.multiple ? selected : undefined,
1335
+ selected,
1336
+ disabled: !!disabled,
1337
+ }}
1338
+ accessibilityLabel={label}
1339
+ accessibilityHint={description}
1340
+ disabled={disabled}
1341
+ onPress={() => toggleAnswer(item.name, value, item.multiple)}
1342
+ className={slots.row({ className })}
1343
+ >
1344
+ <View className={cn(slots.indicator(), selected && 'border-primary bg-primary')}>
1345
+ <Animated.View style={markStyle}>
1346
+ {item.multiple ? (
1347
+ <CheckIcon
1348
+ size={13}
1349
+ color={typeof checkColor === 'string' ? checkColor : '#fff'}
1350
+ />
1351
+ ) : (
1352
+ <View className="h-2 w-2 rounded-full bg-primary-foreground" />
1353
+ )}
1354
+ </Animated.View>
1355
+ </View>
1356
+ <View className="min-w-0 flex-1 gap-1">
1357
+ {labelled}
1358
+ {description ? <Text className={slots.description()}>{description}</Text> : null}
1359
+ </View>
1360
+ {shortcut ? (
1361
+ <View className={slots.shortcut()}>
1362
+ <Text className={slots.shortcutLabel()}>{shortcut}</Text>
1363
+ </View>
1364
+ ) : null}
1365
+ </Pressable>
1366
+ );
1367
+ }
1368
+ );
1369
+ QuestionnaireChoice.displayName = 'Questionnaire.Choice';
1370
+
1371
+ export interface QuestionnaireInputProps
1372
+ extends Omit<InputProps, 'value' | 'onChangeText' | 'errorMessage'> {
1373
+ className?: string;
1374
+ }
1375
+
1376
+ /**
1377
+ * An answer that is not on the list. It holds whatever the question is
1378
+ * answered with that none of its own choices offers, so picking a choice
1379
+ * empties it and typing clears the choice — one answer to one question, with
1380
+ * neither part having to know about the other.
1381
+ */
1382
+ const QuestionnaireInput = forwardRef<TextInput, QuestionnaireInputProps>(
1383
+ ({ className, ...props }, ref) => {
1384
+ const { answers, setAnswer } = useQuestionnaire('Questionnaire.Input');
1385
+ const item = useQuestionnaireItem('Questionnaire.Input');
1386
+
1387
+ const answer = answers[item.name];
1388
+ const freeform = useMemo(() => {
1389
+ if (Array.isArray(answer)) {
1390
+ return answer.find((entry) => !item.choiceValues.has(entry)) ?? '';
1391
+ }
1392
+ return typeof answer === 'string' && !item.choiceValues.has(answer) ? answer : '';
1393
+ }, [answer, item.choiceValues]);
1394
+
1395
+ const onChangeText = useCallback(
1396
+ (text: string) => {
1397
+ if (!item.multiple) {
1398
+ setAnswer(item.name, text);
1399
+ return;
1400
+ }
1401
+ // Replace this question's one typed entry, leaving every picked one alone.
1402
+ const list = Array.isArray(answer) ? answer : [];
1403
+ const fixed = list.filter((entry) => item.choiceValues.has(entry));
1404
+ setAnswer(item.name, text.length > 0 ? [...fixed, text] : fixed);
1405
+ },
1406
+ [item.multiple, item.name, item.choiceValues, answer, setAnswer]
1407
+ );
1408
+
1409
+ return (
1410
+ <Input
1411
+ ref={ref}
1412
+ value={freeform}
1413
+ onChangeText={onChangeText}
1414
+ className={className}
1415
+ {...props}
1416
+ />
1417
+ );
1418
+ }
1419
+ );
1420
+ QuestionnaireInput.displayName = 'Questionnaire.Input';
1421
+
1422
+ export interface QuestionnaireErrorProps extends ViewProps {
1423
+ className?: string;
1424
+ /** Replace the default message. */
1425
+ children?: ReactNode;
1426
+ }
1427
+
1428
+ /** Why the way forward is closed. Nothing until the question fails to pass. */
1429
+ function QuestionnaireError({ className, children, ...props }: QuestionnaireErrorProps) {
1430
+ const item = useQuestionnaireItem('Questionnaire.Error');
1431
+ const slots = questionnaireVariants();
1432
+
1433
+ if (!item.invalid) return null;
1434
+
1435
+ return (
1436
+ <View
1437
+ accessibilityRole="alert"
1438
+ accessibilityLiveRegion="polite"
1439
+ className={className}
1440
+ {...props}
1441
+ >
1442
+ {textChildren(children ?? DEFAULT_ERROR, (text) => (
1443
+ <Text className={slots.error()}>{text}</Text>
1444
+ ))}
1445
+ </View>
1446
+ );
1447
+ }
1448
+ QuestionnaireError.displayName = 'Questionnaire.Error';
1449
+
1450
+ export interface QuestionnaireFooterProps extends ViewProps {
1451
+ className?: string;
1452
+ children?: ReactNode;
1453
+ }
1454
+
1455
+ /**
1456
+ * The action row, in its own section at the foot of the panel. It stays put
1457
+ * while the question above it changes, which is what keeps the button under
1458
+ * the thumb where it was.
1459
+ */
1460
+ function QuestionnaireFooter({ className, children, ...props }: QuestionnaireFooterProps) {
1461
+ const { framed } = useQuestionnaire('Questionnaire.Footer');
1462
+ const slots = questionnaireVariants({ framed });
1463
+ return (
1464
+ <View className={slots.footer({ className })} {...props}>
1465
+ {textChildren(children)}
1466
+ </View>
1467
+ );
1468
+ }
1469
+ QuestionnaireFooter.displayName = 'Questionnaire.Footer';
1470
+
1471
+ /** What a navigation button is told about the question it is acting on. */
1472
+ export interface QuestionnaireActionState {
1473
+ /** Whether the action applies to the active question at all. */
1474
+ visible: boolean;
1475
+ /** Where the active question stands. */
1476
+ status: QuestionnaireItemStatus;
1477
+ }
1478
+
1479
+ export interface QuestionnaireActionProps extends Omit<ButtonProps, 'children'> {
1480
+ /** Replace the label. Given a function, it is called with the question's state. */
1481
+ children?: ReactNode | ((state: QuestionnaireActionState) => ReactNode);
1482
+ }
1483
+
1484
+ /**
1485
+ * Builds one of the four navigation buttons. They differ only in when they
1486
+ * apply, what they do and how they are labelled by default, so they are made
1487
+ * rather than written out four times.
1488
+ */
1489
+ interface ActionConfig {
1490
+ displayName: string;
1491
+ label: string;
1492
+ variant: NonNullable<ButtonProps['variant']>;
1493
+ /** A chevron on the buttons that move, and nothing on the ones that do not. */
1494
+ startContent?: ReactNode;
1495
+ endContent?: ReactNode;
1496
+ /**
1497
+ * Dim this one while the active question is required and unanswered.
1498
+ *
1499
+ * It stays pressable on purpose. A disabled button says no without saying
1500
+ * why, and on a question whose answers have scrolled out of view that is the
1501
+ * whole of the feedback; pressing this one puts the reason under the
1502
+ * question instead. Dimming is what stops it promising something it will not
1503
+ * do — the look says not yet, the press says why not.
1504
+ */
1505
+ dimWhenBlocked?: boolean;
1506
+ use: (context: QuestionnaireContextValue) => { visible: boolean; onPress: () => void };
1507
+ }
1508
+
1509
+ function createAction({
1510
+ displayName,
1511
+ label: fallbackLabel,
1512
+ variant: fallbackVariant,
1513
+ startContent,
1514
+ endContent,
1515
+ dimWhenBlocked,
1516
+ use,
1517
+ }: ActionConfig) {
1518
+ function Action({ children, variant, className, ...props }: QuestionnaireActionProps) {
1519
+ const context = useQuestionnaire(displayName);
1520
+ const { visible, onPress } = use(context);
1521
+ const status = context.activeName ? context.statusOf(context.activeName) : 'unanswered';
1522
+
1523
+ // Not rendered at all rather than hidden: React Native has no `inert`, and
1524
+ // a button left in the tree is one a screen reader still offers.
1525
+ if (!visible) return null;
1526
+
1527
+ const label = typeof children === 'function' ? children({ visible, status }) : children;
1528
+ const dimmed = !!dimWhenBlocked && context.blocked;
1529
+
1530
+ return (
1531
+ <Button
1532
+ variant={variant ?? fallbackVariant}
1533
+ startContent={startContent}
1534
+ endContent={endContent}
1535
+ onPress={onPress}
1536
+ // Not `accessibilityState.disabled`: it is not disabled, and saying so
1537
+ // would stop a screen reader offering the very press that explains it.
1538
+ accessibilityHint={dimmed ? DEFAULT_ERROR : undefined}
1539
+ className={cn(dimmed && 'opacity-[0.64]', className)}
1540
+ {...props}
1541
+ >
1542
+ {label ?? fallbackLabel}
1543
+ </Button>
1544
+ );
1545
+ }
1546
+
1547
+ Action.displayName = displayName;
1548
+ return Action;
1549
+ }
1550
+
1551
+ /** Back to the previous question. Absent on the first one. */
1552
+ const QuestionnaireBack = createAction({
1553
+ displayName: 'Questionnaire.Back',
1554
+ label: 'Back',
1555
+ variant: 'ghost',
1556
+ startContent: <ChevronLeftIcon size={16} />,
1557
+ use: (context) => ({ visible: !context.first, onPress: context.goBack }),
1558
+ });
1559
+
1560
+ /** Records that an optional question was deliberately left out. */
1561
+ const QuestionnaireSkip = createAction({
1562
+ displayName: 'Questionnaire.Skip',
1563
+ label: 'Skip',
1564
+ variant: 'ghost',
1565
+ use: (context) => ({
1566
+ visible: !!context.activeItem && !context.activeItem.required,
1567
+ onPress: context.skip,
1568
+ }),
1569
+ });
1570
+
1571
+ /** On to the next question, if the current one lets go. Absent on the last. */
1572
+ const QuestionnaireNext = createAction({
1573
+ displayName: 'Questionnaire.Next',
1574
+ label: 'Continue',
1575
+ variant: 'primary',
1576
+ endContent: <ChevronRightIcon size={16} />,
1577
+ dimWhenBlocked: true,
1578
+ use: (context) => ({ visible: !context.last, onPress: context.goNext }),
1579
+ });
1580
+
1581
+ /** Hands over every answer. Only on the last question. */
1582
+ const QuestionnaireSubmit = createAction({
1583
+ displayName: 'Questionnaire.Submit',
1584
+ label: 'Submit',
1585
+ variant: 'primary',
1586
+ dimWhenBlocked: true,
1587
+ use: (context) => ({ visible: context.last, onPress: context.submit }),
1588
+ });
1589
+
1590
+ /**
1591
+ * A flexible gap for the footer, so the trailing buttons sit against the
1592
+ * trailing edge whether or not `Questionnaire.Back` is showing.
1593
+ */
1594
+ function QuestionnaireSpacer({ className, ...props }: ViewProps) {
1595
+ return <View className={cn('flex-1', className)} {...props} />;
1596
+ }
1597
+ QuestionnaireSpacer.displayName = 'Questionnaire.Spacer';
1598
+
1599
+ export const Questionnaire = Object.assign(QuestionnaireRoot, {
1600
+ Title: QuestionnaireTitle,
1601
+ Progress: QuestionnaireProgress,
1602
+ Item: QuestionnaireItem,
1603
+ Question: QuestionnaireQuestion,
1604
+ Description: QuestionnaireDescription,
1605
+ Choices: QuestionnaireChoices,
1606
+ Choice: QuestionnaireChoice,
1607
+ Input: QuestionnaireInput,
1608
+ Error: QuestionnaireError,
1609
+ Footer: QuestionnaireFooter,
1610
+ Spacer: QuestionnaireSpacer,
1611
+ Back: QuestionnaireBack,
1612
+ Skip: QuestionnaireSkip,
1613
+ Next: QuestionnaireNext,
1614
+ Submit: QuestionnaireSubmit,
1615
+ });