From 67dfe335aebb39e0ece7ce584f8c8a868133bba4 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Thu, 3 May 2018 09:31:28 -0700 Subject: [PATCH] EuiDescribedFormGroup component (#707) * Add new EuiDescriptiveFormRow component * Change component to display arbitrary number of form row * Rename text prop to description, add paddingSize, wrap title and description in EuiText * Rename to EuiDescribedFormGroup, change to EuiTitle with titleSize prop, adjust aria, update tests --- CHANGELOG.md | 3 +- .../form_layouts/described_form_group.js | 175 ++++++++ .../form_layouts/form_layouts_example.js | 25 ++ src-docs/src/views/form_layouts/form_rows.js | 1 - src-docs/src/views/form_layouts/full_width.js | 1 + src/components/form/_index.scss | 1 + .../described_form_group.test.js.snap | 405 ++++++++++++++++++ .../_described_form_group.scss | 56 +++ .../form/described_form_group/_index.scss | 1 + .../described_form_group.js | 111 +++++ .../described_form_group.test.js | 109 +++++ .../form/described_form_group/index.js | 1 + .../__snapshots__/form_row.test.js.snap | 18 + src/components/form/form_row/form_row.js | 9 +- src/components/form/form_row/form_row.test.js | 11 + src/components/form/index.js | 1 + src/components/index.js | 1 + 17 files changed, 925 insertions(+), 4 deletions(-) create mode 100644 src-docs/src/views/form_layouts/described_form_group.js create mode 100644 src/components/form/described_form_group/__snapshots__/described_form_group.test.js.snap create mode 100644 src/components/form/described_form_group/_described_form_group.scss create mode 100644 src/components/form/described_form_group/_index.scss create mode 100644 src/components/form/described_form_group/described_form_group.js create mode 100644 src/components/form/described_form_group/described_form_group.test.js create mode 100644 src/components/form/described_form_group/index.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b157cc4bc..149f55e385c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [`master`](https://github.com/elastic/eui/tree/master) -No public interface changes since `0.0.45`. +- Added `EuiDescribedFormGroup` component, a wrapper around `EuiFormRow`(s) ([#707](https://github.com/elastic/eui/pull/707)) +- Added `describedByIds` prop to `EuiFormRow` to help with accessibility ([#707](https://github.com/elastic/eui/pull/707)) ## [`0.0.45`](https://github.com/elastic/eui/tree/v0.0.45) diff --git a/src-docs/src/views/form_layouts/described_form_group.js b/src-docs/src/views/form_layouts/described_form_group.js new file mode 100644 index 00000000000..015b25cffb5 --- /dev/null +++ b/src-docs/src/views/form_layouts/described_form_group.js @@ -0,0 +1,175 @@ +import React, { + Component, +} from 'react'; + +import { + EuiButton, + EuiCode, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiDescribedFormGroup, + EuiFilePicker, + EuiRange, + EuiSelect, + EuiSwitch, +} from '../../../../src/components'; + +import makeId from '../../../../src/components/form/form_row/make_id'; + +export default class extends Component { + constructor(props) { + super(props); + + const idPrefix = makeId(); + + this.state = { + isSwitchChecked: false, + checkboxes: [{ + id: `${idPrefix}0`, + label: 'Option one', + }, { + id: `${idPrefix}1`, + label: 'Option two is checked by default', + }, { + id: `${idPrefix}2`, + label: 'Option three', + }], + checkboxIdToSelectedMap: { + [`${idPrefix}1`]: true, + }, + radios: [{ + id: `${idPrefix}4`, + label: 'Option one', + }, { + id: `${idPrefix}5`, + label: 'Option two is selected by default', + }, { + id: `${idPrefix}6`, + label: 'Option three', + }], + radioIdSelected: `${idPrefix}5`, + }; + } + + onSwitchChange = () => { + this.setState({ + isSwitchChecked: !this.state.isSwitchChecked, + }); + } + + onCheckboxChange = optionId => { + const newCheckboxIdToSelectedMap = ({ ...this.state.checkboxIdToSelectedMap, ...{ + [optionId]: !this.state.checkboxIdToSelectedMap[optionId], + } }); + + this.setState({ + checkboxIdToSelectedMap: newCheckboxIdToSelectedMap, + }); + } + + onRadioChange = optionId => { + this.setState({ + radioIdSelected: optionId, + }); + } + + render() { + return ( + + Single text field} + description={ + + When using this with a single form row where this text serves as the help text for the input, + it is a good idea to pass idAria="someID" to the form group and pass + describedByIds={[someID]} to its form row. + + } + > + + + + + + Multiple fields} + titleSize="m" + description="Here are three form rows. The first form row does not have a title." + > + + We do not pass describedByIds when there are multiple form rows. + + } + > + + + + + + + + + + + + + Full width} + titleSize="xxxs" + description={ + + By default, EuiDescribedFormGroup will be double the default width of form elements. + However, you can pass fullWidth prop to this, the individual field and row components + to expand to their container. + + } + fullWidth + > + + + + + + + + + + + Save form + + + ); + } +} diff --git a/src-docs/src/views/form_layouts/form_layouts_example.js b/src-docs/src/views/form_layouts/form_layouts_example.js index c5b9e446f5e..a9cc42f4351 100644 --- a/src-docs/src/views/form_layouts/form_layouts_example.js +++ b/src-docs/src/views/form_layouts/form_layouts_example.js @@ -10,6 +10,7 @@ import { EuiCode, EuiForm, EuiFormRow, + EuiDescribedFormGroup, EuiCheckboxGroup, EuiFieldNumber, EuiFieldPassword, @@ -28,6 +29,10 @@ import FormRows from './form_rows'; const formRowsSource = require('!!raw-loader!./form_rows'); const formRowsHtml = renderToHtml(FormRows); +import DescribedFormGroup from './described_form_group'; +const describedFormGroupSource = require('!!raw-loader!./described_form_group'); +const describedFormGroupHtml = renderToHtml(DescribedFormGroup); + import FullWidth from './full_width'; const fullWidthSource = require('!!raw-loader!./full_width'); const fullWidthHtml = renderToHtml(FullWidth); @@ -81,6 +86,26 @@ export const FormLayoutsExample = { EuiTextArea, }, demo: , + }, { + title: 'Described form groups', + source: [{ + type: GuideSectionTypes.JS, + code: describedFormGroupSource, + }, { + type: GuideSectionTypes.HTML, + code: describedFormGroupHtml, + }], + text: ( +

+ Use EuiDescribedFormGroup component to associate multiple EuiFormRows. + It can also simply be used with one EuiFormRow as a way to display help text (or additional + text) next to the field instead of below (on mobile, will revert to being stacked). +

+ ), + props: { + EuiDescribedFormGroup, + }, + demo: , }, { title: 'Full-width', source: [{ diff --git a/src-docs/src/views/form_layouts/form_rows.js b/src-docs/src/views/form_layouts/form_rows.js index 2a60a408ac8..0f5edd99ecb 100644 --- a/src-docs/src/views/form_layouts/form_rows.js +++ b/src-docs/src/views/form_layouts/form_rows.js @@ -118,7 +118,6 @@ export default class extends Component { > ( fullWidth /> + + + + +

+ Title +

+
+ + Test description + +
+ + + + + +
+ +`; + +exports[`EuiDescribedFormGroup props fullWidth is rendered 1`] = ` +
+ + + +

+ Title +

+
+ + Test description + +
+ + + + + +
+
+`; + +exports[`EuiDescribedFormGroup props gutterSize is rendered 1`] = ` +
+ + + +

+ Title +

+
+ + Test description + +
+ + + + + +
+
+`; + +exports[`EuiDescribedFormGroup props titleSize is rendered 1`] = ` +
+ + + +

+ Title +

+
+ + Test description + +
+ + + + + +
+
+`; + +exports[`EuiDescribedFormGroup ties together parts for accessibility 1`] = ` + + Title + + } + titleSize="xs" +> +
+ +
+ +
+ +

+ Title +

+
+ +
+ + + Test description + + +
+
+
+
+ +
+ +
+ + + + + +
+ Error one +
+
+ +
+ Error two +
+
+ +
+ Help text +
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/components/form/described_form_group/_described_form_group.scss b/src/components/form/described_form_group/_described_form_group.scss new file mode 100644 index 00000000000..6a7d668fbd6 --- /dev/null +++ b/src/components/form/described_form_group/_described_form_group.scss @@ -0,0 +1,56 @@ +.euiDescribedFormGroup { + max-width: $euiFormMaxWidth*2; + + + * { + margin-top: $euiSizeL; + } + + &.euiDescribedFormGroup--fullWidth { + max-width: 100%; + } + + .euiDescribedFormGroup__description { + padding-top: $euiSizeS; + } + + .euiDescribedFormGroup__fields { + min-width: $euiFormMaxWidth; + } + + .euiDescribedFormGroup__fieldPadding { + &--xxxsmall { + padding-top: $euiFontSizeXS*1.5 - $euiFontSizeXS + 2px; + } + &--xxsmall { + padding-top: $euiFontSizeS*1.5 - $euiFontSizeXS + 2px; + } + &--xsmall { + padding-top: $euiFontSize*1.5 - $euiFontSizeXS + 2px; + } + &--small { + padding-top: $euiFontSizeL*1.5 - $euiFontSizeXS + 2px; + } + &--medium { + padding-top: $euiFontSizeXL*1.5 - $euiFontSizeXS + 2px; + } + &--large { + padding-top: $euiFontSizeXXL*1.5 - $euiFontSizeXS + 2px; + } + } + + @include screenXSmall { + max-width: $euiFormMaxWidth; + + &.euiDescribedFormGroup--fullWidth { + max-width: 100%; + } + + .euiDescribedFormGroup__fields { + padding-top: 0; + + > .euiFormRow--hasEmptyLabelSpace:first-child { + padding-top: 0; + } + } + } +} diff --git a/src/components/form/described_form_group/_index.scss b/src/components/form/described_form_group/_index.scss new file mode 100644 index 00000000000..1ccd74c45b0 --- /dev/null +++ b/src/components/form/described_form_group/_index.scss @@ -0,0 +1 @@ +@import 'described_form_group'; diff --git a/src/components/form/described_form_group/described_form_group.js b/src/components/form/described_form_group/described_form_group.js new file mode 100644 index 00000000000..84bb7124fac --- /dev/null +++ b/src/components/form/described_form_group/described_form_group.js @@ -0,0 +1,111 @@ +import React, { + Component, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { EuiTitle, TITLE_SIZES } from '../../title/title'; +import { EuiText } from '../../text/text'; +import { EuiFlexGroup, EuiFlexItem } from '../../flex'; +import { GUTTER_SIZES } from '../../flex/flex_group'; + +import makeId from '../form_row/make_id'; + +const paddingSizeToClassNameMap = { + xxxs: 'euiDescribedFormGroup__fieldPadding--xxxsmall', + xxs: 'euiDescribedFormGroup__fieldPadding--xxsmall', + xs: 'euiDescribedFormGroup__fieldPadding--xsmall', + s: 'euiDescribedFormGroup__fieldPadding--small', + m: 'euiDescribedFormGroup__fieldPadding--medium', + l: 'euiDescribedFormGroup__fieldPadding--large', +}; + +export class EuiDescribedFormGroup extends Component { + constructor(props) { + super(props); + this.ariaId = props.idAria || makeId(); + } + + render() { + const { + children, + className, + gutterSize, + fullWidth, + titleSize, + title, + description, + idAria: userAriaId, + ...rest + } = this.props; + + const ariaId = this.ariaId; + + const classes = classNames( + 'euiDescribedFormGroup', + { + 'euiDescribedFormGroup--fullWidth': fullWidth, + }, + className, + ); + + const fieldClasses = classNames( + 'euiDescribedFormGroup__fields', + paddingSizeToClassNameMap[titleSize], + ); + + const ariaProps = { + 'aria-labelledby': `${ariaId}-title`, + + // if user has defined an aria ID, assume they have passed the ID to + // the form row describedByIds and skip describedby here + 'aria-describedby': userAriaId ? null : ariaId, + }; + + return ( +
+ + + + {title} + + + {description} + + + + {children} + + +
+ ); + } +} + +EuiDescribedFormGroup.propTypes = { + /** + * One or more `EuiFormRow`s + */ + children: PropTypes.node.isRequired, + className: PropTypes.string, + /** + * Passed to `EuiFlexGroup` + */ + gutterSize: PropTypes.oneOf(GUTTER_SIZES), + fullWidth: PropTypes.bool, + titleSize: PropTypes.oneOf(TITLE_SIZES), + title: PropTypes.node.isRequired, + description: PropTypes.node.isRequired, + idAria: PropTypes.string, +}; + +EuiDescribedFormGroup.defaultProps = { + gutterSize: 'l', + titleSize: 'xs', + fullWidth: false, +}; diff --git a/src/components/form/described_form_group/described_form_group.test.js b/src/components/form/described_form_group/described_form_group.test.js new file mode 100644 index 00000000000..8b3648cd75c --- /dev/null +++ b/src/components/form/described_form_group/described_form_group.test.js @@ -0,0 +1,109 @@ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { requiredProps } from '../../../test'; + +import { EuiFormRow } from '../form_row'; +import { EuiDescribedFormGroup } from './described_form_group'; + +jest.mock(`../form_row/make_id`, () => () => `generated-id`); + +describe('EuiDescribedFormGroup', () => { + const props = { + title:

Title

, + description: 'Test description', + }; + + test('is rendered', () => { + const component = shallow( + + + + + + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('ties together parts for accessibility', () => { + const describedFormGroupProps = { + idAria: 'test-id', + }; + + const formRowProps = { + label: `Label`, + helpText: `Help text`, + isInvalid: true, + error: [ + `Error one`, + `Error two` + ], + describedByIds: ['test-id'], + }; + + const tree = mount( + + + + + + ); + + expect(tree) + .toMatchSnapshot(); + }); + + describe('props', () => { + test('fullWidth is rendered', () => { + const describedFormGroupProps = { + fullWidth: true, + }; + + const component = shallow( + + + + + + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('gutterSize is rendered', () => { + const describedFormGroupProps = { + gutterSize: 's', + }; + + const component = shallow( + + + + + + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('titleSize is rendered', () => { + const describedFormGroupProps = { + titleSize: 'l', + }; + + const component = shallow( + + + + + + ); + + expect(component) + .toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/form/described_form_group/index.js b/src/components/form/described_form_group/index.js new file mode 100644 index 00000000000..94d1f6d6af1 --- /dev/null +++ b/src/components/form/described_form_group/index.js @@ -0,0 +1 @@ +export { EuiDescribedFormGroup } from './described_form_group'; diff --git a/src/components/form/form_row/__snapshots__/form_row.test.js.snap b/src/components/form/form_row/__snapshots__/form_row.test.js.snap index e1497b923ba..8e1cd3c94ce 100644 --- a/src/components/form/form_row/__snapshots__/form_row.test.js.snap +++ b/src/components/form/form_row/__snapshots__/form_row.test.js.snap @@ -2,6 +2,7 @@ exports[`EuiFormRow behavior onBlur is called in child 1`] = ` `; +exports[`EuiFormRow props describedByIds is rendered 1`] = ` +
+ +
+`; + exports[`EuiFormRow props error as array is rendered 1`] = `
{ .toMatchSnapshot(); }); + test('describedByIds is rendered', () => { + const component = shallow( + + + + ); + + expect(component) + .toMatchSnapshot(); + }); + test('id is rendered', () => { const component = render( diff --git a/src/components/form/index.js b/src/components/form/index.js index 61d69612dcc..f04fdc5605b 100644 --- a/src/components/form/index.js +++ b/src/components/form/index.js @@ -2,6 +2,7 @@ export { EuiCheckbox, EuiCheckboxGroup, } from './checkbox'; +export { EuiDescribedFormGroup } from './described_form_group'; export { EuiFieldNumber } from './field_number'; export { EuiFieldPassword } from './field_password'; export { EuiFieldSearch } from './field_search'; diff --git a/src/components/index.js b/src/components/index.js index 8eb06d4a9a6..cb9863e38fa 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -107,6 +107,7 @@ export { export { EuiCheckbox, EuiCheckboxGroup, + EuiDescribedFormGroup, EuiFieldNumber, EuiFieldPassword, EuiFieldSearch,