@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "A reusable React component library for Next.js applications",
5
5
  "type": "module",
6
6
  "private": false,
@@ -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 type="primary" onClick={action('primary-click')}>
17
+ <Button variant="primary" onClick={action('primary-click')}>
18
18
  Primary
19
19
  </Button>
20
20
  </div>
21
21
 
22
22
  <div>
23
- <Button type="secondary" onClick={action('secondary-click')}>
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 type="inverse" onClick={action('inverse-click')}>
29
+ <Button variant="inverse" onClick={action('inverse-click')}>
30
30
  Inverse
31
31
  </Button>
32
32
  </div>
33
33
 
34
34
  <div>
35
- <Button type="primary" disabled>
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 <h1 className="py-4 text-4xl font-bold text-text-primary">{children}</h1>;
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 <h2 className="py-3 text-3xl font-bold text-text-primary">{children}</h2>;
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 <h3 className="py-3 text-xl font-bold text-text-primary">{children}</h3>;
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 <h4 className="py-3 text-lg font-bold text-text-primary">{children}</h4>;
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 <h5 className="py-2 text-base font-bold text-text-primary">{children}</h5>;
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 <h6 className="py-2 text-sm font-bold text-text-primary">{children}</h6>;
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
+ };
@@ -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';