@@ -101,4 +101,258 @@ describe('AlphabetScrollbar', () => {
101101
102102 vi . useRealTimers ( ) ;
103103 } ) ;
104+
105+ it ( 'maps items with numeric/symbol titles to # category' , async ( ) => {
106+ const container = createScrollContainer ( ) ;
107+ setScrollMetrics ( container , 1000 , 100 ) ;
108+ const onLetterSelect = vi . fn ( ) ;
109+ const items = [
110+ { id : 'num' , title : '1SongWithNumber' } ,
111+ { id : 'empty' , title : '' } ,
112+ ] ;
113+
114+ render (
115+ < AlphabetScrollbar
116+ items = { items }
117+ scrollContainerRef = { { current : container } }
118+ onLetterSelect = { onLetterSelect }
119+ onScrollToIndex = { vi . fn ( ) }
120+ /> ,
121+ ) ;
122+
123+ const touchArea = await screen . findByTestId ( 'alphabet-touch-area' ) ;
124+ Object . defineProperty ( touchArea , 'getBoundingClientRect' , {
125+ value : ( ) => ( { top : 0 , left : 0 , right : 0 , bottom : 260 , width : 20 , height : 260 , x : 0 , y : 0 , toJSON : ( ) => '' } ) ,
126+ } ) ;
127+
128+ // Touch at position that maps to '#' (first letter at top ~0 index)
129+ fireEvent . touchStart ( touchArea , { touches : [ { clientY : 1 } ] } ) ;
130+ expect ( onLetterSelect ) . toHaveBeenCalledWith ( '#' ) ;
131+ } ) ;
132+
133+ it ( 'scrollToLetter returns early when letter has no items' , async ( ) => {
134+ const container = createScrollContainer ( ) ;
135+ setScrollMetrics ( container , 1000 , 100 ) ;
136+ const onLetterSelect = vi . fn ( ) ;
137+ const items = [
138+ { id : 'alpha' , title : 'Alpha' } ,
139+ ] ;
140+
141+ render (
142+ < AlphabetScrollbar
143+ items = { items }
144+ scrollContainerRef = { { current : container } }
145+ onLetterSelect = { onLetterSelect }
146+ onScrollToIndex = { vi . fn ( ) }
147+ /> ,
148+ ) ;
149+
150+ const touchArea = await screen . findByTestId ( 'alphabet-touch-area' ) ;
151+ Object . defineProperty ( touchArea , 'getBoundingClientRect' , {
152+ value : ( ) => ( { top : 0 , left : 0 , right : 0 , bottom : 260 , width : 20 , height : 260 , x : 0 , y : 0 , toJSON : ( ) => '' } ) ,
153+ } ) ;
154+
155+ // Touch at position that maps to 'Z' (last letter, near bottom)
156+ fireEvent . touchStart ( touchArea , { touches : [ { clientY : 255 } ] } ) ;
157+ // 'Z' is not in indices (only 'Alpha' starting with 'A' exists)
158+ // scrollToLetter should return early with no call
159+ // But note: '#' exists (empty title items don't exist here), and 'A' exists
160+ // 'Z' at index 26 doesn't exist → early return
161+ expect ( onLetterSelect ) . not . toHaveBeenCalledWith ( 'Z' ) ;
162+ } ) ;
163+
164+ it ( 'handles scroll event that shows and schedules hide' , async ( ) => {
165+ vi . useFakeTimers ( ) ;
166+ const container = createScrollContainer ( ) ;
167+ setScrollMetrics ( container , 1000 , 100 ) ;
168+
169+ render (
170+ < AlphabetScrollbar
171+ items = { [ { id : 'a' , title : 'Alpha' } , { id : 'b' , title : 'Beta' } ] }
172+ scrollContainerRef = { { current : container } }
173+ /> ,
174+ ) ;
175+
176+ await act ( async ( ) => {
177+ await Promise . resolve ( ) ;
178+ } ) ;
179+
180+ act ( ( ) => { container . dispatchEvent ( new Event ( 'scroll' ) ) ; } ) ;
181+
182+ const overlay = screen . getByTestId ( 'alphabet-overlay' ) ;
183+ expect ( overlay . className ) . toContain ( 'opacity-100' ) ;
184+
185+ vi . useRealTimers ( ) ;
186+ } ) ;
187+
188+ it ( 'uses onScrollToIndex callback when provided' , async ( ) => {
189+ const container = createScrollContainer ( ) ;
190+ setScrollMetrics ( container , 1000 , 100 ) ;
191+ const onScrollToIndex = vi . fn ( ) ;
192+ const items = [
193+ { id : 'alpha' , title : 'Alpha' } ,
194+ { id : 'beta' , title : 'Beta' } ,
195+ ] ;
196+
197+ render (
198+ < AlphabetScrollbar
199+ items = { items }
200+ scrollContainerRef = { { current : container } }
201+ onScrollToIndex = { onScrollToIndex }
202+ /> ,
203+ ) ;
204+
205+ const touchArea = await screen . findByTestId ( 'alphabet-touch-area' ) ;
206+ Object . defineProperty ( touchArea , 'getBoundingClientRect' , {
207+ value : ( ) => ( { top : 0 , left : 0 , right : 0 , bottom : 260 , width : 20 , height : 260 , x : 0 , y : 0 , toJSON : ( ) => '' } ) ,
208+ } ) ;
209+
210+ // Touch at position that maps to 'A'
211+ fireEvent . touchStart ( touchArea , { touches : [ { clientY : 10 } ] } ) ;
212+ expect ( onScrollToIndex ) . toHaveBeenCalledWith ( 0 ) ;
213+ } ) ;
214+
215+ it ( 'uses querySelector scrollIntoView when onScrollToIndex is not provided' , async ( ) => {
216+ const container = createScrollContainer ( ) ;
217+ setScrollMetrics ( container , 1000 , 100 ) ;
218+ const onLetterSelect = vi . fn ( ) ;
219+ const items = [
220+ { id : 'alpha' , title : 'Alpha' } ,
221+ ] ;
222+
223+ render (
224+ < AlphabetScrollbar
225+ items = { items }
226+ scrollContainerRef = { { current : container } }
227+ onLetterSelect = { onLetterSelect }
228+ /> ,
229+ ) ;
230+
231+ const touchArea = await screen . findByTestId ( 'alphabet-touch-area' ) ;
232+ Object . defineProperty ( touchArea , 'getBoundingClientRect' , {
233+ value : ( ) => ( { top : 0 , left : 0 , right : 0 , bottom : 260 , width : 20 , height : 260 , x : 0 , y : 0 , toJSON : ( ) => '' } ) ,
234+ } ) ;
235+
236+ // Touch 'A' zone
237+ fireEvent . touchStart ( touchArea , { touches : [ { clientY : 10 } ] } ) ;
238+ // querySelector would look for [data-row-id="alpha"] in container
239+ const alphaNode = container . querySelector ( '[data-row-id="alpha"]' ) ;
240+ expect ( alphaNode ) . not . toBeNull ( ) ;
241+ expect ( onLetterSelect ) . toHaveBeenCalledWith ( 'A' ) ;
242+ } ) ;
243+
244+ it ( 'handles pointer enter and leave events' , async ( ) => {
245+ const container = createScrollContainer ( ) ;
246+ setScrollMetrics ( container , 1000 , 100 ) ;
247+
248+ render (
249+ < AlphabetScrollbar
250+ items = { [ { id : 'a' , title : 'Alpha' } , { id : 'b' , title : 'Beta' } ] }
251+ scrollContainerRef = { { current : container } }
252+ /> ,
253+ ) ;
254+
255+ const touchArea = await screen . findByTestId ( 'alphabet-touch-area' ) ;
256+ // pointer events should not throw
257+ fireEvent . pointerEnter ( touchArea ) ;
258+ fireEvent . pointerLeave ( touchArea ) ;
259+
260+ // overlay might be visible after pointer enter
261+ const overlay = screen . getByTestId ( 'alphabet-overlay' ) ;
262+ expect ( overlay ) . toBeInTheDocument ( ) ;
263+ } ) ;
264+
265+ it ( 'handles touch move and end events' , async ( ) => {
266+ const container = createScrollContainer ( ) ;
267+ setScrollMetrics ( container , 1000 , 100 ) ;
268+ const onLetterSelect = vi . fn ( ) ;
269+
270+ render (
271+ < AlphabetScrollbar
272+ items = { [ { id : 'alpha' , title : 'Alpha' } , { id : 'beta' , title : 'Beta' } ] }
273+ scrollContainerRef = { { current : container } }
274+ onLetterSelect = { onLetterSelect }
275+ onScrollToIndex = { vi . fn ( ) }
276+ /> ,
277+ ) ;
278+
279+ const touchArea = await screen . findByTestId ( 'alphabet-touch-area' ) ;
280+ Object . defineProperty ( touchArea , 'getBoundingClientRect' , {
281+ value : ( ) => ( { top : 0 , left : 0 , right : 0 , bottom : 260 , width : 20 , height : 260 , x : 0 , y : 0 , toJSON : ( ) => '' } ) ,
282+ } ) ;
283+
284+ fireEvent . touchStart ( touchArea , { touches : [ { clientY : 10 } ] } ) ;
285+ fireEvent . touchMove ( touchArea , { touches : [ { clientY : 30 } ] } ) ;
286+ fireEvent . touchEnd ( touchArea ) ;
287+ } ) ;
288+
289+ it ( 'handleScroll resets visibility when component is not eligible' , async ( ) => {
290+ // 0 items + no overflowing container → isEligible stays false
291+ const container = document . createElement ( 'div' ) ;
292+ setScrollMetrics ( container , 100 , 100 ) ; // not scrollable
293+
294+ render (
295+ < AlphabetScrollbar
296+ items = { [ ] }
297+ scrollContainerRef = { { current : container } }
298+ /> ,
299+ ) ;
300+
301+ await act ( async ( ) => {
302+ await Promise . resolve ( ) ;
303+ } ) ;
304+
305+ // touch area is NOT rendered (not eligible)
306+ expect ( screen . queryByTestId ( 'alphabet-touch-area' ) ) . toBeNull ( ) ;
307+
308+ // Fire scroll on the container — handleScroll should see isEligible=false
309+ act ( ( ) => {
310+ container . dispatchEvent ( new Event ( 'scroll' ) ) ;
311+ } ) ;
312+ // No errors; overlay is not rendered
313+ expect ( screen . queryByTestId ( 'alphabet-overlay' ) ) . toBeNull ( ) ;
314+ } ) ;
315+
316+ it ( 'renders without crashing when ResizeObserver is unavailable' , async ( ) => {
317+ const originalResizeObserver = window . ResizeObserver ;
318+ // Remove ResizeObserver to simulate unavailable environment (line 171 fallback)
319+ Object . defineProperty ( window , 'ResizeObserver' , { value : undefined , configurable : true } ) ;
320+
321+ const container = createScrollContainer ( ) ;
322+ setScrollMetrics ( container , 1000 , 100 ) ;
323+
324+ render (
325+ < AlphabetScrollbar
326+ items = { [ { id : 'alpha' , title : 'Alpha' } ] }
327+ scrollContainerRef = { { current : container } }
328+ /> ,
329+ ) ;
330+
331+ await act ( async ( ) => {
332+ await Promise . resolve ( ) ;
333+ } ) ;
334+
335+ // Component still renders and becomes eligible
336+ const overlay = screen . getByTestId ( 'alphabet-overlay' ) ;
337+ expect ( overlay ) . toBeInTheDocument ( ) ;
338+
339+ // Restore
340+ Object . defineProperty ( window , 'ResizeObserver' , { value : originalResizeObserver , configurable : true } ) ;
341+ } ) ;
342+
343+ it ( 'handles null scrollContainerRef gracefully' , async ( ) => {
344+ render (
345+ < AlphabetScrollbar
346+ items = { [ { id : 'alpha' , title : 'Alpha' } ] }
347+ scrollContainerRef = { { current : null } }
348+ /> ,
349+ ) ;
350+
351+ await act ( async ( ) => {
352+ await Promise . resolve ( ) ;
353+ } ) ;
354+
355+ // Component renders without crash; no touch area since not eligible
356+ expect ( screen . queryByTestId ( 'alphabet-touch-area' ) ) . toBeNull ( ) ;
357+ } ) ;
104358} ) ;
0 commit comments