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"