diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status.mdx new file mode 100644 index 00000000000..99fbf99aed2 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status.mdx @@ -0,0 +1,25 @@ +--- +title: 'Status' +description: 'The `Form.Status` can be used to show success or error messages.' +showTabs: true +tabs: + - title: Info + key: '/info' + - title: Demos + key: '/demos' + - title: Properties + key: '/properties' +breadcrumb: + - text: Forms + href: /uilib/extensions/forms/ + - text: Form + href: /uilib/extensions/forms/Form/ + - text: Status + href: /uilib/extensions/forms/Form/Status +--- + +import Info from 'Docs/uilib/extensions/forms/Form/Status/info' +import Demos from 'Docs/uilib/extensions/forms/Form/Status/demos' + + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/Examples.tsx new file mode 100644 index 00000000000..82a58740665 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/Examples.tsx @@ -0,0 +1,74 @@ +import ComponentBox from '../../../../../../shared/tags/ComponentBox' +import { Field, Form } from '@dnb/eufemia/src/extensions/forms' +import { createRequest } from '../SubmitIndicator/Examples' +import { Button } from '@dnb/eufemia/src' + +const request = createRequest() + +export const ErrorMessage = () => { + return ( + + {() => { + // myFormId can be anything, as long as it's a unique instance + const myFormId = () => null + + return ( + { + await request(1000) // Simulate a request + + Form.Status.setStatus(myFormId, 'error') + }} + > + + + + + + + + + + + ) + }} + + ) +} + +export const SuccessMessage = () => { + return ( + + {() => { + // myFormId can be anything, as long as it's a unique instance + const myFormId = () => null + + return ( + { + await request(1000) // Simulate a request + + Form.Status.setStatus(myFormId, 'success') + }} + > + + + + + + + + ) + }} + + ) +} diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/demos.mdx new file mode 100644 index 00000000000..ef05da36b48 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/demos.mdx @@ -0,0 +1,16 @@ +--- +showTabs: true +hideInMenu: true +--- + +import * as Examples from './Examples' + +## Demos + +### Error message + + + +### Success message + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/info.mdx new file mode 100644 index 00000000000..eceaadc9dde --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/info.mdx @@ -0,0 +1,56 @@ +--- +showTabs: true +hideInMenu: true +--- + +## Description + +`Form.Status` displays a status message that is fully covering the available space and can be used to show success as a receipt or error messages. + +## Usage + +By default the given children will be shown. + +```tsx +import { Form } from '@dnb/eufemia/extensions/forms' + +render( + + visible content + , +) +``` + +## Showing the success or error message + +You can show the success or error message by using the `Form.Status.setStatus` method: + +```tsx +Form.Status.setStatus(id, 'success') +// or +Form.Status.setStatus(id, 'error') +``` + +You can call it whenever you need to show the success message. Here is an example of how to use it: + +```tsx +import { Form } from '@dnb/eufemia/extensions/forms' + +// myFormId can be anything, as long as it's a unique instance +const myFormId = () => null + +render( + { + Form.Status.setStatus(myFormId, 'success') + }} + > + content + , +) +``` + +## Accessibility + +The component will manage focus handling, which is important for screen readers and users using keyboard navigation. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/properties.mdx new file mode 100644 index 00000000000..13143f60a98 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/Form/Status/properties.mdx @@ -0,0 +1,25 @@ +--- +showTabs: true +hideInMenu: true +--- + +import TranslationsTable from 'dnb-design-system-portal/src/shared/parts/TranslationsTable' +import PropertiesTable from 'dnb-design-system-portal/src/shared/parts/PropertiesTable' +import { + StatusSuccessProperties, + StatusErrorProperties, +} from '@dnb/eufemia/src/extensions/forms/Form/Status/StatusDocs' + +## Properties + +### Error + + + +### Success + + + +## Translations + + diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Status/Status.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Status/Status.tsx new file mode 100644 index 00000000000..25a94c6acc0 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Status/Status.tsx @@ -0,0 +1,167 @@ +import React, { useCallback, useContext, useEffect, useRef } from 'react' +import classnames from 'classnames' +import Visibility from '../Visibility' +import DataContext from '../../DataContext/Context' +import { useSharedState } from '../../../../shared/helpers/useSharedState' +import setStatus, { Status } from './setStatus' +import { Button, Flex, Section } from '../../../../components' +import { P } from '../../../../elements' +import { useTranslation } from '../../hooks' +import MainHeading from '../MainHeading' +import SubmitButton from '../SubmitButton' + +export type Props = { + success?: { + title?: React.ReactNode + description?: React.ReactNode + buttonText?: React.ReactNode + buttonHref?: string + } + error?: { + title?: React.ReactNode + description?: React.ReactNode + retryButton?: React.ReactNode + cancelButton?: React.ReactNode + } + onCancel?: () => void + children: React.ReactNode + className?: string +} + +function StatusContainer(props: Props) { + const { success, error, onCancel, className, children, ...restProps } = + props + + const translations = useTranslation() + + const { id } = useContext(DataContext) || {} + + const { data } = useSharedState<{ + activeStatus?: Status + }>(id) + const { activeStatus } = data || {} + + // To ensure we not animate on first render. + // When there are several Examples rendered at the same time, + // the first one will animate on the first render. + const animateRef = useRef(undefined) + useEffect(() => { + animateRef.current = true + }, []) + + const innerRef = useRef(null) + const onVisible = useCallback(() => { + if (animateRef.current) { + innerRef.current.focus?.() + } + }, []) + + // To keep the content visible while hiding it with the HightAnimation + const currentStatusRef = useRef() + if (activeStatus) { + currentStatusRef.current = activeStatus + } + + const onCancelHandler = useCallback(() => { + if (id) { + setStatus(id, undefined) + } + onCancel?.() + }, [id, onCancel]) + + const childrenAreVisible = + typeof activeStatus !== 'undefined' + ? !(activeStatus === activeStatus) + : undefined + const statusContentIsVisible = + typeof activeStatus !== 'undefined' + ? activeStatus === activeStatus + : false + + let statusContent = null + + if (currentStatusRef.current === 'success') { + const tr = translations.StatusSuccess + const { + title, + description, + buttonText, + buttonHref = '/', + } = success || {} + + statusContent = ( +
+ + {title ?? tr.title} +

{description ?? tr.description}

+ {buttonHref && ( + + )} +
+
+ ) + } else if (currentStatusRef.current === 'error') { + const tr = translations.StatusError + const { title, description, cancelButton, retryButton } = error || {} + + statusContent = ( +
+ + {title ?? tr.title} +

{description ?? tr.description}

+ + + {retryButton ?? tr.retryButton} + +
+
+ ) + } + + return ( +
+ + {statusContent} + + + + {children} + +
+ ) +} + +StatusContainer.setStatus = setStatus +StatusContainer._supportsSpacingProps = true + +export default StatusContainer diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Status/StatusDocs.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Status/StatusDocs.tsx new file mode 100644 index 00000000000..b801dc6c449 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Status/StatusDocs.tsx @@ -0,0 +1,52 @@ +import { PropertiesTableProps } from '../../../../shared/types' + +export const StatusSuccessProperties: PropertiesTableProps = { + title: { + doc: 'The title of the component.', + type: 'React.Node', + status: 'optional', + }, + description: { + doc: 'The description of the component.', + type: 'React.Node', + status: 'optional', + }, + buttonText: { + doc: 'The text of the button.', + type: 'React.Node', + status: 'optional', + }, + '[Section](/uilib/components/section/properties)': { + doc: 'All Section properties.', + type: 'various', + status: 'optional', + }, +} + +export const StatusErrorProperties: PropertiesTableProps = { + title: { + doc: 'The title of the component.', + type: 'React.Node', + status: 'optional', + }, + description: { + doc: 'The description of the component.', + type: 'React.Node', + status: 'optional', + }, + cancelButton: { + doc: 'The text of the cancel button.', + type: 'React.Node', + status: 'optional', + }, + retryButton: { + doc: 'The text of the retry button.', + type: 'React.Node', + status: 'optional', + }, + '[Section](/uilib/components/section/properties)': { + doc: 'All Section properties.', + type: 'various', + status: 'optional', + }, +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Status/__tests__/Status.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Status/__tests__/Status.test.tsx new file mode 100644 index 00000000000..b087c1c5258 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Status/__tests__/Status.test.tsx @@ -0,0 +1,268 @@ +import React from 'react' +import { act, fireEvent, render } from '@testing-library/react' +import nbNO from '../../../constants/locales/nb-NO' +import { Form } from '../../..' + +describe('Form.Status', () => { + it('should render success with correct text', () => { + const nb = nbNO['nb-NO'].StatusSuccess + const formId = {} + + render( + + content + + ) + + expect(document.querySelector('h2')).toBeNull() + expect(document.querySelector('p')).toBeNull() + + act(() => { + Form.Status.setStatus(formId, 'success') + }) + + expect(document.querySelector('h2')).toHaveTextContent(nb.title) + expect(document.querySelector('p')).toHaveTextContent(nb.description) + + const anchors = document.querySelectorAll('a') + expect(anchors).toHaveLength(1) + + const [anchor] = Array.from(anchors) + expect(anchor).toHaveTextContent(nb.buttonText) + expect(anchor).toHaveAttribute('href', '/') + }) + + it('should render error with correct text', async () => { + const nb = nbNO['nb-NO'].StatusError + const formId = {} + + render( + + content + + ) + + expect(document.querySelector('h2')).toBeNull() + expect(document.querySelector('p')).toBeNull() + + act(() => { + Form.Status.setStatus(formId, 'error') + }) + + expect(document.querySelector('h2')).toHaveTextContent(nb.title) + expect(document.querySelector('p')).toHaveTextContent(nb.description) + + const buttons = document.querySelectorAll('button') + expect(buttons).toHaveLength(2) + + const [backButton, retryButton] = Array.from(buttons) + expect(backButton).toHaveTextContent(nb.cancelButton) + expect(retryButton).toHaveTextContent(nb.retryButton) + }) + + it('should render success with custom text', () => { + const formId = {} + + render( + + + content + + + ) + + expect(document.querySelector('h2')).toBeNull() + expect(document.querySelector('p')).toBeNull() + + act(() => { + Form.Status.setStatus(formId, 'success') + }) + + expect(document.querySelector('h2')).toHaveTextContent('Custom title') + expect(document.querySelector('p')).toHaveTextContent( + 'Custom description' + ) + + const anchors = document.querySelectorAll('a') + expect(anchors).toHaveLength(1) + + const [anchor] = Array.from(anchors) + expect(anchor).toHaveTextContent('Custom button text') + expect(anchor).toHaveAttribute('href', '/') + }) + + it('should render error with custom text', async () => { + const formId = {} + + render( + + + content + + + ) + + expect(document.querySelector('h2')).toBeNull() + expect(document.querySelector('p')).toBeNull() + + act(() => { + Form.Status.setStatus(formId, 'error') + }) + + expect(document.querySelector('h2')).toHaveTextContent('Custom title') + expect(document.querySelector('p')).toHaveTextContent( + 'Custom description' + ) + + const buttons = document.querySelectorAll('button') + expect(buttons).toHaveLength(2) + + const [backButton, retryButton] = Array.from(buttons) + expect(backButton).toHaveTextContent('Custom cancel') + expect(retryButton).toHaveTextContent('Custom retry') + }) + + it('should keep children in the DOM', () => { + const formId = {} + + render( + + + content + + + ) + + expect(document.querySelector('h2')).toBeNull() + expect(document.querySelector('output')).toHaveTextContent('content') + + act(() => { + Form.Status.setStatus(formId, 'success') + }) + + expect(document.querySelector('h2')).toBeInTheDocument() + expect(document.querySelector('output')).toHaveTextContent('content') + }) + + it('should handle focus', () => { + const formId = {} + + render( + + + content + + + ) + + expect(document.querySelector('body')).toHaveFocus() + + act(() => { + Form.Status.setStatus(formId, 'success') + }) + + expect( + document.querySelector('.dnb-forms-status--success') + ).toHaveFocus() + + act(() => { + document.querySelector('body').focus() + Form.Status.setStatus(formId, undefined) + }) + + expect(document.querySelector('.dnb-forms-status')).toHaveFocus() + expect(document.querySelector('.dnb-forms-status')).not.toHaveClass( + 'dnb-forms-status--success' + ) + + act(() => { + document.querySelector('body').focus() + Form.Status.setStatus(formId, 'success') + }) + + expect( + document.querySelector('.dnb-forms-status--success') + ).toHaveFocus() + expect(document.querySelector('.dnb-forms-status')).toHaveClass( + 'dnb-no-focus' + ) + expect(document.querySelector('.dnb-forms-status')).toHaveAttribute( + 'tabindex', + '-1' + ) + }) + + it('should show content when cancel button is clicked', () => { + const formId = {} + + render( + + + content + + + ) + + act(() => { + Form.Status.setStatus(formId, 'error') + }) + + expect(document.querySelector('.dnb-forms-status')).toHaveClass( + 'dnb-forms-status--error' + ) + + const buttons = document.querySelectorAll('button') + expect(buttons).toHaveLength(2) + + const [backButton] = Array.from(buttons) + + fireEvent.click(backButton) + + expect(document.querySelector('.dnb-forms-status')).not.toHaveClass( + 'dnb-forms-status--error' + ) + }) + + it('should call onCancel when clicking on cancel button', () => { + const formId = {} + const onCancel = jest.fn() + + render( + + + content + + + ) + + act(() => { + Form.Status.setStatus(formId, 'error') + }) + + expect(document.querySelector('.dnb-forms-status')).toHaveClass( + 'dnb-forms-status--error' + ) + expect(onCancel).not.toHaveBeenCalled() + + const buttons = document.querySelectorAll('button') + expect(buttons).toHaveLength(2) + + const [backButton] = Array.from(buttons) + + fireEvent.click(backButton) + expect(onCancel).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Status/index.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Status/index.ts new file mode 100644 index 00000000000..1ef184788df --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Status/index.ts @@ -0,0 +1,2 @@ +export { default } from './Status' +export * from './Status' diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Status/setStatus.ts b/packages/dnb-eufemia/src/extensions/forms/Form/Status/setStatus.ts new file mode 100644 index 00000000000..7da1aea5380 --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Status/setStatus.ts @@ -0,0 +1,11 @@ +import { + SharedStateId, + createSharedState, +} from '../../../../shared/helpers/useSharedState' + +export type Status = 'success' | 'error' | undefined + +export default function setStatus(id: SharedStateId, status: Status) { + const sharedState = createSharedState(id) + sharedState.extend({ activeStatus: status }) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/Status/stories/Status.stories.tsx b/packages/dnb-eufemia/src/extensions/forms/Form/Status/stories/Status.stories.tsx new file mode 100644 index 00000000000..e2e368342de --- /dev/null +++ b/packages/dnb-eufemia/src/extensions/forms/Form/Status/stories/Status.stories.tsx @@ -0,0 +1,41 @@ +import { Field, Form } from '../../..' +import { Button } from '../../../../../components' + +export default { + title: 'Eufemia/Extensions/Forms/Status', +} + +export function BothStatuses() { + const formId = () => null + return ( + <> + { + await new Promise((r) => setTimeout(r, 1000)) // Simulate a request + + Form.Status.setStatus(formId, 'success') + }} + > + + + + + + + + + + +
+ ----- Content ---- + + ) +} diff --git a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts index 3b2c6c245b6..7ccf22388b1 100644 --- a/packages/dnb-eufemia/src/extensions/forms/Form/index.ts +++ b/packages/dnb-eufemia/src/extensions/forms/Form/index.ts @@ -11,6 +11,7 @@ export { default as MainHeading } from './MainHeading' export { default as SubHeading } from './SubHeading' export { default as Visibility } from './Visibility' export { default as Section } from './Section' +export { default as Status } from './Status' export { default as Card } from './Card' export { default as Isolation } from './Isolation' export { default as Snapshot } from './Snapshot' 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 d2df5c1b137..e2d77930b17 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 @@ -35,6 +35,17 @@ export default { text: 'Remove', confirmRemoveText: 'Are you sure you want to delete this?', }, + StatusSuccess: { + title: 'Thank you', + description: 'We have received your information.', + buttonText: 'Back to homepage', + }, + StatusError: { + title: 'Something went wrong', + description: 'We were unable to submit the form.', + retryButton: 'Try again', + cancelButton: 'Back', + }, SectionViewContainer: { editButton: 'Edit', }, 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 1110f5caee2..54edc58fd4f 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 @@ -34,6 +34,17 @@ export default { text: 'Fjern', confirmRemoveText: 'Er du sikker på at du vil slette dette?', }, + StatusSuccess: { + title: 'Takk skal du ha', + description: 'Vi har mottatt din informasjon.', + buttonText: 'Tilbake til forsiden', + }, + StatusError: { + title: 'Noe gikk galt', + description: 'Vi klarte ikke å sende inn skjemaet.', + retryButton: 'Prøv igjen', + cancelButton: 'Tilbake', + }, SectionViewContainer: { editButton: 'Endre', },