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
13 changes: 12 additions & 1 deletion docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,18 @@ A link is active only on its own path by default. Set `activeMatch="prefix"` to

`aria-current="page"` marks only the exact page, so select `[aria-current="page"]` to style it differently from its section. A link to `/` is active only on the home page.

Paths match the way pages are routed: letter case and trailing slashes count, while the query string and hash are ignored. Links to other origins and non-GET links are never active. An `aria-current` or `data-void-active` you pass to `Link` replaces the computed value.
A link without a query ignores the current query, as `/users?page=2` shows above. A link with a query is active only when the current URL has each of its params with the same value. The current URL can have other params, in any order, and a GET link's `data` counts as part of its query. Pagination links to other pages are not marked, while a filter tab stays active as you page through it:

| Current URL | Link | Active |
| ---------------------------- | ------------------------------ | --------------- |
| `/posts?page=2` | `href="/posts?page=1"` | none |
| `/posts?page=2` | `href="/posts?page=2"` | both attributes |
| `/issues?status=open&page=2` | `href="/issues?status=open"` | both attributes |
| `/issues?status=open` | `href="/issues?status=closed"` | none |

On nested pages, `activeMatch="prefix"` compares only the path, so `href="/reports?range=30d"` has `data-void-active` on `/reports/42`.

Paths match the way pages are routed: letter case and trailing slashes count. The hash is ignored. Links to other origins and non-GET links are never active. An `aria-current` or `data-void-active` you pass to `Link` replaces the computed value. Pass them yourself on a link that stands for a default value, such as an "All" tab at `/issues`, or a `?page=1` link when page 1 is `/posts`.

### Pending links

Expand Down
29 changes: 22 additions & 7 deletions packages/void/src/pages/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,11 @@ export function linkPrefetchStrategies(
/**
* Marks a GET link to the current page. Pathnames compare the way the server
* matches pages: case-sensitive, trailing slash significant, decoded like Hono's
* getPath. The query and hash are ignored. `prefix` also matches nested pages by
* whole segment, except for `/`, but only an exact match is `aria-current`.
* Spread the result before the user's attributes so theirs win.
* getPath. A link with a query also needs each of its params, with the same value,
* in the current URL, which may have more params in any order. GET data counts as
* query, and the hash is ignored. `prefix` also matches nested pages by whole
* segment, except for `/`, comparing only the path, but only an exact match is
* `aria-current`. Spread the result before the user's attributes so theirs win.
*/
export function linkActiveAttributes(
router: VoidRouter | null | undefined,
Expand All @@ -145,7 +147,10 @@ export function linkActiveAttributes(
const currentPath = decodePagePath(current.pathname);
const linkPath = decodePagePath(target.pathname);
if (linkPath === currentPath) {
return { 'aria-current': 'page', 'data-void-active': '' };
// A link that only changes the query, such as pagination, leads to another page.
return hasQueryParams(current.searchParams, target.searchParams)
? { 'aria-current': 'page', 'data-void-active': '' }
: {};
}

if (match === 'prefix' && linkPath !== '/') {
Expand All @@ -160,9 +165,9 @@ export function linkActiveAttributes(

/**
* Marks a link while a visit to its destination is loading: the visit uses the link's
* method and goes to its path, query and hash. Unlike active links, the query counts,
* so `?page=2` isn't pending during a visit to `?page=3`. Every link to the destination
* is marked, not only the one that was clicked.
* method and goes to its path, query and hash. Unlike active links, the whole query and
* the hash count, so `?tab=open` isn't pending during a visit to `?tab=open&page=2`.
* Every link to the destination is marked, not only the one that was clicked.
* Spread the result before the user's attributes so theirs win.
*/
export function linkPendingAttributes(
Expand Down Expand Up @@ -195,6 +200,16 @@ export function linkPendingAttributes(
return {};
}

/** Each of `params` has the same value in `current`. Extra params in `current` are allowed. */
function hasQueryParams(current: URLSearchParams, params: URLSearchParams): boolean {
for (const [key, value] of params) {
if (!current.getAll(key).includes(value)) {
return false;
}
}
return true;
}

/** Resolves `href` against `base`, or returns `null` for a malformed or other-origin href. */
function resolveSameOrigin(href: string, base: URL): URL | null {
let target: URL;
Expand Down
55 changes: 51 additions & 4 deletions packages/void/test/unit/pages-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,59 @@ describe('linkActiveAttributes', () => {
expect(attributes('/admin/users', { href: '/' }, 'prefix')).toEqual({});
});

it('ignores the query and hash', () => {
expect(attributes('/inbox?page=2', { href: '/inbox' })).toEqual(page);
expect(attributes('/settings?tab=billing', { href: '/settings?tab=profile' })).toEqual(page);
expect(attributes('/search', { href: '/search', data: { q: 'void' } })).toEqual(page);
it('ignores the query for links without one', () => {
expect(attributes('/posts?page=2', { href: '/posts' })).toEqual(page);
expect(attributes('/posts?sort=new', { href: '/posts?' })).toEqual(page);
});

it('requires each param of the link query in the current URL', () => {
expect(attributes('/posts?page=2', { href: '/posts?page=1' })).toEqual({});
expect(attributes('/posts?page=2', { href: '/posts?page=3' })).toEqual({});
expect(attributes('/posts?page=2', { href: '/posts?page=2' })).toEqual(page);
expect(attributes('/posts', { href: '/posts?page=1' })).toEqual({});
expect(attributes('/issues?status=open&page=2', { href: '/issues?status=open' })).toEqual(page);
expect(attributes('/issues?page=2&status=open', { href: '/issues?status=open' })).toEqual(page);
expect(attributes('/issues?status=open', { href: '/issues?status=closed' })).toEqual({});
expect(attributes('/issues?status=open', { href: '/issues?status=open&page=2' })).toEqual({});
expect(attributes('/issues?status=', { href: '/issues?status=' })).toEqual(page);
expect(attributes('/issues', { href: '/issues?status=' })).toEqual({});
});

it('compares decoded query values and repeated params', () => {
expect(attributes('/search?q=void+pages', { href: '/search?q=void%20pages' })).toEqual(page);
expect(attributes('/search?tag=a&tag=b', { href: '/search?tag=b' })).toEqual(page);
expect(attributes('/search?tag=a&tag=b', { href: '/search?tag=b&tag=a' })).toEqual(page);
expect(attributes('/search?tag=a', { href: '/search?tag=a&tag=b' })).toEqual({});
expect(attributes('/search?Tag=a', { href: '/search?tag=a' })).toEqual({});
});

it('treats GET data as part of the link query', () => {
expect(attributes('/search', { href: '/search', data: { q: 'void' } })).toEqual({});
expect(attributes('/search?q=void', { href: '/search', data: { q: 'void' } })).toEqual(page);
expect(
attributes('/search?q=void&page=2', { href: '/search?q=void', data: { page: 3 } }),
).toEqual({});
});

it('resolves query-only and hash-only hrefs against the current URL', () => {
expect(attributes('/posts?page=2', { href: '?page=3' })).toEqual({});
expect(attributes('/posts?page=2', { href: '?page=2' })).toEqual(page);
expect(attributes('/docs?v=2#install', { href: '#usage' })).toEqual(page);
});

it('ignores the hash', () => {
expect(attributes('/docs', { href: '/docs#install' })).toEqual(page);
expect(attributes('/docs#install', { href: '#usage' })).toEqual(page);
expect(attributes('/docs?v=2#install', { href: '/docs?v=2#usage' })).toEqual(page);
});

it('compares only the path on nested pages with prefix', () => {
expect(attributes('/reports/42', { href: '/reports?range=30d' }, 'prefix')).toEqual(nested);
expect(attributes('/reports?range=30d', { href: '/reports?range=30d' }, 'prefix')).toEqual(
page,
);
expect(attributes('/reports?range=7d', { href: '/reports?range=30d' }, 'prefix')).toEqual({});
expect(attributes('/admin/?tab=a', { href: '/admin/?tab=b' }, 'prefix')).toEqual({});
});

it('compares paths the way the router matches them', () => {
Expand Down
6 changes: 5 additions & 1 deletion test/pages-client-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export function registerSharedClientTests({
'absolute-current-link',
'protocol-relative-current-link',
'external-localhost-link',
'query-only-link',
].map((id) => {
const link = root.querySelector(`[data-testid="${id}"]`)!;
return [
Expand All @@ -200,6 +201,8 @@ export function registerSharedClientTests({
[`${BASE}/link-behavior`, 'page', ''],
[`//${new URL(BASE).host}/link-behavior`, 'page', ''],
['http://localhost/link-behavior', null, null],
// A link with a query is current only once the URL has its params.
['?view=compact', null, null],
];
try {
const response = await page.goto(BASE + '/link-behavior');
Expand All @@ -216,7 +219,8 @@ export function registerSharedClientTests({
expect(pageUrl).toBe('/link-behavior');
await page.getByTestId('query-only-link').click();
await page.waitForURL(BASE + '/link-behavior?view=compact');
expect(await links()).toEqual(expected);
await page.waitForSelector('[data-testid="query-only-link"][aria-current="page"]');
expect(await links()).toEqual([...expected.slice(0, 3), ['?view=compact', 'page', '']]);
} finally {
await page.context().close();
}
Expand Down
Loading