From 6330d816ffccdba196c18e91464fb6e5e6fc2a3c Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:37:11 +0200 Subject: [PATCH 1/5] feat(ui): add Curriculum course layout component --- .../curriculum-default-collapsed.png | Bin 0 -> 20822 bytes .../curriculum-module-expanded.png | Bin 0 -> 28096 bytes .../curriculum/curriculum.stories.tsx | 75 +++ .../components/curriculum/curriculum.test.tsx | 176 +++++++ .../src/components/curriculum/curriculum.tsx | 443 ++++++++++++++++++ .../curriculum/curriculum.visual.tsx | 96 ++++ .../ui/src/components/curriculum/index.ts | 10 + packages/ui/src/components/index.ts | 10 + 8 files changed, 810 insertions(+) create mode 100644 packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-default-collapsed.png create mode 100644 packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-module-expanded.png create mode 100644 packages/ui/src/components/curriculum/curriculum.stories.tsx create mode 100644 packages/ui/src/components/curriculum/curriculum.test.tsx create mode 100644 packages/ui/src/components/curriculum/curriculum.tsx create mode 100644 packages/ui/src/components/curriculum/curriculum.visual.tsx create mode 100644 packages/ui/src/components/curriculum/index.ts diff --git a/packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-default-collapsed.png b/packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-default-collapsed.png new file mode 100644 index 0000000000000000000000000000000000000000..aaac0203a5f84f03b86f7f2e5ed48ffcdcb5aca9 GIT binary patch literal 20822 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 W{yq5M1q^&fK=+K{>HL#dZvGcqnw<0i literal 0 HcmV?d00001 diff --git a/packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-module-expanded.png b/packages/ui/.snapshots/curriculum/curriculum.visual.tsx-chromium/curriculum-module-expanded.png new file mode 100644 index 0000000000000000000000000000000000000000..9780a8a08408ff56b00868734e4058214fc08ea9 GIT binary patch literal 28096 zcmc$`byQVt)Gvy!oruz@AR#Hz{i>vtn{K3}W7CbVMQ)HrDWxT)o2|4UAl=}f00_k@L3}rY` z+l|E9*=B~e{vv%}n3xBIo=1;)__B?+C3tSPS6(l>_s%|dS&G**!A&8a>vZmH#I#+7 zg@r{Hj`UJqoed84`o`_o@TK{}IZ}8b_y7NkeX(<8MMaK{L1fp7S((_`gBLHr*{boq zfB(KWQ#DJk+Wq8k$4IbW?}eHg@fnGaX-9&H^-$5Jo8&1c0RaJb;FmQm{#sudETlUoj*5zsMDc(A_noK3C*panwcd@t`KFvsOhnY1rOts|MV`*z zpSS`81Fw^?YP_g+-$Bl~?**K2AYRpm&tH$=rR8_O>g(t2%+1YDJ80o=evqeSWH8m* z79+p=_;0*RczE~jUU-STc=F%);x&(yx%uM$=Co0g+x8q>#j9?o6zBGL`t^MlQ=<*1 zzP!CPWSZy|mWrDW5xYerT4nFipQ{_aVING!HCKl(He!oCvFOjG!pH7U1y(pM+kF3d zDR5a|N9VX#UEk|)G2Jq!xX*dmy1&GF*w`&r#KV~tD;v#@A?G>#)lMfFL@MZLCa4RS zq%TLi)V%wZL}CrmEpoW}o?WsFTlOcOrAC;%Dw} zYB^egu`Eh1*(MV|{#hJbtvOnYwJP{UcdS620Xlv28FNM+~HlO@7g~Iz9PChR2R!7#)rBd zL_0ztMTtsRh^J@0AJCZSwL6#=YeW9Nw8&(v%8lT5BZh*cw@kyzc+o75A3e1Qtsffvs8S8r>DXH>pw(Fs5ntCck#BIlA)!kKn`jl=aq{jIvcB{FdOYl<*Xq-rGL5=eRXzf$9?M;qfs3FIlR|af%uJZ zfBBDA3PXg}%KR!fQ35RpH1YE>PH%-M6Shm18zPn3)e1nG-%TqNSFUDAg*qfaQGhD%P5qjbJ(4WM|A{9Ye;& zuxAIq)OiQZsY&j2w2SF~T#{na8f7kf!&b+6&?Mi0%d|a+>z?kbY$k>&xt2CkwhrNK zIl}wXR)NaQKA*PdJAB~t8xh`92o4Jin=xG;$Tx}*gwxt(%EX?X9ucV#uG&^@O4|H= z-#L8??Kc@mx*?rDZ4$uux7C<0jd$4$o`6Y9#6S~i&i?*%$hOOasN36(;@R9|q^Q33 z9QHBh{9Py`jU^3Q?zn}}8r7#aXh)p-br*vUh)YF6&&1A-w^NR-o!6`PL^td2q7oGo z4=3K@cis075j|v5BS}`iiic;c(^A&#=6g$&{Lh$)HKvTfjAGiFE;rN#7C>8`YMq$KF6EKnzZqfren`Rpvg}<&W;t8O!8IHdUNboLhds0E=DLT_!BiqcQjJi#;qMe zF;5oxU}w)IDL3l1H`WZ331 zmE#MWBHCN(D=%kyT^s1|ns89-qHE3>ni{Qgs@zo^^*KAqFNT}#`r8$@c$0y@JSK7s zey6;c)xd*xQ_pqseducGnESGHGxo4IFPMMI&o#Y4nRByA?39xsSa2yrL-)Z9^nk|? zMdOgQwP?~2NH|->G^6GxEP%>8JUv>c>zv_}G#-X`P2Py?$=`vDp#X`FTwKQz4E?~ALvp7!*@bxJv0 z+#LvF`o(KA!vE0cA{|AO5TlEP`wfzYj3@3F3epW=i+T<(VZp_EoP0SMgS?s&|9|(ZJ5Kf z+*uY?Jasx!zc2M>*%oz5%DCMMFpglk&Y@KAyD*+A8(rk%nGiy5Fl&R<&bOCtCQ%c2 zKBWEoU5jypM!VQs#|9$x<0%rX>%>2oZD3zQkDFdhPZT}Dbz`l!}U{7WXQz#WxSOmg%KJ-q@yzeREQnLRe32TCE6ZDU3qfGy&mEAcL4 z<%dgIM$^!@b|l6O_?IyGQXQg4R^s*ilZ_Fwd9|3R6k+x`!9+dwhho>mw~lF&sZftG zbz!X4+)+(#QI|%UFy2tX%T0~aMNjvq&)?#|_rWf=Vkwi~Y|~zREVv!%wBcK=_=X`< z^v!K|?hCX}D~cXGc&4!Dr{?M&wV*3x+Eo;=Nm3Acvf6fh#@H> z*25y+zFgZPy;ZJb5xEiVsH6@NitCzaTR^HB@)A`1XAD0#pNLG!MLcjBG>K+fm)L(k zyv=2;M4do)Io_`JsyQdeQ(KkvOZe23k?H%C3HlELHz+$KC;1-h0)}^(ozk#BEFNp{ zca1yioyxFe-%60@sM$BPk|FHq%Fd@;jwfP08$TEP_73O%Y_xuGh8iF%CfU&fVZ0_D~%&!>vc7g{aqwl81Z{^v4vj6(eviJ9T!bYAx82y6ub ziEZ^xAHR8$x3=5;Leq}mJ8M!jUWD)0q&v~>5+7yXnzZcX81L%%DYe?S5WF z>0k~|yVg{)nG^!uca~D$@^OZqt)xclebzg^E#=y;>*95KFi(ev-Kf1zCFm$UY?OPg zQ2gA%XUp7Qqj;>0mmV@7tEjq*;aZBPRNU2Z{Ervwj!yzgrB0K&Tx8w3z3n}ig=1CR zyZ^%bJ1RA?OA13y^nOIgNKR_V9cj-1iJSEa{!V(9>!9FC9ul%C;;}17BRRVQg*`Ap z1s4)?_nc{n$gXrE`D0^g@u~f6>T_ka4PBA5E`GRJpS&av~ zV_weex#WA3LhbA9S6}MSLM}F^9oHL5bfM@Hy!O3CRnh!@#J-gc#o|4U;iHX8XWJWf z8F%OqA-QYb!2dzX-sNRr&Sx%Dg|%p>=^L&yp;CrZQ0FV50dQ2V6r{9F=>!#vr^&Zm zCZAgOXG|CXHX-3GI^{hXdyyut#`0LIeXTEkRz6ZE z{Wr3ucZY2gBS`KgPmp{w&B`eW;J=%10~j>Z&@YgQ;C+5fmh5%T$E#`}kTKHl7Iw zb?FO7&Yd>PqNXeXj~2)!i6)$3{#+v(Wq$6Hu?YRM#WrXZ7TiB%7vaX&X&Fnut%TPU7AfI^c=P>WjhEs@Z_Yv zYhDg;c(FRodE9Gn=4{PAP6SYn){4U624KhFaM8>)+nR${?05*gfvVSnwK#0$z{+ui z=YFrq)vc7QX~Rb1l0!s4N8mHeYHj&MvLs-_`Nyb#b!8mxszecg@@eldI|uaj-csPE zzu?K^IogK8lcT*58<|K6Jt1gC`am1RP8Jnmcj(q99S294yG3xg`IYTPErkKz=Hs&J&$BsLKKJlvUGfL7Mu0~$jRKRgy>huN7HP7r+ zsSs-Pak4|)xU5sPcW-HW0e|i09-MSJl%wbap-G_G6@i4^dHUFW9j}%ArnxnTh`XJ9 zv>NsAii#>PAb@m>M)Jzx$-0ji^y}j;bh>@HD2a~UPg)YE0R!I#wbaBVx+mgQ0U389 zmyY#+wXGM9dml-x`Avj>h{qkphMGl9mi&}0N&3rg&R3xA$9?pxMkxjC_F*|JNVR|lA6zmwS_llDCt3-sn z?FuEQQ2Se^Noto7bU348Fv$vl7|d_tomGB;p%QC~Z=x+>p~4;f(?Lu&P5e@h8C7FU zFB^BI1o#r#bnodtabKQGIquIg3QsbfZ;yBAXe5@zh`e#U@v*+kFnbbOL-W}oI!=i| zXM4FLzZe+3XV7dt8L<#W`qsgGqW+|K$2ytqSDY5KhRxuB{13;Xp&rRv6co4PIk3lD z8Rm?#P-B!eQx5Mc@K5s1((=xGCz0Xd6)+UYyvxg|mQa0|Ssbd9#LffqQ^tuT*<-C> z(ZdDfPps^j$0^(`b1M6X=vmR77J3$qpBbp+D7R?ut}3@}+Aoa+XMmn*GbMsUD2Y`_-r{+hGzfgf0G>d|9Fzk zNuV`w8Z`z{3KV?#^2G`6u2wNKE2~_eS*rjZQhK;v{Vv0G$!_E8jMsP}su0LIwm>sf zHJgEl@`K`lR6#TqLjF#my~R8A$wW2Mg$Nh)s%bl|jT|s~hysTWXOM3J-8fUt+KoC3 z_yJHQS?Ku+L`Sg0S1(iA{e85%dKa|KMC*--pMV^V>q~)jM1UH4!Sc_SpGfx5*@o_D6ET6N+u`+*wM)>-0h#Z=HWtH0t;!4V#_M{7$SDcFR8rMT7qs&k?>qBqlfYe|;T!NJ4ts{H@kB7L$d?_A_9*r&X`Hn;y zse4gXARFXt_QM9m6MNWIPhneyo_tS|Rz3$pUoa&C1thKvd2Y9@10lL{kKh^eZz7)B z`2-L4r8=jjp2czO+0V-~l>e|>B!(tMGh5{7w2BeLgWh>|*pIbO{tM33^}(-#f&!eu zgCATS!dy@7RnS6?3mec&Q&%_b;D&^pM+CpU)nPvC<2ssCyIxiG)NX>1WLg@Z?S3ME zT-kJRpk~tNidObY@#Cy25!7?c`#ATdZ$v|Y6^XA`!O4VW`ftAk5z%jS5Z0A##bR0! z8iY^(c=>dM`-tp~a4}3h@S_r*e0P@}79oJpy&$f3riTw@S~ynM`f`Sq6-9`O7f3aK zu^kMWCW-(k)brZuHCyRQm7VDw2$illQ9Y1DJ zD3(5z)tCktd6x?zZ>6Ko+HqfC6Goc#|qUfH3AFTC*pQl zkETHZCe~aZFbcsK!)o{N1U0Zcoz_hJCI`u3RGaT?7?IV%_ytSYVE%uInz)y8Hy>sG z%&XdZe&bO}hvc2W$LmMySekA)w1aVfvxjn;Tseqc$d=t~q==4+YW(pGC{1QTF5Fqp z;4N8>YQo|+(vN;*h}NFrJh$gRv`#y34N~ldX(0a=p&gQx7<8!PA*hKooQCxv;?rCY zDV#y1n0KoK`_i?)3_XEnL)Vmga0Y~Pt*Bdp(TS16TPzMM_qW7Ws&;g;J=Pvg1q1Oz?07Kx_{Q8E#0Nnc3sg7*@rV(@i1-}^3Wguiz@ys_D$(FsW ze$)I55trd+4`(_C6Hz%27%Y;?z-ESGxlW?zYH;<0?nk=%i~dYi7H6N|F`Uzln7|Al zASP*4NrZ9Bl(Wmw!ph7c(+RM-MlhCGx}-qabYLSe13mjw)_5QEdYNzrqTFiv7Ui+;26Z+y z2XkVln;~Fz`DJPkiF)j=WNb_H+U;*lM$TsjaG$IxvolN?6x6cLU$fIJ_j5yat`3*l zMzlMGauAvHrpd?7QRf0JxyvBu$LtAc-W~%R|J%?|7^$nDlE;M!+$3icWjIZVPw+Kh z(*2LmJh02Tl{VQfR}1IYjp7vZ-x9o@evqY}r#GZgYBQ?i3k7@+l`slmlnx^`FI^s} zZ53yScAa*VqbafMXFqK`r!zxtlmx&NrCo!tx0?N_)skU$=COmH6tEONGsQ@JvaS^`RWdVi|q7e5=KS8{nT0aoiK8}30Z%>%h?5tC@oXA}?wQhc1w%(o<+ z!!{!CZl#TDndkF9!YPZ0?N7Xg_3{%j{uUK|T6~!8wb%vIu!6ln5&7i~Ogcs`F7R0P z{XU^Y9h&rCGc*rf2o~8Lz!^j=cFUEGNIZ1v$v~C+CQF3`w<(l9+G`l{E0fiD`e2)s z!T)`V%a6~3P8LJdoritewqVDYR=TWH<0pdZ%tXjIJFheQzFvmdXIdk*cMB)o7&T+7 zU6Wz2ufu+8hdx@z9wdjWKfv=bA&_HGh$FpNICA2c%uJ-~rKGzJa=(n$?<#1N zmf+$UumK=|S|OG&w(-#3NI3h@_M*gvlf<)wM2RuJtO7u<_b&;9bs7+|-;fs6E3uF!=f0xI;Ox+p7Z5=lEa;}AyAd=d2Q%DEq;tcT} zr$t=wAGC&{-6YtFEgTHuaSSb3`%s=(r8{vC;b4=&O}luBTx0hEa?3OWw)Zz~ze z!OvT;O+z(aM@;ADT>Pr;Mq;@?zi&pXR#|t4HMrB^RGD@g>&$}E!32D-^12QzhdYtK za98Gpc6yZQB-8XIF}m(^pG?Vltzsg4@OKegPBUpU&Y>MtaN8twxIC|aLgtvL+ zjsKF_`&3t{wCSQa7H`U7YhD^O7o{fQGB82jq6q?7H52 zmE%b1Xp7|wSw)Hi;#--8@Pl%f_it^zZ4Vu>L6Y6zK59{9=HoL26Yx7FZG$&Th2gBX zpN&*+y!|(n9a&k~Jl5z4{ZF^wUWCSPX*C9YE?Qgq!3^9U(Oa?+!A2BGziY~R8~!|_ z%*VBBhrVN!i@8mA3oM?G*1k8*&a3=~7C`O3pzAW)ix|*sS}Lv zglQRnjA02=2SxP4gf#uHj;OTE7PXQ0Srr+QN%cZ+Rmg=N*nsE!1v04Vg zbY12>x!u>-wr`R?x+kf|%pq{{cw;J9<|3U`a8OYdxCiR{oi9N`kA;$3jaVR1`hoDb zIbifXHyZ%SmO?S+SSzUyeL@=#z{d=&{xx?BJkV16e;sp{&WWFI69KO!9bhvckZw!A?Z%T8m!hvpb_aiWeq`cBtUzcxomR5)#%e z^q=nIfhzRz>c4OO>E3_o#rt1dYFb)?a1@~W^k=#zCMMu-%0wky63M0UjJq| z56FGCauANm+GT(1N8N{;m5XVyW8elv>$xv}OHT-Jj6NB(rmZn2&d_5S;G!-(b@u@Y#-mATtCY<*uUeCg2fkz~5j*CyEj+ zIzn{=VDasdEO**n!1Mu&sX1k82`p*Jr^0YnEi3^~m*Fxx6b}M_lhWx3DdVIXg{;pzAuI>>Dc3)y`utl5b zs(==8xgC7M%-n@AS@v(xI0}Su%l#a+^N1`cYSoz}fkL5<OSZyyORXW#u= zVLM)v9|gTwJ%*)k2RKb*7&ISfwr4`G{7=iRDIUH20RWpe7`gf2?jrv2uq~F#oQq+o z)D}>5KpnzeimCuoAznODLl?A7;#I`sZ(LYy282!wHSTaZ4@pcQFgyXcj=gaM^ffx&r{eX{I9ZlbF%*2kv=$du;FC9v z(*^U>zA=1X4klr#fWzDhAafv&0XGgnW)bMqfZgwi;-r${p|u1>gwCmHr~{DqKoH^= zzY?1c;xeR5wxHTZY~lPOqhJAsIV6sO+=YQW>8~KFD;J4Z1mAyKTU!g_7IRcLtRBBM zP>P;dnWju3<}H-OcB+3`gs2Pa*=fKuLH!+UMKi1koTq4;wsfhmLsV$xd!3-u5~0(g znH+LS)LOalq-J@SK1B}u;?sDKpqm=Rdt~VhB)f)RzP*W1Ce$M@-le2XzBE7wWK>)8 z7*UV{pVha5wtTXjhYiMt4$*0G*yn;doPHOOkN)qc&!$0mnJfzcYz)4Jz_2$2P+s+AvYhB z1kr6tkO%qe%yK<7!VNlh%afG<_WJ^RSNy^6x3>hDqISValI(N}b%=~=P2_WV=yb7@ zjo734zVD3ITqGp40+%e0F~-9H5zX?T>ETbD(0jRrL#VS}`)7223!A2xg~nwD9Nt<9YC(q9VpC z7NJF99$U#t8O!@DGPWduBa^iu(FfF>KOkul2f%@pYAX>_V8P0K$Gk@&`S1{I^!o9J zHlq8^5af_?TjW{F;Rv@OPCY23hyu0%Gwu)6$tX7MxpDP! z?NXb~M=i0vE%9J|5D`;6;bdEt{HF*qV}OF!rse`}5$Nl?FQ!7_mX0}S7;qFoEV!Oj zBM;~?AppIEXie(-kq(_80$u@FyP;PGpxXLZOJ_2ud3DJ!HUyz2FpmFft76)2O>|M| z`qT*^tXUx$fYQ2KLMicdvg%`*19RFo<@8T?z?-HM;4}Mk59G3!p9q7 zE%IF}A!6^N;(8@_p}ax&rw|nH7)vm$TCrpMjBNI~zrq>$Y(`u|Tm1*fe7aj>lMBcV z_zg|hBsHNMb>5*UFDh*a_7sJ}3K6JTpL9M(pC-C-YIHpTf?k{?KJ5X{DO<|S+7g2E zxl*@DE^**qjzxCjAg9ep8PT2BnsEp_g2plq`!DI3;z>2sJtza}u!MeGW5CP1fWY*} zCM^(0N|vMgSMz(>01-D~?0GrW9+qf$uZ?l~|59uH&)f0)t64d)Bjw?)hALvHgq%T{ z;Y2AZJvRIEg@8^HgeZtx;gP{<<$}fN2@DoC;9g{6K=x6OvLX?vl)^}a0a)QE@K7jG zrXcIUzWrN`twZ)p^1%KAG*w80fp?*(76a1O3ZN+P5lx^{hw-Qw7#I{=^j^Dmjktdj zc7-3_2oPHd(W76CiB?c00k8OtRD!U~}0 zdx%}z19Vmf(ULG8^$0u22}b|>WXkOS@s~hJa8QLs|I#R+0RBN=D`0OG&4q4;Q(Sbahcr+Q&x){VqWE$N^0mwBPB+aKO6Zbh=7gK2-=K(t@Q zi%1MM`~RyLtOn`p!2dI|n*8m*Y1<$h>PJDWF0bwL#oq@qXmh2gZ1-#7Z^&he8{h9N z5%$PE&U^H4_4&;l%a`WZGGhEQ^ON^8D-A0>D`P8*Ft;oQ9p6E^fY#?z20L$(6rB15Y~!Ep>R1MXAoMb_=-xvG zwYyoJX}SC?Vt9%V6WkhpP>ZB{qU{Q)I&2(%^xfq3YgA0fcXjAWH$AZ%u8}V(&pmmF z$?VNK{0ofB(O@c}5`gkv`~dtBECt^~v}~y$Qq++sH(aBogGf%bzGSWwut3&Icy z2z2vx@W~A(74@(D0VoIxn&dq23Z3bX_n(0ZG!EYYyvU$>`@|>}v_?&+iJe5El>X#R1o13#<3Zrr z!#_*@^q|A1v7!R(X~3^ndIMLG+6OfZb?ME0gH6F7R~^7rY8Yjfm+rLv2Yz<{#C#;DGK<0h`ld; zmpAKo=;P(0p{Yhrn|x~hKuL}VjZzPi@2HYE?eNE-m^<=>&*S}IULFoTZ|lDL)2qS} zv$!sGlbxNdeEHotF|o=w9KUPrhUnXxfiEVNGg$V=5+T$g72s)rKoUwP)tG+h)TcS0 zmG?V|>*znUfSQ0tu6BMSw1b3nu&WcKDT^&j?P5XUT|91mWrRW;c|SqJwdZ3*#`Kpb zd?m5oa)G)epS+cYcvmPp_iU5L_YcmwUKMm+AwBo_`A>qNX@edK3O*Z30`|vxy(D)I z9$BOyDR!<2(3odwk61On-?O*xNu>MMSSK9|wATDkg&Pq)5R6eKWII*`43%>Vu}dga zr(#%27!_)u%L>vuzWcRL!PvdoL_@LB#_2Ag5q)1pV_#1Er+* zKv6wHlp+vdzlTwXVSfUokO!)ejKGl5jfX9xD3-N9U!QrK`2bmY^*e^Z=o~_-R<9-uJ@Xo1NEG(Swpc3{T7{Yu`JpI$#Wb5+-HM~~Z<{mp2#pO7a5!N+z ztF6DSxc*KJ1&>`_aetonw3q-F^KTTl&5KI!EXlU*(ywO&_bEzr#J&~i9AXytKPFz9 zXyYBW+Fcoh@ZgD+{u7nCsc_FzJ{daknf?LdhY-FhOR9>Fh+d}k68DpCd8XOy1h-Eo zVxh~XwX@~M4+q(K{Lfs6OU_NIHe%Q_eZI`$U3F{x5Z}>QtCNn+bq*$$Z+b6O8tB1_ z=|{_TFJ`_Q@OuMT)#P0rH(ryt@NcZ#A!EYir-!j~35n+S&#rMv9-T zNa$_!Yf*-5XV@`osy=|esfNaV;VYU65LPb#>->ysN2I3CP}ip?QYa1pu+`h!pW|;I z^&7lQ?fO!#Y4>^^g8ADoujIP@O5{H&66BG)ND(>&jJ;yP`K&;4yU zL*i{3Uv=N~2Re4^{-T|aSLt%y);MBI2JL?LY_d`CU@guHB^AhAwk<`bl$|v-?T69o z>S&>4O44t}p%>E;oe~oLsDzx45_Btw+!5*pD?LHSh}C$i-XGN%XkiF9jm6X52Lk1^ zeUcaa?&#uz-snff(@B5~utuOtt6AT43U-*lU$Ww_{rU)uX%(q}^8DITzAa!Zq5g?v zetPjFlcH!9wBf9f56I#Gvcwp%|}4R)vUlh|qXwuyG6`iG{r#DeK`5|PS9 z+mh_@Nyg|;EExrJ{`WJGm?TUOs5VZ71(+0G$;Wz32H$EMj*5u7k;pw4WFsZ1>oQlR zDK+lZX=FOuRi^Ovn?o9n!(Ic2uH!n5-tqES>a8eAfmH3^-S2sH62O zIujGO~1?dR38>9pM2`3c^TihU={U1@d$JVa|u||76Tl+5I>N zUD~>5`*3&Fqvc`x%;gD0zOcl?;-#H}V#}gQEg3>x{g~y4`KqnW;+w3QqgG6nCZQ+S8d*8D@zlaCa8Kh8I5G zs4I^(VpC$>Mq*L_#PKT|#W1#a@|)%V*0!zYX&=gvm0ARUV?7xC8Jr}(4=P=O`N^4- z82G6Z&i{RViPy%@%bbpcmj%YI=3`|JgiO)Ve>WvLgYPtQsa^}lxHM)Z4y)H^#G)c1 zanu`&$}}gI+fIB5Ts~_n2M*pREAr;PgWL1L@q%B`qnGqC`}hCa$oMwRqE9|(U1#0p z-{?f4@VR_rs?E7=Sn?3#U%bR~Xtq!r+j{!Qz4_Vt+4dxULdfD(E(Uy3KC8+d3Le`( z#-WR)OE9*#zE4V%Itnc^AGqAb*)1VRdDw$~kBk|-C+mR0gZ&$c3LOANp=kz0#E##0 z!G4v#K=8(p_KnYS~sHfpDhv{uN|o2lsZ zf^fq%yJu1T$>cg?Zq>#Y1LJl9IwRr%Xns1GWJZn;^unaEoK)X9k`|He4$4`JIziKx zc8L|)Hy$ri4!7YFYp4~mdJn1G9*f*>X=$NVOw%tuks-K8SKZ_uz7?Kb%a+HYF+<;k zZU>_?3oueOX(eznf|OWLKgvyKy7)u`5!MI=s9>yy}9mCEmpf;_PzBDw0h zv-?pVo<(g`2a9~d!gD#!#aeyLI!o$kbBTYr|1z$U2HO5psg3j``7?Pr=R7kBj^3az0-^FJ zOAe!)-GrGb5iLL$h5T3YH#tvW56{pPk#atd-iDRhVE5zm9*Z%6pTV@>38!jUd~F`9 zzH+A~GKW_?G(r!^p%-pelC1fy9Ey)epV}>46j7+2tf*txDwSC_en|A-qLAajRB_%o zX7NfL-|=z{x|@-q^}!pU3`puOsDfA`ZwH zk~xH$bRp@}^2ICvK=jCQ%^=7x4$|l$!duce?8W~PZ4Y6>As!OUcZHPG-kEoA^e5sx z`secLc55TbT68u-nf7j@V~+$fjKgnE>)9ya2*8?N)uEn1N*MA(;2fZPq?2z^ny%jQ zF5u^Vg&cFSW$)_buGsntxuOYSQ}WTtT_x4(get37$!h%dH0 zIUW%edGg48Fn|m%hPDDs7DyWt~G*Hr^*c1$iuP zCURG3zD=87cY9h8;qCFKTwU0sdw6^=zVfBsyGi1kpCvEyYce3I+|SBewhhOC&2ErB zn&?r^B5Z=rR0{Tf<5SvBalGK>KVRz<#~T*0RwiQ?Sez`du}5v>-piLvS5+RKm%>#W zPgKYDTAuxoBw@^|$}njB8AM36>GX?_FPNX?eH)XxBwp_KcR;+Qyo1H#Suy-|hK*Oc zRFCsV(9?|)ei$NEGa4ahD;4y19-jvDBFEKIvrgK*!bVj-C{P0@$F`d3}U%a3?XO zidEBHOo38UNo%O)KvAEgybKKOq=+4m#iAl3f%>tE4lBm-b1Kz=P6`7sK)Fh+2JJr? zH3V>hs2WN0I-Rhi%J5}Kf^rRi*U$r&|7mp;KivAzgk?*k-66c3T@u0`^Jk4tcN5$872MGEXX#h-E(HJUz#+ z8WwRYP9!tq%XhC;wf8cSxpNnx)M`pb$$gGyl~%jV6TV~}@c98*Ntn;&vVx?P@5=E` z&^BN9KAEm{HtVXaOaGjiB}}fUkqhgW(Hr z{D4?lK5Q-7;@=R^LhLC-qJk`hTMtUA7}7k}`rlvvgow^A_-SqNLZx5;L#Rat^wh3& zK&Oa|j)52|2y4C&IsnxT<(7}kSg}sNK7R{wHCs3yfy}2m(&L8$yW$}elNsPfzz*R& ztu5FKf=|~$sRgQaOvn6#z$G?1&>t}h!TMV3PVYbzah2=GVDtc>+yl!J$qm-|;)gIH z=rT<)9<-d$BA7&Uhq@~crk2|_vEF5x#XS(A{U3QkNe0{09oQ(M?&-Px0_hk_7sYr+ zgvdMf`)~y{a^&KQ~20aCRoV6Vn{is zkP#xFsnH`oup5yyr)|(XqKDZVX zroeo79zlb$R0NU`BG$Ir1aM9X!*U3sFIop71w|Q5A~YwT`3h73B)c~=4^4BH*E^Ip zO6sIsK9(0{s;84k#`UV+?{66y8CD0#yy{js${;(ozLf6r7;fn8k>etH_jm}oj*gan z|5Yp-YlhMydgI-s*Cns~C#}xEd~{O=sX?v-`4c9kkpRcbVwkz~gamJ=Ya^Md${LNp z0SGI@pusRg(y6x+;Xvfbr7#;eOU5MGmBFQu=y{&W+R~1Av=Q;)MCSDBuh+puLt;kq z*soy55xB$8>U4Zz`b{8d9pqO1hKrKAdw@a9R|;C~*%0^T!bHk35k{R8`;Nk79!Kww z3cwfLzxRQ%YZpm|f^C;MMUITQLI!3BsC^JA766(ET-5wHAY3z&9I#ZsBB44?Gz@t( zCLrn*V4OBk4q`+rkxAbHg>+=F3Qm|!Z{O8mhO3x;a#Do1_>%L*!8xh485h0Ly zo=p4BatK6i9DHAY)s`4;>3?k--{?|Ef?!Q8FAji{CV4rIqWc8O!iBhuTcR+1NhByW zusB+|2SaYu2CInB2Eha*mXSIE{t`03iFrEB;k_9sU2D`7oKuy=v?DQd&~Xc9etEEt z74SVo*2o=(9HRYbRC1)|xnTQ+*XPdXLKH$r7Lp|@!}#F6L&A3(=F+~wdRNj5!%&ho z6~RcGSUFN0`t$UC5U;Vg{`OPjl^DOIWk{{K^l6$({0ROTWES#;;^MsP5+tkq_sKVf z8rdxRvdPZz*t;t6ASekE*rB&vDIukYz9jz39SJO0BT8;EXG?$RwoxjT;IX#{cwa>ht_*fqCrBCON9{0@QZau9)Ra1ey=qm;B}4Ll#*Zp|vGu zn|W}lHDENl8J7?CoVZ0GEBRIWe$vRvEAl8qN^Y|kMd{JYlJt+GKI++0$PT+sV_-g{ z^?2DL*^n~7{QpiGx^9J2Of|q{kzqSg2 z@uT^t*44DFBgaWiI$#}ea5mnSGC1CUUq=c%+P}q`aw?OREBGa18t$zmo*mBvUw~=a zGRWb~C;E8z7$tkdyiGe3l|BqFLevHcVAI++70UGLz#uw-3-U+-NHVWL)+8wZBh#29 zwL)MinnA*?9A>@|>z+GJU9@RH@KOGXQofd1-r7Wsf2eRO)5v$j(``>n4y-#v{ z(A+9YzBT*XtuQ(DY~eoO(XPCTME4cv%T*f(+MQcsd1F3f)QsZ7!AcHioNtq3C^4Z~ z_~Y)0nd##x$6M3L?k<)4^q8Jc1n}T-mSgyq5Si3{zJNx8WJJXf18m+&N;CnpQnQER zcY@2b4J1$(Fo$+2BUgGuTrzR%_6|6#SMS9)SSSsd<}q%2qM~d*fV&D)aIfzeX5<*@es~=G+HY z4d_zJEe+3;aqh@m^+TUz4DaDD9?4?fxqw-zFvb2m9)tvfnJolE$={KDw$wB{=wchY zhdgYc@zMkE>&VleZI|rDA2)>+gpHjS^;$~yv94KbbeQXi7x8J&ORRpl`pDySBwv}4 zJ9l^@i$Kf~1Zb3UQ~EbX{nDGIGK}Q?*8JvkwfsZ5Zo|A7?fQDH9da*jM+38C1Kda7 zv9tL)8ey95yRYP^AK45zFY{T9eUa%8)}|M$1C|Aa)kVsD1HxX}u#ldVAKwaXm&A}e zL5E9IUzEJM4QUWR(NJ3lFL(kE%<;81*LK*_{PP;V%JuZ-ziKnz|xMzYBh`jJ6&`0g*XO=*5^u z7$XsNQCx(yA5u&}d-8urskw*mv;U0z#k7fRKlSAbX3Hu_U(zk7jc;MXc0d7&jyMb6 zgzy=;!~~4hmjfH_ykj!YFJbQ>@s+y1*iE+ zVPe&oS9rV@j}5^$er%mhdD>`-`8gej2ziUhC01w}Gb zKlv@NoFxR8U0~D&@<7{3*JyleAe2v{>ximqB+6~mizA&`==r9sbV^r;bl=>Q?{&A3 z2gFQ&!&xFv73qR%g(NOgj;Hnqns9sXV7BNC{)sWbKHC8(GxsXwN1`)~OQ|U|WG5{iF{mRRD`t5!0C*F1pEvKC+E&NCc!&-l&y4|0;{qW}*L~Qn28KOhV(1-9GFA{2)YhS$?NWo!6H^ zJgBLzb-d{R_uRi$DVOnJ^~t>6=8C+n&$!Fl8#jUv+T3R3L=(oJ#GkuE*eYyGhWWD+_7nKKa&m1#R!5BLn#1Kt3h$XQ6# z(kehIB7A0;AU_*|FeVgI9H!PPh+P;$HouetI{iq)7a%zV`A}~U@u{!Rd>acBVIfeQ-QRLwk~Wmr1B8<~hu3 zY*QF{B>A|f0^|FBja=t($x?i>!cm*wIWSAC^{~H=!!G)cJB_uHjFGH51kP7)ndB*c z@IINRJCg)|Hd5%vBg}SO@7O=$VT2 zhpaNc8pK3^03?MGBNpaF5S)3l`2Qvfl*$s1O}Z_BUs!46%LdT-*ExPy$Dvm&))X;RY3~jQ+K=?n)?C?s+huwg%SD5V^)hFWPNKz1hn+6p(dp2K*8mt?(c8RgCf(^@h+wJG;B=^OqhR;>CO{z{GGe}^UzFU0qMX#!A;;S=EX znf-T;a^Bl+c}1V99B!7o4;V zPs%R_9zV>EHXhC-dM*3>`70>NcY!IgFf${GCAFf0Z!*NJBl725d*N|*yKrZ3!hmjn z0Ssj!wkm{yA+Pr1nz947D-z;nnax z0FTR;9fb`{&|jSpNH_pg{O0pdKRx2~Uh$&*B8THVfXxd{jnTeP+YYB4x!AygS7iyF zDeFhGouzomWCib$WQRwoO7>Gf%zNx{#B>j*L$4o#WZG0`{H3Nc z8-be1!ah>V!ZvY!C?o^IZ6>f;2<5+HLUBPRJfNYv1TyUZz^ zw!SoX%jfGjWi>sXo!cs2Y@)f;2Zl5~x&h@hh4IIX=}gtcGaW*#6UkJsx#u&q0)>&Aj9I9t}Z8tIf=O8i;CZX zC^A84BAF2>QK)g|E95o&$+HArQ)rSW-iV~RbBG@k5lPC2?+#Fb1b?jJ1lh7s_!3He z@dX0ySRB0$^%7bPM3h_&2BWb3P3fAs(~_>R^+&Up%!NRDM+pS;1@0^H2Gf}JSXadB ziVt4eyS}4-fRKwAa`|y+OYT-~LI%>&L*EHviLVik7*<5&7!Z)EOFr~klF)LD+h@0&9MiiOaPrU~IF!m3BY>rT8jZ!x_AZBFWTF6BRhg=tzjt;#>KJ!XK$NSh zA4xxzokw+upP|46{J%zwMhZEu@YIeJg_Pt^MYwlQMZV-6p>P(BiLEPi1!1iko)uOw@yMacJkx6 znn3j836lA1C{9YhgVg2^%kykvOf3KGZL=j*&BL-oku5S$_YSWCwK*An(k5NIPv$mTMOv4?H?}JHJj3sZ?Z!8a{y2-|c zS2(*nYv-%hLF?1ZOX^F;DyPX2+%@Na@Rm*gTz%IVy`mVj96yiom^hcF`218hJ~}me zmAX<|A~+@Ia=c)ckMF8dvCl1$TgrZAKH0gW+k@VD44w?(?|tTRe?QxJd**Q$_Z}ZU z9Ui$H-IrzCm;A4fo{XWG(B{X6x2Oj#U3^(KsEbv2@>XQia$N!mA z;(lr88r_Y7JDwLp$UoUXx+%EJj80rB5qhvH({ITybLFPxQkAYxg|2yDI>-I- zfqz=7cK*7DZt5{{*?yeo@qI1j@UX?49UToQl%ZM{R`a=t$?9&cU^R`y;F{F!C8h2) zqb6TF0QT!Bh7hAaTs=Hlru*7cyz3-Fl9qf5#ZCT{->5Q@x!XGDc|T+#arV>dfa z@@<Ovq^90Tg{4$K~mt55Z2i41}7P{@!Og=QNM%WY$G1tO!n)g*rikTx7V7Ja=N}<{@4Qab*;+MTY1&L!9 ztoSu67p!mM*44L?h?JYxCcA|XU5?Yi)#M~4jjM7*Eyl-l9e^0&SY|N zTa-6*Pxd(XS6dm{h|B3sQ54pLdoi6@Inei4m;1jnwZTq?a zzk8tT=pBddt!b^+e#Mp97_a`@@Z4t9Y4G>Va|kicaK=S?(~LOJ3i@6@z4@N2Dvorb zxXNj?SoPu>2>W+s1Ec0kJwGXHHE|9Fv#{u^IWiWoA}V^>rqjX?BR6Br002yxoZNm($zrJR zy>NhOLMC{^xAuxT*Fv$3Aqv`!^$%tHRNk)V@IT14MPiTPm}p~`%I*1(#Pp{`oTFNo zVl9`k&~WqlNeoojPU-6|ScKlr0DKAZa0_R(79~7j4Y%0Ca>-d&sW+*AY=~o9-?mygaK~8i(m1G zIIZ0lx)kj%{FdC$@7tINXs*KE+B`Fgy9>YW?wGJf@yz%OzY}0qDdoRqgr-!*Ca~Y% z)*FR#p=s0PO1>&{#a^khkhwgiaw8MAN?_#q)albgIoW4ik zL?uYMoOPo-7{O4@_(l-Zp!ta_z)h>W9BI8axno1D|J6k>*M(0&ItCN1gQ~4XRglyGZ+j+H;SQTePj^*6}*6p z?%g>pkUilIt!a}{m#EDsWxMZmpH%J&l-15P(0pdQVoA{lV}(5}8$Y6Hh>RHr+ZsbU zBqjdBPd@LDFiJ7NquApRuUQmyFz{I{md;ZSFybS^)wmIUG~>?pb`Rl?UiCbMCqKKL zUE&}^{`1z~C68S`DzSTl=RWC;%Ju=fD;J?NL{tx4tx?6Cll}1cgCL?+m?TDaWpi6b z79it3Y8|yu*pmLZbvfUKpE>FUec0}mj+FaN<)7*Tr*$eemOJu5q^a^d)l{fmQr$qF z;`(Xf~YK$J9X?*OANjiqt7uk8Cd`!1)4F z=p2Ep^yW)49vIFaKavVcOOM%Pa>tr=7j(9%N%8|JZY(WfMdz1m7famF#dUsDIG=kk zqJ8=PC-Yjca-o_L{P<0_n(JqtSLNA7W1PQ-(0p6Si@XmB1TKpS(t)BMO7$ixya{Td z8M2OAVKW!f{iMiaY3w3W-|ELn1(9315#L4$)1ByO@qh z^=SER6(w?Ttbu+7DFQ{T2BBB2+~CO(UR(ppuQW1(dYOO1WDXMSS(+=UBL@uvwEKd>w!Q7slXV9cIj4rsC*wz` zSdP3I9#N-VsRQCAr2z_oyW; zyhXOr)tUb9p1Sy=05UUt*wW6OeY#qCA}uvks@#mlm(b7#p~orv-9pVf4!XF5}?8fnfBxwY0Sna&8y)*AI7sSYDaLQ;jr?SGt*As(k=58-|3C6zjQ=Z7;;+y7PdEnU z*H!p`b`_uiq5j(rf%zX(C;uHM&;0sO*i8PVXZiekw_m^LKh@T+RrN2wvxb>4I%YDv i$+QYsWZO~Ww1s6vZalk4&ps1gVbN66RV_Si8~R_`u1JCa literal 0 HcmV?d00001 diff --git a/packages/ui/src/components/curriculum/curriculum.stories.tsx b/packages/ui/src/components/curriculum/curriculum.stories.tsx new file mode 100644 index 0000000..af4fdef --- /dev/null +++ b/packages/ui/src/components/curriculum/curriculum.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Curriculum, CurriculumLesson, CurriculumModule } from "./curriculum"; + +const meta = { + args: { + title: "Full-Stack Development", + totalHours: 40, + children: ( + <> + + + + + + + + + + + ), + }, + component: Curriculum, + title: "Learning/Curriculum", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Expanded: Story = { + args: { + defaultExpandedModules: ["mod-1"], + }, +}; diff --git a/packages/ui/src/components/curriculum/curriculum.test.tsx b/packages/ui/src/components/curriculum/curriculum.test.tsx new file mode 100644 index 0000000..d64130f --- /dev/null +++ b/packages/ui/src/components/curriculum/curriculum.test.tsx @@ -0,0 +1,176 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { Curriculum, CurriculumLesson, CurriculumModule } from "./curriculum"; + +describe("Curriculum", () => { + describe("rendering", () => { + it("renders with title", () => { + const { getByText } = render( + +
+ , + ); + expect(getByText("Full-Stack Development")).toBeInTheDocument(); + }); + + it("applies custom className", () => { + const { container } = render( + +
+ , + ); + expect(container.firstChild).toHaveClass("custom-class"); + }); + + it("renders totalHours when provided", () => { + const { getByText } = render( + +
+ , + ); + expect(getByText("40h total")).toBeInTheDocument(); + }); + + it("does not render hours label when totalHours is omitted", () => { + const { queryByText } = render( + +
+ , + ); + expect(queryByText(/total/)).not.toBeInTheDocument(); + }); + }); + + describe("accessibility", () => { + it("has tree role on lesson list", () => { + const { getByRole } = render( + +
+ , + ); + expect(getByRole("tree")).toBeInTheDocument(); + }); + }); +}); + +describe("CurriculumModule", () => { + const wrapper = (children: React.ReactNode) => ( + + {children} + + ); + + it("renders module title", () => { + const { getByText } = render( + wrapper( + +
+ , + ), + ); + expect(getByText("Module 1: Foundations")).toBeInTheDocument(); + }); + + it("renders description when provided", () => { + const { getByText } = render( + wrapper( + +
+ , + ), + ); + expect(getByText("Core web technologies")).toBeInTheDocument(); + }); + + it("renders estimatedHours when provided", () => { + const { getByText } = render( + wrapper( + +
+ , + ), + ); + expect(getByText("8h")).toBeInTheDocument(); + }); + + it("throws when used outside Curriculum", () => { + expect(() => + render( + +
+ , + ), + ).toThrow("CurriculumModule must be used within a Curriculum"); + }); +}); + +describe("CurriculumLesson", () => { + const wrapper = (children: React.ReactNode) => ( + + + {children} + + + ); + + it("renders lesson title", () => { + const { getByText } = render( + wrapper(), + ); + expect(getByText("HTML & Semantic Markup")).toBeInTheDocument(); + }); + + it("renders duration when provided", () => { + const { getByText } = render( + wrapper(), + ); + expect(getByText("45 min")).toBeInTheDocument(); + }); + + it("renders difficulty badge when provided", () => { + const { getByText } = render( + wrapper(), + ); + expect(getByText("beginner")).toBeInTheDocument(); + }); + + it("renders as anchor when href provided and not locked", () => { + const { container } = render( + wrapper( + , + ), + ); + expect( + container.querySelector("a[href='/lessons/html']"), + ).toBeInTheDocument(); + }); + + it("does not render anchor when status is locked", () => { + const { container } = render( + wrapper( + , + ), + ); + expect(container.querySelector("a")).not.toBeInTheDocument(); + }); + + it("applies completed style when status is completed", () => { + const { getByText } = render( + wrapper(), + ); + expect(getByText("HTML Basics")).toHaveClass("line-through"); + }); +}); diff --git a/packages/ui/src/components/curriculum/curriculum.tsx b/packages/ui/src/components/curriculum/curriculum.tsx new file mode 100644 index 0000000..d6db31f --- /dev/null +++ b/packages/ui/src/components/curriculum/curriculum.tsx @@ -0,0 +1,443 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import { + BookOpen, + CheckCircle2, + ChevronDown, + Clock, + GraduationCap, + Link2, + Lock, + PlayCircle, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "../../lib/utils"; + +export type LessonStatus = "available" | "completed" | "in-progress" | "locked"; +export type LessonDifficulty = "advanced" | "beginner" | "intermediate"; + +type CurriculumContextValue = { + expandedModules: Set; + toggleModule: (id: string) => void; +}; + +const CurriculumContext = createContext(null); + +function useCurriculumContext(): CurriculumContextValue { + const ctx = useContext(CurriculumContext); + if (!ctx) { + throw new Error("CurriculumModule must be used within a Curriculum"); + } + return ctx; +} + +type ModuleContextValue = { + moduleId: string; + registerLesson: (id: string, status: LessonStatus) => void; +}; + +const ModuleContext = createContext(null); + +function useModuleContext(): ModuleContextValue | null { + return useContext(ModuleContext); +} + +type ModuleProgress = { + completed: number; + moduleCtx: ModuleContextValue; + progressPct: number; + total: number; +}; + +function useModuleProgress(id: string): ModuleProgress { + const [lessonStatuses, setLessonStatuses] = useState< + Map + >(() => new Map()); + + const registerLesson = useCallback( + (lessonId: string, status: LessonStatus) => { + setLessonStatuses((previous) => { + if (previous.get(lessonId) === status) return previous; + const next = new Map(previous); + next.set(lessonId, status); + return next; + }); + }, + [], + ); + + const moduleCtx = useMemo( + () => ({ moduleId: id, registerLesson }), + [id, registerLesson], + ); + + const total = lessonStatuses.size; + const completed = [...lessonStatuses.values()].filter( + (s) => s === "completed", + ).length; + const progressPct = total > 0 ? Math.round((completed / total) * 100) : 0; + + return { completed, moduleCtx, progressPct, total }; +} + +function statusIcon(status: LessonStatus): ReactNode { + if (status === "completed") { + return ; + } + if (status === "in-progress") { + return ; + } + if (status === "locked") { + return ; + } + return ; +} + +const difficultyStyles: Record = { + advanced: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400", + beginner: + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", + intermediate: + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400", +}; + +type ProgressBarProps = { + completed: number; + progressPct: number; + total: number; +}; + +function ProgressBar({ + completed, + progressPct, + total, +}: ProgressBarProps): React.ReactNode { + if (total === 0) return null; + return ( +
+
+
+
+ + {completed}/{total} + +
+ ); +} + +type LessonMetaProps = { + difficulty?: LessonDifficulty; + duration?: string; + prerequisites?: string[]; +}; + +function LessonMeta({ + difficulty, + duration, + prerequisites, +}: LessonMetaProps): React.ReactNode { + return ( +
+ {prerequisites && prerequisites.length > 0 ? ( + + + + ) : null} + {difficulty ? ( + + {difficulty} + + ) : null} + {duration ? ( + + + {duration} + + ) : null} +
+ ); +} + +type ModuleTriggerProps = { + completed: number; + description?: string; + estimatedHours?: number; + id: string; + isExpanded: boolean; + progressPct: number; + title: string; + toggle: () => void; + total: number; +}; + +function ModuleTrigger({ + completed, + description, + estimatedHours, + id, + isExpanded, + progressPct, + title, + toggle, + total, +}: ModuleTriggerProps): React.ReactNode { + return ( + + ); +} + +export type CurriculumProps = { + children: ReactNode; + className?: string; + defaultExpandedModules?: string[]; + title: string; + totalHours?: number; +}; + +function Curriculum({ + children, + className, + defaultExpandedModules, + title, + totalHours, +}: CurriculumProps): React.ReactNode { + const [expandedModules, setExpandedModules] = useState>( + () => new Set(defaultExpandedModules ?? []), + ); + + const toggleModule = useCallback((id: string) => { + setExpandedModules((previous) => { + const next = new Set(previous); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const contextValue = useMemo( + () => ({ expandedModules, toggleModule }), + [expandedModules, toggleModule], + ); + + return ( + +
+
+
+ +

{title}

+
+ {totalHours === undefined ? null : ( +
+ + {totalHours}h total +
+ )} +
+
+ {children} +
+
+
+ ); +} + +export type CurriculumModuleProps = { + children: ReactNode; + className?: string; + description?: string; + estimatedHours?: number; + id: string; + title: string; +}; + +function CurriculumModule({ + children, + className, + description, + estimatedHours, + id, + title, +}: CurriculumModuleProps): React.ReactNode { + const { expandedModules, toggleModule } = useCurriculumContext(); + const isExpanded = expandedModules.has(id); + const { completed, moduleCtx, progressPct, total } = useModuleProgress(id); + + return ( + +
+ { + toggleModule(id); + }} + total={total} + /> +
+
{children}
+
+
+
+ ); +} + +export type CurriculumLessonProps = { + className?: string; + difficulty?: LessonDifficulty; + duration?: string; + href?: string; + id?: string; + prerequisites?: string[]; + status?: LessonStatus; + title: string; +}; + +function CurriculumLesson({ + className, + difficulty, + duration, + href, + id, + prerequisites, + status = "available", + title, +}: CurriculumLessonProps): React.ReactNode { + const moduleCtx = useModuleContext(); + const lessonId = id ?? title; + + useEffect(() => { + if (moduleCtx) { + moduleCtx.registerLesson(lessonId, status); + } + }, [lessonId, moduleCtx, status]); + + const isLocked = status === "locked"; + + const inner = ( +
+ {statusIcon(status)} + + {title} + + +
+ ); + + if (href && !isLocked) { + return ( + + {inner} + + ); + } + + return inner; +} + +Curriculum.Module = CurriculumModule; +Curriculum.Lesson = CurriculumLesson; + +export { Curriculum, CurriculumLesson, CurriculumModule }; diff --git a/packages/ui/src/components/curriculum/curriculum.visual.tsx b/packages/ui/src/components/curriculum/curriculum.visual.tsx new file mode 100644 index 0000000..18984f6 --- /dev/null +++ b/packages/ui/src/components/curriculum/curriculum.visual.tsx @@ -0,0 +1,96 @@ +import { expect, test } from "@playwright/experimental-ct-react"; + +import { Curriculum, CurriculumLesson, CurriculumModule } from "./curriculum"; + +test.describe("Curriculum Visual", () => { + test("default collapsed", async ({ mount, page }) => { + await mount( + + + + + + + + + + , + ); + await expect(page).toHaveScreenshot("curriculum-default-collapsed.png"); + }); + + test("module expanded", async ({ mount, page }) => { + await mount( + + + + + + + , + ); + await expect(page).toHaveScreenshot("curriculum-module-expanded.png"); + }); +}); diff --git a/packages/ui/src/components/curriculum/index.ts b/packages/ui/src/components/curriculum/index.ts new file mode 100644 index 0000000..c6c7d7f --- /dev/null +++ b/packages/ui/src/components/curriculum/index.ts @@ -0,0 +1,10 @@ +export { + Curriculum, + CurriculumLesson, + type CurriculumLessonProps, + CurriculumModule, + type CurriculumModuleProps, + type CurriculumProps, + type LessonDifficulty, + type LessonStatus, +} from "./curriculum"; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 8c076ec..1cd8875 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -324,6 +324,16 @@ export { Summary, type SummaryProps, } from "./learning-objectives"; +export { + Curriculum, + CurriculumLesson, + type CurriculumLessonProps, + CurriculumModule, + type CurriculumModuleProps, + type CurriculumProps, + type LessonDifficulty, + type LessonStatus, +} from "./curriculum"; export { ProgressBar, type ProgressBarProps } from "./progress-bar"; export { CommonMistake, From bded9cce499dcd21ec5e43171fc67d3973c5f3f6 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:23:51 +0200 Subject: [PATCH 2/5] fix(ui): harden curriculum accessibility and progress state --- .../components/curriculum/curriculum.test.tsx | 68 +++++++++++++++-- .../src/components/curriculum/curriculum.tsx | 73 +++++++++++++------ 2 files changed, 113 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/curriculum/curriculum.test.tsx b/packages/ui/src/components/curriculum/curriculum.test.tsx index d64130f..41b9047 100644 --- a/packages/ui/src/components/curriculum/curriculum.test.tsx +++ b/packages/ui/src/components/curriculum/curriculum.test.tsx @@ -1,4 +1,4 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; import { Curriculum, CurriculumLesson, CurriculumModule } from "./curriculum"; @@ -43,13 +43,13 @@ describe("Curriculum", () => { }); describe("accessibility", () => { - it("has tree role on lesson list", () => { - const { getByRole } = render( + it("does not expose a tree role for the module list", () => { + const { queryByRole } = render(
, ); - expect(getByRole("tree")).toBeInTheDocument(); + expect(queryByRole("tree")).not.toBeInTheDocument(); }); }); }); @@ -98,6 +98,63 @@ describe("CurriculumModule", () => { expect(getByText("8h")).toBeInTheDocument(); }); + it("keeps collapsed lessons out of the accessible tree until expanded", () => { + const { getByRole, queryByRole } = render( + + + + + , + ); + + expect( + queryByRole("link", { name: "HTML Basics" }), + ).not.toBeInTheDocument(); + + fireEvent.click(getByRole("button", { name: /module 1/i })); + + expect(getByRole("link", { name: "HTML Basics" })).toBeInTheDocument(); + }); + + it("removes lesson progress when a lesson unmounts", () => { + const { getByText, queryByText, rerender } = render( + + + + + , + ); + + expect(getByText("1/1")).toBeInTheDocument(); + + rerender( + + + {null} + + , + ); + + expect(queryByText("1/1")).not.toBeInTheDocument(); + }); + + it("tracks duplicate lesson titles independently when ids are omitted", () => { + const { getByText } = render( + + + + + + , + ); + + expect(getByText("2/2")).toBeInTheDocument(); + }); + it("throws when used outside Curriculum", () => { expect(() => render( @@ -155,7 +212,7 @@ describe("CurriculumLesson", () => { }); it("does not render anchor when status is locked", () => { - const { container } = render( + const { container, getByText } = render( wrapper( { ), ); expect(container.querySelector("a")).not.toBeInTheDocument(); + expect(getByText(/locked/i)).toBeInTheDocument(); }); it("applies completed style when status is completed", () => { diff --git a/packages/ui/src/components/curriculum/curriculum.tsx b/packages/ui/src/components/curriculum/curriculum.tsx index d6db31f..fd679b0 100644 --- a/packages/ui/src/components/curriculum/curriculum.tsx +++ b/packages/ui/src/components/curriculum/curriculum.tsx @@ -5,6 +5,7 @@ import { useCallback, useContext, useEffect, + useId, useMemo, useState, } from "react"; @@ -44,6 +45,7 @@ function useCurriculumContext(): CurriculumContextValue { type ModuleContextValue = { moduleId: string; registerLesson: (id: string, status: LessonStatus) => void; + unregisterLesson: (id: string) => void; }; const ModuleContext = createContext(null); @@ -76,9 +78,18 @@ function useModuleProgress(id: string): ModuleProgress { [], ); + const unregisterLesson = useCallback((lessonId: string) => { + setLessonStatuses((previous) => { + if (!previous.has(lessonId)) return previous; + const next = new Map(previous); + next.delete(lessonId); + return next; + }); + }, []); + const moduleCtx = useMemo( - () => ({ moduleId: id, registerLesson }), - [id, registerLesson], + () => ({ moduleId: id, registerLesson, unregisterLesson }), + [id, registerLesson, unregisterLesson], ); const total = lessonStatuses.size; @@ -149,14 +160,19 @@ function LessonMeta({ duration, prerequisites, }: LessonMetaProps): React.ReactNode { + const prerequisitesLabel = prerequisites?.length + ? `Requires: ${prerequisites.join(", ")}` + : null; + return (
- {prerequisites && prerequisites.length > 0 ? ( + {prerequisitesLabel ? ( - + ) : null} {difficulty ? ( @@ -205,6 +221,7 @@ function ModuleTrigger({ return (
-
+
{children}
@@ -331,12 +348,7 @@ function CurriculumModule({ return ( -
+
-
+
); } @@ -387,26 +400,32 @@ function CurriculumLesson({ title, }: CurriculumLessonProps): React.ReactNode { const moduleCtx = useModuleContext(); - const lessonId = id ?? title; + const fallbackId = useId(); + const lessonId = id ?? fallbackId; useEffect(() => { - if (moduleCtx) { - moduleCtx.registerLesson(lessonId, status); + if (!moduleCtx) { + return; } + + moduleCtx.registerLesson(lessonId, status); + + return () => { + moduleCtx.unregisterLesson(lessonId); + }; }, [lessonId, moduleCtx, status]); const isLocked = status === "locked"; const inner = (
{statusIcon(status)} {title} + {isLocked ? (Locked) : null} + {inner} ); @@ -437,7 +457,14 @@ function CurriculumLesson({ return inner; } -Curriculum.Module = CurriculumModule; -Curriculum.Lesson = CurriculumLesson; +type CurriculumComponent = ((props: CurriculumProps) => React.ReactNode) & { + Lesson: typeof CurriculumLesson; + Module: typeof CurriculumModule; +}; + +const Curriculum = Object.assign(CurriculumRoot, { + Lesson: CurriculumLesson, + Module: CurriculumModule, +}) as CurriculumComponent; export { Curriculum, CurriculumLesson, CurriculumModule }; From c22f0007950fd0518dc908042942056097ced7a2 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:42:36 +0200 Subject: [PATCH 3/5] fix(ui): improve curriculum accessibility polish --- .../components/curriculum/curriculum.test.tsx | 14 +++++++++---- .../src/components/curriculum/curriculum.tsx | 21 ++++++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/components/curriculum/curriculum.test.tsx b/packages/ui/src/components/curriculum/curriculum.test.tsx index 41b9047..419e8b6 100644 --- a/packages/ui/src/components/curriculum/curriculum.test.tsx +++ b/packages/ui/src/components/curriculum/curriculum.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render } from "@testing-library/react"; +import type { ReactNode } from "react"; import { describe, expect, it } from "vitest"; import { Curriculum, CurriculumLesson, CurriculumModule } from "./curriculum"; @@ -55,7 +56,7 @@ describe("Curriculum", () => { }); describe("CurriculumModule", () => { - const wrapper = (children: React.ReactNode) => ( + const wrapper = (children: ReactNode) => ( {children} @@ -113,7 +114,9 @@ describe("CurriculumModule", () => { fireEvent.click(getByRole("button", { name: /module 1/i })); - expect(getByRole("link", { name: "HTML Basics" })).toBeInTheDocument(); + expect( + getByRole("link", { name: "Available HTML Basics" }), + ).toBeInTheDocument(); }); it("removes lesson progress when a lesson unmounts", () => { @@ -167,7 +170,7 @@ describe("CurriculumModule", () => { }); describe("CurriculumLesson", () => { - const wrapper = (children: React.ReactNode) => ( + const wrapper = (children: ReactNode) => ( {children} @@ -222,7 +225,10 @@ describe("CurriculumLesson", () => { ), ); expect(container.querySelector("a")).not.toBeInTheDocument(); - expect(getByText(/locked/i)).toBeInTheDocument(); + expect(getByText("Locked")).toBeInTheDocument(); + expect( + getByText((_, element) => element?.textContent === " (Locked)"), + ).toBeInTheDocument(); }); it("applies completed style when status is completed", () => { diff --git a/packages/ui/src/components/curriculum/curriculum.tsx b/packages/ui/src/components/curriculum/curriculum.tsx index fd679b0..3e6e83b 100644 --- a/packages/ui/src/components/curriculum/curriculum.tsx +++ b/packages/ui/src/components/curriculum/curriculum.tsx @@ -101,6 +101,13 @@ function useModuleProgress(id: string): ModuleProgress { return { completed, moduleCtx, progressPct, total }; } +function statusLabel(status: LessonStatus): string { + if (status === "completed") return "Completed"; + if (status === "in-progress") return "In progress"; + if (status === "locked") return "Locked"; + return "Available"; +} + function statusIcon(status: LessonStatus): ReactNode { if (status === "completed") { return ; @@ -223,15 +230,15 @@ function ModuleTrigger({ aria-controls={`module-content-${id}`} aria-expanded={isExpanded} className={cn( - "w-full flex items-start justify-between gap-3 px-6 py-4 text-left transition-colors", - "hover:bg-muted/50", + "w-full flex items-start justify-between gap-3 rounded-md px-6 py-4 text-left transition-colors", + "hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2", isExpanded && "bg-muted/30", )} onClick={toggle} type="button" >
- {title} +

{title}

{description ? ( {description} @@ -366,7 +373,7 @@ function CurriculumModule({ aria-hidden={!isExpanded} className={cn( "overflow-hidden transition-all duration-200", - isExpanded ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0", + isExpanded ? "max-h-[9999px] opacity-100" : "max-h-0 opacity-0", )} hidden={!isExpanded} id={`module-content-${id}`} @@ -428,6 +435,7 @@ function CurriculumLesson({ )} > {statusIcon(status)} + {statusLabel(status)} + {inner} ); From 155eb50ee57d02db6f8c450d8db02f89ff6ca8f8 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:49:23 +0200 Subject: [PATCH 4/5] fix(ui): finish curriculum accessibility review follow-ups --- packages/ui/src/components/curriculum/curriculum.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/curriculum/curriculum.tsx b/packages/ui/src/components/curriculum/curriculum.tsx index 3e6e83b..98a379d 100644 --- a/packages/ui/src/components/curriculum/curriculum.tsx +++ b/packages/ui/src/components/curriculum/curriculum.tsx @@ -238,7 +238,7 @@ function ModuleTrigger({ type="button" >
-

{title}

+ {title} {description ? ( {description} From 7691afdec0006d8bcff8a6134b945ec0fc91a011 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:28:35 +0200 Subject: [PATCH 5/5] fix(ui): compute curriculum progress during render --- .../components/curriculum/curriculum.test.tsx | 14 ++ .../src/components/curriculum/curriculum.tsx | 155 +++++++----------- 2 files changed, 72 insertions(+), 97 deletions(-) diff --git a/packages/ui/src/components/curriculum/curriculum.test.tsx b/packages/ui/src/components/curriculum/curriculum.test.tsx index 419e8b6..7e5a0b2 100644 --- a/packages/ui/src/components/curriculum/curriculum.test.tsx +++ b/packages/ui/src/components/curriculum/curriculum.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render } from "@testing-library/react"; import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { Curriculum, CurriculumLesson, CurriculumModule } from "./curriculum"; @@ -158,6 +159,19 @@ describe("CurriculumModule", () => { expect(getByText("2/2")).toBeInTheDocument(); }); + it("renders module progress during server render", () => { + const html = renderToStaticMarkup( + + + + + + , + ); + + expect(html).toContain("1/2"); + }); + it("throws when used outside Curriculum", () => { expect(() => render( diff --git a/packages/ui/src/components/curriculum/curriculum.tsx b/packages/ui/src/components/curriculum/curriculum.tsx index 98a379d..9dacf66 100644 --- a/packages/ui/src/components/curriculum/curriculum.tsx +++ b/packages/ui/src/components/curriculum/curriculum.tsx @@ -1,11 +1,11 @@ "use client"; import { + Children, createContext, + isValidElement, useCallback, useContext, - useEffect, - useId, useMemo, useState, } from "react"; @@ -42,63 +42,39 @@ function useCurriculumContext(): CurriculumContextValue { return ctx; } -type ModuleContextValue = { - moduleId: string; - registerLesson: (id: string, status: LessonStatus) => void; - unregisterLesson: (id: string) => void; -}; - -const ModuleContext = createContext(null); - -function useModuleContext(): ModuleContextValue | null { - return useContext(ModuleContext); -} - -type ModuleProgress = { +type LessonProgressSummary = { completed: number; - moduleCtx: ModuleContextValue; - progressPct: number; total: number; }; -function useModuleProgress(id: string): ModuleProgress { - const [lessonStatuses, setLessonStatuses] = useState< - Map - >(() => new Map()); - - const registerLesson = useCallback( - (lessonId: string, status: LessonStatus) => { - setLessonStatuses((previous) => { - if (previous.get(lessonId) === status) return previous; - const next = new Map(previous); - next.set(lessonId, status); - return next; - }); - }, - [], - ); +type LessonElementProps = { + children?: ReactNode; + status?: LessonStatus; +}; - const unregisterLesson = useCallback((lessonId: string) => { - setLessonStatuses((previous) => { - if (!previous.has(lessonId)) return previous; - const next = new Map(previous); - next.delete(lessonId); - return next; - }); - }, []); +function summarizeLessonProgress(children: ReactNode): LessonProgressSummary { + let completed = 0; + let total = 0; - const moduleCtx = useMemo( - () => ({ moduleId: id, registerLesson, unregisterLesson }), - [id, registerLesson, unregisterLesson], - ); + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } - const total = lessonStatuses.size; - const completed = [...lessonStatuses.values()].filter( - (s) => s === "completed", - ).length; - const progressPct = total > 0 ? Math.round((completed / total) * 100) : 0; + if (child.type === CurriculumLesson) { + total += 1; + if (child.props.status === "completed") { + completed += 1; + } + return; + } + + const nested = summarizeLessonProgress(child.props.children); + total += nested.total; + completed += nested.completed; + }); - return { completed, moduleCtx, progressPct, total }; + return { completed, total }; } function statusLabel(status: LessonStatus): string { @@ -351,37 +327,39 @@ function CurriculumModule({ }: CurriculumModuleProps): React.ReactNode { const { expandedModules, toggleModule } = useCurriculumContext(); const isExpanded = expandedModules.has(id); - const { completed, moduleCtx, progressPct, total } = useModuleProgress(id); + const { completed, total } = useMemo( + () => summarizeLessonProgress(children), + [children], + ); + const progressPct = total > 0 ? Math.round((completed / total) * 100) : 0; return ( - -
- { - toggleModule(id); - }} - total={total} - /> - -
-
+
+ { + toggleModule(id); + }} + total={total} + /> + +
); } @@ -401,27 +379,10 @@ function CurriculumLesson({ difficulty, duration, href, - id, prerequisites, status = "available", title, }: CurriculumLessonProps): React.ReactNode { - const moduleCtx = useModuleContext(); - const fallbackId = useId(); - const lessonId = id ?? fallbackId; - - useEffect(() => { - if (!moduleCtx) { - return; - } - - moduleCtx.registerLesson(lessonId, status); - - return () => { - moduleCtx.unregisterLesson(lessonId); - }; - }, [lessonId, moduleCtx, status]); - const isLocked = status === "locked"; const inner = (