11<script lang="ts" setup>
2- import {ref , computed , watch , nextTick , useTemplateRef , onMounted , onUnmounted } from ' vue' ;
3- import {GET } from ' ../modules/fetch.ts' ;
4- import {filterRepoFilesWeighted } from ' ../features/repo-findfile.ts' ;
5- import {pathEscapeSegments } from ' ../utils/url.ts' ;
6- import {svg } from ' ../svg.ts' ;
2+ import { ref , computed , watch , nextTick , useTemplateRef , onMounted , onUnmounted } from ' vue' ;
3+ import { onInputDebounce } from ' ../utils/dom.ts' ;
4+ import { GET } from ' ../modules/fetch.ts' ;
5+ import { filterRepoFilesWeighted } from ' ../features/repo-findfile.ts' ;
6+ import { pathEscapeSegments } from ' ../utils/url.ts' ;
7+ import { svg } from ' ../svg.ts' ;
78
89const searchInput = useTemplateRef (' searchInput' );
910const searchResults = useTemplateRef (' searchResults' );
@@ -14,11 +15,11 @@ const isLoadingFileList = ref(false);
1415const hasLoadedFileList = ref (false );
1516
1617const props = defineProps ({
17- repoLink: {type: String , required: true },
18- currentRefNameSubURL: {type: String , required: true },
19- treeListUrl: {type: String , required: true },
20- noResultsText: {type: String , required: true },
21- placeholder: {type: String , required: true },
18+ repoLink: { type: String , required: true },
19+ currentRefNameSubURL: { type: String , required: true },
20+ treeListUrl: { type: String , required: true },
21+ noResultsText: { type: String , required: true },
22+ placeholder: { type: String , required: true },
2223});
2324
2425const filteredFiles = computed (() => {
@@ -28,10 +29,12 @@ const filteredFiles = computed(() => {
2829
2930const treeLink = computed (() => ` ${props .repoLink }/src/${pathEscapeSegments (props .currentRefNameSubURL )} ` );
3031
31- const handleSearchInput = (e : Event ) => {
32- searchQuery .value = (e .target as HTMLInputElement ).value ;
33- selectedIndex .value = 0 ;
34- };
32+ const handleSearchInput = onInputDebounce (() => {
33+ if (searchInput .value ) {
34+ searchQuery .value = searchInput .value .value ;
35+ selectedIndex .value = 0 ;
36+ }
37+ });
3538
3639const handleKeyDown = (e : KeyboardEvent ) => {
3740 if (e .key === ' Escape' && searchQuery .value ) {
@@ -68,30 +71,30 @@ const scrollSelectedIntoView = () => {
6871 nextTick (() => {
6972 const resultsEl = searchResults .value ;
7073 if (! resultsEl ) return ;
71-
74+
7275 const selectedEl = resultsEl .querySelector (' .file-tree-search-result-item.selected' );
7376 if (selectedEl ) {
74- selectedEl .scrollIntoView ({block: ' nearest' , behavior: ' smooth' });
77+ selectedEl .scrollIntoView ({ block: ' nearest' , behavior: ' smooth' });
7578 }
7679 });
7780};
7881
7982const handleClickOutside = (e : MouseEvent ) => {
8083 if (! searchQuery .value ) return ;
81-
84+
8285 const target = e .target as HTMLElement ;
8386 const resultsEl = searchResults .value ;
8487 const inputEl = searchInput .value ;
85-
86- if (inputEl && ! inputEl .contains (target ) &&
87- resultsEl && ! resultsEl .contains (target )) {
88+
89+ if (inputEl && ! inputEl .contains (target ) &&
90+ resultsEl && ! resultsEl .contains (target )) {
8891 clearSearch ();
8992 }
9093};
9194
9295const loadFileListForSearch = async () => {
9396 if (hasLoadedFileList .value || isLoadingFileList .value ) return ;
94-
97+
9598 isLoadingFileList .value = true ;
9699 try {
97100 const response = await GET (props .treeListUrl );
@@ -111,64 +114,70 @@ function handleSearchResultClick(filePath: string) {
111114 window .location .href = ` ${treeLink .value }/${pathEscapeSegments (filePath )} ` ;
112115}
113116
117+ const updatePosition = () => {
118+ if (searchInput .value && searchResults .value ) {
119+ const rect = searchInput .value .getBoundingClientRect ();
120+ searchResults .value .style .top = ` ${rect .bottom + 4 }px ` ;
121+ searchResults .value .style .left = ` ${rect .left }px ` ;
122+ searchResults .value .style .visibility = ' visible' ;
123+ }
124+ };
125+
114126onMounted (() => {
115127 document .addEventListener (' click' , handleClickOutside );
128+ window .addEventListener (' scroll' , updatePosition , { passive: true });
116129});
117130
118131onUnmounted (() => {
119132 document .removeEventListener (' click' , handleClickOutside );
133+ window .removeEventListener (' scroll' , updatePosition );
120134});
121135
122136// Position search results below the input
123- watch (searchQuery , async () => {
124- if (searchQuery .value && searchInput . value ) {
137+ watch ([ searchQuery , filteredFiles ] , async () => {
138+ if (searchQuery .value ) {
125139 await nextTick ();
126- const resultsEl = searchResults .value ;
127- if (resultsEl ) {
128- const rect = searchInput .value .getBoundingClientRect ();
129- resultsEl .style .top = ` ${rect .bottom + 4 }px ` ;
130- resultsEl .style .left = ` ${rect .left }px ` ;
131- }
140+ updatePosition ();
132141 }
133142});
134143 </script >
135144
136145<template >
137146 <div class =" repo-file-search" >
138- <div class =" ui small input tw-w-full tw-px-2 tw-pb-2" >
139- <input
140- ref =" searchInput"
141- type =" text"
142- :placeholder =" placeholder"
143- autocomplete =" off"
144- @input =" handleSearchInput"
145- @keydown =" handleKeyDown"
146- @focus =" handleSearchFocus"
147+ <div class =" ui small input tw-w-full tw-px-2 tw-pb-2 tw-items-center" >
148+ <input
149+ ref =" searchInput" type =" text" :placeholder =" placeholder" autocomplete =" off" role =" combobox"
150+ aria-autocomplete =" list" :aria-expanded =" searchQuery ? 'true' : 'false'" aria-controls =" file-search-results"
151+ @input =" handleSearchInput" @keydown =" handleKeyDown" @focus =" handleSearchFocus"
147152 >
148153 </div >
149-
154+
150155 <Teleport to =" body" >
151- <div v-if =" searchQuery && filteredFiles.length > 0" ref =" searchResults" class =" file-tree-search-results" >
152- <div
153- v-for =" (result, idx) in filteredFiles"
154- :key =" result.matchResult.join('')"
155- :class =" ['file-tree-search-result-item', {'selected': idx === selectedIndex}]"
156- @click =" handleSearchResultClick(result.matchResult.join(''))"
157- @mouseenter =" selectedIndex = idx"
158- :title =" result.matchResult.join('')"
156+ <div
157+ v-if =" searchQuery && filteredFiles.length > 0" id =" file-search-results" ref =" searchResults"
158+ class =" file-tree-search-results" role =" listbox" style =" visibility : hidden "
159+ >
160+ <div
161+ v-for =" (result, idx) in filteredFiles" :key =" result.matchResult.join('')"
162+ :class =" ['file-tree-search-result-item', { 'selected': idx === selectedIndex }]" role =" option"
163+ :aria-selected =" idx === selectedIndex" @click =" handleSearchResultClick(result.matchResult.join(''))"
164+ @mouseenter =" selectedIndex = idx" :title =" result.matchResult.join('')"
159165 >
160166 <!-- eslint-disable-next-line vue/no-v-html -->
161167 <span v-html =" svg('octicon-file', 16)" />
162168 <span class =" file-tree-search-result-path" >
163- <span
164- v-for =" (part, index) in result.matchResult"
165- :key = " index"
166- :class = " {'search-match': index % 2 === 1} "
167- >{{ part }}</span >
169+ <span
170+ v-for =" (part, index) in result.matchResult" :key = " index "
171+ :class = " { 'search-match': index % 2 === 1 } "
172+ >{{
173+ part }}</span >
168174 </span >
169175 </div >
170176 </div >
171- <div v-if =" searchQuery && filteredFiles.length === 0" ref =" searchResults" class =" file-tree-search-results file-tree-search-no-results" >
177+ <div
178+ v-if =" searchQuery && filteredFiles.length === 0" ref =" searchResults"
179+ class =" file-tree-search-results file-tree-search-no-results" style =" visibility : hidden "
180+ >
172181 <div class =" file-tree-no-results-content" >
173182 <!-- eslint-disable-next-line vue/no-v-html -->
174183 <span v-html =" svg('octicon-search', 24)" />
@@ -193,11 +202,9 @@ watch(searchQuery, async () => {
193202 background : var (--color-box-body );
194203 border : 1px solid var (--color-secondary );
195204 border-radius : 6px ;
196- box-shadow : 0 8px 24px rgba (0 , 0 , 0 , 0.12 );
197205 min-width : 300px ;
198206 width : max-content ;
199207 max-width : 600px ;
200- z-index : 99999 ;
201208}
202209
203210.file-tree-search-result-item {
@@ -210,7 +217,7 @@ watch(searchQuery, async () => {
210217 border-bottom : 1px solid var (--color-secondary );
211218}
212219
213- .file-tree-search-result-item > span :first-child {
220+ .file-tree-search-result-item > span :first-child {
214221 flex-shrink : 0 ;
215222 margin-top : 0.125rem ;
216223}
@@ -236,10 +243,6 @@ watch(searchQuery, async () => {
236243 font-weight : var (--font-weight-semibold );
237244}
238245
239- .file-tree-search-no-results {
240- padding : 0 ;
241- }
242-
243246.file-tree-no-results-content {
244247 display : flex ;
245248 flex-direction : column ;
@@ -249,8 +252,4 @@ watch(searchQuery, async () => {
249252 color : var (--color-text-light-2 );
250253 font-size : 14px ;
251254}
252-
253- .file-tree-no-results-content > span :first-child {
254- opacity : 0.5 ;
255- }
256255 </style >
0 commit comments