diff --git a/docs/data/toolpad/core/api/components/dashboard-layout.md b/docs/data/toolpad/core/api/components/dashboard-layout.md new file mode 100644 index 00000000000..e099cc8857f --- /dev/null +++ b/docs/data/toolpad/core/api/components/dashboard-layout.md @@ -0,0 +1,3 @@ +# Dashboard Layout API + +

DashboardLayout API reference

diff --git a/docs/data/toolpad/core/api/components/dashboard.md b/docs/data/toolpad/core/api/components/dashboard.md deleted file mode 100644 index 4fbe818460c..00000000000 --- a/docs/data/toolpad/core/api/components/dashboard.md +++ /dev/null @@ -1,3 +0,0 @@ -# Dashboard API - -

Dashboard API reference

diff --git a/docs/data/toolpad/core/api/index.md b/docs/data/toolpad/core/api/index.md index 2ee020a6458..b8fa7bc5387 100644 --- a/docs/data/toolpad/core/api/index.md +++ b/docs/data/toolpad/core/api/index.md @@ -4,7 +4,7 @@ ## Components -- [Dashboard](/toolpad/core/api/dashboard/) +- [Dashboard Layout](/toolpad/core/api/dashboard-layout/) - [Data Grid](/toolpad/core/api/data-grid/) - [Line Chart](/toolpad/core/api/line-chart/) - [Select Filter](/toolpad/core/api/select-filter/) diff --git a/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBasic.js b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBasic.js new file mode 100644 index 00000000000..e14eb8afff2 --- /dev/null +++ b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBasic.js @@ -0,0 +1,69 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; +import BarChartIcon from '@mui/icons-material/BarChart'; +import DescriptionIcon from '@mui/icons-material/Description'; +import LayersIcon from '@mui/icons-material/Layers'; +import { AppProvider } from '@toolpad/core/AppProvider'; +import { DashboardLayout } from '@toolpad/core/DashboardLayout'; + +const NAVIGATION = [ + { + kind: 'header', + title: 'Main items', + }, + { + title: 'Dashboard', + icon: , + }, + { + title: 'Orders', + icon: , + }, + { + kind: 'divider', + }, + { + kind: 'header', + title: 'Analytics', + }, + { + title: 'Reports', + icon: , + children: [ + { + title: 'Sales', + icon: , + }, + { + title: 'Traffic', + icon: , + }, + ], + }, + { + title: 'Integrations', + icon: , + }, +]; + +export default function DashboardLayoutBasic() { + return ( + + + + Dashboard content goes here. + + + + ); +} diff --git a/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBasic.tsx b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBasic.tsx new file mode 100644 index 00000000000..3b1163d5b7c --- /dev/null +++ b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBasic.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; +import BarChartIcon from '@mui/icons-material/BarChart'; +import DescriptionIcon from '@mui/icons-material/Description'; +import LayersIcon from '@mui/icons-material/Layers'; +import { AppProvider } from '@toolpad/core/AppProvider'; +import { DashboardLayout } from '@toolpad/core/DashboardLayout'; +import type { Navigation } from '@toolpad/core'; + +const NAVIGATION: Navigation = [ + { + kind: 'header', + title: 'Main items', + }, + { + title: 'Dashboard', + icon: , + }, + { + title: 'Orders', + icon: , + }, + { + kind: 'divider', + }, + { + kind: 'header', + title: 'Analytics', + }, + { + title: 'Reports', + icon: , + children: [ + { + title: 'Sales', + icon: , + }, + { + title: 'Traffic', + icon: , + }, + ], + }, + { + title: 'Integrations', + icon: , + }, +]; + +export default function DashboardLayoutBasic() { + return ( + + + + Dashboard content goes here. + + + + ); +} diff --git a/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBranding.js b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBranding.js new file mode 100644 index 00000000000..3047c2500ae --- /dev/null +++ b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBranding.js @@ -0,0 +1,42 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; +import { AppProvider } from '@toolpad/core/AppProvider'; +import { DashboardLayout } from '@toolpad/core/DashboardLayout'; + +const NAVIGATION = [ + { + title: 'Dashboard', + icon: , + }, + { + title: 'Orders', + icon: , + }, +]; + +const BRANDING = { + logo: MUI logo, + title: 'MUI', +}; + +export default function DashboardLayoutBranding() { + return ( + + + + Dashboard content goes here. + + + + ); +} diff --git a/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBranding.tsx b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBranding.tsx new file mode 100644 index 00000000000..b0a1b6b17d6 --- /dev/null +++ b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutBranding.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; +import { AppProvider } from '@toolpad/core/AppProvider'; +import { DashboardLayout } from '@toolpad/core/DashboardLayout'; +import type { Navigation, Branding } from '@toolpad/core'; + +const NAVIGATION: Navigation = [ + { + title: 'Dashboard', + icon: , + }, + { + title: 'Orders', + icon: , + }, +]; + +const BRANDING: Branding = { + logo: MUI logo, + title: 'MUI', +}; + +export default function DashboardLayoutBranding() { + return ( + + + + Dashboard content goes here. + + + + ); +} diff --git a/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutNavigation.js b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutNavigation.js new file mode 100644 index 00000000000..c83c0c0593c --- /dev/null +++ b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutNavigation.js @@ -0,0 +1,109 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import DescriptionIcon from '@mui/icons-material/Description'; +import FolderIcon from '@mui/icons-material/Folder'; +import { AppProvider } from '@toolpad/core/AppProvider'; +import { DashboardLayout } from '@toolpad/core/DashboardLayout'; + +const NAVIGATION = [ + { + title: 'Item 1', + icon: , + }, + { + title: 'Item 2', + icon: , + }, + { + title: 'Folder 1', + icon: , + children: [ + { + title: 'Item A1', + icon: , + }, + { + title: 'Item A2', + icon: , + }, + { + title: 'Folder A1', + icon: , + children: [ + { + title: 'Item B1', + icon: , + }, + { + title: 'Item B2', + icon: , + }, + ], + }, + ], + }, + { kind: 'divider' }, + { + kind: 'header', + title: 'Header 1', + }, + { + title: 'Item A', + icon: , + }, + { + kind: 'header', + title: 'Header 2', + }, + { + title: 'Item B', + icon: , + }, + { + title: 'Folder 2', + icon: , + children: [ + { + kind: 'header', + title: 'Header A1', + }, + { + title: 'Item C1', + icon: , + }, + { + title: 'Item C2', + icon: , + }, + { kind: 'divider' }, + { + kind: 'header', + title: 'Header A2', + }, + { + title: 'Item C3', + icon: , + }, + ], + }, +]; + +export default function DashboardLayoutNavigation() { + return ( + + + + Dashboard content goes here. + + + + ); +} diff --git a/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutNavigation.tsx b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutNavigation.tsx new file mode 100644 index 00000000000..07e65802f2c --- /dev/null +++ b/docs/data/toolpad/core/components/dashboard-layout/DashboardLayoutNavigation.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import DescriptionIcon from '@mui/icons-material/Description'; +import FolderIcon from '@mui/icons-material/Folder'; +import { AppProvider } from '@toolpad/core/AppProvider'; +import { DashboardLayout } from '@toolpad/core/DashboardLayout'; +import type { Navigation } from '@toolpad/core'; + +const NAVIGATION: Navigation = [ + { + title: 'Item 1', + icon: , + }, + { + title: 'Item 2', + icon: , + }, + { + title: 'Folder 1', + icon: , + children: [ + { + title: 'Item A1', + icon: , + }, + { + title: 'Item A2', + icon: , + }, + { + title: 'Folder A1', + icon: , + children: [ + { + title: 'Item B1', + icon: , + }, + { + title: 'Item B2', + icon: , + }, + ], + }, + ], + }, + { kind: 'divider' }, + { + kind: 'header', + title: 'Header 1', + }, + { + title: 'Item A', + icon: , + }, + { + kind: 'header', + title: 'Header 2', + }, + { + title: 'Item B', + icon: , + }, + { + title: 'Folder 2', + icon: , + children: [ + { + kind: 'header', + title: 'Header A1', + }, + { + title: 'Item C1', + icon: , + }, + { + title: 'Item C2', + icon: , + }, + { kind: 'divider' }, + { + kind: 'header', + title: 'Header A2', + }, + { + title: 'Item C3', + icon: , + }, + ], + }, +]; + +export default function DashboardLayoutNavigation() { + return ( + + + + Dashboard content goes here. + + + + ); +} diff --git a/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md b/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md new file mode 100644 index 00000000000..86f2aeccf8b --- /dev/null +++ b/docs/data/toolpad/core/components/dashboard-layout/dashboard-layout.md @@ -0,0 +1,23 @@ +# Dashboard Layout + +

