From 25deb153ddc1a47fa25243b51d3bc0efc7a49e5f Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 18 Jun 2025 16:36:31 +0200 Subject: [PATCH 01/17] Add support for keyboard shortcuts to `` --- docs/Menu.md | 68 +++++++++++++---- jest.config.js | 2 +- packages/ra-ui-materialui/package.json | 1 + .../ra-ui-materialui/src/KeyboardShortcut.tsx | 19 +++++ .../src/getKeyboardShortcutLabel.ts | 10 +++ packages/ra-ui-materialui/src/index.ts | 2 + .../src/layout/DashboardMenuItem.tsx | 6 +- .../ra-ui-materialui/src/layout/Menu.spec.tsx | 59 +++++++++++++++ .../src/layout/Menu.stories.tsx | 73 +++++++++++++++++++ packages/ra-ui-materialui/src/layout/Menu.tsx | 7 +- .../src/layout/MenuItemLink.tsx | 47 ++++++++++-- .../src/layout/ResourceMenuItem.tsx | 11 ++- yarn.lock | 11 +++ 13 files changed, 287 insertions(+), 29 deletions(-) create mode 100644 packages/ra-ui-materialui/src/KeyboardShortcut.tsx create mode 100644 packages/ra-ui-materialui/src/getKeyboardShortcutLabel.ts create mode 100644 packages/ra-ui-materialui/src/layout/Menu.spec.tsx diff --git a/docs/Menu.md b/docs/Menu.md index 14104f6bacc..846f1f29a1e 100644 --- a/docs/Menu.md +++ b/docs/Menu.md @@ -183,16 +183,34 @@ 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 | string[]` | - | The keyboard shortcut(s) to activate this menu item | +| `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 +230,24 @@ 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 = () => ( + + + +); +``` + +### `leftIcon` + The `letfIcon` prop allows setting the menu left icon. ```jsx @@ -231,7 +267,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 +305,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/jest.config.js b/jest.config.js index bbdd9c727ef..83942a5f39d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,7 +25,7 @@ module.exports = { '/packages/create-react-admin/templates', ], transformIgnorePatterns: [ - '[/\\\\]node_modules[/\\\\](?!(@hookform)/).+\\.(js|jsx|mjs|ts|tsx)$', + '[/\\\\]node_modules[/\\\\](?!(@hookform|react-hotkeys-hook)/).+\\.(js|jsx|mjs|ts|tsx)$', ], transform: { // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 7499398f5ef..569a73aee34 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -79,6 +79,7 @@ "query-string": "^7.1.3", "react-dropzone": "^14.2.3", "react-error-boundary": "^4.0.13", + "react-hotkeys-hook": "^5.1.0", "react-transition-group": "^4.4.5" }, "gitHead": "587df4c27bfcec4a756df4f95e5fc14728dfc0d7" diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx new file mode 100644 index 00000000000..bf4dbb77e0f --- /dev/null +++ b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx @@ -0,0 +1,19 @@ +import { + type HotkeyCallback, + type Keys, + type Options, + useHotkeys, +} from 'react-hotkeys-hook'; + +export const KeyboardShortcut = (props: KeyboardShortcutProps) => { + const { callback, dependencies, keys, options } = props; + useHotkeys(keys, callback, options, dependencies); + return null; +}; + +export interface KeyboardShortcutProps { + keys: Keys; + callback: HotkeyCallback; + options?: Options; + dependencies?: readonly unknown[]; +} diff --git a/packages/ra-ui-materialui/src/getKeyboardShortcutLabel.ts b/packages/ra-ui-materialui/src/getKeyboardShortcutLabel.ts new file mode 100644 index 00000000000..7c5cee6de54 --- /dev/null +++ b/packages/ra-ui-materialui/src/getKeyboardShortcutLabel.ts @@ -0,0 +1,10 @@ +import { Keys } from 'react-hotkeys-hook'; + +export const getKeyboardShortcutLabel = (keyboardShortcut: Keys) => { + if (typeof keyboardShortcut === 'string') { + return keyboardShortcut.split('+').join(' + '); + } + return keyboardShortcut + .map(shortcut => getKeyboardShortcutLabel(shortcut)) + .join(', '); +}; diff --git a/packages/ra-ui-materialui/src/index.ts b/packages/ra-ui-materialui/src/index.ts index 82696d33f40..11f692c6de9 100644 --- a/packages/ra-ui-materialui/src/index.ts +++ b/packages/ra-ui-materialui/src/index.ts @@ -12,3 +12,5 @@ export * from './list'; export * from './preferences'; export * from './AdminUI'; export * from './AdminContext'; +export * from './KeyboardShortcut'; +export * from './getKeyboardShortcutLabel'; 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..b8690da3645 --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/Menu.spec.tsx @@ -0,0 +1,59 @@ +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: 'c', + code: 'KeyC', + ctrlKey: true, + altKey: true, + }); + expect(await screen.findAllByText('Customers')).toHaveLength(2); + fireEvent.keyDown(global.document, { + key: 's', + code: 'KeyS', + ctrlKey: true, + altKey: true, + }); + expect(await screen.findAllByText('Sales')).toHaveLength(2); + fireEvent.keyDown(global.document, { + key: 'p', + code: 'KeyP', + ctrlKey: true, + altKey: true, + }); + expect(await screen.findAllByText('Products')).toHaveLength(2); + fireEvent.keyDown(global.document, { + key: 'd', + code: 'KeyD', + ctrlKey: true, + altKey: true, + }); + 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..f12fd31c108 100644 --- a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx +++ b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx @@ -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,54 @@ export const Custom = () => { ); }; +export const WithKeyboardShortcuts = () => { + const CustomMenu = () => ( + + + } + primaryText="Sales" + keyboardShortcut="ctrl+alt+S" + /> + } + primaryText="Customers" + keyboardShortcut="ctrl+alt+C" + /> + } + keyboardShortcut="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..665deabf643 100644 --- a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx +++ b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx @@ -1,6 +1,8 @@ import React, { forwardRef, + lazy, useCallback, + useRef, type ReactElement, type ReactNode, } from 'react'; @@ -19,10 +21,18 @@ import { type TooltipProps, useMediaQuery, Theme, + useForkRef, } from '@mui/material'; - +import { useTranslate, useBasename, useEvent } from 'ra-core'; +import type { Keys } from 'react-hotkeys-hook'; import { useSidebarState } from './useSidebarState'; -import { useTranslate, useBasename } from 'ra-core'; +import { getKeyboardShortcutLabel } from '../getKeyboardShortcutLabel'; + +const KeyboardShortcut = lazy(() => + import('../KeyboardShortcut').then(module => ({ + default: module.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 +101,7 @@ export const MenuItemLink = forwardRef( sidebarIsOpen, tooltipProps, children, + keyboardShortcut, ...rest } = props; @@ -115,6 +126,10 @@ 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); + const forkedRef = useForkRef(itemRef, ref); + const handleShortcut = useEvent(() => itemRef.current?.click()); + const renderMenuItem = () => { return ( ( })} // @ts-ignore component={LinkRef} - ref={ref} + ref={forkedRef} tabIndex={0} {...rest} onClick={handleMenuTap} > + {keyboardShortcut ? ( + + ) : null} {leftIcon && ( {leftIcon} @@ -141,10 +162,23 @@ export const MenuItemLink = forwardRef( ); }; + if (open && keyboardShortcut == null) { + return renderMenuItem(); + } + + if (open && keyboardShortcut != null) { + return ( + + {renderMenuItem()} + + ); + } - return open ? ( - renderMenuItem() - ) : ( + return ( { +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 0e7e0ca3705..7cd299b9406 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" From 212100cf8822df26f7ca66b245340bd25a0b6e41 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 18 Jun 2025 17:06:56 +0200 Subject: [PATCH 02/17] Don't lazy load KeyboardShortcut (tsc incompatibility) --- packages/ra-ui-materialui/src/layout/MenuItemLink.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx index 665deabf643..7c39b6a3e46 100644 --- a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx +++ b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx @@ -1,6 +1,5 @@ import React, { forwardRef, - lazy, useCallback, useRef, type ReactElement, @@ -26,14 +25,9 @@ import { import { useTranslate, useBasename, useEvent } from 'ra-core'; import type { Keys } from 'react-hotkeys-hook'; import { useSidebarState } from './useSidebarState'; +import { KeyboardShortcut } from '../KeyboardShortcut'; import { getKeyboardShortcutLabel } from '../getKeyboardShortcutLabel'; -const KeyboardShortcut = lazy(() => - import('../KeyboardShortcut').then(module => ({ - default: module.KeyboardShortcut, - })) -); - /** * Displays a menu item with a label and an icon - or only the icon with a tooltip when the sidebar is minimized. * It also handles the automatic closing of the menu on tap on mobile. From 9599edff9c4840605394e26540e257d48616cb83 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 19 Jun 2025 10:02:07 +0200 Subject: [PATCH 03/17] Move keyboard shortcuts code to ra-core --- packages/ra-core/package.json | 1 + .../src => ra-core/src/util}/KeyboardShortcut.tsx | 0 .../src/util}/getKeyboardShortcutLabel.ts | 0 packages/ra-core/src/util/index.ts | 2 ++ packages/ra-ui-materialui/package.json | 1 - packages/ra-ui-materialui/src/index.ts | 2 -- packages/ra-ui-materialui/src/layout/MenuItemLink.tsx | 10 +++++++--- yarn.lock | 2 +- 8 files changed, 11 insertions(+), 7 deletions(-) rename packages/{ra-ui-materialui/src => ra-core/src/util}/KeyboardShortcut.tsx (100%) rename packages/{ra-ui-materialui/src => ra-core/src/util}/getKeyboardShortcutLabel.ts (100%) diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index cb4363621a3..37e90c026ec 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -66,6 +66,7 @@ "lodash": "^4.17.21", "query-string": "^7.1.3", "react-error-boundary": "^4.0.13", + "react-hotkeys-hook": "^5.1.0", "react-is": "^18.2.0 || ^19.0.0" }, "gitHead": "587df4c27bfcec4a756df4f95e5fc14728dfc0d7" diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx b/packages/ra-core/src/util/KeyboardShortcut.tsx similarity index 100% rename from packages/ra-ui-materialui/src/KeyboardShortcut.tsx rename to packages/ra-core/src/util/KeyboardShortcut.tsx diff --git a/packages/ra-ui-materialui/src/getKeyboardShortcutLabel.ts b/packages/ra-core/src/util/getKeyboardShortcutLabel.ts similarity index 100% rename from packages/ra-ui-materialui/src/getKeyboardShortcutLabel.ts rename to packages/ra-core/src/util/getKeyboardShortcutLabel.ts diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index 2d83c5bcbe5..297dff067bd 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -28,3 +28,5 @@ export * from './asyncDebounce'; export * from './hooks'; export * from './shallowEqual'; export * from './useCheckForApplicationUpdate'; +export * from './getKeyboardShortcutLabel'; +export * from './KeyboardShortcut'; diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 569a73aee34..7499398f5ef 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -79,7 +79,6 @@ "query-string": "^7.1.3", "react-dropzone": "^14.2.3", "react-error-boundary": "^4.0.13", - "react-hotkeys-hook": "^5.1.0", "react-transition-group": "^4.4.5" }, "gitHead": "587df4c27bfcec4a756df4f95e5fc14728dfc0d7" diff --git a/packages/ra-ui-materialui/src/index.ts b/packages/ra-ui-materialui/src/index.ts index 11f692c6de9..82696d33f40 100644 --- a/packages/ra-ui-materialui/src/index.ts +++ b/packages/ra-ui-materialui/src/index.ts @@ -12,5 +12,3 @@ export * from './list'; export * from './preferences'; export * from './AdminUI'; export * from './AdminContext'; -export * from './KeyboardShortcut'; -export * from './getKeyboardShortcutLabel'; diff --git a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx index 7c39b6a3e46..e5933cfaca6 100644 --- a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx +++ b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx @@ -22,11 +22,15 @@ import { Theme, useForkRef, } from '@mui/material'; -import { useTranslate, useBasename, useEvent } from 'ra-core'; +import { + getKeyboardShortcutLabel, + KeyboardShortcut, + useTranslate, + useBasename, + useEvent, +} from 'ra-core'; import type { Keys } from 'react-hotkeys-hook'; import { useSidebarState } from './useSidebarState'; -import { KeyboardShortcut } from '../KeyboardShortcut'; -import { getKeyboardShortcutLabel } from '../getKeyboardShortcutLabel'; /** * Displays a menu item with a label and an icon - or only the icon with a tooltip when the sidebar is minimized. diff --git a/yarn.lock b/yarn.lock index 7cd299b9406..e76aa79f5d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16050,6 +16050,7 @@ __metadata: react-dom: "npm:^18.3.1" 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" @@ -16333,7 +16334,6 @@ __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" From caf750d941d4e6f13b7cd63f2bb787a76afc68b3 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 19 Jun 2025 11:49:51 +0200 Subject: [PATCH 04/17] Improve documentation --- docs/Menu.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/Menu.md b/docs/Menu.md index 846f1f29a1e..1389a266917 100644 --- a/docs/Menu.md +++ b/docs/Menu.md @@ -246,6 +246,8 @@ export const MyMenu = () => ( ); ``` +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/) for more examples. + ### `leftIcon` The `letfIcon` prop allows setting the menu left icon. From 1a66cc1b82b661731f0be0cbb34e675198386e1a Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:39:57 +0200 Subject: [PATCH 05/17] Improve shortcut support --- packages/ra-core/package.json | 1 - .../ra-core/src/util/KeyboardShortcut.tsx | 19 --- .../src/util/getKeyboardShortcutLabel.ts | 10 -- packages/ra-core/src/util/index.ts | 2 - packages/ra-ui-materialui/package.json | 1 + .../src/KeyboardShortcut.stories.tsx | 24 ++++ .../ra-ui-materialui/src/KeyboardShortcut.tsx | 122 ++++++++++++++++++ .../ra-ui-materialui/src/field/DateField.tsx | 1 - packages/ra-ui-materialui/src/index.ts | 1 + .../src/layout/Menu.stories.tsx | 56 +++++++- .../src/layout/MenuItemLink.tsx | 77 +++++------ yarn.lock | 2 +- 12 files changed, 243 insertions(+), 73 deletions(-) delete mode 100644 packages/ra-core/src/util/KeyboardShortcut.tsx delete mode 100644 packages/ra-core/src/util/getKeyboardShortcutLabel.ts create mode 100644 packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx create mode 100644 packages/ra-ui-materialui/src/KeyboardShortcut.tsx diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 3f0995fdd3a..f8a6e8cb2db 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -66,7 +66,6 @@ "lodash": "^4.17.21", "query-string": "^7.1.3", "react-error-boundary": "^4.0.13", - "react-hotkeys-hook": "^5.1.0", "react-is": "^18.2.0 || ^19.0.0" }, "gitHead": "587df4c27bfcec4a756df4f95e5fc14728dfc0d7" diff --git a/packages/ra-core/src/util/KeyboardShortcut.tsx b/packages/ra-core/src/util/KeyboardShortcut.tsx deleted file mode 100644 index bf4dbb77e0f..00000000000 --- a/packages/ra-core/src/util/KeyboardShortcut.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { - type HotkeyCallback, - type Keys, - type Options, - useHotkeys, -} from 'react-hotkeys-hook'; - -export const KeyboardShortcut = (props: KeyboardShortcutProps) => { - const { callback, dependencies, keys, options } = props; - useHotkeys(keys, callback, options, dependencies); - return null; -}; - -export interface KeyboardShortcutProps { - keys: Keys; - callback: HotkeyCallback; - options?: Options; - dependencies?: readonly unknown[]; -} diff --git a/packages/ra-core/src/util/getKeyboardShortcutLabel.ts b/packages/ra-core/src/util/getKeyboardShortcutLabel.ts deleted file mode 100644 index 7c5cee6de54..00000000000 --- a/packages/ra-core/src/util/getKeyboardShortcutLabel.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Keys } from 'react-hotkeys-hook'; - -export const getKeyboardShortcutLabel = (keyboardShortcut: Keys) => { - if (typeof keyboardShortcut === 'string') { - return keyboardShortcut.split('+').join(' + '); - } - return keyboardShortcut - .map(shortcut => getKeyboardShortcutLabel(shortcut)) - .join(', '); -}; diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index 297dff067bd..2d83c5bcbe5 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -28,5 +28,3 @@ export * from './asyncDebounce'; export * from './hooks'; export * from './shallowEqual'; export * from './useCheckForApplicationUpdate'; -export * from './getKeyboardShortcutLabel'; -export * from './KeyboardShortcut'; diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 957d7dca3fe..283860c19ce 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -79,6 +79,7 @@ "query-string": "^7.1.3", "react-dropzone": "^14.2.3", "react-error-boundary": "^4.0.13", + "react-hotkeys-hook": "^5.1.0", "react-transition-group": "^4.4.5" }, "gitHead": "587df4c27bfcec4a756df4f95e5fc14728dfc0d7" diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx b/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx new file mode 100644 index 00000000000..5cd072eef21 --- /dev/null +++ b/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { createTheme, ThemeProvider } from '@mui/material'; +import { KeyboardShortcut } from './KeyboardShortcut'; +import { defaultTheme } from './theme'; + +export default { + title: 'ra-ui-materialui/KeyboardShortcut', +}; + +const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +export const Default = () => ( + + + +); + +export const Sequential = () => ( + + + +); diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx new file mode 100644 index 00000000000..9754e9dd804 --- /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, keys) => ( + + + {KeyMap[key] + ? KeyMap[key] + : key.toUpperCase()} + + {keyIndex < keys.length - 1 ? ( + <>  + ) : null} + + ))} + {sequenceIndex < sequences.length - 1 ? ( + <>  + ) : null} + + ))} + + ); +}; + +const KeyMap = { + meta: '⌘', + ctrl: '⌃', + shift: '⇧', + alt: '⌥', + enter: '⏎', + 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 }) => ({ + [`& .${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/Menu.stories.tsx b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx index f12fd31c108..9ced4a78d14 100644 --- a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx +++ b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx @@ -163,23 +163,77 @@ export const Custom = () => { export const WithKeyboardShortcuts = () => { const CustomMenu = () => ( - + + } + primaryText="Sales" + keyboardShortcut="G>S" + /> + } + primaryText="Customers" + 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" keyboardShortcut="ctrl+alt+C" + keyboardShortcutRepresentation="ctrl+alt+C" /> } keyboardShortcut="ctrl+alt+P" + keyboardShortcutRepresentation="ctrl+alt+P" /> ); diff --git a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx index e5933cfaca6..31a719e0a07 100644 --- a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx +++ b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx @@ -21,16 +21,12 @@ import { useMediaQuery, Theme, useForkRef, + Box, } from '@mui/material'; -import { - getKeyboardShortcutLabel, - KeyboardShortcut, - useTranslate, - useBasename, - useEvent, -} from 'ra-core'; -import type { Keys } from 'react-hotkeys-hook'; +import { useTranslate, useBasename, useEvent } from 'ra-core'; +import { useHotkeys } from 'react-hotkeys-hook'; import { useSidebarState } from './useSidebarState'; +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. @@ -100,6 +96,7 @@ export const MenuItemLink = forwardRef( tooltipProps, children, keyboardShortcut, + keyboardShortcutRepresentation, ...rest } = props; @@ -125,8 +122,13 @@ export const MenuItemLink = forwardRef( 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 ( @@ -141,39 +143,37 @@ export const MenuItemLink = forwardRef( {...rest} onClick={handleMenuTap} > - {keyboardShortcut ? ( - - ) : null} - {leftIcon && ( - - {leftIcon} - - )} - {children - ? children - : typeof primaryText === 'string' - ? translate(primaryText, { _: primaryText }) - : primaryText} + + {leftIcon && ( + + {leftIcon} + + )} + {children + ? children + : typeof primaryText === 'string' + ? translate(primaryText, { _: primaryText }) + : primaryText} + + {keyboardShortcut + ? keyboardShortcutRepresentation ?? ( + + ) + : null} ); }; - if (open && keyboardShortcut == null) { - return renderMenuItem(); - } - if (open && keyboardShortcut != null) { - return ( - - {renderMenuItem()} - - ); + if (open) { + return renderMenuItem(); } return ( @@ -203,7 +203,8 @@ export type MenuItemLinkProps = Omit< */ sidebarIsOpen?: boolean; tooltipProps?: TooltipProps; - keyboardShortcut?: Keys; + keyboardShortcut?: string; + keyboardShortcutRepresentation?: ReactNode; }; const PREFIX = 'RaMenuItemLink'; diff --git a/yarn.lock b/yarn.lock index c72098c7549..e9c09dd47c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16050,7 +16050,6 @@ __metadata: react-dom: "npm:^18.3.1" 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" @@ -16334,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" From 8f81a7dcc793b77148a716d9d7312796d0467242 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:43:30 +0200 Subject: [PATCH 06/17] Add shortcuts to simple example --- examples/simple/src/Layout.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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} + Date: Mon, 23 Jun 2025 17:22:45 +0200 Subject: [PATCH 07/17] Fix tests --- .../ra-ui-materialui/src/layout/Menu.spec.tsx | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/packages/ra-ui-materialui/src/layout/Menu.spec.tsx b/packages/ra-ui-materialui/src/layout/Menu.spec.tsx index b8690da3645..8aee13c2402 100644 --- a/packages/ra-ui-materialui/src/layout/Menu.spec.tsx +++ b/packages/ra-ui-materialui/src/layout/Menu.spec.tsx @@ -5,54 +5,70 @@ 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"]' }); + 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"]' }); + 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"]' }); + await screen.findByText('Dashboard', { + selector: '[role="menuitem"] *', + }); + fireEvent.keyDown(global.document, { + key: 'g', + code: 'KeyG', + }); fireEvent.keyDown(global.document, { key: 'c', code: 'KeyC', - ctrlKey: true, - altKey: true, }); expect(await screen.findAllByText('Customers')).toHaveLength(2); + fireEvent.keyDown(global.document, { + key: 'g', + code: 'KeyG', + }); fireEvent.keyDown(global.document, { key: 's', code: 'KeyS', - ctrlKey: true, - altKey: true, }); expect(await screen.findAllByText('Sales')).toHaveLength(2); + fireEvent.keyDown(global.document, { + key: 'g', + code: 'KeyG', + }); fireEvent.keyDown(global.document, { key: 'p', code: 'KeyP', - ctrlKey: true, - altKey: true, }); expect(await screen.findAllByText('Products')).toHaveLength(2); + fireEvent.keyDown(global.document, { + key: 'g', + code: 'KeyG', + }); fireEvent.keyDown(global.document, { key: 'd', code: 'KeyD', - ctrlKey: true, - altKey: true, }); expect(await screen.findAllByText('Dashboard')).toHaveLength(2); }); From eb48bad643ca43b7028d7a8a11ed8b6a7a748535 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:30:03 +0200 Subject: [PATCH 08/17] Document the keyboardShortcutRepresentation prop --- docs/Menu.md | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/Menu.md b/docs/Menu.md index 1389a266917..03e8f38594f 100644 --- a/docs/Menu.md +++ b/docs/Menu.md @@ -183,13 +183,14 @@ 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. | -| `keyboardShortcut` | Optional | `string | string[]` | - | The keyboard shortcut(s) to activate this menu item | -| `leftIcon` | Optional | `ReactNode` | - | The menu icon | -| `sx` | Optional | `SxProp` | - | Style overrides, powered by MUI System | +| 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 | string[]` | - | The keyboard shortcut(s) to activate this menu item | +| `keyboardShortcutRepresentation` | 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/). @@ -240,7 +241,8 @@ export const MyMenu = () => ( ); @@ -248,6 +250,25 @@ export const MyMenu = () => ( 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/) 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 = () => ( + + + +); +``` + + ### `leftIcon` The `letfIcon` prop allows setting the menu left icon. From eeb1502e519d2cf71f03af62bfd62d5717ece459 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:54:53 +0200 Subject: [PATCH 09/17] Apply suggestions from code review Co-authored-by: Jean-Baptiste Kaiser --- docs/Menu.md | 6 +++--- packages/ra-ui-materialui/src/KeyboardShortcut.tsx | 3 --- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/Menu.md b/docs/Menu.md index 03e8f38594f..928dec952ac 100644 --- a/docs/Menu.md +++ b/docs/Menu.md @@ -187,8 +187,8 @@ export const MyMenu = () => ( | -------------------------------- | -------- | -------------------- | -------------------- | ---------------------------------------- | | `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 | string[]` | - | The keyboard shortcut(s) to activate this menu item | -| `keyboardShortcutRepresentation` | Optional | `ReactNode` | `` | A react node that displays the keyboard shortcut | +| `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 | @@ -241,7 +241,7 @@ export const MyMenu = () => ( diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx index 9754e9dd804..5d44c1b2107 100644 --- a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx +++ b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx @@ -36,9 +36,6 @@ export const KeyboardShortcut = ({ ? KeyMap[key] : key.toUpperCase()} - {keyIndex < keys.length - 1 ? ( - <>  - ) : null} ))} {sequenceIndex < sequences.length - 1 ? ( From 07445adadf77392cc508852a5ab17273f2933491 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:56:43 +0200 Subject: [PATCH 10/17] Fix useHotkeys documentation link --- docs/Menu.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Menu.md b/docs/Menu.md index 928dec952ac..ca5b5db4536 100644 --- a/docs/Menu.md +++ b/docs/Menu.md @@ -248,7 +248,7 @@ export const MyMenu = () => ( ); ``` -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/) for more examples. +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` From 1397d2e41dc02354f6cbf0f87be2e72d7c23ca32 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:58:29 +0200 Subject: [PATCH 11/17] Ensure React keys are unique --- packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx | 6 ++++++ packages/ra-ui-materialui/src/KeyboardShortcut.tsx | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx b/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx index 5cd072eef21..903202ee9de 100644 --- a/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx +++ b/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx @@ -22,3 +22,9 @@ export const Sequential = () => ( ); + +export const SameKeyTwice = () => ( + + + +); diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx index 5d44c1b2107..59c9475fff5 100644 --- a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx +++ b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx @@ -24,9 +24,9 @@ export const KeyboardShortcut = ({ {keyboardShortcut .split('>') .map((sequence, sequenceIndex, sequences) => ( - - {sequence.split('+').map((key, keyIndex, keys) => ( - + + {sequence.split('+').map((key, keyIndex) => ( + Date: Thu, 26 Jun 2025 11:16:33 +0200 Subject: [PATCH 12/17] More stories --- .../src/KeyboardShortcut.stories.tsx | 102 +++++++++++++++--- .../ra-ui-materialui/src/KeyboardShortcut.tsx | 2 + 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx b/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx index 903202ee9de..3672690018a 100644 --- a/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx +++ b/packages/ra-ui-materialui/src/KeyboardShortcut.stories.tsx @@ -1,5 +1,12 @@ import * as React from 'react'; -import { createTheme, ThemeProvider } from '@mui/material'; +import { + createTheme, + List, + ListItem, + ListItemText, + Paper, + ThemeProvider, +} from '@mui/material'; import { KeyboardShortcut } from './KeyboardShortcut'; import { defaultTheme } from './theme'; @@ -8,23 +15,88 @@ export default { }; const Wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + + {children} + + ); export const Default = () => ( - - -); - -export const Sequential = () => ( - - - -); - -export const SameKeyTwice = () => ( - - + + } + > + + + + } + > + + + } + > + + + } + > + + + + } + > + + + } + > + + + + } + > + + + } + > + + + + } + > + + + + } + > + + + } + > + + + + } + > + + + ); diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx index 59c9475fff5..488839da292 100644 --- a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx +++ b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx @@ -49,10 +49,12 @@ export const KeyboardShortcut = ({ const KeyMap = { meta: '⌘', + mod: '⌘', ctrl: '⌃', shift: '⇧', alt: '⌥', enter: '⏎', + esc: '⎋', escape: '⎋', backspace: '⌫', delete: '⌦', From 2d66154531959f64f0e9b78dad1992e38bea891b Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:17:44 +0200 Subject: [PATCH 13/17] Dim shortcut representation --- packages/ra-ui-materialui/src/KeyboardShortcut.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx index 488839da292..d316362c727 100644 --- a/packages/ra-ui-materialui/src/KeyboardShortcut.tsx +++ b/packages/ra-ui-materialui/src/KeyboardShortcut.tsx @@ -86,6 +86,7 @@ const KeyboardShortcutClasses = { }; const Root = styled('div')(({ theme }) => ({ + opacity: 0.7, [`& .${KeyboardShortcutClasses.kbd}`]: { padding: '4px 5px', display: 'inline-block', From 88374b650056d1b3d3e73ffb602b27a694d41dfe Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:48:47 +0200 Subject: [PATCH 14/17] Fix MenuItemLink styles --- packages/ra-ui-materialui/src/layout/MenuItemLink.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx index 31a719e0a07..e599078cfaa 100644 --- a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx +++ b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx @@ -219,6 +219,8 @@ const StyledMenuItem = styled(MenuItem, { overridesResolver: (props, styles) => styles.root, })(({ theme }) => ({ color: (theme.vars || theme).palette.text.secondary, + justifyContent: 'space-between', + gap: theme.spacing(1), [`&.${MenuItemLinkClasses.active}`]: { color: (theme.vars || theme).palette.text.primary, From e242533ae2c2196d5628b4f0c9e2a064d807ae3c Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:48:57 +0200 Subject: [PATCH 15/17] Add screenshots to documentation --- docs/Menu.md | 4 ++++ docs/img/menu-custom-shortcuts.png | Bin 0 -> 22773 bytes docs/img/menu-shortcuts.png | Bin 0 -> 28791 bytes 3 files changed, 4 insertions(+) create mode 100644 docs/img/menu-custom-shortcuts.png create mode 100644 docs/img/menu-shortcuts.png diff --git a/docs/Menu.md b/docs/Menu.md index ca5b5db4536..15f9d3abc89 100644 --- a/docs/Menu.md +++ b/docs/Menu.md @@ -248,6 +248,8 @@ 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` @@ -268,6 +270,8 @@ const CustomMenu = () => ( ); ``` +![A menu with keyboard shortcuts displayed](./img/menu-custom-shortcuts.png) + ### `leftIcon` diff --git a/docs/img/menu-custom-shortcuts.png b/docs/img/menu-custom-shortcuts.png new file mode 100644 index 0000000000000000000000000000000000000000..a62b130fe514de31f2f8bf3912f7548fa4d98151 GIT binary patch literal 22773 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 WSBrxDzr)+vNRpy5BDq3(Fa95l1MPJH literal 0 HcmV?d00001 diff --git a/docs/img/menu-shortcuts.png b/docs/img/menu-shortcuts.png new file mode 100644 index 0000000000000000000000000000000000000000..93f83aee9fb4ade5d5aef7f815cbbbde95c48c05 GIT binary patch literal 28791 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 literal 0 HcmV?d00001 From 707583e2eea0576c7889d99e539ea24912ce1277 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:16:48 +0200 Subject: [PATCH 16/17] Show shortcuts on hover only --- .../src/layout/Menu.stories.tsx | 80 ++++++++++++++++++- .../src/layout/MenuItemLink.tsx | 60 ++++++++------ 2 files changed, 114 insertions(+), 26 deletions(-) diff --git a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx index 9ced4a78d14..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' }; @@ -173,7 +173,7 @@ export const WithKeyboardShortcuts = () => { } - primaryText="Customers" + primaryText="Customers very long" keyboardShortcut="G>C" /> { } - primaryText="Customers" + primaryText="Customers very long" keyboardShortcut="ctrl+alt+C" keyboardShortcutRepresentation="ctrl+alt+C" /> @@ -262,6 +262,80 @@ export const WithCustomKeyboardShortcutRepresentation = () => { ); }; +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/MenuItemLink.tsx b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx index e599078cfaa..f448e47f8f5 100644 --- a/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx +++ b/packages/ra-ui-materialui/src/layout/MenuItemLink.tsx @@ -21,7 +21,7 @@ import { useMediaQuery, Theme, useForkRef, - Box, + Typography, } from '@mui/material'; import { useTranslate, useBasename, useEvent } from 'ra-core'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -133,7 +133,7 @@ export const MenuItemLink = forwardRef( const renderMenuItem = () => { return ( ( {...rest} onClick={handleMenuTap} > - - {leftIcon && ( - - {leftIcon} - - )} + {leftIcon && ( + + {leftIcon} + + )} + {children ? children : typeof primaryText === 'string' - ? translate(primaryText, { _: primaryText }) + ? translate(primaryText, { + _: primaryText, + }) : primaryText} - + {keyboardShortcut ? keyboardShortcutRepresentation ?? ( ) @@ -210,8 +207,10 @@ export type MenuItemLinkProps = Omit< const PREFIX = 'RaMenuItemLink'; export const MenuItemLinkClasses = { + root: `${PREFIX}-root`, active: `${PREFIX}-active`, icon: `${PREFIX}-icon`, + shortcut: `${PREFIX}-shortcut`, }; const StyledMenuItem = styled(MenuItem, { @@ -219,8 +218,23 @@ const StyledMenuItem = styled(MenuItem, { overridesResolver: (props, styles) => styles.root, })(({ theme }) => ({ color: (theme.vars || theme).palette.text.secondary, - justifyContent: 'space-between', - gap: theme.spacing(1), + + [`& .${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, @@ -238,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]; }; } } From eb5452b15a9e37782da192353cd2224e7abb1c13 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:34:07 +0200 Subject: [PATCH 17/17] Fix tests --- packages/ra-ui-materialui/src/layout/Menu.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/layout/Menu.spec.tsx b/packages/ra-ui-materialui/src/layout/Menu.spec.tsx index 8aee13c2402..7dfa8302ef1 100644 --- a/packages/ra-ui-materialui/src/layout/Menu.spec.tsx +++ b/packages/ra-ui-materialui/src/layout/Menu.spec.tsx @@ -43,7 +43,8 @@ describe('', () => { key: 'c', code: 'KeyC', }); - expect(await screen.findAllByText('Customers')).toHaveLength(2); + // 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',