From daea37930bd3094f18fe1ec286be103b66a08f72 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Thu, 14 May 2026 13:17:03 +0200 Subject: [PATCH 01/20] feat: text field component --- docs/docusaurus.config.js | 6 + docs/src/components/PropTable.tsx | 14 +- docs/src/data/screenshots.js | 4 + docs/static/screenshots/text-field-filled.png | Bin 0 -> 40005 bytes .../screenshots/text-field-outlined.png | Bin 0 -> 25143 bytes example/src/ExampleList.tsx | 2 + example/src/Examples/TextFieldExample.tsx | 244 ++ jest/testSetup.js | 9 +- src/components/TextField/TextField.tsx | 422 +++ .../TextField/TextFieldErrorIcon.tsx | 31 + src/components/TextField/TextFieldIcon.tsx | 112 + src/components/TextField/constants.ts | 112 + src/components/TextField/hooks.ts | 157 + src/components/TextField/index.ts | 13 + src/components/TextField/styles.ts | 123 + src/components/TextField/utils.ts | 636 ++++ src/components/__tests__/TextField.test.tsx | 1022 ++++++ .../__snapshots__/DataTable.test.tsx.snap | 10 +- .../__snapshots__/TextField.test.tsx.snap | 3105 +++++++++++++++++ src/index.tsx | 7 + 20 files changed, 6017 insertions(+), 12 deletions(-) create mode 100644 docs/static/screenshots/text-field-filled.png create mode 100644 docs/static/screenshots/text-field-outlined.png create mode 100644 example/src/Examples/TextFieldExample.tsx create mode 100644 src/components/TextField/TextField.tsx create mode 100644 src/components/TextField/TextFieldErrorIcon.tsx create mode 100644 src/components/TextField/TextFieldIcon.tsx create mode 100644 src/components/TextField/constants.ts create mode 100644 src/components/TextField/hooks.ts create mode 100644 src/components/TextField/index.ts create mode 100644 src/components/TextField/styles.ts create mode 100644 src/components/TextField/utils.ts create mode 100644 src/components/__tests__/TextField.test.tsx create mode 100644 src/components/__tests__/__snapshots__/TextField.test.tsx.snap diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 1dec1bb6ec..ed833ce99e 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -170,6 +170,10 @@ const config = { TextInputAffix: 'TextInput/Adornment/TextInputAffix', TextInputIcon: 'TextInput/Adornment/TextInputIcon', }, + TextField: { + TextField: 'TextField/TextField', + TextFieldIcon: 'TextField/TextFieldIcon', + }, ToggleButton: { ToggleButton: 'ToggleButton/ToggleButton', ToggleButtonGroup: 'ToggleButton/ToggleButtonGroup', @@ -210,6 +214,8 @@ const config = { 'src/components/TextInput/Adornment/TextInputAffix.tsx', TextInputIcon: 'src/components/TextInput/Adornment/TextInputIcon.tsx', + TextField: 'src/components/TextField/TextField.tsx', + Text: 'src/components/Typography/Text.tsx', showcase: 'docs/src/components/Showcase.tsx', }; diff --git a/docs/src/components/PropTable.tsx b/docs/src/components/PropTable.tsx index 35f5069433..f25d03ba6a 100644 --- a/docs/src/components/PropTable.tsx +++ b/docs/src/components/PropTable.tsx @@ -11,17 +11,25 @@ const typeDefinitions = { 'https://github.com/callstack/react-native-paper/blob/main/src/components/Icon.tsx#L16', ThemeProp: 'https://callstack.github.io/react-native-paper/docs/guides/theming#theme-properties', + 'ComponentType': + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L26', AccessibilityState: 'https://reactnative.dev/docs/accessibility#accessibilitystate', 'StyleProp': 'https://reactnative.dev/docs/view-style-props', 'StyleProp': 'https://reactnative.dev/docs/text-style-props', + TextProps: 'https://reactnative.dev/docs/text#props', + AccessibilityProps: + 'https://reactnative.dev/docs/accessibility#accessibilityprops', }; const renderBadge = (annotation: string) => { const [annotType, ...annotLabel] = annotation.split(' '); // eslint-disable-next-line prettier/prettier - return `${annotLabel.join(' ')}`; + return `${annotLabel.join(' ')}`; }; export default function PropTable({ @@ -56,7 +64,9 @@ export default function PropTable({ if (line.includes('@')) { const annotIndex = line.indexOf('@'); // eslint-disable-next-line prettier/prettier - return `${line.substr(0, annotIndex)} ${renderBadge(line.substr(annotIndex))}`; + return `${line.substr(0, annotIndex)} ${renderBadge( + line.substr(annotIndex) + )}`; } else { return line; } diff --git a/docs/src/data/screenshots.js b/docs/src/data/screenshots.js index c1afa99a6a..ed4f3f26c1 100644 --- a/docs/src/data/screenshots.js +++ b/docs/src/data/screenshots.js @@ -154,6 +154,10 @@ const screenshots = { }, 'TextInput.Affix': 'screenshots/textinput-outline.affix.png', 'TextInput.Icon': 'screenshots/textinput-flat.icon.png', + TextField: { + filled: 'screenshots/text-field-filled.png', + outlined: 'screenshots/text-field-outlined.png', + }, ToggleButton: 'screenshots/toggle-button.png', 'ToggleButton.Group': 'screenshots/toggle-button-group.gif', 'ToggleButton.Row': 'screenshots/toggle-button-row.gif', diff --git a/docs/static/screenshots/text-field-filled.png b/docs/static/screenshots/text-field-filled.png new file mode 100644 index 0000000000000000000000000000000000000000..03ab10d37eb94c5e60b825fbdac149fcae4e7924 GIT binary patch literal 40005 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

w2 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 Swl<>x0000g#C%~n|ML|I!Pyl3MC@AOv3JR7X zHu{6a&Mp%9u#pPb9Zxfe|~nozjwa5zPGaQdunv5r?WRdx8!?JP$K-JqhFqv zcSy6*n{pW`m`VIwV=HCD=+6RtFL_1L(b46oOmk3BjB6ESr8GTfcV~y}5nMzV%Rg?} z2O?5i7Vq_RP|*e@Ej)OiP2atYnC@m`DX1YZ(p0>h)>#OKu>l2|iHgPaDgQ!AvHeqa zuV7o*@%pDynN7g0`sGOQ_~R#MBkrcZDo*j#PomltQ4Jt>&%49opVL4_#R_S(UFOuH znloQDKO>-zQ~iAQqX)v%!k(2B6hJD6rSlk+Ft0JI3kk%}r?{~GIIL;^nE(a5@RN&AjA->Wqfe)!8`p2{Jj{xm<|`LZHT zjRI4p>T_viclO}%psOAd-osdYKPwyNZchv`NPS1SCA({XqcNQy>}rILf>fnqv|G{HU9AM^j1Zt&+&DLAL6N=_p8`DZ~08OHJ@hS%w|Ro-jg zrGotk-p+FXshe{zV)!@YrrKe)Ym!CIUf>!&&@fdWT4cp(Tsl-A;36Uj$jOS$ceY(R4<}q=7l5NlzCvJlf!BjYz zu1+GPQY288E`FWG95_BEhD6PjH%S?dv(}C~jTAv{MlBmW_7d$<$ai+@5bqP>M+QA} zKd)@mWE2}6i;yW<54uIy(-=`i-Uf&2#37GBFK*{9@J5BkNh;&*3W7nb`B+A@!&0;al#i3YEc;)-MNUov6#QN%r zO^%(L>pVdeL`SQ9&5uoH<#OVf^GL1|IpL($0o<0tu$(?ZaDS0js->E-Bz2saGq+Oh7cT(C+B zx=>KU8KTss9|w2b*Tz$W_}Q!n+?{U4UzNceB#2{$2+@(x;(_9iCzZlGm)U;l9B9tn ziAeF@se||0C;o5R6DLOFelNuWm!8LAf4n=2ZfXgyk=l(U5t`e=9TQ8PBba>#3l}Zv z45x5Lm~H}mzFu?0gIeihlQD2)TwZg;Q$XVXQLQifwLoe@_Q>o>R6zny?yxjv<@@8) z^U4o{ytR@Lk3^ds*N^A!$yeiRvs14>h&4LY?x^TqoIs}pK%k4n27TvusjvZ1=Ig6` zzluGpw*w$v zxDVUOJJCeu!FF4I9Lp%2-QRl)A`EM|JvSwpPpZg}kxFjQVr%NKKVYJOQBmkYC`33Y zkr*hlXeg49|6l`wf3klW!BPIf{$=!U?B8Wt;yDDIz0snuS0%7-#=yU+0xU(W3A8+X zwqr7UhHwG{R3M{rmvjW1c%4m~*h_Su3vJ1!=M9lhdKL)4K_gJm&{oXa_KW3HVuK?J zLw(TC0b(~29A6m-_Y0x&CRK#8tQTw2jqJApeFzE8NA{!-*E%Zw-}W|YO!vAv?Gqng zYv075k$F`DvEU#LDCoq2PN666*M6_m6<g))-#DWCpH51HJc8g?uVK2Wn zuNqMLtqySv0=|p3Xd>)5LD3kh#9Q784LV@Ey_~l`!c{TJ4N$nYgan!O$7y~N91hu* z#e6rou2M0y+2}QWa&Klj6w%V!7uUuHTYOeX0Ql=clAL9Pn4s`4(g=1j{g|F_H}Rx4 z1CP5BJ*N_iTfDlhSdiW`Xx4L*zsaD{QPM0R-ALC0d0TjoD3`U%g7I^InTv3YHWpZ| zS#?k~peb4v3=Rqdro8IJ<&insADVq!5pr&znGOTZ!FO~W)z4wNppk*HjEL-f+vc9i zGb$DkIrQ&Fn$&8sSZbdLhR&GS1vexTo!n(4q_dPq%bvfDW!+FV$=&7xcO(#1YkH92 zD1!|TM)eVG8-;UNTg(Lu#Xdh5uc=v`pVV{j?@xUvuCWirjmC(NCZToeS9^gMA4B5d zSX=Uhaq8B)qJ3#DN1K05Tp<5rwU9Tt`hon9R}e=4armQryogL_hnn>u%F2;?Lp`Bg zIVOd`UhL*W<`I^&=+C%uZYfeI8rH+*V#-LRevzb$VJ$dps{QNrmKI#jj05(1XJYIK zl~(Rh2qgF%c7!JzE&cmaFoDctkHkhDLHoV~Fy7|hm86hsV0MvtIOZ!z1zF^HTUB1% z%hL!>wpupW)+^eWx;{VYkXa52`Pf&G!)m}7HnDN2w*gF8D%x1-w!OJ+|7PwzrRvw~ z99!pCLIFj~i6)70p7-=oyn1>Nsrfx2BsQS)D)Wv5Bo-O^*R2}OoQ~kpjWJ`)pggvK z%dLZFfQPG%3a{X^=0q;w^^d>zp#dvkMU$i|Q+;`8xe8)W^!>UEVkRd2waLod5jc#g zl(@|nD?Wx-W+RgXtin!U0eQH%-B3jnwQOQ>d9-KIB$fMuSrRv&w5_Q4(bi++G2^5TI0RHo4kPc zMD#?ayAsBhc5b{biy_ew(V9^{;YA*Vn4Ad+a=;`vGDyG#3ddd6a}BaK99?Z*(-^GrH=@`Z-)_i3M;RrTRp=dmJpqn*|D zgUkq_7p2XvGZzx8t;6jXu@ZLrFA~+Xx5uZ#ynT>?wG7aBz7C5>8oo+>{E^Ao8YC-nbuN%(J8jKIWY8O2Ac>{Hfv1dggv zP`=(aiJd4AcPRJzPY#Jj4a{X;hOb4^Z1JmYehm~UFn?Uf9yMa32Os+X;deNYy|4@5 zc1xl(Wc4&~(B0grwPHw*@pRvvI;s1SIZ_JJr<9QX_m$k?wdDrTgLk~IVk4J1ySup? zCo^xvXiQe@7U@+s(M3gLkMdS#!o6gh_zQh1JC6HRdxp9<(aZGW>A&VjnbY|GY!`3& zDW{#t%TCD%pu8~ABDKiY;{IR{AO!qv5wu&ua%i5$1Bxjs7gs7HKiED^0GX4I0`^dz zbF!r*&|p=*EF>5cDJ*NEv@SQqvMBuRFPpf$rCa)X@=`ZQU1P=XYuOmrfTFx_!Gb=GUc`8rVSHc@`%>-568uv3XMpD_#`&ieC2~Ter1yy z1)H6g!##Ok{AxaC1X9>IyhXz{oT%H^x@5I0DdI5H2$Oy)1Yi`$RvNap%E2s5j!MJ> z#4{qNqIxoBMuEdq`sTI+(GR{35U_Bt>liTQ9e2i+?MgV@7R$`f3;G1V`w1{8pT_v{ z=(>|*vt}p1O^xHiVB^vxYXcy3i<0(pGTNu)VnHZ4%o6`o=-akVJ?S5AKUfiKI6uErD@d#Zk=}D~LScRyO+{$54J95)^J<1=xiT<)zw+}p> zfTP$L#i^V_Ca;y#Ei>YiKdO#$bKf5ql(U4wtL_g1muUK4xE}BtMZfK87|V_z=-Z;N z&R{&#V*TYsw(sXf;#CpKbM7hQj-#N0^UE8(2xyGrpWZDfa2qH$bQaarFU7zhYekVg z2>->6OAL;acAZ?He06KOVedYV8H9qub3;z@SbOo_+@S4EdA?l9Inr$6p!Ew1GZrt! zT1Y~iZ(Pj1wCs)DGL?^*oFegmKPvufjjvJ9`(Y-1=e{i{n3`BZvVt!sD|IY)GD)#C zjn9uxs9n~cQv|YTlvztd`gzz-Nh#eWremZ=&UEOvQ-BuO?z+qdbWl0iP8U_TcMS|2 z3|oSK?@gYRJ}FjsDAkdr@N)mc%k_#4&D{2qysdoQElQJLN5ntWzcU3;x4*vJ2r!#; zDvHsKBB^FW+Q1N{kw1if@p$7SaaG}y;Zx>E^&Db;EGAIPYqRTJP7DX8fY;f1dnc<& zI@(XlFJ51LxDfI-jM8l2mV2?Kl-vp;4dYZYPc@ zz!N~?>sZ7jQUz|koG#3T%d*t=n!&|l^+d}V&P%G%zBmm?o@5o{zKW^9j<9UnZj$s) zw!}c{_dXr=+(|m2u)HA;>4*UFt0%;^CCn+YSYs??S$o3^QI!)>0Ct@sA~cru#VhM? z@5hgYP!aZ%bqySHRrYoX#c!Ry5~Bx^;dX0O*%%nWVL`k)c8Ts@$hro$cJV=s7bAkJ zcA=$p4)C6*dM$K3U8CwZC=^ruS71tK~{xR=gxb~RrpX|U`Cem ziqToNK7J=(Q}^aqKc7=!d0MkpMeT70RKV92SW*mhU1Kto96ku zo@(suIba8nDk^*I9OP|~sF+@00vR0Er(fa8i!{Xs#0##<`T=`Xlg54fpqPq zC_e}5Bn&_`mUkNteAN5dH5RQ{s}9sr9HfpOBkCqI&!SFEEIDw^8ZjFJb+yO7XlPR& z=@~CGS9S`)j^-;U1w)@Hxt-iBIG(Ck&RJUCn35*{kw{o07I(ayYMG@s4hogbq3)X; z5X<8tuikY*`ze;}7=VDQlAyG@T0MGYzKvfhIJ|8;idow(0+Jte>$%w_>POw$ytg@z za}Y^hBG2s24%x8@sZ*WbTBTJ0yW$_=Gty^e@t)^>f{{+Y)2}UZ+Ia^}kyW7*7KZlsWzs3mnYev(0bi;1{l2mY`pzM`EYh}R6o0=RCjpqmkld@7&1D}!PX6S%ilVlY_PLO=~LeGvwGfR8-!W>rWd%DO3SdjuD(Vbu*9}!H2mJ( zi%r(gN>Gv-BXdsw!gDGH749IHBBX-7{*{wZpCDqvKN9@y8}m@_JDCRAv4-LZg~)4! z=@@Xpf67&7&h3n0AAL%#5Rggh`qBM#)Qa|_$}rsX8Sc$nxb8ffEUpnc90kq-LV==v z0#G7l;VA!KXz%!*f%WBy7>I(At8ugO&H8d!4CGt#K-@>=mG`G<+b8-yp1;B0kdr%a z%bCJQoV{|Hn zFQn0ZT{zrAtVt=hMJXghCTw;*IPfn|qTPm80#hfGL`f99XnythU5ZS?iy*RS5~l;s zL(gc>Y$v4PB=SLp5n#Khl5k)-@bcg`BY}3|b0OV}q~lS_($$Y)z;ZDl|Gq_DJQ~5E zVsN_Y)!0c7YLXJpKrAp~jQ|_NpIuw?h;ZiV=TwmS^5WWiNI`+D`NRr5aFERV{!vCO zX#bR6db5VDG{o`SBBI+bD+K&|EVHRPIgJ?Sb#7B8qFDanA#xG)lY{I=5rQBoeH!0S8k7N zONy{3ey`G<2$h>-%YawoI}Gu%bAbpJ4W0t??aI|c2K*>r5u3`lS7?5#RS1_&`tCHb>932NfA$$(FVE!(%bSEeAf13=Hz>VdH{8(f+ahQ}92F zUPAB{;F1s9m&C{1d*M9`5p@z0h!%0$9V=j7Gd6(i)hd&5rqUPAluIa^KYz-*zF6aBXL>$G0ro{7pxWE6t`TTt~HhqO$(r@uDbD!e4eKYJdri1*g$XZS3Q1((R*C@qM z7gi^8+UunxO?kD~im&yy^Ffe6dej_Z2qQob@PZ^6Q6EsE6(mp&?Qet)io^H`p!|)b z^57_xIG_GP|6$9BiV^is+lK=5qyNcvs}nMb#E3h6KC|#^zfT;qwEYRflu{vc%Cx}j z{+L#5KwM5yHj19+Cs2NIB`BO{13k2TV?HDbUN?;g`{bBC(`3X|00XU5to1^45vO)- z>!!oZc04Ormy&4#6bRRrXI*DJm(((e=K@LFAGzvz4h{i<5h0U(F3ZjNjpn?nEjAf9V2i})pvjQ ztWjucq2l^U4*$;Q<63x~*PYc-#??nd@9ZVfkv4(c7Y}chOIi z6bN}~cWoHF^p5=W@ds*lLFlSy5^ewi38lN0BW4V-=RVC=?nRr z=jUUHkbsmo7TB$A&dbYAXnS`6i7?Iev5cnCACr*__4-VkdE5IXiYtIq3*-~+dHoF= z5e`$eNT+nojP67>WPye?JQQq_J*q$w`6!s`M6qDQ5UtM$A~!LV{`8e_O^8;S^e!Vr z@)AQu5O1p~+m&XkC~=bf6Cdx?=tO-ZW|C#*Dvb#~6_*c0fFkn8zA7t~sqF5%bmmsZ z8H}5K_)FlKI6q0WG8i{cbhwV2N@}{ug^(T`7=qzsHUA2|3Z-Ykn@!2cOd40g`ZT-! zZOSnH77;g+7@i%i15CmFv5PTXQOuqjqq@+Dmw0UaX`Op=cR9q5s2{p5yWay|X|#6Z z$WReKQH#mY7b2dLtwxcux*VlP37nT%c*Rqz>z7)Cwgso5Qu|$)SaaHuu1>a?xP41C z>V_aejxgg)XQBScnzG(jq6nB4$)xF%BF8qB;i?fle0a-(%b)!-D*5BNW_?6f)7L`z zOHCSoZ+|H^|AB9X*q~0%_H;o$5#YiNBEJ3AHQKEZK^et7F+)~6NzXUpL^CQ~n=ALe zj4kQe+oyo#Cr^46_4<7&BggORvzXhR$>~i%>GU;cil{myH(?fMj#+3PB25urX zDXAC_gf3FE@-p-bQ4vtta=?U2sGg>nzUC$fcsi94#ll@l?12YaxBEI1ca96gzk{{J zk50!nRxzof;KZMz!RD3a>H7OTafz~Ntg-rTA^XyPE(oIEY${rAvHrn`H0EUgt16w{ zA-?ZYE&}0-oKe(VXDF>H-=2O3JUtKyQ!5vWvX{g9FrG;`3KB~mm%gN0=B2mF(Da{Q zK+j$16T0j_rHop8rV1>PynFP}WiY$X=i7NUgRy+*7bzqPD{YxcPrNo=K+R(gaWU3M z7hviwJV3MeC$d+NI;{5&ak~o~&{ww!!<4y{$}Nj%AX92|8T4m~!#&Lni_e65l`!Pk z>w;uG5(On&*0MTz6rtm~Am5r0Mj_yUP{Cz!E>Ybl@qQg}y8q6P!?^4>In4awiOaDi z#EAGw>1ZI4*B@j{}cy(#M!hj+~&MvK^-}%=B2xeh$Hdjn4xZ@|yd@45b|r zI*B)T_y)yJM}6=c3EVW|gQqS#YVmdT+-h^{qcP{buEw0U4(Z!+cuXZF$7!;WkF|{P z{^*a}U0v@3=d{Os)mEh>2PhF%D}a%t@Kq$EU-4kfYAa)k|L=;l0WdQh|A2*%-nxN* zK?iCYH3>7)j{1RChNrKnRYRwQIDC41mV?r_E!%_X>w$0`WYA?VemDP2pWZlu=&z23 z$~Q$jd|HWN^m+vn69&zlQ-bGIyHyK4don}>>c-|?s>3^IWx#Mj81d()pS<1KWRnYW zNTX-#-GpxXc7Qimk(|=!3`4!nE)et*yeI0`8!?)#MjPQ-1SowpTkYdb6=9Eyp856| zk80;oN7Xp|8IS6n{lddx5^siW^`x-;b2+$tTaQzHSN->o-NBl{9^|zqk>!T z+WfnOz2h0Z(x)4R*kc+gdf@xsnr{42As9n4cUv$Y*W_xo|>T|<>gxfgub4PyjEN% zje0YJ)s-)1n7IM-d@)cvrp+~6CSe}`CXDgRpt)NVvhn#$Yt!PQ3^m^iPf;{rAGqI8 z>fAbi$<$Ks)kjgCHu!=QTs7rSdW%o7M^ z*zp)r52G7H$`OJc%qT@fjT4LldO-iQd@w?V2!c)%+I?p1h%ocGM0q-5v4Y6j?z_8@R4JOCdOUAB z;Yh#KwiG0|OQl!PpC{oO5q>~vEz3~)4%tveda2nJBM&*zwhi66FfWOUoIFJ4F~RFx zp8CZ8^nfco5r&0G@CS33RrR2uf2&; zOwk$NvjrQ!ly$a4a?F=2j5$(Rww$po(1P-6)iCP`FOzOh`D}#1JqRfjlJ%5)Y>c-+Mz3o2{$Z^Q#fdd(%6i6l^b56gjk;m z%P<^2H)74GP1JHoF5gdDil+{4(s}i=q*}Kv!rJtwSfp1^B^dQqm|qsA$l)p?U%Ea$p;*8t3USD^6DbW*8;(%$Nuek6k5>vtKLgXdGn_ePh$k4)MB^neu{NzHMRYD zN9eI9J}uNr^Uat%cGfA<*7`l(`R$S$6p0V20~xf4NV}-M_@fX-v^iF+5;;gOM#aim z9wtFj=A5j?C_5Hn zt;{GqyDYx6Ecl-OtJmlC%%q!-^yzIDh;??RnL_yUGs-uj`i=8<{o!*_6E5dg1L1Iv zUaR)-z6Id+dBA=MkGcpWARKa<0k5!Q8r-ghD>3pimglR4g{F@#KYVfXbgV zV|m)JR8KxuU8jm&JNjnQ5K5>JdO`2yxh+s1z7d)CYXz(iPEvNxO3U}!vdoA~*7dHQ z+MqVOr<|CpimbY#x_fj#2dt7I&d7bm=Je>82NcX{sL|P|?&V=BLPggUE)@#>sq2F$ z{Q8IAx-BWT1lg`PRTM6)?k0HkZQ2iqy0IpK4IYs-EUxo+frNa>%g-E~v4){2jO|$n z!Ex@Y7V<32F95r@Z;_TWM$TgWwLd`o7J^~}L5U58qi}&x1;RDj0L|gH+MS&?iIl{1s`sV4|d(>!i`xtU?2tT7Wyw20aGf7u-;;k zGJ-19$Amxo;a0!FmyUU>o${!~LzrrH;6n8R`)%MTtMV{^MgTBj&S9RwBrJ%N?Ip@h zWhvO-1Y$7v4ucs{A3NNmz{u)l*e-QD#%VNK#qHjD^}%&hI^7|X+B{h@W`p(LE>Y6P z2{c+~59|40U-gfa5KNzLXmv32JT$PBj~>$aiUXmx$R~|e4`sHv ztePI*n*^qP45-RIFm?y2M}|um54t9O&SvGr@792c2tzm0x?>bjCQjEXRW$4Ggx2vP z>L^|-@zSjQ4VRbK9}^BSdArD)ot;hW-sh_z80LmV6hM0CCSWBKo0g?U9|b#>5ia4?LEYyfdt=Jr?p&CZsEgv@Ut%Z zEj_g_48kfTkI^pik>2fkX0?yGKay!67JJ|(%Hevi3L5OzknpzjKQnM&9js#D6fJX| zMi1t&3jCbVBoyAp0@8{NcgaYv1woH9v8fOVH!lc(6fXXNqow~Ztb>%dSGHOqjD592g88BrP0gKe~|XL7a>9WIcK3=PSajT{+0 zYRxQi+PJ&p^v88d!jZxyPSO54kn-a;_sL#NcNDhLXOPx(0l?8kgGW3uU0-=8H=dVQ zrzKTSb_a3R}UdSfpb+i`ZI85_5vMeL`?aeIqWotI^+ z=Hm7B980DGGhVSTXmeO)!0S zLzMy+9a7*dM24bl981*Mt|U0CNI>*E;ovMP1eEiPoDmM7L}4U8AX)H(L=ub|3r7J( z$)W*}Kv}d8_2>Wug!4A`Z;J;60evZ>`5%pB8G>Z>;XqbIWQeqPn9Y{UxirH{-lXQ2 zJG^QpTBT5Sn2O!da<5672MI+}Hpp% zdU5_3ZF+J0Wn+;t?QI1k>hgSBCgBnG6z1ps^cCqqm8l_I;u9i4zTHE;2qu0qjtB#p zx7Cr3E02)uD9uUHd-N-V%ou^cv{MNvd9bwnkH)^NEQ(8ZY5Qk><+WrC8~!GYjEQiG z^Bnm!m$98&_?tp4UGz#zKBFY5&hY|ZB)L%bV0f4MeW6a9L2J7Tl5PJzupw8$(!wn6 zgTHqW?@qQQ@{k5{6#bYsF%m#>bX zR}AH~q2#Whhnrb#*f$4_iN8mAqSe=MY_eV(l>!anjQ8vH2NKFAGHky185hPjE`rkG35^c8rc;nuF8=myS+* z$fll4PS$KBBa5a=ef83Y=5f9l&DLlFO`m!4tQbIQQd0QTh@Pf8&tjbl@N=*D;AA*B z2qw5FSa^prH3E0^9LAdHQhqsGa@~=mUl}SupJHWoDO2UsZ(HANAOfUT*pz77$8n{| z3FdsXM{Zq4*U3xtBe9E6?4?WwGz91gV(>3KgEY}Gm_Ck})*c&)fPk_yO5N`$6}QAW=UT^)K466uJij+!1)dF#%}57G2Ux#t6&p z$HVvLwTD}W@QMPGu`z>j`grEU5oVw#YOxP~3~JzOQ{old1jx#&k2P5hZ7513eRTmX z&el`Jl_4tepZO8eGCtQv_r&uVVhyM#B|0}c*ll2=YGw4^71D-k-Gv+&aV?%DK(C{L z)Km%X=TQ0H>;ry#xeXevVG9hHO?v>}JLWbl>$19u2k%Tqg|4+e#1%v!>xS*MxH~;W zIs9=1OCiC36mGRu%IFna?`s?QbMVg-NFTW@ZRO8)XPPBp zGhk3MU(l1nmB%?&pCazg@l1ZB+hmLL+hXC&inroQK~x;%pPNf2&V29*c$n z*SpHfKnia+dj%Xdz^P|Lm9fN5(vU;yqj^CEMEPikdEXpoXe5V2BmQqyf@z)pQ|boS zUy4d=oHC*^x{Pi*n|T%#u$63oC8LY>C+?kwCPTrgtcH&H><1tU*eF{Ov7%*9$Mvzl zt5!#_```h^H@eRu8;6K4+Yj4v6(SH`jaq2}nr<4m3%V$>gbngR;CcC9g z#nNSTD+yt&?qM2=n>5yCPBt;cj@-vIzH8F{!tiu|($^LLx^n%$qyt9?$ao5M zh~Y(}ePbkxERTGJdz7`bB1N?Q{QMCKKX^8|%ivcCrp0DF-%A5tDa+J zHwES>9`SXXEWAXOWHt((jslx~ED5Bg{JA)Zu>smj&>JbYj=KQlV zs%8t$?n@!pq}k(px&m(xp}B-|vZvhOBH-FG161tBUH{`1^D(~8nR2Ca5wZ#9s$5BL zI8+Ozl+Z1Ing+C3l-e)2QlW?!(47TIx(7Jm*?hw08#Id=qSJQpXa#^1u7sEMek7_ylv@Njiq3fYK9xEM07 zFAtcook~SE&uJw4Ltx}Q@QU;Sr$S22eaoHS`cNh!9t~C`=}ZG)y1``+*Vg*=W^NN! z)oQO7zc1#w4{Wr^;rklel>Q+?l#%2{&`C=WDKMJqwW|${59UFS_j;LNg*$oCXBd;L zNktxeN)QuuzLSha*+P%0(yudFr=#%@(*;S49HSGB@%b=ul!eO*?iUN=h{bUbaXqz}JOE!@f)_EygtU89cEoi zWt)YTrd7(QbLqrr?KK|&pAn0_5N@kO4^T(+rtE{&35{Ci)b|0iSWUA!H0TepNDau4 zD{bbd(=uIk(^~v`v)XIg@ed*GkEuX^Y{7aQ^R5TDt9cV#TVGoaa@#sSA0$KFKthE& zZ^V)@_i6P1bkt3JYhlPT^a-Y0*mzt1M$EIa`$*uA0hpM~)e;LVJ#VUckzRSD%AKPj zOa#vsU-lUgE6pcJsF<*D&k2Dnn3i?ts0R{~;oK*pfTjK;8RXy6KmN`Y`M3O!zmrA& zB_Bi*@{c5vf9n4|spK#IKaxy>fq$*})DjBP%6+1Bo72uSt3n>0Yt=M$d2f_$)BKIt zs`UIrHX|aqx;0|!2ZVbrj_r<$d>4|WI{AsuKKDaz!q1!1S(l6@?p?+tlVM0+^XjG2 zZ=*@8MdB#6tg0*k`iCP{Nami@T3Hw*s*b3lVNG6AAA1P&*^fpvVmtF}VUIpA89*cS z&;*6%A^OG$+-{;RHTUiPJ6l#KIekB7g(cyn$?bjwEo|Nrr#EauA|Cuz?bWnvW>R2m3+2y+{=-BiOo2%-efC3plmtC{w6yzt+5Ur}0f01bv{XIJ?pQOWg+uw3Qe4~j-P%IFcV$q&#c#abS4;o-nd;P8uKYwj3T zDGdfxwhYX4rGog;xCI=xxgcbn^`8hoszPw@`_=g{K6IpAO>T_qS;3}{s{oX8kGTxK z%nJ6hkct|Y+s~lpe&|j2JzV#_WvQ_x|CnU5i@I2yPcfRgGf@4JXkNjGIBGw-K33Qp zS49w;JliEF?6ZeKNbMeZ{`Sj*;0ggc83FTdjLl zK0rnb6_MAEXkQpxZQMF|vw#Nu34f~r@1lfC$O|t`B+(rYegNk@yVG?Sn%w4jf57-j-vnrv}z9OC=?mW{K;ymV!uS)%IY@hyy6 z2TP$MfFSAj%d!^UXY)@maK5v{+%{z1u_F&TmZ+u%m}lHbtpi4$X(2u*r)w4M9X$^Y z4UwyGv+D8XQ)ncQW%@JqqQkwR1>QtH2j1nl%y5D?$8a@KW@OL-NYWuc~x!b+QG)21g6>=*3(4IebuZ zSST68)rW7%=JRo`N5AD@D5}JqkXNqs2;YEub_O94upAB!N1g`I>?_1*Lg^XH{fJod z6|YZqiT~N@rA=c_T>hA6r9z4}a;4b1AkvQ{4RrVs=|noV0FW8kBkH&V+QUNYnK_^z zr70(+6^Yd_Qph`C1)Ef-qk6B`KIrwC1KeRG`5$)2j*TT)S@pU1fgFx%@Q7+w|8_S4 zNj2qWoTg#=jDPC2td{`tTN)LPn1`tYqK6#w?Z!vxE>ltL>QFO=U$>JL8}q@&7F7|o zp_(fUSD6zbiy`+;w+rU(bRFL%Afgo%%k3o@QJ;D0NWQa)r|*|9eCu48J=3jEgqeiT z1-USa84qG5x7}Tzq)Go8(DY{R>m-7`o4DD($^;)G<*};#no~r_4xb|)L)vV6A5QMx zmXRW_^HAVTD1`6NGlrDPz;UybV+}xJTBMN%%ZVKSdl5Oq^W%po4RAHXSSl-Wa94Zw zz7Oq=gy+Kx~Xb^Hg$pruMcKh98bx|RmTM(Q%UU+8jay!4=zp! zHYFpq_H<%Am1sOFfAO+^taBmfV>r+S=}QhyQKP{J&?GCBpiRf;(3+@9`oFZn$I1JHJ>;^XA-PBz-HG z54sdWxA4zFpZIo|8blZh^%TEFZL9mOjiT46_`cYu)Z8VSGpQoC#V))^B<7C6Dgs%s}>MS_E)Qiq$FgtY?3$vq4^r<}Ec~V&T zqN4}yiSZ)Zq@&#st_ySr)rh)x^e6@Qcl z_m{I<(>11SdeQasuFu4rzt0+Dn9Kf7grsOOT78blH}IY@9PT;n2qG*43=GVlf)vh} zJT{H=4dhZ@89iaR5)b}Sg`8fuZlmUPxJ$e#H^DNbn0e)kRh zD4fsn&_Q}inbi8E{E`D=FgI*-U7BT126VOvU6fQ6z<&q_Z%(GJ=+%B}o|c={Ctxt& zG%iTIGC}t-kSC1UE$?Zqo&o)|A26eE8n33{iEwR?(r~y6a0KNJXC%dw)1j1qX&VmZYw}znOUTjSjkPyH`zJOkeFHr90EWIs8s;+2+t9M~>v z^zc^z@p_A>*A^~c>p)#RfJ>u6a;mJ!PwcFyQ1hEg9(P54_O5}@(PVB6TaA`BS?MD< znW&je!z=GJfJg#E@p%l|Km#2R>OmkM z{G~_iV>tF(3!FBPwWvU(>Oo!PbG9sSy#}P6bn9C7!@ikLS85ni{J3%I%sm$b` zbix3P6-HYys!ix)xc)R7GL|5Q%VmcJB{D;v!bnzF`2XteJENl5wsn;#0zx;EQv(f3P$cKjq;3=mN~1){NS2&~F8*u_pL>2YlnG4{eo3H(E^lTnkB_^)V;qpia%qrW$GARcEGH9sa4c0 z2#|R)0&-@&g<#jcFoS-3=cYSA@4L}dc?y}Bj9h*bu@`hr-&Y=f(LwZx0J+1{kCaqo zUr_pivPjgg3Q;wTSdMKel7{VXZePFtR}yC$Ukdk7y7VhBK#?R zUJl(mB6Yp#?;tCFzy>eOep4{MU0+_HeMk>U5=PV<9N81ZfDwH`hqgf;xD>iD0}gGw zEvnLM1LHI5#WvY;#`#k0ChYxNC|{V7Cr*v@L$HM{!b2a2Fs|%S2rGnOmln*#i1BcH z@cZli9kxF|W~hIM^5Qg0HWzoKoHhvceByer@42T!Ml_% z4qTB@BPDY`e~R-H2(0e#X#8%x35ZqJ2eAQP8xVnY(3^eJz#z8L+HoA$A)aG3^FYw@ zv~YZoMQc;UQ(hP*pWLq@xD#9eS z7Vt88a|rK~edFHK@6|H~q^V@y^jvb0QGHM?gT&O&Lr3P*hm|@PB+jN7XK)s}nbGbS zq5d9;+L>m0-37h#>t)W{3ZpRf+Fg^Bk^R933#81vwwW!{Fjgr7g;`km-sJpEa{LxV zTKTV4@l68#A3_agAiK$z|1CshHzxlSNXSiS`k(SIJ~y8K>b9W;;ylYG%j&yhifMt; z*sk2$8QKr$yQSt!G=BCDOzD;DnwQSptV8?_MrBGl-UW&IO7dg5KTvFjHzdmrvk5TS zNCpkIuz+_$oKtQow8Maa{dY~f;y^LCqF>h}3av0%PEL|*Dz)sL#XG=X05vC#g# zS4>aSqR^QXu#=Lnhto8BP3E7Dg2~2Ba!Qw^NOQUvLb9Du6JjtaZxW@l@E9``#oPl; zBk$7250K3op0-zyLi(Dgi# z`qDq#UwQxe77K*WBGzccj5{I)}ZoxD2N_5?SK+eZE1WSa$y z*kisK9tT*4i;!%wL z*<`G@eb$6(|ITL|=YIcE&!|}#y;FIAXZ$nk+8z-v0lO90z?pHsa|WMdTZo!%3>i5& zVo~5ZzJvC+!Yq#%h4$24l;pGM_0hOJ;V@pvj88fkd{%7D>FPoE#*Bd%8K>vTHhtil z?DU4g_6#@m+i`ZZnq*ss>uexB<9p8raYI{c1{1Fr&CL8d8P20IjF7YxqoeIxPuxpDM`O)gB!I;tQ zdvcf1UR?FpO&6Wu=J}J!uf$;755y5s$#>b@O!QsJYw;`aw6*D2HKLk|kXq3f$LFz$ z;DeZ5I_A=Jvu?2Chlv1WLHy*hvaHglnW>fM8D(i((sHTjLnbq96_zS@#2-M_MIyVG zpk}T&`ZPa+kI#@0&T8>i@OgFFZAbCy1A4#H`V2|xP_u~>76<(+q0^x1>2Nyj6wgXN zepTGL3V=EySX(||>5m^C-I+BT0C`-VUyw$w-#7v=%tjJP_0mT|w(a~<%Bj}j>*+@hs;1pX5w!eMYsN6up99z$o~xi`6r?y{$>l|Z}i6v1SCY|Kae8- z0vEXhd{+aC@9i{<7O_uTo`%@zB)Wwyx4Y|!COf|L_ zM$uMEEQFoKVL8f7xr&5?X|SJJ4w$B7D{ij#@(wsZmmq|A$a^MJ=5$v)j?y1kS;_VL zwn~is`(pXx-RF9X<<6_Y3q}AGLoai0YxazVhgsZu_!6*LnIW4mKJ$Pkeo=cQqJ{B* zIAhLA;V?NUEN|2`nRRugx;>`f)kepj_Ad+H5#N{n2tyITph?dPJDw7q_9LRk$jm&8 zR6U@?T|!DyB@+h8i8x}c+t;im1IfH6zG*09%w#YubUs+l}AF&u=2u4f_ zWZciM?f8C04#kY=A_HaaNx;7Pgxwx033fm}=C*e4ar7b|YSyUj$wKWqzA?G+hP}>? zpw2u6MVpBWN0L_}bdHT`7)adWdnstrQg{1c@uk`)DZIES&$m#^m zhRx|g+wdykRU4wx0Cquh_EIj3)gg%PJM zFePEz^(NDv@RdVQG=y^y&y?lzj-LP`!%OYeuL?jbX%nn8a&>?mHV-O>cWzDbPlrOo zJ(%HY&zrE-!$i>n{8je4Qmw8G)-f?!yEqcN-@##*P!jKcykd1plzkZd5SnnJIf?=3~3JS3YE-P1!Z%Cz5$ z_GUa)=j$W~e;jQAw*OoaqhekkM>H!>@JYZ5(j%nxMbbfo1Z3Oxp|#FaSCYl@*1YC0{;qLy7$v%03vC%!Xs@IKRJmLj@< z;^i?Fa*2TV`Vh3NJyd9^41-XOcUPfKb=NKD$D)AdKCts2drA7-%!iuqPm&)&N z%R5XU;Tyz2Q0OKB*^1xh{F`L{SiOLvd2-C^d%WiIOd{nSf_V_f%6ox!A%TS}I8-|8 zE?MUHh}xGj4(#<--80K(K~firXMB(*Udg9b!9E4?diRAP*~CD;a+>eZbyjdmSBSw3 z2nT!jc;2Njd^bTQRt!gl)Nb%slvW`i<_bn*>0y5Wc{y&=`?3bvCvyxcNCQ3m88mQB zE`&&p7)!dAkYWGzcT4MoYf8NiX}*f+tMgEVAazYK#c4Mab{!@*Bg^mMV$DYb9tv|> z;8vVdw95fTzJk|4rf6BMQSgL{AWrGK)UVUfR+Jd+$vH*NvKXrXFYuQIYn=rmiW%mN zOd?1xyCl-doGQozoUT9BJ5)(>aXMbL)mkI<2gibu{9yo3n zK1j>!;cH_Ws=Sy8$tmJODJ9?Tkw6-J@tD{PRZ}*Eksjqng-z(_-AnVn~{!gda^2N7Rdxd5fpQotq?qqg+NvZo$ zzT^D6eu#`h#@sE4H2PKUE@)y=v#LR2X)Q?paeZi1pU3awoZHBP-`5ij-{D;l5XJXE zI~G8Y_w}oWqP*(-$ZwOmivvdie_o#8LYIVlu^YqIeqX56sa%{hslzP$3Fy89EL3rlaI$?*8;h=Oc z)~{^wJpbe^s~0QONpwi=@Q4_3@8^WXPGYymmWf75(vj>jGC`zkakG<<|M1YlZZ6JU zSLOPuOMbg&UgXSRzd*cpDxkn)<#5LwTg`p?v=a4}@*=85jSI&u&P3MIfv{iq%GZtY zIW^}!d?M%1z!pyY@00rfdFKz_o~e^XD0U==ONK3QdvO+pYqnYa*wl4BaLwNzo6w;h z=e95LEE#2)@p%h2N~_fyJ{g|?e-r`ky|FAI@!+UsI*Y3ERNj6MmMDoEn@%10EmncP zROMR4Uc3d5=Z>L2D)~-4mz_{vNSv+NpOX+rq%__PV&^AzuDjMkD~+}{6FqP)C7~{J zCmy5g$K{yoA$D9Y_fSr|*Tb?`=8eIQkG)6Y%ZE5$8JvGYL_> z)jsT3pxTfmSI5+6ZvGr)B}Ry&81}b+52nujNrW${Epz2d$Bq}JB`Un}qp^*)Tf{g! zKL-r;`@_bAT9$CjT(%z&rvdhpJ}WUaLqv9c#j)Z{_|>&NsnNPIve~A1zOBz7H8wz%)CjvGLHB|+SRX8t!w#21z{Aq;Q>Y9W2q^^$cQZkzdDwcRe z?wSm0^W87iZqeTW>+-f$n>a)OW_1)wV5#9|lixP6qD=hFHBzVy=L$u!PCmwZ!aN_z zn9X{!ek>m5IWCka_S2TSH@fTumG^mvWU$!Unn>`GK#2C;Xt%cevO#3>owrmU3vw+O9>1$0-W}>Ya_7b9%q>&h{BHM`0v!cg&3u|L zce?Gsfxv7>lx4-T3g-J3BmATa)~EYBD+-Lie`_q0Z!(vW%^iwV! zYPCgaJdLXH$hpEcGE7?w?W~Fs#SkqABVo~FE%da zw(wrP1oFruBMe;1FiYn&wZ&e{`EcJ2Gdc?$t2kG(lfRm!!c!eL^F@AAM(b4jZWZw{ z0!t5BmC@I1=*=@p3>`czS%1;4i0=@Fz!mrQ#$0`vxl*#3eUKB_=%tz1qCAzg9Ko^p zG?kt=RiSALRoA&~24_a437w8O6!*%$xrZ+|QmwL?Vb{q*b!oL#DKsm?Gf<3f?Mo7t z`~0z0g|dgMJbXNyJgX&>zdxPFho;Hul#dyGVqx#da`-05H?-E3d$#|`XXE&~=%8`Q zhAw(H=Nq!w2pmBaaYKd?L!=E3=FkBLbKbDG-4J9bXTxroG9CgIQMdl7=DbZ2{kK|` zfC2coI)>;8_&>b=YxTk!y*m7O)W;-C(r0HcMb)mUVW+3>Wyz`2we+;hnGbgRzR$-# zw@3*AG_E)<@RPlB+v&!+k-pUF~)GlQplI?>!9s$aFn`B!&kDeDH*mi43ZNyf3n+Doy!Vm&#Zmz z56tpY8(W+4Z?LH2l#^jJRH<1jb`x&;S)KTdA7CYGqcXB4Tc#1v^sMJnib~nMj--J*}XX~)vFUyasmp~bow-sSw>ugM>trj1|Er76tAv%OOY`lsG=_>A4V zPxjaOH`T&lWDEaj_`fy%K>jC%!+(pwf#Bb*LRu6sO{beVmR8lLm*dKx$C(0Biun

P9hfvwG&=nSMyK9 zKVcp0F|+upifHE~QU{7$6v9c&4tL>96x{rL%`hAqbXU;$yg&JpfKGIK4H|SLqy8LH z_H#v{6;)8jJ8a*|-{(9la~2RrM;k)>gnU@flIG@ksb|MI zG+FCg!}Ee%!8mO1Q|91_yL4>qt@5!{P;+X7l)>Bp$#^moMlq^mo7wVQ`zF-farhma zz1pCcNQ5`;=UWwYvKZf&(a|-e`w`DE_8tzef-HrPO%0=X$p0J)0B76yq^`oBz7<*L z3a;|eTSx0nHLgu6J{yoxpk*L>ZJ)B@?c||YH1zzpoi&`~OTT~oT@!obD2GR@w+u(+PZoIkNx z6vdJfru2Oi{X&KovaTcvwpo*}SvB8?JHkFN-5UYv3l)UJvDH3`(!P;nR)(*3k4Nr3 z!alL2C2%63u0)hHKbEb2>}OXyS{Y%q$6_BBEJhELO|j8O>!ha9m%Pd?nlz24<5w|7 zFk|8=ER#~;L8Y6%rH7|~Cd_g6Zu!jj^n)H5RLn&9V}L5SSvLoD-FlDPZ$pw|9ze~k ze?x|R-?NzW)I-_`$lez15WmQX^sEG(plUS_<*uyDstQSAiI?u%{>?bABcAeCo7yRd z_RyhX!$}B}M?hoNa-*0WjlU;2!SyzY)=8Krjelr6Ie5O!|=o36t$3Vk+ta^qdr-4pm1s}fFp9uO{_@7 zLGq^OFXGail}sdudi6OhSV05`Rq2G7tUFM{ zTf-hnNuN;#A;{o2ruu92Lfz*t#dr<9_BUWX)k(KDOFuN?s$=SgZ+AEDt!v%k?qU2x z{KvGZnxXy44;0vQmU1Xqh8bY5_r##@F-AY&ebt0J2l7J?iLD$ML=LuH%NZJ!u$vyB zp2DbAv?Z}8xZ_rJANAY`1&Ng12A=imm7Twc^r*LlFKcV`e5gNs#G`d`6Ck=*7Ce9r ze8g3#VU1n(N6bYnCX9ud>0qZ)qXoK(7yhk_>wcqbSA=Crwq&#y?%?blv82aD`^6|= zGQrSmna)2v#x~He$Fv_hE@BayRM7Nf%j3O*1#*lr7hQZ~wtHPhcrd$TO=jCcV5yY> zDJExESHzwfW6eW!6`Sd&4q+f6xV5Qb?s`r0ERXOx1VF#E+Vnhe!@uG&#-eYquc-2v z(xI;&&-7^gw7KHd;M8%>g8XW4$cS%q&+XiUEqfm^JlC4lJoH2^!L?`M^`nE2lR^kU zkw9cg8&hRP*Lr#X1{ATODWl+VnXlx;X)w)o+*nPQoWA8A@Ul9idX`i-u%i!ZbZ*iQ zZ^ilu3E1`O>$TD?3i4lQHBxle1ZJ^i8Fr+G&O009|N0le|G*Laz|RxerbwZ5yNc-p zMXTyg&{WE#r+#-^GkrT@Hm*DXm6uSDbnkIB`^)Icus-{Y*l4;2Q0tA+*VE_OC literal 0 HcmV?d00001 diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx index af6ed7534c..2dc53f93d4 100644 --- a/example/src/ExampleList.tsx +++ b/example/src/ExampleList.tsx @@ -43,6 +43,7 @@ import SwitchExample from './Examples/SwitchExample'; import TeamDetails from './Examples/TeamDetails'; import TeamsList from './Examples/TeamsList'; import TextExample from './Examples/TextExample'; +import TextFieldExample from './Examples/TextFieldExample'; import TextInputExample from './Examples/TextInputExample'; import ThemeExample from './Examples/ThemeExample'; import ThemingWithReactNavigation from './Examples/ThemingWithReactNavigation'; @@ -90,6 +91,7 @@ export const mainExamples: Record< switch: SwitchExample, text: TextExample, textInput: TextInputExample, + textField: TextFieldExample, toggleButton: ToggleButtonExample, tooltipExample: TooltipExample, touchableRipple: TouchableRippleExample, diff --git a/example/src/Examples/TextFieldExample.tsx b/example/src/Examples/TextFieldExample.tsx new file mode 100644 index 0000000000..a58fedcb02 --- /dev/null +++ b/example/src/Examples/TextFieldExample.tsx @@ -0,0 +1,244 @@ +import * as React from 'react'; +import { + StyleSheet, + TextInput, + View, + type TextStyle, + type ViewStyle, +} from 'react-native'; + +import { + Divider, + List, + Switch, + Text, + TextField, + TouchableRipple, + type TextFieldAccessoryProps, + type TextFieldVariant, +} from 'react-native-paper'; + +import { useExampleTheme } from '../hooks/useExampleTheme'; +import ScreenWrapper from '../ScreenWrapper'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type DemoControls = { + error: boolean; + disabled: boolean; + leadingIcon: boolean; + trailingIcon: boolean; + counter: boolean; + showPrefix: boolean; + showSuffix: boolean; + multiline: boolean; +}; + +type DemoModifiers = { + label: string; + helperText: string; + placeholder: string; + prefix: string; + suffix: string; +}; + +// --------------------------------------------------------------------------- +// TextFieldDemo +// --------------------------------------------------------------------------- + +type TextFieldDemoProps = { + variant: TextFieldVariant; +}; + +const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { + const theme = useExampleTheme(); + + const [value, setValue] = React.useState(''); + + const [controls, setControls] = React.useState({ + error: false, + disabled: false, + leadingIcon: false, + trailingIcon: false, + counter: false, + showPrefix: false, + showSuffix: false, + multiline: false, + }); + + const [modifiers, setModifiers] = React.useState({ + label: 'Label', + helperText: 'Supporting text', + placeholder: 'Placeholder', + prefix: '$', + suffix: '/100', + }); + + const toggleControl = (key: keyof DemoControls) => + setControls((prev) => ({ ...prev, [key]: !prev[key] })); + + const setModifier = (key: keyof DemoModifiers, text: string) => + setModifiers((prev) => ({ ...prev, [key]: text })); + + const LeadingIcon = React.useCallback( + (props: TextFieldAccessoryProps) => ( + + ), + [] + ); + + const TrailingIcon = React.useCallback( + (props: TextFieldAccessoryProps) => ( + setValue('')} /> + ), + [] + ); + + const inputColor = theme.colors.onSurfaceVariant; + const borderColor = theme.colors.outlineVariant; + + const modifierInputStyle: TextStyle = { + flex: 1, + color: inputColor, + fontSize: 14, + paddingVertical: 4, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: borderColor, + }; + + const SWITCH_CONTROLS: { label: string; key: keyof DemoControls }[] = [ + { label: 'Error', key: 'error' }, + { label: 'Disabled', key: 'disabled' }, + { label: 'Leading icon', key: 'leadingIcon' }, + { label: 'Trailing icon', key: 'trailingIcon' }, + { label: 'Counter', key: 'counter' }, + { label: 'Prefix', key: 'showPrefix' }, + { label: 'Suffix', key: 'showSuffix' }, + { label: 'Multiline', key: 'multiline' }, + ]; + + const MODIFIER_FIELDS: { label: string; key: keyof DemoModifiers }[] = [ + { label: 'Label', key: 'label' }, + { label: 'Helper', key: 'helperText' }, + { label: 'Placeholder', key: 'placeholder' }, + { label: 'Prefix', key: 'prefix' }, + { label: 'Suffix', key: 'suffix' }, + ]; + + return ( + + {/* Live TextField */} + + + + + {/* Controls */} + Controls + {SWITCH_CONTROLS.map(({ label, key }) => ( + toggleControl(key)}> + + {label} + + + + + + ))} + + + + {/* Modifiers */} + Modifiers + {MODIFIER_FIELDS.map(({ label, key }) => ( + + + {label} + + setModifier(key, text)} + style={modifierInputStyle} + placeholderTextColor={theme.colors.outline} + placeholder={`Enter ${label.toLowerCase()}…`} + /> + + ))} + + ); +}; + +// --------------------------------------------------------------------------- +// TextFieldExample +// --------------------------------------------------------------------------- + +const TextFieldExample = () => { + return ( + + + + + + + + + ); +}; + +TextFieldExample.title = 'TextField'; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + paddingVertical: 8, + } satisfies ViewStyle, + demoContainer: { + gap: 4, + } satisfies ViewStyle, + divider: { + marginVertical: 8, + } satisfies ViewStyle, + subheader: { + paddingHorizontal: 0, + } satisfies TextStyle, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 8, + paddingHorizontal: 8, + } satisfies ViewStyle, + modifierRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 8, + paddingHorizontal: 8, + } satisfies ViewStyle, + modifierLabel: { + width: 80, + } satisfies TextStyle, +}); + +export default TextFieldExample; diff --git a/jest/testSetup.js b/jest/testSetup.js index 5088ab5585..e6561e0211 100644 --- a/jest/testSetup.js +++ b/jest/testSetup.js @@ -14,17 +14,14 @@ jest.mock('@react-native-vector-icons/material-design-icons', () => { const MockIcon = ({ name, color, size, style, ...props }) => { return ( - + {name || '□'} ); }; MockIcon.displayName = 'MockedMaterialDesignIcon'; - + return { __esModule: true, default: MockIcon, @@ -89,6 +86,8 @@ jest.mock('react-native', () => { RN.Animated.loop = loop; RN.Animated.parallel = parallel; + jest.spyOn(RN.PixelRatio, 'getFontScale').mockReturnValue(1); + return RN; }); diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx new file mode 100644 index 0000000000..b610eb5288 --- /dev/null +++ b/src/components/TextField/TextField.tsx @@ -0,0 +1,422 @@ +import React, { ComponentType } from 'react'; +import { + BlurEvent, + ColorValue, + FocusEvent, + Pressable, + StyleProp, + Text, + TextInput, + TextInputProps, + TextProps, + TextStyle, + View, + ViewStyle, +} from 'react-native'; + +import Animated, { AnimatedStyle } from 'react-native-reanimated'; + +import { useTextField } from './hooks'; +import { styles } from './styles'; +import TextFieldErrorIcon from './TextFieldErrorIcon'; +import type { InternalTheme, ThemeProp } from '../../types'; + +export type TextFieldVariant = 'filled' | 'outlined'; + +export interface TextFieldAccessoryProps { + style: StyleProp; + multiline: boolean; + disabled: boolean; + error: boolean; +} + +export type TextFieldSharedApi = { + input: React.RefObject; + theme: InternalTheme; + isFocused: boolean; + disabled: boolean; + hasAccessory: boolean; + hasError: boolean; + hasSuffix: boolean; + animatedLabelWrapperStyle: StyleProp>>; + animatedLabelTextStyle: StyleProp>>; + animatedActiveOutlineStyle?: StyleProp>>; +}; + +export type SharedTextFieldStyleData = { + isRTL: boolean; + animatedLabelTextStyles: StyleProp>>; + supportingTextStyles: StyleProp; + counterStyles: StyleProp; + prefixStyles: StyleProp; + suffixStyles: StyleProp; + leadingAccessoryStyles: StyleProp; + trailingAccessoryStyles: StyleProp; +}; + +export type FilledTextFieldHookData = SharedTextFieldStyleData & { + input: React.RefObject; + disabled: boolean; + hasError: boolean; + hasSuffix: boolean; + animatedLabelWrapperStyles: StyleProp>>; + containerStyles: StyleProp; + fieldStyles: StyleProp; + disabledBackgroundStyles: StyleProp | undefined; + outlineStyles: StyleProp; + animatedActiveOutlineStyles: StyleProp>>; + inputStyles: StyleProp; +}; + +export type OutlinedTextFieldHookData = SharedTextFieldStyleData & { + input: React.RefObject; + disabled: boolean; + hasError: boolean; + hasSuffix: boolean; + animatedLabelWrapperStyles: StyleProp>>; + containerStyles: StyleProp; + fieldStyles: StyleProp; + disabledBackgroundStyles: undefined; + outlineStyles: StyleProp; + inputStyles: StyleProp; +}; + +export type TextFieldHookReturn = SharedTextFieldStyleData & { + input: React.RefObject; + disabled: boolean; + hasPrefix: boolean; + hasCounter: boolean; + hasSuffix: boolean; + hasError: boolean; + placeholderTextColor: ColorValue; + selectionColor: ColorValue; + cursorColor: ColorValue; + animatedActiveOutlineStyles: + | StyleProp>> + | undefined; + animatedContainerStyle: StyleProp>>; + animatedLabelWrapperStyles: StyleProp>>; + containerStyles: StyleProp; + fieldStyles: StyleProp; + disabledBackgroundStyles: StyleProp | undefined; + outlineStyles: StyleProp; + inputStyles: StyleProp; + placeholder: string | undefined; + counterText: string; + LeadingAccessory: ComponentType | undefined; + TrailingAccessory: ComponentType | undefined; + onFocusHandler: (e: FocusEvent) => void; + onBlurHandler: (e: BlurEvent) => void; + focusInput: () => void; +}; + +export interface TextFieldProps extends TextInputProps { + /** + * Ref forwarded to the underlying TextInput. + */ + ref?: React.Ref; + /** + * - `filled` text fields are often used in dialogs and short forms where their style draws more attention. + * - `outlined` text fields are often used in long forms where their reduced emphasis helps simplify the layout. + */ + variant?: TextFieldVariant; + /** + * When `true`, the field uses error styling and validation semantics (`aria-invalid`). + */ + error?: boolean; + /** + * The label text to display above the input. + */ + label?: string; + /** + * Pass any additional props directly to the label Text component. + */ + labelProps?: TextProps; + /** + * Supporting text to display below the input (Material Design 3). When + * `error` is `true`, this text is styled as an error message. + */ + supportingText?: string; + /** + * Pass any additional props directly to the supporting text `Text` component. + */ + supportingTextProps?: TextProps; + /** + * When `true`, displays a character counter below the input on the trailing + * side, showing `currentLength/maxLength`. Requires `maxLength` to be set. + */ + counter?: boolean; + /** + * Pass any additional props directly to the counter `Text` component. + */ + counterProps?: TextProps; + /** + * A short text string displayed at the start of the input (e.g. `"$"`). + */ + prefix?: string; + /** + * Pass any additional props directly to the prefix `Text` component. + */ + prefixProps?: TextProps; + /** + * A short text string displayed at the end of the input (e.g. `"/100"`). + */ + suffix?: string; + /** + * Pass any additional props directly to the suffix `Text` component. + */ + suffixProps?: TextProps; + /** + * Style overrides for the pressable root element. + */ + pressableStyle?: StyleProp; + /** + * Style overrides for the field container (the bordered row that includes + * StartAccessory, input content, and EndAccessory). + */ + fieldStyle?: StyleProp; + /** + * Style overrides for the input content wrapper (the area containing + * the label and TextInput, excluding accessories). + */ + containerStyle?: StyleProp; + /** + * Style overrides for the indicator layer (the purely visual border or line + * that shows state, not the interactive input). + * - `filled` — applied to both the always-visible bottom edge and the + * animated bar that expands on focus. + * - `outlined` — applied to the rounded border around the field for both states. + */ + outlineStyle?: StyleProp; + theme?: ThemeProp; + /** + * An optional component to render on the start side of the input (leading in LTR). + * Can be a custom component or `TextField.Icon`. + */ + StartAccessory?: ComponentType; + /** + * An optional component to render on the end side of the input (trailing in LTR). + * Can be a custom component or `TextField.Icon`. + */ + EndAccessory?: ComponentType; +} + +/** + * A text field lets users enter and edit text. It shows an optional floating label, + * supports `filled` and `outlined` variants, optional supporting text (including + * error state), and start/end accessories. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { TextField } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [text, setText] = React.useState(''); + * + * const SearchIcon = (props) => ( + * + * ); + * + * const ClearAccessory = ({ style, disabled }) => ( + * setText('')} + * accessibilityRole="button" + * accessibilityLabel="Clear text" + * > + * + * + * ); + * + * return ( + * + * ); + * }; + * + * export default MyComponent; + * ``` + * + * @extends TextInput props https://reactnative.dev/docs/textinput#props + */ +function TextField(props: TextFieldProps) { + /* eslint-disable @typescript-eslint/no-unused-vars -- peel TextField-only props before TextInput spread */ + const { + ref, + error, + label, + supportingText, + supportingTextProps, + labelProps, + variant, + pressableStyle: pressableStyleOverride, + fieldStyle, + containerStyle, + outlineStyle, + theme, + StartAccessory, + EndAccessory, + prefix, + prefixProps, + suffix, + suffixProps, + counter, + counterProps, + ...textInputProps + } = props; + + const { + input, + disabled, + hasPrefix, + hasSuffix, + hasCounter, + hasError, + leadingAccessoryStyles, + trailingAccessoryStyles, + fieldStyles, + disabledBackgroundStyles, + outlineStyles, + animatedActiveOutlineStyles, + animatedLabelWrapperStyles, + animatedLabelTextStyles, + animatedContainerStyle, + containerStyles, + inputStyles, + prefixStyles, + suffixStyles, + supportingTextStyles, + counterStyles, + placeholderTextColor, + selectionColor, + cursorColor, + placeholder, + counterText, + LeadingAccessory, + TrailingAccessory, + focusInput, + onFocusHandler, + onBlurHandler, + } = useTextField(props); + + return ( + + + {/* Disabled tint overlay — filled variant only. A childless + absolutely-positioned View whose translucent fill is applied via the + `opacity` style, so it never affects label/input rendering and works + with PlatformColor on Android. */} + {!!disabledBackgroundStyles && ( + + )} + + {/* Inactive indicator — always-visible 1px bottom border (filled) or + full border (outlined); height and color reflect error/disabled state + but do not change on focus */} + + + {/* Active indicator — filled variant only; 2px bar that expands from + the center outward via scaleX (0 → 1) on focus and collapses on blur */} + {!!animatedActiveOutlineStyles && ( + + )} + + {!!label && ( + + + {label} + + + )} + + {!!LeadingAccessory && ( + + )} + + + {hasPrefix && ( + + {prefix} + + )} + + + + {hasSuffix && ( + + {suffix} + + )} + + + {TrailingAccessory ? ( + + ) : hasError ? ( + + ) : null} + + + + {!!supportingText && ( + + {supportingText} + + )} + + {hasCounter && ( + + {counterText} + + )} + + + ); +} + +export default TextField; diff --git a/src/components/TextField/TextFieldErrorIcon.tsx b/src/components/TextField/TextFieldErrorIcon.tsx new file mode 100644 index 0000000000..72fb6e7847 --- /dev/null +++ b/src/components/TextField/TextFieldErrorIcon.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { StyleProp, View, ViewStyle } from 'react-native'; + +import { ACCESSORY_SIZE } from './constants'; +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import Icon from '../Icon'; + +interface TextFieldErrorIconProps { + style?: StyleProp; + theme?: ThemeProp; +} + +const TextFieldErrorIcon = ({ + style: wrapperStyle, + theme: themeOverride, +}: TextFieldErrorIconProps) => { + const theme = useInternalTheme(themeOverride); + + return ( + + + + ); +}; + +export default TextFieldErrorIcon; diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextField/TextFieldIcon.tsx new file mode 100644 index 0000000000..ab91bafb72 --- /dev/null +++ b/src/components/TextField/TextFieldIcon.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { AccessibilityProps, GestureResponderEvent, View } from 'react-native'; + +import { useInternalTheme } from '../../core/theming'; +import type { ThemeProp } from '../../types'; +import type { IconSource } from '../Icon'; +import { ACCESSORY_SIZE } from './constants'; +import { styles } from './styles'; +import type { TextFieldAccessoryProps } from './TextField'; +import { getIconColor } from './utils'; +import IconButton from '../IconButton/IconButton'; + +export interface TextFieldIconProps extends TextFieldAccessoryProps { + /** + * Icon to display. + */ + icon: IconSource; + /** + * Color of the icon. + */ + color?: string; + /** + * Size of the icon. + */ + size?: number; + /** + * Accessibility props for the icon button. + */ + accessibility?: AccessibilityProps; + theme?: ThemeProp; + /** + * Function to execute on press. + */ + onPress?: (event: GestureResponderEvent) => void; +} + +/** + * A component to render a leading / trailing icon in the TextField + * (inside `StartAccessory` or `EndAccessory`). Accepts icon-specific props as well as + * `TextFieldAccessoryProps`, which TextField forwards automatically. + * + * ## Usage + * ```js + * import * as React from 'react'; + * import { TextField } from 'react-native-paper'; + * + * const MyComponent = () => { + * const [text, setText] = React.useState(''); + * + * const SearchIcon = (props) => ( + * + * ); + * + * const ClearIcon = (props) => ( + * setText('')} /> + * ); + * + * return ( + * + * ); + * }; + * + * export default MyComponent; + * ``` + */ +const TextFieldIcon = ({ + icon, + color, + size, + style, + error, + disabled, + accessibility, + theme: themeOverride, + onPress, +}: TextFieldIconProps) => { + const theme = useInternalTheme(themeOverride); + + const iconSize = size ?? ACCESSORY_SIZE; + + const iconColor = getIconColor({ + theme, + color, + hasError: error, + disabled, + }); + + const onPressHandler = disabled ? undefined : onPress; + + return ( + + + + ); +}; + +TextFieldIcon.displayName = 'TextField.Icon'; + +export default TextFieldIcon; diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts new file mode 100644 index 0000000000..e4925b9176 --- /dev/null +++ b/src/components/TextField/constants.ts @@ -0,0 +1,112 @@ +import { I18nManager, PixelRatio, Platform } from 'react-native'; + +import { tokens } from '../../theme/tokens'; +import { motionDuration } from '../../theme/tokens/sys/motion'; +import { defaultShapes } from '../../theme/tokens/sys/shape'; + +export const isWeb = Platform.OS === 'web'; + +export const fontScale = PixelRatio.getFontScale(); + +/** + * Common constants for the text field component. + */ + +export const BASELINE_TEXT_FIELD_HEIGHT = 56; +export const BASELINE_TEXT_FIELD_PADDING_VERTICAL = 8; + +export const TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL = 16; +export const TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL = 12; + +export const TEXT_FIELD_HEIGHT = BASELINE_TEXT_FIELD_HEIGHT * fontScale; +export const TEXT_FIELD_PADDING_VERTICAL = + BASELINE_TEXT_FIELD_PADDING_VERTICAL * fontScale; + +export const TEXT_FIELD_BORDER_RADIUS = defaultShapes.corner.extraSmall; + +export const LABEL_START_OFFSET_WITHOUT_ACCESSORY = + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; + +export const ACCESSORY_SIZE = 24; + +export const PREFIX_END_PADDING = 2; +export const SUFFIX_START_PADDING = 2; + +export const ERROR_ICON_SIZE = 16; + +export const LINE_HEIGHT_DELTA = 2; +export const INPUT_FONT_SIZE = tokens.md.sys.typescale.bodyLarge.fontSize; +export const ACTIVE_LABEL_FONT_SIZE = + tokens.md.sys.typescale.bodySmall.fontSize; +export const INACTIVE_LABEL_FONT_SIZE = INPUT_FONT_SIZE; +export const SUPPORTING_TEXT_FONT_SIZE = + tokens.md.sys.typescale.bodySmall.fontSize; + +export const INACTIVE_LABEL_TOP_POSITION = + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 + + BASELINE_TEXT_FIELD_PADDING_VERTICAL - + LINE_HEIGHT_DELTA) * + fontScale; + +export const SUPPORTING_TEXT_MARGIN_TOP = 4; + +export const ANIMATION_DURATION_MS = motionDuration.short3; + +export const ACTIVE_INDICATOR_SIZE = 2; +export const INACTIVE_INDICATOR_SIZE = 1; + +const isRTL = I18nManager.getConstants().isRTL; +const layoutSupportMultiplier = isRTL ? -1 : 1; + +/** + * Constants for the filled variant. + */ + +export const FILLED_LABEL_START_OFFSET_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL; + +export const FILLED_ACTIVE_LABEL_TOP_POSITION = TEXT_FIELD_PADDING_VERTICAL; + +export const FILLED_MULTILINE_PADDING_TOP = + ACTIVE_LABEL_FONT_SIZE * fontScale + TEXT_FIELD_PADDING_VERTICAL; + +export const FILLED_DISABLED_CONTAINER_OPACITY = 0.04; + +/** + * Constants for the outlined variant. + */ + +export const OUTLINED_DISABLED_OUTLINE_OPACITY = 0.12; + +export const OUTLINED_MULTILINE_PADDING_TOP = + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 - + LINE_HEIGHT_DELTA) * + fontScale; + +export const OUTLINED_LABEL_PADDING_HORIZONTAL = 4; + +export const OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + OUTLINED_LABEL_PADDING_HORIZONTAL; + +export const OUTLINED_ACTIVE_LABEL_TOP_POSITION = + (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + LINE_HEIGHT_DELTA) * fontScale; + +export const OUTLINED_LABEL_TRANSLATE_X_WITH_ACCESSORY = + -layoutSupportMultiplier * + (ACCESSORY_SIZE + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + OUTLINED_LABEL_PADDING_HORIZONTAL); + +export const OUTLINED_LABEL_TRANSLATE_X_WITHOUT_ACCESSORY = + -layoutSupportMultiplier * OUTLINED_LABEL_PADDING_HORIZONTAL; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts new file mode 100644 index 0000000000..b51930b013 --- /dev/null +++ b/src/components/TextField/hooks.ts @@ -0,0 +1,157 @@ +import { useImperativeHandle, useRef, useState } from 'react'; +import { BlurEvent, FocusEvent, I18nManager, TextInput } from 'react-native'; + +import type { + TextFieldHookReturn, + TextFieldProps, + TextFieldSharedApi, +} from './TextField'; +import { + getAccentColors, + getFilledTextFieldData, + getOutlinedTextFieldData, + getTextFieldAnimation, +} from './utils'; +import { useInternalTheme } from '../../core/theming'; + +export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { + const { + ref, + variant = 'filled', + theme: themeOverride, + onFocus, + onBlur, + } = props; + + /** + * Hooks + */ + + const input = useRef(null); + + const theme = useInternalTheme(themeOverride); + + const [isFocused, setIsFocused] = useState(false); + + useImperativeHandle(ref, () => input.current as TextInput); + + /** + * Constants + */ + + const { isRTL } = I18nManager.getConstants(); + const disabled = props.editable === false; + const isFloating = isFocused || !!props.value; + const hasError = !!props.error; + const hasAccessory = isRTL ? !!props.EndAccessory : !!props.StartAccessory; + const hasPrefix = !!props.prefix && isFloating; + const hasSuffix = !!props.suffix && isFloating; + const hasCounter = !!(props.counter && props.maxLength); + + /** + * Theme tokens + */ + + const { selectionColor, cursorColor } = getAccentColors({ + theme, + hasError, + }); + + const placeholderTextColor = + props.placeholderTextColor ?? theme.colors.onSurfaceVariant; + + /** + * Label animation + */ + + const { + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + animatedContainerStyle, + } = getTextFieldAnimation({ + variant, + isFloating, + isFocused, + hasAccessory, + }); + + /** + * Handlers + */ + + const onFocusHandler = (e: FocusEvent) => { + onFocus?.(e); + setIsFocused(true); + }; + + const onBlurHandler = (e: BlurEvent) => { + onBlur?.(e); + setIsFocused(false); + }; + + const focusInput = () => { + if (disabled) return; + input.current?.focus(); + }; + + /** + * Shared API + */ + + const api: TextFieldSharedApi = { + input, + theme, + isFocused, + disabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }; + + /** + * Components + */ + + const LeadingAccessory = isRTL ? props.EndAccessory : props.StartAccessory; + const TrailingAccessory = isRTL ? props.StartAccessory : props.EndAccessory; + // https://github.com/facebook/react-native/issues/31573 + const placeholder = isFocused ? props.placeholder : ' '; + const counterText = `${props.value?.length ?? 0}/${props.maxLength}`; + + /** + * Styles + */ + + const data = { + hasPrefix, + hasCounter, + placeholderTextColor, + selectionColor, + cursorColor, + animatedActiveOutlineStyles: undefined, + animatedContainerStyle, + placeholder, + counterText, + LeadingAccessory, + TrailingAccessory, + onFocusHandler, + onBlurHandler, + focusInput, + }; + + if (variant === 'filled') { + return { + ...data, + ...getFilledTextFieldData(api, props), + }; + } + + return { + ...data, + ...getOutlinedTextFieldData(api, props), + }; +}; diff --git a/src/components/TextField/index.ts b/src/components/TextField/index.ts new file mode 100644 index 0000000000..097b9b6980 --- /dev/null +++ b/src/components/TextField/index.ts @@ -0,0 +1,13 @@ +import TextFieldComponent from './TextField'; +import TextFieldIcon from './TextFieldIcon'; + +const TextField = Object.assign( + // @component ./TextField.tsx + TextFieldComponent, + { + // @component ./TextFieldIcon.tsx + Icon: TextFieldIcon, + } +); + +export default TextField; diff --git a/src/components/TextField/styles.ts b/src/components/TextField/styles.ts new file mode 100644 index 0000000000..d6d5ba5bd9 --- /dev/null +++ b/src/components/TextField/styles.ts @@ -0,0 +1,123 @@ +import { StyleSheet } from 'react-native'; + +import { + ACCESSORY_SIZE, + FILLED_DISABLED_CONTAINER_OPACITY, + OUTLINED_LABEL_PADDING_HORIZONTAL, + SUPPORTING_TEXT_FONT_SIZE, + SUPPORTING_TEXT_MARGIN_TOP, + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + TEXT_FIELD_BORDER_RADIUS, + TEXT_FIELD_HEIGHT, + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + TEXT_FIELD_PADDING_VERTICAL, +} from './constants'; +import { tokens } from '../../theme/tokens'; + +const { bodyLarge, bodySmall } = tokens.md.sys.typescale; + +export const styles = StyleSheet.create({ + input: { + paddingVertical: 0, + paddingHorizontal: 0, + includeFontPadding: false, + fontWeight: bodyLarge.fontWeight, + }, + field: { + flexDirection: 'row', + minHeight: TEXT_FIELD_HEIGHT, + paddingVertical: TEXT_FIELD_PADDING_VERTICAL, + }, + addendum: { + flexDirection: 'row', + }, + supportingText: { + flex: 1, + marginTop: SUPPORTING_TEXT_MARGIN_TOP, + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + fontSize: SUPPORTING_TEXT_FONT_SIZE, + fontWeight: bodySmall.fontWeight, + textAlign: 'left', + }, + counter: { + marginTop: SUPPORTING_TEXT_MARGIN_TOP, + marginStart: 'auto', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + fontSize: SUPPORTING_TEXT_FONT_SIZE, + fontWeight: bodySmall.fontWeight, + textAlign: 'right', + }, + trailingAccessory: { + width: ACCESSORY_SIZE, + height: ACCESSORY_SIZE, + alignSelf: 'center', + justifyContent: 'center', + alignItems: 'center', + marginEnd: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + }, + leadingAccessory: { + width: ACCESSORY_SIZE, + height: ACCESSORY_SIZE, + alignSelf: 'center', + justifyContent: 'center', + alignItems: 'center', + marginStart: TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL, + }, + disabled: { + opacity: tokens.md.ref.stateOpacity.disabled, + }, + iconWrapper: { + justifyContent: 'center', + alignItems: 'center', + }, + icon: { + margin: 0, + }, +}); + +export const filledStyles = StyleSheet.create({ + outline: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + }, + container: { + flex: 1, + flexDirection: 'row', + alignItems: 'flex-end', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + }, + labelWrapper: { + position: 'absolute', + }, + disabledBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + opacity: FILLED_DISABLED_CONTAINER_OPACITY, + }, +}); + +export const outlinedStyles = StyleSheet.create({ + outline: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + borderRadius: TEXT_FIELD_BORDER_RADIUS, + }, + container: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL, + }, + labelWrapper: { + position: 'absolute', + paddingHorizontal: OUTLINED_LABEL_PADDING_HORIZONTAL, + }, +}); diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts new file mode 100644 index 0000000000..846d992589 --- /dev/null +++ b/src/components/TextField/utils.ts @@ -0,0 +1,636 @@ +import { I18nManager, StyleProp, TextStyle, ViewStyle } from 'react-native'; + +import { AnimatedStyle } from 'react-native-reanimated'; + +import { + ACTIVE_INDICATOR_SIZE, + ACTIVE_LABEL_FONT_SIZE, + ANIMATION_DURATION_MS, + FILLED_ACTIVE_LABEL_TOP_POSITION, + FILLED_LABEL_START_OFFSET_WITH_ACCESSORY, + FILLED_MULTILINE_PADDING_TOP, + INACTIVE_INDICATOR_SIZE, + INACTIVE_LABEL_FONT_SIZE, + INACTIVE_LABEL_TOP_POSITION, + INPUT_FONT_SIZE, + LABEL_START_OFFSET_WITHOUT_ACCESSORY, + OUTLINED_ACTIVE_LABEL_TOP_POSITION, + OUTLINED_DISABLED_OUTLINE_OPACITY, + OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY, + OUTLINED_LABEL_TRANSLATE_X_WITHOUT_ACCESSORY, + OUTLINED_LABEL_TRANSLATE_X_WITH_ACCESSORY, + OUTLINED_MULTILINE_PADDING_TOP, + PREFIX_END_PADDING, + SUFFIX_START_PADDING, + TEXT_FIELD_BORDER_RADIUS, + isWeb, +} from './constants'; +import { filledStyles, outlinedStyles, styles } from './styles'; +import type { + FilledTextFieldHookData, + OutlinedTextFieldHookData, + TextFieldProps, + TextFieldSharedApi, + SharedTextFieldStyleData, +} from './TextField'; +import type { InternalTheme } from '../../types'; + +export const getAccentColors = ({ + theme, + hasError, +}: { + theme: InternalTheme; + hasError: boolean; +}) => { + const color = hasError ? theme.colors.error : theme.colors.primary; + + return { + selectionColor: color, + cursorColor: color, + }; +}; + +export const getLabelColor = ({ + theme, + hasError, + isFocused, + disabled, +}: { + theme: InternalTheme; + isFocused: boolean; + hasError: boolean; + disabled: boolean; +}) => { + const { + colors: { error, primary, onSurface, onSurfaceVariant }, + } = theme; + + if (hasError) { + return error; + } + if (disabled) { + return onSurface; + } + if (isFocused) { + return primary; + } + return onSurfaceVariant; +}; + +export const getSupportingTextColor = ({ + theme, + hasError, + disabled, +}: { + theme: InternalTheme; + hasError: boolean; + disabled: boolean; +}) => { + const { + colors: { error, onSurface, onSurfaceVariant }, + } = theme; + + if (hasError) { + return error; + } + if (disabled) { + return onSurface; + } + return onSurfaceVariant; +}; + +/** + * Returns the solid background color for the filled field container, or + * `undefined` when disabled. The disabled tint is rendered + * as a separate overlay View whose alpha is applied via the `opacity` style; + * keeping the alpha out of the color string is what makes the component safe + * to use with `PlatformColor` values on Android. + */ +export const getFieldBackgroundColor = ({ + theme, + disabled, +}: { + theme: InternalTheme; + disabled: boolean; +}): string | undefined => { + if (disabled) { + return undefined; + } + + return theme.colors.surfaceContainerHighest; +}; + +export const getIconColor = ({ + theme, + color, + hasError, + disabled, +}: { + theme: InternalTheme; + color?: string; + hasError: boolean; + disabled: boolean; +}) => { + if (color) return color; + if (hasError) return theme.colors.error; + if (disabled) return theme.colors.onSurface; + return theme.colors.onSurfaceVariant; +}; + +/** + * Returns the raw outline color for a filled field. The disabled state's + * alpha is intentionally NOT baked in here — it is applied via the `opacity` + * style on the (childless) outline View so the value can be a `PlatformColor` + * on Android, which the `color` library cannot parse at runtime. + */ +export const getOutlineColor = ({ + theme, + hasError, + isFocused, + disabled, +}: { + theme: InternalTheme; + isFocused: boolean; + hasError: boolean; + disabled: boolean; +}) => { + const { + colors: { error, onSurface, primary, outline }, + } = theme; + + if (hasError) { + return error; + } + if (disabled) { + return onSurface; + } + if (isFocused) { + return primary; + } + + return outline; +}; + +/** + * Computes the style arrays that are identical across the filled and outlined + * variants. Each variant logic function calls this and then only computes its + * own variant-specific styles on top. + * + * Returns `isRTL` as well so callers can use it when building `inputStyles`, + * which is variant-specific (filled adds `MULTILINE_PADDING_TOP`). + */ +export const getSharedTextFieldStyleData = ( + api: TextFieldSharedApi, + props: TextFieldProps +): SharedTextFieldStyleData => { + const { isRTL } = I18nManager.getConstants(); + + const { theme, disabled, hasError, isFocused, animatedLabelTextStyle } = api; + const { + labelProps, + supportingTextProps, + counterProps, + prefixProps, + suffixProps, + } = props; + + const labelColor = getLabelColor({ theme, hasError, isFocused, disabled }); + + const supportingTextColor = getSupportingTextColor({ + theme, + hasError, + disabled, + }); + const { + colors: { onSurfaceVariant }, + } = theme; + + const animatedLabelTextStyles: StyleProp< + AnimatedStyle> + > = [ + styles.input, + { color: labelColor }, + animatedLabelTextStyle, + disabled && styles.disabled, + labelProps?.style, + ]; + + const supportingTextStyles: StyleProp = [ + styles.supportingText, + { + color: supportingTextColor, + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + disabled && styles.disabled, + supportingTextProps?.style, + ]; + + const counterStyles: StyleProp = [ + styles.counter, + { + color: supportingTextColor, + writingDirection: isRTL ? 'rtl' : 'ltr', + }, + disabled && styles.disabled, + counterProps?.style, + ]; + + const prefixStyles: StyleProp = [ + styles.input, + { + fontSize: INPUT_FONT_SIZE, + color: onSurfaceVariant, + paddingEnd: PREFIX_END_PADDING, + }, + disabled && styles.disabled, + prefixProps?.style, + ]; + + const suffixStyles: StyleProp = [ + styles.input, + { + fontSize: INPUT_FONT_SIZE, + color: onSurfaceVariant, + paddingStart: SUFFIX_START_PADDING, + }, + disabled && styles.disabled, + suffixProps?.style, + ]; + + const leadingAccessoryStyles: StyleProp = [ + styles.leadingAccessory, + disabled && styles.disabled, + ]; + + const trailingAccessoryStyles: StyleProp = [ + styles.trailingAccessory, + disabled && styles.disabled, + ]; + + return { + isRTL, + animatedLabelTextStyles, + supportingTextStyles, + counterStyles, + prefixStyles, + suffixStyles, + leadingAccessoryStyles, + trailingAccessoryStyles, + }; +}; + +export const getTextFieldAnimation = ({ + variant, + isFloating, + isFocused, + hasAccessory, +}: { + variant: 'filled' | 'outlined'; + isFloating: boolean; + isFocused: boolean; + hasAccessory: boolean; +}): { + animatedLabelWrapperStyle: StyleProp>>; + animatedLabelTextStyle: StyleProp>>; + animatedContainerStyle: StyleProp>>; + animatedActiveOutlineStyle?: StyleProp>>; +} => { + const activeTop = + variant === 'filled' + ? FILLED_ACTIVE_LABEL_TOP_POSITION + : OUTLINED_ACTIVE_LABEL_TOP_POSITION; + + const top = isFloating ? activeTop : INACTIVE_LABEL_TOP_POSITION; + const fontSize = isFloating + ? ACTIVE_LABEL_FONT_SIZE + : INACTIVE_LABEL_FONT_SIZE; + + const animatedContainerStyle: StyleProp>> = + { + opacity: isFloating ? 1 : 0, + transitionProperty: 'opacity', + transitionDuration: ANIMATION_DURATION_MS, + }; + + if (variant === 'filled') { + return { + animatedLabelWrapperStyle: { + top, + transitionProperty: 'top', + transitionDuration: ANIMATION_DURATION_MS, + }, + animatedLabelTextStyle: { + fontSize, + transitionProperty: 'fontSize', + transitionDuration: ANIMATION_DURATION_MS, + }, + animatedActiveOutlineStyle: { + transform: [{ scaleX: isFocused ? 1 : 0 }], + transitionProperty: 'transform', + transitionDuration: ANIMATION_DURATION_MS, + }, + animatedContainerStyle, + }; + } + + const translateXEnd = hasAccessory + ? OUTLINED_LABEL_TRANSLATE_X_WITH_ACCESSORY + : OUTLINED_LABEL_TRANSLATE_X_WITHOUT_ACCESSORY; + + return { + animatedLabelWrapperStyle: { + top, + transform: [{ translateX: isFloating ? translateXEnd : 0 }], + transitionProperty: ['top', 'transform'], + transitionDuration: ANIMATION_DURATION_MS, + }, + animatedLabelTextStyle: { + fontSize, + transitionProperty: 'fontSize', + transitionDuration: ANIMATION_DURATION_MS, + }, + animatedContainerStyle, + }; +}; + +export const getFilledTextFieldData = ( + api: TextFieldSharedApi, + props: TextFieldProps +): FilledTextFieldHookData => { + const { + style: inputStyleOverride, + fieldStyle: fieldStyleOverride, + containerStyle: containerStyleOverride, + outlineStyle: outlineStyleOverride, + ...textInputProps + } = props; + + const { + input, + theme, + hasSuffix, + disabled, + hasAccessory, + hasError, + animatedLabelWrapperStyle, + animatedActiveOutlineStyle, + } = api; + + /** + * Theme tokens + */ + const { + colors: { onSurface }, + } = theme; + + const outlineColor = getOutlineColor({ + theme, + hasError, + isFocused: false, + disabled, + }); + + const activeOutlineColor = getOutlineColor({ + theme, + hasError, + isFocused: true, + disabled, + }); + + const fieldBackgroundColor = getFieldBackgroundColor({ theme, disabled }); + + /** + * Shared styles + */ + + const shared = getSharedTextFieldStyleData(api, props); + + /** + * Variant-specific styles + */ + + const animatedLabelWrapperStyles: StyleProp< + AnimatedStyle> + > = [ + filledStyles.labelWrapper, + { + left: hasAccessory + ? FILLED_LABEL_START_OFFSET_WITH_ACCESSORY + : LABEL_START_OFFSET_WITHOUT_ACCESSORY, + }, + animatedLabelWrapperStyle, + ]; + + const containerStyles: StyleProp = [ + filledStyles.container, + disabled && styles.disabled, + containerStyleOverride, + ]; + + const fieldStyles: StyleProp = [ + styles.field, + { + backgroundColor: fieldBackgroundColor, + borderTopStartRadius: TEXT_FIELD_BORDER_RADIUS, + borderTopEndRadius: TEXT_FIELD_BORDER_RADIUS, + overflow: 'hidden', + }, + fieldStyleOverride, + ]; + + /* Disabled tint (DISABLED_CONTAINER_OPACITY) is rendered as a childless overlay so its + alpha can be applied via the `opacity` style without leaking onto the label + and input. The View accepts `PlatformColor` directly. */ + const disabledBackgroundStyles: StyleProp | undefined = disabled + ? [ + filledStyles.disabledBackground, + { + backgroundColor: onSurface, + }, + ] + : undefined; + + const outlineStyles: StyleProp = [ + filledStyles.outline, + { + height: INACTIVE_INDICATOR_SIZE, + backgroundColor: outlineColor, + }, + disabled && styles.disabled, + outlineStyleOverride, + ]; + + const animatedActiveOutlineStyles: StyleProp< + AnimatedStyle> + > = [ + filledStyles.outline, + { + height: ACTIVE_INDICATOR_SIZE, + backgroundColor: activeOutlineColor, + }, + disabled && styles.disabled, + outlineStyleOverride, + animatedActiveOutlineStyle, + ]; + + const inputStyles: StyleProp = [ + styles.input, + { + flex: 1, + color: onSurface, + fontSize: INPUT_FONT_SIZE, + textAlign: hasSuffix === shared.isRTL ? 'left' : 'right', + writingDirection: shared.isRTL ? 'rtl' : 'ltr', + }, + textInputProps.multiline && { + height: 'auto', + paddingTop: FILLED_MULTILINE_PADDING_TOP, + }, + isWeb && { + outlineStyle: 'none' as TextStyle['outlineStyle'], + }, + disabled && styles.disabled, + inputStyleOverride, + ]; + + return { + input, + disabled, + hasError, + hasSuffix, + animatedLabelWrapperStyles, + containerStyles, + fieldStyles, + disabledBackgroundStyles, + outlineStyles, + animatedActiveOutlineStyles, + inputStyles, + ...shared, + }; +}; + +export const getOutlinedTextFieldData = ( + api: TextFieldSharedApi, + props: TextFieldProps +): OutlinedTextFieldHookData => { + const { + style: inputStyleOverride, + fieldStyle: fieldStyleOverride, + containerStyle: containerStyleOverride, + outlineStyle: outlineStyleOverride, + ...textInputProps + } = props; + + const { + input, + theme, + isFocused, + disabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + } = api; + + /** + * Theme tokens + */ + + const { + colors: { background: labelBackgroundColor, onSurface }, + } = theme; + + const outlineColor = getOutlineColor({ + theme, + disabled, + isFocused, + hasError, + }); + + /** + * Shared styles + */ + + const shared = getSharedTextFieldStyleData(api, props); + + /** + * Variant-specific styles + */ + + const containerStyles: StyleProp = [ + outlinedStyles.container, + disabled && styles.disabled, + containerStyleOverride, + ]; + + const fieldStyles: StyleProp = [ + styles.field, + { + borderRadius: TEXT_FIELD_BORDER_RADIUS, + }, + textInputProps.multiline && { alignItems: 'flex-start' }, + fieldStyleOverride, + ]; + + /* The outline is a childless absolutely-positioned View, so applying + `opacity` here is safe and lets us pass `outlineColor` through unchanged + (including PlatformColor values on Android). */ + const outlineStyles: StyleProp = [ + outlinedStyles.outline, + { + borderWidth: isFocused ? 2 : 1, + borderColor: outlineColor, + }, + disabled && { opacity: OUTLINED_DISABLED_OUTLINE_OPACITY }, + fieldStyleOverride, + outlineStyleOverride, + ]; + + const animatedLabelWrapperStyles: StyleProp< + AnimatedStyle> + > = [ + outlinedStyles.labelWrapper, + { + left: hasAccessory + ? OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY + : LABEL_START_OFFSET_WITHOUT_ACCESSORY, + backgroundColor: labelBackgroundColor, + }, + animatedLabelWrapperStyle, + ]; + + const inputStyles: StyleProp = [ + styles.input, + { + flex: 1, + color: onSurface, + fontSize: INPUT_FONT_SIZE, + textAlign: hasSuffix === shared.isRTL ? 'left' : 'right', + writingDirection: shared.isRTL ? 'rtl' : 'ltr', + }, + textInputProps.multiline && { + height: 'auto', + textAlignVertical: 'top', + paddingTop: OUTLINED_MULTILINE_PADDING_TOP, + }, + isWeb && { + outlineStyle: 'none' as TextStyle['outlineStyle'], + }, + disabled && styles.disabled, + inputStyleOverride, + ]; + + return { + input, + disabled, + hasError, + hasSuffix, + animatedLabelWrapperStyles, + containerStyles, + fieldStyles, + disabledBackgroundStyles: undefined, + outlineStyles, + inputStyles, + ...shared, + }; +}; diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx new file mode 100644 index 0000000000..3b07c24baf --- /dev/null +++ b/src/components/__tests__/TextField.test.tsx @@ -0,0 +1,1022 @@ +import * as React from 'react'; +import { I18nManager, StyleSheet, TextInput, View } from 'react-native'; + +import { fireEvent, render } from '../../test-utils'; +import { tokens } from '../../theme/tokens'; +import TextField from '../TextField'; +import type { TextFieldAccessoryProps } from '../TextField/TextField'; + +const { stateOpacity } = tokens.md.ref; + +const defaultI18nIsRTL = I18nManager.isRTL; + +const getConstantsOriginal = I18nManager.getConstants.bind(I18nManager); + +beforeAll(() => { + jest.spyOn(I18nManager, 'getConstants').mockImplementation(() => ({ + ...getConstantsOriginal(), + isRTL: I18nManager.isRTL, + })); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +afterEach(() => { + I18nManager.isRTL = defaultI18nIsRTL; +}); + +function firstIndexOfTestIdInTree(tree: unknown, testID: string): number { + const serialized = JSON.stringify(tree); + const match = new RegExp(`"testID":\\s*"${testID}"`).exec(serialized); + return match ? match.index : -1; +} + +it('renders filled TextField with label and value', () => { + const tree = render( + {}} /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders outlined TextField with label and value', () => { + const tree = render( + {}} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders filled TextField with TextField.Icon accessories', () => { + const tree = render( + {}} + StartAccessory={(props: TextFieldAccessoryProps) => ( + + )} + EndAccessory={(props: TextFieldAccessoryProps) => ( + + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders outlined TextField with TextField.Icon accessories', () => { + const tree = render( + {}} + StartAccessory={(props: TextFieldAccessoryProps) => ( + + )} + EndAccessory={(props: TextFieldAccessoryProps) => ( + + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders filled TextField with TextField.Icon accessories when error is true', () => { + const tree = render( + {}} + error + StartAccessory={(props: TextFieldAccessoryProps) => ( + + )} + EndAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('renders outlined TextField with TextField.Icon accessories when error is true', () => { + const tree = render( + {}} + error + StartAccessory={(props: TextFieldAccessoryProps) => ( + + )} + EndAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + /> + ).toJSON(); + + expect(tree).toMatchSnapshot(); +}); + +it('fires onPress on TextField.Icon end accessory', () => { + const onClear = jest.fn(); + const { getAllByTestId } = render( + {}} + StartAccessory={(props: TextFieldAccessoryProps) => ( + + )} + EndAccessory={(props: TextFieldAccessoryProps) => ( + + )} + /> + ); + + fireEvent.press(getAllByTestId('icon-button')[1]); + + expect(onClear).toHaveBeenCalledTimes(1); +}); + +it('disables TextField.Icon when the field is not editable', () => { + const { getAllByTestId } = render( + {}} + editable={false} + StartAccessory={(props: TextFieldAccessoryProps) => ( + + )} + EndAccessory={(props: TextFieldAccessoryProps) => ( + + )} + /> + ); + + const buttons = getAllByTestId('icon-button'); + expect(buttons[0].props.accessibilityState?.disabled).toBe(true); + expect(buttons[1].props.accessibilityState?.disabled).toBe(true); +}); + +it('renders supporting text below the field', () => { + const { getByText } = render( + {}} + supportingText="Use a valid address" + /> + ); + + expect(getByText('Use a valid address')).toBeTruthy(); +}); + +it('sets aria-invalid on the input when error is true', () => { + const { getByTestId } = render( + {}} + error + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props['aria-invalid']).toBe(true); +}); + +it('uses assertive aria-live on supporting text when error is true', () => { + const { getByText } = render( + {}} + supportingText="Invalid" + error + /> + ); + + expect(getByText('Invalid').props['aria-live']).toBe('assertive'); +}); + +it('uses polite aria-live on supporting text when there is no error', () => { + const { getByText } = render( + {}} + supportingText="Optional" + /> + ); + + expect(getByText('Optional').props['aria-live']).toBe('polite'); +}); + +it('marks the input as aria-disabled when editable is false', () => { + const { getByTestId } = render( + {}} + editable={false} + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props['aria-disabled']).toBe(true); +}); + +it('marks the input as aria-invalid and aria-disabled when error and editable is false', () => { + const { getByTestId } = render( + {}} + error + editable={false} + testID="tf-input" + /> + ); + + const input = getByTestId('tf-input'); + expect(input.props['aria-invalid']).toBe(true); + expect(input.props['aria-disabled']).toBe(true); +}); + +it('applies disabled opacity to the TextInput when editable is false (filled)', () => { + const { getByTestId } = render( + {}} + editable={false} + testID="tf-input-dis" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-dis').props.style) + ).toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('applies disabled opacity to the TextInput when editable is false (outlined)', () => { + const { getByTestId } = render( + {}} + editable={false} + testID="tf-input-dis-out" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-dis-out').props.style) + ).toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('forwards TextInput props such as testID', () => { + const { getByTestId } = render( + {}} + testID="email-input" + /> + ); + + expect(getByTestId('email-input')).toBeTruthy(); +}); + +it('does not pass TextField-only props through to TextInput', () => { + const { getByTestId } = render( + {}} + error + testID="tf-native" + /> + ); + + const input = getByTestId('tf-native'); + expect(input.props.variant).toBeUndefined(); + expect(input.props.theme).toBeUndefined(); + expect(input.props.StartAccessory).toBeUndefined(); + expect(input.props.EndAccessory).toBeUndefined(); + expect(input.props.pressableStyle).toBeUndefined(); + expect(input.props.fieldStyle).toBeUndefined(); + expect(input.props.containerStyle).toBeUndefined(); + expect(input.props.outlineStyle).toBeUndefined(); + expect(input.props.supportingText).toBeUndefined(); + expect(input.props.supportingTextProps).toBeUndefined(); + expect(input.props.prefix).toBeUndefined(); + expect(input.props.prefixProps).toBeUndefined(); + expect(input.props.suffix).toBeUndefined(); + expect(input.props.suffixProps).toBeUndefined(); + expect(input.props.counter).toBeUndefined(); + expect(input.props.counterProps).toBeUndefined(); + expect(input.props.error).toBeUndefined(); +}); + +it('shows a character counter when counter is true and maxLength is set (filled)', () => { + const { getByText, queryByText } = render( + {}} + counter + maxLength={100} + /> + ); + + expect(getByText('5/100')).toBeTruthy(); + expect(queryByText('0/100')).toBeNull(); +}); + +it('shows a character counter when counter is true and maxLength is set (outlined)', () => { + const { getByText } = render( + {}} + counter + maxLength={50} + /> + ); + + expect(getByText('0/50')).toBeTruthy(); +}); + +it('updates the character counter when the value changes', () => { + const { getByText, rerender } = render( + {}} + counter + maxLength={10} + /> + ); + + expect(getByText('1/10')).toBeTruthy(); + + rerender( + {}} + counter + maxLength={10} + /> + ); + + expect(getByText('4/10')).toBeTruthy(); +}); + +it('does not show a character counter when counter is false', () => { + const { queryByText } = render( + {}} + maxLength={100} + /> + ); + + expect(queryByText('5/100')).toBeNull(); +}); + +it('does not show a character counter when maxLength is missing', () => { + const { queryByText } = render( + {}} counter /> + ); + + expect(queryByText('5/100')).toBeNull(); + expect(queryByText(/\//)).toBeNull(); +}); + +it('invokes onFocus and onBlur on the TextInput', () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + const { getByTestId } = render( + {}} + onFocus={onFocus} + onBlur={onBlur} + testID="tf-input" + /> + ); + + const input = getByTestId('tf-input'); + fireEvent(input, 'focus'); + fireEvent(input, 'blur'); + + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenCalledTimes(1); +}); + +it('focuses the TextInput when the outer Pressable is pressed', () => { + const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); + + const { UNSAFE_getByProps, getByTestId } = render( + {}} + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input')).toBeTruthy(); + + /* Pressable is not exposed as a distinct type in the test renderer; match its props. */ + const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); + fireEvent.press(pressable); + + expect(focusSpy).toHaveBeenCalled(); + focusSpy.mockRestore(); +}); + +it('does not focus the TextInput when disabled and the Pressable is pressed', () => { + const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); + + const { UNSAFE_getByProps } = render( + {}} + editable={false} + /> + ); + + const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); + fireEvent.press(pressable); + + expect(focusSpy).not.toHaveBeenCalled(); + focusSpy.mockRestore(); +}); + +it('exposes the TextInput instance via ref prop', () => { + const ref = React.createRef(); + + render( + {}} + testID="tf-input" + /> + ); + + expect(ref.current).toBeTruthy(); + expect(typeof ref.current?.focus).toBe('function'); +}); + +it('passes error, disabled, and multiline to accessories', () => { + const startAccessoryProps: TextFieldAccessoryProps[] = []; + const endAccessoryProps: TextFieldAccessoryProps[] = []; + + function StartAccessory(props: TextFieldAccessoryProps) { + startAccessoryProps.push(props); + return ; + } + + function EndAccessory(props: TextFieldAccessoryProps) { + endAccessoryProps.push(props); + return ; + } + + const { getByTestId } = render( + {}} + multiline + error + editable={false} + StartAccessory={StartAccessory} + EndAccessory={EndAccessory} + /> + ); + + expect(getByTestId('start-accessory')).toBeTruthy(); + expect(getByTestId('end-accessory')).toBeTruthy(); + expect(startAccessoryProps[0]).toMatchObject({ + error: true, + disabled: true, + multiline: true, + }); + expect(endAccessoryProps[0]).toMatchObject({ + error: true, + disabled: true, + multiline: true, + }); +}); + +it('passes error to accessories when the field is disabled', () => { + const startAccessoryProps: TextFieldAccessoryProps[] = []; + + function StartAccessory(props: TextFieldAccessoryProps) { + startAccessoryProps.push(props); + return ; + } + + const { getByTestId } = render( + {}} + error + editable={false} + StartAccessory={StartAccessory} + /> + ); + + expect(getByTestId('start-acc-error-disabled')).toBeTruthy(); + expect(startAccessoryProps[0].error).toBe(true); + expect(startAccessoryProps[0].disabled).toBe(true); +}); + +it('applies supportingTextProps to the supporting Text', () => { + const { getByTestId } = render( + {}} + supportingText="Hint" + supportingTextProps={{ testID: 'supporting-text' }} + /> + ); + + expect(getByTestId('supporting-text').props.children).toBe('Hint'); +}); + +it('applies counterProps to the counter Text', () => { + const { getByTestId } = render( + {}} + counter + maxLength={80} + counterProps={{ testID: 'counter-text' }} + /> + ); + + expect(getByTestId('counter-text').props.children).toBe('2/80'); +}); + +it('does not apply supportingTextProps style to the counter Text', () => { + const { getByTestId } = render( + {}} + counter + maxLength={10} + supportingTextProps={{ style: { fontSize: 9 } }} + counterProps={{ testID: 'counter-text' }} + /> + ); + + expect( + StyleSheet.flatten(getByTestId('counter-text').props.style).fontSize + ).not.toBe(9); +}); + +it('applies RTL text alignment and writing direction to the TextInput (filled)', () => { + I18nManager.isRTL = true; + + const { getByTestId } = render( + {}} + testID="tf-input-rtl" + /> + ); + + expect(StyleSheet.flatten(getByTestId('tf-input-rtl').props.style)).toEqual( + expect.objectContaining({ + textAlign: 'right', + writingDirection: 'rtl', + }) + ); +}); + +it('applies RTL text alignment and writing direction to the TextInput (outlined)', () => { + I18nManager.isRTL = true; + + const { getByTestId } = render( + {}} + testID="tf-input-rtl-outlined" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-rtl-outlined').props.style) + ).toEqual( + expect.objectContaining({ + textAlign: 'right', + writingDirection: 'rtl', + }) + ); +}); + +it('applies RTL writing direction to supporting text', () => { + I18nManager.isRTL = true; + + const { getByTestId } = render( + {}} + supportingText="Hint" + supportingTextProps={{ testID: 'supporting-text-rtl' }} + /> + ); + + expect( + StyleSheet.flatten(getByTestId('supporting-text-rtl').props.style) + ).toEqual( + expect.objectContaining({ + writingDirection: 'rtl', + }) + ); +}); + +it('places EndAccessory before StartAccessory in the tree when RTL', () => { + I18nManager.isRTL = true; + + function StartAccessory() { + return ; + } + + function EndAccessory() { + return ; + } + + const { toJSON } = render( + {}} + StartAccessory={StartAccessory} + EndAccessory={EndAccessory} + testID="tf-input-rtl-order" + /> + ); + + const tree = toJSON(); + expect(firstIndexOfTestIdInTree(tree, 'rtl-acc-from-end-prop')).toBeLessThan( + firstIndexOfTestIdInTree(tree, 'rtl-acc-from-start-prop') + ); +}); + +it('places StartAccessory before EndAccessory in the tree when LTR', () => { + I18nManager.isRTL = false; + + function StartAccessory() { + return ; + } + + function EndAccessory() { + return ; + } + + const { toJSON } = render( + {}} + StartAccessory={StartAccessory} + EndAccessory={EndAccessory} + testID="tf-input-ltr-order" + /> + ); + + const tree = toJSON(); + expect( + firstIndexOfTestIdInTree(tree, 'ltr-acc-from-start-prop') + ).toBeLessThan(firstIndexOfTestIdInTree(tree, 'ltr-acc-from-end-prop')); +}); + +it('does not expose the placeholder string when the TextField is not focused', () => { + const { getByTestId } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); + + /* Sentinel space avoids iOS multiline UITextView not updating placeholder from nil (react-native#31573). */ + expect(getByTestId('tf-input').props.placeholder).toBe(' '); +}); + +it('shows placeholder when the TextField is focused', () => { + const { getByTestId } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); + + fireEvent(getByTestId('tf-input'), 'focus'); + + expect(getByTestId('tf-input').props.placeholder).toBe( + 'e.g. user@example.com' + ); +}); + +it('shows placeholder on multiline TextField when focused', () => { + const { getByTestId } = render( + {}} + placeholder="Add a note…" + multiline + testID="tf-multiline" + /> + ); + + expect(getByTestId('tf-multiline').props.placeholder).toBe(' '); + + fireEvent(getByTestId('tf-multiline'), 'focus'); + + expect(getByTestId('tf-multiline').props.placeholder).toBe('Add a note…'); +}); + +it('does not expose the placeholder string again after the TextField loses focus', () => { + const { getByTestId } = render( + {}} + placeholder="e.g. user@example.com" + testID="tf-input" + /> + ); + + fireEvent(getByTestId('tf-input'), 'focus'); + fireEvent(getByTestId('tf-input'), 'blur'); + + expect(getByTestId('tf-input').props.placeholder).toBe(' '); +}); + +it('maps a lone StartAccessory to leading in LTR and trailing in RTL (tree order)', () => { + function LoneStartAccessory() { + return ; + } + + I18nManager.isRTL = false; + + const { toJSON: toJsonLtr } = render( + {}} + StartAccessory={LoneStartAccessory} + testID="tf-lone-ltr" + /> + ); + + I18nManager.isRTL = true; + + const { toJSON: toJsonRtl } = render( + {}} + StartAccessory={LoneStartAccessory} + testID="tf-lone-rtl" + /> + ); + + const ltrTree = toJsonLtr(); + expect(firstIndexOfTestIdInTree(ltrTree, 'lone-start-acc')).toBeLessThan( + firstIndexOfTestIdInTree(ltrTree, 'tf-lone-ltr') + ); + + const rtlTree = toJsonRtl(); + expect(firstIndexOfTestIdInTree(rtlTree, 'tf-lone-rtl')).toBeLessThan( + firstIndexOfTestIdInTree(rtlTree, 'lone-start-acc') + ); +}); + +it('shows prefix and suffix when the field is floating and hides them after value is cleared while blurred', () => { + const { getByTestId, queryByTestId, rerender } = render( + {}} + prefix="$" + suffix="/100" + testID="tf-ps" + prefixProps={{ testID: 'tf-prefix' }} + suffixProps={{ testID: 'tf-suffix' }} + /> + ); + + expect(getByTestId('tf-prefix')).toBeTruthy(); + expect(getByTestId('tf-suffix')).toBeTruthy(); + + rerender( + {}} + prefix="$" + suffix="/100" + testID="tf-ps" + prefixProps={{ testID: 'tf-prefix' }} + suffixProps={{ testID: 'tf-suffix' }} + /> + ); + + expect(queryByTestId('tf-prefix')).toBeNull(); + expect(queryByTestId('tf-suffix')).toBeNull(); + expect(getByTestId('tf-ps')).toBeTruthy(); +}); + +it('renders prefix and suffix while focused even when value is empty', () => { + const { getByTestId, queryByTestId } = render( + {}} + prefix="$" + suffix=" kg" + testID="tf-ps-focus" + prefixProps={{ testID: 'tf-prefix-focus' }} + suffixProps={{ testID: 'tf-suffix-focus' }} + /> + ); + + expect(queryByTestId('tf-prefix-focus')).toBeNull(); + expect(queryByTestId('tf-suffix-focus')).toBeNull(); + + fireEvent(getByTestId('tf-ps-focus'), 'focus'); + + expect(getByTestId('tf-prefix-focus')).toBeTruthy(); + expect(getByTestId('tf-suffix-focus')).toBeTruthy(); +}); + +it('places prefix Text before the TextInput and suffix Text after it', () => { + const { toJSON } = render( + {}} + prefix="$" + suffix="/100" + testID="tf-order" + prefixProps={{ testID: 'order-prefix' }} + suffixProps={{ testID: 'order-suffix' }} + /> + ); + + const tree = toJSON(); + expect(firstIndexOfTestIdInTree(tree, 'order-prefix')).toBeLessThan( + firstIndexOfTestIdInTree(tree, 'tf-order') + ); + expect(firstIndexOfTestIdInTree(tree, 'tf-order')).toBeLessThan( + firstIndexOfTestIdInTree(tree, 'order-suffix') + ); +}); + +it('aligns input text toward the suffix when suffix is active (LTR)', () => { + const { getByTestId } = render( + {}} + suffix="/100" + testID="tf-suffix-align-ltr" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-suffix-align-ltr').props.style) + ).toEqual( + expect.objectContaining({ + textAlign: 'right', + writingDirection: 'ltr', + }) + ); +}); + +it('aligns input text toward the suffix when suffix is active (RTL)', () => { + I18nManager.isRTL = true; + + const { getByTestId } = render( + {}} + suffix="/100" + testID="tf-suffix-align-rtl" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-suffix-align-rtl').props.style) + ).toEqual( + expect.objectContaining({ + textAlign: 'left', + writingDirection: 'rtl', + }) + ); +}); + +it('uses default horizontal alignment when suffix prop exists but suffix is not shown yet (LTR)', () => { + const { getByTestId } = render( + {}} + suffix="/100" + testID="tf-no-suffix-yet" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-no-suffix-yet').props.style) + ).toEqual( + expect.objectContaining({ + textAlign: 'left', + writingDirection: 'ltr', + }) + ); +}); + +it('does not apply the TextInput style prop to prefix or suffix Text', () => { + const { getByTestId } = render( + {}} + prefix="$" + suffix="]" + style={{ fontSize: 40, letterSpacing: 9 }} + testID="tf-input-style" + prefixProps={{ testID: 'pfx-no-input-style' }} + suffixProps={{ testID: 'sfx-no-input-style' }} + /> + ); + + const inputFlat = StyleSheet.flatten( + getByTestId('tf-input-style').props.style + ); + expect(inputFlat).toEqual( + expect.objectContaining({ fontSize: 40, letterSpacing: 9 }) + ); + + const prefixFlat = StyleSheet.flatten( + getByTestId('pfx-no-input-style').props.style + ); + const suffixFlat = StyleSheet.flatten( + getByTestId('sfx-no-input-style').props.style + ); + + expect(prefixFlat.fontSize).not.toBe(40); + expect(prefixFlat.letterSpacing).toBeUndefined(); + expect(suffixFlat.fontSize).not.toBe(40); + expect(suffixFlat.letterSpacing).toBeUndefined(); +}); diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index 0f997739ba..dcd43b927f 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -229,7 +229,7 @@ exports[`DataTable.Header renders data table header 1`] = ` "lineHeight": 24, }, { - "maxHeight": 48, + "maxHeight": 24, }, {}, { @@ -310,7 +310,7 @@ exports[`DataTable.Header renders data table header 1`] = ` "lineHeight": 24, }, { - "maxHeight": 48, + "maxHeight": 24, }, {}, { @@ -2619,7 +2619,7 @@ exports[`DataTable.Title renders data table title with press handler 1`] = ` "lineHeight": 24, }, { - "maxHeight": 48, + "maxHeight": 24, }, {}, { @@ -2747,7 +2747,7 @@ exports[`DataTable.Title renders data table title with sort icon 1`] = ` "lineHeight": 24, }, { - "maxHeight": 48, + "maxHeight": 24, }, {}, { @@ -2833,7 +2833,7 @@ exports[`DataTable.Title renders right aligned data table title 1`] = ` "lineHeight": 24, }, { - "maxHeight": 48, + "maxHeight": 24, }, {}, { diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap new file mode 100644 index 0000000000..3f4978cdad --- /dev/null +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -0,0 +1,3105 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders filled TextField with TextField.Icon accessories 1`] = ` + + + + + + + Search + + + + + + + + + magnify + + + + + + + + + + + + + + + + close + + + + + + + + + +`; + +exports[`renders filled TextField with TextField.Icon accessories when error is true 1`] = ` + + + + + + + Search + + + + + + + + + magnify + + + + + + + + + + + + + + + + close + + + + + + + + + +`; + +exports[`renders filled TextField with label and value 1`] = ` + + + + + + + Email + + + + + + + + +`; + +exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` + + + + + + Search + + + + + + + + + magnify + + + + + + + + + + + + + + + + close + + + + + + + + + +`; + +exports[`renders outlined TextField with TextField.Icon accessories when error is true 1`] = ` + + + + + + Search + + + + + + + + + magnify + + + + + + + + + + + + + + + + close + + + + + + + + + +`; + +exports[`renders outlined TextField with label and value 1`] = ` + + + + + + Password + + + + + + + + +`; diff --git a/src/index.tsx b/src/index.tsx index 1bb4cbdf9e..1db43a3a84 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -52,6 +52,7 @@ export { default as Switch } from './components/Switch/Switch'; export { default as Appbar } from './components/Appbar'; export { default as TouchableRipple } from './components/TouchableRipple/TouchableRipple'; export { default as TextInput } from './components/TextInput/TextInput'; +export { default as TextField } from './components/TextField'; export { default as ToggleButton } from './components/ToggleButton'; export { default as SegmentedButtons } from './components/SegmentedButtons/SegmentedButtons'; export { default as Tooltip } from './components/Tooltip/Tooltip'; @@ -131,6 +132,12 @@ export type { Props as SwitchProps } from './components/Switch/Switch'; export type { Props as TextInputProps } from './components/TextInput/TextInput'; export type { Props as TextInputAffixProps } from './components/TextInput/Adornment/TextInputAffix'; export type { Props as TextInputIconProps } from './components/TextInput/Adornment/TextInputIcon'; +export type { + TextFieldProps, + TextFieldAccessoryProps, + TextFieldVariant, +} from './components/TextField/TextField'; +export type { TextFieldIconProps } from './components/TextField/TextFieldIcon'; export type { Props as ToggleButtonProps } from './components/ToggleButton/ToggleButton'; export type { Props as ToggleButtonGroupProps } from './components/ToggleButton/ToggleButtonGroup'; export type { Props as ToggleButtonRowProps } from './components/ToggleButton/ToggleButtonRow'; From 72ba9d589930cf93b3ac1f64929c7a0edc02af12 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 11:02:13 +0200 Subject: [PATCH 02/20] chore: interface to type --- src/components/TextField/TextField.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index b610eb5288..062ed0249c 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -23,12 +23,12 @@ import type { InternalTheme, ThemeProp } from '../../types'; export type TextFieldVariant = 'filled' | 'outlined'; -export interface TextFieldAccessoryProps { +export type TextFieldAccessoryProps = { style: StyleProp; multiline: boolean; disabled: boolean; error: boolean; -} +}; export type TextFieldSharedApi = { input: React.RefObject; @@ -110,7 +110,7 @@ export type TextFieldHookReturn = SharedTextFieldStyleData & { focusInput: () => void; }; -export interface TextFieldProps extends TextInputProps { +export type TextFieldProps = TextInputProps & { /** * Ref forwarded to the underlying TextInput. */ @@ -199,7 +199,7 @@ export interface TextFieldProps extends TextInputProps { * Can be a custom component or `TextField.Icon`. */ EndAccessory?: ComponentType; -} +}; /** * A text field lets users enter and edit text. It shows an optional floating label, From 5446df64a109d3ae764f759a9d143a5c7063ad05 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 11:25:30 +0200 Subject: [PATCH 03/20] feat: remove override props --- src/components/TextField/TextField.tsx | 71 ++------------ src/components/TextField/utils.ts | 43 +-------- src/components/__tests__/TextField.test.tsx | 93 +++++++------------ .../__snapshots__/TextField.test.tsx.snap | 45 --------- 4 files changed, 47 insertions(+), 205 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 062ed0249c..f129abca9c 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -8,7 +8,6 @@ import { Text, TextInput, TextInputProps, - TextProps, TextStyle, View, ViewStyle, @@ -129,65 +128,22 @@ export type TextFieldProps = TextInputProps & { */ label?: string; /** - * Pass any additional props directly to the label Text component. - */ - labelProps?: TextProps; - /** - * Supporting text to display below the input (Material Design 3). When - * `error` is `true`, this text is styled as an error message. + * Supporting text to display below the input (Material Design 3). */ supportingText?: string; - /** - * Pass any additional props directly to the supporting text `Text` component. - */ - supportingTextProps?: TextProps; /** * When `true`, displays a character counter below the input on the trailing * side, showing `currentLength/maxLength`. Requires `maxLength` to be set. */ counter?: boolean; - /** - * Pass any additional props directly to the counter `Text` component. - */ - counterProps?: TextProps; /** * A short text string displayed at the start of the input (e.g. `"$"`). */ prefix?: string; - /** - * Pass any additional props directly to the prefix `Text` component. - */ - prefixProps?: TextProps; /** * A short text string displayed at the end of the input (e.g. `"/100"`). */ suffix?: string; - /** - * Pass any additional props directly to the suffix `Text` component. - */ - suffixProps?: TextProps; - /** - * Style overrides for the pressable root element. - */ - pressableStyle?: StyleProp; - /** - * Style overrides for the field container (the bordered row that includes - * StartAccessory, input content, and EndAccessory). - */ - fieldStyle?: StyleProp; - /** - * Style overrides for the input content wrapper (the area containing - * the label and TextInput, excluding accessories). - */ - containerStyle?: StyleProp; - /** - * Style overrides for the indicator layer (the purely visual border or line - * that shows state, not the interactive input). - * - `filled` — applied to both the always-visible bottom edge and the - * animated bar that expands on focus. - * - `outlined` — applied to the rounded border around the field for both states. - */ - outlineStyle?: StyleProp; theme?: ThemeProp; /** * An optional component to render on the start side of the input (leading in LTR). @@ -253,22 +209,13 @@ function TextField(props: TextFieldProps) { error, label, supportingText, - supportingTextProps, - labelProps, variant, - pressableStyle: pressableStyleOverride, - fieldStyle, - containerStyle, - outlineStyle, theme, StartAccessory, EndAccessory, prefix, - prefixProps, suffix, - suffixProps, counter, - counterProps, ...textInputProps } = props; @@ -307,12 +254,7 @@ function TextField(props: TextFieldProps) { } = useTextField(props); return ( - + {/* Disabled tint overlay — filled variant only. A childless absolutely-positioned View whose translucent fill is applied via the @@ -342,7 +284,7 @@ function TextField(props: TextFieldProps) { pointerEvents="none" style={animatedLabelWrapperStyles} > - + {label} @@ -359,7 +301,7 @@ function TextField(props: TextFieldProps) { {hasPrefix && ( - + {prefix} )} @@ -380,7 +322,7 @@ function TextField(props: TextFieldProps) { /> {hasSuffix && ( - + {suffix} )} @@ -402,7 +344,6 @@ function TextField(props: TextFieldProps) { {!!supportingText && ( {supportingText} @@ -410,7 +351,7 @@ function TextField(props: TextFieldProps) { )} {hasCounter && ( - + {counterText} )} diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 846d992589..f3fe43a4d5 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -180,19 +180,11 @@ export const getOutlineColor = ({ * which is variant-specific (filled adds `MULTILINE_PADDING_TOP`). */ export const getSharedTextFieldStyleData = ( - api: TextFieldSharedApi, - props: TextFieldProps + api: TextFieldSharedApi ): SharedTextFieldStyleData => { const { isRTL } = I18nManager.getConstants(); const { theme, disabled, hasError, isFocused, animatedLabelTextStyle } = api; - const { - labelProps, - supportingTextProps, - counterProps, - prefixProps, - suffixProps, - } = props; const labelColor = getLabelColor({ theme, hasError, isFocused, disabled }); @@ -212,7 +204,6 @@ export const getSharedTextFieldStyleData = ( { color: labelColor }, animatedLabelTextStyle, disabled && styles.disabled, - labelProps?.style, ]; const supportingTextStyles: StyleProp = [ @@ -222,7 +213,6 @@ export const getSharedTextFieldStyleData = ( writingDirection: isRTL ? 'rtl' : 'ltr', }, disabled && styles.disabled, - supportingTextProps?.style, ]; const counterStyles: StyleProp = [ @@ -232,7 +222,6 @@ export const getSharedTextFieldStyleData = ( writingDirection: isRTL ? 'rtl' : 'ltr', }, disabled && styles.disabled, - counterProps?.style, ]; const prefixStyles: StyleProp = [ @@ -243,7 +232,6 @@ export const getSharedTextFieldStyleData = ( paddingEnd: PREFIX_END_PADDING, }, disabled && styles.disabled, - prefixProps?.style, ]; const suffixStyles: StyleProp = [ @@ -254,7 +242,6 @@ export const getSharedTextFieldStyleData = ( paddingStart: SUFFIX_START_PADDING, }, disabled && styles.disabled, - suffixProps?.style, ]; const leadingAccessoryStyles: StyleProp = [ @@ -357,13 +344,7 @@ export const getFilledTextFieldData = ( api: TextFieldSharedApi, props: TextFieldProps ): FilledTextFieldHookData => { - const { - style: inputStyleOverride, - fieldStyle: fieldStyleOverride, - containerStyle: containerStyleOverride, - outlineStyle: outlineStyleOverride, - ...textInputProps - } = props; + const { style: inputStyleOverride, ...textInputProps } = props; const { input, @@ -403,7 +384,7 @@ export const getFilledTextFieldData = ( * Shared styles */ - const shared = getSharedTextFieldStyleData(api, props); + const shared = getSharedTextFieldStyleData(api); /** * Variant-specific styles @@ -424,7 +405,6 @@ export const getFilledTextFieldData = ( const containerStyles: StyleProp = [ filledStyles.container, disabled && styles.disabled, - containerStyleOverride, ]; const fieldStyles: StyleProp = [ @@ -435,7 +415,6 @@ export const getFilledTextFieldData = ( borderTopEndRadius: TEXT_FIELD_BORDER_RADIUS, overflow: 'hidden', }, - fieldStyleOverride, ]; /* Disabled tint (DISABLED_CONTAINER_OPACITY) is rendered as a childless overlay so its @@ -457,7 +436,6 @@ export const getFilledTextFieldData = ( backgroundColor: outlineColor, }, disabled && styles.disabled, - outlineStyleOverride, ]; const animatedActiveOutlineStyles: StyleProp< @@ -469,7 +447,6 @@ export const getFilledTextFieldData = ( backgroundColor: activeOutlineColor, }, disabled && styles.disabled, - outlineStyleOverride, animatedActiveOutlineStyle, ]; @@ -513,13 +490,7 @@ export const getOutlinedTextFieldData = ( api: TextFieldSharedApi, props: TextFieldProps ): OutlinedTextFieldHookData => { - const { - style: inputStyleOverride, - fieldStyle: fieldStyleOverride, - containerStyle: containerStyleOverride, - outlineStyle: outlineStyleOverride, - ...textInputProps - } = props; + const { style: inputStyleOverride, ...textInputProps } = props; const { input, @@ -551,7 +522,7 @@ export const getOutlinedTextFieldData = ( * Shared styles */ - const shared = getSharedTextFieldStyleData(api, props); + const shared = getSharedTextFieldStyleData(api); /** * Variant-specific styles @@ -560,7 +531,6 @@ export const getOutlinedTextFieldData = ( const containerStyles: StyleProp = [ outlinedStyles.container, disabled && styles.disabled, - containerStyleOverride, ]; const fieldStyles: StyleProp = [ @@ -569,7 +539,6 @@ export const getOutlinedTextFieldData = ( borderRadius: TEXT_FIELD_BORDER_RADIUS, }, textInputProps.multiline && { alignItems: 'flex-start' }, - fieldStyleOverride, ]; /* The outline is a childless absolutely-positioned View, so applying @@ -582,8 +551,6 @@ export const getOutlinedTextFieldData = ( borderColor: outlineColor, }, disabled && { opacity: OUTLINED_DISABLED_OUTLINE_OPACITY }, - fieldStyleOverride, - outlineStyleOverride, ]; const animatedLabelWrapperStyles: StyleProp< diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index 3b07c24baf..f4160fb9bf 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -33,6 +33,11 @@ function firstIndexOfTestIdInTree(tree: unknown, testID: string): number { return match ? match.index : -1; } +/** Locates a Text node whose children are serialized as a one-element JSON string array. */ +function firstIndexOfTextChildArrayInTree(tree: unknown, text: string): number { + return JSON.stringify(tree).indexOf(JSON.stringify([text])); +} + it('renders filled TextField with label and value', () => { const tree = render( {}} /> @@ -308,6 +313,8 @@ it('forwards TextInput props such as testID', () => { expect(getByTestId('email-input')).toBeTruthy(); }); +/* TextField peels these before spreading onto TextInput (see TextField.tsx). + * Custom layout / sub-component styling props are intentionally not supported. */ it('does not pass TextField-only props through to TextInput', () => { const { getByTestId } = render( { expect(input.props.theme).toBeUndefined(); expect(input.props.StartAccessory).toBeUndefined(); expect(input.props.EndAccessory).toBeUndefined(); - expect(input.props.pressableStyle).toBeUndefined(); - expect(input.props.fieldStyle).toBeUndefined(); - expect(input.props.containerStyle).toBeUndefined(); - expect(input.props.outlineStyle).toBeUndefined(); + expect(input.props.label).toBeUndefined(); expect(input.props.supportingText).toBeUndefined(); - expect(input.props.supportingTextProps).toBeUndefined(); expect(input.props.prefix).toBeUndefined(); - expect(input.props.prefixProps).toBeUndefined(); expect(input.props.suffix).toBeUndefined(); - expect(input.props.suffixProps).toBeUndefined(); expect(input.props.counter).toBeUndefined(); - expect(input.props.counterProps).toBeUndefined(); expect(input.props.error).toBeUndefined(); }); @@ -563,51 +563,47 @@ it('passes error to accessories when the field is disabled', () => { expect(startAccessoryProps[0].disabled).toBe(true); }); -it('applies supportingTextProps to the supporting Text', () => { - const { getByTestId } = render( +it('renders supporting text as a Text child', () => { + const { getByText } = render( {}} supportingText="Hint" - supportingTextProps={{ testID: 'supporting-text' }} /> ); - expect(getByTestId('supporting-text').props.children).toBe('Hint'); + expect(getByText('Hint')).toBeTruthy(); }); -it('applies counterProps to the counter Text', () => { - const { getByTestId } = render( +it('renders the counter as a Text child', () => { + const { getByText } = render( {}} counter maxLength={80} - counterProps={{ testID: 'counter-text' }} /> ); - expect(getByTestId('counter-text').props.children).toBe('2/80'); + expect(getByText('2/80')).toBeTruthy(); }); -it('does not apply supportingTextProps style to the counter Text', () => { - const { getByTestId } = render( +it('renders supporting text and counter separately when both are shown', () => { + const { getByText } = render( {}} + supportingText="Help text" counter maxLength={10} - supportingTextProps={{ style: { fontSize: 9 } }} - counterProps={{ testID: 'counter-text' }} /> ); - expect( - StyleSheet.flatten(getByTestId('counter-text').props.style).fontSize - ).not.toBe(9); + expect(getByText('Help text')).toBeTruthy(); + expect(getByText('1/10')).toBeTruthy(); }); it('applies RTL text alignment and writing direction to the TextInput (filled)', () => { @@ -656,19 +652,16 @@ it('applies RTL text alignment and writing direction to the TextInput (outlined) it('applies RTL writing direction to supporting text', () => { I18nManager.isRTL = true; - const { getByTestId } = render( + const { getByText } = render( {}} supportingText="Hint" - supportingTextProps={{ testID: 'supporting-text-rtl' }} /> ); - expect( - StyleSheet.flatten(getByTestId('supporting-text-rtl').props.style) - ).toEqual( + expect(StyleSheet.flatten(getByText('Hint').props.style)).toEqual( expect.objectContaining({ writingDirection: 'rtl', }) @@ -841,7 +834,7 @@ it('maps a lone StartAccessory to leading in LTR and trailing in RTL (tree order }); it('shows prefix and suffix when the field is floating and hides them after value is cleared while blurred', () => { - const { getByTestId, queryByTestId, rerender } = render( + const { getByTestId, getByText, queryByText, rerender } = render( ); - expect(getByTestId('tf-prefix')).toBeTruthy(); - expect(getByTestId('tf-suffix')).toBeTruthy(); + expect(getByText('$')).toBeTruthy(); + expect(getByText('/100')).toBeTruthy(); rerender( ); - expect(queryByTestId('tf-prefix')).toBeNull(); - expect(queryByTestId('tf-suffix')).toBeNull(); + expect(queryByText('$')).toBeNull(); + expect(queryByText('/100')).toBeNull(); expect(getByTestId('tf-ps')).toBeTruthy(); }); it('renders prefix and suffix while focused even when value is empty', () => { - const { getByTestId, queryByTestId } = render( + const { getByTestId, getByText, queryByText } = render( { prefix="$" suffix=" kg" testID="tf-ps-focus" - prefixProps={{ testID: 'tf-prefix-focus' }} - suffixProps={{ testID: 'tf-suffix-focus' }} /> ); - expect(queryByTestId('tf-prefix-focus')).toBeNull(); - expect(queryByTestId('tf-suffix-focus')).toBeNull(); + expect(queryByText('$')).toBeNull(); + expect(queryByText(' kg')).toBeNull(); fireEvent(getByTestId('tf-ps-focus'), 'focus'); - expect(getByTestId('tf-prefix-focus')).toBeTruthy(); - expect(getByTestId('tf-suffix-focus')).toBeTruthy(); + expect(getByText('$')).toBeTruthy(); + expect(getByText(' kg')).toBeTruthy(); }); it('places prefix Text before the TextInput and suffix Text after it', () => { @@ -907,17 +894,15 @@ it('places prefix Text before the TextInput and suffix Text after it', () => { prefix="$" suffix="/100" testID="tf-order" - prefixProps={{ testID: 'order-prefix' }} - suffixProps={{ testID: 'order-suffix' }} /> ); const tree = toJSON(); - expect(firstIndexOfTestIdInTree(tree, 'order-prefix')).toBeLessThan( + expect(firstIndexOfTextChildArrayInTree(tree, '$')).toBeLessThan( firstIndexOfTestIdInTree(tree, 'tf-order') ); expect(firstIndexOfTestIdInTree(tree, 'tf-order')).toBeLessThan( - firstIndexOfTestIdInTree(tree, 'order-suffix') + firstIndexOfTextChildArrayInTree(tree, '/100') ); }); @@ -987,7 +972,7 @@ it('uses default horizontal alignment when suffix prop exists but suffix is not }); it('does not apply the TextInput style prop to prefix or suffix Text', () => { - const { getByTestId } = render( + const { getByTestId, getByText } = render( { suffix="]" style={{ fontSize: 40, letterSpacing: 9 }} testID="tf-input-style" - prefixProps={{ testID: 'pfx-no-input-style' }} - suffixProps={{ testID: 'sfx-no-input-style' }} /> ); @@ -1008,12 +991,8 @@ it('does not apply the TextInput style prop to prefix or suffix Text', () => { expect.objectContaining({ fontSize: 40, letterSpacing: 9 }) ); - const prefixFlat = StyleSheet.flatten( - getByTestId('pfx-no-input-style').props.style - ); - const suffixFlat = StyleSheet.flatten( - getByTestId('sfx-no-input-style').props.style - ); + const prefixFlat = StyleSheet.flatten(getByText('$').props.style); + const suffixFlat = StyleSheet.flatten(getByText(']').props.style); expect(prefixFlat.fontSize).not.toBe(40); expect(prefixFlat.letterSpacing).toBeUndefined(); diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index 3f4978cdad..5f39e572e7 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -47,7 +47,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "borderTopStartRadius": 4, "overflow": "hidden", }, - undefined, ] } > @@ -66,7 +65,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "height": 1, }, false, - undefined, ] } /> @@ -95,7 +93,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "height": 2, }, false, - undefined, { "transform": [ { @@ -121,7 +118,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "height": 2, }, false, - undefined, { "transform": [ { @@ -204,7 +200,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -222,7 +217,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "fontSize": 12, }, false, - undefined, ] } > @@ -414,7 +408,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -432,7 +425,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, @@ -695,7 +687,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "borderTopStartRadius": 4, "overflow": "hidden", }, - undefined, ] } > @@ -714,7 +705,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "height": 1, }, false, - undefined, ] } /> @@ -743,7 +733,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "height": 2, }, false, - undefined, { "transform": [ { @@ -769,7 +758,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "height": 2, }, false, - undefined, { "transform": [ { @@ -852,7 +840,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -870,7 +857,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "fontSize": 12, }, false, - undefined, ] } > @@ -1062,7 +1048,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -1080,7 +1065,6 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, @@ -1343,7 +1327,6 @@ exports[`renders filled TextField with label and value 1`] = ` "borderTopStartRadius": 4, "overflow": "hidden", }, - undefined, ] } > @@ -1362,7 +1345,6 @@ exports[`renders filled TextField with label and value 1`] = ` "height": 1, }, false, - undefined, ] } /> @@ -1391,7 +1373,6 @@ exports[`renders filled TextField with label and value 1`] = ` "height": 2, }, false, - undefined, { "transform": [ { @@ -1417,7 +1398,6 @@ exports[`renders filled TextField with label and value 1`] = ` "height": 2, }, false, - undefined, { "transform": [ { @@ -1500,7 +1480,6 @@ exports[`renders filled TextField with label and value 1`] = ` "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -1518,7 +1497,6 @@ exports[`renders filled TextField with label and value 1`] = ` "fontSize": 12, }, false, - undefined, ] } > @@ -1547,7 +1525,6 @@ exports[`renders filled TextField with label and value 1`] = ` "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -1565,7 +1542,6 @@ exports[`renders filled TextField with label and value 1`] = ` "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, @@ -1663,7 +1639,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "borderRadius": 4, }, undefined, - undefined, ] } > @@ -1684,8 +1659,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "borderWidth": 1, }, false, - undefined, - undefined, ] } /> @@ -1778,7 +1751,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -1796,7 +1768,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "fontSize": 12, }, false, - undefined, ] } > @@ -1988,7 +1959,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -2006,7 +1976,6 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, @@ -2267,7 +2236,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "borderRadius": 4, }, undefined, - undefined, ] } > @@ -2288,8 +2256,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "borderWidth": 1, }, false, - undefined, - undefined, ] } /> @@ -2382,7 +2348,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -2400,7 +2365,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "fontSize": 12, }, false, - undefined, ] } > @@ -2592,7 +2556,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -2610,7 +2573,6 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, @@ -2871,7 +2833,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "borderRadius": 4, }, undefined, - undefined, ] } > @@ -2892,8 +2853,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "borderWidth": 1, }, false, - undefined, - undefined, ] } /> @@ -2986,7 +2945,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "transitionProperty": "fontSize", }, false, - undefined, ] } style={ @@ -3004,7 +2962,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "fontSize": 12, }, false, - undefined, ] } > @@ -3033,7 +2990,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "paddingHorizontal": 16, }, false, - undefined, ], { "opacity": 1, @@ -3051,7 +3007,6 @@ exports[`renders outlined TextField with label and value 1`] = ` "paddingHorizontal": 16, }, false, - undefined, { "opacity": 1, }, From 50fc4d9dceccc831764da22dae383f12b900a09e Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 11:31:56 +0200 Subject: [PATCH 04/20] chore: pointer events declared in styles --- src/components/TextField/TextField.tsx | 15 ++----- src/components/TextField/styles.ts | 5 +++ .../__snapshots__/TextField.test.tsx.snap | 39 ++++++++++++------- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index f129abca9c..f57b1120f5 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -261,29 +261,22 @@ function TextField(props: TextFieldProps) { `opacity` style, so it never affects label/input rendering and works with PlatformColor on Android. */} {!!disabledBackgroundStyles && ( - + )} {/* Inactive indicator — always-visible 1px bottom border (filled) or full border (outlined); height and color reflect error/disabled state but do not change on focus */} - + {/* Active indicator — filled variant only; 2px bar that expands from the center outward via scaleX (0 → 1) on focus and collapses on blur */} {!!animatedActiveOutlineStyles && ( - + )} {!!label && ( - + {label} diff --git a/src/components/TextField/styles.ts b/src/components/TextField/styles.ts index d6d5ba5bd9..c7cc6d3c2c 100644 --- a/src/components/TextField/styles.ts +++ b/src/components/TextField/styles.ts @@ -81,6 +81,7 @@ export const filledStyles = StyleSheet.create({ left: 0, right: 0, bottom: 0, + pointerEvents: 'none', }, container: { flex: 1, @@ -90,6 +91,7 @@ export const filledStyles = StyleSheet.create({ }, labelWrapper: { position: 'absolute', + pointerEvents: 'none', }, disabledBackground: { position: 'absolute', @@ -98,6 +100,7 @@ export const filledStyles = StyleSheet.create({ right: 0, bottom: 0, opacity: FILLED_DISABLED_CONTAINER_OPACITY, + pointerEvents: 'none', }, }); @@ -109,6 +112,7 @@ export const outlinedStyles = StyleSheet.create({ top: 0, bottom: 0, borderRadius: TEXT_FIELD_BORDER_RADIUS, + pointerEvents: 'none', }, container: { flex: 1, @@ -119,5 +123,6 @@ export const outlinedStyles = StyleSheet.create({ labelWrapper: { position: 'absolute', paddingHorizontal: OUTLINED_LABEL_PADDING_HORIZONTAL, + pointerEvents: 'none', }, }); diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index 5f39e572e7..1cbc1b5246 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -51,12 +51,12 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` } > Date: Fri, 15 May 2026 11:44:58 +0200 Subject: [PATCH 05/20] chore: isweb to inline check --- src/components/TextField/constants.ts | 4 +--- src/components/TextField/utils.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts index e4925b9176..4746a5c172 100644 --- a/src/components/TextField/constants.ts +++ b/src/components/TextField/constants.ts @@ -1,11 +1,9 @@ -import { I18nManager, PixelRatio, Platform } from 'react-native'; +import { I18nManager, PixelRatio } from 'react-native'; import { tokens } from '../../theme/tokens'; import { motionDuration } from '../../theme/tokens/sys/motion'; import { defaultShapes } from '../../theme/tokens/sys/shape'; -export const isWeb = Platform.OS === 'web'; - export const fontScale = PixelRatio.getFontScale(); /** diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index f3fe43a4d5..62deecfef4 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -1,4 +1,10 @@ -import { I18nManager, StyleProp, TextStyle, ViewStyle } from 'react-native'; +import { + I18nManager, + Platform, + StyleProp, + TextStyle, + ViewStyle, +} from 'react-native'; import { AnimatedStyle } from 'react-native-reanimated'; @@ -23,7 +29,6 @@ import { PREFIX_END_PADDING, SUFFIX_START_PADDING, TEXT_FIELD_BORDER_RADIUS, - isWeb, } from './constants'; import { filledStyles, outlinedStyles, styles } from './styles'; import type { @@ -463,7 +468,7 @@ export const getFilledTextFieldData = ( height: 'auto', paddingTop: FILLED_MULTILINE_PADDING_TOP, }, - isWeb && { + Platform.OS === 'web' && { outlineStyle: 'none' as TextStyle['outlineStyle'], }, disabled && styles.disabled, @@ -580,7 +585,7 @@ export const getOutlinedTextFieldData = ( textAlignVertical: 'top', paddingTop: OUTLINED_MULTILINE_PADDING_TOP, }, - isWeb && { + Platform.OS === 'web' && { outlineStyle: 'none' as TextStyle['outlineStyle'], }, disabled && styles.disabled, From 77049d01a84c2b0edacac251aec1fb8225bc4544 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 11:48:48 +0200 Subject: [PATCH 06/20] chore: remove unnecessary spyon in jest --- jest/testSetup.js | 2 - .../__snapshots__/DataTable.test.tsx.snap | 10 ++-- .../__snapshots__/TextField.test.tsx.snap | 48 +++++++++---------- 3 files changed, 29 insertions(+), 31 deletions(-) diff --git a/jest/testSetup.js b/jest/testSetup.js index e6561e0211..207c532a7b 100644 --- a/jest/testSetup.js +++ b/jest/testSetup.js @@ -86,8 +86,6 @@ jest.mock('react-native', () => { RN.Animated.loop = loop; RN.Animated.parallel = parallel; - jest.spyOn(RN.PixelRatio, 'getFontScale').mockReturnValue(1); - return RN; }); diff --git a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap index dcd43b927f..0f997739ba 100644 --- a/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/DataTable.test.tsx.snap @@ -229,7 +229,7 @@ exports[`DataTable.Header renders data table header 1`] = ` "lineHeight": 24, }, { - "maxHeight": 24, + "maxHeight": 48, }, {}, { @@ -310,7 +310,7 @@ exports[`DataTable.Header renders data table header 1`] = ` "lineHeight": 24, }, { - "maxHeight": 24, + "maxHeight": 48, }, {}, { @@ -2619,7 +2619,7 @@ exports[`DataTable.Title renders data table title with press handler 1`] = ` "lineHeight": 24, }, { - "maxHeight": 24, + "maxHeight": 48, }, {}, { @@ -2747,7 +2747,7 @@ exports[`DataTable.Title renders data table title with sort icon 1`] = ` "lineHeight": 24, }, { - "maxHeight": 24, + "maxHeight": 48, }, {}, { @@ -2833,7 +2833,7 @@ exports[`DataTable.Title renders right aligned data table title 1`] = ` "lineHeight": 24, }, { - "maxHeight": 24, + "maxHeight": 48, }, {}, { diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index 1cbc1b5246..36b9b6fada 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -38,8 +38,8 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "backgroundColor": "rgba(230, 224, 233, 1)", @@ -152,7 +152,7 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "left": 52, }, { - "top": 8, + "top": 16, "transitionDuration": 150, "transitionProperty": "top", }, @@ -168,7 +168,7 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "left": 52, }, { - "top": 8, + "top": 16, }, ] } @@ -680,8 +680,8 @@ exports[`renders filled TextField with TextField.Icon accessories when error is [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "backgroundColor": "rgba(230, 224, 233, 1)", @@ -794,7 +794,7 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "left": 52, }, { - "top": 8, + "top": 16, "transitionDuration": 150, "transitionProperty": "top", }, @@ -810,7 +810,7 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "left": 52, }, { - "top": 8, + "top": 16, }, ] } @@ -1322,8 +1322,8 @@ exports[`renders filled TextField with label and value 1`] = ` [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "backgroundColor": "rgba(230, 224, 233, 1)", @@ -1436,7 +1436,7 @@ exports[`renders filled TextField with label and value 1`] = ` "left": 16, }, { - "top": 8, + "top": 16, "transitionDuration": 150, "transitionProperty": "top", }, @@ -1452,7 +1452,7 @@ exports[`renders filled TextField with label and value 1`] = ` "left": 16, }, { - "top": 8, + "top": 16, }, ] } @@ -1638,8 +1638,8 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "borderRadius": 4, @@ -1693,7 +1693,7 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "left": 48, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -36, @@ -1719,7 +1719,7 @@ exports[`renders outlined TextField with TextField.Icon accessories 1`] = ` "left": 48, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -36, @@ -2236,8 +2236,8 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "borderRadius": 4, @@ -2291,7 +2291,7 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "left": 48, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -36, @@ -2317,7 +2317,7 @@ exports[`renders outlined TextField with TextField.Icon accessories when error i "left": 48, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -36, @@ -2834,8 +2834,8 @@ exports[`renders outlined TextField with label and value 1`] = ` [ { "flexDirection": "row", - "minHeight": 56, - "paddingVertical": 8, + "minHeight": 112, + "paddingVertical": 16, }, { "borderRadius": 4, @@ -2889,7 +2889,7 @@ exports[`renders outlined TextField with label and value 1`] = ` "left": 16, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -4, @@ -2915,7 +2915,7 @@ exports[`renders outlined TextField with label and value 1`] = ` "left": 16, }, { - "top": -6, + "top": -12, "transform": [ { "translateX": -4, From 6a7f01ee2b6047fdf009498f8f1427b8259ffa2b Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 11:58:03 +0200 Subject: [PATCH 07/20] chore: remove unnecessary comments --- example/src/Examples/TextFieldExample.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/example/src/Examples/TextFieldExample.tsx b/example/src/Examples/TextFieldExample.tsx index a58fedcb02..c020d97dcb 100644 --- a/example/src/Examples/TextFieldExample.tsx +++ b/example/src/Examples/TextFieldExample.tsx @@ -21,10 +21,6 @@ import { import { useExampleTheme } from '../hooks/useExampleTheme'; import ScreenWrapper from '../ScreenWrapper'; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - type DemoControls = { error: boolean; disabled: boolean; @@ -44,10 +40,6 @@ type DemoModifiers = { suffix: string; }; -// --------------------------------------------------------------------------- -// TextFieldDemo -// --------------------------------------------------------------------------- - type TextFieldDemoProps = { variant: TextFieldVariant; }; @@ -185,10 +177,6 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { ); }; -// --------------------------------------------------------------------------- -// TextFieldExample -// --------------------------------------------------------------------------- - const TextFieldExample = () => { return ( @@ -204,10 +192,6 @@ const TextFieldExample = () => { TextFieldExample.title = 'TextField'; -// --------------------------------------------------------------------------- -// Styles -// --------------------------------------------------------------------------- - const styles = StyleSheet.create({ container: { paddingHorizontal: 16, From 492964a902acff7a3d31bb125013f2d9d1f1f49e Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 12:27:26 +0200 Subject: [PATCH 08/20] feat: use locale instead of i18n manager --- src/components/TextField/TextField.tsx | 1 + src/components/TextField/constants.ts | 20 ++++++++-------- src/components/TextField/hooks.ts | 9 ++++++-- src/components/TextField/utils.ts | 32 ++++++++++++++------------ 4 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index f57b1120f5..0acd4d53c8 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -33,6 +33,7 @@ export type TextFieldSharedApi = { input: React.RefObject; theme: InternalTheme; isFocused: boolean; + isRTL: boolean; disabled: boolean; hasAccessory: boolean; hasError: boolean; diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts index 4746a5c172..9f5065ad20 100644 --- a/src/components/TextField/constants.ts +++ b/src/components/TextField/constants.ts @@ -1,4 +1,4 @@ -import { I18nManager, PixelRatio } from 'react-native'; +import { PixelRatio } from 'react-native'; import { tokens } from '../../theme/tokens'; import { motionDuration } from '../../theme/tokens/sys/motion'; @@ -56,9 +56,6 @@ export const ANIMATION_DURATION_MS = motionDuration.short3; export const ACTIVE_INDICATOR_SIZE = 2; export const INACTIVE_INDICATOR_SIZE = 1; -const isRTL = I18nManager.getConstants().isRTL; -const layoutSupportMultiplier = isRTL ? -1 : 1; - /** * Constants for the filled variant. */ @@ -100,11 +97,12 @@ export const OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY = export const OUTLINED_ACTIVE_LABEL_TOP_POSITION = (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + LINE_HEIGHT_DELTA) * fontScale; -export const OUTLINED_LABEL_TRANSLATE_X_WITH_ACCESSORY = - -layoutSupportMultiplier * - (ACCESSORY_SIZE + - TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - - OUTLINED_LABEL_PADDING_HORIZONTAL); +/** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ +export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY = + ACCESSORY_SIZE + + TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - + OUTLINED_LABEL_PADDING_HORIZONTAL; -export const OUTLINED_LABEL_TRANSLATE_X_WITHOUT_ACCESSORY = - -layoutSupportMultiplier * OUTLINED_LABEL_PADDING_HORIZONTAL; +/** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ +export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY = + OUTLINED_LABEL_PADDING_HORIZONTAL; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index b51930b013..1adeebc6fe 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -1,5 +1,5 @@ import { useImperativeHandle, useRef, useState } from 'react'; -import { BlurEvent, FocusEvent, I18nManager, TextInput } from 'react-native'; +import { BlurEvent, FocusEvent, TextInput } from 'react-native'; import type { TextFieldHookReturn, @@ -12,6 +12,7 @@ import { getOutlinedTextFieldData, getTextFieldAnimation, } from './utils'; +import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { @@ -31,6 +32,8 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const theme = useInternalTheme(themeOverride); + const { direction } = useLocale(); + const [isFocused, setIsFocused] = useState(false); useImperativeHandle(ref, () => input.current as TextInput); @@ -39,7 +42,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { * Constants */ - const { isRTL } = I18nManager.getConstants(); + const isRTL = direction === 'rtl'; const disabled = props.editable === false; const isFloating = isFocused || !!props.value; const hasError = !!props.error; @@ -73,6 +76,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { variant, isFloating, isFocused, + isRTL, hasAccessory, }); @@ -103,6 +107,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { input, theme, isFocused, + isRTL, disabled, hasAccessory, hasError, diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 62deecfef4..b5f283c8a6 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -1,10 +1,4 @@ -import { - I18nManager, - Platform, - StyleProp, - TextStyle, - ViewStyle, -} from 'react-native'; +import { Platform, StyleProp, TextStyle, ViewStyle } from 'react-native'; import { AnimatedStyle } from 'react-native-reanimated'; @@ -23,8 +17,8 @@ import { OUTLINED_ACTIVE_LABEL_TOP_POSITION, OUTLINED_DISABLED_OUTLINE_OPACITY, OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY, - OUTLINED_LABEL_TRANSLATE_X_WITHOUT_ACCESSORY, - OUTLINED_LABEL_TRANSLATE_X_WITH_ACCESSORY, + OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY, + OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY, OUTLINED_MULTILINE_PADDING_TOP, PREFIX_END_PADDING, SUFFIX_START_PADDING, @@ -187,9 +181,14 @@ export const getOutlineColor = ({ export const getSharedTextFieldStyleData = ( api: TextFieldSharedApi ): SharedTextFieldStyleData => { - const { isRTL } = I18nManager.getConstants(); - - const { theme, disabled, hasError, isFocused, animatedLabelTextStyle } = api; + const { + theme, + disabled, + hasError, + isFocused, + isRTL, + animatedLabelTextStyle, + } = api; const labelColor = getLabelColor({ theme, hasError, isFocused, disabled }); @@ -275,11 +274,13 @@ export const getTextFieldAnimation = ({ variant, isFloating, isFocused, + isRTL, hasAccessory, }: { variant: 'filled' | 'outlined'; isFloating: boolean; isFocused: boolean; + isRTL: boolean; hasAccessory: boolean; }): { animatedLabelWrapperStyle: StyleProp>>; @@ -325,9 +326,10 @@ export const getTextFieldAnimation = ({ }; } - const translateXEnd = hasAccessory - ? OUTLINED_LABEL_TRANSLATE_X_WITH_ACCESSORY - : OUTLINED_LABEL_TRANSLATE_X_WITHOUT_ACCESSORY; + const distance = hasAccessory + ? OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY + : OUTLINED_LABEL_TRANSLATE_DISTANCE_WITHOUT_ACCESSORY; + const translateXEnd = (isRTL ? 1 : -1) * distance; return { animatedLabelWrapperStyle: { From d839884bbf1e1feebfd4f67e748bb75fd673bf97 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 12:49:21 +0200 Subject: [PATCH 09/20] feat: accessories as function not components --- example/src/Examples/TextFieldExample.tsx | 18 ++---- src/components/TextField/TextField.tsx | 62 +++++++++++---------- src/components/TextField/TextFieldIcon.tsx | 12 ++-- src/components/TextField/hooks.ts | 14 +++-- src/components/__tests__/TextField.test.tsx | 46 +++++++-------- 5 files changed, 77 insertions(+), 75 deletions(-) diff --git a/example/src/Examples/TextFieldExample.tsx b/example/src/Examples/TextFieldExample.tsx index c020d97dcb..27b8b74a3d 100644 --- a/example/src/Examples/TextFieldExample.tsx +++ b/example/src/Examples/TextFieldExample.tsx @@ -74,18 +74,12 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { const setModifier = (key: keyof DemoModifiers, text: string) => setModifiers((prev) => ({ ...prev, [key]: text })); - const LeadingIcon = React.useCallback( - (props: TextFieldAccessoryProps) => ( - - ), - [] + const leadingIcon = (props: TextFieldAccessoryProps) => ( + ); - const TrailingIcon = React.useCallback( - (props: TextFieldAccessoryProps) => ( - setValue('')} /> - ), - [] + const trailingIcon = (props: TextFieldAccessoryProps) => ( + setValue('')} /> ); const inputColor = theme.colors.onSurfaceVariant; @@ -136,8 +130,8 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { maxLength={controls.counter ? 100 : undefined} prefix={controls.showPrefix ? modifiers.prefix : undefined} suffix={controls.showSuffix ? modifiers.suffix : undefined} - StartAccessory={controls.leadingIcon ? LeadingIcon : undefined} - EndAccessory={controls.trailingIcon ? TrailingIcon : undefined} + startAccessory={controls.leadingIcon ? leadingIcon : undefined} + endAccessory={controls.trailingIcon ? trailingIcon : undefined} /> diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 0acd4d53c8..3161592de3 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -1,4 +1,4 @@ -import React, { ComponentType } from 'react'; +import React from 'react'; import { BlurEvent, ColorValue, @@ -103,8 +103,12 @@ export type TextFieldHookReturn = SharedTextFieldStyleData & { inputStyles: StyleProp; placeholder: string | undefined; counterText: string; - LeadingAccessory: ComponentType | undefined; - TrailingAccessory: ComponentType | undefined; + renderLeadingAccessory: + | ((props: TextFieldAccessoryProps) => React.ReactNode) + | undefined; + renderTrailingAccessory: + | ((props: TextFieldAccessoryProps) => React.ReactNode) + | undefined; onFocusHandler: (e: FocusEvent) => void; onBlurHandler: (e: BlurEvent) => void; focusInput: () => void; @@ -150,12 +154,12 @@ export type TextFieldProps = TextInputProps & { * An optional component to render on the start side of the input (leading in LTR). * Can be a custom component or `TextField.Icon`. */ - StartAccessory?: ComponentType; + startAccessory?: (props: TextFieldAccessoryProps) => React.ReactNode; /** * An optional component to render on the end side of the input (trailing in LTR). * Can be a custom component or `TextField.Icon`. */ - EndAccessory?: ComponentType; + endAccessory?: (props: TextFieldAccessoryProps) => React.ReactNode; }; /** @@ -171,11 +175,11 @@ export type TextFieldProps = TextInputProps & { * const MyComponent = () => { * const [text, setText] = React.useState(''); * - * const SearchIcon = (props) => ( - * + * const searchAccessory = (accessoryProps) => ( + * * ); * - * const ClearAccessory = ({ style, disabled }) => ( + * const clearAccessory = ({ style, disabled }) => ( * * ); * }; @@ -212,8 +216,8 @@ function TextField(props: TextFieldProps) { supportingText, variant, theme, - StartAccessory, - EndAccessory, + startAccessory, + endAccessory, prefix, suffix, counter, @@ -247,8 +251,8 @@ function TextField(props: TextFieldProps) { cursorColor, placeholder, counterText, - LeadingAccessory, - TrailingAccessory, + renderLeadingAccessory, + renderTrailingAccessory, focusInput, onFocusHandler, onBlurHandler, @@ -284,14 +288,14 @@ function TextField(props: TextFieldProps) { )} - {!!LeadingAccessory && ( - - )} + {renderLeadingAccessory + ? renderLeadingAccessory({ + style: leadingAccessoryStyles, + error: hasError, + disabled, + multiline: !!textInputProps.multiline, + }) + : null} {hasPrefix && ( @@ -322,13 +326,13 @@ function TextField(props: TextFieldProps) { )} - {TrailingAccessory ? ( - + {renderTrailingAccessory ? ( + renderTrailingAccessory({ + style: trailingAccessoryStyles, + error: hasError, + disabled, + multiline: !!textInputProps.multiline, + }) ) : hasError ? ( ) : null} diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextField/TextFieldIcon.tsx index ab91bafb72..99de90a981 100644 --- a/src/components/TextField/TextFieldIcon.tsx +++ b/src/components/TextField/TextFieldIcon.tsx @@ -36,8 +36,8 @@ export interface TextFieldIconProps extends TextFieldAccessoryProps { /** * A component to render a leading / trailing icon in the TextField - * (inside `StartAccessory` or `EndAccessory`). Accepts icon-specific props as well as - * `TextFieldAccessoryProps`, which TextField forwards automatically. + * (return it from `startAccessory` or `endAccessory`). Accepts icon-specific props as well as + * `TextFieldAccessoryProps`, which TextField passes into those render props. * * ## Usage * ```js @@ -47,11 +47,11 @@ export interface TextFieldIconProps extends TextFieldAccessoryProps { * const MyComponent = () => { * const [text, setText] = React.useState(''); * - * const SearchIcon = (props) => ( + * const searchAccessory = (props) => ( * * ); * - * const ClearIcon = (props) => ( + * const clearAccessory = (props) => ( * setText('')} /> * ); * @@ -60,8 +60,8 @@ export interface TextFieldIconProps extends TextFieldAccessoryProps { * label="Search" * value={text} * onChangeText={setText} - * StartAccessory={SearchIcon} - * EndAccessory={ClearIcon} + * startAccessory={searchAccessory} + * endAccessory={clearAccessory} * /> * ); * }; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 1adeebc6fe..39d134faec 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -46,7 +46,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const disabled = props.editable === false; const isFloating = isFocused || !!props.value; const hasError = !!props.error; - const hasAccessory = isRTL ? !!props.EndAccessory : !!props.StartAccessory; + const hasAccessory = isRTL ? !!props.endAccessory : !!props.startAccessory; const hasPrefix = !!props.prefix && isFloating; const hasSuffix = !!props.suffix && isFloating; const hasCounter = !!(props.counter && props.maxLength); @@ -121,8 +121,12 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { * Components */ - const LeadingAccessory = isRTL ? props.EndAccessory : props.StartAccessory; - const TrailingAccessory = isRTL ? props.StartAccessory : props.EndAccessory; + const renderLeadingAccessory = isRTL + ? props.endAccessory + : props.startAccessory; + const renderTrailingAccessory = isRTL + ? props.startAccessory + : props.endAccessory; // https://github.com/facebook/react-native/issues/31573 const placeholder = isFocused ? props.placeholder : ' '; const counterText = `${props.value?.length ?? 0}/${props.maxLength}`; @@ -141,8 +145,8 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { animatedContainerStyle, placeholder, counterText, - LeadingAccessory, - TrailingAccessory, + renderLeadingAccessory, + renderTrailingAccessory, onFocusHandler, onBlurHandler, focusInput, diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index f4160fb9bf..4568e262ee 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -65,10 +65,10 @@ it('renders filled TextField with TextField.Icon accessories', () => { label="Search" value="q" onChangeText={() => {}} - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( )} /> @@ -84,10 +84,10 @@ it('renders outlined TextField with TextField.Icon accessories', () => { label="Search" value="q" onChangeText={() => {}} - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( )} /> @@ -103,10 +103,10 @@ it('renders filled TextField with TextField.Icon accessories when error is true' value="q" onChangeText={() => {}} error - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( {}} /> )} /> @@ -123,10 +123,10 @@ it('renders outlined TextField with TextField.Icon accessories when error is tru value="q" onChangeText={() => {}} error - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( {}} /> )} /> @@ -142,10 +142,10 @@ it('fires onPress on TextField.Icon end accessory', () => { label="Search" value="x" onChangeText={() => {}} - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( { value="x" onChangeText={() => {}} editable={false} - StartAccessory={(props: TextFieldAccessoryProps) => ( + startAccessory={(props: TextFieldAccessoryProps) => ( )} - EndAccessory={(props: TextFieldAccessoryProps) => ( + endAccessory={(props: TextFieldAccessoryProps) => ( )} /> @@ -330,8 +330,8 @@ it('does not pass TextField-only props through to TextInput', () => { const input = getByTestId('tf-native'); expect(input.props.variant).toBeUndefined(); expect(input.props.theme).toBeUndefined(); - expect(input.props.StartAccessory).toBeUndefined(); - expect(input.props.EndAccessory).toBeUndefined(); + expect(input.props.startAccessory).toBeUndefined(); + expect(input.props.endAccessory).toBeUndefined(); expect(input.props.label).toBeUndefined(); expect(input.props.supportingText).toBeUndefined(); expect(input.props.prefix).toBeUndefined(); @@ -520,8 +520,8 @@ it('passes error, disabled, and multiline to accessories', () => { multiline error editable={false} - StartAccessory={StartAccessory} - EndAccessory={EndAccessory} + startAccessory={StartAccessory} + endAccessory={EndAccessory} /> ); @@ -554,7 +554,7 @@ it('passes error to accessories when the field is disabled', () => { onChangeText={() => {}} error editable={false} - StartAccessory={StartAccessory} + startAccessory={StartAccessory} /> ); @@ -684,8 +684,8 @@ it('places EndAccessory before StartAccessory in the tree when RTL', () => { label="Email" value="" onChangeText={() => {}} - StartAccessory={StartAccessory} - EndAccessory={EndAccessory} + startAccessory={StartAccessory} + endAccessory={EndAccessory} testID="tf-input-rtl-order" /> ); @@ -712,8 +712,8 @@ it('places StartAccessory before EndAccessory in the tree when LTR', () => { label="Email" value="" onChangeText={() => {}} - StartAccessory={StartAccessory} - EndAccessory={EndAccessory} + startAccessory={StartAccessory} + endAccessory={EndAccessory} testID="tf-input-ltr-order" /> ); @@ -805,7 +805,7 @@ it('maps a lone StartAccessory to leading in LTR and trailing in RTL (tree order label="Email" value="" onChangeText={() => {}} - StartAccessory={LoneStartAccessory} + startAccessory={LoneStartAccessory} testID="tf-lone-ltr" /> ); @@ -817,7 +817,7 @@ it('maps a lone StartAccessory to leading in LTR and trailing in RTL (tree order label="Email" value="" onChangeText={() => {}} - StartAccessory={LoneStartAccessory} + startAccessory={LoneStartAccessory} testID="tf-lone-rtl" /> ); From dbb91ff26cbc155eef794f4383c28c7f50e45b4c Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Fri, 15 May 2026 13:16:31 +0200 Subject: [PATCH 10/20] feat: introduce separate disable props --- example/src/Examples/TextFieldExample.tsx | 6 +- src/components/TextField/TextField.tsx | 25 ++-- src/components/TextField/TextFieldIcon.tsx | 2 +- src/components/TextField/hooks.ts | 9 +- src/components/TextField/utils.ts | 82 ++++++------- src/components/__tests__/TextField.test.tsx | 125 ++++++++++++++++++-- 6 files changed, 183 insertions(+), 66 deletions(-) diff --git a/example/src/Examples/TextFieldExample.tsx b/example/src/Examples/TextFieldExample.tsx index 27b8b74a3d..00566385bf 100644 --- a/example/src/Examples/TextFieldExample.tsx +++ b/example/src/Examples/TextFieldExample.tsx @@ -24,6 +24,7 @@ import ScreenWrapper from '../ScreenWrapper'; type DemoControls = { error: boolean; disabled: boolean; + readOnly: boolean; leadingIcon: boolean; trailingIcon: boolean; counter: boolean; @@ -52,6 +53,7 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { const [controls, setControls] = React.useState({ error: false, disabled: false, + readOnly: false, leadingIcon: false, trailingIcon: false, counter: false, @@ -97,6 +99,7 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { const SWITCH_CONTROLS: { label: string; key: keyof DemoControls }[] = [ { label: 'Error', key: 'error' }, { label: 'Disabled', key: 'disabled' }, + { label: 'Readonly', key: 'readOnly' }, { label: 'Leading icon', key: 'leadingIcon' }, { label: 'Trailing icon', key: 'trailingIcon' }, { label: 'Counter', key: 'counter' }, @@ -122,7 +125,8 @@ const TextFieldDemo = ({ variant }: TextFieldDemoProps) => { placeholder={modifiers.placeholder || undefined} supportingText={modifiers.helperText || undefined} error={controls.error} - editable={!controls.disabled} + disabled={controls.disabled} + editable={!controls.readOnly} value={value} onChangeText={setValue} multiline={controls.multiline} diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 3161592de3..4998da76db 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -34,7 +34,7 @@ export type TextFieldSharedApi = { theme: InternalTheme; isFocused: boolean; isRTL: boolean; - disabled: boolean; + isDisabled: boolean; hasAccessory: boolean; hasError: boolean; hasSuffix: boolean; @@ -56,7 +56,7 @@ export type SharedTextFieldStyleData = { export type FilledTextFieldHookData = SharedTextFieldStyleData & { input: React.RefObject; - disabled: boolean; + isDisabled: boolean; hasError: boolean; hasSuffix: boolean; animatedLabelWrapperStyles: StyleProp>>; @@ -70,7 +70,7 @@ export type FilledTextFieldHookData = SharedTextFieldStyleData & { export type OutlinedTextFieldHookData = SharedTextFieldStyleData & { input: React.RefObject; - disabled: boolean; + isDisabled: boolean; hasError: boolean; hasSuffix: boolean; animatedLabelWrapperStyles: StyleProp>>; @@ -83,7 +83,8 @@ export type OutlinedTextFieldHookData = SharedTextFieldStyleData & { export type TextFieldHookReturn = SharedTextFieldStyleData & { input: React.RefObject; - disabled: boolean; + isDisabled: boolean; + isEditable: boolean | undefined; hasPrefix: boolean; hasCounter: boolean; hasSuffix: boolean; @@ -141,6 +142,11 @@ export type TextFieldProps = TextInputProps & { * side, showing `currentLength/maxLength`. Requires `maxLength` to be set. */ counter?: boolean; + /** + * This is separate from `editable={false}`, which makes the text read-only while the + * input can still be focused and text selected. + */ + disabled?: boolean; /** * A short text string displayed at the start of the input (e.g. `"$"`). */ @@ -221,12 +227,14 @@ function TextField(props: TextFieldProps) { prefix, suffix, counter, + disabled, ...textInputProps } = props; const { input, - disabled, + isDisabled, + isEditable, hasPrefix, hasSuffix, hasCounter, @@ -292,7 +300,7 @@ function TextField(props: TextFieldProps) { ? renderLeadingAccessory({ style: leadingAccessoryStyles, error: hasError, - disabled, + disabled: isDisabled, multiline: !!textInputProps.multiline, }) : null} @@ -306,7 +314,7 @@ function TextField(props: TextFieldProps) { @@ -330,7 +339,7 @@ function TextField(props: TextFieldProps) { renderTrailingAccessory({ style: trailingAccessoryStyles, error: hasError, - disabled, + disabled: isDisabled, multiline: !!textInputProps.multiline, }) ) : hasError ? ( diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextField/TextFieldIcon.tsx index 99de90a981..f34275a7d9 100644 --- a/src/components/TextField/TextFieldIcon.tsx +++ b/src/components/TextField/TextFieldIcon.tsx @@ -88,7 +88,7 @@ const TextFieldIcon = ({ theme, color, hasError: error, - disabled, + isDisabled: disabled, }); const onPressHandler = disabled ? undefined : onPress; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 39d134faec..0cb64a01be 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -43,7 +43,8 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { */ const isRTL = direction === 'rtl'; - const disabled = props.editable === false; + const isDisabled = !!props.disabled; + const isEditable = props.disabled ? false : props.editable; const isFloating = isFocused || !!props.value; const hasError = !!props.error; const hasAccessory = isRTL ? !!props.endAccessory : !!props.startAccessory; @@ -95,7 +96,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { }; const focusInput = () => { - if (disabled) return; + if (isDisabled) return; input.current?.focus(); }; @@ -108,7 +109,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { theme, isFocused, isRTL, - disabled, + isDisabled, hasAccessory, hasError, hasSuffix, @@ -136,6 +137,8 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { */ const data = { + isEditable, + isDisabled, hasPrefix, hasCounter, placeholderTextColor, diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index b5f283c8a6..6c79d0bb65 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -53,12 +53,12 @@ export const getLabelColor = ({ theme, hasError, isFocused, - disabled, + isDisabled, }: { theme: InternalTheme; isFocused: boolean; hasError: boolean; - disabled: boolean; + isDisabled: boolean; }) => { const { colors: { error, primary, onSurface, onSurfaceVariant }, @@ -67,7 +67,7 @@ export const getLabelColor = ({ if (hasError) { return error; } - if (disabled) { + if (isDisabled) { return onSurface; } if (isFocused) { @@ -79,11 +79,11 @@ export const getLabelColor = ({ export const getSupportingTextColor = ({ theme, hasError, - disabled, + isDisabled, }: { theme: InternalTheme; hasError: boolean; - disabled: boolean; + isDisabled: boolean; }) => { const { colors: { error, onSurface, onSurfaceVariant }, @@ -92,7 +92,7 @@ export const getSupportingTextColor = ({ if (hasError) { return error; } - if (disabled) { + if (isDisabled) { return onSurface; } return onSurfaceVariant; @@ -107,12 +107,12 @@ export const getSupportingTextColor = ({ */ export const getFieldBackgroundColor = ({ theme, - disabled, + isDisabled, }: { theme: InternalTheme; - disabled: boolean; + isDisabled: boolean; }): string | undefined => { - if (disabled) { + if (isDisabled) { return undefined; } @@ -123,16 +123,16 @@ export const getIconColor = ({ theme, color, hasError, - disabled, + isDisabled, }: { theme: InternalTheme; color?: string; hasError: boolean; - disabled: boolean; + isDisabled: boolean; }) => { if (color) return color; if (hasError) return theme.colors.error; - if (disabled) return theme.colors.onSurface; + if (isDisabled) return theme.colors.onSurface; return theme.colors.onSurfaceVariant; }; @@ -146,12 +146,12 @@ export const getOutlineColor = ({ theme, hasError, isFocused, - disabled, + isDisabled, }: { theme: InternalTheme; isFocused: boolean; hasError: boolean; - disabled: boolean; + isDisabled: boolean; }) => { const { colors: { error, onSurface, primary, outline }, @@ -160,7 +160,7 @@ export const getOutlineColor = ({ if (hasError) { return error; } - if (disabled) { + if (isDisabled) { return onSurface; } if (isFocused) { @@ -183,19 +183,19 @@ export const getSharedTextFieldStyleData = ( ): SharedTextFieldStyleData => { const { theme, - disabled, + isDisabled, hasError, isFocused, isRTL, animatedLabelTextStyle, } = api; - const labelColor = getLabelColor({ theme, hasError, isFocused, disabled }); + const labelColor = getLabelColor({ theme, hasError, isFocused, isDisabled }); const supportingTextColor = getSupportingTextColor({ theme, hasError, - disabled, + isDisabled, }); const { colors: { onSurfaceVariant }, @@ -207,7 +207,7 @@ export const getSharedTextFieldStyleData = ( styles.input, { color: labelColor }, animatedLabelTextStyle, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const supportingTextStyles: StyleProp = [ @@ -216,7 +216,7 @@ export const getSharedTextFieldStyleData = ( color: supportingTextColor, writingDirection: isRTL ? 'rtl' : 'ltr', }, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const counterStyles: StyleProp = [ @@ -225,7 +225,7 @@ export const getSharedTextFieldStyleData = ( color: supportingTextColor, writingDirection: isRTL ? 'rtl' : 'ltr', }, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const prefixStyles: StyleProp = [ @@ -235,7 +235,7 @@ export const getSharedTextFieldStyleData = ( color: onSurfaceVariant, paddingEnd: PREFIX_END_PADDING, }, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const suffixStyles: StyleProp = [ @@ -245,17 +245,17 @@ export const getSharedTextFieldStyleData = ( color: onSurfaceVariant, paddingStart: SUFFIX_START_PADDING, }, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const leadingAccessoryStyles: StyleProp = [ styles.leadingAccessory, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const trailingAccessoryStyles: StyleProp = [ styles.trailingAccessory, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; return { @@ -357,7 +357,7 @@ export const getFilledTextFieldData = ( input, theme, hasSuffix, - disabled, + isDisabled, hasAccessory, hasError, animatedLabelWrapperStyle, @@ -375,17 +375,17 @@ export const getFilledTextFieldData = ( theme, hasError, isFocused: false, - disabled, + isDisabled, }); const activeOutlineColor = getOutlineColor({ theme, hasError, isFocused: true, - disabled, + isDisabled, }); - const fieldBackgroundColor = getFieldBackgroundColor({ theme, disabled }); + const fieldBackgroundColor = getFieldBackgroundColor({ theme, isDisabled }); /** * Shared styles @@ -411,7 +411,7 @@ export const getFilledTextFieldData = ( const containerStyles: StyleProp = [ filledStyles.container, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const fieldStyles: StyleProp = [ @@ -427,7 +427,7 @@ export const getFilledTextFieldData = ( /* Disabled tint (DISABLED_CONTAINER_OPACITY) is rendered as a childless overlay so its alpha can be applied via the `opacity` style without leaking onto the label and input. The View accepts `PlatformColor` directly. */ - const disabledBackgroundStyles: StyleProp | undefined = disabled + const disabledBackgroundStyles: StyleProp | undefined = isDisabled ? [ filledStyles.disabledBackground, { @@ -442,7 +442,7 @@ export const getFilledTextFieldData = ( height: INACTIVE_INDICATOR_SIZE, backgroundColor: outlineColor, }, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const animatedActiveOutlineStyles: StyleProp< @@ -453,7 +453,7 @@ export const getFilledTextFieldData = ( height: ACTIVE_INDICATOR_SIZE, backgroundColor: activeOutlineColor, }, - disabled && styles.disabled, + isDisabled && styles.disabled, animatedActiveOutlineStyle, ]; @@ -473,13 +473,13 @@ export const getFilledTextFieldData = ( Platform.OS === 'web' && { outlineStyle: 'none' as TextStyle['outlineStyle'], }, - disabled && styles.disabled, + isDisabled && styles.disabled, inputStyleOverride, ]; return { input, - disabled, + isDisabled, hasError, hasSuffix, animatedLabelWrapperStyles, @@ -503,7 +503,7 @@ export const getOutlinedTextFieldData = ( input, theme, isFocused, - disabled, + isDisabled, hasAccessory, hasError, hasSuffix, @@ -520,7 +520,7 @@ export const getOutlinedTextFieldData = ( const outlineColor = getOutlineColor({ theme, - disabled, + isDisabled, isFocused, hasError, }); @@ -537,7 +537,7 @@ export const getOutlinedTextFieldData = ( const containerStyles: StyleProp = [ outlinedStyles.container, - disabled && styles.disabled, + isDisabled && styles.disabled, ]; const fieldStyles: StyleProp = [ @@ -557,7 +557,7 @@ export const getOutlinedTextFieldData = ( borderWidth: isFocused ? 2 : 1, borderColor: outlineColor, }, - disabled && { opacity: OUTLINED_DISABLED_OUTLINE_OPACITY }, + isDisabled && { opacity: OUTLINED_DISABLED_OUTLINE_OPACITY }, ]; const animatedLabelWrapperStyles: StyleProp< @@ -590,13 +590,13 @@ export const getOutlinedTextFieldData = ( Platform.OS === 'web' && { outlineStyle: 'none' as TextStyle['outlineStyle'], }, - disabled && styles.disabled, + isDisabled && styles.disabled, inputStyleOverride, ]; return { input, - disabled, + isDisabled, hasError, hasSuffix, animatedLabelWrapperStyles, diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index 4568e262ee..931d8ee68b 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -161,18 +161,18 @@ it('fires onPress on TextField.Icon end accessory', () => { expect(onClear).toHaveBeenCalledTimes(1); }); -it('disables TextField.Icon when the field is not editable', () => { +it('disables TextField.Icon when the field is disabled', () => { const { getAllByTestId } = render( {}} - editable={false} + disabled startAccessory={(props: TextFieldAccessoryProps) => ( - + {}} /> )} endAccessory={(props: TextFieldAccessoryProps) => ( - + {}} /> )} /> ); @@ -182,6 +182,27 @@ it('disables TextField.Icon when the field is not editable', () => { expect(buttons[1].props.accessibilityState?.disabled).toBe(true); }); +it('does not disable TextField.Icon when the field is read-only (editable false)', () => { + const { getAllByTestId } = render( + {}} + editable={false} + startAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + endAccessory={(props: TextFieldAccessoryProps) => ( + {}} /> + )} + /> + ); + + const buttons = getAllByTestId('icon-button'); + expect(buttons[0].props.accessibilityState?.disabled).not.toBe(true); + expect(buttons[1].props.accessibilityState?.disabled).not.toBe(true); +}); + it('renders supporting text below the field', () => { const { getByText } = render( { expect(getByText('Optional').props['aria-live']).toBe('polite'); }); -it('marks the input as aria-disabled when editable is false', () => { +it('does not mark the input as aria-disabled when editable is false (read-only)', () => { const { getByTestId } = render( { /> ); + expect(getByTestId('tf-input').props['aria-disabled']).not.toBe(true); +}); + +it('marks the input as aria-disabled when disabled is true', () => { + const { getByTestId } = render( + {}} + disabled + testID="tf-input" + /> + ); + expect(getByTestId('tf-input').props['aria-disabled']).toBe(true); }); -it('marks the input as aria-invalid and aria-disabled when error and editable is false', () => { +it('marks the input as aria-invalid but not aria-disabled when error and read-only', () => { const { getByTestId } = render( ); + const input = getByTestId('tf-input'); + expect(input.props['aria-invalid']).toBe(true); + expect(input.props['aria-disabled']).not.toBe(true); +}); + +it('marks the input as aria-invalid and aria-disabled when error and disabled', () => { + const { getByTestId } = render( + {}} + error + disabled + testID="tf-input" + /> + ); + const input = getByTestId('tf-input'); expect(input.props['aria-invalid']).toBe(true); expect(input.props['aria-disabled']).toBe(true); }); -it('applies disabled opacity to the TextInput when editable is false (filled)', () => { +it('does not apply disabled opacity to the TextInput when editable is false (filled)', () => { + const { getByTestId } = render( + {}} + editable={false} + testID="tf-input-ro" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-ro').props.style) + ).not.toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('does not apply disabled opacity to the TextInput when editable is false (outlined)', () => { const { getByTestId } = render( {}} editable={false} + testID="tf-input-ro-out" + /> + ); + + expect( + StyleSheet.flatten(getByTestId('tf-input-ro-out').props.style) + ).not.toMatchObject({ opacity: stateOpacity.disabled }); +}); + +it('applies disabled opacity to the TextInput when disabled is true (filled)', () => { + const { getByTestId } = render( + {}} + disabled testID="tf-input-dis" /> ); @@ -283,14 +368,14 @@ it('applies disabled opacity to the TextInput when editable is false (filled)', ).toMatchObject({ opacity: stateOpacity.disabled }); }); -it('applies disabled opacity to the TextInput when editable is false (outlined)', () => { +it('applies disabled opacity to the TextInput when disabled is true (outlined)', () => { const { getByTestId } = render( {}} - editable={false} + disabled testID="tf-input-dis-out" /> ); @@ -323,6 +408,7 @@ it('does not pass TextField-only props through to TextInput', () => { value="" onChangeText={() => {}} error + disabled testID="tf-native" /> ); @@ -338,6 +424,7 @@ it('does not pass TextField-only props through to TextInput', () => { expect(input.props.suffix).toBeUndefined(); expect(input.props.counter).toBeUndefined(); expect(input.props.error).toBeUndefined(); + expect(input.props.disabled).toBeUndefined(); }); it('shows a character counter when counter is true and maxLength is set (filled)', () => { @@ -465,6 +552,20 @@ it('focuses the TextInput when the outer Pressable is pressed', () => { it('does not focus the TextInput when disabled and the Pressable is pressed', () => { const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); + const { UNSAFE_getByProps } = render( + {}} disabled /> + ); + + const pressable = UNSAFE_getByProps({ role: 'none', accessible: false }); + fireEvent.press(pressable); + + expect(focusSpy).not.toHaveBeenCalled(); + focusSpy.mockRestore(); +}); + +it('focuses the TextInput when read-only and the Pressable is pressed', () => { + const focusSpy = jest.spyOn(TextInput.prototype, 'focus'); + const { UNSAFE_getByProps } = render( { onChangeText={() => {}} multiline error - editable={false} + disabled startAccessory={StartAccessory} endAccessory={EndAccessory} /> @@ -553,7 +654,7 @@ it('passes error to accessories when the field is disabled', () => { value="" onChangeText={() => {}} error - editable={false} + disabled startAccessory={StartAccessory} /> ); From b03311ee5a89c09e6a8bf4d208d15d814e635683 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Mon, 18 May 2026 09:56:41 +0200 Subject: [PATCH 11/20] feat: render prop --- docs/src/components/PropTable.tsx | 6 +- src/components/TextField/TextField.tsx | 51 +++++++++++------ src/components/__tests__/TextField.test.tsx | 56 ++++++------------- .../__snapshots__/TextField.test.tsx.snap | 6 -- src/index.tsx | 3 +- 5 files changed, 55 insertions(+), 67 deletions(-) diff --git a/docs/src/components/PropTable.tsx b/docs/src/components/PropTable.tsx index f25d03ba6a..6687256554 100644 --- a/docs/src/components/PropTable.tsx +++ b/docs/src/components/PropTable.tsx @@ -11,8 +11,10 @@ const typeDefinitions = { 'https://github.com/callstack/react-native-paper/blob/main/src/components/Icon.tsx#L16', ThemeProp: 'https://callstack.github.io/react-native-paper/docs/guides/theming#theme-properties', - 'ComponentType': - 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L26', + '(props: TextFieldAccessoryProps) => React.ReactNode': + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L25', + '(props: TextFieldRenderProps) => React.ReactNode': + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L118', AccessibilityState: 'https://reactnative.dev/docs/accessibility#accessibilitystate', 'StyleProp': 'https://reactnative.dev/docs/view-style-props', diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 4998da76db..34fc6918dc 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -115,6 +115,10 @@ export type TextFieldHookReturn = SharedTextFieldStyleData & { focusInput: () => void; }; +export type TextFieldRenderProps = React.ComponentPropsWithRef< + typeof TextInput +>; + export type TextFieldProps = TextInputProps & { /** * Ref forwarded to the underlying TextInput. @@ -126,7 +130,8 @@ export type TextFieldProps = TextInputProps & { */ variant?: TextFieldVariant; /** - * When `true`, the field uses error styling and validation semantics (`aria-invalid`). + * When `true`, the field uses error styling and replaces the trailing accessory + * with an error indicator when no `endAccessory` is provided. */ error?: boolean; /** @@ -166,8 +171,18 @@ export type TextFieldProps = TextInputProps & { * Can be a custom component or `TextField.Icon`. */ endAccessory?: (props: TextFieldAccessoryProps) => React.ReactNode; + /** + * Callback to render a custom input component in place of the native `TextInput`. + * Receives all props that would be passed to `TextInput`, allowing integration + * with third-party inputs such as masked inputs. + */ + render?: (props: TextFieldRenderProps) => React.ReactNode; }; +const DefaultRenderer = (props: TextFieldRenderProps) => ( + +); + /** * A text field lets users enter and edit text. It shows an optional floating label, * supports `filled` and `outlined` variants, optional supporting text (including @@ -222,12 +237,13 @@ function TextField(props: TextFieldProps) { supportingText, variant, theme, - startAccessory, - endAccessory, prefix, suffix, counter, disabled, + startAccessory, + endAccessory, + render = DefaultRenderer, ...textInputProps } = props; @@ -312,21 +328,20 @@ function TextField(props: TextFieldProps) { )} - + {render({ + 'aria-label': label, + 'aria-disabled': isDisabled, + ref: input, + selectionColor, + cursorColor, + placeholderTextColor, + onFocus: onFocusHandler, + onBlur: onBlurHandler, + ...textInputProps, + editable: isEditable, + placeholder, + style: inputStyles, + })} {hasSuffix && ( diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index 931d8ee68b..657eca8d87 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -4,7 +4,10 @@ import { I18nManager, StyleSheet, TextInput, View } from 'react-native'; import { fireEvent, render } from '../../test-utils'; import { tokens } from '../../theme/tokens'; import TextField from '../TextField'; -import type { TextFieldAccessoryProps } from '../TextField/TextField'; +import type { + TextFieldAccessoryProps, + TextFieldRenderProps, +} from '../TextField/TextField'; const { stateOpacity } = tokens.md.ref; @@ -216,20 +219,6 @@ it('renders supporting text below the field', () => { expect(getByText('Use a valid address')).toBeTruthy(); }); -it('sets aria-invalid on the input when error is true', () => { - const { getByTestId } = render( - {}} - error - testID="tf-input" - /> - ); - - expect(getByTestId('tf-input').props['aria-invalid']).toBe(true); -}); - it('uses assertive aria-live on supporting text when error is true', () => { const { getByText } = render( { expect(getByTestId('tf-input').props['aria-disabled']).toBe(true); }); -it('marks the input as aria-invalid but not aria-disabled when error and read-only', () => { - const { getByTestId } = render( - {}} - error - editable={false} - testID="tf-input" - /> - ); - - const input = getByTestId('tf-input'); - expect(input.props['aria-invalid']).toBe(true); - expect(input.props['aria-disabled']).not.toBe(true); -}); +it('renders the input via render with merged props', () => { + const renderInput = jest.fn((props: TextFieldRenderProps) => ( + + )); -it('marks the input as aria-invalid and aria-disabled when error and disabled', () => { const { getByTestId } = render( {}} - error - disabled - testID="tf-input" + render={renderInput} /> ); - const input = getByTestId('tf-input'); - expect(input.props['aria-invalid']).toBe(true); - expect(input.props['aria-disabled']).toBe(true); + expect(getByTestId('custom-input')).toBeTruthy(); + expect(renderInput).toHaveBeenCalled(); + const merged = renderInput.mock.calls[0]?.[0] as TextFieldRenderProps; + expect(merged['aria-label']).toBe('Pin'); + expect(merged.value).toBe('12'); }); it('does not apply disabled opacity to the TextInput when editable is false (filled)', () => { diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index 36b9b6fada..5452cbce02 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -435,7 +435,6 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` > Date: Mon, 18 May 2026 11:15:53 +0200 Subject: [PATCH 12/20] chore: text field icon props --- src/components/TextField/TextFieldIcon.tsx | 51 ++-- src/components/TextField/utils.ts | 6 +- src/components/__tests__/TextField.test.tsx | 10 +- .../__snapshots__/TextField.test.tsx.snap | 240 +++++++----------- 4 files changed, 111 insertions(+), 196 deletions(-) diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextField/TextFieldIcon.tsx index f34275a7d9..e9b7448e63 100644 --- a/src/components/TextField/TextFieldIcon.tsx +++ b/src/components/TextField/TextFieldIcon.tsx @@ -1,38 +1,17 @@ import React from 'react'; -import { AccessibilityProps, GestureResponderEvent, View } from 'react-native'; +import { View } from 'react-native'; -import { useInternalTheme } from '../../core/theming'; -import type { ThemeProp } from '../../types'; -import type { IconSource } from '../Icon'; import { ACCESSORY_SIZE } from './constants'; import { styles } from './styles'; import type { TextFieldAccessoryProps } from './TextField'; import { getIconColor } from './utils'; -import IconButton from '../IconButton/IconButton'; +import { useInternalTheme } from '../../core/theming'; +import IconButton, { + type Props as IconButtonProps, +} from '../IconButton/IconButton'; -export interface TextFieldIconProps extends TextFieldAccessoryProps { - /** - * Icon to display. - */ - icon: IconSource; - /** - * Color of the icon. - */ - color?: string; - /** - * Size of the icon. - */ - size?: number; - /** - * Accessibility props for the icon button. - */ - accessibility?: AccessibilityProps; - theme?: ThemeProp; - /** - * Function to execute on press. - */ - onPress?: (event: GestureResponderEvent) => void; -} +export type TextFieldIconProps = TextFieldAccessoryProps & + Omit; /** * A component to render a leading / trailing icon in the TextField @@ -71,22 +50,22 @@ export interface TextFieldIconProps extends TextFieldAccessoryProps { */ const TextFieldIcon = ({ icon, - color, + iconColor, size, style, error, disabled, - accessibility, theme: themeOverride, onPress, + ...rest }: TextFieldIconProps) => { const theme = useInternalTheme(themeOverride); const iconSize = size ?? ACCESSORY_SIZE; - const iconColor = getIconColor({ + const color = getIconColor({ theme, - color, + iconColor, hasError: error, isDisabled: disabled, }); @@ -94,14 +73,14 @@ const TextFieldIcon = ({ const onPressHandler = disabled ? undefined : onPress; return ( - + ); diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 6c79d0bb65..fd3ce01510 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -121,16 +121,16 @@ export const getFieldBackgroundColor = ({ export const getIconColor = ({ theme, - color, + iconColor, hasError, isDisabled, }: { theme: InternalTheme; - color?: string; + iconColor?: string; hasError: boolean; isDisabled: boolean; }) => { - if (color) return color; + if (iconColor) return iconColor; if (hasError) return theme.colors.error; if (isDisabled) return theme.colors.onSurface; return theme.colors.onSurfaceVariant; diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index 657eca8d87..a0dc05d03b 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -69,10 +69,10 @@ it('renders filled TextField with TextField.Icon accessories', () => { value="q" onChangeText={() => {}} startAccessory={(props: TextFieldAccessoryProps) => ( - + )} endAccessory={(props: TextFieldAccessoryProps) => ( - + )} /> ).toJSON(); @@ -88,10 +88,10 @@ it('renders outlined TextField with TextField.Icon accessories', () => { value="q" onChangeText={() => {}} startAccessory={(props: TextFieldAccessoryProps) => ( - + )} endAccessory={(props: TextFieldAccessoryProps) => ( - + )} /> ).toJSON(); @@ -153,7 +153,7 @@ it('fires onPress on TextField.Icon end accessory', () => { {...props} icon="close" onPress={onClear} - accessibility={{ accessibilityLabel: 'Clear' }} + accessibilityLabel="Clear" /> )} /> diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index 5452cbce02..d2cfdeecaa 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -227,33 +227,22 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` Date: Mon, 18 May 2026 12:10:28 +0200 Subject: [PATCH 13/20] chore: ts style declarations --- src/components/TextField/utils.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index fd3ce01510..f37cf2bb8d 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -470,8 +470,9 @@ export const getFilledTextFieldData = ( height: 'auto', paddingTop: FILLED_MULTILINE_PADDING_TOP, }, + //@ts-expect-error - RN’s defs are narrower than CSS and RNW does not ship TS extensions that fix that Platform.OS === 'web' && { - outlineStyle: 'none' as TextStyle['outlineStyle'], + outlineStyle: 'none' as const, }, isDisabled && styles.disabled, inputStyleOverride, @@ -587,8 +588,9 @@ export const getOutlinedTextFieldData = ( textAlignVertical: 'top', paddingTop: OUTLINED_MULTILINE_PADDING_TOP, }, + //@ts-expect-error - RN’s defs are narrower than CSS and RNW does not ship TS extensions that fix that Platform.OS === 'web' && { - outlineStyle: 'none' as TextStyle['outlineStyle'], + outlineStyle: 'none' as const, }, isDisabled && styles.disabled, inputStyleOverride, From 996d92c2af8550fb9bf398d67c94c3fcdc285e2e Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Tue, 19 May 2026 08:55:33 +0200 Subject: [PATCH 14/20] feat: a11y --- src/components/TextField/TextField.tsx | 39 +++++---- .../TextField/TextFieldErrorIcon.tsx | 2 +- src/components/TextField/hooks.ts | 13 +++ src/components/TextField/utils.ts | 79 +++++++++++++++++++ src/components/__tests__/TextField.test.tsx | 53 ++++++++++--- .../__snapshots__/TextField.test.tsx.snap | 42 ++++++++-- 6 files changed, 196 insertions(+), 32 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 34fc6918dc..c163235dfd 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { + AccessibilityProps, BlurEvent, ColorValue, FocusEvent, @@ -20,6 +21,19 @@ import { styles } from './styles'; import TextFieldErrorIcon from './TextFieldErrorIcon'; import type { InternalTheme, ThemeProp } from '../../types'; +export type GetAccessibilityDataReturn = { + input: AccessibilityProps; + supportingText: AccessibilityProps; + counter: AccessibilityProps; +}; + +export type GetAccessibilityDataProps = { + data: TextFieldProps; + hasError: boolean; + hasCounter: boolean; + isDisabled: boolean; +}; + export type TextFieldVariant = 'filled' | 'outlined'; export type TextFieldAccessoryProps = { @@ -104,6 +118,7 @@ export type TextFieldHookReturn = SharedTextFieldStyleData & { inputStyles: StyleProp; placeholder: string | undefined; counterText: string; + accessibilityProps: GetAccessibilityDataReturn; renderLeadingAccessory: | ((props: TextFieldAccessoryProps) => React.ReactNode) | undefined; @@ -205,8 +220,8 @@ const DefaultRenderer = (props: TextFieldRenderProps) => ( * style={style} * disabled={disabled} * onPress={() => setText('')} - * accessibilityRole="button" - * accessibilityLabel="Clear text" + * role="button" + * aria-label="Clear text" * > * * @@ -275,6 +290,7 @@ function TextField(props: TextFieldProps) { cursorColor, placeholder, counterText, + accessibilityProps, renderLeadingAccessory, renderTrailingAccessory, focusInput, @@ -322,19 +338,14 @@ function TextField(props: TextFieldProps) { : null} - {hasPrefix && ( - - {prefix} - - )} + {hasPrefix && {prefix}} {render({ - 'aria-label': label, - 'aria-disabled': isDisabled, ref: input, selectionColor, cursorColor, placeholderTextColor, + ...accessibilityProps.input, onFocus: onFocusHandler, onBlur: onBlurHandler, ...textInputProps, @@ -343,11 +354,7 @@ function TextField(props: TextFieldProps) { style: inputStyles, })} - {hasSuffix && ( - - {suffix} - - )} + {hasSuffix && {suffix}} {renderTrailingAccessory ? ( @@ -365,7 +372,7 @@ function TextField(props: TextFieldProps) { {!!supportingText && ( {supportingText} @@ -373,7 +380,7 @@ function TextField(props: TextFieldProps) { )} {hasCounter && ( - + {counterText} )} diff --git a/src/components/TextField/TextFieldErrorIcon.tsx b/src/components/TextField/TextFieldErrorIcon.tsx index 72fb6e7847..bb9974f768 100644 --- a/src/components/TextField/TextFieldErrorIcon.tsx +++ b/src/components/TextField/TextFieldErrorIcon.tsx @@ -18,7 +18,7 @@ const TextFieldErrorIcon = ({ const theme = useInternalTheme(themeOverride); return ( - + { const placeholder = isFocused ? props.placeholder : ' '; const counterText = `${props.value?.length ?? 0}/${props.maxLength}`; + /** + * Accessibility + */ + + const accessibilityProps = getAccessibilityData({ + hasError, + hasCounter, + isDisabled, + data: props, + }); + /** * Styles */ @@ -148,6 +160,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { animatedContainerStyle, placeholder, counterText, + accessibilityProps, renderLeadingAccessory, renderTrailingAccessory, onFocusHandler, diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index f37cf2bb8d..c9568b4405 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -31,6 +31,8 @@ import type { TextFieldProps, TextFieldSharedApi, SharedTextFieldStyleData, + GetAccessibilityDataProps, + GetAccessibilityDataReturn, } from './TextField'; import type { InternalTheme } from '../../types'; @@ -610,3 +612,80 @@ export const getOutlinedTextFieldData = ( ...shared, }; }; + +export const getAccessibilityData = ({ + data, + hasError, + hasCounter, + isDisabled, +}: GetAccessibilityDataProps): GetAccessibilityDataReturn => { + const { label, supportingText, ...props } = data; + + let textLength = 0; + + if (props.value) { + textLength = props.value.length; + } else if (props.defaultValue) { + textLength = props.defaultValue.length; + } + + const maxLength = props.maxLength; + const shouldEvaluateCounter = !!maxLength && hasCounter; + const isEmptyString = textLength === 0; + const isCounterExceeded = shouldEvaluateCounter && textLength > maxLength; + const isCounterReached = shouldEvaluateCounter && textLength === maxLength; + const isInvalid = hasError || isCounterExceeded; + const isSupportingTextHidden = !!(supportingText && !hasError); + + const chunks: string[] = []; + + if (label) { + chunks.push(label); + } + + if (isSupportingTextHidden) { + chunks.push(supportingText); + } + + if (isEmptyString && props.placeholder && !hasError) { + chunks.push(props.placeholder); + } + + const ariaLabel = chunks.length > 0 ? chunks.join(', ') : label; + + let hint: string | undefined; + + if (isCounterExceeded && !(hasError && supportingText)) { + hint = `Character limit exceeded ${textLength} of ${maxLength}`; + } + + const counterAccessibilityLabel = shouldEvaluateCounter + ? isCounterExceeded + ? `Character limit exceeded ${textLength} of ${maxLength}` + : `Characters entered ${textLength} of ${maxLength}` + : undefined; + + const accessibilityState = { + disabled: isDisabled, + invalid: isInvalid, + ...props.accessibilityState, + } as const; + + return { + input: { + 'aria-label': ariaLabel, + 'aria-valuemax': isCounterReached ? maxLength : undefined, + 'aria-valuenow': isCounterReached ? textLength : undefined, + accessibilityHint: hint, + accessibilityState, + }, + supportingText: { + 'aria-hidden': isSupportingTextHidden, + 'aria-live': hasError && supportingText ? 'polite' : undefined, + }, + counter: { + 'aria-label': counterAccessibilityLabel, + 'aria-live': 'polite', + }, + }; +}; diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index a0dc05d03b..5b8f9acbb3 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -219,31 +219,64 @@ it('renders supporting text below the field', () => { expect(getByText('Use a valid address')).toBeTruthy(); }); -it('uses assertive aria-live on supporting text when error is true', () => { - const { getByText } = render( +it('uses polite aria-live on error supporting text', () => { + const { getByText, getByTestId } = render( {}} supportingText="Invalid" error + testID="tf-input" /> ); - expect(getByText('Invalid').props['aria-live']).toBe('assertive'); + expect(getByText('Invalid').props['aria-live']).toBe('polite'); + expect(getByTestId('tf-input').props.accessibilityState?.invalid).toBe(true); }); -it('uses polite aria-live on supporting text when there is no error', () => { - const { getByText } = render( +it('marks the input invalid when error is true without supporting text', () => { + const { getByTestId } = render( + {}} + error + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props.accessibilityState?.invalid).toBe(true); + expect(getByTestId('tf-input').props.accessibilityHint).toBeUndefined(); +}); + +it('hides helper supporting text from the accessibility tree and omits aria-live', () => { + const { getByText, getByTestId } = render( {}} supportingText="Optional" + testID="tf-input" /> ); - expect(getByText('Optional').props['aria-live']).toBe('polite'); + expect(getByText('Optional').props['aria-hidden']).toBe(true); + expect(getByText('Optional').props['aria-live']).toBeUndefined(); + expect(getByTestId('tf-input').props['aria-label']).toBe('Email, Optional'); +}); + +it('includes supporting text in aria-label when label is omitted', () => { + const { getByTestId } = render( + {}} + supportingText="Helper only" + testID="tf-input" + /> + ); + + expect(getByTestId('tf-input').props['aria-label']).toBe('Helper only'); }); it('does not mark the input as aria-disabled when editable is false (read-only)', () => { @@ -257,10 +290,12 @@ it('does not mark the input as aria-disabled when editable is false (read-only)' /> ); - expect(getByTestId('tf-input').props['aria-disabled']).not.toBe(true); + expect(getByTestId('tf-input').props.accessibilityState?.disabled).not.toBe( + true + ); }); -it('marks the input as aria-disabled when disabled is true', () => { +it('marks the input as disabled in accessibilityState when disabled is true', () => { const { getByTestId } = render( { /> ); - expect(getByTestId('tf-input').props['aria-disabled']).toBe(true); + expect(getByTestId('tf-input').props.accessibilityState?.disabled).toBe(true); }); it('renders the input via render with merged props', () => { diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index d2cfdeecaa..da32e01fcf 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -426,7 +426,12 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` } > Date: Tue, 19 May 2026 12:00:01 +0200 Subject: [PATCH 15/20] chore: dimensions round --- src/components/TextField/constants.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts index 9f5065ad20..be60fb6a62 100644 --- a/src/components/TextField/constants.ts +++ b/src/components/TextField/constants.ts @@ -16,9 +16,12 @@ export const BASELINE_TEXT_FIELD_PADDING_VERTICAL = 8; export const TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL = 16; export const TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL = 12; -export const TEXT_FIELD_HEIGHT = BASELINE_TEXT_FIELD_HEIGHT * fontScale; -export const TEXT_FIELD_PADDING_VERTICAL = - BASELINE_TEXT_FIELD_PADDING_VERTICAL * fontScale; +export const TEXT_FIELD_HEIGHT = Math.ceil( + BASELINE_TEXT_FIELD_HEIGHT * fontScale +); +export const TEXT_FIELD_PADDING_VERTICAL = Math.ceil( + BASELINE_TEXT_FIELD_PADDING_VERTICAL * fontScale +); export const TEXT_FIELD_BORDER_RADIUS = defaultShapes.corner.extraSmall; @@ -40,14 +43,15 @@ export const INACTIVE_LABEL_FONT_SIZE = INPUT_FONT_SIZE; export const SUPPORTING_TEXT_FONT_SIZE = tokens.md.sys.typescale.bodySmall.fontSize; -export const INACTIVE_LABEL_TOP_POSITION = +export const INACTIVE_LABEL_TOP_POSITION = Math.ceil( ((BASELINE_TEXT_FIELD_HEIGHT - 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - INPUT_FONT_SIZE) / 2 + BASELINE_TEXT_FIELD_PADDING_VERTICAL - LINE_HEIGHT_DELTA) * - fontScale; + fontScale +); export const SUPPORTING_TEXT_MARGIN_TOP = 4; @@ -68,7 +72,7 @@ export const FILLED_LABEL_START_OFFSET_WITH_ACCESSORY = export const FILLED_ACTIVE_LABEL_TOP_POSITION = TEXT_FIELD_PADDING_VERTICAL; export const FILLED_MULTILINE_PADDING_TOP = - ACTIVE_LABEL_FONT_SIZE * fontScale + TEXT_FIELD_PADDING_VERTICAL; + Math.ceil(ACTIVE_LABEL_FONT_SIZE * fontScale) + TEXT_FIELD_PADDING_VERTICAL; export const FILLED_DISABLED_CONTAINER_OPACITY = 0.04; @@ -78,13 +82,14 @@ export const FILLED_DISABLED_CONTAINER_OPACITY = 0.04; export const OUTLINED_DISABLED_OUTLINE_OPACITY = 0.12; -export const OUTLINED_MULTILINE_PADDING_TOP = +export const OUTLINED_MULTILINE_PADDING_TOP = Math.ceil( ((BASELINE_TEXT_FIELD_HEIGHT - 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - INPUT_FONT_SIZE) / 2 - LINE_HEIGHT_DELTA) * - fontScale; + fontScale +); export const OUTLINED_LABEL_PADDING_HORIZONTAL = 4; @@ -94,8 +99,9 @@ export const OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY = TEXT_FIELD_INPUT_WRAPPER_PADDING_HORIZONTAL - OUTLINED_LABEL_PADDING_HORIZONTAL; -export const OUTLINED_ACTIVE_LABEL_TOP_POSITION = - (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + LINE_HEIGHT_DELTA) * fontScale; +export const OUTLINED_ACTIVE_LABEL_TOP_POSITION = Math.ceil( + (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + LINE_HEIGHT_DELTA) * fontScale +); /** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ export const OUTLINED_LABEL_TRANSLATE_DISTANCE_WITH_ACCESSORY = From 7adba7d2e6056ff24cd7d6f550e86eaa80f4f8fb Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Tue, 19 May 2026 13:18:22 +0200 Subject: [PATCH 16/20] chore: style adjustment --- src/components/TextField/constants.ts | 42 +++++++++++++------ src/components/TextField/utils.ts | 12 +++++- .../__snapshots__/TextField.test.tsx.snap | 3 ++ 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/components/TextField/constants.ts b/src/components/TextField/constants.ts index be60fb6a62..c1f5534b17 100644 --- a/src/components/TextField/constants.ts +++ b/src/components/TextField/constants.ts @@ -35,7 +35,6 @@ export const SUFFIX_START_PADDING = 2; export const ERROR_ICON_SIZE = 16; -export const LINE_HEIGHT_DELTA = 2; export const INPUT_FONT_SIZE = tokens.md.sys.typescale.bodyLarge.fontSize; export const ACTIVE_LABEL_FONT_SIZE = tokens.md.sys.typescale.bodySmall.fontSize; @@ -43,16 +42,6 @@ export const INACTIVE_LABEL_FONT_SIZE = INPUT_FONT_SIZE; export const SUPPORTING_TEXT_FONT_SIZE = tokens.md.sys.typescale.bodySmall.fontSize; -export const INACTIVE_LABEL_TOP_POSITION = Math.ceil( - ((BASELINE_TEXT_FIELD_HEIGHT - - 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - - INPUT_FONT_SIZE) / - 2 + - BASELINE_TEXT_FIELD_PADDING_VERTICAL - - LINE_HEIGHT_DELTA) * - fontScale -); - export const SUPPORTING_TEXT_MARGIN_TOP = 4; export const ANIMATION_DURATION_MS = motionDuration.short3; @@ -64,6 +53,8 @@ export const INACTIVE_INDICATOR_SIZE = 1; * Constants for the filled variant. */ +const FILLED_LINE_HEIGHT_DELTA = 3; + export const FILLED_LABEL_START_OFFSET_WITH_ACCESSORY = ACCESSORY_SIZE + TEXT_FIELD_ACCESSORY_MARGIN_HORIZONTAL + @@ -71,15 +62,29 @@ export const FILLED_LABEL_START_OFFSET_WITH_ACCESSORY = export const FILLED_ACTIVE_LABEL_TOP_POSITION = TEXT_FIELD_PADDING_VERTICAL; +export const FILLED_INACTIVE_LABEL_TOP_POSITION = Math.ceil( + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 + + BASELINE_TEXT_FIELD_PADDING_VERTICAL) * + fontScale +); + export const FILLED_MULTILINE_PADDING_TOP = Math.ceil(ACTIVE_LABEL_FONT_SIZE * fontScale) + TEXT_FIELD_PADDING_VERTICAL; export const FILLED_DISABLED_CONTAINER_OPACITY = 0.04; +export const FILLED_PADDING_BOTTOM = + TEXT_FIELD_PADDING_VERTICAL + FILLED_LINE_HEIGHT_DELTA; + /** * Constants for the outlined variant. */ +const OUTLINED_LINE_HEIGHT_DELTA = 2; + export const OUTLINED_DISABLED_OUTLINE_OPACITY = 0.12; export const OUTLINED_MULTILINE_PADDING_TOP = Math.ceil( @@ -87,7 +92,7 @@ export const OUTLINED_MULTILINE_PADDING_TOP = Math.ceil( 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - INPUT_FONT_SIZE) / 2 - - LINE_HEIGHT_DELTA) * + OUTLINED_LINE_HEIGHT_DELTA) * fontScale ); @@ -100,7 +105,18 @@ export const OUTLINED_LABEL_START_OFFSET_WITH_ACCESSORY = OUTLINED_LABEL_PADDING_HORIZONTAL; export const OUTLINED_ACTIVE_LABEL_TOP_POSITION = Math.ceil( - (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + LINE_HEIGHT_DELTA) * fontScale + (-BASELINE_TEXT_FIELD_PADDING_VERTICAL + OUTLINED_LINE_HEIGHT_DELTA) * + fontScale +); + +export const OUTLINED_INACTIVE_LABEL_TOP_POSITION = Math.ceil( + ((BASELINE_TEXT_FIELD_HEIGHT - + 2 * BASELINE_TEXT_FIELD_PADDING_VERTICAL - + INPUT_FONT_SIZE) / + 2 + + BASELINE_TEXT_FIELD_PADDING_VERTICAL - + OUTLINED_LINE_HEIGHT_DELTA) * + fontScale ); /** Positive distance; apply sign in animation using `isRTL` from `useLocale`. */ diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index c9568b4405..3f00fd24b9 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -11,7 +11,8 @@ import { FILLED_MULTILINE_PADDING_TOP, INACTIVE_INDICATOR_SIZE, INACTIVE_LABEL_FONT_SIZE, - INACTIVE_LABEL_TOP_POSITION, + FILLED_INACTIVE_LABEL_TOP_POSITION, + OUTLINED_INACTIVE_LABEL_TOP_POSITION, INPUT_FONT_SIZE, LABEL_START_OFFSET_WITHOUT_ACCESSORY, OUTLINED_ACTIVE_LABEL_TOP_POSITION, @@ -23,6 +24,7 @@ import { PREFIX_END_PADDING, SUFFIX_START_PADDING, TEXT_FIELD_BORDER_RADIUS, + FILLED_PADDING_BOTTOM, } from './constants'; import { filledStyles, outlinedStyles, styles } from './styles'; import type { @@ -295,7 +297,12 @@ export const getTextFieldAnimation = ({ ? FILLED_ACTIVE_LABEL_TOP_POSITION : OUTLINED_ACTIVE_LABEL_TOP_POSITION; - const top = isFloating ? activeTop : INACTIVE_LABEL_TOP_POSITION; + const inactiveTop = + variant === 'filled' + ? FILLED_INACTIVE_LABEL_TOP_POSITION + : OUTLINED_INACTIVE_LABEL_TOP_POSITION; + + const top = isFloating ? activeTop : inactiveTop; const fontSize = isFloating ? ACTIVE_LABEL_FONT_SIZE : INACTIVE_LABEL_FONT_SIZE; @@ -419,6 +426,7 @@ export const getFilledTextFieldData = ( const fieldStyles: StyleProp = [ styles.field, { + paddingBottom: FILLED_PADDING_BOTTOM, backgroundColor: fieldBackgroundColor, borderTopStartRadius: TEXT_FIELD_BORDER_RADIUS, borderTopEndRadius: TEXT_FIELD_BORDER_RADIUS, diff --git a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap index da32e01fcf..c4b83fee84 100644 --- a/src/components/__tests__/__snapshots__/TextField.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/TextField.test.tsx.snap @@ -46,6 +46,7 @@ exports[`renders filled TextField with TextField.Icon accessories 1`] = ` "borderTopEndRadius": 4, "borderTopStartRadius": 4, "overflow": "hidden", + "paddingBottom": 19, }, ] } @@ -676,6 +677,7 @@ exports[`renders filled TextField with TextField.Icon accessories when error is "borderTopEndRadius": 4, "borderTopStartRadius": 4, "overflow": "hidden", + "paddingBottom": 19, }, ] } @@ -1306,6 +1308,7 @@ exports[`renders filled TextField with label and value 1`] = ` "borderTopEndRadius": 4, "borderTopStartRadius": 4, "overflow": "hidden", + "paddingBottom": 19, }, ] } From 83224056ee56d6456ab9d22be8295a2f0c21d017 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 20 May 2026 09:50:43 +0200 Subject: [PATCH 17/20] chore: refactor --- src/components/TextField/TextField.tsx | 30 ++ src/components/TextField/hooks.ts | 372 +++++++++++++++++-------- src/components/TextField/utils.ts | 15 + 3 files changed, 302 insertions(+), 115 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index c163235dfd..227b87b43b 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -19,8 +19,29 @@ import Animated, { AnimatedStyle } from 'react-native-reanimated'; import { useTextField } from './hooks'; import { styles } from './styles'; import TextFieldErrorIcon from './TextFieldErrorIcon'; +import { getTextFieldAnimation } from './utils'; import type { InternalTheme, ThemeProp } from '../../types'; +export type TextFieldFlags = { + isRTL: boolean; + isDisabled: boolean; + isEditable: boolean | undefined; + hasError: boolean; + hasCounter: boolean; + hasAccessory: boolean; + isFloating: boolean; + hasPrefix: boolean; + hasSuffix: boolean; +}; + +export type TextFieldColors = { + selectionColor: ColorValue; + cursorColor: ColorValue; + placeholderTextColor: ColorValue; +}; + +export type TextFieldAnimationState = ReturnType; + export type GetAccessibilityDataReturn = { input: AccessibilityProps; supportingText: AccessibilityProps; @@ -95,6 +116,15 @@ export type OutlinedTextFieldHookData = SharedTextFieldStyleData & { inputStyles: StyleProp; }; +export type TextFieldLayoutData = + | FilledTextFieldHookData + | OutlinedTextFieldHookData; + +export type TextFieldLayoutState = Omit< + TextFieldLayoutData, + 'input' | 'isDisabled' | 'hasError' | 'hasSuffix' +>; + export type TextFieldHookReturn = SharedTextFieldStyleData & { input: React.RefObject; isDisabled: boolean; diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 1072111451..775255e86c 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -1,182 +1,324 @@ -import { useImperativeHandle, useRef, useState } from 'react'; +import { + useImperativeHandle, + useMemo, + useRef, + useState, + type RefObject, +} from 'react'; import { BlurEvent, FocusEvent, TextInput } from 'react-native'; import type { + GetAccessibilityDataReturn, + TextFieldAnimationState, + TextFieldColors, + TextFieldFlags, TextFieldHookReturn, + TextFieldLayoutState, TextFieldProps, - TextFieldSharedApi, + TextFieldVariant, } from './TextField'; import { getAccentColors, getAccessibilityData, getFilledTextFieldData, + getLayoutState, getOutlinedTextFieldData, getTextFieldAnimation, } from './utils'; import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; +import type { InternalTheme } from '../../types'; -export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { - const { - ref, - variant = 'filled', - theme: themeOverride, - onFocus, - onBlur, - } = props; +const useTextFieldRef = (ref: TextFieldProps['ref']) => { + const input = useRef(null); - /** - * Hooks - */ + useImperativeHandle(ref, () => input.current as TextInput); - const input = useRef(null); + return input; +}; - const theme = useInternalTheme(themeOverride); +const useTextFieldFocus = ( + props: Pick, + input: RefObject, + isDisabled: boolean +) => { + const [isFocused, setIsFocused] = useState(false); - const { direction } = useLocale(); + const onFocusHandler = (e: FocusEvent) => { + props.onFocus?.(e); + setIsFocused(true); + }; - const [isFocused, setIsFocused] = useState(false); + const onBlurHandler = (e: BlurEvent) => { + props.onBlur?.(e); + setIsFocused(false); + }; - useImperativeHandle(ref, () => input.current as TextInput); + const focusInput = () => { + if (isDisabled) return; + input.current?.focus(); + }; - /** - * Constants - */ + return { + isFocused, + onFocusHandler, + onBlurHandler, + focusInput, + }; +}; +const useTextFieldFlags = ( + props: TextFieldProps, + isFocused: boolean +): TextFieldFlags => { + const { direction } = useLocale(); const isRTL = direction === 'rtl'; - const isDisabled = !!props.disabled; - const isEditable = props.disabled ? false : props.editable; const isFloating = isFocused || !!props.value; - const hasError = !!props.error; - const hasAccessory = isRTL ? !!props.endAccessory : !!props.startAccessory; - const hasPrefix = !!props.prefix && isFloating; - const hasSuffix = !!props.suffix && isFloating; - const hasCounter = !!(props.counter && props.maxLength); - /** - * Theme tokens - */ + return { + isRTL, + isFloating, + isDisabled: !!props.disabled, + isEditable: props.disabled ? false : props.editable, + hasError: !!props.error, + hasCounter: !!(props.counter && props.maxLength), + hasAccessory: isRTL ? !!props.endAccessory : !!props.startAccessory, + hasPrefix: !!props.prefix && isFloating, + hasSuffix: !!props.suffix && isFloating, + }; +}; - const { selectionColor, cursorColor } = getAccentColors({ - theme, - hasError, - }); +const useTextFieldColors = ( + theme: InternalTheme, + hasError: boolean, + placeholderTextColor: TextFieldProps['placeholderTextColor'] +): TextFieldColors => + useMemo( + () => ({ + ...getAccentColors({ theme, hasError }), + placeholderTextColor: + placeholderTextColor ?? theme.colors.onSurfaceVariant, + }), + [theme, hasError, placeholderTextColor] + ); - const placeholderTextColor = - props.placeholderTextColor ?? theme.colors.onSurfaceVariant; +const useTextFieldAnimation = ({ + variant, + isFloating, + isFocused, + isRTL, + hasAccessory, +}: { + variant: TextFieldVariant; + isFloating: boolean; + isFocused: boolean; + isRTL: boolean; + hasAccessory: boolean; +}): TextFieldAnimationState => + useMemo( + () => + getTextFieldAnimation({ + variant, + isFloating, + isFocused, + isRTL, + hasAccessory, + }), + [variant, isFloating, isFocused, isRTL, hasAccessory] + ); - /** - * Label animation - */ +const useTextFieldLayout = ({ + variant, + props, + input, + theme, + flags, + isFocused, + animation, +}: { + variant: TextFieldVariant; + props: TextFieldProps; + input: RefObject; + theme: InternalTheme; + flags: TextFieldFlags; + isFocused: boolean; + animation: TextFieldAnimationState; +}): TextFieldLayoutState => { + const { isRTL, isDisabled, hasError, hasAccessory, hasSuffix, isFloating } = + flags; + + const { multiline } = props; const { animatedLabelWrapperStyle, animatedLabelTextStyle, animatedActiveOutlineStyle, - animatedContainerStyle, - } = getTextFieldAnimation({ - variant, - isFloating, - isFocused, - isRTL, - hasAccessory, - }); + } = animation; + + return useMemo( + () => + getLayoutState( + variant === 'filled' + ? getFilledTextFieldData( + { + input, + theme, + isFocused, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }, + props + ) + : getOutlinedTextFieldData( + { + input, + theme, + isFocused, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }, + props + ) + ), + /** + * `input` is a stable ref. `props` is omitted — only `multiline` affects layout. + * `style` is omitted — assumed stable; dynamic `style` changes won't invalidate layout. + * `isFloating` + `isFocused` cover all animation inputs (replacing individual style objects). + */ + // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment + [ + variant, + theme, + isFocused, + isFloating, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + multiline, + ] + ); +}; - /** - * Handlers - */ +const useTextFieldAccessibility = ( + props: TextFieldProps, + flags: Pick +): GetAccessibilityDataReturn => { + const { hasError, hasCounter, isDisabled } = flags; - const onFocusHandler = (e: FocusEvent) => { - onFocus?.(e); - setIsFocused(true); - }; + const { value, defaultValue, label, supportingText, placeholder, maxLength } = + props; - const onBlurHandler = (e: BlurEvent) => { - onBlur?.(e); - setIsFocused(false); - }; + return useMemo( + () => + getAccessibilityData({ + hasError, + hasCounter, + isDisabled, + data: props, + }), + // `props` is omitted — fields read by `getAccessibilityData` are listed explicitly. + // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment + [ + value, + defaultValue, + label, + supportingText, + placeholder, + maxLength, + hasError, + hasCounter, + isDisabled, + ] + ); +}; - const focusInput = () => { - if (isDisabled) return; - input.current?.focus(); - }; +const useTextFieldCounter = ( + value: TextFieldProps['value'], + maxLength: TextFieldProps['maxLength'] +) => useMemo(() => `${value?.length ?? 0}/${maxLength}`, [value, maxLength]); + +export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { + const { ref, variant = 'filled', theme: themeOverride } = props; + + const input = useTextFieldRef(ref); + + const theme = useInternalTheme(themeOverride); + + const { isFocused, onFocusHandler, onBlurHandler, focusInput } = + useTextFieldFocus(props, input, !!props.disabled); + + const flags = useTextFieldFlags(props, isFocused); - /** - * Shared API - */ + const { selectionColor, cursorColor, placeholderTextColor } = + useTextFieldColors(theme, flags.hasError, props.placeholderTextColor); + + const animation = useTextFieldAnimation({ + variant, + isFloating: flags.isFloating, + isFocused, + isRTL: flags.isRTL, + hasAccessory: flags.hasAccessory, + }); - const api: TextFieldSharedApi = { + const layout = useTextFieldLayout({ + variant, + props, input, theme, + flags, isFocused, - isRTL, - isDisabled, - hasAccessory, - hasError, - hasSuffix, - animatedLabelWrapperStyle, - animatedLabelTextStyle, - animatedActiveOutlineStyle, - }; + animation, + }); - /** - * Components - */ + const accessibilityProps = useTextFieldAccessibility(props, flags); - const renderLeadingAccessory = isRTL + const counterText = useTextFieldCounter(props.value, props.maxLength); + + const renderLeadingAccessory = flags.isRTL ? props.endAccessory : props.startAccessory; - const renderTrailingAccessory = isRTL + const renderTrailingAccessory = flags.isRTL ? props.startAccessory : props.endAccessory; + // https://github.com/facebook/react-native/issues/31573 const placeholder = isFocused ? props.placeholder : ' '; - const counterText = `${props.value?.length ?? 0}/${props.maxLength}`; - /** - * Accessibility - */ - - const accessibilityProps = getAccessibilityData({ - hasError, - hasCounter, - isDisabled, - data: props, - }); - - /** - * Styles - */ - - const data = { - isEditable, - isDisabled, - hasPrefix, - hasCounter, + return { + input, + isDisabled: flags.isDisabled, + isEditable: flags.isEditable, + hasPrefix: flags.hasPrefix, + hasCounter: flags.hasCounter, + hasSuffix: flags.hasSuffix, + hasError: flags.hasError, placeholderTextColor, selectionColor, cursorColor, animatedActiveOutlineStyles: undefined, - animatedContainerStyle, + animatedContainerStyle: animation.animatedContainerStyle, placeholder, counterText, accessibilityProps, + ...layout, renderLeadingAccessory, renderTrailingAccessory, onFocusHandler, onBlurHandler, focusInput, }; - - if (variant === 'filled') { - return { - ...data, - ...getFilledTextFieldData(api, props), - }; - } - - return { - ...data, - ...getOutlinedTextFieldData(api, props), - }; }; diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 3f00fd24b9..2095724e6a 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -30,11 +30,13 @@ import { filledStyles, outlinedStyles, styles } from './styles'; import type { FilledTextFieldHookData, OutlinedTextFieldHookData, + TextFieldLayoutData, TextFieldProps, TextFieldSharedApi, SharedTextFieldStyleData, GetAccessibilityDataProps, GetAccessibilityDataReturn, + TextFieldLayoutState, } from './TextField'; import type { InternalTheme } from '../../types'; @@ -697,3 +699,16 @@ export const getAccessibilityData = ({ }, }; }; + +export const getLayoutState = ( + layout: TextFieldLayoutData +): TextFieldLayoutState => { + const { input, isDisabled, hasError, hasSuffix, ...layoutState } = layout; + + void input; + void isDisabled; + void hasError; + void hasSuffix; + + return layoutState; +}; From 8effa885761364909d093573eb94a2344168c542 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 20 May 2026 10:30:27 +0200 Subject: [PATCH 18/20] feat: uncontrolled variant --- src/components/TextField/TextField.tsx | 19 +++++--- src/components/TextField/hooks.ts | 63 +++++++++++++++++++------- src/components/TextField/utils.ts | 2 - 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 227b87b43b..8003d4602e 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -127,6 +127,7 @@ export type TextFieldLayoutState = Omit< export type TextFieldHookReturn = SharedTextFieldStyleData & { input: React.RefObject; + value: string | undefined; isDisabled: boolean; isEditable: boolean | undefined; hasPrefix: boolean; @@ -155,8 +156,9 @@ export type TextFieldHookReturn = SharedTextFieldStyleData & { renderTrailingAccessory: | ((props: TextFieldAccessoryProps) => React.ReactNode) | undefined; - onFocusHandler: (e: FocusEvent) => void; - onBlurHandler: (e: BlurEvent) => void; + onChangeText: (text: string) => void; + onFocus: (e: FocusEvent) => void; + onBlur: (e: BlurEvent) => void; focusInput: () => void; }; @@ -276,6 +278,7 @@ const DefaultRenderer = (props: TextFieldRenderProps) => ( function TextField(props: TextFieldProps) { /* eslint-disable @typescript-eslint/no-unused-vars -- peel TextField-only props before TextInput spread */ const { + defaultValue: _defaultValue, ref, error, label, @@ -294,6 +297,7 @@ function TextField(props: TextFieldProps) { const { input, + value, isDisabled, isEditable, hasPrefix, @@ -324,8 +328,9 @@ function TextField(props: TextFieldProps) { renderLeadingAccessory, renderTrailingAccessory, focusInput, - onFocusHandler, - onBlurHandler, + onChangeText, + onFocus, + onBlur, } = useTextField(props); return ( @@ -376,12 +381,14 @@ function TextField(props: TextFieldProps) { cursorColor, placeholderTextColor, ...accessibilityProps.input, - onFocus: onFocusHandler, - onBlur: onBlurHandler, ...textInputProps, + value, editable: isEditable, placeholder, style: inputStyles, + onChangeText, + onFocus, + onBlur, })} {hasSuffix && {suffix}} diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 775255e86c..9a4a4c2c6f 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -37,6 +37,28 @@ const useTextFieldRef = (ref: TextFieldProps['ref']) => { return input; }; +const useTextFieldValue = ( + props: Pick +) => { + const isControlled = props.value !== undefined; + + const [uncontrolledValue, setUncontrolledValue] = useState( + isControlled ? props.value : props.defaultValue + ); + + const value = isControlled ? props.value : uncontrolledValue; + + const onChangeText = (text: string) => { + if (!isControlled) { + setUncontrolledValue(text); + } + + props.onChangeText?.(text); + }; + + return { value, onChangeText }; +}; + const useTextFieldFocus = ( props: Pick, input: RefObject, @@ -44,12 +66,12 @@ const useTextFieldFocus = ( ) => { const [isFocused, setIsFocused] = useState(false); - const onFocusHandler = (e: FocusEvent) => { + const onFocus = (e: FocusEvent) => { props.onFocus?.(e); setIsFocused(true); }; - const onBlurHandler = (e: BlurEvent) => { + const onBlur = (e: BlurEvent) => { props.onBlur?.(e); setIsFocused(false); }; @@ -61,19 +83,20 @@ const useTextFieldFocus = ( return { isFocused, - onFocusHandler, - onBlurHandler, + onFocus, + onBlur, focusInput, }; }; const useTextFieldFlags = ( props: TextFieldProps, - isFocused: boolean + isFocused: boolean, + value: TextFieldProps['value'] ): TextFieldFlags => { const { direction } = useLocale(); const isRTL = direction === 'rtl'; - const isFloating = isFocused || !!props.value; + const isFloating = isFocused || !!value; return { isRTL, @@ -215,12 +238,12 @@ const useTextFieldLayout = ({ const useTextFieldAccessibility = ( props: TextFieldProps, + value: TextFieldProps['value'], flags: Pick ): GetAccessibilityDataReturn => { const { hasError, hasCounter, isDisabled } = flags; - const { value, defaultValue, label, supportingText, placeholder, maxLength } = - props; + const { label, supportingText, placeholder, maxLength } = props; return useMemo( () => @@ -228,13 +251,12 @@ const useTextFieldAccessibility = ( hasError, hasCounter, isDisabled, - data: props, + data: { ...props, value }, }), // `props` is omitted — fields read by `getAccessibilityData` are listed explicitly. // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment [ value, - defaultValue, label, supportingText, placeholder, @@ -258,10 +280,15 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const theme = useInternalTheme(themeOverride); - const { isFocused, onFocusHandler, onBlurHandler, focusInput } = - useTextFieldFocus(props, input, !!props.disabled); + const { value, onChangeText } = useTextFieldValue(props); + + const { isFocused, onFocus, onBlur, focusInput } = useTextFieldFocus( + props, + input, + !!props.disabled + ); - const flags = useTextFieldFlags(props, isFocused); + const flags = useTextFieldFlags(props, isFocused, value); const { selectionColor, cursorColor, placeholderTextColor } = useTextFieldColors(theme, flags.hasError, props.placeholderTextColor); @@ -284,9 +311,9 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { animation, }); - const accessibilityProps = useTextFieldAccessibility(props, flags); + const accessibilityProps = useTextFieldAccessibility(props, value, flags); - const counterText = useTextFieldCounter(props.value, props.maxLength); + const counterText = useTextFieldCounter(value, props.maxLength); const renderLeadingAccessory = flags.isRTL ? props.endAccessory @@ -300,6 +327,7 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { return { input, + value, isDisabled: flags.isDisabled, isEditable: flags.isEditable, hasPrefix: flags.hasPrefix, @@ -317,8 +345,9 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { ...layout, renderLeadingAccessory, renderTrailingAccessory, - onFocusHandler, - onBlurHandler, + onChangeText, + onFocus, + onBlur, focusInput, }; }; diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index 2095724e6a..f14fd6fa54 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -635,8 +635,6 @@ export const getAccessibilityData = ({ if (props.value) { textLength = props.value.length; - } else if (props.defaultValue) { - textLength = props.defaultValue.length; } const maxLength = props.maxLength; From 2dbd99d8c975b8bc5a2031a810de51b59cf51412 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 20 May 2026 11:10:19 +0200 Subject: [PATCH 19/20] chore: docs --- docs/src/components/PropTable.tsx | 4 ++-- src/components/TextField/TextField.tsx | 8 +------- src/components/TextField/TextFieldIcon.tsx | 19 +++++++++++++------ src/components/__tests__/TextField.test.tsx | 6 ++---- src/index.tsx | 6 ++++-- 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/docs/src/components/PropTable.tsx b/docs/src/components/PropTable.tsx index 6687256554..05e8f809c4 100644 --- a/docs/src/components/PropTable.tsx +++ b/docs/src/components/PropTable.tsx @@ -12,9 +12,9 @@ const typeDefinitions = { ThemeProp: 'https://callstack.github.io/react-native-paper/docs/guides/theming#theme-properties', '(props: TextFieldAccessoryProps) => React.ReactNode': - 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L25', + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextFieldIcon.tsx#L11', '(props: TextFieldRenderProps) => React.ReactNode': - 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L118', + 'https://github.com/callstack/react-native-paper/blob/main/src/components/TextField/TextField.tsx#L159', AccessibilityState: 'https://reactnative.dev/docs/accessibility#accessibilitystate', 'StyleProp': 'https://reactnative.dev/docs/view-style-props', diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 8003d4602e..eb431d43c8 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -19,6 +19,7 @@ import Animated, { AnimatedStyle } from 'react-native-reanimated'; import { useTextField } from './hooks'; import { styles } from './styles'; import TextFieldErrorIcon from './TextFieldErrorIcon'; +import type { TextFieldAccessoryProps } from './TextFieldIcon'; import { getTextFieldAnimation } from './utils'; import type { InternalTheme, ThemeProp } from '../../types'; @@ -57,13 +58,6 @@ export type GetAccessibilityDataProps = { export type TextFieldVariant = 'filled' | 'outlined'; -export type TextFieldAccessoryProps = { - style: StyleProp; - multiline: boolean; - disabled: boolean; - error: boolean; -}; - export type TextFieldSharedApi = { input: React.RefObject; theme: InternalTheme; diff --git a/src/components/TextField/TextFieldIcon.tsx b/src/components/TextField/TextFieldIcon.tsx index e9b7448e63..86d3a5f2ed 100644 --- a/src/components/TextField/TextFieldIcon.tsx +++ b/src/components/TextField/TextFieldIcon.tsx @@ -1,17 +1,22 @@ import React from 'react'; -import { View } from 'react-native'; +import { StyleProp, View, ViewStyle } from 'react-native'; import { ACCESSORY_SIZE } from './constants'; import { styles } from './styles'; -import type { TextFieldAccessoryProps } from './TextField'; import { getIconColor } from './utils'; import { useInternalTheme } from '../../core/theming'; -import IconButton, { - type Props as IconButtonProps, -} from '../IconButton/IconButton'; +import type { $Omit } from '../../types'; +import IconButton from '../IconButton/IconButton'; + +export type TextFieldAccessoryProps = { + style: StyleProp; + multiline: boolean; + disabled: boolean; + error: boolean; +}; export type TextFieldIconProps = TextFieldAccessoryProps & - Omit; + $Omit, keyof TextFieldAccessoryProps>; /** * A component to render a leading / trailing icon in the TextField @@ -47,6 +52,8 @@ export type TextFieldIconProps = TextFieldAccessoryProps & * * export default MyComponent; * ``` + * + * @extends IconButton props https://callstack.github.io/react-native-paper/docs/components/IconButton */ const TextFieldIcon = ({ icon, diff --git a/src/components/__tests__/TextField.test.tsx b/src/components/__tests__/TextField.test.tsx index 5b8f9acbb3..b73ba5cc16 100644 --- a/src/components/__tests__/TextField.test.tsx +++ b/src/components/__tests__/TextField.test.tsx @@ -4,10 +4,8 @@ import { I18nManager, StyleSheet, TextInput, View } from 'react-native'; import { fireEvent, render } from '../../test-utils'; import { tokens } from '../../theme/tokens'; import TextField from '../TextField'; -import type { - TextFieldAccessoryProps, - TextFieldRenderProps, -} from '../TextField/TextField'; +import type { TextFieldRenderProps } from '../TextField/TextField'; +import type { TextFieldAccessoryProps } from '../TextField/TextFieldIcon'; const { stateOpacity } = tokens.md.ref; diff --git a/src/index.tsx b/src/index.tsx index e55021f12a..a6ba4bf99e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -133,12 +133,14 @@ export type { Props as TextInputProps } from './components/TextInput/TextInput'; export type { Props as TextInputAffixProps } from './components/TextInput/Adornment/TextInputAffix'; export type { Props as TextInputIconProps } from './components/TextInput/Adornment/TextInputIcon'; export type { - TextFieldAccessoryProps, TextFieldProps, TextFieldRenderProps, TextFieldVariant, } from './components/TextField/TextField'; -export type { TextFieldIconProps } from './components/TextField/TextFieldIcon'; +export type { + TextFieldAccessoryProps, + TextFieldIconProps, +} from './components/TextField/TextFieldIcon'; export type { Props as ToggleButtonProps } from './components/ToggleButton/ToggleButton'; export type { Props as ToggleButtonGroupProps } from './components/ToggleButton/ToggleButtonGroup'; export type { Props as ToggleButtonRowProps } from './components/ToggleButton/ToggleButtonRow'; From 0f534fd0da0d4f62188286bd2af237672ae4d893 Mon Sep 17 00:00:00 2001 From: Michal Lul Date: Wed, 20 May 2026 11:38:16 +0200 Subject: [PATCH 20/20] chore: refactor --- src/components/TextField/hooks.ts | 196 ++++++++++-------------------- src/components/TextField/utils.ts | 15 --- 2 files changed, 67 insertions(+), 144 deletions(-) diff --git a/src/components/TextField/hooks.ts b/src/components/TextField/hooks.ts index 9a4a4c2c6f..60272d808e 100644 --- a/src/components/TextField/hooks.ts +++ b/src/components/TextField/hooks.ts @@ -8,9 +8,7 @@ import { import { BlurEvent, FocusEvent, TextInput } from 'react-native'; import type { - GetAccessibilityDataReturn, TextFieldAnimationState, - TextFieldColors, TextFieldFlags, TextFieldHookReturn, TextFieldLayoutState, @@ -21,7 +19,6 @@ import { getAccentColors, getAccessibilityData, getFilledTextFieldData, - getLayoutState, getOutlinedTextFieldData, getTextFieldAnimation, } from './utils'; @@ -29,14 +26,6 @@ import { useLocale } from '../../core/locale'; import { useInternalTheme } from '../../core/theming'; import type { InternalTheme } from '../../types'; -const useTextFieldRef = (ref: TextFieldProps['ref']) => { - const input = useRef(null); - - useImperativeHandle(ref, () => input.current as TextInput); - - return input; -}; - const useTextFieldValue = ( props: Pick ) => { @@ -95,6 +84,7 @@ const useTextFieldFlags = ( value: TextFieldProps['value'] ): TextFieldFlags => { const { direction } = useLocale(); + const isRTL = direction === 'rtl'; const isFloating = isFocused || !!value; @@ -111,45 +101,6 @@ const useTextFieldFlags = ( }; }; -const useTextFieldColors = ( - theme: InternalTheme, - hasError: boolean, - placeholderTextColor: TextFieldProps['placeholderTextColor'] -): TextFieldColors => - useMemo( - () => ({ - ...getAccentColors({ theme, hasError }), - placeholderTextColor: - placeholderTextColor ?? theme.colors.onSurfaceVariant, - }), - [theme, hasError, placeholderTextColor] - ); - -const useTextFieldAnimation = ({ - variant, - isFloating, - isFocused, - isRTL, - hasAccessory, -}: { - variant: TextFieldVariant; - isFloating: boolean; - isFocused: boolean; - isRTL: boolean; - hasAccessory: boolean; -}): TextFieldAnimationState => - useMemo( - () => - getTextFieldAnimation({ - variant, - isFloating, - isFocused, - isRTL, - hasAccessory, - }), - [variant, isFloating, isFocused, isRTL, hasAccessory] - ); - const useTextFieldLayout = ({ variant, props, @@ -179,42 +130,54 @@ const useTextFieldLayout = ({ } = animation; return useMemo( - () => - getLayoutState( - variant === 'filled' - ? getFilledTextFieldData( - { - input, - theme, - isFocused, - isRTL, - isDisabled, - hasAccessory, - hasError, - hasSuffix, - animatedLabelWrapperStyle, - animatedLabelTextStyle, - animatedActiveOutlineStyle, - }, - props - ) - : getOutlinedTextFieldData( - { - input, - theme, - isFocused, - isRTL, - isDisabled, - hasAccessory, - hasError, - hasSuffix, - animatedLabelWrapperStyle, - animatedLabelTextStyle, - animatedActiveOutlineStyle, - }, - props - ) - ), + () => { + const { + input: _input, + isDisabled: _isDisabled, + hasError: _hasError, + hasSuffix: _hasSuffix, + ...layout + } = variant === 'filled' + ? getFilledTextFieldData( + { + input, + theme, + isFocused, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }, + props + ) + : getOutlinedTextFieldData( + { + input, + theme, + isFocused, + isRTL, + isDisabled, + hasAccessory, + hasError, + hasSuffix, + animatedLabelWrapperStyle, + animatedLabelTextStyle, + animatedActiveOutlineStyle, + }, + props + ); + + void _input; + void _isDisabled; + void _hasError; + void _hasSuffix; + + return layout; + }, /** * `input` is a stable ref. `props` is omitted — only `multiline` affects layout. * `style` is omitted — assumed stable; dynamic `style` changes won't invalidate layout. @@ -236,47 +199,10 @@ const useTextFieldLayout = ({ ); }; -const useTextFieldAccessibility = ( - props: TextFieldProps, - value: TextFieldProps['value'], - flags: Pick -): GetAccessibilityDataReturn => { - const { hasError, hasCounter, isDisabled } = flags; - - const { label, supportingText, placeholder, maxLength } = props; - - return useMemo( - () => - getAccessibilityData({ - hasError, - hasCounter, - isDisabled, - data: { ...props, value }, - }), - // `props` is omitted — fields read by `getAccessibilityData` are listed explicitly. - // eslint-disable-next-line react-hooks/exhaustive-deps -- see comment - [ - value, - label, - supportingText, - placeholder, - maxLength, - hasError, - hasCounter, - isDisabled, - ] - ); -}; - -const useTextFieldCounter = ( - value: TextFieldProps['value'], - maxLength: TextFieldProps['maxLength'] -) => useMemo(() => `${value?.length ?? 0}/${maxLength}`, [value, maxLength]); - export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const { ref, variant = 'filled', theme: themeOverride } = props; - const input = useTextFieldRef(ref); + const input = useRef(null); const theme = useInternalTheme(themeOverride); @@ -290,10 +216,17 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { const flags = useTextFieldFlags(props, isFocused, value); - const { selectionColor, cursorColor, placeholderTextColor } = - useTextFieldColors(theme, flags.hasError, props.placeholderTextColor); + useImperativeHandle(ref, () => input.current as TextInput); + + const { selectionColor, cursorColor } = getAccentColors({ + theme, + hasError: flags.hasError, + }); - const animation = useTextFieldAnimation({ + const placeholderTextColor = + props.placeholderTextColor ?? theme.colors.onSurfaceVariant; + + const animation = getTextFieldAnimation({ variant, isFloating: flags.isFloating, isFocused, @@ -311,9 +244,14 @@ export const useTextField = (props: TextFieldProps): TextFieldHookReturn => { animation, }); - const accessibilityProps = useTextFieldAccessibility(props, value, flags); + const accessibilityProps = getAccessibilityData({ + hasError: flags.hasError, + hasCounter: flags.hasCounter, + isDisabled: flags.isDisabled, + data: { ...props, value }, + }); - const counterText = useTextFieldCounter(value, props.maxLength); + const counterText = `${value?.length ?? 0}/${props.maxLength}`; const renderLeadingAccessory = flags.isRTL ? props.endAccessory diff --git a/src/components/TextField/utils.ts b/src/components/TextField/utils.ts index f14fd6fa54..3491543ec8 100644 --- a/src/components/TextField/utils.ts +++ b/src/components/TextField/utils.ts @@ -30,13 +30,11 @@ import { filledStyles, outlinedStyles, styles } from './styles'; import type { FilledTextFieldHookData, OutlinedTextFieldHookData, - TextFieldLayoutData, TextFieldProps, TextFieldSharedApi, SharedTextFieldStyleData, GetAccessibilityDataProps, GetAccessibilityDataReturn, - TextFieldLayoutState, } from './TextField'; import type { InternalTheme } from '../../types'; @@ -697,16 +695,3 @@ export const getAccessibilityData = ({ }, }; }; - -export const getLayoutState = ( - layout: TextFieldLayoutData -): TextFieldLayoutState => { - const { input, isDisabled, hasError, hasSuffix, ...layoutState } = layout; - - void input; - void isDisabled; - void hasError; - void hasSuffix; - - return layoutState; -};