@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.
Files changed (26) hide show
  1. package/package.json +13 -1
  2. package/src/components/ButtonLink/ButtonLink.stories.tsx +72 -0
  3. package/src/components/ButtonLink/ButtonLink.test.tsx +154 -0
  4. package/src/components/ButtonLink/ButtonLink.tsx +33 -0
  5. package/src/components/InfoBox/InfoBox.stories.tsx +31 -28
  6. package/src/components/InfoBox/InfoBox.test.tsx +8 -60
  7. package/src/components/InfoBox/InfoBox.tsx +60 -69
  8. package/src/components/LinkButton/LinkButton.stories.tsx +74 -0
  9. package/src/components/LinkButton/LinkButton.test.tsx +177 -0
  10. package/src/components/LinkButton/LinkButton.tsx +80 -0
  11. package/src/components/index.ts +5 -8
  12. package/src/components/link/ExternalLink.test.tsx +104 -0
  13. package/src/components/link/ExternalLink.tsx +1 -0
  14. package/src/map/MapComponent.tsx +7 -12
  15. package/src/components/InfoBox/hooks/index.ts +0 -3
  16. package/src/components/InfoBox/hooks/useInfoBoxPosition.test.ts +0 -187
  17. package/src/components/InfoBox/hooks/useInfoBoxPosition.ts +0 -69
  18. package/src/components/InfoBox/hooks/useInfoBoxState.test.ts +0 -168
  19. package/src/components/InfoBox/hooks/useInfoBoxState.ts +0 -71
  20. package/src/components/InfoBox/hooks/usePortalMount.test.ts +0 -62
  21. package/src/components/InfoBox/hooks/usePortalMount.ts +0 -15
  22. package/src/components/InfoBox/utils/focusTrapConfig.test.ts +0 -310
  23. package/src/components/InfoBox/utils/focusTrapConfig.ts +0 -59
  24. package/src/components/InfoBox/utils/index.ts +0 -2
  25. package/src/components/InfoBox/utils/positionUtils.test.ts +0 -170
  26. 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, type RefObject } from 'react';
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 { useInfoBoxPosition, useInfoBoxState, usePortalMount } from './hooks';
10
- import type { Position } from './types';
11
- import { getTransformClasses, getFocusTrapConfig } from './utils';
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
- /** Force a specific position instead of auto-calculating */
38
- position?: Position;
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
- position: forcedPosition,
53
+ placement = 'bottom-start',
51
54
  className,
52
55
  ...props
53
56
  }: InfoBoxProps) => {
54
- const triggerRef = useRef<HTMLButtonElement>(null);
55
- const contentRef = useRef<HTMLDialogElement>(null);
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={triggerRef}
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
- {isMounted &&
143
- isOpen &&
144
- createPortal(
145
- <FocusTrap active={isTrapActive} focusTrapOptions={focusTrapConfig}>
146
- <dialog
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
- top: contentPosition.top,
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
- </dialog>
172
- </FocusTrap>,
173
- document.body,
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
+ };
@@ -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
+ });