@jobber/components-native 0.36.0 → 0.38.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 (43) hide show
  1. package/dist/src/ContentOverlay/ContentOverlay.js +144 -0
  2. package/dist/src/ContentOverlay/ContentOverlay.style.js +56 -0
  3. package/dist/src/ContentOverlay/hooks/useKeyboardVisibility.js +21 -0
  4. package/dist/src/ContentOverlay/hooks/useViewLayoutHeight.js +10 -0
  5. package/dist/src/ContentOverlay/index.js +1 -0
  6. package/dist/src/ContentOverlay/messages.js +8 -0
  7. package/dist/src/ContentOverlay/types.js +1 -0
  8. package/dist/src/Disclosure/Disclosure.js +50 -0
  9. package/dist/src/Disclosure/Disclosure.style.js +21 -0
  10. package/dist/src/Disclosure/constants.js +1 -0
  11. package/dist/src/Disclosure/index.js +1 -0
  12. package/dist/src/index.js +2 -0
  13. package/dist/tsconfig.tsbuildinfo +1 -1
  14. package/dist/types/src/ContentOverlay/ContentOverlay.d.ts +6 -0
  15. package/dist/types/src/ContentOverlay/ContentOverlay.style.d.ts +60 -0
  16. package/dist/types/src/ContentOverlay/hooks/useKeyboardVisibility.d.ts +6 -0
  17. package/dist/types/src/ContentOverlay/hooks/useViewLayoutHeight.d.ts +6 -0
  18. package/dist/types/src/ContentOverlay/index.d.ts +2 -0
  19. package/dist/types/src/ContentOverlay/messages.d.ts +7 -0
  20. package/dist/types/src/ContentOverlay/types.d.ts +87 -0
  21. package/dist/types/src/Disclosure/Disclosure.d.ts +35 -0
  22. package/dist/types/src/Disclosure/Disclosure.style.d.ts +19 -0
  23. package/dist/types/src/Disclosure/constants.d.ts +1 -0
  24. package/dist/types/src/Disclosure/index.d.ts +1 -0
  25. package/dist/types/src/index.d.ts +2 -0
  26. package/package.json +2 -2
  27. package/src/ContentOverlay/ContentOverlay.style.ts +70 -0
  28. package/src/ContentOverlay/ContentOverlay.test.tsx +371 -0
  29. package/src/ContentOverlay/ContentOverlay.tsx +295 -0
  30. package/src/ContentOverlay/hooks/useKeyboardVisibility.test.ts +42 -0
  31. package/src/ContentOverlay/hooks/useKeyboardVisibility.ts +36 -0
  32. package/src/ContentOverlay/hooks/useViewLayoutHeight.test.ts +56 -0
  33. package/src/ContentOverlay/hooks/useViewLayoutHeight.ts +18 -0
  34. package/src/ContentOverlay/index.ts +2 -0
  35. package/src/ContentOverlay/messages.ts +9 -0
  36. package/src/ContentOverlay/types.ts +96 -0
  37. package/src/Disclosure/Disclosure.style.ts +22 -0
  38. package/src/Disclosure/Disclosure.test.tsx +71 -0
  39. package/src/Disclosure/Disclosure.tsx +162 -0
  40. package/src/Disclosure/__snapshots__/Disclosure.test.tsx.snap +488 -0
  41. package/src/Disclosure/constants.ts +1 -0
  42. package/src/Disclosure/index.ts +1 -0
  43. package/src/index.ts +2 -0
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Keyboard, KeyboardEvent } from "react-native";
3
+
4
+ interface KeyboardVisibility {
5
+ readonly isKeyboardVisible: boolean;
6
+ readonly keyboardHeight: number;
7
+ }
8
+
9
+ export function useKeyboardVisibility(): KeyboardVisibility {
10
+ const [isKeyboardVisible, setKeyboardVisible] = useState(false);
11
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
12
+
13
+ useEffect(() => {
14
+ const keyboardDidShowListener = Keyboard.addListener(
15
+ "keyboardDidShow",
16
+ (event: KeyboardEvent) => {
17
+ setKeyboardVisible(true);
18
+ setKeyboardHeight(event.endCoordinates.height);
19
+ },
20
+ );
21
+ const keyboardDidHideListener = Keyboard.addListener(
22
+ "keyboardDidHide",
23
+ () => {
24
+ setKeyboardVisible(false);
25
+ setKeyboardHeight(0);
26
+ },
27
+ );
28
+
29
+ return () => {
30
+ keyboardDidHideListener.remove();
31
+ keyboardDidShowListener.remove();
32
+ };
33
+ }, []);
34
+
35
+ return { isKeyboardVisible, keyboardHeight };
36
+ }
@@ -0,0 +1,56 @@
1
+ import { act, renderHook } from "@testing-library/react-hooks";
2
+ import { LayoutChangeEvent } from "react-native";
3
+ import { useViewLayoutHeight } from "./useViewLayoutHeight";
4
+
5
+ describe("useViewLayoutHeight", () => {
6
+ it("should return initial values", async () => {
7
+ const { result } = renderHook(() => useViewLayoutHeight());
8
+ expect(result.current.height).toBe(0);
9
+ expect(result.current.heightKnown).toBe(false);
10
+ expect(typeof result.current.handleLayout).toBe("function");
11
+ });
12
+ it("should handle layout change event", async () => {
13
+ const expectedHeight = 100;
14
+ const layoutChangeEvent: LayoutChangeEvent = {
15
+ nativeEvent: {
16
+ layout: {
17
+ height: expectedHeight,
18
+ width: 100,
19
+ x: 0,
20
+ y: 0,
21
+ },
22
+ },
23
+ currentTarget: 0,
24
+ target: 0,
25
+ bubbles: false,
26
+ cancelable: false,
27
+ defaultPrevented: false,
28
+ eventPhase: 0,
29
+ isTrusted: false,
30
+ preventDefault: function (): void {
31
+ throw new Error("Function not implemented.");
32
+ },
33
+ isDefaultPrevented: function (): boolean {
34
+ throw new Error("Function not implemented.");
35
+ },
36
+ stopPropagation: function (): void {
37
+ throw new Error("Function not implemented.");
38
+ },
39
+ isPropagationStopped: function (): boolean {
40
+ throw new Error("Function not implemented.");
41
+ },
42
+ persist: function (): void {
43
+ throw new Error("Function not implemented.");
44
+ },
45
+ timeStamp: 0,
46
+ type: "",
47
+ };
48
+ const { result } = renderHook(() => useViewLayoutHeight());
49
+ await act(async () => {
50
+ result.current.handleLayout(layoutChangeEvent);
51
+ });
52
+
53
+ expect(result.current.height).toBe(expectedHeight);
54
+ expect(result.current.heightKnown).toBe(true);
55
+ });
56
+ });
@@ -0,0 +1,18 @@
1
+ import { useState } from "react";
2
+ import { LayoutChangeEvent } from "react-native";
3
+
4
+ export function useViewLayoutHeight(): {
5
+ readonly handleLayout: ({ nativeEvent }: LayoutChangeEvent) => void;
6
+ readonly height: number;
7
+ readonly heightKnown: boolean;
8
+ } {
9
+ const [heightKnown, setHeightKnown] = useState(false);
10
+ const [height, setHeight] = useState(0);
11
+
12
+ const handleLayout = ({ nativeEvent }: LayoutChangeEvent): void => {
13
+ setHeightKnown(true);
14
+ setHeight(nativeEvent.layout.height);
15
+ };
16
+
17
+ return { handleLayout, height, heightKnown } as const;
18
+ }
@@ -0,0 +1,2 @@
1
+ export { ContentOverlay } from "./ContentOverlay";
2
+ export type { ContentOverlayRef, ModalBackgroundColor } from "./types";
@@ -0,0 +1,9 @@
1
+ import { defineMessages } from "react-intl";
2
+
3
+ export const messages = defineMessages({
4
+ closeOverlayA11YLabel: {
5
+ id: "closeOverlayA11yLabel",
6
+ defaultMessage: "Close {title} modal",
7
+ description: "Accessibility label for button to close the overlay modal",
8
+ },
9
+ });
@@ -0,0 +1,96 @@
1
+ import { ReactNode } from "react";
2
+ import { Modalize } from "react-native-modalize";
3
+
4
+ export interface ContentOverlayProps {
5
+ /**
6
+ * Content to be passed into the overlay
7
+ */
8
+ readonly children: ReactNode;
9
+ /**
10
+ * Title of overlay, appears in the header next to the close button.
11
+ */
12
+ readonly title: string;
13
+ /**
14
+ * Optional accessibilityLabel describing the overlay.
15
+ * This will read out when the overlay is opened.
16
+ * @default "Close {title} modal"
17
+ */
18
+ readonly accessibilityLabel?: string;
19
+ /**
20
+ * Force overlay height to fill the screen.
21
+ * Width not impacted.
22
+ * @default false
23
+ */
24
+ readonly fullScreen?: boolean;
25
+ /**
26
+ * Display the dismiss button in the header of the overlay.
27
+ * @default false
28
+ */
29
+ readonly showDismiss?: boolean;
30
+ /**
31
+ * If false, hides the handle and turns off dragging.
32
+ * @default true
33
+ */
34
+ readonly isDraggable?: boolean;
35
+ /**
36
+ * If true, automatically adjusts the overlay height to the content height.
37
+ * This will disable the ability to drag the overlay to fullscreen when
38
+ * `isDraggable` is true.
39
+ * @default false
40
+ */
41
+ readonly adjustToContentHeight?: boolean;
42
+ /**
43
+ * Allows taps to be registered behind keyboard if enabled
44
+ * @default false
45
+ */
46
+ readonly keyboardShouldPersistTaps?: boolean;
47
+ /**
48
+ * Enables scrolling in the content body of overlay
49
+ */
50
+ readonly scrollEnabled?: boolean;
51
+ /**
52
+ * Set the background color of the modal window
53
+ * @default "surface"
54
+ */
55
+ readonly modalBackgroundColor?: ModalBackgroundColor;
56
+ /**
57
+ * Callback that is called when the overlay is closed.
58
+ */
59
+ readonly onClose?: () => void;
60
+ /**
61
+ * Callback that is called when the overlay is opened.
62
+ */
63
+ readonly onOpen?: () => void;
64
+
65
+ /**
66
+ * Callback that is called between overlay is closed and when the "x" button is pressed
67
+ */
68
+ readonly onBeforeExit?: () => void;
69
+
70
+ /**
71
+ * Define the behavior of the keyboard when having inputs inside the modal.
72
+ * @default padding
73
+ */
74
+ readonly keyboardAvoidingBehavior?: "height" | "padding" | "position";
75
+
76
+ /**
77
+ * Boolean to show a disabled state
78
+ * @default false
79
+ */
80
+ readonly loading?: boolean;
81
+
82
+ /**
83
+ * Define keyboard's Android behavior like iOS's one.
84
+ * @default Platform.select({ ios: true, android: false })
85
+ */
86
+ readonly avoidKeyboardLikeIOS?: boolean;
87
+ }
88
+
89
+ export type ModalBackgroundColor = "surface" | "background";
90
+
91
+ export type ContentOverlayRef =
92
+ | {
93
+ open?: Modalize["open"];
94
+ close?: Modalize["close"];
95
+ }
96
+ | undefined;
@@ -0,0 +1,22 @@
1
+ import { StyleSheet } from "react-native";
2
+ import { tokens } from "../utils/design";
3
+
4
+ export const styles = StyleSheet.create({
5
+ container: {
6
+ width: "100%",
7
+ },
8
+ headerContainer: {
9
+ flexDirection: "row",
10
+ alignItems: "flex-start",
11
+ justifyContent: "space-between",
12
+ },
13
+ countColumn: {
14
+ paddingRight: tokens["space-base"],
15
+ },
16
+ titleContainer: {
17
+ flexDirection: "row",
18
+ },
19
+ contentContainer: {
20
+ paddingTop: tokens["space-small"],
21
+ },
22
+ });
@@ -0,0 +1,71 @@
1
+ import React from "react";
2
+ import { fireEvent, render } from "@testing-library/react-native";
3
+ import { ReactTestInstance } from "react-test-renderer";
4
+ import { Disclosure } from ".";
5
+ import { Text } from "../Text";
6
+
7
+ jest.mock("react-native-svg", () => {
8
+ return {
9
+ __esModule: true,
10
+ ...jest.requireActual("react-native-svg"),
11
+ Path: "Path",
12
+ default: "SVGMock",
13
+ };
14
+ });
15
+
16
+ function fireLayoutEvent(disclosureContent: ReactTestInstance) {
17
+ fireEvent(disclosureContent, "onLayout", {
18
+ nativeEvent: {
19
+ layout: {
20
+ height: 100,
21
+ },
22
+ },
23
+ });
24
+ }
25
+
26
+ it("renders a Disclosure with a header and a content when open is true", () => {
27
+ const disclosure = render(
28
+ <Disclosure
29
+ header={<Text>This is the header</Text>}
30
+ content={<Text>This is the content</Text>}
31
+ open={true}
32
+ isEmpty={false}
33
+ onToggle={() => {
34
+ return;
35
+ }}
36
+ />,
37
+ );
38
+ fireLayoutEvent(disclosure.getByTestId("content"));
39
+ expect(disclosure).toMatchSnapshot();
40
+ });
41
+
42
+ it("renders a Disclosure with a header and with a content of size 0 when closed is false", () => {
43
+ const disclosure = render(
44
+ <Disclosure
45
+ header={<Text>This is the header</Text>}
46
+ content={<Text>This is the content</Text>}
47
+ open={false}
48
+ isEmpty={false}
49
+ onToggle={() => {
50
+ return;
51
+ }}
52
+ />,
53
+ );
54
+ fireLayoutEvent(disclosure.getByTestId("content"));
55
+ expect(disclosure).toMatchSnapshot();
56
+ });
57
+
58
+ it("should not render the caret when the Disclosure is empty", () => {
59
+ const disclosure = render(
60
+ <Disclosure
61
+ header={<Text>This is the header</Text>}
62
+ content={<Text>This is the content</Text>}
63
+ open={false}
64
+ isEmpty={true}
65
+ onToggle={() => {
66
+ return;
67
+ }}
68
+ />,
69
+ );
70
+ expect(disclosure).toMatchSnapshot();
71
+ });
@@ -0,0 +1,162 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ LayoutChangeEvent,
4
+ ScrollView,
5
+ TouchableOpacity,
6
+ View,
7
+ } from "react-native";
8
+ import Reanimated, {
9
+ Easing,
10
+ useAnimatedStyle,
11
+ useSharedValue,
12
+ withTiming,
13
+ } from "react-native-reanimated";
14
+ import { EASE_CUBIC_IN_OUT } from "./constants";
15
+ import { styles } from "./Disclosure.style";
16
+ import { tokens } from "../utils/design";
17
+ import { Icon } from "../Icon";
18
+
19
+ const ReanimatedView = Reanimated.createAnimatedComponent(View);
20
+ const ReanimatedScrollView = Reanimated.createAnimatedComponent(ScrollView);
21
+
22
+ interface DisclosureProps {
23
+ /**
24
+ * Specifies the main content of the disclosure component.
25
+ * It can be any React Node - simple text, JSX, or a complex React component.
26
+ */
27
+ readonly content: React.ReactNode;
28
+
29
+ /**
30
+ * Defines the header of the disclosure component.
31
+ * Similar to `content`, it can be any React Node.
32
+ */
33
+ readonly header: React.ReactNode;
34
+
35
+ /**
36
+ * A boolean that determines whether the disclosure component is in an open or closed state.
37
+ * If `open` is true, the disclosure is in an open state; if false, it's closed.
38
+ */
39
+ readonly open: boolean;
40
+
41
+ /**
42
+ * A boolean that indicates whether the disclosure component is empty or not.
43
+ * If `isEmpty` is `true`, there is no content in the disclosure; if false, there is some content.
44
+ */
45
+ readonly isEmpty: boolean;
46
+
47
+ /**
48
+ * An optional property that determines the duration of the opening and closing animation of the disclosure component.
49
+ * It's defined in milliseconds.
50
+ * @default tokens["timing-slowest"]
51
+ */
52
+ readonly animationDuration?: number;
53
+
54
+ /**
55
+ * A function that is called whenever the disclosure component is toggled between its open and closed states.
56
+ */
57
+ onToggle(): void;
58
+ }
59
+
60
+ export function Disclosure({
61
+ content,
62
+ header,
63
+ open,
64
+ onToggle,
65
+ isEmpty,
66
+ animationDuration = tokens["timing-slowest"],
67
+ }: DisclosureProps): JSX.Element {
68
+ return (
69
+ <View style={styles.container}>
70
+ <DisclosureHeader
71
+ {...{ header, onToggle, isEmpty, open, animationDuration }}
72
+ />
73
+ <DisclosureContent {...{ content, open, animationDuration }} />
74
+ </View>
75
+ );
76
+ }
77
+
78
+ type DisclosureHeaderProps = Pick<
79
+ DisclosureProps,
80
+ "header" | "onToggle" | "isEmpty" | "open" | "animationDuration"
81
+ >;
82
+
83
+ function DisclosureHeader({
84
+ header,
85
+ onToggle,
86
+ isEmpty,
87
+ open,
88
+ animationDuration,
89
+ }: DisclosureHeaderProps) {
90
+ const rotateZ = useSharedValue(0);
91
+
92
+ rotateZ.value = withTiming(open ? 0 : -180, {
93
+ easing: Easing.bezier(...EASE_CUBIC_IN_OUT),
94
+ duration: animationDuration,
95
+ });
96
+
97
+ const animatedStyle = useAnimatedStyle(() => {
98
+ return {
99
+ transform: [{ rotateZ: `${rotateZ.value}deg` }],
100
+ };
101
+ });
102
+
103
+ return (
104
+ <TouchableOpacity
105
+ activeOpacity={tokens["opacity-pressed"]}
106
+ onPress={onToggle}
107
+ disabled={isEmpty}
108
+ >
109
+ <View style={styles.headerContainer}>
110
+ {header}
111
+ {!isEmpty && (
112
+ <ReanimatedView style={[animatedStyle]}>
113
+ <Icon name={"arrowUp"} color="grey" />
114
+ </ReanimatedView>
115
+ )}
116
+ </View>
117
+ </TouchableOpacity>
118
+ );
119
+ }
120
+
121
+ type DisclosureContentProps = Pick<
122
+ DisclosureProps,
123
+ "content" | "open" | "animationDuration"
124
+ >;
125
+
126
+ function DisclosureContent({
127
+ content,
128
+ open,
129
+ animationDuration,
130
+ }: DisclosureContentProps) {
131
+ const [maxHeight, setMaxHeight] = useState(0);
132
+ const height = useSharedValue(0);
133
+
134
+ const onContentLayoutChange = (event: LayoutChangeEvent) => {
135
+ const newHeight = event.nativeEvent.layout.height;
136
+ setMaxHeight(newHeight);
137
+ };
138
+
139
+ height.value = withTiming(open ? maxHeight : 0, {
140
+ duration: animationDuration,
141
+ easing: Easing.bezier(...EASE_CUBIC_IN_OUT),
142
+ });
143
+
144
+ const animatedStyle = useAnimatedStyle(() => {
145
+ return {
146
+ height: height.value,
147
+ };
148
+ }, []);
149
+
150
+ return (
151
+ <ReanimatedScrollView
152
+ scrollEnabled={false}
153
+ showsHorizontalScrollIndicator={false}
154
+ showsVerticalScrollIndicator={false}
155
+ style={[styles.contentContainer, animatedStyle]}
156
+ >
157
+ <View testID={"content"} onLayout={onContentLayoutChange}>
158
+ {content}
159
+ </View>
160
+ </ReanimatedScrollView>
161
+ );
162
+ }