Skip to content

Commit 25deb15

Browse files
committed
Add support for keyboard shortcuts to <MenuItemLink>
1 parent f80fa27 commit 25deb15

File tree

13 files changed

+287
-29
lines changed

13 files changed

+287
-29
lines changed

docs/Menu.md

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,34 @@ export const MyMenu = () => (
183183
);
184184
```
185185

186-
| Prop | Required | Type | Default | Description |
187-
| ------------- | -------- | -------------------- | ------- | ---------------------------------------- |
188-
| `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. |
189-
| `primaryText` | Required | `ReactNode` | - | The menu content, displayed when the menu isn't minimized. |
190-
| `leftIcon` | Optional | `ReactNode` | - | The menu icon |
186+
| Prop | Required | Type | Default | Description |
187+
| ------------------ | -------- | -------------------- | ------- | ---------------------------------------- |
188+
| `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. |
189+
| `primaryText` | Required | `ReactNode` | - | The menu content, displayed when the menu isn't minimized. |
190+
| `keyboardShortcut` | Optional | `string | string[]` | - | The keyboard shortcut(s) to activate this menu item |
191+
| `leftIcon` | Optional | `ReactNode` | - | The menu icon |
192+
| `sx` | Optional | `SxProp` | - | Style overrides, powered by MUI System |
191193

192194
Additional props are passed down to [the underling Material UI `<MenuItem>` component](https://mui.com/material-ui/api/menu-item/).
193195

196+
### `to`
194197

195-
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:
198+
The menu item's target. It is passed to a React Router [NavLink](https://reacttraining.com/react-router/web/api/NavLink) component.
199+
200+
```tsx
201+
// in src/MyMenu.js
202+
import { Menu } from 'react-admin';
203+
204+
export const MyMenu = () => (
205+
<Menu>
206+
<Menu.Item to="/custom-route" primaryText="Miscellaneous" />
207+
</Menu>
208+
);
209+
```
210+
211+
### `primaryText`
212+
213+
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:
196214

197215
```jsx
198216
import Badge from '@mui/material/Badge';
@@ -212,6 +230,24 @@ export const MyMenu = () => (
212230

213231
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.
214232

233+
### `keyboardShortcut`
234+
235+
The keyboard shortcut(s) to activate this menu item. Pass a string or an array of string that defines the supported keyboard shortcuts:
236+
237+
```tsx
238+
export const MyMenu = () => (
239+
<Menu>
240+
<Menu.Item
241+
to="/sales"
242+
primaryText="Sales"
243+
keyboardShortcut="ctrl+alt+S"
244+
/>
245+
</Menu>
246+
);
247+
```
248+
249+
### `leftIcon`
250+
215251
The `letfIcon` prop allows setting the menu left icon.
216252

217253
```jsx
@@ -231,7 +267,16 @@ export const MyMenu = () => (
231267
);
232268
```
233269

234-
Additional props are passed down to [the underling Material UI `<MenuItem>` component](https://mui.com/material-ui/api/menu-item/).
270+
### `sx`
271+
272+
You can use the `sx` prop to customize the style of the component.
273+
274+
| Rule name | Description |
275+
|-----------------------------|---------------------------------------------------------------------|
276+
| `&.RaMenuItemLink-active` | Applied to the underlying `MuiMenuItem`'s `activeClassName` prop |
277+
| `& .RaMenuItemLink-icon` | Applied to the `ListItemIcon` component when `leftIcon` prop is set |
278+
279+
To override the style of all instances of `<MenuItemLink>` using the [application-wide style overrides](./AppTheme.md#theming-individual-components), use the `RaMenuItemLink` key.
235280

236281
**Tip**: The `<Menu.Item>` 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:
237282

@@ -260,15 +305,6 @@ export const theme = {
260305
};
261306
```
262307

263-
You can use the `sx` prop to customize the style of the component.
264-
265-
| Rule name | Description |
266-
|-----------------------------|---------------------------------------------------------------------|
267-
| `&.RaMenuItemLink-active` | Applied to the underlying `MuiMenuItem`'s `activeClassName` prop |
268-
| `& .RaMenuItemLink-icon` | Applied to the `ListItemIcon` component when `leftIcon` prop is set |
269-
270-
To override the style of all instances of `<MenuItemLink>` using the [application-wide style overrides](./AppTheme.md#theming-individual-components), use the `RaMenuItemLink` key.
271-
272308
## `<Menu.DashboardItem>`
273309

274310
The `<Menu.DashboardItem>` component displays a menu item for the dashboard.

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ module.exports = {
2525
'/packages/create-react-admin/templates',
2626
],
2727
transformIgnorePatterns: [
28-
'[/\\\\]node_modules[/\\\\](?!(@hookform)/).+\\.(js|jsx|mjs|ts|tsx)$',
28+
'[/\\\\]node_modules[/\\\\](?!(@hookform|react-hotkeys-hook)/).+\\.(js|jsx|mjs|ts|tsx)$',
2929
],
3030
transform: {
3131
// '^.+\\.[tj]sx?$' to process js/ts with `ts-jest`

packages/ra-ui-materialui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"query-string": "^7.1.3",
8080
"react-dropzone": "^14.2.3",
8181
"react-error-boundary": "^4.0.13",
82+
"react-hotkeys-hook": "^5.1.0",
8283
"react-transition-group": "^4.4.5"
8384
},
8485
"gitHead": "587df4c27bfcec4a756df4f95e5fc14728dfc0d7"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {
2+
type HotkeyCallback,
3+
type Keys,
4+
type Options,
5+
useHotkeys,
6+
} from 'react-hotkeys-hook';
7+
8+
export const KeyboardShortcut = (props: KeyboardShortcutProps) => {
9+
const { callback, dependencies, keys, options } = props;
10+
useHotkeys(keys, callback, options, dependencies);
11+
return null;
12+
};
13+
14+
export interface KeyboardShortcutProps {
15+
keys: Keys;
16+
callback: HotkeyCallback;
17+
options?: Options;
18+
dependencies?: readonly unknown[];
19+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Keys } from 'react-hotkeys-hook';
2+
3+
export const getKeyboardShortcutLabel = (keyboardShortcut: Keys) => {
4+
if (typeof keyboardShortcut === 'string') {
5+
return keyboardShortcut.split('+').join(' + ');
6+
}
7+
return keyboardShortcut
8+
.map(shortcut => getKeyboardShortcutLabel(shortcut))
9+
.join(', ');
10+
};

packages/ra-ui-materialui/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ export * from './list';
1212
export * from './preferences';
1313
export * from './AdminUI';
1414
export * from './AdminContext';
15+
export * from './KeyboardShortcut';
16+
export * from './getKeyboardShortcutLabel';

packages/ra-ui-materialui/src/layout/DashboardMenuItem.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from 'react';
22
import DashboardIcon from '@mui/icons-material/Dashboard';
3-
import { To } from 'react-router';
43
import { useBasename } from 'ra-core';
54

65
import { MenuItemLink, MenuItemLinkProps } from './MenuItemLink';
@@ -24,8 +23,9 @@ export const DashboardMenuItem = (props: DashboardMenuItemProps) => {
2423
);
2524
};
2625

27-
export interface DashboardMenuItemProps extends Omit<MenuItemLinkProps, 'to'> {
28-
to?: To;
26+
export interface DashboardMenuItemProps
27+
extends Omit<MenuItemLinkProps, 'to'>,
28+
Partial<Pick<MenuItemLinkProps, 'to'>> {
2929
/**
3030
* @deprecated
3131
*/
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as React from 'react';
2+
import { fireEvent, render, screen } from '@testing-library/react';
3+
import { Default, WithDashboard, WithKeyboardShortcuts } from './Menu.stories';
4+
5+
describe('<Menu>', () => {
6+
it('should render a default menu with items for all registered resources', async () => {
7+
render(<Default />);
8+
await screen.findByText('Posts', { selector: '[role="menuitem"]' });
9+
await screen.findByText('Comments', { selector: '[role="menuitem"]' });
10+
await screen.findByText('Tags', { selector: '[role="menuitem"]' });
11+
await screen.findByText('Users', { selector: '[role="menuitem"]' });
12+
await screen.findByText('Orders', { selector: '[role="menuitem"]' });
13+
await screen.findByText('Reviews', { selector: '[role="menuitem"]' });
14+
});
15+
16+
it('should render a default menu with items for all registered resources and the dashboard', async () => {
17+
render(<WithDashboard />);
18+
await screen.findByText('Dashboard', { selector: '[role="menuitem"]' });
19+
await screen.findByText('Posts', { selector: '[role="menuitem"]' });
20+
await screen.findByText('Comments', { selector: '[role="menuitem"]' });
21+
await screen.findByText('Tags', { selector: '[role="menuitem"]' });
22+
await screen.findByText('Users', { selector: '[role="menuitem"]' });
23+
await screen.findByText('Orders', { selector: '[role="menuitem"]' });
24+
await screen.findByText('Reviews', { selector: '[role="menuitem"]' });
25+
});
26+
27+
it('should support keyboard shortcuts', async () => {
28+
render(<WithKeyboardShortcuts />);
29+
await screen.findByText('Dashboard', { selector: '[role="menuitem"]' });
30+
fireEvent.keyDown(global.document, {
31+
key: 'c',
32+
code: 'KeyC',
33+
ctrlKey: true,
34+
altKey: true,
35+
});
36+
expect(await screen.findAllByText('Customers')).toHaveLength(2);
37+
fireEvent.keyDown(global.document, {
38+
key: 's',
39+
code: 'KeyS',
40+
ctrlKey: true,
41+
altKey: true,
42+
});
43+
expect(await screen.findAllByText('Sales')).toHaveLength(2);
44+
fireEvent.keyDown(global.document, {
45+
key: 'p',
46+
code: 'KeyP',
47+
ctrlKey: true,
48+
altKey: true,
49+
});
50+
expect(await screen.findAllByText('Products')).toHaveLength(2);
51+
fireEvent.keyDown(global.document, {
52+
key: 'd',
53+
code: 'KeyD',
54+
ctrlKey: true,
55+
altKey: true,
56+
});
57+
expect(await screen.findAllByText('Dashboard')).toHaveLength(2);
58+
});
59+
});

packages/ra-ui-materialui/src/layout/Menu.stories.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,31 @@ export const Default = () => {
6363
);
6464
};
6565

66+
export const WithDashboard = () => {
67+
const MenuDefault = () => <Menu hasDashboard={true} dense={false} />;
68+
const DefaultLayout = ({ children }) => (
69+
<Layout menu={MenuDefault}>{children}</Layout>
70+
);
71+
const Dashboard = () => <Page title="Dashboard" />;
72+
73+
return (
74+
<Admin
75+
store={memoryStore()}
76+
dataProvider={testDataProvider()}
77+
layout={DefaultLayout}
78+
dashboard={Dashboard}
79+
>
80+
{resources.map((resource, index) => (
81+
<Resource
82+
name={resource}
83+
key={`resource_${index}`}
84+
list={<DemoList name={resource} />}
85+
/>
86+
))}
87+
</Admin>
88+
);
89+
};
90+
6691
export const Dense = () => {
6792
const MenuDense = () => <Menu hasDashboard={true} dense={true} />;
6893
const LayoutDense = ({ children }) => (
@@ -135,6 +160,54 @@ export const Custom = () => {
135160
);
136161
};
137162

163+
export const WithKeyboardShortcuts = () => {
164+
const CustomMenu = () => (
165+
<Menu>
166+
<Menu.DashboardItem keyboardShortcut="ctrl+alt+D" />
167+
<Menu.Item
168+
to="/sales"
169+
leftIcon={<PieChartOutlined />}
170+
primaryText="Sales"
171+
keyboardShortcut="ctrl+alt+S"
172+
/>
173+
<Menu.Item
174+
to="/customers"
175+
leftIcon={<PeopleOutlined />}
176+
primaryText="Customers"
177+
keyboardShortcut="ctrl+alt+C"
178+
/>
179+
<Menu.ResourceItem
180+
name="products"
181+
leftIcon={<Inventory />}
182+
keyboardShortcut="ctrl+alt+P"
183+
/>
184+
</Menu>
185+
);
186+
const CustomLayout = ({ children }) => (
187+
<Layout menu={CustomMenu}>{children}</Layout>
188+
);
189+
190+
const Dashboard = () => <Page title="Dashboard" />;
191+
return (
192+
<TestMemoryRouter initialEntries={['/']}>
193+
<Admin
194+
dataProvider={testDataProvider()}
195+
layout={CustomLayout}
196+
dashboard={Dashboard}
197+
>
198+
<Resource name="products" list={<Page title="Products" />} />
199+
<CustomRoutes>
200+
<Route path="/sales" element={<Page title="Sales" />} />
201+
<Route
202+
path="/customers"
203+
element={<Page title="Customers" />}
204+
/>
205+
</CustomRoutes>
206+
</Admin>
207+
</TestMemoryRouter>
208+
);
209+
};
210+
138211
const Page = ({ title }) => (
139212
<>
140213
<Typography variant="h5" mt={2}>

packages/ra-ui-materialui/src/layout/Menu.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@ export const Menu = (inProps: MenuProps) => {
4444
props: inProps,
4545
name: PREFIX,
4646
});
47-
const { children, className, ...rest } = props;
47+
const {
48+
children,
49+
className,
50+
hasDashboard: hasDashboardProp,
51+
...rest
52+
} = props;
4853
const hasDashboard = useHasDashboard();
4954
const [open] = useSidebarState();
5055

0 commit comments

Comments
 (0)