@tpzdsp/next-toolkit 1.0.1 → 1.1.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 +1 -1
- package/src/components/Button/Button.stories.tsx +4 -4
- package/src/components/Heading/Heading.tsx +34 -7
- 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/index.ts +2 -0
package/package.json
CHANGED
|
@@ -14,25 +14,25 @@ export const AllButtons: StoryObj<typeof Button> = {
|
|
|
14
14
|
render: () => (
|
|
15
15
|
<div className="flex flex-col gap-4">
|
|
16
16
|
<div>
|
|
17
|
-
<Button
|
|
17
|
+
<Button variant="primary" onClick={action('primary-click')}>
|
|
18
18
|
Primary
|
|
19
19
|
</Button>
|
|
20
20
|
</div>
|
|
21
21
|
|
|
22
22
|
<div>
|
|
23
|
-
<Button
|
|
23
|
+
<Button variant="secondary" onClick={action('secondary-click')}>
|
|
24
24
|
Secondary
|
|
25
25
|
</Button>
|
|
26
26
|
</div>
|
|
27
27
|
|
|
28
28
|
<div className="p-4 bg-brand">
|
|
29
|
-
<Button
|
|
29
|
+
<Button variant="inverse" onClick={action('inverse-click')}>
|
|
30
30
|
Inverse
|
|
31
31
|
</Button>
|
|
32
32
|
</div>
|
|
33
33
|
|
|
34
34
|
<div>
|
|
35
|
-
<Button
|
|
35
|
+
<Button variant="primary" disabled>
|
|
36
36
|
Disabled Button
|
|
37
37
|
</Button>
|
|
38
38
|
</div>
|
|
@@ -1,21 +1,48 @@
|
|
|
1
|
+
import { twMerge } from 'tailwind-merge';
|
|
2
|
+
|
|
1
3
|
export type HeadingProps = {
|
|
2
4
|
type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
5
|
+
className?: string;
|
|
3
6
|
children: React.ReactNode;
|
|
4
7
|
};
|
|
5
8
|
|
|
6
|
-
export const Heading = ({ type, children }: HeadingProps) => {
|
|
9
|
+
export const Heading = ({ type, className, children }: HeadingProps) => {
|
|
7
10
|
switch (type) {
|
|
8
11
|
case 'h1':
|
|
9
|
-
return
|
|
12
|
+
return (
|
|
13
|
+
<h1 className={twMerge('py-4 text-4xl font-bold text-text-primary', className)}>
|
|
14
|
+
{children}
|
|
15
|
+
</h1>
|
|
16
|
+
);
|
|
10
17
|
case 'h2':
|
|
11
|
-
return
|
|
18
|
+
return (
|
|
19
|
+
<h2 className={twMerge('py-3 text-3xl font-bold text-text-primary', className)}>
|
|
20
|
+
{children}
|
|
21
|
+
</h2>
|
|
22
|
+
);
|
|
12
23
|
case 'h3':
|
|
13
|
-
return
|
|
24
|
+
return (
|
|
25
|
+
<h3 className={twMerge('py-3 text-xl font-bold text-text-primary', className)}>
|
|
26
|
+
{children}
|
|
27
|
+
</h3>
|
|
28
|
+
);
|
|
14
29
|
case 'h4':
|
|
15
|
-
return
|
|
30
|
+
return (
|
|
31
|
+
<h4 className={twMerge('py-3 text-lg font-bold text-text-primary', className)}>
|
|
32
|
+
{children}
|
|
33
|
+
</h4>
|
|
34
|
+
);
|
|
16
35
|
case 'h5':
|
|
17
|
-
return
|
|
36
|
+
return (
|
|
37
|
+
<h5 className={twMerge('py-2 text-base font-bold text-text-primary', className)}>
|
|
38
|
+
{children}
|
|
39
|
+
</h5>
|
|
40
|
+
);
|
|
18
41
|
case 'h6':
|
|
19
|
-
return
|
|
42
|
+
return (
|
|
43
|
+
<h6 className={twMerge('py-2 text-sm font-bold text-text-primary', className)}>
|
|
44
|
+
{children}
|
|
45
|
+
</h6>
|
|
46
|
+
);
|
|
20
47
|
}
|
|
21
48
|
};
|
|
@@ -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
|
+
};
|
package/src/components/index.ts
CHANGED
|
@@ -22,11 +22,13 @@ export type { HintProps } from './Hint/Hint';
|
|
|
22
22
|
export type { ExternalLinkProps } from './link/ExternalLink';
|
|
23
23
|
export type { LinkProps } from './link/Link';
|
|
24
24
|
export type { ParagraphProps } from './Paragraph/Paragraph';
|
|
25
|
+
export type { SlidingPanelProps } from './SlidingPanel/SlidingPanel';
|
|
25
26
|
|
|
26
27
|
// Client components - these require 'use client' directive
|
|
27
28
|
export { NextLinkWrapper } from './link/NextLinkWrapper';
|
|
28
29
|
export { DropdownMenu } from './dropdown/DropdownMenu';
|
|
29
30
|
export { useDropdownMenu } from './dropdown/useDropdownMenu';
|
|
31
|
+
export { SlidingPanel } from './SlidingPanel/SlidingPanel';
|
|
30
32
|
|
|
31
33
|
// Export client component types
|
|
32
34
|
export type { NextLinkWrapperProps } from './link/NextLinkWrapper';
|