diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b399ac16db..5dd6529151c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Added `left-start` popover placement to `EuiDatePicker` ([#3511](https://github.com/elastic/eui/pull/3511)) - Added `theme` prop to `EuiHeader` ([#3524](https://github.com/elastic/eui/pull/3524)) - Added `.euiHeaderLink-isActive` class to `EuiHeaderLink` when `isActive` ([#3524](https://github.com/elastic/eui/pull/3524)) +- Added `display`, `descriptionWidth`, `textWrap` and `isInvalid` props to `EuiExpression` ([#3467](https://github.com/elastic/eui/pull/3467)) **Bug Fixes** diff --git a/src-docs/src/views/expression/columns.js b/src-docs/src/views/expression/columns.js new file mode 100644 index 00000000000..ce8521d310a --- /dev/null +++ b/src-docs/src/views/expression/columns.js @@ -0,0 +1,202 @@ +import React, { useState, Fragment } from 'react'; + +import { + EuiPopoverTitle, + EuiPopover, + EuiSelect, + EuiComboBox, + EuiExpression, + EuiTitle, + EuiSpacer, +} from '../../../../src/components'; + +export default () => { + const [example1, setExample1] = useState({ + isOpen: false, + value: ( + +

.kibana_task_manager,

+

kibana_sample_data_ecommerce

+
+ ), + }); + + const [example2, setExample2] = useState({ + isOpen: false, + value: 'count()', + }); + + const options = [ + { + label: '.kibana_task_manager', + }, + { + label: 'kibana_sample_data_ecommerce', + }, + { + label: '.kibana-event-log-8.0.0-000001', + }, + { + label: 'kibana_sample_data_flights', + }, + { + label: '.kibana-event-log-8.0.0', + }, + ]; + + const [selectedOptions, setSelected] = useState([options[0], options[1]]); + + const openExample1 = () => { + setExample1({ + ...example1, + isOpen: !example1.isOpen, + }); + }; + + const closeExample1 = () => { + setExample1({ + ...example1, + isOpen: false, + }); + }; + + const openExample2 = () => { + setExample2({ + ...example2, + isOpen: !example2.isOpen, + }); + }; + + const closeExample2 = () => { + setExample2({ + ...example2, + isOpen: false, + }); + }; + + const changeExample2 = e => { + setExample2({ + value: e.target.value, + isOpen: false, + }); + }; + + const onChange = selectedOptions => { + setSelected(selectedOptions); + const indices = selectedOptions.map((s, index) => { + return ( +

+ {s.label} + {index < selectedOptions.length - 1 ? ',' : null} +

+ ); + }); + setExample1({ + ...example1, + value: indices, + }); + }; + + const renderPopover1 = () => ( +
+ INDICES + +
+ ); + + const renderPopover2 = () => ( +
+ WHEN + +
+ ); + + return ( +
+ 0 ? false : true + } + isActive={example1.isOpen} + onClick={openExample1} + /> + } + isOpen={example1.isOpen} + closePopover={closeExample1} + ownFocus + display="block" + panelPaddingSize="s" + anchorPosition="downLeft"> + {renderPopover1()} + + + + } + isOpen={example2.isOpen} + closePopover={closeExample2} + ownFocus + display="block" + anchorPosition="downLeft"> + {renderPopover2()} + + + + +

Description width at 50px

