@tpzdsp/next-toolkit 1.12.1 → 1.14.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,177 @@
1
+ import { LinkButton } from './LinkButton';
2
+ import { render, screen } from '../../test/renderers';
3
+
4
+ // Mock next/link
5
+ vi.mock('next/link', () => ({
6
+ default: ({
7
+ href,
8
+ children,
9
+ className,
10
+ ...props
11
+ }: {
12
+ href: string;
13
+ children: React.ReactNode;
14
+ className?: string;
15
+ }) => (
16
+ <a href={href} className={className} data-testid="next-link" {...props}>
17
+ {children}
18
+ </a>
19
+ ),
20
+ }));
21
+
22
+ describe('LinkButton', () => {
23
+ describe('rendering', () => {
24
+ it('should render with children', () => {
25
+ render(<LinkButton href="/test">Click me</LinkButton>);
26
+
27
+ expect(screen.getByRole('link', { name: 'Click me' })).toBeInTheDocument();
28
+ });
29
+
30
+ it('should render as a link with correct href', () => {
31
+ render(<LinkButton href="/explore">Explore</LinkButton>);
32
+
33
+ const link = screen.getByRole('link', { name: 'Explore' });
34
+
35
+ expect(link).toHaveAttribute('href', '/explore');
36
+ });
37
+
38
+ it('should apply primary variant by default', () => {
39
+ render(<LinkButton href="/test">Button</LinkButton>);
40
+
41
+ const link = screen.getByRole('link');
42
+
43
+ expect(link).toHaveClass('bg-brand');
44
+ });
45
+
46
+ it('should apply secondary variant when specified', () => {
47
+ render(
48
+ <LinkButton href="/test" variant="secondary">
49
+ Button
50
+ </LinkButton>,
51
+ );
52
+
53
+ const link = screen.getByRole('link');
54
+
55
+ expect(link).toHaveClass('bg-slate-100');
56
+ });
57
+
58
+ it('should apply inverse variant when specified', () => {
59
+ render(
60
+ <LinkButton href="/test" variant="inverse">
61
+ Button
62
+ </LinkButton>,
63
+ );
64
+
65
+ const link = screen.getByRole('link');
66
+
67
+ expect(link).toHaveClass('bg-white');
68
+ });
69
+
70
+ it('should merge custom className', () => {
71
+ render(
72
+ <LinkButton href="/test" className="custom-class">
73
+ Button
74
+ </LinkButton>,
75
+ );
76
+
77
+ const link = screen.getByRole('link');
78
+
79
+ expect(link).toHaveClass('custom-class');
80
+ expect(link).toHaveClass('bg-brand'); // Still has base classes
81
+ });
82
+ });
83
+
84
+ describe('external links', () => {
85
+ it('should automatically detect external URLs', () => {
86
+ render(<LinkButton href="https://example.com">External</LinkButton>);
87
+
88
+ const link = screen.getByRole('link');
89
+
90
+ expect(link).toHaveAttribute('target', '_blank');
91
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
92
+ });
93
+
94
+ it('should detect protocol-relative URLs as external', () => {
95
+ render(<LinkButton href="//example.com">External</LinkButton>);
96
+
97
+ const link = screen.getByRole('link');
98
+
99
+ expect(link).toHaveAttribute('target', '_blank');
100
+ });
101
+
102
+ it('should detect mailto links as external', () => {
103
+ render(<LinkButton href="mailto:test@example.com">Email</LinkButton>);
104
+
105
+ const link = screen.getByRole('link');
106
+
107
+ expect(link).toHaveAttribute('target', '_blank');
108
+ });
109
+
110
+ it('should use Next.js Link for internal URLs', () => {
111
+ render(<LinkButton href="/internal">Internal</LinkButton>);
112
+
113
+ expect(screen.getByTestId('next-link')).toBeInTheDocument();
114
+
115
+ const link = screen.getByRole('link');
116
+
117
+ expect(link).not.toHaveAttribute('target');
118
+ expect(link).not.toHaveAttribute('rel');
119
+ });
120
+ });
121
+
122
+ describe('openInNewTab', () => {
123
+ it('should open in new tab when openInNewTab is true', () => {
124
+ render(
125
+ <LinkButton href="/internal" openInNewTab>
126
+ Open in new tab
127
+ </LinkButton>,
128
+ );
129
+
130
+ const link = screen.getByRole('link');
131
+
132
+ expect(link).toHaveAttribute('target', '_blank');
133
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
134
+ });
135
+
136
+ it('should use regular anchor tag when openInNewTab is true', () => {
137
+ render(
138
+ <LinkButton href="/internal" openInNewTab>
139
+ Open in new tab
140
+ </LinkButton>,
141
+ );
142
+
143
+ // Should not use Next.js Link when opening in new tab
144
+ expect(screen.queryByTestId('next-link')).not.toBeInTheDocument();
145
+ });
146
+
147
+ it('should not open in new tab by default for internal links', () => {
148
+ render(<LinkButton href="/internal">Internal</LinkButton>);
149
+
150
+ const link = screen.getByRole('link');
151
+
152
+ expect(link).not.toHaveAttribute('target');
153
+ });
154
+ });
155
+
156
+ describe('accessibility', () => {
157
+ it('should have link role', () => {
158
+ render(<LinkButton href="/test">Button</LinkButton>);
159
+
160
+ const link = screen.getByRole('link');
161
+
162
+ expect(link).toBeInTheDocument();
163
+ });
164
+
165
+ it('should accept aria attributes', () => {
166
+ render(
167
+ <LinkButton href="/test" aria-label="Custom label">
168
+ Button
169
+ </LinkButton>,
170
+ );
171
+
172
+ const link = screen.getByRole('link', { name: 'Custom label' });
173
+
174
+ expect(link).toBeInTheDocument();
175
+ });
176
+ });
177
+ });
@@ -0,0 +1,80 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import NextLink from 'next/link';
4
+
5
+ import type { ExtendProps } from '../../types/utils';
6
+ import { cn } from '../../utils';
7
+
8
+ type Props = {
9
+ /** URL to navigate to */
10
+ href: string;
11
+ /** Button variant styling */
12
+ variant?: 'primary' | 'secondary' | 'inverse';
13
+ /** Content to display in the button */
14
+ children: ReactNode;
15
+ /** Whether to open in a new tab (default: false) */
16
+ openInNewTab?: boolean;
17
+ };
18
+
19
+ export type LinkButtonProps = ExtendProps<'a', Props>;
20
+
21
+ const VARIANTS = {
22
+ primary: 'bg-brand hover-enabled:bg-green-600 text-white',
23
+ secondary: 'bg-slate-100 hover-enabled:bg-slate-200 text-black',
24
+ inverse: 'bg-white hover-enabled:bg-slate-50 text-brand',
25
+ };
26
+
27
+ /**
28
+ * Determines if a URL is external (different origin or absolute URL)
29
+ */
30
+ const isExternalUrl = (href: string): boolean => {
31
+ // Absolute URLs (http://, https://, //)
32
+ if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
33
+ return true;
34
+ }
35
+
36
+ // mailto:, tel:, etc.
37
+ if (href.includes(':')) {
38
+ return true;
39
+ }
40
+
41
+ return false;
42
+ };
43
+
44
+ // NOTE: some of the styles are applied in `tailwind.css`, under `Button Styles`
45
+ export const LinkButton = ({
46
+ href,
47
+ variant = 'primary',
48
+ openInNewTab = false,
49
+ className,
50
+ children,
51
+ ...props
52
+ }: LinkButtonProps) => {
53
+ const buttonClasses = cn(
54
+ `sm:w-auto text-lg relative inline-flex w-full outline-none items-center border-transparent
55
+ justify-center border-2 text-center active-enabled:translate-y-[2px] focus:shadow-focus
56
+ focus:border-focus focus:shadow-[inset_0_0_0_1px] focus-idle:border-focus
57
+ focus-idle:text-focus-text focus-idle:bg-focus focus-idle:shadow-border-input
58
+ disabled:opacity-50 disabled:hover:cursor-not-allowed button`,
59
+ VARIANTS[variant],
60
+ className,
61
+ );
62
+
63
+ const isExternal = isExternalUrl(href);
64
+
65
+ // External link or forced new tab - use <a> tag with appropriate attributes
66
+ if (isExternal || openInNewTab) {
67
+ return (
68
+ <a href={href} target="_blank" rel="noopener noreferrer" className={buttonClasses} {...props}>
69
+ {children}
70
+ </a>
71
+ );
72
+ }
73
+
74
+ // Internal link - use Next.js Link for client-side navigation
75
+ return (
76
+ <NextLink href={href} className={buttonClasses} {...props}>
77
+ {children}
78
+ </NextLink>
79
+ );
80
+ };
@@ -1,6 +1,7 @@
1
1
  // Default components - these can be used in server or client-side rendering
