@@ -2,9 +2,136 @@ import Swiper from 'swiper'
22import { Autoplay , Navigation , Pagination } from 'swiper/modules'
33import htmx from 'htmx.org'
44import SlimSelect from 'slim-select'
5+ import Alpine from 'alpinejs'
56
67window . htmx = htmx
78
9+ Alpine . data ( 'packageSearch' , ( ) => ( {
10+ query : '' ,
11+ results : [ ] ,
12+ open : false ,
13+ loading : false ,
14+ selectedIndex : - 1 ,
15+ abortController : null ,
16+ debounceTimer : null ,
17+
18+ init ( ) {
19+ // Sync initial value from the input
20+ this . query = this . $refs . input ?. value || ''
21+
22+ this . $watch ( 'query' , ( value ) => {
23+ this . debouncedFetch ( value )
24+ } )
25+ } ,
26+
27+ debouncedFetch ( value ) {
28+ clearTimeout ( this . debounceTimer )
29+
30+ if ( this . abortController ) {
31+ this . abortController . abort ( )
32+ this . abortController = null
33+ }
34+
35+ if ( value . trim ( ) . length < 2 ) {
36+ this . results = [ ]
37+ this . open = false
38+ this . loading = false
39+ return
40+ }
41+
42+ this . loading = true
43+
44+ this . debounceTimer = setTimeout ( ( ) => {
45+ this . fetchResults ( value . trim ( ) )
46+ } , 250 )
47+ } ,
48+
49+ async fetchResults ( q ) {
50+ if ( this . abortController ) {
51+ this . abortController . abort ( )
52+ }
53+
54+ this . abortController = new AbortController ( )
55+
56+ try {
57+ const response = await fetch ( `/autocomplete?q=${ encodeURIComponent ( q ) } ` , {
58+ signal : this . abortController . signal ,
59+ headers : { 'Accept' : 'application/json' } ,
60+ } )
61+
62+ const data = await response . json ( )
63+ this . results = data
64+ this . open = data . length > 0
65+ this . selectedIndex = - 1
66+ } catch ( e ) {
67+ if ( e . name !== 'AbortError' ) {
68+ this . results = [ ]
69+ this . open = false
70+ }
71+ } finally {
72+ this . loading = false
73+ }
74+ } ,
75+
76+ close ( ) {
77+ this . open = false
78+ this . selectedIndex = - 1
79+ } ,
80+
81+ onKeydown ( e ) {
82+ if ( ! this . open ) return
83+
84+ switch ( e . key ) {
85+ case 'ArrowDown' :
86+ e . preventDefault ( )
87+ this . selectedIndex = Math . min ( this . selectedIndex + 1 , this . results . length - 1 )
88+ this . scrollToSelected ( )
89+ break
90+ case 'ArrowUp' :
91+ e . preventDefault ( )
92+ this . selectedIndex = Math . max ( this . selectedIndex - 1 , - 1 )
93+ this . scrollToSelected ( )
94+ break
95+ case 'Enter' :
96+ if ( this . selectedIndex >= 0 ) {
97+ e . preventDefault ( )
98+ this . selectResult ( this . results [ this . selectedIndex ] )
99+ }
100+ break
101+ case 'Escape' :
102+ this . close ( )
103+ break
104+ }
105+ } ,
106+
107+ scrollToSelected ( ) {
108+ this . $nextTick ( ( ) => {
109+ const el = this . $refs . listbox ?. querySelector ( '[aria-selected="true"]' )
110+ el ?. scrollIntoView ( { block : 'nearest' } )
111+ } )
112+ } ,
113+
114+ selectResult ( result ) {
115+ try {
116+ const url = new URL ( result . repo_url )
117+ if ( url . protocol === 'https:' ) {
118+ window . open ( url . toString ( ) , '_blank' , 'noopener,noreferrer' )
119+ }
120+ } catch {
121+ // Invalid URL, ignore
122+ }
123+ this . close ( )
124+ } ,
125+
126+
127+
128+ formatNumber ( num ) {
129+ if ( num >= 1000000 ) return ( num / 1000000 ) . toFixed ( 1 ) . replace ( / \. 0 $ / , '' ) + 'M'
130+ if ( num >= 1000 ) return ( num / 1000 ) . toFixed ( 1 ) . replace ( / \. 0 $ / , '' ) + 'k'
131+ return String ( num )
132+ } ,
133+ } ) )
134+
8135const initializeSelects = ( root = document ) => {
9136 const selects = document . querySelectorAll ( 'select' )
10137
@@ -78,6 +205,8 @@ document.addEventListener('DOMContentLoaded', () => {
78205 initializeSelects ( )
79206} )
80207
208+ Alpine . start ( )
209+
81210if ( typeof window . htmx !== 'undefined' ) {
82211 const reinitializeDynamicUi = ( ) => {
83212 initializeFeaturedPackagesSlider ( document )
0 commit comments