diff --git a/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md b/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md
index 06e75d36773..b5eeaed5798 100644
--- a/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md
+++ b/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md
@@ -111,6 +111,44 @@ Some examples:
{{"demo": "DashboardLayoutPattern.js", "height": 400, "iframe": true}}
+### Navigation with search parameters
+
+Navigation links have an optional `searchParams` prop to include URL search parameters in the navigation links.
+Child navigation items inherit parent search parameters by default, and can override or clear them.
+
+```tsx
+{
+ segment: 'reports',
+ title: 'Reports',
+ searchParams: new URLSearchParams({ view: 'summary' }),
+ children: [
+ {
+ segment: 'sales',
+ title: 'Sales',
+ // Inherits parent params: /reports/sales?view=summary
+ },
+ {
+ segment: 'inventory',
+ title: 'Inventory',
+ searchParams: new URLSearchParams({ view: 'detailed' }),
+ // Overrides parent param: /reports/inventory?view=detailed
+ },
+ {
+ segment: 'analytics',
+ title: 'Analytics',
+ searchParams: new URLSearchParams(),
+ // Clears inherited params: /reports/analytics
+ },
+ ],
+}
+```
+
+**Key behaviors:**
+- Child items inherit parent `searchParams` by default
+- Child `searchParams` override parent parameters with the same key
+- Empty `URLSearchParams()` clears all inherited parameters
+- Hash fragments in navigation links are preserved during navigation
+
### Disable collapsible sidebar
The layout sidebar is collapsible to a mini-drawer (with icons only) in desktop and tablet viewports. This behavior can be disabled with the `disableCollapsibleSidebar` prop.
diff --git a/packages/toolpad-core/src/AppProvider/AppProvider.tsx b/packages/toolpad-core/src/AppProvider/AppProvider.tsx
index 2c5c4f0e5d7..759016487ec 100644
--- a/packages/toolpad-core/src/AppProvider/AppProvider.tsx
+++ b/packages/toolpad-core/src/AppProvider/AppProvider.tsx
@@ -46,6 +46,7 @@ export interface NavigationPageItem {
pattern?: string;
action?: React.ReactNode;
children?: Navigation;
+ searchParams?: URLSearchParams;
}
export interface NavigationSubheaderItem {
@@ -244,6 +245,7 @@ AppProvider.propTypes /* remove-proptypes */ = {
icon: PropTypes.node,
kind: PropTypes.oneOf(['page']),
pattern: PropTypes.string,
+ searchParams: PropTypes.instanceOf(URLSearchParams),
segment: PropTypes.string,
title: PropTypes.string,
}),
diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardSidebarPageItem.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardSidebarPageItem.tsx
index d419d00e6e6..964eda78232 100644
--- a/packages/toolpad-core/src/DashboardLayout/DashboardSidebarPageItem.tsx
+++ b/packages/toolpad-core/src/DashboardLayout/DashboardSidebarPageItem.tsx
@@ -354,6 +354,7 @@ DashboardSidebarPageItem.propTypes /* remove-proptypes */ = {
icon: PropTypes.node,
kind: PropTypes.oneOf(['page']),
pattern: PropTypes.string,
+ searchParams: PropTypes.instanceOf(URLSearchParams),
segment: PropTypes.string,
title: PropTypes.string,
}).isRequired,
diff --git a/packages/toolpad-core/src/shared/Link.test.tsx b/packages/toolpad-core/src/shared/Link.test.tsx
new file mode 100644
index 00000000000..498a248c733
--- /dev/null
+++ b/packages/toolpad-core/src/shared/Link.test.tsx
@@ -0,0 +1,116 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import * as React from 'react';
+import { describe, test, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { DefaultLink } from './Link';
+import { RouterContext } from './context';
+import type { Router } from '../AppProvider';
+
+describe('DefaultLink', () => {
+ test('preserves search params and hash when navigating', async () => {
+ const user = userEvent.setup();
+ const navigate = vi.fn();
+
+ const router: Router = {
+ pathname: '/current',
+ searchParams: new URLSearchParams(),
+ navigate,
+ };
+
+ render(
+
+ Jobs
+ ,
+ );
+
+ const link = screen.getByText('Jobs');
+ await user.click(link);
+
+ expect(navigate).toHaveBeenCalledWith('/jobs?page=2&filter=active#section', {
+ history: undefined,
+ });
+ });
+
+ test('preserves only hash when no search params', async () => {
+ const user = userEvent.setup();
+ const navigate = vi.fn();
+
+ const router: Router = {
+ pathname: '/current',
+ searchParams: new URLSearchParams(),
+ navigate,
+ };
+
+ render(
+
+ About
+ ,
+ );
+
+ const link = screen.getByText('About');
+ await user.click(link);
+
+ expect(navigate).toHaveBeenCalledWith('/about#team', { history: undefined });
+ });
+
+ test('preserves only search params when no hash', async () => {
+ const user = userEvent.setup();
+ const navigate = vi.fn();
+
+ const router: Router = {
+ pathname: '/current',
+ searchParams: new URLSearchParams(),
+ navigate,
+ };
+
+ render(
+
+ Products
+ ,
+ );
+
+ const link = screen.getByText('Products');
+ await user.click(link);
+
+ expect(navigate).toHaveBeenCalledWith('/products?category=electronics', {
+ history: undefined,
+ });
+ });
+
+ test('works with history prop', async () => {
+ const user = userEvent.setup();
+ const navigate = vi.fn();
+
+ const router: Router = {
+ pathname: '/current',
+ searchParams: new URLSearchParams(),
+ navigate,
+ };
+
+ render(
+
+
+ Jobs
+
+ ,
+ );
+
+ const link = screen.getByText('Jobs');
+ await user.click(link);
+
+ expect(navigate).toHaveBeenCalledWith('/jobs?page=2', { history: 'replace' });
+ });
+
+ test('uses default anchor behavior when no router context', async () => {
+ const onClick = vi.fn((event: React.MouseEvent) => event.preventDefault());
+
+ render();
+
+ const link = screen.getByRole('link');
+ expect(link).toHaveAttribute('href', '/jobs?page=2');
+ });
+});
diff --git a/packages/toolpad-core/src/shared/Link.tsx b/packages/toolpad-core/src/shared/Link.tsx
index 9c4814bf8dc..ba29966e20b 100644
--- a/packages/toolpad-core/src/shared/Link.tsx
+++ b/packages/toolpad-core/src/shared/Link.tsx
@@ -29,7 +29,7 @@ export const DefaultLink = React.forwardRef(function Link(
return (event: React.MouseEvent) => {
event.preventDefault();
const url = new URL(event.currentTarget.href);
- routerContext.navigate(url.pathname, { history });
+ routerContext.navigate(url.pathname + url.search + url.hash, { history });
onClick?.(event);
};
}, [routerContext, onClick, history]);
diff --git a/packages/toolpad-core/src/shared/navigation.test.tsx b/packages/toolpad-core/src/shared/navigation.test.tsx
new file mode 100644
index 00000000000..008464daa8f
--- /dev/null
+++ b/packages/toolpad-core/src/shared/navigation.test.tsx
@@ -0,0 +1,146 @@
+/**
+ * @vitest-environment jsdom
+ */
+
+import { describe, test, expect } from 'vitest';
+import type { Navigation } from '../AppProvider';
+import { getItemPath, matchPath } from './navigation';
+
+describe('navigation', () => {
+ describe('getItemPath', () => {
+ test('returns path without searchParams when not specified', () => {
+ const navigation: Navigation = [
+ {
+ segment: 'dashboard',
+ title: 'Dashboard',
+ },
+ ];
+
+ const path = getItemPath(navigation, navigation[0] as any);
+ expect(path).toBe('/dashboard');
+ });
+
+ test('includes searchParams when specified', () => {
+ const searchParams = new URLSearchParams({ page: '2', filter: 'active' });
+ const navigation: Navigation = [
+ {
+ segment: 'jobs',
+ title: 'Jobs',
+ searchParams,
+ },
+ ];
+
+ const path = getItemPath(navigation, navigation[0] as any);
+ expect(path).toBe('/jobs?page=2&filter=active');
+ });
+
+ test('inherits parent searchParams in nested navigation', () => {
+ const parentSearchParams = new URLSearchParams({ theme: 'dark' });
+ const navigation: Navigation = [
+ {
+ segment: 'reports',
+ title: 'Reports',
+ searchParams: parentSearchParams,
+ children: [
+ {
+ segment: 'sales',
+ title: 'Sales',
+ },
+ ],
+ },
+ ];
+
+ const parent = navigation[0] as any;
+ const child = parent.children[0];
+
+ expect(getItemPath(navigation, parent)).toBe('/reports?theme=dark');
+ expect(getItemPath(navigation, child)).toBe('/reports/sales?theme=dark');
+ });
+
+ test('child searchParams override parent searchParams', () => {
+ const parentSearchParams = new URLSearchParams({ foo: 'bar', baz: 'quux' });
+ const childSearchParams = new URLSearchParams({ foo: 'hello' });
+ const navigation: Navigation = [
+ {
+ segment: 'movies',
+ title: 'Movies',
+ searchParams: parentSearchParams,
+ children: [
+ {
+ segment: 'lord-of-the-rings',
+ title: 'Lord of the Rings',
+ searchParams: childSearchParams,
+ },
+ {
+ segment: 'harry-potter',
+ title: 'Harry Potter',
+ },
+ ],
+ },
+ ];
+
+ const parent = navigation[0] as any;
+ const child1 = parent.children[0];
+ const child2 = parent.children[1];
+
+ expect(getItemPath(navigation, parent)).toBe('/movies?foo=bar&baz=quux');
+ expect(getItemPath(navigation, child1)).toBe('/movies/lord-of-the-rings?foo=hello&baz=quux');
+ expect(getItemPath(navigation, child2)).toBe('/movies/harry-potter?foo=bar&baz=quux');
+ });
+
+ test('empty searchParams clears inherited searchParams', () => {
+ const parentSearchParams = new URLSearchParams({ foo: 'bar' });
+ const emptySearchParams = new URLSearchParams();
+ const navigation: Navigation = [
+ {
+ segment: 'movies',
+ title: 'Movies',
+ searchParams: parentSearchParams,
+ children: [
+ {
+ segment: 'dune',
+ title: 'Dune',
+ searchParams: emptySearchParams,
+ },
+ ],
+ },
+ ];
+
+ const parent = navigation[0] as any;
+ const child = parent.children[0];
+
+ expect(getItemPath(navigation, parent)).toBe('/movies?foo=bar');
+ expect(getItemPath(navigation, child)).toBe('/movies/dune');
+ });
+ });
+
+ describe('matchPath', () => {
+ test('matches path ignoring searchParams in navigation item', () => {
+ const navigation: Navigation = [
+ {
+ segment: 'jobs',
+ title: 'Jobs',
+ searchParams: new URLSearchParams({ page: '2' }),
+ },
+ ];
+
+ // matchPath should match the pathname, ignoring the searchParams defined in nav
+ const match = matchPath(navigation, '/jobs');
+ expect(match).toBe(navigation[0]);
+ });
+
+ test('matches path with different search params in URL', () => {
+ const navigation: Navigation = [
+ {
+ segment: 'jobs',
+ title: 'Jobs',
+ searchParams: new URLSearchParams({ page: '2' }),
+ },
+ ];
+
+ // Even if URL has different params, it should still match based on pathname
+ const match = matchPath(navigation, '/jobs?page=1');
+ expect(match).toBe(navigation[0]);
+ });
+ });
+});
diff --git a/packages/toolpad-core/src/shared/navigation.tsx b/packages/toolpad-core/src/shared/navigation.tsx
index b959e292972..f83f4e955a0 100644
--- a/packages/toolpad-core/src/shared/navigation.tsx
+++ b/packages/toolpad-core/src/shared/navigation.tsx
@@ -23,23 +23,42 @@ export const getItemTitle = (item: NavigationPageItem | NavigationSubheaderItem)
function buildItemToPathMap(navigation: Navigation): Map {
const map = new Map();
- const visit = (item: NavigationItem, base: string) => {
+ const visit = (item: NavigationItem, base: string, parentSearchParams?: URLSearchParams) => {
if (isPageItem(item)) {
// Append segment to base path. Make sure to always have an initial slash, and slashes between segments.
- const path =
+ const pathname =
`${base.startsWith('/') ? base : `/${base}`}${base && base !== '/' && item.segment ? '/' : ''}${item.segment || ''}` ||
'/';
+
+ // Merge parent searchParams with item's searchParams
+ // If item has searchParams defined (even if empty), it replaces parent params
+ let searchParams = parentSearchParams;
+ if (item.searchParams !== undefined) {
+ searchParams = new URLSearchParams(item.searchParams);
+ // If parent params exist and item params is not empty, merge them
+ if (parentSearchParams && item.searchParams.size > 0) {
+ for (const [key, value] of parentSearchParams.entries()) {
+ if (!searchParams.has(key)) {
+ searchParams.set(key, value);
+ }
+ }
+ }
+ }
+
+ const searchString = searchParams && searchParams.size > 0 ? `?${searchParams.toString()}` : '';
+ const path = pathname + searchString;
+
map.set(item, path);
if (item.children) {
for (const child of item.children) {
- visit(child, path);
+ visit(child, pathname, searchParams);
}
}
}
};
for (const item of navigation) {
- visit(item, '');
+ visit(item, '', undefined);
}
return map;
@@ -61,20 +80,22 @@ function getItemToPathMap(navigation: Navigation) {
/**
* Build a lookup map of paths to navigation items. This map is used to match paths against
- * to find the active page.
+ * to find the active page. Only pathname is used for matching, searchParams are ignored.
*/
function buildItemLookup(navigation: Navigation) {
const map = new Map();
const visit = (item: NavigationItem) => {
if (isPageItem(item)) {
- const path = getItemPath(navigation, item);
- if (map.has(path)) {
- console.warn(`Duplicate path in navigation: ${path}`);
+ const fullPath = getItemPath(navigation, item);
+ // Extract pathname without search params for matching
+ const pathname = fullPath.split('?')[0];
+ if (map.has(pathname)) {
+ console.warn(`Duplicate path in navigation: ${pathname}`);
}
- map.set(path, item);
+ map.set(pathname, item);
if (item.pattern) {
- const basePath = item.segment ? path.slice(0, -item.segment.length) : path;
+ const basePath = item.segment ? pathname.slice(0, -item.segment.length) : pathname;
map.set(pathToRegexp(basePath + item.pattern), item);
}
if (item.children) {
@@ -101,16 +122,18 @@ function getItemLookup(navigation: Navigation) {
/**
* Matches a path against the navigation to find the active page. i.e. the page that should be
- * marked as selected in the navigation.
+ * marked as selected in the navigation. Only the pathname is matched, search params are ignored.
*/
export function matchPath(navigation: Navigation, path: string): NavigationPageItem | null {
const lookup = getItemLookup(navigation);
+ // Strip search params and hash from the path for matching
+ const pathname = path.split('?')[0].split('#')[0];
for (const [key, item] of lookup.entries()) {
- if (typeof key === 'string' && key === path) {
+ if (typeof key === 'string' && key === pathname) {
return item;
}
- if (key instanceof RegExp && key.test(path)) {
+ if (key instanceof RegExp && key.test(pathname)) {
return item;
}
}