Skip to content

Feat: active (nav) links #419

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 8, 2024
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
25 changes: 17 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,13 @@ Link will always wrap its children in an `<a />` tag, unless `asChild` prop is p
// in order for navigation to work!
```

Active links are not yet shipped out-of-the-box, but you can easily [implement them](#how-do-i-make-a-link-active-for-the-current-route) using `useLocation`.
When you pass a function as a `className` prop, it will be called with a boolean value indicating whether the link is active for the current route. You can use this to style active links (e.g. for links in navigation menu)

```jsx
<Link className={(active) => (active ? "active" : "")}>Nav</Link>
```

Read more about [active links here](#how-do-i-make-a-link-active-for-the-current-route).

### `<Switch />`

Expand Down Expand Up @@ -615,18 +621,21 @@ If you want to have access to the matched segment of the path you can use wildca

### How do I make a link active for the current route?

There are cases when you need to highlight an active link, for example, in the navigation bar. While
this functionality isn't provided out-of-the-box, you can easily write your own `<Link />` wrapper
and detect if the path is active by using the `useRoute` hook. The `useRoute(pattern)` hook returns
a pair of `[match, params]`, where `match` is a boolean value that tells if the pattern matches
current location:
Instead of a regular `className` string, provide a function to use custom class when this link matches the current route. Note that it will always perform an exact match (i.e. `/users` will not be active for `/users/1`).

```jsx
<Link className={(active) => (active ? "active" : "")}>Nav link</Link>
```

If you need to control other props, such as `aria-current` or `style`, you can write your own `<Link />` wrapper
and detect if the path is active by using the `useRoute` hook.

```js
const [isActive] = useRoute(props.href);

return (
<Link {...props}>
<a className={isActive ? "active" : ""}>{props.children}</a>
<Link {...props} asChild>
<a style={isActive ? { color: "red" } : {}}>{props.children}</a>
</Link>
);
```
Expand Down
6 changes: 5 additions & 1 deletion packages/wouter-preact/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,15 @@ type AsChildProps<ComponentProps, DefaultElementProps> =
| ({ asChild?: false } & DefaultElementProps)
| ({ asChild: true } & ComponentProps);

type HTMLLinkAttributes = Omit<JSX.HTMLAttributes, "className"> & {
className?: string | undefined | ((isActive: boolean) => string | undefined);
};

export type LinkProps<H extends BaseLocationHook = BrowserLocationHook> =
NavigationalProps<H> &
AsChildProps<
{ children: ComponentChildren; onClick?: JSX.MouseEventHandler<Element> },
JSX.HTMLAttributes
HTMLLinkAttributes
>;

export type RedirectProps<H extends BaseLocationHook = BrowserLocationHook> =
Expand Down
18 changes: 14 additions & 4 deletions packages/wouter/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,18 +178,20 @@ export const Route = ({ path, nest, match, ...renderProps }) => {

export const Link = forwardRef((props, ref) => {
const router = useRouter();
const [, navigate] = useLocationFromRouter(router);
const [path, navigate] = useLocationFromRouter(router);

const {
to,
href: _href = to,
onClick: _onClick,
asChild,
children,
className: cls,
/* eslint-disable no-unused-vars */
replace /* ignore nav props */,
state /* ignore nav props */,
/* eslint-enable no-unused-vars */

...restProps
} = props;

Expand All @@ -205,7 +207,7 @@ export const Link = forwardRef((props, ref) => {
)
return;

_onClick && _onClick(event); // TODO: is it safe to use _onClick?.(event)
_onClick?.(event);
if (!event.defaultPrevented) {
event.preventDefault();
navigate(_href, props);
Expand All @@ -216,8 +218,16 @@ export const Link = forwardRef((props, ref) => {
const href = _href[0] === "~" ? _href.slice(1) : router.base + _href;

return asChild && isValidElement(children)
? cloneElement(children, { href, onClick })
: h("a", { ...restProps, href, onClick, children, ref });
? cloneElement(children, { onClick, href })
: h("a", {
...restProps,
onClick,
href,
// `className` can be a function to apply the class if this link is active
className: cls?.call ? cls(path === href) : cls,
children,
ref,
});
});

const flattenChildren = (children) => {
Expand Down
12 changes: 12 additions & 0 deletions packages/wouter/test/link.test-d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ describe("<Link /> types", () => {
</Link>;
});

it("can accept function as `className`", () => {
<Link
href="/"
className={(isActive) => (isActive ? "active" : "non-active")}
/>;

<Link
href="/"
className={(isActive) => (isActive ? "active" : undefined)}
/>;
});

it("should support other navigation params", () => {
<Link href="/" state={{ a: "foo" }}>
test
Expand Down
43 changes: 42 additions & 1 deletion packages/wouter/test/link.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { type MouseEventHandler } from "react";
import { it, expect, afterEach, vi, describe } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { render, cleanup, fireEvent, act } from "@testing-library/react";

import { Router, Link } from "wouter";
import { memoryLocation } from "wouter/memory-location";

afterEach(cleanup);

Expand Down Expand Up @@ -166,6 +167,46 @@ describe("<Link />", () => {
});
});

describe("active links", () => {
it("proxies `className` when it is a string", () => {
const { getByText } = render(
<Link href="/" className="link--active warning">
Click Me
</Link>
);

const element = getByText("Click Me");
expect(element).toHaveAttribute("class", "link--active warning");
});

it("calls the `className` function with active link flag", () => {
const { navigate, hook } = memoryLocation({ path: "/" });

const { getByText } = render(
<Router hook={hook}>
<Link
href="/"
className={(isActive) => {
return [isActive ? "active" : "", "link"].join(" ");
}}
>
Click Me
</Link>
</Router>
);

const element = getByText("Click Me");
expect(element).toBeInTheDocument();
expect(element).toHaveClass("active");
expect(element).toHaveClass("link");

act(() => navigate("/about"));

expect(element).not.toHaveClass("active");
expect(element).toHaveClass("link");
});
});

describe("<Link /> with `asChild` prop", () => {
it("when `asChild` is not specified, wraps the children in an <a />", () => {
const { getByText } = render(
Expand Down
9 changes: 8 additions & 1 deletion packages/wouter/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,18 @@ type AsChildProps<ComponentProps, DefaultElementProps> =
| ({ asChild?: false } & DefaultElementProps)
| ({ asChild: true } & ComponentProps);

type HTMLLinkAttributes = Omit<
AnchorHTMLAttributes<HTMLAnchorElement>,
"className"
> & {
className?: string | undefined | ((isActive: boolean) => string | undefined);
};

export type LinkProps<H extends BaseLocationHook = BrowserLocationHook> =
NavigationalProps<H> &
AsChildProps<
{ children: ReactElement; onClick?: MouseEventHandler },
AnchorHTMLAttributes<HTMLAnchorElement> & RefAttributes<HTMLAnchorElement>
HTMLLinkAttributes & RefAttributes<HTMLAnchorElement>
>;

export function Link<H extends BaseLocationHook = BrowserLocationHook>(
Expand Down