@jobber/components-native 0.92.0 → 0.94.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 (30) hide show
  1. package/dist/package.json +4 -2
  2. package/dist/src/BottomSheet/BottomSheet.js +58 -32
  3. package/dist/src/BottomSheet/BottomSheet.style.js +8 -9
  4. package/dist/src/BottomSheet/hooks/useBottomSheetBackHandler.js +26 -0
  5. package/dist/src/Content/ContentHorizontal.style.js +15 -0
  6. package/dist/src/Content/ContentSpaceAround.style.js +15 -0
  7. package/dist/src/Content/ContentVertical.style.js +15 -0
  8. package/dist/src/InputText/InputText.js +2 -2
  9. package/dist/tsconfig.build.tsbuildinfo +1 -1
  10. package/dist/types/src/BottomSheet/BottomSheet.d.ts +7 -3
  11. package/dist/types/src/BottomSheet/BottomSheet.style.d.ts +7 -14
  12. package/dist/types/src/BottomSheet/hooks/useBottomSheetBackHandler.d.ts +8 -0
  13. package/dist/types/src/Content/Content.d.ts +1 -1
  14. package/dist/types/src/Content/ContentHorizontal.style.d.ts +15 -0
  15. package/dist/types/src/Content/ContentSpaceAround.style.d.ts +15 -0
  16. package/dist/types/src/Content/ContentVertical.style.d.ts +15 -0
  17. package/dist/types/src/InputText/InputText.d.ts +1 -1
  18. package/package.json +4 -2
  19. package/src/BottomSheet/BottomSheet.stories.tsx +128 -0
  20. package/src/BottomSheet/BottomSheet.style.ts +7 -14
  21. package/src/BottomSheet/BottomSheet.test.tsx +19 -24
  22. package/src/BottomSheet/BottomSheet.tsx +112 -93
  23. package/src/BottomSheet/hooks/useBottomSheetBackHandler.test.ts +90 -0
  24. package/src/BottomSheet/hooks/useBottomSheetBackHandler.ts +41 -0
  25. package/src/Content/Content.tsx +6 -1
  26. package/src/Content/ContentHorizontal.style.ts +20 -0
  27. package/src/Content/ContentSpaceAround.style.ts +20 -0
  28. package/src/Content/ContentVertical.style.ts +20 -0
  29. package/src/InputText/InputText.tsx +3 -3
  30. package/src/ThumbnailList/__snapshots__/ThumbnailList.test.tsx.snap +199 -1
@@ -1,14 +1,19 @@
1
- import type { ReactNode, Ref, RefObject } from "react";
2
- import React, { forwardRef, useState } from "react";
3
- import type { Modalize } from "react-native-modalize";
4
- import { useSafeAreaInsets } from "react-native-safe-area-context";
1
+ import type { ReactNode } from "react";
2
+ import React, { useCallback, useImperativeHandle, useRef } from "react";
5
3
  import { Keyboard, View } from "react-native";
6
- import { BottomSheetOption } from "./components/BottomSheetOption";
4
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
5
+ import RNBottomSheet, {
6
+ BottomSheetBackdrop,
7
+ BottomSheetView,
8
+ } from "@gorhom/bottom-sheet";
9
+ import type { BottomSheetBackdropProps } from "@gorhom/bottom-sheet";
10
+ import { tokens } from "@jobber/design";
7
11
  import { useStyles } from "./BottomSheet.style";
8
- import { UNSAFE_WrappedModalize } from "../ContentOverlay/UNSAFE_WrappedModalize";
9
- import { useIsScreenReaderEnabled } from "../hooks";
12
+ import { BottomSheetOption } from "./components/BottomSheetOption";
13
+ import { useBottomSheetBackHandler } from "./hooks/useBottomSheetBackHandler";
10
14
  import { Divider } from "../Divider";
11
15
  import { Heading } from "../Heading";
16
+ import { useIsScreenReaderEnabled } from "../hooks";
12
17
  import { useAtlantisI18n } from "../hooks/useAtlantisI18n";
13
18
 
