import { z } from 'zod' import { BlockDefinition, ResolvableArray, ResolvableBoolean, ResolvableString, EvaluatedBlock, FieldBlockDefinition, } from '@ministryofjustice/hmpps-forge/core/components' import { nunjucksComponent } from '../../utils/nunjucksComponent' import { normaliseGovukErrorMessage, normaliseGovukFieldset, normaliseGovukTextParam, renderGovukBlocksToHtml, type GovukRenderedBlockContent, } from '../../utils/govukParamNormalisers' /** * GOV.UK Radio Input component. * Allows users to select a single option from a list of mutually exclusive choices. * * @see https://design-system.service.gov.uk/components/radios/ * @example * ```typescript * GovUKRadioInput({ * code: 'contact_method', * label: 'How would you like to be contacted?', * items: [ * { value: 'email', text: 'Email' }, * { value: 'phone', text: 'Phone' }, * { value: 'text', text: 'Text message' }, * ], * }) * ``` */ export interface GovUKRadioInput extends FieldBlockDefinition { /** * The label for the radio group. * When using fieldset, this becomes the legend text if no fieldset legend is specified. * @example 'How would you like to be contacted?' */ label?: ResolvableString /** * Can be used to add a fieldset to the radios component. * Provides semantic grouping and accessibility benefits for multiple related inputs. */ fieldset?: { /** * Legend for the fieldset - describes the group of radio options. * If not provided, falls back to the `label` property. */ legend?: { /** Text content of the legend */ text?: ResolvableString /** HTML content of the legend (takes precedence over text) */ html?: ResolvableString /** Additional CSS classes for the legend */ classes?: ResolvableString /** Whether to render the legend as a page heading (wrapped in h1) */ isPageHeading?: ResolvableBoolean } /** Additional CSS classes for the fieldset wrapper */ classes?: ResolvableString /** HTML attributes to add to the fieldset */ attributes?: Record /** Element IDs to add to the fieldset's aria-describedby attribute */ describedBy?: ResolvableString } /** * Can be used to add a hint to the radios component. * Provides additional context or instructions for the radio group. * * @example 'Select all that apply' // Simple hint * @example { html: 'Choose the most appropriate option' } // Rich HTML hint */ hint?: | ResolvableString | { /** Unique ID for the hint (auto-generated if not provided) */ id?: ResolvableString /** Text content of the hint */ text?: ResolvableString /** HTML content of the hint (takes precedence over text) */ html?: ResolvableString /** Additional CSS classes for the hint */ classes?: ResolvableString /** Additional HTML attributes for the hint */ attributes?: Record } /** * Additional options for the form group containing the radios component. * Allows customization of the wrapper element and additional content. */ formGroup?: { /** * Classes to add to the form group wrapper. * Useful for custom styling or indicating error states. */ classes?: ResolvableString /** HTML attributes to add to the form group wrapper */ attributes?: Record /** * Content to add before all radio items within the radios component. * Useful for additional instructions or context. */ beforeInputs?: { /** Text content to add before all radio items */ text?: ResolvableString /** HTML content to add before all radio items (takes precedence over text) */ html?: ResolvableString /** Additional CSS classes for the before inputs content */ classes?: ResolvableString } /** * Content to add after all radio items within the radios component. * Useful for additional information or related actions. */ afterInputs?: { /** Text content to add after all radio items */ text?: ResolvableString /** HTML content to add after all radio items (takes precedence over text) */ html?: ResolvableString /** Additional CSS classes for the after inputs content */ classes?: ResolvableString } } /** * Optional prefix. This is used to prefix the `id` attribute for each radio input, * hint and error message, separated by `-`. Defaults to the `code` value. * @example 'contact-method' // Creates IDs like 'contact-method-email', 'contact-method-phone' */ idPrefix?: ResolvableString /** * Additional CSS classes to add to the radio container. * @example 'govuk-radios--inline' // Display radios horizontally * @example 'govuk-radios--small' // Smaller radio buttons */ classes?: ResolvableString /** * Additional HTML attributes (such as data attributes) to add to the radio input tag. * @example { 'data-module': 'govuk-radios' } */ attributes?: Record /** * The radio items within the radios component. * Can include both radio options and dividers for visual separation. * Can also be an expression for dynamic items using the Iterator pattern. * * @example [ * { value: 'yes', text: 'Yes' }, * { value: 'no', text: 'No' }, * { divider: 'or' }, * { value: 'maybe', text: 'Not sure' } * ] * * @example * // Dynamic items using Iterator * Data('areas').each(Iterator.Map({ value: Item().path('value'), text: Item().path('text') })) */ items: ResolvableArray } /** * Individual radio option within a radio group. * Represents a single selectable choice with optional conditional reveals. */ export interface GovUKRadioInputItem { /** * Value for the radio input. This is submitted with the form data when selected. * @example 'email' * @example 'phone' */ value: ResolvableString /** * Text to use within the radio item label. * If `html` is provided, this will be ignored. * @example 'Email' */ text?: ResolvableString /** * HTML to use within the radio item label. * Takes precedence over `text` if both are provided. * @example 'Email Fastest response' */ html?: ResolvableString /** * Specific ID attribute for the radio item. * If omitted, then `idPrefix` string will be applied with the value. * @example 'contact-email' */ id?: ResolvableString /** * Can be used to add a hint to each radio item within the radios component. * Provides additional context for individual options. * @example 'We'll send updates to this email address' */ hint?: | ResolvableString | { /** Unique ID for the hint (auto-generated if not provided) */ id?: ResolvableString /** Text content of the hint */ text?: ResolvableString /** HTML content of the hint (takes precedence over text) */ html?: ResolvableString /** Additional CSS classes for the hint */ classes?: ResolvableString /** Additional HTML attributes for the hint */ attributes?: Record } /** * Whether the radio should be checked when the page loads. * Takes precedence over the top-level `value` option. * @example true // Pre-select this option */ checked?: ResolvableBoolean /** * If `true`, radio will be disabled and cannot be selected. * @example true // Disable this option */ disabled?: ResolvableBoolean /** * Additional HTML attributes (such as data attributes) to add to the radio input tag. * @example { 'data-aria-controls': 'conditional-content' } */ attributes?: Record /** * Provide additional content to reveal when the radio is checked. * Useful for collecting additional information when specific options are selected. * @example someConditionalField // A field definition that appears when this radio is selected */ block?: BlockDefinition | BlockDefinition[] /** Conditional visibility for this radio item */ visibleWhen?: ResolvableBoolean } /** * Divider element to separate radio options visually. * Useful for grouping related options or providing "or" separators. */ export interface GovUKRadioInputDivider { /** * Divider text to separate radio items. * @example 'or' * @example 'Alternative options' */ divider: ResolvableString /** Conditional visibility for this divider */ visibleWhen?: ResolvableBoolean } /** * GOV.UK Radio Input component. * Allows users to select a single option from a list of mutually exclusive choices. * * @see https://design-system.service.gov.uk/components/radios/ * @example * ```typescript * GovUKRadioInput({ * code: 'contact_method', * label: 'How would you like to be contacted?', * items: [ * { value: 'email', text: 'Email' }, * { value: 'phone', text: 'Phone' }, * { value: 'text', text: 'Text message' }, * ], * }) * ``` */ export const GovUKRadioInput = nunjucksComponent('govukRadioInput', { field: true, inputSchema: z.string(), // The first rendered radio's id is the idPrefix, so error summary links land there. errorAnchor: props => props.idPrefix || props.code, render: (props, nunjucksEnv) => { const items = props.items .filter(option => option.visibleWhen !== false) .map(option => makeOption(option, props.value as string)) const params = { fieldset: normaliseGovukFieldset(props.fieldset, props.label), idPrefix: props.idPrefix || props.code, name: props.code, value: props.value, formGroup: props.formGroup, hint: normaliseGovukTextParam(props.hint), items, classes: props.classes, attributes: props.attributes, errorMessage: normaliseGovukErrorMessage(props.errors), } return nunjucksEnv.render('govuk/components/radios/template.njk', { params, }) }, }) const getConditionalContent = (block: GovukRenderedBlockContent) => { const html = renderGovukBlocksToHtml(block) if (html === undefined) { return undefined } return { html } } const makeOption = (option: EvaluatedBlock, checkedValue: string) => { if (isRadioDivider(option)) { return { divider: option.divider, } } return { value: option.value, text: option.text, html: option.html, id: option.id, hint: normaliseGovukTextParam(option.hint), checked: option.checked ?? checkedValue === option.value, conditional: getConditionalContent(option.block), disabled: option.disabled, attributes: option.attributes, } } // Narrow to Divider function isRadioDivider( option: EvaluatedBlock, ): option is EvaluatedBlock function isRadioDivider(option: any): option is GovUKRadioInputDivider { return option != null && typeof option === 'object' && 'divider' in option && !('value' in option) // prefer Divider if both accidentally exist }