@@ -2,6 +2,7 @@ import React, { useContext, useEffect, useRef, useState, useTransition } from "r
22import { useLocation , useNavigate } from "react-router-dom" ;
33import { styled } from "@linaria/react" ;
44import { SearchContext } from "./SearchContext" ;
5+ import { KindIcon , IconKind } from "../KindIcon" ;
56
67export function useCtrlFHook < T extends HTMLElement > ( ) {
78 const ref = useRef < T | null > ( null ) ;
@@ -47,6 +48,133 @@ export const SearchInput = styled.input`
4748 }
4849` ;
4950
51+ const SEARCH_TAGS = [
52+ { tag : "module:" , icon : "field" as IconKind , label : "Module" , description : "Filter by module name" , example : "e.g. module:client" } ,
53+ { tag : "offset:" , icon : "meta-default" as IconKind , label : "Offset" , description : "Filter by byte offset" , example : "e.g. offset:0x1A0" } ,
54+ { tag : "metadata:" , icon : "meta-tag" as IconKind , label : "Metadata" , description : "Filter by metadata key name" , example : "e.g. metadata:MNetworkEnable" } ,
55+ { tag : "metadatavalue:" , icon : "meta-variable" as IconKind , label : "Metadata Value" , description : "Filter by metadata value" , example : "e.g. metadatavalue:true" } ,
56+ ] as const ;
57+
58+ function getLastWord ( input : string ) : string {
59+ if ( input === "" || input . endsWith ( " " ) ) return "" ;
60+ return input . split ( " " ) . at ( - 1 ) ?? "" ;
61+ }
62+
63+ function shouldShowPopup ( input : string ) : boolean {
64+ return ! getLastWord ( input ) . includes ( ":" ) ;
65+ }
66+
67+ function filterTags ( lastWord : string ) {
68+ if ( lastWord === "" ) return SEARCH_TAGS ;
69+ return SEARCH_TAGS . filter ( t => t . tag . startsWith ( lastWord . toLowerCase ( ) ) ) ;
70+ }
71+
72+ function insertTag ( inputValue : string , tag : string ) : string {
73+ if ( inputValue === "" || inputValue . endsWith ( " " ) ) return inputValue + tag ;
74+ const parts = inputValue . split ( " " ) ;
75+ parts [ parts . length - 1 ] = tag ;
76+ return parts . join ( " " ) ;
77+ }
78+
79+ const SearchBoxWrapper = styled . div `
80+ position: relative;
81+ width: 100%;
82+ ` ;
83+
84+ const TagPopup = styled . div `
85+ position: absolute;
86+ top: calc(100% + 4px);
87+ left: 0;
88+ right: 0;
89+ background: var(--group);
90+ border: 1px solid var(--group-border);
91+ border-radius: 8px;
92+ box-shadow: var(--group-shadow);
93+ z-index: 200;
94+ overflow: hidden;
95+ ` ;
96+
97+ const TagPopupHeader = styled . div `
98+ padding: 6px 12px 4px;
99+ font-size: 11px;
100+ font-weight: 600;
101+ text-transform: uppercase;
102+ letter-spacing: 0.05em;
103+ color: var(--text-dim);
104+ ` ;
105+
106+ const TagItem = styled . button `
107+ display: flex;
108+ align-items: center;
109+ gap: 10px;
110+ width: 100%;
111+ padding: 7px 12px;
112+ border: none;
113+ background: transparent;
114+ color: var(--text);
115+ font-size: 14px;
116+ cursor: pointer;
117+ text-align: left;
118+ &[data-active], &:hover { background: var(--group-members); }
119+ ` ;
120+
121+ const TagItemText = styled . div `
122+ display: flex;
123+ flex-direction: column;
124+ gap: 1px;
125+ flex: 1;
126+ min-width: 0;
127+ ` ;
128+
129+ const TagItemName = styled . span `
130+ font-weight: 600;
131+ font-size: 13px;
132+ font-family: monospace;
133+ ` ;
134+
135+ const TagItemDesc = styled . span `
136+ font-size: 12px;
137+ color: var(--text-dim);
138+ ` ;
139+
140+ const TagItemExample = styled . span `
141+ font-size: 11px;
142+ color: var(--text-dim);
143+ opacity: 0.6;
144+ font-family: monospace;
145+ flex-shrink: 0;
146+ ` ;
147+
148+ function SearchTagPopup ( {
149+ tags, activeIndex, onSelect,
150+ } : {
151+ tags : typeof SEARCH_TAGS [ number ] [ ] ;
152+ activeIndex : number ;
153+ onSelect : ( tag : string ) => void ;
154+ } ) {
155+ return (
156+ < TagPopup role = "listbox" aria-label = "Search tag suggestions" >
157+ < TagPopupHeader > Filters</ TagPopupHeader >
158+ { tags . map ( ( t , i ) => (
159+ < TagItem
160+ key = { t . tag }
161+ role = "option"
162+ aria-selected = { i === activeIndex }
163+ data-active = { i === activeIndex || undefined }
164+ onMouseDown = { ( e ) => { e . preventDefault ( ) ; onSelect ( t . tag ) ; } }
165+ >
166+ < KindIcon kind = { t . icon } size = "small" />
167+ < TagItemText >
168+ < TagItemName > { t . tag } </ TagItemName >
169+ < TagItemDesc > { t . description } </ TagItemDesc >
170+ </ TagItemText >
171+ < TagItemExample > { t . example } </ TagItemExample >
172+ </ TagItem >
173+ ) ) }
174+ </ TagPopup >
175+ ) ;
176+ }
177+
50178export function SearchBox ( {
51179 baseUrl,
52180 className,
@@ -58,6 +186,8 @@ export function SearchBox({
58186} ) {
59187 const { search, setSearch } = useContext ( SearchContext ) ;
60188 const [ inputValue , setInputValue ] = useState ( search ) ;
189+ const [ isFocused , setIsFocused ] = useState ( false ) ;
190+ const [ activeIndex , setActiveIndex ] = useState ( 0 ) ;
61191 const navigate = useNavigate ( ) ;
62192 const location = useLocation ( ) ;
63193 const [ , startTransition ] = useTransition ( ) ;
@@ -77,9 +207,27 @@ export function SearchBox({
77207 // infinite loop: setSearch triggers a navigate which changes location.search.
78208 } , [ location . search ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
79209
210+ const lastWord = getLastWord ( inputValue ) ;
211+ const filteredTags = filterTags ( lastWord ) ;
212+ const showPopup = isFocused && shouldShowPopup ( inputValue ) && filteredTags . length > 0 ;
213+
214+ const handleTagSelect = ( tag : string ) => {
215+ const newValue = insertTag ( inputValue , tag ) ;
216+ setInputValue ( newValue ) ;
217+ ownNavigateRef . current = true ;
218+ const replace = inputValue !== "" || newValue === "" ;
219+ startTransition ( ( ) => {
220+ setSearch ( newValue ) ;
221+ navigate ( newValue === "" ? baseUrl : `${ baseUrl } ?search=${ encodeURIComponent ( newValue ) } ` , { replace } ) ;
222+ } ) ;
223+ setActiveIndex ( 0 ) ;
224+ ref . current ?. focus ( ) ;
225+ } ;
226+
80227 const onChange : React . ChangeEventHandler < HTMLInputElement > = ( { target : { value } } ) => {
81228 const wasSearching = inputValue !== "" ;
82229 setInputValue ( value ) ;
230+ setActiveIndex ( 0 ) ;
83231 ownNavigateRef . current = true ;
84232 // Push a history entry when starting a new search so back button works.
85233 // Replace while typing to avoid polluting history with every keystroke.
@@ -92,20 +240,37 @@ export function SearchBox({
92240 } ) ;
93241 } ;
94242
243+ const onKeyDown : React . KeyboardEventHandler < HTMLInputElement > = ( e ) => {
244+ if ( ! showPopup ) return ;
245+ if ( e . key === "ArrowDown" ) { e . preventDefault ( ) ; setActiveIndex ( i => Math . min ( i + 1 , filteredTags . length - 1 ) ) ; }
246+ else if ( e . key === "ArrowUp" ) { e . preventDefault ( ) ; setActiveIndex ( i => Math . max ( i - 1 , 0 ) ) ; }
247+ else if ( e . key === "Enter" || e . key === "Tab" ) { if ( filteredTags [ activeIndex ] ) { e . preventDefault ( ) ; handleTagSelect ( filteredTags [ activeIndex ] . tag ) ; } }
248+ else if ( e . key === "Escape" ) { setIsFocused ( false ) ; }
249+ } ;
250+
95251 const ref = useCtrlFHook < HTMLInputElement > ( ) ;
96252
97253 return (
98- < SearchInput
99- type = "search"
100- className = { className }
101- placeholder = { placeholder }
102- ref = { ref }
103- value = { inputValue }
104- onChange = { onChange }
105- aria-label = "Search"
106- spellCheck = { false }
107- autoCorrect = "off"
108- autoCapitalize = "off"
109- />
254+ < SearchBoxWrapper className = { className } >
255+ < SearchInput
256+ type = "search"
257+ placeholder = { placeholder }
258+ ref = { ref }
259+ value = { inputValue }
260+ onChange = { onChange }
261+ onFocus = { ( ) => setIsFocused ( true ) }
262+ onBlur = { ( ) => setIsFocused ( false ) }
263+ onKeyDown = { onKeyDown }
264+ aria-label = "Search"
265+ aria-autocomplete = "list"
266+ aria-expanded = { showPopup }
267+ spellCheck = { false }
268+ autoCorrect = "off"
269+ autoCapitalize = "off"
270+ />
271+ { showPopup && (
272+ < SearchTagPopup tags = { filteredTags } activeIndex = { activeIndex } onSelect = { handleTagSelect } />
273+ ) }
274+ </ SearchBoxWrapper >
110275 ) ;
111276}
0 commit comments