summaryrefslogtreecommitdiffstats
path: root/comm/mail/modules/SelectionWidgetController.jsm
blob: 267ff7902b6d58cd01bdda74ff308e7b025a966b (plain)
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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, you can obtain one at http://mozilla.org/MPL/2.0/. */

const EXPORTED_SYMBOLS = ["SelectionWidgetController"];

var { AppConstants } = ChromeUtils.importESModule(
  "resource://gre/modules/AppConstants.sys.mjs"
);

/**
 * @callback GetLayoutDirectionMethod
 *
 * @returns {"horizontal"|"vertical"} - The direction in which the widget
 *   visually lays out its items. "vertical" for top to bottom, "horizontal" for
 *   following the text direction.
 */
/**
 * Details about the sizing of the widget in the same direction as its layout.
 *
 * @typedef {object} PageSizeDetails
 * @param {number} viewSize - The size of the widget's "view" of its items. If
 *   the items are placed under a scrollable area with 0 padding, this would
 *   usually be the clientHeight or clientWidth, which exclude the border and
 *   the scroll bars.
 * @param {number} viewOffset - The offset of the widget's "view" from the
 *   starting item. If the items are placed under a scrollable area with 0
 *   padding, this would usually be its scrollTop, or the absolute value of its
 *   scrollLeft (to account for negative values in right-to-left).
 * @param {?number} itemSize - The size of an item. If the items have no spacing
 *   between them, then this would usually correspond to their bounding client
 *   widths or heights. If the items do not share the same size, or there are no
 *   items this should return null.
 */
/**
 * @callback GetPageSizeDetailsMethod
 *
 * @returns {?PageSizeDetails} Details about the currently visible items. Or null
 *   if page navigation should not be allowed: either because the required
 *   conditions do not apply or PageUp and PageDown should be used for something
 *   else.
 */
/**
 * @callback IndexFromTargetMethod
 *
 * @param {EventTarget} target - An event target.
 *
 * @returns {?number} - The index for the selectable item that contains the event
 *   target, or null if there is none.
 */
/**
 * @callback SetFocusableItemMethod
 *
 * @param {?number} index - The index for the selectable item that should become
 *   focusable, replacing any previous focusable item. Or null if the widget
 *   itself should become focusable instead. If the corresponding item was not
 *   previously the focused item and it is not yet visible, it should be scrolled
 *   into view.
 * @param {boolean} focus - Whether to also focus the specified item after it
 *   becomes focusable.
 */
/**
 * @callback SetItemSelectionStateMethod
 *
 * @param {number} index - The index of the first selectable items to set the
 *   selection state of.
 * @param {number} number - The number of subsequent selectable items that
 *   should be set to the same selection state, including the first item and any
 *   immediately following it.
 * @param {boolean} selected - Whether the specified items should be selected or
 *   unselected.
 */

/**
 * A class for handling the focus and selection controls for a widget.
 *
 * The widget is assumed to control a totally ordered set of selectable items,
 * each of which may be referenced by their index in this ordering. The visual
 * display of these items has an ordering that is faithful to this ordering.
 * Note, a "selectable item" is any item that may receive focus and can be
 * selected or unselected.
 *
 * A SelectionWidgetController instance will keep track of its widget's focus
 * and selection states, and will provide a standard set of keyboard and mouse
 * controls to the widget that handle changes in these states.
 *
 * The SelectionWidgetController instance will communicate with the widget to
 * inform it of any changes in these states that the widget should adjust to. It
 * may also query the widget for information as needed.
 *
 * The widget must inform its SelectionWidgetController instance of any changes
 * in the index of selectable items. In particular, the widget should call the
 * addedSelectableItems method to inform the controller of any initial set of
 * items or any additional items that are added to the widget. It should also
 * use the removeSelectableItems and moveSelectableItems methods when it wishes
 * to remove or move items.
 *
 * The communication between the widget and its SelectionWidgetController
 * instance will use the item's index to reference the item. This means that the
 * representation of the item itself is left up to the widget.
 *
 * # Selection models
 *
 * The controller implements a number of selection models. Each of which has
 * different selection features and controls suited to them. A model appropriate
 * to the specific situation should be chosen.
 *
 * Model behaviour table:
 *
 *  Model Name  | Selection follows focus | Multi selectable
 *  ==========================================================================
 *  focus         always                    no
 *  browse        default                   no
 *  browse-multi  default                   yes
 *
 *
 * ## Behaviour: Selection follows focus
 *
 * This determines whether the focused item is selected.
 *
 * "always" means a focused item will always be selected, and no other item will
 * be selected, which makes the selection redundant to the focus. This should be
 * used if a change in the selection has no side effect beyond what a change in
 * focus should trigger.
 *
 * "default" means the default action when navigating to a focused item is to
 * change the selection to just that item, but the user may press a modifier
 * (Control) to move the focus without selecting an item. The side effects to
 * selecting an item should be light and non-disruptive since a user will likely
 * change the selection regularly as they navigate the items without a modifier.
 * Moreover, this behaviour will prefer selecting a single item, and so is not
 * appropriate if the primary use case is to select multiple, or zero, items.
 *
 * ## Behaviour: Multi selectable
 *
 * This determines whether the user can select more than one item. If the
 * selection follows the focus (by default) the user can use a modifier to
 * select more than one item.
 *
 * Note, if this is "no", then in most usage, exactly one item will be selected.
 * However, it is still possible to get into a state where no item is selected
 * when the widget is empty or the selected item is deleted when it doesn't have
 * focus.
 */
