@tpzdsp/next-toolkit 1.13.0 → 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.
Files changed (26) hide show
  1. package/package.json +13 -1
  2. package/src/components/ButtonLink/ButtonLink.stories.tsx +72 -0
  3. package/src/components/ButtonLink/ButtonLink.test.tsx +154 -0
  4. package/src/components/ButtonLink/ButtonLink.tsx +33 -0
  5. package/src/components/InfoBox/InfoBox.stories.tsx +31 -28
  6. package/src/components/InfoBox/InfoBox.test.tsx +8 -60
  7. package/src/components/InfoBox/InfoBox.tsx +60 -69
  8. package/src/components/LinkButton/LinkButton.stories.tsx +74 -0
  9. package/src/components/LinkButton/LinkButton.test.tsx +177 -0
  10. package/src/components/LinkButton/LinkButton.tsx +80 -0
  11. package/src/components/index.ts +5 -8
  12. package/src/components/link/ExternalLink.test.tsx +104 -0
  13. package/src/components/link/ExternalLink.tsx +1 -0
  14. package/src/map/MapComponent.tsx +7 -12
  15. package/src/components/InfoBox/hooks/index.ts +0 -3
  16. package/src/components/InfoBox/hooks/useInfoBoxPosition.test.ts +0 -187
  17. package/src/components/InfoBox/hooks/useInfoBoxPosition.ts +0 -69
  18. package/src/components/InfoBox/hooks/useInfoBoxState.test.ts +0 -168
  19. package/src/components/InfoBox/hooks/useInfoBoxState.ts +0 -71
  20. package/src/components/InfoBox/hooks/usePortalMount.test.ts +0 -62
  21. package/src/components/InfoBox/hooks/usePortalMount.ts +0 -15
  22. package/src/components/InfoBox/utils/focusTrapConfig.test.ts +0 -310
  23. package/src/components/InfoBox/utils/focusTrapConfig.ts +0 -59
  24. package/src/components/InfoBox/utils/index.ts +0 -2
  25. package/src/components/InfoBox/utils/positionUtils.test.ts +0 -170
  26. package/src/components/InfoBox/utils/positionUtils.ts +0 -89
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "A reusable React component library for Next.js applications",
5
5
  "engines": {
6
6
  "node": ">= 24.12.0",
@@ -57,6 +57,11 @@
57
57
  "import": "./src/components/select/index.ts",
58
58
  "require": "./src/components/select/index.ts"
59
59
  },
60
+ "./components/info-box": {
61
+ "types": "./src/components/InfoBox/InfoBox.tsx",
62
+ "import": "./src/components/InfoBox/InfoBox.tsx",
63
+ "require": "./src/components/InfoBox/InfoBox.tsx"
64
+ },
60
65
  "./http": {
61
66
  "types": "./src/http/index.ts",
62
67
  "import": "./src/http/index.ts",
@@ -200,6 +205,7 @@
200
205
  },
201
206
  "peerDependencies": {
202
207
  "@better-fetch/fetch": "^1.1.21",
208
+ "@floating-ui/react": "^0.27.17",
203
209
  "@tanstack/react-query": "^5.90.19",
204
210
  "@testing-library/react": "^16.0.0",
205
211
  "@testing-library/user-event": "^14.6.1",
@@ -227,6 +233,9 @@
227
233
  "@better-fetch/fetch": {
228
234
  "optional": true
229
235
  },
236
+ "@floating-ui/react": {
237
+ "optional": true
238
+ },
230
239
  "@turf/turf": {
231
240
  "optional": true
232
241
  },
@@ -272,5 +281,8 @@
272
281
  },
273
282
  "release": {
274
283
  "extends": "./release.config.js"
284
+ },
285
+ "dependencies": {
286
+ "@floating-ui/react": "^0.27.17"
275
287
  }
276
288
  }
