From ba92aa123048a6d599ec2feb6ca04337c0a6110c Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Mon, 27 Jul 2026 10:46:07 +0530 Subject: [PATCH] Support grid layouts in the STACK flow component Honor the items property on STACK flow elements so children can be laid out in an n-column grid (e.g. a 2 x 2 grid with items: 2). Non-numeric or absent values keep the existing flex layout. Refs thunder-id/thunderid#3703 --- .gitignore | 3 + ...677941df85b03d5d2cfa78a06ab03580dd9ac6.png | Bin 2082 -> 0 bytes ...ab8c5a94eed1b7587e7dbb5fed1db2c644801d.png | Bin 6923 -> 0 bytes ...7d509e64e503baebcb9f280cb5bd323b6d722e.png | Bin 2082 -> 0 bytes .../presentation/auth/AuthOptionFactory.tsx | 53 ++++++++-- .../auth/__tests__/AuthOptionFactory.test.tsx | 93 ++++++++++++++++++ 6 files changed, 141 insertions(+), 8 deletions(-) delete mode 100644 packages/react/.vitest-attachments/27677941df85b03d5d2cfa78a06ab03580dd9ac6.png delete mode 100644 packages/react/.vitest-attachments/3dab8c5a94eed1b7587e7dbb5fed1db2c644801d.png delete mode 100644 packages/react/.vitest-attachments/5a7d509e64e503baebcb9f280cb5bd323b6d722e.png diff --git a/.gitignore b/.gitignore index 005cdd1..77f15ed 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,9 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ +# Vitest test attachments +.vitest-attachments/ + # OS generated files .DS_Store Thumbs.db diff --git a/packages/react/.vitest-attachments/27677941df85b03d5d2cfa78a06ab03580dd9ac6.png b/packages/react/.vitest-attachments/27677941df85b03d5d2cfa78a06ab03580dd9ac6.png deleted file mode 100644 index 47767d2f3db3e57c7b5cb7d5e6f5d4e0ed04e8ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2082 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

