Custom Controls

Start of documentation content

Formally uses many components to render forms and these can be individually replaced through the controls prop.

Intro

This is an SDK feature and it requires a licence. Please contact support for a licence.

Note: if you just want to theme CSS in Formally see the Theming page.

Use of the controls prop

import { Formally, ControlsProps } from 'formally';

type TextProps = ControlsProps['Text'];

const MyCustomText = ({ label, localisedMessages, field }: TextProps) => {
  // this component is just for demonstration purposes
  // it doesn't implement accessibility features
  return (
    <>
      {label}
      <input type="text" {...field} />
    </>
  );
};

export const MyForm = () => (
  <Formally
    formBuilderId="your-form-id"
    licence="your-licence-key"
    controls={{
      Text: MyCustomText,
    }}
  />
);

If you have your own Design System you can use it with Formally.

The full list of overrides available is still under development but the current list is:

Form fields

Text

Displays a text input field.

Text.tsx
import type { NodeText as TextNode } from '../../../FormallyData';

import { classNames } from '../../Utils/classNames';
import { StandardFormProps } from '../SwitchFormFieldNode';
import { TextLocalisedMessages } from './Text.locale';

type Props = {
  node: TextNode;
  localisedMessages: TextLocalisedMessages;
} & StandardFormProps;

export const Text = ({
  node,
  field,
  localisedMessages,
  error,
  errorAccessibilityCompass,
  hint,
  label,
  inputFieldId,
  inputFieldErrorId,
  inputFieldHintId,
}: Props): JSX.Element => {
  const { isRequired, inputType, autoComplete, rows } = node;
  const {
    plaintext: { placeholder },
  } = localisedMessages;

  const TagName = rows === 1 ? 'input' : 'textarea';

  const className = `formally-input formally-input--text${
    rows > 1 ? ' formally-input--textarea' : ''
  }`;

  const STYLE_DISPLAY_NONE = { display: 'none' } as const;

  const props = {
    // afaik we can't use lang={placeholder.locale} because that might affect the language of the value too not just the placeholder
    className,
    autoComplete,
    autoCorrect: 'off',
    placeholder: placeholder.value,
    id: inputFieldId,
    required: isRequired,
    'aria-required': isRequired,
    'aria-describedby': classNames({
      [inputFieldHintId]: !!hint,
      [inputFieldErrorId]: !!error,
    }),
    'aria-invalid': !!error,
    rows: node.rows > 1 ? node.rows : undefined,
  };

  return (
    <div
      className="formally-node"
      style={
        /*
     if inputType=hidden then hide the input until there's an error.
     a hidden input field shouldn't ever have an error, but it happens.
     The form designer probably shouldn't have made it a required field
    */
        node.inputType === 'hidden' && !error ? STYLE_DISPLAY_NONE : undefined
      }
    >
      {label}
      {hint}
      {error}
      {inputType === 'number' ? (
        <TagName
          inputMode="decimal"
          type="text"
          pattern="[0-9]*(.[0-9]+)?"
          {...props}
          {...field}
        />
      ) : (
        <TagName
          type={inputType === 'hidden' && error ? 'text' : inputType}
          {...props}
          {...field}
        />
      )}
      {errorAccessibilityCompass}
    </div>
  );
};

Live Upload

Displays a file upload field.

The onUpload prop (eg <Formally onUpload={myCustomUpload} />) can be used to customise the upload behaviour to send files to your own server.

LiveUpload.tsx
import { stripHtml } from '../../Utils/stripHtml';
import type { NodeLiveUpload as LiveUploadNode } from '../../../FormallyData';
import { classNames } from '../../Utils/classNames';
import { StandardFormLiveUploadProps } from '../SwitchFormFieldNode';
import { LiveUploadLocalisedMessages } from './LiveUpload.locale';
import {
  filterFileResponseByError,
  liveUploadValueStringify,
} from './LiveUpload.util';

type Props = {
  node: LiveUploadNode;
  localisedMessages: LiveUploadLocalisedMessages;
} & StandardFormLiveUploadProps;

export const LiveUpload = ({
  node,
  field,
  label,
  localisedMessages,
  error,
  errorAccessibilityCompass,
  hint,
  inputFieldId,
  inputFieldErrorId,
  inputFieldHintId,
  progressId,
}: Props): JSX.Element => {
  // The component attempts to follow these patterns
  // https://accessible-app.com/pattern/vue/progress

  const { isRequired, accept } = node;
  const { onChange, onCancel, onClear, uploadStatus, value, clearId } = field;
  const progressValue =
    uploadStatus.type === 'UPLOADING' ? uploadStatus.progressRatio : 1;
  const hasValue = value && value.length > 0;

  const {
    html: {
      progressUploadingInProgressLabelHtml,
      progressUploadingCompleteLabelHtml,
      uploadingErrorsHtml,
      cancelUploadHtml,
      clearUploadHtml,
    },
  } = localisedMessages;

  const uploadingCompleteStatus =
    value && Array.isArray(value) && value.length > 0
      ? value.every((response) => response.type === 'success')
        ? 'success'
        : 'error'
      : undefined;

  return (
    <div className="formally-node">
      {label}
      {hint}
      {error}
      {/*
          Note that we don't spread `field` into a single control.
          because the value in RHF state is:
          * The field.onChange is given to the input[type=file]
          * The rest is given to the text box.
      */}
      <div
        className={classNames({
          'formally-upload': true,
          'formally-upload--error': !!error,
        })}
      >
        <div className="formally-upload__input-row">
          <div className="formally-upload__input-row__input">
            {value && (
              <b className="formally-upload__input-row__name">
                {liveUploadValueStringify(value)}
              </b>
            )}
            <input
              className={classNames({
                'formally-upload__input-row__input-file': true,
                'formally-upload__input-row__input-file--success':
                  value && uploadingCompleteStatus === 'success',
              })}
              type="file"
              onChange={onChange}
              id={inputFieldId}
              required={isRequired}
              aria-required={isRequired}
              aria-describedby={classNames({
                [inputFieldErrorId]: !!error,
                [inputFieldHintId]: !!hint,
              })}
              aria-invalid={!!error}
              accept={accept}
              multiple={node.multiple}
            />
          </div>

          {uploadStatus.type === 'UPLOADING' && (
            <button
              className="formally-upload__input-row__button formally-button formally-button--secondary"
              type="button"
              onClick={onCancel}
              lang={cancelUploadHtml.locale}
              dangerouslySetInnerHTML={
                cancelUploadHtml.value
                  ? {
                      __html: cancelUploadHtml.value,
                    }
                  : undefined
              }
            />
          )}

          {hasValue && uploadStatus.type !== 'UPLOADING' && (
            <>
              <button
                className="formally-upload__input-row__button formally-button formally-button--secondary"
                type="button"
                id={clearId}
                onClick={onClear}
                lang={clearUploadHtml.locale}
                dangerouslySetInnerHTML={
                  clearUploadHtml.value
                    ? {
                        __html: clearUploadHtml.value,
                      }
                    : undefined
                }
              />
            </>
          )}
        </div>

        {uploadStatus.type === 'UPLOADING' && (
          <>
            <label
              htmlFor={progressId}
              aria-live="polite"
              lang={progressUploadingInProgressLabelHtml.locale}
              dangerouslySetInnerHTML={
                progressUploadingInProgressLabelHtml.value
                  ? {
                      __html: progressUploadingInProgressLabelHtml.value,
                    }
                  : undefined
              }
            />
            <progress
              tabIndex={
                -1 //  so that it can be programatically focused.
              }
              id={
                progressId /*
                We move focus to this progressId when upload begins
                for accessibility reasons see basic pattern here
                https://accessible-app.com/pattern/vue/progress
                The only deviation is that we retain the progress bar
                (we don't hide it when complete) because their example
                can move focus to content, whereas we don't have that
              */
              }
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuenow={Math.round(progressValue * 100)}
              value={Math.round(progressValue * 100)}
              max={100}
            />
          </>
        )}

        <div
          aria-live="polite"
          lang={
            uploadingCompleteStatus === 'success'
              ? progressUploadingCompleteLabelHtml.locale
              : uploadingCompleteStatus === 'error'
              ? uploadingErrorsHtml.locale
              : 'en'
          }
          className={
            uploadingCompleteStatus === 'success'
              ? 'formally-upload-status formally-upload-status--success'
              : uploadingCompleteStatus === 'error'
              ? 'formally-upload-status formally-upload-status--error'
              : undefined
          }
          dangerouslySetInnerHTML={
            uploadingCompleteStatus === 'success'
              ? { __html: progressUploadingCompleteLabelHtml.value || '' }
              : uploadingCompleteStatus === 'error'
              ? {
                  __html:
                    `${uploadingErrorsHtml.value}${
                      value && Array.isArray(value)
                        ? value
                            .filter(filterFileResponseByError)
                            .map((liveUploadFileResponse) =>
                              stripHtml(
                                liveUploadFileResponse.localisedMessage,
                              ),
                            )
                        : ''
                    }` || '',
                }
              : undefined
          }
        />
      </div>
      {errorAccessibilityCompass}
    </div>
  );
};

