@samitouri / QOS-React / commits / 98dba66ee1

Add ⎇ + arrow key navigation to DevTools (#19741)

⎇ + left/right navigates between owners (similar to owners tree) and ⎇ + up/down navigations between siblings.

Brian Vaughn committed Sep 1, 2020 at 20:03 UTC 98dba66ee1e5fcf3b88a0fe0331c8d84e9ddfbed
4 files changed +451 -14
packages/react-devtools-shared/src/__tests__/__snapshots__/treeContext-test.js.snap
+89
@@ -15,6 +15,7 @@ Object {
15 "numElements": 5,
16 "ownerFlatTree": null,
17 "ownerID": null,
18 + "ownerSubtreeLeafElementID": null,
19 "searchIndex": null,
20 "searchResults": Array [],
21 "searchText": "",
@@ -73,6 +74,7 @@ Object {
74 },
75 ],
76 "ownerID": 4,
77 + "ownerSubtreeLeafElementID": null,
78 "searchIndex": null,
79 "searchResults": Array [],
80 "searchText": "",
@@ -131,6 +133,7 @@ Object {
133 },
134 ],
135 "ownerID": 4,
136 + "ownerSubtreeLeafElementID": null,
137 "searchIndex": null,
138 "searchResults": Array [],
139 "searchText": "",
@@ -145,6 +148,7 @@ Object {
148 "numElements": 5,
149 "ownerFlatTree": null,
150 "ownerID": null,
151 + "ownerSubtreeLeafElementID": null,
152 "searchIndex": null,
153 "searchResults": Array [],
154 "searchText": "",
@@ -165,6 +169,7 @@ Object {
169 "numElements": 2,
170 "ownerFlatTree": null,
171 "ownerID": null,
172 + "ownerSubtreeLeafElementID": null,
173 "searchIndex": null,
174 "searchResults": Array [],
175 "searchText": "",
@@ -193,6 +198,7 @@ Object {
198 },
199 ],
200 "ownerID": 3,
201 + "ownerSubtreeLeafElementID": null,
202 "searchIndex": null,
203 "searchResults": Array [],
204 "searchText": "",
@@ -207,6 +213,7 @@ Object {
213 "numElements": 1,
214 "ownerFlatTree": null,
215 "ownerID": null,
216 + "ownerSubtreeLeafElementID": null,
217 "searchIndex": null,
218 "searchResults": Array [],
219 "searchText": "",
@@ -235,6 +242,7 @@ Object {
242 },
243 ],
244 "ownerID": 2,
245 + "ownerSubtreeLeafElementID": null,
246 "searchIndex": null,
247 "searchResults": Array [],
248 "searchText": "",
@@ -249,6 +257,7 @@ Object {
257 "numElements": 0,
258 "ownerFlatTree": null,
259 "ownerID": null,
260 + "ownerSubtreeLeafElementID": null,
261 "searchIndex": null,
262 "searchResults": Array [],
263 "searchText": "",
@@ -271,6 +280,7 @@ Object {
280 "numElements": 4,
281 "ownerFlatTree": null,
282 "ownerID": null,
283 + "ownerSubtreeLeafElementID": null,
284 "searchIndex": null,
285 "searchResults": Array [],
286 "searchText": "",
@@ -328,6 +338,7 @@ Object {
338 },
339 ],
340 "ownerID": 3,
341 + "ownerSubtreeLeafElementID": null,
342 "searchIndex": null,
343 "searchResults": Array [],
344 "searchText": "",
@@ -371,6 +382,7 @@ Object {
382 },
383 ],
384 "ownerID": 3,
385 + "ownerSubtreeLeafElementID": null,
386 "searchIndex": null,
387 "searchResults": Array [],
388 "searchText": "",
@@ -399,6 +411,7 @@ Object {
411 },
412 ],
413 "ownerID": 3,
414 + "ownerSubtreeLeafElementID": null,
415 "searchIndex": null,
416 "searchResults": Array [],
417 "searchText": "",
@@ -421,6 +434,7 @@ Object {
434 "numElements": 4,
435 "ownerFlatTree": null,
436 "ownerID": null,
437 + "ownerSubtreeLeafElementID": null,
438 "searchIndex": null,
439 "searchResults": Array [],
440 "searchText": "",
@@ -478,6 +492,7 @@ Object {
492 },
493 ],
494 "ownerID": 3,
495 + "ownerSubtreeLeafElementID": null,
496 "searchIndex": null,
497 "searchResults": Array [],
498 "searchText": "",
@@ -492,6 +507,7 @@ Object {
507 "numElements": 4,
508 "ownerFlatTree": null,
509 "ownerID": null,
510 + "ownerSubtreeLeafElementID": null,
511 "searchIndex": null,
512 "searchResults": Array [],
513 "searchText": "",
@@ -512,6 +528,7 @@ Object {
528 "numElements": 2,
529 "ownerFlatTree": null,
530 "ownerID": null,
531 + "ownerSubtreeLeafElementID": null,
532 "searchIndex": null,
533 "searchResults": Array [],
534 "searchText": "",
@@ -526,6 +543,7 @@ Object {
543 "numElements": 2,
544 "ownerFlatTree": null,
545 "ownerID": null,
546 + "ownerSubtreeLeafElementID": null,
547 "searchIndex": 0,
548 "searchResults": Array [
549 3,
@@ -542,6 +560,7 @@ Object {
560 "numElements": 3,
561 "ownerFlatTree": null,
562 "ownerID": null,
563 + "ownerSubtreeLeafElementID": null,
564 "searchIndex": 0,
565 "searchResults": Array [
566 3,
@@ -567,6 +586,7 @@ Object {
586 "numElements": 4,
587 "ownerFlatTree": null,
588 "ownerID": null,
589 + "ownerSubtreeLeafElementID": null,
590 "searchIndex": null,
591 "searchResults": Array [],
592 "searchText": "",
@@ -581,6 +601,7 @@ Object {
601 "numElements": 4,
602 "ownerFlatTree": null,
603 "ownerID": null,
604 + "ownerSubtreeLeafElementID": null,
605 "searchIndex": 0,
606 "searchResults": Array [
607 3,
@@ -598,6 +619,7 @@ Object {
619 "numElements": 4,
620 "ownerFlatTree": null,
621 "ownerID": null,
622 + "ownerSubtreeLeafElementID": null,
623 "searchIndex": 0,
624 "searchResults": Array [
625 2,
@@ -614,6 +636,7 @@ Object {
636 "numElements": 4,
637 "ownerFlatTree": null,
638 "ownerID": null,
639 + "ownerSubtreeLeafElementID": null,
640 "searchIndex": null,
641 "searchResults": Array [],
642 "searchText": "y",
@@ -628,6 +651,7 @@ Object {
651 "numElements": 4,
652 "ownerFlatTree": null,
653 "ownerID": null,
654 + "ownerSubtreeLeafElementID": null,
655 "searchIndex": 0,
656 "searchResults": Array [
657 5,
@@ -651,6 +675,7 @@ Object {
675 "numElements": 3,
676 "ownerFlatTree": null,
677 "ownerID": null,
678 + "ownerSubtreeLeafElementID": null,
679 "searchIndex": null,
680 "searchResults": Array [],
681 "searchText": "",
@@ -665,6 +690,7 @@ Object {
690 "numElements": 3,
691 "ownerFlatTree": null,
692 "ownerID": null,
693 + "ownerSubtreeLeafElementID": null,
694 "searchIndex": 0,
695 "searchResults": Array [
696 3,
@@ -682,6 +708,7 @@ Object {
708 "numElements": 3,
709 "ownerFlatTree": null,
710 "ownerID": null,
711 + "ownerSubtreeLeafElementID": null,
712 "searchIndex": 1,
713 "searchResults": Array [
714 3,
@@ -699,6 +726,7 @@ Object {
726 "numElements": 2,
727 "ownerFlatTree": null,
728 "ownerID": null,
729 + "ownerSubtreeLeafElementID": null,
730 "searchIndex": 0,
731 "searchResults": Array [
732 3,
@@ -723,6 +751,7 @@ Object {
751 "numElements": 4,
752 "ownerFlatTree": null,
753 "ownerID": null,
754 + "ownerSubtreeLeafElementID": null,
755 "searchIndex": null,
756 "searchResults": Array [],
757 "searchText": "",
@@ -737,6 +766,7 @@ Object {
766 "numElements": 4,
767 "ownerFlatTree": null,
768 "ownerID": null,
769 + "ownerSubtreeLeafElementID": null,
770 "searchIndex": 0,
771 "searchResults": Array [
772 3,
@@ -755,6 +785,7 @@ Object {
785 "numElements": 4,
786 "ownerFlatTree": null,
787 "ownerID": null,
788 + "ownerSubtreeLeafElementID": null,
789 "searchIndex": 1,
790 "searchResults": Array [
791 3,
@@ -773,6 +804,7 @@ Object {
804 "numElements": 4,
805 "ownerFlatTree": null,
806 "ownerID": null,
807 + "ownerSubtreeLeafElementID": null,
808 "searchIndex": 2,
809 "searchResults": Array [
810 3,
@@ -791,6 +823,7 @@ Object {
823 "numElements": 4,
824 "ownerFlatTree": null,
825 "ownerID": null,
826 + "ownerSubtreeLeafElementID": null,
827 "searchIndex": 1,
828 "searchResults": Array [
829 3,
@@ -809,6 +842,7 @@ Object {
842 "numElements": 4,
843 "ownerFlatTree": null,
844 "ownerID": null,
845 + "ownerSubtreeLeafElementID": null,
846 "searchIndex": 0,
847 "searchResults": Array [
848 3,
@@ -827,6 +861,7 @@ Object {
861 "numElements": 4,
862 "ownerFlatTree": null,
863 "ownerID": null,
864 + "ownerSubtreeLeafElementID": null,
865 "searchIndex": 2,
866 "searchResults": Array [
867 3,
@@ -845,6 +880,7 @@ Object {
880 "numElements": 4,
881 "ownerFlatTree": null,
882 "ownerID": null,
883 + "ownerSubtreeLeafElementID": null,
884 "searchIndex": 0,
885 "searchResults": Array [
886 3,
@@ -871,6 +907,7 @@ Object {
907 "numElements": 4,
908 "ownerFlatTree": null,
909 "ownerID": null,
910 + "ownerSubtreeLeafElementID": null,
911 "searchIndex": null,
912 "searchResults": Array [],
913 "searchText": "",
@@ -885,6 +922,7 @@ Object {
922 "numElements": 4,
923 "ownerFlatTree": null,
924 "ownerID": null,
925 + "ownerSubtreeLeafElementID": null,
926 "searchIndex": null,
927 "searchResults": Array [],
928 "searchText": "",
@@ -899,6 +937,7 @@ Object {
937 "numElements": 2,
938 "ownerFlatTree": null,
939 "ownerID": null,
940 + "ownerSubtreeLeafElementID": null,
941 "searchIndex": null,
942 "searchResults": Array [],
943 "searchText": "",
@@ -913,6 +952,7 @@ Object {
952 "numElements": 0,
953 "ownerFlatTree": null,
954 "ownerID": null,
955 + "ownerSubtreeLeafElementID": null,
956 "searchIndex": null,
957 "searchResults": Array [],
958 "searchText": "",
@@ -921,6 +961,37 @@ Object {
961 }
962 `;
963
964 +exports[`TreeListContext tree state should navigate next/previous sibling and skip over children in between: 0: mount 1`] = `
965 +[root]
966 + ▾ <Grandparent>
967 + ▾ <Parent>
968 + <Child key="0">
969 + ▾ <Parent>
970 + <Child key="0">
971 + <Child key="1">
972 + <Child key="2">
973 + ▾ <Parent>
974 + <Child key="0">
975 + <Child key="1">
976 +`;
977 +
978 +exports[`TreeListContext tree state should navigate the owner hierarchy: 0: mount 1`] = `
979 +[root]
980 + ▾ <Grandparent>
981 + ▾ <Wrapper>
982 + ▾ <Parent>
983 + <Child key="0">
984 + ▾ <Wrapper>
985 + ▾ <Parent>
986 + <Child key="0">
987 + <Child key="1">
988 + <Child key="2">
989 + ▾ <Wrapper>
990 + ▾ <Parent>
991 + <Child key="0">
992 + <Child key="1">
993 +`;
994 +
995 exports[`TreeListContext tree state should select child elements: 0: mount 1`] = `
996 [root]
997 ▾ <Grandparent>
@@ -938,6 +1009,7 @@ Object {
1009 "numElements": 7,
1010 "ownerFlatTree": null,
1011 "ownerID": null,
1012 + "ownerSubtreeLeafElementID": null,
1013 "searchIndex": null,
1014 "searchResults": Array [],
1015 "searchText": "",
@@ -952,6 +1024,7 @@ Object {
1024 "numElements": 7,
1025 "ownerFlatTree": null,
1026 "ownerID": null,
1027 + "ownerSubtreeLeafElementID": null,
1028 "searchIndex": null,
1029 "searchResults": Array [],
1030 "searchText": "",
@@ -966,6 +1039,7 @@ Object {
1039 "numElements": 7,
1040 "ownerFlatTree": null,
1041 "ownerID": null,
1042 + "ownerSubtreeLeafElementID": null,
1043 "searchIndex": null,
1044 "searchResults": Array [],
1045 "searchText": "",
@@ -980,6 +1054,7 @@ Object {
1054 "numElements": 7,
1055 "ownerFlatTree": null,
1056 "ownerID": null,
1057 + "ownerSubtreeLeafElementID": null,
1058 "searchIndex": null,
1059 "searchResults": Array [],
1060 "searchText": "",
@@ -1005,6 +1080,7 @@ Object {
1080 "numElements": 7,
1081 "ownerFlatTree": null,
1082 "ownerID": null,
1083 + "ownerSubtreeLeafElementID": null,
1084 "searchIndex": null,
1085 "searchResults": Array [],
1086 "searchText": "",
@@ -1019,6 +1095,7 @@ Object {
1095 "numElements": 7,
1096 "ownerFlatTree": null,
1097 "ownerID": null,
1098 + "ownerSubtreeLeafElementID": null,
1099 "searchIndex": null,
1100 "searchResults": Array [],
1101 "searchText": "",
@@ -1033,6 +1110,7 @@ Object {
1110 "numElements": 7,
1111 "ownerFlatTree": null,
1112 "ownerID": null,
1113 + "ownerSubtreeLeafElementID": null,
1114 "searchIndex": null,
1115 "searchResults": Array [],
1116 "searchText": "",
@@ -1047,6 +1125,7 @@ Object {
1125 "numElements": 7,
1126 "ownerFlatTree": null,
1127 "ownerID": null,
1128 + "ownerSubtreeLeafElementID": null,
1129 "searchIndex": null,
1130 "searchResults": Array [],
1131 "searchText": "",
@@ -1069,6 +1148,7 @@ Object {
1148 "numElements": 4,
1149 "ownerFlatTree": null,
1150 "ownerID": null,
1151 + "ownerSubtreeLeafElementID": null,
1152 "searchIndex": null,
1153 "searchResults": Array [],
1154 "searchText": "",
@@ -1083,6 +1163,7 @@ Object {
1163 "numElements": 4,
1164 "ownerFlatTree": null,
1165 "ownerID": null,
1166 + "ownerSubtreeLeafElementID": null,
1167 "searchIndex": null,
1168 "searchResults": Array [],
1169 "searchText": "",
@@ -1097,6 +1178,7 @@ Object {
1178 "numElements": 4,
1179 "ownerFlatTree": null,
1180 "ownerID": null,
1181 + "ownerSubtreeLeafElementID": null,
1182 "searchIndex": null,
1183 "searchResults": Array [],
1184 "searchText": "",
@@ -1111,6 +1193,7 @@ Object {
1193 "numElements": 4,
1194 "ownerFlatTree": null,
1195 "ownerID": null,
1196 + "ownerSubtreeLeafElementID": null,
1197 "searchIndex": null,
1198 "searchResults": Array [],
1199 "searchText": "",
@@ -1125,6 +1208,7 @@ Object {
1208 "numElements": 4,
1209 "ownerFlatTree": null,
1210 "ownerID": null,
1211 + "ownerSubtreeLeafElementID": null,
1212 "searchIndex": null,
1213 "searchResults": Array [],
1214 "searchText": "",
@@ -1139,6 +1223,7 @@ Object {
1223 "numElements": 4,
1224 "ownerFlatTree": null,
1225 "ownerID": null,
1226 + "ownerSubtreeLeafElementID": null,
1227 "searchIndex": null,
1228 "searchResults": Array [],
1229 "searchText": "",
@@ -1153,6 +1238,7 @@ Object {
1238 "numElements": 4,
1239 "ownerFlatTree": null,
1240 "ownerID": null,
1241 + "ownerSubtreeLeafElementID": null,
1242 "searchIndex": null,
1243 "searchResults": Array [],
1244 "searchText": "",
@@ -1167,6 +1253,7 @@ Object {
1253 "numElements": 4,
1254 "ownerFlatTree": null,
1255 "ownerID": null,
1256 + "ownerSubtreeLeafElementID": null,
1257 "searchIndex": null,
1258 "searchResults": Array [],
1259 "searchText": "",
@@ -1181,6 +1268,7 @@ Object {
1268 "numElements": 4,
1269 "ownerFlatTree": null,
1270 "ownerID": null,
1271 + "ownerSubtreeLeafElementID": null,
1272 "searchIndex": null,
1273 "searchResults": Array [],
1274 "searchText": "",
@@ -1195,6 +1283,7 @@ Object {
1283 "numElements": 4,
1284 "ownerFlatTree": null,
1285 "ownerID": null,
1286 + "ownerSubtreeLeafElementID": null,
1287 "searchIndex": null,
1288 "searchResults": Array [],
1289 "searchText": "",
packages/react-devtools-shared/src/__tests__/treeContext-test.js
+202
@@ -268,6 +268,208 @@ describe('TreeListContext', () => {
268
269 done();
270 });
271 +
272 + it('should navigate next/previous sibling and skip over children in between', () => {
273 + const Grandparent = () => (
274 + <React.Fragment>
275 + <Parent numChildren={1} />
276 + <Parent numChildren={3} />
277 + <Parent numChildren={2} />
278 + </React.Fragment>
279 + );
280 + const Parent = ({numChildren}) =>
281 + new Array(numChildren)
282 + .fill(true)
283 + .map((_, index) => <Child key={index} />);
284 + const Child = () => null;
285 +
286 + utils.act(() =>
287 + ReactDOM.render(<Grandparent />, document.createElement('div')),
288 + );
289 +
290 + /*
291 + * 0 ▾ <Grandparent>
292 + * 1 ▾ <Parent>
293 + * 2 <Child key="0">
294 + * 3 ▾ <Parent>
295 + * 4 <Child key="0">
296 + * 5 <Child key="1">
297 + * 6 <Child key="2">
298 + * 7 ▾ <Parent>
299 + * 8 <Child key="0">
300 + * 9 <Child key="1">
301 + */
302 +
303 + expect(store).toMatchSnapshot('0: mount');
304 +
305 + let renderer;
306 + utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
307 +
308 + const firstParentID = ((store.getElementIDAtIndex(1): any): number);
309 +
310 + utils.act(() =>
311 + dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: firstParentID}),
312 + );
313 + utils.act(() => renderer.update(<Contexts />));
314 + expect(state.selectedElementIndex).toBe(1);
315 +
316 + utils.act(() => dispatch({type: 'SELECT_NEXT_SIBLING_IN_TREE'}));
317 + utils.act(() => renderer.update(<Contexts />));
318 + expect(state.selectedElementIndex).toBe(3);
319 +
320 + utils.act(() => dispatch({type: 'SELECT_NEXT_SIBLING_IN_TREE'}));
321 + utils.act(() => renderer.update(<Contexts />));
322 + expect(state.selectedElementIndex).toBe(7);
323 +
324 + utils.act(() => dispatch({type: 'SELECT_NEXT_SIBLING_IN_TREE'}));
325 + utils.act(() => renderer.update(<Contexts />));
326 + expect(state.selectedElementIndex).toBe(1);
327 +
328 + utils.act(() => dispatch({type: 'SELECT_PREVIOUS_SIBLING_IN_TREE'}));
329 + utils.act(() => renderer.update(<Contexts />));
330 + expect(state.selectedElementIndex).toBe(7);
331 +
332 + utils.act(() => dispatch({type: 'SELECT_PREVIOUS_SIBLING_IN_TREE'}));
333 + utils.act(() => renderer.update(<Contexts />));
334 + expect(state.selectedElementIndex).toBe(3);
335 +
336 + utils.act(() => dispatch({type: 'SELECT_PREVIOUS_SIBLING_IN_TREE'}));
337 + utils.act(() => renderer.update(<Contexts />));
338 + expect(state.selectedElementIndex).toBe(1);
339 + });
340 +
341 + it('should navigate the owner hierarchy', () => {
342 + const Wrapper = ({children}) => children;
343 + const Grandparent = () => (
344 + <React.Fragment>
345 + <Wrapper>
346 + <Parent numChildren={1} />
347 + </Wrapper>
348 + <Wrapper>
349 + <Parent numChildren={3} />
350 + </Wrapper>
351 + <Wrapper>
352 + <Parent numChildren={2} />
353 + </Wrapper>
354 + </React.Fragment>
355 + );
356 + const Parent = ({numChildren}) =>
357 + new Array(numChildren)
358 + .fill(true)
359 + .map((_, index) => <Child key={index} />);
360 + const Child = () => null;
361 +
362 + utils.act(() =>
363 + ReactDOM.render(<Grandparent />, document.createElement('div')),
364 + );
365 +
366 + /*
367 + * 0 ▾ <Grandparent>
368 + * 1 ▾ <Wrapper>
369 + * 2 ▾ <Parent>
370 + * 3 <Child key="0">
371 + * 4 ▾ <Wrapper>
372 + * 5 ▾ <Parent>
373 + * 6 <Child key="0">
374 + * 7 <Child key="1">
375 + * 8 <Child key="2">
376 + * 9 ▾ <Wrapper>
377 + * 10 ▾ <Parent>
378 + * 11 <Child key="0">
379 + * 12 <Child key="1">
380 + */
381 +
382 + expect(store).toMatchSnapshot('0: mount');
383 +
384 + let renderer;
385 + utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
386 +
387 + const childID = ((store.getElementIDAtIndex(7): any): number);
388 + utils.act(() =>
389 + dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: childID}),
390 + );
391 + utils.act(() => renderer.update(<Contexts />));
392 + expect(state.ownerSubtreeLeafElementID).toBeNull();
393 + expect(state.selectedElementIndex).toBe(7);
394 +
395 + // Basic navigation test
396 + utils.act(() =>
397 + dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'}),
398 + );
399 + utils.act(() => renderer.update(<Contexts />));
400 + expect(state.ownerSubtreeLeafElementID).toBe(childID);
401 + expect(state.selectedElementIndex).toBe(5);
402 +
403 + utils.act(() =>
404 + dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'}),
405 + );
406 + utils.act(() => renderer.update(<Contexts />));
407 + expect(state.selectedElementIndex).toBe(0);
408 +
409 + utils.act(() =>
410 + dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'}),
411 + );
412 + utils.act(() => renderer.update(<Contexts />));
413 + expect(state.selectedElementIndex).toBe(0); // noop since we're at the top
414 +
415 + utils.act(() =>
416 + dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'}),
417 + );
418 + utils.act(() => renderer.update(<Contexts />));
419 + expect(state.selectedElementIndex).toBe(5);
420 +
421 + utils.act(() =>
422 + dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'}),
423 + );
424 + utils.act(() => renderer.update(<Contexts />));
425 + expect(state.selectedElementIndex).toBe(7);
426 +
427 + utils.act(() =>
428 + dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'}),
429 + );
430 + utils.act(() => renderer.update(<Contexts />));
431 + expect(state.selectedElementIndex).toBe(7); // noop since we're at the leaf node
432 +
433 + // Other navigational actions should clear out the temporary owner chain.
434 + utils.act(() => dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'}));
435 + utils.act(() => renderer.update(<Contexts />));
436 + expect(state.selectedElementIndex).toBe(6);
437 + expect(state.ownerSubtreeLeafElementID).toBeNull();
438 +
439 + const parentID = ((store.getElementIDAtIndex(5): any): number);
440 + utils.act(() =>
441 + dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: parentID}),
442 + );
443 + utils.act(() => renderer.update(<Contexts />));
444 + expect(state.ownerSubtreeLeafElementID).toBeNull();
445 + expect(state.selectedElementIndex).toBe(5);
446 +
447 + // It should not be possible to navigate beyond the owner chain leaf.
448 + utils.act(() =>
449 + dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'}),
450 + );
451 + utils.act(() => renderer.update(<Contexts />));
452 + expect(state.ownerSubtreeLeafElementID).toBe(parentID);
453 + expect(state.selectedElementIndex).toBe(0);
454 +
455 + utils.act(() =>
456 + dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'}),
457 + );
458 + utils.act(() => renderer.update(<Contexts />));
459 + expect(state.selectedElementIndex).toBe(0); // noop since we're at the top
460 +
461 + utils.act(() =>
462 + dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'}),
463 + );
464 + utils.act(() => renderer.update(<Contexts />));
465 + expect(state.selectedElementIndex).toBe(5);
466 +
467 + utils.act(() =>
468 + dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'}),
469 + );
470 + utils.act(() => renderer.update(<Contexts />));
471 + expect(state.selectedElementIndex).toBe(5); // noop since we're at the leaf node
472 + });
473 });
474
475 describe('search state', () => {
packages/react-devtools-shared/src/devtools/views/Components/Tree.js
+26 -8
@@ -130,7 +130,11 @@ export default function Tree(props: Props) {
130 switch (event.key) {
131 case 'ArrowDown':
132 event.preventDefault();
133 - dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'});
133 + if (event.altKey) {
134 + dispatch({type: 'SELECT_NEXT_SIBLING_IN_TREE'});
135 + } else {
136 + dispatch({type: 'SELECT_NEXT_ELEMENT_IN_TREE'});
137 + }
138 break;
139 case 'ArrowLeft':
140 event.preventDefault();
@@ -139,10 +143,16 @@ export default function Tree(props: Props) {
143 ? store.getElementByID(selectedElementID)
144 : null;
145 if (element !== null) {
142 - if (element.children.length > 0 && !element.isCollapsed) {
143 - store.toggleIsCollapsed(element.id, true);
146 + if (event.altKey) {
147 + if (element.ownerID !== null) {
148 + dispatch({type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE'});
149 + }
150 } else {
145 - dispatch({type: 'SELECT_PARENT_ELEMENT_IN_TREE'});
151 + if (element.children.length > 0 && !element.isCollapsed) {
152 + store.toggleIsCollapsed(element.id, true);
153 + } else {
154 + dispatch({type: 'SELECT_PARENT_ELEMENT_IN_TREE'});
155 + }
156 }
157 }
158 break;
@@ -153,16 +163,24 @@ export default function Tree(props: Props) {
163 ? store.getElementByID(selectedElementID)
164 : null;
165 if (element !== null) {
156 - if (element.children.length > 0 && element.isCollapsed) {
157 - store.toggleIsCollapsed(element.id, false);
166 + if (event.altKey) {
167 + dispatch({type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE'});
168 } else {
159 - dispatch({type: 'SELECT_CHILD_ELEMENT_IN_TREE'});
169 + if (element.children.length > 0 && element.isCollapsed) {
170 + store.toggleIsCollapsed(element.id, false);
171 + } else {
172 + dispatch({type: 'SELECT_CHILD_ELEMENT_IN_TREE'});
173 + }
174 }
175 }
176 break;
177 case 'ArrowUp':
178 event.preventDefault();
165 - dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'});
179 + if (event.altKey) {
180 + dispatch({type: 'SELECT_PREVIOUS_SIBLING_IN_TREE'});
181 + } else {
182 + dispatch({type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE'});
183 + }
184 break;
185 default:
186 return;
packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js
+134 -6
@@ -49,6 +49,7 @@ import type {Element} from './types';
49 export type StateContext = {|
50 // Tree
51 numElements: number,
52 + ownerSubtreeLeafElementID: number | null,
53 selectedElementID: number | null,
54 selectedElementIndex: number | null,
55
@@ -92,15 +93,27 @@ type ACTION_SELECT_ELEMENT_BY_ID = {|
93 type ACTION_SELECT_NEXT_ELEMENT_IN_TREE = {|
94 type: 'SELECT_NEXT_ELEMENT_IN_TREE',
95 |};
96 +type ACTION_SELECT_NEXT_SIBLING_IN_TREE = {|
97 + type: 'SELECT_NEXT_SIBLING_IN_TREE',
98 +|};
99 +type ACTION_SELECT_OWNER = {|
100 + type: 'SELECT_OWNER',
101 + payload: number,
102 +|};
103 type ACTION_SELECT_PARENT_ELEMENT_IN_TREE = {|
104 type: 'SELECT_PARENT_ELEMENT_IN_TREE',
105 |};
106 type ACTION_SELECT_PREVIOUS_ELEMENT_IN_TREE = {|
107 type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE',
108 |};
101 -type ACTION_SELECT_OWNER = {|
102 - type: 'SELECT_OWNER',
103 - payload: number,
109 +type ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE = {|
110 + type: 'SELECT_PREVIOUS_SIBLING_IN_TREE',
111 +|};
112 +type ACTION_SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE = {|
113 + type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE',
114 +|};
115 +type ACTION_SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE = {|
116 + type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE',
117 |};
118 type ACTION_SET_SEARCH_TEXT = {|
119 type: 'SET_SEARCH_TEXT',
@@ -119,9 +132,13 @@ type Action =
132 | ACTION_SELECT_ELEMENT_AT_INDEX
133 | ACTION_SELECT_ELEMENT_BY_ID
134 | ACTION_SELECT_NEXT_ELEMENT_IN_TREE
135 + | ACTION_SELECT_NEXT_SIBLING_IN_TREE
136 + | ACTION_SELECT_OWNER
137 | ACTION_SELECT_PARENT_ELEMENT_IN_TREE
138 | ACTION_SELECT_PREVIOUS_ELEMENT_IN_TREE
124 - | ACTION_SELECT_OWNER
139 + | ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE
140 + | ACTION_SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE
141 + | ACTION_SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE
142 | ACTION_SET_SEARCH_TEXT
143 | ACTION_UPDATE_INSPECTED_ELEMENT_ID;
144
@@ -140,6 +157,7 @@ TreeDispatcherContext.displayName = 'TreeDispatcherContext';
157 type State = {|
158 // Tree
159 numElements: number,
160 + ownerSubtreeLeafElementID: number | null,
161 selectedElementID: number | null,
162 selectedElementIndex: number | null,
163
@@ -157,7 +175,12 @@ type State = {|
175 |};
176
177 function reduceTreeState(store: Store, state: State, action: Action): State {
160 - let {numElements, selectedElementIndex, selectedElementID} = state;
178 + let {
179 + numElements,
180 + ownerSubtreeLeafElementID,
181 + selectedElementIndex,
182 + selectedElementID,
183 + } = state;
184 const ownerID = state.ownerID;
185
186 let lookupIDForIndex = true;
@@ -187,6 +210,8 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
210 }
211 break;
212 case 'SELECT_CHILD_ELEMENT_IN_TREE':
213 + ownerSubtreeLeafElementID = null;
214 +
215 if (selectedElementIndex !== null) {
216 const selectedElement = store.getElementAtIndex(
217 ((selectedElementIndex: any): number),
@@ -205,9 +230,13 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
230 }
231 break;
232 case 'SELECT_ELEMENT_AT_INDEX':
233 + ownerSubtreeLeafElementID = null;
234 +
235 selectedElementIndex = (action: ACTION_SELECT_ELEMENT_AT_INDEX).payload;
236 break;
237 case 'SELECT_ELEMENT_BY_ID':
238 + ownerSubtreeLeafElementID = null;
239 +
240 // Skip lookup in this case; it would be redundant.
241 // It might also cause problems if the specified element was inside of a (not yet expanded) subtree.
242 lookupIDForIndex = false;
@@ -219,6 +248,8 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
248 : store.getIndexOfElementID(selectedElementID);
249 break;
250 case 'SELECT_NEXT_ELEMENT_IN_TREE':
251 + ownerSubtreeLeafElementID = null;
252 +
253 if (
254 selectedElementIndex === null ||
255 selectedElementIndex + 1 >= numElements
@@ -228,12 +259,80 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
259 selectedElementIndex++;
260 }
261 break;
262 + case 'SELECT_NEXT_SIBLING_IN_TREE':
263 + ownerSubtreeLeafElementID = null;
264 +
265 + if (selectedElementIndex !== null) {
266 + const selectedElement = store.getElementAtIndex(
267 + ((selectedElementIndex: any): number),
268 + );
269 + if (selectedElement !== null && selectedElement.parentID !== 0) {
270 + const parent = store.getElementByID(selectedElement.parentID);
271 + if (parent !== null) {
272 + const {children} = parent;
273 + const selectedChildIndex = children.indexOf(selectedElement.id);
274 + const nextChildID =
275 + selectedChildIndex < children.length - 1
276 + ? children[selectedChildIndex + 1]
277 + : children[0];
278 + selectedElementIndex = store.getIndexOfElementID(nextChildID);
279 + }
280 + }
281 + }
282 + break;
283 + case 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE':
284 + if (selectedElementIndex !== null) {
285 + if (
286 + ownerSubtreeLeafElementID !== null &&
287 + ownerSubtreeLeafElementID !== selectedElementID
288 + ) {
289 + const leafElement = store.getElementByID(ownerSubtreeLeafElementID);
290 + if (leafElement !== null) {
291 + let currentElement = leafElement;
292 + while (currentElement !== null) {
293 + if (currentElement.ownerID === selectedElementID) {
294 + selectedElementIndex = store.getIndexOfElementID(
295 + currentElement.id,
296 + );
297 + break;
298 + } else if (currentElement.ownerID !== 0) {
299 + currentElement = store.getElementByID(currentElement.ownerID);
300 + }
301 + }
302 + }
303 + }
304 + }
305 + break;
306 + case 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE':
307 + if (selectedElementIndex !== null) {
308 + if (ownerSubtreeLeafElementID === null) {
309 + // If this is the first time we're stepping through the owners tree,
310 + // pin the current component as the owners list leaf.
311 + // This will enable us to step back down to this component.
312 + ownerSubtreeLeafElementID = selectedElementID;
313 + }
314 +
315 + const selectedElement = store.getElementAtIndex(
316 + ((selectedElementIndex: any): number),
317 + );
318 + if (selectedElement !== null && selectedElement.ownerID !== 0) {
319 + const ownerIndex = store.getIndexOfElementID(
320 + selectedElement.ownerID,
321 + );
322 + if (ownerIndex !== null) {
323 + selectedElementIndex = ownerIndex;
324 + }
325 + }
326 + }
327 + break;
328 case 'SELECT_PARENT_ELEMENT_IN_TREE':
329 + ownerSubtreeLeafElementID = null;
330 +
331 if (selectedElementIndex !== null) {
332 const selectedElement = store.getElementAtIndex(
333 ((selectedElementIndex: any): number),
334 );
236 - if (selectedElement !== null && selectedElement.parentID !== null) {
335 + if (selectedElement !== null && selectedElement.parentID !== 0) {
336 const parentIndex = store.getIndexOfElementID(
337 selectedElement.parentID,
338 );
@@ -244,12 +343,35 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
343 }
344 break;
345 case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
346 + ownerSubtreeLeafElementID = null;
347 +
348 if (selectedElementIndex === null || selectedElementIndex === 0) {
349 selectedElementIndex = numElements - 1;
350 } else {
351 selectedElementIndex--;
352 }
353 break;
354 + case 'SELECT_PREVIOUS_SIBLING_IN_TREE':
355 + ownerSubtreeLeafElementID = null;
356 +
357 + if (selectedElementIndex !== null) {
358 + const selectedElement = store.getElementAtIndex(
359 + ((selectedElementIndex: any): number),
360 + );
361 + if (selectedElement !== null && selectedElement.parentID !== 0) {
362 + const parent = store.getElementByID(selectedElement.parentID);
363 + if (parent !== null) {
364 + const {children} = parent;
365 + const selectedChildIndex = children.indexOf(selectedElement.id);
366 + const nextChildID =
367 + selectedChildIndex > 0
368 + ? children[selectedChildIndex - 1]
369 + : children[children.length - 1];
370 + selectedElementIndex = store.getIndexOfElementID(nextChildID);
371 + }
372 + }
373 + }
374 + break;
375 default:
376 // React can bailout of no-op updates.
377 return state;
@@ -271,6 +393,7 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
393 ...state,
394
395 numElements,
396 + ownerSubtreeLeafElementID,
397 selectedElementIndex,
398 selectedElementID,
399 };
@@ -653,8 +776,12 @@ function TreeContextController({
776 case 'SELECT_ELEMENT_BY_ID':
777 case 'SELECT_CHILD_ELEMENT_IN_TREE':
778 case 'SELECT_NEXT_ELEMENT_IN_TREE':
779 + case 'SELECT_NEXT_SIBLING_IN_TREE':
780 + case 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE':
781 + case 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE':
782 case 'SELECT_PARENT_ELEMENT_IN_TREE':
783 case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
784 + case 'SELECT_PREVIOUS_SIBLING_IN_TREE':
785 case 'SELECT_OWNER':
786 case 'UPDATE_INSPECTED_ELEMENT_ID':
787 case 'SET_SEARCH_TEXT':
@@ -687,6 +814,7 @@ function TreeContextController({
814 const [state, dispatch] = useReducer(reducer, {
815 // Tree
816 numElements: store.numElements,
817 + ownerSubtreeLeafElementID: null,
818 selectedElementID:
819 defaultSelectedElementID == null ? null : defaultSelectedElementID,
820 selectedElementIndex: