- FHIR Questionnaire
- Item type → component reference
- Examples
- Boolean
- Integer and Decimal
- Date, Date+Time, and Time
- String and URL
- Text
- Required fields
- Read-only
- Disabled
- Groups
- Repeating fields
- Choice (drop-down)
- Choice (radio group)
- Open-choice (free-text fallback)
- Choice (answerValueSet)
- Choice (autocomplete — large value sets)
- Quantity
- Follow-up questions (enableWhen)
- Paging by group
- Resuming saved answers
- Language
- Live QuestionnaireResponse
- Autosave patterns
- Empty state
- Importing
- Slots
- Properties
- Events
- Methods
- CSS Parts
- Dependencies
Fhir Questionnaire
<ahi-fhir-questionnaire> | AhiFhirQuestionnaire
Renders a dynamic form from a FHIR R4 Questionnaire resource and emits a QuestionnaireResponse as answers change.
The FHIR questionnaire component renders a dynamic form from a FHIR R4 Questionnaire resource. The parent application owns the submit button and state management.
Item type → component reference
| Item type | Rendered as |
|---|---|
display |
Read-only <p> — renders item.text as instructional text, no input
|
boolean |
<ahi-checkbox> — checked emits valueBoolean: true; unchecked emits
no answer (absent from the response)
|
integer, decimal |
<ahi-input type="number"> |
date |
<ahi-input type="date"> |
dateTime |
<ahi-input type="datetime-local"> — answered as a FHIR dateTime with seconds and
the browser’s UTC offset (2026-03-01T09:30:00-05:00); a saved value with a zone is
shown in local time
|
time |
<ahi-input type="time"> — answered with seconds (08:15:00) |
string, url |
<ahi-input type="text"> |
text |
<ahi-textarea> |
choice |
<ahi-select> (with questionnaire-itemControl =
drop-down or autocomplete) or <ahi-radio-group>;
supports answerOption and answerValueSet
|
open-choice |
Same as choice, but adds an Other… option that reveals a free-text
input when selected; the typed value is emitted as valueString
|
quantity |
Value <ahi-input type="number"> + unit control in an inline row. The unit renders
as a free-text <ahi-input> by default, or as an
<ahi-select> when questionnaire-unitOption /
questionnaire-unitValueSet extensions are present.
|
Examples
Boolean
<ahi-fhir-questionnaire id="q-bool"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-bool').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [{ linkId: 'consent', text: 'I agree to the terms', type: 'boolean' }]
};
</script>
Integer and Decimal
<ahi-fhir-questionnaire id="q-num"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-num').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'age', text: 'Age (years)', type: 'integer' },
{ linkId: 'weight', text: 'Weight (kg)', type: 'decimal' }
]
};
</script>
Date, Date+Time, and Time
<ahi-fhir-questionnaire id="q-dates"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-dates').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'dob', text: 'Date of birth', type: 'date' },
{ linkId: 'appt', text: 'Appointment', type: 'dateTime' },
{ linkId: 'wake-time', text: 'Usual wake-up time', type: 'time' }
]
};
</script>
String and URL
<ahi-fhir-questionnaire id="q-str"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-str').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'name', text: 'Full name', type: 'string' },
{ linkId: 'website', text: 'Website', type: 'url' }
]
};
</script>
Text
<ahi-fhir-questionnaire id="q-text"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-text').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [{ linkId: 'notes', text: 'Additional notes', type: 'text' }]
};
</script>
Required fields
Items with required: true propagate the required constraint to the underlying
control. The component renders a visual indicator (asterisk) on the label, and the underlying input enforces
browser-native validation on submit.
The parent application is responsible for providing a submit button and wrapping the questionnaire in a
<form>. Required validation fires when the form is submitted.
Let the form decide how to indicate required fields — avoid encoding words like “required” into the item’s
text. Use required: true on the item definition instead.
<form id="q-required-form">
<ahi-fhir-questionnaire id="q-required"></ahi-fhir-questionnaire>
<br>
<ahi-button type="submit" variant="primary">Submit</ahi-button>
</form>
<script>
document.getElementById('q-required').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'name', text: 'Full name', type: 'string', required: true },
{ linkId: 'dob', text: 'Date of birth', type: 'date', required: true }
]
};
document.getElementById('q-required-form').addEventListener('submit', event => {
event.preventDefault();
const questionnaire = document.getElementById('q-required');
if (questionnaire.reportValidity()) {
alert('Form submitted successfully!');
}
});
</script>
Read-only
<ahi-fhir-questionnaire readonly id="q-readonly"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-readonly').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'name', text: 'Full name', type: 'string' },
{ linkId: 'dob', text: 'Date of birth', type: 'date' }
]
};
</script>
Disabled
<ahi-fhir-questionnaire disabled id="q-disabled"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-disabled').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'name', text: 'Full name', type: 'string' },
{ linkId: 'dob', text: 'Date of birth', type: 'date' }
]
};
</script>
Groups
Items nested inside a group render inside a <fieldset> with a legend.
<ahi-fhir-questionnaire id="q-groups"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-groups').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{
linkId: 'demographics',
text: 'Demographics',
type: 'group',
item: [
{ linkId: 'first-name', text: 'First name', type: 'string' },
{ linkId: 'last-name', text: 'Last name', type: 'string' }
]
}
]
};
</script>
Repeating fields
Set repeats: true on any leaf item to allow multiple answers. The field starts with one input
and an Add another button. Each row gets its own entry in answer[] in the
emitted response. A ✕ remove button appears on each row once a second row is added; the
last remaining row cannot be removed.
readOnly and disabled hide both the add and remove controls.
<ahi-fhir-questionnaire id="q-repeats"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-repeats').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{
linkId: 'allergies',
text: 'Known allergies',
type: 'string',
repeats: true
}
]
};
</script>
When two rows are filled and the user changes either one, ahi-questionnaire-change fires with a
response like:
{
"resourceType": "QuestionnaireResponse",
"status": "in-progress",
"item": [
{
"linkId": "allergies",
"text": "Known allergies",
"answer": [{ "valueString": "Penicillin" }, { "valueString": "Latex" }]
}
]
}
Row order is significant: entries in answer[] appear in the same order the user added them, and
that order is preserved when rows are removed or reordered by the user.
Not supported with
repeats:groupitems,booleanitems,quantityitems, andchoice/open-choiceitems. These are deferred. They render one control, so a saved response restores only their first answer.
Choice (drop-down)
A choice item with the questionnaire-itemControl extension set to
drop-down renders an <ahi-select>. Each answerOption becomes
one <ahi-option>.
<ahi-fhir-questionnaire id="q-choice-select"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-choice-select').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{
linkId: 'gender',
text: 'Biological sex',
type: 'choice',
extension: [
{
url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl',
valueCodeableConcept: {
coding: [{ system: 'http://hl7.org/fhir/questionnaire-item-control', code: 'drop-down' }]
}
}
],
answerOption: [
{ valueCoding: { system: 'http://hl7.org/fhir/administrative-gender', code: 'male', display: 'Male' } },
{ valueCoding: { system: 'http://hl7.org/fhir/administrative-gender', code: 'female', display: 'Female' } },
{ valueCoding: { system: 'http://hl7.org/fhir/administrative-gender', code: 'other', display: 'Other' } },
{ valueCoding: { system: 'http://hl7.org/fhir/administrative-gender', code: 'unknown', display: 'Unknown' } }
]
}
]
};
</script>
Choice (radio group)
A choice item without the drop-down extension renders an
<ahi-radio-group>.
<ahi-fhir-questionnaire id="q-choice-radio"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-choice-radio').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{
linkId: 'smoking',
text: 'Smoking status',
type: 'choice',
answerOption: [
{ valueString: 'never' },
{ valueString: 'former' },
{ valueString: 'current' }
]
}
]
};
</script>
Open-choice (free-text fallback)
An open-choice item behaves like choice but adds an
Other… option. When selected, a free-text input appears below the control.
<ahi-fhir-questionnaire id="q-open-choice"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-open-choice').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{
linkId: 'ethnicity',
text: 'Ethnicity',
type: 'open-choice',
extension: [
{
url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl',
valueCodeableConcept: {
coding: [{ system: 'http://hl7.org/fhir/questionnaire-item-control', code: 'drop-down' }]
}
}
],
answerOption: [
{ valueString: 'Hispanic or Latino' },
{ valueString: 'Not Hispanic or Latino' }
]
}
]
};
</script>
Choice (answerValueSet)
When a choice item uses answerValueSet instead of answerOption,
provide a resolveValueSet callback. The callback signature is:
resolveValueSet(url: string, search?: string): Promise<Coding[]>
-
When
searchis absent, return the full list of options (used bydrop-downand radio/checkbox items on mount). -
When
searchis present, return options matching that search string (used byautocompleteitems on keystroke). Implement this usingValueSet/$expand?filter=<search>against your terminology server.
The control renders disabled with a spinner while the promise is pending, then becomes interactive once options arrive. If the resolved list exceeds 30 options on a radio or checkbox item, it is truncated and a “Showing first 30 options” label is shown.
<ahi-fhir-questionnaire id="q-vs"></ahi-fhir-questionnaire>
<br>
<ahi-button id="q-vs-reset" variant="default" size="small">Reload value set</ahi-button>
<script>
const q = document.getElementById('q-vs');
const DROP_DOWN_EXTENSION = [{
url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl',
valueCodeableConcept: {
coding: [{ system: 'http://hl7.org/fhir/questionnaire-item-control', code: 'drop-down' }]
}
}];
q.resolveValueSet = (url) =>
new Promise(resolve =>
setTimeout(() => resolve([
{ code: 'LA6156-9', display: 'Single' },
{ code: 'LA6157-7', display: 'Married' },
{ code: 'LA6158-5', display: 'Divorced' },
{ code: 'LA6159-3', display: 'Widowed' }
]), 1500)
);
q.questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{
linkId: 'marital-status-select',
text: 'Marital status (select)',
type: 'choice',
extension: DROP_DOWN_EXTENSION,
answerValueSet: 'http://loinc.org/vs/LL362-0'
},
{
linkId: 'marital-status-radio',
text: 'Marital status (radio)',
type: 'choice',
answerValueSet: 'http://loinc.org/vs/LL362-0'
}
]
};
document.getElementById('q-vs-reset').addEventListener('click', () => {
q.resetValueSetCache();
});
</script>
Choice (autocomplete — large value sets)
For large value sets (e.g. ICD-10 with 50,000+ codes), add a
questionnaire-itemControl extension with code autocomplete to the item. The
component renders a searchable select that calls resolveValueSet(url, search) on each
keystroke, after minSearchCharacters have been typed (default: 2). Implement the callback using
ValueSet/$expand?filter=<search> against your terminology server.
If resolveValueSet is not provided, the control renders permanently disabled.
<ahi-fhir-questionnaire id="q-ac"></ahi-fhir-questionnaire>
<script>
const q = document.getElementById('q-ac');
const AUTOCOMPLETE_EXTENSION = [{
url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl',
valueCodeableConcept: {
coding: [{ system: 'http://hl7.org/fhir/questionnaire-item-control', code: 'autocomplete' }]
}
}];
// Simulates a server-side $expand?filter= call with a 600ms delay
const ALL_CODINGS = [
{ code: 'J45.20', display: 'Mild intermittent asthma, uncomplicated' },
{ code: 'J45.21', display: 'Mild intermittent asthma with acute exacerbation' },
{ code: 'J45.30', display: 'Mild persistent asthma, uncomplicated' },
{ code: 'J45.40', display: 'Moderate persistent asthma, uncomplicated' },
{ code: 'J45.50', display: 'Severe persistent asthma, uncomplicated' },
{ code: 'E11.9', display: 'Type 2 diabetes mellitus without complications' },
{ code: 'E11.65', display: 'Type 2 diabetes mellitus with hyperglycemia' },
{ code: 'I10', display: 'Essential (primary) hypertension' },
];
q.resolveValueSet = (url, search) =>
new Promise(resolve =>
setTimeout(() => {
const lower = (search ?? '').toLowerCase();
resolve(ALL_CODINGS.filter(c =>
c.display.toLowerCase().includes(lower) || c.code.toLowerCase().includes(lower)
));
}, 600)
);
q.questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{
linkId: 'diagnosis',
text: 'Primary diagnosis',
type: 'choice',
extension: AUTOCOMPLETE_EXTENSION,
answerValueSet: 'http://hl7.org/fhir/sid/icd-10-cm'
}
]
};
</script>
Quantity
A quantity item renders a numeric value input and a unit control in a single labeled inline
row. The unit field behavior depends on the extensions present on the item:
-
No unit extensions — free-text
<ahi-input>, the user types any unit string. -
questionnaire-unitOption— one or morevalueCodingextensions each contribute an allowed unit. The unit field renders as an<ahi-select>. If only one option is provided it is pre-selected and the select is set to read-only. -
questionnaire-unitValueSet— avalueCanonicalpointing to a value set URL. The component resolves the set viaresolveValueSet(same callback used forchoiceitems). The unit field renders as an<ahi-select>. If the resolved set contains exactly one option it is pre-selected and read-only.
The comparator field is intentionally omitted — quantity items in a questionnaire use
SimpleQuantity, which excludes comparator.
<ahi-fhir-questionnaire id="q-quantity"></ahi-fhir-questionnaire>
<script>
const q = document.getElementById('q-quantity');
// Simulates resolving a unit value set (e.g. body-temperature units) with a 1s delay
q.resolveValueSet = (url) =>
new Promise(resolve =>
setTimeout(() => resolve([
{ code: 'Cel', display: '°C' },
{ code: '[degF]', display: '°F' }
]), 1000)
);
q.questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{
linkId: 'height',
text: 'Height',
type: 'quantity',
required: true,
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' } }
]
},
{
linkId: 'weight',
text: 'Weight (single unit — auto-selected)',
type: 'quantity',
extension: [
{ url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption', valueCoding: { system: 'http://unitsofmeasure.org', code: 'kg', display: 'kg' } }
]
},
{
linkId: 'temp',
text: 'Temperature (async unit value set)',
type: 'quantity',
extension: [
{ url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitValueSet', valueCanonical: 'http://unitsofmeasure.org/vs/temperature' }
]
},
{ linkId: 'other', text: 'Other measurement (free unit)', type: 'quantity' }
]
};
</script>
Follow-up questions (enableWhen)
An item with enableWhen is shown only while its conditions are met, so follow-up questions
appear as the patient answers. Each condition tests the answers to the item whose linkId is its
question:
| Operator | Met when |
|---|---|
exists |
answerBoolean: true — the question has an answer; answerBoolean: false —
it has none
|
= |
at least one answer equals the condition’s answer[x] |
!= |
no answer equals it — so an unanswered question meets != |
>, <, >=, <= |
at least one answer compares that way (numbers, dates, date-times, times, strings, same-unit quantities) |
A checkbox has no separate “No”, so a shown boolean question left unchecked counts as false for
every operator but exists: a = false follow-up shows until the box is checked. The
response still leaves the unchecked question out.
Codings match on code, and on system when both sides have one. With more than one
condition, enableBehavior decides whether any or all must be met; it
defaults to all. A condition the component cannot evaluate — an answerReference,
say — counts as met, so a question is never hidden by a condition it does not understand.
A hidden item is not rendered, so it cannot fail reportValidity(), and hiding a group hides
everything in it. A group whose questions are all hidden is hidden too, rather than showing its heading
alone. Answers to hidden questions are left out of the response and do not count toward other conditions, so
a chain of follow-ups collapses together. The component keeps those answers, though: switch the question
back on and its answer returns. Leaving them out rather than sending them empty matters to servers that
clear a stored value when an item arrives without an answer.
<ahi-fhir-questionnaire id="q-enable-when"></ahi-fhir-questionnaire>
<script>
const yes = { system: 'http://loinc.org', code: 'LA33-6', display: 'Yes' };
const no = { system: 'http://loinc.org', code: 'LA32-8', display: 'No' };
document.getElementById('q-enable-when').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'smokes', text: 'Do you smoke?', type: 'choice', answerOption: [{ valueCoding: yes }, { valueCoding: no }] },
{
linkId: 'packs',
text: 'How many packs a day?',
type: 'decimal',
enableWhen: [{ question: 'smokes', operator: '=', answerCoding: yes }]
},
{
linkId: 'quit',
text: 'Would you like help quitting?',
type: 'boolean',
enableWhen: [{ question: 'packs', operator: 'exists', answerBoolean: true }]
}
]
};
</script>
Paging by group
Set paged to show one page at a time. Each top-level group is a page, and each run of adjacent
top-level questions outside a group is one page, so a questionnaire with no groups is a single page. A group
that enableWhen hides, or whose questions it all hides, is not a page.
The parent owns navigation: set page (zero-based) and read pageCount. Answers on
every page are kept and included in the response, and conditions work across pages.
pageCount can change as the patient answers, so read it again after each
ahi-questionnaire-change. So can page: when an answer switches on or off a page
before the one on screen, page moves so the same page stays on screen, and a parent that keeps
its own page number should update it from page in that handler. A page outside the
range is clamped to the nearest page once there is a questionnaire, so it can be set before the
questionnaire arrives. The page asked for is kept and clamped again when questionnaire or
initialResponse changes, so when resuming, a page that only the saved answers switch on is
shown even if the response arrives after the questionnaire. reportValidity() checks the page on
screen, which suits gating a Next button. It cannot see earlier pages, where a required
question can still be empty: switched on by a later answer, or on a page a resumed form opened past. Before
the final submit, call firstInvalidPage(). It returns the first page with a required question
still unanswered, or -1; go to that page and call reportValidity() to show what is
missing.
<ahi-fhir-questionnaire id="q-paged" paged></ahi-fhir-questionnaire>
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: center;">
<ahi-button id="q-paged-back" size="small">Back</ahi-button>
<ahi-button id="q-paged-next" variant="primary" size="small">Next</ahi-button>
<span id="q-paged-status" aria-live="polite"></span>
</div>
<script>
const q = document.getElementById('q-paged');
const back = document.getElementById('q-paged-back');
const next = document.getElementById('q-paged-next');
const status = document.getElementById('q-paged-status');
q.questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{
linkId: 'visit',
text: 'Your visit',
type: 'group',
item: [{ linkId: 'reason', text: 'Reason for visit', type: 'string', required: true }]
},
{
linkId: 'history',
text: 'History',
type: 'group',
item: [{ linkId: 'surgery', text: 'I have had surgery', type: 'boolean' }]
},
{
linkId: 'surgeries',
text: 'Surgeries',
type: 'group',
enableWhen: [{ question: 'surgery', operator: '=', answerBoolean: true }],
item: [{ linkId: 'surgery-list', text: 'Which surgeries, and when?', type: 'text' }]
}
]
};
async function show(page) {
await customElements.whenDefined('ahi-fhir-questionnaire');
q.page = page;
await q.updateComplete;
back.disabled = q.page === 0;
next.disabled = q.page === q.pageCount - 1;
status.textContent = `Page ${q.page + 1} of ${q.pageCount}`;
}
back.addEventListener('click', () => show(q.page - 1));
next.addEventListener('click', () => {
if (q.reportValidity()) show(q.page + 1);
});
q.addEventListener('ahi-questionnaire-change', () => show(q.page));
show(0);
</script>
Resuming saved answers
Set initialResponse to a saved QuestionnaireResponse to resume where the patient
left off. Its answers are matched to questions by linkId at any depth — items nested under
groups or under other answers are found too — and answers to questions the questionnaire does not have are
ignored. Restoring does not set dirty or emit ahi-questionnaire-change, so an
autosave does not fire until the patient changes something.
Saved answers are converted to the shape the controls produce, so a response from a server that returns a
different value[x] still shows:
-
A choice answer saved as a string, an integer or a coding without its display selects the matching
answerOption, matched by code, display or string value. -
A string answer to a boolean, number, date, time or URL question is parsed into that type. A boolean
accepts
true/false,1/0andyes/no. -
A date or dateTime its control cannot show is dropped rather than kept in the response out of sight: a
partial date such as
2026-03, or a dateTime without a time. A dateTime with a zone is shown in local time and sent back as saved. -
An
open-choiceanswer that matches no option shows in the Other… field. - A repeating question gets one row per saved answer. A question type that cannot repeat yet (see Repeating fields) keeps only its first saved answer, the one its control can show.
initialResponse is applied again whenever questionnaire changes, and
reset() returns to it. Set it to null to start from a blank form. A response whose
questionnaire reference names a different questionnaire, by url or by
Questionnaire/<id>, is not restored and logs a console warning, so a response left set
when moving on to the next questionnaire does not fill it in. A response without a
questionnaire reference is always restored. Defaults the questionnaire itself defines
(item.initial, answerOption.initialSelected) are not applied.
<ahi-fhir-questionnaire id="q-resume"></ahi-fhir-questionnaire>
<script>
const q = document.getElementById('q-resume');
q.initialResponse = {
resourceType: 'QuestionnaireResponse',
status: 'in-progress',
item: [
{ linkId: 'name', answer: [{ valueString: 'Marcus Bellamy' }] },
{ linkId: 'allergies', answer: [{ valueString: 'Penicillin' }, { valueString: 'Latex' }] },
// Saved by its display rather than its coding — still selects the option.
{ linkId: 'smokes', answer: [{ valueString: 'No' }] }
]
};
q.questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'name', text: 'Full name', type: 'string' },
{ linkId: 'allergies', text: 'Known allergies', type: 'string', repeats: true },
{
linkId: 'smokes',
text: 'Do you smoke?',
type: 'choice',
answerOption: [
{ valueCoding: { system: 'http://loinc.org', code: 'LA33-6', display: 'Yes' } },
{ valueCoding: { system: 'http://loinc.org', code: 'LA32-8', display: 'No' } }
]
}
]
};
</script>
Language
The component’s own text — Add another, Other…, the unit and value labels,
and its loading and error messages — follows lang, and the component passes its
lang to the controls inside it, which do not inherit it. Without a lang of its
own, it follows <html lang>. English is bundled; import other translations, such as
translations/es.js, as with any localized component. The questionnaire’s own text
(item.text, option displays) is shown as the questionnaire supplies it.
<!-- In an app: import '@azaleahealth/azalea-kit-ui/dist/translations/es.js'; -->
<script type="module" src="../../dist/translations/es.js"></script>
<ahi-fhir-questionnaire id="q-lang" lang="es"></ahi-fhir-questionnaire>
<script>
document.getElementById('q-lang').questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'meds', text: 'Medicamentos que toma', type: 'string', repeats: true },
{
linkId: 'pharmacy',
text: 'Farmacia',
type: 'open-choice',
answerOption: [{ valueString: 'CVS' }, { valueString: 'Walgreens' }]
}
]
};
</script>
Live QuestionnaireResponse
The component emits a single custom event, ahi-questionnaire-change (typed as
AhiQuestionnaireChangeEvent), whenever any field changes. Its detail.response is
the complete current QuestionnaireResponse — not a per-field delta — so to react to one field
you read that linkId out of response.item. The event bubbles and is composed, so
you can also bind it on a containing element via event delegation rather than on each
<ahi-fhir-questionnaire> directly.
The example below listens on a wrapping <div> (delegation) and surfaces both the raw
payload and the extracted value of a single field (name).
Current name answer:
<div id="q-live-host">
<ahi-fhir-questionnaire id="q-live"></ahi-fhir-questionnaire>
</div>
<br>
<p id="q-live-field" hidden>Current <code>name</code> answer: <strong id="q-live-name"></strong></p>
<pre id="q-live-output" hidden><code id="q-live-code"></code></pre>
<script>
const q = document.getElementById('q-live');
q.questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'name', text: 'Full name', type: 'string' },
{ linkId: 'dob', text: 'Date of birth', type: 'date' },
{
linkId: 'sex',
text: 'Biological sex',
type: 'choice',
extension: [{
url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl',
valueCodeableConcept: {
coding: [{ system: 'http://hl7.org/fhir/questionnaire-item-control', code: 'drop-down' }]
}
}],
answerOption: [
{ valueCoding: { system: 'http://hl7.org/fhir/administrative-gender', code: 'male', display: 'Male' } },
{ valueCoding: { system: 'http://hl7.org/fhir/administrative-gender', code: 'female', display: 'Female' } },
{ valueCoding: { system: 'http://hl7.org/fhir/administrative-gender', code: 'other', display: 'Other' } },
{ valueCoding: { system: 'http://hl7.org/fhir/administrative-gender', code: 'unknown', display: 'Unknown' } }
]
},
{
linkId: 'height',
text: 'Height',
type: 'quantity',
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' } }
]
},
{ linkId: 'allergies', text: 'Known allergies', type: 'string', repeats: true },
{ linkId: 'notes', text: 'Additional notes', type: 'text' }
]
};
// Listen on the wrapping element, not the component itself — the event bubbles and is composed,
// so event delegation on a container works (useful with jQuery's `.on()` or a single root listener).
document.getElementById('q-live-host').addEventListener('ahi-questionnaire-change', e => {
const response = e.detail.response;
// The full payload
document.getElementById('q-live-code').textContent = JSON.stringify(response, null, 2);
document.getElementById('q-live-output').removeAttribute('hidden');
// Reading one field's answer out of the response by its linkId
const nameItem = response.item?.find(item => item.linkId === 'name');
const nameValue = nameItem?.answer?.[0]?.valueString ?? '(empty)';
document.getElementById('q-live-name').textContent = nameValue;
document.getElementById('q-live-field').removeAttribute('hidden');
});
</script>
Autosave patterns
ahi-fhir-questionnaire exposes three members for driving save behavior from parent code:
| Member | Type | Description |
|---|---|---|
getValue() |
() => QuestionnaireResponse | null |
Returns the current response — identical to detail.response from the last
ahi-questionnaire-change event.
|
dirty |
boolean |
true after any user interaction. Writable — set q.dirty = false after
saving to suppress further autosaves until the next user change.
|
reset() |
() => void |
Clears all user answers, re-applies auto-populated defaults and any initialResponse,
and sets dirty = false. Use for “start over” flows (e.g., after submitting a draft),
not after a background autosave. Does not emit
ahi-questionnaire-change.
|
getValue() returns every answer in its current state regardless of
validation — it does not filter out invalid, incomplete, or required fields that are still
empty. Validation is a separate concern enforced at submit time via the <form> and
reportValidity() (see Required fields). If
your autosave persists drafts, expect to receive partially-filled, not-yet-valid responses; gate any
“final submit” on reportValidity() rather than on the autosaved payload. With
paged, reportValidity() checks only the page on screen, so also check
firstInvalidPage() (see Paging by group).
Interval-based autosave — save every 30 seconds if there are unsaved changes. After saving,
set dirty = false so the form stays visible and the next tick only fires if the user keeps
editing:
<ahi-fhir-questionnaire id="q-autosave"></ahi-fhir-questionnaire>
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: center;">
<ahi-button id="q-save-btn" variant="primary" size="small">Save now</ahi-button>
<ahi-button id="q-reset-btn" variant="default" size="small">Reset</ahi-button>
<span id="q-save-status" style="font-size: 0.875rem; color: var(--ahi-text-subdued);"></span>
</div>
<br>
<pre id="q-autosave-output" hidden><code id="q-autosave-code"></code></pre>
<script>
const q = document.getElementById('q-autosave');
const status = document.getElementById('q-save-status');
q.questionnaire = {
resourceType: 'Questionnaire',
status: 'active',
item: [
{ linkId: 'name', text: 'Full name', type: 'string' },
{ linkId: 'dob', text: 'Date of birth', type: 'date' }
]
};
function doSave() {
if (!q.dirty) return;
const response = q.getValue();
// In a real app: await fhirClient.save(response);
document.getElementById('q-autosave-code').textContent = JSON.stringify(response, null, 2);
document.getElementById('q-autosave-output').removeAttribute('hidden');
status.textContent = 'Saved at ' + new Date().toLocaleTimeString();
q.dirty = false;
}
// Autosave every 30 s
setInterval(doSave, 30_000);
// Manual save
document.getElementById('q-save-btn').addEventListener('click', doSave);
// Reset — clears all answers, re-applies defaults, and sets dirty = false.
// Does not emit ahi-questionnaire-change, so the next tick stays quiet until
// the user edits again.
document.getElementById('q-reset-btn').addEventListener('click', () => {
q.reset();
status.textContent = 'Reset to initial state';
document.getElementById('q-autosave-output').setAttribute('hidden', '');
});
</script>
Change-based autosave — save as the user commits each field.
ahi-questionnaire-change fires whenever a field is committed (typically on blur), so you do not
need to manage focus yourself. Debounce in the parent if you are writing to a remote API on every
keystroke-equivalent commit:
<ahi-fhir-questionnaire id="q-onchange"></ahi-fhir-questionnaire>
<script>
const q = document.getElementById('q-onchange');
// ...set q.questionnaire...
q.addEventListener('ahi-questionnaire-change', e => {
// In a real app: debounce, then save.
// await fhirClient.save(e.detail.response);
q.dirty = false;
});
</script>
Empty state
Use the empty slot to render a fallback when no questionnaire is set.
<ahi-fhir-questionnaire>
<span slot="empty">No questionnaire loaded.</span>
</ahi-fhir-questionnaire>
Importing
To import this component using a script tag:
<script type="module" src="/path/to/azalea-kit-ui/dist/components/fhir-questionnaire/fhir-questionnaire.js"></script>
To import this component using a bundler:
import '@azaleahealth/azalea-kit-ui/dist/components/fhir-questionnaire/fhir-questionnaire.js';
Slots
| Name | Description |
|---|---|
empty
|
Rendered when no questionnaire is provided. |
Learn more about using slots.
Properties
| Name | Description | Type | Default | |
|---|---|---|---|---|
questionnaire
|
The FHIR R4 Questionnaire resource to render. Setting this property triggers a re-render. This must be set as a property (not an attribute) because the object is too large to serialize. |
Questionnaire | null | undefined
|
- | |
readonly
|
Puts all controls in read-only mode. |
boolean
|
false
|
|
disabled
|
Disables all controls. |
boolean
|
false
|
|
resolveValueSet
|
Resolves an answerValueSet URL to a list of Coding entries. - Called with
search absent for drop-down, radio, and checkbox items — fetches the full
list once on mount. - Called with search present for autocomplete items —
called on each keystroke after minSearchCharacters is reached; implement using
ValueSet/$expand?filter=<search>. If not provided, items with
answerValueSet render as a permanently disabled empty control.
|
(url: string, search?: string) => Promise<Coding[]> | undefined
|
- | |
minSearchCharacters
|
Minimum number of characters required before resolveValueSet is called for
autocomplete items. Has no effect on drop-down, radio, or checkbox items.
|
number
|
2
|
|
initialResponse
|
A saved FHIR R4 QuestionnaireResponse to resume from. Its answers replace the current ones when it
is set, and again whenever questionnaire changes or reset() is called,
without setting dirty or emitting ahi-questionnaire-change. Answers are
matched to questions by linkId at any depth; answers to questions the questionnaire
does not have are ignored. A response whose questionnaire reference names a different
questionnaire, by url or by Questionnaire/<id>, is not restored,
with a console warning; one without a reference always is. Set null to start from a
blank form. This must be set as a property (not an attribute).
|
QuestionnaireResponse | null | undefined
|
- | |
paged
|
Shows one page at a time instead of the whole questionnaire. Each top-level group is a page, and
each run of adjacent top-level questions outside a group is one page, so a questionnaire with no
groups is a single page. Answers on every page are kept and included in the response, and enableWhen
conditions work across pages. The parent owns navigation: set page, and read
pageCount.
|
boolean
|
false
|
|
dirty
|
true after any user interaction since the last reset() or initial render.
Writable — parent code can set dirty = false after persisting to suppress further
autosaves until the next user change (see the autosave patterns in the docs).
|
boolean
|
false
|
|
page
|
The zero-based page shown when paged is set. Values outside 0 to
pageCount - 1 are clamped to the nearest page on the next update. The page asked for is
kept and clamped again whenever questionnaire or initialResponse changes,
so a page that only the saved answers switch on is shown even when the response arrives after the
questionnaire. When an answer switches a page before the one on screen on or off,
page changes so the same page stays on screen, before
ahi-questionnaire-change fires.
|
number
|
- | |
pageCount
|
The number of pages the questionnaire has — see paged. Groups that enableWhen hides are
not counted, so the count can change as the patient answers; read it again after each
ahi-questionnaire-change. Reflects the current answers whether or not
paged is set, and is 0 when there is no questionnaire.
|
number
|
- | |
updateComplete |
A read-only promise that resolves when the component has finished updating. |
Learn more about properties and attributes.
Events
| Name | Description | Event Detail |
|---|---|---|
ahi-questionnaire-change |
Fired when any field value changes. event.detail.response is the complete current FHIR
R4 QuestionnaireResponse (not a per-field delta — read an individual answer from
response.item). This is the component’s own namespaced event, distinct from the
ahi-change events emitted by the child controls inside it. It bubbles and is composed,
so it crosses shadow boundaries and can be caught via event delegation on a containing element.
|
AhiQuestionnaireChangeEvent
|
Learn more about events.
Methods
| Name | Description | Arguments |
|---|---|---|
resetValueSetCache() |
Clears the resolved value-set cache and re-fetches all answerValueSet items. |
- |
getValue() |
Returns the current QuestionnaireResponse — identical to the
detail.response of the last emitted ahi-questionnaire-change event.
Returns null when no questionnaire is set. Answers to questions that enableWhen has
switched off are left out, though the component keeps them and puts them back if the question is
switched on again.
|
- |
reset() |
Returns the form to its starting state: clears every answer, re-applies auto-populated defaults
(e.g., a single-unit option on a quantity item) and the answers in initialResponse, and
resets dirty to false, so the rendered state matches what was shown on
initial render. To discard restored answers too, set initialResponse to
null. Resolved value-set caches are preserved. Does not emit
ahi-questionnaire-change.
|
- |
reportValidity() |
Reports validity of all child controls. Returns true if all are valid. | - |
firstInvalidPage() |
The first page with a required question that is shown and unanswered, or -1 when there
is none — see paged. reportValidity() checks only the page on screen, so
before the final submit, go to this page and call reportValidity() there to show the
patient what is missing. It is worked out from the answers, so it covers required only;
other constraints are what reportValidity() checks as the patient leaves each page.
|
- |
Learn more about methods.
CSS Parts
| Name | Description |
|---|---|
question-group |
The <fieldset> wrapper rendered for each group item. |
question-item |
The wrapper rendered for each leaf item. |
question-label |
The label element rendered for each item. |
Learn more about customizing CSS parts.
Dependencies
This component automatically imports the following dependencies.
<ahi-button-group><ahi-checkbox><ahi-combobox><ahi-combobox-option><ahi-icon><ahi-icon-button><ahi-input><ahi-option><ahi-popup><ahi-radio><ahi-radio-group><ahi-select><ahi-spinner><ahi-tag><ahi-textarea>