ferns-ui 1.10.0 → 1.12.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.
Files changed (76) hide show
  1. package/dist/Accordion.js +1 -1
  2. package/dist/Accordion.js.map +1 -1
  3. package/dist/Accordion.test.d.ts +1 -0
  4. package/dist/Accordion.test.js +71 -0
  5. package/dist/Accordion.test.js.map +1 -0
  6. package/dist/AddressField.test.d.ts +1 -0
  7. package/dist/AddressField.test.js +65 -0
  8. package/dist/AddressField.test.js.map +1 -0
  9. package/dist/Avatar.js +2 -2
  10. package/dist/Avatar.js.map +1 -1
  11. package/dist/Avatar.test.d.ts +1 -0
  12. package/dist/Avatar.test.js +131 -0
  13. package/dist/Avatar.test.js.map +1 -0
  14. package/dist/Badge.d.ts +1 -1
  15. package/dist/Badge.js +3 -3
  16. package/dist/Badge.js.map +1 -1
  17. package/dist/Badge.test.d.ts +1 -0
  18. package/dist/Badge.test.js +76 -0
  19. package/dist/Badge.test.js.map +1 -0
  20. package/dist/Box.test.d.ts +1 -0
  21. package/dist/Box.test.js +528 -0
  22. package/dist/Box.test.js.map +1 -0
  23. package/dist/Common.d.ts +101 -2
  24. package/dist/Common.js.map +1 -1
  25. package/dist/DateTimeField.js +15 -2
  26. package/dist/DateTimeField.js.map +1 -1
  27. package/dist/Heading.js +2 -0
  28. package/dist/Heading.js.map +1 -1
  29. package/dist/InfoModalIcon.js +1 -1
  30. package/dist/InfoModalIcon.js.map +1 -1
  31. package/dist/MarkdownView.d.ts +5 -0
  32. package/dist/MarkdownView.js +44 -0
  33. package/dist/MarkdownView.js.map +1 -0
  34. package/dist/Slider.d.ts +3 -0
  35. package/dist/Slider.js +94 -0
  36. package/dist/Slider.js.map +1 -0
  37. package/dist/Text.js +2 -0
  38. package/dist/Text.js.map +1 -1
  39. package/dist/TextField.test.js +9 -9
  40. package/dist/TextField.test.js.map +1 -1
  41. package/dist/Tooltip.js +2 -0
  42. package/dist/Tooltip.js.map +1 -1
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.js +2 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/setupTests.js +40 -2
  47. package/dist/setupTests.js.map +1 -1
  48. package/dist/test-utils.js.map +1 -1
  49. package/package.json +2 -47
  50. package/src/Accordion.test.tsx +104 -0
  51. package/src/Accordion.tsx +1 -0
  52. package/src/AddressField.test.tsx +89 -0
  53. package/src/Avatar.test.tsx +163 -0
  54. package/src/Avatar.tsx +2 -0
  55. package/src/Badge.test.tsx +116 -0
  56. package/src/Badge.tsx +3 -1
  57. package/src/Box.test.tsx +665 -0
  58. package/src/Common.ts +115 -2
  59. package/src/DateTimeField.tsx +15 -2
  60. package/src/Heading.tsx +2 -0
  61. package/src/InfoModalIcon.tsx +1 -0
  62. package/src/MarkdownView.tsx +67 -0
  63. package/src/Slider.tsx +205 -0
  64. package/src/Text.tsx +2 -0
  65. package/src/TextField.test.tsx +59 -71
  66. package/src/Tooltip.tsx +2 -0
  67. package/src/__snapshots__/Accordion.test.tsx.snap +120 -0
  68. package/src/__snapshots__/AddressField.test.tsx.snap +464 -0
  69. package/src/__snapshots__/Avatar.test.tsx.snap +78 -0
  70. package/src/__snapshots__/Badge.test.tsx.snap +44 -0
  71. package/src/__snapshots__/Box.test.tsx.snap +159 -0
  72. package/src/__snapshots__/TextArea.test.tsx.snap +12 -0
  73. package/src/__snapshots__/TextField.test.tsx.snap +38 -1
  74. package/src/index.tsx +2 -0
  75. package/src/setupTests.ts +45 -2
  76. package/src/test-utils.tsx +1 -0
