@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.
- package/package.json +4 -3
- package/src/index.ts +10 -0
- package/src/useAssert/index.ts +1 -0
- package/src/useAssert/useAssert.stories.mdx +32 -0
- package/src/useAssert/useAssert.tsx +19 -0
- package/src/useCollectionQuery/index.ts +1 -0
- package/src/useCollectionQuery/mdxUtils.ts +190 -0
- package/src/useCollectionQuery/test-utilities/index.ts +3 -0
- package/src/useCollectionQuery/test-utilities/mocks.tsx +147 -0
- package/src/useCollectionQuery/test-utilities/queries.ts +95 -0
- package/src/useCollectionQuery/test-utilities/utils.ts +3 -0
- package/src/useCollectionQuery/uniqueEdges.tsx +26 -0
- package/src/useCollectionQuery/uniqueNodes.tsx +12 -0
- package/src/useCollectionQuery/useCollectionQuery.stories.mdx +129 -0
- package/src/useCollectionQuery/useCollectionQuery.test.tsx +419 -0
- package/src/useCollectionQuery/useCollectionQuery.ts +359 -0
- package/src/useFocusTrap/index.ts +1 -0
- package/src/useFocusTrap/useFocusTrap.stories.mdx +49 -0
- package/src/useFocusTrap/useFocusTrap.test.tsx +66 -0
- package/src/useFocusTrap/useFocusTrap.ts +64 -0
- package/src/useFormState/index.ts +1 -0
- package/src/useFormState/useFormState.stories.mdx +70 -0
- package/src/useFormState/useFormState.ts +10 -0
- package/src/useIsMounted/index.ts +1 -0
- package/src/useIsMounted/useIsMounted.stories.mdx +59 -0
- package/src/useIsMounted/useIsMounted.test.tsx +18 -0
- package/src/useIsMounted/useIsMounted.ts +30 -0
- package/src/useLiveAnnounce/index.ts +1 -0
- package/src/useLiveAnnounce/useLiveAnnounce.stories.mdx +38 -0
- package/src/useLiveAnnounce/useLiveAnnounce.test.tsx +55 -0
- package/src/useLiveAnnounce/useLiveAnnounce.tsx +47 -0
- package/src/useOnKeyDown/index.ts +1 -0
- package/src/useOnKeyDown/useOnKeyDown.stories.mdx +67 -0
- package/src/useOnKeyDown/useOnKeyDown.test.tsx +31 -0
- package/src/useOnKeyDown/useOnKeyDown.ts +52 -0
- package/src/usePasswordStrength/index.ts +1 -0
- package/src/usePasswordStrength/usePasswordStrength.stories.mdx +51 -0
- package/src/usePasswordStrength/usePasswordStrength.ts +21 -0
- package/src/useRefocusOnActivator/index.ts +1 -0
- package/src/useRefocusOnActivator/useRefocusOnActivator.stories.mdx +39 -0
- package/src/useRefocusOnActivator/useRefocusOnActivator.ts +26 -0
- package/src/useResizeObserver/index.ts +1 -0
- package/src/useResizeObserver/useResizeObserver.stories.mdx +134 -0
- 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 @@
|
|
|
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>
|