|
| 1 | +/** |
| 2 | + * @module ember-paper |
| 3 | + */ |
| 4 | +import Component from '@glimmer/component'; |
| 5 | +import { tracked } from '@glimmer/tracking'; |
| 6 | +import { action } from '@ember/object'; |
| 7 | +import { guidFor } from '@ember/object/internals'; |
| 8 | +import { assert } from '@ember/debug'; |
| 9 | +import Validation from 'ember-paper/lib/validation'; |
| 10 | +import clamp from 'ember-paper/utils/clamp'; |
| 11 | + |
| 12 | +const SELECT_EDGE_MARGIN = 8; |
| 13 | + |
| 14 | +function getOffsetRect(node) { |
| 15 | + return node |
| 16 | + ? { |
| 17 | + left: node.offsetLeft, |
| 18 | + top: node.offsetTop, |
| 19 | + width: node.offsetWidth, |
| 20 | + height: node.offsetHeight, |
| 21 | + } |
| 22 | + : { left: 0, top: 0, width: 0, height: 0 }; |
| 23 | +} |
| 24 | + |
| 25 | +/** |
| 26 | + * @class PaperSelect |
| 27 | + * @extends Component |
| 28 | + */ |
| 29 | +export default class PaperSelect extends Component { |
| 30 | + /** |
| 31 | + * tracks the validity of an input. |
| 32 | + * |
| 33 | + * @type {Validation} |
| 34 | + * @private |
| 35 | + */ |
| 36 | + validation; |
| 37 | + |
| 38 | + /** |
| 39 | + * The parent this component is bound to. |
| 40 | + * |
| 41 | + * @type {PaperRadioGroup|PaperForm|PaperItem|PaperTabs} |
| 42 | + * @private |
| 43 | + */ |
| 44 | + #parent; |
| 45 | + /** |
| 46 | + * Marks whether the component should register itself to the supplied parent. |
| 47 | + * |
| 48 | + * @type {Boolean} |
| 49 | + * @public |
| 50 | + */ |
| 51 | + shouldRegister; |
| 52 | + |
| 53 | + /** |
| 54 | + * true after having animated scaling out the element. |
| 55 | + * |
| 56 | + * @type {boolean} |
| 57 | + */ |
| 58 | + @tracked didAnimateScale; |
| 59 | + /** |
| 60 | + * true when the element is currently focused. |
| 61 | + * |
| 62 | + * @type {boolean} |
| 63 | + */ |
| 64 | + @tracked isFocused; |
| 65 | + |
| 66 | + constructor(owner, args) { |
| 67 | + super(owner, args); |
| 68 | + |
| 69 | + this.didAnimateScale = false; |
| 70 | + this.isFocused = false; |
| 71 | + |
| 72 | + const elementId = |
| 73 | + this.args.elementId || this.args.inputElementId || guidFor(this); |
| 74 | + |
| 75 | + // Construct Input Validation and pass through of custom attributes. |
| 76 | + this.validation = new Validation( |
| 77 | + elementId, |
| 78 | + this.args.onValidityChange || null, |
| 79 | + this.args.validations, |
| 80 | + this.args.customValidations, |
| 81 | + this.args.errors, |
| 82 | + this.args.errorMessages, |
| 83 | + this.args.isTouched |
| 84 | + ); |
| 85 | + |
| 86 | + if (this.shouldRegister) { |
| 87 | + assert( |
| 88 | + 'A parent component should be supplied to <PaperInput> when shouldRegister=true', |
| 89 | + this.args.parentComponent |
| 90 | + ); |
| 91 | + this.#parent = this.args.parentComponent; |
| 92 | + } |
| 93 | + |
| 94 | + assert( |
| 95 | + '<PaperSelect> requires an `onChange` action or null for no action.', |
| 96 | + this.args.onChange !== undefined |
| 97 | + ); |
| 98 | + } |
| 99 | + |
| 100 | + /** |
| 101 | + * Performs any required DOM setup. |
| 102 | + * |
| 103 | + * @param {HTMLElement} element - the node that has been added to the DOM. |
| 104 | + */ |
| 105 | + @action didInsertNode() { |
| 106 | + // setValue ensures that the input value is the same as this.value |
| 107 | + this.validation.value = this.args.selected; |
| 108 | + |
| 109 | + if (this.shouldRegister) { |
| 110 | + this.#parent.registerChild(this); |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + /** |
| 115 | + * didUpdateNode is called when tracked component attributes change. |
| 116 | + */ |
| 117 | + @action didUpdateNode() { |
| 118 | + // update validation checking |
| 119 | + if (this.args.errors) { |
| 120 | + this.validation.errors = this.args.errors; |
| 121 | + } |
| 122 | + |
| 123 | + this.validation.value = this.args.selected; |
| 124 | + this.validation.validate(this.args); |
| 125 | + this.validation.notifyOnChange(); |
| 126 | + } |
| 127 | + |
| 128 | + /** |
| 129 | + * Performs any required DOM teardown. |
| 130 | + * |
| 131 | + * @param {HTMLElement} element - the node to be removed from the DOM. |
| 132 | + */ |
| 133 | + @action willDestroyNode() { |
| 134 | + // noop |
| 135 | + } |
| 136 | + |
| 137 | + /** |
| 138 | + * lifecycle hook to perform non-DOM related teardown. |
| 139 | + */ |
| 140 | + willDestroy() { |
| 141 | + super.willDestroy(...arguments); |
| 142 | + |
| 143 | + if (this.shouldRegister) { |
| 144 | + this.#parent.unregisterChild(this); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + get isFocusedAndSelected() { |
| 149 | + return this.isFocused && this.args.isSelected; |
| 150 | + } |
| 151 | + |
| 152 | + @action handleClose() { |
| 153 | + this.didAnimateScale = false; |
| 154 | + |
| 155 | + this.validation.isTouched = true; |
| 156 | + this.validation.value = this.args.selected; |
| 157 | + this.validation.validate(this.args); |
| 158 | + this.validation.notifyOnValidityChange(); |
| 159 | + } |
| 160 | + |
| 161 | + @action handleOpen() { |
| 162 | + this.didAnimateScale = false; |
| 163 | + |
| 164 | + this.validation.notifyOnValidityChange(); |
| 165 | + } |
| 166 | + |
| 167 | + @action handleFocus() { |
| 168 | + this.isFocused = true; |
| 169 | + } |
| 170 | + |
| 171 | + @action handleBlur() { |
| 172 | + this.isFocused = false; |
| 173 | + } |
| 174 | + |
| 175 | + @action calculatePosition(trigger, content) { |
| 176 | + let opts = { |
| 177 | + target: trigger, |
| 178 | + parent: document.body, |
| 179 | + selectEl: content.querySelector('md-select-menu'), |
| 180 | + contentEl: content.querySelector('md-content'), |
| 181 | + }; |
| 182 | + |
| 183 | + let containerNode = content; |
| 184 | + let targetNode = opts.target.firstElementChild; // target the label |
| 185 | + let parentNode = opts.parent; |
| 186 | + let selectNode = opts.selectEl; |
| 187 | + let contentNode = opts.contentEl; |
| 188 | + let parentRect = parentNode.getBoundingClientRect(); |
| 189 | + let targetRect = targetNode.getBoundingClientRect(); |
| 190 | + let shouldOpenAroundTarget = false; |
| 191 | + let bounds = { |
| 192 | + left: parentRect.left + SELECT_EDGE_MARGIN, |
| 193 | + top: SELECT_EDGE_MARGIN, |
| 194 | + bottom: parentRect.height - SELECT_EDGE_MARGIN, |
| 195 | + right: parentRect.width - SELECT_EDGE_MARGIN, |
| 196 | + }; |
| 197 | + let spaceAvailable = { |
| 198 | + top: targetRect.top - bounds.top, |
| 199 | + left: targetRect.left - bounds.left, |
| 200 | + right: bounds.right - (targetRect.left + targetRect.width), |
| 201 | + bottom: bounds.bottom - (targetRect.top + targetRect.height), |
| 202 | + }; |
| 203 | + let maxWidth = parentRect.width - SELECT_EDGE_MARGIN * 2; |
| 204 | + let selectedNode = selectNode.querySelector('md-option[selected]'); |
| 205 | + let optionNodes = selectNode.getElementsByTagName('md-option'); |
| 206 | + let optgroupNodes = selectNode.getElementsByTagName('md-optgroup'); |
| 207 | + |
| 208 | + let centeredNode, left, top, transformOrigin; |
| 209 | + |
| 210 | + // If a selected node, center around that |
| 211 | + if (selectedNode) { |
| 212 | + centeredNode = selectedNode; |
| 213 | + // If there are option groups, center around the first option group |
| 214 | + } else if (optgroupNodes.length) { |
| 215 | + centeredNode = optgroupNodes[0]; |
| 216 | + // Otherwise, center around the first optionNode |
| 217 | + } else if (optionNodes.length) { |
| 218 | + centeredNode = optionNodes[0]; |
| 219 | + // In case there are no options, center on whatever's in there... (eg progress indicator) |
| 220 | + } else { |
| 221 | + centeredNode = contentNode.firstElementChild || contentNode; |
| 222 | + } |
| 223 | + |
| 224 | + if (contentNode.offsetWidth > maxWidth) { |
| 225 | + contentNode.style['max-width'] = `${maxWidth}px`; |
| 226 | + } |
| 227 | + if (shouldOpenAroundTarget) { |
| 228 | + contentNode.style['min-width'] = `${targetRect.width}px`; |
| 229 | + } |
| 230 | + |
| 231 | + // Remove padding before we compute the position of the menu |
| 232 | + |
| 233 | + let focusedNode = centeredNode; |
| 234 | + if ((focusedNode.tagName || '').toUpperCase() === 'MD-OPTGROUP') { |
| 235 | + focusedNode = |
| 236 | + optionNodes[0] || contentNode.firstElementChild || contentNode; |
| 237 | + centeredNode = focusedNode; |
| 238 | + } |
| 239 | + |
| 240 | + // Get the selectMenuRect *after* max-width is possibly set above |
| 241 | + containerNode.style.display = 'block'; |
| 242 | + let selectMenuRect = selectNode.getBoundingClientRect(); |
| 243 | + let centeredRect = getOffsetRect(centeredNode); |
| 244 | + |
| 245 | + if (centeredNode) { |
| 246 | + let centeredStyle = window.getComputedStyle(centeredNode); |
| 247 | + centeredRect.paddingLeft = parseInt(centeredStyle.paddingLeft, 10) || 0; |
| 248 | + centeredRect.paddingRight = parseInt(centeredStyle.paddingRight, 10) || 0; |
| 249 | + } |
| 250 | + |
| 251 | + // Get scrollHeight/offsetHeight *after* container is set with display:block |
| 252 | + let isScrollable = contentNode.scrollHeight > contentNode.offsetHeight; |
| 253 | + if (isScrollable) { |
| 254 | + let scrollBuffer = contentNode.offsetHeight / 2; |
| 255 | + contentNode.scrollTop = |
| 256 | + centeredRect.top + centeredRect.height / 2 - scrollBuffer; |
| 257 | + |
| 258 | + if (spaceAvailable.top < scrollBuffer) { |
| 259 | + contentNode.scrollTop = Math.min( |
| 260 | + centeredRect.top, |
| 261 | + contentNode.scrollTop + scrollBuffer - spaceAvailable.top |
| 262 | + ); |
| 263 | + } else if (spaceAvailable.bottom < scrollBuffer) { |
| 264 | + contentNode.scrollTop = Math.max( |
| 265 | + centeredRect.top + centeredRect.height - selectMenuRect.height, |
| 266 | + contentNode.scrollTop - scrollBuffer + spaceAvailable.bottom |
| 267 | + ); |
| 268 | + } |
| 269 | + } |
| 270 | + |
| 271 | + if (shouldOpenAroundTarget) { |
| 272 | + left = targetRect.left; |
| 273 | + top = targetRect.top + targetRect.height; |
| 274 | + transformOrigin = '50% 0'; |
| 275 | + if (top + selectMenuRect.height > bounds.bottom) { |
| 276 | + top = targetRect.top - selectMenuRect.height; |
| 277 | + transformOrigin = '50% 100%'; |
| 278 | + } |
| 279 | + } else { |
| 280 | + left = targetRect.left + centeredRect.left - centeredRect.paddingLeft + 2; |
| 281 | + top = |
| 282 | + Math.floor( |
| 283 | + targetRect.top + |
| 284 | + targetRect.height / 2 - |
| 285 | + centeredRect.height / 2 - |
| 286 | + centeredRect.top + |
| 287 | + contentNode.scrollTop |
| 288 | + ) + 2; |
| 289 | + |
| 290 | + transformOrigin = `${centeredRect.left + targetRect.width / 2}px |
| 291 | + ${ |
| 292 | + centeredRect.top + centeredRect.height / 2 - contentNode.scrollTop |
| 293 | + }px 0px`; |
| 294 | + |
| 295 | + containerNode.style.minWidth = `${ |
| 296 | + targetRect.width + centeredRect.paddingLeft + centeredRect.paddingRight |
| 297 | + }px`; |
| 298 | + } |
| 299 | + |
| 300 | + let containerRect = containerNode.getBoundingClientRect(); |
| 301 | + |
| 302 | + let dropdownTop = clamp( |
| 303 | + bounds.top, |
| 304 | + top, |
| 305 | + bounds.bottom - containerRect.height |
| 306 | + ); |
| 307 | + let dropdownLeft = clamp( |
| 308 | + bounds.left, |
| 309 | + left, |
| 310 | + bounds.right - containerRect.width |
| 311 | + ); |
| 312 | + |
| 313 | + let scaleX = Math.min(targetRect.width / selectMenuRect.width, 1.0); |
| 314 | + let scaleY = Math.min(targetRect.height / selectMenuRect.height, 1.0); |
| 315 | + let style = { |
| 316 | + top: dropdownTop, |
| 317 | + left: dropdownLeft, |
| 318 | + // Animate a scale out if we aren't just repositioning |
| 319 | + transform: !this.didAnimateScale |
| 320 | + ? `scale(${scaleX}, ${scaleY})` |
| 321 | + : undefined, |
| 322 | + 'transform-origin': transformOrigin, |
| 323 | + }; |
| 324 | + |
| 325 | + this.didAnimateScale = true; |
| 326 | + |
| 327 | + return { style, horizontalPosition: '', verticalPosition: '' }; |
| 328 | + } |
| 329 | +} |
0 commit comments