@tpzdsp/next-toolkit 1.11.3 → 1.12.1

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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/assets/styles/globals.css +21 -0
  3. package/src/components/Button/Button.tsx +2 -3
  4. package/src/components/Card/Card.tsx +8 -5
  5. package/src/components/ErrorBoundary/ErrorFallback.tsx +15 -2
  6. package/src/components/ErrorText/ErrorText.tsx +2 -3
  7. package/src/components/Heading/Heading.tsx +8 -9
  8. package/src/components/Hint/Hint.tsx +2 -3
  9. package/src/components/Modal/Modal.tsx +13 -4
  10. package/src/components/NotificationBanner/NotificationBanner.stories.tsx +45 -0
  11. package/src/components/NotificationBanner/NotificationBanner.test.tsx +60 -0
  12. package/src/components/NotificationBanner/NotificationBanner.tsx +45 -0
  13. package/src/components/Paragraph/Paragraph.tsx +2 -3
  14. package/src/components/SlidingPanel/SlidingPanel.tsx +15 -2
  15. package/src/components/accordion/Accordion.tsx +19 -6
  16. package/src/components/backToTop/BackToTop.tsx +16 -15
  17. package/src/components/chip/Chip.tsx +8 -5
  18. package/src/components/container/Container.tsx +6 -2
  19. package/src/components/divider/RuleDivider.tsx +8 -3
  20. package/src/components/dropdown/DropdownMenu.tsx +9 -7
  21. package/src/components/form/Input.tsx +2 -3
  22. package/src/components/form/TextArea.tsx +2 -3
  23. package/src/components/index.ts +2 -0
  24. package/src/components/link/ExternalLink.tsx +2 -3
  25. package/src/components/link/Link.tsx +2 -2
  26. package/src/components/select/Select.tsx +14 -20
  27. package/src/components/select/SelectSkeleton.tsx +10 -7
  28. package/src/components/skipLink/SkipLink.tsx +13 -4
  29. package/src/http/logger.ts +1 -1
  30. package/src/map/geometries.ts +7 -1
  31. package/src/map/osOpenNamesSearch.ts +2 -2
  32. package/src/map/useKeyboardDrawing.ts +2 -2
  33. package/src/map/utils.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpzdsp/next-toolkit",
3
- "version": "1.11.3",
3
+ "version": "1.12.1",
4
4
  "description": "A reusable React component library for Next.js applications",
