From 9713d09603bd9d798211256a30f0f8623b8bab2f Mon Sep 17 00:00:00 2001 From: Elijah Olmos <35435704+elijaholmos@users.noreply.github.com> Date: Fri, 13 Jun 2025 01:41:16 -0700 Subject: [PATCH] feat: add command bar (#5076) * deps(desktop-client): add cmdk * feat(desktop-client): create and integrate CommandBar * hover on selection * Ctrl+K only opens, ESC closes * add custom reports * add navigation items to cmdk * fix: mouse hovering can interfere with keyboard navigation * reset search state when CommandBar closes * revert import order changes * deps(desktop-client): readd cmdk * fix vite error * add item icons * move navigation items into their own section * hide scrollbar and release notes * style: run yarn lint:fix * fix: infinite loop opening commandbar with active modal * fix: infinite error loop bc focus conflicts * fix: kebab case console warning * chore: update yarn.lock * refactor: use autoFocus prop * feat: add i18next * style: relocate eslint-disable comment --- packages/desktop-client/package.json | 1 + .../src/components/CommandBar.tsx | 278 ++++++++++++ .../src/components/FinancesApp.tsx | 3 +- upcoming-release-notes/5076.md | 6 + yarn.lock | 418 +++++++++++++++++- 5 files changed, 698 insertions(+), 8 deletions(-) create mode 100644 packages/desktop-client/src/components/CommandBar.tsx create mode 100644 upcoming-release-notes/5076.md diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 76170c6436..2c2fa90e8d 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -31,6 +31,7 @@ "@vitejs/plugin-react-swc": "^3.6.0", "auto-text-size": "^0.2.3", "chokidar": "^3.6.0", + "cmdk": "^1.1.1", "cross-env": "^7.0.3", "date-fns": "^4.1.0", "debounce": "^1.2.1", diff --git a/packages/desktop-client/src/components/CommandBar.tsx b/packages/desktop-client/src/components/CommandBar.tsx new file mode 100644 index 0000000000..b98f924a01 --- /dev/null +++ b/packages/desktop-client/src/components/CommandBar.tsx @@ -0,0 +1,278 @@ +import { + type ComponentType, + type SVGProps, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + SvgCog, + SvgPiggyBank, + SvgReports, + SvgStoreFront, + SvgTuning, + SvgWallet, +} from '@actual-app/components/icons/v1'; +import { + SvgCalendar3, + SvgNotesPaperText, +} from '@actual-app/components/icons/v2'; +import { css } from '@emotion/css'; +import { Command } from 'cmdk'; + +import { useAccounts } from '@desktop-client/hooks/useAccounts'; +import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref'; +import { useModalState } from '@desktop-client/hooks/useModalState'; +import { useNavigate } from '@desktop-client/hooks/useNavigate'; +import { useReports } from '@desktop-client/hooks/useReports'; + +type SearchableItem = { + id: string; + name: string; + Icon: ComponentType>; +}; + +type SearchSection = { + key: string; + heading: string; + items: Readonly; + onSelect: (item: Pick) => void; +}; + +export function CommandBar() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const navigate = useNavigate(); + const [budgetName] = useMetadataPref('budgetName'); + const { modalStack } = useModalState(); + + const navigationItems = useMemo( + () => [ + { id: 'budget', name: t('Budget'), path: '/budget', Icon: SvgWallet }, + { + id: 'reports-nav', + name: t('Reports'), + path: '/reports', + Icon: SvgReports, + }, + { + id: 'schedules', + name: t('Schedules'), + path: '/schedules', + Icon: SvgCalendar3, + }, + { id: 'payees', name: t('Payees'), path: '/payees', Icon: SvgStoreFront }, + { id: 'rules', name: t('Rules'), path: '/rules', Icon: SvgTuning }, + { id: 'settings', name: t('Settings'), path: '/settings', Icon: SvgCog }, + ], + [t], + ); + + useEffect(() => { + // Reset search when closing + if (!open) setSearch(''); + }, [open]); + + const allAccounts = useAccounts(); + const { data: customReports } = useReports(); + + const accounts = allAccounts.filter(acc => !acc.closed); + + const openEventListener = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + // Do not open CommandBar if a modal is already open + if (modalStack.length > 0) return; + setOpen(true); + } + }, + [modalStack.length], + ); + + useEffect(() => { + document.addEventListener('keydown', openEventListener); + return () => document.removeEventListener('keydown', openEventListener); + }, [openEventListener]); + + const handleNavigate = useCallback( + (path: string) => { + setOpen(false); + navigate(path); + }, + [navigate], + ); + + const sections: SearchSection[] = [ + { + key: 'navigation', + heading: t('Navigation'), + items: navigationItems.map(({ id, name, Icon }) => ({ + id, + name, + Icon, + })), + onSelect: ({ id }) => { + const item = navigationItems.find(item => item.id === id); + if (!!item) handleNavigate(item.path); + }, + }, + { + key: 'accounts', + heading: t('Accounts'), + items: accounts.map(account => ({ + ...account, + Icon: SvgPiggyBank, + })), + onSelect: ({ id }) => handleNavigate(`/accounts/${id}`), + }, + { + key: 'reports-custom', + heading: t('Custom Reports'), + items: customReports.map(report => ({ + ...report, + Icon: SvgNotesPaperText, + })), + onSelect: ({ id }) => handleNavigate(`/reports/custom/${id}`), + }, + ]; + + const searchLower = search.toLowerCase(); + const filteredSections = sections.map(section => ({ + ...section, + items: section.items.filter(item => + item.name.toLowerCase().includes(searchLower), + ), + })); + const hasResults = filteredSections.some(section => !!section.items.length); + + return ( + + + + {filteredSections.map( + section => + !!section.items.length && ( + + {section.items.map(({ id, name, Icon }) => ( + section.onSelect({ id })} + value={name} + className={css({ + padding: '8px 16px', + cursor: 'pointer', + fontSize: '0.9rem', + borderRadius: '4px', + margin: '0', + display: 'flex', + alignItems: 'center', + gap: '8px', + // Avoid showing mouse hover styles when using keyboard navigation + '[data-cmdk-list]:not([data-cmdk-list-nav-active]) &:hover': + { + backgroundColor: + 'var(--color-menuItemBackgroundHover)', + color: 'var(--color-menuItemTextHover)', + }, + // eslint-disable-next-line rulesdir/typography + "&[data-selected='true']": { + backgroundColor: 'var(--color-menuItemBackgroundHover)', + color: 'var(--color-menuItemTextHover)', + }, + })} + > + + {name} + + ))} + + ), + )} + + {!hasResults && ( + + {t('No results found')} + + )} + + + ); +} diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 692b5783bd..a357739ded 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -18,6 +18,7 @@ import * as undo from 'loot-core/platform/client/undo'; import { UserAccessPage } from './admin/UserAccess/UserAccessPage'; import { BankSync } from './banksync'; import { BankSyncStatus } from './BankSyncStatus'; +import { CommandBar } from './CommandBar'; import { GlobalKeys } from './GlobalKeys'; import { ManageRulesPage } from './ManageRulesPage'; import { Category } from './mobile/budget/Category'; @@ -176,7 +177,7 @@ export function FinancesApp() { - +