diff --git a/components/_style.ts b/components/_style.ts index 1fbf9985..671dcebf 100644 --- a/components/_style.ts +++ b/components/_style.ts @@ -57,3 +57,4 @@ import './avatar/style'; import './avatarGroup/style'; import './progress/style'; import './transfer/style'; +import './input-file/style'; diff --git a/components/components.ts b/components/components.ts index aabee438..a44f944c 100644 --- a/components/components.ts +++ b/components/components.ts @@ -55,3 +55,4 @@ export * from './avatar'; export * from './avatarGroup'; export * from './progress'; export * from './transfer'; +export * from './input-file'; diff --git a/components/input-file/const.ts b/components/input-file/const.ts new file mode 100644 index 00000000..860e5ce4 --- /dev/null +++ b/components/input-file/const.ts @@ -0,0 +1,3 @@ +export const COMPONENT_NAME = 'FInputFile'; + +export const COMPONENT_NAME_DRAGGER = `${COMPONENT_NAME}Dragger`; diff --git a/components/input-file/index.ts b/components/input-file/index.ts new file mode 100644 index 00000000..1f77ba43 --- /dev/null +++ b/components/input-file/index.ts @@ -0,0 +1,19 @@ +import { withInstall } from '../_util/withInstall'; +import { type SFCWithInstall } from '../_util/interface'; +import InputFile from './inputFile'; +import InputFileDragger from './inputFileDragger'; + +export { inputFileProps, type InputFileProps } from './props'; + +type InputFileType = SFCWithInstall; +export const FInputFile = withInstall( + InputFile as InputFileType, +); + +export { inputFileDraggerProps, type InputFileDraggerProps } from './props'; +type InputFileDraggerType = SFCWithInstall; +export const FInputFileDragger = withInstall( + InputFileDragger as InputFileDraggerType, +); + +export default FInputFile; diff --git a/components/input-file/inputFile.tsx b/components/input-file/inputFile.tsx new file mode 100644 index 00000000..e5088f60 --- /dev/null +++ b/components/input-file/inputFile.tsx @@ -0,0 +1,84 @@ +import { defineComponent, type VNodeChild, type StyleValue } from 'vue'; +import { useTheme } from '../_theme/useTheme'; +import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../_util/constants'; +import getPrefixCls from '../_util/getPrefixCls'; +import Button from '../button'; +import { UploadOutlined } from '../icon'; +import { COMPONENT_NAME } from './const'; +import { type InputFileSlots, inputFileProps } from './props'; +import { useInputFile } from './useInputFile'; + +const prefixCls = getPrefixCls('input-file'); +const cls = (appendClass: string): string => `${prefixCls}-${appendClass}`; + +const InputFile = defineComponent({ + name: COMPONENT_NAME, + props: inputFileProps, + emits: [UPDATE_MODEL_EVENT, CHANGE_EVENT], + slots: Object as InputFileSlots, + setup: (props, { emit, slots, attrs }) => { + useTheme(); + + const { + currentFiles, + inputRef, + disabled, + multiple, + acceptStr, + openFileExplorer, + handleInputFileChange, + } = useInputFile(props, emit); + + const renderTrigger = (): VNodeChild => { + if (slots.default) { + return slots.default({}); + } + return ( + + ); + }; + + const renderFileList = (files: File[]): VNodeChild => { + if (files.length === 0) return null; + + if (slots.fileList) { + return slots.fileList({ files }); + } + + return files.length > 1 ? `${files.length} 个文件` : files[0].name; + }; + + return () => ( +
+
+
+ {renderTrigger()} +
+
+ {renderFileList(currentFiles.value)} +
+
+ e.stopPropagation()} + /> +
+ ); + }, +}); + +export default InputFile; diff --git a/components/input-file/inputFileDragger.tsx b/components/input-file/inputFileDragger.tsx new file mode 100644 index 00000000..067ccaaf --- /dev/null +++ b/components/input-file/inputFileDragger.tsx @@ -0,0 +1,104 @@ +import { defineComponent, type StyleValue, type VNodeChild } from 'vue'; +import { useTheme } from '../_theme/useTheme'; +import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../_util/constants'; +import getPrefixCls from '../_util/getPrefixCls'; +import Message from '../message'; +import { useLocale } from '../config-provider/useLocale'; +import { COMPONENT_NAME_DRAGGER } from './const'; +import { type InputFileSlots, inputFileDraggerProps } from './props'; +import { useInputFile } from './useInputFile'; +import { useFileDrop } from './useFileDrop'; + +const prefixCls = getPrefixCls('input-file-dragger'); +const cls = (appendClass: string): string => `${prefixCls}-${appendClass}`; + +const InputFileDragger = defineComponent({ + name: COMPONENT_NAME_DRAGGER, + props: inputFileDraggerProps, + emits: [UPDATE_MODEL_EVENT, CHANGE_EVENT], + slots: Object as InputFileSlots, + setup: (props, { emit, slots, attrs }) => { + useTheme(); + + const { t } = useLocale(); + + const { + currentFiles, + updateCurrentFiles, + inputRef, + disabled, + multiple, + accept, + acceptStr, + openFileExplorer, + handleInputFileChange, + } = useInputFile(props, emit); + + const handleFileTypeInvalid = (files: File[]): void => { + if (!props.onFileTypeInvalid) { + Message.error(t('upload.fileTypeInvalidTip')); + } + props.onFileTypeInvalid(files); + }; + + const { isHovering, handleEnter, handleLeave, handleOver, handleDrop } = + useFileDrop({ + disabled, + multiple, + accept, + afterDrop: (files) => { + updateCurrentFiles(files); + emit(CHANGE_EVENT, files); + }, + onFileTypeInvalid: handleFileTypeInvalid, + }); + + const renderFileList = (files: File[]): VNodeChild => { + if (files.length === 0) return null; + + if (slots.fileList) { + return slots.fileList({ files }); + } + + return files.length > 1 ? `${files.length} 个文件` : files[0].name; + }; + + return () => ( +
+
+
+ {slots.default?.({})} +
+
+ {renderFileList(currentFiles.value)} +
+
+ e.stopPropagation()} + /> +
+ ); + }, +}); + +export default InputFileDragger; diff --git a/components/input-file/props.ts b/components/input-file/props.ts new file mode 100644 index 00000000..19c417fd --- /dev/null +++ b/components/input-file/props.ts @@ -0,0 +1,64 @@ +import { + type PropType, + type ComponentObjectPropsOptions, + type SlotsType, +} from 'vue'; +import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../_util/constants'; +import { + type ComponentEmit, + type ExtractPublicPropTypes, +} from '../_util/interface'; + +const commonProps = { + modelValue: { + type: Array as PropType, + default: (): File[] => [], + }, + accept: { + type: Array as PropType, + default: (): string[] => [], + }, + disabled: { + type: Boolean, + default: false, + }, + multiple: { + type: Boolean, + default: false, + }, +} as const satisfies ComponentObjectPropsOptions; + +// ------ Default Props ------ + +export const inputFileProps = { + ...commonProps, +} as const satisfies ComponentObjectPropsOptions; + +export type InputFileProps = ExtractPublicPropTypes; + +// ------ Dragger Props ------ + +export const inputFileDraggerProps = { + ...commonProps, + onFileTypeInvalid: { + type: Function as PropType<(files: File[]) => void>, + }, +} as const satisfies ComponentObjectPropsOptions; + +export type InputFileDraggerProps = ExtractPublicPropTypes< + typeof inputFileDraggerProps +>; + +// ------ Emit ------ +export const EMIT_EVENTS = [UPDATE_MODEL_EVENT, CHANGE_EVENT] as const; +export type InputFileEmit = ComponentEmit; + +// ------ Slots ------ +export type InputFileSlotsParams = { + default: Record; + fileList: { + files: File[]; + }; +}; + +export type InputFileSlots = SlotsType; diff --git a/components/input-file/style/index.less b/components/input-file/style/index.less new file mode 100644 index 00000000..e4f736af --- /dev/null +++ b/components/input-file/style/index.less @@ -0,0 +1,63 @@ +@import '../../style/themes/index'; +@import '../../style/mixins/index'; + +@input-file: ~'@{cls-prefix}-input-file'; +@input-file-dragger: ~'@{cls-prefix}-input-file-dragger'; + +.@{input-file}, .@{input-file-dragger} { + &-input { + display: none; + } + + &-file-list { + .text(); + color: var(--f-sub-head-color); + } +} + +.@{input-file} { + .@{input-file}-visible-content { + display: flex; + align-items: center; + + .@{input-file}-trigger { + cursor: pointer; + } + + .@{input-file}-file-list { + margin-left: var(--f-padding-xsmall); + } + } +} + +.@{input-file-dragger} { + width: 100%; + + .@{input-file-dragger}-visible-content { + + .@{input-file-dragger}-droppable { + width: 100%; + padding: var(--f-padding-middle); + text-align: center; + background-color: var(--f-component-bg-color); + border: var(--f-border-width-base) dashed var(--f-border-color-base); + border-radius: var(--f-border-radius-base); + cursor: pointer; + transition: border-color @animation-duration-slow @ease-base-in; + + &:hover, &.is-hovering{ + border-color: var(--f-primary-color); + } + + &.is-disabled { + color: var(--f-text-color-disabled); + border-color: var(--f-border-color-base); + cursor: not-allowed; + } + } + + .@{input-file-dragger}-file-list { + margin-top: var(--f-padding-xsmall); + } + } +} \ No newline at end of file diff --git a/components/input-file/style/index.ts b/components/input-file/style/index.ts new file mode 100644 index 00000000..ed51d175 --- /dev/null +++ b/components/input-file/style/index.ts @@ -0,0 +1,2 @@ +import '../../style'; +import './index.less'; diff --git a/components/input-file/useFileDrop.ts b/components/input-file/useFileDrop.ts new file mode 100644 index 00000000..96e9d00b --- /dev/null +++ b/components/input-file/useFileDrop.ts @@ -0,0 +1,72 @@ +import { type Ref, ref } from 'vue'; +import { matchType } from '../upload/utils'; + +export const useFileDrop = ({ + accept, + multiple, + disabled, + afterDrop, + onFileTypeInvalid, +}: { + accept: Ref; + multiple: Ref; + disabled: Ref; + afterDrop: (files: File[]) => void; + onFileTypeInvalid?: (files: File[]) => void; +}) => { + const isHovering = ref(false); + + const handleEnter = (event: DragEvent): void => { + if (disabled.value) return; + event.preventDefault(); + + isHovering.value = true; + }; + + const handleLeave = (event: DragEvent): void => { + if (disabled.value) return; + event.preventDefault(); + + isHovering.value = false; + }; + + const handleOver = (event: DragEvent): void => { + if (disabled.value) return; + event.preventDefault(); + }; + + const handleDrop = (event: DragEvent): void => { + if (disabled.value) return; + event.preventDefault(); + + isHovering.value = false; + let files = Array.from(event.dataTransfer.files); + if (!files.length) return; + if (!multiple.value) { + files = files.slice(0, 1); + } + + const filterFiles = accept.value.length + ? files.filter((file) => { + return matchType(file.name, file.type, accept.value); + }) + : files; + if (filterFiles.length !== files.length) { + onFileTypeInvalid?.( + files.filter((file) => { + return !matchType(file.name, file.type, accept.value); + }), + ); + } + + afterDrop(files); + }; + + return { + isHovering, + handleEnter, + handleLeave, + handleOver, + handleDrop, + }; +}; diff --git a/components/input-file/useInputFile.ts b/components/input-file/useInputFile.ts new file mode 100644 index 00000000..b14004f2 --- /dev/null +++ b/components/input-file/useInputFile.ts @@ -0,0 +1,52 @@ +import { computed, ref } from 'vue'; +import { CHANGE_EVENT } from '../_util/constants'; +import useFormAdaptor from '../_util/use/useFormAdaptor'; +import { useNormalModel } from '../_util/use/useModel'; +import { type InputFileEmit, type InputFileProps } from './props'; + +// 所需的数据 +export const useInputFile = (props: InputFileProps, emit: InputFileEmit) => { + const [currentFiles, updateCurrentFiles] = useNormalModel(props, emit); + + const inputRef = ref(null); + + // 表单组件的总体disabled状态 + const { isFormDisabled } = useFormAdaptor(); + const disabled = computed(() => props.disabled || isFormDisabled.value); + + const accept = computed(() => props.accept); + + const acceptStr = computed(() => accept.value.join(',')); + + const multiple = computed(() => props.multiple); + + const openFileExplorer = () => { + if (disabled.value) return; + inputRef.value.click(); + }; + + const handleInputFileChange = (e: Event): void => { + const target = e.target as HTMLInputElement; + + const files = Array.from(target.files); + if (!files) return; + + updateCurrentFiles(files); + emit(CHANGE_EVENT, files); + + // 若不重置,重复选择相同文件,change 事件可能不触发 + target.value = null; + }; + + return { + currentFiles, + updateCurrentFiles, + inputRef, + disabled, + multiple, + accept, + acceptStr, + openFileExplorer, + handleInputFileChange, + }; +}; diff --git a/docs/.vitepress/components/inputFile/basic.vue b/docs/.vitepress/components/inputFile/basic.vue new file mode 100644 index 00000000..a18f8cea --- /dev/null +++ b/docs/.vitepress/components/inputFile/basic.vue @@ -0,0 +1,29 @@ + + + diff --git a/docs/.vitepress/components/inputFile/dragAndDrop.vue b/docs/.vitepress/components/inputFile/dragAndDrop.vue new file mode 100644 index 00000000..a71789f1 --- /dev/null +++ b/docs/.vitepress/components/inputFile/dragAndDrop.vue @@ -0,0 +1,31 @@ + + + diff --git a/docs/.vitepress/components/inputFile/index.md b/docs/.vitepress/components/inputFile/index.md new file mode 100644 index 00000000..4e28fb68 --- /dev/null +++ b/docs/.vitepress/components/inputFile/index.md @@ -0,0 +1,50 @@ +# InputFile 文件选择 + +## 组件注册 + +```js +import { FInputFile } from '@fesjs/fes-design'; + +app.use(FInputFile); +``` + +## 代码演示 + +### 基础用法 + +:::demo +basic.vue +::: + +### 拖放文件 + +:::demo +dragAndDrop.vue +::: + +## Props +| 属性 | 说明 | 类型 | 默认值 | +|----------|----------------|------------|---------| +| v-model | 选择的文件 | `File[]` | `[]` | +| multiple | 是否支持多选文件 | `boolean` | `false` | +| accept | 接受的文件类型 | `string[]` | `[]` | +| disabled | 是否禁用 | `boolean` | `false` | + +## Events + +| 事件名称 | 说明 | 回调参数 | +|----------|--------------|---------------------------| +| change | 选择文件后调用 | `(files: File[]) => void` | + +## Slots + +| 名称 | 说明 | 参数 | +|----------|--------------------|-------------| +| default | 触发文件选择框的内容 | - | +| fileList | 自定义选中文件的展示 | `{ files }` | + +## InputFileDragger Props + +| 属性 | 说明 | 类型 | 默认值 | +|-------------------|--------------------------------------------------------------------|---------------------------|--------| +| onFileTypeInvalid | 拖拽文件类型不满足 `accept` 时的钩子函数,
若未定义则使用内置提示 | `(files: File[]) => void` | - | \ No newline at end of file diff --git a/docs/.vitepress/components/upload/index.md b/docs/.vitepress/components/upload/index.md index 3438b077..52d3dd1a 100644 --- a/docs/.vitepress/components/upload/index.md +++ b/docs/.vitepress/components/upload/index.md @@ -122,7 +122,7 @@ singleUpload.vue | 属性 | 说明 | 类型 | 默认值 | | ----------------- | -------------------------------------------------------------- | ----------------------- | ------ | -| onFileTypeInvalid | 拖拽文件类型不满足`accept`时的钩子函数,若未定义则使用内置提示 | (files: File[]) => void | - | +| onFileTypeInvalid | 拖拽文件类型不满足 `accept` 时的钩子函数,若未定义则使用内置提示 | (files: File[]) => void | - | ## 类型 diff --git a/docs/.vitepress/configs/sidebar/index.ts b/docs/.vitepress/configs/sidebar/index.ts index 5fd559c0..5ed6c053 100644 --- a/docs/.vitepress/configs/sidebar/index.ts +++ b/docs/.vitepress/configs/sidebar/index.ts @@ -77,6 +77,10 @@ const sidebarConfig: Record = { text: 'InputNumber 数字输入框', link: '/zh/components/inputNumber', }, + { + text: 'InputFile 文件选择', + link: '/zh/components/inputFile', + }, { text: 'Checkbox 多选框', link: '/zh/components/checkbox',