diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ae5696121..3d21fee81f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [`master`](https://github.com/elastic/eui/tree/master) - Added `zIndexAdjustment` to `EuiPopover` which allows tweaking the popover content's `z-index` ([#1097](https://github.com/elastic/eui/pull/1097)) +- Added new `EuiSuperSelect` component and `hasArrow` prop to `EuiPopover` ([#921](https://github.com/elastic/eui/pull/921)) ## [`3.6.1`](https://github.com/elastic/eui/tree/v3.6.1) diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 2f76fc32321..b5286e43229 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -237,6 +237,9 @@ import { XYChartLineExample } import { Changelog } from './views/package/changelog'; +import { SuperSelectExample } + from './views/super_select/super_select_example'; + /** * Lowercases input and replaces spaces with hyphens: * e.g. 'GridView Example' -> 'gridview-example' @@ -356,6 +359,7 @@ const navigation = [{ FormLayoutsExample, FormControlsExample, FormValidationExample, + SuperSelectExample, ComboBoxExample, ColorPickerExample, CodeEditorExample, diff --git a/src-docs/src/views/form_controls/form_controls_example.js b/src-docs/src/views/form_controls/form_controls_example.js index 8df657ecc55..cf3d4f0115a 100644 --- a/src-docs/src/views/form_controls/form_controls_example.js +++ b/src-docs/src/views/form_controls/form_controls_example.js @@ -182,6 +182,14 @@ export const FormControlsExample = { type: GuideSectionTypes.HTML, code: selectHtml, }], + text: ( +

+ This component renders a basic HTML <select> element. If you need more customization + for how the options and/or selected values render, use the EuiSuperSelect. + Another option is to use the EuiComboBox, which has search and multi-select + capabilities, but also has restrictions on how items are rendered. +

+ ), props: { EuiSelect, }, diff --git a/src-docs/src/views/super_select/super_select.js b/src-docs/src/views/super_select/super_select.js new file mode 100644 index 00000000000..74f796156fe --- /dev/null +++ b/src-docs/src/views/super_select/super_select.js @@ -0,0 +1,91 @@ +import React, { + Component, + Fragment, +} from 'react'; + +import { + EuiSuperSelect, + EuiSpacer, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [ + { + value: 'option_one', + inputDisplay: 'Option one', + disabled: true, + 'data-test-subj': 'option one', + }, + { + value: 'option_two', + inputDisplay: 'Option two', + }, + { + value: 'option_three', + inputDisplay: 'Option three has a super long text to see if it will truncate or what', + }, + ]; + + this.state = { + value: this.options[1].value, + }; + } + + onChange = (value) => { + this.setState({ + value: value, + }); + }; + + render() { + return ( + + + + + + + + + + + + + + + + + + + + ); + } +} diff --git a/src-docs/src/views/super_select/super_select_basic.js b/src-docs/src/views/super_select/super_select_basic.js new file mode 100644 index 00000000000..1942e1a681b --- /dev/null +++ b/src-docs/src/views/super_select/super_select_basic.js @@ -0,0 +1,65 @@ +import React, { + Component, +} from 'react'; + +import { + EuiSuperSelect, + EuiHealth, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [ + { + value: 'warning', + inputDisplay: ( + + Warning + + ), + 'data-test-subj': 'option-warning', + disabled: true, + }, + { + value: 'minor', + inputDisplay: ( + + Minor + + ), + 'data-test-subj': 'option-minor', + }, + { + value: 'critical', + inputDisplay: ( + + Critical + + ), + 'data-test-subj': 'option-critical', + }, + ]; + + this.state = { + value: this.options[1].value, + }; + } + + onChange = (value) => { + this.setState({ + value: value, + }); + }; + + render() { + return ( + + ); + } +} diff --git a/src-docs/src/views/super_select/super_select_complex.js b/src-docs/src/views/super_select/super_select_complex.js new file mode 100644 index 00000000000..3a410f293f8 --- /dev/null +++ b/src-docs/src/views/super_select/super_select_complex.js @@ -0,0 +1,78 @@ +import React, { + Component, + Fragment, +} from 'react'; + +import { + EuiSuperSelect, + EuiSpacer, + EuiText, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [ + { + value: 'option_one', + inputDisplay: 'Option one', + dropdownDisplay: ( + + Option one + + +

Has a short description giving more detail to the option.

+
+
+ ), + }, + { + value: 'option_two', + inputDisplay: 'Option two', + dropdownDisplay: ( + + Option two + + +

Has a short description giving more detail to the option.

+
+
+ ), + }, + { + value: 'option_three', + inputDisplay: 'Option three', + dropdownDisplay: ( + + Option three + + +

Has a short description giving more detail to the option.

+
+
+ ), + }, + ]; + + this.state = { + value: this.options[1].value, + }; + } + + onChange = (value) => { + this.setState({ value }); + }; + + render() { + return ( + + ); + } +} diff --git a/src-docs/src/views/super_select/super_select_example.js b/src-docs/src/views/super_select/super_select_example.js new file mode 100644 index 00000000000..5b7d5360c1d --- /dev/null +++ b/src-docs/src/views/super_select/super_select_example.js @@ -0,0 +1,95 @@ +import React from 'react'; + +import { renderToHtml } from '../../services'; + +import { + GuideSectionTypes, +} from '../../components'; + +import { + EuiCode, + EuiSuperSelect, +} from '../../../../src/components'; + +import SuperSelect from './super_select'; +const superSelectSource = require('!!raw-loader!./super_select'); +const superSelectHtml = renderToHtml(SuperSelect); + +import SuperSelectBasic from './super_select_basic'; +const superSelectBasicSource = require('!!raw-loader!./super_select_basic'); +const superSelectBasicHtml = renderToHtml(SuperSelectBasic); + +import SuperSelectComplex from './super_select_complex'; +const superSelectComplexSource = require('!!raw-loader!./super_select_complex'); +const superSelectComplexHtml = renderToHtml(SuperSelectComplex); + +export const SuperSelectExample = { + title: 'SuperSelect', + sections: [{ + source: [{ + type: GuideSectionTypes.JS, + code: superSelectBasicSource, + }, { + type: GuideSectionTypes.HTML, + code: superSelectBasicHtml, + }], + text: ( +
+

+ This is a simple replacement component for EuiSelect if you + need more customization in either the display of the input or option. Simply pass + an array of option objects: +

+
    +
  • value: for storing unique value of item,
  • +
  • inputDisplay: what shows inside the form input when selected,
  • +
  • dropdownDisplay: (optional) what shows for the item in the dropdown
  • +
+

+ … and the component will create a select styled button + that triggers a popover of selectable items. +

+
+ ), + props: { EuiSuperSelect }, + demo: , + }, + { + title: 'More complex', + source: [{ + type: GuideSectionTypes.JS, + code: superSelectComplexSource, + }, { + type: GuideSectionTypes.HTML, + code: superSelectComplexHtml, + }], + text: ( +

+ Both inputDisplay and dropdownDisplay accept + React nodes. Therefore you can pass some descriptions with each option to show + in the dropdown. If your options will most likely be multi-line, add + the hasDividers prop to show borders between options. +

+ ), + props: { }, + demo: , + }, + { + title: 'States', + source: [{ + type: GuideSectionTypes.JS, + code: superSelectSource, + }, { + type: GuideSectionTypes.HTML, + code: superSelectHtml, + }], + text: ( +

+ You can pass the same props as you normally would to EuiSelect like + disabled, isLoading, compressed, etc… +

+ ), + props: { EuiSuperSelect }, + demo: , + }], +}; diff --git a/src/components/basic_table/__snapshots__/collapsed_item_actions.test.js.snap b/src/components/basic_table/__snapshots__/collapsed_item_actions.test.js.snap index 3e3483542f8..7f2c460a91f 100644 --- a/src/components/basic_table/__snapshots__/collapsed_item_actions.test.js.snap +++ b/src/components/basic_table/__snapshots__/collapsed_item_actions.test.js.snap @@ -16,6 +16,7 @@ exports[`CollapsedItemActions render 1`] = ` /> } closePopover={[Function]} + hasArrow={true} id="id-actions" isOpen={false} ownFocus={false} @@ -29,12 +30,14 @@ exports[`CollapsedItemActions render 1`] = ` default1 , , diff --git a/src/components/basic_table/__snapshots__/in_memory_table.test.js.snap b/src/components/basic_table/__snapshots__/in_memory_table.test.js.snap index d0d3fcdeda1..e94597cd9b7 100644 --- a/src/components/basic_table/__snapshots__/in_memory_table.test.js.snap +++ b/src/components/basic_table/__snapshots__/in_memory_table.test.js.snap @@ -270,6 +270,7 @@ exports[`EuiInMemoryTable behavior pagination 1`] = ` } closePopover={[Function]} + hasArrow={true} id="customizablePagination" isOpen={false} ownFocus={false} diff --git a/src/components/combo_box/_combo_box.scss b/src/components/combo_box/_combo_box.scss index 9de524ff8d0..a41c15c996e 100644 --- a/src/components/combo_box/_combo_box.scss +++ b/src/components/combo_box/_combo_box.scss @@ -1,6 +1,7 @@ .euiComboBox { @include euiFormControlSize(auto, $includeAlternates: true); position: relative; + z-index: $euiZComboBox + 1; // Ensures the dropdown's shadow is hidden behind the input on top /** * 1. Allow pills to truncate their text with an ellipsis. 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 index 6a27b267688..7193b6da88d 100644 --- 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 @@ -4,6 +4,7 @@ * the portal is appended to the body. This would throw off our logic for positioning the * list beneath the input. * 3. The height can expand, hence auto + * 4. Using specificity to override panel shadow */ .euiComboBoxOptionsList { @include euiFormControlSize(auto, $includeAlternates: true); /* 3 */ @@ -14,6 +15,14 @@ .ReactVirtualized__List { @include euiScrollBar; } + + &.euiComboBoxOptionsList--bottom { /* 4 */ + @include euiBottomShadowMedium; + z-index: $euiZComboBox + 2; // Ensures the dropdown's is above the combo box's shadow + } + &.euiComboBoxOptionsList--top { /* 4 */ + @include euiBottomShadowFlat; + } } .euiComboBoxOptionsList--bottom { @@ -23,7 +32,6 @@ .euiComboBoxOptionsList--top { border-radius: $euiBorderRadius $euiBorderRadius 0 0 !important; - box-shadow: none !important; } /** diff --git a/src/components/context_menu/__snapshots__/context_menu_panel.test.js.snap b/src/components/context_menu/__snapshots__/context_menu_panel.test.js.snap index 2fbd9f164b9..9a581184bb9 100644 --- a/src/components/context_menu/__snapshots__/context_menu_panel.test.js.snap +++ b/src/components/context_menu/__snapshots__/context_menu_panel.test.js.snap @@ -433,7 +433,7 @@ exports[`EuiContextMenuPanel updating items and content updates to items should "
- + - + - + - + - +
+
+ + +`; + +exports[`EuiSuperSelect props custom display is propagated to dropdown 1`] = ` +
+
+ +
+
+ + Select an option: , is selected + +
+
+
+
+
+
+ +
+ + +
+
+
+
+`; + +exports[`EuiSuperSelect props more props are propogated to each option 1`] = ` +
+
+ +
+
+ + Select an option: Option #1, is selected + + +
+ + + +
+
+
+
+
+
+
+ +
+ + +
+
+
+
+`; + +exports[`EuiSuperSelect props more props are propogated to each option 2`] = ` + + + } + className="euiSuperSelect" + closePopover={[Function]} + hasArrow={false} + isOpen={true} + ownFocus={false} + panelClassName="euiSuperSelect__popoverPanel" + panelPaddingSize="none" + popoverRef={[Function]} + > + +
+
+ + + +
+
+ + + Select an option: + Option #1 + , is selected + + + + +
+ + + + + +
+
+
+
+
+
+
+ + +
+ +
+
+ + + +