5
5
  "engines": {
6
6
  "node": ">= 24.12.0",
@@ -3,6 +3,27 @@
3
3
  @tailwind components;
4
4
  @tailwind utilities;
5
5
 
6
+ /**
7
+ * Accessibility: Respect user's motion preferences
8
+ *
9
+ * Many users experience discomfort or motion sickness from animations.
10
+ * This disables transitions and animations when the user has enabled
11
+ * "Reduce motion" in their operating system settings.
12
+ *
13
+ * Using 0.01ms instead of 0s ensures animation events still fire
14
+ * (which some JavaScript might depend on), but the effect is instant.
15
+ */
16
+ @media (prefers-reduced-motion: reduce) {
17
+ *,
18
+ *::before,
19
+ *::after {
20
+ animation-duration: 0.01ms !important;
21
+ animation-iteration-count: 1 !important;
22
+ transition-duration: 0.01ms !important;
23
+ scroll-behavior: auto !important;
24
+ }
25
+ }
26
+
6
27
  /* Custom font faces */
7
28
  @layer base {
8
29
  /* Add your custom font faces here */
@@ -1,6 +1,5 @@
1
- import { twMerge } from 'tailwind-merge';
2
-
3
1
  import type { ExtendProps } from '../../types/utils';
2
+ import { cn } from '../../utils';
4
3
 
5
4
  type Props = {
6
5
  type?: 'submit' | 'reset' | 'button';
@@ -27,7 +26,7 @@ export const Button = ({
27
26
  return (
28
27
  <button
29
28
  type={type}
30
- className={twMerge(
29
+ className={cn(
31
30
  `sm:w-auto text-lg relative flex w-full outline-none items-center border-transparent
32
31
  justify-center border-2 text-center active-enabled:translate-y-[2px] focus:shadow-focus
33
32
  focus:border-focus focus:shadow-[inset_0_0_0_1px] focus-idle:border-focus
@@ -1,17 +1,20 @@
1
- import { twMerge } from 'tailwind-merge';
1
+ import type { ExtendProps } from '../../types';
2
+ import { cn } from '../../utils';
2
3
 
3
- export type CardProps = {
4
- className?: string;
4
+ type Props = {
5
5
  children: React.ReactNode;
6
6
  };
7
7
 
8
- export const Card = ({ className, children }: CardProps) => {
8
+ export type CardProps = ExtendProps<'article', Props>;
9
+
10
+ export const Card = ({ className, children, ...props }: CardProps) => {
9
11
  return (
10
12
  <article
11
- className={twMerge(
13
+ className={cn(
12
14
  'h-full flex flex-col pt-[12px] px-[4px] mx-3 border-t border-slate-500',
13
15
  className,
14
16
  )}
17
+ {...props}
15
18
  >
16
19
  {children}
17
20
  </article>
@@ -5,12 +5,21 @@ import { useId } from 'react';
5
5
  import { type FallbackProps } from 'react-error-boundary';
6
6
 
7
7
  import { ApiError } from '../../errors/ApiError';
8
+ import type { ExtendProps } from '../../types';
9
+ import { cn } from '../../utils';
8
10
  import { Accordion } from '../accordion/Accordion';
9
11
  import { Button } from '../Button/Button';
10
12
  import { Heading } from '../Heading/Heading';
11
13
  import { Paragraph } from '../Paragraph/Paragraph';
12
14
 
13
- export const ErrorFallback = ({ resetErrorBoundary, error }: FallbackProps) => {
15
+ export type ErrorFallbackProps = ExtendProps<'div', FallbackProps>;
16
+
17
+ export const ErrorFallback = ({
18
+ resetErrorBoundary,
19
+ error,
20
+ className,
21
+ ...props
22
+ }: ErrorFallbackProps) => {
14
23
  const id = useId();
15
24
 
16
25
  let message;
@@ -39,8 +48,12 @@ export const ErrorFallback = ({ resetErrorBoundary, error }: FallbackProps) => {
39
48
  <div
40
49
  role="alert"
41
50
  aria-labelledby={id}
42
- className="grid gap-2 border-form border-transparent border-l-error max-w-full pl-2"
51
+ className={cn(
52
+ 'grid gap-2 border-form border-transparent border-l-error max-w-full pl-2',
53
+ className,
54
+ )}
43
55
  style={{ gridTemplateColumns: '1fr auto' }}
56
+ {...props}
44
57
  >
45
58
  <div className="flex flex-col gap-0.5">
46
59
  <Heading id={id} type="h3" className="text-error text-base font-semibold py-0">
@@ -1,6 +1,5 @@
1
- import { twMerge } from 'tailwind-merge';
2
-
3
1
  import type { ExtendProps } from '../../types/utils';
2
+ import { cn } from '../../utils';
4
3
 
5
4
  export type ErrorTextProps = ExtendProps<'p'>;
6
5
 
@@ -10,7 +9,7 @@ export const ErrorText = ({ className, children, ...props }: ErrorTextProps) =>
10
9
  return (
11
10
  <Component
12
11
  role="alert"
13
- className={twMerge('mb-3 text-base text-error font-bold', className)}
12
+ className={cn('mb-3 text-base text-error font-bold', className)}
14
13
  {...props}
15
14
  >
16
15
  {children}
@@ -1,6 +1,5 @@
1
- import { twMerge } from 'tailwind-merge';
2
-
3
1
  import type { ExtendProps } from '../../types';
2
+ import { cn } from '../../utils';
4
3
 
5
4
  type Props = {
6
5
  type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
@@ -9,41 +8,41 @@ type Props = {
9
8
  // h1 through h6 have identical props
10
9
  export type HeadingProps = ExtendProps<'h1', Props>;
11
10
 
12
- export const Heading = ({ type, className, children }: HeadingProps) => {
11
+ export const Heading = ({ type, className, children, ...props }: HeadingProps) => {
13
12
  switch (type) {
14
13
  case 'h1':
15
14
  return (
16
- <h1 className={twMerge('py-4 text-4xl font-bold text-text-primary', className)}>
15
+ <h1 className={cn('py-4 text-4xl font-bold text-text-primary', className)} {...props}>
17
16
  {children}
18
17
  </h1>
19
18
  );
20
19
  case 'h2':
21
20
  return (
22
- <h2 className={twMerge('py-3 text-3xl font-bold text-text-primary', className)}>
21
+ <h2 className={cn('py-3 text-3xl font-bold text-text-primary', className)} {...props}>
23
22
  {children}
24
23
  </h2>
25
24
  );
26
25
  case 'h3':
27
26
  return (
28
- <h3 className={twMerge('py-3 text-xl font-bold text-text-primary', className)}>
27
+ <h3 className={cn('py-3 text-xl font-bold text-text-primary', className)} {...props}>
29
28
  {children}
30
29
  </h3>
31
30
  );
32
31
  case 'h4':
33
32
  return (
34
- <h4 className={twMerge('py-3 text-lg font-bold text-text-primary', className)}>
33
+ <h4 className={cn('py-3 text-lg font-bold text-text-primary', className)} {...props}>
35
34
  {children}
36
35
  </h4>
37
36
  );
38
37
  case 'h5':
39
38
  return (
40
- <h5 className={twMerge('py-2 text-base font-bold text-text-primary', className)}>
39
+ <h5 className={cn('py-2 text-base font-bold text-text-primary', className)} {...props}>
41
40
  {children}
42
41
  </h5>
43
42
  );
44
43
  case 'h6':
45
44
  return (
46
- <h6 className={twMerge('py-2 text-sm font-bold text-text-primary', className)}>
45
+ <h6 className={cn('py-2 text-sm font-bold text-text-primary', className)} {...props}>
47
46
  {children}
48
47
  </h6>
49
48
  );
@@ -1,12 +1,11 @@
1
- import { twMerge } from 'tailwind-merge';
2
-
3
1
  import type { ExtendProps } from '../../types/utils';
2
+ import { cn } from '../../utils';
4
3
 
5
4
  export type HintProps = ExtendProps<'div'>;
6
5
 
7
6
  export const Hint = ({ className, children, ...props }: HintProps) => {
8
7
  return (
9
- <div className={twMerge('mb-2 text-lg text-text-secondary', className)} {...props}>
8
+ <div className={cn('mb-2 text-lg text-text-secondary', className)} {...props}>
10
9
  {children}
11
10
  </div>
12
11
  );
@@ -5,13 +5,18 @@ import { useCallback, useEffect, useRef } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { IoMdCloseCircle } from 'react-icons/io';
7
7
 
8
- type ModalProps = {
8
+ import type { ExtendProps } from '../../types';
9
+ import { cn } from '../../utils';
10
+
11
+ type Props = {
9
12
  isOpen: boolean;
10
13
  onClose: () => void;
11
14
  children: React.ReactNode;
12
15
  };
13
16
 
14
- export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
17
+ export type ModalProps = ExtendProps<'dialog', Props>;
18
+
19
+ export const Modal = ({ isOpen, onClose, children, className, ...props }: ModalProps) => {
15
20
  const modalRef = useRef<HTMLDialogElement>(null);
16
21
 
17
22
  const handleClose = useCallback(() => {
@@ -36,8 +41,11 @@ export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
36
41
  // dialog elements do have a key handler as you can close them with `Escape`, so this can be ignored
37
42
  // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
38
43
  <dialog
39
- className="fixed inset-0 flex items-center justify-center w-full h-full m-0 bg-transparent
40
- backdrop:bg-black/50"
44
+ className={cn(
45
+ `fixed inset-0 flex items-center justify-center w-full h-full m-0 bg-transparent
46
+ backdrop:bg-black/50`,
47
+ className,
48
+ )}
41
49
  ref={modalRef}
42
50
  onCancel={(event) => {
43
51
  event.preventDefault();
@@ -49,6 +57,7 @@ export const Modal = ({ isOpen, onClose, children }: ModalProps) => {
49
57
  // close the modal if the user clicks outside the main content (i.e. on the backdrop)
50
58
  handleClose();
51
59
  }}
60
+ {...props}
52
61
  >
53
62
  {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
54
63
  <div
@@ -0,0 +1,45 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+
3
+ import { NotificationBanner } from './NotificationBanner';
4
+
5
+ export default {
6
+ title: 'Components/NotificationBanner',
7
+ component: NotificationBanner,
8
+ parameters: {
9
+ docs: {
10
+ description: {
11
+ component:
12
+ 'A notification banner for displaying important messages. Follows the GOV.UK Design System notification banner pattern. Commonly used wrapped in a <noscript> tag to inform users when JavaScript is required.',
13
+ },
14
+ },
15
+ },
16
+ } as Meta<typeof NotificationBanner>;
17
+
18
+ export const Default: StoryObj<typeof NotificationBanner> = {
19
+ args: {
20
+ message: 'This is an important notification message for users.',
21
+ },
22
+ };
23
+
24
+ export const CustomTitle: StoryObj<typeof NotificationBanner> = {
25
+ args: {
26
+ title: 'Service Update',
27
+ message:
28
+ 'This service will be undergoing maintenance on Saturday 15th January from 9am to 5pm.',
29
+ },
30
+ };
31
+
32
+ export const JavaScriptRequired: StoryObj<typeof NotificationBanner> = {
33
+ args: {
34
+ title: 'Important: JavaScript is required',
35
+ message:
36
+ 'This application requires JavaScript to display the interactive map and data. Please enable JavaScript in your browser settings to use this service.',
37
+ },
38
+ parameters: {
39
+ docs: {
40
+ description: {
41
+ story: 'Example usage for a noscript fallback message.',
42
+ },
43
+ },
44
+ },
45
+ };
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { NotificationBanner } from './NotificationBanner';
4
+ import { render, screen } from '../../test/renderers';
5
+
6
+ describe('NotificationBanner Component', () => {
7
+ it('renders with default title and provided message', () => {
8
+ render(<NotificationBanner message="Test message content" />);
9
+
10
+ const heading = screen.getByRole('heading', { name: 'Important' });
11
+ const message = screen.getByText('Test message content');
12
+
13
+ expect(heading).toHaveAttribute('id', 'notification-banner-title');
14
+ expect(heading).toBeInTheDocument();
15
+ expect(message).toBeInTheDocument();
16
+ });
17
+
18
+ it('renders with custom title and message', () => {
19
+ render(<NotificationBanner title="Custom Title" message="Custom message content" />);
20
+
21
+ const heading = screen.getByRole('heading', { name: 'Custom Title' });
22
+ const message = screen.getByText('Custom message content');
23
+
24
+ expect(heading).toBeInTheDocument();
25
+ expect(message).toBeInTheDocument();
26
+ });
27
+
28
+ it('renders as a section with aria-labelledby', () => {
29
+ render(<NotificationBanner message="Test message" />);
30
+
31
+ const section = screen.getByRole('region', { name: 'Important' });
32
+
33
+ expect(section).toBeInTheDocument();
34
+ expect(section).toHaveAttribute('aria-labelledby', 'notification-banner-title');
35
+ });
36
+
37
+ it('applies default styling classes', () => {
38
+ render(<NotificationBanner message="Test message" />);
39
+
40
+ const section = screen.getByRole('region');
41
+
42
+ expect(section).toHaveClass('bg-brand', 'text-white', 'font-gds');
43
+ });
44
+
45
+ it('allows custom className to be merged', () => {
46
+ render(<NotificationBanner message="Test message" className="custom-class" />);
47
+
48
+ const section = screen.getByRole('region');
49
+
50
+ expect(section).toHaveClass('bg-brand', 'text-white', 'font-gds', 'custom-class');
51
+ });
52
+
53
+ it('spreads additional props to section element', () => {
54
+ render(<NotificationBanner message="Test message" data-testid="notification-banner" />);
55
+
56
+ const section = screen.getByTestId('notification-banner');
57
+
58
+ expect(section).toBeInTheDocument();
59
+ });
60
+ });
@@ -0,0 +1,45 @@
1
+ import type { ExtendProps } from '../../types';
2
+ import { cn } from '../../utils';
3
+ import { Heading } from '../Heading/Heading';
4
+ import { Paragraph } from '../Paragraph/Paragraph';
5
+
6
+ type Props = {
7
+ title?: string;
8
+ message: string;
9
+ /** Explicitly disallow children as we use title and message props instead */
10
+ children?: never;
11
+ };
12
+
13
+ export type NotificationBannerProps = ExtendProps<'section', Props>;
14
+
15
+ /**
16
+ * A notification banner component for displaying important messages.
17
+ * Follows the GOV.UK Design System notification banner pattern.
18
+ *
19
+ * Common use cases:
20
+ * - Wrapped in <noscript> to inform users that JavaScript is required
21
+ * - Service announcements or maintenance notices
22
+ * - Important information that needs prominent display
23
+ */
24
+ export const NotificationBanner = ({
25
+ title = 'Important',
26
+ message,
27
+ className,
28
+ ...props
29
+ }: NotificationBannerProps) => (
30
+ <section
31
+ aria-labelledby="notification-banner-title"
32
+ className={cn('bg-brand text-white font-gds', className)}
33
+ {...props}
34
+ >
35
+ <div className="px-4 py-2 border-b border-white/30">
36
+ <Heading type="h2" id="notification-banner-title" className="text-lg text-white py-2">
37
+ {title}
38
+ </Heading>
39
+ </div>
40
+
41
+ <div className="p-4">
42
+ <Paragraph className="text-lg text-white pb-0">{message}</Paragraph>
43
+ </div>
44
+ </section>
45
+ );
@@ -1,12 +1,11 @@
1
- import { twMerge } from 'tailwind-merge';
2
-
3
1
  import type { ExtendProps } from '../../types/utils';
2
+ import { cn } from '../../utils';
4
3
 
5
4
  export type ParagraphProps = ExtendProps<'p'>;
6
5
 
7
6
  export const Paragraph = ({ className, children, ...props }: ParagraphProps) => {
8
7
  return (
9
- <p className={twMerge('pb-4 text-sm text-text-primary', className)} {...props}>
8
+ <p className={cn('pb-4 text-sm text-text-primary', className)} {...props}>
10
9
  {children}
11
10
  </p>
12
11
  );
@@ -4,20 +4,27 @@ import { useState, useEffect, type ReactNode, useRef, useMemo, useId } from 'rea
4
4
 
5
5
  import { FocusTrap } from 'focus-trap-react';
6
6
 
7
+ import type { ExtendProps } from '../../types';
8
+ import { cn } from '../../utils';
9
+
7
10
  type Position = 'center-left' | 'center-right' | 'center-top' | 'center-bottom';
8
11
 
9
- export type SlidingPanelProps = {
12
+ type Props = {
10
13
  children: ReactNode;
11
14
  position?: Position;
12
15
  tabLabel?: string;
13
16
  defaultOpen?: boolean;
14
17
  };
15
18
 
19
+ export type SlidingPanelProps = ExtendProps<'div', Props>;
20
+
16
21
  export const SlidingPanel = ({
17
22
  children,
18
23
  tabLabel = 'Open',
19
24
  position = 'center-left',
20
25
  defaultOpen = false,
26
+ className,
27
+ ...props
21
28
  }: SlidingPanelProps) => {
22
29
  const id = useId();
23
30
 
@@ -145,7 +152,13 @@ export const SlidingPanel = ({
145
152
  };
146
153
 
147
154
  return (
148
- <div className="absolute inset-0 z-30 overflow-hidden pointer-events-none sliding-panel">
155
+ <div
156
+ className={cn(
157
+ 'absolute inset-0 z-30 overflow-hidden pointer-events-none sliding-panel',
158
+ className,
159
+ )}
160
+ {...props}
161
+ >
149
162
  <button
150
163
  className={`pointer-events-auto ${buttonPosition} bg-gray-700 text-white z-40 focus-yellow`}
151
164
  style={getButtonStyle}
@@ -3,22 +3,35 @@
3
3
  import { type ReactNode, useId, useState } from 'react';
4
4
 
5
5
  import { LuChevronDown } from 'react-icons/lu';
6
- import { twMerge } from 'tailwind-merge';
7
6
 
8
- export type AccordionProps = {
7
+ import type { ExtendProps } from '../../types';
8
+ import { cn } from '../../utils';
9
+
10
+ type Props = {
9
11
  title: string;
10
12
  children: ReactNode;
11
13
  defaultOpen?: boolean;
12
14
  };
13
15
 
14
- export const Accordion = ({ title, children, defaultOpen = false }: AccordionProps) => {
16
+ export type AccordionProps = ExtendProps<'div', Props>;
17
+
18
+ export const Accordion = ({
19
+ title,
20
+ children,
21
+ defaultOpen = false,
22
+ className,
23
+ ...props
24
+ }: AccordionProps) => {
15
25
  const contentId = useId();
16
26
  const buttonId = useId();
17
27
 
18
28
  const [isOpen, setIsOpen] = useState(defaultOpen);
19
29
 
20
30
  return (
21
- <div className="flex flex-col gap-2 border-l-2 border-neutral-100 rounded-md">
31
+ <div
32
+ className={cn('flex flex-col gap-2 border-l-2 border-neutral-100 rounded-md', className)}
33
+ {...props}
34
+ >
22
35
  <button
23
36
  aria-expanded={isOpen}
24
37
  aria-controls={contentId}
@@ -31,7 +44,7 @@ export const Accordion = ({ title, children, defaultOpen = false }: AccordionPro
31
44
  <span>{title}</span>
32
45
 
33
46
  <span aria-hidden="true">
34
- <LuChevronDown className={twMerge('w-4 h-4', isOpen ? 'rotate-180' : '')} />
47
+ <LuChevronDown className={cn('w-4 h-4', isOpen ? 'rotate-180' : '')} />
35
48
  </span>
36
49
  </button>
37
50
 
@@ -39,7 +52,7 @@ export const Accordion = ({ title, children, defaultOpen = false }: AccordionPro
39
52
  id={contentId}
40
53
  aria-labelledby={buttonId}
41
54
  aria-hidden={!isOpen}
42
- className={twMerge('px-2 pb-1', isOpen ? 'block' : 'hidden')}
55
+ className={cn('px-2 pb-1', isOpen ? 'block' : 'hidden')}
43
56
  >
44
57
  {children}
45
58
  </section>
@@ -4,24 +4,28 @@ import { type KeyboardEvent, useCallback, useEffect, useState } from 'react';
4
4
 
5
5
  import { LuArrowUp } from 'react-icons/lu';
6
6
 
7
- import { KeyboardKeys } from '../../utils';
7
+ import type { ExtendProps } from '../../types';
8
+ import { cn, KeyboardKeys } from '../../utils';
8
9
 
9
- export type BackToTopProps = {
10
+ type Props = {
10
11
  /** Scroll threshold in pixels before button appears */
11
12
  threshold?: number;
12
13
  /** Position from bottom in pixels */
13
14
  bottom?: number;
14
15
  /** Position from left in pixels */
15
16
  left?: number;
16
- /** Custom className for styling */
17
- className?: string;
17
+ /** Explicitly disallow children as we hard-code the button content */
18
+ children?: never;
18
19
  };
19
20
 
21
+ export type BackToTopProps = ExtendProps<'button', Props>;
22
+
20
23
  export const BackToTop = ({
21
24
  threshold = 600,
22
25
  bottom = 16,
23
26
  left = 8,
24
- className = '',
27
+ className,
28
+ ...props
25
29
  }: BackToTopProps) => {
26
30
  const [isVisible, setIsVisible] = useState(false);
27
31
 
@@ -104,16 +108,12 @@ export const BackToTop = ({
104
108
  return (
105
109
  <button
106
110
  type="button"
107
- className={`
108
- fixed z-50 inline-flex items-center gap-1
109
- bg-white text-link border border-gray-300
110
- rounded-md px-3 py-2 shadow-lg
111
- hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
112
- transition-all duration-200 ease-in-out
113
- ${className}
114
- `.trim()}
115
- // className={`fixed bottom-4 left-2 text-link gap-1 inline-flex items-center bg-white
116
- // ${className}`.trim()}
111
+ className={cn(
112
+ `fixed z-50 inline-flex items-center gap-1 bg-white text-link border border-gray-300
113
+ rounded-md px-3 py-2 shadow-lg hover:bg-gray-50 focus:outline-none focus:ring-2
114
+ focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200 ease-in-out`,
115
+ className,
116
+ )}
117
117
  style={{
118
118
  bottom: `${bottom}px`,
119
119
  left: `${left}px`,
@@ -122,6 +122,7 @@ export const BackToTop = ({
122
122
  onKeyDown={handleKeyDown}
123
123
  aria-label="Scroll back to top of page"
124
124
  title="Back to top"
125
+ {...props}
125
126
  >
126
127
  <LuArrowUp size={20} aria-hidden="true" />
127
128
 
@@ -1,18 +1,21 @@
1
- import { twMerge } from 'tailwind-merge';
1
+ import type { ExtendProps } from '../../types';
2
+ import { cn } from '../../utils';
2
3
 
3
- export type ChipProps = {
4
+ type Props = {
4
5
  children: React.ReactNode;
5
- className?: string;
6
6
  };
7
7
 
8
- export const Chip = ({ className, children }: ChipProps) => {
8
+ export type ChipProps = ExtendProps<'span', Props>;
9
+
10
+ export const Chip = ({ className, children, ...props }: ChipProps) => {
9
11
  return (
10
12
  <span
11
- className={twMerge(
13
+ className={cn(
12
14
  `inline-flex items-center rounded-lg bg-gray-200 px-3 py-1 text-sm font-medium
13
15
  text-gray-800`,
14
16
  className,
15
17
  )}
18
+ {...props}
16
19
  >
17
20
  {children}
18
21
  </span>
@@ -1,14 +1,16 @@
1
1
  import type { ReactNode } from 'react';
2
2
 
3
+ import type { ExtendProps } from '../../types';
3
4
  import { cn } from '../../utils';
4
5
 
5
- export type ContainerProps = {
6
+ type Props = {
6
7
  children: ReactNode;
7
- className?: string;
8
8
  size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
9
9
  centerContent?: boolean;
10
10
  };
11
11
 
12
+ export type ContainerProps = ExtendProps<'div', Props>;
13
+
12
14
  const containerSizes = {
13
15
  sm: 'max-w-3xl',
14
16
  md: 'max-w-5xl',
@@ -22,6 +24,7 @@ export const Container = ({
22
24
  className,
23
25
  size = 'lg',
24
26
  centerContent = false,
27
+ ...props
25
28
  }: ContainerProps) => {
26
29
  return (
27
30
  <div
@@ -31,6 +34,7 @@ export const Container = ({
31
34
  centerContent && 'flex items-center justify-center min-h-screen',
32
35
  className,
33
36
  )}
37
+ {...props}
34
38
  >
35
39
  {children}
36
40
  </div>
@@ -1,10 +1,15 @@
1
- type RuleDividerProps = {
1
+ import type { ExtendProps } from '../../types';
2
+ import { cn } from '../../utils';
3
+
4
+ type Props = {
2
5
  children?: React.ReactNode;
3
6
  };
4
7
 
5
- export const RuleDivider = ({ children }: RuleDividerProps) => {
8
+ export type RuleDividerProps = ExtendProps<'div', Props>;
9
+
10
+ export const RuleDivider = ({ children, className, ...props }: RuleDividerProps) => {
6
11
  return (
7
- <div className="flex items-center">
12
+ <div className={cn('flex items-center', className)} {...props}>
8
13
  <hr className="flex-grow" />
9
14
  {children ? (
10
15
  <>
@@ -1,15 +1,15 @@
1
1
  'use client';
2
2
 
3
- import type { ComponentType } from 'react';
3
+ import type { ComponentType, HTMLAttributes } from 'react';
4
4
 
5
5
  import { LuChevronDown } from 'react-icons/lu';
6
- import { twMerge } from 'tailwind-merge';
7
6
 
8
7
  import {
9
8
  type ButtonRendererProps,
10
9
  type ItemRendererProps,
11
10
  useDropdownMenu,
12
11
  } from './useDropdownMenu';
12
+ import { cn } from '../../utils';
13
13
 
14
14
  export type DropdownMenuItem<ItemProps extends object> = ItemRendererProps & {
15
15
  label: string;
@@ -17,7 +17,7 @@ export type DropdownMenuItem<ItemProps extends object> = ItemRendererProps & {
17
17
 
18
18
  export type DrowndownMenuButton = ButtonRendererProps;
19
19
 
20
- type DropdownMenuProps<ItemProps extends object> = {
20
+ type DropdownMenuProps<ItemProps extends object> = HTMLAttributes<HTMLDivElement> & {
21
21
  items: DropdownMenuItem<ItemProps>[];
22
22
  containerClassName?: string;
23
23
  menuContainerClassName?: string;
@@ -32,7 +32,7 @@ const DefaultButton = ({ state: { isOpen }, ...props }: ButtonRendererProps) =>
32
32
  <button
33
33
  {...props}
34
34
  aria-label="Open Menu"
35
- className={twMerge(
35
+ className={cn(
36
36
  `text-black flex gap-2 items-center justify-center border rounded-md border-gray-300
37
37
  bg-white px-2 py-1 text-sm shadow-sm focus:border-brand focus:outline-none focus:ring-1
38
38
  focus:ring-brand`,
@@ -51,7 +51,7 @@ const DefaultItem = <Item extends object>({ label, ...props }: DropdownMenuItem<
51
51
  <div
52
52
  {...props}
53
53
  aria-label={label}
54
- className={twMerge(
54
+ className={cn(
55
55
  `text-black cursor-pointer hover:bg-slate-200 focus:bg-slate-200 active:bg-slate-300 px-2
56
56
  py-1`,
57
57
  props.className,
@@ -70,6 +70,8 @@ export const DropdownMenu = <Item extends object>({
70
70
  buttonClassName,
71
71
  itemRenderer = DefaultItem,
72
72
  itemClassName,
73
+ className,
74
+ ...props
73
75
  }: DropdownMenuProps<Item>) => {
74
76
  // rebind to avoid linting errors
75
77
  const ButtonRenderer = buttonRenderer;
@@ -78,13 +80,13 @@ export const DropdownMenu = <Item extends object>({
78
80
  const { isOpen, buttonProps, itemProps } = useDropdownMenu(items.length);
79
81
 
80
82
  return (
81
- <div className={twMerge('relative', containerClassName)}>
83
+ <div className={cn('relative', containerClassName, className)} {...props}>
82
84
  <ButtonRenderer {...buttonProps} className={buttonClassName} />
83
85
 
84
86
  <div
85
87
  style={{ display: isOpen ? 'flex' : 'none' }}
86
88
  aria-hidden={!isOpen}
87
- className={twMerge(
89
+ className={cn(
88
90
  `absolute right-0 mt-1 bg-white border shadow-md rounded-md min-w-full flex-col gap-0
89
91
  z-[999] divide-y divide-slate-400`,
90
92
  menuContainerClassName,
@@ -1,8 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { twMerge } from 'tailwind-merge';
4
-
5
3
  import type { ExtendProps } from '../../types/utils';
4
+ import { cn } from '../../utils';
6
5
 
7
6
  type Props = {
8
7
  hasError?: boolean;
@@ -14,7 +13,7 @@ export const Input = ({ hasError, className, ...props }: InputProps) => {
14
13
  return (
15
14
  <input
16
15
  {...props}
17
- className={twMerge(
16
+ className={cn(
18
17
  'rounded-md border p-1 disabled:opacity-60 disabled:bg-gray-100',
19
18
  hasError ? 'border-error' : '',
20
19
  className,
@@ -1,8 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { twMerge } from 'tailwind-merge';
4
-
5
3
  import type { ExtendProps } from '../../types/utils';
4
+ import { cn } from '../../utils';
6
5
 
7
6
  type Props = {
8
7
  hasError?: boolean;
@@ -14,7 +13,7 @@ export const TextArea = ({ hasError, className, ...props }: TextAreaProps) => {
14
13
  return (
15
14
  <textarea
16
15
  {...props}
17
- className={twMerge(
16
+ className={cn(
18
17
  'rounded-md border p-1 disabled:opacity-60 disabled:bg-gray-100',
19
18
  hasError ? 'border-error' : '',
20
19
  className,
@@ -10,6 +10,7 @@ export { GlobalVars } from './googleAnalytics/GlobalVars';
10
10
  export { GoogleAnalytics } from './googleAnalytics/GoogleAnalytics';
11
11
  export { Heading } from './Heading/Heading';
12
12
  export { Hint } from './Hint/Hint';
13
+ export { NotificationBanner } from './NotificationBanner/NotificationBanner';
13
14
  export { DefraLogo } from './images/DefraLogo';
14
15
  export { EaLogo } from './images/EaLogo';
15
16
  export { OglLogo } from './images/OglLogo';
@@ -30,6 +31,7 @@ export type { ContainerProps } from './container/Container';
30
31
  export type { ErrorTextProps } from './ErrorText/ErrorText';
31
32
  export type { HeadingProps } from './Heading/Heading';
32
33
  export type { HintProps } from './Hint/Hint';
34
+ export type { NotificationBannerProps } from './NotificationBanner/NotificationBanner';
33
35
  export type { ExternalLinkProps } from './link/ExternalLink';
34
36
  export type { LinkProps } from './link/Link';
35
37
  export type { ParagraphProps } from './Paragraph/Paragraph';
@@ -1,8 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
2
 
3
- import { twMerge } from 'tailwind-merge';
4
-
5
3
  import type { ExtendProps } from '../../types/utils';
4
+ import { cn } from '../../utils';
6
5
 
7
6
  type Props = {
8
7
  children: ReactNode;
@@ -13,7 +12,7 @@ export type ExternalLinkProps = ExtendProps<'a', Props>;
13
12
  export const ExternalLink = ({ href, className, children, ...props }: ExternalLinkProps) => (
14
13
  <a
15
14
  {...props}
16
- className={twMerge(
15
+ className={cn(
17
16
  `cursor-pointer text-link hover:decoration-[max(3px,_.1875rem,_.12em)] hover:text-link-hover
18
17
  visited:text-link-visited focus:decoration-[max(3px,_.1875rem,_.12em)]
19
18
  decoration-[max(1px,_.0625rem)] underline-offset-[0.1578em] underline outline-none
@@ -1,7 +1,7 @@
1
1
  import NextLink from 'next/link';
2
- import { twMerge } from 'tailwind-merge';
3
2
 
4
3
  import type { ExtendProps } from '../../types/utils';
4
+ import { cn } from '../../utils';
5
5
 
6
6
  type Props = {
7
7
  href: string | object;
@@ -12,7 +12,7 @@ export type LinkProps = ExtendProps<typeof NextLink, Props>;
12
12
  export const Link = ({ href, className, children, ...props }: LinkProps) => (
13
13
  <NextLink
14
14
  {...props}
15
- className={twMerge(
15
+ className={cn(
16
16
  `cursor-pointer text-link hover:decoration-[max(3px,_.1875rem,_.12em)] hover:text-link-hover
17
17
  visited:text-link-visited active:text-black focus:decoration-[max(3px,_.1875rem,_.12em)]
18
18
  decoration-[max(1px,_.0625rem)] underline-offset-[0.1578em] underline outline-none
@@ -12,9 +12,9 @@ import type {
12
12
  Props as ReactSelectProps,
13
13
  } from 'react-select';
14
14
  import { components, default as ReactSelect } from 'react-select';
15
- import { twMerge } from 'tailwind-merge';
16
15
 
17
16
  import { SELECT_CONTAINER_CLASSES, SELECT_CONTROL_CLASSES, SELECT_MIN_HEIGHT } from './common';
17
+ import { cn } from '../../utils';
18
18
 
19
19
  // extends the react-select props with some of our own
20
20
  export type SelectProps<Option, IsMulti extends boolean, Group extends GroupBase<Option>> = Omit<
@@ -26,9 +26,9 @@ const getClassNames = <Option, IsMulti extends boolean, Group extends GroupBase<
26
26
  userClassNames?: ClassNamesConfig<Option, IsMulti, Group>,
27
27
  ): ClassNamesConfig<Option, IsMulti, Group> => {
28
28
  return {
29
- container: (props) => twMerge(SELECT_CONTAINER_CLASSES, userClassNames?.container?.(props)),
29
+ container: (props) => cn(SELECT_CONTAINER_CLASSES, userClassNames?.container?.(props)),
30
30
  control: (props) =>
31
- twMerge(
31
+ cn(
32
32
  SELECT_CONTROL_CLASSES,
33
33
  SELECT_MIN_HEIGHT,
34
34
  props.isDisabled ? '!cursor-not-allowed bg-gray-100' : 'bg-white',
@@ -37,43 +37,37 @@ const getClassNames = <Option, IsMulti extends boolean, Group extends GroupBase<
37
37
  : '',
38
38
  userClassNames?.control?.(props),
39
39
  ),
40
- dropdownIndicator: (props) => twMerge('w-4 h-4', userClassNames?.dropdownIndicator?.(props)),
41
- placeholder: (props) => twMerge('text-text-secondary', userClassNames?.placeholder?.(props)),
40
+ dropdownIndicator: (props) => cn('w-4 h-4', userClassNames?.dropdownIndicator?.(props)),
41
+ placeholder: (props) => cn('text-text-secondary', userClassNames?.placeholder?.(props)),
42
42
  menu: (props) =>
43
- twMerge(
43
+ cn(
44
44
  'bg-white rounded-md border mt-1 overflow-hidden shadow-sm shadow-[0px_0px_6px_0px_#00000044]',
45
45
  userClassNames?.menu?.(props),
46
46
  ),
47
- menuList: (props) => twMerge('flex flex-col', userClassNames?.menuList?.(props)),
47
+ menuList: (props) => cn('flex flex-col', userClassNames?.menuList?.(props)),
48
48
  option: (props) =>
49
- twMerge(
49
+ cn(
50
50
  'overflow-x-hidden text-ellipsis px-4 py-1 shrink-0',
51
51
  !props.isSelected && props.isFocused ? 'bg-slate-100' : '',
52
52
  props.isSelected ? 'bg-brand text-white' : '',
53
53
  userClassNames?.option?.(props),
54
54
  ),
55
- noOptionsMessage: (props) => twMerge('px-4 py-1', userClassNames?.noOptionsMessage?.(props)),
55
+ noOptionsMessage: (props) => cn('px-4 py-1', userClassNames?.noOptionsMessage?.(props)),
56
56
  clearIndicator: (props) =>
57
- twMerge(
58
- 'cursor-pointer pointer-events-auto w-4 h-4',
59
- userClassNames?.clearIndicator?.(props),
60
- ),
57
+ cn('cursor-pointer pointer-events-auto w-4 h-4', userClassNames?.clearIndicator?.(props)),
61
58
  indicatorsContainer: (props) =>
62
- twMerge(
63
- 'flex gap-1 items-center justify-center',
64
- userClassNames?.indicatorsContainer?.(props),
65
- ),
59
+ cn('flex gap-1 items-center justify-center', userClassNames?.indicatorsContainer?.(props)),
66
60
  indicatorSeparator: (props) =>
67
- twMerge(
61
+ cn(
68
62
  props.isMulti && props.hasValue ? 'bg-border-input/30' : '',
69
63
  userClassNames?.indicatorSeparator?.(props),
70
64
  ),
71
65
  multiValue: (props) =>
72
- twMerge(
66
+ cn(
73
67
  'flex gap-2 items-center justify-center px-2 bg-brand text-white rounded-md m-[2px]',
74
68
  userClassNames?.multiValue?.(props),
75
69
  ),
76
- multiValueRemove: (props) => twMerge('w-3 h-3', userClassNames?.multiValueRemove?.(props)),
70
+ multiValueRemove: (props) => cn('w-3 h-3', userClassNames?.multiValueRemove?.(props)),
77
71
  };
78
72
  };
79
73
 
@@ -1,15 +1,18 @@
1
- import { twMerge } from 'tailwind-merge';
2
-
3
1
  import { SELECT_CONTAINER_CLASSES, SELECT_CONTROL_CLASSES, SELECT_MIN_HEIGHT } from './common';
2
+ import type { ExtendProps } from '../../types';
3
+ import { cn } from '../../utils';
4
4
 
5
- export type SelectSkeletonProps = {
6
- className?: string;
5
+ type Props = {
6
+ /** Explicitly disallow children as we hard-code the skeleton content */
7
+ children?: never;
7
8
  };
8
9
 
9
- export const SelectSkeleton = ({ className }: SelectSkeletonProps = {}) => {
10
+ export type SelectSkeletonProps = ExtendProps<'div', Props>;
11
+
12
+ export const SelectSkeleton = ({ className, ...props }: SelectSkeletonProps = {}) => {
10
13
  return (
11
- <div className={twMerge(SELECT_CONTAINER_CLASSES, className)}>
12
- <div className={twMerge(SELECT_CONTROL_CLASSES, SELECT_MIN_HEIGHT)}>
14
+ <div className={cn(SELECT_CONTAINER_CLASSES, className)} {...props}>
15
+ <div className={cn(SELECT_CONTROL_CLASSES, SELECT_MIN_HEIGHT)}>
13
16
  <div
14
17
  className="w-full h-full bg-gray-100 animate-pulse rounded-md col-span-2"
15
18
  aria-label="Loading options"
@@ -1,13 +1,22 @@
1
1
  'use client';
2
2
 
3
- import { KeyboardKeys } from '../../utils';
3
+ import type { ExtendProps } from '../../types';
4
+ import { cn, KeyboardKeys } from '../../utils';
4
5
  import { Link } from '../link/Link';
5
6
 
6
- export type SkipLinkProps = {
7
+ type Props = {
7
8
  mainContentId?: string;
9
+ /** Explicitly disallow children as we hard-code the link content */
10
+ children?: never;
8
11
  };
9
12
 
10
- export const SkipLink = ({ mainContentId = 'main-content' }: SkipLinkProps) => {
13
+ export type SkipLinkProps = ExtendProps<'nav', Props>;
14
+
15
+ export const SkipLink = ({
16
+ mainContentId = 'main-content',
17
+ className,
18
+ ...props
19
+ }: SkipLinkProps) => {
11
20
  const handleActivate = () => {
12
21
  // Let the browser scroll first, then move focus
13
22
  setTimeout(() => {
@@ -20,7 +29,7 @@ export const SkipLink = ({ mainContentId = 'main-content' }: SkipLinkProps) => {
20
29
  };
21
30
 
22
31
  return (
23
- <nav aria-label="Skip navigation">
32
+ <nav aria-label="Skip navigation" className={cn(className)} {...props}>
24
33
  <Link
25
34
  className="absolute w-full p-3 text-black bg-focus focus:relative focus:top-0 -top-full
26
35
  visited:text-black hover:text-black skip-link"
@@ -59,7 +59,7 @@ export const requestLogger = (options?: RequestLoggerOptions): BetterFetchPlugin
59
59
 
60
60
  const timings = new Map<string, number>();
61
61
 
62
- const genId = () => crypto.randomUUID().split('-')[0].toUpperCase();
62
+ const genId = () => crypto.randomUUID().split('-')[0]!.toUpperCase();
63
63
 
64
64
  return {
65
65
  id: 'logger',
@@ -56,5 +56,11 @@ export const transformPolygonCoords = (
56
56
  from: string,
57
57
  to: string,
58
58
  ): number[][][] => {
59
- return coords.map((ring) => ring.map(([x, y]) => transform([x, y], from, to)));
59
+ return coords.map((ring) =>
60
+ ring.map(([x, y]) => {
61
+ const result = transform([x!, y!], from, to);
62
+
63
+ return [result[0]!, result[1]!];
64
+ }),
65
+ );
60
66
  };
@@ -48,8 +48,8 @@ export const osOpenNamesSearch = (options?: SearchOption) => {
48
48
  .map((feature) => {
49
49
  if (feature.geometry.type === 'Point') {
50
50
  const result: SearchResult = {
51
- lon: feature.geometry.coordinates[0],
52
- lat: feature.geometry.coordinates[1],
51
+ lon: feature.geometry.coordinates[0]!,
52
+ lat: feature.geometry.coordinates[1]!,
53
53
  address: feature?.properties?.address,
54
54
  };
55
55
 
@@ -51,8 +51,8 @@ export const useKeyboardDrawing = ({
51
51
  const delta = DELTA * resolution;
52
52
 
53
53
  setCursorPosition([
54
- cursorPosition[0] + dx * delta,
55
- cursorPosition[1] - dy * delta, // Invert Y for map coordinates
54
+ cursorPosition[0]! + dx * delta,
55
+ cursorPosition[1]! - dy * delta, // Invert Y for map coordinates
56
56
  ]);
57
57
  },
58
58
  [cursorPosition, map],
package/src/map/utils.ts CHANGED
@@ -13,7 +13,8 @@ const POPUP_HEIGHT = 300;
13
13
 
14
14
  export const getPopupPositionClass = (coordinate: number[], map: Map): PopupDirection => {
15
15
  const pixel = map.getPixelFromCoordinate(coordinate);
16
- const [x, y] = pixel;
16
+ const x = pixel[0]!;
17
+ const y = pixel[1]!;
17
18
 
18
19
  const isTop = y > POPUP_HEIGHT;
19
20
  const isBottom = y < POPUP_HEIGHT;