package/src/Common.ts CHANGED
@@ -430,7 +430,13 @@ export const iconSizeToNumber = (size?: IconSize) => {
430
430
  }[size || "md"];
431
431
  };
432
432
 
433
- export type TextSize = "sm" | "md" | "lg" | "xl";
433
+ export type TextSize = "sm" | "md" | "lg" | "xl" | "2xl";
434
+
435
+ export type ValueMappingItem = {
436
+ value: number;
437
+ label: string;
438
+ size?: IconSize;
439
+ };
434
440
 
435
441
  export type IconPrefix = "far" | "fas";
436
442
 
@@ -732,7 +738,7 @@ export interface HeadingProps {
732
738
  children?: React.ReactNode;
733
739
  color?: TextColor;
734
740
  overflow?: "normal" | "breakWord"; // default "breakWord"
735
- size?: "sm" | "md" | "lg" | "xl"; // default "sm"
741
+ size?: "sm" | "md" | "lg" | "xl" | "2xl"; // default "sm"
736
742
  truncate?: boolean; // default false
737
743
  testID?: string;
738
744
  }
@@ -1305,6 +1311,10 @@ export interface AvatarProps {
1305
1311
  * Accessibility label for the avatar image.
1306
1312
  */
1307
1313
  accessibilityLabel?: string;
1314
+ /**
1315
+ * Test ID for unit testing
1316
+ */
1317
+ testID?: string;
1308
1318
  }
1309
1319
 
