packages/core/src/lib/layout/flex.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.