1- import React , { useContext , useEffect , useRef , useState , useTransition } from "react" ;
1+ import React , { useContext , useEffect , useMemo , useRef , useState , useTransition } from "react" ;
22import { useLocation , useNavigate } from "react-router-dom" ;
33import { styled } from "@linaria/react" ;
44import { SearchContext } from "./SearchContext" ;
5+ import { DeclarationsContext } from "../Docs/DeclarationsContext" ;
56import { KindIcon , IconKind } from "../KindIcon" ;
67
78export function useCtrlFHook < T extends HTMLElement > ( ) {
@@ -60,7 +61,7 @@ function getLastWord(input: string): string {
6061 return input . split ( " " ) . at ( - 1 ) ?? "" ;
6162}
6263
63- function shouldShowPopup ( input : string ) : boolean {
64+ function shouldShowFirstLevelPopup ( input : string ) : boolean {
6465 return ! getLastWord ( input ) . includes ( ":" ) ;
6566}
6667
@@ -76,6 +77,20 @@ function insertTag(inputValue: string, tag: string): string {
7677 return parts . join ( " " ) ;
7778}
7879
80+ function getSecondLevelContext ( lastWord : string ) : { type : "module" | "metadata" ; value : string } | null {
81+ const lower = lastWord . toLowerCase ( ) ;
82+ if ( lower . startsWith ( "module:" ) ) return { type : "module" , value : lastWord . slice ( 7 ) } ;
83+ if ( lower . startsWith ( "metadata:" ) ) return { type : "metadata" , value : lastWord . slice ( 9 ) } ;
84+ return null ;
85+ }
86+
87+ function insertValue ( inputValue : string , tagPrefix : string , value : string ) : string {
88+ if ( inputValue === "" || inputValue . endsWith ( " " ) ) return inputValue + tagPrefix + value ;
89+ const parts = inputValue . split ( " " ) ;
90+ parts [ parts . length - 1 ] = tagPrefix + value ;
91+ return parts . join ( " " ) ;
92+ }
93+
7994const SearchBoxWrapper = styled . div `
8095 position: relative;
8196 width: 100%;
@@ -145,6 +160,39 @@ const TagItemExample = styled.span`
145160 flex-shrink: 0;
146161` ;
147162
163+ const ValuePopupList = styled . div `
164+ max-height: 200px;
165+ overflow-y: auto;
166+ ` ;
167+
168+ function ValueSuggestPopup ( {
169+ header, values, activeIndex, onSelect,
170+ } : {
171+ header : string ;
172+ values : string [ ] ;
173+ activeIndex : number ;
174+ onSelect : ( value : string ) => void ;
175+ } ) {
176+ return (
177+ < TagPopup role = "listbox" aria-label = { header } >
178+ < TagPopupHeader > { header } </ TagPopupHeader >
179+ < ValuePopupList >
180+ { values . map ( ( v , i ) => (
181+ < TagItem
182+ key = { v }
183+ role = "option"
184+ aria-selected = { i === activeIndex }
185+ data-active = { i === activeIndex || undefined }
186+ onMouseDown = { ( e ) => { e . preventDefault ( ) ; onSelect ( v ) ; } }
187+ >
188+ < TagItemName > { v } </ TagItemName >
189+ </ TagItem >
190+ ) ) }
191+ </ ValuePopupList >
192+ </ TagPopup >
193+ ) ;
194+ }
195+
148196function SearchTagPopup ( {
149197 tags, activeIndex, onSelect,
150198} : {
@@ -185,6 +233,7 @@ export function SearchBox({
185233 placeholder ?: string ;
186234} ) {
187235 const { search, setSearch } = useContext ( SearchContext ) ;
236+ const { declarations } = useContext ( DeclarationsContext ) ;
188237 const [ inputValue , setInputValue ] = useState ( search ) ;
189238 const [ isFocused , setIsFocused ] = useState ( false ) ;
190239 const [ activeIndex , setActiveIndex ] = useState ( 0 ) ;
@@ -207,12 +256,44 @@ export function SearchBox({
207256 // infinite loop: setSearch triggers a navigate which changes location.search.
208257 } , [ location . search ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
209258
259+ const uniqueModules = useMemo ( ( ) => {
260+ const set = new Set < string > ( ) ;
261+ for ( const d of declarations ) set . add ( d . module ) ;
262+ return [ ...set ] . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
263+ } , [ declarations ] ) ;
264+
265+ const uniqueMetadataKeys = useMemo ( ( ) => {
266+ const set = new Set < string > ( ) ;
267+ for ( const d of declarations ) {
268+ for ( const m of d . metadata ) set . add ( m . name ) ;
269+ if ( d . kind === "class" ) {
270+ for ( const f of d . fields ) for ( const m of f . metadata ) set . add ( m . name ) ;
271+ } else {
272+ for ( const mem of d . members ) for ( const m of mem . metadata ) set . add ( m . name ) ;
273+ }
274+ }
275+ return [ ...set ] . sort ( ( a , b ) => a . localeCompare ( b ) ) ;
276+ } , [ declarations ] ) ;
277+
210278 const lastWord = getLastWord ( inputValue ) ;
211279 const filteredTags = filterTags ( lastWord ) ;
212- const showPopup = isFocused && shouldShowPopup ( inputValue ) && filteredTags . length > 0 ;
280+ const showFirstLevel = isFocused && shouldShowFirstLevelPopup ( inputValue ) && filteredTags . length > 0 ;
213281
214- const handleTagSelect = ( tag : string ) => {
215- const newValue = insertTag ( inputValue , tag ) ;
282+ const secondLevel = getSecondLevelContext ( lastWord ) ;
283+ const secondLevelValues = useMemo ( ( ) => {
284+ if ( ! secondLevel ) return [ ] ;
285+ const list = secondLevel . type === "module" ? uniqueModules : uniqueMetadataKeys ;
286+ if ( secondLevel . value === "" ) return list ;
287+ const lower = secondLevel . value . toLowerCase ( ) ;
288+ return list . filter ( v => v . toLowerCase ( ) . startsWith ( lower ) ) ;
289+ } , [ secondLevel ?. type , secondLevel ?. value , uniqueModules , uniqueMetadataKeys ] ) ;
290+ const isExactMatch = secondLevel != null && secondLevelValues . length === 1 && secondLevelValues [ 0 ] . toLowerCase ( ) === secondLevel . value . toLowerCase ( ) ;
291+ const showSecondLevel = isFocused && secondLevel != null && secondLevelValues . length > 0 && ! isExactMatch ;
292+
293+ const showPopup = showFirstLevel || showSecondLevel ;
294+ const popupLength = showFirstLevel ? filteredTags . length : secondLevelValues . length ;
295+
296+ const applyNewValue = ( newValue : string ) => {
216297 setInputValue ( newValue ) ;
217298 ownNavigateRef . current = true ;
218299 const replace = inputValue !== "" || newValue === "" ;
@@ -224,6 +305,16 @@ export function SearchBox({
224305 ref . current ?. focus ( ) ;
225306 } ;
226307
308+ const handleTagSelect = ( tag : string ) => {
309+ applyNewValue ( insertTag ( inputValue , tag ) ) ;
310+ } ;
311+
312+ const handleValueSelect = ( value : string ) => {
313+ if ( ! secondLevel ) return ;
314+ const tagPrefix = secondLevel . type === "module" ? "module:" : "metadata:" ;
315+ applyNewValue ( insertValue ( inputValue , tagPrefix , value ) ) ;
316+ } ;
317+
227318 const onChange : React . ChangeEventHandler < HTMLInputElement > = ( { target : { value } } ) => {
228319 const wasSearching = inputValue !== "" ;
229320 setInputValue ( value ) ;
@@ -242,9 +333,13 @@ export function SearchBox({
242333
243334 const onKeyDown : React . KeyboardEventHandler < HTMLInputElement > = ( e ) => {
244335 if ( ! showPopup ) return ;
245- if ( e . key === "ArrowDown" ) { e . preventDefault ( ) ; setActiveIndex ( i => Math . min ( i + 1 , filteredTags . length - 1 ) ) ; }
336+ if ( e . key === "ArrowDown" ) { e . preventDefault ( ) ; setActiveIndex ( i => Math . min ( i + 1 , popupLength - 1 ) ) ; }
246337 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 ) ; } }
338+ else if ( e . key === "Enter" || e . key === "Tab" ) {
339+ e . preventDefault ( ) ;
340+ if ( showFirstLevel && filteredTags [ activeIndex ] ) handleTagSelect ( filteredTags [ activeIndex ] . tag ) ;
341+ else if ( showSecondLevel && secondLevelValues [ activeIndex ] ) handleValueSelect ( secondLevelValues [ activeIndex ] ) ;
342+ }
248343 else if ( e . key === "Escape" ) { setIsFocused ( false ) ; }
249344 } ;
250345
@@ -268,9 +363,17 @@ export function SearchBox({
268363 autoCorrect = "off"
269364 autoCapitalize = "off"
270365 />
271- { showPopup && (
366+ { showFirstLevel && (
272367 < SearchTagPopup tags = { filteredTags } activeIndex = { activeIndex } onSelect = { handleTagSelect } />
273368 ) }
369+ { showSecondLevel && secondLevel && (
370+ < ValueSuggestPopup
371+ header = { secondLevel . type === "module" ? "Modules" : "Metadata Keys" }
372+ values = { secondLevelValues }
373+ activeIndex = { activeIndex }
374+ onSelect = { handleValueSelect }
375+ />
376+ ) }
274377 </ SearchBoxWrapper >
275378 ) ;
276379}
0 commit comments