Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add StudioTextarea and StudioTextfield components #12323

Merged
merged 8 commits into from
Feb 22, 2024
2 changes: 1 addition & 1 deletion frontend/libs/studio-components/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ module.exports = {
selector: ['variable', 'function'],
modifiers: ['exported'],
format: ['PascalCase'],
prefix: ['Studio'],
prefix: ['Studio', 'use'],
},
],
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TextfieldProps, 'onChange'> {
export interface StudioDecimalInputProps extends Omit<StudioTextfieldProps, 'onChange'> {
description: string;
onChange: (value: number) => void;
value?: number;
Expand All @@ -17,8 +17,6 @@ export const StudioDecimalInput = forwardRef(
ref: RefObject<HTMLInputElement>,
) => {
const [inputValue, setInputValue] = useState('');
const [hasBeenBlurred, setHasBeenBlurred] = useState(false);
const isEmpty = inputValue === '';

useEffect(() => {
const newInputValue = convertNumberToString(value);
Expand All @@ -29,25 +27,22 @@ export const StudioDecimalInput = forwardRef(
(e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Textfield
<StudioTextfield
description={description}
value={inputValue}
onChange={handleInputChange}
error={errorMessage}
onBlur={() => setHasBeenBlurred(true)}
errorAfterBlur={errorMessage}
inputMode='decimal'
ref={ref}
{...rest}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.studioLabelWrapper.withAsterisk::after {
content: '*';
color: var(--fds-semantic-text-warning-default);
margin-left: var(--fds-spacing-1);
}
Original file line number Diff line number Diff line change
@@ -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(<StudioLabelWrapper>{label}</StudioLabelWrapper>);
expect(screen.getByText(label)).toBeInTheDocument();
});

it('Renders with withAsterisk class when "withAsterisk" is set', () => {
const { container } = render(<StudioLabelWrapper withAsterisk>Test</StudioLabelWrapper>);
expect(container.firstChild).toHaveClass('withAsterisk');
});

it.each([false, undefined])(
'Renders without withAsterisk class when "withAsterisk" is %s',
(withAsterisk) => {
const { container } = render(
<StudioLabelWrapper withAsterisk={withAsterisk}>Test</StudioLabelWrapper>,
);
expect(container.firstChild).not.toHaveClass('withAsterisk');
},
);

it('Appends given classname to internal classname', () => {
const className = 'test-class';
const { container } = render(
<StudioLabelWrapper className={className}>Test</StudioLabelWrapper>,
);
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<HTMLSpanElement>();
const { container } = render(<StudioLabelWrapper ref={ref}>Test</StudioLabelWrapper>);
expect(ref.current).toBe(container.firstChild);
});
});
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement> & {
withAsterisk?: boolean;
};

const StudioLabelWrapper = forwardRef<HTMLSpanElement, StudioLabelWrapperProps>(
({ children, className, withAsterisk, ...rest }, ref) => {
const finalClassName = cn(
classes.studioLabelWrapper,
withAsterisk && classes.withAsterisk,
className,
);

return (
<span className={finalClassName} {...rest} ref={ref}>
{children}
</span>
);
},
);

StudioLabelWrapper.displayName = 'StudioLabelWrapper';

export { StudioLabelWrapper };
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { StudioLabelWrapper } from './StudioLabelWrapper';
export type { StudioLabelWrapperProps } from './StudioLabelWrapper';
Original file line number Diff line number Diff line change
@@ -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(<StudioTextarea value={newValue} />);
expect(screen.getByRole('textbox')).toHaveValue(newValue);
});

it('Updates the value when the user types', async () => {
standeren marked this conversation as resolved.
Show resolved Hide resolved
const user = userEvent.setup();
standeren marked this conversation as resolved.
Show resolved Hide resolved
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', () => {
standeren marked this conversation as resolved.
Show resolved Hide resolved
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<HTMLTextAreaElement>();
renderTextarea({}, ref);
expect(ref.current).toBe(screen.getByRole('textbox'));
});
});

const renderTextarea = (
props: Partial<StudioTextareaProps> = {},
ref?: RefObject<HTMLTextAreaElement>,
) => render(<StudioTextarea {...props} ref={ref} />);
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement>;

const StudioTextarea = forwardRef<HTMLTextAreaElement, StudioTextareaProps>((props, ref) => {
const textareaProps: TextareaProps = useTextInputProps<HTMLTextAreaElement>(props);
return <Textarea ref={ref} {...textareaProps} />;
});

StudioTextarea.displayName = 'StudioTextarea';

export { StudioTextarea };
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { StudioTextarea } from './StudioTextarea';
export type { StudioTextareaProps } from './StudioTextarea';
Loading
Loading