bootstrap-vue
Version:
With more than 85 components, over 45 available plugins, several directives, and 1000+ icons, BootstrapVue provides one of the most comprehensive implementations of the Bootstrap v4 component and grid system available for Vue.js v2.6, complete with extens
559 lines (536 loc) • 18.1 kB
JavaScript
import Vue from '../../vue'
import { NAME_FORM_FILE } from '../../constants/components'
import { EVENT_OPTIONS_PASSIVE } from '../../constants/events'
import { RX_EXTENSION, RX_STAR } from '../../constants/regex'
import cloneDeep from '../../utils/clone-deep'
import identity from '../../utils/identity'
import looseEqual from '../../utils/loose-equal'
import { from as arrayFrom, flatten, flattenDeep } from '../../utils/array'
import { getComponentConfig } from '../../utils/config'
import { closest } from '../../utils/dom'
import { hasPromiseSupport } from '../../utils/env'
import { eventOn, eventOff, stopEvent } from '../../utils/events'
import { isArray, isFile, isFunction, isNull, isUndefinedOrNull } from '../../utils/inspect'
import { File } from '../../utils/safe-types'
import { escapeRegExp } from '../../utils/string'
import { warn } from '../../utils/warn'
import attrsMixin from '../../mixins/attrs'
import formCustomMixin from '../../mixins/form-custom'
import formMixin from '../../mixins/form'
import formStateMixin from '../../mixins/form-state'
import idMixin from '../../mixins/id'
import normalizeSlotMixin from '../../mixins/normalize-slot'
// --- Constants ---
const VALUE_EMPTY_DEPRECATED_MSG =
'Setting "value"/"v-model" to an empty string for reset is deprecated. Set to "null" instead.'
// --- Helper methods ---
const isValidValue = value => isFile(value) || (isArray(value) && value.every(v => isValidValue(v)))
// Helper method to "safely" get the entry from a data-transfer item
/* istanbul ignore next: not supported in JSDOM */
const getDataTransferItemEntry = item =>
isFunction(item.getAsEntry)
? item.getAsEntry()
: isFunction(item.webkitGetAsEntry)
? item.webkitGetAsEntry()
: null
// Drop handler function to get all files
/* istanbul ignore next: not supported in JSDOM */
const getAllFileEntries = (dataTransferItemList, traverseDirectories = true) =>
Promise.all(
arrayFrom(dataTransferItemList)
.filter(item => item.kind === 'file')
.map(item => {
const entry = getDataTransferItemEntry(item)
if (entry) {
if (entry.isDirectory && traverseDirectories) {
return getAllFileEntriesInDirectory(entry.createReader(), `${entry.name}/`)
} else if (entry.isFile) {
return new Promise(resolve => {
entry.file(file => {
file.$path = ''
resolve(file)
})
})
}
}
return null
})
.filter(identity)
)
// Get all the file entries (recursive) in a directory
/* istanbul ignore next: not supported in JSDOM */
const getAllFileEntriesInDirectory = (directoryReader, path = '') =>
new Promise(resolve => {
const entryPromises = []
const readDirectoryEntries = () => {
directoryReader.readEntries(entries => {
if (entries.length === 0) {
resolve(Promise.all(entryPromises).then(entries => flatten(entries)))
} else {
entryPromises.push(
Promise.all(
entries
.map(entry => {
if (entry) {
if (entry.isDirectory) {
return getAllFileEntriesInDirectory(
entry.createReader(),
`${path}${entry.name}/`
)
} else if (entry.isFile) {
return new Promise(resolve => {
entry.file(file => {
file.$path = `${path}${file.name}`
resolve(file)
})
})
}
}
return null
})
.filter(identity)
)
)
readDirectoryEntries()
}
})
}
readDirectoryEntries()
})
// @vue/component
export const BFormFile = /*#__PURE__*/ Vue.extend({
name: NAME_FORM_FILE,
mixins: [attrsMixin, idMixin, formMixin, formStateMixin, formCustomMixin, normalizeSlotMixin],
inheritAttrs: false,
model: {
prop: 'value',
event: 'input'
},
props: {
size: {
type: String,
default: () => getComponentConfig('BFormControl', 'size')
},
value: {
type: [File, Array],
default: null,
validator: value => {
/* istanbul ignore next */
if (value === '') {
warn(VALUE_EMPTY_DEPRECATED_MSG, NAME_FORM_FILE)
return true
}
return isUndefinedOrNull(value) || isValidValue(value)
}
},
accept: {
type: String,
default: ''
},
// Instruct input to capture from camera
capture: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: () => getComponentConfig(NAME_FORM_FILE, 'placeholder')
},
browseText: {
type: String,
default: () => getComponentConfig(NAME_FORM_FILE, 'browseText')
},
dropPlaceholder: {
type: String,
default: () => getComponentConfig(NAME_FORM_FILE, 'dropPlaceholder')
},
noDropPlaceholder: {
type: String,
default: () => getComponentConfig(NAME_FORM_FILE, 'noDropPlaceholder')
},
multiple: {
type: Boolean,
default: false
},
directory: {
type: Boolean,
default: false
},
// TODO:
// Should we deprecate this and only support flat file structures?
// Nested file structures are only supported when files are dropped
// A Chromium "bug" prevents `webkitEntries` from being populated
// on the file input's `change` event and is marked as "WontFix"
// Mozilla implemented the behavior the same way as Chromium
// See: https://bugs.chromium.org/p/chromium/issues/detail?id=138987
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1326031
noTraverse: {
type: Boolean,
default: false
},
noDrop: {
type: Boolean,
default: false
},
fileNameFormatter: {
type: Function,
default: null
}
},
data() {
return {
files: [],
dragging: false,
// IE 11 doesn't respect setting `evt.dataTransfer.dropEffect`,
// so we handle it ourselves as well
// https://stackoverflow.com/a/46915971/2744776
dropAllowed: !this.noDrop,
hasFocus: false
}
},
computed: {
// Convert `accept` to an array of `[{ RegExpr, isMime }, ...]`
computedAccept() {
let { accept } = this
accept = (accept || '')
.trim()
.split(/[,\s]+/)
.filter(Boolean)
// Allow any file type/extension
if (accept.length === 0) {
return null
}
return accept.map(extOrType => {
let prop = 'name'
let startMatch = '^'
let endMatch = '$'
if (RX_EXTENSION.test(extOrType)) {
// File extension /\.ext$/
startMatch = ''
} else {
// MIME type /^mime\/.+$/ or /^mime\/type$/
prop = 'type'
if (RX_STAR.test(extOrType)) {
endMatch = '.+$'
// Remove trailing `*`
extOrType = extOrType.slice(0, -1)
}
}
// Escape all RegExp special chars
extOrType = escapeRegExp(extOrType)
const rx = new RegExp(`${startMatch}${extOrType}${endMatch}`)
return { rx, prop }
})
},
computedCapture() {
const { capture } = this
return capture === true || capture === '' ? true : capture || null
},
computedAttrs() {
const { name, disabled, required, form, computedCapture, accept, multiple, directory } = this
return {
...this.bvAttrs,
type: 'file',
id: this.safeId(),
name,
disabled,
required,
form: form || null,
capture: computedCapture,
accept: accept || null,
multiple,
directory,
webkitdirectory: directory,
'aria-required': required ? 'true' : null
}
},
clonedFiles() {
return cloneDeep(this.files)
},
flattenedFiles() {
return flattenDeep(this.files)
},
fileNames() {
return this.flattenedFiles.map(file => file.name)
},
labelContent() {
const h = this.$createElement
// Draging active
/* istanbul ignore next: used by drag/drop which can't be tested easily */
if (this.dragging && !this.noDrop) {
return (
// TODO: Add additional scope with file count, and other not-allowed reasons
this.normalizeSlot('drop-placeholder', { allowed: this.dropAllowed }) ||
(this.dropAllowed
? this.dropPlaceholder
: h('span', { staticClass: 'text-danger' }, this.noDropPlaceholder))
)
}
// No file chosen
if (this.files.length === 0) {
return this.normalizeSlot('placeholder') || this.placeholder
}
// There is a slot for formatting the files/names
if (this.hasNormalizedSlot('file-name')) {
return this.normalizeSlot('file-name', {
files: this.flattenedFiles,
filesTraversed: this.clonedFiles,
names: this.fileNames
})
}
// Use the user supplied formatter, or the built in one
return isFunction(this.fileNameFormatter)
? String(this.fileNameFormatter(this.flattenedFiles, this.clonedFiles))
: this.fileNames.join(', ')
}
},
watch: {
value(newValue) {
if (!newValue || (isArray(newValue) && newValue.length === 0)) {
this.reset()
}
},
files(newValue, oldValue) {
if (!looseEqual(newValue, oldValue)) {
const { multiple, noTraverse } = this
const files = !multiple || noTraverse ? flattenDeep(newValue) : newValue
this.$emit('input', multiple ? files : files[0] || null)
}
}
},
mounted() {
// Listen for form reset events, to reset the file input
const $form = closest('form', this.$el)
if ($form) {
eventOn($form, 'reset', this.reset, EVENT_OPTIONS_PASSIVE)
this.$on('hook:beforeDestroy', () => {
eventOff($form, 'reset', this.reset, EVENT_OPTIONS_PASSIVE)
})
}
},
methods: {
isFileValid(file) {
if (!file) {
return false
}
const accept = this.computedAccept
return accept ? accept.some(a => a.rx.test(file[a.prop])) : true
},
isFilesArrayValid(files) {
return isArray(files) ? files.every(file => this.isFileValid(file)) : this.isFileValid(files)
},
setFiles(files) {
// Reset the dragging flags
this.dropAllowed = !this.noDrop
this.dragging = false
// Set the selected files
this.files = this.multiple
? this.directory
? files
: flattenDeep(files)
: flattenDeep(files).slice(0, 1)
},
setInputFiles(files) /* istanbul ignore next: used by Drag/Drop */ {
// Try an set the file input files array so that `required`
// constraint works for dropped files (will fail in IE11 though)
// To be used only when dropping files
try {
// Firefox < 62 workaround exploiting https://bugzilla.mozilla.org/show_bug.cgi?id=1422655
const dataTransfer = new ClipboardEvent('').clipboardData || new DataTransfer()
// Add flattened files to temp `dataTransfer` object to get a true `FileList` array
flattenDeep(cloneDeep(files)).forEach(file => {
// Make sure to remove the custom `$path` attribute
delete file.$path
dataTransfer.items.add(file)
})
this.$refs.input.files = dataTransfer.files
} catch {}
},
reset() {
// IE 11 doesn't support setting `$input.value` to `''` or `null`
// So we use this little extra hack to reset the value, just in case
// This also appears to work on modern browsers as well
// Wrapped in try in case IE 11 or mobile Safari crap out
try {
const $input = this.$refs.input
$input.value = ''
$input.type = ''
$input.type = 'file'
} catch {}
this.files = []
},
handleFiles(files, isDrop = false) {
if (isDrop) {
// When dropped, make sure to filter files with the internal `accept` logic
const filteredFiles = files.filter(this.isFilesArrayValid)
// Only update files when we have any after filtering
if (filteredFiles.length > 0) {
this.setFiles(filteredFiles)
// Try an set the file input's files array so that `required`
// constraint works for dropped files (will fail in IE 11 though)
this.setInputFiles(filteredFiles)
}
} else {
// We always update the files from the `change` event
this.setFiles(files)
}
},
focusHandler(evt) {
// Bootstrap v4 doesn't have focus styling for custom file input
// Firefox has a `[type=file]:focus ~ sibling` selector issue,
// so we add a `focus` class to get around these bugs
if (this.plain || evt.type === 'focusout') {
this.hasFocus = false
} else {
// Add focus styling for custom file input
this.hasFocus = true
}
},
onChange(evt) {
const { type, target, dataTransfer = {} } = evt
const isDrop = type === 'drop'
// Always emit original event
this.$emit('change', evt)
const items = arrayFrom(dataTransfer.items || [])
if (hasPromiseSupport && items.length > 0 && !isNull(getDataTransferItemEntry(items[0]))) {
// Drop handling for modern browsers
// Supports nested directory structures in `directory` mode
/* istanbul ignore next: not supported in JSDOM */
getAllFileEntries(items, this.directory).then(files => this.handleFiles(files, isDrop))
} else {
// Standard file input handling (native file input change event),
// or fallback drop mode (IE 11 / Opera) which don't support `directory` mode
const files = arrayFrom(target.files || dataTransfer.files || []).map(file => {
// Add custom `$path` property to each file (to be consistent with drop mode)
file.$path = file.webkitRelativePath || ''
return file
})
this.handleFiles(files, isDrop)
}
},
onDragenter(evt) {
stopEvent(evt)
this.dragging = true
const { dataTransfer = {} } = evt
// Early exit when the input or dropping is disabled
if (this.noDrop || this.disabled || !this.dropAllowed) {
// Show deny feedback
/* istanbul ignore next: not supported in JSDOM */
dataTransfer.dropEffect = 'none'
this.dropAllowed = false
return
}
/* istanbul ignore next: not supported in JSDOM */
dataTransfer.dropEffect = 'copy'
},
// Note this event fires repeatedly while the mouse is over the dropzone at
// intervals in the milliseconds, so avoid doing much processing in here
onDragover(evt) {
stopEvent(evt)
this.dragging = true
const { dataTransfer = {} } = evt
// Early exit when the input or dropping is disabled
if (this.noDrop || this.disabled || !this.dropAllowed) {
// Show deny feedback
/* istanbul ignore next: not supported in JSDOM */
dataTransfer.dropEffect = 'none'
this.dropAllowed = false
return
}
/* istanbul ignore next: not supported in JSDOM */
dataTransfer.dropEffect = 'copy'
},
onDragleave(evt) {
stopEvent(evt)
this.$nextTick(() => {
this.dragging = false
// Reset `dropAllowed` to default
this.dropAllowed = !this.noDrop
})
},
// Triggered by a file drop onto drop target
onDrop(evt) {
stopEvent(evt)
this.dragging = false
// Early exit when the input or dropping is disabled
if (this.noDrop || this.disabled || !this.dropAllowed) {
this.$nextTick(() => {
// Reset `dropAllowed` to default
this.dropAllowed = !this.noDrop
})
return
}
this.onChange(evt)
}
},
render(h) {
const { custom, plain, size, dragging, stateClass } = this
// Form Input
const $input = h('input', {
ref: 'input',
class: [
{
'form-control-file': plain,
'custom-file-input': custom,
focus: custom && this.hasFocus
},
stateClass
],
// With IE 11, the input gets in the "way" of the drop events,
// so we move it out of the way by putting it behind the label
// Bootstrap v4 has it in front
style: custom ? { zIndex: -5 } : {},
attrs: this.computedAttrs,
on: {
change: this.onChange,
focusin: this.focusHandler,
focusout: this.focusHandler,
reset: this.reset
}
})
if (plain) {
return $input
}
// Overlay label
const $label = h(
'label',
{
staticClass: 'custom-file-label',
class: { dragging },
attrs: {
for: this.safeId(),
// This goes away in Bootstrap v5
'data-browse': this.browseText || null
}
},
[
h(
'span',
{
staticClass: 'd-block form-file-text',
// `pointer-events: none` is used to make sure
// the drag events fire only on the label
style: { pointerEvents: 'none' }
},
[this.labelContent]
)
]
)
// Return rendered custom file input
return h(
'div',
{
staticClass: 'custom-file b-form-file',
class: [{ [`b-custom-control-${size}`]: size }, stateClass],
attrs: { id: this.safeId('_BV_file_outer_') },
on: {
dragenter: this.onDragenter,
dragover: this.onDragover,
dragleave: this.onDragleave,
drop: this.onDrop
}
},
[$input, $label]
)
}
})