ExamplesPlaygroundReference Source

coral-spectrum/coral-component-tooltip/src/scripts/Tooltip.js

  1. /**
  2. * Copyright 2019 Adobe. All rights reserved.
  3. * This file is licensed to you under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License. You may obtain a copy
  5. * of the License at http://www.apache.org/licenses/LICENSE-2.0
  6. *
  7. * Unless required by applicable law or agreed to in writing, software distributed under
  8. * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
  9. * OF ANY KIND, either express or implied. See the License for the specific language
  10. * governing permissions and limitations under the License.
  11. */
  12.  
  13. import {ExtensibleOverlay, Overlay} from '../../../coral-component-overlay';
  14. import Vent from '@adobe/vent';
  15. import base from '../templates/base';
  16. import {commons, transform, validate} from '../../../coral-utils';
  17. import {Decorator} from '../../../coral-decorator';
  18.  
  19. const arrowMap = {
  20. left: 'left',
  21. right: 'right',
  22. top: 'top',
  23. bottom: 'bottom'
  24. };
  25.  
  26. const CLASSNAME = '_coral-Tooltip';
  27.  
  28. const OFFSET = 5;
  29.  
  30. /**
  31. Enumeration for {@link Tooltip} variants.
  32.  
  33. @typedef {Object} TooltipVariantEnum
  34.  
  35. @property {String} DEFAULT
  36. A default tooltip that provides additional information.
  37. @property {String} INFO
  38. A tooltip that informs the user of non-critical information.
  39. @property {String} SUCCESS
  40. A tooltip that indicates an operation was successful.
  41. @property {String} ERROR
  42. A tooltip that indicates an error has occurred.
  43. @property {String} WARNING
  44. Not supported. Falls back to DEFAULT.
  45. @property {String} INSPECT
  46. Not supported. Falls back to DEFAULT.
  47. */
  48. const variant = {
  49. DEFAULT: 'default',
  50. INFO: 'info',
  51. SUCCESS: 'success',
  52. ERROR: 'error',
  53. WARNING: 'warning',
  54. INSPECT: 'inspect'
  55. };
  56.  
  57. // A string of all possible variant classnames
  58. const ALL_VARIANT_CLASSES = [];
  59. for (const variantName in variant) {
  60. ALL_VARIANT_CLASSES.push(`${CLASSNAME}--${variant[variantName]}`);
  61. }
  62.  
  63. // A string of all position placement classnames
  64. const ALL_PLACEMENT_CLASSES = [];
  65.  
  66. // A map of lowercase directions to their corresponding classname
  67. const placementClassMap = {};
  68. for (const key in Overlay.placement) {
  69. const direction = Overlay.placement[key];
  70. const placementClass = `${CLASSNAME}--${arrowMap[direction]}`;
  71.  
  72. // Store in map
  73. placementClassMap[direction] = placementClass;
  74.  
  75. // Store in list
  76. ALL_PLACEMENT_CLASSES.push(placementClass);
  77. }
  78.  
  79. /**
  80. @class Coral.Tooltip
  81. @classdesc A Tooltip component that can be attached to any element and may be displayed immediately or on hovering the
  82. target element.
  83. @htmltag coral-tooltip
  84. @extends {Overlay}
  85. */
  86. const Tooltip = Decorator(class extends ExtensibleOverlay {
  87. /** @ignore */
  88. constructor() {
  89. super();
  90.  
  91. // Override defaults
  92. this._lengthOffset = OFFSET;
  93. this._overlayAnimationTime = Overlay.FADETIME;
  94. this._focusOnShow = Overlay.focusOnShow.OFF;
  95.  
  96. // Fetch or create the content zone element
  97. this._elements = commons.extend(this._elements, {
  98. content: this.querySelector('coral-tooltip-content') || document.createElement('coral-tooltip-content')
  99. });
  100.  
  101. // Generate template
  102. base.call(this._elements);
  103.  
  104. // Used for events
  105. this._id = commons.getUID();
  106. this._delegateEvents({
  107. 'coral-overlay:positioned': '_onPositioned',
  108. 'coral-overlay:_animate': '_onAnimate',
  109. 'mouseenter': '_onMouseEnter',
  110. 'mouseleave': '_onMouseLeave'
  111. });
  112. }
  113.  
  114. /**
  115. The variant of tooltip. See {@link TooltipVariantEnum}.
  116.  
  117. @type {String}
  118. @default TooltipVariantEnum.DEFAULT
  119. @htmlattribute variant
  120. @htmlattributereflected
  121. */
  122. get variant() {
  123. return this._variant || variant.DEFAULT;
  124. }
  125.  
  126. set variant(value) {
  127. value = transform.string(value).toLowerCase();
  128. this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
  129. this._reflectAttribute('variant', this._variant);
  130.  
  131. this.classList.remove(...ALL_VARIANT_CLASSES);
  132. this.classList.add(`${CLASSNAME}--${this._variant}`);
  133. }
  134.  
  135. /**
  136. The amount of time in miliseconds to wait before showing the tooltip when the target is interacted with.
  137.  
  138. @type {Number}
  139. @default 500
  140. @htmlattribute delay
  141. */
  142. get delay() {
  143. return typeof this._delay === 'number' ? this._delay : 500;
  144. }
  145.  
  146. set delay(value) {
  147. this._delay = transform.number(value);
  148. }
  149.  
  150. /**
  151. The Tooltip content element.
  152.  
  153. @type {TooltipContent}
  154. @contentzone
  155. */
  156. get content() {
  157. return this._getContentZone(this._elements.content);
  158. }
  159.  
  160. set content(value) {
  161. this._setContentZone('content', value, {
  162. handle: 'content',
  163. tagName: 'coral-tooltip-content',
  164. insert: function (content) {
  165. content.classList.add(`${CLASSNAME}-label`);
  166. this.appendChild(content);
  167. }
  168. });
  169. }
  170.  
  171. /**
  172. Inherited from {@link Overlay#open}.
  173. */
  174. get open() {
  175. return super.open;
  176. }
  177.  
  178. set open(value) {
  179. super.open = value;
  180.  
  181. if (!this.open) {
  182. // Stop previous show operations from happening
  183. this._cancelShow();
  184. }
  185. }
  186.  
  187. /**
  188. Inherited from {@link Overlay#target}.
  189. */
  190. get target() {
  191. return super.target;
  192. }
  193.  
  194. set target(value) {
  195. super.target = value;
  196.  
  197. const target = this._getTarget(value);
  198.  
  199. if (target) {
  200. this._elements.tip.hidden = false;
  201.  
  202. if (this.interaction === this.constructor.interaction.ON) {
  203. // Add listeners to the target
  204. this._addTargetListeners(target);
  205. }
  206. } else {
  207. this._elements.tip.hidden = true;
  208. }
  209. }
  210.  
  211. /**
  212. Inherited from {@link Overlay#interaction}.
  213. */
  214. get interaction() {
  215. return super.interaction;
  216. }
  217.  
  218. set interaction(value) {
  219. super.interaction = value;
  220.  
  221. const target = this._getTarget();
  222.  
  223. if (target) {
  224. if (value === this.constructor.interaction.ON) {
  225. this._addTargetListeners(target);
  226. } else {
  227. this._removeTargetListeners(target);
  228. }
  229. }
  230. }
  231.  
  232. /** @ignore */
  233. _onPositioned(event) {
  234. // Set arrow placement
  235. this.classList.remove(...ALL_PLACEMENT_CLASSES);
  236. this.classList.add(placementClassMap[event.detail.placement]);
  237. }
  238.  
  239. _onAnimate() {
  240. // popper attribute
  241. const popperPlacement = this.getAttribute('x-placement');
  242.  
  243. // popper takes care of setting left, top to 0 on positioning
  244. if (popperPlacement === 'left') {
  245. this.style.left = '8px';
  246. } else if (popperPlacement === 'top') {
  247. this.style.top = '8px';
  248. } else if (popperPlacement === 'right') {
  249. this.style.left = '-8px';
  250. } else if (popperPlacement === 'bottom') {
  251. this.style.top = '-8px';
  252. }
  253. }
  254.  
  255. _onMouseEnter() {
  256. if (this.interaction === this.constructor.interaction.ON && this.open) {
  257. // on automatic interaction and tooltip still open and mouse enters the tooltip, cancel hide.
  258. this._cancelHide();
  259. }
  260. }
  261.  
  262. _onMouseLeave() {
  263. if (this.interaction === this.constructor.interaction.ON) {
  264. // on automatic interaction and mouse leave tooltip and execute same flow when mouse leaves target.
  265. this._startHide();
  266. }
  267. }
  268.  
  269. /** @ignore */
  270. _handleFocusOut() {
  271. // The item that should have focus will get it on the next frame
  272. window.requestAnimationFrame(() => {
  273. const targetIsFocused = document.activeElement === this._getTarget();
  274.  
  275. if (!targetIsFocused) {
  276. this._cancelShow();
  277. this.open = false;
  278. }
  279. });
  280. }
  281.  
  282. /** @ignore */
  283. _cancelShow() {
  284. window.clearTimeout(this._showTimeout);
  285. }
  286.  
  287. /** @ignore */
  288. _cancelHide() {
  289. window.clearTimeout(this._hideTimeout);
  290. }
  291.  
  292. /** @ignore */
  293. _startHide() {
  294. if (this.delay === 0) {
  295. // Hide immediately
  296. this._handleFocusOut();
  297. } else {
  298. this._hideTimeout = window.setTimeout(() => {
  299. this._handleFocusOut();
  300. }, this.delay);
  301. }
  302. }
  303.  
  304. /** @ignore */
  305. _addTargetListeners(target) {
  306. // Make sure we don't add listeners twice to the same element for this particular tooltip
  307. if (target[`_hasTooltipListeners${this._id}`]) {
  308. return;
  309. }
  310. target[`_hasTooltipListeners${this._id}`] = true;
  311.  
  312. // Remove listeners from the old target
  313. if (this._oldTarget) {
  314. const oldTarget = this._getTarget(this._oldTarget);
  315. if (oldTarget) {
  316. this._removeTargetListeners(oldTarget);
  317. }
  318. }
  319.  
  320. // Store the current target value
  321. this._oldTarget = target;
  322.  
  323. // Use Vent to bind events on the target
  324. this._targetEvents = new Vent(target);
  325.  
  326. this._targetEvents.on(`mouseenter.Tooltip${this._id}`, this._handleOpenTooltip.bind(this));
  327. this._targetEvents.on(`focusin.Tooltip${this._id}`, this._handleOpenTooltip.bind(this));
  328.  
  329. this._targetEvents.on(`mouseleave.Tooltip${this._id}`, () => {
  330. if (this.interaction === this.constructor.interaction.ON) {
  331. this._startHide();
  332. }
  333. });
  334.  
  335. this._targetEvents.on(`focusout.Tooltip${this._id}`, () => {
  336. if (this.interaction === this.constructor.interaction.ON) {
  337. this._handleFocusOut();
  338. }
  339. });
  340. }
  341.  
  342. _handleOpenTooltip() {
  343. // Don't let the tooltip hide
  344. this._cancelHide();
  345.  
  346. if (!this.open) {
  347. this._cancelShow();
  348.  
  349. if (this.delay === 0) {
  350. // Show immediately
  351. this.show();
  352. } else {
  353. this._showTimeout = window.setTimeout(() => {
  354. this.show();
  355. }, this.delay);
  356. }
  357. }
  358. }
  359.  
  360. /** @ignore */
  361. _removeTargetListeners(target) {
  362. // Remove listeners for this tooltip and mark that the element doesn't have them
  363. // Use the ID so we can support multiple tooltips on the same element
  364. if (this._targetEvents) {
  365. this._targetEvents.off(`.Tooltip${this._id}`);
  366. }
  367. target[`_hasTooltipListeners${this._id}`] = false;
  368. }
  369.  
  370. get _contentZones() {
  371. return {'coral-tooltip-content': 'content'};
  372. }
  373.  
  374. /**
  375. Returns {@link Tooltip} variants.
  376.  
  377. @return {TooltipVariantEnum}
  378. */
  379. static get variant() {
  380. return variant;
  381. }
  382.  
  383. /** @ignore */
  384. static get observedAttributes() {
  385. return super.observedAttributes.concat(['variant', 'delay']);
  386. }
  387.  
  388. /** @ignore */
  389. render() {
  390. super.render();
  391.  
  392. this.classList.add(CLASSNAME);
  393.  
  394. // ARIA
  395. this.setAttribute('role', 'tooltip');
  396. // Let the tooltip be focusable
  397. // We'll marshall focus around when its focused
  398. this.setAttribute('tabindex', '-1');
  399.  
  400. // Default reflected attributes
  401. if (!this._variant) {
  402. this.variant = variant.DEFAULT;
  403. }
  404.  
  405. // Support cloneNode
  406. const tip = this.querySelector('._coral-Tooltip-tip');
  407. if (tip) {
  408. tip.remove();
  409. }
  410.  
  411. const content = this._elements.content;
  412.  
  413. // Move the content into the content zone if none specified
  414. if (!content.parentNode) {
  415. while (this.firstChild) {
  416. content.appendChild(this.firstChild);
  417. }
  418. }
  419.  
  420. // Append template
  421. this.appendChild(this._elements.tip);
  422.  
  423. // Assign the content zone so the insert function will be called
  424. this.content = content;
  425. }
  426. });
  427.  
  428. export default Tooltip;