2 throw new Error "You must include the utils.js file before tether.js"
6 {getScrollParent, getSize, getOuterSize, getBounds, getOffsetParent, extend, addClass, removeClass, updateClasses, defer, flush, getScrollBarSize} = Tether.Utils
8 within = (a, b, diff=1) ->
9 a + diff >= b >= a - diff
12 el = document.createElement 'div'
14 for key in ['transform', 'webkitTransform', 'OTransform', 'MozTransform', 'msTransform']
15 if el.style[key] isnt undefined
22 tether.position(false)
27 performance?.now?() ? +new Date
35 if lastDuration? and lastDuration > 16
36 # We voluntarily throttle ourselves if we can't manage 60fps
37 lastDuration = Math.min(lastDuration - 16, 250)
39 # Just in case this is the last event, remember to position just once more
40 pendingTimeout = setTimeout tick, 250
43 if lastCall? and (now() - lastCall) < 10
44 # Some browsers call events a little too frequently, refuse to run more than is reasonable
48 clearTimeout pendingTimeout
55 lastDuration = now() - lastCall
57 for event in ['resize', 'scroll', 'touchmove']
58 window.addEventListener event, tick
78 autoToFixedAttachment = (attachment, relativeToAttachment) ->
79 {left, top} = attachment
82 left = MIRROR_LR[relativeToAttachment.left]
85 top = MIRROR_TB[relativeToAttachment.top]
89 attachmentToOffset = (attachment) ->
91 left: OFFSET_MAP[attachment.left] ? attachment.left
92 top: OFFSET_MAP[attachment.top] ? attachment.top
95 addOffset = (offsets...) ->
96 out = {top: 0, left: 0}
98 for {top, left} in offsets
99 if typeof top is 'string'
100 top = parseFloat(top, 10)
101 if typeof left is 'string'
102 left = parseFloat(left, 10)
109 offsetToPx = (offset, size) ->
110 if typeof offset.left is 'string' and offset.left.indexOf('%') isnt -1
111 offset.left = parseFloat(offset.left, 10) / 100 * size.width
112 if typeof offset.top is 'string' and offset.top.indexOf('%') isnt -1
113 offset.top = parseFloat(offset.top, 10) / 100 * size.height
117 parseAttachment = parseOffset = (value) ->
118 [top, left] = value.split(' ')
125 constructor: (options) ->
130 @setOptions options, false
132 for module in Tether.modules
133 module.initialize?.call(@)
138 if @options.classes?[key]
139 @options.classes[key]
140 else if @options.classes?[key] isnt false
141 if @options.classPrefix
142 "#{ @options.classPrefix }-#{ key }"
148 setOptions: (@options, position=true) ->
152 targetAttachment: 'auto auto'
153 classPrefix: 'tether'
155 @options = extend defaults, @options
157 {@element, @target, @targetModifier} = @options
159 if @target is 'viewport'
160 @target = document.body
161 @targetModifier = 'visible'
162 else if @target is 'scroll-handle'
163 @target = document.body
164 @targetModifier = 'scroll-handle'
166 for key in ['element', 'target']
168 throw new Error "Tether Error: Both element and target must be defined"
172 else if typeof @[key] is 'string'
173 @[key] = document.querySelector @[key]
175 addClass @element, @getClass 'element'
176 addClass @target, @getClass 'target'
178 if not @options.attachment
179 throw new Error "Tether Error: You must provide an attachment"
181 @targetAttachment = parseAttachment @options.targetAttachment
182 @attachment = parseAttachment @options.attachment
183 @offset = parseOffset @options.offset
184 @targetOffset = parseOffset @options.targetOffset
189 if @targetModifier is 'scroll-handle'
190 @scrollParent = @target
192 @scrollParent = getScrollParent @target
194 unless @options.enabled is false
199 switch @targetModifier
201 if @target is document.body
202 {top: pageYOffset, left: pageXOffset, height: innerHeight, width: innerWidth}
204 bounds = getBounds @target
207 height: bounds.height
212 out.height = Math.min(out.height, bounds.height - (pageYOffset - bounds.top))
213 out.height = Math.min(out.height, bounds.height - ((bounds.top + bounds.height) - (pageYOffset + innerHeight)))
214 out.height = Math.min(innerHeight, out.height)
217 out.width = Math.min(out.width, bounds.width - (pageXOffset - bounds.left))
218 out.width = Math.min(out.width, bounds.width - ((bounds.left + bounds.width) - (pageXOffset + innerWidth)))
219 out.width = Math.min(innerWidth, out.width)
222 if out.top < pageYOffset
223 out.top = pageYOffset
224 if out.left < pageXOffset
225 out.left = pageXOffset
231 if target is document.body
232 target = document.documentElement
240 bounds = getBounds target
242 style = getComputedStyle target
244 hasBottomScroll = target.scrollWidth > target.clientWidth or 'scroll' is [style.overflow, style.overflowX] or @target isnt document.body
250 height = bounds.height - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth) - scrollBottom
254 height: height * 0.975 * (height / target.scrollHeight)
255 left: bounds.left + bounds.width - parseFloat(style.borderLeftWidth) - 15
258 if height < 408 and @target is document.body
259 fitAdj = -0.00011 * Math.pow(height, 2) - 0.00727 * height + 22.58
261 if @target isnt document.body
262 out.height = Math.max out.height, 24
264 scrollPercentage = @target.scrollTop / (target.scrollHeight - height)
265 out.top = scrollPercentage * (height - out.height - fitAdj) + bounds.top + parseFloat(style.borderTopWidth)
267 if @target is document.body
268 out.height = Math.max out.height, 24
277 cache: (k, getter) ->
278 # More than one module will often need the same DOM info, so
279 # we keep a cache which is cleared on each position call
283 @_cache[k] = getter.call(@)
287 enable: (position=true) ->
288 addClass @target, @getClass 'enabled'
289 addClass @element, @getClass 'enabled'
292 if @scrollParent isnt document
293 @scrollParent.addEventListener 'scroll', @position
299 removeClass @target, @getClass 'enabled'
300 removeClass @element, @getClass 'enabled'
304 @scrollParent.removeEventListener 'scroll', @position
309 for tether, i in tethers
314 updateAttachClasses: (elementAttach=@attachment, targetAttach=@targetAttachment) ->
315 sides = ['left', 'top', 'bottom', 'right', 'middle', 'center']
317 if @_addAttachClasses?.length
318 # updateAttachClasses can be called more than once in a position call, so
319 # we need to clean up after ourselves such that when the last defer gets
320 # ran it doesn't add any extra classes from previous calls.
321 @_addAttachClasses.splice 0, @_addAttachClasses.length
323 add = @_addAttachClasses ?= []
324 add.push "#{ @getClass('element-attached') }-#{ elementAttach.top }" if elementAttach.top
325 add.push "#{ @getClass('element-attached') }-#{ elementAttach.left }" if elementAttach.left
326 add.push "#{ @getClass('target-attached') }-#{ targetAttach.top }" if targetAttach.top
327 add.push "#{ @getClass('target-attached') }-#{ targetAttach.left }" if targetAttach.left
330 all.push "#{ @getClass('element-attached') }-#{ side }" for side in sides
331 all.push "#{ @getClass('target-attached') }-#{ side }" for side in sides
334 return unless @_addAttachClasses?
336 updateClasses @element, @_addAttachClasses, all
337 updateClasses @target, @_addAttachClasses, all
339 @_addAttachClasses = undefined
341 position: (flushChanges=true) =>
342 # flushChanges commits the changes immediately, leave true unless you are positioning multiple
343 # tethers (in which case call Tether.Utils.flush yourself when you're done)
345 return unless @enabled
349 # Turn 'auto' attachments into the appropriate corner or edge
350 targetAttachment = autoToFixedAttachment(@targetAttachment, @attachment)
352 @updateAttachClasses @attachment, targetAttachment
354 elementPos = @cache 'element-bounds', => getBounds @element
355 {width, height} = elementPos
357 if width is 0 and height is 0 and @lastSize?
358 # We cache the height and width to make it possible to position elements that are
360 {width, height} = @lastSize
362 @lastSize = {width, height}
364 targetSize = targetPos = @cache 'target-bounds', => @getTargetBounds()
366 # Get an actual px offset from the attachment
367 offset = offsetToPx attachmentToOffset(@attachment), {width, height}
368 targetOffset = offsetToPx attachmentToOffset(targetAttachment), targetSize
370 manualOffset = offsetToPx(@offset, {width, height})
371 manualTargetOffset = offsetToPx(@targetOffset, targetSize)
373 # Add the manually provided offset
374 offset = addOffset offset, manualOffset
375 targetOffset = addOffset targetOffset, manualTargetOffset
377 # It's now our goal to make (element position + offset) == (target position + target offset)
378 left = targetPos.left + targetOffset.left - offset.left
379 top = targetPos.top + targetOffset.top - offset.top
381 for module in Tether.modules
382 ret = module.position.call(@, {left, top, targetAttachment, targetPos, @attachment, elementPos, offset, targetOffset, manualOffset, manualTargetOffset, scrollbarSize})
384 if not ret? or typeof ret isnt 'object'
391 # We describe the position three different ways to give the optimizer
392 # a chance to decide the best possible way to position the element
393 # with the fewest repaints.
395 # It's position relative to the page (absolute positioning when
396 # the element is a child of the body)
401 # It's position relative to the viewport (fixed positioning)
403 top: top - pageYOffset
404 bottom: pageYOffset - top - height + innerHeight
405 left: left - pageXOffset
406 right: pageXOffset - left - width + innerWidth
409 if document.body.scrollWidth > window.innerWidth
410 scrollbarSize = @cache 'scrollbar-size', getScrollBarSize
411 next.viewport.bottom -= scrollbarSize.height
413 if document.body.scrollHeight > window.innerHeight
414 scrollbarSize = @cache 'scrollbar-size', getScrollBarSize
415 next.viewport.right -= scrollbarSize.width
417 if document.body.style.position not in ['', 'static'] or document.body.parentElement.style.position not in ['', 'static']
418 # Absolute positioning in the body will be relative to the page, not the 'initial containing block'
419 next.page.bottom = document.body.scrollHeight - top - height
420 next.page.right = document.body.scrollWidth - left - width
422 if @options.optimizations?.moveElement isnt false and not @targetModifier?
423 offsetParent = @cache 'target-offsetparent', => getOffsetParent @target
424 offsetPosition = @cache 'target-offsetparent-bounds', -> getBounds offsetParent
425 offsetParentStyle = getComputedStyle offsetParent
426 elementStyle = getComputedStyle @element
427 offsetParentSize = offsetPosition
430 for side in ['Top', 'Left', 'Bottom', 'Right']
431 offsetBorder[side.toLowerCase()] = parseFloat offsetParentStyle["border#{ side }Width"]
433 offsetPosition.right = document.body.scrollWidth - offsetPosition.left - offsetParentSize.width + offsetBorder.right
434 offsetPosition.bottom = document.body.scrollHeight - offsetPosition.top - offsetParentSize.height + offsetBorder.bottom
436 if next.page.top >= (offsetPosition.top + offsetBorder.top) and next.page.bottom >= offsetPosition.bottom
437 if next.page.left >= (offsetPosition.left + offsetBorder.left) and next.page.right >= offsetPosition.right
438 # We're within the visible part of the target's scroll parent
440 scrollTop = offsetParent.scrollTop
441 scrollLeft = offsetParent.scrollLeft
443 # It's position relative to the target's offset parent (absolute positioning when
444 # the element is moved to be a child of the target's offset parent).
446 top: next.page.top - offsetPosition.top + scrollTop - offsetBorder.top
447 left: next.page.left - offsetPosition.left + scrollLeft - offsetBorder.left
450 # We could also travel up the DOM and try each containing context, rather than only
451 # looking at the body, but we're gonna get diminishing returns.
455 @history.unshift next
457 if @history.length > 3
466 return if not @element.parentNode?
473 for key of position[type]
476 for point in @history
477 unless within(point[type]?[key], position[type][key])
482 same[type][key] = true
484 css = {top: '', left: '', right: '', bottom: ''}
486 transcribe = (same, pos) =>
487 if @options.optimizations?.gpu isnt false
503 css[transformKey] = "translateX(#{ Math.round xPos }px) translateY(#{ Math.round yPos }px)"
505 if transformKey isnt 'msTransform'
506 # The Z transform will keep this in the GPU (faster, and prevents artifacts),
507 # but IE9 doesn't support 3d transforms and will choke.
508 css[transformKey] += " translateZ(0)"
512 css.top = "#{ pos.top }px"
514 css.bottom = "#{ pos.bottom }px"
517 css.left = "#{ pos.left }px"
519 css.right = "#{ pos.right }px"
522 if (same.page.top or same.page.bottom) and (same.page.left or same.page.right)
523 css.position = 'absolute'
524 transcribe same.page, position.page
526 else if (same.viewport.top or same.viewport.bottom) and (same.viewport.left or same.viewport.right)
527 css.position = 'fixed'
529 transcribe same.viewport, position.viewport
531 else if same.offset? and same.offset.top and same.offset.left
532 css.position = 'absolute'
534 offsetParent = @cache 'target-offsetparent', => getOffsetParent @target
536 if getOffsetParent(@element) isnt offsetParent
538 @element.parentNode.removeChild @element
539 offsetParent.appendChild @element
541 transcribe same.offset, position.offset
546 css.position = 'absolute'
547 transcribe {top: true, left: true}, position.page
549 if not moved and @element.parentNode.tagName isnt 'BODY'
550 @element.parentNode.removeChild @element
551 document.body.appendChild @element
553 # Any css change will trigger a repaint, so let's avoid one if nothing changed
557 elVal = @element.style[key]
559 if elVal isnt '' and val isnt '' and key in ['top', 'left', 'bottom', 'right']
560 elVal = parseFloat elVal
565 writeCSS[key] = css[key]
569 extend @element.style, writeCSS
571 Tether.position = position
573 @Tether = extend _Tether, Tether