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 {