From 14c30c5d868fd6e84dc74ba2da1a36b29905e303 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 7 Apr 2025 06:41:23 -0400 Subject: [PATCH 01/15] #94 initial Checkbox --- src/common/components/Form/Checkbox.tsx | 68 +++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/common/components/Form/Checkbox.tsx diff --git a/src/common/components/Form/Checkbox.tsx b/src/common/components/Form/Checkbox.tsx new file mode 100644 index 0000000..d287964 --- /dev/null +++ b/src/common/components/Form/Checkbox.tsx @@ -0,0 +1,68 @@ +import { InputHTMLAttributes } from 'react'; +import { Control, FieldValues, Path, useController } from 'react-hook-form'; + +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'; + +export interface CheckboxProps + extends InputHTMLAttributes, + PropsWithTestId { + control: Control; + label: string; + name: string; + supportingText?: string; +} + +const Checkbox = ({ + className, + control, + label, + name, + supportingText, + testId = 'checkbox', + ...props +}: CheckboxProps): JSX.Element => { + const { field, fieldState } = useController({ control, name: name as Path }); + const isDisabled = props.disabled || props.readOnly; + + console.log('Checkbox fieldState:', fieldState); + console.log('Checkbox field:', field); + + return ( +
+
+ + +
+
+ + {supportingText && ( + {supportingText} + )} +
+
+ ); +}; + +export default Checkbox; From 03dd23d7f197220fa23e1a1cbe344969052b9c66 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 7 Apr 2025 06:41:34 -0400 Subject: [PATCH 02/15] #94 Checkbox examples --- src/common/components/Router/Router.tsx | 5 + src/pages/Components/ComponentsPage.tsx | 3 + .../components/CheckboxComponents.tsx | 156 ++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 src/pages/Components/components/CheckboxComponents.tsx diff --git a/src/common/components/Router/Router.tsx b/src/common/components/Router/Router.tsx index 8d4eae0..fa02189 100644 --- a/src/common/components/Router/Router.tsx +++ b/src/common/components/Router/Router.tsx @@ -28,6 +28,7 @@ const BreadcrumbsComponents = lazy( ); const ButtonComponents = lazy(() => import('pages/Components/components/ButtonComponents')); const CardComponents = lazy(() => import('pages/Components/components/CardComponents')); +const CheckboxComponents = lazy(() => import('pages/Components/components/CheckboxComponents')); const ColumnComponents = lazy(() => import('pages/Components/components/ColumnsComponents')); const ContainerComponents = lazy(() => import('pages/Components/components/ContainerComponents')); const DialogComponents = lazy(() => import('pages/Components/components/DialogComponents')); @@ -129,6 +130,10 @@ export const routes: RouteObject[] = [ path: 'card', element: withSuspense(), }, + { + path: 'checkbox', + element: withSuspense(), + }, { path: 'columns', element: withSuspense(), diff --git a/src/pages/Components/ComponentsPage.tsx b/src/pages/Components/ComponentsPage.tsx index 18225b5..7d42017 100644 --- a/src/pages/Components/ComponentsPage.tsx +++ b/src/pages/Components/ComponentsPage.tsx @@ -53,6 +53,9 @@ const ComponentsPage = (): JSX.Element => { Card + + Checkbox + Columns diff --git a/src/pages/Components/components/CheckboxComponents.tsx b/src/pages/Components/components/CheckboxComponents.tsx new file mode 100644 index 0000000..ba89103 --- /dev/null +++ b/src/pages/Components/components/CheckboxComponents.tsx @@ -0,0 +1,156 @@ +import { ColumnDef, createColumnHelper } from '@tanstack/react-table'; +import { useForm } from 'react-hook-form'; +import noop from 'lodash/noop'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import { BaseComponentProps } from 'common/utils/types'; +import { ComponentProperty } from '../model/components'; +import Table from 'common/components/Table/Table'; +import CodeSnippet from 'common/components/Text/CodeSnippet'; +import Heading from 'common/components/Text/Heading'; +import Button from 'common/components/Button/Button'; +import Checkbox from 'common/components/Form/Checkbox'; + +/** + * The `CheckboxComponents` component renders a set of examples illustrating + * the use of the `Checkbox` component. + */ +const CheckboxComponents = ({ + className, + testId = 'components-checkbox', +}: BaseComponentProps): JSX.Element => { + const data: ComponentProperty[] = [ + { + name: 'className', + description: 'Optional. Additional CSS class names.', + }, + { + name: 'control', + description: 'The React Hook Form control object.', + }, + { + name: 'label', + description: 'The label text.', + }, + { + name: 'name', + description: 'The form control name.', + }, + { + name: 'supportingText', + description: 'Optional. The supporting or help text.', + }, + { + name: 'testId', + description: 'Optional. Identifier for testing.', + }, + ]; + const columnHelper = createColumnHelper(); + const columns = [ + columnHelper.accessor('name', { + cell: (info) => ( + {info.getValue()} + ), + header: () => 'Name', + }), + columnHelper.accessor('description', { + cell: (info) => info.renderValue(), + header: () => 'Description', + }), + ] as ColumnDef[]; + + /* example setup */ + const formSchema = z.object({ + isAccepted: z.boolean().refine((val) => val === true, { + message: 'You must accept the terms and conditions', + }), + }); + const { control, handleSubmit, reset } = useForm({ + defaultValues: { + isAccepted: false, + }, + mode: 'all', + resolver: zodResolver(formSchema), + }); + + const onSubmit = noop; + + return ( +
+ + Checkbox Component + + +
+
+ The Input component renders a HTML input + element. It is used to capture a single line of text input. The Input component internally + uses the Label, HelpText, and FieldError components. +
+
+ In addition to the custom properties listed below, the Input component also accepts all + standard HTML input element attribute React properties. +
+ +
+ + Properties + + data={data} columns={columns} /> +
+ + + Examples + + + + Basic + +
+ This is the most basic use of the Input component. It has no label or supporting text. It + is integrated with React Hook Form through the "control" and "reset" values obtained from + the "useForm" hook (see the React Hook Form documentation for more information). +
+
+ To view an example validation error message, click or tab into the Last Name input and + then exit the field without entering a value. +
+
+
+ {/* Example */} +
+ + + +
+ + + +`} + /> +
+
+
+ ); +}; + +export default CheckboxComponents; From 235a0c0f056217599dbc97a10323f0af427bbdf6 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 8 Apr 2025 17:44:42 -0400 Subject: [PATCH 03/15] #94 disabled --- src/common/components/Form/Checkbox.tsx | 59 ++++++---- .../components/CheckboxComponents.tsx | 103 ++++++++++++++++-- 2 files changed, 130 insertions(+), 32 deletions(-) diff --git a/src/common/components/Form/Checkbox.tsx b/src/common/components/Form/Checkbox.tsx index d287964..ac8295a 100644 --- a/src/common/components/Form/Checkbox.tsx +++ b/src/common/components/Form/Checkbox.tsx @@ -1,61 +1,74 @@ -import { InputHTMLAttributes } from 'react'; import { Control, FieldValues, Path, useController } from 'react-hook-form'; -import { PropsWithTestId } from 'common/utils/types'; +import { BaseComponentProps } 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'; -export interface CheckboxProps - extends InputHTMLAttributes, - PropsWithTestId { +export interface CheckboxProps extends BaseComponentProps { control: Control; + disabled?: boolean; label: string; name: string; + required?: boolean; supportingText?: string; } const Checkbox = ({ className, control, + disabled = false, label, name, + required = false, supportingText, testId = 'checkbox', - ...props }: CheckboxProps): JSX.Element => { const { field, fieldState } = useController({ control, name: name as Path }); - const isDisabled = props.disabled || props.readOnly; + const isChecked = field.value === true; - console.log('Checkbox fieldState:', fieldState); - console.log('Checkbox field:', field); + // console.log(`Checkbox::${name}::`, { field, fieldState }); + // console.log(`Checkbox::${name}::error::`, fieldState.error); + + const handleClick = () => { + if (!disabled) { + field.onChange(!isChecked); + } + }; return (
- +
-
+
{supportingText && ( {supportingText} diff --git a/src/pages/Components/components/CheckboxComponents.tsx b/src/pages/Components/components/CheckboxComponents.tsx index ba89103..fe8a648 100644 --- a/src/pages/Components/components/CheckboxComponents.tsx +++ b/src/pages/Components/components/CheckboxComponents.tsx @@ -63,12 +63,18 @@ const CheckboxComponents = ({ /* example setup */ const formSchema = z.object({ isAccepted: z.boolean().refine((val) => val === true, { - message: 'You must accept the terms and conditions', + message: 'You must accept the terms and conditions. ', }), + isDisabledChecked: z.boolean(), + isDisabledUnchecked: z.boolean(), + isOptInMarketing: z.boolean(), }); const { control, handleSubmit, reset } = useForm({ defaultValues: { isAccepted: false, + isDisabledChecked: true, + isDisabledUnchecked: false, + isOptInMarketing: true, }, mode: 'all', resolver: zodResolver(formSchema), @@ -108,13 +114,18 @@ const CheckboxComponents = ({ Basic
- This is the most basic use of the Input component. It has no label or supporting text. It - is integrated with React Hook Form through the "control" and "reset" values obtained from - the "useForm" hook (see the React Hook Form documentation for more information). + This is the most basic use of the Checkbox component. It is integrated with React Hook + Form through the "control" and "reset" values obtained from the "useForm" hook (see the + React Hook Form documentation for more information).
- To view an example validation error message, click or tab into the Last Name input and - then exit the field without entering a value. + Use the "required" property to make the checkbox required. Use the "supportingText" + property to add helpful information below the input containing instructions, validation + requirements, or other tips for entering information. +
+
+ To view an example validation error message, check and then uncheck the terms and + conditions checkbox.
@@ -125,7 +136,14 @@ const CheckboxComponents = ({ name="isAccepted" label="I accept the terms & conditions" className="mb-4" - supportingText="Do you accept the terms and conditions?" + required + supportingText="You must accept the terms and conditions to proceed." + /> + +`} + /> +
+ + + Disabled + +
+ Use the "disabled" property to disable the checkbox. The checkbox will be unresponsive to + user input and will be visually styled to indicate its disabled state. +
+
+
+ {/* Example */} +
+ + + +
+ + + `} />
From 798e6ab445adce39101aab74cebc7cbe9b2d4a33 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Apr 2025 05:44:45 -0400 Subject: [PATCH 04/15] #94 tests --- package-lock.json | 132 ++++++++++++++++++ package.json | 4 +- src/common/components/Form/Checkbox.tsx | 1 + .../components/CheckboxComponents.tsx | 1 + .../__tests__/CheckboxComponents.test.tsx | 49 +++++++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 src/pages/Components/components/__tests__/CheckboxComponents.test.tsx diff --git a/package-lock.json b/package-lock.json index 84d74d3..7b70314 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "@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", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-refresh": "0.4.19", @@ -1902,6 +1903,13 @@ "node": ">=14" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.28", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", + "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", + "dev": true, + "license": "MIT" + }, "node_modules/@react-hook/intersection-observer": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@react-hook/intersection-observer/-/intersection-observer-3.1.2.tgz", @@ -4086,6 +4094,56 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.1.1.tgz", + "integrity": "sha512-2HpiRIYg3dlvAJBV9RtsVswFgUSJK4Sv7QhpxoP0eBGkYwzGIKP34PjaV00AULQi9Ovl6LGyZfsetxDWY5BQdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.1.1", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.1", + "tinyglobby": "^0.2.12", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "3.1.1" + } + }, + "node_modules/@vitest/ui/node_modules/@vitest/pretty-format": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", + "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui/node_modules/@vitest/utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", + "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.1.1", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/utils": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", @@ -5659,6 +5717,28 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", + "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7165,6 +7245,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8546,6 +8636,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8895,6 +9000,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", + "integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tinypool": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", @@ -8958,6 +9080,16 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", diff --git a/package.json b/package.json index aee8dda..4a63f9c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "storybook": "storybook dev -p 6006", "test": "vitest", "test:coverage": "vitest --coverage --coverage.all=false", - "test:ci": "vitest run --coverage --silent" + "test:ci": "vitest run --coverage --silent", + "test:ui": "vitest --ui --coverage --silent" }, "dependencies": { "@codesandbox/sandpack-react": "2.20.0", @@ -77,6 +78,7 @@ "@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", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-refresh": "0.4.19", diff --git a/src/common/components/Form/Checkbox.tsx b/src/common/components/Form/Checkbox.tsx index ac8295a..b4ff23b 100644 --- a/src/common/components/Form/Checkbox.tsx +++ b/src/common/components/Form/Checkbox.tsx @@ -56,6 +56,7 @@ const Checkbox = ({ onClick={handleClick} role="checkbox" aria-checked={field.value === true} + data-testid={`${testId}-button`} > {isChecked && } diff --git a/src/pages/Components/components/CheckboxComponents.tsx b/src/pages/Components/components/CheckboxComponents.tsx index fe8a648..a5f49f3 100644 --- a/src/pages/Components/components/CheckboxComponents.tsx +++ b/src/pages/Components/components/CheckboxComponents.tsx @@ -138,6 +138,7 @@ const CheckboxComponents = ({ className="mb-4" required supportingText="You must accept the terms and conditions to proceed." + testId="checkbox-terms" /> { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('components-checkbox'); + + // ASSERT + expect(screen.getByTestId('components-checkbox')).toBeDefined(); + }); + + it('should click buttons', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('components-checkbox'); + + // ACT + await user.click(screen.getByTestId('reset-1')); + + // ASSERT + expect(screen.getByTestId('components-checkbox')).toBeDefined(); + }); + + it('should show validation error', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('checkbox-terms-button'); + + // ACT + // Click the button to check the checkbox + await user.click(screen.getByTestId('checkbox-terms-button')); + // Click the button to uncheck the checkbox + await user.click(screen.getByTestId('checkbox-terms-button')); + await waitFor(() => { + expect(screen.getByTestId('checkbox-terms-error')).toBeDefined(); + }); + + // ASSERT + expect(screen.getByTestId('checkbox-terms-error')).toBeDefined(); + }); +}); From 24167828c2d21538d27cfa528def7dfa2ed35434 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Wed, 9 Apr 2025 06:13:10 -0400 Subject: [PATCH 05/15] #94 styles --- src/common/components/Form/Checkbox.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/common/components/Form/Checkbox.tsx b/src/common/components/Form/Checkbox.tsx index b4ff23b..bd44d6b 100644 --- a/src/common/components/Form/Checkbox.tsx +++ b/src/common/components/Form/Checkbox.tsx @@ -60,16 +60,11 @@ const Checkbox = ({ > {isChecked && } -
-
+
{supportingText && ( {supportingText} From 4a30659e033ff40368df315da24b0cf935b5045d Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Thu, 10 Apr 2025 08:28:42 -0400 Subject: [PATCH 06/15] #94 use cva --- src/common/components/Form/Checkbox.tsx | 48 ++++++++++++++++++------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/common/components/Form/Checkbox.tsx b/src/common/components/Form/Checkbox.tsx index bd44d6b..71fe711 100644 --- a/src/common/components/Form/Checkbox.tsx +++ b/src/common/components/Form/Checkbox.tsx @@ -1,4 +1,5 @@ import { Control, FieldValues, Path, useController } from 'react-hook-form'; +import { cva } from 'class-variance-authority'; import { BaseComponentProps } from 'common/utils/types'; import { cn } from 'common/utils/css'; @@ -7,6 +8,36 @@ import FieldError from './FieldError'; import HelpText from '../Text/HelpText'; import FAIcon from '../Icon/FAIcon'; +/** + * Define the `Checkbox` component base and variant styles. + */ +const checkboxVariants = cva('flex size-4 appearance-none items-center justify-center rounded-sm', { + variants: { + checked: { + true: 'bg-blue-600', + false: 'bg-gray-300', + }, + disabled: { + true: 'cursor-not-allowed opacity-50', + false: 'cursor-pointer', + }, + }, + defaultVariants: { + checked: false, + disabled: false, + }, +}); + +/** + * Properties for the `Checkbox` component. + * @param {Control} control - Object containing methods for registering components + * into React Hook Form. + * @param {boolean} [disabled] - Optional. Indicates if the checkbox is disabled. Default: `false` + * @param {string} label - The label text. + * @param {string} name - Name of the form control. + * @param {boolean} [required] - Optional. Indicates if the checkbox is required. Default: `false` + * @param {string} [supportingText] - Optional. Help text or instructions. + */ export interface CheckboxProps extends BaseComponentProps { control: Control; disabled?: boolean; @@ -16,6 +47,10 @@ export interface CheckboxProps extends BaseComponentProps supportingText?: string; } +/** + * The `Checkbox` component renders a checkbox input. It is used to capture + * boolean input from a user. + */ const Checkbox = ({ className, control, @@ -29,9 +64,6 @@ const Checkbox = ({ const { field, fieldState } = useController({ control, name: name as Path }); const isChecked = field.value === true; - // console.log(`Checkbox::${name}::`, { field, fieldState }); - // console.log(`Checkbox::${name}::error::`, fieldState.error); - const handleClick = () => { if (!disabled) { field.onChange(!isChecked); @@ -44,15 +76,7 @@ const Checkbox = ({ -
diff --git a/src/common/components/Form/__tests__/Checkbox.test.tsx b/src/common/components/Form/__tests__/Checkbox.test.tsx new file mode 100644 index 0000000..eb94323 --- /dev/null +++ b/src/common/components/Form/__tests__/Checkbox.test.tsx @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; + +import { render, screen } from 'test/test-utils'; + +import Checkbox, { CheckboxProps } from '../Checkbox'; + +const formSchema = z.object({ + isEnabled: z.boolean().refine((val) => val === true, { message: 'Required' }), +}); + +type FormValues = z.infer; + +/** + * A wrapper for testing the `Checkbox` component which requires some + * react-hook-form objects passed as props. + */ +const CheckboxWrapper = (props: Omit, 'control'>) => { + const form = useForm({ + defaultValues: { isEnabled: false }, + resolver: zodResolver(formSchema), + }); + + const onSubmit = () => {}; + + return ( +
+ + + + ); +}; + +describe('Checkbox', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('checkbox'); + + // ASSERT + expect(screen.getByTestId('checkbox')).toBeDefined(); + }); + + it('should show label', async () => { + // ARRANGE + const labelText = 'Enable'; + render(); + await screen.findByTestId('checkbox-label'); + + // ASSERT + const checkboxLabel = screen.getByTestId('checkbox-label'); + expect(checkboxLabel).toHaveTextContent(labelText); + }); + + it('should be checked when clicked', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('checkbox'); + + const checkboxButton = screen.getByTestId('checkbox-button'); + expect(checkboxButton).toHaveAttribute('aria-checked', 'false'); + + // ACT + await user.click(checkboxButton); + + // ASSERT + expect(checkboxButton).toHaveAttribute('aria-checked', 'true'); + expect(checkboxButton).toHaveClass('bg-blue-600'); + const checkIcon = screen.queryByTestId('checkbox-icon'); + expect(checkIcon).toBeInTheDocument(); + }); + + it('should display validation error', async () => { + // ARRANGE + const user = userEvent.setup(); + render(); + await screen.findByTestId('checkbox'); + + const checkboxButton = screen.getByTestId('checkbox-button'); + expect(checkboxButton).toHaveAttribute('aria-checked', 'false'); + + // ACT + const submitButton = screen.getByTestId('button-submit'); + await user.click(submitButton); + + // ASSERT + const errorMessage = screen.getByTestId('checkbox-error'); + expect(errorMessage).toHaveTextContent(/required/i); + }); + + it('should be disabled when disabled prop is true', async () => { + // ARRANGE + render(); + await screen.findByTestId('checkbox'); + + // ASSERT + const checkboxButton = screen.getByTestId('checkbox-button'); + expect(checkboxButton).toHaveAttribute('aria-disabled', 'true'); + expect(checkboxButton).toHaveClass('cursor-not-allowed'); + expect(checkboxButton).toHaveClass('opacity-50'); + expect(checkboxButton).toBeDisabled(); + }); + + it('should display supporting text', async () => { + // ARRANGE + const supportingText = 'This is a supporting text'; + render(); + await screen.findByTestId('checkbox'); + + // ASSERT + const checkboxSupportingText = screen.getByTestId('checkbox-supporting-text'); + expect(checkboxSupportingText).toHaveTextContent(supportingText); + }); +}); From 5938c3d816bcebeb2a33a6d6518b8ed8f05c64a7 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 11 Apr 2025 06:23:02 -0400 Subject: [PATCH 09/15] #94 Label cva --- src/common/components/Form/Label.tsx | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/common/components/Form/Label.tsx b/src/common/components/Form/Label.tsx index a909e6f..b954c08 100644 --- a/src/common/components/Form/Label.tsx +++ b/src/common/components/Form/Label.tsx @@ -1,8 +1,24 @@ import { LabelHTMLAttributes } from 'react'; +import { cva } from 'class-variance-authority'; import { cn } from 'common/utils/css'; import { BaseComponentProps } from 'common/utils/types'; +/** + * Define the `Label` component base and variant styles. + */ +const labelVariants = cva('mb-1 block text-sm font-medium', { + variants: { + required: { + true: 'font-bold after:content-["*"]', + false: '', + }, + }, + defaultVariants: { + required: false, + }, +}); + /** * Properties for the `Label` component. * @param {boolean} [required] - Optional. Indicates if the label is for a required field. @@ -17,8 +33,6 @@ export interface LabelProps extends BaseComponentProps, LabelHTMLAttributes { return ( -