Skip to content

packages/core/src/lib/layout/flex.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import { preparedStyleValue, preparedStyleQuery, type PreparedQuery } from '../style/responsive.js';
2 import { definePrimitive } from '../define-primitive.js';
3 import { clonePibblElement } from '../element/create-element.js';
4 import { getChildPrimitiveReceiverToken } from '../element/metadata.js';
5 import type { PibblElement } from '../element/types.js';
6 import { GLOBAL_STATE } from '../global-state.js';
7 import { layoutDiagnostic } from '../style/diagnostics.js';
8 import {
9   type PibblStructuralEventProps,
10   wireStructuralEvents,
11 } from '../components/structural-events.js';
12 import { normalizeEdges } from '../style/normalize.js';
13 import { resolveLength } from '../style/resolve-length.js';
14 import { setPreNormalizedStyle } from '../style/resolve-dispatch.js';
15 import type {
16   BoxStyle,
17   FlexItemStyle,
18   LayoutItemStyle,
19   ResolvedBoxStyle,
20 } from '../style/types.js';
21 import type {
22   CanvasMeasurementService,
23   PibblNodeInput,
24   MeasureInput,
25   MeasureResult,
26   RenderingContext2D,
27   SystemStyle,
28 } from '../types.js';
29 import {
30   childWithUsedSize,
31   prepareDirectChildStyle,
32   materializeLayoutChildren,
33   placeDirectChildren,
34   prepareLayoutChildren,
35   resolveContainerBox,
36 } from './absolute.js';
37 import { pushLayoutBox } from './context.js';
38 import { recordLayoutEvaluation } from './diagnostics.js';
39 import { assertSupportedChildPosition } from './positioning-diagnostics.js';
40 import { measureElement } from './measure.js';
41 import type { Constraints, LayoutBox } from './types.js';
42 
43 type FlexDirection = 'row' | 'column';
44 type FlexWrap = 'nowrap' | 'wrap';
45 type MainAlignment =
46   | 'flex-start'
47   | 'flex-end'
48   | 'center'
49   | 'space-between'
50   | 'space-around'
51   | 'space-evenly';
52 type CrossAlignment = 'stretch' | 'flex-start' | 'flex-end' | 'center';
53 
54 /**
55  * Supported geometry and presentation properties for Flex.
56  *
57  * @see {@link BoxStyle}
58  * @see {@link LayoutItemStyle}
59  * @see {@link Flex}
60  */
61 export interface FlexStyle extends BoxStyle, LayoutItemStyle {
62   /** The main axis along which flex children are placed. See {@link FlexDirection}. */
63   flexDirection?: FlexDirection;
64   /** Whether children may wrap onto additional flex lines. See {@link FlexWrap}. */
65   flexWrap?: FlexWrap;
66   /** Distribution of children along the main axis. See {@link MainAlignment}. */
67   justifyContent?: MainAlignment;
68   /** Default alignment of children on the cross or block axis. See {@link CrossAlignment}. */
69   alignItems?: CrossAlignment;
70   /**
71    * Distribution of wrapped flex lines within the available cross-axis space. See
72    * {@link CrossAlignment}, {@link MainAlignment}.
73    */
74   alignContent?: CrossAlignment | MainAlignment;
75   /** Spacing between adjacent items. See {@link FlexStyle}. */
76   gap?: number;
77   /** Space between adjacent rows or items on the row axis. See {@link FlexStyle}. */
78   rowGap?: number;
79   /** Space between adjacent columns or items on the column axis. See {@link FlexStyle}. */
80   columnGap?: number;
81 }
82 
83 /**
84  * Authored inputs for Flex, including the declared data and presentation options.
85  *
86  * @see {@link PibblNodeInput}
87  * @see {@link Flex}
88  */
89 export interface FlexProps extends PibblStructuralEventProps {
90   /** Descendant content or the callback that supplies it. See {@link PibblNodeInput}. */
91   children: PibblNodeInput;
92 }
93 
94 export interface FlexMeasurementInput {
95   service: CanvasMeasurementService;
96   measureElement(
97     element: PibblElement<any>,
98     constraints: Readonly<Constraints>,
99   ): MeasureResult;
100 }
101 
102 interface ResolvedFlexStyle extends SystemStyle {
103   box: ResolvedBoxStyle;
104   overflow: 'visible' | 'clip';
105   flexDirection: FlexDirection;
106   flexWrap: FlexWrap;
107   justifyContent: MainAlignment;
108   alignItems: CrossAlignment;
109   alignContent: CrossAlignment | MainAlignment;
110   rowGap: number;
111   columnGap: number;
112 }
113 
114 interface FlexItem {
115   child: PibblElement<any>;
116   normalized: Readonly<BoxStyle & FlexItemStyle & LayoutItemStyle>;
117   baseMain: number;
118   targetMain: number;
119   paddingMain: number;
120   preferredCross: number;
121   paddingCross: number;
122   minMain: number;
123   maxMain: number;
124   minCross: number;
125   maxCross: number;
126   grow: number;
127   shrink: number;
128   crossAuto: boolean;
129   alignSelf: CrossAlignment | 'auto';
130   receiverToken: object;
131   query?: PreparedQuery;
132 }
133 
134 interface FlexLine {
135   items: FlexItem[];
136   crossSize: number;
137   crossOffset: number;
138 }
139 
140 interface FlexPlacement {
141   box: LayoutBox;
142   element: PibblElement<any>;
143 }
144 
145 function renderFlex(
146   props: FlexProps,
147   style: Readonly<ResolvedFlexStyle>,
148   ctx: RenderingContext2D,
149 ) {
150   wireStructuralEvents(props);
151   const children = prepareLayoutChildren(props.children, 'Flex');
152   recordLayoutEvaluation();
153   const content = style.box.contentBox;
154   ctx.translate(content.x, content.y);
155   const localContent = Object.freeze({
156     x: 0,
157     y: 0,
158     width: content.width,
159     height: content.height,
160   });
161   const releaseLayout = pushLayoutBox(localContent);
162   GLOBAL_STATE.componentRefs!.onAfterRender = releaseLayout;
163   const service = {
164     measureText(value: string, font: string) {
165       ctx.save();
166       try {
167         ctx.font = font;
168         return ctx.measureText(value);
169       } finally {
170         ctx.restore();
171       }
172     },
173   };
174   const placements = resolveFlexPlacements(
175     localContent,
176     children,
177     style,
178     {
179       service,
180       measureElement: (element, constraints) =>
181         measureElement(element, constraints, service),
182     },
183   );
184   let index = 0;
185   return placeDirectChildren(
186     children,
187     localContent,
188     () => placements[index++],
189     style.overflow,
190   );
191 }
192 
193 /**
194  * Describes the supported Flexbox subset for JSX or createElement authoring.
195  *
196  * @param props - Authored component inputs, supplied through JSX or createElement. See the linked
197  * props and style types.
198  * @throws When called directly; Pibbl mounts this component through JSX or createElement.
199  *
200  * @see {@link FlexProps}
201  * @see {@link FlexStyle}
202  */
203 export const Flex = definePrimitive<
204   FlexProps,
205   FlexStyle,
206   FlexStyle,
207   ResolvedFlexStyle
208 >(renderFlex, {
209   childInput: 'structural',
210   resolveStyle: (style, context) => ({
211     box: resolveContainerBox(style, context, 'Flex', 'flex'),
212     overflow: style.overflow ?? 'visible',
213     ...normalizeFlexOptions(style, context.allocation),
214   }),
215   measure: input => measureFlex(input),
216 });
217 
218 function measureFlex(
219   input: MeasureInput<FlexProps, FlexStyle>,
220 ): MeasureResult {
221   const children = prepareLayoutChildren(input.props.children, 'Flex');
222   const widthDefinite = input.style.width !== undefined &&
223     input.style.width !== 'auto';
224   const heightDefinite = input.style.height !== undefined &&
225     input.style.height !== 'auto';
226   if (widthDefinite && heightDefinite) {
227     return { status: 'measured', size: {
228       width: resolveMeasuredContainerLength(
229         input.style.width!,
230         input.constraints.maxWidth,
231         'width',
232         input.constraints,
233       ),
234       height: resolveMeasuredContainerLength(
235         input.style.height!,
236         input.constraints.maxHeight,
237         'height',
238         input.constraints,
239       ),
240     } };
241   }
242   if ((input.style.flexWrap ?? 'nowrap') !== 'nowrap') {
243     return unsupportedIntrinsic('requires nowrap because wrapped intrinsic sizing is deferred');
244   }
245   const row = (input.style.flexDirection ?? 'row') === 'row';
246   const diagnosticBox = {
247     x: 0,
248     y: 0,
249     width: Number.isFinite(input.constraints.maxWidth) ?
250       input.constraints.maxWidth : 0,
251     height: Number.isFinite(input.constraints.maxHeight) ?
252       input.constraints.maxHeight : 0,
253   };
254   const options = normalizeFlexOptions(input.style, diagnosticBox);
255   const mainGap = row ? options.columnGap : options.rowGap;
256   let intrinsicMain = 0;
257   let intrinsicCross = 0;
258   let count = 0;
259   for (const child of children) {
260     const receiverToken = getChildPrimitiveReceiverToken(
261       GLOBAL_STATE.renderTransaction,
262       GLOBAL_STATE.primitiveReceiverToken,
263       count,
264     );
265     const prepared = prepareDirectChildStyle(child, receiverToken, diagnosticBox);
266     const style = preparedStyleValue(prepared) as Readonly<
267       BoxStyle & FlexItemStyle & LayoutItemStyle & Record<string, unknown>
268     >;
269     rejectUnsupportedItemFields(child, style, diagnosticBox);
270     const grow = nonnegativeFactor(
271       style.flexGrow ?? 0,
272       'flexGrow',
273       child,
274       diagnosticBox,
275     );
276     if (grow !== 0) {
277       return unsupportedIntrinsic('does not support flexGrow during intrinsic measurement');
278     }
279     const shrink = nonnegativeFactor(
280       style.flexShrink ?? 1,
281       'flexShrink',
282       child,
283       diagnosticBox,
284     );
285     const basis = style.flexBasis ?? 'auto';
286     const mainProperty = row ? 'width' : 'height';
287     const crossProperty = row ? 'height' : 'width';
288     const minimumProperty = row ? 'minWidth' : 'minHeight';
289     if ((shrink > 0 || basis === 'auto') && style[minimumProperty] === undefined) {
290       throw childDiagnostic(
291         child,
292         minimumProperty,
293         style[minimumProperty],
294         diagnosticBox,
295         'an explicit main-axis minimum is required for shrink or auto basis',
296       );
297     }
298     const mainValue = basis === 'auto' ? style[mainProperty] : basis;
299     const crossValue = style[crossProperty];
300     if (isPercentage(mainValue) || isPercentage(crossValue) ||
301       hasPercentageBounds(style)) {
302       return unsupportedIntrinsic('does not support percentage child sizes or bounds');
303     }
304     if (mainValue === undefined || mainValue === 'auto') {
305       return unsupportedIntrinsic('requires a definite child basis; measured auto basis is deferred');
306     }
307     if (crossValue === undefined || crossValue === 'auto') {
308       return unsupportedIntrinsic('requires a definite child cross size');
309     }
310     const baseMain = intrinsicNumber(mainValue, 'flexBasis');
311     const baseCross = intrinsicNumber(crossValue, crossProperty);
312     const minMain = intrinsicOptionalNumber(style[minimumProperty], 0, minimumProperty);
313     const maxMainProperty = row ? 'maxWidth' : 'maxHeight';
314     const maxMain = intrinsicOptionalNumber(
315       style[maxMainProperty],
316       Number.POSITIVE_INFINITY,
317       maxMainProperty,
318     );
319     const minCrossProperty = row ? 'minHeight' : 'minWidth';
320     const maxCrossProperty = row ? 'maxHeight' : 'maxWidth';
321     const minCross = intrinsicOptionalNumber(style[minCrossProperty], 0, minCrossProperty);
322     const maxCross = intrinsicOptionalNumber(
323       style[maxCrossProperty],
324       Number.POSITIVE_INFINITY,
325       maxCrossProperty,
326     );
327     const padding = normalizeEdges(
328       style.padding,
329       'padding',
330       childContext(child, diagnosticBox),
331     );
332     const paddingMain = row ? padding.left + padding.right :
333       padding.top + padding.bottom;
334     const paddingCross = row ? padding.top + padding.bottom :
335       padding.left + padding.right;
336     intrinsicMain += clamp(baseMain, minMain, maxMain) + paddingMain;
337     intrinsicCross = Math.max(
338       intrinsicCross,
339       clamp(baseCross, minCross, maxCross) + paddingCross,
340     );
341     count++;
342   }
343   intrinsicMain += Math.max(0, count - 1) * mainGap;
344   return { status: 'measured', size: {
345     width: widthDefinite ? resolveMeasuredContainerLength(
346       input.style.width!,
347       input.constraints.maxWidth,
348       'width',
349       input.constraints,
350     ) : row ? intrinsicMain : intrinsicCross,
351     height: heightDefinite ? resolveMeasuredContainerLength(
352       input.style.height!,
353       input.constraints.maxHeight,
354       'height',
355       input.constraints,
356     ) : row ? intrinsicCross : intrinsicMain,
357   } };
358 }
359 
360 function resolveMeasuredContainerLength(
361   value: Exclude<BoxStyle['width'], undefined>,
362   reference: number,
363   property: string,
364   constraints: Readonly<Constraints>,
365 ): number {
366   const resolved = resolveLength(value, reference, property, {
367     component: 'Flex',
368     algorithm: 'measurement',
369     constraints,
370   });
371   if (resolved === undefined || resolved < 0) {
372     throw layoutDiagnostic({
373       component: 'Flex',
374       property,
375       value,
376       algorithm: 'measurement',
377       constraints,
378       reason: 'requires a definite nonnegative container size',
379     });
380   }
381   return resolved;
382 }
383 
384 function intrinsicNumber(
385   value: unknown,
386   property: string,
387 ): number {
388   if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
389     throw layoutDiagnostic({
390       component: 'Flex',
391       property,
392       value,
393       algorithm: 'measurement',
394       reason: 'intrinsic measurement requires finite nonnegative numeric sizes',
395     });
396   }
397   return value;
398 }
399 
400 function intrinsicOptionalNumber(
401   value: unknown,
402   fallback: number,
403   property: string,
404 ): number {
405   return value === undefined ? fallback : intrinsicNumber(value, property);
406 }
407 
408 function isPercentage(value: unknown): boolean {
409   return typeof value === 'string' && value.endsWith('%');
410 }
411 
412 function hasPercentageBounds(style: Readonly<BoxStyle>): boolean {
413   return [
414     style.minWidth,
415     style.maxWidth,
416     style.minHeight,
417     style.maxHeight,
418   ].some(isPercentage);
419 }
420 
421 function unsupportedIntrinsic(reason: string): MeasureResult {
422   return {
423     status: 'unsupported',
424     reason: `Flex intrinsic measurement ${reason}`,
425   };
426 }
427 
428 export function layoutFlex(
429   container: Readonly<LayoutBox>,
430   children: PibblNodeInput,
431   style: Readonly<FlexStyle>,
432   measurement: FlexMeasurementInput,
433 ): LayoutBox[] {
434   const prepared = materializeLayoutChildren(children, 'Flex');
435   return resolveFlexPlacements(container, prepared, style, measurement)
436     .map(placement => placement.box);
437 }
438 
439 function resolveFlexPlacements(
440   container: Readonly<LayoutBox>,
441   children: readonly PibblElement<any>[],
442   style: Readonly<FlexStyle>,
443   measurement: FlexMeasurementInput,
444 ): FlexPlacement[] {
445   assertFiniteBox(container);
446   const options = normalizeFlexOptions(style, container);
447   const row = options.flexDirection === 'row';
448   const mainSize = row ? container.width : container.height;
449   const crossSize = row ? container.height : container.width;
450   const mainGap = row ? options.columnGap : options.rowGap;
451   const crossGap = row ? options.rowGap : options.columnGap;
452   const items: FlexItem[] = [];
453 
454   for (const child of children) {
455     const receiverToken = getChildPrimitiveReceiverToken(
456       GLOBAL_STATE.renderTransaction,
457       GLOBAL_STATE.primitiveReceiverToken,
458       items.length,
459     );
460     const prepared = prepareDirectChildStyle(child, receiverToken, container);
461     const normalized = preparedStyleValue(prepared) as Readonly<
462       BoxStyle & FlexItemStyle & LayoutItemStyle & Record<string, unknown>
463     >;
464     assertSupportedChildPosition('Flex', child, normalized);
465     rejectUnsupportedItemFields(child, normalized, container);
466     const grow = nonnegativeFactor(normalized.flexGrow ?? 0, 'flexGrow', child, container);
467     const shrink = nonnegativeFactor(
468       normalized.flexShrink ?? 1,
469       'flexShrink',
470       child,
471       container,
472     );
473     const basisValue = normalized.flexBasis ?? 'auto';
474     const minimumProperty = row ? 'minWidth' : 'minHeight';
475     if ((shrink > 0 || basisValue === 'auto') && normalized[minimumProperty] === undefined) {
476       throw childDiagnostic(
477         child,
478         minimumProperty,
479         normalized[minimumProperty],
480         container,
481         'an explicit main-axis minimum is required for shrink or auto basis',
482       );
483     }
484 
485     const mainProperty = row ? 'width' : 'height';
486     const crossProperty = row ? 'height' : 'width';
487     const mainReference = mainSize;
488     const crossReference = crossSize;
489     let measured: { width: number; height: number } | undefined;
490     const specifiedMain = normalized[mainProperty];
491     const specifiedCross = normalized[crossProperty];
492     const needsMeasuredMain = basisValue === 'auto' &&
493       (specifiedMain === undefined || specifiedMain === 'auto');
494     const crossAuto = specifiedCross === undefined || specifiedCross === 'auto';
495     const alignment = normalizeItemAlignment(normalized.alignSelf, child, container);
496     const needsMeasuredCross = crossAuto && alignment !== 'stretch' &&
497       options.alignItems !== 'stretch';
498     if (needsMeasuredMain || needsMeasuredCross || crossAuto) {
499       const constraints = {
500         minWidth: 0,
501         maxWidth: container.width,
502         minHeight: 0,
503         maxHeight: container.height,
504       };
505       const measurementChild = clonePibblElement(child);
506       setPreNormalizedStyle(
507         measurementChild,
508         normalized,
509         child,
510         receiverToken,
511         preparedStyleQuery(prepared),
512       );
513       const result = measurement.measureElement(measurementChild, constraints);
514       if (result.status === 'measured') {
515         measured = result.size;
516       } else if (needsMeasuredMain || (crossAuto && effectiveAlignment(
517         alignment,
518         options.alignItems,
519       ) !== 'stretch')) {
520         throw childDiagnostic(
521           child,
522           needsMeasuredMain ? mainProperty : crossProperty,
523           'auto',
524           container,
525           `measurement is required but unsupported: ${result.reason}`,
526         );
527       }
528     }
529 
530     const baseMain = resolveBasis(
531       basisValue,
532       specifiedMain,
533       measured?.[mainProperty],
534       mainReference,
535       mainProperty,
536       child,
537       container,
538     );
539     const preferredCross = crossAuto ?
540       measured?.[crossProperty] ?? 0 :
541       resolveItemLength(
542         specifiedCross,
543         crossReference,
544         crossProperty,
545         child,
546         container,
547       );
548     const padding = normalizeEdges(normalized.padding, 'padding', childContext(child, container));
549     const paddingMain = row ? padding.left + padding.right : padding.top + padding.bottom;
550     const paddingCross = row ? padding.top + padding.bottom : padding.left + padding.right;
551     const minMain = resolveOptionalBound(
552       normalized[minimumProperty],
553       mainReference,
554       minimumProperty,
555       0,
556       child,
557       container,
558     );
559     const maximumProperty = row ? 'maxWidth' : 'maxHeight';
560     const maxMain = resolveOptionalBound(
561       normalized[maximumProperty],
562       mainReference,
563       maximumProperty,
564       Number.POSITIVE_INFINITY,
565       child,
566       container,
567     );
568     const minCrossProperty = row ? 'minHeight' : 'minWidth';
569     const maxCrossProperty = row ? 'maxHeight' : 'maxWidth';
570     const minCross = resolveOptionalBound(
571       normalized[minCrossProperty],
572       crossReference,
573       minCrossProperty,
574       0,
575       child,
576       container,
577     );
578     const maxCross = resolveOptionalBound(
579       normalized[maxCrossProperty],
580       crossReference,
581       maxCrossProperty,
582       Number.POSITIVE_INFINITY,
583       child,
584       container,
585     );
586     if (minMain > maxMain) {
587       throw childDiagnostic(
588         child,
589         minimumProperty,
590         minMain,
591         container,
592         'minimum must not exceed maximum',
593       );
594     }
595     if (minCross > maxCross) {
596       throw childDiagnostic(
597         child,
598         minCrossProperty,
599         minCross,
600         container,
601         'minimum must not exceed maximum',
602       );
603     }
604     items.push({
605       child,
606       normalized,
607       baseMain,
608       targetMain: baseMain,
609       paddingMain,
610       preferredCross: clamp(preferredCross, minCross, maxCross) + paddingCross,
611       paddingCross,
612       minMain,
613       maxMain,
614       minCross,
615       maxCross,
616       grow,
617       shrink,
618       crossAuto,
619       alignSelf: alignment,
620       receiverToken,
621       query: preparedStyleQuery(prepared),
622     });
623   }
624 
625   const lines = collectLines(items, mainSize, mainGap, options.flexWrap);
626   for (const line of lines) resolveFlexibleLengths(line, mainSize, mainGap);
627   resolveLineCrossSizes(
628     lines,
629     crossSize,
630     crossGap,
631     options.alignContent,
632     options.flexWrap === 'wrap',
633   );
634 
635   const placements: FlexPlacement[] = [];
636   for (const line of lines) {
637     const occupied = line.items.reduce(
638       (total, item) => total + item.targetMain + item.paddingMain,
639       Math.max(0, line.items.length - 1) * mainGap,
640     );
641     const distribution = distributeAlignment(
642       options.justifyContent,
643       mainSize - occupied,
644       line.items.length,
645     );
646     let mainCursor = distribution.start;
647     for (const item of line.items) {
648       const alignment = effectiveAlignment(item.alignSelf, options.alignItems);
649       let outerCross = item.preferredCross;
650       if (alignment === 'stretch' && item.crossAuto) {
651         outerCross = clamp(
652           Math.max(0, line.crossSize - item.paddingCross),
653           item.minCross,
654           item.maxCross,
655         ) + item.paddingCross;
656       }
657       const crossOffset = alignmentOffset(
658         alignment,
659         line.crossSize,
660         outerCross,
661       );
662       const outerMain = item.targetMain + item.paddingMain;
663       const box = row ? {
664         x: container.x + mainCursor,
665         y: container.y + line.crossOffset + crossOffset,
666         width: outerMain,
667         height: outerCross,
668       } : {
669         x: container.x + line.crossOffset + crossOffset,
670         y: container.y + mainCursor,
671         width: outerCross,
672         height: outerMain,
673       };
674       const usedStyle = {
675         ...item.normalized,
676         width: row ? item.targetMain : Math.max(0, outerCross - item.paddingCross),
677         height: row ? Math.max(0, outerCross - item.paddingCross) : item.targetMain,
678       };
679       placements.push({
680         box,
681         element: childWithUsedSize(
682           item.child,
683           usedStyle,
684           usedStyle.width,
685           usedStyle.height,
686           item.receiverToken,
687           item.query,
688         ),
689       });
690       mainCursor += outerMain + mainGap + distribution.between;
691     }
692   }
693   return placements;
694 }
695 
696 function collectLines(
697   items: readonly FlexItem[],
698   mainSize: number,
699   gap: number,
700   wrap: FlexWrap,
701 ): FlexLine[] {
702   const lines: FlexLine[] = [];
703   let current: FlexItem[] = [];
704   let occupied = 0;
705   for (const item of items) {
706     const hypothetical = clamp(item.baseMain, item.minMain, item.maxMain) +
707       item.paddingMain;
708     const next = current.length === 0 ? hypothetical : occupied + gap + hypothetical;
709     if (wrap === 'wrap' && current.length > 0 && next > mainSize) {
710       lines.push({ items: current, crossSize: 0, crossOffset: 0 });
711       current = [];
712       occupied = 0;
713     }
714     occupied = current.length === 0 ? hypothetical : occupied + gap + hypothetical;
715     current.push(item);
716   }
717   if (current.length > 0) {
718     lines.push({ items: current, crossSize: 0, crossOffset: 0 });
719   }
720   return lines;
721 }
722 
723 function resolveFlexibleLengths(
724   line: FlexLine,
725   mainSize: number,
726   gap: number,
727 ): void {
728   const gapSpace = Math.max(0, line.items.length - 1) * gap;
729   const baseOuter = line.items.reduce(
730     (total, item) => total + item.baseMain + item.paddingMain,
731     0,
732   );
733   const hypotheticalOuter = line.items.reduce(
734     (total, item) => total +
735       clamp(item.baseMain, item.minMain, item.maxMain) + item.paddingMain,
736     0,
737   );
738   const growing = hypotheticalOuter + gapSpace < mainSize;
739   const initialFree = mainSize - gapSpace - baseOuter;
740   const frozen = new Set<FlexItem>();
741   for (const item of line.items) {
742     item.targetMain = item.baseMain;
743     const hypothetical = clamp(item.baseMain, item.minMain, item.maxMain);
744     if (
745       (growing ? item.grow : item.shrink) === 0 ||
746       (growing && item.baseMain > hypothetical) ||
747       (!growing && item.baseMain < hypothetical)
748     ) {
749       item.targetMain = hypothetical;
750       frozen.add(item);
751     }
752   }
753 
754   while (frozen.size < line.items.length) {
755     const fixed = line.items.reduce(
756       (total, item) => total +
757         (frozen.has(item) ? item.targetMain : item.baseMain) +
758         item.paddingMain,
759       gapSpace,
760     );
761     let free = mainSize - fixed;
762     const unfrozen = line.items.filter(item => !frozen.has(item));
763     const rawFactor = growing ?
764       unfrozen.reduce((total, item) => total + item.grow, 0) :
765       unfrozen.reduce((total, item) => total + item.shrink, 0);
766     const scaledFactor = growing ? rawFactor :
767       unfrozen.reduce((total, item) => total + item.shrink * item.baseMain, 0);
768     if (scaledFactor === 0) {
769       for (const item of unfrozen) {
770         item.targetMain = clamp(item.baseMain, item.minMain, item.maxMain);
771         frozen.add(item);
772       }
773       break;
774     }
775     if (rawFactor < 1) {
776       const partialFree = initialFree * rawFactor;
777       if (Math.abs(partialFree) < Math.abs(free)) free = partialFree;
778     }
779     const violations: { item: FlexItem; amount: number }[] = [];
780     for (const item of unfrozen) {
781       const share = growing ? item.grow / scaledFactor :
782         item.shrink * item.baseMain / scaledFactor;
783       const candidate = item.baseMain + free * share;
784       const clamped = clamp(candidate, item.minMain, item.maxMain);
785       item.targetMain = clamped;
786       if (clamped !== candidate) {
787         violations.push({ item, amount: clamped - candidate });
788       }
789     }
790     if (violations.length === 0) break;
791     const totalViolation = violations.reduce(
792       (total, violation) => total + violation.amount,
793       0,
794     );
795     for (const violation of violations) {
796       if (
797         totalViolation === 0 ||
798         (totalViolation > 0 && violation.amount > 0) ||
799         (totalViolation < 0 && violation.amount < 0)
800       ) {
801         frozen.add(violation.item);
802       }
803     }
804   }
805 }
806 
807 function resolveLineCrossSizes(
808   lines: FlexLine[],
809   crossSize: number,
810   gap: number,
811   alignment: ResolvedFlexStyle['alignContent'],
812   multiLine: boolean,
813 ): void {
814   if (lines.length === 0) return;
815   for (const line of lines) {
816     line.crossSize = Math.max(0, ...line.items.map(item => item.preferredCross));
817   }
818   if (lines.length === 1 && !multiLine) {
819     lines[0].crossSize = crossSize;
820     lines[0].crossOffset = 0;
821     return;
822   }
823   const occupied = lines.reduce(
824     (total, line) => total + line.crossSize,
825     Math.max(0, lines.length - 1) * gap,
826   );
827   const free = crossSize - occupied;
828   if (alignment === 'stretch' && free > 0) {
829     const addition = free / lines.length;
830     for (const line of lines) line.crossSize += addition;
831   }
832   const distribution = alignment === 'stretch' ?
833     { start: 0, between: 0 } :
834     distributeAlignment(alignment, free, lines.length);
835   let cursor = distribution.start;
836   for (const line of lines) {
837     line.crossOffset = cursor;
838     cursor += line.crossSize + gap + distribution.between;
839   }
840 }
841 
842 function distributeAlignment(
843   alignment: MainAlignment | CrossAlignment,
844   free: number,
845   count: number,
846 ): { start: number; between: number } {
847   if (count === 0 || alignment === 'flex-start' || alignment === 'stretch') {
848     return { start: 0, between: 0 };
849   }
850   if (free < 0) {
851     if (alignment === 'flex-end') return { start: free, between: 0 };
852     if (alignment === 'center' || alignment === 'space-around' ||
853       alignment === 'space-evenly') {
854       return { start: free / 2, between: 0 };
855     }
856     return { start: 0, between: 0 };
857   }
858   if (alignment === 'flex-end') return { start: free, between: 0 };
859   if (alignment === 'center') return { start: free / 2, between: 0 };
860   if (alignment === 'space-between') {
861     return count === 1 ? { start: 0, between: 0 } :
862       { start: 0, between: free / (count - 1) };
863   }
864   if (alignment === 'space-around') {
865     const between = free / count;
866     return { start: between / 2, between };
867   }
868   if (alignment === 'space-evenly') {
869     const between = free / (count + 1);
870     return { start: between, between };
871   }
872   return { start: 0, between: 0 };
873 }
874 
875 function normalizeFlexOptions(
876   style: Readonly<FlexStyle>,
877   container: Readonly<LayoutBox>,
878 ) {
879   const flexDirection = supported(style.flexDirection ?? 'row', ['row', 'column'], 'flexDirection', container);
880   const flexWrap = supported(style.flexWrap ?? 'nowrap', ['nowrap', 'wrap'], 'flexWrap', container);
881   const justifyContent = supported(
882     style.justifyContent ?? 'flex-start',
883     ['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly'],
884     'justifyContent',
885     container,
886   );
887   const alignItems = supported(
888     style.alignItems ?? 'stretch',
889     ['stretch', 'flex-start', 'flex-end', 'center'],
890     'alignItems',
891     container,
892   );
893   const alignContent = supported(
894     style.alignContent ?? 'stretch',
895     ['stretch', 'flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly'],
896     'alignContent',
897     container,
898   );
899   const gap = nonnegativeGap(style.gap ?? 0, 'gap', container);
900   return {
901     flexDirection: flexDirection as FlexDirection,
902     flexWrap: flexWrap as FlexWrap,
903     justifyContent: justifyContent as MainAlignment,
904     alignItems: alignItems as CrossAlignment,
905     alignContent: alignContent as CrossAlignment | MainAlignment,
906     rowGap: nonnegativeGap(style.rowGap ?? gap, 'rowGap', container),
907     columnGap: nonnegativeGap(style.columnGap ?? gap, 'columnGap', container),
908   };
909 }
910 
911 function supported(
912   value: string,
913   accepted: readonly string[],
914   property: string,
915   container: Readonly<LayoutBox>,
916 ): string {
917   if (!accepted.includes(value)) throw optionDiagnostic(property, value, container);
918   return value;
919 }
920 
921 function nonnegativeGap(
922   value: unknown,
923   property: string,
924   container: Readonly<LayoutBox>,
925 ): number {
926   if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
927     throw optionDiagnostic(property, value, container);
928   }
929   return value;
930 }
931 
932 function nonnegativeFactor(
933   value: number,
934   property: string,
935   child: PibblElement<any>,
936   container: Readonly<LayoutBox>,
937 ): number {
938   if (!Number.isFinite(value) || value < 0) {
939     throw childDiagnostic(
940       child,
941       property,
942       value,
943       container,
944       'must be finite and nonnegative',
945     );
946   }
947   return value;
948 }
949 
950 function resolveBasis(
951   basis: FlexItemStyle['flexBasis'],
952   specifiedMain: BoxStyle['width'],
953   measuredMain: number | undefined,
954   reference: number,
955   property: 'width' | 'height',
956   child: PibblElement<any>,
957   container: Readonly<LayoutBox>,
958 ): number {
959   const value = basis === 'auto' || basis === undefined ? specifiedMain : basis;
960   if (value === undefined || value === 'auto') {
961     if (measuredMain === undefined) {
962       throw childDiagnostic(
963         child,
964         property,
965         value ?? 'auto',
966         container,
967         'auto flex basis requires supported pure measurement',
968       );
969     }
970     return measuredMain;
971   }
972   return resolveItemLength(value, reference, 'flexBasis', child, container);
973 }
974 
975 function resolveItemLength(
976   value: Exclude<BoxStyle['width'], undefined>,
977   reference: number,
978   property: string,
979   child: PibblElement<any>,
980   container: Readonly<LayoutBox>,
981 ): number {
982   const resolved = resolveLength(value, reference, property, childContext(child, container));
983   if (resolved === undefined || resolved < 0) {
984     throw childDiagnostic(
985       child,
986       property,
987       value,
988       container,
989       'must resolve to a finite nonnegative length',
990     );
991   }
992   return resolved;
993 }
994 
995 function resolveOptionalBound(
996   value: BoxStyle['minWidth'],
997   reference: number,
998   property: string,
999   fallback: number,
1000   child: PibblElement<any>,
1001   container: Readonly<LayoutBox>,
1002 ): number {
1003   if (value === undefined) return fallback;
1004   return resolveItemLength(value, reference, property, child, container);
1005 }
1006 
1007 function normalizeItemAlignment(
1008   value: LayoutItemStyle['alignSelf'],
1009   child: PibblElement<any>,
1010   container: Readonly<LayoutBox>,
1011 ): CrossAlignment | 'auto' {
1012   if (value === undefined || value === 'auto') return 'auto';
1013   if (value === 'stretch' || value === 'flex-start' ||
1014     value === 'flex-end' || value === 'center') return value;
1015   throw childDiagnostic(
1016     child,
1017     'alignSelf',
1018     value,
1019     container,
1020     'uses an unsupported flex item alignment',
1021   );
1022 }
1023 
1024 function effectiveAlignment(
1025   value: CrossAlignment | 'auto',
1026   fallback: CrossAlignment,
1027 ): CrossAlignment {
1028   return value === 'auto' ? fallback : value;
1029 }
1030 
1031 function alignmentOffset(
1032   alignment: CrossAlignment,
1033   lineSize: number,
1034   itemSize: number,
1035 ): number {
1036   if (alignment === 'center') return (lineSize - itemSize) / 2;
1037   if (alignment === 'flex-end') return lineSize - itemSize;
1038   return 0;
1039 }
1040 
1041 function rejectUnsupportedItemFields(
1042   child: PibblElement<any>,
1043   style: Readonly<Record<string, unknown>>,
1044   container: Readonly<LayoutBox>,
1045 ): void {
1046   for (const property of ['order', 'margin', 'marginLeft', 'marginRight', 'marginTop', 'marginBottom']) {
1047     if (style[property] !== undefined) {
1048       throw childDiagnostic(
1049         child,
1050         property,
1051         style[property],
1052         container,
1053         'uses an unsupported flex item feature',
1054       );
1055     }
1056   }
1057 }
1058 
1059 function clamp(value: number, minimum: number, maximum: number): number {
1060   return Math.min(maximum, Math.max(minimum, value));
1061 }
1062 
1063 function assertFiniteBox(container: Readonly<LayoutBox>): void {
1064   for (const [property, value] of Object.entries(container)) {
1065     if (!Number.isFinite(value) ||
1066       ((property === 'width' || property === 'height') && value < 0)) {
1067       throw optionDiagnostic(property, value, container);
1068     }
1069   }
1070 }
1071 
1072 function optionDiagnostic(
1073   property: string,
1074   value: unknown,
1075   container: Readonly<LayoutBox>,
1076 ) {
1077   return layoutDiagnostic({
1078     component: 'Flex',
1079     property,
1080     value,
1081     algorithm: 'flex',
1082     constraints: constraintsFor(container),
1083     reason: 'uses an unsupported flex value',
1084   });
1085 }
1086 
1087 function childDiagnostic(
1088   child: PibblElement<any>,
1089   property: string,
1090   value: unknown,
1091   container: Readonly<LayoutBox>,
1092   reason: string,
1093 ) {
1094   return layoutDiagnostic({
1095     component: child.type.name || 'Anonymous',
1096     property,
1097     value,
1098     algorithm: 'flex',
1099     constraints: constraintsFor(container),
1100     reason,
1101   });
1102 }
1103 
1104 function childContext(
1105   child: PibblElement<any>,
1106   container: Readonly<LayoutBox>,
1107 ) {
1108   return {
1109     component: child.type.name || 'Anonymous',
1110     algorithm: 'flex',
1111     constraints: constraintsFor(container),
1112   };
1113 }
1114 
1115 function constraintsFor(container: Readonly<LayoutBox>): Constraints {
1116   return {
1117     minWidth: 0,
1118     maxWidth: container.width,
1119     minHeight: 0,
1120     maxHeight: container.height,
1121   };
1122 }
1123 

Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.