diff --git a/docs/translations/api-docs/dashboard-layout/dashboard-layout.json b/docs/translations/api-docs/dashboard-layout/dashboard-layout.json
index 0a9ddf79fb6..1dd92dd0858 100644
--- a/docs/translations/api-docs/dashboard-layout/dashboard-layout.json
+++ b/docs/translations/api-docs/dashboard-layout/dashboard-layout.json
@@ -10,7 +10,7 @@
"description": "Whether the sidebar should not be collapsible to a mini variant in desktop and tablet viewports."
},
"hideNavigation": {
- "description": "Whether the navigation bar and menu icon should be hidden"
+ "description": "Whether the navigation bar and menu icon should be hidden."
},
"navigation": {
"description": "Navigation definition for the dashboard. Find out more."
diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.test.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.test.tsx
index dde3582feed..8b89572c504 100644
--- a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.test.tsx
+++ b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.test.tsx
@@ -207,13 +207,18 @@ describe('DashboardLayout', () => {
icon: ,
},
{
- segment: 'dynamic',
+ segment: 'dynamic/override',
+ title: 'Dynamic Override',
+ icon: ,
+ },
+ {
+ segment: 'dynamicMoreOnly',
title: 'Dynamic',
icon: ,
pattern: 'dynamic/:dynamicId',
},
{
- segment: 'optional',
+ segment: 'optionalMoreOnly',
title: 'Optional',
pattern: 'optional{/:optionalId}?',
},
@@ -268,11 +273,23 @@ describe('DashboardLayout', () => {
expect(within(desktopNavigation).getByRole('link', { name: 'Dynamic' })).toHaveClass(
'Mui-selected',
);
+ expect(
+ within(desktopNavigation).getByRole('link', { name: 'Dynamic Override' }),
+ ).not.toHaveClass('Mui-selected');
rerender();
expect(within(desktopNavigation).getByRole('link', { name: 'Dynamic' })).not.toHaveClass(
'Mui-selected',
);
+ // Does not show multiple selected items if a dynamic segment is overridden by a more specific segment
+ rerender();
+ expect(within(desktopNavigation).getByRole('link', { name: 'Dynamic Override' })).toHaveClass(
+ 'Mui-selected',
+ );
+ expect(within(desktopNavigation).getByRole('link', { name: 'Dynamic' })).not.toHaveClass(
+ 'Mui-selected',
+ );
+
rerender();
expect(within(desktopNavigation).getByRole('link', { name: 'Optional' })).toHaveClass(
'Mui-selected',
diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx
index 8145f927215..4a3c29f6fb1 100644
--- a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx
+++ b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx
@@ -19,6 +19,7 @@ import { DashboardSidebarSubNavigation } from './DashboardSidebarSubNavigation';
import { ToolbarActions } from './ToolbarActions';
import { AppTitle, AppTitleProps } from './AppTitle';
import { getDrawerSxTransitionMixin, getDrawerWidthTransitionMixin } from './utils';
+import { MINI_DRAWER_WIDTH } from './shared';
import type { Branding, Navigation } from '../AppProvider';
const AppBar = styled(MuiAppBar)(({ theme }) => ({
@@ -80,17 +81,17 @@ export interface DashboardLayoutProps {
*/
navigation?: Navigation;
/**
- * Whether the sidebar should not be collapsible to a mini variant in desktop and tablet viewports.
+ * Whether the sidebar should start collapsed in desktop size screens.
* @default false
*/
- disableCollapsibleSidebar?: boolean;
+ defaultSidebarCollapsed?: boolean;
/**
- * Whether the sidebar should start collapsed in desktop size screens.
+ * Whether the sidebar should not be collapsible to a mini variant in desktop and tablet viewports.
* @default false
*/
- defaultSidebarCollapsed?: boolean;
+ disableCollapsibleSidebar?: boolean;
/**
- * Whether the navigation bar and menu icon should be hidden
+ * Whether the navigation bar and menu icon should be hidden.
* @default false
*/
hideNavigation?: boolean;
@@ -130,8 +131,8 @@ function DashboardLayout(props: DashboardLayoutProps) {
children,
branding: brandingProp,
navigation: navigationProp,
- disableCollapsibleSidebar = false,
defaultSidebarCollapsed = false,
+ disableCollapsibleSidebar = false,
hideNavigation = false,
sidebarExpandedWidth = 320,
slots,
@@ -182,6 +183,8 @@ function DashboardLayout(props: DashboardLayoutProps) {
const [isNavigationFullyExpanded, setIsNavigationFullyExpanded] =
React.useState(isNavigationExpanded);
+ const [isNavigationFullyCollapsed, setIsNavigationFullyCollapsed] =
+ React.useState(!isNavigationExpanded);
React.useEffect(() => {
if (isNavigationExpanded) {
@@ -197,7 +200,19 @@ function DashboardLayout(props: DashboardLayoutProps) {
return () => {};
}, [isNavigationExpanded, theme]);
- const selectedItemIdRef = React.useRef('');
+ React.useEffect(() => {
+ if (!isNavigationExpanded) {
+ const drawerWidthTransitionTimeout = setTimeout(() => {
+ setIsNavigationFullyCollapsed(true);
+ }, theme.transitions.duration.leavingScreen);
+
+ return () => clearTimeout(drawerWidthTransitionTimeout);
+ }
+
+ setIsNavigationFullyCollapsed(false);
+
+ return () => {};
+ }, [isNavigationExpanded, theme]);
const handleSetNavigationExpanded = React.useCallback(
(newExpanded: boolean) => () => {
@@ -211,17 +226,9 @@ function DashboardLayout(props: DashboardLayoutProps) {
}, [isNavigationExpanded, setIsNavigationExpanded]);
const handleNavigationLinkClick = React.useCallback(() => {
- selectedItemIdRef.current = '';
setIsMobileNavigationExpanded(false);
}, [setIsMobileNavigationExpanded]);
- // If useEffect was used, the reset would also happen on the client render after SSR which we don't need
- React.useMemo(() => {
- if (navigation) {
- selectedItemIdRef.current = '';
- }
- }, [navigation]);
-
const isDesktopMini = !disableCollapsibleSidebar && !isDesktopNavigationExpanded;
const isMobileMini = !disableCollapsibleSidebar && !isMobileNavigationExpanded;
@@ -279,8 +286,8 @@ function DashboardLayout(props: DashboardLayoutProps) {
onLinkClick={handleNavigationLinkClick}
isMini={isMini}
isFullyExpanded={isNavigationFullyExpanded}
+ isFullyCollapsed={isNavigationFullyCollapsed}
hasDrawerTransitions={hasDrawerTransitions}
- selectedItemId={selectedItemIdRef.current}
/>
{SidebarFooterSlot ? (
@@ -292,6 +299,7 @@ function DashboardLayout(props: DashboardLayoutProps) {
SidebarFooterSlot,
handleNavigationLinkClick,
hasDrawerTransitions,
+ isNavigationFullyCollapsed,
isNavigationFullyExpanded,
navigation,
slotProps?.sidebarFooter,
@@ -300,7 +308,7 @@ function DashboardLayout(props: DashboardLayoutProps) {
const getDrawerSharedSx = React.useCallback(
(isMini: boolean, isTemporary: boolean) => {
- const drawerWidth = isMini ? 64 : sidebarExpandedWidth;
+ const drawerWidth = isMini ? MINI_DRAWER_WIDTH : sidebarExpandedWidth;
return {
displayPrint: 'none',
@@ -335,7 +343,7 @@ function DashboardLayout(props: DashboardLayoutProps) {
}}
>
-
+
({
borderRadius: 8,
@@ -32,7 +36,7 @@ const NavigationListItemButton = styled(ListItemButton)(({ theme }) => ({
color: (theme.vars ?? theme).palette.primary.dark,
},
'& .MuiTypography-root': {
- color: (theme.vars ?? theme).palette.text.primary,
+ color: (theme.vars ?? theme).palette.primary.dark,
},
'& .MuiSvgIcon-root': {
color: (theme.vars ?? theme).palette.primary.dark,
@@ -54,13 +58,13 @@ const NavigationListItemButton = styled(ListItemButton)(({ theme }) => ({
interface DashboardSidebarSubNavigationProps {
subNavigation: Navigation;
- basePath?: string;
depth?: number;
onLinkClick: () => void;
isMini?: boolean;
+ isPopover?: boolean;
isFullyExpanded?: boolean;
+ isFullyCollapsed?: boolean;
hasDrawerTransitions?: boolean;
- selectedItemId: string;
}
/**
@@ -68,17 +72,17 @@ interface DashboardSidebarSubNavigationProps {
*/
function DashboardSidebarSubNavigation({
subNavigation,
- basePath = '',
depth = 0,
onLinkClick,
isMini = false,
+ isPopover = false,
isFullyExpanded = true,
+ isFullyCollapsed = false,
hasDrawerTransitions = false,
- selectedItemId,
}: DashboardSidebarSubNavigationProps) {
- const routerContext = React.useContext(RouterContext);
+ const navigationContext = React.useContext(NavigationContext);
- const pathname = routerContext?.pathname ?? '/';
+ const activePage = useActivePage();
const initialExpandedSidebarItemIds = React.useMemo(
() =>
@@ -87,16 +91,22 @@ function DashboardSidebarSubNavigation({
navigationItem,
originalIndex: navigationItemIndex,
}))
- .filter(({ navigationItem }) =>
- hasSelectedNavigationChildren(navigationItem, basePath, pathname),
+ .filter(
+ ({ navigationItem }) =>
+ isPageItem(navigationItem) &&
+ !!activePage &&
+ hasSelectedNavigationChildren(navigationContext, navigationItem, activePage.path),
)
.map(({ originalIndex }) => `${depth}-${originalIndex}`),
- [basePath, depth, pathname, subNavigation],
+ [activePage, depth, navigationContext, subNavigation],
);
const [expandedSidebarItemIds, setExpandedSidebarItemIds] = React.useState(
initialExpandedSidebarItemIds,
);
+ const [hoveredMiniSidebarItemId, setHoveredMiniSidebarItemId] = React.useState(
+ null,
+ );
const handleOpenFolderClick = React.useCallback(
(itemId: string) => () => {
@@ -110,7 +120,15 @@ function DashboardSidebarSubNavigation({
);
return (
-
+
{subNavigation.map((navigationItem, navigationItemIndex) => {
if (navigationItem.kind === 'header') {
return (
@@ -155,32 +173,56 @@ function DashboardSidebarSubNavigation({
);
}
- const navigationItemFullPath = getPageItemFullPath(basePath, navigationItem);
+ const navigationItemFullPath = getItemPath(navigationContext, navigationItem);
const navigationItemId = `${depth}-${navigationItemIndex}`;
const navigationItemTitle = getItemTitle(navigationItem);
const isNestedNavigationExpanded = expandedSidebarItemIds.includes(navigationItemId);
- const nestedNavigationCollapseIcon = isNestedNavigationExpanded ? (
-
- ) : (
-
- );
-
const listItemIconSize = 34;
- const isSelected = isPageItemSelected(navigationItem, basePath, pathname);
+ const isActive =
+ !!activePage && activePage.path === getItemPath(navigationContext, navigationItem);
- if (process.env.NODE_ENV !== 'production' && isSelected && selectedItemId) {
- console.warn(`Duplicate selected path in navigation: ${navigationItemFullPath}`);
+ let nestedNavigationCollapseSx: SxProps = { display: 'none' };
+ if (isMini && isFullyCollapsed) {
+ nestedNavigationCollapseSx = {
+ fontSize: 18,
+ position: 'absolute',
+ top: '41.5%',
+ right: '2px',
+ transform: 'translateY(-50%) rotate(-90deg)',
+ };
+ } else if (!isMini && isFullyExpanded) {
+ nestedNavigationCollapseSx = {
+ ml: 0.5,
+ transform: `rotate(${isNestedNavigationExpanded ? 0 : -90}deg)`,
+ transition: (theme: Theme) =>
+ theme.transitions.create('transform', {
+ easing: theme.transitions.easing.sharp,
+ duration: 100,
+ }),
+ };
}
- if (isSelected && !selectedItemId) {
- selectedItemId = navigationItemId;
- }
+ // Show as selected in mini sidebar if any of the children matches path, otherwise show as selected if item matches path
+ const isSelected =
+ activePage && navigationItem.children && isMini
+ ? hasSelectedNavigationChildren(navigationContext, navigationItem, activePage.path)
+ : isActive && !navigationItem.children;
const listItem = (
{
+ setHoveredMiniSidebarItemId(navigationItemId);
+ },
+ onMouseLeave: () => {
+ setHoveredMiniSidebarItemId(null);
+ },
+ }
+ : {})}
sx={{
py: 0,
px: 1,
@@ -188,79 +230,131 @@ function DashboardSidebarSubNavigation({
}}
>
{navigationItem.icon || isMini ? (
-
- {navigationItem.icon ?? null}
- {!navigationItem.icon && isMini ? (
-
+ {navigationItem.icon ?? null}
+ {!navigationItem.icon && isMini ? (
+
+ {navigationItemTitle
+ .split(' ')
+ .slice(0, 2)
+ .map((itemTitleWord) => itemTitleWord.charAt(0).toUpperCase())}
+
+ ) : null}
+
+ {isMini ? (
+
- {navigationItemTitle
- .split(' ')
- .slice(0, 2)
- .map((itemTitleWord) => itemTitleWord.charAt(0).toUpperCase())}
-
+ {navigationItemTitle}
+
) : null}
-
+
+ ) : null}
+ {!isMini ? (
+
) : null}
-
{navigationItem.action && !isMini && isFullyExpanded ? navigationItem.action : null}
- {navigationItem.children && !isMini && isFullyExpanded
- ? nestedNavigationCollapseIcon
- : null}
+ {navigationItem.children ? : null}
+ {navigationItem.children && isMini ? (
+
+
+
+
+
+
+
+ ) : null}
);
return (
- {isMini ? (
-
- {listItem}
-
- ) : (
- listItem
- )}
-
+ {listItem}
{navigationItem.children && !isMini ? (
) : null}
diff --git a/packages/toolpad-core/src/DashboardLayout/shared.ts b/packages/toolpad-core/src/DashboardLayout/shared.ts
new file mode 100644
index 00000000000..8fb17ea0d70
--- /dev/null
+++ b/packages/toolpad-core/src/DashboardLayout/shared.ts
@@ -0,0 +1 @@
+export const MINI_DRAWER_WIDTH = 84; // px
diff --git a/packages/toolpad-core/src/shared/navigation.tsx b/packages/toolpad-core/src/shared/navigation.tsx
index fae0efa2e66..b959e292972 100644
--- a/packages/toolpad-core/src/shared/navigation.tsx
+++ b/packages/toolpad-core/src/shared/navigation.tsx
@@ -16,48 +16,6 @@ export const getItemTitle = (item: NavigationPageItem | NavigationSubheaderItem)
return isPageItem(item) ? (item.title ?? item.segment ?? '') : item.title;
};
-export function getPageItemFullPath(basePath: string, navigationItem: NavigationPageItem) {
- return `${basePath}${basePath && !navigationItem.segment ? '' : '/'}${navigationItem.segment ?? ''}`;
-}
-
-export function isPageItemSelected(
- navigationItem: NavigationPageItem,
- basePath: string,
- pathname: string,
-) {
- return navigationItem.pattern
- ? pathToRegexp(`${basePath}/${navigationItem.pattern}`).test(pathname)
- : getPageItemFullPath(basePath, navigationItem) === pathname;
-}
-
-export function hasSelectedNavigationChildren(
- navigationItem: NavigationItem,
- basePath: string,
- pathname: string,
-): boolean {
- if (isPageItem(navigationItem) && navigationItem.children) {
- const navigationItemFullPath = getPageItemFullPath(basePath, navigationItem);
-
- return navigationItem.children.some((nestedNavigationItem) => {
- if (!isPageItem(nestedNavigationItem)) {
- return false;
- }
-
- if (nestedNavigationItem.children) {
- return hasSelectedNavigationChildren(
- nestedNavigationItem,
- navigationItemFullPath,
- pathname,
- );
- }
-
- return isPageItemSelected(nestedNavigationItem, navigationItemFullPath, pathname);
- });
- }
-
- return false;
-}
-
/**
* Builds a map of navigation page items to their respective paths. This map is used to quickly
* lookup the path of a navigation item. It will be cached for the lifetime of the navigation.
@@ -67,7 +25,10 @@ function buildItemToPathMap(navigation: Navigation): Map {
if (isPageItem(item)) {
- const path = `${base}${item.segment ? `/${item.segment}` : ''}` || '/';
+ // Append segment to base path. Make sure to always have an initial slash, and slashes between segments.
+ const path =
+ `${base.startsWith('/') ? base : `/${base}`}${base && base !== '/' && item.segment ? '/' : ''}${item.segment || ''}` ||
+ '/';
map.set(item, path);
if (item.children) {
for (const child of item.children) {
@@ -110,6 +71,7 @@ function buildItemLookup(navigation: Navigation) {
if (map.has(path)) {
console.warn(`Duplicate path in navigation: ${path}`);
}
+
map.set(path, item);
if (item.pattern) {
const basePath = item.segment ? path.slice(0, -item.segment.length) : path;
@@ -165,3 +127,28 @@ export function getItemPath(navigation: Navigation, item: NavigationPageItem): s
invariant(path, `Item not found in navigation: ${item.title}`);
return path;
}
+
+/**
+ * Checks if a specific navigation page item has the active page as a child item.
+ */
+export function hasSelectedNavigationChildren(
+ navigation: Navigation,
+ item: NavigationPageItem,
+ activePagePath: string,
+): boolean {
+ if (item.children) {
+ return item.children.some((nestedItem) => {
+ if (!isPageItem(nestedItem)) {
+ return false;
+ }
+
+ if (nestedItem.children) {
+ return hasSelectedNavigationChildren(navigation, nestedItem, activePagePath);
+ }
+
+ return activePagePath === getItemPath(navigation, nestedItem);
+ });
+ }
+
+ return false;
+}