ferns-ui 1.7.0 → 1.8.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 (42) hide show
  1. package/dist/Common.d.ts +42 -0
  2. package/dist/DateTimeField.test.js +5 -9
  3. package/dist/DateTimeField.test.js.map +1 -1
  4. package/dist/Icon.js +2 -1
  5. package/dist/Icon.js.map +1 -1
  6. package/dist/Modal.js +21 -6
  7. package/dist/Modal.js.map +1 -1
  8. package/dist/SelectBadge.d.ts +3 -0
  9. package/dist/SelectBadge.js +180 -0
  10. package/dist/SelectBadge.js.map +1 -0
  11. package/dist/TextArea.test.d.ts +1 -0
  12. package/dist/TextArea.test.js +146 -0
  13. package/dist/TextArea.test.js.map +1 -0
  14. package/dist/TextField.test.d.ts +1 -0
  15. package/dist/TextField.test.js +251 -0
  16. package/dist/TextField.test.js.map +1 -0
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +1 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/test-utils.d.ts +97 -0
  21. package/dist/test-utils.js +23 -0
  22. package/dist/test-utils.js.map +1 -0
  23. package/dist/useStoredState.d.ts +1 -1
  24. package/dist/useStoredState.js +19 -4
  25. package/dist/useStoredState.js.map +1 -1
  26. package/dist/useStoredState.test.d.ts +1 -0
  27. package/dist/useStoredState.test.js +93 -0
  28. package/dist/useStoredState.test.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/Common.ts +43 -0
  31. package/src/DateTimeField.test.tsx +5 -10
  32. package/src/Icon.tsx +1 -1
  33. package/src/Modal.tsx +24 -6
  34. package/src/SelectBadge.tsx +279 -0
  35. package/src/TextArea.test.tsx +271 -0
  36. package/src/TextField.test.tsx +442 -0
  37. package/src/__snapshots__/TextArea.test.tsx.snap +424 -0
  38. package/src/__snapshots__/TextField.test.tsx.snap +481 -0
  39. package/src/index.tsx +1 -0
  40. package/src/test-utils.tsx +26 -0
  41. package/src/useStoredState.test.tsx +134 -0
  42. package/src/useStoredState.ts +21 -5
