/
descendants.js
217 lines (192 loc) · 7.05 KB
/
descendants.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import * as React from 'react';
import PropTypes from 'prop-types';
/** Credit: https://github.com/reach/reach-ui/blob/86a046f54d53b6420e392b3fa56dd991d9d4e458/packages/descendants/README.md
* Modified slightly to suit our purposes.
*/
// To replace with .findIndex() once we stop IE 11 support.
function findIndex(array, comp) {
for (let i = 0; i < array.length; i += 1) {
if (comp(array[i])) {
return i;
}
}
return -1;
}
const useEnhancedEffect =
typeof window !== 'undefined' && process.env.NODE_ENV !== 'test'
? React.useLayoutEffect
: React.useEffect;
const DescendantContext = React.createContext({});
if (process.env.NODE_ENV !== 'production') {
DescendantContext.displayName = 'DescendantContext';
}
function usePrevious(value) {
const ref = React.useRef(null);
React.useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
const noop = () => {};
/**
* This hook registers our descendant by passing it into an array. We can then
* search that array by to find its index when registering it in the component.
* We use this for focus management, keyboard navigation, and typeahead
* functionality for some components.
*
* The hook accepts the element node
*
* Our main goals with this are:
* 1) maximum composability,
* 2) minimal API friction
* 3) SSR compatibility*
* 4) concurrent safe
* 5) index always up-to-date with the tree despite changes
* 6) works with memoization of any component in the tree (hopefully)
*
* * As for SSR, the good news is that we don't actually need the index on the
* server for most use-cases, as we are only using it to determine the order of
* composed descendants for keyboard navigation.
*/
export function useDescendant(descendant) {
const [, forceUpdate] = React.useState();
const {
registerDescendant = noop,
unregisterDescendant = noop,
descendants = [],
parentId = null,
} = React.useContext(DescendantContext);
// This will initially return -1 because we haven't registered the descendant
// on the first render. After we register, this will then return the correct
// index on the following render and we will re-register descendants
// so that everything is up-to-date before the user interacts with a
// collection.
const index = findIndex(descendants, (item) => item.element === descendant.element);
const previousDescendants = usePrevious(descendants);
// We also need to re-register descendants any time ANY of the other
// descendants have changed. My brain was melting when I wrote this and it
// feels a little off, but checking in render and usin // effect's dependency array works well enough.g the result in the
const someDescendantsHaveChanged = descendants.some((d, i) => {
return d.element !== previousDescendants?.[i]?.element;
});
// Prevent any flashing
useEnhancedEffect(() => {
if (!descendant.element) forceUpdate({});
registerDescendant({
...descendant,
index,
});
return () => unregisterDescendant(descendant.element);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
registerDescendant,
unregisterDescendant,
index,
someDescendantsHaveChanged,
// eslint-disable-next-line react-hooks/exhaustive-deps
...Object.values(descendant),
]);
return { parentId, index };
}
export function useDescendantsInit() {
return React.useState([]);
}
export function DescendantProvider(props) {
const { children, items, set, id } = props;
const registerDescendant = React.useCallback(
({ element, ...rest }) => {
if (!element) {
return;
}
set((oldItems) => {
let newItems;
if (oldItems.length === 0) {
// If there are no items, register at index 0 and bail.
newItems = [
...oldItems,
{
...rest,
element,
index: 0,
},
];
} else if (oldItems.find((item) => item.element === element)) {
// If the element is already registered, just use the same array
newItems = oldItems;
} else {
// When registering a descendant, we need to make sure we insert in
// into the array in the same order that it appears in the DOM. So as
// new descendants are added or maybe some are removed, we always know
// that the array is up-to-date and correct.
//
// So here we look at our registered descendants and see if the new
// element we are adding appears earlier than an existing descendant's
// DOM node via `node.compareDocumentPosition`. If it does, we insert
// the new element at this index. Because `registerDescendant` will be
// called in an effect every time the descendants state value changes,
// we should be sure that this index is accurate when descendent
// elements come or go from our component.
const index = findIndex(oldItems, (item) => {
if (!item.element || !element) {
return false;
}
// Does this element's DOM node appear before another item in the
// array in our DOM tree? If so, return true to grab the index at
// this point in the array so we know where to insert the new
// element.
return Boolean(
// eslint-disable-next-line no-bitwise
item.element.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING,
);
});
const newItem = {
...rest,
element,
index,
};
// If an index is not found we will push the element to the end.
if (index === -1) {
newItems = [...oldItems, newItem];
} else {
newItems = [...oldItems.slice(0, index), newItem, ...oldItems.slice(index)];
}
}
return newItems.map((item, index) => ({ ...item, index }));
});
},
// set is a state setter initialized by the useDescendants hook.
// We can safely ignore the lint warning here because it will not change
// between renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const unregisterDescendant = React.useCallback(
(element) => {
if (!element) {
return;
}
set((oldItems) => oldItems.filter((item) => element !== item.element));
},
// set is a state setter initialized by the useDescendants hook.
// We can safely ignore the lint warning here because it will not change
// between renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const value = React.useMemo(
() => ({
descendants: items,
registerDescendant,
unregisterDescendant,
parentId: id,
}),
[items, registerDescendant, unregisterDescendant, id],
);
return <DescendantContext.Provider value={value}>{children}</DescendantContext.Provider>;
}
DescendantProvider.propTypes = {
children: PropTypes.node,
id: PropTypes.string,
items: PropTypes.array,
set: PropTypes.func,
};