Skip to content

Commit dbd5f59

Browse files
authored
feat: allow matching navigation hierarchies with side nav item (#7693)
* feat: allow matching navigation hierarchies with side nav item * cleanup * rename property to matchExact * address review comments * fix test case
1 parent 11494cd commit dbd5f59

File tree

6 files changed

+146
-11
lines changed

6 files changed

+146
-11
lines changed

packages/component-base/src/url-utils.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,17 @@ function containsQueryParams(actual, expected) {
2727
*
2828
* @param {string} actual The actual URL to match.
2929
* @param {string} expected The expected URL to match.
30+
* @param {Object} matchOptions Options for path matching.
3031
*/
31-
export function matchPaths(actual, expected) {
32+
export function matchPaths(actual, expected, matchOptions = { matchNested: false }) {
3233
const base = document.baseURI;
3334
const actualUrl = new URL(actual, base);
3435
const expectedUrl = new URL(expected, base);
3536

36-
return (
37-
actualUrl.origin === expectedUrl.origin &&
38-
actualUrl.pathname === expectedUrl.pathname &&
39-
containsQueryParams(actualUrl.searchParams, expectedUrl.searchParams)
40-
);
37+
const matchesOrigin = actualUrl.origin === expectedUrl.origin;
38+
const matchesPath = matchOptions.matchNested
39+
? actualUrl.pathname === expectedUrl.pathname || actualUrl.pathname.startsWith(`${expectedUrl.pathname}/`)
40+
: actualUrl.pathname === expectedUrl.pathname;
41+
42+
return matchesOrigin && matchesPath && containsQueryParams(actualUrl.searchParams, expectedUrl.searchParams);
4143
}

packages/component-base/test/url-utils.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,29 @@ describe('url-utils', () => {
5050
expect(matchPaths('https://vaadin.com/docs/components', 'components')).to.be.true;
5151
});
5252

53+
describe('matchNested option', () => {
54+
it('should match the exact path by default', () => {
55+
expect(matchPaths('/users', '/users')).to.be.true;
56+
expect(matchPaths('/users/', '/users')).to.be.false;
57+
expect(matchPaths('/users/john', '/users')).to.be.false;
58+
expect(matchPaths('/usersessions', '/users')).to.be.false;
59+
});
60+
61+
it('should match the exact path when matchNested is false', () => {
62+
expect(matchPaths('/users', '/users', { matchNested: false })).to.be.true;
63+
expect(matchPaths('/users/', '/users', { matchNested: false })).to.be.false;
64+
expect(matchPaths('/users/john', '/users', { matchNested: false })).to.be.false;
65+
expect(matchPaths('/usersessions', '/users', { matchNested: false })).to.be.false;
66+
});
67+
68+
it('should match nested paths when matchNested is true', () => {
69+
expect(matchPaths('/users', '/users', { matchNested: true })).to.be.true;
70+
expect(matchPaths('/users/', '/users', { matchNested: true })).to.be.true;
71+
expect(matchPaths('/users/john', '/users', { matchNested: true })).to.be.true;
72+
expect(matchPaths('/usersessions', '/users', { matchNested: true })).to.be.false;
73+
});
74+
});
75+
5376
describe('query params', () => {
5477
it('should return true when query params match', () => {
5578
expect(matchPaths('/products', '/products')).to.be.true;

packages/side-nav/src/location.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2023 - 2024 Vaadin Ltd.
4+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5+
*/
6+
7+
/**
8+
* Facade for `document.location`, can be stubbed for testing.
9+
*
10+
* For internal use only.
11+
*/
12+
export const location = {
13+
get pathname() {
14+
return document.location.pathname;
15+
},
16+
get search() {
17+
return document.location.search;
18+
},
19+
};

packages/side-nav/src/vaadin-side-nav-item.d.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,28 @@ declare class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixi
9393
*/
9494
expanded: boolean;
9595

96+
/**
97+
* Whether to also match nested paths / routes. `false` by default.
98+
*
99+
* When enabled, an item with the path `/path` is considered current when
100+
* the browser URL is `/path`, `/path/child`, `/path/child/grandchild`,
101+
* etc.
102+
*
103+
* Note that this only affects matching of the URLs path, not the base
104+
* origin or query parameters.
105+
*
106+
* @attr {boolean} match-nested
107+
*/
108+
matchNested: boolean;
109+
96110
/**
97111
* Whether the item's path matches the current browser URL.
98112
*
99113
* A match occurs when both share the same base origin (like https://example.com),
100-
* the same path (like /path/to/page), and the browser URL contains all
101-
* the query parameters with the same values from the item's path.
114+
* the same path (like /path/to/page), and the browser URL contains at least
115+
* all the query parameters with the same values from the item's path.
116+
*
117+
* See [`matchNested`](#/elements/vaadin-side-nav-item#property-matchNested) for how to change the path matching behavior.
102118
*
103119
* The state is updated when the item is added to the DOM or when the browser
104120
* navigates to a new page.

packages/side-nav/src/vaadin-side-nav-item.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
1212
import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
1313
import { matchPaths } from '@vaadin/component-base/src/url-utils.js';
1414
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
15+
import { location } from './location.js';
1516
import { sideNavItemBaseStyles } from './vaadin-side-nav-base-styles.js';
1617
import { SideNavChildrenMixin } from './vaadin-side-nav-children-mixin.js';
1718

@@ -114,13 +115,33 @@ class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixin(Themab
114115
reflectToAttribute: true,
115116
},
116117

118+
/**
119+
* Whether to also match nested paths / routes. `false` by default.
120+
*
121+
* When enabled, an item with the path `/path` is considered current when
122+
* the browser URL is `/path`, `/path/child`, `/path/child/grandchild`,
123+
* etc.
124+
*
125+
* Note that this only affects matching of the URLs path, not the base
126+
* origin or query parameters.
127+
*
128+
* @type {boolean}
129+
* @attr {boolean} match-nested
130+
*/
131+
matchNested: {
132+
type: Boolean,
133+
value: false,
134+
},
135+
117136
/**
118137
* Whether the item's path matches the current browser URL.
119138
*
120139
* A match occurs when both share the same base origin (like https://example.com),
121140
* the same path (like /path/to/page), and the browser URL contains at least
122141
* all the query parameters with the same values from the item's path.
123142
*
143+
* See [`matchNested`](#/elements/vaadin-side-nav-item#property-matchNested) for how to change the path matching behavior.
144+
*
124145
* The state is updated when the item is added to the DOM or when the browser
125146
* navigates to a new page.
126147
*
@@ -190,7 +211,7 @@ class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixin(Themab
190211
updated(props) {
191212
super.updated(props);
192213

193-
if (props.has('path') || props.has('pathAliases')) {
214+
if (props.has('path') || props.has('pathAliases') || props.has('matchNested')) {
194215
this.__updateCurrent();
195216
}
196217

@@ -304,8 +325,12 @@ class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixin(Themab
304325
return false;
305326
}
306327

307-
const browserPath = `${document.location.pathname}${document.location.search}`;
308-
return matchPaths(browserPath, this.path) || this.pathAliases.some((alias) => matchPaths(browserPath, alias));
328+
const browserPath = `${location.pathname}${location.search}`;
329+
const matchOptions = { matchNested: this.matchNested };
330+
return (
331+
matchPaths(browserPath, this.path, matchOptions) ||
332+
this.pathAliases.some((alias) => matchPaths(browserPath, alias, matchOptions))
333+
);
309334
}
310335
}
311336

packages/side-nav/test/side-nav-item.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect } from '@vaadin/chai-plugins';
22
import { fixtureSync, nextRender } from '@vaadin/testing-helpers';
33
import sinon from 'sinon';
44
import '../vaadin-side-nav-item.js';
5+
import { location } from '../src/location.js';
56

67
describe('side-nav-item', () => {
78
let item, documentBaseURI;
@@ -283,6 +284,55 @@ describe('side-nav-item', () => {
283284
});
284285
});
285286

287+
describe('matchNested', () => {
288+
let currentPath = '/';
289+
let pathnameStub;
290+
291+
beforeEach(() => {
292+
pathnameStub = sinon.stub(location, 'pathname').get(() => currentPath);
293+
});
294+
295+
afterEach(() => {
296+
pathnameStub.restore();
297+
});
298+
299+
it('should be false by default', () => {
300+
item = fixtureSync('<vaadin-side-nav-item></vaadin-side-nav-item>');
301+
expect(item.matchNested).to.be.false;
302+
});
303+
304+
it('should match exact path when matchNested is false', () => {
305+
currentPath = '/users';
306+
item = fixtureSync('<vaadin-side-nav-item path="/users"></vaadin-side-nav-item>');
307+
expect(item.current).to.be.true;
308+
309+
currentPath = '/users/john';
310+
item = fixtureSync('<vaadin-side-nav-item path="/users"></vaadin-side-nav-item>');
311+
expect(item.current).to.be.false;
312+
});
313+
314+
it('should match nested paths when matchNested is true', () => {
315+
currentPath = '/users';
316+
item = fixtureSync('<vaadin-side-nav-item path="/users" match-nested></vaadin-side-nav-item>');
317+
expect(item.current).to.be.true;
318+
319+
currentPath = '/users/john';
320+
item = fixtureSync('<vaadin-side-nav-item path="/users" match-nested></vaadin-side-nav-item>');
321+
expect(item.current).to.be.true;
322+
});
323+
324+
it('should update when toggling matchNested', async () => {
325+
currentPath = '/users/john';
326+
item = fixtureSync('<vaadin-side-nav-item path="/users"></vaadin-side-nav-item>');
327+
await item.updateComplete;
328+
expect(item.current).to.be.false;
329+
330+
item.matchNested = true;
331+
await item.updateComplete;
332+
expect(item.current).to.be.true;
333+
});
334+
});
335+
286336
describe('navigation', () => {
287337
let anchor, toggle;
288338

0 commit comments

Comments
 (0)