Radios

A wrapper around radio fields.

Radios.tsx
import React from 'react';

import type { NodeRadios as RadiosNode } from '../../../FormallyData';
import { RadiosLocalisedMessages } from './Radios.locale';
import { classNames } from '../../Utils/classNames';
import { ReactNode } from 'react';
import { StandardFormRadioProps } from '../SwitchFormFieldNode';

type Props = {
  node: RadiosNode;
  children: ReactNode;
  localisedMessages: RadiosLocalisedMessages;
  inputFieldId: string;
  inputFieldErrorId: string;
  inputFieldHintId: string;
} & StandardFormRadioProps;

export const Radios = ({
  node,
  field,
  firstOption,
  children,
  label,
  localisedMessages,
  error,
  hint,
  fieldsetLegendLabel,
  localisedRequiredLabel,
  inputFieldId,
  inputFieldErrorId,
  inputFieldHintId,
}: Props): JSX.Element => {
  const { isRequired } = node;

  return (
    <div className="formally-node">
      <fieldset
        className="formally-fieldset"
        aria-describedby={classNames({
          [inputFieldHintId]: !!hint,
          [inputFieldErrorId]: !!error,
        })}
      >
        <legend className="formally-legend">{fieldsetLegendLabel}</legend>
        {error}
        {hint}
        {
          // For radios/checkboxes the first Option (as opposed to OptionRoot/OptionGroup)
          // is the first form control that could be focused so that's what the Error Summary
          // links to.
          //
          // However technically there isn't a requirement to have any Option (even though
          // this would be pointless and unusual) so handle this edge case we'll ensure the
          // Error Summary can link to something even when there's not a first Option.
          firstOption === undefined ? <a id={inputFieldId} /> : null
        }
        {children}
      </fieldset>
    </div>
  );
};

Checkboxes

A wrapper around checkbox fields.

Checkboxes.tsx
import React from 'react';

import type { NodeCheckboxes as CheckboxesNode } from '../../../FormallyData';
import { StandardFormMultichoiceCheckboxProps } from '../SwitchFormFieldNode';
import { LocalisedMessages } from '../../Utils/useLocalisedMessage';
import { classNames } from '../../Utils/classNames';
import { ReactNode } from 'react';

export type CheckboxLocalisedMessages = LocalisedMessages<
  CheckboxesNode,
  'labelHtml' | 'hintHtml',
  never
>;

type Props = {
  node: CheckboxesNode;
  localisedMessages: CheckboxLocalisedMessages;
  children: ReactNode;
} & StandardFormMultichoiceCheckboxProps;

export const Checkboxes = ({
  node,
  label,
  firstOption,
  inputFieldId,
  localisedMessages,
  children,
  error,
  fieldsetLegendLabel,
  errorAccessibilityCompass,
  hint,
  inputFieldErrorId,
  inputFieldHintId,
}: Props): JSX.Element => {
  return (
    <div className="formally-node">
      <fieldset
        className="formally-fieldset"
        aria-describedby={classNames({
          [inputFieldHintId]: !!hint,
          [inputFieldErrorId]: !!error,
        })}
      >
        <legend className="formally-legend">{fieldsetLegendLabel}</legend>
        {error}
        {hint}
        {
          // For radios/checkboxes the first Option (as opposed to OptionRoot/OptionGroup)
          // is the first form control so that's what the Error Summary link targets.
          // However technically there isn't a requirement to have any Option (even though
          // this would be pointless and unusual) so this is an almost useless bit of
          // code to ensure the Error Summary can link to something even if there's not
          // a first Option.
          firstOption === undefined ? <a id={inputFieldId} /> : null
        }
        {children}
        {errorAccessibilityCompass}
      </fieldset>
    </div>
  );
};

Select

Displays a <select> dropdown.

A wrapper around <option> and <optgroup>.

Select.tsx
import { ReactNode } from 'react';

import type { NodeSelect as SelectNode } from '../../../FormallyData';
import { stripHtml } from '../../Utils/stripHtml';
import { SelectLocalisedMessages } from './Select.locale';
import { classNames } from '../../Utils/classNames';
import { StandardFormProps } from '../SwitchFormFieldNode';

type Props = {
  node: SelectNode;
  localisedMessages: SelectLocalisedMessages;
  children: ReactNode;
} & StandardFormProps;

