From fbe94622357e22acaf8bab0eae33ceae663d7a5b Mon Sep 17 00:00:00 2001 From: Lee seung chan Date: Tue, 1 Mar 2022 07:42:16 +0900 Subject: [PATCH] feat(editable): add editable textarea element (#4443) * feat(editable): add editable textarea element To maintain consistency of editable context, use union type of textarea and input element to reference element. * refactor(editable): change target element type * refactor(editable): add types on propgetter To simplify usage and the type safety, add types where it declared. * fix(editable): use prop getter v2 The 'height' attribute type that html elemenet delivered is not compatable with 'height' of chakra. So, use PropGetterV2 to resolve style incompatiblity. * fix(editable): use hook for styling textarea * fix(editable): add missing property referencing on styling * feat: add textarea to editableAnatomy * feat: add default styles for EditableTextarea * chore: update changeset Co-authored-by: Tim Kolberger --- .changeset/beige-poems-fold.md | 5 + .changeset/modern-dryers-kiss.md | 13 ++ .changeset/proud-camels-dream.md | 5 + packages/anatomy/src/index.ts | 6 +- packages/editable/src/editable.tsx | 43 +++- packages/editable/src/use-editable.ts | 65 +++++- .../editable/stories/editable.stories.tsx | 18 ++ .../editable/tests/editableTextarea.test.tsx | 211 ++++++++++++++++++ packages/theme/src/components/editable.ts | 11 + 9 files changed, 363 insertions(+), 14 deletions(-) create mode 100644 .changeset/beige-poems-fold.md create mode 100644 .changeset/modern-dryers-kiss.md create mode 100644 .changeset/proud-camels-dream.md create mode 100644 packages/editable/tests/editableTextarea.test.tsx diff --git a/.changeset/beige-poems-fold.md b/.changeset/beige-poems-fold.md new file mode 100644 index 00000000000..d681c2f2393 --- /dev/null +++ b/.changeset/beige-poems-fold.md @@ -0,0 +1,5 @@ +--- +"@chakra-ui/anatomy": minor +--- + +Add `textarea` part to `editableAnatomy` diff --git a/.changeset/modern-dryers-kiss.md b/.changeset/modern-dryers-kiss.md new file mode 100644 index 00000000000..2d763b06fb7 --- /dev/null +++ b/.changeset/modern-dryers-kiss.md @@ -0,0 +1,13 @@ +--- +"@chakra-ui/editable": minor +--- + +Added the component `EditableTextarea` to `Editable`. Use the textarea element +to handle multi line text input in an editable context. + +```tsx live=false + + + + +``` diff --git a/.changeset/proud-camels-dream.md b/.changeset/proud-camels-dream.md new file mode 100644 index 00000000000..4817c8f8594 --- /dev/null +++ b/.changeset/proud-camels-dream.md @@ -0,0 +1,5 @@ +--- +"@chakra-ui/theme": minor +--- + +Add styles for new `textarea` element in `Editable` diff --git a/packages/anatomy/src/index.ts b/packages/anatomy/src/index.ts index bc46d148911..11baaea6def 100644 --- a/packages/anatomy/src/index.ts +++ b/packages/anatomy/src/index.ts @@ -59,7 +59,11 @@ export const drawerAnatomy = anatomy("drawer") .parts("overlay", "dialogContainer", "dialog") .extend("header", "closeButton", "body", "footer") -export const editableAnatomy = anatomy("editable").parts("preview", "input") +export const editableAnatomy = anatomy("editable").parts( + "preview", + "input", + "textarea", +) export const formAnatomy = anatomy("form").parts( "container", diff --git a/packages/editable/src/editable.tsx b/packages/editable/src/editable.tsx index 10464fed327..abe762ff1c8 100644 --- a/packages/editable/src/editable.tsx +++ b/packages/editable/src/editable.tsx @@ -139,7 +139,7 @@ export const EditableInput = forwardRef( const { getInputProps } = useEditableContext() const styles = useStyles() - const inputProps = getInputProps(props, ref) as HTMLChakraProps<"input"> + const inputProps = getInputProps(props, ref) const _className = cx("chakra-editable__input", props.className) return ( @@ -160,17 +160,44 @@ if (__DEV__) { EditableInput.displayName = "EditableInput" } +export interface EditableTextareaProps extends HTMLChakraProps<"textarea"> {} + +/** + * EditableTextarea + * + * The textarea used in the `edit` mode + */ +export const EditableTextarea = forwardRef( + (props, ref) => { + const { getTextareaProps } = useEditableContext() + const styles = useStyles() + + const textareaProps = getTextareaProps(props, ref) + const _className = cx("chakra-editable__textarea", props.className) + + return ( + + ) + }, +) + +if (__DEV__) { + EditableTextarea.displayName = "EditableTextarea" +} /** * React hook use to gain access to the editable state and actions. */ export function useEditableState() { - const { - isEditing, - onSubmit, - onCancel, - onEdit, - isDisabled, - } = useEditableContext() + const { isEditing, onSubmit, onCancel, onEdit, isDisabled } = + useEditableContext() return { isEditing, diff --git a/packages/editable/src/use-editable.ts b/packages/editable/src/use-editable.ts index 29e2177319c..ca3f1f6e1f2 100644 --- a/packages/editable/src/use-editable.ts +++ b/packages/editable/src/use-editable.ts @@ -4,7 +4,13 @@ import { useUpdateEffect, useSafeLayoutEffect, } from "@chakra-ui/hooks" -import { EventKeyMap, mergeRefs, PropGetter } from "@chakra-ui/react-utils" +import { + EventKeyMap, + mergeRefs, + PropGetter, + PropGetterV2, +} from "@chakra-ui/react-utils" +import { HTMLChakraProps } from "@chakra-ui/system" import { ariaAttr, callAllHandlers, @@ -113,7 +119,7 @@ export function useEditable(props: UseEditableProps = {}) { /** * Ref to help focus the input in edit mode */ - const inputRef = useRef(null) + const inputRef = useRef(null) const previewRef = useRef(null) const editButtonRef = useRef(null) @@ -168,8 +174,8 @@ export function useEditable(props: UseEditableProps = {}) { }, [value, onSubmitProp]) const onChange = useCallback( - (event: React.ChangeEvent) => { - setValue(event.target.value) + (event: React.ChangeEvent) => { + setValue(event.currentTarget.value) }, [setValue], ) @@ -197,6 +203,24 @@ export function useEditable(props: UseEditableProps = {}) { [onCancel, onSubmit], ) + const onKeyDownWithoutSubmit = useCallback( + (event: React.KeyboardEvent) => { + const eventKey = normalizeEventKey(event) + + const keyMap: EventKeyMap = { + Escape: onCancel, + } + + const action = keyMap[eventKey] + + if (action) { + event.preventDefault() + action(event) + } + }, + [onCancel], + ) + const isValueEmpty = isEmpty(value) const onBlur = useCallback( @@ -238,7 +262,10 @@ export function useEditable(props: UseEditableProps = {}) { ], ) - const getInputProps: PropGetter = useCallback( + const getInputProps: PropGetterV2< + "input", + HTMLChakraProps<"input"> + > = useCallback( (props = {}, ref = null) => ({ ...props, hidden: !isEditing, @@ -254,6 +281,33 @@ export function useEditable(props: UseEditableProps = {}) { [isDisabled, isEditing, onBlur, onChange, onKeyDown, placeholder, value], ) + const getTextareaProps: PropGetterV2< + "textarea", + HTMLChakraProps<"textarea"> + > = useCallback( + (props = {}, ref = null) => ({ + ...props, + hidden: !isEditing, + placeholder, + ref: mergeRefs(ref, inputRef), + disabled: isDisabled, + "aria-disabled": ariaAttr(isDisabled), + value, + onBlur: callAllHandlers(props.onBlur, onBlur), + onChange: callAllHandlers(props.onChange, onChange), + onKeyDown: callAllHandlers(props.onKeyDown, onKeyDownWithoutSubmit), + }), + [ + isDisabled, + isEditing, + onBlur, + onChange, + onKeyDownWithoutSubmit, + placeholder, + value, + ], + ) + const getEditButtonProps: PropGetter = useCallback( (props = {}, ref = null) => ({ "aria-label": "Edit", @@ -298,6 +352,7 @@ export function useEditable(props: UseEditableProps = {}) { onSubmit, getPreviewProps, getInputProps, + getTextareaProps, getEditButtonProps, getSubmitButtonProps, getCancelButtonProps, diff --git a/packages/editable/stories/editable.stories.tsx b/packages/editable/stories/editable.stories.tsx index ae7157adbe6..014805d9975 100644 --- a/packages/editable/stories/editable.stories.tsx +++ b/packages/editable/stories/editable.stories.tsx @@ -5,6 +5,7 @@ import { Editable, EditableInput, EditablePreview, + EditableTextarea, useEditableControls, } from "../src" @@ -107,3 +108,20 @@ export const CodeSandboxTopbar = () => { ) } + +export const TextareaAsInput = () => { + return ( + + + + + + ) +} diff --git a/packages/editable/tests/editableTextarea.test.tsx b/packages/editable/tests/editableTextarea.test.tsx new file mode 100644 index 00000000000..6cd27f61231 --- /dev/null +++ b/packages/editable/tests/editableTextarea.test.tsx @@ -0,0 +1,211 @@ +import { + fireEvent, + render, + screen, + testA11y, + userEvent, +} from "@chakra-ui/test-utils" +import * as React from "react" +import { Editable, EditablePreview, EditableTextarea } from "../src" + +test("matches snapshot", () => { + render( + + + , + ) + + const textarea = screen.getByTestId("textarea") + + expect(textarea).toHaveAttribute("hidden") +}) + +it("passes a11y test", async () => { + await testA11y( + + + , + ) +}) + +test("uncontrolled: handles callbacks correctly", async () => { + const onChange = jest.fn() + const onCancel = jest.fn() + const onSubmit = jest.fn() + const onEdit = jest.fn() + + render( + + + + , + ) + const preview = screen.getByTestId("preview") + const textarea = screen.getByTestId("textarea") + + // calls `onEdit` when preview is focused + fireEvent.focus(preview) + expect(onEdit).toHaveBeenCalled() + + // calls `onChange` with input on change + userEvent.type(textarea, "World") + expect(onChange).toHaveBeenCalledWith("Hello World") + + // get new line on user press "Enter" + userEvent.type( + textarea, + ` + textarea`, + ) + expect(onChange).toHaveBeenLastCalledWith(`Hello World + textarea`) + + // calls `onCancel` with previous value when "esc" pressed + fireEvent.keyDown(textarea, { key: "Escape" }) + expect(onCancel).toHaveBeenCalledWith("Hello ") + + fireEvent.focus(preview) + + // do not calls `onSubmit` with previous value when "enter" pressed after cancelling + fireEvent.keyDown(textarea, { key: "Enter" }) + expect(onSubmit).not.toHaveBeenCalled() +}) + +test("controlled: handles callbacks correctly", () => { + const onChange = jest.fn() + const onCancel = jest.fn() + const onSubmit = jest.fn() + const onEdit = jest.fn() + + const Component = () => { + const [value, setValue] = React.useState("Hello ") + return ( + { + setValue(val) + onChange(val) + }} + onCancel={onCancel} + onSubmit={onSubmit} + onEdit={onEdit} + value={value} + > + + + + ) + } + + render() + const preview = screen.getByTestId("preview") + const textarea = screen.getByTestId("textarea") + + // calls `onEdit` when preview is focused + fireEvent.focus(preview) + expect(onEdit).toHaveBeenCalled() + + // calls `onChange` with input on change + userEvent.type(textarea, "World") + expect(onChange).toHaveBeenCalledWith("Hello World") + + // do not calls `onSubmit` + fireEvent.keyDown(textarea, { key: "Enter" }) + expect(onSubmit).not.toHaveBeenCalledWith("World") + + expect(textarea).toBeVisible() + + // update the input value with new line + userEvent.type( + textarea, + ` + textarea`, + ) + expect(onChange).toHaveBeenCalledWith(`Hello World + textarea`) + + // press `Escape` + fireEvent.keyDown(textarea, { key: "Escape" }) + + // calls `onCancel` with previous `value` + expect(onCancel).toHaveBeenCalledWith(`Hello `) +}) + +test("handles preview and textarea callbacks", () => { + const onFocus = jest.fn() + const onBlur = jest.fn() + const onChange = jest.fn() + const onKeyDown = jest.fn() + + render( + + + + , + ) + const preview = screen.getByTestId("preview") + const textarea = screen.getByTestId("textarea") + + // calls `onFocus` when preview is focused + fireEvent.focus(preview) + expect(onFocus).toHaveBeenCalled() + + // calls `onChange` when input is changed + userEvent.type(textarea, "World") + expect(onChange).toHaveBeenCalled() + + // calls `onKeyDown` when key is pressed in input + fireEvent.keyDown(textarea, { key: "Escape" }) + expect(onKeyDown).toHaveBeenCalled() + + expect(textarea).not.toBeVisible() +}) + +test("has the proper aria attributes", () => { + const { rerender } = render( + + + , + ) + let textarea = screen.getByTestId("textarea") + + // preview and input do not have aria-disabled when `Editable` is not disabled + expect(textarea).not.toHaveAttribute("aria-disabled") + + rerender( + + + , + ) + + textarea = screen.getByTestId("textarea") + + // preview and input have aria-disabled when `Editable` is disabled + expect(textarea).toHaveAttribute("aria-disabled", "true") +}) + +test("editable textarea can submit on blur", () => { + const onSubmit = jest.fn() + + render( + + + + , + ) + + const textarea = screen.getByTestId("textarea") + + fireEvent.blur(textarea) + expect(onSubmit).toHaveBeenCalledWith("testing") +}) diff --git a/packages/theme/src/components/editable.ts b/packages/theme/src/components/editable.ts index 78ee4a82863..868e4533c31 100644 --- a/packages/theme/src/components/editable.ts +++ b/packages/theme/src/components/editable.ts @@ -21,9 +21,20 @@ const baseStyleInput: SystemStyleObject = { _placeholder: { opacity: 0.6 }, } +const baseStyleTextarea: SystemStyleObject = { + borderRadius: "md", + py: "3px", + transitionProperty: "common", + transitionDuration: "normal", + width: "full", + _focus: { boxShadow: "outline" }, + _placeholder: { opacity: 0.6 }, +} + const baseStyle: PartsStyleObject = { preview: baseStylePreview, input: baseStyleInput, + textarea: baseStyleTextarea, } export default {