Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0666d9a
moved Datepicker to lib
adamhaeger May 15, 2026
a09dac2
merge
adamhaeger May 15, 2026
6aa43c2
fix
adamhaeger May 15, 2026
ba1ac3c
changed paths update
adamhaeger May 15, 2026
4cdd85b
extracted mocks to __mocks__
adamhaeger May 18, 2026
0a0d7b6
Merge branch 'main' into refactor/move-Datepicker-to-app
adamhaeger May 18, 2026
731f2a2
importing from @app
adamhaeger May 18, 2026
3124efd
cleanup after code review
adamhaeger May 18, 2026
cca3019
Merge branch 'main' into refactor/move-Datepicker-to-app
adamhaeger May 18, 2026
d4e361d
fixed broken imports
adamhaeger May 18, 2026
ca3d464
moved Input to lib
adamhaeger May 18, 2026
5402916
Merge branch 'refactor/move-Datepicker-to-app' into refactor/move-Inp…
adamhaeger May 18, 2026
da05e62
merge
adamhaeger May 18, 2026
789ae87
Merge branch 'refactor/move-Datepicker-to-app' into refactor/move-Inp…
adamhaeger May 18, 2026
fe95528
removed unused IGridStyling.xl size
adamhaeger May 18, 2026
bf79f00
moved constants to layout component
adamhaeger May 19, 2026
061cf17
merge and fix broken test
adamhaeger May 19, 2026
4d707fc
fixed unused
adamhaeger May 19, 2026
1bfea13
Merge branch 'main' into refactor/move-Input-to-app
adamhaeger May 19, 2026
2bc469d
Merge branch 'main' into refactor/move-Input-to-app
adamhaeger May 20, 2026
e2248b9
Merge branch 'main' into refactor/move-Input-to-app
adamhaeger May 20, 2026
610ef1c
merge
adamhaeger May 20, 2026
0828395
cleanup after code review
adamhaeger May 20, 2026
7c93f76
Merge branch 'refactor/move-Input-to-app' of https://github.com/Altin…
adamhaeger May 20, 2026
69fab81
Merge branch 'main' into refactor/move-Input-to-app
adamhaeger May 20, 2026
2232c5e
moved FormattedInput and NumericInput to sepearate files
adamhaeger May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libs/form-component/src/app-components/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react'
import type { PropsWithChildren, Ref } from 'react';

import { Button as DesignSystemButton } from '@digdir/designsystemet-react';
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<FormattedInput
aria-label='Phone'
format='### ## ###'
value='12345678'
onValueChange={() => {}}
/>,
);

expect(screen.getByRole('textbox', { name: 'Phone' })).toHaveValue('123 45 678');
});
});
12 changes: 12 additions & 0 deletions libs/form-component/src/app-components/Input/FormattedInput.tsx
Original file line number Diff line number Diff line change
@@ -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<PatternFormatProps, 'customInput' | 'size'> & InputProps,
) {
return <PatternFormat {...props} customInput={Input} />;
}
69 changes: 69 additions & 0 deletions libs/form-component/src/app-components/Input/Input.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
Comment thread
adamhaeger marked this conversation as resolved.

import { Input } from './Input';

const meta = {
title: 'AppComponents/Input',
component: Input,
args: {
label: 'First name',
},
} satisfies Meta<typeof Input>;

export default meta;

type Story = StoryObj<typeof meta>;

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',
},
},
};
109 changes: 109 additions & 0 deletions libs/form-component/src/app-components/Input/Input.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Input label='First name' />);

expect(screen.getByRole('textbox', { name: 'First name' })).toBeInTheDocument();
});

it('uses aria-label as the accessible name', () => {
render(<Input aria-label='Search' />);

expect(screen.getByRole('textbox', { name: 'Search' })).toBeInTheDocument();
});

it('uses aria-labelledby as the accessible name', () => {
render(
<>
<span id='ssn-label'>Social security number</span>
<Input aria-labelledby='ssn-label' />
</>,
);

expect(screen.getByRole('textbox', { name: 'Social security number' })).toBeInTheDocument();
});

it('forwards change events', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<Input aria-label='Name' onChange={onChange} />);

await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Ada');

expect(onChange).toHaveBeenCalled();
Comment thread
adamhaeger marked this conversation as resolved.
expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('Ada');
});

it('renders a read-only input', () => {
render(<Input aria-label='Name' value='Locked' readOnly onChange={() => {}} />);

expect(screen.getByRole('textbox', { name: 'Name' })).toHaveAttribute('readonly');
});

it('marks the input as invalid when an error is passed', () => {
render(<Input aria-label='Name' error='Required' />);

expect(screen.getByRole('textbox', { name: 'Name' })).toHaveAttribute('aria-invalid', 'true');
});

it('renders prefix and suffix as already-translated strings', () => {
Comment thread
adamhaeger marked this conversation as resolved.
render(<Input aria-label='Amount' prefix='NOK' suffix='per month' />);

expect(screen.getByText('NOK')).toBeInTheDocument();
expect(screen.getByText('per month')).toBeInTheDocument();
});

it('renders a placeholder', () => {
render(<Input aria-label='Search' placeholder='Type to search' />);

expect(screen.getByPlaceholderText('Type to search')).toBeInTheDocument();
});

it('shows a character counter when characterLimit is set', () => {
render(
<Input
aria-label='Bio'
value='Hi'
onChange={() => {}}
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(
<Input
aria-label='Bio'
value='Hi'
readOnly
onChange={() => {}}
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(<Input aria-label='Name' textonly value='Ada Lovelace' onChange={() => {}} />);

expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});

it('renders nothing when the value is empty', () => {
const { container } = render(
<Input aria-label='Name' textonly value='' onChange={() => {}} />,
);

expect(container).toBeEmptyDOMElement();
});
});
});
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>,
| 'value'
Expand All @@ -65,7 +49,7 @@ export function Input(props: InputProps) {
readOnly,
error,
textonly,
maxLength,
characterLimit,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
label,
Expand All @@ -75,9 +59,6 @@ export function Input(props: InputProps) {
...rest
} = props;

const characterLimit = useCharacterLimit(maxLength);
const { translate } = useTranslation();

const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
if (readOnly) {
event.preventDefault();
Expand All @@ -103,7 +84,7 @@ export function Input(props: InputProps) {
}

const labelProps = ariaLabel
? { 'aria-label': translate(ariaLabel) }
? { 'aria-label': ariaLabel }
: ariaLabelledBy
? { 'aria-labelledby': ariaLabelledBy }
: { label };
Expand All @@ -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}
/>
Expand Down
18 changes: 18 additions & 0 deletions libs/form-component/src/app-components/Input/NumericInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<NumericInput
aria-label='Amount'
thousandSeparator=' '
value='1234567'
onValueChange={() => {}}
/>,
);

expect(screen.getByRole('textbox', { name: 'Amount' })).toHaveValue('1 234 567');
});
});
10 changes: 10 additions & 0 deletions libs/form-component/src/app-components/Input/NumericInput.tsx
Original file line number Diff line number Diff line change
@@ -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<NumericFormatProps, 'customInput' | 'size'> & InputProps) {
return <NumericFormat {...props} customInput={Input} />;
}
5 changes: 5 additions & 0 deletions libs/form-component/src/app-components/Input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { Input } from './Input';
export type { InputProps } from './Input';
export { FormattedInput } from './FormattedInput';
export { NumericInput } from './NumericInput';

1 change: 1 addition & 0 deletions libs/form-component/src/app-components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 3 additions & 4 deletions src/App/frontend/monorepo-changed-paths.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading