@tpzdsp/next-toolkit 1.2.9 → 1.3.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.
@@ -0,0 +1,131 @@
1
+ 'use client';
2
+
3
+ import { type KeyboardEvent, useCallback, useEffect, useState } from 'react';
4
+
5
+ import { LuArrowUp } from 'react-icons/lu';
6
+
7
+ export type BackToTopProps = {
8
+ /** Scroll threshold in pixels before button appears */
9
+ threshold?: number;
10
+ /** Position from bottom in pixels */
11
+ bottom?: number;
12
+ /** Position from left in pixels */
13
+ left?: number;
14
+ /** Custom className for styling */
15
+ className?: string;
16
+ };
17
+
18
+ export const BackToTop = ({
19
+ threshold = 600,
20
+ bottom = 16,
21
+ left = 8,
22
+ className = '',
23
+ }: BackToTopProps) => {
24
+ const [isVisible, setIsVisible] = useState(false);
25
+
26
+ // Throttle scroll events for better performance
27
+ const throttle = useCallback((func: () => void, delay: number) => {
28
+ let timeoutId: number | null = null;
29
+ let lastExecTime = 0;
30
+
31
+ return () => {
32
+ const currentTime = Date.now();
33
+
34
+ if (currentTime - lastExecTime > delay) {
35
+ func();
36
+ lastExecTime = currentTime;
37
+ } else {
38
+ if (timeoutId) {
39
+ clearTimeout(timeoutId);
40
+ }
41
+
42
+ timeoutId = window.setTimeout(
43
+ () => {
44
+ func();
45
+ lastExecTime = Date.now();
46
+ },
47
+ delay - (currentTime - lastExecTime),
48
+ );
49
+ }
50
+ };
51
+ }, []);
52
+
53
+ // Show button when page is scrolled down
54
+ const toggleVisibility = useCallback(() => {
55
+ if (typeof window !== 'undefined') {
56
+ setIsVisible(window.pageYOffset > threshold);
57
+ }
58
+ }, [threshold]);
59
+
60
+ // Scroll to top smoothly
61
+ const scrollToTop = useCallback(() => {
62
+ if (typeof window !== 'undefined') {
63
+ window.scrollTo({
64
+ top: 0,
65
+ behavior: 'smooth',
66
+ });
67
+ }
68
+ }, []);
69
+
70
+ useEffect(() => {
71
+ if (typeof window === 'undefined') {
72
+ return;
73
+ }
74
+
75
+ const throttledToggleVisibility = throttle(toggleVisibility, 100);
76
+
77
+ // Check initial scroll position
78
+ toggleVisibility();
79
+
80
+ window.addEventListener('scroll', throttledToggleVisibility, { passive: true });
81
+
82
+ return () => {
83
+ window.removeEventListener('scroll', throttledToggleVisibility);
84
+ };
85
+ }, [toggleVisibility, throttle]);
86
+
87
+ // Handle keyboard interaction
88
+ const handleKeyDown = useCallback(
89
+ (event: KeyboardEvent<HTMLButtonElement>) => {
90
+ if (event.key === 'Enter' || event.key === ' ') {
91
+ event.preventDefault();
92
+ scrollToTop();
93
+ }
94
+ },
95
+ [scrollToTop],
96
+ );
97
+
98
+ if (!isVisible) {
99
+ return null;
100
+ }
101
+
102
+ return (
103
+ <button
104
+ type="button"
105
+ className={`
106
+ fixed z-50 inline-flex items-center gap-1
107
+ bg-white text-link border border-gray-300
108
+ rounded-md px-3 py-2 shadow-lg
109
+ hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
110
+ transition-all duration-200 ease-in-out
111
+ ${className}
112
+ `.trim()}
113
+ // className={`fixed bottom-4 left-2 text-link gap-1 inline-flex items-center bg-white
114
+ // ${className}`.trim()}
115
+ style={{
116
+ bottom: `${bottom}px`,
117
+ left: `${left}px`,
118
+ }}
119
+ onClick={scrollToTop}
120
+ onKeyDown={handleKeyDown}
121
+ aria-label="Scroll back to top of page"
122
+ title="Back to top"
123
+ >
124
+ <LuArrowUp size={20} aria-hidden="true" />
125
+
126
+ <span className="hidden sm:inline">Back to top</span>
127
+
128
+ <span className="sr-only sm:hidden">Back to top</span>
129
+ </button>
130
+ );
131
+ };
@@ -0,0 +1,40 @@
1
+ import { AiFillChrome } from 'react-icons/ai';
2
+
3
+ /* eslint-disable storybook/no-renderer-packages */
4
+ import type { Meta, StoryFn } from '@storybook/react';
5
+
6
+ import { Chip, type ChipProps } from './Chip';
7
+ import { Paragraph } from '../Paragraph/Paragraph';
8
+
9
+ export default {
10
+ children: 'Chip',
11
+ component: Chip,
12
+ } as Meta;
13
+
14
+ const Template: StoryFn<ChipProps> = (args) => <Chip {...args} />;
15
+
16
+ export const Default = Template.bind({});
17
+ Default.args = {
18
+ children: 'Chip',
19
+ };
20
+
21
+ export const JustText = Template.bind({});
22
+ JustText.args = {
23
+ children: 'Hello, this is some simple text',
24
+ };
25
+
26
+ export const ParagraphOfText = Template.bind({});
27
+ ParagraphOfText.args = {
28
+ children: <Paragraph className="pb-0">Hello, this is a paragraph of text</Paragraph>,
29
+ };
30
+
31
+ export const TextWithIcon = Template.bind({});
32
+ TextWithIcon.args = {
33
+ children: (
34
+ <div className="flex items-center justify-center gap-2">
35
+ <AiFillChrome className="text-base" />
36
+
37
+ <Paragraph className="pb-0">Hello, this is a paragraph of text</Paragraph>
38
+ </div>
39
+ ),
40
+ };
@@ -0,0 +1,31 @@
1
+ import { Chip } from './Chip';
2
+ import { render, screen } from '../../test/renderers';
3
+
4
+ describe('Chip component', () => {
5
+ it('should render children correctly', () => {
6
+ render(
7
+ <Chip>
8
+ <p>Hello, World!</p>
9
+ </Chip>,
10
+ );
11
+
12
+ expect(screen.getByText('Hello, World!')).toBeInTheDocument();
13
+ });
14
+
15
+ it('should merge custom className correctly', () => {
16
+ render(<Chip className="bg-red-500 rounded-lg">Custom</Chip>);
17
+ const element = screen.getByText('Custom');
18
+
19
+ expect(element).toHaveClass('bg-red-500');
20
+ expect(element).toHaveClass('rounded-lg');
21
+ });
22
+
23
+ it('should override conflicting className using twMerge', () => {
24
+ render(<Chip className="px-2">Custom</Chip>);
25
+ const element = screen.getByText('Custom');
26
+
27
+ // Should NOT have original 'pt-[12px]' due to twMerge override
28
+ expect(element?.className).not.toMatch(/px-3/);
29
+ expect(element).toHaveClass('px-2');
30
+ });
31
+ });
@@ -0,0 +1,20 @@
1
+ import { twMerge } from 'tailwind-merge';
2
+
3
+ export type ChipProps = {
4
+ children: React.ReactNode;
5
+ className?: string;
6
+ };
7
+
8
+ export const Chip = ({ className, children }: ChipProps) => {
9
+ return (
10
+ <span
11
+ className={twMerge(
12
+ `inline-flex items-center rounded-lg bg-gray-200 px-3 py-1 text-sm font-medium
13
+ text-gray-800`,
14
+ className,
15
+ )}
16
+ >
17
+ {children}
18
+ </span>
19
+ );
20
+ };
@@ -0,0 +1,258 @@
1
+ /* eslint-disable storybook/no-renderer-packages */
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+
4
+ import { CookieBanner } from './CookieBanner';
5
+
6
+ const meta = {
7
+ title: 'Components/CookieBanner',
8
+ component: CookieBanner,
9
+ parameters: {
10
+ layout: 'fullscreen',
11
+ docs: {
12
+ description: {
13
+ component: 'Cookie banner component for displaying cookie consent options.',
14
+ },
15
+ },
16
+ },
17
+ tags: ['autodocs'],
18
+ } satisfies Meta<typeof CookieBanner>;
19
+
20
+ export default meta;
21
+
22
+ type Story = StoryObj<typeof meta>;
23
+
24
+ export const Default: Story = {
25
+ parameters: {
26
+ docs: {
27
+ description: {
28
+ story: 'Default cookie banner (hidden by default with inline style).',
29
+ },
30
+ },
31
+ },
32
+ };
33
+
34
+ export const Visible: Story = {
35
+ render: () => (
36
+ <div>
37
+ <style>
38
+ {`
39
+ #cookie-banner {
40
+ display: block !important;
41
+ }
42
+ `}
43
+ </style>
44
+
45
+ <CookieBanner />
46
+ </div>
47
+ ),
48
+ parameters: {
49
+ docs: {
50
+ description: {
51
+ story: 'Cookie banner with visibility forced on for demonstration purposes.',
52
+ },
53
+ },
54
+ },
55
+ };
56
+
57
+ export const VisibleWithBackground: Story = {
58
+ render: () => (
59
+ <div className="min-h-screen bg-gray-100">
60
+ <CookieBanner />
61
+
62
+ <div className="p-8">
63
+ <h1 className="text-2xl font-bold mb-4">Sample Page Content</h1>
64
+
65
+ <p className="mb-4">
66
+ This demonstrates how the cookie banner would appear on a real page. The banner is
67
+ positioned at the bottom of the screen.
68
+ </p>
69
+
70
+ <p className="mb-4">
71
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt
72
+ ut labore et dolore magna aliqua.
73
+ </p>
74
+ </div>
75
+ </div>
76
+ ),
77
+ parameters: {
78
+ docs: {
79
+ description: {
80
+ story:
81
+ 'Cookie banner shown in context with page content, positioned at the bottom of the screen.',
82
+ },
83
+ },
84
+ },
85
+ };
86
+
87
+ export const InteractiveDemo: Story = {
88
+ render: () => {
89
+ const handleAcceptAll = () => {
90
+ alert('All cookies accepted!');
91
+ // In a real implementation, this would hide the banner
92
+ const banner = document.getElementById('cookie-banner');
93
+
94
+ if (banner) {
95
+ banner.style.display = 'none';
96
+ }
97
+ };
98
+
99
+ const handleRejectAdditional = () => {
100
+ alert('Additional cookies rejected!');
101
+ // In a real implementation, this would hide the banner
102
+ const banner = document.getElementById('cookie-banner');
103
+
104
+ if (banner) {
105
+ banner.style.display = 'none';
106
+ }
107
+ };
108
+
109
+ return (
110
+ <div className="min-h-screen bg-gray-50">
111
+ <style>
112
+ {`
113
+ #cookie-banner {
114
+ display: block !important;
115
+ background-color: white;
116
+ border-top: 2px solid #1f2937;
117
+ position: fixed;
118
+ bottom: 0;
119
+ left: 0;
120
+ right: 0;
121
+ z-index: 1000;
122
+ box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
123
+ }
124
+ `}
125
+ </style>
126
+
127
+ <div className="p-8">
128
+ <h1 className="text-3xl font-bold mb-6">Interactive Cookie Banner Demo</h1>
129
+
130
+ <p className="mb-4 text-lg">
131
+ Click either button on the cookie banner below to see the interaction.
132
+ </p>
133
+
134
+ <p className="mb-4">
135
+ This story demonstrates the banner in a more realistic context with interactive
136
+ behavior.
137
+ </p>
138
+
139
+ <div className="space-y-4">
140
+ <p>Sample page content continues here...</p>
141
+
142
+ <p>More content to show page scrolling behavior...</p>
143
+
144
+ <p>The cookie banner remains fixed at the bottom.</p>
145
+ </div>
146
+ </div>
147
+
148
+ <div id="cookie-banner" role="region" aria-label="cookie banner">
149
+ <div className="mx-auto max-w-[960px] p-[1rem]">
150
+ <h3 className="mb-4 text-base font-bold text-govukBlack">
151
+ Tell us whether you accept cookies
152
+ </h3>
153
+
154
+ <p className="mb-4 text-sm text-black">
155
+ We use essential cookies to give you the best online experience. Without them, this
156
+ service will not work.
157
+ </p>
158
+
159
+ <p className="mb-4 text-sm text-black">
160
+ We also use non-essential cookies to analyze site usage to continually improve the
161
+ services we provide you with.
162
+ </p>
163
+
164
+ <p className="mb-4 text-sm text-black">
165
+ Full details of cookies collected, and the functionality to change your cookie
166
+ preference at any time can be accessed on our{' '}
167
+ <a
168
+ href="https://environment.data.gov.uk/help/cookies"
169
+ className="text-blue-600 underline hover:text-blue-800"
170
+ target="_blank"
171
+ rel="noopener noreferrer"
172
+ >
173
+ Cookie Policy Page
174
+ </a>
175
+ .
176
+ </p>
177
+
178
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-[1fr_1fr_1fr]">
179
+ <div>
180
+ <button
181
+ className="focus:outline-3 relative box-border inline-block w-full cursor-pointer
182
+ appearance-none rounded-none border-2 border-transparent bg-green-500 px-[10px]
183
+ py-[7px] text-center align-top text-base font-normal leading-[19px] text-white
184
+ antialiased shadow-[0_2px_0_#002413] focus:bg-green-600 hover:bg-green-600
185
+ focus:outline focus:outline-offset-0 focus:outline-yellow-500"
186
+ id="accept-all-cookies"
187
+ type="button"
188
+ onClick={handleAcceptAll}
189
+ >
190
+ Accept all cookies
191
+ </button>
192
+ </div>
193
+
194
+ <div>
195
+ <button
196
+ className="focus:outline-3 relative box-border inline-block w-full cursor-pointer
197
+ appearance-none rounded-none border-2 border-transparent bg-green-500 px-[10px]
198
+ py-[7px] text-center align-top text-base font-normal leading-[19px] text-white
199
+ antialiased shadow-[0_2px_0_#002413] focus:bg-green-600 hover:bg-green-600
200
+ focus:outline focus:outline-offset-0 focus:outline-yellow-500"
201
+ onClick={handleRejectAdditional}
202
+ >
203
+ Reject additional cookies
204
+ </button>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ );
211
+ },
212
+ parameters: {
213
+ docs: {
214
+ description: {
215
+ story: 'Interactive version with working buttons that demonstrate the expected behavior.',
216
+ },
217
+ },
218
+ },
219
+ };
220
+
221
+ export const MobileView: Story = {
222
+ render: () => (
223
+ <div className="max-w-sm mx-auto bg-gray-100 min-h-screen">
224
+ <style>
225
+ {`
226
+ #cookie-banner {
227
+ display: block !important;
228
+ background-color: white;
229
+ border-top: 1px solid #ccc;
230
+ position: fixed;
231
+ bottom: 0;
232
+ left: 0;
233
+ right: 0;
234
+ z-index: 1000;
235
+ }
236
+ `}
237
+ </style>
238
+
239
+ <div className="p-4">
240
+ <h1 className="text-xl font-bold mb-4">Mobile View</h1>
241
+
242
+ <p className="mb-4 text-sm">This shows how the cookie banner appears on mobile devices.</p>
243
+ </div>
244
+
245
+ <CookieBanner />
246
+ </div>
247
+ ),
248
+ parameters: {
249
+ docs: {
250
+ description: {
251
+ story: 'Cookie banner optimized for mobile viewport to test responsive behavior.',
252
+ },
253
+ },
254
+ viewport: {
255
+ defaultViewport: 'mobile1',
256
+ },
257
+ },
258
+ };
@@ -0,0 +1,68 @@
1
+ import { CookieBanner } from './CookieBanner';
2
+ import { render, screen, userEvent } from '../../test/renderers';
3
+
4
+ const handlePush = vi.fn();
5
+
6
+ vi.mock('next/navigation', () => ({
7
+ useRouter: () => ({
8
+ push: handlePush,
9
+ }),
10
+ }));
11
+
12
+ describe('CookieBanner', () => {
13
+ it('should render cookie banner content', () => {
14
+ render(<CookieBanner />);
15
+
16
+ expect(
17
+ screen.getByRole('heading', {
18
+ name: /tell us whether you accept cookies/i,
19
+ hidden: true,
20
+ }),
21
+ ).toBeInTheDocument();
22
+
23
+ expect(
24
+ screen.getByText(/we use essential cookies to give you the best online experience/i),
25
+ ).toBeInTheDocument();
26
+
27
+ expect(
28
+ screen.getByText(/we also use non-essential cookies to analyze site usage/i),
29
+ ).toBeInTheDocument();
30
+
31
+ expect(
32
+ screen.getByText(
33
+ /full details of cookies collected, and the functionality to change your cookie preference/i,
34
+ ),
35
+ ).toBeInTheDocument();
36
+
37
+ expect(screen.getByRole('link', { name: /cookie policy page/i, hidden: true })).toHaveAttribute(
38
+ 'href',
39
+ 'https://environment.data.gov.uk/help/cookies',
40
+ );
41
+
42
+ expect(
43
+ screen.getByRole('button', { name: /accept all cookies/i, hidden: true }),
44
+ ).toBeInTheDocument();
45
+
46
+ expect(
47
+ screen.getByRole('button', {
48
+ name: /reject additional cookies/i,
49
+ hidden: true,
50
+ }),
51
+ ).toBeInTheDocument();
52
+ });
53
+
54
+ it('should navigate to cookie preference page on "Reject additional cookies" click', async () => {
55
+ const user = userEvent.setup();
56
+
57
+ render(<CookieBanner />);
58
+
59
+ await user.click(
60
+ screen.getByRole('button', {
61
+ name: /reject additional cookies/i,
62
+ hidden: true,
63
+ }),
64
+ );
65
+
66
+ expect(handlePush).toHaveBeenCalled();
67
+ });
68
+ });
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+
3
+ import { useRouter } from 'next/navigation';
4
+ import Script from 'next/script';
5
+
6
+ import { ExternalLink } from '@tpzdsp/next-toolkit/components';
7
+
8
+ const helpCookieUrl = 'https://environment.data.gov.uk/help/cookies';
9
+
10
+ export const CookieBanner = () => {
11
+ const router = useRouter();
12
+
13
+ const setPreferences = () => {
14
+ router.push(helpCookieUrl);
15
+ };
16
+
17
+ return (
18
+ <div id="cookie-banner" role="region" aria-label="cookie banner" style={{ display: 'none' }}>
19
+ <Script src="https://environment.data.gov.uk/shared/cookie-banner.js"></Script>
20
+
21
+ <div className="mx-auto max-w-[960px] p-[1rem]">
22
+ <h3 className="mb-4 text-base font-bold text-govukBlack">
23
+ Tell us whether you accept cookies
24
+ </h3>
25
+
26
+ <p className="mb-4 text-sm text-black">
27
+ We use essential cookies to give you the best online experience. Without them, this
28
+ service will not work.
29
+ </p>
30
+
31
+ <p className="mb-4 text-sm text-black">
32
+ We also use non-essential cookies to analyze site usage to continually improve the
33
+ services we provide you with.
34
+ </p>
35
+
36
+ <p className="mb-4 text-sm text-black">
37
+ Full details of cookies collected, and the functionality to change your cookie preference
38
+ at any time can be accessed on our{' '}
39
+ <ExternalLink href={helpCookieUrl}>Cookie Policy Page</ExternalLink>.
40
+ </p>
41
+
42
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-[1fr_1fr_1fr]">
43
+ <div>
44
+ <button
45
+ className="focus:outline-3 relative box-border inline-block w-full cursor-pointer
46
+ appearance-none rounded-none border-2 border-transparent bg-green-500 px-[10px]
47
+ py-[7px] text-center align-top text-base font-normal leading-[19px] text-white
48
+ antialiased shadow-[0_2px_0_#002413] focus:bg-green-600 hover:bg-green-600
49
+ focus:outline focus:outline-offset-0 focus:outline-yellow-500"
50
+ id="accept-all-cookies"
51
+ type="submit"
52
+ >
53
+ Accept all cookies
54
+ </button>
55
+ </div>
56
+
57
+ <div>
58
+ <button
59
+ className="focus:outline-3 relative box-border inline-block w-full cursor-pointer
60
+ appearance-none rounded-none border-2 border-transparent bg-green-500 px-[10px]
61
+ py-[7px] text-center align-top text-base font-normal leading-[19px] text-white
62
+ antialiased shadow-[0_2px_0_#002413] focus:bg-green-600 hover:bg-green-600
63
+ focus:outline focus:outline-offset-0 focus:outline-yellow-500"
64
+ onClick={setPreferences}
65
+ >
66
+ Reject additional cookies
67
+ </button>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ );
73
+ };