[Security Solution][Endpoint] Respond console command input area support for text selection/replacement (#140447)

Respond Console support for command input area text selection and replacement via keyboard or copy paste. Users can now select text in the input area of the console and overwrite the selection with a new value (keyboard key) or delete/backspace on it.
This commit is contained in:
Paul Tavares 2022-09-19 14:33:09 -04:00 committed by GitHub
parent 3099159d02
commit d8d1b1097b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 554 additions and 396 deletions

View file

@ -10,7 +10,7 @@ import type { ConsoleTestSetup } from '../../mocks';
import { getConsoleTestSetup } from '../../mocks';
import type { ConsoleProps } from '../../types';
import { INPUT_DEFAULT_PLACEHOLDER_TEXT } from '../console_state/state_update_handlers/handle_input_area_state';
import { act, waitFor } from '@testing-library/react';
import { act, waitFor, createEvent, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('When entering data into the Console input', () => {
@ -34,8 +34,8 @@ describe('When entering data into the Console input', () => {
return renderResult.getByTestId('test-inputPlaceholder').textContent;
};
const getUserInputText = () => {
return renderResult.getByTestId('test-cmdInput-userTextInput').textContent;
const getLeftOfCursorText = () => {
return renderResult.getByTestId('test-cmdInput-leftOfCursor').textContent;
};
const getFooterText = () => {
@ -57,24 +57,24 @@ describe('When entering data into the Console input', () => {
render();
enterCommand('c', { inputOnly: true });
expect(getUserInputText()).toEqual('c');
expect(getLeftOfCursorText()).toEqual('c');
enterCommand('m', { inputOnly: true });
expect(getUserInputText()).toEqual('cm');
expect(getLeftOfCursorText()).toEqual('cm');
});
it('should repeat letters if the user holds letter key down on the keyboard', () => {
render();
enterCommand('{a>5/}', { inputOnly: true, useKeyboard: true });
expect(getUserInputText()).toEqual('aaaaa');
expect(getLeftOfCursorText()).toEqual('aaaaa');
});
it('should not display command key names in the input, when command keys are used', () => {
render();
enterCommand('{Meta>}', { inputOnly: true, useKeyboard: true });
expect(getUserInputText()).toEqual('');
expect(getLeftOfCursorText()).toEqual('');
enterCommand('{Shift>}A{/Shift}', { inputOnly: true, useKeyboard: true });
expect(getUserInputText()).toEqual('A');
expect(getLeftOfCursorText()).toEqual('A');
});
it('should display placeholder text when input area is blank', () => {
@ -87,13 +87,13 @@ describe('When entering data into the Console input', () => {
render();
enterCommand('cm', { inputOnly: true });
expect(getInputPlaceholderText()).toEqual('');
expect(renderResult.queryByTestId('test-inputPlaceholder')).toBeNull();
});
it('should NOT display any hint test in footer if nothing is displayed', () => {
it('should NOT display any hint text in footer if nothing is displayed', () => {
render();
expect(getFooterText()?.trim()).toEqual('');
expect(getFooterText()?.trim()).toBe('');
});
it('should display hint when a known command is typed', () => {
@ -180,7 +180,7 @@ describe('When entering data into the Console input', () => {
it('should clear the input area and show placeholder with first item that is focused', async () => {
await renderWithInputHistory('one');
expect(getUserInputText()).toEqual('');
expect(getLeftOfCursorText()).toEqual('');
await waitFor(() => {
expect(getInputPlaceholderText()).toEqual('cmd1 --help');
@ -197,7 +197,7 @@ describe('When entering data into the Console input', () => {
userEvent.keyboard('{Escape}');
await waitFor(() => {
expect(getUserInputText()).toEqual('one');
expect(getLeftOfCursorText()).toEqual('one');
});
});
@ -211,7 +211,7 @@ describe('When entering data into the Console input', () => {
userEvent.keyboard('{Enter}');
await waitFor(() => {
expect(getUserInputText()).toEqual('cmd1 --help');
expect(getLeftOfCursorText()).toEqual('cmd1 --help');
});
});
});
@ -221,6 +221,17 @@ describe('When entering data into the Console input', () => {
return renderResult.getByTestId('test-cmdInput-rightOfCursor').textContent;
};
const selectLeftOfCursorText = () => {
// Select text to the left of the cursor
const selection = window.getSelection();
const range = document.createRange();
// Create a new range with the content that is to the left of the cursor
range.selectNodeContents(renderResult.getByTestId('test-cmdInput-leftOfCursor'));
selection!.removeAllRanges();
selection!.addRange(range);
};
beforeEach(() => {
render();
enterCommand('isolate', { inputOnly: true });
@ -228,53 +239,53 @@ describe('When entering data into the Console input', () => {
it('should backspace and delete last character', () => {
typeKeyboardKey('{backspace}');
expect(getUserInputText()).toEqual('isolat');
expect(getLeftOfCursorText()).toEqual('isolat');
expect(getRightOfCursorText()).toEqual('');
});
it('should clear the input if the user holds down the delete/backspace key', () => {
typeKeyboardKey('{backspace>7/}');
expect(getUserInputText()).toEqual('');
expect(getLeftOfCursorText()).toEqual('');
});
it('should move cursor to the left', () => {
typeKeyboardKey('{ArrowLeft}');
typeKeyboardKey('{ArrowLeft}');
expect(getUserInputText()).toEqual('isola');
expect(getLeftOfCursorText()).toEqual('isola');
expect(getRightOfCursorText()).toEqual('te');
});
it('should move cursor to the right', () => {
typeKeyboardKey('{ArrowLeft}');
typeKeyboardKey('{ArrowLeft}');
expect(getUserInputText()).toEqual('isola');
expect(getLeftOfCursorText()).toEqual('isola');
expect(getRightOfCursorText()).toEqual('te');
typeKeyboardKey('{ArrowRight}');
expect(getUserInputText()).toEqual('isolat');
expect(getLeftOfCursorText()).toEqual('isolat');
expect(getRightOfCursorText()).toEqual('e');
});
it('should move cursor to the beginning', () => {
typeKeyboardKey('{Home}');
expect(getUserInputText()).toEqual('');
expect(getLeftOfCursorText()).toEqual('');
expect(getRightOfCursorText()).toEqual('isolate');
});
it('should should move cursor to the end', () => {
typeKeyboardKey('{Home}');
expect(getUserInputText()).toEqual('');
expect(getLeftOfCursorText()).toEqual('');
expect(getRightOfCursorText()).toEqual('isolate');
typeKeyboardKey('{End}');
expect(getUserInputText()).toEqual('isolate');
expect(getLeftOfCursorText()).toEqual('isolate');
expect(getRightOfCursorText()).toEqual('');
});
it('should delete text', () => {
typeKeyboardKey('{Home}');
typeKeyboardKey('{Delete}');
expect(getUserInputText()).toEqual('');
expect(getLeftOfCursorText()).toEqual('');
expect(getRightOfCursorText()).toEqual('solate');
});
@ -282,7 +293,7 @@ describe('When entering data into the Console input', () => {
typeKeyboardKey('{ArrowLeft}');
typeKeyboardKey('{ArrowLeft}');
expect(getUserInputText()).toEqual('isola');
expect(getLeftOfCursorText()).toEqual('isola');
expect(getRightOfCursorText()).toEqual('te');
typeKeyboardKey('{enter}');
@ -296,12 +307,51 @@ describe('When entering data into the Console input', () => {
typeKeyboardKey('{Home}');
typeKeyboardKey('{ArrowRight}');
expect(getUserInputText()).toEqual('c');
expect(getLeftOfCursorText()).toEqual('c');
expect(getRightOfCursorText()).toEqual('md1 ');
expect(getFooterText()).toEqual('Hit enter to execute');
});
it('should replace selected text with key pressed', () => {
typeKeyboardKey('{ArrowLeft>3/}'); // Press left arrow for 3 times
selectLeftOfCursorText();
typeKeyboardKey('a');
expect(getLeftOfCursorText()).toEqual('a');
expect(getRightOfCursorText()).toEqual('ate');
});
it('should replace selected text with content pasted', () => {
typeKeyboardKey('{ArrowLeft>3/}'); // Press left arrow for 3 times
selectLeftOfCursorText();
const inputCaptureEle = renderResult.getByTestId('test-keyCapture-input');
// Mocking the `DataTransfer` class since its not available in Jest test setup
const clipboardData = {
getData: () => 'I pasted this',
} as unknown as DataTransfer;
const pasteEvent = createEvent.paste(inputCaptureEle, {
clipboardData,
});
fireEvent(inputCaptureEle, pasteEvent);
expect(getLeftOfCursorText()).toEqual('I pasted this');
expect(getRightOfCursorText()).toEqual('ate');
});
it('should delete selected text when delete key is pressed', () => {
typeKeyboardKey('{ArrowLeft>3/}'); // Press left arrow for 3 times
selectLeftOfCursorText();
typeKeyboardKey('{Delete}');
expect(getLeftOfCursorText()).toEqual('');
expect(getRightOfCursorText()).toEqual('ate');
});
// FIXME:PT uncomment once task OLM task #4384 is implemented
it.skip('should return original cursor position if input history is closed with no selection', async () => {
typeKeyboardKey('{Enter}'); // add `isolate` to the input history
@ -310,12 +360,12 @@ describe('When entering data into the Console input', () => {
typeKeyboardKey('{Home}');
typeKeyboardKey('{ArrowRight}');
expect(getUserInputText()).toEqual('r');
expect(getLeftOfCursorText()).toEqual('r');
expect(getRightOfCursorText()).toEqual('elease');
await showInputHistoryPopover();
expect(getUserInputText()).toEqual('');
expect(getLeftOfCursorText()).toEqual('');
expect(getRightOfCursorText()).toEqual('');
await waitFor(() => {
@ -324,7 +374,7 @@ describe('When entering data into the Console input', () => {
userEvent.keyboard('{Escape}');
expect(getUserInputText()).toEqual('r');
expect(getLeftOfCursorText()).toEqual('r');
expect(getRightOfCursorText()).toEqual('elease');
});
@ -336,12 +386,12 @@ describe('When entering data into the Console input', () => {
typeKeyboardKey('{Home}');
typeKeyboardKey('{ArrowRight}');
expect(getUserInputText()).toEqual('r');
expect(getLeftOfCursorText()).toEqual('r');
expect(getRightOfCursorText()).toEqual('elease');
await showInputHistoryPopover();
expect(getUserInputText()).toEqual('');
expect(getLeftOfCursorText()).toEqual('');
expect(getRightOfCursorText()).toEqual('');
await waitFor(() => {
@ -350,7 +400,7 @@ describe('When entering data into the Console input', () => {
userEvent.keyboard('{Enter}');
expect(getUserInputText()).toEqual('isolate');
expect(getLeftOfCursorText()).toEqual('isolate');
expect(getRightOfCursorText()).toEqual('');
});
});

View file

@ -11,14 +11,14 @@ import type { CommonProps } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, useResizeObserver, EuiButtonIcon } from '@elastic/eui';
import styled from 'styled-components';
import classNames from 'classnames';
import { EnteredInput } from './lib/entered_input';
import type { InputCaptureProps } from './components/input_capture';
import { InputCapture } from './components/input_capture';
import { useWithInputVisibleState } from '../../hooks/state_selectors/use_with_input_visible_state';
import type { ConsoleDataState } from '../console_state/types';
import { useInputHints } from './hooks/use_input_hints';
import { InputPlaceholder } from './components/input_placeholder';
import { useWithInputTextEntered } from '../../hooks/state_selectors/use_with_input_text_entered';
import { InputAreaPopover } from './components/input_area_popover';
import type { KeyCaptureProps } from './key_capture';
import { KeyCapture } from './key_capture';
import { useConsoleStateDispatch } from '../../hooks/state_selectors/use_console_state_dispatch';
import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator';
import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj';
@ -29,11 +29,7 @@ const CommandInputContainer = styled.div`
padding: ${({ theme: { eui } }) => eui.euiSizeS};
outline: ${({ theme: { eui } }) => eui.euiBorderThin};
.prompt {
padding-right: 1ch;
}
&.active {
&:focus-within {
border-bottom: ${({ theme: { eui } }) => eui.euiBorderThick};
border-bottom-color: ${({ theme: { eui } }) => eui.euiColorPrimary};
}
@ -46,29 +42,33 @@ const CommandInputContainer = styled.div`
white-space: break-spaces;
}
.prompt {
padding-right: 1ch;
}
.cursor {
display: inline-block;
width: 1px;
height: ${({ theme: { eui } }) => eui.euiLineHeight}em;
background-color: ${({ theme: { eui } }) => eui.euiTextColor};
background-color: ${({ theme }) => theme.eui.euiTextSubduedColor};
}
animation: cursor-blink-animation 1s steps(5, start) infinite;
-webkit-animation: cursor-blink-animation 1s steps(5, start) infinite;
@keyframes cursor-blink-animation {
to {
visibility: hidden;
}
}
@-webkit-keyframes cursor-blink-animation {
to {
visibility: hidden;
}
}
&.hasFocus {
.cursor {
background-color: ${({ theme: { eui } }) => eui.euiTextColor};
animation: cursor-blink-animation 1s steps(5, start) infinite;
-webkit-animation: cursor-blink-animation 1s steps(5, start) infinite;
&.inactive {
background-color: ${({ theme }) => theme.eui.euiTextSubduedColor} !important;
animation: none;
-webkit-animation: none;
@keyframes cursor-blink-animation {
to {
visibility: hidden;
}
}
@-webkit-keyframes cursor-blink-animation {
to {
visibility: hidden;
}
}
}
}
`;
@ -76,7 +76,7 @@ const CommandInputContainer = styled.div`
export interface CommandInputProps extends CommonProps {
prompt?: string;
isWaiting?: boolean;
focusRef?: KeyCaptureProps['focusRef'];
focusRef?: InputCaptureProps['focusRef'];
}
export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ...commonProps }) => {
@ -89,12 +89,8 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
const [commandToExecute, setCommandToExecute] = useState('');
const containerRef = useRef<HTMLDivElement | null>(null);
const _focusRef: KeyCaptureProps['focusRef'] = useRef(null);
// TODO:PT what do I use this for? investigate
const textDisplayRef = useRef<HTMLDivElement | null>(null);
const dimensions = useResizeObserver(containerRef.current);
const _focusRef: InputCaptureProps['focusRef'] = useRef(null);
const keyCaptureFocusRef = focusRef || _focusRef;
@ -102,17 +98,10 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
return dimensions.width ? `${dimensions.width}px` : '92vw';
}, [dimensions.width]);
const cursorClassName = useMemo(() => {
return classNames({
cursor: true,
inactive: !isKeyInputBeingCaptured,
});
}, [isKeyInputBeingCaptured]);
const inputContainerClassname = useMemo(() => {
return classNames({
cmdInput: true,
active: isKeyInputBeingCaptured,
hasFocus: isKeyInputBeingCaptured,
error: visibleState === 'error',
});
}, [isKeyInputBeingCaptured, visibleState]);
@ -133,9 +122,9 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
[dispatch, textEntered, rightOfCursor.text]
);
const handleKeyCaptureOnStateChange = useCallback<NonNullable<KeyCaptureProps['onStateChange']>>(
(isCapturing) => {
setIsKeyInputBeingCaptured(isCapturing);
const handleOnChangeFocus = useCallback<NonNullable<InputCaptureProps['onChangeFocus']>>(
(hasFocus) => {
setIsKeyInputBeingCaptured(hasFocus);
},
[]
);
@ -149,8 +138,8 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
[keyCaptureFocusRef]
);
const handleKeyCapture = useCallback<KeyCaptureProps['onCapture']>(
({ value, eventDetails }) => {
const handleInputCapture = useCallback<InputCaptureProps['onCapture']>(
({ value, selection, eventDetails }) => {
const keyCode = eventDetails.keyCode;
// UP arrow key
@ -165,107 +154,59 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
// Update the store with the updated text that was entered
dispatch({
type: 'updateInputTextEnteredState',
payload: ({ rightOfCursor: prevRightOfCursor, textEntered: prevTextEntered }) => {
let updatedTextEnteredState = prevTextEntered + value;
let updatedRightOfCursor: ConsoleDataState['input']['rightOfCursor'] | undefined =
prevRightOfCursor;
payload: ({ textEntered: prevLeftOfCursor, rightOfCursor: prevRightOfCursor }) => {
let inputText = new EnteredInput(prevLeftOfCursor, prevRightOfCursor.text);
const lengthOfTextEntered = updatedTextEnteredState.length;
inputText.addValue(value ?? '', selection);
switch (keyCode) {
// BACKSPACE
// remove the last character from the text entered
case 8:
if (lengthOfTextEntered) {
updatedTextEnteredState = updatedTextEnteredState.substring(
0,
lengthOfTextEntered - 1
);
}
break;
// ENTER
// Execute command and blank out the input area
case 13:
setCommandToExecute(updatedTextEnteredState + rightOfCursor.text);
updatedTextEnteredState = '';
updatedRightOfCursor = undefined;
break;
// ARROW LEFT
// Move cursor left (or more accurately - move text to the right of the cursor)
case 37:
updatedRightOfCursor = {
...prevRightOfCursor,
text:
updatedTextEnteredState.charAt(lengthOfTextEntered - 1) + prevRightOfCursor.text,
};
updatedTextEnteredState = updatedTextEnteredState.substring(
0,
lengthOfTextEntered - 1
);
break;
// ARROW RIGHT
// Move cursor right (or more accurately - move text to the left of the cursor)
case 39:
updatedRightOfCursor = {
...prevRightOfCursor,
text: prevRightOfCursor.text.substring(1),
};
updatedTextEnteredState = updatedTextEnteredState + prevRightOfCursor.text.charAt(0);
break;
// HOME
// Move cursor to the start of the input area
// (or more accurately - move all text to the right of the cursor)
case 36:
updatedRightOfCursor = {
...prevRightOfCursor,
text: updatedTextEnteredState + prevRightOfCursor.text,
};
updatedTextEnteredState = '';
break;
// END
// Move cursor to the end of the input area
// (or more accurately - move all text to the left of the cursor)
case 35:
updatedRightOfCursor = {
...prevRightOfCursor,
text: '',
};
updatedTextEnteredState = updatedTextEnteredState + prevRightOfCursor.text;
inputText.backspaceChar(selection);
break;
// DELETE
// Remove the first character from the Right side of cursor
case 46:
if (prevRightOfCursor.text) {
updatedRightOfCursor = {
...prevRightOfCursor,
text: prevRightOfCursor.text.substring(1),
};
}
inputText.deleteChar(selection);
break;
// ENTER = Execute command and blank out the input area
case 13:
setCommandToExecute(inputText.getFullText());
inputText = new EnteredInput('', '');
break;
// ARROW LEFT
case 37:
inputText.moveCursorTo('left');
break;
// ARROW RIGHT
case 39:
inputText.moveCursorTo('right');
break;
// HOME
case 36:
inputText.moveCursorTo('home');
break;
// END
case 35:
inputText.moveCursorTo('end');
break;
}
return {
textEntered: updatedTextEnteredState,
rightOfCursor: updatedRightOfCursor,
textEntered: inputText.getLeftOfCursorText(),
rightOfCursor: { text: inputText.getRightOfCursorText() },
};
},
});
},
[dispatch, rightOfCursor.text]
[dispatch]
);
const handleOnFocus = useCallback(() => {
if (!isKeyInputBeingCaptured) {
dispatch({ type: 'addFocusToKeyCapture' });
}
}, [dispatch, isKeyInputBeingCaptured]);
// Execute the command if one was ENTER'd.
useEffect(() => {
if (commandToExecute) {
@ -281,8 +222,6 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
className={inputContainerClassname}
onClick={handleTypingAreaClick}
ref={containerRef}
tabIndex={0}
onFocus={handleOnFocus}
data-test-subj={getTestId('cmdInput-container')}
>
<EuiFlexGroup
@ -291,7 +230,6 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
alignItems="center"
gutterSize="none"
justifyContent="flexStart"
ref={textDisplayRef}
>
{prompt && (
<EuiFlexItem grow={false} data-test-subj={getTestId('cmdInput-prompt')}>
@ -299,22 +237,30 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
</EuiFlexItem>
)}
<EuiFlexItem className="textEntered">
<EuiFlexGroup
responsive={false}
alignItems="center"
gutterSize="none"
justifyContent="flexStart"
<InputCapture
onCapture={handleInputCapture}
onChangeFocus={handleOnChangeFocus}
focusRef={focusRef}
>
<EuiFlexItem grow={false}>
<div data-test-subj={getTestId('cmdInput-userTextInput')}>{textEntered}</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span className={cursorClassName} />
</EuiFlexItem>
<EuiFlexItem>
<div data-test-subj={getTestId('cmdInput-rightOfCursor')}>{rightOfCursor.text}</div>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup
responsive={false}
alignItems="center"
gutterSize="none"
justifyContent="flexStart"
>
<EuiFlexItem grow={false}>
<div data-test-subj={getTestId('cmdInput-leftOfCursor')}>{textEntered}</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span className="cursor essentialAnimation" />
</EuiFlexItem>
<EuiFlexItem>
<div data-test-subj={getTestId('cmdInput-rightOfCursor')}>
{rightOfCursor.text}
</div>
</EuiFlexItem>
</EuiFlexGroup>
</InputCapture>
<InputPlaceholder />
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -329,12 +275,6 @@ export const CommandInput = memo<CommandInputProps>(({ prompt = '', focusRef, ..
/>
</EuiFlexItem>
</EuiFlexGroup>
<KeyCapture
onCapture={handleKeyCapture}
focusRef={keyCaptureFocusRef}
onStateChange={handleKeyCaptureOnStateChange}
/>
</CommandInputContainer>
</InputAreaPopover>
);

View file

@ -0,0 +1,280 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
KeyboardEventHandler,
KeyboardEvent,
MutableRefObject,
PropsWithChildren,
ClipboardEventHandler,
} from 'react';
import React, { memo, useCallback, useMemo, useRef } from 'react';
import { pick } from 'lodash';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { useTestIdGenerator } from '../../../../../hooks/use_test_id_generator';
import { useDataTestSubj } from '../../../hooks/state_selectors/use_data_test_subj';
const ARIA_PLACEHOLDER_MESSAGE = i18n.translate(
'xpack.securitySolution.inputCapture.ariaPlaceHolder',
{ defaultMessage: 'Enter a command' }
);
const deSelectTextOnPage = () => {
const selection = getSelection();
if (selection) {
selection.removeAllRanges();
}
};
const InputCaptureContainer = styled.div`
.focus-container {
// Tried to find a way to not use '!important', but cant seem to figure
// out right combination of pseudo selectors
outline: none !important;
}
.textSelectionBoundaryHelper {
opacity: 0;
position: absolute;
top: -100vh;
left: -100vw;
}
.invisible-input {
&,
&:focus {
border: none;
outline: none;
background-image: none;
background-color: transparent;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
animation: none !important;
width: 1ch !important;
position: absolute;
opacity: 0;
top: -100vh;
left: -100vw;
}
`;
/**
* Interface exposed by the `InputCapture` component that allows for interaction
* with the component's focus/blur states.
*/
interface InputFocusInterface {
focus: (force?: boolean) => void;
blur: () => void;
}
export type InputCaptureProps = PropsWithChildren<{
onCapture: (params: {
/** The keyboard key value that was entered by the user */
value: string | undefined;
/** Any text that is selected/highlighted when user clicked the keyboard key */
selection: string;
/** Keyboard control keys from the keyboard event */
eventDetails: Pick<
KeyboardEvent,
'key' | 'altKey' | 'ctrlKey' | 'keyCode' | 'metaKey' | 'repeat' | 'shiftKey'
>;
}) => void;
/** Sets an interface that allows interactions with this component's focus/blur states */
focusRef?: MutableRefObject<InputFocusInterface | null>;
/** Callback triggered whenever Focus/Blur events are triggered */
onChangeFocus?: (hasFocus: boolean) => void;
}>;
/**
* Component that will capture keyboard and other user input (ex. paste) that
* occur within this component
*/
export const InputCapture = memo<InputCaptureProps>(
({ onCapture, focusRef, onChangeFocus, children }) => {
const getTestId = useTestIdGenerator(useDataTestSubj());
// Reference to the `<div>` that take in focus (`tabIndex`)
const focusEleRef = useRef<HTMLDivElement | null>(null);
const hiddenInputEleRef = useRef<HTMLInputElement | null>(null);
const getTextSelection = useCallback((): string => {
if (focusEleRef.current) {
const selection = document.getSelection();
// Get the selected text and remove any new line breaks from it.
// The input area does not allow for new line breaks and due to the markup, if user makes
// a selection that also captures the cursor, then a new line break is included in the selection
const selectionText = (selection?.toString() ?? '').replace(/[\r\n]/g, '');
const isSelectionWithinInputCapture =
focusEleRef.current && selection
? focusEleRef.current?.contains(selection.focusNode) &&
focusEleRef.current?.contains(selection.anchorNode)
: false;
if (!selection || selectionText.length === 0 || !isSelectionWithinInputCapture) {
return '';
}
return selectionText;
}
return '';
}, []);
const handleOnKeyDown = useCallback<KeyboardEventHandler>(
(ev) => {
// allows for clipboard events to be captured via onPaste event handler
if (ev.metaKey || ev.ctrlKey) {
return;
}
// checking to ensure that the key is not a control character. Control character's `.key`
// are at least two characters long and because we are handling `onKeyDown` we know that
// a printable `.key` will always be just one character long.
const newValue = /^[\w\d]{2}/.test(ev.key) ? '' : ev.key;
const currentTextSelection = getTextSelection();
const eventDetails = pick(ev, [
'key',
'altKey',
'ctrlKey',
'keyCode',
'metaKey',
'repeat',
'shiftKey',
]);
onCapture({
value: newValue,
selection: currentTextSelection,
eventDetails,
});
if (currentTextSelection) {
deSelectTextOnPage();
}
},
[getTextSelection, onCapture]
);
const handleOnPaste = useCallback<ClipboardEventHandler>(
(ev) => {
ev.preventDefault();
ev.stopPropagation();
// Get the data the user pasted as text and remove all new line breaks from it
const value = ev.clipboardData.getData('text').replace(/[\r\n]/g, '');
const currentTextSelection = getTextSelection();
// hard-coded for use in onCapture and future keyboard functions
const eventDetails = {
altKey: false,
ctrlKey: false,
key: 'Meta',
keyCode: 91,
metaKey: true,
repeat: false,
shiftKey: false,
};
onCapture({
value,
selection: currentTextSelection,
eventDetails,
});
if (currentTextSelection) {
deSelectTextOnPage();
}
},
[getTextSelection, onCapture]
);
const handleOnFocus = useCallback(() => {
if (onChangeFocus) {
onChangeFocus(true);
}
}, [onChangeFocus]);
const handleOnBlur = useCallback(() => {
if (onChangeFocus) {
onChangeFocus(false);
}
}, [onChangeFocus]);
const focusInterface = useMemo<InputFocusInterface>(() => {
return {
focus: (force: boolean = false) => {
// If user selected text and `force` is not true, then don't focus (else they lose selection)
if (
(!force && (window.getSelection()?.toString() ?? '').length > 0) ||
document.activeElement === hiddenInputEleRef.current
) {
return;
}
hiddenInputEleRef.current?.focus();
},
blur: () => {
// only blur if the input has focus
if (hiddenInputEleRef.current && document.activeElement === hiddenInputEleRef.current) {
hiddenInputEleRef.current?.blur();
}
},
};
}, []);
if (focusRef) {
focusRef.current = focusInterface;
}
return (
<InputCaptureContainer
data-test-subj={getTestId('inputCapture')}
onKeyDown={handleOnKeyDown}
onPaste={handleOnPaste}
>
<div
role="textbox"
aria-placeholder={ARIA_PLACEHOLDER_MESSAGE}
tabIndex={0}
ref={focusEleRef}
className="focus-container"
data-test-subj={getTestId('keyCapture-input')}
onBlur={handleOnBlur}
onFocus={handleOnFocus}
>
{/*
This div.textSelectionBoundaryHelper and the one below help to ensure that when the user
selects the start or end of the input text, that the node that are returned in the
`Selection` object for 'focusNode` and `anchorNode` are within the input capture area.
*/}
<div className="textSelectionBoundaryHelper"> </div>
{children}
<div className="textSelectionBoundaryHelper"> </div>
<input
ref={hiddenInputEleRef}
type="text"
value=""
tabIndex={-1}
onPaste={handleOnPaste}
onChange={() => {}}
spellCheck="false"
className="invisible-input"
/>
</div>
</InputCaptureContainer>
);
}
);
InputCapture.displayName = 'InputCapture';

View file

@ -26,13 +26,17 @@ export const InputPlaceholder = memo(() => {
const placeholder = useWithInputPlaceholder();
const getTestId = useTestIdGenerator(useDataTestSubj());
if (fullTextEntered.length > 0) {
return null;
}
return (
<InputPlaceholderContainer
size="s"
className="eui-textTruncate"
data-test-subj={getTestId('inputPlaceholder')}
>
<div className="eui-textTruncate">{fullTextEntered ? '' : placeholder}</div>
<div className="eui-textTruncate">{placeholder}</div>
</InputPlaceholderContainer>
);
});

View file

@ -1,214 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
ClipboardEventHandler,
FormEventHandler,
KeyboardEventHandler,
MutableRefObject,
} from 'react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { pick } from 'lodash';
import styled from 'styled-components';
import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator';
import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj';
const NOOP = () => undefined;
const KeyCaptureContainer = styled.span`
display: inline-block;
position: absolute;
width: 1px;
height: 1em;
left: -110vw;
top: -110vh;
overflow: hidden;
.invisible-input {
&,
&:focus {
border: none;
background-image: none;
background-color: transparent;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
animation: none !important;
width: 1ch !important;
position: absolute;
}
}
`;
interface KeyCaptureFocusInterface {
focus: (force?: boolean) => void;
blur: () => void;
}
export interface KeyCaptureProps {
onCapture: (params: {
value: string | undefined;
eventDetails: Pick<
KeyboardEvent,
'key' | 'altKey' | 'ctrlKey' | 'keyCode' | 'metaKey' | 'repeat' | 'shiftKey'
>;
}) => void;
onStateChange?: (isCapturing: boolean) => void;
focusRef?: MutableRefObject<KeyCaptureFocusInterface | null>;
}
/**
* Key Capture is an invisible INPUT field that we set focus to when the user clicks inside of
* the console. It's sole purpose is to capture what the user types, which is then pass along to be
* displayed in a more UX friendly way
*/
export const KeyCapture = memo<KeyCaptureProps>(({ onCapture, focusRef, onStateChange }) => {
// We don't need the actual value that was last input in this component, because
// `setLastInput()` is used with a function that returns the typed character.
// This state is used like this:
// 1. User presses a keyboard key down
// 2. We store the key that was pressed
// 3. When the 'keyup' event is triggered, we call `onCapture()`
// with all of the character that were entered
// 4. We set the last input back to an empty string
const getTestId = useTestIdGenerator(useDataTestSubj());
const inputRef = useRef<HTMLInputElement | null>(null);
const blurInputRef = useRef<HTMLInputElement | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const handleInputOnBlur = useCallback(() => {
setIsCapturing(false);
if (onStateChange) {
onStateChange(false);
}
}, [onStateChange]);
const handleInputOnFocus = useCallback<FormEventHandler>(
(ev) => {
setIsCapturing(true);
if (onStateChange) {
onStateChange(true);
}
},
[onStateChange]
);
const handleInputOnPaste = useCallback<ClipboardEventHandler>(
(ev) => {
const value = ev.clipboardData.getData('text');
ev.stopPropagation();
// hard-coded for use in onCapture and future keyboard functions
const metaKey = {
altKey: false,
ctrlKey: false,
key: 'Meta',
keyCode: 91,
metaKey: true,
repeat: false,
shiftKey: false,
};
onCapture({
value,
eventDetails: metaKey,
});
},
[onCapture]
);
// 1. Determine if the key press is one that we need to store ex) letters, digits, values that we see
// 2. If the user clicks a key we don't need to store as text, but we need to do logic with ex) backspace, delete, l/r arrows, we must call onCapture
const handleOnKeyDown = useCallback<KeyboardEventHandler>(
(ev) => {
// checking to ensure that the key is not a control character
const newValue = /^[\w\d]{2}/.test(ev.key) ? '' : ev.key;
// @ts-expect-error
if (!isCapturing || ev._CONSOLE_IGNORE_KEY) {
// @ts-expect-error
if (ev._CONSOLE_IGNORE_KEY) {
// @ts-expect-error
ev._CONSOLE_IGNORE_KEY = false;
}
return;
}
ev.stopPropagation();
// allows for clipboard events to be captured via onPaste event handler
if (ev.metaKey || ev.ctrlKey) {
return;
}
const eventDetails = pick(ev, [
'key',
'altKey',
'ctrlKey',
'keyCode',
'metaKey',
'repeat',
'shiftKey',
]);
onCapture({
value: newValue,
eventDetails,
});
},
[isCapturing, onCapture]
);
const keyCaptureFocusMethods = useMemo<KeyCaptureFocusInterface>(() => {
return {
focus: (force: boolean = false) => {
// If user selected text and `force` is not true, then don't focus (else they lose selection)
if (!force && (window.getSelection()?.toString() ?? '').length > 0) {
return;
}
inputRef.current?.focus();
},
blur: () => {
// only blur if the input has focus
if (inputRef.current && document.activeElement === inputRef.current) {
blurInputRef.current?.focus();
}
},
};
}, []);
if (focusRef) {
focusRef.current = keyCaptureFocusMethods;
}
return (
<KeyCaptureContainer data-test-subj={getTestId('keyCapture')} aria-hidden="true" tabIndex={-1}>
<input value="" ref={blurInputRef} tabIndex={-1} onChange={NOOP} />
<input
className="invisible-input"
data-test-subj={getTestId('keyCapture-input')}
spellCheck="false"
value=""
tabIndex={-1}
onKeyDown={handleOnKeyDown}
onBlur={handleInputOnBlur}
onFocus={handleInputOnFocus}
onPaste={handleInputOnPaste}
onChange={NOOP} // this just silences Jest output warnings
ref={inputRef}
/>
</KeyCaptureContainer>
);
});
KeyCapture.displayName = 'KeyCapture';

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/**
* Class that manages the command entered and how that is displayed to the left and right of the cursor
*/
export class EnteredInput {
constructor(private leftOfCursorText: string, private rightOfCursorText: string) {}
private replaceSelection(selection: string, newValue: string) {
const prevFullTextEntered = this.leftOfCursorText + this.rightOfCursorText;
this.leftOfCursorText =
prevFullTextEntered.substring(0, prevFullTextEntered.indexOf(selection)) + newValue;
this.rightOfCursorText = prevFullTextEntered.substring(
prevFullTextEntered.indexOf(selection) + selection.length
);
}
getLeftOfCursorText(): string {
return this.leftOfCursorText;
}
getRightOfCursorText(): string {
return this.rightOfCursorText;
}
getFullText(): string {
return this.leftOfCursorText + this.rightOfCursorText;
}
moveCursorTo(direction: 'left' | 'right' | 'end' | 'home') {
switch (direction) {
case 'end':
this.leftOfCursorText = this.leftOfCursorText + this.rightOfCursorText;
this.rightOfCursorText = '';
break;
case 'home':
this.rightOfCursorText = this.leftOfCursorText + this.rightOfCursorText;
this.leftOfCursorText = '';
break;
case 'left':
if (this.leftOfCursorText.length) {
// Add last character on the left, to the right side of the cursor
this.rightOfCursorText =
this.leftOfCursorText.charAt(this.leftOfCursorText.length - 1) + this.rightOfCursorText;
// Remove the last character from the left (it's now on the right side of cursor)
this.leftOfCursorText = this.leftOfCursorText.substring(
0,
this.leftOfCursorText.length - 1
);
}
break;
case 'right':
if (this.rightOfCursorText.length) {
// MOve the first character from the Right side, to the left side of the cursor
this.leftOfCursorText = this.leftOfCursorText + this.rightOfCursorText.charAt(0);
// Remove the first character from the Right side of the cursor (now on the left)
this.rightOfCursorText = this.rightOfCursorText.substring(1);
}
break;
}
}
addValue(value: string, replaceSelection: string = '') {
if (replaceSelection.length && value.length) {
this.replaceSelection(replaceSelection, value);
} else {
this.leftOfCursorText += value;
}
}
deleteChar(replaceSelection: string = '') {
if (replaceSelection) {
this.replaceSelection(replaceSelection, '');
} else if (this.rightOfCursorText) {
this.rightOfCursorText = this.rightOfCursorText.substring(1);
}
}
backspaceChar(replaceSelection: string = '') {
if (replaceSelection) {
this.replaceSelection(replaceSelection, '');
} else if (this.leftOfCursorText) {
this.leftOfCursorText = this.leftOfCursorText.substring(0, this.leftOfCursorText.length - 1);
}
}
}