/** * 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 { type ReactNode } from 'react'; import { View, type TextInput, type ViewProps } from 'react-native'; import { type ButtonProps } from '../button/index.js'; import { type InputProps } from '../input/index.js'; /** 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; } 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; } declare function QuestionnaireRoot({ className, items, item: itemProp, defaultItem, onItemChange, answers: answersProp, defaultAnswers, onAnswersChange, onSubmit, shortcuts, swipeable, frame, children, ...props }: QuestionnaireProps): import("react").JSX.Element; declare namespace QuestionnaireRoot { var displayName: string; } 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`. */ declare function QuestionnaireTitle({ className, children, ...props }: QuestionnaireTitleProps): import("react").JSX.Element; declare namespace QuestionnaireTitle { var displayName: string; } /** 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); } /** Where the reader is in the set, announced as a progress bar. */ declare function QuestionnaireProgress({ className, variant, children, }: QuestionnaireProgressProps): import("react").JSX.Element; declare namespace QuestionnaireProgress { var displayName: string; } 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. */ declare function QuestionnaireItem({ className, name, required, multiple, invalid: invalidProp, children, disabled: _disabled, onStatusChange: _onStatusChange, ...props }: QuestionnaireItemProps): import("react").JSX.Element; declare namespace QuestionnaireItem { var displayName: string; } export interface QuestionnaireQuestionProps extends ViewProps { className?: string; children?: ReactNode; } /** The question being asked. */ declare function QuestionnaireQuestion({ className, children, ...props }: QuestionnaireQuestionProps): import("react").JSX.Element | null; declare namespace QuestionnaireQuestion { var displayName: string; } export interface QuestionnaireDescriptionProps extends ViewProps { className?: string; children?: ReactNode; } /** A line under the question — what to consider, or that it can be skipped. */ declare function QuestionnaireDescription({ className, children, ...props }: QuestionnaireDescriptionProps): import("react").JSX.Element; declare namespace QuestionnaireDescription { var displayName: string; } 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. */ declare function QuestionnaireChoices({ className, children, ...props }: QuestionnaireChoicesProps): import("react").JSX.Element; declare namespace QuestionnaireChoices { var displayName: string; } 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; } export interface QuestionnaireInputProps extends Omit { className?: string; } 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. */ declare function QuestionnaireError({ className, children, ...props }: QuestionnaireErrorProps): import("react").JSX.Element | null; declare namespace QuestionnaireError { var displayName: string; } 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. */ declare function QuestionnaireFooter({ className, children, ...props }: QuestionnaireFooterProps): import("react").JSX.Element; declare namespace QuestionnaireFooter { var displayName: string; } /** 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); } /** * A flexible gap for the footer, so the trailing buttons sit against the * trailing edge whether or not `Questionnaire.Back` is showing. */ declare function QuestionnaireSpacer({ className, ...props }: ViewProps): import("react").JSX.Element; declare namespace QuestionnaireSpacer { var displayName: string; } export declare const Questionnaire: typeof QuestionnaireRoot & { Title: typeof QuestionnaireTitle; Progress: typeof QuestionnaireProgress; Item: typeof QuestionnaireItem; Question: typeof QuestionnaireQuestion; Description: typeof QuestionnaireDescription; Choices: typeof QuestionnaireChoices; Choice: import("react").ForwardRefExoticComponent>; Input: import("react").ForwardRefExoticComponent>; Error: typeof QuestionnaireError; Footer: typeof QuestionnaireFooter; Spacer: typeof QuestionnaireSpacer; Back: { ({ children, variant, className, ...props }: QuestionnaireActionProps): import("react").JSX.Element | null; displayName: string; }; Skip: { ({ children, variant, className, ...props }: QuestionnaireActionProps): import("react").JSX.Element | null; displayName: string; }; Next: { ({ children, variant, className, ...props }: QuestionnaireActionProps): import("react").JSX.Element | null; displayName: string; }; Submit: { ({ children, variant, className, ...props }: QuestionnaireActionProps): import("react").JSX.Element | null; displayName: string; }; }; export {}; //# sourceMappingURL=index.d.ts.map