diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 316976ee9c..8749403007 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -46,7 +46,7 @@ }, { "path": "./dist/js/bootstrap.esm.min.js", - "maxSize": "18.25 kB" + "maxSize": "18.5 kB" }, { "path": "./dist/js/bootstrap.js", diff --git a/js/src/popover.js b/js/src/popover.js index 144ec1cad5..0b255a585e 100644 --- a/js/src/popover.js +++ b/js/src/popover.js @@ -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) } diff --git a/js/src/tooltip.js b/js/src/tooltip.js index f069dc7515..c845961011 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -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) + const tipId = getUID(this.constructor.NAME).toString() + + tip.setAttribute('id', tipId) + + if (this._config.animation) { + tip.classList.add(CLASS_NAME_FADE) + } + this.tip = tip return this.tip } - setContent(tip) { - this._sanitizeAndSetContent(tip, this.getTitle(), SELECTOR_TOOLTIP_INNER) + setContent(content) { + let isShown = false + if (this.tip) { + isShown = this.tip.classList.contains(CLASS_NAME_SHOW) + this.tip.remove() + } + + this._disposePopper() + + this.tip = this._getTemplateFactory(content).toHtml() + + if (isShown) { + this.show() + } } - _sanitizeAndSetContent(template, content, selector) { - const templateElement = SelectorEngine.findOne(selector, template) - - if (!content && templateElement) { - templateElement.remove() - return - } - - // we use append for html objects to maintain js events - this.setElementContent(templateElement, content) - } - - setElementContent(element, content) { - if (element === null) { - return - } - - if (isElement(content)) { - content = getElement(content) - - // 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 - } - - return - } - - 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 + } + + _getContentForTemplate() { + return { + [SELECTOR_TOOLTIP_INNER]: this.getTitle() } } getTitle() { - const title = this._element.getAttribute('data-bs-original-title') || this._config.title - - return this._resolvePossibleFunction(title) + 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 index 0000000000..a9cee1086c --- /dev/null +++ b/js/src/util/template-factory.js @@ -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: '
', + 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 diff --git a/js/tests/unit/popover.spec.js b/js/tests/unit/popover.spec.js index 4452a132d4..b3bba3180e 100644 --- a/js/tests/unit/popover.spec.js +++ b/js/tests/unit/popover.spec.js @@ -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() } diff --git a/js/tests/unit/tooltip.spec.js b/js/tests/unit/tooltip.spec.js index 0cca4acff8..3c28cd837f 100644 --- a/js/tests/unit/tooltip.spec.js +++ b/js/tests/unit/tooltip.spec.js @@ -1041,7 +1041,7 @@ describe('Tooltip', () => { fixtureEl.innerHTML = '' 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 = '' + + 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 = '' + + 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 = '' 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 = [ - '', - '
' - ].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 = [ '
', @@ -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(), '
Tooltip
') + tooltip.setContent({ '.tooltip': '
Tooltip
' }) expect(tooltip.getTipElement().querySelector('div').id).toEqual('childContent') }) @@ -1187,12 +1199,13 @@ describe('Tooltip', () => { html: true }) - tooltip.setElementContent(tooltip.getTipElement(), [ + const content = [ '
', ' ', '
' - ].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 index 0000000000..842c480c2b --- /dev/null +++ b/js/tests/unit/util/template-factory.spec.js @@ -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: '
Click me
' + }) + 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: '
Click me
' + }) + 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: '
', + content: { '#foo': 'Click me' } + }) + expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)') + }) + + it('should not sanitize content', () => { + const factory = new TemplateFactory({ + sanitize: false, + html: true, + template: '
', + content: { '#foo': 'Click me' } + }) + 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: '
', + content: { '#foo': 'Click me' } + }) + 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 = [ + '
' + + '
' + + '
' + + '
' + ].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: '
', + content: { '#bar': 'test' } + }) + + expect(factory.toHtml().outerHTML).toBe('
') + }) + + it('should remove template selector, if content is null', () => { + const factory = new TemplateFactory({ + sanitize: true, + html: true, + template: '
', + content: { '#foo': null } + }) + + expect(factory.toHtml().outerHTML).toBe('
') + }) + + it('should resolve content if is function', () => { + const factory = new TemplateFactory({ + sanitize: true, + html: true, + template: '
', + content: { '#foo': () => null } + }) + + expect(factory.toHtml().outerHTML).toBe('
') + }) + + it('if content is element and "config.html=false", should put content\'s textContent', () => { + fixtureEl.innerHTML = '
foobar
' + const contentElement = fixtureEl.querySelector('div') + + const factory = new TemplateFactory({ + html: false, + template: '
', + 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 = '
foobar
' + const contentElement = fixtureEl.querySelector('div') + + const factory = new TemplateFactory({ + html: true, + template: '
', + 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 = [ + '
' + + '
' + + '
' + + '
' + ].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 = [ + '
' + + '
' + + '
' + + '
' + ].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') + }) + }) +}) diff --git a/site/assets/js/application.js b/site/assets/js/application.js index acf859764e..2c57906c9f 100644 --- a/site/assets/js/application.js +++ b/site/assets/js/application.js @@ -144,11 +144,12 @@ 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() }) @@ -156,11 +157,12 @@ 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 = { diff --git a/site/content/docs/5.1/components/popovers.md b/site/content/docs/5.1/components/popovers.md index dc1c985d3f..0acc76a0a2 100644 --- a/site/content/docs/5.1/components/popovers.md +++ b/site/content/docs/5.1/components/popovers.md @@ -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. diff --git a/site/content/docs/5.1/components/tooltips.md b/site/content/docs/5.1/components/tooltips.md index caa2a2d0c0..16501a3c96 100644 --- a/site/content/docs/5.1/components/tooltips.md +++ b/site/content/docs/5.1/components/tooltips.md @@ -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.