fix(front-component-renderer): gate global hotkeys on input focus
https://sonarly.com/issue/38618?type=bug Typing inside worker-rendered front-component inputs can trigger host global hotkeys (for example Fix: Implemented a host-side focus bridge for worker-rendered front-component inputs so global hotkeys are suppressed while users type. What changed: 1. `FrontComponentRendererProvider` now captures focus/blur events from descendants and detects editable targets (`input` text-like types, `textarea`, `contentEditable`). 2. On editable focus, it pushes a focus-stack item with: - a stable provider-scoped focus id - `enableGlobalHotkeysConflictingWithKeyboard: false` 3. On editable blur (unless focus is moving to another editable element inside the same provider), it removes that focus-stack item. 4. Added unmount cleanup to ensure no stale focus-stack item remains if renderer unmounts while focused. 5. Added tests covering: - push on editable focus - no remove when moving between editable fields - remove when leaving editable context This directly addresses the root cause: front-component inputs were not participating in the host focus-stack hotkey gate, so global shortcuts remained active during typing. Authored by Sonarly by autonomous analysis (run 43975).
This commit is contained in:
+104
-1
@@ -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<HTMLDivElement>) => {
|
||||
if (!isEditableElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushFrontComponentInputFocusItem();
|
||||
},
|
||||
[pushFrontComponentInputFocusItem],
|
||||
);
|
||||
|
||||
const handleBlurCapture = useCallback(
|
||||
(event: FocusEvent<HTMLDivElement>) => {
|
||||
if (!isEditableElement(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.currentTarget.contains(event.relatedTarget) &&
|
||||
isEditableElement(event.relatedTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeFrontComponentInputFocusItem();
|
||||
},
|
||||
[removeFrontComponentInputFocusItem],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
removeFrontComponentInputFocusItem();
|
||||
};
|
||||
}, [removeFrontComponentInputFocusItem]);
|
||||
|
||||
return (
|
||||
<FrontComponentInstanceContext.Provider
|
||||
value={{ instanceId: frontComponentId }}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
style={{ display: 'contents' }}
|
||||
onFocusCapture={handleFocusCapture}
|
||||
onBlurCapture={handleBlurCapture}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</FrontComponentInstanceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
+87
@@ -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(
|
||||
<FrontComponentRendererProvider frontComponentId="front-component-id">
|
||||
<input />
|
||||
</FrontComponentRendererProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FrontComponentRendererProvider frontComponentId="front-component-id">
|
||||
<>
|
||||
<input data-testid="first-input" />
|
||||
<textarea data-testid="second-input" />
|
||||
</>
|
||||
</FrontComponentRendererProvider>,
|
||||
);
|
||||
|
||||
const firstInput = getByTestId('first-input');
|
||||
const secondInput = getByTestId('second-input');
|
||||
|
||||
fireEvent.focus(firstInput);
|
||||
fireEvent.blur(firstInput, { relatedTarget: secondInput });
|
||||
|
||||
expect(mockRemoveFocusItemFromFocusStackById).not.toHaveBeenCalledWith({
|
||||
focusId: 'front-component-renderer-front-component-id-input',
|
||||
});
|
||||
});
|
||||
|
||||
it('should restore global hotkeys when editable input loses focus', () => {
|
||||
const { getByRole } = render(
|
||||
<FrontComponentRendererProvider frontComponentId="front-component-id">
|
||||
<>
|
||||
<input />
|
||||
<button type="button">Action</button>
|
||||
</>
|
||||
</FrontComponentRendererProvider>,
|
||||
);
|
||||
|
||||
const input = getByRole('textbox');
|
||||
const button = getByRole('button', { name: 'Action' });
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.blur(input, { relatedTarget: button });
|
||||
|
||||
expect(mockRemoveFocusItemFromFocusStackById).toHaveBeenCalledWith({
|
||||
focusId: 'front-component-renderer-front-component-id-input',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user