export const Select = ({
  node,
  label,
  field,
  children,
  localisedMessages,
  error,
  errorAccessibilityCompass,
  hint,
  inputFieldId,
  inputFieldErrorId,
  inputFieldHintId,
}: Props): JSX.Element => {
  const { isRequired } = node;
  const {
    html: { choosePromptHtml },
  } = localisedMessages;

  return (
    <div className="formally-node">
      {label}
      {hint}
      {error}
      <div className="formally-input--select-wrapper">
        <select
          id={inputFieldId}
          required={isRequired}
          aria-required={isRequired}
          aria-describedby={classNames({
            [inputFieldErrorId]: !!error,
            [inputFieldHintId]: !!hint,
          })}
          className="formally-input formally-input--select"
          aria-invalid={!!error}
          {...field}
        >
          {choosePromptHtml && !!choosePromptHtml.value ? (
            <option
              disabled
              lang={choosePromptHtml.locale}
              value=""
              /* Note can't use 'selected' as React wants value set on <select> */
            >
              {choosePromptHtml.value
                ? stripHtml(choosePromptHtml.value)
                : null}
            </option>
          ) : null}
          {children}
        </select>
      </div>

      {errorAccessibilityCompass}
    </div>
  );
};

Range

Displays an <input type="range"> slider.

Range.tsx
import type { NodeRange as RangeNode } from '../../../FormallyData';

import { RangeLocalisedMessages } from './Range.locale';
import { classNames } from '../../Utils/classNames';
import { StandardFormProps } from '../SwitchFormFieldNode';

type Props = {
  node: RangeNode;
  localisedMessages: RangeLocalisedMessages;
} & StandardFormProps;

export const Range = ({
  node,
  field,
  label,
  error,
  errorAccessibilityCompass,
  hint,
  inputFieldId,
  inputFieldErrorId,
  inputFieldHintId,
  localisedMessages,
}: Props): JSX.Element => {
  const { step, min, max, hideValue } = node;
  const {
    html: { minLabelHtml, maxLabelHtml },
  } = localisedMessages;

  const { value, ...otherField } = field;
  const minId = maxLabelHtml && `${inputFieldId}-min`;
  const maxId = maxLabelHtml && `${inputFieldId}-max`;

  const valueIsUndefined = value === '' || value === undefined;
  const defaultValue = (max - min) / 2 + min;

  return (
    <div className="formally-node">
      <div className="formally-slider">
        {label}
        {hint}
        {error}
        {!hideValue && <div className="formally-slider__value">{value}</div>}
        <input
          className={classNames({
            'formally-slider__input': true,
            'formally-slider__input--unset':
              typeof value === 'string'
                ? value.length === 0
                : value === undefined,
          })}
          type="range"
          id={inputFieldId}
          aria-valuenow={valueIsUndefined ? defaultValue : parseFloat(value)}
          aria-describedby={classNames({
            [inputFieldErrorId]: !!error,
            [inputFieldHintId]: !!hint,
          })}
          // Not allowed using `required` and `aria-required` on range sliders
          // aria-required={node.isRequired}
          // required={node.isRequired}

          min={min}
          max={max}
          step={
            step
          } /* defaultValue and step can conflict eg defaultValue=4.5 step=1 will mean the browser displays 5. We accept that this will happen but this may need more consideration. */
          value={valueIsUndefined ? defaultValue : value}
          {...otherField}
        />
        {minLabelHtml && maxLabelHtml && (
          <div className="formally-slider__minMaxLabel" aria-hidden>
            {/* TODO: handle RTL languages and reverse this? */}
            <span
              id={minId}
              lang={minLabelHtml.locale}
              dangerouslySetInnerHTML={{
                __html: minLabelHtml.value || '',
              }}
            />
            <span
              id={maxId}
              lang={maxLabelHtml.locale}
              dangerouslySetInnerHTML={{
                __html: maxLabelHtml.value || '',
              }}
            />
          </div>
        )}
        {errorAccessibilityCompass}
      </div>
    </div>
  );
};

DateInput

Displays an date input with discrete fields for year, month, day, hours, minutes, and seconds.

DateInput.tsx
import { classNames } from '../../Utils/classNames';
import { NodeDateInput as DateInputNode } from '../../../FormallyData';
import { StandardFormDateInputProps } from '../SwitchFormFieldNode';
import { Label } from '../Label.Control';
import { DateInputLocalisedMessages } from './DateInput.locale';
import { LocalisedPlaintextMessage } from '../../Utils/useLocalisedMessage';

type Props = {
  node: DateInputNode;
  localisedMessages: DateInputLocalisedMessages;
  localisedRequiredLabel: LocalisedPlaintextMessage;
} & StandardFormDateInputProps;

export const DateInput = ({
  node,
  fieldsetLegendLabel,
  hint,
  error,
  inputFieldErrorId,
  inputFieldHintId,
  dayField,
  monthField,
  yearField,
  hourField,
  minuteField,
  secondField,
  localisedMessages,
  localisedRequiredLabel,
}: Props): JSX.Element => {
  const { isRequired, showDate, showTime, showSecond } = node;

  const {
    html: {
      hourLabelHtml,
      minuteLabelHtml,
      dayLabelHtml,
      monthLabelHtml,
      yearLabelHtml,
      secondLabelHtml,
    },
    plaintext: {
      hourPlaceholder,
      minutePlaceholder,
      dayPlaceholder,
      monthPlaceholder,
      yearPlaceholder,
      secondPlaceholder,
    },
  } = localisedMessages;

  return (
    <div className="formally-node">
      <fieldset
        className="formally-dateInput__fieldset"
        role="group"
        aria-describedby={classNames({
          [inputFieldHintId]: !!hint,
          [inputFieldErrorId]: !!error,
        })}
      >
        <legend className="formally-dateInput__fieldset__label">
          {fieldsetLegendLabel}
        </legend>

        {hint}

        {error}

        <div className="formally-dateInput">
          {/**
           * Order of fields per https://design-system-alpha.digital.govt.nz/components/Date/
           * DD-MM-YYYY
           *
           * So the final ordering is
           *
           * HH-MM-SS DD-MM-YYYY
           *
           * Arguably we should rearrange the order based on the browser's
           * localisation info, or allow it to be configured,
           * so TODO on that
           *
           * If you do want to change the order then syncronise
           * to the answer summary AnswerSummary.util.tsx
           */}

          {showTime && (
            <>
              <div className="formally-dateInput--item">
                <Label
                  as="label"
                  htmlFor={hourField.id}
                  isRequired={false} // even if the whole DateInput is required we don't need * on each label
                  className="formally-dateInput__label"
                  labelHtml={hourLabelHtml}
                  requiredLabel={localisedRequiredLabel}
                />
                <input
                  className="formally-input formally-dateInput-input--width-2"
                  type="text"
                  inputMode="decimal"
                  pattern="[0-9]*?"
                  placeholder={hourPlaceholder.value}
                  maxLength={2}
                  required={isRequired}
                  aria-required={isRequired}
                  {...hourField}
                />
              </div>

              <div className="formally-dateInput--item">
                <Label
                  as="label"
                  isRequired={false} // even if the whole DateInput is required we don't need * on each label
                  className="formally-dateInput__label"
                  htmlFor={minuteField.id}
                  labelHtml={minuteLabelHtml}
                  requiredLabel={localisedRequiredLabel}
                />
                <input
                  className="formally-input formally-dateInput-input--width-2"
                  type="text"
                  inputMode="decimal"
                  pattern="[0-9]*?"
                  placeholder={minutePlaceholder.value}
                  maxLength={2}
                  required={isRequired}
                  aria-required={isRequired}
                  {...minuteField}
                />
              </div>
            </>
          )}

          {showSecond && (
            <div className="formally-dateInput--item">
              <Label
                as="label"
                htmlFor={secondField.id}
                isRequired={false} // even if the whole DateInput is required we don't need * on each label
                className="formally-dateInput__label"
                labelHtml={secondLabelHtml}
                requiredLabel={localisedRequiredLabel}
              />
              <input
                className="formally-input formally-dateInput-input--width-2"
                type="text"
                inputMode="decimal"
                pattern="[0-9]*?"
                placeholder={secondPlaceholder.value}
                maxLength={2}
                required={isRequired}
                aria-required={isRequired}
                {...secondField}
              />
            </div>
          )}

          {showDate && (
            <>
              <div className="formally-dateInput--item">
                <Label
                  as="label"
                  htmlFor={dayField.id}
                  isRequired={false} // even if the whole DateInput is required we don't need * on each label
                  className="formally-dateInput__label"
                  labelHtml={dayLabelHtml}
                  requiredLabel={localisedRequiredLabel}
                />
                <input
                  className="formally-input formally-dateInput-input--width-2"
                  type="text"
                  inputMode="decimal"
                  pattern="[0-9]*?"
                  placeholder={dayPlaceholder.value}
                  maxLength={2}
                  required={isRequired}
                  aria-required={isRequired}
                  {...dayField}
                />
              </div>
              <div className="formally-dateInput--item">
                <Label
                  as="label"
                  htmlFor={monthField.id}
                  isRequired={false} // even if the whole DateInput is required we don't need * on each label
                  className="formally-dateInput__label"
                  labelHtml={monthLabelHtml}
                  requiredLabel={localisedRequiredLabel}
                />
                <input
                  className="formally-input formally-dateInput-input--width-2"
                  type="text"
                  inputMode="decimal"
                  pattern="[0-9]*?"
                  placeholder={monthPlaceholder.value}
                  maxLength={2}
                  required={isRequired}
                  aria-required={isRequired}
                  {...monthField}
                />
              </div>
              <div className="formally-dateInput--item">
                <Label
                  as="label"
                  htmlFor={yearField.id}
                  isRequired={false} // even if the whole DateInput is required we don't need * on each label
                  className="formally-dateInput__label"
                  labelHtml={yearLabelHtml}
                  requiredLabel={localisedRequiredLabel}
                />
                <input
                  className="formally-input formally-dateInput-input--width-4"
                  type="text"
                  inputMode="decimal"
                  pattern="[0-9]*?"
                  placeholder={yearPlaceholder.value}
                  maxLength={4}
                  required={isRequired}
                  aria-required={isRequired}
                  {...yearField}
                />
              </div>
            </>
          )}
        </div>
      </fieldset>
    </div>
  );
};

Answer Summary

Displays a summary of all answers.

AnswerSummary.tsx
import { useCallback } from 'react';
import { H, Level } from 'react-accessible-headings';

import {
  LocalisedHtmlMessage,
  LocalisedPlaintextMessage,
} from '../../Utils/useLocalisedMessage';
import { FormallyData } from '../../../FormallyData';
import { Label } from '../Label.Control';
import { AnswerSummaryLocalisedMessages } from './AnswerSummary.locale';

type AnswerSummaryProps = {
  answers: Answer[];
  localisedMessages: AnswerSummaryLocalisedMessages;
  formallyData: FormallyData;
  localisedRequiredLabel: LocalisedPlaintextMessage;
  goToField: (pageIndex: number, elementId: string) => void;
  hasLinkBack: boolean;
};

export const AnswerSummary = ({
  answers,
  localisedMessages,
  formallyData,
  localisedRequiredLabel,
  goToField,
  hasLinkBack,
}: AnswerSummaryProps): JSX.Element => {
  const { titleHtml } = localisedMessages.html;

  return (
    <div className="formally-node">
      <Level>
        {titleHtml.value && (
          <H
            lang={titleHtml.locale}
            dangerouslySetInnerHTML={{ __html: titleHtml.value }}
            className="formally-answersummary__heading"
          />
        )}
        <ul className="formally-answersummary__list--top-level">
          {answers.map((childAnswer, index) => (
            <AnswerItem
              key={`${index}${childAnswer.labelLocalisedHtml}${childAnswer.value}`}
              {...childAnswer}
              {...{
                localisedRequiredLabel,
                localisedMessages,
                formallyData,
                goToField,
                hasLinkBack,
              }}
            />
          ))}
        </ul>
      </Level>
    </div>
  );
};

type AnswerItemProps = Answer & {
  localisedMessages: AnswerSummaryLocalisedMessages;
  localisedRequiredLabel: LocalisedPlaintextMessage;
  formallyData: FormallyData;
  goToField: AnswerSummaryProps['goToField'];
  hasLinkBack: boolean;
};

