@tpzdsp/next-toolkit 1.1.0 → 1.2.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 (49) hide show
  1. package/package.json +21 -2
  2. package/src/assets/styles/globals.css +2 -0
  3. package/src/assets/styles/ol.css +122 -0
  4. package/src/components/Modal/Modal.stories.tsx +252 -0
  5. package/src/components/Modal/Modal.test.tsx +248 -0
  6. package/src/components/Modal/Modal.tsx +61 -0
  7. package/src/components/accordion/Accordion.stories.tsx +235 -0
  8. package/src/components/accordion/Accordion.test.tsx +199 -0
  9. package/src/components/accordion/Accordion.tsx +47 -0
  10. package/src/components/divider/RuleDivider.stories.tsx +255 -0
  11. package/src/components/divider/RuleDivider.test.tsx +164 -0
  12. package/src/components/divider/RuleDivider.tsx +18 -0
  13. package/src/components/index.ts +9 -2
  14. package/src/components/layout/header/HeaderAuthClient.tsx +16 -8
  15. package/src/components/layout/header/HeaderNavClient.tsx +2 -2
  16. package/src/components/map/LayerSwitcherControl.ts +147 -0
  17. package/src/components/map/Map.tsx +230 -0
  18. package/src/components/map/MapContext.tsx +211 -0
  19. package/src/components/map/Popup.tsx +74 -0
  20. package/src/components/map/basemaps.ts +79 -0
  21. package/src/components/map/geocoder.ts +61 -0
  22. package/src/components/map/geometries.ts +60 -0
  23. package/src/components/map/images/basemaps/OS.png +0 -0
  24. package/src/components/map/images/basemaps/dark.png +0 -0
  25. package/src/components/map/images/basemaps/sat-map-tiler.png +0 -0
  26. package/src/components/map/images/basemaps/satellite-map-tiler.png +0 -0
  27. package/src/components/map/images/basemaps/satellite.png +0 -0
  28. package/src/components/map/images/basemaps/streets.png +0 -0
  29. package/src/components/map/images/openlayers-logo.png +0 -0
  30. package/src/components/map/index.ts +10 -0
  31. package/src/components/map/map.ts +40 -0
  32. package/src/components/map/osOpenNamesSearch.ts +54 -0
  33. package/src/components/map/projections.ts +14 -0
  34. package/src/components/select/Select.stories.tsx +336 -0
  35. package/src/components/select/Select.test.tsx +473 -0
  36. package/src/components/select/Select.tsx +132 -0
  37. package/src/components/select/SelectSkeleton.stories.tsx +195 -0
  38. package/src/components/select/SelectSkeleton.test.tsx +105 -0
  39. package/src/components/select/SelectSkeleton.tsx +16 -0
  40. package/src/components/select/common.ts +4 -0
  41. package/src/contexts/index.ts +0 -5
  42. package/src/hooks/index.ts +1 -0
  43. package/src/hooks/useClickOutside.test.ts +290 -0
  44. package/src/hooks/useClickOutside.ts +26 -0
  45. package/src/types.ts +51 -1
  46. package/src/utils/http.ts +143 -0
  47. package/src/utils/index.ts +1 -0
  48. package/src/components/link/NextLinkWrapper.tsx +0 -66
  49. package/src/contexts/ThemeContext.tsx +0 -72
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "A reusable React component library for Next.js applications",
5
5
  "type": "module",
6
6
  "private": false,
@@ -71,8 +71,11 @@
71
71
  "@testing-library/react": "^16.3.0",
72
72
  "@testing-library/user-event": "^14.6.1",
73
73
  "@tpzdsp/eslint-config-dsp": "^1.9.0",
74
+ "@turf/turf": "^7.2.0",
75
+ "@types/geojson": "^7946.0.16",
74
76
  "@types/jsonwebtoken": "^9.0.10",
75
77
  "@types/node": "^24.0.15",
78
+ "@types/proj4": "^2.19.0",
76
79
  "@types/react": "^19.1.8",
