Merge pull request #12911 from atom/ns-ku-add-tooltip-features

Add ability to use custom elements inside tooltips
This commit is contained in:
Nathan Sobo
2016-10-11 08:09:31 -06:00
committed by GitHub
5 changed files with 133 additions and 47 deletions

View File

@@ -112,7 +112,7 @@
"settings-view": "0.243.1",
"snippets": "1.0.3",
"spell-check": "0.68.4",
"status-bar": "1.4.1",
"status-bar": "1.4.2",
"styleguide": "0.47.2",
"symbols-view": "0.113.1",
"tabs": "0.102.2",

View File

@@ -8,7 +8,7 @@ describe "TooltipManager", ->
ctrlY = _.humanizeKeystroke("ctrl-y")
beforeEach ->
manager = new TooltipManager(keymapManager: atom.keymaps)
manager = new TooltipManager(keymapManager: atom.keymaps, viewRegistry: atom.views)
element = document.createElement('div')
element.classList.add('foo')
jasmine.attachToDOM(element)
@@ -16,23 +16,62 @@ describe "TooltipManager", ->
hover = (element, fn) ->
element.dispatchEvent(new CustomEvent('mouseenter', bubbles: false))
element.dispatchEvent(new CustomEvent('mouseover', bubbles: true))
advanceClock(manager.defaults.delay.show)
advanceClock(manager.hoverDefaults.delay.show)
fn()
element.dispatchEvent(new CustomEvent('mouseleave', bubbles: false))
element.dispatchEvent(new CustomEvent('mouseout', bubbles: true))
advanceClock(manager.defaults.delay.hide)
advanceClock(manager.hoverDefaults.delay.hide)
describe "::add(target, options)", ->
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 the trigger is 'hover' (the default)", ->
it "creates a tooltip when hovering over the target element", ->
manager.add element, title: "Title"
hover element, ->
expect(document.body.querySelector(".tooltip")).toHaveText("Title")
it "creates a tooltip immediately if the trigger type is manual", ->
disposable = manager.add element, title: "Title", trigger: "manual"
expect(document.body.querySelector(".tooltip")).toHaveText("Title")
disposable.dispose()
expect(document.body.querySelector(".tooltip")).toBeNull()
describe "when the trigger is 'manual'", ->
it "creates a tooltip immediately and only hides it on dispose", ->
disposable = manager.add element, title: "Title", trigger: "manual"
expect(document.body.querySelector(".tooltip")).toHaveText("Title")
disposable.dispose()
expect(document.body.querySelector(".tooltip")).toBeNull()
describe "when the trigger is 'click'", ->
it "shows and hides the tooltip when the target element is clicked", ->
disposable = manager.add element, title: "Title", trigger: "click"
expect(document.body.querySelector(".tooltip")).toBeNull()
element.click()
expect(document.body.querySelector(".tooltip")).not.toBeNull()
element.click()
expect(document.body.querySelector(".tooltip")).toBeNull()
# Hide the tooltip when clicking anywhere but inside the tooltip element
element.click()
expect(document.body.querySelector(".tooltip")).not.toBeNull()
document.body.querySelector(".tooltip").click()
expect(document.body.querySelector(".tooltip")).not.toBeNull()
document.body.querySelector(".tooltip").firstChild.click()
expect(document.body.querySelector(".tooltip")).not.toBeNull()
document.body.click()
expect(document.body.querySelector(".tooltip")).toBeNull()
# Tooltip can show again after hiding due to clicking outside of the tooltip
element.click()
expect(document.body.querySelector(".tooltip")).not.toBeNull()
element.click()
expect(document.body.querySelector(".tooltip")).toBeNull()
it "allows a custom item to be specified for the content of the tooltip", ->
tooltipElement = document.createElement('div')
manager.add element, item: {element: tooltipElement}
hover element, ->
expect(tooltipElement.closest(".tooltip")).not.toBeNull()
it "allows a custom class to be specified for the tooltip", ->
tooltipElement = document.createElement('div')
manager.add element, title: 'Title', class: 'custom-tooltip-class'
hover element, ->
expect(document.body.querySelector(".tooltip").classList.contains('custom-tooltip-class')).toBe(true)
it "allows jQuery elements to be passed as the target", ->
element2 = document.createElement('div')
@@ -52,20 +91,6 @@ describe "TooltipManager", ->
hover element, -> expect(document.body.querySelector(".tooltip")).toBeNull()
hover element2, -> expect(document.body.querySelector(".tooltip")).toBeNull()
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", ->
it "appends the key binding corresponding to the command to the title", ->

View File

@@ -153,7 +153,7 @@ class AtomEnvironment extends Model
@keymaps = new KeymapManager({@configDirPath, resourcePath, notificationManager: @notifications})
@tooltips = new TooltipManager(keymapManager: @keymaps)
@tooltips = new TooltipManager(keymapManager: @keymaps, viewRegistry: @views)
@commands = new CommandRegistry
@commands.attach(@window)

View File