const AnswerItem = (props: AnswerItemProps) => {
  const {
    nodeId,
    pageIndex,
    labelLocalisedHtml,
    localisedMessages,
    value,
    children,
    localisedRequiredLabel,
    formallyData,
    goToField,
    elementId,
    hasLinkBack,
  } = props;
  const node = formallyData.items[nodeId];
  const {
    emptyAnswerHtml,
    linkBackFieldButtonHtml,
    linkBackNonFieldButtonHtml,
    linkBackPageButtonHtml,
  } = localisedMessages.html;
  const { pagePrefix } = localisedMessages.plaintext;

  const linkBackButtonHtml = node.isFormField
    ? linkBackFieldButtonHtml
    : node.type === 'Page'
    ? linkBackPageButtonHtml
    : linkBackNonFieldButtonHtml;

  const handleGoToField = useCallback(() => {
    goToField(pageIndex, elementId);
  }, [linkBackButtonHtml, pageIndex, elementId, goToField]);

  const pageNumber = `${pageIndex + 1}`;

  return (
    <li className="formally-answersummary__listitem">
      {node.type === 'Page' && (
        <b lang={pagePrefix.locale}>
          {pagePrefix.value
            ?.replace(/{number}/g, pageNumber)
            .replace(/{pageNumber}/g, pageNumber)}
        </b>
      )}{' '}
      <Label
        as="span"
        isInline
        // don't use 'labelProps.id' for this, see dev note
        labelHtml={labelLocalisedHtml}
        isRequired={node.isFormField ? node.isRequired : false}
        requiredLabel={localisedRequiredLabel}
      />
      {value && ': '}
      {value}{' '}
      {value === '""' && (
        <>
          {' '}
          <span
            lang={emptyAnswerHtml.locale}
            dangerouslySetInnerHTML={{ __html: emptyAnswerHtml.value || '' }}
          />
        </>
      )}
      {hasLinkBack && (
        <>
          {' '}
          {linkBackButtonHtml.value?.trim() && (
            <button
              type="button"
              onClick={handleGoToField}
              lang={linkBackButtonHtml.locale}
              dangerouslySetInnerHTML={{
                __html: linkBackButtonHtml.value.replace(
                  /{number}/g,
                  `${pageIndex + 1}`,
                ),
              }}
              className="formally-answersummary-linkback-button"
            />
          )}
        </>
      )}
      {children && (
        <ul className="formally-answersummary__child-list">
          {children.map((childAnswer, index) => (
            <AnswerItem
              key={`${index}${childAnswer.labelLocalisedHtml}${childAnswer.value}`}
              {...{
                localisedMessages,
                localisedRequiredLabel,
                formallyData,
                goToField,
                hasLinkBack,
              }}
              {...childAnswer}
            />
          ))}
        </ul>
      )}
    </li>
  );
};

export type Answer = {
  nodeId: string;
  pageIndex: number;
  labelLocalisedHtml: LocalisedHtmlMessage;
  elementId: string;
  value?: string;
  children?: Answer[];
};

Errors

ErrorSummary

A wrapper around the error summary which displays a list of errors at the top of the page.

This feature is enabled by default.

ErrorSummary.tsx
import { H, Level } from 'react-accessible-headings';
import { FieldErrors } from 'react-hook-form';
import { ErrorSummaryLocalisedMessages } from './ErrorSummary.Utils';
import { ErrorSummaryItem } from './Page/Page.Hooks';

type Props = {
  containerId: string;
  pageIndex: number;
  locale: string;
  children: JSX.Element;
  errorsObj: FieldErrors;
  errors: ErrorSummaryItem[];
  localisedMessages: ErrorSummaryLocalisedMessages;
};

export const ErrorSummary = ({
  containerId,
  localisedMessages,
  errors,
  children,
}: Props): JSX.Element | null => {
  if (errors.length === 0) return null;
  const {
    html: { errorSummaryIntroHtml },
  } = localisedMessages;

  const labelledById = `${containerId}-errorSummaryIntro`;

  return (
    <nav
      id={containerId}
      className="formally-error-summary"
      tabIndex={
        -1 // so that it can be programatically focused.
      }
      aria-labelledby={labelledById}
    >
      <Level>
        <H
          id={labelledById}
          className="formally-error-summary__intro"
          lang={errorSummaryIntroHtml.locale}
          dangerouslySetInnerHTML={
            errorSummaryIntroHtml.value
              ? {
                  __html: errorSummaryIntroHtml.value,
                }
              : undefined
          }
        />
        <ul className="formally-error-summary__list">{children}</ul>
      </Level>
    </nav>
  );
};

ErrorSummaryItem

Displays each error summary item.

ErrorSummaryItem.tsx
import { useScrollToInternalLink } from '../Utils/scrollToInternalLink';
import { LocalisedHtmlMessage } from '../Utils/useLocalisedMessage';
import { ErrorSummaryItem as ErrorItem } from './Page/Page.Hooks';

type Props = {
  error: ErrorItem;
  localisedErrorMessage: LocalisedHtmlMessage | undefined;
};

export const ErrorSummaryItem = ({
  error,
  localisedErrorMessage,
}: Props): JSX.Element | null => {
  const scrollToInternalLink = useScrollToInternalLink();
  if (!localisedErrorMessage) return null;

  return (
    <li className="formally-error-summary__item">
      <a
        href={error.href}
        onClick={scrollToInternalLink}
        lang={localisedErrorMessage.locale}
        dangerouslySetInnerHTML={
          localisedErrorMessage.value
            ? { __html: localisedErrorMessage.value }
            : undefined
        }
      />
    </li>
  );
};

ErrorScreen

A wrapper around any errors that occur during form submission.

ErrorScreen.tsx
import { FormSubmitCallbackData } from '../Formally';

type Props = {
  children: JSX.Element;
  formSubmitCallbackData: FormSubmitCallbackData;
};

export const ErrorScreen = ({
  children,
  formSubmitCallbackData,
}: Props): JSX.Element => {
  return (
    <div className="formally-submit-error">
      {formSubmitCallbackData.localisedMessage && (
        <div role="alert" aria-live="polite" aria-atomic>
          {formSubmitCallbackData.localisedMessage}
        </div>
      )}
      {children}
    </div>
  );
};

Field Error

Displays an individual field error.

FieldError.tsx
import React from 'react';

import type { Id } from '../../FormallyData';
import { ErrorSummaryItem } from './Page/Page.Hooks';

type Props = {
  inputFieldErrorId: Id;
  children: React.ReactNode;
  previousErrorSummaryItem: ErrorSummaryItem | undefined;
  nextErrorSummaryItem: ErrorSummaryItem | undefined;
};

export const FieldError = ({
  children,
  inputFieldErrorId,
}: Props): JSX.Element => {
  return (
    <>
      <div
        aria-live="polite"
        role="alert"
        className="formally-error-message"
        id={inputFieldErrorId}
      >
        <span className="formally-sr-text">Error: </span>
        {children}
      </div>
    </>
  );
};

ErrorAccessibilityCompass

The Accessibility Compass is a prototype accessibility feature.

The Accessibility Compass metaphor comes from the idea that each field with an error would also link to the 'preceding error' and/or 'following error' so that they can navigate directly between fields with errors without having to navigate back to the Error Summary.

This feature is disabled by default.

We're very keen to hear feedback about this feature.

Formally UI wrappers

Page

Displays a wrapper around the page content.

Page.tsx
import { classNames } from '../../Utils/classNames';
import type { NodePage as PageNode, Id } from '../../../FormallyData';

import { ClickOrSubmitHandler } from '../Form';
import { PageLocalisedMessages } from './Page.locale';

