Changelog Azalea Kit UI 101 Usage Customizing Utilities Form controls Laravel Vue FHIR Types Accessibility Icons Actions Button Button group Layout and structure Card Card section Care summary Care timeline Divider Empty state Footer Header Layout Stack Table Selection and input Calendar Checkbox Checkbox group Color picker Combobox Combobox option Input Input mask Memorable date Number input Option Phone input Radio Radio button Radio group Range Select SSN input Tag Textarea Toggle ZIP code input Images and icons Avatar Icon Icon button Logo Feedback indicators Badge Banner Progress bar Skeleton Skeleton body text Skeleton display text Skeleton thumbnail Spinner Typography Text Navigation Breadcrumb Breadcrumb item Dropdown Menu Menu item Menu label Navigation Nav group Nav item Nav toggle Pagination Tab Tab group Tab panel Top bar Tree Tree item User menu Overlays Dialog Popover Tooltip Utilities Collapsible FHIR address FHIR human name FHIR quantity FHIR questionnaire Format bytes Format date Format number Relative time Resize observer Split panel Tooltip Border Color Shadow Spacing Transition Typography Z-index Contributing Visual Tests Light Dark System

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.


Submit
<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: group items, boolean items, quantity items, and choice/open-choice items. 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 search is absent, return the full list of options (used by drop-down and radio/checkbox items on mount).
  • When search is present, return options matching that search string (used by autocomplete items on keystroke). Implement this using ValueSet/$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.


Reload value set
<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 more valueCoding extensions 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 — a valueCanonical pointing to a value set URL. The component resolves the set via resolveValueSet (same callback used for choice items). 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.

Back Next
<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/0 and yes/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-choice answer 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).


<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:

Save now Reset

<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.

No questionnaire loaded.
<ahi-fhir-questionnaire>
  <span slot="empty">No questionnaire loaded.</span>
</ahi-fhir-questionnaire>

Importing

Script Bundler

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>