diff --git a/frontend/libs/studio-components/.eslintrc.js b/frontend/libs/studio-components/.eslintrc.js index 9a2d0585fe7..f1c32d3895e 100644 --- a/frontend/libs/studio-components/.eslintrc.js +++ b/frontend/libs/studio-components/.eslintrc.js @@ -9,7 +9,7 @@ module.exports = { selector: ['variable', 'function'], modifiers: ['exported'], format: ['PascalCase'], - prefix: ['Studio'], + prefix: ['Studio', 'use'], }, ], }, diff --git a/frontend/libs/studio-components/src/components/StudioNumberInput/StudioDecimalInput.test.tsx b/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.test.tsx similarity index 100% rename from frontend/libs/studio-components/src/components/StudioNumberInput/StudioDecimalInput.test.tsx rename to frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.test.tsx diff --git a/frontend/libs/studio-components/src/components/StudioNumberInput/StudioDecimalInput.tsx b/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx similarity index 60% rename from frontend/libs/studio-components/src/components/StudioNumberInput/StudioDecimalInput.tsx rename to frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx index 2fd3486baed..e859f1ac9d2 100644 --- a/frontend/libs/studio-components/src/components/StudioNumberInput/StudioDecimalInput.tsx +++ b/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx @@ -1,10 +1,10 @@ import type { RefObject } from 'react'; import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; -import type { TextfieldProps } from '@digdir/design-system-react'; -import { Textfield } from '@digdir/design-system-react'; import { convertNumberToString, convertStringToNumber, isStringValidDecimalNumber } from './utils'; +import type { StudioTextfieldProps } from '../StudioTextfield'; +import { StudioTextfield } from '../StudioTextfield'; -export interface StudioDecimalInputProps extends Omit { +export interface StudioDecimalInputProps extends Omit { description: string; onChange: (value: number) => void; value?: number; @@ -17,8 +17,6 @@ export const StudioDecimalInput = forwardRef( ref: RefObject, ) => { const [inputValue, setInputValue] = useState(''); - const [hasBeenBlurred, setHasBeenBlurred] = useState(false); - const isEmpty = inputValue === ''; useEffect(() => { const newInputValue = convertNumberToString(value); @@ -29,25 +27,22 @@ export const StudioDecimalInput = forwardRef( (e: React.ChangeEvent) => { const input = e.target.value; setInputValue(input); - if (isEmpty) setHasBeenBlurred(false); if (isStringValidDecimalNumber(input)) onChange(convertStringToNumber(input)); }, - [setInputValue, onChange, isEmpty, setHasBeenBlurred], + [setInputValue, onChange], ); - const errorMessage = useMemo(() => { - const showErrorMessage = - hasBeenBlurred && !isEmpty && !isStringValidDecimalNumber(inputValue); - return showErrorMessage ? validationErrorMessage : undefined; - }, [hasBeenBlurred, isEmpty, inputValue, validationErrorMessage]); + const errorMessage = useMemo( + () => (!isStringValidDecimalNumber(inputValue) ? validationErrorMessage : undefined), + [inputValue, validationErrorMessage], + ); return ( - setHasBeenBlurred(true)} + errorAfterBlur={errorMessage} inputMode='decimal' ref={ref} {...rest} diff --git a/frontend/libs/studio-components/src/components/StudioNumberInput/index.ts b/frontend/libs/studio-components/src/components/StudioDecimalInput/index.ts similarity index 100% rename from frontend/libs/studio-components/src/components/StudioNumberInput/index.ts rename to frontend/libs/studio-components/src/components/StudioDecimalInput/index.ts diff --git a/frontend/libs/studio-components/src/components/StudioNumberInput/utils.test.ts b/frontend/libs/studio-components/src/components/StudioDecimalInput/utils.test.ts similarity index 100% rename from frontend/libs/studio-components/src/components/StudioNumberInput/utils.test.ts rename to frontend/libs/studio-components/src/components/StudioDecimalInput/utils.test.ts diff --git a/frontend/libs/studio-components/src/components/StudioNumberInput/utils.ts b/frontend/libs/studio-components/src/components/StudioDecimalInput/utils.ts similarity index 100% rename from frontend/libs/studio-components/src/components/StudioNumberInput/utils.ts rename to frontend/libs/studio-components/src/components/StudioDecimalInput/utils.ts diff --git a/frontend/libs/studio-components/src/components/StudioLabelWrapper/StudioLabelWrapper.module.css b/frontend/libs/studio-components/src/components/StudioLabelWrapper/StudioLabelWrapper.module.css new file mode 100644 index 00000000000..cff4d1f40db --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioLabelWrapper/StudioLabelWrapper.module.css @@ -0,0 +1,5 @@ +.studioLabelWrapper.withAsterisk::after { + content: '*'; + color: var(--fds-semantic-text-warning-default); + margin-left: var(--fds-spacing-1); +} diff --git a/frontend/libs/studio-components/src/components/StudioLabelWrapper/StudioLabelWrapper.test.tsx b/frontend/libs/studio-components/src/components/StudioLabelWrapper/StudioLabelWrapper.test.tsx new file mode 100644 index 00000000000..831882e0d54 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioLabelWrapper/StudioLabelWrapper.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { StudioLabelWrapper } from './StudioLabelWrapper'; +import { render, screen } from '@testing-library/react'; + +jest.mock('./StudioLabelWrapper.module.css', () => ({ + studioLabelWrapper: 'studioLabelWrapper', + withAsterisk: 'withAsterisk', +})); + +/* eslint-disable testing-library/no-node-access */ +describe('StudioLabelWrapper', () => { + it('Renders with given label', () => { + const label = 'test-label'; + render({label}); + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it('Renders with withAsterisk class when "withAsterisk" is set', () => { + const { container } = render(Test); + expect(container.firstChild).toHaveClass('withAsterisk'); + }); + + it.each([false, undefined])( + 'Renders without withAsterisk class when "withAsterisk" is %s', + (withAsterisk) => { + const { container } = render( + Test, + ); + expect(container.firstChild).not.toHaveClass('withAsterisk'); + }, + ); + + it('Appends given classname to internal classname', () => { + const className = 'test-class'; + const { container } = render( + Test, + ); + expect(container.firstChild).toHaveClass(className); + expect(container.firstChild).toHaveClass('studioLabelWrapper'); + }); + + it('Forwards the ref object to the span element if given', () => { + const ref = React.createRef(); + const { container } = render(Test); + expect(ref.current).toBe(container.firstChild); + }); +}); diff --git a/frontend/libs/studio-components/src/components/StudioLabelWrapper/StudioLabelWrapper.tsx b/frontend/libs/studio-components/src/components/StudioLabelWrapper/StudioLabelWrapper.tsx new file mode 100644 index 00000000000..c520552e789 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioLabelWrapper/StudioLabelWrapper.tsx @@ -0,0 +1,28 @@ +import type { HTMLAttributes } from 'react'; +import React, { forwardRef } from 'react'; +import classes from './StudioLabelWrapper.module.css'; +import cn from 'classnames'; + +export type StudioLabelWrapperProps = HTMLAttributes & { + withAsterisk?: boolean; +}; + +const StudioLabelWrapper = forwardRef( + ({ children, className, withAsterisk, ...rest }, ref) => { + const finalClassName = cn( + classes.studioLabelWrapper, + withAsterisk && classes.withAsterisk, + className, + ); + + return ( + + {children} + + ); + }, +); + +StudioLabelWrapper.displayName = 'StudioLabelWrapper'; + +export { StudioLabelWrapper }; diff --git a/frontend/libs/studio-components/src/components/StudioLabelWrapper/index.ts b/frontend/libs/studio-components/src/components/StudioLabelWrapper/index.ts new file mode 100644 index 00000000000..345cece0ebe --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioLabelWrapper/index.ts @@ -0,0 +1,2 @@ +export { StudioLabelWrapper } from './StudioLabelWrapper'; +export type { StudioLabelWrapperProps } from './StudioLabelWrapper'; diff --git a/frontend/libs/studio-components/src/components/StudioTextarea/StudioTextarea.test.tsx b/frontend/libs/studio-components/src/components/StudioTextarea/StudioTextarea.test.tsx new file mode 100644 index 00000000000..b1401080058 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTextarea/StudioTextarea.test.tsx @@ -0,0 +1,155 @@ +import type { StudioTextareaProps } from './StudioTextarea'; +import { StudioTextarea } from './StudioTextarea'; +import { act, render, screen } from '@testing-library/react'; +import type { RefObject } from 'react'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +describe('StudioTextarea', () => { + it('Renders a textarea', () => { + renderTextarea(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('Renders with given label', () => { + const label = 'test-label'; + renderTextarea({ label }); + expect(screen.getByRole('textbox', { name: label })).toBeInTheDocument(); + }); + + it('Renders with given label when there is an asterisk', () => { + const label = 'test-label'; + renderTextarea({ label, withAsterisk: true }); + expect(screen.getByRole('textbox', { name: label })).toBeInTheDocument(); + }); + + it('Renders with the given value', () => { + const value = 'test'; + renderTextarea({ value }); + expect(screen.getByRole('textbox')).toHaveValue(value); + }); + + it('Updates the value when the component rerenders with another value', () => { + const value = 'test'; + const { rerender } = renderTextarea({ value }); + expect(screen.getByRole('textbox')).toHaveValue(value); + const newValue = 'new value'; + rerender(); + expect(screen.getByRole('textbox')).toHaveValue(newValue); + }); + + it('Updates the value when the user types', async () => { + const user = userEvent.setup(); + renderTextarea(); + const textarea = screen.getByRole('textbox'); + const newValue = 'new value'; + await act(() => user.type(textarea, newValue)); + expect(textarea).toHaveValue(newValue); + }); + + it('Calls the onChange handler when the user types', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderTextarea({ onChange }); + const textarea = screen.getByRole('textbox'); + const newValue = 'new value'; + await act(() => user.type(textarea, newValue)); + expect(onChange).toHaveBeenCalledTimes(newValue.length); + const expectedTarget = expect.objectContaining({ value: newValue }); + const expectedEvent = expect.objectContaining({ target: expectedTarget }); + expect(onChange).toHaveBeenLastCalledWith(expectedEvent); + }); + + it('Calls the onBlur handler when the user blurs', async () => { + const user = userEvent.setup(); + const onBlur = jest.fn(); + renderTextarea({ onBlur }); + const textarea = screen.getByRole('textbox'); + await act(() => user.click(textarea)); + await act(() => user.tab()); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it('Does not display the after blur error message by default', () => { + const errorAfterBlur = 'error message'; + renderTextarea({ errorAfterBlur }); + expect(screen.queryByText(errorAfterBlur)).not.toBeInTheDocument(); + }); + + it('Does not display the after blur error message when the textarea is empty and the user blurs', async () => { + const user = userEvent.setup(); + const errorAfterBlur = 'error message'; + renderTextarea({ errorAfterBlur }); + const textarea = screen.getByRole('textbox'); + await act(() => user.click(textarea)); + await act(() => user.tab()); + expect(screen.queryByText(errorAfterBlur)).not.toBeInTheDocument(); + }); + + it('Does not display the after blur error message when the user types', async () => { + const user = userEvent.setup(); + const errorAfterBlur = 'error message'; + renderTextarea({ errorAfterBlur }); + const textarea = screen.getByRole('textbox'); + await act(() => user.type(textarea, 'test')); + expect(screen.queryByText(errorAfterBlur)).not.toBeInTheDocument(); + }); + + it('Displays the message provided through the errorAfterBlur prop when the user types something and then blurs', async () => { + const user = userEvent.setup(); + const errorAfterBlur = 'error message'; + renderTextarea({ errorAfterBlur }); + const textarea = screen.getByRole('textbox'); + await act(() => user.type(textarea, 'test')); + await act(() => user.tab()); + expect(screen.getByText(errorAfterBlur)).toBeInTheDocument(); + }); + + it('Diplays the message provided through the errorAfterBlur prop when the user types something after blurring', async () => { + const user = userEvent.setup(); + const errorAfterBlur = 'error message'; + renderTextarea({ errorAfterBlur }); + const textarea = screen.getByRole('textbox'); + await act(() => user.type(textarea, 'test')); + await act(() => user.tab()); + await act(() => user.type(textarea, 'test')); + expect(screen.getByText(errorAfterBlur)).toBeInTheDocument(); + }); + + it('Does not display the message provided through the errorAfterBlur prop when the user empties the textarea after blurring', async () => { + const user = userEvent.setup(); + const errorAfterBlur = 'error message'; + renderTextarea({ errorAfterBlur }); + const textarea = screen.getByRole('textbox'); + await act(() => user.type(textarea, 'test')); + await act(() => user.tab()); + await act(() => user.clear(textarea)); + expect(screen.queryByText(errorAfterBlur)).not.toBeInTheDocument(); + await act(() => user.type(textarea, 'test')); + expect(screen.queryByText(errorAfterBlur)).not.toBeInTheDocument(); + }); + + it('Displays the error message if it is set in the "error" prop', async () => { + const user = userEvent.setup(); + const error = 'error message'; + renderTextarea({ error }); + expect(screen.getByText(error)).toBeInTheDocument(); + const textarea = screen.getByRole('textbox'); + await act(() => user.type(textarea, 'test')); + expect(screen.getByText(error)).toBeInTheDocument(); + await act(() => user.tab()); + await act(() => user.clear(textarea)); + expect(screen.getByText(error)).toBeInTheDocument(); + }); + + it('Forwards the ref object to the textarea element if given', () => { + const ref = React.createRef(); + renderTextarea({}, ref); + expect(ref.current).toBe(screen.getByRole('textbox')); + }); +}); + +const renderTextarea = ( + props: Partial = {}, + ref?: RefObject, +) => render(); diff --git a/frontend/libs/studio-components/src/components/StudioTextarea/StudioTextarea.tsx b/frontend/libs/studio-components/src/components/StudioTextarea/StudioTextarea.tsx new file mode 100644 index 00000000000..26f7b8b5993 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTextarea/StudioTextarea.tsx @@ -0,0 +1,16 @@ +import React, { forwardRef } from 'react'; +import type { TextareaProps } from '@digdir/design-system-react'; +import { Textarea } from '@digdir/design-system-react'; +import { useTextInputProps } from '../../hooks/useTextInputProps'; +import type { SharedTextInputProps } from '../../types/SharedTextInputProps'; + +export type StudioTextareaProps = SharedTextInputProps; + +const StudioTextarea = forwardRef((props, ref) => { + const textareaProps: TextareaProps = useTextInputProps(props); + return