@tpzdsp/next-toolkit 1.0.1 → 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 (54) 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/Button/Button.stories.tsx +4 -4
  5. package/src/components/Heading/Heading.tsx +34 -7
  6. package/src/components/Modal/Modal.stories.tsx +252 -0
  7. package/src/components/Modal/Modal.test.tsx +248 -0
  8. package/src/components/Modal/Modal.tsx +61 -0
  9. package/src/components/SlidingPanel/SlidingPanel.stories.tsx +31 -0
  10. package/src/components/SlidingPanel/SlidingPanel.test.tsx +86 -0
  11. package/src/components/SlidingPanel/SlidingPanel.tsx +133 -0
  12. package/src/components/accordion/Accordion.stories.tsx +235 -0
  13. package/src/components/accordion/Accordion.test.tsx +199 -0
  14. package/src/components/accordion/Accordion.tsx +47 -0
  15. package/src/components/divider/RuleDivider.stories.tsx +255 -0
  16. package/src/components/divider/RuleDivider.test.tsx +164 -0
  17. package/src/components/divider/RuleDivider.tsx +18 -0
  18. package/src/components/index.ts +11 -2
  19. package/src/components/layout/header/HeaderAuthClient.tsx +16 -8
  20. package/src/components/layout/header/HeaderNavClient.tsx +2 -2
  21. package/src/components/map/LayerSwitcherControl.ts +147 -0
  22. package/src/components/map/Map.tsx +230 -0
  23. package/src/components/map/MapContext.tsx +211 -0
  24. package/src/components/map/Popup.tsx +74 -0
  25. package/src/components/map/basemaps.ts +79 -0
  26. package/src/components/map/geocoder.ts +61 -0
  27. package/src/components/map/geometries.ts +60 -0
  28. package/src/components/map/images/basemaps/OS.png +0 -0
  29. package/src/components/map/images/basemaps/dark.png +0 -0
  30. package/src/components/map/images/basemaps/sat-map-tiler.png +0 -0
  31. package/src/components/map/images/basemaps/satellite-map-tiler.png +0 -0
  32. package/src/components/map/images/basemaps/satellite.png +0 -0
  33. package/src/components/map/images/basemaps/streets.png +0 -0
  34. package/src/components/map/images/openlayers-logo.png +0 -0
  35. package/src/components/map/index.ts +10 -0
  36. package/src/components/map/map.ts +40 -0
  37. package/src/components/map/osOpenNamesSearch.ts +54 -0
  38. package/src/components/map/projections.ts +14 -0
  39. package/src/components/select/Select.stories.tsx +336 -0
  40. package/src/components/select/Select.test.tsx +473 -0
  41. package/src/components/select/Select.tsx +132 -0
  42. package/src/components/select/SelectSkeleton.stories.tsx +195 -0
  43. package/src/components/select/SelectSkeleton.test.tsx +105 -0
  44. package/src/components/select/SelectSkeleton.tsx +16 -0
  45. package/src/components/select/common.ts +4 -0
  46. package/src/contexts/index.ts +0 -5
  47. package/src/hooks/index.ts +1 -0
  48. package/src/hooks/useClickOutside.test.ts +290 -0
  49. package/src/hooks/useClickOutside.ts +26 -0
  50. package/src/types.ts +51 -1
  51. package/src/utils/http.ts +143 -0
  52. package/src/utils/index.ts +1 -0
  53. package/src/components/link/NextLinkWrapper.tsx +0 -66
  54. package/src/contexts/ThemeContext.tsx +0 -72