KT=a_ssj|k9# zV|LklG(nsg;0s;cR|b)dP58*qzV%JfH83zV6s|~sJpKLQX?nbIfU)l8CZl>^7Uhb7 zJl_S7ljh!8;X~gP(vGIn<_s+$G*NVPAq&nS2?5&1htBSx?gV3{}pm2gN|W!06S9+FH@TmpaUF4UbxE z9B7;1>NOmS`BY@8YLCPFKdEnLK3HI=9d}|q2Tjk?QfBgHBtt0 zu>>U@yYJe8I`~FO;}U>kz!d17sWYCtZguHkuWC!-A z2xmy#;`paepJWUfZEY>S7{U1*c3#Ay@d1>WSVs4#QAo)Q;wZdurZxN9)9@0kfVZT%;uzZdbH%=w3y_7**g~z|F(f1~&^rYw) zGGp%}wI4kqMF(}RX?LCD33zSsP%q_szICwrmqMQ;7KvpFheOG59&$ry#k1lA*0O;c zCam4uH1=X|7V8P{tz<()6MxP<=3m?n#7$*oR$Ye}l7o9i$4afvT3C2Q=9=q-``$&Y zG@|MWAbx&X8R+PCHLO~uFbTn{n?V9^o4(7cu`aU`gdc{-A&c|(XHKVRCe8N9H~9S7 z-D%0fS1i_fXk4kakoP+)sX<9t_5d`5$vDkL0Nkw{g1(R!yzxd1P(6l*fN%+neg_AaMb;KLhFAZxq{ zflVW!0|tCJ>56!+6EQ~OY3wAqkllWCiD*s7h!34bqtQC%;q{=k$LOGwa7h~7TkLl0 zMBxJPBPt_DhjDhuo8o5FqrICND|{S9@OZH}xceZ-af}$&{tLy@$`l$)VISc{9A!V@ z8q7%bpznH3j7Dvo=IV_X28)(eHLc@5_Z=<%sI9FnRR6fT+MuhhhhFZ-5)&9m~MMD@Ox7gmEHA! zdjU1K8YX)3SlxGc(MMX!-g4S}#*zr5aQ)I}B}5$0f>iY0G9s?$%3?zXErB}ow*aoW zBX6d~QyHDJvE8tr$>{sCFz4H(bn=cwgwI&EE~m1x0ybh*+H12GeAD_%$C=`nu0I}7 zzW+laQ6~)rLDm#0J~R&ZFkoyVwUDSwOrs@MYnuQRf#+>CtHDQShi!p24HQZmxt9Uw z4{)oZ#Nk7WY|}qswQ&ABp?ob7hdq7sgWQLzg%Zn7s4%i9nBy@AAiLtTsU-?7o zeJy}L$9NS6H5EBZ@HL^(kMv%&bssrxLHZi^-kJ{~8HzuW!F z>4aVmKZ;V@({tqqz2OZuwz830h zp}yklKQVj1BIPSmz9Qu-QvTOX(0`Ky9iyhAqEtd!qqMhHTHpRXzpAPn4kM-JYmjbl QMO3a|b-Yr0`EK&R0oMU`#Q*>R diff --git a/packages/react/.vitest-attachments/5a7d509e64e503baebcb9f280cb5bd323b6d722e.png b/packages/react/.vitest-attachments/5a7d509e64e503baebcb9f280cb5bd323b6d722e.png deleted file mode 100644 index 47767d2f3db3e57c7b5cb7d5e6f5d4e0ed04e8ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2082 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1QeOwv~@BA1N(JP7srr_Id3jA@-i53 z95DEFXMgI4ja%e?m6tbcF?X2z{K9j~FO__?^PK08GDpKv_u@O4hD6# qC=0CM|NmbQa_}pV!3L`0nHgejI2P=w?qmguFnGH9xvX { switch (variant) { case EmbeddedFlowComponentType.EmailInput: @@ -726,14 +729,48 @@ const createAuthComponentFromFlow = ( const align: string = (component as any).align || 'center'; const justify: string = (component as any).justify || 'flex-start'; - const stackStyle: CSSProperties = { - alignItems: align, - display: 'flex', - flexDirection: direction as CSSProperties['flexDirection'], - flexWrap: 'wrap', - gap: `${gap * 0.5}rem`, - justifyContent: justify, - }; + // `items` is the number of slots across the main axis and `direction` picks + // that axis: with `row` it is the column count (items: 2 with four buttons + // renders a 2 x 2 grid), and with `column` it is the row count and children + // flow into further columns. Fewer than two slots keeps the flex layout, so + // stacks authored before grid support are unaffected. + const rawItems: string | number | undefined = component.items; + const parsedItems: number = + typeof rawItems === 'string' ? Number(rawItems) : typeof rawItems === 'number' ? rawItems : NaN; + const items: number | null = + Number.isFinite(parsedItems) && Math.floor(parsedItems) >= 2 + ? Math.min(Math.floor(parsedItems), MAX_STACK_ITEMS) + : null; + // CSS Grid has no reverse auto-flow, so the reverse variants fall back to + // their base axis rather than silently becoming a row. + const isColumn: boolean = direction.startsWith('column'); + const gridJustify: string = component.justify ?? 'stretch'; + // Equal `1fr` tracks fill the container, leaving `justify-content` no free + // space to distribute. Content-sized tracks restore it, so every justify + // value has a visible effect while the default stays an even split. + const track: string = gridJustify === 'stretch' ? '1fr' : 'auto'; + + const stackStyle: CSSProperties = + items !== null + ? { + alignItems: component.align ?? 'stretch', + display: 'grid', + gap: `${gap * 0.5}rem`, + gridAutoFlow: isColumn ? 'column' : 'row', + justifyContent: gridJustify, + width: '100%', + ...(isColumn + ? {gridTemplateRows: `repeat(${items}, ${track})`} + : {gridTemplateColumns: `repeat(${items}, ${track})`}), + } + : { + alignItems: align, + display: 'flex', + flexDirection: direction as CSSProperties['flexDirection'], + flexWrap: 'wrap', + gap: `${gap * 0.5}rem`, + justifyContent: justify, + }; const stackChildren: (ReactElement | null)[] = component.components ? component.components.map((childComponent: any, index: number) => diff --git a/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx b/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx index 9783293..45b9de1 100644 --- a/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx +++ b/packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx @@ -173,3 +173,96 @@ describe('AuthOptionFactory rich-text action', () => { expect(() => container.querySelector('a')!.click()).not.toThrow(); }); }); + +describe('AuthOptionFactory stack grid layout', () => { + const stackWith = (extra: Record, childCount = 4): EmbeddedFlowComponent => + ({ + components: Array.from({length: childCount}, (_, index) => ({ + id: `text_${index}`, + label: `