type Props = {
  page: PageNode;
  requiredField?: JSX.Element;
  localisedMessages: PageLocalisedMessages;
  isHorizontalLayout: boolean;
  isFirstVisiblePage: boolean;
  isLastVisiblePage: boolean;
  onPreviousClick: ClickOrSubmitHandler;
  onNextClick: ClickOrSubmitHandler;
  currentVisiblePageIndex: number; // starts at 0
  totalVisiblePages: number; // starts at 1 (ie, a single page under root will have totalPages === 1)
  errorSummary: JSX.Element | null;
  children: JSX.Element;
  title: JSX.Element | null;
  buttonGroup: JSX.Element;
  currentPageIndex: number;
  totalPages: number;
  hasMultipleVisiblePages: boolean;
  currentPageId: Id | undefined;
};

export const Page = ({
  errorSummary,
  children,
  title,
  buttonGroup,
  requiredField,
  isHorizontalLayout,
}: Props): JSX.Element => {
  return (
    <>
      {title}
      {errorSummary}
      {requiredField}
      <div
        className={classNames({
          'formally-layout': true,
          'formally-layout--horizontal': isHorizontalLayout,
          'formally-layout--vertical': !isHorizontalLayout,
        })}
      >
        {children}
        {buttonGroup}
      </div>
    </>
  );
};

PageWrapper

Displays a wrapper around the page.

PageWrapper.tsx
export type PageWrapperProps = {
  totalVisiblePages: number; // total pages minus conditional pages that are not visible
  currentVisiblePageIndex: number; // current page index while ignoring conditional pages that aren't visible
  currentPageIndex: number; // starts at 0
  totalPages: number; // starts at 1 (a single page under root has totalPages === 1)
  children: JSX.Element;
};

export const PageWrapper = ({ children }: PageWrapperProps): JSX.Element => {
  return <>{children}</>;
};

ProgressIndicator

Displays a message showing form progress e.g. 'Page 1 of 4'.

ProgressIndicator.tsx
import { LocalisedHtmlMessage } from '../../Utils/useLocalisedMessage';
import { classNames } from '../../Utils/classNames';

type Props = {
  totalVisiblePages: number; // total pages minus conditional pages that are not visible
  currentVisiblePageIndex: number; // current page index while ignoring conditional pages that aren't visible
  currentPageIndex: number; // starts at 0
  totalPages: number; // starts at 1 (a single page under root has totalPages === 1)
  localisedProgressIndicatorHtml: LocalisedHtmlMessage;
};

export const ProgressIndicator = ({
  currentPageIndex,
  currentVisiblePageIndex,
  totalPages,
  totalVisiblePages,
  localisedProgressIndicatorHtml,
}: Props): JSX.Element => {
  return (
    <div className="formally-progress-indicator-container">
      <div className="formally-progress-indicator">
        {Array.from(Array(totalVisiblePages)).map((value, index) => (
          <div
            key={index}
            className={classNames({
              'formally-progress-indicator__indicator': true,
              'formally-progress-indicator__indicator--is-previous':
                currentVisiblePageIndex > index,
              'formally-progress-indicator__indicator--is-active':
                index === currentVisiblePageIndex,
            })}
            aria-hidden
          ></div>
        ))}
      </div>
      <div
        className="formally-progress-indicator-container__text"
        lang={localisedProgressIndicatorHtml.locale}
        dangerouslySetInnerHTML={
          localisedProgressIndicatorHtml.value
            ? { __html: localisedProgressIndicatorHtml.value }
            : undefined
        }
      />
    </div>
  );
};

PageTitle

Displays a message for the page title.

PageTitle.tsx
import { H } from 'react-accessible-headings';
import { LocalisedHtmlMessage } from '../../Utils/useLocalisedMessage';

type Props = {
  titleHtml: LocalisedHtmlMessage;
};

export const PageTitle = ({ titleHtml }: Props): JSX.Element | null => {
  if (!titleHtml.value) return null;

  return (
    <H
      className="formally-page-title"
      lang={titleHtml.locale}
      dangerouslySetInnerHTML={{
        __html: titleHtml.value,
      }}
    />
  );
};

FormRoot

A wrapper around the whole form

FormRoot.tsx
import { useCallback } from 'react';
import { ReactNode, type KeyboardEvent } from 'react';
import type { NodeRoot } from '../../../FormallyData';

import { ClickOrSubmitHandler } from '../Form';

type Props = {
  root: NodeRoot;
  children: ReactNode;
  handleFormSubmit: ClickOrSubmitHandler;
};

export const FormRoot = ({
  children,
  handleFormSubmit,
}: Props): JSX.Element => {
  const preventShiftSubmit = useCallback(
    (e: KeyboardEvent<HTMLFormElement>) => {
      if (e.shiftKey && e.key === 'Enter') {
        e.stopPropagation();
        e.preventDefault();
      }
    },
    [handleFormSubmit],
  );

  return (
    <form
      className="formally-form"
      noValidate
      onKeyDown={preventShiftSubmit}
      onSubmit={handleFormSubmit}
    >
      {children}
    </form>
  );
};

Buttons

ButtonGroup

A wrapper around the previous/next/submit buttons.

ButtonGroup.tsx
import { classNames } from '../Utils/classNames';

type Props = {
  children: JSX.Element;
  hasPreviousButton: boolean;
  hasNextButton: boolean;
  hasSubmitButton: boolean;
  isHorizontalLayout?: boolean;
};

export const ButtonGroup = ({
  children,
  hasPreviousButton,
  hasNextButton,
  hasSubmitButton,
  isHorizontalLayout,
}: Props): JSX.Element => {
  return (
    <div
      className={classNames({
        'formally-button-group': true,
        'formally-button-group--is-vertical-layout': !isHorizontalLayout,
        'formally-button-group--is-horizontal-layout': !!isHorizontalLayout,
        'formally-button-group--has-previous-button': hasPreviousButton,
        'formally-button-group--has-next-button': hasNextButton,
        'formally-button-group--has-submit-button': hasSubmitButton,
      })}
    >
      {children}
    </div>
  );
};

RepeaterButton

Displays a button for adding a repeating section of the form.

PreviousButton

Displays a button for navigating to the previous page.

PreviousButton.tsx
import { LocalisedHtmlMessage } from '../Utils/useLocalisedMessage';
import { Button } from './Button';
import { ClickOrSubmitHandler } from './Form';

type Props = {
  onClick: ClickOrSubmitHandler;
  innerHtml: LocalisedHtmlMessage;
};

export const PreviousButton = ({ innerHtml, onClick }: Props): JSX.Element => {
  return (
    <Button
      {...{ innerHtml, onClick }}
      buttonType="button"
      buttonStyle="secondary"
    />
  );
};

NextButton

Displays a button for navigating to the next page.

NextButton.tsx
import { LocalisedHtmlMessage } from '../Utils/useLocalisedMessage';
import { Button } from './Button';
import { ClickOrSubmitHandler } from './Form';

