ferns-ui 0.22.4 → 0.23.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,8 @@
1
1
  import React from "react";
2
+ import {Platform} from "react-native";
2
3
 
3
4
  import {FieldWithLabelsProps, StyleProp} from "./Common";
5
+ import {Icon} from "./Icon";
4
6
  import RNPickerSelect from "./PickerSelect";
5
7
  import {Unifier} from "./Unifier";
6
8
  import {WithLabel} from "./WithLabel";
@@ -34,6 +36,12 @@ export function SelectList({
34
36
  return (
35
37
  <WithLabel {...withLabelProps}>
36
38
  <RNPickerSelect
39
+ // Icon only needed for iOs, web and android use default icons
40
+ Icon={() => {
41
+ return Platform.OS === "ios" ? (
42
+ <Icon color="darkGray" name="angle-down" size="md" />
43
+ ) : null;
44
+ }}
37
45
  items={options}
38
46
  placeholder={placeholder ? {label: placeholder, value: ""} : {}}
39
47
  style={{
@@ -43,14 +51,28 @@ export function SelectList({
43
51
  alignItems: style?.alignItems || "center",
44
52
  minHeight: style?.minHeight || 50,
45
53
  width: style?.width || "100%",
46
- // Add padding so the border doesn't mess up layouts
47
- paddingHorizontal: style?.paddingHorizontal || 6,
48
- paddingVertical: style?.paddingVertical || 4,
49
54
  borderColor: style?.borderColor || Unifier.theme.gray,
50
55
  borderWidth: style?.borderWidth || 1,
51
56
  borderRadius: style?.borderRadius || 5,
52
57
  backgroundColor: style?.backgroundColor || Unifier.theme.white,
53
58
  },
59
+ inputIOS: {
60
+ paddingVertical: 12,
61
+ paddingHorizontal: 10,
62
+ paddingRight: 30,
63
+ },
64
+ iconContainer: {
65
+ top: 13,
66
+ right: 10,
67
+ bottom: 12,
68
+ paddingLeft: 40,
69
+ },
70
+ inputWeb: {
71
+ // Add padding so the border doesn't mess up layouts
72
+ paddingHorizontal: style?.paddingHorizontal || 6,
73
+ paddingVertical: style?.paddingVertical || 4,
74
+ borderRadius: style?.borderRadius || 5,
75
+ },
54
76
  }}
55
77
  value={value}
56
78
  onValueChange={onChange}
package/src/TapToEdit.tsx CHANGED
@@ -110,6 +110,27 @@ export const TapToEdit = ({
110
110
  } else if (fieldProps?.type === "multiselect") {
111
111
  // ???
112
112
  displayValue = value.join(", ");
113
+ } else if (fieldProps?.type === "address") {
114
+ let city = "";
115
+ if (value?.city) {
116
+ city = value?.state || value.zipcode ? `${value.city}, ` : `${value.city}`;
117
+ }
118
+
119
+ let state = "";
120
+ if (value?.state) {
121
+ state = value?.zipcode ? `${value.state} ` : `${value.state}`;
122
+ }
123
+
124
+ const zip = value?.zipcode;
125
+
126
+ const addressLineOne = value?.address1 ?? "";
127
+ const addressLineTwo = value?.address2 ?? "";
128
+ const addressLineThree = `${city}${state}${zip}`;
129
+
130
+ // Only add new lines if lines before and after are not empty to avoid awkward whitespace
131
+ displayValue = `${addressLineOne}${
132
+ addressLineOne && (addressLineTwo || addressLineThree) ? `\n` : ""
133
+ }${addressLineTwo}${addressLineTwo && addressLineThree ? `\n` : ""}${addressLineThree}`;
113
134
  }
114
135
  }
115
136
  return (
package/src/Toast.tsx ADDED
@@ -0,0 +1,87 @@
1
+ import React from "react";
2
+ import {Dimensions} from "react-native";
3
+ import {useToast as useRNToast} from "react-native-toast-notifications";
4
+
5
+ import {Box} from "./Box";
6
+ import {Button} from "./Button";
7
+ import {AllColors} from "./Common";
8
+ import {Icon} from "./Icon";
9
+ import {Text} from "./Text";
10
+
11
+ export function useToast(): any {
12
+ const toast = useRNToast();
13
+ return {
14
+ show: (
15
+ text: string,
16
+ options?: {
17
+ variant?: "default" | "warning" | "error";
18
+ buttonText?: string;
19
+ buttonOnClick: () => void | Promise<void>;
20
+ }
21
+ ): string => {
22
+ return toast.show(text, {
23
+ data: options,
24
+ });
25
+ },
26
+ hide: (id: string) => toast.hide(id),
27
+ };
28
+ }
29
+
30
+ export function Toast({
31
+ message,
32
+ data,
33
+ }: {
34
+ message: string;
35
+ data: {
36
+ variant?: "default" | "warning" | "error";
37
+ buttonText?: string;
38
+ buttonOnClick?: () => void | Promise<void>;
39
+ };
40
+ }): React.ReactElement {
41
+ // margin 8 on either side, times the standard 4px we multiply by.
42
+ const width = Math.min(Dimensions.get("window").width - 16 * 4, 712);
43
+ const {variant, buttonText, buttonOnClick} = data ?? {};
44
+ let color: AllColors = "darkGray";
45
+ if (variant === "warning") {
46
+ color = "orange";
47
+ } else if (variant === "error") {
48
+ color = "red";
49
+ }
50
+ return (
51
+ <Box
52
+ alignItems="center"
53
+ color={color}
54
+ direction="row"
55
+ flex="shrink"
56
+ marginBottom={4}
57
+ marginLeft={8}
58
+ marginRight={8}
59
+ maxWidth={width}
60
+ padding={2}
61
+ paddingX={4}
62
+ paddingY={3}
63
+ rounding={4}
64
+ >
65
+ {Boolean(variant === "error") && (
66
+ <Box marginRight={4}>
67
+ <Icon color="white" name="exclamation-circle" size="lg" />
68
+ </Box>
69
+ )}
70
+ {Boolean(variant === "warning") && (
71
+ <Box marginRight={4}>
72
+ <Icon color="white" name="exclamation-triangle" size="lg" />
73
+ </Box>
74
+ )}
75
+ <Box alignItems="center" direction="column" flex="shrink" justifyContent="center">
76
+ <Text color="white" size="lg" weight="bold">
77
+ {message}
78
+ </Text>
79
+ </Box>
80
+ {Boolean(buttonOnClick && buttonText) && (
81
+ <Box alignItems="center" justifyContent="center" marginLeft={4}>
82
+ <Button color="lightGray" shape="pill" text={buttonText!} onClick={buttonOnClick} />
83
+ </Box>
84
+ )}
85
+ </Box>
86
+ );
87
+ }
@@ -0,0 +1,259 @@
1
+ import * as React from "react";
2
+ import {forwardRef} from "react";
3
+ import {
4
+ Dimensions,
5
+ LayoutChangeEvent,
6
+ LayoutRectangle,
7
+ Platform,
8
+ Pressable,
9
+ View,
10
+ } from "react-native";
11
+ import {Portal} from "react-native-portalize";
12
+
13
+ import {TooltipDirection} from "./Common";
14
+ import {Text} from "./Text";
15
+ import {Unifier} from "./Unifier";
16
+
17
+ const TOOLTIP_OFFSET = 8;
18
+ // How many pixels to leave between the tooltip and the edge of the screen
19
+ const TOOLTIP_OVERFLOW_PADDING = 20;
20
+
21
+ type ChildrenMeasurement = {
22
+ width: number;
23
+ height: number;
24
+ pageX: number;
25
+ pageY: number;
26
+ };
27
+
28
+ type Measurement = {
29
+ children: ChildrenMeasurement;
30
+ tooltip: LayoutRectangle;
31
+ measured: boolean;
32
+ idealDirection?: TooltipDirection;
33
+ };
34
+
35
+ const overflowLeft = (x: number): boolean => {
36
+ return x < TOOLTIP_OVERFLOW_PADDING;
37
+ };
38
+
39
+ const overflowRight = (x: number): boolean => {
40
+ const {width: layoutWidth} = Dimensions.get("window");
41
+ return x + TOOLTIP_OVERFLOW_PADDING > layoutWidth;
42
+ };
43
+
44
+ const getTooltipPosition = ({
45
+ children,
46
+ tooltip,
47
+ measured,
48
+ idealDirection,
49
+ }: Measurement): {} | {left: number; top: number} => {
50
+ if (!measured) {
51
+ console.debug("No measurements for child yet, cannot show tooltip.");
52
+ return {};
53
+ }
54
+
55
+ const {
56
+ pageY: childrenY,
57
+ height: childrenHeight,
58
+ pageX: childrenX,
59
+ width: childrenWidth,
60
+ }: ChildrenMeasurement = children;
61
+ const {width: tooltipWidth, height: tooltipHeight} = tooltip;
62
+ const horizontalCenter = childrenX + childrenWidth / 2;
63
+ const right = childrenX + childrenWidth + TOOLTIP_OFFSET;
64
+ const left = childrenX - tooltipWidth - TOOLTIP_OFFSET;
65
+
66
+ const top = childrenY - tooltipHeight - TOOLTIP_OFFSET;
67
+ const bottom = childrenY + childrenHeight + TOOLTIP_OFFSET;
68
+ const verticalCenter = top + childrenHeight + TOOLTIP_OFFSET;
69
+
70
+ // Top is overflowed if it would go off either side or the top of the screen.
71
+ const overflowTop = top < TOOLTIP_OVERFLOW_PADDING;
72
+
73
+ // Bottom is overflowed if it would go off either side or the bottom of the screen.
74
+ const overflowBottom =
75
+ bottom + tooltipHeight + TOOLTIP_OVERFLOW_PADDING > Dimensions.get("window").height;
76
+
77
+ // If it would overflow to the right, try to put it above, if not, put it on the left.
78
+ // If it would overflow to the left, try to put it above, if not, put it to the right.
79
+
80
+ // Happy path:
81
+ if (idealDirection === "left" && !overflowLeft(left)) {
82
+ return {left, top: verticalCenter};
83
+ } else if (idealDirection === "right" && !overflowRight(right + tooltipWidth)) {
84
+ return {left: right, top: verticalCenter};
85
+ } else if (
86
+ idealDirection === "bottom" &&
87
+ !overflowBottom &&
88
+ !overflowLeft(horizontalCenter - tooltipWidth) &&
89
+ !overflowRight(horizontalCenter + tooltipWidth)
90
+ ) {
91
+ return {left: horizontalCenter - tooltipWidth / 2, top: bottom};
92
+ } else {
93
+ // At this point, we're either trying to place it above or below, and force it into the viewport.
94
+
95
+ let y = top;
96
+ if ((idealDirection === "bottom" && !overflowBottom) || overflowTop) {
97
+ y = bottom;
98
+ }
99
+
100
+ // If it fits in the viewport, center it above the child.
101
+ if (
102
+ !overflowLeft(horizontalCenter - tooltipWidth) &&
103
+ !overflowRight(horizontalCenter + tooltipWidth)
104
+ ) {
105
+ return {left: horizontalCenter - tooltipWidth / 2, top: y};
106
+ }
107
+ // Failing that, if it fits on the left, put it there, otherwise to the right. We know it's smaller than the
108
+ // viewport.
109
+ else if (overflowLeft(horizontalCenter - tooltipWidth)) {
110
+ return {left: TOOLTIP_OVERFLOW_PADDING, top: y};
111
+ } else {
112
+ return {
113
+ left: Dimensions.get("window").width - TOOLTIP_OVERFLOW_PADDING - tooltipWidth,
114
+ top: y,
115
+ };
116
+ }
117
+ }
118
+ };
119
+
120
+ interface TooltipProps {
121
+ children: React.ReactElement;
122
+ text: string;
123
+ idealDirection?: "top" | "bottom" | "left" | "right";
124
+ bgColor?: "white" | "lightGray" | "gray" | "darkGray";
125
+ }
126
+
127
+ // eslint-disable-next-line react/display-name
128
+ export const Tooltip = forwardRef((props: TooltipProps, _ref: any) => {
129
+ const {text, children, bgColor, idealDirection} = props;
130
+ const hoverDelay = 500;
131
+ const hoverEndDelay = 1500;
132
+ const [visible, setVisible] = React.useState(false);
133
+
134
+ const [measurement, setMeasurement] = React.useState({
135
+ children: {},
136
+ tooltip: {},
137
+ measured: false,
138
+ });
139
+ const showTooltipTimer = React.useRef<NodeJS.Timeout>();
140
+ const hideTooltipTimer = React.useRef<NodeJS.Timeout>();
141
+ const childrenWrapperRef = React.useRef() as React.MutableRefObject<View>;
142
+
143
+ const touched = React.useRef(false);
144
+
145
+ const isWeb = Platform.OS === "web";
146
+
147
+ React.useEffect(() => {
148
+ return () => {
149
+ if (showTooltipTimer.current) {
150
+ clearTimeout(showTooltipTimer.current);
151
+ }
152
+
153
+ if (hideTooltipTimer.current) {
154
+ clearTimeout(hideTooltipTimer.current);
155
+ }
156
+ };
157
+ }, []);
158
+
159
+ const handleOnLayout = ({nativeEvent: {layout}}: LayoutChangeEvent) => {
160
+ childrenWrapperRef?.current?.measure((_x, _y, width, height, pageX, pageY) => {
161
+ setMeasurement({
162
+ children: {pageX, pageY, height, width},
163
+ tooltip: {...layout},
164
+ measured: true,
165
+ });
166
+ });
167
+ };
168
+
169
+ const handleTouchStart = () => {
170
+ if (hideTooltipTimer.current) {
171
+ clearTimeout(hideTooltipTimer.current);
172
+ }
173
+
174
+ showTooltipTimer.current = setTimeout(() => {
175
+ touched.current = true;
176
+ setVisible(true);
177
+ }, 100) as unknown as NodeJS.Timeout;
178
+ };
179
+
180
+ const handleHoverIn = () => {
181
+ if (hideTooltipTimer.current) {
182
+ clearTimeout(hideTooltipTimer.current);
183
+ }
184
+
185
+ showTooltipTimer.current = setTimeout(() => {
186
+ touched.current = true;
187
+ setVisible(true);
188
+ }, hoverDelay) as unknown as NodeJS.Timeout;
189
+ };
190
+
191
+ const handleHoverOut = () => {
192
+ touched.current = false;
193
+ if (showTooltipTimer.current) {
194
+ clearTimeout(showTooltipTimer.current);
195
+ }
196
+
197
+ hideTooltipTimer.current = setTimeout(() => {
198
+ setVisible(false);
199
+ setMeasurement({children: {}, tooltip: {}, measured: false});
200
+ }, hoverEndDelay) as unknown as NodeJS.Timeout;
201
+ };
202
+
203
+ const mobilePressProps = {
204
+ onPress: React.useCallback(() => {
205
+ if (touched.current) {
206
+ return null;
207
+ } else {
208
+ return children.props.onClick?.();
209
+ }
210
+ }, [children.props]),
211
+ };
212
+
213
+ const webPressProps = {
214
+ onHoverIn: () => {
215
+ handleHoverIn();
216
+ children.props.onHoverIn?.();
217
+ },
218
+ onHoverOut: () => {
219
+ handleHoverOut();
220
+ children.props.onHoverOut?.();
221
+ },
222
+ };
223
+
224
+ return (
225
+ <>
226
+ {visible && (
227
+ <Portal>
228
+ <Pressable
229
+ style={{
230
+ alignSelf: "flex-start",
231
+ justifyContent: "center",
232
+ paddingHorizontal: 16,
233
+ backgroundColor: Unifier.theme[bgColor ?? "darkGray"],
234
+ borderRadius: 16,
235
+ paddingVertical: 8,
236
+ display: "flex",
237
+ flexShrink: 1,
238
+ maxWidth: Math.max(Dimensions.get("window").width - 32, 300),
239
+ ...getTooltipPosition({...(measurement as Measurement), idealDirection}),
240
+ ...(measurement.measured ? {opacity: 1} : {opacity: 0}),
241
+ }}
242
+ testID="tooltip-container"
243
+ onLayout={handleOnLayout}
244
+ onPress={() => {
245
+ setVisible(false);
246
+ }}
247
+ >
248
+ <Text color="white">{text}</Text>
249
+ </Pressable>
250
+ </Portal>
251
+ )}
252
+ <Pressable onTouchStart={handleTouchStart} {...(isWeb ? webPressProps : mobilePressProps)}>
253
+ {React.cloneElement(children, {
254
+ ref: childrenWrapperRef,
255
+ })}
256
+ </Pressable>
257
+ </>
258
+ );
259
+ });
package/src/index.tsx CHANGED
@@ -13,6 +13,7 @@ export * from "./CheckBox";
13
13
  export * from "./DateTimeActionSheet";
14
14
  export * from "./ErrorBoundary";
15
15
  export * from "./ErrorPage";
16
+ export * from "./FernsProvider";
16
17
  export * from "./FlatList";
17
18
  export * from "./Field";
18
19
  export * from "./Form";
@@ -22,6 +23,7 @@ export * from "./Icon";
22
23
  export * from "./IconButton";
23
24
  export * from "./Image";
24
25
  export * from "./ImageBackground";
26
+ export * from "./InfoTooltipButton";
25
27
  // export * from "./Layout";
26
28
  // export * from "./Drawer";
27
29
  export * from "./Link";
@@ -41,6 +43,8 @@ export * from "./TapToEdit";
41
43
  export * from "./Text";
42
44
  export * from "./TextArea";
43
45
  export * from "./TextField";
46
+ export * from "./Tooltip";
47
+ export * from "./Toast";
44
48
  export * from "./UnifiedScreens";
45
49
  export * from "./Unifier";
46
50
  export * from "./WithLabel";
package/README.md DELETED
@@ -1,22 +0,0 @@
1
- # @ferns/ui
2
-
3
- This package provides an abstraction for React (web) and React-Native code,
4
- so the code and components you write work the same on both platforms. It does this
5
- by creating a higher level abstraction over both and providing components
6
- that work the same on all supported platforms. For example, instead of using
7
- `<div>` and `<View>`, you use `<Box>` which provides a flexbox-first layout system,
8
- translated to `<div>` and `<View>`.
9
-
10
- `@ferns/ui` also provides an abstraction for many of the features of native and web,
11
- such as Camera, Permissions, etc. This way you can write apps that work similarly
12
- on all 3 platforms.
13
-
14
- `@ferns/ui` is Typescript first.
15
-
16
- You can easily add your own components by creating a `Foo.tsx` and
17
- `Foo.native.tsx` component. Most of the `@ferns/ui` components are implemented
18
- this way, with some only having a single `Component.tsx` if the component is a
19
- higher level component that already works the same on all platforms.
20
-
21
- `@ferns/ui` supports theming, allowing you to change the colors and fonts of the
22
- components.