@tpzdsp/next-toolkit 1.12.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,330 @@
1
+ import { InfoBox } from './InfoBox';
2
+ import { render, screen, userEvent, waitFor } from '../../test/renderers';
3
+
4
+ const TEST_CONTENT = 'Test info content';
5
+ const TEST_TITLE = 'Test Title';
6
+ const ARIA_EXPANDED = 'aria-expanded';
7
+
8
+ describe('InfoBox', () => {
9
+ describe('rendering', () => {
10
+ it('should render the trigger button with info icon', () => {
11
+ render(
12
+ <InfoBox>
13
+ <p>{TEST_CONTENT}</p>
14
+ </InfoBox>,
15
+ );
16
+
17
+ const button = screen.getByRole('button', { name: /show information/i });
18
+
19
+ expect(button).toBeInTheDocument();
20
+ expect(button.querySelector('svg')).toBeInTheDocument();
21
+ });
22
+
23
+ it('should not render content when closed', () => {
24
+ render(
25
+ <InfoBox>
26
+ <p>{TEST_CONTENT}</p>
27
+ </InfoBox>,
28
+ );
29
+
30
+ expect(screen.queryByText(TEST_CONTENT)).not.toBeInTheDocument();
31
+ });
32
+
33
+ it('should render content when defaultOpen is true', () => {
34
+ render(
35
+ <InfoBox defaultOpen>
36
+ <p>{TEST_CONTENT}</p>
37
+ </InfoBox>,
38
+ );
39
+
40
+ expect(screen.getByText(TEST_CONTENT)).toBeInTheDocument();
41
+ });
42
+
43
+ it('should render title when provided', () => {
44
+ render(
45
+ <InfoBox title={TEST_TITLE} defaultOpen>
46
+ <p>{TEST_CONTENT}</p>
47
+ </InfoBox>,
48
+ );
49
+
50
+ expect(screen.getByText(TEST_TITLE)).toBeInTheDocument();
51
+ expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(TEST_TITLE);
52
+ });
53
+
54
+ it('should not render title element when not provided', () => {
55
+ render(
56
+ <InfoBox defaultOpen>
57
+ <p>{TEST_CONTENT}</p>
58
+ </InfoBox>,
59
+ );
60
+
61
+ expect(screen.queryByRole('heading')).not.toBeInTheDocument();
62
+ });
63
+ });
64
+
65
+ describe('interactions', () => {
66
+ it('should open content when trigger is clicked', async () => {
67
+ const user = userEvent.setup();
68
+
69
+ render(
70
+ <InfoBox>
71
+ <p>{TEST_CONTENT}</p>
72
+ </InfoBox>,
73
+ );
74
+
75
+ const button = screen.getByRole('button', { name: /show information/i });
76
+
77
+ expect(screen.queryByText(TEST_CONTENT)).not.toBeInTheDocument();
78
+
79
+ await user.click(button);
80
+
81
+ expect(screen.getByText(TEST_CONTENT)).toBeInTheDocument();
82
+ });
83
+
84
+ it('should close content when trigger is clicked while open', async () => {
85
+ const user = userEvent.setup();
86
+
87
+ render(
88
+ <InfoBox defaultOpen>
89
+ <p>{TEST_CONTENT}</p>
90
+ </InfoBox>,
91
+ );
92
+
93
+ expect(screen.getByText(TEST_CONTENT)).toBeInTheDocument();
94
+
95
+ const button = screen.getByRole('button', { name: /show information/i });
96
+
97
+ await user.click(button);
98
+
99
+ await waitFor(() => {
100
+ expect(screen.queryByText(TEST_CONTENT)).not.toBeInTheDocument();
101
+ });
102
+ });
103
+
104
+ // Note: Escape key closing is handled by FocusTrap, which is mocked in tests.
105
+ // The real behavior is tested through FocusTrap's own test suite.
106
+ });
107
+
108
+ describe('accessibility', () => {
109
+ it('should have aria-expanded attribute that toggles correctly', async () => {
110
+ const user = userEvent.setup();
111
+
112
+ render(
113
+ <InfoBox>
114
+ <p>{TEST_CONTENT}</p>
115
+ </InfoBox>,
116
+ );
117
+
118
+ const button = screen.getByRole('button', { name: /show information/i });
119
+
120
+ expect(button).toHaveAttribute(ARIA_EXPANDED, 'false');
121
+
122
+ await user.click(button);
123
+
124
+ expect(button).toHaveAttribute(ARIA_EXPANDED, 'true');
125
+ });
126
+
127
+ it('should have aria-haspopup="dialog" on trigger', () => {
128
+ render(
129
+ <InfoBox>
130
+ <p>{TEST_CONTENT}</p>
131
+ </InfoBox>,
132
+ );
133
+
134
+ const button = screen.getByRole('button', { name: /show information/i });
135
+
136
+ expect(button).toHaveAttribute('aria-haspopup', 'dialog');
137
+ });
138
+
139
+ it('should have dialog role', async () => {
140
+ const user = userEvent.setup();
141
+
142
+ render(
143
+ <InfoBox>
144
+ <p>{TEST_CONTENT}</p>
145
+ </InfoBox>,
146
+ );
147
+
148
+ await user.click(screen.getByRole('button', { name: /show information/i }));
149
+
150
+ const dialog = screen.getByRole('dialog');
151
+
152
+ // Floating UI uses div with role="dialog"
153
+ expect(dialog).toBeInTheDocument();
154
+ });
155
+
156
+ it('should have aria-labelledby when title is provided', async () => {
157
+ const user = userEvent.setup();
158
+
159
+ render(
160
+ <InfoBox title={TEST_TITLE}>
161
+ <p>{TEST_CONTENT}</p>
162
+ </InfoBox>,
163
+ );
164
+
165
+ await user.click(screen.getByRole('button', { name: /show information/i }));
166
+
167
+ const dialog = screen.getByRole('dialog');
168
+ const titleId = dialog.getAttribute('aria-labelledby');
169
+
170
+ expect(titleId).toBeTruthy();
171
+
172
+ const title = screen.getByRole('heading', { level: 2 });
173
+
174
+ expect(title).toHaveAttribute('id', titleId);
175
+ });
176
+
177
+ it('should have aria-label when title is not provided', async () => {
178
+ const user = userEvent.setup();
179
+
180
+ render(
181
+ <InfoBox>
182
+ <p>{TEST_CONTENT}</p>
183
+ </InfoBox>,
184
+ );
185
+
186
+ await user.click(screen.getByRole('button', { name: /show information/i }));
187
+
188
+ const dialog = screen.getByRole('dialog');
189
+
190
+ expect(dialog).toHaveAttribute('aria-label', 'Information');
191
+ expect(dialog).not.toHaveAttribute('aria-labelledby');
192
+ });
193
+
194
+ it('should use custom triggerLabel when provided', () => {
195
+ render(
196
+ <InfoBox triggerLabel="Learn more about this feature">
197
+ <p>{TEST_CONTENT}</p>
198
+ </InfoBox>,
199
+ );
200
+
201
+ expect(
202
+ screen.getByRole('button', { name: /learn more about this feature/i }),
203
+ ).toBeInTheDocument();
204
+ });
205
+ });
206
+
207
+ describe('callbacks', () => {
208
+ it('should call onOpenChange when opening', async () => {
209
+ const user = userEvent.setup();
210
+ const onOpenChange = vi.fn();
211
+
212
+ render(
213
+ <InfoBox onOpenChange={onOpenChange}>
214
+ <p>{TEST_CONTENT}</p>
215
+ </InfoBox>,
216
+ );
217
+
218
+ await user.click(screen.getByRole('button', { name: /show information/i }));
219
+
220
+ expect(onOpenChange).toHaveBeenCalledWith(true);
221
+ });
222
+
223
+ it('should call onOpenChange when closing', async () => {
224
+ const user = userEvent.setup();
225
+ const onOpenChange = vi.fn();
226
+
227
+ render(
228
+ <InfoBox defaultOpen onOpenChange={onOpenChange}>
229
+ <p>{TEST_CONTENT}</p>
230
+ </InfoBox>,
231
+ );
232
+
233
+ await user.click(screen.getByRole('button', { name: /show information/i }));
234
+
235
+ await waitFor(() => {
236
+ expect(onOpenChange).toHaveBeenCalledWith(false);
237
+ });
238
+ });
239
+ });
240
+
241
+ describe('positioning', () => {
242
+ it('should accept placement prop', async () => {
243
+ const user = userEvent.setup();
244
+
245
+ render(
246
+ <InfoBox placement="top-start">
247
+ <p>{TEST_CONTENT}</p>
248
+ </InfoBox>,
249
+ );
250
+
251
+ await user.click(screen.getByRole('button', { name: /show information/i }));
252
+
253
+ const dialog = screen.getByRole('dialog');
254
+
255
+ // Floating UI handles positioning automatically
256
+ expect(dialog).toBeInTheDocument();
257
+ });
258
+ });
259
+
260
+ describe('styling', () => {
261
+ it('should apply maxWidth style', async () => {
262
+ const user = userEvent.setup();
263
+
264
+ render(
265
+ <InfoBox maxWidth="400px">
266
+ <p>{TEST_CONTENT}</p>
267
+ </InfoBox>,
268
+ );
269
+
270
+ await user.click(screen.getByRole('button', { name: /show information/i }));
271
+
272
+ const dialog = screen.getByRole('dialog');
273
+
274
+ expect(dialog).toHaveStyle({ maxWidth: 'min(400px, calc(100vw - 32px))' });
275
+ });
276
+
277
+ it('should merge custom className', () => {
278
+ render(
279
+ <InfoBox className="custom-class">
280
+ <p>{TEST_CONTENT}</p>
281
+ </InfoBox>,
282
+ );
283
+
284
+ const container = screen.getByRole('button', { name: /show information/i }).parentElement;
285
+
286
+ expect(container).toHaveClass('custom-class');
287
+ });
288
+
289
+ it('should have correct trigger button styling', () => {
290
+ render(
291
+ <InfoBox>
292
+ <p>{TEST_CONTENT}</p>
293
+ </InfoBox>,
294
+ );
295
+
296
+ const button = screen.getByRole('button', { name: /show information/i });
297
+
298
+ expect(button).toHaveClass('rounded-full', 'bg-transparent');
299
+ });
300
+ });
301
+
302
+ describe('complex content', () => {
303
+ it('should render interactive content correctly', async () => {
304
+ const user = userEvent.setup();
305
+ const onButtonClick = vi.fn();
306
+
307
+ render(
308
+ <InfoBox defaultOpen>
309
+ <div>
310
+ <p>Description text</p>
311
+
312
+ <input type="text" placeholder="Enter text" />
313
+
314
+ <button onClick={onButtonClick}>Action</button>
315
+ </div>
316
+ </InfoBox>,
317
+ );
318
+
319
+ expect(screen.getByText('Description text')).toBeInTheDocument();
320
+ expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
321
+
322
+ const actionButton = screen.getByRole('button', { name: 'Action' });
323
+
324
+ expect(actionButton).toBeInTheDocument();
325
+
326
+ await user.click(actionButton);
327
+ expect(onButtonClick).toHaveBeenCalled();
328
+ });
329
+ });
330
+ });
@@ -0,0 +1,168 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, useRef, useId, useState } from 'react';
4
+
5
+ import { FaInfoCircle } from 'react-icons/fa';
6
+
7
+ import {
8
+ useFloating,
9
+ autoUpdate,
10
+ offset,
11
+ flip,
12
+ shift,
13
+ arrow,
14
+ useClick,
15
+ useDismiss,
16
+ useRole,
17
+ useInteractions,
18
+ FloatingArrow,
19
+ FloatingFocusManager,
20
+ FloatingPortal,
21
+ type Placement,
22
+ } from '@floating-ui/react';
23
+
24
+ import type { ExtendProps } from '../../types';
25
+ import { cn } from '../../utils';
26
+
27
+ type Props = {
28
+ /** Optional title displayed at the top of the info box content */
29
+ title?: string;
30
+ /** Content to display inside the info box */
31
+ children: ReactNode;
32
+ /** Whether the info box starts open (default: false) */
33
+ defaultOpen?: boolean;
34
+ /** Callback when the info box opens or closes */
35
+ onOpenChange?: (isOpen: boolean) => void;
36
+ /** Maximum width of the info box (default: '320px') */
37
+ maxWidth?: string;
38
+ /** Custom aria-label for the trigger button (default: 'Show information') */
39
+ triggerLabel?: string;
40
+ /** Preferred placement (Floating UI will auto-adjust if needed) */
41
+ placement?: Placement;
42
+ };
43
+
44
+ export type InfoBoxProps = ExtendProps<'div', Props>;
45
+
46
+ export const InfoBox = ({
47
+ title,
48
+ children,
49
+ defaultOpen = false,
50
+ onOpenChange,
51
+ maxWidth = '320px',
52
+ triggerLabel = 'Show information',
53
+ placement = 'bottom-start',
54
+ className,
55
+ ...props
56
+ }: InfoBoxProps) => {
57
+ const [isOpen, setIsOpen] = useState(defaultOpen);
58
+ const arrowRef = useRef(null);
59
+
60
+ const { refs, floatingStyles, context } = useFloating({
61
+ open: isOpen,
62
+ onOpenChange: (open) => {
63
+ setIsOpen(open);
64
+ onOpenChange?.(open);
65
+ },
66
+ placement,
67
+ middleware: [
68
+ offset(12), // Distance from trigger
69
+ flip(), // Flip to opposite side if not enough space
70
+ shift({ padding: 8 }), // Shift along the axis to stay in viewport
71
+ arrow({ element: arrowRef }), // Arrow pointing to trigger
72
+ ],
73
+ whileElementsMounted: autoUpdate, // Update position on scroll/resize
74
+ });
75
+
76
+ const click = useClick(context);
77
+ const dismiss = useDismiss(context);
78
+ const role = useRole(context);
79
+
80
+ const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
81
+
82
+ const triggerId = useId();
83
+ const contentId = useId();
84
+ const titleId = useId();
85
+
86
+ const triggerClasses = cn(
87
+ // Base styles - button structure only
88
+ 'inline-flex items-center justify-center',
89
+ 'w-6 h-6 rounded-full',
90
+ 'bg-transparent border-none',
91
+ // Focus outline only
92
+ 'focus:outline focus:outline-[3px] focus:outline-focus',
93
+ );
94
+
95
+ const iconClasses = cn(
96
+ // Icon size
97
+ 'w-5 h-5',
98
+ // Icon color - yellow when open, black when closed
99
+ isOpen ? 'text-focus' : 'text-black',
100
+ // Hover state - yellow
101
+ 'hover:text-focus',
102
+ // Focus state - yellow
103
+ 'focus:text-focus',
104
+ // Transition
105
+ 'transition-colors duration-150',
106
+ );
107
+
108
+ const contentClasses = cn(
109
+ // Base styles
110
+ 'bg-white rounded-lg shadow-lg border border-gray-200',
111
+ 'p-4',
112
+ // Width constraints
113
+ 'min-w-[280px]',
114
+ // Z-index
115
+ 'z-50',
116
+ );
117
+
118
+ return (
119
+ <div className={cn('relative inline-flex', className)} {...props}>
120
+ <button
121
+ ref={refs.setReference}
122
+ id={triggerId}
123
+ type="button"
124
+ aria-expanded={isOpen}
125
+ aria-controls={contentId}
126
+ aria-haspopup="dialog"
127
+ aria-label={triggerLabel}
128
+ className={triggerClasses}
129
+ {...getReferenceProps()}
130
+ >
131
+ <FaInfoCircle className={iconClasses} aria-hidden="true" />
132
+ </button>
133
+
134
+ {isOpen && (
135
+ <FloatingPortal>
136
+ <FloatingFocusManager context={context} modal={false}>
137
+ <div
138
+ ref={refs.setFloating}
139
+ id={contentId}
140
+ aria-labelledby={title ? titleId : undefined}
141
+ aria-label={title ? undefined : 'Information'}
142
+ style={{
143
+ ...floatingStyles,
144
+ maxWidth: `min(${maxWidth}, calc(100vw - 32px))`,
145
+ }}
146
+ className={contentClasses}
147
+ {...getFloatingProps()}
148
+ >
149
+ <FloatingArrow ref={arrowRef} context={context} className="fill-white" />
150
+
151
+ {title ? (
152
+ <h2 id={titleId} className="text-sm font-semibold text-gray-900 mb-2">
153
+ {title}
154
+ </h2>
155
+ ) : null}
156
+ <div
157
+ className="text-sm text-gray-700 max-h-[min(24rem,calc(100vh-200px))]
158
+ overflow-y-auto"
159
+ >
160
+ {children}
161
+ </div>
162
+ </div>
163
+ </FloatingFocusManager>
164
+ </FloatingPortal>
165
+ )}
166
+ </div>
167
+ );
168
+ };
@@ -0,0 +1,6 @@
1
+ export type Position = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
2
+
3
+ export const POSITION_TOP_LEFT: Position = 'top-left';
4
+ export const POSITION_TOP_RIGHT: Position = 'top-right';
5
+ export const POSITION_BOTTOM_LEFT: Position = 'bottom-left';
6
+ export const POSITION_BOTTOM_RIGHT: Position = 'bottom-right';
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import { LinkButton } from './LinkButton';
4
+
5
+ const meta = {
6
+ title: 'Components/LinkButton',
7
+ component: LinkButton,
8
+ parameters: {
9
+ layout: 'centered',
10
+ },
11
+ tags: ['autodocs'],
12
+ argTypes: {
13
+ variant: {
14
+ control: 'select',
15
+ options: ['primary', 'secondary', 'inverse'],
16
+ },
17
+ openInNewTab: {
18
+ control: 'boolean',
19
+ },
20
+ },
21
+ } satisfies Meta<typeof LinkButton>;
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ export const Primary: Story = {
27
+ args: {
28
+ children: 'Explore data',
29
+ href: '/explore',
30
+ variant: 'primary',
31
+ },
32
+ };
33
+
34
+ export const Secondary: Story = {
35
+ args: {
36
+ children: 'Learn more',
37
+ href: '/about',
38
+ variant: 'secondary',
39
+ },
40
+ };
41
+
42
+ export const Inverse: Story = {
43
+ args: {
44
+ children: 'Get started',
45
+ href: '/start',
46
+ variant: 'inverse',
47
+ },
48
+ };
49
+
50
+ export const ExternalLink: Story = {
51
+ args: {
52
+ children: 'Visit GOV.UK',
53
+ href: 'https://www.gov.uk',
54
+ variant: 'primary',
55
+ },
56
+ };
57
+
58
+ export const OpenInNewTab: Story = {
59
+ args: {
60
+ children: 'Open in new tab',
61
+ href: '/internal-page',
62
+ variant: 'primary',
63
+ openInNewTab: true,
64
+ },
65
+ };
66
+
67
+ export const WithCustomStyling: Story = {
68
+ args: {
69
+ children: 'Custom styled button',
70
+ href: '/custom',
71
+ variant: 'primary',
72
+ className: 'px-8 py-4 text-xl',
73
+ },
74
+ };