diff --git a/.cspell.json b/.cspell.json index 3d30b10716..3995ec1222 100644 --- a/.cspell.json +++ b/.cspell.json @@ -19,6 +19,7 @@ "btnradio", "callout", "callouts", + "camelCase", "clearfix", "Codesniffer", "combinator", diff --git a/js/src/dom/manipulator.js b/js/src/dom/manipulator.js index 5e6ad92ae7..2d96d65fc8 100644 --- a/js/src/dom/manipulator.js +++ b/js/src/dom/manipulator.js @@ -22,7 +22,15 @@ function normalizeData(value) { return null } - return value + if (typeof value !== 'string') { + return value + } + + try { + return JSON.parse(decodeURIComponent(value)) + } catch { + return value + } } function normalizeDataKey(key) { @@ -44,7 +52,7 @@ const Manipulator = { } const attributes = {} - const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs')) + const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig')) for (const key of bsKeys) { let pureKey = key.replace(/^bs/, '') diff --git a/js/src/util/config.js b/js/src/util/config.js index 19d02955dd..f6c194276b 100644 --- a/js/src/util/config.js +++ b/js/src/util/config.js @@ -38,8 +38,11 @@ class Config { } _mergeConfigObj(config, element) { + const jsonConfig = isElement(element) ? Manipulator.getDataAttribute(element, 'config') : {} // try to parse + return { ...this.constructor.Default, + ...(typeof jsonConfig === 'object' ? jsonConfig : {}), ...(isElement(element) ? Manipulator.getDataAttributes(element) : {}), ...(typeof config === 'object' ? config : {}) } diff --git a/js/tests/unit/dom/manipulator.spec.js b/js/tests/unit/dom/manipulator.spec.js index 2ed1995b60..4561e2e46c 100644 --- a/js/tests/unit/dom/manipulator.spec.js +++ b/js/tests/unit/dom/manipulator.spec.js @@ -70,6 +70,17 @@ describe('Manipulator', () => { target: '#element' }) }) + + it('should omit `bs-config` data attribute', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + + expect(Manipulator.getDataAttributes(div)).toEqual({ + toggle: 'tabs', + target: '#element' + }) + }) }) describe('getDataAttribute', () => { @@ -104,5 +115,21 @@ describe('Manipulator', () => { div.setAttribute('data-bs-test', '1') expect(Manipulator.getDataAttribute(div, 'test')).toEqual(1) }) + + it('should normalize json data', () => { + fixtureEl.innerHTML = '
' + + const div = fixtureEl.querySelector('div') + + expect(Manipulator.getDataAttribute(div, 'test')).toEqual({ delay: { show: 100, hide: 10 } }) + + const objectData = { 'Super Hero': ['Iron Man', 'Super Man'], testNum: 90, url: 'http://localhost:8080/test?foo=bar' } + const dataStr = JSON.stringify(objectData) + div.setAttribute('data-bs-test', encodeURIComponent(dataStr)) + expect(Manipulator.getDataAttribute(div, 'test')).toEqual(objectData) + + div.setAttribute('data-bs-test', dataStr) + expect(Manipulator.getDataAttribute(div, 'test')).toEqual(objectData) + }) }) }) diff --git a/js/tests/unit/tooltip.spec.js b/js/tests/unit/tooltip.spec.js index 5267305a54..ff44d4182a 100644 --- a/js/tests/unit/tooltip.spec.js +++ b/js/tests/unit/tooltip.spec.js @@ -730,15 +730,12 @@ describe('Tooltip', () => { it('should not hide tooltip if leave event occurs and enter event occurs within the hide delay', () => { return new Promise(resolve => { - fixtureEl.innerHTML = '' + fixtureEl.innerHTML = '' const tooltipEl = fixtureEl.querySelector('a') - const tooltip = new Tooltip(tooltipEl, { - delay: { - show: 0, - hide: 150 - } - }) + const tooltip = new Tooltip(tooltipEl) + + expect(tooltip._config.delay).toEqual({ show: 0, hide: 150 }) setTimeout(() => { expect(tooltip._getTipElement()).toHaveClass('show') diff --git a/js/tests/unit/util/config.spec.js b/js/tests/unit/util/config.spec.js index a8f8962ee3..e1693c0c1f 100644 --- a/js/tests/unit/util/config.spec.js +++ b/js/tests/unit/util/config.spec.js @@ -1,4 +1,5 @@ import Config from '../../../src/util/config' +import { clearFixture, getFixture } from '../../helpers/fixture' class DummyConfigClass extends Config { static get NAME() { @@ -7,7 +8,17 @@ class DummyConfigClass extends Config { } describe('Config', () => { + let fixtureEl const name = 'dummy' + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + describe('NAME', () => { it('should return plugin NAME', () => { expect(DummyConfigClass.NAME).toEqual(name) @@ -26,6 +37,83 @@ describe('Config', () => { }) }) + describe('mergeConfigObj', () => { + it('should parse element\'s data attributes and merge it with default config. Element\'s data attributes must excel Defaults', () => { + fixtureEl.innerHTML = '
' + + spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({ + testBool: true, + testString: 'foo', + testString1: 'foo', + testInt: 7 + }) + const instance = new DummyConfigClass() + const configResult = instance._mergeConfigObj({}, fixtureEl.querySelector('#test')) + + expect(configResult.testBool).toEqual(false) + expect(configResult.testString).toEqual('foo') + expect(configResult.testString1).toEqual('bar') + expect(configResult.testInt).toEqual(8) + }) + + it('should parse element\'s data attributes and merge it with default config, plug these given during method call. The programmatically given should excel all', () => { + fixtureEl.innerHTML = '
' + + spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({ + testBool: true, + testString: 'foo', + testString1: 'foo', + testInt: 7 + }) + const instance = new DummyConfigClass() + const configResult = instance._mergeConfigObj({ + testString1: 'test', + testInt: 3 + }, fixtureEl.querySelector('#test')) + + expect(configResult.testBool).toEqual(false) + expect(configResult.testString).toEqual('foo') + expect(configResult.testString1).toEqual('test') + expect(configResult.testInt).toEqual(3) + }) + + it('should parse element\'s data attribute `config` and any rest attributes. The programmatically given should excel all. Data attribute `config` should excel only Defaults', () => { + fixtureEl.innerHTML = '
' + + spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({ + testBool: true, + testString: 'foo', + testString1: 'foo', + testInt: 7, + testInt2: 600 + }) + const instance = new DummyConfigClass() + const configResult = instance._mergeConfigObj({ + testString1: 'test' + }, fixtureEl.querySelector('#test')) + + expect(configResult.testBool).toEqual(false) + expect(configResult.testString).toEqual('foo') + expect(configResult.testString1).toEqual('test') + expect(configResult.testInt).toEqual(8) + expect(configResult.testInt2).toEqual(100) + }) + + it('should omit element\'s data attribute `config` if is not an object', () => { + fixtureEl.innerHTML = '
' + + spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({ + testInt: 7, + testInt2: 79 + }) + const instance = new DummyConfigClass() + const configResult = instance._mergeConfigObj({}, fixtureEl.querySelector('#test')) + + expect(configResult.testInt).toEqual(8) + expect(configResult.testInt2).toEqual(79) + }) + }) + describe('typeCheckConfig', () => { it('should check type of the config object', () => { spyOnProperty(DummyConfigClass, 'DefaultType', 'get').and.returnValue({ diff --git a/site/content/docs/5.1/components/carousel.md b/site/content/docs/5.1/components/carousel.md index 86de96a074..14f91911d2 100644 --- a/site/content/docs/5.1/components/carousel.md +++ b/site/content/docs/5.1/components/carousel.md @@ -308,7 +308,9 @@ var carousel = new bootstrap.Carousel(myCarousel) ### Options -Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-interval=""`. +{{< markdown >}} +{{< partial "js-data-attributes.md" >}} +{{< /markdown >}} {{< bs-table >}} | Name | Type | Default | Description | diff --git a/site/content/docs/5.1/components/collapse.md b/site/content/docs/5.1/components/collapse.md index 4fb6f5e944..60b16826ce 100644 --- a/site/content/docs/5.1/components/collapse.md +++ b/site/content/docs/5.1/components/collapse.md @@ -141,7 +141,9 @@ var collapseList = collapseElementList.map(function (collapseEl) { ### Options -Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-parent=""`. +{{< markdown >}} +{{< partial "js-data-attributes.md" >}} +{{< /markdown >}} {{< bs-table "table" >}} | Name | Type | Default | Description | diff --git a/site/content/docs/5.1/components/dropdowns.md b/site/content/docs/5.1/components/dropdowns.md index 7751a9bade..86b8491ca7 100644 --- a/site/content/docs/5.1/components/dropdowns.md +++ b/site/content/docs/5.1/components/dropdowns.md @@ -1064,7 +1064,9 @@ Regardless of whether you call your dropdown via JavaScript or instead use the d ### Options -Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-offset=""`. Make sure to change the case type of the option name from camelCase to kebab-case when passing the options via data attributes. For example, instead of using `data-bs-autoClose="false"`, use `data-bs-auto-close="false"`. +{{< markdown >}} +{{< partial "js-data-attributes.md" >}} +{{< /markdown >}} {{< bs-table "table" >}} | Name | Type | Default | Description | diff --git a/site/content/docs/5.1/components/modal.md b/site/content/docs/5.1/components/modal.md index d9bd120d46..011aee2f46 100644 --- a/site/content/docs/5.1/components/modal.md +++ b/site/content/docs/5.1/components/modal.md @@ -820,7 +820,9 @@ var myModal = new bootstrap.Modal(document.getElementById('myModal'), options) ### Options -Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-backdrop=""`. +{{< markdown >}} +{{< partial "js-data-attributes.md" >}} +{{< /markdown >}} {{< bs-table "table" >}} | Name | Type | Default | Description | diff --git a/site/content/docs/5.1/components/offcanvas.md b/site/content/docs/5.1/components/offcanvas.md index 56ae26e6ba..10d184ed57 100644 --- a/site/content/docs/5.1/components/offcanvas.md +++ b/site/content/docs/5.1/components/offcanvas.md @@ -279,7 +279,9 @@ var offcanvasList = offcanvasElementList.map(function (offcanvasEl) { ### Options -Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-backdrop=""`. +{{< markdown >}} +{{< partial "js-data-attributes.md" >}} +{{< /markdown >}} {{< bs-table "table" >}} | Name | Type | Default | Description | diff --git a/site/content/docs/5.1/components/popovers.md b/site/content/docs/5.1/components/popovers.md index f2cfdb6d0a..7dce915635 100644 --- a/site/content/docs/5.1/components/popovers.md +++ b/site/content/docs/5.1/components/popovers.md @@ -166,7 +166,9 @@ Additionally, while it is possible to also include interactive controls (such as ### Options -Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-animation=""`. Make sure to change the case type of the option name from camelCase to kebab-case when passing the options via data attributes. For example, instead of using `data-bs-customClass="beautifier"`, use `data-bs-custom-class="beautifier"`. +{{< markdown >}} +{{< partial "js-data-attributes.md" >}} +{{< /markdown >}} {{< callout warning >}} Note that for security reasons the `sanitize`, `sanitizeFn`, and `allowList` options cannot be supplied using data attributes. diff --git a/site/content/docs/5.1/components/scrollspy.md b/site/content/docs/5.1/components/scrollspy.md index 6dfd9732fa..e48cc06f27 100644 --- a/site/content/docs/5.1/components/scrollspy.md +++ b/site/content/docs/5.1/components/scrollspy.md @@ -338,7 +338,9 @@ Target elements that are not visible will be ignored and their corresponding nav ### Options -Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-root-margin=""`. +{{< markdown >}} +{{< partial "js-data-attributes.md" >}} +{{< /markdown >}} {{< bs-table "table" >}} | Name | Type | Default | Description | diff --git a/site/content/docs/5.1/components/toasts.md b/site/content/docs/5.1/components/toasts.md index a54a6656a6..32a0f2f413 100644 --- a/site/content/docs/5.1/components/toasts.md +++ b/site/content/docs/5.1/components/toasts.md @@ -355,7 +355,9 @@ var toastList = toastElList.map(function (toastEl) { ### Options -Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-animation=""`. +{{< markdown >}} +{{< partial "js-data-attributes.md" >}} +{{< /markdown >}} {{< bs-table "table" >}} | Name | Type | Default | Description | diff --git a/site/content/docs/5.1/components/tooltips.md b/site/content/docs/5.1/components/tooltips.md index 9460da2a0b..f913ff5fea 100644 --- a/site/content/docs/5.1/components/tooltips.md +++ b/site/content/docs/5.1/components/tooltips.md @@ -193,7 +193,9 @@ Elements with the `disabled` attribute aren't interactive, meaning users cannot ### Options -Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-animation=""`. Make sure to change the case type of the option name from camelCase to kebab-case when passing the options via data attributes. For example, instead of using `data-bs-customClass="beautifier"`, use `data-bs-custom-class="beautifier"`. +{{< markdown >}} +{{< partial "js-data-attributes.md" >}} +{{< /markdown >}} {{< callout warning >}} Note that for security reasons the `sanitize`, `sanitizeFn`, and `allowList` options cannot be supplied using data attributes. diff --git a/site/layouts/partials/js-data-attributes.md b/site/layouts/partials/js-data-attributes.md new file mode 100644 index 0000000000..c188652f06 --- /dev/null +++ b/site/layouts/partials/js-data-attributes.md @@ -0,0 +1,3 @@ +Options can be passed via data attributes or JavaScript. For data attributes, append the option name to `data-bs-`, as in `data-bs-animation=""`. Make sure to change the case type of the option name from camelCase to kebab-case when passing the options via data attributes. For example, use `data-bs-custom-class="beautifier"` instead of `data-bs-customClass="beautifier"`. + +As of Bootstrap 5.2.0, all components support an **experimental** reserved data attribute `data-bs-config` that can house simple component configuration as a JSON string. When an element has `data-bs-config='{"delay":0, "title":123}'` and `data-bs-title="456"` attributes, the final `title` value will be `456` and the separate data attributes will override values given on `data-bs-config`. In addition, existing data attributes are able to house JSON values like `data-bs-delay='{"show":0,"hide":150}'`.