mirror of
https://github.com/mattermost/focalboard.git
synced 2025-01-05 14:50:29 +02:00
[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 <harshilsharma63@gmail.com>
This commit is contained in:
parent
33708e022b
commit
bc8d778236
@ -59,7 +59,7 @@ describe('Create and delete board / card', () => {
|
|||||||
|
|
||||||
it('Can set the card title', () => {
|
it('Can set the card title', () => {
|
||||||
// Card title
|
// Card title
|
||||||
cy.get('.CardDetail>.Editable.title').
|
cy.get('.CardDetail .EditableArea.title').
|
||||||
type(cardTitle).
|
type(cardTitle).
|
||||||
type('{enter}').
|
type('{enter}').
|
||||||
should('have.value', cardTitle);
|
should('have.value', cardTitle);
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
.CardDetail {
|
.CardDetail {
|
||||||
|
.title {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.add-buttons {
|
.add-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -8,7 +8,8 @@ import mutator from '../../mutator'
|
|||||||
import {BoardTree} from '../../viewModel/boardTree'
|
import {BoardTree} from '../../viewModel/boardTree'
|
||||||
import {CardTree} from '../../viewModel/cardTree'
|
import {CardTree} from '../../viewModel/cardTree'
|
||||||
import Button from '../../widgets/buttons/button'
|
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 EmojiIcon from '../../widgets/icons/emoji'
|
||||||
|
|
||||||
import BlockIconSelector from '../blockIconSelector'
|
import BlockIconSelector from '../blockIconSelector'
|
||||||
@ -30,7 +31,7 @@ const CardDetail = (props: Props): JSX.Element|null => {
|
|||||||
const {cardTree} = props
|
const {cardTree} = props
|
||||||
const {card, comments} = cardTree
|
const {card, comments} = cardTree
|
||||||
const [title, setTitle] = useState(cardTree.card.title)
|
const [title, setTitle] = useState(cardTree.card.title)
|
||||||
const titleRef = useRef<{focus(selectAll?: boolean): void}>(null)
|
const titleRef = useRef<Focusable>(null)
|
||||||
const titleValueRef = useRef(title)
|
const titleValueRef = useRef(title)
|
||||||
titleValueRef.current = title
|
titleValueRef.current = title
|
||||||
|
|
||||||
@ -76,7 +77,7 @@ const CardDetail = (props: Props): JSX.Element|null => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<Editable
|
<EditableArea
|
||||||
ref={titleRef}
|
ref={titleRef}
|
||||||
className='title'
|
className='title'
|
||||||
value={title}
|
value={title}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
import React, {useRef, useImperativeHandle, forwardRef} from 'react'
|
import React, {forwardRef, useImperativeHandle, useRef} from 'react'
|
||||||
|
|
||||||
import './editable.scss'
|
import './editable.scss'
|
||||||
|
|
||||||
type Props = {
|
export type EditableProps = {
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
value?: string
|
value?: string
|
||||||
placeholderText?: string
|
placeholderText?: string
|
||||||
@ -18,8 +18,28 @@ type Props = {
|
|||||||
onSave?: (saveType: 'onEnter'|'onEsc'|'onBlur') => void
|
onSave?: (saveType: 'onEnter'|'onEsc'|'onBlur') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Editable = (props: Props, ref: React.Ref<{focus: (selectAll?: boolean) => void}>): JSX.Element => {
|
export type Focusable = {
|
||||||
const elementRef = useRef<HTMLInputElement>(null)
|
focus: (selectAll?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ElementType = HTMLInputElement | HTMLTextAreaElement
|
||||||
|
|
||||||
|
export type ElementProps = {
|
||||||
|
className: string,
|
||||||
|
placeholder?: string,
|
||||||
|
onChange: (e: React.ChangeEvent<ElementType>) => void,
|
||||||
|
value?: string,
|
||||||
|
title?: string,
|
||||||
|
onBlur: () => void,
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<ElementType>) => void,
|
||||||
|
readOnly?: boolean,
|
||||||
|
spellCheck?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditable(
|
||||||
|
props: EditableProps,
|
||||||
|
focusableRef: React.Ref<Focusable>,
|
||||||
|
elementRef: React.RefObject<ElementType>): ElementProps {
|
||||||
const saveOnBlur = useRef<boolean>(true)
|
const saveOnBlur = useRef<boolean>(true)
|
||||||
|
|
||||||
const save = (saveType: 'onEnter'|'onEsc'|'onBlur'): void => {
|
const save = (saveType: 'onEnter'|'onEsc'|'onBlur'): void => {
|
||||||
@ -38,7 +58,7 @@ const Editable = (props: Props, ref: React.Ref<{focus: (selectAll?: boolean) =>
|
|||||||
props.onSave(saveType)
|
props.onSave(saveType)
|
||||||
}
|
}
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(focusableRef, () => ({
|
||||||
focus: (selectAll = false): void => {
|
focus: (selectAll = false): void => {
|
||||||
if (elementRef.current) {
|
if (elementRef.current) {
|
||||||
const valueLength = elementRef.current.value.length
|
const valueLength = elementRef.current.value.length
|
||||||
@ -63,35 +83,42 @@ const Editable = (props: Props, ref: React.Ref<{focus: (selectAll?: boolean) =>
|
|||||||
if (props.validator) {
|
if (props.validator) {
|
||||||
error = !props.validator(value || '')
|
error = !props.validator(value || '')
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
className: 'Editable ' + (error ? 'error ' : '') + (readonly ? 'readonly ' : '') + className,
|
||||||
|
placeholder: placeholderText,
|
||||||
|
onChange: (e: React.ChangeEvent<ElementType>) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
},
|
||||||
|
value,
|
||||||
|
title: value,
|
||||||
|
onBlur: () => save('onBlur'),
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<ElementType>): 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<Focusable>): JSX.Element => {
|
||||||
|
const elementRef = useRef<HTMLInputElement>(null)
|
||||||
|
const elementProps = useEditable(props, ref, elementRef)
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
|
{...elementProps}
|
||||||
ref={elementRef}
|
ref={elementRef}
|
||||||
className={'Editable ' + (error ? 'error ' : '') + (readonly ? 'readonly ' : '') + className}
|
|
||||||
placeholder={placeholderText}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(e.target.value)
|
|
||||||
}}
|
|
||||||
value={value}
|
|
||||||
title={value}
|
|
||||||
onBlur={() => save('onBlur')}
|
|
||||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>): 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}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
17
webapp/src/widgets/editableArea.scss
Normal file
17
webapp/src/widgets/editableArea.scss
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
.EditableAreaWrap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EditableArea {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EditableAreaContainer {
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EditableAreaReference {
|
||||||
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
66
webapp/src/widgets/editableArea.tsx
Normal file
66
webapp/src/widgets/editableArea.tsx
Normal file
@ -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<Focusable>): JSX.Element => {
|
||||||
|
const elementRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const referenceRef = useRef<HTMLTextAreaElement>(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 (
|
||||||
|
<div className={'EditableAreaWrap'}>
|
||||||
|
<textarea
|
||||||
|
{...elementProps}
|
||||||
|
{...heightProps}
|
||||||
|
ref={elementRef}
|
||||||
|
className={'EditableArea ' + elementProps.className}
|
||||||
|
/>
|
||||||
|
<div className={'EditableAreaContainer'}>
|
||||||
|
<textarea
|
||||||
|
ref={referenceRef}
|
||||||
|
className={'EditableAreaReference ' + elementProps.className}
|
||||||
|
dir='auto'
|
||||||
|
disabled={true}
|
||||||
|
rows={1}
|
||||||
|
value={elementProps.value}
|
||||||
|
aria-hidden={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default forwardRef(EditableArea)
|
Loading…
Reference in New Issue
Block a user