Skip to content

Commit 774e089

Browse files
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
1 parent 0d057c0 commit 774e089

4 files changed

Lines changed: 138 additions & 8 deletions

File tree

2.03 KB
Loading
2.03 KB
Loading

‎packages/react/src/components/presentation/auth/AuthOptionFactory.tsx‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ export type AuthType = 'signin' | 'signup' | 'recovery';
114114
/**
115115
* Get the appropriate FieldType for an input component.
116116
*/
117+
/** Upper bound on STACK grid slots, so a mistyped `items` cannot render thousands of cells. */
118+
const MAX_STACK_ITEMS = 12;
119+
117120
const getFieldType = (variant: EmbeddedFlowComponentType): FieldType => {
118121
switch (variant) {
119122
case EmbeddedFlowComponentType.EmailInput:
@@ -726,14 +729,48 @@ const createAuthComponentFromFlow = (
726729
const align: string = (component as any).align || 'center';
727730
const justify: string = (component as any).justify || 'flex-start';
728731

729-
const stackStyle: CSSProperties = {
730-
alignItems: align,
731-
display: 'flex',
732-
flexDirection: direction as CSSProperties['flexDirection'],
733-
flexWrap: 'wrap',
734-
gap: `${gap * 0.5}rem`,
735-
justifyContent: justify,
736-
};
732+
// `items` is the number of slots across the main axis and `direction` picks
733+
// that axis: with `row` it is the column count (items: 2 with four buttons
734+
// renders a 2 x 2 grid), and with `column` it is the row count and children
735+
// flow into further columns. Fewer than two slots keeps the flex layout, so
736+
// stacks authored before grid support are unaffected.
737+
const rawItems: string | number | undefined = component.items;
738+
const parsedItems: number =
739+
typeof rawItems === 'string' ? Number(rawItems) : typeof rawItems === 'number' ? rawItems : NaN;
740+
const items: number | null =
741+
Number.isFinite(parsedItems) && Math.floor(parsedItems) >= 2
742+
? Math.min(Math.floor(parsedItems), MAX_STACK_ITEMS)
743+
: null;
744+
// CSS Grid has no reverse auto-flow, so the reverse variants fall back to
745+
// their base axis rather than silently becoming a row.
746+
const isColumn: boolean = direction.startsWith('column');
747+
const gridJustify: string = component.justify ?? 'stretch';
748+
// Equal `1fr` tracks fill the container, leaving `justify-content` no free
749+
// space to distribute. Content-sized tracks restore it, so every justify
750+
// value has a visible effect while the default stays an even split.
751+
const track: string = gridJustify === 'stretch' ? '1fr' : 'auto';
752+
753+
const stackStyle: CSSProperties =
754+
items !== null
755+
? {
756+
alignItems: component.align ?? 'stretch',
757+
display: 'grid',
758+
gap: `${gap * 0.5}rem`,
759+
gridAutoFlow: isColumn ? 'column' : 'row',
760+
justifyContent: gridJustify,
761+
width: '100%',
762+
...(isColumn
763+
? {gridTemplateRows: `repeat(${items}, ${track})`}
764+
: {gridTemplateColumns: `repeat(${items}, ${track})`}),
765+
}
766+
: {
767+
alignItems: align,
768+
display: 'flex',
769+
flexDirection: direction as CSSProperties['flexDirection'],
770+
flexWrap: 'wrap',
771+
gap: `${gap * 0.5}rem`,
772+
justifyContent: justify,
773+
};
737774

738775
const stackChildren: (ReactElement | null)[] = component.components
739776
? component.components.map((childComponent: any, index: number) =>

‎packages/react/src/components/presentation/auth/__tests__/AuthOptionFactory.test.tsx‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,96 @@ describe('AuthOptionFactory rich-text action', () => {
173173
expect(() => container.querySelector<HTMLAnchorElement>('a')!.click()).not.toThrow();
174174
});
175175
});
176+
177+
describe('AuthOptionFactory stack grid layout', () => {
178+
const stackWith = (extra: Record<string, unknown>, childCount = 4): EmbeddedFlowComponent =>
179+
({
180+
components: Array.from({length: childCount}, (_, index) => ({
181+
id: `text_${index}`,
182+
label: `<p>Option ${index}</p>`,
183+
type: EmbeddedFlowComponentType.RichText,
184+
})),
185+
id: 'stack_1',
186+
type: EmbeddedFlowComponentType.Stack,
187+
...extra,
188+
}) as EmbeddedFlowComponent;
189+
190+
const stackElement = (component: EmbeddedFlowComponent): HTMLElement => {
191+
const {container} = renderInto(component);
192+
return container.querySelector<HTMLElement>('#stack_1')!;
193+
};
194+
195+
it('renders a grid with the configured number of columns when items is 2', () => {
196+
const stack = stackElement(stackWith({items: 2}));
197+
198+
expect(stack.style.display).toBe('grid');
199+
expect(stack.style.gridTemplateColumns).toBe('repeat(2, 1fr)');
200+
expect(stack.children).toHaveLength(4);
201+
});
202+
203+
it('supports arbitrary column counts provided as a numeric string', () => {
204+
const stack = stackElement(stackWith({items: '3'}, 5));
205+
206+
expect(stack.style.display).toBe('grid');
207+
expect(stack.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
208+
expect(stack.children).toHaveLength(5);
209+
});
210+
211+
it('keeps the flex layout when items is absent', () => {
212+
const stack = stackElement(stackWith({direction: 'column'}));
213+
214+
expect(stack.style.display).toBe('flex');
215+
expect(stack.style.flexDirection).toBe('column');
216+
});
217+
218+
it('keeps the flex layout when items is 1', () => {
219+
// Stacks authored in the flow builder are seeded with items 1, so a single slot
220+
// must not promote them to a grid.
221+
const stack = stackElement(stackWith({items: 1}));
222+
223+
expect(stack.style.display).toBe('flex');
224+
});
225+
226+
it('falls back to the base axis for the reverse directions', () => {
227+
expect(stackElement(stackWith({direction: 'column-reverse', items: 2})).style.gridAutoFlow).toBe('column');
228+
expect(stackElement(stackWith({direction: 'row-reverse', items: 2})).style.gridAutoFlow).toBe('row');
229+
});
230+
231+
it('uses content-sized tracks when justify has to distribute free space', () => {
232+
const stack = stackElement(stackWith({items: 2, justify: 'space-between'}));
233+
234+
expect(stack.style.gridTemplateColumns).toBe('repeat(2, auto)');
235+
expect(stack.style.justifyContent).toBe('space-between');
236+
});
237+
238+
it('keeps equal tracks when justify is unset', () => {
239+
expect(stackElement(stackWith({items: 2})).style.gridTemplateColumns).toBe('repeat(2, 1fr)');
240+
});
241+
242+
it('clamps absurd slot counts', () => {
243+
const stack = stackElement(stackWith({items: 5000}));
244+
245+
expect(stack.style.gridTemplateColumns).toBe('repeat(12, 1fr)');
246+
});
247+
248+
it('treats items as the row count when direction is column', () => {
249+
const stack = stackElement(stackWith({direction: 'column', items: 2}));
250+
251+
expect(stack.style.display).toBe('grid');
252+
expect(stack.style.gridTemplateRows).toBe('repeat(2, 1fr)');
253+
expect(stack.style.gridAutoFlow).toBe('column');
254+
expect(stack.style.gridTemplateColumns).toBe('');
255+
});
256+
257+
it('falls back to the flex layout when items is not numeric', () => {
258+
const stack = stackElement(stackWith({items: 'garbage'}));
259+
260+
expect(stack.style.display).toBe('flex');
261+
});
262+
263+
it('falls back to the flex layout when items is a malformed numeric string', () => {
264+
const stack = stackElement(stackWith({items: '2invalid'}));
265+
266+
expect(stack.style.display).toBe('flex');
267+
});
268+
});

0 commit comments

Comments
 (0)