From bc8d778236bb9854d1e9081d0e5b59bbc9126eab Mon Sep 17 00:00:00 2001 From: kamre Date: Mon, 26 Jul 2021 15:34:21 +0700 Subject: [PATCH] [GH-770] Use textarea for title in the card dialog (#776) * New widget `EditableArea` introduced: - textarea with automatic height - implementation is based on AutosizeTextarea from mattermost-webapp - used for title in CardDetail * Cypress test for setting card title fixed. Co-authored-by: Harshil Sharma --- webapp/cypress/integration/createBoard.js | 2 +- .../src/components/cardDetail/cardDetail.scss | 3 + .../src/components/cardDetail/cardDetail.tsx | 7 +- webapp/src/widgets/editable.tsx | 87 ++++++++++++------- webapp/src/widgets/editableArea.scss | 17 ++++ webapp/src/widgets/editableArea.tsx | 66 ++++++++++++++ 6 files changed, 148 insertions(+), 34 deletions(-) create mode 100644 webapp/src/widgets/editableArea.scss create mode 100644 webapp/src/widgets/editableArea.tsx diff --git a/webapp/cypress/integration/createBoard.js b/webapp/cypress/integration/createBoard.js index 7aabd989c..e3d22aaa2 100644 --- a/webapp/cypress/integration/createBoard.js +++ b/webapp/cypress/integration/createBoard.js @@ -59,7 +59,7 @@ describe('Create and delete board / card', () => { it('Can set the card title', () => { // Card title - cy.get('.CardDetail>.Editable.title'). + cy.get('.CardDetail .EditableArea.title'). type(cardTitle). type('{enter}'). should('have.value', cardTitle); diff --git a/webapp/src/components/cardDetail/cardDetail.scss b/webapp/src/components/cardDetail/cardDetail.scss index ff4bb05a1..fff0a920e 100644 --- a/webapp/src/components/cardDetail/cardDetail.scss +++ b/webapp/src/components/cardDetail/cardDetail.scss @@ -1,4 +1,7 @@ .CardDetail { + .title { + width: 100%; + } .add-buttons { display: flex; diff --git a/webapp/src/components/cardDetail/cardDetail.tsx b/webapp/src/components/cardDetail/cardDetail.tsx index a9949f5bf..f529d7193 100644 --- a/webapp/src/components/cardDetail/cardDetail.tsx +++ b/webapp/src/components/cardDetail/cardDetail.tsx @@ -8,7 +8,8 @@ import mutator from '../../mutator' import {BoardTree} from '../../viewModel/boardTree' import {CardTree} from '../../viewModel/cardTree' import Button from '../../widgets/buttons/button' -import Editable from '../../widgets/editable' +import {Focusable} from '../../widgets/editable' +import EditableArea from '../../widgets/editableArea' import EmojiIcon from '../../widgets/icons/emoji' import BlockIconSelector from '../blockIconSelector' @@ -30,7 +31,7 @@ const CardDetail = (props: Props): JSX.Element|null => { const {cardTree} = props const {card, comments} = cardTree const [title, setTitle] = useState(cardTree.card.title) - const titleRef = useRef<{focus(selectAll?: boolean): void}>(null) + const titleRef = useRef(null) const titleValueRef = useRef(title) titleValueRef.current = title @@ -76,7 +77,7 @@ const CardDetail = (props: Props): JSX.Element|null => { } - void value?: string placeholderText?: string @@ -18,8 +18,28 @@ type Props = { onSave?: (saveType: 'onEnter'|'onEsc'|'onBlur') => void } -const Editable = (props: Props, ref: React.Ref<{focus: (selectAll?: boolean) => void}>): JSX.Element => { - const elementRef = useRef(null) +export type Focusable = { + focus: (selectAll?: boolean) => void +} + +export type ElementType = HTMLInputElement | HTMLTextAreaElement + +export type ElementProps = { + className: string, + placeholder?: string, + onChange: (e: React.ChangeEvent) => void, + value?: string, + title?: string, + onBlur: () => void, + onKeyDown: (e: React.KeyboardEvent) => void, + readOnly?: boolean, + spellCheck?: boolean +} + +export function useEditable( + props: EditableProps, + focusableRef: React.Ref, + elementRef: React.RefObject): ElementProps { const saveOnBlur = useRef(true) const save = (saveType: 'onEnter'|'onEsc'|'onBlur'): void => { @@ -38,7 +58,7 @@ const Editable = (props: Props, ref: React.Ref<{focus: (selectAll?: boolean) => props.onSave(saveType) } - useImperativeHandle(ref, () => ({ + useImperativeHandle(focusableRef, () => ({ focus: (selectAll = false): void => { if (elementRef.current) { const valueLength = elementRef.current.value.length @@ -63,35 +83,42 @@ const Editable = (props: Props, ref: React.Ref<{focus: (selectAll?: boolean) => if (props.validator) { error = !props.validator(value || '') } + return { + className: 'Editable ' + (error ? 'error ' : '') + (readonly ? 'readonly ' : '') + className, + placeholder: placeholderText, + onChange: (e: React.ChangeEvent) => { + onChange(e.target.value) + }, + value, + title: value, + onBlur: () => save('onBlur'), + onKeyDown: (e: React.KeyboardEvent): void => { + if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC + e.preventDefault() + if (props.saveOnEsc) { + save('onEsc') + } else { + props.onCancel?.() + } + blur() + } else if (e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return + e.preventDefault() + save('onEnter') + blur() + } + }, + readOnly: readonly, + spellCheck: props.spellCheck, + } +} +const Editable = (props: EditableProps, ref: React.Ref): JSX.Element => { + const elementRef = useRef(null) + const elementProps = useEditable(props, ref, elementRef) return ( ) => { - onChange(e.target.value) - }} - value={value} - title={value} - onBlur={() => save('onBlur')} - onKeyDown={(e: React.KeyboardEvent): void => { - if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC - e.stopPropagation() - if (props.saveOnEsc) { - save('onEsc') - } else { - props.onCancel?.() - } - blur() - } else if (e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return - e.stopPropagation() - save('onEnter') - blur() - } - }} - readOnly={readonly} - spellCheck={props.spellCheck} /> ) } diff --git a/webapp/src/widgets/editableArea.scss b/webapp/src/widgets/editableArea.scss new file mode 100644 index 000000000..a1cc833ac --- /dev/null +++ b/webapp/src/widgets/editableArea.scss @@ -0,0 +1,17 @@ +.EditableAreaWrap { + width: 100%; +} + +.EditableArea { + resize: none; +} + +.EditableAreaContainer { + height: 0; + overflow: hidden; +} + +.EditableAreaReference { + height: auto; + width: 100%; +} diff --git a/webapp/src/widgets/editableArea.tsx b/webapp/src/widgets/editableArea.tsx new file mode 100644 index 000000000..b3dd3c4ba --- /dev/null +++ b/webapp/src/widgets/editableArea.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {forwardRef, useEffect, useRef} from 'react' + +import {EditableProps, Focusable, useEditable} from './editable' + +import './editableArea.scss' + +function getBorderWidth(style: CSSStyleDeclaration): number { + return parseInt(style.borderTopWidth || '0', 10) + parseInt(style.borderBottomWidth || '0', 10) +} + +const EditableArea = (props: EditableProps, ref: React.Ref): JSX.Element => { + const elementRef = useRef(null) + const referenceRef = useRef(null) + const heightRef = useRef(0) + const elementProps = useEditable(props, ref, elementRef) + + useEffect(() => { + if (!elementRef.current || !referenceRef.current) { + return + } + + const height = referenceRef.current.scrollHeight + const textarea = elementRef.current + + if (height > 0 && height !== heightRef.current) { + const style = getComputedStyle(textarea) + const borderWidth = getBorderWidth(style) + + // Directly change the height to avoid circular rerenders + textarea.style.height = String(height + borderWidth) + 'px' + + heightRef.current = height + } + }) + + const heightProps = { + height: heightRef.current, + rows: 1, + } + + return ( +
+