panelui-native 0.46.0 → 0.51.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -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/sortable/index.js +806 -0
- package/lib/module/components/sortable/index.js.map +1 -0
- package/lib/module/components/swipe/index.js +140 -5
- package/lib/module/components/swipe/index.js.map +1 -1
- 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 +246 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +8 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/haptics.js +19 -0
- package/lib/module/utils/haptics.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/sortable/index.d.ts +238 -0
- package/lib/typescript/src/components/sortable/index.d.ts.map +1 -0
- package/lib/typescript/src/components/swipe/index.d.ts +35 -0
- package/lib/typescript/src/components/swipe/index.d.ts.map +1 -1
- 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 +30 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +10 -4
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/haptics.d.ts +14 -0
- package/lib/typescript/src/utils/haptics.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/sortable/index.tsx +1080 -0
- package/src/components/swipe/index.tsx +165 -3
- 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 +176 -0
- package/src/index.ts +89 -1
- package/src/utils/haptics.ts +21 -0
|
@@ -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
|