1310
1320
  export interface BadgeProps {
@@ -1354,6 +1364,11 @@ export interface BadgeProps {
1354
1364
  */
1355
1365
  status?: "info" | "error" | "warning" | "success" | "neutral" | "custom";
1356
1366
 
1367
+ /**
1368
+ * Test ID for unit testing
1369
+ */
1370
+ testID?: string;
1371
+
1357
1372
  /**
1358
1373
  * The text or number to display inside the badge.
1359
1374
  */
@@ -2678,3 +2693,101 @@ export interface TableNumberProps {
2678
2693
  */
2679
2694
  align?: "left" | "right";
2680
2695
  }
2696
+
2697
+ export interface SliderProps extends HelperTextProps, ErrorTextProps {
2698
+ /**
2699
+ * The title of the slider field.
2700
+ */
2701
+ title?: string;
2702
+
2703
+ /**
2704
+ * The current value of the slider.
2705
+ */
2706
+ value: number;
2707
+
2708
+ /**
2709
+ * The function to call when the slider value changes.
2710
+ */
2711
+ onChange: (value: number) => void;
2712
+
2713
+ /**
2714
+ * The minimum value of the slider.
2715
+ * @default 0
2716
+ */
2717
+ minimumValue?: number;
2718
+
2719
+ /**
2720
+ * The maximum value of the slider.
2721
+ * @default 1
2722
+ */
2723
+ maximumValue?: number;
2724
+
2725
+ /**
2726
+ * The step value of the slider.
2727
+ * @default 0
2728
+ */
2729
+ step?: number;
2730
+
2731
+ /**
2732
+ * If true, the slider will be disabled.
2733
+ * @default false
2734
+ */
2735
+ disabled?: boolean;
2736
+
2737
+ /**
2738
+ * The color of the track to the left of the thumb.
2739
+ */
2740
+ minimumTrackTintColor?: string;
2741
+
2742
+ /**
2743
+ * The color of the track to the right of the thumb.
2744
+ */
2745
+ maximumTrackTintColor?: string;
2746
+
2747
+ /**
2748
+ * The color of the thumb.
2749
+ */
2750
+ thumbTintColor?: string;
2751
+
2752
+ /**
2753
+ * If true, the slider will show the current value as text.
2754
+ * @default false
2755
+ */
2756
+ showSelection?: boolean;
2757
+
2758
+ /**
2759
+ * Labels to show below the slider track.
2760
+ */
2761
+ labels?: {
2762
+ /**
2763
+ * The minimum value label.
2764
+ */
2765
+ min?: string;
2766
+ /**
2767
+ * The maximum value label.
2768
+ */
2769
+ max?: string;
2770
+ /**
2771
+ * Additional labels with their positions (0-1 range).
2772
+ */
2773
+ custom?: Array<{index: number; label: string}>;
2774
+ };
2775
+
2776
+ /**
2777
+ * If true, displays min and max labels inline on both ends of the track.
2778
+ * @default false
2779
+ */
2780
+ inlineLabels?: boolean;
2781
+
2782
+ /**
2783
+ * If true, icons will be displayed instead of numeric values when valueMapping is provided.
2784
+ * @default false
2785
+ */
2786
+ useIcons?: boolean;
2787
+
2788
+ /**
2789
+ * Graphics/icons to display instead of numeric values.
2790
+ * Maps slider values to icon names or any string.
2791
+ */
2792
+ valueMapping?: ValueMappingItem[];
2793
+ }
@@ -336,6 +336,8 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
336
336
  day: parseInt(dayVal),
337
337
  hour: hourNum,
338
338
  minute: parseInt(minuteVal),
339
+ second: 0,
340
+ millisecond: 0,
339
341
  },
340
342
  {
341
343
  zone: override?.timezone ?? timezone,
@@ -350,6 +352,10 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
350
352
  year: parseInt(yearVal),
351
353
  month: parseInt(monthVal),
352
354
  day: parseInt(dayVal),
355
+ hour: 0,
356
+ minute: 0,
357
+ second: 0,
358
+ millisecond: 0,
353
359
  },
354
360
  {
355
361
  zone: "UTC",
@@ -369,6 +375,8 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
369
375
  {
370
376
  hour: hourNum,
371
377
  minute: parseInt(minuteVal),
378
+ second: 0,
379
+ millisecond: 0,
372
380
  },
373
381
  {
374
382
  zone: override?.timezone ?? timezone,
@@ -527,8 +535,13 @@ export const DateTimeField: FC<DateTimeFieldProps> = ({
527
535
  // Normalize emitted value to ISO (UTC for date-only)
528
536
  const normalized =
529
537
  type === "date"
530
- ? parsedDate.setZone("UTC").startOf("day").toUTC().toISO()
531
- : parsedDate.toUTC().toISO();
538
+ ? parsedDate
539
+ .setZone("UTC")
540
+ .startOf("day")
541
+ .set({second: 0, millisecond: 0})
542
+ .toUTC()
543
+ .toISO()
544
+ : parsedDate.set({second: 0, millisecond: 0}).toUTC().toISO();
532
545
  if (!normalized) {
533
546
  console.warn("Invalid date passed to DateTimeField", parsedDate);
534
547
  return;
package/src/Heading.tsx CHANGED
@@ -14,6 +14,7 @@ const fontSizeAndWeightWeb = {
14
14
  md: {size: 18, weight: "bold"},
15
15
  lg: {size: 24, weight: "bold"},
16
16
  xl: {size: 32, weight: "bold"},
17
+ "2xl": {size: 48, weight: "bold"},
17
18
  };
18
19
 
19
20
  const fontSizeAndWeighMobile = {
@@ -21,6 +22,7 @@ const fontSizeAndWeighMobile = {
21
22
  md: {size: 16, weight: "bold"},
22
23
  lg: {size: 20, weight: "bold"},
23
24
  xl: {size: 28, weight: "bold"},
25
+ "2xl": {size: 32, weight: "bold"},
24
26
  };
25
27
 
26
28
  const fontSizes = Platform.OS === "web" ? fontSizeAndWeightWeb : fontSizeAndWeighMobile;
@@ -30,6 +30,7 @@ export const InfoModalIcon: FC<InfoModalIconProps> = ({
30
30
  aria-role="button"
31
31
  hitSlop={{top: 10, bottom: 10, left: 10, right: 10}}
32
32
  style={{marginLeft: 8}}
33
+ testID="info-icon"
33
34
  onPress={() => setInfoModalVisibleState(true)}
34
35
  >
35
36
  <Heading color="secondaryLight" size="sm">
@@ -0,0 +1,67 @@
1
+ import {
2
+ Nunito_400Regular,
3
+ Nunito_500Medium,
4
+ Nunito_700Bold,
5
+ useFonts as useTextFonts,
6
+ } from "@expo-google-fonts/nunito";
7
+ import {
8
+ TitilliumWeb_600SemiBold,
9
+ TitilliumWeb_700Bold,
10
+ useFonts as useHeadingFonts,
11
+ } from "@expo-google-fonts/titillium-web";
12
+ import React from "react";
13
+ import {Platform} from "react-native";
14
+ import Markdown from "react-native-markdown-display";
15
+
16
+ import {useTheme} from "./Theme";
17
+
18
+ // Takes markdown and renders it with our theme. We should open source this component.
19
+ export const MarkdownView: React.FC<{children: React.ReactNode; inverted?: boolean}> = ({
20
+ children,
21
+ inverted,
22
+ }) => {
23
+ const {theme} = useTheme();
24
+
25
+ const color = {color: inverted ? theme.text.inverted : theme.text.primary};
26
+
27
+ // Match Heading font sizes to Heading component
28
+ // Web sizes (see src/Heading.tsx): sm:16, md:18, lg:24, xl:32
29
+ // Mobile sizes: sm:14, md:16, lg:20, xl:28
30
+ const isWeb = Platform.OS === "web";
31
+ const sizes = {
32
+ sm: isWeb ? 16 : 14,
33
+ md: isWeb ? 18 : 16,
34
+ lg: isWeb ? 24 : 20,
35
+ xl: isWeb ? 32 : 28,
36
+ } as const;
37
+
38
+ // Load fonts similar to Heading/Text components so fontFamily names resolve
39
+ useHeadingFonts({
40
+ heading: TitilliumWeb_600SemiBold,
41
+ "heading-bold": TitilliumWeb_700Bold,
42
+ "heading-semibold": TitilliumWeb_600SemiBold,
43
+ });
44
+ useTextFonts({
45
+ text: Nunito_400Regular,
46
+ "text-regular": Nunito_400Regular,
47
+ "text-medium": Nunito_500Medium,
48
+ "text-bold": Nunito_700Bold,
49
+ });
50
+
51
+ return (
52
+ <Markdown
53
+ style={{
54
+ body: {fontFamily: "text", ...color},
55
+ heading1: {fontFamily: "heading-bold", fontSize: sizes.xl, ...color},
56
+ heading2: {fontFamily: "heading-bold", fontSize: sizes.lg, ...color},
57
+ heading3: {fontFamily: "heading-bold", fontSize: sizes.md, ...color},
58
+ heading4: {fontFamily: "heading-semibold", fontSize: sizes.sm, ...color},
59
+ // h5/h6 map to small as well for consistency, slightly smaller visually handled by weight
60
+ heading5: {fontFamily: "heading-semibold", fontSize: sizes.sm, ...color},
61
+ heading6: {fontFamily: "heading-semibold", fontSize: sizes.sm, ...color},
62
+ }}
63
+ >
64
+ {children}
65
+ </Markdown>
66
+ );
67
+ };
package/src/Slider.tsx ADDED
@@ -0,0 +1,205 @@
1
+ import SliderComponent from "@react-native-community/slider";
2
+ import React, {FC} from "react";
3
+ import {View} from "react-native";
4
+
5
+ import {Box} from "./Box";
6
+ import {IconName, SliderProps, ValueMappingItem} from "./Common";
7
+ import {FieldError} from "./fieldElements/FieldError";
8
+ import {FieldHelperText} from "./fieldElements/FieldHelperText";
9
+ import {FieldTitle} from "./fieldElements/FieldTitle";
10
+ import {Icon} from "./Icon";
11
+ import {Text} from "./Text";
12
+ import {useTheme} from "./Theme";
13
+
14
+ // Find the closest option for the current value
15
+ const getCurrentMapping = (map: ValueMappingItem[], value: number) => {
16
+ if (!map || map.length === 0) {
17
+ return null;
18
+ }
19
+
20
+ // Find the option with the closest value
21
+ let closestOption = map[0];
22
+ let closestDistance = Math.abs(value - closestOption.value);
23
+
24
+ for (const option of map) {
25
+ const distance = Math.abs(value - option.value);
26
+ if (distance < closestDistance) {
27
+ closestDistance = distance;
28
+ closestOption = option;
29
+ }
30
+ }
31
+
32
+ return closestOption;
33
+ };
34
+
35
+ const getCenterContent = (
36
+ valueMapping: ValueMappingItem[] | undefined,
37
+ value: number,
38
+ step: number,
39
+ disabled: boolean,
40
+ useIcons: boolean
41
+ ): React.ReactElement => {
42
+ if (!valueMapping || valueMapping.length === 0) {
43
+ const formattedValue = value.toFixed(
44
+ step > 0 && step < 1 ? String(step).split(".")[1]?.length || 0 : 0
45
+ );
46
+ return (
47
+ <Text align="center" color={disabled ? "secondaryLight" : "primary"} size="lg">
48
+ {formattedValue}
49
+ </Text>
50
+ );
51
+ }
52
+
53
+ const currentOption = getCurrentMapping(valueMapping, value);
54
+
55
+ if (useIcons) {
56
+ return (
57
+ <Icon
58
+ color={disabled ? "secondaryLight" : "primary"}
59
+ iconName={currentOption!.label as IconName}
60
+ size={currentOption!.size || "md"}
61
+ />
62
+ );
63
+ }
64
+
65
+ return (
66
+ <Text align="center" color={disabled ? "secondaryLight" : "primary"} size="2xl">
67
+ {currentOption?.label}
68
+ </Text>
69
+ );
70
+ };
71
+
72
+ const getSliderContent = (
73
+ slider: React.ReactElement,
74
+ inlineLabels: boolean,
75
+ labels?: SliderProps['labels']
76
+ ): React.ReactElement => {
77
+ if (inlineLabels && labels?.min && labels?.max) {
78
+ return (
79
+ <Box alignItems="center" direction="row" gap={2}>
80
+ <Box flex="shrink" minWidth={30}>
81
+ <Text color="secondaryDark" size="md">
82
+ {labels.min}
83
+ </Text>
84
+ </Box>
85
+ <Box flex="grow">{slider}</Box>
86
+ <Box alignItems="end" flex="shrink" minWidth={30}>
87
+ <Text color="secondaryDark" size="md">
88
+ {labels.max}
89
+ </Text>
90
+ </Box>
91
+ </Box>
92
+ );
93
+ }
94
+
95
+ return (
96
+ <>
97
+ {slider}
98
+ {labels && (
99
+ <Box direction="row" justifyContent="between" marginTop={2}>
100
+ {labels.min && (
101
+ <Text color="secondaryDark" size="sm">
102
+ {labels.min}
103
+ </Text>
104
+ )}
105
+ {labels.custom?.map((customLabel, index) => (
106
+ <Text key={index} color="secondaryDark" size="sm">
107
+ {customLabel.label}
108
+ </Text>
109
+ ))}
110
+ {labels.max && (
111
+ <Text color="secondaryDark" size="sm">
112
+ {labels.max}
113
+ </Text>
114
+ )}
115
+ </Box>
116
+ )}
117
+ </>
118
+ );
119
+ };
120
+
121
+ export const Slider: FC<SliderProps> = ({
122
+ disabled = false,
123
+ errorText,
124
+ helperText,
125
+ inlineLabels = false,
126
+ labels,
127
+ maximumTrackTintColor,
128
+ maximumValue = 1,
129
+ minimumTrackTintColor,
130
+ minimumValue = 0,
131
+ step = 0,
132
+ thumbTintColor,
133
+ title,
134
+ showSelection = false,
135
+ useIcons = false,
136
+ value,
137
+ valueMapping,
138
+ onChange,
139
+ }) => {
140
+ const {theme} = useTheme();
141
+
142
+ if (!theme) {
143
+ return null;
144
+ }
145
+
146
+ const minTrackColor = minimumTrackTintColor || theme.surface.primary;
147
+ const maxTrackColor = maximumTrackTintColor || theme.border.default;
148
+ const thumbColor = thumbTintColor || theme.surface.primary;
149
+
150
+ const sliderStyles = {
151
+ trackStyle: {
152
+ height: 10,
153
+ },
154
+ thumbStyle: {
155
+ width: 48,
156
+ height: 48,
157
+ backgroundColor: 'white',
158
+ borderRadius: 24,
159
+ shadowColor: '#000',
160
+ shadowOffset: {
161
+ width: 0,
162
+ height: 2,
163
+ },
164
+ shadowOpacity: 0.25,
165
+ shadowRadius: 3.84,
166
+ elevation: 5,
167
+ },
168
+ };
169
+
170
+ const sliderElement = (
171
+ <SliderComponent
172
+ disabled={disabled}
173
+ maximumTrackTintColor={maxTrackColor}
174
+ maximumValue={maximumValue}
175
+ minimumTrackTintColor={minTrackColor}
176
+ minimumValue={minimumValue}
177
+ step={step}
178
+ thumbTintColor={thumbColor}
179
+ value={value}
180
+ onValueChange={onChange}
181
+ {...sliderStyles}
182
+ />
183
+ );
184
+
185
+ const centerContent = getCenterContent(valueMapping, value, step, disabled, useIcons);
186
+ const sliderContent = getSliderContent(sliderElement, inlineLabels, labels);
187
+
188
+ return (
189
+ <Box>
190
+ {Boolean(title) && <FieldTitle text={title!} />}
191
+ <Box direction="column" gap={showSelection ? 2 : 0}>
192
+ {showSelection && (
193
+ <Box alignItems="center">
194
+ {centerContent}
195
+ </Box>
196
+ )}
197
+ {sliderContent}
198
+ </Box>
199
+ {Boolean(helperText && !errorText) && <FieldHelperText text={helperText!} />}
200
+ {Boolean(errorText) && <FieldError text={errorText!} />}
201
+ </Box>
202
+ );
203
+ };
204
+
205
+
package/src/Text.tsx CHANGED
@@ -19,6 +19,7 @@ const fontSizeAndWeightWeb = {
19
19
  md: {size: 16, weight: "regular"},
20
20
  lg: {size: 18, weight: "medium"},
21
21
  xl: {size: 20, weight: "medium"},
22
+ "2xl": {size: 48, weight: "medium"},
22
23
  };
23
24
 
24
25
  const fontSizeAndWeighMobile = {
@@ -26,6 +27,7 @@ const fontSizeAndWeighMobile = {
26
27
  md: {size: 14, weight: "regular"},
27
28
  lg: {size: 16, weight: "medium"},
28
29
  xl: {size: 18, weight: "medium"},
30
+ "2xl": {size: 40, weight: "medium"},
29
31
  };
30
32
 
31
33
  const fontSizes = Platform.OS === "web" ? fontSizeAndWeightWeb : fontSizeAndWeighMobile;