diff --git a/packages/component-base/src/url-utils.d.ts b/packages/component-base/src/url-utils.d.ts index a27131f610b..299fd33678a 100644 --- a/packages/component-base/src/url-utils.d.ts +++ b/packages/component-base/src/url-utils.d.ts @@ -5,7 +5,11 @@ */ /** - * Check if two paths can be resolved as URLs - * with the same origin and pathname. + * Checks if two paths match based on their origin, pathname, and query parameters. + * + * The function matches an actual URL against an expected URL to see if they share + * the same base origin (like https://example.com), the same path (like /path/to/page), + * and if the actual URL contains at least all the query parameters with the same values + * from the expected URL. */ -export declare function matchPaths(path1: string, path2: string): boolean; +export declare function matchPaths(actual: string, expected: string): boolean; diff --git a/packages/component-base/src/url-utils.js b/packages/component-base/src/url-utils.js index 7658d1f06f2..a08139cc06d 100644 --- a/packages/component-base/src/url-utils.js +++ b/packages/component-base/src/url-utils.js @@ -5,15 +5,37 @@ */ /** - * Check if two paths can be resolved as URLs - * with the same origin and pathname. + * Checks if one set of URL parameters contains all the parameters + * with the same values from another set. * - * @param {string} path1 - * @param {string} path2 + * @param {URLSearchParams} actual + * @param {URLSearchParams} expected */ -export function matchPaths(path1, path2) { +function containsQueryParams(actual, expected) { + return [...expected.entries()].every(([key, value]) => { + return actual.getAll(key).includes(value); + }); +} + +/** + * Checks if two paths match based on their origin, pathname, and query parameters. + * + * The function matches an actual URL against an expected URL to see if they share + * the same base origin (like https://example.com), the same path (like /path/to/page), + * and if the actual URL contains at least all the query parameters with the same values + * from the expected URL. + * + * @param {string} actual The actual URL to match. + * @param {string} expected The expected URL to match. + */ +export function matchPaths(actual, expected) { const base = document.baseURI; - const url1 = new URL(path1, base); - const url2 = new URL(path2, base); - return url1.origin === url2.origin && url1.pathname === url2.pathname; + 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) + ); } diff --git a/packages/component-base/test/url-utils.test.js b/packages/component-base/test/url-utils.test.js index 1bca60cae9d..87cf69ab2ac 100644 --- a/packages/component-base/test/url-utils.test.js +++ b/packages/component-base/test/url-utils.test.js @@ -4,8 +4,18 @@ import { matchPaths } from '../src/url-utils.js'; describe('url-utils', () => { describe('matchPaths', () => { + let documentBaseURI; + const paths = ['', '/', '/path', 'base/path']; + beforeEach(() => { + documentBaseURI = sinon.stub(document, 'baseURI').value('http://localhost/'); + }); + + afterEach(() => { + documentBaseURI.restore(); + }); + it('should return true when paths match', () => { paths.forEach((path) => expect(matchPaths(path, path)).to.be.true); }); @@ -35,20 +45,68 @@ describe('url-utils', () => { }); }); - describe('base url', () => { - let baseUri; + it('should use document.baseURI as a base url', () => { + documentBaseURI.value('https://vaadin.com/docs/'); + expect(matchPaths('https://vaadin.com/docs/components', 'components')).to.be.true; + }); - beforeEach(() => { - baseUri = sinon.stub(document, 'baseURI'); - }); + describe('query params', () => { + it('should return true when query params match', () => { + expect(matchPaths('/products', '/products')).to.be.true; + expect(matchPaths('/products?c=socks', '/products')).to.be.true; + expect(matchPaths('/products?c=pants', '/products')).to.be.true; + expect(matchPaths('/products?c=', '/products')).to.be.true; + expect(matchPaths('/products?c=socks&item=5', '/products')).to.be.true; + expect(matchPaths('/products?item=5&c=socks', '/products')).to.be.true; + expect(matchPaths('/products?c=socks&c=pants', '/products')).to.be.true; + expect(matchPaths('/products?socks', '/products')).to.be.true; + expect(matchPaths('/products?socks=', '/products')).to.be.true; - afterEach(() => { - baseUri.restore(); + expect(matchPaths('/products?c=socks', '/products?c=socks')).to.be.true; + expect(matchPaths('/products?c=socks&item=5', '/products?c=socks')).to.be.true; + expect(matchPaths('/products?item=5&c=socks', '/products?c=socks')).to.be.true; + expect(matchPaths('/products?c=socks&c=pants', '/products?c=socks')).to.be.true; + + expect(matchPaths('/products?c=', '/products?c=')).to.be.true; + + expect(matchPaths('/products?c=socks&c=pants', '/products?c=socks&c=pants')).to.be.true; + + expect(matchPaths('/products?socks', '/products?socks')).to.be.true; + expect(matchPaths('/products?socks=', '/products?socks')).to.be.true; }); - it('should use document.baseURI as a base url', () => { - baseUri.value('https://vaadin.com/docs/'); - expect(matchPaths('https://vaadin.com/docs/components', 'components')).to.be.true; + it('should return false when query params do not match', () => { + expect(matchPaths('/products', '/products?c=socks')).to.be.false; + expect(matchPaths('/products?c=pants', '/products?c=socks')).to.be.false; + expect(matchPaths('/products?c=', '/products?c=socks')).to.be.false; + expect(matchPaths('/products?socks', '/products?c=socks')).to.be.false; + expect(matchPaths('/products?socks=', '/products?c=socks')).to.be.false; + + expect(matchPaths('/products', '/products?c=')).to.be.false; + expect(matchPaths('/products?c=socks', '/products?c=')).to.be.false; + expect(matchPaths('/products?c=pants', '/products?c=')).to.be.false; + expect(matchPaths('/products?c=socks&item=5', '/products?c=')).to.be.false; + expect(matchPaths('/products?item=5&c=socks', '/products?c=')).to.be.false; + expect(matchPaths('/products?c=socks&c=pants', '/products?c=')).to.be.false; + expect(matchPaths('/products?socks', '/products?c=')).to.be.false; + expect(matchPaths('/products?socks=', '/products?c=')).to.be.false; + + expect(matchPaths('/products', '/products?c=socks&c=pants')).to.be.false; + expect(matchPaths('/products?c=socks', '/products?c=socks&c=pants')).to.be.false; + expect(matchPaths('/products?c=pants', '/products?c=socks&c=pants')).to.be.false; + expect(matchPaths('/products?c=', '/products?c=socks&c=pants')).to.be.false; + expect(matchPaths('/products?c=socks&item=5', '/products?c=socks&c=pants')).to.be.false; + expect(matchPaths('/products?item=5&c=socks', '/products?c=socks&c=pants')).to.be.false; + expect(matchPaths('/products?socks', '/products?c=socks&c=pants')).to.be.false; + expect(matchPaths('/products?socks=', '/products?c=socks&c=pants')).to.be.false; + + expect(matchPaths('/products', '/products?socks')).to.be.false; + expect(matchPaths('/products?c=socks', '/products?socks')).to.be.false; + expect(matchPaths('/products?c=pants', '/products?socks')).to.be.false; + expect(matchPaths('/products?c=', '/products?socks')).to.be.false; + expect(matchPaths('/products?c=socks&item=5', '/products?socks')).to.be.false; + expect(matchPaths('/products?item=5&c=socks', '/products?socks')).to.be.false; + expect(matchPaths('/products?c=socks&c=pants', '/products?socks')).to.be.false; }); }); }); diff --git a/packages/side-nav/src/vaadin-side-nav-item.d.ts b/packages/side-nav/src/vaadin-side-nav-item.d.ts index b92e4930550..09e42f87d68 100644 --- a/packages/side-nav/src/vaadin-side-nav-item.d.ts +++ b/packages/side-nav/src/vaadin-side-nav-item.d.ts @@ -94,9 +94,14 @@ declare class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixi expanded: boolean; /** - * Whether the path of the item matches the current path. - * Set when the item is appended to DOM or when navigated back - * to the page that contains this item using the browser. + * 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 state is updated when the item is added to the DOM or when the browser + * navigates to a new page. */ readonly current: boolean; diff --git a/packages/side-nav/src/vaadin-side-nav-item.js b/packages/side-nav/src/vaadin-side-nav-item.js index eeb83dbc78c..59943d38580 100644 --- a/packages/side-nav/src/vaadin-side-nav-item.js +++ b/packages/side-nav/src/vaadin-side-nav-item.js @@ -115,9 +115,14 @@ class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixin(Themab }, /** - * Whether the path of the item matches the current path. - * Set when the item is appended to DOM or when navigated back - * to the page that contains this item using the browser. + * 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. + * + * The state is updated when the item is added to the DOM or when the browser + * navigates to a new page. * * @type {boolean} */ @@ -266,10 +271,9 @@ class SideNavItem extends SideNavChildrenMixin(DisabledMixin(ElementMixin(Themab if (this.path == null) { return false; } - return ( - matchPaths(document.location.pathname, this.path) || - this.pathAliases.some((alias) => matchPaths(document.location.pathname, alias)) - ); + + const browserPath = `${document.location.pathname}${document.location.search}`; + return matchPaths(browserPath, this.path) || this.pathAliases.some((alias) => matchPaths(browserPath, alias)); } } diff --git a/packages/side-nav/test/dom/side-nav-item.test.js b/packages/side-nav/test/dom/side-nav-item.test.js index 23ea1269dad..42cf252a77c 100644 --- a/packages/side-nav/test/dom/side-nav-item.test.js +++ b/packages/side-nav/test/dom/side-nav-item.test.js @@ -1,11 +1,20 @@ import { expect } from '@esm-bundle/chai'; import { fixtureSync, nextFrame, nextRender } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; import '../../src/vaadin-side-nav-item.js'; import '@vaadin/icon'; import '@vaadin/icons'; describe('vaadin-side-nav-item', () => { - let sideNavItem; + let sideNavItem, documentBaseURI; + + beforeEach(() => { + documentBaseURI = sinon.stub(document, 'baseURI').value('http://localhost/'); + }); + + afterEach(() => { + documentBaseURI.restore(); + }); beforeEach(async () => { sideNavItem = fixtureSync( diff --git a/packages/side-nav/test/side-nav-item.test.js b/packages/side-nav/test/side-nav-item.test.js index 5d802352eb9..7b50c83cf53 100644 --- a/packages/side-nav/test/side-nav-item.test.js +++ b/packages/side-nav/test/side-nav-item.test.js @@ -4,7 +4,15 @@ import sinon from 'sinon'; import '../vaadin-side-nav-item.js'; describe('side-nav-item', () => { - let item; + let item, documentBaseURI; + + beforeEach(() => { + documentBaseURI = sinon.stub(document, 'baseURI').value('http://localhost/'); + }); + + afterEach(() => { + documentBaseURI.restore(); + }); describe('custom element definition', () => { let tagName;