type Props = {
  onClick: ClickOrSubmitHandler;
  innerHtml: LocalisedHtmlMessage;
};

export const NextButton = ({ innerHtml, onClick }: Props): JSX.Element => {
  return (
    <Button
      {...{ innerHtml, onClick }}
      buttonType="button"
      buttonStyle="primary"
    />
  );
};

SubmitButton

Displays a button for submitting the form.

SubmitButton.tsx
import { LocalisedHtmlMessage } from '../Utils/useLocalisedMessage';
import { Button } from './Button';

type Props = {
  innerHtml: LocalisedHtmlMessage;
  isDisabled: boolean;
};

export const SubmitButton = ({ innerHtml, isDisabled }: Props): JSX.Element => {
  return (
    <Button
      innerHtml={innerHtml}
      isDisabled={isDisabled}
      buttonStyle="primary"
      buttonType="submit"
    />
  );
};

SubmitPending

Displays a message while the form is being submitted.

SubmitPending.tsx
import { LocalisedHtmlMessage } from '../Utils/useLocalisedMessage';

type Props = {
  innerHtml: LocalisedHtmlMessage;
};

export const SubmitPending = ({ innerHtml }: Props): JSX.Element => {
  if (innerHtml.value === undefined) {
    throw Error(
      `Formally: SubmitPending missing necessary localisation ${JSON.stringify(
        innerHtml,
      )}`,
    );
  }

  return (
    <div
      role="alert"
      lang={innerHtml.locale}
      className="formally-submit-pending"
      dangerouslySetInnerHTML={{ __html: innerHtml.value }}
    />
  );
};

CheckboxesGroup

Displays a <fieldset> around groups of checkbox options.

CheckboxesGroup.tsx
import type { NodeOptionGroup } from '../../../FormallyData';

import { classNames } from '../../Utils/classNames';
import { OptionGroupLocalisedMessages } from '../Options/OptionGroup.locale';

type Props = {
  node: NodeOptionGroup;
  children: JSX.Element;
  localisedMessages: OptionGroupLocalisedMessages;
  hint: JSX.Element | null;
  inputHintId: string;
};

export const CheckboxesGroup = ({
  children,
  localisedMessages,
  hint,
  inputHintId,
}: Props): JSX.Element => {
  const {
    html: { labelHtml },
  } = localisedMessages;

  return (
    <fieldset
      className="formally-fieldset"
      aria-describedby={classNames({
        [inputHintId]: !!hint,
      })}
    >
      <legend
        className="formally-legend formally-legend--nested"
        lang={labelHtml.locale}
        dangerouslySetInnerHTML={
          labelHtml.value ? { __html: labelHtml.value } : undefined
        }
      />
      {hint}
      {children}
    </fieldset>
  );
};

Checkbox

Displays a <input type="checkbox">.

Checkbox.tsx
import type { NodeCheckbox } from '../../../FormallyData';

import { classNames } from '../../Utils/classNames';
import { StandardFormProps } from '../SwitchFormFieldNode';
import { CheckboxLocalisedMessages } from './Checkbox.locale';

type Props = {
  checked: boolean;
  field: StandardFormProps['field'];
  node: NodeCheckbox;
  localisedMessages: CheckboxLocalisedMessages;
} & StandardFormProps;

export const Checkbox = ({
  checked,
  field,
  hint,
  node,
  inputFieldId,
  inputFieldHintId,
  inputFieldErrorId,
  localisedRequiredLabel,
  localisedMessages,
  label,
  error,
}: Props): JSX.Element => {
  const {
    html: { labelHtml, hintHtml },
  } = localisedMessages;

  return (
    <div className="formally-node">
      {error}
      <div className="formally-choice">
        <input
          {...field}
          type="checkbox"
          className="formally-choice__input formally-choice__input--checkbox"
          id={inputFieldId}
          aria-describedby={classNames({
            [inputFieldHintId]: !!hint,
            [inputFieldErrorId]: !!error,
          })}
          checked={checked} // Test that this is correctly overridden when a custom checkbox is subbed.
        />
        {label}
        {hint}
      </div>
    </div>
  );
};

RadiosGroup

Displays a <fieldset> around groups of radio options.

RadiosGroup.tsx
import type { NodeOptionGroup } from '../../../FormallyData';
import { classNames } from '../../Utils/classNames';

import { OptionGroupLocalisedMessages } from '../Options/OptionGroup.locale';

type Props = {
  node: NodeOptionGroup;
  children: JSX.Element;
  localisedMessages: OptionGroupLocalisedMessages;
  inputFieldHintId: string;
  hint: JSX.Element | null;
};

export const RadiosGroup = ({
  children,
  localisedMessages,
  inputFieldHintId,
  hint,
}: Props): JSX.Element => {
  const {
    html: { labelHtml },
  } = localisedMessages;

  return (
    <fieldset
      className="formally-fieldset"
      aria-describedby={classNames({
        [inputFieldHintId]: !!hint,
      })}
    >
      <legend
        className="formally-legend formally-legend--nested"
        lang={labelHtml.locale}
        dangerouslySetInnerHTML={
          labelHtml.value ? { __html: labelHtml.value } : undefined
        }
      />
      {hint}
      {children}
    </fieldset>
  );
};

Radio

Displays a <input type="radio">.

Radio.tsx
import type { NodeOption, NodeRadios } from '../../../FormallyData';

import { OptionLocalisedMessages } from '../Options/Option.locale';
import { classNames } from '../../Utils/classNames';
import { Label } from '../Label.Control';
import { LocalisedPlaintextMessage } from '../../Utils/useLocalisedMessage';
import { StandardFormProps } from '../SwitchFormFieldNode';

type Props = {
  field: StandardFormProps['field'];
  checked: boolean;
  node: NodeOption;
  localisedMessages: OptionLocalisedMessages;
  hint: JSX.Element | null;
  inputFieldId: string;
  inputFieldHintId: string;
  radios: NodeRadios;
  requiredLabel: LocalisedPlaintextMessage;
  // There's no errors possible per-radio so we don't provide that id
};

export const Radio = ({
  field,
  checked,
  node,
  localisedMessages,
  hint,
  inputFieldId,
  inputFieldHintId,
  radios,
  requiredLabel,
}: Props): JSX.Element => {
  const {
    html: { labelHtml },
  } = localisedMessages;

  return (
    <div className="formally-choice">
      <input
        {...field}
        checked={checked}
        type="radio"
        className="formally-choice__input formally-choice__input--radio"
        id={inputFieldId}
        value={node.id}
        aria-describedby={classNames({
          [inputFieldHintId]: !!hint,
        })}
        // aria-required={radios.isRequired} // not supported by radio
      />
      <Label
        as="label"
        isRequired={false}
        requiredLabel={requiredLabel}
        className={classNames({
          'formally-choice__label--hint': !!hint,
          'formally-choice__label--radio formally-choice__label': true,
        })}
        htmlFor={inputFieldId}
        labelHtml={labelHtml}
        isOptionLabel={true}
      />
      {hint}
    </div>
  );
};

