Skip to content

Commit 1b78b06

Browse files
authored
feat(VFileUpload, VFileInput): filter-by-type prop (#21576)
resolves #21150
1 parent 9b2541e commit 1b78b06

File tree

7 files changed

+201
-20
lines changed

7 files changed

+201
-20
lines changed

packages/api-generator/src/locale/en/VFileInput.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"props": {
3-
"accept": "One or more [unique file type specifiers](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers) describing file types to allow.",
43
"chips": "Changes display of selections to chips.",
54
"counter": "Displays the number of selected files.",
65
"counterSizeString": "The text displayed when using the **counter** and **show-size** props. Can also be customized globally on the [internationalization page](/customization/internationalization).",

packages/api-generator/src/locale/en/generic.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"size": "Sets the height and width of the component.",
5454
"subtitle": "Specify a subtitle text for the component.",
5555
"start": "Applies margin at the end of the component.",
56+
"filterByType": "Make the input accept only files matched by one or more [unique file type specifiers](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers). Applies to drag & drop and selecting folders. Emits `rejected` event when some files do not pass through to make it possible to notify user and deliver better user experience.",
5657
"symbol": "The [Symbol](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol) used to hook into group functionality for components like [v-btn-toggle](/components/btn-toggle) and [v-bottom-navigation](/components/bottom-navigations/).",
5758
"tag": "Specify a custom tag used on the root element.",
5859
"text": "Specify content text for the component.",
@@ -97,6 +98,7 @@
9798
"click:prependInner": "Emitted when prepended inner icon is clicked.",
9899
"input": "The updated bound model.",
99100
"group:selected": "Event that is emitted when an item is selected within a group.",
101+
"rejected": "Emitted when some of the files from user input, drop or folder selection did not pass through `strict-accept` filter.",
100102
"update:focused": "Event that is emitted when the component's focus state changes.",
101103
"update:menu": "Event that is emitted when the component's menu state changes.",
102104
"update:modelValue": "Event that is emitted when the component's model changes.",

packages/docs/src/data/new-in.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@
7676
"glow": "3.8.0"
7777
}
7878
},
79+
"VFileInput": {
80+
"props": {
81+
"filterByType": "3.10.0"
82+
}
83+
},
84+
"VFileUpload": {
85+
"props": {
86+
"filterByType": "3.10.0"
87+
}
88+
},
7989
"VIcon": {
8090
"props": {
8191
"opacity": "3.8.0"

packages/vuetify/src/components/VFileInput/VFileInput.tsx

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { makeVInputProps, VInput } from '@/components/VInput/VInput'
1010

1111
// Composables
1212
import { useFileDrop } from '@/composables/fileDrop'
13+
import { makeFileFilterProps, useFileFilter } from '@/composables/fileFilter'
1314
import { useFocus } from '@/composables/focus'
1415
import { forwardRefs } from '@/composables/forwardRefs'
1516
import { useLocale } from '@/composables/locale'
@@ -75,6 +76,7 @@ export const makeVFileInputProps = propsFactory({
7576
},
7677
},
7778

79+
...makeFileFilterProps(),
7880
...makeVFieldProps({ clearable: true }),
7981
}, 'VFileInput')
8082

