Skip to content

Commit 66c4c7f

Browse files
committed
feat(FilterInput): Add suggestions dropdown
1 parent 92eb3f3 commit 66c4c7f

1 file changed

Lines changed: 131 additions & 21 deletions

File tree

src/components/ui/filter-input/filter-input.tsx

Lines changed: 131 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { Dispatch, Fragment, Ref, useEffect, useRef, useState } from "react"
1+
import { Dispatch, Ref, useEffect, useRef, useState } from "react"
22

33
import { css } from "goober"
44
import { Search, XCircle } from "lucide-react"
55

6+
import { useFocus } from "hooks/use-focus"
67
import { cn } from "utils/cn"
7-
import { hstack } from "utils/styles"
8+
import { hstack, surface, vstack } from "utils/styles"
9+
import { zIndex } from "utils/z-index"
810

911
import { Input, InputProps } from "../input"
1012
import {
@@ -13,6 +15,7 @@ import {
1315
parseFilter,
1416
TagConfig,
1517
} from "./parse-filter"
18+
import { Button } from "../button"
1619
import { Icon } from "../icon"
1720
import { IconButton } from "../icon-button"
1821

@@ -25,15 +28,27 @@ const transparentText = css`
2528

2629
const textStyles = cn("text-sm whitespace-pre text-text **:whitespace-pre")
2730

31+
const measureText = (text: string) => {
32+
const span = document.createElement("span")
33+
span.innerText = text
34+
span.className = textStyles
35+
document.body.appendChild(span)
36+
const width = span.offsetWidth
37+
span.remove()
38+
return width
39+
}
40+
2841
interface FilterTextInputProps {
2942
ref: Ref<HTMLInputElement>
3043
value: string
3144
onChange: Dispatch<string>
45+
onCursorMove: Dispatch<number>
3246
}
3347
const FilterTextInput = ({
3448
ref,
3549
value,
3650
onChange,
51+
onCursorMove,
3752
...props
3853
}: FilterTextInputProps) => (
3954
<Input
@@ -43,6 +58,15 @@ const FilterTextInput = ({
4358
value={value}
4459
onChange={onChange}
4560
className={cn(transparentText, textStyles, "relative w-full px-10")}
61+
onKeyDown={({ currentTarget }) =>
62+
onCursorMove(currentTarget.selectionStart ?? 0)
63+
}
64+
onKeyUp={({ currentTarget }) =>
65+
onCursorMove(currentTarget.selectionStart ?? 0)
66+
}
67+
onMouseUp={({ currentTarget }) =>
68+
onCursorMove(currentTarget.selectionStart ?? 0)
69+
}
4670
/>
4771
)
4872

@@ -60,23 +84,58 @@ const FilterTextDisplay = ({ ref, segments }: FilterTextDisplayProps) => (
6084
"absolute inset-0 right-10 left-10 -z-1 ml-px h-full overflow-hidden"
6185
)}
6286
>
63-
{segments.map(({ tag, value, text, isTagValid, isValueValid }, index) => (
64-
<Fragment key={index.toString() + tag + value}>
65-
{!tag ? (
66-
<span>{text}</span>
67-
) : (
68-
<span
69-
className={cn(
70-
"inline-block rounded-[1px] outline-1 outline-offset-1 outline-solid",
71-
isTagValid && isValueValid
72-
? "bg-highlight/10 text-highlight outline-highlight/20"
73-
: "bg-background outline-stroke-gentle decoration-wavy underline decoration-alert-error"
74-
)}
75-
>
76-
{text}
77-
</span>
78-
)}
79-
</Fragment>
87+
{segments.map(({ tag, value, text, isTagValid, isValueValid }, index) =>
88+
!tag ? (
89+
// eslint-disable-next-line react/no-array-index-key
90+
<span key={`${value}-${index}`}>{text}</span>
91+
) : (
92+
<span
93+
// eslint-disable-next-line react/no-array-index-key
94+
key={`${value}-${tag}-${index}`}
95+
className={cn(
96+
"inline-block rounded-[1px] outline-1 outline-offset-1 outline-solid",
97+
isTagValid && isValueValid
98+
? "bg-highlight/10 text-highlight outline-highlight/20"
99+
: "bg-background outline-stroke-gentle decoration-wavy underline decoration-alert-error"
100+
)}
101+
>
102+
{text}
103+
</span>
104+
)
105+
)}
106+
</div>
107+
)
108+
109+
interface FilterSuggestions {
110+
offsetLeft: number
111+
suggestions: string[]
112+
onSelect: Dispatch<string>
113+
}
114+
const FilterSuggestions = ({
115+
offsetLeft,
116+
suggestions,
117+
onSelect,
118+
}: FilterSuggestions) => (
119+
<div
120+
className={cn(
121+
surface({ size: "md", look: "overlay" }),
122+
vstack(),
123+
zIndex.popover,
124+
"absolute top-11 left-8 p-0 transition-transform duration-200 ease-out"
125+
)}
126+
style={{
127+
translate: offsetLeft,
128+
}}
129+
>
130+
{suggestions.map(item => (
131+
<Button
132+
key={item}
133+
size="sm"
134+
className="justify-start"
135+
onClick={() => onSelect(item)}
136+
>
137+
{item}
138+
</Button>
80139
))}
81140
</div>
82141
)
@@ -97,9 +156,14 @@ export const FilterInput = <TTagName extends string>({
97156
className,
98157
...props
99158
}: FilterInputProps<TTagName>) => {
159+
const wrapperRef = useRef<HTMLDivElement>(null)
100160
const inputRef = useRef<HTMLInputElement>(null)
101161
const textRef = useRef<HTMLDivElement>(null)
102162

163+
const hasFocus = useFocus([wrapperRef])
164+
165+
const [cursorPos, setCursorPos] = useState(0)
166+
103167
const [text, setText] = useState(initialValue)
104168
const [filter, setFilter] = useState(() =>
105169
parseFilter(initialValue, tagConfigs)
@@ -129,16 +193,62 @@ export const FilterInput = <TTagName extends string>({
129193
return () => input.removeEventListener("scroll", syncScroll)
130194
}, [])
131195

196+
const suggestions = Object.keys(tagConfigs)
197+
.map(tag => `${tag}:`)
198+
.filter(item => {
199+
if (text.includes(item)) return false
200+
const currentWord = text.slice(0, cursorPos).split(/\s+/).at(-1) ?? ""
201+
return item.startsWith(currentWord)
202+
})
203+
204+
const insertSuggestion = (suggestion: string) => {
205+
const beforeCursor = text.slice(0, cursorPos).replace(/[^\s]*$/, suggestion)
206+
207+
let afterCursor = text.slice(cursorPos)
208+
if (afterCursor && !afterCursor.startsWith(" ")) {
209+
afterCursor = " " + afterCursor
210+
}
211+
212+
updateText(beforeCursor + afterCursor)
213+
214+
window.queueMicrotask(() => {
215+
const newCursorPos = beforeCursor.length
216+
inputRef.current?.focus()
217+
inputRef.current?.setSelectionRange(newCursorPos, newCursorPos)
218+
setCursorPos(newCursorPos)
219+
})
220+
}
221+
132222
return (
133-
<div {...props} className={cn("relative inline-block", className)}>
223+
<div
224+
{...props}
225+
ref={wrapperRef}
226+
className={cn("relative inline-block", className)}
227+
>
134228
<span className="pointer-events-none absolute top-1 bottom-1 left-1 grid size-8 place-items-center">
135229
<Icon icon={Search} size="sm" color="muted" />
136230
</span>
137231

138-
<FilterTextInput ref={inputRef} value={text} onChange={updateText} />
232+
<FilterTextInput
233+
ref={inputRef}
234+
value={text}
235+
onChange={updateText}
236+
onCursorMove={setCursorPos}
237+
/>
139238

140239
<FilterTextDisplay ref={textRef} segments={filter.segments} />
141240

241+
{hasFocus && suggestions.length > 0 && (
242+
<FilterSuggestions
243+
suggestions={suggestions}
244+
onSelect={insertSuggestion}
245+
offsetLeft={(() => {
246+
const scroll = textRef.current?.scrollLeft ?? 0
247+
return measureText(text.slice(0, cursorPos)) - scroll
248+
})()}
249+
/>
250+
)}
251+
142252
{text && (
143253
<IconButton
144254
icon={XCircle}

0 commit comments

Comments
 (0)