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