class SelectionWidgetController {
  /**
   * The widget this controller controls.
   *
   * @type {Element}
   */
  #widget = null;
  /**
   * A collection of methods passed to the controller at initialization.
   *
   * @type {object}
   */
  #methods = null;
  /**
   * The number of items the controller controls.
   *
   * @type {number}
   */
  #numItems = 0;
  /**
   * A range that points to all selectable items whose index `i` obeys
   *   `start <= i < end`
   * Note, the `start` is inclusive of the index but the `end` is not.
   *
   * @typedef {object} SelectionRange
   * @property {number} start - The starting point of the range.
   * @property {number} end - The ending point of the range.
   */
  /**
   * The ranges of selected indices, ordered by their `start` property.
   *
   * Each range is kept "disjoint": no natural number N obeys
   *   `#ranges[i].start <= N <= #ranges[i].end`
   * for more than one index `i`. Essentially, this means that no range of
   * selected items will overlap, or even be immediately adjacent to
   * another set of selected items. Instead, if two ranges would be adjacent or
   * overlap, they will be merged into one range instead.
   *
   * We use ranges, rather than a list of indices to reduce the footprint when a
   * large number of items are selected. Similarly, we also avoid looping over
   * all selected indices.
   *
   * @type {SelectionRange[]}
   */
  #ranges = [];
  /**
   * The direction of travel when holding the Shift modifier, or null if some
   * other selection has broken the Shift selection sequence.
   *
   * @type {"forward"|"backward"|null}
   */
  #shiftRangeDirection = null;
  /**
   * The index of the focused selectable item, or null if the widget is focused
   * instead.
   *
   * @type {?number}
   */
  #focusIndex = null;
  /**
   * Whether the focused item must always be selected.
   *
   * @type {boolean}
   */
  #focusIsSelected = false;
  /**
   * Whether the user can select multiple items.
   *
   * @type {boolean}
   */
  #multiSelectable = false;

  /**
   * Creates a new selection controller for the given widget.
   *
   * @param {widget} widget - The widget to control.
   * @param {"focus"|"browse"|"browse-multi"} model - The selection model to
   *   follow.
   * @param {object} methods - Methods for the controller to communicate with
   *   the widget.
   * @param {GetLayoutDirectionMethod} methods.getLayoutDirection - Used to
   *   get the layout direction of the widget.
   * @param {IndexFromTargetMethod} methods.indexFromTarget - Used to get the
   *   corresponding item index from an event target.
   * @param {GetPageSizeDetailsMethod} method.getPageSizeDetails - Used to get
   *   details about the visible display of the widget items for page
   *   navigation.
   * @param {SetFocusableItemMethod} methods.setFocusableItem - Used to update
   *   the widget on which item should receive focus.
   * @param {SetItemSelectionStateMethod} methods.setItemSelectionState - Used
   *   to update the widget on whether a range of items should be selected.
   */
  constructor(widget, model, methods) {
    this.#widget = widget;
    switch (model) {
      case "focus":
        this.#focusIsSelected = true;
        this.#multiSelectable = false;
        break;
      case "browse":
        this.#focusIsSelected = false;
        this.#multiSelectable = false;
        break;
      case "browse-multi":
        this.#focusIsSelected = false;
        this.#multiSelectable = true;
        break;
      default:
        throw new RangeError(`The model "${model}" is not a supported model`);
    }
    this.#methods = methods;

    widget.addEventListener("mousedown", event => this.#handleMouseDown(event));
    if (this.#multiSelectable) {
      widget.addEventListener("click", event => this.#handleClick(event));
    }
    widget.addEventListener("keydown", event => this.#handleKeyDown(event));
    widget.addEventListener("focusin", event => this.#handleFocusIn(event));
  }

  #assertIntegerInRange(integer, lower, upper, name) {
    if (!Number.isInteger(integer)) {
      throw new RangeError(`"${name}" ${integer} is not an integer`);
    }
    if (lower != null && integer < lower) {
      throw new RangeError(
        `"${name}" ${integer} is not greater than or equal to ${lower}`
      );
    }
    if (upper != null && integer > upper) {
      throw new RangeError(
        `"${name}" ${integer} is not less than or equal to ${upper}`
      );
    }
  }

  /**
   * Update the widget's selection state for the specified items.
   *
   * @param {number} index - The index at which to start.
   * @param {number} number - The number of items to set the state of.
   */
  #updateWidgetSelectionState(index, number) {
    // First, inform the widget of the selection state of the new items.
    let prevRangeEnd = index;
    for (let { start, end } of this.#ranges) {
      // Deselect the items in the gap between the previous range and this one.
      // For the first range, there may not be a gap.
      if (start > prevRangeEnd) {
        this.#methods.setItemSelectionState(
          prevRangeEnd,
          start - prevRangeEnd,
          false
        );
      }
      // Select the items in the range.
      this.#methods.setItemSelectionState(start, end - start, true);
      prevRangeEnd = end;
    }
    // Deselect the items in the gap between the final range and the end of the
    // new items, if there is a gap.
    if (index + number > prevRangeEnd) {
      this.#methods.setItemSelectionState(
        prevRangeEnd,
        index + number - prevRangeEnd,
        false
      );
    }
  }

  /**
   * Informs the controller that a set of selectable items were added to the
   * widget. It is important to call this *after* the widget has indexed the new
   * items.
   *
   * @param {number} index - The index at which the selectable items were added.
   *   Between 0 and the current number of items (inclusive).
   * @param {number} number - The number of selectable items that were added at
   *   this index.
   */
  addedSelectableItems(index, number) {
    this.#assertIntegerInRange(index, 0, this.#numItems, "index");
    this.#assertIntegerInRange(number, 1, null, "number");
    // Newly added items are unselected.
    this.#adjustRangesOnAddItems(index, number, []);
    this.#numItems += number;

    if (this.#focusIndex != null && this.#focusIndex >= index) {
      // Focus remains on the same item, but is adjusted in index.
      this.#focusIndex += number;
    }

    this.#updateWidgetSelectionState(index, number);
  }

