From 77536181729c53087ce118d945304d2877892e3c Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Wed, 1 May 2019 12:49:32 -0400 Subject: [PATCH] Selectable EuiCards (#1895) --- CHANGELOG.md | 1 + src-docs/src/views/card/card_example.js | 56 ++++++- src-docs/src/views/card/card_selectable.js | 84 ++++++++++ .../button/button_empty/_button_empty.scss | 13 +- .../card/__snapshots__/card.test.js.snap | 149 +++++++++++++++++- .../__snapshots__/card_select.test.js.snap | 103 ++++++++++++ src/components/card/_card.scss | 31 ++-- src/components/card/_card_graphic.scss | 3 + src/components/card/_card_select.scss | 21 +++ src/components/card/_index.scss | 4 + src/components/card/_mixins.scss | 11 ++ src/components/card/_variables.scss | 21 +++ src/components/card/card.js | 31 +++- src/components/card/card.test.js | 140 +++++++++++----- src/components/card/card_select.js | 105 ++++++++++++ src/components/card/card_select.test.js | 70 ++++++++ 16 files changed, 768 insertions(+), 75 deletions(-) create mode 100644 src-docs/src/views/card/card_selectable.js create mode 100644 src/components/card/__snapshots__/card_select.test.js.snap create mode 100644 src/components/card/_card_graphic.scss create mode 100644 src/components/card/_card_select.scss create mode 100644 src/components/card/_mixins.scss create mode 100644 src/components/card/_variables.scss create mode 100644 src/components/card/card_select.js create mode 100644 src/components/card/card_select.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c8c02fc2c..c1ab7ba4621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [`master`](https://github.com/elastic/eui/tree/master) +- Added `selectable` prop to `EuiCard` ([#1895](https://github.com/elastic/eui/pull/1895)) - Converted `EuiValidatableControl` to TS ([#1879](https://github.com/elastic/eui/pull/1879)) **Bug fixes** diff --git a/src-docs/src/views/card/card_example.js b/src-docs/src/views/card/card_example.js index 4a22edc672c..f658f1d6807 100644 --- a/src-docs/src/views/card/card_example.js +++ b/src-docs/src/views/card/card_example.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import { renderToHtml } from '../../services'; @@ -12,6 +12,10 @@ import { EuiCallOut, } from '../../../../src/components'; +import { + EuiCardSelect +} from '../../../../src/components/card/card_select'; + import Card from './card'; const cardSource = require('!!raw-loader!./card'); const cardHtml = renderToHtml(Card); @@ -32,6 +36,10 @@ import CardLayout from './card_layout'; const cardLayoutSource = require('!!raw-loader!./card_layout'); const cardLayoutHtml = renderToHtml(CardLayout); +import CardSelectable from './card_selectable'; +const cardSelectableSource = require('!!raw-loader!./card_selectable'); +const cardSelectableHtml = renderToHtml(CardSelectable); + export const CardExample = { title: 'Card', sections: [{ @@ -90,7 +98,7 @@ export const CardExample = { /> ), - components: { EuiCard }, + props: { EuiCard }, demo: , }, { @@ -117,7 +125,7 @@ export const CardExample = { /> ), - components: { EuiCard }, + props: { EuiCard }, demo: , }, { @@ -156,7 +164,45 @@ export const CardExample = { change the title of the tooltip, supply a betaBadgeTitle prop.

), - components: { EuiCard }, + props: { EuiCard }, demo: , - }], + }, + { + title: 'Selectable', + source: [{ + type: GuideSectionTypes.JS, + code: cardSelectableSource, + }, { + type: GuideSectionTypes.HTML, + code: cardSelectableHtml, + }], + text: ( + +

+ When you have a list of cards that can be selected but do not navigate anywhere, you + can add the selectable prop. The prop is an object that requires an onClick. + It will apply the button as seen below, and passing selectable.isSelected = true will alter the + styles of the card and button to look selected. +

+

+ The select button is essentially an EuiButtonEmpty and so the selectable object can + also accept any props that EuiButtonEmpty can. +

+
+ ), + props: { EuiCardSelect }, + demo: , + snippet: `} + title="Title" + description="Example of a short card description." + footer={cardFooterContent} + selectable={{ + onClick: this.cardClicked, + isSelected: this.state.cardIsSelected, + isDisabled: this.state.cardIsDisabled, + }} +/>` + }, + ], }; diff --git a/src-docs/src/views/card/card_selectable.js b/src-docs/src/views/card/card_selectable.js new file mode 100644 index 00000000000..1d8d86890bc --- /dev/null +++ b/src-docs/src/views/card/card_selectable.js @@ -0,0 +1,84 @@ +import React, { Component } from 'react'; + +import { + EuiButtonEmpty, + EuiCard, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, +} from '../../../../src/components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + card1Selected: true, + card2Selected: false, + }; + } + + card1Clicked = () => { + this.setState({ + card1Selected: !this.state.card1Selected, + }); + } + + card2Clicked = () => { + this.setState({ + card2Selected: !this.state.card2Selected, + }); + } + + render() { + const cardFooterContent = ( + + More details + + ); + + return ( + + + } + title="Sketch" + description="Example of a short card description." + footer={cardFooterContent} + selectable={{ + onClick: this.card1Clicked, + isSelected: this.state.card1Selected, + }} + /> + + + } + title="Google" + description="Example of a longer card description. See how the footers stay lined up." + footer={cardFooterContent} + selectable={{ + onClick: this.card2Clicked, + isSelected: this.state.card2Selected, + }} + /> + + + } + title="Not Adobe" + description="Example of a short card description." + footer={cardFooterContent} + selectable={{ + onClick: () => {}, + isDisabled: true, + }} + /> + + + ); + } +} diff --git a/src/components/button/button_empty/_button_empty.scss b/src/components/button/button_empty/_button_empty.scss index 43b31d287e5..8f2b19ca767 100644 --- a/src/components/button/button_empty/_button_empty.scss +++ b/src/components/button/button_empty/_button_empty.scss @@ -76,10 +76,15 @@ $buttonTypes: ( // Create button modifiders based upon the map. @each $name, $color in $buttonTypes { .euiButtonEmpty--#{$name} { - color: $color; - - .euiButtonEmpty__icon { - fill: $color; + @if ($name == 'ghost') { + // Ghost is unique and ALWAYS sits against a dark background. + color: $color; + } @else if ($name == 'text') { + // The default color is lighter than the normal text color, make the it the text color + color: $euiTextColor; + } @else { + // Other colors need to check their contrast against the page background color. + color: makeHighContrastColor($color, $euiColorEmptyShade); } &:focus { diff --git a/src/components/card/__snapshots__/card.test.js.snap b/src/components/card/__snapshots__/card.test.js.snap index 6af8c7e3a10..6566b7e6b3e 100644 --- a/src/components/card/__snapshots__/card.test.js.snap +++ b/src/components/card/__snapshots__/card.test.js.snap @@ -1,6 +1,68 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`EuiCard horizontal 1`] = ` +exports[`EuiCard is rendered 1`] = ` +
+ + + Card title + +
+