+
+ {}} + /> +
+ ); +}; diff --git a/src-docs/src/views/expression/expression_example.js b/src-docs/src/views/expression/expression_example.js index a869f1b637c..2692e707834 100644 --- a/src-docs/src/views/expression/expression_example.js +++ b/src-docs/src/views/expression/expression_example.js @@ -12,7 +12,7 @@ import Expression from './expression'; const expressionSource = require('!!raw-loader!./expression'); const expressionHtml = renderToHtml(Expression); const expressionSnippet = ``; @@ -32,17 +32,44 @@ const stringingSource = require('!!raw-loader!./stringing'); const stringingHtml = renderToHtml(Stringing); const stringingSnippet = `
`; +import Columns from './columns'; +const columnsSource = require('!!raw-loader!./columns'); +const columnsHtml = renderToHtml(Columns); +const columnsSnippet = ``; + +import Invalid from './invalid'; +const invalidSource = require('!!raw-loader!./invalid'); +const invalidHtml = renderToHtml(Invalid); +const invalidSnippet = ``; + +import Truncate from './truncate'; +const truncateSource = require('!!raw-loader!./truncate'); +const truncateHtml = renderToHtml(Truncate); +const truncateSnippet = ``; + export const ExpressionExample = { title: 'Expression', sections: [ @@ -114,5 +141,89 @@ export const ExpressionExample = { snippet: stringingSnippet, demo: , }, + { + title: 'Column display', + source: [ + { + type: GuideSectionTypes.JS, + code: columnsSource, + }, + { + type: GuideSectionTypes.HTML, + code: columnsHtml, + }, + ], + text: ( +
+

+ There might be cases where displaying multiple{' '} + EuiExpressions in a paragraph is not ideal. For + example, when both the description and the{' '} + value are variable or when their text is quite + long. To use a column display instead, pass{' '} + {'display="columns"'}. +

+

+ In column display, each expression is its own line and the{' '} + description column is aligned to the right. The + default width for the description is 20%, but you + can customize this with the + descriptionWidth prop. When displaying a group of{' '} + EuiExpressions, make sure to set the same width for + all descriptions. +

+
+ ), + snippet: columnsSnippet, + demo: , + }, + { + title: 'Invalid state', + source: [ + { + type: GuideSectionTypes.JS, + code: invalidSource, + }, + { + type: GuideSectionTypes.HTML, + code: invalidHtml, + }, + ], + text: ( +

+ Set isInvalid to true to display{' '} + EuiExpression's error state. This state will + override the color prop with danger. +

+ ), + snippet: invalidSnippet, + demo: , + }, + { + title: 'Truncate text', + source: [ + { + type: GuideSectionTypes.JS, + code: truncateSource, + }, + { + type: GuideSectionTypes.HTML, + code: truncateHtml, + }, + ], + text: ( +

+ To truncate EuiExpression's content, pass{' '} + {'textWrap="truncate"'}. Text + truncation only works properly if the prop types of{' '} + description and value are + strings. If you're using nodes, use the{' '} + .eui-textTruncate utility class on all their + sub-children. +

+ ), + snippet: truncateSnippet, + demo: , + }, ], }; diff --git a/src-docs/src/views/expression/invalid.js b/src-docs/src/views/expression/invalid.js new file mode 100644 index 00000000000..bf2e09785cd --- /dev/null +++ b/src-docs/src/views/expression/invalid.js @@ -0,0 +1,24 @@ +import React from 'react'; + +import { EuiExpression, EuiSpacer } from '../../../../src/components'; + +export default () => ( +
+ {}} + description="sort by" + value="count" + isInvalid + /> + +
+ {}} + /> +
+
+); diff --git a/src-docs/src/views/expression/stringing.tsx b/src-docs/src/views/expression/stringing.tsx index f8cefa571bf..71acd396635 100644 --- a/src-docs/src/views/expression/stringing.tsx +++ b/src-docs/src/views/expression/stringing.tsx @@ -24,6 +24,8 @@ export default () => ( description="group by" value="right.kytccountynmbr" onClick={() => {}} + color="accent" /> + ); diff --git a/src-docs/src/views/expression/truncate.js b/src-docs/src/views/expression/truncate.js new file mode 100644 index 00000000000..7f46a8fcf82 --- /dev/null +++ b/src-docs/src/views/expression/truncate.js @@ -0,0 +1,48 @@ +import React, { Fragment } from 'react'; + +import { EuiExpression, EuiSpacer, EuiTitle } from '../../../../src/components'; + +const value = 'and a very long string as value'; +const description = 'some very very long description'; +const nodes = ( + +

.kibana_task_manager

+

kibana_sample_data_ecommerce

+
+); + +export default () => ( +
+
+ {}} + description={description} + value={value} + textWrap="truncate" + /> + + {}} + /> + +
+ +

