ferns-ui 0.27.0 → 0.28.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.
package/src/Modal.tsx CHANGED
@@ -1,9 +1,12 @@
1
- import React from "react";
1
+ import React, {useEffect, useRef} from "react";
2
2
  import {Dimensions, Modal as RNModal} from "react-native";
3
+ import ActionSheet, {ActionSheetRef} from "react-native-actions-sheet";
3
4
 
4
5
  import {Box} from "./Box";
5
6
  import {Button} from "./Button";
6
7
  import {Heading} from "./Heading";
8
+ import {IconButton} from "./IconButton";
9
+ import {isMobileDevice} from "./MediaQuery";
7
10
  import {Text} from "./Text";
8
11
 
9
12
  interface ModalProps {
@@ -26,6 +29,8 @@ interface ModalProps {
26
29
  // Requires primaryButtonText to be defined, but is not required itself.
27
30
  secondaryButtonText?: string;
28
31
  secondaryButtonOnClick?: (value?: any) => void;
32
+ // Whether to show a close button in the upper left of modals or action sheets.
33
+ showClose?: boolean;
29
34
  }
30
35
 
31
36
  export const Modal = ({
@@ -42,17 +47,57 @@ export const Modal = ({
42
47
  primaryButtonDisabled = false,
43
48
  secondaryButtonText,
44
49
  secondaryButtonOnClick,
50
+ showClose = false,
45
51
  }: ModalProps): React.ReactElement => {
52
+ const actionSheetRef = useRef<ActionSheetRef>(null);
53
+
46
54
  if (subHeading && !heading) {
47
55
  throw new Error("Cannot render Modal with subHeading and no heading");
48
56
  }
49
- if (!footer && !primaryButtonText && !secondaryButtonText) {
57
+ if (!footer && !primaryButtonText && !secondaryButtonText && !showClose) {
50
58
  throw new Error(
51
- "Cannot render Modal without footer, primaryButtonText, or secondaryButtonText"
59
+ "Cannot render Modal without footer, primaryButtonText, secondaryButtonText, or showClose"
52
60
  );
53
61
  }
54
62
 
55
- function renderHeader(): React.ReactElement {
63
+ let sizePx: string | number = 540;
64
+ if (size === "md") {
65
+ sizePx = 720;
66
+ } else if (size === "lg") {
67
+ sizePx = 900;
68
+ }
69
+
70
+ // Adjust size for small screens
71
+ if (sizePx > Dimensions.get("window").width) {
72
+ sizePx = "90%";
73
+ }
74
+
75
+ // Modal uses a visible prop, but ActionSheet uses a setModalVisible method on a reference.
76
+ // Open the action sheet ref when the visible prop changes.
77
+ useEffect(() => {
78
+ if (actionSheetRef.current) {
79
+ actionSheetRef.current.setModalVisible(visible);
80
+ }
81
+ }, [visible, actionSheetRef]);
82
+
83
+ const renderClose = (): React.ReactElement | null => {
84
+ if (!showClose) {
85
+ return null;
86
+ }
87
+ return (
88
+ <Box padding={3} width="100%">
89
+ <IconButton
90
+ accessibilityLabel="close"
91
+ bgColor="white"
92
+ icon="times"
93
+ iconColor="darkGray"
94
+ onClick={() => onDismiss()}
95
+ />
96
+ </Box>
97
+ );
98
+ };
99
+
100
+ const renderModalHeader = (): React.ReactElement => {
56
101
  return (
57
102
  <Box paddingY={3} width="100%">
58
103
  <Box>
@@ -67,8 +112,9 @@ export const Modal = ({
67
112
  )}
68
113
  </Box>
69
114
  );
70
- }
71
- function renderFooter(): React.ReactElement | null {
115
+ };
116
+
117
+ const renderModalFooter = (): React.ReactElement | null => {
72
118
  if (footer) {
73
119
  return footer;
74
120
  }
@@ -93,56 +139,106 @@ export const Modal = ({
93
139
  </Box>
94
140
  </Box>
95
141
  );
96
- }
142
+ };
97
143
 
98
- let sizePx: string | number = 540;
99
- if (size === "md") {
100
- sizePx = 720;
101
- } else if (size === "lg") {
102
- sizePx = 900;
103
- }
144
+ const renderModal = (): React.ReactElement => {
145
+ return (
146
+ <RNModal animationType="slide" transparent visible={visible} onRequestClose={onDismiss}>
147
+ <Box
148
+ alignItems="center"
149
+ alignSelf="center"
150
+ color="white"
151
+ dangerouslySetInlineStyle={{
152
+ __style: {
153
+ zIndex: 1,
154
+ shadowColor: "#999",
155
+ shadowOffset: {
156
+ width: 4,
157
+ height: 6,
158
+ },
159
+ shadowRadius: 4,
160
+ shadowOpacity: 1.0,
161
+ elevation: 8,
162
+ },
163
+ }}
164
+ direction="column"
165
+ justifyContent="center"
166
+ marginTop={12}
167
+ maxWidth={sizePx}
168
+ minWidth={300}
169
+ paddingX={8}
170
+ paddingY={2}
171
+ rounding={6}
172
+ shadow
173
+ width={sizePx}
174
+ >
175
+ <Box marginBottom={6} width="100%">
176
+ {renderClose()}
177
+ {renderModalHeader()}
178
+ <Box paddingY={4}>{children}</Box>
179
+ <Box paddingY={4}>{renderModalFooter()}</Box>
180
+ </Box>
181
+ </Box>
182
+ </RNModal>
183
+ );
184
+ };
104
185
 
105
- // Adjust size for small screens
106
- if (sizePx > Dimensions.get("window").width) {
107
- sizePx = "90%";
108
- }
186
+ const renderActionSheet = (): React.ReactElement => {
187
+ return (
188
+ <ActionSheet ref={actionSheetRef} onClose={onDismiss}>
189
+ <Box direction="row" marginBottom={2} paddingX={2} paddingY={2} width="100%">
190
+ <Box marginRight={4}>
191
+ {Boolean(secondaryButtonText) && (
192
+ <Button
193
+ color="darkGray"
194
+ inline
195
+ text={secondaryButtonText ?? ""}
196
+ type="ghost"
197
+ onClick={secondaryButtonOnClick}
198
+ />
199
+ )}
200
+ {Boolean(showClose) && (
201
+ <IconButton
202
+ accessibilityLabel="close"
203
+ bgColor="white"
204
+ icon="times"
205
+ iconColor="darkGray"
206
+ onClick={() => onDismiss()}
207
+ />
208
+ )}
209
+ </Box>
210
+ <Box direction="column" flex="grow">
211
+ <Heading align={align === "center" ? "center" : undefined} size="sm">
212
+ {heading}
213
+ </Heading>
214
+ {Boolean(subHeading) && (
215
+ <Box paddingY={2}>
216
+ <Text align={align === "center" ? "center" : undefined}>{subHeading}</Text>
217
+ </Box>
218
+ )}
219
+ </Box>
109
220
 
110
- return (
111
- <RNModal animationType="slide" transparent visible={visible} onRequestClose={onDismiss}>
112
- <Box
113
- alignItems="center"
114
- alignSelf="center"
115
- color="white"
116
- dangerouslySetInlineStyle={{
117
- __style: {
118
- zIndex: 1,
119
- shadowColor: "#999",
120
- shadowOffset: {
121
- width: 4,
122
- height: 6,
123
- },
124
- shadowRadius: 4,
125
- shadowOpacity: 1.0,
126
- elevation: 8,
127
- },
128
- }}
129
- direction="column"
130
- justifyContent="center"
131
- marginTop={12}
132
- maxWidth={sizePx}
133
- minWidth={300}
134
- paddingX={8}
135
- paddingY={2}
136
- rounding={6}
137
- shadow
138
- width={sizePx}
139
- >
140
- <Box marginBottom={6} width="100%">
141
- {renderHeader()}
142
- <Box paddingY={4}>{children}</Box>
143
- <Box paddingY={4}>{renderFooter()}</Box>
221
+ <Box alignSelf="end">
222
+ {Boolean(primaryButtonText) && (
223
+ <Button
224
+ color="primary"
225
+ disabled={primaryButtonDisabled}
226
+ inline
227
+ text={primaryButtonText!}
228
+ type="ghost"
229
+ onClick={primaryButtonOnClick}
230
+ />
231
+ )}
232
+ </Box>
144
233
  </Box>
145
- </Box>
146
- </RNModal>
147
- );
234
+ <Box marginBottom={12}>{children}</Box>
235
+ </ActionSheet>
236
+ );
237
+ };
238
+
239
+ if (isMobileDevice()) {
240
+ return renderActionSheet();
241
+ } else {
242
+ return renderModal();
243
+ }
148
244
  };
@@ -364,7 +364,11 @@ export function RNPickerSelect({
364
364
  }
365
365
 
366
366
  return (
367
- <View style={[defaultStyles.iconContainer, style.iconContainer]} testID="icon_container">
367
+ <View
368
+ pointerEvents="none"
369
+ style={[defaultStyles.iconContainer, style.iconContainer]}
370
+ testID="icon_container"
371
+ >
368
372
  <Icon testID="icon" />
369
373
  </View>
370
374
  );
package/src/TapToEdit.tsx CHANGED
@@ -140,7 +140,10 @@ export const TapToEdit = ({
140
140
  const url = new URL(value);
141
141
  displayValue = url?.hostname ?? value;
142
142
  } catch (e) {
143
- console.debug(`Invalid URL: ${value}`);
143
+ // Don't print an error message for empty values.
144
+ if (value) {
145
+ console.debug(`Invalid URL: ${value}`);
146
+ }
144
147
  displayValue = value;
145
148
  }
146
149
  } else if (fieldProps?.type === "address") {
package/src/TextField.tsx CHANGED
@@ -1,71 +1,18 @@
1
1
  import {AsYouType} from "libphonenumber-js";
2
2
  import moment from "moment-timezone";
3
3
  import React, {ReactElement, useCallback, useMemo, useState} from "react";
4
- import {ActivityIndicator, KeyboardTypeOptions, Platform, TextInput, View} from "react-native";
5
- import {Calendar} from "react-native-calendars";
4
+ import {ActivityIndicator, KeyboardTypeOptions, Platform, Pressable, TextInput} from "react-native";
6
5
 
7
6
  import {Box} from "./Box";
8
7
  import {TextFieldProps} from "./Common";
9
8
  import {DateTimeActionSheet} from "./DateTimeActionSheet";
10
9
  import {DecimalRangeActionSheet} from "./DecimalRangeActionSheet";
11
- import {Heading} from "./Heading";
12
10
  import {HeightActionSheet} from "./HeightActionSheet";
13
11
  import {Icon} from "./Icon";
14
- import {IconButton} from "./IconButton";
15
12
  import {NumberPickerActionSheet} from "./NumberPickerActionSheet";
16
13
  import {Unifier} from "./Unifier";
17
14
  import {WithLabel} from "./WithLabel";
18
15
 
19
- function CalendarHeader(props: any) {
20
- const {addMonth, month} = props;
21
- const displayDate = moment(month[0]).format("MMM YYYY");
22
- return (
23
- <Box alignItems="center" direction="row" height={40} justifyContent="between" width="100%">
24
- <IconButton
25
- accessibilityLabel="arrow"
26
- bgColor="white"
27
- icon="angle-double-left"
28
- iconColor="blue"
29
- size="md"
30
- onClick={() => {
31
- addMonth(-12);
32
- }}
33
- />
34
- <IconButton
35
- accessibilityLabel="arrow"
36
- bgColor="white"
37
- icon="angle-left"
38
- iconColor="blue"
39
- size="md"
40
- onClick={() => {
41
- addMonth(-1);
42
- }}
43
- />
44
- <Heading size="sm">{displayDate}</Heading>
45
- <IconButton
46
- accessibilityLabel="arrow"
47
- bgColor="white"
48
- icon="angle-right"
49
- iconColor="blue"
50
- size="md"
51
- onClick={() => {
52
- addMonth(1);
53
- }}
54
- />
55
- <IconButton
56
- accessibilityLabel="arrow"
57
- bgColor="white"
58
- icon="angle-double-right"
59
- iconColor="blue"
60
- size="md"
61
- onClick={() => {
62
- addMonth(12);
63
- }}
64
- />
65
- </Box>
66
- );
67
- }
68
-
69
16
  const keyboardMap = {
70
17
  date: "default",
71
18
  email: "email-address",
@@ -144,7 +91,7 @@ export function TextField({
144
91
  if (type !== "search") {
145
92
  return null;
146
93
  }
147
- if (searching === true) {
94
+ if (searching) {
148
95
  return (
149
96
  <Box marginRight={4}>
150
97
  <ActivityIndicator color={Unifier.theme.primary} size="small" />
@@ -181,9 +128,9 @@ export function TextField({
181
128
  const defaultTextInputStyles = useMemo(() => {
182
129
  const defaultStyles = {
183
130
  flex: 1,
184
- paddingTop: 10,
185
- paddingRight: 10,
186
- paddingBottom: 10,
131
+ paddingTop: 4,
132
+ paddingRight: 4,
133
+ paddingBottom: 4,
187
134
  paddingLeft: 0,
188
135
  height: getHeight(),
189
136
  width: "100%",
@@ -199,8 +146,14 @@ export function TextField({
199
146
  return defaultStyles;
200
147
  }, [getHeight, style]);
201
148
 
202
- const isHandledByModal =
203
- type === "date" || type === "numberRange" || type === "decimalRange" || type === "height";
149
+ const isHandledByModal = [
150
+ "date",
151
+ "datetime",
152
+ "time",
153
+ "numberRange",
154
+ "decimalRange",
155
+ "height",
156
+ ].includes(type);
204
157
 
205
158
  const isEditable = !disabled && !isHandledByModal;
206
159
 
@@ -213,11 +166,29 @@ export function TextField({
213
166
  label,
214
167
  labelColor,
215
168
  };
216
- if (value) {
169
+
170
+ const onTap = useCallback((): void => {
171
+ if (["date", "datetime", "time"].includes(type)) {
172
+ setShowDate(true);
173
+ } else if (type === "numberRange") {
174
+ numberRangeActionSheetRef?.current?.show();
175
+ } else if (type === "decimalRange") {
176
+ decimalRangeActionSheetRef?.current?.show();
177
+ } else if (type === "height") {
178
+ weightActionSheetRef?.current?.show();
179
+ }
180
+ }, [decimalRangeActionSheetRef, numberRangeActionSheetRef, type, weightActionSheetRef]);
181
+
182
+ let displayValue = value;
183
+ if (displayValue) {
217
184
  if (type === "date") {
218
- value = moment.utc(value).format("MM/DD/YYYY");
185
+ displayValue = moment(value).format("MM/DD/YYYY");
186
+ } else if (type === "time") {
187
+ displayValue = moment(value).format("h:mm A");
188
+ } else if (type === "datetime") {
189
+ displayValue = moment(value).format("MM/DD/YYYY h:mm A");
219
190
  } else if (type === "height") {
220
- value = `${Math.floor(Number(value) / 12)} ft, ${Number(value) % 12} in`;
191
+ displayValue = `${Math.floor(Number(value) / 12)} ft, ${Number(value) % 12} in`;
221
192
  } else if (type === "phoneNumber") {
222
193
  // By default, if a value is something like `"(123)"`
223
194
  // then Backspace would only erase the rightmost brace
@@ -226,9 +197,9 @@ export function TextField({
226
197
  // which would then be formatted back to `"(123)"`
227
198
  // and so a user wouldn't be able to erase the phone number.
228
199
  // This is the workaround for that.
229
- const formattedPhoneNumber = new AsYouType("US").input(value);
230
- if (value !== formattedPhoneNumber && value.length !== 4) {
231
- value = formattedPhoneNumber;
200
+ const formattedPhoneNumber = new AsYouType("US").input(displayValue);
201
+ if (displayValue !== formattedPhoneNumber && displayValue.length !== 4) {
202
+ displayValue = formattedPhoneNumber;
232
203
  }
233
204
  }
234
205
  }
@@ -242,34 +213,30 @@ export function TextField({
242
213
  labelSize="sm"
243
214
  >
244
215
  <WithLabel {...withLabelProps}>
245
- <View
216
+ <Pressable
246
217
  style={{
247
218
  flexDirection: "row",
248
219
  justifyContent: "center",
249
220
  alignItems: "center",
250
- // height: 40,
251
- // minHeight: getHeight(),
221
+ // height: multiline || grow ? undefined : 40,
252
222
  minHeight: getHeight(),
253
223
  width: "100%",
254
224
  // Add padding so the border doesn't mess up layouts
255
225
  paddingHorizontal: paddingX || focused ? 10 : 14,
256
226
  paddingVertical: paddingY || focused ? 0 : 4,
257
227
  borderColor,
258
- borderWidth: focused && !errorMessage ? 5 : 1,
228
+ borderWidth: focused ? 5 : 1,
259
229
  borderRadius: 16,
260
230
  backgroundColor: disabled ? Unifier.theme.gray : Unifier.theme.white,
261
231
  overflow: "hidden",
262
232
  }}
263
- onTouchEnd={() => {
264
- if (type === "date") {
265
- dateActionSheetRef?.current?.setModalVisible(true);
266
- } else if (type === "numberRange") {
267
- numberRangeActionSheetRef?.current?.setModalVisible(true);
268
- } else if (type === "decimalRange") {
269
- decimalRangeActionSheetRef?.current?.setModalVisible(true);
270
- } else if (type === "height") {
271
- weightActionSheetRef?.current?.setModalVisible(true);
272
- }
233
+ onPress={() => {
234
+ // This runs on web
235
+ onTap();
236
+ }}
237
+ onTouchStart={() => {
238
+ // This runs on mobile
239
+ onTap();
273
240
  }}
274
241
  >
275
242
  {renderIcon()}
@@ -292,10 +259,9 @@ export function TextField({
292
259
  returnKeyType={type === "number" || type === "decimal" ? "done" : returnKeyType}
293
260
  secureTextEntry={type === "password"}
294
261
  style={defaultTextInputStyles}
295
- // For react-native-autofocus
296
262
  textContentType={textContentType}
297
263
  underlineColorAndroid="transparent"
298
- value={value}
264
+ value={displayValue}
299
265
  onBlur={() => {
300
266
  if (!isHandledByModal) {
301
267
  setFocused(false);
@@ -304,7 +270,7 @@ export function TextField({
304
270
  onBlur({value});
305
271
  }
306
272
  // if (type === "date") {
307
- // actionSheetRef?.current?.setModalVisible(false);
273
+ // actionSheetRef?.current?.hide();
308
274
  // }
309
275
  }}
310
276
  onChangeText={(text) => {
@@ -332,9 +298,6 @@ export function TextField({
332
298
  if (!isHandledByModal) {
333
299
  setFocused(true);
334
300
  }
335
- if (Platform.OS === "web" && type === "date") {
336
- setShowDate(true);
337
- }
338
301
  }}
339
302
  onSubmitEditing={() => {
340
303
  if (onEnter) {
@@ -345,30 +308,36 @@ export function TextField({
345
308
  }
346
309
  }}
347
310
  />
348
- </View>
311
+ </Pressable>
349
312
  </WithLabel>
350
313
  </WithLabel>
351
- {type === "date" && Platform.OS !== "web" && (
314
+ {(type === "date" || type === "time" || type === "datetime") && (
352
315
  <DateTimeActionSheet
353
316
  actionSheetRef={dateActionSheetRef}
354
- mode="date"
317
+ mode={type}
355
318
  value={value}
356
- onChange={(result) => onChange(result)}
319
+ visible={showDate}
320
+ onChange={(result) => {
321
+ onChange(result);
322
+ setShowDate(false);
323
+ setFocused(false);
324
+ }}
325
+ onDismiss={() => setShowDate(false)}
357
326
  />
358
327
  )}
359
- {type === "date" && Platform.OS === "web" && showDate && (
360
- <Box maxWidth={300}>
361
- {/* TODO: Calendar should disappear when you click away from it. */}
362
- <Calendar
363
- customHeader={CalendarHeader}
364
- initialDate={value}
365
- onDayPress={(day: any) => {
366
- onChange({value: day.dateString});
367
- setShowDate(false);
368
- }}
369
- />
370
- </Box>
371
- )}
328
+ {/* {type === "date" && showDate && ( */}
329
+ {/* <Box maxWidth={300}> */}
330
+ {/* /!* TODO: Calendar should disappear when you click away from it. *!/ */}
331
+ {/* <Calendar */}
332
+ {/* customHeader={CalendarHeader} */}
333
+ {/* initialDate={value} */}
334
+ {/* onDayPress={(day: any) => { */}
335
+ {/* onChange({value: day.dateString}); */}
336
+ {/* setShowDate(false); */}
337
+ {/* }} */}
338
+ {/* /> */}
339
+ {/* </Box> */}
340
+ {/* )} */}
372
341
  {type === "numberRange" && value && (
373
342
  <NumberPickerActionSheet
374
343
  actionSheetRef={numberRangeActionSheetRef}