@@ -0,0 +1,93 @@
1
+ import { act, renderHook } from "@testing-library/react-native";
2
+ import { Unifier } from "./Unifier";
3
+ import { useStoredState } from "./useStoredState";
4
+ jest.mock("./Unifier", () => ({
5
+ Unifier: {
6
+ storage: {
7
+ getItem: jest.fn(),
8
+ setItem: jest.fn(),
9
+ },
10
+ },
11
+ }));
12
+ describe("useStoredState", () => {
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+ });
16
+ it("should return initialValue and isLoading=true on initial render", async () => {
17
+ Unifier.storage.getItem.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve("stored value"), 100)));
18
+ const { result } = renderHook(() => useStoredState("testKey", "initial value"));
19
+ expect(result.current[0]).toBe("initial value");
20
+ expect(result.current[2]).toBe(true);
21
+ await act(async () => {
22
+ await new Promise((resolve) => setTimeout(resolve, 200));
23
+ });
24
+ expect(result.current[0]).toBe("stored value");
25
+ expect(result.current[2]).toBe(false);
26
+ });
27
+ it("should update state and storage when setter is called", async () => {
28
+ Unifier.storage.getItem.mockResolvedValue("stored value");
29
+ Unifier.storage.setItem.mockResolvedValue(undefined);
30
+ const { result } = renderHook(() => useStoredState("testKey", "initial value"));
31
+ await act(async () => {
32
+ await new Promise((resolve) => setTimeout(resolve, 10));
33
+ });
34
+ await act(async () => {
35
+ await result.current[1]("new value");
36
+ });
37
+ expect(result.current[0]).toBe("new value");
38
+ expect(Unifier.storage.setItem).toHaveBeenCalledWith("testKey", "new value");
39
+ });
40
+ it("should handle errors when reading from storage", async () => {
41
+ const originalConsoleError = console.error;
42
+ console.error = jest.fn();
43
+ Unifier.storage.getItem.mockRejectedValue(new Error("Storage error"));
44
+ const { result } = renderHook(() => useStoredState("testKey", "initial value"));
45
+ await act(async () => {
46
+ await new Promise((resolve) => setTimeout(resolve, 10));
47
+ });
48
+ expect(result.current[0]).toBe("initial value");
49
+ expect(result.current[2]).toBe(false);
50
+ expect(console.error).toHaveBeenCalled();
51
+ console.error = originalConsoleError;
52
+ });
53
+ it("should handle errors when writing to storage", async () => {
54
+ const originalConsoleError = console.error;
55
+ console.error = jest.fn();
56
+ Unifier.storage.getItem.mockResolvedValue("stored value");
57
+ Unifier.storage.setItem.mockRejectedValue(new Error("Storage error"));
58
+ const { result } = renderHook(() => useStoredState("testKey", "initial value"));
59
+ await act(async () => {
60
+ await new Promise((resolve) => setTimeout(resolve, 10));
61
+ });
62
+ await act(async () => {
63
+ await result.current[1]("new value");
64
+ });
65
+ expect(result.current[0]).toBe("stored value");
66
+ expect(console.error).toHaveBeenCalled();
67
+ console.error = originalConsoleError;
68
+ });
69
+ it("should handle undefined initialValue", async () => {
70
+ Unifier.storage.getItem.mockResolvedValue(null);
71
+ const { result } = renderHook(() => useStoredState("testKey"));
72
+ expect(result.current[0]).toBeUndefined();
73
+ expect(result.current[2]).toBe(true);
74
+ await act(async () => {
75
+ await new Promise((resolve) => setTimeout(resolve, 10));
76
+ });
77
+ expect(result.current[0]).toBeNull();
78
+ expect(result.current[2]).toBe(false);
79
+ });
80
+ it("should not update state if component unmounts before storage resolves", async () => {
81
+ Unifier.storage.getItem.mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve("stored value"), 100)));
82
+ const { result, unmount } = renderHook(() => useStoredState("testKey", "initial value"));
83
+ expect(result.current[0]).toBe("initial value");
84
+ expect(result.current[2]).toBe(true);
85
+ unmount();
86
+ await act(async () => {
87
+ await new Promise((resolve) => setTimeout(resolve, 200));
88
+ });
89
+ expect(result.current[0]).toBe("initial value");
90
+ expect(result.current[2]).toBe(true);
91
+ });
92
+ });
93
+ //# sourceMappingURL=useStoredState.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useStoredState.test.js","sourceRoot":"","sources":["../src/useStoredState.test.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ferns-ui",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "Apache-2.0",
6
6
  "scripts": {
package/src/Common.ts CHANGED
@@ -1361,6 +1361,49 @@ export interface BadgeProps {
1361
1361
  variant?: "iconOnly" | "numberOnly";
1362
1362
  }
1363
1363
 
1364
+ export interface SelectBadgeProps {
1365
+ /**
1366
+ * When status is "custom", determines the badge's background color.
1367
+ */
1368
+ customBackgroundColor?: string;
1369
+ /**
1370
+ * When status is "custom", determines the badge's border color.
1371
+ */
1372
+ customBorderColor?: string;
1373
+ /**
1374
+ * When status is "custom", determines the badge's text color.
1375
+ */
1376
+ customTextColor?: string;
1377
+ /**
1378
+ * If true, the badge will be disabled and not interactive.
1379
+ * @default false
1380
+ */
1381
+ disabled?: boolean;
1382
+ /**
1383
+ * The options available for the dropdown badge.
1384
+ * Each option should have a label and a value.
1385
+ */
1386
+ options: FieldOption[];
1387
+ /**
1388
+ * The current value of the select field.
1389
+ */
1390
+ value?: string;
1391
+ /**
1392
+ * If true, the badge will have a secondary style.
1393
+ * @default false
1394
+ */
1395
+ secondary?: boolean;
1396
+ /**
1397
+ * The status of the badge. Determines its color and appearance.
1398
+ * @default "info"
1399
+ */
1400
+ status?: "info" | "error" | "warning" | "success" | "neutral" | "custom";
1401
+ /**
1402
+ * The function to call when the selected value changes.
1403
+ */
1404
+ onChange: (value: string) => void;
1405
+ }
1406
+
1364
1407
  type BannerButtonProps = {
1365
1408
  /**
1366
1409
  * Text to display on optional banner button, will display button if provided
@@ -1,26 +1,21 @@
1
- import {act, render, userEvent} from "@testing-library/react-native";
1
+ import {act, userEvent} from "@testing-library/react-native";
2
2
  import {DateTime} from "luxon";
3
3
  import React from "react";
4
4
 
5
5
  import {DateTimeField} from "./DateTimeField";
6
- import {ThemeProvider} from "./Theme";
7
-
8
- const renderWithTheme = (ui: React.ReactElement) => {
9
- return render(<ThemeProvider>{ui}</ThemeProvider>);
10
- };
6
+ import {renderWithTheme, setupComponentTest, teardownComponentTest} from "./test-utils";
11
7
 
12
8
  describe("DateTimeField", () => {
13
9
  let mockOnChange: jest.Mock;
14
10
 
15
11
  beforeEach(() => {
16
- jest.useFakeTimers();
12
+ const mocks = setupComponentTest();
17
13
  jest.setSystemTime(new Date("2023-05-15T10:30:00.000Z"));
18
- mockOnChange = jest.fn();
14
+ mockOnChange = mocks.onChange;
19
15
  });
20
16
 
21
17
  afterEach(() => {
22
- jest.useRealTimers();
23
- jest.clearAllMocks();
18
+ teardownComponentTest();
24
19
  });
25
20
 
26
21
  describe("date type", () => {
package/src/Icon.tsx CHANGED
@@ -15,7 +15,7 @@ export const Icon = ({
15
15
  testID,
16
16
  }: IconProps): React.ReactElement => {
17
17
  const {theme} = useTheme();
18
- const iconColor = theme.text[color];
18
+ const iconColor = theme.text[color] ?? color;
19
19
  const iconSize = iconSizeToNumber(size);
20
20
  return (
21
21
  <FontAwesome6
package/src/Modal.tsx CHANGED
@@ -178,9 +178,27 @@ export const Modal: FC<ModalProps> = ({
178
178
  const actionSheetRef = useRef<ActionSheetRef>(null);
179
179
  const {theme} = useTheme();
180
180
 
181
+ const handleDismiss = () => {
182
+ if (visible && onDismiss) {
183
+ onDismiss();
184
+ }
185
+ };
186
+
187
+ const handlePrimaryButtonClick = (value?: Parameters<NonNullable<ModalProps["primaryButtonOnClick"]>>[0]) => {
188
+ if (visible && primaryButtonOnClick) {
189
+ return primaryButtonOnClick(value);
190
+ }
191
+ };
192
+
193
+ const handleSecondaryButtonClick = (value?: Parameters<NonNullable<ModalProps["secondaryButtonOnClick"]>>[0]) => {
194
+ if (visible && secondaryButtonOnClick) {
195
+ return secondaryButtonOnClick(value);
196
+ }
197
+ };
198
+
181
199
  const onHandlerStateChange = ({nativeEvent}: PanGestureHandlerStateChangeEvent) => {
182
200
  if (nativeEvent.state === State.END && nativeEvent.translationY > 100) {
183
- onDismiss();
201
+ handleDismiss();
184
202
  }
185
203
  };
186
204
 
@@ -201,9 +219,9 @@ export const Modal: FC<ModalProps> = ({
201
219
  primaryButtonText,
202
220
  primaryButtonDisabled,
203
221
  secondaryButtonText,
204
- primaryButtonOnClick,
205
- secondaryButtonOnClick,
206
- onDismiss,
222
+ primaryButtonOnClick: handlePrimaryButtonClick,
223
+ secondaryButtonOnClick: handleSecondaryButtonClick,
224
+ onDismiss: handleDismiss,
207
225
  sizePx,
208
226
  theme,
209
227
  isMobile,
@@ -211,7 +229,7 @@ export const Modal: FC<ModalProps> = ({
211
229
 
212
230
  if (isMobile) {
213
231
  return (
214
- <ActionSheet ref={actionSheetRef} onClose={onDismiss}>
232
+ <ActionSheet ref={actionSheetRef} onClose={handleDismiss}>
215
233
  <PanGestureHandler onHandlerStateChange={onHandlerStateChange}>
216
234
  <View>
217
235
  <View
@@ -237,7 +255,7 @@ export const Modal: FC<ModalProps> = ({
237
255
  );
238
256
  } else {
239
257
  return (
240
- <RNModal animationType="slide" transparent visible={visible} onRequestClose={onDismiss}>
258
+ <RNModal animationType="slide" transparent visible={visible} onRequestClose={handleDismiss}>
241
259
  <ModalContent {...modalContentProps}>{children}</ModalContent>
242
260
  </RNModal>
243
261
  );
@@ -0,0 +1,279 @@
1
+ import {Picker} from "@react-native-picker/picker";
2
+ import React, {useCallback, useMemo, useState} from "react";
3
+ import {Modal, Platform, Text, TouchableOpacity, View} from "react-native";
4
+
5
+ import {FieldOption, SelectBadgeProps, SurfaceTheme, TextTheme} from "./Common";
6
+ import {Icon} from "./Icon";
7
+ import {useTheme} from "./Theme";
8
+
9
+ export const SelectBadge = ({
10
+ value,
11
+ status = "info",
12
+ secondary = false,
13
+ customBackgroundColor,
14
+ customTextColor,
15
+ customBorderColor,
16
+ disabled = false,
17
+ options,
18
+ onChange,
19
+ }: SelectBadgeProps): React.ReactElement => {
20
+ const {theme} = useTheme();
21
+ const [showPicker, setShowPicker] = useState(false);
22
+ // Temporary state to manage value changes for ios picker
23
+ // Assures the badge display value persists when user scrolls through options
24
+ const [iosDisplayValue, setIosDisplayValue] = useState<string | undefined>(value);
25
+
26
+ const secondaryBorderColors = {
27
+ error: "#F39E9E",
28
+ warning: "#FCC58F",
29
+ info: "#8FC1D2",
30
+ success: "#7FD898",
31
+ neutral: "#AAAAAA",
32
+ custom: "#AAAAAA",
33
+ };
34
+
35
+ let borderWidth = 0;
36
+ if (secondary || status === "custom") borderWidth = 1;
37
+
38
+ let badgeColor: keyof TextTheme = "inverted";
39
+
40
+ if (secondary) {
41
+ if (status === "error") badgeColor = "error";
42
+ else if (status === "warning") badgeColor = "warning";
43
+ else if (status === "info") badgeColor = "secondaryDark";
44
+ else if (status === "success") badgeColor = "success";
45
+ else if (status === "neutral") badgeColor = "primary";
46
+ }
47
+
48
+ let badgeBgColor: keyof SurfaceTheme = "neutralDark";
49
+
50
+ if (status === "error") badgeBgColor = secondary ? "errorLight" : "error";
51
+ else if (status === "warning") badgeBgColor = secondary ? "warningLight" : "warning";
52
+ else if (status === "info") badgeBgColor = secondary ? "secondaryLight" : "secondaryDark";
53
+ else if (status === "success") badgeBgColor = secondary ? "successLight" : "success";
54
+ else if (status === "neutral") badgeBgColor = secondary ? "neutralLight" : "neutralDark";
55
+
56
+ const backgroundColor = status === "custom" ? customBackgroundColor : theme.surface[badgeBgColor];
57
+ const borderColor = status === "custom" ? customBorderColor : secondaryBorderColors[status];
58
+ const textColor = status === "custom" ? customTextColor : theme.text[badgeColor];
59
+
60
+ let leftOfChevronBorderColor = textColor;
61
+ if (status === "custom") leftOfChevronBorderColor = customBorderColor ?? textColor;
62
+ else if (secondary) leftOfChevronBorderColor = borderColor;
63
+
64
+ const findSelectedItem = useCallback(
65
+ (v: string | undefined | null): FieldOption | null => {
66
+ if (v !== undefined && v !== null) {
67
+ return options.find((opt) => opt.value === v) || null;
68
+ }
69
+ return null;
70
+ },
71
+ [options]
72
+ );
73
+
74
+ const displayVal = useMemo(() => {
75
+ return findSelectedItem(value)?.label ?? "---";
76
+ }, [value, findSelectedItem]);
77
+
78
+ const handleOnChange = useCallback(
79
+ (val: string) => {
80
+ const selectedItem = findSelectedItem(val);
81
+ if (selectedItem) {
82
+ onChange(selectedItem.value);
83
+ }
84
+ setShowPicker(false);
85
+ },
86
+ [findSelectedItem, onChange]
87
+ );
88
+
89
+ const renderPickerItems = useCallback(() => {
90
+ return options?.map((item: any) => (
91
+ <Picker.Item key={item.key || item.label} label={item.label} value={item.value} />
92
+ ));
93
+ }, [options]);
94
+
95
+ const renderIosPicker = useCallback(() => {
96
+ const handleValueChangeIos = (itemValue: string) => {
97
+ setIosDisplayValue(itemValue);
98
+ };
99
+
100
+ const handleSave = () => {
101
+ if (iosDisplayValue && !disabled) {
102
+ handleOnChange(iosDisplayValue);
103
+ } else {
104
+ setShowPicker(false);
105
+ }
106
+ };
107
+
108
+ const handleDismiss = () => {
109
+ setShowPicker(false);
110
+ setIosDisplayValue(value);
111
+ };
112
+
113
+ return (
114
+ <Modal
115
+ animationType="slide"
116
+ supportedOrientations={["portrait", "landscape"]}
117
+ transparent
118
+ visible={showPicker}
119
+ onRequestClose={handleDismiss}
120
+ >
121
+ <View style={{flex: 1, justifyContent: "flex-end"}}>
122
+ <TouchableOpacity
123
+ accessibilityHint="Closes the picker modal"
124
+ accessibilityLabel="Dismiss picker modal"
125
+ activeOpacity={1}
126
+ style={{flex: 1}}
127
+ onPress={handleDismiss}
128
+ />
129
+ <View
130
+ style={{
131
+ backgroundColor: theme.surface.neutralLight,
132
+ borderTopWidth: 1,
133
+ borderTopColor: theme.border.default,
134
+ height: 215,
135
+ }}
136
+ >
137
+ <View
138
+ style={{
139
+ width: "100%",
140
+ height: 45,
141
+ backgroundColor: "#f8f8f8",
142
+ alignItems: "center",
143
+ justifyContent: "center",
144
+ }}
145
+ >
146
+ <TouchableOpacity
147
+ accessibilityHint="Saves the selected value"
148
+ accessibilityLabel="Save selected value"
149
+ aria-role="button"
150
+ hitSlop={{top: 4, right: 4, bottom: 4, left: 4}}
151
+ style={{
152
+ alignSelf: "flex-end",
153
+ paddingRight: 12,
154
+ }}
155
+ onPress={handleSave}
156
+ >
157
+ <View>
158
+ <Text
159
+ style={{
160
+ color: "#007aff",
161
+ fontWeight: "600",
162
+ fontSize: 17,
163
+ paddingTop: 1,
164
+ }}
165
+ >
166
+ Save
167
+ </Text>
168
+ </View>
169
+ </TouchableOpacity>
170
+ </View>
171
+ <Picker
172
+ enabled={!disabled}
173
+ selectedValue={iosDisplayValue}
174
+ onValueChange={handleValueChangeIos}
175
+ >
176
+ {renderPickerItems()}
177
+ </Picker>
178
+ </View>
179
+ </View>
180
+ </Modal>
181
+ );
182
+ }, [showPicker, iosDisplayValue, disabled, theme, value, handleOnChange, renderPickerItems]);
183
+
184
+ const renderPicker = useCallback(() => {
185
+ return (
186
+ <Picker
187
+ enabled={!disabled}
188
+ selectedValue={findSelectedItem(value)?.value ?? undefined}
189
+ style={[
190
+ {
191
+ position: "absolute",
192
+ width: "100%",
193
+ height: "100%",
194
+ color: "transparent",
195
+ opacity: 0,
196
+ },
197
+ // Android headless picker: transparent overlay to capture touches without visible UI.
198
+ Platform.OS !== "web" && {backgroundColor: "transparent"},
199
+ ]}
200
+ onValueChange={handleOnChange}
201
+ >
202
+ {renderPickerItems()}
203
+ </Picker>
204
+ );
205
+ }, [disabled, findSelectedItem, value, handleOnChange, renderPickerItems]);
206
+
207
+ return (
208
+ <View style={{alignItems: "flex-start", opacity: disabled ? 0.5 : 1}}>
209
+ <TouchableOpacity
210
+ accessibilityHint="Opens the options picker"
211
+ accessibilityLabel="Open select badge options"
212
+ aria-role="button"
213
+ disabled={disabled}
214
+ onPress={() => setShowPicker(!showPicker)}
215
+ >
216
+ <View
217
+ style={{
218
+ display: "flex",
219
+ flexDirection: "row",
220
+ alignItems: "center",
221
+ height: 20,
222
+ width: "auto",
223
+ }}
224
+ >
225
+ <View
226
+ style={{
227
+ justifyContent: "center",
228
+ alignItems: "center",
229
+ paddingHorizontal: theme.spacing.sm,
230
+ flexDirection: "row",
231
+ borderTopLeftRadius: 4,
232
+ borderBottomLeftRadius: 4,
233
+ backgroundColor,
234
+ height: 20,
235
+ width: "auto",
236
+ borderWidth,
237
+ borderColor,
238
+ }}
239
+ >
240
+ <Text
241
+ style={{
242
+ color: textColor,
243
+ fontSize: 10,
244
+ fontWeight: "700",
245
+ fontFamily: "text",
246
+ }}
247
+ >
248
+ {displayVal}
249
+ </Text>
250
+ </View>
251
+ <View
252
+ style={{
253
+ justifyContent: "center",
254
+ alignItems: "center",
255
+ paddingVertical: 1,
256
+ paddingHorizontal: theme.spacing.xs,
257
+ flexDirection: "row",
258
+ borderTopRightRadius: 4,
259
+ borderBottomRightRadius: 4,
260
+ backgroundColor,
261
+ height: 20,
262
+ width: "auto",
263
+ borderWidth,
264
+ borderLeftWidth: 1,
265
+ borderColor: leftOfChevronBorderColor,
266
+ }}
267
+ >
268
+ <Icon
269
+ color={textColor as any}
270
+ iconName={showPicker ? "chevron-up" : "chevron-down"}
271
+ size="sm"
272
+ />
273
+ </View>
274
+ </View>
275
+ </TouchableOpacity>
276
+ {Platform.OS === "ios" ? renderIosPicker() : renderPicker()}
277
+ </View>
278
+ );
279
+ };