diff --git a/.changeset/file-upload-improvements.md b/.changeset/file-upload-improvements.md new file mode 100644 index 00000000..35494f3f --- /dev/null +++ b/.changeset/file-upload-improvements.md @@ -0,0 +1,10 @@ +--- +"@geajs/ui": patch +--- + +Fixed and enhanced FileUpload: +- use typed `Props` to enable autocompletion +- show drag-active overlay state +- file list shows file size (with human-readable formatting) and per-file delete button +- `preventDocumentDrop` disables dropping files outside dropzone +- i18n diff --git a/.cursor/skills/gea-ui-components/reference.md b/.cursor/skills/gea-ui-components/reference.md index 9a8b014a..712f59eb 100644 --- a/.cursor/skills/gea-ui-components/reference.md +++ b/.cursor/skills/gea-ui-components/reference.md @@ -382,14 +382,32 @@ Drag-and-drop file upload area. | Prop | Type | Default | Description | |------|------|---------|-------------| -| `label` | `string` | — | Label text | -| `accept` | `string \| Record` | — | Accepted file types | +| `label` | `JSXNode` | — | Label text | +| `accept` | `string \| string[] \| Record` | — | Accepted file types | | `maxFiles` | `number` | — | Maximum number of files | -| `multiple` | `boolean` | — | Allow multiple files | -| `onFileChange` | `(details) => void` | — | Files changed | - -```tsx - +| `minFileSize` | `number` | — | Minimum file size in bytes | +| `maxFileSize` | `number` | — | Maximum file size in bytes | +| `allowDrop` | `boolean` | `true` | Allow drag&drop selection | +| `preventDocumentDrop` | `boolean` | `true` | Prevent dropping file outside the dropzone | +| `name` | `string` | — | Input name (for usage in form) | +| `class` | `string` | — | Class name(s) added to the root element | +| `disabled` | `boolean` | `false` | Disable interaction | +| `formatFileSize` | `(bytes: number) => string` | localized, 2 decimal points, suffix | Function formatting file size in the file list | +| `onFileChange` | `(details: { acceptedFiles: File[], rejectedFiles: { file: File, errors: string[] }[] }) => void` | — | Called when files are selected | +| `translations` | `Partial` | see Translations | Override UI strings | + +#### Translations + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `dropzone` | `string` | Drag and drop files here | Default dropzone text | +| `dropzoneButton` | `string` | Choose Files | Button triggering file selection | +| `dropzoneActive` | `string` | Drop your files here | Dropzone text while files are being dragged | +| `deleteFile` | `(file: File) => string` | ``(file: File) => `Remove file ${file.name}` `` | Button removing single file from the list | +| `clearAllButton` | `string` | Clear all | Button removing all files from the list | + +```tsx + ``` ### HoverCard diff --git a/docs/gea-ui/interactive-components.md b/docs/gea-ui/interactive-components.md index 8e92354d..9c79181b 100644 --- a/docs/gea-ui/interactive-components.md +++ b/docs/gea-ui/interactive-components.md @@ -578,11 +578,29 @@ import { FileUpload } from '@geajs/ui' | Prop | Type | Default | Description | | --- | --- | --- | --- | -| `label` | `string` | — | Label text | -| `accept` | `Record` | — | Accepted MIME types and extensions | +| `label` | `JSXNode` | — | Label text | +| `accept` | `string \| string[] \| Record` | — | Accepted MIME types and extensions | | `maxFiles` | `number` | — | Maximum number of files | +| `minFileSize` | `number` | — | Minimum file size in bytes | | `maxFileSize` | `number` | — | Maximum file size in bytes | -| `onFileChange` | `(details: { acceptedFiles: File[], rejectedFiles: File[] }) => void` | — | Called when files are selected | +| `allowDrop` | `boolean` | `true` | Allow drag&drop selection | +| `preventDocumentDrop` | `boolean` | `true` | Prevent dropping file outside the dropzone | +| `name` | `string` | — | Input name (for usage in form) | +| `class` | `string` | — | Class name(s) added to the root element | +| `disabled` | `boolean` | `false` | Disable interaction | +| `formatFileSize` | `(bytes: number) => string` | localized, 2 decimal points, suffix | Function formatting file size in the file list | +| `onFileChange` | `(details: { acceptedFiles: File[], rejectedFiles: { file: File, errors: string[] }[] }) => void` | — | Called when files are selected | +| `translations` | `Partial` | see Translations | Override UI strings | + +### Translations + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `dropzone` | `string` | Drag and drop files here | Default dropzone text | +| `dropzoneButton` | `string` | Choose Files | Button triggering file selection | +| `dropzoneActive` | `string` | Drop your files here | Dropzone text while files are being dragged | +| `deleteFile` | `(file: File) => string` | ``(file: File) => `Remove file ${file.name}` `` | Button removing single file from the list | +| `clearAllButton` | `string` | Clear all | Button removing all files from the list | ## Toast diff --git a/examples/docs/app.tsx b/examples/docs/app.tsx index ebca5b30..35f1e64a 100644 --- a/examples/docs/app.tsx +++ b/examples/docs/app.tsx @@ -1791,9 +1791,9 @@ export default class App extends Component {

Drag-and-drop or click-to-browse file uploader.

- +
-
{``}
+
{``}

API

@@ -1809,7 +1809,7 @@ export default class App extends Component { @@ -1822,6 +1822,14 @@ export default class App extends Component { + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + - + + + + + + + + + +
accept - Record<string, string[]> + string | string[] | Record<string, string[]> Accepted file types Maximum file count
minFileSize + number + Min file size in bytes
maxFileSize @@ -1831,20 +1839,141 @@ export default class App extends Component { Max file size in bytes
multipleallowDrop + boolean + trueAllow drag&drop selection
preventDocumentDrop + boolean + truePrevent dropping file outside the dropzone
name + string + Input name (for usage in form)
class + string + Class name(s) added to the root element
label + JSXNode + Label above the dropzone
disabled boolean falseAllow multiple filesDisable interaction
formatFileSize + {'(bytes: number) => string'} + localized, 2 decimal points, suffixFunction formatting file size in the file list
onFileChange - {'(details) => void'} + + { + '(details: { acceptedFiles: File[], rejectedFiles: { file: File, errors: string[] }[] }) => void' + } + File change callback + + File change callback + +
translations + + Partial{'<'}FileUploadTranslations{'>'} + + see TranslationsOverride UI strings
+

Translations

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyTypeDefaultDescription
dropzone + string + Drag and drop files hereDefault dropzone text
dropzoneButton + string + Choose FilesButton triggering file selection
dropzoneActive + string + Drop your files hereDropzone text while files are being dragged
deleteFile + {'(file: File) => string'} + + {`(file: File) => 'Remove file ' + file.name`} + Button removing single file from the list
clearAllButton + string + Clear allButton removing all files from the list
diff --git a/examples/forms/app.tsx b/examples/forms/app.tsx index aa36a1a2..1d35b99a 100644 --- a/examples/forms/app.tsx +++ b/examples/forms/app.tsx @@ -172,7 +172,7 @@ export default class App extends Component { Upload identity documents for verification. - + diff --git a/examples/showcase/app.tsx b/examples/showcase/app.tsx index f8dfd341..98c57b23 100644 --- a/examples/showcase/app.tsx +++ b/examples/showcase/app.tsx @@ -392,7 +392,7 @@ export default class App extends Component {

Drag-and-drop or click to upload files.

- +
diff --git a/packages/gea-ui/src/components/file-upload.tsx b/packages/gea-ui/src/components/file-upload.tsx index 79252733..8e35f541 100644 --- a/packages/gea-ui/src/components/file-upload.tsx +++ b/packages/gea-ui/src/components/file-upload.tsx @@ -1,31 +1,123 @@ import * as fileUpload from '@zag-js/file-upload' import { normalizeProps } from '@zag-js/vanilla' +import { type ClassValue } from 'clsx' import ZagComponent from '../primitives/zag-component' +import { cn } from '../utils/cn' +import { JSXNode } from '../types' + +export interface FileUploadTranslations { + dropzone: string + dropzoneButton: string + dropzoneActive: string + deleteFile: (file: File) => string + clearAllButton: string +} + +export interface FileUploadProps { + accept?: string | string[] | Record + maxFiles?: number + minFileSize?: number + maxFileSize?: number + allowDrop?: boolean + preventDocumentDrop?: boolean + name?: string + class?: ClassValue + label?: JSXNode + disabled?: boolean + formatFileSize?: (bytes: number) => string + onFileChange?: (details: { acceptedFiles: File[]; rejectedFiles: { file: File; errors: string[] }[] }) => void + translations?: Partial +} + +export default class FileUpload extends ZagComponent { + static readonly FILE_SIZE_FORMATTER = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + }) -export default class FileUpload extends ZagComponent { declare acceptedFiles: File[] declare rejectedFiles: any[] - createMachine(_props: any): any { + readonly _translations: FileUploadTranslations = { + dropzone: 'Drag and drop files here', + dropzoneButton: 'Choose Files', + dropzoneActive: 'Drop your files here', + deleteFile: (file: File) => `Remove file ${file.name}`, + clearAllButton: 'Clear all', + } + + isDraggingIntoPage = false + + _dragEnterListener = (ev: DragEvent) => { + if (!this.isDraggingIntoPage && ev.dataTransfer?.types.includes('Files')) { + this.isDraggingIntoPage = true + } + } + + _dragLeaveListener = (ev: DragEvent) => { + if (this.isDraggingIntoPage && !ev.relatedTarget) { + this.isDraggingIntoPage = false + } + } + + _dragEndListener = () => { + this.isDraggingIntoPage = false + } + + created(props: FileUploadProps) { + super.created?.(props) + + document.addEventListener('dragenter', this._dragEnterListener) + document.addEventListener('dragleave', this._dragLeaveListener) + document.addEventListener('drop', this._dragEndListener) + document.addEventListener('dragend', this._dragEndListener) + } + + dispose(): void { + document.removeEventListener('dragenter', this._dragEnterListener) + document.removeEventListener('dragleave', this._dragLeaveListener) + document.removeEventListener('drop', this._dragEndListener) + document.removeEventListener('dragend', this._dragEndListener) + + super.dispose() + } + + getTranslations({ translations }: FileUploadProps): FileUploadTranslations { + return { + dropzone: translations?.dropzone ?? this._translations.dropzone, + dropzoneButton: translations?.dropzoneButton ?? this._translations.dropzoneButton, + dropzoneActive: translations?.dropzoneActive ?? this._translations.dropzoneActive, + deleteFile: translations?.deleteFile ?? this._translations.deleteFile, + clearAllButton: translations?.clearAllButton ?? this._translations.clearAllButton, + } + } + + createMachine(_props: FileUploadProps): any { return fileUpload.machine } - getMachineProps(props: any) { + getMachineProps(props: FileUploadProps): fileUpload.Props { + const t = this.getTranslations(props) return { id: this.id, - accept: props.accept, + disabled: props.disabled, + name: props.name, + // undefined produces accept="[object Object]" + accept: props.accept ?? [], maxFiles: props.maxFiles, - maxFileSize: props.maxFileSize, minFileSize: props.minFileSize, - multiple: props.multiple ?? false, - disabled: props.disabled, + maxFileSize: props.maxFileSize, allowDrop: props.allowDrop ?? true, - name: props.name, + preventDocumentDrop: props.preventDocumentDrop ?? true, onFileChange: (details: fileUpload.FileChangeDetails) => { this.acceptedFiles = details.acceptedFiles this.rejectedFiles = details.rejectedFiles + this.isDraggingIntoPage = false props.onFileChange?.(details) }, + translations: { + dropzone: t.dropzone, + deleteFile: t.deleteFile, + }, } } @@ -40,18 +132,58 @@ export default class FileUpload extends ZagComponent { '[data-part="dropzone"]': 'getDropzoneProps', '[data-part="trigger"]': 'getTriggerProps', '[data-part="hidden-input"]': 'getHiddenInputProps', + '[data-part="item-group"]': 'getItemGroupProps', + '[data-part="item"]': (api: fileUpload.Api, el: HTMLElement) => { + const file = this.getFileByItemEl(el) + return file ? api.getItemProps({ file }) : {} + }, + '[data-part="item-name"]': (api: fileUpload.Api, el: HTMLElement) => { + const file = this.getFileByItemEl(el) + return file ? api.getItemNameProps({ file }) : {} + }, + '[data-part="item-size-text"]': (api: fileUpload.Api, el: HTMLElement) => { + const file = this.getFileByItemEl(el) + return file ? api.getItemSizeTextProps({ file }) : {} + }, + '[data-part="item-delete-trigger"]': (api: fileUpload.Api, el: HTMLElement) => { + const file = this.getFileByItemEl(el) + return file ? api.getItemDeleteTriggerProps({ file }) : {} + }, '[data-part="clear-trigger"]': 'getClearTriggerProps', } } - syncState(api: any) { + syncState(api: fileUpload.Api) { this.acceptedFiles = api.acceptedFiles this.rejectedFiles = api.rejectedFiles } - template(props: any) { + getFileByItemEl(el: HTMLElement) { + const itemEl = el.closest('[data-part="item"]') + const intIndex = parseInt(itemEl?.dataset.itemIndex ?? '', 10) + return Number.isNaN(intIndex) ? undefined : (this.acceptedFiles || [])[intIndex] + } + + formatFileSize(bytes: number): string { + const units = ['B', 'KB', 'MB', 'GB'] + + let value = bytes + let unitIndex = 0 + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex++ + } + + return `${FileUpload.FILE_SIZE_FORMATTER.format(value)} ${units[unitIndex]}` + } + + template(props: FileUploadProps) { + const t = this.getTranslations(props) + const isDropzoneActive = props.allowDrop !== false && !props.disabled && this.isDraggingIntoPage + return ( -
+
{props.label && (
- - {this.acceptedFiles.length > 0 && ( -
- {this.acceptedFiles.map((file: File) => ( -
- {file.name} + + {(this.acceptedFiles || []).length > 0 && ( +
+ {(this.acceptedFiles || []).map((file: File, index) => ( +
+ + {file.name} + + + {props.formatFileSize ? props.formatFileSize(file.size) : this.formatFileSize(file.size)} + +
))}
)} diff --git a/packages/gea-ui/src/index.ts b/packages/gea-ui/src/index.ts index c621550a..262b59d6 100644 --- a/packages/gea-ui/src/index.ts +++ b/packages/gea-ui/src/index.ts @@ -13,6 +13,7 @@ export { default as Combobox } from './components/combobox' export { default as Dialog } from './components/dialog' export type { DialogProps } from './components/dialog' export { default as FileUpload } from './components/file-upload' +export type { FileUploadProps, FileUploadTranslations } from './components/file-upload' export { default as HoverCard } from './components/hover-card' export { default as Menu } from './components/menu' export { default as NumberInput } from './components/number-input' diff --git a/tests/e2e/forms.spec.ts b/tests/e2e/forms.spec.ts index 3bbd76c8..3e2115c4 100644 --- a/tests/e2e/forms.spec.ts +++ b/tests/e2e/forms.spec.ts @@ -90,14 +90,16 @@ test.describe('forms settings page', () => { const deleteBtn = page.locator('[data-scope="tags-input"] [data-part="item-delete-trigger"]').first() await deleteBtn.click() - await expect(page.locator('[data-scope="tags-input"] [data-part="item-preview"]', { hasText: '192.168.1.1' })).toHaveCount(0) + await expect( + page.locator('[data-scope="tags-input"] [data-part="item-preview"]', { hasText: '192.168.1.1' }), + ).toHaveCount(0) await expect(page.getByText('10.0.0.1')).toBeVisible() }) test('documents section has file upload', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible() // FileUpload component should exist - await expect(page.locator('[data-scope="file-upload"]')).toBeAttached() + await expect(page.locator('[data-scope="file-upload"][data-part="dropzone"]')).toBeAttached() }) test('action buttons are visible', async ({ page }) => {