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.
@@ -1,55 +1,379 @@
1
- import DateTimePicker from "@react-native-community/datetimepicker";
2
- import moment from "moment-timezone";
3
- import React from "react";
1
+ import {Picker} from "@react-native-picker/picker";
2
+ import range from "lodash/range";
3
+ import moment from "moment";
4
+ import React, {useState} from "react";
5
+ import {Platform, StyleProp, TextInput, TextStyle, View} from "react-native";
6
+ import {Calendar} from "react-native-calendars";
4
7
 
5
- import {ActionSheet} from "./ActionSheet";
6
8
  import {Box} from "./Box";
7
- import {Button} from "./Button";
8
9
  import {OnChangeCallback} from "./Common";
10
+ import {Heading} from "./Heading";
11
+ import {IconButton} from "./IconButton";
12
+ import {isMobileDevice} from "./MediaQuery";
13
+ import {Modal} from "./Modal";
14
+ import {SelectList} from "./SelectList";
15
+ import {Unifier} from "./Unifier";
16
+
17
+ const TIME_PICKER_HEIGHT = 104;
18
+ const INPUT_HEIGHT = 40;
19
+
20
+ const hours = range(1, 13).map((n) => String(n));
21
+ // TODO: support limited picker minutes, e.g. 5 or 15 minute increments.
22
+ const minutes = range(0, 60).map((n) => String(n).padStart(2, "0"));
23
+ const minutesOptions = [...minutes, "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
24
+
25
+ function TimeInput({
26
+ type,
27
+ value,
28
+ onChange,
29
+ }: {
30
+ type: "hour" | "minute";
31
+ value: number;
32
+ onChange: (value: number) => void;
33
+ }): React.ReactElement {
34
+ const defaultText = type === "minute" ? String(value).padStart(2, "0") : String(value);
35
+ const [text, setText] = useState(defaultText);
36
+ const [focused, setFocused] = useState(false);
37
+ let error = false;
38
+ if (type === "hour") {
39
+ error = !hours.includes(String(Number(text)));
40
+ } else if (type === "minute") {
41
+ error = !minutesOptions.includes(String(Number(text)));
42
+ }
43
+
44
+ // Broken out because types don't think "outline" is a valid style.
45
+ const textInputStyle: StyleProp<TextStyle> = {
46
+ flex: 1,
47
+ paddingTop: 4,
48
+ paddingRight: 4,
49
+ paddingBottom: 4,
50
+ paddingLeft: 0,
51
+ height: INPUT_HEIGHT,
52
+ width: "100%",
53
+ color: Unifier.theme.darkGray,
54
+ fontFamily: Unifier.theme.primaryFont,
55
+ };
56
+
57
+ return (
58
+ <View
59
+ style={{
60
+ flexDirection: "row",
61
+ justifyContent: "center",
62
+ alignItems: "center",
63
+ height: INPUT_HEIGHT,
64
+ width: "100%",
65
+ // Add padding so the border doesn't mess up layouts
66
+ paddingHorizontal: focused ? 10 : 14,
67
+ paddingVertical: focused ? 0 : 4,
68
+ borderColor: error ? Unifier.theme.red : Unifier.theme.blue,
69
+ borderWidth: focused ? 5 : 1,
70
+ borderRadius: 5,
71
+ backgroundColor: Unifier.theme.white,
72
+ }}
73
+ >
74
+ <TextInput
75
+ keyboardType="number-pad"
76
+ returnKeyType="done"
77
+ style={
78
+ {
79
+ ...textInputStyle,
80
+ outline: Platform.select({web: "none"}),
81
+ } as any
82
+ }
83
+ textContentType="none"
84
+ underlineColorAndroid="transparent"
85
+ value={text}
86
+ onBlur={() => {
87
+ setFocused(false);
88
+ }}
89
+ onChangeText={(t) => {
90
+ setText(t);
91
+ onChange(Number(t));
92
+ }}
93
+ onFocus={() => {
94
+ setFocused(true);
95
+ }}
96
+ />
97
+ </View>
98
+ );
99
+ }
100
+
101
+ function CalendarHeader({
102
+ addMonth,
103
+ month,
104
+ }: {
105
+ addMonth: (num: number) => void;
106
+ month: Date[];
107
+ }): React.ReactElement {
108
+ const displayDate = moment(month[0]).format("MMM YYYY");
109
+ return (
110
+ <Box alignItems="center" direction="row" height={40} justifyContent="between" width="100%">
111
+ <IconButton
112
+ accessibilityLabel="arrow"
113
+ bgColor="white"
114
+ icon="angle-double-left"
115
+ iconColor="primary"
116
+ size="md"
117
+ onClick={() => {
118
+ addMonth(-12);
119
+ }}
120
+ />
121
+ <IconButton
122
+ accessibilityLabel="arrow"
123
+ bgColor="white"
124
+ icon="angle-left"
125
+ iconColor="primary"
126
+ size="md"
127
+ onClick={() => {
128
+ addMonth(-1);
129
+ }}
130
+ />
131
+ <Heading size="sm">{displayDate}</Heading>
132
+ <IconButton
133
+ accessibilityLabel="arrow"
134
+ bgColor="white"
135
+ icon="angle-right"
136
+ iconColor="primary"
137
+ size="md"
138
+ onClick={() => {
139
+ addMonth(1);
140
+ }}
141
+ />
142
+ <IconButton
143
+ accessibilityLabel="arrow"
144
+ bgColor="white"
145
+ icon="angle-double-right"
146
+ iconColor="primary"
147
+ size="md"
148
+ onClick={() => {
149
+ addMonth(12);
150
+ }}
151
+ />
152
+ </Box>
153
+ );
154
+ }
9
155
 
10
156
  interface DateTimeActionSheetProps {
11
157
  value?: string;
12
- mode?: "date" | "time";
158
+ mode?: "date" | "time" | "datetime";
159
+ // Returns an ISO 8601 string. If mode is "time", the date portion is today.
13
160
  onChange: OnChangeCallback;
14
161
  actionSheetRef: React.RefObject<any>;
162
+ visible: boolean;
163
+ onDismiss: () => void;
15
164
  }
16
165
 
166
+ // For mobile, renders all components in an action sheet.
167
+ // For web, renders all components in a modal.
168
+ // For mobile:
169
+ // If mode is "time", renders a spinner picker for time picker on both platforms.
170
+ // If mode is "date", renders our custom calendar on both platforms.
171
+ // If mode is "datetime", renders a spinner picker for time picker and our custom calendar on both platforms.
172
+ // For web, renders a simplistic text box for time picker and a calendar for date picker in a modal
173
+ // In the future, web time picker should be a typeahead dropdown like Google calendar.
17
174
  export function DateTimeActionSheet({
18
- actionSheetRef,
175
+ // actionSheetRef,
19
176
  mode,
20
177
  value,
21
178
  onChange,
179
+ visible,
180
+ onDismiss,
22
181
  }: DateTimeActionSheetProps) {
23
- return (
24
- <ActionSheet ref={actionSheetRef} bounceOnOpen gestureEnabled>
25
- <Box marginBottom={8} paddingX={4} width="100%">
26
- <Box alignItems="end" display="flex" width="100%">
27
- <Box width="33%">
28
- <Button
29
- color="blue"
30
- size="lg"
31
- text="Save"
32
- type="ghost"
33
- onClick={() => {
34
- actionSheetRef?.current?.setModalVisible(false);
35
- }}
36
- />
37
- </Box>
182
+ // Accept ISO 8601, HH:mm, or hh:mm A formats. We may want only HH:mm or hh:mm A for mode=time
183
+ const m = moment(value, [moment.ISO_8601, "HH:mm", "hh:mm A"]);
184
+
185
+ if (!m.isValid()) {
186
+ throw new Error(`Invalid date/time value ${value}`);
187
+ }
188
+
189
+ let hr = moment(m).hour() % 12;
190
+ if (hr === 0) {
191
+ hr = 12;
192
+ }
193
+
194
+ const [hour, setHour] = useState<number>(hr);
195
+ const [minute, setMinute] = useState<number>(moment(m).minute());
196
+ const [amPm, setAmPm] = useState<"am" | "pm">(moment(m).format("a") === "am" ? "am" : "pm");
197
+ const [date, setDate] = useState<string>(moment(m).toISOString());
198
+
199
+ // TODO Support 24 hour time for time picker.
200
+ const renderMobileTime = () => {
201
+ return (
202
+ <Box direction="row" width="100%">
203
+ <Box paddingY={2} width="35%">
204
+ <Picker
205
+ itemStyle={{
206
+ height: TIME_PICKER_HEIGHT,
207
+ }}
208
+ selectedValue={hour}
209
+ style={{
210
+ height: TIME_PICKER_HEIGHT,
211
+ backgroundColor: "#FFFFFF",
212
+ }}
213
+ onValueChange={(itemValue) => setHour(itemValue)}
214
+ >
215
+ {hours.map((n) => (
216
+ <Picker.Item key={String(n)} label={String(n)} value={String(n)} />
217
+ ))}
218
+ </Picker>
219
+ </Box>
220
+ <Box paddingY={2} width="35%">
221
+ <Picker
222
+ itemStyle={{
223
+ height: TIME_PICKER_HEIGHT,
224
+ }}
225
+ selectedValue={minute}
226
+ style={{
227
+ height: TIME_PICKER_HEIGHT,
228
+ backgroundColor: "#FFFFFF",
229
+ }}
230
+ onValueChange={(itemValue) => setMinute(itemValue)}
231
+ >
232
+ {minutes.map((n) => (
233
+ <Picker.Item key={String(n)} label={String(n)} value={String(n)} />
234
+ ))}
235
+ </Picker>
236
+ </Box>
237
+ <Box paddingY={2} width="30%">
238
+ <Picker
239
+ itemStyle={{
240
+ height: TIME_PICKER_HEIGHT,
241
+ }}
242
+ selectedValue={amPm}
243
+ style={{
244
+ height: TIME_PICKER_HEIGHT,
245
+ backgroundColor: "#FFFFFF",
246
+ }}
247
+ onValueChange={(itemValue) => setAmPm(itemValue)}
248
+ >
249
+ <Picker.Item key="am" label="am" value="am" />
250
+ <Picker.Item key="pm" label="pm" value="pm" />
251
+ </Picker>
38
252
  </Box>
39
- <DateTimePicker
40
- display="spinner"
41
- is24Hour
42
- mode={mode}
43
- testID="dateTimePicker"
44
- value={moment(value).toDate()}
45
- onChange={(event: any, date: any) => {
46
- if (!date) {
47
- return;
48
- }
49
- onChange({event, value: date.toString()});
50
- }}
51
- />
52
253
  </Box>
53
- </ActionSheet>
254
+ );
255
+ };
256
+
257
+ // TODO: Support a typeahead dropdown for time picker, similar to Google Calendar on the web.
258
+ const renderWebTime = () => {
259
+ return (
260
+ <Box direction="row" justifyContent="center" width="100%">
261
+ <Box width={60}>
262
+ <TimeInput type="hour" value={hour} onChange={(v) => setHour(v)} />
263
+ </Box>
264
+ <Box
265
+ alignItems="center"
266
+ height={INPUT_HEIGHT}
267
+ justifyContent="center"
268
+ marginLeft={2}
269
+ marginRight={2}
270
+ >
271
+ <Heading size="md">:</Heading>
272
+ </Box>
273
+ <Box marginRight={2} width={60}>
274
+ <TimeInput type="minute" value={minute} onChange={(v) => setMinute(v)} />
275
+ </Box>
276
+
277
+ <Box width={60}>
278
+ <SelectList
279
+ options={[
280
+ {label: "am", value: "am"},
281
+ {label: "pm", value: "pm"},
282
+ ]}
283
+ style={{minHeight: INPUT_HEIGHT}}
284
+ value={amPm}
285
+ onChange={(result) => {
286
+ setAmPm(result as "am" | "pm");
287
+ }}
288
+ />
289
+ </Box>
290
+ </Box>
291
+ );
292
+ };
293
+
294
+ const renderDateTime = (): React.ReactElement => {
295
+ return (
296
+ <Box>
297
+ <Box marginBottom={2}>{renderDateCalendar()}</Box>
298
+ {isMobileDevice() ? renderMobileTime() : renderWebTime()}
299
+ </Box>
300
+ );
301
+ };
302
+
303
+ // Note: do not call this if waiting on a state change.
304
+ const sendOnChange = () => {
305
+ const hourChange = amPm === "pm" && hour !== 12 ? Number(hour) + 12 : Number(hour);
306
+ if (mode === "date") {
307
+ onChange({value: date});
308
+ } else if (mode === "time") {
309
+ onChange({
310
+ value: moment().hour(hourChange).minute(Number(minute)).toISOString(),
311
+ });
312
+ } else if (mode === "datetime") {
313
+ onChange({
314
+ value: moment(date).hour(hourChange).minute(Number(minute)).toISOString(),
315
+ });
316
+ }
317
+ onDismiss();
318
+ };
319
+
320
+ // Renders our custom calendar component on mobile or web.
321
+ const renderDateCalendar = () => {
322
+ const markedDates = {};
323
+ if (date) {
324
+ markedDates[moment(date).format("YYYY-MM-DD")] = {
325
+ selected: true,
326
+ selectedColor: Unifier.theme.primary,
327
+ };
328
+ }
329
+ return (
330
+ <Calendar
331
+ customHeader={CalendarHeader}
332
+ initialDate={moment(date).format("YYYY-MM-DD")}
333
+ markedDates={markedDates}
334
+ onDayPress={(day) => {
335
+ setDate(day.dateString);
336
+ // If mode is just date, we can shortcut and close right away. time and datetime need to wait for the
337
+ // primary button.
338
+ if (mode === "date") {
339
+ onChange({value: day.dateString});
340
+ onDismiss();
341
+ }
342
+ }}
343
+ />
344
+ );
345
+ };
346
+
347
+ const renderContent = (): React.ReactElement => {
348
+ if (isMobileDevice()) {
349
+ if (mode === "date") {
350
+ return renderDateCalendar();
351
+ } else if (mode === "time") {
352
+ return renderMobileTime();
353
+ } else {
354
+ return renderDateTime();
355
+ }
356
+ } else {
357
+ if (mode === "date") {
358
+ return renderDateCalendar();
359
+ } else if (mode === "time") {
360
+ return renderWebTime();
361
+ } else {
362
+ return renderDateTime();
363
+ }
364
+ }
365
+ };
366
+
367
+ return (
368
+ <Modal
369
+ primaryButtonOnClick={sendOnChange}
370
+ primaryButtonText="Save"
371
+ secondaryButtonOnClick={onDismiss}
372
+ secondaryButtonText="Cancel"
373
+ visible={visible}
374
+ onDismiss={onDismiss}
375
+ >
376
+ {renderContent()}
377
+ </Modal>
54
378
  );
55
379
  }
@@ -1,3 +1,4 @@
1
+ import isArray from "lodash/isArray";
1
2
  import React, {ReactElement} from "react";
2
3
  import DatePicker from "react-date-picker";
3
4
  import DateTimePickerWeb from "react-datetime-picker";
@@ -23,14 +24,39 @@ export const DateTimeField = ({
23
24
  >
24
25
  <Box flex="grow" maxWidth={300} zIndex="auto">
25
26
  {mode === "datetime" && (
26
- <DateTimePickerWeb disableClock value={value} onChange={onChange} />
27
+ <DateTimePickerWeb
28
+ disableClock
29
+ value={value}
30
+ onChange={(newVal) => {
31
+ if (isArray(newVal) || !newVal) {
32
+ console.warn("DateTimePicker returned an array", newVal);
33
+ return;
34
+ }
35
+ onChange(newVal);
36
+ }}
37
+ />
38
+ )}
39
+ {mode === "date" && (
40
+ <DatePicker
41
+ value={value}
42
+ onChange={(newVal) => {
43
+ if (isArray(newVal) || !newVal) {
44
+ console.warn("DatePicker returned an array", newVal);
45
+ return;
46
+ }
47
+ onChange(newVal);
48
+ }}
49
+ />
27
50
  )}
28
- {mode === "date" && <DatePicker value={value} onChange={onChange} />}
29
51
  {mode === "time" && (
30
52
  <TimePicker
31
53
  disableClock
32
54
  value={value}
33
55
  onChange={(newVal) => {
56
+ if (isArray(newVal) || !newVal) {
57
+ console.warn("TimePicker returned an array", newVal);
58
+ return;
59
+ }
34
60
  // TimePicker returns a string or Date, so we need to make sure it's a Date
35
61
  const newDate = new Date(newVal);
36
62
  onChange(newDate);
package/src/Field.tsx CHANGED
@@ -22,6 +22,7 @@ export interface FieldProps extends FieldWithLabelsProps {
22
22
  | "currency"
23
23
  | "customSelect"
24
24
  | "date"
25
+ | "datetime"
25
26
  | "email"
26
27
  | "multiselect"
27
28
  | "number"
@@ -31,6 +32,7 @@ export interface FieldProps extends FieldWithLabelsProps {
31
32
  | "select"
32
33
  | "text"
33
34
  | "textarea"
35
+ | "time"
34
36
  | "url";
35
37
  rows?: number;
36
38
  value?: any;
@@ -149,14 +151,12 @@ export const Field = ({
149
151
  onChange={(result) => handleSwitchChange(result)}
150
152
  />
151
153
  );
152
- } else if (type === "date") {
154
+ } else if (type && ["date", "time", "datetime"].includes(type)) {
153
155
  return (
154
156
  <TextField
155
- disabled
156
157
  id={name}
157
158
  placeholder={placeholder}
158
- type="date"
159
- // TODO: allow editing with a date picker
159
+ type={type as "date" | "time" | "datetime"}
160
160
  value={value}
161
161
  onChange={(result) => onChange(result.value)}
162
162
  />
@@ -230,7 +230,10 @@ export const Field = ({
230
230
  let tfValue: string = value;
231
231
  // Number is supported differently because we need fractional numbers and they don't work
232
232
  // well on iOS.
233
- if (type && ["date", "email", "phoneNumber", "password", "url"].indexOf(type) > -1) {
233
+ if (
234
+ type &&
235
+ ["date", "time", "datetime", "email", "phoneNumber", "password", "url"].includes(type)
236
+ ) {
234
237
  tfType = type as TextFieldType;
235
238
  } else if (type === "percent" || type === "currency") {
236
239
  tfType = "text";
@@ -252,7 +255,18 @@ export const Field = ({
252
255
  disabled={disabled}
253
256
  id={name}
254
257
  placeholder={placeholder}
255
- type={tfType as "date" | "email" | "number" | "password" | "phoneNumber" | "text" | "url"}
258
+ type={
259
+ tfType as
260
+ | "date"
261
+ | "datetime"
262
+ | "email"
263
+ | "number"
264
+ | "password"
265
+ | "phoneNumber"
266
+ | "text"
267
+ | "time"
268
+ | "url"
269
+ }
256
270
  value={tfValue}
257
271
  onChange={(result) => onChange(result.value)}
258
272
  />
package/src/MediaQuery.ts CHANGED
@@ -40,3 +40,7 @@ export function mediaQuerySmallerThan(size: "xs" | "sm" | "md" | "lg"): boolean
40
40
  }
41
41
  return false;
42
42
  }
43
+
44
+ export function isMobileDevice(): boolean {
45
+ return !mediaQueryLargerThan("sm");
46
+ }