eui-textTruncate applied to sub-children

+
+
+ {}} + /> +
+
+); diff --git a/src/components/expression/__snapshots__/expression.test.tsx.snap b/src/components/expression/__snapshots__/expression.test.tsx.snap index 9f91b365e54..9d97367d008 100644 --- a/src/components/expression/__snapshots__/expression.test.tsx.snap +++ b/src/components/expression/__snapshots__/expression.test.tsx.snap @@ -120,6 +120,49 @@ exports[`EuiExpression props color warning is rendered 1`] = ` `; +exports[`EuiExpression props descriptionWidth changes the description's width when using columns 1`] = ` + + + the answer is + + + + 42 + +
+ +`; + +exports[`EuiExpression props display can be columns 1`] = ` + + + the answer is + + + + 42 + + +`; + exports[`EuiExpression props isActive false renders inactive 1`] = ` `; -exports[`EuiExpression props uppercase false renders inherted case 1`] = ` +exports[`EuiExpression props isInvalid renders error state 1`] = ` + + + the answer is + + + + 42 + +
+ +`; + +exports[`EuiExpression props textWrap can truncate text 1`] = ` + + + the answer is + + + + 42 + + +`; + +exports[`EuiExpression props uppercase false renders inherited case 1`] = ` diff --git a/src/components/expression/_expression.scss b/src/components/expression/_expression.scss index 3797018cc0b..b97a51c05b0 100644 --- a/src/components/expression/_expression.scss +++ b/src/components/expression/_expression.scss @@ -7,12 +7,12 @@ @include euiFontSizeS; @include euiCodeFont; + border-bottom: $euiBorderWidthThick solid transparent; display: inline-block; /* 1 */ text-align: left; padding: ($euiSizeXS / 2) 0; - transition: all $euiAnimSpeedNormal $euiAnimSlightBounce; + transition: all $euiAnimSpeedNormal ease-in-out; color: $euiTextColor; - border-bottom: 2px solid transparent; &:focus { border-bottom-style: solid; @@ -21,6 +21,25 @@ & + .euiExpression { margin-left: $euiSizeS; } + + &.euiExpression--columns { + border-color: transparent; + // Ensures there's no flash of the dashed style before turning solid for the active state + border-bottom-style: solid; + margin-bottom: $euiSizeXS; + } + + &.euiExpression--truncate { + max-width: 100%; + + .euiExpression__description, + .euiExpression__value { + @include euiTextTruncate; + display: inline-block; + vertical-align: bottom; + } + + } } .euiExpression-isUppercase .euiExpression__description { @@ -37,11 +56,48 @@ } } +.euiExpression__icon { + margin-left: $euiSizeXS; +} + .euiExpression-isActive { border-bottom-style: solid; } +.euiExpression--columns { + width: 100%; + display: flex; + padding: $euiSizeXS; + border-radius: $euiSizeXS; + &.euiExpression-isClickable { + background-color: $euiColorLightestShade; + + // sass-lint:disable-block nesting-depth + &:focus, + &:hover:not(:disabled) { + .euiExpression__description, + .euiExpression__value { + // inner child specificity so it inherits underline color from text color + text-decoration: underline; + } + } + } + + .euiExpression__description { + text-align: right; + margin-right: $euiSizeS; + flex-shrink: 0; // Ensures it doesn't get smaller in case the value is really long + } + + .euiExpression__value { + flex-grow: 1; + } + + .euiExpression__icon { + margin-top: $euiSizeXS; + } +} @each $name, $color in $euiExpressionColors { .euiExpression--#{$name} { @@ -51,6 +107,7 @@ &.euiExpression-isActive { border-bottom-color: $color; + border-color: $color; } .euiExpression__description { diff --git a/src/components/expression/expression.test.tsx b/src/components/expression/expression.test.tsx index f334919d9d4..3a80cb64d80 100644 --- a/src/components/expression/expression.test.tsx +++ b/src/components/expression/expression.test.tsx @@ -70,7 +70,7 @@ describe('EuiExpression', () => { expect(render(component)).toMatchSnapshot(); }); - test('false renders inherted case', () => { + test('false renders inherited case', () => { const component = ( { }); }); + describe('display', () => { + test('can be columns', () => { + const component = ( + + ); + + expect(render(component)).toMatchSnapshot(); + }); + }); + + describe('isInvalid', () => { + test('renders error state', () => { + const component = ( + + ); + + expect(render(component)).toMatchSnapshot(); + }); + }); + + describe('descriptionWidth', () => { + test('changes the description's width when using columns', () => { + const component = ( + + ); + + expect(render(component)).toMatchSnapshot(); + }); + }); + + describe('textWrap', () => { + test('can truncate text', () => { + const component = ( + + ); + + expect(render(component)).toMatchSnapshot(); + }); + }); + describe('isActive', () => { test('true renders active', () => { const component = ( diff --git a/src/components/expression/expression.tsx b/src/components/expression/expression.tsx index 7de53d0b346..b0d15d056bc 100644 --- a/src/components/expression/expression.tsx +++ b/src/components/expression/expression.tsx @@ -25,6 +25,7 @@ import React, { } from 'react'; import classNames from 'classnames'; import { CommonProps, keysOf, ExclusiveUnion } from '../common'; +import { EuiIcon } from '../icon'; const colorToClassNameMap = { subdued: 'euiExpression--subdued', @@ -35,10 +36,20 @@ const colorToClassNameMap = { danger: 'euiExpression--danger', }; +const textWrapToClassNameMap = { + 'break-word': null, + truncate: 'euiExpression--truncate', +}; + export const COLORS = keysOf(colorToClassNameMap); export type ExpressionColor = keyof typeof colorToClassNameMap; +const displayToClassNameMap = { + inline: null, + columns: 'euiExpression--columns', +}; + export type EuiExpressionProps = CommonProps & { /** * First part of the expression @@ -66,6 +77,25 @@ export type EuiExpressionProps = CommonProps & { * Turns the component into a button and adds an editable style border at the bottom */ onClick?: MouseEventHandler; + /** + * Sets the display style for the expression. Defaults to `inline` + */ + display?: keyof typeof displayToClassNameMap; + /** + * Forces color to display as `danger` and shows an `alert` icon + */ + isInvalid?: boolean; + /** + * Sets a custom width for the description when using the columns layout. + * Set to a number for a custom width in `px`. + * Set to a string for a custom width in custom measurement. + * Defaults to `20%` + */ + descriptionWidth?: number | string; + /** + * Sets how to handle the wrapping of long text. + */ + textWrap?: keyof typeof textWrapToClassNameMap; }; type Buttonlike = EuiExpressionProps & @@ -86,9 +116,15 @@ export const EuiExpression: React.FunctionComponent< color = 'secondary', uppercase = true, isActive = false, + display = 'inline', + descriptionWidth = '20%', onClick, + isInvalid = false, + textWrap = 'break-word', ...rest }) => { + const calculatedColor = isInvalid ? 'danger' : color; + const classes = classNames( 'euiExpression', className, @@ -97,19 +133,44 @@ export const EuiExpression: React.FunctionComponent< 'euiExpression-isClickable': onClick, 'euiExpression-isUppercase': uppercase, }, - colorToClassNameMap[color] + displayToClassNameMap[display], + colorToClassNameMap[calculatedColor], + textWrapToClassNameMap[textWrap] ); const Component = onClick ? 'button' : 'span'; + const descriptionStyle = descriptionProps && descriptionProps.style; + const customWidth = + display === 'columns' && descriptionWidth + ? { + flexBasis: descriptionWidth, + ...descriptionStyle, + } + : undefined; + + const invalidIcon = isInvalid ? ( + + ) : ( + undefined + ); + return ( - + {description} {' '} {value} + {invalidIcon} ); };