14
19
  export interface BottomSheetProps {
@@ -40,71 +45,89 @@ export interface BottomSheetProps {
40
45
  readonly onClose?: () => void;
41
46
  }
42
47
 
43
- export const BottomSheet = forwardRef(BottomSheetInternal);
44
-
45
- export type BottomSheetRef = Modalize | undefined;
46
-
47
- function BottomSheetInternal(
48
- {
49
- children,
50
- showCancel,
51
- loading = false,
52
- heading,
53
- onOpen,
54
- onClose,
55
- }: BottomSheetProps,
56
- ref: Ref<BottomSheetRef>,
57
- ) {
58
- const isScreenReaderEnabled = useIsScreenReaderEnabled();
59
- const [open, setOpen] = useState<boolean>(false);
48
+ export interface BottomSheetRef {
49
+ open: () => void;
50
+ close: () => void;
51
+ }
52
+
53
+ export function BottomSheet({
54
+ children,
55
+ showCancel,
56
+ loading = false,
57
+ heading,
58
+ onOpen,
59
+ onClose,
60
+ ref,
61
+ }: BottomSheetProps & { readonly ref?: React.Ref<BottomSheetRef> }) {
60
62
  const styles = useStyles();
63
+ const isScreenReaderEnabled = useIsScreenReaderEnabled();
64
+
65
+ const cancellable = (showCancel && !loading) || isScreenReaderEnabled;
66
+
67
+ const { t } = useAtlantisI18n();
68
+ const insets = useSafeAreaInsets();
69
+ const previousIndexRef = useRef(-1);
70
+ const bottomSheetRef = useRef<RNBottomSheet>(null);
71
+ const { handleSheetPositionChange } =
72
+ useBottomSheetBackHandler(bottomSheetRef);
73
+
74
+ useImperativeHandle(ref, () => ({
75
+ open: () => {
76
+ bottomSheetRef.current?.expand();
77
+ },
78
+ close: () => {
79
+ close();
80
+ },
81
+ }));
82
+
83
+ const close = useCallback(() => {
84
+ bottomSheetRef.current?.close();
85
+ }, []);
86
+
87
+ const handleChange = (index: number) => {
88
+ // Handle Android back button
89
+ handleSheetPositionChange(index);
90
+
91
+ const previousIndex = previousIndexRef.current;
92
+
93
+ if (previousIndex === -1 && index >= 0) {
94
+ // Transitioned from closed to open
95
+ dismissKeyboard();
96
+ onOpen?.();
97
+ } else if (previousIndex >= 0 && index === -1) {
98
+ // Transitioned from open to closed
99
+ dismissKeyboard();
100
+ onClose?.();
101
+ }
102
+
103
+ previousIndexRef.current = index;
104
+ };
61
105
 
62
106
  return (
63
- <>
64
- {open && <Overlay styles={styles} />}
65
- <UNSAFE_WrappedModalize
66
- ref={ref}
67
- adjustToContentHeight={true}
68
- modalStyle={styles.modal}
69
- overlayStyle={styles.overlayModalize}
70
- HeaderComponent={
71
- heading && <Header heading={heading} styles={styles} />
72
- }
73
- FooterComponent={
74
- <Footer
75
- cancellable={(showCancel && !loading) || isScreenReaderEnabled}
76
- onCancel={() => {
77
- (ref as RefObject<BottomSheetRef>)?.current?.close();
78
- }}
79
- styles={styles}
80
- />
81
- }
82
- withHandle={false}
83
- withReactModal={isScreenReaderEnabled}
84
- onOpen={openModal}
85
- onClose={closeModal}
107
+ <RNBottomSheet
108
+ ref={bottomSheetRef}
109
+ index={-1}
110
+ backdropComponent={Backdrop}
111
+ backgroundStyle={styles.background}
112
+ enablePanDownToClose={true}
113
+ onChange={handleChange}
114
+ keyboardBlurBehavior="restore"
115
+ handleStyle={styles.handle}
116
+ >
117
+ <BottomSheetView
118
+ style={{
119
+ paddingBottom: insets.bottom + tokens["space-small"],
120
+ paddingTop: tokens["space-small"],
121
+ }}
86
122
  >
87
- <View
88
- style={
89
- !showCancel && !isScreenReaderEnabled ? styles.children : undefined
90
- }
91
- >
92
- {children}
93
- </View>
94
- </UNSAFE_WrappedModalize>
95
- </>
123
+ {heading && <Header heading={heading} styles={styles} />}
124
+ {children}
125
+ {cancellable && (
126
+ <Footer styles={styles} close={close} cancelLabel={t("cancel")} />
127
+ )}
128
+ </BottomSheetView>
129
+ </RNBottomSheet>
96
130
  );
97
-
98
- function openModal() {
99
- onOpen?.();
100
- setOpen(true);
101
- dismissKeyboard();
102
- }
103
-
104
- function closeModal() {
105
- onClose?.();
106
- setOpen(false);
107
- }
108
131
  }
109
132
 
110
133
  function Header({
@@ -122,31 +145,21 @@ function Header({
122
145
  }
123
146
 
124
147
  function Footer({
125
- cancellable,
126
- onCancel,
127
148
  styles,
149
+ close,
150
+ cancelLabel,
128
151
  }: {
129
- readonly cancellable: boolean;
130
- readonly onCancel: () => void;
131
152
  readonly styles: ReturnType<typeof useStyles>;
153
+ readonly close: () => void;
154
+ readonly cancelLabel: string;
132
155
  }) {
133
- const insets = useSafeAreaInsets();
134
- const { t } = useAtlantisI18n();
135
-
136
156
  return (
137
- <View style={{ marginBottom: insets.bottom }}>
138
- {cancellable && (
139
- <View style={styles.children}>
140
- <View style={styles.footerDivider}>
141
- <Divider />
142
- </View>
143
- <BottomSheetOption
144
- text={t("cancel")}
145
- icon={"remove"}
146
- onPress={onCancel}
147
- />
148
- </View>
149
- )}
157
+ <View>
158
+ <View style={styles.footerDivider}>
159
+ <Divider />
160
+ </View>
161
+
162
+ <BottomSheetOption text={cancelLabel} icon="remove" onPress={close} />
150
163
  </View>
151
164
  );
152
165
  }
@@ -157,10 +170,16 @@ function dismissKeyboard() {
157
170
  Keyboard.dismiss();
158
171
  }
159
172
 
160
- function Overlay({
161
- styles,
162
- }: {
163
- readonly styles: ReturnType<typeof useStyles>;
164
- }) {
165
- return <View style={styles.overlay} />;
173
+ function Backdrop(bottomSheetBackdropProps: BottomSheetBackdropProps) {
174
+ const styles = useStyles();
175
+
176
+ return (
177
+ <BottomSheetBackdrop
178
+ {...bottomSheetBackdropProps}
179
+ appearsOnIndex={0}
180
+ disappearsOnIndex={-1}
181
+ style={styles.backdrop}
182
+ opacity={1}
183
+ />
184
+ );
166
185
  }
@@ -0,0 +1,90 @@
1
+ import { createRef } from "react";
2
+ import type { RefObject } from "react";
3
+ import { act, renderHook } from "@testing-library/react-native";
4
+ import { BackHandler } from "react-native";
5
+ import type BottomSheet from "@gorhom/bottom-sheet";
6
+ import { useBottomSheetBackHandler } from "./useBottomSheetBackHandler";
7
+
8
+ describe("useBottomSheetBackHandler", () => {
9
+ let mockRemove: jest.Mock;
10
+ let mockAddEventListener: jest.SpyInstance;
11
+
12
+ beforeEach(() => {
13
+ mockRemove = jest.fn();
14
+ mockAddEventListener = jest.spyOn(BackHandler, "addEventListener");
15
+ mockAddEventListener.mockReturnValue({ remove: mockRemove });
16
+ });
17
+
18
+ afterEach(() => {
19
+ mockAddEventListener.mockRestore();
20
+ });
21
+
22
+ it("should register BackHandler listener when sheet becomes visible", async () => {
23
+ const bottomSheetRef = createRef<BottomSheet | null>();
24
+ const { result } = renderHook(() =>
25
+ useBottomSheetBackHandler(bottomSheetRef),
26
+ );
27
+
28
+ await act(async () => {
29
+ result.current.handleSheetPositionChange(0);
30
+ });
31
+
32
+ expect(mockAddEventListener).toHaveBeenCalledWith(
33
+ "hardwareBackPress",
34
+ expect.any(Function),
35
+ );
36
+ });
37
+
38
+ it("should call close() when back button is pressed", async () => {
39
+ const mockClose = jest.fn();
40
+ const bottomSheetRef = {
41
+ current: {
42
+ close: mockClose,
43
+ } as unknown as BottomSheet,
44
+ } as RefObject<BottomSheet | null>;
45
+
46
+ const { result } = renderHook(() =>
47
+ useBottomSheetBackHandler(bottomSheetRef),
48
+ );
49
+
50
+ await act(async () => {
51
+ result.current.handleSheetPositionChange(0);
52
+ });
53
+
54
+ const registeredCallback = mockAddEventListener.mock.calls[0][1];
55
+ const returnValue = registeredCallback();
56
+
57
+ expect(mockClose).toHaveBeenCalled();
58
+ expect(returnValue).toBe(true);
59
+ });
60
+
61
+ it("should remove listener when sheet is dismissed", async () => {
62
+ const bottomSheetRef = createRef<BottomSheet | null>();
63
+ const { result } = renderHook(() =>
64
+ useBottomSheetBackHandler(bottomSheetRef),
65
+ );
66
+
67
+ await act(async () => {
68
+ result.current.handleSheetPositionChange(0);
69
+ });
70
+
71
+ await act(async () => {
72
+ result.current.handleSheetPositionChange(-1);
73
+ });
74
+
75
+ expect(mockRemove).toHaveBeenCalled();
76
+ });
77
+
78
+ it("should not register listener when index is negative", async () => {
79
+ const bottomSheetRef = createRef<BottomSheet | null>();
80
+ const { result } = renderHook(() =>
81
+ useBottomSheetBackHandler(bottomSheetRef),
82
+ );
83
+
84
+ await act(async () => {
85
+ result.current.handleSheetPositionChange(-1);
86
+ });
87
+
88
+ expect(mockAddEventListener).not.toHaveBeenCalled();
89
+ });
90
+ });
@@ -0,0 +1,41 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { BackHandler, type NativeEventSubscription } from "react-native";
3
+ import type BottomSheet from "@gorhom/bottom-sheet";
4
+
5
+ /**
6
+ * Hook that closes the bottom sheet on the hardware back button press if it is visible
7
+ * @param bottomSheetRef ref to the bottom sheet component
8
+ */
9
+ export function useBottomSheetBackHandler(
10
+ bottomSheetRef: React.RefObject<BottomSheet | null>,
11
+ ): {
12
+ handleSheetPositionChange: (index: number) => void;
13
+ } {
14
+ const backHandlerSubscriptionRef = useRef<NativeEventSubscription | null>(
15
+ null,
16
+ );
17
+
18
+ const handleSheetPositionChange = useCallback(
19
+ (index: number) => {
20
+ const isBottomSheetVisible = index >= 0;
21
+
22
+ if (isBottomSheetVisible && !backHandlerSubscriptionRef.current) {
23
+ // Setup the back handler if the bottom sheet is right in front of the user
24
+ backHandlerSubscriptionRef.current = BackHandler.addEventListener(
25
+ "hardwareBackPress",
26
+ () => {
27
+ bottomSheetRef.current?.close();
28
+
29
+ return true;
30
+ },
31
+ );
32
+ } else if (!isBottomSheetVisible) {
33
+ backHandlerSubscriptionRef.current?.remove();
34
+ backHandlerSubscriptionRef.current = null;
35
+ }
36
+ },
37
+ [bottomSheetRef],
38
+ );
39
+
40
+ return { handleSheetPositionChange };
41
+ }
@@ -9,10 +9,15 @@ import { useSpaceAroundStyles } from "./ContentSpaceAround.style";
9
9
  export type Spacing =
10
10
  | "none"
11
11
  | "base"
12
+ | "slim"
12
13
  | "small"
13
14
  | "smaller"
14
15
  | "smallest"
15
- | "large";
16
+ | "minuscule"
17
+ | "large"
18
+ | "larger"
19
+ | "largest"
20
+ | "extravagant";
16
21
 
17
22
  export interface ContentUnsafeStyle {
18
23
  container?: StyleProp<ViewStyle>;
@@ -16,6 +16,10 @@ export const useHorizontalStyles = buildThemedStyles(tokens => {
16
16
  padding: 0,
17
17
  },
18
18
 
19
+ minusculeChildSpace: {
20
+ paddingLeft: tokens["space-minuscule"],
21
+ },
22
+
19
23
  smallestChildSpace: {
20
24
  paddingLeft: tokens["space-smallest"],
21
25
  },
@@ -28,6 +32,10 @@ export const useHorizontalStyles = buildThemedStyles(tokens => {
28
32
  paddingLeft: tokens["space-small"],
29
33
  },
30
34
 
35
+ slimChildSpace: {
36
+ paddingLeft: tokens["space-slim"],
37
+ },
38
+
31
39
  baseChildSpace: {
32
40
  paddingLeft: tokens["space-base"],
33
41
  },
@@ -35,5 +43,17 @@ export const useHorizontalStyles = buildThemedStyles(tokens => {
35
43
  largeChildSpace: {
36
44
  paddingLeft: tokens["space-large"],
37
45
  },
46
+
47
+ largerChildSpace: {
48
+ paddingLeft: tokens["space-larger"],
49
+ },
50
+
51
+ largestChildSpace: {
52
+ paddingLeft: tokens["space-largest"],
53
+ },
54
+
55
+ extravagantChildSpace: {
56
+ paddingLeft: tokens["space-extravagant"],
57
+ },
38
58
  };
39
59
  });
@@ -6,6 +6,10 @@ export const useSpaceAroundStyles = buildThemedStyles(tokens => {
6
6
  padding: 0,
7
7
  },
8
8
 
9
+ minusculeSpace: {
10
+ padding: tokens["space-minuscule"],
11
+ },
12
+
9
13
  smallestSpace: {
10
14
  padding: tokens["space-smallest"],
11
15
  },
@@ -18,6 +22,10 @@ export const useSpaceAroundStyles = buildThemedStyles(tokens => {
18
22
  padding: tokens["space-small"],
19
23
  },
20
24
 
25
+ slimSpace: {
26
+ padding: tokens["space-slim"],
27
+ },
28
+
21
29
  baseSpace: {
22
30
  padding: tokens["space-base"],
23
31
  },
@@ -25,5 +33,17 @@ export const useSpaceAroundStyles = buildThemedStyles(tokens => {
25
33
  largeSpace: {
26
34
  padding: tokens["space-large"],
27
35
  },
36
+
37
+ largerSpace: {
38
+ padding: tokens["space-larger"],
39
+ },
40
+
41
+ largestSpace: {
42
+ padding: tokens["space-largest"],
43
+ },
44
+
45
+ extravagantSpace: {
46
+ padding: tokens["space-extravagant"],
47
+ },
28
48
  };
29
49
  });
@@ -16,6 +16,10 @@ export const useVerticalStyles = buildThemedStyles(tokens => {
16
16
  padding: 0,
17
17
  },
18
18
 
19
+ minusculeChildSpace: {
20
+ paddingTop: tokens["space-minuscule"],
21
+ },
22
+
19
23
  smallestChildSpace: {
20
24
  paddingTop: tokens["space-smallest"],
21
25
  },
@@ -28,6 +32,10 @@ export const useVerticalStyles = buildThemedStyles(tokens => {
28
32
  paddingTop: tokens["space-small"],
29
33
  },
30
34
 
35
+ slimChildSpace: {
36
+ paddingTop: tokens["space-slim"],
37
+ },
38
+
31
39
  baseChildSpace: {
32
40
  paddingTop: tokens["space-base"],
33
41
  },
@@ -35,5 +43,17 @@ export const useVerticalStyles = buildThemedStyles(tokens => {
35
43
  largeChildSpace: {
36
44
  paddingTop: tokens["space-large"],
37
45
  },
46
+
47
+ largerChildSpace: {
48
+ paddingTop: tokens["space-larger"],
49
+ },
50
+
51
+ largestChildSpace: {
52
+ paddingTop: tokens["space-largest"],
53
+ },
54
+
55
+ extravagantChildSpace: {
56
+ paddingTop: tokens["space-extravagant"],
57
+ },
38
58
  };
39
59
  });
@@ -126,7 +126,7 @@ export interface InputTextProps
126
126
  /**
127
127
  * Callback that is called when the text input is blurred
128
128
  */
129
- readonly onBlur?: () => void;
129
+ readonly onBlur?: (event?: FocusEvent) => void;
130
130
 
131
131
  /**
132
132
  * VoiceOver will read this string when a user selects the associated element
@@ -436,10 +436,10 @@ function InputTextInternal(
436
436
  setFocused(true);
437
437
  onFocus?.(event);
438
438
  }}
439
- onBlur={() => {
439
+ onBlur={event => {
440
440
  _name && setFocusedInput("");
441
441
  setFocused(false);
442
- onBlur?.();
442
+ onBlur?.(event);
443
443
  field.onBlur();
444
444
  trimWhitespace(inputTransform(field.value), updateFormAndState);
445
445
  }}