2
2
  export { BackToTop } from './backToTop/BackToTop';
3
3
  export { Button } from './Button/Button';
4
+ export { ButtonLink } from './ButtonLink/ButtonLink';
4
5
  export { Card } from './Card/Card';
5
6
  export { Chip } from './chip/Chip';
6
7
  export { CookieBanner } from './cookieBanner/CookieBanner';
@@ -16,6 +17,7 @@ export { EaLogo } from './images/EaLogo';
16
17
  export { OglLogo } from './images/OglLogo';
17
18
  export { ExternalLink } from './link/ExternalLink';
18
19
  export { Link } from './link/Link';
20
+ export { LinkButton } from './LinkButton/LinkButton';
19
21
  export { Paragraph } from './Paragraph/Paragraph';
20
22
  export { RuleDivider } from './divider/RuleDivider';
21
23
  export { SkipLink } from './skipLink/SkipLink';
@@ -25,6 +27,7 @@ export { TextArea } from './form/TextArea';
25
27
  // Export default component types
26
28
  export type { BackToTopProps } from './backToTop/BackToTop';
27
29
  export type { ButtonProps } from './Button/Button';
30
+ export type { ButtonLinkProps } from './ButtonLink/ButtonLink';
28
31
  export type { CardProps } from './Card/Card';
29
32
  export type { ChipProps } from './chip/Chip';
30
33
  export type { ContainerProps } from './container/Container';
@@ -34,6 +37,7 @@ export type { HintProps } from './Hint/Hint';
34
37
  export type { NotificationBannerProps } from './NotificationBanner/NotificationBanner';
35
38
  export type { ExternalLinkProps } from './link/ExternalLink';
36
39
  export type { LinkProps } from './link/Link';
40
+ export type { LinkButtonProps } from './LinkButton/LinkButton';
37
41
  export type { ParagraphProps } from './Paragraph/Paragraph';
38
42
  export type { SlidingPanelProps } from './SlidingPanel/SlidingPanel';
39
43
  export type { SkipLinkProps } from './skipLink/SkipLink';
@@ -51,6 +55,7 @@ export { ErrorFallback } from './ErrorBoundary/ErrorFallback';
51
55
  // NOTE: Select components moved to separate entry point '@tpzdsp/next-toolkit/components/select'
52
56
  // export { Select } from './select/Select';
53
57
  // export { SelectSkeleton } from './select/SelectSkeleton';
58
+ // NOTE: InfoBox moved to separate entry point '@tpzdsp/next-toolkit/components/info-box'
54
59
 
55
60
  // Export client component types
56
61
  export type { AccordionProps } from './accordion/Accordion';
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { render, screen } from '@testing-library/react';
4
+
5
+ import { ExternalLink } from './ExternalLink';
6
+
7
+ describe('ExternalLink', () => {
8
+ it('renders with children', () => {
9
+ render(<ExternalLink href="https://example.com">Visit Example</ExternalLink>);
10
+
11
+ const link = screen.getByRole('link', { name: /Visit Example/i });
12
+
13
+ expect(link).toBeInTheDocument();
14
+ });
15
+
16
+ it('sets target="_blank" to open in new tab', () => {
17
+ render(<ExternalLink href="https://example.com">External Link</ExternalLink>);
18
+
19
+ const link = screen.getByRole('link');
20
+
21
+ expect(link).toHaveAttribute('target', '_blank');
22
+ });
23
+
24
+ it('sets rel="noopener noreferrer" for security', () => {
25
+ render(<ExternalLink href="https://example.com">External Link</ExternalLink>);
26
+
27
+ const link = screen.getByRole('link');
28
+
29
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
30
+ });
31
+
32
+ it('includes screen reader text indicating new tab', () => {
33
+ render(<ExternalLink href="https://example.com">Visit Site</ExternalLink>);
34
+
35
+ // The accessible name should include the "(opens in new tab)" text
36
+ const link = screen.getByRole('link', { name: /Visit Site\(opens in new tab\)/i });
37
+
38
+ expect(link).toBeInTheDocument();
39
+ });
40
+
41
+ it('applies custom className', () => {
42
+ render(
43
+ <ExternalLink href="https://example.com" className="custom-class">
44
+ Link
45
+ </ExternalLink>,
46
+ );
47
+
48
+ const link = screen.getByRole('link');
49
+
50
+ expect(link).toHaveClass('custom-class');
51
+ });
52
+
53
+ it('preserves default link styling classes', () => {
54
+ render(<ExternalLink href="https://example.com">Link</ExternalLink>);
55
+
56
+ const link = screen.getByRole('link');
57
+
58
+ expect(link).toHaveClass('text-link');
59
+ expect(link).toHaveClass('underline');
60
+ });
61
+
62
+ it('applies href attribute correctly', () => {
63
+ render(<ExternalLink href="https://example.com">Link</ExternalLink>);
64
+
65
+ const link = screen.getByRole('link');
66
+
67
+ expect(link).toHaveAttribute('href', 'https://example.com');
68
+ });
69
+
70
+ it('forwards additional props to anchor element', () => {
71
+ render(
72
+ <ExternalLink href="https://example.com" data-testid="external-link">
73
+ Link
74
+ </ExternalLink>,
75
+ );
76
+
77
+ const link = screen.getByTestId('external-link');
78
+
79
+ expect(link).toBeInTheDocument();
80
+ });
81
+
82
+ it('renders with complex children', () => {
83
+ render(
84
+ <ExternalLink href="https://example.com">
85
+ <span>Complex </span>
86
+
87
+ <strong>Content</strong>
88
+ </ExternalLink>,
89
+ );
90
+
91
+ const link = screen.getByRole('link');
92
+
93
+ expect(link).toBeInTheDocument();
94
+ expect(link).toHaveTextContent('Complex Content');
95
+ });
96
+
97
+ it('screen reader text is visually hidden but accessible', () => {
98
+ render(<ExternalLink href="https://example.com">Link</ExternalLink>);
99
+
100
+ const srText = screen.getByText('(opens in new tab)', { exact: false });
101
+
102
+ expect(srText).toHaveClass('sr-only');
103
+ });
104
+ });
@@ -24,5 +24,6 @@ export const ExternalLink = ({ href, className, children, ...props }: ExternalLi
24
24
  target="_blank"
25
25
  >
26
26
  {children}
27
+ <span className="sr-only"> (opens in new tab)</span>
27
28
  </a>
28
29
  );
@@ -0,0 +1,126 @@
1
+ /* eslint-disable no-restricted-syntax */
2
+ import { Map } from 'ol';
3
+ import { Control } from 'ol/control';
4
+ import type { Options as ControlOptions } from 'ol/control/Control';
5
+
6
+ import { CONTROL_ICONS, createControlButton } from './createControlButton';
7
+
8
+ const FULL_SCREEN_CLASS = 'map-fullscreen';
9
+ const ARIA_LABEL_TOGGLE = 'Toggle full screen';
10
+ const ARIA_LABEL_EXIT = 'Exit full screen';
11
+
12
+ export class FullScreenControl extends Control {
13
+ private isFullScreen = false;
14
+ private readonly button!: HTMLButtonElement;
15
+ liveRegion!: HTMLElement;
16
+ private mapContainer: HTMLElement | null = null;
17
+
18
+ constructor(options?: ControlOptions) {
19
+ const button = createControlButton({
20
+ ariaLabel: ARIA_LABEL_TOGGLE,
21
+ title: ARIA_LABEL_TOGGLE,
22
+ iconSvg: CONTROL_ICONS.EXPAND,
23
+ className: 'ol-fullscreen-toggle',
24
+ });
25
+
26
+ const element = document.createElement('div');
27
+
28
+ element.className = 'ol-fullscreen ol-unselectable ol-control';
29
+ element.appendChild(button);
30
+
31
+ // Create a live region for screen reader announcements
32
+ const liveRegion = document.createElement('div');
33
+
34
+ liveRegion.setAttribute('aria-live', 'polite');
35
+ liveRegion.setAttribute('role', 'status');
36
+ liveRegion.className = 'ol-fullscreen-live-region';
37
+ liveRegion.style.position = 'absolute';
38
+ liveRegion.style.width = '1px';
39
+ liveRegion.style.height = '1px';
40
+ liveRegion.style.margin = '-1px';
41
+ liveRegion.style.border = '0';
42
+ liveRegion.style.padding = '0';
43
+ liveRegion.style.overflow = 'hidden';
44
+ liveRegion.style.clipPath = 'inset(50%)';
45
+ element.appendChild(liveRegion);
46
+
47
+ super({
48
+ element,
49
+ target: options?.target,
50
+ });
51
+
52
+ this.button = button;
53
+ this.liveRegion = liveRegion;
54
+
55
+ button.addEventListener('click', this.toggleFullScreen, false);
56
+ }
57
+
58
+ setMap(map: Map | null) {
59
+ super.setMap(map);
60
+
61
+ if (map) {
62
+ // Find the map container (the target element)
63
+ this.mapContainer = map.getTargetElement();
64
+ }
65
+ }
66
+
67
+ private readonly updateButtonIcon = (isFullScreen: boolean): void => {
68
+ // Update the SVG icon content
69
+ const svg = this.button.querySelector('svg');
70
+
71
+ if (svg) {
72
+ svg.innerHTML = isFullScreen ? CONTROL_ICONS.COLLAPSE : CONTROL_ICONS.EXPAND;
73
+ }
74
+ };
75
+
76
+ toggleFullScreen = (): void => {
77
+ if (!this.mapContainer) {
78
+ console.warn('Map container not found');
79
+
80
+ return;
81
+ }
82
+
83
+ this.isFullScreen = !this.isFullScreen;
84
+
85
+ if (this.isFullScreen) {
86
+ // Enter full screen mode
87
+ this.mapContainer.classList.add(FULL_SCREEN_CLASS);
88
+ this.updateButtonIcon(true);
89
+ this.button.setAttribute('aria-label', ARIA_LABEL_EXIT);
90
+ this.button.setAttribute('title', ARIA_LABEL_EXIT);
91
+ this.liveRegion.textContent = 'Full screen mode enabled';
92
+ } else {
93
+ // Exit full screen mode
94
+ this.mapContainer.classList.remove(FULL_SCREEN_CLASS);
95
+ this.updateButtonIcon(false);
96
+ this.button.setAttribute('aria-label', ARIA_LABEL_TOGGLE);
97
+ this.button.setAttribute('title', ARIA_LABEL_TOGGLE);
98
+ this.liveRegion.textContent = 'Full screen mode disabled';
99
+ }
100
+
101
+ // Force map to update its size after the transition
102
+ const map = this.getMap();
103
+
104
+ if (map) {
105
+ // Wait for CSS transition to complete before updating size
106
+ setTimeout(() => {
107
+ map.updateSize();
108
+ }, 300);
109
+ }
110
+ };
111
+
112
+ // Public method to exit full screen programmatically
113
+ exitFullScreen(): void {
114
+ if (this.isFullScreen) {
115
+ this.toggleFullScreen();
116
+ }
117
+ }
118
+
119
+ dispose(): void {
120
+ if (this.button) {
121
+ this.button.removeEventListener('click', this.toggleFullScreen);
122
+ }
123
+
124
+ super.dispose?.();
125
+ }
126
+ }