diff --git a/docs/Menu.md b/docs/Menu.md index 14104f6bacc..15f9d3abc89 100644 --- a/docs/Menu.md +++ b/docs/Menu.md @@ -183,16 +183,35 @@ export const MyMenu = () => ( ); ``` -| Prop | Required | Type | Default | Description | -| ------------- | -------- | -------------------- | ------- | ---------------------------------------- | -| `to` | Required | `string | location` | - | The menu item's target. It is passed to a React Router [NavLink](https://reacttraining.com/react-router/web/api/NavLink) component. | -| `primaryText` | Required | `ReactNode` | - | The menu content, displayed when the menu isn't minimized. | -| `leftIcon` | Optional | `ReactNode` | - | The menu icon | +| Prop | Required | Type | Default | Description | +| -------------------------------- | -------- | -------------------- | -------------------- | ---------------------------------------- | +| `to` | Required | `string | location` | - | The menu item's target. It is passed to a React Router [NavLink](https://reacttraining.com/react-router/web/api/NavLink) component. | +| `primaryText` | Required | `ReactNode` | - | The menu content, displayed when the menu isn't minimized. | +| `keyboardShortcut` | Optional | `string` | - | The keyboard shortcut(s) to activate this menu item | +| `keyboardShortcut Representation` | Optional | `ReactNode` | `` | A react node that displays the keyboard shortcut | +| `leftIcon` | Optional | `ReactNode` | - | The menu icon | +| `sx` | Optional | `SxProp` | - | Style overrides, powered by MUI System | Additional props are passed down to [the underling Material UI `` component](https://mui.com/material-ui/api/menu-item/). +### `to` -The `primaryText` prop accepts a string, that react-admin passes through the [translation utility](./Translation.md). Alternately, you can set the menu item content using the `children`, e.g. to display a badge on top of the menu item: +The menu item's target. It is passed to a React Router [NavLink](https://reacttraining.com/react-router/web/api/NavLink) component. + +```tsx +// in src/MyMenu.js +import { Menu } from 'react-admin'; + +export const MyMenu = () => ( + + + +); +``` + +### `primaryText` + +The menu content, displayed when the menu isn't minimized. It accepts a string, that react-admin passes through the [translation utility](./Translation.md). Alternately, you can set the menu item content using the `children`, e.g. to display a badge on top of the menu item: ```jsx import Badge from '@mui/material/Badge'; @@ -212,6 +231,50 @@ export const MyMenu = () => ( Note that if you use the `children` prop, you'll have to translate the menu item content yourself using [`useTranslate`](./useTranslate.md). You'll also need to provide a `primaryText` either way, because it will be rendered in the tooltip when the side menu is collapsed. +### `keyboardShortcut` + +The keyboard shortcut(s) to activate this menu item. Pass a string or an array of string that defines the supported keyboard shortcuts: + +```tsx +export const MyMenu = () => ( + + + +); +``` + +![A menu with keyboard shortcuts displayed](./img/menu-shortcuts.png) + +This leverages the [react-hotkeys-hook](https://github.com/JohannesKlauss/react-hotkeys-hook) library, checkout [their documentation](https://react-hotkeys-hook.vercel.app/docs/documentation/useHotkeys/basic-usage) for more examples. + +### `keyboardShortcutRepresentation` + +A React node that displays the keyboard shortcut. It defaults to ``. You can customize it by providing your own: + +```tsx +const CustomMenu = () => ( + + + +); +``` + +![A menu with keyboard shortcuts displayed](./img/menu-custom-shortcuts.png) + + +### `leftIcon` + The `letfIcon` prop allows setting the menu left icon. ```jsx @@ -231,7 +294,16 @@ export const MyMenu = () => ( ); ``` -Additional props are passed down to [the underling Material UI `` component](https://mui.com/material-ui/api/menu-item/). +### `sx` + +You can use the `sx` prop to customize the style of the component. + +| Rule name | Description | +|-----------------------------|---------------------------------------------------------------------| +| `&.RaMenuItemLink-active` | Applied to the underlying `MuiMenuItem`'s `activeClassName` prop | +| `& .RaMenuItemLink-icon` | Applied to the `ListItemIcon` component when `leftIcon` prop is set | + +To override the style of all instances of `` using the [application-wide style overrides](./AppTheme.md#theming-individual-components), use the `RaMenuItemLink` key. **Tip**: The `` component makes use of the React Router [NavLink](https://reactrouter.com/docs/en/v6/components/nav-link) component, hence allowing to customize the active menu style. For instance, here is how to use a custom theme to show a left border for the active menu: @@ -260,15 +332,6 @@ export const theme = { }; ``` -You can use the `sx` prop to customize the style of the component. - -| Rule name | Description | -|-----------------------------|---------------------------------------------------------------------| -| `&.RaMenuItemLink-active` | Applied to the underlying `MuiMenuItem`'s `activeClassName` prop | -| `& .RaMenuItemLink-icon` | Applied to the `ListItemIcon` component when `leftIcon` prop is set | - -To override the style of all instances of `` using the [application-wide style overrides](./AppTheme.md#theming-individual-components), use the `RaMenuItemLink` key. - ## `` The `` component displays a menu item for the dashboard. diff --git a/docs/img/menu-custom-shortcuts.png b/docs/img/menu-custom-shortcuts.png new file mode 100644 index 00000000000..a62b130fe51 Binary files /dev/null and b/docs/img/menu-custom-shortcuts.png differ diff --git a/docs/img/menu-shortcuts.png b/docs/img/menu-shortcuts.png new file mode 100644 index 00000000000..93f83aee9fb Binary files /dev/null and b/docs/img/menu-shortcuts.png differ diff --git a/examples/simple/src/Layout.tsx b/examples/simple/src/Layout.tsx index f17e8f98f70..4935a4e6da4 100644 --- a/examples/simple/src/Layout.tsx +++ b/examples/simple/src/Layout.tsx @@ -1,6 +1,12 @@ import * as React from 'react'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { AppBar, Layout, InspectorButton, TitlePortal } from 'react-admin'; +import { + AppBar, + Layout, + Menu, + InspectorButton, + TitlePortal, +} from 'react-admin'; import '../assets/app.css'; const MyAppBar = () => ( @@ -10,9 +16,20 @@ const MyAppBar = () => ( ); +const MyMenu = () => ( + + + + + + +); + export default ({ children }) => ( <> - {children} + + {children} + ( + + + {children} + + +); + +export const Default = () => ( + + + } + > + + + + } + > + + + } + > + + + } + > + + + + } + > + + + } + > + + + + } + > + + + } + > + + + + } + > + + + + } + > + + + } + > + + + + } + > + + + + +); diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx new file mode 100644 index 00000000000..d316362c727 --- /dev/null +++ b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx @@ -0,0 +1,122 @@ +import * as React from 'react'; +import { + ComponentsOverrides, + styled, + SxProps, + Typography, +} from '@mui/material'; +import clsx from 'clsx'; + +export const KeyboardShortcut = ({ + className, + keyboardShortcut, + ...rest +}: KeyboardShortcutProps) => { + if (!keyboardShortcut) { + return null; + } + + return ( + + {keyboardShortcut + .split('>') + .map((sequence, sequenceIndex, sequences) => ( + + {sequence.split('+').map((key, keyIndex) => ( + + + {KeyMap[key] + ? KeyMap[key] + : key.toUpperCase()} + + + ))} + {sequenceIndex < sequences.length - 1 ? ( + <>  + ) : null} + + ))} + + ); +}; + +const KeyMap = { + meta: '⌘', + mod: '⌘', + ctrl: '⌃', + shift: '⇧', + alt: '⌥', + enter: '⏎', + esc: '⎋', + escape: '⎋', + backspace: '⌫', + delete: '⌦', + tab: '⇥', + space: '␣', + up: '↑', + down: '↓', + left: '←', + right: '→', + home: '↖', + end: '↘', + pageup: '⇞', + pagedown: '⇟', +}; + +export interface KeyboardShortcutProps + extends React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + > { + keyboardShortcut?: string; + sx?: SxProps; +} + +const PREFIX = 'RaKeyboardShortcut'; +const KeyboardShortcutClasses = { + root: `${PREFIX}-root`, + kbd: `${PREFIX}-kbd`, +}; + +const Root = styled('div')(({ theme }) => ({ + opacity: 0.7, + [`& .${KeyboardShortcutClasses.kbd}`]: { + padding: '4px 5px', + display: 'inline-block', + whiteSpace: 'nowrap', + margin: '0 1px', + fontSize: '11px', + lineHeight: '10px', + color: theme.palette.text.primary, + verticalAlign: 'middle', + border: `1px solid ${theme.palette.divider}`, + borderRadius: 6, + boxShadow: `inset 0 -1px 0 ${theme.palette.divider}`, + }, +})); + +declare module '@mui/material/styles' { + interface ComponentNameToClassKey { + [PREFIX]: 'root' | 'kbd'; + } + + interface ComponentsPropsList { + [PREFIX]: Partial; + } + + interface Components { + [PREFIX]?: { + defaultProps?: ComponentsPropsList[typeof PREFIX]; + styleOverrides?: ComponentsOverrides< + Omit + >[typeof PREFIX]; + }; + } +} diff --git a/packages/ra-ui-materialui/src/field/DateField.tsx b/packages/ra-ui-materialui/src/field/DateField.tsx index 53da22c1e35..2ec473d8975 100644 --- a/packages/ra-ui-materialui/src/field/DateField.tsx +++ b/packages/ra-ui-materialui/src/field/DateField.tsx @@ -148,7 +148,6 @@ const toLocaleStringSupportsLocales = (() => { })(); const PREFIX = 'RaDateField'; - const StyledTypography = styled(Typography, { name: PREFIX, overridesResolver: (props, styles) => styles.root, diff --git a/packages/ra-ui-materialui/src/index.ts b/packages/ra-ui-materialui/src/index.ts index 82696d33f40..6ef5bfa3d89 100644 --- a/packages/ra-ui-materialui/src/index.ts +++ b/packages/ra-ui-materialui/src/index.ts @@ -12,3 +12,4 @@ export * from './list'; export * from './preferences'; export * from './AdminUI'; export * from './AdminContext'; +export * from './KeyboardShortcut'; diff --git a/packages/ra-ui-materialui/src/layout/DashboardMenuItem.tsx b/packages/ra-ui-materialui/src/layout/DashboardMenuItem.tsx index d04b65dae4a..7dfee3d4c0b 100644 --- a/packages/ra-ui-materialui/src/layout/DashboardMenuItem.tsx +++ b/packages/ra-ui-materialui/src/layout/DashboardMenuItem.tsx @@ -1,6 +1,5 @@ import React from 'react'; import DashboardIcon from '@mui/icons-material/Dashboard'; -import { To } from 'react-router'; import { useBasename } from 'ra-core'; import { MenuItemLink, MenuItemLinkProps } from './MenuItemLink'; @@ -24,8 +23,9 @@ export const DashboardMenuItem = (props: DashboardMenuItemProps) => { ); }; -export interface DashboardMenuItemProps extends Omit { - to?: To; +export interface DashboardMenuItemProps + extends Omit, + Partial> { /** * @deprecated */ diff --git a/packages/ra-ui-materialui/src/layout/Menu.spec.tsx b/packages/ra-ui-materialui/src/layout/Menu.spec.tsx new file mode 100644 index 00000000000..7dfa8302ef1 --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/Menu.spec.tsx @@ -0,0 +1,76 @@ +import * as React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Default, WithDashboard, WithKeyboardShortcuts } from './Menu.stories'; + +describe('', () => { + it('should render a default menu with items for all registered resources', async () => { + render(); + await screen.findByText('Posts', { selector: '[role="menuitem"] *' }); + await screen.findByText('Comments', { + selector: '[role="menuitem"] *', + }); + await screen.findByText('Tags', { selector: '[role="menuitem"] *' }); + await screen.findByText('Users', { selector: '[role="menuitem"] *' }); + await screen.findByText('Orders', { selector: '[role="menuitem"] *' }); + await screen.findByText('Reviews', { selector: '[role="menuitem"] *' }); + }); + + it('should render a default menu with items for all registered resources and the dashboard', async () => { + render(); + await screen.findByText('Dashboard', { + selector: '[role="menuitem"] *', + }); + await screen.findByText('Posts', { selector: '[role="menuitem"] *' }); + await screen.findByText('Comments', { + selector: '[role="menuitem"] *', + }); + await screen.findByText('Tags', { selector: '[role="menuitem"] *' }); + await screen.findByText('Users', { selector: '[role="menuitem"] *' }); + await screen.findByText('Orders', { selector: '[role="menuitem"] *' }); + await screen.findByText('Reviews', { selector: '[role="menuitem"] *' }); + }); + + it('should support keyboard shortcuts', async () => { + render(); + await screen.findByText('Dashboard', { + selector: '[role="menuitem"] *', + }); + fireEvent.keyDown(global.document, { + key: 'g', + code: 'KeyG', + }); + fireEvent.keyDown(global.document, { + key: 'c', + code: 'KeyC', + }); + // Only one Customers text as the menu item has a different longer label + await screen.findByText('Customers'); + fireEvent.keyDown(global.document, { + key: 'g', + code: 'KeyG', + }); + fireEvent.keyDown(global.document, { + key: 's', + code: 'KeyS', + }); + expect(await screen.findAllByText('Sales')).toHaveLength(2); + fireEvent.keyDown(global.document, { + key: 'g', + code: 'KeyG', + }); + fireEvent.keyDown(global.document, { + key: 'p', + code: 'KeyP', + }); + expect(await screen.findAllByText('Products')).toHaveLength(2); + fireEvent.keyDown(global.document, { + key: 'g', + code: 'KeyG', + }); + fireEvent.keyDown(global.document, { + key: 'd', + code: 'KeyD', + }); + expect(await screen.findAllByText('Dashboard')).toHaveLength(2); + }); +}); diff --git a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx index a6ce24edc06..1ac8c616b29 100644 --- a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx +++ b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx @@ -27,7 +27,7 @@ import ExpandMore from '@mui/icons-material/ExpandMore'; import QrCode from '@mui/icons-material/QrCode'; import { Route } from 'react-router-dom'; -import { Layout, Menu, Title } from '.'; +import { Layout, Menu, MenuItemLinkClasses, Title } from '.'; export default { title: 'ra-ui-materialui/layout/Menu' }; @@ -63,6 +63,31 @@ export const Default = () => { ); }; +export const WithDashboard = () => { + const MenuDefault = () => ; + const DefaultLayout = ({ children }) => ( + {children} + ); + const Dashboard = () => ; + + return ( + + {resources.map((resource, index) => ( + } + /> + ))} + + ); +}; + export const Dense = () => { const MenuDense = () => ; const LayoutDense = ({ children }) => ( @@ -135,6 +160,182 @@ export const Custom = () => { ); }; +export const WithKeyboardShortcuts = () => { + const CustomMenu = () => ( + + + } + primaryText="Sales" + keyboardShortcut="G>S" + /> + } + primaryText="Customers very long" + keyboardShortcut="G>C" + /> + } + keyboardShortcut="G>P" + /> + + ); + const CustomLayout = ({ children }) => ( + {children} + ); + + const Dashboard = () => ; + return ( + + + } /> + + } /> + } + /> + + + + ); +}; + +export const WithCustomKeyboardShortcutRepresentation = () => { + const CustomMenu = () => ( + + + } + primaryText="Sales" + keyboardShortcut="ctrl+alt+S" + keyboardShortcutRepresentation="ctrl+alt+S" + /> + } + primaryText="Customers very long" + keyboardShortcut="ctrl+alt+C" + keyboardShortcutRepresentation="ctrl+alt+C" + /> + } + keyboardShortcut="ctrl+alt+P" + keyboardShortcutRepresentation="ctrl+alt+P" + /> + + ); + const CustomLayout = ({ children }) => ( + {children} + ); + + const Dashboard = () => ; + return ( + + + } /> + + } /> + } + /> + + + + ); +}; + +export const WithCustomKeyboardShortcutRepresentationUsingMenuItemClasses = + () => { + const CustomMenu = () => ( + + + ctrl+alt+D + + } + /> + } + primaryText="Sales" + keyboardShortcut="ctrl+alt+S" + keyboardShortcutRepresentation={ +
+ ctrl+alt+S +
+ } + /> + } + primaryText="Customers very long" + keyboardShortcut="ctrl+alt+C" + keyboardShortcutRepresentation={ +
+ ctrl+alt+C +
+ } + /> + } + keyboardShortcut="ctrl+alt+P" + keyboardShortcutRepresentation={ +
+ ctrl+alt+P +
+ } + /> +
+ ); + const CustomLayout = ({ children }) => ( + {children} + ); + + const Dashboard = () => ; + return ( + + + } + /> + + } /> + } + /> + + + + ); + }; + const Page = ({ title }) => ( <> diff --git a/packages/ra-ui-materialui/src/layout/Menu.tsx b/packages/ra-ui-materialui/src/layout/Menu.tsx index d38b9d404fb..53b7ddf91ea 100644 --- a/packages/ra-ui-materialui/src/layout/Menu.tsx +++ b/packages/ra-ui-materialui/src/layout/Menu.tsx @@ -44,7 +44,12 @@ export const Menu = (inProps: MenuProps) => { props: inProps, name: PREFIX, }); - const { children, className, ...rest } = props; + const { + children, + className, + hasDashboard: hasDashboardProp, + ...rest + } = props; const hasDashboard = useHasDashboard(); const [open] = useSidebarState(); diff --git a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx index feea445a2f2..f448e47f8f5 100644 --- a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx +++ b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx @@ -1,6 +1,7 @@ import React, { forwardRef, useCallback, + useRef, type ReactElement, type ReactNode, } from 'react'; @@ -19,10 +20,13 @@ import { type TooltipProps, useMediaQuery, Theme, + useForkRef, + Typography, } from '@mui/material'; - +import { useTranslate, useBasename, useEvent } from 'ra-core'; +import { useHotkeys } from 'react-hotkeys-hook'; import { useSidebarState } from './useSidebarState'; -import { useTranslate, useBasename } from 'ra-core'; +import { KeyboardShortcut } from '../KeyboardShortcut'; /** * Displays a menu item with a label and an icon - or only the icon with a tooltip when the sidebar is minimized. @@ -91,6 +95,8 @@ export const MenuItemLink = forwardRef( sidebarIsOpen, tooltipProps, children, + keyboardShortcut, + keyboardShortcutRepresentation, ...rest } = props; @@ -115,15 +121,24 @@ export const MenuItemLink = forwardRef( (typeof props.to === 'string' ? props.to : props.to.pathname) || ''; const match = useMatch({ path: to, end: to === `${basename}/` }); + const itemRef = useRef(null); + // Use a forked ref allows us to have a ref locally without losing the one passed by users + const forkedRef = useForkRef(itemRef, ref); + + const handleShortcut = useEvent(() => itemRef.current?.click()); + useHotkeys(keyboardShortcut ?? [], handleShortcut, { + enabled: keyboardShortcut != null, + }); + const renderMenuItem = () => { return ( ( {leftIcon} )} - {children - ? children - : typeof primaryText === 'string' - ? translate(primaryText, { _: primaryText }) - : primaryText} + + {children + ? children + : typeof primaryText === 'string' + ? translate(primaryText, { + _: primaryText, + }) + : primaryText} + + {keyboardShortcut + ? keyboardShortcutRepresentation ?? ( + + ) + : null} ); }; - return open ? ( - renderMenuItem() - ) : ( + if (open) { + return renderMenuItem(); + } + + return ( ({ color: (theme.vars || theme).palette.text.secondary, + [`& .${MenuItemLinkClasses.icon}`]: { + color: (theme.vars || theme).palette.text.secondary, + }, + + [`& .${MenuItemLinkClasses.shortcut}`]: { + color: (theme.vars || theme).palette.text.secondary, + fontSize: theme.typography.body2.fontSize, + opacity: 0, + display: 'none', + transition: 'opacity 0.3s', + }, + + [`&:hover .${MenuItemLinkClasses.shortcut}`]: { + opacity: 0.7, + display: 'inline-flex', + }, + [`&.${MenuItemLinkClasses.active}`]: { color: (theme.vars || theme).palette.text.primary, }, @@ -202,19 +252,19 @@ const LinkRef = forwardRef((props, ref) => ( declare module '@mui/material/styles' { interface ComponentNameToClassKey { - RaMenuItemLink: 'root' | 'active' | 'icon'; + [PREFIX]: 'root' | 'active' | 'icon' | 'shortcut'; } interface ComponentsPropsList { - RaMenuItemLink: Partial; + [PREFIX]: Partial; } interface Components { - RaMenuItemLink?: { - defaultProps?: ComponentsPropsList['RaMenuItemLink']; + [PREFIX]?: { + defaultProps?: ComponentsPropsList[typeof PREFIX]; styleOverrides?: ComponentsOverrides< Omit - >['RaMenuItemLink']; + >[typeof PREFIX]; }; } } diff --git a/packages/ra-ui-materialui/src/layout/ResourceMenuItem.tsx b/packages/ra-ui-materialui/src/layout/ResourceMenuItem.tsx index 694d7a32c95..05a48646121 100644 --- a/packages/ra-ui-materialui/src/layout/ResourceMenuItem.tsx +++ b/packages/ra-ui-materialui/src/layout/ResourceMenuItem.tsx @@ -9,9 +9,9 @@ import { useCanAccess, } from 'ra-core'; -import { MenuItemLink } from './MenuItemLink'; +import { MenuItemLink, MenuItemLinkProps } from './MenuItemLink'; -export const ResourceMenuItem = ({ name }: { name: string }) => { +export const ResourceMenuItem = ({ name, ...rest }: ResourceMenuItemProps) => { const resources = useResourceDefinitions(); const { canAccess, error, isPending } = useCanAccess({ action: 'list', @@ -42,6 +42,13 @@ export const ResourceMenuItem = ({ name }: { name: string }) => { ) } + {...rest} /> ); }; + +export interface ResourceMenuItemProps + extends Omit, + Partial> { + name: string; +} diff --git a/yarn.lock b/yarn.lock index cd71595216b..e9c09dd47c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16333,6 +16333,7 @@ __metadata: react-dropzone: "npm:^14.2.3" react-error-boundary: "npm:^4.0.13" react-hook-form: "npm:^7.53.0" + react-hotkeys-hook: "npm:^5.1.0" react-is: "npm:^18.2.0 || ^19.0.0" react-router: "npm:^6.28.1" react-router-dom: "npm:^6.28.1" @@ -16625,6 +16626,16 @@ __metadata: languageName: node linkType: hard +"react-hotkeys-hook@npm:^5.1.0": + version: 5.1.0 + resolution: "react-hotkeys-hook@npm:5.1.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 99df6d3c305b139ac7afd073b58575961bf30a819fb23e8f1251b1b3a9f1c7662737f8b6266e3fc42bd5bdfdaca81aa1e019613f95f9a6313267de265e45836d + languageName: node + linkType: hard + "react-i18next@npm:^14.1.1": version: 14.1.1 resolution: "react-i18next@npm:14.1.1"