diff --git a/libs/form-component/src/app-components/Button/Button.tsx b/libs/form-component/src/app-components/Button/Button.tsx index a91183ea496..22dd3b88b76 100644 --- a/libs/form-component/src/app-components/Button/Button.tsx +++ b/libs/form-component/src/app-components/Button/Button.tsx @@ -1,3 +1,4 @@ +import React from 'react' import type { PropsWithChildren, Ref } from 'react'; import { Button as DesignSystemButton } from '@digdir/designsystemet-react'; diff --git a/libs/form-component/src/app-components/Input/FormattedInput.test.tsx b/libs/form-component/src/app-components/Input/FormattedInput.test.tsx new file mode 100644 index 00000000000..70cce0160b7 --- /dev/null +++ b/libs/form-component/src/app-components/Input/FormattedInput.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; + +import { FormattedInput } from './FormattedInput'; + +describe('FormattedInput', () => { + it('formats the value according to the pattern', () => { + render( + {}} + />, + ); + + expect(screen.getByRole('textbox', { name: 'Phone' })).toHaveValue('123 45 678'); + }); +}); diff --git a/libs/form-component/src/app-components/Input/FormattedInput.tsx b/libs/form-component/src/app-components/Input/FormattedInput.tsx new file mode 100644 index 00000000000..9a2de0286fa --- /dev/null +++ b/libs/form-component/src/app-components/Input/FormattedInput.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { PatternFormat } from 'react-number-format'; +import type { PatternFormatProps } from 'react-number-format'; + +import { Input } from './Input'; +import type { InputProps } from './Input'; + +export function FormattedInput( + props: Omit & InputProps, +) { + return ; +} diff --git a/src/App/frontend/src/app-components/Input/Input.module.css b/libs/form-component/src/app-components/Input/Input.module.css similarity index 100% rename from src/App/frontend/src/app-components/Input/Input.module.css rename to libs/form-component/src/app-components/Input/Input.module.css diff --git a/libs/form-component/src/app-components/Input/Input.stories.tsx b/libs/form-component/src/app-components/Input/Input.stories.tsx new file mode 100644 index 00000000000..7f793256cde --- /dev/null +++ b/libs/form-component/src/app-components/Input/Input.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Input } from './Input'; + +const meta = { + title: 'AppComponents/Input', + component: Input, + args: { + label: 'First name', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Preview: Story = {}; + +export const WithPlaceholder: Story = { + args: { + placeholder: 'Type your name', + }, +}; + +export const WithPrefixAndSuffix: Story = { + args: { + label: 'Amount', + prefix: 'NOK', + suffix: ',-', + }, +}; + +export const Search: Story = { + args: { + label: 'Search', + type: 'search', + }, +}; + +export const WithError: Story = { + args: { + error: true, + }, +}; + +export const ReadOnly: Story = { + args: { + value: 'Ada Lovelace', + readOnly: true, + }, +}; + +export const TextOnly: Story = { + args: { + value: 'Ada Lovelace', + textonly: true, + }, +}; + +export const WithCharacterLimit: Story = { + args: { + label: 'Short bio', + characterLimit: { + limit: 50, + under: 'characters remaining', + over: 'characters too many', + }, + }, +}; diff --git a/libs/form-component/src/app-components/Input/Input.test.tsx b/libs/form-component/src/app-components/Input/Input.test.tsx new file mode 100644 index 00000000000..b12a20c259a --- /dev/null +++ b/libs/form-component/src/app-components/Input/Input.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { Input } from './Input'; + +describe('Input', () => { + it('renders a textbox with a visible label', () => { + render(); + + expect(screen.getByRole('textbox', { name: 'First name' })).toBeInTheDocument(); + }); + + it('uses aria-label as the accessible name', () => { + render(); + + expect(screen.getByRole('textbox', { name: 'Search' })).toBeInTheDocument(); + }); + + it('uses aria-labelledby as the accessible name', () => { + render( + <> + Social security number + + , + ); + + expect(screen.getByRole('textbox', { name: 'Social security number' })).toBeInTheDocument(); + }); + + it('forwards change events', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Ada'); + + expect(onChange).toHaveBeenCalled(); + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('Ada'); + }); + + it('renders a read-only input', () => { + render( {}} />); + + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveAttribute('readonly'); + }); + + it('marks the input as invalid when an error is passed', () => { + render(); + + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveAttribute('aria-invalid', 'true'); + }); + + it('renders prefix and suffix as already-translated strings', () => { + render(); + + expect(screen.getByText('NOK')).toBeInTheDocument(); + expect(screen.getByText('per month')).toBeInTheDocument(); + }); + + it('renders a placeholder', () => { + render(); + + expect(screen.getByPlaceholderText('Type to search')).toBeInTheDocument(); + }); + + it('shows a character counter when characterLimit is set', () => { + render( + {}} + characterLimit={{ limit: 10, under: 'characters left', over: 'too many characters' }} + />, + ); + + expect(screen.getByText(/characters left/i)).toBeInTheDocument(); + }); + + it('hides the character counter for read-only inputs', () => { + render( + {}} + characterLimit={{ limit: 10, under: 'characters left', over: 'too many characters' }} + />, + ); + + expect(screen.queryByText(/characters left/i)).not.toBeInTheDocument(); + }); + + describe('textonly', () => { + it('renders the value as plain text instead of an input', () => { + render( {}} />); + + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('renders nothing when the value is empty', () => { + const { container } = render( + {}} />, + ); + + expect(container).toBeEmptyDOMElement(); + }); + }); +}); diff --git a/src/App/frontend/src/app-components/Input/Input.tsx b/libs/form-component/src/app-components/Input/Input.tsx similarity index 56% rename from src/App/frontend/src/app-components/Input/Input.tsx rename to libs/form-component/src/app-components/Input/Input.tsx index 29ef419d86f..af5c6c15cde 100644 --- a/src/App/frontend/src/app-components/Input/Input.tsx +++ b/libs/form-component/src/app-components/Input/Input.tsx @@ -1,48 +1,32 @@ import React from 'react'; import type { InputHTMLAttributes, ReactNode } from 'react'; -import { Paragraph, Textfield } from '@digdir/designsystemet-react'; -import type { FieldCounterProps } from '@digdir/designsystemet-react'; +import { + Paragraph, + Textfield, + type TextfieldProps, + type FieldCounterProps, +} from '@digdir/designsystemet-react'; -import { useTranslation } from 'src/app-components/AppComponentsProvider'; -import classes from 'src/app-components/Input/Input.module.css'; -import type { InputType } from 'src/app-components/Input/constants'; -import type { TranslationKey } from 'src/app-components/types'; - -/** - * Hook to create a character limit object for use in input components - */ -export const useCharacterLimit = (maxLength: number | undefined): FieldCounterProps | undefined => { - const { translate } = useTranslation(); - - if (maxLength === undefined) { - return undefined; - } - - return { - limit: maxLength, - under: translate('input_components.remaining_characters'), - over: translate('input_components.exceeded_max_limit'), - }; -}; +import classes from './Input.module.css'; type LabelRequired = - | { 'aria-label': TranslationKey; 'aria-labelledby'?: never; label?: never } + | { 'aria-label': string; 'aria-labelledby'?: never; label?: never } | { 'aria-label'?: never; 'aria-labelledby'?: never; label: ReactNode } | { 'aria-label'?: never; 'aria-labelledby': string; label?: never }; export type InputProps = { size?: 'sm' | 'md' | 'lg'; - prefix?: TranslationKey; - suffix?: TranslationKey; + prefix?: string; + suffix?: string; error?: ReactNode; disabled?: boolean; id?: string; readOnly?: boolean; - type?: InputType; + type?: TextfieldProps['type']; textonly?: boolean; - maxLength?: number; - placeholder?: TranslationKey; + characterLimit?: FieldCounterProps; + placeholder?: string; } & Pick< InputHTMLAttributes, | 'value' @@ -65,7 +49,7 @@ export function Input(props: InputProps) { readOnly, error, textonly, - maxLength, + characterLimit, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, label, @@ -75,9 +59,6 @@ export function Input(props: InputProps) { ...rest } = props; - const characterLimit = useCharacterLimit(maxLength); - const { translate } = useTranslation(); - const handlePaste = (event: React.ClipboardEvent) => { if (readOnly) { event.preventDefault(); @@ -103,7 +84,7 @@ export function Input(props: InputProps) { } const labelProps = ariaLabel - ? { 'aria-label': translate(ariaLabel) } + ? { 'aria-label': ariaLabel } : ariaLabelledBy ? { 'aria-labelledby': ariaLabelledBy } : { label }; @@ -115,9 +96,9 @@ export function Input(props: InputProps) { aria-invalid={!!error} readOnly={readOnly} counter={!readOnly ? characterLimit : undefined} - prefix={prefix ? translate(prefix) : undefined} - suffix={suffix ? translate(suffix) : undefined} - placeholder={placeholder ? translate(placeholder) : undefined} + prefix={prefix} + suffix={suffix} + placeholder={placeholder} {...labelProps} {...rest} /> diff --git a/libs/form-component/src/app-components/Input/NumericInput.test.tsx b/libs/form-component/src/app-components/Input/NumericInput.test.tsx new file mode 100644 index 00000000000..6676f1cded1 --- /dev/null +++ b/libs/form-component/src/app-components/Input/NumericInput.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; + +import { NumericInput } from './NumericInput'; + +describe('NumericInput', () => { + it('formats the value with a thousand separator', () => { + render( + {}} + />, + ); + + expect(screen.getByRole('textbox', { name: 'Amount' })).toHaveValue('1 234 567'); + }); +}); diff --git a/libs/form-component/src/app-components/Input/NumericInput.tsx b/libs/form-component/src/app-components/Input/NumericInput.tsx new file mode 100644 index 00000000000..8de0e297419 --- /dev/null +++ b/libs/form-component/src/app-components/Input/NumericInput.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { NumericFormat } from 'react-number-format'; +import type { NumericFormatProps } from 'react-number-format'; + +import { Input } from './Input'; +import type { InputProps } from './Input'; + +export function NumericInput(props: Omit & InputProps) { + return ; +} diff --git a/libs/form-component/src/app-components/Input/index.ts b/libs/form-component/src/app-components/Input/index.ts new file mode 100644 index 00000000000..cff21c209c8 --- /dev/null +++ b/libs/form-component/src/app-components/Input/index.ts @@ -0,0 +1,5 @@ +export { Input } from './Input'; +export type { InputProps } from './Input'; +export { FormattedInput } from './FormattedInput'; +export { NumericInput } from './NumericInput'; + diff --git a/libs/form-component/src/app-components/index.ts b/libs/form-component/src/app-components/index.ts index 22ea64ccdf2..7b5bd7bafe1 100644 --- a/libs/form-component/src/app-components/index.ts +++ b/libs/form-component/src/app-components/index.ts @@ -3,6 +3,7 @@ export * from './Button'; export * from './Card'; export * from './Datepicker'; export * from './Flex'; +export * from './Input'; export * from './hooks'; export * from './DisplayDate'; export * from './DisplayNumber'; diff --git a/src/App/frontend/monorepo-changed-paths.txt b/src/App/frontend/monorepo-changed-paths.txt index 009d0a15603..19060ac8b8c 100644 --- a/src/App/frontend/monorepo-changed-paths.txt +++ b/src/App/frontend/monorepo-changed-paths.txt @@ -2,6 +2,7 @@ .github/ .husky/ .yarn/patches/jsdom-npm-26.1.0-3857255f02.patch +.yarn/releases/yarn-4.12.0.cjs .yarnrc.yml LICENSE.md adr/001-component-library.md @@ -17,6 +18,7 @@ src/app-components/Card/ src/app-components/Date/ src/app-components/Datepicker/ src/app-components/Flex/ +src/app-components/Input/ src/app-components/Number/ src/app-components/Text/DisplayText.tsx src/app-components/TimePicker/TimeSegment/TimeSegment.tsx @@ -48,10 +50,8 @@ src/features/form/layoutSettings/ src/features/form/rules/RulesContext.tsx src/features/formData/LegacyRules.ts src/features/instance/instanceUtils.ts +src/features/instantiate/InstantiationError.tsx src/features/instantiate/containers/InstantiateContainer.tsx -src/features/instantiate/containers/UnknownError.module.css -src/features/instantiate/containers/UnknownErrorDetails.module.css -src/features/instantiate/containers/UnknownErrorDetails.tsx src/features/instantiate/useInstantiation.tsx src/features/language/LangDataSourcesProvider.tsx src/features/language/LangToolsStore.tsx @@ -77,7 +77,6 @@ src/layout/Group/SummaryGroupComponent.test.tsx src/layout/Group/__snapshots__/SummaryGroupComponent.test.tsx.snap src/layout/RepeatingGroup/Summary/SummaryRepeatingGroup.test.tsx src/layout/RepeatingGroup/Summary/__snapshots__/SummaryRepeatingGroup.test.tsx.snap -src/layout/SigningDocumentList/api.test.ts src/queries/appPrefetcher.ts src/queries/formPrefetcher.ts src/utils/formLayout.test.ts diff --git a/src/App/frontend/src/app-components/Input/FormattedInput.tsx b/src/App/frontend/src/app-components/Input/FormattedInput.tsx deleted file mode 100644 index cb6efae676d..00000000000 --- a/src/App/frontend/src/app-components/Input/FormattedInput.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { PatternFormat } from 'react-number-format'; -import type { PatternFormatProps } from 'react-number-format'; - -import { Input } from 'src/app-components/Input/Input'; -import type { InputProps } from 'src/app-components/Input/Input'; - -export function FormattedInput(props: Omit & InputProps) { - return ( - - ); -} diff --git a/src/App/frontend/src/app-components/Input/NumericInput.tsx b/src/App/frontend/src/app-components/Input/NumericInput.tsx deleted file mode 100644 index 097ed0ddf1a..00000000000 --- a/src/App/frontend/src/app-components/Input/NumericInput.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { NumericFormat, type NumericFormatProps } from 'react-number-format'; - -import { Input, type InputProps } from 'src/app-components/Input/Input'; - -export function NumericInput(props: Omit & InputProps) { - return ( - - ); -} diff --git a/src/App/frontend/src/features/devtools/components/DevToolsLogs/DevToolsLogs.tsx b/src/App/frontend/src/features/devtools/components/DevToolsLogs/DevToolsLogs.tsx index 7df146973df..235d936950d 100644 --- a/src/App/frontend/src/features/devtools/components/DevToolsLogs/DevToolsLogs.tsx +++ b/src/App/frontend/src/features/devtools/components/DevToolsLogs/DevToolsLogs.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { Button } from '@app/form-component'; +import { Button, Input } from '@app/form-component'; import { DownloadIcon, ExclamationmarkTriangleFillIcon, @@ -9,10 +9,9 @@ import { XMarkOctagonFillIcon, } from '@navikt/aksel-icons'; -import { Input } from 'src/app-components/Input/Input'; -import { translationKey } from 'src/AppComponentsBridge'; import classes from 'src/features/devtools/components/DevToolsLogs/DevToolsLogs.module.css'; import { useDevToolsStore } from 'src/features/devtools/data/DevToolsStore'; +import { useLanguage } from 'src/features/language/useLanguage'; const colorMap = { error: 'red', @@ -21,6 +20,7 @@ const colorMap = { }; export const DevToolsLogs = () => { + const { langAsString } = useLanguage(); const logs = useDevToolsStore((state) => state.logs); const [filter, setFilter] = useState(''); const [showLevels, setShowLevels] = useState({ error: true, warn: true, info: true }); @@ -72,10 +72,10 @@ export const DevToolsLogs = () => {
setFilter(e.target.value)} - placeholder={translationKey('devtools.filter_logs')} + placeholder={langAsString('devtools.filter_logs')} />
diff --git a/src/App/frontend/src/features/instantiate/containers/PartySelection.tsx b/src/App/frontend/src/features/instantiate/containers/PartySelection.tsx index 53e87d208fc..52cff1d60e7 100644 --- a/src/App/frontend/src/features/instantiate/containers/PartySelection.tsx +++ b/src/App/frontend/src/features/instantiate/containers/PartySelection.tsx @@ -1,13 +1,11 @@ import React from 'react'; import { useMatch, useNavigate } from 'react-router'; -import { Button, Flex } from '@app/form-component'; +import { Button, Flex, Input } from '@app/form-component'; import { Checkbox, Heading, Paragraph } from '@digdir/designsystemet-react'; import { PlusIcon } from '@navikt/aksel-icons'; import cn from 'classnames'; -import { Input } from 'src/app-components/Input/Input'; -import { translationKey } from 'src/AppComponentsBridge'; import { AltinnParty } from 'src/components/altinnParty'; import { useAppName, useAppOwner } from 'src/core/texts/appTexts'; import { getApplicationMetadata } from 'src/features/applicationMetadata'; @@ -141,8 +139,8 @@ export const PartySelection = () => { > (undefined); const formValue = localValue ?? realFormValue; @@ -146,7 +147,7 @@ const InputVariant = ({ }); const labelProps = textResourceBindings?.title - ? { 'aria-label': translationKey(textResourceBindings?.title) } + ? { 'aria-label': langAsString(textResourceBindings.title) } : { 'aria-labelledby': labelId }; const inputProps: InputProps = { @@ -160,8 +161,8 @@ const InputVariant = ({ required, onBlur: () => debounce('blur'), error: !useIsValid(baseComponentId), - prefix: translationKey(textResourceBindings?.prefix), - suffix: translationKey(textResourceBindings?.suffix), + prefix: textResourceBindings?.prefix ? langAsString(textResourceBindings.prefix) : undefined, + suffix: textResourceBindings?.suffix ? langAsString(textResourceBindings.suffix) : undefined, style: { width: '100%' }, inputMode, pattern, @@ -178,7 +179,7 @@ const InputVariant = ({ onChange={(event) => { setValue('simpleBinding', event.target.value); }} - maxLength={maxLength} + characterLimit={characterLimit} /> ); case 'pattern': @@ -194,7 +195,7 @@ const InputVariant = ({ } setValue('simpleBinding', values.value); }} - maxLength={maxLength} + characterLimit={characterLimit} /> ); case 'number': @@ -202,8 +203,8 @@ const InputVariant = ({ { @@ -251,7 +252,7 @@ const InputVariant = ({ setValue('simpleBinding', pastedText); } }} - maxLength={maxLength} + characterLimit={characterLimit} /> ); } diff --git a/src/App/frontend/src/layout/Input/config.ts b/src/App/frontend/src/layout/Input/config.ts index 4f59ae502b3..f97c547d941 100644 --- a/src/App/frontend/src/layout/Input/config.ts +++ b/src/App/frontend/src/layout/Input/config.ts @@ -1,6 +1,6 @@ -import { EXTERNAL_INPUT_TYPE, INPUT_AUTO_COMPLETE } from 'src/app-components/Input/constants'; import { CG } from 'src/codegen/CG'; import { CompCategory } from 'src/layout/common'; +import { EXTERNAL_INPUT_TYPE, INPUT_AUTO_COMPLETE } from 'src/layout/Input/constants'; export const Config = new CG.component({ category: CompCategory.Form, diff --git a/src/App/frontend/src/app-components/Input/constants.ts b/src/App/frontend/src/layout/Input/constants.ts similarity index 100% rename from src/App/frontend/src/app-components/Input/constants.ts rename to src/App/frontend/src/layout/Input/constants.ts diff --git a/src/App/frontend/src/layout/OrganisationLookup/OrganisationLookupComponent.tsx b/src/App/frontend/src/layout/OrganisationLookup/OrganisationLookupComponent.tsx index 3882799b171..4d52f68b3ee 100644 --- a/src/App/frontend/src/layout/OrganisationLookup/OrganisationLookupComponent.tsx +++ b/src/App/frontend/src/layout/OrganisationLookup/OrganisationLookupComponent.tsx @@ -1,15 +1,13 @@ import React, { useState } from 'react'; -import { Button } from '@app/form-component'; +import { Button, NumericInput } from '@app/form-component'; import { Field, Paragraph, ValidationMessage } from '@digdir/designsystemet-react'; import { queryOptions, useQuery } from '@tanstack/react-query'; import type { PropsFromGenericComponent } from '..'; -import { NumericInput } from 'src/app-components/Input/NumericInput'; import { Fieldset } from 'src/app-components/Label/Fieldset'; import { Label } from 'src/app-components/Label/Label'; -import { translationKey } from 'src/AppComponentsBridge'; import { Description } from 'src/components/form/Description'; import { RequiredIndicator } from 'src/components/form/RequiredIndicator'; import { getDescriptionId } from 'src/components/label/Label'; @@ -154,7 +152,7 @@ export function OrganisationLookupComponent({