@welshare/questionnaire
v0.2.3
Published
FHIR Questionnaire components for React with state management and validation
Maintainers
Readme
@welshare/questionnaire
FHIR R4 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 classesrenderRadioInput?: (props: RadioInputProps) => ReactNode- Custom radio rendererrenderCheckboxInput?: (props: CheckboxInputProps) => ReactNode- Custom checkbox renderer
Supported Types: choice, boolean, integer, decimal, string, text
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}
/>
);
}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;
}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)
