@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.
- package/package.json +21 -2
- package/src/assets/styles/globals.css +2 -0
- package/src/assets/styles/ol.css +122 -0
- package/src/components/Button/Button.stories.tsx +4 -4
- package/src/components/Heading/Heading.tsx +34 -7
- package/src/components/Modal/Modal.stories.tsx +252 -0
- package/src/components/Modal/Modal.test.tsx +248 -0
- package/src/components/Modal/Modal.tsx +61 -0
- package/src/components/SlidingPanel/SlidingPanel.stories.tsx +31 -0
- package/src/components/SlidingPanel/SlidingPanel.test.tsx +86 -0
- package/src/components/SlidingPanel/SlidingPanel.tsx +133 -0
- package/src/components/accordion/Accordion.stories.tsx +235 -0
- package/src/components/accordion/Accordion.test.tsx +199 -0
- package/src/components/accordion/Accordion.tsx +47 -0
- package/src/components/divider/RuleDivider.stories.tsx +255 -0
- package/src/components/divider/RuleDivider.test.tsx +164 -0
- package/src/components/divider/RuleDivider.tsx +18 -0
- package/src/components/index.ts +11 -2
- package/src/components/layout/header/HeaderAuthClient.tsx +16 -8
- package/src/components/layout/header/HeaderNavClient.tsx +2 -2
- package/src/components/map/LayerSwitcherControl.ts +147 -0
- package/src/components/map/Map.tsx +230 -0
- package/src/components/map/MapContext.tsx +211 -0
- package/src/components/map/Popup.tsx +74 -0
- package/src/components/map/basemaps.ts +79 -0
- package/src/components/map/geocoder.ts +61 -0
- package/src/components/map/geometries.ts +60 -0
- package/src/components/map/images/basemaps/OS.png +0 -0
- package/src/components/map/images/basemaps/dark.png +0 -0
- package/src/components/map/images/basemaps/sat-map-tiler.png +0 -0
- package/src/components/map/images/basemaps/satellite-map-tiler.png +0 -0
- package/src/components/map/images/basemaps/satellite.png +0 -0
- package/src/components/map/images/basemaps/streets.png +0 -0
- package/src/components/map/images/openlayers-logo.png +0 -0
- package/src/components/map/index.ts +10 -0
- package/src/components/map/map.ts +40 -0
- package/src/components/map/osOpenNamesSearch.ts +54 -0
- package/src/components/map/projections.ts +14 -0
- package/src/components/select/Select.stories.tsx +336 -0
- package/src/components/select/Select.test.tsx +473 -0
- package/src/components/select/Select.tsx +132 -0
- package/src/components/select/SelectSkeleton.stories.tsx +195 -0
- package/src/components/select/SelectSkeleton.test.tsx +105 -0
- package/src/components/select/SelectSkeleton.tsx +16 -0
- package/src/components/select/common.ts +4 -0
- package/src/contexts/index.ts +0 -5
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useClickOutside.test.ts +290 -0
- package/src/hooks/useClickOutside.ts +26 -0
- package/src/types.ts +51 -1
- package/src/utils/http.ts +143 -0
- package/src/utils/index.ts +1 -0
- package/src/components/link/NextLinkWrapper.tsx +0 -66
- 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
|
+
};
|