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:
@@ -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",
|
||||
|
||||
+6
@@ -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';
|
||||
|
||||
+21
@@ -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;
|
||||
};
|
||||
+37
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+142
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user