From 3c58e221a2911f2e2bf6ac22294e695f0b33bca0 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Fri, 4 Sep 2015 17:08:11 -0600 Subject: [PATCH] =?UTF-8?q?Add=20custom=20tooltip=20implementation=20that?= =?UTF-8?q?=20doesn=E2=80=99t=20depend=20on=20jQuery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spec/tooltip-manager-spec.coffee | 28 +- src/tooltip-manager.coffee | 27 +- src/tooltip.js | 466 +++++++++++++++++++++++++++++++ 3 files changed, 497 insertions(+), 24 deletions(-) create mode 100644 src/tooltip.js diff --git a/spec/tooltip-manager-spec.coffee b/spec/tooltip-manager-spec.coffee index 8b5fbca40..9c1a8cf65 100644 --- a/spec/tooltip-manager-spec.coffee +++ b/spec/tooltip-manager-spec.coffee @@ -1,5 +1,4 @@ TooltipManager = require '../src/tooltip-manager' -{$} = require 'space-pen' _ = require "underscore-plus" describe "TooltipManager", -> @@ -15,18 +14,31 @@ describe "TooltipManager", -> jasmine.attachToDOM(element) hover = (element, fn) -> - $(element).trigger 'mouseenter' + element.dispatchEvent(new CustomEvent('mouseenter', bubbles: true)) advanceClock(manager.defaults.delay.show) fn() - $(element).trigger 'mouseleave' + element.dispatchEvent(new CustomEvent('mouseleave', bubbles: true)) advanceClock(manager.defaults.delay.hide) describe "::add(target, options)", -> - describe "when the target is an element", -> - it "creates a tooltip based on the given options when hovering over the target element", -> - manager.add element, title: "Title" - hover element, -> - expect(document.body.querySelector(".tooltip")).toHaveText("Title") + it "creates a tooltip based on the given options when hovering over the target element", -> + manager.add element, title: "Title" + hover element, -> + expect(document.body.querySelector(".tooltip")).toHaveText("Title") + + describe "when a selector is specified", -> + it "creates a tooltip when hovering over a descendant of the target that matches the selector", -> + child = document.createElement('div') + child.classList.add('bar') + grandchild = document.createElement('div') + element.appendChild(child) + child.appendChild(grandchild) + + manager.add element, selector: '.bar', title: 'Bar' + + hover grandchild, -> + expect(document.body.querySelector('.tooltip')).toHaveText('Bar') + expect(document.body.querySelector('.tooltip')).toBeNull() describe "when a keyBindingCommand is specified", -> describe "when a title is specified", -> diff --git a/src/tooltip-manager.coffee b/src/tooltip-manager.coffee index c1b5e1c15..1c9236aff 100644 --- a/src/tooltip-manager.coffee +++ b/src/tooltip-manager.coffee @@ -1,6 +1,5 @@ _ = require 'underscore-plus' {Disposable} = require 'event-kit' -{$} = require 'space-pen' # Essential: Associates tooltips with HTML elements or selectors. # @@ -71,7 +70,7 @@ class TooltipManager # Returns a {Disposable} on which `.dispose()` can be called to remove the # tooltip. add: (target, options) -> - requireBootstrapTooltip() + Tooltip = require './tooltip' {keyBindingCommand, keyBindingTarget} = options @@ -83,18 +82,18 @@ class TooltipManager else if keystroke? options.title = getKeystroke(bindings) - $target = $(target) - $target.tooltip(_.defaults(options, @defaults)) + tooltip = new Tooltip(target, _.defaults(options, @defaults)) + + hideTooltip = -> + tooltip.leave(currentTarget: target) + tooltip.hide() + + window.addEventListener('resize', hideTooltip) - removeTooltipOnWindowResize = -> disposable.dispose() - window.addEventListener('resize', removeTooltipOnWindowResize) disposable = new Disposable -> - window.removeEventListener('resize', removeTooltipOnWindowResize) - tooltip = $target.data('bs.tooltip') - if tooltip? - tooltip.leave(currentTarget: target) - tooltip.hide() - $target.tooltip('destroy') + window.removeEventListener('resize', hideTooltip) + hideTooltip() + tooltip.destroy() disposable @@ -106,7 +105,3 @@ humanizeKeystrokes = (keystroke) -> getKeystroke = (bindings) -> if bindings?.length "#{humanizeKeystrokes(bindings[0].keystrokes)}" - else - -requireBootstrapTooltip = _.once -> - atom.requireWithGlobals('bootstrap/js/tooltip', {jQuery: $}) diff --git a/src/tooltip.js b/src/tooltip.js new file mode 100644 index 000000000..ced2a2fbe --- /dev/null +++ b/src/tooltip.js @@ -0,0 +1,466 @@ +'use strict'; + +const EventKit = require('event-kit') +const tooltipComponentsByElement = new WeakMap + +// This tooltip class is derived from Bootstrap 3, but modified to not require +// jQuery, which is an expensive dependency we want to eliminate. + +var Tooltip = function (element, options) { + this.options = null + this.enabled = null + this.timeout = null + this.hoverState = null + this.element = null + this.inState = null + + this.init(element, options) +} + +Tooltip.VERSION = '3.3.5' + +Tooltip.TRANSITION_DURATION = 150 + +Tooltip.DEFAULTS = { + animation: true, + placement: 'top', + selector: false, + template: '', + trigger: 'hover focus', + title: '', + delay: 0, + html: false, + container: false, + viewport: { + selector: 'body', + padding: 0 + } +} + +Tooltip.prototype.init = function (element, options) { + this.enabled = true + this.element = element + this.options = this.getOptions(options) + this.disposables = new EventKit.CompositeDisposable() + + if (this.options.viewport) { + if (typeof this.options.viewport === 'function') { + this.viewport = this.options.viewport.call(this, this.element) + } else { + this.viewport = document.querySelector(this.options.viewport.selector || this.options.viewport) + } + } + this.inState = {click: false, hover: false, focus: false} + + if (this.element instanceof document.constructor && !this.options.selector) { + throw new Error('`selector` option must be specified when initializing tooltip on the window.document object!') + } + + var triggers = this.options.trigger.split(' ') + + for (var i = triggers.length; i--;) { + var trigger = triggers[i] + + if (trigger == 'click') { + this.disposables.add(listen(this.element, 'click', this.options.selector, this.toggle.bind(this))) + } else if (trigger != 'manual') { + var eventIn = trigger == 'hover' ? 'mouseenter' : 'focusin' + var eventOut = trigger == 'hover' ? 'mouseleave' : 'focusout' + this.disposables.add(listen(this.element, eventIn, this.options.selector, this.enter.bind(this))) + this.disposables.add(listen(this.element, eventOut, this.options.selector, this.leave.bind(this))) + } + } + + this.options.selector ? + (this._options = extend({}, this.options, { trigger: 'manual', selector: '' })) : + this.fixTitle() +} + +Tooltip.prototype.getDefaults = function () { + return Tooltip.DEFAULTS +} + +Tooltip.prototype.getOptions = function (options) { + options = extend({}, this.getDefaults(), options) + + if (options.delay && typeof options.delay == 'number') { + options.delay = { + show: options.delay, + hide: options.delay + } + } + + return options +} + +Tooltip.prototype.getDelegateOptions = function () { + var options = {} + var defaults = this.getDefaults() + + if (this._options) { + for (var key of Object.getOwnPropertyNames(this._options)) { + var value = this._options[key] + if (defaults[key] != value) options[key] = value + } + } + + return options +} + +Tooltip.prototype.enter = function (event) { + if (event) { + if (event.currentTarget !== this.element) { + this.getDelegateComponent(event.currentTarget).enter(event) + return + } + + this.inState[event.type == 'focusin' ? 'focus' : 'hover'] = true + } + + if (this.getTooltipElement().classList.contains('in') || this.hoverState == 'in') { + this.hoverState = 'in' + return + } + + clearTimeout(this.timeout) + + this.hoverState = 'in' + + if (!this.options.delay || !this.options.delay.show) return this.show() + + this.timeout = setTimeout(function () { + if (this.hoverState == 'in') this.show() + }.bind(this), this.options.delay.show) +} + +Tooltip.prototype.isInStateTrue = function () { + for (var key in this.inState) { + if (this.inState[key]) return true + } + + return false +} + +Tooltip.prototype.leave = function (event) { + if (event) { + if (event.currentTarget !== this.element) { + this.getDelegateComponent(event.currentTarget).leave(event) + return + } + + this.inState[event.type == 'focusout' ? 'focus' : 'hover'] = false + } + + if (this.isInStateTrue()) return + + clearTimeout(this.timeout) + + this.hoverState = 'out' + + if (!this.options.delay || !this.options.delay.hide) return this.hide() + + this.timeout = setTimeout(function () { + if (this.hoverState == 'out') this.hide() + }.bind(this), this.options.delay.hide) +} + +Tooltip.prototype.show = function () { + if (this.hasContent() && this.enabled) { + var inDom = this.element.ownerDocument.documentElement.contains(this.element) + + var tip = this.getTooltipElement() + + var tipId = this.getUID('tooltip') + + this.setContent() + tip.setAttribute('id', tipId) + this.element.setAttribute('aria-describedby', tipId) + + if (this.options.animation) tip.classList.add('fade') + + var placement = typeof this.options.placement == 'function' ? + this.options.placement.call(this, tip, this.element) : + this.options.placement + + var autoToken = /\s?auto?\s?/i + var autoPlace = autoToken.test(placement) + if (autoPlace) placement = placement.replace(autoToken, '') || 'top' + + tip.remove() + tip.style.top = '0px' + tip.style.left = '0px' + tip.style.display = 'block' + tip.classList.add(placement) + + document.body.appendChild(tip) + + var pos = this.element.getBoundingClientRect() + var actualWidth = tip.offsetWidth + var actualHeight = tip.offsetHeight + + if (autoPlace) { + var orgPlacement = placement + var viewportDim = this.viewport.getBoundingClientRect() + + placement = placement == 'bottom' && pos.bottom + actualHeight > viewportDim.bottom ? 'top' : + placement == 'top' && pos.top - actualHeight < viewportDim.top ? 'bottom' : + placement == 'right' && pos.right + actualWidth > viewportDim.width ? 'left' : + placement == 'left' && pos.left - actualWidth < viewportDim.left ? 'right' : + placement + + tip.classList.remove(orgPlacement) + tip.classList.add(placement) + } + + var calculatedOffset = this.getCalculatedOffset(placement, pos, actualWidth, actualHeight) + + this.applyPlacement(calculatedOffset, placement) + + var prevHoverState = this.hoverState + this.hoverState = null + + if (prevHoverState == 'out') this.leave() + } +} + +Tooltip.prototype.applyPlacement = function (offset, placement) { + var tip = this.getTooltipElement() + + var width = tip.offsetWidth + var height = tip.offsetHeight + + // manually read margins because getBoundingClientRect includes difference + var computedStyle = getComputedStyle(tip) + var marginTop = parseInt(computedStyle.marginTop, 10) + var marginLeft = parseInt(computedStyle.marginLeft, 10) + + offset.top += marginTop + offset.left += marginLeft + + tip.style.top = offset.top + 'px' + tip.style.left = offset.left + 'px' + + tip.classList.add('in') + + // check to see if placing tip in new offset caused the tip to resize itself + var actualWidth = tip.offsetWidth + var actualHeight = tip.offsetHeight + + if (placement == 'top' && actualHeight != height) { + offset.top = offset.top + height - actualHeight + } + + var delta = this.getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight) + + if (delta.left) offset.left += delta.left + else offset.top += delta.top + + var isVertical = /top|bottom/.test(placement) + var arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight + var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight' + + tip.style.top = offset.top + 'px' + tip.style.left = offset.left + 'px' + + this.replaceArrow(arrowDelta, tip[arrowOffsetPosition], isVertical) +} + +Tooltip.prototype.replaceArrow = function (delta, dimension, isVertical) { + var arrow = this.getArrowElement() + var amount = 50 * (1 - delta / dimension) + '%' + + if (isVertical) { + arrow.style.left = amount + arrow.style.top = '' + } else { + arrow.style.top = amount + arrow.style.left = '' + } +} + +Tooltip.prototype.setContent = function () { + var tip = this.getTooltipElement() + var title = this.getTitle() + + var inner = tip.querySelector('.tooltip-inner') + if (this.options.html) { + inner.innerHTML = title + } else { + inner.textContent = title + } + + tip.classList.remove('fade', 'in', 'top', 'bottom', 'left', 'right') +} + +Tooltip.prototype.hide = function (callback) { + this.tip && this.tip.classList.remove('in') + + if (this.hoverState != 'in') this.tip && this.tip.remove() + + this.element.removeAttribute('aria-describedby') + + callback && callback() + + this.hoverState = null + + return this +} + +Tooltip.prototype.fixTitle = function () { + if (this.element.getAttribute('title') || typeof this.element.getAttribute('data-original-title') != 'string') { + this.element.setAttribute('data-original-title', this.element.getAttribute('title') || '') + this.element.setAttribute('title', '') + } +} + +Tooltip.prototype.hasContent = function () { + return this.getTitle() +} + +Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) { + return placement == 'bottom' ? { top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'top' ? { top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2 } : + placement == 'left' ? { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth } : + /* placement == 'right' */ { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width } +} + +Tooltip.prototype.getViewportAdjustedDelta = function (placement, pos, actualWidth, actualHeight) { + var delta = { top: 0, left: 0 } + if (!this.viewport) return delta + + var viewportPadding = this.options.viewport && this.options.viewport.padding || 0 + var viewportDimensions = this.viewport.getBoundingClientRect() + + if (/right|left/.test(placement)) { + var topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll + var bottomEdgeOffset = pos.top + viewportPadding - viewportDimensions.scroll + actualHeight + if (topEdgeOffset < viewportDimensions.top) { // top overflow + delta.top = viewportDimensions.top - topEdgeOffset + } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow + delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset + } + } else { + var leftEdgeOffset = pos.left - viewportPadding + var rightEdgeOffset = pos.left + viewportPadding + actualWidth + if (leftEdgeOffset < viewportDimensions.left) { // left overflow + delta.left = viewportDimensions.left - leftEdgeOffset + } else if (rightEdgeOffset > viewportDimensions.right) { // right overflow + delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset + } + } + + return delta +} + +Tooltip.prototype.getTitle = function () { + var title = this.element.getAttribute('data-original-title') + if (title) { + return title + } else { + return (typeof this.options.title == 'function') + ? this.options.title.call(this.element) + : this.options.title + } +} + +Tooltip.prototype.getUID = function (prefix) { + do prefix += ~~(Math.random() * 1000000) + while (document.getElementById(prefix)) + return prefix +} + +Tooltip.prototype.getTooltipElement = function () { + if (!this.tip) { + let div = document.createElement('div') + div.innerHTML = this.options.template + if (div.children.length != 1) { + throw new Error('Tooltip `template` option must consist of exactly 1 top-level element!') + } + this.tip = div.firstChild + } + return this.tip +} + +Tooltip.prototype.getArrowElement = function () { + return (this.arrow = this.arrow || this.getTooltipElement().querySelector('.tooltip-arrow')) +} + +Tooltip.prototype.enable = function () { + this.enabled = true +} + +Tooltip.prototype.disable = function () { + this.enabled = false +} + +Tooltip.prototype.toggleEnabled = function () { + this.enabled = !this.enabled +} + +Tooltip.prototype.toggle = function (event) { + if (event) { + if (event.currentTarget !== this.element) { + this.getDelegateComponent(event.currentTarget).toggle(event) + return + } + + this.inState.click = !this.inState.click + if (this.isInStateTrue()) this.enter() + else this.leave() + } else { + this.getTooltipElement().classList.contains('in') ? this.leave() : this.enter() + } +} + +Tooltip.prototype.destroy = function () { + clearTimeout(this.timeout) + this.tip && this.tip.remove() + this.disposables.dispose() +} + +Tooltip.prototype.getDelegateComponent = function (element) { + var component = tooltipComponentsByElement.get(element) + if (!component) { + component = new Tooltip(element, this.getDelegateOptions()) + tooltipComponentsByElement.set(element, component) + } + return component +} + +function extend () { + var args = Array.prototype.slice.apply(arguments) + var target = args.shift() + var source + while (source = args.shift()) { + for (var key of Object.getOwnPropertyNames(source)) { + target[key] = source[key] + } + } + return target +} + +function listen(element, eventName, selector, handler) { + var innerHandler = function (event) { + if (selector) { + var currentTarget = event.target + while (true) { + if (!currentTarget) debugger + if (currentTarget.matches(selector)) { + handler({type: event.type, currentTarget: currentTarget}) + } + currentTarget = currentTarget.parentElement + if (currentTarget === element) break + } + } else { + handler(event) + } + } + + element.addEventListener(eventName, innerHandler) + + return new EventKit.Disposable(function () { + element.removeEventListener(eventName, innerHandler) + }) +} + +module.exports = Tooltip