Skip to content

Commit d5d32d4

Browse files
authored
refactor: migrated Button and Spinner to form-component (#18753)
1 parent a0bf398 commit d5d32d4

70 files changed

Lines changed: 395 additions & 141 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

libs/form-component/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
},
2222
"peerDependencies": {
2323
"@digdir/designsystemet-react": "^1.11.1",
24+
"classnames": "2.5.1",
2425
"react": "^19.0.0",
2526
"react-dom": "^19.0.0"
2627
},
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
3+
import { Button } from './Button';
4+
5+
const meta = {
6+
title: 'AppComponents/Button',
7+
component: Button,
8+
args: {
9+
children: 'Save',
10+
},
11+
} satisfies Meta<typeof Button>;
12+
13+
export default meta;
14+
15+
type Story = StoryObj<typeof meta>;
16+
17+
export const Primary: Story = {
18+
args: {
19+
variant: 'primary',
20+
color: 'first',
21+
},
22+
};
23+
24+
export const Secondary: Story = {
25+
args: {
26+
variant: 'secondary',
27+
color: 'first',
28+
},
29+
};
30+
31+
export const Tertiary: Story = {
32+
args: {
33+
variant: 'tertiary',
34+
color: 'second',
35+
},
36+
};
37+
38+
export const Danger: Story = {
39+
args: {
40+
variant: 'primary',
41+
color: 'danger',
42+
children: 'Delete',
43+
},
44+
};
45+
46+
export const Loading: Story = {
47+
args: {
48+
isLoading: true,
49+
children: 'Submitting…',
50+
},
51+
};
52+
53+
export const FullWidth: Story = {
54+
args: {
55+
fullWidth: true,
56+
},
57+
parameters: {
58+
layout: 'padded',
59+
},
60+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import { Button } from './Button';
5+
6+
describe('Button', () => {
7+
it('renders children and forwards click events', async () => {
8+
const onClick = vi.fn();
9+
const user = userEvent.setup();
10+
11+
render(<Button onClick={onClick}>Save</Button>);
12+
13+
const btn = screen.getByRole('button', { name: 'Save' });
14+
await user.click(btn);
15+
expect(onClick).toHaveBeenCalledTimes(1);
16+
});
17+
18+
it('disables and shows a spinner when isLoading', () => {
19+
render(<Button isLoading>Submitting</Button>);
20+
21+
const btn = screen.getByRole('button', { name: /Submitting/ });
22+
expect(btn).toBeDisabled();
23+
});
24+
25+
it('respects an explicit disabled prop', () => {
26+
render(<Button disabled>Save</Button>);
27+
28+
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
29+
});
30+
31+
it('passes through aria-label and title as already-translated strings', () => {
32+
render(
33+
<Button aria-label='Lukk dialog' title='Lukk dialog'>
34+
X
35+
</Button>,
36+
);
37+
38+
const btn = screen.getByRole('button', { name: 'Lukk dialog' });
39+
expect(btn).toHaveAttribute('title', 'Lukk dialog');
40+
});
41+
42+
it('forwards refs to the underlying button element', () => {
43+
const ref = { current: null as HTMLButtonElement | null };
44+
45+
render(<Button ref={ref}>Save</Button>);
46+
47+
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
48+
});
49+
});

src/App/frontend/src/app-components/Button/Button.tsx renamed to libs/form-component/src/app-components/Button/Button.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ import type { PropsWithChildren } from 'react';
44
import { Button as DesignSystemButton } from '@digdir/designsystemet-react';
55
import type { ButtonProps as DesignSystemButtonProps } from '@digdir/designsystemet-react';
66

7-
import { useTranslation } from 'src/app-components/AppComponentsProvider';
8-
import { Spinner } from 'src/app-components/loading/Spinner/Spinner';
9-
import type { TranslationKey } from 'src/app-components/types';
7+
import { Spinner } from '../Spinner';
108

119
export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | undefined;
1210
export type ButtonColor = 'first' | 'second' | 'success' | 'danger' | undefined;
@@ -16,14 +14,23 @@ export type ButtonProps = {
1614
variant?: ButtonVariant;
1715
color?: ButtonColor;
1816
isLoading?: boolean;
17+
loadingLabel?: string;
1918
size?: 'sm' | 'md' | 'lg';
2019
fullWidth?: boolean;
2120
textAlign?: TextAlign;
22-
title?: TranslationKey;
23-
'aria-label'?: TranslationKey;
21+
title?: string;
22+
'aria-label'?: string;
2423
} & Omit<DesignSystemButtonProps, 'variant' | 'color' | 'size' | 'title' | 'aria-label'>;
2524

26-
type DSButtonColor = 'accent' | 'neutral' | 'success' | 'danger' | 'brand1' | 'brand2' | 'brand3' | undefined;
25+
type DSButtonColor =
26+
| 'accent'
27+
| 'neutral'
28+
| 'success'
29+
| 'danger'
30+
| 'brand1'
31+
| 'brand2'
32+
| 'brand3'
33+
| undefined;
2734

2835
function mapColorNames(color: ButtonColor): DSButtonColor {
2936
switch (color) {
@@ -47,34 +54,30 @@ export const Button = forwardRef<HTMLButtonElement, PropsWithChildren<ButtonProp
4754
fullWidth,
4855
style,
4956
textAlign,
50-
title,
51-
'aria-label': ariaLabel,
57+
loadingLabel,
5258
...rest
5359
},
5460
ref,
5561
) {
56-
const { translate } = useTranslation();
5762
const expandedStyle = { ...style, justifyContent: textAlign ? textAlign : undefined };
5863
return (
5964
<DesignSystemButton
6065
{...rest}
61-
title={title ? translate(title) : undefined}
6266
disabled={disabled || isLoading}
6367
variant={variant}
6468
data-color={mapColorNames(color)}
6569
data-size={size}
6670
data-fullwidth={fullWidth ? true : undefined}
6771
ref={ref}
6872
style={expandedStyle}
69-
aria-label={ariaLabel ? translate(ariaLabel) : undefined}
7073
>
7174
{isLoading ? (
7275
<>
7376
<Spinner
7477
aria-hidden='true'
78+
aria-label={loadingLabel}
7579
data-color={color}
7680
data-size={size === 'lg' ? 'sm' : 'xs'}
77-
aria-label={translate('general.loading')}
7881
/>
7982
{children}
8083
</>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Button } from './Button';
2+
export type { ButtonProps, ButtonVariant, ButtonColor, TextAlign } from './Button';
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.icon {
2+
width: 24px;
3+
margin-right: 12px;
4+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
3+
import { DisplayDate } from './DisplayDate';
4+
5+
const meta = {
6+
title: 'AppComponents/DisplayDate',
7+
component: DisplayDate,
8+
args: {
9+
value: '15.03.2025',
10+
},
11+
} satisfies Meta<typeof DisplayDate>;
12+
13+
export default meta;
14+
15+
type Story = StoryObj<typeof meta>;
16+
17+
export const Preview: Story = {};
18+
19+
export const WithIcon: Story = {
20+
args: {
21+
iconUrl: 'https://altinncdn.no/orgs/digdir/digdir.png',
22+
iconAltText: 'Calendar icon',
23+
},
24+
};
25+
26+
export const WithLabel: Story = {
27+
args: {
28+
labelId: 'date-label',
29+
},
30+
render: (args) => (
31+
<>
32+
<span id='date-label'>Date of birth</span>
33+
<DisplayDate {...args} />
34+
</>
35+
),
36+
};
37+
38+
export const EmptyValue: Story = {
39+
args: {
40+
value: null,
41+
},
42+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { render, screen } from '@testing-library/react';
2+
3+
import { DisplayDate } from './DisplayDate';
4+
5+
describe('DisplayDate', () => {
6+
it('renders the value inside a span', () => {
7+
render(<DisplayDate value='15.03.2025' />);
8+
9+
expect(screen.getByText('15.03.2025')).toBeInTheDocument();
10+
});
11+
12+
it('renders an icon with alt text when iconUrl is provided', () => {
13+
render(<DisplayDate value='15.03.2025' iconUrl='/icon.svg' iconAltText='Calendar icon' />);
14+
15+
const icon = screen.getByRole('img', { name: 'Calendar icon' });
16+
expect(icon).toHaveAttribute('src', '/icon.svg');
17+
});
18+
19+
it('omits the icon when iconUrl is not provided', () => {
20+
render(<DisplayDate value='15.03.2025' iconAltText='unused' />);
21+
22+
expect(screen.queryByRole('img')).not.toBeInTheDocument();
23+
});
24+
25+
it('associates the value span with labelId via aria-labelledby', () => {
26+
render(<DisplayDate value='15.03.2025' labelId='date-label' />);
27+
28+
expect(screen.getByText('15.03.2025')).toHaveAttribute('aria-labelledby', 'date-label');
29+
});
30+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
import classes from './DisplayDate.module.css';
4+
5+
export type DisplayDateProps = {
6+
value: React.ReactNode;
7+
iconUrl?: string;
8+
iconAltText?: string;
9+
labelId?: string;
10+
};
11+
12+
export function DisplayDate({ value, iconUrl, iconAltText, labelId }: DisplayDateProps) {
13+
return (
14+
<>
15+
{iconUrl && <img src={iconUrl} className={classes.icon} alt={iconAltText} />}
16+
<span aria-labelledby={labelId}>{value}</span>
17+
</>
18+
);
19+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { DisplayDate } from './DisplayDate';
2+
export type { DisplayDateProps } from './DisplayDate';

0 commit comments

Comments
 (0)