The dashboard layout component provides a customizable out-of-the-box layout for a typical dashboard page.

+ +## Demo + +A Dashboard Layout has a configurable header and sidebar with navigation. + +{{"demo": "DashboardLayoutBasic.js", "height": 500, "iframe": true}} + +Some features of this layout depend on the `AppProvider` component that must be present at the base application level. + +## Branding + +The `branding` prop in the `AppProvider` allows for setting a `logo` or `title` in the page header. + +{{"demo": "DashboardLayoutBranding.js", "height": 500, "iframe": true}} + +## Navigation + +The `navigation` prop in the `AppProvider` allows for setting any type of navigation structure in the sidebar, such as links, headings, nested collapsible lists and dividers, in any order. + +{{"demo": "DashboardLayoutNavigation.js", "height": 640, "iframe": true}} diff --git a/docs/data/toolpad/core/components/dashboard.md b/docs/data/toolpad/core/components/dashboard.md deleted file mode 100644 index 98975441fdf..00000000000 --- a/docs/data/toolpad/core/components/dashboard.md +++ /dev/null @@ -1,3 +0,0 @@ -# Dashboard - -

Dashboard component

diff --git a/docs/data/toolpad/core/components/index.md b/docs/data/toolpad/core/components/index.md index 61b8c61eb76..d08d3d85418 100644 --- a/docs/data/toolpad/core/components/index.md +++ b/docs/data/toolpad/core/components/index.md @@ -2,7 +2,7 @@

