Skip to content

Commit 1de1130

Browse files
committed
feat(FileUpload): implemented component
1 parent 6c4e90c commit 1de1130

File tree

4 files changed

+268
-1
lines changed

4 files changed

+268
-1
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<template>
2+
<doc-page title="File Upload">
3+
<template #description>
4+
A <b>simple file upload</b> component allows the users to upload a single file into the browser. This is primarily used for uploading and previewing text files, but other file types will also be accepted.
5+
</template>
6+
7+
<template #apidocs>
8+
<component-info src="packages/core/src/components/FielUpload.vue" />
9+
</template>
10+
11+
<story-canvas title="Simple text file">
12+
<pf-file-upload :data-types="['text/plain']" />
13+
</story-canvas>
14+
15+
<story-canvas title="Custom file preview">
16+
<pf-file-upload browse-button-text="Upload" hide-default-preview @file-input-change="file = $event">
17+
<div v-if="file">
18+
<upload-icon />
19+
Custom preview here for your {{ file.size }}-byte file named {{ file.name }}
20+
</div>
21+
</pf-file-upload>
22+
</story-canvas>
23+
</doc-page>
24+
</template>
25+
26+
<script lang="ts" setup>
27+
import { ref, type Ref } from "vue";
28+
import UploadIcon from '@vue-patternfly/icons/upload-icon';
29+
30+
const file: Ref<File | null> = ref(null);
31+
</script>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,6 @@
2626
"turbo": "^2.5.0",
2727
"typescript": "^5.5.4"
2828
},
29-
"packageManager": "pnpm@8.6.1",
29+
"packageManager": "pnpm@10.8.1+sha512.c50088ba998c67b8ca8c99df8a5e02fd2ae2e2b29aaf238feaa9e124248d3f48f9fb6db2424949ff901cffbb5e0f0cc1ad6aedb602cd29450751d11c35023677",
3030
"name": "vue-patternfly"
3131
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
<template>
2+
<div
3+
v-bind="ouiaProps" :class="[styles.fileUpload, {
4+
[styles.modifiers.dragHover]: dragActive,
5+
[styles.modifiers.loading]: loading,
6+
}]">
7+
<div :class="styles.fileUploadFileSelect">
8+
<pf-input-group>
9+
<pf-input-group-item fill>
10+
<pf-text-input
11+
:id="`${validId}-filename`"
12+
ref="filenameInputRef"
13+
read-only-variant="default"
14+
:disabled="disabled"
15+
:auto-validate="false"
16+
:name="name"
17+
:aria-label="filenameAriaLabel ?? (managedFilename ? 'Read only filename' : filenamePlaceholder)"
18+
:placeholder="filenamePlaceholder"
19+
:model-value="managedFilename"
20+
:aria-describedby="`${validId}-browse-button`"
21+
/>
22+
</pf-input-group-item>
23+
<pf-input-group-item>
24+
<pf-button
25+
:id="`${validId}-browse-button`"
26+
style="cursor: pointer"
27+
component="label"
28+
variant="control"
29+
:disabled="disabled"
30+
@click="$emit('browseButtonClick', $event)"
31+
>
32+
<input type="file" :accept="props.dataTypes?.join(',')" :name="name" :required="required" style="display: none" @change="setFile(($event.target as HTMLInputElement | undefined)?.files?.[0] ?? null)">
33+
{{ browseButtonText }}
34+
</pf-button>
35+
</pf-input-group-item>
36+
<pf-input-group-item>
37+
<pf-button
38+
variant="control"
39+
:disabled="disabled || (clearButtonDisabled ?? (!managedFilename && !value))"
40+
@click="onClearButtonClick"
41+
>
42+
{{ clearButtonText }}
43+
</pf-button>
44+
</pf-input-group-item>
45+
</pf-input-group>
46+
</div>
47+
<div :class="styles.fileUploadFileDetails">
48+
<pf-textarea
49+
v-if="!hideDefaultPreview && type === 'text'"
50+
:id="id"
51+
:disabled="disabled"
52+
:required="required"
53+
:readonly="readonly || (!!managedFilename && !allowEditingUploadedText)"
54+
resize-orientation="vertical"
55+
:validated="validated"
56+
:aria-label="ariaLabel"
57+
:model-value="typeof value === 'string' ? value : ''"
58+
:name="textareaName"
59+
:placeholder="textareaPlaceholder"
60+
@click="$emit('textAreaClick', $event)"
61+
@blur="$emit('textAreaBlur', $event)"
62+
/>
63+
<div v-if="managedLoading" :class="styles.fileUploadFileDetailsSpinner">
64+
<pf-spinner size="lg" :aria-valuetext="spinnerAriaValueText" />
65+
</div>
66+
</div>
67+
<slot />
68+
</div>
69+
</template>
70+
71+
<script lang="ts" setup>
72+
import styles from '@patternfly/react-styles/css/components/FileUpload/file-upload';
73+
import { computed, useTemplateRef, type HTMLAttributes } from 'vue';
74+
import { useOUIAProps, type OUIAProps } from '../helpers/ouia';
75+
import PfInputGroup from './InputGroup/InputGroup.vue';
76+
import PfInputGroupItem from './InputGroup/InputGroupItem.vue';
77+
import PfTextInput from './TextInput.vue';
78+
import PfButton from './Button.vue';
79+
import PfTextarea from './Textarea.vue';
80+
import PfSpinner from './Spinner.vue';
81+
import { getUniqueId } from '../util';
82+
import { useManagedProp } from '../use';
83+
import { useDropZone } from '@vueuse/core';
84+
85+
defineOptions({
86+
name: 'PfFileUpload',
87+
});
88+
89+
export interface Props extends OUIAProps, /* @vue-ignore */ HTMLAttributes {
90+
/** Unique id for the text area. Also used to generate ids for accessible labels. */
91+
id?: string;
92+
/** Flag to allow editing of a text file's contents after it is selected from disk. */
93+
allowEditingUploadedText?: boolean;
94+
/** Aria-label for the text area. */
95+
ariaLabel?: string;
96+
/** Text for the browse button. */
97+
browseButtonText?: string;
98+
/** Text for the clear button. */
99+
clearButtonText?: string;
100+
/** Value to be shown in the read-only filename field. */
101+
filename?: string;
102+
/** Aria-label for the read-only filename field. */
103+
filenameAriaLabel?: string;
104+
/** Placeholder string to display in the empty filename field. */
105+
filenamePlaceholder?: string;
106+
/** Flag to hide the built-in preview of the file (where available). If true, you can use
107+
* the children property to render an alternate preview.
108+
*/
109+
hideDefaultPreview?: boolean;
110+
/** Name property for the text input. */
111+
name?: string;
112+
/** Flag to disable the clear button. */
113+
clearButtonDisabled?: boolean;
114+
/** Flag to show if the field is disabled. */
115+
disabled?: boolean;
116+
/** Flag to show if a file is being loaded. */
117+
loading?: boolean;
118+
/** Flag to show if the field is read only. */
119+
readonly?: boolean;
120+
/** Flag to show if the field is required. */
121+
required?: boolean;
122+
/** Aria-valuetext for the loading spinner. */
123+
spinnerAriaValueText?: string;
124+
/** What type of file. Determines what is is expected by the value property (a string for
125+
* 'text' and 'dataURL', or a File object otherwise).
126+
*/
127+
type?: 'text' | 'dataURL' | 'arrayBuffer';
128+
/** Value to indicate if the field is modified to show that validation state.
129+
* If set to success, field will be modified to indicate valid state.
130+
* If set to error, field will be modified to indicate error state.
131+
*/
132+
validated?: 'success' | 'error' | 'default';
133+
/** Value of the file's contents (string if text file, ArrayBuffer object otherwise). */
134+
modelValue?: string | ArrayBuffer;
135+
/** Flag to show if a file is being dragged over the file upload field. */
136+
dragActive?: boolean;
137+
/** Name property for the preview textarea. */
138+
textareaName?: string;
139+
/** Placeholder string to display in the empty text area field. */
140+
textareaPlaceholder?: string;
141+
/** Allowed data types, if not set, all data types are allowed. */
142+
dataTypes?: readonly string[];
143+
}
144+
145+
const props = withDefaults(defineProps<Props>(), {
146+
type: 'text',
147+
validated: 'default',
148+
ariaLabel: 'File upload',
149+
filenamePlaceholder: 'Drag a file here or browse to upload',
150+
browseButtonText: 'Browse...',
151+
clearButtonText: 'Clear',
152+
clearButtonDisabled: undefined,
153+
loading: undefined,
154+
});
155+
const ouiaProps = useOUIAProps({id: props.ouiaId, safe: props.ouiaSafe});
156+
157+
const emit = defineEmits<{
158+
(name: 'onUpdate:modelValue', value: string | File): void;
159+
(name: 'browseButtonClick', e: PointerEvent): void;
160+
(name: 'clearButtonClick', e: PointerEvent): void;
161+
(name: 'textAreaBlur', e: Event): void;
162+
(name: 'textAreaClick', e: MouseEvent): void;
163+
(name: 'fileInputChange', file: File | null): void;
164+
(name: 'readStarted', file: File): void;
165+
(name: 'readFinished', file: File, content: string | ArrayBuffer | null): void;
166+
}>();
167+
168+
defineSlots<{
169+
default?: (props?: Record<never, never>) => any;
170+
}>();
171+
172+
const value = useManagedProp<string | ArrayBuffer | null>('modelValue', null);
173+
const managedFilename = useManagedProp<string | null>('filename', null);
174+
const managedLoading = useManagedProp('loading', false);
175+
const validId = computed(() => props.id || getUniqueId());
176+
const filenameInputRef = useTemplateRef('filenameInputRef');
177+
178+
useDropZone(() => filenameInputRef.value?.$el, {
179+
dataTypes: props.dataTypes,
180+
multiple: false,
181+
async onDrop(files) {
182+
if (!files?.length) {
183+
return;
184+
}
185+
await setFile(files[0]);
186+
},
187+
});
188+
189+
function readFile(fileHandle: File) {
190+
return new Promise((resolve: (value: string | ArrayBuffer | null) => void, reject: (reason: DOMException | string | null) => void) => {
191+
const reader = new FileReader();
192+
reader.onload = () => resolve(reader.result);
193+
reader.onerror = () => reject(reader.error);
194+
if (props.type === 'text') {
195+
reader.readAsText(fileHandle);
196+
} else if (props.type === 'dataURL') {
197+
reader.readAsDataURL(fileHandle);
198+
} else if (props.type === 'arrayBuffer') {
199+
reader.readAsArrayBuffer(fileHandle);
200+
} else {
201+
reject('unknown type');
202+
}
203+
});
204+
}
205+
206+
async function setFile(file: File | null) {
207+
emit('fileInputChange', file);
208+
managedFilename.value = file?.name ?? null;
209+
210+
if (!file) {
211+
value.value = null;
212+
return;
213+
}
214+
215+
if (props.type !== 'text' && props.type !== 'dataURL') {
216+
return;
217+
}
218+
219+
emit('readStarted', file);
220+
managedLoading.value = true;
221+
let content = null;
222+
try {
223+
content = await readFile(file);
224+
} finally {
225+
managedLoading.value = false;
226+
emit('readFinished', file, content);
227+
value.value = content;
228+
}
229+
}
230+
231+
function onClearButtonClick(e: PointerEvent) {
232+
emit('clearButtonClick', e);
233+
setFile(null);
234+
}
235+
</script>

packages/core/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { default as PfButton } from './Button.vue';
88
export { default as PfCheckbox } from './Checkbox.vue';
99
export { default as PfCloseButton } from './CloseButton.vue';
1010
export { default as PfDivider } from './Divider.vue';
11+
export { default as PfFileUpload } from './FileUpload.vue';
1112
export { default as PfFormControlIcon } from './FormControlIcon.vue';
1213
export { default as PfIcon } from './Icon.vue';
1314
export { default as PfLabel } from './Label.vue';

0 commit comments

Comments
 (0)