Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ A detailed test coverage report is created in the `./coverage` directory.

> **NOTE:** This is the command which should be utilized by CI/CD platforms.

### `npm run test:ui`

Executes the tests and opens the [Vitest UI](https://vitest.dev/guide/ui) to view and interact with the unit tests.

### `npm run build`

Builds the app for production to the `dist` folder.
Expand All @@ -236,9 +240,9 @@ Build a static version the [Storybook][storybook] UI which may be deployed to a

### Cloud Resources

The AWS resources for this application component are provisioned via AWS CloudFormation. The `template.yml` file is the CloudFormation template.
The AWS resources for this application component are provisioned via AWS CloudFormation. CloudFormation templates are located in the `.aws/` directory. The `app.yml` file contains the CloudFormation template which provisions resources to host the React app. The `storybook.yml` file contains the CloudFormation template which provisions resources to host the Storybook static website.

The resources provisioned are:
The resources provisioned to host the React app are:

| Resource | Description |
| ----------------------- | ----------------------------------------------------------------------------- |
Expand Down
1,520 changes: 777 additions & 743 deletions package-lock.json

Large diffs are not rendered by default.

76 changes: 38 additions & 38 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"scripts": {
"clean": "rimraf coverage dist storybook-static",
"dev": "vite",
"dev": "vite --open",
"build": "tsc && vite build",
"build:storybook": "storybook build",
"lint": "eslint --max-warnings 0",
Expand All @@ -31,68 +31,68 @@
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2",
"@hookform/resolvers": "4.1.3",
"@hookform/resolvers": "5.0.1",
"@react-spring/web": "9.7.5",
"@tailwindcss/vite": "4.1.1",
"@tanstack/react-query": "5.71.3",
"@tanstack/react-query-devtools": "5.71.3",
"@tanstack/react-table": "8.21.2",
"axios": "1.8.4",
"@tailwindcss/vite": "4.1.4",
"@tanstack/react-query": "5.74.4",
"@tanstack/react-query-devtools": "5.74.6",
"@tanstack/react-table": "8.21.3",
"axios": "1.9.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"dayjs": "1.11.13",
"i18next": "24.2.3",
"i18next-browser-languagedetector": "8.0.4",
"i18next": "25.0.1",
"i18next-browser-languagedetector": "8.0.5",
"lodash": "4.17.21",
"qs": "6.14.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "7.55.0",
"react-i18next": "15.4.1",
"react-router-dom": "7.4.1",
"recharts": "2.15.1",
"tailwind-merge": "3.1.0",
"tailwindcss": "4.1.1",
"react-hook-form": "7.56.1",
"react-i18next": "15.5.1",
"react-router-dom": "7.5.2",
"recharts": "2.15.3",
"tailwind-merge": "3.2.0",
"tailwindcss": "4.1.4",
"uuid": "11.1.0",
"zod": "3.24.2"
"zod": "3.24.3"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.6",
"@eslint/js": "9.23.0",
"@storybook/addon-essentials": "8.6.11",
"@storybook/addon-interactions": "8.6.11",
"@storybook/addon-onboarding": "8.6.11",
"@storybook/addon-themes": "8.6.11",
"@storybook/blocks": "8.6.11",
"@storybook/react": "8.6.11",
"@storybook/react-vite": "8.6.11",
"@storybook/test": "8.6.11",
"@eslint/js": "9.25.1",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-onboarding": "8.6.12",
"@storybook/addon-themes": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.2.0",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/eslint__js": "8.42.3",
"@types/lodash": "4.17.16",
"@types/qs": "6.9.18",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/uuid": "10.0.0",
"@vitejs/plugin-react": "4.3.4",
"@vitest/coverage-v8": "3.1.1",
"@vitest/ui": "3.1.1",
"eslint": "9.23.0",
"@vitejs/plugin-react": "4.4.1",
"@vitest/coverage-v8": "3.1.2",
"@vitest/ui": "3.1.2",
"eslint": "9.25.1",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.19",
"eslint-plugin-react-refresh": "0.4.20",
"eslint-plugin-storybook": "0.12.0",
"globals": "16.0.0",
"jsdom": "26.0.0",
"msw": "2.7.3",
"jsdom": "26.1.0",
"msw": "2.7.5",
"prettier": "3.5.3",
"prettier-plugin-tailwindcss": "0.6.11",
"rimraf": "6.0.1",
"storybook": "8.6.11",
"typescript": "5.8.2",
"typescript-eslint": "8.29.0",
"vite": "6.2.6",
"vitest": "3.1.1"
"storybook": "8.6.12",
"typescript": "5.8.3",
"typescript-eslint": "8.31.0",
"vite": "6.3.3",
"vitest": "3.1.2"
}
}
209 changes: 209 additions & 0 deletions src/common/components/Form/RadioGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { createContext, InputHTMLAttributes, useContext } from 'react';
import { Control, FieldValues, Path, useController } from 'react-hook-form';
import { cva } from 'class-variance-authority';
import noop from 'lodash/noop';

import { PropsWithTestId } from 'common/utils/types';
import { cn } from 'common/utils/css';
import Label from './Label';
import FieldError from './FieldError';
import HelpText from '../Text/HelpText';
import FAIcon from '../Icon/FAIcon';