+ Card description +

+
+
+ +
+`; + +exports[`EuiCard props footer 1`] = ` +
+ + + Card title + +
+

+ Card description +

+
+
+ + + Footer + + +
+`; + +exports[`EuiCard props horizontal 1`] = `
@@ -9,11 +71,13 @@ exports[`EuiCard horizontal 1`] = ` > Card title

Card description @@ -23,7 +87,7 @@ exports[`EuiCard horizontal 1`] = `

`; -exports[`EuiCard icon 1`] = ` +exports[`EuiCard props icon 1`] = `
@@ -56,11 +120,13 @@ exports[`EuiCard icon 1`] = ` > Card title

Card description @@ -73,22 +139,93 @@ exports[`EuiCard icon 1`] = `

`; -exports[`EuiCard is rendered 1`] = ` +exports[`EuiCard props selectable 1`] = `
+ + + Card title + +
+

+ Card description +

+
+
+ + +
+`; + +exports[`EuiCard props textAlign 1`] = ` +
Card title
+

+ Card description +

+
+
+ +
+`; + +exports[`EuiCard props titleElement 1`] = ` +
+ +

+ Card title +

+

Card description diff --git a/src/components/card/__snapshots__/card_select.test.js.snap b/src/components/card/__snapshots__/card_select.test.js.snap new file mode 100644 index 00000000000..56d3a14c309 --- /dev/null +++ b/src/components/card/__snapshots__/card_select.test.js.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiCardSelect is rendered 1`] = ` + +`; + +exports[`EuiCardSelect props can override color 1`] = ` + +`; + +exports[`EuiCardSelect props can override text 1`] = ` + +`; + +exports[`EuiCardSelect props isDisabled 1`] = ` + +`; + +exports[`EuiCardSelect props isSelected 1`] = ` + +`; diff --git a/src/components/card/_card.scss b/src/components/card/_card.scss index c5f6a5b2048..83772b5e230 100644 --- a/src/components/card/_card.scss +++ b/src/components/card/_card.scss @@ -1,11 +1,6 @@ -@import '../panel/variables'; @import '../panel/mixins'; @import '../badge/beta_badge/mixins'; -$euiCardSpacing: map-get($euiPanelPaddingModifiers, 'paddingMedium'); -$euiCardTitleSize: 18px; // Hardcoded pixel value for theme parity. -$euiCardGraphicHeight: 40px; // Actual height of the svg used in EuiCardGraphic - // Start with a base of EuiPanel styles @include euiPanel($selector: 'euiCard'); @@ -62,10 +57,22 @@ $euiCardGraphicHeight: 40px; // Actual height of the svg used in EuiCardGraphic } } - &.euiCard--hasBottomGraphic { + &.euiCard--hasBottomGraphic, + &.euiCard--isSelectable { position: relative; padding-bottom: $euiCardSpacing + $euiCardGraphicHeight; } + + &.euiCard-isSelected { + @include euiBottomShadowMedium; + transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance, border-color $euiAnimSpeedFast $euiAnimSlightResistance; + } +} + +@each $name, $color in $euiCardSelectButtonBorders { + .euiCard--isSelectable--#{$name}.euiCard-isSelected { + border-color: $color; + } } .euiCard__top { @@ -151,14 +158,4 @@ $euiCardGraphicHeight: 40px; // Actual height of the svg used in EuiCardGraphic } } -// Optional decorative graphics -.euiCard__graphic { - position: absolute; - bottom: 0; - left: 0; - height: $euiCardGraphicHeight; - width: 100%; - overflow: hidden; - border-bottom-left-radius: $euiBorderRadius; - border-bottom-right-radius: $euiBorderRadius; -} + diff --git a/src/components/card/_card_graphic.scss b/src/components/card/_card_graphic.scss new file mode 100644 index 00000000000..9052151998e --- /dev/null +++ b/src/components/card/_card_graphic.scss @@ -0,0 +1,3 @@ +.euiCard__graphic { + @include euiCardBottomNodePosition; +} diff --git a/src/components/card/_card_select.scss b/src/components/card/_card_select.scss new file mode 100644 index 00000000000..6a63a9e141f --- /dev/null +++ b/src/components/card/_card_select.scss @@ -0,0 +1,21 @@ +.euiCardSelect { + // Option select button that expands to sides and bottom + @include euiCardBottomNodePosition; + font-weight: $euiFontWeightBold; + + // Create button modifiers based upon the map. + @each $name, $color in $euiCardSelectButtonBackgrounds { + &--#{$name}:enabled { + background-color: $color; + + // Custom success text color since it doesn't exist on EuiButtonEmpty + @if ($name == 'success') { + color: makeHighContrastColor($euiColorSuccess, $color); + } + } + } + + &:disabled { + background-color: $euiPageBackgroundColor; + } +} diff --git a/src/components/card/_index.scss b/src/components/card/_index.scss index c68691f95f2..316afc276c2 100644 --- a/src/components/card/_index.scss +++ b/src/components/card/_index.scss @@ -1 +1,5 @@ +@import 'variables'; +@import 'mixins'; @import 'card'; +@import 'card_graphic'; +@import 'card_select'; diff --git a/src/components/card/_mixins.scss b/src/components/card/_mixins.scss new file mode 100644 index 00000000000..af0aa29439a --- /dev/null +++ b/src/components/card/_mixins.scss @@ -0,0 +1,11 @@ +@mixin euiCardBottomNodePosition { + position: absolute; + bottom: 0; + left: 0; + height: $euiCardGraphicHeight !important; // sass-lint:disable-line no-important -- To override .euiButtonEmpty--xSmall + width: 100%; + overflow: hidden; + // Subtract 1px from border radius since it sits inside a border-radius + border-bottom-left-radius: $euiBorderRadius - 1px; + border-bottom-right-radius: $euiBorderRadius - 1px; +} diff --git a/src/components/card/_variables.scss b/src/components/card/_variables.scss new file mode 100644 index 00000000000..cc4fb8d85fe --- /dev/null +++ b/src/components/card/_variables.scss @@ -0,0 +1,21 @@ +@import '../panel/variables'; + +$euiCardSpacing: map-get($euiPanelPaddingModifiers, 'paddingMedium'); +$euiCardTitleSize: 18px; // Hardcoded pixel value for theme parity. +$euiCardGraphicHeight: 40px; // Actual height of the svg used in EuiCardGraphic + +$euiCardSelectButtonBorders: ( + text: $euiColorSuccess, // Default for selected + primary: $euiColorPrimary, + success: $euiColorSuccess, + danger: $euiColorDanger, + ghost: $euiColorDarkShade, +); + +$euiCardSelectButtonBackgrounds: ( + text: $euiColorLightestShade, // Default for unselected + primary: tintOrShade($euiColorPrimary, 90%, 70%), + success: tintOrShade($euiColorSuccess, 90%, 70%), + danger: tintOrShade($euiColorDanger, 90%, 70%), + ghost: $euiColorDarkShade, +); diff --git a/src/components/card/card.js b/src/components/card/card.js index d8b6d1d5a89..a3de82e19c7 100644 --- a/src/components/card/card.js +++ b/src/components/card/card.js @@ -6,6 +6,8 @@ import { getSecureRelForTarget } from '../../services'; import { EuiText } from '../text'; import { EuiTitle } from '../title'; import { EuiBetaBadge } from '../badge/beta_badge'; +import { EuiCardSelect, EuiCardSelectProps, euiCardSelectableColor } from './card_select'; +import makeId from '../form/form_row/make_id'; const textAlignToClassNameMap = { left: 'euiCard--leftAligned', @@ -55,8 +57,12 @@ export const EuiCard = ({ betaBadgeTitle, layout, bottomGraphic, + selectable, ...rest, }) => { + const selectableColorClass = selectable ? + `euiCard--isSelectable--${euiCardSelectableColor(selectable.color, selectable.isSelected)}` : undefined; + const classes = classNames( 'euiCard', textAlignToClassNameMap[textAlign], @@ -66,10 +72,15 @@ export const EuiCard = ({ 'euiCard--hasBetaBadge': betaBadgeLabel, 'euiCard--hasIcon': icon, 'euiCard--hasBottomGraphic': bottomGraphic, + 'euiCard--isSelectable': selectable, + 'euiCard-isSelected': selectable && selectable.isSelected, }, + selectableColorClass, className, ); + const ariaId = makeId(); + let secureRel; if (href) { secureRel = getSecureRelForTarget({ href, target, rel }); @@ -135,6 +146,15 @@ export const EuiCard = ({ ); } + let optionalSelectButton; + if (selectable) { + if (bottomGraphic) { + console.warn('EuiCard cannot support both `bottomGraphic` and `selectable`. It will ignore the bottomGraphic.'); + } + + optionalSelectButton = ; + } + return ( - + {title} - +

{description}

@@ -164,7 +184,7 @@ export const EuiCard = ({ } - {optionalBottomGraphic} + {optionalSelectButton || optionalBottomGraphic} ); }; @@ -223,6 +243,11 @@ EuiCard.propTypes = { */ betaBadgeTitle: PropTypes.string, + /** + * Adds a button to the bottom of the card to allow for in-place selection. + */ + selectable: PropTypes.shape(EuiCardSelectProps), + /** * Add a decorative bottom graphic to the card. * This should be used sparingly, consult the Kibana Design team before use. diff --git a/src/components/card/card.test.js b/src/components/card/card.test.js index b31fb6ee137..fcc9372fc56 100644 --- a/src/components/card/card.test.js +++ b/src/components/card/card.test.js @@ -10,6 +10,8 @@ import { EuiIcon, } from '../icon'; +jest.mock(`./../form/form_row/make_id`, () => () => `generated-id`); + describe('EuiCard', () => { test('is rendered', () => { const component = render( @@ -20,58 +22,116 @@ describe('EuiCard', () => { .toMatchSnapshot(); }); - test('icon', () => { - const component = render( - } - /> - ); + describe('props', () => { + test('icon', () => { + const component = render( + } + /> + ); - expect(component) - .toMatchSnapshot(); - }); + expect(component) + .toMatchSnapshot(); + }); - test('horizontal', () => { - const component = render( - - ); + test('horizontal', () => { + const component = render( + + ); - expect(component) - .toMatchSnapshot(); - }); + expect(component) + .toMatchSnapshot(); + }); + + describe('onClick', () => { + it('supports onClick as a link', () => { + const handler = jest.fn(); + const component = mount( + + ); + component.find('a').simulate('click'); + expect(handler.mock.calls.length).toEqual(1); + }); + + it('supports onClick as a button', () => { + const handler = jest.fn(); + const component = mount( + + ); + component.find('button').simulate('click'); + expect(handler.mock.calls.length).toEqual(1); + }); + }); + + test('titleElement', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); - describe('onClick', () => { - it('supports onClick as a link', () => { - const handler = jest.fn(); - const component = mount( + test('footer', () => { + const component = render( Footer + } /> ); - component.find('a').simulate('click'); - expect(handler.mock.calls.length).toEqual(1); + + expect(component) + .toMatchSnapshot(); }); - it('supports onClick as a button', () => { - const handler = jest.fn(); - const component = mount( + test('textAlign', () => { + const component = render( ); - component.find('button').simulate('click'); - expect(handler.mock.calls.length).toEqual(1); + + expect(component) + .toMatchSnapshot(); + }); + + test('selectable', () => { + const component = render( + {} + }} + /> + ); + + expect(component) + .toMatchSnapshot(); }); }); }); diff --git a/src/components/card/card_select.js b/src/components/card/card_select.js new file mode 100644 index 00000000000..bfca559e0e6 --- /dev/null +++ b/src/components/card/card_select.js @@ -0,0 +1,105 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { + EuiButtonEmpty, + COLORS as BUTTON_EMPTY_COLORS, +} from '../button/button_empty'; + +import { EuiI18n } from '../i18n'; + +export const EuiCardSelect = ({ + className, + onClick, + isSelected, + isDisabled, + color, + children, + ...rest +}) => { + const child = euiCardSelectableText(isSelected, isDisabled, children); + + const selectClasses = classNames( + 'euiCardSelect', + `euiCardSelect--${euiCardSelectableColor(color, isSelected)}`, + className + ); + + return ( + + {child} + + ); +}; + +export const EuiCardSelectProps = { + className: PropTypes.string, + /** + * You must handle the click event in order to have a select button + */ + onClick: PropTypes.func.isRequired, + /** + * Is in the selected state + */ + isSelected: PropTypes.bool, + isDisabled: PropTypes.bool, + /** + * Override the default color with one of the available colors from `EuiButtonEmpty` + */ + color: PropTypes.oneOf(BUTTON_EMPTY_COLORS), + /** + * Override the content (text) of the button + */ + children: PropTypes.node, +}; + +EuiCardSelect.propTypes = EuiCardSelectProps; + +function euiCardSelectableText(isSelected, isDisabled, children) { + if (children) { + return children; + } + + let text; + + if (isSelected) { + text = (); + } else if (isDisabled) { + text = (); + } else { + text = (); + } + + return text; +} + +export function euiCardSelectableColor(color, isSelected) { + let calculatedColor; + if (color) { + calculatedColor = color; + } else if (isSelected) { + calculatedColor = 'success'; + } else { + calculatedColor = 'text'; + } + + return calculatedColor; +} diff --git a/src/components/card/card_select.test.js b/src/components/card/card_select.test.js new file mode 100644 index 00000000000..6c427154640 --- /dev/null +++ b/src/components/card/card_select.test.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../test'; + +import { + EuiCardSelect, +} from './card_select'; + +describe('EuiCardSelect', () => { + test('is rendered', () => { + const component = render( + {}} {...requiredProps} /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + describe('props', () => { + + test('isSelected', () => { + const component = render( + {}} + isSelected + /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('isDisabled', () => { + const component = render( + {}} + isDisabled + /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('can override color', () => { + const component = render( + {}} + color="danger" + /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('can override text', () => { + const component = render( + {}} + children="Custom text" + /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + }); +});