  /**
   * Adjust the #ranges to account for additional inserted items.
   *
   * @param {number} index - The index at which items are added.
   * @param {number} number - The number of items that are added at this index.
   * @param {SelectionRange[]} insertSelection - The selection state of the
   *   inserted items. The ranges should be "disjoint" and only overlap the
   *   added indices. The given array is owned by the method.
   */
  #adjustRangesOnAddItems(index, number, insertSelection) {
    // We want to insert whatever ranges are specified in insertSelection into
    // the #ranges Array. insertRangeIndex tracks the index at which we will
    // insert the given insertSelection.
    let insertRangeIndex = 0;
    // However, if insertSelection touches the start or end of the new items, it
    // may be possible to merge it with an existing SelectionRange that touches
    // the same edge.
    let touchStartRange =
      insertSelection.length && insertSelection[0].start == index
        ? insertSelection[0]
        : null;
    let touchEndRange =
      insertSelection.length &&
      insertSelection[insertSelection.length - 1].end == index + number
        ? insertSelection[insertSelection.length - 1]
        : null;

    // Go through ranges from last to first.
    for (let i = this.#ranges.length - 1; i >= 0; i--) {
      let { start, end } = this.#ranges[i];
      if (touchStartRange && end == index) {
        // Merge the range with touchStartRange.
        touchStartRange.start = start;
        this.#ranges.splice(i, 1, ...insertSelection);
        // All earlier ranges should end strictly before the index.
        return;
      }
      if (end <= index) {
        // A   B [ C   D   E ] F   G
        //         ^start   end^
        //                     ^index (or higher)
        // No change, and all earlier ranges are also before.
        // This is the last range that lies before the inserted items, so we
        // want to insert the given insertSelection after this range.
        insertRangeIndex = i + 1;
        break;
      }
      if (start < index) {
        // start < index < end
        // A   B [ C   D   E ] F   G
        //         ^start   end^
        //             ^index
        // The range is split in two parts by the index.
        if (touchEndRange) {
          // Extend touchEndRange to the end part of the current range.
          // We add "number" to account for the inserted indices.
          touchEndRange.end = end + number;
        } else {
          // Append a new range for the end part of the current range.
          insertSelection.push({ start: index + number, end: end + number });
        }
        if (touchStartRange) {
          // We merge touchStartRange with the first part of the current range.
          touchStartRange.start = start;
          this.#ranges.splice(i, 1, ...insertSelection);
        } else {
          // We adjust the first part to end where the inserted indices begin.
          this.#ranges[i].end = index;
          this.#ranges.splice(i + 1, 0, ...insertSelection);
        }
        // All earlier ranges should end strictly before the index.
        return;
      }
      // A   B [ C   D   E ] F   G
      //         ^start   end^
      //         ^index (or lower)
      if (touchEndRange && start == index) {
        // Merge the range with the touchEndRange.
        // We add "number" to account for the inserted indices.
        touchEndRange.end = end + number;
        this.#ranges.splice(i, 1, ...insertSelection);
        // All earlier ranges should end strictly before the index.
        return;
      }
      // Shift the range to account for the inserted indices.
      this.#ranges[i].start = start + number;
      this.#ranges[i].end = end + number;
    }

