diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json
index 7cab49f39833..ea2e39de7e22 100644
--- a/.bundlewatch.config.json
+++ b/.bundlewatch.config.json
@@ -38,7 +38,7 @@
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
- "maxSize": "22.5 kB"
+ "maxSize": "22.75 kB"
},
{
"path": "./dist/js/bootstrap.esm.js",
@@ -54,7 +54,7 @@
},
{
"path": "./dist/js/bootstrap.min.js",
- "maxSize": "16 kB"
+ "maxSize": "16.25 kB"
}
],
"ci": {
diff --git a/build/build-plugins.js b/build/build-plugins.js
index c5c357645bc7..cee4e8413ade 100644
--- a/build/build-plugins.js
+++ b/build/build-plugins.js
@@ -33,6 +33,7 @@ const bsPlugins = {
Carousel: path.resolve(__dirname, '../js/src/carousel.js'),
Collapse: path.resolve(__dirname, '../js/src/collapse.js'),
Dropdown: path.resolve(__dirname, '../js/src/dropdown.js'),
+ FileInput: path.resolve(__dirname, '../js/src/file-input.js'),
Modal: path.resolve(__dirname, '../js/src/modal.js'),
Popover: path.resolve(__dirname, '../js/src/popover.js'),
ScrollSpy: path.resolve(__dirname, '../js/src/scrollspy.js'),
@@ -73,7 +74,7 @@ const getConfigByPluginKey = pluginKey => {
}
}
- if (pluginKey === 'Alert' || pluginKey === 'Tab') {
+ if (pluginKey === 'Alert' || pluginKey === 'Tab' || pluginKey === 'FileInput') {
return defaultPluginConfig
}
diff --git a/js/index.esm.js b/js/index.esm.js
index 068117b9ecbf..32ef4c0053f3 100644
--- a/js/index.esm.js
+++ b/js/index.esm.js
@@ -10,6 +10,7 @@ import Button from './src/button'
import Carousel from './src/carousel'
import Collapse from './src/collapse'
import Dropdown from './src/dropdown'
+import FileInput from './src/file-input'
import Modal from './src/modal'
import Popover from './src/popover'
import ScrollSpy from './src/scrollspy'
@@ -23,6 +24,7 @@ export {
Carousel,
Collapse,
Dropdown,
+ FileInput,
Modal,
Popover,
ScrollSpy,
diff --git a/js/index.umd.js b/js/index.umd.js
index ca3510ea2784..03987d724ca0 100644
--- a/js/index.umd.js
+++ b/js/index.umd.js
@@ -10,6 +10,7 @@ import Button from './src/button'
import Carousel from './src/carousel'
import Collapse from './src/collapse'
import Dropdown from './src/dropdown'
+import FileInput from './src/file-input'
import Modal from './src/modal'
import Popover from './src/popover'
import ScrollSpy from './src/scrollspy'
@@ -23,6 +24,7 @@ export default {
Carousel,
Collapse,
Dropdown,
+ FileInput,
Modal,
Popover,
ScrollSpy,
diff --git a/js/src/file-input.js b/js/src/file-input.js
new file mode 100644
index 000000000000..d6dc332306c9
--- /dev/null
+++ b/js/src/file-input.js
@@ -0,0 +1,163 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v5.0.0-alpha1): file-input.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import { getjQuery } from './util/index'
+import Data from './dom/data'
+import EventHandler from './dom/event-handler'
+import SelectorEngine from './dom/selector-engine'
+
+/**
+ * ------------------------------------------------------------------------
+ * Constants
+ * ------------------------------------------------------------------------
+ */
+
+const NAME = 'fileInput'
+const VERSION = '5.0.0-alpha1'
+const DATA_KEY = 'bs.file-input'
+const EVENT_KEY = `.${DATA_KEY}`
+const DATA_API_KEY = '.data-api'
+
+const SELECTOR_DATA_TOGGLE = '[data-toggle="file-input"]'
+const SELECTOR_FILE_INPUT = '.form-file-input'
+const SELECTOR_FILE_INPUT_LABEL = '.form-file-text'
+const SELECTOR_FORM = 'form'
+
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_CHANGE_DATA_API = `change${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_RESET_DATA_API = `reset${EVENT_KEY}${DATA_API_KEY}`
+
+// TODO: remove when we drop Opera Mini support
+const HAS_FILE_API = Boolean(window.File)
+const FAKE_PATH = 'fakepath'
+const FAKE_PATH_SEPARATOR = '\\'
+
+/**
+ * ------------------------------------------------------------------------
+ * Class Definition
+ * ------------------------------------------------------------------------
+ */
+
+class FileInput {
+ constructor(element) {
+ this._element = element
+ this._labelInput = SelectorEngine.findOne(SELECTOR_FILE_INPUT_LABEL, this._element)
+ this._input = SelectorEngine.findOne(SELECTOR_FILE_INPUT, this._element)
+ this._defaultText = this._labelInput.textContent
+
+ EventHandler.on(this._input, EVENT_CHANGE_DATA_API, () => this._handleChange())
+ Data.setData(element, DATA_KEY, this)
+ }
+
+ // Getters
+
+ static get VERSION() {
+ return VERSION
+ }
+
+ // Public
+
+ dispose() {
+ [window, this._element].forEach(htmlElement => EventHandler.off(htmlElement, EVENT_KEY))
+
+ Data.removeData(this._element, DATA_KEY)
+ this._element = null
+ }
+
+ restoreDefaultText() {
+ this._labelInput.textContent = this._defaultText
+ }
+
+ // Private
+
+ _handleChange() {
+ const inputValue = this._getSelectedFiles()
+
+ if (inputValue.length) {
+ this._labelInput.textContent = inputValue
+ } else {
+ this.restoreDefaultText()
+ }
+ }
+
+ _getSelectedFiles() {
+ if (this._input.hasAttribute('multiple') && HAS_FILE_API) {
+ return [].slice.call(this._input.files)
+ .map(file => file.name)
+ .join(', ')
+ }
+
+ if (this._input.value.indexOf(FAKE_PATH) !== -1) {
+ const splitValue = this._input.value.split(FAKE_PATH_SEPARATOR)
+
+ return splitValue[splitValue.length - 1]
+ }
+
+ return this._input.value
+ }
+
+ // Static
+
+ static jQueryInterface() {
+ return this.each(function () {
+ FileInput.createInstance(this)
+ })
+ }
+
+ static getInstance(element) {
+ return Data.getData(element, DATA_KEY)
+ }
+
+ static createInstance(element) {
+ return Data.getData(element, DATA_KEY) ?
+ Data.getData(element, DATA_KEY) :
+ new FileInput(element)
+ }
+}
+
+/**
+ * ------------------------------------------------------------------------
+ * Data Api implementation
+ * ------------------------------------------------------------------------
+ */
+EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => {
+ FileInput.createInstance(event.target.closest(SELECTOR_DATA_TOGGLE))
+})
+
+EventHandler.on(document, EVENT_RESET_DATA_API, SELECTOR_FORM, event => {
+ const form = event.target
+
+ SelectorEngine.find(SELECTOR_DATA_TOGGLE, form)
+ .filter(inputFileNode => FileInput.getInstance(inputFileNode))
+ .forEach(inputFileNode => {
+ const inputFile = FileInput.getInstance(inputFileNode)
+
+ inputFile.restoreDefaultText()
+ })
+})
+
+const $ = getjQuery()
+
+/**
+ * ------------------------------------------------------------------------
+ * jQuery
+ * ------------------------------------------------------------------------
+ * add .fileInput to jQuery only if jQuery is present
+ */
+
+/* istanbul ignore if */
+if ($) {
+ const JQUERY_NO_CONFLICT = $.fn[NAME]
+ $.fn[NAME] = FileInput.jQueryInterface
+ $.fn[NAME].Constructor = FileInput
+ $.fn[NAME].noConflict = () => {
+ $.fn[NAME] = JQUERY_NO_CONFLICT
+ return FileInput.jQueryInterface
+ }
+}
+
+export default FileInput
diff --git a/js/tests/helpers/fixture.js b/js/tests/helpers/fixture.js
index 0bfc26f468bb..c512d12b0b19 100644
--- a/js/tests/helpers/fixture.js
+++ b/js/tests/helpers/fixture.js
@@ -39,3 +39,10 @@ export const jQueryMock = {
})
}
}
+
+export const mockFileApi = (part, name) => {
+ return {
+ part,
+ name
+ }
+}
diff --git a/js/tests/unit/file-input.spec.js b/js/tests/unit/file-input.spec.js
new file mode 100644
index 000000000000..5ddc07f4e641
--- /dev/null
+++ b/js/tests/unit/file-input.spec.js
@@ -0,0 +1,336 @@
+import FileInput from '../../src/file-input'
+
+/** Test helpers */
+import { getFixture, clearFixture, jQueryMock, mockFileApi } from '../helpers/fixture'
+
+describe('FileInput', () => {
+ const isEdge = window.navigator.userAgent.indexOf('Edge') > -1
+
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ it('should return version', () => {
+ expect(typeof FileInput.VERSION).toEqual('string')
+ })
+
+ describe('data-api', () => {
+ it('should handle change on input', done => {
+ if (isEdge) {
+ expect().nothing()
+ return done()
+ }
+
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const input = fixtureEl.querySelector('input')
+ const formFileNode = fixtureEl.querySelector('.form-file')
+ const label = fixtureEl.querySelector('.form-file-text')
+
+ input.click()
+
+ expect(FileInput.getInstance(formFileNode)).toBeDefined()
+
+ input.addEventListener('change', () => {
+ expect(label.textContent).toEqual(input.value)
+
+ done()
+ })
+
+ Object.defineProperty(input, 'value', {
+ value: 'myFakeFile.exe'
+ })
+
+ input.dispatchEvent(new Event('change'))
+ })
+
+ it('should not re create a new file input', () => {
+ if (isEdge) {
+ return expect().nothing()
+ }
+
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const input = fixtureEl.querySelector('input')
+ const formFileNode = fixtureEl.querySelector('.form-file')
+
+ input.click()
+
+ const instance = FileInput.getInstance(formFileNode)
+
+ expect(instance).toBeDefined()
+
+ input.click()
+
+ expect(instance).toEqual(FileInput.getInstance(formFileNode))
+ })
+
+ it('should restore default text if value is empty', done => {
+ if (isEdge) {
+ expect().nothing()
+ return done()
+ }
+
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const input = fixtureEl.querySelector('input')
+ const label = fixtureEl.querySelector('.form-file-text')
+
+ input.click()
+
+ const firstListener = () => {
+ expect(label.textContent).toEqual('myFakeFile.exe')
+ input.removeEventListener('change', firstListener)
+ input.addEventListener('change', secondListener)
+
+ input.value = ''
+ input.dispatchEvent(new Event('change'))
+ }
+
+ const secondListener = () => {
+ expect(label.textContent).toEqual('Choose file...')
+ input.removeEventListener('change', secondListener)
+ done()
+ }
+
+ input.addEventListener('change', firstListener)
+
+ Object.defineProperty(input, 'value', {
+ value: 'myFakeFile.exe',
+ configurable: true,
+ writable: true
+ })
+
+ input.dispatchEvent(new Event('change'))
+ })
+
+ it('should remove fake path', done => {
+ if (isEdge) {
+ expect().nothing()
+ return done()
+ }
+
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const input = fixtureEl.querySelector('input')
+ const label = fixtureEl.querySelector('.form-file-text')
+
+ input.click()
+
+ input.addEventListener('change', () => {
+ expect(label.textContent).toEqual('myFakeFile.exe')
+
+ done()
+ })
+
+ Object.defineProperty(input, 'value', {
+ value: 'C:\\fakepath\\myFakeFile.exe'
+ })
+
+ input.dispatchEvent(new Event('change'))
+ })
+
+ it('should handle change when multiple files added', done => {
+ if (isEdge) {
+ expect().nothing()
+ return done()
+ }
+
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const input = fixtureEl.querySelector('input')
+ const label = fixtureEl.querySelector('.form-file-text')
+
+ input.click()
+
+ input.addEventListener('change', () => {
+ expect(label.textContent).toEqual('myFakeFile.exe, fakeImage.png')
+
+ done()
+ })
+
+ Object.defineProperty(input, 'files', {
+ value: [
+ mockFileApi([], 'myFakeFile.exe'),
+ mockFileApi([], 'fakeImage.png')
+ ]
+ })
+
+ input.dispatchEvent(new Event('change'))
+ })
+
+ it('should handle form reset', done => {
+ if (isEdge) {
+ expect().nothing()
+ return done()
+ }
+
+ fixtureEl.innerHTML = [
+ ''
+ ].join('')
+
+ const form = fixtureEl.querySelector('form')
+ const input = fixtureEl.querySelector('input')
+ const label = fixtureEl.querySelector('.form-file-text')
+
+ input.click()
+
+ input.addEventListener('change', () => {
+ expect(label.textContent).toEqual(input.value)
+
+ form.dispatchEvent(new Event('reset'))
+ })
+
+ form.addEventListener('reset', () => {
+ expect(label.textContent).toEqual('Choose file...')
+ done()
+ })
+
+ Object.defineProperty(input, 'value', {
+ value: 'myFakeFile.exe'
+ })
+
+ input.dispatchEvent(new Event('change'))
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose an alert', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const formFileNode = fixtureEl.querySelector('.form-file')
+ const fileInput = new FileInput(formFileNode)
+
+ expect(FileInput.getInstance(formFileNode)).toBeDefined()
+
+ fileInput.dispose()
+
+ expect(FileInput.getInstance(formFileNode)).toBeNull()
+ })
+ })
+
+ describe('restoreDefaultText', () => {
+ it('should restore default text', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const expectedText = 'Choose file...'
+ const changedText = 'Choose one file...'
+ const formFileNode = fixtureEl.querySelector('.form-file')
+ const formFileLabel = fixtureEl.querySelector('.form-file-text')
+ const fileInput = new FileInput(formFileNode)
+ formFileLabel.textContent = changedText
+
+ expect(formFileLabel.textContent).toEqual(changedText)
+
+ fileInput.restoreDefaultText()
+
+ expect(formFileLabel.textContent).toEqual(expectedText)
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should just create an file input instance', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const formFileNode = fixtureEl.querySelector('.form-file')
+
+ jQueryMock.fn.fileInput = FileInput.jQueryInterface
+ jQueryMock.elements = [formFileNode]
+
+ jQueryMock.fn.fileInput.call(jQueryMock)
+
+ expect(FileInput.getInstance(formFileNode)).toBeDefined()
+ })
+
+ it('should not create another file input instance', () => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const formFileNode = fixtureEl.querySelector('.form-file')
+ const fileInput = new FileInput(formFileNode)
+
+ jQueryMock.fn.fileInput = FileInput.jQueryInterface
+ jQueryMock.elements = [formFileNode]
+
+ jQueryMock.fn.fileInput.call(jQueryMock)
+
+ expect(FileInput.getInstance(formFileNode)).toBeDefined()
+ expect(FileInput.getInstance(formFileNode)).toEqual(fileInput)
+ })
+ })
+})
diff --git a/js/tests/visual/file-input.html b/js/tests/visual/file-input.html
new file mode 100644
index 000000000000..54ec3cb4b9b7
--- /dev/null
+++ b/js/tests/visual/file-input.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+ FileInput
+
+
+
+
FileInput Bootstrap Visual Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Form reset
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/site/assets/js/application.js b/site/assets/js/application.js
index 51dc20d42d16..924dd5ba5df6 100644
--- a/site/assets/js/application.js
+++ b/site/assets/js/application.js
@@ -10,7 +10,7 @@
* For details, see https://creativecommons.org/licenses/by/3.0/.
*/
-/* global ClipboardJS: false, anchors: false, bootstrap: false, bsCustomFileInput: false */
+/* global ClipboardJS: false, anchors: false, bootstrap: false */
(function () {
'use strict'
@@ -91,6 +91,16 @@
})
}
+ // Activate form reset file input
+ var btnResetFormFileInput = document.getElementById('btnResetFormFileInput')
+ if (btnResetFormFileInput) {
+ var formFileInput = document.getElementById('formFileInput')
+
+ btnResetFormFileInput.addEventListener('click', function () {
+ formFileInput.reset()
+ })
+ }
+
// Insert copy to clipboard button before .highlight
var btnHtml = ''
document.querySelectorAll('figure.highlight, div.highlight')
@@ -141,6 +151,4 @@
icon: '#'
}
anchors.add('.bd-content > h2, .bd-content > h3, .bd-content > h4, .bd-content > h5')
-
- bsCustomFileInput.init()
})()
diff --git a/site/assets/js/vendor/bs-custom-file-input.min.js b/site/assets/js/vendor/bs-custom-file-input.min.js
deleted file mode 100644
index 0815f3768d3e..000000000000
--- a/site/assets/js/vendor/bs-custom-file-input.min.js
+++ /dev/null
@@ -1,7 +0,0 @@
-/*!
- * bsCustomFileInput v1.3.4 (https://github.com/Johann-S/bs-custom-file-input)
- * Copyright 2018 - 2020 Johann-S
- * Licensed under MIT (https://github.com/Johann-S/bs-custom-file-input/blob/master/LICENSE)
- */
-!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).bsCustomFileInput=t()}(this,function(){"use strict";var s={CUSTOMFILE:'.custom-file input[type="file"]',CUSTOMFILELABEL:".custom-file-label",FORM:"form",INPUT:"input"},l=function(e){if(0}}
-The recommended plugin to animate custom file inputs is [bs-custom-file-input](https://www.npmjs.com/package/bs-custom-file-input); it's what we use here in our docs.
-{{< /callout >}}
-
## Default
The file input is the most gnarly of the bunch and requires additional JavaScript if you'd like to hook them up with functional *Choose file...* and selected file name text.
{{< example >}}
-