Fix focus in front components inputs (#20961)

Fixes https://github.com/twentyhq/twenty/issues/20714

Fixes keyboard hotkey conflicts when typing inside `<input>` /
`<textarea>` elements rendered by Front Components. Editable fields
rendered through the component renderer now properly push/pop a focus
item onto Twenty's focus stack, disabling global keyboard hotkeys while
the user is typing.

## Before


https://github.com/user-attachments/assets/2003c2cb-2698-480f-aedf-bb2f30396572


## After


https://github.com/user-attachments/assets/2c7c6cb0-ecd7-4557-a77b-4d1f264345f0
This commit is contained in:
Raphaël Bosi
2026-05-27 18:30:49 +02:00
committed by GitHub
parent fbae66de8a
commit c8b9dace72
8 changed files with 250 additions and 8 deletions
@@ -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",
@@ -0,0 +1,6 @@
import { createContext } from 'react';
export type SetEditableFocused = (focused: boolean) => void;
export const FrontComponentInputFocusContext =
createContext<SetEditableFocused | null>(null);
@@ -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<string, unknown>,
forcedProps: Record<string, unknown> | 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<CaretPreservingElement>) => {
setEditableFocused?.(true);
if (isFunction(forwardedOnFocus)) {
forwardedOnFocus(event);
}
};
const handleBlur = (event: React.FocusEvent<CaretPreservingElement>) => {
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(
@@ -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';
@@ -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;
};
@@ -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 (
<FrontComponentInstanceContext.Provider
value={{ instanceId: frontComponentId }}
>
{children}
<FrontComponentInputFocusContext.Provider value={setEditableFocused}>
<FrontComponentInputFocusCleanupEffect focusId={focusId} />
{children}
</FrontComponentInputFocusContext.Provider>
</FrontComponentInstanceContext.Provider>
);
};
@@ -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<SetEditableFocused | null>(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<SetEditableFocused | null>;
};
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(
<JotaiProvider store={store}>
<FrontComponentRendererProvider frontComponentId={FRONT_COMPONENT_ID}>
<TestConsumerEffect
onCallbackResolved={(callback) => {
resolvedSetEditableFocused = callback;
}}
/>
</FrontComponentRendererProvider>
</JotaiProvider>,
);
const getFocusStack = () =>
renderHook(() => useAtomStateValue(focusStackState), {
wrapper: ({ children }) => (
<JotaiProvider store={store}>{children}</JotaiProvider>
),
}).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([]);
});
});
+2 -2
View File
@@ -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"