Skip to content

Commit 684a8f9

Browse files
authored
refactor: Add helptext to form component lib (#18896)
1 parent 2e772b9 commit 684a8f9

16 files changed

Lines changed: 259 additions & 269 deletions

File tree

src/App/frontend/src/app-components/HelpText/Helptext.module.css renamed to libs/form-component/src/app-components/HelpText/HelpText.module.css

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,21 @@
1212
}
1313
}
1414

15-
.helpText-iconFilled {
15+
.helpTextIconFilled {
1616
display: none;
1717
}
1818

19-
.helpText-icon {
19+
.helpTextIcon {
2020
color: var(--ds-color-accent-base-default);
2121
height: 26px;
2222
width: 26px;
2323
}
2424

25-
.helpTextButton:where(:hover, :focus, [data-state^='open']) > .helpText-icon {
25+
.helpTextButton:where(:hover, :focus, [data-state^='open']) > .helpTextIcon {
2626
display: none;
2727
}
2828

29-
.helpTextButton:where(:hover, :focus, [data-state^='open']) > .helpText-iconFilled {
29+
.helpTextButton:where(:hover, :focus, [data-state^='open']) > .helpTextIconFilled {
3030
display: inline-block;
3131
}
3232

@@ -41,19 +41,8 @@
4141
font-weight: 400;
4242
}
4343

44-
.helpText-focus:focus-visible {
44+
.helpTextFocus:focus-visible {
4545
outline: var(--ds-border-width-focus) solid var(--ds-color-neutral-text-default);
4646
outline-offset: var(--ds-border-width-focus);
4747
box-shadow: 0 0 0 var(--ds-border-width-focus) var(--ds-color-neutral-background-default);
4848
}
49-
50-
.screenReaderOnly {
51-
border: 0;
52-
clip: rect(0 0 0 0);
53-
height: 1px;
54-
overflow: hidden;
55-
padding: 0;
56-
position: absolute;
57-
white-space: nowrap;
58-
width: 1px;
59-
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
3+
import { HelpText } from './HelpText';
4+
5+
const meta = {
6+
title: 'AppComponents/HelpText',
7+
component: HelpText,
8+
parameters: {
9+
layout: 'centered',
10+
},
11+
args: {
12+
title: 'Help for this field',
13+
children: 'This is some helpful text that explains what to do.',
14+
},
15+
} satisfies Meta<typeof HelpText>;
16+
17+
export default meta;
18+
19+
type Story = StoryObj<typeof meta>;
20+
21+
export const Preview: Story = {};
22+
23+
export const WithTitlePrefix: Story = {
24+
args: {
25+
titlePrefix: 'Help for',
26+
title: 'Date of birth',
27+
},
28+
};
29+
30+
export const PlacementBottom: Story = {
31+
args: {
32+
placement: 'bottom',
33+
},
34+
};
35+
36+
export const PlacementLeft: Story = {
37+
args: {
38+
placement: 'left',
39+
},
40+
};
41+
42+
export const PlacementTop: Story = {
43+
args: {
44+
placement: 'top',
45+
},
46+
};
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
4+
import { HelpText } from './HelpText';
5+
import type { HelpTextProps } from './HelpText';
6+
7+
const renderHelpText = (props: Partial<HelpTextProps> = {}) =>
8+
render(
9+
<HelpText title='Helptext for test' {...props}>
10+
Help
11+
</HelpText>,
12+
);
13+
14+
describe('HelpText', () => {
15+
it('should render HelpText button', () => {
16+
renderHelpText();
17+
expect(screen.getByRole('button')).toBeInTheDocument();
18+
});
19+
20+
it('should compose aria-label from titlePrefix and title', () => {
21+
renderHelpText({ titlePrefix: 'Help for', title: 'My field' });
22+
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Help for My field');
23+
});
24+
25+
it('should omit aria-label when no title is given', () => {
26+
renderHelpText({ title: undefined });
27+
expect(screen.getByRole('button')).not.toHaveAttribute('aria-label');
28+
});
29+
30+
it('should open HelpText on trigger-click when closed', async () => {
31+
const user = userEvent.setup();
32+
renderHelpText();
33+
const trigger = screen.getByRole('button');
34+
35+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
36+
await user.click(trigger);
37+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
38+
});
39+
40+
it('should close HelpText on trigger-click when open', async () => {
41+
const user = userEvent.setup();
42+
renderHelpText();
43+
const trigger = screen.getByRole('button');
44+
45+
await user.click(trigger);
46+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
47+
await user.click(trigger);
48+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
49+
});
50+
51+
it('should open HelpText on SPACE pressed when closed', async () => {
52+
const user = userEvent.setup();
53+
renderHelpText();
54+
const trigger = screen.getByRole('button');
55+
56+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
57+
trigger.focus();
58+
await user.keyboard('[Space]');
59+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
60+
});
61+
62+
it('should close HelpText on ESC pressed when open', async () => {
63+
const user = userEvent.setup();
64+
renderHelpText();
65+
const trigger = screen.getByRole('button');
66+
67+
await user.click(trigger);
68+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
69+
await user.keyboard('[Escape]');
70+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
71+
});
72+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useState } from 'react';
2+
import type { PropsWithChildren, Ref } from 'react';
3+
4+
import { Popover } from '@digdir/designsystemet-react';
5+
import cn from 'classnames';
6+
7+
import { HelpTextIcon } from './HelpTextIcon';
8+
import classes from './HelpText.module.css';
9+
10+
export interface HelpTextProps extends PropsWithChildren {
11+
id?: string;
12+
title?: string;
13+
titlePrefix?: string;
14+
placement?: 'right' | 'bottom' | 'left' | 'top';
15+
className?: string;
16+
ref?: Ref<HTMLButtonElement>;
17+
}
18+
19+
export function HelpText({
20+
id,
21+
title,
22+
titlePrefix,
23+
placement = 'right',
24+
className,
25+
children,
26+
ref,
27+
}: HelpTextProps) {
28+
const [open, setOpen] = useState(false);
29+
30+
const ariaLabel = title ? `${titlePrefix ? `${titlePrefix} ` : ''}${title}` : undefined;
31+
32+
return (
33+
<Popover.TriggerContext>
34+
<Popover.Trigger asChild ref={ref} aria-label={ariaLabel} id={id}>
35+
<button
36+
className={cn(classes.helpTextButton, classes.helpTextFocus, className)}
37+
aria-expanded={open}
38+
onClick={() => setOpen(!open)}
39+
>
40+
<HelpTextIcon
41+
filled
42+
className={cn(classes.helpTextIcon, classes.helpTextIconFilled)}
43+
openState={open}
44+
/>
45+
<HelpTextIcon className={cn(classes.helpTextIcon)} openState={open} />
46+
</button>
47+
</Popover.Trigger>
48+
<Popover
49+
data-testid='helptext'
50+
className={classes.helpTextContent}
51+
data-color='info'
52+
placement={placement}
53+
data-size='md'
54+
open={open}
55+
onClose={() => setOpen(false)}
56+
>
57+
{children}
58+
</Popover>
59+
</Popover.TriggerContext>
60+
);
61+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { render, screen } from '@testing-library/react';
2+
3+
import { HelpTextIcon } from './HelpTextIcon';
4+
import type { HelpTextIconProps } from './HelpTextIcon';
5+
6+
const className = 'test-class';
7+
const defaultProps: HelpTextIconProps = {
8+
className,
9+
openState: true,
10+
};
11+
12+
const renderIcon = (props: Partial<HelpTextIconProps> = {}) =>
13+
render(<HelpTextIcon {...defaultProps} {...props} />);
14+
15+
const getIcon = () => screen.getByRole('img', { hidden: true });
16+
17+
describe('HelpTextIcon', () => {
18+
it('renders an icon', () => {
19+
renderIcon();
20+
expect(getIcon()).toBeInTheDocument();
21+
});
22+
23+
it('renders with correct path when filled is true', () => {
24+
renderIcon({ filled: true });
25+
expect(getIcon().firstChild).toHaveAttribute(
26+
'd',
27+
expect.stringMatching(
28+
/^M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0Zm0 16/,
29+
),
30+
);
31+
});
32+
33+
it('renders with correct path when filled is false', () => {
34+
renderIcon({ filled: false });
35+
expect(getIcon().firstChild).toHaveAttribute(
36+
'd',
37+
expect.stringMatching(
38+
/^M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0Zm0 2C/,
39+
),
40+
);
41+
});
42+
43+
it('renders with given className', () => {
44+
renderIcon({ className });
45+
expect(getIcon()).toHaveClass(className);
46+
});
47+
48+
it('renders with data-state="open" when openState is true', () => {
49+
renderIcon({ openState: true });
50+
expect(getIcon()).toHaveAttribute('data-state', 'open');
51+
});
52+
53+
it('renders with data-state="closed" when openState is false', () => {
54+
renderIcon({ openState: false });
55+
expect(getIcon()).toHaveAttribute('data-state', 'closed');
56+
});
57+
});

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

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import React from 'react';
2-
31
export type HelpTextIconProps = {
42
className: string;
53
filled?: boolean;
@@ -21,12 +19,7 @@ export const HelpTextIcon = ({ className, filled = false, openState }: HelpTextI
2119
viewBox='0 0 24 24'
2220
xmlns='http://www.w3.org/2000/svg'
2321
>
24-
<path
25-
clipRule='evenodd'
26-
d={d}
27-
fill='currentColor'
28-
fillRule='evenodd'
29-
/>
22+
<path clipRule='evenodd' d={d} fill='currentColor' fillRule='evenodd' />
3023
</svg>
3124
);
3225
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { HelpText } from './HelpText';
2+
export type { HelpTextProps } from './HelpText';

libs/form-component/src/app-components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './Label';
1010
export * from './DisplayDate';
1111
export * from './DisplayText';
1212
export * from './DisplayNumber';
13+
export * from './HelpText';
1314
export * from './Spinner';
1415
export * from './Timepicker';
1516
export * from './Table';

src/App/frontend/monorepo-changed-paths.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.claude/settings.json
12
.gitattributes
23
.github/
34
.husky/
@@ -18,6 +19,7 @@ src/app-components/Date/
1819
src/app-components/Datepicker/
1920
src/app-components/Dropzone/
2021
src/app-components/Flex/
22+
src/app-components/HelpText/
2123
src/app-components/Input/
2224
src/app-components/Label/
2325
src/app-components/Number/

0 commit comments

Comments
 (0)