flowboard-react 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import React, { useMemo } from 'react';
2
2
  import {
3
3
  Animated,
4
+ Platform,
4
5
  Pressable,
5
6
  ScrollView,
6
7
  StyleSheet,
@@ -10,8 +11,9 @@ import {
10
11
  Image,
11
12
  Vibration,
12
13
  type TextStyle,
14
+ type ViewStyle,
13
15
  } from 'react-native';
14
- import { SafeAreaView } from 'react-native-safe-area-context';
16
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
15
17
  import MaskedView from '@react-native-masked-view/masked-view';
16
18
  import LinearGradient from 'react-native-linear-gradient';
17
19
  import LottieView from 'lottie-react-native';
@@ -53,7 +55,7 @@ const styles = StyleSheet.create({
53
55
  whiteBg: {
54
56
  backgroundColor: '#ffffff',
55
57
  },
56
- safeArea: { flex: 1 },
58
+ contentLayer: { flex: 1 },
57
59
  progressWrapper: { width: '100%' },
58
60
  });
59
61
 
@@ -67,6 +69,32 @@ type FlowboardRendererProps = {
67
69
  totalScreens?: number;
68
70
  };
69
71
 
72
+ type PageScrollAxis = 'vertical' | 'none';
73
+
74
+ type AxisBounds = {
75
+ widthBounded: boolean;
76
+ heightBounded: boolean;
77
+ };
78
+
79
+ function resolvePageScrollAxis(
80
+ screenData: Record<string, any>
81
+ ): PageScrollAxis {
82
+ const raw = String(
83
+ screenData.pageScroll ??
84
+ screenData.scrollDirection ??
85
+ (screenData.scrollable === true ? 'vertical' : 'none')
86
+ ).toLowerCase();
87
+ if (raw === 'none') return 'none';
88
+ return 'vertical';
89
+ }
90
+
91
+ function getAxisBoundsForPageScroll(pageScroll: PageScrollAxis): AxisBounds {
92
+ if (pageScroll === 'vertical') {
93
+ return { widthBounded: true, heightBounded: false };
94
+ }
95
+ return { widthBounded: true, heightBounded: true };
96
+ }
97
+
70
98
  export default function FlowboardRenderer(props: FlowboardRendererProps) {
71
99
  const {
72
100
  screenData,
@@ -90,8 +118,12 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
90
118
  const progressThickness = Number(screenData.progressThickness ?? 4);
91
119
  const progressRadius = Number(screenData.progressRadius ?? 0);
92
120
  const progressStyle = screenData.progressStyle ?? 'linear';
93
- const scrollable = screenData.scrollable === true;
121
+ const pageScroll = resolvePageScrollAxis(screenData);
122
+ const pageAxisBounds = getAxisBoundsForPageScroll(pageScroll);
123
+ const scrollable = pageScroll !== 'none';
94
124
  const safeArea = screenData.safeArea !== false;
125
+ const safeAreaInsets = useSafeAreaInsets();
126
+ const rootAxis: 'vertical' = 'vertical';
95
127
 
96
128
  const padding = parseInsets(screenData.padding);
97
129
  const contentPaddingStyle = insetsToStyle(padding);
@@ -102,6 +134,14 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
102
134
  };
103
135
  const rootCrossAxisAlignment =
104
136
  screenData.crossAxisAlignment ?? screenData.crossAxis;
137
+ const safeAreaPaddingStyle = safeArea
138
+ ? {
139
+ paddingTop: safeAreaInsets.top,
140
+ paddingRight: safeAreaInsets.right,
141
+ paddingBottom: safeAreaInsets.bottom,
142
+ paddingLeft: safeAreaInsets.left,
143
+ }
144
+ : null;
105
145
 
106
146
  const content = (
107
147
  <View style={{ flex: 1 }}>
@@ -120,6 +160,7 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
120
160
 
121
161
  {scrollable ? (
122
162
  <ScrollView
163
+ horizontal={false}
123
164
  contentContainerStyle={{
124
165
  ...contentPaddingStyle,
125
166
  paddingTop: showProgress ? 0 : padding.top,
@@ -128,8 +169,11 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
128
169
  <View
129
170
  style={{
130
171
  flexGrow: 1,
172
+ flexDirection: 'column',
131
173
  justifyContent: parseFlexAlignment(screenData.mainAxisAlignment),
132
174
  alignItems: parseRootCrossAlignment(rootCrossAxisAlignment),
175
+ minHeight: 0,
176
+ minWidth: 0,
133
177
  }}
134
178
  >
135
179
  {childrenData.map((child, index) =>
@@ -138,6 +182,9 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
138
182
  formData,
139
183
  onInputChange,
140
184
  allowFlexExpansion: true,
185
+ parentFlexAxis: rootAxis,
186
+ parentMainAxisBounded: pageAxisBounds.heightBounded,
187
+ pageAxisBounds,
141
188
  screenData,
142
189
  enableFontAwesomeIcons,
143
190
  key: `child-${index}`,
@@ -161,6 +208,9 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
161
208
  formData,
162
209
  onInputChange,
163
210
  allowFlexExpansion: true,
211
+ parentFlexAxis: rootAxis,
212
+ parentMainAxisBounded: true,
213
+ pageAxisBounds,
164
214
  screenData,
165
215
  enableFontAwesomeIcons,
166
216
  key: `child-${index}`,
@@ -174,17 +224,7 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
174
224
  return (
175
225
  <View style={styles.root}>
176
226
  {renderBackground(backgroundData, bgColorCode)}
177
- {safeArea ? (
178
- <SafeAreaView
179
- style={styles.safeArea}
180
- mode="padding"
181
- edges={['top', 'right', 'bottom', 'left']}
182
- >
183
- {content}
184
- </SafeAreaView>
185
- ) : (
186
- content
187
- )}
227
+ <View style={[styles.contentLayer, safeAreaPaddingStyle]}>{content}</View>
188
228
  </View>
189
229
  );
190
230
  }
@@ -357,6 +397,484 @@ function renderProgressBar(
357
397
  );
358
398
  }
359
399
 
400
+ const STACK_SPACE_DISTRIBUTIONS = new Set([
401
+ 'spaceBetween',
402
+ 'spaceAround',
403
+ 'spaceEvenly',
404
+ ]);
405
+
406
+ function normalizeStackSpacingConfig(value: any): Record<string, number> {
407
+ if (typeof value === 'number' && Number.isFinite(value)) {
408
+ return { x: value, y: value };
409
+ }
410
+ if (!value || typeof value !== 'object') {
411
+ return { x: 0, y: 0 };
412
+ }
413
+
414
+ const x = Number(value.x ?? value.horizontal);
415
+ const y = Number(value.y ?? value.vertical);
416
+ const top = Number(value.top);
417
+ const right = Number(value.right);
418
+ const bottom = Number(value.bottom);
419
+ const left = Number(value.left);
420
+
421
+ const normalized: Record<string, number> = {};
422
+ if (!Number.isNaN(x)) normalized.x = x;
423
+ if (!Number.isNaN(y)) normalized.y = y;
424
+ if (!Number.isNaN(top)) normalized.top = top;
425
+ if (!Number.isNaN(right)) normalized.right = right;
426
+ if (!Number.isNaN(bottom)) normalized.bottom = bottom;
427
+ if (!Number.isNaN(left)) normalized.left = left;
428
+
429
+ if (Object.keys(normalized).length === 0) {
430
+ return { x: 0, y: 0 };
431
+ }
432
+ return normalized;
433
+ }
434
+
435
+ function stackSpacingToInsets(value: any) {
436
+ const normalized = normalizeStackSpacingConfig(value);
437
+ return parseInsets({
438
+ horizontal: normalized.x,
439
+ vertical: normalized.y,
440
+ top: normalized.top,
441
+ right: normalized.right,
442
+ bottom: normalized.bottom,
443
+ left: normalized.left,
444
+ });
445
+ }
446
+
447
+ function isLegacyOverlayAlignment(value: unknown): boolean {
448
+ return [
449
+ 'topLeft',
450
+ 'topCenter',
451
+ 'topRight',
452
+ 'centerLeft',
453
+ 'center',
454
+ 'centerRight',
455
+ 'bottomLeft',
456
+ 'bottomCenter',
457
+ 'bottomRight',
458
+ ].includes(String(value ?? ''));
459
+ }
460
+
461
+ function normalizeStackAxis(type: string, props: Record<string, any>) {
462
+ const explicit = String(props.axis ?? '').toLowerCase();
463
+ if (
464
+ explicit === 'vertical' ||
465
+ explicit === 'horizontal' ||
466
+ explicit === 'overlay'
467
+ ) {
468
+ return explicit as 'vertical' | 'horizontal' | 'overlay';
469
+ }
470
+ if (type === 'container') return 'overlay';
471
+ if (type === 'row') return 'horizontal';
472
+ if (type === 'column') return 'vertical';
473
+ if (type === 'layout') {
474
+ return props.direction === 'horizontal' ? 'horizontal' : 'vertical';
475
+ }
476
+ if (
477
+ type === 'stack' &&
478
+ (props.fit !== undefined || isLegacyOverlayAlignment(props.alignment))
479
+ ) {
480
+ return 'overlay';
481
+ }
482
+ return 'vertical';
483
+ }
484
+
485
+ function normalizeStackDistribution(value: unknown) {
486
+ const normalized = String(value ?? '').trim();
487
+ if (
488
+ normalized === 'center' ||
489
+ normalized === 'end' ||
490
+ normalized === 'spaceBetween' ||
491
+ normalized === 'spaceAround' ||
492
+ normalized === 'spaceEvenly'
493
+ ) {
494
+ return normalized;
495
+ }
496
+ return 'start';
497
+ }
498
+
499
+ function normalizeStackCrossAlignment(value: unknown) {
500
+ const raw = String(value ?? '').toLowerCase();
501
+ if (raw === 'center' || raw.includes('center')) return 'center';
502
+ if (raw === 'end' || raw.endsWith('right') || raw.startsWith('bottom'))
503
+ return 'end';
504
+ return 'start';
505
+ }
506
+
507
+ function normalizeOverlayAlignment(value: unknown) {
508
+ const raw = String(value ?? '').trim();
509
+ switch (raw) {
510
+ case 'topStart':
511
+ case 'top':
512
+ case 'topEnd':
513
+ case 'start':
514
+ case 'center':
515
+ case 'end':
516
+ case 'bottomStart':
517
+ case 'bottom':
518
+ case 'bottomEnd':
519
+ return raw;
520
+ case 'topLeft':
521
+ return 'topStart';
522
+ case 'topCenter':
523
+ return 'top';
524
+ case 'topRight':
525
+ return 'topEnd';
526
+ case 'centerLeft':
527
+ return 'start';
528
+ case 'centerRight':
529
+ return 'end';
530
+ case 'bottomLeft':
531
+ return 'bottomStart';
532
+ case 'bottomCenter':
533
+ return 'bottom';
534
+ case 'bottomRight':
535
+ return 'bottomEnd';
536
+ default:
537
+ return 'center';
538
+ }
539
+ }
540
+
541
+ function isFillDimensionValue(value: unknown) {
542
+ if (typeof value === 'number') return value === Number.POSITIVE_INFINITY;
543
+ if (typeof value !== 'string') return false;
544
+ const normalized = value.trim().toLowerCase();
545
+ return (
546
+ normalized === 'infinity' ||
547
+ normalized === 'double.infinity' ||
548
+ normalized === '+infinity' ||
549
+ normalized === '100%'
550
+ );
551
+ }
552
+
553
+ function isFixedDimensionValue(value: unknown) {
554
+ if (isFillDimensionValue(value)) return false;
555
+ const parsed = Number(value);
556
+ return Number.isFinite(parsed);
557
+ }
558
+
559
+ function normalizeStackSize(
560
+ props: Record<string, any>,
561
+ axis: 'vertical' | 'horizontal' | 'overlay'
562
+ ) {
563
+ const widthMode = String(props.size?.width ?? '').toLowerCase();
564
+ const heightMode = String(props.size?.height ?? '').toLowerCase();
565
+
566
+ const width =
567
+ widthMode === 'fill' || widthMode === 'fit' || widthMode === 'fixed'
568
+ ? widthMode
569
+ : props.fit === 'expand' || props.width === 'infinity'
570
+ ? 'fill'
571
+ : isFixedDimensionValue(props.width)
572
+ ? 'fixed'
573
+ : axis === 'horizontal'
574
+ ? 'fit'
575
+ : 'fill';
576
+ const height =
577
+ heightMode === 'fill' || heightMode === 'fit' || heightMode === 'fixed'
578
+ ? heightMode
579
+ : props.fit === 'expand' || props.height === 'infinity'
580
+ ? 'fill'
581
+ : isFixedDimensionValue(props.height)
582
+ ? 'fixed'
583
+ : axis === 'vertical'
584
+ ? 'fit'
585
+ : 'fill';
586
+
587
+ return { width, height };
588
+ }
589
+
590
+ function normalizeStackFill(props: Record<string, any>) {
591
+ if (props.fill && typeof props.fill === 'object') {
592
+ const next = { ...props.fill };
593
+ if (!next.type && next.color) next.type = 'solid';
594
+ return next;
595
+ }
596
+ if (props.background && typeof props.background === 'object') {
597
+ if (props.background.type === 'color') {
598
+ return { type: 'solid', color: props.background.color };
599
+ }
600
+ return { ...props.background };
601
+ }
602
+ if (props.backgroundColor) {
603
+ return { type: 'solid', color: props.backgroundColor };
604
+ }
605
+ return undefined;
606
+ }
607
+
608
+ function normalizeStackBorder(props: Record<string, any>) {
609
+ if (props.border && typeof props.border === 'object') {
610
+ return { ...props.border };
611
+ }
612
+ if (props.borderColor || props.borderWidth !== undefined) {
613
+ return {
614
+ width: Number(props.borderWidth ?? 1),
615
+ color: props.borderColor,
616
+ };
617
+ }
618
+ return undefined;
619
+ }
620
+
621
+ function normalizeStackShadow(props: Record<string, any>) {
622
+ if (props.shadow && typeof props.shadow === 'object') {
623
+ return { ...props.shadow };
624
+ }
625
+ return undefined;
626
+ }
627
+
628
+ function spacingToInsets(value: any) {
629
+ const normalized = normalizeStackSpacingConfig(value);
630
+ const x = normalized.x ?? 0;
631
+ const y = normalized.y ?? 0;
632
+ return {
633
+ top: normalized.top ?? y,
634
+ right: normalized.right ?? x,
635
+ bottom: normalized.bottom ?? y,
636
+ left: normalized.left ?? x,
637
+ };
638
+ }
639
+
640
+ function insetsToSpacing(insets: {
641
+ top: number;
642
+ right: number;
643
+ bottom: number;
644
+ left: number;
645
+ }) {
646
+ if (insets.top === insets.bottom && insets.left === insets.right) {
647
+ return { x: insets.left, y: insets.top };
648
+ }
649
+ return insets;
650
+ }
651
+
652
+ function mergeStackProps(
653
+ parentProps: Record<string, any>,
654
+ childProps: Record<string, any>
655
+ ) {
656
+ const parentAxis = String(parentProps.axis ?? 'vertical');
657
+ const childAxis = String(childProps.axis ?? 'vertical');
658
+
659
+ let axis = childAxis;
660
+ if (parentAxis !== childAxis) {
661
+ if (parentAxis === 'overlay' && childAxis !== 'overlay') axis = childAxis;
662
+ else if (childAxis === 'overlay' && parentAxis !== 'overlay')
663
+ axis = parentAxis;
664
+ }
665
+
666
+ const axisSource =
667
+ axis === parentAxis && axis !== childAxis ? parentProps : childProps;
668
+ const fallbackAxisSource =
669
+ axisSource === childProps ? parentProps : childProps;
670
+
671
+ const parentPadding = spacingToInsets(parentProps.layout?.padding);
672
+ const childPadding = spacingToInsets(childProps.layout?.padding);
673
+ const parentMargin = spacingToInsets(parentProps.layout?.margin);
674
+ const childMargin = spacingToInsets(childProps.layout?.margin);
675
+
676
+ const mergedProps: Record<string, any> = {
677
+ axis,
678
+ alignment: axisSource.alignment ?? fallbackAxisSource.alignment ?? 'start',
679
+ distribution:
680
+ axisSource.distribution ?? fallbackAxisSource.distribution ?? 'start',
681
+ childSpacing:
682
+ Number(axisSource.childSpacing ?? fallbackAxisSource.childSpacing ?? 0) ||
683
+ 0,
684
+ size: {
685
+ width: childProps.size?.width ?? parentProps.size?.width ?? 'fill',
686
+ height: childProps.size?.height ?? parentProps.size?.height ?? 'fit',
687
+ },
688
+ layout: {
689
+ padding: insetsToSpacing({
690
+ top: parentPadding.top + childPadding.top,
691
+ right: parentPadding.right + childPadding.right,
692
+ bottom: parentPadding.bottom + childPadding.bottom,
693
+ left: parentPadding.left + childPadding.left,
694
+ }),
695
+ margin: insetsToSpacing({
696
+ top: parentMargin.top + childMargin.top,
697
+ right: parentMargin.right + childMargin.right,
698
+ bottom: parentMargin.bottom + childMargin.bottom,
699
+ left: parentMargin.left + childMargin.left,
700
+ }),
701
+ },
702
+ appearance: {
703
+ shape: 'rectangle',
704
+ cornerRadius: Number(
705
+ childProps.appearance?.cornerRadius ??
706
+ parentProps.appearance?.cornerRadius ??
707
+ 0
708
+ ),
709
+ },
710
+ };
711
+
712
+ if (axis === 'overlay') {
713
+ mergedProps.overlayAlignment =
714
+ childProps.overlayAlignment ?? parentProps.overlayAlignment ?? 'center';
715
+ }
716
+
717
+ if (parentProps.fill || childProps.fill) {
718
+ mergedProps.fill = childProps.fill ?? parentProps.fill;
719
+ }
720
+ if (parentProps.border || childProps.border) {
721
+ mergedProps.border = childProps.border ?? parentProps.border;
722
+ }
723
+ if (parentProps.shadow || childProps.shadow) {
724
+ mergedProps.shadow = childProps.shadow ?? parentProps.shadow;
725
+ }
726
+
727
+ if (childProps.width !== undefined || parentProps.width !== undefined) {
728
+ mergedProps.width = childProps.width ?? parentProps.width;
729
+ }
730
+ if (childProps.height !== undefined || parentProps.height !== undefined) {
731
+ mergedProps.height = childProps.height ?? parentProps.height;
732
+ }
733
+
734
+ return mergedProps;
735
+ }
736
+
737
+ function normalizeStackLikeNode(
738
+ json: Record<string, any>
739
+ ): Record<string, any> {
740
+ if (!json || typeof json !== 'object') return json;
741
+ const type = String(json.type ?? '');
742
+ if (
743
+ !['stack', 'container', 'layout', 'row', 'column', 'grid'].includes(type)
744
+ ) {
745
+ return json;
746
+ }
747
+
748
+ const props = { ...(json.properties ?? {}) } as Record<string, any>;
749
+ const axis = normalizeStackAxis(type, props);
750
+ const size = normalizeStackSize(props, axis);
751
+ const children = Array.isArray(json.children) ? [...json.children] : [];
752
+ if (json.child) {
753
+ children.push(json.child);
754
+ }
755
+
756
+ const normalizedProps: Record<string, any> = {
757
+ axis,
758
+ alignment: normalizeStackCrossAlignment(
759
+ props.alignment ?? props.crossAxisAlignment
760
+ ),
761
+ distribution: normalizeStackDistribution(
762
+ props.distribution ?? props.mainAxisAlignment
763
+ ),
764
+ childSpacing: Number(props.childSpacing ?? props.gap ?? props.spacing ?? 0),
765
+ size,
766
+ layout: {
767
+ padding: normalizeStackSpacingConfig(
768
+ props.layout?.padding ?? props.padding
769
+ ),
770
+ margin: normalizeStackSpacingConfig(props.layout?.margin ?? props.margin),
771
+ },
772
+ appearance: {
773
+ shape: 'rectangle',
774
+ cornerRadius: Number(
775
+ props.appearance?.cornerRadius ?? props.borderRadius ?? 0
776
+ ),
777
+ },
778
+ };
779
+
780
+ if (axis === 'overlay') {
781
+ normalizedProps.overlayAlignment = normalizeOverlayAlignment(
782
+ props.overlayAlignment ?? props.alignment
783
+ );
784
+ }
785
+
786
+ const fill = normalizeStackFill(props);
787
+ if (fill) normalizedProps.fill = fill;
788
+
789
+ const border = normalizeStackBorder(props);
790
+ if (border) normalizedProps.border = border;
791
+
792
+ const shadow = normalizeStackShadow(props);
793
+ if (shadow) normalizedProps.shadow = shadow;
794
+
795
+ if (props.width !== undefined) normalizedProps.width = props.width;
796
+ if (props.height !== undefined) normalizedProps.height = props.height;
797
+
798
+ if ((type === 'container' || type === 'layout') && children.length === 1) {
799
+ const childCandidate = children[0];
800
+ if (childCandidate && typeof childCandidate === 'object') {
801
+ const normalizedChild: Record<string, any> = normalizeStackLikeNode(
802
+ childCandidate as Record<string, any>
803
+ );
804
+ if (normalizedChild?.type === 'stack') {
805
+ const childProps = {
806
+ ...(normalizedChild.properties ?? {}),
807
+ } as Record<string, any>;
808
+ const mergedProps = mergeStackProps(normalizedProps, childProps);
809
+ return {
810
+ ...json,
811
+ type: 'stack',
812
+ properties: mergedProps,
813
+ children: Array.isArray(normalizedChild.children)
814
+ ? normalizedChild.children
815
+ : [],
816
+ child: undefined,
817
+ };
818
+ }
819
+ }
820
+ }
821
+
822
+ return {
823
+ ...json,
824
+ type: 'stack',
825
+ properties: normalizedProps,
826
+ children,
827
+ child: undefined,
828
+ };
829
+ }
830
+
831
+ function parseOverlayGridAlignment(
832
+ value?: string
833
+ ): Pick<ViewStyle, 'justifyContent' | 'alignItems'> {
834
+ switch (normalizeOverlayAlignment(value)) {
835
+ case 'topStart':
836
+ return { justifyContent: 'flex-start', alignItems: 'flex-start' };
837
+ case 'top':
838
+ return { justifyContent: 'flex-start', alignItems: 'center' };
839
+ case 'topEnd':
840
+ return { justifyContent: 'flex-start', alignItems: 'flex-end' };
841
+ case 'start':
842
+ return { justifyContent: 'center', alignItems: 'flex-start' };
843
+ case 'end':
844
+ return { justifyContent: 'center', alignItems: 'flex-end' };
845
+ case 'bottomStart':
846
+ return { justifyContent: 'flex-end', alignItems: 'flex-start' };
847
+ case 'bottom':
848
+ return { justifyContent: 'flex-end', alignItems: 'center' };
849
+ case 'bottomEnd':
850
+ return { justifyContent: 'flex-end', alignItems: 'flex-end' };
851
+ case 'center':
852
+ default:
853
+ return { justifyContent: 'center', alignItems: 'center' };
854
+ }
855
+ }
856
+
857
+ function resolveStackDimension(
858
+ props: Record<string, any>,
859
+ axis: 'vertical' | 'horizontal' | 'overlay',
860
+ key: 'width' | 'height',
861
+ axisBounds: AxisBounds
862
+ ) {
863
+ const mode = props.size?.[key];
864
+ const legacy = normalizeDimension(parseLayoutDimension(props[key]));
865
+ const isBounded =
866
+ key === 'width' ? axisBounds.widthBounded : axisBounds.heightBounded;
867
+ if (mode === 'fill') return isBounded ? '100%' : legacy;
868
+ if (mode === 'fit') return legacy;
869
+ if (mode === 'fixed') return legacy;
870
+ if (legacy !== undefined) return legacy;
871
+ if (key === 'width' && axis !== 'horizontal' && axisBounds.widthBounded)
872
+ return '100%';
873
+ if (key === 'height' && axis === 'horizontal' && axisBounds.heightBounded)
874
+ return '100%';
875
+ return undefined;
876
+ }
877
+
360
878
  function buildWidget(
361
879
  json: Record<string, any>,
362
880
  params: {
@@ -364,19 +882,32 @@ function buildWidget(
364
882
  formData: Record<string, any>;
365
883
  onInputChange?: (id: string, value: any) => void;
366
884
  allowFlexExpansion?: boolean;
885
+ parentFlexAxis?: 'vertical' | 'horizontal';
886
+ parentMainAxisBounded?: boolean;
887
+ pageAxisBounds?: AxisBounds;
367
888
  screenData: Record<string, any>;
368
889
  enableFontAwesomeIcons: boolean;
369
890
  key?: string;
370
891
  }
371
892
  ): React.ReactNode {
372
893
  if (!json || typeof json !== 'object') return null;
373
- const type = json.type;
374
- const id = json.id;
375
- const props = { ...(json.properties ?? {}) } as Record<string, any>;
376
- const childrenJson = Array.isArray(json.children) ? json.children : undefined;
377
- const childJson = json.child as Record<string, any> | undefined;
378
- const action = json.action as string | undefined;
379
- const actionPayload = action ? buildActionPayload(props, json) : undefined;
894
+ const normalizedJson = normalizeStackLikeNode(json);
895
+ const type = normalizedJson.type;
896
+ const id = normalizedJson.id;
897
+ const props = { ...(normalizedJson.properties ?? {}) } as Record<string, any>;
898
+ const pageAxisBounds =
899
+ params.pageAxisBounds ??
900
+ getAxisBoundsForPageScroll(resolvePageScrollAxis(params.screenData));
901
+ const childrenJson: Record<string, any>[] | undefined = Array.isArray(
902
+ normalizedJson.children
903
+ )
904
+ ? (normalizedJson.children as Record<string, any>[])
905
+ : undefined;
906
+ const childJson = normalizedJson.child as Record<string, any> | undefined;
907
+ const action = normalizedJson.action as string | undefined;
908
+ const actionPayload = action
909
+ ? buildActionPayload(props, normalizedJson)
910
+ : undefined;
380
911
 
381
912
  let node: React.ReactNode = null;
382
913
 
@@ -565,62 +1096,121 @@ function buildWidget(
565
1096
  const height =
566
1097
  normalizeDimension(parseLayoutDimension(props.height)) ?? 50;
567
1098
  const label = props.label ?? 'Button';
1099
+ const normalizedStroke = normalizeButtonStroke(props.stroke);
1100
+ const normalizedEffects = normalizeButtonEffects(
1101
+ props.effects,
1102
+ props.shadow
1103
+ );
568
1104
  const textStyle = getTextStyle(
569
1105
  { ...props, height: null },
570
1106
  'textColor',
571
1107
  '0xFFFFFFFF'
572
1108
  );
1109
+ const backgroundColor = parseColor(props.color ?? '0xFF2196F3');
1110
+ const shadowBounds = getButtonShadowLayerBounds(
1111
+ normalizedStroke,
1112
+ borderRadius
1113
+ );
1114
+ const webShadowStyle = buildButtonWebShadowStyle(normalizedEffects);
1115
+ const firstNativeEffect =
1116
+ normalizedEffects.length > 0 ? normalizedEffects[0] : null;
1117
+ const nativePrimaryShadowStyle =
1118
+ Platform.OS === 'web' || !firstNativeEffect
1119
+ ? null
1120
+ : buildButtonNativeShadowStyle(firstNativeEffect);
1121
+ const centeredStrokeStyle =
1122
+ normalizedStroke && normalizedStroke.position === 'center'
1123
+ ? {
1124
+ borderWidth: normalizedStroke.width,
1125
+ borderColor: normalizedStroke.color,
1126
+ }
1127
+ : null;
1128
+ const fillLayerStyle: any = {
1129
+ position: 'absolute',
1130
+ top: 0,
1131
+ right: 0,
1132
+ bottom: 0,
1133
+ left: 0,
1134
+ borderRadius,
1135
+ overflow: 'hidden',
1136
+ ...(centeredStrokeStyle ?? {}),
1137
+ };
1138
+ const nativeShadowEffectsInPaintOrder = normalizedEffects
1139
+ .slice()
1140
+ .reverse();
573
1141
 
574
- const content = (
575
- <View
576
- style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}
1142
+ const pressableStyle: any = {
1143
+ width,
1144
+ height,
1145
+ borderRadius,
1146
+ justifyContent: 'center',
1147
+ alignItems: 'center',
1148
+ overflow: 'visible',
1149
+ ...(nativePrimaryShadowStyle ?? {}),
1150
+ };
1151
+
1152
+ node = (
1153
+ <Pressable
1154
+ collapsable={false}
1155
+ onPress={
1156
+ action
1157
+ ? () => params.onAction(action, actionPayload ?? props)
1158
+ : undefined
1159
+ }
1160
+ style={pressableStyle}
577
1161
  >
578
- <Text style={textStyle}>{label}</Text>
579
- </View>
580
- );
1162
+ {Platform.OS === 'web' ? (
1163
+ webShadowStyle ? (
1164
+ <View
1165
+ pointerEvents="none"
1166
+ style={{
1167
+ position: 'absolute',
1168
+ ...shadowBounds,
1169
+ ...webShadowStyle,
1170
+ }}
1171
+ />
1172
+ ) : null
1173
+ ) : (
1174
+ nativeShadowEffectsInPaintOrder.map((effect, index) => (
1175
+ <View
1176
+ key={`shadow-${index}`}
1177
+ collapsable={false}
1178
+ pointerEvents="none"
1179
+ style={{
1180
+ position: 'absolute',
1181
+ ...shadowBounds,
1182
+ ...buildButtonNativeShadowStyle(effect),
1183
+ }}
1184
+ />
1185
+ ))
1186
+ )}
581
1187
 
582
- if (gradient) {
583
- node = (
584
- <Pressable
585
- onPress={
586
- action
587
- ? () => params.onAction(action, actionPayload ?? props)
588
- : undefined
589
- }
590
- style={{ width, height }}
591
- >
1188
+ {gradient ? (
592
1189
  <LinearGradient
593
1190
  colors={gradient.colors}
594
1191
  start={gradient.start}
595
1192
  end={gradient.end}
596
1193
  locations={gradient.stops}
597
- style={{ flex: 1, borderRadius, overflow: 'hidden' }}
598
- >
599
- {content}
600
- </LinearGradient>
601
- </Pressable>
602
- );
603
- } else {
604
- node = (
605
- <Pressable
606
- onPress={
607
- action
608
- ? () => params.onAction(action, actionPayload ?? props)
609
- : undefined
610
- }
611
- style={{
612
- width,
613
- height,
614
- backgroundColor: parseColor(props.color ?? '0xFF2196F3'),
615
- borderRadius,
616
- justifyContent: 'center',
617
- alignItems: 'center',
618
- }}
1194
+ style={fillLayerStyle}
1195
+ />
1196
+ ) : (
1197
+ <View
1198
+ style={{
1199
+ ...fillLayerStyle,
1200
+ backgroundColor,
1201
+ }}
1202
+ />
1203
+ )}
1204
+
1205
+ {renderButtonStrokeOverlay(normalizedStroke, borderRadius)}
1206
+
1207
+ <View
1208
+ style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}
619
1209
  >
620
- {content}
621
- </Pressable>
622
- );
623
- }
1210
+ <Text style={textStyle}>{label}</Text>
1211
+ </View>
1212
+ </Pressable>
1213
+ );
624
1214
  break;
625
1215
  }
626
1216
  case 'text_input': {
@@ -936,25 +1526,170 @@ function buildWidget(
936
1526
  break;
937
1527
  }
938
1528
  case 'stack': {
939
- const alignment = parseAlignment(props.alignment ?? 'topLeft');
940
- const fit = props.fit === 'expand' ? 'expand' : 'loose';
941
- node = (
1529
+ const axis =
1530
+ props.axis === 'horizontal' || props.axis === 'overlay'
1531
+ ? props.axis
1532
+ : 'vertical';
1533
+ const isOverlay = axis === 'overlay';
1534
+ const spacing = Number(props.childSpacing ?? 0);
1535
+ const applySpacing = !STACK_SPACE_DISTRIBUTIONS.has(
1536
+ String(props.distribution ?? 'start')
1537
+ );
1538
+ const paddingInsets = stackSpacingToInsets(
1539
+ props.layout?.padding ?? props.padding
1540
+ );
1541
+ const width = resolveStackDimension(props, axis, 'width', pageAxisBounds);
1542
+ const height = resolveStackDimension(
1543
+ props,
1544
+ axis,
1545
+ 'height',
1546
+ pageAxisBounds
1547
+ );
1548
+ const mainAxisBounded =
1549
+ axis === 'horizontal'
1550
+ ? pageAxisBounds.widthBounded
1551
+ : pageAxisBounds.heightBounded;
1552
+ const borderRadius = Number(
1553
+ props.appearance?.cornerRadius ?? props.borderRadius ?? 0
1554
+ );
1555
+
1556
+ const borderWidth = Number(props.border?.width ?? 0);
1557
+ const borderColor =
1558
+ borderWidth > 0 ? parseColor(props.border?.color ?? '#E5E7EB') : null;
1559
+ const shadowColor = props.shadow?.color
1560
+ ? parseColor(props.shadow.color)
1561
+ : null;
1562
+ const shadowStyle: Record<string, any> =
1563
+ shadowColor && props.shadow
1564
+ ? Platform.OS === 'web'
1565
+ ? {
1566
+ boxShadow: `${Number(props.shadow.x ?? 0)}px ${Number(
1567
+ props.shadow.y ?? 8
1568
+ )}px ${Number(props.shadow.blur ?? 24)}px ${shadowColor}`,
1569
+ }
1570
+ : {
1571
+ shadowColor,
1572
+ shadowOffset: {
1573
+ width: Number(props.shadow.x ?? 0),
1574
+ height: Number(props.shadow.y ?? 8),
1575
+ },
1576
+ shadowOpacity: 0.35,
1577
+ shadowRadius: Math.max(0, Number(props.shadow.blur ?? 24) / 2),
1578
+ elevation: Math.max(
1579
+ 1,
1580
+ Math.round(
1581
+ (Number(props.shadow.blur ?? 24) +
1582
+ Math.abs(Number(props.shadow.y ?? 8))) /
1583
+ 3
1584
+ )
1585
+ ),
1586
+ }
1587
+ : {};
1588
+
1589
+ const fill = props.fill;
1590
+ const stackGradient =
1591
+ fill?.type === 'gradient'
1592
+ ? parseGradient({ ...fill, type: 'gradient' })
1593
+ : undefined;
1594
+ const stackColor =
1595
+ fill?.type === 'solid' || (!fill?.type && fill?.color)
1596
+ ? parseColor(fill?.color ?? '#FFFFFFFF')
1597
+ : parseColor(props.backgroundColor ?? '#00000000');
1598
+
1599
+ const baseStackStyle: any = {
1600
+ width,
1601
+ height,
1602
+ borderRadius: borderRadius > 0 ? borderRadius : undefined,
1603
+ overflow: borderRadius > 0 ? 'hidden' : undefined,
1604
+ borderWidth: borderColor ? borderWidth : undefined,
1605
+ borderColor: borderColor ?? undefined,
1606
+ ...insetsToStyle(paddingInsets),
1607
+ ...shadowStyle,
1608
+ };
1609
+
1610
+ const content = isOverlay ? (
1611
+ <View style={{ position: 'relative', minHeight: 0, minWidth: 0 }}>
1612
+ {(childrenJson ?? []).map((child, index) => {
1613
+ const alignment = parseOverlayGridAlignment(
1614
+ props.overlayAlignment ?? props.alignment
1615
+ );
1616
+ return (
1617
+ <View
1618
+ key={`stack-overlay-${index}`}
1619
+ style={{
1620
+ position: 'absolute',
1621
+ top: 0,
1622
+ right: 0,
1623
+ bottom: 0,
1624
+ left: 0,
1625
+ justifyContent: alignment.justifyContent,
1626
+ alignItems: alignment.alignItems,
1627
+ }}
1628
+ >
1629
+ {buildWidget(child, {
1630
+ ...params,
1631
+ allowFlexExpansion: true,
1632
+ pageAxisBounds,
1633
+ })}
1634
+ </View>
1635
+ );
1636
+ })}
1637
+ {(childrenJson ?? []).length === 0 ? null : null}
1638
+ </View>
1639
+ ) : (
942
1640
  <View
943
1641
  style={{
944
- position: 'relative',
945
- width: fit === 'expand' ? '100%' : undefined,
946
- height: fit === 'expand' ? '100%' : undefined,
947
- justifyContent: alignment.justifyContent,
948
- alignItems: alignment.alignItems,
1642
+ flexDirection: axis === 'horizontal' ? 'row' : 'column',
1643
+ justifyContent: parseFlexAlignment(props.distribution),
1644
+ alignItems: parseCrossAlignment(props.alignment),
1645
+ minHeight: 0,
1646
+ minWidth: 0,
949
1647
  }}
950
1648
  >
951
1649
  {(childrenJson ?? []).map((child, index) => (
952
1650
  <React.Fragment key={`stack-${index}`}>
953
- {buildWidget(child, { ...params })}
1651
+ {buildWidget(child, {
1652
+ ...params,
1653
+ allowFlexExpansion: true,
1654
+ parentFlexAxis:
1655
+ axis === 'horizontal' ? 'horizontal' : 'vertical',
1656
+ parentMainAxisBounded: mainAxisBounded,
1657
+ pageAxisBounds,
1658
+ })}
1659
+ {applySpacing &&
1660
+ spacing > 0 &&
1661
+ index < (childrenJson?.length ?? 0) - 1 ? (
1662
+ <View
1663
+ style={{
1664
+ width: axis === 'horizontal' ? spacing : undefined,
1665
+ height: axis === 'vertical' ? spacing : undefined,
1666
+ }}
1667
+ />
1668
+ ) : null}
954
1669
  </React.Fragment>
955
1670
  ))}
956
1671
  </View>
957
1672
  );
1673
+
1674
+ if (stackGradient) {
1675
+ node = (
1676
+ <LinearGradient
1677
+ colors={stackGradient.colors}
1678
+ start={stackGradient.start}
1679
+ end={stackGradient.end}
1680
+ locations={stackGradient.stops}
1681
+ style={baseStackStyle}
1682
+ >
1683
+ {content}
1684
+ </LinearGradient>
1685
+ );
1686
+ } else {
1687
+ node = (
1688
+ <View style={{ ...baseStackStyle, backgroundColor: stackColor }}>
1689
+ {content}
1690
+ </View>
1691
+ );
1692
+ }
958
1693
  break;
959
1694
  }
960
1695
  case 'positioned': {
@@ -987,29 +1722,64 @@ function buildWidget(
987
1722
 
988
1723
  if (!node) return null;
989
1724
 
990
- const marginVal = props.margin;
1725
+ const marginVal =
1726
+ type === 'stack' ? props.layout?.margin ?? props.margin : props.margin;
991
1727
  if (marginVal !== undefined && marginVal !== null) {
992
- const marginInsets = parseInsets(marginVal);
1728
+ const marginInsets =
1729
+ type === 'stack'
1730
+ ? stackSpacingToInsets(marginVal)
1731
+ : parseInsets(marginVal);
993
1732
  node = <View style={insetsToMarginStyle(marginInsets)}>{node}</View>;
994
- } else if (type !== 'container' && type !== 'padding' && props.padding) {
1733
+ } else if (
1734
+ type !== 'container' &&
1735
+ type !== 'padding' &&
1736
+ type !== 'stack' &&
1737
+ props.padding
1738
+ ) {
995
1739
  const paddingInsets = parseInsets(props.padding);
996
1740
  node = <View style={insetsToStyle(paddingInsets)}>{node}</View>;
997
1741
  }
998
1742
 
999
- if (params.allowFlexExpansion && params.screenData.scrollable !== true) {
1743
+ if (params.allowFlexExpansion) {
1000
1744
  let shouldExpand = false;
1001
- if (type === 'stack' && props.fit === 'expand') {
1002
- shouldExpand = true;
1003
- }
1004
- if (props.height !== undefined) {
1005
- const heightVal = parseLayoutDimension(props.height);
1006
- if (heightVal === Number.POSITIVE_INFINITY) {
1007
- shouldExpand = true;
1745
+ const parentAxis = params.parentFlexAxis ?? 'vertical';
1746
+ const parentMainAxisBounded = params.parentMainAxisBounded ?? true;
1747
+
1748
+ if (parentMainAxisBounded) {
1749
+ if (type === 'stack') {
1750
+ const legacyExpand = props.fit === 'expand';
1751
+ const widthMode = String(props.size?.width ?? '').toLowerCase();
1752
+ const heightMode = String(props.size?.height ?? '').toLowerCase();
1753
+ if (
1754
+ parentAxis === 'vertical' &&
1755
+ (legacyExpand || heightMode === 'fill')
1756
+ ) {
1757
+ shouldExpand = true;
1758
+ }
1759
+ if (
1760
+ parentAxis === 'horizontal' &&
1761
+ (legacyExpand || widthMode === 'fill')
1762
+ ) {
1763
+ shouldExpand = true;
1764
+ }
1765
+ }
1766
+
1767
+ if (parentAxis === 'vertical' && props.height !== undefined) {
1768
+ const heightVal = parseLayoutDimension(props.height);
1769
+ if (heightVal === Number.POSITIVE_INFINITY) {
1770
+ shouldExpand = true;
1771
+ }
1772
+ }
1773
+ if (parentAxis === 'horizontal' && props.width !== undefined) {
1774
+ const widthVal = parseLayoutDimension(props.width);
1775
+ if (widthVal === Number.POSITIVE_INFINITY) {
1776
+ shouldExpand = true;
1777
+ }
1008
1778
  }
1009
1779
  }
1010
1780
 
1011
1781
  if (shouldExpand) {
1012
- node = <View style={{ flex: 1 }}>{node}</View>;
1782
+ node = <View style={{ flex: 1, minHeight: 0, minWidth: 0 }}>{node}</View>;
1013
1783
  }
1014
1784
  }
1015
1785
 
@@ -1088,6 +1858,227 @@ function parseGradient(value: any):
1088
1858
  return { colors, start, end, stops };
1089
1859
  }
1090
1860
 
1861
+ type ButtonStrokePosition = 'inside' | 'center' | 'outside';
1862
+
1863
+ interface ButtonStroke {
1864
+ enabled?: boolean;
1865
+ color?: string;
1866
+ opacity?: number;
1867
+ width?: number;
1868
+ position?: ButtonStrokePosition;
1869
+ }
1870
+
1871
+ interface ButtonDropShadowEffect {
1872
+ type?: 'dropShadow' | string;
1873
+ enabled?: boolean;
1874
+ x?: number;
1875
+ y?: number;
1876
+ blur?: number;
1877
+ spread?: number;
1878
+ color?: string;
1879
+ opacity?: number;
1880
+ }
1881
+
1882
+ interface LegacyButtonShadow {
1883
+ enabled?: boolean;
1884
+ x?: number;
1885
+ y?: number;
1886
+ blur?: number;
1887
+ color?: string;
1888
+ }
1889
+
1890
+ type NormalizedButtonStroke = {
1891
+ color: string;
1892
+ width: number;
1893
+ position: ButtonStrokePosition;
1894
+ };
1895
+
1896
+ type NormalizedButtonDropShadow = {
1897
+ x: number;
1898
+ y: number;
1899
+ blur: number;
1900
+ spread: number;
1901
+ color: string;
1902
+ opacity: number;
1903
+ };
1904
+
1905
+ function normalizeButtonStroke(
1906
+ input: ButtonStroke | undefined
1907
+ ): NormalizedButtonStroke | null {
1908
+ if (!input || typeof input !== 'object') return null;
1909
+ const legacyStrokeWithoutEnabled = input.enabled === undefined;
1910
+ const enabled = legacyStrokeWithoutEnabled ? true : input.enabled === true;
1911
+ if (!enabled) return null;
1912
+
1913
+ const width = Number(input.width ?? 1);
1914
+ if (!Number.isFinite(width) || width <= 0) return null;
1915
+
1916
+ const position =
1917
+ input.position === 'inside' ||
1918
+ input.position === 'center' ||
1919
+ input.position === 'outside'
1920
+ ? input.position
1921
+ : 'center';
1922
+ const opacity = clamp01(Number(input.opacity ?? 1));
1923
+ const color = normalizeColorWithOpacity(input.color, opacity);
1924
+ if (color === 'transparent') return null;
1925
+
1926
+ return { color, width, position };
1927
+ }
1928
+
1929
+ function normalizeButtonEffects(
1930
+ effectsInput: ButtonDropShadowEffect[] | undefined,
1931
+ legacyShadowInput: LegacyButtonShadow | undefined
1932
+ ): NormalizedButtonDropShadow[] {
1933
+ if (Array.isArray(effectsInput)) {
1934
+ return effectsInput
1935
+ .map(normalizeDropShadowEffect)
1936
+ .filter(
1937
+ (effect): effect is NormalizedButtonDropShadow => effect !== null
1938
+ );
1939
+ }
1940
+
1941
+ if (!legacyShadowInput || typeof legacyShadowInput !== 'object') {
1942
+ return [];
1943
+ }
1944
+ if (legacyShadowInput.enabled !== true) return [];
1945
+
1946
+ const migrated = normalizeDropShadowEffect({
1947
+ type: 'dropShadow',
1948
+ enabled: true,
1949
+ x: legacyShadowInput.x,
1950
+ y: legacyShadowInput.y,
1951
+ blur: legacyShadowInput.blur,
1952
+ spread: 0,
1953
+ color: legacyShadowInput.color,
1954
+ opacity: 1,
1955
+ });
1956
+
1957
+ return migrated ? [migrated] : [];
1958
+ }
1959
+
1960
+ function normalizeDropShadowEffect(
1961
+ effect: ButtonDropShadowEffect
1962
+ ): NormalizedButtonDropShadow | null {
1963
+ if (!effect || typeof effect !== 'object') return null;
1964
+ if ((effect.type ?? 'dropShadow') !== 'dropShadow') return null;
1965
+ if (effect.enabled === false) return null;
1966
+
1967
+ const opacity = clamp01(Number(effect.opacity ?? 1));
1968
+ const color = normalizeColorWithOpacity(effect.color, opacity);
1969
+ if (color === 'transparent') return null;
1970
+
1971
+ return {
1972
+ x: Number(effect.x ?? 0),
1973
+ y: Number(effect.y ?? 0),
1974
+ blur: Math.max(0, Number(effect.blur ?? 0)),
1975
+ spread: Number(effect.spread ?? 0),
1976
+ color,
1977
+ opacity,
1978
+ };
1979
+ }
1980
+
1981
+ function normalizeColorWithOpacity(
1982
+ input: string | undefined,
1983
+ opacity: number
1984
+ ): string {
1985
+ const raw = typeof input === 'string' ? input.trim() : '';
1986
+ const normalizedOpacity = clamp01(opacity);
1987
+ if (raw.startsWith('rgba(') || raw.startsWith('rgb(')) {
1988
+ return withOpacity(raw, normalizedOpacity);
1989
+ }
1990
+ return withOpacity(parseColor(input ?? '0xFF000000'), normalizedOpacity);
1991
+ }
1992
+
1993
+ function buildButtonWebShadowStyle(
1994
+ effects: NormalizedButtonDropShadow[]
1995
+ ): Record<string, any> | null {
1996
+ if (effects.length === 0) return null;
1997
+ const layers = effects.map(
1998
+ (effect) =>
1999
+ `${effect.x}px ${effect.y}px ${effect.blur}px ${effect.spread}px ${effect.color}`
2000
+ );
2001
+ return { boxShadow: layers.join(', ') };
2002
+ }
2003
+
2004
+ function buildButtonNativeShadowStyle(
2005
+ effect: NormalizedButtonDropShadow
2006
+ ): Record<string, any> {
2007
+ const rgba = parseRgbaColor(effect.color);
2008
+ return {
2009
+ // Android elevation requires a drawable host shape; keep it effectively invisible.
2010
+ backgroundColor: 'rgba(255,255,255,0.02)',
2011
+ shadowColor: rgba ? `rgba(${rgba.r},${rgba.g},${rgba.b},1)` : effect.color,
2012
+ shadowOffset: { width: effect.x, height: effect.y },
2013
+ shadowOpacity: rgba ? rgba.a : effect.opacity,
2014
+ shadowRadius: effect.blur / 2,
2015
+ elevation: Math.max(1, Math.round((effect.blur + Math.abs(effect.y)) / 3)),
2016
+ };
2017
+ }
2018
+
2019
+ function getButtonShadowLayerBounds(
2020
+ stroke: NormalizedButtonStroke | null,
2021
+ borderRadius: number
2022
+ ): Record<string, number> {
2023
+ const expandBy =
2024
+ !stroke || stroke.position === 'inside'
2025
+ ? 0
2026
+ : stroke.position === 'center'
2027
+ ? stroke.width / 2
2028
+ : stroke.width;
2029
+
2030
+ return {
2031
+ top: -expandBy,
2032
+ right: -expandBy,
2033
+ bottom: -expandBy,
2034
+ left: -expandBy,
2035
+ borderRadius: borderRadius + expandBy,
2036
+ };
2037
+ }
2038
+
2039
+ function renderButtonStrokeOverlay(
2040
+ stroke: NormalizedButtonStroke | null,
2041
+ borderRadius: number
2042
+ ) {
2043
+ if (!stroke || stroke.position === 'center') {
2044
+ return null;
2045
+ }
2046
+
2047
+ if (stroke.position === 'inside') {
2048
+ return (
2049
+ <View
2050
+ pointerEvents="none"
2051
+ style={{
2052
+ position: 'absolute',
2053
+ top: stroke.width / 2,
2054
+ left: stroke.width / 2,
2055
+ right: stroke.width / 2,
2056
+ bottom: stroke.width / 2,
2057
+ borderRadius: Math.max(0, borderRadius - stroke.width / 2),
2058
+ borderWidth: stroke.width,
2059
+ borderColor: stroke.color,
2060
+ }}
2061
+ />
2062
+ );
2063
+ }
2064
+
2065
+ return (
2066
+ <View
2067
+ pointerEvents="none"
2068
+ style={{
2069
+ position: 'absolute',
2070
+ top: -stroke.width,
2071
+ left: -stroke.width,
2072
+ right: -stroke.width,
2073
+ bottom: -stroke.width,
2074
+ borderRadius: borderRadius + stroke.width,
2075
+ borderWidth: stroke.width,
2076
+ borderColor: stroke.color,
2077
+ }}
2078
+ />
2079
+ );
2080
+ }
2081
+
1091
2082
  function alignmentToGradient(value: string) {
1092
2083
  switch (value) {
1093
2084
  case 'topCenter':
@@ -1113,13 +2104,29 @@ function alignmentToGradient(value: string) {
1113
2104
  }
1114
2105
 
1115
2106
  function withOpacity(color: string, opacity: number): string {
1116
- if (color.startsWith('rgba')) {
1117
- return color.replace(
1118
- /rgba\((\d+),(\d+),(\d+),([\d.]+)\)/,
1119
- (_m, r, g, b) => `rgba(${r},${g},${b},${opacity})`
1120
- );
1121
- }
1122
- return color;
2107
+ const rgba = parseRgbaColor(color);
2108
+ if (!rgba) return color;
2109
+ return `rgba(${rgba.r},${rgba.g},${rgba.b},${clamp01(rgba.a * opacity)})`;
2110
+ }
2111
+
2112
+ function parseRgbaColor(
2113
+ color: string
2114
+ ): { r: number; g: number; b: number; a: number } | null {
2115
+ const match = color.match(
2116
+ /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i
2117
+ );
2118
+ if (!match) return null;
2119
+ return {
2120
+ r: Math.round(Number(match[1])),
2121
+ g: Math.round(Number(match[2])),
2122
+ b: Math.round(Number(match[3])),
2123
+ a: match[4] === undefined ? 1 : clamp01(Number(match[4])),
2124
+ };
2125
+ }
2126
+
2127
+ function clamp01(value: number): number {
2128
+ if (!Number.isFinite(value)) return 1;
2129
+ return Math.max(0, Math.min(1, value));
1123
2130
  }
1124
2131
 
1125
2132
  function normalizeDimension(
@@ -1278,6 +2285,81 @@ function GradientText({
1278
2285
  );
1279
2286
  }
1280
2287
 
2288
+ type TextInputStrokeStyle = 'solid' | 'dashed' | 'dotted';
2289
+
2290
+ type NormalizedTextInputStroke = {
2291
+ width: number;
2292
+ color: string;
2293
+ radius: number;
2294
+ style: TextInputStrokeStyle;
2295
+ };
2296
+
2297
+ function resolveTextInputBackgroundColor(properties: Record<string, any>) {
2298
+ const canonical = properties.backgroundColor;
2299
+ if (typeof canonical === 'string' && canonical.trim().length > 0) {
2300
+ return parseColor(canonical);
2301
+ }
2302
+
2303
+ const legacyBackground = properties.background;
2304
+ if (
2305
+ legacyBackground &&
2306
+ typeof legacyBackground === 'object' &&
2307
+ !Array.isArray(legacyBackground)
2308
+ ) {
2309
+ const type = String(legacyBackground.type ?? '').toLowerCase();
2310
+ if (type === 'color' && typeof legacyBackground.color === 'string') {
2311
+ return parseColor(legacyBackground.color);
2312
+ }
2313
+ if (type === 'gradient' && Array.isArray(legacyBackground.colors)) {
2314
+ const firstGradientColor = legacyBackground.colors.find(
2315
+ (value: unknown) => typeof value === 'string' && value.trim().length > 0
2316
+ );
2317
+ if (typeof firstGradientColor === 'string') {
2318
+ return parseColor(firstGradientColor);
2319
+ }
2320
+ }
2321
+ } else if (
2322
+ typeof legacyBackground === 'string' &&
2323
+ legacyBackground.trim().length > 0
2324
+ ) {
2325
+ return parseColor(legacyBackground);
2326
+ }
2327
+
2328
+ return parseColor('0xFFF0F0F0');
2329
+ }
2330
+
2331
+ function normalizeTextInputStroke(
2332
+ properties: Record<string, any>
2333
+ ): NormalizedTextInputStroke {
2334
+ const parsedWidth = Number(properties.strokeWidth ?? 0);
2335
+ const width =
2336
+ Number.isFinite(parsedWidth) && parsedWidth > 0 ? parsedWidth : 0;
2337
+ const enabled =
2338
+ typeof properties.strokeEnabled === 'boolean'
2339
+ ? properties.strokeEnabled
2340
+ : width > 0;
2341
+ const normalizedWidth = enabled ? width : 0;
2342
+ const color =
2343
+ normalizedWidth > 0
2344
+ ? parseColor(properties.strokeColor ?? '#000000')
2345
+ : 'transparent';
2346
+ const parsedRadius = Number(
2347
+ properties.strokeRadius ?? properties.borderRadius ?? 8
2348
+ );
2349
+ const radius = Number.isFinite(parsedRadius) ? Math.max(0, parsedRadius) : 8;
2350
+ const style: TextInputStrokeStyle =
2351
+ properties.strokeStyle === 'dashed' || properties.strokeStyle === 'dotted'
2352
+ ? properties.strokeStyle
2353
+ : 'solid';
2354
+
2355
+ return {
2356
+ width: normalizedWidth,
2357
+ color,
2358
+ radius,
2359
+ style,
2360
+ };
2361
+ }
2362
+
1281
2363
  function DynamicInput({
1282
2364
  initialValue,
1283
2365
  properties,
@@ -1315,10 +2397,12 @@ function DynamicInput({
1315
2397
  );
1316
2398
  const inputStyle = getTextStyle({ ...properties, height: null }, 'textColor');
1317
2399
 
1318
- const backgroundColor = parseColor(
1319
- properties.backgroundColor ?? '0xFFF0F0F0'
1320
- );
1321
- const borderRadius = Number(properties.borderRadius ?? 8);
2400
+ const backgroundColor = resolveTextInputBackgroundColor(properties);
2401
+ const stroke = normalizeTextInputStroke(properties);
2402
+ const parsedPadding = Number(properties.padding ?? 12);
2403
+ const padding = Number.isFinite(parsedPadding)
2404
+ ? Math.max(0, parsedPadding)
2405
+ : 12;
1322
2406
 
1323
2407
  return (
1324
2408
  <View>
@@ -1326,9 +2410,11 @@ function DynamicInput({
1326
2410
  <View
1327
2411
  style={{
1328
2412
  backgroundColor,
1329
- borderRadius,
1330
- paddingHorizontal: 16,
1331
- paddingVertical: 10,
2413
+ borderRadius: stroke.radius,
2414
+ borderStyle: stroke.width > 0 ? stroke.style : undefined,
2415
+ borderWidth: stroke.width,
2416
+ borderColor: stroke.color,
2417
+ padding,
1332
2418
  }}
1333
2419
  >
1334
2420
  {mask ? (
@@ -1498,7 +2584,7 @@ function SelectionList({
1498
2584
 
1499
2585
  if (layout === 'row') {
1500
2586
  return (
1501
- <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
2587
+ <View style={{ width: '100%', flexDirection: 'row', flexWrap: 'wrap' }}>
1502
2588
  {options.map((option, index) => (
1503
2589
  <View
1504
2590
  key={`row-option-${index}`}
@@ -1515,7 +2601,7 @@ function SelectionList({
1515
2601
  const crossAxisCount = Number(properties.gridCrossAxisCount ?? 2);
1516
2602
  const aspectRatio = Number(properties.gridAspectRatio ?? 1);
1517
2603
  return (
1518
- <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
2604
+ <View style={{ width: '100%', flexDirection: 'row', flexWrap: 'wrap' }}>
1519
2605
  {options.map((option, index) => (
1520
2606
  <View
1521
2607
  key={`grid-option-${index}`}
@@ -1534,9 +2620,12 @@ function SelectionList({
1534
2620
  }
1535
2621
 
1536
2622
  return (
1537
- <View>
2623
+ <View style={{ width: '100%' }}>
1538
2624
  {options.map((option, index) => (
1539
- <View key={`column-option-${index}`} style={{ marginBottom: spacing }}>
2625
+ <View
2626
+ key={`column-option-${index}`}
2627
+ style={{ width: '100%', marginBottom: spacing }}
2628
+ >
1540
2629
  {renderItem(option, index)}
1541
2630
  </View>
1542
2631
  ))}
@@ -1653,7 +2742,11 @@ function WheelPicker({
1653
2742
  const parsed = Number(raw);
1654
2743
  if (!Number.isFinite(parsed)) return 1;
1655
2744
  return Math.max(1, Math.floor(parsed));
1656
- }, [properties.startIndex, properties.startItemIndex, properties.start_index]);
2745
+ }, [
2746
+ properties.startIndex,
2747
+ properties.startItemIndex,
2748
+ properties.start_index,
2749
+ ]);
1657
2750
 
1658
2751
  const clampWheelIndex = React.useCallback(
1659
2752
  (index: number) =>
@@ -1697,7 +2790,14 @@ function WheelPicker({
1697
2790
  }
1698
2791
  }
1699
2792
  },
1700
- [clampWheelIndex, onAction, onChanged, properties.autoGoNext, resolvedOptions, triggerLightHaptic]
2793
+ [
2794
+ clampWheelIndex,
2795
+ onAction,
2796
+ onChanged,
2797
+ properties.autoGoNext,
2798
+ resolvedOptions,
2799
+ triggerLightHaptic,
2800
+ ]
1701
2801
  );
1702
2802
 
1703
2803
  React.useEffect(() => {
@@ -1861,10 +2961,19 @@ function WheelPicker({
1861
2961
  const overlayBackgroundColor = parseColor(
1862
2962
  properties.wheelCenterBackgroundColor ?? '#24FFFFFF'
1863
2963
  );
2964
+ const wheelEdgeFadeOpacity = Math.max(
2965
+ 0,
2966
+ Math.min(1, Number(properties.wheelEdgeFadeOpacity ?? 0))
2967
+ );
2968
+ const wheelEdgeFadeColor = parseColor(
2969
+ properties.wheelEdgeFadeColor ?? '#FFFFFF'
2970
+ );
1864
2971
 
1865
2972
  const handleWheelMomentumEnd = (event: any) => {
1866
2973
  const offsetY = Number(event?.nativeEvent?.contentOffset?.y ?? 0);
1867
- const settledIndex = clampWheelIndex(Math.round(offsetY / wheelRowStride));
2974
+ const settledIndex = clampWheelIndex(
2975
+ Math.round(offsetY / wheelRowStride)
2976
+ );
1868
2977
  const targetOffset = settledIndex * wheelRowStride;
1869
2978
  if (Math.abs(targetOffset - offsetY) > 0.5) {
1870
2979
  wheelScrollRef.current?.scrollTo({
@@ -1937,7 +3046,12 @@ function WheelPicker({
1937
3046
  ],
1938
3047
  }}
1939
3048
  >
1940
- <View style={{ minHeight: wheelItemHeight, justifyContent: 'center' }}>
3049
+ <View
3050
+ style={{
3051
+ minHeight: wheelItemHeight,
3052
+ justifyContent: 'center',
3053
+ }}
3054
+ >
1941
3055
  {renderItem(option, index)}
1942
3056
  </View>
1943
3057
  </Animated.View>
@@ -1959,22 +3073,38 @@ function WheelPicker({
1959
3073
  backgroundColor: overlayBackgroundColor,
1960
3074
  }}
1961
3075
  />
1962
- <LinearGradient
1963
- pointerEvents="none"
1964
- colors={['rgba(255,255,255,0.92)', 'rgba(255,255,255,0)']}
1965
- style={{ position: 'absolute', top: 0, left: 0, right: 0, height: centerPadding }}
1966
- />
1967
- <LinearGradient
1968
- pointerEvents="none"
1969
- colors={['rgba(255,255,255,0)', 'rgba(255,255,255,0.92)']}
1970
- style={{
1971
- position: 'absolute',
1972
- bottom: 0,
1973
- left: 0,
1974
- right: 0,
1975
- height: centerPadding,
1976
- }}
1977
- />
3076
+ {wheelEdgeFadeOpacity > 0 && centerPadding > 0 ? (
3077
+ <LinearGradient
3078
+ pointerEvents="none"
3079
+ colors={[
3080
+ withOpacity(wheelEdgeFadeColor, wheelEdgeFadeOpacity),
3081
+ withOpacity(wheelEdgeFadeColor, 0),
3082
+ ]}
3083
+ style={{
3084
+ position: 'absolute',
3085
+ top: 0,
3086
+ left: 0,
3087
+ right: 0,
3088
+ height: centerPadding,
3089
+ }}
3090
+ />
3091
+ ) : null}
3092
+ {wheelEdgeFadeOpacity > 0 && centerPadding > 0 ? (
3093
+ <LinearGradient
3094
+ pointerEvents="none"
3095
+ colors={[
3096
+ withOpacity(wheelEdgeFadeColor, 0),
3097
+ withOpacity(wheelEdgeFadeColor, wheelEdgeFadeOpacity),
3098
+ ]}
3099
+ style={{
3100
+ position: 'absolute',
3101
+ bottom: 0,
3102
+ left: 0,
3103
+ right: 0,
3104
+ height: centerPadding,
3105
+ }}
3106
+ />
3107
+ ) : null}
1978
3108
  </View>
1979
3109
  );
1980
3110
  }
@@ -1982,7 +3112,10 @@ function WheelPicker({
1982
3112
  return (
1983
3113
  <View>
1984
3114
  {resolvedOptions.map((option, index) => (
1985
- <View key={`wheel-list-option-${index}`} style={{ marginBottom: spacing }}>
3115
+ <View
3116
+ key={`wheel-list-option-${index}`}
3117
+ style={{ marginBottom: spacing }}
3118
+ >
1986
3119
  {renderItem(option, index)}
1987
3120
  </View>
1988
3121
  ))}
@@ -2066,7 +3199,10 @@ function buildWheelTemplateBaseContext(
2066
3199
  properties.start ?? properties.itemStart,
2067
3200
  context
2068
3201
  );
2069
- const step = resolveNumericValue(properties.step ?? properties.itemStep, context);
3202
+ const step = resolveNumericValue(
3203
+ properties.step ?? properties.itemStep,
3204
+ context
3205
+ );
2070
3206
 
2071
3207
  if (start !== null) context.start = start;
2072
3208
  if (step !== null) context.step = step;
@@ -2527,6 +3663,14 @@ function RadarChart({
2527
3663
  'color',
2528
3664
  '0xFF424242'
2529
3665
  );
3666
+ const resolvedAxisLabelFontSize = resolveRadarAxisLabelFontSize(
3667
+ axisLabelStyle.fontSize
3668
+ );
3669
+ const autoFitLabels = properties.autoFitLabels !== false;
3670
+ const resolvedLabelOffset = resolveRadarLabelOffset(
3671
+ properties.labelOffset,
3672
+ resolvedAxisLabelFontSize
3673
+ );
2530
3674
  const showLegend = properties.showLegend !== false;
2531
3675
  const legendPosition = (properties.legendPosition ?? 'bottom').toLowerCase();
2532
3676
  const legendSpacing = Number(properties.legendSpacing ?? 12);
@@ -2536,14 +3680,21 @@ function RadarChart({
2536
3680
  '0xFF212121'
2537
3681
  );
2538
3682
  const shape = (properties.shape ?? 'polygon').toLowerCase();
2539
-
2540
- const chartSize = Math.min(width, height);
2541
- const radius = Math.max(
2542
- chartSize / 2 -
2543
- Math.max(padding.left, padding.right, padding.top, padding.bottom),
2544
- 8
2545
- );
2546
- const center = { x: width / 2, y: height / 2 };
3683
+ const innerWidth = Math.max(1, width - padding.left - padding.right);
3684
+ const innerHeight = Math.max(1, height - padding.top - padding.bottom);
3685
+ const radius = calculateRadarChartRadius({
3686
+ width: innerWidth,
3687
+ height: innerHeight,
3688
+ padding: { left: 0, right: 0, top: 0, bottom: 0 },
3689
+ axisLabels: axes.map((axis) => axis.label),
3690
+ fontSize: resolvedAxisLabelFontSize,
3691
+ autoFitLabels,
3692
+ labelOffset: resolvedLabelOffset,
3693
+ });
3694
+ const center = {
3695
+ x: padding.left + innerWidth / 2,
3696
+ y: padding.top + innerHeight / 2,
3697
+ };
2547
3698
 
2548
3699
  const axisAngle = (Math.PI * 2) / axes.length;
2549
3700
 
@@ -2562,7 +3713,10 @@ function RadarChart({
2562
3713
  const points = dataset.values.map((value, index) => {
2563
3714
  const axis = normalizedAxes[index];
2564
3715
  const maxValue = axis?.maxValue ?? 0;
2565
- const ratio = (maxValue > 0 ? value / maxValue : 0) * lineProgress;
3716
+ const normalizedRatio = maxValue > 0 ? value / maxValue : 0;
3717
+ const ratio =
3718
+ Math.max(0, Math.min(1, normalizedRatio)) *
3719
+ Math.max(0, Math.min(1, lineProgress));
2566
3720
  const angle = index * axisAngle - Math.PI / 2;
2567
3721
  const x = center.x + radius * ratio * Math.cos(angle);
2568
3722
  const y = center.y + radius * ratio * Math.sin(angle);
@@ -2571,16 +3725,24 @@ function RadarChart({
2571
3725
  return { dataset, points };
2572
3726
  });
2573
3727
 
3728
+ const isVerticalLegend =
3729
+ legendPosition === 'left' || legendPosition === 'right';
2574
3730
  const legend = (
2575
- <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
3731
+ <View
3732
+ style={{
3733
+ flexDirection: isVerticalLegend ? 'column' : 'row',
3734
+ flexWrap: isVerticalLegend ? 'nowrap' : 'wrap',
3735
+ alignItems: isVerticalLegend ? 'flex-start' : 'center',
3736
+ }}
3737
+ >
2576
3738
  {datasets.map((dataset, index) => (
2577
3739
  <View
2578
3740
  key={`legend-${index}`}
2579
3741
  style={{
2580
3742
  flexDirection: 'row',
2581
3743
  alignItems: 'center',
2582
- marginRight: legendSpacing,
2583
- marginBottom: 6,
3744
+ marginRight: isVerticalLegend ? 0 : legendSpacing,
3745
+ marginBottom: isVerticalLegend ? legendSpacing : 6,
2584
3746
  }}
2585
3747
  >
2586
3748
  <View
@@ -2588,11 +3750,15 @@ function RadarChart({
2588
3750
  width: 12,
2589
3751
  height: 12,
2590
3752
  borderRadius: 6,
2591
- backgroundColor: dataset.borderColor,
3753
+ backgroundColor: dataset.fillColor ?? dataset.borderColor,
3754
+ borderWidth: 1,
3755
+ borderColor: dataset.borderColor,
2592
3756
  marginRight: 6,
2593
3757
  }}
2594
3758
  />
2595
- <Text style={legendStyle}>{dataset.label}</Text>
3759
+ <Text numberOfLines={1} style={legendStyle}>
3760
+ {dataset.label}
3761
+ </Text>
2596
3762
  </View>
2597
3763
  ))}
2598
3764
  </View>
@@ -2601,12 +3767,10 @@ function RadarChart({
2601
3767
  const chart = (
2602
3768
  <Animated.View
2603
3769
  style={{
2604
- width: normalizedWidth ?? width,
3770
+ width: isVerticalLegend ? width : normalizedWidth ?? '100%',
3771
+ maxWidth: '100%',
3772
+ flexShrink: 1,
2605
3773
  height,
2606
- paddingLeft: padding.left,
2607
- paddingRight: padding.right,
2608
- paddingTop: padding.top,
2609
- paddingBottom: padding.bottom,
2610
3774
  opacity: reveal,
2611
3775
  }}
2612
3776
  onLayout={(event) => {
@@ -2657,14 +3821,16 @@ function RadarChart({
2657
3821
  })}
2658
3822
  {axes.map((axis, index) => {
2659
3823
  const angle = index * axisAngle - Math.PI / 2;
2660
- const labelX = center.x + (radius + 12) * Math.cos(angle);
2661
- const labelY = center.y + (radius + 12) * Math.sin(angle);
3824
+ const labelX =
3825
+ center.x + (radius + resolvedLabelOffset) * Math.cos(angle);
3826
+ const labelY =
3827
+ center.y + (radius + resolvedLabelOffset) * Math.sin(angle);
2662
3828
  return (
2663
3829
  <SvgText
2664
3830
  key={`label-${index}`}
2665
3831
  x={labelX}
2666
3832
  y={labelY}
2667
- fontSize={axisLabelStyle.fontSize ?? 12}
3833
+ fontSize={resolvedAxisLabelFontSize}
2668
3834
  fontWeight={axisLabelStyle.fontWeight}
2669
3835
  fill={axisLabelStyle.color}
2670
3836
  textAnchor="middle"
@@ -2673,21 +3839,20 @@ function RadarChart({
2673
3839
  </SvgText>
2674
3840
  );
2675
3841
  })}
2676
- {datasetPolygons.map((entry, index) => (
2677
- <Polygon
2678
- key={`dataset-${index}`}
2679
- points={entry.points.join(' ')}
2680
- stroke={entry.dataset.borderColor}
2681
- strokeWidth={entry.dataset.borderWidth}
2682
- fill={entry.dataset.fillColor ?? 'transparent'}
2683
- strokeDasharray={
2684
- entry.dataset.borderStyle === 'dotted' ||
2685
- entry.dataset.borderStyle === 'dashed'
2686
- ? entry.dataset.dashArray ?? '6 4'
2687
- : undefined
2688
- }
2689
- />
2690
- ))}
3842
+ {datasetPolygons.map((entry, index) => {
3843
+ const strokePattern = resolveRadarStrokePattern(entry.dataset);
3844
+ return (
3845
+ <Polygon
3846
+ key={`dataset-${index}`}
3847
+ points={entry.points.join(' ')}
3848
+ stroke={entry.dataset.borderColor}
3849
+ strokeWidth={entry.dataset.borderWidth}
3850
+ fill={entry.dataset.fillColor ?? 'transparent'}
3851
+ strokeDasharray={strokePattern.strokeDasharray}
3852
+ strokeLinecap={strokePattern.strokeLinecap}
3853
+ />
3854
+ );
3855
+ })}
2691
3856
  {datasetPolygons.map((entry, datasetIndex) =>
2692
3857
  entry.dataset.showPoints
2693
3858
  ? entry.points.map((point, pointIndex) => {
@@ -2709,22 +3874,15 @@ function RadarChart({
2709
3874
  </Animated.View>
2710
3875
  );
2711
3876
 
2712
- const composed = (
2713
- <View style={{ backgroundColor }}>
2714
- {chart}
2715
- {showLegend && legendPosition === 'bottom' ? (
2716
- <View style={{ marginTop: 12 }}>{legend}</View>
2717
- ) : null}
2718
- </View>
2719
- );
2720
-
2721
- if (!showLegend || legendPosition === 'bottom') return composed;
3877
+ if (!showLegend) {
3878
+ return <View style={{ backgroundColor }}>{chart}</View>;
3879
+ }
2722
3880
 
2723
3881
  if (legendPosition === 'top') {
2724
3882
  return (
2725
- <View>
3883
+ <View style={{ backgroundColor, alignItems: 'center' }}>
2726
3884
  {legend}
2727
- <View style={{ height: 12 }} />
3885
+ <View style={{ height: legendSpacing }} />
2728
3886
  {chart}
2729
3887
  </View>
2730
3888
  );
@@ -2732,9 +3890,11 @@ function RadarChart({
2732
3890
 
2733
3891
  if (legendPosition === 'left') {
2734
3892
  return (
2735
- <View style={{ flexDirection: 'row' }}>
3893
+ <View
3894
+ style={{ backgroundColor, flexDirection: 'row', alignItems: 'center' }}
3895
+ >
2736
3896
  {legend}
2737
- <View style={{ width: 12 }} />
3897
+ <View style={{ width: legendSpacing }} />
2738
3898
  {chart}
2739
3899
  </View>
2740
3900
  );
@@ -2742,32 +3902,262 @@ function RadarChart({
2742
3902
 
2743
3903
  if (legendPosition === 'right') {
2744
3904
  return (
2745
- <View style={{ flexDirection: 'row' }}>
3905
+ <View
3906
+ style={{ backgroundColor, flexDirection: 'row', alignItems: 'center' }}
3907
+ >
2746
3908
  {chart}
2747
- <View style={{ width: 12 }} />
3909
+ <View style={{ width: legendSpacing }} />
2748
3910
  {legend}
2749
3911
  </View>
2750
3912
  );
2751
3913
  }
2752
3914
 
2753
- return composed;
3915
+ return (
3916
+ <View style={{ backgroundColor, alignItems: 'center' }}>
3917
+ {chart}
3918
+ <View style={{ height: legendSpacing }} />
3919
+ {legend}
3920
+ </View>
3921
+ );
2754
3922
  }
2755
3923
 
2756
3924
  export type RadarAxis = { label: string; maxValue: number };
3925
+ export type RadarBorderStyle = 'solid' | 'dotted' | 'dashed';
2757
3926
 
2758
3927
  export type RadarDataset = {
2759
3928
  label: string;
2760
3929
  values: number[];
2761
3930
  borderColor: string;
2762
3931
  borderWidth: number;
2763
- borderStyle: string;
3932
+ borderStyle: RadarBorderStyle;
2764
3933
  dashArray?: number[];
3934
+ dashLength?: number;
2765
3935
  showPoints: boolean;
2766
3936
  pointRadius: number;
2767
3937
  pointColor: string;
2768
3938
  fillColor?: string;
2769
3939
  };
2770
3940
 
3941
+ type RadarStrokePattern = {
3942
+ strokeDasharray?: string;
3943
+ strokeLinecap?: 'butt' | 'round';
3944
+ };
3945
+
3946
+ const RADAR_DASH_DEFAULT_LENGTH = 8;
3947
+ const RADAR_DASH_GAP_FACTOR = 0.6;
3948
+ const RADAR_DOT_DASH_LENGTH = 0.001;
3949
+ const RADAR_DOT_GAP_FACTOR = 2.4;
3950
+ const RADAR_DOT_MIN_GAP = 3;
3951
+ const radarDashConflictWarnings = new Set<string>();
3952
+
3953
+ function isDevelopmentEnvironment(): boolean {
3954
+ return process.env.NODE_ENV !== 'production';
3955
+ }
3956
+
3957
+ function warnRadarDashConflictOnce(label: string) {
3958
+ if (!isDevelopmentEnvironment()) return;
3959
+ if (radarDashConflictWarnings.has(label)) return;
3960
+ radarDashConflictWarnings.add(label);
3961
+ // Keep API backward-compatible but deterministic when both flags are passed.
3962
+ console.warn(
3963
+ `[RadarChart] Dataset "${label}" received both dotted=true and dashed=true. dashed takes precedence.`
3964
+ );
3965
+ }
3966
+
3967
+ function resolveRadarBorderStyle(
3968
+ dataset: Record<string, any>,
3969
+ fallbackLabel: string
3970
+ ): RadarBorderStyle {
3971
+ const dotted = dataset.dotted === true;
3972
+ const dashed = dataset.dashed === true;
3973
+
3974
+ if (dotted && dashed) {
3975
+ warnRadarDashConflictOnce(fallbackLabel);
3976
+ return 'dashed';
3977
+ }
3978
+ if (dashed) return 'dashed';
3979
+ if (dotted) return 'dotted';
3980
+
3981
+ const borderStyle = (dataset.borderStyle ?? 'solid').toString().toLowerCase();
3982
+ if (borderStyle === 'dotted' || borderStyle === 'dashed') {
3983
+ return borderStyle;
3984
+ }
3985
+ return 'solid';
3986
+ }
3987
+
3988
+ function resolveRadarStrokePattern(dataset: RadarDataset): RadarStrokePattern {
3989
+ if (dataset.borderStyle === 'dotted') {
3990
+ // Tiny dash + round caps renders circular dots more reliably than short dashes.
3991
+ const gap = Math.max(
3992
+ RADAR_DOT_MIN_GAP,
3993
+ dataset.borderWidth * RADAR_DOT_GAP_FACTOR
3994
+ );
3995
+ return {
3996
+ strokeDasharray: `${RADAR_DOT_DASH_LENGTH} ${gap}`,
3997
+ strokeLinecap: 'round',
3998
+ };
3999
+ }
4000
+
4001
+ if (dataset.borderStyle === 'dashed') {
4002
+ if (Array.isArray(dataset.dashArray) && dataset.dashArray.length > 0) {
4003
+ const dash = Math.max(
4004
+ 1,
4005
+ dataset.dashArray[0] ?? RADAR_DASH_DEFAULT_LENGTH
4006
+ );
4007
+ const gap = Math.max(
4008
+ 1,
4009
+ dataset.dashArray[1] ?? dash * RADAR_DASH_GAP_FACTOR
4010
+ );
4011
+ return {
4012
+ strokeDasharray: `${dash} ${gap}`,
4013
+ strokeLinecap: 'butt',
4014
+ };
4015
+ }
4016
+
4017
+ const dashLength =
4018
+ typeof dataset.dashLength === 'number' &&
4019
+ Number.isFinite(dataset.dashLength) &&
4020
+ dataset.dashLength > 0
4021
+ ? dataset.dashLength
4022
+ : RADAR_DASH_DEFAULT_LENGTH;
4023
+ const gap = Math.max(2, dashLength * RADAR_DASH_GAP_FACTOR);
4024
+ return {
4025
+ strokeDasharray: `${dashLength} ${gap}`,
4026
+ strokeLinecap: 'butt',
4027
+ };
4028
+ }
4029
+
4030
+ return {};
4031
+ }
4032
+
4033
+ export const RADAR_LABEL_ESTIMATION = {
4034
+ minRadius: 10,
4035
+ safeEdge: 4,
4036
+ minLabelWidthFactor: 1.2,
4037
+ avgCharWidthFactor: 0.58,
4038
+ lineHeightFactor: 1.2,
4039
+ defaultFontSize: 14,
4040
+ minLabelOffset: 20,
4041
+ labelOffsetFactor: 1.5,
4042
+ } as const;
4043
+
4044
+ export function resolveRadarAxisLabelFontSize(value: unknown): number {
4045
+ const parsed = Number(value);
4046
+ if (!Number.isFinite(parsed) || parsed <= 0) {
4047
+ return RADAR_LABEL_ESTIMATION.defaultFontSize;
4048
+ }
4049
+ return parsed;
4050
+ }
4051
+
4052
+ export function resolveRadarLabelOffset(
4053
+ value: unknown,
4054
+ fontSize: number
4055
+ ): number {
4056
+ const parsed = Number(value);
4057
+ if (Number.isFinite(parsed) && parsed > 0) {
4058
+ return parsed;
4059
+ }
4060
+ return Math.max(
4061
+ RADAR_LABEL_ESTIMATION.minLabelOffset,
4062
+ fontSize * RADAR_LABEL_ESTIMATION.labelOffsetFactor
4063
+ );
4064
+ }
4065
+
4066
+ export function estimateRadarLabelSize(
4067
+ label: string,
4068
+ fontSize: number
4069
+ ): {
4070
+ width: number;
4071
+ height: number;
4072
+ } {
4073
+ const safeLabel = `${label ?? ''}`;
4074
+ const width = Math.max(
4075
+ safeLabel.length * fontSize * RADAR_LABEL_ESTIMATION.avgCharWidthFactor,
4076
+ fontSize * RADAR_LABEL_ESTIMATION.minLabelWidthFactor
4077
+ );
4078
+ const height = fontSize * RADAR_LABEL_ESTIMATION.lineHeightFactor;
4079
+ return { width, height };
4080
+ }
4081
+
4082
+ export function calculateRadarChartRadius({
4083
+ width,
4084
+ height,
4085
+ padding,
4086
+ axisLabels,
4087
+ fontSize,
4088
+ autoFitLabels = true,
4089
+ labelOffset,
4090
+ }: {
4091
+ width: number;
4092
+ height: number;
4093
+ padding: { left: number; right: number; top: number; bottom: number };
4094
+ axisLabels: string[];
4095
+ fontSize: number;
4096
+ autoFitLabels?: boolean;
4097
+ labelOffset: number;
4098
+ }): number {
4099
+ const center = { x: width / 2, y: height / 2 };
4100
+ const baseRadius = Math.max(
4101
+ Math.min(width, height) / 2 -
4102
+ Math.max(padding.left, padding.right, padding.top, padding.bottom),
4103
+ RADAR_LABEL_ESTIMATION.minRadius
4104
+ );
4105
+ if (!autoFitLabels || axisLabels.length === 0) {
4106
+ return baseRadius;
4107
+ }
4108
+
4109
+ const angleStep = (Math.PI * 2) / axisLabels.length;
4110
+ let maxAllowedRadius = baseRadius;
4111
+
4112
+ for (let index = 0; index < axisLabels.length; index += 1) {
4113
+ const { width: labelWidth, height: labelHeight } = estimateRadarLabelSize(
4114
+ axisLabels[index] ?? '',
4115
+ fontSize
4116
+ );
4117
+ const halfWidth = labelWidth / 2;
4118
+ const halfHeight = labelHeight / 2;
4119
+ const angle = index * angleStep - Math.PI / 2;
4120
+ const dx = Math.cos(angle);
4121
+ const dy = Math.sin(angle);
4122
+
4123
+ // Solve per-axis line constraints so estimated label bounds stay inside.
4124
+ if (dx > 0.0001) {
4125
+ maxAllowedRadius = Math.min(
4126
+ maxAllowedRadius,
4127
+ (width - RADAR_LABEL_ESTIMATION.safeEdge - halfWidth - center.x) / dx -
4128
+ labelOffset
4129
+ );
4130
+ } else if (dx < -0.0001) {
4131
+ maxAllowedRadius = Math.min(
4132
+ maxAllowedRadius,
4133
+ (RADAR_LABEL_ESTIMATION.safeEdge + halfWidth - center.x) / dx -
4134
+ labelOffset
4135
+ );
4136
+ }
4137
+
4138
+ if (dy > 0.0001) {
4139
+ maxAllowedRadius = Math.min(
4140
+ maxAllowedRadius,
4141
+ (height - RADAR_LABEL_ESTIMATION.safeEdge - halfHeight - center.y) /
4142
+ dy -
4143
+ labelOffset
4144
+ );
4145
+ } else if (dy < -0.0001) {
4146
+ maxAllowedRadius = Math.min(
4147
+ maxAllowedRadius,
4148
+ (RADAR_LABEL_ESTIMATION.safeEdge + halfHeight - center.y) / dy -
4149
+ labelOffset
4150
+ );
4151
+ }
4152
+ }
4153
+
4154
+ const boundedRadius = Math.min(maxAllowedRadius, baseRadius);
4155
+ if (!Number.isFinite(boundedRadius)) {
4156
+ return baseRadius;
4157
+ }
4158
+ return Math.max(boundedRadius, RADAR_LABEL_ESTIMATION.minRadius);
4159
+ }
4160
+
2771
4161
  export function parseRadarAxes(rawAxes: any): RadarAxis[] {
2772
4162
  if (!Array.isArray(rawAxes)) return [];
2773
4163
  const axes: RadarAxis[] = [];
@@ -2776,7 +4166,9 @@ export function parseRadarAxes(rawAxes: any): RadarAxis[] {
2776
4166
  const label = axis.label ?? '';
2777
4167
  if (!label) return;
2778
4168
  const maxValue = resolveNumericValue(axis.maxValue, {}) ?? 100;
2779
- axes.push({ label, maxValue: maxValue > 0 ? maxValue : 100 });
4169
+ const normalizedMaxValue =
4170
+ Number.isFinite(maxValue) && maxValue > 0 ? maxValue : 100;
4171
+ axes.push({ label, maxValue: normalizedMaxValue });
2780
4172
  });
2781
4173
  return axes;
2782
4174
  }
@@ -2788,14 +4180,16 @@ export function parseRadarDatasets(
2788
4180
  ): RadarDataset[] {
2789
4181
  if (!Array.isArray(raw) || axisLength === 0) return [];
2790
4182
  const datasets: RadarDataset[] = [];
2791
- raw.forEach((dataset) => {
4183
+ raw.forEach((dataset, datasetIndex) => {
2792
4184
  if (!dataset || typeof dataset !== 'object') return;
2793
4185
  const valuesRaw = Array.isArray(dataset.data) ? dataset.data : [];
2794
4186
  const values: number[] = [];
2795
4187
  for (let i = 0; i < axisLength; i += 1) {
2796
4188
  const rawValue = i < valuesRaw.length ? valuesRaw[i] : null;
2797
- const resolved = resolveNumericValue(rawValue, formData) ?? 0;
2798
- values.push(resolved);
4189
+ const resolved = resolveNumericValue(rawValue, formData);
4190
+ values.push(
4191
+ typeof resolved === 'number' && Number.isFinite(resolved) ? resolved : 0
4192
+ );
2799
4193
  }
2800
4194
 
2801
4195
  const label = dataset.label ?? `Dataset ${datasets.length + 1}`;
@@ -2806,18 +4200,30 @@ export function parseRadarDatasets(
2806
4200
  const pointColor = parseColor(
2807
4201
  dataset.pointColor ?? dataset.borderColor ?? '0xFF2196F3'
2808
4202
  );
2809
- const borderWidth = Number(dataset.borderWidth ?? 2);
2810
- const pointRadius = Number(dataset.pointRadius ?? 4);
2811
- const borderStyle = (dataset.borderStyle ?? 'solid')
2812
- .toString()
2813
- .toLowerCase();
2814
- const normalizedStyle =
2815
- borderStyle === 'dotted' || borderStyle === 'dashed'
2816
- ? borderStyle
2817
- : 'solid';
4203
+ const rawBorderWidth = Number(dataset.borderWidth ?? 2);
4204
+ const borderWidth =
4205
+ Number.isFinite(rawBorderWidth) && rawBorderWidth > 0
4206
+ ? rawBorderWidth
4207
+ : 2;
4208
+ const rawPointRadius = Number(dataset.pointRadius ?? 4);
4209
+ const pointRadius =
4210
+ Number.isFinite(rawPointRadius) && rawPointRadius >= 0
4211
+ ? rawPointRadius
4212
+ : 4;
4213
+ const normalizedStyle = resolveRadarBorderStyle(
4214
+ dataset,
4215
+ label ?? `Dataset ${datasetIndex + 1}`
4216
+ );
2818
4217
  const dashArray = Array.isArray(dataset.dashArray)
2819
- ? dataset.dashArray.map((val: any) => Number(val))
4218
+ ? dataset.dashArray
4219
+ .map((val: any) => Number(val))
4220
+ .filter((val: number) => Number.isFinite(val) && val > 0)
2820
4221
  : undefined;
4222
+ const rawDashLength = Number(dataset.dashLength);
4223
+ const dashLength =
4224
+ Number.isFinite(rawDashLength) && rawDashLength > 0
4225
+ ? rawDashLength
4226
+ : undefined;
2821
4227
  const showPoints = dataset.showPoints !== false;
2822
4228
 
2823
4229
  datasets.push({
@@ -2827,6 +4233,7 @@ export function parseRadarDatasets(
2827
4233
  borderWidth,
2828
4234
  borderStyle: normalizedStyle,
2829
4235
  dashArray,
4236
+ dashLength,
2830
4237
  showPoints,
2831
4238
  pointRadius,
2832
4239
  pointColor,
@@ -2844,7 +4251,9 @@ export function normalizeAxisMaximums(
2844
4251
  let maxValue = axis.maxValue;
2845
4252
  datasets.forEach((dataset) => {
2846
4253
  const value = dataset.values[index];
2847
- if (value !== undefined && value > maxValue) maxValue = value;
4254
+ if (value !== undefined && Number.isFinite(value) && value > maxValue) {
4255
+ maxValue = value;
4256
+ }
2848
4257
  });
2849
4258
  return { ...axis, maxValue };
2850
4259
  });
@@ -2854,12 +4263,15 @@ function resolveRadarWidth(
2854
4263
  parsedWidth: number | string | undefined,
2855
4264
  measuredWidth: number
2856
4265
  ): number {
4266
+ if (measuredWidth > 0) {
4267
+ if (typeof parsedWidth === 'number' && Number.isFinite(parsedWidth)) {
4268
+ return Math.max(1, Math.min(parsedWidth, measuredWidth));
4269
+ }
4270
+ return Math.max(1, measuredWidth);
4271
+ }
2857
4272
  if (typeof parsedWidth === 'number' && Number.isFinite(parsedWidth)) {
2858
4273
  return parsedWidth;
2859
4274
  }
2860
- if (measuredWidth > 0) {
2861
- return measuredWidth;
2862
- }
2863
4275
  return 300;
2864
4276
  }
2865
4277