OLD | NEW |
1 // Copyright 2016 The Chromium Authors. All rights reserved. | 1 // Copyright 2016 The Chromium Authors. All rights reserved. |
2 // Use of this source code is governed by a BSD-style license that can be | 2 // Use of this source code is governed by a BSD-style license that can be |
3 // found in the LICENSE file. | 3 // found in the LICENSE file. |
4 | 4 |
5 Polymer({ | 5 /** |
6 is: 'cr-action-menu', | 6 * @typedef {{ |
7 extends: 'dialog', | 7 * top: number, |
8 | 8 * left: number, |
| 9 * width: (number| undefined), |
| 10 * height: (number| undefined), |
| 11 * anchorAlignmentX: (number| undefined), |
| 12 * anchorAlignmentY: (number| undefined), |
| 13 * minX: (number| undefined), |
| 14 * minY: (number| undefined), |
| 15 * maxX: (number| undefined), |
| 16 * maxY: (number| undefined), |
| 17 * }} |
| 18 */ |
| 19 var ShowConfig; |
| 20 |
| 21 /** |
| 22 * @enum {number} |
| 23 * @const |
| 24 */ |
| 25 var AnchorAlignment = { |
| 26 BEFORE_START: -2, |
| 27 AFTER_START: -1, |
| 28 CENTER: 0, |
| 29 BEFORE_END: 1, |
| 30 AFTER_END: 2, |
| 31 }; |
| 32 |
| 33 (function() { |
9 /** | 34 /** |
10 * List of all options in this action menu. | 35 * Returns the point to start along the X or Y axis given a start and end |
11 * @private {?NodeList<!Element>} | 36 * point to anchor to, the length of the target and the direction to anchor |
| 37 * in. If honoring the anchor would force the menu outside of min/max, this |
| 38 * will ignore the anchor position and try to keep the menu within min/max. |
| 39 * @private |
| 40 * @param {number} start |
| 41 * @param {number} end |
| 42 * @param {number} length |
| 43 * @param {AnchorAlignment} anchorAlignment |
| 44 * @param {number} min |
| 45 * @param {number} max |
| 46 * @return {number} |
12 */ | 47 */ |
13 options_: null, | 48 function getStartPointWithAnchor( |
| 49 start, end, length, anchorAlignment, min, max) { |
| 50 var startPoint = 0; |
| 51 switch (anchorAlignment) { |
| 52 case AnchorAlignment.BEFORE_START: |
| 53 startPoint = -length; |
| 54 break; |
| 55 case AnchorAlignment.AFTER_START: |
| 56 startPoint = start; |
| 57 break; |
| 58 case AnchorAlignment.CENTER: |
| 59 startPoint = (start + end - length) / 2; |
| 60 break; |
| 61 case AnchorAlignment.BEFORE_END: |
| 62 startPoint = end - length; |
| 63 break; |
| 64 case AnchorAlignment.AFTER_END: |
| 65 startPoint = end; |
| 66 break; |
| 67 } |
| 68 |
| 69 if (startPoint + length > max) |
| 70 startPoint = end - length; |
| 71 if (startPoint < min) |
| 72 startPoint = start; |
| 73 return startPoint; |
| 74 } |
| 75 |
14 | 76 |
15 /** | 77 /** |
16 * The element which the action menu will be anchored to. Also the element | 78 * @private |
17 * where focus will be returned after the menu is closed. | 79 * @return {!ShowConfig} |
18 * @private {?Element} | |
19 */ | 80 */ |
20 anchorElement_: null, | 81 function getDefaultShowConfig() { |
21 | 82 return { |
22 /** | 83 top: 0, |
23 * Bound reference to an event listener function such that it can be removed | 84 left: 0, |
24 * on detach. | 85 height: 0, |
25 * @private {?Function} | 86 width: 0, |
26 */ | 87 anchorAlignmentX: AnchorAlignment.AFTER_START, |
27 boundClose_: null, | 88 anchorAlignmentY: AnchorAlignment.AFTER_START, |
28 | 89 minX: 0, |
29 /** @private {boolean} */ | 90 minY: 0, |
30 hasMousemoveListener_: false, | 91 maxX: window.innerWidth, |
31 | 92 maxY: window.innerHeight, |
32 hostAttributes: { | 93 }; |
33 tabindex: 0, | 94 } |
34 }, | 95 |
35 | 96 Polymer({ |
36 listeners: { | 97 is: 'cr-action-menu', |
37 'keydown': 'onKeyDown_', | 98 extends: 'dialog', |
38 'mouseover': 'onMouseover_', | 99 |
39 'tap': 'onTap_', | 100 /** |
40 }, | 101 * List of all options in this action menu. |
41 | 102 * @private {?NodeList<!Element>} |
42 /** override */ | 103 */ |
43 attached: function() { | 104 options_: null, |
44 this.options_ = this.querySelectorAll('.dropdown-item'); | 105 |
45 }, | 106 /** |
46 | 107 * The element which the action menu will be anchored to. Also the element |
47 /** override */ | 108 * where focus will be returned after the menu is closed. Only populated if |
48 detached: function() { | 109 * menu is opened with showAt(). |
49 this.removeListeners_(); | 110 * @private {?Element} |
50 }, | 111 */ |
51 | 112 anchorElement_: null, |
52 /** @private */ | 113 |
53 removeListeners_: function() { | 114 /** |
54 window.removeEventListener('resize', this.boundClose_); | 115 * Bound reference to an event listener function such that it can be removed |
55 window.removeEventListener('popstate', this.boundClose_); | 116 * on detach. |
56 }, | 117 * @private {?Function} |
57 | 118 */ |
58 /** | 119 boundClose_: null, |
59 * @param {!Event} e | 120 |
60 * @private | 121 /** @private {boolean} */ |
61 */ | 122 hasMousemoveListener_: false, |
62 onTap_: function(e) { | 123 |
63 if (e.target == this) { | 124 hostAttributes: { |
64 this.close(); | 125 tabindex: 0, |
65 e.stopPropagation(); | 126 }, |
66 } | 127 |
67 }, | 128 listeners: { |
68 | 129 'keydown': 'onKeyDown_', |
69 /** | 130 'mouseover': 'onMouseover_', |
70 * @param {!KeyboardEvent} e | 131 'tap': 'onTap_', |
71 * @private | 132 }, |
72 */ | 133 |
73 onKeyDown_: function(e) { | 134 /** override */ |
74 if (e.key == 'Tab' || e.key == 'Escape') { | 135 attached: function() { |
75 this.close(); | 136 this.options_ = this.querySelectorAll('.dropdown-item'); |
| 137 }, |
| 138 |
| 139 /** override */ |
| 140 detached: function() { |
| 141 this.removeListeners_(); |
| 142 }, |
| 143 |
| 144 /** @private */ |
| 145 removeListeners_: function() { |
| 146 window.removeEventListener('resize', this.boundClose_); |
| 147 window.removeEventListener('popstate', this.boundClose_); |
| 148 }, |
| 149 |
| 150 /** |
| 151 * @param {!Event} e |
| 152 * @private |
| 153 */ |
| 154 onTap_: function(e) { |
| 155 if (e.target == this) { |
| 156 this.close(); |
| 157 e.stopPropagation(); |
| 158 } |
| 159 }, |
| 160 |
| 161 /** |
| 162 * @param {!KeyboardEvent} e |
| 163 * @private |
| 164 */ |
| 165 onKeyDown_: function(e) { |
| 166 if (e.key == 'Tab' || e.key == 'Escape') { |
| 167 this.close(); |
| 168 e.preventDefault(); |
| 169 return; |
| 170 } |
| 171 |
| 172 if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') |
| 173 return; |
| 174 |
| 175 var nextOption = this.getNextOption_(e.key == 'ArrowDown' ? 1 : -1); |
| 176 if (nextOption) { |
| 177 if (!this.hasMousemoveListener_) { |
| 178 this.hasMousemoveListener_ = true; |
| 179 listenOnce(this, 'mousemove', function(e) { |
| 180 this.onMouseover_(e); |
| 181 this.hasMousemoveListener_ = false; |
| 182 }.bind(this)); |
| 183 } |
| 184 nextOption.focus(); |
| 185 } |
| 186 |
76 e.preventDefault(); | 187 e.preventDefault(); |
77 return; | 188 }, |
78 } | 189 |
79 | 190 /** |
80 if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') | 191 * @param {!Event} e |
81 return; | 192 * @private |
82 | 193 */ |
83 var nextOption = this.getNextOption_(e.key == 'ArrowDown' ? 1 : -1); | 194 onMouseover_: function(e) { |
84 if (nextOption) { | 195 // TODO(scottchen): Using "focus" to determine selected item might mess |
85 if (!this.hasMousemoveListener_) { | 196 // with screen readers in some edge cases. |
86 this.hasMousemoveListener_ = true; | 197 var i = 0; |
87 listenOnce(this, 'mousemove', function(e) { | 198 do { |
88 this.onMouseover_(e); | 199 var target = e.path[i++]; |
89 this.hasMousemoveListener_ = false; | 200 if (target.classList && target.classList.contains('dropdown-item')) { |
90 }.bind(this)); | 201 target.focus(); |
91 } | 202 return; |
92 nextOption.focus(); | 203 } |
93 } | 204 } while (this != target); |
94 | 205 |
95 e.preventDefault(); | 206 // The user moved the mouse off the options. Reset focus to the dialog. |
96 }, | 207 this.focus(); |
97 | 208 }, |
98 /** | 209 |
99 * @param {!Event} e | 210 /** |
100 * @private | 211 * @param {number} step -1 for getting previous option (up), 1 for getting |
101 */ | 212 * next option (down). |
102 onMouseover_: function(e) { | 213 * @return {?Element} The next focusable option, taking into account |
103 // TODO(scottchen): Using "focus" to determine selected item might mess | 214 * disabled/hidden attributes, or null if no focusable option exists. |
104 // with screen readers in some edge cases. | 215 * @private |
105 var i = 0; | 216 */ |
106 do { | 217 getNextOption_: function(step) { |
107 var target = e.path[i++]; | 218 // Using a counter to ensure no infinite loop occurs if all elements are |
108 if (target.classList && target.classList.contains('dropdown-item')) { | 219 // hidden/disabled. |
109 target.focus(); | 220 var counter = 0; |
110 return; | 221 var nextOption = null; |
111 } | 222 var numOptions = this.options_.length; |
112 } while (this != target); | 223 var focusedIndex = |
113 | 224 Array.prototype.indexOf.call(this.options_, this.root.activeElement); |
114 // The user moved the mouse off the options. Reset focus to the dialog. | 225 |
115 this.focus(); | 226 // Handle case where nothing is focused and up is pressed. |
116 }, | 227 if (focusedIndex === -1 && step === -1) |
117 | 228 focusedIndex = 0; |
118 /** | 229 |
119 * @param {number} step -1 for getting previous option (up), 1 for getting | 230 do { |
120 * next option (down). | 231 focusedIndex = (numOptions + focusedIndex + step) % numOptions; |
121 * @return {?Element} The next focusable option, taking into account | 232 nextOption = this.options_[focusedIndex]; |
122 * disabled/hidden attributes, or null if no focusable option exists. | 233 if (nextOption.disabled || nextOption.hidden) |
123 * @private | 234 nextOption = null; |
124 */ | 235 counter++; |
125 getNextOption_: function(step) { | 236 } while (!nextOption && counter < numOptions); |
126 // Using a counter to ensure no infinite loop occurs if all elements are | 237 |
127 // hidden/disabled. | 238 return nextOption; |
128 var counter = 0; | 239 }, |
129 var nextOption = null; | 240 |
130 var numOptions = this.options_.length; | 241 /** @override */ |
131 var focusedIndex = | 242 close: function() { |
132 Array.prototype.indexOf.call(this.options_, this.root.activeElement); | 243 // Removing 'resize' and 'popstate' listeners when dialog is closed. |
133 | 244 this.removeListeners_(); |
134 // Handle case where nothing is focused and up is pressed. | 245 HTMLDialogElement.prototype.close.call(this); |
135 if (focusedIndex === -1 && step === -1) | 246 if (this.anchorElement_) { |
136 focusedIndex = 0; | 247 cr.ui.focusWithoutInk(assert(this.anchorElement_)); |
137 | 248 this.anchorElement_ = null; |
138 do { | 249 } |
139 focusedIndex = (numOptions + focusedIndex + step) % numOptions; | 250 }, |
140 nextOption = this.options_[focusedIndex]; | 251 |
141 if (nextOption.disabled || nextOption.hidden) | 252 /** |
142 nextOption = null; | 253 * Shows the menu anchored to the given element. |
143 counter++; | 254 * @param {!Element} anchorElement |
144 } while (!nextOption && counter < numOptions); | 255 */ |
145 | 256 showAt: function(anchorElement) { |
146 return nextOption; | 257 this.anchorElement_ = anchorElement; |
147 }, | 258 this.anchorElement_.scrollIntoViewIfNeeded(); |
148 | 259 var rect = this.anchorElement_.getBoundingClientRect(); |
149 /** @override */ | 260 this.showAtPosition({ |
150 close: function() { | 261 top: rect.top, |
151 // Removing 'resize' and 'popstate' listeners when dialog is closed. | 262 left: rect.left, |
152 this.removeListeners_(); | 263 height: rect.height, |
153 HTMLDialogElement.prototype.close.call(this); | 264 width: rect.width, |
154 cr.ui.focusWithoutInk(assert(this.anchorElement_)); | 265 // Default to anchoring towards the left. |
155 this.anchorElement_ = null; | 266 anchorAlignmentX: AnchorAlignment.BEFORE_END, |
156 }, | 267 }); |
157 | 268 }, |
158 /** | 269 |
159 * Shows the menu anchored to the given element. | 270 /** |
160 * @param {!Element} anchorElement | 271 * Shows the menu anchored to the given box. The anchor alignment is |
161 */ | 272 * specified as an X and Y alignment which represents a point in the anchor |
162 showAt: function(anchorElement) { | 273 * where the menu will align to, which can have the menu either before or |
163 this.anchorElement_ = anchorElement; | 274 * after the given point in each axis. Center alignment places the center of |
164 this.boundClose_ = this.boundClose_ || function() { | 275 * the menu in line with the center of the anchor. |
165 if (this.open) | 276 * |
166 this.close(); | 277 * y-start |
167 }.bind(this); | 278 * _____________ |
168 window.addEventListener('resize', this.boundClose_); | 279 * | | |
169 window.addEventListener('popstate', this.boundClose_); | 280 * | | |
170 | 281 * | CENTER | |
171 // Reset position to prevent previous values from affecting layout. | 282 * x-start | x | x-end |
172 this.style.left = ''; | 283 * | | |
173 this.style.right = ''; | 284 * |anchor box | |
174 this.style.top = ''; | 285 * |___________| |
175 | 286 * |
176 this.anchorElement_.scrollIntoViewIfNeeded(); | 287 * y-end |
177 this.showModal(); | 288 * |
178 | 289 * For example, aligning the menu to the inside of the top-right edge of |
179 var rect = this.anchorElement_.getBoundingClientRect(); | 290 * the anchor, extending towards the bottom-left would use a alignment of |
180 if (getComputedStyle(this.anchorElement_).direction == 'rtl') { | 291 * (BEFORE_END, AFTER_START), whereas centering the menu below the bottom |
181 var right = window.innerWidth - rect.left - this.offsetWidth; | 292 * edge of the anchor would use (CENTER, AFTER_END). |
182 this.style.right = right + 'px'; | 293 * |
183 } else { | 294 * @param {!ShowConfig} config |
184 var left = rect.right - this.offsetWidth; | 295 */ |
185 this.style.left = left + 'px'; | 296 showAtPosition: function(config) { |
186 } | 297 var c = Object.assign(getDefaultShowConfig(), config); |
187 | 298 |
188 // Attempt to show the menu starting from the top of the rectangle and | 299 var top = c.top; |
189 // extending downwards. If that does not fit within the window, fallback to | 300 var left = c.left; |
190 // starting from the bottom and extending upwards. | 301 var bottom = top + c.height; |
191 var top = rect.top + this.offsetHeight <= window.innerHeight ? rect.top : | 302 var right = left + c.width; |
192 rect.bottom - | 303 |
193 this.offsetHeight - Math.max(rect.bottom - window.innerHeight, 0); | 304 this.boundClose_ = this.boundClose_ || function() { |
194 | 305 if (this.open) |
195 this.style.top = top + 'px'; | 306 this.close(); |
196 }, | 307 }.bind(this); |
197 }); | 308 window.addEventListener('resize', this.boundClose_); |
| 309 window.addEventListener('popstate', this.boundClose_); |
| 310 |
| 311 // Reset position to prevent previous values from affecting layout. |
| 312 this.style.left = ''; |
| 313 this.style.right = ''; |
| 314 this.style.top = ''; |
| 315 |
| 316 this.showModal(); |
| 317 |
| 318 // Flip the X anchor in RTL. |
| 319 var rtl = getComputedStyle(this).direction == 'rtl'; |
| 320 if (rtl) |
| 321 c.anchorAlignmentX *= -1; |
| 322 |
| 323 var menuLeft = getStartPointWithAnchor( |
| 324 left, right, this.offsetWidth, c.anchorAlignmentX, c.minX, c.maxX); |
| 325 |
| 326 if (rtl) { |
| 327 var menuRight = window.innerWidth - menuLeft - this.offsetWidth; |
| 328 this.style.right = menuRight + 'px'; |
| 329 } else { |
| 330 this.style.left = menuLeft + 'px'; |
| 331 } |
| 332 |
| 333 var menuTop = getStartPointWithAnchor( |
| 334 top, bottom, this.offsetHeight, c.anchorAlignmentY, c.minY, c.maxY); |
| 335 this.style.top = menuTop + 'px'; |
| 336 }, |
| 337 }); |
| 338 })(); |
OLD | NEW |