Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(94)

Side by Side Diff: ui/webui/resources/cr_elements/cr_action_menu/cr_action_menu.js

Issue 2814743007: [cr-action-menu] Allow configurable anchors. (Closed)
Patch Set: rebase Created 3 years, 7 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « chrome/test/data/webui/cr_elements/cr_action_menu_test.js ('k') | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
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 })();
OLDNEW
« no previous file with comments | « chrome/test/data/webui/cr_elements/cr_action_menu_test.js ('k') | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698