Skip to content

Commit f53b5f8

Browse files
authored
feat(form-file): add in prop and scoped slot for formatting selected file names (#2902)
1 parent e9a8e85 commit f53b5f8

File tree

4 files changed

+165
-10
lines changed

4 files changed

+165
-10
lines changed

src/components/form-file/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,73 @@ stylesheets. Also it is advised to use
119119
Alternatively you can set the content of the custom file browse button text via the `browse-text`
120120
prop. Note, only plain text is supported. HTML and components are not supported.
121121

122+
## Customize the foratting of the selected file names
123+
124+
By default, the custom styled file input lists the file names separated by commas. You can customize
125+
how the file names are shown either via a custom formatter function or the `file-name` scoped slot.
126+
127+
### File name formatter functon
128+
129+
Set the prop `file-name-formatter` to a function that accepts a single argument which is an array of
130+
[`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) objects. The function should return
131+
a single formatted string (HTML is not supported). The formatter will not be called if no files are
132+
selected.
133+
134+
Regardless of if the prop `multiple` is set or not, the argument to the formatter will always be an
135+
array.
136+
137+
```html
138+
<template>
139+
<b-form-file multiple :file-name-formatter="formatNames"></b-form-file>
140+
</template>
141+
142+
<script>
143+
export default {
144+
methods: {
145+
formatNames(files) {
146+
if (files.length === 1) {
147+
return files[0].name
148+
} else {
149+
return `${files.length} files selected`
150+
}
151+
}
152+
}
153+
}
154+
</script>
155+
156+
<!-- file-formatter-function.vue -->
157+
```
158+
159+
### File name formatting via scoped slot
160+
161+
Alternatively, you can use the scoped slot `file-name` to render the file names. The scoped slot
162+
will receive the following properties:
163+
164+
| Property | Type | Description |
165+
| -------- | ----- | ----------------------- |
166+
| `files` | Array | Array of `File` objects |
167+
| `names` | Array | Array of file names |
168+
169+
Both properties are always arrays, regarless of the setting of the `multiple` prop.
170+
171+
```html
172+
<template>
173+
<b-form-file multiple>
174+
<template slot="file-name" slot-scope="{ names }">
175+
<b-badge variant="dark">{{ names[0] }}</b-badge>
176+
<b-badge v-if="names.length > 1" variant="dark" class="ml-1">
177+
+ {{ names.length - 1 }} More files
178+
</b-badge>
179+
</template>
180+
</b-form-file>
181+
</template>
182+
183+
<!-- file-formatter-slot.vue -->
184+
```
185+
186+
When using the `file-name` slot, the `file-name-formatter` prop is ignored. Also, the slot will
187+
not be rendered when there are no file(s) selected.
188+
122189
## Non custom file input
123190

124191
You can have `<b-form-file>` render a browser native file input by setting the `plain` prop. Note

src/components/form-file/form-file.js

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import idMixin from '../../mixins/id'
22
import formMixin from '../../mixins/form'
33
import formStateMixin from '../../mixins/form-state'
44
import formCustomMixin from '../../mixins/form-custom'
5-
import { from as arrayFrom, isArray } from '../../utils/array'
5+
import normalizeSlotMixin from '../../mixins/normalize-slot'
6+
import { from as arrayFrom, isArray, concat } from '../../utils/array'
67

78
// @vue/component
89
export default {
910
name: 'BFormFile',
10-
mixins: [idMixin, formMixin, formStateMixin, formCustomMixin],
11+
mixins: [idMixin, formMixin, formStateMixin, formCustomMixin, normalizeSlotMixin],
1112
props: {
1213
value: {
1314
// type: Object,
@@ -49,6 +50,10 @@ export default {
4950
noDrop: {
5051
type: Boolean,
5152
default: false
53+
},
54+
fileNameFormatter: {
55+
type: Function,
56+
default: null
5257
}
5358
},
5459
data() {
@@ -70,13 +75,23 @@ export default {
7075
return this.placeholder
7176
}
7277

73-
// Multiple files
74-
if (this.multiple) {
75-
return this.selectedFile.map(file => file.name).join(', ')
76-
}
78+
// Convert selectedFile to an array (if not already one)
79+
const files = concat(this.selectedFile).filter(Boolean)
7780

78-
// Single file
79-
return this.selectedFile.name
81+
if (this.hasNormalizedSlot('file-name')) {
82+
// There is a slot for formatting the files/names
83+
return [
84+
this.normalizeSlot('file-name', {
85+
files: files,
86+
names: files.map(f => f.name)
87+
})
88+
]
89+
} else {
90+
// Use the user supplied formatter, or the built in one.
91+
return typeof this.fileNameFormatter === 'function'
92+
? String(this.fileNameFormatter(files))
93+
: files.map(file => file.name).join(', ')
94+
}
8095
}
8196
},
8297
watch: {
@@ -265,7 +280,8 @@ export default {
265280
const label = h(
266281
'label',
267282
{
268-
class: ['custom-file-label', this.dragging ? 'dragging' : null],
283+
staticClass: 'custom-file-label',
284+
class: [this.dragging ? 'dragging' : null],
269285
attrs: {
270286
for: this.safeId(),
271287
'data-browse': this.browseText || null
@@ -278,7 +294,8 @@ export default {
278294
return h(
279295
'div',
280296
{
281-
class: ['custom-file', 'b-form-file', this.stateClass],
297+
staticClass: 'custom-file b-form-file',
298+
class: this.stateClass,
282299
attrs: { id: this.safeId('_BV_file_outer_') },
283300
on: {
284301
dragover: this.onDragover,

src/components/form-file/form-file.spec.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,69 @@ describe('form-file', () => {
418418

419419
wrapper.destroy()
420420
})
421+
422+
it('file-name-formatter works', async () => {
423+
let called = false
424+
let filesIsArray = false
425+
const wrapper = mount(Input, {
426+
propsData: {
427+
id: 'foo',
428+
fileNameFormatter: files => {
429+
called = true
430+
filesIsArray = Array.isArray(files)
431+
return 'foobar'
432+
}
433+
}
434+
})
435+
const file = new File(['foo'], 'foo.txt', {
436+
type: 'text/plain',
437+
lastModified: Date.now()
438+
})
439+
440+
// Emulate the files array
441+
wrapper.vm.setFiles([file])
442+
expect(wrapper.emitted('input')).toBeDefined()
443+
expect(wrapper.emitted('input').length).toEqual(1)
444+
expect(wrapper.emitted('input')[0][0]).toEqual(file)
445+
446+
// Formatter should have been called, and passed an array
447+
expect(called).toBe(true)
448+
expect(filesIsArray).toBe(true)
449+
// Should have our custom formatted "filename"
450+
expect(wrapper.find('label').text()).toContain('foobar')
451+
452+
wrapper.destroy()
453+
})
454+
455+
it('file-name slot works', async () => {
456+
let slotScope = null
457+
const wrapper = mount(Input, {
458+
propsData: {
459+
id: 'foo'
460+
},
461+
scopedSlots: {
462+
'file-name': scope => {
463+
slotScope = scope
464+
return 'foobar'
465+
}
466+
}
467+
})
468+
const file = new File(['foo'], 'foo.txt', {
469+
type: 'text/plain',
470+
lastModified: Date.now()
471+
})
472+
473+
// Emulate the files array
474+
wrapper.vm.setFiles([file])
475+
expect(wrapper.emitted('input')).toBeDefined()
476+
expect(wrapper.emitted('input').length).toEqual(1)
477+
expect(wrapper.emitted('input')[0][0]).toEqual(file)
478+
479+
// scoped slot should have been called, with expected scope
480+
expect(slotScope).toEqual({ files: [file], names: [file.name] })
481+
// Should have our custom formatted "filename"
482+
expect(wrapper.find('label').text()).toContain('foobar')
483+
484+
wrapper.destroy()
485+
})
421486
})

src/components/form-file/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
"aliases": [
1010
"BFile"
1111
],
12+
"slots": [
13+
{
14+
"name": "file-name",
15+
"description": "Scoped slot for formatting the file names. Scoped props: files - array of File objects, names: array of file names"
16+
}
17+
],
1218
"events": [
1319
{
1420
"event": "change",

0 commit comments

Comments
 (0)