diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cadec1e898..1f7edc8e7a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ No public interface changes since `16.1.0`. - Added `disabled` prop to the `EuiCheckboxGroup` definition ([#2545](https://github.com/elastic/eui/pull/2545)) - Added `disabled` option to the `option` attribute of the `options` object that is passed to the `EuiCheckboxGroup` so that checkboxes in a group can be individually disabled ([#2548](https://github.com/elastic/eui/pull/2548)) - Added `EuiAspectRatio` component that allows for responsively resizing embeds ([#2535](https://github.com/elastic/eui/pull/2535)) +- Added `EuiCheckableCard` component, for radio buttons or checkboxes with complex child content ([#2555](https://github.com/elastic/eui/pull/2555)) +- Updated `EuiCheckbox` and `EuiCheckboxGroup` to TypeScript. ([#2555](https://github.com/elastic/eui/pull/2555)) - Added `display` and `titleSize` props to `EuiCard` ([#2566](https://github.com/elastic/eui/pull/2566)) - Added `accessibility` glyph to `EuiIcon` ([#2566](https://github.com/elastic/eui/pull/2566)) diff --git a/src-docs/src/views/card/card_checkable.js b/src-docs/src/views/card/card_checkable.js new file mode 100644 index 00000000000..c2e06416f0f --- /dev/null +++ b/src-docs/src/views/card/card_checkable.js @@ -0,0 +1,101 @@ +import React, { Component, Fragment } from 'react'; + +import { + EuiCheckableCard, + EuiSpacer, + EuiRadioGroup, + EuiTitle, +} from '../../../../src/components'; + +import makeId from '../../../../src/components/form/form_row/make_id'; + +export default class extends Component { + state = { + radioName: makeId(), + radio: 'radio2', + nestedRadio: 'nestedRadio1', + checkbox: false, + }; + + render() { + const { radioName } = this.state; + + const nestedRadios = [ + { + id: 'nestedRadio1', + label: 'Nested option one', + }, + { + id: 'nestedRadio2', + label: 'Nested option two', + }, + { + id: 'nestedRadio3', + label: 'Nested option three', + }, + ]; + + return ( + +
+ + + Checkable card radio group with legend + + + + + + this.setState({ radio: 'radio1' })} + /> + + + + this.setState({ radio: 'radio2' })}> + this.setState({ nestedRadio })} + disabled={this.state.radio !== 'radio2'} + /> + + + + + this.setState({ radio: 'radio3' })} + disabled + /> +
+ + + + this.setState({ checkbox: !this.state.checkbox })} + /> +
+ ); + } +} diff --git a/src-docs/src/views/card/card_example.js b/src-docs/src/views/card/card_example.js index 4696cb80645..34c3b9ee52b 100644 --- a/src-docs/src/views/card/card_example.js +++ b/src-docs/src/views/card/card_example.js @@ -4,7 +4,12 @@ import { renderToHtml } from '../../services'; import { GuideSectionTypes } from '../../components'; -import { EuiCode, EuiCard, EuiCallOut } from '../../../../src/components'; +import { + EuiCode, + EuiCard, + EuiCallOut, + EuiCheckableCard, +} from '../../../../src/components'; import { EuiCardSelect } from '../../../../src/components/card/card_select'; @@ -36,6 +41,10 @@ import CardChildren from './card_children'; const cardChildrenSource = require('!!raw-loader!./card_children'); const cardChildrenHtml = renderToHtml(CardChildren); +import CardCheckable from './card_checkable'; +const cardCheckableSource = require('!!raw-loader!./card_checkable'); +const cardCheckableHtml = renderToHtml(CardCheckable); + export const CardExample = { title: 'Card', sections: [ @@ -293,6 +302,42 @@ export const CardExample = { />} />`, }, + { + title: 'Checkable', + text: ( + +

+ EuiCheckableCard wraps an{' '} + EuiRadio or EuiCheckbox with a + more-prominent panel, allowing for children to be displayed. +

+ + When used as a radio group, you must provide a{' '} + fieldset with a legend for + accessibility. + + } + /> +
+ ), + source: [ + { + type: GuideSectionTypes.JS, + code: cardCheckableSource, + }, + { + type: GuideSectionTypes.HTML, + code: cardCheckableHtml, + }, + ], + props: { + EuiCheckableCard, + }, + demo: , + }, { title: 'Custom children', source: [ diff --git a/src-docs/src/views/form_controls/radio_group.js b/src-docs/src/views/form_controls/radio_group.js index e727f684893..b070fbd65ea 100644 --- a/src-docs/src/views/form_controls/radio_group.js +++ b/src-docs/src/views/form_controls/radio_group.js @@ -44,6 +44,7 @@ export default class extends Component { options={this.radios} idSelected={this.state.radioIdSelected} onChange={this.onChange} + name="radio group" /> diff --git a/src/components/card/_card.scss b/src/components/card/_card.scss index 3e9f0ae5835..5e56b4add56 100644 --- a/src/components/card/_card.scss +++ b/src/components/card/_card.scss @@ -67,6 +67,7 @@ &:focus, &:hover { + .euiCard__title, .euiCard__titleAnchor, .euiCard__titleButton { text-decoration: underline; diff --git a/src/components/card/_index.scss b/src/components/card/_index.scss index 23b53ba7174..9451ad19213 100644 --- a/src/components/card/_index.scss +++ b/src/components/card/_index.scss @@ -2,3 +2,4 @@ @import 'mixins'; @import 'card'; @import 'card_select'; +@import 'checkable_card/index'; diff --git a/src/components/card/checkable_card/__snapshots__/checkable_card.test.tsx.snap b/src/components/card/checkable_card/__snapshots__/checkable_card.test.tsx.snap new file mode 100644 index 00000000000..82cc79d5158 --- /dev/null +++ b/src/components/card/checkable_card/__snapshots__/checkable_card.test.tsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiCheckableCard is rendered 1`] = ` +
+
+
+
+ +
+
+
+ +
+
+`; + +exports[`EuiCheckableCard renders a checkbox when specified 1`] = ` +
+
+
+
+ +
+
+
+ +
+
+`; diff --git a/src/components/card/checkable_card/_checkable_card.scss b/src/components/card/checkable_card/_checkable_card.scss new file mode 100644 index 00000000000..92b77c49e63 --- /dev/null +++ b/src/components/card/checkable_card/_checkable_card.scss @@ -0,0 +1,55 @@ +@import '../../panel/mixins'; + +@include euiPanel('euiCheckableCard'); + +.euiCheckableCard { + transition: border-color $euiAnimSpeedNormal ease-in; + overflow: hidden; // Hides background color inside panel rounded corners + + &:not(.euiCheckableCard-isDisabled) { + &:focus-within { + @include euiFocusRing; + } + } +} + +.euiCheckableCard-isChecked { + border-color: $euiColorPrimary; +} + + + +.euiCheckableCard__row { + display: flex; + align-items: stretch; +} + +.euiCheckableCard__control { + display: flex; + flex: 0 0 $euiSizeXXL; + justify-content: center; + align-items: center; + background-color: map-get($euiCardSelectButtonBackgrounds, 'text'); + transition: background-color $euiAnimSpeedNormal ease-in; + + .euiCheckableCard-isChecked & { + background-color: map-get($euiCardSelectButtonBackgrounds, 'primary'); + } +} + +.euiCheckableCard__label { + flex-grow: 1; + font-size: $euiFontSize; + line-height: $euiSizeL; + padding: $euiSizeS $euiSizeS $euiSizeS $euiSize; + cursor: pointer; +} + +.euiCheckableCard__label-isDisabled { + color: $euiFormControlDisabledColor; + cursor: not-allowed; +} + +.euiCheckableCard__children { + padding: 0 $euiSizeS $euiSizeS $euiSize; +} diff --git a/src/components/card/checkable_card/_index.scss b/src/components/card/checkable_card/_index.scss new file mode 100644 index 00000000000..fdab488f2ad --- /dev/null +++ b/src/components/card/checkable_card/_index.scss @@ -0,0 +1 @@ +@import 'checkable_card'; diff --git a/src/components/card/checkable_card/checkable_card.test.tsx b/src/components/card/checkable_card/checkable_card.test.tsx new file mode 100644 index 00000000000..cadfd326a5e --- /dev/null +++ b/src/components/card/checkable_card/checkable_card.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiCheckableCard } from './checkable_card'; + +const checkablePanelRequiredProps = { + label: 'Label', + id: 'id', + onChange: () => {}, +}; + +describe('EuiCheckableCard', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('renders a checkbox when specified', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/components/card/checkable_card/checkable_card.tsx b/src/components/card/checkable_card/checkable_card.tsx new file mode 100644 index 00000000000..8da1def6e1c --- /dev/null +++ b/src/components/card/checkable_card/checkable_card.tsx @@ -0,0 +1,90 @@ +import React, { FunctionComponent, ReactNode } from 'react'; +import classNames from 'classnames'; + +import { EuiRadio, EuiRadioProps } from '../../form/radio'; +import { EuiCheckbox, EuiCheckboxProps } from '../../form/checkbox'; + +interface EuiCheckableCardBaseProps { + id: string; + label: ReactNode; +} + +// if `checkableType` is left out or set to 'radio', use EuiRadioProps +interface EuiCheckableCardAsRadioProps + extends Omit { + /** + * Whether the control is a radio button or checkbox + */ + checkableType?: 'radio'; +} + +// if `checkableType` is set to 'checkbox', use EuiCheckboxProps +interface EuiCheckableCardAsCheckboxProps + extends Omit { + checkableType: 'checkbox'; +} + +export type EuiCheckableCardProps = EuiCheckableCardBaseProps & + (EuiCheckableCardAsCheckboxProps | EuiCheckableCardAsRadioProps); + +export const EuiCheckableCard: FunctionComponent = ({ + children, + className, + checkableType = 'radio', + label, + checked, + disabled, + ...rest +}) => { + const { id } = rest; + const classes = classNames( + 'euiCheckableCard', + { + 'euiCheckableCard-isChecked': checked, + 'euiCheckableCard-isDisabled': disabled, + }, + className + ); + + let checkableElement; + if (checkableType === 'radio') { + checkableElement = ( + + ); + } else { + checkableElement = ( + + ); + } + + const labelClasses = classNames('euiCheckableCard__label', { + 'euiCheckableCard__label-isDisabled': disabled, + }); + + return ( +
+
+
{checkableElement}
+ +
+ {children && ( +
+ {/* Empty div for left side background color only */} +
+
+ {children} +
+
+ )} +
+ ); +}; diff --git a/src/components/card/checkable_card/index.ts b/src/components/card/checkable_card/index.ts new file mode 100644 index 00000000000..53fa42d59c3 --- /dev/null +++ b/src/components/card/checkable_card/index.ts @@ -0,0 +1 @@ +export { EuiCheckableCard, EuiCheckableCardProps } from './checkable_card'; diff --git a/src/components/card/index.ts b/src/components/card/index.ts index 679ef81085d..3556951adfb 100644 --- a/src/components/card/index.ts +++ b/src/components/card/index.ts @@ -1 +1,2 @@ export { EuiCard } from './card'; +export { EuiCheckableCard } from './checkable_card'; diff --git a/src/components/form/checkbox/__snapshots__/checkbox.test.js.snap b/src/components/form/checkbox/__snapshots__/checkbox.test.tsx.snap similarity index 100% rename from src/components/form/checkbox/__snapshots__/checkbox.test.js.snap rename to src/components/form/checkbox/__snapshots__/checkbox.test.tsx.snap diff --git a/src/components/form/checkbox/__snapshots__/checkbox_group.test.js.snap b/src/components/form/checkbox/__snapshots__/checkbox_group.test.tsx.snap similarity index 100% rename from src/components/form/checkbox/__snapshots__/checkbox_group.test.js.snap rename to src/components/form/checkbox/__snapshots__/checkbox_group.test.tsx.snap index 2e37e919b1c..9e898755868 100644 --- a/src/components/form/checkbox/__snapshots__/checkbox_group.test.js.snap +++ b/src/components/form/checkbox/__snapshots__/checkbox_group.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`EuiCheckboxGroup (mocked checkbox) individual disabled is rendered 1`] = ` +exports[`EuiCheckboxGroup (mocked checkbox) disabled is rendered 1`] = `
`; -exports[`EuiCheckboxGroup (mocked checkbox) disabled is rendered 1`] = ` +exports[`EuiCheckboxGroup (mocked checkbox) idToSelectedMap is rendered 1`] = `
`; -exports[`EuiCheckboxGroup (mocked checkbox) idToSelectedMap is rendered 1`] = ` +exports[`EuiCheckboxGroup (mocked checkbox) individual disabled is rendered 1`] = `
diff --git a/src/components/form/checkbox/checkbox.test.js b/src/components/form/checkbox/checkbox.test.tsx similarity index 85% rename from src/components/form/checkbox/checkbox.test.js rename to src/components/form/checkbox/checkbox.test.tsx index 5f43bc2f8f2..fd802836ed3 100644 --- a/src/components/form/checkbox/checkbox.test.js +++ b/src/components/form/checkbox/checkbox.test.tsx @@ -26,16 +26,6 @@ describe('EuiCheckbox', () => { }); describe('props', () => { - test('id is required', () => { - expect(() => ( - {}} /> - )).toThrow(); - }); - - test('onChange is required', () => { - expect(() => ).toThrow(); - }); - test('check is rendered', () => { const component = render( diff --git a/src/components/form/checkbox/checkbox.js b/src/components/form/checkbox/checkbox.tsx similarity index 56% rename from src/components/form/checkbox/checkbox.js rename to src/components/form/checkbox/checkbox.tsx index ac4a67ea3a5..88d5e23515e 100644 --- a/src/components/form/checkbox/checkbox.js +++ b/src/components/form/checkbox/checkbox.tsx @@ -1,16 +1,48 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { + Component, + ChangeEventHandler, + ReactNode, + InputHTMLAttributes, +} from 'react'; import classNames from 'classnames'; -import { omit } from '../../../services/objects'; +import { keysOf, CommonProps } from '../../common'; const typeToClassNameMap = { inList: 'euiCheckbox--inList', }; -export const TYPES = Object.keys(typeToClassNameMap); +export const TYPES = keysOf(typeToClassNameMap); + +export type EuiCheckboxType = keyof typeof typeToClassNameMap; + +export interface EuiCheckboxProps + extends CommonProps, + InputHTMLAttributes { + id: string; + checked?: boolean; + onChange: ChangeEventHandler; // overriding to make it required + inputRef?: (element: HTMLInputElement) => void; + label?: ReactNode; + type?: EuiCheckboxType; + disabled?: boolean; + /** + * when `true` creates a shorter height checkbox row + */ + compressed?: boolean; + indeterminate?: boolean; +} + +export class EuiCheckbox extends Component { + static defaultProps = { + checked: false, + disabled: false, + indeterminate: false, + compressed: false, + }; + + inputRef?: HTMLInputElement = undefined; -export class EuiCheckbox extends Component { componentDidMount() { this.invalidateIndeterminate(); } @@ -32,11 +64,11 @@ export class EuiCheckbox extends Component { ...rest } = this.props; - const inputProps = omit(rest, 'indeterminate'); + const { indeterminate, ...inputProps } = rest; // `indeterminate` is set dynamically later const classes = classNames( 'euiCheckbox', - typeToClassNameMap[type], + type && typeToClassNameMap[type], { 'euiCheckbox--noLabel': !label, 'euiCheckbox--compressed': compressed, @@ -74,7 +106,7 @@ export class EuiCheckbox extends Component { ); } - setInputRef = input => { + setInputRef = (input: HTMLInputElement) => { this.inputRef = input; if (this.props.inputRef) { @@ -86,29 +118,7 @@ export class EuiCheckbox extends Component { invalidateIndeterminate() { if (this.inputRef) { - this.inputRef.indeterminate = this.props.indeterminate; + this.inputRef.indeterminate = this.props.indeterminate!; } } } - -EuiCheckbox.propTypes = { - className: PropTypes.string, - id: PropTypes.string.isRequired, - checked: PropTypes.bool, - label: PropTypes.node, - onChange: PropTypes.func.isRequired, - type: PropTypes.oneOf(TYPES), - disabled: PropTypes.bool, - indeterminate: PropTypes.bool, - /** - * when `true` creates a shorter height checkbox row - */ - compressed: PropTypes.bool, -}; - -EuiCheckbox.defaultProps = { - checked: false, - disabled: false, - indeterminate: false, - compressed: false, -}; diff --git a/src/components/form/checkbox/checkbox_group.behavior.test.js b/src/components/form/checkbox/checkbox_group.behavior.test.tsx similarity index 82% rename from src/components/form/checkbox/checkbox_group.behavior.test.js rename to src/components/form/checkbox/checkbox_group.behavior.test.tsx index 41f254bea8e..01959404094 100644 --- a/src/components/form/checkbox/checkbox_group.behavior.test.js +++ b/src/components/form/checkbox/checkbox_group.behavior.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { mount } from 'enzyme'; -import sinon from 'sinon'; import { EuiCheckboxGroup } from './checkbox_group'; @@ -9,7 +8,7 @@ import { EuiCheckboxGroup } from './checkbox_group'; // an interaction that is handled by the Checkbox component. describe('EuiCheckboxGroup behavior', () => { test('id is bound to onChange', () => { - const onChangeHandler = sinon.stub(); + const onChangeHandler = jest.fn(); const component = mount( { ); component.find('input[type="checkbox"]').simulate('change'); - sinon.assert.calledWith(onChangeHandler, '1'); + expect(onChangeHandler).toBeCalledTimes(1); + expect(onChangeHandler.mock.calls[0][0]).toBe('1'); }); }); diff --git a/src/components/form/checkbox/checkbox_group.test.js b/src/components/form/checkbox/checkbox_group.test.tsx similarity index 82% rename from src/components/form/checkbox/checkbox_group.test.js rename to src/components/form/checkbox/checkbox_group.test.tsx index 02afa8dc29c..c6ec2ccf051 100644 --- a/src/components/form/checkbox/checkbox_group.test.js +++ b/src/components/form/checkbox/checkbox_group.test.tsx @@ -6,10 +6,20 @@ import { EuiCheckboxGroup } from './checkbox_group'; jest.mock('./checkbox', () => ({ EuiCheckbox: 'eui_checkbox' })); +const checkboxGroupRequiredProps = { + options: [], + idToSelectedMap: {}, + onChange: () => {}, +}; + describe('EuiCheckboxGroup (mocked checkbox)', () => { test('is rendered', () => { const component = render( - {}} {...requiredProps} /> + {}} + {...checkboxGroupRequiredProps} + {...requiredProps} + /> ); expect(component).toMatchSnapshot(); @@ -18,6 +28,7 @@ describe('EuiCheckboxGroup (mocked checkbox)', () => { test('options are rendered', () => { const component = render( {}} /> @@ -29,6 +40,7 @@ describe('EuiCheckboxGroup (mocked checkbox)', () => { test('idToSelectedMap is rendered', () => { const component = render( { test('disabled is rendered', () => { const component = render( { test('individual disabled is rendered', () => { const component = render( void; + /** + * Tightens up the spacing between checkbox rows and sends down the + * compressed prop to the checkbox itself + */ + compressed?: boolean; + disabled?: boolean; +} + +export const EuiCheckboxGroup: FunctionComponent = ({ + options = [], + idToSelectedMap = {}, onChange, className, disabled, @@ -29,25 +51,3 @@ export const EuiCheckboxGroup = ({ })}
); - -EuiCheckboxGroup.propTypes = { - options: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - label: PropTypes.node, - disabled: PropTypes.bool, - }) - ).isRequired, - idToSelectedMap: PropTypes.objectOf(PropTypes.bool).isRequired, - onChange: PropTypes.func.isRequired, - /** - * Tightens up the spacing between checkbox rows and sends down the - * compressed prop to the checkbox itself - */ - compressed: PropTypes.bool, -}; - -EuiCheckboxGroup.defaultProps = { - options: [], - idToSelectedMap: {}, -}; diff --git a/src/components/form/checkbox/index.d.ts b/src/components/form/checkbox/index.d.ts deleted file mode 100644 index c93499d5ed1..00000000000 --- a/src/components/form/checkbox/index.d.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { CommonProps } from '../../common'; - -import { - FunctionComponent, - ReactNode, - HTMLAttributes, - ChangeEventHandler, - InputHTMLAttributes, -} from 'react'; - -declare module '@elastic/eui' { - /** - * checkbox type defs - * - * @see './checkbox.js' - */ - - export type EuiCheckboxType = 'inList'; - - export interface EuiCheckboxProps { - id: string; - checked?: boolean; - onChange: ChangeEventHandler; // overriding to make it required - label?: ReactNode; - type?: EuiCheckboxType; - disabled?: boolean; - compressed?: boolean; - indeterminate?: boolean; - } - - export const EuiCheckbox: FunctionComponent< - CommonProps & InputHTMLAttributes & EuiCheckboxProps - >; - - /** - * checkbox group type defs - * - * @see './checkbox_group.js' - */ - - export interface EuiCheckboxGroupOption { - id: string; - label?: ReactNode; - disabled?: boolean; - } - - export interface EuiCheckboxGroupIdToSelectedMap { - [id: string]: boolean; - } - - export interface EuiCheckboxGroupProps { - options: EuiCheckboxGroupOption[]; - idToSelectedMap: EuiCheckboxGroupIdToSelectedMap; - onChange: ChangeEventHandler; - compressed?: boolean; - disabled?: boolean; - } - - export const EuiCheckboxGroup: FunctionComponent< - CommonProps & HTMLAttributes & EuiCheckboxGroupProps - >; -} diff --git a/src/components/form/checkbox/index.js b/src/components/form/checkbox/index.js deleted file mode 100644 index dec0b16a0a2..00000000000 --- a/src/components/form/checkbox/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { EuiCheckbox } from './checkbox'; -export { EuiCheckboxGroup } from './checkbox_group'; diff --git a/src/components/form/checkbox/index.ts b/src/components/form/checkbox/index.ts new file mode 100644 index 00000000000..7be49859685 --- /dev/null +++ b/src/components/form/checkbox/index.ts @@ -0,0 +1,2 @@ +export { EuiCheckbox, EuiCheckboxProps } from './checkbox'; +export { EuiCheckboxGroup, EuiCheckboxGroupProps } from './checkbox_group'; diff --git a/src/components/form/index.d.ts b/src/components/form/index.d.ts index a707dbfd399..2d025407909 100644 --- a/src/components/form/index.d.ts +++ b/src/components/form/index.d.ts @@ -1,5 +1,4 @@ import { CommonProps } from '../common'; -/// /// /// /// diff --git a/src/components/form/radio/_radio_group.scss b/src/components/form/radio/_radio_group.scss index 8630999fea0..89a3809bbc8 100644 --- a/src/components/form/radio/_radio_group.scss +++ b/src/components/form/radio/_radio_group.scss @@ -1,5 +1,5 @@ .euiRadioGroup__item + .euiRadioGroup__item { - margin-top: $euiSizeS; + margin-top: $euiSizeXS; &.euiRadio--compressed { margin-top: 0; diff --git a/src/components/index.js b/src/components/index.js index 829ab8a0594..febbba31948 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -22,7 +22,7 @@ export { export { EuiCallOut } from './call_out'; -export { EuiCard } from './card'; +export { EuiCard, EuiCheckableCard } from './card'; export { EuiCode, EuiCodeBlock, EuiCodeBlockImpl } from './code';