This page contains an index to the component pages that come with Toolpad Core.

-- [Dashboard](/toolpad/core/components/dashboard/) +- [Dashboard Layout](/toolpad/core/components/dashboard-layout/) - [Data Grid](/toolpad/core/components/data-grid/) - [Line Chart](/toolpad/core/components/line-chart/) - [Select Filter](/toolpad/core/components/select-filter/) diff --git a/docs/data/toolpad/core/pages.ts b/docs/data/toolpad/core/pages.ts index f54c7095622..fc88395282b 100644 --- a/docs/data/toolpad/core/pages.ts +++ b/docs/data/toolpad/core/pages.ts @@ -66,8 +66,8 @@ const pages: MuiPage[] = [ subheader: 'Layout', children: [ { - pathname: '/toolpad/core/components/dashboard', - title: 'Dashboard', + pathname: '/toolpad/core/components/dashboard-layout', + title: 'Dashboard Layout', }, ], }, @@ -110,8 +110,8 @@ const pages: MuiPage[] = [ subheader: 'Components', children: [ { - pathname: '/toolpad/core/api/dashboard', - title: 'Dashboard', + pathname: '/toolpad/core/api/dashboard-layout', + title: 'DashboardLayout', }, { pathname: '/toolpad/core/api/data-grid', diff --git a/docs/package.json b/docs/package.json index 651bb1a8a33..7acb798771e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -36,6 +36,7 @@ "@mui/styles": "5.15.18", "@mui/utils": "5.15.14", "@mui/x-license": "7.2.0", + "@toolpad/core": "workspace:*", "@toolpad/studio": "workspace:*", "@trendmicro/react-interpolate": "0.5.5", "@types/lodash": "4.17.1", diff --git a/docs/pages/toolpad/core/api/dashboard.js b/docs/pages/toolpad/core/api/dashboard-layout.js similarity index 85% rename from docs/pages/toolpad/core/api/dashboard.js rename to docs/pages/toolpad/core/api/dashboard-layout.js index ccd1ebe5a92..6eb70763b78 100644 --- a/docs/pages/toolpad/core/api/dashboard.js +++ b/docs/pages/toolpad/core/api/dashboard-layout.js @@ -1,6 +1,6 @@ import * as React from 'react'; import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; -import * as pageProps from '../../../../data/toolpad/core/api/components/dashboard.md?muiMarkdown'; +import * as pageProps from '../../../../data/toolpad/core/api/components/dashboard-layout.md?muiMarkdown'; export default function Page() { return ; diff --git a/docs/pages/toolpad/core/components/dashboard.js b/docs/pages/toolpad/core/components/dashboard-layout.js similarity index 81% rename from docs/pages/toolpad/core/components/dashboard.js rename to docs/pages/toolpad/core/components/dashboard-layout.js index dbf7a2d6ae4..928b679bea5 100644 --- a/docs/pages/toolpad/core/components/dashboard.js +++ b/docs/pages/toolpad/core/components/dashboard-layout.js @@ -1,6 +1,6 @@ import * as React from 'react'; import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; -import * as pageProps from '../../../../data/toolpad/core/components/dashboard.md?muiMarkdown'; +import * as pageProps from '../../../../data/toolpad/core/components/dashboard-layout/dashboard-layout.md?muiMarkdown'; export default function Page() { return ; diff --git a/docs/scripts/formattedTSDemos.js b/docs/scripts/formattedTSDemos.js index 5f620df7f8e..909f03e01f3 100644 --- a/docs/scripts/formattedTSDemos.js +++ b/docs/scripts/formattedTSDemos.js @@ -154,8 +154,6 @@ async function main(argv) { ...(await getFiles(path.join(workspaceRoot, 'docs/data'))), // new structure ].filter((fileName) => filePattern.test(fileName)); - console.log(tsxFiles); - const buildProject = createTypeScriptProjectBuilder(CORE_TYPESCRIPT_PROJECTS); const project = buildProject('docs', { files: tsxFiles }); diff --git a/packages/create-toolpad-app/src/generateProject.ts b/packages/create-toolpad-app/src/generateProject.ts index 3355f0debd9..dada0be290d 100644 --- a/packages/create-toolpad-app/src/generateProject.ts +++ b/packages/create-toolpad-app/src/generateProject.ts @@ -9,14 +9,30 @@ export default function generateProject( options: GenerateProjectOptions, ): Map { const rootLayoutContent = ` - import { AppProvider } from '@toolpad/core'; + import { AppProvider } from '@toolpad/core/AppProvider'; + import DashboardIcon from "@mui/icons-material/Dashboard"; + import type { Navigation } from "@toolpad/core"; import theme from '../theme'; + + const NAVIGATION: Navigation = [ + { + kind: 'header', + title: 'Main items', + }, + { + slug: '/page', + title: 'Page', + icon: , + }, + ]; export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( - {children} + + {children} + ); @@ -24,69 +40,13 @@ export default function generateProject( `; const dashboardLayoutContent = ` - import { - AppBar, - Badge, - Box, - Container, - Divider, - Drawer, - IconButton, - List, - ListItemButton, - ListItemIcon, - Toolbar, - } from "@mui/material"; - - import HomeIcon from "@mui/icons-material/Home"; - import SettingsIcon from "@mui/icons-material/Settings"; - import NotificationsIcon from "@mui/icons-material/Notifications"; + import { DashboardLayout } from '@toolpad/core/DashboardLayout'; export default function Layout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {children} - - + {children} ); } `; @@ -139,25 +99,31 @@ export default function generateProject( `; const themeContent = ` - "use client" - import { Roboto } from "next/font/google"; + "use client"; import { createTheme } from "@mui/material/styles"; - - const roboto = Roboto({ - weight: ["300", "400", "500", "700"], - subsets: ["latin"], - display: "swap", - }); - - const theme = createTheme({ + + const defaultTheme = createTheme(); + + const theme = createTheme(defaultTheme, { + palette: { + background: { + default: defaultTheme.palette.grey['50'], + }, + }, typography: { - fontFamily: roboto.style.fontFamily, + h6: { + fontWeight: '700', + }, }, components: { MuiAppBar: { styleOverrides: { root: { - boxShadow: "none", + borderWidth: 0, + borderBottomWidth: 1, + borderStyle: 'solid', + borderColor: defaultTheme.palette.divider, + boxShadow: 'none', }, }, }, @@ -168,16 +134,85 @@ export default function generateProject( }, }, }, + MuiIconButton: { + styleOverrides: { + root: { + color: defaultTheme.palette.primary.dark, + padding: 8, + }, + }, + }, + MuiListSubheader: { + styleOverrides: { + root: { + color: defaultTheme.palette.grey['600'], + fontSize: 12, + fontWeight: '700', + height: 40, + paddingLeft: 32, + }, + }, + }, + MuiListItem: { + styleOverrides: { + root: { + paddingTop: 0, + paddingBottom: 0, + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + borderRadius: 8, + '&.Mui-selected': { + '& .MuiListItemIcon-root': { + color: defaultTheme.palette.primary.dark, + }, + '& .MuiTypography-root': { + color: defaultTheme.palette.primary.dark, + }, + '& .MuiSvgIcon-root': { + color: defaultTheme.palette.primary.dark, + }, + '& .MuiTouchRipple-child': { + backgroundColor: defaultTheme.palette.primary.dark, + }, + }, + '& .MuiSvgIcon-root': { + color: defaultTheme.palette.action.active, + }, + }, + }, + }, + MuiListItemText: { + styleOverrides: { + root: { + '& .MuiTypography-root': { + fontWeight: '500', + }, + }, + }, + }, MuiListItemIcon: { styleOverrides: { root: { - minWidth: "28px", + minWidth: 34, + }, + }, + }, + MuiDivider: { + styleOverrides: { + root: { + borderBottomWidth: 2, + marginLeft: '16px', + marginRight: '16px', }, }, }, }, }); - + export default theme; `; diff --git a/packages/toolpad-core/src/AppProvider/AppProvider.test.tsx b/packages/toolpad-core/src/AppProvider/AppProvider.test.tsx index c0601331241..7f40c298cd5 100644 --- a/packages/toolpad-core/src/AppProvider/AppProvider.test.tsx +++ b/packages/toolpad-core/src/AppProvider/AppProvider.test.tsx @@ -4,18 +4,15 @@ import * as React from 'react'; import { describe, test, expect, afterEach } from 'vitest'; -import { render, cleanup, screen } from '@testing-library/react'; -import { createTheme } from '@mui/material/styles'; +import { render, cleanup } from '@testing-library/react'; import { AppProvider } from './AppProvider'; describe('AppProvider', () => { afterEach(cleanup); test('renders content correctly', async () => { - const theme = createTheme(); + const { getByText } = render(Hello world); - render(hello); - - expect(screen.getByText('hello')).toBeTruthy(); + expect(getByText('Hello world')).toBeTruthy(); }); }); diff --git a/packages/toolpad-core/src/AppProvider/AppProvider.tsx b/packages/toolpad-core/src/AppProvider/AppProvider.tsx index 7dc7768cad1..d202839ba59 100644 --- a/packages/toolpad-core/src/AppProvider/AppProvider.tsx +++ b/packages/toolpad-core/src/AppProvider/AppProvider.tsx @@ -1,20 +1,58 @@ import * as React from 'react'; import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; - import type { Theme } from '@emotion/react'; +import { baseTheme } from '../themes'; + +export interface Branding { + title?: string; + logo?: React.ReactNode; +} + +export interface NavigationPageItem { + kind?: 'page'; + title: string; + slug?: string; + icon?: React.ReactNode; + children?: Navigation; +} + +export interface NavigationSubheaderItem { + kind: 'header'; + title: string; +} + +export interface NavigationDividerItem { + kind: 'divider'; +} + +export type NavigationItem = NavigationPageItem | NavigationSubheaderItem | NavigationDividerItem; + +export type Navigation = NavigationItem[]; + +export const BrandingContext = React.createContext(null); + +export const NavigationContext = React.createContext([]); interface AppProviderProps { children: React.ReactNode; - theme: Theme; + theme?: Theme; + branding?: Branding | null; + navigation?: Navigation; } -export function AppProvider({ children, theme }: AppProviderProps) { +export function AppProvider({ + children, + theme = baseTheme, + branding = null, + navigation = [], +}: AppProviderProps) { return ( - {/* CssBaseline kickstarts an elegant, consistent, and simple baseline to build upon. */} - {children} + + {children} + ); } diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.test.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.test.tsx new file mode 100644 index 00000000000..eef83aeb0f8 --- /dev/null +++ b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.test.tsx @@ -0,0 +1,129 @@ +/** + * @vitest-environment jsdom + */ + +import * as React from 'react'; +import { describe, test, expect, afterEach } from 'vitest'; +import { render, cleanup, within, waitFor } from '@testing-library/react'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; +import BarChartIcon from '@mui/icons-material/BarChart'; +import DescriptionIcon from '@mui/icons-material/Description'; +import LayersIcon from '@mui/icons-material/Layers'; +import { DashboardLayout } from './DashboardLayout'; +import { BrandingContext, Navigation, NavigationContext } from '../AppProvider'; + +describe('DashboardLayout', () => { + afterEach(cleanup); + + test('renders content correctly', async () => { + const { getByText } = render(Hello world); + + expect(getByText('Hello world')).toBeTruthy(); + }); + + test('renders branding correctly in header', async () => { + const BRANDING = { + title: 'My Company', + logo: Placeholder Logo, + }; + + const { getByRole } = render( + + Hello world + , + ); + + const header = getByRole('banner'); + + expect(within(header).getByText('My Company')).toBeTruthy(); + expect(within(header).getByAltText('Placeholder Logo')).toBeTruthy(); + }); + + test('navigation works correctly', async () => { + const NAVIGATION: Navigation = [ + { + kind: 'header', + title: 'Main items', + }, + { + title: 'Dashboard', + slug: '/dashboard', + icon: , + }, + { + title: 'Orders', + slug: '/orders', + icon: , + }, + { + kind: 'divider', + }, + { + kind: 'header', + title: 'Analytics', + }, + { + title: 'Reports', + icon: , + children: [ + { + title: 'Sales', + icon: , + }, + { + title: 'Traffic', + icon: , + }, + ], + }, + { + title: 'Integrations', + icon: , + }, + ]; + + const { getByRole } = render( + + Hello world + , + ); + + const navigation = getByRole('navigation'); + + // Check list subheaders + + expect(within(navigation).getByText('Main items')).toBeTruthy(); + expect(within(navigation).getByText('Analytics')).toBeTruthy(); + + // Check list items and their links + + const dashboardItem = within(navigation).getByText('Dashboard'); + const ordersItem = within(navigation).getByText('Orders'); + + expect(dashboardItem).toBeTruthy(); + expect(ordersItem).toBeTruthy(); + + const dashboardItemLink = dashboardItem.closest('a') as HTMLElement; + expect(dashboardItemLink.getAttribute('href')).toBe('/dashboard'); + const ordersItemLink = ordersItem.closest('a') as HTMLElement; + expect(ordersItemLink.getAttribute('href')).toBe('/orders'); + + const reportsItem = within(navigation).getByText('Reports'); + + expect(reportsItem).toBeTruthy(); + expect(within(navigation).getByText('Integrations')).toBeTruthy(); + + expect(within(navigation).queryByText('Sales')).toBeNull(); + expect(within(navigation).queryByText('Traffic')).toBeNull(); + + // Check nested list items + + reportsItem.click(); + + await waitFor(async () => { + expect(within(navigation).getByText('Sales')).toBeTruthy(); + expect(within(navigation).getByText('Traffic')).toBeTruthy(); + }); + }); +}); diff --git a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx index 9cf3fcbbece..8bc1def1159 100644 --- a/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx +++ b/packages/toolpad-core/src/DashboardLayout/DashboardLayout.tsx @@ -1,9 +1,246 @@ import * as React from 'react'; +import { styled } from '@mui/material'; +import AppBar from '@mui/material/AppBar'; +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import Container from '@mui/material/Container'; +import Divider from '@mui/material/Divider'; +import Drawer from '@mui/material/Drawer'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import ListSubheader from '@mui/material/ListSubheader'; +import Stack from '@mui/material/Stack'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { BrandingContext, Navigation, NavigationContext, NavigationPageItem } from '../AppProvider'; +import ToolpadLogo from './ToolpadLogo'; + +const DRAWER_WIDTH = 320; + +// @TODO: Remove temporary usePathname once navigation adapter is implemented + +function subscribe() { + return () => {}; +} + +function getSnapshot() { + return new URL(window.location.href).pathname; +} + +function getServerSnapshot() { + return '/'; +} + +function usePathname() { + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +const LogoContainer = styled('div')({ + position: 'relative', + height: 40, + '& img': { + maxHeight: 40, + }, +}); + +interface DashboardSidebarSubNavigationProps { + subNavigation: Navigation; + basePath?: string; + depth?: number; +} + +function DashboardSidebarSubNavigation({ + subNavigation, + basePath = '', + depth = 0, +}: DashboardSidebarSubNavigationProps) { + const pathname = usePathname(); + + const initialExpandedSidebarItemIds = React.useMemo( + () => + subNavigation + .map((navigationItem, navigationItemIndex) => ({ + navigationItem, + originalIndex: navigationItemIndex, + })) + .filter( + ({ navigationItem }) => + (!navigationItem.kind || navigationItem.kind === 'page') && + navigationItem.children && + navigationItem.children.some((nestedNavigationItem) => { + const navigationItemFullPath = `${basePath}${(nestedNavigationItem as NavigationPageItem).slug ?? ''}`; + + return ( + (!nestedNavigationItem.kind || nestedNavigationItem.kind === 'page') && + navigationItemFullPath === pathname + ); + }), + ) + .map( + ({ navigationItem, originalIndex }) => + `${(navigationItem as NavigationPageItem).title}-${depth}-${originalIndex}`, + ), + [basePath, depth, pathname, subNavigation], + ); + + const [expandedSidebarItemIds, setExpandedSidebarItemIds] = React.useState( + initialExpandedSidebarItemIds, + ); + + const handleSidebarItemClick = React.useCallback( + (itemId: string) => () => { + setExpandedSidebarItemIds((previousValue) => + previousValue.includes(itemId) + ? previousValue.filter((previousValueItemId) => previousValueItemId !== itemId) + : [...previousValue, itemId], + ); + }, + [], + ); + + return ( + + {subNavigation.map((navigationItem, navigationItemIndex) => { + if (navigationItem.kind === 'header') { + return ( + + {navigationItem.title} + + ); + } + + if (navigationItem.kind === 'divider') { + const nextItem = subNavigation[navigationItemIndex + 1]; + + return ( + + ); + } + + const navigationItemFullPath = `${basePath}${navigationItem.slug ?? ''}`; + + const navigationItemId = `${navigationItem.title}-${depth}-${navigationItemIndex}`; + + const isNestedNavigationExpanded = expandedSidebarItemIds.includes(navigationItemId); + + const nestedNavigationCollapseIcon = isNestedNavigationExpanded ? ( + + ) : ( + + ); + + const listItem = ( + + + {navigationItem.icon} + + {navigationItem.children ? nestedNavigationCollapseIcon : null} + + + ); + + return ( + + {navigationItem.slug && !navigationItem.children ? ( + + {listItem} + + ) : ( + listItem + )} + {navigationItem.children ? ( + + + + ) : null} + + ); + })} + + ); +} interface DashboardLayoutProps { children: React.ReactNode; } export function DashboardLayout({ children }: DashboardLayoutProps) { - return children; + const branding = React.useContext(BrandingContext); + const navigation = React.useContext(NavigationContext); + + return ( + + theme.zIndex.drawer + 1, + }} + > + + + + + {branding?.logo ?? } + + theme.palette.primary.main }}> + {branding?.title ?? 'Toolpad'} + + + + + {/* + `1px solid ${theme.palette.divider}`, + }} + > + + + */} + + + + + + + + + + + {children} + + + ); } diff --git a/packages/toolpad-core/src/DashboardLayout/ToolpadLogo.tsx b/packages/toolpad-core/src/DashboardLayout/ToolpadLogo.tsx new file mode 100644 index 00000000000..f5ed7d6a1d8 --- /dev/null +++ b/packages/toolpad-core/src/DashboardLayout/ToolpadLogo.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; + +export default function ToolpadLogo({ size = 40 }: { size?: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/toolpad-core/src/index.ts b/packages/toolpad-core/src/index.ts index 0e09b0381d8..f71afdb4dcf 100644 --- a/packages/toolpad-core/src/index.ts +++ b/packages/toolpad-core/src/index.ts @@ -1,3 +1,4 @@ export { AppProvider } from './AppProvider'; +export type { Branding, Navigation } from './AppProvider'; export { DashboardLayout } from './DashboardLayout'; diff --git a/packages/toolpad-core/src/themes/baseTheme.ts b/packages/toolpad-core/src/themes/baseTheme.ts new file mode 100644 index 00000000000..8ef32b02c81 --- /dev/null +++ b/packages/toolpad-core/src/themes/baseTheme.ts @@ -0,0 +1,112 @@ +import { createTheme } from '@mui/material/styles'; + +const defaultTheme = createTheme(); + +export const baseTheme = createTheme(defaultTheme, { + palette: { + background: { + default: defaultTheme.palette.grey['50'], + }, + }, + typography: { + h6: { + fontWeight: '700', + }, + }, + components: { + MuiAppBar: { + styleOverrides: { + root: { + borderWidth: 0, + borderBottomWidth: 1, + borderStyle: 'solid', + borderColor: defaultTheme.palette.divider, + boxShadow: 'none', + }, + }, + }, + MuiList: { + styleOverrides: { + root: { + padding: 0, + }, + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + color: defaultTheme.palette.primary.dark, + padding: 8, + }, + }, + }, + MuiListSubheader: { + styleOverrides: { + root: { + color: defaultTheme.palette.grey['600'], + fontSize: 12, + fontWeight: '700', + height: 40, + paddingLeft: 32, + }, + }, + }, + MuiListItem: { + styleOverrides: { + root: { + paddingTop: 0, + paddingBottom: 0, + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + borderRadius: 8, + '&.Mui-selected': { + '& .MuiListItemIcon-root': { + color: defaultTheme.palette.primary.dark, + }, + '& .MuiTypography-root': { + color: defaultTheme.palette.primary.dark, + }, + '& .MuiSvgIcon-root': { + color: defaultTheme.palette.primary.dark, + }, + '& .MuiTouchRipple-child': { + backgroundColor: defaultTheme.palette.primary.dark, + }, + }, + '& .MuiSvgIcon-root': { + color: defaultTheme.palette.action.active, + }, + }, + }, + }, + MuiListItemText: { + styleOverrides: { + root: { + '& .MuiTypography-root': { + fontWeight: '500', + }, + }, + }, + }, + MuiListItemIcon: { + styleOverrides: { + root: { + minWidth: 34, + }, + }, + }, + MuiDivider: { + styleOverrides: { + root: { + borderBottomWidth: 2, + marginLeft: '16px', + marginRight: '16px', + }, + }, + }, + }, +}); diff --git a/packages/toolpad-core/src/themes/index.ts b/packages/toolpad-core/src/themes/index.ts new file mode 100644 index 00000000000..4ed30a92200 --- /dev/null +++ b/packages/toolpad-core/src/themes/index.ts @@ -0,0 +1,2 @@ +'use client'; +export * from './baseTheme'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 228e8267565..6b1216dae67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: '@mui/x-license': specifier: 7.2.0 version: 7.2.0(@types/react@18.3.1)(react@18.2.0) + '@toolpad/core': + specifier: workspace:* + version: link:../packages/toolpad-core '@toolpad/studio': specifier: workspace:* version: link:../packages/toolpad-studio @@ -2625,7 +2628,7 @@ packages: resolution: {integrity: sha512-eFa8up2/8cZXLIpkafhaADTXSnl7IsUFCYenRWrARBz0/qZwcT0RBXpys0LJU4+WfPoF2ZG6ew6s2V6izMCwRA==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.24.5 + '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.24.5 '@babel/helper-plugin-utils': 7.24.5 @@ -2743,7 +2746,7 @@ packages: /@docsearch/react@3.6.0(@algolia/client-search@4.23.3)(@types/react@18.3.1)(react-dom@18.2.0)(react@18.2.0)(search-insights@2.13.0): resolution: {integrity: sha512-HUFut4ztcVNmqy9gp/wxNbC7pTOHhgVVkHVGCACTuLhUKUhKAF9KYHJtMiLUJxEqiFLQiuri1fWF8zqwM/cu1w==} peerDependencies: - '@types/react': 18.2.55 + '@types/react': '>= 16.8.0 < 19.0.0' react: '>= 16.8.0 < 19.0.0' react-dom: '>= 16.8.0 < 19.0.0' search-insights: '>= 1 < 3' @@ -4321,7 +4324,7 @@ packages: /@mui/types@7.2.14(@types/react@18.3.1): resolution: {integrity: sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==} peerDependencies: - '@types/react': 18.2.55 + '@types/react': ^17.0.0 || ^18.0.0 peerDependenciesMeta: '@types/react': optional: true @@ -7032,6 +7035,7 @@ packages: /are-we-there-yet@3.0.1: resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. dependencies: delegates: 1.0.0 readable-stream: 3.6.2 @@ -9978,6 +9982,7 @@ packages: /fstream@1.0.12: resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} engines: {node: '>=0.6'} + deprecated: This package is no longer supported. dependencies: graceful-fs: 4.2.11 inherits: 2.0.4 @@ -10003,6 +10008,7 @@ packages: /gauge@4.0.4: resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. dependencies: aproba: 2.0.0 color-support: 1.1.3 @@ -12747,6 +12753,7 @@ packages: /npmlog@6.0.2: resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. dependencies: are-we-there-yet: 3.0.1 console-control-strings: 1.1.0 @@ -14089,6 +14096,7 @@ packages: /read-package-json@6.0.4: resolution: {integrity: sha512-AEtWXYfopBj2z5N5PbkAOeNHRPUg5q+Nen7QLxV8M2zJq1ym6/lCz3fYNTCXe19puu2d06jfHhrP7v/S2PtMMw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + deprecated: This package is no longer supported. Please use @npmcli/package-json instead. dependencies: glob: 10.3.15 json-parse-even-better-errors: 3.0.1