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.
- package/README.md +5 -1
- package/lib/module/components/accordion/index.js +32 -4
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/button/index.js +83 -15
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/button-group/index.js +186 -0
- package/lib/module/components/button-group/index.js.map +1 -0
- package/lib/module/components/color-picker/index.js +110 -1
- package/lib/module/components/color-picker/index.js.map +1 -1
- package/lib/module/components/date-time-picker/index.js +23 -5
- package/lib/module/components/date-time-picker/index.js.map +1 -1
- package/lib/module/components/fab/index.js +514 -0
- package/lib/module/components/fab/index.js.map +1 -0
- package/lib/module/components/markdown-editor/index.js +406 -0
- package/lib/module/components/markdown-editor/index.js.map +1 -0
- package/lib/module/components/markdown-editor/markdown-transforms.js +243 -0
- package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -0
- package/lib/module/components/questionnaire/index.js +1312 -0
- package/lib/module/components/questionnaire/index.js.map +1 -0
- package/lib/module/components/tabs/index.js +94 -18
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +34 -6
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/components/tree/index.js +500 -0
- package/lib/module/components/tree/index.js.map +1 -0
- package/lib/module/icons/index.js +217 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +6 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +21 -0
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +21 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button-group/index.d.ts +212 -0
- package/lib/typescript/src/components/button-group/index.d.ts.map +1 -0
- package/lib/typescript/src/components/color-picker/index.d.ts +82 -1
- package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/date-time-picker/index.d.ts +7 -1
- package/lib/typescript/src/components/date-time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/fab/index.d.ts +285 -0
- package/lib/typescript/src/components/fab/index.d.ts.map +1 -0
- package/lib/typescript/src/components/markdown-editor/index.d.ts +102 -0
- package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -0
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +76 -0
- package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -0
- package/lib/typescript/src/components/questionnaire/index.d.ts +336 -0
- package/lib/typescript/src/components/questionnaire/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tabs/index.d.ts +9 -0
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts +19 -1
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tree/index.d.ts +125 -0
- package/lib/typescript/src/components/tree/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +22 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +8 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/accordion/index.tsx +48 -6
- package/src/components/button/index.tsx +97 -15
- package/src/components/button-group/index.tsx +199 -0
- package/src/components/color-picker/index.tsx +140 -3
- package/src/components/date-time-picker/index.tsx +39 -3
- package/src/components/fab/index.tsx +583 -0
- package/src/components/markdown-editor/index.tsx +526 -0
- package/src/components/markdown-editor/markdown-transforms.ts +228 -0
- package/src/components/questionnaire/index.tsx +1615 -0
- package/src/components/tabs/index.tsx +100 -28
- package/src/components/time-picker/index.tsx +42 -6
- package/src/components/tree/index.tsx +564 -0
- package/src/icons/index.tsx +154 -0
- 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
|
+
});
|