77
80
  "@types/react-dom": "^19.1.6",
78
81
  "@typescript-eslint/eslint-plugin": "^8.38.0",
@@ -94,18 +97,25 @@
94
97
  "eslint-plugin-react-refresh": "^0.4.20",
95
98
  "eslint-plugin-sonarjs": "^3.0.4",
96
99
  "eslint-plugin-storybook": "^9.0.18",
100
+ "geojson": "^0.5.0",
97
101
  "globals": "^16.3.0",
98
102
  "husky": "^9.1.7",
99
103
  "jsdom": "^26.1.0",
100
104
  "jsonwebtoken": "^9.0.2",
101
105
  "next": "^15.4.2",
106
+ "ol": "^10.6.1",
107
+ "ol-geocoder": "^4.3.3",
108
+ "ol-mapbox-style": "^13.0.1",
102
109
  "postcss": "^8.5.6",
103
110
  "prettier": "^3.6.2",
104
111
  "prettier-plugin-classnames": "^0.8.1",
105
112
  "prettier-plugin-tailwindcss": "^0.6.14",
113
+ "proj4": "^2.19.10",
106
114
  "react": "^19.1.0",
107
115
  "react-dom": "^19.1.0",
108
116
  "react-icons": "^5.5.0",
117
+ "react-select": "^5.10.2",
118
+ "react-select-event": "^5.5.1",
109
119
  "rollup-plugin-peer-deps-external": "^2.2.4",
110
120
  "semantic-release": "^24.2.7",
111
121
  "storybook": "8.6.14",
@@ -120,12 +130,21 @@
120
130
  "peerDependencies": {
121
131
  "@testing-library/react": "^16.0.0",
122
132
  "@testing-library/user-event": "^14.6.1",
133
+ "@turf/turf": "^7.2.0",
134
+ "@types/geojson": "^7946.0.16",
123
135
  "@types/jsonwebtoken": "^9.0.10",
136
+ "@types/proj4": "^2.19.0",
137
+ "geojson": "^0.5.0",
124
138
  "jsonwebtoken": "^9.0.2",
125
139
  "next": "^15.4.2",
140
+ "ol": "^10.6.1",
141
+ "ol-geocoder": "^4.3.3",
142
+ "ol-mapbox-style": "^13.0.1",
143
+ "proj4": "^2.19.10",
126
144
  "react": "^19.1.0",
127
145
  "react-dom": "^19.1.0",
128
- "react-icons": "^5.5.0"
146
+ "react-icons": "^5.5.0",
147
+ "react-select": "^5.10.2"
129
148
  },
