diff --git a/CHANGELOG.md b/CHANGELOG.md index 222fb0791f8..d6dbd5e95e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,29 @@ # [`master`](https://github.com/elastic/eui/tree/master) -No public interface changes since `0.0.36`. +- Added `EuiComboBox` for selecting many options from a list of options ([567](https://github.com/elastic/eui/pull/567)) +- Added `EuiHighlight` for highlighting a substring within text ([567](https://github.com/elastic/eui/pull/567)) +- `calculatePopoverPosition` service now accepts a `positions` argument so you can specify which positions are acceptable ([567](https://github.com/elastic/eui/pull/567)) +- Added `closeButtonProps` prop to `EuiBadge`, `hollow` badge type, and support for arbitrary hex color ([567](https://github.com/elastic/eui/pull/567)) +- Added support for arbitrary hex color to `EuiIcon` ([567](https://github.com/elastic/eui/pull/567)) + +**Breaking changes** + +- Renamed `euiBody-hasToolTip` class to `euiBody-hasPortalContent` ([567](https://github.com/elastic/eui/pull/567)) # [`0.0.36`](https://github.com/elastic/eui/tree/v0.0.36) -- Relaxed query syntax of `EuiSearchBar` to allow usage of hyphens without escaping ([#581](https://github.com/elastic/eui/pull/581)) - Added support for range queries in `EuiSearchBar` (works for numeric and date values) ([#485](https://github.com/elastic/eui/pull/485)) - Added support for emitting a `EuiSearchBar` query to an Elasticsearch query string ([#598](https://github.com/elastic/eui/pull/598)) -- Add support for expandable rows to `EuiBasicTable` ([#585](https://github.com/elastic/eui/pull/585)) +- Added support for expandable rows to `EuiBasicTable` ([#585](https://github.com/elastic/eui/pull/585)) **Bug fixes** -- Fix font-weight issue in K6 theme ([#596](https://github.com/elastic/eui/pull/596)) + +- Relaxed query syntax of `EuiSearchBar` to allow usage of hyphens without escaping ([#581](https://github.com/elastic/eui/pull/581)) +- Fixed font-weight issue in K6 theme ([#596](https://github.com/elastic/eui/pull/596)) # [`0.0.35`](https://github.com/elastic/eui/tree/v0.0.35) -- Modified `link` and all buttons to support both href and onClick ([#554](https://github.com/elastic/eui/pull/554)) +- Modified `EuiLink` and all buttons to support both href and onClick ([#554](https://github.com/elastic/eui/pull/554)) - Added `color` prop to `EuiIconTip` ([#580](https://github.com/elastic/eui/pull/580)) # [`0.0.34`](https://github.com/elastic/eui/tree/v0.0.34) diff --git a/generator-eui/documentation/templates/documentation_page.js b/generator-eui/documentation/templates/documentation_page.js index 7c19c54f1b7..32758cffcc6 100644 --- a/generator-eui/documentation/templates/documentation_page.js +++ b/generator-eui/documentation/templates/documentation_page.js @@ -31,7 +31,7 @@ export const <%= componentExampleName %>Example = { Description needed: how to use the Eui<%= componentExampleName %> component.

), - components: { <%= componentName %> }, + props: { <%= componentName %> }, demo: <<%= componentExampleName %> />, }], }; diff --git a/package.json b/package.json index db748e2adba..ec98e182ed2 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "prop-types": "^15.6.0", "react-ace": "^5.5.0", "react-color": "^2.13.8", + "react-input-autosize": "^2.2.1", "serve": "^6.3.1", "tabbable": "^1.1.0", "uuid": "^3.1.0" diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 0075e9a41ed..7de30ebd36e 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -74,6 +74,9 @@ import { CodeExample } import { ColorPickerExample } from './views/color_picker/color_picker_example'; +import { ComboBoxExample } + from './views/combo_box/combo_box_example'; + import { ContextMenuExample } from './views/context_menu/context_menu_example'; @@ -113,6 +116,9 @@ import { HeaderExample } import { HealthExample } from './views/health/health_example'; +import { HighlightExample } + from './views/highlight/highlight_example'; + import { HorizontalRuleExample } from './views/horizontal_rule/horizontal_rule_example'; @@ -302,6 +308,7 @@ const navigation = [{ FormLayoutsExample, FormControlsExample, FormValidationExample, + ComboBoxExample, ColorPickerExample, CodeEditorExample, ExpressionExample, @@ -314,6 +321,7 @@ const navigation = [{ AccessibilityExample, DelayHideExample, ErrorBoundaryExample, + HighlightExample, IsColorDarkExample, OutsideClickDetectorExample, PortalExample, diff --git a/src-docs/src/views/badge/badge.js b/src-docs/src/views/badge/badge.js index 6117de2b2cc..0bc8fea00ab 100644 --- a/src-docs/src/views/badge/badge.js +++ b/src-docs/src/views/badge/badge.js @@ -8,6 +8,7 @@ import { const badges = [ 'default', + 'hollow', 'primary', 'secondary', 'accent', diff --git a/src-docs/src/views/combo_box/async.js b/src-docs/src/views/combo_box/async.js new file mode 100644 index 00000000000..050cc376f0e --- /dev/null +++ b/src-docs/src/views/combo_box/async.js @@ -0,0 +1,114 @@ +import React, { Component } from 'react'; + +import { + EuiComboBox, +} from '../../../../src/components'; + +const allOptions = [{ + label: 'Titan', + 'data-test-subj': 'titanOption', +}, { + label: 'Enceladus', +}, { + label: 'Mimas', +}, { + label: 'Dione', +}, { + label: 'Iapetus', +}, { + label: 'Phoebe', +}, { + label: 'Rhea', +}, { + label: 'Pandora is one of Saturn\'s moons, named for a Titaness of Greek mythology', +}, { + label: 'Tethys', +}, { + label: 'Hyperion', +}]; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + isLoading: false, + isPopoverOpen: false, + selectedOptions: [], + options: [], + }; + } + + onChange = (selectedOptions) => { + this.setState({ + selectedOptions, + }); + }; + + onSearchChange = (searchValue) => { + this.setState({ + isLoading: true, + options: [], + }); + + clearTimeout(this.searchTimeout); + + this.searchTimeout = setTimeout(() => { + // Simulate a remotely-executed search. + this.setState({ + isLoading: false, + options: allOptions.filter(option => option.label.toLowerCase().includes(searchValue.toLowerCase())), + }); + }, 1200); + } + + onCreateOption = (searchValue, flattenedOptions) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption = { + label: searchValue, + }; + + // Create the option if it doesn't exist. + if (flattenedOptions.findIndex(option => + option.value.trim().toLowerCase() === normalizedSearchValue + ) === -1) { + // Simulate creating this option on the server. + allOptions.push(newOption); + this.setState(prevState => ({ + options: prevState.options.concat(newOption), + })); + } + + // Select the option. + this.setState(prevState => ({ + selectedOptions: prevState.selectedOptions.concat(newOption), + })); + }; + + componentDidMount() { + // Simulate initial load. + this.onSearchChange(''); + } + + render() { + const { selectedOptions, isLoading, options } = this.state; + + return ( + + ); + } +} diff --git a/src-docs/src/views/combo_box/colors.js b/src-docs/src/views/combo_box/colors.js new file mode 100644 index 00000000000..0c4dafefb77 --- /dev/null +++ b/src-docs/src/views/combo_box/colors.js @@ -0,0 +1,92 @@ +import React, { Component } from 'react'; + +import { + EuiComboBox, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [{ + label: 'Titan', + 'data-test-subj': 'titanOption', + color: 'primary', + }, { + label: 'Enceladus', + color: 'secondary', + }, { + label: 'Mimas', + color: '#DB1374', + }, { + label: 'Dione', + color: 'accent', + }, { + label: 'Iapetus', + color: 'primary', + color: 'warning', + }, { + label: 'Phoebe', + color: 'danger', + }, { + label: 'Rhea', + color: 'default', + }, { + label: 'Pandora is one of Saturn\'s moons, named for a Titaness of Greek mythology', + color: '#F98510', + }, { + label: 'Tethys', + color: '#FEB6DB', + }, { + label: 'Hyperion', + color: '#BFA180', + }]; + + this.state = { + selectedOptions: [this.options[2], this.options[4]], + }; + } + + onChange = (selectedOptions) => { + this.setState({ + selectedOptions, + }); + }; + + onCreateOption =(searchValue, flattenedOptions) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption = { + label: searchValue, + }; + + // Create the option if it doesn't exist. + if (flattenedOptions.findIndex(option => + option.value.trim().toLowerCase() === normalizedSearchValue + ) === -1) { + this.options.push(newOption); + } + + // Select the option. + this.setState(prevState => ({ + selectedOptions: prevState.selectedOptions.concat(newOption), + })); + }; + + render() { + const { selectedOptions } = this.state; + return ( + + ); + } +} diff --git a/src-docs/src/views/combo_box/combo_box.js b/src-docs/src/views/combo_box/combo_box.js new file mode 100644 index 00000000000..bd01a4bae0b --- /dev/null +++ b/src-docs/src/views/combo_box/combo_box.js @@ -0,0 +1,81 @@ +import React, { Component } from 'react'; + +import { + EuiComboBox, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [{ + label: 'Titan', + 'data-test-subj': 'titanOption', + }, { + label: 'Enceladus', + }, { + label: 'Mimas', + }, { + label: 'Dione', + }, { + label: 'Iapetus', + }, { + label: 'Phoebe', + }, { + label: 'Rhea', + }, { + label: 'Pandora is one of Saturn\'s moons, named for a Titaness of Greek mythology', + }, { + label: 'Tethys', + }, { + label: 'Hyperion', + }]; + + this.state = { + selectedOptions: [this.options[2], this.options[4]], + }; + } + + onChange = (selectedOptions) => { + this.setState({ + selectedOptions, + }); + }; + + onCreateOption = (searchValue, flattenedOptions) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption = { + label: searchValue, + }; + + // Create the option if it doesn't exist. + if (flattenedOptions.findIndex(option => + option.label.trim().toLowerCase() === normalizedSearchValue + ) === -1) { + this.options.push(newOption); + } + + // Select the option. + this.setState(prevState => ({ + selectedOptions: prevState.selectedOptions.concat(newOption), + })); + }; + + render() { + const { selectedOptions } = this.state; + return ( + + ); + } +} diff --git a/src-docs/src/views/combo_box/combo_box_example.js b/src-docs/src/views/combo_box/combo_box_example.js new file mode 100644 index 00000000000..e35a35c5ec0 --- /dev/null +++ b/src-docs/src/views/combo_box/combo_box_example.js @@ -0,0 +1,235 @@ +import React, { Fragment } from 'react'; + +import { + Link, +} from 'react-router'; + +import { renderToHtml } from '../../services'; + +import { + GuideSectionTypes, +} from '../../components'; + +import { + EuiCallOut, + EuiCode, + EuiComboBox, + EuiSpacer, + EuiText, +} from '../../../../src/components'; + +import ComboBox from './combo_box'; +const comboBoxSource = require('!!raw-loader!./combo_box'); +const comboBoxHtml = renderToHtml(ComboBox); + +import Containers from './containers'; +const containersSource = require('!!raw-loader!./containers'); +const containersHtml = renderToHtml(Containers); + +import Colors from './colors'; +const colorsSource = require('!!raw-loader!./colors'); +const colorsHtml = renderToHtml(Colors); + +import RenderOption from './render_option'; +const renderOptionSource = require('!!raw-loader!./render_option'); +const renderOptionHtml = renderToHtml(RenderOption); + +import Groups from './groups'; +const groupsSource = require('!!raw-loader!./groups'); +const groupsHtml = renderToHtml(Groups); + +import SingleSelection from './single_selection'; +const singleSelectionSource = require('!!raw-loader!./single_selection'); +const singleSelectionHtml = renderToHtml(SingleSelection); + +import DisallowCustomOptions from './disallow_custom_options'; +const disallowCustomOptionsSource = require('!!raw-loader!./disallow_custom_options'); +const disallowCustomOptionsHtml = renderToHtml(DisallowCustomOptions); + +import CustomOptionsOnly from './custom_options_only'; +const customOptionsOnlySource = require('!!raw-loader!./custom_options_only'); +const customOptionsOnlyHtml = renderToHtml(CustomOptionsOnly); + +import Async from './async'; +const asyncSource = require('!!raw-loader!./async'); +const asyncHtml = renderToHtml(Async); + +export const ComboBoxExample = { + title: 'Combo Box', + intro: ( + + +

+ Use a EuiComboBox when the input has so many options that the user + needs to be able to search them, the user needs to be able to select multiple options, + and/or the user should have the ability to specify + a custom value in addition to selecting from a predetermined list. +

+
+ + + + +

+ The combo box will have errors if any of the options you pass to it share the same label + property. It’s OK if options have duplicate values, though. This is because the label + is the only thing the combo box is concerned about, since this is what the user sees + and what is matched against when the user searches. +

+
+ + +
+ ), + sections: [{ + source: [{ + type: GuideSectionTypes.JS, + code: comboBoxSource, + }, { + type: GuideSectionTypes.HTML, + code: comboBoxHtml, + }], + props: { EuiComboBox }, + demo: , + }, { + title: 'Containers', + source: [{ + type: GuideSectionTypes.JS, + code: containersSource, + }, { + type: GuideSectionTypes.HTML, + code: containersHtml, + }], + text: ( +

+ This example demonstrates how the combo box works within containers. Because this component + uses portals, it’s important that it works within other portal-using components. +

+ ), + props: { EuiComboBox }, + demo: , + }, { + title: 'Pill colors', + source: [{ + type: GuideSectionTypes.JS, + code: colorsSource, + }, { + type: GuideSectionTypes.HTML, + code: colorsHtml, + }], + text: ( +

+ Useful for visualization or tagging systems. You can also pass a color in + your option list. The color can be a hex value + (like #000) or any other named color value accepted by + the Badge component. +

+ ), + props: { EuiComboBox }, + demo: , + }, { + title: 'Option rendering', + source: [{ + type: GuideSectionTypes.JS, + code: renderOptionSource, + }, { + type: GuideSectionTypes.HTML, + code: renderOptionHtml, + }], + text: ( +

+ You can provide a renderOption prop which will accept option + and searchValue arguments. Use the value prop of the + option object to store metadata about the option for use in this callback. +

+ ), + props: { EuiComboBox }, + demo: , + }, { + title: 'Groups', + source: [{ + type: GuideSectionTypes.JS, + code: groupsSource, + }, { + type: GuideSectionTypes.HTML, + code: groupsHtml, + }], + text: ( +

+ You can group options together. The groups won’t match against the search value. +

+ ), + props: { EuiComboBox }, + demo: , + }, { + title: 'Single selection', + source: [{ + type: GuideSectionTypes.JS, + code: singleSelectionSource, + }, { + type: GuideSectionTypes.HTML, + code: singleSelectionHtml, + }], + text: ( +

+ To only allow the user to select a single option, provide + the singleSelection prop. +

+ ), + props: { EuiComboBox }, + demo: , + }, { + title: 'Disallowing custom options', + source: [{ + type: GuideSectionTypes.JS, + code: disallowCustomOptionsSource, + }, { + type: GuideSectionTypes.HTML, + code: disallowCustomOptionsHtml, + }], + text: ( +

+ Leave out the onCreateOption prop to disallow the creation of custom options. +

+ ), + props: { EuiComboBox }, + demo: , + }, { + title: 'Hiding suggestions', + source: [{ + type: GuideSectionTypes.JS, + code: customOptionsOnlySource, + }, { + type: GuideSectionTypes.HTML, + code: customOptionsOnlyHtml, + }], + text: ( +

+ Alternatively, provide thhe noSuggestions prop to hide the suggestions list + and only allow the creation of custom options. +

+ ), + props: { EuiComboBox }, + demo: , + }, { + title: 'Async', + source: [{ + type: GuideSectionTypes.JS, + code: asyncSource, + }, { + type: GuideSectionTypes.HTML, + code: asyncHtml, + }], + text: ( +

+ Use the onSearchChange code to handle searches asynchronously. Use the + isLoading prop to let the user know that something async is happening. +

+ ), + props: { EuiComboBox }, + demo: , + }], +}; diff --git a/src-docs/src/views/combo_box/containers.js b/src-docs/src/views/combo_box/containers.js new file mode 100644 index 00000000000..f0171aa0002 --- /dev/null +++ b/src-docs/src/views/combo_box/containers.js @@ -0,0 +1,175 @@ +import React, { Component, Fragment } from 'react'; + +import { + EuiComboBox, + EuiButton, + EuiPopover, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSpacer, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [{ + label: 'Titan', + 'data-test-subj': 'titanOption', + }, { + label: 'Enceladus', + }, { + label: 'Mimas', + }, { + label: 'Dione', + }, { + label: 'Iapetus', + }, { + label: 'Phoebe', + }, { + label: 'Rhea', + }, { + label: 'Pandora is one of Saturn\'s moons, named for a Titaness of Greek mythology', + }, { + label: 'Tethys', + }, { + label: 'Hyperion', + }]; + + this.state = { + selectedOptions: [this.options[2], this.options[4]], + isModalVisible: false, + isPopoverOpen: false, + }; + } + + closeModal = () => { + this.setState({ isModalVisible: false }); + } + + showModal = () => { + this.setState({ isModalVisible: true }); + } + + togglePopover = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + onChange = (selectedOptions) => { + this.setState({ + selectedOptions, + }); + }; + + onCreateOption = (searchValue, flattenedOptions) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption = { + label: searchValue, + }; + + // Create the option if it doesn't exist. + if (flattenedOptions.findIndex(option => + option.value.trim().toLowerCase() === normalizedSearchValue + ) === -1) { + this.options.push(newOption); + } + + // Select the option. + this.setState(prevState => ({ + selectedOptions: prevState.selectedOptions.concat(newOption), + })); + }; + + render() { + const { selectedOptions, isModalVisible, isPopoverOpen } = this.state; + + const comboBox = ( + + ); + + const button = ( + + Open popover + + ); + + let modal; + + if (isModalVisible) { + modal = ( + + + + + Combo box in a modal + + + + + {comboBox} + + + + ); + } + + return ( + + + {comboBox} + + + +
{comboBox}
+
+ + + + + Show modal + + + {modal} +
+ ); + } +} diff --git a/src-docs/src/views/combo_box/custom_options_only.js b/src-docs/src/views/combo_box/custom_options_only.js new file mode 100644 index 00000000000..a2d6bed25c0 --- /dev/null +++ b/src-docs/src/views/combo_box/custom_options_only.js @@ -0,0 +1,51 @@ +import React, { Component } from 'react'; + +import { + EuiComboBox, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + selectedOptions: [], + }; + } + + onCreateOption = (searchValue) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption = { + label: searchValue, + }; + + // Select the option. + this.setState(prevState => ({ + selectedOptions: prevState.selectedOptions.concat(newOption), + })); + }; + + onChange = (selectedOptions) => { + this.setState({ + selectedOptions, + }); + }; + + render() { + const { selectedOptions } = this.state; + return ( + + ); + } +} diff --git a/src-docs/src/views/combo_box/disallow_custom_options.js b/src-docs/src/views/combo_box/disallow_custom_options.js new file mode 100644 index 00000000000..f3448ac5c0e --- /dev/null +++ b/src-docs/src/views/combo_box/disallow_custom_options.js @@ -0,0 +1,55 @@ +import React, { Component } from 'react'; + +import { + EuiComboBox, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [{ + label: 'Titan', + 'data-test-subj': 'titanOption', + }, { + label: 'Enceladus', + }, { + label: 'Mimas', + }, { + label: 'Dione', + }, { + label: 'Iapetus', + }, { + label: 'Phoebe', + }, { + label: 'Rhea', + }, { + label: 'Pandora is one of Saturn\'s moons, named for a Titaness of Greek mythology', + }, { + label: 'Tethys', + }, { + label: 'Hyperion', + }]; + + this.state = { + selectedOptions: [this.options[2], this.options[4]], + }; + } + + onChange = (selectedOptions) => { + this.setState({ + selectedOptions, + }); + }; + + render() { + return ( + + ); + } +} diff --git a/src-docs/src/views/combo_box/groups.js b/src-docs/src/views/combo_box/groups.js new file mode 100644 index 00000000000..55bca665e41 --- /dev/null +++ b/src-docs/src/views/combo_box/groups.js @@ -0,0 +1,92 @@ +import React, { Component } from 'react'; + +import { + EuiComboBox, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + const colorGroup = { + label: 'Colors', + options: [{ + label: 'Red', + }, { + label: 'Blue', + }, { + label: 'Yellow', + }, { + label: 'Green', + }], + }; + + const soundGroup = { + label: 'Sounds', + options: [{ + label: 'Pop', + }, { + label: 'Hiss', + }, { + label: 'Screech', + }, { + label: 'Ding', + }], + }; + + this.options = [colorGroup, soundGroup]; + + this.state = { + selectedOptions: [colorGroup.options[2], soundGroup.options[3]], + }; + } + + onChange = (selectedOptions) => { + this.setState({ + selectedOptions, + }); + }; + + onCreateOption = (searchValue, flattenedOptions) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption = { + label: searchValue, + }; + + // Create the option if it doesn't exist. + if (flattenedOptions.findIndex(option => + option.value.trim().toLowerCase() === normalizedSearchValue + ) === -1) { + if (this.options[this.options.length - 1].label !== 'Custom') { + this.options.push({ + label: 'Custom', + options: [], + }); + } + + this.options[this.options.length - 1].options.push(newOption); + } + + // Select the option. + this.setState(prevState => ({ + selectedOptions: prevState.selectedOptions.concat(newOption), + })); + }; + + render() { + return ( + + ); + } +} diff --git a/src-docs/src/views/combo_box/render_option.js b/src-docs/src/views/combo_box/render_option.js new file mode 100644 index 00000000000..2d39f806f25 --- /dev/null +++ b/src-docs/src/views/combo_box/render_option.js @@ -0,0 +1,141 @@ +import React, { Component } from 'react'; + +import { + EuiComboBox, + EuiHighlight, + EuiHealth, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [{ + value: { + size: 5, + }, + label: 'Titan', + 'data-test-subj': 'titanOption', + color: 'primary', + }, { + value: { + size: 2, + }, + label: 'Enceladus', + color: 'secondary', + }, { + value: { + size: 15, + }, + label: 'Mimas', + color: '#DB1374', + }, { + value: { + size: 1, + }, + label: 'Dione', + color: 'accent', + }, { + value: { + size: 8, + }, + label: 'Iapetus', + color: 'primary', + color: 'warning', + }, { + value: { + size: 2, + }, + label: 'Phoebe', + color: 'danger', + }, { + value: { + size: 33, + }, + label: 'Rhea', + color: 'default', + }, { + value: { + size: 18, + }, + label: 'Pandora is one of Saturn\'s moons, named for a Titaness of Greek mythology', + color: '#F98510', + }, { + value: { + size: 9, + }, + label: 'Tethys', + color: '#FEB6DB', + }, { + value: { + size: 4, + }, + label: 'Hyperion', + color: '#BFA180', + }]; + + this.state = { + selectedOptions: [this.options[2], this.options[4]], + }; + } + + onChange = (selectedOptions) => { + this.setState({ + selectedOptions, + }); + }; + + onCreateOption =(searchValue, flattenedOptions) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + + if (!normalizedSearchValue) { + return; + } + + const newOption = { + value: searchValue, + label: searchValue, + }; + + // Create the option if it doesn't exist. + if (flattenedOptions.findIndex(option => + option.value.trim().toLowerCase() === normalizedSearchValue + ) === -1) { + this.options.push(newOption); + } + + // Select the option. + this.setState(prevState => ({ + selectedOptions: prevState.selectedOptions.concat(newOption), + })); + }; + + renderOption = (option, searchValue) => { + const { color, label, value } = option; + return ( + + + + {label} + +   + ({value.size}) + + + ); + }; + + render() { + const { selectedOptions } = this.state; + return ( + + ); + } +} diff --git a/src-docs/src/views/combo_box/single_selection.js b/src-docs/src/views/combo_box/single_selection.js new file mode 100644 index 00000000000..e178b63040a --- /dev/null +++ b/src-docs/src/views/combo_box/single_selection.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react'; + +import { + EuiComboBox, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [{ + label: 'Titan', + 'data-test-subj': 'titanOption', + }, { + label: 'Enceladus', + }, { + label: 'Mimas', + }, { + label: 'Dione', + }, { + label: 'Iapetus', + }, { + label: 'Phoebe', + }, { + label: 'Rhea', + }, { + label: 'Pandora is one of Saturn\'s moons, named for a Titaness of Greek mythology', + }, { + label: 'Tethys', + }, { + label: 'Hyperion', + }]; + + this.state = { + selectedOptions: undefined, + }; + } + + onChange = (selectedOptions) => { + // We should only get back either 0 or 1 options. + this.setState({ + selectedOptions: selectedOptions, + }); + }; + + render() { + const { selectedOptions } = this.state; + return ( + + ); + } +} diff --git a/src-docs/src/views/highlight/highlight.js b/src-docs/src/views/highlight/highlight.js new file mode 100644 index 00000000000..a937bee8e24 --- /dev/null +++ b/src-docs/src/views/highlight/highlight.js @@ -0,0 +1,42 @@ +import React, { Component, Fragment } from 'react'; + +import { + EuiHighlight, + EuiFieldSearch, + EuiSpacer, +} from '../../../../src/components'; + +export class Highlight extends Component { + constructor(props) { + super(props); + + this.state = { + searchValue: 'jumped over', + }; + } + + onSearchChange = (e) => { + const searchValue = e.target.value; + this.setState({ + searchValue, + }); + } + + render() { + const { searchValue } = this.state; + return ( + + + + + + + The quick brown fox jumped over the lazy dog + + + ); + } +} diff --git a/src-docs/src/views/highlight/highlight_example.js b/src-docs/src/views/highlight/highlight_example.js new file mode 100644 index 00000000000..1171ec6ce50 --- /dev/null +++ b/src-docs/src/views/highlight/highlight_example.js @@ -0,0 +1,38 @@ +import React from 'react'; + +import { renderToHtml } from '../../services'; + +import { + GuideSectionTypes, +} from '../../components'; + +import { + EuiCode, + EuiHighlight, +} from '../../../../src/components'; + +import { Highlight } from './highlight'; +const highlightSource = require('!!raw-loader!./highlight'); +const highlightHtml = renderToHtml(Highlight); + +export const HighlightExample = { + title: 'Highlight', + sections: [{ + title: 'Highlight', + source: [{ + type: GuideSectionTypes.JS, + code: highlightSource, + }, { + type: GuideSectionTypes.HTML, + code: highlightHtml, + }], + text: ( +

+ Use EuiHighlight to highlight substrings within a string, typically in + response to user input. +

+ ), + components: { EuiHighlight }, + demo: , + }], +}; diff --git a/src-docs/src/views/icon/icon_colors.js b/src-docs/src/views/icon/icon_colors.js index 075d992c539..ceb9a99480c 100644 --- a/src-docs/src/views/icon/icon_colors.js +++ b/src-docs/src/views/icon/icon_colors.js @@ -18,6 +18,8 @@ const iconColors = [ 'text', 'subdued', 'ghost', + '#490', + '#F98510', ]; export default () => ( diff --git a/src-docs/src/views/icon/icon_example.js b/src-docs/src/views/icon/icon_example.js index bc038675c68..3813b20e169 100644 --- a/src-docs/src/views/icon/icon_example.js +++ b/src-docs/src/views/icon/icon_example.js @@ -147,7 +147,8 @@ export const IconExample = { }], text: (

- Use the color prop to assign a color for your icons. + Use the color prop to assign a color for your icons. It + can accept named colors from our pallete or a three or six color hex code. The default behavior is to inherit the text color as the SVG color fill property via currentColor in CSS.

diff --git a/src/components/accordion/__snapshots__/accordion.test.js.snap b/src/components/accordion/__snapshots__/accordion.test.js.snap index a9fc7b00761..813c6c91220 100644 --- a/src/components/accordion/__snapshots__/accordion.test.js.snap +++ b/src/components/accordion/__snapshots__/accordion.test.js.snap @@ -57,6 +57,11 @@ exports[`EuiAccordion behavior closes when clicked twice 1`] = ` - + Content @@ -23,7 +25,9 @@ exports[`EuiBadge props color accent is rendered 1`] = ` - + Content @@ -37,7 +41,9 @@ exports[`EuiBadge props color danger is rendered 1`] = ` - + Content @@ -51,7 +57,25 @@ exports[`EuiBadge props color default is rendered 1`] = ` - + + Content + + + +`; + +exports[`EuiBadge props color hollow is rendered 1`] = ` + + + Content @@ -65,7 +89,9 @@ exports[`EuiBadge props color primary is rendered 1`] = ` - + Content @@ -79,7 +105,9 @@ exports[`EuiBadge props color secondary is rendered 1`] = ` - + Content @@ -93,7 +121,9 @@ exports[`EuiBadge props color warning is rendered 1`] = ` - + Content @@ -108,7 +138,7 @@ exports[`EuiBadge props iconSide left is rendered 1`] = ` class="euiBadge__content" > - + Content @@ -140,7 +172,7 @@ exports[`EuiBadge props iconSide right is rendered 1`] = ` class="euiBadge__content" > - + Content @@ -172,7 +206,7 @@ exports[`EuiBadge props iconType is rendered 1`] = ` class="euiBadge__content" > - + Content diff --git a/src/components/badge/_badge.scss b/src/components/badge/_badge.scss index 207d31a9836..b635706cb6f 100644 --- a/src/components/badge/_badge.scss +++ b/src/components/badge/_badge.scss @@ -1,16 +1,22 @@ +/** + * 1. Accounts for the border + */ .euiBadge { font-size: $euiSizeM; font-weight: $euiFontWeightMedium; - line-height: $euiSize + $euiSizeXS; + line-height: $euiSize + 2px; /* 1 */ display: inline-block; text-decoration: none; box-sizing: content-box; border-radius: $euiBorderRadius / 2; - padding: 0 $euiSizeXS; + border: solid 1px transparent; + padding: 0 $euiSizeS; background-color: transparent; white-space: nowrap; vertical-align: middle; text-align: center; + white-space: nowrap; + overflow: hidden; + .euiBadge { margin-left: $euiSizeXS; @@ -19,6 +25,13 @@ .euiBadge__content { display: flex; align-items: center; + overflow: hidden; + } + + .euiBadge__text { + overflow: hidden; + text-overflow: ellipsis; + flex: 1 1 auto; } &:focus { @@ -26,6 +39,7 @@ } .euiBadge__icon { + flex: 0 0 auto; margin-right: $euiSizeXS; &:focus { @@ -66,3 +80,10 @@ $badgeTypes: ( } } } + +// Hollow has a border and is mostly used for autocompleters. +.euiBadge--hollow { + background-color: $euiColorEmptyShade; + border-color: $euiBorderColor; + color: $euiTextColor; +} diff --git a/src/components/badge/badge.js b/src/components/badge/badge.js index c7c910d08ac..52844b3165f 100644 --- a/src/components/badge/badge.js +++ b/src/components/badge/badge.js @@ -17,6 +17,7 @@ const colorToClassNameMap = { accent: 'euiBadge--accent', warning: 'euiBadge--warning', danger: 'euiBadge--danger', + hollow: 'euiBadge--hollow', }; export const COLORS = Object.keys(colorToClassNameMap); @@ -36,6 +37,7 @@ export const EuiBadge = ({ className, onClick, iconOnClick, + closeButtonProps, ...rest }) => { @@ -69,7 +71,7 @@ export const EuiBadge = ({ if (iconOnClick) { optionalIcon = ( - + ); @@ -105,7 +107,7 @@ export const EuiBadge = ({ > {optionalIcon} - + {children} @@ -114,6 +116,16 @@ export const EuiBadge = ({ } }; +function checkValidColor(props, propName, componentName) { + const validHex = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(props.color); + if (props.color && !validHex && !COLORS.includes(props.color)) { + throw new Error( + `${componentName} needs to pass a valid color. This can either be a three ` + + `or six character hex value or one of the following: ${COLORS}` + ); + } +} + EuiBadge.propTypes = { children: PropTypes.node, className: PropTypes.string, @@ -140,7 +152,12 @@ EuiBadge.propTypes = { /** * Accepts either our palette colors (primary, secondary ..etc) or a hex value `#FFFFFF`, `#000`. */ - color: PropTypes.string, + color: checkValidColor, + + /** + * Props passed to the close button. + */ + closeButtonProps: PropTypes.object, }; EuiBadge.defaultProps = { diff --git a/src/components/button/__snapshots__/button.test.js.snap b/src/components/button/__snapshots__/button.test.js.snap index b010733d217..ee3804c9c2b 100644 --- a/src/components/button/__snapshots__/button.test.js.snap +++ b/src/components/button/__snapshots__/button.test.js.snap @@ -136,7 +136,7 @@ exports[`EuiButton props iconSide left is rendered 1`] = ` >
+
+
+ +
+
+
+ + + + + + +
+
+`; diff --git a/src/components/combo_box/_combo_box.scss b/src/components/combo_box/_combo_box.scss new file mode 100644 index 00000000000..6447beb2f3b --- /dev/null +++ b/src/components/combo_box/_combo_box.scss @@ -0,0 +1,57 @@ +.euiComboBox { + @include euiFormControlSize; + position: relative; + + /** + * 1. Allow pills to truncate their text with an ellipsis. + * 2. Don't allow pills to overlap with the caret. + * 3. + */ + .euiComboBox__inputWrap { + @include euiFormControlStyle; + @include euiFormControlWithIcon($isIconOptional: true); + $padding: $euiSizeXS; + display: flex; /* 1 */ + flex-wrap: wrap; /* 1 */ + padding: $padding $euiSizeXXL $padding $padding; /* 2 */ + align-content: flex-start; + + &:hover { + cursor: text; + } + } + + /** + * 1. Force field height to match other field heights. + * 2. Force input height to expand tp fill this element. + * 3. Reset appearance on Safari. + * 4. Fix react-input-autosize appearance. + */ + .euiComboBox__input { + display: inline-flex !important; /* 1 */ + height: 32px; /* 2 */ + + > input { + appearance: none; /* 3 */ + padding: 0; + border: none; + background: transparent; + font-size: $euiFontSizeS; + font-family: $euiFontFamily; + color: $euiTextColor; + margin: $euiSizeXS; + line-height: $euiLineHeight; /* 4 */ + } + } + + &.euiComboBox-isOpen { + .euiComboBox__inputWrap { + background: $euiColorEmptyShade; + box-shadow: + 0 4px 4px -2px rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(0,0,0,0.16), + inset 0 0 0 0 $euiColorEmptyShade, + inset 0 -2px 0 0 $euiColorPrimary; + } + } +} diff --git a/src/components/combo_box/_index.scss b/src/components/combo_box/_index.scss new file mode 100644 index 00000000000..29040a0addd --- /dev/null +++ b/src/components/combo_box/_index.scss @@ -0,0 +1,3 @@ +@import 'combo_box'; +@import 'combo_box_input/index'; +@import 'combo_box_options_list/index'; diff --git a/src/components/combo_box/combo_box.js b/src/components/combo_box/combo_box.js new file mode 100644 index 00000000000..fb2bc8bacf6 --- /dev/null +++ b/src/components/combo_box/combo_box.js @@ -0,0 +1,498 @@ +/** + * 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. + */ + +import React, { + Component, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import tabbable from 'tabbable'; + +import { comboBoxKeyCodes, calculatePopoverPosition } 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 { + getMatchingOptions, + flattenOptionGroups, + getSelectedOptionForSearchValue, +} from './matching_options'; + +export class EuiComboBox extends Component { + static propTypes = { + id: PropTypes.string, + className: PropTypes.string, + placeholder: PropTypes.string, + isLoading: PropTypes.bool, + async: PropTypes.bool, + singleSelection: PropTypes.bool, + noSuggestions: PropTypes.bool, + options: PropTypes.array, + selectedOptions: PropTypes.array, + onChange: PropTypes.func, + onSearchChange: PropTypes.func, + onCreateOption: PropTypes.func, + renderOption: PropTypes.func, + } + + static defaultProps = { + options: [], + selectedOptions: [], + } + + constructor(props) { + super(props); + + const initialSearchValue = ''; + const { options, selectedOptions } = props; + const { matchingOptions, optionToGroupMap } = this.getMatchingOptions(options, selectedOptions, initialSearchValue); + + this.state = { + searchValue: initialSearchValue, + isListOpen: false, + listPosition: 'bottom', + listStyles: {}, + }; + + // Cached derived state. + this.matchingOptions = matchingOptions; + this.optionToGroupMap = optionToGroupMap; + this.activeOptionIndex = undefined; + this.listBounds = undefined; + + // Refs. + this.comboBox = undefined; + this.autoSizeInput = undefined; + this.searchInput = undefined; + this.optionsList = undefined; + this.options = []; + } + + getMatchingOptions = (options, selectedOptions, searchValue) => { + // Assume the consumer has already filtered the options against the search value. + const isPreFiltered = this.props.async; + return getMatchingOptions(options, selectedOptions, searchValue, isPreFiltered); + }; + + openList = () => { + this.setState({ + isListOpen: true, + }); + }; + + closeList = () => { + this.clearActiveOption(); + this.setState({ + isListOpen: false, + }); + }; + + updateListPosition = (listBounds = this.listBounds) => { + if (!this.state.isListOpen) { + return; + } + + if (!listBounds) { + return; + } + + // Cache for future calls. + this.listBounds = listBounds; + const comboBoxBounds = this.comboBox.getBoundingClientRect(); + const { position, left, top } = calculatePopoverPosition(comboBoxBounds, listBounds, 'bottom', 0, ['bottom', 'top']); + + const listStyles = { + top: top + window.scrollY, + left, + }; + + this.setState({ + listPosition: position, + listStyles, + }); + }; + + tabAway = amount => { + const tabbableItems = tabbable(document); + const comboBoxIndex = tabbableItems.indexOf(this.searchInput); + + // Wrap to last tabbable if tabbing backwards. + if (amount < 0) { + if (comboBoxIndex === 0) { + tabbableItems[tabbableItems.length - 1].focus(); + return; + } + } + + // Wrap to first tabbable if tabbing forwards. + if (amount > 0) { + if (comboBoxIndex === tabbableItems.length - 1) { + tabbableItems[0].focus(); + return; + } + } + + tabbableItems[comboBoxIndex + amount].focus(); + }; + + incrementActiveOptionIndex = amount => { + // If there are no options available, reset the focus. + if (!this.matchingOptions.length) { + this.clearActiveOption(); + return; + } + + let nextActiveOptionIndex; + + if (!this.hasActiveOption()) { + // If this is the beginning of the user's keyboard navigation of the menu, then we'll focus + // either the first or last item. + nextActiveOptionIndex = amount < 0 ? this.options.length - 1 : 0; + } else { + nextActiveOptionIndex = this.activeOptionIndex + amount; + + if (nextActiveOptionIndex < 0) { + nextActiveOptionIndex = this.options.length - 1; + } else if (nextActiveOptionIndex === this.options.length) { + nextActiveOptionIndex = 0; + } + } + + this.activeOptionIndex = nextActiveOptionIndex; + this.focusActiveOption(); + }; + + hasActiveOption = () => { + return this.activeOptionIndex !== undefined; + }; + + clearActiveOption = () => { + this.activeOptionIndex = undefined; + }; + + focusActiveOption = () => { + // If an item is focused, focus it. + if (this.hasActiveOption()) { + this.options[this.activeOptionIndex].focus(); + } + }; + + clearSearchValue = () => { + this.onSearchChange(''); + }; + + removeLastOption = () => { + if (this.hasActiveOption()) { + return; + } + + if (!this.props.selectedOptions.length) { + return; + } + + // Backspace will be used to delete the input, not a pill. + if (this.state.searchValue.length) { + return; + } + + // Delete last pill. + this.onRemoveOption(this.props.selectedOptions[this.props.selectedOptions.length - 1]); + }; + + addCustomOption = () => { + if (this.doesSearchMatchOnlyOption()) { + this.options[0].click(); + return; + } + + if (!this.props.onCreateOption) { + return; + } + + // Don't create the value if it's already been selected. + if (getSelectedOptionForSearchValue(this.state.searchValue, this.props.selectedOptions)) { + return; + } + + // Add new custom pill if this is custom input, even if it partially matches an option.. + if (!this.hasActiveOption() || this.doesSearchMatchOnlyOption()) { + this.props.onCreateOption(this.state.searchValue, flattenOptionGroups(this.props.options)); + this.clearSearchValue(); + } + }; + + doesSearchMatchOnlyOption = () => { + const { searchValue } = this.state; + if (this.matchingOptions.length !== 1) { + return false; + } + return this.matchingOptions[0].label.toLowerCase() === searchValue.toLowerCase(); + }; + + areAllOptionsSelected = () => { + const { options, selectedOptions, async } = this.props; + // Assume if this is async then there could be infinite options. + if (async) { + return false; + } + return flattenOptionGroups(options).length === selectedOptions.length; + }; + + onFocusChange = event => { + // Close the list if the combo box has lost focus. + if ( + this.comboBox === event.target + || this.comboBox.contains(event.target) + || this.optionsList === event.target + || this.optionsList && this.optionsList.contains(event.target) + ) { + return; + } + + // Wait for the DOM to update. + requestAnimationFrame(() => { + this.closeList(); + }); + }; + + onKeyDown = (e) => { + switch (e.keyCode) { + case comboBoxKeyCodes.UP: + e.preventDefault(); + this.incrementActiveOptionIndex(-1); + break; + + case comboBoxKeyCodes.DOWN: + e.preventDefault(); + this.incrementActiveOptionIndex(1); + break; + + case BACKSPACE: + this.removeLastOption(); + break; + + case ESCAPE: + // Move focus from options list to input. + if (this.hasActiveOption()) { + this.clearActiveOption(); + this.searchInput.focus(); + } + break; + + case comboBoxKeyCodes.ENTER: + this.addCustomOption(); + break; + + case TAB: + e.preventDefault(); + e.stopPropagation(); + if (e.shiftKey) { + this.tabAway(-1); + } else { + this.tabAway(1); + } + break; + } + }; + + onOptionEnterKey = (option) => { + this.onAddOption(option); + } + + onOptionClick = (option) => { + this.onAddOption(option); + } + + onAddOption = (addedOption) => { + const { onChange, selectedOptions, singleSelection } = this.props; + onChange(singleSelection ? [addedOption] : selectedOptions.concat(addedOption)); + this.clearActiveOption(); + this.clearSearchValue(); + this.searchInput.focus(); + }; + + onRemoveOption = (removedOption) => { + const { onChange, selectedOptions } = this.props; + onChange(selectedOptions.filter(option => option !== removedOption)); + }; + + onComboBoxClick = () => { + // When the user clicks anywhere on the box, enter the interaction state. + this.searchInput.focus(); + }; + + onComboBoxFocus = (e) => { + // If the user has tabbed to the combo box, open it. + if (e.target === this.searchInput) { + this.searchInput.focus(); + return; + } + + // If a user clicks on an option without selecting it, then it will take focus + // and we need to update the index. + const optionIndex = this.options.indexOf(e.target); + if (optionIndex !== -1) { + this.activeOptionIndex = optionIndex; + } + }; + + onSearchChange = (searchValue) => { + if (this.props.onSearchChange) { + this.props.onSearchChange(searchValue); + } + this.setState({ searchValue }); + }; + + comboBoxRef = node => { + this.comboBox = node; + }; + + autoSizeInputRef = node => { + this.autoSizeInput = node; + }; + + searchInputRef = node => { + this.searchInput = node; + }; + + optionsListRef = node => { + this.optionsList = node; + }; + + optionRef = (index, node) => { + // Sometimes the node is null. + if (node) { + // Store all options. + this.options[index] = node; + } + }; + + componentDidMount() { + document.addEventListener('click', this.onFocusChange); + document.addEventListener('focusin', this.onFocusChange); + + // TODO: This will need to be called once the actual stylesheet loads. + setTimeout(() => { + this.autoSizeInput.copyInputStyles(); + }, 100); + } + + componentWillUpdate(nextProps, nextState) { + const { options, selectedOptions } = nextProps; + const { searchValue } = nextState; + + if ( + options !== this.props.options + || selectedOptions !== this.props.selectedOptions + || searchValue !== this.props.searchValue + ) { + // Clear refs to options if the ones we can display changes. + this.options = []; + } + + // Calculate and cache the options which match the searchValue, because we use this information + // in multiple places and it would be expensive to calculate repeatedly. + const { matchingOptions, optionToGroupMap } = this.getMatchingOptions(options, selectedOptions, nextState.searchValue); + this.matchingOptions = matchingOptions; + this.optionToGroupMap = optionToGroupMap; + + if (!matchingOptions.length) { + this.clearActiveOption(); + } + } + + componentDidUpdate() { + this.focusActiveOption(); + } + + componentWillUnmount() { + document.removeEventListener('click', this.onFocusChange); + document.removeEventListener('focusin', this.onFocusChange); + } + + render() { + const { + id, + className, + isLoading, + options, + selectedOptions, + onCreateOption, + placeholder, + noSuggestions, + renderOption, + singleSelection, // eslint-disable-line no-unused-vars + onChange, // eslint-disable-line no-unused-vars + onSearchChange, // eslint-disable-line no-unused-vars + async, // eslint-disable-line no-unused-vars + ...rest + } = this.props; + + const { searchValue, isListOpen, listStyles, listPosition } = this.state; + + const classes = classNames('euiComboBox', className, { + 'euiComboBox-isOpen': isListOpen, + }); + + const value = selectedOptions.map(selectedOption => selectedOption.label).join(', '); + + let optionsList; + + if (!noSuggestions && isListOpen) { + optionsList = ( + + + + ); + } + + return ( +
+ + + {optionsList} +
+ ); + } +} diff --git a/src/components/combo_box/combo_box.test.js b/src/components/combo_box/combo_box.test.js new file mode 100644 index 00000000000..6a5d06efa8a --- /dev/null +++ b/src/components/combo_box/combo_box.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../test'; + +import { EuiComboBox } from './combo_box'; + +describe('EuiComboBox', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/components/combo_box/combo_box_input/_combo_box_pill.scss b/src/components/combo_box/combo_box_input/_combo_box_pill.scss new file mode 100644 index 00000000000..4b94f31e674 --- /dev/null +++ b/src/components/combo_box/combo_box_input/_combo_box_pill.scss @@ -0,0 +1,6 @@ +// Overwrites the base styling of EuiBadge, to give it a larger size and margins +// that make sense in the input wrap. +.euiComboBoxPill { + margin: $euiSizeXS !important; + line-height: $euiSizeL - 2px; +} diff --git a/src/components/combo_box/combo_box_input/_combo_box_placeholder.scss b/src/components/combo_box/combo_box_input/_combo_box_placeholder.scss new file mode 100644 index 00000000000..6b21b27c513 --- /dev/null +++ b/src/components/combo_box/combo_box_input/_combo_box_placeholder.scss @@ -0,0 +1,8 @@ +.euiComboBoxPlaceholder { + position: absolute; + pointer-events: none; + padding: 0 $euiSizeXS; + line-height: $euiSizeXL; + color: $euiColorMediumShade; + margin-bottom: 0 !important; +} diff --git a/src/components/combo_box/combo_box_input/_index.scss b/src/components/combo_box/combo_box_input/_index.scss new file mode 100644 index 00000000000..7e14a6429cf --- /dev/null +++ b/src/components/combo_box/combo_box_input/_index.scss @@ -0,0 +1,2 @@ +@import 'combo_box_pill'; +@import 'combo_box_placeholder'; 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.js new file mode 100644 index 00000000000..0944c876668 --- /dev/null +++ b/src/components/combo_box/combo_box_input/combo_box_input.js @@ -0,0 +1,165 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import AutosizeInput from 'react-input-autosize'; + +import { EuiScreenReaderOnly } from '../../accessibility'; +import { EuiFormControlLayout, EuiValidatableControl } from '../../form'; +import { EuiComboBoxPill } from './combo_box_pill'; +import { htmlIdGenerator } from '../../../services'; + +const makeId = htmlIdGenerator(); + +export class EuiComboBoxInput extends Component { + static propTypes = { + id: PropTypes.string, + placeholder: PropTypes.string, + selectedOptions: PropTypes.array, + onRemoveOption: PropTypes.func, + onClick: PropTypes.func, + onFocus: PropTypes.func, + onChange: PropTypes.func, + value: PropTypes.string, + searchValue: PropTypes.string, + autoSizeInputRef: PropTypes.func, + inputRef: PropTypes.func, + updatePosition: PropTypes.func.isRequired, + } + + constructor(props) { + super(props); + + this.state = { + hasFocus: false, + }; + } + + updatePosition = () => { + // Wait a beat for the DOM to update, since we depend on DOM elements' bounds. + requestAnimationFrame(() => { + this.props.updatePosition(); + }); + }; + + onFocus = () => { + this.props.onFocus(); + this.setState({ + hasFocus: true, + }); + }; + + onBlur = () => { + this.setState({ + hasFocus: false, + }); + }; + + componentWillUpdate(nextProps) { + const { searchValue } = nextProps; + + // We need to update the position of everything if the user enters enough input to change + // the size of the input. + if (searchValue !== this.props.searchValue) { + this.updatePosition(); + } + } + + render() { + const { + id, + placeholder, + selectedOptions, + onRemoveOption, + onClick, + onChange, + value, + searchValue, + autoSizeInputRef, + inputRef, + } = this.props; + + const pills = selectedOptions.map((option) => { + const { + label, + color, + ...rest + } = option; + + return ( + + {label} + + ) + }); + + let removeOptionMessage; + let removeOptionMessageId; + + if (this.state.hasFocus) { + const removeOptionMessageContent = + `Combo box. Selected. ` + + (searchValue ? `${searchValue}. Selected. ` : '') + + (selectedOptions.length ? `${value}. Unselected. Press Backspace to delete ${selectedOptions[selectedOptions.length - 1].label}. ` : '') + + `You are currently on a combo box. Type text or, to display a list of choices, press Down Arrow. ` + + `To exit the list of choices, press Escape.`; + + removeOptionMessageId = makeId(); + + // aria-live="assertive" will read this message aloud immediately once it enters the DOM. + // We'll render to the DOM when the input gains focus and remove it when the input loses focus. + // We'll use aria-hidden to prevent default aria information from being read by the screen + // reader. + removeOptionMessage = ( + + + {removeOptionMessageContent} + + + ); + } + + let placeholderMessage; + + if (placeholder && !selectedOptions.length && !searchValue) { + placeholderMessage = ( +

{placeholder}

+ ); + } + + return ( + +
+ {pills} + {placeholderMessage} + + onChange(e.target.value)} + value={searchValue} + ref={autoSizeInputRef} + inputRef={inputRef} + /> + + {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.js new file mode 100644 index 00000000000..697ed24c281 --- /dev/null +++ b/src/components/combo_box/combo_box_input/combo_box_pill.js @@ -0,0 +1,60 @@ +import React, { + Component, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { + EuiBadge, +} from '../../badge'; + +export class EuiComboBoxPill extends Component { + static propTypes = { + option: PropTypes.object.isRequired, + children: PropTypes.string, + className: PropTypes.string, + color: PropTypes.string, + onClose: PropTypes.func.isRequired, + }; + + static defaultProps = { + color: 'hollow', + }; + + onCloseButtonClick(option, e) { + // Prevent the combo box from losing focus. + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + this.props.onClose(option) + } + + render() { + const { + children, + className, + option, + onClose, // eslint-disable-line no-unused-vars + color, + ...rest + } = this.props; + const classes = classNames('euiComboBoxPill', className); + + return ( + + {children} + + ); + } +} diff --git a/src/components/combo_box/combo_box_input/index.js b/src/components/combo_box/combo_box_input/index.js new file mode 100644 index 00000000000..204db689c3d --- /dev/null +++ b/src/components/combo_box/combo_box_input/index.js @@ -0,0 +1,3 @@ +export { + EuiComboBoxInput, +} from './combo_box_input'; diff --git a/src/components/combo_box/combo_box_options_list/_combo_box_option.scss b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss new file mode 100644 index 00000000000..7f37487eb80 --- /dev/null +++ b/src/components/combo_box/combo_box_options_list/_combo_box_option.scss @@ -0,0 +1,19 @@ +.euiComboBoxOption { + font-size: $euiFontSizeS; + padding: $euiSizeXS $euiSizeS; + width: 100%; + text-align: left; + border: $euiBorderThin; + border-color: transparent; + display: flex; + align-items: center; + + &:hover { + text-decoration: underline; + } + &:focus { + cursor: pointer; + color: $euiColorPrimary; + background-color: $euiFocusBackgroundColor; + } +} diff --git a/src/components/combo_box/combo_box_options_list/_combo_box_options_list.scss b/src/components/combo_box/combo_box_options_list/_combo_box_options_list.scss new file mode 100644 index 00000000000..6bc0c22485a --- /dev/null +++ b/src/components/combo_box/combo_box_options_list/_combo_box_options_list.scss @@ -0,0 +1,33 @@ +/** + * 1. Make width match that of the input. + */ +.euiComboBoxOptionsList { + @include euiFormControlSize; + box-sizing: content-box; /* 1 */ + z-index: $euiZComboBox; + position: absolute; +} + +.euiComboBoxOptionsList--bottom { + border-radius: 0 0 $euiBorderRadius $euiBorderRadius !important; + border-top: none !important; +} + +.euiComboBoxOptionsList--top { + border-radius: $euiBorderRadius $euiBorderRadius 0 0 !important; + box-shadow: none !important; +} + + .euiComboBoxOptionsList__empty { + padding: $euiSizeS; + text-align: center; + color: $euiColorDarkShade; + } + + .euiComboBoxOptionsList__rowWrap { + @include euiScrollBar; + + padding: $euiSizeS; + max-height: 200px; + overflow-y: auto; + } diff --git a/src/components/combo_box/combo_box_options_list/_combo_box_title.scss b/src/components/combo_box/combo_box_options_list/_combo_box_title.scss new file mode 100644 index 00000000000..0a800293790 --- /dev/null +++ b/src/components/combo_box/combo_box_options_list/_combo_box_title.scss @@ -0,0 +1,11 @@ +.euiComboBoxTitle { + font-size: $euiFontSizeXS; + padding: $euiSizeXS $euiSizeS $euiSizeXS 0; + width: 100%; + font-weight: $euiFontWeightBold; + color: $euiColorFullShade; + + .euiComboBoxOption + & { + margin-top: $euiSizeS; + } +} diff --git a/src/components/combo_box/combo_box_options_list/_index.scss b/src/components/combo_box/combo_box_options_list/_index.scss new file mode 100644 index 00000000000..876183f5961 --- /dev/null +++ b/src/components/combo_box/combo_box_options_list/_index.scss @@ -0,0 +1,3 @@ +@import 'combo_box_options_list'; +@import 'combo_box_option'; +@import 'combo_box_title'; 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.js new file mode 100644 index 00000000000..66c8a72f766 --- /dev/null +++ b/src/components/combo_box/combo_box_options_list/combo_box_option.js @@ -0,0 +1,67 @@ +import React, { + Component, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { ENTER, SPACE } from '../../../services/key_codes'; + +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, + } + + onClick = (e) => { + // Prevent the combo box from losing focus. + e.preventDefault(); + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + const { onClick, option } = this.props; + onClick(option); + }; + + onKeyDown = (e) => { + if (e.keyCode === ENTER || e.keyCode === SPACE) { + e.preventDefault(); + e.stopPropagation(); + const { onEnterKey, option } = this.props; + onEnterKey(option); + } + }; + + render() { + const { + children, + className, + optionRef, + option, // eslint-disable-line no-unused-vars + onClick, // eslint-disable-line no-unused-vars + onEnterKey, // eslint-disable-line no-unused-vars + ...rest + } = this.props; + + const classes = classNames( + 'euiComboBoxOption', + className + ); + + 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.js new file mode 100644 index 00000000000..8e0d6df9e49 --- /dev/null +++ b/src/components/combo_box/combo_box_options_list/combo_box_options_list.js @@ -0,0 +1,200 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { EuiCode } from '../../code'; +import { EuiFlexGroup, EuiFlexItem } from '../../flex'; +import { EuiHighlight } from '../../highlight'; +import { EuiPanel } from '../../panel'; +import { EuiText } from '../../text'; +import { EuiLoadingSpinner } from '../../loading'; +import { EuiComboBoxOption } from './combo_box_option'; +import { EuiComboBoxTitle } from './combo_box_title'; + +const positionToClassNameMap = { + top: 'euiComboBoxOptionsList--top', + bottom: 'euiComboBoxOptionsList--bottom', +}; + +const POSITIONS = Object.keys(positionToClassNameMap); + +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, + optionToGroupMap: PropTypes.object, + optionRef: PropTypes.func, + onOptionClick: PropTypes.func, + onOptionEnterKey: PropTypes.func, + areAllOptionsSelected: PropTypes.bool, + getSelectedOptionForSearchValue: PropTypes.func, + updatePosition: PropTypes.func.isRequired, + position: PropTypes.oneOf(POSITIONS), + style: PropTypes.object, + listRef: PropTypes.func.isRequired, + renderOption: PropTypes.func, + } + + updatePosition = () => { + // Wait a beat for the DOM to update, since we depend on DOM elements' bounds. + requestAnimationFrame(() => { + this.props.updatePosition(this.list.getBoundingClientRect()); + }); + }; + + componentDidMount() { + document.body.classList.add('euiBody-hasPortalContent'); + + this.updatePosition(); + window.addEventListener('resize', this.updatePosition); + window.addEventListener('scroll', this.updatePosition); + } + + componentWillUpdate(nextProps) { + const { options, selectedOptions, searchValue } = nextProps; + + // We don't compare matchingOptions because that will result in a loop. + if ( + searchValue !== this.props.searchValue + || options !== this.props.options + || selectedOptions !== this.props.selectedOptions + ) { + this.updatePosition(); + } + } + + componentWillUnmount() { + document.body.classList.remove('euiBody-hasPortalContent'); + window.removeEventListener('resize', this.updatePosition); + window.removeEventListener('scroll', this.updatePosition); + } + + listRef = node => { + this.props.listRef(node); + this.list = node; + } + + render() { + const { + options, + isLoading, + selectedOptions, + onCreateOption, + searchValue, + matchingOptions, + optionToGroupMap, + optionRef, + onOptionClick, + onOptionEnterKey, + areAllOptionsSelected, + getSelectedOptionForSearchValue, + position, + renderOption, + listRef, // eslint-disable-line no-unused-vars + updatePosition, // eslint-disable-line no-unused-vars + ...rest + } = this.props; + + let emptyStateContent; + + if (isLoading) { + emptyStateContent = ( + + + + + + Loading options + + + ); + } else if (searchValue && matchingOptions.length === 0) { + if (onCreateOption) { + const selectedOptionForValue = getSelectedOptionForSearchValue(searchValue, selectedOptions); + if (selectedOptionForValue) { + // Disallow duplicate custom options. + emptyStateContent = ( +

{selectedOptionForValue.value} has already been added

+ ); + } else { + emptyStateContent = ( +

Hit ENTER to add {searchValue} as a custom option

+ ); + } + } else { + emptyStateContent = ( +

{searchValue} doesn’t match any options

+ ); + } + } else if (!options.length) { + emptyStateContent =

There aren’t any options available

; + } else if (areAllOptionsSelected) { + emptyStateContent =

You’ve selected all available options

; + } + + const emptyState = emptyStateContent ? ( + + {emptyStateContent} + + ) : undefined; + + const groupLabelToGroupMap = {}; + const optionsList = []; + + matchingOptions.forEach((option, index) => { + const { + value, // eslint-disable-line no-unused-vars + label, + ...rest + } = option; + + const group = optionToGroupMap.get(option); + + if (group && !groupLabelToGroupMap[group.label]) { + groupLabelToGroupMap[group.label] = true; + optionsList.push( + + {group.label} + + ); + } + + const renderedOption = ( + + {renderOption ? renderOption(option, searchValue) : ( + {label} + )} + + ); + + optionsList.push(renderedOption); + }); + + const classes = classNames('euiComboBoxOptionsList', positionToClassNameMap[position]); + + return ( + +
+ {emptyState || optionsList} +
+
+ ); + } +} diff --git a/src/components/combo_box/combo_box_options_list/combo_box_title.js b/src/components/combo_box/combo_box_options_list/combo_box_title.js new file mode 100644 index 00000000000..e8b5a491cd9 --- /dev/null +++ b/src/components/combo_box/combo_box_options_list/combo_box_title.js @@ -0,0 +1,10 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export const EuiComboBoxTitle = ({ children }) => ( +
{children}
+); + +EuiComboBoxTitle.propTypes = { + children: PropTypes.node, +}; diff --git a/src/components/combo_box/combo_box_options_list/index.js b/src/components/combo_box/combo_box_options_list/index.js new file mode 100644 index 00000000000..fce224b9a68 --- /dev/null +++ b/src/components/combo_box/combo_box_options_list/index.js @@ -0,0 +1,3 @@ +export { + EuiComboBoxOptionsList, +} from './combo_box_options_list'; diff --git a/src/components/combo_box/index.js b/src/components/combo_box/index.js new file mode 100644 index 00000000000..158e8b37c48 --- /dev/null +++ b/src/components/combo_box/index.js @@ -0,0 +1,3 @@ +export { + EuiComboBox, +} from './combo_box'; diff --git a/src/components/combo_box/matching_options.js b/src/components/combo_box/matching_options.js new file mode 100644 index 00000000000..86bc46677de --- /dev/null +++ b/src/components/combo_box/matching_options.js @@ -0,0 +1,57 @@ +export const flattenOptionGroups = optionsOrGroups => { + return optionsOrGroups.reduce((options, optionOrGroup) => { + if (optionOrGroup.options) { + options.push(...optionOrGroup.options); + } else { + options.push(optionOrGroup); + } + return options; + }, []); +}; + +export const getSelectedOptionForSearchValue = (searchValue, selectedOptions) => { + const normalizedSearchValue = searchValue.toLowerCase(); + return selectedOptions.find(option => option.label.toLowerCase() === normalizedSearchValue); +}; + +const collectMatchingOption = (accumulator, option, selectedOptions, normalizedSearchValue, isPreFiltered) => { + // Only show options which haven't yet been selected. + const selectedOption = getSelectedOptionForSearchValue(option.label, selectedOptions); + if (selectedOption) { + return false; + } + + // If the options have already been prefiltered then we can skip filtering against the search value. + if (isPreFiltered) { + accumulator.push(option); + return; + } + + if (!normalizedSearchValue) { + accumulator.push(option); + return; + } + + const normalizedOption = option.label.trim().toLowerCase(); + if (normalizedOption.includes(normalizedSearchValue)) { + accumulator.push(option); + } +}; + +export const getMatchingOptions = (options, selectedOptions, searchValue, isPreFiltered) => { + const normalizedSearchValue = searchValue.trim().toLowerCase(); + const optionToGroupMap = new Map(); + const matchingOptions = []; + + options.forEach(option => { + if (option.options) { + option.options.forEach(groupOption => { + optionToGroupMap.set(groupOption, option) + collectMatchingOption(matchingOptions, groupOption, selectedOptions, normalizedSearchValue, isPreFiltered); + }) + } else { + collectMatchingOption(matchingOptions, option, selectedOptions, normalizedSearchValue, isPreFiltered); + } + }); + return { optionToGroupMap, matchingOptions }; +}; diff --git a/src/components/context_menu/__snapshots__/context_menu.test.js.snap b/src/components/context_menu/__snapshots__/context_menu.test.js.snap index 290d775bfe2..0d3f728c7fb 100644 --- a/src/components/context_menu/__snapshots__/context_menu.test.js.snap +++ b/src/components/context_menu/__snapshots__/context_menu.test.js.snap @@ -26,7 +26,7 @@ exports[`EuiContextMenu props panels and initialPanelId allows you to click the class="euiContextMenu__itemLayout" > + case + + match +
+`; + +exports[`EuiHighlight behavior matching only applies to first match 1`] = ` + + + match + + match match + +`; + +exports[`EuiHighlight behavior strict matching doesn't match strings with different casing 1`] = ` + + different case match + +`; + +exports[`EuiHighlight is rendered 1`] = ` + + value + +`; diff --git a/src/components/highlight/highlight.js b/src/components/highlight/highlight.js new file mode 100644 index 00000000000..8311b9ef13a --- /dev/null +++ b/src/components/highlight/highlight.js @@ -0,0 +1,50 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; + +const highlight = (searchSubject, searchValue, isStrict = false) => { + if (!searchValue) { + return searchSubject; + } + + const normalizedSearchSubject = isStrict ? searchSubject : searchSubject.toLowerCase(); + const normalizedSearchValue = isStrict ? searchValue : searchValue.toLowerCase(); + + const indexOfMatch = normalizedSearchSubject.indexOf(normalizedSearchValue); + if (indexOfMatch === -1) { + return searchSubject; + } + + const preMatch = searchSubject.substr(0, indexOfMatch); + const match = searchSubject.substr(indexOfMatch, searchValue.length) + const postMatch = searchSubject.substr(indexOfMatch + searchValue.length); + + return ( + + {preMatch}{match}{postMatch} + + ); +} + +export const EuiHighlight = ({ + children, + className, + search, + strict, + ...rest, +}) => { + return ( + + {highlight(children, search, strict)} + + ); +}; + +EuiHighlight.propTypes = { + children: PropTypes.string.isRequired, + className: PropTypes.string, + search: PropTypes.string.isRequired, + strict: PropTypes.bool, +}; diff --git a/src/components/highlight/highlight.test.js b/src/components/highlight/highlight.test.js new file mode 100644 index 00000000000..4fb7c33b7e0 --- /dev/null +++ b/src/components/highlight/highlight.test.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../test'; + +import { EuiHighlight } from './highlight'; + +describe('EuiHighlight', () => { + test('is rendered', () => { + const component = render( + value + ); + + expect(component).toMatchSnapshot(); + }); + + describe('behavior', () => { + describe('matching', () => { + test('only applies to first match', () => { + const component = render( + match match match + ); + + expect(component).toMatchSnapshot(); + }); + }); + + describe('loose matching', () => { + test('matches strings with different casing', () => { + const component = render( + different case match + ); + + expect(component).toMatchSnapshot(); + }); + }); + + describe('strict matching', () => { + test(`doesn't match strings with different casing`, () => { + const component = render( + different case match + ); + + expect(component).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/src/components/highlight/index.js b/src/components/highlight/index.js new file mode 100644 index 00000000000..4b994d1599d --- /dev/null +++ b/src/components/highlight/index.js @@ -0,0 +1,3 @@ +export { + EuiHighlight, +} from './highlight'; diff --git a/src/components/icon/__snapshots__/icon.test.js.snap b/src/components/icon/__snapshots__/icon.test.js.snap index 96c72f2ab7b..89075fd81f9 100644 --- a/src/components/icon/__snapshots__/icon.test.js.snap +++ b/src/components/icon/__snapshots__/icon.test.js.snap @@ -3,7 +3,7 @@ exports[`EuiIcon is rendered 1`] = ` { - const classes = classNames('euiIcon', className, sizeToClassNameMap[size], colorToClassMap[color]); + let optionalColorClass = null; + let optionalCustomStyles = null; + + if (COLORS.indexOf(color) > -1) { + optionalColorClass = colorToClassMap[color]; + } else { + optionalCustomStyles = { fill: color }; + } + + const classes = classNames( + 'euiIcon', + sizeToClassNameMap[size], + optionalColorClass, + className, + ); const Svg = typeToIconMap[type] || empty; - return ; + return ; }; +function checkValidColor(props, propName, componentName) { + const validHex = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(props.color); + if (props.color && !validHex && !COLORS.includes(props.color)) { + throw new Error( + `${componentName} needs to pass a valid color. This can either be a three ` + + `or six character hex value or one of the following: ${COLORS}` + ); + } +} + EuiIcon.propTypes = { type: PropTypes.oneOf(TYPES), - color: PropTypes.oneOf(COLORS), + color: checkValidColor, size: PropTypes.oneOf(SIZES) }; diff --git a/src/components/index.js b/src/components/index.js index bfb1de5524d..0777c546f5c 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -47,6 +47,10 @@ export { EuiColorPicker, } from './color_picker'; +export { + EuiComboBox, +} from './combo_box'; + export { EuiContextMenu, EuiContextMenuPanel, @@ -131,6 +135,10 @@ export { EuiHealth, } from './health'; +export { + EuiHighlight, +} from './highlight'; + export { EuiHorizontalRule, } from './horizontal_rule'; diff --git a/src/components/index.scss b/src/components/index.scss index 305a8a731f0..75d2c6c3779 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -13,6 +13,7 @@ @import 'code/index'; @import 'code_editor/index'; @import 'color_picker/index'; +@import 'combo_box/index'; @import 'context_menu/index'; @import 'description_list/index'; @import 'error_boundary/index'; @@ -35,6 +36,7 @@ @import 'pagination/index'; @import 'panel/index'; @import 'popover/index'; +@import 'portal/index'; @import 'progress/index'; @import 'search_bar/index'; @import 'side_nav/index'; diff --git a/src/components/modal/__snapshots__/confirm_modal.test.js.snap b/src/components/modal/__snapshots__/confirm_modal.test.js.snap index cf7035a7635..78549a05b2c 100644 --- a/src/components/modal/__snapshots__/confirm_modal.test.js.snap +++ b/src/components/modal/__snapshots__/confirm_modal.test.js.snap @@ -15,7 +15,7 @@ exports[`renders EuiConfirmModal 1`] = ` >