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: