@tpzdsp/next-toolkit 1.13.0 → 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.
- package/package.json +13 -1
- package/src/components/ButtonLink/ButtonLink.stories.tsx +72 -0
- package/src/components/ButtonLink/ButtonLink.test.tsx +154 -0
- package/src/components/ButtonLink/ButtonLink.tsx +33 -0
- package/src/components/InfoBox/InfoBox.stories.tsx +31 -28
- package/src/components/InfoBox/InfoBox.test.tsx +8 -60
- package/src/components/InfoBox/InfoBox.tsx +60 -69
- package/src/components/LinkButton/LinkButton.stories.tsx +74 -0
- package/src/components/LinkButton/LinkButton.test.tsx +177 -0
- package/src/components/LinkButton/LinkButton.tsx +80 -0
- package/src/components/index.ts +5 -8
- package/src/components/link/ExternalLink.test.tsx +104 -0
- package/src/components/link/ExternalLink.tsx +1 -0
- package/src/map/MapComponent.tsx +7 -12
- package/src/components/InfoBox/hooks/index.ts +0 -3
- package/src/components/InfoBox/hooks/useInfoBoxPosition.test.ts +0 -187
- package/src/components/InfoBox/hooks/useInfoBoxPosition.ts +0 -69
- package/src/components/InfoBox/hooks/useInfoBoxState.test.ts +0 -168
- package/src/components/InfoBox/hooks/useInfoBoxState.ts +0 -71
- package/src/components/InfoBox/hooks/usePortalMount.test.ts +0 -62
- package/src/components/InfoBox/hooks/usePortalMount.ts +0 -15
- package/src/components/InfoBox/utils/focusTrapConfig.test.ts +0 -310
- package/src/components/InfoBox/utils/focusTrapConfig.ts +0 -59
- package/src/components/InfoBox/utils/index.ts +0 -2
- package/src/components/InfoBox/utils/positionUtils.test.ts +0 -170
- package/src/components/InfoBox/utils/positionUtils.ts +0 -89
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { type ReactNode, useRef, useId,
|
|
3
|
+
import { type ReactNode, useRef, useId, useState } from 'react';
|
|
4
4
|
|
|
5
|
-
import { FocusTrap } from 'focus-trap-react';
|
|
6
|
-
import { createPortal } from 'react-dom';
|
|
7
5
|
import { FaInfoCircle } from 'react-icons/fa';
|
|
8
6
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
import {
|
|
8
|
+
useFloating,
|
|
9
|
+
autoUpdate,
|
|
10
|
+
offset,
|
|
11
|
+
flip,
|
|
12
|
+
shift,
|
|
13
|
+
arrow,
|
|
14
|
+
useClick,
|
|
15
|
+
useDismiss,
|
|
16
|
+
useRole,
|
|
17
|
+
useInteractions,
|
|
18
|
+
FloatingArrow,
|
|
19
|
+
FloatingFocusManager,
|
|
20
|
+
FloatingPortal,
|
|
21
|
+
type Placement,
|
|
22
|
+
} from '@floating-ui/react';
|
|
23
|
+
|
|
12
24
|
import type { ExtendProps } from '../../types';
|
|
13
25
|
import { cn } from '../../utils';
|
|
14
26
|
|
|
15
|
-
// Re-export position constants for consumer convenience
|
|
16
|
-
export {
|
|
17
|
-
POSITION_TOP_LEFT,
|
|
18
|
-
POSITION_TOP_RIGHT,
|
|
19
|
-
POSITION_BOTTOM_LEFT,
|
|
20
|
-
POSITION_BOTTOM_RIGHT,
|
|
21
|
-
} from './types';
|
|
22
|
-
export type { Position } from './types';
|
|
23
|
-
|
|
24
27
|
type Props = {
|
|
25
28
|
/** Optional title displayed at the top of the info box content */
|
|
26
29
|
title?: string;
|
|
@@ -34,8 +37,8 @@ type Props = {
|
|
|
34
37
|
maxWidth?: string;
|
|
35
38
|
/** Custom aria-label for the trigger button (default: 'Show information') */
|
|
36
39
|
triggerLabel?: string;
|
|
37
|
-
/**
|
|
38
|
-
|
|
40
|
+
/** Preferred placement (Floating UI will auto-adjust if needed) */
|
|
41
|
+
placement?: Placement;
|
|
39
42
|
};
|
|
40
43
|
|
|
41
44
|
export type InfoBoxProps = ExtendProps<'div', Props>;
|
|
@@ -47,43 +50,39 @@ export const InfoBox = ({
|
|
|
47
50
|
onOpenChange,
|
|
48
51
|
maxWidth = '320px',
|
|
49
52
|
triggerLabel = 'Show information',
|
|
50
|
-
|
|
53
|
+
placement = 'bottom-start',
|
|
51
54
|
className,
|
|
52
55
|
...props
|
|
53
56
|
}: InfoBoxProps) => {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
57
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
58
|
+
const arrowRef = useRef(null);
|
|
59
|
+
|
|
60
|
+
const { refs, floatingStyles, context } = useFloating({
|
|
61
|
+
open: isOpen,
|
|
62
|
+
onOpenChange: (open) => {
|
|
63
|
+
setIsOpen(open);
|
|
64
|
+
onOpenChange?.(open);
|
|
65
|
+
},
|
|
66
|
+
placement,
|
|
67
|
+
middleware: [
|
|
68
|
+
offset(12), // Distance from trigger
|
|
69
|
+
flip(), // Flip to opposite side if not enough space
|
|
70
|
+
shift({ padding: 8 }), // Shift along the axis to stay in viewport
|
|
71
|
+
arrow({ element: arrowRef }), // Arrow pointing to trigger
|
|
72
|
+
],
|
|
73
|
+
whileElementsMounted: autoUpdate, // Update position on scroll/resize
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const click = useClick(context);
|
|
77
|
+
const dismiss = useDismiss(context);
|
|
78
|
+
const role = useRole(context);
|
|
79
|
+
|
|
80
|
+
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]);
|
|
56
81
|
|
|
57
82
|
const triggerId = useId();
|
|
58
83
|
const contentId = useId();
|
|
59
84
|
const titleId = useId();
|
|
60
85
|
|
|
61
|
-
// Custom hooks for separation of concerns
|
|
62
|
-
const isMounted = usePortalMount();
|
|
63
|
-
const {
|
|
64
|
-
isOpen,
|
|
65
|
-
isTrapActive,
|
|
66
|
-
setIsTrapActive,
|
|
67
|
-
isOpenRef,
|
|
68
|
-
deactivatedByClick,
|
|
69
|
-
handleClose,
|
|
70
|
-
toggleOpen,
|
|
71
|
-
} = useInfoBoxState({ defaultOpen, onOpenChange });
|
|
72
|
-
const { calculatedPosition, contentPosition } = useInfoBoxPosition({
|
|
73
|
-
isOpen,
|
|
74
|
-
triggerRef: triggerRef as RefObject<HTMLElement | null>,
|
|
75
|
-
forcedPosition,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const focusTrapConfig = getFocusTrapConfig({
|
|
79
|
-
isOpenRef,
|
|
80
|
-
deactivatedByClick,
|
|
81
|
-
triggerRef: triggerRef as RefObject<HTMLElement | null>,
|
|
82
|
-
contentRef: contentRef as RefObject<HTMLElement | null>,
|
|
83
|
-
handleClose,
|
|
84
|
-
setIsTrapActive,
|
|
85
|
-
});
|
|
86
|
-
|
|
87
86
|
const triggerClasses = cn(
|
|
88
87
|
// Base styles - button structure only
|
|
89
88
|
'inline-flex items-center justify-center',
|
|
@@ -107,18 +106,11 @@ export const InfoBox = ({
|
|
|
107
106
|
);
|
|
108
107
|
|
|
109
108
|
const contentClasses = cn(
|
|
110
|
-
// Position
|
|
111
|
-
'fixed',
|
|
112
109
|
// Base styles
|
|
113
110
|
'bg-white rounded-lg shadow-lg border border-gray-200',
|
|
114
111
|
'p-4',
|
|
115
112
|
// Width constraints
|
|
116
113
|
'min-w-[280px]',
|
|
117
|
-
// Animation
|
|
118
|
-
'transition-all duration-200 ease-out',
|
|
119
|
-
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95 pointer-events-none',
|
|
120
|
-
// Transform based on position
|
|
121
|
-
getTransformClasses(calculatedPosition),
|
|
122
114
|
// Z-index
|
|
123
115
|
'z-50',
|
|
124
116
|
);
|
|
@@ -126,37 +118,36 @@ export const InfoBox = ({
|
|
|
126
118
|
return (
|
|
127
119
|
<div className={cn('relative inline-flex', className)} {...props}>
|
|
128
120
|
<button
|
|
129
|
-
ref={
|
|
121
|
+
ref={refs.setReference}
|
|
130
122
|
id={triggerId}
|
|
131
123
|
type="button"
|
|
132
124
|
aria-expanded={isOpen}
|
|
133
125
|
aria-controls={contentId}
|
|
134
126
|
aria-haspopup="dialog"
|
|
135
127
|
aria-label={triggerLabel}
|
|
136
|
-
onClick={toggleOpen}
|
|
137
128
|
className={triggerClasses}
|
|
129
|
+
{...getReferenceProps()}
|
|
138
130
|
>
|
|
139
131
|
<FaInfoCircle className={iconClasses} aria-hidden="true" />
|
|
140
132
|
</button>
|
|
141
133
|
|
|
142
|
-
{
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
ref={contentRef}
|
|
134
|
+
{isOpen && (
|
|
135
|
+
<FloatingPortal>
|
|
136
|
+
<FloatingFocusManager context={context} modal={false}>
|
|
137
|
+
<div
|
|
138
|
+
ref={refs.setFloating}
|
|
148
139
|
id={contentId}
|
|
149
|
-
open
|
|
150
140
|
aria-labelledby={title ? titleId : undefined}
|
|
151
141
|
aria-label={title ? undefined : 'Information'}
|
|
152
|
-
tabIndex={-1}
|
|
153
142
|
style={{
|
|
154
|
-
|
|
155
|
-
left: contentPosition.left,
|
|
143
|
+
...floatingStyles,
|
|
156
144
|
maxWidth: `min(${maxWidth}, calc(100vw - 32px))`,
|
|
157
145
|
}}
|
|
158
146
|
className={contentClasses}
|
|
147
|
+
{...getFloatingProps()}
|
|
159
148
|
>
|
|
149
|
+
<FloatingArrow ref={arrowRef} context={context} className="fill-white" />
|
|
150
|
+
|
|
160
151
|
{title ? (
|
|
161
152
|
<h2 id={titleId} className="text-sm font-semibold text-gray-900 mb-2">
|
|
162
153
|
{title}
|
|
@@ -168,10 +159,10 @@ export const InfoBox = ({
|
|
|
168
159
|
>
|
|
169
160
|
{children}
|
|
170
161
|
</div>
|
|
171
|
-
</
|
|
172
|
-
</
|
|
173
|
-
|
|
174
|
-
|
|
162
|
+
</div>
|
|
163
|
+
</FloatingFocusManager>
|
|
164
|
+
</FloatingPortal>
|
|
165
|
+
)}
|
|
175
166
|
</div>
|
|
176
167
|
);
|
|
177
168
|
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
|
|
3
|
+
import { LinkButton } from './LinkButton';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/LinkButton',
|
|
7
|
+
component: LinkButton,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'centered',
|
|
10
|
+
},
|
|
11
|
+
tags: ['autodocs'],
|
|
12
|
+
argTypes: {
|
|
13
|
+
variant: {
|
|
14
|
+
control: 'select',
|
|
15
|
+
options: ['primary', 'secondary', 'inverse'],
|
|
16
|
+
},
|
|
17
|
+
openInNewTab: {
|
|
18
|
+
control: 'boolean',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
} satisfies Meta<typeof LinkButton>;
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof meta>;
|
|
25
|
+
|
|
26
|
+
export const Primary: Story = {
|
|
27
|
+
args: {
|
|
28
|
+
children: 'Explore data',
|
|
29
|
+
href: '/explore',
|
|
30
|
+
variant: 'primary',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const Secondary: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
children: 'Learn more',
|
|
37
|
+
href: '/about',
|
|
38
|
+
variant: 'secondary',
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const Inverse: Story = {
|
|
43
|
+
args: {
|
|
44
|
+
children: 'Get started',
|
|
45
|
+
href: '/start',
|
|
46
|
+
variant: 'inverse',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const ExternalLink: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
children: 'Visit GOV.UK',
|
|
53
|
+
href: 'https://www.gov.uk',
|
|
54
|
+
variant: 'primary',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const OpenInNewTab: Story = {
|
|
59
|
+
args: {
|
|
60
|
+
children: 'Open in new tab',
|
|
61
|
+
href: '/internal-page',
|
|
62
|
+
variant: 'primary',
|
|
63
|
+
openInNewTab: true,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const WithCustomStyling: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
children: 'Custom styled button',
|
|
70
|
+
href: '/custom',
|
|
71
|
+
variant: 'primary',
|
|
72
|
+
className: 'px-8 py-4 text-xl',
|
|
73
|
+
},
|
|
74
|
+
};
|
|
@@ -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
|
+
};
|
package/src/components/index.ts
CHANGED
|
@@ -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';
|
|
@@ -46,22 +50,15 @@ export { useDropdownMenu } from './dropdown/useDropdownMenu';
|
|
|
46
50
|
export { SlidingPanel } from './SlidingPanel/SlidingPanel';
|
|
47
51
|
export { Accordion } from './accordion/Accordion';
|
|
48
52
|
export { Modal } from './Modal/Modal';
|
|
49
|
-
export {
|
|
50
|
-
InfoBox,
|
|
51
|
-
POSITION_TOP_LEFT,
|
|
52
|
-
POSITION_TOP_RIGHT,
|
|
53
|
-
POSITION_BOTTOM_LEFT,
|
|
54
|
-
POSITION_BOTTOM_RIGHT,
|
|
55
|
-
} from './InfoBox/InfoBox';
|
|
56
53
|
export { ErrorBoundary } from './ErrorBoundary/ErrorBoundary';
|
|
57
54
|
export { ErrorFallback } from './ErrorBoundary/ErrorFallback';
|
|
58
55
|
// NOTE: Select components moved to separate entry point '@tpzdsp/next-toolkit/components/select'
|
|
59
56
|
// export { Select } from './select/Select';
|
|
60
57
|
// export { SelectSkeleton } from './select/SelectSkeleton';
|
|
58
|
+
// NOTE: InfoBox moved to separate entry point '@tpzdsp/next-toolkit/components/info-box'
|
|
61
59
|
|
|
62
60
|
// Export client component types
|
|
63
61
|
export type { AccordionProps } from './accordion/Accordion';
|
|
64
|
-
export type { InfoBoxProps, Position } from './InfoBox/InfoBox';
|
|
65
62
|
export type { ItemRendererProps } from './dropdown/useDropdownMenu';
|
|
66
63
|
export type { DropdownMenuItem } from './dropdown/DropdownMenu';
|
|
67
64
|
|
|
@@ -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
|
+
});
|