diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd3032ce3d..f70d2e2ce79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ ## [`master`](https://github.com/elastic/eui/tree/master) -- Converted `EuiCodeEditor` to Typescript ([#2836](https://github.com/elastic/eui/pull/2836)) -- Converted `EuiCode` and `EuiCodeBlock` and to Typescript ([#2835](https://github.com/elastic/eui/pull/2835)) +- Converted `EuiComboBox`, `EuiComboBoxInput`, `EuiComboBoxPill`, `EuiComboBoxOptionsList`, `EuiComboBoxOption`, and `EuiComboBoxTitle` to TypeScript ([#2838](https://github.com/elastic/eui/pull/2838)) +- Converted `EuiCodeEditor` to TypeScript ([#2836](https://github.com/elastic/eui/pull/2836)) +- Converted `EuiCode` and `EuiCodeBlock` and to TypeScript ([#2835](https://github.com/elastic/eui/pull/2835)) - Converted `EuiFilePicker` to TypeScript ([#2832](https://github.com/elastic/eui/issues/2832)) - Exported `EuiSelectOptionProps` type ([#2830](https://github.com/elastic/eui/pull/2830)) - Added `paperClip` glyph to `EuiIcon` ([#2845](https://github.com/elastic/eui/pull/2845)) diff --git a/package.json b/package.json index 6d77bfa6d9c..79fe76e120f 100644 --- a/package.json +++ b/package.json @@ -89,8 +89,10 @@ "@types/jest": "^24.0.6", "@types/react": "^16.9.11", "@types/react-dom": "^16.9.4", + "@types/react-input-autosize": "^2.0.1", "@types/react-is": "^16.7.1", "@types/resize-observer-browser": "^0.1.1", + "@types/sinon": "^7.5.1", "@types/tabbable": "^3.1.0", "@types/uuid": "^3.4.4", "@typescript-eslint/eslint-plugin": "^1.13.0", diff --git a/src/components/badge/badge.tsx b/src/components/badge/badge.tsx index 8cb7def8d7b..295769f688d 100644 --- a/src/components/badge/badge.tsx +++ b/src/components/badge/badge.tsx @@ -1,7 +1,8 @@ import React, { + AriaAttributes, FunctionComponent, - MouseEventHandler, HTMLAttributes, + MouseEventHandler, ReactNode, } from 'react'; import classNames from 'classnames'; @@ -22,7 +23,7 @@ type WithButtonProps = { /** * Aria label applied to the onClick button */ - onClickAriaLabel: string; + onClickAriaLabel: AriaAttributes['aria-label']; } & Omit, 'onClick' | 'color'>; type WithSpanProps = Omit, 'onClick' | 'color'>; @@ -36,7 +37,7 @@ interface WithIconOnClick { /** * Aria label applied to the iconOnClick button */ - iconOnClickAriaLabel: string; + iconOnClickAriaLabel: AriaAttributes['aria-label']; } export type EuiBadgeProps = { diff --git a/src/components/combo_box/__snapshots__/combo_box.test.js.snap b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap similarity index 98% rename from src/components/combo_box/__snapshots__/combo_box.test.js.snap rename to src/components/combo_box/__snapshots__/combo_box.test.tsx.snap index 298bcdf7af9..e05eaa15dd3 100644 --- a/src/components/combo_box/__snapshots__/combo_box.test.js.snap +++ b/src/components/combo_box/__snapshots__/combo_box.test.tsx.snap @@ -25,6 +25,7 @@ exports[`EuiComboBox is rendered 1`] = ` style="font-size:14px;display:inline-block" > diff --git a/src/components/combo_box/combo_box.test.js b/src/components/combo_box/combo_box.test.tsx similarity index 96% rename from src/components/combo_box/combo_box.test.js rename to src/components/combo_box/combo_box.test.tsx index 5591545d029..409d3232a02 100644 --- a/src/components/combo_box/combo_box.test.js +++ b/src/components/combo_box/combo_box.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { shallow, render, mount } from 'enzyme'; import sinon from 'sinon'; import { @@ -11,20 +11,24 @@ import { comboBoxKeyCodes } from '../../services'; import { EuiComboBox } from './combo_box'; jest.mock('../portal', () => ({ - EuiPortal: ({ children }) => children, + EuiPortal: ({ children }: { children: ReactNode }) => children, })); // Mock the htmlIdGenerator to generate predictable ids for snapshot tests jest.mock('../../services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => { - return suffix => `htmlid_${suffix}`; + return (suffix: string) => `htmlid_${suffix}`; }, })); -const options = [ +interface TitanOption { + 'data-test-subj'?: 'titanOption'; + label: string; +} +const options: TitanOption[] = [ { - label: 'Titan', 'data-test-subj': 'titanOption', + label: 'Titan', }, { label: 'Enceladus', diff --git a/src/components/combo_box/combo_box.js b/src/components/combo_box/combo_box.tsx similarity index 63% rename from src/components/combo_box/combo_box.js rename to src/components/combo_box/combo_box.tsx index 5a9c480a797..e071534e3e6 100644 --- a/src/components/combo_box/combo_box.js +++ b/src/components/combo_box/combo_box.tsx @@ -1,11 +1,15 @@ /** * Elements within EuiComboBox which would normally be tabbable (inputs, buttons) have been removed - * from the tab order with tabindex="-1" so that we can control the keyboard navigation interface. + * from the tab order with tabindex={-1} so that we can control the keyboard navigation interface. */ /* eslint-disable jsx-a11y/role-has-required-aria-props */ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { + Component, + FocusEventHandler, + KeyboardEventHandler, + HTMLAttributes, +} from 'react'; import classNames from 'classnames'; import { @@ -15,7 +19,6 @@ import { } from '../../services'; import { BACKSPACE, TAB, ESCAPE } from '../../services/key_codes'; import { EuiPortal } from '../portal'; -import { EuiComboBoxInput } from './combo_box_input'; import { EuiComboBoxOptionsList } from './combo_box_options_list'; import { @@ -23,78 +26,160 @@ import { flattenOptionGroups, getSelectedOptionForSearchValue, } from './matching_options'; +import { + EuiComboBoxInputProps, + EuiComboBoxInput, +} from './combo_box_input/combo_box_input'; +import { EuiComboBoxOptionsListProps } from './combo_box_options_list/combo_box_options_list'; +import { + UpdatePositionHandler, + OptionHandler, + RefCallback, + RefInstance, + EuiComboBoxOptionOption, + EuiComboBoxOptionsListPosition, + EuiComboBoxSingleSelectionShape, +} from './types'; +import { EuiFilterSelectItem } from '../filter_group'; +import AutosizeInput from 'react-input-autosize'; +import { CommonProps } from '../common'; + +type DrillProps = Pick< + EuiComboBoxOptionsListProps, + 'onCreateOption' | 'options' | 'renderOption' | 'selectedOptions' +>; + +export interface EuiComboBoxProps + extends CommonProps, + Omit, 'onChange'>, + DrillProps { + 'data-test-subj'?: string; + async: boolean; + className?: string; + compressed: boolean; + fullWidth: boolean; + id?: string; + inputRef?: RefCallback; + isClearable: boolean; + isDisabled?: boolean; + isInvalid?: boolean; + isLoading?: boolean; + noSuggestions?: boolean; + onBlur?: FocusEventHandler; + onChange?: (options: Array>) => void; + onFocus?: FocusEventHandler; + onKeyDown?: KeyboardEventHandler; + onSearchChange?: (searchValue: string, hasMatchingOptions?: boolean) => void; + placeholder?: string; + rowHeight?: number; + singleSelection: boolean | EuiComboBoxSingleSelectionShape; +} -export class EuiComboBox extends Component { - static propTypes = { - id: PropTypes.string, - isDisabled: PropTypes.bool, - className: PropTypes.string, - placeholder: PropTypes.string, - isLoading: PropTypes.bool, - async: PropTypes.bool, - singleSelection: PropTypes.oneOfType([ - PropTypes.bool, - PropTypes.shape({ - asPlainText: PropTypes.bool, - }), - ]), - noSuggestions: PropTypes.bool, - options: PropTypes.array, - selectedOptions: PropTypes.array, - onBlur: PropTypes.func, - onChange: PropTypes.func, - onFocus: PropTypes.func, - onSearchChange: PropTypes.func, - onCreateOption: PropTypes.func, - renderOption: PropTypes.func, - isInvalid: PropTypes.bool, - rowHeight: PropTypes.number, - isClearable: PropTypes.bool, - fullWidth: PropTypes.bool, - compressed: PropTypes.bool, - inputRef: PropTypes.func, - }; +interface EuiComboBoxState { + activeOptionIndex: number; + hasFocus: boolean; + isListOpen: boolean; + listElement?: RefInstance; + listPosition: EuiComboBoxOptionsListPosition; + matchingOptions: Array>; + searchValue: string; + width: number; +} + +const initialSearchValue = ''; +export class EuiComboBox extends Component< + EuiComboBoxProps, + EuiComboBoxState +> { static defaultProps = { + async: false, + compressed: false, + fullWidth: false, + isClearable: true, options: [], selectedOptions: [], - isClearable: true, singleSelection: false, - fullWidth: false, - compressed: false, }; - constructor(props) { - super(props); + state: EuiComboBoxState = { + activeOptionIndex: -1, + hasFocus: false, + isListOpen: false, + listElement: null, + listPosition: 'bottom', + matchingOptions: getMatchingOptions( + this.props.options, + this.props.selectedOptions, + initialSearchValue, + this.props.async, + Boolean(this.props.singleSelection) + ), + searchValue: initialSearchValue, + width: 0, + }; + + _isMounted = false; + rootId = htmlIdGenerator(); + + // Refs + comboBoxRefInstance: RefInstance = null; + comboBoxRefCallback: RefCallback = ref => { + // IE11 doesn't support the `relatedTarget` event property for blur events + // but does add it for focusout. React doesn't support `onFocusOut` so here we are. + if (this.comboBoxRefInstance) { + this.comboBoxRefInstance.removeEventListener( + 'focusout', + this.onContainerBlur + ); + } - const initialSearchValue = ''; - const { options, selectedOptions, singleSelection } = props; + this.comboBoxRefInstance = ref; - this.state = { - matchingOptions: getMatchingOptions( - options, - selectedOptions, - initialSearchValue, - props.async, - singleSelection - ), - listElement: undefined, - searchValue: initialSearchValue, - isListOpen: false, - listPosition: 'bottom', - activeOptionIndex: -1, - hasFocus: false, - }; + if (this.comboBoxRefInstance) { + this.comboBoxRefInstance.addEventListener( + 'focusout', + this.onContainerBlur + ); + const comboBoxBounds = this.comboBoxRefInstance.getBoundingClientRect(); + this.setState({ + width: comboBoxBounds.width, + }); + } + }; + autoSizeInputRefInstance: RefInstance = null; + autoSizeInputRefCallback: RefCallback< + AutosizeInput & HTMLDivElement + > = ref => { + this.autoSizeInputRefInstance = ref; + }; - this.rootId = htmlIdGenerator(); + searchInputRefInstance: RefInstance = null; + searchInputRefCallback: RefCallback = ref => { + this.searchInputRefInstance = ref; + }; - // Refs. - this.comboBox = undefined; - this.autoSizeInput = undefined; - this.searchInput = undefined; - this.optionsList = undefined; - this.options = []; - } + listRefInstance: RefInstance = null; + listRefCallback: RefCallback = ref => { + this.listRefInstance = ref; + }; + + toggleButtonRefInstance: RefInstance< + HTMLButtonElement | HTMLSpanElement + > = null; + toggleButtonRefCallback: RefCallback< + HTMLButtonElement | HTMLSpanElement + > = ref => { + this.toggleButtonRefInstance = ref; + }; + + optionsRefInstances: Array> = []; + optionRefCallback: EuiComboBoxOptionsListProps['optionRef'] = ( + index, + ref + ) => { + this.optionsRefInstances[index] = ref; + }; openList = () => { this.setState({ @@ -109,7 +194,9 @@ export class EuiComboBox extends Component { }); }; - updateListPosition = (listElement = this.state.listElement) => { + updatePosition: UpdatePositionHandler = ( + listElement = this.state.listElement + ) => { if (!this._isMounted) { return; } @@ -128,32 +215,38 @@ export class EuiComboBox extends Component { return; } - const comboBoxBounds = this.comboBox.getBoundingClientRect(); + if (!this.comboBoxRefInstance) { + return; + } + + const comboBoxBounds = this.comboBoxRefInstance.getBoundingClientRect(); const { position, top } = findPopoverPosition({ - anchor: this.comboBox, + allowCrossAxis: false, + anchor: this.comboBoxRefInstance, popover: listElement, position: 'bottom', - allowCrossAxis: false, - }); + }) as { position: 'bottom'; top: number }; - this.optionsList.style.top = `${top}px`; - // listElement doesn't have its width set until after updating the position - // which means the popover service won't know about the correct width - // however, we already know where to position the element - this.optionsList.style.left = `${comboBoxBounds.left + - window.pageXOffset}px`; - this.optionsList.style.width = `${comboBoxBounds.width}px`; + if (this.listRefInstance) { + this.listRefInstance.style.top = `${top}px`; + // listElement doesn't have its width set until after updating the position + // which means the popover service won't know about the correct width + // however, we already know where to position the element + this.listRefInstance.style.left = `${comboBoxBounds.left + + window.pageXOffset}px`; + this.listRefInstance.style.width = `${comboBoxBounds.width}px`; + } // Cache for future calls. this.setState({ listElement, - width: comboBoxBounds.width, listPosition: position, + width: comboBoxBounds.width, }); }; - incrementActiveOptionIndex = amount => { + incrementActiveOptionIndex = (amount: number) => { // If there are no options available, do nothing. if (!this.state.matchingOptions.length) { return; @@ -225,16 +318,16 @@ export class EuiComboBox extends Component { this.props.selectedOptions[this.props.selectedOptions.length - 1] ); - if (this.props.singleSelection && !this.state.isListOpen) { + if (Boolean(this.props.singleSelection) && !this.state.isListOpen) { this.openList(); } }; - addCustomOption = isContainerBlur => { + addCustomOption = (isContainerBlur: boolean) => { const { + onCreateOption, options, selectedOptions, - onCreateOption, singleSelection, } = this.props; @@ -260,7 +353,7 @@ export class EuiComboBox extends Component { } // Add new custom pill if this is custom input, even if it partially matches an option.. - const isOptionCreated = this.props.onCreateOption( + const isOptionCreated = onCreateOption( searchValue, flattenOptionGroups(options) ); @@ -274,7 +367,7 @@ export class EuiComboBox extends Component { if ( this.isSingleSelectionCustomOption() || - (singleSelection && matchingOptions.length < 1) + (Boolean(singleSelection) && matchingOptions.length < 1) ) { // Adding a custom option to a single select that does not appear in the list of options this.closeList(); @@ -310,16 +403,16 @@ export class EuiComboBox extends Component { } = this.props; // The selected option of a single select is custom and does not appear in the list of options return ( - singleSelection && + Boolean(singleSelection) && onCreateOption && selectedOptions.length > 0 && !options.includes(selectedOptions[0]) ); }; - onComboBoxFocus = () => { + onComboBoxFocus: FocusEventHandler = event => { if (this.props.onFocus) { - this.props.onFocus(); + this.props.onFocus(event); } if (!this.isSingleSelectionCustomOption()) { this.openList(); @@ -327,22 +420,34 @@ export class EuiComboBox extends Component { this.setState({ hasFocus: true }); }; - onContainerBlur = e => { + onContainerBlur: EventListener = event => { // close the options list, unless the use clicked on an option - // FireFox returns `relatedTarget` as `null` for security reasons, but provides a proprietary `explicitOriginalTarget` - const relatedTarget = e.relatedTarget || e.explicitOriginalTarget; + /** + * FireFox returns `relatedTarget` as `null` for security reasons, but provides a proprietary `explicitOriginalTarget`. + * @see https://developer.mozilla.org/en-US/docs/Web/API/Event/explicitOriginalTarget + */ + const focusEvent = event as FocusEvent & { + explicitOriginalTarget: EventTarget; + }; + const relatedTarget = (focusEvent.relatedTarget || + focusEvent.explicitOriginalTarget) as Node | null; + const focusedInOptionsList = relatedTarget && - this.optionsList && - this.optionsList.contains(relatedTarget); + this.listRefInstance && + this.listRefInstance.contains(relatedTarget); const focusedInInput = - relatedTarget && this.comboBox && this.comboBox.contains(relatedTarget); + relatedTarget && + this.comboBoxRefInstance && + this.comboBoxRefInstance.contains(relatedTarget); if (!focusedInOptionsList && !focusedInInput) { this.closeList(); if (this.props.onBlur) { - this.props.onBlur(); + this.props.onBlur((event as unknown) as React.FocusEvent< + HTMLDivElement + >); } this.setState({ hasFocus: false }); @@ -354,11 +459,11 @@ export class EuiComboBox extends Component { } }; - onKeyDown = e => { - switch (e.keyCode) { + onKeyDown: KeyboardEventHandler = event => { + switch (event.keyCode) { case comboBoxKeyCodes.UP: - e.preventDefault(); - e.stopPropagation(); + event.preventDefault(); + event.stopPropagation(); if (this.state.isListOpen) { this.incrementActiveOptionIndex(-1); } else { @@ -367,8 +472,8 @@ export class EuiComboBox extends Component { break; case comboBoxKeyCodes.DOWN: - e.preventDefault(); - e.stopPropagation(); + event.preventDefault(); + event.stopPropagation(); if (this.state.isListOpen) { this.incrementActiveOptionIndex(1); } else { @@ -377,65 +482,79 @@ export class EuiComboBox extends Component { break; case BACKSPACE: - e.stopPropagation(); + event.stopPropagation(); this.removeLastOption(); break; case ESCAPE: - e.stopPropagation(); + event.stopPropagation(); this.closeList(); break; case comboBoxKeyCodes.ENTER: - e.preventDefault(); - e.stopPropagation(); + event.preventDefault(); + event.stopPropagation(); if (this.hasActiveOption()) { this.onAddOption( this.state.matchingOptions[this.state.activeOptionIndex] ); } else { - this.addCustomOption(); + this.addCustomOption(false); } break; case TAB: // Disallow tabbing when the user is navigating the options. if (this.hasActiveOption() && this.state.isListOpen) { - e.preventDefault(); - e.stopPropagation(); + event.preventDefault(); + event.stopPropagation(); } break; default: if (this.props.onKeyDown) { - this.props.onKeyDown(e); + this.props.onKeyDown(event); } } }; - onOptionEnterKey = option => { + onOptionEnterKey: OptionHandler = option => { this.onAddOption(option); }; - onOptionClick = option => { + onOptionClick: OptionHandler = option => { this.onAddOption(option); }; - onAddOption = (addedOption, isContainerBlur) => { + onAddOption = ( + addedOption: EuiComboBoxOptionOption, + isContainerBlur?: boolean + ) => { if (addedOption.disabled) { return; } - const { onChange, selectedOptions, singleSelection } = this.props; - onChange( - singleSelection ? [addedOption] : selectedOptions.concat(addedOption) - ); + const { + onChange, + selectedOptions, + singleSelection: singleSelectionProp, + } = this.props; + const singleSelection = Boolean(singleSelectionProp); + const changeOptions = singleSelection + ? [addedOption] + : selectedOptions.concat(addedOption); + + if (onChange) { + onChange(changeOptions); + } this.clearSearchValue(); this.clearActiveOption(); if (!isContainerBlur) { - this.searchInput.focus(); + if (this.searchInputRefInstance) { + this.searchInputRefInstance.focus(); + } } if (singleSelection) { @@ -443,18 +562,27 @@ export class EuiComboBox extends Component { } }; - onRemoveOption = removedOption => { + onRemoveOption: OptionHandler = removedOption => { const { onChange, selectedOptions } = this.props; - onChange(selectedOptions.filter(option => option !== removedOption)); + if (onChange) { + onChange(selectedOptions.filter(option => option !== removedOption)); + } this.clearActiveOption(); }; clearSelectedOptions = () => { - this.props.onChange([]); + const { onChange } = this.props; + if (onChange) { + onChange([]); + } + // Clicking the clear button will also cause it to disappear. This would result in focus // shifting unexpectedly to the body element so we set it to the input which is more reasonable, - this.searchInput.focus(); + if (this.searchInputRefInstance) { + this.searchInputRefInstance.focus(); + } + if (!this.state.isListOpen) { this.openList(); } @@ -462,10 +590,15 @@ export class EuiComboBox extends Component { onComboBoxClick = () => { // When the user clicks anywhere on the box, enter the interaction state. - this.searchInput.focus(); + if (this.searchInputRefInstance) { + this.searchInputRefInstance.focus(); + } // If the user does this from a state in which an option has focus, then we need to reset it or clear it. - if (this.props.singleSelection && this.props.selectedOptions.length === 1) { + if ( + Boolean(this.props.singleSelection) && + this.props.selectedOptions.length === 1 + ) { this.setState({ activeOptionIndex: this.state.matchingOptions.indexOf( this.props.selectedOptions[0] @@ -477,20 +610,31 @@ export class EuiComboBox extends Component { }; onOpenListClick = () => { - this.searchInput.focus(); + if (this.searchInputRefInstance) { + this.searchInputRefInstance.focus(); + } if (!this.state.isListOpen) { this.openList(); } }; + onOptionListScroll = () => { + if (this.searchInputRefInstance) { + this.searchInputRefInstance.focus(); + } + }; + onCloseListClick = () => { this.closeList(); }; - onSearchChange = searchValue => { - if (this.props.onSearchChange) { + onSearchChange: NonNullable< + EuiComboBoxInputProps['onChange'] + > = searchValue => { + const { onSearchChange } = this.props; + if (onSearchChange) { const hasMatchingOptions = this.state.matchingOptions.length > 0; - this.props.onSearchChange(searchValue, hasMatchingOptions); + onSearchChange(searchValue, hasMatchingOptions); } this.setState({ searchValue }, () => { @@ -498,59 +642,22 @@ export class EuiComboBox extends Component { }); }; - comboBoxRef = node => { - // IE11 doesn't support the `relatedTarget` event property for blur events - // but does add it for focusout. React doesn't support `onFocusOut` so here we are. - if (this.comboBox != null) { - this.comboBox.removeEventListener('focusout', this.onContainerBlur); - } - - this.comboBox = node; - - if (this.comboBox) { - this.comboBox.addEventListener('focusout', this.onContainerBlur); - const comboBoxBounds = this.comboBox.getBoundingClientRect(); - this.setState({ - width: comboBoxBounds.width, - }); - } - }; - - autoSizeInputRef = node => { - this.autoSizeInput = node; - }; - - searchInputRef = node => { - this.searchInput = node; - if (this.props.inputRef) { - this.props.inputRef(node); - } - }; - - optionsListRef = node => { - this.optionsList = node; - }; - - optionRef = (index, node) => { - this.options[index] = node; - }; - - toggleButtonRef = node => { - this.toggleButton = node; - }; - componentDidMount() { this._isMounted = true; // TODO: This will need to be called once the actual stylesheet loads. setTimeout(() => { - if (this.autoSizeInput) { - this.autoSizeInput.copyInputStyles(); + if (this.autoSizeInputRefInstance) { + // @ts-ignore https://github.com/DefinitelyTyped/DefinitelyTyped/pull/42467 + this.autoSizeInputRefInstance.copyInputStyles(); } }, 100); } - static getDerivedStateFromProps(nextProps, prevState) { + static getDerivedStateFromProps( + nextProps: EuiComboBoxProps, + prevState: EuiComboBoxState + ) { const { options, selectedOptions, singleSelection } = nextProps; const { activeOptionIndex, searchValue } = prevState; @@ -561,10 +668,10 @@ export class EuiComboBox extends Component { selectedOptions, searchValue, nextProps.async, - singleSelection + Boolean(singleSelection) ); - const stateUpdate = { matchingOptions }; + const stateUpdate: Partial> = { matchingOptions }; if (activeOptionIndex >= matchingOptions.length) { stateUpdate.activeOptionIndex = -1; @@ -573,7 +680,9 @@ export class EuiComboBox extends Component { return stateUpdate; } - updateMatchingOptionsIfDifferent(newMatchingOptions) { + updateMatchingOptionsIfDifferent = ( + newMatchingOptions: Array> + ) => { const { matchingOptions, activeOptionIndex } = this.state; const { singleSelection, selectedOptions } = this.props; @@ -591,10 +700,10 @@ export class EuiComboBox extends Component { } if (areOptionsDifferent) { - this.options = []; + this.optionsRefInstances = []; let nextActiveOptionIndex = activeOptionIndex; // ensure that the currently selected single option is active if it is in the matchingOptions - if (singleSelection && selectedOptions.length === 1) { + if (Boolean(singleSelection) && selectedOptions.length === 1) { if (newMatchingOptions.includes(selectedOptions[0])) { nextActiveOptionIndex = newMatchingOptions.indexOf( selectedOptions[0] @@ -613,7 +722,7 @@ export class EuiComboBox extends Component { } } } - } + }; componentDidUpdate() { const { options, selectedOptions, singleSelection } = this.props; @@ -628,7 +737,7 @@ export class EuiComboBox extends Component { selectedOptions, searchValue, this.props.async, - singleSelection + Boolean(singleSelection) ) ); } @@ -639,37 +748,37 @@ export class EuiComboBox extends Component { render() { const { + 'data-test-subj': dataTestSubj, + async, + className, + compressed, + fullWidth, id, + inputRef, + isClearable, isDisabled, - className, + isInvalid, isLoading, - options, - selectedOptions, - onCreateOption, - placeholder, noSuggestions, - renderOption, - singleSelection, + onBlur, onChange, + onCreateOption, onSearchChange, - async, - onBlur, - inputRef, - isInvalid, + options, + placeholder, + renderOption, rowHeight, - isClearable, - fullWidth, - compressed, - 'data-test-subj': dataTestSubj, + selectedOptions, + singleSelection, ...rest } = this.props; const { + activeOptionIndex, hasFocus, - searchValue, isListOpen, listPosition, + searchValue, width, - activeOptionIndex, } = this.state; // Visually indicate the combobox is in an invalid state if it has lost focus but there is text entered in the input. @@ -680,11 +789,11 @@ export class EuiComboBox extends Component { ((hasFocus === false || isListOpen === false) && searchValue); const classes = classNames('euiComboBox', className, { - 'euiComboBox-isOpen': isListOpen, - 'euiComboBox-isInvalid': markAsInvalid, - 'euiComboBox-isDisabled': isDisabled, - 'euiComboBox--fullWidth': fullWidth, 'euiComboBox--compressed': compressed, + 'euiComboBox--fullWidth': fullWidth, + 'euiComboBox-isDisabled': isDisabled, + 'euiComboBox-isInvalid': markAsInvalid, + 'euiComboBox-isOpen': isListOpen, }); const value = selectedOptions @@ -701,30 +810,29 @@ export class EuiComboBox extends Component { optionsList = ( this.searchInput.focus()} + rowHeight={rowHeight} + scrollToIndex={activeOptionIndex} + searchValue={searchValue} + selectedOptions={selectedOptions} + updatePosition={this.updatePosition} + width={width} /> ); @@ -744,45 +852,45 @@ export class EuiComboBox extends Component { // eslint-disable-next-line jsx-a11y/interactive-supports-focus
+ onKeyDown={this.onKeyDown} + ref={this.comboBoxRefCallback} + role="combobox"> 0} id={id} - placeholder={placeholder} - selectedOptions={selectedOptions} - onRemoveOption={this.onRemoveOption} - onClick={this.onComboBoxClick} + inputRef={this.searchInputRefCallback} + isDisabled={isDisabled} + isListOpen={isListOpen} + noIcon={!!noSuggestions} onChange={this.onSearchChange} - onFocus={this.onComboBoxFocus} - value={value} - searchValue={searchValue} - autoSizeInputRef={this.autoSizeInputRef} - inputRef={this.searchInputRef} - updatePosition={this.updateListPosition} onClear={ isClearable && !isDisabled ? this.clearSelectedOptions : undefined } - hasSelectedOptions={selectedOptions.length > 0} - isListOpen={isListOpen} - onOpenListClick={this.onOpenListClick} + onClick={this.onComboBoxClick} onCloseListClick={this.onCloseListClick} - singleSelection={singleSelection} - isDisabled={isDisabled} - toggleButtonRef={this.toggleButtonRef} - fullWidth={fullWidth} - noIcon={!!noSuggestions} + onFocus={this.onComboBoxFocus} + onOpenListClick={this.onOpenListClick} + onRemoveOption={this.onRemoveOption} + placeholder={placeholder} rootId={this.rootId} - focusedOptionId={ - this.hasActiveOption() - ? this.rootId(`_option-${this.state.activeOptionIndex}`) - : null - } - compressed={compressed} + searchValue={searchValue} + selectedOptions={selectedOptions} + singleSelection={singleSelection} + toggleButtonRef={this.toggleButtonRefCallback} + updatePosition={this.updatePosition} + value={value} /> {optionsList} diff --git a/src/components/combo_box/combo_box_input/combo_box_input.js b/src/components/combo_box/combo_box_input/combo_box_input.tsx similarity index 53% rename from src/components/combo_box/combo_box_input/combo_box_input.js rename to src/components/combo_box/combo_box_input/combo_box_input.tsx index c75d6896616..de91822d9e4 100644 --- a/src/components/combo_box/combo_box_input/combo_box_input.js +++ b/src/components/combo_box/combo_box_input/combo_box_input.tsx @@ -1,57 +1,63 @@ -import React, { Component } from 'react'; +import React, { Component, FocusEventHandler, ChangeEventHandler } from 'react'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import AutosizeInput from 'react-input-autosize'; import { EuiScreenReaderOnly } from '../../accessibility'; -import { EuiFormControlLayout } from '../../form'; +import { EuiFormControlLayout } from '../../form/form_control_layout'; import { EuiComboBoxPill } from './combo_box_pill'; import { htmlIdGenerator } from '../../../services'; +import { EuiFormControlLayoutIconsProps } from '../../form/form_control_layout/form_control_layout_icons'; +import { + EuiComboBoxOptionOption, + EuiComboBoxSingleSelectionShape, + OptionHandler, + RefCallback, + UpdatePositionHandler, +} from '../types'; +import { CommonProps } from '../../common'; const makeId = htmlIdGenerator(); -export class EuiComboBoxInput extends Component { - static propTypes = { - id: PropTypes.string, - placeholder: PropTypes.string, - selectedOptions: PropTypes.array, - onRemoveOption: PropTypes.func, - onBlur: PropTypes.func, - onClick: PropTypes.func, - onFocus: PropTypes.func.isRequired, - onChange: PropTypes.func, - value: PropTypes.string, - searchValue: PropTypes.string, - autoSizeInputRef: PropTypes.func, - inputRef: PropTypes.func, - updatePosition: PropTypes.func.isRequired, - onClear: PropTypes.func, - hasSelectedOptions: PropTypes.bool.isRequired, - isListOpen: PropTypes.bool.isRequired, - noIcon: PropTypes.bool.isRequired, - onOpenListClick: PropTypes.func.isRequired, - onCloseListClick: PropTypes.func.isRequired, - singleSelection: PropTypes.oneOfType([ - PropTypes.bool, - PropTypes.shape({ - asPlainText: PropTypes.bool, - }), - ]), - isDisabled: PropTypes.bool, - toggleButtonRef: PropTypes.func, - fullWidth: PropTypes.bool, - rootId: PropTypes.func.isRequired, - focusedOptionId: PropTypes.string, - compressed: PropTypes.bool.isRequired, - }; +export interface EuiComboBoxInputProps extends CommonProps { + autoSizeInputRef?: RefCallback; + compressed: boolean; + focusedOptionId?: string; + fullWidth?: boolean; + hasSelectedOptions: boolean; + id?: string; + inputRef?: RefCallback; + isDisabled?: boolean; + isListOpen: boolean; + noIcon: boolean; + onBlur?: FocusEventHandler; + onChange?: (searchValue: string) => void; + onClear?: () => void; + onClick?: () => void; + onCloseListClick: () => void; + onFocus: FocusEventHandler; + onOpenListClick: () => void; + onRemoveOption?: OptionHandler; + placeholder?: string; + rootId: ReturnType; + searchValue: string; + selectedOptions?: Array>; + singleSelection?: boolean | EuiComboBoxSingleSelectionShape; + toggleButtonRef?: RefCallback; + updatePosition: UpdatePositionHandler; + value?: string; +} - constructor(props) { - super(props); +interface EuiComboBoxInputState { + hasFocus: boolean; +} - this.state = { - hasFocus: false, - }; - } +export class EuiComboBoxInput extends Component< + EuiComboBoxInputProps, + EuiComboBoxInputState +> { + state: EuiComboBoxInputState = { + hasFocus: false, + }; updatePosition = () => { // Wait a beat for the DOM to update, since we depend on DOM elements' bounds. @@ -60,23 +66,23 @@ export class EuiComboBoxInput extends Component { }); }; - onFocus = () => { - this.props.onFocus(); + onFocus: FocusEventHandler = event => { + this.props.onFocus(event); this.setState({ hasFocus: true, }); }; - onBlur = () => { + onBlur: FocusEventHandler = event => { if (this.props.onBlur) { - this.props.onBlur(); + this.props.onBlur(event); } this.setState({ hasFocus: false, }); }; - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: EuiComboBoxInputProps) { const { searchValue } = prevProps; // We need to update the position of everything if the user enters enough input to change @@ -86,54 +92,74 @@ export class EuiComboBoxInput extends Component { } } + inputOnChange: ChangeEventHandler = event => { + const { onChange, searchValue } = this.props; + if (onChange) { + onChange(event.target.value as typeof searchValue); + } + }; + + inputRefCallback = (ref: HTMLInputElement & AutosizeInput) => { + const { autoSizeInputRef } = this.props; + if (autoSizeInputRef) { + autoSizeInputRef(ref); + } + }; + render() { const { + compressed, + focusedOptionId, + fullWidth, + hasSelectedOptions, id, - placeholder, - selectedOptions, - onRemoveOption, - onClick, - onChange, - value, - searchValue, - autoSizeInputRef, inputRef, - onClear, - hasSelectedOptions, - isListOpen, - onOpenListClick, - onCloseListClick, - singleSelection, isDisabled, - toggleButtonRef, - fullWidth, + isListOpen, noIcon, + onClear, + onClick, + onCloseListClick, + onOpenListClick, + onRemoveOption, + placeholder, rootId, - focusedOptionId, - compressed, + searchValue, + selectedOptions, + singleSelection: singleSelectionProp, + toggleButtonRef, + value, } = this.props; - const pills = selectedOptions.map(option => { - const { label, color, onClick, ...rest } = option; + const singleSelection = Boolean(singleSelectionProp); + const asPlainText = + (singleSelectionProp && + typeof singleSelectionProp === 'object' && + singleSelectionProp.asPlainText) || + false; - const asPlainText = singleSelection && singleSelection.asPlainText; - - return ( - - {label} - - ); - }); + const pills = selectedOptions + ? selectedOptions.map(option => { + const { label, color, onClick, ...rest } = option; + const pillOnClose = + isDisabled || singleSelection || onClick + ? undefined + : onRemoveOption; + return ( + + {label} + + ); + }) + : null; let removeOptionMessage; let removeOptionMessageId; @@ -144,7 +170,7 @@ export class EuiComboBoxInput extends Component { `Combo box. Selected. ${ searchValue ? `${searchValue}. Selected. ` : '' }${ - selectedOptions.length + selectedOptions && selectedOptions.length > 0 ? `${value}. Press Backspace to delete ${ selectedOptions[selectedOptions.length - 1].label }. ` @@ -169,33 +195,37 @@ export class EuiComboBoxInput extends Component { let placeholderMessage; - if (placeholder && !selectedOptions.length && !searchValue) { + if ( + placeholder && + selectedOptions && + !selectedOptions.length && + !searchValue + ) { placeholderMessage = (

{placeholder}

); } - const clickProps = {}; - + const clickProps: EuiFormControlLayoutIconsProps = {}; if (!isDisabled && onClear && hasSelectedOptions) { clickProps.clear = { - onClick: onClear, 'data-test-subj': 'comboBoxClearButton', + onClick: onClear, }; } - let icon; + let icon: EuiFormControlLayoutIconsProps['icon']; if (!noIcon) { icon = { - type: 'arrowDown', - side: 'right', - onClick: isListOpen && !isDisabled ? onCloseListClick : onOpenListClick, - ref: toggleButtonRef, 'aria-label': isListOpen ? 'Close list of options' : 'Open list of options', - disabled: isDisabled, 'data-test-subj': 'comboBoxToggleListButton', + disabled: isDisabled, + onClick: isListOpen && !isDisabled ? onCloseListClick : onOpenListClick, + ref: toggleButtonRef, + side: 'right', + type: 'arrowDown', }; } @@ -210,30 +240,31 @@ export class EuiComboBoxInput extends Component { + compressed={compressed} + fullWidth={fullWidth}>
+ tabIndex={-1} // becomes onBlur event's relatedTarget, otherwise relatedTarget is null when clicking on this div + > {!singleSelection || !searchValue ? pills : null} {placeholderMessage} onChange(e.target.value)} + onChange={this.inputOnChange} + onFocus={this.onFocus} + ref={this.inputRefCallback} + role="textbox" + style={{ fontSize: 14 }} value={searchValue} - ref={autoSizeInputRef} - inputRef={inputRef} - disabled={isDisabled} - data-test-subj="comboBoxSearchInput" /> {removeOptionMessage}
diff --git a/src/components/combo_box/combo_box_input/combo_box_pill.js b/src/components/combo_box/combo_box_input/combo_box_pill.tsx similarity index 62% rename from src/components/combo_box/combo_box_input/combo_box_pill.js rename to src/components/combo_box/combo_box_input/combo_box_pill.tsx index f0bc0dfb859..ab53b49a2d0 100644 --- a/src/components/combo_box/combo_box_input/combo_box_pill.js +++ b/src/components/combo_box/combo_box_input/combo_box_pill.tsx @@ -1,41 +1,44 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { AriaAttributes, Component, MouseEventHandler } from 'react'; import classNames from 'classnames'; import { EuiBadge } from '../../badge'; import { EuiI18n } from '../../i18n'; +import { EuiComboBoxOptionOption, OptionHandler } from '../types'; +import { CommonProps } from '../../common'; -export class EuiComboBoxPill extends Component { - static propTypes = { - option: PropTypes.object.isRequired, - children: PropTypes.string, - className: PropTypes.string, - color: PropTypes.string, - onClose: PropTypes.func, - asPlainText: PropTypes.bool, - onClick: PropTypes.func, - onClickAriaLabel: PropTypes.string, - }; +export interface EuiComboBoxPillProps extends CommonProps { + asPlainText?: boolean; + children?: string; + className?: string; + color?: string; + onClick?: MouseEventHandler; + onClickAriaLabel?: AriaAttributes['aria-label']; + onClose?: OptionHandler; + option: EuiComboBoxOptionOption; +} +export class EuiComboBoxPill extends Component> { static defaultProps = { color: 'hollow', }; onCloseButtonClick = () => { const { onClose, option } = this.props; - onClose(option); + if (onClose) { + onClose(option); + } }; render() { const { + asPlainText, children, className, - option, // eslint-disable-line no-unused-vars - onClose, // eslint-disable-line no-unused-vars color, onClick, onClickAriaLabel, - asPlainText, + onClose, // eslint-disable-line no-unused-vars + option, // eslint-disable-line no-unused-vars ...rest } = this.props; const classes = classNames( @@ -45,6 +48,13 @@ export class EuiComboBoxPill extends Component { }, className ); + const onClickProps = + onClick && onClickAriaLabel + ? { + onClick, + onClickAriaLabel, + } + : {}; if (onClose) { return ( @@ -52,20 +62,17 @@ export class EuiComboBoxPill extends Component { token="euiComboBoxPill.removeSelection" default="Remove {children} from selection in this group" values={{ children }}> - {removeSelection => ( + {(removeSelection: string) => ( {children} @@ -85,11 +92,10 @@ export class EuiComboBoxPill extends Component { return ( + {...onClickProps}> {children} ); diff --git a/src/components/combo_box/combo_box_input/index.js b/src/components/combo_box/combo_box_input/index.js deleted file mode 100644 index 3835fee08ec..00000000000 --- a/src/components/combo_box/combo_box_input/index.js +++ /dev/null @@ -1 +0,0 @@ -export { EuiComboBoxInput } from './combo_box_input'; diff --git a/src/components/combo_box/combo_box_input/index.ts b/src/components/combo_box/combo_box_input/index.ts new file mode 100644 index 00000000000..e9ae3ec895a --- /dev/null +++ b/src/components/combo_box/combo_box_input/index.ts @@ -0,0 +1,2 @@ +export { EuiComboBoxInput, EuiComboBoxInputProps } from './combo_box_input'; +export { EuiComboBoxPill, EuiComboBoxPillProps } from './combo_box_pill'; diff --git a/src/components/combo_box/combo_box_options_list/combo_box_option.js b/src/components/combo_box/combo_box_options_list/combo_box_option.tsx similarity index 58% rename from src/components/combo_box/combo_box_options_list/combo_box_option.js rename to src/components/combo_box/combo_box_options_list/combo_box_option.tsx index f642cb46ad7..3ee02230ee8 100644 --- a/src/components/combo_box/combo_box_options_list/combo_box_option.js +++ b/src/components/combo_box/combo_box_options_list/combo_box_option.tsx @@ -1,21 +1,29 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { + Component, + ReactNode, + KeyboardEventHandler, + HTMLAttributes, +} from 'react'; import classNames from 'classnames'; import { ENTER, SPACE } from '../../../services/key_codes'; +import { EuiComboBoxOptionOption, OptionHandler, RefCallback } from '../types'; +import { CommonProps } from '../../common'; -export class EuiComboBoxOption extends Component { - static propTypes = { - option: PropTypes.object.isRequired, - children: PropTypes.node, - className: PropTypes.string, - optionRef: PropTypes.func, - onClick: PropTypes.func.isRequired, - onEnterKey: PropTypes.func.isRequired, - disabled: PropTypes.bool, - isFocused: PropTypes.bool.isRequired, - }; +export interface EuiComboBoxOptionProps + extends CommonProps, + Omit, 'onClick'> { + children?: ReactNode; + className?: string; + disabled?: boolean; + isFocused: boolean; + onClick: OptionHandler; + onEnterKey: OptionHandler; + option: EuiComboBoxOptionOption; + optionRef?: RefCallback; +} +export class EuiComboBoxOption extends Component> { onClick = () => { const { onClick, option, disabled } = this.props; @@ -26,10 +34,10 @@ export class EuiComboBoxOption extends Component { onClick(option); }; - onKeyDown = e => { - if (e.keyCode === ENTER || e.keyCode === SPACE) { - e.preventDefault(); - e.stopPropagation(); + onKeyDown: KeyboardEventHandler = event => { + if (event.keyCode === ENTER || event.keyCode === SPACE) { + event.preventDefault(); + event.stopPropagation(); const { onEnterKey, option, disabled } = this.props; if (disabled) { @@ -44,12 +52,12 @@ export class EuiComboBoxOption extends Component { const { children, className, - optionRef, - option, - onClick, // eslint-disable-line no-unused-vars - onEnterKey, // eslint-disable-line no-unused-vars disabled, isFocused, + onClick, // eslint-disable-line no-unused-vars + onEnterKey, // eslint-disable-line no-unused-vars + option, + optionRef, ...rest } = this.props; @@ -62,15 +70,15 @@ export class EuiComboBoxOption extends Component { return ( diff --git a/src/components/combo_box/combo_box_options_list/combo_box_options_list.js b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx similarity index 71% rename from src/components/combo_box/combo_box_options_list/combo_box_options_list.js rename to src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx index 0d348be2d8e..82a0a9ec3ce 100644 --- a/src/components/combo_box/combo_box_options_list/combo_box_options_list.js +++ b/src/components/combo_box/combo_box_options_list/combo_box_options_list.tsx @@ -1,9 +1,8 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { Component, ReactNode, ComponentProps } from 'react'; import classNames from 'classnames'; -import { List } from 'react-virtualized'; +import { List, ListProps } from 'react-virtualized'; // eslint-disable-line import/named -import { EuiCode } from '../../code'; +import { EuiCode } from '../../../components/code'; import { EuiFlexGroup, EuiFlexItem } from '../../flex'; import { EuiHighlight } from '../../highlight'; import { EuiPanel } from '../../panel'; @@ -12,52 +11,78 @@ import { EuiLoadingSpinner } from '../../loading'; import { EuiComboBoxTitle } from './combo_box_title'; import { EuiI18n } from '../../i18n'; import { EuiFilterSelectItem } from '../../filter_group/filter_select_item'; +import { htmlIdGenerator } from '../../../services'; +import { + EuiComboBoxOptionOption, + EuiComboBoxOptionsListPosition, + OptionHandler, + RefCallback, + RefInstance, + UpdatePositionHandler, +} from '../types'; +import { CommonProps } from '../../common'; -const positionToClassNameMap = { +const positionToClassNameMap: { + [position in EuiComboBoxOptionsListPosition]: string +} = { top: 'euiComboBoxOptionsList--top', bottom: 'euiComboBoxOptionsList--bottom', }; -const POSITIONS = Object.keys(positionToClassNameMap); - const OPTION_CONTENT_CLASSNAME = 'euiComboBoxOption__content'; -export class EuiComboBoxOptionsList extends Component { - static propTypes = { - options: PropTypes.array, - isLoading: PropTypes.bool, - selectedOptions: PropTypes.array, - onCreateOption: PropTypes.func, - searchValue: PropTypes.string, - matchingOptions: PropTypes.array, - optionRef: PropTypes.func, - onOptionClick: PropTypes.func, - onOptionEnterKey: PropTypes.func, - areAllOptionsSelected: PropTypes.bool, - getSelectedOptionForSearchValue: PropTypes.func, - updatePosition: PropTypes.func.isRequired, - position: PropTypes.oneOf(POSITIONS), - listRef: PropTypes.func.isRequired, - renderOption: PropTypes.func, - width: PropTypes.number, - scrollToIndex: PropTypes.number, - onScroll: PropTypes.func, - rowHeight: PropTypes.number, - fullWidth: PropTypes.bool, - activeOptionIndex: PropTypes.number, - rootId: PropTypes.func.isRequired, - onCloseList: PropTypes.func.isRequired, +export type EuiComboBoxOptionsListProps = CommonProps & + ComponentProps & { + 'data-test-subj': string; + activeOptionIndex?: number; + areAllOptionsSelected?: boolean; + fullWidth?: boolean; + getSelectedOptionForSearchValue?: ( + searchValue: string, + selectedOptions: any[] + ) => EuiComboBoxOptionOption; + isLoading?: boolean; + listRef: RefCallback; + matchingOptions: Array>; + onCloseList: () => void; + onCreateOption?: ( + searchValue: string, + options: Array> + ) => boolean; + onOptionClick?: OptionHandler; + onOptionEnterKey?: OptionHandler; + onScroll?: ListProps['onScroll']; + optionRef: (index: number, node: RefInstance) => void; + options: Array>; + position?: EuiComboBoxOptionsListPosition; + renderOption?: ( + option: EuiComboBoxOptionOption, + searchValue: string, + OPTION_CONTENT_CLASSNAME: string + ) => ReactNode; + rootId: ReturnType; + rowHeight: number; + scrollToIndex?: number; + searchValue: string; + selectedOptions: Array>; + updatePosition: UpdatePositionHandler; + width: number; }; +export class EuiComboBoxOptionsList extends Component< + EuiComboBoxOptionsListProps +> { + listRefInstance: RefInstance = null; + static defaultProps = { - rowHeight: 27, // row height of default option renderer 'data-test-subj': '', + rowHeight: 27, // row height of default option renderer }; updatePosition = () => { // Wait a beat for the DOM to update, since we depend on DOM elements' bounds. requestAnimationFrame(() => { - this.props.updatePosition(this.list); + this.props.updatePosition(this.listRefInstance); }); }; @@ -80,7 +105,7 @@ export class EuiComboBoxOptionsList extends Component { }, 500); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: EuiComboBoxOptionsListProps) { const { options, selectedOptions, searchValue } = prevProps; // We don't compare matchingOptions because that will result in a loop. @@ -97,50 +122,53 @@ export class EuiComboBoxOptionsList extends Component { document.body.classList.remove('euiBody-hasPortalContent'); window.removeEventListener('resize', this.updatePosition); window.removeEventListener('scroll', this.closeListOnScroll, { - passive: true, capture: true, }); } - closeListOnScroll = e => { - // close the list when a scroll event happens, but not if the scroll happened in the options list - // this mirrors Firefox's approach of auto-closing `select` elements onscroll - if (this.list && this.list.contains(e.target) === false) { + closeListOnScroll = (event: Event) => { + // Close the list when a scroll event happens, but not if the scroll happened in the options list. + // This mirrors Firefox's approach of auto-closing `select` elements onscroll. + if ( + this.listRefInstance && + event.target && + this.listRefInstance.contains(event.target as Node) === false + ) { this.props.onCloseList(); } }; - listRef = node => { - this.props.listRef(node); - this.list = node; + listRefCallback: RefCallback = ref => { + this.props.listRef(ref); + this.listRefInstance = ref; }; render() { const { - options, + 'data-test-subj': dataTestSubj, + activeOptionIndex, + areAllOptionsSelected, + fullWidth, + getSelectedOptionForSearchValue, isLoading, - selectedOptions, - onCreateOption, - searchValue, + listRef, matchingOptions, - optionRef, + onCloseList, + onCreateOption, onOptionClick, onOptionEnterKey, - areAllOptionsSelected, - getSelectedOptionForSearchValue, + onScroll, + optionRef, + options, position, renderOption, - listRef, + rootId, + rowHeight, + scrollToIndex, + searchValue, + selectedOptions, updatePosition, width, - scrollToIndex, - onScroll, - rowHeight, - fullWidth, - 'data-test-subj': dataTestSubj, - activeOptionIndex, - rootId, - onCloseList, ...rest } = this.props; @@ -160,8 +188,8 @@ export class EuiComboBoxOptionsList extends Component { ); - } else if (searchValue && matchingOptions.length === 0) { - if (onCreateOption) { + } else if (searchValue && matchingOptions && matchingOptions.length === 0) { + if (onCreateOption && getSelectedOptionForSearchValue) { const selectedOptionForValue = getSelectedOptionForSearchValue( searchValue, selectedOptions @@ -238,20 +266,15 @@ export class EuiComboBoxOptionsList extends Component { const optionsList = ( { const option = matchingOptions[index]; const { - value, // eslint-disable-line no-unused-vars - label, isGroupLabelOption, + label, + value, // eslint-disable-line no-unused-vars ...rest } = option; @@ -267,8 +290,11 @@ export class EuiComboBoxOptionsList extends Component { onOptionClick(option)} - // onEnterKey={onOptionEnterKey} + onClick={() => { + if (onOptionClick) { + onOptionClick(option); + } + }} ref={optionRef.bind(this, index)} isFocused={activeOptionIndex === index} id={rootId(`_option-${index}`)} @@ -287,12 +313,17 @@ export class EuiComboBoxOptionsList extends Component { ); }} + role="listbox" + rowCount={matchingOptions.length} + rowHeight={rowHeight} + scrollToIndex={scrollToIndex} + width={width} /> ); const classes = classNames( 'euiComboBoxOptionsList', - positionToClassNameMap[position], + position ? positionToClassNameMap[position] : '', { 'euiComboBoxOptionsList--fullWidth': fullWidth, } @@ -302,7 +333,7 @@ export class EuiComboBoxOptionsList extends Component {
diff --git a/src/components/combo_box/combo_box_options_list/index.js b/src/components/combo_box/combo_box_options_list/index.js deleted file mode 100644 index d59b19770e0..00000000000 --- a/src/components/combo_box/combo_box_options_list/index.js +++ /dev/null @@ -1 +0,0 @@ -export { EuiComboBoxOptionsList } from './combo_box_options_list'; diff --git a/src/components/combo_box/combo_box_options_list/index.ts b/src/components/combo_box/combo_box_options_list/index.ts new file mode 100644 index 00000000000..58d07c814f9 --- /dev/null +++ b/src/components/combo_box/combo_box_options_list/index.ts @@ -0,0 +1,6 @@ +export { + EuiComboBoxOptionsList, + EuiComboBoxOptionsListProps, +} from './combo_box_options_list'; +export { EuiComboBoxOption, EuiComboBoxOptionProps } from './combo_box_option'; +export { EuiComboBoxTitle } from './combo_box_title'; diff --git a/src/components/combo_box/index.d.ts b/src/components/combo_box/index.d.ts deleted file mode 100644 index 5bf094a420d..00000000000 --- a/src/components/combo_box/index.d.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - ButtonHTMLAttributes, - HTMLAttributes, - ReactNode, - FunctionComponent, - FocusEventHandler, -} from 'react'; -import { ListProps } from 'react-virtualized'; // eslint-disable-line import/named -import { - EuiComboBoxOption, - EuiComboBoxOptionProps, - EuiComboBoxOptionsListPosition, - EuiComboBoxOptionsListProps, - EuiComboBoxProps, -} from '@elastic/eui'; // eslint-disable-line import/no-unresolved -import { RefCallback, CommonProps } from '../common'; -import { EuiPanelProps } from '../panel/panel'; - -declare module '@elastic/eui' { - export type EuiComboBoxOptionProps< - T = string | number | string[] | undefined - > = CommonProps & - Omit, 'value'> & { - label: string; - isGroupLabelOption?: boolean; - options?: Array>; - value?: T; - }; - - export type EuiComboBoxOptionsListPosition = 'top' | 'bottom'; - - export interface EuiComboBoxOption { - option: EuiComboBoxOptionProps; - children?: ReactNode; - className?: string; - optionRef?: RefCallback; - onClick: (option: EuiComboBoxOptionProps) => any; - onEnterKey: (option: EuiComboBoxOptionProps) => any; - disabled?: boolean; - } - - export interface EuiComboBoxOptionsListProps { - options?: Array>; - isLoading?: boolean; - selectedOptions?: any[]; - onCreateOption?: any; - searchValue?: string; - matchingOptions?: Array>; - optionRef?: EuiComboBoxOption['optionRef']; - onOptionClick?: EuiComboBoxOption['onClick']; - onOptionEnterKey?: EuiComboBoxOption['onEnterKey']; - areAllOptionsSelected?: boolean; - getSelectedOptionForSearchValue?: ( - searchValue: string, - selectedOptions: any[] - ) => EuiComboBoxOptionProps; - updatePosition: (parameter?: UIEvent | EuiPanelProps['panelRef']) => any; - position?: EuiComboBoxOptionsListPosition; - listRef: EuiPanelProps['panelRef']; - renderOption?: ( - option: EuiComboBoxOptionProps, - searchValue: string, - OPTION_CONTENT_CLASSNAME: string - ) => ReactNode; - width?: number; - scrollToIndex?: number; - onScroll?: ListProps['onScroll']; - rowHeight?: number; - fullWidth?: boolean; - } - export function EuiComboBoxOptionsList( - props: EuiComboBoxOptionsListProps - ): ReturnType>>; - - export interface EuiComboBoxSingleSelectionShape { - asPlainText?: boolean; - } - - export interface EuiComboBoxProps { - id?: string; - isDisabled?: boolean; - compressed?: boolean; - className?: string; - placeholder?: string; - isLoading?: boolean; - async?: boolean; - singleSelection?: EuiComboBoxSingleSelectionShape | boolean; - noSuggestions?: boolean; - options?: EuiComboBoxOptionsListProps['options']; - selectedOptions?: EuiComboBoxOptionsListProps['selectedOptions']; - onBlur?: FocusEventHandler; - onChange?: (options: Array>) => any; - onFocus?: FocusEventHandler; - onSearchChange?: (searchValue: string) => any; - onCreateOption?: EuiComboBoxOptionsListProps['onCreateOption']; - renderOption?: EuiComboBoxOptionsListProps['renderOption']; - isInvalid?: boolean; - rowHeight?: number; - isClearable?: boolean; - fullWidth?: boolean; - inputRef?: (element: HTMLInputElement) => void; - } - - export function EuiComboBox( - props: EuiComboBoxProps & - Omit, 'onChange'> - ): ReturnType>>; -} diff --git a/src/components/combo_box/index.js b/src/components/combo_box/index.js deleted file mode 100644 index 3484b23b57b..00000000000 --- a/src/components/combo_box/index.js +++ /dev/null @@ -1 +0,0 @@ -export { EuiComboBox } from './combo_box'; diff --git a/src/components/combo_box/index.ts b/src/components/combo_box/index.ts new file mode 100644 index 00000000000..c30dc03e9f8 --- /dev/null +++ b/src/components/combo_box/index.ts @@ -0,0 +1,8 @@ +export { EuiComboBox, EuiComboBoxProps } from './combo_box'; +export * from './combo_box_input'; +export * from './combo_box_options_list'; +export { + EuiComboBoxOptionOption, + EuiComboBoxOptionsListPosition, + EuiComboBoxSingleSelectionShape, +} from './types'; diff --git a/src/components/combo_box/matching_options.test.ts b/src/components/combo_box/matching_options.test.ts index 113ca4c6300..9f0c787157b 100644 --- a/src/components/combo_box/matching_options.test.ts +++ b/src/components/combo_box/matching_options.test.ts @@ -1,8 +1,8 @@ -import { EuiComboBoxOptionProps } from '@elastic/eui'; // eslint-disable-line import/no-unresolved +import { EuiComboBoxOptionOption } from './types'; import { flattenOptionGroups, - getSelectedOptionForSearchValue, getMatchingOptions, + getSelectedOptionForSearchValue, } from './matching_options'; const options = [ @@ -82,18 +82,16 @@ describe('getSelectedOptionForSearchValue', () => { }); }); -interface GetMatchingOptionsTestCase { - options: Array>; - selectedOptions: Array>; - searchValue: string; +interface GetMatchingOptionsTestCase { + expected: EuiComboBoxOptionOption[]; isPreFiltered: boolean; + options: EuiComboBoxOptionOption[]; + searchValue: string; + selectedOptions: EuiComboBoxOptionOption[]; showPrevSelected: boolean; - expected: Array>; } -const testCases: Array< - GetMatchingOptionsTestCase<{ [key: string]: string }> -> = [ +const testCases: GetMatchingOptionsTestCase[] = [ { options, selectedOptions: [ diff --git a/src/components/combo_box/matching_options.ts b/src/components/combo_box/matching_options.ts index 4b47d93f8a2..726e1a5b047 100644 --- a/src/components/combo_box/matching_options.ts +++ b/src/components/combo_box/matching_options.ts @@ -1,12 +1,12 @@ -import { EuiComboBoxOptionProps } from '@elastic/eui'; // eslint-disable-line import/no-unresolved +import { EuiComboBoxOptionOption } from './types'; export const flattenOptionGroups = ( - optionsOrGroups: Array> + optionsOrGroups: Array> ) => { return optionsOrGroups.reduce( ( - options: Array>, - optionOrGroup: EuiComboBoxOptionProps + options: Array>, + optionOrGroup: EuiComboBoxOptionOption ) => { if (optionOrGroup.options) { options.push(...optionOrGroup.options); @@ -21,7 +21,7 @@ export const flattenOptionGroups = ( export const getSelectedOptionForSearchValue = ( searchValue: string, - selectedOptions: Array> + selectedOptions: Array> ) => { const normalizedSearchValue = searchValue.toLowerCase(); return selectedOptions.find( @@ -30,9 +30,9 @@ export const getSelectedOptionForSearchValue = ( }; const collectMatchingOption = ( - accumulator: Array>, - option: EuiComboBoxOptionProps, - selectedOptions: Array>, + accumulator: Array>, + option: EuiComboBoxOptionOption, + selectedOptions: Array>, normalizedSearchValue: string, isPreFiltered: boolean, showPrevSelected: boolean @@ -64,19 +64,19 @@ const collectMatchingOption = ( }; export const getMatchingOptions = ( - options: Array>, - selectedOptions: Array>, + options: Array>, + selectedOptions: Array>, searchValue: string, isPreFiltered: boolean, showPrevSelected: boolean ) => { const normalizedSearchValue = searchValue.trim().toLowerCase(); - const matchingOptions: Array> = []; + const matchingOptions: Array> = []; options.forEach(option => { if (option.options) { - const matchingOptionsForGroup: Array> = []; - option.options.forEach((groupOption: EuiComboBoxOptionProps) => { + const matchingOptionsForGroup: Array> = []; + option.options.forEach((groupOption: EuiComboBoxOptionOption) => { collectMatchingOption( matchingOptionsForGroup, groupOption, diff --git a/src/components/combo_box/types.ts b/src/components/combo_box/types.ts new file mode 100644 index 00000000000..e0c3f1c7c4c --- /dev/null +++ b/src/components/combo_box/types.ts @@ -0,0 +1,31 @@ +import { ButtonHTMLAttributes } from 'react'; +import { CommonProps } from '../common'; + +// note similarity to `Option` in `components/selectable/types.tsx` +export type EuiComboBoxOptionOption< + T = string | number | string[] | undefined +> = CommonProps & + Omit, 'value'> & { + isGroupLabelOption?: boolean; + label: string; + options?: Array>; + value?: T; + }; + +export type UpdatePositionHandler = ( + listElement?: RefInstance +) => void; +export type OptionHandler = (option: EuiComboBoxOptionOption) => void; + +// See https://github.com/DefinitelyTyped/DefinitelyTyped/pull/42482/files +export type RefCallback = { + bivarianceHack(instance: T | null): void; +}['bivarianceHack']; + +export type RefInstance = T | null; + +export type EuiComboBoxOptionsListPosition = 'top' | 'bottom'; + +export interface EuiComboBoxSingleSelectionShape { + asPlainText?: boolean; +} diff --git a/src/components/index.d.ts b/src/components/index.d.ts index 24be6528a3f..e69343571bb 100644 --- a/src/components/index.d.ts +++ b/src/components/index.d.ts @@ -1,4 +1,3 @@ -/// /// /// diff --git a/yarn.lock b/yarn.lock index 3b78ccecb33..ac1d628ff44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1307,6 +1307,13 @@ dependencies: "@types/react" "*" +"@types/react-input-autosize@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/react-input-autosize/-/react-input-autosize-2.0.1.tgz#04ac4b532128c98532352042b6c7af8b5d3f52ca" + integrity sha512-vsmqA6Pp5IyMZv3C7x7mNM8lqikfWQ4lCWsoVk/w+HZ2Y3KOdFk6ad4jsQeltXn/UaO+YUZLtWdMIjLLIeCxBA== + dependencies: + "@types/react" "*" + "@types/react-is@^16.7.1": version "16.7.1" resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-16.7.1.tgz#d3f1c68c358c00ce116b55ef5410cf486dd08539" @@ -1335,6 +1342,11 @@ resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.1.tgz#9b7cdae9cdc8b1a7020ca7588018dac64c770866" integrity sha512-5/bJS/uGB5kmpRrrAWXQnmyKlv+4TlPn4f+A2NBa93p+mt6Ht+YcNGkQKf8HMx28a9hox49ZXShtbGqZkk41Sw== +"@types/sinon@^7.5.1": + version "7.5.1" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.5.1.tgz#d27b81af0d1cfe1f9b24eebe7a24f74ae40f5b7c" + integrity sha512-EZQUP3hSZQyTQRfiLqelC9NMWd1kqLcmQE0dMiklxBkgi84T+cHOhnKpgk4NnOWpGX863yE6+IaGnOXUNFqDnQ== + "@types/tabbable@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@types/tabbable/-/tabbable-3.1.0.tgz#540d4c2729872560badcc220e73c9412c1d2bffe"