diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx index f6a92bc7def..99ac398825c 100644 --- a/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentRendererProvider.tsx @@ -1,4 +1,40 @@ import { FrontComponentInstanceContext } from '@/front-components/states/contexts/FrontComponentInstanceContext'; +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { type FocusEvent, useCallback, useEffect, useMemo } from 'react'; + +const INPUT_TYPES_WITHOUT_TEXT_ENTRY = new Set([ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit', +]); + +const isEditableElement = (element: EventTarget | null): boolean => { + if (!(element instanceof HTMLElement)) { + return false; + } + + if (element instanceof HTMLTextAreaElement) { + return !element.disabled; + } + + if (element instanceof HTMLInputElement) { + return ( + !element.disabled && + !INPUT_TYPES_WITHOUT_TEXT_ENTRY.has(element.type.toLowerCase()) + ); + } + + return element.isContentEditable; +}; type FrontComponentRendererProviderProps = { frontComponentId: string; @@ -9,11 +45,78 @@ export const FrontComponentRendererProvider = ({ frontComponentId, children, }: FrontComponentRendererProviderProps) => { + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + const { removeFocusItemFromFocusStackById } = + useRemoveFocusItemFromFocusStackById(); + + const focusId = useMemo( + () => `front-component-renderer-${frontComponentId}-input`, + [frontComponentId], + ); + + const pushFrontComponentInputFocusItem = useCallback(() => { + pushFocusItemToFocusStack({ + focusId, + component: { + type: FocusComponentType.TEXT_INPUT, + instanceId: focusId, + }, + globalHotkeysConfig: { + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }); + }, [focusId, pushFocusItemToFocusStack]); + + const removeFrontComponentInputFocusItem = useCallback(() => { + removeFocusItemFromFocusStackById({ focusId }); + }, [focusId, removeFocusItemFromFocusStackById]); + + const handleFocusCapture = useCallback( + (event: FocusEvent) => { + if (!isEditableElement(event.target)) { + return; + } + + pushFrontComponentInputFocusItem(); + }, + [pushFrontComponentInputFocusItem], + ); + + const handleBlurCapture = useCallback( + (event: FocusEvent) => { + if (!isEditableElement(event.target)) { + return; + } + + if ( + event.currentTarget.contains(event.relatedTarget) && + isEditableElement(event.relatedTarget) + ) { + return; + } + + removeFrontComponentInputFocusItem(); + }, + [removeFrontComponentInputFocusItem], + ); + + useEffect(() => { + return () => { + removeFrontComponentInputFocusItem(); + }; + }, [removeFrontComponentInputFocusItem]); + return ( - {children} +
+ {children} +
); }; diff --git a/packages/twenty-front/src/modules/front-components/components/__tests__/FrontComponentRendererProvider.test.tsx b/packages/twenty-front/src/modules/front-components/components/__tests__/FrontComponentRendererProvider.test.tsx new file mode 100644 index 00000000000..aac199a972e --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/components/__tests__/FrontComponentRendererProvider.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render } from '@testing-library/react'; + +import { FrontComponentRendererProvider } from '@/front-components/components/FrontComponentRendererProvider'; + +const mockPushFocusItemToFocusStack = jest.fn(); +const mockRemoveFocusItemFromFocusStackById = jest.fn(); + +jest.mock('@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack', () => ({ + usePushFocusItemToFocusStack: () => ({ + pushFocusItemToFocusStack: mockPushFocusItemToFocusStack, + }), +})); + +jest.mock('@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById', () => ({ + useRemoveFocusItemFromFocusStackById: () => ({ + removeFocusItemFromFocusStackById: mockRemoveFocusItemFromFocusStackById, + }), +})); + +describe('FrontComponentRendererProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should disable conflicting global hotkeys when focusing an editable input', () => { + const { getByRole } = render( + + + , + ); + + fireEvent.focus(getByRole('textbox')); + + expect(mockPushFocusItemToFocusStack).toHaveBeenCalledWith({ + focusId: 'front-component-renderer-front-component-id-input', + component: { + type: 'text-input', + instanceId: 'front-component-renderer-front-component-id-input', + }, + globalHotkeysConfig: { + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }); + }); + + it('should keep focus stack item while moving focus between editable fields', () => { + const { getByTestId } = render( + + <> + +