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 47767d2..0000000 Binary files a/packages/react/.vitest-attachments/27677941df85b03d5d2cfa78a06ab03580dd9ac6.png and /dev/null differ diff --git a/packages/react/.vitest-attachments/3dab8c5a94eed1b7587e7dbb5fed1db2c644801d.png b/packages/react/.vitest-attachments/3dab8c5a94eed1b7587e7dbb5fed1db2c644801d.png deleted file mode 100644 index 539d54c..0000000 Binary files a/packages/react/.vitest-attachments/3dab8c5a94eed1b7587e7dbb5fed1db2c644801d.png and /dev/null differ diff --git a/packages/react/.vitest-attachments/5a7d509e64e503baebcb9f280cb5bd323b6d722e.png b/packages/react/.vitest-attachments/5a7d509e64e503baebcb9f280cb5bd323b6d722e.png deleted file mode 100644 index 47767d2..0000000 Binary files a/packages/react/.vitest-attachments/5a7d509e64e503baebcb9f280cb5bd323b6d722e.png and /dev/null differ diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index eb1d1c5..8bffa36 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -114,6 +114,9 @@ export type AuthType = 'signin' | 'signup' | 'recovery'; /** * Get the appropriate FieldType for an input component. */ +/** Upper bound on STACK grid slots, so a mistyped `items` cannot render thousands of cells. */ +const MAX_STACK_ITEMS = 12; + const getFieldType = (variant: EmbeddedFlowComponentType): FieldType => { 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'); + }); +});