/**
* Defines the shape of the `RadioGroupContext` value.
* This context is used to share state and methods between the `RadioGroup`
* and its items.
*/
type RadioGroupContextValue = {
disabled?: boolean;
name: string;
setValue: (val: string) => void;
value?: string;
};

/**
* The `RadioGroupContext` instance.
*/
const RadioGroupContext = createContext<RadioGroupContextValue>({
disabled: false,
name: '',
setValue: noop,
value: undefined,
});

/**
* Define the `RadioGroup` options list base and variant styles.
*/
const radioGroupOptionsVariants = cva('flex', {
variants: {
orientation: {
horizontal: 'flex-row gap-8',
vertical: 'flex-col gap-2',
},
},
defaultVariants: {
orientation: 'vertical',
},
});

/**
* Properties for the `RadioGroup` component.
* @param {Control} control - Object containing methods for registering components
* into React Hook Form.
* @param {string} [label] - Optional. The label text to display.
* @param {string} name - Name of the form control.
* @param {string} [orientation] - Optional. Orientation of the radio group.
* @param {string} [supportingText] - Optional. Help text or instructions.
* @see {@link PropsWithTestId}
* @see {@link InputHTMLAttributes}
*/
export interface RadioGroupProps<T extends FieldValues>
extends Pick<
InputHTMLAttributes<HTMLInputElement>,
'children' | 'className' | 'disabled' | 'required'
>,
PropsWithTestId {
control: Control<T>;
label?: string;
name: string;
orientation?: 'horizontal' | 'vertical';
supportingText?: string;
}

/**
* The `RadioGroup` component renders a group of radio buttons. It is used to
* capture a single choice from a set of options.
*/
const RadioGroup = <T extends FieldValues>({
children,
className,
control,
disabled = false,
label,
name,
orientation = 'vertical',
required = false,
supportingText,
testId = 'radio-group',
}: RadioGroupProps<T>) => {
const { field, fieldState } = useController({ control, name: name as Path<T> });

return (
<div className={cn('flex flex-col gap-2', className)} data-testid={testId}>
{!!label && (
<Label htmlFor={name} required={required} className="mb-0" testId={`${testId}-label`}>
{label}
</Label>
)}
<RadioGroupContext.Provider
value={{
disabled,
name,
setValue: field.onChange,
value: field.value,
}}
>
<div className={cn(radioGroupOptionsVariants({ orientation }))}>{children}</div>
</RadioGroupContext.Provider>
<div>
<FieldError message={fieldState.error?.message} testId={`${testId}-error`} />
{!!supportingText && (
<HelpText testId={`${testId}-supporting-text`}>{supportingText}</HelpText>
)}
</div>
</div>
);
};

/**
* Define the `RadioGroup` item base and variant styles.
*/
const radioGroupItemVariants = cva('flex items-center', {
variants: {
disabled: {
true: 'opacity-50 cursor-not-allowed',
false: 'cursor-pointer',
},
},
defaultVariants: {
disabled: false,
},
});

/**
* Properties for the `RadioGroup.Item` component.
* @param {string} [className] - Optional. Additional class names.
* @param {boolean} [disabled] - Optional. Disables the radio button.
* @param {string} id - Required. The unique identifier for the radio button.
* @param {string} label - The label text for the radio button.
* @param {string} [testId] - Optional. The test ID for the radio button.
* @param {string} [value] - The value of the radio button.
* @see {@link PropsWithTestId}
* @see {@link InputHTMLAttributes}
*/
interface RadioGroupItemProps
extends Pick<InputHTMLAttributes<HTMLInputElement>, 'className' | 'disabled' | 'value'>,
Required<Pick<InputHTMLAttributes<HTMLInputElement>, 'id'>>,
PropsWithTestId {
label: string;
}

/**
* The `Item` component renders a single radio button within
* a `RadioGroup`. It is used to represent an individual option in the group.
*/
const Item = ({
className,
disabled = false,
id,
testId = 'radio-group-item',
...props
}: RadioGroupItemProps) => {
const {
disabled: groupDisabled,
name,
setValue,
value: currentValue,
} = useContext(RadioGroupContext);
const isChecked = currentValue === props.value;
const isDisabled = disabled || groupDisabled;

const handleChange = () => {
if (!isDisabled) {
setValue(props.value as string);
}
};

return (
<div
className={cn(radioGroupItemVariants({ disabled: isDisabled }))}
data-testid={testId}
onClick={handleChange}
>
<input
type="radio"
id={id}
name={name}
className={cn('size-0', className)}
disabled={isDisabled}
data-testid={`${testId}-input`}
onChange={handleChange}
checked={isChecked}
aria-checked={isChecked}
{...props}
/>
<FAIcon
icon={isChecked ? 'circleDot' : 'circle'}
className={cn('me-2', { 'text-blue-600': isChecked }, { 'text-neutral-500': !isChecked })}
testId={`${testId}-icon`}
/>
<label htmlFor={id} data-testid={`${testId}-label`}>
{props.label}
</label>
</div>
);
};
RadioGroup.Item = Item;

export default RadioGroup;
Loading