Skip to content

Commit e48ba95

Browse files
Navlink component (#996)
* Implemented Navlink for internal navigation and use it Footer * Refactor Footer and Navlink components to use MemoryRouter for improved routing context in tests * Refactor Navlink component to use Link component as fallback * Refactor imports in Footer and Navlink components to use 'react-router' instead of 'react-router-dom' * Refactor Footer.spec.tsx to import MemoryRouter from 'react-router' instead of 'react-router-dom' * Updated import sections * Refactor Navlink component to remove forwardRef and simplify function definition * Prettier code * Add tests for Navlink component functionality and rendering * Prettier code * Update MemoryRouter import to use 'react-router' instead of 'react-router-dom' * Update mock for Link component to include fallback text
1 parent 32881bb commit e48ba95

File tree

4 files changed

+224
-22
lines changed

4 files changed

+224
-22
lines changed

packages/pxweb2/src/app/components/Footer/Footer.spec.tsx

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,12 @@ import React from 'react';
22
import { render, screen } from '@testing-library/react';
33
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
44
import '@testing-library/jest-dom/vitest';
5+
import { MemoryRouter } from 'react-router';
56

67
import { Footer, scrollToTop } from './Footer';
78
import { useLocaleContent } from '../../util/hooks/useLocaleContent';
89

910
let currentPathname = '/en/tables';
10-
const navigateMock = vi.fn();
11-
12-
vi.mock('react-router', () => ({
13-
useNavigate: () => navigateMock,
14-
useLocation: () => ({ pathname: currentPathname }),
15-
}));
1611

1712
vi.mock('../../util/hooks/useLocaleContent', () => ({
1813
useLocaleContent: vi.fn(),
@@ -64,26 +59,38 @@ describe('Footer', () => {
6459

6560
it('renders footer columns and links from mocked hook', async () => {
6661
(useLocaleContent as Mock).mockReturnValue(footerContent);
67-
render(<Footer />);
62+
render(
63+
<MemoryRouter initialEntries={[currentPathname]}>
64+
<Footer />
65+
</MemoryRouter>,
66+
);
6867

69-
footerContent.footer.columns.forEach((col) => {
68+
for (const col of footerContent.footer.columns) {
7069
expect(screen.getByText(col.header)).toBeInTheDocument();
71-
col.links.forEach((link) => {
70+
for (const link of col.links) {
7271
expect(screen.getByText(link.text)).toBeInTheDocument();
73-
});
74-
});
72+
}
73+
}
7574
});
7675

7776
it('applies variant--tableview on the <footer> when variant="tableview"', () => {
7877
(useLocaleContent as Mock).mockReturnValue(footerContent);
79-
render(<Footer variant="tableview" />);
78+
render(
79+
<MemoryRouter initialEntries={[currentPathname]}>
80+
<Footer variant="tableview" />
81+
</MemoryRouter>,
82+
);
8083
const footer = screen.getByRole('contentinfo');
8184
expect(footer.className).toMatch(/variant--tableview/);
8285
});
8386

8487
it('uses variant--generic by default', () => {
8588
(useLocaleContent as Mock).mockReturnValue(footerContent);
86-
render(<Footer />);
89+
render(
90+
<MemoryRouter initialEntries={[currentPathname]}>
91+
<Footer />
92+
</MemoryRouter>,
93+
);
8794
const footer = screen.getByRole('contentinfo');
8895
expect(footer.className).toMatch(/variant--generic/);
8996
});
@@ -116,23 +123,35 @@ describe('Footer', () => {
116123
const ref = {
117124
current: document.createElement('div'),
118125
} as React.RefObject<HTMLDivElement>;
119-
render(<Footer containerRef={ref} />);
126+
render(
127+
<MemoryRouter initialEntries={[currentPathname]}>
128+
<Footer containerRef={ref} />
129+
</MemoryRouter>,
130+
);
120131
expect(
121132
screen.getByRole('button', { name: /common.footer.top_button_text/i }),
122133
).toBeInTheDocument();
123134
});
124135

125136
it('shows Top button when enableWindowScroll is true (no containerRef)', () => {
126137
(useLocaleContent as Mock).mockReturnValue(footerContent);
127-
render(<Footer enableWindowScroll />);
138+
render(
139+
<MemoryRouter initialEntries={[currentPathname]}>
140+
<Footer enableWindowScroll />
141+
</MemoryRouter>,
142+
);
128143
expect(
129144
screen.getByRole('button', { name: /common.footer.top_button_text/i }),
130145
).toBeInTheDocument();
131146
});
132147

133148
it('hides Top button when neither containerRef nor enableWindowScroll', () => {
134149
(useLocaleContent as Mock).mockReturnValue(footerContent);
135-
render(<Footer />);
150+
render(
151+
<MemoryRouter initialEntries={[currentPathname]}>
152+
<Footer />
153+
</MemoryRouter>,
154+
);
136155
expect(
137156
screen.queryByRole('button', {
138157
name: /common.footer.top_button_text/i,
@@ -145,7 +164,11 @@ describe('Footer', () => {
145164
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {
146165
vi.fn();
147166
});
148-
render(<Footer enableWindowScroll />);
167+
render(
168+
<MemoryRouter initialEntries={[currentPathname]}>
169+
<Footer enableWindowScroll />
170+
</MemoryRouter>,
171+
);
149172

150173
const btn = screen.getByRole('button', {
151174
name: /common.footer.top_button_text/i,

packages/pxweb2/src/app/components/Footer/Footer.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ import { getConfig } from '../../util/config/getConfig';
88
import { getLanguagePath } from '../../util/language/getLanguagePath';
99
import { BodyShort, Button, Heading, Link } from '@pxweb2/pxweb2-ui';
1010
import { useLocaleContent } from '../../util/hooks/useLocaleContent';
11+
import Navlink from '../Navlink/Navlink';
12+
13+
function useSafeLocation(): { pathname: string } {
14+
try {
15+
// Attempt router location
16+
return useLocation() as { pathname: string };
17+
} catch {
18+
// Fallback to global location when outside Router
19+
return { pathname: globalThis.location?.pathname || '/' };
20+
}
21+
}
1122

1223
type FooterProps = {
1324
containerRef?: React.RefObject<HTMLDivElement | null>;
@@ -47,7 +58,7 @@ export const Footer: React.FC<FooterProps> = ({
4758
const footerContent = content?.footer;
4859
// Ref for the main scrollable container
4960
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
50-
const location = useLocation();
61+
const location = useSafeLocation();
5162

5263
const canShowTopButton = !!containerRef || enableWindowScroll;
5364

@@ -122,8 +133,8 @@ export const Footer: React.FC<FooterProps> = ({
122133
lang.shorthand,
123134
);
124135
return (
125-
<Link
126-
href={languageHref}
136+
<Navlink
137+
to={languageHref}
127138
size="medium"
128139
key={lang.shorthand}
129140
lang={lang.shorthand}
@@ -132,11 +143,10 @@ export const Footer: React.FC<FooterProps> = ({
132143
if (!isCurrent) {
133144
i18n.changeLanguage(lang.shorthand);
134145
}
135-
// Allow default navigation (no preventDefault) so URL updates
136146
}}
137147
>
138148
{lang.languageName || lang.shorthand.toUpperCase()}
139-
</Link>
149+
</Navlink>
140150
);
141151
},
142152
)}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React, { createRef } from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { MemoryRouter } from 'react-router';
5+
import '@testing-library/jest-dom/vitest';
6+
7+
import Navlink from './Navlink';
8+
9+
// Mock pxweb2-ui Link and Icon for fallback
10+
vi.mock('@pxweb2/pxweb2-ui', () => ({
11+
Icon: (props: Record<string, unknown>) => (
12+
<span data-testid="icon" {...props} />
13+
),
14+
Link: (props: Record<string, unknown>) => (
15+
<a data-testid="ui-link" {...props}>
16+
test
17+
</a>
18+
),
19+
}));
20+
21+
describe('Navlink', () => {
22+
it('renders as RouterNavLink inside router', () => {
23+
render(
24+
<MemoryRouter>
25+
<Navlink to="/test">Test Link</Navlink>
26+
</MemoryRouter>,
27+
);
28+
const link = screen.getByRole('link', { name: 'Test Link' });
29+
expect(link).toBeInTheDocument();
30+
expect(link.getAttribute('href')).toBe('/test');
31+
});
32+
33+
it('renders as pxweb2-ui Link outside router', () => {
34+
render(<Navlink to="/plain">Plain Link</Navlink>);
35+
const link = screen.getByTestId('ui-link');
36+
expect(link).toBeInTheDocument();
37+
expect(link.getAttribute('href')).toBe('/plain');
38+
});
39+
40+
it('applies className and size', () => {
41+
render(
42+
<MemoryRouter>
43+
<Navlink to="/class" size="medium" className="custom-class">
44+
Class Link
45+
</Navlink>
46+
</MemoryRouter>,
47+
);
48+
const link = screen.getByRole('link', { name: 'Class Link' });
49+
expect(link.className).toMatch(/custom-class/);
50+
expect(link.className).toMatch(/bodyshort-medium/);
51+
});
52+
53+
it('forwards ref to the link', () => {
54+
const ref = createRef<HTMLAnchorElement>();
55+
render(
56+
<MemoryRouter>
57+
<Navlink to="/ref" ref={ref}>
58+
Ref Link
59+
</Navlink>
60+
</MemoryRouter>,
61+
);
62+
expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
63+
expect(ref.current?.getAttribute('href')).toBe('/ref');
64+
});
65+
66+
it('renders icon if provided', () => {
67+
render(
68+
<MemoryRouter>
69+
<Navlink to="/icon" icon="ExternalLink" iconPosition="start">
70+
Icon Link
71+
</Navlink>
72+
</MemoryRouter>,
73+
);
74+
expect(screen.getByTestId('icon')).toBeInTheDocument();
75+
});
76+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React from 'react';
2+
import {
3+
NavLink as RouterNavLink,
4+
NavLinkProps as RouterNavLinkProps,
5+
useInRouterContext,
6+
} from 'react-router';
7+
import cl from 'clsx';
8+
9+
import classes from '$ui/src/lib/components/Link/Link.module.scss';
10+
import { Icon, IconProps, Link as UiLink } from '@pxweb2/pxweb2-ui';
11+
12+
interface NavlinkProps extends Omit<RouterNavLinkProps, 'to'> {
13+
to: string;
14+
size?: 'small' | 'medium';
15+
icon?: IconProps['iconName'];
16+
iconPosition?: 'start' | 'end';
17+
children: React.ReactNode;
18+
inline?: boolean;
19+
noUnderline?: boolean;
20+
}
21+
22+
export function Navlink({
23+
children,
24+
to,
25+
size,
26+
icon,
27+
iconPosition,
28+
inline = false,
29+
noUnderline = false,
30+
className,
31+
ref,
32+
...rest
33+
}: NavlinkProps & { ref?: React.Ref<HTMLAnchorElement> }) {
34+
const inRouter = useInRouterContext();
35+
36+
const commonClassName = cl(
37+
classes.link,
38+
{
39+
[classes.no_underline]: noUnderline,
40+
[classes.inline]: inline,
41+
[classes[`bodyshort-${size}`]]: size,
42+
[classes[`padding-${size}`]]: size,
43+
},
44+
className,
45+
);
46+
47+
if (!inRouter) {
48+
// Fallback to regular anchor if no router context (e.g., isolated component tests)
49+
const { style, ...anchorRest } = rest as Record<string, unknown>;
50+
const anchorStyle =
51+
typeof style === 'function'
52+
? style({ isActive: false, isPending: false })
53+
: style;
54+
return (
55+
<UiLink
56+
href={to}
57+
ref={ref}
58+
className={commonClassName}
59+
style={anchorStyle}
60+
{...anchorRest}
61+
>
62+
{icon && iconPosition === 'start' && (
63+
<Icon iconName={icon} className={cl(classes.icon)} />
64+
)}
65+
{children}
66+
{icon && iconPosition === 'end' && (
67+
<Icon iconName={icon} className={cl(classes.icon)} />
68+
)}
69+
</UiLink>
70+
);
71+
}
72+
73+
return (
74+
<RouterNavLink
75+
to={to}
76+
className={({ isActive }) =>
77+
cl(commonClassName, { [classes.active]: isActive })
78+
}
79+
ref={ref}
80+
{...rest}
81+
>
82+
{icon && iconPosition === 'start' && (
83+
<Icon iconName={icon} className={cl(classes.icon)} />
84+
)}
85+
{children}
86+
{icon && iconPosition === 'end' && (
87+
<Icon iconName={icon} className={cl(classes.icon)} />
88+
)}
89+
</RouterNavLink>
90+
);
91+
}
92+
93+
export default Navlink;

0 commit comments

Comments
 (0)