flowboard-react 0.4.2 → 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,
@@ -8,9 +9,11 @@ import {
8
9
  TextInput,
9
10
  View,
10
11
  Image,
12
+ Vibration,
11
13
  type TextStyle,
14
+ type ViewStyle,
12
15
  } from 'react-native';
13
- import { SafeAreaView } from 'react-native-safe-area-context';
16
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
14
17
  import MaskedView from '@react-native-masked-view/masked-view';
15
18
  import LinearGradient from 'react-native-linear-gradient';
16
19
  import LottieView from 'lottie-react-native';
@@ -52,7 +55,7 @@ const styles = StyleSheet.create({
52
55
  whiteBg: {
53
56
  backgroundColor: '#ffffff',
54
57
  },
55
- safeArea: { flex: 1 },
58
+ contentLayer: { flex: 1 },
56
59
  progressWrapper: { width: '100%' },
57
60
  });
58
61
 
@@ -66,6 +69,32 @@ type FlowboardRendererProps = {
66
69
  totalScreens?: number;
67
70
  };
68
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
+
69
98
  export default function FlowboardRenderer(props: FlowboardRendererProps) {
70
99
  const {
71
100
  screenData,
@@ -89,8 +118,12 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
89
118
  const progressThickness = Number(screenData.progressThickness ?? 4);
90
119
  const progressRadius = Number(screenData.progressRadius ?? 0);
91
120
  const progressStyle = screenData.progressStyle ?? 'linear';
92
- const scrollable = screenData.scrollable === true;
121
+ const pageScroll = resolvePageScrollAxis(screenData);
122
+ const pageAxisBounds = getAxisBoundsForPageScroll(pageScroll);
123
+ const scrollable = pageScroll !== 'none';
93
124
  const safeArea = screenData.safeArea !== false;
125
+ const safeAreaInsets = useSafeAreaInsets();
126
+ const rootAxis: 'vertical' = 'vertical';
94
127
 
95
128
  const padding = parseInsets(screenData.padding);
96
129
  const contentPaddingStyle = insetsToStyle(padding);
@@ -101,6 +134,14 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
101
134
  };
102
135
  const rootCrossAxisAlignment =
103
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;
104
145
 
105
146
  const content = (
106
147
  <View style={{ flex: 1 }}>
@@ -119,6 +160,7 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
119
160
 
120
161
  {scrollable ? (
121
162
  <ScrollView
163
+ horizontal={false}
122
164
  contentContainerStyle={{
123
165
  ...contentPaddingStyle,
124
166
  paddingTop: showProgress ? 0 : padding.top,
@@ -127,8 +169,11 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
127
169
  <View
128
170
  style={{
129
171
  flexGrow: 1,
172
+ flexDirection: 'column',
130
173
  justifyContent: parseFlexAlignment(screenData.mainAxisAlignment),
131
174
  alignItems: parseRootCrossAlignment(rootCrossAxisAlignment),
175
+ minHeight: 0,
176
+ minWidth: 0,
132
177
  }}
133
178
  >
134
179
  {childrenData.map((child, index) =>
@@ -137,6 +182,9 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
137
182
  formData,
138
183
  onInputChange,
139
184
  allowFlexExpansion: true,
185
+ parentFlexAxis: rootAxis,
186
+ parentMainAxisBounded: pageAxisBounds.heightBounded,
187
+ pageAxisBounds,
140
188
  screenData,
141
189
  enableFontAwesomeIcons,
142
190
  key: `child-${index}`,
@@ -160,6 +208,9 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
160
208
  formData,
161
209
  onInputChange,
162
210
  allowFlexExpansion: true,
211
+ parentFlexAxis: rootAxis,
212
+ parentMainAxisBounded: true,
213
+ pageAxisBounds,
163
214
  screenData,
164
215
  enableFontAwesomeIcons,
165
216
  key: `child-${index}`,
@@ -173,17 +224,7 @@ export default function FlowboardRenderer(props: FlowboardRendererProps) {
173
224
  return (
174
225
  <View style={styles.root}>
175
226
  {renderBackground(backgroundData, bgColorCode)}
176
- {safeArea ? (
177
- <SafeAreaView
178
- style={styles.safeArea}
179
- mode="padding"
180
- edges={['top', 'right', 'bottom', 'left']}
181
- >
182
- {content}
183
- </SafeAreaView>
184
- ) : (
185
- content
186
- )}
227
+ <View style={[styles.contentLayer, safeAreaPaddingStyle]}>{content}</View>
187
228
  </View>
188
229
  );
189
230
  }
@@ -356,6 +397,484 @@ function renderProgressBar(
356
397
  );
357
398
  }
358
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
+
359
878
  function buildWidget(
360
879
  json: Record<string, any>,
361
880
  params: {
@@ -363,19 +882,32 @@ function buildWidget(
363
882
  formData: Record<string, any>;
364
883
  onInputChange?: (id: string, value: any) => void;
365
884
  allowFlexExpansion?: boolean;
885
+ parentFlexAxis?: 'vertical' | 'horizontal';
886
+ parentMainAxisBounded?: boolean;
887
+ pageAxisBounds?: AxisBounds;
366
888
  screenData: Record<string, any>;
367
889
  enableFontAwesomeIcons: boolean;
368
890
  key?: string;
369
891
  }
370
892
  ): React.ReactNode {
371
893
  if (!json || typeof json !== 'object') return null;
372
- const type = json.type;
373
- const id = json.id;
374
- const props = { ...(json.properties ?? {}) } as Record<string, any>;
375
- const childrenJson = Array.isArray(json.children) ? json.children : undefined;
376
- const childJson = json.child as Record<string, any> | undefined;
377
- const action = json.action as string | undefined;
378
- 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;
379
911
 
380
912
  let node: React.ReactNode = null;
381
913
 
@@ -564,62 +1096,121 @@ function buildWidget(
564
1096
  const height =
565
1097
  normalizeDimension(parseLayoutDimension(props.height)) ?? 50;
566
1098
  const label = props.label ?? 'Button';
1099
+ const normalizedStroke = normalizeButtonStroke(props.stroke);
1100
+ const normalizedEffects = normalizeButtonEffects(
1101
+ props.effects,
1102
+ props.shadow
1103
+ );
567
1104
  const textStyle = getTextStyle(
568
1105
  { ...props, height: null },
569
1106
  'textColor',
570
1107
  '0xFFFFFFFF'
571
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();
572
1141
 
573
- const content = (
574
- <View
575
- 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}
576
1161
  >
577
- <Text style={textStyle}>{label}</Text>
578
- </View>
579
- );
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
+ )}
580
1187
 
581
- if (gradient) {
582
- node = (
583
- <Pressable
584
- onPress={
585
- action
586
- ? () => params.onAction(action, actionPayload ?? props)
587
- : undefined
588
- }
589
- style={{ width, height }}
590
- >
1188
+ {gradient ? (
591
1189
  <LinearGradient
592
1190
  colors={gradient.colors}
593
1191
  start={gradient.start}
594
1192
  end={gradient.end}
595
1193
  locations={gradient.stops}
596
- style={{ flex: 1, borderRadius, overflow: 'hidden' }}
597
- >
598
- {content}
599
- </LinearGradient>
600
- </Pressable>
601
- );
602
- } else {
603
- node = (
604
- <Pressable
605
- onPress={
606
- action
607
- ? () => params.onAction(action, actionPayload ?? props)
608
- : undefined
609
- }
610
- style={{
611
- width,
612
- height,
613
- backgroundColor: parseColor(props.color ?? '0xFF2196F3'),
614
- borderRadius,
615
- justifyContent: 'center',
616
- alignItems: 'center',
617
- }}
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' }}
618
1209
  >
619
- {content}
620
- </Pressable>
621
- );
622
- }
1210
+ <Text style={textStyle}>{label}</Text>
1211
+ </View>
1212
+ </Pressable>
1213
+ );
623
1214
  break;
624
1215
  }
625
1216
  case 'text_input': {
@@ -935,25 +1526,170 @@ function buildWidget(
935
1526
  break;
936
1527
  }
937
1528
  case 'stack': {
938
- const alignment = parseAlignment(props.alignment ?? 'topLeft');
939
- const fit = props.fit === 'expand' ? 'expand' : 'loose';
940
- 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
+ ) : (
941
1640
  <View
942
1641
  style={{
943
- position: 'relative',
944
- width: fit === 'expand' ? '100%' : undefined,
945
- height: fit === 'expand' ? '100%' : undefined,
946
- justifyContent: alignment.justifyContent,
947
- 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,
948
1647
  }}
949
1648
  >
950
1649
  {(childrenJson ?? []).map((child, index) => (
951
1650
  <React.Fragment key={`stack-${index}`}>
952
- {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}
953
1669
  </React.Fragment>
954
1670
  ))}
955
1671
  </View>
956
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
+ }
957
1693
  break;
958
1694
  }
959
1695
  case 'positioned': {
@@ -986,29 +1722,64 @@ function buildWidget(
986
1722
 
987
1723
  if (!node) return null;
988
1724
 
989
- const marginVal = props.margin;
1725
+ const marginVal =
1726
+ type === 'stack' ? props.layout?.margin ?? props.margin : props.margin;
990
1727
  if (marginVal !== undefined && marginVal !== null) {
991
- const marginInsets = parseInsets(marginVal);
1728
+ const marginInsets =
1729
+ type === 'stack'
1730
+ ? stackSpacingToInsets(marginVal)
1731
+ : parseInsets(marginVal);
992
1732
  node = <View style={insetsToMarginStyle(marginInsets)}>{node}</View>;
993
- } else if (type !== 'container' && type !== 'padding' && props.padding) {
1733
+ } else if (
1734
+ type !== 'container' &&
1735
+ type !== 'padding' &&
1736
+ type !== 'stack' &&
1737
+ props.padding
1738
+ ) {
994
1739
  const paddingInsets = parseInsets(props.padding);
995
1740
  node = <View style={insetsToStyle(paddingInsets)}>{node}</View>;
996
1741
  }
997
1742
 
998
- if (params.allowFlexExpansion && params.screenData.scrollable !== true) {
1743
+ if (params.allowFlexExpansion) {
999
1744
  let shouldExpand = false;
1000
- if (type === 'stack' && props.fit === 'expand') {
1001
- shouldExpand = true;
1002
- }
1003
- if (props.height !== undefined) {
1004
- const heightVal = parseLayoutDimension(props.height);
1005
- if (heightVal === Number.POSITIVE_INFINITY) {
1006
- 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
+ }
1007
1778
  }
1008
1779
  }
1009
1780
 
1010
1781
  if (shouldExpand) {
1011
- node = <View style={{ flex: 1 }}>{node}</View>;
1782
+ node = <View style={{ flex: 1, minHeight: 0, minWidth: 0 }}>{node}</View>;
1012
1783
  }
1013
1784
  }
1014
1785
 
@@ -1087,6 +1858,227 @@ function parseGradient(value: any):
1087
1858
  return { colors, start, end, stops };
1088
1859
  }
1089
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
+
1090
2082
  function alignmentToGradient(value: string) {
1091
2083
  switch (value) {
1092
2084
  case 'topCenter':
@@ -1112,13 +2104,29 @@ function alignmentToGradient(value: string) {
1112
2104
  }
1113
2105
 
1114
2106
  function withOpacity(color: string, opacity: number): string {
1115
- if (color.startsWith('rgba')) {
1116
- return color.replace(
1117
- /rgba\((\d+),(\d+),(\d+),([\d.]+)\)/,
1118
- (_m, r, g, b) => `rgba(${r},${g},${b},${opacity})`
1119
- );
1120
- }
1121
- 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));
1122
2130
  }
1123
2131
 
1124
2132
  function normalizeDimension(
@@ -1277,6 +2285,81 @@ function GradientText({
1277
2285
  );
1278
2286
  }
1279
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
+
1280
2363
  function DynamicInput({
1281
2364
  initialValue,
1282
2365
  properties,
@@ -1314,10 +2397,12 @@ function DynamicInput({
1314
2397
  );
1315
2398
  const inputStyle = getTextStyle({ ...properties, height: null }, 'textColor');
1316
2399
 
1317
- const backgroundColor = parseColor(
1318
- properties.backgroundColor ?? '0xFFF0F0F0'
1319
- );
1320
- 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;
1321
2406
 
1322
2407
  return (
1323
2408
  <View>
@@ -1325,9 +2410,11 @@ function DynamicInput({
1325
2410
  <View
1326
2411
  style={{
1327
2412
  backgroundColor,
1328
- borderRadius,
1329
- paddingHorizontal: 16,
1330
- paddingVertical: 10,
2413
+ borderRadius: stroke.radius,
2414
+ borderStyle: stroke.width > 0 ? stroke.style : undefined,
2415
+ borderWidth: stroke.width,
2416
+ borderColor: stroke.color,
2417
+ padding,
1331
2418
  }}
1332
2419
  >
1333
2420
  {mask ? (
@@ -1497,7 +2584,7 @@ function SelectionList({
1497
2584
 
1498
2585
  if (layout === 'row') {
1499
2586
  return (
1500
- <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
2587
+ <View style={{ width: '100%', flexDirection: 'row', flexWrap: 'wrap' }}>
1501
2588
  {options.map((option, index) => (
1502
2589
  <View
1503
2590
  key={`row-option-${index}`}
@@ -1514,7 +2601,7 @@ function SelectionList({
1514
2601
  const crossAxisCount = Number(properties.gridCrossAxisCount ?? 2);
1515
2602
  const aspectRatio = Number(properties.gridAspectRatio ?? 1);
1516
2603
  return (
1517
- <View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
2604
+ <View style={{ width: '100%', flexDirection: 'row', flexWrap: 'wrap' }}>
1518
2605
  {options.map((option, index) => (
1519
2606
  <View
1520
2607
  key={`grid-option-${index}`}
@@ -1533,9 +2620,12 @@ function SelectionList({
1533
2620
  }
1534
2621
 
1535
2622
  return (
1536
- <View>
2623
+ <View style={{ width: '100%' }}>
1537
2624
  {options.map((option, index) => (
1538
- <View key={`column-option-${index}`} style={{ marginBottom: spacing }}>
2625
+ <View
2626
+ key={`column-option-${index}`}
2627
+ style={{ width: '100%', marginBottom: spacing }}
2628
+ >
1539
2629
  {renderItem(option, index)}
1540
2630
  </View>
1541
2631
  ))}
@@ -1562,6 +2652,10 @@ function WheelPicker({
1562
2652
  onAction: (action: string, data?: Record<string, any>) => void;
1563
2653
  buildWidget: (widgetJson: Record<string, any>) => React.ReactNode;
1564
2654
  }) {
2655
+ const triggerLightHaptic = React.useCallback(() => {
2656
+ Vibration.vibrate(8);
2657
+ }, []);
2658
+
1565
2659
  const multiSelect = properties.multiSelect === true;
1566
2660
  const [selectedValues, setSelectedValues] = React.useState<string[]>(() => {
1567
2661
  if (initialValue === null || initialValue === undefined) return [];
@@ -1632,6 +2726,27 @@ function WheelPicker({
1632
2726
  requestedVisible % 2 === 0 ? requestedVisible + 1 : requestedVisible;
1633
2727
  const wheelRowStride = wheelItemHeight + Math.max(0, spacing);
1634
2728
  const wheelContainerHeight = wheelItemHeight * visibleItems;
2729
+ const hasProvidedInitialValue = React.useMemo(() => {
2730
+ if (initialValue === null || initialValue === undefined) return false;
2731
+ if (Array.isArray(initialValue)) return initialValue.length > 0;
2732
+ if (typeof initialValue === 'string') return initialValue.trim().length > 0;
2733
+ return true;
2734
+ }, [initialValue]);
2735
+
2736
+ const resolveStartItemIndex = React.useCallback(() => {
2737
+ const raw =
2738
+ properties.startItemIndex ??
2739
+ properties.start_index ??
2740
+ properties.startIndex ??
2741
+ 1;
2742
+ const parsed = Number(raw);
2743
+ if (!Number.isFinite(parsed)) return 1;
2744
+ return Math.max(1, Math.floor(parsed));
2745
+ }, [
2746
+ properties.startIndex,
2747
+ properties.startItemIndex,
2748
+ properties.start_index,
2749
+ ]);
1635
2750
 
1636
2751
  const clampWheelIndex = React.useCallback(
1637
2752
  (index: number) =>
@@ -1666,6 +2781,7 @@ function WheelPicker({
1666
2781
 
1667
2782
  if (lastCommittedWheelValue.current !== value) {
1668
2783
  lastCommittedWheelValue.current = value;
2784
+ triggerLightHaptic();
1669
2785
  onChanged(value);
1670
2786
  if (properties.autoGoNext === true) {
1671
2787
  requestAnimationFrame(() =>
@@ -1674,7 +2790,14 @@ function WheelPicker({
1674
2790
  }
1675
2791
  }
1676
2792
  },
1677
- [clampWheelIndex, onAction, onChanged, properties.autoGoNext, resolvedOptions]
2793
+ [
2794
+ clampWheelIndex,
2795
+ onAction,
2796
+ onChanged,
2797
+ properties.autoGoNext,
2798
+ resolvedOptions,
2799
+ triggerLightHaptic,
2800
+ ]
1678
2801
  );
1679
2802
 
1680
2803
  React.useEffect(() => {
@@ -1696,6 +2819,31 @@ function WheelPicker({
1696
2819
  wheelScrollY,
1697
2820
  ]);
1698
2821
 
2822
+ React.useEffect(() => {
2823
+ if (layout !== 'wheel') return;
2824
+ if (hasProvidedInitialValue) return;
2825
+ if (selectedValues.length > 0) return;
2826
+ if (resolvedOptions.length === 0) return;
2827
+
2828
+ const oneBasedIndex = resolveStartItemIndex();
2829
+ const targetIndex = Math.max(
2830
+ 0,
2831
+ Math.min(resolvedOptions.length - 1, oneBasedIndex - 1)
2832
+ );
2833
+ const value = String(resolvedOptions[targetIndex]?.value ?? '');
2834
+ if (!value) return;
2835
+
2836
+ setSelectedValues([value]);
2837
+ onChanged(value);
2838
+ }, [
2839
+ hasProvidedInitialValue,
2840
+ layout,
2841
+ onChanged,
2842
+ resolveStartItemIndex,
2843
+ resolvedOptions,
2844
+ selectedValues.length,
2845
+ ]);
2846
+
1699
2847
  const renderItem = (option: Record<string, any>, index: number) => {
1700
2848
  const value = String(option.value ?? '');
1701
2849
  const label = String(option.text ?? value);
@@ -1813,10 +2961,19 @@ function WheelPicker({
1813
2961
  const overlayBackgroundColor = parseColor(
1814
2962
  properties.wheelCenterBackgroundColor ?? '#24FFFFFF'
1815
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
+ );
1816
2971
 
1817
2972
  const handleWheelMomentumEnd = (event: any) => {
1818
2973
  const offsetY = Number(event?.nativeEvent?.contentOffset?.y ?? 0);
1819
- const settledIndex = clampWheelIndex(Math.round(offsetY / wheelRowStride));
2974
+ const settledIndex = clampWheelIndex(
2975
+ Math.round(offsetY / wheelRowStride)
2976
+ );
1820
2977
  const targetOffset = settledIndex * wheelRowStride;
1821
2978
  if (Math.abs(targetOffset - offsetY) > 0.5) {
1822
2979
  wheelScrollRef.current?.scrollTo({
@@ -1889,7 +3046,12 @@ function WheelPicker({
1889
3046
  ],
1890
3047
  }}
1891
3048
  >
1892
- <View style={{ minHeight: wheelItemHeight, justifyContent: 'center' }}>
3049
+ <View
3050
+ style={{
3051
+ minHeight: wheelItemHeight,
3052
+ justifyContent: 'center',
3053
+ }}
3054
+ >
1893
3055
  {renderItem(option, index)}
1894
3056
  </View>
1895
3057
  </Animated.View>
@@ -1911,22 +3073,38 @@ function WheelPicker({
1911
3073
  backgroundColor: overlayBackgroundColor,
1912
3074
  }}
1913
3075
  />
1914
- <LinearGradient
1915
- pointerEvents="none"
1916
- colors={['rgba(255,255,255,0.92)', 'rgba(255,255,255,0)']}
1917
- style={{ position: 'absolute', top: 0, left: 0, right: 0, height: centerPadding }}
1918
- />
1919
- <LinearGradient
1920
- pointerEvents="none"
1921
- colors={['rgba(255,255,255,0)', 'rgba(255,255,255,0.92)']}
1922
- style={{
1923
- position: 'absolute',
1924
- bottom: 0,
1925
- left: 0,
1926
- right: 0,
1927
- height: centerPadding,
1928
- }}
1929
- />
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}
1930
3108
  </View>
1931
3109
  );
1932
3110
  }
@@ -1934,7 +3112,10 @@ function WheelPicker({
1934
3112
  return (
1935
3113
  <View>
1936
3114
  {resolvedOptions.map((option, index) => (
1937
- <View key={`wheel-list-option-${index}`} style={{ marginBottom: spacing }}>
3115
+ <View
3116
+ key={`wheel-list-option-${index}`}
3117
+ style={{ marginBottom: spacing }}
3118
+ >
1938
3119
  {renderItem(option, index)}
1939
3120
  </View>
1940
3121
  ))}
@@ -2018,7 +3199,10 @@ function buildWheelTemplateBaseContext(
2018
3199
  properties.start ?? properties.itemStart,
2019
3200
  context
2020
3201
  );
2021
- const step = resolveNumericValue(properties.step ?? properties.itemStep, context);
3202
+ const step = resolveNumericValue(
3203
+ properties.step ?? properties.itemStep,
3204
+ context
3205
+ );
2022
3206
 
2023
3207
  if (start !== null) context.start = start;
2024
3208
  if (step !== null) context.step = step;
@@ -2479,6 +3663,14 @@ function RadarChart({
2479
3663
  'color',
2480
3664
  '0xFF424242'
2481
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
+ );
2482
3674
  const showLegend = properties.showLegend !== false;
2483
3675
  const legendPosition = (properties.legendPosition ?? 'bottom').toLowerCase();
2484
3676
  const legendSpacing = Number(properties.legendSpacing ?? 12);
@@ -2488,14 +3680,21 @@ function RadarChart({
2488
3680
  '0xFF212121'
2489
3681
  );
2490
3682
  const shape = (properties.shape ?? 'polygon').toLowerCase();
2491
-
2492
- const chartSize = Math.min(width, height);
2493
- const radius = Math.max(
2494
- chartSize / 2 -
2495
- Math.max(padding.left, padding.right, padding.top, padding.bottom),
2496
- 8
2497
- );
2498
- 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
+ };
2499
3698
 
2500
3699
  const axisAngle = (Math.PI * 2) / axes.length;
2501
3700
 
@@ -2514,7 +3713,10 @@ function RadarChart({
2514
3713
  const points = dataset.values.map((value, index) => {
2515
3714
  const axis = normalizedAxes[index];
2516
3715
  const maxValue = axis?.maxValue ?? 0;
2517
- 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));
2518
3720
  const angle = index * axisAngle - Math.PI / 2;
2519
3721
  const x = center.x + radius * ratio * Math.cos(angle);
2520
3722
  const y = center.y + radius * ratio * Math.sin(angle);
@@ -2523,16 +3725,24 @@ function RadarChart({
2523
3725
  return { dataset, points };
2524
3726
  });
2525
3727
 
3728
+ const isVerticalLegend =
3729
+ legendPosition === 'left' || legendPosition === 'right';
2526
3730
  const legend = (
2527
- <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
+ >
2528
3738
  {datasets.map((dataset, index) => (
2529
3739
  <View
2530
3740
  key={`legend-${index}`}
2531
3741
  style={{
2532
3742
  flexDirection: 'row',
2533
3743
  alignItems: 'center',
2534
- marginRight: legendSpacing,
2535
- marginBottom: 6,
3744
+ marginRight: isVerticalLegend ? 0 : legendSpacing,
3745
+ marginBottom: isVerticalLegend ? legendSpacing : 6,
2536
3746
  }}
2537
3747
  >
2538
3748
  <View
@@ -2540,11 +3750,15 @@ function RadarChart({
2540
3750
  width: 12,
2541
3751
  height: 12,
2542
3752
  borderRadius: 6,
2543
- backgroundColor: dataset.borderColor,
3753
+ backgroundColor: dataset.fillColor ?? dataset.borderColor,
3754
+ borderWidth: 1,
3755
+ borderColor: dataset.borderColor,
2544
3756
  marginRight: 6,
2545
3757
  }}
2546
3758
  />
2547
- <Text style={legendStyle}>{dataset.label}</Text>
3759
+ <Text numberOfLines={1} style={legendStyle}>
3760
+ {dataset.label}
3761
+ </Text>
2548
3762
  </View>
2549
3763
  ))}
2550
3764
  </View>
@@ -2553,12 +3767,10 @@ function RadarChart({
2553
3767
  const chart = (
2554
3768
  <Animated.View
2555
3769
  style={{
2556
- width: normalizedWidth ?? width,
3770
+ width: isVerticalLegend ? width : normalizedWidth ?? '100%',
3771
+ maxWidth: '100%',
3772
+ flexShrink: 1,
2557
3773
  height,
2558
- paddingLeft: padding.left,
2559
- paddingRight: padding.right,
2560
- paddingTop: padding.top,
2561
- paddingBottom: padding.bottom,
2562
3774
  opacity: reveal,
2563
3775
  }}
2564
3776
  onLayout={(event) => {
@@ -2609,14 +3821,16 @@ function RadarChart({
2609
3821
  })}
2610
3822
  {axes.map((axis, index) => {
2611
3823
  const angle = index * axisAngle - Math.PI / 2;
2612
- const labelX = center.x + (radius + 12) * Math.cos(angle);
2613
- 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);
2614
3828
  return (
2615
3829
  <SvgText
2616
3830
  key={`label-${index}`}
2617
3831
  x={labelX}
2618
3832
  y={labelY}
2619
- fontSize={axisLabelStyle.fontSize ?? 12}
3833
+ fontSize={resolvedAxisLabelFontSize}
2620
3834
  fontWeight={axisLabelStyle.fontWeight}
2621
3835
  fill={axisLabelStyle.color}
2622
3836
  textAnchor="middle"
@@ -2625,21 +3839,20 @@ function RadarChart({
2625
3839
  </SvgText>
2626
3840
  );
2627
3841
  })}
2628
- {datasetPolygons.map((entry, index) => (
2629
- <Polygon
2630
- key={`dataset-${index}`}
2631
- points={entry.points.join(' ')}
2632
- stroke={entry.dataset.borderColor}
2633
- strokeWidth={entry.dataset.borderWidth}
2634
- fill={entry.dataset.fillColor ?? 'transparent'}
2635
- strokeDasharray={
2636
- entry.dataset.borderStyle === 'dotted' ||
2637
- entry.dataset.borderStyle === 'dashed'
2638
- ? entry.dataset.dashArray ?? '6 4'
2639
- : undefined
2640
- }
2641
- />
2642
- ))}
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
+ })}
2643
3856
  {datasetPolygons.map((entry, datasetIndex) =>
2644
3857
  entry.dataset.showPoints
2645
3858
  ? entry.points.map((point, pointIndex) => {
@@ -2661,22 +3874,15 @@ function RadarChart({
2661
3874
  </Animated.View>
2662
3875
  );
2663
3876
 
2664
- const composed = (
2665
- <View style={{ backgroundColor }}>
2666
- {chart}
2667
- {showLegend && legendPosition === 'bottom' ? (
2668
- <View style={{ marginTop: 12 }}>{legend}</View>
2669
- ) : null}
2670
- </View>
2671
- );
2672
-
2673
- if (!showLegend || legendPosition === 'bottom') return composed;
3877
+ if (!showLegend) {
3878
+ return <View style={{ backgroundColor }}>{chart}</View>;
3879
+ }
2674
3880
 
2675
3881
  if (legendPosition === 'top') {
2676
3882
  return (
2677
- <View>
3883
+ <View style={{ backgroundColor, alignItems: 'center' }}>
2678
3884
  {legend}
2679
- <View style={{ height: 12 }} />
3885
+ <View style={{ height: legendSpacing }} />
2680
3886
  {chart}
2681
3887
  </View>
2682
3888
  );
@@ -2684,9 +3890,11 @@ function RadarChart({
2684
3890
 
2685
3891
  if (legendPosition === 'left') {
2686
3892
  return (
2687
- <View style={{ flexDirection: 'row' }}>
3893
+ <View
3894
+ style={{ backgroundColor, flexDirection: 'row', alignItems: 'center' }}
3895
+ >
2688
3896
  {legend}
2689
- <View style={{ width: 12 }} />
3897
+ <View style={{ width: legendSpacing }} />
2690
3898
  {chart}
2691
3899
  </View>
2692
3900
  );
@@ -2694,32 +3902,262 @@ function RadarChart({
2694
3902
 
2695
3903
  if (legendPosition === 'right') {
2696
3904
  return (
2697
- <View style={{ flexDirection: 'row' }}>
3905
+ <View
3906
+ style={{ backgroundColor, flexDirection: 'row', alignItems: 'center' }}
3907
+ >
2698
3908
  {chart}
2699
- <View style={{ width: 12 }} />
3909
+ <View style={{ width: legendSpacing }} />
2700
3910
  {legend}
2701
3911
  </View>
2702
3912
  );
2703
3913
  }
2704
3914
 
2705
- return composed;
3915
+ return (
3916
+ <View style={{ backgroundColor, alignItems: 'center' }}>
3917
+ {chart}
3918
+ <View style={{ height: legendSpacing }} />
3919
+ {legend}
3920
+ </View>
3921
+ );
2706
3922
  }
2707
3923
 
2708
3924
  export type RadarAxis = { label: string; maxValue: number };
3925
+ export type RadarBorderStyle = 'solid' | 'dotted' | 'dashed';
2709
3926
 
2710
3927
  export type RadarDataset = {
2711
3928
  label: string;
2712
3929
  values: number[];
2713
3930
  borderColor: string;
2714
3931
  borderWidth: number;
2715
- borderStyle: string;
3932
+ borderStyle: RadarBorderStyle;
2716
3933
  dashArray?: number[];
3934
+ dashLength?: number;
2717
3935
  showPoints: boolean;
2718
3936
  pointRadius: number;
2719
3937
  pointColor: string;
2720
3938
  fillColor?: string;
2721
3939
  };
2722
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
+
2723
4161
  export function parseRadarAxes(rawAxes: any): RadarAxis[] {
2724
4162
  if (!Array.isArray(rawAxes)) return [];
2725
4163
  const axes: RadarAxis[] = [];
@@ -2728,7 +4166,9 @@ export function parseRadarAxes(rawAxes: any): RadarAxis[] {
2728
4166
  const label = axis.label ?? '';
2729
4167
  if (!label) return;
2730
4168
  const maxValue = resolveNumericValue(axis.maxValue, {}) ?? 100;
2731
- 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 });
2732
4172
  });
2733
4173
  return axes;
2734
4174
  }
@@ -2740,14 +4180,16 @@ export function parseRadarDatasets(
2740
4180
  ): RadarDataset[] {
2741
4181
  if (!Array.isArray(raw) || axisLength === 0) return [];
2742
4182
  const datasets: RadarDataset[] = [];
2743
- raw.forEach((dataset) => {
4183
+ raw.forEach((dataset, datasetIndex) => {
2744
4184
  if (!dataset || typeof dataset !== 'object') return;
2745
4185
  const valuesRaw = Array.isArray(dataset.data) ? dataset.data : [];
2746
4186
  const values: number[] = [];
2747
4187
  for (let i = 0; i < axisLength; i += 1) {
2748
4188
  const rawValue = i < valuesRaw.length ? valuesRaw[i] : null;
2749
- const resolved = resolveNumericValue(rawValue, formData) ?? 0;
2750
- values.push(resolved);
4189
+ const resolved = resolveNumericValue(rawValue, formData);
4190
+ values.push(
4191
+ typeof resolved === 'number' && Number.isFinite(resolved) ? resolved : 0
4192
+ );
2751
4193
  }
2752
4194
 
2753
4195
  const label = dataset.label ?? `Dataset ${datasets.length + 1}`;
@@ -2758,18 +4200,30 @@ export function parseRadarDatasets(
2758
4200
  const pointColor = parseColor(
2759
4201
  dataset.pointColor ?? dataset.borderColor ?? '0xFF2196F3'
2760
4202
  );
2761
- const borderWidth = Number(dataset.borderWidth ?? 2);
2762
- const pointRadius = Number(dataset.pointRadius ?? 4);
2763
- const borderStyle = (dataset.borderStyle ?? 'solid')
2764
- .toString()
2765
- .toLowerCase();
2766
- const normalizedStyle =
2767
- borderStyle === 'dotted' || borderStyle === 'dashed'
2768
- ? borderStyle
2769
- : '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
+ );
2770
4217
  const dashArray = Array.isArray(dataset.dashArray)
2771
- ? 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)
2772
4221
  : undefined;
4222
+ const rawDashLength = Number(dataset.dashLength);
4223
+ const dashLength =
4224
+ Number.isFinite(rawDashLength) && rawDashLength > 0
4225
+ ? rawDashLength
4226
+ : undefined;
2773
4227
  const showPoints = dataset.showPoints !== false;
2774
4228
 
2775
4229
  datasets.push({
@@ -2779,6 +4233,7 @@ export function parseRadarDatasets(
2779
4233
  borderWidth,
2780
4234
  borderStyle: normalizedStyle,
2781
4235
  dashArray,
4236
+ dashLength,
2782
4237
  showPoints,
2783
4238
  pointRadius,
2784
4239
  pointColor,
@@ -2796,7 +4251,9 @@ export function normalizeAxisMaximums(
2796
4251
  let maxValue = axis.maxValue;
2797
4252
  datasets.forEach((dataset) => {
2798
4253
  const value = dataset.values[index];
2799
- if (value !== undefined && value > maxValue) maxValue = value;
4254
+ if (value !== undefined && Number.isFinite(value) && value > maxValue) {
4255
+ maxValue = value;
4256
+ }
2800
4257
  });
2801
4258
  return { ...axis, maxValue };
2802
4259
  });
@@ -2806,12 +4263,15 @@ function resolveRadarWidth(
2806
4263
  parsedWidth: number | string | undefined,
2807
4264
  measuredWidth: number
2808
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
+ }
2809
4272
  if (typeof parsedWidth === 'number' && Number.isFinite(parsedWidth)) {
2810
4273
  return parsedWidth;
2811
4274
  }
2812
- if (measuredWidth > 0) {
2813
- return measuredWidth;
2814
- }
2815
4275
  return 300;
2816
4276
  }
2817
4277