130
149
  "release": {
131
150
  "extends": "./release.config.js"
@@ -1,3 +1,5 @@
1
+ @import './ol.css';
2
+
1
3
  /* Global CSS for the component library */
2
4
  @tailwind base;
3
5
  @tailwind components;
@@ -0,0 +1,122 @@
1
+ @import 'ol/ol.css';
2
+ @import 'ol-geocoder/dist/ol-geocoder.min.css';
3
+
4
+ .ol-zoom {
5
+ left: unset;
6
+ right: 0.5rem;
7
+ }
8
+
9
+ .ol-layer-switcher {
10
+ top: 65px;
11
+ right: 0.5rem;
12
+ }
13
+
14
+ .ol-btn {
15
+ display: flex !important;
16
+ justify-content: center;
17
+ align-items: center;
18
+ }
19
+
20
+ .ol-layer-switcher-panel {
21
+ position: absolute;
22
+ top: 0;
23
+ right: -100%; /* Start off-screen */
24
+ min-width: fit-content;
25
+ max-width: 80vw;
26
+ background-color: #008938;
27
+ display: flex;
28
+ flex-direction: row;
29
+ align-items: center;
30
+ padding: 10px;
31
+ gap: 10px;
32
+ transition: right 0.3s ease-in-out;
33
+ overflow: hidden;
34
+ white-space: nowrap;
35
+ }
36
+
37
+ .ol-layer-switcher-panel.open {
38
+ right: 1.5rem; /* Slide into view */
39
+ }
40
+
41
+ .ol-layer-switcher-panel button {
42
+ display: flex;
43
+ flex-direction: column;
44
+ align-items: center;
45
+ justify-content: center;
46
+ border: 2px solid #ccc;
47
+ background-color: #fff;
48
+ min-width: 135px;
49
+ min-height: 135px;
50
+ cursor: pointer;
51
+ color: #000;
52
+ }
53
+
54
+ .ol-layer-switcher-panel button:hover {
55
+ background-color: #ddd;
56
+ }
57
+
58
+ .ol-layer-switcher-panel button.active {
59
+ background-color: #007bff; /* Bright blue background */
60
+ color: white; /* White text */
61
+ border-color: #0056b3; /* Darker border for contrast */
62
+ box-shadow: 0 4px 10px rgba(0, 123, 255, 0.3); /* Subtle shadow effect */
63
+ transform: scale(1.05); /* Slightly scale the active button */
64
+ transition: all 0.2s ease; /* Smooth transition for all styles */
65
+ }
66
+
67
+ .ol-layer-switcher-panel button.active img {
68
+ filter: brightness(1.2); /* Makes the image brighter */
69
+ }
70
+
71
+ .ol-layer-switcher-panel button:not(.active) img {
72
+ filter: grayscale(1); /* Apply grayscale to inactive images */
73
+ }
74
+
75
+ .ol-layer-switcher-panel button img {
76
+ max-width: 80px;
77
+ height: auto;
78
+ border: 2px solid #000;
79
+ border-radius: 0.3rem;
80
+ }
81
+
82
+ .ol-layer-switcher-panel button div {
83
+ font-size: 14px;
84
+ padding-top: 0.5rem;
85
+ }
86
+
87
+ .ol-geocoder .gcd-txt-control {
88
+ height: unset !important;
89
+ }
90
+
91
+ .ol-geocoder .gcd-txt-glass,
92
+ .ol-geocoder ul.gcd-txt-result {
93
+ top: unset !important;
94
+ }
95
+
96
+ .ol-geocoder ul.gcd-txt-result>li:nth-child(odd) {
97
+ background-color: #008938;
98
+ }
99
+
100
+ .ol-geocoder ul.gcd-txt-result>li:nth-child(even) {
101
+ background-color: #bddabd;
102
+ }
103
+
104
+ .ol-geocoder ul.gcd-txt-result>li>a:hover {
105
+ background-color: #fff;
106
+ }
107
+
108
+ /* Change the text color on hover */
109
+ .ol-geocoder ul.gcd-txt-result>li>a:hover .gcd-address,
110
+ .ol-geocoder ul.gcd-txt-result>li>a:hover .gcd-road,
111
+ .ol-geocoder ul.gcd-txt-result>li>a:hover .gcd-city,
112
+ .ol-geocoder ul.gcd-txt-result>li>a:hover .gcd-country {
113
+ color: #000;
114
+ }
115
+
116
+ /* Ensure the text color when not hovering */
117
+ .ol-geocoder ul.gcd-txt-result .gcd-address,
118
+ .ol-geocoder ul.gcd-txt-result .gcd-road,
119
+ .ol-geocoder ul.gcd-txt-result .gcd-city,
120
+ .ol-geocoder ul.gcd-txt-result .gcd-country {
121
+ color: #fff;
122
+ }
@@ -0,0 +1,252 @@
1
+ /* eslint-disable storybook/no-renderer-packages */
2
+ import { useEffect, useState } from 'react';
3
+
4
+ import type { Meta, StoryObj } from '@storybook/react';
5
+
6
+ import { Modal } from './Modal';
7
+
8
+ const MODAL_ROOT_ID = 'modal-root';
9
+
10
+ const meta: Meta<typeof Modal> = {
11
+ title: 'Components/Modal',
12
+ component: Modal,
13
+ parameters: {
14
+ layout: 'fullscreen',
15
+ docs: {
16
+ description: {
17
+ component: 'A modal dialog component that renders content in a portal overlay.',
18
+ },
19
+ },
20
+ },
21
+ tags: ['autodocs'],
22
+ argTypes: {
23
+ isOpen: {
24
+ control: 'boolean',
25
+ description: 'Whether the modal is open',
26
+ },
27
+ onClose: {
28
+ action: 'onClose',
29
+ description: 'Callback function called when the modal should close',
30
+ },
31
+ children: {
32
+ control: false,
33
+ description: 'Content to display inside the modal',
34
+ },
35
+ },
36
+ decorators: [
37
+ // eslint-disable-next-line @typescript-eslint/naming-convention
38
+ (Story) => {
39
+ useEffect(() => {
40
+ // Ensure modal-root exists
41
+ if (!document.getElementById(MODAL_ROOT_ID)) {
42
+ const modalRoot = document.createElement('div');
43
+
44
+ modalRoot.id = MODAL_ROOT_ID;
45
+ document.body.appendChild(modalRoot);
46
+ }
47
+
48
+ return () => {
49
+ // Clean up on unmount
50
+ const modalRoot = document.getElementById(MODAL_ROOT_ID);
51
+
52
+ if (modalRoot) {
53
+ document.body.removeChild(modalRoot);
54
+ }
55
+ };
56
+ }, []);
57
+
58
+ return (
59
+ <div>
60
+ <Story />
61
+ </div>
62
+ );
63
+ },
64
+ ],
65
+ };
66
+
67
+ export default meta;
68
+ type Story = StoryObj<typeof Modal>;
69
+
70
+ export const Default: Story = {
71
+ args: {
72
+ isOpen: true,
73
+ children: (
74
+ <div>
75
+ <h2 className="text-xl font-bold mb-4">Modal Title</h2>
76
+
77
+ <p className="text-gray-600 mb-4">
78
+ This is a basic modal with some content. You can close it by clicking the X button,
79
+ pressing Escape, or clicking outside the modal.
80
+ </p>
81
+
82
+ <div className="flex gap-2">
83
+ <button className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
84
+ Confirm
85
+ </button>
86
+
87
+ <button className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400">
88
+ Cancel
89
+ </button>
90
+ </div>
91
+ </div>
92
+ ),
93
+ },
94
+ };
95
+
96
+ export const Closed: Story = {
97
+ args: {
98
+ isOpen: false,
99
+ children: (
100
+ <div>
101
+ <h2 className="text-xl font-bold mb-4">You won&apos;t see this</h2>
102
+
103
+ <p>This modal is closed, so the content is not visible.</p>
104
+ </div>
105
+ ),
106
+ },
107
+ };
108
+
109
+ export const SimpleMessage: Story = {
110
+ args: {
111
+ isOpen: true,
112
+ children: (
113
+ <div className="text-center">
114
+ <h3 className="text-lg font-semibold mb-2">Success!</h3>
115
+
116
+ <p className="text-gray-600">Your action was completed successfully.</p>
117
+ </div>
118
+ ),
119
+ },
120
+ };
121
+
122
+ type ModalWrapperProps = {
123
+ children: React.ReactNode;
124
+ isOpen: boolean;
125
+ onClose: () => void;
126
+ };
127
+
128
+ const ModalWrapper = ({ children, isOpen, onClose }: ModalWrapperProps) => {
129
+ useEffect(() => {
130
+ if (!document.getElementById(MODAL_ROOT_ID)) {
131
+ const modalRoot = document.createElement('div');
132
+
133
+ modalRoot.id = MODAL_ROOT_ID;
134
+ document.body.appendChild(modalRoot);
135
+ }
136
+ }, []);
137
+
138
+ return (
139
+ <Modal isOpen={isOpen} onClose={onClose}>
140
+ {children}
141
+ </Modal>
142
+ );
143
+ };
144
+
145
+ export const WithWrapper: Story = {
146
+ render: (args) => (
147
+ <ModalWrapper {...args}>
148
+ <div>
149
+ <h2 className="text-xl font-bold mb-4">Modal with Wrapper</h2>
150
+
151
+ <p className="text-gray-600">This modal uses a wrapper to ensure modal-root exists.</p>
152
+ </div>
153
+ </ModalWrapper>
154
+ ),
155
+ args: {
156
+ isOpen: true,
157
+ },
158
+ };
159
+
160
+ export const Interactive: Story = {
161
+ render: () => {
162
+ const [isOpen, setIsOpen] = useState(false);
163
+ const [selectedModal, setSelectedModal] = useState<string | null>(null);
164
+
165
+ useEffect(() => {
166
+ if (!document.getElementById(MODAL_ROOT_ID)) {
167
+ const modalRoot = document.createElement('div');
168
+
169
+ modalRoot.id = MODAL_ROOT_ID;
170
+ document.body.appendChild(modalRoot);
171
+ }
172
+ }, []);
173
+
174
+ const openModal = (type: string) => {
175
+ setSelectedModal(type);
176
+ setIsOpen(true);
177
+ };
178
+
179
+ const closeModal = () => {
180
+ setIsOpen(false);
181
+ setSelectedModal(null);
182
+ };
183
+
184
+ const renderModalContent = () => {
185
+ switch (selectedModal) {
186
+ case 'info':
187
+ return (
188
+ <div>
189
+ <h3 className="text-lg font-semibold mb-2">Information</h3>
190
+
191
+ <p className="text-gray-600">This is an informational modal.</p>
192
+ </div>
193
+ );
194
+ case 'warning':
195
+ return (
196
+ <div>
197
+ <h3 className="text-lg font-semibold mb-2 text-yellow-600">Warning</h3>
198
+
199
+ <p className="text-gray-600">This action requires confirmation.</p>
200
+ </div>
201
+ );
202
+ case 'error':
203
+ return (
204
+ <div>
205
+ <h3 className="text-lg font-semibold mb-2 text-red-600">Error</h3>
206
+
207
+ <p className="text-gray-600">Something went wrong. Please try again.</p>
208
+ </div>
209
+ );
210
+ default:
211
+ return <p>Default modal content</p>;
212
+ }
213
+ };
214
+
215
+ return (
216
+ <div className="p-8">
217
+ <h2 className="text-xl font-bold mb-4">Interactive Modal Demo</h2>
218
+
219
+ <p className="text-gray-600 mb-6">
220
+ Click any button below to open different types of modals.
221
+ </p>
222
+
223
+ <div className="space-x-4">
224
+ <button
225
+ onClick={() => openModal('info')}
226
+ className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
227
+ >
228
+ Info Modal
229
+ </button>
230
+
231
+ <button
232
+ onClick={() => openModal('warning')}
233
+ className="px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
234
+ >
235
+ Warning Modal
236
+ </button>
237
+
238
+ <button
239
+ onClick={() => openModal('error')}
240
+ className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
241
+ >
242
+ Error Modal
243
+ </button>
244
+ </div>
245
+
246
+ <Modal isOpen={isOpen} onClose={closeModal}>
247
+ {renderModalContent()}
248
+ </Modal>
249
+ </div>
250
+ );
251
+ },
252
+ };
@@ -0,0 +1,248 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { Modal } from './Modal';
4
+ import { render, screen, userEvent } from '../../utils/renderers';
5
+
6
+ const MODAL_CONTENT = 'Modal content';
7
+ const MODAL_ROOT_ID = 'modal-root';
8
+ const BACKDROP_SELECTOR = '[aria-modal="true"]';
9
+
10
+ // Mock the useClickOutside hook
11
+ vi.mock('../../hooks/useClickOutside', () => ({
12
+ useClickOutside: vi.fn(),
13
+ }));
14
+
15
+ describe('Modal', () => {
16
+ // Setup modal root element for each test
17
+ beforeEach(() => {
18
+ const modalRoot = document.createElement('div');
19
+
20
+ modalRoot.id = MODAL_ROOT_ID;
21
+ document.body.appendChild(modalRoot);
22
+ });
23
+
24
+ afterEach(() => {
25
+ const modalRoot = document.getElementById(MODAL_ROOT_ID);
26
+
27
+ if (modalRoot) {
28
+ document.body.removeChild(modalRoot);
29
+ }
30
+ });
31
+
32
+ it('should render modal when isOpen is true', () => {
33
+ render(
34
+ <Modal isOpen={true} onClose={vi.fn()}>
35
+ <div>{MODAL_CONTENT}</div>
36
+ </Modal>,
37
+ );
38
+
39
+ expect(screen.getByText(MODAL_CONTENT)).toBeInTheDocument();
40
+
41
+ // Check for the backdrop with aria-modal attribute instead of role
42
+ const backdrop = screen.getByText(MODAL_CONTENT).closest(BACKDROP_SELECTOR);
43
+
44
+ expect(backdrop).toBeInTheDocument();
45
+ });
46
+
47
+ it('should not render modal when isOpen is false', () => {
48
+ render(
49
+ <Modal isOpen={false} onClose={vi.fn()}>
50
+ <div>{MODAL_CONTENT}</div>
51
+ </Modal>,
52
+ );
53
+
54
+ expect(screen.queryByText(MODAL_CONTENT)).not.toBeInTheDocument();
55
+ });
56
+
57
+ it('should not render modal when modal root is missing', () => {
58
+ // Remove modal root
59
+ const modalRoot = document.getElementById(MODAL_ROOT_ID);
60
+
61
+ if (modalRoot) {
62
+ document.body.removeChild(modalRoot);
63
+ }
64
+
65
+ render(
66
+ <Modal isOpen={true} onClose={vi.fn()}>
67
+ <div>{MODAL_CONTENT}</div>
68
+ </Modal>,
69
+ );
70
+
71
+ expect(screen.queryByText(MODAL_CONTENT)).not.toBeInTheDocument();
72
+ });
73
+
74
+ it('should have proper accessibility attributes', () => {
75
+ render(
76
+ <Modal isOpen={true} onClose={vi.fn()}>
77
+ <div>{MODAL_CONTENT}</div>
78
+ </Modal>,
79
+ );
80
+
81
+ const backdrop = screen.getByText(MODAL_CONTENT).closest(BACKDROP_SELECTOR);
82
+
83
+ expect(backdrop).toHaveAttribute('aria-modal', 'true');
84
+
85
+ const closeButton = screen.getByRole('button', { name: /close modal/i });
86
+
87
+ expect(closeButton).toHaveAttribute('aria-label', 'Close modal');
88
+ });
89
+
90
+ it('should call onClose when close button is clicked', async () => {
91
+ const user = userEvent.setup();
92
+ const onCloseMock = vi.fn();
93
+
94
+ render(
95
+ <Modal isOpen={true} onClose={onCloseMock}>
96
+ <div>{MODAL_CONTENT}</div>
97
+ </Modal>,
98
+ );
99
+
100
+ const closeButton = screen.getByRole('button', { name: /close modal/i });
101
+
102
+ await user.click(closeButton);
103
+
104
+ expect(onCloseMock).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it('should call onClose when Escape key is pressed', async () => {
108
+ const user = userEvent.setup();
109
+ const onCloseMock = vi.fn();
110
+
111
+ render(
112
+ <Modal isOpen={true} onClose={onCloseMock}>
113
+ <div>{MODAL_CONTENT}</div>
114
+ </Modal>,
115
+ );
116
+
117
+ await user.keyboard('{Escape}');
118
+
119
+ expect(onCloseMock).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it('should not call onClose when other keys are pressed', async () => {
123
+ const user = userEvent.setup();
124
+ const onCloseMock = vi.fn();
125
+
126
+ render(
127
+ <Modal isOpen={true} onClose={onCloseMock}>
128
+ <div>{MODAL_CONTENT}</div>
129
+ </Modal>,
130
+ );
131
+
132
+ await user.keyboard('{Enter}');
133
+ await user.keyboard('{Space}');
134
+ await user.keyboard('a');
135
+
136
+ expect(onCloseMock).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it('should render complex content correctly', () => {
140
+ render(
141
+ <Modal isOpen={true} onClose={vi.fn()}>
142
+ <div>
143
+ <h2>Modal Title</h2>
144
+
145
+ <p>Modal description</p>
146
+
147
+ <button>Action Button</button>
148
+
149
+ <ul>
150
+ <li>Item 1</li>
151
+
152
+ <li>Item 2</li>
153
+ </ul>
154
+ </div>
155
+ </Modal>,
156
+ );
157
+
158
+ expect(screen.getByText('Modal Title')).toBeInTheDocument();
159
+ expect(screen.getByText('Modal description')).toBeInTheDocument();
160
+ expect(screen.getByRole('button', { name: 'Action Button' })).toBeInTheDocument();
161
+ expect(screen.getByText('Item 1')).toBeInTheDocument();
162
+ expect(screen.getByText('Item 2')).toBeInTheDocument();
163
+ });
164
+
165
+ it('should have correct styling classes', () => {
166
+ render(
167
+ <Modal isOpen={true} onClose={vi.fn()}>
168
+ <div>{MODAL_CONTENT}</div>
169
+ </Modal>,
170
+ );
171
+
172
+ const backdrop = screen.getByText(MODAL_CONTENT).closest(BACKDROP_SELECTOR);
173
+
174
+ expect(backdrop).toHaveClass(
175
+ 'fixed',
176
+ 'inset-0',
177
+ 'z-50',
178
+ 'flex',
179
+ 'items-center',
180
+ 'justify-center',
181
+ 'bg-black',
182
+ 'bg-opacity-50',
183
+ );
184
+
185
+ const modalContent = backdrop?.firstChild as HTMLElement;
186
+
187
+ expect(modalContent).toHaveClass(
188
+ 'bg-white',
189
+ 'rounded-lg',
190
+ 'p-6',
191
+ 'relative',
192
+ 'max-w-md',
193
+ 'w-full',
194
+ 'shadow-lg',
195
+ 'z-10',
196
+ );
197
+ });
198
+
199
+ it('should render close icon correctly', () => {
200
+ render(
201
+ <Modal isOpen={true} onClose={vi.fn()}>
202
+ <div>{MODAL_CONTENT}</div>
203
+ </Modal>,
204
+ );
205
+
206
+ const closeButton = screen.getByRole('button', { name: /close modal/i });
207
+ const closeIcon = closeButton.querySelector('svg');
208
+
209
+ expect(closeIcon).toBeInTheDocument();
210
+ expect(closeButton).toHaveClass(
211
+ 'text-static-xl',
212
+ 'absolute',
213
+ 'top-2',
214
+ 'right-2',
215
+ 'text-gray-600',
216
+ 'hover:text-black',
217
+ );
218
+ });
219
+
220
+ it('should clean up event listeners when unmounted', () => {
221
+ const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
222
+ const onCloseMock = vi.fn();
223
+
224
+ const { unmount } = render(
225
+ <Modal isOpen={true} onClose={onCloseMock}>
226
+ <div>{MODAL_CONTENT}</div>
227
+ </Modal>,
228
+ );
229
+
230
+ unmount();
231
+
232
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
233
+
234
+ removeEventListenerSpy.mockRestore();
235
+ });
236
+
237
+ it('should have modal content with correct tabIndex', () => {
238
+ render(
239
+ <Modal isOpen={true} onClose={vi.fn()}>
240
+ <div>{MODAL_CONTENT}</div>
241
+ </Modal>,
242
+ );
243
+
244
+ const modalContent = screen.getByText(MODAL_CONTENT).closest('div[tabIndex="-1"]');
245
+
246
+ expect(modalContent).toHaveAttribute('tabIndex', '-1');
247
+ });
248
+ });