diff --git a/packages/twenty-front-component-renderer/package.json b/packages/twenty-front-component-renderer/package.json index 39f998dbcd..9718abf588 100644 --- a/packages/twenty-front-component-renderer/package.json +++ b/packages/twenty-front-component-renderer/package.json @@ -36,8 +36,8 @@ "@storybook/addon-vitest": "^10.2.13", "@storybook/react-vite": "^10.2.13", "@types/node": "^24.0.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", + "@types/react": "^18.2.39", + "@types/react-dom": "^18.2.15", "@typescript/native-preview": "^7.0.0-dev.20260116.1", "@vitest/browser-playwright": "^4.0.18", "playwright": "^1.56.1", diff --git a/packages/twenty-front-component-renderer/src/host/contexts/FrontComponentInputFocusContext.ts b/packages/twenty-front-component-renderer/src/host/contexts/FrontComponentInputFocusContext.ts new file mode 100644 index 0000000000..79873dbb72 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/host/contexts/FrontComponentInputFocusContext.ts @@ -0,0 +1,6 @@ +import { createContext } from 'react'; + +export type SetEditableFocused = (focused: boolean) => void; + +export const FrontComponentInputFocusContext = + createContext(null); diff --git a/packages/twenty-front-component-renderer/src/host/utils/createHtmlHostWrapper.ts b/packages/twenty-front-component-renderer/src/host/utils/createHtmlHostWrapper.ts index 73c966ff10..cfaea56849 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/createHtmlHostWrapper.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/createHtmlHostWrapper.ts @@ -7,7 +7,7 @@ import { isString, isUndefined, } from '@sniptt/guards'; -import React from 'react'; +import React, { useContext } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { EVENT_TO_REACT } from '@/constants/EventToReact'; @@ -15,6 +15,10 @@ import { type SerializedEventData, type SerializedFileData, } from '@/constants/SerializedEventData'; +import { + FrontComponentInputFocusContext, + type SetEditableFocused, +} from '@/host/contexts/FrontComponentInputFocusContext'; const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']); @@ -352,18 +356,41 @@ const createCaretPreservingElement = ( htmlTag: 'input' | 'textarea', reactProps: Record, forcedProps: Record | undefined, + setEditableFocused: SetEditableFocused | null, ) => { - const { value, defaultValue, ...rest } = reactProps; + const { + value, + defaultValue, + onFocus: forwardedOnFocus, + onBlur: forwardedOnBlur, + ...rest + } = reactProps; const initialValue = isNonEmptyString(defaultValue) ? defaultValue : isNonEmptyString(value) ? value : undefined; + const handleFocus = (event: React.FocusEvent) => { + setEditableFocused?.(true); + if (isFunction(forwardedOnFocus)) { + forwardedOnFocus(event); + } + }; + + const handleBlur = (event: React.FocusEvent) => { + setEditableFocused?.(false); + if (isFunction(forwardedOnBlur)) { + forwardedOnBlur(event); + } + }; + return React.createElement(htmlTag, { ...rest, ...forcedProps, defaultValue: initialValue, + onFocus: handleFocus, + onBlur: handleBlur, ref: (node: CaretPreservingElement | null) => { if (!isDefined(node)) { return; @@ -380,13 +407,19 @@ export const createHtmlHostWrapper = (htmlTag: string) => { const isVoid = VOID_ELEMENTS.has(htmlTag); return ({ children, ...props }: WrapperProps) => { + const setEditableFocused = useContext(FrontComponentInputFocusContext); const reactProps = filterProps(props); if ( htmlTag === 'textarea' || (htmlTag === 'input' && isTextLikeInputType(reactProps.type)) ) { - return createCaretPreservingElement(htmlTag, reactProps, forcedProps); + return createCaretPreservingElement( + htmlTag, + reactProps, + forcedProps, + setEditableFocused, + ); } return React.createElement( diff --git a/packages/twenty-front-component-renderer/src/index.ts b/packages/twenty-front-component-renderer/src/index.ts index a92c9b66ab..44a691548a 100644 --- a/packages/twenty-front-component-renderer/src/index.ts +++ b/packages/twenty-front-component-renderer/src/index.ts @@ -1,4 +1,8 @@ export { FrontComponentRenderer } from './host/components/FrontComponentRenderer'; +export { + FrontComponentInputFocusContext, + type SetEditableFocused, +} from './host/contexts/FrontComponentInputFocusContext'; export { componentRegistry } from './host/generated/host-component-registry'; export { FrontComponentErrorEffect } from './remote/components/FrontComponentErrorEffect'; export { FrontComponentInitializeHostCommunicationApiEffect } from './remote/components/FrontComponentInitializeHostCommunicationApiEffect'; diff --git a/packages/twenty-front/src/modules/front-components/components/FrontComponentInputFocusCleanupEffect.tsx b/packages/twenty-front/src/modules/front-components/components/FrontComponentInputFocusCleanupEffect.tsx new file mode 100644 index 0000000000..5a5cde2dc3 --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/components/FrontComponentInputFocusCleanupEffect.tsx @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; + +import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; + +type FrontComponentInputFocusCleanupEffectProps = { + focusId: string; +}; + +export const FrontComponentInputFocusCleanupEffect = ({ + focusId, +}: FrontComponentInputFocusCleanupEffectProps) => { + const { removeFocusItemFromFocusStackById } = + useRemoveFocusItemFromFocusStackById(); + + useEffect( + () => () => removeFocusItemFromFocusStackById({ focusId }), + [focusId, removeFocusItemFromFocusStackById], + ); + + return null; +}; 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 f6a92bc7de..840639fcce 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,11 @@ +import { useCallback } from 'react'; +import { FrontComponentInputFocusContext } from 'twenty-front-component-renderer'; + +import { FrontComponentInputFocusCleanupEffect } from '@/front-components/components/FrontComponentInputFocusCleanupEffect'; 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'; type FrontComponentRendererProviderProps = { frontComponentId: string; @@ -9,11 +16,40 @@ export const FrontComponentRendererProvider = ({ frontComponentId, children, }: FrontComponentRendererProviderProps) => { + const focusId = `front-component-input-focus-${frontComponentId}`; + + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + const { removeFocusItemFromFocusStackById } = + useRemoveFocusItemFromFocusStackById(); + + const setEditableFocused = useCallback( + (focused: boolean) => { + if (focused) { + pushFocusItemToFocusStack({ + focusId, + component: { + type: FocusComponentType.TEXT_INPUT, + instanceId: focusId, + }, + globalHotkeysConfig: { + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }); + } else { + removeFocusItemFromFocusStackById({ focusId }); + } + }, + [focusId, pushFocusItemToFocusStack, removeFocusItemFromFocusStackById], + ); + 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 0000000000..59316990fb --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/components/__tests__/FrontComponentRendererProvider.test.tsx @@ -0,0 +1,142 @@ +import { act, render, renderHook } from '@testing-library/react'; +import { createStore, Provider as JotaiProvider } from 'jotai'; +import { useContext, type Context, type createContext } from 'react'; + +type SetEditableFocused = (focused: boolean) => void; + +jest.mock('twenty-front-component-renderer', () => { + const ReactForMock = require('react') as { + createContext: typeof createContext; + }; + return { + FrontComponentInputFocusContext: + ReactForMock.createContext(null), + }; +}); + +import { FrontComponentRendererProvider } from '@/front-components/components/FrontComponentRendererProvider'; +import { focusStackState } from '@/ui/utilities/focus/states/focusStackState'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; + +const { FrontComponentInputFocusContext } = jest.requireMock( + 'twenty-front-component-renderer', +) as { + FrontComponentInputFocusContext: Context; +}; + +const FRONT_COMPONENT_ID = 'fc-1'; +const EXPECTED_FOCUS_ID = `front-component-input-focus-${FRONT_COMPONENT_ID}`; + +const TestConsumerEffect = ({ + onCallbackResolved, +}: { + onCallbackResolved: (setEditableFocused: SetEditableFocused | null) => void; +}) => { + const setEditableFocused = useContext(FrontComponentInputFocusContext); + onCallbackResolved(setEditableFocused); + return null; +}; + +const renderProviderWithStore = () => { + const store = createStore(); + let resolvedSetEditableFocused: SetEditableFocused | null | undefined; + + const result = render( + + + { + resolvedSetEditableFocused = callback; + }} + /> + + , + ); + + const getFocusStack = () => + renderHook(() => useAtomStateValue(focusStackState), { + wrapper: ({ children }) => ( + {children} + ), + }).result.current; + + return { + store, + getFocusStack, + getSetEditableFocused: () => resolvedSetEditableFocused, + unmount: result.unmount, + }; +}; + +describe('FrontComponentRendererProvider', () => { + it('should expose a setEditableFocused callback through the context', () => { + const { getSetEditableFocused } = renderProviderWithStore(); + expect(typeof getSetEditableFocused()).toBe('function'); + }); + + it('should push a focus item with suppressed keyboard hotkeys when called with true', () => { + const { getSetEditableFocused, getFocusStack } = renderProviderWithStore(); + + act(() => { + getSetEditableFocused()?.(true); + }); + + expect(getFocusStack()).toEqual([ + { + focusId: EXPECTED_FOCUS_ID, + componentInstance: { + componentType: FocusComponentType.TEXT_INPUT, + componentInstanceId: EXPECTED_FOCUS_ID, + }, + globalHotkeysConfig: { + enableGlobalHotkeysWithModifiers: true, + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }, + ]); + }); + + it('should remove the focus item when called with false', () => { + const { getSetEditableFocused, getFocusStack } = renderProviderWithStore(); + + act(() => { + getSetEditableFocused()?.(true); + }); + expect(getFocusStack()).toHaveLength(1); + + act(() => { + getSetEditableFocused()?.(false); + }); + expect(getFocusStack()).toEqual([]); + }); + + it('should end in pushed state when focus moves between editable fields (true→false→true)', () => { + const { getSetEditableFocused, getFocusStack } = renderProviderWithStore(); + + act(() => { + getSetEditableFocused()?.(true); + getSetEditableFocused()?.(false); + getSetEditableFocused()?.(true); + }); + + expect(getFocusStack()).toHaveLength(1); + expect(getFocusStack()[0].focusId).toBe(EXPECTED_FOCUS_ID); + }); + + it('should remove the focus item on unmount', () => { + const { getSetEditableFocused, getFocusStack, unmount } = + renderProviderWithStore(); + + act(() => { + getSetEditableFocused()?.(true); + }); + expect(getFocusStack()).toHaveLength(1); + + act(() => { + unmount(); + }); + + expect(getFocusStack()).toEqual([]); + }); +}); diff --git a/yarn.lock b/yarn.lock index 0d2b1c3c3e..87941da6a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -56441,8 +56441,8 @@ __metadata: "@storybook/addon-vitest": "npm:^10.2.13" "@storybook/react-vite": "npm:^10.2.13" "@types/node": "npm:^24.0.0" - "@types/react": "npm:^19.0.0" - "@types/react-dom": "npm:^19.0.0" + "@types/react": "npm:^18.2.39" + "@types/react-dom": "npm:^18.2.15" "@typescript/native-preview": "npm:^7.0.0-dev.20260116.1" "@vitest/browser-playwright": "npm:^4.0.18" playwright: "npm:^1.56.1"