From dc2400f6c4237e326f7ccd4a1eb4c93d962d900b Mon Sep 17 00:00:00 2001 From: mdodgelooker <53451193+mdodgelooker@users.noreply.github.com> Date: Mon, 11 Jan 2021 15:52:17 -0800 Subject: [PATCH] feat: Arrow key navigation persists selected item (Menu, Tabs) (#1761) --- packages/components/CHANGELOG.md | 1 + .../snapshots/MenuGroup/No Label-snap.png | Bin 0 -> 11961 bytes packages/components/src/Menu/Menu.story.tsx | 33 +- .../components/src/Menu/MenuGroup.story.tsx | 6 + .../components/src/Menu/MenuGroup.test.tsx | 95 ++-- packages/components/src/Menu/MenuItem.tsx | 32 +- .../components/src/Menu/MenuItemContext.ts | 4 +- packages/components/src/Menu/MenuList.tsx | 33 +- .../__snapshots__/MenuGroup.test.tsx.snap | 502 ------------------ packages/components/src/Tabs/Tab.tsx | 21 +- packages/components/src/Tabs/TabContext.ts | 34 -- packages/components/src/Tabs/TabList.tsx | 45 +- packages/components/src/Tabs/Tabs.test.tsx | 18 - .../src/Tabs/__snapshots__/Tabs.test.tsx.snap | 4 +- .../utils/{moveFocus.ts => getNextFocus.ts} | 43 +- packages/components/src/utils/index.ts | 3 +- .../src/utils/useArrowKeyNav.test.tsx | 155 ++++++ .../components/src/utils/useArrowKeyNav.ts | 146 +++++ packages/components/src/utils/useWindow.tsx | 13 +- playground/src/index.tsx | 4 +- 20 files changed, 448 insertions(+), 744 deletions(-) create mode 100644 packages/components/snapshots/MenuGroup/No Label-snap.png delete mode 100644 packages/components/src/Menu/__snapshots__/MenuGroup.test.tsx.snap delete mode 100644 packages/components/src/Tabs/TabContext.ts rename packages/components/src/utils/{moveFocus.ts => getNextFocus.ts} (58%) create mode 100644 packages/components/src/utils/useArrowKeyNav.test.tsx create mode 100644 packages/components/src/utils/useArrowKeyNav.ts diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 09f04ca1907..8b3aa239b51 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `Tabs` and `Menu` arrow key navigation persists the last focused item - `Breakpoint` component - `Tree / TreeItem` - disabled and selected states diff --git a/packages/components/snapshots/MenuGroup/No Label-snap.png b/packages/components/snapshots/MenuGroup/No Label-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..68e1fc413d2420624713b0e43a2b52b9f839430a GIT binary patch literal 11961 zcmdUVWkA!>`|m^rB}Ju6K|w%3Ktyr|0!pb!m$Y;UjVgk}J5D-uVCeksaMvRv3 zZbpw0+dcF5zwzR}y089sFUDE$-T9vLoagz(6ZTwPiH4Gu5(0tHJXKbB34xrw2F715 zkb`HtMzwhG=ZxD+rALs$F7_1&ts>&?>@2V zY|6RK=h^Fy=s4)=CtHnZsEu`ei?h+Q+DxLtHC$}H z&eY?o%{9WfTzJT*3pT|ZiDIjmY z62JbRyVSSd- zU_at-8!2`iq+v(4pMZ7VRMH!fT8Fwv6r0n}FEJVRry~#=!^jH#GizFdylGysu_D}K?qf>(wJfSqE4^Wd&%Hm)iwJZK{0$TIP z=tdX*C9?YO5^G+T?d}JTQ|=Rftb;}9J^t9%wzli;OTxzf9s&u%MrOm)56iWxNy~EL z!@x{!KUx*e_=l#oy~}iNFzvU~#BXIpqyg*&@twY|fdSHGO_c`K&A{HkYho0JFZkKk+1c3see0)}qa)|N!NH1nH}VYEoShptjqcSE zRIIERm7<

ia4RNh#aly?^WTFH8GRa*nlCgumN3{$HJgWvVuaNg?qO84N0vj+kuR zlA{CG_Kq;RYGTVpTn2{C)P&dFz2MlaX^xKllIWgOILEg|bz!!lIb2r4Q`VfXMnPYn z73^=pnDeRLXVltc&J|kPgBHTd%8F{bblSJ;9B+nie;~?1qdWNdpG4t%8sV3QE6#6J z@0qprSw3k|sc?=~LimPIyie}^+5Lc)Q`)x^?~axE7^v9kz1L=4@g?z%eYh-3bMXtf zX1S@Rei;_Sw~G?^g5Y zU{Rr5-tWQHR%G5Vhr&4pdnM?>Bl?kNndQ@GbvwpAgJriXy}et8z|-((yhNS)v#N-R zDdWd8x7!aUDBcWK$Xi)iB_<{H7QB#q1mnt>mhS!{U@%C|WR2#Af<@2q^(^nYab|vX ztGCWWbGAF3f)tySFn%vcc217yV&%oVc}%UiDNL?;sOS1{ZSHX0u2r|ifk8jAynLx= z)8RLbjn-FlU0vO2y;|uPy`m6|mk;d>Js1#GOn+WdAoXCgZ&`29C)vcRX zYLO+}iv6a_x4uPhe%y^BC>CuR;qjqPd4~DiMd~Rp=Dj*}e$MRcm@J0Fr}*Rvx1BJl zG!HyI@4;oaskMG+&kQ}8+Fos9I$RGgI^52^2}%7V(+M_N-0O9K_UXZu#!&gD4+*^V z0gDAL>wXlpeP8wNKu@=5?%lgrbJvEFg@wgeoM>*$42|zJC`(vQCLJK3>LJn%dtDRJ$5FYaQv8XEYmIPW!As%yb~?N-5ub=7DAnL*_*#bEo$OyOK`|LP=_qS~?wPd+%MWt3_L*Lid?GPGH<#ORP2(!HV9SzxCC?#d# zc){Kd)m$DIGo(KJO8Q(raxO{U@^XhxA+LxCd_|Mj)Y2wQgT`rA9lhJOWFl5+9;q zAP}%lW*E1q=*4ndtuxLZLg-W&^mWJJbK#r3;6n`$m?WhA3xDJCi$-s&ajM_;lV8fN z|6Cr<)YDYy8okIH0cR~e;- z6lvDP-3|=<`uknn-LLuM^o&yoj3Cf0tgT~HQdB%W%aEwjxVUZHux4&?S=qf1I*5u& zcwdDfsWQEM1a3cnVXd=m`M#K#c=)-`l!e0QD9&H4o{EwS9j=tb99FqD@06d`=k~b| zk$vQEq%b7%x(EFU)DzXXbW05!>!zmkuhuwWh-#`V16VIce;Gu3oB(nGnyCDm14UY5O?l-yL0n*>wD0)Ggis1}RA?*$;WQqf06QIlk6jc*5e{T* zY;1lzv60mmPWY_!|B#6j)FQjaBi=tODZm>7n>_x!y6G6fd@qp;b$VGzNh!WaUvqZu z)UT$vQ3?6`ced?-z~SKD%=6)B?xk|;sWacTe!d4a#OGiG-Jd1x@L1o}!9i_POI%QA zcP~#}U7eObnlEx}$5jQks7wm0&8Qzvk6^{+)p&K@=d0q^a00$J`1r2)2o$#7v$p1| za$l_^z;GzL3QveXCVy$6%buSN+k*k2s;P+A$T`2C8fXI z8m68m-&whP(d(pA;l~XtOls#^QLXC3hiAmCyK_{~w8J2>aKdTeNN9LFjHX1e%70lN z)|Ef9m!C`L(9{L%J3}9RTd#^*1HOtOt{U`-i?z1#%YX2C`--o^LQbHbWF^r1q6Jiv!jzo6fee-lmtM?^PHF7C%&8T zV<|_aeDcpeQoGyDLsW;eYv-SoMRM$((~u+F;=-gg+x1Uc;M*n6N!ln3b#WB=&?z^P z@4p$%7KKAk*|9ZpS;Ks$0ICL3RPVR6w2)6F%hyQwQKoU#{7T>Kw9)ihD7B&HkYEjf znxy+2J!KR&egw8_!N>?dA5@7+l}He&GY%|?y#kM7R?MYPspE8dK%A^OSzQAqEq%J> zp{lPx`*W{=wk58*j=wD8D^(JyMSw5J_B&p#eI{m8^jnmQIs;TeycvAizH`MmLXWeC#(TSLvaw$pak+dHP z3-!@zAD?|3T`1+KO`D+k$)ClPg)N0!qpykyLmkp*utHoJ3U6!ctX=9TBt@74fnS)H z*X;-+wK^c=dwC_krxqMmMm%=|hYww!SjBY+1uYl4)^@?J?&Fq)JG;92!50yo!*uBx zGw%HSmA_lAsKvygW8OVpnzL_Sp&awmr+x|yuzXI1V)>pQULw_Q{#RWi(G$lF&Ocs; zt*5g8`sEfebGE*{J*J?5eYTtTwYfv{h#S*|yg8T>ntc>?oEGml^ktR0^%xOgGGE#d zO1jqz7M7N7^IkXNwc`O4`qTGy`AYuI&aRZJY)s9#?CRmM;qH?Ukc1fFE1WDw7AZw= zrb1zLSm{BZr_R~djL9?x`F}Utl=tc^>2d&^MUKZ|&yc1ECKyHbpEnaD{@V{QDBi#i zo8z7JnLHQY{7u#7sJdg*?*3A<|Cz&yy4=xD+ey(ghZ+V}U2N1FiMVvX8z*TnirHPJW$hHkH}iit^X8SLSBQpRUk(Hg&z32J$@Qd}5m z8^)85diCnGG3ItX4^K#MhVeCs zQWWP>l8eYz>sID%q9y&G$RIDzlkoY41^Ox*nxG?Gj@nv0`)DnVl2WL__Zcs*bLMDN zQy`%h@6h(>tp~O}af$bmx7c@ujQ~lH7S*rjX86NesW1GR~B5S+K*^bl9ktX*^$!mGyYRctO;zGYj zo!LOLhws5mbw#`&Y=Pa)?G58NGZT}wzX_5uFesGPO-vv;eWIGV=pAW)TA>qQ76eM_ z1kIO_`vLOrs%TPJ115>fnh^C5O2zIYuB#F@4{LdaF4%HDdiz#-+q!Gqg$5-5y9Fji zt!0WdY?nCc^}c!rUZIc1B;n3XRC~6Jb(APirKTF*zI}UnFy{)CSV?UM_y+h7I+&!S ztK078A^X1rtomoin8`U;Kqow{hq&o+io zn>{DiJvo30ExGEfiZN3snrhD;G&S8I-`i-0hbQ#pTEkWc3oY#hZ{31qz~GL)+xuj+ z!5mij?*lbkDCCn-QUy{VtZW{$T(YyX)4%J~jtlp1czx%!jZIj20v}+6!^664#{*jj zvkG1}zz#%07Yq#xZ%>F-`|DP)fC{n>^~!54lRG}pEU}8bTzd8{t!?Y?A>Q7fZc6Eh z`$_BT>t^sd>{15+D{Wm}nQmy>a-))eYh3F*MR6&RsU>p3;?>x*DoPG6^eiamSpktiXs6FGE0@H)OU3i zL*~wYIst(sW_$R2&|$bqa!TliG;ciK#IGaXAv5BwSKjupq%B49;jWEQtnu4)4Cb~~ zw;&;$!e@W^*?Ru{(cs?=e`bzwwW%_&I_0!n4fNDeFvGvx`CsBi_$?ZMUGOL=EC@h( zYUV9xnDz4XyFF}w$1n+$VEnK=v+9VJiO$IFZaKB%vg`jS%(AHYQ$Q*A1>WFVu#|ZQCi=qeBFOCZ;u`Y+LFY1ogc`~*P zCY0plLqwpbpH&isw`a5Y*(cd3>zh>)-VD@HadoArDgR)EP;*En>l5l~c4p5B8`da? zm!(xC*VfAUlsi?$s@x`>E>|=`;>^DwQy}083@XL{#`A;>$iK74&71ZAIB;c3l}o6f zUn#+Qu>89e@jm4>9MV#72A4S>~cu5J*`Sa&b%6x@naGdp%T~WppoZ0F^Q+BrZJ-K5L35C~W_!1~= zj~Q&nDy^Y{L-yck^xUqv&M{V6XF9R0$4)J0Q$?=KrI}7idnJFnh z%6lXi(v@8&tb4E9JFNW8lrRz2jmXJ4ztm-NO-LxM5}AG{O;Av5etn18 z>oBwK)errV8v&Z6P{qTlunDhk^5quWH%q7cx!#C1@rpkbUmC8(UV}`D95LuHS%0e$ zi`q;Z+g&IRSAME?_L`7f+Mv*T?*+Vd-UhZ~Xo{!+7v}KV&q2QWX&9|$0D~0Iz&Dzj z5eoxMXfgNaPIU%*f_i!*Pi$K;nAsFA|8Gh|6yBB%{kra5H)8=!!|%kiT5}8St$lki~946^J1GTXfHw~ z-*5@>5?WnB%>A{K{#`rO-D$-L=Ds3jB1oR{r#n8QWw!0V+pgrzEc{`jVq}5G#Uv{V zLAi`saaZ@xuKu!WjF%w>k;-gqz$nP#7$HiH>oW~E?d=P^SCOF?3k*^sB2>Ywmiv%G zgWB4EcXt1v$6|v_mCh8GmNqxmvO*+kzH3&Ro&Wx-@Iqj2C|kniSjcb*_R_|qWKAH7 zf!&zVE~TXB%L@N{$we&r8AJP(=V$~F8s3anyhA!*33pf}E@Zz9-nlZ78e)q7T_RY9d1gBFa_3A*qYiIFuJ*w*cApNoM>*}#vsc}Zsr+7 zl;BF^TcaFw@a&o&RQqJBuPtq!9^VsEe)eo}9Dfe7zOfNeA~!JcBPQ{SFI4~wDFCAF zCU7dn>>TSX`&*Df$6-=F&+&h4ROR8Q^m4}%#3Hh+OzgnJ!{a*MwOzs-h)AYZj>hvI zfaO3EG&S*y#|Y8mU77%YfBF5hYgb30HzjD8n3l}es#9ay!?t2SY>rYr0AHs2yNz<3 z;D6c%Zjzz0Jj=4ArQF|$eWUVHnpdMdsGIz_@~Y0xP8SzXZoDgHmwMEbBf{xFQRd@# zA%H*G-*V<=*D;B`bg|J%%_l`nzid=)3B6~FPLF=5u3jkm;RY}7oMz*0d<)Fg`Sk;Y zqnD2s%8?74+!zrPVgCRniqUeLKS3M%ae13oYVQG);DbV^m>;PtHqXA8*oa?MKM`*o z-x_D<_=K?qB6`fHi(LOwQ_e!2$$Jm^88R+^0!`S{Hh_`LtPL0+zdK_v_UrJ-tHgCf z5tQQ#@Ca^hfe^4eQ++Um{Bx`1$ka=NB1Qu-LW6W|gPx)H2T;jXBpO9U7b4qJd5@ z*(Y(CwA=jo$NuC^AbLUe#2jx?{c>~N+TH%<5&jxNPPg1l%83J#R&(p~t*x1-F7CyA z``v84DqsDSsl>#8dAW6k0_1mTJnxKp6z!9vFG2<(ra?zryL}Ac$9wZ0W*~otBv7PD zf7rqgK|d$QM#+P4>Ca zf45XAWJTU+(k&ZvJd!}gIJY+UWOq8_zifn7V2J#$nw9y#O4Uu>fBR}Cmi%92C-wz| zv^w4V9GOCD_J`b%*TMqvWpIAI@tiGkxWFe{!aS3LhpNb}h< zpJ-8o`Hq{TH2^Va3Jzyd`Ackms+oAlrH_LPHaL!B-X4w}a=?nexy9wbgXiE{@=S|@ z27wC$ytJuD16B?f&ahTi?A+F#a&Wx2)~t{kA@T4@j?)BJX2D3sX<&$EgI&<_1rRWjHx~LrH|2BnU}*bNm=A$TBVY zJmx6k@V^a^mtP54iqs?$Qf(MV%F&Svi;MJZOx?{*H)3KmH$9|?7Xs|38h1)lRK`)? z$|)u}9bw3?W9EALXF1>>$uV!Hq$^qvzWjBk<&^3IXQK(BcE_&soWu> zf@M@rMh3)fH=y}O7MNRGT0~eQ@HMRYlo~Wzda_OHYlTBUk0gE6qFTr3&~h_Dm6nNc!qWBZ#LPuG|FZJ3Il@1ti>{2~8oyN1&{SgEqC@5vBr?+=Axf5rvPS;CeX^9A zkkGv*Znd-K)PXE15qc?JKeD^TB6`G6%P#xDpwt$VD4i5Yu{ybTM5cgl+5uv~`d9$4 znmYQ5G&Be}jAlzRf`do~xUBhhj7ie&0oz@>zQlYd%sejchqH%Ajz;&UWbHk0I>_kx z%=2dSIGC)J4%qLzs+_qYaBw%Xn$}rLg2q~jB3U9oG}lK0QiheT2O$g!Z&-wUaB!uR z8Y}tpu_AH^9)I3`v@HB%o-XjlY?yUZIx~G(`ij_uyQ1b77mzpIMd)830fyE5@lCQb zW-;r17BH=#QyYxEuORrjxEX3OKqvty57pVQK4 z>NAUZva_>$$~=CsZQ>GYDQLxT#7T?8&!LHuKD;GU$x(o&gnBw|1jow^Hy;EzP2Kn?92Gjt9U#G*3@?5cKw)FR{~C+Vc%MYQiUCBX?GP_wVpFHRmuw z3w7GGl}RT$lLPXPR5>kd69S>NCiMbP)u%Xh&zuBP-6m`bE_S_*T@W1F>V#269%Cn~ z>3n>A82xYzwLb2GAY`!}I->49kUHY3TaBz7kXTfJ#1glB6vZhXFQ6q*V&4YGq~pn{ z{B@hKg4!%&;^HOBPoK_=;ko*Yjjtj6w3t7TQ0@9;NBa%l<8=BGefd=$0fk&3ClO1Lm3Y{s&bS zIqsWF5XJCs2gxM8VC;Dur|ctlds&XLvJ4;eXS+#6;l$Nv&um#w-@0epg=#A(pk4Qx zo|+$z>@wwTY6qXGl{UU2@9OHNSLZ3NC7_kzgrnHrc3s*_+HE-iQ_!pQ6yHBv+0wF@ydc?JN-Xoe>Fw>UiV(YqV4ZY-GZd1m z2yN#>1bBB=Hm83w2U%-ayHMO!fe~ZtQSgYrL)1a9n(EZKttguE;Yc{5cYRGD5TLqdvChx6txKL_`7x!zw zA#7Zu4Aw-lwhmZl7;hhP5hyg>R_n*5@%tZnBUxSVthY<0BqfRN4DxXd0Vnp9%N3U2Va&3U^ajp8}gf*tCb4GT@aa3TRf)YXzpl?eCu)Q;^Lb9@PWhk zYAwsDKjtm+q@amHY0OZE6iEVJe&dwoc@#T5`^9OMmsXLYw+_>2nZ4OK*7X_d3sD!) z2ys=1iSN61A@I7OQA&y?EY+^($<_{8tgjPE4xZAtXG|J7dP@z~;Gna$ zbkNL)z{ zm2mk=TX%PE)p4hj`zI-nPtv6);i)CT!8h^v;F3#GQM651`3}4ODQke_b@lAS03jOGF2;^K4O46s_F4elb~?I%})5fPaoLvfOe z^2VF%QqCV1)mEq6r&Pk)4v*h5jcAuXd&2#-@EkK$xFFZ%loY1A{O-;UJ`oYbUWBTE zmb*}&BfAVgOGaydHYFuB@a)_y*V)F2NvSc)ritg3$fWIm2g zeRpT)DUd=0jO5rs55@>=>lT{(tFlV>ov2uZiSq<2;UaLZ!=NekRZveuH}j)`8oBW| zTzy{{>?8qL>KNqkS?S9hputs+HU?3MW|%nrDk)(hEXN)m<5`In%*mY6I$!h5*^CZgYM6j#gE1DXExSX>4D4)zd$L zeWOC9X)HX8)7@%>-##?$Rl`+}sObS?;Ew66#UwbJLdoaNfy){jzY(#qF&0v6x-Ui; zu8G?R;e-u>CyDRUjF#H?p_kmV)>7i)n)7D-C7RR9>Zai< ziwbWya12)mcldwRu1}G5O>qF^n>E9?(pSf@V3-P)dL|U1M!Y&~;4#G2>n6+n$s=IB z!UpO@Ckf8%nP8QQmoNLBR_+JLo&OP!;xbS7itxq;sJ?wW!z@uF7Zwrm^-=JS*DDx0 zt4gMD!SEJVKhZIWjUqZB0bR}Iue$q9&DptFq}RW>Qtwhl)da|yp$$>Y*oP$L1_FGK zKV4;Sdh%B^gMR5wy*6~wWDT!fPxqVwWqhDvsxS~112%Su`#05;C}Z=*C5D~?M#)E z0ZzNVmUYc9O51V-{Ao)>xVtNdiAEIq znHs7;T>$p>)Jescy}brH*^Xh|SYN5&4^#=aZr!RMX?xeYtO;~po`XHTK_`A+)G3b6 zn;7=Y;o@LFoVIRGloXEgc}pB{b4!~xs=1aO6qB)4PasKu4(^8f!&jULTz=#(dEID!gDeNq84mwzUoH|p1J>_}4^^8|2_ePH6dAuM92 zP}tpm=sia@-7g2UJaAB#$Lnqa>;LBW>v_f;FZ=uSYW)Sv$}ICYmec_3pYzF~y{?8g zKjw`8DeN1QS02iy7DT3Ux2!ad!ARz}+1#Mf2c_m&?NYHi3E2poBOr{=mOO<gzHSc}9WF9;77O{w~d+sJiYsuzTiFv#TPP<$X5E zpv1a(K1J%LzL#{d{4}tAUU&QwetuUZEG%-N4b6F-hbKsq=tK*;hxmO--VX#!4G7i( zk*h;DB_$JtWDaJvYLgq3`1`0^160k-E<2&b;WTWL0K_S-#djthHdJuK85uz$#TT_> z+O5XAP8AT+ku1|Q#*y)AY*)P_P~$A;|Fv$=i?YPZo>%$jgq^$k;NRb-5C3yu@!xGg b;Ce;=JkqD6;tc3Thdh0vu2A^s_51$;Fh+Nk literal 0 HcmV?d00001 diff --git a/packages/components/src/Menu/Menu.story.tsx b/packages/components/src/Menu/Menu.story.tsx index a283e50383b..ae88f3747ee 100644 --- a/packages/components/src/Menu/Menu.story.tsx +++ b/packages/components/src/Menu/Menu.story.tsx @@ -39,7 +39,8 @@ import { Divider } from '../Divider' import { FieldToggleSwitch } from '../Form' import { Icon } from '../Icon' import { Space, SpaceVertical } from '../Layout' -import { Text, Paragraph } from '../Text' +import { Tab, TabList, TabPanel, TabPanels, Tabs } from '../Tabs' +import { Heading, Text, Paragraph } from '../Text' import { Tooltip } from '../Tooltip' import { useToggle } from '../utils' import { Menu } from './Menu' @@ -564,3 +565,33 @@ export const WithTooltip = () => { WithTooltip.parameters = { storyshots: { disable: true }, } + +export const ArrowKeyNavigation = () => ( + + + Menu + + 1 + 2 + 3 + + Tabs + + + 1 + 2 + 3 + + + One + Two + Three + + + + +) + +ArrowKeyNavigation.parameters = { + storyshots: { disable: true }, +} diff --git a/packages/components/src/Menu/MenuGroup.story.tsx b/packages/components/src/Menu/MenuGroup.story.tsx index f933c3517dd..47139a7bbea 100644 --- a/packages/components/src/Menu/MenuGroup.story.tsx +++ b/packages/components/src/Menu/MenuGroup.story.tsx @@ -96,6 +96,12 @@ NoIcons.args = { icons: false, } +export const NoLabel = Template.bind({}) +NoLabel.args = { + ...Basic.args, + label: undefined, +} + export default { component: MenuGroup, title: 'MenuGroup', diff --git a/packages/components/src/Menu/MenuGroup.test.tsx b/packages/components/src/Menu/MenuGroup.test.tsx index 6dd49ac4ef1..37e8a3dfa77 100644 --- a/packages/components/src/Menu/MenuGroup.test.tsx +++ b/packages/components/src/Menu/MenuGroup.test.tsx @@ -26,74 +26,47 @@ import 'jest-styled-components' import React from 'react' -import { assertSnapshot, renderWithTheme } from '@looker/components-test-utils' +import { renderWithTheme } from '@looker/components-test-utils' -import { Box } from '../Layout' import { MenuGroup } from './MenuGroup' import { MenuItem } from './MenuItem' import { MenuList } from './MenuList' -test('MenuGroup', () => { - assertSnapshot( - - who! - - ) -}) - -test('MenuGroup - label', () => { - assertSnapshot( - - what? - who? - where? - - ) -}) - -test('MenuGroup - JSX label', () => { - assertSnapshot( - Questions}> - what? - who? - where? - - ) -}) - -test('MenuGroup - indents MenuItem with no icon when a sibling within the same group has an icon', () => { - const { getByTestId } = renderWithTheme( - - Gouda - Cheddar - - ) - - getByTestId('menu-item-cheddar-icon-placeholder') -}) - -test('MenuGroup - indents MenuItem with no icon when a sibling outside of the group has an icon', () => { - const { getByTestId } = renderWithTheme( - - Gouda +describe('MenuGroup', () => { + test('indents MenuItem with no icon when a sibling within the same group has an icon', () => { + const { getByTestId } = renderWithTheme( + Gouda Cheddar - - ) - - getByTestId('menu-item-cheddar-icon-placeholder') -}) - -test('MenuGroup - does not indent MenuItems with no icon if all siblings do not have icons', () => { - const { queryByTestId } = renderWithTheme( - - Gouda - Cheddar - - ) + ) + + getByTestId('menu-item-cheddar-icon-placeholder') + }) + + test('indents MenuItem with no icon when a sibling outside of the group has an icon', () => { + const { getByTestId } = renderWithTheme( + + Gouda + + Cheddar + + + ) + + getByTestId('menu-item-cheddar-icon-placeholder') + }) + + test('does not indent MenuItems with no icon if all siblings do not have icons', () => { + const { queryByTestId } = renderWithTheme( + + Gouda + Cheddar + + ) - expect( - queryByTestId('menu-item-cheddar-icon-placeholder') - ).not.toBeInTheDocument() + expect( + queryByTestId('menu-item-cheddar-icon-placeholder') + ).not.toBeInTheDocument() + }) }) diff --git a/packages/components/src/Menu/MenuItem.tsx b/packages/components/src/Menu/MenuItem.tsx index 197763252bb..245948f2ce2 100644 --- a/packages/components/src/Menu/MenuItem.tsx +++ b/packages/components/src/Menu/MenuItem.tsx @@ -86,7 +86,11 @@ const MenuItemInternal: FC = (props) => { } = props const [isFocusVisible, setFocusVisible] = useState(false) - const { compact: contextCompact } = useContext(MenuItemContext) + const { + compact: contextCompact, + renderIconPlaceholder, + setRenderIconPlaceholder, + } = useContext(MenuItemContext) const compact = propCompact === undefined ? contextCompact : propCompact const handleOnBlur = (event: React.FocusEvent) => { @@ -95,12 +99,6 @@ const MenuItemInternal: FC = (props) => { } const { closeModal } = useContext(DialogContext) - const { - renderIconPlaceholder, - setRenderIconPlaceholder, - handleArrowDown, - handleArrowUp, - } = useContext(MenuItemContext) const handleOnClick = (event: React.MouseEvent) => { setFocusVisible(false) @@ -111,17 +109,6 @@ const MenuItemInternal: FC = (props) => { } } - const handleOnKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case 'ArrowUp': - handleArrowUp && handleArrowUp(event) - break - case 'ArrowDown': - handleArrowDown && handleArrowDown(event) - break - } - } - const handleOnKeyUp = (event: React.KeyboardEvent) => { onKeyUp && onKeyUp(event) setFocusVisible(true) @@ -174,7 +161,13 @@ const MenuItemInternal: FC = (props) => { : props.rel const menuItemContent = ( - + {renderedIcon} {children} @@ -197,7 +190,6 @@ const MenuItemInternal: FC = (props) => { onBlur={handleOnBlur} onClick={disabled ? undefined : handleOnClick} onKeyUp={handleOnKeyUp} - onKeyDown={handleOnKeyDown} className={className} > {tooltip ? ( diff --git a/packages/components/src/Menu/MenuItemContext.ts b/packages/components/src/Menu/MenuItemContext.ts index ba9d22c3f94..b32eef7374e 100644 --- a/packages/components/src/Menu/MenuItemContext.ts +++ b/packages/components/src/Menu/MenuItemContext.ts @@ -24,14 +24,12 @@ */ -import { createContext, KeyboardEvent } from 'react' +import { createContext } from 'react' export interface MenuItemContextProps { compact?: boolean renderIconPlaceholder?: boolean setRenderIconPlaceholder?: (state: boolean) => void - handleArrowUp?: (e: KeyboardEvent) => void - handleArrowDown?: (e: KeyboardEvent) => void } const menuItemContext: MenuItemContextProps = {} diff --git a/packages/components/src/Menu/MenuList.tsx b/packages/components/src/Menu/MenuList.tsx index d78aacb4fe7..cca7a85e6d2 100644 --- a/packages/components/src/Menu/MenuList.tsx +++ b/packages/components/src/Menu/MenuList.tsx @@ -30,7 +30,6 @@ import React, { Children, forwardRef, isValidElement, - KeyboardEvent, ReactChild, Ref, useEffect, @@ -57,7 +56,7 @@ import { reset, omitStyledProps, } from '@looker/design-tokens' -import { moveFocus, useForkedRef, useWindow } from '../utils' +import { useArrowKeyNav, useWindow } from '../utils' import { MenuItemContext } from './MenuItemContext' import { MenuGroup } from './MenuGroup' @@ -137,6 +136,10 @@ export const MenuListInternal = forwardRef( pin, placement, windowing, + + onBlur, + onFocus, + onKeyDown, ...props }: MenuListProps, forwardedRef: Ref @@ -179,30 +182,23 @@ export const MenuListInternal = forwardRef( return (child: ReactChild) => getMenuGroupHeight(child, compact) }, [windowing, childArray, compact]) - const { content, containerElement, ref } = useWindow({ + const { content, ref } = useWindow({ childHeight: childHeight, children: children as JSX.Element | JSX.Element[], enabled: windowing !== 'none', + ref: forwardedRef, spacerTag: 'li', }) - const forkedRef = useForkedRef(forwardedRef, ref) - function handleArrowKey(direction: number, initial: number) { - moveFocus(direction, initial, containerElement) - } + const navProps = useArrowKeyNav({ + onBlur, + onFocus, + onKeyDown, + ref, + }) const context = { compact, - handleArrowDown: (e: KeyboardEvent) => { - e.preventDefault() - handleArrowKey(1, 0) - return false - }, - handleArrowUp: (e: KeyboardEvent) => { - e.preventDefault() - handleArrowKey(-1, -1) - return false - }, renderIconPlaceholder, setRenderIconPlaceholder, } @@ -210,10 +206,9 @@ export const MenuListInternal = forwardRef( return (

    {content}
diff --git a/packages/components/src/Menu/__snapshots__/MenuGroup.test.tsx.snap b/packages/components/src/Menu/__snapshots__/MenuGroup.test.tsx.snap deleted file mode 100644 index f4b71d82110..00000000000 --- a/packages/components/src/Menu/__snapshots__/MenuGroup.test.tsx.snap +++ /dev/null @@ -1,502 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MenuGroup - JSX label 1`] = ` -.c4 { - font-family: 'Roboto','Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif; -} - -.c3 { - font-family: Roboto,'Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif; - font-size: 0.875rem; - font-weight: 600; - line-height: 1.25rem; - padding-left: 1rem; - color: #262D33; -} - -.c1 { - background: #FFFFFF; - box-shadow: none; - margin-bottom: 0.25rem; - position: -webkit-sticky; - position: sticky; - top: -1px; -} - -.c1 .c2 { - color: #343C42; -} - -.c0 { - font-family: 'Roboto','Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif; - list-style-type: none; - padding: 0.5rem 0; -} - -.c5 { - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - color: #262D33; - font-size: 0.875rem; - font-weight: 400; - list-style-type: none; - outline: none; - -webkit-text-decoration: none; - text-decoration: none; - -webkit-transition: background 150ms cubic-bezier(0.86,0,0.07,1), color 150ms cubic-bezier(0.86,0,0.07,1); - transition: background 150ms cubic-bezier(0.86,0,0.07,1), color 150ms cubic-bezier(0.86,0,0.07,1); -} - -.c5 button, -.c5 a { - font-family: 'Roboto','Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - background: transparent; - border: none; - color: inherit; - cursor: pointer; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; - font-size: inherit; - font-weight: inherit; - min-height: 40px; - outline: none; - padding: 0.5rem 1rem; - text-align: left; - -webkit-text-decoration: none; - text-decoration: none; - width: 100%; -} - -.c5 button:hover, -.c5 a:hover, -.c5 button:focus, -.c5 a:focus { - color: inherit; - position: relative; - -webkit-text-decoration: none; - text-decoration: none; -} - -.c5 .Icon-sc-7y0t4i-0 { - -webkit-transition: color 150ms cubic-bezier(0.86,0,0.07,1); - transition: color 150ms cubic-bezier(0.86,0,0.07,1); -} - -.c5:hover { - background: #F5F6F7; -} - -.c5[aria-current='true'] { - background: #DEE1E5; - font-weight: 600; -} - -.c5[disabled] { - color: #939BA5; -} - -.c5[disabled] button, -.c5[disabled] a { - cursor: not-allowed; -} - -.c5[disabled]:hover { - background: #FFFFFF; - color: #939BA5; -} - -.c6 .Icon-sc-7y0t4i-0 { - -webkit-align-self: center; - -ms-flex-item-align: center; - align-self: center; -} - -
  • -
    -
    -

    -
    - Questions -
    -

    -
    -
      -
    • - -
    • -
    • - -
    • -
    • - -
    • -
    -
  • -`; - -exports[`MenuGroup - label 1`] = ` -.c3 { - font-family: Roboto,'Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif; - font-size: 0.875rem; - font-weight: 600; - line-height: 1.25rem; - padding-left: 1rem; - color: #262D33; -} - -.c1 { - background: #FFFFFF; - box-shadow: none; - margin-bottom: 0.25rem; - position: -webkit-sticky; - position: sticky; - top: -1px; -} - -.c1 .c2 { - color: #343C42; -} - -.c0 { - font-family: 'Roboto','Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif; - list-style-type: none; - padding: 0.5rem 0; -} - -.c4 { - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - color: #262D33; - font-size: 0.875rem; - font-weight: 400; - list-style-type: none; - outline: none; - -webkit-text-decoration: none; - text-decoration: none; - -webkit-transition: background 150ms cubic-bezier(0.86,0,0.07,1), color 150ms cubic-bezier(0.86,0,0.07,1); - transition: background 150ms cubic-bezier(0.86,0,0.07,1), color 150ms cubic-bezier(0.86,0,0.07,1); -} - -.c4 button, -.c4 a { - font-family: 'Roboto','Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - background: transparent; - border: none; - color: inherit; - cursor: pointer; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; - font-size: inherit; - font-weight: inherit; - min-height: 40px; - outline: none; - padding: 0.5rem 1rem; - text-align: left; - -webkit-text-decoration: none; - text-decoration: none; - width: 100%; -} - -.c4 button:hover, -.c4 a:hover, -.c4 button:focus, -.c4 a:focus { - color: inherit; - position: relative; - -webkit-text-decoration: none; - text-decoration: none; -} - -.c4 .Icon-sc-7y0t4i-0 { - -webkit-transition: color 150ms cubic-bezier(0.86,0,0.07,1); - transition: color 150ms cubic-bezier(0.86,0,0.07,1); -} - -.c4:hover { - background: #F5F6F7; -} - -.c4[aria-current='true'] { - background: #DEE1E5; - font-weight: 600; -} - -.c4[disabled] { - color: #939BA5; -} - -.c4[disabled] button, -.c4[disabled] a { - cursor: not-allowed; -} - -.c4[disabled]:hover { - background: #FFFFFF; - color: #939BA5; -} - -.c5 .Icon-sc-7y0t4i-0 { - -webkit-align-self: center; - -ms-flex-item-align: center; - align-self: center; -} - -
  • -
    -
    -

    - Questions -

    -
    -
      -
    • - -
    • -
    • - -
    • -
    • - -
    • -
    -
  • -`; - -exports[`MenuGroup 1`] = ` -.c0 { - font-family: 'Roboto','Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif; - list-style-type: none; - padding: 0.5rem 0; -} - -.c1 { - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - color: #262D33; - font-size: 0.875rem; - font-weight: 400; - list-style-type: none; - outline: none; - -webkit-text-decoration: none; - text-decoration: none; - -webkit-transition: background 150ms cubic-bezier(0.86,0,0.07,1), color 150ms cubic-bezier(0.86,0,0.07,1); - transition: background 150ms cubic-bezier(0.86,0,0.07,1), color 150ms cubic-bezier(0.86,0,0.07,1); -} - -.c1 button, -.c1 a { - font-family: 'Roboto','Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - background: transparent; - border: none; - color: inherit; - cursor: pointer; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; - font-size: inherit; - font-weight: inherit; - min-height: 40px; - outline: none; - padding: 0.5rem 1rem; - text-align: left; - -webkit-text-decoration: none; - text-decoration: none; - width: 100%; -} - -.c1 button:hover, -.c1 a:hover, -.c1 button:focus, -.c1 a:focus { - color: inherit; - position: relative; - -webkit-text-decoration: none; - text-decoration: none; -} - -.c1 .Icon-sc-7y0t4i-0 { - -webkit-transition: color 150ms cubic-bezier(0.86,0,0.07,1); - transition: color 150ms cubic-bezier(0.86,0,0.07,1); -} - -.c1:hover { - background: #F5F6F7; -} - -.c1[aria-current='true'] { - background: #DEE1E5; - font-weight: 600; -} - -.c1[disabled] { - color: #939BA5; -} - -.c1[disabled] button, -.c1[disabled] a { - cursor: not-allowed; -} - -.c1[disabled]:hover { - background: #FFFFFF; - color: #939BA5; -} - -.c2 .Icon-sc-7y0t4i-0 { - -webkit-align-self: center; - -ms-flex-item-align: center; - align-self: center; -} - -
  • -
      -
    • - -
    • -
    -
  • -`; diff --git a/packages/components/src/Tabs/Tab.tsx b/packages/components/src/Tabs/Tab.tsx index c85da413a19..f5f7771878f 100644 --- a/packages/components/src/Tabs/Tab.tsx +++ b/packages/components/src/Tabs/Tab.tsx @@ -24,7 +24,7 @@ */ -import React, { forwardRef, Ref, useContext, useState } from 'react' +import React, { forwardRef, Ref, useState } from 'react' import styled from 'styled-components' import { CompatibleHTMLProps, @@ -38,7 +38,6 @@ import { TypographyProps, tabShadowColor, } from '@looker/design-tokens' -import { TabContext } from './TabContext' export interface TabProps extends Omit, 'type'>, @@ -104,7 +103,6 @@ const TabJSX = forwardRef((props: TabProps, ref: Ref) => { disabled, index, onBlur, - onKeyDown, onKeyUp, onSelect, selected, @@ -113,25 +111,11 @@ const TabJSX = forwardRef((props: TabProps, ref: Ref) => { const [isFocusVisible, setFocusVisible] = useState(false) - const { handleArrowLeft, handleArrowRight } = useContext(TabContext) - const handleOnKeyUp = (event: React.KeyboardEvent) => { setFocusVisible(true) onKeyUp && onKeyUp(event) } - const handleOnKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case 'ArrowLeft': - handleArrowLeft && handleArrowLeft(event) - break - case 'ArrowRight': - handleArrowRight && handleArrowRight(event) - break - } - onKeyDown && onKeyDown(event) - } - const handleOnBlur = (event: React.FocusEvent) => { setFocusVisible(false) onBlur && onBlur(event) @@ -153,13 +137,12 @@ const TabJSX = forwardRef((props: TabProps, ref: Ref) => { focusVisible={isFocusVisible} id={`tab-${index}`} onBlur={handleOnBlur} - onKeyDown={handleOnKeyDown} onClick={onClick} onKeyUp={handleOnKeyUp} ref={ref} role="tab" selected={selected} - tabIndex={selected ? 0 : -1} + tabIndex={-1} {...restProps} > {children} diff --git a/packages/components/src/Tabs/TabContext.ts b/packages/components/src/Tabs/TabContext.ts deleted file mode 100644 index 29c398298c2..00000000000 --- a/packages/components/src/Tabs/TabContext.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - - MIT License - - Copyright (c) 2020 Looker Data Sciences, Inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - - */ - -import { createContext, KeyboardEvent } from 'react' - -export interface TabContextProps { - handleArrowLeft?: (e: KeyboardEvent) => void - handleArrowRight?: (e: KeyboardEvent) => void -} - -export const TabContext = createContext({}) diff --git a/packages/components/src/Tabs/TabList.tsx b/packages/components/src/Tabs/TabList.tsx index bce9752097c..819c4efe6ae 100644 --- a/packages/components/src/Tabs/TabList.tsx +++ b/packages/components/src/Tabs/TabList.tsx @@ -24,14 +24,7 @@ */ -import React, { - Children, - cloneElement, - forwardRef, - KeyboardEvent, - useRef, - Ref, -} from 'react' +import React, { Children, cloneElement, forwardRef, Ref } from 'react' import { fontSize, FontSizeProps, @@ -40,8 +33,7 @@ import { reset, } from '@looker/design-tokens' import styled, { css } from 'styled-components' -import { moveFocus, useForkedRef } from '../utils' -import { TabContext } from './TabContext' +import { useArrowKeyNav } from '../utils' import { Tab } from '.' export interface TabListProps extends PaddingProps, FontSizeProps { @@ -57,9 +49,6 @@ const TabListLayout = forwardRef( { children, selectedIndex, onSelectTab, className }: TabListProps, ref: Ref ) => { - const wrapperRef = useRef(null) - const forkedRef = useForkedRef(wrapperRef, ref) - const clonedChildren = Children.map( children, (child: JSX.Element, index: number) => { @@ -72,34 +61,12 @@ const TabListLayout = forwardRef( } ) - function handleArrowKey(direction: number, initial: number) { - moveFocus(direction, initial, wrapperRef.current) - } - - const context = { - handleArrowLeft: (e: KeyboardEvent) => { - e.preventDefault() - handleArrowKey(-1, -1) - return false - }, - handleArrowRight: (e: KeyboardEvent) => { - e.preventDefault() - handleArrowKey(1, 0) - return false - }, - } + const navProps = useArrowKeyNav({ axis: 'horizontal', ref }) return ( - -
    - {clonedChildren} -
    -
    +
    + {clonedChildren} +
    ) } ) diff --git a/packages/components/src/Tabs/Tabs.test.tsx b/packages/components/src/Tabs/Tabs.test.tsx index 3727698c201..0a5b2f5d023 100644 --- a/packages/components/src/Tabs/Tabs.test.tsx +++ b/packages/components/src/Tabs/Tabs.test.tsx @@ -27,7 +27,6 @@ import 'jest-styled-components' import '@testing-library/jest-dom/extend-expect' import { - assertSnapshotShallow, mountWithTheme, renderWithTheme, shallowWithTheme, @@ -40,23 +39,6 @@ import { TabPanel } from './TabPanel' import { TabPanels } from './TabPanels' import { Tabs, useTabs } from './Tabs' -test('Tabs snapshot works as expected', () => { - assertSnapshotShallow( - - - tab1 - tab2 - tab3 - - - this is tab1 content - this is tab2 content - this is tab3 content - - - ) -}) - test('shows the correct number of navigation tabs', () => { const tabs = shallowWithTheme( diff --git a/packages/components/src/Tabs/__snapshots__/Tabs.test.tsx.snap b/packages/components/src/Tabs/__snapshots__/Tabs.test.tsx.snap index a130cacc103..3248e17cb9d 100644 --- a/packages/components/src/Tabs/__snapshots__/Tabs.test.tsx.snap +++ b/packages/components/src/Tabs/__snapshots__/Tabs.test.tsx.snap @@ -1,7 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Tabs snapshot works as expected 1`] = `ShallowWrapper {}`; - exports[`focus behavior Tab Focus: does not render focus ring after click 1`] = ` .c0 { font-family: 'Roboto','Noto Sans JP','Noto Sans CJK KR','Noto Sans Arabic UI','Noto Sans Devanagari UI','Noto Sans Hebrew','Noto Sans Thai UI','Helvetica','Arial',sans-serif; @@ -50,7 +48,7 @@ exports[`focus behavior Tab Focus: does not render focus ring after click 1`] = class="c0 Tab-eojndt-1" id="tab-0" role="tab" - tabindex="0" + tabindex="-1" > tab1 diff --git a/packages/components/src/utils/moveFocus.ts b/packages/components/src/utils/getNextFocus.ts similarity index 58% rename from packages/components/src/utils/moveFocus.ts rename to packages/components/src/utils/getNextFocus.ts index a6692487cac..45197d3ba2e 100644 --- a/packages/components/src/utils/moveFocus.ts +++ b/packages/components/src/utils/getNextFocus.ts @@ -24,30 +24,39 @@ */ -const getTabStops = (ref: HTMLElement): HTMLElement[] => - Array.from(ref.querySelectorAll('a,button:not(:disabled),[tabindex="0"]')) - -export const moveFocus = ( - direction: number, - initial: number, - element?: HTMLElement | null -) => { - if (element) { - const tabStops = getTabStops(element) +export const getTabStops = (ref: HTMLElement): HTMLElement[] => + Array.from( + ref.querySelectorAll( + 'a,button:not(:disabled),[tabindex="0"],[tabindex="-1"]:not(:disabled)' + ) + ) + +/** + * Returns the next focusable inside an element in a given direction + * @param direction 1 for forward -1 for reverse + * @param element the container element + */ +export const getNextFocus = (direction: 1 | -1, element: HTMLElement) => { + const tabStops = getTabStops(element) + if (tabStops.length > 0) { + const fallback = + direction === 1 ? tabStops[0] : tabStops[tabStops.length - 1] if ( document.activeElement && tabStops.includes(document.activeElement as HTMLElement) ) { const next = - tabStops.findIndex((f) => f === document.activeElement) + direction + tabStops.findIndex((el) => el === document.activeElement) + direction + + if (next === tabStops.length || !tabStops[next]) { + // Reached the end of tab stops for this direction + return fallback + } - if (next === tabStops.length) return - if (!tabStops[next]) return - tabStops[next].focus() - } else { - tabStops.slice(initial)[0].focus() + return tabStops[next] } + return fallback } - return false + return null } diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts index 68fabde1f7e..2bc1e1ed13b 100644 --- a/packages/components/src/utils/index.ts +++ b/packages/components/src/utils/index.ts @@ -28,11 +28,12 @@ export * from './getNextFocusTarget' export * from './getWindowedListBoundaries' export * from './HoverDisclosure' export * from './mergeHandlers' -export * from './moveFocus' +export * from './getNextFocus' export * from './targetIsButton' export * from './undefinedCoalesce' export * from './useAnimationState' export * from './useClickable' +export * from './useArrowKeyNav' export * from './useControlWarn' export * from './useReadOnlyWarn' export * from './useCallbackRef' diff --git a/packages/components/src/utils/useArrowKeyNav.test.tsx b/packages/components/src/utils/useArrowKeyNav.test.tsx new file mode 100644 index 00000000000..11f3a683be2 --- /dev/null +++ b/packages/components/src/utils/useArrowKeyNav.test.tsx @@ -0,0 +1,155 @@ +/* + + MIT License + + Copyright (c) 2020 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { useArrowKeyNav, UseArrowKeyNavProps } from './useArrowKeyNav' + +const ArrowKeyNavComponent = ({ + axis, +}: { + axis?: UseArrowKeyNavProps['axis'] +}) => { + const navProps = useArrowKeyNav({ axis }) + return ( +
      +
    • first
    • +
    • second
    • +
    • third
    • +
    + ) +} + +describe('useArrowKeyNav', () => { + test('tabbing', () => { + render( + <> + + + + + ) + const before = screen.getByText('before') + const first = screen.getByText('first') + + userEvent.click(before) + userEvent.tab() + expect(first).toHaveFocus() + + // second and third are skipped due to tabIndex={-1} + userEvent.tab() + expect(screen.getByText('after')).toHaveFocus() + + userEvent.tab({ shift: true }) + expect(first).toHaveFocus() + + userEvent.tab({ shift: true }) + expect(before).toHaveFocus() + }) + + test('up/down arrow keys', () => { + render( + <> + + + + + ) + const before = screen.getByText('before') + const first = screen.getByText('first') + const second = screen.getByText('second') + const third = screen.getByText('third') + + userEvent.click(before) + userEvent.tab() + + userEvent.type(first, '{arrowdown}') + expect(second).toHaveFocus() + + userEvent.type(second, '{arrowdown}') + expect(third).toHaveFocus() + + // circles back + userEvent.type(third, '{arrowdown}') + expect(first).toHaveFocus() + + // circles back in reverse + userEvent.type(first, '{arrowup}') + expect(third).toHaveFocus() + + userEvent.type(third, '{arrowup}') + expect(second).toHaveFocus() + + userEvent.tab({ shift: true }) + expect(before).toHaveFocus() + + // Previous focus item is persisted + userEvent.tab() + expect(second).toHaveFocus() + }) + + test('left/right arrow keys', () => { + render( + <> + + + + + ) + const before = screen.getByText('before') + const first = screen.getByText('first') + const second = screen.getByText('second') + const third = screen.getByText('third') + + userEvent.click(before) + userEvent.tab() + + userEvent.type(first, '{arrowright}') + expect(second).toHaveFocus() + + userEvent.type(second, '{arrowright}') + expect(third).toHaveFocus() + + // circles back + userEvent.type(third, '{arrowright}') + expect(first).toHaveFocus() + + // circles back in reverse + userEvent.type(first, '{arrowleft}') + expect(third).toHaveFocus() + + userEvent.type(third, '{arrowleft}') + expect(second).toHaveFocus() + + userEvent.tab({ shift: true }) + expect(before).toHaveFocus() + + // Previous focus item is persisted + userEvent.tab() + expect(second).toHaveFocus() + }) +}) diff --git a/packages/components/src/utils/useArrowKeyNav.ts b/packages/components/src/utils/useArrowKeyNav.ts new file mode 100644 index 00000000000..f8f2c8766c0 --- /dev/null +++ b/packages/components/src/utils/useArrowKeyNav.ts @@ -0,0 +1,146 @@ +/* + + MIT License + + Copyright (c) 2020 Looker Data Sciences, Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +import { FocusEvent, KeyboardEvent, Ref, useRef, useState } from 'react' +import { getNextFocus as getNextFocusDefault } from './getNextFocus' +import { useForkedRef } from './useForkedRef' +import { useWrapEvent } from './useWrapEvent' + +export interface UseArrowKeyNavProps { + /** + * vertical for up/down arrow keys, horizontal for left/right, both for all (grid) + * @default vertical + */ + axis?: 'vertical' | 'horizontal' | 'both' + /** + * A custom getter for the next item to focus + */ + getNextFocus?: ( + direction: 1 | -1, + element: E, + vertical?: boolean + ) => HTMLElement | null + /** + * will be merged with the ref in the return + */ + ref?: Ref + /** + * will be merged with the onBlur in the return + */ + onBlur?: (e: FocusEvent) => void + /** + * will be merged with the onFocus in the return + */ + onFocus?: (e: FocusEvent) => void + /** + * will be merged with the onKeyDown in the return + */ + onKeyDown?: (e: KeyboardEvent) => void +} + +/** + * Returns props to spread onto container element for arrow key navigation. + * Add tabIndex={-1} to child elements. + */ +export const useArrowKeyNav = ({ + axis = 'vertical', + getNextFocus = getNextFocusDefault, + ref, + onBlur, + onFocus, + onKeyDown, +}: UseArrowKeyNavProps) => { + const internalRef = useRef(null) + const [focusedItem, setFocusedItem] = useState(null) + const [focusInside, setFocusInside] = useState(false) + + const handleArrowKey = ( + e: KeyboardEvent, + direction: 1 | -1, + vertical: boolean + ) => { + if (internalRef.current) { + const newFocusedItem = getNextFocus( + direction, + internalRef.current, + vertical + ) + if (newFocusedItem) { + e.preventDefault() + newFocusedItem.focus() + setFocusedItem(newFocusedItem) + } + } + } + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowUp': + axis !== 'horizontal' && handleArrowKey(e, -1, true) + break + case 'ArrowDown': + axis !== 'horizontal' && handleArrowKey(e, 1, true) + break + case 'ArrowLeft': + axis !== 'vertical' && handleArrowKey(e, -1, false) + break + case 'ArrowRight': + axis !== 'vertical' && handleArrowKey(e, 1, false) + break + } + } + + const handleFocus = (e: FocusEvent) => { + setFocusInside(true) + // When focus lands on the container + if (e.target === internalRef.current) { + // Check if there's a previously focused item that is still rendered + if (focusedItem && internalRef.current.contains(focusedItem)) { + focusedItem.focus() + } else { + const toFocus = getNextFocus(1, internalRef.current) + if (toFocus) { + // No need to update focusedItem with this since it's the default + toFocus.focus() + } + } + } + } + + const handleBlur = () => { + setFocusInside(false) + } + + return { + onBlur: useWrapEvent(handleBlur, onBlur), + onFocus: useWrapEvent(handleFocus, onFocus), + onKeyDown: useWrapEvent(handleKeyDown, onKeyDown), + ref: useForkedRef(internalRef, ref), + // Remove tabIndex from container if focus is inside to prevent focus from + // landing back on the container when shift-tabbing from the first item + tabIndex: focusInside ? undefined : 0, + } +} diff --git a/packages/components/src/utils/useWindow.tsx b/packages/components/src/utils/useWindow.tsx index c6cebbfb33b..7847ae23dbc 100644 --- a/packages/components/src/utils/useWindow.tsx +++ b/packages/components/src/utils/useWindow.tsx @@ -28,6 +28,7 @@ import React, { Children, ReactChild, Reducer, + Ref, useEffect, useMemo, useReducer, @@ -146,21 +147,23 @@ export type ChildHeightFunction = (child: ReactChild, index: number) => number export type WindowSpacerTag = 'div' | 'li' | 'tr' -export interface UseWindowProps { +export interface UseWindowProps { enabled?: boolean children?: JSX.Element | JSX.Element[] /** Derive the height of each child using props, type, etc. */ childHeight: number | ChildHeightFunction /** Tagname to use for the spacers above and below the window */ spacerTag?: WindowSpacerTag + ref?: Ref } -export const useWindow = ({ +export const useWindow = ({ children, enabled, childHeight, + ref, spacerTag = 'div', -}: UseWindowProps) => { +}: UseWindowProps) => { const childArray = useMemo(() => Children.toArray(children), [children]) const [totalHeight, childHeightLadder] = useMemo(() => { @@ -175,7 +178,7 @@ export const useWindow = ({ return [sum, ladder] }, [childHeight, childArray]) - const [containerElement, ref] = useCallbackRef() + const [containerElement, callbackRef] = useCallbackRef(ref) const { height } = useMeasuredElement(containerElement) const scrollPosition = useScrollPosition(containerElement) @@ -240,6 +243,6 @@ export const useWindow = ({ {after} ), - ref, + ref: callbackRef, } } diff --git a/playground/src/index.tsx b/playground/src/index.tsx index a7e2a0bd1d2..e999d92f9b7 100644 --- a/playground/src/index.tsx +++ b/playground/src/index.tsx @@ -26,12 +26,12 @@ import React from 'react' import { render } from 'react-dom' import { ComponentsProvider } from '@looker/components' -import { Basic } from '@looker/components/src/Menu/Menu.story' +import { LongMenus } from '@looker/components/src/Menu/Menu.story' const App = () => { return ( - + ) }