diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index 68fc06689..79ca5e9ec 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -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 diff --git a/packages/void/src/pages/link.ts b/packages/void/src/pages/link.ts index 2c9599c68..c7bd3b27d 100644 --- a/packages/void/src/pages/link.ts +++ b/packages/void/src/pages/link.ts @@ -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, @@ -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 !== '/') { @@ -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( @@ -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; diff --git a/packages/void/test/unit/pages-link.test.ts b/packages/void/test/unit/pages-link.test.ts index 4c40cb593..efaeceb66 100644 --- a/packages/void/test/unit/pages-link.test.ts +++ b/packages/void/test/unit/pages-link.test.ts @@ -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', () => { diff --git a/test/pages-client-contract.ts b/test/pages-client-contract.ts index 6278fbcfd..9182500fb 100644 --- a/test/pages-client-contract.ts +++ b/test/pages-client-contract.ts @@ -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 [ @@ -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'); @@ -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(); }