diff --git a/front/src/modules/ui/input/relation-picker/components/CreateNewButton.tsx b/front/src/modules/ui/input/relation-picker/components/CreateNewButton.tsx new file mode 100644 index 000000000..06d4f836f --- /dev/null +++ b/front/src/modules/ui/input/relation-picker/components/CreateNewButton.tsx @@ -0,0 +1,14 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { MenuItem } from '@/ui/menu-item/components/MenuItem'; + +const StyledCreateNewButton = styled(MenuItem)<{ hovered: boolean }>` + ${({ hovered, theme }) => + hovered && + css` + background: ${theme.background.transparent.light}; + `} +`; + +export const CreateNewButton = StyledCreateNewButton; diff --git a/front/src/modules/ui/input/relation-picker/components/SingleEntitySelect.tsx b/front/src/modules/ui/input/relation-picker/components/SingleEntitySelect.tsx index 26bb28075..357b07254 100644 --- a/front/src/modules/ui/input/relation-picker/components/SingleEntitySelect.tsx +++ b/front/src/modules/ui/input/relation-picker/components/SingleEntitySelect.tsx @@ -2,10 +2,7 @@ import { useRef } from 'react'; import { DropdownMenuSearchInput } from '@/ui/dropdown/components/DropdownMenuSearchInput'; import { StyledDropdownMenu } from '@/ui/dropdown/components/StyledDropdownMenu'; -import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer'; import { StyledDropdownMenuSeparator } from '@/ui/dropdown/components/StyledDropdownMenuSeparator'; -import { IconPlus } from '@/ui/icon'; -import { MenuItem } from '@/ui/menu-item/components/MenuItem'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { isDefined } from '~/utils/isDefined'; @@ -59,6 +56,7 @@ export const SingleEntitySelect = < onCancel?.(); }, }); + return ( - - {showCreateButton && ( - <> - - - - - - )} + ); }; diff --git a/front/src/modules/ui/input/relation-picker/components/SingleEntitySelectBase.tsx b/front/src/modules/ui/input/relation-picker/components/SingleEntitySelectBase.tsx index a1ebf4165..0da136af8 100644 --- a/front/src/modules/ui/input/relation-picker/components/SingleEntitySelectBase.tsx +++ b/front/src/modules/ui/input/relation-picker/components/SingleEntitySelectBase.tsx @@ -2,19 +2,24 @@ import { useRef } from 'react'; import { Key } from 'ts-key-enum'; import { StyledDropdownMenuItemsContainer } from '@/ui/dropdown/components/StyledDropdownMenuItemsContainer'; +import { StyledDropdownMenuSeparator } from '@/ui/dropdown/components/StyledDropdownMenuSeparator'; +import { IconPlus } from '@/ui/icon'; import type { IconComponent } from '@/ui/icon/types/IconComponent'; import { MenuItem } from '@/ui/menu-item/components/MenuItem'; +import { MenuItemSelect } from '@/ui/menu-item/components/MenuItemSelect'; import { MenuItemSelectAvatar } from '@/ui/menu-item/components/MenuItemSelectAvatar'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { Avatar } from '@/users/components/Avatar'; import { assertNotNull } from '~/utils/assert'; import { isNonEmptyString } from '~/utils/isNonEmptyString'; +import { CreateButtonId, EmptyButtonId } from '../constants'; import { useEntitySelectScroll } from '../hooks/useEntitySelectScroll'; import { EntityForSelect } from '../types/EntityForSelect'; import { RelationPickerHotkeyScope } from '../types/RelationPickerHotkeyScope'; import { DropdownMenuSkeletonItem } from './skeletons/DropdownMenuSkeletonItem'; +import { CreateNewButton } from './CreateNewButton'; export type SingleEntitySelectBaseProps< CustomEntityForSelect extends EntityForSelect, @@ -26,6 +31,8 @@ export type SingleEntitySelectBaseProps< onCancel?: () => void; onEntitySelected: (entity?: CustomEntityForSelect) => void; selectedEntity?: CustomEntityForSelect; + onCreate?: () => void; + showCreateButton?: boolean; }; export const SingleEntitySelectBase = < @@ -38,6 +45,8 @@ export const SingleEntitySelectBase = < onCancel, onEntitySelected, selectedEntity, + onCreate, + showCreateButton, }: SingleEntitySelectBaseProps) => { const containerRef = useRef(null); const entitiesInDropdown = [selectedEntity, ...entitiesToSelect].filter( @@ -45,19 +54,31 @@ export const SingleEntitySelectBase = < assertNotNull(entity) && isNonEmptyString(entity.name.trim()), ); - const { hoveredIndex, resetScroll } = useEntitySelectScroll({ - entities: entitiesInDropdown, + const { preselectedOptionId, resetScroll } = useEntitySelectScroll({ + selectableOptionIds: [ + EmptyButtonId, + ...entitiesInDropdown.map((item) => item.id), + ...(showCreateButton ? [CreateButtonId] : []), + ], containerRef, }); useScopedHotkeys( Key.Enter, () => { - onEntitySelected(entitiesInDropdown[hoveredIndex]); + if (showCreateButton && preselectedOptionId === CreateButtonId) { + onCreate?.(); + } else { + const entity = entitiesInDropdown.findIndex( + (entity) => entity.id === preselectedOptionId, + ); + onEntitySelected(entitiesInDropdown[entity]); + } + resetScroll(); }, RelationPickerHotkeyScope.RelationPicker, - [entitiesInDropdown, hoveredIndex, onEntitySelected], + [entitiesInDropdown, preselectedOptionId, onEntitySelected], ); useScopedHotkeys( @@ -70,39 +91,56 @@ export const SingleEntitySelectBase = < ); return ( - - {emptyLabel && ( - onEntitySelected()} - LeftIcon={EmptyIcon} - text={emptyLabel} - /> - )} - {loading ? ( - - ) : entitiesInDropdown.length === 0 ? ( - - ) : ( - entitiesInDropdown?.map((entity) => ( - onEntitySelected(entity)} - text={entity.name} - hovered={hoveredIndex === entitiesInDropdown.indexOf(entity)} - avatar={ - - } + <> + + {emptyLabel && ( + onEntitySelected()} + LeftIcon={EmptyIcon} + text={emptyLabel} + hovered={preselectedOptionId === EmptyButtonId} + selected={!selectedEntity} /> - )) + )} + {loading ? ( + + ) : entitiesInDropdown.length === 0 ? ( + + ) : ( + entitiesInDropdown?.map((entity) => ( + onEntitySelected(entity)} + text={entity.name} + hovered={preselectedOptionId === entity.id} + avatar={ + + } + /> + )) + )} + + {showCreateButton && ( + <> + + + + + )} - + ); }; diff --git a/front/src/modules/ui/input/relation-picker/constants/index.ts b/front/src/modules/ui/input/relation-picker/constants/index.ts new file mode 100644 index 000000000..4521251b9 --- /dev/null +++ b/front/src/modules/ui/input/relation-picker/constants/index.ts @@ -0,0 +1,2 @@ +export const CreateButtonId = 'create-button'; +export const EmptyButtonId = 'empty-button'; diff --git a/front/src/modules/ui/input/relation-picker/hooks/useEntitySelectScroll.ts b/front/src/modules/ui/input/relation-picker/hooks/useEntitySelectScroll.ts index 67d78b391..88c8f0f3e 100644 --- a/front/src/modules/ui/input/relation-picker/hooks/useEntitySelectScroll.ts +++ b/front/src/modules/ui/input/relation-picker/hooks/useEntitySelectScroll.ts @@ -4,28 +4,36 @@ import { Key } from 'ts-key-enum'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; -import { relationPickerHoverIndexScopedState } from '../states/relationPickerHoverIndexScopedState'; -import { EntityForSelect } from '../types/EntityForSelect'; +import { CreateButtonId } from '../constants'; +import { RelationPickerRecoilScopeContext } from '../states/recoil-scope-contexts/RelationPickerRecoilScopeContext'; +import { relationPickerPreselectedIdScopedState } from '../states/relationPickerPreselectedIdScopedState'; import { RelationPickerHotkeyScope } from '../types/RelationPickerHotkeyScope'; +import { getPreselectedIdIndex } from '../utils/getPreselectedIdIndex'; -export const useEntitySelectScroll = < - CustomEntityForSelect extends EntityForSelect, ->({ +export const useEntitySelectScroll = ({ containerRef, - entities, + selectableOptionIds, }: { - entities: CustomEntityForSelect[]; + selectableOptionIds: string[]; containerRef: React.RefObject; }) => { - const [relationPickerHoverIndex, setRelationPickerHoverIndex] = - useRecoilScopedState(relationPickerHoverIndexScopedState); + const [relationPickerPreselectedId, setRelationPickerPreselectedId] = + useRecoilScopedState( + relationPickerPreselectedIdScopedState, + RelationPickerRecoilScopeContext, + ); + + const preselectedIdIndex = getPreselectedIdIndex( + selectableOptionIds, + relationPickerPreselectedId, + ); const resetScroll = () => { - setRelationPickerHoverIndex(0); + setRelationPickerPreselectedId(''); - const currentHoveredRef = containerRef.current?.children[0] as HTMLElement; + const preselectedRef = containerRef.current?.children[0] as HTMLElement; - scrollIntoView(currentHoveredRef, { + scrollIntoView(preselectedRef, { align: { top: 0, }, @@ -39,16 +47,15 @@ export const useEntitySelectScroll = < useScopedHotkeys( Key.ArrowUp, () => { - setRelationPickerHoverIndex((prevSelectedIndex) => - Math.max(prevSelectedIndex - 1, 0), - ); - - const currentHoveredRef = containerRef.current?.children[ - relationPickerHoverIndex + const previousSelectableIndex = Math.max(preselectedIdIndex - 1, 0); + const previousSelectableId = selectableOptionIds[previousSelectableIndex]; + setRelationPickerPreselectedId(previousSelectableId); + const preselectedRef = containerRef.current?.children[ + previousSelectableIndex ] as HTMLElement; - if (currentHoveredRef) { - scrollIntoView(currentHoveredRef, { + if (preselectedRef) { + scrollIntoView(preselectedRef, { align: { top: 0.5, }, @@ -60,38 +67,40 @@ export const useEntitySelectScroll = < } }, RelationPickerHotkeyScope.RelationPicker, - [setRelationPickerHoverIndex, entities], + [selectableOptionIds], ); useScopedHotkeys( Key.ArrowDown, () => { - setRelationPickerHoverIndex((prevSelectedIndex) => - Math.min(prevSelectedIndex + 1, (entities?.length ?? 0) - 1), + const nextSelectableIndex = Math.min( + preselectedIdIndex + 1, + selectableOptionIds?.length - 1, ); + const nextSelectableId = selectableOptionIds[nextSelectableIndex]; + setRelationPickerPreselectedId(nextSelectableId); + if (nextSelectableId !== CreateButtonId) { + const preselectedRef = containerRef.current?.children[ + nextSelectableIndex + ] as HTMLElement; - const currentHoveredRef = containerRef.current?.children[ - relationPickerHoverIndex - ] as HTMLElement; - - if (currentHoveredRef) { - scrollIntoView(currentHoveredRef, { - align: { - top: 0.15, - }, - isScrollable: (target) => { - return target === containerRef.current; - }, - time: 0, - }); + if (preselectedRef) { + scrollIntoView(preselectedRef, { + align: { + top: 0.15, + }, + isScrollable: (target) => target === containerRef.current, + time: 0, + }); + } } }, RelationPickerHotkeyScope.RelationPicker, - [setRelationPickerHoverIndex, entities], + [selectableOptionIds], ); return { - hoveredIndex: relationPickerHoverIndex, + preselectedOptionId: relationPickerPreselectedId, resetScroll, }; }; diff --git a/front/src/modules/ui/input/relation-picker/hooks/useEntitySelectSearch.ts b/front/src/modules/ui/input/relation-picker/hooks/useEntitySelectSearch.ts index 3858fe757..e9a15af31 100644 --- a/front/src/modules/ui/input/relation-picker/hooks/useEntitySelectSearch.ts +++ b/front/src/modules/ui/input/relation-picker/hooks/useEntitySelectSearch.ts @@ -3,12 +3,14 @@ import debounce from 'lodash.debounce'; import { useRecoilScopedState } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedState'; -import { relationPickerHoverIndexScopedState } from '../states/relationPickerHoverIndexScopedState'; +import { RelationPickerRecoilScopeContext } from '../states/recoil-scope-contexts/RelationPickerRecoilScopeContext'; +import { relationPickerPreselectedIdScopedState } from '../states/relationPickerPreselectedIdScopedState'; import { relationPickerSearchFilterScopedState } from '../states/relationPickerSearchFilterScopedState'; export const useEntitySelectSearch = () => { - const [, setRelationPickerHoverIndex] = useRecoilScopedState( - relationPickerHoverIndexScopedState, + const [, setRelationPickerPreselectedId] = useRecoilScopedState( + relationPickerPreselectedIdScopedState, + RelationPickerRecoilScopeContext, ); const [relationPickerSearchFilter, setRelationPickerSearchFilter] = @@ -26,7 +28,7 @@ export const useEntitySelectSearch = () => { event: React.ChangeEvent, ) => { debouncedSetSearchFilter(event.currentTarget.value); - setRelationPickerHoverIndex(0); + setRelationPickerPreselectedId(''); }; useEffect(() => { diff --git a/front/src/modules/ui/input/relation-picker/states/recoil-scope-contexts/RelationPickerRecoilScopeContext.ts b/front/src/modules/ui/input/relation-picker/states/recoil-scope-contexts/RelationPickerRecoilScopeContext.ts new file mode 100644 index 000000000..25dbee7f3 --- /dev/null +++ b/front/src/modules/ui/input/relation-picker/states/recoil-scope-contexts/RelationPickerRecoilScopeContext.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +export const RelationPickerRecoilScopeContext = createContext( + 'relation-picker-context', +); diff --git a/front/src/modules/ui/input/relation-picker/states/relationPickerHoverIndexScopedState.ts b/front/src/modules/ui/input/relation-picker/states/relationPickerHoverIndexScopedState.ts deleted file mode 100644 index dad7085d7..000000000 --- a/front/src/modules/ui/input/relation-picker/states/relationPickerHoverIndexScopedState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { atomFamily } from 'recoil'; - -export const relationPickerHoverIndexScopedState = atomFamily({ - key: 'relationPickerHoverIndexScopedState', - default: 0, -}); diff --git a/front/src/modules/ui/input/relation-picker/states/relationPickerPreselectedIdScopedState.ts b/front/src/modules/ui/input/relation-picker/states/relationPickerPreselectedIdScopedState.ts new file mode 100644 index 000000000..101f15a50 --- /dev/null +++ b/front/src/modules/ui/input/relation-picker/states/relationPickerPreselectedIdScopedState.ts @@ -0,0 +1,9 @@ +import { atomFamily } from 'recoil'; + +export const relationPickerPreselectedIdScopedState = atomFamily< + string, + string +>({ + key: 'relationPickerPreselectedIdScopedState', + default: (param) => param, +}); diff --git a/front/src/modules/ui/input/relation-picker/utils/getPreselectedIdIndex.ts b/front/src/modules/ui/input/relation-picker/utils/getPreselectedIdIndex.ts new file mode 100644 index 000000000..f42631ec2 --- /dev/null +++ b/front/src/modules/ui/input/relation-picker/utils/getPreselectedIdIndex.ts @@ -0,0 +1,10 @@ +export const getPreselectedIdIndex = ( + selectableOptionIds: string[], + preselectedOptionId: string, +) => { + const preselectedIdIndex = selectableOptionIds.findIndex( + (option) => option === preselectedOptionId, + ); + + return preselectedIdIndex === -1 ? 0 : preselectedIdIndex; +};