SidebarTreeView.sys.mjs (7094B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * A controller that enables selection and keyboard navigation within a "tree" 7 * view in the sidebar. This tree represents any hierarchical structure of 8 * URLs, such as those from synced tabs or history visits. 9 * 10 * The host component should have the following queries: 11 * - `cards` for the `<moz-card>` instances of collapsible containers. 12 * 13 * @implements {ReactiveController} 14 */ 15 export class SidebarTreeView { 16 /** 17 * All lists that currently have a row selected. 18 * 19 * @type {Set<SidebarTabList>} 20 */ 21 selectedLists; 22 23 constructor(host, { multiSelect = true } = {}) { 24 this.host = host; 25 host.addController(this); 26 27 this.multiSelect = multiSelect; 28 this.selectedLists = new Set(); 29 } 30 31 get cards() { 32 return this.host.cards; 33 } 34 35 hostConnected() { 36 this.host.addEventListener("update-selection", this); 37 this.host.addEventListener("clear-selection", this); 38 } 39 40 hostDisconnected() { 41 this.host.removeEventListener("update-selection", this); 42 this.host.removeEventListener("clear-selection", this); 43 } 44 45 /** 46 * Handle events bubbling up from `<sidebar-tab-list>` elements. 47 * 48 * @param {CustomEvent} event 49 */ 50 handleEvent(event) { 51 switch (event.type) { 52 case "update-selection": 53 this.selectedLists.add(event.originalTarget); 54 break; 55 case "clear-selection": 56 this.selectedLists.delete(event.originalTarget); 57 this.clearSelection(); 58 break; 59 } 60 } 61 62 /** 63 * Handle keydown event originating from the card header. 64 * 65 * @param {KeyboardEvent} event 66 */ 67 handleCardKeydown(event) { 68 if (!this.#shouldHandleEvent(event)) { 69 return; 70 } 71 const nextSibling = event.target.nextElementSibling; 72 const prevSibling = event.target.previousElementSibling; 73 let focusedRow = null; 74 switch (event.code) { 75 case "Tab": 76 if (prevSibling?.localName === "moz-card") { 77 event.preventDefault(); 78 } 79 break; 80 case "ArrowUp": 81 if (prevSibling?.localName !== "moz-card") { 82 this.#focusParentHeader(event.target); 83 break; 84 } 85 if (prevSibling?.expanded) { 86 focusedRow = this.#focusLastRow(prevSibling); 87 } else { 88 prevSibling?.summaryEl?.focus(); 89 } 90 break; 91 case "ArrowDown": 92 if (event.target.expanded) { 93 focusedRow = this.#focusFirstRow(event.target); 94 } else if (nextSibling?.localName === "moz-card") { 95 nextSibling?.summaryEl?.focus(); 96 } else if (event.target.classList.contains("last-card")) { 97 const outerCard = event.target.parentElement; 98 const nextOuterCard = outerCard?.nextElementSibling; 99 nextOuterCard?.summaryEl?.focus(); 100 } 101 break; 102 case "ArrowLeft": 103 if (!event.target.expanded) { 104 this.#focusParentHeader(event.target); 105 } else { 106 event.target.expanded = false; 107 } 108 break; 109 case "ArrowRight": 110 if (event.target.expanded) { 111 focusedRow = this.#focusFirstRow(event.target); 112 } else { 113 event.target.expanded = true; 114 } 115 break; 116 case "Home": 117 this.cards[0]?.summaryEl?.focus(); 118 break; 119 case "End": 120 this.#focusLastVisibleRow(); 121 break; 122 } 123 if (this.multiSelect) { 124 this.updateSelection(event, focusedRow); 125 } 126 } 127 128 /** 129 * Check if we should handle this event, or if it should be handled by a 130 * child element such as `<sidebar-tab-list>`. 131 * 132 * @param {KeyboardEvent} event 133 * @returns {boolean} 134 */ 135 #shouldHandleEvent(event) { 136 if (event.keyCode === "Home" || event.keyCode === "End") { 137 // Keys that scroll the entire tree should always be handled. 138 return true; 139 } 140 const headerIsSelected = event.originalTarget === event.target.summaryEl; 141 return headerIsSelected; 142 } 143 144 /** 145 * Focus the first row of this card (either a URL or nested card header). 146 * 147 * @param {MozCard} card 148 * @returns {SidebarTabRow} 149 */ 150 #focusFirstRow(card) { 151 let focusedRow = null; 152 let innerElement = card.contentSlotEl.assignedElements()[0]; 153 if (innerElement.classList.contains("nested-card")) { 154 // Focus the first nested card header. 155 innerElement.summaryEl.focus(); 156 } else { 157 // Focus the first URL. 158 focusedRow = innerElement.rowEls[0]; 159 focusedRow?.focus(); 160 } 161 return focusedRow; 162 } 163 164 /** 165 * Focus the last row of this card (either a URL or nested card header). 166 * 167 * @param {MozCard} card 168 * @returns {SidebarTabRow} 169 */ 170 #focusLastRow(card) { 171 let focusedRow = null; 172 let innerElement = card.contentSlotEl.assignedElements()[0]; 173 if (innerElement.classList.contains("nested-card")) { 174 // Focus the last nested card header (or URL, if nested card is expanded). 175 const lastNestedCard = card.lastElementChild; 176 if (lastNestedCard.expanded) { 177 focusedRow = this.#focusLastRow(lastNestedCard); 178 } else { 179 lastNestedCard.summaryEl.focus(); 180 } 181 } else { 182 // Focus the last URL. 183 focusedRow = innerElement.rowEls[innerElement.rowEls.length - 1]; 184 focusedRow?.focus(); 185 } 186 return focusedRow; 187 } 188 189 /** 190 * Focus the last visible row of the entire tree. 191 */ 192 #focusLastVisibleRow() { 193 const lastCard = this.cards[this.cards.length - 1]; 194 if ( 195 lastCard.classList.contains("nested-card") && 196 !lastCard.parentElement.expanded 197 ) { 198 // If this is an inner card, and the outer card is collapsed, then focus 199 // the outer header. 200 lastCard.parentElement.summaryEl.focus(); 201 } else if (lastCard.expanded) { 202 this.#focusLastRow(lastCard); 203 } else { 204 lastCard.summaryEl.focus(); 205 } 206 } 207 208 /** 209 * If we're currently on a nested card, focus the "outer" card's header. 210 * 211 * @param {MozCard} card 212 */ 213 #focusParentHeader(card) { 214 if (card.classList.contains("nested-card")) { 215 card.parentElement.summaryEl.focus(); 216 } 217 } 218 219 /** 220 * When a row is focused while the shift key is held down, add it to the 221 * selection. If shift key was not held down, clear the selection. 222 * 223 * @param {KeyboardEvent} event 224 * @param {SidebarTabRow} rowEl 225 */ 226 updateSelection(event, rowEl) { 227 if (event.code !== "ArrowUp" && event.code !== "ArrowDown") { 228 return; 229 } 230 if (!event.shiftKey) { 231 this.clearSelection(); 232 return; 233 } 234 if (rowEl != null) { 235 const listForRow = rowEl.getRootNode().host; 236 listForRow.selectedGuids.add(rowEl.guid); 237 listForRow.requestVirtualListUpdate(); 238 this.selectedLists.add(listForRow); 239 } 240 } 241 242 /** 243 * Clear the selection from all lists. 244 */ 245 clearSelection() { 246 for (const list of this.selectedLists) { 247 list.clearSelection(); 248 } 249 this.selectedLists.clear(); 250 } 251 }