;
export const Playground: Story = {
+ argTypes: {
+ hasEmptyLabelSpace: { if: { arg: 'label', truthy: false } },
+ },
args: {
children:
,
label: 'Text field label',
helpText: 'I am some friendly help text.',
},
};
+
+export const ColumnLayout: Story = {
+ parameters: {
+ // Show for relevant props documentation, but don't allow configuring
+ controls: { include: ['display'] },
+ codeSnippet: {
+ // Not 1:1 with actual rendering, just a bare-bones example to get devs started
+ snippet: `
+
+
+
+ `,
+ },
+ },
+ argTypes: { display: { control: false } },
+ args: { display: 'columnCompressed' },
+ render: ({ ..._args }) => (
+
+
+
+
+
+ {}}
+ compressed
+ />
+
+
+
+
+
+
+
+
+ {}}
+ />
+
+
+ ),
+};
+
+export const InlineLayout: Story = {
+ parameters: {
+ // Show for relevant props documentation, but don't allow configuring
+ controls: { include: ['display', 'hasEmptyLabelSpace'] },
+ codeSnippet: {
+ // Not 1:1 with actual rendering, just a bare-bones example to get devs started
+ snippet: `
+
+
+
+
+
+
+
+
+ Save
+
+
+
+ `,
+ },
+ },
+ argTypes: {
+ display: { control: false },
+ hasEmptyLabelSpace: { control: false },
+ },
+ render: ({ ..._args }) => (
+ <>
+
+
+
+ >
+ ),
+};
+const RenderInlineLayout = ({ compressed }: { compressed: boolean }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {}}
+ />
+
+
+
+
+ Save
+
+
+
+);
diff --git a/packages/eui/src/components/form/form_row/form_row.styles.ts b/packages/eui/src/components/form/form_row/form_row.styles.ts
new file mode 100644
index 00000000000..6b3ce4b0a92
--- /dev/null
+++ b/packages/eui/src/components/form/form_row/form_row.styles.ts
@@ -0,0 +1,129 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { css } from '@emotion/react';
+
+import { UseEuiTheme } from '../../../services';
+import { logicalCSS } from '../../../global_styling';
+import { euiFormVariables } from '../form.styles';
+
+export const euiFormRowStyles = (euiThemeContext: UseEuiTheme) => {
+ const { euiTheme } = euiThemeContext;
+ const { maxWidth, controlHeight, controlCompressedHeight } =
+ euiFormVariables(euiThemeContext);
+
+ return {
+ euiFormRow: css`
+ /* Coerce inline form elements to behave as block-level elements */
+ display: flex;
+
+ + .euiButton {
+ ${logicalCSS('margin-top', euiTheme.size.base)}
+ }
+ `,
+ // Skip css`` to avoid generating an Emotion className
+ formWidth: `
+ ${logicalCSS('max-width', maxWidth)}
+ `,
+ fullWidth: css`
+ ${logicalCSS('max-width', '100%')}
+ `,
+
+ // Skip css`` to avoid generating an extra className
+ row: `
+ flex-direction: column;
+ row-gap: ${euiTheme.size.xs};
+
+ .euiFormRow__labelWrapper {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ }
+
+ + .euiFormRow {
+ ${logicalCSS('margin-top', euiTheme.size.base)}
+ }
+ `,
+ // No difference from the uncompressed row for this current theme AFAICT
+ // TODO: Deprecate prop
+ get rowCompressed() {
+ return this.row;
+ },
+
+ columnCompressed: css`
+ flex-direction: row;
+ align-items: stretch;
+ column-gap: ${euiTheme.size.s};
+
+ .euiFormRow__label {
+ hyphens: auto;
+ }
+
+ .euiFormRow__labelWrapper {
+ flex-basis: calc(33% - ${euiTheme.size.s}); /* Account for gap */
+ ${logicalCSS('min-width', 0)}
+ line-height: ${controlCompressedHeight};
+ }
+
+ .euiFormRow__fieldWrapper {
+ flex-basis: 67%;
+ ${logicalCSS('min-width', 0)}
+ }
+
+ + .euiFormRow {
+ ${logicalCSS('margin-top', euiTheme.size.s)}
+ }
+
+ /* Increase spacing for switches */
+ &:has(.euiSwitch) {
+ &:not(:first-child) {
+ ${logicalCSS('margin-top', euiTheme.size.m)}
+ }
+
+ &:not(:last-child) {
+ ${logicalCSS('margin-bottom', euiTheme.size.m)}
+ }
+
+ .euiFormRow__labelWrapper {
+ line-height: ${euiTheme.size.base};
+ }
+ }
+ `,
+ // Handled by :has CSS now rather than a separate modifier/prop
+ // TODO: Deprecate prop
+ get columnCompressedSwitch() {
+ return this.columnCompressed;
+ },
+
+ // Center display is primarily for inline form rows, which may have have
+ // field content that is shorter than form controls (e.g. switches, text),
+ // and should vertically center said content
+ centerDisplayCss: (compressed: boolean) => `
+ .euiFormRow__fieldWrapper {
+ display: flex;
+ align-items: center;
+ ${logicalCSS(
+ 'min-height',
+ compressed ? controlCompressedHeight : controlHeight
+ )}
+ }
+ `,
+ get center() {
+ return css`
+ ${this.row}
+ ${this.centerDisplayCss(false)}
+ `;
+ },
+ get centerCompressed() {
+ return css`
+ ${this.row}
+ ${this.centerDisplayCss(true)}
+ `;
+ },
+ };
+};
diff --git a/packages/eui/src/components/form/form_row/form_row.test.tsx b/packages/eui/src/components/form/form_row/form_row.test.tsx
index 0c93b48f970..5c48ae151a0 100644
--- a/packages/eui/src/components/form/form_row/form_row.test.tsx
+++ b/packages/eui/src/components/form/form_row/form_row.test.tsx
@@ -7,14 +7,21 @@
*/
import React from 'react';
-import { mount } from 'enzyme';
-import { requiredProps } from '../../../test';
+import { fireEvent } from '@testing-library/react';
import { render } from '../../../test/rtl';
+import { shouldRenderCustomStyles } from '../../../test/internal';
+import { requiredProps } from '../../../test';
import { EuiForm } from '../form';
import { EuiFormRow, DISPLAYS } from './form_row';
describe('EuiFormRow', () => {
+ shouldRenderCustomStyles(
+
+
+
+ );
+
test('is rendered', () => {
const { container } = render(
@@ -33,27 +40,28 @@ describe('EuiFormRow', () => {
error: ['Error one', 'Error two'],
};
- const tree = mount(
+ const { container } = render(
);
+ const input = container.querySelector('input')!;
+ const label = container.querySelector('label')!;
+
// Input is labeled by the label.
- expect(tree.find('input').prop('id')).toEqual('generated-id');
- expect(tree.find('EuiFormLabel').prop('htmlFor')).toEqual('generated-id');
+ expect(input).toHaveAttribute('id', 'generated-id');
+ expect(label).toHaveAttribute('for', 'generated-id');
+
+ const helpText = container.querySelector('.euiFormHelpText')!;
+ const errorText = container.querySelectorAll('.euiFormErrorText');
// Input is described by help and error text.
- expect(tree.find('EuiFormHelpText').prop('id')).toEqual(
- 'generated-id-help-0'
- );
- expect(tree.find('EuiFormErrorText').at(0).prop('id')).toEqual(
- 'generated-id-error-0'
- );
- expect(tree.find('EuiFormErrorText').at(1).prop('id')).toEqual(
- 'generated-id-error-1'
- );
- expect(tree.find('input').prop('aria-describedby')).toEqual(
+ expect(helpText).toHaveAttribute('id', 'generated-id-help-0');
+ expect(errorText[0]).toHaveAttribute('id', 'generated-id-error-0');
+ expect(errorText[1]).toHaveAttribute('id', 'generated-id-error-1');
+ expect(input).toHaveAttribute(
+ 'aria-describedby',
'generated-id-help-0 generated-id-error-0 generated-id-error-1'
);
});
@@ -227,80 +235,24 @@ describe('EuiFormRow', () => {
});
describe('behavior', () => {
- describe('onFocus', () => {
- test('is called in child', () => {
- const focusMock = jest.fn();
-
- const component = mount(
- Label}>
-
-
- );
-
- component.find('input').simulate('focus');
-
- expect(focusMock).toBeCalledTimes(1);
-
- // Ensure the focus event is properly fired on the parent
- // which will pass down to the EuiFormLabel
- expect(component.find('label').getDOMNode()).toHaveClass(
- 'euiFormLabel-isFocused'
- );
- });
-
- test('works in parent even if not in child', () => {
- const component = mount(
- Label}>
-
-
- );
+ it('onFocus and onBlur', () => {
+ const focusMock = jest.fn();
+ const blurMock = jest.fn();
- component.find('input').simulate('focus');
-
- // Ensure the focus event is properly fired on the parent
- // which will pass down to the EuiFormLabel
- expect(component.find('label').getDOMNode()).toHaveClass(
- 'euiFormLabel-isFocused'
- );
- });
- });
-
- describe('onBlur', () => {
- test('is called in child', () => {
- const blurMock = jest.fn();
-
- const component = mount(
- Label}>
-
-
- );
-
- component.find('input').simulate('blur');
-
- expect(blurMock).toBeCalledTimes(1);
-
- // Ensure the blur event is properly fired on the parent
- // which will pass down to the EuiFormLabel
- expect(component.find('label').getDOMNode()).not.toHaveClass(
- 'euiFormLabel-isFocused'
- );
- });
-
- test('works in parent even if not in child', () => {
- const component = mount(
- Label}>
-
-
- );
+ const { getByRole, container } = render(
+ Label}>
+
+
+ );
+ const label = container.querySelector('.euiFormLabel')!;
- component.find('input').simulate('blur');
+ fireEvent.focus(getByRole('textbox'));
+ expect(focusMock).toHaveBeenCalledTimes(1);
+ expect(label.className).toContain('isFocused');
- // Ensure the blur event is properly fired on the parent
- // which will pass down to the EuiFormLabel
- expect(component.find('label').getDOMNode()).not.toHaveClass(
- 'euiFormLabel-isFocused'
- );
- });
+ fireEvent.blur(getByRole('textbox'));
+ expect(blurMock).toHaveBeenCalledTimes(1);
+ expect(label.className).not.toContain('isFocused');
});
});
@@ -315,7 +267,7 @@ describe('EuiFormRow', () => {
);
const row = container.querySelector('.euiFormRow');
- expect(row).toHaveClass('euiFormRow--fullWidth');
+ expect(row!.className).toContain('fullWidth');
});
});
});
diff --git a/packages/eui/src/components/form/form_row/form_row.tsx b/packages/eui/src/components/form/form_row/form_row.tsx
index 38633c842b1..934d7fae4a1 100644
--- a/packages/eui/src/components/form/form_row/form_row.tsx
+++ b/packages/eui/src/components/form/form_row/form_row.tsx
@@ -8,54 +8,56 @@
import React, {
cloneElement,
- Component,
+ FunctionComponent,
Children,
HTMLAttributes,
ReactElement,
ReactNode,
+ useState,
+ useCallback,
+ useMemo,
} from 'react';
import classNames from 'classnames';
-import { ExclusiveUnion, CommonProps, keysOf } from '../../common';
-import { get } from '../../../services/objects';
+import { useGeneratedHtmlId, useEuiMemoizedStyles } from '../../../services';
+import { ExclusiveUnion, CommonProps } from '../../common';
+import { EuiSpacer } from '../../spacer';
import { EuiFormHelpText } from '../form_help_text';
import { EuiFormErrorText } from '../form_error_text';
import { EuiFormLabel } from '../form_label';
-import { FormContext, FormContextValue } from '../eui_form_context';
-
-import { htmlIdGenerator } from '../../../services/accessibility';
-
-const displayToClassNameMap = {
- row: null,
- rowCompressed: 'euiFormRow--compressed',
- columnCompressed: 'euiFormRow--compressed euiFormRow--horizontal',
- center: null,
- centerCompressed: 'euiFormRow--compressed',
- columnCompressedSwitch:
- 'euiFormRow--compressed euiFormRow--horizontal euiFormRow--hasSwitch',
-};
-
-export const DISPLAYS = keysOf(displayToClassNameMap);
-
-export type EuiFormRowDisplayKeys = keyof typeof displayToClassNameMap;
+import { useFormContext } from '../eui_form_context';
+import { euiFormRowStyles } from './form_row.styles';
+
+export const DISPLAYS = [
+ 'row',
+ 'columnCompressed',
+ 'center',
+ 'centerCompressed',
+ /**
+ * @deprecated
+ */
+ 'columnCompressedSwitch',
+ /**
+ * @deprecated
+ */
+ 'rowCompressed',
+] as const;
-interface EuiFormRowState {
- isFocused: boolean;
- id: string;
-}
+export type EuiFormRowDisplayKeys = (typeof DISPLAYS)[number];
type EuiFormRowCommonProps = CommonProps & {
/**
- * When `rowCompressed`, just tightens up the spacing;
- * Set to `columnCompressed` if compressed
- * and horizontal layout is needed.
- * Set to `center` or `centerCompressed` to align non-input
- * content better with inline rows.
- * Set to `columnCompressedSwitch` if the form control being passed
- * as the child is a switch.
+ * - `columnCompressed` creates a compressed and horizontal layout
+ * - `columnCompressedSwitch` - **deprecated**, use `columnCompressed` instead
+ * - `center`/`centerCompressed` helps align non-input content better with inline form layouts
+ * - `rowCompressed` - **deprecated**, does not currently affect styling
*/
display?: EuiFormRowDisplayKeys;
+ /**
+ * Useful for inline form layouts, primarily for content that
+ * needs to be aligned with inputs but does not need a label
+ */
hasEmptyLabelSpace?: boolean;
/**
* Expand to fill 100% of the parent.
@@ -111,225 +113,156 @@ type LegendProps = {
export type EuiFormRowProps = ExclusiveUnion;
-export class EuiFormRow extends Component {
- static contextType = FormContext;
-
- static defaultProps: Partial = {
- display: 'row',
- hasEmptyLabelSpace: false,
- describedByIds: [],
- labelType: 'label',
- hasChildLabel: true,
- };
-
- state: EuiFormRowState = {
- isFocused: false,
- id: this.props.id || htmlIdGenerator()(),
- };
-
- onFocus = (...args: any[]) => {
- // Doing this to allow onFocus to be called correctly from the child input element as this component overrides it
- const onChildFocus = get(this.props, 'children.props.onFocus');
- if (onChildFocus) {
- onChildFocus(...args);
- }
-
- this.setState(({ isFocused }) => {
- if (!isFocused) {
- return {
- isFocused: true,
- };
- } else {
- return null;
- }
- });
- };
-
- onBlur = (...args: any[]) => {
- // Doing this to allow onBlur to be called correctly from the child input element as this component overrides it
- const onChildBlur = get(this.props, 'children.props.onBlur');
- if (onChildBlur) {
- onChildBlur(...args);
- }
-
- this.setState({
- isFocused: false,
+export const EuiFormRow: FunctionComponent = ({
+ className,
+ children,
+ helpText,
+ isInvalid,
+ error,
+ label,
+ labelType = 'label',
+ labelAppend,
+ hasEmptyLabelSpace = false,
+ fullWidth: _fullWidth,
+ describedByIds,
+ display = 'row',
+ hasChildLabel = true,
+ id: propsId,
+ isDisabled,
+ ...rest
+}) => {
+ const { defaultFullWidth } = useFormContext();
+ const fullWidth = _fullWidth ?? defaultFullWidth;
+ const id = useGeneratedHtmlId({ conditionalId: propsId });
+ const hasLabel = label || labelAppend;
+
+ const [isFocused, setIsFocused] = useState(false);
+ const onFocusWithin = useCallback(() => setIsFocused(true), []);
+ const onBlurWithin = useCallback(() => setIsFocused(false), []);
+
+ const classes = classNames(
+ 'euiFormRow',
+ {
+ 'euiFormRow--hasEmptyLabelSpace': hasEmptyLabelSpace,
+ 'euiFormRow--hasLabel': hasLabel,
+ },
+ className
+ );
+
+ const styles = useEuiMemoizedStyles(euiFormRowStyles);
+ const cssStyles = [
+ styles.euiFormRow,
+ fullWidth ? styles.fullWidth : styles.formWidth,
+ styles[display],
+ ];
+
+ const optionalHelpTexts = useMemo(() => {
+ if (!helpText) return;
+ const helpTexts = Array.isArray(helpText) ? helpText : [helpText];
+
+ return helpTexts.map((helpText, i) => {
+ const key = typeof helpText === 'string' ? helpText : i;
+ return (
+
+ {helpText}
+
+ );
});
- };
-
- render() {
- const { defaultFullWidth } = this.context as FormContextValue;
-
- const {
- children,
- helpText,
- isInvalid,
- error,
- label,
- labelType,
- labelAppend,
- hasEmptyLabelSpace,
- fullWidth = defaultFullWidth,
- className,
- describedByIds,
- display,
- hasChildLabel,
- id: propsId,
- isDisabled,
- ...rest
- } = this.props;
-
- const { id } = this.state;
- const hasLabel = label || labelAppend;
-
- const classes = classNames(
- 'euiFormRow',
- {
- 'euiFormRow--hasEmptyLabelSpace': hasEmptyLabelSpace,
- 'euiFormRow--fullWidth': fullWidth,
- 'euiFormRow--hasLabel': hasLabel,
- },
- displayToClassNameMap[display!], // Safe use of ! as default prop is 'row'
- className
- );
-
- let optionalHelpTexts;
-
- if (helpText) {
- const helpTexts = Array.isArray(helpText) ? helpText : [helpText];
- optionalHelpTexts = helpTexts.map((helpText, i) => {
- const key = typeof helpText === 'string' ? helpText : i;
- return (
-
- {helpText}
-
- );
- });
- }
-
- let optionalErrors;
-
- if (error && isInvalid) {
- const errorTexts = Array.isArray(error) ? error : [error];
- optionalErrors = errorTexts.map((error, i) => {
- const key = typeof error === 'string' ? error : i;
- return (
-
- {error}
-
- );
- });
- }
-
- let optionalLabel;
- const isLegend = label && labelType === 'legend' ? true : false;
- const labelId = `${id}-label`;
-
- if (hasLabel) {
- let labelProps = {};
- if (isLegend) {
- labelProps = {
- type: labelType,
- };
- } else {
- labelProps = {
- htmlFor: hasChildLabel ? id : undefined,
- ...(!isDisabled && { isFocused: this.state.isFocused }), // If the row is disabled, don't pass the isFocused state.
- type: labelType,
- };
- }
-
- optionalLabel = (
-
-
- {label}
-
- {labelAppend && ' '}
- {labelAppend}
-
+ }, [helpText, id]);
+
+ const optionalErrors = useMemo(() => {
+ if (!(error && isInvalid)) return;
+ const errorTexts = Array.isArray(error) ? error : [error];
+
+ return errorTexts.map((error, i) => {
+ const key = typeof error === 'string' ? error : i;
+ return (
+
+ {error}
+
);
- }
+ });
+ }, [error, isInvalid, id]);
- const optionalProps: React.AriaAttributes = {};
- /**
- * Safe use of ! as default prop is []
- */
- const describingIds = [...describedByIds!];
+ const ariaDescribedBy = useMemo(() => {
+ const describingIds = [...(describedByIds || [])];
- if (optionalHelpTexts) {
+ if (optionalHelpTexts?.length) {
optionalHelpTexts.forEach((optionalHelpText) =>
describingIds.push(optionalHelpText.props.id)
);
}
-
- if (optionalErrors) {
+ if (optionalErrors?.length) {
optionalErrors.forEach((error) => describingIds.push(error.props.id));
}
-
- if (describingIds.length > 0) {
- optionalProps['aria-describedby'] = describingIds.join(' ');
+ if (describingIds.length) {
+ return describingIds.join(' ');
}
+ }, [describedByIds, optionalHelpTexts, optionalErrors]);
+ const field = useMemo(() => {
const child = Children.only(children);
- const field = cloneElement(child, {
+ return cloneElement(child, {
id,
// Allow the child's disabled or isDisabled prop to supercede the `isDisabled`
disabled: child.props.disabled ?? child.props.isDisabled ?? isDisabled,
- onFocus: this.onFocus,
- onBlur: this.onBlur,
- ...optionalProps,
+ 'aria-describedby': ariaDescribedBy,
});
-
- const fieldWrapperClasses = classNames('euiFormRow__fieldWrapper', {
- euiFormRow__fieldWrapperDisplayOnly:
- /**
- * Safe use of ! as default prop is 'row'
- */
- display!.startsWith('center'),
- });
-
- const sharedProps = {
- className: classes,
- id: `${id}-row`,
- };
-
- const contents = (
-
- {optionalLabel}
-
- {field}
- {optionalErrors}
- {optionalHelpTexts}
+ }, [children, id, isDisabled, ariaDescribedBy]);
+
+ const Element = labelType === 'legend' ? 'fieldset' : 'div';
+
+ return (
+
)}
+ >
+ {hasLabel ? (
+
+
+ {label}
+
+ {labelAppend && ' '}
+ {labelAppend}
-
- );
-
- return labelType === 'legend' ? (
- )}
+ ) : (
+ hasEmptyLabelSpace && (
+
+ )
+ )}
+
- {contents}
-
- ) : (
-
)}>
- {contents}
+ {field}
+ {optionalErrors}
+ {optionalHelpTexts}
- );
- }
-}
+
+ );
+};
diff --git a/packages/eui/src/components/index.scss b/packages/eui/src/components/index.scss
index 2daaa063c25..276f366acfd 100644
--- a/packages/eui/src/components/index.scss
+++ b/packages/eui/src/components/index.scss
@@ -1,4 +1,3 @@
// Components
@import 'datagrid/index';
-@import 'form/index';
diff --git a/packages/eui/src/components/inline_edit/__snapshots__/inline_edit_form.test.tsx.snap b/packages/eui/src/components/inline_edit/__snapshots__/inline_edit_form.test.tsx.snap
index 2a4b4f41a1c..46db6fd72d0 100644
--- a/packages/eui/src/components/inline_edit/__snapshots__/inline_edit_form.test.tsx.snap
+++ b/packages/eui/src/components/inline_edit/__snapshots__/inline_edit_form.test.tsx.snap
@@ -13,7 +13,7 @@ exports[`EuiInlineEditForm edit mode editModeProps.cancelButtonProps 1`] = `
class="euiFlexItem emotion-euiFlexItem-grow-1"
>