@jobber/hooks 2.0.3-dar.45 → 2.0.3-dar.47

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 (44) hide show
  1. package/package.json +4 -3
  2. package/src/index.ts +10 -0
  3. package/src/useAssert/index.ts +1 -0
  4. package/src/useAssert/useAssert.stories.mdx +32 -0
  5. package/src/useAssert/useAssert.tsx +19 -0
  6. package/src/useCollectionQuery/index.ts +1 -0
  7. package/src/useCollectionQuery/mdxUtils.ts +190 -0
  8. package/src/useCollectionQuery/test-utilities/index.ts +3 -0
  9. package/src/useCollectionQuery/test-utilities/mocks.tsx +147 -0
  10. package/src/useCollectionQuery/test-utilities/queries.ts +95 -0
  11. package/src/useCollectionQuery/test-utilities/utils.ts +3 -0
  12. package/src/useCollectionQuery/uniqueEdges.tsx +26 -0
  13. package/src/useCollectionQuery/uniqueNodes.tsx +12 -0
  14. package/src/useCollectionQuery/useCollectionQuery.stories.mdx +129 -0
  15. package/src/useCollectionQuery/useCollectionQuery.test.tsx +419 -0
  16. package/src/useCollectionQuery/useCollectionQuery.ts +359 -0
  17. package/src/useFocusTrap/index.ts +1 -0
  18. package/src/useFocusTrap/useFocusTrap.stories.mdx +49 -0
  19. package/src/useFocusTrap/useFocusTrap.test.tsx +66 -0
  20. package/src/useFocusTrap/useFocusTrap.ts +64 -0
  21. package/src/useFormState/index.ts +1 -0
  22. package/src/useFormState/useFormState.stories.mdx +70 -0
  23. package/src/useFormState/useFormState.ts +10 -0
  24. package/src/useIsMounted/index.ts +1 -0
  25. package/src/useIsMounted/useIsMounted.stories.mdx +59 -0
  26. package/src/useIsMounted/useIsMounted.test.tsx +18 -0
  27. package/src/useIsMounted/useIsMounted.ts +30 -0
  28. package/src/useLiveAnnounce/index.ts +1 -0
  29. package/src/useLiveAnnounce/useLiveAnnounce.stories.mdx +38 -0
  30. package/src/useLiveAnnounce/useLiveAnnounce.test.tsx +55 -0
  31. package/src/useLiveAnnounce/useLiveAnnounce.tsx +47 -0
  32. package/src/useOnKeyDown/index.ts +1 -0
  33. package/src/useOnKeyDown/useOnKeyDown.stories.mdx +67 -0
  34. package/src/useOnKeyDown/useOnKeyDown.test.tsx +31 -0
  35. package/src/useOnKeyDown/useOnKeyDown.ts +52 -0
  36. package/src/usePasswordStrength/index.ts +1 -0
  37. package/src/usePasswordStrength/usePasswordStrength.stories.mdx +51 -0
  38. package/src/usePasswordStrength/usePasswordStrength.ts +21 -0
  39. package/src/useRefocusOnActivator/index.ts +1 -0
  40. package/src/useRefocusOnActivator/useRefocusOnActivator.stories.mdx +39 -0
  41. package/src/useRefocusOnActivator/useRefocusOnActivator.ts +26 -0
  42. package/src/useResizeObserver/index.ts +1 -0
  43. package/src/useResizeObserver/useResizeObserver.stories.mdx +134 -0
  44. package/src/useResizeObserver/useResizeObserver.ts +78 -0
