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
@@ -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([]);
});
});