Option ${index}

`, + type: EmbeddedFlowComponentType.RichText, + })), + id: 'stack_1', + type: EmbeddedFlowComponentType.Stack, + ...extra, + }) as EmbeddedFlowComponent; + + const stackElement = (component: EmbeddedFlowComponent): HTMLElement => { + const {container} = renderInto(component); + return container.querySelector('#stack_1')!; + }; + + it('renders a grid with the configured number of columns when items is 2', () => { + const stack = stackElement(stackWith({items: 2})); + + expect(stack.style.display).toBe('grid'); + expect(stack.style.gridTemplateColumns).toBe('repeat(2, 1fr)'); + expect(stack.children).toHaveLength(4); + }); + + it('supports arbitrary column counts provided as a numeric string', () => { + const stack = stackElement(stackWith({items: '3'}, 5)); + + expect(stack.style.display).toBe('grid'); + expect(stack.style.gridTemplateColumns).toBe('repeat(3, 1fr)'); + expect(stack.children).toHaveLength(5); + }); + + it('keeps the flex layout when items is absent', () => { + const stack = stackElement(stackWith({direction: 'column'})); + + expect(stack.style.display).toBe('flex'); + expect(stack.style.flexDirection).toBe('column'); + }); + + it('keeps the flex layout when items is 1', () => { + // Stacks authored in the flow builder are seeded with items 1, so a single slot + // must not promote them to a grid. + const stack = stackElement(stackWith({items: 1})); + + expect(stack.style.display).toBe('flex'); + }); + + it('falls back to the base axis for the reverse directions', () => { + expect(stackElement(stackWith({direction: 'column-reverse', items: 2})).style.gridAutoFlow).toBe('column'); + expect(stackElement(stackWith({direction: 'row-reverse', items: 2})).style.gridAutoFlow).toBe('row'); + }); + + it('uses content-sized tracks when justify has to distribute free space', () => { + const stack = stackElement(stackWith({items: 2, justify: 'space-between'})); + + expect(stack.style.gridTemplateColumns).toBe('repeat(2, auto)'); + expect(stack.style.justifyContent).toBe('space-between'); + }); + + it('keeps equal tracks when justify is unset', () => { + expect(stackElement(stackWith({items: 2})).style.gridTemplateColumns).toBe('repeat(2, 1fr)'); + }); + + it('clamps absurd slot counts', () => { + const stack = stackElement(stackWith({items: 5000})); + + expect(stack.style.gridTemplateColumns).toBe('repeat(12, 1fr)'); + }); + + it('treats items as the row count when direction is column', () => { + const stack = stackElement(stackWith({direction: 'column', items: 2})); + + expect(stack.style.display).toBe('grid'); + expect(stack.style.gridTemplateRows).toBe('repeat(2, 1fr)'); + expect(stack.style.gridAutoFlow).toBe('column'); + expect(stack.style.gridTemplateColumns).toBe(''); + }); + + it('falls back to the flex layout when items is not numeric', () => { + const stack = stackElement(stackWith({items: 'garbage'})); + + expect(stack.style.display).toBe('flex'); + }); + + it('falls back to the flex layout when items is a malformed numeric string', () => { + const stack = stackElement(stackWith({items: '2invalid'})); + + expect(stack.style.display).toBe('flex'); + }); +});