@@ -2,7 +2,7 @@ _ = require 'underscore-plus'
{Disposable, CompositeDisposable} = require 'event-kit'
Tooltip = null
# Essential: Associates tooltips with HTML elements or selectors.
# Essential: Associates tooltips with HTML elements.
#
# You can get the `TooltipManager` via `atom.tooltips`.
#
@@ -46,25 +46,55 @@ Tooltip = null
module.exports =
class TooltipManager
defaults:
delay:
show: 1000
hide: 100
trigger: 'hover'
container: 'body'
html: true
placement: 'auto top'
viewportPadding: 2
constructor: ({@keymapManager}) ->
hoverDefaults:
{delay: {show: 1000, hide: 100}}
constructor: ({@keymapManager, @viewRegistry}) ->
# Essential: Add a tooltip to the given element.
#
# * `target` An `HTMLElement`
# * `options` See http://getbootstrap.com/javascript/#tooltips-options for a
# full list of options. You can also supply the following additional options:
# * `options` An object with one or more of the following options:
# * `title` A {String} or {Function} to use for the text in the tip. If
# given a function, `this` will be set to the `target` element.
# * `trigger` A {String} that's the same as Bootstrap 'click | hover | focus
# | manual', except 'manual' will show the tooltip immediately.
# a function is passed, `this` will be set to the `target` element. This
# option is mutually exclusive with the `item` option.
# * `html` A {Boolean} affecting the interpetation of the `title` option.
# If `true` (the default), the `title` string will be interpreted as HTML.
# Otherwise it will be interpreted as plain text.
# * `item` A view (object with an `.element` property) or a DOM element
# containing custom content for the tooltip. This option is mutually
# exclusive with the `title` option.
# * `class` A {String} with a class to apply to the tooltip element to
# enable custom styling.
# * `placement` A {String} or {Function} returning a string to indicate
# the position of the tooltip relative to `element`. Can be `'top'`,
# `'bottom'`, `'left'`, `'right'`, or `'auto'`. When `'auto'` is
# specified, it will dynamically reorient the tooltip. For example, if
# placement is `'auto left'`, the tooltip will display to the left when
# possible, otherwise it will display right.
# When a function is used to determine the placement, it is called with
# the tooltip DOM node as its first argument and the triggering element
# DOM node as its second. The `this` context is set to the tooltip
# instance.
# * `trigger` A {String} indicating how the tooltip should be displayed.
# Choose from one of the following options:
# * `'hover'` Show the tooltip when the mouse hovers over the element.
# This is the default.
# * `'click'` Show the tooltip when the element is clicked. The tooltip
# will be hidden after clicking the element again or anywhere else
# outside of the tooltip itself.
# * `'focus'` Show the tooltip when the element is focused.
# * `'manual'` Show the tooltip immediately and only hide it when the
# returned disposable is disposed.
# * `delay` An object specifying the show and hide delay in milliseconds.
# Defaults to `{show: 1000, hide: 100}` if the `trigger` is `hover` and
# otherwise defaults to `0` for both values.
# * `keyBindingCommand` A {String} containing a command name. If you specify
# this option and a key binding exists that matches the command, it will
# be appended to the title or rendered alone if no title is specified.
@@ -92,7 +122,12 @@ class TooltipManager
else if keystroke?
options.title = getKeystroke(bindings)
tooltip = new Tooltip(target, _.defaults(options, @defaults))
delete options.selector
options = _.defaults(options, @defaults)
if options.trigger is 'hover'
options = _.defaults(options, @hoverDefaults)
tooltip = new Tooltip(target, options, @viewRegistry)
hideTooltip = ->
tooltip.leave(currentTarget: target)

View File

@@ -7,13 +7,14 @@ const listen = require('./delegated-listener')
// 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) {
var Tooltip = function (element, options, viewRegistry) {
this.options = null
this.enabled = null
this.timeout = null
this.hoverState = null
this.element = null
this.inState = null
this.viewRegistry = viewRegistry
this.init(element, options)
}
@@ -64,6 +65,14 @@ Tooltip.prototype.init = function (element, options) {
if (trigger === 'click') {
this.disposables.add(listen(this.element, 'click', this.options.selector, this.toggle.bind(this)))
this.hideOnClickOutsideOfTooltip = (event) => {
const tooltipElement = this.getTooltipElement()
if (tooltipElement === event.target) return
if (tooltipElement.contains(event.target)) return
if (this.element === event.target) return
if (this.element.contains(event.target)) return
this.hide()
}
} else if (trigger === 'manual') {
this.show()
} else {
@@ -182,8 +191,11 @@ Tooltip.prototype.leave = function (event) {
Tooltip.prototype.show = function () {
if (this.hasContent() && this.enabled) {
var tip = this.getTooltipElement()
if (this.hideOnClickOutsideOfTooltip) {
window.addEventListener('click', this.hideOnClickOutsideOfTooltip, true)
}
var tip = this.getTooltipElement()
var tipId = this.getUID('tooltip')
this.setContent()
@@ -294,19 +306,33 @@ Tooltip.prototype.replaceArrow = function (delta, dimension, isVertical) {
Tooltip.prototype.setContent = function () {
var tip = this.getTooltipElement()
var title = this.getTitle()
if (this.options.class) {
tip.classList.add(this.options.class)
}
var inner = tip.querySelector('.tooltip-inner')
if (this.options.html) {
inner.innerHTML = title
if (this.options.item) {
inner.appendChild(this.viewRegistry.getView(this.options.item))
} else {
inner.textContent = title
var title = this.getTitle()
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.inState = {}
if (this.hideOnClickOutsideOfTooltip) {
window.removeEventListener('click', this.hideOnClickOutsideOfTooltip, true)
}
this.tip && this.tip.classList.remove('in')
if (this.hoverState !== 'in') this.tip && this.tip.remove()
@@ -328,7 +354,7 @@ Tooltip.prototype.fixTitle = function () {
}
Tooltip.prototype.hasContent = function () {
return this.getTitle()
return this.getTitle() || this.options.item
}
Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) {
@@ -436,7 +462,7 @@ Tooltip.prototype.destroy = function () {
Tooltip.prototype.getDelegateComponent = function (element) {
var component = tooltipComponentsByElement.get(element)
if (!component) {
component = new Tooltip(element, this.getDelegateOptions())
component = new Tooltip(element, this.getDelegateOptions(), this.viewRegistry)
tooltipComponentsByElement.set(element, component)
}
return component