diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index f596ab76b7d..4069ef311e5 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -151,6 +151,8 @@ import { ImageExample } from './views/image/image_example'; import { InnerTextExample } from './views/inner_text/inner_text_example'; +import { InlineEditExample } from './views/inline_edit/inline_edit_example'; + import { KeyPadMenuExample } from './views/key_pad_menu/key_pad_menu_example'; import { LinkExample } from './views/link/link_example'; @@ -561,6 +563,7 @@ const navigation = [ HealthExample, IconExample, ImageExample, + InlineEditExample, ListGroupExample, LoadingExample, NotificationEventExample, diff --git a/src-docs/src/views/inline_edit/inline_edit_example.js b/src-docs/src/views/inline_edit/inline_edit_example.js new file mode 100644 index 00000000000..f76960b12ec --- /dev/null +++ b/src-docs/src/views/inline_edit/inline_edit_example.js @@ -0,0 +1,292 @@ +import React from 'react'; + +import { Link } from 'react-router-dom'; + +import { GuideSectionTypes } from '../../components'; + +import { + EuiCode, + EuiText, + EuiInlineEditText, + EuiInlineEditTitle, +} from '../../../../src'; + +import { inlineEditTextConfig, inlineEditTitleConfig } from './playground'; + +import InlineEditText from './inline_edit_text'; +const inlineEditTextSource = require('!!raw-loader!./inline_edit_text'); +const inlineEditTextSnippet = ``; + +import InlineEditTitle from './inline_edit_title'; +const inlineEditTitleSource = require('!!raw-loader!./inline_edit_title'); +const inlineEditTitleSnippet = ``; + +import InlineEditSave from './inline_edit_save'; +const inlineEditSaveSource = require('!!raw-loader!././inline_edit_save'); +const inlineEditModeSaveSnippet = ` { + localStorage.setItem('inlineEditValue', newInlineEditValue); + }} +/>`; + +import InlineEditValidation from './inline_edit_validation'; +const inlineEditValidationSource = require('!!raw-loader!././inline_edit_validation'); + +import InlineEditStartInEdit from './inline_edit_start_in_edit'; +const inlineEditStartInEditSource = require('!!raw-loader!././inline_edit_start_in_edit'); +const inlineEditStartInEditSnippet = ``; + +import InlineEditReadOnly from './inline_edit_read_only'; +const InlineEditReadOnlySource = require('!!raw-loader!././inline_edit_read_only'); +const inlineEditReadOnlySnippet = ``; + +import InlineEditModeProps from './inline_edit_mode_props'; +const inlineEditModePropsSource = require('!!raw-loader!./inline_edit_mode_props'); +const inlineEditModePropsSnippet = ``; + +export const InlineEditExample = { + title: 'Inline edit', + intro: ( + <> + + The EuiInlineEdit components are useful for updating + single lines of text outside a form. The component has two states:{' '} + readMode shows editable text inside of a button and{' '} + editMode displays a form control to update the text. + + + ), + isNew: true, + sections: [ + { + title: 'Display and edit basic text', + text: ( + <> +

+ Use EuiInlineEditText to display and edit basic + text. Adjust the size property to change the font + size in both readMode and{' '} + editMode. +

+ + ), + source: [ + { + type: GuideSectionTypes.TSX, + code: inlineEditTextSource, + }, + ], + demo: , + props: { EuiInlineEditText }, + snippet: inlineEditTextSnippet, + playground: inlineEditTextConfig, + }, + { + title: 'Display and edit headings and titles', + text: ( + <> +

+ Use EuiInlineEditTitle to display and edit titles. + Use the heading property to set the heading level + in readMode. +

+ + ), + source: [ + { + type: GuideSectionTypes.TSX, + code: inlineEditTitleSource, + }, + ], + demo: , + props: { EuiInlineEditTitle }, + snippet: inlineEditTitleSnippet, + playground: inlineEditTitleConfig, + }, + { + title: 'Saving edited text', + text: ( +

+ Use the onSave property to retrieve the value of + the edited text when the save button is pressed.{' '} + onSave does not fire if the user cancels their + edit. +

+ ), + source: [ + { + type: GuideSectionTypes.TSX, + code: inlineEditSaveSource, + }, + ], + demo: , + snippet: inlineEditModeSaveSnippet, + }, + { + title: 'Validating edited text', + text: ( + <> +

+ Validation states (isLoading and{' '} + isInvalid) only display while the user is in edit + mode. +

+

+ Return a boolean from your onSave callback to + validate text after pressing Save. You can also return a boolean + from a promise. Validation happens before the user returns to read + mode. +

+

+ Returning false from onSave{' '} + will keep the user in edit mode, where you can then display + validation state and messages. Returning true or{' '} + undefined will return the user to read mode. +

+ + ), + source: [ + { + type: GuideSectionTypes.TSX, + code: inlineEditValidationSource, + }, + ], + demo: , + }, + { + title: 'Start in edit mode', + text: ( +

+ Use the startWithEditOpen prop to default to + opening in edit mode. +

+ ), + source: [ + { + type: GuideSectionTypes.TSX, + code: inlineEditStartInEditSource, + }, + ], + demo: , + snippet: inlineEditStartInEditSnippet, + }, + { + title: 'Read only', + text: ( +

+ Use the isReadOnly prop to lock{' '} + EuiInlineEdit in read mode and display the text + value. This does not affect the input form control in edit mode. +

+ ), + source: [ + { + type: GuideSectionTypes.TSX, + code: InlineEditReadOnlySource, + }, + ], + demo: , + snippet: inlineEditReadOnlySnippet, + }, + { + title: 'Customizing read and edit modes', + text: ( + <> +

+ Customize the read mode by passing readModeProps, + which accepts any{' '} + + EuiButtonEmpty + {' '} + properties. +

+ +

+ Customize the edit mode by passing editModeProps. + This prop contains nested object properties that are applied to + various child components in edit mode: +

+
    +
  • + editMode.formRowProps accepts any{' '} + + EuiFormRow + {' '} + properties +
  • +
  • + editMode.inputProps accepts any{' '} + + EuiFieldText + {' '} + properties +
  • +
  • + editMode.saveButtonProps accepts any{' '} + + EuiIconButton + {' '} + properties +
  • +
  • + editMode.cancelButtonProps accepts any{' '} + + EuiIconButton + {' '} + properties +
  • +
+ + ), + source: [ + { + type: GuideSectionTypes.TSX, + code: inlineEditModePropsSource, + }, + ], + demo: , + snippet: inlineEditModePropsSnippet, + }, + ], +}; diff --git a/src-docs/src/views/inline_edit/inline_edit_mode_props.tsx b/src-docs/src/views/inline_edit/inline_edit_mode_props.tsx new file mode 100644 index 00000000000..5239fe5361c --- /dev/null +++ b/src-docs/src/views/inline_edit/inline_edit_mode_props.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { EuiInlineEditText } from '../../../../src'; + +export default () => { + return ( + + ); +}; diff --git a/src-docs/src/views/inline_edit/inline_edit_read_only.tsx b/src-docs/src/views/inline_edit/inline_edit_read_only.tsx new file mode 100644 index 00000000000..3e4a2686792 --- /dev/null +++ b/src-docs/src/views/inline_edit/inline_edit_read_only.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; + +import { + EuiInlineEditText, + EuiInlineEditTitle, + EuiSpacer, + EuiSwitch, +} from '../../../../src'; + +export default () => { + const [isReadOnly, setIsReadOnly] = useState(true); + + return ( + <> + setIsReadOnly(e.target.checked)} + /> + + + + + + + + + + ); +}; diff --git a/src-docs/src/views/inline_edit/inline_edit_save.tsx b/src-docs/src/views/inline_edit/inline_edit_save.tsx new file mode 100644 index 00000000000..9b01f695a56 --- /dev/null +++ b/src-docs/src/views/inline_edit/inline_edit_save.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { EuiInlineEditText } from '../../../../src'; + +export default () => { + const saveToLocalStorage = (newInlineEditValue: string) => { + localStorage.setItem('inlineEditValue', newInlineEditValue); + }; + + const defaultInlineEditValue = + localStorage.getItem('inlineEditValue') || + 'This value will persist when you refresh the page!'; + + return ( + + ); +}; diff --git a/src-docs/src/views/inline_edit/inline_edit_start_in_edit.tsx b/src-docs/src/views/inline_edit/inline_edit_start_in_edit.tsx new file mode 100644 index 00000000000..0c2ed4bdf2a --- /dev/null +++ b/src-docs/src/views/inline_edit/inline_edit_start_in_edit.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { + EuiInlineEditText, + EuiInlineEditTitle, + EuiSpacer, +} from '../../../../src'; + +export default () => { + return ( + <> + + + + + + + ); +}; diff --git a/src-docs/src/views/inline_edit/inline_edit_text.tsx b/src-docs/src/views/inline_edit/inline_edit_text.tsx new file mode 100644 index 00000000000..04d837e4e42 --- /dev/null +++ b/src-docs/src/views/inline_edit/inline_edit_text.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; + +import { + EuiInlineEditText, + EuiSpacer, + EuiButtonGroup, + EuiInlineEditTextProps, +} from '../../../../src'; + +export default () => { + const textSizeButtons = [ + { + id: 'xs', + label: 'Extra Small', + }, + { + id: 's', + label: 'Small', + }, + { + id: 'm', + label: 'Medium', + }, + ]; + + const [toggleTextButtonSize, setToggleTextButtonSize] = useState< + EuiInlineEditTextProps['size'] + >('m'); + + const textSizeOnChange = (optionId: EuiInlineEditTextProps['size']) => { + setToggleTextButtonSize(optionId); + }; + + return ( + <> + + textSizeOnChange(id as EuiInlineEditTextProps['size']) + } + /> + + + + + + ); +}; diff --git a/src-docs/src/views/inline_edit/inline_edit_title.tsx b/src-docs/src/views/inline_edit/inline_edit_title.tsx new file mode 100644 index 00000000000..edd3794ceed --- /dev/null +++ b/src-docs/src/views/inline_edit/inline_edit_title.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; + +import { + EuiInlineEditTitle, + EuiSpacer, + EuiButtonGroup, + EuiTitleSize, +} from '../../../../src'; + +export default () => { + const titleSizeButtons = [ + { + id: 'xxxs', + label: '3X Small', + }, + { + id: 'xxs', + label: '2X Small', + }, + { + id: 'xs', + label: 'Extra small', + }, + { + id: 's', + label: 'Small', + }, + { + id: 'm', + label: 'Medium', + }, + { + id: 'l', + label: 'Large', + }, + ]; + + const [toggleTitleButtonSize, setToggleTitleButtonSize] = useState< + EuiTitleSize + >('m'); + + const titleSizeOnChange = (optionId: EuiTitleSize) => { + setToggleTitleButtonSize(optionId); + }; + + return ( + <> + titleSizeOnChange(id as EuiTitleSize)} + /> + + + + + + ); +}; diff --git a/src-docs/src/views/inline_edit/inline_edit_validation.tsx b/src-docs/src/views/inline_edit/inline_edit_validation.tsx new file mode 100644 index 00000000000..f3977e7da3c --- /dev/null +++ b/src-docs/src/views/inline_edit/inline_edit_validation.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; + +import { EuiInlineEditText } from '../../../../src'; + +export default () => { + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState([]); + const isInvalid = errors.length > 0; + + const mockApiCall = (value: string) => + new Promise((resolve) => { + localStorage.setItem('inlineEditValueValidated', value); + setTimeout(resolve, 3000); + }); + + const defaultInlineEditValue = + localStorage.getItem('inlineEditValueValidated') || + 'This value will persist when you refresh the page!'; + + return ( + <> + setErrors([]) }, + inputProps: { readOnly: isLoading }, + }} + isInvalid={isInvalid} + isLoading={isLoading} + onSave={async (value) => { + // Validate edited text + if (!value) { + setErrors(['Please enter text.']); + return false; + } else if (value.length > 20) { + setErrors([ + 'Your text is too long - please enter less than 20 characters', + ]); + return false; + } + + // Clear errors, set loading state, and "call" an API + setErrors([]); + setIsLoading(true); + await mockApiCall(value); + setIsLoading(false); + return true; + }} + /> + + ); +}; diff --git a/src-docs/src/views/inline_edit/playground.js b/src-docs/src/views/inline_edit/playground.js new file mode 100644 index 00000000000..7d7290905d4 --- /dev/null +++ b/src-docs/src/views/inline_edit/playground.js @@ -0,0 +1,103 @@ +import { PropTypes } from 'react-view'; +import { + EuiInlineEditText, + EuiInlineEditTitle, +} from '../../../../src/components'; +import { + propUtilityForPlayground, + dummyFunction, + simulateFunction, +} from '../../services/playground'; + +const setCommonPropsToUse = (propsToUse) => { + propsToUse.inputAriaLabel = { + ...propsToUse.inputAriaLabel, + value: 'Edit text inline', + type: PropTypes.String, + }; + + propsToUse.isLoading = { + type: PropTypes.Boolean, + }; + + propsToUse.isInvalid = { + type: PropTypes.Boolean, + }; + + propsToUse.startWithEditOpen = { + type: PropTypes.Boolean, + }; + + propsToUse.onSave = simulateFunction(propsToUse.onSave); +}; + +export const inlineEditTextConfig = () => { + const docgenInfo = Array.isArray(EuiInlineEditText.__docgenInfo) + ? EuiInlineEditText.__docgenInfo[0] + : EuiInlineEditText.__docgenInfo; + const propsToUse = propUtilityForPlayground(docgenInfo.props); + + propsToUse.defaultValue = { + ...propsToUse.defaultValue, + value: 'Hello! You are editing text content!', + type: PropTypes.String, + }; + + setCommonPropsToUse(propsToUse); + + return { + config: { + componentName: 'EuiInlineEditText', + props: propsToUse, + scope: { + EuiInlineEditText, + }, + imports: { + '@elastic/eui': { + named: ['EuiInlineEditText'], + }, + }, + customProps: { + onSave: dummyFunction, + }, + }, + }; +}; + +export const inlineEditTitleConfig = () => { + const docgenInfo = Array.isArray(EuiInlineEditTitle.__docgenInfo) + ? EuiInlineEditTitle.__docgenInfo[0] + : EuiInlineEditTitle.__docgenInfo; + const propsToUse = propUtilityForPlayground(docgenInfo.props); + + propsToUse.defaultValue = { + ...propsToUse.defaultValue, + value: 'Hello! You are editing a title!', + type: PropTypes.String, + }; + + propsToUse.heading = { + ...propsToUse.heading, + value: 'h4', + }; + + setCommonPropsToUse(propsToUse); + + return { + config: { + componentName: 'EuiInlineEditTitle', + props: propsToUse, + scope: { + EuiInlineEditTitle, + }, + imports: { + '@elastic/eui': { + named: ['EuiInlineEditTitle'], + }, + }, + customProps: { + onSave: dummyFunction, + }, + }, + }; +}; diff --git a/src/components/button/button_empty/button_empty.tsx b/src/components/button/button_empty/button_empty.tsx index 03402dd5101..052e1c14401 100644 --- a/src/components/button/button_empty/button_empty.tsx +++ b/src/components/button/button_empty/button_empty.tsx @@ -91,7 +91,9 @@ export interface CommonEuiButtonEmptyProps type EuiButtonEmptyPropsForAnchor = PropsForAnchor; -type EuiButtonEmptyPropsForButton = PropsForButton; +export type EuiButtonEmptyPropsForButton = PropsForButton< + CommonEuiButtonEmptyProps +>; export type EuiButtonEmptyProps = ExclusiveUnion< EuiButtonEmptyPropsForAnchor, diff --git a/src/components/index.ts b/src/components/index.ts index f11d7b79db8..671e45d7a43 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -91,6 +91,8 @@ export * from './image'; export * from './inner_text'; +export * from './inline_edit'; + export * from './i18n'; export * from './loading'; diff --git a/src/components/inline_edit/__snapshots__/inline_edit_form.test.tsx.snap b/src/components/inline_edit/__snapshots__/inline_edit_form.test.tsx.snap new file mode 100644 index 00000000000..16d2621d462 --- /dev/null +++ b/src/components/inline_edit/__snapshots__/inline_edit_form.test.tsx.snap @@ -0,0 +1,774 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiInlineEditForm edit mode editModeProps.cancelButtonProps 1`] = ` +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+`; + +exports[`EuiInlineEditForm edit mode editModeProps.formRowProps 1`] = ` +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+`; + +exports[`EuiInlineEditForm edit mode editModeProps.inputProps 1`] = ` +
+
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+`; + +exports[`EuiInlineEditForm edit mode editModeProps.saveButtonProps 1`] = ` +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+`; + +exports[`EuiInlineEditForm edit mode isInvalid 1`] = ` +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+`; + +exports[`EuiInlineEditForm edit mode isLoading 1`] = ` +
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+ Loading +
+ +
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`EuiInlineEditForm edit mode renders 1`] = ` +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+`; + +exports[`EuiInlineEditForm read mode isReadOnly 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditForm read mode readModeProps 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditForm read mode renders 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditForm read mode sizes 1`] = ` +
+ + +
+`; diff --git a/src/components/inline_edit/__snapshots__/inline_edit_text.test.tsx.snap b/src/components/inline_edit/__snapshots__/inline_edit_text.test.tsx.snap new file mode 100644 index 00000000000..460ed4ee2a0 --- /dev/null +++ b/src/components/inline_edit/__snapshots__/inline_edit_text.test.tsx.snap @@ -0,0 +1,186 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiInlineEditText isReadOnly 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditText renders 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditText text sizes renders m 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditText text sizes renders s 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditText text sizes renders xs 1`] = ` +
+ + +
+`; diff --git a/src/components/inline_edit/__snapshots__/inline_edit_title.test.tsx.snap b/src/components/inline_edit/__snapshots__/inline_edit_title.test.tsx.snap new file mode 100644 index 00000000000..8c14a849bec --- /dev/null +++ b/src/components/inline_edit/__snapshots__/inline_edit_title.test.tsx.snap @@ -0,0 +1,302 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiInlineEditTitle isReadOnly 1`] = ` +
+ +
+`; + +exports[`EuiInlineEditTitle renders 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditTitle title sizes renders size l 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditTitle title sizes renders size m 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditTitle title sizes renders size s 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditTitle title sizes renders size xs 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditTitle title sizes renders size xxs 1`] = ` +
+ + +
+`; + +exports[`EuiInlineEditTitle title sizes renders size xxxs 1`] = ` +
+ + +
+`; diff --git a/src/components/inline_edit/index.ts b/src/components/inline_edit/index.ts new file mode 100644 index 00000000000..8e38132e9b1 --- /dev/null +++ b/src/components/inline_edit/index.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export { EuiInlineEditText } from './inline_edit_text'; +export type { EuiInlineEditTextProps } from './inline_edit_text'; + +export { EuiInlineEditTitle } from './inline_edit_title'; +export type { EuiInlineEditTitleProps } from './inline_edit_title'; diff --git a/src/components/inline_edit/inline_edit_form.styles.ts b/src/components/inline_edit/inline_edit_form.styles.ts new file mode 100644 index 00000000000..62fbc70c24e --- /dev/null +++ b/src/components/inline_edit/inline_edit_form.styles.ts @@ -0,0 +1,26 @@ +/* + * 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'; + +export const euiInlineEditReadModeStyles = ({ euiTheme }: UseEuiTheme) => { + return { + euiInlineEditReadMode: css``, + + // Override the cursor and allow users to highlight text when read mode is in the read only state + // Once EuiEmptyButton has been converted to Emotion, remove this extra selector + isReadOnly: css` + &.euiButtonEmpty:disabled { + cursor: text; + color: ${euiTheme.colors.text}; + user-select: text; + } + `, + }; +}; diff --git a/src/components/inline_edit/inline_edit_form.test.tsx b/src/components/inline_edit/inline_edit_form.test.tsx new file mode 100644 index 00000000000..8d51e56c50d --- /dev/null +++ b/src/components/inline_edit/inline_edit_form.test.tsx @@ -0,0 +1,453 @@ +/* + * 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 React from 'react'; +import { render } from '../../test/rtl'; +import { requiredProps } from '../../test/required_props'; +import { fireEvent, act, waitFor } from '@testing-library/react'; + +import { + EuiInlineEditForm, + EuiInlineEditFormProps, + SMALL_SIZE_FORM, + MEDIUM_SIZE_FORM, +} from './inline_edit_form'; + +describe('EuiInlineEditForm', () => { + const commonInlineEditFormProps: EuiInlineEditFormProps = { + ...requiredProps, + defaultValue: 'Hello World!', + inputAriaLabel: 'Edit inline', + sizes: MEDIUM_SIZE_FORM, + children: (readModeValue) => readModeValue, + }; + + describe('read mode', () => { + it('renders', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + test('isReadOnly', () => { + const { container, getByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + + expect(getByTestSubject('euiInlineReadModeButton')).toBeDisabled(); + }); + + test('readModeProps', () => { + const { container, getByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect(getByTestSubject('euiInlineReadModeButton')).toBeTruthy(); + }); + + test('sizes', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + }); + + describe('edit mode', () => { + it('renders', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + test('editModeProps.inputProps', () => { + const onChange = jest.fn(); + + const { container, getByTestSubject } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); + + const mockChangeEvent = { target: { value: 'changed' } }; + fireEvent.change(getByTestSubject('customInput'), mockChangeEvent); + expect(onChange).toHaveBeenCalled(); + + // Consumer `onChange` callbacks should not override EUI's + expect( + (getByTestSubject('customInput') as HTMLInputElement).value + ).toEqual('changed'); + }); + + test('editModeProps.formRowProps', () => { + const { container, getByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect(getByTestSubject('customErrorText')).toBeTruthy(); + }); + + test('editModeProps.saveButtonProps', () => { + const { container, getByLabelText } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect(getByLabelText("Yes! Let's save.")).toBeTruthy(); + }); + + test('editModeProps.cancelButtonProps', () => { + const { container, getByLabelText } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect(getByLabelText('Uh no. Do not save.')).toBeDisabled(); + }); + + test('isLoading', () => { + const { container, queryByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect(container.querySelectorAll('.euiSkeletonRectangle')).toHaveLength( + 2 + ); + expect(queryByTestSubject('euiInlineEditModeSaveButton')).toBeFalsy(); + expect(queryByTestSubject('euiInlineEditModeCancelButton')).toBeFalsy(); + }); + + test('isInvalid', () => { + const { container, getByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect( + getByTestSubject('euiInlineEditModeInput').hasAttribute('aria-invalid') + ).toBeTruthy(); + }); + }); + + describe('toggling between read mode and edit mode', () => { + jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: Function) => cb()); + + const onClick = jest.fn(); + const onSave = jest.fn(); + beforeEach(() => { + onClick.mockReset(); + onSave.mockReset(); + }); + + it('activates edit mode when the read mode button is clicked', () => { + const { getByTestSubject, queryByTestSubject } = render( + + ); + + fireEvent.click(getByTestSubject('euiInlineReadModeButton')); + + expect(queryByTestSubject('euiInlineReadModeButton')).toBeFalsy(); + waitFor(() => { + expect(document.activeElement).toEqual( + getByTestSubject('euiInlineEditModeInput') + ); + }); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('saves text and returns to read mode', () => { + const { getByTestSubject, getByText } = render( + + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + expect( + getByTestSubject('euiInlineEditModeInput').getAttribute('value') + ).toEqual('New message!'); + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + + waitFor(() => { + expect(document.activeElement).toEqual( + getByTestSubject('euiInlineReadModeButton') + ); + }); + expect(getByText('New message!')).toBeTruthy(); + expect(onSave).toHaveBeenCalledWith('New message!'); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('cancels text and returns to read mode', () => { + const { getByTestSubject, getByText } = render( + + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + expect( + getByTestSubject('euiInlineEditModeInput').getAttribute('value') + ).toEqual('New message!'); + fireEvent.click(getByTestSubject('euiInlineEditModeCancelButton')); + + waitFor(() => { + expect(document.activeElement).toEqual( + getByTestSubject('euiInlineReadModeButton') + ); + }); + expect(getByText('Hello World!')).toBeTruthy(); + expect(onSave).not.toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + describe('onSave validation', () => { + it('returns to read mode with updated text when onSave returns true', () => { + onSave.mockReturnValueOnce(true); + + const { getByTestSubject, getByText } = render( + + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + act(() => { + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + }); + + expect(getByTestSubject('euiInlineReadModeButton')).toBeTruthy(); + expect(getByText('New message!')).toBeTruthy(); + }); + + it('stays in edit mode when onSave returns false', () => { + onSave.mockReturnValueOnce(false); + + const { getByTestSubject, queryByTestSubject } = render( + + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + act(() => { + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + }); + + expect(queryByTestSubject('euiInlineReadModeButton')).toBeFalsy(); + expect(getByTestSubject('euiInlineEditModeInput')).toBeTruthy(); + }); + + it('handles async promises', async () => { + onSave.mockImplementation( + (value) => + new Promise((resolve) => { + setTimeout(resolve, 100); + return !!value; // returns false if empty string, true if not + }) + ); + + const { getByTestSubject, queryByTestSubject, getByText } = render( + + ); + + // Should still be in edit mode after an empty string is submitted + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: '' }, + }); + await act(async () => { + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + waitFor(() => setTimeout(() => {}, 100)); // Let the promise finish resolving + }); + expect(queryByTestSubject('euiInlineReadModeButton')).toBeFalsy(); + expect(getByTestSubject('euiInlineEditModeInput')).toBeTruthy(); + + // Should successfully save into read mode + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'hey there' }, + }); + await act(async () => { + fireEvent.click(getByTestSubject('euiInlineEditModeSaveButton')); + }); + waitFor(() => { + expect(getByTestSubject('euiInlineReadModeButton')).toBeTruthy(); + expect(getByText('hey there')).toBeTruthy(); + }); + }); + }); + + describe('keyboard events', () => { + test('pressing the Enter key saves text and returns to read mode', () => { + const { getByTestSubject, getByText } = render( + + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + fireEvent.keyDown(getByTestSubject('euiInlineEditModeInput'), { + key: 'Enter', + }); + + waitFor(() => { + expect(document.activeElement).toEqual( + getByTestSubject('euiInlineReadModeButton') + ); + }); + expect(getByText('New message!')).toBeTruthy(); + }); + + test('pressing the Escape key cancels text changes and returns to read mode', () => { + const { getByTestSubject, getByText } = render( + + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + fireEvent.keyDown(getByTestSubject('euiInlineEditModeInput'), { + key: 'Escape', + }); + + waitFor(() => { + expect(document.activeElement).toEqual( + getByTestSubject('euiInlineReadModeButton') + ); + }); + expect(getByText('Hello World!')).toBeTruthy(); + }); + + it('calls passed `inputModeProps.onKeyDown` callbacks', () => { + const onKeyDown = jest.fn(); + + const { getByTestSubject, getByText } = render( + + ); + + fireEvent.change(getByTestSubject('euiInlineEditModeInput'), { + target: { value: 'New message!' }, + }); + fireEvent.keyDown(getByTestSubject('euiInlineEditModeInput'), { + key: 'Enter', + }); + + // Both EUI and consumer `onKeyDown` events should have run + expect(onKeyDown).toHaveBeenCalled(); + expect(getByTestSubject('euiInlineReadModeButton')).toBeTruthy(); + expect(getByText('New message!')).toBeTruthy(); + }); + }); + }); +}); diff --git a/src/components/inline_edit/inline_edit_form.tsx b/src/components/inline_edit/inline_edit_form.tsx new file mode 100644 index 00000000000..3793f5e5213 --- /dev/null +++ b/src/components/inline_edit/inline_edit_form.tsx @@ -0,0 +1,356 @@ +/* + * 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 React, { + ReactNode, + FunctionComponent, + useState, + useRef, + useEffect, + HTMLAttributes, + MouseEvent, + KeyboardEvent, +} from 'react'; +import classNames from 'classnames'; + +import { CommonProps } from '../common'; +import { + EuiFormRow, + EuiFormRowProps, + EuiFieldText, + EuiFieldTextProps, +} from '../form'; +import { euiFormVariables } from '../form/form.styles'; +import { EuiButtonIcon, EuiButtonEmpty } from '../button'; +import { EuiButtonIconPropsForButton } from '../button/button_icon'; +import { EuiButtonEmptyPropsForButton } from '../button/button_empty/button_empty'; +import { EuiFlexGroup, EuiFlexItem } from '../flex'; +import { EuiSkeletonLoading, EuiSkeletonRectangle } from '../skeleton'; +import { useEuiTheme, useCombinedRefs, keys } from '../../services'; +import { EuiI18n, useEuiI18n } from '../i18n'; +import { useGeneratedHtmlId } from '../../services/accessibility'; +import { euiInlineEditReadModeStyles } from './inline_edit_form.styles'; + +// Props shared between the internal form component as well as consumer-facing components +export type EuiInlineEditCommonProps = HTMLAttributes & + CommonProps & { + defaultValue: string; + /** + * Callback that fires when a user clicks the save button. + * Passes the current edited text value as an argument. + * + * To validate the value of the edited text, pass back a boolean flag. + * If `false`, EuiInlineEdit will remain in edit mode, where loading or invalid states can be set. + * If `true`, EuiInlineEdit will return to read mode. + */ + onSave?: (value: string) => void | boolean | Promise; + /** + * Form label that appears above the form control. + * This is required for accessibility because there is no visual label on the input. + */ + inputAriaLabel: string; + /** + * Starts the component in edit mode + */ + startWithEditOpen?: boolean; + /** + * Props that will be applied directly to the `EuiEmptyButton` displayed in read mode + */ + readModeProps?: Partial; + /** + * Multiple props objects that can be applied directly to various child components displayed in edit mode. + * - `formRowProps` will be passed to `EuiFormRow` + * - `inputProps` will be passed to `EuiFieldText` + * - `saveButtonProps` & `cancelButtonProps` will be passed to their respective `EuiIconButton`s + */ + editModeProps?: { + formRowProps?: Partial; + inputProps?: Partial; + saveButtonProps?: Partial; + cancelButtonProps?: Partial; + }; + /** + * Loading state - only displayed in edit mode + */ + isLoading?: boolean; + /** + * Invalid state - only displayed edit mode + */ + isInvalid?: boolean; + /** + * Locks inline edit in read mode and displays the text value + */ + isReadOnly?: boolean; + }; + +// Internal-only props, passed by the consumer-facing components +export type EuiInlineEditFormProps = EuiInlineEditCommonProps & { + /** + * Form sizes + */ + sizes: { + compressed: boolean; + buttonSize: EuiButtonEmptyPropsForButton['size']; + iconSize: EuiButtonEmptyPropsForButton['iconSize']; + }; + /** + * Render prop that returns the read mode value as an arg + */ + children: (readModeValue: ReactNode) => ReactNode; +}; + +export const SMALL_SIZE_FORM = { + iconSize: 's', + compressed: true, + buttonSize: 's', +} as const; + +export const MEDIUM_SIZE_FORM = { + iconSize: 'm', + compressed: false, + buttonSize: 'm', +} as const; + +export const EuiInlineEditForm: FunctionComponent = ({ + className, + children, + sizes, + defaultValue, + inputAriaLabel, + startWithEditOpen, + readModeProps, + editModeProps, + isLoading = false, + isInvalid, + onSave, + isReadOnly, +}) => { + const classes = classNames('euiInlineEdit', className); + + const euiTheme = useEuiTheme(); + + const readModeStyles = euiInlineEditReadModeStyles(euiTheme); + const readModeCssStyles = [ + readModeStyles.euiInlineEditReadMode, + isReadOnly && readModeStyles.isReadOnly, + ]; + + const { controlHeight, controlCompressedHeight } = euiFormVariables(euiTheme); + const loadingSkeletonSize = sizes.compressed + ? controlCompressedHeight + : controlHeight; + + const defaultSaveButtonAriaLabel = useEuiI18n( + 'euiInlineEditForm.saveButtonAriaLabel', + 'Save edit' + ); + const defaultCancelButtonAriaLabel = useEuiI18n( + 'euiInlineEditForm.cancelButtonAriaLabel', + 'Cancel edit' + ); + + const readModeDescribedById = useGeneratedHtmlId({ prefix: 'inlineEdit' }); + const editModeDescribedById = useGeneratedHtmlId({ prefix: 'inlineEdit' }); + + const readModeFocusRef = useRef(null); + const editModeFocusRef = useRef(null); + const setReadModeRefs = useCombinedRefs([ + readModeFocusRef, + readModeProps?.buttonRef, + ]); + const setEditModeRefs = useCombinedRefs([ + editModeFocusRef, + editModeProps?.inputProps?.inputRef, + ]); + + const [isEditing, setIsEditing] = useState(false || startWithEditOpen); + const [editModeValue, setEditModeValue] = useState(defaultValue); + const [readModeValue, setReadModeValue] = useState(defaultValue); + + const activateEditMode = () => { + setIsEditing(true); + // Waits a tick for state to settle and the focus target to render + requestAnimationFrame(() => editModeFocusRef.current?.focus()); + }; + + const cancelInlineEdit = () => { + setEditModeValue(readModeValue); + setIsEditing(false); + requestAnimationFrame(() => readModeFocusRef.current?.focus()); + }; + + const saveInlineEditValue = async () => { + // If an onSave callback is present, and returns false, stay in edit mode + if (onSave) { + const onSaveReturn = onSave(editModeValue); + const awaitedReturn = + onSaveReturn instanceof Promise ? await onSaveReturn : onSaveReturn; + if (awaitedReturn === false) return; + } + + setReadModeValue(editModeValue); + setIsEditing(false); + requestAnimationFrame(() => readModeFocusRef.current?.focus()); + }; + + const editModeInputOnKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case keys.ENTER: + event.preventDefault(); // Enter keypresses will not proceed otherwise on webkit browsers & screen readers + saveInlineEditValue(); + break; + case keys.ESCAPE: + cancelInlineEdit(); + break; + } + }; + + // If the state of isReadOnly changes while in edit mode, switch back to read mode + useEffect(() => { + if (isReadOnly) { + setIsEditing(false); + } + }, [isReadOnly]); + + const editModeForm = ( + + + + { + setEditModeValue(e.target.value); + editModeProps?.inputProps?.onChange?.(e); + }} + onKeyDown={(e) => { + editModeInputOnKeyDown(e); + editModeProps?.inputProps?.onKeyDown?.(e); + }} + aria-describedby={classNames( + editModeDescribedById, + editModeProps?.inputProps?.['aria-describedby'] + )} + /> + + + + + + + + + + } + loadedContent={ + + ) => { + saveInlineEditValue(); + editModeProps?.saveButtonProps?.onClick?.(e); + }} + /> + ) => { + cancelInlineEdit(); + editModeProps?.cancelButtonProps?.onClick?.(e); + }} + /> + + } + /> + + + ); + + const readModeElement = ( + <> + ) => { + activateEditMode(); + readModeProps?.onClick?.(e); + }} + > + {children(readModeValue)} + + + + ); + + return ( +
{isEditing ? editModeForm : readModeElement}
+ ); +}; diff --git a/src/components/inline_edit/inline_edit_text.styles.ts b/src/components/inline_edit/inline_edit_text.styles.ts new file mode 100644 index 00000000000..864cdf20b33 --- /dev/null +++ b/src/components/inline_edit/inline_edit_text.styles.ts @@ -0,0 +1,37 @@ +/* + * 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 { euiTextStyles } from '../text/text.styles'; + +export const euiInlineEditTextStyles = (euiThemeContext: UseEuiTheme) => { + const textFontStyles = euiTextStyles(euiThemeContext); + + return { + euiInlineEditText: css``, + + fontSize: { + xs: css` + .euiFieldText { + ${textFontStyles.xs} + } + `, + s: css` + .euiFieldText { + ${textFontStyles.s} + } + `, + m: css` + .euiFieldText { + ${textFontStyles.m} + } + `, + }, + }; +}; diff --git a/src/components/inline_edit/inline_edit_text.test.tsx b/src/components/inline_edit/inline_edit_text.test.tsx new file mode 100644 index 00000000000..4c6fc2ceefd --- /dev/null +++ b/src/components/inline_edit/inline_edit_text.test.tsx @@ -0,0 +1,61 @@ +/* + * 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 React from 'react'; +import { render } from '../../test/rtl'; +import { requiredProps } from '../../test/required_props'; + +import { EuiInlineEditText, EuiInlineEditTextProps } from './inline_edit_text'; +import { TEXT_SIZES } from '../text/text'; + +describe('EuiInlineEditText', () => { + const inlineEditTextProps: EuiInlineEditTextProps = { + ...requiredProps, + inputAriaLabel: 'Edit text inline', + defaultValue: 'Hello World!', + }; + + it('renders', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + test('isReadOnly', () => { + const { container, getByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + + expect(getByTestSubject('euiInlineReadModeButton')).toHaveAttribute( + 'role', + 'paragraph' + ); + }); + + describe('text sizes', () => { + // Remove 'relative' from text sizes available for EuiInlineEditText + const availableTextSizes = TEXT_SIZES.filter((size) => size !== 'relative'); + + availableTextSizes.forEach((size: string) => { + test(`renders ${size}`, () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/src/components/inline_edit/inline_edit_text.tsx b/src/components/inline_edit/inline_edit_text.tsx new file mode 100644 index 00000000000..8015b7ac141 --- /dev/null +++ b/src/components/inline_edit/inline_edit_text.tsx @@ -0,0 +1,88 @@ +/* + * 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 React, { useMemo, FunctionComponent } from 'react'; +import classNames from 'classnames'; +import { EuiText, EuiTextProps } from '../text'; +import { + EuiInlineEditCommonProps, + EuiInlineEditForm, + SMALL_SIZE_FORM, + MEDIUM_SIZE_FORM, +} from './inline_edit_form'; +import { useEuiTheme } from '../../services'; +import { euiInlineEditTextStyles } from './inline_edit_text.styles'; + +export type EuiInlineEditTextSizes = Exclude; + +export type EuiInlineEditTextProps = EuiInlineEditCommonProps & { + /** + * Text size level + */ + size?: EuiInlineEditTextSizes; +}; + +export const EuiInlineEditText: FunctionComponent = ({ + children, + className, + size = 'm', + defaultValue, + inputAriaLabel, + startWithEditOpen, + readModeProps: _readModeProps, + editModeProps, + isLoading, + isInvalid, + isReadOnly, + ...rest +}) => { + const classes = classNames('euiInlineEditText', className); + + const theme = useEuiTheme(); + const styles = euiInlineEditTextStyles(theme); + const cssStyles = [styles.euiInlineEditText, styles.fontSize[size]]; + + const isSmallSize = ['xs', 's'].includes(size); + const sizes = isSmallSize ? SMALL_SIZE_FORM : MEDIUM_SIZE_FORM; + + const readModeProps = useMemo(() => { + if (!isReadOnly) return _readModeProps; + + return { + ..._readModeProps, + role: 'paragraph', + }; + }, [_readModeProps, isReadOnly]); + + const formProps = { + sizes, + defaultValue, + inputAriaLabel, + startWithEditOpen, + readModeProps, + editModeProps, + isLoading, + isInvalid, + isReadOnly, + }; + + return ( + + {(textReadModeValue) => ( + + {textReadModeValue} + + )} + + ); +}; diff --git a/src/components/inline_edit/inline_edit_title.styles.ts b/src/components/inline_edit/inline_edit_title.styles.ts new file mode 100644 index 00000000000..11590c83142 --- /dev/null +++ b/src/components/inline_edit/inline_edit_title.styles.ts @@ -0,0 +1,52 @@ +/* + * 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 { euiTitleStyles } from '../title/title.styles'; + +export const euiInlineEditTitleStyles = (euiThemeContext: UseEuiTheme) => { + const titleFontStyles = euiTitleStyles(euiThemeContext); + + return { + euiInlineEditTitle: css``, + + fontSize: { + xxxs: css` + .euiFieldText { + ${titleFontStyles.xxxs} + } + `, + xxs: css` + .euiFieldText { + ${titleFontStyles.xxs} + } + `, + xs: css` + .euiFieldText { + ${titleFontStyles.xs} + } + `, + s: css` + .euiFieldText { + ${titleFontStyles.s} + } + `, + m: css` + .euiFieldText { + ${titleFontStyles.m} + } + `, + l: css` + .euiFieldText { + ${titleFontStyles.l} + } + `, + }, + }; +}; diff --git a/src/components/inline_edit/inline_edit_title.test.tsx b/src/components/inline_edit/inline_edit_title.test.tsx new file mode 100644 index 00000000000..6de83071e6c --- /dev/null +++ b/src/components/inline_edit/inline_edit_title.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 React from 'react'; +import { render } from '../../test/rtl'; +import { requiredProps } from '../../test/required_props'; + +import { + EuiInlineEditTitle, + EuiInlineEditTitleProps, +} from './inline_edit_title'; +import { TITLE_SIZES } from '../title/title'; + +describe('EuiInlineEditTitle', () => { + const inlineEditTitleProps: EuiInlineEditTitleProps = { + ...requiredProps, + inputAriaLabel: 'Edit title inline', + defaultValue: 'Hello World!', + heading: 'h1', + }; + + it('renders', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + + it('renders the heading prop', () => { + const { container } = render( + + ); + expect(container.querySelector('h3')).toBeTruthy(); + }); + + test('isReadOnly', () => { + const { container, getByTestSubject } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + + expect(getByTestSubject('euiInlineReadModeButton')).toHaveAttribute( + 'role', + 'heading' + ); + expect(getByTestSubject('euiInlineReadModeButton')).toHaveAttribute( + 'aria-level', + '1' + ); + }); + + describe('title sizes', () => { + TITLE_SIZES.forEach((size) => { + it(`renders size ${size}`, () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/src/components/inline_edit/inline_edit_title.tsx b/src/components/inline_edit/inline_edit_title.tsx new file mode 100644 index 00000000000..ffa1b7ea077 --- /dev/null +++ b/src/components/inline_edit/inline_edit_title.tsx @@ -0,0 +1,106 @@ +/* + * 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 React, { useMemo, FunctionComponent } from 'react'; +import classNames from 'classnames'; +import { EuiTitle, EuiTitleSize } from '../title'; +import { + EuiInlineEditCommonProps, + EuiInlineEditForm, + SMALL_SIZE_FORM, + MEDIUM_SIZE_FORM, +} from './inline_edit_form'; +import { useEuiTheme } from '../../services'; +import { euiInlineEditTitleStyles } from './inline_edit_title.styles'; + +export const HEADINGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span'] as const; +type Heading = typeof HEADINGS[number]; + +export type EuiInlineEditTitleProps = EuiInlineEditCommonProps & { + /** + * Title size level + */ + size?: EuiTitleSize; + /** + * Level of heading to be used for the title. + * Use `span` for text that is not semantically a heading, but should still visually appear as a title. + */ + heading: Heading; +}; + +export const EuiInlineEditTitle: FunctionComponent = ({ + children, + className, + size = 'm', + heading, + defaultValue, + inputAriaLabel, + startWithEditOpen, + readModeProps: _readModeProps, + editModeProps, + isLoading, + isInvalid, + isReadOnly, + ...rest +}) => { + const classes = classNames('euiInlineEditTitle', className); + + const theme = useEuiTheme(); + const styles = euiInlineEditTitleStyles(theme); + const cssStyles = [styles.euiInlineEditTitle, styles.fontSize[size]]; + + const H: Heading = heading; + + const isSmallSize = ['xxxs', 'xxs', 'xs', 's'].includes(size); + const sizes = isSmallSize ? SMALL_SIZE_FORM : MEDIUM_SIZE_FORM; + + const readModeProps = useMemo(() => { + if (!isReadOnly) return _readModeProps; + + const headingNumber = Number(heading.substring(1)); + return headingNumber + ? { + ..._readModeProps, + role: 'heading', + 'aria-level': headingNumber, + } + : { + ..._readModeProps, + role: 'paragraph', + }; + }, [_readModeProps, isReadOnly, heading]); + + const formProps = { + sizes, + defaultValue, + inputAriaLabel, + startWithEditOpen, + readModeProps, + editModeProps, + isLoading, + isInvalid, + isReadOnly, + }; + + return ( + + {(titleReadModeValue) => ( + + + {titleReadModeValue} + + + )} + + ); +}; diff --git a/upcoming_changelogs/6757.md b/upcoming_changelogs/6757.md new file mode 100644 index 00000000000..f2c3b6287b4 --- /dev/null +++ b/upcoming_changelogs/6757.md @@ -0,0 +1,2 @@ +- Added `EuiInlineEditText` and `EuiInlineEditTitle` components +