@@ -0,0 +1,72 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import { ButtonLink } from './ButtonLink';
4
+
5
+ const meta = {
6
+ title: 'Components/ButtonLink',
7
+ component: ButtonLink,
8
+ parameters: {
9
+ layout: 'centered',
10
+ },
11
+ tags: ['autodocs'],
12
+ argTypes: {
13
+ type: {
14
+ control: 'select',
15
+ options: ['button', 'submit', 'reset'],
16
+ },
17
+ },
18
+ } satisfies Meta<typeof ButtonLink>;
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ export const Default: Story = {
24
+ args: {
25
+ children: 'Click me',
26
+ type: 'button',
27
+ },
28
+ };
29
+
30
+ export const WithClickHandler: Story = {
31
+ args: {
32
+ children: 'Show alert',
33
+ onClick: () => alert('Button clicked!'),
34
+ },
35
+ };
36
+
37
+ export const Submit: Story = {
38
+ args: {
39
+ children: 'Submit form',
40
+ type: 'submit',
41
+ },
42
+ render: (args) => (
43
+ <form
44
+ onSubmit={(e) => {
45
+ e.preventDefault();
46
+ alert('Form submitted!');
47
+ }}
48
+ >
49
+ <ButtonLink {...args} />
50
+ </form>
51
+ ),
52
+ };
53
+
54
+ export const Disabled: Story = {
55
+ args: {
56
+ children: 'Disabled button link',
57
+ disabled: true,
58
+ },
59
+ };
60
+
61
+ export const WithCustomStyling: Story = {
62
+ args: {
63
+ children: 'Custom styled',
64
+ className: 'font-bold text-xl',
65
+ },
66
+ };
67
+
68
+ export const LongText: Story = {
69
+ args: {
70
+ children: 'This is a longer button link that demonstrates how text wraps',
71
+ },
72
+ };
@@ -0,0 +1,154 @@
1
+ import { ButtonLink } from './ButtonLink';
2
+ import { render, screen, userEvent } from '../../test/renderers';
3
+
4
+ describe('ButtonLink', () => {
5
+ describe('rendering', () => {
6
+ it('should render with children', () => {
7
+ render(<ButtonLink>Click me</ButtonLink>);
8
+
9
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
10
+ });
11
+
12
+ it('should render as a button element', () => {
13
+ render(<ButtonLink>Button</ButtonLink>);
14
+
15
+ const button = screen.getByRole('button');
16
+
17
+ expect(button.tagName).toBe('BUTTON');
18
+ });
19
+
20
+ it('should have link styling classes', () => {
21
+ render(<ButtonLink>Button</ButtonLink>);
22
+
23
+ const button = screen.getByRole('button');
24
+
25
+ expect(button).toHaveClass('text-link', 'underline', 'cursor-pointer');
26
+ });
27
+
28
+ it('should merge custom className', () => {
29
+ render(<ButtonLink className="custom-class">Button</ButtonLink>);
30
+
31
+ const button = screen.getByRole('button');
32
+
33
+ expect(button).toHaveClass('custom-class');
34
+ expect(button).toHaveClass('text-link'); // Still has base classes
35
+ });
36
+ });
37
+
38
+ describe('button types', () => {
39
+ it('should default to type="button"', () => {
40
+ render(<ButtonLink>Button</ButtonLink>);
41
+
42
+ const button = screen.getByRole('button');
43
+
44
+ expect(button).toHaveAttribute('type', 'button');
45
+ });
46
+
47
+ it('should accept type="submit"', () => {
48
+ render(<ButtonLink type="submit">Submit</ButtonLink>);
49
+
50
+ const button = screen.getByRole('button');
51
+
52
+ expect(button).toHaveAttribute('type', 'submit');
53
+ });
54
+
55
+ it('should accept type="reset"', () => {
56
+ render(<ButtonLink type="reset">Reset</ButtonLink>);
57
+
58
+ const button = screen.getByRole('button');
59
+
60
+ expect(button).toHaveAttribute('type', 'reset');
61
+ });
62
+ });
63
+
64
+ describe('interactions', () => {
65
+ it('should handle click events', async () => {
66
+ const user = userEvent.setup();
67
+ const onClick = vi.fn();
68
+
69
+ render(<ButtonLink onClick={onClick}>Click me</ButtonLink>);
70
+
71
+ await user.click(screen.getByRole('button'));
72
+
73
+ expect(onClick).toHaveBeenCalledTimes(1);
74
+ });
75
+
76
+ it('should not trigger onClick when disabled', async () => {
77
+ const user = userEvent.setup();
78
+ const onClick = vi.fn();
79
+
80
+ render(
81
+ <ButtonLink onClick={onClick} disabled>
82
+ Disabled
83
+ </ButtonLink>,
84
+ );
85
+
86
+ await user.click(screen.getByRole('button'));
87
+
88
+ expect(onClick).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it('should submit form when type="submit"', () => {
92
+ const onSubmit = vi.fn((e) => e.preventDefault());
93
+
94
+ render(
95
+ <form onSubmit={onSubmit}>
96
+ <ButtonLink type="submit">Submit</ButtonLink>
97
+ </form>,
98
+ );
99
+
100
+ const button = screen.getByRole('button');
101
+
102
+ button.click();
103
+
104
+ expect(onSubmit).toHaveBeenCalled();
105
+ });
106
+ });
107
+
108
+ describe('disabled state', () => {
109
+ it('should have disabled attribute when disabled', () => {
110
+ render(<ButtonLink disabled>Disabled</ButtonLink>);
111
+
112
+ const button = screen.getByRole('button');
113
+
114
+ expect(button).toBeDisabled();
115
+ });
116
+
117
+ it('should have reduced opacity when disabled', () => {
118
+ render(<ButtonLink disabled>Disabled</ButtonLink>);
119
+
120
+ const button = screen.getByRole('button');
121
+
122
+ expect(button).toHaveClass('disabled:opacity-50');
123
+ });
124
+
125
+ it('should have not-allowed cursor when disabled', () => {
126
+ render(<ButtonLink disabled>Disabled</ButtonLink>);
127
+
128
+ const button = screen.getByRole('button');
129
+
130
+ expect(button).toHaveClass('disabled:cursor-not-allowed');
131
+ });
132
+ });
133
+
134
+ describe('accessibility', () => {
135
+ it('should have button role', () => {
136
+ render(<ButtonLink>Button</ButtonLink>);
137
+
138
+ expect(screen.getByRole('button')).toBeInTheDocument();
139
+ });
140
+
141
+ it('should accept aria attributes', () => {
142
+ render(
143
+ <ButtonLink aria-label="Custom label" aria-describedby="description">
144
+ Button
145
+ </ButtonLink>,
146
+ );
147
+
148
+ const button = screen.getByRole('button', { name: 'Custom label' });
149
+
150
+ expect(button).toBeInTheDocument();
151
+ expect(button).toHaveAttribute('aria-describedby', 'description');
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,33 @@
1
+ import type { ExtendProps } from '../../types/utils';
2
+ import { cn } from '../../utils';
3
+
4
+ type Props = {
5
+ type?: 'submit' | 'reset' | 'button';
6
+ children: React.ReactNode;
7
+ };
8
+
9
+ export type ButtonLinkProps = ExtendProps<'button', Props>;
10
+
11
+ /**
12
+ * A button that looks like a link.
13
+ * Use when you need button functionality (onClick, form submission)
14
+ * but want the visual appearance of a link.
15
+ */
16
+ export const ButtonLink = ({ type = 'button', className, children, ...props }: ButtonLinkProps) => {
17
+ return (
18
+ <button
19
+ type={type}
20
+ className={cn(
21
+ `cursor-pointer text-link hover:decoration-[max(3px,_.1875rem,_.12em)] hover:text-link-hover
22
+ visited:text-link-visited active:text-black focus:decoration-[max(3px,_.1875rem,_.12em)]
23
+ decoration-[max(1px,_.0625rem)] underline-offset-[0.1578em] underline outline-none
24
+ focus:text-focus-text focus:bg-focus inline-block bg-transparent border-0 p-0
25
+ disabled:opacity-50 disabled:cursor-not-allowed`,
26
+ className,
27
+ )}
28
+ {...props}
29
+ >
30
+ {children}
31
+ </button>
32
+ );
33
+ };
@@ -1,12 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
 
3
- import {
4
- InfoBox,
5
- POSITION_TOP_LEFT,
6
- POSITION_TOP_RIGHT,
7
- POSITION_BOTTOM_LEFT,
8
- POSITION_BOTTOM_RIGHT,
9
- } from './InfoBox';
3
+ import { InfoBox } from './InfoBox';
10
4
 
11
5
  const meta = {
12
6
  title: 'Components/InfoBox',
@@ -16,7 +10,7 @@ const meta = {
16
10
  docs: {
17
11
  description: {
18
12
  component:
19
- 'An accessible info box component that displays contextual information in a popover triggered by an info icon button. Auto-positions based on viewport proximity.',
13
+ 'An accessible info box component that displays contextual information in a popover triggered by an info icon button. Uses Floating UI for intelligent auto-positioning that adapts to viewport constraints.',
20
14
  },
21
15
  },
22
16
  },
@@ -39,16 +33,24 @@ const meta = {
39
33
  control: 'text',
40
34
  description: 'Accessible label for the trigger button',
41
35
  },
42
- position: {
36
+ placement: {
43
37
  control: 'select',
44
38
  options: [
45
- undefined,
46
- POSITION_TOP_LEFT,
47
- POSITION_TOP_RIGHT,
48
- POSITION_BOTTOM_LEFT,
49
- POSITION_BOTTOM_RIGHT,
39
+ 'top',
40
+ 'top-start',
41
+ 'top-end',
42
+ 'bottom',
43
+ 'bottom-start',
44
+ 'bottom-end',
45
+ 'left',
46
+ 'left-start',
47
+ 'left-end',
48
+ 'right',
49
+ 'right-start',
50
+ 'right-end',
50
51
  ],
51
- description: 'Force a specific position instead of auto-calculating',
52
+ description:
53
+ 'Preferred placement (Floating UI will auto-adjust if there is not enough space)',
52
54
  },
53
55
  children: {
54
56
  control: false,
@@ -163,7 +165,8 @@ export const AllPositions: Story = {
163
165
  layout: 'fullscreen',
164
166
  docs: {
165
167
  description: {
166
- story: 'Demonstrates all four forced position options for the info box.',
168
+ story:
169
+ 'Demonstrates various placement options using Floating UI. The content will automatically adjust if there is not enough space.',
167
170
  },
168
171
  },
169
172
  },
@@ -171,40 +174,40 @@ export const AllPositions: Story = {
171
174
  <div className="p-8 grid grid-cols-2 gap-8 h-screen">
172
175
  <div className="flex items-start justify-start">
173
176
  <div className="flex items-center gap-2">
174
- <span className="text-sm text-gray-600">bottom-right:</span>
177
+ <span className="text-sm text-gray-600">bottom-start:</span>
175
178
 
176
- <InfoBox title="Position Demo" position="bottom-right">
177
- <p>Opens to the bottom-right of the trigger.</p>
179
+ <InfoBox title="Position Demo" placement="bottom-start">
180
+ <p>Prefers bottom-start placement.</p>
178
181
  </InfoBox>
179
182
  </div>
180
183
  </div>
181
184
 
182
185
  <div className="flex items-start justify-end">
183
186
  <div className="flex items-center gap-2">
184
- <span className="text-sm text-gray-600">bottom-left:</span>
187
+ <span className="text-sm text-gray-600">bottom-end:</span>
185
188
 
186
- <InfoBox title="Position Demo" position="bottom-left">
187
- <p>Opens to the bottom-left of the trigger.</p>
189
+ <InfoBox title="Position Demo" placement="bottom-end">
190
+ <p>Prefers bottom-end placement.</p>
188
191
  </InfoBox>
189
192
  </div>
190
193
  </div>
191
194
 
192
195
  <div className="flex items-end justify-start">
193
196
  <div className="flex items-center gap-2">
194
- <span className="text-sm text-gray-600">top-right:</span>
197
+ <span className="text-sm text-gray-600">top-start:</span>
195
198
 
196
- <InfoBox title="Position Demo" position="top-right">
197
- <p>Opens to the top-right of the trigger.</p>
199
+ <InfoBox title="Position Demo" placement="top-start">
200
+ <p>Prefers top-start placement.</p>
198
201
  </InfoBox>
199
202
  </div>
200
203
  </div>
201
204
 
202
205
  <div className="flex items-end justify-end">
203
206
  <div className="flex items-center gap-2">
204
- <span className="text-sm text-gray-600">top-left:</span>
207
+ <span className="text-sm text-gray-600">top-end:</span>
205
208
 
206
- <InfoBox title="Position Demo" position="top-left">
207
- <p>Opens to the top-left of the trigger.</p>
209
+ <InfoBox title="Position Demo" placement="top-end">
210
+ <p>Prefers top-end placement.</p>
208
211
  </InfoBox>
209
212
  </div>
210
213
  </div>
@@ -1,17 +1,10 @@
1
- import { InfoBox, POSITION_TOP_LEFT, POSITION_BOTTOM_RIGHT } from './InfoBox';
1
+ import { InfoBox } from './InfoBox';
2
2
  import { render, screen, userEvent, waitFor } from '../../test/renderers';
3
3
 
4
4
  const TEST_CONTENT = 'Test info content';
5
5
  const TEST_TITLE = 'Test Title';
6
6
  const ARIA_EXPANDED = 'aria-expanded';
7
7
 
8
- // Mock focus-trap-react to simplify testing
9
- vi.mock('focus-trap-react', () => ({
10
- FocusTrap: ({ children }: { children: React.ReactNode }) => (
11
- <div data-testid="focus-trap">{children}</div>
12
- ),
13
- }));
14
-
15
8
  describe('InfoBox', () => {
16
9
  describe('rendering', () => {
17
10
  it('should render the trigger button with info icon', () => {
@@ -131,27 +124,6 @@ describe('InfoBox', () => {
131
124
  expect(button).toHaveAttribute(ARIA_EXPANDED, 'true');
132
125
  });
133
126
 
134
- it('should have aria-controls linking trigger to content', async () => {
135
- const user = userEvent.setup();
136
-
137
- render(
138
- <InfoBox>
139
- <p>{TEST_CONTENT}</p>
140
- </InfoBox>,
141
- );
142
-
143
- const button = screen.getByRole('button', { name: /show information/i });
144
- const controlsId = button.getAttribute('aria-controls');
145
-
146
- expect(controlsId).toBeTruthy();
147
-
148
- await user.click(button);
149
-
150
- const dialog = screen.getByRole('dialog');
151
-
152
- expect(dialog).toHaveAttribute('id', controlsId);
153
- });
154
-
155
127
  it('should have aria-haspopup="dialog" on trigger', () => {
156
128
  render(
157
129
  <InfoBox>
@@ -164,7 +136,7 @@ describe('InfoBox', () => {
164
136
  expect(button).toHaveAttribute('aria-haspopup', 'dialog');
165
137
  });
166
138
 
167
- it('should render as a dialog element', async () => {
139
+ it('should have dialog role', async () => {
168
140
  const user = userEvent.setup();
169
141
 
170
142
  render(
@@ -177,8 +149,8 @@ describe('InfoBox', () => {
177
149
 
178
150
  const dialog = screen.getByRole('dialog');
179
151
 
180
- // Native <dialog> element provides implicit dialog semantics
181
- expect(dialog.tagName).toBe('DIALOG');
152
+ // Floating UI uses div with role="dialog"
153
+ expect(dialog).toBeInTheDocument();
182
154
  });
183
155
 
184
156
  it('should have aria-labelledby when title is provided', async () => {
@@ -267,34 +239,11 @@ describe('InfoBox', () => {
267
239
  });
268
240
 
269
241
  describe('positioning', () => {
270
- beforeEach(() => {
271
- // Mock window dimensions
272
- Object.defineProperty(globalThis, 'innerWidth', { value: 1000, writable: true });
273
- Object.defineProperty(globalThis, 'innerHeight', { value: 800, writable: true });
274
- });
275
-
276
- it('should use forced position when position prop is provided', async () => {
277
- const user = userEvent.setup();
278
-
279
- render(
280
- <InfoBox position={POSITION_TOP_LEFT}>
281
- <p>{TEST_CONTENT}</p>
282
- </InfoBox>,
283
- );
284
-
285
- await user.click(screen.getByRole('button', { name: /show information/i }));
286
-
287
- const dialog = screen.getByRole('dialog');
288
-
289
- // top-left position applies both X and Y transforms
290
- expect(dialog).toHaveClass('-translate-x-full', '-translate-y-full');
291
- });
292
-
293
- it('should apply correct transform for bottom-right position', async () => {
242
+ it('should accept placement prop', async () => {
294
243
  const user = userEvent.setup();
295
244
 
296
245
  render(
297
- <InfoBox position={POSITION_BOTTOM_RIGHT}>
246
+ <InfoBox placement="top-start">
298
247
  <p>{TEST_CONTENT}</p>
299
248
  </InfoBox>,
300
249
  );
@@ -303,9 +252,8 @@ describe('InfoBox', () => {
303
252
 
304
253
  const dialog = screen.getByRole('dialog');
305
254
 
306
- // bottom-right has no transforms
307
- expect(dialog).not.toHaveClass('-translate-x-full');
308
- expect(dialog).not.toHaveClass('-translate-y-full');
255
+ // Floating UI handles positioning automatically
256
+ expect(dialog).toBeInTheDocument();
309
257
  });
310
258
  });
311
259