@@ -307,6 +307,105 @@ describe('PollOptionList', () => {
307307 } ) ;
308308 } ) ;
309309
310+ it ( 'ignores auto-repeated key presses on a held key' , async ( ) => {
311+ const poll = new Poll ( {
312+ client : fromPartial < StreamChat > ( { } ) ,
313+ poll : pollWithNoVotes ,
314+ } ) ;
315+ const castVoteSpy = vi . spyOn ( poll , 'castVote' ) . mockResolvedValue ( fromPartial ( { } ) ) ;
316+ const removeVoteSpy = vi . spyOn ( poll , 'removeVote' ) . mockResolvedValue ( fromPartial ( { } ) ) ;
317+
318+ const { container } = renderComponent ( { poll } ) ;
319+ const firstOption = container . querySelector ( VOTABLE_OPTION_SELECTOR ) as HTMLElement ;
320+
321+ act ( ( ) => {
322+ fireEvent . keyDown ( firstOption , { key : 'Enter' } ) ;
323+ fireEvent . keyDown ( firstOption , { key : 'Enter' , repeat : true } ) ;
324+ fireEvent . keyDown ( firstOption , { key : ' ' , repeat : true } ) ;
325+ } ) ;
326+
327+ await waitFor ( ( ) => {
328+ expect ( castVoteSpy ) . toHaveBeenCalledTimes ( 1 ) ;
329+ } ) ;
330+ expect ( removeVoteSpy ) . not . toHaveBeenCalled ( ) ;
331+ } ) ;
332+
333+ it ( 'treats a double click as a single vote toggle' , async ( ) => {
334+ const poll = new Poll ( {
335+ client : fromPartial < StreamChat > ( { } ) ,
336+ poll : pollWithNoVotes ,
337+ } ) ;
338+ const castVoteSpy = vi . spyOn ( poll , 'castVote' ) . mockResolvedValue ( fromPartial ( { } ) ) ;
339+ const removeVoteSpy = vi . spyOn ( poll , 'removeVote' ) . mockResolvedValue ( fromPartial ( { } ) ) ;
340+
341+ const { container } = renderComponent ( { poll } ) ;
342+ const firstOption = container . querySelector ( VOTABLE_OPTION_SELECTOR ) as HTMLElement ;
343+
344+ // the browser reports the click count of a multi-click in `detail`
345+ act ( ( ) => {
346+ fireEvent . click ( firstOption , { detail : 1 } ) ;
347+ fireEvent . click ( firstOption , { detail : 2 } ) ;
348+ fireEvent . click ( firstOption , { detail : 3 } ) ;
349+ } ) ;
350+
351+ await waitFor ( ( ) => {
352+ expect ( castVoteSpy ) . toHaveBeenCalledTimes ( 1 ) ;
353+ } ) ;
354+ expect ( removeVoteSpy ) . not . toHaveBeenCalled ( ) ;
355+ } ) ;
356+
357+ it ( 'toggles the vote on rapid clicks without waiting for the server' , async ( ) => {
358+ const client = await getTestClientWithUser ( generateUser ( ) ) ;
359+ const poll = new Poll ( { client, poll : pollWithNoVotes } ) ;
360+ const optionId = pollWithNoVotes . options [ 0 ] . id ;
361+ const messageId = defaultMessageContext . message . id ;
362+ const serverVote = {
363+ created_at : new Date ( ) . toISOString ( ) ,
364+ id : 'server-vote-id' ,
365+ option_id : optionId ,
366+ poll_id : pollWithNoVotes . id ,
367+ updated_at : new Date ( ) . toISOString ( ) ,
368+ user : client . user ,
369+ user_id : client . userID ,
370+ } ;
371+ const castPollVoteSpy = vi
372+ . spyOn ( client , 'castPollVote' )
373+ . mockResolvedValue ( fromPartial ( { vote : serverVote } ) ) ;
374+ const removePollVoteSpy = vi
375+ . spyOn ( client , 'removePollVote' )
376+ . mockResolvedValue ( fromPartial ( { } ) ) ;
377+
378+ const { container } = renderComponent ( { poll } ) ;
379+ const firstOption = container . querySelector ( VOTABLE_OPTION_SELECTOR ) as HTMLElement ;
380+
381+ // separate clicks (not a double click), the second one lands before the server
382+ // responded to the first one
383+ act ( ( ) => {
384+ fireEvent . click ( firstOption , { detail : 1 } ) ;
385+ } ) ;
386+ expect ( firstOption ) . toHaveAttribute ( 'aria-pressed' , 'true' ) ;
387+ act ( ( ) => {
388+ fireEvent . click ( firstOption , { detail : 1 } ) ;
389+ } ) ;
390+ expect ( firstOption ) . toHaveAttribute ( 'aria-pressed' , 'false' ) ;
391+
392+ await waitFor ( ( ) => {
393+ expect ( removePollVoteSpy ) . toHaveBeenCalledWith (
394+ messageId ,
395+ pollWithNoVotes . id ,
396+ serverVote . id ,
397+ ) ;
398+ } ) ;
399+ expect ( castPollVoteSpy ) . toHaveBeenCalledTimes ( 1 ) ;
400+ expect ( castPollVoteSpy ) . toHaveBeenCalledWith ( messageId , pollWithNoVotes . id , {
401+ option_id : optionId ,
402+ } ) ;
403+ expect ( removePollVoteSpy ) . toHaveBeenCalledTimes ( 1 ) ;
404+ expect ( castPollVoteSpy . mock . invocationCallOrder [ 0 ] ) . toBeLessThan (
405+ removePollVoteSpy . mock . invocationCallOrder [ 0 ] ,
406+ ) ;
407+ } ) ;
408+
310409 it ( 'passes axe on the default poll option list' , async ( ) => {
311410 const poll = new Poll ( { client : fromPartial < StreamChat > ( { } ) , poll : generatePoll ( ) } ) ;
312411 const { container } = renderComponent ( { poll } ) ;
0 commit comments