Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,42 @@ describe('ActiveLink', () => {
'link active'
);
});

it('forces active class when active override is true regardless of pathname', async () => {
render(
<ActiveLink
className="link"
activeClassName="active"
href="/link"
pathname="/not-link"
active
>
Link
</ActiveLink>
);

assert.equal(
(await screen.findByText('Link')).getAttribute('class'),
'link active'
);
});

it('forces inactive when active override is false even when pathname matches', async () => {
render(
<ActiveLink
className="link"
activeClassName="active"
href="/link"
pathname="/link"
active={false}
>
Link
</ActiveLink>
);

assert.equal(
(await screen.findByText('Link')).getAttribute('class'),
'link'
);
});
});
13 changes: 9 additions & 4 deletions packages/ui-components/src/Common/BaseActiveLink/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type ActiveLocalizedLinkProps = ComponentProps<LinkLike> & {
activeClassName?: string;
allowSubPath?: boolean;
pathname?: string;
active?: boolean;
as?: LinkLike;
};

Expand All @@ -16,17 +17,21 @@ const BaseActiveLink: FC<ActiveLocalizedLinkProps> = ({
className,
href = '',
pathname = '/',
active,
as: Component = 'a',
...props
}) => {
const finalClassName = classNames(className, {
[activeClassName]: allowSubPath
// NavBar sets `active` explicitly; if it's not passed we fall back to the path check.
const isActive =
active ??
(allowSubPath
? // When using allowSubPath we want only to check if
// the current pathname starts with the utmost upper level
// of an href (e.g. /docs/...)
pathname.startsWith(`/${href.toString().split('/')[1]}`)
: href.toString() === pathname,
});
: href.toString() === pathname);

const finalClassName = classNames(className, { [activeClassName]: isActive });

return <Component className={finalClassName} href={href} {...props} />;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type NavItemProps = {
target?: HTMLAttributeAnchorTarget | undefined;

pathname: string;
active?: boolean;
as: LinkLike;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import { getActiveNavLink } from '..';

const navItems = [
{ link: '/about' },
{ link: '/about/sponsors' },
{ link: '/docs' },
{ link: 'https://nodejs.org/learn' },
];

describe('getActiveNavLink', () => {
it('picks the most specific sibling on its own pages', () => {
assert.equal(
getActiveNavLink(navItems, '/about/sponsors'),
'/about/sponsors'
);
});

it('keeps the parent active on its other sub-pages', () => {
assert.equal(
getActiveNavLink(navItems, '/about/governance/charter'),
'/about'
);
});

it('matches the parent on its own index page', () => {
assert.equal(getActiveNavLink(navItems, '/about'), '/about');
});

it('does not match across word boundaries', () => {
assert.equal(getActiveNavLink(navItems, '/about-us'), '');
});

it('never returns an external (non-internal) link', () => {
assert.equal(getActiveNavLink(navItems, 'https://nodejs.org/learn'), '');
});

it('returns empty string when nothing matches', () => {
assert.equal(getActiveNavLink(navItems, '/unknown'), '');
});
});
15 changes: 15 additions & 0 deletions packages/ui-components/src/Containers/NavBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ const navInteractionIcons = {
close: <XMark className={styles.navInteractionIcon} />,
};

// Picks the longest nav link that prefixes the current path, so `/about` and `/about/sponsors` never highlight together.
export const getActiveNavLink = (
navItems: Array<{ link: string }>,
pathname: string
): string =>
navItems.reduce((longest, { link }) => {
const isMatch =
link.startsWith('/') &&
(pathname === link || pathname.startsWith(`${link}/`));
return isMatch && link.length > longest.length ? link : longest;
}, '');

type NavbarProps = {
navItems?: Array<{
text: FormattedMessage;
Expand All @@ -43,6 +55,8 @@ const NavBar: FC<PropsWithChildren<NavbarProps>> = ({
}) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);

const activeLink = getActiveNavLink(navItems ?? [], pathname);

return (
<nav className={styles.container}>
<div className={styles.innerContainer}>
Expand Down Expand Up @@ -79,6 +93,7 @@ const NavBar: FC<PropsWithChildren<NavbarProps>> = ({
{navItems.map(({ text, link, target }) => (
<NavItem
pathname={pathname}
active={link === activeLink}
as={Component}
key={link}
href={link}
Expand Down
Loading