    // Add the insert ranges in the gap.
    if (insertSelection.length) {
      this.#ranges.splice(insertRangeIndex, 0, ...insertSelection);
    }
  }

  /**
   * Remove a set of selectable items from the widget. The actual removing of
   * the items and their elements from the widget is controlled by the widget
   * through a callback, and the controller will update its internals. The
   * controller may also change the selection state and focus of the widget
   * if need be.
   *
   * @param {number} index - The index of the first selectable item to be
   *   removed.
   * @param {number} number - The number of subsequent selectable items that
   *   will be removed, including the first item and any immediately following
   *   it.
   * @param {Function} removeCallback - A function to call with no arguments
   *   that removes the specified items from the widget. After this call the
   *   widget should no longer be tracking the specified items and should have
   *   shifted the indices of the remaining items to fill the gap.
   */
  removeSelectableItems(index, number, removeCallback) {
    this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
    this.#assertIntegerInRange(number, 1, this.#numItems - index, "number");

    let focusWasSelected =
      this.#focusIndex != null && this.itemIsSelected(this.#focusIndex);
    // Get whether the focus is within the widget now in case it is lost when
    // the items are removed.
    let focusInWidget = this.#focusInWidget();

    removeCallback();

    this.#adjustRangesOnRemoveItems(index, number);
    this.#numItems -= number;

    if (!this.#ranges.length) {
      // Ends any shift range.
      this.#shiftRangeDirection = null;
    }

    // Adjust focus.
    if (this.#focusIndex == null || this.#focusIndex < index) {
      // No change in index if on widget or before the removed index.
      return;
    }
    if (this.#focusIndex >= index + number) {
      // Reduce index if after the removed items.
      this.#focusIndex -= number;
      return;
    }
    // Focus is lost.
    // Try to move to the first item after the removed items. If this does
    // not exist, it will be capped to the last item overall in #moveFocus.
    let newFocus = index;
    if (focusWasSelected && this.#shiftRangeDirection) {
      // As a special case, if the focused item was inside a shift selection
      // range when it was removed, and the range still exists after, we keep
      // the focus within the selection boundary that is opposite the "pivot"
      // point. I.e. when selecting forwards we keep the focus below the
      // selection end, and when selecting backwards we keep the focus above the
      // selection start. This is to prevent the focused item becoming
      // unselected in the middle of an ongoing shift range selection.
      // NOTE: When selecting forwards, we do not keep the focus above the
      // selection start because the user would only be here (at the selection
      // "pivot") if they navigated with Ctrl+Space to this position, so we do
      // not override the default behaviour. Similarly when selecting backwards
      // we do not require the focus to remain above the selection end.
      switch (this.#shiftRangeDirection) {
        case "forward":
          newFocus = Math.min(
            newFocus,
            this.#ranges[this.#ranges.length - 1].end - 1
          );
          break;
        case "backward":
          newFocus = Math.max(newFocus, this.#ranges[0].start);
      }
    }
    // TODO: if we have a tree structure, we will want to move the focus
    // within the nearest parent by clamping the focus to lie between the
    // parent index (inclusive) and its last descendant (inclusive). If
    // there are no children left, this will fallback to focusing the
    // parent.
    this.#moveFocus(newFocus, focusInWidget);
    // #focusIndex may now be different from newFocus if the deleted indices
    // were the final ones, and may be null if no items remain.
    if (!this.#ranges.length && this.#focusIndex != null) {
      // If the focus was moved, and now we have no selection, we select it.
      // This is deemed relatively safe to do since it only effects the state of
      // the focused item. And it is convenient to have selection resume.
      this.#selectSingle(this.#focusIndex);
    }
  }

  /**
   * Adjust the #ranges to remove items.
   *
   * @param {number} index - The index at which items are removed.
   * @param {number} number - The number of items that are removed.
   *
   * @returns {SelectionRange[]} - The removed SelectionRange objects. This will
   *   contain all the ranges that touched or overlapped the selected items.
   *   Owned by the caller.
   */
  #adjustRangesOnRemoveItems(index, number) {
    // The ranges to remove.
    let deleteRangesStart = 0;
    let deleteRangesNumber = 0;
    // The range to insert by combining overlapping ranges on either side of the
    // deleted indices.
    let insertRange = { start: index, end: index };

    // Go through ranges from last to first.
    for (let i = this.#ranges.length - 1; i >= 0; i--) {
      let { start, end } = this.#ranges[i];
      if (end < index) {
        //                                     <- removed ->
        // A   B   C   D   E [ F   G   H ] I   J   K   L   M
        //                     ^start   end^
        //                                     ^index (or higher)
        deleteRangesStart = i + 1;
        // This and all earlier ranges do not need to be updated.
        break;
      } else if (start > index + number) {
        // <- removed ->
        // A   B   C   D   E [ F   G   H ] I   J   K   L   M
        //                     ^start   end^
        //                 ^index + number (or lower)
        // Shift the range.
        this.#ranges[i].start = start - number;
        this.#ranges[i].end = end - number;
        continue;
      }
      deleteRangesNumber++;
      if (end > index + number) {
        // start <= (index + number) < end
        //     <- removed ->
        // A   B   C   D   E [ F   G   H ] I   J   K   L   M
        //                     ^start   end^
        //     ^index          ^index + number
        //
        //             <- removed ->
        // A   B   C   D   E [ F   G   H ] I   J   K   L   M
        //                     ^start   end^
        //             ^index          ^index + number
        //
        //                 <- removed ->
        // A   B   C [ D   E   F   G   H   I ] J   K   L   M
        //             ^start               end^
        //                 ^index          ^index + number
        //
        // Overlaps or touches the end of the removed indices, but is not
        // entirely contained within the removed region.
        // Extend the insertRange to the end of this range, and then shift it to
        // remove the deleted indices.
        insertRange.end = end - number;
      }
      if (start < index) {
        // start < index <= end
        //                                 <- removed ->
        // A   B   C   D   E [ F   G   H ] I   J   K   L   M
        //                     ^start   end^
        //                                 ^index          ^index + number
        //
        //                         <- removed ->
        // A   B   C   D   E [ F   G   H ] I   J   K   L   M
        //                     ^start   end^
        //                         ^index          ^index + number
        //
        //                 <- removed ->
        // A   B   C [ D   E   F   G   H   I ] J   K   L   M
        //             ^start               end^
        //                 ^index          ^index + number
        //
        // Overlaps or touches the start of the removed indices, but is not
        // entirely contained within the removed region.
        // Extend the insertRange to the start of this range.
        insertRange.start = start;
        // Expect break on next loop.
      }
    }
    if (!deleteRangesNumber) {
      // No change in selection.
      return [];
    }
    if (insertRange.end > insertRange.start) {
      return this.#ranges.splice(
        deleteRangesStart,
        deleteRangesNumber,
        insertRange
      );
    }
    // No range to insert.
    return this.#ranges.splice(deleteRangesStart, deleteRangesNumber);
  }

  /**
   * Move a set of selectable items within the widget. The actual moving of
   * the items and their elements in the widget is controlled by the widget
   * through a callback, and the controller will update its internals.
   *
   * Unlike simply adding and then removing indices, this will transfer the
   * focus and selection states along with the moved items.
   *
   * @param {number} from - The index of the first selectable item to be
   *   moved, before the move.
   * @param {number} to - The index that the first selectable item will be moved
   *   to, after the move.
   * @param {number} number - The number of subsequent selectable items that
   *   will be moved along with the first item, including the first item and any
   *   immediately following it. Their relative positions should remain the
   *   same.
   * @param {Function} moveCallback - A function to call with no arguments
   *   that moves the specified items within the widget to the specified
   *   position. After this call the widget should have adjusted the indices
   *   of its items accordingly.
   */
  moveSelectableItems(from, to, number, moveCallback) {
    this.#assertIntegerInRange(from, 0, this.#numItems - 1, "from");
    this.#assertIntegerInRange(number, 1, this.#numItems - from, "number");
    this.#assertIntegerInRange(to, 0, this.#numItems - number, "to");
    // Get whether the focus is within the widget now in case it is lost when
    // the items are moved.
    let focusInWidget = this.#focusInWidget();

    moveCallback();

    let movedSelection = this.#adjustRangesOnRemoveItems(from, number);
    // Descend the removed ranges.
    for (let i = movedSelection.length - 1; i >= 0; i--) {
      let range = movedSelection[i];
      if (range.end <= from || range.start >= from + number) {
        // Touched the start or end, but did not overlap.
        movedSelection.splice(i, 1);
        // NOTE: Since we are descending it is safe to continue the loop by
        // decreasing i by 1.
        continue;
      }
      // Translate and clip the range.
      range.start = to + Math.max(0, range.start - from);
      range.end = to + Math.min(number, range.end - from);
    }
    this.#adjustRangesOnAddItems(to, number, movedSelection);

    // End any range selection.
    this.#shiftRangeDirection = null;

    // Adjust focus.
    if (this.#focusIndex != null) {
      if (this.#focusIndex >= from && this.#focusIndex < from + number) {
        // Focus was in the moved range.
        // We adjust the #focusIndex, but we also force the widget to reset the
        // focus in case it needs to apply it to a newly created items.
        this.#moveFocus(this.#focusIndex + to - from, focusInWidget);
      } else {
        // Adjust for removing `number` items at `from`.
        if (this.#focusIndex >= from + number) {
          this.#focusIndex -= number;
        }
        // Adjust for then adding `number` items at `to`.
        if (this.#focusIndex >= to) {
          this.#focusIndex += number;
        }
      }
    }
    // Reset the selection state for the moved items in case it needs to be
    // applied to newly created items.
    this.#updateWidgetSelectionState(to, number);
  }

  /**
   * Select the specified item and deselect all other items. The next time the
   * widget is entered by the user, the specified item will also receive the
   * focus.
   *
   * This should normally not be used in a situation were the focus may already
   * be within the widget because it will actively move the focus, which can be
   * disruptive if unexpected. It is mostly exposed to set an initial selection
   * after creating the widget, or when changing its dataset.
   *
   * @param {number} index - The index for the item to select. This must not
   *   exceed the number of items controlled by the widget.
   */
  selectSingleItem(index) {
    this.#selectSingle(index);
    let focusInWidget = this.#focusInWidget();
    if (this.#focusIndex == null && !focusInWidget) {
      // Wait until handleFocusIn to move the focus to the selected item in case
      // other items become selected through setItemSelected.
      return;
    }
    this.#moveFocus(index, focusInWidget);
  }

  /**
   * Set the selection state of the specified item, but otherwise leave the
   * selection state of other items the same.
   *
   * Note that this will throw if the selection model does not support multi
   * selection. Generally, you should try and use selectSingleItem instead
   * because this also moves the focus appropriately and works for all models.
   *
   * @param {number} index - The index for the item to set the selection state
   *   of.
   * @param {boolean} selected - Whether the item should be selected or
   *   unselected.
   */
  setItemSelected(index, selected) {
    if (!this.#multiSelectable) {
      throw new Error("Widget does not support multi-selection");
    }
    this.#toggleSelection(index, !!selected);
  }

  /**
   * Get the ranges of all selected items.
   *
   * Note that ranges are returned rather than individual indices to keep this
   * method fast. Unlike the selected indices which might become very large with
   * a single user operation, like Select-All, the number of ranges will
   * increase by order-one range per user interaction or public method call.
   *
   * Note that the SelectionRange objects specify the range with a `start` and
   * `end` index. The `start` is inclusive of the index, but the `end` is
   * not.
   *
   * Note that the returned Array is static (it will not update as the selection
   * changes).
   *
   * @returns {SelectionRange[]} - An array of all non-overlapping selection
   * ranges, order by their start index.
   */
  getSelectionRanges() {
    return Array.from(this.#ranges, r => {
      return { start: r.start, end: r.end };
    });
  }

  /**
   * Query whether the specified item is selected or not.
   *
   * @param {number} index - The index for the item to query.
   *
   * @returns {boolean} - Whether the item is selected.
   */
  itemIsSelected(index) {
    this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
    for (let { start, end } of this.#ranges) {
      if (index < start) {
        // index was not in any lower ranges and is before the start of this
        // range, so should be unselected.
        return false;
      }
      if (index < end) {
        // start <= index < end
        return true;
      }
    }
    return false;
  }

  /**
   * Select the specified range of indices, and nothing else.
   *
   * @param {number} index - The first index to select.
   * @param {number} number - The number of indices to select.
   */
  #selectRange(index, number) {
    this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");
    this.#assertIntegerInRange(number, 1, this.#numItems - index, "number");

    let prevRanges = this.#ranges;
    let start = index;
    let end = index + number;
    if (
      prevRanges.length == 1 &&
      prevRanges[0].start == start &&
      prevRanges[0].end == end
    ) {
      // No change.
      return;
    }

    this.#ranges = [{ start, end }];
    // Adjust the selection state to match the new range.
    // NOTE: For simplicity, we do a blanket re-selection across the whole
    // region, even items in between ranges that are not selected.
    // NOTE: If the new range overlaps the previous range then the selection
    // state be set more than once for an item, but it will be to the same
    // value.
    if (prevRanges.length) {
      let firstRangeStart = prevRanges[0].start;
      let lastRangeEnd = prevRanges[prevRanges.length - 1].end;
      this.#updateWidgetSelectionState(
        firstRangeStart,
        lastRangeEnd - firstRangeStart
      );
    }
    this.#updateWidgetSelectionState(index, number);
  }

  /**
   * Select one index and nothing else.
   *
   * @param {number} index - The index to select.
   */
  #selectSingle(index) {
    this.#selectRange(index, 1);
    // Cancel any shift range.
    this.#shiftRangeDirection = null;
  }

  /**
   * Toggle the selection state at a single index.
   *
   * @param {number} index - The index to toggle the selection state of.
   * @param {boolean} [selectState] - The state to force the selection state of
   *   the item to, or leave undefined to toggle the state.
   */
  #toggleSelection(index, selectState) {
    this.#assertIntegerInRange(index, 0, this.#numItems - 1, "index");

    let wasSelected = false;
    let i;
    // We traverse over the ranges.
    for (i = 0; i < this.#ranges.length; i++) {
      let { start, end } = this.#ranges[i];
      // Test if in a gap between the end of last range and the start of the
      // current one.
      // NOTE: Since we did not break on the previous loop, we already know that
      // the index is above the end of the previous range.
      if (index < start) {
        // This index is not selected.
        break;
      }
      // Test if in the range.
      if (index < end) {
        // start <= index < end
        wasSelected = true;
        if (selectState) {
          // Already selected and we want to keep it that way.
          break;
        }
        if (start == index && end == index + 1) {
          // A   B   C [ D ] E   F   G
          //        start^   ^end
          //             ^index
          //
          // Remove the range entirely.
          this.#ranges.splice(i, 1);
        } else if (start == index) {
          // A [ B   C   D   E   F ] G
          //     ^start           end^
          //     ^index
          //
          // Remove the start of the range.
          this.#ranges[i].start = index + 1;
        } else if (end == index + 1) {
          // A [ B   C   D   E   F ] G
          //     ^start           end^
          //                     ^index
          //
          // Remove the end of the range.
          this.#ranges[i].end = index;
        } else {
          // A [ B   C   D   E   F ] G
          //     ^start           end^
          //             ^index
          //
          // Split the range in two.
          //
          // A [ B   C ] D [ E   F ] G
          this.#ranges[i].end = index;
          this.#ranges.splice(i + 1, 0, { start: index + 1, end });
        }
        break;
      }
    }
    if (!wasSelected && (selectState == undefined || selectState)) {
      // The index i points to a *gap* between existing ranges, so lies in
      // [0, numItems]. Note, the space between the start and the first range,
      // or the end and the last range count as gaps, even if they are zero
      // width.
      // We want to know whether the index touches the borders of the range
      // either side of the gap.
      let touchesRangeEnd = i > 0 && index == this.#ranges[i - 1].end;
      // A [ B   C   D ] E   F   G   H   I
      //         end(i-1)^
      //                 ^index
      let touchesRangeStart =
        i < this.#ranges.length && index + 1 == this.#ranges[i].start;
      // A   B   C   D   E [ F   G   H ] I
      //                     ^start(i)
      //                 ^index
      if (touchesRangeEnd && touchesRangeStart) {
        // A [ B   C   D ] E [ F   G   H ] I
        //                 ^index
        // Merge the two ranges together.
        this.#ranges[i - 1].end = this.#ranges[i].end;
        this.#ranges.splice(i, 1);
      } else if (touchesRangeEnd) {
        // Grow the range forwards to include the index.
        this.#ranges[i - 1].end = index + 1;
      } else if (touchesRangeStart) {
        // Grow the range backwards to include the index.
        this.#ranges[i].start = index;
      } else {
        // Create a new range.
        this.#ranges.splice(i, 0, { start: index, end: index + 1 });
      }
    }
    this.#methods.setItemSelectionState(index, 1, selectState ?? !wasSelected);
    // Cancel any shift range.
    this.#shiftRangeDirection = null;
  }

  /**
   * Determine whether the focus lies within the widget or elsewhere.
   *
   * @returns {boolean} - Whether the active element is the widget or one of its
   *   descendants.
   */
  #focusInWidget() {
    return this.#widget.contains(this.#widget.ownerDocument.activeElement);
  }

  /**
   * Make the specified element focusable. Also move focus to this element if
   * the widget already has focus.
   *
   * @param {?number} index - The index of the item to focus, or null to focus
   *   the widget. If the index is out of range, it will be truncated.
   * @param {boolean} [forceInWidget] - Whether the focus was in the widget
   *   before the specified element becomes focusable. This should be given to
   *   reference an earlier focus state, otherwise leave undefined to use the
   *   current focus state.
   */
  #moveFocus(index, focusInWidget) {
    let numItems = this.#numItems;
    if (index != null) {
      if (index >= numItems) {
        index = numItems ? numItems - 1 : null;
      } else if (index < 0) {
        index = numItems ? 0 : null;
      }
    }
    if (focusInWidget == undefined) {
      focusInWidget = this.#focusInWidget();
    }

    this.#focusIndex = index;
    // If focus is within the widget, we move focus onto the new item.
    this.#methods.setFocusableItem(index, focusInWidget);
  }

  #handleFocusIn(event) {
    if (
      // No item is focused,
      this.#focusIndex == null &&
      // and we have at least one item,
      this.#numItems &&
      // and the focus moved from outside the widget.
      // NOTE: relatedTarget may be null, but Node.contains will also return
      // false for this case, as desired.
      !this.#widget.contains(event.relatedTarget)
    ) {
      // If nothing is selected, select the first item.
      if (!this.#ranges.length) {
        this.#selectSingle(0);
      }
      // Focus first selected item.
      this.#moveFocus(this.#ranges[0].start);
      return;
    }
    if (this.#focusIndex != this.#methods.indexFromTarget(event.target)) {
      // Restore focus to where it needs to be.
      this.#moveFocus(this.#focusIndex);
    }
  }

  /**
   * Adjust the focus and selection in response to a user generated event.
   *
   * @param {?number} [focusIndex] - The new index to move focus to, or null to
   *   move the focus to the widget, or undefined to leave the focus as it is.
   *   Note that the focusIndex will be clamped to lie within the current index
   *   range.
   * @param {string} [select] - The change in selection to trigger, relative to
   *   the #focusIndex. "single" to select the #focusIndex, "toggle" to swap its
   *   selection state, "range" to start or continue a range selection, or "all"
   *   to select all items.
   */
  #adjustFocusAndSelection(focusIndex, select) {
    let prevFocusIndex = this.#focusIndex;
    if (focusIndex !== undefined) {
      // NOTE: We need a strict inequality since focusIndex may be null.
      this.#moveFocus(focusIndex);
    }
    // Change selection relative to the focused index.
    // NOTE: We use the #focusIndex value rather than the focusIndex variable.
    if (this.#focusIndex != null) {
      switch (select) {
        case "single":
          this.#selectSingle(this.#focusIndex);
          break;
        case "toggle":
          this.#toggleSelection(this.#focusIndex);
          break;
        case "range":
          // We want to select all items between a "pivot" point and the focused
          // index. If we do not have a "pivot" point, we use the previously
          // focused index.
          // This "pivot" point is lost every time the user performs a single
          // selection or a toggle selection. I.e. if the selection changes by
          // any means other than "range" selection.
          //
          // NOTE: We represent the presence of such a "pivot" point using the
          // #shiftRangeDirection property. If it is null, no such point exists,
          // if it is "forward" then the "pivot" point is the first selected
          // index, and if it is "backward" then the "pivot" point is the last
          // selected index.
          // Usually, we only have one #ranges entry whilst doing such a Shift
          // selection, but if items are added in the middle of such a range,
          // then the selection can be split, but subsequent Shift selection
          // will reselect all of them.
          // NOTE: We do not keep track of this "pivot" index explicitly in a
          // property because otherwise we would have to adjust its value every
          // time items are removed, and handle cases where the "pivot" index is
          // removed. Instead, we just borrow the logic of how the #ranges array
          // is updated, and continue to derive the "pivot" point from the
          // #shiftRangeDirection and #ranges properties.
          let start;
          switch (this.#shiftRangeDirection) {
            case "forward":
              // When selecting forward, the range start is the first selected
              // index.
              start = this.#ranges[0].start;
              break;
            case "backward":
              // When selecting backward, the range end is the last selected
              // index.
              start = this.#ranges[this.#ranges.length - 1].end - 1;
              break;
            default:
              // We start a new range selection between the previously focused
              // index and the newly focused index.
              start = prevFocusIndex || 0;
              break;
          }
          let number;
          // NOTE: Selection may transition from "forward" to "backward" if the
          // user moves the selection in the other direction.
          if (start > this.#focusIndex) {
            this.#shiftRangeDirection = "backward";
            number = start - this.#focusIndex + 1;
            start = this.#focusIndex;
          } else {
            this.#shiftRangeDirection = "forward";
            number = this.#focusIndex - start + 1;
          }
          this.#selectRange(start, number);
          break;
      }
    }

    // Selecting all does not require focus.
    if (select == "all" && this.#numItems) {
      this.#shiftRangeDirection = null;
      this.#selectRange(0, this.#numItems);
    }
  }

  #handleMouseDown(event) {
    // NOTE: The default handler for mousedown will move focus onto the clicked
    // item or the widget, but #handleFocusIn will re-assign it to the current
    // #focusIndex if it differs.
    if (event.button != 0 || event.metaKey || event.altKey) {
      return;
    }
    let { shiftKey, ctrlKey } = event;
    if (
      (ctrlKey && shiftKey) ||
      // Both modifiers pressed.
      ((ctrlKey || shiftKey) && !this.#multiSelectable)
      // Attempting multi-selection when not supported
    ) {
      return;
    }
    let clickIndex = this.#methods.indexFromTarget(event.target);
    if (clickIndex == null) {
      // Clicked empty space.
      return;
    }
    if (ctrlKey) {
      this.#adjustFocusAndSelection(clickIndex, "toggle");
    } else if (shiftKey) {
      this.#adjustFocusAndSelection(clickIndex, "range");
    } else if (this.#multiSelectable && this.itemIsSelected(clickIndex)) {
      // We set the focus now, but wait until "click" to select a single item.
      // We do this to allow the user to drag a multi selection.
      this.#adjustFocusAndSelection(clickIndex, undefined);
    } else {
      this.#adjustFocusAndSelection(clickIndex, "single");
    }
  }

  #handleClick(event) {
    // NOTE: This handler is only used if we have #multiSelectable.
    // See #handleMouseDown
    if (
      event.button != 0 ||
      event.metaKey ||
      event.altKey ||
      event.shiftKey ||
      event.ctrlKey
    ) {
      return;
    }
    let clickIndex = this.#methods.indexFromTarget(event.target);
    if (clickIndex == null) {
      return;
    }
    this.#adjustFocusAndSelection(clickIndex, "single");
  }

  #handleKeyDown(event) {
    if (event.altKey) {
      // Not handled.
      return;
    }

    let { shiftKey, ctrlKey, metaKey } = event;
    if (
      this.#multiSelectable &&
      event.key == "a" &&
      !shiftKey &&
      (AppConstants.platform == "macosx") == metaKey &&
      (AppConstants.platform != "macosx") == ctrlKey
    ) {
      this.#adjustFocusAndSelection(undefined, "all");
      event.stopPropagation();
      event.preventDefault();
      return;
    }

    if (metaKey) {
      // Not handled.
      return;
    }

    if (event.key == " ") {
      // Always reserve the Space press.
      event.stopPropagation();
      event.preventDefault();

      if (shiftKey) {
        // Not handled.
        return;
      }

      if (ctrlKey) {
        if (this.#multiSelectable) {
          this.#adjustFocusAndSelection(undefined, "toggle");
        }
        // Else, do nothing.
        return;
      }

      this.#adjustFocusAndSelection(undefined, "single");
      return;
    }

    let forwardKey;
    let backwardKey;
    if (this.#methods.getLayoutDirection() == "vertical") {
      forwardKey = "ArrowDown";
      backwardKey = "ArrowUp";
    } else if (this.#widget.matches(":dir(ltr)")) {
      forwardKey = "ArrowRight";
      backwardKey = "ArrowLeft";
    } else {
      forwardKey = "ArrowLeft";
      backwardKey = "ArrowRight";
    }

    // NOTE: focusIndex may be set to an out of range index, but it will be
    // clipped in #moveFocus.
    let focusIndex;
    switch (event.key) {
      case "Home":
        focusIndex = 0;
        break;
      case "End":
        focusIndex = this.#numItems - 1;
        break;
      case "PageUp":
      case "PageDown":
        let sizeDetails = this.#methods.getPageSizeDetails();
        if (!sizeDetails) {
          // Do not handle and allow PageUp or PageDown to propagate.
          return;
        }
        if (!sizeDetails.itemSize || !sizeDetails.viewSize) {
          // Still reserve PageUp and PageDown
          break;
        }
        let { itemSize, viewSize, viewOffset } = sizeDetails;
        // We want to determine what items are visible. We count an item as
        // "visible" if more than half of it is in view.
        //
        // Consider an item at index i that follows the assumed model:
        //
        //      [   item content   ]
        //      <---- itemSize ---->
        // ---->start_i = i * itemSize
        //
        // where start_i is the offset of the starting edge of the item relative
        // to the starting edge of the first item.
        //
        // As such, an item will be visible if
        //     start_i + itemSize / 2 > viewOffset
        // and
        //     start_i + itemSize / 2 < viewOffset + viewSize
        // <=>
        //     i > (viewOffset / itemSize) - 1/2
        // and
        //     i < ((viewOffset + viewSize) / itemSize) - 1/2

        // First, we want to know the number of items we can visibly fit on a
        // page. I.e. when the viewOffset is 0, the number of items whose midway
        // point is lower than the viewSize. This is given by (i + 1), where i
        // is the largest index i that satisfies
        //     i < (viewSize / itemSize) - 1/2
        // This is given by taking the ceiling - 1, which cancels with the +1.
        let itemsPerPage = Math.ceil(viewSize / itemSize - 0.5);
        if (itemsPerPage <= 1) {
          break;
        }
        if (event.key == "PageUp") {
          // We want to know what the first visible index is. I.e. the smallest
          // i that satisfies
          //     i > (viewOffset / itemSize) - 1/2
          // This is equivalent to flooring the right hand side + 1.
          let pageStart = Math.floor(viewOffset / itemSize - 0.5) + 1;
          if (this.#focusIndex == null || this.#focusIndex > pageStart) {
            // Move focus to the top of the page.
            focusIndex = pageStart;
          } else {
            // Reduce focusIndex by one page.
            // We add "1" index to try and keep the previous focusIndex visible
            // at the bottom of the view.
            focusIndex = this.#focusIndex - itemsPerPage + 1;
          }
        } else {
          // We want to know what the last visible index is. I.e. the largest i
          // that satisfies
          //     i < (viewOffset + viewSize) / itemSize - 1/2
          // This is equivalent to ceiling the right hand side - 1.
          let pageEnd = Math.ceil((viewOffset + viewSize) / itemSize - 0.5) - 1;
          if (this.#focusIndex == null || this.#focusIndex < pageEnd) {
            // Move focus to the end of the page.
            focusIndex = pageEnd;
          } else {
            // Increase focusIndex by one page.
            // We minus "1" index to try and keep the previous focusIndex
            // visible at the top of the view.
            focusIndex = this.#focusIndex + itemsPerPage - 1;
          }
        }
        break;
      case forwardKey:
        if (this.#focusIndex == null) {
          // Move to first item.
          focusIndex = 0;
        } else {
          focusIndex = this.#focusIndex + 1;
        }
        break;
      case backwardKey:
        if (this.#focusIndex == null) {
          // Move to first item.
          focusIndex = 0;
        } else {
          focusIndex = this.#focusIndex - 1;
        }
        break;
      default:
        // Not a navigation key.
        return;
    }

    // NOTE: We always reserve control over these keys, regardless of whether
    // we respond to them.
    event.stopPropagation();
    event.preventDefault();

    if (focusIndex === undefined) {
      return;
    }

    if (shiftKey && ctrlKey) {
      // Both modifiers not handled.
      return;
    }

    if (ctrlKey) {
      // Move the focus without changing the selection.
      if (!this.#focusIsSelected) {
        this.#adjustFocusAndSelection(focusIndex, undefined);
      }
      return;
    }

    if (shiftKey) {
      // Range selection.
      if (this.#multiSelectable) {
        this.#adjustFocusAndSelection(focusIndex, "range");
      }
      return;
    }

    this.#adjustFocusAndSelection(focusIndex, "single");
  }
}