Skip to content

Commit

Permalink
feat: allow matching navigation hierarchies with side nav item (#7693)
Browse files Browse the repository at this point in the history
* feat: allow matching navigation hierarchies with side nav item

* cleanup

* rename property to matchExact

* address review comments

* fix test case
  • Loading branch information
sissbruecker authored Aug 28, 2024
1 parent 11494cd commit dbd5f59
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 11 deletions.
14 changes: 8 additions & 6 deletions packages/component-base/src/url-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,17 @@ function containsQueryParams(actual, expected) {
*
* @param {string} actual The actual URL to match.
* @param {string} expected The expected URL to match.
* @param {Object} matchOptions Options for path matching.
*/
export function matchPaths(actual, expected) {
export function matchPaths(actual, expected, matchOptions = { matchNested: false }) {
const base = document.baseURI;
const actualUrl = new URL(actual, base);
const expectedUrl = new URL(expected, base);

return (
actualUrl.origin === expectedUrl.origin &&
actualUrl.pathname === expectedUrl.pathname &&
containsQueryParams(actualUrl.searchParams, expectedUrl.searchParams)
);
const matchesOrigin = actualUrl.origin === expectedUrl.origin;
const matchesPath = matchOptions.matchNested
? actualUrl.pathname === expectedUrl.pathname || actualUrl.pathname.startsWith(`${expectedUrl.pathname}/`)
: actualUrl.pathname === expectedUrl.pathname;

return matchesOrigin && matchesPath && containsQueryParams(actualUrl.searchParams, expectedUrl.searchParams);
}
23 changes: 23 additions & 0 deletions packages/component-base/test/url-utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,29 @@ describe('url-utils', () => {
expect(matchPaths('https://vaadin.com/docs/components', 'components')).to.be.true;
});

describe('matchNested option', () => {
it('should match the exact path by default', () => {
expect(matchPaths('/users', '/users')).to.be.true;
expect(matchPaths('/users/', '/users')).to.be.false;
expect(matchPaths('/users/john', '/users')).to.be.false;
expect(matchPaths('/usersessions', '/users')).to.be.false;
});

it('should match the exact path when matchNested is false', () => {
expect(matchPaths('/users', '/users', { matchNested: false })).to.be.true;
expect(matchPaths('/users/', '/users', { matchNested: false })).to.be.false;
expect(matchPaths('/users/john', '/users', { matchNested: false })).to.be.false;
expect(matchPaths('/usersessions', '/users', { matchNested: false })).to.be.false;
});

it('should match nested paths when matchNested is true', () => {
expect(matchPaths('/users', '/users', { matchNested: true })).to.be.true;
expect(matchPaths('/users/', '/users', { matchNested: true })).to.be.true;
expect(matchPaths('/users/john', '/users', { matchNested: true })).to.be.true;
expect(matchPaths('/usersessions', '/users', { matchNested: true })).to.be.false;
});
});

describe('query params', () => {
it('should return true when query params match', () => {
expect(matchPaths('/products', '/products')).to.be.true;
Expand Down
19 changes: 19 additions & 0 deletions packages/side-nav/src/location.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @license
* Copyright (c) 2023 - 2024 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/

/**
* Facade for `document.location`, can be stubbed for testing.
*
* For internal use only.
*/
export const location = {
get pathname() {
return document.location.pathname;
},
get search() {
return document.location.search;
},
};
20 changes: 18 additions & 2 deletions packages/side-nav/src/vaadin-side-nav-item.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,28 @@ declare class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixi
*/
expanded: boolean;

/**
* Whether to also match nested paths / routes. `false` by default.
*
* When enabled, an item with the path `/path` is considered current when
* the browser URL is `/path`, `/path/child`, `/path/child/grandchild`,
* etc.
*
* Note that this only affects matching of the URLs path, not the base
* origin or query parameters.
*
* @attr {boolean} match-nested
*/
matchNested: boolean;

/**
* Whether the item's path matches the current browser URL.
*
* A match occurs when both share the same base origin (like https://example.com),
* the same path (like /path/to/page), and the browser URL contains all
* the query parameters with the same values from the item's path.
* the same path (like /path/to/page), and the browser URL contains at least
* all the query parameters with the same values from the item's path.
*
* See [`matchNested`](#/elements/vaadin-side-nav-item#property-matchNested) for how to change the path matching behavior.
*
* The state is updated when the item is added to the DOM or when the browser
* navigates to a new page.
Expand Down
31 changes: 28 additions & 3 deletions packages/side-nav/src/vaadin-side-nav-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
import { matchPaths } from '@vaadin/component-base/src/url-utils.js';
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
import { location } from './location.js';
import { sideNavItemBaseStyles } from './vaadin-side-nav-base-styles.js';
import { SideNavChildrenMixin } from './vaadin-side-nav-children-mixin.js';

Expand Down Expand Up @@ -114,13 +115,33 @@ class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixin(Themab
reflectToAttribute: true,
},

/**
* Whether to also match nested paths / routes. `false` by default.
*
* When enabled, an item with the path `/path` is considered current when
* the browser URL is `/path`, `/path/child`, `/path/child/grandchild`,
* etc.
*
* Note that this only affects matching of the URLs path, not the base
* origin or query parameters.
*
* @type {boolean}
* @attr {boolean} match-nested
*/
matchNested: {
type: Boolean,
value: false,
},

/**
* Whether the item's path matches the current browser URL.
*
* A match occurs when both share the same base origin (like https://example.com),
* the same path (like /path/to/page), and the browser URL contains at least
* all the query parameters with the same values from the item's path.
*
* See [`matchNested`](#/elements/vaadin-side-nav-item#property-matchNested) for how to change the path matching behavior.
*
* The state is updated when the item is added to the DOM or when the browser
* navigates to a new page.
*
Expand Down Expand Up @@ -190,7 +211,7 @@ class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixin(Themab
updated(props) {
super.updated(props);

if (props.has('path') || props.has('pathAliases')) {
if (props.has('path') || props.has('pathAliases') || props.has('matchNested')) {
this.__updateCurrent();
}

Expand Down Expand Up @@ -304,8 +325,12 @@ class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixin(Themab
return false;
}

const browserPath = `${document.location.pathname}${document.location.search}`;
return matchPaths(browserPath, this.path) || this.pathAliases.some((alias) => matchPaths(browserPath, alias));
const browserPath = `${location.pathname}${location.search}`;
const matchOptions = { matchNested: this.matchNested };
return (
matchPaths(browserPath, this.path, matchOptions) ||
this.pathAliases.some((alias) => matchPaths(browserPath, alias, matchOptions))
);
}
}

Expand Down
50 changes: 50 additions & 0 deletions packages/side-nav/test/side-nav-item.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { expect } from '@vaadin/chai-plugins';
import { fixtureSync, nextRender } from '@vaadin/testing-helpers';
import sinon from 'sinon';
import '../vaadin-side-nav-item.js';
import { location } from '../src/location.js';

describe('side-nav-item', () => {
let item, documentBaseURI;
Expand Down Expand Up @@ -283,6 +284,55 @@ describe('side-nav-item', () => {
});
});

describe('matchNested', () => {
let currentPath = '/';
let pathnameStub;

beforeEach(() => {
pathnameStub = sinon.stub(location, 'pathname').get(() => currentPath);
});

afterEach(() => {
pathnameStub.restore();
});

it('should be false by default', () => {
item = fixtureSync('<vaadin-side-nav-item></vaadin-side-nav-item>');
expect(item.matchNested).to.be.false;
});

it('should match exact path when matchNested is false', () => {
currentPath = '/users';
item = fixtureSync('<vaadin-side-nav-item path="/users"></vaadin-side-nav-item>');
expect(item.current).to.be.true;

currentPath = '/users/john';
item = fixtureSync('<vaadin-side-nav-item path="/users"></vaadin-side-nav-item>');
expect(item.current).to.be.false;
});

it('should match nested paths when matchNested is true', () => {
currentPath = '/users';
item = fixtureSync('<vaadin-side-nav-item path="/users" match-nested></vaadin-side-nav-item>');
expect(item.current).to.be.true;

currentPath = '/users/john';
item = fixtureSync('<vaadin-side-nav-item path="/users" match-nested></vaadin-side-nav-item>');
expect(item.current).to.be.true;
});

it('should update when toggling matchNested', async () => {
currentPath = '/users/john';
item = fixtureSync('<vaadin-side-nav-item path="/users"></vaadin-side-nav-item>');
await item.updateComplete;
expect(item.current).to.be.false;

item.matchNested = true;
await item.updateComplete;
expect(item.current).to.be.true;
});
});

describe('navigation', () => {
let anchor, toggle;

Expand Down

0 comments on commit dbd5f59

Please sign in to comment.