+ You are in a form selector of + 2 + items and must select a single option. Use the up and down keys to navigate or escape to close. +

+
+
+ +
+ + + + + + +
+
+
+
+ +
+ + +
+ + + +`; + +exports[`EuiSuperSelect props options are rendered when select is open 1`] = ` +
+
+ +
+
+ + Select an option: , is selected + +
+
+
+
+
+
+ +
+ + +
+
+
+
+`; + +exports[`EuiSuperSelect props select component is rendered 1`] = ` +
+
+ +
+
+ + Select an option: , is selected + +
+
+
+
+`; + +exports[`EuiSuperSelect props valueSelected is rendered 1`] = ` +
+
+ +
+
+ + Select an option: Option #2, is selected + + +
+ + + +
+
+
+
+
+`; diff --git a/src/components/form/super_select/__snapshots__/super_select_control.test.js.snap b/src/components/form/super_select/__snapshots__/super_select_control.test.js.snap new file mode 100644 index 00000000000..fb59b3c13c2 --- /dev/null +++ b/src/components/form/super_select/__snapshots__/super_select_control.test.js.snap @@ -0,0 +1,362 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiSuperSelectControl is rendered 1`] = ` +Array [ + , +
+
+ + Select an option: , is selected + +
+
, +] +`; + +exports[`EuiSuperSelectControl props compressed is rendered 1`] = ` +Array [ + , +
+
+ + Select an option: , is selected + +
+
, +] +`; + +exports[`EuiSuperSelectControl props disabled options are rendered 1`] = ` +Array [ + , +
+
+ + Select an option: , is selected + +
+
, +] +`; + +exports[`EuiSuperSelectControl props fullWidth is rendered 1`] = ` +Array [ + , +
+
+ + Select an option: , is selected + +
+
, +] +`; + +exports[`EuiSuperSelectControl props isLoading is rendered 1`] = ` +Array [ + , +
+
+ + Select an option: , is selected + +
, +] +`; + +exports[`EuiSuperSelectControl props value option is rendered 1`] = ` +Array [ + , +
+
+ + Select an option: Option #1, is selected + + +
+ + + +
+
+
, +] +`; diff --git a/src/components/form/super_select/_index.scss b/src/components/form/super_select/_index.scss new file mode 100644 index 00000000000..ce308688e46 --- /dev/null +++ b/src/components/form/super_select/_index.scss @@ -0,0 +1,2 @@ +@import 'super_select'; +@import 'super_select_control'; diff --git a/src/components/form/super_select/_super_select.scss b/src/components/form/super_select/_super_select.scss new file mode 100644 index 00000000000..902081301b3 --- /dev/null +++ b/src/components/form/super_select/_super_select.scss @@ -0,0 +1,32 @@ +/* + * 1. Make popover the same width as the form control + * 2. Style popover similar to combobox + * 3. Need specificity to override default popover styles + * 4. Use attribute selector to match popover position without needing the full popover class name + */ + +.euiSuperSelect.euiPopover { /* 3 */ + @include euiFormControlSize; /* 1 */ + height: auto; + + .euiPopover__anchor { + display: block; + } +} + +.euiSuperSelect__popoverPanel[class*="bottom"] { /* 4 */ + border-top-color: transparentize($euiBorderColor, .2); + border-top-right-radius: 0; /* 2 */ + border-top-left-radius: 0; /* 2 */ +} + +.euiSuperSelect__popoverPanel[class*="top"] { /* 4 */ + border-bottom-color: transparentize($euiBorderColor, .2); + border-bottom-right-radius: 0; /* 2 */ + border-bottom-left-radius: 0; /* 2 */ + @include euiBottomShadowFlat; /* 2 */ +} + +.euiSuperSelect__item--hasDividers:not(:last-of-type) { + border-bottom: $euiBorderThin; +} diff --git a/src/components/form/super_select/_super_select_control.scss b/src/components/form/super_select/_super_select_control.scss new file mode 100644 index 00000000000..b32d0e71cf6 --- /dev/null +++ b/src/components/form/super_select/_super_select_control.scss @@ -0,0 +1,30 @@ +/** + * 1. Leave room for caret. + * 2. Ensure the descenders don't get cut off + */ + +.euiSuperSelectControl { + @include euiFormControlStyle; + @include euiFormControlWithIcon($side: "right"); /* 1 */ + @include euiFormControlIsLoading($isNextToIcon: true); + text-align: left; + + line-height: $euiFormControlHeight; /* 2 */ + padding-top: 0; /* 2 */ + padding-bottom: 0; /* 2 */ + + &--compressed { + line-height: $euiFormControlCompressedHeight; /* 2 */ + padding-top: 0; /* 2 */ + padding-bottom: 0; /* 2 */ + } + + // Truncate the text + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &.euiSuperSelect--isOpen__button { // since this is a button, we also want the visual indicator of active when options are shown + @include euiFormControlFocusStyle; + } +} diff --git a/src/components/form/super_select/index.js b/src/components/form/super_select/index.js new file mode 100644 index 00000000000..18075be5062 --- /dev/null +++ b/src/components/form/super_select/index.js @@ -0,0 +1,7 @@ +export { + EuiSuperSelect, +} from './super_select'; + +export { + EuiSuperSelectControl, +} from './super_select_control'; diff --git a/src/components/form/super_select/super_select.js b/src/components/form/super_select/super_select.js new file mode 100644 index 00000000000..a92df04b555 --- /dev/null +++ b/src/components/form/super_select/super_select.js @@ -0,0 +1,277 @@ +import React, { + Component, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { EuiScreenReaderOnly } from '../../accessibility'; +import { EuiSuperSelectControl } from './super_select_control'; +import { EuiPopover } from '../../popover'; +import { EuiContextMenuItem } from '../../context_menu'; +import { keyCodes } from '../../../services'; + +const SHIFT_BACK = 'back'; +const SHIFT_FORWARD = 'forward'; + +export class EuiSuperSelect extends Component { + constructor(props) { + super(props); + + this.itemNodes = []; + this.state = { + isPopoverOpen: props.isOpen || false, + menuWidth: null, + }; + } + + setItemNode = (node, index) => { + this.itemNodes[index] = node; + }; + + setPopoverRef = ref => { + this.popoverRef = ref; + } + + openPopover = () => { + this.setState({ + isPopoverOpen: true, + }); + + const focusSelected = () => { + const indexOfSelected = this.props.options.reduce( + (indexOfSelected, option, index) => { + if (indexOfSelected != null) return indexOfSelected; + if (option == null) return null; + return option.value === this.props.valueOfSelected ? index : null; + }, + null + ); + + // valueOfSelected is optional, and options may not exist yet + if (indexOfSelected != null) { + // wait for the CSS classes to be applied, removing visibility: hidden + requestAnimationFrame(() => { + this.focusItemAt(indexOfSelected); + + this.setState({ + menuWidth: this.popoverRef.getBoundingClientRect().width - 2, // account for border not inner shadow + }); + }); + } else { + requestAnimationFrame(focusSelected); + } + }; + + requestAnimationFrame(focusSelected); + }; + + closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + itemClicked = (value) => { + this.setState({ + isPopoverOpen: false, + }); + this.props.onChange(value); + }; + + onSelectKeyDown = e => { + if (e.keyCode === keyCodes.UP || e.keyCode === keyCodes.DOWN) { + e.preventDefault(); + e.stopPropagation(); + this.openPopover(); + } + } + + onItemKeyDown = e => { + switch (e.keyCode) { + case keyCodes.ESCAPE: + // close the popover and prevent ancestors from handling + e.preventDefault(); + e.stopPropagation(); + this.closePopover(); + break; + + case keyCodes.TAB: + // no-op + e.preventDefault(); + e.stopPropagation(); + break; + + case keyCodes.UP: + e.preventDefault(); + e.stopPropagation(); + this.shiftFocus(SHIFT_BACK); + break; + + case keyCodes.DOWN: + e.preventDefault(); + e.stopPropagation(); + this.shiftFocus(SHIFT_FORWARD); + break; + } + } + + focusItemAt(index) { + const targetElement = this.itemNodes[index]; + if (targetElement != null) { + targetElement.focus(); + } + } + + shiftFocus(direction) { + const currentIndex = this.itemNodes.indexOf(document.activeElement); + let targetElementIndex; + + if (currentIndex === -1) { + // somehow the select options has lost focus + targetElementIndex = 0; + } else { + if (direction === SHIFT_BACK) { + targetElementIndex = currentIndex === 0 ? this.itemNodes.length - 1 : currentIndex - 1; + } else { + targetElementIndex = currentIndex === this.itemNodes.length - 1 ? 0 : currentIndex + 1; + } + } + + this.focusItemAt(targetElementIndex); + } + + render() { + const { + className, + options, + valueOfSelected, + onChange, + isOpen, + hasDividers, + itemClassName, + itemLayoutAlign, + ...rest + } = this.props; + + const buttonClasses = classNames( + { + 'euiSuperSelect--isOpen__button': this.state.isPopoverOpen, + }, + className, + ); + + const itemClasses = classNames( + 'euiSuperSelect__item', + { + 'euiSuperSelect__item--hasDividers': hasDividers, + }, + itemClassName, + ); + + const button = ( + + ); + + const items = options.map((option, index) => { + const { + value, + dropdownDisplay, + inputDisplay, + ...optionRest + } = option; + + return ( + this.itemClicked(value)} + onKeyDown={this.onItemKeyDown} + layoutAlign={itemLayoutAlign} + buttonRef={node => this.setItemNode(node, index)} + style={{ width: this.state.menuWidth }} + role="option" + id={value} + {...optionRest} + > + {dropdownDisplay || inputDisplay} + + ); + }); + + return ( + + +

+ You are in a form selector of {options.length} items and must select a single option. + Use the up and down keys to navigate or escape to close. +

+
+
+ {items} +
+
+ ); + } +} + +EuiSuperSelect.propTypes = { + /** + * Classes (and `...rest`) will be applied to the control + */ + className: PropTypes.string, + /** + * Classes for the context menu item + */ + itemClassName: PropTypes.string, + /** + * You must pass an `onChange` function to handle the update of the value + */ + onChange: PropTypes.func, + /** + * Pass an array of options that must at least include: + * `value`: storing unique value of item, + * `inputDisplay`: what shows inside the form input when selected + * `dropdownDisplay` (optional): what shows for the item in the dropdown + */ + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + inputDisplay: PropTypes.node, + dropdownDisplay: PropTypes.node, + }), + ).isRequired, + valueOfSelected: PropTypes.string, + /** + * Change to `true` if you want horizontal lines between options. + * This is best used when options are multi-line. + */ + hasDividers: PropTypes.bool, + /** + * Change `EuiContextMenuItem` layout position of icon + */ + itemLayoutAlign: PropTypes.string, +}; + +EuiSuperSelect.defaultProps = { + options: [], +}; diff --git a/src/components/form/super_select/super_select.test.js b/src/components/form/super_select/super_select.test.js new file mode 100644 index 00000000000..61716f84982 --- /dev/null +++ b/src/components/form/super_select/super_select.test.js @@ -0,0 +1,116 @@ +import React from 'react'; +import { mount, render } from 'enzyme'; +import { requiredProps, takeMountedSnapshot } from '../../../test'; + +import { EuiSuperSelect } from './super_select'; + +jest.mock(`../form_row/make_id`, () => () => `generated-id`); + +jest.mock( + '../../portal', + () => ({ + EuiPortal: ({ children }) => children + }) +); + +describe('EuiSuperSelect', () => { + test('is rendered', () => { + const component = render( + {}} /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + describe('props', () => { + test('select component is rendered', () => { + const component = render( + {}} + /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('options are rendered when select is open', () => { + const component = mount( + {}} + data-test-subj="superSelect" + /> + ); + + component.find('button[data-test-subj="superSelect"]').simulate('click'); + + expect(takeMountedSnapshot(component)) + .toMatchSnapshot(); + }); + + test('valueSelected is rendered', () => { + const component = render( + {}} + /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('custom display is propagated to dropdown', () => { + const component = mount( + {}} + data-test-subj="superSelect" + /> + ); + + component.find('button[data-test-subj="superSelect"]').simulate('click'); + + expect(takeMountedSnapshot(component)) + .toMatchSnapshot(); + }); + + test('more props are propogated to each option', () => { + const component = mount( + {}} + data-test-subj="superSelect" + /> + ); + + component.find('button[data-test-subj="superSelect"]').simulate('click'); + + expect(takeMountedSnapshot(component)) + .toMatchSnapshot(); + + expect(component) + .toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/form/super_select/super_select_control.js b/src/components/form/super_select/super_select_control.js new file mode 100644 index 00000000000..00bf41066cd --- /dev/null +++ b/src/components/form/super_select/super_select_control.js @@ -0,0 +1,117 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { EuiScreenReaderOnly } from '../../accessibility'; +import makeId from '../form_row/make_id'; +import { + EuiFormControlLayout, +} from '../form_control_layout'; + +export const EuiSuperSelectControl = ({ + className, + options, + id, + name, + fullWidth, + isLoading, + defaultValue, + compressed, + value, + ...rest +}) => { + const classes = classNames( + 'euiSuperSelectControl', + { + 'euiSuperSelectControl--fullWidth': fullWidth, + 'euiSuperSelectControl--compressed': compressed, + 'euiSuperSelectControl-isLoading': isLoading, + }, + className + ); + + // React HTML input can not have both value and defaultValue properties. + // https://reactjs.org/docs/uncontrolled-components.html#default-values + let selectDefaultValue; + if (!value) { + selectDefaultValue = defaultValue || ''; + } + + let selectedValue; + if (value) { + const selectedOption = options.find(option => option.value === value); + selectedValue = selectedOption.inputDisplay; + } + + const icon = { + type: 'arrowDown', + side: 'right', + }; + + const screenReaderId = makeId(); + + return ( + + + + + + {/* + This is read when the user tabs in. The comma is important, + otherwise the screen reader often combines the text. + */} + + + Select an option: {selectedValue}, is selected + + + + + + + + ); +}; + +EuiSuperSelectControl.propTypes = { + name: PropTypes.string, + id: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.node.isRequired, + inputDisplay: PropTypes.node.isRequired + })).isRequired, + isInvalid: PropTypes.bool, + fullWidth: PropTypes.bool, + isLoading: PropTypes.bool, + /** + * when `true` creates a shorter height input + */ + compressed: PropTypes.bool, +}; + +EuiSuperSelectControl.defaultProps = { + options: [], + fullWidth: false, + isLoading: false, + compressed: false, +}; diff --git a/src/components/form/super_select/super_select_control.test.js b/src/components/form/super_select/super_select_control.test.js new file mode 100644 index 00000000000..117e1f6a768 --- /dev/null +++ b/src/components/form/super_select/super_select_control.test.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../../test'; + +import { EuiSuperSelectControl } from './super_select_control'; + +jest.mock(`../form_row/make_id`, () => () => `generated-id`); + +describe('EuiSuperSelectControl', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + + describe('props', () => { + test('fullWidth is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('compressed is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('isLoading is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('disabled options are rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('value option is rendered', () => { + const component = render( + {}} + /> + ); + + expect(component) + .toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/index.js b/src/components/index.js index 0213f6fe84f..7d34c6382f2 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -135,6 +135,8 @@ export { EuiRadioGroup, EuiRange, EuiSelect, + EuiSuperSelect, + EuiSuperSelectControl, EuiSwitch, EuiTextArea, EuiValidatableControl, diff --git a/src/components/popover/__snapshots__/popover.test.js.snap b/src/components/popover/__snapshots__/popover.test.js.snap index 029bfc1bca9..9250a8d08cd 100644 --- a/src/components/popover/__snapshots__/popover.test.js.snap +++ b/src/components/popover/__snapshots__/popover.test.js.snap @@ -94,11 +94,11 @@ exports[`EuiPopover props isOpen renders true 1`] = `
@@ -121,11 +121,11 @@ exports[`EuiPopover props ownFocus defaults to false 1`] = `
@@ -154,12 +154,12 @@ exports[`EuiPopover props ownFocus renders true 1`] = `

@@ -182,11 +182,11 @@ exports[`EuiPopover props panelClassName is rendered 1`] = `
@@ -209,11 +209,11 @@ exports[`EuiPopover props panelPaddingSize is rendered 1`] = `
diff --git a/src/components/popover/_popover.scss b/src/components/popover/_popover.scss index d67ea552fe8..33004c41a3f 100644 --- a/src/components/popover/_popover.scss +++ b/src/components/popover/_popover.scss @@ -42,7 +42,7 @@ pointer-events: auto; } - .euiPopover__panel__arrow { + .euiPopover__panelArrow { position: absolute; width: 0; height: 0; @@ -63,7 +63,7 @@ width: 0; } - &.euiPopover__panel__arrow-top { + &.euiPopover__panelArrow--top { &:before { bottom: -$euiPopoverArrowSize + 1; border-left: $euiPopoverArrowSize solid transparent; @@ -79,7 +79,7 @@ } } - &.euiPopover__panel__arrow-right { + &.euiPopover__panelArrow--right { &:before { left: -$euiPopoverArrowSize; top: 50%; @@ -97,7 +97,7 @@ } } - &.euiPopover__panel__arrow-bottom { + &.euiPopover__panelArrow--bottom { &:before { top: -$euiPopoverArrowSize; border-left: $euiPopoverArrowSize solid transparent; @@ -113,7 +113,7 @@ } } - &.euiPopover__panel__arrow-left { + &.euiPopover__panelArrow--left { &:before { right: -$euiPopoverArrowSize + 1; top: 50%; @@ -131,36 +131,41 @@ } } } + + + &.euiPopover__panel-noArrow .euiPopover__panelArrow { + display: none; + } } .euiPopover__panel.euiPopover__panel-withTitle { - .euiPopover__panel__arrow { - &.euiPopover__panel__arrow-bottom:after { + .euiPopover__panelArrow { + &.euiPopover__panelArrow--bottom:after { border-bottom-color: $euiColorLightestShade; } - &.euiPopover__panel__arrow-right:after { + &.euiPopover__panelArrow--right:after { border-right-color: $euiColorLightestShade; } - &.euiPopover__panel__arrow-left:after { + &.euiPopover__panelArrow--left:after { border-left-color: $euiColorLightestShade; } } } -.euiPopover__panel.euiPopover__panel-isOpen.euiPopover__panel-top { +.euiPopover__panel.euiPopover__panel-isOpen.euiPopover__panel--top { transform: translateY($euiPopoverTranslateDistance) translateZ(0); } -.euiPopover__panel.euiPopover__panel-isOpen.euiPopover__panel-bottom { +.euiPopover__panel.euiPopover__panel-isOpen.euiPopover__panel--bottom { transform: translateY(-$euiPopoverTranslateDistance) translateZ(0); } -.euiPopover__panel.euiPopover__panel-isOpen.euiPopover__panel-left { +.euiPopover__panel.euiPopover__panel-isOpen.euiPopover__panel--left { transform: translateX($euiPopoverTranslateDistance) translateZ(0); } -.euiPopover__panel.euiPopover__panel-isOpen.euiPopover__panel-right { +.euiPopover__panel.euiPopover__panel-isOpen.euiPopover__panel--right { transform: translateX(-$euiPopoverTranslateDistance) translateZ(0); } diff --git a/src/components/popover/index.d.ts b/src/components/popover/index.d.ts index 687308e6d0e..51622c122e0 100644 --- a/src/components/popover/index.d.ts +++ b/src/components/popover/index.d.ts @@ -31,6 +31,7 @@ declare module '@elastic/eui' { withTitle?: boolean; isOpen?: boolean; ownFocus?: boolean; + hasArrow?: boolean; anchorPosition?: PopoverAnchorPosition; panelClassName?: string; panelPaddingSize?: PanelPaddingSize; diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index d5c8b391cfc..12dd244dc43 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -250,7 +250,7 @@ export class EuiPopover extends Component { align: getPopoverAlignFromAnchorPosition(this.props.anchorPosition), anchor: this.button, popover: this.panel, - offset: 16, + offset: this.props.hasArrow ? 16 : 8, arrowConfig: { arrowWidth: 24, arrowBuffer: 10, @@ -269,7 +269,7 @@ export class EuiPopover extends Component { zIndex, }; - const arrowStyles = arrow; + const arrowStyles = this.props.hasArrow ? arrow : null; const arrowPosition = position; this.setState({ popoverStyles, arrowStyles, arrowPosition }); @@ -308,6 +308,7 @@ export class EuiPopover extends Component { panelClassName, panelPaddingSize, popoverRef, + hasArrow, repositionOnScroll, // eslint-disable-line no-unused-vars zIndex, // eslint-disable-line no-unused-vars ...rest @@ -325,9 +326,10 @@ export class EuiPopover extends Component { const panelClasses = classNames( 'euiPopover__panel', - `euiPopover__panel-${this.state.arrowPosition}`, + `euiPopover__panel--${this.state.arrowPosition}`, { 'euiPopover__panel-isOpen': this.state.isOpening }, { 'euiPopover__panel-withTitle': withTitle }, + { 'euiPopover__panel-noArrow': !hasArrow }, panelClassName ); @@ -357,8 +359,8 @@ export class EuiPopover extends Component { } const arrowClassNames = classNames( - 'euiPopover__panel__arrow', - `euiPopover__panel__arrow-${this.state.arrowPosition}` + 'euiPopover__panelArrow', + `euiPopover__panelArrow--${this.state.arrowPosition}` ); panel = ( @@ -433,6 +435,7 @@ EuiPopover.propTypes = { panelClassName: PropTypes.string, panelPaddingSize: PropTypes.oneOf(SIZES), popoverRef: PropTypes.func, + hasArrow: PropTypes.bool, container: PropTypes.oneOfType([ PropTypes.node, PropTypes.instanceOf(HTMLElement) @@ -448,4 +451,5 @@ EuiPopover.defaultProps = { ownFocus: false, anchorPosition: 'downCenter', panelPaddingSize: 'm', + hasArrow: true, }; diff --git a/src/components/search_bar/filters/__snapshots__/field_value_selection_filter.test.js.snap b/src/components/search_bar/filters/__snapshots__/field_value_selection_filter.test.js.snap index dbadf457d73..43889704dc8 100644 --- a/src/components/search_bar/filters/__snapshots__/field_value_selection_filter.test.js.snap +++ b/src/components/search_bar/filters/__snapshots__/field_value_selection_filter.test.js.snap @@ -17,6 +17,7 @@ exports[`FieldValueSelectionFilter render - all configurations 1`] = ` } closePopover={[Function]} + hasArrow={true} id="field_value_selection_0" isOpen={false} ownFocus={true} @@ -62,6 +63,7 @@ exports[`FieldValueSelectionFilter render - multi-select OR 1`] = ` } closePopover={[Function]} + hasArrow={true} id="field_value_selection_0" isOpen={false} ownFocus={true} @@ -107,6 +109,7 @@ exports[`FieldValueSelectionFilter render - options as a function 1`] = ` } closePopover={[Function]} + hasArrow={true} id="field_value_selection_0" isOpen={false} ownFocus={true} @@ -152,6 +155,7 @@ exports[`FieldValueSelectionFilter render - options as an array 1`] = ` } closePopover={[Function]} + hasArrow={true} id="field_value_selection_0" isOpen={false} ownFocus={true} diff --git a/src/components/text/_text.scss b/src/components/text/_text.scss index 6488daf7587..b579cd125f6 100644 --- a/src/components/text/_text.scss +++ b/src/components/text/_text.scss @@ -224,7 +224,8 @@ @include euiScaleText($euiFontSizeXS); } - > :last-child { + > :last-child, + .euiTextColor > :last-child { margin-bottom: 0 !important; } }