@@ -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
+ });
@@ -0,0 +1,61 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ import { createPortal } from 'react-dom';
6
+ import { IoMdCloseCircle } from 'react-icons/io';
7
+
8
+ import { useClickOutside } from '../../hooks/useClickOutside';
9
+
10
+ type ModalProps = {
11
+ isOpen: boolean;
12
+ onClose: () => void;
13
+ children: React.ReactNode;
14
+ };
15
+
16
+ export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
17
+ const modalRoot = document.getElementById('modal-root');
18
+
19
+ const modalRef = useRef<HTMLDivElement>(null);
20
+
21
+ useClickOutside(modalRef, onClose);
22
+
23
+ useEffect(() => {
24
+ const handleEscape = (event: KeyboardEvent) => {
25
+ if (event.key === 'Escape') {
26
+ onClose();
27
+ }
28
+ };
29
+
30
+ document.addEventListener('keydown', handleEscape);
31
+
32
+ return () => document.removeEventListener('keydown', handleEscape);
33
+ }, [onClose]);
34
+
35
+ if (!modalRoot || !isOpen) {
36
+ return null;
37
+ }
38
+
39
+ return createPortal(
40
+ <div
41
+ aria-modal="true"
42
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
43
+ >
44
+ <div
45
+ ref={modalRef}
46
+ className="bg-white rounded-lg p-6 relative max-w-md w-full shadow-lg z-10"
47
+ tabIndex={-1}
48
+ >
49
+ <button
50
+ onClick={onClose}
51
+ className="text-static-xl absolute top-2 right-2 text-gray-600 hover:text-black"
52
+ aria-label="Close modal"
53
+ >
54
+ <IoMdCloseCircle size={20} />
55
+ </button>
56
+ {children}
57
+ </div>
58
+ </div>,
59
+ modalRoot,
60
+ );
61
+ };
@@ -0,0 +1,31 @@
1
+ /* eslint-disable storybook/no-renderer-packages */
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+
4
+ import { SlidingPanel } from './SlidingPanel';
5
+
6
+ export default {
7
+ children: 'SlidingPanel',
8
+ component: SlidingPanel,
9
+ } as Meta;
10
+
11
+ export const AllSlidingPanels: StoryObj<typeof SlidingPanel> = {
12
+ render: () => (
13
+ <div>
14
+ <div>
15
+ <SlidingPanel>Left</SlidingPanel>
16
+ </div>
17
+
18
+ <div>
19
+ <SlidingPanel position="center-right">Right</SlidingPanel>
20
+ </div>
21
+
22
+ <div>
23
+ <SlidingPanel position="center-top">Top</SlidingPanel>
24
+ </div>
25
+
26
+ <div>
27
+ <SlidingPanel position="center-bottom">Bottom</SlidingPanel>
28
+ </div>
29
+ </div>
30
+ ),
31
+ };
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import { act, render, screen, userEvent, waitFor } from '@tpzdsp/next-toolkit';
4
+
5
+ import { SlidingPanel } from './SlidingPanel';
6
+
7
+ const positions = ['center-left', 'center-right', 'center-top', 'center-bottom'] as const;
8
+
9
+ const getExpectedOpenClass = (pos: string) =>
10
+ pos.includes('left') || pos.includes('right') ? 'translate-x-0' : 'translate-y-0';
11
+
12
+ const hiddenPositionClasses = {
13
+ 'center-left': '-translate-x-full',
14
+ 'center-right': 'translate-x-full',
15
+ 'center-top': '-translate-y-[200%]',
16
+ 'center-bottom': 'translate-y-[200%]',
17
+ };
18
+
19
+ describe('SlidingPanel', () => {
20
+ it.each(positions)('should render closed panel by default at %s', (position) => {
21
+ render(
22
+ <SlidingPanel position={position} tabLabel="Open Test">
23
+ <p>Panel Content</p>
24
+ </SlidingPanel>,
25
+ );
26
+
27
+ expect(screen.getByRole('button', { name: 'Open Test' })).toBeVisible();
28
+
29
+ // Content should still be in the DOM but hidden
30
+ const panelContent = screen.queryByText('Panel Content')?.parentElement?.parentElement;
31
+
32
+ expect(panelContent).toBeInTheDocument();
33
+ expect(panelContent).toHaveClass(hiddenPositionClasses[position]);
34
+ });
35
+
36
+ it.each(positions)(
37
+ 'should render content and correct class after opening %s',
38
+ async (position) => {
39
+ const user = userEvent.setup();
40
+
41
+ render(
42
+ <SlidingPanel position={position} tabLabel="Open Me">
43
+ <div>My content</div>
44
+ </SlidingPanel>,
45
+ );
46
+
47
+ await user.click(screen.getByRole('button', { name: 'Open Me' }));
48
+
49
+ // Wait one animation frame for isVisible to be set
50
+ await act(() => new Promise(requestAnimationFrame));
51
+
52
+ const content = await screen.findByText('My content');
53
+
54
+ expect(content).toBeVisible();
55
+
56
+ // Go up from content div to panel wrapper
57
+ const wrapper = content.closest('div')!.parentElement!.parentElement!;
58
+
59
+ expect(wrapper.className).toContain(getExpectedOpenClass(position));
60
+ },
61
+ );
62
+
63
+ it.each(positions)('should close panel when "Close" is clicked at %s', async (position) => {
64
+ const user = userEvent.setup();
65
+
66
+ render(
67
+ <SlidingPanel position={position} tabLabel="Trigger" defaultOpen>
68
+ <p>Panel Content</p>
69
+ </SlidingPanel>,
70
+ );
71
+
72
+ const panelContent = screen.getByText('Panel Content');
73
+
74
+ expect(panelContent).toBeVisible();
75
+
76
+ await user.click(screen.getByRole('button', { name: 'Close' }));
77
+
78
+ await waitFor(() => {
79
+ expect(panelContent).toBeInTheDocument();
80
+ const insideDiv = panelContent?.parentElement?.parentElement;
81
+
82
+ expect(insideDiv).not.toBeNull();
83
+ expect(insideDiv).toHaveClass(hiddenPositionClasses[position]);
84
+ });
85
+ });
86
+ });
@@ -0,0 +1,133 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, type ReactNode, useRef, useMemo } from 'react';
4
+
5
+ type Position = 'center-left' | 'center-right' | 'center-top' | 'center-bottom';
6
+
7
+ export type SlidingPanelProps = {
8
+ children: ReactNode;
9
+ position?: Position;
10
+ tabLabel?: string;
11
+ defaultOpen?: boolean;
12
+ };
13
+
14
+ export const SlidingPanel = ({
15
+ children,
16
+ tabLabel = 'Open',
17
+ position = 'center-left',
18
+ defaultOpen = false,
19
+ }: SlidingPanelProps) => {
20
+ const [isVisible, setIsVisible] = useState(defaultOpen);
21
+ const [panelDimensions, setPanelDimensions] = useState({ width: 0, height: 0 });
22
+ const panelRef = useRef<HTMLDivElement>(null);
23
+
24
+ // Measure panel dimensions when visible
25
+ useEffect(() => {
26
+ if (isVisible && panelRef.current) {
27
+ const updateDimensions = () => {
28
+ if (panelRef.current) {
29
+ const rect = panelRef.current.getBoundingClientRect();
30
+
31
+ const newDimensions = { width: rect.width, height: rect.height };
32
+
33
+ if (
34
+ newDimensions.width !== panelDimensions.width ||
35
+ newDimensions.height !== panelDimensions.height
36
+ ) {
37
+ setPanelDimensions(newDimensions);
38
+ }
39
+ }
40
+ };
41
+
42
+ // Initial measurement
43
+ updateDimensions();
44
+
45
+ // Use ResizeObserver to detect changes
46
+ const resizeObserver = new ResizeObserver(updateDimensions);
47
+
48
+ resizeObserver.observe(panelRef.current);
49
+
50
+ return () => resizeObserver.disconnect();
51
+ }
52
+ }, [isVisible, panelDimensions.height, panelDimensions.width]);
53
+
54
+ const panelBase =
55
+ 'absolute bg-white shadow-lg p-4 flex flex-col transition-transform duration-300 ease-in-out overflow-auto z-30';
56
+
57
+ const panelLayout = {
58
+ 'center-left': `top-0 left-0 h-full w-[30%] lg:w-[35%] ${
59
+ isVisible ? 'translate-x-0' : '-translate-x-full'
60
+ }`,
61
+ 'center-right': `top-0 right-0 h-full w-[30%] lg:w-[35%] ${
62
+ isVisible ? 'translate-x-0' : 'translate-x-full'
63
+ }`,
64
+ // Changed: Use max-height and let content determine actual height
65
+ 'center-top': `top-0 left-0 w-full max-h-[80vh] ${
66
+ isVisible ? 'translate-y-0' : '-translate-y-[200%]'
67
+ }`,
68
+ 'center-bottom': `bottom-0 left-0 w-full max-h-[80vh] ${
69
+ isVisible ? 'translate-y-0' : 'translate-y-[200%]'
70
+ }`,
71
+ }[position];
72
+
73
+ const buttonPosition = {
74
+ 'center-left':
75
+ 'absolute top-1/2 -translate-y-1/2 transition-all duration-300 ease-in-out rounded-tr-md rounded-br-md [writing-mode:vertical-rl] px-1 py-3',
76
+ 'center-right':
77
+ 'absolute top-1/2 -translate-y-1/2 transition-all duration-300 ease-in-out rounded-tl-md rounded-bl-md [writing-mode:vertical-rl] px-1 py-3',
78
+ 'center-top':
79
+ 'absolute left-1/2 -translate-x-1/2 transition-all duration-300 ease-in-out rounded-bl-md rounded-br-md px-3 py-1',
80
+ 'center-bottom':
81
+ 'absolute left-1/2 -translate-x-1/2 transition-all duration-300 ease-in-out rounded-tl-md rounded-tr-md px-3 py-1',
82
+ }[position];
83
+
84
+ // Dynamic positioning using actual panel dimensions
85
+ const getButtonStyle = useMemo(() => {
86
+ if (position === 'center-left') {
87
+ return {
88
+ left: isVisible ? `${panelDimensions.width}px` : '0px',
89
+ };
90
+ }
91
+
92
+ if (position === 'center-right') {
93
+ return {
94
+ right: isVisible ? `${panelDimensions.width}px` : '0px',
95
+ };
96
+ }
97
+
98
+ if (position === 'center-top') {
99
+ return {
100
+ top: isVisible ? `${panelDimensions.height}px` : '0px',
101
+ };
102
+ }
103
+
104
+ if (position === 'center-bottom') {
105
+ return {
106
+ bottom: isVisible ? `${panelDimensions.height}px` : '0px',
107
+ };
108
+ }
109
+
110
+ return {};
111
+ }, [isVisible, panelDimensions.height, panelDimensions.width, position]);
112
+
113
+ return (
114
+ <div className="absolute inset-0 overflow-hidden pointer-events-none z-30">
115
+ <button
116
+ className={`pointer-events-auto ${buttonPosition} bg-gray-700 text-white z-40`}
117
+ style={getButtonStyle}
118
+ onClick={() => setIsVisible((prev) => !prev)}
119
+ >
120
+ {isVisible ? 'Close' : tabLabel}
121
+ </button>
122
+
123
+ <div
124
+ ref={panelRef}
125
+ className={`${panelBase} ${panelLayout} pointer-events-auto`}
126
+ aria-hidden={!isVisible}
127
+ inert={!isVisible}
128
+ >
129
+ <div className="mt-4">{children}</div>
130
+ </div>
131
+ </div>
132
+ );
133
+ };