From 3d527eb392bae31a93e707efaea12dd14e927def Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 9 Jun 2026 05:05:52 +0200 Subject: [PATCH] feat(frontend): add hotkey registration function and hotkey helper modal (#12128) * add new hotkey registration interface and hotkey modal * fix import * add printing hotkey * add todo * add hotkey for barcode scanning * register spotlight shortcut key * sort keys * render nicer overview * fix props * expose for plugins --------- Co-authored-by: Oliver --- src/frontend/CHANGELOG.md | 4 ++ src/frontend/lib/functions/Events.tsx | 31 +++++++++ src/frontend/lib/index.ts | 2 + src/frontend/lib/states/LocalLibState.tsx | 19 ++++++ src/frontend/package.json | 2 +- .../components/buttons/PrintingActions.tsx | 28 ++++++++ .../src/components/buttons/ScanButton.tsx | 17 ++++- .../components/buttons/SpotlightButton.tsx | 10 ++- .../components/dashboard/DashboardLayout.tsx | 6 +- .../src/components/modals/HotkeyModal.tsx | 66 +++++++++++++++++++ src/frontend/src/components/nav/Header.tsx | 15 ++--- src/frontend/src/components/nav/Layout.tsx | 4 +- .../src/components/nav/PageDetail.tsx | 7 +- src/frontend/src/contexts/ThemeContext.tsx | 4 +- src/frontend/src/defaults/actions.tsx | 25 ++++++- 15 files changed, 220 insertions(+), 20 deletions(-) create mode 100644 src/frontend/src/components/modals/HotkeyModal.tsx diff --git a/src/frontend/CHANGELOG.md b/src/frontend/CHANGELOG.md index d9144e2b26..54d5f6f2aa 100644 --- a/src/frontend/CHANGELOG.md +++ b/src/frontend/CHANGELOG.md @@ -2,6 +2,10 @@ This file contains historical changelog information for the InvenTree UI components library. +### 1.4.6 - June 2026 + +Adds `useInvenTreeHotkeys` hook that mirrors mantine's `useHotkeys` hook, but adds visibility of the hotkeys to the user interface. + ### 1.4.5 - June 2026 Fixes callback signature for `` to allow for an optional `loadLocale` function to be passed in, which is used to dynamically load locale messages for the plugin. diff --git a/src/frontend/lib/functions/Events.tsx b/src/frontend/lib/functions/Events.tsx index a7daee5440..12043cbbb4 100644 --- a/src/frontend/lib/functions/Events.tsx +++ b/src/frontend/lib/functions/Events.tsx @@ -1,6 +1,37 @@ +import { useHotkeys } from '@mantine/hooks'; +import type { HotkeyItemOptions } from '@mantine/hooks'; +import { useEffect } from 'react'; +import { useLocalLibState } from '..'; + // Helper function to cancel event propagation export function cancelEvent(event: any) { event?.preventDefault(); event?.stopPropagation(); event?.nativeEvent?.stopImmediatePropagation(); } + +export type InvenTreeHotkeyItem = [ + string, + string, + (event: KeyboardEvent) => void, + HotkeyItemOptions? +]; + +export function useInvenTreeHotkeys(hotkeys: InvenTreeHotkeyItem[]) { + // Register the hotkeys using the Mantine hook + useHotkeys( + hotkeys.map(([key, _, handler, options]) => [key, handler, options]) + ); + + // register to helper state to store hotkeys + // This allows us to display the hotkeys in the UI + const keyelems: [string, string][] = hotkeys.map(([key, description]) => [ + key, + description + ]); + useEffect(() => { + useLocalLibState.getState().addHotkeys(keyelems); + return () => + useLocalLibState.getState().removeHotkeys(keyelems.map(([key]) => key)); + }, []); +} diff --git a/src/frontend/lib/index.ts b/src/frontend/lib/index.ts index 25b901848d..cb554301e0 100644 --- a/src/frontend/lib/index.ts +++ b/src/frontend/lib/index.ts @@ -139,6 +139,8 @@ export { type TableStateExtraProps } from './hooks/UseTable'; +export { useInvenTreeHotkeys } from './functions/Events'; + export { type DrawerProps, DetailDrawer, diff --git a/src/frontend/lib/states/LocalLibState.tsx b/src/frontend/lib/states/LocalLibState.tsx index 76431c8d79..8089a165b2 100644 --- a/src/frontend/lib/states/LocalLibState.tsx +++ b/src/frontend/lib/states/LocalLibState.tsx @@ -10,8 +10,24 @@ export const useLocalLibState = create()( detailDrawerStack: value === false ? 0 : get().detailDrawerStack + value }); + }, + hotkeys: {}, + addHotkeys: (hotkeys) => { + const newHotkeys = { ...get().hotkeys }; + for (const [ref, details] of hotkeys) { + newHotkeys[ref] = details; + } + set({ hotkeys: newHotkeys }); + }, + removeHotkeys: (hotkeys) => { + const newHotkeys = { ...get().hotkeys }; + for (const ref of hotkeys) { + delete newHotkeys[ref]; + } + set({ hotkeys: newHotkeys }); } }), + { name: 'session-settings-inventreedb_lib' } @@ -20,4 +36,7 @@ export const useLocalLibState = create()( export interface LocalLibStateProps { detailDrawerStack: number; addDetailDrawer: (value: number | false) => void; + hotkeys: Record; + addHotkeys: (hotkeys: [string, string][]) => void; + removeHotkeys: (hotkeys: string[]) => void; } diff --git a/src/frontend/package.json b/src/frontend/package.json index 6f9236c80a..2d6ee20ef6 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -1,7 +1,7 @@ { "name": "@inventreedb/ui", "description": "UI components for the InvenTree project", - "version": "1.4.5", + "version": "1.4.6", "private": false, "type": "module", "license": "MIT", diff --git a/src/frontend/src/components/buttons/PrintingActions.tsx b/src/frontend/src/components/buttons/PrintingActions.tsx index 7f028320b8..19e8b08e9d 100644 --- a/src/frontend/src/components/buttons/PrintingActions.tsx +++ b/src/frontend/src/components/buttons/PrintingActions.tsx @@ -1,6 +1,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import type { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; +import { useInvenTreeHotkeys } from '@lib/functions/Events'; import type { ApiFormFieldSet } from '@lib/types/Forms'; import { t } from '@lingui/core/macro'; import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react'; @@ -34,6 +35,33 @@ export function PrintingActions({ const enabled = useMemo(() => items.length > 0, [items]); + useInvenTreeHotkeys([ + [ + 'mod+P', + t`Open Print Report dialog`, + (event) => { + if (event.repeat) { + return; + } + if (enabled && !hidden) { + reportModal.open(); + } + } + ], + [ + 'mod+L', + t`Open Print Label dialog`, + (event) => { + if (event.repeat) { + return; + } + if (enabled && !hidden) { + labelModal.open(); + } + } + ] + ]); + const defaultLabelPlugin = useMemo( () => userSettings.getSetting('LABEL_DEFAULT_PRINTER'), [userSettings] diff --git a/src/frontend/src/components/buttons/ScanButton.tsx b/src/frontend/src/components/buttons/ScanButton.tsx index 286f3ef4ac..420ce8d0c7 100644 --- a/src/frontend/src/components/buttons/ScanButton.tsx +++ b/src/frontend/src/components/buttons/ScanButton.tsx @@ -1,3 +1,4 @@ +import { useInvenTreeHotkeys } from '@lib/functions/Events'; import type { ModelType } from '@lib/index'; import { t } from '@lingui/core/macro'; import { ActionIcon, Tooltip } from '@mantine/core'; @@ -14,14 +15,28 @@ import BarcodeScanDialog, { export function ScanButton({ modelType, callback, - onScanSuccess + onScanSuccess, + hotkey = false }: { modelType?: ModelType; callback?: BarcodeScanCallback; onScanSuccess?: BarcodeScanSuccessCallback; + hotkey?: boolean; }) { const [opened, { open, close }] = useDisclosure(false); + if (hotkey) { + useInvenTreeHotkeys([ + [ + 'mod+b', + t`Open barcode scanner`, + () => { + open(); + } + ] + ]); + } + return ( <> diff --git a/src/frontend/src/components/buttons/SpotlightButton.tsx b/src/frontend/src/components/buttons/SpotlightButton.tsx index f9faf95e41..3331e5a3df 100644 --- a/src/frontend/src/components/buttons/SpotlightButton.tsx +++ b/src/frontend/src/components/buttons/SpotlightButton.tsx @@ -2,12 +2,18 @@ import { t } from '@lingui/core/macro'; import { ActionIcon, Tooltip } from '@mantine/core'; import { IconCommand } from '@tabler/icons-react'; -import { firstSpotlight } from '../nav/Layout'; +import { useLocalLibState } from '@lib/states/LocalLibState'; +import { firstSpotlight, searchShortcutKey } from '../nav/Layout'; /** * A button which opens the quick command modal */ -export function SpotlightButton() { +export function SpotlightButton({ hotkey = false }: { hotkey?: boolean }) { + if (hotkey) { + useLocalLibState + .getState() + .addHotkeys([[searchShortcutKey, t`Open spotlight`]]); + } return ( { setEditing.toggle(); } diff --git a/src/frontend/src/components/modals/HotkeyModal.tsx b/src/frontend/src/components/modals/HotkeyModal.tsx new file mode 100644 index 0000000000..80c158d4fe --- /dev/null +++ b/src/frontend/src/components/modals/HotkeyModal.tsx @@ -0,0 +1,66 @@ +import { useLocalLibState } from '@lib/states/LocalLibState'; +import { Kbd, Table } from '@mantine/core'; +import { type UseOSReturnValue, useOs } from '@mantine/hooks'; +import type { ContextModalProps } from '@mantine/modals'; +import { Fragment, useMemo } from 'react'; + +function modRenderer(value: string, os: UseOSReturnValue) { + if (os === 'macos') { + return value.replace('mod', '⌘'); + } + return value.replace('mod', 'Ctrl'); +} + +function kbdRenderer(value: string, os: UseOSReturnValue) { + const parts = value.split('+'); + if (parts.length > 1) { + return ( + <> + {parts.map((part, idx) => ( + + {modRenderer(part, os)} + {idx < parts.length - 1 && ' + '} + + ))} + + ); + } + return {modRenderer(value, os)}; +} + +export function HotkeyModal({ + context, + id +}: ContextModalProps<{ modalBody: string }>) { + const os = useOs(); + + const hotkeys = useMemo(() => { + const keys = Object.entries(useLocalLibState.getState().hotkeys).map( + ([hotkey, description]) => { + return { + key: hotkey, + dec: description + }; + } + ); + keys.sort((a, b) => a.key.localeCompare(b.key)); + return keys; + }, []); + const data = useMemo(() => { + return { + head: ['Hotkey', 'Action'], + body: [...hotkeys.map((item) => [kbdRenderer(item.key, os), item.dec])] + }; + }, [os, hotkeys]); + + return ( + + ); +} diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx index d1c7f3fb04..5872988554 100644 --- a/src/frontend/src/components/nav/Header.tsx +++ b/src/frontend/src/components/nav/Header.tsx @@ -10,11 +10,7 @@ import { Tooltip, UnstyledButton } from '@mantine/core'; -import { - useDisclosure, - useDocumentVisibility, - useHotkeys -} from '@mantine/hooks'; +import { useDisclosure, useDocumentVisibility } from '@mantine/hooks'; import { IconBell, IconSearch, IconUserBolt } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; import { type ReactNode, useEffect, useMemo, useState } from 'react'; @@ -22,6 +18,7 @@ import { useMatch, useNavigate } from 'react-router-dom'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { apiUrl } from '@lib/functions/Api'; +import { useInvenTreeHotkeys } from '@lib/functions/Events'; import { getBaseUrl } from '@lib/functions/Navigation'; import { navigateToLink } from '@lib/functions/Navigation'; import { t } from '@lingui/core/macro'; @@ -60,15 +57,17 @@ export function Header() { { open: openSearchDrawer, close: closeSearchDrawer } ] = useDisclosure(false); - useHotkeys([ + useInvenTreeHotkeys([ [ '/', + t`Open search`, () => { openSearchDrawer(); } ], [ 'mod+/', + t`Open search`, () => { openSearchDrawer(); } @@ -198,8 +197,8 @@ export function Header() { - {userSettings.isSet('SHOW_SPOTLIGHT') && } - {globalSettings.isSet('BARCODE_ENABLE') && } + {userSettings.isSet('SHOW_SPOTLIGHT') && } + {globalSettings.isSet('BARCODE_ENABLE') && } { export const [firstStore, firstSpotlight] = createSpotlight(); +export const searchShortcutKey = 'mod+K'; + export default function LayoutComponent() { const navigate = useNavigate(); const location = useLocation(); @@ -140,7 +142,7 @@ export default function LayoutComponent() { leftSection: , placeholder: t`Search...` }} - shortcut={['mod + K']} + shortcut={[searchShortcutKey]} nothingFound={t`Nothing found...`} /> )} diff --git a/src/frontend/src/components/nav/PageDetail.tsx b/src/frontend/src/components/nav/PageDetail.tsx index 65754bdd98..5f5a441a95 100644 --- a/src/frontend/src/components/nav/PageDetail.tsx +++ b/src/frontend/src/components/nav/PageDetail.tsx @@ -1,8 +1,9 @@ import { Group, Paper, Space, Stack, Text } from '@mantine/core'; -import { useHotkeys } from '@mantine/hooks'; import { StylishText } from '@lib/components/StylishText'; +import { useInvenTreeHotkeys } from '@lib/functions/Events'; import { shortenString } from '@lib/functions/String'; +import { t } from '@lingui/core/macro'; import { Fragment, type ReactNode, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { usePluginUIFeature } from '../../hooks/UsePluginUIFeature'; @@ -52,9 +53,11 @@ export function PageDetail({ const userSettings = useUserSettingsState(); const navigate = useNavigate(); const location = useLocation(); - useHotkeys([ + + useInvenTreeHotkeys([ [ 'mod+E', + title ? t`Edit ${title}` : t`Edit`, (event) => { if (event.repeat) { return; diff --git a/src/frontend/src/contexts/ThemeContext.tsx b/src/frontend/src/contexts/ThemeContext.tsx index 192878f559..9e9d9e574b 100644 --- a/src/frontend/src/contexts/ThemeContext.tsx +++ b/src/frontend/src/contexts/ThemeContext.tsx @@ -11,6 +11,7 @@ import { ContextMenuProvider } from 'mantine-contextmenu'; import type { JSX } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { AboutInvenTreeModal } from '../components/modals/AboutInvenTreeModal'; +import { HotkeyModal } from '../components/modals/HotkeyModal'; import { LicenseModal } from '../components/modals/LicenseModal'; import { QrModal } from '../components/modals/QrModal'; import { ServerInfoModal } from '../components/modals/ServerInfoModal'; @@ -59,7 +60,8 @@ export function ThemeContext({ info: ServerInfoModal, about: AboutInvenTreeModal, license: LicenseModal, - qr: QrModal + qr: QrModal, + hotkey: HotkeyModal }} > diff --git a/src/frontend/src/defaults/actions.tsx b/src/frontend/src/defaults/actions.tsx index fb0af4ef78..c33b80d812 100644 --- a/src/frontend/src/defaults/actions.tsx +++ b/src/frontend/src/defaults/actions.tsx @@ -14,7 +14,8 @@ import { import type { NavigateFunction } from 'react-router-dom'; import { ModelInformationDict } from '@lib/enums/ModelInformation'; -import { ModelType, UserRoles } from '@lib/index'; +import { ModelType, StylishText, UserRoles } from '@lib/index'; +import { Trans } from '@lingui/react/macro'; import { openContextModal } from '@mantine/modals'; import { useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; @@ -23,13 +24,26 @@ import { useGlobalSettingsState } from '../states/SettingsStates'; import { useUserState } from '../states/UserState'; import { aboutInvenTree, docLinks, licenseInfo, serverInfo } from './links'; -export function openQrModal(navigate: NavigateFunction) { +function openQrModal(navigate: NavigateFunction) { return openContextModal({ modal: 'qr', innerProps: { navigate: navigate } }); } +function openHotkeys() { + return openContextModal({ + modal: 'hotkey', + title: ( + + Hotkeys + + ), + size: 'xl', + innerProps: {} + }); +} + export function getActions(navigate: NavigateFunction) { const setNavigationOpen = useLocalState( useShallow((state) => state.setNavigationOpen) @@ -91,6 +105,13 @@ export function getActions(navigate: NavigateFunction) { description: t`Go to your user settings`, onClick: () => navigate('/settings/user'), leftSection: + }, + { + id: 'hotkeys', + label: t`Hotkeys`, + description: t`View a list of available hotkeys`, + onClick: () => openHotkeys(), + leftSection: } ];