Skip to content

Commit

Permalink
refactor(comments): refactor how input and focus is handled
Browse files Browse the repository at this point in the history
* Enter will submit the comment
* Input field is reset after submission or discard
* Setting focus is handled through the same function everywhere.
  • Loading branch information
skogsmaskin committed Oct 25, 2023
1 parent d6e5b99 commit 9debc98
Show file tree
Hide file tree
Showing 6 changed files with 66 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,6 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm

onReply?.(nextComment)
setValue(EMPTY_ARRAY)

replyInputRef.current?.focus()
}, [
onReply,
parentComment._id,
Expand All @@ -149,7 +147,9 @@ export const CommentsListItem = React.memo(function CommentsListItem(props: Comm

const confirmDiscard = useCallback(() => {
replyInputRef.current?.discardDialogController.close()
replyInputRef.current?.reset()
setValue(EMPTY_ARRAY)
replyInputRef.current?.focus()
}, [])

const handleThreadRootClick = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,31 +34,29 @@ export function CreateNewThreadInput(props: CreateNewThreadInputProps) {
const handleSubmit = useCallback(() => {
onNewThreadCreate?.(value)
setValue(EMPTY_ARRAY)
commentInputHandle.current?.focus()
}, [onNewThreadCreate, value])

const hasValue = useMemo(() => hasCommentMessageValue(value), [value])

const startDiscard = useCallback(() => {
if (!hasValue) {
onEditDiscard?.()
commentInputHandle.current?.focus()
return
}

commentInputHandle.current?.discardDialogController.open()
}, [hasValue, onEditDiscard])

const confirmDiscard = useCallback(() => {
commentInputHandle.current?.discardDialogController.close()
setValue(EMPTY_ARRAY)
onEditDiscard?.()
commentInputHandle.current?.reset()
commentInputHandle.current?.discardDialogController.close()
commentInputHandle.current?.focus()
onEditDiscard?.()
}, [onEditDiscard])

const cancelDiscard = useCallback(() => {
commentInputHandle.current?.discardDialogController.close()
commentInputHandle.current?.focus()
}, [])

const placeholder = (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react'
import {EditorChange, PortableTextEditor} from '@sanity/portable-text-editor'
import {EditorChange, PortableTextEditor, keyGenerator} from '@sanity/portable-text-editor'
import {CurrentUser, PortableTextBlock} from '@sanity/types'
import FocusLock from 'react-focus-lock'
import {Stack} from '@sanity/ui'
Expand Down Expand Up @@ -47,6 +47,7 @@ export interface CommentInputHandle {
discardDialogController: CommentDiscardDialogController
focus: () => void
scrollTo: () => void
reset: () => void
}

/**
Expand Down Expand Up @@ -79,15 +80,26 @@ export const CommentInput = forwardRef<CommentInputHandle, CommentInputProps>(
const editorContainerRef = useRef<HTMLDivElement | null>(null)
const [showDiscardDialog, setShowDiscardDialog] = useState<boolean>(false)

// A unique (React) key for the editor instance.
const [editorInstanceKey, setEditorInstanceKey] = useState(keyGenerator())

const requestFocus = useCallback(() => {
requestAnimationFrame(() => {
if (!editorRef.current) return
PortableTextEditor.focus(editorRef.current)
})
}, [])

const resetEditorInstance = useCallback(() => {
setEditorInstanceKey(keyGenerator())
}, [])

const handleChange = useCallback(
(change: EditorChange) => {
// Focus the editor when ready if focusOnMount is true
if (change.type === 'ready') {
if (focusOnMount && editorRef.current) {
requestAnimationFrame(() => {
if (!editorRef.current) return
PortableTextEditor.focus(editorRef.current)
})
if (focusOnMount) {
requestFocus()
}
}
if (change.type === 'focus') {
Expand All @@ -101,60 +113,57 @@ export const CommentInput = forwardRef<CommentInputHandle, CommentInputProps>(
// Update the comment value whenever the comment is edited by the user.
if (change.type === 'patch' && editorRef.current) {
const editorStateValue = PortableTextEditor.getValue(editorRef.current)
if (editorStateValue) {
onChange(editorStateValue)
}
onChange(editorStateValue || EMPTY_ARRAY)
}
},
[focusOnMount, onChange],
[focusOnMount, onChange, requestFocus],
)

const scrollToEditor = useCallback(() => {
editorContainerRef.current?.scrollIntoView(SCROLL_INTO_VIEW_OPTIONS)
}, [])

const handleSubmit = useCallback(() => {
onSubmit()
resetEditorInstance()
requestFocus()
scrollToEditor()
}, [onSubmit, requestFocus, resetEditorInstance, scrollToEditor])

// The way a user a comment can be discarded varies from the context it is used in.
// This controller is used to take care of the main logic of the discard process, while
// specific behavior is handled by the consumer.
const discardDialogController = useMemo(() => {
return {
open: () => {
if (editorRef?.current) {
PortableTextEditor.blur(editorRef.current)
}

setShowDiscardDialog(true)
},
close: () => {
setShowDiscardDialog(false)

if (editorRef?.current) {
PortableTextEditor.focus(editorRef.current)
}
requestFocus()
},
} satisfies CommentDiscardDialogController
}, [])
}, [requestFocus])

useImperativeHandle(
ref,
() => {
return {
focus() {
if (editorRef?.current) {
PortableTextEditor.focus(editorRef.current)
}
},
focus: requestFocus,
blur() {
if (editorRef?.current) {
if (editorRef.current) {
PortableTextEditor.blur(editorRef.current)
}
},
scrollTo: scrollToEditor,
reset: () => {
setEditorInstanceKey(keyGenerator())
},

discardDialogController,
}
},
[discardDialogController, scrollToEditor],
[discardDialogController, requestFocus, scrollToEditor],
)

return (
Expand All @@ -165,6 +174,7 @@ export const CommentInput = forwardRef<CommentInputHandle, CommentInputProps>(

<Stack ref={editorContainerRef} data-testid="comment-input">
<PortableTextEditor
key={editorInstanceKey}
onChange={handleChange}
readOnly={readOnly}
ref={editorRef}
Expand All @@ -191,7 +201,7 @@ export const CommentInput = forwardRef<CommentInputHandle, CommentInputProps>(
onBlur={onBlur}
onEscapeKeyDown={onEscapeKeyDown}
onFocus={onFocus}
onSubmit={onSubmit}
onSubmit={handleSubmit}
placeholder={placeholder}
withAvatar={withAvatar}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export function CommentInputInner(props: CommentInputInnerProps) {
focusLock={focusLock}
onBlur={onBlur}
onFocus={onFocus}
onSubmit={onSubmit}
placeholder={placeholder}
/>
</EditableWrap>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,8 @@ export function CommentInputProvider(props: CommentInputProviderProps) {
}
}
}

closeMentions()
},
[closeMentions, editor, selectionAtMentionInsert],
[editor, selectionAtMentionInsert],
)

const ctxValue = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface EditableProps {
focusLock?: boolean
onBlur?: (e: React.FormEvent<HTMLDivElement>) => void
onFocus?: (e: React.FormEvent<HTMLDivElement>) => void
onSubmit?: () => void
placeholder?: React.ReactNode
}

Expand All @@ -61,14 +62,15 @@ export interface EditableHandle {
}

export function Editable(props: EditableProps) {
const {focusLock, placeholder = 'Create a new comment', onFocus, onBlur} = props
const {focusLock, placeholder = 'Create a new comment', onFocus, onBlur, onSubmit} = props
const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
const rootElementRef = useRef<HTMLDivElement | null>(null)
const editableRef = useRef<HTMLDivElement | null>(null)
const mentionsMenuRef = useRef<MentionsMenuHandle | null>(null)
const selection = usePortableTextEditorSelection()

const {
canSubmit,
closeMentions,
focusEditor,
insertMention,
Expand Down Expand Up @@ -118,10 +120,25 @@ export function Editable(props: EditableProps) {
(event: KeyboardEvent) => {
switch (event.code) {
case 'Enter':
// Shift enter is used to insert a new line,
// keep the default behavior
if (event.shiftKey) {
break
}
// Enter is being used both to select something from the mentionsMenu
// or to submit the comment. Prevent the default behavior.
event.preventDefault()
event.stopPropagation()

// If the mention menu is open close it, but don't submit.
if (mentionsMenuOpen) {
// Stop the event from creating a new block in the editor here
event.preventDefault()
event.stopPropagation()
closeMentions()
break
}

// Submit the comment if eligible for submission
if (onSubmit && canSubmit) {
onSubmit()
}
break
case 'Escape':
Expand All @@ -135,7 +152,7 @@ export function Editable(props: EditableProps) {
default:
}
},
[closeMentions, focusEditor, mentionsMenuOpen],
[canSubmit, closeMentions, focusEditor, mentionsMenuOpen, onSubmit],
)

const initialSelectionAtEndOfContent: EditorSelection | undefined = useMemo(() => {
Expand Down

0 comments on commit 9debc98

Please sign in to comment.