diff --git a/front/package.json b/front/package.json
index e00ebc841..45203794a 100644
--- a/front/package.json
+++ b/front/package.json
@@ -22,7 +22,6 @@
"@types/react-helmet-async": "^1.0.3",
"afterframe": "^1.0.2",
"apollo-upload-client": "^17.0.0",
- "cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"deep-equal": "^2.2.2",
"framer-motion": "^10.12.17",
diff --git a/front/src/AppNavbar.tsx b/front/src/AppNavbar.tsx
index 21c981377..d25e66cb4 100644
--- a/front/src/AppNavbar.tsx
+++ b/front/src/AppNavbar.tsx
@@ -19,7 +19,7 @@ import NavTitle from '@/ui/navigation/navbar/components/NavTitle';
export const AppNavbar = () => {
const currentPath = useLocation().pathname;
- const { openCommandMenu } = useCommandMenu();
+ const { toggleCommandMenu } = useCommandMenu();
const isInSubMenu = useIsSubMenuNavbarDisplayed();
const { currentUserDueTaskCount } = useCurrentUserTaskCount();
@@ -32,7 +32,7 @@ export const AppNavbar = () => {
label="Search"
Icon={IconSearch}
onClick={() => {
- openCommandMenu();
+ toggleCommandMenu();
}}
keyboard={['⌘', 'K']}
/>
diff --git a/front/src/effect-components/PageChangeEffect.tsx b/front/src/effect-components/PageChangeEffect.tsx
index f90e5d101..792ef555b 100644
--- a/front/src/effect-components/PageChangeEffect.tsx
+++ b/front/src/effect-components/PageChangeEffect.tsx
@@ -182,6 +182,7 @@ export const PageChangeEffect = () => {
addToCommandMenu([
{
+ id: 'create-task',
to: '',
label: 'Create Task',
type: CommandType.Create,
diff --git a/front/src/modules/command-menu/components/CommandGroup.tsx b/front/src/modules/command-menu/components/CommandGroup.tsx
index c01212a12..d6246e382 100644
--- a/front/src/modules/command-menu/components/CommandGroup.tsx
+++ b/front/src/modules/command-menu/components/CommandGroup.tsx
@@ -1,21 +1,17 @@
import React from 'react';
import styled from '@emotion/styled';
-import { Command } from 'cmdk';
-const StyledGroup = styled(Command.Group)`
- [cmdk-group-heading] {
- align-items: center;
- color: ${({ theme }) => theme.font.color.light};
- display: flex;
- font-size: ${({ theme }) => theme.font.size.xs};
- font-weight: ${({ theme }) => theme.font.weight.semiBold};
- padding-bottom: ${({ theme }) => theme.spacing(2)};
- padding-left: ${({ theme }) => theme.spacing(2)};
- padding-right: ${({ theme }) => theme.spacing(1)};
- padding-top: ${({ theme }) => theme.spacing(2)};
- text-transform: uppercase;
- user-select: none;
- }
+const StyledGroup = styled.div`
+ align-items: center;
+ color: ${({ theme }) => theme.font.color.light};
+ font-size: ${({ theme }) => theme.font.size.xs};
+ font-weight: ${({ theme }) => theme.font.weight.semiBold};
+ padding-bottom: ${({ theme }) => theme.spacing(2)};
+ padding-left: ${({ theme }) => theme.spacing(2)};
+ padding-right: ${({ theme }) => theme.spacing(1)};
+ padding-top: ${({ theme }) => theme.spacing(2)};
+ text-transform: uppercase;
+ user-select: none;
`;
type CommandGroupProps = {
@@ -27,5 +23,10 @@ export const CommandGroup = ({ heading, children }: CommandGroupProps) => {
if (!children || !React.Children.count(children)) {
return null;
}
- return {children};
+ return (
+
+ {heading}
+ {children}
+
+ );
};
diff --git a/front/src/modules/command-menu/components/CommandMenu.tsx b/front/src/modules/command-menu/components/CommandMenu.tsx
index 9f089601c..d3983d25b 100644
--- a/front/src/modules/command-menu/components/CommandMenu.tsx
+++ b/front/src/modules/command-menu/components/CommandMenu.tsx
@@ -1,12 +1,18 @@
import { useState } from 'react';
+import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
+import { CommandMenuSelectableListEffect } from '@/command-menu/components/CommandMenuSelectableListEffect';
import { useFindManyObjectRecords } from '@/object-record/hooks/useFindManyObjectRecords';
import { Person } from '@/people/types/Person';
import { IconNotes } from '@/ui/display/icon';
+import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem';
+import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
+import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
+import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { Avatar } from '@/users/components/Avatar';
import { getLogoUrlFromDomainName } from '~/utils';
@@ -17,21 +23,77 @@ import { Command, CommandType } from '../types/Command';
import { CommandGroup } from './CommandGroup';
import { CommandMenuItem } from './CommandMenuItem';
-import {
- StyledDialog,
- StyledEmpty,
- StyledInput,
- StyledList,
-} from './CommandMenuStyles';
+
+export const StyledDialog = styled.div`
+ background: ${({ theme }) => theme.background.primary};
+ border-radius: ${({ theme }) => theme.border.radius.md};
+ box-shadow: ${({ theme }) => theme.boxShadow.strong};
+ font-family: ${({ theme }) => theme.font.family};
+ left: 50%;
+ max-width: 640px;
+ overflow: hidden;
+ padding: 0;
+ position: fixed;
+ top: 30%;
+ transform: ${() =>
+ useIsMobile() ? 'translateX(-49.5%)' : 'translateX(-50%)'};
+ width: ${() => (useIsMobile() ? 'calc(100% - 40px)' : '100%')};
+ z-index: 1000;
+`;
+
+export const StyledInput = styled.input`
+ background: ${({ theme }) => theme.background.primary};
+ border: none;
+ border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
+ border-radius: 0;
+ color: ${({ theme }) => theme.font.color.primary};
+ font-size: ${({ theme }) => theme.font.size.lg};
+ margin: 0;
+ outline: none;
+ padding: ${({ theme }) => theme.spacing(5)};
+ width: ${({ theme }) => `calc(100% - ${theme.spacing(10)})`};
+
+ &::placeholder {
+ color: ${({ theme }) => theme.font.color.light};
+ }
+`;
+
+export const StyledList = styled.div`
+ background: ${({ theme }) => theme.background.secondary};
+ height: 400px;
+ max-height: 400px;
+ overscroll-behavior: contain;
+ transition: 100ms ease;
+ transition-property: height;
+`;
+
+export const StyledInnerList = styled.div`
+ padding-left: ${({ theme }) => theme.spacing(1)};
+ width: 100%;
+`;
+
+export const StyledEmpty = styled.div`
+ align-items: center;
+ color: ${({ theme }) => theme.font.color.light};
+ display: flex;
+ font-size: ${({ theme }) => theme.font.size.md};
+ height: 64px;
+ justify-content: center;
+ white-space: pre-wrap;
+`;
export const CommandMenu = () => {
- const { openCommandMenu, closeCommandMenu, toggleCommandMenu } =
- useCommandMenu();
+ const { toggleCommandMenu, closeCommandMenu } = useCommandMenu();
+
const openActivityRightDrawer = useOpenActivityRightDrawer();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const [search, setSearch] = useState('');
const commandMenuCommands = useRecoilValue(commandMenuCommandsState);
+ const handleSearchChange = (event: React.ChangeEvent) => {
+ setSearch(event.target.value);
+ };
+
useScopedHotkeys(
'ctrl+k,meta+k',
() => {
@@ -39,7 +101,17 @@ export const CommandMenu = () => {
toggleCommandMenu();
},
AppHotkeyScope.CommandMenu,
- [openCommandMenu, setSearch],
+ [toggleCommandMenu, setSearch],
+ );
+
+ useScopedHotkeys(
+ 'esc',
+ () => {
+ setSearch('');
+ closeCommandMenu();
+ },
+ AppHotkeyScope.CommandMenu,
+ [toggleCommandMenu, setSearch],
);
const { objects: people } = useFindManyObjectRecords({
@@ -102,96 +174,133 @@ export const CommandMenu = () => {
: true) && cmd.type === CommandType.Create,
);
+ const selectableItemIds = matchingCreateCommand
+ .map((cmd) => cmd.id)
+ .concat(matchingNavigateCommand.map((cmd) => cmd.id))
+ .concat(people.map((person) => person.id))
+ .concat(companies.map((company) => company.id))
+ .concat(activities.map((activity) => activity.id));
+
return (
- {
- if (!opened) {
- closeCommandMenu();
- }
- }}
- shouldFilter={false}
- label="Global Command Menu"
- >
-
-
- No results found.
-
- {matchingCreateCommand.map((cmd) => (
-
- ))}
-
-
- {matchingNavigateCommand.map((cmd) => (
-
- ))}
-
-
- {people.map((person) => (
- (
-
- )}
- />
- ))}
-
-
- {companies.map((company) => (
- (
-
- )}
- />
- ))}
-
-
- {activities.map((activity) => (
- openActivityRightDrawer(activity.id)}
- />
- ))}
-
-
-
+ isCommandMenuOpened && (
+
+
+
+
+
+
+
+ {!matchingCreateCommand.length &&
+ !matchingNavigateCommand.length &&
+ !people.length &&
+ !companies.length &&
+ !activities.length && (
+ No results found
+ )}
+
+ {matchingCreateCommand.map((cmd) => (
+
+
+
+ ))}
+
+
+ {matchingNavigateCommand.map((cmd) => (
+
+
+
+ ))}
+
+
+ {people.map((person) => (
+
+ (
+
+ )}
+ />
+
+ ))}
+
+
+ {companies.map((company) => (
+
+ (
+
+ )}
+ />
+
+ ))}
+
+
+ {activities.map((activity) => (
+
+ openActivityRightDrawer(activity.id)}
+ />
+
+ ))}
+
+
+
+
+
+
+ )
);
};
diff --git a/front/src/modules/command-menu/components/CommandMenuItem.tsx b/front/src/modules/command-menu/components/CommandMenuItem.tsx
index 29d2a4664..88f165175 100644
--- a/front/src/modules/command-menu/components/CommandMenuItem.tsx
+++ b/front/src/modules/command-menu/components/CommandMenuItem.tsx
@@ -2,6 +2,7 @@ import { useNavigate } from 'react-router-dom';
import { IconArrowUpRight } from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
+import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { MenuItemCommand } from '@/ui/navigation/menu-item/components/MenuItemCommand';
import { useCommandMenu } from '../hooks/useCommandMenu';
@@ -9,7 +10,7 @@ import { useCommandMenu } from '../hooks/useCommandMenu';
export type CommandMenuItemProps = {
label: string;
to?: string;
- key: string;
+ id: string;
onClick?: () => void;
Icon?: IconComponent;
firstHotKey?: string;
@@ -19,20 +20,23 @@ export type CommandMenuItemProps = {
export const CommandMenuItem = ({
label,
to,
+ id,
onClick,
Icon,
firstHotKey,
secondHotKey,
}: CommandMenuItemProps) => {
const navigate = useNavigate();
- const { closeCommandMenu } = useCommandMenu();
+ const { toggleCommandMenu } = useCommandMenu();
if (to && !Icon) {
Icon = IconArrowUpRight;
}
+ const { isSelectedItemId } = useSelectableList({ itemId: id });
+
const onItemClick = () => {
- closeCommandMenu();
+ toggleCommandMenu();
if (onClick) {
onClick();
@@ -51,6 +55,7 @@ export const CommandMenuItem = ({
firstHotKey={firstHotKey}
secondHotKey={secondHotKey}
onClick={onItemClick}
+ isSelected={isSelectedItemId}
/>
);
};
diff --git a/front/src/modules/command-menu/components/CommandMenuSelectableListEffect.tsx b/front/src/modules/command-menu/components/CommandMenuSelectableListEffect.tsx
new file mode 100644
index 000000000..200539e1e
--- /dev/null
+++ b/front/src/modules/command-menu/components/CommandMenuSelectableListEffect.tsx
@@ -0,0 +1,21 @@
+import { useEffect } from 'react';
+
+import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
+
+type CommandMenuSelectableListEffectProps = {
+ selectableItemIds: string[];
+};
+
+export const CommandMenuSelectableListEffect = ({
+ selectableItemIds,
+}: CommandMenuSelectableListEffectProps) => {
+ const { setSelectableItemIds } = useSelectableList({
+ selectableListId: 'command-menu-list',
+ });
+
+ useEffect(() => {
+ setSelectableItemIds(selectableItemIds);
+ }, [selectableItemIds, setSelectableItemIds]);
+
+ return <>>;
+};
diff --git a/front/src/modules/command-menu/components/CommandMenuStyles.tsx b/front/src/modules/command-menu/components/CommandMenuStyles.tsx
deleted file mode 100644
index 1e26491d9..000000000
--- a/front/src/modules/command-menu/components/CommandMenuStyles.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import styled from '@emotion/styled';
-import { Command } from 'cmdk';
-
-import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
-
-export const StyledDialog = styled(Command.Dialog)`
- background: ${({ theme }) => theme.background.primary};
- border-radius: ${({ theme }) => theme.border.radius.md};
- box-shadow: ${({ theme }) => theme.boxShadow.strong};
- font-family: ${({ theme }) => theme.font.family};
- left: 50%;
- max-width: 640px;
- overflow: hidden;
- padding: 0;
- padding: ${({ theme }) => theme.spacing(1)};
- position: fixed;
- top: 30%;
- transform: ${() =>
- useIsMobile() ? 'translateX(-49.5%)' : 'translateX(-50%)'};
- width: ${() => (useIsMobile() ? 'calc(100% - 40px)' : '100%')};
- z-index: 1000;
-`;
-
-export const StyledInput = styled(Command.Input)`
- background: ${({ theme }) => theme.background.primary};
- border: none;
- border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
- border-radius: 0;
- color: ${({ theme }) => theme.font.color.primary};
- font-size: ${({ theme }) => theme.font.size.lg};
- margin: 0;
- outline: none;
- padding: ${({ theme }) => theme.spacing(5)};
- width: 100%;
- &::placeholder {
- color: ${({ theme }) => theme.font.color.light};
- }
-`;
-
-export const StyledList = styled(Command.List)`
- background: ${({ theme }) => theme.background.secondary};
- height: min(300px, var(--cmdk-list-height));
- max-height: 400px;
- overflow: auto;
- overscroll-behavior: contain;
- transition: 100ms ease;
- transition-property: height;
-`;
-
-export const StyledEmpty = styled(Command.Empty)`
- align-items: center;
- color: ${({ theme }) => theme.font.color.light};
- display: flex;
- font-size: ${({ theme }) => theme.font.size.md};
- height: 64px;
- justify-content: center;
- white-space: pre-wrap;
-`;
diff --git a/front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx b/front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx
index 779b08fd0..03f30117e 100644
--- a/front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx
+++ b/front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx
@@ -20,13 +20,14 @@ const meta: Meta = {
decorators: [
ComponentWithRouterDecorator,
(Story) => {
- const { addToCommandMenu, setToIntitialCommandMenu, openCommandMenu } =
+ const { addToCommandMenu, setToIntitialCommandMenu, toggleCommandMenu } =
useCommandMenu();
useEffect(() => {
setToIntitialCommandMenu();
addToCommandMenu([
{
+ id: 'create-task',
to: '',
label: 'Create Task',
type: CommandType.Create,
@@ -34,6 +35,7 @@ const meta: Meta = {
onCommandClick: () => console.log('create task click'),
},
{
+ id: 'create-note',
to: '',
label: 'Create Note',
type: CommandType.Create,
@@ -41,8 +43,8 @@ const meta: Meta = {
onCommandClick: () => console.log('create note click'),
},
]);
- openCommandMenu();
- }, [addToCommandMenu, setToIntitialCommandMenu, openCommandMenu]);
+ toggleCommandMenu();
+ }, [addToCommandMenu, setToIntitialCommandMenu, toggleCommandMenu]);
return ;
},
diff --git a/front/src/modules/command-menu/constants/commandMenuCommands.ts b/front/src/modules/command-menu/constants/commandMenuCommands.ts
index dcc76e7fb..3d5414f4c 100644
--- a/front/src/modules/command-menu/constants/commandMenuCommands.ts
+++ b/front/src/modules/command-menu/constants/commandMenuCommands.ts
@@ -10,6 +10,7 @@ import { Command, CommandType } from '../types/Command';
export const commandMenuCommands: Command[] = [
{
+ id: 'go-to-people',
to: '/objects/people',
label: 'Go to People',
type: CommandType.Navigate,
@@ -18,6 +19,7 @@ export const commandMenuCommands: Command[] = [
Icon: IconUser,
},
{
+ id: 'go-to-companies',
to: '/objects/companies',
label: 'Go to Companies',
type: CommandType.Navigate,
@@ -26,6 +28,7 @@ export const commandMenuCommands: Command[] = [
Icon: IconBuildingSkyscraper,
},
{
+ id: 'go-to-activities',
to: '/objects/opportunities',
label: 'Go to Opportunities',
type: CommandType.Navigate,
@@ -34,6 +37,7 @@ export const commandMenuCommands: Command[] = [
Icon: IconTargetArrow,
},
{
+ id: 'go-to-settings',
to: '/settings/profile',
label: 'Go to Settings',
type: CommandType.Navigate,
@@ -42,6 +46,7 @@ export const commandMenuCommands: Command[] = [
Icon: IconSettings,
},
{
+ id: 'go-to-tasks',
to: '/tasks',
label: 'Go to Tasks',
type: CommandType.Navigate,
diff --git a/front/src/modules/command-menu/states/commandMenuCommandsState.ts b/front/src/modules/command-menu/states/commandMenuCommandsState.ts
index 8cea591c0..cfd4c3596 100644
--- a/front/src/modules/command-menu/states/commandMenuCommandsState.ts
+++ b/front/src/modules/command-menu/states/commandMenuCommandsState.ts
@@ -6,6 +6,7 @@ export const commandMenuCommandsState = atom({
key: 'command-menu/commandMenuCommandsState',
default: [
{
+ id: '',
to: '',
label: '',
type: CommandType.Navigate,
diff --git a/front/src/modules/command-menu/types/Command.ts b/front/src/modules/command-menu/types/Command.ts
index 843a5294c..eb7e43c72 100644
--- a/front/src/modules/command-menu/types/Command.ts
+++ b/front/src/modules/command-menu/types/Command.ts
@@ -6,6 +6,7 @@ export enum CommandType {
}
export type Command = {
+ id: string;
to: string;
label: string;
type: CommandType.Navigate | CommandType.Create;
diff --git a/front/src/modules/ui/layout/selectable-list/components/SelectableItem.tsx b/front/src/modules/ui/layout/selectable-list/components/SelectableItem.tsx
new file mode 100644
index 000000000..4f2feccdf
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/components/SelectableItem.tsx
@@ -0,0 +1,27 @@
+import React, { useEffect, useRef } from 'react';
+import { useRecoilValue } from 'recoil';
+
+import { useSelectableListScopedStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListScopedStates';
+
+type SelectableItemProps = {
+ itemId: string;
+ children: React.ReactElement;
+};
+
+export const SelectableItem = ({ itemId, children }: SelectableItemProps) => {
+ const { isSelectedItemIdSelector } = useSelectableListScopedStates({
+ itemId: itemId,
+ });
+
+ const isSelectedItemId = useRecoilValue(isSelectedItemIdSelector);
+
+ const scrollRef = useRef(null);
+
+ useEffect(() => {
+ if (isSelectedItemId) {
+ scrollRef.current?.scrollIntoView({ block: 'nearest' });
+ }
+ }, [isSelectedItemId]);
+
+ return {children}
;
+};
diff --git a/front/src/modules/ui/layout/selectable-list/components/SelectableList.tsx b/front/src/modules/ui/layout/selectable-list/components/SelectableList.tsx
new file mode 100644
index 000000000..587644e7f
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/components/SelectableList.tsx
@@ -0,0 +1,30 @@
+import { ReactNode } from 'react';
+import styled from '@emotion/styled';
+
+import { useSelectableListHotKeys } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys';
+import { SelectableListScope } from '@/ui/layout/selectable-list/scopes/SelectableListScope';
+type SelectableListProps = {
+ children: ReactNode;
+ selectableListId: string;
+ selectableItemIds: string[];
+ onSelect?: (selected: string) => void;
+};
+
+const StyledSelectableItemsContainer = styled.div`
+ width: 100%;
+`;
+
+export const SelectableList = ({
+ children,
+ selectableListId,
+}: SelectableListProps) => {
+ useSelectableListHotKeys(selectableListId);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/front/src/modules/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys.tsx b/front/src/modules/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys.tsx
new file mode 100644
index 000000000..e491a407f
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/hooks/internal/useSelectableListHotKeys.tsx
@@ -0,0 +1,88 @@
+import { isNull } from '@sniptt/guards';
+import { useRecoilCallback } from 'recoil';
+import { Key } from 'ts-key-enum';
+
+import { getSelectableListScopedStates } from '@/ui/layout/selectable-list/utils/internal/getSelectableListScopedStates';
+import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
+import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
+import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
+
+export const useSelectableListHotKeys = (scopeId: string) => {
+ const handleSelect = useRecoilCallback(
+ ({ snapshot, set }) =>
+ (direction: 'up' | 'down') => {
+ const { selectedItemIdState, selectableItemIdsState } =
+ getSelectableListScopedStates({
+ selectableListScopeId: scopeId,
+ });
+ const selectedItemId = getSnapshotValue(snapshot, selectedItemIdState);
+ const selectableItemIds = getSnapshotValue(
+ snapshot,
+ selectableItemIdsState,
+ );
+
+ const computeNextId = (direction: 'up' | 'down') => {
+ if (selectableItemIds.length === 0) {
+ return;
+ }
+
+ if (isNull(selectedItemId)) {
+ return direction === 'up'
+ ? selectableItemIds[selectableItemIds.length - 1]
+ : selectableItemIds[0];
+ }
+
+ const currentIndex = selectableItemIds.indexOf(selectedItemId);
+ if (currentIndex === -1) {
+ return direction === 'up'
+ ? selectableItemIds[selectableItemIds.length - 1]
+ : selectableItemIds[0];
+ }
+
+ return direction === 'up'
+ ? currentIndex == 0
+ ? selectableItemIds[selectableItemIds.length - 1]
+ : selectableItemIds[currentIndex - 1]
+ : currentIndex == selectableItemIds.length - 1
+ ? selectableItemIds[0]
+ : selectableItemIds[currentIndex + 1];
+ };
+
+ const nextId = computeNextId(direction);
+
+ if (nextId) {
+ const { isSelectedItemIdSelector } = getSelectableListScopedStates({
+ selectableListScopeId: scopeId,
+ itemId: nextId,
+ });
+ set(isSelectedItemIdSelector, true);
+ set(selectedItemIdState, nextId);
+ }
+
+ if (selectedItemId) {
+ const { isSelectedItemIdSelector } = getSelectableListScopedStates({
+ selectableListScopeId: scopeId,
+ itemId: selectedItemId,
+ });
+ set(isSelectedItemIdSelector, false);
+ }
+ },
+ [scopeId],
+ );
+
+ useScopedHotkeys(
+ Key.ArrowUp,
+ () => handleSelect('up'),
+ AppHotkeyScope.CommandMenu,
+ [],
+ );
+
+ useScopedHotkeys(
+ Key.ArrowDown,
+ () => handleSelect('down'),
+ AppHotkeyScope.CommandMenu,
+ [],
+ );
+
+ return <>>;
+};
diff --git a/front/src/modules/ui/layout/selectable-list/hooks/internal/useSelectableListScopedStates.ts b/front/src/modules/ui/layout/selectable-list/hooks/internal/useSelectableListScopedStates.ts
new file mode 100644
index 000000000..885fd68e3
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/hooks/internal/useSelectableListScopedStates.ts
@@ -0,0 +1,34 @@
+import { SelectableListScopeInternalContext } from '@/ui/layout/selectable-list/scopes/scope-internal-context/SelectableListScopeInternalContext';
+import { getSelectableListScopedStates } from '@/ui/layout/selectable-list/utils/internal/getSelectableListScopedStates';
+import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
+
+type UseSelectableListScopedStatesProps = {
+ selectableListScopeId?: string;
+ itemId?: string;
+};
+
+export const useSelectableListScopedStates = (
+ args?: UseSelectableListScopedStatesProps,
+) => {
+ const { selectableListScopeId, itemId } = args ?? {};
+ const scopeId = useAvailableScopeIdOrThrow(
+ SelectableListScopeInternalContext,
+ selectableListScopeId,
+ );
+
+ const {
+ selectedItemIdState,
+ selectableItemIdsState,
+ isSelectedItemIdSelector,
+ } = getSelectableListScopedStates({
+ selectableListScopeId: scopeId,
+ itemId: itemId,
+ });
+
+ return {
+ scopeId,
+ isSelectedItemIdSelector,
+ selectableItemIdsState,
+ selectedItemIdState,
+ };
+};
diff --git a/front/src/modules/ui/layout/selectable-list/hooks/useSelectableList.ts b/front/src/modules/ui/layout/selectable-list/hooks/useSelectableList.ts
new file mode 100644
index 000000000..b67144190
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/hooks/useSelectableList.ts
@@ -0,0 +1,33 @@
+import { useRecoilValue, useSetRecoilState } from 'recoil';
+
+import { useSelectableListScopedStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListScopedStates';
+import { SelectableListScopeInternalContext } from '@/ui/layout/selectable-list/scopes/scope-internal-context/SelectableListScopeInternalContext';
+import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
+
+type UseSelectableListProps = {
+ selectableListId?: string;
+ itemId?: string;
+};
+
+export const useSelectableList = (props?: UseSelectableListProps) => {
+ const scopeId = useAvailableScopeIdOrThrow(
+ SelectableListScopeInternalContext,
+ props?.selectableListId,
+ );
+
+ const { selectableItemIdsState, isSelectedItemIdSelector } =
+ useSelectableListScopedStates({
+ selectableListScopeId: scopeId,
+ itemId: props?.itemId,
+ });
+
+ const setSelectableItemIds = useSetRecoilState(selectableItemIdsState);
+ const isSelectedItemId = useRecoilValue(isSelectedItemIdSelector);
+
+ return {
+ setSelectableItemIds,
+ isSelectedItemId,
+ selectableListId: scopeId,
+ isSelectedItemIdSelector,
+ };
+};
diff --git a/front/src/modules/ui/layout/selectable-list/scopes/SelectableListScope.tsx b/front/src/modules/ui/layout/selectable-list/scopes/SelectableListScope.tsx
new file mode 100644
index 000000000..59af4bde4
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/scopes/SelectableListScope.tsx
@@ -0,0 +1,21 @@
+import { ReactNode } from 'react';
+
+import { SelectableListScopeInternalContext } from './scope-internal-context/SelectableListScopeInternalContext';
+
+type SelectableListScopeProps = {
+ children: ReactNode;
+ selectableListScopeId: string;
+};
+
+export const SelectableListScope = ({
+ children,
+ selectableListScopeId,
+}: SelectableListScopeProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/front/src/modules/ui/layout/selectable-list/scopes/scope-internal-context/SelectableListScopeInternalContext.ts b/front/src/modules/ui/layout/selectable-list/scopes/scope-internal-context/SelectableListScopeInternalContext.ts
new file mode 100644
index 000000000..57285e0ea
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/scopes/scope-internal-context/SelectableListScopeInternalContext.ts
@@ -0,0 +1,7 @@
+import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
+import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
+
+type SelectableListScopeInternalContextProps = ScopedStateKey;
+
+export const SelectableListScopeInternalContext =
+ createScopeInternalContext();
diff --git a/front/src/modules/ui/layout/selectable-list/states/isSelectedItemIdMapScopedFamilyState.ts b/front/src/modules/ui/layout/selectable-list/states/isSelectedItemIdMapScopedFamilyState.ts
new file mode 100644
index 000000000..ed527c59a
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/states/isSelectedItemIdMapScopedFamilyState.ts
@@ -0,0 +1,9 @@
+import { createScopedFamilyState } from '@/ui/utilities/recoil-scope/utils/createScopedFamilyState';
+
+export const isSelectedItemIdMapScopedFamilyState = createScopedFamilyState<
+ boolean,
+ string
+>({
+ key: 'isSelectedItemIdMapScopedFamilyState',
+ defaultValue: false,
+});
diff --git a/front/src/modules/ui/layout/selectable-list/states/selectableItemIdsScopedState.ts b/front/src/modules/ui/layout/selectable-list/states/selectableItemIdsScopedState.ts
new file mode 100644
index 000000000..861b9bee9
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/states/selectableItemIdsScopedState.ts
@@ -0,0 +1,6 @@
+import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
+
+export const selectableItemIdsScopedState = createScopedState({
+ key: 'selectableItemIdsScopedState',
+ defaultValue: [],
+});
diff --git a/front/src/modules/ui/layout/selectable-list/states/selectedItemIdScopedState.ts b/front/src/modules/ui/layout/selectable-list/states/selectedItemIdScopedState.ts
new file mode 100644
index 000000000..b1b41c3cf
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/states/selectedItemIdScopedState.ts
@@ -0,0 +1,6 @@
+import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
+
+export const selectedItemIdScopedState = createScopedState({
+ key: 'selectedItemIdScopedState',
+ defaultValue: null,
+});
diff --git a/front/src/modules/ui/layout/selectable-list/states/selectors/isSelectedItemIdScopedFamilySelector.ts b/front/src/modules/ui/layout/selectable-list/states/selectors/isSelectedItemIdScopedFamilySelector.ts
new file mode 100644
index 000000000..f217cef99
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/states/selectors/isSelectedItemIdScopedFamilySelector.ts
@@ -0,0 +1,26 @@
+import { selectorFamily } from 'recoil';
+
+import { isSelectedItemIdMapScopedFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdMapScopedFamilyState';
+
+export const isSelectedItemIdScopedFamilySelector = selectorFamily({
+ key: 'isSelectedItemIdScopedFamilySelector',
+ get:
+ ({ scopeId, itemId }: { scopeId: string; itemId: string }) =>
+ ({ get }) =>
+ get(
+ isSelectedItemIdMapScopedFamilyState({
+ scopeId: scopeId,
+ familyKey: itemId,
+ }),
+ ),
+ set:
+ ({ scopeId, itemId }: { scopeId: string; itemId: string }) =>
+ ({ set }, newValue) =>
+ set(
+ isSelectedItemIdMapScopedFamilyState({
+ scopeId: scopeId,
+ familyKey: itemId,
+ }),
+ newValue,
+ ),
+});
diff --git a/front/src/modules/ui/layout/selectable-list/utils/internal/getSelectableListScopedStates.ts b/front/src/modules/ui/layout/selectable-list/utils/internal/getSelectableListScopedStates.ts
new file mode 100644
index 000000000..8a04dcb14
--- /dev/null
+++ b/front/src/modules/ui/layout/selectable-list/utils/internal/getSelectableListScopedStates.ts
@@ -0,0 +1,35 @@
+import { selectableItemIdsScopedState } from '@/ui/layout/selectable-list/states/selectableItemIdsScopedState';
+import { selectedItemIdScopedState } from '@/ui/layout/selectable-list/states/selectedItemIdScopedState';
+import { isSelectedItemIdScopedFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdScopedFamilySelector';
+import { getScopedState } from '@/ui/utilities/recoil-scope/utils/getScopedState';
+
+const UNDEFINED_SELECTABLE_ITEM_ID = 'UNDEFINED_SELECTABLE_ITEM_ID';
+
+export const getSelectableListScopedStates = ({
+ selectableListScopeId,
+ itemId,
+}: {
+ selectableListScopeId: string;
+ itemId?: string;
+}) => {
+ const isSelectedItemIdSelector = isSelectedItemIdScopedFamilySelector({
+ scopeId: selectableListScopeId,
+ itemId: itemId ?? UNDEFINED_SELECTABLE_ITEM_ID,
+ });
+
+ const selectedItemIdState = getScopedState(
+ selectedItemIdScopedState,
+ selectableListScopeId,
+ );
+
+ const selectableItemIdsState = getScopedState(
+ selectableItemIdsScopedState,
+ selectableListScopeId,
+ );
+
+ return {
+ isSelectedItemIdSelector,
+ selectableItemIdsState,
+ selectedItemIdState,
+ };
+};
diff --git a/front/src/modules/ui/navigation/menu-item/components/MenuItemCommand.tsx b/front/src/modules/ui/navigation/menu-item/components/MenuItemCommand.tsx
index 1b89086cf..a9506ae14 100644
--- a/front/src/modules/ui/navigation/menu-item/components/MenuItemCommand.tsx
+++ b/front/src/modules/ui/navigation/menu-item/components/MenuItemCommand.tsx
@@ -1,6 +1,5 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
-import { Command } from 'cmdk';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
@@ -27,10 +26,12 @@ const StyledBigIconContainer = styled.div`
padding: ${({ theme }) => theme.spacing(1)};
`;
-const StyledMenuItemCommandContainer = styled(Command.Item)`
+const StyledMenuItemCommandContainer = styled.div<{ isSelected?: boolean }>`
--horizontal-padding: ${({ theme }) => theme.spacing(1)};
--vertical-padding: ${({ theme }) => theme.spacing(2)};
align-items: center;
+ background: ${({ isSelected, theme }) =>
+ isSelected ? theme.background.transparent.light : theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
@@ -38,8 +39,6 @@ const StyledMenuItemCommandContainer = styled(Command.Item)`
flex-direction: row;
font-size: ${({ theme }) => theme.font.size.sm};
gap: ${({ theme }) => theme.spacing(2)};
- height: calc(32px - 2 * var(--vertical-padding));
- height: 24px;
justify-content: space-between;
padding: var(--vertical-padding) var(--horizontal-padding);
position: relative;
@@ -69,6 +68,7 @@ export type MenuItemCommandProps = {
firstHotKey?: string;
secondHotKey?: string;
className?: string;
+ isSelected?: boolean;
onClick?: () => void;
};
@@ -78,12 +78,17 @@ export const MenuItemCommand = ({
firstHotKey,
secondHotKey,
className,
+ isSelected,
onClick,
}: MenuItemCommandProps) => {
const theme = useTheme();
return (
-
+
{LeftIcon && (
diff --git a/front/src/modules/ui/navigation/menu-item/components/__stories__/MenuItemCommand.stories.tsx b/front/src/modules/ui/navigation/menu-item/components/__stories__/MenuItemCommand.stories.tsx
index b4c871eab..6162d06ea 100644
--- a/front/src/modules/ui/navigation/menu-item/components/__stories__/MenuItemCommand.stories.tsx
+++ b/front/src/modules/ui/navigation/menu-item/components/__stories__/MenuItemCommand.stories.tsx
@@ -1,5 +1,4 @@
import { Meta, StoryObj } from '@storybook/react';
-import { Command } from 'cmdk';
import { IconBell } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
@@ -24,16 +23,15 @@ export const Default: Story = {
secondHotKey: '1',
},
render: (props) => (
-
-
-
+
),
decorators: [ComponentDecorator],
};
@@ -83,16 +81,15 @@ export const Catalog: CatalogStory = {
},
},
render: (props) => (
-
-
-
+
),
decorators: [CatalogDecorator],
};
diff --git a/front/yarn.lock b/front/yarn.lock
index 2d87e437d..479e7dc1a 100644
--- a/front/yarn.lock
+++ b/front/yarn.lock
@@ -8466,14 +8466,6 @@ clsx@^1.1.1:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
-cmdk@^0.2.0:
- version "0.2.0"
- resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-0.2.0.tgz#53c52d56d8776c8bb8ced1055b5054100c388f7c"
- integrity sha512-JQpKvEOb86SnvMZbYaFKYhvzFntWBeSZdyii0rZPhKJj9uwJBxu4DaVYDrRN7r3mPop56oPhRw+JYWTKs66TYw==
- dependencies:
- "@radix-ui/react-dialog" "1.0.0"
- command-score "0.1.2"
-
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"