From e2d6f89523576520058024e3a02fad367148ac7e Mon Sep 17 00:00:00 2001 From: Michael Marszalek Date: Mon, 31 Jul 2023 14:12:32 +0200 Subject: [PATCH] feat(Radio, Fieldset): :sparkles: New `Radio` & `Fieldset` component (unreleased) (#666) Co-authored-by: Albertlarsen <31471142+Albertlarsen@users.noreply.github.com> --- .vscode/settings.json | 3 +- .../Typography/Label/Label.module.css | 1 + .../form/Fieldset/Fieldset.module.css | 28 ++++ .../form/Fieldset/Fieldset.test.tsx | 51 ++++++ .../src/components/form/Fieldset/Fieldset.tsx | 98 ++++++++++++ .../src/components/form/Fieldset/index.ts | 1 + .../components/form/Fieldset/useFieldset.ts | 17 ++ .../form/Radio/Group/Group.module.css | 11 ++ .../form/Radio/Group/Group.stories.tsx | 106 +++++++++++++ .../form/Radio/Group/Group.test.tsx | 72 +++++++++ .../src/components/form/Radio/Group/Group.tsx | 81 ++++++++++ .../src/components/form/Radio/Group/index.ts | 1 + .../react/src/components/form/Radio/Radio.mdx | 70 +++++++++ .../components/form/Radio/Radio.module.css | 142 +++++++++++++++++ .../components/form/Radio/Radio.stories.tsx | 33 ++++ .../src/components/form/Radio/Radio.test.tsx | 104 +++++++++++++ .../react/src/components/form/Radio/Radio.tsx | 99 ++++++++++++ .../react/src/components/form/Radio/index.ts | 27 ++++ .../src/components/form/Radio/useRadio.ts | 62 ++++++++ .../src/components/form/useFormField.test.tsx | 147 ++++++++++++++++++ .../react/src/components/form/useFormField.ts | 78 ++++++++++ packages/react/src/utils/objectUtils.test.ts | 14 +- packages/react/src/utils/objectUtils.ts | 29 ++++ yarn.lock | 16 +- 24 files changed, 1274 insertions(+), 17 deletions(-) create mode 100644 packages/react/src/components/form/Fieldset/Fieldset.module.css create mode 100644 packages/react/src/components/form/Fieldset/Fieldset.test.tsx create mode 100644 packages/react/src/components/form/Fieldset/Fieldset.tsx create mode 100644 packages/react/src/components/form/Fieldset/index.ts create mode 100644 packages/react/src/components/form/Fieldset/useFieldset.ts create mode 100644 packages/react/src/components/form/Radio/Group/Group.module.css create mode 100644 packages/react/src/components/form/Radio/Group/Group.stories.tsx create mode 100644 packages/react/src/components/form/Radio/Group/Group.test.tsx create mode 100644 packages/react/src/components/form/Radio/Group/Group.tsx create mode 100644 packages/react/src/components/form/Radio/Group/index.ts create mode 100644 packages/react/src/components/form/Radio/Radio.mdx create mode 100644 packages/react/src/components/form/Radio/Radio.module.css create mode 100644 packages/react/src/components/form/Radio/Radio.stories.tsx create mode 100644 packages/react/src/components/form/Radio/Radio.test.tsx create mode 100644 packages/react/src/components/form/Radio/Radio.tsx create mode 100644 packages/react/src/components/form/Radio/index.ts create mode 100644 packages/react/src/components/form/Radio/useRadio.ts create mode 100644 packages/react/src/components/form/useFormField.test.tsx create mode 100644 packages/react/src/components/form/useFormField.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index e7389ae072..32671accbe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,6 @@ ], "[ignore]": { "editor.defaultFormatter": "foxundermoon.shell-format" - } + }, + "jest.jestCommandLine": "yarn test" } diff --git a/packages/react/src/components/Typography/Label/Label.module.css b/packages/react/src/components/Typography/Label/Label.module.css index 197d1ef543..65a74827f3 100644 --- a/packages/react/src/components/Typography/Label/Label.module.css +++ b/packages/react/src/components/Typography/Label/Label.module.css @@ -4,6 +4,7 @@ display: inline-block; margin: 0; + padding: 0; color: var(--fds-semantic-text-neutral-default); } diff --git a/packages/react/src/components/form/Fieldset/Fieldset.module.css b/packages/react/src/components/form/Fieldset/Fieldset.module.css new file mode 100644 index 0000000000..f2b24069fd --- /dev/null +++ b/packages/react/src/components/form/Fieldset/Fieldset.module.css @@ -0,0 +1,28 @@ +.fieldset { + margin: 0; + padding: 0; + border: 0; + min-width: 0; +} + +.fieldset > :not(:first-child, :empty) { + margin-top: var(--fds-spacing-2); +} + +.description { + color: var(--fds-semantic-text-neutral-subtle); + font-weight: 400; +} + +.readonly { + opacity: 0.3; +} + +.readonly > .legend { + display: inline-flex; +} + +.disabled > .legend, +.disabled > .description { + color: var(--fds-semantic-border-neutral-subtle); +} diff --git a/packages/react/src/components/form/Fieldset/Fieldset.test.tsx b/packages/react/src/components/form/Fieldset/Fieldset.test.tsx new file mode 100644 index 0000000000..e9966ca4ca --- /dev/null +++ b/packages/react/src/components/form/Fieldset/Fieldset.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { Fieldset } from './Fieldset'; + +describe('Fieldset', () => { + test('has correct legend and description', () => { + render( +
, + ); + const fieldset = screen.getByRole('group', { name: 'test legend' }); + expect(fieldset).toBeDefined(); + expect(fieldset).toHaveAccessibleDescription('test description'); + }); + test('is described by error message and invalid', () => { + render( +
, + ); + + const errorFieldset = screen.getByRole('group', { + description: 'test description test error', + }); + expect(errorFieldset).toBeDefined(); + expect(errorFieldset).toHaveAccessibleDescription( + 'test description test error', + ); + expect(errorFieldset).toBeInvalid(); + }); + test('and its children are disabled', () => { + render( +
+ +
, + ); + + const input = screen.getByDisplayValue('test'); + + expect(input).toBeDisabled(); + expect(screen.getByRole('group')).toBeDisabled(); + }); +}); diff --git a/packages/react/src/components/form/Fieldset/Fieldset.tsx b/packages/react/src/components/form/Fieldset/Fieldset.tsx new file mode 100644 index 0000000000..a9dae36105 --- /dev/null +++ b/packages/react/src/components/form/Fieldset/Fieldset.tsx @@ -0,0 +1,98 @@ +import type { FieldsetHTMLAttributes, ReactNode } from 'react'; +import React, { useContext, forwardRef, createContext } from 'react'; +import cn from 'classnames'; +import { PadlockLockedFillIcon } from '@navikt/aksel-icons'; + +import { Label, Paragraph, ErrorMessage } from '../../Typography'; + +import { useFieldset } from './useFieldset'; +import classes from './Fieldset.module.css'; + +export type FieldsetContextType = { + error?: ReactNode; + errorId?: string; + disabled?: boolean; + readOnly?: boolean; + size?: 'xsmall' | 'small' | 'medium'; +}; + +export const FieldsetContext = createContext(null); + +export type FieldsetProps = { + /** A description of the fieldset. This will appear below the legend. */ + description?: ReactNode; + /** Toggle `disabled` all input fields within the fieldset. */ + disabled?: boolean; + /** If set, this will diplay an error message at the bottom of the fieldset. */ + error?: ReactNode; + /** The legend of the fieldset. */ + legend?: ReactNode; + /** The size of the fieldset. */ + size?: 'xsmall' | 'small' | 'medium'; + /** Toggle `readOnly` on fieldset context. + * @note This does not prevent fieldset values from being submited */ + readOnly?: boolean; +} & FieldsetHTMLAttributes; + +export const Fieldset = forwardRef( + (props, ref) => { + const { children, legend, description, error, ...rest } = props; + + const { fieldsetProps, size, readOnly, errorId, hasError, descriptionId } = + useFieldset(props); + + const fieldset = useContext(FieldsetContext); + + return ( + +
+ + {description && ( + + {description} + + )} + {children} +
+ {hasError && {error}} +
+
+
+ ); + }, +); diff --git a/packages/react/src/components/form/Fieldset/index.ts b/packages/react/src/components/form/Fieldset/index.ts new file mode 100644 index 0000000000..d27acb8805 --- /dev/null +++ b/packages/react/src/components/form/Fieldset/index.ts @@ -0,0 +1 @@ +export * from './Fieldset'; diff --git a/packages/react/src/components/form/Fieldset/useFieldset.ts b/packages/react/src/components/form/Fieldset/useFieldset.ts new file mode 100644 index 0000000000..7236a84182 --- /dev/null +++ b/packages/react/src/components/form/Fieldset/useFieldset.ts @@ -0,0 +1,17 @@ +import { useFormField } from '../useFormField'; + +import type { FieldsetProps } from './Fieldset'; + +/** Handles fieldset props and state */ +export const useFieldset = (props: FieldsetProps) => { + const formField = useFormField(props, 'fieldset'); + const { inputProps } = formField; + + return { + ...formField, + fieldsetProps: { + 'aria-invalid': inputProps['aria-invalid'], + 'aria-describedby': inputProps['aria-describedby'], + }, + }; +}; diff --git a/packages/react/src/components/form/Radio/Group/Group.module.css b/packages/react/src/components/form/Radio/Group/Group.module.css new file mode 100644 index 0000000000..f77e7e7462 --- /dev/null +++ b/packages/react/src/components/form/Radio/Group/Group.module.css @@ -0,0 +1,11 @@ +.xsmall, +.small, +.medium { + margin-left: calc(var(--fds-spacing-2) * -1); +} + +.inline { + display: flex; + flex-direction: row; + gap: var(--fds-spacing-2); +} diff --git a/packages/react/src/components/form/Radio/Group/Group.stories.tsx b/packages/react/src/components/form/Radio/Group/Group.stories.tsx new file mode 100644 index 0000000000..ccce932cd5 --- /dev/null +++ b/packages/react/src/components/form/Radio/Group/Group.stories.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; + +import { Button, Paragraph } from '../../..'; +import { Radio } from '../'; + +export default { + title: 'ikke utgitt/Radio/Group', + component: Radio.Group, + parameters: { + status: { + type: 'beta', + url: 'http://www.url.com/status', + }, + }, +} as Meta; + +export const Preview: StoryFn = (args) => ( + + Vanilje + Jordbær + Sjokolade + Jeg spiser ikke iskrem + +); + +Preview.args = { + legend: 'Hvilken iskremsmak er best?', + description: 'Velg din favorittsmak blant alternativene.', + readOnly: false, + disabled: false, + error: '', +}; + +export const Error: StoryFn = () => ( + + Bare ost + + Dobbeldekker + + Flammen + Snadder + +); + +export const Controlled: StoryFn = () => { + const [value, setValue] = useState(); + + return ( + <> + + + + Du har valgt: {value} + + setValue(e.target.value)} + > + Bare ost + + Dobbeldekker + + Flammen + Snadder + + + ); +}; + +export const ReadOnly = Preview.bind({}); + +ReadOnly.args = { + ...Preview.args, + readOnly: true, +}; + +export const Disabled = Preview.bind({}); + +Disabled.args = { + ...Preview.args, + disabled: true, +}; + +export const Inline: StoryFn = () => ( + + Ja + Nei + +); diff --git a/packages/react/src/components/form/Radio/Group/Group.test.tsx b/packages/react/src/components/form/Radio/Group/Group.test.tsx new file mode 100644 index 0000000000..6bc986eadd --- /dev/null +++ b/packages/react/src/components/form/Radio/Group/Group.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { Radio } from '..'; + +import { RadioGroup } from './Group'; + +describe('RadioGroup', () => { + test('has generated name for Radio children', () => { + render( + + test + , + ); + + const radio = screen.getByRole('radio'); + expect(radio).toHaveAttribute('name'); + }); + test('has passed name to Radio children', (): void => { + render( + + test + , + ); + + const radio = screen.getByRole('radio'); + expect(radio.name).toEqual('my name'); + }); + test('has passed required to Radio children', (): void => { + render( + + test + , + ); + + const radio = screen.getByRole('radio'); + expect(radio).toHaveAttribute('required'); + }); + test('has correct Radio defaultChecked & checked when defaultValue is used', () => { + render( + + test1 + test2 + test3 + , + ); + + const radio = screen.getByDisplayValue('test2'); + expect(radio.defaultChecked).toBeTruthy(); + expect(radio.checked).toBeTruthy(); + }); + test('has passed clicked Radio element to onChange', async () => { + const user = userEvent.setup(); + let onChangeValue = ''; + + render( + (onChangeValue = e.currentTarget.value)}> + test1 + test2 + test3 + , + ); + + const radio = screen.getByDisplayValue('test2'); + + await user.click(radio); + + expect(onChangeValue).toEqual('test2'); + expect(radio.checked).toBeTruthy(); + }); +}); diff --git a/packages/react/src/components/form/Radio/Group/Group.tsx b/packages/react/src/components/form/Radio/Group/Group.tsx new file mode 100644 index 0000000000..a6e26fe5aa --- /dev/null +++ b/packages/react/src/components/form/Radio/Group/Group.tsx @@ -0,0 +1,81 @@ +import type { ChangeEventHandler, ReactNode } from 'react'; +import React, { forwardRef, createContext, useId } from 'react'; +import cn from 'classnames'; + +import type { FieldsetProps } from '../../Fieldset'; +import { Fieldset } from '../../Fieldset'; +import type { RadioProps } from '../Radio'; + +import classes from './Group.module.css'; + +export type RadioGroupContextProps = { + name?: string; + value?: string | ReadonlyArray | number; + defaultValue?: string | ReadonlyArray | number; + required?: boolean; +} & Pick; + +export const RadioGroupContext = createContext( + null, +); + +export type RadioGroupProps = { + /** Collection of `Radio` components */ + children?: ReactNode; + /** Controlled state for `Radio` */ + value?: string | ReadonlyArray | number; + /** Default checked `Radio` */ + defaultValue?: string | ReadonlyArray | number; + /** Callback event with changed `Radio` */ + onChange?: ChangeEventHandler; + /** Toggle if collection of `Radio` are required */ + required?: boolean; + /** Orientation of `Radio` components. + * @note Only use `horizontal` for when you have two options and short labels + */ + inline?: boolean; +} & Omit; + +export const RadioGroup = forwardRef( + ( + { + onChange, + children, + value, + readOnly, + defaultValue, + name, + size = 'medium', + required, + inline, + ...rest + }, + ref, + ) => { + const nameId = useId(); + + return ( +
+ +
+ {children} +
+
+
+ ); + }, +); diff --git a/packages/react/src/components/form/Radio/Group/index.ts b/packages/react/src/components/form/Radio/Group/index.ts new file mode 100644 index 0000000000..8401278d65 --- /dev/null +++ b/packages/react/src/components/form/Radio/Group/index.ts @@ -0,0 +1 @@ +export * from './Group'; diff --git a/packages/react/src/components/form/Radio/Radio.mdx b/packages/react/src/components/form/Radio/Radio.mdx new file mode 100644 index 0000000000..ea915f2ce4 --- /dev/null +++ b/packages/react/src/components/form/Radio/Radio.mdx @@ -0,0 +1,70 @@ +import { Meta, Canvas, Story, Controls, Primary } from '@storybook/blocks'; +import { Information } from '../../../../../../docs-components'; +import * as RadioStories from './Radio.stories'; +import * as RadioGroupStories from './Group/Group.stories'; + + + +# Radio + + + +`Radio` er en knapp som skal brukes i kombinasjon med andre radioknapper for å velge mellom flere alternativer. Vi anbefaler å bruke `Radio.Group` hvis man har behov for radioknapper. + + + + +## Bruk + + + +```tsx +import '@digdir/design-system-tokens/brand/altinn/tokens.css'; // Importeres kun en gang i appen din. +import { Radio } from '@digdir/design-system-react'; + + + Ja + Nei +; +``` + +### Enkel + +`Radio` skal alltid ha en label i en form eller annen. Bruk din egen `label` eller ledetekten fra en annen plass, husk å bruk da `aria-label` eller `aria-labelledby` + + + +## `Radio.Group` + +Bruk `Radio.Group` for gruppering av radioknapper. + + + + +### Feilmelding + +Bruk `error` på `Radio.Group` for å vise feilmelding. + +Her må `Radio.Group` brukes da den aktivere korrekt stil og sørger for at innholdet har riktige attributter mtp. tilgjengelighet. + + + +### Kontrollert + +Bruk `value` på `Radio.Group` for å kontrollere verdiene selv. + + + +### Readonly + + + +### Disabled + + + +### Inline + +Bruk `inline` for å plassere alternativene på samme linje. Anbefales kun bruke dette dersom du har kun to alternativer og kort label, som i f.eks typisk ja/nei spørsmål. + + diff --git a/packages/react/src/components/form/Radio/Radio.module.css b/packages/react/src/components/form/Radio/Radio.module.css new file mode 100644 index 0000000000..5afacab766 --- /dev/null +++ b/packages/react/src/components/form/Radio/Radio.module.css @@ -0,0 +1,142 @@ +.container { + position: relative; + padding-left: calc(var(--fds-spacing-6) + 20px); + min-width: 44px; + min-height: 44px; +} + +.icon { + grid-area: input; + pointer-events: none; + height: 1.75em; + width: 1.75em; + z-index: 1; + margin: auto; + overflow: visible; +} + +.label { + min-height: 44px; + min-width: min-content; + display: inline-flex; + flex-direction: row; + gap: var(--fds-spacing-1); + align-items: center; + cursor: pointer; +} + +.description { + color: var(--fds-semantic-text-neutral-subtle); +} + +.control { + --fds-inner-focus-border-color: var(--fds-semantic-border-focus-boxshadow); + --fds-outer-focus-border-color: var(--fds-semantic-border-focus-outline); + --fds-focus-border-width: 3px; + + position: absolute; + left: 0; + top: 0; + min-width: 44px; + min-height: 44px; + display: inline-grid; + grid: [input] 1fr / [input] 1fr; + gap: var(--fds-spacing-2); + grid-auto-flow: column; +} + +.radio, +.radio .icon { + border-radius: var(--fds-border_radius-full); +} + +.input { + height: 100%; + width: 100%; + opacity: 0; + margin: 0; + grid-area: input; + cursor: pointer; +} + +.error > .input, +.error > .label, +.readonly > .control > .input, +.readonly > .label { + cursor: default; +} + +.disabled > .control .input, +.disabled > .label { + cursor: not-allowed; + color: var(--fds-semantic-border-neutral-subtle); +} + +.disabled > .description { + color: var(--fds-semantic-border-neutral-subtle); +} + +.input:not(:checked) ~ .icon .checked { + display: none; +} + +.input:checked ~ .icon .checked { + display: inline; + fill: var(--fds-semantic-border-input-hover); +} + +.input:not(:checked) ~ .icon .box { + stroke: var(--fds-semantic-border-input-default); +} + +.input:checked ~ .icon .box { + stroke: var(--fds-semantic-border-input-hover); +} + +.input:disabled ~ .icon .box { + stroke: var(--fds-semantic-border-neutral-subtle); +} + +.input:focus-visible ~ .icon { + outline: var(--fds-focus-border-width) solid var(--fds-outer-focus-border-color); + outline-offset: 0; +} + +.input:focus-visible ~ .icon .box { + stroke: var(--fds-semantic-border-focus-boxshadow); + stroke-width: var(--fds-focus-border-width); +} + +.input:disabled ~ .icon .checked { + fill: var(--fds-semantic-border-neutral-subtle); +} + +.error .input:not(:disabled, :focus-visible) ~ .icon .box { + stroke: var(--fds-semantic-border-danger-default); +} + +.error .input:not(:disabled, :focus-visible) ~ .icon .checked { + fill: var(--fds-semantic-border-danger-default); +} + +.readonly .input:read-only:not(:focus-visible) ~ .icon .box { + stroke: var(--fds-semantic-border-neutral-default); + fill: var(--fds-semantic-background-subtle); +} + +.readonly .input:read-only:not(:focus-visible):is(:checked) ~ .icon .checked { + fill: var(--fds-semantic-border-neutral-default); +} + +.container:not(.disabled, .readonly) > .control:hover { + background: var(--fds-semantic-surface-info-subtle-hover); +} + +.container:not(.disabled, .readonly) > .label:hover, +.container:not(.disabled, .readonly) > .control:hover ~ .label:hover { + color: var(--fds-semantic-border-input-hover); +} + +.container:not(.disabled, .readonly) > .control:hover .icon .box { + stroke: var(--fds-semantic-border-input-hover); +} diff --git a/packages/react/src/components/form/Radio/Radio.stories.tsx b/packages/react/src/components/form/Radio/Radio.stories.tsx new file mode 100644 index 0000000000..15dd2ea387 --- /dev/null +++ b/packages/react/src/components/form/Radio/Radio.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Radio } from '.'; + +type Story = StoryObj; + +export default { + title: 'ikke utgitt/Radio', + component: Radio, + parameters: { + status: { + type: 'beta', + url: 'http://www.url.com/status', + }, + }, +} as Meta; + +export const Preview: Story = { + args: { + children: 'Radio', + description: 'Description', + disabled: false, + readOnly: false, + value: 'value', + }, +}; + +export const Single: Story = { + args: { + value: 'value', + 'aria-label': 'Radio', + }, +}; diff --git a/packages/react/src/components/form/Radio/Radio.test.tsx b/packages/react/src/components/form/Radio/Radio.test.tsx new file mode 100644 index 0000000000..e3807d9c46 --- /dev/null +++ b/packages/react/src/components/form/Radio/Radio.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { Radio } from './Radio'; + +describe('Radio', () => { + test('has correct value and label', () => { + render(label); + expect(screen.getByLabelText('label')).toBeDefined(); + expect(screen.getByDisplayValue('test')).toBeDefined(); + }); + + test('has correct description', () => { + render( + + test + , + ); + expect( + screen.getByRole('radio', { description: 'description' }), + ).toBeDefined(); + }); + it('calls onChange and onClick when user clicks', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const onClick = jest.fn(); + + const value = 'test'; + + render( + + label + , + ); + + const radio = screen.getByRole('radio'); + + expect(radio.checked).toBeFalsy(); + + await user.click(radio); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(1); + expect(radio.checked).toBeTruthy(); + }); + + it('does not call onChange or onClick when user clicks and the radio is disabled', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const onClick = jest.fn(); + + render( + + disabled radio + , + ); + + const radio = screen.getByRole('radio'); + await user.click(radio); + + expect(radio).toBeDisabled(); + expect(onClick).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not call onChange or onClick when user clicks and the radio is readOnly', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const onClick = jest.fn(); + + render( + + readonly radio + , + ); + + const radio = screen.getByRole('radio'); + await user.click(radio); + + expect(radio).toHaveAttribute('readonly'); + expect(onClick).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + + //TODO is there a good way to test size? +}); diff --git a/packages/react/src/components/form/Radio/Radio.tsx b/packages/react/src/components/form/Radio/Radio.tsx new file mode 100644 index 0000000000..4fb1e4a02d --- /dev/null +++ b/packages/react/src/components/form/Radio/Radio.tsx @@ -0,0 +1,99 @@ +import type { InputHTMLAttributes, ReactNode, SVGAttributes } from 'react'; +import React, { forwardRef } from 'react'; +import cn from 'classnames'; + +import { omit } from '../../../utils'; +import { Label, Paragraph } from '../../Typography'; +import type { FormFieldProps } from '../useFormField'; + +import classes from './Radio.module.css'; +import { useRadio } from './useRadio'; + +const RadioIcon = (props: SVGAttributes) => ( + + + + +); + +export type RadioProps = { + /** Radio label */ + children?: ReactNode; + /** Value of the `input` element */ + value: string | ReadonlyArray | number | undefined; +} & Omit & + Omit, 'size' | 'value'>; + +export const Radio = forwardRef((props, ref) => { + const { children, description, ...rest } = props; + const { inputProps, descriptionId, hasError, size, readOnly } = + useRadio(props); + + return ( + + + + + + + {children && ( + + )} + {description && ( + + {description} + + )} + + ); +}); diff --git a/packages/react/src/components/form/Radio/index.ts b/packages/react/src/components/form/Radio/index.ts new file mode 100644 index 0000000000..f0de6d81f7 --- /dev/null +++ b/packages/react/src/components/form/Radio/index.ts @@ -0,0 +1,27 @@ +import type { RadioProps } from './Radio'; +import type { RadioGroupProps } from './Group'; +import { Radio as RadioParent } from './Radio'; +import { RadioGroup } from './Group'; + +type RadioComponent = typeof RadioParent & { + /** + * Grouping multiple `Radio` together. + * @example + * + * Yes + * No + * + */ + Group: typeof RadioGroup; +}; + +/** ` element with `type="radio"` used for selecting one option */ +const Radio = RadioParent as RadioComponent; + +Radio.Group = RadioGroup; + +Radio.Group.displayName = 'Radio.Group'; + +export type { RadioProps, RadioGroupProps }; + +export { Radio }; diff --git a/packages/react/src/components/form/Radio/useRadio.ts b/packages/react/src/components/form/Radio/useRadio.ts new file mode 100644 index 0000000000..71e1d4d901 --- /dev/null +++ b/packages/react/src/components/form/Radio/useRadio.ts @@ -0,0 +1,62 @@ +import type { InputHTMLAttributes } from 'react'; +import { useContext } from 'react'; + +import type { FormField } from '../useFormField'; +import { useFormField } from '../useFormField'; + +import type { RadioProps } from './Radio'; +import { RadioGroupContext } from './Group'; + +type UseRadio = (props: RadioProps) => FormField & { + inputProps?: Pick< + InputHTMLAttributes, + | 'readOnly' + | 'type' + | 'name' + | 'required' + | 'defaultChecked' + | 'checked' + | 'onClick' + | 'onChange' + >; +}; +/** Handles props for `Radio` in context with `Radio.Group` (and `Fieldset`) */ +export const useRadio: UseRadio = (props) => { + const radioGroup = useContext(RadioGroupContext); + const { inputProps, readOnly, ...rest } = useFormField(props, 'radio'); + + return { + ...rest, + readOnly, + inputProps: { + ...inputProps, + readOnly, + type: 'radio', + name: radioGroup?.name, + required: radioGroup?.required, + defaultChecked: + radioGroup?.defaultValue === undefined + ? undefined + : radioGroup?.defaultValue === props.value, + checked: + radioGroup?.value === undefined + ? undefined + : radioGroup?.value === props.value, + onClick: (e) => { + if (readOnly) { + e.preventDefault(); + return; + } + props?.onClick?.(e); + }, + onChange: (e) => { + if (readOnly) { + e.preventDefault(); + return; + } + props?.onChange?.(e); + radioGroup?.onChange?.(e); + }, + }, + }; +}; diff --git a/packages/react/src/components/form/useFormField.test.tsx b/packages/react/src/components/form/useFormField.test.tsx new file mode 100644 index 0000000000..bfa2b91584 --- /dev/null +++ b/packages/react/src/components/form/useFormField.test.tsx @@ -0,0 +1,147 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import { renderHook } from '@testing-library/react'; + +import type { FieldsetProps } from './Fieldset'; +import { Fieldset } from './Fieldset'; +import type { FormField } from './useFormField'; +import { useFormField } from './useFormField'; + +const createWrapper = (Wrapper: typeof Fieldset, props?: FieldsetProps) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +describe('useFormField', () => { + test('has correct error props', () => { + const { result } = renderHook( + () => useFormField({ error: 'error' }, 'test'), + { wrapper: createWrapper(Fieldset) }, + ); + + const field = result.current; + + expect(field.hasError).toBeTruthy(); + expect(field.errorId).toBeDefined(); + expect(field.inputProps['aria-invalid']).toBeTruthy(); + expect(field.inputProps['aria-describedby']).toEqual(field.errorId); + }); + + test('has correct error props when Fieldset has error', () => { + const { result } = renderHook( + () => useFormField({}, 'test'), + { + wrapper: createWrapper(Fieldset, { error: 'error' }), + }, + ); + + const field = result.current; + + expect(field.hasError).toBeTruthy(); + expect(field.errorId).toBeDefined(); + expect(field.inputProps['aria-invalid']).toBeTruthy(); + expect( + field.inputProps['aria-describedby']?.includes(field.errorId), + ).toBeFalsy(); + expect( + field.inputProps['aria-describedby']?.includes('fieldset-error'), + ).toBeTruthy(); + }); + + test('has correct description props', () => { + const { result } = renderHook( + () => useFormField({ description: 'description' }, 'test'), + { wrapper: createWrapper(Fieldset) }, + ); + + const field = result.current; + + expect(field.hasError).toBeFalsy(); + expect(field.inputProps.id).toBeDefined(); + expect(field.inputProps['aria-invalid']).toBeFalsy(); + expect(field.inputProps['aria-describedby']).toEqual(field.descriptionId); + }); + test('has overridden internal id and error id', () => { + const { result } = renderHook( + () => useFormField({ errorId: 'my errorId', id: 'my id' }, 'test'), + { wrapper: createWrapper(Fieldset) }, + ); + + const field = result.current; + + expect(field.errorId).toEqual('my errorId'); + expect(field.inputProps.id).toEqual('my id'); + }); + + test('is disabled', () => { + const { result } = renderHook( + () => useFormField({ disabled: true }, 'test'), + { wrapper: createWrapper(Fieldset) }, + ); + + const field = result.current; + + expect(field.inputProps.disabled).toBeTruthy(); + }); + + test('is readonly', () => { + const { result } = renderHook( + () => useFormField({ readOnly: true }, 'test'), + { wrapper: createWrapper(Fieldset) }, + ); + + const field = result.current; + + expect(field.readOnly).toBeTruthy(); + }); + + test('has disabled take presedens over readonly', () => { + const { result } = renderHook( + () => useFormField({ readOnly: true, disabled: true }, 'test'), + { wrapper: createWrapper(Fieldset) }, + ); + + const field = result.current; + + expect(field.readOnly).toBeFalsy(); + expect(field.inputProps.disabled).toBeTruthy(); + }); + + test('has correct size', () => { + const { result } = renderHook( + () => useFormField({ size: 'xsmall' }, 'test'), + { wrapper: createWrapper(Fieldset) }, + ); + + const field = result.current; + + expect(field.size).toEqual('xsmall'); + }); + test('has correct values inherited from Fieldset', () => { + const { result } = renderHook( + () => useFormField({}, 'test'), + { + wrapper: createWrapper(Fieldset, { disabled: true, size: 'small' }), + }, + ); + + const field = result.current; + + expect(field.size).toEqual('small'); + expect(field.inputProps.disabled).toBeTruthy(); + }); + + test('has readOnly inherited from Fieldset', () => { + const { result } = renderHook( + () => useFormField({}, 'test'), + { + wrapper: createWrapper(Fieldset, { readOnly: true }), + }, + ); + + const field = result.current; + + expect(field.readOnly).toBeTruthy(); + }); +}); diff --git a/packages/react/src/components/form/useFormField.ts b/packages/react/src/components/form/useFormField.ts new file mode 100644 index 0000000000..e44806270d --- /dev/null +++ b/packages/react/src/components/form/useFormField.ts @@ -0,0 +1,78 @@ +import { useContext, useId } from 'react'; +import type { HTMLAttributes, InputHTMLAttributes, ReactNode } from 'react'; +import cn from 'classnames'; + +import { FieldsetContext } from './Fieldset'; + +export type FormFieldProps = { + /** Error message for form field */ + error?: ReactNode; + /** Override generated errorId */ + errorId?: string; + /** Disables element + * @note Avoid using if possible for accessibility purposes + */ + disabled?: boolean; + /** Description for field */ + description?: ReactNode; + /** Override internal id */ + id?: string; + /** Toggle `readOnly` */ + readOnly?: boolean; + /** Changes field size and paddings */ + size?: 'xsmall' | 'small' | 'medium'; +} & Pick, 'aria-describedby'>; + +export type FormField = { + hasError: boolean; + errorId: string; + descriptionId: string; + inputProps: Pick< + InputHTMLAttributes, + 'id' | 'disabled' | 'aria-invalid' | 'aria-describedby' + >; + readOnly?: boolean; + size?: 'xsmall' | 'small' | 'medium'; +}; + +/** + * Handles props and their state for various form-fields in context with Fieldset + */ +export const useFormField = ( + props: FormFieldProps, + prefix: string, +): FormField => { + const fieldset = useContext(FieldsetContext); + + const randomId = useId(); + + const id = props.id ?? `${prefix}-${randomId}`; + const errorId = props.errorId ?? `${prefix}-error-${randomId}`; + const descriptionId = `${prefix}-description-${randomId}`; + + const disabled = fieldset?.disabled || props?.disabled; + const readOnly = + ((fieldset?.readOnly || props?.readOnly) && !disabled) || undefined; + + const hasError = !disabled && !readOnly && !!(props.error || fieldset?.error); + + return { + readOnly, + hasError, + errorId, + descriptionId, + size: props?.size ?? fieldset?.size ?? 'medium', + inputProps: { + id, + disabled, + 'aria-invalid': hasError ? true : undefined, + 'aria-describedby': + cn(props['aria-describedby'], { + [descriptionId]: + !!props?.description && typeof props?.description === 'string', + [errorId]: hasError && !fieldset?.error, + [fieldset?.errorId ?? '']: hasError && !!fieldset?.error, + }) || undefined, + }, + }; +}; diff --git a/packages/react/src/utils/objectUtils.test.ts b/packages/react/src/utils/objectUtils.test.ts index 33f9258f3b..8e9f6e26fd 100644 --- a/packages/react/src/utils/objectUtils.test.ts +++ b/packages/react/src/utils/objectUtils.test.ts @@ -1,4 +1,4 @@ -import { objectValuesEqual } from './objectUtils'; +import { objectValuesEqual, omit } from './objectUtils'; describe('objectUtils', () => { describe('objectValuesEqual', () => { @@ -29,4 +29,16 @@ describe('objectUtils', () => { expect(objectValuesEqual(object3, object4)).toBe(true); // Expected because object3.subObject === object4.subObject }); }); + + describe('omit', () => { + it('Returns copy of object with omitted property', () => { + const object1 = { a: 1, b: 2, c: 3 }; + + const omittedObject = omit(['c'], object1); + + expect(Object.keys(omittedObject).includes('a')).toBeTruthy(); + expect(Object.keys(omittedObject).includes('b')).toBeTruthy(); + expect(Object.keys(omittedObject).includes('c')).toBeFalsy(); + }); + }); }); diff --git a/packages/react/src/utils/objectUtils.ts b/packages/react/src/utils/objectUtils.ts index 019b45e418..cc0d413cf8 100644 --- a/packages/react/src/utils/objectUtils.ts +++ b/packages/react/src/utils/objectUtils.ts @@ -14,3 +14,32 @@ export const objectValuesEqual = >( for (const key of keys1) if (object1[key] !== object2[key]) return false; return true; }; + +// https://github.com/ramda/ramda/blob/master/source/omit.js +type UnknownRecord = Record; + +/** + * Returns a partial copy of an object omitting the keys specified. + * @param {Array} names an array of String property names to omit from the new object + * @param {Object} obj The object to copy from + * @return {Object} A new object with properties from `names` not on it. + * @example omit(['a', 'd'], {a: 1, b: 2, c: 3, d: 4}); //=> {b: 2, c: 3} + */ +export const omit = (names: string[], obj: UnknownRecord): object => { + const result: UnknownRecord = {}; + const index: UnknownRecord = {}; + let idx = 0; + const len = names.length; + + while (idx < len) { + index[names[idx]] = 1; + idx += 1; + } + + for (const prop in obj) { + if (!Object.prototype.hasOwnProperty.call(index, prop)) { + result[prop] = obj[prop]; + } + } + return result; +}; diff --git a/yarn.lock b/yarn.lock index ef0920178f..615f6b037b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10407,21 +10407,7 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001400": - version: 1.0.30001436 - resolution: "caniuse-lite@npm:1.0.30001436" - checksum: 7928ac7d93741a81b3005ca4623b133e7d790828be70b26ee55e4860facc59bc344f4092e20034981070a4714f70814c8be4929be4b22728031784f267f69099 - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001406": - version: 1.0.30001450 - resolution: "caniuse-lite@npm:1.0.30001450" - checksum: 511b360bfc907b2e437699364cf96b83507bc45043926450056642332bcd6f65a1e72540c828534ae15e0ac906e3e9af46cb2bb84458dd580bc31478e9dce282 - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001503": +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001400, caniuse-lite@npm:^1.0.30001406, caniuse-lite@npm:^1.0.30001503": version: 1.0.30001517 resolution: "caniuse-lite@npm:1.0.30001517" checksum: e4e87436ae1c4408cf4438aac22902b31eb03f3f5bad7f33bc518d12ffb35f3fd9395ccf7efc608ee046f90ce324ec6f7f26f8a8172b8c43c26a06ecee612a29