1- import { Dispatch , Fragment , Ref , useEffect , useRef , useState } from "react"
1+ import { Dispatch , Ref , useEffect , useRef , useState } from "react"
22
33import { css } from "goober"
44import { Search , XCircle } from "lucide-react"
55
6+ import { useFocus } from "hooks/use-focus"
67import { 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
911import { Input , InputProps } from "../input"
1012import {
@@ -13,6 +15,7 @@ import {
1315 parseFilter ,
1416 TagConfig ,
1517} from "./parse-filter"
18+ import { Button } from "../button"
1619import { Icon } from "../icon"
1720import { IconButton } from "../icon-button"
1821
@@ -25,15 +28,27 @@ const transparentText = css`
2528
2629const 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+
2841interface FilterTextInputProps {
2942 ref : Ref < HTMLInputElement >
3043 value : string
3144 onChange : Dispatch < string >
45+ onCursorMove : Dispatch < number >
3246}
3347const 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