/** * Questionnaire — one question at a time, with progress, validation and a way * back. * * Unlike `Steps`, which reflects a flow the app owns, Questionnaire *owns* the * flow: it holds the answers, decides which question is current, gates the way * forward on the current one being answered, and reports the whole set back * when it is done. The caller supplies the questions and does something with * the answers; everything between those two points belongs here. * * ```tsx * save(answers)}> * Project setup * * * What should we build next? * Choose one, or write your own. * * * * * * * * * * * * * * ``` * * ## Why it draws its own Frame * * A survey is a widget, not a paragraph: it wants a boundary, a title strip and * a footer that stays put while the middle changes. That is exactly `Frame`, so * the root renders one rather than leaving every caller to assemble the same * shell. `Frame.Panel`'s `overflow-hidden` also does the clipping the sliding * question needs, for free. Pass `frame={false}` to drop it — for a * questionnaire inside a `BottomSheet` or a card that already draws a border. * * It is the `inset` variant, so the panel the question is written on floats in * a recessed band rather than sitting flush in a tray, and the actions go in * the band rather than in a section under the question. That separation is the * point: the question changes and the row under it does not, and a row drawn * on the band is visibly not part of the card that keeps being replaced. * * The band shapes its actions into equal pills, which is why * `Questionnaire.Spacer` is dropped on the way in — see `bandActions`. * * ## Why the root reads its children instead of collecting registrations * * Only the active question is mounted, so an unmounted one cannot report that * it exists — and without knowing the full set there is no total to count * against, no "is this the last one", and no way to disable a question the * user has not reached. So the root inspects its children once per render and * reads `name`, `required`, `multiple` and `disabled` straight off the * elements. React elements carry their props before anything renders them, * which makes the whole set knowable without mounting any of it. * * That same pass sorts the parts into the shell: the title and progress go to * the header strip above the panel, the footer's actions to the band around * it, and everything else is a question. * * ## Answers are one record, the way a form would submit them * * `answers[name]` is a string for a single-answer question and an array for a * `multiple` one. A freeform answer lands under the same name — it is another * answer to the same question, not a separate field — which is why the text * input shows whatever value does not match one of the question's own choices. * Picking a choice and typing therefore replace each other, without either one * having to know the other exists. * * ## What blocks the way forward * * A required question blocks until it has an answer. An optional one never * blocks. `Questionnaire.Skip` does not unblock anything, then — it *records* * that the question was deliberately left out, moving its status from * `unanswered` to `skipped` so the app can tell the two apart. Making an * optional question demand an explicit skip would trap anyone who did not * render the Skip button, and a question that cannot be ignored is not * optional. */ import { Children, cloneElement, createContext, forwardRef, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from 'react'; import { AccessibilityInfo, findNodeHandle, Pressable, View, type LayoutChangeEvent, type TextInput, type ViewProps, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, FadeOut, runOnJS, useAnimatedProps, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, withTiming, type EntryExitAnimationFunction, } from 'react-native-reanimated'; import Svg, { Circle } from 'react-native-svg'; import { tv } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { CheckIcon, ChevronLeftIcon, ChevronRightIcon } from '../../icons'; import { Text, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { Button, type ButtonProps } from '../button'; import { Frame } from '../frame'; import { Input, type InputProps } from '../input'; /** How long the body takes to settle into the next question's height. */ const HEIGHT_DURATION = 240; /** How long the arriving question takes to slide and fade in. */ const ENTER_DURATION = 220; /** * How long the leaving one takes to fade out — shorter, so it is gone before * the arriving one is fully in and the two are never both legible. */ const EXIT_DURATION = 140; /** How long a progress pip takes to fill once its question has been reached. */ const PIP_DURATION = 260; /** * The progress ring: its diameter, and the weight of the arc drawn round it. * * Sized against the title beside it rather than against the strip it sits on. * A ring the height of a line of text reads as the trailing half of a pair; * one much larger reads as the header's subject, which it is not. */ const RING_SIZE = 22; const RING_STROKE = 2.5; /** How far across the body a swipe has to travel before it commits. */ const SWIPE_FRACTION = 0.25; /** Where the sliding question starts from and exits to, as a fraction of width. */ const SLIDE_FRACTION = 0.35; const EASE = Easing.out(Easing.cubic); /** What `Questionnaire.Error` says when the caller gives it no message. */ const DEFAULT_ERROR = 'Choose an answer to continue.'; const questionnaireVariants = tv({ slots: { body: 'overflow-hidden', // Absolute, so the question measures its own height rather than being // clamped by the animated box above it — and so the question leaving and // the one arriving overlap instead of stacking during the transition. slide: 'absolute inset-x-0 top-0', pane: 'gap-4', choices: 'gap-2.5', footer: 'flex-row items-center gap-2', error: 'text-sm text-destructive', }, variants: { /** * With the frame off, only the vertical rhythm is the questionnaire's — * the container it was placed in is already holding it off the edges, and * insetting it again would inset it twice. */ framed: { true: { pane: 'p-4', footer: 'p-3' }, false: { pane: 'py-4', footer: 'pt-3' }, }, }, defaultVariants: { framed: true, }, }); const choiceVariants = tv({ slots: { row: 'w-full flex-row items-start gap-3 rounded-xl border border-border bg-card px-3.5 py-3', /* * `mt-px` against a `leading-snug` first line: the indicator is centred on * the label's cap height rather than on its line box, which is where the * eye reads the two as being on the same line. */ indicator: 'mt-px h-5 w-5 shrink-0 items-center justify-center border border-input bg-background', label: 'text-base font-medium leading-snug text-foreground', description: 'text-sm leading-snug text-muted-foreground', shortcut: 'mt-px h-5 min-w-[22px] shrink-0 items-center justify-center rounded-md border border-border bg-muted px-1', shortcutLabel: 'text-[11px] font-medium tabular-nums text-muted-foreground', }, variants: { /** A disc for one-of, a rounded square for many-of — the usual grammar. */ multiple: { true: { indicator: 'rounded-md' }, false: { indicator: 'rounded-full' }, }, selected: { true: { row: 'border-primary bg-accent', // The badge follows the row rather than staying grey against a filled // surface, where it would read as the one part that did not respond. shortcut: 'border-primary/40 bg-primary/10', shortcutLabel: 'text-primary', }, }, disabled: { true: { row: 'opacity-50' }, }, /** The question failed validation, so every answer in it reads as at fault. */ invalid: { true: { row: 'border-destructive' }, }, }, defaultVariants: { multiple: false, }, }); /** * How many questions can be drawn as pips before the count is the clearer * thing. Past this they stop being countable at a glance and become a texture. */ const MAX_PIPS = 8; /** Where a question stands: never touched, answered, or deliberately left out. */ export type QuestionnaireItemStatus = 'unanswered' | 'answered' | 'skipped'; /** Which key each answer is badged with. */ export type QuestionnaireShortcutMode = 'letters' | 'numbers'; /** Every answer given so far, keyed by question name. */ export type QuestionnaireAnswers = Record; /** * A question, described rather than rendered. Pass these as `items` so the * questionnaire knows its full set before any of it mounts — which is what * makes a conditional question countable and a total meaningful. */ export interface QuestionnaireItemDefinition { /** Unique name — the key this question's answer is stored under. */ name: string; /** Blocks the way forward until it has an answer. */ required?: boolean; /** Accepts more than one answer, so its answer is an array. */ multiple?: boolean; /** Left out of the count and never navigated to. */ disabled?: boolean; } /** The whole set, as everything downstream sees it. */ interface ResolvedItem extends QuestionnaireItemDefinition { element: ReactElement; onStatusChange?: (status: QuestionnaireItemStatus) => void; } interface QuestionnaireContextValue { /** One-based position of the active question among the enabled ones. */ current: number; /** How many questions are enabled. */ total: number; first: boolean; last: boolean; activeName: string | null; activeItem: ResolvedItem | null; answers: QuestionnaireAnswers; statusOf: (name: string) => QuestionnaireItemStatus; invalid: ReadonlySet; setAnswer: (name: string, value: string | string[] | undefined) => void; toggleAnswer: (name: string, value: string, multiple: boolean) => void; goNext: () => void; goBack: () => void; skip: () => void; submit: () => void; shortcuts: QuestionnaireShortcutMode | null; /** Whether the root drew the frame, which decides who owns the insets. */ framed: boolean; /** * The question is drawn on the header strip rather than in the panel, so * `Questionnaire.Question` renders nothing where it stands. */ questionOnStrip: boolean; /** The active question is required and has no answer, so the way on is shut. */ blocked: boolean; } const QuestionnaireContext = createContext(null); function useQuestionnaire(component: string): QuestionnaireContextValue { const context = useContext(QuestionnaireContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } interface QuestionnaireItemContextValue { name: string; required: boolean; multiple: boolean; invalid: boolean; /** Every fixed value this question offers — what tells a typed answer apart. */ choiceValues: ReadonlySet; } const QuestionnaireItemContext = createContext(null); function useQuestionnaireItem(component: string): QuestionnaireItemContextValue { const context = useContext(QuestionnaireItemContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } /** The shortcut `Questionnaire.Choices` hands the choice it wraps. */ const ShortcutContext = createContext(null); /** True when the question has an answer of some kind. */ function isAnswered(value: string | string[] | undefined): boolean { if (Array.isArray(value)) return value.length > 0; return typeof value === 'string' && value.trim().length > 0; } /** * The footer's actions, on their way into the band. * * `Frame.Footer` shapes its *direct* children into the band's pills, so the * row itself has to be unwrapped — handed the `Questionnaire.Footer` element * whole it would be the wrapper that came out a pill, with the buttons * untouched inside it. * * The spacer goes at the same time. It exists to push the actions apart in a * row that lays them out at their own widths; in a band where every action is * already a share of the row it would take a share of its own, and the buttons * would come out narrower the more carefully the caller had spaced them. * * The band's own rule is equal widths, and it is the wrong one here. A row of * equal pills says these are equal decisions; Back and Skip are ways off the * path and Next is the path, so they are sized to their labels and the primary * action takes what is left. Equal thirds also truncate it — "Continue" does * not fit in a third of the band, and the button that carries the flow is the * worst one to lose the end of. * * `flex-none` and nothing else on those two: the button's own padding is what * a label needs either side of it, and anything added here comes off the * primary action, which has the longest label and the least room to lose. * * The primary action goes up one size instead — `lg`, not the default. It is * the only thing on the band anybody is aiming for: the whole widget is one * decision repeated, and this is the button that takes the reader to the next * one, so it carries a larger label and a taller box than the way back beside * it. One size and no more; `xl` is for a button that is the whole of a * screen, and this one shares its row. The band's fixed height comes off with * it, or the taller box would be clamped back to the row's. */ function bandActions(footer: ReactNode): ReactNode { if (!isValidElement(footer)) return footer; return Children.toArray(footer.props.children).flatMap((child) => { if (!isValidElement(child)) return [child]; if (child.type === QuestionnaireSpacer) return []; // Merged after the band's own classes rather than before, so what is set // here is what survives. if (child.type === QuestionnaireBack || child.type === QuestionnaireSkip) { return [cloneElement(child, { className: cn('flex-none', child.props.className) })]; } return [ cloneElement(child, { // A caller who asked for a size meant it. size: child.props.size ?? 'lg', className: cn('h-auto', child.props.className), }), ]; }); } /** * The text of a question's `Questionnaire.Question`, for the header strip. * * Read off the element rather than reported up by the mounted question: only * the active one is mounted, and the strip has to be drawn in the same render * as the pane it labels — a value arriving from below would land one frame * late, which on a question change is a header still naming the question that * has just left. */ function questionOf(item: ReactElement | undefined): ReactNode { if (!item) return null; let found: ReactNode = null; Children.forEach(item.props.children, (child) => { if (found != null) return; if (!isValidElement(child)) return; if (child.type === QuestionnaireQuestion) found = child.props.children; }); return found; } /** The letter or number an answer at this position is badged with. */ function shortcutAt(mode: QuestionnaireShortcutMode, index: number): string | null { if (mode === 'numbers') return index < 9 ? String(index + 1) : null; return index < 26 ? String.fromCharCode(65 + index) : null; } export interface QuestionnaireProps extends Omit { className?: string; /** * The full set of questions, in order. Optional: without it the order and * the totals come from the `Questionnaire.Item` children instead. Pass it * when a question is conditional, since a question the user has not reached * still has to be counted — or not counted, if it no longer applies. */ items?: readonly QuestionnaireItemDefinition[]; /** Controlled active question, by name. */ item?: string; /** Which question to open on. Defaults to the first enabled one. */ defaultItem?: string; /** Called with the name of the question being moved to. */ onItemChange?: (name: string) => void; /** Controlled answers. */ answers?: QuestionnaireAnswers; /** Answers to start with — for resuming a part-finished questionnaire. */ defaultAnswers?: QuestionnaireAnswers; /** Called with the whole set every time any answer changes. */ onAnswersChange?: (answers: QuestionnaireAnswers) => void; /** Called with every answer once the last question validates. */ onSubmit?: (answers: QuestionnaireAnswers) => void; /** * Badge every answer with a letter (`A`, `B`, `C`) or a number (`1`, `2`, * `3`). Disabled answers are skipped rather than taking a badge with them. * * The badge is an affordance, not a binding: React Native surfaces hardware * key events only to a focused text field, so nothing here can listen for * the key itself. */ shortcuts?: QuestionnaireShortcutMode; /** * Let a horizontal drag move between questions. Going forward is gated on * the same answer the button is, so a swipe off an unanswered required * question springs back and shows its error. */ swipeable?: boolean; /** * Draw the surrounding `Frame`. Turn it off to place the questionnaire in a * sheet, a dialog or a card that already draws its own boundary. */ frame?: boolean; children?: ReactNode; } function QuestionnaireRoot({ className, items, item: itemProp, defaultItem, onItemChange, answers: answersProp, defaultAnswers, onAnswersChange, onSubmit, shortcuts, swipeable = true, frame = true, children, ...props }: QuestionnaireProps) { /* * One pass over the children does two jobs: it sorts the parts into the * shell's three regions, and it reads the questions' props off the elements * so the set is known without mounting any of it. */ const { titleNode, progressNode, footerNode, elements } = useMemo(() => { let title: ReactNode = null; let progress: ReactNode = null; let footer: ReactNode = null; const found: ReactElement[] = []; Children.forEach(children, (child) => { if (!isValidElement(child)) return; 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) { found.push(child as ReactElement); } }); return { titleNode: title, progressNode: progress, footerNode: footer, elements: found }; }, [children]); /* * `items` decides the order when it is given, because a question that has * not rendered still has to hold its place in the count. A question's own * props win over the definition wherever both say something, so a * conditional `disabled` can be computed at the point it is rendered. */ const resolved = useMemo(() => { const byName = new Map(elements.map((element) => [element.props.name, element])); const merge = ( definition: QuestionnaireItemDefinition, element: ReactElement | undefined ): ResolvedItem | null => { if (!element) return null; const p = element.props; return { name: definition.name, required: p.required ?? definition.required, multiple: p.multiple ?? definition.multiple, disabled: p.disabled ?? definition.disabled, onStatusChange: p.onStatusChange, element, }; }; if (items?.length) { return items .map((definition) => merge(definition, byName.get(definition.name))) .filter((entry): entry is ResolvedItem => entry !== null); } return elements.map((element) => ({ name: element.props.name, required: element.props.required, multiple: element.props.multiple, disabled: element.props.disabled, onStatusChange: element.props.onStatusChange, element, })); }, [items, elements]); /** The ones that count: disabled questions are neither shown nor tallied. */ const enabled = useMemo(() => resolved.filter((entry) => !entry.disabled), [resolved]); const [internalItem, setInternalItem] = useState(defaultItem ?? null); const [internalAnswers, setInternalAnswers] = useState( () => defaultAnswers ?? {} ); const [skipped, setSkipped] = useState>(() => new Set()); const [invalid, setInvalid] = useState>(() => new Set()); const isItemControlled = itemProp !== undefined; const isAnswersControlled = answersProp !== undefined; const answers = isAnswersControlled ? answersProp : internalAnswers; /* * Falling back to the first enabled question rather than storing it means a * questionnaire whose first question becomes disabled moves off it by * itself, instead of sitting on a question it has been told not to show. */ const requested = isItemControlled ? itemProp : internalItem; const activeIndex = Math.max( 0, enabled.findIndex((entry) => entry.name === requested) ); const activeItem = enabled[activeIndex] ?? null; const activeName = activeItem?.name ?? null; const total = enabled.length; const current = total === 0 ? 0 : activeIndex + 1; const first = activeIndex === 0; const last = total === 0 || activeIndex === total - 1; const statusOf = useCallback( (name: string): QuestionnaireItemStatus => { if (isAnswered(answers[name])) return 'answered'; if (skipped.has(name)) return 'skipped'; return 'unanswered'; }, [answers, skipped] ); /* * Reported from here rather than from the question itself: only the active * question is mounted, and skipping one is immediately followed by leaving * it, so an effect inside it would be racing its own unmount. */ const emitStatus = useCallback( (name: string, status: QuestionnaireItemStatus) => { resolved.find((entry) => entry.name === name)?.onStatusChange?.(status); }, [resolved] ); const commitAnswers = useCallback( (next: QuestionnaireAnswers) => { if (!isAnswersControlled) setInternalAnswers(next); onAnswersChange?.(next); }, [isAnswersControlled, onAnswersChange] ); const setAnswer = useCallback( (name: string, value: string | string[] | undefined) => { const next = { ...answers }; if (value === undefined || (Array.isArray(value) && value.length === 0)) { delete next[name]; } else { next[name] = value; } commitAnswers(next); // Answering clears both a recorded skip and a failed validation: the // reason for either has just stopped being true. setSkipped((previous) => { if (!previous.has(name)) return previous; const copy = new Set(previous); copy.delete(name); return copy; }); setInvalid((previous) => { if (!previous.has(name)) return previous; const copy = new Set(previous); copy.delete(name); return copy; }); emitStatus(name, isAnswered(next[name]) ? 'answered' : 'unanswered'); }, [answers, commitAnswers, emitStatus] ); const toggleAnswer = useCallback( (name: string, value: string, multiple: boolean) => { if (!multiple) { // Pressing the selected answer again clears it, which is the only way // to undo an answer to an optional question without a Skip button. setAnswer(name, answers[name] === value ? undefined : value); return; } const currentValue = answers[name]; const list = Array.isArray(currentValue) ? currentValue : []; setAnswer( name, list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value] ); }, [answers, setAnswer] ); const moveTo = useCallback( (index: number) => { const target = enabled[index]; if (!target) return; if (!isItemControlled) setInternalItem(target.name); onItemChange?.(target.name); }, [enabled, isItemControlled, onItemChange] ); /** A required question is the only thing that blocks. */ const validate = useCallback( (entry: ResolvedItem | null): boolean => { if (!entry || !entry.required) return true; if (isAnswered(answers[entry.name])) return true; setInvalid((previous) => new Set(previous).add(entry.name)); return false; }, [answers] ); const goNext = useCallback(() => { if (!validate(activeItem)) return; moveTo(activeIndex + 1); }, [validate, activeItem, moveTo, activeIndex]); // Going back never validates: the way out of a question you cannot answer // must not be the same door you came in by. const goBack = useCallback(() => moveTo(activeIndex - 1), [moveTo, activeIndex]); const skip = useCallback(() => { if (!activeItem || activeItem.required) return; setAnswer(activeItem.name, undefined); setSkipped((previous) => new Set(previous).add(activeItem.name)); emitStatus(activeItem.name, 'skipped'); if (!last) moveTo(activeIndex + 1); }, [activeItem, setAnswer, emitStatus, last, moveTo, activeIndex]); const submit = useCallback(() => { const failed = enabled.filter((entry) => entry.required && !isAnswered(answers[entry.name])); if (failed.length > 0) { setInvalid((previous) => { const copy = new Set(previous); failed.forEach((entry) => copy.add(entry.name)); return copy; }); // Take them to the first question that is missing an answer rather than // leaving them on the last one wondering which of the others it was. const index = enabled.findIndex((entry) => entry.name === failed[0]!.name); if (index !== activeIndex) moveTo(index); return; } onSubmit?.(answers); }, [enabled, answers, activeIndex, moveTo, onSubmit]); /* * Whether the way on is shut, as against whether it has been *tried* — the * error under the question needs somebody to have pressed the button, but * the button's own look must not, or it would read as ready right up until * it refused. */ const blocked = !!activeItem?.required && !isAnswered(answers[activeItem.name]); /* * With no title the header strip has nothing on it but the ring, and a ring * centred on an empty strip is a widget that will not say what it is asking. * The question stands in for the title instead — it is the only line that * names the thing, and a questionnaire that has not been given a title is * one where the question is the title. * * It takes the title's place exactly, at the leading edge with the ring * still at the trailing one, so a titled questionnaire and an untitled one * draw the same strip. Two arrangements of the same two things would make * the header look like it meant something different in each. * * The trade is that the question no longer travels with the pane: the strip * stays put while the answers slide under it. That is the right way round * for a label, and the wrong way round for a question that is meant to * arrive with its answers — which is why giving it a title turns this off. */ const questionOnStrip = frame && !titleNode; const stripQuestion = useMemo( () => (questionOnStrip ? questionOf(activeItem?.element) : null), [questionOnStrip, activeItem] ); const context = useMemo( () => ({ current, total, first, last, activeName, activeItem, answers, statusOf, invalid, setAnswer, toggleAnswer, goNext, goBack, skip, submit, shortcuts: shortcuts ?? null, framed: frame, questionOnStrip, blocked, }), [ current, total, first, last, activeName, activeItem, answers, statusOf, invalid, setAnswer, toggleAnswer, goNext, goBack, skip, submit, shortcuts, frame, questionOnStrip, blocked, ] ); const body = ( ); /* * A little more room under the strip than the `inset` header takes by * default. That default is set for a line of text; the ring is a shape whose * stroke reaches the edge of its box, with none of the leading a line of * text has, so against the same padding it sits closer to the panel than the * title beside it does. * * With no title, the progress centres rather than staying hard right. Right * is where it belongs when it is the trailing half of a pair; on its own at * the end of an otherwise empty strip it reads as something left over * rather than as the strip's subject. */ const header = ( {titleNode ?? textChildren(stripQuestion, (text) => ( // One line, and it truncates rather than wrapping: the strip is a // label for the panel under it, and a label that grows to two lines // pushes the answers down the screen every time a longer question // comes round. {text} ))} {progressNode ? {progressNode} : null} ); return ( {frame ? ( /* * The panel floats in a recessed band rather than sitting flush in a * tray, and the band is where the actions go. A questionnaire is a * screen's worth of one decision repeated — answer, then move — and * the band draws that row as equal pills clear of the card the * question is written on, which is the distinction the old footer * section inside the panel did not make. */ {header} {body} {footerNode ? ( // Tighter than the band's own gap. That one is set for two or // three equal pills; this row is three actions of three different // widths, and every point between them comes off the longest label. {bandActions(footerNode)} ) : null} ) : ( {titleNode || progressNode ? ( // The same rule as the framed strip: paired with a title it sits // at the trailing edge, alone it centres. {titleNode} {progressNode} ) : null} {body} {footerNode} )} ); } QuestionnaireRoot.displayName = 'Questionnaire'; interface QuestionnaireBodyProps { activeName: string | null; activeItem: ResolvedItem | null; /** Position of the active question, which is what says which way a move went. */ activeIndex: number; framed: boolean; swipeable: boolean; canAdvance: boolean; canRetreat: boolean; onNext: () => void; onBack: () => void; } /** * The middle of the questionnaire: the one mounted question, the height it * animates to, and the drag that moves between them. */ function QuestionnaireBody({ activeName, activeItem, activeIndex, framed, swipeable, canAdvance, canRetreat, onNext, onBack, }: QuestionnaireBodyProps) { const slots = questionnaireVariants({ framed }); const [width, setWidth] = useState(0); // -1 means nothing has been measured yet, so the first question takes its // height outright instead of growing into it from nothing. const height = useSharedValue(-1); const drag = useSharedValue(0); const paneRef = useRef(null); /* * Until the first question has been measured the pane stays in the flow, so * the body has a real height from the moment it first lays out. Absolute * from the start would measure zero on that first pass — the container's * only child would contribute nothing to it — and anything sizing itself to * this content would take the zero and keep it. A sheet set to wrap its * content is exactly that, and it would open around a question nobody can * see. Once the height is known the pane goes absolute, which is what lets * the question leaving and the one arriving overlap. */ const [measured, setMeasured] = useState(false); /* * Which way the question slides in from, worked out from the move itself * rather than from whatever triggered it. A button, a swipe and a caller * setting `item` directly are all the same move to the reader, and only the * change in position says which direction it went. * * Read during render, not in an effect: the arriving question's animation is * fixed when it mounts, and an effect runs after that — which would leave * every transition playing the direction of the one before it. */ const previousIndex = useRef(activeIndex); const direction: 1 | -1 = activeIndex >= previousIndex.current ? 1 : -1; const navigated = useRef(false); useEffect(() => { if (activeIndex !== previousIndex.current) { previousIndex.current = activeIndex; navigated.current = true; } }, [activeIndex]); const navigate = useCallback( (delta: 1 | -1) => { if (delta === 1) onNext(); else onBack(); }, [onNext, onBack] ); useEffect(() => { drag.value = 0; }, [activeName, drag]); // Move the reader onto the question that just arrived, so a screen reader // reads the new question rather than leaving focus where the button was. useEffect(() => { if (!navigated.current) return; const node = paneRef.current; if (!node) return; const tag = findNodeHandle(node); if (tag != null) AccessibilityInfo.setAccessibilityFocus(tag); }, [activeName]); const onPaneLayout = useCallback( (event: LayoutChangeEvent) => { const next = event.nativeEvent.layout.height; if (next <= 0) return; if (measured) { height.value = withTiming(next, { duration: HEIGHT_DURATION, easing: EASE }); return; } // The first measurement is taken outright: there is no previous height // to travel from, and animating in from nothing is a question that // unfurls on arrival rather than one that is simply there. height.value = next; setMeasured(true); }, [measured, height] ); const heightStyle = useAnimatedStyle(() => height.value < 0 ? {} : { height: height.value } ); const dragStyle = useAnimatedStyle(() => ({ transform: [{ translateX: drag.value }], })); const pan = useMemo( () => Gesture.Pan() // A vertical scroll must always win: the questionnaire sits in a page // that scrolls, and a drag that is even slightly vertical belongs to it. .activeOffsetX([-12, 12]) .failOffsetY([-8, 8]) .enabled(swipeable && width > 0) .onUpdate((event) => { const forward = event.translationX < 0; if ((forward && !canAdvance) || (!forward && !canRetreat)) { // Nowhere to go this way — let it move a little so the drag is // acknowledged, then stop. drag.value = event.translationX * 0.2; return; } drag.value = event.translationX; }) .onEnd((event) => { const threshold = width * SWIPE_FRACTION; if (event.translationX < -threshold && canAdvance) { runOnJS(navigate)(1); } else if (event.translationX > threshold && canRetreat) { runOnJS(navigate)(-1); } // Springs back either way. When the move is taken the question is // replaced outright, so this only ever shows on a move that was not. drag.value = withSpring(0, { damping: 20, stiffness: 220, mass: 0.6 }); }), [swipeable, width, canAdvance, canRetreat, drag, navigate] ); /* * The question arrives from the side it is coming from, over a fraction of * the width rather than the whole of it — this is a widget in a page, not a * screen, and a slide the full width of it reads as the page moving. * * Written out rather than assembled from the stock builders because those * carry an initial opacity and nothing else: the distance is the part worth * controlling here, and none of them lets it be set. */ const offset = width > 0 ? width * SLIDE_FRACTION : 60; const entering = useCallback(() => { 'worklet'; return { initialValues: { opacity: 0, transform: [{ translateX: direction * offset }] }, animations: { opacity: withTiming(1, { duration: ENTER_DURATION, easing: EASE }), transform: [{ translateX: withTiming(0, { duration: ENTER_DURATION, easing: EASE }) }], }, }; }, [direction, offset]); const exiting = FadeOut.duration(EXIT_DURATION); return ( setWidth(event.nativeEvent.layout.width)} > {/* * Two views, not one: the entering animation drives this one's * transform, so the drag needs a view of its own underneath it rather * than a second transform on the same style the animation is writing. */} {activeItem?.element ?? null} ); } export interface QuestionnaireTitleProps extends ViewProps { className?: string; children?: ReactNode; } /** * Names the questionnaire as a whole, in the frame's header strip. The * current question's own prompt is `Questionnaire.Question`. */ function QuestionnaireTitle({ className, children, ...props }: QuestionnaireTitleProps) { return ( {textChildren(children, (text) => ( {text} ))} ); } QuestionnaireTitle.displayName = 'Questionnaire.Title'; /** What a custom progress indicator is told about where the reader is. */ export interface QuestionnaireProgressState { /** One-based position of the active question. */ current: number; /** How many questions are enabled. */ total: number; first: boolean; last: boolean; } /** How the position is drawn. */ export type QuestionnaireProgressVariant = 'ring' | 'pips' | 'numbers' | 'count'; export interface QuestionnaireProgressProps { className?: string; /** * `ring` is an arc that sweeps round as the reader advances — how far * through the set they are, without saying how many questions there are. * It is the only one that holds its size and its meaning at any length, * which is why it is the default. * * `pips` is a bar per question, filled up to the one being asked and widened * on it. `numbers` counts them out instead, which is what you want when the * reader will be sent back to a particular question. `count` is the plain * `Question 2 of 5`. * * `pips` and `numbers` fall back to `count` past eight questions, where * neither is countable at a glance any more. `ring` never does. */ variant?: QuestionnaireProgressVariant; /** * Replace the indicator entirely. Given a function, it is called with the * position — for a bar, a row of dots, or a percentage. */ children?: ReactNode | ((state: QuestionnaireProgressState) => ReactNode); } const AnimatedCircle = Animated.createAnimatedComponent(Circle); /** * How far through the set, as an arc. * * A fraction rather than a count, which is what lets one ring stand for three * questions or thirty: the marks that name each question individually stop * being countable somewhere around eight, and this never has that problem. * What it gives up is any answer to "how many left" — reach for `numbers` * where that matters. * * Drawn with `strokeDasharray` rather than `strokeDashoffset`: a dash the * length of the filled arc and a gap the length of the rest leaves exactly one * visible stroke, and its length is the only number that has to be animated. * The circle is turned back a quarter because a stroke starts at three * o'clock, and an arc that begins there reads as a gauge already part-way * along. */ function ProgressRing({ current, total }: { current: number; total: number }) { const reduceMotion = useReducedMotion(); const tokens = useCSSVariable(['--color-primary', '--color-border']); // Narrowed on the way out because `useCSSVariable` resolves to a number for // any token that happens to be one. const fillColor = typeof tokens[0] === 'string' ? tokens[0] : 'rgb(120,120,255)'; const trackColor = typeof tokens[1] === 'string' ? tokens[1] : 'rgba(128,128,128,0.2)'; const radius = (RING_SIZE - RING_STROKE) / 2; const circumference = 2 * Math.PI * radius; const centre = RING_SIZE / 2; const fraction = total > 0 ? Math.min(Math.max(current / total, 0), 1) : 0; const filled = useSharedValue(fraction); useEffect(() => { filled.value = reduceMotion ? fraction : withTiming(fraction, { duration: PIP_DURATION, easing: EASE }); }, [fraction, reduceMotion, filled]); const arc = useAnimatedProps(() => ({ strokeDasharray: [circumference * filled.value, circumference], })); return ( ); } /** * One question's worth of the track. Filled once it has been reached, and the * one being asked is drawn wider than the rest so the reader's place in the * set is legible without counting. */ function ProgressPip({ filled, active }: { filled: boolean; active: boolean }) { const fill = useSharedValue(filled ? 1 : 0); useEffect(() => { fill.value = withTiming(filled ? 1 : 0, { duration: PIP_DURATION, easing: EASE }); }, [filled, fill]); const fillStyle = useAnimatedStyle(() => ({ opacity: fill.value })); return ( ); } /** * The same position, counted out. A number says which question this is in a * way a bar cannot — worth it where the reader is going to be asked to go back * to one of them, since a bar gives them nothing to go back *to*. */ function ProgressNumber({ value, done, active, }: { value: number; done: boolean; active: boolean; }) { return ( {value} ); } /** Where the reader is in the set, announced as a progress bar. */ function QuestionnaireProgress({ className, variant = 'ring', children, }: QuestionnaireProgressProps) { const { current, total, first, last } = useQuestionnaire('Questionnaire.Progress'); const label = `Question ${current} of ${total}`; /* * Marks while they can still be counted, the count itself once they cannot. * Twenty of either is a texture rather than a number, and the text says the * same thing in less room. The ring is exempt: it never claimed to be * countable, so there is nothing for a long set to take away from it. */ const drawable = variant !== 'count' && variant !== 'ring' && total > 0 && total <= MAX_PIPS; const fallback = variant === 'ring' ? ( ) : drawable ? ( {Array.from({ length: total }, (_, index) => variant === 'numbers' ? ( ) : ( ) )} ) : ( {label} ); const content = typeof children === 'function' ? children({ current, total, first, last }) : (children ?? fallback); return ( {textChildren(content, (text) => ( {text} ))} ); } QuestionnaireProgress.displayName = 'Questionnaire.Progress'; export interface QuestionnaireItemProps extends Omit { className?: string; /** Unique name — the key this question's answer is stored under. */ name: string; /** Blocks the way forward until it has an answer. */ required?: boolean; /** Accepts more than one answer, so its answer is an array. */ multiple?: boolean; /** Left out of the count and never navigated to. */ disabled?: boolean; /** Mark the question at fault from a validator of your own. */ invalid?: boolean; /** Called whenever this question moves between unanswered, answered and skipped. */ onStatusChange?: (status: QuestionnaireItemStatus) => void; children?: ReactNode; } /** * One question. Only the active one is mounted, so anything it holds is built * when it is reached and thrown away when it is left. */ function QuestionnaireItem({ className, name, required, multiple, invalid: invalidProp, children, // Read by the root off this element rather than used here. disabled: _disabled, onStatusChange: _onStatusChange, ...props }: QuestionnaireItemProps) { const { invalid: invalidNames } = useQuestionnaire('Questionnaire.Item'); /* * Collected so a typed answer can be told from a chosen one: whatever the * question is holding that is not one of these values came from the text * field, and that is what puts it back in the field on the way back. */ const choiceValues = useMemo(() => { const values = new Set(); const walk = (node: ReactNode) => { Children.forEach(node, (child) => { if (!isValidElement(child)) return; if (child.type === QuestionnaireChoice) { values.add((child.props as QuestionnaireChoiceProps).value); return; } walk((child.props as { children?: ReactNode }).children); }); }; walk(children); return values; }, [children]); const context = useMemo( () => ({ name, required: !!required, multiple: !!multiple, invalid: !!invalidProp || invalidNames.has(name), choiceValues, }), [name, required, multiple, invalidProp, invalidNames, choiceValues] ); return ( {textChildren(children)} ); } QuestionnaireItem.displayName = 'Questionnaire.Item'; export interface QuestionnaireQuestionProps extends ViewProps { className?: string; children?: ReactNode; } /** The question being asked. */ function QuestionnaireQuestion({ className, children, ...props }: QuestionnaireQuestionProps) { const { questionOnStrip } = useQuestionnaire('Questionnaire.Question'); // Drawn on the header strip instead, where an untitled questionnaire uses it // as its label. Rendering here as well would ask the same thing twice. if (questionOnStrip) return null; return ( {textChildren(children, (text) => ( // `text-pretty` rather than a hard wrap: a question is a sentence, and // the one thing worse than two lines is a second line holding one word. {text} ))} ); } QuestionnaireQuestion.displayName = 'Questionnaire.Question'; export interface QuestionnaireDescriptionProps extends ViewProps { className?: string; children?: ReactNode; } /** A line under the question — what to consider, or that it can be skipped. */ function QuestionnaireDescription({ className, children, ...props }: QuestionnaireDescriptionProps) { return ( // Pulled up against the question it belongs to: the pane's gap is the // distance between one part and the next, and these two are one part. {textChildren(children, (text) => ( {text} ))} ); } QuestionnaireDescription.displayName = 'Questionnaire.Description'; export interface QuestionnaireChoicesProps extends Omit { className?: string; children?: ReactNode; } /** * The answers to a question. It hands each choice its shortcut badge, counting * only the ones that can be picked so a disabled answer does not take a letter * out of the sequence with it. */ function QuestionnaireChoices({ className, children, ...props }: QuestionnaireChoicesProps) { const { shortcuts } = useQuestionnaire('Questionnaire.Choices'); const slots = questionnaireVariants(); const badged = useMemo(() => { if (!shortcuts) return children; let index = 0; return Children.map(children, (child) => { if (!isValidElement(child) || child.type !== QuestionnaireChoice) return child; if ((child.props as QuestionnaireChoiceProps).disabled) return child; const key = shortcutAt(shortcuts, index++); if (!key) return child; return ( {child} ); }); }, [children, shortcuts]); return ( {textChildren(badged)} ); } QuestionnaireChoices.displayName = 'Questionnaire.Choices'; export interface QuestionnaireChoiceProps { className?: string; /** The value recorded when this answer is picked. */ value: string; /** The answer itself. */ label?: string; /** A line under the label, for an answer that needs explaining. */ description?: string; disabled?: boolean; children?: ReactNode; } /** * One fixed answer — the whole row is the target, with the indicator reading * as confirmation rather than as the thing to aim at. */ const QuestionnaireChoice = forwardRef( ({ className, value, label, description, disabled, children }, ref) => { const { answers, toggleAnswer } = useQuestionnaire('Questionnaire.Choice'); const item = useQuestionnaireItem('Questionnaire.Choice'); const shortcut = useContext(ShortcutContext); const answer = answers[item.name]; const selected = Array.isArray(answer) ? answer.includes(value) : answer === value; const progress = useSharedValue(selected ? 1 : 0); useEffect(() => { progress.value = selected ? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 }) : withTiming(0, { duration: 120 }); }, [selected, progress]); const markStyle = useAnimatedStyle(() => ({ opacity: progress.value, transform: [{ scale: progress.value }], })); const checkColor = useCSSVariable('--color-primary-foreground'); const slots = choiceVariants({ multiple: item.multiple, selected, disabled: !!disabled, invalid: item.invalid && !selected, }); const labelled = label ? ( {label} ) : ( textChildren(children, (text) => {text}) ); return ( toggleAnswer(item.name, value, item.multiple)} className={slots.row({ className })} > {item.multiple ? ( ) : ( )} {labelled} {description ? {description} : null} {shortcut ? ( {shortcut} ) : null} ); } ); QuestionnaireChoice.displayName = 'Questionnaire.Choice'; export interface QuestionnaireInputProps extends Omit { className?: string; } /** * An answer that is not on the list. It holds whatever the question is * answered with that none of its own choices offers, so picking a choice * empties it and typing clears the choice — one answer to one question, with * neither part having to know about the other. */ const QuestionnaireInput = forwardRef( ({ className, ...props }, ref) => { const { answers, setAnswer } = useQuestionnaire('Questionnaire.Input'); const item = useQuestionnaireItem('Questionnaire.Input'); const answer = answers[item.name]; const freeform = useMemo(() => { if (Array.isArray(answer)) { return answer.find((entry) => !item.choiceValues.has(entry)) ?? ''; } return typeof answer === 'string' && !item.choiceValues.has(answer) ? answer : ''; }, [answer, item.choiceValues]); const onChangeText = useCallback( (text: string) => { if (!item.multiple) { setAnswer(item.name, text); return; } // Replace this question's one typed entry, leaving every picked one alone. const list = Array.isArray(answer) ? answer : []; const fixed = list.filter((entry) => item.choiceValues.has(entry)); setAnswer(item.name, text.length > 0 ? [...fixed, text] : fixed); }, [item.multiple, item.name, item.choiceValues, answer, setAnswer] ); return ( ); } ); QuestionnaireInput.displayName = 'Questionnaire.Input'; export interface QuestionnaireErrorProps extends ViewProps { className?: string; /** Replace the default message. */ children?: ReactNode; } /** Why the way forward is closed. Nothing until the question fails to pass. */ function QuestionnaireError({ className, children, ...props }: QuestionnaireErrorProps) { const item = useQuestionnaireItem('Questionnaire.Error'); const slots = questionnaireVariants(); if (!item.invalid) return null; return ( {textChildren(children ?? DEFAULT_ERROR, (text) => ( {text} ))} ); } QuestionnaireError.displayName = 'Questionnaire.Error'; export interface QuestionnaireFooterProps extends ViewProps { className?: string; children?: ReactNode; } /** * The action row, in its own section at the foot of the panel. It stays put * while the question above it changes, which is what keeps the button under * the thumb where it was. */ function QuestionnaireFooter({ className, children, ...props }: QuestionnaireFooterProps) { const { framed } = useQuestionnaire('Questionnaire.Footer'); const slots = questionnaireVariants({ framed }); return ( {textChildren(children)} ); } QuestionnaireFooter.displayName = 'Questionnaire.Footer'; /** What a navigation button is told about the question it is acting on. */ export interface QuestionnaireActionState { /** Whether the action applies to the active question at all. */ visible: boolean; /** Where the active question stands. */ status: QuestionnaireItemStatus; } export interface QuestionnaireActionProps extends Omit { /** Replace the label. Given a function, it is called with the question's state. */ children?: ReactNode | ((state: QuestionnaireActionState) => ReactNode); } /** * Builds one of the four navigation buttons. They differ only in when they * apply, what they do and how they are labelled by default, so they are made * rather than written out four times. */ interface ActionConfig { displayName: string; label: string; variant: NonNullable; /** A chevron on the buttons that move, and nothing on the ones that do not. */ startContent?: ReactNode; endContent?: ReactNode; /** * Dim this one while the active question is required and unanswered. * * It stays pressable on purpose. A disabled button says no without saying * why, and on a question whose answers have scrolled out of view that is the * whole of the feedback; pressing this one puts the reason under the * question instead. Dimming is what stops it promising something it will not * do — the look says not yet, the press says why not. */ dimWhenBlocked?: boolean; use: (context: QuestionnaireContextValue) => { visible: boolean; onPress: () => void }; } function createAction({ displayName, label: fallbackLabel, variant: fallbackVariant, startContent, endContent, dimWhenBlocked, use, }: ActionConfig) { function Action({ children, variant, className, ...props }: QuestionnaireActionProps) { const context = useQuestionnaire(displayName); const { visible, onPress } = use(context); const status = context.activeName ? context.statusOf(context.activeName) : 'unanswered'; // Not rendered at all rather than hidden: React Native has no `inert`, and // a button left in the tree is one a screen reader still offers. if (!visible) return null; const label = typeof children === 'function' ? children({ visible, status }) : children; const dimmed = !!dimWhenBlocked && context.blocked; return ( ); } Action.displayName = displayName; return Action; } /** Back to the previous question. Absent on the first one. */ const QuestionnaireBack = createAction({ displayName: 'Questionnaire.Back', label: 'Back', variant: 'ghost', startContent: , use: (context) => ({ visible: !context.first, onPress: context.goBack }), }); /** Records that an optional question was deliberately left out. */ const QuestionnaireSkip = createAction({ displayName: 'Questionnaire.Skip', label: 'Skip', variant: 'ghost', use: (context) => ({ visible: !!context.activeItem && !context.activeItem.required, onPress: context.skip, }), }); /** On to the next question, if the current one lets go. Absent on the last. */ const QuestionnaireNext = createAction({ displayName: 'Questionnaire.Next', label: 'Continue', variant: 'primary', endContent: , dimWhenBlocked: true, use: (context) => ({ visible: !context.last, onPress: context.goNext }), }); /** Hands over every answer. Only on the last question. */ const QuestionnaireSubmit = createAction({ displayName: 'Questionnaire.Submit', label: 'Submit', variant: 'primary', dimWhenBlocked: true, use: (context) => ({ visible: context.last, onPress: context.submit }), }); /** * A flexible gap for the footer, so the trailing buttons sit against the * trailing edge whether or not `Questionnaire.Back` is showing. */ function QuestionnaireSpacer({ className, ...props }: ViewProps) { return ; } QuestionnaireSpacer.displayName = 'Questionnaire.Spacer'; export const Questionnaire = Object.assign(QuestionnaireRoot, { Title: QuestionnaireTitle, Progress: QuestionnaireProgress, Item: QuestionnaireItem, Question: QuestionnaireQuestion, Description: QuestionnaireDescription, Choices: QuestionnaireChoices, Choice: QuestionnaireChoice, Input: QuestionnaireInput, Error: QuestionnaireError, Footer: QuestionnaireFooter, Spacer: QuestionnaireSpacer, Back: QuestionnaireBack, Skip: QuestionnaireSkip, Next: QuestionnaireNext, Submit: QuestionnaireSubmit, });