From 40a322ac05d7ef6295f122f14f4b98157eb52e91 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 13 Mar 2026 10:53:09 +0100 Subject: [PATCH] Room list: add a grouped virtualized list to shared components (#32566) * refactor: extract most of the logic from the virtualized list The VirtualizedList component is renamed FlatVirtualizedList and most of the logic is extracted. In order to prepare the introduction of the GroupedVirtualizedList which will share most of the behaviour. * refactor: use `FlatVirtualizedList` instead of `VirtualizedList` * feat: add grouped virtualized list to shared components * feat: add accessiblity helps for virtualized list * test: use one test suite for the two virtualized lists * test: update storybook screenshots * feat: add keyboard navigation on header * test: make a11y test pass * chore: delete old screenshot * doc: a11y docs to list stories * chore: fix copyright --- .../views/rooms/MemberList/MemberListView.tsx | 4 +- .../default-auto.png | Bin .../default-auto.png | Bin 0 -> 23481 bytes .../VirtualizedRoomListView.tsx | 4 +- .../FlatVirtualizedList.stories.tsx | 113 +++++ .../FlatVirtualizedList.tsx | 58 +++ .../FlatVirtualizedList/index.ts | 9 + .../GroupedVirtualizedList.stories.tsx | 218 ++++++++++ .../GroupedVirtualizedList.tsx | 242 +++++++++++ .../GroupedVirtualizedList/index.ts | 9 + .../VirtualizedList.stories.tsx | 59 --- .../src/utils/VirtualizedList/accessbility.ts | 158 +++++++ .../src/utils/VirtualizedList/index.ts | 8 +- .../VirtualizedList/story-mock.module.css | 20 + .../src/utils/VirtualizedList/story-mock.tsx | 100 +++++ ...ist.test.tsx => virtualized-list.test.tsx} | 400 +++++++++++++----- ...rtualizedList.tsx => virtualized-list.tsx} | 136 +++--- 17 files changed, 1304 insertions(+), 234 deletions(-) rename packages/shared-components/__vis__/linux/__baselines__/utils/VirtualizedList/{VirtualizedList.stories.tsx => FlatVirtualizedList/FlatVirtualizedList.stories.tsx}/default-auto.png (100%) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.stories.tsx/default-auto.png create mode 100644 packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.stories.tsx create mode 100644 packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx create mode 100644 packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/index.ts create mode 100644 packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.stories.tsx create mode 100644 packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx create mode 100644 packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/index.ts delete mode 100644 packages/shared-components/src/utils/VirtualizedList/VirtualizedList.stories.tsx create mode 100644 packages/shared-components/src/utils/VirtualizedList/accessbility.ts create mode 100644 packages/shared-components/src/utils/VirtualizedList/story-mock.tsx rename packages/shared-components/src/utils/VirtualizedList/{VirtualizedList.test.tsx => virtualized-list.test.tsx} (63%) rename packages/shared-components/src/utils/VirtualizedList/{VirtualizedList.tsx => virtualized-list.tsx} (76%) diff --git a/apps/web/src/components/views/rooms/MemberList/MemberListView.tsx b/apps/web/src/components/views/rooms/MemberList/MemberListView.tsx index 3a78d271d9..46a9cd1397 100644 --- a/apps/web/src/components/views/rooms/MemberList/MemberListView.tsx +++ b/apps/web/src/components/views/rooms/MemberList/MemberListView.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import { Form } from "@vector-im/compound-web"; import React, { type JSX, useCallback } from "react"; -import { Flex, type VirtualizedListContext, VirtualizedList } from "@element-hq/web-shared-components"; +import { Flex, type VirtualizedListContext, FlatVirtualizedList } from "@element-hq/web-shared-components"; import { type MemberWithSeparator, @@ -108,7 +108,7 @@ const MemberListView: React.FC = (props: IProps) => { e.preventDefault()}> - jAfSJ$Ag%RvZc5&NO!>}Z{evg(XF|JR^m-zR z%r8Qr(g^TFX(@$ZqzUm5XO!Q{gi1*L<`<(v4hmL`0S7hle5JX=3|*>H1vZ+ZHe-fE*+&J2U#%-@1rHP|m&i>W)3$PS{( z$^--dY3j<+J}2F&1kut-SM~0KgA}=`O1hNHN2Bj{gOT*?VOt)LdWkbnyNA<0eiX)F zL@>xtCXr&un`bi%CI`o!`1dh9VxI%~A@a&eh%Utqm!A#%l=OK$mH~o zRDc|=eY1f{lxgW%JNw;ddCVnoN~MVj+c7* zI&nQaGx1F7!SkwIGPMqT>c&JS+tbL)++94wTV{Tvw-?9&wzGgjr(bKD@y-?Gpw z(%*R?JAUMhp8vk2N&m)CzV??|&&R?gIj{oIz^_YYHB?UgRe0-=R`1fDZZqv{t!23> ziVk)!+WbmeO^YXwEdG}2ARko!q^&CXsb23bS--`j$3DfRx=prb>bQ;W*-`xA5pRQv z^+Me*u_&8hxRjXRDtoy5U2#IfRGMju!^@^I@BQ^PVmv&eP2=aLTYFn8Hw~l)PfTUS zblYZ4#r=wMl-gGCsQOF#&oS!sxrBH9&BgCVt0W^^OvCF9KvB-rtC1g)e2Yg_N*>wFEs~edd@w2hsXf%%V8LabQn?F8?h7u<)_+TXvoCjV zPrT1SuyE1Uv^j-=Ho}|h|LUmst#kgMX(wFuSUAzrOg__4*g4BlKGs}H!qTiKG|;>?G^=z$=9`2a zeogv%8iQZ%HZuDCn#|3*Lu)Jf8nve1B6t~nK8e!2dIqMx7N^V;L-HjzDD%j%`;I(O8b zzq@(3u*18@I5^#F0Mf@O5DLx3SbY`aqPoVw+P3iS*~& zvD^)uaRPmxK=P>=7}xa8-NH3aBpHfIJt(Quxa%MmnC2hC$K%hGd&1XAobexhM-RPD zLajz}+}y{;Sibqdv96gDSbu4D((Av0{kULkJ&O|Y~@=b7Nih-;s+b2M*_b4w+E!FKm8tYTOqW2%6N0zr=c260{`hNS- z=5fRPNYM1SSFEtCf51f3fsb=A z{qE*BFIoZ;y8@%`Y>edIl0Z)p%#fj_z3d)acc7#uyCq&h<8YSVzTU=!`)BNfUx)U- zat;f%_pV=^Xc}y^GFX8!JUpD#m73l6aG>Sd1HPiKm3GnzH@)5bf|!@Ri|$9+#}8=? zwEc=SyWC%@qgWKSVD*4P3z#VrvJ(XTvDUkd(vAg76Sw^^*r`8sd|QJ4+W9JD zdjtCWlp8yJ4;A$tFxl5#vtFa6;)`=vRc7X%S+i#Cd$UBi@bqXx$f%@)_{+}nsCdV9 z)9D^{qmqS@=K9u0UiLbb>J5MQX;lPWCb(`1%R57a)rm~M!Sm<%Wc~3b(<`~QFYANG zi{|Pal~FAL8bn@}*xuyms0V6-p8wOA3M6d6U7zk)y#8+G*rdZTuuKV=q zg9O01)L+aG9K>&)|8^-M&Bx0yU_vZmt3i2ez)Ef3$9AJWx`P8>GA|W{)edMh|MJh% z)60yylXPhET|+~nP#Aim4wAzn{D8?Olt_Z^-hcki9uX2M58tFHA5u5sGc4Nk@X_*6 zRM}ATNL4tyv`e^{5abMkUhe8IH_6mE^htY-YF?-9Q@_D;29W~|XN2E~_T01|PdCmy zHy3@GK;KEOF=27?8cNv>LO|JN9_PMaS;hL}W?{B&_EPdwWf~gJMMRD9K%+PQ_0#l$ zmcvOU#s$JP)MK}BllXj5g90ihWzkeiqEGoft^4xsW<+7i3NK1j{x{M6u2ewm?+f6xr8h7vllJu4j2uQE15FFNsBk7j?e+ zhn(V~hIknQBZKO{^{LD|)FCsCW zfs!RoLVb@3iFDi~z-<{$Cq#JYW&#=7VT788jB#!yKxK%Kc!il~)psuC=63#HR4Sq5 z)#5ewr=@33cLPP@rZlI28AFciIQ(lC!x8V?SD{bvCnGi)gMmH^)Y`09YZR#zVUjCH zh|98RgqrM|I)JM(d*WTTHeI6t%e-k`hP>Z;gXV$n{Yh{jbx)>}E++;$`<-V>r+V7$ zDQ-IUeCNpv3=I7Fip!kz74SX2vi2Ekipw$K5q_`qp17Cc*a6FoiOvXjQa=E~KK!lC zKHu9t)M+?@%Xv8ET*E-N@)db&MlB`tXBblnZesV`a7x3}w;QMlnyTWx&7l_x~M9aF!{9YTFQk;$VNQ%&G zwJH#=;F(034o*O-Etz8oGVjw4YVXc;wGJ46cx2zZa>q5EFa6QYsHCk57_()V0Jl$k zTwPjpQ88|wTJ96=l%~{|!SfEqpFHIkJNU&rbBI?Un2PoT&8i(ElZsYLdP(r9@$$GWayyo}o{OfLde3{O53lwn>)tc^@ zb{*fPy_9v792KVkln=`s5G{JL(q2C4<4x(|n)Id1x}F=YbeKrb_@~1?TSMRH!3Awg30xf7QL?5lo%xKUX1!gF8AgQViB%p*s{q@<)SNQCQ( z2sH<*Haq%k?|f$B=oj>JqO{1j#Zkmuf7B|atLTjEsqgwz1LjFbbweGe$F5{g8U)D% zPnCFAq^@VP_VRq%0^kpd5NcTrYr(R3^cgD_1&wz}8@uTJ9BC^5^YZAAyv*kGxjJh$ zhc1*jFCVmPU^p%0=4xT%(nE45j(6&M?9-hp6w7@6T2^;*V0G`6@Tp_5V^1yjrRCLW z+lf_j_)UB}^%I$Y|I!F!ff80Auh*x&E$PS?DVy4DH& z1J>qCYuoPNRqL|b{)xEzE6O46&y~o1?PK0&%x$N;`y!&^aGef2SnML66?<#;Uo4QT z@3M|>DIu24vGaqozsMIE3aNe8P~(@;BY?2xW1!1ENai@;+02ph6sBEZ{5p_0By!a41eDt0qTXf8w_DzM{)K`-aBD zE)C~T^R$(IF^h`~lFz;_Q0rS++nd+pDCu3}YtuPe?EWaQa4a%mY=y|-laY(*)e)=K# zn`=Tp-!)2-5AF8tS2WkC+>mnH9;7jg-GhNXzuV zgc%3zoS2IeLAdhs@yeo5X~{2%rlA+bycc#KsN6j7kM@iMW|&gG5Ne()owPD0Msgb< zstzcsQ?BhH)N%5_CYDi0mZ!$RxaOJmFarG)f9n~x&ZCaQ_)tvy6&Oho$d17uP{fGD zbz(bFPN1ffx^Zu5zXFADNZ}DAb$~a(ydb6pk+<2f%xsU7SO#j%VulKO-nTo!zJ0Ul z!MK4wM$TA_O#cYeKVF=}hF|?EtrCn-nE>S%A0-tRi zv6A1*j7sQ^&E(d+lqdIT>0$q2$GBq_FF#=~zuda~{QN&o|IYKFSwD**y@>R1s`o&x z`x$O+PotiNutHbm`5unw0r-^FfUDJeWVMtSdfZfcCN4rtulTA(ALGrylqK>W#TlkM zJJpI&EW&A;965Ogwjo_h6N#~(t29`%ZF?9$KT~$ECO@+DQB&qg>;W|i&SGXP&5CDp z_b??`4;W{_%JMy(W5^=@QPqZMl&D@nitfi*pYfwhlXVe)fLaWs2#b`Cz$uTuma~XL z@ZMM(%|4y=e06UUIS1kxZ-Kfe>|yf56;9vCIf`Xg1c&s4rYr5TlaE*%^nXnE_~e~0 zjmX*T*|WV;=JK#z4?qB85i#_o61-wYYQ?RW$`rTtx?Z2JT{k{;xAEtB`LMKr!GxyQ z!GQs~(-Zx>g&$#=N{qK%wlk`;v?OTf=TFvnmKNuI=#ln&(bY0M-Dl(R^R!ywCoKp0 zme|RgBU9dKVRdzNBXz@RM>y6CW8V`3|1HwlP+5Q6Ip9yUvvXzb@a4l_>P0^|3cQ`1 zoILzeIW<=aF>%{&`Z?JJe#-2<>D!W6$!)Q^(uW;-Ka2;7iY@Eh<$t(XD_>*It4txx z@5XtBsZLViYq%S(uaw_AXtMA9wMpmS&cek5ZI;fT@5mQvsTC|PiT6IZ(((Dy=8`xu zPcuEeZ>jdS%8N$3yDu5ZUEu9EknC5>U4H7veYLCoLM_3XB|6imefr)dxgI+&mpjrJ z;?ZL7U2G88rreugkiTTA?`?rp)jg?}hg@6-yupi#r?(UY9K^5uz|Q;cSfn@3jNWN^aw4iQZoG0Hw(-o_$YS ze?2|%yQ#P0i+N?U=Bdu;Dvx=8`}?^jeg2X@wg33>FJ~7S`FHl-7zxgwj@va9l3>^; z{h%ezvo2!3nZPdxDxJfb{9e2Mo6$P#_h@Iz!@#I@Z7u_?pL@HP%nf&(Xe_vD2}x(c zB@7GB8RI0mgQ<4JYEAm|wSRr|<%qEUh=ojV{Etk7m}?G48{47^{3v01-G6ThlJ+_= zZqiXaQFOol@|lA&(XoCA(@T*!=x)74wIv;&M^!Wr^6yOV`IY_q+u7cq!m?tk56Xz7 z*Lz##4o}K&364M29y>73O!>HKH0V!F_@|4J=2N@IdfO+b_HXS>AFgXtt9HHbIE+|p zY76-?uI^iJ%nZPGjQnQ+){@|DR?~MT#<_0yp~5bM9F>Em`l}8vd{=xUGJMnU;Nbn1 zca|sW>-@W}%Or*;sOB%3Oet@B7m);!v_{ZGg283OtVzm^$;Ou67o}768mBtH{}D#{ zc{z?lK-dl`||TM*r>9c!O+w1|s2MOqVK*fh}D#+YfxwNUFb z82-(xu1k02k5<4n1cBZyG@w7;nT~~B^x4C->Da;g)!b(7*FNC^w}Qzbc4$U|0qNU@XwzYX1*_QuGF`-aV)s;WnKjEeZ&h;IgPxn>2zDj^ z+s3slD5$&1A5k~r1Vm#kZpPv`_u-yzI82wbF*IcZZ8+Zo0-p5}A~eq&_4kMyzyi-= z8_`3wdZEL(jEQBD4gatuVqDL03B_`gR>5 z>;C4r8H6FwZ@?lq1hPKU*+~O*4~iBX(e1{afV0t}T-%cSz{<58UR$wa8PSuA^pry+ zQ{INPN4^#Qb2cPL|9btz!2>nmQfjPQ_kD1WIB-BSDU3tPU*`Xg(BeMZdrvPd(e3CQ z$hI^un4bLL(dnUB$QQm|^IM8-<%7MU8-cy9@Mf^t>g|0*l3TZNvUZ33roN_4dLn^I zqH`-+_E;!+oDVxbB@MA89jPz%&2FyN>V9#^cw5!6@LjnhhuZAY3Vs$1)c*MYmalu7 znRH%$>SRDmRZr26{*tXR0|Nv14D?W^h^{Im70;sK+o#kI#F5s!;g{S4Ca*@XIxxL! z^!sa#z_E&58Lj7gKYd8sC2vuB`QP!DI1dlc)5a@&`(90yzL^kG+gkt0v_vg-`p3GS z38BV!iU|)hW&QqyABvSY6z97+_ouK!LjEru=87|lD3g>OAihTZ3VHw%?VXRQo1;}H zu9)DpuQRJY|LN$}BIzdz!P zbIV1sre4MPm{cCV z26Ay`a>_RPa^`xIUUT+$e%HusTFUP}wW2vbKK{bqp!EL!QUiI%srQwQ{X)%^-6z>> zUlvG|KJ{K})qqcMTL3zYO=WF$FVP#qMGjVZO**%P%Ak;r2{4$xRNXHAbA7*5zK5Mpfj!Y{*!L zEXwz3o%*u>leKztd;1D*Y1%CE=frI!y%D)AZ$+S@<|wbtyQ9UI7l=u!WK+5w^VeJr zU$0o-rZ=7I*?2>~My9_+dx_ih=koT21$Zvtn1=t*72_{bdVXGh?v9R*e(|~r!(Dlm z4)6AkjkGoDl-Dc8#UE`tTxqX(KTsji?e5Kg2|5q`&dDEEgjDaV|1~>^C>V+uU6Ze2 zn`^}k03boN8?re!kEhSYYd$O$MRfzcQlo0xD`+_*v){K8^oQ@s3dr?kaz)Y+o?vBK zgzo8*-iTOmsrCs-FOE7ck(ft`@Fotj;|=i*RDx>Zsufp3#8^cKbXUf5P+Hq}ZjD3! ziZCLs#a`f@Cqfg-*o7sCM(0_~L5Z;Z2DB4A9^AnS$0!k!#FL0}gc=RG;UATor*VB% zD`|n`;V&(R^q0ri3U=wK%#`R1frH1 z3r3wD%zywmdl7I}T2BdD&(P$TK$A-z&aAklNX+E22^Q+GhLcdD9cfg=f|q?@dLtB* z-q@~|5TleGBQ~)^7V#ZGiC@oF+=BgT#ne|}FEEl3O4yj8wWzW=L}!fFyz_w)xjtK2 zJ`5y^7osmLe-4!B9JItj#IhB{CKe@tiT+W$jT3_WOi~>Q0pC-&Vk+hSg5kQ~h!^kP zyQRHttHvxm4=9p+nznu(#bO?k!CPIbE3FvswKez8Kke6katBn3)7?Eyr-kMh+T{Bg zclA!V2gk7UfCyGCwiCZBvyem6fNciDHT@nvedFgGN`6f|t)Eyn)z(;AJe-=BGt^q` z+p?{woLfGi7CX7)zlKlL|2=$~Tz{aQ?lHKwvglBATui*YdQ+T7+kvR0z}bO*?GKWg zybH$dJ99ej-Bw$VyM!nm>6${-ZcTp#}XC|mPWdAIRX+-K7 zr@rqUU&qPwo%%oI`J{zU)dyy+v2q-q>?us^o=#ggdRP8p!FYv1;nn*E-@g}{&y)rA z$JAv+pEq}v8=DeFtj4Z1|8oBP&iVKI;-&{l38@m`mH%h_WD|C(#-n&7rYS5`E5A3i zP3IM4t`->O9@$xQs=6Srd~uU}&P-JQBe;JaO>tYah-C2Izs9b#!Y0!4J^GFIDu1Pn z^-PD2cTfMGA2<-UP$JFu)Av8-L!`}Y2L@3!S9q)D(TcRo-lHLbE&voCwQPIt2iY~^|JTW51n#PbMh+jvbAh^ zC6aiN@J!#Oh_2z=SM8h-s93Dh!P9o$z-!ybXbme6sx_f8w~!n-z*__$@^H`CeXBuT?8NCue>;7Q*6{dz zTBz}V$pgL{J{kG^u2HyIPgh5WiP;w;Rum9U!LRUWAq0Tc7oQXjC0QAEyQC31g*{rHfV ze-Oh&g)&1HVfkwSwCd!%7z)P!PWy=}i00-6Gz&gD8pcL*^}Ih|aU>wrRen~Mh&J<~ z&zyXSHUd^g(9lq_Cx%;tZ{&@&few|_iW4y_vBlAoYYJF(Z6TyqU5c5J9amCPUrMmC zdX>R8StyzZpo5~>gzYi2NVOH(Hlj`)mQIMq62+|bEe;}*b!+XBeLw#(wD%l^x|Ad+ zSI?Du241)J^z`hJouzu6_KwXr0OpDRFmNs0298HqS1P@3Q1a=Y=fNTF_M=@RkEVNH z`FOp#^!WT&1Nv2=XgM}$^gba-Oq_qF%eHfFED>-Q znb;|>EbqTA(LCW+kk;^jGkq`S{+~?WWWj`W?r#mXuLwo(M%6O(2~+e{!;8 zU-Ovv$;kej=VfGc%wwg(4SF{rbv4ApoSGb(z8pC8LnkP`@pkr*j?J5m!g?}Mf&I49 zF(Vi+SExV+8Ih&r8#C;m>ajkL7Eb=q3sN1HI%cQc&CR>k`1yTHm_64m=g<4kkPO>b zEL$%nzy7Ctx#oChUgel~l)-$h`iZo(j6Zm3#3G4?YA%cQ^lhX+e+!5;Hma@MVlXc* zV5MJEQ>{_%BZY>%;i@B(I`1a0Ossl*L;D0Y=c0nfXe1?qFH2Vv%upbN%NN)`_HDa= ztN!us^UwqnsJt_+?$4pCashvCuWQsxKN76@;MlyZ5=b>|rr`rCDIDAy9j(zsi=NEC+nCogO&#mcuxzZ3M(|dPEiI)YVDIR4Bvh$Es>bl?V=C1c`xg2Vg&y!PUCJ{G81!xV= zY~uJYIYhScD~(M0mH(^h(>sqoJW7)h4qRGrY^+4SUT(VQWPRxG58W5j0!AC9wA^aS z4wm5TjiB+5E73+t1)Shio0S5ISTXG3@Qs|RyymseO-!D|;*s8_4%f699zXvkt6C_H z*`2iO=NNL(Mf4i}zX`DknHBZ2{L>4vU?rE)QPm+m>VDgmOwdoB+(2}$E;RrMB^WW$yFWhLo8;~CLP?0 zJiiX*Bjo~K0=B)ih`JGS#tW_X&`N+9KRm^U1Ypa1<^B|SPr3)e?=8brB&h!PlqY=e zbND`fsC&+**-ezE53R>@uLwroD?1$|YeM^?*6ZWkZHM$#0?{9CfF-ygSHeaF@`E8m zO1pKIFrhS0a_0DD(pN#|0lU9EISE08TsaWX#{B|WwPyENZ8P5f<>EH&`dYcVpCp(MoBMe>Dde!7DdUw#O?kue^Es>L;wglY4k{)<-68jX_*h14FK`R8_Y zClua16c;CVXw>UNO4b_);NHy-cb*>md!$#^`{4S=`E1s`q`=q%OU_m<)9L&=OHCg$ zZwqrD^)@FZ3_TsG%e1MKoBlJL5LTIg%I+hSa*A|3@!TN*yF)Nq9O4-wSyk- zV_$fb>Y3~-ck?f7d}Clzobx|fw(g!D^J;sK-_~uVD=dXCKT3O{slU3XW?+K0py#rQ z+DqLo=X*&ZYEvgBy57knrXlEFBA1sU}v|Eqx=aB`yT)a|3LjGlfG9sJYq z=?B%ow$Zcw2GT8Y!GGzF{<1{cCu-97ABKO%u_IT+&iVgX%O$-0R%Xn` z+AVj5nSNX0clUz8FW!gD|2Gr%abx+vEZA{As3Jw>j_b%=K7z_7$_I!wdjDqa?Bw2Ngs9ti(2zaeQzv1QvX zgcLlXOm6Xl!dYmzw|sAEicL9F*>R-N;OlU=J5Qi1%2({e{GQKM&RY9mW`0m1xM29g z@W<52ANTgWUN-ltcW{*IVh};oEOaE_?`5^hFTX|eV=YEM2EkT!c`qv&1T_reb(ExZQP6avv|K#n zw)`}0A@T;e_e7ulOAr^IwPBwMLmRfLPyj9T<{roB(Q^fpLkM&~2qygQXW)Gr@D?SQ zq_nt<2K6MujYKxS;m6xZ+Mb1=yQTZfk(3taQQxAn;}WFfulKv*K%$$|7YklfhO{8G zpko}(dMqo-vMtwMGqv{^&1fuVCHbD`^ZAXBjs0`xFM%OiU}yaYJZZ?jU-jgZUc-_y zS>L<06!NqAWvrfi*6K?DUrPjh;|TN#p=3^X0#z2WZn9f$Apn+=T-op@n?nRsYK+$2 z-X_r=;&?j=+0~Plhgn9dkKInm5C!WYs&Wh?4!fOl4J1Id_!IF9TzESry$H}-^r;39 z>F|O8V+Aa;LQ{(q%(B`kU7*>*ZD&a!2eP5U{B&iU3|rf*#|br;!{zesG{Ucva}G&m!~P7UHAi3#(uT!| zAr-NEj$EJx$F1a(JpyT&Eb%U`r*47o5|ik;1m&;R3|cRXAkb?}ZrC}1_ZxeeF}I*N zVi>~mhSV%vytEfNp~YJlxSr-#Yaz@==tN^g61aRny#xA zm~BRn-z8bLgsurt9V@KBZ8^%vRVx^2LonVoSRu_V2K*UDjIKPGulRN56|G;}8M}z< z!cV)-^i?)MgU;DqiE|A7*jPbWnBELL)7(|_V>j_GH$@l<0IGt%mRRdGng`CN0H2FB zfNEVf30|q16Yd)zpaRcS(~^Y%)cq?M?l{EsA=tLdqe2Kd`u-&h8|(oU?(H=^-`;)! zC3A)?73+ItcZOl)PoTxK*ooa4#==3_Tq4?u%iS5qBGFo+H;{+BGYng5CleTbzXDfh z7>jR^%H+Q~!%$A9ZzgAan%Nn~+S?+5+MF`8GYnnHSLCfXq2x@&LN0UxABqEKhed5a z%w`VcN0~mLH}Hm^D|7^NPcPdE<0-M)OUhJ6>{ouc0-pxr=M;xvJ=P}MG4 zb`HTNR}&a|c(|~V3pSk2-2}$MA$04R(PlP*u^!k6WpDUb6Bz5kCtexve>H)jXK^}1 zEl%M5pPIlT(g-!2!UhWkR0ZHK8I|-M+{mR;bfCDUW2>jNjKQ6tC9i^V|LWgg*MohU zVVjP?^doyG#VG2}X!F^)C2K?bwKw612#kEcL91kMp$)k`@1h^ek(uhbv`5$*(6R)* zad$OEQ0z)6HDC$P)_Y*}jE|vR=YZ81vJ5N7HW!S6>?|!AoMxhbP(dw$;W#ZV^l}lP ziqTGLM&kEWptSv7t>xHk8ucXa$5;a04fd?Ps-=n#BY}SOYUp->*@aqb*u!vDcbM6OrQ@!MCJFl+vJCA&LV(` zvh~2ap5HcC?O<~EO^~&f09)6poyeMkc+Lb_keUPv$t*-VoIvNF#aSo^!8x%u0`3Ca zGCSF>PY#0e;@V`GnEa6Di!0_Fh=ROB>zK&<7J9_hn99pp0mGK{-_d0mTS?6<$1`Uo z4Eqvi13D2Ptu``OA{#KqL2`#Hsl3=J=+A_rZ|J=OdsNc#9-K=yeIkpupRXTl8Oux#}ukj^=|dDKhr4UH})AbUfb1=orc)j4dQrR$+EMWFb8BsqZml zTv`Mo0aM-QiQ{f`cuF^7VoH6~=5_P{K*B zi!CU?RMe3BR`{U-wS5YVTl+vUz1gGom5cun(K-8QBSE$1>WUe8DDo{)7k7eNB5C+v zs%9=IyWZ)m<1N{SS$tT zw>-MP6NMf`fK}P2p9bi8ZPkBc`{9*+xD!fHt-JLAhaRi!Ga}&Vi=s?Fql827%c|@f zg1rjKIl-noNH$^smp_4{H@varR^!MOu&1C=_oXGYvk8Z&f)(_C!j}*vg9K)}^W=6> z7ZpO~>eHI+J<%T&J~)a{NCOKXmK;DvDhzgv&3tcre@6Qu^)|vPDpC9TmoVmW(f<;t zWyxc2DopT*g(YqlR6rIjBym6VZYw)^;V;nl0hhnA)3OGw&afpF;oSt{z~!o)wTsfgtO^8%fP@@eleS4Vq^lQ{AMbN~|UCf2Uu!M3A* zBOzCgppz3Se=F}AvpH_RBI6LKa^Ui)0}7w;%sb4SPa0clu^5iNDdeyc7q&8NsP4ZZ z3Hu>4{@_NoP=E?MC2%cOo|x@r;<|xrI`|5)RA-`3wwxWeRsv%4g8r4>wbYcGZd67# zfay`0`sF&9NG^4C7BrhsUcjZEnRmRF=Kz=k5>^IuGkz(q!wUeI^H$zj0oNd%(&IevTYEmpw&Z)#VVM-U&vLP z1t!&>Jb=#l4O;F)R^q`;7*<@YnGi^bEWZzDe1Bl=3XBv;P*P(|qwWU2eU|#z7ne1x zF1Z!Vb(D{XDOX{Y4s}V@ELf#`aP5p%oC;-&u~VeuZBgIJY#0&tpoo4yMKIfgRJ6G% zG!OJj^{!27oE;JS;0w6wdwk&Kp*sfn2@$khgL0(m*Aut4{p%9T519TeC3zINGN_Mi z#@gYU1x){W=hnQLcUTy)K+pA+UmqiweM}q2U$_r4Zt^l?*=FSJP@!y$c8~)m*foD$ ztA(DV>;mmMiettvSmmAM_W{=H>?OBgC*!A*QobJzgbAK>5qckg1CW?7 zV1BRWbPjomt4hL>w*&Ll5fS04l3-hz5z`k_xvM1DxF)fG(ab6dCbmY*ZRdbDHd1G1 zx2X%r4tjjVk*~{A*&>eopbGFRNzhEPgA#Eq$2t(}V+_|BH&<{^XOx57-j6ES8mWb+ zMi5{hs)A$%@0@@%>x&4`yXUyU*3=GN??NokvJm3 zZ-9`SahcSNALctmBks?Rv;KUq;DniZ=6nd==F6C|xP$(TKbnmJ*mN6Vi)u33i z?l;XSb%+~L?=Jy_FCqBecc5weXvdURf^oyNdrfvkm+i15C!!J9%>|zD^O{;DG*mmsnvMF$+ zDdWXioFlRHGSw57@pS9vzly@W3S;PA_Maho2hwdNVZ*KF44R+e+UL(mbBF0MC4T#(~_Dy=2sVF?E3 z9w374vI=1S-9oJ_`-ebE)d3=$THcO0gFggXIYcl8HjJU1M8l2U{H^#`K=3p(i#@v} z%CHgwJI&1Et17IglEO(dv&fL4%HTsFFMK1c1hTxVM( z6kXE5RGRzRAI6c!I1&nI^oQ{wzxGgeWWu5ewHn5sN47^G>{x@dBG6q3Y^;zql6+(M z61~~p@qRF-hscj?5fr(wTP6wgPM}if(Lq)ra-momR=|7x%IuA0L4oLykD=Ef(ddNr1n!oS8%lOdv$}APu4uqqq1I%; z)^<1iio7)M1j{du0}0bnannUGjYt+5O$4-_hS|Jc8*vgpRB;-D39Vr;EbGm#8}eI0 z-cta(HR;E7ZyW-E1!DNEN!)hB25^3*-tBX_G5&s0M8R%Y-BwIurm~nt zlAXg`P?2VWu>^)Iizsj`BrRqD3 zI8Z>G%9tP>rl zgCqwd%t4$JI_@ap=ceBgOpbxBL?G!4`-NvY=vp36yCm=nOlL`dJL-P2HXzU!3nZ^? zflW&!xAD3vfY>xUNU}o<8e!xI5{Jzzl91`Ft_yhFACikeMQArEDgaFVl2IviUDL|$@;t(;D%}SW>v;y&1M#U}gj6U4wge>Q` zt@jZCBeaNv*dfmX^g@|-msk`S6~#Jbp@WHFmmy(^T>*&TBd$eWpc3KeB-LA2ap-F$ z)RDx>C8z^Rde&N*;`+8sh1dl5({_PYd}5Mx9Or)`DNa&-F?})UE<*>sOfG$nd@W1@ z443K%Nr~g00Ss}X%cqu;j`voU&Bl2Xr4eH;{RXrIO&-DWN;uMyVxm^T?CzOTK>~fo zyXSGh^@j^NXMqkb6WH+^T$)uQwR0H@#&JN_&l}rbk#p1-kRQM|HUusY$#!*JHnSN9 zz7|TbD?4k6T!PG5;)EAys2+f<#?-j?2(lPoNJW^#E(mm%^c%%4K58pEK`{z;u, + parameters: { + docs: { + description: { + component: ` +A flat virtualized list that renders large datasets efficiently using +[react-virtuoso](https://virtuoso.dev/), while exposing full keyboard navigation. + +## Accessibility with **\`listbox\`** ARIA pattern + +This example uses the **\`listbox\`** ARIA pattern, which maps naturally to a +flat list of selectable options. + +### Container props — \`getContainerAccessibleProps("listbox")\` + +Spread the result of \`getContainerAccessibleProps("listbox")\` directly onto the +\`FlatVirtualizedList\` component to mark the scrollable container as a \`listbox\`: + +| Prop | Value | Purpose | +|------|-------|---------| +| \`role\` | \`"listbox"\` | Identifies the container as a listbox widget to assistive technologies. | + +\`\`\`tsx + +\`\`\` + +### Item props — \`getItemAccessibleProps("listbox", index, listSize)\` + +Spread the result of \`getItemAccessibleProps("listbox", index, listSize)\` onto each rendered +item element so that screen readers can announce position and total count even when most DOM +nodes are not mounted (virtualized): + +| Prop | Value | Purpose | +|------|-------|---------| +| \`role\` | \`"option"\` | Identifies the element as a selectable option within the listbox. | +| \`aria-posinset\` | \`index + 1\` | 1-based position of this option within the full set. | +| \`aria-setsize\` | \`listSize\` | Total number of options in the list. | + +The list uses a [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) +pattern: \`context.tabIndexKey\` holds the key of the item that currently owns focus. Set +\`tabIndex={0}\` on the matching item and \`tabIndex={-1}\` on every other to keep the list +to a single tab stop while arrow-key navigation moves focus between items. + +\`\`\`tsx +getItemComponent={(index, item, context, onFocus) => { + const selected = context.tabIndexKey === item.id; + + return ( + + ); +}} +\`\`\` + `, + }, + }, + }, + args: { + items, + "getItemComponent": ( + index: number, + item: SimpleItemComponent, + context: VirtualizedListContext, + onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void, + ) => ( + + ), + "isItemFocusable": () => true, + "getItemKey": (item) => item.id, + "style": { height: "400px" }, + "aria-label": "Flat virtualized list", + ...getContainerAccessibleProps("listbox"), + }, +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx b/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx new file mode 100644 index 0000000000..7373092a94 --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/FlatVirtualizedList.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX, useCallback } from "react"; +import { Virtuoso } from "react-virtuoso"; + +import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list"; + +export interface FlatVirtualizedListProps extends VirtualizedListProps { + /** + * Function that renders each list item as a JSX element. + * @param index - The index of the item in the list + * @param item - The data item to render + * @param context - The context object containing the focused key and any additional data + * @param onFocus - A callback that is required to be called when the item component receives focus + * @returns JSX element representing the rendered item + */ + getItemComponent: ( + index: number, + item: Item, + context: VirtualizedListContext, + onFocus: (item: Item, e: React.FocusEvent) => void, + ) => JSX.Element; +} + +/** + * A generic virtualized list component built on top of react-virtuoso. + * Provides keyboard navigation and virtualized rendering for performance with large lists. + * + * @template Item - The type of data items in the list + * @template Context - The type of additional context data passed to items + */ +export function FlatVirtualizedList(props: FlatVirtualizedListProps): React.ReactElement { + const { getItemComponent, ...restProps } = props; + const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList(restProps); + + const getItemComponentInternal = useCallback( + (index: number, item: Item, context: VirtualizedListContext): JSX.Element => + getItemComponent(index, item, context, onFocusForGetItemComponent), + [getItemComponent, onFocusForGetItemComponent], + ); + + return ( + + ); +} diff --git a/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/index.ts b/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/index.ts new file mode 100644 index 0000000000..48f9f25996 --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/FlatVirtualizedList/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { FlatVirtualizedList } from "./FlatVirtualizedList"; +export type { FlatVirtualizedListProps } from "./FlatVirtualizedList"; diff --git a/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.stories.tsx b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.stories.tsx new file mode 100644 index 0000000000..d0f6ac7ecb --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.stories.tsx @@ -0,0 +1,218 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Meta, type StoryObj } from "@storybook/react-vite"; +import React from "react"; + +import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "./GroupedVirtualizedList"; +import { type VirtualizedListContext } from "../virtualized-list"; +import { GroupHeaderComponent, groups, SimpleItemComponent, type SimpleGroupHeader } from "../story-mock"; +import { getContainerAccessibleProps, getGroupHeaderAccessibleProps, getItemAccessibleProps } from "../accessbility"; + +// Calculate total rows for ARIA props (group headers + items) +const totalRows = groups.reduce((total, group) => total + 1 + group.items.length, 0); + +const meta = { + title: "Utils/VirtualizedList/GroupedVirtualizedList", + component: GroupedVirtualizedList, + parameters: { + docs: { + description: { + component: ` +A grouped virtualized list that renders large datasets organised into labelled sections +efficiently using [react-virtuoso](https://virtuoso.dev/), while exposing full keyboard +navigation for both group headers and child items. + +## Accessibility with **\`treegrid\`** ARIA pattern + +This example uses the **\`treegrid\`** ARIA pattern. A treegrid models a +two-level hierarchy: group headers sit at **level 1** and their child items sit at +**level 2**. This lets assistive technologies announce both the group structure and the +position of each item within its group. + +### Container props — \`getContainerAccessibleProps("treegrid", totalRows)\` + +Spread the result of \`getContainerAccessibleProps("treegrid", totalRows)\` directly onto the +\`GroupedVirtualizedList\` component to mark the scrollable container as a \`treegrid\`: + +| Prop | Value | Purpose | +|------|-------|---------| +| \`role\` | \`"treegrid"\` | Identifies the container as a treegrid widget to assistive technologies. | +| \`aria-rowcount\` | \`totalRows\` | Total number of rows in the treegrid (group headers + items). Because virtualization only mounts a subset of rows, browsers cannot count them from the DOM — this attribute supplies the true count so screen readers can announce e.g. *"row 12 of 53"*. | + +\`totalRows\` must include **every** row that will ever appear: one per group header plus one +per item across all groups. + +\`\`\`tsx +const totalRows = groups.reduce((total, group) => total + 1 + group.items.length, 0); + + +\`\`\` + +--- + +### Group header props — \`getGroupHeaderAccessibleProps(index, groupIndex, groupSize)\` + +Spread the result of \`getGroupHeaderAccessibleProps\` onto each rendered group header element +to place it at level 1 in the tree hierarchy: + +| Prop | Value | Purpose | +|------|-------|---------| +| \`role\` | \`"row"\` | Identifies the element as a row within the treegrid. | +| \`aria-level\` | \`1\` | Places the header at the root level of the tree hierarchy. | +| \`aria-posinset\` | \`groupIndex + 1\` | 1-based position of this group among all groups. | +| \`aria-rowindex\` | \`index + 1\` | 1-based position of this row in the full flat row sequence (headers + items). | +| \`aria-setsize\` | \`groupSize\` | Total number of items inside this group. | + +The list also uses a [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) +pattern: \`context.tabIndexKey\` holds the key of the element that currently owns focus. Set +\`tabIndex={0}\` on the matching gridcell and \`tabIndex={-1}\` on every other to keep the +list to a single tab stop while arrow-key navigation moves focus between rows. + +\`\`\`tsx +getGroupHeaderComponent={(groupIndex, header, context, onFocus) => { + // Flat row index: sum of (1 header + N items) for every preceding group + const index = groups + .slice(0, groupIndex) + .reduce((sum, g) => sum + 1 + g.items.length, 0); + + const groupSize = groups[groupIndex].items.length; + const selected = context.tabIndexKey === header.id; + + return ( +
+ {/* Direct child must be a gridcell */} + +
+ ); +}} +\`\`\` + +--- + +### Item props — \`getItemAccessibleProps("treegrid", index, indexInGroup)\` + +Spread the result of \`getItemAccessibleProps("treegrid", index, indexInGroup)\` onto each +rendered item element to place it at level 2 in the tree hierarchy: + +| Prop | Value | Purpose | +|------|-------|---------| +| \`role\` | \`"row"\` | Identifies the element as a row within the treegrid. | +| \`aria-level\` | \`2\` | Places the item as a child of its group header at level 1. | +| \`aria-rowindex\` | \`index + 1\` | 1-based position of this row in the full flat row sequence (headers + items). | +| \`aria-posinset\` | \`indexInGroup + 1\` | 1-based position of this item within its own group. | + +Both \`index\` (flat row index across the whole treegrid) and \`indexInGroup\` (position +within the item's group) must be computed before passing to the function. +As with group headers, apply the [roving tabindex](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) +pattern using \`context.tabIndexKey\` to keep the list to a single tab stop. + +\`\`\`tsx +getItemComponent={(_, item, context, onFocus, groupIndex) => { + const group = groups[groupIndex]; + const indexInGroup = group.items.findIndex((i) => i.id === item.id); + + // Flat row index: skip (1 header + N items) per preceding group, then add + // 1 for the current group's header, then the item's position within the group. + const index = groups + .slice(0, groupIndex) + .reduce((sum, g) => sum + 1 + g.items.length, indexInGroup + 1); + + const selected = context.tabIndexKey === item.id; + + return ( +
+ {/* Direct child must be a gridcell */} + +
+ ); +}} +\`\`\` + `, + }, + }, + }, + args: { + groups, + "getItemComponent": ( + _index: number, + item: SimpleItemComponent, + context: VirtualizedListContext, + onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void, + groupIndex: number, + ) => { + const group = groups[groupIndex]; + const indexInGroup = group.items.findIndex((i) => i.id === item.id); + const index = groups.slice(0, groupIndex).reduce((sum, g) => sum + 1 + g.items.length, indexInGroup + 1); + + return ( + + ); + }, + "getGroupHeaderComponent": ( + groupIndex: number, + header: SimpleGroupHeader, + context: VirtualizedListContext, + onFocus: (header: SimpleGroupHeader, e: React.FocusEvent) => void, + ) => { + const index = groups.slice(0, groupIndex).reduce((sum, g) => sum + 1 + g.items.length, 0); + const groupSize = groups[groupIndex].items.length; + + return ( + + ); + }, + "isItemFocusable": () => true, + "isGroupHeaderFocusable": () => true, + "getItemKey": (item) => item.id, + "getHeaderKey": (header) => header.id, + "style": { height: "400px" }, + "aria-label": "Grouped virtualized list", + ...getContainerAccessibleProps("treegrid", totalRows), + }, +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx new file mode 100644 index 0000000000..d8d050601c --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/GroupedVirtualizedList.tsx @@ -0,0 +1,242 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX, useCallback, useMemo } from "react"; +import { GroupedVirtuoso } from "react-virtuoso"; + +import { useVirtualizedList, type VirtualizedListContext, type VirtualizedListProps } from "../virtualized-list"; + +/** + * A group of items for the grouped virtualized list. + * The `header` uses a dedicated `Header` type, separate from the `Item` type + * used for the group's child items. + */ +export interface Group { + /** The data representing this group's header. */ + header: Header; + /** The items belonging to this group. */ + items: Item[]; +} + +/** + * Internal discriminated union used to bridge the separate `Item` / `Header` + * types into a single array that the keyboard-navigation hook can operate on. + * Discriminated by property name: `"header" in entry` vs `"item" in entry`. + */ +type NavigationEntry = { header: Header } | { item: Item }; + +export interface GroupedVirtualizedListProps extends Omit< + VirtualizedListProps, + "items" | "isItemFocusable" | "getItemKey" +> { + /** + * The groups to display in the virtualized list. + * Each group has a header and an array of child items. + */ + groups: Group[]; + + /** + * Function to get a unique key for an item. + * @param item - The item to get the key for + * @returns A unique key string + */ + getItemKey: (item: Item) => string; + + /** + * Function to get a unique key for a group header. + * @param header - The header to get the key for + * @returns A unique key string + */ + getHeaderKey: (header: Header) => string; + + /** + * Function to determine if an item can receive focus during keyboard navigation. + * @param item - The item to check + * @returns true if the item can be focused + */ + isItemFocusable: (item: Item) => boolean; + + /** + * Function to determine if a group header can receive focus during keyboard navigation. + * @param header - The header to check + * @returns true if the header can be focused + */ + isGroupHeaderFocusable: (header: Header) => boolean; + + /** + * Function that renders the group header as a JSX element. + * @param groupIndex - The index of the group in the list + * @param header - The header data for this group + * @param context - The context object containing the focused key and any additional data + * @param onFocus - A callback that must be called when the group header component receives + * focus. Should be invoked as `onFocus(header, e)`. + * @returns JSX element representing the rendered group header + */ + getGroupHeaderComponent: ( + groupIndex: number, + header: Header, + context: VirtualizedListContext, + onFocus: (header: Header, e: React.FocusEvent) => void, + ) => JSX.Element; + + /** + * Function that renders each list item as a JSX element. + * @param index - The index of the item in the list (relative to the entire list, not the group) + * @param item - The data item to render + * @param context - The context object containing the focused key and any additional data + * @param onFocus - A callback that is required to be called when the item component receives focus + * @param groupIndex - The index of the group this item belongs to + * @returns JSX element representing the rendered item + */ + getItemComponent: ( + index: number, + item: Item, + context: VirtualizedListContext, + onFocus: (item: Item, e: React.FocusEvent) => void, + groupIndex: number, + ) => JSX.Element; +} + +/** + * A generic grouped virtualized list component built on top of react-virtuoso's GroupedVirtuoso. + * Provides keyboard navigation (including group headers) and virtualized rendering for + * performance with large lists. + * + * Group headers use a dedicated `Header` type, while child items use `Item`. + * Internally, a unified flat array interleaving headers and items is built using + * `flatMap` so that the keyboard-navigation hook can treat every focusable element + * uniformly. + * + * @template Header - The type of group header data + * @template Item - The type of data items in the list + * @template Context - The type of additional context data passed to items + */ +export function GroupedVirtualizedList( + props: GroupedVirtualizedListProps, +): React.ReactElement { + const { + getItemComponent, + groups, + getGroupHeaderComponent, + isItemFocusable, + isGroupHeaderFocusable, + getItemKey, + getHeaderKey, + ...restProps + } = props; + + const groupCounts = useMemo(() => groups.map((group) => group.items.length), [groups]); + const items = useMemo(() => groups.flatMap((group) => group.items), [groups]); + + // Build a flat navigation array interleaving group headers with items. + const flatEntries = useMemo( + () => + groups.flatMap>((group) => [ + { header: group.header }, + ...group.items.map>((item) => ({ item })), + ]), + [groups], + ); + + // Build both index-mapping functions in a single pass over the flat entries. + // mapScrollIndex: flat index → GroupedVirtuoso item index (headers map to their + // first item so scrollIntoView makes the sticky header visible). + // mapRangeIndex: GroupedVirtuoso item index → flat index (translates visible-range + // indices back so the hook's PageUp/PageDown and focus-restore logic works). + const { mapScrollIndex, mapRangeIndex } = useMemo(() => { + // Map each flat index to the corresponding virtuoso item index. + // Headers map to the first item of their group so scrollIntoView shows the sticky header. + const flatIndexToVirtuosoIndex: number[] = []; + + // Map the Item index (from virtuoso) to their position in the flat list + const virtuosoIndexToFlatIndex: number[] = []; + let virtuosoIndex = 0; + + for (let i = 0; i < flatEntries.length; i++) { + flatIndexToVirtuosoIndex.push(virtuosoIndex); + + if ("item" in flatEntries[i]) { + virtuosoIndexToFlatIndex.push(i); + virtuosoIndex++; + } + } + + return { + mapScrollIndex: (flatIndex: number): number => flatIndexToVirtuosoIndex[flatIndex] ?? 0, + mapRangeIndex: (virtuosoIndex: number): number => virtuosoIndexToFlatIndex[virtuosoIndex] ?? 0, + }; + }, [flatEntries]); + + // Wrap getItemKey: dispatch to getHeaderKey or getItemKey based on entry type + const wrappedGetEntryKey = useCallback( + (entry: NavigationEntry): string => + "header" in entry ? getHeaderKey(entry.header) : getItemKey(entry.item), + [getHeaderKey, getItemKey], + ); + + // Wrap isItemFocusable: headers use isHeaderFocusable (default: always true), items use isItemFocusable + const wrappedIsEntryFocusable = useCallback( + (entry: NavigationEntry): boolean => + "header" in entry ? isGroupHeaderFocusable(entry.header) : isItemFocusable(entry.item), + [isGroupHeaderFocusable, isItemFocusable], + ); + + const { onFocusForGetItemComponent, ...virtuosoProps } = useVirtualizedList, Context>( + { + ...(restProps as Omit< + VirtualizedListProps, Context>, + "items" | "isItemFocusable" | "getItemKey" + >), + items: flatEntries, + isItemFocusable: wrappedIsEntryFocusable, + getItemKey: wrappedGetEntryKey, + mapScrollIndex, + mapRangeIndex, + }, + ); + + // Convert (Item, e) → (NavigationEntry, e) for regular items + const onFocusForItem = useCallback( + (item: Item, e: React.FocusEvent): void => { + onFocusForGetItemComponent({ item }, e); + }, + [onFocusForGetItemComponent], + ); + + // Convert (Header, e) → (NavigationEntry, e) for group headers + const onFocusForHeader = useCallback( + (header: Header, e: React.FocusEvent): void => { + onFocusForGetItemComponent({ header }, e); + }, + [onFocusForGetItemComponent], + ); + + const getItemComponentInternal = useCallback( + (index: number, groupIndex: number, _item: unknown, context: VirtualizedListContext): JSX.Element => + getItemComponent(index, items[index], context, onFocusForItem, groupIndex), + [items, getItemComponent, onFocusForItem], + ); + + const getGroupHeaderComponentInternal = useCallback( + (groupIndex: number, context: VirtualizedListContext): JSX.Element => + getGroupHeaderComponent(groupIndex, groups[groupIndex].header, context, onFocusForHeader), + [getGroupHeaderComponent, onFocusForHeader, groups], + ); + + return ( + + ); +} diff --git a/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/index.ts b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/index.ts new file mode 100644 index 0000000000..d905cc055e --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/GroupedVirtualizedList/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export { GroupedVirtualizedList } from "./GroupedVirtualizedList"; +export type { GroupedVirtualizedListProps, Group } from "./GroupedVirtualizedList"; diff --git a/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.stories.tsx b/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.stories.tsx deleted file mode 100644 index e2b9ef3626..0000000000 --- a/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.stories.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright 2026 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; -import classNames from "classnames"; - -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { VirtualizedList, type IVirtualizedListProps, type VirtualizedListContext } from "./VirtualizedList"; -import styles from "./story-mock.module.css"; - -interface SimpleItem { - id: string; - label: string; -} - -const items: SimpleItem[] = Array.from({ length: 50 }, (_, i) => ({ - id: `item-${i}`, - label: `Item ${i + 1}`, -})); - -const meta = { - title: "Utils/VirtualizedList", - component: VirtualizedList, - args: { - items, - getItemComponent: ( - _index: number, - item: SimpleItem, - context: VirtualizedListContext, - onFocus: (item: SimpleItem, e: React.FocusEvent) => void, - ) => { - const selected = context.tabIndexKey === item.id; - - return ( - - ); - }, - isItemFocusable: () => true, - getItemKey: (item) => item.id, - style: { height: "400px" }, - }, -} satisfies Meta>; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/packages/shared-components/src/utils/VirtualizedList/accessbility.ts b/packages/shared-components/src/utils/VirtualizedList/accessbility.ts new file mode 100644 index 0000000000..b414f16aab --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/accessbility.ts @@ -0,0 +1,158 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** The ARIA pattern used to make the virtualized list accessible. */ +export type Pattern = "listbox" | "treegrid"; + +/** ARIA props for a `listbox` container element. */ +export type ListboxContainerProps = { + role: "listbox"; +}; + +/** ARIA props for a `treegrid` container element, including the total row count. */ +export type TreegridContainerProps = { + /** The ARIA role identifying this element as a treegrid. */ + "role": "treegrid"; + /** The total number of rows in the treegrid, used by assistive technologies to announce list size. */ + "aria-rowcount": number; +}; + +/** + * Returns the ARIA props to spread onto the virtualized list container element. + * + * @param pattern - `"listbox"` — returns {@link ListboxContainerProps}. + * @returns ARIA props for a `listbox` container. + */ +export function getContainerAccessibleProps(pattern: "listbox"): ListboxContainerProps; +/** + * Returns the ARIA props to spread onto the virtualized list container element. + * + * @param pattern - `"treegrid"` — returns {@link TreegridContainerProps}. + * @param size - Total number of rows in the treegrid, set as `aria-rowcount`. + * @returns ARIA props for a `treegrid` container. + */ +export function getContainerAccessibleProps(pattern: "treegrid", size: number): TreegridContainerProps; +export function getContainerAccessibleProps( + pattern: Pattern, + size?: number, +): ListboxContainerProps | TreegridContainerProps { + switch (pattern) { + case "listbox": + return { + role: "listbox", + }; + case "treegrid": + return { + "role": "treegrid", + "aria-rowcount": size!, + }; + } +} + +/** ARIA props for an item rendered inside a `listbox`. */ +export type ListboxItemProps = { + /** Identifies the element as a selectable option within the listbox. */ + "role": "option"; + /** The 1-based position of this option within the full set, used for virtual lists where not all DOM nodes are mounted. */ + "aria-posinset": number; + /** The total number of options in the set. */ + "aria-setsize": number; +}; + +/** ARIA props for an item rendered inside a `treegrid` at depth level 2 (i.e. a child row within a group). */ +export type TreegridItemProps = { + /** Identifies the element as a row within the treegrid. */ + "role": "row"; + /** The depth of this row in the tree hierarchy. Items are always at level 2 (inside a group). */ + "aria-level": 2; + /** The 1-based index of this row within the full treegrid row sequence (headers + items). */ + "aria-rowindex": number; + /** The 1-based position of this item within its group, used by assistive technologies to announce position. */ + "aria-posinset": number; +}; + +/** ARIA props for a virtualized list item, either in a `listbox` or `treegrid`. */ +export type ItemAccessibleProps = ListboxItemProps | TreegridItemProps; + +/** + * Returns the ARIA props to spread onto a virtualized list item element. + * + * @param pattern - `"listbox"` — returns {@link ListboxItemProps}. + * @param index - The 0-based index of the item in the full flat list. + * @param listSize - The total number of items across the entire list. + * @returns ARIA props for a `listbox` option. + */ +export function getItemAccessibleProps(pattern: "listbox", index: number, listSize: number): ListboxItemProps; +/** + * Returns the ARIA props to spread onto a virtualized list item element. + * + * @param pattern - `"treegrid"` — returns {@link TreegridItemProps}. + * @param index - The 0-based index of this row in the full flat treegrid row sequence (headers + items). + * @param indexInGroup - The 0-based index of this item within its group, used to compute `aria-posinset`. + * @returns ARIA props for a `treegrid` row at level 2. + */ +export function getItemAccessibleProps(pattern: "treegrid", index: number, indexInGroup: number): TreegridItemProps; +export function getItemAccessibleProps( + pattern: Pattern, + index: number, + listSizeOrIndexInGroup: number, +): ListboxItemProps | TreegridItemProps { + switch (pattern) { + case "listbox": + return { + "role": "option", + "aria-posinset": index + 1, + "aria-setsize": listSizeOrIndexInGroup, + }; + case "treegrid": + return { + "role": "row", + "aria-level": 2, + "aria-rowindex": index + 1, + "aria-posinset": listSizeOrIndexInGroup + 1, + }; + } +} + +/** ARIA props for a group header row rendered inside a `treegrid` at depth level 1. */ +export type TreegridGroupHeaderProps = { + /** Identifies the element as a row within the treegrid. */ + "role": "row"; + /** The depth of this row in the tree hierarchy. Group headers are always at the root level (1). */ + "aria-level": 1; + /** The 1-based position of this group among all groups. */ + "aria-posinset": number; + /** The 1-based index of this row within the full treegrid row sequence (headers + items). */ + "aria-rowindex": number; + /** The total number of groups in the treegrid. */ + "aria-setsize": number; +}; + +/** + * Returns the ARIA props to spread onto a group header row element inside a `treegrid`. + * + * Group headers are rendered at `aria-level="1"` and act as the parent nodes for their + * child item rows (`aria-level="2"`). + * + * @param index - The 0-based index of this row in the full flat treegrid row sequence (headers + items), used to compute `aria-rowindex`. + * @param groupIndex - The 0-based index of this group among all groups, used to compute `aria-posinset`. + * @param groupSize - The total number of items in the group, set as `aria-setsize`. + * @returns ARIA props for a group header `row` at level 1. + */ +export function getGroupHeaderAccessibleProps( + index: number, + groupIndex: number, + groupSize: number, +): TreegridGroupHeaderProps { + return { + "role": "row", + "aria-level": 1, + "aria-posinset": groupIndex + 1, + "aria-rowindex": index + 1, + "aria-setsize": groupSize, + }; +} diff --git a/packages/shared-components/src/utils/VirtualizedList/index.ts b/packages/shared-components/src/utils/VirtualizedList/index.ts index 72476c231a..8aea34c326 100644 --- a/packages/shared-components/src/utils/VirtualizedList/index.ts +++ b/packages/shared-components/src/utils/VirtualizedList/index.ts @@ -5,8 +5,12 @@ * Please see LICENSE files in the repository root for full details. */ -export { VirtualizedList } from "./VirtualizedList"; -export type { IVirtualizedListProps, VirtualizedListContext, ScrollIntoViewOnChange } from "./VirtualizedList"; +export { FlatVirtualizedList } from "./FlatVirtualizedList"; +export type { FlatVirtualizedListProps } from "./FlatVirtualizedList"; +export { GroupedVirtualizedList } from "./GroupedVirtualizedList"; +export type { GroupedVirtualizedListProps, Group } from "./GroupedVirtualizedList"; +export type { VirtualizedListContext, ScrollIntoViewOnChange } from "./virtualized-list"; +export * from "./accessbility"; // Re-export VirtuosoMockContext for testing purposes // Tests should import this from shared-components to ensure context compatibility diff --git a/packages/shared-components/src/utils/VirtualizedList/story-mock.module.css b/packages/shared-components/src/utils/VirtualizedList/story-mock.module.css index 87a9346ef4..44b1e35161 100644 --- a/packages/shared-components/src/utils/VirtualizedList/story-mock.module.css +++ b/packages/shared-components/src/utils/VirtualizedList/story-mock.module.css @@ -10,8 +10,28 @@ width: 100%; padding: 12px 16px; border-bottom: 1px solid #e0e0e0; + + button { + all: unset; + } } .itemSelected { background-color: #559f24; } + +.group { + width: 100%; + padding: 8px; + background-color: #00adad; + border: 1px solid lightgrey; + font-weight: "bold"; + + button { + all: unset; + } +} + +.groupSelected { + background-color: #559f24; +} diff --git a/packages/shared-components/src/utils/VirtualizedList/story-mock.tsx b/packages/shared-components/src/utils/VirtualizedList/story-mock.tsx new file mode 100644 index 0000000000..7f8424eeb8 --- /dev/null +++ b/packages/shared-components/src/utils/VirtualizedList/story-mock.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { memo } from "react"; +import { type JSX } from "react"; +import classNames from "classnames"; + +import { type VirtualizedListContext } from "./virtualized-list"; +import type { Group } from "./GroupedVirtualizedList"; +import styles from "./story-mock.module.css"; +import type { ItemAccessibleProps, TreegridGroupHeaderProps } from "./accessbility"; + +export interface SimpleItemComponent { + id: string; + label: string; +} + +export interface SimpleGroupHeader { + id: string; + label: string; +} + +export const items: SimpleItemComponent[] = Array.from({ length: 50 }, (_, i) => ({ + id: `item-${i}`, + label: `Item ${i + 1}`, +})); + +export const groups: Group[] = [ + { header: { id: "group-1", label: "Group 1" }, items: items.slice(0, 10) }, + { header: { id: "group-2", label: "Group 2" }, items: items.slice(10, 30) }, + { header: { id: "group-3", label: "Group 3" }, items: items.slice(30, 50) }, +]; + +type SimpleItemComponentProps = ItemAccessibleProps & { + item: SimpleItemComponent; + context: Context; + onFocus: (item: SimpleItemComponent, e: React.FocusEvent) => void; +}; + +export const SimpleItemComponent = memo(function SimpleItemComponent({ + item, + context, + onFocus, + ...rest +}: SimpleItemComponentProps>): JSX.Element { + const selected = context.tabIndexKey === item.id; + const { role } = rest; + + const buttonProps = role === "row" ? { role: "gridcell" } : rest; + const button = ( + + ); + + if (role === "option") return button; + + return ( +
+ {button} +
+ ); +}); + +interface GroupHeaderComponentProps extends TreegridGroupHeaderProps { + header: SimpleGroupHeader; + context: VirtualizedListContext; + onFocus: (header: SimpleGroupHeader, e: React.FocusEvent) => void; +} + +export const GroupHeaderComponent = memo(function GroupHeaderComponent({ + header, + context, + onFocus, + ...rest +}: GroupHeaderComponentProps): JSX.Element { + const selected = context.tabIndexKey === header.id; + + return ( +
+ +
+ ); +}); diff --git a/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.test.tsx b/packages/shared-components/src/utils/VirtualizedList/virtualized-list.test.tsx similarity index 63% rename from packages/shared-components/src/utils/VirtualizedList/VirtualizedList.test.tsx rename to packages/shared-components/src/utils/VirtualizedList/virtualized-list.test.tsx index e6564f3f43..4a6696aaac 100644 --- a/packages/shared-components/src/utils/VirtualizedList/VirtualizedList.test.tsx +++ b/packages/shared-components/src/utils/VirtualizedList/virtualized-list.test.tsx @@ -10,15 +10,11 @@ import { render, screen, fireEvent, waitFor, act } from "@test-utils"; import { VirtuosoMockContext } from "react-virtuoso"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { VirtualizedList, type IVirtualizedListProps } from "./VirtualizedList"; +import { FlatVirtualizedList, type FlatVirtualizedListProps } from "./FlatVirtualizedList"; +import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "./GroupedVirtualizedList"; +import type { VirtualizedListContext } from "./virtualized-list"; -const expectTabIndex = (element: Element, expected: string): void => { - expect(element.getAttribute("tabindex")).toBe(expected); -}; - -const expectAttribute = (element: Element, attr: string, expected: string): void => { - expect(element.getAttribute(attr)).toBe(expected); -}; +// ─── Test types ────────────────────────────────────────────────────────────── interface TestItem { id: string; @@ -29,7 +25,192 @@ interface TestItem { const SEPARATOR_ITEM = "SEPARATOR" as const; type TestItemWithSeparator = TestItem | typeof SEPARATOR_ITEM; -describe("VirtualizedList", () => { +interface TestGroupHeader { + id: string; + name: string; +} + +// ─── Shared helpers ────────────────────────────────────────────────────────── + +const expectTabIndex = (element: Element, expected: string): void => { + expect(element.getAttribute("tabindex")).toBe(expected); +}; + +const expectAttribute = (element: Element, attr: string, expected: string): void => { + expect(element.getAttribute(attr)).toBe(expected); +}; + +const getItemKey = (item: TestItemWithSeparator): string => (typeof item === "string" ? item : item.id); + +/** Renders an item element used by the default mock. */ +function renderItemElement( + index: number, + item: TestItemWithSeparator, + context: VirtualizedListContext, +): React.JSX.Element { + const itemKey = typeof item === "string" ? item : item.id; + const isFocused = context.tabIndexKey === itemKey; + return ( +
+ {item === SEPARATOR_ITEM ? "---" : (item as TestItem).name} +
+ ); +} + +/** Renders a clickable item element used by the scroll-click test mock. */ +function renderClickableItemElement( + index: number, + item: TestItemWithSeparator, + context: VirtualizedListContext, + onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void, + onClick: () => void, +): React.JSX.Element { + const itemKey = typeof item === "string" ? item : item.id; + const isFocused = context.tabIndexKey === itemKey; + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + onClick(); + } + }} + onFocus={(e) => onFocus(item, e)} + > + {item === SEPARATOR_ITEM ? "---" : (item as TestItem).name} +
+ ); +} + +// ─── Variant definitions ───────────────────────────────────────────────────── + +interface ListTestVariant { + name: string; + /** Build the JSX element for the given items and props. */ + createComponent: ( + items: TestItemWithSeparator[], + mockGetItemComponent: any, + mockIsItemFocusable: any, + extraProps?: Record, + ) => React.JSX.Element; + /** Wire up the default `getItemComponent` mock (simple items, no onFocus). */ + setupDefaultMock: (mockGetItemComponent: any, getItems: () => TestItemWithSeparator[]) => void; + /** Wire up the `getItemComponent` mock for the click-after-scroll test. */ + setupClickTestMock: (mockGetItemComponent: any, mockOnClick: any, getItems: () => TestItemWithSeparator[]) => void; + /** Number of ArrowDown key presses after initial focus to reach the first regular item. + * 0 for flat lists, 1 for grouped lists (to skip past the group header). */ + stepsToFirstItem: number; + /** CSS selector matching all elements that participate in keyboard navigation. */ + navigableSelector: string; +} + +const flatVariant: ListTestVariant = { + name: "FlatVirtualizedList", + stepsToFirstItem: 0, + navigableSelector: ".mx_item", + + createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps = {}) { + const props: FlatVirtualizedListProps = { + items, + "getItemComponent": mockGetItemComponent, + "isItemFocusable": mockIsItemFocusable, + getItemKey, + "role": "grid", + "aria-rowcount": items.length, + "aria-colcount": 1, + ...extraProps, + }; + return ; + }, + + setupDefaultMock(mockGetItemComponent, _getItems) { + mockGetItemComponent.mockImplementation( + (index: number, item: TestItemWithSeparator, context: VirtualizedListContext) => + renderItemElement(index, item, context), + ); + }, + + setupClickTestMock(mockGetItemComponent, mockOnClick, _getItems) { + mockGetItemComponent.mockImplementation( + ( + index: number, + item: TestItemWithSeparator, + context: VirtualizedListContext, + onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void, + ) => renderClickableItemElement(index, item, context, onFocus, () => mockOnClick(item)), + ); + }, +}; + +const groupedVariant: ListTestVariant = { + name: "GroupedVirtualizedList", + stepsToFirstItem: 1, + navigableSelector: ".mx_group_header, .mx_item", + + createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps = {}) { + const header: TestGroupHeader = { id: "test-group-header", name: "Group 0" }; + const props: GroupedVirtualizedListProps = { + "groups": [{ header, items }], + "getItemComponent": mockGetItemComponent, + "getGroupHeaderComponent": ( + _groupIndex: number, + header: TestGroupHeader, + context: VirtualizedListContext, + onFocus: (header: TestGroupHeader, e: React.FocusEvent) => void, + ) => ( +
onFocus(header, e)} + > + {header.name} +
+ ), + "isGroupHeaderFocusable": () => true, + "isItemFocusable": mockIsItemFocusable, + getItemKey, + "getHeaderKey": (header) => header.id, + "role": "grid", + "aria-rowcount": items.length, + "aria-colcount": 1, + ...extraProps, + }; + return ; + }, + + setupDefaultMock(mockGetItemComponent, _getItems) { + mockGetItemComponent.mockImplementation( + (index: number, item: TestItemWithSeparator, context: VirtualizedListContext) => + renderItemElement(index, item, context), + ); + }, + + setupClickTestMock(mockGetItemComponent, mockOnClick, _getItems) { + mockGetItemComponent.mockImplementation( + ( + index: number, + item: TestItemWithSeparator, + context: VirtualizedListContext, + onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void, + ) => renderClickableItemElement(index, item, context, onFocus, () => mockOnClick(item)), + ); + }, +}; + +// ─── Shared test suite ─────────────────────────────────────────────────────── + +const virtuosoWrapper = ({ children }: PropsWithChildren): React.JSX.Element => ( + + {children} + +); + +describe.each([flatVariant, groupedVariant])("$name", (variant) => { const mockGetItemComponent = vi.fn(); const mockIsItemFocusable = vi.fn(); @@ -40,44 +221,30 @@ describe("VirtualizedList", () => { { id: "3", name: "Item 3" }, ]; - const defaultProps: IVirtualizedListProps = { - items: defaultItems, - getItemComponent: mockGetItemComponent, - isItemFocusable: mockIsItemFocusable, - getItemKey: (item) => (typeof item === "string" ? item : item.id), - }; + /** Tracks whichever items were most recently passed to render / rerender, + * so the grouped variant's mock can look them up by index. */ + let currentItems: TestItemWithSeparator[] = defaultItems; const getListComponent = ( - props: Partial> = {}, + items: TestItemWithSeparator[], + extraProps: Record = {}, ): React.JSX.Element => { - const mergedProps = { ...defaultProps, ...props }; - return ; + currentItems = items; + return variant.createComponent(items, mockGetItemComponent, mockIsItemFocusable, extraProps); }; const renderListWithHeight = ( - props: Partial> = {}, + overrides: { items?: TestItemWithSeparator[] } & Record = {}, ): ReturnType => { - const mergedProps = { ...defaultProps, ...props }; - return render(getListComponent(mergedProps), { - wrapper: ({ children }: PropsWithChildren) => ( - - <>{children} - - ), - }); + const { items: overrideItems, ...extraProps } = overrides; + const items = overrideItems ?? defaultItems; + return render(getListComponent(items, extraProps), { wrapper: virtuosoWrapper }); }; beforeEach(() => { vi.clearAllMocks(); - mockGetItemComponent.mockImplementation((index: number, item: TestItemWithSeparator, context: any) => { - const itemKey = typeof item === "string" ? item : item.id; - const isFocused = context.tabIndexKey === itemKey; - return ( -
- {item === SEPARATOR_ITEM ? "---" : (item as TestItem).name} -
- ); - }); + currentItems = defaultItems; + variant.setupDefaultMock(mockGetItemComponent, () => currentItems); mockIsItemFocusable.mockImplementation((item: TestItemWithSeparator) => item !== SEPARATOR_ITEM); }); @@ -97,12 +264,21 @@ describe("VirtualizedList", () => { }); }); + /** Press ArrowDown the required number of times to move from the initial + * focus target (e.g. a group header) to the first regular item. */ + const navigateToFirstItem = (container: Element): void => { + for (let i = 0; i < variant.stepsToFirstItem; i++) { + fireEvent.keyDown(container, { code: "ArrowDown" }); + } + }; + describe("Keyboard Navigation", () => { it("should handle ArrowDown key navigation", () => { renderListWithHeight(); const container = screen.getByRole("grid"); fireEvent.focus(container); + navigateToFirstItem(container); fireEvent.keyDown(container, { code: "ArrowDown" }); // ArrowDown should skip the non-focusable item at index 1 and go to index 2 @@ -116,8 +292,9 @@ describe("VirtualizedList", () => { renderListWithHeight(); const container = screen.getByRole("grid"); - // First focus and navigate down to second item + // First focus and navigate down past separator fireEvent.focus(container); + navigateToFirstItem(container); fireEvent.keyDown(container, { code: "ArrowDown" }); // Then navigate back up @@ -135,18 +312,19 @@ describe("VirtualizedList", () => { // First focus and navigate to a later item fireEvent.focus(container); + navigateToFirstItem(container); fireEvent.keyDown(container, { code: "ArrowDown" }); fireEvent.keyDown(container, { code: "ArrowDown" }); - // Then press Home to go to first item + // Then press Home to go to first navigable element fireEvent.keyDown(container, { code: "Home" }); - // Verify focus moved to first item - const items = container.querySelectorAll(".mx_item"); - expectTabIndex(items[0], "0"); - // Check that other items are not focused - for (let i = 1; i < items.length; i++) { - expectTabIndex(items[i], "-1"); + // Verify focus moved to the very first navigable element + const allNav = container.querySelectorAll(variant.navigableSelector); + expectTabIndex(allNav[0], "0"); + // Check that other navigable elements are not focused + for (let i = 1; i < allNav.length; i++) { + expectTabIndex(allNav[i], "-1"); } }); @@ -154,20 +332,19 @@ describe("VirtualizedList", () => { renderListWithHeight(); const container = screen.getByRole("grid"); - // First focus on the list (starts at first item) + // First focus on the list fireEvent.focus(container); // Then press End to go to last item fireEvent.keyDown(container, { code: "End" }); - // Verify focus moved to last visible item - const items = container.querySelectorAll(".mx_item"); - // Should focus on the last visible item - const lastIndex = items.length - 1; - expectTabIndex(items[lastIndex], "0"); - // Check that other items are not focused + // Verify focus moved to last visible navigable element + const allNav = container.querySelectorAll(variant.navigableSelector); + const lastIndex = allNav.length - 1; + expectTabIndex(allNav[lastIndex], "0"); + // Check that other navigable elements are not focused for (let i = 0; i < lastIndex; i++) { - expectTabIndex(items[i], "-1"); + expectTabIndex(allNav[i], "-1"); } }); @@ -175,8 +352,9 @@ describe("VirtualizedList", () => { renderListWithHeight(); const container = screen.getByRole("grid"); - // First focus on the list (starts at first item) + // First focus on the list and navigate to first item fireEvent.focus(container); + navigateToFirstItem(container); // Then press PageDown to jump down by viewport size fireEvent.keyDown(container, { code: "PageDown" }); @@ -193,8 +371,9 @@ describe("VirtualizedList", () => { renderListWithHeight(); const container = screen.getByRole("grid"); - // First focus and navigate to last item to have something to page up from + // First focus, navigate to first item, then End fireEvent.focus(container); + navigateToFirstItem(container); fireEvent.keyDown(container, { code: "End" }); // Then press PageUp to jump up by viewport size @@ -213,6 +392,7 @@ describe("VirtualizedList", () => { const container = screen.getByRole("grid"); fireEvent.focus(container); + navigateToFirstItem(container); // Store initial state - first item should be focused const initialItems = container.querySelectorAll(".mx_item"); @@ -255,9 +435,9 @@ describe("VirtualizedList", () => { expectTabIndex(items[2], "0"); // Should have moved to third item (skipping separator) }); - it("should skip non-focusable items when navigating down", async () => { + it("should skip non-focusable items when navigating down", () => { // Create items where every other item is not focusable - const mixedItems = [ + const mixedItems: TestItemWithSeparator[] = [ { id: "1", name: "Item 1", isFocusable: true }, { id: "2", name: "Item 2", isFocusable: false }, { id: "3", name: "Item 3", isFocusable: true }, @@ -274,6 +454,7 @@ describe("VirtualizedList", () => { const container = screen.getByRole("grid"); fireEvent.focus(container); + navigateToFirstItem(container); fireEvent.keyDown(container, { code: "ArrowDown" }); // Verify it skipped the non-focusable item at index 1 @@ -285,7 +466,7 @@ describe("VirtualizedList", () => { }); it("should skip non-focusable items when navigating up", () => { - const mixedItems = [ + const mixedItems: TestItemWithSeparator[] = [ { id: "1", name: "Item 1", isFocusable: true }, SEPARATOR_ITEM, { id: "2", name: "Item 2", isFocusable: false }, @@ -305,8 +486,9 @@ describe("VirtualizedList", () => { fireEvent.keyDown(container, { code: "End" }); fireEvent.keyDown(container, { code: "ArrowUp" }); - // Verify it skipped non-focusable items - // and went to the first focusable item + // Verify it skipped non-focusable items and went to the first focusable item. + // For grouped lists the header sits above the first item, so ArrowUp from + // Item 2 (skipping the non-focusable entries) lands on Item 1. const items = container.querySelectorAll(".mx_item"); expectTabIndex(items[0], "0"); // Item 1 is focused expectTabIndex(items[3], "-1"); // Item 3 is not focused anymore @@ -314,19 +496,19 @@ describe("VirtualizedList", () => { }); describe("Focus Management", () => { - it("should focus first item when list gains focus for the first time", () => { + it("should focus first navigable element when list gains focus for the first time", () => { renderListWithHeight(); const container = screen.getByRole("grid"); - // Initial focus should go to first item + // Initial focus should go to first navigable element fireEvent.focus(container); - // Verify first item gets focus - const items = container.querySelectorAll(".mx_item"); - expectTabIndex(items[0], "0"); - // Other items should not be focused - for (let i = 1; i < items.length; i++) { - expectTabIndex(items[i], "-1"); + // Verify first navigable element gets focus + const allNav = container.querySelectorAll(variant.navigableSelector); + expectTabIndex(allNav[0], "0"); + // Other navigable elements should not be focused + for (let i = 1; i < allNav.length; i++) { + expectTabIndex(allNav[i], "-1"); } }); @@ -336,11 +518,12 @@ describe("VirtualizedList", () => { // Focus and navigate to simulate previous usage fireEvent.focus(container); + navigateToFirstItem(container); fireEvent.keyDown(container, { code: "ArrowDown" }); - // Verify item 2 is focused + // Verify item 2 is focused (ArrowDown skips separator) let items = container.querySelectorAll(".mx_item"); - expectTabIndex(items[2], "0"); // ArrowDown skips to item 2 + expectTabIndex(items[2], "0"); // Simulate blur by focusing elsewhere fireEvent.blur(container); @@ -368,49 +551,23 @@ describe("VirtualizedList", () => { it("should not scroll to top when clicking an item after manual scroll", () => { // Create a larger list to enable meaningful scrolling - const largerItems = Array.from({ length: 50 }, (_, i) => ({ + const largerItems: TestItemWithSeparator[] = Array.from({ length: 50 }, (_, i) => ({ id: `item-${i}`, name: `Item ${i}`, })); const mockOnClick = vi.fn(); - mockGetItemComponent.mockImplementation( - ( - index: number, - item: TestItemWithSeparator, - context: any, - onFocus: (item: TestItemWithSeparator, e: React.FocusEvent) => void, - ) => { - const itemKey = typeof item === "string" ? item : item.id; - const isFocused = context.tabIndexKey === itemKey; - return ( -
mockOnClick(item)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - mockOnClick(item); - } - }} - onFocus={(e) => onFocus(item, e)} - > - {item === SEPARATOR_ITEM ? "---" : (item as TestItem).name} -
- ); - }, - ); + variant.setupClickTestMock(mockGetItemComponent, mockOnClick, () => currentItems); const { container } = renderListWithHeight({ items: largerItems }); const listContainer = screen.getByRole("grid"); - // Step 1: Focus the list initially (this sets tabIndexKey to first item: "item-0") + // Step 1: Focus the list initially and navigate to the first regular item fireEvent.focus(listContainer); + navigateToFirstItem(listContainer); - // Verify first item is focused initially and tabIndexKey is set to first item + // Verify first item is focused and tabIndexKey is set to first item let items = container.querySelectorAll(".mx_item"); expectTabIndex(items[0], "0"); expectAttribute(items[0], "data-testid", "row-0"); @@ -431,11 +588,10 @@ describe("VirtualizedList", () => { // Find a visible item to click on (should be items from further down the list) const visibleItems = container.querySelectorAll(".mx_item"); expect(visibleItems.length).toBeGreaterThan(0); - const clickTargetItem = visibleItems[0]; // Click on the first visible item + const clickTargetItem = visibleItems[0]; // Click on the visible item fireEvent.click(clickTargetItem); - // The click should trigger the onFocus callback, which updates the tabIndexKey // This simulates the real user interaction where clicking an item focuses it fireEvent.focus(clickTargetItem); @@ -454,6 +610,34 @@ describe("VirtualizedList", () => { }); }); + describe("Group header keyboard navigation", () => { + // These tests only exercise meaningful behaviour for the grouped variant; + // for the flat variant they degenerate to basic navigation assertions. + it("should navigate from first navigable element to the first item with ArrowDown", () => { + renderListWithHeight(); + const container = screen.getByRole("grid"); + + fireEvent.focus(container); + navigateToFirstItem(container); + + const items = container.querySelectorAll(".mx_item"); + expectTabIndex(items[0], "0"); + }); + + it("should navigate back to the first navigable element with ArrowUp from the first item", () => { + renderListWithHeight(); + const container = screen.getByRole("grid"); + + fireEvent.focus(container); + navigateToFirstItem(container); + // Now press ArrowUp to go back before the first item + fireEvent.keyDown(container, { code: "ArrowUp" }); + + const allNav = container.querySelectorAll(variant.navigableSelector); + expectTabIndex(allNav[0], "0"); + }); + }); + describe("Accessibility", () => { it("should set correct ARIA attributes", () => { renderListWithHeight(); @@ -469,17 +653,11 @@ describe("VirtualizedList", () => { let container = screen.getByRole("grid"); expectAttribute(container, "aria-rowcount", "4"); - // Update with fewer items - const fewerItems = [ + const fewerItems: TestItemWithSeparator[] = [ { id: "1", name: "Item 1" }, { id: "2", name: "Item 2" }, ]; - rerender( - getListComponent({ - ...defaultProps, - items: fewerItems, - }), - ); + rerender(getListComponent(fewerItems)); container = screen.getByRole("grid"); expectAttribute(container, "aria-rowcount", "2"); @@ -537,7 +715,7 @@ describe("VirtualizedList", () => { ); return render( - = { context: Context; }; -export interface IVirtualizedListProps extends Omit< +export interface VirtualizedListProps extends Omit< VirtuosoProps>, "data" | "itemContent" | "context" > { @@ -52,21 +52,6 @@ export interface IVirtualizedListProps extends Omit< */ items: Item[]; - /** - * Function that renders each list item as a JSX element. - * @param index - The index of the item in the list - * @param item - The data item to render - * @param context - The context object containing the focused key and any additional data - * @param onFocus - A callback that is required to be called when the item component receives focus - * @returns JSX element representing the rendered item - */ - getItemComponent: ( - index: number, - item: Item, - context: VirtualizedListContext, - onFocus: (item: Item, e: React.FocusEvent) => void, - ) => JSX.Element; - /** * Optional additional context data to pass to each rendered item. * This will be available in the VirtualizedListContext passed to getItemComponent. @@ -108,6 +93,28 @@ export interface IVirtualizedListProps extends Omit< * @param range - The new visible range with startIndex and endIndex */ rangeChanged?: (range: ListRange) => void; + + /** + * Optional function to map from the items array index to the scroll index + * used by virtuoso's scrollIntoView. This is needed when the items array + * contains entries (such as group headers) that don't have a direct 1:1 + * mapping with virtuoso's own item indices. + * + * @param itemsIndex - The index in the items array + * @returns The index to pass to virtuoso's scrollIntoView + */ + mapScrollIndex?: (itemsIndex: number) => number; + + /** + * Optional function to map from virtuoso's reported visible-range indices + * back to the items array indices. This is needed when virtuoso reports + * ranges in a different index space than the items array (e.g., in + * GroupedVirtuoso where group headers are not counted in the range). + * + * @param virtuosoIndex - The index reported by virtuoso's rangeChanged + * @returns The corresponding index in the items array + */ + mapRangeIndex?: (virtuosoIndex: number) => number; } /** @@ -117,24 +124,49 @@ export type ScrollIntoViewOnChange = NonNullable< VirtuosoProps>["scrollIntoViewOnChange"] >; +export interface UseVirtualizedListResult extends Omit< + VirtuosoProps>, + "data" | "itemContent" | "context" | "onKeyDown" | "onFocus" | "onBlur" | "rangeChanged" | "scrollerRef" | "ref" +> { + ref: React.RefObject; + scrollerRef: (element: HTMLElement | Window | null) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onFocus: (e: React.FocusEvent) => void; + onBlur: (event: React.FocusEvent) => void; + rangeChanged: (range: ListRange) => void; + onFocusForGetItemComponent: (item: Item, e: React.FocusEvent) => void; + context: VirtualizedListContext; +} + /** - * A generic virtualized list component built on top of react-virtuoso. - * Provides keyboard navigation and virtualized rendering for performance with large lists. + * A hook that provides keyboard navigation and focus management for a virtualized list + * built on top of react-virtuoso. * - * @template Item - The type of data items in the list - * @template Context - The type of additional context data passed to items + * Handles Arrow Up/Down, Home, End, Page Up/Down key navigation, focus tracking via + * a roving `tabIndex`, and automatic scrolling to keep the focused item visible. + * + * Returns props to spread onto a Virtuoso component along with an `onFocusForGetItemComponent` + * callback that each item must call on focus to keep the focus state in sync. + * + * @param props - The virtualized list configuration including items, focusability checks, + * key extraction, and any pass-through Virtuoso props. + * @returns An object of props to wire up to a Virtuoso component, plus `onFocusForGetItemComponent` + * for individual item focus handling. */ -export function VirtualizedList(props: IVirtualizedListProps): React.ReactElement { +export function useVirtualizedList( + props: VirtualizedListProps, +): UseVirtualizedListResult { // Extract our custom props to avoid conflicts with Virtuoso props const { items, - getItemComponent, isItemFocusable, getItemKey, context, onKeyDown, totalCount, rangeChanged, + mapScrollIndex, + mapRangeIndex, ...virtuosoProps } = props; /** Reference to the Virtuoso component for programmatic scrolling */ @@ -181,14 +213,15 @@ export function VirtualizedList(props: IVirtualizedListProps(props: IVirtualizedListProps): JSX.Element => - getItemComponent(index, item, context, onFocusForGetItemComponent), - [getItemComponent, onFocusForGetItemComponent], - ); - /** * Handles focus events on the list. * Sets the focused state and scrolls to the focused item if it is not currently visible. */ const onFocus = useCallback( - (e?: React.FocusEvent): void => { + (e: React.FocusEvent): void => { if (e?.currentTarget !== virtuosoDomRef.current || typeof tabIndexKey !== "string") { return; } @@ -325,8 +352,8 @@ export function VirtualizedList(props: IVirtualizedListProps(props: IVirtualizedListProps { - setVisibleRange(range); + const internalRange = mapRangeIndex + ? { startIndex: mapRangeIndex(range.startIndex), endIndex: mapRangeIndex(range.endIndex) } + : range; + setVisibleRange(internalRange); rangeChanged?.(range); }, - [rangeChanged], + [rangeChanged, mapRangeIndex], ); - return ( - - ); + return { + ...virtuosoProps, + ref: virtuosoHandleRef, + scrollerRef, + onKeyDown: keyDownCallback, + onFocus, + onBlur, + rangeChanged: handleRangeChanged, + onFocusForGetItemComponent, + context: listContext, + }; }