@treeui/vue 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/TAppShell.cjs.map +1 -1
- package/dist/components/TAppShell.js.map +1 -1
- package/dist/components/TAppShell.vue.d.ts +25 -0
- package/dist/components/TAppShell.vue.d.ts.map +1 -1
- package/dist/components/TAppShell.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TAppShell.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TAppShell.vue_vue_type_script_setup_true_lang.js +166 -104
- package/dist/components/TAppShell.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TButton.cjs.map +1 -1
- package/dist/components/TButton.js.map +1 -1
- package/dist/components/TButton.vue.d.ts +8 -2
- package/dist/components/TButton.vue.d.ts.map +1 -1
- package/dist/components/TButton.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TButton.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TButton.vue_vue_type_script_setup_true_lang.js +12 -3
- package/dist/components/TButton.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TChart.cjs.map +1 -1
- package/dist/components/TChart.js.map +1 -1
- package/dist/components/TChart.vue.d.ts.map +1 -1
- package/dist/components/TChart.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TChart.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TChart.vue_vue_type_script_setup_true_lang.js +58 -58
- package/dist/components/TChart.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TColorSwatch.cjs +2 -0
- package/dist/components/TColorSwatch.cjs.map +1 -0
- package/dist/components/TColorSwatch.js +7 -0
- package/dist/components/TColorSwatch.js.map +1 -0
- package/dist/components/TColorSwatch.vue.d.ts +33 -0
- package/dist/components/TColorSwatch.vue.d.ts.map +1 -0
- package/dist/components/TColorSwatch.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TColorSwatch.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TColorSwatch.vue_vue_type_script_setup_true_lang.js +63 -0
- package/dist/components/TColorSwatch.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TDropdown.vue.d.ts +1 -1
- package/dist/components/TDropdown.vue.d.ts.map +1 -1
- package/dist/components/TFileUpload.cjs.map +1 -1
- package/dist/components/TFileUpload.js.map +1 -1
- package/dist/components/TFileUpload.vue.d.ts +77 -10
- package/dist/components/TFileUpload.vue.d.ts.map +1 -1
- package/dist/components/TFileUpload.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TFileUpload.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TFileUpload.vue_vue_type_script_setup_true_lang.js +393 -144
- package/dist/components/TFileUpload.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TInput.cjs.map +1 -1
- package/dist/components/TInput.js.map +1 -1
- package/dist/components/TInput.vue.d.ts +4 -1
- package/dist/components/TInput.vue.d.ts.map +1 -1
- package/dist/components/TInput.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TInput.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TInput.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/TInput.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TKbd.cjs +2 -0
- package/dist/components/TKbd.cjs.map +1 -0
- package/dist/components/TKbd.js +7 -0
- package/dist/components/TKbd.js.map +1 -0
- package/dist/components/TKbd.vue.d.ts +18 -0
- package/dist/components/TKbd.vue.d.ts.map +1 -0
- package/dist/components/TKbd.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TKbd.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TKbd.vue_vue_type_script_setup_true_lang.js +12 -0
- package/dist/components/TKbd.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TNavMenu.cjs.map +1 -1
- package/dist/components/TNavMenu.js.map +1 -1
- package/dist/components/TNavMenu.vue.d.ts +9 -0
- package/dist/components/TNavMenu.vue.d.ts.map +1 -1
- package/dist/components/TNavMenu.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TNavMenu.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TNavMenu.vue_vue_type_script_setup_true_lang.js +67 -55
- package/dist/components/TNavMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TPage.cjs +2 -0
- package/dist/components/TPage.cjs.map +1 -0
- package/dist/components/TPage.js +7 -0
- package/dist/components/TPage.js.map +1 -0
- package/dist/components/TPage.vue.d.ts +35 -0
- package/dist/components/TPage.vue.d.ts.map +1 -0
- package/dist/components/TPage.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TPage.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TPage.vue_vue_type_script_setup_true_lang.js +49 -0
- package/dist/components/TPage.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TPageHeader.cjs +2 -0
- package/dist/components/TPageHeader.cjs.map +1 -0
- package/dist/components/TPageHeader.js +7 -0
- package/dist/components/TPageHeader.js.map +1 -0
- package/dist/components/TPageHeader.vue.d.ts +40 -0
- package/dist/components/TPageHeader.vue.d.ts.map +1 -0
- package/dist/components/TPageHeader.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TPageHeader.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TPageHeader.vue_vue_type_script_setup_true_lang.js +54 -0
- package/dist/components/TPageHeader.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TPopover.vue.d.ts +1 -1
- package/dist/components/TPopover.vue.d.ts.map +1 -1
- package/dist/components/TSelect.cjs.map +1 -1
- package/dist/components/TSelect.js.map +1 -1
- package/dist/components/TSelect.vue.d.ts +4 -1
- package/dist/components/TSelect.vue.d.ts.map +1 -1
- package/dist/components/TSelect.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TSelect.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TSelect.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/TSelect.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TSidebar.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TSidebar.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TSidebar.vue_vue_type_script_setup_true_lang.js +1 -4
- package/dist/components/TSidebar.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TSpacer.cjs +2 -0
- package/dist/components/TSpacer.cjs.map +1 -0
- package/dist/components/TSpacer.js +7 -0
- package/dist/components/TSpacer.js.map +1 -0
- package/dist/components/TSpacer.vue.d.ts +8 -0
- package/dist/components/TSpacer.vue.d.ts.map +1 -0
- package/dist/components/TSpacer.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TSpacer.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TSpacer.vue_vue_type_script_setup_true_lang.js +16 -0
- package/dist/components/TSpacer.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TSplit.cjs +2 -0
- package/dist/components/TSplit.cjs.map +1 -0
- package/dist/components/TSplit.js +7 -0
- package/dist/components/TSplit.js.map +1 -0
- package/dist/components/TSplit.vue.d.ts +38 -0
- package/dist/components/TSplit.vue.d.ts.map +1 -0
- package/dist/components/TSplit.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TSplit.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TSplit.vue_vue_type_script_setup_true_lang.js +43 -0
- package/dist/components/TSplit.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TStack.cjs.map +1 -1
- package/dist/components/TStack.js.map +1 -1
- package/dist/components/TStack.vue.d.ts +3 -0
- package/dist/components/TStack.vue.d.ts.map +1 -1
- package/dist/components/TStack.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TStack.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TStack.vue_vue_type_script_setup_true_lang.js +6 -1
- package/dist/components/TStack.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/TStackItem.cjs +2 -0
- package/dist/components/TStackItem.cjs.map +1 -0
- package/dist/components/TStackItem.js +7 -0
- package/dist/components/TStackItem.js.map +1 -0
- package/dist/components/TStackItem.vue.d.ts +38 -0
- package/dist/components/TStackItem.vue.d.ts.map +1 -0
- package/dist/components/TStackItem.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TStackItem.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TStackItem.vue_vue_type_script_setup_true_lang.js +45 -0
- package/dist/components/TStackItem.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TText.cjs +2 -0
- package/dist/components/TText.cjs.map +1 -0
- package/dist/components/TText.js +7 -0
- package/dist/components/TText.js.map +1 -0
- package/dist/components/TText.vue.d.ts +39 -0
- package/dist/components/TText.vue.d.ts.map +1 -0
- package/dist/components/TText.vue_vue_type_script_setup_true_lang.cjs +2 -0
- package/dist/components/TText.vue_vue_type_script_setup_true_lang.cjs.map +1 -0
- package/dist/components/TText.vue_vue_type_script_setup_true_lang.js +32 -0
- package/dist/components/TText.vue_vue_type_script_setup_true_lang.js.map +1 -0
- package/dist/components/TTextarea.cjs.map +1 -1
- package/dist/components/TTextarea.js.map +1 -1
- package/dist/components/TTextarea.vue.d.ts +4 -1
- package/dist/components/TTextarea.vue.d.ts.map +1 -1
- package/dist/components/TTextarea.vue_vue_type_script_setup_true_lang.cjs +1 -1
- package/dist/components/TTextarea.vue_vue_type_script_setup_true_lang.cjs.map +1 -1
- package/dist/components/TTextarea.vue_vue_type_script_setup_true_lang.js +2 -0
- package/dist/components/TTextarea.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/index.d.ts +12 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/composables/useTheme.cjs +2 -0
- package/dist/composables/useTheme.cjs.map +1 -0
- package/dist/composables/useTheme.d.ts +38 -0
- package/dist/composables/useTheme.d.ts.map +1 -0
- package/dist/composables/useTheme.js +54 -0
- package/dist/composables/useTheme.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +64 -55
- package/dist/plugin.cjs +1 -1
- package/dist/plugin.cjs.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +113 -97
- package/dist/plugin.js.map +1 -1
- package/dist/style.css +1 -1
- package/dist/types/contracts.cjs +1 -1
- package/dist/types/contracts.cjs.map +1 -1
- package/dist/types/contracts.d.ts +8 -1
- package/dist/types/contracts.d.ts.map +1 -1
- package/dist/types/contracts.js +8 -2
- package/dist/types/contracts.js.map +1 -1
- package/package.json +2 -2
|
@@ -52,10 +52,10 @@ declare const __VLS_component: import('vue').DefineComponent<__VLS_Props, {}, {}
|
|
|
52
52
|
disabled: boolean;
|
|
53
53
|
size: TSize;
|
|
54
54
|
label: string;
|
|
55
|
+
align: TDropdownAlign;
|
|
55
56
|
open: boolean;
|
|
56
57
|
defaultOpen: boolean;
|
|
57
58
|
items: TDropdownItem[];
|
|
58
|
-
align: TDropdownAlign;
|
|
59
59
|
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {
|
|
60
60
|
rootRef: HTMLDivElement;
|
|
61
61
|
triggerRef: HTMLButtonElement;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TDropdown.vue.d.ts","sourceRoot":"","sources":["../../src/components/TDropdown.vue"],"names":[],"mappings":"AA6SA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAMhD,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,KAAK,CAAC;AAE7C,KAAK,WAAW,GAAG;IACf,KAAK,CAAC,EAAE,aAAa,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB,CAAC;AA+MJ,iBAAS,cAAc;WA8GT,OAAO,IAA6B;;uBAvSjC;YAAE,MAAM,EAAE,OAAO,CAAA;SAAE,GAAG,OAAO;oBAChC;YAAE,IAAI,EAAE,aAAa,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,GAAG,OAAO;;uBAD7C;YAAE,MAAM,EAAE,OAAO,CAAA;SAAE,GAAG,OAAO;oBAChC;YAAE,IAAI,EAAE,aAAa,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,GAAG,OAAO;;;;;;;EA2S7D;AAuBD,KAAK,oBAAoB,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AAC9D,QAAA,MAAM,eAAe;;;;;;;;;cA9VN,OAAO;UACX,KAAK;WACJ,MAAM;
|
|
1
|
+
{"version":3,"file":"TDropdown.vue.d.ts","sourceRoot":"","sources":["../../src/components/TDropdown.vue"],"names":[],"mappings":"AA6SA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAMhD,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,KAAK,CAAC;AAE7C,KAAK,WAAW,GAAG;IACf,KAAK,CAAC,EAAE,aAAa,EAAE,CAAC;IACxB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB,CAAC;AA+MJ,iBAAS,cAAc;WA8GT,OAAO,IAA6B;;uBAvSjC;YAAE,MAAM,EAAE,OAAO,CAAA;SAAE,GAAG,OAAO;oBAChC;YAAE,IAAI,EAAE,aAAa,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,GAAG,OAAO;;uBAD7C;YAAE,MAAM,EAAE,OAAO,CAAA;SAAE,GAAG,OAAO;oBAChC;YAAE,IAAI,EAAE,aAAa,CAAC;YAAC,KAAK,EAAE,MAAM,CAAA;SAAE,GAAG,OAAO;;;;;;;EA2S7D;AAuBD,KAAK,oBAAoB,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AAC9D,QAAA,MAAM,eAAe;;;;;;;;;cA9VN,OAAO;UACX,KAAK;WACJ,MAAM;WACN,cAAc;UALf,OAAO;iBACA,OAAO;WAFb,aAAa,EAAE;;;;OA0WzB,CAAC;wBACkB,uBAAuB,CAAC,OAAO,eAAe,EAAE,oBAAoB,CAAC,OAAO,CAAC,CAAC;AAAnG,wBAAoG;AAapG,KAAK,uBAAuB,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,GAAG;IACxC,QAAO;QACN,MAAM,EAAE,CAAC,CAAC;KAEV,CAAA;CACD,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TFileUpload.cjs","names":[],"sources":["../../src/components/TFileUpload.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { createId, isActivationKey } from '@treeui/utils';\nimport { computed, onBeforeUnmount, onMounted, ref, useAttrs } from 'vue';\nimport type { TSize } from '../types/contracts';\nimport TSpinner from './TSpinner.vue';\n\ndefineOptions({\n inheritAttrs: false,\n});\n\nexport type TFileUploadRejectionReason =\n | 'file-invalid-type'\n | 'file-too-large'\n | 'too-many-files';\n\nexport interface TFileUploadRejection {\n file: File;\n reason: TFileUploadRejectionReason;\n message: string;\n}\n\nconst slots = defineSlots<{\n icon?: () => unknown;\n default?: (props: {\n files: File[];\n isDragActive: boolean;\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n file?: (props: {\n file: File;\n index: number;\n removeFile: () => void;\n }) => unknown;\n actions?: (props: {\n files: File[];\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n}>();\n\nconst props = withDefaults(\n defineProps<{\n modelValue?: File[];\n size?: TSize;\n disabled?: boolean;\n loading?: boolean;\n invalid?: boolean;\n accept?: string;\n multiple?: boolean;\n maxFiles?: number | null;\n maxFileSize?: number | null;\n label?: string;\n description?: string;\n buttonLabel?: string;\n filesLabel?: string;\n paste?: boolean;\n drop?: boolean;\n showFileList?: boolean;\n }>(),\n {\n modelValue: () => [],\n size: 'md',\n disabled: false,\n loading: false,\n invalid: false,\n accept: '',\n multiple: true,\n label: 'Drop files here',\n description: 'Click to browse, drag and drop, or paste with Ctrl+V.',\n buttonLabel: 'Browse files',\n filesLabel: 'Selected files',\n maxFiles: null,\n maxFileSize: null,\n paste: true,\n drop: true,\n showFileList: true,\n },\n);\n\nconst emit = defineEmits<{\n 'update:modelValue': [files: File[]];\n 'files-accepted': [files: File[]];\n 'files-rejected': [rejections: TFileUploadRejection[]];\n}>();\n\nconst attrs = useAttrs();\nconst rootRef = ref<HTMLElement | null>(null);\nconst inputRef = ref<HTMLInputElement | null>(null);\nconst isDragActive = ref(false);\nconst isFocusedWithin = ref(false);\nconst dragDepth = ref(0);\nconst feedbackMessages = ref<string[]>([]);\n\nconst uploadId = createId('t-file-upload');\nconst descriptionId = `${uploadId}-description`;\nconst feedbackId = `${uploadId}-feedback`;\nconst filesId = `${uploadId}-files`;\n\nconst isUnavailable = computed(() => props.disabled || props.loading);\nconst hasFiles = computed(() => props.modelValue.length > 0);\nconst hasCustomBody = computed(() => Boolean(slots.default));\n\nconst effectiveMaxFiles = computed(() => {\n if (!props.multiple) {\n return 1;\n }\n\n if (typeof props.maxFiles !== 'number') {\n return Number.POSITIVE_INFINITY;\n }\n\n return Math.max(props.maxFiles, 0);\n});\n\nconst rootClasses = computed(() => [\n 't-file-upload',\n `t-file-upload--${props.size}`,\n {\n 'is-disabled': isUnavailable.value,\n 'is-invalid': props.invalid,\n 'is-loading': props.loading,\n 'is-drag-active': isDragActive.value,\n 'has-files': hasFiles.value,\n },\n attrs.class,\n]);\n\nconst rootStyle = computed(() => attrs.style);\n\nconst rootAttrs = computed(() => {\n const { class: _class, style: _style, ...rest } = attrs;\n return rest;\n});\n\nconst describedBy = computed(() => {\n const ids: string[] = [];\n\n if (props.description && !hasCustomBody.value) {\n ids.push(descriptionId);\n }\n\n if (feedbackMessages.value.length > 0) {\n ids.push(feedbackId);\n }\n\n if (props.showFileList && hasFiles.value) {\n ids.push(filesId);\n }\n\n return ids.length > 0 ? ids.join(' ') : undefined;\n});\n\nconst selectedFilesLabel = computed(() => `${props.filesLabel} (${props.modelValue.length})`);\n\nconst formatFileSize = (size: number) => {\n if (size < 1024) {\n return `${size} B`;\n }\n\n const units = ['KB', 'MB', 'GB'];\n let value = size / 1024;\n let unitIndex = 0;\n\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024;\n unitIndex += 1;\n }\n\n const roundedValue = value >= 10 || Number.isInteger(value)\n ? Math.round(value)\n : Number(value.toFixed(1));\n\n return `${roundedValue} ${units[unitIndex]}`;\n};\n\nconst fileKey = (file: File, index: number) =>\n `${file.name}-${file.size}-${file.lastModified}-${index}`;\n\nconst matchesAccept = (file: File) => {\n if (!props.accept.trim()) {\n return true;\n }\n\n const tokens = props.accept\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter(Boolean);\n\n const fileName = file.name.toLowerCase();\n const fileType = file.type.toLowerCase();\n\n return tokens.some((token) => {\n if (token.startsWith('.')) {\n return fileName.endsWith(token);\n }\n\n if (token.endsWith('/*')) {\n return fileType.startsWith(token.slice(0, -1));\n }\n\n return fileType === token;\n });\n};\n\nconst buildRejectionMessage = (\n file: File,\n reason: TFileUploadRejectionReason,\n) => {\n switch (reason) {\n case 'file-invalid-type':\n return `${file.name} is not an accepted file type.`;\n case 'file-too-large':\n return `${file.name} exceeds the ${formatFileSize(props.maxFileSize ?? 0)} limit.`;\n case 'too-many-files':\n return `You can only add up to ${effectiveMaxFiles.value} file${effectiveMaxFiles.value === 1 ? '' : 's'}.`;\n default:\n return `${file.name} could not be added.`;\n }\n};\n\nconst resetInputValue = () => {\n if (inputRef.value) {\n inputRef.value.value = '';\n }\n};\n\nconst openFileDialog = () => {\n if (isUnavailable.value) {\n return;\n }\n\n inputRef.value?.click();\n};\n\nconst emitFiles = (\n nextFiles: File[],\n acceptedFiles: File[],\n rejections: TFileUploadRejection[],\n) => {\n feedbackMessages.value = rejections.map((rejection) => rejection.message);\n\n if (rejections.length > 0) {\n emit('files-rejected', rejections);\n }\n\n if (acceptedFiles.length > 0) {\n emit('update:modelValue', nextFiles);\n emit('files-accepted', acceptedFiles);\n }\n};\n\nconst processFiles = (incomingFiles: File[]) => {\n if (isUnavailable.value || incomingFiles.length === 0) {\n return;\n }\n\n const nextFiles = props.multiple ? [...props.modelValue] : [];\n const acceptedFiles: File[] = [];\n const rejections: TFileUploadRejection[] = [];\n let remainingSlots = Number.isFinite(effectiveMaxFiles.value)\n ? Math.max(effectiveMaxFiles.value - nextFiles.length, 0)\n : Number.POSITIVE_INFINITY;\n\n for (const file of incomingFiles) {\n if (remainingSlots <= 0) {\n rejections.push({\n file,\n reason: 'too-many-files',\n message: buildRejectionMessage(file, 'too-many-files'),\n });\n continue;\n }\n\n if (!matchesAccept(file)) {\n rejections.push({\n file,\n reason: 'file-invalid-type',\n message: buildRejectionMessage(file, 'file-invalid-type'),\n });\n continue;\n }\n\n if (\n typeof props.maxFileSize === 'number' &&\n props.maxFileSize > 0 &&\n file.size > props.maxFileSize\n ) {\n rejections.push({\n file,\n reason: 'file-too-large',\n message: buildRejectionMessage(file, 'file-too-large'),\n });\n continue;\n }\n\n acceptedFiles.push(file);\n nextFiles.push(file);\n\n if (Number.isFinite(remainingSlots)) {\n remainingSlots -= 1;\n }\n }\n\n emitFiles(nextFiles, acceptedFiles, rejections);\n};\n\nconst removeFile = (index: number) => {\n if (isUnavailable.value) {\n return;\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', props.modelValue.filter((_, fileIndex) => fileIndex !== index));\n};\n\nconst clearFiles = () => {\n if (isUnavailable.value || !hasFiles.value) {\n return;\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', []);\n};\n\nconst hasTransferFiles = (dataTransfer?: DataTransfer | null) => {\n if (!dataTransfer) {\n return false;\n }\n\n return dataTransfer.files.length > 0 || Array.from(dataTransfer.types).includes('Files');\n};\n\nconst getClipboardFiles = (\n clipboardData?: Pick<DataTransfer, 'items'> | null,\n) => {\n if (!clipboardData?.items) {\n return [];\n }\n\n return Array.from(clipboardData.items)\n .map((item) => item.getAsFile?.() ?? null)\n .filter((file): file is File => file instanceof File);\n};\n\nconst onInputChange = (event: Event) => {\n const target = event.target as HTMLInputElement;\n processFiles(Array.from(target.files ?? []));\n resetInputValue();\n};\n\nconst onDropzoneKeydown = (event: KeyboardEvent) => {\n if (!isActivationKey(event) || isUnavailable.value) {\n return;\n }\n\n event.preventDefault();\n openFileDialog();\n};\n\nconst onFocusIn = () => {\n isFocusedWithin.value = true;\n};\n\nconst onFocusOut = (event: FocusEvent) => {\n const nextTarget = event.relatedTarget as Node | null;\n\n if (nextTarget && rootRef.value?.contains(nextTarget)) {\n return;\n }\n\n isFocusedWithin.value = false;\n};\n\nconst onPaste = (event: ClipboardEvent) => {\n if (!props.paste || isUnavailable.value) {\n return;\n }\n\n const clipboardFiles = getClipboardFiles(event.clipboardData);\n\n if (clipboardFiles.length === 0) {\n return;\n }\n\n event.preventDefault();\n processFiles(clipboardFiles);\n};\n\nconst onDocumentPaste = (event: Event) => {\n if (!isFocusedWithin.value || event.defaultPrevented) {\n return;\n }\n\n onPaste(event as ClipboardEvent);\n};\n\nconst onDragEnter = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value += 1;\n isDragActive.value = true;\n};\n\nconst onDragOver = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n\n if (event.dataTransfer) {\n event.dataTransfer.dropEffect = 'copy';\n }\n\n isDragActive.value = true;\n};\n\nconst onDragLeave = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = Math.max(dragDepth.value - 1, 0);\n\n if (dragDepth.value === 0) {\n isDragActive.value = false;\n }\n};\n\nconst onDrop = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = 0;\n isDragActive.value = false;\n processFiles(Array.from(event.dataTransfer?.files ?? []));\n};\n\nonMounted(() => {\n document.addEventListener('paste', onDocumentPaste);\n});\n\nonBeforeUnmount(() => {\n document.removeEventListener('paste', onDocumentPaste);\n});\n</script>\n\n<template>\n <div\n ref=\"rootRef\"\n v-bind=\"rootAttrs\"\n :class=\"rootClasses\"\n :style=\"rootStyle\"\n @focusin=\"onFocusIn\"\n @focusout=\"onFocusOut\"\n @paste=\"onPaste\"\n >\n <input\n :id=\"uploadId\"\n ref=\"inputRef\"\n class=\"t-visually-hidden\"\n type=\"file\"\n :accept=\"accept || undefined\"\n :multiple=\"multiple\"\n :disabled=\"isUnavailable\"\n @change=\"onInputChange\"\n >\n\n <div\n class=\"t-file-upload__dropzone\"\n :aria-busy=\"loading || undefined\"\n :aria-controls=\"showFileList && hasFiles ? filesId : undefined\"\n :aria-describedby=\"describedBy\"\n :aria-disabled=\"isUnavailable || undefined\"\n :aria-invalid=\"invalid || undefined\"\n role=\"button\"\n :tabindex=\"isUnavailable ? -1 : 0\"\n @click=\"openFileDialog\"\n @keydown=\"onDropzoneKeydown\"\n @dragenter=\"onDragEnter\"\n @dragover=\"onDragOver\"\n @dragleave=\"onDragLeave\"\n @drop=\"onDrop\"\n >\n <div\n v-if=\"$slots.icon || !$slots.default\"\n class=\"t-file-upload__icon\"\n aria-hidden=\"true\"\n >\n <slot name=\"icon\">\n <span class=\"t-file-upload__icon-mark\">+</span>\n </slot>\n </div>\n\n <div class=\"t-file-upload__content\">\n <slot\n :files=\"modelValue\"\n :is-drag-active=\"isDragActive\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n >\n <p class=\"t-file-upload__label\">\n {{ label }}\n </p>\n <p\n v-if=\"description\"\n :id=\"descriptionId\"\n class=\"t-file-upload__description\"\n >\n {{ description }}\n </p>\n <span class=\"t-file-upload__browse\">\n {{ buttonLabel }}\n </span>\n </slot>\n </div>\n\n <TSpinner\n v-if=\"loading\"\n class=\"t-file-upload__spinner\"\n size=\"sm\"\n label=\"Uploading files\"\n />\n </div>\n\n <ul\n v-if=\"feedbackMessages.length > 0\"\n :id=\"feedbackId\"\n class=\"t-file-upload__feedback\"\n aria-live=\"polite\"\n >\n <li\n v-for=\"message in feedbackMessages\"\n :key=\"message\"\n >\n {{ message }}\n </li>\n </ul>\n\n <div\n v-if=\"showFileList && hasFiles\"\n class=\"t-file-upload__files\"\n >\n <div class=\"t-file-upload__files-header\">\n <p\n :id=\"filesId\"\n class=\"t-file-upload__files-label\"\n >\n {{ selectedFilesLabel }}\n </p>\n\n <button\n class=\"t-file-upload__clear\"\n type=\"button\"\n :disabled=\"isUnavailable\"\n @click=\"clearFiles\"\n >\n Clear all\n </button>\n </div>\n\n <ul class=\"t-file-upload__list\">\n <li\n v-for=\"(file, index) in modelValue\"\n :key=\"fileKey(file, index)\"\n class=\"t-file-upload__file\"\n >\n <slot\n name=\"file\"\n :file=\"file\"\n :index=\"index\"\n :remove-file=\"() => removeFile(index)\"\n >\n <div class=\"t-file-upload__file-copy\">\n <p class=\"t-file-upload__file-name\">\n {{ file.name }}\n </p>\n <p class=\"t-file-upload__file-meta\">\n {{ formatFileSize(file.size) }}\n </p>\n </div>\n\n <button\n class=\"t-file-upload__remove\"\n type=\"button\"\n :disabled=\"isUnavailable\"\n :aria-label=\"`Remove ${file.name}`\"\n @click=\"removeFile(index)\"\n >\n Remove\n </button>\n </slot>\n </li>\n </ul>\n </div>\n\n <div\n v-if=\"$slots.actions\"\n class=\"t-file-upload__actions\"\n >\n <slot\n name=\"actions\"\n :files=\"modelValue\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n />\n </div>\n </div>\n</template>\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"TFileUpload.cjs","names":[],"sources":["../../src/components/TFileUpload.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { createId, isActivationKey } from '@treeui/utils';\nimport { computed, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue';\nimport type { TSize } from '../types/contracts';\nimport TProgress from './TProgress.vue';\nimport TSpinner from './TSpinner.vue';\n\ndefineOptions({\n inheritAttrs: false,\n});\n\nexport type TFileUploadRejectionReason =\n | 'file-invalid-type'\n | 'file-too-large'\n | 'too-many-files';\n\nexport interface TFileUploadRejection {\n file: File;\n reason: TFileUploadRejectionReason;\n message: string;\n}\n\nexport type TFileUploadStatus =\n | 'pending'\n | 'uploading'\n | 'paused'\n | 'error'\n | 'success';\n\nexport interface TFileUploadState {\n status: TFileUploadStatus;\n progress?: number;\n uploadedBytes?: number;\n remainingMs?: number;\n remainingLabel?: string;\n error?: string;\n resumable?: boolean;\n retryable?: boolean;\n thumbnailUrl?: string;\n meta?: string;\n}\n\nexport type TFileUploadStateSource =\n | ReadonlyMap<File, TFileUploadState>\n | ((file: File, index: number) => TFileUploadState | undefined);\n\nexport interface TFileUploadRetryPayload {\n file: File;\n index: number;\n fileKey: string;\n mode: 'resume' | 'restart';\n state: TFileUploadState;\n}\n\nexport interface TFileUploadCancelPayload {\n file: File;\n index: number;\n fileKey: string;\n state: TFileUploadState;\n reason: 'remove' | 'clear';\n}\n\nexport interface TFileUploadFileSlotProps {\n file: File;\n index: number;\n fileKey: string;\n state: TFileUploadState | undefined;\n thumbnailUrl: string | undefined;\n sizeLabel: string;\n typeLabel: string;\n dimensionsLabel: string | undefined;\n progressLabel: string | undefined;\n remainingLabel: string | undefined;\n uploadedLabel: string | undefined;\n statusLabel: string | undefined;\n actionLabel: string | undefined;\n canRetry: boolean;\n retryMode: 'resume' | 'restart';\n isDragActive: boolean;\n isDragReject: boolean;\n removeFile: () => void;\n retryFile: () => void;\n}\n\ninterface ThumbEntry {\n url: string;\n width: number | null;\n height: number | null;\n}\n\ninterface FileRow {\n slotProps: TFileUploadFileSlotProps;\n rowClass: string | undefined;\n metaText: string;\n statusText: string;\n errorText: string | undefined;\n errorId: string | undefined;\n showProgress: boolean;\n progressValue: number | undefined;\n progressAriaLabel: string;\n isBusy: boolean;\n isSuccess: boolean;\n}\n\nconst DEFAULT_STATUS_LABELS: Record<TFileUploadStatus, string> = {\n pending: 'Waiting',\n uploading: 'Uploading',\n paused: 'Paused',\n error: 'Upload failed',\n success: 'Uploaded',\n};\n\n// The status log keeps a bounded history of announcements. Old entries are\n// trimmed rather than replaced in place so that each terminal transition\n// inserts a brand new node: `aria-relevant` defaults to \"additions text\", so\n// assistive tech announces the insertion and ignores the trim.\nconst STATUS_LOG_LIMIT = 5;\n\ntype StatusLogEntry = {\n id: number;\n text: string;\n};\n\nconst slots = defineSlots<{\n icon?: () => unknown;\n default?: (props: {\n files: File[];\n isDragActive: boolean;\n isDragReject: boolean;\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n file?: (props: TFileUploadFileSlotProps) => unknown;\n 'file-status'?: (props: TFileUploadFileSlotProps) => unknown;\n actions?: (props: {\n files: File[];\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n}>();\n\nconst props = withDefaults(\n defineProps<{\n modelValue?: File[];\n size?: TSize;\n disabled?: boolean;\n loading?: boolean;\n invalid?: boolean;\n accept?: string;\n multiple?: boolean;\n maxFiles?: number | null;\n maxFileSize?: number | null;\n label?: string;\n description?: string;\n buttonLabel?: string;\n filesLabel?: string;\n paste?: boolean;\n drop?: boolean;\n showFileList?: boolean;\n uploadState?: TFileUploadStateSource;\n dragLabel?: string;\n dragRejectLabel?: string;\n thumbnails?: boolean;\n maxThumbnailSize?: number | null;\n retryLabel?: string;\n resumeLabel?: (percent: number) => string;\n remainingTimeFormat?: (remainingMs: number) => string;\n statusLabels?: Partial<Record<TFileUploadStatus, string>>;\n }>(),\n {\n modelValue: () => [],\n size: 'md',\n disabled: false,\n loading: false,\n invalid: false,\n accept: '',\n multiple: true,\n label: 'Drop files here',\n description: 'Click to browse, drag and drop, or paste with Ctrl+V.',\n buttonLabel: 'Browse files',\n filesLabel: 'Selected files',\n maxFiles: null,\n maxFileSize: null,\n paste: true,\n drop: true,\n showFileList: true,\n uploadState: undefined,\n dragLabel: 'Release to upload',\n dragRejectLabel: 'This file type is not accepted',\n thumbnails: true,\n maxThumbnailSize: 10 * 1024 * 1024,\n retryLabel: 'Retry',\n resumeLabel: (percent: number) => `Resume from ${percent}%`,\n remainingTimeFormat: (remainingMs: number) => {\n if (remainingMs < 5000) {\n return 'Less than 5s left';\n }\n\n const seconds = Math.round(remainingMs / 1000);\n\n if (seconds < 60) {\n return `About ${seconds}s left`;\n }\n\n const minutes = Math.round(seconds / 60);\n\n if (minutes < 60) {\n return `About ${minutes} min left`;\n }\n\n return `About ${Math.round(minutes / 60)}h left`;\n },\n statusLabels: () => ({}),\n },\n);\n\nconst emit = defineEmits<{\n 'update:modelValue': [files: File[]];\n 'files-accepted': [files: File[]];\n 'files-rejected': [rejections: TFileUploadRejection[]];\n retry: [payload: TFileUploadRetryPayload];\n cancel: [payload: TFileUploadCancelPayload];\n}>();\n\nconst attrs = useAttrs();\nconst rootRef = ref<HTMLElement | null>(null);\nconst inputRef = ref<HTMLInputElement | null>(null);\nconst isDragActive = ref(false);\nconst isDragReject = ref(false);\nconst isFocusedWithin = ref(false);\nconst dragDepth = ref(0);\nconst feedbackMessages = ref<string[]>([]);\nconst statusLog = ref<StatusLogEntry[]>([]);\nlet statusLogId = 0;\nconst retryFocusKey = ref<string | null>(null);\nconst thumbs = ref(new Map<File, ThumbEntry>());\n\nconst uploadId = createId('t-file-upload');\nconst descriptionId = `${uploadId}-description`;\nconst feedbackId = `${uploadId}-feedback`;\nconst filesId = `${uploadId}-files`;\n\nconst isDisabled = computed(() => props.disabled);\nconst hasFiles = computed(() => props.modelValue.length > 0);\nconst hasCustomBody = computed(() => Boolean(slots.default));\n\nconst resolvedStatusLabels = computed(() => ({\n ...DEFAULT_STATUS_LABELS,\n ...props.statusLabels,\n}));\n\nconst effectiveMaxFiles = computed(() => {\n if (!props.multiple) {\n return 1;\n }\n\n if (typeof props.maxFiles !== 'number') {\n return Number.POSITIVE_INFINITY;\n }\n\n return Math.max(props.maxFiles, 0);\n});\n\nconst rootClasses = computed(() => [\n 't-file-upload',\n `t-file-upload--${props.size}`,\n {\n 'is-disabled': isDisabled.value,\n 'is-invalid': props.invalid,\n 'is-loading': props.loading,\n 'is-drag-active': isDragActive.value,\n 'is-drag-reject': isDragReject.value,\n 'has-files': hasFiles.value,\n },\n attrs.class,\n]);\n\nconst rootStyle = computed(() => attrs.style);\n\nconst rootAttrs = computed(() => {\n const { class: _class, style: _style, ...rest } = attrs;\n return rest;\n});\n\nconst describedBy = computed(() => {\n const ids: string[] = [];\n\n if (props.description && !hasCustomBody.value) {\n ids.push(descriptionId);\n }\n\n if (feedbackMessages.value.length > 0) {\n ids.push(feedbackId);\n }\n\n if (props.showFileList && hasFiles.value) {\n ids.push(filesId);\n }\n\n return ids.length > 0 ? ids.join(' ') : undefined;\n});\n\nconst selectedFilesLabel = computed(() => `${props.filesLabel} (${props.modelValue.length})`);\n\nconst dropzoneLabel = computed(() => {\n if (isDragReject.value) {\n return props.dragRejectLabel;\n }\n\n return isDragActive.value ? props.dragLabel : props.label;\n});\n\nconst formatFileSize = (size: number) => {\n if (size < 1024) {\n return `${size} B`;\n }\n\n const units = ['KB', 'MB', 'GB'];\n let value = size / 1024;\n let unitIndex = 0;\n\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024;\n unitIndex += 1;\n }\n\n const roundedValue = value >= 10 || Number.isInteger(value)\n ? Math.round(value)\n : Number(value.toFixed(1));\n\n return `${roundedValue} ${units[unitIndex]}`;\n};\n\nconst formatFileType = (file: File) => {\n const dotIndex = file.name.lastIndexOf('.');\n const extension = dotIndex > 0 && dotIndex < file.name.length - 1\n ? file.name.slice(dotIndex + 1).toUpperCase()\n : '';\n\n if (extension && extension.length <= 5) {\n return extension;\n }\n\n const subtype = file.type.includes('/') ? file.type.split('/')[1] : '';\n\n return subtype ? subtype.toUpperCase() : 'FILE';\n};\n\nlet keySeq = 0;\nconst keyCache = new WeakMap<File, string>();\n\nconst fileKeyFor = (file: File) => {\n let key = keyCache.get(file);\n\n if (!key) {\n keySeq += 1;\n key = `${uploadId}-f${keySeq}`;\n keyCache.set(file, key);\n }\n\n return key;\n};\n\nconst acceptTokens = computed(() =>\n props.accept\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter(Boolean),\n);\n\nconst matchesAccept = (file: File) => {\n if (acceptTokens.value.length === 0) {\n return true;\n }\n\n const fileName = file.name.toLowerCase();\n const fileType = file.type.toLowerCase();\n\n return acceptTokens.value.some((token) => {\n if (token.startsWith('.')) {\n return fileName.endsWith(token);\n }\n\n if (token.endsWith('/*')) {\n return fileType.startsWith(token.slice(0, -1));\n }\n\n return fileType === token;\n });\n};\n\nconst canDetectDragReject = computed(() =>\n acceptTokens.value.length > 0 && acceptTokens.value.every((token) => token.includes('/')),\n);\n\nconst matchesAcceptType = (type: string) => {\n const fileType = type.toLowerCase();\n\n if (!fileType) {\n return true;\n }\n\n return acceptTokens.value.some((token) =>\n token.endsWith('/*') ? fileType.startsWith(token.slice(0, -1)) : fileType === token,\n );\n};\n\nconst detectDragReject = (dataTransfer?: DataTransfer | null) => {\n if (!canDetectDragReject.value || !dataTransfer) {\n return false;\n }\n\n const items = dataTransfer.items;\n\n if (!items || items.length === 0) {\n return false;\n }\n\n const fileItems = Array.from(items).filter((item) => item.kind === 'file');\n\n if (fileItems.length === 0) {\n return false;\n }\n\n return fileItems.every((item) => !matchesAcceptType(item.type));\n};\n\nconst resolveState = (file: File, index: number) => {\n const source = props.uploadState;\n\n if (!source) {\n return undefined;\n }\n\n return typeof source === 'function' ? source(file, index) : source.get(file);\n};\n\nconst resolvedStates = computed(() => props.modelValue.map(resolveState));\n\nconst canUseObjectUrl = () =>\n typeof window !== 'undefined' &&\n typeof URL !== 'undefined' &&\n typeof URL.createObjectURL === 'function' &&\n typeof URL.revokeObjectURL === 'function';\n\nconst isThumbnailable = (file: File) =>\n file.type.startsWith('image/') &&\n (props.maxThumbnailSize === null || file.size <= props.maxThumbnailSize);\n\nconst releaseAllThumbnails = () => {\n if (canUseObjectUrl()) {\n for (const entry of thumbs.value.values()) {\n URL.revokeObjectURL(entry.url);\n }\n }\n\n thumbs.value.clear();\n};\n\nconst syncThumbnails = () => {\n if (!props.thumbnails || !canUseObjectUrl()) {\n releaseAllThumbnails();\n return;\n }\n\n const live = new Set(props.modelValue);\n\n for (const [file, entry] of [...thumbs.value]) {\n if (!live.has(file)) {\n URL.revokeObjectURL(entry.url);\n thumbs.value.delete(file);\n }\n }\n\n for (const file of props.modelValue) {\n if (thumbs.value.has(file) || !isThumbnailable(file)) {\n continue;\n }\n\n const url = URL.createObjectURL(file);\n thumbs.value.set(file, { url, width: null, height: null });\n\n const image = new Image();\n\n image.onload = () => {\n const current = thumbs.value.get(file);\n\n if (!current || current.url !== url) {\n return;\n }\n\n thumbs.value.set(file, { url, width: image.naturalWidth, height: image.naturalHeight });\n };\n\n image.src = url;\n }\n};\n\nconst buildRejectionMessage = (\n file: File,\n reason: TFileUploadRejectionReason,\n) => {\n switch (reason) {\n case 'file-invalid-type':\n return `${file.name} is not an accepted file type.`;\n case 'file-too-large':\n return `${file.name} exceeds the ${formatFileSize(props.maxFileSize ?? 0)} limit.`;\n case 'too-many-files':\n return `You can only add up to ${effectiveMaxFiles.value} file${effectiveMaxFiles.value === 1 ? '' : 's'}.`;\n default:\n return `${file.name} could not be added.`;\n }\n};\n\nconst resetInputValue = () => {\n if (inputRef.value) {\n inputRef.value.value = '';\n }\n};\n\nconst openFileDialog = () => {\n if (isDisabled.value) {\n return;\n }\n\n inputRef.value?.click();\n};\n\nconst emitFiles = (\n nextFiles: File[],\n acceptedFiles: File[],\n rejections: TFileUploadRejection[],\n) => {\n feedbackMessages.value = rejections.map((rejection) => rejection.message);\n\n if (rejections.length > 0) {\n emit('files-rejected', rejections);\n }\n\n if (acceptedFiles.length > 0) {\n emit('update:modelValue', nextFiles);\n emit('files-accepted', acceptedFiles);\n }\n};\n\nconst processFiles = (incomingFiles: File[]) => {\n if (isDisabled.value || incomingFiles.length === 0) {\n return;\n }\n\n const nextFiles = props.multiple ? [...props.modelValue] : [];\n const acceptedFiles: File[] = [];\n const rejections: TFileUploadRejection[] = [];\n let remainingSlots = Number.isFinite(effectiveMaxFiles.value)\n ? Math.max(effectiveMaxFiles.value - nextFiles.length, 0)\n : Number.POSITIVE_INFINITY;\n\n for (const file of incomingFiles) {\n if (remainingSlots <= 0) {\n rejections.push({\n file,\n reason: 'too-many-files',\n message: buildRejectionMessage(file, 'too-many-files'),\n });\n continue;\n }\n\n if (!matchesAccept(file)) {\n rejections.push({\n file,\n reason: 'file-invalid-type',\n message: buildRejectionMessage(file, 'file-invalid-type'),\n });\n continue;\n }\n\n if (\n typeof props.maxFileSize === 'number' &&\n props.maxFileSize > 0 &&\n file.size > props.maxFileSize\n ) {\n rejections.push({\n file,\n reason: 'file-too-large',\n message: buildRejectionMessage(file, 'file-too-large'),\n });\n continue;\n }\n\n acceptedFiles.push(file);\n nextFiles.push(file);\n\n if (Number.isFinite(remainingSlots)) {\n remainingSlots -= 1;\n }\n }\n\n emitFiles(nextFiles, acceptedFiles, rejections);\n};\n\nconst emitCancel = (file: File, index: number, reason: 'remove' | 'clear') => {\n const state = resolvedStates.value[index];\n\n if (!state || (state.status !== 'uploading' && state.status !== 'paused')) {\n return;\n }\n\n emit('cancel', { file, index, fileKey: fileKeyFor(file), state, reason });\n};\n\nconst removeFile = (index: number) => {\n if (isDisabled.value) {\n return;\n }\n\n const file = props.modelValue[index];\n\n if (file) {\n emitCancel(file, index, 'remove');\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', props.modelValue.filter((_, fileIndex) => fileIndex !== index));\n};\n\nconst clearFiles = () => {\n if (isDisabled.value || !hasFiles.value) {\n return;\n }\n\n props.modelValue.forEach((file, index) => {\n emitCancel(file, index, 'clear');\n });\n\n feedbackMessages.value = [];\n emit('update:modelValue', []);\n};\n\nconst rows = computed<FileRow[]>(() =>\n props.modelValue.map((file, index) => {\n const state = resolvedStates.value[index];\n const status = state?.status;\n const thumb = thumbs.value.get(file);\n const key = fileKeyFor(file);\n\n const progress = state?.progress;\n const percent = typeof progress === 'number' ? Math.round(progress) : undefined;\n const isResumable = state?.resumable === true;\n\n const canRetry = Boolean(\n state && (status === 'error' || status === 'paused') && state.retryable !== false,\n );\n const retryMode: 'resume' | 'restart' = isResumable ? 'resume' : 'restart';\n\n const sizeLabel = formatFileSize(file.size);\n const typeLabel = formatFileType(file);\n const dimensionsLabel = thumb && thumb.width !== null && thumb.height !== null\n ? `${thumb.width} × ${thumb.height}`\n : undefined;\n\n const progressLabel = status === 'uploading' && percent !== undefined\n ? `${percent}%`\n : undefined;\n\n let remainingLabel: string | undefined;\n\n if (status === 'uploading') {\n if (state?.remainingLabel) {\n remainingLabel = state.remainingLabel;\n } else if (\n typeof state?.remainingMs === 'number' &&\n Number.isFinite(state.remainingMs) &&\n state.remainingMs >= 0\n ) {\n remainingLabel = props.remainingTimeFormat(state.remainingMs);\n }\n }\n\n const uploadedLabel = (status === 'error' || status === 'paused') &&\n typeof state?.uploadedBytes === 'number'\n ? `${formatFileSize(state.uploadedBytes)} of ${sizeLabel} uploaded`\n : undefined;\n\n const statusLabel = status ? resolvedStatusLabels.value[status] : undefined;\n\n let actionLabel: string | undefined;\n\n if (canRetry) {\n actionLabel = isResumable && percent !== undefined\n ? props.resumeLabel(percent)\n : props.retryLabel;\n }\n\n const errorText = status === 'error' ? state?.error : undefined;\n\n const slotProps: TFileUploadFileSlotProps = {\n file,\n index,\n fileKey: key,\n state,\n thumbnailUrl: state?.thumbnailUrl ?? thumb?.url,\n sizeLabel,\n typeLabel,\n dimensionsLabel,\n progressLabel,\n remainingLabel,\n uploadedLabel,\n statusLabel,\n actionLabel,\n canRetry,\n retryMode,\n isDragActive: isDragActive.value,\n isDragReject: isDragReject.value,\n removeFile: () => removeFile(index),\n retryFile: () => {\n if (!canRetry || isDisabled.value || !state) {\n return;\n }\n\n emit('retry', { file, index, fileKey: key, mode: retryMode, state });\n },\n };\n\n return {\n slotProps,\n rowClass: status ? `is-${status}` : undefined,\n metaText: [typeLabel, sizeLabel, dimensionsLabel, state?.meta].filter(Boolean).join(' · '),\n statusText: [statusLabel, progressLabel, remainingLabel, uploadedLabel]\n .filter(Boolean)\n .join(' · '),\n errorText,\n errorId: errorText ? `${key}-error` : undefined,\n showProgress: Boolean(\n state &&\n (status === 'uploading' ||\n status === 'paused' ||\n status === 'error' ||\n (status === 'success' && percent !== undefined)),\n ),\n progressValue: status === 'uploading' ? progress : (progress ?? 0),\n progressAriaLabel: `${statusLabel ?? ''} ${file.name}`.trim(),\n isBusy: status === 'uploading',\n isSuccess: status === 'success',\n };\n }),\n);\n\nconst hasTransferFiles = (dataTransfer?: DataTransfer | null) => {\n if (!dataTransfer) {\n return false;\n }\n\n return dataTransfer.files.length > 0 || Array.from(dataTransfer.types).includes('Files');\n};\n\nconst getClipboardFiles = (\n clipboardData?: Pick<DataTransfer, 'items'> | null,\n) => {\n if (!clipboardData?.items) {\n return [];\n }\n\n return Array.from(clipboardData.items)\n .map((item) => item.getAsFile?.() ?? null)\n .filter((file): file is File => file instanceof File);\n};\n\nconst onInputChange = (event: Event) => {\n const target = event.target as HTMLInputElement;\n processFiles(Array.from(target.files ?? []));\n resetInputValue();\n};\n\nconst onDropzoneKeydown = (event: KeyboardEvent) => {\n if (!isActivationKey(event) || isDisabled.value) {\n return;\n }\n\n event.preventDefault();\n openFileDialog();\n};\n\nconst onFocusIn = () => {\n isFocusedWithin.value = true;\n};\n\nconst onFocusOut = (event: FocusEvent) => {\n const nextTarget = event.relatedTarget as Node | null;\n\n if (nextTarget && rootRef.value?.contains(nextTarget)) {\n return;\n }\n\n isFocusedWithin.value = false;\n};\n\nconst onRetryFocus = (key: string) => {\n retryFocusKey.value = key;\n};\n\nconst onRetryBlur = (key: string) => {\n if (retryFocusKey.value === key) {\n retryFocusKey.value = null;\n }\n};\n\nconst onPaste = (event: ClipboardEvent) => {\n if (!props.paste || isDisabled.value) {\n return;\n }\n\n const clipboardFiles = getClipboardFiles(event.clipboardData);\n\n if (clipboardFiles.length === 0) {\n return;\n }\n\n event.preventDefault();\n processFiles(clipboardFiles);\n};\n\nconst onDocumentPaste = (event: Event) => {\n if (!isFocusedWithin.value || event.defaultPrevented) {\n return;\n }\n\n onPaste(event as ClipboardEvent);\n};\n\nconst onDragEnter = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value += 1;\n isDragActive.value = true;\n isDragReject.value = detectDragReject(event.dataTransfer);\n};\n\nconst onDragOver = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n\n if (event.dataTransfer) {\n event.dataTransfer.dropEffect = 'copy';\n }\n\n isDragActive.value = true;\n isDragReject.value = detectDragReject(event.dataTransfer);\n};\n\nconst onDragLeave = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = Math.max(dragDepth.value - 1, 0);\n\n if (dragDepth.value === 0) {\n isDragActive.value = false;\n isDragReject.value = false;\n }\n};\n\nconst onDrop = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = 0;\n isDragActive.value = false;\n isDragReject.value = false;\n processFiles(Array.from(event.dataTransfer?.files ?? []));\n};\n\nconst previousStatuses = new Map<File, TFileUploadStatus>();\n\nwatch(\n resolvedStates,\n (states) => {\n const messages: string[] = [];\n const live = new Set<File>();\n\n props.modelValue.forEach((file, index) => {\n live.add(file);\n\n const status = states[index]?.status;\n\n if (!status) {\n return;\n }\n\n const previous = previousStatuses.get(file);\n\n if (previous === status) {\n return;\n }\n\n previousStatuses.set(file, status);\n\n if (previous === undefined) {\n return;\n }\n\n if (status === 'success') {\n messages.push(`${file.name} uploaded.`);\n } else if (status === 'error') {\n messages.push(`${file.name} failed. ${states[index]?.error ?? ''}`.trim());\n }\n });\n\n for (const file of [...previousStatuses.keys()]) {\n if (!live.has(file)) {\n previousStatuses.delete(file);\n }\n }\n\n if (messages.length > 0) {\n // Append instead of overwriting a single string: two identical messages\n // in a row (a retry that fails with the same error) would otherwise write\n // a byte-identical value, which `Object.is` suppresses — no DOM mutation,\n // so `aria-live` never fires and the repeat failure is announced as silence.\n statusLogId += 1;\n statusLog.value = [\n ...statusLog.value,\n { id: statusLogId, text: messages.join(' ') },\n ].slice(-STATUS_LOG_LIMIT);\n }\n },\n { flush: 'post', immediate: true },\n);\n\nwatch(\n resolvedStates,\n () => {\n const key = retryFocusKey.value;\n const root = rootRef.value;\n\n if (!key || !root) {\n return;\n }\n\n if (document.activeElement && document.activeElement !== document.body) {\n return;\n }\n\n if (root.querySelector(`[data-t-retry=\"${key}\"]`)) {\n return;\n }\n\n retryFocusKey.value = null;\n\n const fallback = root.querySelector<HTMLElement>(`[data-t-remove=\"${key}\"]`) ??\n root.querySelector<HTMLElement>('.t-file-upload__dropzone');\n\n fallback?.focus();\n },\n { flush: 'post' },\n);\n\nwatch(() => props.modelValue, syncThumbnails);\nwatch(() => props.thumbnails, syncThumbnails);\n\nonMounted(() => {\n document.addEventListener('paste', onDocumentPaste);\n syncThumbnails();\n});\n\nonBeforeUnmount(() => {\n document.removeEventListener('paste', onDocumentPaste);\n releaseAllThumbnails();\n});\n</script>\n\n<template>\n <div\n ref=\"rootRef\"\n v-bind=\"rootAttrs\"\n :class=\"rootClasses\"\n :style=\"rootStyle\"\n @focusin=\"onFocusIn\"\n @focusout=\"onFocusOut\"\n @paste=\"onPaste\"\n >\n <input\n :id=\"uploadId\"\n ref=\"inputRef\"\n class=\"t-visually-hidden\"\n type=\"file\"\n :accept=\"accept || undefined\"\n :multiple=\"multiple\"\n :disabled=\"isDisabled\"\n @change=\"onInputChange\"\n >\n\n <div\n class=\"t-file-upload__dropzone\"\n :aria-busy=\"loading || undefined\"\n :aria-controls=\"showFileList && hasFiles ? filesId : undefined\"\n :aria-describedby=\"describedBy\"\n :aria-disabled=\"isDisabled || undefined\"\n :aria-invalid=\"invalid || undefined\"\n role=\"button\"\n :tabindex=\"isDisabled ? -1 : 0\"\n @click=\"openFileDialog\"\n @keydown=\"onDropzoneKeydown\"\n @dragenter=\"onDragEnter\"\n @dragover=\"onDragOver\"\n @dragleave=\"onDragLeave\"\n @drop=\"onDrop\"\n >\n <div\n v-if=\"$slots.icon || !$slots.default\"\n class=\"t-file-upload__icon\"\n aria-hidden=\"true\"\n >\n <slot name=\"icon\">\n <span class=\"t-file-upload__icon-mark\">+</span>\n </slot>\n </div>\n\n <div class=\"t-file-upload__content\">\n <slot\n :files=\"modelValue\"\n :is-drag-active=\"isDragActive\"\n :is-drag-reject=\"isDragReject\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n >\n <p class=\"t-file-upload__label\">\n {{ dropzoneLabel }}\n </p>\n <p\n v-if=\"description\"\n :id=\"descriptionId\"\n class=\"t-file-upload__description\"\n >\n {{ description }}\n </p>\n <span class=\"t-file-upload__browse\">\n {{ buttonLabel }}\n </span>\n </slot>\n </div>\n\n <TSpinner\n v-if=\"loading\"\n class=\"t-file-upload__spinner\"\n size=\"sm\"\n label=\"Uploading files\"\n />\n </div>\n\n <ul\n v-if=\"feedbackMessages.length > 0\"\n :id=\"feedbackId\"\n class=\"t-file-upload__feedback\"\n aria-live=\"polite\"\n >\n <li\n v-for=\"message in feedbackMessages\"\n :key=\"message\"\n >\n {{ message }}\n </li>\n </ul>\n\n <div\n class=\"t-file-upload__status-log t-visually-hidden\"\n role=\"log\"\n aria-live=\"polite\"\n >\n <p\n v-for=\"entry in statusLog\"\n :key=\"entry.id\"\n class=\"t-file-upload__status-log-entry\"\n >\n {{ entry.text }}\n </p>\n </div>\n\n <div\n v-if=\"showFileList && hasFiles\"\n class=\"t-file-upload__files\"\n >\n <div class=\"t-file-upload__files-header\">\n <p\n :id=\"filesId\"\n class=\"t-file-upload__files-label\"\n >\n {{ selectedFilesLabel }}\n </p>\n\n <button\n class=\"t-file-upload__clear\"\n type=\"button\"\n :disabled=\"isDisabled\"\n @click=\"clearFiles\"\n >\n Clear all\n </button>\n </div>\n\n <ul class=\"t-file-upload__list\">\n <li\n v-for=\"row in rows\"\n :key=\"row.slotProps.fileKey\"\n class=\"t-file-upload__file\"\n :class=\"row.rowClass\"\n :aria-busy=\"row.isBusy || undefined\"\n >\n <slot\n name=\"file\"\n v-bind=\"row.slotProps\"\n >\n <span\n class=\"t-file-upload__thumb\"\n aria-hidden=\"true\"\n >\n <img\n v-if=\"row.slotProps.thumbnailUrl\"\n class=\"t-file-upload__thumb-image\"\n :src=\"row.slotProps.thumbnailUrl\"\n alt=\"\"\n >\n <span\n v-else\n class=\"t-file-upload__thumb-fallback\"\n >\n {{ row.slotProps.typeLabel }}\n </span>\n </span>\n\n <div class=\"t-file-upload__file-copy\">\n <p class=\"t-file-upload__file-name\">\n {{ row.slotProps.file.name }}\n </p>\n <p class=\"t-file-upload__file-meta\">\n {{ row.metaText }}\n </p>\n\n <slot\n name=\"file-status\"\n v-bind=\"row.slotProps\"\n >\n <div\n v-if=\"row.showProgress\"\n class=\"t-file-upload__file-progress\"\n >\n <TProgress\n size=\"sm\"\n :value=\"row.progressValue\"\n :label=\"row.progressAriaLabel\"\n />\n </div>\n\n <p\n v-if=\"row.statusText\"\n class=\"t-file-upload__file-status\"\n >\n <span\n v-if=\"row.isSuccess\"\n class=\"t-file-upload__proof\"\n aria-hidden=\"true\"\n >✓</span>{{ row.statusText }}\n </p>\n\n <p\n v-if=\"row.errorText\"\n :id=\"row.errorId\"\n class=\"t-file-upload__file-error\"\n >\n {{ row.errorText }}\n </p>\n </slot>\n </div>\n\n <button\n v-if=\"row.slotProps.canRetry\"\n class=\"t-file-upload__retry\"\n type=\"button\"\n :data-t-retry=\"row.slotProps.fileKey\"\n :disabled=\"isDisabled\"\n :aria-describedby=\"row.errorId\"\n @focus=\"onRetryFocus(row.slotProps.fileKey)\"\n @blur=\"onRetryBlur(row.slotProps.fileKey)\"\n @click=\"row.slotProps.retryFile()\"\n >\n {{ row.slotProps.actionLabel }}\n </button>\n\n <button\n class=\"t-file-upload__remove\"\n type=\"button\"\n :data-t-remove=\"row.slotProps.fileKey\"\n :disabled=\"isDisabled\"\n :aria-label=\"`Remove ${row.slotProps.file.name}`\"\n @click=\"row.slotProps.removeFile()\"\n >\n Remove\n </button>\n </slot>\n </li>\n </ul>\n </div>\n\n <div\n v-if=\"$slots.actions\"\n class=\"t-file-upload__actions\"\n >\n <slot\n name=\"actions\"\n :files=\"modelValue\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n />\n </div>\n </div>\n</template>\n"],"mappings":""}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TFileUpload.js","names":[],"sources":["../../src/components/TFileUpload.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { createId, isActivationKey } from '@treeui/utils';\nimport { computed, onBeforeUnmount, onMounted, ref, useAttrs } from 'vue';\nimport type { TSize } from '../types/contracts';\nimport TSpinner from './TSpinner.vue';\n\ndefineOptions({\n inheritAttrs: false,\n});\n\nexport type TFileUploadRejectionReason =\n | 'file-invalid-type'\n | 'file-too-large'\n | 'too-many-files';\n\nexport interface TFileUploadRejection {\n file: File;\n reason: TFileUploadRejectionReason;\n message: string;\n}\n\nconst slots = defineSlots<{\n icon?: () => unknown;\n default?: (props: {\n files: File[];\n isDragActive: boolean;\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n file?: (props: {\n file: File;\n index: number;\n removeFile: () => void;\n }) => unknown;\n actions?: (props: {\n files: File[];\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n}>();\n\nconst props = withDefaults(\n defineProps<{\n modelValue?: File[];\n size?: TSize;\n disabled?: boolean;\n loading?: boolean;\n invalid?: boolean;\n accept?: string;\n multiple?: boolean;\n maxFiles?: number | null;\n maxFileSize?: number | null;\n label?: string;\n description?: string;\n buttonLabel?: string;\n filesLabel?: string;\n paste?: boolean;\n drop?: boolean;\n showFileList?: boolean;\n }>(),\n {\n modelValue: () => [],\n size: 'md',\n disabled: false,\n loading: false,\n invalid: false,\n accept: '',\n multiple: true,\n label: 'Drop files here',\n description: 'Click to browse, drag and drop, or paste with Ctrl+V.',\n buttonLabel: 'Browse files',\n filesLabel: 'Selected files',\n maxFiles: null,\n maxFileSize: null,\n paste: true,\n drop: true,\n showFileList: true,\n },\n);\n\nconst emit = defineEmits<{\n 'update:modelValue': [files: File[]];\n 'files-accepted': [files: File[]];\n 'files-rejected': [rejections: TFileUploadRejection[]];\n}>();\n\nconst attrs = useAttrs();\nconst rootRef = ref<HTMLElement | null>(null);\nconst inputRef = ref<HTMLInputElement | null>(null);\nconst isDragActive = ref(false);\nconst isFocusedWithin = ref(false);\nconst dragDepth = ref(0);\nconst feedbackMessages = ref<string[]>([]);\n\nconst uploadId = createId('t-file-upload');\nconst descriptionId = `${uploadId}-description`;\nconst feedbackId = `${uploadId}-feedback`;\nconst filesId = `${uploadId}-files`;\n\nconst isUnavailable = computed(() => props.disabled || props.loading);\nconst hasFiles = computed(() => props.modelValue.length > 0);\nconst hasCustomBody = computed(() => Boolean(slots.default));\n\nconst effectiveMaxFiles = computed(() => {\n if (!props.multiple) {\n return 1;\n }\n\n if (typeof props.maxFiles !== 'number') {\n return Number.POSITIVE_INFINITY;\n }\n\n return Math.max(props.maxFiles, 0);\n});\n\nconst rootClasses = computed(() => [\n 't-file-upload',\n `t-file-upload--${props.size}`,\n {\n 'is-disabled': isUnavailable.value,\n 'is-invalid': props.invalid,\n 'is-loading': props.loading,\n 'is-drag-active': isDragActive.value,\n 'has-files': hasFiles.value,\n },\n attrs.class,\n]);\n\nconst rootStyle = computed(() => attrs.style);\n\nconst rootAttrs = computed(() => {\n const { class: _class, style: _style, ...rest } = attrs;\n return rest;\n});\n\nconst describedBy = computed(() => {\n const ids: string[] = [];\n\n if (props.description && !hasCustomBody.value) {\n ids.push(descriptionId);\n }\n\n if (feedbackMessages.value.length > 0) {\n ids.push(feedbackId);\n }\n\n if (props.showFileList && hasFiles.value) {\n ids.push(filesId);\n }\n\n return ids.length > 0 ? ids.join(' ') : undefined;\n});\n\nconst selectedFilesLabel = computed(() => `${props.filesLabel} (${props.modelValue.length})`);\n\nconst formatFileSize = (size: number) => {\n if (size < 1024) {\n return `${size} B`;\n }\n\n const units = ['KB', 'MB', 'GB'];\n let value = size / 1024;\n let unitIndex = 0;\n\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024;\n unitIndex += 1;\n }\n\n const roundedValue = value >= 10 || Number.isInteger(value)\n ? Math.round(value)\n : Number(value.toFixed(1));\n\n return `${roundedValue} ${units[unitIndex]}`;\n};\n\nconst fileKey = (file: File, index: number) =>\n `${file.name}-${file.size}-${file.lastModified}-${index}`;\n\nconst matchesAccept = (file: File) => {\n if (!props.accept.trim()) {\n return true;\n }\n\n const tokens = props.accept\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter(Boolean);\n\n const fileName = file.name.toLowerCase();\n const fileType = file.type.toLowerCase();\n\n return tokens.some((token) => {\n if (token.startsWith('.')) {\n return fileName.endsWith(token);\n }\n\n if (token.endsWith('/*')) {\n return fileType.startsWith(token.slice(0, -1));\n }\n\n return fileType === token;\n });\n};\n\nconst buildRejectionMessage = (\n file: File,\n reason: TFileUploadRejectionReason,\n) => {\n switch (reason) {\n case 'file-invalid-type':\n return `${file.name} is not an accepted file type.`;\n case 'file-too-large':\n return `${file.name} exceeds the ${formatFileSize(props.maxFileSize ?? 0)} limit.`;\n case 'too-many-files':\n return `You can only add up to ${effectiveMaxFiles.value} file${effectiveMaxFiles.value === 1 ? '' : 's'}.`;\n default:\n return `${file.name} could not be added.`;\n }\n};\n\nconst resetInputValue = () => {\n if (inputRef.value) {\n inputRef.value.value = '';\n }\n};\n\nconst openFileDialog = () => {\n if (isUnavailable.value) {\n return;\n }\n\n inputRef.value?.click();\n};\n\nconst emitFiles = (\n nextFiles: File[],\n acceptedFiles: File[],\n rejections: TFileUploadRejection[],\n) => {\n feedbackMessages.value = rejections.map((rejection) => rejection.message);\n\n if (rejections.length > 0) {\n emit('files-rejected', rejections);\n }\n\n if (acceptedFiles.length > 0) {\n emit('update:modelValue', nextFiles);\n emit('files-accepted', acceptedFiles);\n }\n};\n\nconst processFiles = (incomingFiles: File[]) => {\n if (isUnavailable.value || incomingFiles.length === 0) {\n return;\n }\n\n const nextFiles = props.multiple ? [...props.modelValue] : [];\n const acceptedFiles: File[] = [];\n const rejections: TFileUploadRejection[] = [];\n let remainingSlots = Number.isFinite(effectiveMaxFiles.value)\n ? Math.max(effectiveMaxFiles.value - nextFiles.length, 0)\n : Number.POSITIVE_INFINITY;\n\n for (const file of incomingFiles) {\n if (remainingSlots <= 0) {\n rejections.push({\n file,\n reason: 'too-many-files',\n message: buildRejectionMessage(file, 'too-many-files'),\n });\n continue;\n }\n\n if (!matchesAccept(file)) {\n rejections.push({\n file,\n reason: 'file-invalid-type',\n message: buildRejectionMessage(file, 'file-invalid-type'),\n });\n continue;\n }\n\n if (\n typeof props.maxFileSize === 'number' &&\n props.maxFileSize > 0 &&\n file.size > props.maxFileSize\n ) {\n rejections.push({\n file,\n reason: 'file-too-large',\n message: buildRejectionMessage(file, 'file-too-large'),\n });\n continue;\n }\n\n acceptedFiles.push(file);\n nextFiles.push(file);\n\n if (Number.isFinite(remainingSlots)) {\n remainingSlots -= 1;\n }\n }\n\n emitFiles(nextFiles, acceptedFiles, rejections);\n};\n\nconst removeFile = (index: number) => {\n if (isUnavailable.value) {\n return;\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', props.modelValue.filter((_, fileIndex) => fileIndex !== index));\n};\n\nconst clearFiles = () => {\n if (isUnavailable.value || !hasFiles.value) {\n return;\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', []);\n};\n\nconst hasTransferFiles = (dataTransfer?: DataTransfer | null) => {\n if (!dataTransfer) {\n return false;\n }\n\n return dataTransfer.files.length > 0 || Array.from(dataTransfer.types).includes('Files');\n};\n\nconst getClipboardFiles = (\n clipboardData?: Pick<DataTransfer, 'items'> | null,\n) => {\n if (!clipboardData?.items) {\n return [];\n }\n\n return Array.from(clipboardData.items)\n .map((item) => item.getAsFile?.() ?? null)\n .filter((file): file is File => file instanceof File);\n};\n\nconst onInputChange = (event: Event) => {\n const target = event.target as HTMLInputElement;\n processFiles(Array.from(target.files ?? []));\n resetInputValue();\n};\n\nconst onDropzoneKeydown = (event: KeyboardEvent) => {\n if (!isActivationKey(event) || isUnavailable.value) {\n return;\n }\n\n event.preventDefault();\n openFileDialog();\n};\n\nconst onFocusIn = () => {\n isFocusedWithin.value = true;\n};\n\nconst onFocusOut = (event: FocusEvent) => {\n const nextTarget = event.relatedTarget as Node | null;\n\n if (nextTarget && rootRef.value?.contains(nextTarget)) {\n return;\n }\n\n isFocusedWithin.value = false;\n};\n\nconst onPaste = (event: ClipboardEvent) => {\n if (!props.paste || isUnavailable.value) {\n return;\n }\n\n const clipboardFiles = getClipboardFiles(event.clipboardData);\n\n if (clipboardFiles.length === 0) {\n return;\n }\n\n event.preventDefault();\n processFiles(clipboardFiles);\n};\n\nconst onDocumentPaste = (event: Event) => {\n if (!isFocusedWithin.value || event.defaultPrevented) {\n return;\n }\n\n onPaste(event as ClipboardEvent);\n};\n\nconst onDragEnter = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value += 1;\n isDragActive.value = true;\n};\n\nconst onDragOver = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n\n if (event.dataTransfer) {\n event.dataTransfer.dropEffect = 'copy';\n }\n\n isDragActive.value = true;\n};\n\nconst onDragLeave = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = Math.max(dragDepth.value - 1, 0);\n\n if (dragDepth.value === 0) {\n isDragActive.value = false;\n }\n};\n\nconst onDrop = (event: DragEvent) => {\n if (!props.drop || isUnavailable.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = 0;\n isDragActive.value = false;\n processFiles(Array.from(event.dataTransfer?.files ?? []));\n};\n\nonMounted(() => {\n document.addEventListener('paste', onDocumentPaste);\n});\n\nonBeforeUnmount(() => {\n document.removeEventListener('paste', onDocumentPaste);\n});\n</script>\n\n<template>\n <div\n ref=\"rootRef\"\n v-bind=\"rootAttrs\"\n :class=\"rootClasses\"\n :style=\"rootStyle\"\n @focusin=\"onFocusIn\"\n @focusout=\"onFocusOut\"\n @paste=\"onPaste\"\n >\n <input\n :id=\"uploadId\"\n ref=\"inputRef\"\n class=\"t-visually-hidden\"\n type=\"file\"\n :accept=\"accept || undefined\"\n :multiple=\"multiple\"\n :disabled=\"isUnavailable\"\n @change=\"onInputChange\"\n >\n\n <div\n class=\"t-file-upload__dropzone\"\n :aria-busy=\"loading || undefined\"\n :aria-controls=\"showFileList && hasFiles ? filesId : undefined\"\n :aria-describedby=\"describedBy\"\n :aria-disabled=\"isUnavailable || undefined\"\n :aria-invalid=\"invalid || undefined\"\n role=\"button\"\n :tabindex=\"isUnavailable ? -1 : 0\"\n @click=\"openFileDialog\"\n @keydown=\"onDropzoneKeydown\"\n @dragenter=\"onDragEnter\"\n @dragover=\"onDragOver\"\n @dragleave=\"onDragLeave\"\n @drop=\"onDrop\"\n >\n <div\n v-if=\"$slots.icon || !$slots.default\"\n class=\"t-file-upload__icon\"\n aria-hidden=\"true\"\n >\n <slot name=\"icon\">\n <span class=\"t-file-upload__icon-mark\">+</span>\n </slot>\n </div>\n\n <div class=\"t-file-upload__content\">\n <slot\n :files=\"modelValue\"\n :is-drag-active=\"isDragActive\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n >\n <p class=\"t-file-upload__label\">\n {{ label }}\n </p>\n <p\n v-if=\"description\"\n :id=\"descriptionId\"\n class=\"t-file-upload__description\"\n >\n {{ description }}\n </p>\n <span class=\"t-file-upload__browse\">\n {{ buttonLabel }}\n </span>\n </slot>\n </div>\n\n <TSpinner\n v-if=\"loading\"\n class=\"t-file-upload__spinner\"\n size=\"sm\"\n label=\"Uploading files\"\n />\n </div>\n\n <ul\n v-if=\"feedbackMessages.length > 0\"\n :id=\"feedbackId\"\n class=\"t-file-upload__feedback\"\n aria-live=\"polite\"\n >\n <li\n v-for=\"message in feedbackMessages\"\n :key=\"message\"\n >\n {{ message }}\n </li>\n </ul>\n\n <div\n v-if=\"showFileList && hasFiles\"\n class=\"t-file-upload__files\"\n >\n <div class=\"t-file-upload__files-header\">\n <p\n :id=\"filesId\"\n class=\"t-file-upload__files-label\"\n >\n {{ selectedFilesLabel }}\n </p>\n\n <button\n class=\"t-file-upload__clear\"\n type=\"button\"\n :disabled=\"isUnavailable\"\n @click=\"clearFiles\"\n >\n Clear all\n </button>\n </div>\n\n <ul class=\"t-file-upload__list\">\n <li\n v-for=\"(file, index) in modelValue\"\n :key=\"fileKey(file, index)\"\n class=\"t-file-upload__file\"\n >\n <slot\n name=\"file\"\n :file=\"file\"\n :index=\"index\"\n :remove-file=\"() => removeFile(index)\"\n >\n <div class=\"t-file-upload__file-copy\">\n <p class=\"t-file-upload__file-name\">\n {{ file.name }}\n </p>\n <p class=\"t-file-upload__file-meta\">\n {{ formatFileSize(file.size) }}\n </p>\n </div>\n\n <button\n class=\"t-file-upload__remove\"\n type=\"button\"\n :disabled=\"isUnavailable\"\n :aria-label=\"`Remove ${file.name}`\"\n @click=\"removeFile(index)\"\n >\n Remove\n </button>\n </slot>\n </li>\n </ul>\n </div>\n\n <div\n v-if=\"$slots.actions\"\n class=\"t-file-upload__actions\"\n >\n <slot\n name=\"actions\"\n :files=\"modelValue\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n />\n </div>\n </div>\n</template>\n"],"mappings":""}
|
|
1
|
+
{"version":3,"file":"TFileUpload.js","names":[],"sources":["../../src/components/TFileUpload.vue"],"sourcesContent":["<script setup lang=\"ts\">\nimport { createId, isActivationKey } from '@treeui/utils';\nimport { computed, onBeforeUnmount, onMounted, ref, useAttrs, watch } from 'vue';\nimport type { TSize } from '../types/contracts';\nimport TProgress from './TProgress.vue';\nimport TSpinner from './TSpinner.vue';\n\ndefineOptions({\n inheritAttrs: false,\n});\n\nexport type TFileUploadRejectionReason =\n | 'file-invalid-type'\n | 'file-too-large'\n | 'too-many-files';\n\nexport interface TFileUploadRejection {\n file: File;\n reason: TFileUploadRejectionReason;\n message: string;\n}\n\nexport type TFileUploadStatus =\n | 'pending'\n | 'uploading'\n | 'paused'\n | 'error'\n | 'success';\n\nexport interface TFileUploadState {\n status: TFileUploadStatus;\n progress?: number;\n uploadedBytes?: number;\n remainingMs?: number;\n remainingLabel?: string;\n error?: string;\n resumable?: boolean;\n retryable?: boolean;\n thumbnailUrl?: string;\n meta?: string;\n}\n\nexport type TFileUploadStateSource =\n | ReadonlyMap<File, TFileUploadState>\n | ((file: File, index: number) => TFileUploadState | undefined);\n\nexport interface TFileUploadRetryPayload {\n file: File;\n index: number;\n fileKey: string;\n mode: 'resume' | 'restart';\n state: TFileUploadState;\n}\n\nexport interface TFileUploadCancelPayload {\n file: File;\n index: number;\n fileKey: string;\n state: TFileUploadState;\n reason: 'remove' | 'clear';\n}\n\nexport interface TFileUploadFileSlotProps {\n file: File;\n index: number;\n fileKey: string;\n state: TFileUploadState | undefined;\n thumbnailUrl: string | undefined;\n sizeLabel: string;\n typeLabel: string;\n dimensionsLabel: string | undefined;\n progressLabel: string | undefined;\n remainingLabel: string | undefined;\n uploadedLabel: string | undefined;\n statusLabel: string | undefined;\n actionLabel: string | undefined;\n canRetry: boolean;\n retryMode: 'resume' | 'restart';\n isDragActive: boolean;\n isDragReject: boolean;\n removeFile: () => void;\n retryFile: () => void;\n}\n\ninterface ThumbEntry {\n url: string;\n width: number | null;\n height: number | null;\n}\n\ninterface FileRow {\n slotProps: TFileUploadFileSlotProps;\n rowClass: string | undefined;\n metaText: string;\n statusText: string;\n errorText: string | undefined;\n errorId: string | undefined;\n showProgress: boolean;\n progressValue: number | undefined;\n progressAriaLabel: string;\n isBusy: boolean;\n isSuccess: boolean;\n}\n\nconst DEFAULT_STATUS_LABELS: Record<TFileUploadStatus, string> = {\n pending: 'Waiting',\n uploading: 'Uploading',\n paused: 'Paused',\n error: 'Upload failed',\n success: 'Uploaded',\n};\n\n// The status log keeps a bounded history of announcements. Old entries are\n// trimmed rather than replaced in place so that each terminal transition\n// inserts a brand new node: `aria-relevant` defaults to \"additions text\", so\n// assistive tech announces the insertion and ignores the trim.\nconst STATUS_LOG_LIMIT = 5;\n\ntype StatusLogEntry = {\n id: number;\n text: string;\n};\n\nconst slots = defineSlots<{\n icon?: () => unknown;\n default?: (props: {\n files: File[];\n isDragActive: boolean;\n isDragReject: boolean;\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n file?: (props: TFileUploadFileSlotProps) => unknown;\n 'file-status'?: (props: TFileUploadFileSlotProps) => unknown;\n actions?: (props: {\n files: File[];\n openFileDialog: () => void;\n clearFiles: () => void;\n }) => unknown;\n}>();\n\nconst props = withDefaults(\n defineProps<{\n modelValue?: File[];\n size?: TSize;\n disabled?: boolean;\n loading?: boolean;\n invalid?: boolean;\n accept?: string;\n multiple?: boolean;\n maxFiles?: number | null;\n maxFileSize?: number | null;\n label?: string;\n description?: string;\n buttonLabel?: string;\n filesLabel?: string;\n paste?: boolean;\n drop?: boolean;\n showFileList?: boolean;\n uploadState?: TFileUploadStateSource;\n dragLabel?: string;\n dragRejectLabel?: string;\n thumbnails?: boolean;\n maxThumbnailSize?: number | null;\n retryLabel?: string;\n resumeLabel?: (percent: number) => string;\n remainingTimeFormat?: (remainingMs: number) => string;\n statusLabels?: Partial<Record<TFileUploadStatus, string>>;\n }>(),\n {\n modelValue: () => [],\n size: 'md',\n disabled: false,\n loading: false,\n invalid: false,\n accept: '',\n multiple: true,\n label: 'Drop files here',\n description: 'Click to browse, drag and drop, or paste with Ctrl+V.',\n buttonLabel: 'Browse files',\n filesLabel: 'Selected files',\n maxFiles: null,\n maxFileSize: null,\n paste: true,\n drop: true,\n showFileList: true,\n uploadState: undefined,\n dragLabel: 'Release to upload',\n dragRejectLabel: 'This file type is not accepted',\n thumbnails: true,\n maxThumbnailSize: 10 * 1024 * 1024,\n retryLabel: 'Retry',\n resumeLabel: (percent: number) => `Resume from ${percent}%`,\n remainingTimeFormat: (remainingMs: number) => {\n if (remainingMs < 5000) {\n return 'Less than 5s left';\n }\n\n const seconds = Math.round(remainingMs / 1000);\n\n if (seconds < 60) {\n return `About ${seconds}s left`;\n }\n\n const minutes = Math.round(seconds / 60);\n\n if (minutes < 60) {\n return `About ${minutes} min left`;\n }\n\n return `About ${Math.round(minutes / 60)}h left`;\n },\n statusLabels: () => ({}),\n },\n);\n\nconst emit = defineEmits<{\n 'update:modelValue': [files: File[]];\n 'files-accepted': [files: File[]];\n 'files-rejected': [rejections: TFileUploadRejection[]];\n retry: [payload: TFileUploadRetryPayload];\n cancel: [payload: TFileUploadCancelPayload];\n}>();\n\nconst attrs = useAttrs();\nconst rootRef = ref<HTMLElement | null>(null);\nconst inputRef = ref<HTMLInputElement | null>(null);\nconst isDragActive = ref(false);\nconst isDragReject = ref(false);\nconst isFocusedWithin = ref(false);\nconst dragDepth = ref(0);\nconst feedbackMessages = ref<string[]>([]);\nconst statusLog = ref<StatusLogEntry[]>([]);\nlet statusLogId = 0;\nconst retryFocusKey = ref<string | null>(null);\nconst thumbs = ref(new Map<File, ThumbEntry>());\n\nconst uploadId = createId('t-file-upload');\nconst descriptionId = `${uploadId}-description`;\nconst feedbackId = `${uploadId}-feedback`;\nconst filesId = `${uploadId}-files`;\n\nconst isDisabled = computed(() => props.disabled);\nconst hasFiles = computed(() => props.modelValue.length > 0);\nconst hasCustomBody = computed(() => Boolean(slots.default));\n\nconst resolvedStatusLabels = computed(() => ({\n ...DEFAULT_STATUS_LABELS,\n ...props.statusLabels,\n}));\n\nconst effectiveMaxFiles = computed(() => {\n if (!props.multiple) {\n return 1;\n }\n\n if (typeof props.maxFiles !== 'number') {\n return Number.POSITIVE_INFINITY;\n }\n\n return Math.max(props.maxFiles, 0);\n});\n\nconst rootClasses = computed(() => [\n 't-file-upload',\n `t-file-upload--${props.size}`,\n {\n 'is-disabled': isDisabled.value,\n 'is-invalid': props.invalid,\n 'is-loading': props.loading,\n 'is-drag-active': isDragActive.value,\n 'is-drag-reject': isDragReject.value,\n 'has-files': hasFiles.value,\n },\n attrs.class,\n]);\n\nconst rootStyle = computed(() => attrs.style);\n\nconst rootAttrs = computed(() => {\n const { class: _class, style: _style, ...rest } = attrs;\n return rest;\n});\n\nconst describedBy = computed(() => {\n const ids: string[] = [];\n\n if (props.description && !hasCustomBody.value) {\n ids.push(descriptionId);\n }\n\n if (feedbackMessages.value.length > 0) {\n ids.push(feedbackId);\n }\n\n if (props.showFileList && hasFiles.value) {\n ids.push(filesId);\n }\n\n return ids.length > 0 ? ids.join(' ') : undefined;\n});\n\nconst selectedFilesLabel = computed(() => `${props.filesLabel} (${props.modelValue.length})`);\n\nconst dropzoneLabel = computed(() => {\n if (isDragReject.value) {\n return props.dragRejectLabel;\n }\n\n return isDragActive.value ? props.dragLabel : props.label;\n});\n\nconst formatFileSize = (size: number) => {\n if (size < 1024) {\n return `${size} B`;\n }\n\n const units = ['KB', 'MB', 'GB'];\n let value = size / 1024;\n let unitIndex = 0;\n\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024;\n unitIndex += 1;\n }\n\n const roundedValue = value >= 10 || Number.isInteger(value)\n ? Math.round(value)\n : Number(value.toFixed(1));\n\n return `${roundedValue} ${units[unitIndex]}`;\n};\n\nconst formatFileType = (file: File) => {\n const dotIndex = file.name.lastIndexOf('.');\n const extension = dotIndex > 0 && dotIndex < file.name.length - 1\n ? file.name.slice(dotIndex + 1).toUpperCase()\n : '';\n\n if (extension && extension.length <= 5) {\n return extension;\n }\n\n const subtype = file.type.includes('/') ? file.type.split('/')[1] : '';\n\n return subtype ? subtype.toUpperCase() : 'FILE';\n};\n\nlet keySeq = 0;\nconst keyCache = new WeakMap<File, string>();\n\nconst fileKeyFor = (file: File) => {\n let key = keyCache.get(file);\n\n if (!key) {\n keySeq += 1;\n key = `${uploadId}-f${keySeq}`;\n keyCache.set(file, key);\n }\n\n return key;\n};\n\nconst acceptTokens = computed(() =>\n props.accept\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter(Boolean),\n);\n\nconst matchesAccept = (file: File) => {\n if (acceptTokens.value.length === 0) {\n return true;\n }\n\n const fileName = file.name.toLowerCase();\n const fileType = file.type.toLowerCase();\n\n return acceptTokens.value.some((token) => {\n if (token.startsWith('.')) {\n return fileName.endsWith(token);\n }\n\n if (token.endsWith('/*')) {\n return fileType.startsWith(token.slice(0, -1));\n }\n\n return fileType === token;\n });\n};\n\nconst canDetectDragReject = computed(() =>\n acceptTokens.value.length > 0 && acceptTokens.value.every((token) => token.includes('/')),\n);\n\nconst matchesAcceptType = (type: string) => {\n const fileType = type.toLowerCase();\n\n if (!fileType) {\n return true;\n }\n\n return acceptTokens.value.some((token) =>\n token.endsWith('/*') ? fileType.startsWith(token.slice(0, -1)) : fileType === token,\n );\n};\n\nconst detectDragReject = (dataTransfer?: DataTransfer | null) => {\n if (!canDetectDragReject.value || !dataTransfer) {\n return false;\n }\n\n const items = dataTransfer.items;\n\n if (!items || items.length === 0) {\n return false;\n }\n\n const fileItems = Array.from(items).filter((item) => item.kind === 'file');\n\n if (fileItems.length === 0) {\n return false;\n }\n\n return fileItems.every((item) => !matchesAcceptType(item.type));\n};\n\nconst resolveState = (file: File, index: number) => {\n const source = props.uploadState;\n\n if (!source) {\n return undefined;\n }\n\n return typeof source === 'function' ? source(file, index) : source.get(file);\n};\n\nconst resolvedStates = computed(() => props.modelValue.map(resolveState));\n\nconst canUseObjectUrl = () =>\n typeof window !== 'undefined' &&\n typeof URL !== 'undefined' &&\n typeof URL.createObjectURL === 'function' &&\n typeof URL.revokeObjectURL === 'function';\n\nconst isThumbnailable = (file: File) =>\n file.type.startsWith('image/') &&\n (props.maxThumbnailSize === null || file.size <= props.maxThumbnailSize);\n\nconst releaseAllThumbnails = () => {\n if (canUseObjectUrl()) {\n for (const entry of thumbs.value.values()) {\n URL.revokeObjectURL(entry.url);\n }\n }\n\n thumbs.value.clear();\n};\n\nconst syncThumbnails = () => {\n if (!props.thumbnails || !canUseObjectUrl()) {\n releaseAllThumbnails();\n return;\n }\n\n const live = new Set(props.modelValue);\n\n for (const [file, entry] of [...thumbs.value]) {\n if (!live.has(file)) {\n URL.revokeObjectURL(entry.url);\n thumbs.value.delete(file);\n }\n }\n\n for (const file of props.modelValue) {\n if (thumbs.value.has(file) || !isThumbnailable(file)) {\n continue;\n }\n\n const url = URL.createObjectURL(file);\n thumbs.value.set(file, { url, width: null, height: null });\n\n const image = new Image();\n\n image.onload = () => {\n const current = thumbs.value.get(file);\n\n if (!current || current.url !== url) {\n return;\n }\n\n thumbs.value.set(file, { url, width: image.naturalWidth, height: image.naturalHeight });\n };\n\n image.src = url;\n }\n};\n\nconst buildRejectionMessage = (\n file: File,\n reason: TFileUploadRejectionReason,\n) => {\n switch (reason) {\n case 'file-invalid-type':\n return `${file.name} is not an accepted file type.`;\n case 'file-too-large':\n return `${file.name} exceeds the ${formatFileSize(props.maxFileSize ?? 0)} limit.`;\n case 'too-many-files':\n return `You can only add up to ${effectiveMaxFiles.value} file${effectiveMaxFiles.value === 1 ? '' : 's'}.`;\n default:\n return `${file.name} could not be added.`;\n }\n};\n\nconst resetInputValue = () => {\n if (inputRef.value) {\n inputRef.value.value = '';\n }\n};\n\nconst openFileDialog = () => {\n if (isDisabled.value) {\n return;\n }\n\n inputRef.value?.click();\n};\n\nconst emitFiles = (\n nextFiles: File[],\n acceptedFiles: File[],\n rejections: TFileUploadRejection[],\n) => {\n feedbackMessages.value = rejections.map((rejection) => rejection.message);\n\n if (rejections.length > 0) {\n emit('files-rejected', rejections);\n }\n\n if (acceptedFiles.length > 0) {\n emit('update:modelValue', nextFiles);\n emit('files-accepted', acceptedFiles);\n }\n};\n\nconst processFiles = (incomingFiles: File[]) => {\n if (isDisabled.value || incomingFiles.length === 0) {\n return;\n }\n\n const nextFiles = props.multiple ? [...props.modelValue] : [];\n const acceptedFiles: File[] = [];\n const rejections: TFileUploadRejection[] = [];\n let remainingSlots = Number.isFinite(effectiveMaxFiles.value)\n ? Math.max(effectiveMaxFiles.value - nextFiles.length, 0)\n : Number.POSITIVE_INFINITY;\n\n for (const file of incomingFiles) {\n if (remainingSlots <= 0) {\n rejections.push({\n file,\n reason: 'too-many-files',\n message: buildRejectionMessage(file, 'too-many-files'),\n });\n continue;\n }\n\n if (!matchesAccept(file)) {\n rejections.push({\n file,\n reason: 'file-invalid-type',\n message: buildRejectionMessage(file, 'file-invalid-type'),\n });\n continue;\n }\n\n if (\n typeof props.maxFileSize === 'number' &&\n props.maxFileSize > 0 &&\n file.size > props.maxFileSize\n ) {\n rejections.push({\n file,\n reason: 'file-too-large',\n message: buildRejectionMessage(file, 'file-too-large'),\n });\n continue;\n }\n\n acceptedFiles.push(file);\n nextFiles.push(file);\n\n if (Number.isFinite(remainingSlots)) {\n remainingSlots -= 1;\n }\n }\n\n emitFiles(nextFiles, acceptedFiles, rejections);\n};\n\nconst emitCancel = (file: File, index: number, reason: 'remove' | 'clear') => {\n const state = resolvedStates.value[index];\n\n if (!state || (state.status !== 'uploading' && state.status !== 'paused')) {\n return;\n }\n\n emit('cancel', { file, index, fileKey: fileKeyFor(file), state, reason });\n};\n\nconst removeFile = (index: number) => {\n if (isDisabled.value) {\n return;\n }\n\n const file = props.modelValue[index];\n\n if (file) {\n emitCancel(file, index, 'remove');\n }\n\n feedbackMessages.value = [];\n emit('update:modelValue', props.modelValue.filter((_, fileIndex) => fileIndex !== index));\n};\n\nconst clearFiles = () => {\n if (isDisabled.value || !hasFiles.value) {\n return;\n }\n\n props.modelValue.forEach((file, index) => {\n emitCancel(file, index, 'clear');\n });\n\n feedbackMessages.value = [];\n emit('update:modelValue', []);\n};\n\nconst rows = computed<FileRow[]>(() =>\n props.modelValue.map((file, index) => {\n const state = resolvedStates.value[index];\n const status = state?.status;\n const thumb = thumbs.value.get(file);\n const key = fileKeyFor(file);\n\n const progress = state?.progress;\n const percent = typeof progress === 'number' ? Math.round(progress) : undefined;\n const isResumable = state?.resumable === true;\n\n const canRetry = Boolean(\n state && (status === 'error' || status === 'paused') && state.retryable !== false,\n );\n const retryMode: 'resume' | 'restart' = isResumable ? 'resume' : 'restart';\n\n const sizeLabel = formatFileSize(file.size);\n const typeLabel = formatFileType(file);\n const dimensionsLabel = thumb && thumb.width !== null && thumb.height !== null\n ? `${thumb.width} × ${thumb.height}`\n : undefined;\n\n const progressLabel = status === 'uploading' && percent !== undefined\n ? `${percent}%`\n : undefined;\n\n let remainingLabel: string | undefined;\n\n if (status === 'uploading') {\n if (state?.remainingLabel) {\n remainingLabel = state.remainingLabel;\n } else if (\n typeof state?.remainingMs === 'number' &&\n Number.isFinite(state.remainingMs) &&\n state.remainingMs >= 0\n ) {\n remainingLabel = props.remainingTimeFormat(state.remainingMs);\n }\n }\n\n const uploadedLabel = (status === 'error' || status === 'paused') &&\n typeof state?.uploadedBytes === 'number'\n ? `${formatFileSize(state.uploadedBytes)} of ${sizeLabel} uploaded`\n : undefined;\n\n const statusLabel = status ? resolvedStatusLabels.value[status] : undefined;\n\n let actionLabel: string | undefined;\n\n if (canRetry) {\n actionLabel = isResumable && percent !== undefined\n ? props.resumeLabel(percent)\n : props.retryLabel;\n }\n\n const errorText = status === 'error' ? state?.error : undefined;\n\n const slotProps: TFileUploadFileSlotProps = {\n file,\n index,\n fileKey: key,\n state,\n thumbnailUrl: state?.thumbnailUrl ?? thumb?.url,\n sizeLabel,\n typeLabel,\n dimensionsLabel,\n progressLabel,\n remainingLabel,\n uploadedLabel,\n statusLabel,\n actionLabel,\n canRetry,\n retryMode,\n isDragActive: isDragActive.value,\n isDragReject: isDragReject.value,\n removeFile: () => removeFile(index),\n retryFile: () => {\n if (!canRetry || isDisabled.value || !state) {\n return;\n }\n\n emit('retry', { file, index, fileKey: key, mode: retryMode, state });\n },\n };\n\n return {\n slotProps,\n rowClass: status ? `is-${status}` : undefined,\n metaText: [typeLabel, sizeLabel, dimensionsLabel, state?.meta].filter(Boolean).join(' · '),\n statusText: [statusLabel, progressLabel, remainingLabel, uploadedLabel]\n .filter(Boolean)\n .join(' · '),\n errorText,\n errorId: errorText ? `${key}-error` : undefined,\n showProgress: Boolean(\n state &&\n (status === 'uploading' ||\n status === 'paused' ||\n status === 'error' ||\n (status === 'success' && percent !== undefined)),\n ),\n progressValue: status === 'uploading' ? progress : (progress ?? 0),\n progressAriaLabel: `${statusLabel ?? ''} ${file.name}`.trim(),\n isBusy: status === 'uploading',\n isSuccess: status === 'success',\n };\n }),\n);\n\nconst hasTransferFiles = (dataTransfer?: DataTransfer | null) => {\n if (!dataTransfer) {\n return false;\n }\n\n return dataTransfer.files.length > 0 || Array.from(dataTransfer.types).includes('Files');\n};\n\nconst getClipboardFiles = (\n clipboardData?: Pick<DataTransfer, 'items'> | null,\n) => {\n if (!clipboardData?.items) {\n return [];\n }\n\n return Array.from(clipboardData.items)\n .map((item) => item.getAsFile?.() ?? null)\n .filter((file): file is File => file instanceof File);\n};\n\nconst onInputChange = (event: Event) => {\n const target = event.target as HTMLInputElement;\n processFiles(Array.from(target.files ?? []));\n resetInputValue();\n};\n\nconst onDropzoneKeydown = (event: KeyboardEvent) => {\n if (!isActivationKey(event) || isDisabled.value) {\n return;\n }\n\n event.preventDefault();\n openFileDialog();\n};\n\nconst onFocusIn = () => {\n isFocusedWithin.value = true;\n};\n\nconst onFocusOut = (event: FocusEvent) => {\n const nextTarget = event.relatedTarget as Node | null;\n\n if (nextTarget && rootRef.value?.contains(nextTarget)) {\n return;\n }\n\n isFocusedWithin.value = false;\n};\n\nconst onRetryFocus = (key: string) => {\n retryFocusKey.value = key;\n};\n\nconst onRetryBlur = (key: string) => {\n if (retryFocusKey.value === key) {\n retryFocusKey.value = null;\n }\n};\n\nconst onPaste = (event: ClipboardEvent) => {\n if (!props.paste || isDisabled.value) {\n return;\n }\n\n const clipboardFiles = getClipboardFiles(event.clipboardData);\n\n if (clipboardFiles.length === 0) {\n return;\n }\n\n event.preventDefault();\n processFiles(clipboardFiles);\n};\n\nconst onDocumentPaste = (event: Event) => {\n if (!isFocusedWithin.value || event.defaultPrevented) {\n return;\n }\n\n onPaste(event as ClipboardEvent);\n};\n\nconst onDragEnter = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value += 1;\n isDragActive.value = true;\n isDragReject.value = detectDragReject(event.dataTransfer);\n};\n\nconst onDragOver = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n\n if (event.dataTransfer) {\n event.dataTransfer.dropEffect = 'copy';\n }\n\n isDragActive.value = true;\n isDragReject.value = detectDragReject(event.dataTransfer);\n};\n\nconst onDragLeave = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = Math.max(dragDepth.value - 1, 0);\n\n if (dragDepth.value === 0) {\n isDragActive.value = false;\n isDragReject.value = false;\n }\n};\n\nconst onDrop = (event: DragEvent) => {\n if (!props.drop || isDisabled.value || !hasTransferFiles(event.dataTransfer)) {\n return;\n }\n\n event.preventDefault();\n dragDepth.value = 0;\n isDragActive.value = false;\n isDragReject.value = false;\n processFiles(Array.from(event.dataTransfer?.files ?? []));\n};\n\nconst previousStatuses = new Map<File, TFileUploadStatus>();\n\nwatch(\n resolvedStates,\n (states) => {\n const messages: string[] = [];\n const live = new Set<File>();\n\n props.modelValue.forEach((file, index) => {\n live.add(file);\n\n const status = states[index]?.status;\n\n if (!status) {\n return;\n }\n\n const previous = previousStatuses.get(file);\n\n if (previous === status) {\n return;\n }\n\n previousStatuses.set(file, status);\n\n if (previous === undefined) {\n return;\n }\n\n if (status === 'success') {\n messages.push(`${file.name} uploaded.`);\n } else if (status === 'error') {\n messages.push(`${file.name} failed. ${states[index]?.error ?? ''}`.trim());\n }\n });\n\n for (const file of [...previousStatuses.keys()]) {\n if (!live.has(file)) {\n previousStatuses.delete(file);\n }\n }\n\n if (messages.length > 0) {\n // Append instead of overwriting a single string: two identical messages\n // in a row (a retry that fails with the same error) would otherwise write\n // a byte-identical value, which `Object.is` suppresses — no DOM mutation,\n // so `aria-live` never fires and the repeat failure is announced as silence.\n statusLogId += 1;\n statusLog.value = [\n ...statusLog.value,\n { id: statusLogId, text: messages.join(' ') },\n ].slice(-STATUS_LOG_LIMIT);\n }\n },\n { flush: 'post', immediate: true },\n);\n\nwatch(\n resolvedStates,\n () => {\n const key = retryFocusKey.value;\n const root = rootRef.value;\n\n if (!key || !root) {\n return;\n }\n\n if (document.activeElement && document.activeElement !== document.body) {\n return;\n }\n\n if (root.querySelector(`[data-t-retry=\"${key}\"]`)) {\n return;\n }\n\n retryFocusKey.value = null;\n\n const fallback = root.querySelector<HTMLElement>(`[data-t-remove=\"${key}\"]`) ??\n root.querySelector<HTMLElement>('.t-file-upload__dropzone');\n\n fallback?.focus();\n },\n { flush: 'post' },\n);\n\nwatch(() => props.modelValue, syncThumbnails);\nwatch(() => props.thumbnails, syncThumbnails);\n\nonMounted(() => {\n document.addEventListener('paste', onDocumentPaste);\n syncThumbnails();\n});\n\nonBeforeUnmount(() => {\n document.removeEventListener('paste', onDocumentPaste);\n releaseAllThumbnails();\n});\n</script>\n\n<template>\n <div\n ref=\"rootRef\"\n v-bind=\"rootAttrs\"\n :class=\"rootClasses\"\n :style=\"rootStyle\"\n @focusin=\"onFocusIn\"\n @focusout=\"onFocusOut\"\n @paste=\"onPaste\"\n >\n <input\n :id=\"uploadId\"\n ref=\"inputRef\"\n class=\"t-visually-hidden\"\n type=\"file\"\n :accept=\"accept || undefined\"\n :multiple=\"multiple\"\n :disabled=\"isDisabled\"\n @change=\"onInputChange\"\n >\n\n <div\n class=\"t-file-upload__dropzone\"\n :aria-busy=\"loading || undefined\"\n :aria-controls=\"showFileList && hasFiles ? filesId : undefined\"\n :aria-describedby=\"describedBy\"\n :aria-disabled=\"isDisabled || undefined\"\n :aria-invalid=\"invalid || undefined\"\n role=\"button\"\n :tabindex=\"isDisabled ? -1 : 0\"\n @click=\"openFileDialog\"\n @keydown=\"onDropzoneKeydown\"\n @dragenter=\"onDragEnter\"\n @dragover=\"onDragOver\"\n @dragleave=\"onDragLeave\"\n @drop=\"onDrop\"\n >\n <div\n v-if=\"$slots.icon || !$slots.default\"\n class=\"t-file-upload__icon\"\n aria-hidden=\"true\"\n >\n <slot name=\"icon\">\n <span class=\"t-file-upload__icon-mark\">+</span>\n </slot>\n </div>\n\n <div class=\"t-file-upload__content\">\n <slot\n :files=\"modelValue\"\n :is-drag-active=\"isDragActive\"\n :is-drag-reject=\"isDragReject\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n >\n <p class=\"t-file-upload__label\">\n {{ dropzoneLabel }}\n </p>\n <p\n v-if=\"description\"\n :id=\"descriptionId\"\n class=\"t-file-upload__description\"\n >\n {{ description }}\n </p>\n <span class=\"t-file-upload__browse\">\n {{ buttonLabel }}\n </span>\n </slot>\n </div>\n\n <TSpinner\n v-if=\"loading\"\n class=\"t-file-upload__spinner\"\n size=\"sm\"\n label=\"Uploading files\"\n />\n </div>\n\n <ul\n v-if=\"feedbackMessages.length > 0\"\n :id=\"feedbackId\"\n class=\"t-file-upload__feedback\"\n aria-live=\"polite\"\n >\n <li\n v-for=\"message in feedbackMessages\"\n :key=\"message\"\n >\n {{ message }}\n </li>\n </ul>\n\n <div\n class=\"t-file-upload__status-log t-visually-hidden\"\n role=\"log\"\n aria-live=\"polite\"\n >\n <p\n v-for=\"entry in statusLog\"\n :key=\"entry.id\"\n class=\"t-file-upload__status-log-entry\"\n >\n {{ entry.text }}\n </p>\n </div>\n\n <div\n v-if=\"showFileList && hasFiles\"\n class=\"t-file-upload__files\"\n >\n <div class=\"t-file-upload__files-header\">\n <p\n :id=\"filesId\"\n class=\"t-file-upload__files-label\"\n >\n {{ selectedFilesLabel }}\n </p>\n\n <button\n class=\"t-file-upload__clear\"\n type=\"button\"\n :disabled=\"isDisabled\"\n @click=\"clearFiles\"\n >\n Clear all\n </button>\n </div>\n\n <ul class=\"t-file-upload__list\">\n <li\n v-for=\"row in rows\"\n :key=\"row.slotProps.fileKey\"\n class=\"t-file-upload__file\"\n :class=\"row.rowClass\"\n :aria-busy=\"row.isBusy || undefined\"\n >\n <slot\n name=\"file\"\n v-bind=\"row.slotProps\"\n >\n <span\n class=\"t-file-upload__thumb\"\n aria-hidden=\"true\"\n >\n <img\n v-if=\"row.slotProps.thumbnailUrl\"\n class=\"t-file-upload__thumb-image\"\n :src=\"row.slotProps.thumbnailUrl\"\n alt=\"\"\n >\n <span\n v-else\n class=\"t-file-upload__thumb-fallback\"\n >\n {{ row.slotProps.typeLabel }}\n </span>\n </span>\n\n <div class=\"t-file-upload__file-copy\">\n <p class=\"t-file-upload__file-name\">\n {{ row.slotProps.file.name }}\n </p>\n <p class=\"t-file-upload__file-meta\">\n {{ row.metaText }}\n </p>\n\n <slot\n name=\"file-status\"\n v-bind=\"row.slotProps\"\n >\n <div\n v-if=\"row.showProgress\"\n class=\"t-file-upload__file-progress\"\n >\n <TProgress\n size=\"sm\"\n :value=\"row.progressValue\"\n :label=\"row.progressAriaLabel\"\n />\n </div>\n\n <p\n v-if=\"row.statusText\"\n class=\"t-file-upload__file-status\"\n >\n <span\n v-if=\"row.isSuccess\"\n class=\"t-file-upload__proof\"\n aria-hidden=\"true\"\n >✓</span>{{ row.statusText }}\n </p>\n\n <p\n v-if=\"row.errorText\"\n :id=\"row.errorId\"\n class=\"t-file-upload__file-error\"\n >\n {{ row.errorText }}\n </p>\n </slot>\n </div>\n\n <button\n v-if=\"row.slotProps.canRetry\"\n class=\"t-file-upload__retry\"\n type=\"button\"\n :data-t-retry=\"row.slotProps.fileKey\"\n :disabled=\"isDisabled\"\n :aria-describedby=\"row.errorId\"\n @focus=\"onRetryFocus(row.slotProps.fileKey)\"\n @blur=\"onRetryBlur(row.slotProps.fileKey)\"\n @click=\"row.slotProps.retryFile()\"\n >\n {{ row.slotProps.actionLabel }}\n </button>\n\n <button\n class=\"t-file-upload__remove\"\n type=\"button\"\n :data-t-remove=\"row.slotProps.fileKey\"\n :disabled=\"isDisabled\"\n :aria-label=\"`Remove ${row.slotProps.file.name}`\"\n @click=\"row.slotProps.removeFile()\"\n >\n Remove\n </button>\n </slot>\n </li>\n </ul>\n </div>\n\n <div\n v-if=\"$slots.actions\"\n class=\"t-file-upload__actions\"\n >\n <slot\n name=\"actions\"\n :files=\"modelValue\"\n :open-file-dialog=\"openFileDialog\"\n :clear-files=\"clearFiles\"\n />\n </div>\n </div>\n</template>\n"],"mappings":""}
|
|
@@ -5,6 +5,55 @@ export interface TFileUploadRejection {
|
|
|
5
5
|
reason: TFileUploadRejectionReason;
|
|
6
6
|
message: string;
|
|
7
7
|
}
|
|
8
|
+
export type TFileUploadStatus = 'pending' | 'uploading' | 'paused' | 'error' | 'success';
|
|
9
|
+
export interface TFileUploadState {
|
|
10
|
+
status: TFileUploadStatus;
|
|
11
|
+
progress?: number;
|
|
12
|
+
uploadedBytes?: number;
|
|
13
|
+
remainingMs?: number;
|
|
14
|
+
remainingLabel?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
resumable?: boolean;
|
|
17
|
+
retryable?: boolean;
|
|
18
|
+
thumbnailUrl?: string;
|
|
19
|
+
meta?: string;
|
|
20
|
+
}
|
|
21
|
+
export type TFileUploadStateSource = ReadonlyMap<File, TFileUploadState> | ((file: File, index: number) => TFileUploadState | undefined);
|
|
22
|
+
export interface TFileUploadRetryPayload {
|
|
23
|
+
file: File;
|
|
24
|
+
index: number;
|
|
25
|
+
fileKey: string;
|
|
26
|
+
mode: 'resume' | 'restart';
|
|
27
|
+
state: TFileUploadState;
|
|
28
|
+
}
|
|
29
|
+
export interface TFileUploadCancelPayload {
|
|
30
|
+
file: File;
|
|
31
|
+
index: number;
|
|
32
|
+
fileKey: string;
|
|
33
|
+
state: TFileUploadState;
|
|
34
|
+
reason: 'remove' | 'clear';
|
|
35
|
+
}
|
|
36
|
+
export interface TFileUploadFileSlotProps {
|
|
37
|
+
file: File;
|
|
38
|
+
index: number;
|
|
39
|
+
fileKey: string;
|
|
40
|
+
state: TFileUploadState | undefined;
|
|
41
|
+
thumbnailUrl: string | undefined;
|
|
42
|
+
sizeLabel: string;
|
|
43
|
+
typeLabel: string;
|
|
44
|
+
dimensionsLabel: string | undefined;
|
|
45
|
+
progressLabel: string | undefined;
|
|
46
|
+
remainingLabel: string | undefined;
|
|
47
|
+
uploadedLabel: string | undefined;
|
|
48
|
+
statusLabel: string | undefined;
|
|
49
|
+
actionLabel: string | undefined;
|
|
50
|
+
canRetry: boolean;
|
|
51
|
+
retryMode: 'resume' | 'restart';
|
|
52
|
+
isDragActive: boolean;
|
|
53
|
+
isDragReject: boolean;
|
|
54
|
+
removeFile: () => void;
|
|
55
|
+
retryFile: () => void;
|
|
56
|
+
}
|
|
8
57
|
type __VLS_Props = {
|
|
9
58
|
modelValue?: File[];
|
|
10
59
|
size?: TSize;
|
|
@@ -22,6 +71,15 @@ type __VLS_Props = {
|
|
|
22
71
|
paste?: boolean;
|
|
23
72
|
drop?: boolean;
|
|
24
73
|
showFileList?: boolean;
|
|
74
|
+
uploadState?: TFileUploadStateSource;
|
|
75
|
+
dragLabel?: string;
|
|
76
|
+
dragRejectLabel?: string;
|
|
77
|
+
thumbnails?: boolean;
|
|
78
|
+
maxThumbnailSize?: number | null;
|
|
79
|
+
retryLabel?: string;
|
|
80
|
+
resumeLabel?: (percent: number) => string;
|
|
81
|
+
remainingTimeFormat?: (remainingMs: number) => string;
|
|
82
|
+
statusLabels?: Partial<Record<TFileUploadStatus, string>>;
|
|
25
83
|
};
|
|
26
84
|
declare function __VLS_template(): {
|
|
27
85
|
attrs: Partial<{}>;
|
|
@@ -30,14 +88,12 @@ declare function __VLS_template(): {
|
|
|
30
88
|
default?: (props: {
|
|
31
89
|
files: File[];
|
|
32
90
|
isDragActive: boolean;
|
|
91
|
+
isDragReject: boolean;
|
|
33
92
|
openFileDialog: () => void;
|
|
34
93
|
clearFiles: () => void;
|
|
35
94
|
}) => unknown;
|
|
36
|
-
file?: (props:
|
|
37
|
-
|
|
38
|
-
index: number;
|
|
39
|
-
removeFile: () => void;
|
|
40
|
-
}) => unknown;
|
|
95
|
+
file?: (props: TFileUploadFileSlotProps) => unknown;
|
|
96
|
+
'file-status'?: (props: TFileUploadFileSlotProps) => unknown;
|
|
41
97
|
actions?: (props: {
|
|
42
98
|
files: File[];
|
|
43
99
|
openFileDialog: () => void;
|
|
@@ -48,14 +104,12 @@ declare function __VLS_template(): {
|
|
|
48
104
|
default?: (props: {
|
|
49
105
|
files: File[];
|
|
50
106
|
isDragActive: boolean;
|
|
107
|
+
isDragReject: boolean;
|
|
51
108
|
openFileDialog: () => void;
|
|
52
109
|
clearFiles: () => void;
|
|
53
110
|
}) => unknown;
|
|
54
|
-
file?: (props:
|
|
55
|
-
|
|
56
|
-
index: number;
|
|
57
|
-
removeFile: () => void;
|
|
58
|
-
}) => unknown;
|
|
111
|
+
file?: (props: TFileUploadFileSlotProps) => unknown;
|
|
112
|
+
'file-status'?: (props: TFileUploadFileSlotProps) => unknown;
|
|
59
113
|
actions?: (props: {
|
|
60
114
|
files: File[];
|
|
61
115
|
openFileDialog: () => void;
|
|
@@ -71,12 +125,16 @@ declare function __VLS_template(): {
|
|
|
71
125
|
type __VLS_TemplateResult = ReturnType<typeof __VLS_template>;
|
|
72
126
|
declare const __VLS_component: import('vue').DefineComponent<__VLS_Props, {}, {}, {}, {}, import('vue').ComponentOptionsMixin, import('vue').ComponentOptionsMixin, {
|
|
73
127
|
"update:modelValue": (files: File[]) => any;
|
|
128
|
+
cancel: (payload: TFileUploadCancelPayload) => any;
|
|
74
129
|
"files-accepted": (files: File[]) => any;
|
|
75
130
|
"files-rejected": (rejections: TFileUploadRejection[]) => any;
|
|
131
|
+
retry: (payload: TFileUploadRetryPayload) => any;
|
|
76
132
|
}, string, import('vue').PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
77
133
|
"onUpdate:modelValue"?: ((files: File[]) => any) | undefined;
|
|
134
|
+
onCancel?: ((payload: TFileUploadCancelPayload) => any) | undefined;
|
|
78
135
|
"onFiles-accepted"?: ((files: File[]) => any) | undefined;
|
|
79
136
|
"onFiles-rejected"?: ((rejections: TFileUploadRejection[]) => any) | undefined;
|
|
137
|
+
onRetry?: ((payload: TFileUploadRetryPayload) => any) | undefined;
|
|
80
138
|
}>, {
|
|
81
139
|
multiple: boolean;
|
|
82
140
|
modelValue: File[];
|
|
@@ -94,6 +152,15 @@ declare const __VLS_component: import('vue').DefineComponent<__VLS_Props, {}, {}
|
|
|
94
152
|
buttonLabel: string;
|
|
95
153
|
filesLabel: string;
|
|
96
154
|
showFileList: boolean;
|
|
155
|
+
uploadState: TFileUploadStateSource;
|
|
156
|
+
dragLabel: string;
|
|
157
|
+
dragRejectLabel: string;
|
|
158
|
+
thumbnails: boolean;
|
|
159
|
+
maxThumbnailSize: number | null;
|
|
160
|
+
retryLabel: string;
|
|
161
|
+
resumeLabel: (percent: number) => string;
|
|
162
|
+
remainingTimeFormat: (remainingMs: number) => string;
|
|
163
|
+
statusLabels: Partial<Record<TFileUploadStatus, string>>;
|
|
97
164
|
}, {}, {}, {}, string, import('vue').ComponentProvideOptions, false, {
|
|
98
165
|
rootRef: HTMLDivElement;
|
|
99
166
|
inputRef: HTMLInputElement;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TFileUpload.vue.d.ts","sourceRoot":"","sources":["../../src/components/TFileUpload.vue"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"TFileUpload.vue.d.ts","sourceRoot":"","sources":["../../src/components/TFileUpload.vue"],"names":[],"mappings":"AA6sCA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAQhD,MAAM,MAAM,0BAA0B,GAClC,mBAAmB,GACnB,gBAAgB,GAChB,gBAAgB,CAAC;AAErB,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,IAAI,CAAC;IACX,MAAM,EAAE,0BAA0B,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,iBAAiB,GACzB,SAAS,GACT,WAAW,GACX,QAAQ,GACR,OAAO,GACP,SAAS,CAAC;AAEd,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,sBAAsB,GAC9B,WAAW,CAAC,IAAI,EAAE,gBAAgB,CAAC,GACnC,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,KAAK,gBAAgB,GAAG,SAAS,CAAC,CAAC;AAElE,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,QAAQ,GAAG,SAAS,CAAC;IAC3B,KAAK,EAAE,gBAAgB,CAAC;CACzB;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,gBAAgB,CAAC;IACxB,MAAM,EAAE,QAAQ,GAAG,OAAO,CAAC;CAC5B;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,gBAAgB,GAAG,SAAS,CAAC;IACpC,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB,SAAS,EAAE,QAAQ,GAAG,SAAS,CAAC;IAChC,YAAY,EAAE,OAAO,CAAC;IACtB,YAAY,EAAE,OAAO,CAAC;IACtB,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AA2DD,KAAK,WAAW,GAAG;IACf,UAAU,CAAC,EAAE,IAAI,EAAE,CAAC;IACpB,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,sBAAsB,CAAC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1C,mBAAmB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM,CAAC;IACtD,YAAY,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC,CAAC;CAC3D,CAAC;AA61BJ,iBAAS,cAAc;WAwRT,OAAO,IAA6B;;eAhqCzC,MAAM,OAAO;kBACV,CAAC,KAAK,EAAE;YAChB,KAAK,EAAE,IAAI,EAAE,CAAC;YACd,YAAY,EAAE,OAAO,CAAC;YACtB,YAAY,EAAE,OAAO,CAAC;YACtB,cAAc,EAAE,MAAM,IAAI,CAAC;YAC3B,UAAU,EAAE,MAAM,IAAI,CAAC;SACxB,KAAK,OAAO;eACN,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO;wBACnC,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO;kBAClD,CAAC,KAAK,EAAE;YAChB,KAAK,EAAE,IAAI,EAAE,CAAC;YACd,cAAc,EAAE,MAAM,IAAI,CAAC;YAC3B,UAAU,EAAE,MAAM,IAAI,CAAC;SACxB,KAAK,OAAO;;eAdN,MAAM,OAAO;kBACV,CAAC,KAAK,EAAE;YAChB,KAAK,EAAE,IAAI,EAAE,CAAC;YACd,YAAY,EAAE,OAAO,CAAC;YACtB,YAAY,EAAE,OAAO,CAAC;YACtB,cAAc,EAAE,MAAM,IAAI,CAAC;YAC3B,UAAU,EAAE,MAAM,IAAI,CAAC;SACxB,KAAK,OAAO;eACN,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO;wBACnC,CAAC,KAAK,EAAE,wBAAwB,KAAK,OAAO;kBAClD,CAAC,KAAK,EAAE;YAChB,KAAK,EAAE,IAAI,EAAE,CAAC;YACd,cAAc,EAAE,MAAM,IAAI,CAAC;YAC3B,UAAU,EAAE,MAAM,IAAI,CAAC;SACxB,KAAK,OAAO;;;;;;;EAupCd;AA4CD,KAAK,oBAAoB,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AAC9D,QAAA,MAAM,eAAe;;;;;;;;;;;;;cA1rCN,OAAO;gBANL,IAAI,EAAE;cAER,OAAO;UADX,KAAK;WAQJ,MAAM;aANJ,OAAO;aACP,OAAO;UAUV,OAAO;WADN,OAAO;iBAHD,MAAM;YALX,MAAM;cAEJ,MAAM,GAAG,IAAI;iBACV,MAAM,GAAG,IAAI;iBAGb,MAAM;gBACP,MAAM;kBAGJ,OAAO;iBACR,sBAAsB;eACxB,MAAM;qBACA,MAAM;gBACX,OAAO;sBACD,MAAM,GAAG,IAAI;gBACnB,MAAM;iBACL,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM;yBACnB,CAAC,WAAW,EAAE,MAAM,KAAK,MAAM;kBACtC,OAAO,CAAC,MAAM,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC;;;;OAirC3D,CAAC;wBACkB,uBAAuB,CAAC,OAAO,eAAe,EAAE,oBAAoB,CAAC,OAAO,CAAC,CAAC;AAAnG,wBAAoG;AAapG,KAAK,uBAAuB,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,GAAG;IACxC,QAAO;QACN,MAAM,EAAE,CAAC,CAAC;KAEV,CAAA;CACD,CAAC"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("./TSpinner.cjs");let t=require("vue"),n=require("@treeui/utils");var r=[`id`,`accept`,`multiple`,`disabled`],i=[`aria-busy`,`aria-controls`,`aria-describedby`,`aria-disabled`,`aria-invalid`,`tabindex`],a={key:0,class:`t-file-upload__icon`,"aria-hidden":`true`},ee={class:`t-file-upload__content`},te={class:`t-file-upload__label`},o={class:`t-file-upload__browse`},s={key:1,class:`t-file-upload__files`},c={class:`t-file-upload__files-header`},l=[`disabled`],u={class:`t-file-upload__list`},d={class:`t-file-upload__file-copy`},f={class:`t-file-upload__file-name`},p={class:`t-file-upload__file-meta`},m=[`disabled`,`aria-label`,`onClick`],h={key:2,class:`t-file-upload__actions`},g=(0,t.defineComponent)({inheritAttrs:!1,__name:`TFileUpload`,props:{modelValue:{default:()=>[]},size:{default:`md`},disabled:{type:Boolean,default:!1},loading:{type:Boolean,default:!1},invalid:{type:Boolean,default:!1},accept:{default:``},multiple:{type:Boolean,default:!0},maxFiles:{default:null},maxFileSize:{default:null},label:{default:`Drop files here`},description:{default:`Click to browse, drag and drop, or paste with Ctrl+V.`},buttonLabel:{default:`Browse files`},filesLabel:{default:`Selected files`},paste:{type:Boolean,default:!0},drop:{type:Boolean,default:!0},showFileList:{type:Boolean,default:!0}},emits:[`update:modelValue`,`files-accepted`,`files-rejected`],setup(g,{emit:_}){let ne=(0,t.useSlots)(),v=g,y=_,b=(0,t.useAttrs)(),x=(0,t.ref)(null),S=(0,t.ref)(null),C=(0,t.ref)(!1),w=(0,t.ref)(!1),T=(0,t.ref)(0),E=(0,t.ref)([]),D=(0,n.createId)(`t-file-upload`),O=`${D}-description`,k=`${D}-feedback`,A=`${D}-files`,j=(0,t.computed)(()=>v.disabled||v.loading),M=(0,t.computed)(()=>v.modelValue.length>0),N=(0,t.computed)(()=>!!ne.default),P=(0,t.computed)(()=>v.multiple?typeof v.maxFiles==`number`?Math.max(v.maxFiles,0):1/0:1),F=(0,t.computed)(()=>[`t-file-upload`,`t-file-upload--${v.size}`,{"is-disabled":j.value,"is-invalid":v.invalid,"is-loading":v.loading,"is-drag-active":C.value,"has-files":M.value},b.class]),I=(0,t.computed)(()=>b.style),L=(0,t.computed)(()=>{let{class:e,style:t,...n}=b;return n}),R=(0,t.computed)(()=>{let e=[];return v.description&&!N.value&&e.push(O),E.value.length>0&&e.push(k),v.showFileList&&M.value&&e.push(A),e.length>0?e.join(` `):void 0}),z=(0,t.computed)(()=>`${v.filesLabel} (${v.modelValue.length})`),B=e=>{if(e<1024)return`${e} B`;let t=[`KB`,`MB`,`GB`],n=e/1024,r=0;for(;n>=1024&&r<t.length-1;)n/=1024,r+=1;return`${n>=10||Number.isInteger(n)?Math.round(n):Number(n.toFixed(1))} ${t[r]}`},V=(e,t)=>`${e.name}-${e.size}-${e.lastModified}-${t}`,H=e=>{if(!v.accept.trim())return!0;let t=v.accept.split(`,`).map(e=>e.trim().toLowerCase()).filter(Boolean),n=e.name.toLowerCase(),r=e.type.toLowerCase();return t.some(e=>e.startsWith(`.`)?n.endsWith(e):e.endsWith(`/*`)?r.startsWith(e.slice(0,-1)):r===e)},U=(e,t)=>{switch(t){case`file-invalid-type`:return`${e.name} is not an accepted file type.`;case`file-too-large`:return`${e.name} exceeds the ${B(v.maxFileSize??0)} limit.`;case`too-many-files`:return`You can only add up to ${P.value} file${P.value===1?``:`s`}.`;default:return`${e.name} could not be added.`}},W=()=>{S.value&&(S.value.value=``)},G=()=>{j.value||S.value?.click()},K=(e,t,n)=>{E.value=n.map(e=>e.message),n.length>0&&y(`files-rejected`,n),t.length>0&&(y(`update:modelValue`,e),y(`files-accepted`,t))},q=e=>{if(j.value||e.length===0)return;let t=v.multiple?[...v.modelValue]:[],n=[],r=[],i=Number.isFinite(P.value)?Math.max(P.value-t.length,0):1/0;for(let a of e){if(i<=0){r.push({file:a,reason:`too-many-files`,message:U(a,`too-many-files`)});continue}if(!H(a)){r.push({file:a,reason:`file-invalid-type`,message:U(a,`file-invalid-type`)});continue}if(typeof v.maxFileSize==`number`&&v.maxFileSize>0&&a.size>v.maxFileSize){r.push({file:a,reason:`file-too-large`,message:U(a,`file-too-large`)});continue}n.push(a),t.push(a),Number.isFinite(i)&&--i}K(t,n,r)},J=e=>{j.value||(E.value=[],y(`update:modelValue`,v.modelValue.filter((t,n)=>n!==e)))},Y=()=>{j.value||!M.value||(E.value=[],y(`update:modelValue`,[]))},X=e=>e?e.files.length>0||Array.from(e.types).includes(`Files`):!1,re=e=>e?.items?Array.from(e.items).map(e=>e.getAsFile?.()??null).filter(e=>e instanceof File):[],ie=e=>{let t=e.target;q(Array.from(t.files??[])),W()},ae=e=>{!(0,n.isActivationKey)(e)||j.value||(e.preventDefault(),G())},oe=()=>{w.value=!0},se=e=>{let t=e.relatedTarget;t&&x.value?.contains(t)||(w.value=!1)},Z=e=>{if(!v.paste||j.value)return;let t=re(e.clipboardData);t.length!==0&&(e.preventDefault(),q(t))},Q=e=>{!w.value||e.defaultPrevented||Z(e)},ce=e=>{!v.drop||j.value||!X(e.dataTransfer)||(e.preventDefault(),T.value+=1,C.value=!0)},le=e=>{!v.drop||j.value||!X(e.dataTransfer)||(e.preventDefault(),e.dataTransfer&&(e.dataTransfer.dropEffect=`copy`),C.value=!0)},$=e=>{!v.drop||j.value||!X(e.dataTransfer)||(e.preventDefault(),T.value=Math.max(T.value-1,0),T.value===0&&(C.value=!1))},ue=e=>{!v.drop||j.value||!X(e.dataTransfer)||(e.preventDefault(),T.value=0,C.value=!1,q(Array.from(e.dataTransfer?.files??[])))};return(0,t.onMounted)(()=>{document.addEventListener(`paste`,Q)}),(0,t.onBeforeUnmount)(()=>{document.removeEventListener(`paste`,Q)}),(n,_)=>((0,t.openBlock)(),(0,t.createElementBlock)(`div`,(0,t.mergeProps)({ref_key:`rootRef`,ref:x},L.value,{class:F.value,style:I.value,onFocusin:oe,onFocusout:se,onPaste:Z}),[(0,t.createElementVNode)(`input`,{id:(0,t.unref)(D),ref_key:`inputRef`,ref:S,class:`t-visually-hidden`,type:`file`,accept:g.accept||void 0,multiple:g.multiple,disabled:j.value,onChange:ie},null,40,r),(0,t.createElementVNode)(`div`,{class:`t-file-upload__dropzone`,"aria-busy":g.loading||void 0,"aria-controls":g.showFileList&&M.value?A:void 0,"aria-describedby":R.value,"aria-disabled":j.value||void 0,"aria-invalid":g.invalid||void 0,role:`button`,tabindex:j.value?-1:0,onClick:G,onKeydown:ae,onDragenter:ce,onDragover:le,onDragleave:$,onDrop:ue},[n.$slots.icon||!n.$slots.default?((0,t.openBlock)(),(0,t.createElementBlock)(`div`,a,[(0,t.renderSlot)(n.$slots,`icon`,{},()=>[_[0]||=(0,t.createElementVNode)(`span`,{class:`t-file-upload__icon-mark`},`+`,-1)])])):(0,t.createCommentVNode)(``,!0),(0,t.createElementVNode)(`div`,ee,[(0,t.renderSlot)(n.$slots,`default`,{files:g.modelValue,isDragActive:C.value,openFileDialog:G,clearFiles:Y},()=>[(0,t.createElementVNode)(`p`,te,(0,t.toDisplayString)(g.label),1),g.description?((0,t.openBlock)(),(0,t.createElementBlock)(`p`,{key:0,id:O,class:`t-file-upload__description`},(0,t.toDisplayString)(g.description),1)):(0,t.createCommentVNode)(``,!0),(0,t.createElementVNode)(`span`,o,(0,t.toDisplayString)(g.buttonLabel),1)])]),g.loading?((0,t.openBlock)(),(0,t.createBlock)(e.default,{key:1,class:`t-file-upload__spinner`,size:`sm`,label:`Uploading files`})):(0,t.createCommentVNode)(``,!0)],40,i),E.value.length>0?((0,t.openBlock)(),(0,t.createElementBlock)(`ul`,{key:0,id:k,class:`t-file-upload__feedback`,"aria-live":`polite`},[((0,t.openBlock)(!0),(0,t.createElementBlock)(t.Fragment,null,(0,t.renderList)(E.value,e=>((0,t.openBlock)(),(0,t.createElementBlock)(`li`,{key:e},(0,t.toDisplayString)(e),1))),128))])):(0,t.createCommentVNode)(``,!0),g.showFileList&&M.value?((0,t.openBlock)(),(0,t.createElementBlock)(`div`,s,[(0,t.createElementVNode)(`div`,c,[(0,t.createElementVNode)(`p`,{id:A,class:`t-file-upload__files-label`},(0,t.toDisplayString)(z.value),1),(0,t.createElementVNode)(`button`,{class:`t-file-upload__clear`,type:`button`,disabled:j.value,onClick:Y},` Clear all `,8,l)]),(0,t.createElementVNode)(`ul`,u,[((0,t.openBlock)(!0),(0,t.createElementBlock)(t.Fragment,null,(0,t.renderList)(g.modelValue,(e,r)=>((0,t.openBlock)(),(0,t.createElementBlock)(`li`,{key:V(e,r),class:`t-file-upload__file`},[(0,t.renderSlot)(n.$slots,`file`,{file:e,index:r,removeFile:()=>J(r)},()=>[(0,t.createElementVNode)(`div`,d,[(0,t.createElementVNode)(`p`,f,(0,t.toDisplayString)(e.name),1),(0,t.createElementVNode)(`p`,p,(0,t.toDisplayString)(B(e.size)),1)]),(0,t.createElementVNode)(`button`,{class:`t-file-upload__remove`,type:`button`,disabled:j.value,"aria-label":`Remove ${e.name}`,onClick:e=>J(r)},` Remove `,8,m)])]))),128))])])):(0,t.createCommentVNode)(``,!0),n.$slots.actions?((0,t.openBlock)(),(0,t.createElementBlock)(`div`,h,[(0,t.renderSlot)(n.$slots,`actions`,{files:g.modelValue,openFileDialog:G,clearFiles:Y})])):(0,t.createCommentVNode)(``,!0)],16))}});exports.default=g;
|
|
1
|
+
const e=require("./TSpinner.cjs"),t=require("./TProgress.cjs");let n=require("vue"),r=require("@treeui/utils");var i=[`id`,`accept`,`multiple`,`disabled`],a=[`aria-busy`,`aria-controls`,`aria-describedby`,`aria-disabled`,`aria-invalid`,`tabindex`],o={key:0,class:`t-file-upload__icon`,"aria-hidden":`true`},s={class:`t-file-upload__content`},c={class:`t-file-upload__label`},l={class:`t-file-upload__browse`},u={class:`t-file-upload__status-log t-visually-hidden`,role:`log`,"aria-live":`polite`},d={key:1,class:`t-file-upload__files`},f={class:`t-file-upload__files-header`},p=[`disabled`],m={class:`t-file-upload__list`},h=[`aria-busy`],g={class:`t-file-upload__thumb`,"aria-hidden":`true`},_=[`src`],v={key:1,class:`t-file-upload__thumb-fallback`},y={class:`t-file-upload__file-copy`},ee={class:`t-file-upload__file-name`},te={class:`t-file-upload__file-meta`},ne={key:0,class:`t-file-upload__file-progress`},re={key:1,class:`t-file-upload__file-status`},ie={key:0,class:`t-file-upload__proof`,"aria-hidden":`true`},ae=[`id`],oe=[`data-t-retry`,`disabled`,`aria-describedby`,`onFocus`,`onBlur`,`onClick`],se=[`data-t-remove`,`disabled`,`aria-label`,`onClick`],ce={key:2,class:`t-file-upload__actions`},b=(0,n.defineComponent)({inheritAttrs:!1,__name:`TFileUpload`,props:{modelValue:{default:()=>[]},size:{default:`md`},disabled:{type:Boolean,default:!1},loading:{type:Boolean,default:!1},invalid:{type:Boolean,default:!1},accept:{default:``},multiple:{type:Boolean,default:!0},maxFiles:{default:null},maxFileSize:{default:null},label:{default:`Drop files here`},description:{default:`Click to browse, drag and drop, or paste with Ctrl+V.`},buttonLabel:{default:`Browse files`},filesLabel:{default:`Selected files`},paste:{type:Boolean,default:!0},drop:{type:Boolean,default:!0},showFileList:{type:Boolean,default:!0},uploadState:{type:[Map,Function],default:void 0},dragLabel:{default:`Release to upload`},dragRejectLabel:{default:`This file type is not accepted`},thumbnails:{type:Boolean,default:!0},maxThumbnailSize:{default:10*1024*1024},retryLabel:{default:`Retry`},resumeLabel:{type:Function,default:e=>`Resume from ${e}%`},remainingTimeFormat:{type:Function,default:e=>{if(e<5e3)return`Less than 5s left`;let t=Math.round(e/1e3);if(t<60)return`About ${t}s left`;let n=Math.round(t/60);return n<60?`About ${n} min left`:`About ${Math.round(n/60)}h left`}},statusLabels:{default:()=>({})}},emits:[`update:modelValue`,`files-accepted`,`files-rejected`,`retry`,`cancel`],setup(b,{emit:x}){let le={pending:`Waiting`,uploading:`Uploading`,paused:`Paused`,error:`Upload failed`,success:`Uploaded`},ue=(0,n.useSlots)(),S=b,C=x,w=(0,n.useAttrs)(),T=(0,n.ref)(null),E=(0,n.ref)(null),D=(0,n.ref)(!1),O=(0,n.ref)(!1),k=(0,n.ref)(!1),A=(0,n.ref)(0),j=(0,n.ref)([]),M=(0,n.ref)([]),N=0,P=(0,n.ref)(null),F=(0,n.ref)(new Map),I=(0,r.createId)(`t-file-upload`),L=`${I}-description`,R=`${I}-feedback`,z=`${I}-files`,B=(0,n.computed)(()=>S.disabled),V=(0,n.computed)(()=>S.modelValue.length>0),de=(0,n.computed)(()=>!!ue.default),fe=(0,n.computed)(()=>({...le,...S.statusLabels})),H=(0,n.computed)(()=>S.multiple?typeof S.maxFiles==`number`?Math.max(S.maxFiles,0):1/0:1),pe=(0,n.computed)(()=>[`t-file-upload`,`t-file-upload--${S.size}`,{"is-disabled":B.value,"is-invalid":S.invalid,"is-loading":S.loading,"is-drag-active":D.value,"is-drag-reject":O.value,"has-files":V.value},w.class]),me=(0,n.computed)(()=>w.style),he=(0,n.computed)(()=>{let{class:e,style:t,...n}=w;return n}),ge=(0,n.computed)(()=>{let e=[];return S.description&&!de.value&&e.push(L),j.value.length>0&&e.push(R),S.showFileList&&V.value&&e.push(z),e.length>0?e.join(` `):void 0}),_e=(0,n.computed)(()=>`${S.filesLabel} (${S.modelValue.length})`),ve=(0,n.computed)(()=>O.value?S.dragRejectLabel:D.value?S.dragLabel:S.label),U=e=>{if(e<1024)return`${e} B`;let t=[`KB`,`MB`,`GB`],n=e/1024,r=0;for(;n>=1024&&r<t.length-1;)n/=1024,r+=1;return`${n>=10||Number.isInteger(n)?Math.round(n):Number(n.toFixed(1))} ${t[r]}`},ye=e=>{let t=e.name.lastIndexOf(`.`),n=t>0&&t<e.name.length-1?e.name.slice(t+1).toUpperCase():``;if(n&&n.length<=5)return n;let r=e.type.includes(`/`)?e.type.split(`/`)[1]:``;return r?r.toUpperCase():`FILE`},W=0,be=new WeakMap,xe=e=>{let t=be.get(e);return t||(W+=1,t=`${I}-f${W}`,be.set(e,t)),t},G=(0,n.computed)(()=>S.accept.split(`,`).map(e=>e.trim().toLowerCase()).filter(Boolean)),Se=e=>{if(G.value.length===0)return!0;let t=e.name.toLowerCase(),n=e.type.toLowerCase();return G.value.some(e=>e.startsWith(`.`)?t.endsWith(e):e.endsWith(`/*`)?n.startsWith(e.slice(0,-1)):n===e)},Ce=(0,n.computed)(()=>G.value.length>0&&G.value.every(e=>e.includes(`/`))),we=e=>{let t=e.toLowerCase();return!t||G.value.some(e=>e.endsWith(`/*`)?t.startsWith(e.slice(0,-1)):t===e)},Te=e=>{if(!Ce.value||!e)return!1;let t=e.items;if(!t||t.length===0)return!1;let n=Array.from(t).filter(e=>e.kind===`file`);return n.length!==0&&n.every(e=>!we(e.type))},Ee=(e,t)=>{let n=S.uploadState;if(n)return typeof n==`function`?n(e,t):n.get(e)},K=(0,n.computed)(()=>S.modelValue.map(Ee)),De=()=>typeof window<`u`&&typeof URL<`u`&&typeof URL.createObjectURL==`function`&&typeof URL.revokeObjectURL==`function`,Oe=e=>e.type.startsWith(`image/`)&&(S.maxThumbnailSize===null||e.size<=S.maxThumbnailSize),ke=()=>{if(De())for(let e of F.value.values())URL.revokeObjectURL(e.url);F.value.clear()},q=()=>{if(!S.thumbnails||!De()){ke();return}let e=new Set(S.modelValue);for(let[t,n]of[...F.value])e.has(t)||(URL.revokeObjectURL(n.url),F.value.delete(t));for(let e of S.modelValue){if(F.value.has(e)||!Oe(e))continue;let t=URL.createObjectURL(e);F.value.set(e,{url:t,width:null,height:null});let n=new Image;n.onload=()=>{let r=F.value.get(e);!r||r.url!==t||F.value.set(e,{url:t,width:n.naturalWidth,height:n.naturalHeight})},n.src=t}},J=(e,t)=>{switch(t){case`file-invalid-type`:return`${e.name} is not an accepted file type.`;case`file-too-large`:return`${e.name} exceeds the ${U(S.maxFileSize??0)} limit.`;case`too-many-files`:return`You can only add up to ${H.value} file${H.value===1?``:`s`}.`;default:return`${e.name} could not be added.`}},Ae=()=>{E.value&&(E.value.value=``)},Y=()=>{B.value||E.value?.click()},je=(e,t,n)=>{j.value=n.map(e=>e.message),n.length>0&&C(`files-rejected`,n),t.length>0&&(C(`update:modelValue`,e),C(`files-accepted`,t))},X=e=>{if(B.value||e.length===0)return;let t=S.multiple?[...S.modelValue]:[],n=[],r=[],i=Number.isFinite(H.value)?Math.max(H.value-t.length,0):1/0;for(let a of e){if(i<=0){r.push({file:a,reason:`too-many-files`,message:J(a,`too-many-files`)});continue}if(!Se(a)){r.push({file:a,reason:`file-invalid-type`,message:J(a,`file-invalid-type`)});continue}if(typeof S.maxFileSize==`number`&&S.maxFileSize>0&&a.size>S.maxFileSize){r.push({file:a,reason:`file-too-large`,message:J(a,`file-too-large`)});continue}n.push(a),t.push(a),Number.isFinite(i)&&--i}je(t,n,r)},Me=(e,t,n)=>{let r=K.value[t];!r||r.status!==`uploading`&&r.status!==`paused`||C(`cancel`,{file:e,index:t,fileKey:xe(e),state:r,reason:n})},Ne=e=>{if(B.value)return;let t=S.modelValue[e];t&&Me(t,e,`remove`),j.value=[],C(`update:modelValue`,S.modelValue.filter((t,n)=>n!==e))},Z=()=>{B.value||!V.value||(S.modelValue.forEach((e,t)=>{Me(e,t,`clear`)}),j.value=[],C(`update:modelValue`,[]))},Pe=(0,n.computed)(()=>S.modelValue.map((e,t)=>{let n=K.value[t],r=n?.status,i=F.value.get(e),a=xe(e),o=n?.progress,s=typeof o==`number`?Math.round(o):void 0,c=n?.resumable===!0,l=!!(n&&(r===`error`||r===`paused`)&&n.retryable!==!1),u=c?`resume`:`restart`,d=U(e.size),f=ye(e),p=i&&i.width!==null&&i.height!==null?`${i.width} × ${i.height}`:void 0,m=r===`uploading`&&s!==void 0?`${s}%`:void 0,h;r===`uploading`&&(n?.remainingLabel?h=n.remainingLabel:typeof n?.remainingMs==`number`&&Number.isFinite(n.remainingMs)&&n.remainingMs>=0&&(h=S.remainingTimeFormat(n.remainingMs)));let g=(r===`error`||r===`paused`)&&typeof n?.uploadedBytes==`number`?`${U(n.uploadedBytes)} of ${d} uploaded`:void 0,_=r?fe.value[r]:void 0,v;l&&(v=c&&s!==void 0?S.resumeLabel(s):S.retryLabel);let y=r===`error`?n?.error:void 0;return{slotProps:{file:e,index:t,fileKey:a,state:n,thumbnailUrl:n?.thumbnailUrl??i?.url,sizeLabel:d,typeLabel:f,dimensionsLabel:p,progressLabel:m,remainingLabel:h,uploadedLabel:g,statusLabel:_,actionLabel:v,canRetry:l,retryMode:u,isDragActive:D.value,isDragReject:O.value,removeFile:()=>Ne(t),retryFile:()=>{!l||B.value||!n||C(`retry`,{file:e,index:t,fileKey:a,mode:u,state:n})}},rowClass:r?`is-${r}`:void 0,metaText:[f,d,p,n?.meta].filter(Boolean).join(` · `),statusText:[_,m,h,g].filter(Boolean).join(` · `),errorText:y,errorId:y?`${a}-error`:void 0,showProgress:!!(n&&(r===`uploading`||r===`paused`||r===`error`||r===`success`&&s!==void 0)),progressValue:r===`uploading`?o:o??0,progressAriaLabel:`${_??``} ${e.name}`.trim(),isBusy:r===`uploading`,isSuccess:r===`success`}})),Q=e=>e?e.files.length>0||Array.from(e.types).includes(`Files`):!1,Fe=e=>e?.items?Array.from(e.items).map(e=>e.getAsFile?.()??null).filter(e=>e instanceof File):[],Ie=e=>{let t=e.target;X(Array.from(t.files??[])),Ae()},Le=e=>{!(0,r.isActivationKey)(e)||B.value||(e.preventDefault(),Y())},Re=()=>{k.value=!0},ze=e=>{let t=e.relatedTarget;t&&T.value?.contains(t)||(k.value=!1)},Be=e=>{P.value=e},Ve=e=>{P.value===e&&(P.value=null)},He=e=>{if(!S.paste||B.value)return;let t=Fe(e.clipboardData);t.length!==0&&(e.preventDefault(),X(t))},Ue=e=>{!k.value||e.defaultPrevented||He(e)},We=e=>{!S.drop||B.value||!Q(e.dataTransfer)||(e.preventDefault(),A.value+=1,D.value=!0,O.value=Te(e.dataTransfer))},Ge=e=>{!S.drop||B.value||!Q(e.dataTransfer)||(e.preventDefault(),e.dataTransfer&&(e.dataTransfer.dropEffect=`copy`),D.value=!0,O.value=Te(e.dataTransfer))},Ke=e=>{!S.drop||B.value||!Q(e.dataTransfer)||(e.preventDefault(),A.value=Math.max(A.value-1,0),A.value===0&&(D.value=!1,O.value=!1))},qe=e=>{!S.drop||B.value||!Q(e.dataTransfer)||(e.preventDefault(),A.value=0,D.value=!1,O.value=!1,X(Array.from(e.dataTransfer?.files??[])))},$=new Map;return(0,n.watch)(K,e=>{let t=[],n=new Set;S.modelValue.forEach((r,i)=>{n.add(r);let a=e[i]?.status;if(!a)return;let o=$.get(r);o!==a&&($.set(r,a),o!==void 0&&(a===`success`?t.push(`${r.name} uploaded.`):a===`error`&&t.push(`${r.name} failed. ${e[i]?.error??``}`.trim())))});for(let e of[...$.keys()])n.has(e)||$.delete(e);t.length>0&&(N+=1,M.value=[...M.value,{id:N,text:t.join(` `)}].slice(-5))},{flush:`post`,immediate:!0}),(0,n.watch)(K,()=>{let e=P.value,t=T.value;!e||!t||document.activeElement&&document.activeElement!==document.body||t.querySelector(`[data-t-retry="${e}"]`)||(P.value=null,(t.querySelector(`[data-t-remove="${e}"]`)??t.querySelector(`.t-file-upload__dropzone`))?.focus())},{flush:`post`}),(0,n.watch)(()=>S.modelValue,q),(0,n.watch)(()=>S.thumbnails,q),(0,n.onMounted)(()=>{document.addEventListener(`paste`,Ue),q()}),(0,n.onBeforeUnmount)(()=>{document.removeEventListener(`paste`,Ue),ke()}),(r,x)=>((0,n.openBlock)(),(0,n.createElementBlock)(`div`,(0,n.mergeProps)({ref_key:`rootRef`,ref:T},he.value,{class:pe.value,style:me.value,onFocusin:Re,onFocusout:ze,onPaste:He}),[(0,n.createElementVNode)(`input`,{id:(0,n.unref)(I),ref_key:`inputRef`,ref:E,class:`t-visually-hidden`,type:`file`,accept:b.accept||void 0,multiple:b.multiple,disabled:B.value,onChange:Ie},null,40,i),(0,n.createElementVNode)(`div`,{class:`t-file-upload__dropzone`,"aria-busy":b.loading||void 0,"aria-controls":b.showFileList&&V.value?z:void 0,"aria-describedby":ge.value,"aria-disabled":B.value||void 0,"aria-invalid":b.invalid||void 0,role:`button`,tabindex:B.value?-1:0,onClick:Y,onKeydown:Le,onDragenter:We,onDragover:Ge,onDragleave:Ke,onDrop:qe},[r.$slots.icon||!r.$slots.default?((0,n.openBlock)(),(0,n.createElementBlock)(`div`,o,[(0,n.renderSlot)(r.$slots,`icon`,{},()=>[x[0]||=(0,n.createElementVNode)(`span`,{class:`t-file-upload__icon-mark`},`+`,-1)])])):(0,n.createCommentVNode)(``,!0),(0,n.createElementVNode)(`div`,s,[(0,n.renderSlot)(r.$slots,`default`,{files:b.modelValue,isDragActive:D.value,isDragReject:O.value,openFileDialog:Y,clearFiles:Z},()=>[(0,n.createElementVNode)(`p`,c,(0,n.toDisplayString)(ve.value),1),b.description?((0,n.openBlock)(),(0,n.createElementBlock)(`p`,{key:0,id:L,class:`t-file-upload__description`},(0,n.toDisplayString)(b.description),1)):(0,n.createCommentVNode)(``,!0),(0,n.createElementVNode)(`span`,l,(0,n.toDisplayString)(b.buttonLabel),1)])]),b.loading?((0,n.openBlock)(),(0,n.createBlock)(e.default,{key:1,class:`t-file-upload__spinner`,size:`sm`,label:`Uploading files`})):(0,n.createCommentVNode)(``,!0)],40,a),j.value.length>0?((0,n.openBlock)(),(0,n.createElementBlock)(`ul`,{key:0,id:R,class:`t-file-upload__feedback`,"aria-live":`polite`},[((0,n.openBlock)(!0),(0,n.createElementBlock)(n.Fragment,null,(0,n.renderList)(j.value,e=>((0,n.openBlock)(),(0,n.createElementBlock)(`li`,{key:e},(0,n.toDisplayString)(e),1))),128))])):(0,n.createCommentVNode)(``,!0),(0,n.createElementVNode)(`div`,u,[((0,n.openBlock)(!0),(0,n.createElementBlock)(n.Fragment,null,(0,n.renderList)(M.value,e=>((0,n.openBlock)(),(0,n.createElementBlock)(`p`,{key:e.id,class:`t-file-upload__status-log-entry`},(0,n.toDisplayString)(e.text),1))),128))]),b.showFileList&&V.value?((0,n.openBlock)(),(0,n.createElementBlock)(`div`,d,[(0,n.createElementVNode)(`div`,f,[(0,n.createElementVNode)(`p`,{id:z,class:`t-file-upload__files-label`},(0,n.toDisplayString)(_e.value),1),(0,n.createElementVNode)(`button`,{class:`t-file-upload__clear`,type:`button`,disabled:B.value,onClick:Z},` Clear all `,8,p)]),(0,n.createElementVNode)(`ul`,m,[((0,n.openBlock)(!0),(0,n.createElementBlock)(n.Fragment,null,(0,n.renderList)(Pe.value,e=>((0,n.openBlock)(),(0,n.createElementBlock)(`li`,{key:e.slotProps.fileKey,class:(0,n.normalizeClass)([`t-file-upload__file`,e.rowClass]),"aria-busy":e.isBusy||void 0},[(0,n.renderSlot)(r.$slots,`file`,(0,n.mergeProps)({ref_for:!0},e.slotProps),()=>[(0,n.createElementVNode)(`span`,g,[e.slotProps.thumbnailUrl?((0,n.openBlock)(),(0,n.createElementBlock)(`img`,{key:0,class:`t-file-upload__thumb-image`,src:e.slotProps.thumbnailUrl,alt:``},null,8,_)):((0,n.openBlock)(),(0,n.createElementBlock)(`span`,v,(0,n.toDisplayString)(e.slotProps.typeLabel),1))]),(0,n.createElementVNode)(`div`,y,[(0,n.createElementVNode)(`p`,ee,(0,n.toDisplayString)(e.slotProps.file.name),1),(0,n.createElementVNode)(`p`,te,(0,n.toDisplayString)(e.metaText),1),(0,n.renderSlot)(r.$slots,`file-status`,(0,n.mergeProps)({ref_for:!0},e.slotProps),()=>[e.showProgress?((0,n.openBlock)(),(0,n.createElementBlock)(`div`,ne,[(0,n.createVNode)(t.default,{size:`sm`,value:e.progressValue,label:e.progressAriaLabel},null,8,[`value`,`label`])])):(0,n.createCommentVNode)(``,!0),e.statusText?((0,n.openBlock)(),(0,n.createElementBlock)(`p`,re,[e.isSuccess?((0,n.openBlock)(),(0,n.createElementBlock)(`span`,ie,`✓`)):(0,n.createCommentVNode)(``,!0),(0,n.createTextVNode)((0,n.toDisplayString)(e.statusText),1)])):(0,n.createCommentVNode)(``,!0),e.errorText?((0,n.openBlock)(),(0,n.createElementBlock)(`p`,{key:2,id:e.errorId,class:`t-file-upload__file-error`},(0,n.toDisplayString)(e.errorText),9,ae)):(0,n.createCommentVNode)(``,!0)])]),e.slotProps.canRetry?((0,n.openBlock)(),(0,n.createElementBlock)(`button`,{key:0,class:`t-file-upload__retry`,type:`button`,"data-t-retry":e.slotProps.fileKey,disabled:B.value,"aria-describedby":e.errorId,onFocus:t=>Be(e.slotProps.fileKey),onBlur:t=>Ve(e.slotProps.fileKey),onClick:t=>e.slotProps.retryFile()},(0,n.toDisplayString)(e.slotProps.actionLabel),41,oe)):(0,n.createCommentVNode)(``,!0),(0,n.createElementVNode)(`button`,{class:`t-file-upload__remove`,type:`button`,"data-t-remove":e.slotProps.fileKey,disabled:B.value,"aria-label":`Remove ${e.slotProps.file.name}`,onClick:t=>e.slotProps.removeFile()},` Remove `,8,se)])],10,h))),128))])])):(0,n.createCommentVNode)(``,!0),r.$slots.actions?((0,n.openBlock)(),(0,n.createElementBlock)(`div`,ce,[(0,n.renderSlot)(r.$slots,`actions`,{files:b.modelValue,openFileDialog:Y,clearFiles:Z})])):(0,n.createCommentVNode)(``,!0)],16))}});exports.default=b;
|
|
2
2
|
//# sourceMappingURL=TFileUpload.vue_vue_type_script_setup_true_lang.cjs.map
|