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