user and territory autocomplete in search bar (#2217)

* autocomplete in the search bar

* update some naming conventions

* create dual autocomplete

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
Edward Kung 2025-06-13 19:42:28 -07:00 committed by GitHub
parent aebba27c57
commit 524b1b97f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 193 additions and 94 deletions

View File

@ -228,6 +228,86 @@ function useEntityAutocomplete ({
}
}
export function useDualAutocomplete ({ meta, helpers, innerRef, setSelectionRange }) {
const userAutocomplete = useEntityAutocomplete({
prefix: '@',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: UserSuggest
})
const territoryAutocomplete = useEntityAutocomplete({
prefix: '~',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: TerritorySuggest
})
const handleTextChange = useCallback((e) => {
// Try to match user mentions first, then territories
if (!userAutocomplete.handleTextChange(e)) {
territoryAutocomplete.handleTextChange(e)
}
}, [userAutocomplete, territoryAutocomplete])
const handleKeyDown = useCallback((e, userOnKeyDown, territoryOnKeyDown) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (!metaOrCtrl) {
if (userAutocomplete.entityData) {
return userOnKeyDown(e)
} else if (territoryAutocomplete.entityData) {
return territoryOnKeyDown(e)
}
}
return false // Didn't handle the event
}, [userAutocomplete.entityData, territoryAutocomplete.entityData])
const handleBlur = useCallback((resetUserSuggestions, resetTerritorySuggestions) => {
setTimeout(resetUserSuggestions, 500)
setTimeout(resetTerritorySuggestions, 500)
}, [])
return {
userAutocomplete,
territoryAutocomplete,
handleTextChange,
handleKeyDown,
handleBlur
}
}
export function DualAutocompleteWrapper ({
userAutocomplete,
territoryAutocomplete,
children
}) {
return (
<UserSuggest
query={userAutocomplete.entityData?.query}
onSelect={userAutocomplete.handleSelect}
dropdownStyle={userAutocomplete.entityData?.style}
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions: resetUserSuggestions }) => (
<TerritorySuggest
query={territoryAutocomplete.entityData?.query}
onSelect={territoryAutocomplete.handleSelect}
dropdownStyle={territoryAutocomplete.entityData?.style}
>{({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) =>
children({
userSuggestOnKeyDown,
territorySuggestOnKeyDown,
resetUserSuggestions,
resetTerritorySuggestions
})}
</TerritorySuggest>
)}
</UserSuggest>
)
}
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKeyDown, innerRef, ...props }) {
const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props)
@ -287,22 +367,11 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
}
}, [innerRef, selectionRange.start, selectionRange.end])
const userAutocomplete = useEntityAutocomplete({
prefix: '@',
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: UserSuggest
})
const territoryAutocomplete = useEntityAutocomplete({
prefix: '~',
meta,
helpers,
innerRef,
setSelectionRange,
SuggestComponent: TerritorySuggest
setSelectionRange
})
const uploadFeesUpdate = useDebounceCallback(
@ -313,56 +382,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const onChangeInner = useCallback((formik, e) => {
if (onChange) onChange(formik, e)
// check for mentions and territory suggestions
uploadFeesUpdate(e.target.value)
// Try to match user mentions first, then territories
if (!userAutocomplete.handleTextChange(e)) {
territoryAutocomplete.handleTextChange(e)
}
}, [onChange, uploadFeesUpdate, userAutocomplete, territoryAutocomplete])
const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
return (e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'u') {
// some browsers might use CTRL+U to do something else so prevent that behavior too
e.preventDefault()
imageUploadRef.current?.click()
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
}
if (!metaOrCtrl) {
if (userAutocomplete.entityData) {
userSuggestOnKeyDown(e)
} else if (territoryAutocomplete.entityData) {
territorySuggestOnKeyDown(e)
}
}
if (onKeyDown) onKeyDown(e)
}
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, userAutocomplete.entityData, territoryAutocomplete.entityData])
handleTextChange(e)
}, [onChange, uploadFeesUpdate, handleTextChange])
const onPaste = useCallback((event) => {
const items = event.clipboardData.items
@ -406,6 +428,44 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
setDragStyle(null)
}, [setDragStyle])
const onKeyDownInner = useCallback((userSuggestOnKeyDown, territorySuggestOnKeyDown) => {
return (e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
// Handle markdown shortcuts first
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'u') {
// some browsers might use CTRL+U to do something else so prevent that behavior too
e.preventDefault()
imageUploadRef.current?.click()
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
} else {
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
}
if (onKeyDown) onKeyDown(e)
}
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown, handleKeyDown, imageUploadRef])
return (
<FormGroup label={label} className={groupClassName}>
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
@ -472,34 +532,25 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
</span>
</Nav>
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
<UserSuggest
query={userAutocomplete.entityData?.query}
onSelect={userAutocomplete.handleSelect}
dropdownStyle={userAutocomplete.entityData?.style}
>{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions: resetUserSuggestions }) => (
<TerritorySuggest
query={territoryAutocomplete.entityData?.query}
onSelect={territoryAutocomplete.handleSelect}
dropdownStyle={territoryAutocomplete.entityData?.style}
>{({ onKeyDown: territorySuggestOnKeyDown, resetSuggestions: resetTerritorySuggestions }) => (
<DualAutocompleteWrapper
userAutocomplete={userAutocomplete}
territoryAutocomplete={territoryAutocomplete}
>
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
<InputInner
innerRef={innerRef}
{...props}
onChange={onChangeInner}
onKeyDown={onKeyDownInner(userSuggestOnKeyDown, territorySuggestOnKeyDown)}
onBlur={() => {
setTimeout(resetUserSuggestions, 500)
setTimeout(resetTerritorySuggestions, 500)
}}
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
onPaste={onPaste}
className={dragStyle === 'over' ? styles.dragOver : ''}
/>)}
</TerritorySuggest>
)}
</UserSuggest>
/>
)}
</DualAutocompleteWrapper>
</div>
{tab !== 'write' &&
<div className='form-group'>

View File

@ -1,22 +1,26 @@
import Container from 'react-bootstrap/Container'
import styles from './search.module.css'
import SearchIcon from '@/svgs/search-line.svg'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Form, Input, Select, DatePicker, SubmitButton } from './form'
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import {
Form,
Input,
Select,
DatePicker,
SubmitButton,
useDualAutocomplete,
DualAutocompleteWrapper
} from './form'
import { useRouter } from 'next/router'
import { whenToFrom } from '@/lib/time'
import { useMe } from './me'
import { useField } from 'formik'
export default function Search ({ sub }) {
const router = useRouter()
const [q, setQ] = useState(router.query.q || '')
const inputRef = useRef(null)
const { me } = useMe()
useEffect(() => {
inputRef.current?.focus()
}, [])
const search = async values => {
let prefix = ''
if (sub) {
@ -63,18 +67,13 @@ export default function Search ({ sub }) {
onSubmit={values => search({ ...values })}
>
<div className={`${styles.active} mb-3`}>
<Input
<SearchInput
name='q'
required
autoFocus
groupClassName='me-3 mb-0 flex-grow-1'
className='flex-grow-1'
clear
innerRef={inputRef}
overrideValue={q}
onChange={async (formik, e) => {
setQ(e.target.value?.trim())
}}
setOuterQ={setQ}
/>
<SubmitButton variant='primary' className={styles.search}>
<SearchIcon width={22} height={22} />
@ -135,3 +134,52 @@ export default function Search ({ sub }) {
</>
)
}
function SearchInput ({ name, setOuterQ, ...props }) {
const [, meta, helpers] = useField(name)
const inputRef = useRef(null)
useEffect(() => {
if (meta.value !== undefined) setOuterQ(meta.value.trim())
}, [meta.value, setOuterQ])
const setCaret = useCallback(({ start, end }) => {
inputRef.current?.setSelectionRange(start, end)
}, [])
const { userAutocomplete, territoryAutocomplete, handleTextChange, handleKeyDown, handleBlur } = useDualAutocomplete({
meta,
helpers,
innerRef: inputRef,
setSelectionRange: setCaret
})
const handleChangeWithOuter = useCallback((formik, e) => {
setOuterQ(e.target.value.trim())
handleTextChange(e)
}, [setOuterQ, handleTextChange])
return (
<div className='position-relative flex-grow-1'>
<DualAutocompleteWrapper
userAutocomplete={userAutocomplete}
territoryAutocomplete={territoryAutocomplete}
>
{({ userSuggestOnKeyDown, territorySuggestOnKeyDown, resetUserSuggestions, resetTerritorySuggestions }) => (
<Input
name={name}
innerRef={inputRef}
clear
autoComplete='off'
onChange={handleChangeWithOuter}
onKeyDown={(e) => {
handleKeyDown(e, userSuggestOnKeyDown, territorySuggestOnKeyDown)
}}
onBlur={() => handleBlur(resetUserSuggestions, resetTerritorySuggestions)}
{...props}
/>
)}
</DualAutocompleteWrapper>
</div>
)
}