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 >}} -
+