Merge pull request #200 from donetick/0809-fixes

Fix autocomplete issues and enhance task input handling
This commit is contained in:
Mohamad Tarbin
2026-08-10 00:54:52 -04:00
committed by GitHub
6 changed files with 267 additions and 145 deletions

View File

@@ -22,6 +22,8 @@ export const Z_INDEX = {
MODAL_BACKDROP: 2000,
MODAL_CONTENT: 2001,
MODAL_CLOSE_BUTTON: 2002,
// Popups that must float above open modals (portaled to document.body)
MODAL_POPOVER: 2100,
TOAST: 3000,
// Critical System UI (9000-9999)

View File

@@ -3,14 +3,16 @@ import { Add } from '@mui/icons-material'
import { Divider, Menu, MenuItem } from '@mui/joy'
import React, { useEffect } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const AutocompleteDropdown = ({
currentValue,
suggestions,
selectedIndex,
onSelectSuggestion,
onMouseEnterSuggestion, // Added for hover selection
onCreateSuggestion, // Called when the "Create new" row is chosen
onMouseEnterSuggestion, // Added for hover selection
onSelectSuggestion,
parentRefer, // Ref to the dropdown element
selectedIndex,
suggestions,
}) => {
// Scroll selected item into view
const dropdownMenuRef = React.useRef(null)
@@ -60,7 +62,7 @@ const AutocompleteDropdown = ({
position: 'relative',
bottom: 0,
left: 0,
zIndex: 1300,
zIndex: Z_INDEX.MODAL_POPOVER,
}}
>
{filteredOptions.map((option, index) => (

View File

@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'
import * as chrono from 'chrono-node'
import moment from 'moment'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import ModalActions from '../../components/common/ModalActions'
@@ -37,7 +38,6 @@ import {
} from './CustomParsers'
import DueDatePickerField from './DueDatePickerField'
import LabelsPickerField from './LabelsPickerField'
import LearnMoreButton from './LearnMore'
import NotificationPickerField from './NotificationPickerField'
import PriorityPickerField from './PriorityPickerField'
import RepeatPickerField from './RepeatPickerField'
@@ -108,6 +108,66 @@ const getDefaultNotification = () => {
return DEFAULT_NOTIFICATION_TEMPLATES
}
// Get initial project from localStorage (current active project)
const getInitialProject = () => {
const saved = localStorage.getItem('selectedProject')
if (saved) {
try {
const project = JSON.parse(saved)
return project?.id || 'default'
} catch {
return 'default'
}
}
return 'default'
}
const PRIORITY_COLORS = {
0: TASK_COLOR.NO_PRIORITY,
1: TASK_COLOR.PRIORITY_1,
2: TASK_COLOR.PRIORITY_2,
3: TASK_COLOR.PRIORITY_3,
4: TASK_COLOR.PRIORITY_4,
}
const PRIORITY_LABELS = {
0: '--',
1: 'P1',
2: 'P2',
3: 'P3',
4: 'P4',
}
// Static option sets for the smart input's trigger suggestions
const PRIORITY_SUGGESTIONS = {
value: 'id',
display: 'name',
options: [
{ id: '1', name: 'P1' },
{ id: '2', name: 'P2' },
{ id: '3', name: 'P3' },
{ id: '4', name: 'P4' },
],
}
const POINTS_SUGGESTIONS = {
value: 'id',
display: 'name',
options: [
{ id: '1', name: '1 point' },
{ id: '5', name: '5 points' },
{ id: '10', name: '10 points' },
{ id: '25', name: '25 points' },
{ id: '50', name: '50 points' },
{ id: '100', name: '100 points' },
],
}
// Delay between the last keystroke and the smart-input parse. Parsing (chrono
// especially) is too heavy to run per keystroke; submitChore flushes a pending
// parse so a fast type-then-Enter never creates from stale parsed state.
const PARSE_DEBOUNCE_MS = 150
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const { ResponsiveModal } = useResponsiveModal()
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
@@ -138,38 +198,85 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
[queryClient],
)
// Get initial project from localStorage (current active project)
const getInitialProject = () => {
const saved = localStorage.getItem('selectedProject')
if (saved) {
try {
const project = JSON.parse(saved)
return project?.id || 'default'
} catch {
return 'default'
}
}
return 'default'
}
const smartInputSuggestions = useMemo(
() => ({
'#': {
value: 'id',
display: 'name',
options: userLabels || [],
creatable: true,
onCreate: handleCreateLabel,
},
'!': PRIORITY_SUGGESTIONS,
'@': {
value: 'userId',
display: 'displayName',
options: [
{ userId: 'anyone', displayName: 'Anyone' },
...(circleMembers?.res || []),
],
},
'*': POINTS_SUGGESTIONS,
}),
[userLabels, circleMembers, handleCreateLabel],
)
const [taskText, setTaskText] = useState('')
const [taskTitle, setTaskTitle] = useState('')
const [renderedParts, setRenderedParts] = useState([])
// Highlight spans paired with the text they were computed from: the parse
// is debounced, so while typing these lag behind taskText
const [renderedParts, setRenderedParts] = useState({ text: '', parts: [] })
// What the smart input overlay shows. While a parse is pending, keep every
// highlight span that precedes the edit point and render the rest as plain
// text — existing token styles must not flicker away on each keystroke.
const displayedParts = useMemo(() => {
const { parts, text } = renderedParts
if (text === taskText) return parts
let prefixLen = 0
const max = Math.min(text.length, taskText.length)
while (prefixLen < max && text[prefixLen] === taskText[prefixLen]) {
prefixLen++
}
const kept = []
let consumed = 0
for (const part of parts) {
const partText = typeof part === 'string' ? part : part.props.children
if (consumed + partText.length > prefixLen) break
kept.push(part)
consumed += partText.length
}
kept.push(taskText.slice(consumed))
return kept
}, [renderedParts, taskText])
const richTextEditorRef = useRef(null)
const latestRef = useRef({})
// Picker edits made on a voice task card, applied once after the reparse
// that follows landing the spoken text in the smart input
const pendingVoiceOverridesRef = useRef(null)
// True while the current assignees came from an @mention in the text, so a
// reparse without mentions only resets what a mention set — never a
// selection made directly in the assignee picker
const assigneesFromMentionRef = useRef(false)
// Pending debounced parse of the smart input text, if any
const parseTimerRef = useRef(null)
// Identities (type + text) of the highlights from the previous parse, so
// the appear animation only plays for tokens detected just now
const prevHighlightKeysRef = useRef(new Set())
const [priority, setPriority] = useState(0)
const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null)
const [assignees, setAssignees] = useState([])
const [labelsV2, setLabelsV2] = useState([])
const [frequency, setFrequency] = useState(null)
const [notificationMetadata, setNotificationMetadata] = useState({
// Lazy initializers: these read localStorage, which must not happen on
// every render
const [notificationMetadata, setNotificationMetadata] = useState(() => ({
templates: getDefaultNotification(),
})
}))
const [subTasks, setSubTasks] = useState(null)
const [points, setPoints] = useState(-1)
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
@@ -185,7 +292,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const [dueTime, setDueTime] = useState(null)
const [useCustomTime, setUseCustomTime] = useState(false)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject())
const [projectId, setProjectId] = useState(getInitialProject)
const [attachments, setAttachments] = useState([])
const [draftId, setDraftId] = useState(() => generateUUID())
@@ -251,23 +358,6 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}
}, [isModalOpen, initialMode, voiceAvailable, llmAvailable])
// Priority colors
const priorityColors = {
0: TASK_COLOR.NO_PRIORITY,
1: TASK_COLOR.PRIORITY_1,
2: TASK_COLOR.PRIORITY_2,
3: TASK_COLOR.PRIORITY_3,
4: TASK_COLOR.PRIORITY_4,
}
const priorityLabels = {
0: '--',
1: 'P1',
2: 'P2',
3: 'P3',
4: 'P4',
}
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
useEffect(() => {
if (hasDescription && richTextEditorRef.current) {
@@ -281,11 +371,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
useEffect(() => {
const handleKeyDown = event => {
const {
createChore,
dueDate,
handleCloseModal,
hasDescription,
isModalOpen,
submitChore,
} = latestRef.current
const isHoldingCmd = event.ctrlKey || event.metaKey
if (isHoldingCmd) {
@@ -323,7 +413,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
isModalOpen
) {
event.preventDefault()
createChore()
submitChore()
return
}
if (event.key === 'Escape' && isModalOpen) {
@@ -411,6 +501,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}
}
const seenHighlightKeys = new Set()
for (const highlight of resolvedHighlights) {
if (highlight.start > lastIndex) {
const textBefore = sentence.substring(lastIndex, highlight.start)
@@ -446,10 +537,13 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
highlight.start,
highlight.end,
)
const highlightKey = `${highlight.type}:${highlightedText.toLowerCase()}`
const isNewHighlight = !prevHighlightKeysRef.current.has(highlightKey)
seenHighlightKeys.add(highlightKey)
parts.push(
<span
key={highlight.start}
className={className}
className={`${className}${isNewHighlight ? ' highlight-appear' : ''}`}
style={{
textDecoration: 'underline',
textDecorationThickness: '2px',
@@ -462,6 +556,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
lastIndex = highlight.end
}
prevHighlightKeysRef.current = seenHighlightKeys
if (lastIndex < sentence.length) {
const remainingText = sentence.substring(lastIndex)
@@ -477,14 +572,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
[],
)
const processText = useCallback(
sentence => {
const priority = parsePriority(sentence)
const pointsParsed = parsePoints(sentence)
const labels = parseLabels(sentence, userLabels || [])
const circleMembersList = circleMembers?.res || []
const assigneesForParsing = circleMembersList.map(member => ({
// Rebuilt only when the member list actually changes, so a query refetch
// with identical data doesn't re-trigger the parse effect below
const assigneesForParsing = useMemo(
() =>
(circleMembers?.res || []).map(member => ({
userId: member.userId,
username:
member.username ||
@@ -492,7 +584,15 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
displayName: member.displayName,
name: member.displayName,
id: member.userId,
}))
})),
[circleMembers],
)
const processText = useCallback(
sentence => {
const priority = parsePriority(sentence)
const pointsParsed = parsePoints(sentence)
const labels = parseLabels(sentence, userLabels || [])
const assigneesResult = parseAssignees(sentence, assigneesForParsing)
const repeat = parseRepeatV2(sentence)
@@ -510,14 +610,18 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
// @Anyone was used - set empty assignees (anyone can do the task)
setIsAnyoneTask(true)
setAssignees([])
assigneesFromMentionRef.current = true
} else if (assigneesResult.result && assigneesResult.result.length > 0) {
setIsAnyoneTask(false)
const parsedAssignees = assigneesResult.result.map(assignee => ({
userId: assignee.userId,
}))
setAssignees(parsedAssignees)
} else {
// Only assign to current user if no @ mentions found and userProfile exists
assigneesFromMentionRef.current = true
} else if (assigneesFromMentionRef.current) {
// The @mention that set the current assignees was deleted — fall back
// to the implicit self default. Picker selections stay untouched.
assigneesFromMentionRef.current = false
setIsAnyoneTask(false)
if (userProfile?.id) {
setAssignees([
@@ -555,39 +659,47 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
syncDueDateStates(repeat.dueDate)
}
// Create the cleaned sentence by sequentially applying all cleanups
// Create the cleaned sentence by sequentially applying all cleanups.
// Each stage only needs a reparse when an earlier cleanup actually
// changed the sentence; otherwise the first-pass result (computed on the
// identical string) is reused as-is.
let cleanedSentence = sentence
if (priority.result) cleanedSentence = priority.cleanedSentence
if (pointsParsed.result) {
// Apply points cleaning to the current cleaned sentence
const pointsReparse = parsePoints(cleanedSentence)
const pointsReparse =
cleanedSentence === sentence
? pointsParsed
: parsePoints(cleanedSentence)
if (pointsReparse.result)
cleanedSentence = pointsReparse.cleanedSentence
}
if (labels.result) {
// Apply labels cleaning to the current cleaned sentence
const labelsReparse = parseLabels(cleanedSentence, userLabels || [])
const labelsReparse =
cleanedSentence === sentence
? labels
: parseLabels(cleanedSentence, userLabels || [])
if (labelsReparse.result)
cleanedSentence = labelsReparse.cleanedSentence
}
if (assigneesResult.result) {
// Apply assignees cleaning to the current cleaned sentence
const assigneesReparse = parseAssignees(
cleanedSentence,
assigneesForParsing,
)
const assigneesReparse =
cleanedSentence === sentence
? assigneesResult
: parseAssignees(cleanedSentence, assigneesForParsing)
if (assigneesReparse.result)
cleanedSentence = assigneesReparse.cleanedSentence
}
if (repeat.result) {
// Apply repeat cleaning to the current cleaned sentence
const repeatReparse = parseRepeatV2(cleanedSentence)
const repeatReparse =
cleanedSentence === sentence ? repeat : parseRepeatV2(cleanedSentence)
if (repeatReparse.result)
cleanedSentence = repeatReparse.cleanedSentence
}
if (dueDateParsed.result) {
// Apply date cleaning to the current cleaned sentence
const dueDateReparse = parseDueDate(cleanedSentence, chrono)
const dueDateReparse =
cleanedSentence === sentence
? dueDateParsed
: parseDueDate(cleanedSentence, chrono)
if (dueDateReparse.result)
cleanedSentence = dueDateReparse.cleanedSentence
}
@@ -606,7 +718,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
assigneesResult.highlight,
)
setRenderedParts(parts)
setRenderedParts({ text: sentence, parts })
const overrides = pendingVoiceOverridesRef.current
if (overrides) {
@@ -622,6 +734,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
if ('assignees' in overrides || 'isAnyone' in overrides) {
setIsAnyoneTask(!!overrides.isAnyone)
setAssignees(overrides.assignees || [])
assigneesFromMentionRef.current = false
}
if ('dueDate' in overrides) {
if (overrides.dueDate) {
@@ -635,7 +748,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}
}
},
[userLabels, renderHighlightedSentence, circleMembers, userProfile],
[userLabels, renderHighlightedSentence, assigneesForParsing, userProfile],
)
useEffect(() => {
@@ -648,7 +761,16 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
return
}
processText(taskText)
// Debounced so fast typing doesn't run the full parse pipeline per
// keystroke; submitChore flushes a pending parse before creating.
parseTimerRef.current = setTimeout(() => {
parseTimerRef.current = null
processText(taskText)
}, PARSE_DEBOUNCE_MS)
return () => {
clearTimeout(parseTimerRef.current)
parseTimerRef.current = null
}
}, [
taskText,
userLabelsLoading,
@@ -713,7 +835,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}
const handleEnterPressed = () => {
createChore()
submitChore()
}
// The scan keeps its source image when asked: upload it against the draft so
@@ -849,6 +971,10 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
setHasSubTasks(false)
setLabelsV2([])
setAssignees([])
assigneesFromMentionRef.current = false
// The modal closes without a final parse, so drop the highlight identities
// here or nothing would animate on the next open
prevHighlightKeysRef.current = new Set()
setProjectId(getInitialProject())
setDeadlineOffset(-1)
setRequireApproval(false)
@@ -954,11 +1080,26 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
handleCloseModal(false)
}
// All submit paths (Enter, Cmd+Enter, footer button) go through here: a
// debounce may still be holding the parse of the latest text, and creating
// from pre-parse state would drop the tail of what the user typed.
const submitChore = () => {
if (parseTimerRef.current) {
clearTimeout(parseTimerRef.current)
parseTimerRef.current = null
flushSync(() => processText(taskText))
}
// Read through latestRef: after the flush, this render's createChore
// closure is stale
latestRef.current.createChore()
}
latestRef.current = {
isModalOpen,
hasDescription,
dueDate,
createChore,
submitChore,
handleCloseModal,
}
@@ -1028,7 +1169,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
color='primary'
loading={isAttachingScan}
disabled={!taskTitle.trim() || isAttachingScan}
onClick={createChore}
onClick={submitChore}
>
Create
{showKeyboardShortcuts && (
@@ -1041,8 +1182,8 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
>
{!showScan && !showVoice && (
<>
<Box>
<Box
<Box sx={{ mt: 1 }}>
{/* <Box
sx={{
display: 'flex',
flexDirection: 'row',
@@ -1093,7 +1234,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
</>
}
/>
</Box>
</Box> */}
<SmartTaskTitleInput
autoFocus
@@ -1124,7 +1265,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
setTaskText(text)
if (!text) setTaskTitle('')
}}
customRenderer={renderedParts}
customRenderer={displayedParts}
onEnterPressed={handleEnterPressed}
onShiftEnterPressed={() => {
if (!hasDescription) {
@@ -1132,45 +1273,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}
setTimeout(() => richTextEditorRef.current?.focus(), 50)
}}
suggestions={{
'#': {
value: 'id',
display: 'name',
options: userLabels ? userLabels : [],
creatable: true,
onCreate: handleCreateLabel,
},
'!': {
value: 'id',
display: 'name',
options: [
{ id: '1', name: 'P1' },
{ id: '2', name: 'P2' },
{ id: '3', name: 'P3' },
{ id: '4', name: 'P4' },
],
},
'@': {
value: 'userId',
display: 'displayName',
options: [
{ userId: 'anyone', displayName: 'Anyone' },
...(circleMembers?.res || []),
],
},
'*': {
value: 'id',
display: 'name',
options: [
{ id: '1', name: '1 point' },
{ id: '5', name: '5 points' },
{ id: '10', name: '10 points' },
{ id: '25', name: '25 points' },
{ id: '50', name: '50 points' },
{ id: '100', name: '100 points' },
],
},
}}
suggestions={smartInputSuggestions}
/>
</Box>
@@ -1212,8 +1315,8 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
onChange={setPriority}
onClear={() => setPriority(0)}
emptyDisplay={pickerEmptyDisplay}
priorityColors={priorityColors}
priorityLabels={priorityLabels}
priorityColors={PRIORITY_COLORS}
priorityLabels={PRIORITY_LABELS}
/>
<AssigneePickerField
emptyDisplay={pickerEmptyDisplay}
@@ -1292,9 +1395,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}}
>
<Add sx={{ fontSize: 20 }} />
<Typography level='body-sm' sx={{ color: 'inherit' }}>
Description
</Typography>
<Typography level='body-sm'>Description</Typography>
</Button>
)}
{!hasSubTasks && (
@@ -1317,9 +1418,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
}}
>
<Add sx={{ fontSize: 20 }} />
<Typography level='body-sm' sx={{ color: 'inherit' }}>
Subtasks
</Typography>
<Typography level='body-sm'>Subtasks</Typography>
</Button>
)}
<AdvancedOptionsTrigger
@@ -1351,7 +1450,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
onAssignStrategyChange={setAssignStrategy}
hasDueDate={!!dueDate}
hasMultipleAssignees={assignees.length > 1}
hasAssignees={assignees.length > 0}
// Empty assignees still implicitly assigns the current user at
// create time; only an "Anyone" task truly has no assignee
hasAssignees={!isAnyoneTask}
isPrivate={isPrivate}
onIsPrivateChange={setIsPrivate}
/>

View File

@@ -90,7 +90,6 @@ export const AdvancedOptionsTrigger = ({
<Typography
level='body-sm'
sx={{
color: 'inherit',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',

View File

@@ -57,6 +57,34 @@
color: var(--highlight-label-color);
}
/* Played once when a token is first detected: the dashed underline fades in
while a soft tint of the token's own color flashes and clears. Keyframe
values override the span's inline text-decoration while running. */
@keyframes smart-highlight-in {
from {
text-decoration-color: transparent;
background-color: color-mix(in srgb, currentColor 22%, transparent);
}
60% {
background-color: color-mix(in srgb, currentColor 12%, transparent);
}
to {
text-decoration-color: currentColor;
background-color: transparent;
}
}
.highlight-appear {
border-radius: 4px;
animation: smart-highlight-in 450ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.highlight-appear {
animation: none;
}
}
.task-input {
position: relative;
width: 100%;

View File

@@ -89,17 +89,14 @@ const SmartTaskTitleInput = ({
}
}, [])
const handleSuggestionChange = text => {
// if the last word start with '@' or '#' or 'P':
const lastWord = text.split(' ').pop()
if (
lastWord.startsWith('@') ||
lastWord.startsWith('#') ||
lastWord.startsWith('!')
) {
// show the menu when the last word starts with a configured trigger
// character (e.g. '@', '#', '!', '*')
const lastWord = text.split(/\s+/).pop()
if (lastWord && suggestions?.[lastWord[0]]) {
setSuggestionTrigger(lastWord[0])
// last word without the first character:
setLastWord(lastWord.slice(1))
setSelectedSuggestionIndex(0)
setShowSuggestions(true)
} else {
setShowSuggestions(false)
@@ -121,6 +118,7 @@ const SmartTaskTitleInput = ({
const newCursorPosition =
cursorPosition - lastWord.length + suggestionValue.length + 1
setCursorPosition(newCursorPosition)
titleInputRef.current.setSelectionRange(
newCursorPosition,
newCursorPosition,
@@ -376,18 +374,10 @@ const SmartTaskTitleInput = ({
const suggestionValue = suggestions[suggestionTrigger].display
? suggestion[suggestions[suggestionTrigger].display]
: suggestion
const newValue = `${value.slice(0, cursorPosition)}${suggestionValue}${value.slice(cursorPosition)}`
onChange(newValue)
// Same insertion path as keyboard selection: replace the partial
// word typed after the trigger instead of inserting alongside it
titleInputRef?.current?.focus()
setCursorPosition(cursorPosition + suggestion.length)
titleInputRef.current.value = newValue
titleInputRef.current.setSelectionRange(
cursorPosition + suggestionValue.length,
cursorPosition + suggestionValue.length,
)
setShowSuggestions(false)
selectSuggestionText(suggestionValue)
}}
onCreateSuggestion={name => {
selectSuggestionText(name)