]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Add a template factory helper to handle all template cases (#34519)
authorGeoSot <geo.sotis@gmail.com>
Thu, 25 Nov 2021 17:14:02 +0000 (19:14 +0200)
committerGitHub <noreply@github.com>
Thu, 25 Nov 2021 17:14:02 +0000 (19:14 +0200)
Co-authored-by: XhmikosR <xhmikosr@gmail.com>
.bundlewatch.config.json
js/src/popover.js
js/src/tooltip.js
js/src/util/template-factory.js [new file with mode: 0644]
js/tests/unit/popover.spec.js
js/tests/unit/tooltip.spec.js
js/tests/unit/util/template-factory.spec.js [new file with mode: 0644]
site/assets/js/application.js
site/content/docs/5.1/components/popovers.md
site/content/docs/5.1/components/tooltips.md

index 316976ee9c11ff6e047356bd8d10cb7bbb264988..87494030073dcaed1c0fb2df239ea45c5045d165 100644 (file)
@@ -46,7 +46,7 @@
     },
     {
       "path": "./dist/js/bootstrap.esm.min.js",
-      "maxSize": "18.25 kB"
+      "maxSize": "18.5 kB"
     },
     {
       "path": "./dist/js/bootstrap.js",
index 144ec1cad59cfc1d472c9ddf85e83f94c6164d31..0b255a585ef73ada110860018323e2bd1d9eefb6 100644 (file)
@@ -78,12 +78,14 @@ class Popover extends Tooltip {
     return this.getTitle() || this._getContent()
   }
 
-  setContent(tip) {
-    this._sanitizeAndSetContent(tip, this.getTitle(), SELECTOR_TITLE)
-    this._sanitizeAndSetContent(tip, this._getContent(), SELECTOR_CONTENT)
+  // Private
+  _getContentForTemplate() {
+    return {
+      [SELECTOR_TITLE]: this.getTitle(),
+      [SELECTOR_CONTENT]: this._getContent()
+    }
   }
 
-  // Private
   _getContent() {
     return this._resolvePossibleFunction(this._config.content)
   }
index f069dc7515981a0cccb05fdee157dd1d891eb5ab..c845961011c53e6fa242dd35323054aafaa0ea7b 100644 (file)
@@ -11,17 +11,16 @@ import {
   findShadowRoot,
   getElement,
   getUID,
-  isElement,
   isRTL,
   noop,
   typeCheckConfig
 } from './util/index'
-import { DefaultAllowlist, sanitizeHtml } from './util/sanitizer'
+import { DefaultAllowlist } from './util/sanitizer'
 import Data from './dom/data'
 import EventHandler from './dom/event-handler'
 import Manipulator from './dom/manipulator'
-import SelectorEngine from './dom/selector-engine'
 import BaseComponent from './base-component'
+import TemplateFactory from './util/template-factory'
 
 /**
  * Constants
@@ -40,6 +39,7 @@ const CLASS_NAME_SHOW = 'show'
 const HOVER_STATE_SHOW = 'show'
 const HOVER_STATE_OUT = 'out'
 
+const SELECTOR_TOOLTIP_ARROW = '.tooltip-arrow'
 const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'
 const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`
 
@@ -132,6 +132,7 @@ class Tooltip extends BaseComponent {
     this._hoverState = ''
     this._activeTrigger = {}
     this._popper = null
+    this._templateFactory = null
 
     // Protected
     this._config = this._getConfig(config)
@@ -227,23 +228,9 @@ class Tooltip extends BaseComponent {
       return
     }
 
-    // A trick to recreate a tooltip in case a new title is given by using the NOT documented `data-bs-original-title`
-    // This will be removed later in favor of a `setContent` method
-    if (this.constructor.NAME === 'tooltip' && this.tip && this.getTitle() !== this.tip.querySelector(SELECTOR_TOOLTIP_INNER).innerHTML) {
-      this._disposePopper()
-      this.tip.remove()
-      this.tip = null
-    }
-
     const tip = this.getTipElement()
-    const tipId = getUID(this.constructor.NAME)
-
-    tip.setAttribute('id', tipId)
-    this._element.setAttribute('aria-describedby', tipId)
 
-    if (this._config.animation) {
-      tip.classList.add(CLASS_NAME_FADE)
-    }
+    this._element.setAttribute('aria-describedby', tip.getAttribute('id'))
 
     const placement = typeof this._config.placement === 'function' ?
       this._config.placement.call(this, tip, this._element) :
@@ -268,11 +255,6 @@ class Tooltip extends BaseComponent {
 
     tip.classList.add(CLASS_NAME_SHOW)
 
-    const customClass = this._resolvePossibleFunction(this._config.customClass)
-    if (customClass) {
-      tip.classList.add(...customClass.split(' '))
-    }
-
     // If this is a touch-enabled device we add extra
     // empty mouseover listeners to the body's immediate children;
     // only needed because of broken event delegation on iOS
@@ -360,69 +342,63 @@ class Tooltip extends BaseComponent {
       return this.tip
     }
 
-    const element = document.createElement('div')
-    element.innerHTML = this._config.template
+    const templateFactory = this._getTemplateFactory(this._getContentForTemplate())
 
-    const tip = element.children[0]
-    this.setContent(tip)
+    const tip = templateFactory.toHtml()
     tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW)
 
-    this.tip = tip
-    return this.tip
-  }
-
-  setContent(tip) {
-    this._sanitizeAndSetContent(tip, this.getTitle(), SELECTOR_TOOLTIP_INNER)
-  }
+    const tipId = getUID(this.constructor.NAME).toString()
 
-  _sanitizeAndSetContent(template, content, selector) {
-    const templateElement = SelectorEngine.findOne(selector, template)
+    tip.setAttribute('id', tipId)
 
-    if (!content && templateElement) {
-      templateElement.remove()
-      return
+    if (this._config.animation) {
+      tip.classList.add(CLASS_NAME_FADE)
     }
 
-    // we use append for html objects to maintain js events
-    this.setElementContent(templateElement, content)
+    this.tip = tip
+    return this.tip
   }
 
-  setElementContent(element, content) {
-    if (element === null) {
-      return
+  setContent(content) {
+    let isShown = false
+    if (this.tip) {
+      isShown = this.tip.classList.contains(CLASS_NAME_SHOW)
+      this.tip.remove()
     }
 
-    if (isElement(content)) {
-      content = getElement(content)
+    this._disposePopper()
 
-      // content is a DOM node or a jQuery
-      if (this._config.html) {
-        if (content.parentNode !== element) {
-          element.innerHTML = ''
-          element.append(content)
-        }
-      } else {
-        element.textContent = content.textContent
-      }
+    this.tip = this._getTemplateFactory(content).toHtml()
 
-      return
+    if (isShown) {
+      this.show()
     }
+  }
 
-    if (this._config.html) {
-      if (this._config.sanitize) {
-        content = sanitizeHtml(content, this._config.allowList, this._config.sanitizeFn)
-      }
-
-      element.innerHTML = content // lgtm [js/xss-through-dom]
+  _getTemplateFactory(content) {
+    if (this._templateFactory) {
+      this._templateFactory.changeContent(content)
     } else {
-      element.textContent = content
+      this._templateFactory = new TemplateFactory({
+        ...this._config,
+        // the `content` var has to be after `this._config`
+        // to override config.content in case of popover
+        content,
+        extraClass: this._resolvePossibleFunction(this._config.customClass)
+      })
     }
+
+    return this._templateFactory
   }
 
-  getTitle() {
-    const title = this._element.getAttribute('data-bs-original-title') || this._config.title
+  _getContentForTemplate() {
+    return {
+      [SELECTOR_TOOLTIP_INNER]: this.getTitle()
+    }
+  }
 
-    return this._resolvePossibleFunction(title)
+  getTitle() {
+    return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('title')
   }
 
   updateAttachment(attachment) {
@@ -456,8 +432,8 @@ class Tooltip extends BaseComponent {
     return offset
   }
 
-  _resolvePossibleFunction(content) {
-    return typeof content === 'function' ? content.call(this._element) : content
+  _resolvePossibleFunction(arg) {
+    return typeof arg === 'function' ? arg.call(this._element) : arg
   }
 
   _getPopperConfig(attachment) {
@@ -485,7 +461,7 @@ class Tooltip extends BaseComponent {
         {
           name: 'arrow',
           options: {
-            element: `.${this.constructor.NAME}-arrow`
+            element: SELECTOR_TOOLTIP_ARROW
           }
         },
         {
@@ -556,15 +532,9 @@ class Tooltip extends BaseComponent {
 
   _fixTitle() {
     const title = this._element.getAttribute('title')
-    const originalTitleType = typeof this._element.getAttribute('data-bs-original-title')
 
-    if (title || originalTitleType !== 'string') {
-      this._element.setAttribute('data-bs-original-title', title || '')
-      if (title && !this._element.getAttribute('aria-label') && !this._element.textContent) {
-        this._element.setAttribute('aria-label', title)
-      }
-
-      this._element.setAttribute('title', '')
+    if (title && !this._element.getAttribute('aria-label') && !this._element.textContent) {
+      this._element.setAttribute('aria-label', title)
     }
   }
 
@@ -670,11 +640,6 @@ class Tooltip extends BaseComponent {
     }
 
     typeCheckConfig(NAME, config, this.constructor.DefaultType)
-
-    if (config.sanitize) {
-      config.template = sanitizeHtml(config.template, config.allowList, config.sanitizeFn)
-    }
-
     return config
   }
 
diff --git a/js/src/util/template-factory.js b/js/src/util/template-factory.js
new file mode 100644 (file)
index 0000000..a9cee10
--- /dev/null
@@ -0,0 +1,161 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.1.3): util/template-factory.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { DefaultAllowlist, sanitizeHtml } from './sanitizer'
+import { getElement, isElement, typeCheckConfig } from '../util/index'
+import SelectorEngine from '../dom/selector-engine'
+
+/**
+ * Constants
+ */
+
+const NAME = 'TemplateFactory'
+
+const Default = {
+  extraClass: '',
+  template: '<div></div>',
+  content: {}, // { selector : text ,  selector2 : text2 , }
+  html: false,
+  sanitize: true,
+  sanitizeFn: null,
+  allowList: DefaultAllowlist
+}
+
+const DefaultType = {
+  extraClass: '(string|function)',
+  template: 'string',
+  content: 'object',
+  html: 'boolean',
+  sanitize: 'boolean',
+  sanitizeFn: '(null|function)',
+  allowList: 'object'
+}
+
+const DefaultContentType = {
+  selector: '(string|element)',
+  entry: '(string|element|function|null)'
+}
+
+/**
+ * Class definition
+ */
+
+class TemplateFactory {
+  constructor(config) {
+    this._config = this._getConfig(config)
+  }
+
+  // Getters
+  static get NAME() {
+    return NAME
+  }
+
+  static get Default() {
+    return Default
+  }
+
+  // Public
+  getContent() {
+    return Object.values(this._config.content)
+      .map(config => this._resolvePossibleFunction(config))
+      .filter(Boolean)
+  }
+
+  hasContent() {
+    return this.getContent().length > 0
+  }
+
+  changeContent(content) {
+    this._checkContent(content)
+    this._config.content = { ...this._config.content, ...content }
+    return this
+  }
+
+  toHtml() {
+    const templateWrapper = document.createElement('div')
+    templateWrapper.innerHTML = this._maybeSanitize(this._config.template)
+
+    for (const [selector, text] of Object.entries(this._config.content)) {
+      this._setContent(templateWrapper, text, selector)
+    }
+
+    const template = templateWrapper.children[0]
+    const extraClass = this._resolvePossibleFunction(this._config.extraClass)
+
+    if (extraClass) {
+      template.classList.add(...extraClass.split(' '))
+    }
+
+    return template
+  }
+
+  // Private
+  _getConfig(config) {
+    config = {
+      ...Default,
+      ...(typeof config === 'object' ? config : {})
+    }
+
+    typeCheckConfig(NAME, config, DefaultType)
+    this._checkContent(config.content)
+
+    return config
+  }
+
+  _checkContent(arg) {
+    for (const [selector, content] of Object.entries(arg)) {
+      typeCheckConfig(NAME, { selector, entry: content }, DefaultContentType)
+    }
+  }
+
+  _setContent(template, content, selector) {
+    const templateElement = SelectorEngine.findOne(selector, template)
+
+    if (!templateElement) {
+      return
+    }
+
+    content = this._resolvePossibleFunction(content)
+
+    if (!content) {
+      templateElement.remove()
+      return
+    }
+
+    if (isElement(content)) {
+      this._putElementInTemplate(getElement(content), templateElement)
+      return
+    }
+
+    if (this._config.html) {
+      templateElement.innerHTML = this._maybeSanitize(content)
+      return
+    }
+
+    templateElement.textContent = content
+  }
+
+  _maybeSanitize(arg) {
+    return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn) : arg
+  }
+
+  _resolvePossibleFunction(arg) {
+    return typeof arg === 'function' ? arg(this) : arg
+  }
+
+  _putElementInTemplate(element, templateElement) {
+    if (this._config.html) {
+      templateElement.innerHTML = ''
+      templateElement.append(element)
+      return
+    }
+
+    templateElement.textContent = element.textContent
+  }
+}
+
+export default TemplateFactory
index 4452a132d4d7d285369baa5ca3d344035b07c453..b3bba3180ea8f3a97917c64aa84882476eb8b1ec 100644 (file)
@@ -162,8 +162,8 @@ describe('Popover', () => {
       const popover = new Popover(popoverEl, {
         content: 'Popover content'
       })
-
-      const spy = spyOn(popover, 'setContent').and.callThrough()
+      expect(popover._templateFactory).toBeNull()
+      let spy = null
       let times = 1
 
       popoverEl.addEventListener('hidden.bs.popover', () => {
@@ -171,11 +171,12 @@ describe('Popover', () => {
       })
 
       popoverEl.addEventListener('shown.bs.popover', () => {
+        spy = spy || spyOn(popover._templateFactory, 'constructor').and.callThrough()
         const popoverDisplayed = document.querySelector('.popover')
 
         expect(popoverDisplayed).not.toBeNull()
         expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('Popover content')
-        expect(spy).toHaveBeenCalledTimes(1)
+        expect(spy).toHaveBeenCalledTimes(0)
         if (times > 1) {
           done()
         }
index 0cca4acff853da0cbccb05a47ccbd06bfb98379e..3c28cd837fe0be584c81d20c4da9a31b2f90a972 100644 (file)
@@ -1041,7 +1041,7 @@ describe('Tooltip', () => {
       fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
 
       const tooltipEl = fixtureEl.querySelector('a')
-      const tooltip = new Tooltip(tooltipEl)
+      const tooltip = new Tooltip(tooltipEl, { animation: false })
 
       const tip = tooltip.getTipElement()
 
@@ -1051,6 +1051,35 @@ describe('Tooltip', () => {
       expect(tip.classList.contains('fade')).toEqual(false)
       expect(tip.querySelector('.tooltip-inner').textContent).toEqual('Another tooltip')
     })
+
+    it('should re-show tip if it was already shown', () => {
+      fixtureEl.innerHTML = '<a href="#" rel="tooltip" data-bs-title="Another tooltip">'
+
+      const tooltipEl = fixtureEl.querySelector('a')
+      const tooltip = new Tooltip(tooltipEl)
+      tooltip.show()
+      const tip = () => tooltip.getTipElement()
+
+      expect(tip().classList.contains('show')).toEqual(true)
+      tooltip.setContent({ '.tooltip-inner': 'foo' })
+
+      expect(tip().classList.contains('show')).toEqual(true)
+      expect(tip().querySelector('.tooltip-inner').textContent).toEqual('foo')
+    })
+
+    it('should keep tip hidden, if it was already hidden before', () => {
+      fixtureEl.innerHTML = '<a href="#" rel="tooltip" data-bs-title="Another tooltip">'
+
+      const tooltipEl = fixtureEl.querySelector('a')
+      const tooltip = new Tooltip(tooltipEl)
+      const tip = () => tooltip.getTipElement()
+
+      expect(tip().classList.contains('show')).toEqual(false)
+      tooltip.setContent({ '.tooltip-inner': 'foo' })
+
+      expect(tip().classList.contains('show')).toEqual(false)
+      expect(tip().querySelector('.tooltip-inner').textContent).toEqual('foo')
+    })
   })
 
   describe('updateAttachment', () => {
@@ -1087,34 +1116,17 @@ describe('Tooltip', () => {
     })
   })
 
-  describe('setElementContent', () => {
+  describe('setContent', () => {
     it('should do nothing if the element is null', () => {
       fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">'
 
       const tooltipEl = fixtureEl.querySelector('a')
       const tooltip = new Tooltip(tooltipEl)
 
-      tooltip.setElementContent(null, null)
+      tooltip.setContent({ '.tooltip': null })
       expect().nothing()
     })
 
-    it('should add the content as a child of the element', () => {
-      fixtureEl.innerHTML = [
-        '<a href="#" rel="tooltip" title="Another tooltip">',
-        '<div id="childContent"></div>'
-      ].join('')
-
-      const tooltipEl = fixtureEl.querySelector('a')
-      const childContent = fixtureEl.querySelector('div')
-      const tooltip = new Tooltip(tooltipEl, {
-        html: true
-      })
-
-      tooltip.setElementContent(tooltip.getTipElement(), childContent)
-
-      expect(childContent.parentNode).toEqual(tooltip.getTipElement())
-    })
-
     it('should do nothing if the content is a child of the element', () => {
       fixtureEl.innerHTML = [
         '<a href="#" rel="tooltip" title="Another tooltip">',
@@ -1128,7 +1140,7 @@ describe('Tooltip', () => {
       })
 
       tooltip.getTipElement().append(childContent)
-      tooltip.setElementContent(tooltip.getTipElement(), childContent)
+      tooltip.setContent({ '.tooltip': childContent })
 
       expect().nothing()
     })
@@ -1145,7 +1157,7 @@ describe('Tooltip', () => {
         html: true
       })
 
-      tooltip.setElementContent(tooltip.getTipElement(), { 0: childContent, jquery: 'jQuery' })
+      tooltip.setContent({ '.tooltip': { 0: childContent, jquery: 'jQuery' } })
 
       expect(childContent.parentNode).toEqual(tooltip.getTipElement())
     })
@@ -1160,7 +1172,7 @@ describe('Tooltip', () => {
       const childContent = fixtureEl.querySelector('div')
       const tooltip = new Tooltip(tooltipEl)
 
-      tooltip.setElementContent(tooltip.getTipElement(), childContent)
+      tooltip.setContent({ '.tooltip': childContent })
 
       expect(childContent.textContent).toEqual(tooltip.getTipElement().textContent)
     })
@@ -1174,7 +1186,7 @@ describe('Tooltip', () => {
         html: true
       })
 
-      tooltip.setElementContent(tooltip.getTipElement(), '<div id="childContent">Tooltip</div>')
+      tooltip.setContent({ '.tooltip': '<div id="childContent">Tooltip</div>' })
 
       expect(tooltip.getTipElement().querySelector('div').id).toEqual('childContent')
     })
@@ -1187,12 +1199,13 @@ describe('Tooltip', () => {
         html: true
       })
 
-      tooltip.setElementContent(tooltip.getTipElement(), [
+      const content = [
         '<div id="childContent">',
         ' <button type="button">test btn</button>',
         '</div>'
-      ].join(''))
+      ].join('')
 
+      tooltip.setContent({ '.tooltip': content })
       expect(tooltip.getTipElement().querySelector('div').id).toEqual('childContent')
       expect(tooltip.getTipElement().querySelector('button')).toEqual(null)
     })
@@ -1203,7 +1216,7 @@ describe('Tooltip', () => {
       const tooltipEl = fixtureEl.querySelector('a')
       const tooltip = new Tooltip(tooltipEl)
 
-      tooltip.setElementContent(tooltip.getTipElement(), 'test')
+      tooltip.setContent({ '.tooltip': 'test' })
 
       expect(tooltip.getTipElement().textContent).toEqual('test')
     })
diff --git a/js/tests/unit/util/template-factory.spec.js b/js/tests/unit/util/template-factory.spec.js
new file mode 100644 (file)
index 0000000..842c480
--- /dev/null
@@ -0,0 +1,305 @@
+import { clearFixture, getFixture } from '../../helpers/fixture'
+import TemplateFactory from '../../../src/util/template-factory'
+
+describe('TemplateFactory', () => {
+  let fixtureEl
+
+  beforeAll(() => {
+    fixtureEl = getFixture()
+  })
+
+  afterEach(() => {
+    clearFixture()
+  })
+
+  describe('NAME', () => {
+    it('should return plugin NAME', () => {
+      expect(TemplateFactory.NAME).toEqual('TemplateFactory')
+    })
+  })
+
+  describe('Default', () => {
+    it('should return plugin default config', () => {
+      expect(TemplateFactory.Default).toEqual(jasmine.any(Object))
+    })
+  })
+
+  describe('toHtml', () => {
+    describe('Sanitization', () => {
+      it('should use "sanitizeHtml" to sanitize template', () => {
+        const factory = new TemplateFactory({
+          sanitize: true,
+          template: '<div><a href="javascript:alert(7)">Click me</a></div>'
+        })
+        const spy = spyOn(factory, '_maybeSanitize').and.callThrough()
+
+        expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)')
+        expect(spy).toHaveBeenCalled()
+      })
+
+      it('should not sanitize template', () => {
+        const factory = new TemplateFactory({
+          sanitize: false,
+          template: '<div><a href="javascript:alert(7)">Click me</a></div>'
+        })
+        const spy = spyOn(factory, '_maybeSanitize').and.callThrough()
+
+        expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)')
+        expect(spy).toHaveBeenCalled()
+      })
+
+      it('should use "sanitizeHtml" to sanitize content', () => {
+        const factory = new TemplateFactory({
+          sanitize: true,
+          html: true,
+          template: '<div id="foo"></div>',
+          content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
+        })
+        expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)')
+      })
+
+      it('should not sanitize content', () => {
+        const factory = new TemplateFactory({
+          sanitize: false,
+          html: true,
+          template: '<div id="foo"></div>',
+          content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
+        })
+        expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)')
+      })
+
+      it('should sanitize content only if "config.html" is enabled', () => {
+        const factory = new TemplateFactory({
+          sanitize: true,
+          html: false,
+          template: '<div id="foo"></div>',
+          content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
+        })
+        const spy = spyOn(factory, '_maybeSanitize').and.callThrough()
+
+        expect(spy).not.toHaveBeenCalled()
+      })
+    })
+
+    describe('Extra Class', () => {
+      it('should add extra class', () => {
+        const factory = new TemplateFactory({
+          extraClass: 'testClass'
+        })
+        expect(factory.toHtml().classList.contains('testClass')).toBeTrue()
+      })
+
+      it('should add extra classes', () => {
+        const factory = new TemplateFactory({
+          extraClass: 'testClass testClass2'
+        })
+        expect(factory.toHtml().classList.contains('testClass')).toBeTrue()
+        expect(factory.toHtml().classList.contains('testClass2')).toBeTrue()
+      })
+
+      it('should resolve class if function is given', () => {
+        const factory = new TemplateFactory({
+          extraClass: arg => {
+            expect(arg).toEqual(factory)
+            return 'testClass'
+          }
+        })
+
+        expect(factory.toHtml().classList.contains('testClass')).toBeTrue()
+      })
+    })
+  })
+
+  describe('Content', () => {
+    it('add simple text content', () => {
+      const template = [
+        '<div>' +
+        '<div class="foo"></div>' +
+        '<div class="foo2"></div>' +
+        '</div>'
+      ].join(' ')
+
+      const factory = new TemplateFactory({
+        template,
+        content: {
+          '.foo': 'bar',
+          '.foo2': 'bar2'
+        }
+      })
+
+      const html = factory.toHtml()
+      expect(html.querySelector('.foo').textContent).toBe('bar')
+      expect(html.querySelector('.foo2').textContent).toBe('bar2')
+    })
+
+    it('should not fill template if selector not exists', () => {
+      const factory = new TemplateFactory({
+        sanitize: true,
+        html: true,
+        template: '<div id="foo"></div>',
+        content: { '#bar': 'test' }
+      })
+
+      expect(factory.toHtml().outerHTML).toBe('<div id="foo"></div>')
+    })
+
+    it('should remove template selector, if content is null', () => {
+      const factory = new TemplateFactory({
+        sanitize: true,
+        html: true,
+        template: '<div><div id="foo"></div></div>',
+        content: { '#foo': null }
+      })
+
+      expect(factory.toHtml().outerHTML).toBe('<div></div>')
+    })
+
+    it('should resolve content if is function', () => {
+      const factory = new TemplateFactory({
+        sanitize: true,
+        html: true,
+        template: '<div><div id="foo"></div></div>',
+        content: { '#foo': () => null }
+      })
+
+      expect(factory.toHtml().outerHTML).toBe('<div></div>')
+    })
+
+    it('if content is element and "config.html=false", should put content\'s textContent', () => {
+      fixtureEl.innerHTML = '<div>foo<span>bar</span></div>'
+      const contentElement = fixtureEl.querySelector('div')
+
+      const factory = new TemplateFactory({
+        html: false,
+        template: '<div><div id="foo"></div></div>',
+        content: { '#foo': contentElement }
+      })
+
+      const fooEl = factory.toHtml().querySelector('#foo')
+      expect(fooEl.innerHTML).not.toBe(contentElement.innerHTML)
+      expect(fooEl.textContent).toBe(contentElement.textContent)
+      expect(fooEl.textContent).toBe('foobar')
+    })
+
+    it('if content is element and "config.html=true", should put content\'s outerHtml as child', () => {
+      fixtureEl.innerHTML = '<div>foo<span>bar</span></div>'
+      const contentElement = fixtureEl.querySelector('div')
+
+      const factory = new TemplateFactory({
+        html: true,
+        template: '<div><div id="foo"></div></div>',
+        content: { '#foo': contentElement }
+      })
+
+      const fooEl = factory.toHtml().querySelector('#foo')
+      expect(fooEl.innerHTML).toBe(contentElement.outerHTML)
+      expect(fooEl.textContent).toBe(contentElement.textContent)
+    })
+  })
+
+  describe('getContent', () => {
+    it('should get content as array', () => {
+      const factory = new TemplateFactory({
+        content: {
+          '.foo': 'bar',
+          '.foo2': 'bar2'
+        }
+      })
+      expect(factory.getContent()).toEqual(['bar', 'bar2'])
+    })
+
+    it('should filter empties', () => {
+      const factory = new TemplateFactory({
+        content: {
+          '.foo': 'bar',
+          '.foo2': '',
+          '.foo3': null,
+          '.foo4': () => 2,
+          '.foo5': () => null
+        }
+      })
+      expect(factory.getContent()).toEqual(['bar', 2])
+    })
+  })
+
+  describe('hasContent', () => {
+    it('should return true, if it has', () => {
+      const factory = new TemplateFactory({
+        content: {
+          '.foo': 'bar',
+          '.foo2': 'bar2',
+          '.foo3': ''
+        }
+      })
+      expect(factory.hasContent()).toBeTrue()
+    })
+
+    it('should return false, if filtered content is empty', () => {
+      const factory = new TemplateFactory({
+        content: {
+          '.foo2': '',
+          '.foo3': null,
+          '.foo4': () => null
+        }
+      })
+      expect(factory.hasContent()).toBeFalse()
+    })
+  })
+  describe('changeContent', () => {
+    it('should change Content', () => {
+      const template = [
+        '<div>' +
+        '<div class="foo"></div>' +
+        '<div class="foo2"></div>' +
+        '</div>'
+      ].join(' ')
+
+      const factory = new TemplateFactory({
+        template,
+        content: {
+          '.foo': 'bar',
+          '.foo2': 'bar2'
+        }
+      })
+
+      const html = selector => factory.toHtml().querySelector(selector).textContent
+      expect(html('.foo')).toEqual('bar')
+      expect(html('.foo2')).toEqual('bar2')
+      factory.changeContent({
+        '.foo': 'test',
+        '.foo2': 'test2'
+      })
+
+      expect(html('.foo')).toEqual('test')
+      expect(html('.foo2')).toEqual('test2')
+    })
+
+    it('should change only the given, content', () => {
+      const template = [
+        '<div>' +
+        '<div class="foo"></div>' +
+        '<div class="foo2"></div>' +
+        '</div>'
+      ].join(' ')
+
+      const factory = new TemplateFactory({
+        template,
+        content: {
+          '.foo': 'bar',
+          '.foo2': 'bar2'
+        }
+      })
+
+      const html = selector => factory.toHtml().querySelector(selector).textContent
+      expect(html('.foo')).toEqual('bar')
+      expect(html('.foo2')).toEqual('bar2')
+      factory.changeContent({
+        '.foo': 'test',
+        '.wrong': 'wrong'
+      })
+
+      expect(html('.foo')).toEqual('test')
+      expect(html('.foo2')).toEqual('bar2')
+    })
+  })
+})
index acf859764e4ad6ed806ad1379a3110737212868b..2c57906c9fbd34906a14c8d83f6ab162794f8050 100644 (file)
 
   clipboard.on('success', function (event) {
     var tooltipBtn = bootstrap.Tooltip.getInstance(event.trigger)
+    var originalTitle = event.trigger.getAttribute('title')
 
-    event.trigger.setAttribute('data-bs-original-title', 'Copied!')
-    tooltipBtn.show()
-
-    event.trigger.setAttribute('data-bs-original-title', 'Copy to clipboard')
+    tooltipBtn.setContent({ '.tooltip-inner': 'Copied!' })
+    event.trigger.addEventListener('hidden.bs.tooltip', function () {
+      tooltipBtn.setContent({ '.tooltip-inner': originalTitle })
+    }, { once: true })
     event.clearSelection()
   })
 
     var modifierKey = /mac/i.test(navigator.userAgent) ? '\u2318' : 'Ctrl-'
     var fallbackMsg = 'Press ' + modifierKey + 'C to copy'
     var tooltipBtn = bootstrap.Tooltip.getInstance(event.trigger)
+    var originalTitle = event.trigger.getAttribute('title')
 
-    event.trigger.setAttribute('data-bs-original-title', fallbackMsg)
-    tooltipBtn.show()
-
-    event.trigger.setAttribute('data-bs-original-title', 'Copy to clipboard')
+    tooltipBtn.setContent({ '.tooltip-inner': fallbackMsg })
+    event.trigger.addEventListener('hidden.bs.tooltip', function () {
+      tooltipBtn.setContent({ '.tooltip-inner': originalTitle })
+    }, { once: true })
   })
 
   anchors.options = {
index dc1c985d3f1dc3e7f63bfd7666ad54d3a4e91079..0acc76a0a2652d47403b26ceca124b795d1af449 100644 (file)
@@ -368,6 +368,21 @@ Removes the ability for an element's popover to be shown. The popover will only
 myPopover.disable()
 ```
 
+#### setContent
+
+Gives a way to change the popover's content after its initialization.
+
+```js
+myPopover.setContent({
+  '.popover-header': 'another title',
+  '.popover-body': 'another content'
+})
+```
+
+{{< callout info >}}
+The `setContent` method accepts an `object` argument, where each property-key is a valid `string` selector within the popover template, and each related property-value can be `string` | `element` | `function` | `null`
+{{< /callout >}}
+
 #### toggleEnabled
 
 Toggles the ability for an element's popover to be shown or hidden.
index caa2a2d0c0fc99a5b27ef3a1566a562c989022bd..16501a3c964ae109af7bb62839f146da7d41b975 100644 (file)
@@ -392,6 +392,17 @@ Removes the ability for an element's tooltip to be shown. The tooltip will only
 tooltip.disable()
 ```
 
+#### setContent
+
+Gives a way to change the tooltip's content after its initialization.
+
+```js
+tooltip.setContent({ '.tooltip-inner': 'another title' })
+```
+{{< callout info >}}
+The `setContent` method accepts an `object` argument, where each property-key is a valid `string` selector within the popover template, and each related property-value can be `string` | `element` | `function` | `null`
+{{< /callout >}}
+
 #### toggleEnabled
 
 Toggles the ability for an element's tooltip to be shown or hidden.