diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/Examples.tsx index 536e97fc1f4..2727e272a38 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/Examples.tsx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/Examples.tsx @@ -5,6 +5,26 @@ import { Button, Card, Flex, P, Section } from '@dnb/eufemia/src' import { debounceAsync } from '@dnb/eufemia/src/shared/helpers/debounce' import { createRequest } from '../SubmitIndicator/Examples' +export const RequiredAndOptionalFields = () => { + return ( + + + + + + + + + + + ) +} + export const AsyncSubmit = () => { return ( diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/demos.mdx index 29ad9fd2c71..79aa7645246 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/demos.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Handler/demos.mdx @@ -6,6 +6,14 @@ import * as Examples from './Examples' ## Demos +### Required and Optional Fields + +To make all fields required, set the `required` prop on the `Form.Handler` component. + +For fields that should remain optional, use `required={false}` prop on the specific field. When doing so, it will append "(optional)" to the optional field's label(`labelSuffix`). + + + ### In combination with a SubmitButton This example uses an async `onSubmit` event handler. It will disable all fields and show an indicator on the [SubmitButton](/uilib/extensions/forms/Form/SubmitButton/) while the form is pending. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx index a547591fccd..cb782599759 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/getting-started.mdx @@ -329,7 +329,24 @@ The `required` property is a boolean that indicates whether the field is require ``` -**NB:** You can also use the `required` property on the [Form.Handler](/uilib/extensions/forms/Form/Handler/) component, [Wizard.Step](/uilib/extensions/forms/Wizard/Step/) component or nested in the [Form.FieldProps](/uilib/extensions/forms/Form/FieldProps/) component. +**Note:** You can use the `required` property on the [Form.Handler](/uilib/extensions/forms/Form/Handler/) or [Wizard.Step](/uilib/extensions/forms/Wizard/Step/) components ([example](/uilib/extensions/forms/Form/Handler/demos/#required-and-optional-fields)). Additionally, the [Form.Section](/uilib/extensions/forms/Form/Section/) component as well as the [Form.FieldProps](/uilib/extensions/forms/Form/FieldProps/) provider has a `required` property, which will make all nested fields within that section required. + +```tsx + + + + +``` + +When you need to opt-out of the required field validation, you can use the `required={false}` property. This will also add a "(optional)" suffix to the field label(`labelSuffix`). + +```tsx + + + + + +``` #### pattern diff --git a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx index 8d73c819a68..a7ebc84d102 100644 --- a/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/DataContext/Provider/__tests__/Provider.test.tsx @@ -4226,6 +4226,97 @@ describe('DataContext.Provider', () => { }) }) + describe('required={false}', () => { + it('should add optional label', () => { + const { rerender } = render( + + + + + + ) + + const [first, second, third] = Array.from( + document.querySelectorAll('label') + ) + + expect(first).toHaveTextContent('Foo') + expect(second).toHaveTextContent( + `Bar ${nb.Field.optionalLabelSuffix}` + ) + expect(third).toHaveTextContent('Baz') + + rerender( + + + + + + ) + + expect(first).toHaveTextContent( + `Foo ${nb.Field.optionalLabelSuffix}` + ) + expect(second).toHaveTextContent('Bar') + expect(third).toHaveTextContent( + `Baz ${nb.Field.optionalLabelSuffix}` + ) + }) + + it('should prioritize labelSuffix over optionalLabel', () => { + render( + + + + ) + + const labelElement = document.querySelector('label') + expect(labelElement.textContent).toBe('e-post (suffix)') + }) + + it('should hide labelSuffix with empty string', () => { + render( + + + + ) + + const labelElement = document.querySelector('label') + expect(labelElement.textContent).toBe('e-post') + }) + + it('should support translations', () => { + render( + + + + + + ) + + const [first, second, third] = Array.from( + document.querySelectorAll('label') + ) + + expect(first).toHaveTextContent('Foo') + expect(second).toHaveTextContent('Bar (recommended)') + expect(third).toHaveTextContent('Baz') + }) + }) + describe('required', () => { it('should make all fields required', () => { const { rerender } = render( diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/Email/__tests__/Email.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/Email/__tests__/Email.test.tsx index 560d627d4eb..8891ff62059 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/Email/__tests__/Email.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/Email/__tests__/Email.test.tsx @@ -115,6 +115,26 @@ describe('Field.Email', () => { expect(input).toHaveAttribute('type', 'email') }) + it('should have default label', () => { + render() + + const label = document.querySelector('label') + expect(label).toHaveTextContent(nb.Email.label) + }) + + it('should add (optional) text to the label if required={false}', () => { + render( + + + + ) + + const label = document.querySelector('label') + expect(label).toHaveTextContent( + `${nb.Email.label} ${nb.Field.optionalLabelSuffix}` + ) + }) + it('should allow a custom pattern', async () => { render() diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx index d2df977660d..7155eb4f0a1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Field/PhoneNumber/__tests__/PhoneNumber.test.tsx @@ -57,6 +57,40 @@ describe('Field.PhoneNumber', () => { expect(labelElement()).not.toHaveAttribute('disabled') }) + it('should have default label', () => { + render() + + const label = document.querySelector('.dnb-forms-field-phone-number') + expect(label).toHaveTextContent(nbNO.PhoneNumber.label) + }) + + it('should add (optional) text to the number label if required={false}', () => { + render( + + + + ) + + const codeElement = document.querySelector( + '.dnb-forms-field-phone-number__country-code' + ) as HTMLInputElement + const numberElement = document.querySelector( + '.dnb-forms-field-phone-number__number' + ) as HTMLInputElement + + expect(codeElement.querySelector('label')).not.toHaveTextContent( + `${nbNO.Field.optionalLabelSuffix}` + ) + expect(numberElement.querySelector('label')).toHaveTextContent( + `${nbNO.PhoneNumber.label} ${nbNO.Field.optionalLabelSuffix}` + ) + + // Use "textContent" to check against non-breaking space + expect(numberElement.querySelector('label').textContent).toBe( + `${nbNO.PhoneNumber.label}${' '}${nbNO.Field.optionalLabelSuffix}` + ) + }) + it('should only have a mask when +47 is given', async () => { const { rerender } = render() diff --git a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx index 29739884b56..5b32854b5d3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/FieldBlock.tsx @@ -50,6 +50,7 @@ export type Props = Pick< | keyof ComponentProps | 'layout' | 'label' + | 'labelSuffix' | 'labelDescription' | 'info' | 'warning' @@ -76,6 +77,8 @@ export type Props = Pick< fieldState?: SubmitState /** Typography size */ labelSize?: 'medium' | 'large' + /** For internal use only */ + required?: boolean children?: React.ReactNode } & React.HTMLAttributes @@ -93,8 +96,10 @@ function FieldBlock(props: Props) { composition, label: labelProp, labelDescription, + labelSuffix, labelSrOnly, asFieldset, + required, info, warning, error: errorProp, @@ -122,15 +127,46 @@ function FieldBlock(props: Props) { return Boolean(errorProp) }, []) // eslint-disable-line react-hooks/exhaustive-deps + const { optionalLabelSuffix } = useTranslation().Field + const labelSuffixText = useMemo(() => { + if (required === false || typeof labelSuffix !== 'undefined') { + return labelSuffix ?? optionalLabelSuffix + } + return '' + }, [required, labelSuffix, optionalLabelSuffix]) + const label = useMemo(() => { + let content = labelProp + if (iterateIndex !== undefined) { - return convertJsxToString(labelProp).replace( + content = convertJsxToString(labelProp).replace( '{itemNr}', String(iterateIndex + 1) ) } - return labelProp - }, [iterateIndex, labelProp]) + + if (labelSuffixText) { + if (convertJsxToString(content).includes(optionalLabelSuffix)) { + return content + } + + if (typeof content === 'string') { + return content + ' ' + labelSuffixText + } + + if (React.isValidElement(content)) { + return ( + <> + {content} + {' '} + {labelSuffixText} + + ) + } + } + + return content + }, [iterateIndex, labelProp, labelSuffixText]) const setInternalRecord = useCallback((props: StateBasis) => { const { stateId, identifier, type } = props diff --git a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/__tests__/FieldBlock.test.tsx b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/__tests__/FieldBlock.test.tsx index 6e8d32dba47..6297ff4b7ac 100644 --- a/packages/dnb-eufemia/src/extensions/forms/FieldBlock/__tests__/FieldBlock.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/FieldBlock/__tests__/FieldBlock.test.tsx @@ -10,6 +10,11 @@ import { simulateAnimationEnd, } from '../../../../components/height-animation/__tests__/HeightAnimationUtils' import { Field, Form } from '../..' +import nbNO from '../../constants/locales/nb-NO' +import enGB from '../../constants/locales/en-GB' + +const nb = nbNO['nb-NO'] +const en = enGB['en-GB'] describe('FieldBlock', () => { it('should forward HTML attributes', () => { @@ -137,6 +142,130 @@ describe('FieldBlock', () => { expect(labelElement).toHaveTextContent('A Label Description') }) + describe('labelSuffix', () => { + it('should add additional text to the label with a non-breaking space', () => { + render( + + content + + ) + + const labelElement = document.querySelector('label') + + expect(labelElement).toBeInTheDocument() + expect(labelElement).toHaveTextContent( + `A Label ${nb.Field.optionalLabelSuffix}` + ) + expect( + labelElement.querySelector('span > span').innerHTML + ).toContain(`A Label (valgfritt)`) + }) + + it('should add additional text to the label with a non-breaking space when label and labelSuffix is a JSX element', () => { + render( + A Label} + labelSuffix={(valgfritt)} + > + content + + ) + + const labelElement = document.querySelector('label') + + expect(labelElement).toBeInTheDocument() + expect(labelElement).toHaveTextContent( + `A Label ${nb.Field.optionalLabelSuffix}` + ) + expect(labelElement.querySelector('span').innerHTML).toContain( + `A Label (valgfritt)` + ) + }) + }) + + describe('required={false}', () => { + it('should add (optional) text to the label', () => { + render( + + content + + ) + + const labelElement = document.querySelector('label') + + expect(labelElement).toBeInTheDocument() + expect(labelElement).toHaveTextContent( + `A Label ${nb.Field.optionalLabelSuffix}` + ) + }) + + it('should prioritize labelSuffix over optionalLabel', () => { + render( + + content + + ) + + const labelElement = document.querySelector('label') + expect(labelElement.textContent).toBe('A Label (suffix)') + }) + + it('should check if labelSuffix already exists in label', () => { + render( + + content + + ) + + const labelElement = document.querySelector('label') + expect(labelElement.textContent).toBe( + `My Label ${nb.Field.optionalLabelSuffix}` + ) + }) + + it('should support en-GB locale', () => { + render( + + + content + + + ) + + const labelElement = document.querySelector('label') + + expect(labelElement).toBeInTheDocument() + expect(labelElement).toHaveTextContent( + `A Label ${en.Field.optionalLabelSuffix}` + ) + }) + + it('should add (optional) text when label is a JSX element', () => { + render( + A Label} required={false}> + content + + ) + + const labelElement = document.querySelector('label') + + expect(labelElement).toBeInTheDocument() + expect(labelElement).toHaveTextContent( + `A Label ${nb.Field.optionalLabelSuffix}` + ) + expect(labelElement.querySelector('span').innerHTML).toContain( + `A Label (valgfritt)` + ) + }) + }) + it('click on label should set focus on input after value change', async () => { const MockComponent = () => { const fromInput = React.useCallback(({ value }) => value, []) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.screenshot.test.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.screenshot.test.ts new file mode 100644 index 00000000000..58e90a891e5 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/Handler.screenshot.test.ts @@ -0,0 +1,18 @@ +/** + * Screenshot Test + * This file will not run on "test:staged" because we don't require any related files + */ + +import { makeScreenshot } from '../../../../../core/jest/jestSetupScreenshots' + +describe('Form.Handler', () => { + const url = '/uilib/extensions/forms/Form/Handler/demos' + + it('have to match required and optional fields', async () => { + const screenshot = await makeScreenshot({ + url, + selector: '[data-visual-test="required-and-optional-fields"]', + }) + expect(screenshot).toMatchImageSnapshot() + }) +}) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/__image_snapshots__/formhandler-have-to-match-required-and-optional-fields.snap.png b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/__image_snapshots__/formhandler-have-to-match-required-and-optional-fields.snap.png new file mode 100644 index 00000000000..7ee53a4c1a9 Binary files /dev/null and b/packages/dnb-eufemia/src/extensions/forms/Form/Handler/__tests__/__image_snapshots__/formhandler-have-to-match-required-and-optional-fields.snap.png differ diff --git a/packages/dnb-eufemia/src/extensions/forms/Value/String/__tests__/String.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Value/String/__tests__/String.test.tsx index ede9615e6d2..ed1b6499381 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Value/String/__tests__/String.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/Value/String/__tests__/String.test.tsx @@ -2,6 +2,9 @@ import React from 'react' import { screen, render } from '@testing-library/react' import { Field, Form, Value, Wizard } from '../../..' import userEvent from '@testing-library/user-event' +import nbNO from '../../../constants/locales/nb-NO' + +const nb = nbNO['nb-NO'] describe('Value.String', () => { it('renders value', () => { @@ -58,6 +61,32 @@ describe('Value.String', () => { ).toHaveTextContent('The label') }) + it('should only show optional label on the field label if required={false}', () => { + render( + + + + + ) + expect( + document.querySelector('.dnb-forms-field-string') + ).toHaveTextContent(`The label ${nb.Field.optionalLabelSuffix}`) + expect( + document.querySelector('.dnb-forms-value-string') + ).toHaveTextContent('The label') + expect( + document.querySelector('.dnb-forms-value-string') + ).not.toHaveTextContent(nb.Field.optionalLabelSuffix) + }) + it('should not use label from field with same path when label is false', () => { render( { document.querySelector('.dnb-forms-value-string') ).toHaveTextContent('The label') }) + + it('should not inherit labelSuffix in value label', () => { + render( + + + + + ) + + expect(document.querySelector('label').textContent).toBe( + `The label${' '}${nb.Field.optionalLabelSuffix}` + ) + expect( + document.querySelector('.dnb-forms-value-string').textContent + ).toBe('The label') + }) }) it('renders default class', () => { diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts b/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts index 18873752946..2ec23ee403e 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/locales/en-GB.ts @@ -9,6 +9,7 @@ export default { errorSummary: 'Please correct the following errors:', errorRequired: 'This field is required.', errorPattern: 'The value is invalid.', + optionalLabelSuffix: '(optional)', }, SubmitButton: { text: 'Send', diff --git a/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts b/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts index a83ff630201..8b3ff6c54b3 100644 --- a/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts +++ b/packages/dnb-eufemia/src/extensions/forms/constants/locales/nb-NO.ts @@ -9,6 +9,7 @@ export default { errorSummary: 'Feil som må rettes:', errorRequired: 'Dette feltet må fylles ut.', errorPattern: 'Verdien er ugyldig.', + optionalLabelSuffix: '(valgfritt)', }, SubmitButton: { text: 'Send', diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts index 82a62c207ba..3a8e70a2065 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/DataValueDocs.ts @@ -42,10 +42,15 @@ export const dataValueProperties: PropertiesTableProps = { status: 'optional', }, required: { - doc: 'When set `true`, the field will give an error if the value cannot be empty.', + doc: 'When set to `true`, the field will give an error if the value fails the required validation. When set to `false`, the field will not be required, but will add a "(optional)" suffix to the label.', type: 'boolean', status: 'optional', }, + labelSuffix: { + doc: 'Will append an additional text to the label, like "(optional)". When using `inheritLabel`, the suffix will not be inherited. NB: The visual appearance of the `labelSuffix` may change in the future.', + type: 'React.Node', + status: 'optional', + }, schema: { doc: 'Custom JSON Schema for validating the value.', type: 'object', diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx index 8c6f1fb445d..141aacf7939 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/__tests__/useFieldProps.test.tsx @@ -2000,6 +2000,18 @@ describe('useFieldProps', () => { expect(result.current.htmlAttributes).toEqual({}) }) + it('should return empty htmlAttributes when optional prop is true and required prop is true', async () => { + const { result } = renderHook(() => + useFieldProps({ + value: undefined, + required: false, + }) + ) + + expect(result.current.htmlAttributes).toEqual({}) + expect(result.current.error).not.toBeInstanceOf(Error) + }) + describe('required', () => { it('should return aria-required=true when required prop is true', async () => { const { result } = renderHook(() => diff --git a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts index 372dce6f508..a4c3ec105bb 100644 --- a/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts +++ b/packages/dnb-eufemia/src/extensions/forms/hooks/useFieldProps.ts @@ -211,7 +211,7 @@ export default function useFieldProps( const hasFocusRef = useRef() const required = useMemo(() => { - if (requiredProp) { + if (typeof requiredProp !== 'undefined') { return requiredProp } @@ -242,7 +242,7 @@ export default function useFieldProps( return true } } - }, [sectionPath, dataContext.schema, identifier, requiredProp, schema]) + }, [requiredProp, identifier, schema, dataContext.schema, sectionPath]) // Error handling // - Should errors received through validation be shown initially. Assume that providing a direct prop to @@ -1700,6 +1700,8 @@ export default function useFieldProps( info: !inFieldBlock ? infoRef.current : undefined, warning: !inFieldBlock ? warningRef.current : undefined, error: !inFieldBlock ? error : undefined, + required, + labelSuffix: props.labelSuffix, /** HTML Attributes */ disabled: diff --git a/packages/dnb-eufemia/src/extensions/forms/types.ts b/packages/dnb-eufemia/src/extensions/forms/types.ts index 763b8d436eb..a44ca318a37 100644 --- a/packages/dnb-eufemia/src/extensions/forms/types.ts +++ b/packages/dnb-eufemia/src/extensions/forms/types.ts @@ -245,6 +245,10 @@ export type FieldBlockProps = { * Main label text for the field */ label?: React.ReactNode + /** + * Will append an additional text to the label, like "(optional)" or "(recommended)" + */ + labelSuffix?: React.ReactNode /** * A more discreet text displayed beside the label */