SelectOptionGroup

Displays an <optgroup>.

SelectOptionGroup.tsx
import type { NodeOptionGroup } from '../../../FormallyData';

import { OptionGroupLocalisedMessages } from '../Options/OptionGroup.locale';
import { stripHtmlForAttribute } from '../../Utils/stripHtml';

type Props = {
  node: NodeOptionGroup;
  children: JSX.Element;
  localisedMessages: OptionGroupLocalisedMessages;
  hint: JSX.Element | null; // sadly can't render a hint in web standard <select> but might be useful for custom controls
};

export const SelectOptionGroup = ({
  children,
  localisedMessages,
}: Props): JSX.Element => {
  const {
    html: { labelHtml },
  } = localisedMessages;
  return (
    <optgroup
      lang={labelHtml.locale}
      label={stripHtmlForAttribute(labelHtml.value)}
    >
      {children}
    </optgroup>
  );
};

SelectOption

Displays an <option>.

SelectOption.tsx
import type { NodeOption } from '../../../FormallyData';
import { OptionLocalisedMessages } from '../Options/Option.locale';
import { stripHtml } from '../../Utils/stripHtml';
import { StandardFormProps } from '../SwitchFormFieldNode';

type Props = {
  node: NodeOption;
  selected: boolean; // in React the native <select> has the selected value set on the <select> but this prop is given in case a non-native <select> is used
  field: StandardFormProps['field'];
  localisedMessages: OptionLocalisedMessages;
  hint: JSX.Element | null; // sadly can't render a hint in web standard <select> but might be useful for custom controls
};

export const SelectOption = ({
  node,
  localisedMessages,
}: Props): JSX.Element => {
  // Following code might be useful if using a non-native <select>
  // which could allow hints per option etc
  //
  // const { name: id } = field;
  // const inputFieldId = `${id}-${node.id}-input`; // required naming convention
  // const inputHintId = `${id}-${node.id}-hint`; // required naming convention

  const {
    html: { labelHtml },
  } = localisedMessages;

  return (
    <option value={node.id} lang={labelHtml.locale}>
      {labelHtml.value ? stripHtml(labelHtml.value) : null}
    </option>
  );
};

SuccessScreen

A wrapper around the success page.

SuccessScreen.tsx
import { FormSubmitCallbackData } from '../Formally';

type Props = {
  children: JSX.Element;
  formSubmitCallbackData: FormSubmitCallbackData;
};

export const SuccessScreen = ({
  children,
  formSubmitCallbackData,
}: Props): JSX.Element => {
  return (
    <div className="formally-success-screen" role="alert">
      {children}
    </div>
  );
};

LocalePicker

Displays a dropdown of locales for the user to choose.

LocalePicker.tsx
import { LocalisedPlainText } from '../Formally';
import { LocalisedHtmlMessage } from '../Utils/useLocalisedMessage';
import { Label } from './Label.Control';

type LocalePickerProps = {
  fieldId: string;
  labelHtml: LocalisedHtmlMessage;
  locale: string;
  locales: string[];
  setLocale: (locale: string) => void;
  localeNames: LocalisedPlainText;
  worldIconHtml: LocalisedHtmlMessage;
};

export const LocalePicker = ({
  fieldId,
  locale,
  setLocale,
  localeNames,
  locales,
  labelHtml,
  worldIconHtml,
}: LocalePickerProps): JSX.Element => {
  return (
    <div className="formally-locale-picker ">
      <Label
        as="label"
        htmlFor={fieldId}
        labelHtml={labelHtml}
        isRequired={false}
        requiredLabel={{
          type: 'plaintext',
          value: undefined,
          locale: undefined,
        }}
        iconHtml={worldIconHtml}
      />
      <div className="formally-input--select-wrapper formally-locale-picker__select">
        <select
          id={fieldId}
          value={locale}
          onChange={(e) => setLocale(e.target.value)}
          className="formally-input formally-input--select"
        >
          {locales.map((locale) => (
            <option key={locale} value={locale} lang={locale}>
              {localeNames[locale] || locale}
            </option>
          ))}
        </select>
      </div>
    </div>
  );
};

RequiredFieldLabel

For displaying a message on the first page of the form explaining that "*" means a required field.

RequiredFieldLabel.tsx
import { LocalisedHtmlMessage } from '../Utils/useLocalisedMessage';

type LocalePickerWrapperProps = {
  localisedHtmlMessage: LocalisedHtmlMessage;
};

export const RequiredFieldLabel = ({
  localisedHtmlMessage,
}: LocalePickerWrapperProps): JSX.Element => {
  if (localisedHtmlMessage.value === undefined) {
    throw Error(
      `Formally: RequiredFieldLabel ${JSON.stringify(localisedHtmlMessage)}`,
    );
  }

  return (
    <p
      lang={localisedHtmlMessage.locale}
      dangerouslySetInnerHTML={{ __html: localisedHtmlMessage.value }}
    />
  );
};

Hint

Displays a hint per-field that is associated via aria-describedby.

Hint.tsx
import type { Id } from '../../FormallyData';
import { LocalisedHtmlMessage } from '../Utils/useLocalisedMessage';

type Props = {
  inputFieldHintId: Id;
  hintHtml: LocalisedHtmlMessage;
  className?: string | undefined;
};

export const Hint = ({
  inputFieldHintId,
  hintHtml,
  className,
}: Props): JSX.Element => {
  return (
    <div
      id={inputFieldHintId}
      className={`formally-hint ${className ? className : ''}`}
      lang={hintHtml.locale}
      dangerouslySetInnerHTML={
        hintHtml.value ? { __html: hintHtml.value } : undefined
      }
    />
  );
};

Fieldset

The HTML <fieldset> and <legend> component for wrapping related elements.

See fieldset documentation on MDN.

Fieldset.tsx
import { ReactNode } from 'react';
import type { NodeFieldset as FieldsetNode } from '../../../FormallyData';
import { FieldsetLocalisedMessages } from './Fieldset.locale';

type Props = {
  node: FieldsetNode;
  localisedMessages: FieldsetLocalisedMessages;
  children: ReactNode;
};

export const Fieldset = ({ localisedMessages, children }: Props) => {
  const {
    html: { legendHtml },
  } = localisedMessages;
  return (
    <fieldset className="formally-node formally-fieldset">
      <legend
        className="formally-legend"
        lang={legendHtml.locale}
        dangerouslySetInnerHTML={{
          __html: legendHtml.value || '',
        }}
      />
      {children}
    </fieldset>
  );
};