Custom Controls
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>
);
};