@@ -0,0 +1,359 @@
1
+ import {
2
+ ApolloError,
3
+ DocumentNode,
4
+ QueryHookOptions,
5
+ SubscribeToMoreOptions,
6
+ useQuery,
7
+ } from "@apollo/client";
8
+ import { cloneDeep } from "lodash";
9
+ import { useCallback, useEffect, useState } from "react";
10
+ import { config } from "@jobber/formatters";
11
+ import { Node, uniqueNodes } from "./uniqueNodes";
12
+ import { Edge, createEdge, uniqueEdges } from "./uniqueEdges";
13
+ import { useIsMounted } from "../useIsMounted";
14
+
15
+ interface UseCollectionQueryArguments<TQuery, TSubscription> {
16
+ /**
17
+ * The graphQL query that fetches the collection
18
+ */
19
+ query: DocumentNode;
20
+
21
+ /**
22
+ * A list of options for us to pass into the apollo `useQuery` hook
23
+ */
24
+ queryOptions?: QueryHookOptions<TQuery>;
25
+
26
+ /**
27
+ * A function that returns the location where the {@link Collection} is located.
28
+ *
29
+ * The collection is the part of the result that needs to be paginated.
30
+ */
31
+ getCollectionByPath: GetCollectionByPathFunction<TQuery>;
32
+
33
+ /**
34
+ * A list of subscription options if you want to create a GraphQL
35
+ * subscription to listen for more content.
36
+ */
37
+ subscription?: ListSubscription<TSubscription>;
38
+ }
39
+
40
+ interface ListSubscription<TSubscription> {
41
+ /**
42
+ * The graphQL subscription that listens for more data. This query should
43
+ * return a single Node that matches the data structure in
44
+ * `getCollectionByPath<TQuery>(...).edges.node` and
45
+ * `getCollectionByPath<TQuery>(...).nodes
46
+ */
47
+ document: DocumentNode;
48
+
49
+ /**
50
+ * A list of variables to pass into the apollo `subscribeToMore` function.
51
+ */
52
+ options?: Pick<SubscribeToMoreOptions<TSubscription>, "variables">;
53
+
54
+ /**
55
+ * A function that returns the location where the `Node` is located on the
56
+ * `TSubscription` object.
57
+ *
58
+ * It should return a single Node that matches the data structure in
59
+ * `getCollectionByPath<TQuery>(...).edges.node` and
60
+ * `getCollectionByPath<TQuery>(...).nodes
61
+ */
62
+ getNodeByPath: GetNodeByPath<TSubscription>;
63
+ }
64
+
65
+ interface Collection {
66
+ edges?: Edge[];
67
+ nodes?: Node[];
68
+ pageInfo: {
69
+ endCursor?: string | undefined;
70
+ hasNextPage: boolean;
71
+ [otherProperties: string]: unknown;
72
+ };
73
+ totalCount?: number;
74
+ [otherProperties: string]: unknown;
75
+ }
76
+
77
+ interface CollectionQueryResult<TQuery> {
78
+ data: TQuery | undefined;
79
+ error: ApolloError | undefined;
80
+ loadingRefresh: boolean;
81
+ loadingNextPage: boolean;
82
+ loadingInitialContent: boolean;
83
+ refresh(): void;
84
+ nextPage(): void;
85
+ }
86
+
87
+ type GetCollectionByPathFunction<TQuery> = (
88
+ data: TQuery | undefined,
89
+ ) => Collection | undefined;
90
+
91
+ type GetNodeByPath<TSubscription> = (
92
+ data: TSubscription | undefined,
93
+ ) => Node | undefined;
94
+
95
+ export function useCollectionQuery<TQuery, TSubscription = undefined>({
96
+ query,
97
+ queryOptions,
98
+ getCollectionByPath,
99
+ subscription,
100
+ }: UseCollectionQueryArguments<
101
+ TQuery,
102
+ TSubscription
103
+ >): CollectionQueryResult<TQuery> {
104
+ const { data, loading, refetch, error, fetchMore, subscribeToMore } =
105
+ useQuery<TQuery>(query, queryOptions);
106
+
107
+ const isMounted = useIsMounted();
108
+ const [loadingRefresh, setLoadingRefresh] = useState<boolean>(false);
109
+ const [loadingNextPage, setLoadingNextPage] = useState<boolean>(false);
110
+ const loadingInitialContent = loading && !loadingRefresh && !loadingNextPage;
111
+ const isSearching = !!queryOptions?.variables?.searchTerm;
112
+
113
+ const refresh = useCallback(() => {
114
+ if (loadingInitialContent || loadingRefresh) {
115
+ return;
116
+ }
117
+
118
+ setLoadingRefresh(true);
119
+
120
+ fetchMore({
121
+ // a workaround fix for the error described in this post
122
+ // https://github.com/apollographql/apollo-client/issues/7491#issuecomment-767985363
123
+ // These changes can be reverted once we can update to version 3.4
124
+ // (the current release candidate)
125
+ variables: {},
126
+ updateQuery: (prev, { fetchMoreResult }) => fetchMoreResult || prev,
127
+ })
128
+ .catch(err => config.errorNotifier("Refetch Error", err))
129
+ .finally(() => {
130
+ if (isMounted.current) {
131
+ setLoadingRefresh(false);
132
+ }
133
+ });
134
+ }, [
135
+ loadingInitialContent,
136
+ loadingRefresh,
137
+ setLoadingRefresh,
138
+ refetch,
139
+ isMounted,
140
+ ]);
141
+
142
+ const nextPage = useCallback(() => {
143
+ if (loadingInitialContent || loadingRefresh || loadingNextPage) {
144
+ return;
145
+ }
146
+
147
+ const pageInfo = getCollectionByPath(data)?.pageInfo;
148
+
149
+ if (!pageInfo || !pageInfo.hasNextPage) {
150
+ return;
151
+ }
152
+
153
+ setLoadingNextPage(true);
154
+
155
+ fetchMore({
156
+ variables: {
157
+ cursor: pageInfo.endCursor,
158
+ },
159
+ updateQuery: (prev, { fetchMoreResult }) =>
160
+ fetchMoreUpdateQueryHandler(prev, fetchMoreResult, getCollectionByPath),
161
+ })
162
+ .catch(err => config.errorNotifier("FetchMore Error", err))
163
+ .finally(() => {
164
+ if (isMounted.current) {
165
+ setLoadingNextPage(false);
166
+ }
167
+ });
168
+ }, [
169
+ data,
170
+ loadingInitialContent,
171
+ loadingRefresh,
172
+ fetchMore,
173
+ loadingNextPage,
174
+ setLoadingNextPage,
175
+ getCollectionByPath,
176
+ isMounted,
177
+ ]);
178
+
179
+ useEffect(
180
+ () => {
181
+ if (subscription == undefined) return;
182
+
183
+ const subscriptionOptions = subscription.options || {};
184
+
185
+ return subscribeToMore<TSubscription>({
186
+ ...subscriptionOptions,
187
+ document: subscription.document,
188
+ updateQuery: (prev, { subscriptionData }) =>
189
+ subscribeToMoreHandler(
190
+ isSearching,
191
+ prev,
192
+ getCollectionByPath,
193
+ subscriptionData?.data,
194
+ subscription.getNodeByPath,
195
+ ),
196
+ onError: err => config.errorNotifier("Subscribe to More Error", err),
197
+ });
198
+ },
199
+ // Disabling this linter so we can force this only run once. If we didn't
200
+ // do this we would need to ensure subscription, subscribeToMore, and getNodeByPath
201
+ // all use useCallback.
202
+ [queryOptions?.variables?.searchTerm],
203
+ );
204
+
205
+ return {
206
+ data,
207
+ error,
208
+ refresh,
209
+ loadingRefresh,
210
+ nextPage,
211
+ loadingNextPage,
212
+ loadingInitialContent,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * The following method uses an `output` variable, and indirectly modifies it through the `outputCollection` variable.
218
+ * This type of indirect modification is prone to bugs, but I couldn't think of a better way to write this code.
219
+ *
220
+ * Here's what I was balancing:
221
+ * 1. We need to copy the structure of prev to ensure that when we're combining two objects, we're not losing properties
222
+ * 2. We need to extract a key from an unknown path that's given to us by getCollectionByPath and then we need to modify
223
+ * that, and ensure that it's properly set on the cloned output object.
224
+ * 3. We want to keep the interface to this hook as simple and easy to use as possible
225
+ *
226
+ * The alternative approaches that were rejected:
227
+ * 1. We replace the getCollectionByPath with a keyPath string. (Eg. "data.conversation.message") and then we use lodash
228
+ * `get` and `set` which should remove all the object manipulation. But, this approach loses us the type safety that
229
+ * getCollectionByPath gives us
230
+ * 2. We could add a setCollection function to the list of arguments for this hook. This leaves us with type safety but
231
+ * makes the `useCollectionQuery` interface more complicated by adding arguments
232
+ */
233
+ function fetchMoreUpdateQueryHandler<TQuery>(
234
+ prev: TQuery,
235
+ fetchMoreResult: TQuery | undefined,
236
+ getCollectionByPath: GetCollectionByPathFunction<TQuery>,
237
+ ): TQuery {
238
+ const nextCollection = getCollectionByPath(fetchMoreResult);
239
+ const output = cloneDeep(prev);
240
+ const outputCollection = getCollectionByPath(output);
241
+
242
+ if (outputCollection === undefined || nextCollection === undefined) {
243
+ return output;
244
+ }
245
+
246
+ if (outputCollection.nodes && nextCollection.nodes) {
247
+ outputCollection.nodes = getUpdatedNodes(
248
+ outputCollection.nodes,
249
+ nextCollection.nodes,
250
+ );
251
+ }
252
+ if (outputCollection.edges && nextCollection.edges) {
253
+ outputCollection.edges = getUpdatedEdges(
254
+ outputCollection.edges,
255
+ nextCollection.edges,
256
+ );
257
+ }
258
+
259
+ Object.assign(outputCollection, {
260
+ pageInfo: cloneDeep(nextCollection.pageInfo),
261
+ ...getTotalCount(nextCollection.totalCount),
262
+ });
263
+
264
+ return output;
265
+ }
266
+
267
+ function subscribeToMoreHandler<TQuery, TSubscription>(
268
+ isSearching: boolean,
269
+ prev: TQuery,
270
+ getCollectionByPath: GetCollectionByPathFunction<TQuery>,
271
+ subscriptionData: TSubscription | undefined,
272
+ getNodeByPath: GetNodeByPath<TSubscription>,
273
+ ): TQuery {
274
+ const node = getNodeByPath(subscriptionData);
275
+ const output = cloneDeep(prev);
276
+ const outputCollection = getCollectionByPath(output);
277
+
278
+ if (outputCollection == undefined || node == undefined) return output;
279
+
280
+ if (isAlreadyUpdated(outputCollection, node) || isSearching) {
281
+ return prev;
282
+ }
283
+
284
+ if (outputCollection.nodes) {
285
+ outputCollection.nodes = getUpdatedNodes(
286
+ outputCollection.nodes,
287
+ [node],
288
+ false,
289
+ );
290
+ }
291
+ if (outputCollection.edges) {
292
+ outputCollection.edges = getUpdatedEdges(
293
+ outputCollection.edges,
294
+ [createEdge(node)],
295
+ false,
296
+ );
297
+ }
298
+
299
+ Object.assign(outputCollection, {
300
+ pageInfo: cloneDeep(outputCollection.pageInfo),
301
+ ...getTotalCount(outputCollection.totalCount, 1),
302
+ });
303
+
304
+ return output;
305
+ }
306
+
307
+ interface TotalCountReturn {
308
+ totalCount?: number;
309
+ }
310
+
311
+ function getTotalCount(
312
+ totalCount: number | undefined,
313
+ additionalCount = 0,
314
+ ): TotalCountReturn {
315
+ return totalCount !== undefined
316
+ ? { totalCount: totalCount + additionalCount }
317
+ : {};
318
+ }
319
+
320
+ export function isAlreadyUpdated(outputCollection: Collection, newNode: Node) {
321
+ let edgesAlreadyUpdated = true;
322
+ let nodesAlreadyUpdated = true;
323
+
324
+ if (outputCollection.edges) {
325
+ edgesAlreadyUpdated = outputCollection.edges.some(edge => {
326
+ return edge.node.id === newNode.id;
327
+ });
328
+ }
329
+
330
+ if (outputCollection.nodes) {
331
+ nodesAlreadyUpdated = outputCollection.nodes.some(node => {
332
+ return node.id === newNode.id;
333
+ });
334
+ }
335
+
336
+ return edgesAlreadyUpdated && nodesAlreadyUpdated;
337
+ }
338
+
339
+ function getUpdatedEdges(
340
+ prevEdges: Edge[],
341
+ nextEdges: Edge[],
342
+ appendToEnd = true,
343
+ ) {
344
+ const newEdges = appendToEnd
345
+ ? [...prevEdges, ...nextEdges]
346
+ : [...nextEdges, ...prevEdges];
347
+ return uniqueEdges(newEdges);
348
+ }
349
+
350
+ function getUpdatedNodes(
351
+ prevNodes: Node[],
352
+ nextNodes: Node[],
353
+ appendToEnd = true,
354
+ ) {
355
+ const newNodes = appendToEnd
356
+ ? [...prevNodes, ...nextNodes]
357
+ : [...nextNodes, ...prevNodes];
358
+ return uniqueNodes(newNodes);
359
+ }
@@ -0,0 +1 @@
1
+ export { useFocusTrap } from "./useFocusTrap";
@@ -0,0 +1,49 @@
1
+ import { Canvas, Meta, Story } from "@storybook/addon-docs";
2
+ import { useState } from "react";
3
+ import { Button } from "@jobber/components/Button";
4
+ import { Content } from "@jobber/components/Content";
5
+ import { Checkbox } from "@jobber/components/Checkbox";
6
+ import { InputText } from "@jobber/components/InputText";
7
+ import { Text } from "@jobber/components/Text";
8
+ import * as hooks from ".";
9
+
10
+ <Meta title="Hooks/useFocusTrap" />
11
+
12
+ # UseFocusTrap
13
+
14
+ Trap focus within a DOM node.
15
+
16
+ Useful for building Modals, lightboxes, menus, bottom sheets, etc. The user will
17
+ be able to navigate the component using `Tab` and `Shift+Tab`.
18
+
19
+ ```ts
20
+ import { useFocusTrap } from "@jobber/hooks";
21
+ ```
22
+
23
+ <Canvas>
24
+ <Story name="useFocusTrap">
25
+ {() => {
26
+ const [checked, setChecked] = useState(false);
27
+ const trapRef = hooks.useFocusTrap(checked);
28
+ return (
29
+ <>
30
+ <Checkbox
31
+ checked={checked}
32
+ onChange={setChecked}
33
+ label="Trap focus"
34
+ />
35
+ <div ref={trapRef} tabIndex={0}>
36
+ <Content>
37
+ {checked}
38
+ <InputText placeholder="First Name" name="firstName" />
39
+ <InputText placeholder="Last Name" name="lastName" />
40
+ <Button label="Submit Form" submit={true} />
41
+ </Content>
42
+ </div>
43
+ </>
44
+ );
45
+ }}
46
+ </Story>
47
+ </Canvas>
48
+
49
+ <ArgsTable of={hooks.useFocusTrap} />
@@ -0,0 +1,66 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { useFocusTrap } from "./useFocusTrap";
5
+
6
+ const targetId = "target";
7
+ const firstFocusableChild = "first-element";
8
+ const lastFocusableChild = "last-element";
9
+
10
+ it("should focus on the ref target on mount", () => {
11
+ const { getByTestId } = render(<TestComponent />);
12
+ expect(getByTestId(targetId)).toHaveFocus();
13
+ });
14
+
15
+ it("should focus on the ref target when tabbing out of the last focusable element and ignore the tabindex'=-1'", () => {
16
+ const { getByTestId } = render(<TestComponent />);
17
+ getByTestId(lastFocusableChild).focus();
18
+ userEvent.tab();
19
+ expect(getByTestId(targetId)).toHaveFocus();
20
+ });
21
+
22
+ it("should focus on the first focusable element", () => {
23
+ const { getByTestId } = render(<TestComponent />);
24
+ userEvent.tab();
25
+ expect(getByTestId(firstFocusableChild)).toHaveFocus();
26
+ });
27
+
28
+ it("should focus on the last focusable element", () => {
29
+ const { getByTestId } = render(<TestComponent />);
30
+ userEvent.tab({ shift: true });
31
+ expect(getByTestId(lastFocusableChild)).toHaveFocus();
32
+ });
33
+
34
+ it("should not trap the tabbing and focus on the first child node", () => {
35
+ const { getByTestId } = render(<TestComponent trap={false} />);
36
+ userEvent.tab();
37
+ expect(getByTestId(targetId).previousElementSibling).toHaveFocus();
38
+ });
39
+
40
+ interface TestComponentProps {
41
+ readonly trap?: boolean;
42
+ }
43
+
44
+ function TestComponent({ trap = true }: TestComponentProps) {
45
+ const testRef = useFocusTrap<HTMLDivElement>(trap);
46
+
47
+ return (
48
+ <>
49
+ <input type="number" />
50
+
51
+ <div ref={testRef} data-testid={targetId} tabIndex={0}>
52
+ <button data-testid={firstFocusableChild}>Click me</button>
53
+ <a href="#"></a>
54
+ <input type="text" />
55
+ <select>
56
+ <option value="A"></option>
57
+ </select>
58
+ <textarea></textarea>
59
+ <span tabIndex={0} data-testId={lastFocusableChild}></span>
60
+ <span tabIndex={-1}></span>
61
+ </div>
62
+
63
+ <input type="calendar" />
64
+ </>
65
+ );
66
+ }
@@ -0,0 +1,64 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * Traps the focus within the children of the ref element.
5
+ *
6
+ * @param active - Turns the focus trapping on or off. Also adds aria-hidden on the
7
+ * body but not the dialog.
8
+ *
9
+ * @returns ref
10
+ */
11
+ export function useFocusTrap<T extends HTMLElement>(active: boolean) {
12
+ // There's an ongoing issue with useRef return type clashing with an element's
13
+ // ref prop type. TLDR: Use null because useRef doesn't expect undefined.
14
+ // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/35572
15
+ const ref = useRef<T>(null);
16
+
17
+ function handleKeyDown(event: KeyboardEvent) {
18
+ if (!(active && ref.current) || event.key !== "Tab") {
19
+ return;
20
+ }
21
+
22
+ const { firstElement, lastElement } = getElements(ref.current);
23
+
24
+ if (event.shiftKey) {
25
+ if (document.activeElement === firstElement) {
26
+ lastElement.focus();
27
+ event.preventDefault();
28
+ }
29
+ } else {
30
+ if (document.activeElement === lastElement) {
31
+ firstElement.focus();
32
+ event.preventDefault();
33
+ }
34
+ }
35
+ }
36
+
37
+ useEffect(() => {
38
+ if (active) {
39
+ ref.current?.focus();
40
+ ref.current?.addEventListener("keydown", handleKeyDown);
41
+ }
42
+
43
+ return () => {
44
+ ref.current?.removeEventListener("keydown", handleKeyDown);
45
+ };
46
+ }, [active]);
47
+
48
+ return ref;
49
+ }
50
+
51
+ function getElements<T extends HTMLElement>(ref: T) {
52
+ const focusables = [
53
+ "button",
54
+ "[href]",
55
+ "input",
56
+ "select",
57
+ "textarea",
58
+ '[tabindex]:not([tabindex="-1"])',
59
+ ];
60
+ const elements = ref.querySelectorAll<HTMLElement>(focusables.join(", "));
61
+ const firstElement = ref;
62
+ const lastElement = elements[elements.length - 1];
63
+ return { firstElement, lastElement };
64
+ }
@@ -0,0 +1 @@
1
+ export { useFormState } from "./useFormState";
@@ -0,0 +1,70 @@
1
+ import { Canvas, Meta, Story } from "@storybook/addon-docs";
2
+ import { Form } from "@jobber/components/Form";
3
+ import { InputText } from "@jobber/components/InputText";
4
+ import { Content } from "@jobber/components/Content";
5
+ import { Button } from "@jobber/components/Button";
6
+ import * as hooks from ".";
7
+
8
+ <Meta title="Hooks/useFormState" />
9
+
10
+ # UseFormState
11
+
12
+ `useFormState` is an extremely simple hook that wraps the
13
+ [useState()](https://reactjs.org/docs/hooks-state.html) hook and supplies some
14
+ default values.
15
+
16
+ `useFormState` should **only** only by used when using a
17
+ [`<Form />`](../?path=/docs/components-form--form) component.
18
+
19
+ ```tsx
20
+ import { useFormState } from "@jobber/hooks";
21
+ ```
22
+
23
+ <Canvas>
24
+ <Story name="useFormState">
25
+ {() => {
26
+ const [formState, setFormState] = hooks.useFormState();
27
+ return (
28
+ <>
29
+ <Form
30
+ onSubmit={() => alert("submitted")}
31
+ onStateChange={setFormState}
32
+ >
33
+ <Content>
34
+ <InputText
35
+ placeholder="First Name"
36
+ name="firstName"
37
+ validations={{
38
+ required: {
39
+ value: true,
40
+ message: "Tell us your name",
41
+ },
42
+ minLength: {
43
+ value: 3,
44
+ message: "Your name is too short.",
45
+ },
46
+ }}
47
+ />
48
+ <InputText
49
+ placeholder="Last Name"
50
+ name="lastName"
51
+ validations={{
52
+ required: {
53
+ value: true,
54
+ message: "Tell us your last name.",
55
+ },
56
+ }}
57
+ />
58
+ <Button
59
+ label="Submit Form"
60
+ submit={true}
61
+ disabled={!formState.isDirty || !formState.isValid}
62
+ />
63
+ </Content>
64
+ </Form>
65
+ <pre>{JSON.stringify(formState, null, 2)}</pre>
66
+ </>
67
+ );
68
+ }}
69
+ </Story>
70
+ </Canvas>
@@ -0,0 +1,10 @@
1
+ import { useState } from "react";
2
+
3
+ export function useFormState() {
4
+ const [formState, setFormState] = useState({
5
+ isDirty: false,
6
+ isValid: true,
7
+ });
8
+
9
+ return [formState, setFormState] as const;
10
+ }
@@ -0,0 +1 @@
1
+ export { useIsMounted } from "./useIsMounted";
@@ -0,0 +1,59 @@
1
+ import { Canvas, Meta, Story } from "@storybook/addon-docs";
2
+ import { useEffect, useState } from "react";
3
+ import { Button } from "@jobber/components/Button";
4
+ import * as hooks from ".";
5
+
6
+ <Meta title="Hooks/useIsMounted" />
7
+
8
+ # UseIsMounted
9
+
10
+ `useIsMounted` should be used on asynchronous calls to ensure the component is
11
+ mounted before manipulating the state of that component. Manipulating the state
12
+ of unmounted components can cause errors and memory leaks.
13
+
14
+ ```tsx
15
+ import { useIsMounted } from "@jobber/hooks";
16
+ ```
17
+
18
+ <Canvas>
19
+ <Story name="useIsMounted">
20
+ {() => {
21
+ function AlertMountedComponent() {
22
+ const isMounted = hooks.useIsMounted();
23
+ useEffect(() => {
24
+ setTimeout(() => {
25
+ if (isMounted.current) {
26
+ // only set state if the component is still mounted
27
+ console.log("The component is mounted");
28
+ } else {
29
+ console.log("The component is not mounted");
30
+ }
31
+ }, 10000);
32
+ }, []);
33
+ return <h4>I am the AlertMountedComponent</h4>;
34
+ }
35
+ const [shouldMount, setShouldMount] = useState(false);
36
+ let component = <></>;
37
+ if (shouldMount) {
38
+ component = <AlertMountedComponent />;
39
+ }
40
+ return (
41
+ <>
42
+ {component}
43
+ <Button
44
+ label={"Mount Component"}
45
+ onClick={() => {
46
+ setShouldMount(true);
47
+ }}
48
+ />
49
+ <Button
50
+ label={"Unmount Component"}
51
+ onClick={() => {
52
+ setShouldMount(false);
53
+ }}
54
+ />
55
+ </>
56
+ );
57
+ }}
58
+ </Story>
59
+ </Canvas>