@welshare/questionnaire
v0.3.0
Published
FHIR Questionnaire components for React with state management and validation
Downloads
40
Maintainers
Readme
@welshare/questionnaire
FHIR R5 Questionnaire components for React with state management, validation, and theming.
Installation
npm install @welshare/questionnaireQuick Start
import { useState, useEffect } from "react";
import {
QuestionnaireProvider,
useQuestionnaire,
QuestionRenderer,
getVisiblePages,
type Questionnaire,
} from "@welshare/questionnaire";
import "@welshare/questionnaire/tokens.css";
import "@welshare/questionnaire/styles.css";
function QuestionnairePage() {
const { questionnaire, isPageValid } = useQuestionnaire();
const pages = getVisiblePages(questionnaire);
const [currentPageIndex, setCurrentPageIndex] = useState(0);
const currentPage = pages[currentPageIndex];
return (
<div>
<h1>{questionnaire.title}</h1>
{currentPage?.item?.map((item) => (
<QuestionRenderer key={item.linkId} item={item} />
))}
<button
onClick={() => setCurrentPageIndex(currentPageIndex + 1)}
disabled={!isPageValid(currentPage?.item || [])}
>
Next
</button>
</div>
);
}
function App() {
const [questionnaire, setQuestionnaire] = useState<Questionnaire | null>(
null
);
useEffect(() => {
fetch("/api/questionnaire/your-id")
.then((res) => res.json())
.then(setQuestionnaire);
}, []);
if (!questionnaire) return <div>Loading...</div>;
return (
<QuestionnaireProvider questionnaire={questionnaire}>
<QuestionnairePage />
</QuestionnaireProvider>
);
}API
QuestionnaireProvider
Props:
questionnaire: Questionnaire- FHIR Questionnaire objectquestionnaireId?: string- Optional ID override (defaults toquestionnaire.id)useNestedStructure?: boolean- Nested or flat response structure (default:true)
useQuestionnaire Hook
const {
questionnaire,
response,
updateAnswer,
updateMultipleAnswers,
getAnswer,
getAnswers,
isPageValid,
getRequiredQuestions,
getUnansweredRequiredQuestions,
markValidationErrors,
clearValidationErrors,
hasValidationError,
debugMode,
toggleDebugMode,
} = useQuestionnaire();QuestionRenderer
Props:
item: QuestionnaireItem- Questionnaire item to renderclassName?: string- Container CSS classesinputClassName?: string- Input CSS classeschoiceClassName?: string- Choice option CSS classeschoiceLayout?: ChoiceLayout-"stacked"(default) or"inline-wrap"(horizontal chip layout)renderRadioInput?: (props: RadioInputProps) => ReactNode- Custom radio rendererrenderCheckboxInput?: (props: CheckboxInputProps) => ReactNode- Custom checkbox renderer
Supported Types: coding, boolean, integer, decimal, string, text, quantity
Choice Questions with Custom Text Answers
Choice fields can optionally support custom user text input alongside coded options when you set answerConstraint: "optionsOrString" in the FHIR questionnaire definition:
{
"linkId": "referral-source",
"text": "How did you hear about us?",
"type": "coding",
"answerConstraint": "optionsOrString",
"answerOption": [
{ "valueCoding": { "code": "search", "display": "Search engine" } },
{ "valueCoding": { "code": "social", "display": "Social media" } }
]
}This renders coded options with an "Other" text field. For single-select (repeats: false), selecting a coded option or entering text is mutually exclusive. For multi-select (repeats: true), coded selections and free text coexist in the answer array.
BmiForm
A controlled component for collecting height, weight, and calculating BMI. The component acts as an input helper that manages BMI calculation internally and reports changes to the parent via callbacks. Unit system (metric/imperial) is managed internally and automatically clears all fields when switched.
Props:
height: number- Current height value (0 when empty)weight: number- Current weight value (0 when empty)bmi: number- Current BMI value (calculated and set by the component, 0 when not calculated)onHeightChange: (value: number, unit: "cm" | "in") => void- Called when height changes, includes current unitonWeightChange: (value: number, unit: "kg" | "lb") => void- Called when weight changes, includes current unitonBmiChange: (value: number) => void- Called when BMI is calculated or clearedclassName?: string- Optional CSS classes
Features:
- Controlled numeric values (height, weight, bmi) managed by parent
- BMI calculation handled internally by the component
- Unit system managed internally (defaults to metric)
- Automatically clears all fields (sets to 0) when switching unit systems
- No unit conversion - values are cleared on unit system change
- Uses consistent styling with questionnaire components
Example:
import { useState } from "react";
import { BmiForm, getBmiCategory } from "@welshare/questionnaire";
import "@welshare/questionnaire/tokens.css";
import "@welshare/questionnaire/styles.css";
function MyComponent() {
const [height, setHeight] = useState(0);
const [weight, setWeight] = useState(0);
const [bmi, setBmi] = useState(0);
const handleBmiChange = (value: number) => {
setBmi(value);
// Optional: Get BMI category
if (value) {
const category = getBmiCategory(value);
console.log(`BMI Category: ${category}`);
}
};
return (
<BmiForm
height={height}
weight={weight}
bmi={bmi}
onHeightChange={(value, unit) => setHeight(value)}
onWeightChange={(value, unit) => setWeight(value)}
onBmiChange={handleBmiChange}
/>
);
}Quantity Questions with Unit Selection
Quantity questions allow users to enter numeric values with unit selection. Use the standard FHIR questionnaire-unitOption extension to define available units:
const waistCircumferenceItem = {
linkId: "waist",
type: "quantity",
text: "What is your waist circumference?",
required: false,
code: [
{
system: "http://loinc.org",
code: "8280-0",
display: "Waist circumference at umbilicus by tape measure",
},
],
extension: [
{
url: "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption",
valueCoding: {
system: "http://unitsofmeasure.org",
code: "cm",
display: "cm",
},
},
{
url: "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption",
valueCoding: {
system: "http://unitsofmeasure.org",
code: "[in_i]",
display: "in",
},
},
],
};Features:
- Automatic unit toggle when multiple units are defined
- Single unit display when only one unit is provided
- Fallback to simple decimal input when no units are configured
- Value automatically clears when switching units
Response Format:
{
"linkId": "waist",
"answer": [
{
"valueQuantity": {
"value": 85,
"unit": "cm",
"system": "http://unitsofmeasure.org",
"code": "cm"
}
}
]
}LegalConsentForm
A self-contained form component for collecting user consent before data submission. This component handles:
- Terms & Conditions and Privacy Policy (required) - User must accept to proceed
- Study invitation notifications (optional) - User can opt in to receive study invitations
Usage Pattern: This component provides the content for a consent dialog. Applications should wrap this component in their own Dialog/Modal component (e.g., from shadcn/ui, Radix UI, MUI, etc.), similar to how BmiForm is used with input helpers.
Props:
initialValues?: Partial<LegalConsentResult>- Initial consent state (for restoring previous preferences)documentLinks?: LegalDocumentLinks- Custom URLs for Terms & Privacy documentsonConfirm?: (result: LegalConsentResult) => void- Called when user confirms (only if terms accepted)onCancel?: () => void- Called when user cancels/declinesonChange?: (result: LegalConsentResult) => void- Called on any checkbox change (real-time updates)renderCheckbox?: (props: LegalCheckboxProps) => ReactNode- Custom checkbox component rendererconfirmButtonLabel?: string- Custom confirm button text (default: "Confirm & Continue")cancelButtonLabel?: string- Custom cancel button text (default: "Cancel")className?: string- Additional CSS classes
Result Object:
interface LegalConsentResult {
termsAccepted: boolean; // Required - T&Cs and Privacy Policy
studyConsentAccepted: boolean; // Optional - Study invitations
}Example with Dialog:
import { useState } from "react";
import {
LegalConsentForm,
type LegalConsentResult,
} from "@welshare/questionnaire";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import "@welshare/questionnaire/tokens.css";
import "@welshare/questionnaire/styles.css";
function DataSubmissionFlow() {
const [showConsent, setShowConsent] = useState(false);
const handleSubmitData = () => {
// Show consent dialog before submitting data
setShowConsent(true);
};
const handleConsent = (result: LegalConsentResult) => {
console.log("Terms accepted:", result.termsAccepted);
console.log("Study consent:", result.studyConsentAccepted);
// Proceed with data submission
submitDataToServer(result);
setShowConsent(false);
};
return (
<>
<button onClick={handleSubmitData}>Submit My Data</button>
<Dialog open={showConsent} onOpenChange={setShowConsent}>
<DialogContent>
<DialogHeader>
<DialogTitle>Health Data Storage Consent</DialogTitle>
</DialogHeader>
<LegalConsentForm
renderCheckbox={({ id, checked, onCheckedChange, className }) => (
<Checkbox
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
className={className}
/>
)}
onConfirm={handleConsent}
onCancel={() => setShowConsent(false)}
/>
</DialogContent>
</Dialog>
</>
);
}Custom Document Links:
<LegalConsentForm
documentLinks={{
termsUrl: "https://myapp.com/terms",
privacyUrl: "https://myapp.com/privacy",
}}
onConfirm={handleConsent}
onCancel={handleCancel}
/>CSS Classes for Styling:
.wq-legal-consent-form- Main form container.wq-legal-consent-section- Individual consent section.wq-legal-consent-heading- Section headings.wq-legal-consent-checkbox-row- Checkbox + label row.wq-legal-consent-link- Links to legal documents.wq-legal-consent-info-box- Information box (study consent details).wq-legal-consent-actions- Button container.wq-button,.wq-button-primary,.wq-button-outline- Button styles
Utilities
Questionnaire Utilities:
getVisiblePages(questionnaire)- Get visible page groupscalculateProgress(currentIndex, total)- Calculate progress percentagegetAllQuestionsFromPage(pageItem)- Get all questions from a page
BMI Helper Functions:
calculateBmi(height, weight, unitSystem)- Calculate BMI from height and weightgetBmiCategory(bmi)- Get WHO BMI category (Underweight, Normal weight, Overweight, Obese)
Theming
Override CSS custom properties:
:root {
--wq-color-primary-500: #3b82f6;
--wq-space-lg: 1.5rem;
--wq-font-size-base: 1rem;
}Choice Layout
Choice-based questions (coding, choice, boolean) support two layout modes via the choiceLayout prop:
| Mode | Class applied | Behavior |
|------|---------------|----------|
| "stacked" (default) | .wq-choice-layout-stacked | Vertical column, one option per row |
| "inline-wrap" | .wq-choice-layout-inline-wrap | Horizontal chip layout that wraps |
<QuestionRenderer
item={item}
choiceLayout="inline-wrap"
/>Both modes apply to single-select, multi-select (repeats: true), and boolean questions. Existing consumers that don't set choiceLayout see no change (defaults to "stacked").
Chip Design Tokens
When using inline-wrap, these tokens control chip appearance:
| Token | Default | Purpose |
|-------|---------|---------|
| --wq-choice-chip-radius | 9999px (pill) | Border radius |
| --wq-choice-chip-padding-x | 1rem | Horizontal padding |
| --wq-choice-chip-padding-y | 0.5rem | Vertical padding |
| --wq-choice-chip-gap | 0.5rem | Gap between chips |
CSS Class Contract
The choice container always emits:
.wq-question-choice— base container.wq-choice-layout-stackedor.wq-choice-layout-inline-wrap— layout variant- Your
choiceClassNamevalue (if provided)
State classes on individual options remain unchanged: .wq-selected, .wq-disabled.
Quick Setup: Dark Chip Theme
To get a dark-themed chip-style UI (horizontal wrapping options with strong selected state), combine the layout prop with token overrides and a small scoped CSS file.
1) Render with layout and a shared choice class:
<QuestionRenderer
item={item}
choiceLayout="inline-wrap"
choiceClassName="questionnaire-choice"
/>2) Set theme tokens (global CSS):
:root {
--wq-color-surface: hsl(246 65% 10%);
--wq-color-border: hsl(234 50% 20%);
--wq-color-border-hover: hsl(214 98% 52%);
--wq-color-border-focus: hsl(214 98% 52%);
--wq-color-text-primary: hsl(220 20% 95%);
--wq-color-text-secondary: hsl(220 15% 60%);
--wq-color-selected: hsl(214 98% 52%);
--wq-color-selected-border: hsl(214 98% 52%);
--wq-choice-chip-radius: 14px;
--wq-choice-chip-padding-x: 1rem;
--wq-choice-chip-padding-y: 0.625rem;
--wq-choice-chip-gap: 0.5rem;
}3) Add scoped brand overrides (e.g. CustomInputs.css):
.wq-question-choice.questionnaire-choice .wq-choice-option.wq-selected {
background: var(--wq-color-selected);
border-color: var(--wq-color-selected-border);
}
.wq-question-choice.questionnaire-choice .wq-choice-label {
overflow-wrap: anywhere;
}4) (Optional) Hide native radio/checkbox visuals:
Keep inputs in the DOM for accessibility; hide only the visual markers for pure chips:
.wq-question-choice.questionnaire-choice input[type="radio"],
.wq-question-choice.questionnaire-choice input[type="checkbox"] {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}Notes:
- Keep overrides scoped to your
choiceClassNameto avoid unintended global changes. - Use package state classes (
.wq-selected,.wq-disabled) instead of custom state class names. - This setup works consistently for single-choice, multi-select, and boolean question types.
Custom Input Renderers
<QuestionRenderer
item={item}
renderRadioInput={(props) => (
<input type="radio" checked={props.checked} onChange={props.onChange} />
)}
renderCheckboxInput={(props) => (
<input type="checkbox" checked={props.checked} onChange={props.onChange} />
)}
/>Renderer Props: linkId, valueCoding, valueInteger, checked, disabled, onChange, label, index
FHIR Extensions
Hidden Questions
{
"extension": [
{
"url": "http://hl7.org/fhir/StructureDefinition/questionnaire-hidden",
"valueBoolean": true
}
]
}Media Attachments (Images)
Display images or other media content with question items using the FHIR SDC (Structured Data Capture) extensions.
Item Media (images on questions):
{
"linkId": "body-diagram",
"text": "Where is the pain located?",
"type": "choice",
"extension": [
{
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemMedia",
"valueAttachment": {
"contentType": "image/png",
"url": "https://example.com/images/body-diagram.png",
"title": "Body diagram"
}
}
]
}Answer Option Media (images on answer options):
{
"linkId": "skin-condition",
"text": "Which image best matches your condition?",
"type": "choice",
"answerOption": [
{
"valueCoding": {
"code": "mild",
"display": "Mild"
},
"extension": [
{
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemAnswerMedia",
"valueAttachment": {
"contentType": "image/jpeg",
"url": "https://example.com/images/mild.jpg",
"title": "Mild condition"
}
}
]
},
{
"valueCoding": {
"code": "severe",
"display": "Severe"
},
"extension": [
{
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemAnswerMedia",
"valueAttachment": {
"contentType": "image/jpeg",
"url": "https://example.com/images/severe.jpg",
"title": "Severe condition"
}
}
]
}
]
}CSS Classes for Styling:
.wq-question-media- Container for question-level images.wq-question-image- Individual question image.wq-choice-option-wrapper- Wrapper around answer option with image.wq-choice-option-image- Individual answer option image
Example Styling:
.wq-question-image {
max-width: 100%;
height: auto;
margin-bottom: 1rem;
border-radius: 0.5rem;
}
.wq-choice-option-image {
max-width: 200px;
height: auto;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
}Slider Controls
{
"extension": [
{
"url": "http://codes.welshare.app/StructureDefinition/questionnaire-slider-control",
"extension": [
{ "url": "minValue", "valueInteger": 0 },
{ "url": "maxValue", "valueInteger": 100 },
{ "url": "step", "valueInteger": 1 },
{ "url": "unit", "valueString": "minutes" }
]
}
]
}Exclusive Options
{
"extension": [
{
"url": "http://codes.welshare.app/StructureDefinition/questionnaire-exclusive-option",
"valueString": "none-of-the-above-code"
}
]
}Input Helpers
Input helpers allow you to provide auxiliary UI (like calculators or lookup dialogs) to assist users in filling out form fields. The library detects the extension and calls your renderHelperTrigger function, but you control the dialog/modal implementation.
FHIR Extension:
{
"linkId": "bmi",
"text": "Body Mass Index",
"type": "decimal",
"extension": [
{
"url": "http://codes.welshare.app/StructureDefinition/questionnaire-inputHelper",
"valueCodeableConcept": {
"coding": [
{
"system": "http://codes.welshare.app/input-helper-type",
"code": "bmi-calculator",
"display": "BMI Calculator"
}
],
"text": "Calculate BMI from height and weight"
}
}
]
}Implementation:
import { useState } from "react";
import {
QuestionRenderer,
BmiForm,
type HelperTriggerProps,
} from "@welshare/questionnaire";
function MyQuestionnaireRenderer({ item }) {
const [showBmiDialog, setShowBmiDialog] = useState(false);
const [helperCallback, setHelperCallback] = useState<
((v: string | number) => void) | null
>(null);
const handleHelperTrigger = ({
helper,
onValueSelected,
}: HelperTriggerProps) => {
if (helper.type === "bmi-calculator") {
return (
<button
type="button"
onClick={() => {
setHelperCallback(() => onValueSelected);
setShowBmiDialog(true);
}}
>
{helper.display || "Calculate"}
</button>
);
}
return null;
};
return (
<>
<QuestionRenderer item={item} renderHelperTrigger={handleHelperTrigger} />
{showBmiDialog && (
<Dialog onClose={() => setShowBmiDialog(false)}>
<BmiForm
onSubmit={({ bmi }) => {
helperCallback?.(bmi);
setShowBmiDialog(false);
}}
/>
</Dialog>
)}
</>
);
}Helper Trigger Props:
helper: InputHelperConfig- Configuration from the extensiontype: string- Helper identifier (e.g., "bmi-calculator")display?: string- Display name from the extensiondescription?: string- Description/tooltip text
linkId: string- The question's linkIdcurrentValue?: T- Current field value (if any), where T defaults tostring | numberonValueSelected: (value: T) => void- Callback to update the field
Note: HelperTriggerProps<T> is a generic interface. For decimal questions, it's specialized as HelperTriggerProps<number>, but you can use different types for other question types (e.g., HelperTriggerProps<string> for text inputs).
Note: The library only renders the trigger element you provide. You are responsible for implementing the dialog/modal UI, as this allows you to use your preferred dialog system (e.g., shadcn/ui, Radix UI, MUI, etc.).
License
MIT © Welshare UG (haftungsbeschränkt)
