diff options
author | GeoSot <geo.sotis@gmail.com> | 2021-11-25 20:14:02 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-11-25 20:14:02 +0300 |
commit | 94a596fbcb1011ba990da2078ba7e20b39dba2d9 (patch) | |
tree | 26af41580d5cae017e32e29cfef96178e897afa6 /js/src | |
parent | fa33e83f25faf8c378b99126fbd69977e667ad9a (diff) |
Add a template factory helper to handle all template cases (#34519)
Co-authored-by: XhmikosR <xhmikosr@gmail.com>
Diffstat (limited to 'js/src')
-rw-r--r-- | js/src/popover.js | 10 | ||||
-rw-r--r-- | js/src/tooltip.js | 129 | ||||
-rw-r--r-- | js/src/util/template-factory.js | 161 |
3 files changed, 214 insertions, 86 deletions
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) - 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 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: '<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 |