List.js (9498B)
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 "use strict"; 6 7 const { 8 createFactory, 9 createRef, 10 Component, 11 cloneElement, 12 } = require("resource://devtools/client/shared/vendor/react.mjs"); 13 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 14 const { 15 ul, 16 li, 17 div, 18 } = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 19 20 const { scrollIntoView } = ChromeUtils.importESModule( 21 "resource://devtools/client/shared/scroll.mjs" 22 ); 23 const { 24 preventDefaultAndStopPropagation, 25 } = require("resource://devtools/client/shared/events.js"); 26 27 const lazy = {}; 28 ChromeUtils.defineESModuleGetters(lazy, { 29 wrapMoveFocus: "resource://devtools/client/shared/focus.mjs", 30 getFocusableElements: "resource://devtools/client/shared/focus.mjs", 31 }); 32 33 class ListItemClass extends Component { 34 static get propTypes() { 35 return { 36 active: PropTypes.bool, 37 current: PropTypes.bool, 38 onClick: PropTypes.func, 39 item: PropTypes.shape({ 40 key: PropTypes.string, 41 component: PropTypes.object, 42 componentProps: PropTypes.object, 43 className: PropTypes.string, 44 }).isRequired, 45 }; 46 } 47 48 constructor(props) { 49 super(props); 50 51 this.contentRef = createRef(); 52 53 this._setTabbableState = this._setTabbableState.bind(this); 54 this._onKeyDown = this._onKeyDown.bind(this); 55 } 56 57 componentDidMount() { 58 this._setTabbableState(); 59 } 60 61 componentDidUpdate() { 62 this._setTabbableState(); 63 } 64 65 _onKeyDown(event) { 66 const { target, key, shiftKey } = event; 67 68 if (key !== "Tab") { 69 return; 70 } 71 72 const focusMoved = !!lazy.wrapMoveFocus( 73 lazy.getFocusableElements(this.contentRef.current), 74 target, 75 shiftKey 76 ); 77 if (focusMoved) { 78 // Focus was moved to the begining/end of the list, so we need to prevent the 79 // default focus change that would happen here. 80 event.preventDefault(); 81 } 82 83 event.stopPropagation(); 84 } 85 86 /** 87 * Makes sure that none of the focusable elements inside the list item container are 88 * tabbable if the list item is not active. If the list item is active and focus is 89 * outside its container, focus on the first focusable element inside. 90 */ 91 _setTabbableState() { 92 const elms = lazy.getFocusableElements(this.contentRef.current); 93 if (elms.length === 0) { 94 return; 95 } 96 97 if (!this.props.active) { 98 elms.forEach(elm => elm.setAttribute("tabindex", "-1")); 99 return; 100 } 101 102 if (!elms.includes(document.activeElement)) { 103 elms[0].focus(); 104 } 105 } 106 107 render() { 108 const { active, item, current, onClick } = this.props; 109 const { className, component, componentProps } = item; 110 111 return li( 112 { 113 className: `${className}${current ? " current" : ""}${ 114 active ? " active" : "" 115 }`, 116 id: item.key, 117 onClick, 118 onKeyDownCapture: active ? this._onKeyDown : null, 119 }, 120 div( 121 { 122 className: "list-item-content", 123 role: "presentation", 124 ref: this.contentRef, 125 }, 126 cloneElement(component, componentProps || {}) 127 ) 128 ); 129 } 130 } 131 132 const ListItem = createFactory(ListItemClass); 133 134 class List extends Component { 135 static get propTypes() { 136 return { 137 // A list of all items to be rendered using a List component. 138 items: PropTypes.arrayOf( 139 PropTypes.shape({ 140 component: PropTypes.object, 141 componentProps: PropTypes.object, 142 className: PropTypes.string, 143 key: PropTypes.string.isRequired, 144 }) 145 ).isRequired, 146 147 // Note: the two properties below are mutually exclusive. Only one of the 148 // label properties is necessary. 149 // ID of an element whose textual content serves as an accessible label for 150 // a list. 151 labelledBy: PropTypes.string, 152 153 // Accessibility label for a list widget. 154 label: PropTypes.string, 155 }; 156 } 157 158 constructor(props) { 159 super(props); 160 161 this.listRef = createRef(); 162 163 this.state = { 164 active: null, 165 current: null, 166 mouseDown: false, 167 }; 168 169 this._setCurrentItem = this._setCurrentItem.bind(this); 170 this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this); 171 this._onKeyDown = this._onKeyDown.bind(this); 172 } 173 174 shouldComponentUpdate(nextProps, nextState) { 175 const { active, current, mouseDown } = this.state; 176 177 return ( 178 current !== nextState.current || 179 active !== nextState.active || 180 mouseDown === nextState.mouseDown 181 ); 182 } 183 184 _preventArrowKeyScrolling(e) { 185 switch (e.key) { 186 case "ArrowUp": 187 case "ArrowDown": 188 case "ArrowLeft": 189 case "ArrowRight": 190 preventDefaultAndStopPropagation(e); 191 break; 192 } 193 } 194 195 /** 196 * Sets the passed in item to be the current item. 197 * 198 * @param {null | number} index 199 * The index of the item in to be set as current, or undefined to unset the 200 * current item. 201 */ 202 _setCurrentItem(index = -1, options = {}) { 203 const item = this.props.items[index]; 204 if (item !== undefined && !options.preventAutoScroll) { 205 const element = document.getElementById(item.key); 206 scrollIntoView(element, { 207 ...options, 208 container: this.listRef.current, 209 }); 210 } 211 212 const state = {}; 213 if (this.state.active != undefined) { 214 state.active = null; 215 if (this.listRef.current !== document.activeElement) { 216 this.listRef.current.focus(); 217 } 218 } 219 220 if (this.state.current !== index) { 221 this.setState({ 222 ...state, 223 current: index, 224 }); 225 } 226 } 227 228 /** 229 * Handles key down events in the list's container. 230 * 231 * @param {Event} e 232 */ 233 _onKeyDown(e) { 234 const { active, current } = this.state; 235 if (current == null) { 236 return; 237 } 238 239 if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) { 240 return; 241 } 242 243 this._preventArrowKeyScrolling(e); 244 245 const { length } = this.props.items; 246 switch (e.key) { 247 case "ArrowUp": 248 current > 0 && this._setCurrentItem(current - 1, { alignTo: "top" }); 249 break; 250 251 case "ArrowDown": 252 current < length - 1 && 253 this._setCurrentItem(current + 1, { alignTo: "bottom" }); 254 break; 255 256 case "Home": 257 this._setCurrentItem(0, { alignTo: "top" }); 258 break; 259 260 case "End": 261 this._setCurrentItem(length - 1, { alignTo: "bottom" }); 262 break; 263 264 case "Enter": 265 case " ": 266 // On space or enter make current list item active. This means keyboard focus 267 // handling is passed on to the component within the list item. 268 if (document.activeElement === this.listRef.current) { 269 preventDefaultAndStopPropagation(e); 270 if (active !== current) { 271 this.setState({ active: current }); 272 } 273 } 274 break; 275 276 case "Escape": 277 // If current list item is active, make it inactive and let keyboard focusing be 278 // handled normally. 279 preventDefaultAndStopPropagation(e); 280 if (active != null) { 281 this.setState({ active: null }); 282 } 283 284 this.listRef.current.focus(); 285 break; 286 } 287 } 288 289 render() { 290 const { active, current } = this.state; 291 const { items } = this.props; 292 293 return ul( 294 { 295 ref: this.listRef, 296 className: "list", 297 tabIndex: 0, 298 onKeyDown: this._onKeyDown, 299 onKeyPress: this._preventArrowKeyScrolling, 300 onKeyUp: this._preventArrowKeyScrolling, 301 onMouseDown: () => this.setState({ mouseDown: true }), 302 onMouseUp: () => this.setState({ mouseDown: false }), 303 onFocus: () => { 304 if (current != null || this.state.mouseDown) { 305 return; 306 } 307 308 // Only set default current to the first list item if current item is 309 // not yet set and the focus event is not the result of a mouse 310 // interarction. 311 this._setCurrentItem(0); 312 }, 313 onClick: () => { 314 // Focus should always remain on the list container itself. 315 this.listRef.current.focus(); 316 }, 317 onBlur: e => { 318 if (active != null) { 319 const { relatedTarget } = e; 320 if (!this.listRef.current.contains(relatedTarget)) { 321 this.setState({ active: null }); 322 } 323 } 324 }, 325 "aria-label": this.props.label, 326 "aria-labelledby": this.props.labelledBy, 327 "aria-activedescendant": current != null ? items[current].key : null, 328 }, 329 items.map((item, index) => { 330 return ListItem({ 331 item, 332 current: index === current, 333 active: index === active, 334 // We make a key unique depending on whether the list item is in active or 335 // inactive state to make sure that it is actually replaced and the tabbable 336 // state is reset. 337 key: `${item.key}-${index === active ? "active" : "inactive"}`, 338 // Since the user just clicked the item, there's no need to check if it should 339 // be scrolled into view. 340 onClick: () => 341 this._setCurrentItem(index, { preventAutoScroll: true }), 342 }); 343 }) 344 ); 345 } 346 } 347 348 module.exports = { 349 ListItem: ListItemClass, 350 List, 351 };