@@ -90,10 +92,12 @@ export const VFileInput = genericComponent<VFileInputSlots>()({
9092
'mousedown:control': (e: MouseEvent) => true,
9193
'update:focused': (focused: boolean) => true,
9294
'update:modelValue': (files: File | File[]) => true,
95+
rejected: (files: File[]) => true,
9396
},
9497

9598
setup (props, { attrs, emit, slots }) {
9699
const { t } = useLocale()
100+
const { filterAccepted } = useFileFilter(props)
97101
const model = useProxiedModel(
98102
props,
99103
'modelValue',
@@ -172,13 +176,39 @@ export const VFileInput = genericComponent<VFileInputSlots>()({
172176

173177
if (!inputRef.value || !hasFilesOrFolders(e)) return
174178

179+
const allDroppedFiles = await handleDrop(e)
180+
selectAccepted(allDroppedFiles)
181+
}
182+
183+
function onFileSelection (e: Event) {
184+
if (!e.target || (e as any).repack) return // prevent loop
185+
186+
if (!props.filterByType) {
187+
const target = e.target as HTMLInputElement
188+
model.value = [...target.files ?? []]
189+
} else {
190+
selectAccepted([...(e as any).target.files])
191+
}
192+
}
193+
194+
function selectAccepted (files: File[]) {
175195
const dataTransfer = new DataTransfer()
176-
for (const file of await handleDrop(e)) {
196+
const { accepted, rejected } = filterAccepted(files)
197+
198+
if (rejected.length) {
199+
emit('rejected', rejected)
200+
}
201+
202+
for (const file of accepted) {
177203
dataTransfer.items.add(file)
178204
}
179205

180-
inputRef.value.files = dataTransfer.files
181-
inputRef.value.dispatchEvent(new Event('change', { bubbles: true }))
206+
inputRef.value!.files = dataTransfer.files
207+
model.value = [...dataTransfer.files]
208+
209+
const event = new Event('change', { bubbles: true }) as any
210+
event.repack = true
211+
inputRef.value!.dispatchEvent(event)
182212
}
183213

184214
watch(model, newValue => {
@@ -196,6 +226,9 @@ export const VFileInput = genericComponent<VFileInputSlots>()({
196226
const { modelValue: _, ...inputProps } = VInput.filterProps(props)
197227
const fieldProps = VField.filterProps(props)
198228

229+
const expectsDirectory = attrs.webkitdirectory !== undefined && attrs.webkitdirectory !== false
230+
const inputAccept = expectsDirectory ? undefined : (props.filterByType ?? String(attrs.accept))
231+
199232
return (
200233
<VInput
201234
ref={ vInputRef }
@@ -253,6 +286,7 @@ export const VFileInput = genericComponent<VFileInputSlots>()({
253286
<input
254287
ref={ inputRef }
255288
type="file"
289+
accept={ inputAccept }
256290
readonly={ isReadonly.value }
257291
disabled={ isDisabled.value }
258292
multiple={ props.multiple }
@@ -264,12 +298,7 @@ export const VFileInput = genericComponent<VFileInputSlots>()({
264298

265299
onFocus()
266300
}}
267-
onChange={ e => {
268-
if (!e.target) return
269-
270-
const target = e.target as HTMLInputElement
271-
model.value = [...target.files ?? []]
272-
}}
301+
onChange={ onFileSelection }
273302
onDragleave={ onDragleave }
274303
onFocus={ onFocus }
275304
onBlur={ blur }
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Composables
2+
import { useFileFilter } from '../fileFilter'
3+
4+
describe('fileFilter', () => {
5+
it('by extension', () => {
6+
const { filterAccepted } = useFileFilter({ filterByType: '.pdf' })
7+
const { accepted, rejected } = filterAccepted([
8+
new File([], 'file_1.pdf', { type: 'application/pdf' }),
9+
new File([], 'file_2.txt', { type: 'text/plain' }),
10+
new File([], 'file_3.pdf', { type: 'application/pdf' }),
11+
new File([], 'file_4.png', { type: 'image/png' }),
12+
])
13+
expect(accepted.map(x => x.name)).toEqual(['file_1.pdf', 'file_3.pdf'])
14+
expect(rejected.map(x => x.name)).toEqual(['file_2.txt', 'file_4.png'])
15+
})
16+
17+
it('by type', () => {
18+
const { filterAccepted } = useFileFilter({ filterByType: 'image/png, image/jpeg' })
19+
const { accepted, rejected } = filterAccepted([
20+
new File([], 'file_1.pdf', { type: 'application/pdf' }),
21+
new File([], 'file_2.png', { type: 'image/png' }),
22+
new File([], 'file_3.jpeg', { type: 'image/jpeg' }),
23+
new File([], 'file_4.jpg', { type: 'image/jpeg' }),
24+
new File([], 'file_5.htm', { type: 'text/html' }),
25+
])
26+
expect(accepted.map(x => x.name)).toEqual(['file_2.png', 'file_3.jpeg', 'file_4.jpg'])
27+
expect(rejected.map(x => x.name)).toEqual(['file_1.pdf', 'file_5.htm'])
28+
})
29+
30+
it('by type with wildcard', () => {
31+
const { filterAccepted } = useFileFilter({ filterByType: 'video/*' })
32+
const { accepted, rejected } = filterAccepted([
33+
new File([], 'file_1.mp4', { type: 'video/mp4' }),
34+
new File([], 'file_2.mpeg', { type: 'video/mpeg' }),
35+
new File([], 'file_3.gif', { type: 'image/gif' }),
36+
new File([], 'file_4.wav', { type: 'audio/wav' }),
37+
])
38+
expect(accepted.map(x => x.name)).toEqual(['file_1.mp4', 'file_2.mpeg'])
39+
expect(rejected.map(x => x.name)).toEqual(['file_3.gif', 'file_4.wav'])
40+
})
41+
42+
it('by mixed rules', () => {
43+
const { filterAccepted } = useFileFilter({ filterByType: 'font/*,application/manifest+json,.zip' })
44+
const { accepted, rejected } = filterAccepted([
45+
new File([], 'file_1.css', { type: 'text/css' }),
46+
new File([], 'file_2.jpg', { type: 'image/jpeg' }),
47+
new File([], 'file_3.rar', { type: 'application/vnd.rar' }),
48+
new File([], 'file_4.zip', { type: 'application/x-zip-compressed' }),
49+
new File([], 'file_5.js', { type: 'text/javascript' }),
50+
new File([], 'file_6.woff2', { type: 'font/woff2' }),
51+
new File([], 'file_7.woff', { type: 'font/woff' }),
52+
new File([], 'file_8.ico', { type: 'image/vnd.microsoft.icon' }),
53+
new File([], 'file_9.webmanifest', { type: 'application/manifest+json' }),
54+
])
55+
expect(accepted.map(x => x.name)).toEqual(['file_4.zip', 'file_6.woff2', 'file_7.woff', 'file_9.webmanifest'])
56+
expect(rejected.map(x => x.name)).toEqual(['file_1.css', 'file_2.jpg', 'file_3.rar', 'file_5.js', 'file_8.ico'])
57+
})
58+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Utilities
2+
import { computed } from 'vue'
3+
import { propsFactory } from '@/util'
4+
5+
export interface FileFilterProps {
6+
filterByType?: string
7+
}
8+
9+
export type FileFilterResult = {
10+
accepted: File[]
11+
rejected: File[]
12+
}
13+
14+
// Composables
15+
export const makeFileFilterProps = propsFactory({
16+
filterByType: String,
17+
}, 'file-accept')
18+
19+
export function useFileFilter (props: FileFilterProps) {
20+
const fileFilter = computed(() => props.filterByType ? createFilter(props.filterByType) : null)
21+
22+
function filterAccepted (files: File[]): FileFilterResult {
23+
if (fileFilter.value) {
24+
const accepted = files.filter(fileFilter.value)
25+
return {
26+
accepted,
27+
rejected: files.filter(f => !accepted.includes(f)),
28+
}
29+
}
30+
return {
31+
accepted: files,
32+
rejected: [],
33+
}
34+
}
35+
36+
return {
37+
filterAccepted,
38+
}
39+
}
40+
41+
function createFilter (v: string): ((v: File) => boolean) {
42+
const types = v.split(',').map(x => x.trim().toLowerCase())
43+
const extensionsToMatch = types.filter(x => x.startsWith('.'))
44+
const wildcards = types.filter(x => x.endsWith('/*'))
45+
const typesToMatch = types.filter(x => !extensionsToMatch.includes(x) && !wildcards.includes(x))
46+
47+
return (file: File): boolean => {
48+
const extension = file.name.split('.').at(-1)?.toLowerCase() ?? ''
49+
const typeGroup = file.type.split('/').at(0)?.toLowerCase() ?? ''
50+
return typesToMatch.includes(file.type) ||
51+
extensionsToMatch.includes(`.${extension}`) ||
52+
wildcards.includes(`${typeGroup}/*`)
53+
}
54+
}

packages/vuetify/src/labs/VFileUpload/VFileUpload.tsx

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { makeVSheetProps, VSheet } from '@/components/VSheet/VSheet'
1414
import { makeDelayProps } from '@/composables/delay'
1515
import { makeDensityProps, useDensity } from '@/composables/density'
1616
import { useFileDrop } from '@/composables/fileDrop'
17+
import { makeFileFilterProps, useFileFilter } from '@/composables/fileFilter'
1718
import { IconValue } from '@/composables/icons'
1819
import { useLocale } from '@/composables/locale'
1920
import { useProxiedModel } from '@/composables/proxiedModel'
@@ -78,6 +79,7 @@ export const makeVFileUploadProps = propsFactory({
7879
showSize: Boolean,
7980
name: String,
8081

82+
...makeFileFilterProps(),
8183
...makeDelayProps(),
8284
...makeDensityProps(),
8385
...pick(makeVDividerProps({
@@ -95,11 +97,13 @@ export const VFileUpload = genericComponent<VFileUploadSlots>()({
9597

9698
emits: {
9799
'update:modelValue': (files: File[]) => true,
100+
rejected: (files: File[]) => true,
98101
},
99102

100-
setup (props, { attrs, slots }) {
103+
setup (props, { attrs, emit, slots }) {
101104
const { t } = useLocale()
102105
const { densityClasses } = useDensity(props)
106+
const { filterAccepted } = useFileFilter(props)
103107
const model = useProxiedModel(
104108
props,
105109
'modelValue',
@@ -131,13 +135,39 @@ export const VFileUpload = genericComponent<VFileUploadSlots>()({
131135

132136
if (!inputRef.value) return
133137

138+
const allDroppedFiles = await handleDrop(e)
139+
selectAccepted(allDroppedFiles)
140+
}
141+
142+
function onFileSelection (e: Event) {
143+
if (!e.target || (e as any).repack) return // prevent loop
144+
145+
if (!props.filterByType) {
146+
const target = e.target as HTMLInputElement
147+
model.value = [...target.files ?? []]
148+
} else {
149+
selectAccepted([...(e as any).target.files])
150+
}
151+
}
152+
153+
function selectAccepted (files: File[]) {
134154
const dataTransfer = new DataTransfer()
135-
for (const file of await handleDrop(e)) {
155+
const { accepted, rejected } = filterAccepted(files)
156+
157+
if (rejected.length) {
158+
emit('rejected', rejected)
159+
}
160+
161+
for (const file of accepted) {
136162
dataTransfer.items.add(file)
137163
}
138164

139-
inputRef.value.files = dataTransfer.files
140-
inputRef.value.dispatchEvent(new Event('change', { bubbles: true }))
165+
inputRef.value!.files = dataTransfer.files
166+
model.value = [...dataTransfer.files]
167+
168+
const event = new Event('change', { bubbles: true }) as any
169+
event.repack = true
170+
inputRef.value!.dispatchEvent(event)
141171
}
142172

143173
function onClick () {
@@ -161,19 +191,18 @@ export const VFileUpload = genericComponent<VFileUploadSlots>()({
161191
const dividerProps = VDivider.filterProps(props)
162192
const [rootAttrs, inputAttrs] = filterInputAttrs(attrs)
163193

194+
const expectsDirectory = attrs.webkitdirectory !== undefined && attrs.webkitdirectory !== false
195+
const inputAccept = expectsDirectory ? undefined : (props.filterByType ?? String(attrs.accept))
196+
164197
const inputNode = (
165198
<input
166199
ref={ inputRef }
167200
type="file"
201+
accept={ inputAccept }
168202
disabled={ props.disabled }
169203
multiple={ props.multiple }
170204
name={ props.name }
171-
onChange={ e => {
172-
if (!e.target) return
173-
174-
const target = e.target as HTMLInputElement
175-
model.value = [...target.files ?? []]
176-
}}
205+
onChange={ onFileSelection }
177206
{ ...inputAttrs }
178207
/>
179208
)

0 commit comments

Comments
 (0)