From 982163de608410f83998b9b72c41aff5c37ed674 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 8 Jul 2026 12:09:28 +0300 Subject: [PATCH 01/30] chore(FileUpload): checkpoint current implementation --- .../FileUpload/FileUpload.module.css | 15 + .../FileUpload/FileUpload.stories.tsx | 177 +++++++++++ .../components/FileUpload/FileUpload.test.tsx | 275 +++++++++++++++++ .../src/components/FileUpload/FileUpload.tsx | 278 ++++++++++++++++++ .../FileUpload/FileUploadContext.ts | 55 ++++ .../components/Dropzone/Dropzone.module.css | 59 ++++ .../components/Dropzone/Dropzone.tsx | 65 ++++ .../FileUpload/components/Dropzone/index.ts | 1 + .../components/Item/Item.module.css | 62 ++++ .../FileUpload/components/Item/Item.tsx | 87 ++++++ .../FileUpload/components/Item/index.ts | 1 + .../components/List/List.module.css | 13 + .../FileUpload/components/List/List.tsx | 48 +++ .../FileUpload/components/List/index.ts | 1 + .../FileUpload/components/Trigger/Trigger.tsx | 61 ++++ .../FileUpload/components/Trigger/index.ts | 1 + .../components/FileUpload/components/index.ts | 4 + .../src/components/FileUpload/index.ts | 4 + .../src/components/FileUpload/intl.ts | 34 +++ .../src/components/FileUpload/types.ts | 180 ++++++++++++ .../src/components/FileUpload/utils.ts | 46 +++ packages/components/src/components/index.ts | 1 + .../formatFileSize/formatFileSize.test.ts | 47 +++ .../utils/formatFileSize/formatFileSize.ts | 64 ++++ .../src/utils/formatFileSize/index.ts | 1 + packages/components/src/utils/index.ts | 1 + packages/primitives/src/index.ts | 16 + 27 files changed, 1597 insertions(+) create mode 100644 packages/components/src/components/FileUpload/FileUpload.module.css create mode 100644 packages/components/src/components/FileUpload/FileUpload.stories.tsx create mode 100644 packages/components/src/components/FileUpload/FileUpload.test.tsx create mode 100644 packages/components/src/components/FileUpload/FileUpload.tsx create mode 100644 packages/components/src/components/FileUpload/FileUploadContext.ts create mode 100644 packages/components/src/components/FileUpload/components/Dropzone/Dropzone.module.css create mode 100644 packages/components/src/components/FileUpload/components/Dropzone/Dropzone.tsx create mode 100644 packages/components/src/components/FileUpload/components/Dropzone/index.ts create mode 100644 packages/components/src/components/FileUpload/components/Item/Item.module.css create mode 100644 packages/components/src/components/FileUpload/components/Item/Item.tsx create mode 100644 packages/components/src/components/FileUpload/components/Item/index.ts create mode 100644 packages/components/src/components/FileUpload/components/List/List.module.css create mode 100644 packages/components/src/components/FileUpload/components/List/List.tsx create mode 100644 packages/components/src/components/FileUpload/components/List/index.ts create mode 100644 packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx create mode 100644 packages/components/src/components/FileUpload/components/Trigger/index.ts create mode 100644 packages/components/src/components/FileUpload/components/index.ts create mode 100644 packages/components/src/components/FileUpload/index.ts create mode 100644 packages/components/src/components/FileUpload/intl.ts create mode 100644 packages/components/src/components/FileUpload/types.ts create mode 100644 packages/components/src/components/FileUpload/utils.ts create mode 100644 packages/components/src/utils/formatFileSize/formatFileSize.test.ts create mode 100644 packages/components/src/utils/formatFileSize/formatFileSize.ts create mode 100644 packages/components/src/utils/formatFileSize/index.ts diff --git a/packages/components/src/components/FileUpload/FileUpload.module.css b/packages/components/src/components/FileUpload/FileUpload.module.css new file mode 100644 index 000000000..f02819961 --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUpload.module.css @@ -0,0 +1,15 @@ +.base { + display: flex; + inline-size: 100%; + box-sizing: border-box; + flex-direction: column; + gap: var(--kbq-size-xxs); +} + +.default { + /* default layout */ +} + +.compact { + /* compact layout is refined in a later stage */ +} diff --git a/packages/components/src/components/FileUpload/FileUpload.stories.tsx b/packages/components/src/components/FileUpload/FileUpload.stories.tsx new file mode 100644 index 000000000..fe40b1240 --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUpload.stories.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { FileUpload } from './FileUpload'; +import type { FileUploadItem } from './types'; + +const meta = { + title: 'Components/FileUpload', + component: FileUpload, + subcomponents: { + 'FileUpload.Dropzone': FileUpload.Dropzone, + 'FileUpload.List': FileUpload.List, + 'FileUpload.Item': FileUpload.Item, + 'FileUpload.Trigger': FileUpload.Trigger, + }, + parameters: { + layout: 'centered', + }, + tags: ['status:new', 'date:2026-07-07'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const makeItem = (name: string, size: number): FileUploadItem => ({ + id: name, + size, + file: new File(['stub'], name), +}); + +export const Base: Story = { + render: (args) => ( + console.log(items)} + {...args} + /> + ), +}; + +export const SingleWithSelectedFile: Story = { + render: (args) => ( + + ), +}; + +/** + * In multiple mode the whole component is an active drop zone: drag files onto + * it (or the "add more" strip) and they are appended to the list. + */ +export const Multiple: Story = { + render: (args) => ( + + ), +}; + +/** + * The empty multiple state is a large drop area. Drop files anywhere on it or + * use the browse link. + */ +export const MultipleEmpty: Story = { + render: (args) => ( + + ), +}; + +/** A long list scrolls within a capped height; the drop strip stays visible. */ +export const Scrollable: Story = { + name: 'Multiple (scrollable list)', + render: (args) => ( + + makeItem(`document_${index + 1}.pdf`, 148909 * (index + 1)) + )} + {...args} + /> + ), +}; + +export const Controlled: Story = { + render: function Render(args) { + const [items, setItems] = useState([ + makeItem('notes_january.docx', 148909), + ]); + + return ( + + ); + }, +}; + +export const Disabled: Story = { + render: (args) => ( + + ), +}; + +/** + * The same result as the default layout, but assembled explicitly from the + * compound parts — this is the customization surface. + */ +export const Composition: Story = { + name: 'Composition (compound parts)', + render: (args) => ( + + + + Перетащите сюда или{' '} + выберите файлы + + + ), +}; + +/** + * Custom dropzone content: replace the text and keep the browse trigger and the + * drag hint (spec "Кастомный контент в загрузчике"). + */ +export const CustomContent: Story = { + render: (args) => ( + + + Перетащите сюда ZIP-архивы с паролем + + или выберите файлы + + + + ), +}; diff --git a/packages/components/src/components/FileUpload/FileUpload.test.tsx b/packages/components/src/components/FileUpload/FileUpload.test.tsx new file mode 100644 index 000000000..e9eeaf322 --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUpload.test.tsx @@ -0,0 +1,275 @@ +import { createRef, useState } from 'react'; + +import type { DropItem } from '@koobiq/react-primitives'; +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { Provider } from '../Provider'; + +import { FileUpload } from './index'; +import type { FileUploadItem, FileUploadProps } from './index'; +import { readDroppedFiles } from './utils'; + +const ROOT_TEST_ID = 'file-upload'; + +const makeFile = (name: string, content = 'stub') => + new File([content], name, { type: 'text/plain' }); + +const makeItem = (name: string, size?: number): FileUploadItem => ({ + id: name, + size, + file: makeFile(name), +}); + +const getFileInput = () => + document.querySelector('input[type="file"]') as HTMLInputElement; + +const renderComponent = (props: FileUploadProps = {}) => + render( + + ); + +describe('FileUpload', () => { + it('should accept a ref', () => { + const ref = createRef(); + + renderComponent({ ref }); + + expect(ref.current).toBe(screen.getByTestId(ROOT_TEST_ID)); + }); + + it('should merge a custom class name with the default ones', () => { + renderComponent({ className: 'foo' }); + + expect(screen.getByTestId(ROOT_TEST_ID)).toHaveClass('foo'); + }); + + it('should apply custom styles', () => { + renderComponent({ style: { padding: 20 } }); + + expect(screen.getByTestId(ROOT_TEST_ID)).toHaveStyle({ padding: '20px' }); + }); + + it('renders the browse link when empty', () => { + renderComponent(); + + expect(screen.getByText('select a file')).toBeInTheDocument(); + }); + + it('adds the selected file to the list (single)', async () => { + const user = userEvent.setup(); + + renderComponent(); + + await user.upload(getFileInput(), makeFile('hello.txt')); + + expect(screen.getByText('hello.txt')).toBeInTheDocument(); + // in single mode the browse link is hidden once a file is selected + expect(screen.queryByText('select a file')).not.toBeInTheDocument(); + }); + + it('shows the file size when showFileSize is set', () => { + renderComponent({ + showFileSize: true, + defaultValue: [makeItem('report.pdf', 148909)], + }); + + expect(screen.getByText('145.42 KB')).toBeInTheDocument(); + }); + + it('appends files in multiple mode', async () => { + const user = userEvent.setup(); + + renderComponent({ allowsMultiple: true }); + + await user.upload(getFileInput(), [makeFile('a.txt')]); + await user.upload(getFileInput(), [makeFile('b.txt')]); + + expect(screen.getByText('a.txt')).toBeInTheDocument(); + expect(screen.getByText('b.txt')).toBeInTheDocument(); + }); + + it('removes a file via the remove button and calls onRemove', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + + renderComponent({ + onRemove, + defaultValue: [makeItem('remove-me.txt')], + }); + + await user.click(screen.getByRole('button', { name: /remove/i })); + + expect(screen.queryByText('remove-me.txt')).not.toBeInTheDocument(); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('restores focus to the browse link after removing the last file', async () => { + const user = userEvent.setup(); + + renderComponent({ defaultValue: [makeItem('only.txt')] }); + + await user.click(screen.getByRole('button', { name: /remove/i })); + + await waitFor(() => { + expect(screen.getByText('select a file')).toHaveFocus(); + }); + }); + + it('moves focus to the next file after removing a middle one', async () => { + const user = userEvent.setup(); + + renderComponent({ + allowsMultiple: true, + defaultValue: [ + makeItem('first.txt'), + makeItem('second.txt'), + makeItem('third.txt'), + ], + }); + + await user.click( + screen.getByRole('button', { name: /remove second\.txt/i }) + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /remove third\.txt/i }) + ).toHaveFocus(); + }); + }); + + it('supports controlled value with onChange', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + function Controlled() { + const [items, setItems] = useState([]); + + return ( + { + onChange(next); + setItems(next); + }} + /> + ); + } + + render(); + + await user.upload(getFileInput(), [makeFile('controlled.txt')]); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(screen.getByText('controlled.txt')).toBeInTheDocument(); + }); + + it('calls onAdd when files are added', async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + + renderComponent({ onAdd }); + + await user.upload(getFileInput(), makeFile('added.txt')); + + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it('does not add files when disabled', async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + + renderComponent({ isDisabled: true, onAdd }); + + await user.upload(getFileInput(), makeFile('nope.txt')); + + expect(screen.queryByText('nope.txt')).not.toBeInTheDocument(); + expect(onAdd).not.toHaveBeenCalled(); + }); + + it('opens the file dialog when the browse link is pressed', async () => { + const user = userEvent.setup(); + + renderComponent(); + + const clickSpy = vi + .spyOn(getFileInput(), 'click') + .mockImplementation(() => undefined); + + await user.click(screen.getByText('select a file')); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('supports explicit compound composition (uncontrolled)', async () => { + const user = userEvent.setup(); + + render( + + + + browse + + + ); + + expect(screen.getByText('a.txt')).toBeInTheDocument(); + expect(screen.getByText('browse')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /remove/i })); + + expect(screen.queryByText('a.txt')).not.toBeInTheDocument(); + }); + + it('localizes strings via the Provider locale', () => { + render( + + + + ); + + expect(screen.getByText('выберите файл')).toBeInTheDocument(); + }); +}); + +const dropFile = (name: string): DropItem => + ({ + kind: 'file', + type: 'text/plain', + name, + getFile: async () => makeFile(name), + getText: async () => '', + }) as unknown as DropItem; + +const dropText = (): DropItem => + ({ + kind: 'text', + types: new Set(['text/plain']), + getText: async () => 'hello', + }) as unknown as DropItem; + +describe('readDroppedFiles', () => { + it('extracts native files and ignores non-file drop items', async () => { + const files = await readDroppedFiles([ + dropFile('a.txt'), + dropText(), + dropFile('b.txt'), + ]); + + expect(files.map((file) => file.name)).toEqual(['a.txt', 'b.txt']); + }); + + it('returns an empty array when there are no files', async () => { + const files = await readDroppedFiles([dropText()]); + + expect(files).toEqual([]); + }); +}); diff --git a/packages/components/src/components/FileUpload/FileUpload.tsx b/packages/components/src/components/FileUpload/FileUpload.tsx new file mode 100644 index 000000000..b5d2be997 --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUpload.tsx @@ -0,0 +1,278 @@ +'use client'; + +import { + useRef, + useMemo, + useState, + forwardRef, + useCallback, + useLayoutEffect, +} from 'react'; +import type { Ref } from 'react'; + +import type { Key } from '@koobiq/react-core'; +import { + clsx, + useDOMRef, + useControlledState, + useNumberFormatter, + useLocalizedStringFormatter, +} from '@koobiq/react-core'; +import { DropZone } from '@koobiq/react-primitives'; + +import { formatFileSize } from '../../utils'; + +import { FileUploadDropzone } from './components/Dropzone'; +import { FileUploadItemComponent } from './components/Item'; +import { FileUploadList } from './components/List'; +import { FileUploadTrigger } from './components/Trigger'; +import s from './FileUpload.module.css'; +import { FileUploadContext } from './FileUploadContext'; +import type { FileUploadContextValue } from './FileUploadContext'; +import intlMessages from './intl'; +import type { + FileUploadItem, + FileUploadProps, + FileUploadMessages, +} from './types'; +import { readDroppedFiles, createFileUploadItem } from './utils'; + +const EMPTY_ITEMS: FileUploadItem[] = []; + +type PendingFocus = { type: 'item'; id: Key } | { type: 'trigger' } | null; + +function FileUploadRender(props: FileUploadProps, ref?: Ref) { + const { + accept, + onAdd, + style, + value, + onChange, + onRemove, + children, + className, + size = 'default', + allowed = 'file', + defaultValue, + isDisabled = false, + allowsMultiple = false, + 'data-testid': testId, + showFileSize: showFileSizeProp, + directoryMode = 'as-item', + ...other + } = props; + + const domRef = useDOMRef(ref); + + const [items, setItems] = useControlledState( + value, + defaultValue ?? EMPTY_ITEMS, + onChange + ); + + const [isDropTarget, setIsDropTarget] = useState(false); + + const showFileSize = showFileSizeProp ?? allowsMultiple; + + const stringFormatter = useLocalizedStringFormatter(intlMessages); + const numberFormatter = useNumberFormatter({ maximumFractionDigits: 2 }); + + const messages = useMemo( + () => ({ + or: stringFormatter.format('or'), + addMore: stringFormatter.format('addMore'), + dropHere: stringFormatter.format('dropHere'), + browseFile: stringFormatter.format('browseFile'), + browseFiles: stringFormatter.format('browseFiles'), + browseFolder: stringFormatter.format('browseFolder'), + browseFilesOrFolder: stringFormatter.format('browseFilesOrFolder'), + removeButtonLabel: stringFormatter.format('removeButtonLabel'), + uploadingLabel: stringFormatter.format('uploadingLabel'), + bytes: stringFormatter.format('bytes'), + kilobytes: stringFormatter.format('kilobytes'), + megabytes: stringFormatter.format('megabytes'), + gigabytes: stringFormatter.format('gigabytes'), + terabytes: stringFormatter.format('terabytes'), + }), + [stringFormatter] + ); + + const itemRefs = useRef(new Map()); + const triggerRef = useRef(null); + const pendingFocus = useRef(null); + + const registerItemRef = useCallback( + (id: Key, element: HTMLElement | null) => { + if (element) { + itemRefs.current.set(id, element); + } else { + itemRefs.current.delete(id); + } + }, + [] + ); + + const setTriggerRef = useCallback((element: HTMLElement | null) => { + triggerRef.current = element; + }, []); + + const formatSize = useCallback( + (bytes: number) => + formatFileSize(bytes, { + formatNumber: (fileSize) => numberFormatter.format(fileSize), + unitLabels: { + B: messages.bytes, + KB: messages.kilobytes, + MB: messages.megabytes, + GB: messages.gigabytes, + TB: messages.terabytes, + }, + }), + [numberFormatter, messages] + ); + + const addFiles = useCallback( + (files: File[]) => { + if (isDisabled || files.length === 0) return; + + const added = files.map(createFileUploadItem); + const next = allowsMultiple ? [...items, ...added] : added.slice(0, 1); + + setItems(next); + onAdd?.(allowsMultiple ? added : next, next); + }, + [isDisabled, allowsMultiple, items, setItems, onAdd] + ); + + const removeItem = useCallback( + (id: Key) => { + const index = items.findIndex((item) => item.id === id); + + if (index < 0) return; + + const removed = items[index]; + const next = items.filter((item) => item.id !== id); + const neighbor = items[index + 1] ?? items[index - 1]; + + pendingFocus.current = neighbor + ? { type: 'item', id: neighbor.id } + : { type: 'trigger' }; + + setItems(next); + onRemove?.(removed, index, next); + }, + [items, setItems, onRemove] + ); + + useLayoutEffect(() => { + const pending = pendingFocus.current; + + if (!pending) return; + + pendingFocus.current = null; + + if (pending.type === 'item') { + itemRefs.current.get(pending.id)?.focus(); + } else { + triggerRef.current?.focus(); + } + }, [items]); + + const contextValue = useMemo( + () => ({ + items, + accept, + allowed, + size, + directoryMode, + showFileSize, + isDisabled, + isDropTarget, + allowsMultiple, + addFiles, + removeItem, + registerItemRef, + setTriggerRef, + messages, + formatSize, + }), + [ + items, + accept, + allowed, + size, + directoryMode, + showFileSize, + isDisabled, + isDropTarget, + allowsMultiple, + addFiles, + removeItem, + registerItemRef, + setTriggerRef, + messages, + formatSize, + ] + ); + + const isEmpty = items.length === 0; + + return ( + + 'copy'} + onDropEnter={() => setIsDropTarget(true)} + onDropExit={() => setIsDropTarget(false)} + onDrop={async (event) => { + setIsDropTarget(false); + + const files = await readDroppedFiles(event.items); + + if (files.length > 0) { + addFiles(files); + } + }} + > + {children ?? ( + <> + {!isEmpty && } + {(isEmpty || allowsMultiple) && } + + )} + + + ); +} + +const FileUploadComponent = forwardRef(FileUploadRender); + +type CompoundedComponent = typeof FileUploadComponent & { + Dropzone: typeof FileUploadDropzone; + Trigger: typeof FileUploadTrigger; + List: typeof FileUploadList; + Item: typeof FileUploadItemComponent; +}; + +/** + * `FileUpload` lets users pick, display and manage a list of files. It handles + * selection (system dialog and drag-and-drop), the file list and removal, but + * never uploads anything: the consumer drives loading/error state from the + * outside via the controlled `value`. + */ +export const FileUpload = FileUploadComponent as CompoundedComponent; + +FileUpload.Dropzone = FileUploadDropzone; +FileUpload.Trigger = FileUploadTrigger; +FileUpload.List = FileUploadList; +FileUpload.Item = FileUploadItemComponent; + +FileUpload.displayName = 'FileUpload'; diff --git a/packages/components/src/components/FileUpload/FileUploadContext.ts b/packages/components/src/components/FileUpload/FileUploadContext.ts new file mode 100644 index 000000000..493ea020e --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUploadContext.ts @@ -0,0 +1,55 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +import type { Key } from '@koobiq/react-core'; + +import type { + FileUploadItem, + FileUploadMessages, + FileUploadPropSize, + FileUploadPropAllowed, + FileUploadPropDirectoryMode, +} from './types'; + +export type FileUploadContextValue = { + /** The current list of selected files. */ + items: FileUploadItem[]; + allowsMultiple: boolean; + accept?: string[]; + allowed: FileUploadPropAllowed; + directoryMode: FileUploadPropDirectoryMode; + size: FileUploadPropSize; + showFileSize: boolean; + isDisabled: boolean; + /** Whether a drag is currently over the drop zone. */ + isDropTarget: boolean; + /** Wrap the given native files into items and add them to the list. */ + addFiles: (files: File[]) => void; + /** Remove the item with the given id and restore focus. */ + removeItem: (id: Key) => void; + /** Register the remove-button element of an item for focus restoration. */ + registerItemRef: (id: Key, element: HTMLElement | null) => void; + /** Register the browse-link element for focus restoration. */ + setTriggerRef: (element: HTMLElement | null) => void; + /** Resolved localized strings. */ + messages: FileUploadMessages; + /** Locale-aware file-size formatter. */ + formatSize: (bytes: number) => string; +}; + +export const FileUploadContext = createContext( + null +); + +export const useFileUploadContext = (): FileUploadContextValue => { + const context = useContext(FileUploadContext); + + if (context === null) { + throw new Error( + 'FileUpload: compound components must be rendered inside a .' + ); + } + + return context; +}; diff --git a/packages/components/src/components/FileUpload/components/Dropzone/Dropzone.module.css b/packages/components/src/components/FileUpload/components/Dropzone/Dropzone.module.css new file mode 100644 index 000000000..961983b07 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Dropzone/Dropzone.module.css @@ -0,0 +1,59 @@ +.base { + display: flex; + box-sizing: border-box; + align-items: center; + gap: var(--kbq-size-xs); + color: var(--kbq-foreground-contrast-secondary); + border-radius: var(--kbq-size-xs); + border: 1px dashed var(--kbq-line-contrast-fade); + transition: border-color var(--kbq-transition-default); +} + +.empty { + flex-direction: column; + justify-content: center; + text-align: center; + min-block-size: 120px; + padding: var(--kbq-size-l); +} + +/* In multiple mode the empty drop area is taller (spec ~192px). */ +.empty[data-multiple] { + min-block-size: 192px; +} + +.add-more, +.addMore { + justify-content: flex-start; + min-block-size: auto; + padding-block: var(--kbq-size-xs); + padding-inline: var(--kbq-size-l); +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + font-size: var(--kbq-size-xl); + color: var(--kbq-icon-contrast-fade); +} + +.text { + color: var(--kbq-foreground-contrast-secondary); +} + +.base[data-drop-target] { + color: var(--kbq-foreground-theme); + border-color: var(--kbq-states-line-focus-theme); + background-color: var(--kbq-background-theme-fade); +} + +.base[data-drop-target] .icon, +.base[data-drop-target] .text { + color: var(--kbq-foreground-theme); +} + +.base[data-disabled] { + opacity: 0.5; + pointer-events: none; +} diff --git a/packages/components/src/components/FileUpload/components/Dropzone/Dropzone.tsx b/packages/components/src/components/FileUpload/components/Dropzone/Dropzone.tsx new file mode 100644 index 000000000..6b8be203d --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Dropzone/Dropzone.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { forwardRef } from 'react'; + +import { clsx, useDOMRef } from '@koobiq/react-core'; +import { IconArrowUpFromBracket16 } from '@koobiq/react-icons'; + +import { useFileUploadContext } from '../../FileUploadContext'; +import type { FileUploadDropzoneProps } from '../../types'; +import { FileUploadTrigger } from '../Trigger'; + +import s from './Dropzone.module.css'; + +/** + * The upload area. Shows the empty state with the browse link when nothing is + * selected, and a compact "add more" strip once the list has items. + * + * The actual drop handling lives on the `FileUpload` root (the whole component + * is the drop zone); this part only reflects the drag state via + * `data-drop-target` for styling. + */ +export const FileUploadDropzone = forwardRef< + HTMLDivElement, + FileUploadDropzoneProps +>((props, ref) => { + const { children, className, style, 'data-testid': testId, ...other } = props; + + const { items, messages, isDisabled, isDropTarget, allowsMultiple } = + useFileUploadContext(); + + const domRef = useDOMRef(ref); + const isAddMore = items.length > 0; + + return ( +
+ {children ?? ( + <> + {!isAddMore && ( + + )} + + {isAddMore + ? messages.addMore + : `${messages.dropHere} ${messages.or}`}{' '} + + + + )} +
+ ); +}); + +FileUploadDropzone.displayName = 'FileUpload.Dropzone'; diff --git a/packages/components/src/components/FileUpload/components/Dropzone/index.ts b/packages/components/src/components/FileUpload/components/Dropzone/index.ts new file mode 100644 index 000000000..35202a669 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Dropzone/index.ts @@ -0,0 +1 @@ +export * from './Dropzone'; diff --git a/packages/components/src/components/FileUpload/components/Item/Item.module.css b/packages/components/src/components/FileUpload/components/Item/Item.module.css new file mode 100644 index 000000000..027967443 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Item/Item.module.css @@ -0,0 +1,62 @@ +@import url('../../../../styles/mixins.css'); + +.base { + display: flex; + align-items: center; + box-sizing: border-box; + gap: var(--kbq-size-xxs); + block-size: var(--kbq-size-3xl); + color: var(--kbq-foreground-contrast); + border-radius: var(--kbq-size-xxs); + padding-inline: var(--kbq-size-xs); + transition: background-color var(--kbq-transition-default); +} + +.base[data-hovered] { + background-color: var(--kbq-states-background-contrast-fade-hover); +} + +.base[data-invalid] { + color: var(--kbq-foreground-error); +} + +.base[data-disabled] { + color: var(--kbq-states-foreground-disabled); +} + +.icon { + display: flex; + flex: none; + align-items: center; + color: var(--kbq-icon-contrast-fade); +} + +.content { + display: flex; + flex: 1 1 auto; + min-inline-size: 0; + align-items: center; + gap: var(--kbq-size-xs); + justify-content: space-between; +} + +.name { + @mixin ellipsis; + + min-inline-size: 0; +} + +.size { + flex: none; + white-space: nowrap; + color: var(--kbq-foreground-contrast-secondary); +} + +.error { + flex: none; + color: var(--kbq-foreground-error); +} + +.remove { + flex: none; +} diff --git a/packages/components/src/components/FileUpload/components/Item/Item.tsx b/packages/components/src/components/FileUpload/components/Item/Item.tsx new file mode 100644 index 000000000..49e9f6895 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Item/Item.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { + clsx, + isNotNil, + mergeProps, + useHover, + useFocusRing, +} from '@koobiq/react-core'; +import { IconFileO16, IconFolder16, IconXmarkS16 } from '@koobiq/react-icons'; + +import { IconButton } from '../../../IconButton'; +import { useFileUploadContext } from '../../FileUploadContext'; +import type { FileUploadItemProps } from '../../types'; +import { getItemName, getItemSize } from '../../utils'; + +import s from './Item.module.css'; + +/** A single row in the file list: icon, name, size, and a remove button. */ +export const FileUploadItemComponent = (props: FileUploadItemProps) => { + const { item, children, className, style, 'data-testid': testId } = props; + + const { + messages, + formatSize, + removeItem, + showFileSize, + registerItemRef, + isDisabled: groupDisabled, + } = useFileUploadContext(); + + const isDisabled = groupDisabled || item.isDisabled || false; + const isInvalid = isNotNil(item.errorMessage); + const isLoading = item.isLoading || false; + + const { hoverProps, isHovered } = useHover({ isDisabled }); + const { focusProps, isFocusVisible } = useFocusRing({ within: true }); + + const name = getItemName(item); + const size = getItemSize(item); + + const defaultIcon = + item.type === 'folder' ? : ; + + return ( +
  • + + + {children ?? ( + <> + {name} + {showFileSize && isNotNil(size) && ( + {formatSize(size)} + )} + + )} + + {isInvalid && {item.errorMessage}} + registerItemRef(item.id, element)} + onPress={() => removeItem(item.id)} + > + + +
  • + ); +}; + +FileUploadItemComponent.displayName = 'FileUpload.Item'; diff --git a/packages/components/src/components/FileUpload/components/Item/index.ts b/packages/components/src/components/FileUpload/components/Item/index.ts new file mode 100644 index 000000000..c924835a0 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Item/index.ts @@ -0,0 +1 @@ +export * from './Item'; diff --git a/packages/components/src/components/FileUpload/components/List/List.module.css b/packages/components/src/components/FileUpload/components/List/List.module.css new file mode 100644 index 000000000..bc8cf66f8 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/List.module.css @@ -0,0 +1,13 @@ +.base { + margin: 0; + padding: 0; + display: flex; + list-style: none; + min-inline-size: 0; + flex-direction: column; + gap: var(--kbq-size-3xs); + + /* Tall lists scroll; override the cap via `style`/`className` on the List. */ + overflow-y: auto; + max-block-size: 320px; +} diff --git a/packages/components/src/components/FileUpload/components/List/List.tsx b/packages/components/src/components/FileUpload/components/List/List.tsx new file mode 100644 index 000000000..9d5da7b86 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/List.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { forwardRef } from 'react'; + +import { clsx, useDOMRef } from '@koobiq/react-core'; + +import { useFileUploadContext } from '../../FileUploadContext'; +import type { FileUploadListProps } from '../../types'; +import { FileUploadItemComponent } from '../Item'; + +import s from './List.module.css'; + +/** + * The list of selected files. With no children it renders the default + * `FileUpload.Item` for each item from the context (works for both controlled + * and uncontrolled usage); pass children to compose the rows yourself. + */ +export const FileUploadList = forwardRef( + (props, ref) => { + const { + children, + className, + style, + 'data-testid': testId, + ...other + } = props; + + const { items } = useFileUploadContext(); + const domRef = useDOMRef(ref); + + return ( +
      + {children ?? + items.map((item) => ( + + ))} +
    + ); + } +); + +FileUploadList.displayName = 'FileUpload.List'; diff --git a/packages/components/src/components/FileUpload/components/List/index.ts b/packages/components/src/components/FileUpload/components/List/index.ts new file mode 100644 index 000000000..4994c1813 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/index.ts @@ -0,0 +1 @@ +export * from './List'; diff --git a/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx b/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx new file mode 100644 index 000000000..f3559bb0e --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { forwardRef } from 'react'; + +import { FileTrigger } from '@koobiq/react-primitives'; + +import { Link } from '../../../Link'; +import { useFileUploadContext } from '../../FileUploadContext'; +import type { FileUploadTriggerProps } from '../../types'; +import { resolveBrowseText } from '../../utils'; + +/** + * The browse link that opens the system file dialog. Built on React Aria's + * `FileTrigger`; the ref points to the underlying hidden file input. + */ +export const FileUploadTrigger = forwardRef< + HTMLInputElement, + FileUploadTriggerProps +>((props, ref) => { + const { children, className, style, 'data-testid': testId } = props; + + const { + accept, + allowed, + isDisabled, + addFiles, + allowsMultiple, + messages, + setTriggerRef, + } = useFileUploadContext(); + + const label = + children ?? resolveBrowseText(allowed, allowsMultiple, messages); + + return ( + { + if (files) { + addFiles(Array.from(files)); + } + }} + > + + {label} + + + ); +}); + +FileUploadTrigger.displayName = 'FileUpload.Trigger'; diff --git a/packages/components/src/components/FileUpload/components/Trigger/index.ts b/packages/components/src/components/FileUpload/components/Trigger/index.ts new file mode 100644 index 000000000..7247adb3f --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Trigger/index.ts @@ -0,0 +1 @@ +export * from './Trigger'; diff --git a/packages/components/src/components/FileUpload/components/index.ts b/packages/components/src/components/FileUpload/components/index.ts new file mode 100644 index 000000000..404753cb7 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/index.ts @@ -0,0 +1,4 @@ +export * from './Dropzone'; +export * from './Trigger'; +export * from './List'; +export * from './Item'; diff --git a/packages/components/src/components/FileUpload/index.ts b/packages/components/src/components/FileUpload/index.ts new file mode 100644 index 000000000..65cdd6c85 --- /dev/null +++ b/packages/components/src/components/FileUpload/index.ts @@ -0,0 +1,4 @@ +export * from './FileUpload'; +export * from './types'; +export { useFileUploadContext } from './FileUploadContext'; +export type { FileUploadContextValue } from './FileUploadContext'; diff --git a/packages/components/src/components/FileUpload/intl.ts b/packages/components/src/components/FileUpload/intl.ts new file mode 100644 index 000000000..05584ddb3 --- /dev/null +++ b/packages/components/src/components/FileUpload/intl.ts @@ -0,0 +1,34 @@ +export default { + 'ru-RU': { + dropHere: 'Перетащите сюда', + or: 'или', + browseFile: 'выберите файл', + browseFiles: 'выберите файлы', + browseFolder: 'выберите папку', + browseFilesOrFolder: 'выберите файлы или папку', + addMore: 'Перетащите ещё или', + removeButtonLabel: 'Удалить', + uploadingLabel: 'Загрузка…', + bytes: 'Б', + kilobytes: 'КБ', + megabytes: 'МБ', + gigabytes: 'ГБ', + terabytes: 'ТБ', + }, + 'en-US': { + dropHere: 'Drop files here', + or: 'or', + browseFile: 'select a file', + browseFiles: 'select files', + browseFolder: 'select a folder', + browseFilesOrFolder: 'select files or a folder', + addMore: 'Drop more or', + removeButtonLabel: 'Remove', + uploadingLabel: 'Uploading…', + bytes: 'B', + kilobytes: 'KB', + megabytes: 'MB', + gigabytes: 'GB', + terabytes: 'TB', + }, +} as unknown as Record>; diff --git a/packages/components/src/components/FileUpload/types.ts b/packages/components/src/components/FileUpload/types.ts new file mode 100644 index 000000000..8e563445b --- /dev/null +++ b/packages/components/src/components/FileUpload/types.ts @@ -0,0 +1,180 @@ +import type { + Ref, + ReactNode, + ComponentRef, + CSSProperties, + ComponentPropsWithRef, +} from 'react'; + +import type { Key, ExtendableProps } from '@koobiq/react-core'; + +export const fileUploadPropSize = ['default', 'compact'] as const; + +export type FileUploadPropSize = (typeof fileUploadPropSize)[number]; + +export const fileUploadPropAllowed = ['file', 'folder', 'mixed'] as const; + +export type FileUploadPropAllowed = (typeof fileUploadPropAllowed)[number]; + +export const fileUploadPropDirectoryMode = ['as-item', 'contents'] as const; + +export type FileUploadPropDirectoryMode = + (typeof fileUploadPropDirectoryMode)[number]; + +/** + * A single entry in the file upload list. + * + * The component owns `id`/`file` and derives display values from the native + * `File`. The consumer drives `isLoading`/`progress`/`errorMessage` from the + * outside — `FileUpload` never uploads anything itself. + */ +export type FileUploadItem = { + /** Stable unique key, generated when a file is added. */ + id: Key; + /** The native File — the source of `name`/`size`/`type` by default. */ + file: File; + /** Overrides the displayed name (defaults to `file.name`). */ + name?: string; + /** Overrides the displayed size in bytes (defaults to `file.size`). */ + size?: number; + /** Whether the entry represents a folder (affects the default icon). */ + type?: 'file' | 'folder'; + /** Show the loading spinner (spec "Загрузка/Progress"). Consumer-driven. */ + isLoading?: boolean; + /** Determinate progress `0..100`; `undefined` while loading = indeterminate. */ + progress?: number; + /** Per-item error message — puts the row into the Invalid state. */ + errorMessage?: ReactNode; + /** Custom icon for the row (e.g. an image thumbnail). */ + icon?: ReactNode; + /** Whether this row is disabled. */ + isDisabled?: boolean; +}; + +/** Localizable strings used by `FileUpload` (aria labels, size units, captions). */ +export type FileUploadMessages = { + dropHere: string; + or: string; + browseFile: string; + browseFiles: string; + browseFolder: string; + browseFilesOrFolder: string; + addMore: string; + removeButtonLabel: string; + uploadingLabel: string; + bytes: string; + kilobytes: string; + megabytes: string; + gigabytes: string; + terabytes: string; +}; + +type FileUploadDOMProps = Omit< + ComponentPropsWithRef<'div'>, + 'children' | 'defaultValue' | 'onChange' | 'ref' +>; + +type FileUploadBaseProps = { + /** + * Whether more than one file can be selected. + * @default false + */ + allowsMultiple?: boolean; + /** Accepted file types (mime types or extensions). */ + accept?: string[]; + /** + * Which kind of items can be selected. + * @default 'file' + */ + allowed?: FileUploadPropAllowed; + /** + * When a folder is selected, add it as a single item or expand its contents. + * @default 'as-item' + */ + directoryMode?: FileUploadPropDirectoryMode; + /** The selected files (controlled). */ + value?: FileUploadItem[]; + /** The initial selected files (uncontrolled). */ + defaultValue?: FileUploadItem[]; + /** Handler called whenever the list changes (add or remove). */ + onChange?: (items: FileUploadItem[]) => void; + /** Handler called when files are added. */ + onAdd?: (added: FileUploadItem[], all: FileUploadItem[]) => void; + /** Handler called when a file is removed. */ + onRemove?: ( + removed: FileUploadItem, + index: number, + all: FileUploadItem[] + ) => void; + /** + * The layout size. + * @default 'default' + */ + size?: FileUploadPropSize; + /** Whether to show the file size in the list (defaults to `allowsMultiple`). */ + showFileSize?: boolean; + /** Whether the whole component is disabled. */ + isDisabled?: boolean; + /** Composition of `FileUpload.*` parts. Falls back to the default layout. */ + children?: ReactNode; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; + /** Ref to the root element. */ + ref?: Ref; +}; + +export type FileUploadProps = ExtendableProps< + FileUploadBaseProps, + FileUploadDOMProps +>; + +export type FileUploadRef = ComponentRef<'div'>; + +type FileUploadSlotBaseProps = { + /** The content of the component. */ + children?: ReactNode; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; +} & Omit, 'children'>; + +export type FileUploadDropzoneProps = ExtendableProps< + FileUploadSlotBaseProps<'div'>, + object +>; + +export type FileUploadListProps = ExtendableProps< + FileUploadSlotBaseProps<'ul'>, + object +>; + +export type FileUploadTriggerProps = { + /** The visible label of the browse link. Defaults to a localized string. */ + children?: ReactNode; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; +}; + +export type FileUploadItemProps = { + /** The item to render. */ + item: FileUploadItem; + /** Custom content, overrides the default name rendering. */ + children?: ReactNode; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** Unique identifier for testing purposes. */ + 'data-testid'?: string | number; +}; diff --git a/packages/components/src/components/FileUpload/utils.ts b/packages/components/src/components/FileUpload/utils.ts new file mode 100644 index 000000000..a7aa3d99d --- /dev/null +++ b/packages/components/src/components/FileUpload/utils.ts @@ -0,0 +1,46 @@ +import { isFileDropItem } from '@koobiq/react-primitives'; +import type { DropItem } from '@koobiq/react-primitives'; + +import type { + FileUploadItem, + FileUploadMessages, + FileUploadPropAllowed, +} from './types'; + +let uid = 0; + +/** Wraps a native `File` into a `FileUploadItem` with a unique id. */ +export const createFileUploadItem = (file: File): FileUploadItem => { + uid += 1; + + return { id: `kbq-file-upload-${uid}`, file }; +}; + +/** The name to display for an item (explicit override, then the file name). */ +export const getItemName = (item: FileUploadItem): string => + item.name ?? item.file?.name ?? ''; + +/** The size in bytes to display for an item (override, then the file size). */ +export const getItemSize = (item: FileUploadItem): number | undefined => + item.size ?? item.file?.size; + +/** Extracts native files from React Aria drop items (non-file items ignored). */ +export const readDroppedFiles = (items: DropItem[]): Promise => + Promise.all(items.filter(isFileDropItem).map((item) => item.getFile())); + +/** The browse-link label for the current selection mode. */ +export const resolveBrowseText = ( + allowed: FileUploadPropAllowed, + allowsMultiple: boolean, + messages: FileUploadMessages +): string => { + if (allowed === 'folder') { + return messages.browseFolder; + } + + if (allowed === 'mixed') { + return messages.browseFilesOrFolder; + } + + return allowsMultiple ? messages.browseFiles : messages.browseFile; +}; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index 9cb9d87c8..730c19092 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -54,6 +54,7 @@ export * from './ActionsPanel'; export * from './Tree'; export * from './TreeSelect'; export * from './EmptyState'; +export * from './FileUpload'; export * from './layout'; export { useListData, diff --git a/packages/components/src/utils/formatFileSize/formatFileSize.test.ts b/packages/components/src/utils/formatFileSize/formatFileSize.test.ts new file mode 100644 index 000000000..613edce06 --- /dev/null +++ b/packages/components/src/utils/formatFileSize/formatFileSize.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { formatFileSize } from './formatFileSize'; + +describe('formatFileSize', () => { + it('formats bytes below 1 KB in bytes', () => { + expect(formatFileSize(512)).toBe('512 B'); + }); + + it('scales to the largest unit that keeps the value >= 1', () => { + expect(formatFileSize(1024)).toBe('1 KB'); + expect(formatFileSize(1024 * 1024)).toBe('1 MB'); + expect(formatFileSize(1024 ** 3)).toBe('1 GB'); + expect(formatFileSize(1024 ** 4)).toBe('1 TB'); + }); + + it('caps at the largest known unit', () => { + expect(formatFileSize(1024 ** 6)).toContain('TB'); + }); + + it('rounds to two fraction digits by default', () => { + // 148909 B / 1024 = 145.4189 -> 145.42 + expect(formatFileSize(148909)).toBe('145.42 KB'); + }); + + it('treats invalid or negative input as 0 bytes', () => { + expect(formatFileSize(0)).toBe('0 B'); + expect(formatFileSize(-100)).toBe('0 B'); + expect(formatFileSize(Number.NaN)).toBe('0 B'); + }); + + it('uses custom unit labels', () => { + expect( + formatFileSize(1024, { + unitLabels: { KB: 'КБ' }, + }) + ).toBe('1 КБ'); + }); + + it('uses a custom number formatter', () => { + expect( + formatFileSize(1536, { + formatNumber: (value) => value.toFixed(1), + }) + ).toBe('1.5 KB'); + }); +}); diff --git a/packages/components/src/utils/formatFileSize/formatFileSize.ts b/packages/components/src/utils/formatFileSize/formatFileSize.ts new file mode 100644 index 000000000..34cf8f880 --- /dev/null +++ b/packages/components/src/utils/formatFileSize/formatFileSize.ts @@ -0,0 +1,64 @@ +export const fileSizeUnit = ['B', 'KB', 'MB', 'GB', 'TB'] as const; + +export type FileSizeUnit = (typeof fileSizeUnit)[number]; + +export type FileSizeUnitLabels = Record; + +export type FormatFileSizeOptions = { + /** + * Locale-aware number formatter, e.g. the `format` method from React Aria's + * `useNumberFormatter`. When omitted, a default `Intl.NumberFormat` is used. + */ + formatNumber?: (value: number) => string; + /** Localized unit labels. Defaults to English abbreviations (`B`, `KB`, …). */ + unitLabels?: Partial; + /** + * Maximum fraction digits, used only when `formatNumber` is not provided. + * @default 2 + */ + maximumFractionDigits?: number; +}; + +const defaultUnitLabels: FileSizeUnitLabels = { + B: 'B', + KB: 'KB', + MB: 'MB', + GB: 'GB', + TB: 'TB', +}; + +const BASE = 1024; + +/** + * Formats a byte count into a human-readable file size (e.g. `145.42 KB`). + * + * The number is scaled to the largest unit that keeps the value `>= 1` (base + * 1024) and formatted with the provided locale-aware formatter. The value and + * unit are joined with a single space; render them in a `white-space: nowrap` + * container so they never wrap apart. + */ +export function formatFileSize( + bytes: number, + options: FormatFileSizeOptions = {} +): string { + const { formatNumber, unitLabels, maximumFractionDigits = 2 } = options; + const labels = { ...defaultUnitLabels, ...unitLabels }; + + const safeBytes = Number.isFinite(bytes) && bytes > 0 ? bytes : 0; + + const exponent = + safeBytes === 0 + ? 0 + : Math.min( + Math.floor(Math.log(safeBytes) / Math.log(BASE)), + fileSizeUnit.length - 1 + ); + + const value = safeBytes / BASE ** exponent; + + const formatted = formatNumber + ? formatNumber(value) + : new Intl.NumberFormat(undefined, { maximumFractionDigits }).format(value); + + return `${formatted} ${labels[fileSizeUnit[exponent]]}`; +} diff --git a/packages/components/src/utils/formatFileSize/index.ts b/packages/components/src/utils/formatFileSize/index.ts new file mode 100644 index 000000000..6c653c94b --- /dev/null +++ b/packages/components/src/utils/formatFileSize/index.ts @@ -0,0 +1 @@ +export * from './formatFileSize'; diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts index 53058fe23..dbc945e0d 100644 --- a/packages/components/src/utils/index.ts +++ b/packages/components/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './getResponsiveValue'; export * from './capitalizeFirstLetter'; export * from './isPrimitiveNode'; +export * from './formatFileSize'; diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 387d6cef1..167d76fd1 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -134,8 +134,12 @@ export { Tree, Provider, TreeItem, + DropZone, TreeContext, Collection, + FileTrigger, + isFileDropItem, + isTextDropItem, TreeItemContent, ButtonContext, TreeStateContext, @@ -143,11 +147,23 @@ export { useContextProps, useRenderProps, DEFAULT_SLOT, + isDirectoryDropItem, useSlottedContext, composeRenderProps, CollectionRendererContext, } from 'react-aria-components'; +export type { + DropItem, + FileDropItem, + TextDropItem, + DropZoneProps, + DropOperation, + FileTriggerProps, + DirectoryDropItem, + DropZoneRenderProps, +} from 'react-aria-components'; + export * from './behaviors'; export * from './components'; export { removeDataAttributes } from './utils'; From cc3ac25011bbaa8885762545222d31e97a0738e3 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 8 Jul 2026 13:49:13 +0300 Subject: [PATCH 02/30] chore(FileUpload): update implementation after review --- .../src/components/FileUpload/FileUpload.mdx | 126 ++++++++++++++++++ .../FileUpload/FileUpload.stories.tsx | 9 +- .../src/components/FileUpload/FileUpload.tsx | 31 ++--- .../FileUpload/FileUploadContext.ts | 5 +- .../FileUpload/components/Item/Item.tsx | 4 +- .../src/components/FileUpload/types.ts | 2 +- 6 files changed, 147 insertions(+), 30 deletions(-) create mode 100644 packages/components/src/components/FileUpload/FileUpload.mdx diff --git a/packages/components/src/components/FileUpload/FileUpload.mdx b/packages/components/src/components/FileUpload/FileUpload.mdx new file mode 100644 index 000000000..590b2dffa --- /dev/null +++ b/packages/components/src/components/FileUpload/FileUpload.mdx @@ -0,0 +1,126 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './FileUpload.stories'; + + + +# FileUpload + + + +FileUpload lets users pick, display and manage a list of files. It never +uploads anything itself — the consumer drives loading, progress and error +state from the outside through the controlled `value`. + +## Import + +```tsx +import { FileUpload } from '@koobiq/react-components'; +``` + +## Usage + + + +## Props + + + +## Anatomy + +With no children, `FileUpload` renders its default layout. Pass children to +compose it explicitly from its parts instead: + +```tsx + + + + Drop files here or + + +``` + +- `FileUpload.Dropzone` — the drop area with the empty state and browse link. +- `FileUpload.Trigger` — opens the native file dialog. +- `FileUpload.List` — the list of selected files. With no children it renders + a `FileUpload.Item` for every item from context. +- `FileUpload.Item` — a single row: icon, name, size and a remove button. + + + +## Single file + +By default `FileUpload` accepts a single file. Selecting a new file replaces +the current one. + + + +## Multiple files + +Set `allowsMultiple` to let users select more than one file. New files are +appended to the end of the list, and an "add more" strip stays visible below it. + + + + + +## Drag and drop + +The whole component is a drop target — drop files anywhere on it, including +onto the list and the "add more" strip. Dropped items that aren't files (e.g. +plain text) are ignored. + +## Scrolling + +The list scrolls once it grows past its height cap. Override the cap with +`style` or `className` on `FileUpload.List`. + + + +## Controlled + +Pass `value` and `onChange` to own the list from the outside — for example to +update `isLoading` / `progress` / `errorMessage` on an item as an upload +started by your app proceeds. + + + +## File size + +Set `showFileSize` to show each file's size (defaults to on when +`allowsMultiple`). Sizes are formatted for the active locale. + +## Custom content + +Replace the dropzone's text while keeping the browse trigger. + + + +## Disabled + + + +## Accessibility + +- `FileUpload.Trigger` opens the native OS file dialog, so file (and folder) + pickers behave exactly as users expect from their platform. +- The drop zone exposes a visually-hidden button so keyboard and + screen-reader users can trigger an equivalent of drag-and-drop (paste) + without a pointer. +- Each remove button's `aria-label` includes the file name. +- All visible strings and the file-size units are localized through the + active ``. + +## Roadmap + +Planned for upcoming stages, not yet implemented: + +- Per-item states (hover / focus / invalid / progress) and keyboard-driven + removal. +- `compact` size, custom row icons and long-name truncation with a tooltip. +- Folder and mixed file/folder selection (`allowed`, `directoryMode`). diff --git a/packages/components/src/components/FileUpload/FileUpload.stories.tsx b/packages/components/src/components/FileUpload/FileUpload.stories.tsx index fe40b1240..f25523852 100644 --- a/packages/components/src/components/FileUpload/FileUpload.stories.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.stories.tsx @@ -152,8 +152,7 @@ export const Composition: Story = { > - Перетащите сюда или{' '} - выберите файлы + Drop files here or select files
    ), @@ -161,15 +160,15 @@ export const Composition: Story = { /** * Custom dropzone content: replace the text and keep the browse trigger and the - * drag hint (spec "Кастомный контент в загрузчике"). + * drag hint (spec "Custom content in the uploader"). */ export const CustomContent: Story = { render: (args) => ( - Перетащите сюда ZIP-архивы с паролем + Drop password-protected ZIP archives here - или выберите файлы + or select files diff --git a/packages/components/src/components/FileUpload/FileUpload.tsx b/packages/components/src/components/FileUpload/FileUpload.tsx index b5d2be997..bb21a0e7e 100644 --- a/packages/components/src/components/FileUpload/FileUpload.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.tsx @@ -3,7 +3,6 @@ import { useRef, useMemo, - useState, forwardRef, useCallback, useLayoutEffect, @@ -14,6 +13,8 @@ import type { Key } from '@koobiq/react-core'; import { clsx, useDOMRef, + useBoolean, + useKeyedRefs, useControlledState, useNumberFormatter, useLocalizedStringFormatter, @@ -70,7 +71,8 @@ function FileUploadRender(props: FileUploadProps, ref?: Ref) { onChange ); - const [isDropTarget, setIsDropTarget] = useState(false); + const [isDropTarget, { on: onDropTarget, off: offDropTarget }] = + useBoolean(false); const showFileSize = showFileSizeProp ?? allowsMultiple; @@ -97,21 +99,10 @@ function FileUploadRender(props: FileUploadProps, ref?: Ref) { [stringFormatter] ); - const itemRefs = useRef(new Map()); + const getItemRef = useKeyedRefs(); const triggerRef = useRef(null); const pendingFocus = useRef(null); - const registerItemRef = useCallback( - (id: Key, element: HTMLElement | null) => { - if (element) { - itemRefs.current.set(id, element); - } else { - itemRefs.current.delete(id); - } - }, - [] - ); - const setTriggerRef = useCallback((element: HTMLElement | null) => { triggerRef.current = element; }, []); @@ -172,7 +163,7 @@ function FileUploadRender(props: FileUploadProps, ref?: Ref) { pendingFocus.current = null; if (pending.type === 'item') { - itemRefs.current.get(pending.id)?.focus(); + getItemRef(pending.id).current?.focus(); } else { triggerRef.current?.focus(); } @@ -191,7 +182,7 @@ function FileUploadRender(props: FileUploadProps, ref?: Ref) { allowsMultiple, addFiles, removeItem, - registerItemRef, + getItemRef, setTriggerRef, messages, formatSize, @@ -208,7 +199,7 @@ function FileUploadRender(props: FileUploadProps, ref?: Ref) { allowsMultiple, addFiles, removeItem, - registerItemRef, + getItemRef, setTriggerRef, messages, formatSize, @@ -230,10 +221,10 @@ function FileUploadRender(props: FileUploadProps, ref?: Ref) { data-multiple={allowsMultiple || undefined} className={clsx(s.base, s[size], className)} getDropOperation={() => 'copy'} - onDropEnter={() => setIsDropTarget(true)} - onDropExit={() => setIsDropTarget(false)} + onDropEnter={onDropTarget} + onDropExit={offDropTarget} onDrop={async (event) => { - setIsDropTarget(false); + offDropTarget(); const files = await readDroppedFiles(event.items); diff --git a/packages/components/src/components/FileUpload/FileUploadContext.ts b/packages/components/src/components/FileUpload/FileUploadContext.ts index 493ea020e..5257828be 100644 --- a/packages/components/src/components/FileUpload/FileUploadContext.ts +++ b/packages/components/src/components/FileUpload/FileUploadContext.ts @@ -1,6 +1,7 @@ 'use client'; import { createContext, useContext } from 'react'; +import type { RefObject } from 'react'; import type { Key } from '@koobiq/react-core'; @@ -28,8 +29,8 @@ export type FileUploadContextValue = { addFiles: (files: File[]) => void; /** Remove the item with the given id and restore focus. */ removeItem: (id: Key) => void; - /** Register the remove-button element of an item for focus restoration. */ - registerItemRef: (id: Key, element: HTMLElement | null) => void; + /** The remove-button ref of an item, used to restore focus after removal. */ + getItemRef: (id: Key) => RefObject; /** Register the browse-link element for focus restoration. */ setTriggerRef: (element: HTMLElement | null) => void; /** Resolved localized strings. */ diff --git a/packages/components/src/components/FileUpload/components/Item/Item.tsx b/packages/components/src/components/FileUpload/components/Item/Item.tsx index 49e9f6895..f0a8666fc 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.tsx +++ b/packages/components/src/components/FileUpload/components/Item/Item.tsx @@ -24,8 +24,8 @@ export const FileUploadItemComponent = (props: FileUploadItemProps) => { messages, formatSize, removeItem, + getItemRef, showFileSize, - registerItemRef, isDisabled: groupDisabled, } = useFileUploadContext(); @@ -75,7 +75,7 @@ export const FileUploadItemComponent = (props: FileUploadItemProps) => { isDisabled={isDisabled} variant={isInvalid ? 'error' : 'theme'} aria-label={`${messages.removeButtonLabel} ${name}`.trim()} - ref={(element) => registerItemRef(item.id, element)} + ref={getItemRef(item.id)} onPress={() => removeItem(item.id)} > diff --git a/packages/components/src/components/FileUpload/types.ts b/packages/components/src/components/FileUpload/types.ts index 8e563445b..7aa8acd3a 100644 --- a/packages/components/src/components/FileUpload/types.ts +++ b/packages/components/src/components/FileUpload/types.ts @@ -39,7 +39,7 @@ export type FileUploadItem = { size?: number; /** Whether the entry represents a folder (affects the default icon). */ type?: 'file' | 'folder'; - /** Show the loading spinner (spec "Загрузка/Progress"). Consumer-driven. */ + /** Show the loading spinner (spec "Loading/Progress"). Consumer-driven. */ isLoading?: boolean; /** Determinate progress `0..100`; `undefined` while loading = indeterminate. */ progress?: number; From 4561ea0c3243afc84e72c1b4bb4b0a280d3835bd Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 8 Jul 2026 14:22:06 +0300 Subject: [PATCH 03/30] chore(FileUpload): update implementation after review (round 2) --- .../components/FileUpload/FileUpload.test.tsx | 16 +++++++++++++ .../FileUpload/components/Item/Item.tsx | 24 +++++++++++++++---- .../src/components/FileUpload/types.ts | 19 ++++++--------- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/components/src/components/FileUpload/FileUpload.test.tsx b/packages/components/src/components/FileUpload/FileUpload.test.tsx index e9eeaf322..08f4d405b 100644 --- a/packages/components/src/components/FileUpload/FileUpload.test.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.test.tsx @@ -229,6 +229,22 @@ describe('FileUpload', () => { expect(screen.queryByText('a.txt')).not.toBeInTheDocument(); }); + it('forwards a ref on FileUpload.Item to its root element', () => { + const ref = createRef(); + const item = makeItem('ref-item.txt'); + + render( + + + + + + ); + + expect(ref.current).toBe(screen.getByTestId('item')); + expect(ref.current?.tagName).toBe('LI'); + }); + it('localizes strings via the Provider locale', () => { render( diff --git a/packages/components/src/components/FileUpload/components/Item/Item.tsx b/packages/components/src/components/FileUpload/components/Item/Item.tsx index f0a8666fc..fd6f1e71e 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.tsx +++ b/packages/components/src/components/FileUpload/components/Item/Item.tsx @@ -1,8 +1,11 @@ 'use client'; +import { forwardRef } from 'react'; + import { clsx, isNotNil, + useDOMRef, mergeProps, useHover, useFocusRing, @@ -17,8 +20,20 @@ import { getItemName, getItemSize } from '../../utils'; import s from './Item.module.css'; /** A single row in the file list: icon, name, size, and a remove button. */ -export const FileUploadItemComponent = (props: FileUploadItemProps) => { - const { item, children, className, style, 'data-testid': testId } = props; +export const FileUploadItemComponent = forwardRef< + HTMLLIElement, + FileUploadItemProps +>((props, ref) => { + const { + item, + children, + className, + style, + 'data-testid': testId, + ...other + } = props; + + const domRef = useDOMRef(ref); const { messages, @@ -44,7 +59,8 @@ export const FileUploadItemComponent = (props: FileUploadItemProps) => { return (
  • {
  • ); -}; +}); FileUploadItemComponent.displayName = 'FileUpload.Item'; diff --git a/packages/components/src/components/FileUpload/types.ts b/packages/components/src/components/FileUpload/types.ts index 7aa8acd3a..16aedff47 100644 --- a/packages/components/src/components/FileUpload/types.ts +++ b/packages/components/src/components/FileUpload/types.ts @@ -166,15 +166,10 @@ export type FileUploadTriggerProps = { 'data-testid'?: string | number; }; -export type FileUploadItemProps = { - /** The item to render. */ - item: FileUploadItem; - /** Custom content, overrides the default name rendering. */ - children?: ReactNode; - /** Additional CSS-classes. */ - className?: string; - /** Inline styles. */ - style?: CSSProperties; - /** Unique identifier for testing purposes. */ - 'data-testid'?: string | number; -}; +export type FileUploadItemProps = ExtendableProps< + FileUploadSlotBaseProps<'li'> & { + /** The item to render. */ + item: FileUploadItem; + }, + object +>; From 33c6ad1e010c9d0b9f00ea97e99a3779bd40b8c1 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 8 Jul 2026 14:40:06 +0300 Subject: [PATCH 04/30] chore(FileUpload): add validation example (story + docs) --- .../src/components/FileUpload/FileUpload.mdx | 9 +++++ .../FileUpload/FileUpload.stories.tsx | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/packages/components/src/components/FileUpload/FileUpload.mdx b/packages/components/src/components/FileUpload/FileUpload.mdx index 590b2dffa..3b1765353 100644 --- a/packages/components/src/components/FileUpload/FileUpload.mdx +++ b/packages/components/src/components/FileUpload/FileUpload.mdx @@ -90,6 +90,15 @@ started by your app proceeds. +## Validation + +`FileUpload` doesn't validate files itself. Check `file.type`, `file.size`, or +any custom rule in a controlled `onChange`, then set `errorMessage` on the +invalid item to show the error. `accept` only filters the system dialog, so +check dropped files the same way. + + + ## File size Set `showFileSize` to show each file's size (defaults to on when diff --git a/packages/components/src/components/FileUpload/FileUpload.stories.tsx b/packages/components/src/components/FileUpload/FileUpload.stories.tsx index f25523852..f8b0a61da 100644 --- a/packages/components/src/components/FileUpload/FileUpload.stories.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.stories.tsx @@ -122,6 +122,41 @@ export const Controlled: Story = { }, }; +const MAX_SIZE = 150_000; // 150 KB — arbitrary demo limit, compared against the file's real byte size + +const makeOversizedItem = (name: string): FileUploadItem => ({ + id: name, + file: new File([new Uint8Array(MAX_SIZE + 1)], name), + errorMessage: 'File is too large (max 150 KB)', +}); + +export const Validation: Story = { + render: function Render(args) { + const [items, setItems] = useState([ + makeOversizedItem('presentation.pptx'), + ]); + + return ( + { + setItems( + next.map((item) => + item.file.size > MAX_SIZE + ? { ...item, errorMessage: 'File is too large (max 150 KB)' } + : item + ) + ); + }} + {...args} + /> + ); + }, +}; + export const Disabled: Story = { render: (args) => ( Date: Wed, 8 Jul 2026 16:23:01 +0300 Subject: [PATCH 05/30] chore(FileUpload): add hover/focus/invalid states, progress spinner, keyboard removal --- .../src/components/FileUpload/FileUpload.mdx | 14 ++- .../FileUpload/FileUpload.stories.tsx | 57 ++++++++++++ .../components/FileUpload/FileUpload.test.tsx | 93 ++++++++++++++++++- .../src/components/FileUpload/FileUpload.tsx | 10 +- .../components/Item/Item.module.css | 19 +++- .../FileUpload/components/Item/Item.tsx | 22 ++++- 6 files changed, 205 insertions(+), 10 deletions(-) diff --git a/packages/components/src/components/FileUpload/FileUpload.mdx b/packages/components/src/components/FileUpload/FileUpload.mdx index 3b1765353..7119233a9 100644 --- a/packages/components/src/components/FileUpload/FileUpload.mdx +++ b/packages/components/src/components/FileUpload/FileUpload.mdx @@ -90,6 +90,15 @@ started by your app proceeds. +## Progress + +Set `isLoading` on an item to show a spinner in place of its icon — determinate +(an arc) when you also set `progress` (`0`–`100`), indeterminate otherwise. The +remove button stays available while an item is loading. This is an abstract +example of wiring `FileUpload` up to a real upload happening in your app. + + + ## Validation `FileUpload` doesn't validate files itself. Check `file.type`, `file.size`, or @@ -122,6 +131,9 @@ Replace the dropzone's text while keeping the browse trigger. screen-reader users can trigger an equivalent of drag-and-drop (paste) without a pointer. - Each remove button's `aria-label` includes the file name. +- Removing a file with the button, Delete, or Backspace + moves focus to the next item's remove button, the previous one if the last + item was removed, or the browse link if the list is now empty. - All visible strings and the file-size units are localized through the active ``. @@ -129,7 +141,5 @@ Replace the dropzone's text while keeping the browse trigger. Planned for upcoming stages, not yet implemented: -- Per-item states (hover / focus / invalid / progress) and keyboard-driven - removal. - `compact` size, custom row icons and long-name truncation with a tooltip. - Folder and mixed file/folder selection (`allowed`, `directoryMode`). diff --git a/packages/components/src/components/FileUpload/FileUpload.stories.tsx b/packages/components/src/components/FileUpload/FileUpload.stories.tsx index f8b0a61da..904b38f58 100644 --- a/packages/components/src/components/FileUpload/FileUpload.stories.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.stories.tsx @@ -157,6 +157,63 @@ export const Validation: Story = { }, }; +const UPLOAD_TICK = 400; + +/** Stand-in for a real network upload — FileUpload never uploads anything itself. */ +function simulateUpload(onProgress: (progress: number) => void) { + let progress = 0; + + const interval = window.setInterval(() => { + progress += 20; + onProgress(progress); + + if (progress >= 100) { + window.clearInterval(interval); + } + }, UPLOAD_TICK); +} + +export const UploadProgress: Story = { + render: function Render(args) { + const [items, setItems] = useState([]); + + return ( + { + setItems( + all.map((item) => + added.some(({ id }) => id === item.id) + ? { ...item, isLoading: true, progress: 0 } + : item + ) + ); + + added.forEach((item) => { + simulateUpload((progress) => { + setItems((prev) => + prev.map((current) => + current.id === item.id + ? { + ...current, + isLoading: progress < 100, + progress: progress < 100 ? progress : undefined, + } + : current + ) + ); + }); + }); + }} + {...args} + /> + ); + }, +}; + export const Disabled: Story = { render: (args) => ( { }); }); + it('moves focus to the previous file after removing the last one', async () => { + const user = userEvent.setup(); + + renderComponent({ + allowsMultiple: true, + defaultValue: [ + makeItem('first.txt'), + makeItem('second.txt'), + makeItem('third.txt'), + ], + }); + + await user.click( + screen.getByRole('button', { name: /remove third\.txt/i }) + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /remove second\.txt/i }) + ).toHaveFocus(); + }); + }); + + it('removes a file with the Delete key when the remove button is focused', () => { + const onRemove = vi.fn(); + + renderComponent({ + onRemove, + defaultValue: [makeItem('remove-me.txt')], + }); + + const button = screen.getByRole('button', { name: /remove/i }); + + button.focus(); + fireEvent.keyDown(button, { key: 'Delete' }); + + expect(screen.queryByText('remove-me.txt')).not.toBeInTheDocument(); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('removes a file with the Backspace key when the remove button is focused', () => { + const onRemove = vi.fn(); + + renderComponent({ + onRemove, + defaultValue: [makeItem('remove-me.txt')], + }); + + const button = screen.getByRole('button', { name: /remove/i }); + + button.focus(); + fireEvent.keyDown(button, { key: 'Backspace' }); + + expect(screen.queryByText('remove-me.txt')).not.toBeInTheDocument(); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('shows a determinate spinner and marks the row invalid/loading', () => { + renderComponent({ + defaultValue: [ + { ...makeItem('loading.txt'), isLoading: true, progress: 42 }, + ], + }); + + const progress = screen.getByRole('progressbar'); + + expect(progress).toHaveAttribute('aria-valuenow', '42'); + expect(screen.getByRole('listitem')).toHaveAttribute('data-loading'); + }); + + it('shows an indeterminate spinner when progress is not set', () => { + renderComponent({ + defaultValue: [{ ...makeItem('loading.txt'), isLoading: true }], + }); + + expect(screen.getByRole('progressbar')).not.toHaveAttribute( + 'aria-valuenow' + ); + }); + + it('marks the row invalid when errorMessage is set', () => { + renderComponent({ + defaultValue: [ + { ...makeItem('bad.txt'), errorMessage: 'Something went wrong' }, + ], + }); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByRole('listitem')).toHaveAttribute('data-invalid'); + }); + it('supports controlled value with onChange', async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/packages/components/src/components/FileUpload/FileUpload.tsx b/packages/components/src/components/FileUpload/FileUpload.tsx index bb21a0e7e..4ed6fefbb 100644 --- a/packages/components/src/components/FileUpload/FileUpload.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.tsx @@ -23,10 +23,12 @@ import { DropZone } from '@koobiq/react-primitives'; import { formatFileSize } from '../../utils'; -import { FileUploadDropzone } from './components/Dropzone'; -import { FileUploadItemComponent } from './components/Item'; -import { FileUploadList } from './components/List'; -import { FileUploadTrigger } from './components/Trigger'; +import { + FileUploadDropzone, + FileUploadItemComponent, + FileUploadList, + FileUploadTrigger, +} from './components'; import s from './FileUpload.module.css'; import { FileUploadContext } from './FileUploadContext'; import type { FileUploadContextValue } from './FileUploadContext'; diff --git a/packages/components/src/components/FileUpload/components/Item/Item.module.css b/packages/components/src/components/FileUpload/components/Item/Item.module.css index 027967443..166fe11db 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.module.css +++ b/packages/components/src/components/FileUpload/components/Item/Item.module.css @@ -1,6 +1,8 @@ @import url('../../../../styles/mixins.css'); .base { + --item-outline-color: transparent; + display: flex; align-items: center; box-sizing: border-box; @@ -9,17 +11,32 @@ color: var(--kbq-foreground-contrast); border-radius: var(--kbq-size-xxs); padding-inline: var(--kbq-size-xs); - transition: background-color var(--kbq-transition-default); + box-shadow: inset 0 0 0 1px var(--item-outline-color); + transition: + background-color var(--kbq-transition-default), + box-shadow var(--kbq-transition-default); } .base[data-hovered] { background-color: var(--kbq-states-background-contrast-fade-hover); } +.base[data-focus-visible] { + --item-outline-color: var(--kbq-states-line-focus-theme); +} + .base[data-invalid] { color: var(--kbq-foreground-error); } +.base[data-invalid][data-hovered] { + background-color: var(--kbq-states-background-error-fade-hover); +} + +.base[data-invalid][data-focus-visible] { + --item-outline-color: var(--kbq-states-line-focus-error); +} + .base[data-disabled] { color: var(--kbq-states-foreground-disabled); } diff --git a/packages/components/src/components/FileUpload/components/Item/Item.tsx b/packages/components/src/components/FileUpload/components/Item/Item.tsx index fd6f1e71e..18ae340eb 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.tsx +++ b/packages/components/src/components/FileUpload/components/Item/Item.tsx @@ -13,6 +13,7 @@ import { import { IconFileO16, IconFolder16, IconXmarkS16 } from '@koobiq/react-icons'; import { IconButton } from '../../../IconButton'; +import { ProgressSpinner } from '../../../ProgressSpinner'; import { useFileUploadContext } from '../../FileUploadContext'; import type { FileUploadItemProps } from '../../types'; import { getItemName, getItemSize } from '../../utils'; @@ -57,6 +58,17 @@ export const FileUploadItemComponent = forwardRef< const defaultIcon = item.type === 'folder' ? : ; + const icon = isLoading ? ( + + ) : ( + (item.icon ?? defaultIcon) + ); + return (
  • -
  • {children} -
  • + ); } diff --git a/packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.module.css b/packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.module.css index 5756e6e9f..5652985b7 100644 --- a/packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.module.css +++ b/packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.module.css @@ -1,5 +1,3 @@ -@import url('../../../../styles/mixins.css'); - .base { display: flex; min-inline-size: 0; @@ -17,8 +15,6 @@ } .description { - @mixin ellipsis; - min-inline-size: 0; color: var(--file-upload-caption-color); } diff --git a/packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.tsx b/packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.tsx index 62682980c..b8df4707c 100644 --- a/packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.tsx +++ b/packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.tsx @@ -17,8 +17,7 @@ export const FileUploadListEmpty = forwardRef< >((props, ref) => { const { className, style, ...other } = props; - const { size, messages, isDisabled, isDropTarget, allowsMultiple } = - useFileUploadContext(); + const { size, messages, isDisabled, allowsMultiple } = useFileUploadContext(); const domRef = useDOMRef(ref); @@ -30,7 +29,6 @@ export const FileUploadListEmpty = forwardRef< data-size={size} data-disabled={isDisabled || undefined} data-multiple={allowsMultiple || undefined} - data-drop-target={isDropTarget || undefined} className={clsx(s.base, className)} >
    ); - expect(screen.getByText('145.42 KB')).toBeInTheDocument(); + expect( + screen.getByText('145.42\u00a0KB', { normalizer: (value) => value }) + ).toBeInTheDocument(); }); it('localizes strings via the Provider locale', () => { diff --git a/packages/components/src/components/FileUpload/FileUploadContext.ts b/packages/components/src/components/FileUpload/FileUploadContext.ts index 0f24a39e3..bb416132f 100644 --- a/packages/components/src/components/FileUpload/FileUploadContext.ts +++ b/packages/components/src/components/FileUpload/FileUploadContext.ts @@ -37,6 +37,7 @@ export type FileUploadItemContextValue = { nameText?: string; isDisabled: boolean; isInvalid: boolean; + rootRef: RefObject; }; export const FileUploadContext = createContext( diff --git a/packages/components/src/components/FileUpload/components/Item/Item.tsx b/packages/components/src/components/FileUpload/components/Item/Item.tsx index 6c1b1c108..09fad539b 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.tsx +++ b/packages/components/src/components/FileUpload/components/Item/Item.tsx @@ -1,20 +1,23 @@ 'use client'; -import { forwardRef } from 'react'; +import { forwardRef, useRef } from 'react'; import type { ForwardedRef } from 'react'; import { clsx, mergeRefs, mergeProps, + setRef, useHover, useFocusRing, + useElementOverflow, } from '@koobiq/react-core'; import { IconFileO16, IconCircleXmark16 } from '@koobiq/react-icons'; import { CollectionNode, createLeafComponent } from '@koobiq/react-primitives'; import type { Node } from '@react-types/shared'; import { IconButton } from '../../../IconButton'; +import { Tooltip } from '../../../Tooltip'; import { FileUploadItemContext, useFileUploadContext, @@ -63,19 +66,21 @@ export const FileUploadItem = createLeafComponent( const { hoverProps, isHovered } = useHover({ isDisabled }); const { focusProps, isFocusVisible } = useFocusRing({ within: true }); + const rootRef = useRef(null); const contextValue = { id: node.key, nameText: node.textValue, isDisabled, isInvalid, + rootRef, }; return (
    ((props, ref) => { const { children, className, style, 'data-testid': testId, ...other } = props; + const { nameText, rootRef } = useFileUploadItemContext(); + + const { ref: overflowRef, isOverflowX } = + useElementOverflow(); return ( - ( + { + setRef(ref, element); + setRef(overflowRef, element); + setRef(tooltipRef, element); + }} + style={style} + data-testid={testId} + data-overflowing={isOverflowX || undefined} + className={clsx(s.name, className)} + > + {children} + + )} > - {children} - + {nameText ?? children} + ); }); diff --git a/packages/components/src/utils/formatFileSize/formatFileSize.test.ts b/packages/components/src/utils/formatFileSize/formatFileSize.test.ts index 613edce06..1b07856bf 100644 --- a/packages/components/src/utils/formatFileSize/formatFileSize.test.ts +++ b/packages/components/src/utils/formatFileSize/formatFileSize.test.ts @@ -4,14 +4,14 @@ import { formatFileSize } from './formatFileSize'; describe('formatFileSize', () => { it('formats bytes below 1 KB in bytes', () => { - expect(formatFileSize(512)).toBe('512 B'); + expect(formatFileSize(512)).toBe('512\u00a0B'); }); it('scales to the largest unit that keeps the value >= 1', () => { - expect(formatFileSize(1024)).toBe('1 KB'); - expect(formatFileSize(1024 * 1024)).toBe('1 MB'); - expect(formatFileSize(1024 ** 3)).toBe('1 GB'); - expect(formatFileSize(1024 ** 4)).toBe('1 TB'); + expect(formatFileSize(1024)).toBe('1\u00a0KB'); + expect(formatFileSize(1024 * 1024)).toBe('1\u00a0MB'); + expect(formatFileSize(1024 ** 3)).toBe('1\u00a0GB'); + expect(formatFileSize(1024 ** 4)).toBe('1\u00a0TB'); }); it('caps at the largest known unit', () => { @@ -20,13 +20,13 @@ describe('formatFileSize', () => { it('rounds to two fraction digits by default', () => { // 148909 B / 1024 = 145.4189 -> 145.42 - expect(formatFileSize(148909)).toBe('145.42 KB'); + expect(formatFileSize(148909)).toBe('145.42\u00a0KB'); }); it('treats invalid or negative input as 0 bytes', () => { - expect(formatFileSize(0)).toBe('0 B'); - expect(formatFileSize(-100)).toBe('0 B'); - expect(formatFileSize(Number.NaN)).toBe('0 B'); + expect(formatFileSize(0)).toBe('0\u00a0B'); + expect(formatFileSize(-100)).toBe('0\u00a0B'); + expect(formatFileSize(Number.NaN)).toBe('0\u00a0B'); }); it('uses custom unit labels', () => { @@ -34,7 +34,7 @@ describe('formatFileSize', () => { formatFileSize(1024, { unitLabels: { KB: 'КБ' }, }) - ).toBe('1 КБ'); + ).toBe('1\u00a0КБ'); }); it('uses a custom number formatter', () => { @@ -42,6 +42,6 @@ describe('formatFileSize', () => { formatFileSize(1536, { formatNumber: (value) => value.toFixed(1), }) - ).toBe('1.5 KB'); + ).toBe('1.5\u00a0KB'); }); }); diff --git a/packages/components/src/utils/formatFileSize/formatFileSize.ts b/packages/components/src/utils/formatFileSize/formatFileSize.ts index 34cf8f880..d2cf9bc6d 100644 --- a/packages/components/src/utils/formatFileSize/formatFileSize.ts +++ b/packages/components/src/utils/formatFileSize/formatFileSize.ts @@ -34,8 +34,7 @@ const BASE = 1024; * * The number is scaled to the largest unit that keeps the value `>= 1` (base * 1024) and formatted with the provided locale-aware formatter. The value and - * unit are joined with a single space; render them in a `white-space: nowrap` - * container so they never wrap apart. + * unit are joined with a non-breaking space so they never wrap apart. */ export function formatFileSize( bytes: number, @@ -60,5 +59,5 @@ export function formatFileSize( ? formatNumber(value) : new Intl.NumberFormat(undefined, { maximumFractionDigits }).format(value); - return `${formatted} ${labels[fileSizeUnit[exponent]]}`; + return `${formatted}\u00a0${labels[fileSizeUnit[exponent]]}`; } From aa706b5daace47483437cd69906020341956c952 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 14 Jul 2026 19:02:31 +0300 Subject: [PATCH 20/30] chore(FileUpload): refine list composition, layout, and filename truncation --- .../src/components/FileUpload/FileUpload.mdx | 7 +- .../FileUpload/FileUpload.module.css | 30 +---- .../FileUpload/FileUpload.stories.tsx | 32 +++-- .../components/FileUpload/FileUpload.test.tsx | 45 ++++--- .../src/components/FileUpload/FileUpload.tsx | 51 +++----- .../FileUpload/components/AddMore/index.ts | 1 - .../FileUpload/components/Control/Control.tsx | 28 ----- .../FileUpload/components/Control/index.ts | 1 - .../components/Item/Item.module.css | 1 + .../FileUpload/components/Item/Item.tsx | 22 +++- .../List.module.css} | 24 ++++ .../FileUpload/components/List/List.tsx | 115 ++++++++++++++++++ .../ListAddMore.module.css} | 0 .../AddMore.tsx => List/ListAddMore.tsx} | 6 +- .../{ListEmpty => List}/ListEmpty.module.css | 0 .../{ListEmpty => List}/ListEmpty.tsx | 0 .../FileUpload/components/List/index.ts | 3 + .../FileUpload/components/ListEmpty/index.ts | 1 - .../components/FileUpload/components/index.ts | 4 +- .../src/components/FileUpload/hooks/index.ts | 1 + .../hooks/useMiddleEllipsis.test.tsx | 84 +++++++++++++ .../FileUpload/hooks/useMiddleEllipsis.ts | 99 +++++++++++++++ .../src/components/FileUpload/types.ts | 10 +- .../src/components/FileUpload/utils.ts | 10 ++ 24 files changed, 435 insertions(+), 140 deletions(-) delete mode 100644 packages/components/src/components/FileUpload/components/AddMore/index.ts delete mode 100644 packages/components/src/components/FileUpload/components/Control/Control.tsx delete mode 100644 packages/components/src/components/FileUpload/components/Control/index.ts rename packages/components/src/components/FileUpload/components/{Control/Control.module.css => List/List.module.css} (52%) create mode 100644 packages/components/src/components/FileUpload/components/List/List.tsx rename packages/components/src/components/FileUpload/components/{AddMore/AddMore.module.css => List/ListAddMore.module.css} (100%) rename packages/components/src/components/FileUpload/components/{AddMore/AddMore.tsx => List/ListAddMore.tsx} (87%) rename packages/components/src/components/FileUpload/components/{ListEmpty => List}/ListEmpty.module.css (100%) rename packages/components/src/components/FileUpload/components/{ListEmpty => List}/ListEmpty.tsx (100%) create mode 100644 packages/components/src/components/FileUpload/components/List/index.ts delete mode 100644 packages/components/src/components/FileUpload/components/ListEmpty/index.ts create mode 100644 packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.test.tsx create mode 100644 packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.ts diff --git a/packages/components/src/components/FileUpload/FileUpload.mdx b/packages/components/src/components/FileUpload/FileUpload.mdx index 99dc524a6..0c65b520a 100644 --- a/packages/components/src/components/FileUpload/FileUpload.mdx +++ b/packages/components/src/components/FileUpload/FileUpload.mdx @@ -37,7 +37,8 @@ Pass `items` and a render function for a dynamic collection. Pass ## Long file names -Truncated item names show their full text in a tooltip. +Long item names are truncated in the middle, keeping the end of the name and +its extension visible. Their full text is shown in a tooltip. @@ -130,8 +131,8 @@ Keep the native `File` in local items and append it to `FormData` on submit. ## Customization -Use `messages` to change standard text. Use `slotProps` for field text, the -list, compact empty state, add-more area, and drop overlay. Use +Use `messages` to change standard text. Use `slotProps` for field text, list +parts, and the drop overlay. Use `renderEmptyState` to replace the large empty state. diff --git a/packages/components/src/components/FileUpload/FileUpload.module.css b/packages/components/src/components/FileUpload/FileUpload.module.css index e4cf17a34..54dd308b9 100644 --- a/packages/components/src/components/FileUpload/FileUpload.module.css +++ b/packages/components/src/components/FileUpload/FileUpload.module.css @@ -71,8 +71,7 @@ padding-inline: var(--file-upload-single-padding-inline); } -.base[data-multiple][data-size='default']:not([data-list-empty]) - [data-slot='file-upload-control'] { +.base[data-multiple]:not([data-list-empty]) [data-slot='file-upload-control'] { min-block-size: var(--file-upload-multiple-min-block-size); } @@ -102,30 +101,3 @@ margin: 0; margin-block-start: var(--kbq-size-xxs); } - -.list { - margin: 0; - display: flex; - overflow-y: visible; - list-style: none; - min-inline-size: 0; - inline-size: 100%; - box-sizing: border-box; - flex-direction: column; - padding-block: 0; - padding-inline: 0; -} - -.list[data-multiple] { - overflow-y: auto; - scrollbar-width: auto; - scrollbar-gutter: auto; - scrollbar-color: var(--kbq-scrollbar-thumb-default-background) - var(--kbq-background-transparent); - padding-block: var(--kbq-size-xxs) var(--kbq-size-xxl); - min-block-size: var(--file-upload-list-min-block-size); -} - -.list[data-multiple][data-size='default'] { - flex: 1 1 auto; -} diff --git a/packages/components/src/components/FileUpload/FileUpload.stories.tsx b/packages/components/src/components/FileUpload/FileUpload.stories.tsx index 1dfaa749b..f9a01cb97 100644 --- a/packages/components/src/components/FileUpload/FileUpload.stories.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.stories.tsx @@ -85,16 +85,25 @@ const removeById = (items: FileUploadItemData[], id: Key) => export const Base: Story = { render: function Render(args) { + const [items, setItems] = useState([]); + return ( - - - - - Secret.txt - {10000} - - - + setItems(files.map(toItem))} + onRemove={(id) => setItems((prev) => removeById(prev, id))} + {...args} + > + {(item) => ( + + + + {item.name} + + + + )} ); }, @@ -170,7 +179,6 @@ export const Single: Story = { {item.name} - {item.size} @@ -422,7 +430,9 @@ export const Scrollable: Story = { setItems((prev) => [...prev, ...files.map(toItem)])} onRemove={(id) => setItems((prev) => removeById(prev, id))} allowsMultiple diff --git a/packages/components/src/components/FileUpload/FileUpload.test.tsx b/packages/components/src/components/FileUpload/FileUpload.test.tsx index 4acf9c1ec..5ca349e14 100644 --- a/packages/components/src/components/FileUpload/FileUpload.test.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.test.tsx @@ -1066,27 +1066,34 @@ describe('FileUpload', () => { vi.stubGlobal('ResizeObserver', ResizeObserverMock); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + font: '', + measureText: (value: string) => ({ width: value.length * 8 }), + } as CanvasRenderingContext2D); + const renderUpload = () => ( - {fileName} + + {fileName} + ); const { rerender } = render(renderUpload()); - const name = screen.getByText(fileName); + const name = screen.getByTestId('item-name'); Object.defineProperties(name, { clientWidth: { configurable: true, value: 100 }, - scrollWidth: { configurable: true, value: 240 }, clientHeight: { configurable: true, value: 20 }, - scrollHeight: { configurable: true, value: 20 }, }); rerender(renderUpload()); expect(name).toHaveAttribute('data-overflowing'); + expect(name.firstChild?.textContent).toBe('a-ver….json'); + fireEvent.pointerMove(name, { pointerType: 'mouse' }); fireEvent.mouseMove(name); await user.hover(name); @@ -1122,10 +1129,12 @@ describe('FileUpload', () => { items={[]} renderEmptyState={renderEmptyState} slotProps={{ - listEmpty: { - ref: listEmptyRef, - id: 'list-empty', - className: 'custom-list-empty', + list: { + emptyState: { + ref: listEmptyRef, + id: 'list-empty', + className: 'custom-list-empty', + }, }, }} > @@ -1182,11 +1191,13 @@ describe('FileUpload', () => { allowsMultiple items={[makeItem('a.txt')]} slotProps={{ - addMore: { - ref, - className: 'custom-add-more', - id: 'add-more', - children: 'replacement content', + list: { + addMore: { + ref, + className: 'custom-add-more', + id: 'add-more', + children: 'replacement content', + }, }, }} > @@ -1213,9 +1224,11 @@ describe('FileUpload', () => { items={[makeItem('a.txt')]} slotProps={{ list: { - className: 'custom-list', - style: { maxBlockSize: 240 }, - id: 'list', + fileList: { + className: 'custom-list', + style: { maxBlockSize: 240 }, + id: 'list', + }, }, }} > diff --git a/packages/components/src/components/FileUpload/FileUpload.tsx b/packages/components/src/components/FileUpload/FileUpload.tsx index 2670a06c6..d16c0ea83 100644 --- a/packages/components/src/components/FileUpload/FileUpload.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { Ref, ReactNode } from 'react'; +import type { Ref } from 'react'; import { useRef, useMemo, @@ -43,8 +43,6 @@ import type { import { FormField } from '../FormField'; import { - FileUploadAddMore, - FileUploadControl, FileUploadEmpty, FileUploadEmptyIcon, FileUploadEmptyTitle, @@ -53,7 +51,7 @@ import { FileUploadItemIcon, FileUploadItemName, FileUploadItemSize, - FileUploadListEmpty, + FileUploadList, FileUploadRemoveButton, FileUploadTrigger, FileUploadEmptyDescription, @@ -69,7 +67,7 @@ import type { FileUploadMessages, FileUploadProps, } from './types'; -import { prepareFileUploadFiles } from './utils'; +import { getCollectionKeys, prepareFileUploadFiles } from './utils'; type PendingFocus = { type: 'item'; id: Key } | { type: 'trigger' } | null; @@ -79,10 +77,6 @@ type FileUploadInnerProps = { rootRef?: Ref; }; -const getCollectionKeys = ( - collection: AriaCollection> -): Key[] => Array.from(collection, (node) => node.key); - function FileUploadInner({ props, rootRef, @@ -310,8 +304,6 @@ function FileUploadInner({ ] ); - const { className: listClassName, ...listProps } = slotProps?.list ?? {}; - const showsLargeEmpty = isEmpty && allowsMultiple && @@ -339,36 +331,23 @@ function FileUploadInner({ ? (domRef.current?.ownerDocument.documentElement ?? null) : (dropzoneTarget?.current ?? null); - let uploadContent: ReactNode; - - if (!isEmpty) { - uploadContent = ( - <> -
    - -
    - {allowsMultiple && } - - ); - } else if (showsLargeEmpty) { - uploadContent = renderEmptyState ? renderEmptyState() : ; - } else { - uploadContent = ; - } - return (
    - - {uploadContent} - + + + ->(({ children, className, ...other }, ref) => ( -
    - {children} -
    -)); - -FileUploadControl.displayName = 'FileUploadControl'; diff --git a/packages/components/src/components/FileUpload/components/Control/index.ts b/packages/components/src/components/FileUpload/components/Control/index.ts deleted file mode 100644 index 9e5e2afae..000000000 --- a/packages/components/src/components/FileUpload/components/Control/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Control'; diff --git a/packages/components/src/components/FileUpload/components/Item/Item.module.css b/packages/components/src/components/FileUpload/components/Item/Item.module.css index ec2691f12..ad232b1cc 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.module.css +++ b/packages/components/src/components/FileUpload/components/Item/Item.module.css @@ -88,6 +88,7 @@ @mixin ellipsis; flex: 1 1 auto; + position: relative; min-inline-size: 0; } diff --git a/packages/components/src/components/FileUpload/components/Item/Item.tsx b/packages/components/src/components/FileUpload/components/Item/Item.tsx index 09fad539b..74c9122a9 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.tsx +++ b/packages/components/src/components/FileUpload/components/Item/Item.tsx @@ -23,6 +23,7 @@ import { useFileUploadContext, useFileUploadItemContext, } from '../../FileUploadContext'; +import { useMiddleEllipsis } from '../../hooks'; import type { FileUploadItemProps, FileUploadItemIconProps, @@ -149,13 +150,24 @@ export const FileUploadItemName = forwardRef< >((props, ref) => { const { children, className, style, 'data-testid': testId, ...other } = props; const { nameText, rootRef } = useFileUploadItemContext(); + const fileName = typeof children === 'string' ? children : undefined; - const { ref: overflowRef, isOverflowX } = - useElementOverflow(); + const middleEllipsis = useMiddleEllipsis(fileName ?? ''); + const elementOverflow = useElementOverflow(); + + let displayedName = children; + let isOverflowing = elementOverflow.isOverflowX; + let overflowRef = elementOverflow.ref; + + if (fileName !== undefined) { + displayedName = middleEllipsis.text; + isOverflowing = middleEllipsis.isOverflow; + overflowRef = middleEllipsis.ref; + } return ( ( @@ -168,10 +180,10 @@ export const FileUploadItemName = forwardRef< }} style={style} data-testid={testId} - data-overflowing={isOverflowX || undefined} + data-overflowing={isOverflowing || undefined} className={clsx(s.name, className)} > - {children} + {displayedName} )} > diff --git a/packages/components/src/components/FileUpload/components/Control/Control.module.css b/packages/components/src/components/FileUpload/components/List/List.module.css similarity index 52% rename from packages/components/src/components/FileUpload/components/Control/Control.module.css rename to packages/components/src/components/FileUpload/components/List/List.module.css index f2bed5f03..00c22d28e 100644 --- a/packages/components/src/components/FileUpload/components/Control/Control.module.css +++ b/packages/components/src/components/FileUpload/components/List/List.module.css @@ -21,3 +21,27 @@ var(--file-upload-container-border-color); } } + +.list { + margin: 0; + display: flex; + overflow-y: visible; + list-style: none; + min-inline-size: 0; + inline-size: 100%; + box-sizing: border-box; + flex-direction: column; + padding-block: 0; + padding-inline: 0; +} + +.list[data-multiple] { + flex: 1 1 auto; + overflow-y: auto; + scrollbar-width: auto; + scrollbar-gutter: auto; + scrollbar-color: var(--kbq-scrollbar-thumb-default-background) + var(--kbq-background-transparent); + padding-block: var(--kbq-size-xxs) var(--kbq-size-xxl); + min-block-size: var(--file-upload-list-min-block-size); +} diff --git a/packages/components/src/components/FileUpload/components/List/List.tsx b/packages/components/src/components/FileUpload/components/List/List.tsx new file mode 100644 index 000000000..16e4107c4 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/List.tsx @@ -0,0 +1,115 @@ +'use client'; + +import type { ComponentPropsWithRef, ReactNode } from 'react'; +import { forwardRef } from 'react'; + +import { clsx } from '@koobiq/react-core'; + +import { utilClasses } from '../../../../styles/utility'; +import type { FileUploadListSlotProps, FileUploadPropSize } from '../../types'; +import { FileUploadEmpty } from '../Empty'; + +import s from './List.module.css'; +import { FileUploadListAddMore } from './ListAddMore'; +import { FileUploadListEmpty } from './ListEmpty'; + +type FileUploadListProps = Omit, 'children'> & { + children: ReactNode; + slots?: FileUploadListSlotProps; + size: FileUploadPropSize; + isEmpty: boolean; + allowsMultiple: boolean; + showsLargeEmpty: boolean; + renderEmptyState?: () => ReactNode; +}; + +const textNormal = utilClasses.typography['text-normal']; + +const FileUploadListEmptyState = ({ + slots, + showsLargeEmpty, + renderEmptyState, +}: Pick< + FileUploadListProps, + 'slots' | 'showsLargeEmpty' | 'renderEmptyState' +>) => { + if (showsLargeEmpty) { + return renderEmptyState ? renderEmptyState() : ; + } + + return ; +}; + +const FileUploadListFiles = ({ + children, + slots, + size, + allowsMultiple, +}: Pick< + FileUploadListProps, + 'children' | 'slots' | 'size' | 'allowsMultiple' +>) => { + const { className, ...props } = slots?.fileList ?? {}; + + return ( +
    + {children} +
    + ); +}; + +export const FileUploadList = forwardRef( + ( + { + children, + slots, + size, + isEmpty, + allowsMultiple, + showsLargeEmpty, + renderEmptyState, + className, + ...other + }, + ref + ) => { + const contentProps = { + children, + slots, + size, + isEmpty, + allowsMultiple, + showsLargeEmpty, + renderEmptyState, + }; + + let content = ; + + if (!isEmpty) { + content = ( + <> + + {allowsMultiple && } + + ); + } + + return ( +
    + {content} +
    + ); + } +); + +FileUploadList.displayName = 'FileUploadList'; diff --git a/packages/components/src/components/FileUpload/components/AddMore/AddMore.module.css b/packages/components/src/components/FileUpload/components/List/ListAddMore.module.css similarity index 100% rename from packages/components/src/components/FileUpload/components/AddMore/AddMore.module.css rename to packages/components/src/components/FileUpload/components/List/ListAddMore.module.css diff --git a/packages/components/src/components/FileUpload/components/AddMore/AddMore.tsx b/packages/components/src/components/FileUpload/components/List/ListAddMore.tsx similarity index 87% rename from packages/components/src/components/FileUpload/components/AddMore/AddMore.tsx rename to packages/components/src/components/FileUpload/components/List/ListAddMore.tsx index c25d648fc..a13f30272 100644 --- a/packages/components/src/components/FileUpload/components/AddMore/AddMore.tsx +++ b/packages/components/src/components/FileUpload/components/List/ListAddMore.tsx @@ -9,9 +9,9 @@ import { IconArrowUpFromBracket16 } from '@koobiq/react-icons'; import { useFileUploadContext } from '../../FileUploadContext'; import { FileUploadTriggers } from '../Trigger'; -import s from './AddMore.module.css'; +import s from './ListAddMore.module.css'; -export const FileUploadAddMore = forwardRef< +export const FileUploadListAddMore = forwardRef< HTMLDivElement, ComponentPropsWithRef<'div'> >((props, ref) => { @@ -42,4 +42,4 @@ export const FileUploadAddMore = forwardRef< ); }); -FileUploadAddMore.displayName = 'FileUploadAddMore'; +FileUploadListAddMore.displayName = 'FileUploadListAddMore'; diff --git a/packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.module.css b/packages/components/src/components/FileUpload/components/List/ListEmpty.module.css similarity index 100% rename from packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.module.css rename to packages/components/src/components/FileUpload/components/List/ListEmpty.module.css diff --git a/packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.tsx b/packages/components/src/components/FileUpload/components/List/ListEmpty.tsx similarity index 100% rename from packages/components/src/components/FileUpload/components/ListEmpty/ListEmpty.tsx rename to packages/components/src/components/FileUpload/components/List/ListEmpty.tsx diff --git a/packages/components/src/components/FileUpload/components/List/index.ts b/packages/components/src/components/FileUpload/components/List/index.ts new file mode 100644 index 000000000..57363db9a --- /dev/null +++ b/packages/components/src/components/FileUpload/components/List/index.ts @@ -0,0 +1,3 @@ +export * from './List'; +export * from './ListAddMore'; +export * from './ListEmpty'; diff --git a/packages/components/src/components/FileUpload/components/ListEmpty/index.ts b/packages/components/src/components/FileUpload/components/ListEmpty/index.ts deleted file mode 100644 index a09c758b8..000000000 --- a/packages/components/src/components/FileUpload/components/ListEmpty/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './ListEmpty'; diff --git a/packages/components/src/components/FileUpload/components/index.ts b/packages/components/src/components/FileUpload/components/index.ts index 5e1522220..98160927f 100644 --- a/packages/components/src/components/FileUpload/components/index.ts +++ b/packages/components/src/components/FileUpload/components/index.ts @@ -1,6 +1,4 @@ export * from './Trigger'; export * from './Item'; export * from './Empty'; -export * from './AddMore'; -export * from './ListEmpty'; -export * from './Control'; +export * from './List'; diff --git a/packages/components/src/components/FileUpload/hooks/index.ts b/packages/components/src/components/FileUpload/hooks/index.ts index 9b583c154..1d8ba3948 100644 --- a/packages/components/src/components/FileUpload/hooks/index.ts +++ b/packages/components/src/components/FileUpload/hooks/index.ts @@ -1,2 +1,3 @@ export * from './useFileDropTarget'; export * from './useFileUploadField'; +export * from './useMiddleEllipsis'; diff --git a/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.test.tsx b/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.test.tsx new file mode 100644 index 000000000..5e112e998 --- /dev/null +++ b/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.test.tsx @@ -0,0 +1,84 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useMiddleEllipsis } from './useMiddleEllipsis'; + +const TEST_ID = 'element'; + +const createResizeEntry = (width: number): ResizeObserverEntry => + ({ + contentRect: { + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: 20, + width, + height: 20, + }, + borderBoxSize: [{ inlineSize: width, blockSize: 20 }], + }) as unknown as ResizeObserverEntry; + +function TestElement({ value }: { value: string }) { + const { ref, text, isOverflow } = useMiddleEllipsis(value); + + return ( + + {text} + + ); +} + +describe('useMiddleEllipsis', () => { + let resize: ResizeObserverCallback; + + beforeEach(() => { + class ResizeObserverMock { + constructor(callback: ResizeObserverCallback) { + resize = callback; + } + + observe = vi.fn(); + disconnect = vi.fn(); + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + + return 1; + }); + + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + font: '', + measureText: (value: string) => ({ width: value.length * 8 }), + } as CanvasRenderingContext2D); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('keeps both ends of overflowing text and updates on resize', () => { + render(); + + act(() => resize([createResizeEntry(60)], {} as ResizeObserver)); + + expect(screen.getByTestId(TEST_ID)).toHaveTextContent('abc…hij'); + expect(screen.getByTestId(TEST_ID)).toHaveAttribute('data-overflowing'); + + act(() => resize([createResizeEntry(120)], {} as ResizeObserver)); + + expect(screen.getByTestId(TEST_ID)).toHaveTextContent('abcdefghij'); + expect(screen.getByTestId(TEST_ID)).not.toHaveAttribute('data-overflowing'); + }); +}); diff --git a/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.ts b/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.ts new file mode 100644 index 000000000..12f8258b0 --- /dev/null +++ b/packages/components/src/components/FileUpload/hooks/useMiddleEllipsis.ts @@ -0,0 +1,99 @@ +'use client'; + +import { useMemo } from 'react'; + +import { useResizeObserver } from '@koobiq/react-core'; + +const ELLIPSIS = '…'; +const contexts = new WeakMap(); + +const getContext = (document: Document): CanvasRenderingContext2D | null => { + const cached = contexts.get(document); + + if (cached) return cached; + + const context = document.createElement('canvas').getContext('2d'); + + if (context) contexts.set(document, context); + + return context; +}; + +const truncateMiddle = ( + value: string, + availableWidth: number, + measure: (text: string) => number +): string => { + const characters = Array.from(value); + const ellipsisWidth = measure(ELLIPSIS); + const partWidth = Math.max(0, (availableWidth - ellipsisWidth) / 2); + + const findLength = (fromEnd: boolean): number => { + let min = 0; + let max = characters.length; + + while (min < max) { + const length = Math.ceil((min + max) / 2); + + const part = fromEnd + ? characters.slice(-length).join('') + : characters.slice(0, length).join(''); + + if (measure(part) <= partWidth) { + min = length; + } else { + max = length - 1; + } + } + + return min; + }; + + const startLength = findLength(false); + const endLength = findLength(true); + + if (startLength + endLength >= characters.length) return value; + + const start = characters.slice(0, startLength).join(''); + const end = endLength > 0 ? characters.slice(-endLength).join('') : ''; + + return `${start}${ELLIPSIS}${end}`; +}; + +export function useMiddleEllipsis( + value: string +) { + const [ref, rect] = useResizeObserver(); + const element = ref.current; + + const result = useMemo(() => { + const availableWidth = element?.clientWidth || rect.width; + + if (!element || availableWidth <= 0 || value.length === 0) { + return { text: value, isOverflow: false }; + } + + const context = getContext(element.ownerDocument); + const view = element.ownerDocument.defaultView; + + if (!context || !view) { + return { text: value, isOverflow: false }; + } + + const style = view.getComputedStyle(element); + + context.font = + style.font || `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + + const measure = (text: string) => context.measureText(text).width; + const isOverflow = measure(value) > availableWidth; + + const text = isOverflow + ? truncateMiddle(value, availableWidth, measure) + : value; + + return { text, isOverflow }; + }, [element, rect.width, value]); + + return { ref, ...result }; +} diff --git a/packages/components/src/components/FileUpload/types.ts b/packages/components/src/components/FileUpload/types.ts index 22b2e1d59..68d043e7e 100644 --- a/packages/components/src/components/FileUpload/types.ts +++ b/packages/components/src/components/FileUpload/types.ts @@ -67,6 +67,12 @@ export type FileUploadMessages = { terabytesUnit: string; }; +export type FileUploadListSlotProps = { + emptyState?: ComponentPropsWithRef<'div'>; + fileList?: ComponentPropsWithRef<'div'>; + addMore?: ComponentPropsWithRef<'div'>; +}; + /** A native file with its path relative to the selected or dropped directory. */ export interface FileUploadFile extends File { readonly relativePath: string; @@ -132,10 +138,8 @@ export type FileUploadProps = isInvalid?: boolean; /** Props applied to internal parts of FileUpload. */ slotProps?: { - list?: ComponentPropsWithRef<'div'>; - listEmpty?: ComponentPropsWithRef<'div'>; + list?: FileUploadListSlotProps; dropOverlay?: ComponentPropsWithRef<'div'>; - addMore?: ComponentPropsWithRef<'div'>; label?: FormFieldLabelProps<'span'>; caption?: FormFieldCaptionProps; errorMessage?: Omit; diff --git a/packages/components/src/components/FileUpload/utils.ts b/packages/components/src/components/FileUpload/utils.ts index 6db13163d..cf744c70d 100644 --- a/packages/components/src/components/FileUpload/utils.ts +++ b/packages/components/src/components/FileUpload/utils.ts @@ -1,3 +1,9 @@ +import type { + Key, + Node, + Collection as AriaCollection, +} from '@koobiq/react-core'; + import type { FileUploadFile, FileUploadMessages, @@ -13,6 +19,10 @@ type PrepareFilesOptions = { const normalizeRelativePath = (path: string): string => path.replaceAll('\\', '/').replace(/^\/+/, ''); +export const getCollectionKeys = ( + collection: AriaCollection> +): Key[] => Array.from(collection, (node) => node.key); + export const setFileRelativePath = ( file: File, path: string From 5e198b3e1c2b62b65a25e453fc48d71934993b58 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 15 Jul 2026 18:46:43 +0300 Subject: [PATCH 21/30] chore(FileUpload): approve api --- packages/primitives/src/index.ts | 14 +------------- tools/public_api_guard/react-core.api.md | 15 +++++++++------ tools/public_api_guard/react-primitives.api.md | 6 ++++++ 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 145332eb1..5fdcd975f 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -137,8 +137,6 @@ export { TreeContext, Collection, FileTrigger, - isFileDropItem, - isTextDropItem, TreeItemContent, ButtonContext, TreeStateContext, @@ -146,22 +144,12 @@ export { useContextProps, useRenderProps, DEFAULT_SLOT, - isDirectoryDropItem, useSlottedContext, composeRenderProps, CollectionRendererContext, } from 'react-aria-components'; -export type { - DropItem, - FileDropItem, - TextDropItem, - DropZoneProps, - DropOperation, - FileTriggerProps, - DirectoryDropItem, - DropZoneRenderProps, -} from 'react-aria-components'; +export type { FileTriggerProps } from 'react-aria-components'; export * from './behaviors'; export * from './components'; diff --git a/tools/public_api_guard/react-core.api.md b/tools/public_api_guard/react-core.api.md index abb08250b..b184fbdf4 100644 --- a/tools/public_api_guard/react-core.api.md +++ b/tools/public_api_guard/react-core.api.md @@ -7,6 +7,7 @@ import { AriaLabelingProps } from '@react-types/shared'; import { AsyncLoadable } from '@react-types/shared'; import { chain } from '@react-aria/utils'; +import { Collection } from '@react-types/shared'; import { CollectionBase } from '@react-types/shared'; import { CollectionChildren } from '@react-types/shared'; import { CollectionElement } from '@react-types/shared'; @@ -77,6 +78,8 @@ export { chain } // @public (undocumented) export function clsx(...classNames: ClassName[]): string; +export { Collection } + export { CollectionBase } export { CollectionChildren } @@ -276,12 +279,12 @@ export { useDescription } export function useDOMRef(ref?: RefObject_2 | Ref_2): RefObject_2; // @public -export function useElementOverflow(): { - ref: RefObject_2; - isOverflow: boolean; - isOverflowX: boolean; - isOverflowY: boolean; -}; +export function useElementOverflow(): { + ref: RefObject_2; + isOverflow: boolean; + isOverflowX: boolean; + isOverflowY: boolean; +}; // @public export function useElementSize(options?: ResizeObserverOptions): { diff --git a/tools/public_api_guard/react-primitives.api.md b/tools/public_api_guard/react-primitives.api.md index 7e7cbc197..c2245e6ee 100644 --- a/tools/public_api_guard/react-primitives.api.md +++ b/tools/public_api_guard/react-primitives.api.md @@ -68,6 +68,8 @@ import { DragEventHandler } from 'react'; import { ElementType } from 'react'; import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; import type { ExtendableProps } from '@koobiq/react-core'; +import { FileTrigger } from 'react-aria-components'; +import { FileTriggerProps } from 'react-aria-components'; import { FocusableElement } from '@react-types/shared'; import { FocusableProps } from '@koobiq/react-core'; import { FocusEventHandler } from 'react'; @@ -324,6 +326,10 @@ export type FieldErrorProps = ComponentPropsWithRe // @public (undocumented) export type FieldErrorRenderProps = ValidationResult; +export { FileTrigger } + +export { FileTriggerProps } + // @public export const Form: ForwardRefExoticComponent>; From 1d340a245f53e4dabf750e39b80945005834f673 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 17 Jul 2026 14:06:22 +0300 Subject: [PATCH 22/30] chore(FileUpload): add per-file validation --- .../src/components/FileUpload/FileUpload.mdx | 15 +- .../FileUpload/FileUpload.stories.tsx | 196 +++++++-------- .../components/FileUpload/FileUpload.test.tsx | 228 +++++++++++++++++- .../src/components/FileUpload/FileUpload.tsx | 147 ++++++++--- .../FileUpload/FileUploadContext.ts | 7 + .../FileUpload/components/Item/Item.tsx | 66 ++++- .../FileUpload/hooks/useFileUploadField.ts | 14 +- .../src/components/FileUpload/intl.ts | 4 + .../src/components/FileUpload/types.ts | 17 +- .../src/components/FileUpload/utils.ts | 9 +- 10 files changed, 536 insertions(+), 167 deletions(-) diff --git a/packages/components/src/components/FileUpload/FileUpload.mdx b/packages/components/src/components/FileUpload/FileUpload.mdx index 0c65b520a..94508f4d9 100644 --- a/packages/components/src/components/FileUpload/FileUpload.mdx +++ b/packages/components/src/components/FileUpload/FileUpload.mdx @@ -33,7 +33,8 @@ import { FileUpload } from '@koobiq/react-components'; ## Data model Pass `items` and a render function for a dynamic collection. Pass -`FileUpload.Item` elements as children for a static collection. +`FileUpload.Item` elements as children for a static collection. Keep the native +file on each item and pass it to `FileUpload.Item` to enable validation. ## Long file names @@ -60,12 +61,6 @@ Use `allowsMultiple` to add several files. -## Accepted file types - -Use `accept` to restrict selectable and dropped file types. - - - ## Duplicate files Filter duplicate files in `onAdd` when the collection requires it. @@ -99,7 +94,11 @@ Render upload progress inside the item row. ## Validation -Validate files in `onAdd` and report errors below the list. +Use `accept` and `maxFileSize` for standard checks, and `validate` for custom +synchronous checks. Invalid files remain in the collection. + +Pass text or JSX to `errorMessage` to replace generated errors. Pass a function +to format the `ValidationResult`, or `null` to hide the error text. diff --git a/packages/components/src/components/FileUpload/FileUpload.stories.tsx b/packages/components/src/components/FileUpload/FileUpload.stories.tsx index f9a01cb97..2bb5dbb5e 100644 --- a/packages/components/src/components/FileUpload/FileUpload.stories.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.stories.tsx @@ -1,5 +1,4 @@ -import { Fragment, useRef, useState } from 'react'; -import type { ReactNode } from 'react'; +import { useRef, useState } from 'react'; import { type Key, useBoolean } from '@koobiq/react-core'; import { @@ -10,6 +9,7 @@ import { import type { Meta, StoryObj } from '@storybook/react-vite'; import { Button } from '../Button'; +import { FlexBox } from '../FlexBox'; import { Form } from '../Form'; import { spacing } from '../layout'; import { ProgressSpinner } from '../ProgressSpinner'; @@ -57,7 +57,6 @@ type FileUploadItemData = { isLoading?: boolean; isUploaded?: boolean; progress?: number; - errorMessage?: ReactNode; isDisabled?: boolean; kind?: 'file' | 'folder'; file?: FileUploadFile; @@ -66,11 +65,28 @@ type FileUploadItemData = { type Story = StoryObj>; -const makeItem = (name: string, size: number): FileUploadItemData => ({ - id: name, - name, - size, -}); +const imageFileTypes = [ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.svg', + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', +]; + +const makeFile = ( + name: string, + size: number, + type = 'application/octet-stream' +): FileUploadFile => + Object.assign(new File([new Uint8Array(size)], name, { type }), { + relativePath: '', + }); const toItem = (file: FileUploadFile): FileUploadItemData => ({ id: crypto.randomUUID(), @@ -80,6 +96,12 @@ const toItem = (file: FileUploadFile): FileUploadItemData => ({ file, }); +const makeItem = ( + name: string, + size: number, + type?: string +): FileUploadItemData => toItem(makeFile(name, size, type)); + const removeById = (items: FileUploadItemData[], id: Key) => items.filter((item) => item.id !== id); @@ -96,7 +118,7 @@ export const Base: Story = { {...args} > {(item) => ( - + {item.name} @@ -114,13 +136,15 @@ export const LongFileName: Story = { const name = 'incident-response-evidence-security-scan-report-for-production-environment.json'; + const file = makeFile(name, 10000, 'application/json'); + return ( - + {name} - {10000} + {file.size} @@ -143,7 +167,7 @@ export const WithLabel: Story = { {...args} > {(item) => ( - + {item.name} @@ -173,8 +197,8 @@ export const Single: Story = { @@ -205,8 +229,8 @@ export const Multiple: Story = { @@ -221,52 +245,6 @@ export const Multiple: Story = { }, }; -export const Images: Story = { - render: function Render(args) { - const [items, setItems] = useState([]); - - return ( - setItems((prev) => removeById(prev, id))} - onAdd={(files) => setItems((prev) => [...prev, ...files.map(toItem)])} - renderEmptyState={() => ( - - - Upload images - - Drag images here or{' '} - select images - - - )} - allowsMultiple - {...args} - > - {(item) => ( - - - - {item.name} - {item.size} - - - - )} - - ); - }, -}; - export const WithoutDuplicates: Story = { render: function Render(args) { const getFileFingerprint = (file: File) => @@ -304,7 +282,7 @@ export const WithoutDuplicates: Story = { {...args} > {(item) => ( - + {item.name} @@ -366,8 +344,8 @@ export const Allowed: Story = { {item.kind === 'folder' ? : undefined} @@ -402,8 +380,8 @@ export const Compact: Story = { @@ -442,8 +420,8 @@ export const Scrollable: Story = { @@ -509,11 +487,7 @@ export const UploadProgress: Story = { {...args} > {(item) => ( - + {item.isLoading ? ( @@ -533,42 +507,46 @@ export const UploadProgress: Story = { export const Validation: Story = { render: function Render(args) { - const MAX_SIZE = 150_000; - const SIZE_ERROR = 'The file size limit has been exceeded'; - - const [items, setItems] = useState([ - { - ...makeItem('file1.txt', MAX_SIZE + 1), - errorMessage: SIZE_ERROR, - }, - ]); - - const validate = (item: FileUploadItemData): FileUploadItemData => - item.size && item.size > MAX_SIZE - ? { ...item, errorMessage: SIZE_ERROR } - : item; - - const invalidItems = items.filter((item) => item.errorMessage); + const MAX_SIZE = 150 * 1024; + + const [items, setItems] = useState(() => + [ + makeFile('notes.txt', 1000, 'text/plain'), + makeFile('large-image.png', MAX_SIZE + 1, 'image/png'), + makeFile('empty-image.png', 0, 'image/png'), + makeFile('valid-image.png', 1000, 'image/png'), + ].map(toItem) + ); return ( 0} - errorMessage={ - invalidItems.length > 0 - ? invalidItems.map((item, index) => ( - - {index > 0 &&
    } - {item.name} — {item.errorMessage} -
    - )) - : undefined - } - onAdd={(files) => - setItems((prev) => [...prev, ...files.map(toItem).map(validate)]) + accept={imageFileTypes} + maxFileSize={MAX_SIZE} + caption="Accepts PNG, JPG, GIF, WebP, and SVG up to 150 KB" + validate={(file) => + file.size === 0 ? 'Empty files are not allowed' : null } + errorMessage={({ validationErrors }) => ( + + {validationErrors.map((error, index) => ( + {error} + ))} + + )} + onAdd={(files) => setItems((prev) => [...prev, ...files.map(toItem)])} onRemove={(id) => setItems((prev) => removeById(prev, id))} + renderEmptyState={() => ( + + + Upload images + + Drag images here or{' '} + select images + + + )} allowsMultiple {...args} > @@ -576,8 +554,8 @@ export const Validation: Story = { @@ -606,8 +584,8 @@ export const Disabled: Story = { @@ -638,8 +616,8 @@ export const Invalid: Story = { @@ -685,7 +663,11 @@ export const DropzoneTarget: Story = { {...args} > {(item) => ( - + {item.name} @@ -765,7 +747,11 @@ export const ServerFile: Story = { {...args} > {(item) => ( - + {item.isLoading ? ( @@ -829,8 +815,8 @@ export const CustomContent: Story = { diff --git a/packages/components/src/components/FileUpload/FileUpload.test.tsx b/packages/components/src/components/FileUpload/FileUpload.test.tsx index 5ca349e14..b95e28e8b 100644 --- a/packages/components/src/components/FileUpload/FileUpload.test.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.test.tsx @@ -1,7 +1,7 @@ import { createRef, useState } from 'react'; import type { ReactNode } from 'react'; -import type { Key } from '@koobiq/react-core'; +import type { Key, ValidationResult } from '@koobiq/react-core'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -11,7 +11,7 @@ import { Provider } from '../Provider'; import { readDroppedFiles } from './hooks'; import { FileUpload } from './index'; -import type { FileUploadProps } from './index'; +import type { FileUploadFile, FileUploadProps } from './index'; const ROOT_TEST_ID = 'file-upload'; @@ -23,21 +23,26 @@ type FileUploadItemData = { progress?: number; errorMessage?: ReactNode; isDisabled?: boolean; + file?: FileUploadFile; }; const makeFile = (name: string, content = 'stub') => new File([content], name, { type: 'text/plain' }); +const makeSizedFile = (name: string, size: number, type = 'text/plain') => + new File([new Uint8Array(size)], name, { type }) as FileUploadFile; + const makeItem = (name: string, size?: number): FileUploadItemData => ({ id: name, name, size, }); -const toItem = (file: File): FileUploadItemData => ({ +const toItem = (file: FileUploadFile): FileUploadItemData => ({ id: file.name, name: file.name, size: file.size, + file, }); const renderItem = (item: FileUploadItemData) => ( @@ -46,6 +51,7 @@ const renderItem = (item: FileUploadItemData) => ( textValue={String(item.name ?? '')} isDisabled={item.isDisabled} isInvalid={Boolean(item.errorMessage)} + file={item.file} data-loading={item.isLoading || undefined} > @@ -334,7 +340,7 @@ describe('FileUpload', () => { expect(screen.queryByText('select a file')).not.toBeInTheDocument(); }); - it('applies accept to files selected in the picker', async () => { + it('passes picker files to onAdd regardless of accept', async () => { const user = userEvent.setup({ applyAccept: false }); const onAdd = vi.fn(); const image = new File(['image'], 'image.png', { type: 'image/png' }); @@ -348,7 +354,7 @@ describe('FileUpload', () => { await user.upload(getFileInput(), [image, text]); - expect(onAdd).toHaveBeenCalledWith([image]); + expect(onAdd).toHaveBeenCalledWith([image, text]); }); it('uses the upload control as the default drop target', () => { @@ -616,7 +622,7 @@ describe('FileUpload', () => { await waitFor(() => expect(onAdd).toHaveBeenCalledWith([first, second])); }); - it('applies accept to dropped files', async () => { + it('passes dropped files to onAdd regardless of accept', async () => { const onAdd = vi.fn(); const image = new File(['image'], 'image.png', { type: 'image/png' }); const text = makeFile('notes.txt'); @@ -631,7 +637,215 @@ describe('FileUpload', () => { dataTransfer: createDataTransfer([dropFile(image), dropFile(text)]), }); - await waitFor(() => expect(onAdd).toHaveBeenCalledWith([image])); + await waitFor(() => expect(onAdd).toHaveBeenCalledWith([image, text])); + }); + + it('validates an item associated with a native file', async () => { + const file = makeFile('notes.txt') as FileUploadFile; + + renderComponent({ + accept: ['image/*'], + initialItems: [toItem(file)], + }); + + const item = screen.getByText('notes.txt').closest('[data-invalid]'); + const control = getDropTarget(); + + await waitFor(() => { + expect(item).toHaveAttribute('data-invalid'); + expect(control).toHaveAttribute('aria-invalid', 'true'); + }); + + expect( + screen.getByText('notes.txt — Unsupported file type') + ).toBeInTheDocument(); + }); + + it('allows a file at maxFileSize and rejects a larger file', async () => { + const exact = makeSizedFile('exact.bin', 10); + + const { unmount } = renderComponent({ + initialItems: [toItem(exact)], + maxFileSize: exact.size, + }); + + expect(getDropTarget()).not.toHaveAttribute('aria-invalid'); + + expect( + screen.queryByText(/The file size limit has been exceeded/) + ).not.toBeInTheDocument(); + + unmount(); + + const oversized = makeSizedFile('oversized.bin', 11); + + renderComponent({ + initialItems: [toItem(oversized)], + maxFileSize: 10, + }); + + await waitFor(() => { + expect(getDropTarget()).toHaveAttribute('aria-invalid', 'true'); + }); + + expect( + screen.getByText('oversized.bin — The file size limit has been exceeded') + ).toBeInTheDocument(); + }); + + it('combines standard and custom validation errors in order', async () => { + const file = makeSizedFile('notes.txt', 11); + + renderComponent({ + accept: ['image/*'], + maxFileSize: 10, + validate: () => 'Custom validation failed', + initialItems: [toItem(file)], + }); + + expect( + await screen.findByText( + 'notes.txt — Unsupported file type notes.txt — The file size limit has been exceeded notes.txt — Custom validation failed' + ) + ).toBeInTheDocument(); + }); + + it('allows text and JSX to replace automatic validation errors', async () => { + const file = makeSizedFile('notes.txt', 11); + const initialItems = [toItem(file)]; + + const { unmount } = renderComponent({ + accept: ['image/*'], + initialItems, + errorMessage: 'Custom text error', + }); + + expect(await screen.findByText('Custom text error')).toBeInTheDocument(); + + expect( + screen.queryByText('notes.txt — Unsupported file type') + ).not.toBeInTheDocument(); + + unmount(); + + renderComponent({ + accept: ['image/*'], + initialItems, + errorMessage: Custom JSX error, + }); + + const customJSXError = await screen.findByText('Custom JSX error'); + + expect(customJSXError.tagName).toBe('STRONG'); + + expect( + screen.queryByText('notes.txt — Unsupported file type') + ).not.toBeInTheDocument(); + }); + + it('passes the validation result to an errorMessage function', async () => { + const file = makeSizedFile('notes.txt', 11); + const automaticError = 'notes.txt — Unsupported file type'; + + const renderError = vi.fn(({ validationErrors }: ValidationResult) => ( + Upload failed: {validationErrors.join(', ')} + )); + + renderComponent({ + accept: ['image/*'], + initialItems: [toItem(file)], + errorMessage: renderError, + }); + + expect( + await screen.findByText(`Upload failed: ${automaticError}`) + ).toBeInTheDocument(); + + expect(renderError).toHaveBeenLastCalledWith( + expect.objectContaining({ + isInvalid: true, + validationErrors: [automaticError], + }) + ); + }); + + it('allows hiding automatic errors without clearing the invalid state', async () => { + const file = makeSizedFile('notes.txt', 11); + + renderComponent({ + accept: ['image/*'], + initialItems: [toItem(file)], + errorMessage: null, + }); + + await waitFor(() => { + expect(getDropTarget()).toHaveAttribute('aria-invalid', 'true'); + }); + + expect( + screen.queryByText('notes.txt — Unsupported file type') + ).not.toBeInTheDocument(); + }); + + it('uses a custom file size validation message', async () => { + const file = makeSizedFile('large.bin', 11); + + renderComponent({ + initialItems: [toItem(file)], + maxFileSize: 10, + messages: { fileSizeLimitExceeded: 'File is too large' }, + }); + + expect( + await screen.findByText('large.bin — File is too large') + ).toBeInTheDocument(); + }); + + it('does not validate an item without a native file', () => { + const validate = vi.fn(() => 'Invalid file'); + + renderComponent({ + accept: ['image/*'], + maxFileSize: 0, + validate, + initialItems: [makeItem('server-file.txt', 100)], + }); + + expect(validate).not.toHaveBeenCalled(); + expect(getDropTarget()).not.toHaveAttribute('aria-invalid'); + expect(screen.queryByText(/Invalid file/)).not.toBeInTheDocument(); + }); + + it('shows custom validation errors and clears them after removal', async () => { + const user = userEvent.setup(); + const file = makeFile('large.txt') as FileUploadFile; + + renderComponent({ + initialItems: [toItem(file)], + validate: () => ['File is too large', 'File is blocked'], + }); + + const control = getDropTarget(); + + await waitFor(() => { + expect(control).toHaveAttribute('aria-invalid', 'true'); + }); + + expect( + screen.getByText( + 'large.txt — File is too large large.txt — File is blocked' + ) + ).toBeInTheDocument(); + + await user.click( + screen.getByRole('button', { name: /remove large\.txt/i }) + ); + + await waitFor(() => { + expect(getDropTarget()).not.toHaveAttribute('aria-invalid'); + }); + + expect(screen.queryByText(/File is too large/)).not.toBeInTheDocument(); }); it('ignores disabled and non-file drags', () => { diff --git a/packages/components/src/components/FileUpload/FileUpload.tsx b/packages/components/src/components/FileUpload/FileUpload.tsx index d16c0ea83..6a5b90966 100644 --- a/packages/components/src/components/FileUpload/FileUpload.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.tsx @@ -3,6 +3,7 @@ import type { Ref } from 'react'; import { useRef, + useState, useMemo, forwardRef, useCallback, @@ -71,6 +72,11 @@ import { getCollectionKeys, prepareFileUploadFiles } from './utils'; type PendingFocus = { type: 'item'; id: Key } | { type: 'trigger' } | null; +type ItemValidation = { + name: string; + errors: string[]; +}; + type FileUploadInnerProps = { props: Omit, 'ref' | 'items' | 'children'>; collection: AriaCollection>; @@ -89,6 +95,8 @@ function FileUploadInner({ allowed = 'file', size = 'default', accept, + maxFileSize, + validate, onAdd, style, onRemove, @@ -129,14 +137,99 @@ function FileUploadInner({ const stringFormatter = useLocalizedStringFormatter(intlMessages); const numberFormatter = useNumberFormatter({ maximumFractionDigits: 2 }); + const messages = useMemo(() => { + const localizedMessages = { + emptyTitle: stringFormatter.format('emptyTitle'), + listEmptyText: stringFormatter.format('listEmptyText'), + dropOverlayTitle: stringFormatter.format('dropOverlayTitle'), + addMoreText: stringFormatter.format('addMoreText'), + alternativeSeparator: stringFormatter.format('alternativeSeparator'), + browseFile: stringFormatter.format('browseFile'), + browseFiles: stringFormatter.format('browseFiles'), + browseFolder: stringFormatter.format('browseFolder'), + browseFilesOrFolder: stringFormatter.format('browseFilesOrFolder'), + browseFolderMixed: stringFormatter.format('browseFolderMixed'), + removeButtonLabel: stringFormatter.format('removeButtonLabel'), + unsupportedFileType: stringFormatter.format('unsupportedFileType'), + fileSizeLimitExceeded: stringFormatter.format('fileSizeLimitExceeded'), + bytesUnit: stringFormatter.format('bytesUnit'), + kilobytesUnit: stringFormatter.format('kilobytesUnit'), + megabytesUnit: stringFormatter.format('megabytesUnit'), + gigabytesUnit: stringFormatter.format('gigabytesUnit'), + terabytesUnit: stringFormatter.format('terabytesUnit'), + }; + + return { ...localizedMessages, ...messageOverrides }; + }, [stringFormatter, messageOverrides]); + + const getItemRef = useKeyedRefs(); + const triggerRef = useRef(null); + const pendingFocus = useRef(null); + + const [itemValidations, setItemValidations] = useState< + Map + >(new Map()); + + const unregisterItemValidation = useCallback((id: Key) => { + setItemValidations((current) => { + if (!current.has(id)) return current; + + const next = new Map(current); + next.delete(id); + + return next; + }); + }, []); + + const registerItemValidation = useCallback( + (id: Key, name: string, errors: string[]) => { + if (errors.length === 0) { + unregisterItemValidation(id); + + return; + } + + setItemValidations((current) => { + const previous = current.get(id); + + const isUnchanged = + previous?.name === name && + previous.errors.length === errors.length && + previous.errors.every((error, index) => error === errors[index]); + + if (isUnchanged) return current; + + return new Map(current).set(id, { name, errors }); + }); + }, + [unregisterItemValidation] + ); + + const validationErrors = useMemo( + () => + collectionKeys.flatMap((id) => { + const validation = itemValidations.get(id); + + if (!validation) return []; + + return validation.errors.map((error) => + validation.name ? `${validation.name} — ${error}` : error + ); + }), + [collectionKeys, itemValidations] + ); + + const isInvalid = isInvalidProp || validationErrors.length > 0; + const field = useFileUploadField({ id, role, label, caption, - hasErrorMessage: errorMessage != null, + hasErrorMessage: errorMessage != null || validationErrors.length > 0, isDisabled, - isInvalid: isInvalidProp, + isInvalid, + validationErrors, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, @@ -160,38 +253,19 @@ function FileUploadInner({ slotProps?.caption ); + let errorMessageChildren = errorMessage; + + if (errorMessage === null) { + // RAC falls back to validationErrors for null children. An empty element + // keeps the error relationship while intentionally rendering no text. + errorMessageChildren = <>; + } + const errorMessageProps = mergeProps<(FormFieldErrorProps | undefined)[]>( - { children: errorMessage }, + { children: errorMessageChildren }, slotProps?.errorMessage ); - const messages = useMemo(() => { - const localizedMessages = { - emptyTitle: stringFormatter.format('emptyTitle'), - listEmptyText: stringFormatter.format('listEmptyText'), - dropOverlayTitle: stringFormatter.format('dropOverlayTitle'), - addMoreText: stringFormatter.format('addMoreText'), - alternativeSeparator: stringFormatter.format('alternativeSeparator'), - browseFile: stringFormatter.format('browseFile'), - browseFiles: stringFormatter.format('browseFiles'), - browseFolder: stringFormatter.format('browseFolder'), - browseFilesOrFolder: stringFormatter.format('browseFilesOrFolder'), - browseFolderMixed: stringFormatter.format('browseFolderMixed'), - removeButtonLabel: stringFormatter.format('removeButtonLabel'), - bytesUnit: stringFormatter.format('bytesUnit'), - kilobytesUnit: stringFormatter.format('kilobytesUnit'), - megabytesUnit: stringFormatter.format('megabytesUnit'), - gigabytesUnit: stringFormatter.format('gigabytesUnit'), - terabytesUnit: stringFormatter.format('terabytesUnit'), - }; - - return { ...localizedMessages, ...messageOverrides }; - }, [stringFormatter, messageOverrides]); - - const getItemRef = useKeyedRefs(); - const triggerRef = useRef(null); - const pendingFocus = useRef(null); - const setTriggerRef = useCallback((element: HTMLElement | null) => { triggerRef.current = element; }, []); @@ -216,14 +290,13 @@ function FileUploadInner({ if (isDisabled || files.length === 0) return; const preparedFiles = prepareFileUploadFiles(files, { - accept, allowed, allowsMultiple, }); if (preparedFiles.length > 0) onAdd?.(preparedFiles); }, - [accept, allowed, isDisabled, allowsMultiple, onAdd] + [allowed, isDisabled, allowsMultiple, onAdd] ); const isFullscreen = dropzoneTarget === 'fullscreen'; @@ -276,6 +349,8 @@ function FileUploadInner({ const contextValue = useMemo( () => ({ accept, + maxFileSize, + validate, allowed, size, isDisabled, @@ -285,11 +360,15 @@ function FileUploadInner({ removeItem, getItemRef, setTriggerRef, + registerItemValidation, + unregisterItemValidation, messages, formatSize, }), [ accept, + maxFileSize, + validate, allowed, size, isDisabled, @@ -299,6 +378,8 @@ function FileUploadInner({ removeItem, getItemRef, setTriggerRef, + registerItemValidation, + unregisterItemValidation, messages, formatSize, ] @@ -320,7 +401,7 @@ function FileUploadInner({ 'data-empty': isEmpty || undefined, 'data-list-empty': (isEmpty && !showsLargeEmpty) || undefined, 'data-disabled': isDisabled || undefined, - 'data-invalid': isInvalidProp || undefined, + 'data-invalid': isInvalid || undefined, 'data-multiple': allowsMultiple || undefined, 'data-required': isRequired || undefined, 'data-testid': testId, diff --git a/packages/components/src/components/FileUpload/FileUploadContext.ts b/packages/components/src/components/FileUpload/FileUploadContext.ts index bb416132f..f1bd20b05 100644 --- a/packages/components/src/components/FileUpload/FileUploadContext.ts +++ b/packages/components/src/components/FileUpload/FileUploadContext.ts @@ -6,6 +6,7 @@ import type { RefObject } from 'react'; import type { Key } from '@koobiq/react-core'; import type { + FileUploadValidate, FileUploadMessages, FileUploadPropSize, FileUploadPropAllowed, @@ -14,6 +15,8 @@ import type { export type FileUploadContextValue = { allowsMultiple: boolean; accept?: string[]; + maxFileSize?: number; + validate?: FileUploadValidate; allowed: FileUploadPropAllowed; size: FileUploadPropSize; isDisabled: boolean; @@ -26,6 +29,10 @@ export type FileUploadContextValue = { getItemRef: (id: Key) => RefObject; /** Register the browse-link element for focus restoration. */ setTriggerRef: (element: HTMLElement | null) => void; + /** Register validation errors produced by a rendered item. */ + registerItemValidation: (id: Key, name: string, errors: string[]) => void; + /** Remove validation errors when an item is no longer rendered. */ + unregisterItemValidation: (id: Key) => void; /** Resolved localized strings. */ messages: FileUploadMessages; /** Locale-aware file-size formatter. */ diff --git a/packages/components/src/components/FileUpload/components/Item/Item.tsx b/packages/components/src/components/FileUpload/components/Item/Item.tsx index 74c9122a9..88458c66a 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.tsx +++ b/packages/components/src/components/FileUpload/components/Item/Item.tsx @@ -1,6 +1,6 @@ 'use client'; -import { forwardRef, useRef } from 'react'; +import { forwardRef, useLayoutEffect, useMemo, useRef } from 'react'; import type { ForwardedRef } from 'react'; import { @@ -32,6 +32,7 @@ import type { FileUploadItemContentProps, FileUploadRemoveButtonProps, } from '../../types'; +import { isAcceptedFile } from '../../utils'; import s from './Item.module.css'; @@ -39,6 +40,14 @@ class FileUploadItemNode extends CollectionNode { static readonly type = 'item'; } +const toValidationErrors = ( + result: string | string[] | true | null | undefined +): string[] => { + if (!result || result === true) return []; + + return Array.isArray(result) ? result : [result]; +}; + /** A single row in the file collection. */ export const FileUploadItem = createLeafComponent( FileUploadItemNode, @@ -51,20 +60,69 @@ export const FileUploadItem = createLeafComponent( children, className, style, + file, isDisabled: isDisabledProp, isInvalid: isInvalidProp, 'data-testid': testId, ...other } = props; - const { allowsMultiple, isDisabled: groupDisabled } = - useFileUploadContext(); + const { + accept, + maxFileSize, + validate, + messages, + allowsMultiple, + isDisabled: groupDisabled, + registerItemValidation, + unregisterItemValidation, + } = useFileUploadContext(); + + const validationErrors = useMemo(() => { + if (!file) return []; + + const errors: string[] = []; + + if (!isAcceptedFile(file, accept)) { + errors.push(messages.unsupportedFileType); + } + + if (maxFileSize !== undefined && file.size > maxFileSize) { + errors.push(messages.fileSizeLimitExceeded); + } + + return [...errors, ...toValidationErrors(validate?.(file))]; + }, [ + file, + accept, + maxFileSize, + validate, + messages.unsupportedFileType, + messages.fileSizeLimitExceeded, + ]); const isDisabled = groupDisabled || isDisabledProp || false; - const isInvalid = isInvalidProp || false; + const isInvalid = Boolean(isInvalidProp || validationErrors.length > 0); delete other.id; delete other.textValue; + useLayoutEffect(() => { + registerItemValidation( + node.key, + node.textValue || file?.name || '', + validationErrors + ); + + return () => unregisterItemValidation(node.key); + }, [ + file, + node.key, + node.textValue, + validationErrors, + registerItemValidation, + unregisterItemValidation, + ]); + const { hoverProps, isHovered } = useHover({ isDisabled }); const { focusProps, isFocusVisible } = useFocusRing({ within: true }); const rootRef = useRef(null); diff --git a/packages/components/src/components/FileUpload/hooks/useFileUploadField.ts b/packages/components/src/components/FileUpload/hooks/useFileUploadField.ts index afbf9ab5f..f0ac48118 100644 --- a/packages/components/src/components/FileUpload/hooks/useFileUploadField.ts +++ b/packages/components/src/components/FileUpload/hooks/useFileUploadField.ts @@ -21,11 +21,15 @@ type UseFileUploadFieldOptions = FileUploadFieldDOMProps & { hasErrorMessage: boolean; isDisabled: boolean; isInvalid: boolean; + validationErrors?: string[]; }; -const getValidationResult = (isInvalid: boolean): ValidationResult => ({ +const getValidationResult = ( + isInvalid: boolean, + validationErrors: string[] +): ValidationResult => ({ isInvalid, - validationErrors: [], + validationErrors, validationDetails: { badInput: false, customError: isInvalid, @@ -49,6 +53,7 @@ export const useFileUploadField = ({ hasErrorMessage, isDisabled, isInvalid, + validationErrors = [], 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, @@ -68,7 +73,10 @@ export const useFileUploadField = ({ 'aria-describedby': ariaDescribedBy, }); - const validation = useMemo(() => getValidationResult(isInvalid), [isInvalid]); + const validation = useMemo( + () => getValidationResult(isInvalid, validationErrors), + [isInvalid, validationErrors] + ); return { validation, diff --git a/packages/components/src/components/FileUpload/intl.ts b/packages/components/src/components/FileUpload/intl.ts index f564385b9..80c6c3d64 100644 --- a/packages/components/src/components/FileUpload/intl.ts +++ b/packages/components/src/components/FileUpload/intl.ts @@ -11,6 +11,8 @@ export default { browseFilesOrFolder: 'выберите файлы или папку', browseFolderMixed: 'папку', removeButtonLabel: 'Удалить', + unsupportedFileType: 'Неподдерживаемый тип файла', + fileSizeLimitExceeded: 'Превышен допустимый размер файла', bytesUnit: 'Б', kilobytesUnit: 'КБ', megabytesUnit: 'МБ', @@ -29,6 +31,8 @@ export default { browseFilesOrFolder: 'select files or a folder', browseFolderMixed: 'a folder', removeButtonLabel: 'Remove', + unsupportedFileType: 'Unsupported file type', + fileSizeLimitExceeded: 'The file size limit has been exceeded', bytesUnit: 'B', kilobytesUnit: 'KB', megabytesUnit: 'MB', diff --git a/packages/components/src/components/FileUpload/types.ts b/packages/components/src/components/FileUpload/types.ts index 68d043e7e..6919291e2 100644 --- a/packages/components/src/components/FileUpload/types.ts +++ b/packages/components/src/components/FileUpload/types.ts @@ -9,6 +9,7 @@ import type { import type { Key, + Validation, DataAttributeProps, ExtendableComponentPropsWithRef, } from '@koobiq/react-core'; @@ -60,6 +61,8 @@ export type FileUploadMessages = { browseFilesOrFolder: string; browseFolderMixed: string; removeButtonLabel: string; + unsupportedFileType: string; + fileSizeLimitExceeded: string; bytesUnit: string; kilobytesUnit: string; megabytesUnit: string; @@ -78,6 +81,10 @@ export interface FileUploadFile extends File { readonly relativePath: string; } +export type FileUploadValidate = NonNullable< + Validation['validate'] +>; + export type FileUploadProps = ExtendableComponentPropsWithRef< { @@ -87,6 +94,8 @@ export type FileUploadProps = items?: Iterable; /** Handler called when native files are added via picker or drop. */ onAdd?: (files: FileUploadFile[]) => void; + /** Validates native files associated with `FileUpload.Item`. */ + validate?: FileUploadValidate; /** Handler called when a remove button requests item removal. */ onRemove?: (id: Key) => void; /** Where files can be dropped: a target element ref or 'fullscreen'. Defaults to the FileUpload root. */ @@ -100,7 +109,7 @@ export type FileUploadProps = /** Helper text displayed below the upload area. */ caption?: ReactNode; /** Validation error displayed when `isInvalid` is true. */ - errorMessage?: ReactNode; + errorMessage?: FormFieldErrorProps['children']; /** Whether the label is visually hidden. */ isLabelHidden?: boolean; /** Whether the field is marked as required. */ @@ -120,8 +129,10 @@ export type FileUploadProps = * @default false */ allowsMultiple?: boolean; - /** Accepted file types (mime types or extensions) for picker and drop. */ + /** File type specifiers used by the picker and item validation. */ accept?: string[]; + /** Maximum allowed size of an individual file, in bytes. */ + maxFileSize?: number; /** * Which kind of items can be selected or dropped. * @default 'file' @@ -200,6 +211,8 @@ export type FileUploadItemProps = ExtendableComponentPropsWithRef< isDisabled?: boolean; /** Whether this row is invalid. */ isInvalid?: boolean; + /** Native file used for automatic item validation. */ + file?: FileUploadFile; } & DataAttributeProps, 'div' >; diff --git a/packages/components/src/components/FileUpload/utils.ts b/packages/components/src/components/FileUpload/utils.ts index cf744c70d..d640a3368 100644 --- a/packages/components/src/components/FileUpload/utils.ts +++ b/packages/components/src/components/FileUpload/utils.ts @@ -11,7 +11,6 @@ import type { } from './types'; type PrepareFilesOptions = { - accept?: string[]; allowed: FileUploadPropAllowed; allowsMultiple: boolean; }; @@ -44,7 +43,7 @@ export const setFileRelativePath = ( const getRootDirectory = (file: FileUploadFile): string | undefined => file.relativePath.split('/').filter(Boolean)[0]; -const isAcceptedFile = (file: File, accept?: string[]): boolean => { +export const isAcceptedFile = (file: File, accept?: string[]): boolean => { const values = accept ?.map((value) => value.trim().toLowerCase()) .filter(Boolean); @@ -63,10 +62,10 @@ const isAcceptedFile = (file: File, accept?: string[]): boolean => { }); }; -/** Applies the same file constraints to picker and drop results. */ +/** Normalizes files and applies selection-mode constraints. */ export const prepareFileUploadFiles = ( files: File[], - { accept, allowed, allowsMultiple }: PrepareFilesOptions + { allowed, allowsMultiple }: PrepareFilesOptions ): FileUploadFile[] => { const normalized = files.map((file) => setFileRelativePath( @@ -84,7 +83,7 @@ export const prepareFileUploadFiles = ( allowed === 'mixed' || (allowed === 'folder' ? isDirectoryFile : !isDirectoryFile); - return isAllowed && isAcceptedFile(file, accept); + return isAllowed; }); if (allowsMultiple || filtered.length === 0) return filtered; From de322ae9053940085e8fa80f1ba113d78ad8899f Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 17 Jul 2026 14:31:03 +0300 Subject: [PATCH 23/30] chore(FileUpload): cover accept validation by extension --- .../FileUpload/FileUpload.stories.tsx | 28 +++++++++---------- .../components/FileUpload/FileUpload.test.tsx | 21 ++++++++++++++ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/components/src/components/FileUpload/FileUpload.stories.tsx b/packages/components/src/components/FileUpload/FileUpload.stories.tsx index 2bb5dbb5e..c54e7ee80 100644 --- a/packages/components/src/components/FileUpload/FileUpload.stories.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.stories.tsx @@ -65,20 +65,6 @@ type FileUploadItemData = { type Story = StoryObj>; -const imageFileTypes = [ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.svg', - 'image/png', - 'image/jpeg', - 'image/gif', - 'image/webp', - 'image/svg+xml', -]; - const makeFile = ( name: string, size: number, @@ -522,7 +508,19 @@ export const Validation: Story = { diff --git a/packages/components/src/components/FileUpload/FileUpload.test.tsx b/packages/components/src/components/FileUpload/FileUpload.test.tsx index b95e28e8b..ac7fc1746 100644 --- a/packages/components/src/components/FileUpload/FileUpload.test.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.test.tsx @@ -661,6 +661,27 @@ describe('FileUpload', () => { ).toBeInTheDocument(); }); + it('validates accept by file extension independently of MIME type', async () => { + const accepted = makeSizedFile('image.PNG', 1, ''); + const rejected = makeSizedFile('image.jpg', 1, 'image/png'); + + renderComponent({ + accept: ['.png'], + allowsMultiple: true, + initialItems: [toItem(accepted), toItem(rejected)], + }); + + await waitFor(() => { + expect( + screen.getByText('image.PNG').closest('[data-multiple]') + ).not.toHaveAttribute('data-invalid'); + + expect( + screen.getByText('image.jpg').closest('[data-multiple]') + ).toHaveAttribute('data-invalid'); + }); + }); + it('allows a file at maxFileSize and rejects a larger file', async () => { const exact = makeSizedFile('exact.bin', 10); From ff07acf202a54ed4d8759e07ccc281906593f664 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 17 Jul 2026 14:41:58 +0300 Subject: [PATCH 24/30] chore(FileUpload): improve css-layout --- .../src/components/FileUpload/components/List/List.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/components/src/components/FileUpload/components/List/List.module.css b/packages/components/src/components/FileUpload/components/List/List.module.css index 00c22d28e..715d2b510 100644 --- a/packages/components/src/components/FileUpload/components/List/List.module.css +++ b/packages/components/src/components/FileUpload/components/List/List.module.css @@ -1,5 +1,6 @@ .base { display: flex; + overflow: hidden; inline-size: 100%; position: relative; box-sizing: border-box; From 34e665934f8f6e42ca5ce4461a8776471fd97db8 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 17 Jul 2026 14:46:11 +0300 Subject: [PATCH 25/30] chore(FileUpload): address review feedback --- .../src/components/FileUpload/hooks/useFileDropTarget.ts | 2 +- .../src/utils/formatFileSize/formatFileSize.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/FileUpload/hooks/useFileDropTarget.ts b/packages/components/src/components/FileUpload/hooks/useFileDropTarget.ts index b298659b1..809f48deb 100644 --- a/packages/components/src/components/FileUpload/hooks/useFileDropTarget.ts +++ b/packages/components/src/components/FileUpload/hooks/useFileDropTarget.ts @@ -118,7 +118,7 @@ export const useFileDropTarget = ({ activeTargetRef.current = null; dragDepthRef.current = 0; setIsDropTarget(false); - }, []); + }, [setIsDropTarget]); useEffect(() => { reset(); diff --git a/packages/components/src/utils/formatFileSize/formatFileSize.test.ts b/packages/components/src/utils/formatFileSize/formatFileSize.test.ts index 1b07856bf..97e9307d4 100644 --- a/packages/components/src/utils/formatFileSize/formatFileSize.test.ts +++ b/packages/components/src/utils/formatFileSize/formatFileSize.test.ts @@ -20,7 +20,13 @@ describe('formatFileSize', () => { it('rounds to two fraction digits by default', () => { // 148909 B / 1024 = 145.4189 -> 145.42 - expect(formatFileSize(148909)).toBe('145.42\u00a0KB'); + const value = 148909 / 1024; + + const formatted = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + }).format(value); + + expect(formatFileSize(148909)).toBe(`${formatted}\u00a0KB`); }); it('treats invalid or negative input as 0 bytes', () => { From 9e6f375dbef8fbd7b980ffc4d8917a771e83f168 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Sat, 18 Jul 2026 12:32:34 +0300 Subject: [PATCH 26/30] chore(FileUpload): normalize file size formatting --- .../components/FileUpload/FileUpload.test.tsx | 51 ++++++++++++--- .../src/components/FileUpload/FileUpload.tsx | 26 ++------ .../FileUpload/components/Item/Item.tsx | 4 ++ .../src/components/FileUpload/intl.ts | 10 --- .../src/components/FileUpload/types.ts | 10 ++- .../formatFileSize/formatFileSize.test.ts | 53 ---------------- .../utils/formatFileSize/formatFileSize.ts | 63 ------------------- .../src/utils/formatFileSize/index.ts | 1 - packages/components/src/utils/index.ts | 1 - 9 files changed, 58 insertions(+), 161 deletions(-) delete mode 100644 packages/components/src/utils/formatFileSize/formatFileSize.test.ts delete mode 100644 packages/components/src/utils/formatFileSize/formatFileSize.ts delete mode 100644 packages/components/src/utils/formatFileSize/index.ts diff --git a/packages/components/src/components/FileUpload/FileUpload.test.tsx b/packages/components/src/components/FileUpload/FileUpload.test.tsx index ac7fc1746..1af420dc5 100644 --- a/packages/components/src/components/FileUpload/FileUpload.test.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.test.tsx @@ -937,7 +937,7 @@ describe('FileUpload', () => { }); expect( - screen.getByText('145.42\u00a0KB', { normalizer: (value) => value }) + screen.getByText('148.91\u00a0KB', { normalizer: (value) => value }) ).toBeInTheDocument(); }); @@ -1583,15 +1583,52 @@ describe('FileUpload', () => { it('formats numeric ItemSize children using the current locale', () => { render( - - - {148909} - - + + + + {148909} + + + + ); + + expect( + screen.getByText('148,91\u00a0КБ', { normalizer: (value) => value }) + ).toBeInTheDocument(); + }); + + it('renders nothing for a non-finite numeric ItemSize without throwing', () => { + expect(() => + render( + + + + {Number(undefined)} + + + + ) + ).not.toThrow(); + + expect(screen.queryByTestId('size')).not.toBeInTheDocument(); + }); + + it('formats sizes with the IEC unit system via fileSizeFormat', () => { + render( + + + + {1024} + + + ); expect( - screen.getByText('145.42\u00a0KB', { normalizer: (value) => value }) + screen.getByText('1 KiB', { normalizer: (value) => value }) ).toBeInTheDocument(); }); diff --git a/packages/components/src/components/FileUpload/FileUpload.tsx b/packages/components/src/components/FileUpload/FileUpload.tsx index 6a5b90966..668d4f458 100644 --- a/packages/components/src/components/FileUpload/FileUpload.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.tsx @@ -21,7 +21,7 @@ import { mergeProps, useDOMRef, useKeyedRefs, - useNumberFormatter, + useFileSizeFormatter, useLocalizedStringFormatter, } from '@koobiq/react-core'; import { @@ -34,7 +34,6 @@ import { CollectionRendererContext, } from '@koobiq/react-primitives'; -import { formatFileSize } from '../../utils'; import type { FormFieldProps, FormFieldErrorProps, @@ -118,6 +117,7 @@ function FileUploadInner({ 'aria-details': ariaDetails, 'aria-errormessage': ariaErrorMessage, messages: messageOverrides, + fileSizeFormat, renderEmptyState, 'data-testid': testId, ...other @@ -135,7 +135,6 @@ function FileUploadInner({ ); const stringFormatter = useLocalizedStringFormatter(intlMessages); - const numberFormatter = useNumberFormatter({ maximumFractionDigits: 2 }); const messages = useMemo(() => { const localizedMessages = { @@ -152,16 +151,13 @@ function FileUploadInner({ removeButtonLabel: stringFormatter.format('removeButtonLabel'), unsupportedFileType: stringFormatter.format('unsupportedFileType'), fileSizeLimitExceeded: stringFormatter.format('fileSizeLimitExceeded'), - bytesUnit: stringFormatter.format('bytesUnit'), - kilobytesUnit: stringFormatter.format('kilobytesUnit'), - megabytesUnit: stringFormatter.format('megabytesUnit'), - gigabytesUnit: stringFormatter.format('gigabytesUnit'), - terabytesUnit: stringFormatter.format('terabytesUnit'), }; return { ...localizedMessages, ...messageOverrides }; }, [stringFormatter, messageOverrides]); + const fileSizeFormatter = useFileSizeFormatter(fileSizeFormat); + const getItemRef = useKeyedRefs(); const triggerRef = useRef(null); const pendingFocus = useRef(null); @@ -271,18 +267,8 @@ function FileUploadInner({ }, []); const formatSize = useCallback( - (bytes: number) => - formatFileSize(bytes, { - formatNumber: (fileSize) => numberFormatter.format(fileSize), - unitLabels: { - B: messages.bytesUnit, - KB: messages.kilobytesUnit, - MB: messages.megabytesUnit, - GB: messages.gigabytesUnit, - TB: messages.terabytesUnit, - }, - }), - [numberFormatter, messages] + (bytes: number) => fileSizeFormatter.format(bytes), + [fileSizeFormatter] ); const addFiles = useCallback( diff --git a/packages/components/src/components/FileUpload/components/Item/Item.tsx b/packages/components/src/components/FileUpload/components/Item/Item.tsx index 88458c66a..49e7e9a99 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.tsx +++ b/packages/components/src/components/FileUpload/components/Item/Item.tsx @@ -259,6 +259,10 @@ export const FileUploadItemSize = forwardRef< if (children === undefined || children === null) return null; + // A non-finite numeric size (NaN/Infinity, e.g. from `Number(undefined)`) + // would make the formatter throw during render, so skip it instead. + if (typeof children === 'number' && !Number.isFinite(children)) return null; + return ( >; diff --git a/packages/components/src/components/FileUpload/types.ts b/packages/components/src/components/FileUpload/types.ts index 6919291e2..33c82dbcc 100644 --- a/packages/components/src/components/FileUpload/types.ts +++ b/packages/components/src/components/FileUpload/types.ts @@ -11,6 +11,7 @@ import type { Key, Validation, DataAttributeProps, + FileSizeFormatterConfig, ExtendableComponentPropsWithRef, } from '@koobiq/react-core'; @@ -48,7 +49,7 @@ export type FileUploadPropDropzoneTarget = | 'fullscreen' | RefObject; -/** Localizable strings used by `FileUpload` (aria labels, size units, captions). */ +/** Localizable strings used by `FileUpload`. */ export type FileUploadMessages = { emptyTitle: string; listEmptyText: string; @@ -63,11 +64,6 @@ export type FileUploadMessages = { removeButtonLabel: string; unsupportedFileType: string; fileSizeLimitExceeded: string; - bytesUnit: string; - kilobytesUnit: string; - megabytesUnit: string; - gigabytesUnit: string; - terabytesUnit: string; }; export type FileUploadListSlotProps = { @@ -104,6 +100,8 @@ export type FileUploadProps = renderEmptyState?: () => ReactNode; /** Overrides localized text used by FileUpload. */ messages?: Partial; + /** Configures how file sizes are formatted (unit system, precision, unit labels). */ + fileSizeFormat?: FileSizeFormatterConfig; /** The field label. */ label?: ReactNode; /** Helper text displayed below the upload area. */ diff --git a/packages/components/src/utils/formatFileSize/formatFileSize.test.ts b/packages/components/src/utils/formatFileSize/formatFileSize.test.ts deleted file mode 100644 index 97e9307d4..000000000 --- a/packages/components/src/utils/formatFileSize/formatFileSize.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { formatFileSize } from './formatFileSize'; - -describe('formatFileSize', () => { - it('formats bytes below 1 KB in bytes', () => { - expect(formatFileSize(512)).toBe('512\u00a0B'); - }); - - it('scales to the largest unit that keeps the value >= 1', () => { - expect(formatFileSize(1024)).toBe('1\u00a0KB'); - expect(formatFileSize(1024 * 1024)).toBe('1\u00a0MB'); - expect(formatFileSize(1024 ** 3)).toBe('1\u00a0GB'); - expect(formatFileSize(1024 ** 4)).toBe('1\u00a0TB'); - }); - - it('caps at the largest known unit', () => { - expect(formatFileSize(1024 ** 6)).toContain('TB'); - }); - - it('rounds to two fraction digits by default', () => { - // 148909 B / 1024 = 145.4189 -> 145.42 - const value = 148909 / 1024; - - const formatted = new Intl.NumberFormat(undefined, { - maximumFractionDigits: 2, - }).format(value); - - expect(formatFileSize(148909)).toBe(`${formatted}\u00a0KB`); - }); - - it('treats invalid or negative input as 0 bytes', () => { - expect(formatFileSize(0)).toBe('0\u00a0B'); - expect(formatFileSize(-100)).toBe('0\u00a0B'); - expect(formatFileSize(Number.NaN)).toBe('0\u00a0B'); - }); - - it('uses custom unit labels', () => { - expect( - formatFileSize(1024, { - unitLabels: { KB: 'КБ' }, - }) - ).toBe('1\u00a0КБ'); - }); - - it('uses a custom number formatter', () => { - expect( - formatFileSize(1536, { - formatNumber: (value) => value.toFixed(1), - }) - ).toBe('1.5\u00a0KB'); - }); -}); diff --git a/packages/components/src/utils/formatFileSize/formatFileSize.ts b/packages/components/src/utils/formatFileSize/formatFileSize.ts deleted file mode 100644 index d2cf9bc6d..000000000 --- a/packages/components/src/utils/formatFileSize/formatFileSize.ts +++ /dev/null @@ -1,63 +0,0 @@ -export const fileSizeUnit = ['B', 'KB', 'MB', 'GB', 'TB'] as const; - -export type FileSizeUnit = (typeof fileSizeUnit)[number]; - -export type FileSizeUnitLabels = Record; - -export type FormatFileSizeOptions = { - /** - * Locale-aware number formatter, e.g. the `format` method from React Aria's - * `useNumberFormatter`. When omitted, a default `Intl.NumberFormat` is used. - */ - formatNumber?: (value: number) => string; - /** Localized unit labels. Defaults to English abbreviations (`B`, `KB`, …). */ - unitLabels?: Partial; - /** - * Maximum fraction digits, used only when `formatNumber` is not provided. - * @default 2 - */ - maximumFractionDigits?: number; -}; - -const defaultUnitLabels: FileSizeUnitLabels = { - B: 'B', - KB: 'KB', - MB: 'MB', - GB: 'GB', - TB: 'TB', -}; - -const BASE = 1024; - -/** - * Formats a byte count into a human-readable file size (e.g. `145.42 KB`). - * - * The number is scaled to the largest unit that keeps the value `>= 1` (base - * 1024) and formatted with the provided locale-aware formatter. The value and - * unit are joined with a non-breaking space so they never wrap apart. - */ -export function formatFileSize( - bytes: number, - options: FormatFileSizeOptions = {} -): string { - const { formatNumber, unitLabels, maximumFractionDigits = 2 } = options; - const labels = { ...defaultUnitLabels, ...unitLabels }; - - const safeBytes = Number.isFinite(bytes) && bytes > 0 ? bytes : 0; - - const exponent = - safeBytes === 0 - ? 0 - : Math.min( - Math.floor(Math.log(safeBytes) / Math.log(BASE)), - fileSizeUnit.length - 1 - ); - - const value = safeBytes / BASE ** exponent; - - const formatted = formatNumber - ? formatNumber(value) - : new Intl.NumberFormat(undefined, { maximumFractionDigits }).format(value); - - return `${formatted}\u00a0${labels[fileSizeUnit[exponent]]}`; -} diff --git a/packages/components/src/utils/formatFileSize/index.ts b/packages/components/src/utils/formatFileSize/index.ts deleted file mode 100644 index 6c653c94b..000000000 --- a/packages/components/src/utils/formatFileSize/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './formatFileSize'; diff --git a/packages/components/src/utils/index.ts b/packages/components/src/utils/index.ts index dbc945e0d..53058fe23 100644 --- a/packages/components/src/utils/index.ts +++ b/packages/components/src/utils/index.ts @@ -1,4 +1,3 @@ export * from './getResponsiveValue'; export * from './capitalizeFirstLetter'; export * from './isPrimitiveNode'; -export * from './formatFileSize'; From cc9dfdd7a947ea26c4fb62e0d98b9e9ef8c77609 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Mon, 27 Jul 2026 20:14:20 +0300 Subject: [PATCH 27/30] chore(FileUpload): approve api; update roadmap --- .storybook/components/Roadmap/data.ts | 6 + tools/api-extractor/config.json | 1 + .../components/FileUpload.api.md | 218 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 tools/public_api_guard/components/FileUpload.api.md diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index f80fabf1d..1585e632d 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -368,4 +368,10 @@ export const rows: Rows = [ stage: '🔵 experimental', planned: 'Q3 2026', }, + { + component: 'FileUpload', + status: '✅ Done', + stage: '🔵 experimental', + planned: 'Q3 2026', + }, ]; diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 205f5c093..2b38026e3 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -18,6 +18,7 @@ "DatePicker", "Divider", "EmptyState", + "FileUpload", "Flag", "FlexBox", "Form", diff --git a/tools/public_api_guard/components/FileUpload.api.md b/tools/public_api_guard/components/FileUpload.api.md new file mode 100644 index 000000000..e10131248 --- /dev/null +++ b/tools/public_api_guard/components/FileUpload.api.md @@ -0,0 +1,218 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ButtonBaseProps } from '@koobiq/react-primitives'; +import type { ComponentPropsWithRef } from 'react'; +import type { ComponentRef } from 'react'; +import type { CSSProperties } from 'react'; +import { DataAttributeProps } from '@koobiq/react-core'; +import { DetailedHTMLProps } from 'react'; +import type { ElementType } from 'react'; +import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; +import type { ExtendableProps } from '@koobiq/react-core'; +import type { FileSizeFormatterConfig } from '@koobiq/react-core'; +import { ForwardRefExoticComponent } from 'react'; +import { HTMLAttributes } from 'react'; +import type { Key } from '@koobiq/react-core'; +import { Key as Key_2 } from 'react-aria'; +import { PolyForwardComponent } from '@koobiq/react-core'; +import { ReactElement } from 'react'; +import { ReactNode } from 'react'; +import { RefAttributes } from 'react'; +import type { RefObject } from 'react'; +import { TextProps } from '@koobiq/react-primitives'; +import type { Validation } from '@koobiq/react-core'; +import { ValidationResult } from '@koobiq/react-core'; + +// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts +// +// @public +export const FileUpload: CompoundedComponent; + +// @public (undocumented) +export type FileUploadComponent = (props: FileUploadProps) => ReactElement | null; + +// @public (undocumented) +export type FileUploadContextValue = { + allowsMultiple: boolean; + accept?: string[]; + maxFileSize?: number; + validate?: FileUploadValidate; + allowed: FileUploadPropAllowed; + size: FileUploadPropSize; + isDisabled: boolean; + isDropTarget: boolean; + addFiles: (files: File[]) => void; + removeItem: (id: Key) => void; + getItemRef: (id: Key) => RefObject; + setTriggerRef: (element: HTMLElement | null) => void; + registerItemValidation: (id: Key, name: string, errors: string[]) => void; + unregisterItemValidation: (id: Key) => void; + messages: FileUploadMessages; + formatSize: (bytes: number) => string; +}; + +// @public (undocumented) +export type FileUploadEmptyDescriptionProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadEmptyIconProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadEmptyProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadEmptyTitleProps = ExtendableComponentPropsWithRef; + +// @public +export interface FileUploadFile extends File { + // (undocumented) + readonly relativePath: string; +} + +// @public (undocumented) +export type FileUploadItemContentProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadItemIconProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadItemNameProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadItemProps = ExtendableComponentPropsWithRef<{ + id?: Key; + textValue?: string; + isDisabled?: boolean; + isInvalid?: boolean; + file?: FileUploadFile; +} & DataAttributeProps, 'div'>; + +// @public (undocumented) +export type FileUploadItemSizeProps = ExtendableComponentPropsWithRef; + +// @public (undocumented) +export type FileUploadListSlotProps = { + emptyState?: ComponentPropsWithRef<'div'>; + fileList?: ComponentPropsWithRef<'div'>; + addMore?: ComponentPropsWithRef<'div'>; +}; + +// @public +export type FileUploadMessages = { + emptyTitle: string; + listEmptyText: string; + dropOverlayTitle: string; + addMoreText: string; + alternativeSeparator: string; + browseFile: string; + browseFiles: string; + browseFolder: string; + browseFilesOrFolder: string; + browseFolderMixed: string; + removeButtonLabel: string; + unsupportedFileType: string; + fileSizeLimitExceeded: string; +}; + +// @public (undocumented) +export type FileUploadPropAllowed = (typeof fileUploadPropAllowed)[number]; + +// @public (undocumented) +export const fileUploadPropAllowed: readonly ["file", "folder", "mixed"]; + +// @public +export type FileUploadPropDropzoneTarget = 'fullscreen' | RefObject; + +// Warning: (ae-forgotten-export) The symbol "FormFieldPropLabelAlign" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type FileUploadPropLabelAlign = FormFieldPropLabelAlign; + +// @public (undocumented) +export const fileUploadPropLabelAlign: readonly ["start", "end"]; + +// Warning: (ae-forgotten-export) The symbol "FormFieldPropLabelPlacement" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type FileUploadPropLabelPlacement = FormFieldPropLabelPlacement; + +// @public (undocumented) +export const fileUploadPropLabelPlacement: readonly ["top", "side"]; + +// @public (undocumented) +export type FileUploadProps = ExtendableComponentPropsWithRef<{ + children: ReactNode | ((item: T) => ReactNode); + items?: Iterable; + onAdd?: (files: FileUploadFile[]) => void; + validate?: FileUploadValidate; + onRemove?: (id: Key) => void; + dropzoneTarget?: FileUploadPropDropzoneTarget; + renderEmptyState?: () => ReactNode; + messages?: Partial; + fileSizeFormat?: FileSizeFormatterConfig; + label?: ReactNode; + caption?: ReactNode; + errorMessage?: FormFieldErrorProps['children']; + isLabelHidden?: boolean; + isRequired?: boolean; + labelPlacement?: FileUploadPropLabelPlacement; + labelAlign?: FileUploadPropLabelAlign; + allowsMultiple?: boolean; + accept?: string[]; + maxFileSize?: number; + allowed?: FileUploadPropAllowed; + size?: FileUploadPropSize; + isDisabled?: boolean; + isInvalid?: boolean; + slotProps?: { + list?: FileUploadListSlotProps; + dropOverlay?: ComponentPropsWithRef<'div'>; + label?: FormFieldLabelProps<'span'>; + caption?: FormFieldCaptionProps; + errorMessage?: Omit; + }; + 'data-testid'?: string | number; +}, 'div'>; + +// @public (undocumented) +export type FileUploadPropSize = (typeof fileUploadPropSize)[number]; + +// @public (undocumented) +export const fileUploadPropSize: readonly ["default", "compact"]; + +// @public (undocumented) +export type FileUploadRef = ComponentRef<'div'>; + +// Warning: (ae-forgotten-export) The symbol "IconButtonProps" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type FileUploadRemoveButtonProps = IconButtonProps; + +// @public (undocumented) +export type FileUploadTriggerProps = { + children?: ReactNode; + acceptDirectory?: boolean; + className?: string; + style?: CSSProperties; + 'data-testid'?: string | number; +}; + +// @public (undocumented) +export type FileUploadValidate = NonNullable['validate']>; + +// @public (undocumented) +export const useFileUploadContext: () => FileUploadContextValue; + +// Warnings were encountered during analysis: +// +// packages/components/dist/components/FileUpload/types.d.ts:65:5 - (ae-forgotten-export) The symbol "FormFieldErrorProps" needs to be exported by the entry point index.d.ts +// packages/components/dist/components/FileUpload/types.d.ts:107:9 - (ae-forgotten-export) The symbol "FormFieldLabelProps" needs to be exported by the entry point index.d.ts +// packages/components/dist/components/FileUpload/types.d.ts:108:9 - (ae-forgotten-export) The symbol "FormFieldCaptionProps" needs to be exported by the entry point index.d.ts + +// (No @packageDocumentation comment for this package) + +``` From 0f3c3b1300279f4b86ef69d23c22cbdfb5fecb47 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Mon, 27 Jul 2026 20:14:49 +0300 Subject: [PATCH 28/30] chore(FileUpload): align file size column --- .../src/components/FileUpload/FileUpload.mdx | 5 ++--- .../src/components/FileUpload/FileUpload.module.css | 1 - .../FileUpload/components/Item/Item.module.css | 10 +++++++++- .../FileUpload/components/List/List.module.css | 3 +++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/components/src/components/FileUpload/FileUpload.mdx b/packages/components/src/components/FileUpload/FileUpload.mdx index 94508f4d9..3474c0d62 100644 --- a/packages/components/src/components/FileUpload/FileUpload.mdx +++ b/packages/components/src/components/FileUpload/FileUpload.mdx @@ -11,10 +11,9 @@ import * as Stories from './FileUpload.stories'; # FileUpload - + -FileUpload lets users select, display, and remove files. It reports user actions -but does not upload or store files. +Allows users to upload files to the product. ## Import diff --git a/packages/components/src/components/FileUpload/FileUpload.module.css b/packages/components/src/components/FileUpload/FileUpload.module.css index 54dd308b9..7483b8bd4 100644 --- a/packages/components/src/components/FileUpload/FileUpload.module.css +++ b/packages/components/src/components/FileUpload/FileUpload.module.css @@ -18,7 +18,6 @@ --file-upload-multiple-min-inline-size: 320px; --file-upload-grid-cell-padding-inline: var(--kbq-size-s); --file-upload-grid-cell-gap: var(--kbq-size-s); - --file-upload-file-size-inline-size: var(--kbq-size-7xl); --file-upload-compact-padding-block: var(--kbq-size-m); --file-upload-compact-padding-inline: var(--kbq-size-l); diff --git a/packages/components/src/components/FileUpload/components/Item/Item.module.css b/packages/components/src/components/FileUpload/components/Item/Item.module.css index ad232b1cc..174e1b8c8 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.module.css +++ b/packages/components/src/components/FileUpload/components/Item/Item.module.css @@ -33,7 +33,10 @@ } .base[data-multiple] { + display: grid; cursor: pointer; + grid-column: 1 / -1; + grid-template-columns: subgrid; } .base[data-multiple][data-hovered]::after { @@ -84,6 +87,12 @@ gap: var(--kbq-size-s); } +.base[data-multiple] .content { + display: grid; + grid-column: 2 / 4; + grid-template-columns: subgrid; +} + .name { @mixin ellipsis; @@ -97,7 +106,6 @@ text-align: start; white-space: nowrap; color: var(--file-upload-file-size-color); - inline-size: var(--file-upload-file-size-inline-size); } .remove { diff --git a/packages/components/src/components/FileUpload/components/List/List.module.css b/packages/components/src/components/FileUpload/components/List/List.module.css index 715d2b510..17a2fe10c 100644 --- a/packages/components/src/components/FileUpload/components/List/List.module.css +++ b/packages/components/src/components/FileUpload/components/List/List.module.css @@ -37,8 +37,11 @@ } .list[data-multiple] { + display: grid; flex: 1 1 auto; overflow-y: auto; + align-content: start; + grid-template-columns: max-content minmax(0, 1fr) max-content max-content; scrollbar-width: auto; scrollbar-gutter: auto; scrollbar-color: var(--kbq-scrollbar-thumb-default-background) From aa8429639d1c387e0b710cf0ef671d823cba7811 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 28 Jul 2026 10:10:08 +0300 Subject: [PATCH 29/30] chore(FileUpload): hide false focus ring after file selection --- .../components/FileUpload/FileUpload.test.tsx | 18 ++++++++++++++++++ .../components/Trigger/Trigger.module.css | 3 +++ .../FileUpload/components/Trigger/Trigger.tsx | 5 ++++- 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/components/FileUpload/components/Trigger/Trigger.module.css diff --git a/packages/components/src/components/FileUpload/FileUpload.test.tsx b/packages/components/src/components/FileUpload/FileUpload.test.tsx index 1af420dc5..18cceced8 100644 --- a/packages/components/src/components/FileUpload/FileUpload.test.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.test.tsx @@ -1156,6 +1156,24 @@ describe('FileUpload', () => { expect(clickSpy).toHaveBeenCalled(); }); + it('opens the file dialog when the browse link is activated with Enter', async () => { + const user = userEvent.setup(); + + renderComponent(); + + const trigger = screen.getByText('select a file'); + + const clickSpy = vi + .spyOn(getFileInput(), 'click') + .mockImplementation(() => undefined); + + await user.tab(); + await user.keyboard('{Enter}'); + + expect(trigger).toHaveFocus(); + expect(clickSpy).toHaveBeenCalledOnce(); + }); + it('supports a custom browse link label', async () => { const user = userEvent.setup(); diff --git a/packages/components/src/components/FileUpload/components/Trigger/Trigger.module.css b/packages/components/src/components/FileUpload/components/Trigger/Trigger.module.css new file mode 100644 index 000000000..204ed7e5b --- /dev/null +++ b/packages/components/src/components/FileUpload/components/Trigger/Trigger.module.css @@ -0,0 +1,3 @@ +.base[data-focus-visible]:not(:focus-visible) { + outline-color: transparent; +} diff --git a/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx b/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx index 6a0f2d590..0f64deae3 100644 --- a/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx +++ b/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx @@ -2,6 +2,7 @@ import { forwardRef } from 'react'; +import { clsx } from '@koobiq/react-core'; import { FileTrigger } from '@koobiq/react-primitives'; import { Link } from '../../../Link'; @@ -9,6 +10,8 @@ import { useFileUploadContext } from '../../FileUploadContext'; import type { FileUploadTriggerProps } from '../../types'; import { resolveBrowseText } from '../../utils'; +import s from './Trigger.module.css'; + /** * Opens the system file dialog. The ref points to the underlying hidden file * input. @@ -67,7 +70,7 @@ export const FileUploadTrigger = forwardRef< isPseudo style={style} ref={setTriggerRef} - className={className} + className={clsx(s.base, className)} isDisabled={isDisabled} data-testid={testId} > From 905917bd18cf4285df945cebed083b6671e16864 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Tue, 28 Jul 2026 10:23:39 +0300 Subject: [PATCH 30/30] chore(FileUpload): align subcomponent naming conventions --- .../components/src/components/FileUpload/FileUpload.tsx | 2 +- .../FileUpload/components/DropTargetOverlay/index.ts | 1 - .../src/components/FileUpload/components/Empty/index.ts | 1 - .../FileUploadDropTargetOverlay.module.css} | 4 ++-- .../FileUploadDropTargetOverlay.tsx} | 4 ++-- .../components/FileUploadDropTargetOverlay/index.ts | 1 + .../FileUploadEmpty.module.css} | 2 +- .../Empty.tsx => FileUploadEmpty/FileUploadEmpty.tsx} | 6 +++--- .../FileUpload/components/FileUploadEmpty/index.ts | 1 + .../FileUploadItem.module.css} | 0 .../{Item/Item.tsx => FileUploadItem/FileUploadItem.tsx} | 2 +- .../FileUpload/components/FileUploadItem/index.ts | 1 + .../FileUploadList.module.css} | 0 .../{List/List.tsx => FileUploadList/FileUploadList.tsx} | 8 ++++---- .../FileUploadListAddMore.module.css} | 0 .../FileUploadListAddMore.tsx} | 4 ++-- .../FileUploadListEmpty.module.css} | 0 .../FileUploadListEmpty.tsx} | 4 ++-- .../FileUpload/components/FileUploadList/index.ts | 3 +++ .../FileUploadTrigger.module.css} | 0 .../FileUploadTrigger.tsx} | 2 +- .../FileUpload/components/FileUploadTrigger/index.ts | 1 + .../src/components/FileUpload/components/Item/index.ts | 1 - .../src/components/FileUpload/components/List/index.ts | 3 --- .../src/components/FileUpload/components/Trigger/index.ts | 1 - .../src/components/FileUpload/components/index.ts | 8 ++++---- 26 files changed, 30 insertions(+), 30 deletions(-) delete mode 100644 packages/components/src/components/FileUpload/components/DropTargetOverlay/index.ts delete mode 100644 packages/components/src/components/FileUpload/components/Empty/index.ts rename packages/components/src/components/FileUpload/components/{DropTargetOverlay/DropTargetOverlay.module.css => FileUploadDropTargetOverlay/FileUploadDropTargetOverlay.module.css} (94%) rename packages/components/src/components/FileUpload/components/{DropTargetOverlay/DropTargetOverlay.tsx => FileUploadDropTargetOverlay/FileUploadDropTargetOverlay.tsx} (95%) create mode 100644 packages/components/src/components/FileUpload/components/FileUploadDropTargetOverlay/index.ts rename packages/components/src/components/FileUpload/components/{Empty/Empty.module.css => FileUploadEmpty/FileUploadEmpty.module.css} (97%) rename packages/components/src/components/FileUpload/components/{Empty/Empty.tsx => FileUploadEmpty/FileUploadEmpty.tsx} (95%) create mode 100644 packages/components/src/components/FileUpload/components/FileUploadEmpty/index.ts rename packages/components/src/components/FileUpload/components/{Item/Item.module.css => FileUploadItem/FileUploadItem.module.css} (100%) rename packages/components/src/components/FileUpload/components/{Item/Item.tsx => FileUploadItem/FileUploadItem.tsx} (99%) create mode 100644 packages/components/src/components/FileUpload/components/FileUploadItem/index.ts rename packages/components/src/components/FileUpload/components/{List/List.module.css => FileUploadList/FileUploadList.module.css} (100%) rename packages/components/src/components/FileUpload/components/{List/List.tsx => FileUploadList/FileUploadList.tsx} (91%) rename packages/components/src/components/FileUpload/components/{List/ListAddMore.module.css => FileUploadList/FileUploadListAddMore.module.css} (100%) rename packages/components/src/components/FileUpload/components/{List/ListAddMore.tsx => FileUploadList/FileUploadListAddMore.tsx} (91%) rename packages/components/src/components/FileUpload/components/{List/ListEmpty.module.css => FileUploadList/FileUploadListEmpty.module.css} (100%) rename packages/components/src/components/FileUpload/components/{List/ListEmpty.tsx => FileUploadList/FileUploadListEmpty.tsx} (91%) create mode 100644 packages/components/src/components/FileUpload/components/FileUploadList/index.ts rename packages/components/src/components/FileUpload/components/{Trigger/Trigger.module.css => FileUploadTrigger/FileUploadTrigger.module.css} (100%) rename packages/components/src/components/FileUpload/components/{Trigger/Trigger.tsx => FileUploadTrigger/FileUploadTrigger.tsx} (97%) create mode 100644 packages/components/src/components/FileUpload/components/FileUploadTrigger/index.ts delete mode 100644 packages/components/src/components/FileUpload/components/Item/index.ts delete mode 100644 packages/components/src/components/FileUpload/components/List/index.ts delete mode 100644 packages/components/src/components/FileUpload/components/Trigger/index.ts diff --git a/packages/components/src/components/FileUpload/FileUpload.tsx b/packages/components/src/components/FileUpload/FileUpload.tsx index 668d4f458..254827c44 100644 --- a/packages/components/src/components/FileUpload/FileUpload.tsx +++ b/packages/components/src/components/FileUpload/FileUpload.tsx @@ -56,7 +56,7 @@ import { FileUploadTrigger, FileUploadEmptyDescription, } from './components'; -import { FileUploadDropTargetOverlay } from './components/DropTargetOverlay'; +import { FileUploadDropTargetOverlay } from './components/FileUploadDropTargetOverlay'; import s from './FileUpload.module.css'; import type { FileUploadContextValue } from './FileUploadContext'; import { FileUploadContext } from './FileUploadContext'; diff --git a/packages/components/src/components/FileUpload/components/DropTargetOverlay/index.ts b/packages/components/src/components/FileUpload/components/DropTargetOverlay/index.ts deleted file mode 100644 index 6dd7b0bb5..000000000 --- a/packages/components/src/components/FileUpload/components/DropTargetOverlay/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './DropTargetOverlay'; diff --git a/packages/components/src/components/FileUpload/components/Empty/index.ts b/packages/components/src/components/FileUpload/components/Empty/index.ts deleted file mode 100644 index 47dea7850..000000000 --- a/packages/components/src/components/FileUpload/components/Empty/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Empty'; diff --git a/packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.module.css b/packages/components/src/components/FileUpload/components/FileUploadDropTargetOverlay/FileUploadDropTargetOverlay.module.css similarity index 94% rename from packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.module.css rename to packages/components/src/components/FileUpload/components/FileUploadDropTargetOverlay/FileUploadDropTargetOverlay.module.css index 0f745db98..1c2c0b68a 100644 --- a/packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.module.css +++ b/packages/components/src/components/FileUpload/components/FileUploadDropTargetOverlay/FileUploadDropTargetOverlay.module.css @@ -1,4 +1,4 @@ -.overlay { +.base { z-index: var(--kbq-layer-modal); display: flex; position: fixed; @@ -13,7 +13,7 @@ border: var(--kbq-size-border-width) dashed var(--kbq-line-theme-fade); } -.overlay[data-fullscreen] { +.base[data-fullscreen] { border-radius: 0; border: none; diff --git a/packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.tsx b/packages/components/src/components/FileUpload/components/FileUploadDropTargetOverlay/FileUploadDropTargetOverlay.tsx similarity index 95% rename from packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.tsx rename to packages/components/src/components/FileUpload/components/FileUploadDropTargetOverlay/FileUploadDropTargetOverlay.tsx index 848cd825d..6b97a3d42 100644 --- a/packages/components/src/components/FileUpload/components/DropTargetOverlay/DropTargetOverlay.tsx +++ b/packages/components/src/components/FileUpload/components/FileUploadDropTargetOverlay/FileUploadDropTargetOverlay.tsx @@ -6,7 +6,7 @@ import type { CSSProperties, ReactNode, ComponentPropsWithoutRef } from 'react'; import { clsx } from '@koobiq/react-core'; import { Overlay } from '@koobiq/react-primitives'; -import s from './DropTargetOverlay.module.css'; +import s from './FileUploadDropTargetOverlay.module.css'; type FileUploadDropTargetOverlayProps = { children: ReactNode; @@ -83,7 +83,7 @@ export const FileUploadDropTargetOverlay = forwardRef< {...other} ref={ref} style={{ ...styleProp, ...targetStyle }} - className={clsx(s.overlay, className)} + className={clsx(s.base, className)} data-slot="overlay" data-fullscreen={isFullscreen || undefined} > diff --git a/packages/components/src/components/FileUpload/components/FileUploadDropTargetOverlay/index.ts b/packages/components/src/components/FileUpload/components/FileUploadDropTargetOverlay/index.ts new file mode 100644 index 000000000..1e2fa1401 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/FileUploadDropTargetOverlay/index.ts @@ -0,0 +1 @@ +export * from './FileUploadDropTargetOverlay'; diff --git a/packages/components/src/components/FileUpload/components/Empty/Empty.module.css b/packages/components/src/components/FileUpload/components/FileUploadEmpty/FileUploadEmpty.module.css similarity index 97% rename from packages/components/src/components/FileUpload/components/Empty/Empty.module.css rename to packages/components/src/components/FileUpload/components/FileUploadEmpty/FileUploadEmpty.module.css index e0dac179c..a10ad5330 100644 --- a/packages/components/src/components/FileUpload/components/Empty/Empty.module.css +++ b/packages/components/src/components/FileUpload/components/FileUploadEmpty/FileUploadEmpty.module.css @@ -1,4 +1,4 @@ -.empty { +.base { color: var(--file-upload-caption-color); } diff --git a/packages/components/src/components/FileUpload/components/Empty/Empty.tsx b/packages/components/src/components/FileUpload/components/FileUploadEmpty/FileUploadEmpty.tsx similarity index 95% rename from packages/components/src/components/FileUpload/components/Empty/Empty.tsx rename to packages/components/src/components/FileUpload/components/FileUploadEmpty/FileUploadEmpty.tsx index 9026f968e..c169cb951 100644 --- a/packages/components/src/components/FileUpload/components/Empty/Empty.tsx +++ b/packages/components/src/components/FileUpload/components/FileUploadEmpty/FileUploadEmpty.tsx @@ -13,9 +13,9 @@ import type { FileUploadEmptyTitleProps, FileUploadEmptyDescriptionProps, } from '../../types'; -import { FileUploadTriggers } from '../Trigger'; +import { FileUploadTriggers } from '../FileUploadTrigger'; -import s from './Empty.module.css'; +import s from './FileUploadEmpty.module.css'; export const FileUploadEmpty = forwardRef( (props, ref) => { @@ -39,7 +39,7 @@ export const FileUploadEmpty = forwardRef( data-testid={testId} data-disabled={isDisabled || undefined} data-multiple={allowsMultiple || undefined} - className={clsx(s.empty, className)} + className={clsx(s.base, className)} > {children ?? ( diff --git a/packages/components/src/components/FileUpload/components/FileUploadEmpty/index.ts b/packages/components/src/components/FileUpload/components/FileUploadEmpty/index.ts new file mode 100644 index 000000000..f7af0dbca --- /dev/null +++ b/packages/components/src/components/FileUpload/components/FileUploadEmpty/index.ts @@ -0,0 +1 @@ +export * from './FileUploadEmpty'; diff --git a/packages/components/src/components/FileUpload/components/Item/Item.module.css b/packages/components/src/components/FileUpload/components/FileUploadItem/FileUploadItem.module.css similarity index 100% rename from packages/components/src/components/FileUpload/components/Item/Item.module.css rename to packages/components/src/components/FileUpload/components/FileUploadItem/FileUploadItem.module.css diff --git a/packages/components/src/components/FileUpload/components/Item/Item.tsx b/packages/components/src/components/FileUpload/components/FileUploadItem/FileUploadItem.tsx similarity index 99% rename from packages/components/src/components/FileUpload/components/Item/Item.tsx rename to packages/components/src/components/FileUpload/components/FileUploadItem/FileUploadItem.tsx index 49e7e9a99..d21f6b6b7 100644 --- a/packages/components/src/components/FileUpload/components/Item/Item.tsx +++ b/packages/components/src/components/FileUpload/components/FileUploadItem/FileUploadItem.tsx @@ -34,7 +34,7 @@ import type { } from '../../types'; import { isAcceptedFile } from '../../utils'; -import s from './Item.module.css'; +import s from './FileUploadItem.module.css'; class FileUploadItemNode extends CollectionNode { static readonly type = 'item'; diff --git a/packages/components/src/components/FileUpload/components/FileUploadItem/index.ts b/packages/components/src/components/FileUpload/components/FileUploadItem/index.ts new file mode 100644 index 000000000..f47c4c49e --- /dev/null +++ b/packages/components/src/components/FileUpload/components/FileUploadItem/index.ts @@ -0,0 +1 @@ +export * from './FileUploadItem'; diff --git a/packages/components/src/components/FileUpload/components/List/List.module.css b/packages/components/src/components/FileUpload/components/FileUploadList/FileUploadList.module.css similarity index 100% rename from packages/components/src/components/FileUpload/components/List/List.module.css rename to packages/components/src/components/FileUpload/components/FileUploadList/FileUploadList.module.css diff --git a/packages/components/src/components/FileUpload/components/List/List.tsx b/packages/components/src/components/FileUpload/components/FileUploadList/FileUploadList.tsx similarity index 91% rename from packages/components/src/components/FileUpload/components/List/List.tsx rename to packages/components/src/components/FileUpload/components/FileUploadList/FileUploadList.tsx index 16e4107c4..9f141b32a 100644 --- a/packages/components/src/components/FileUpload/components/List/List.tsx +++ b/packages/components/src/components/FileUpload/components/FileUploadList/FileUploadList.tsx @@ -7,11 +7,11 @@ import { clsx } from '@koobiq/react-core'; import { utilClasses } from '../../../../styles/utility'; import type { FileUploadListSlotProps, FileUploadPropSize } from '../../types'; -import { FileUploadEmpty } from '../Empty'; +import { FileUploadEmpty } from '../FileUploadEmpty'; -import s from './List.module.css'; -import { FileUploadListAddMore } from './ListAddMore'; -import { FileUploadListEmpty } from './ListEmpty'; +import s from './FileUploadList.module.css'; +import { FileUploadListAddMore } from './FileUploadListAddMore'; +import { FileUploadListEmpty } from './FileUploadListEmpty'; type FileUploadListProps = Omit, 'children'> & { children: ReactNode; diff --git a/packages/components/src/components/FileUpload/components/List/ListAddMore.module.css b/packages/components/src/components/FileUpload/components/FileUploadList/FileUploadListAddMore.module.css similarity index 100% rename from packages/components/src/components/FileUpload/components/List/ListAddMore.module.css rename to packages/components/src/components/FileUpload/components/FileUploadList/FileUploadListAddMore.module.css diff --git a/packages/components/src/components/FileUpload/components/List/ListAddMore.tsx b/packages/components/src/components/FileUpload/components/FileUploadList/FileUploadListAddMore.tsx similarity index 91% rename from packages/components/src/components/FileUpload/components/List/ListAddMore.tsx rename to packages/components/src/components/FileUpload/components/FileUploadList/FileUploadListAddMore.tsx index a13f30272..70a9bcae8 100644 --- a/packages/components/src/components/FileUpload/components/List/ListAddMore.tsx +++ b/packages/components/src/components/FileUpload/components/FileUploadList/FileUploadListAddMore.tsx @@ -7,9 +7,9 @@ import { clsx, useDOMRef } from '@koobiq/react-core'; import { IconArrowUpFromBracket16 } from '@koobiq/react-icons'; import { useFileUploadContext } from '../../FileUploadContext'; -import { FileUploadTriggers } from '../Trigger'; +import { FileUploadTriggers } from '../FileUploadTrigger'; -import s from './ListAddMore.module.css'; +import s from './FileUploadListAddMore.module.css'; export const FileUploadListAddMore = forwardRef< HTMLDivElement, diff --git a/packages/components/src/components/FileUpload/components/List/ListEmpty.module.css b/packages/components/src/components/FileUpload/components/FileUploadList/FileUploadListEmpty.module.css similarity index 100% rename from packages/components/src/components/FileUpload/components/List/ListEmpty.module.css rename to packages/components/src/components/FileUpload/components/FileUploadList/FileUploadListEmpty.module.css diff --git a/packages/components/src/components/FileUpload/components/List/ListEmpty.tsx b/packages/components/src/components/FileUpload/components/FileUploadList/FileUploadListEmpty.tsx similarity index 91% rename from packages/components/src/components/FileUpload/components/List/ListEmpty.tsx rename to packages/components/src/components/FileUpload/components/FileUploadList/FileUploadListEmpty.tsx index b8df4707c..175651c99 100644 --- a/packages/components/src/components/FileUpload/components/List/ListEmpty.tsx +++ b/packages/components/src/components/FileUpload/components/FileUploadList/FileUploadListEmpty.tsx @@ -7,9 +7,9 @@ import { clsx, useDOMRef } from '@koobiq/react-core'; import { IconArrowUpFromBracket16 } from '@koobiq/react-icons'; import { useFileUploadContext } from '../../FileUploadContext'; -import { FileUploadTriggers } from '../Trigger'; +import { FileUploadTriggers } from '../FileUploadTrigger'; -import s from './ListEmpty.module.css'; +import s from './FileUploadListEmpty.module.css'; export const FileUploadListEmpty = forwardRef< HTMLDivElement, diff --git a/packages/components/src/components/FileUpload/components/FileUploadList/index.ts b/packages/components/src/components/FileUpload/components/FileUploadList/index.ts new file mode 100644 index 000000000..c1384cac1 --- /dev/null +++ b/packages/components/src/components/FileUpload/components/FileUploadList/index.ts @@ -0,0 +1,3 @@ +export * from './FileUploadList'; +export * from './FileUploadListAddMore'; +export * from './FileUploadListEmpty'; diff --git a/packages/components/src/components/FileUpload/components/Trigger/Trigger.module.css b/packages/components/src/components/FileUpload/components/FileUploadTrigger/FileUploadTrigger.module.css similarity index 100% rename from packages/components/src/components/FileUpload/components/Trigger/Trigger.module.css rename to packages/components/src/components/FileUpload/components/FileUploadTrigger/FileUploadTrigger.module.css diff --git a/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx b/packages/components/src/components/FileUpload/components/FileUploadTrigger/FileUploadTrigger.tsx similarity index 97% rename from packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx rename to packages/components/src/components/FileUpload/components/FileUploadTrigger/FileUploadTrigger.tsx index 0f64deae3..7f76eb501 100644 --- a/packages/components/src/components/FileUpload/components/Trigger/Trigger.tsx +++ b/packages/components/src/components/FileUpload/components/FileUploadTrigger/FileUploadTrigger.tsx @@ -10,7 +10,7 @@ import { useFileUploadContext } from '../../FileUploadContext'; import type { FileUploadTriggerProps } from '../../types'; import { resolveBrowseText } from '../../utils'; -import s from './Trigger.module.css'; +import s from './FileUploadTrigger.module.css'; /** * Opens the system file dialog. The ref points to the underlying hidden file diff --git a/packages/components/src/components/FileUpload/components/FileUploadTrigger/index.ts b/packages/components/src/components/FileUpload/components/FileUploadTrigger/index.ts new file mode 100644 index 000000000..d0c98952f --- /dev/null +++ b/packages/components/src/components/FileUpload/components/FileUploadTrigger/index.ts @@ -0,0 +1 @@ +export * from './FileUploadTrigger'; diff --git a/packages/components/src/components/FileUpload/components/Item/index.ts b/packages/components/src/components/FileUpload/components/Item/index.ts deleted file mode 100644 index c924835a0..000000000 --- a/packages/components/src/components/FileUpload/components/Item/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Item'; diff --git a/packages/components/src/components/FileUpload/components/List/index.ts b/packages/components/src/components/FileUpload/components/List/index.ts deleted file mode 100644 index 57363db9a..000000000 --- a/packages/components/src/components/FileUpload/components/List/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './List'; -export * from './ListAddMore'; -export * from './ListEmpty'; diff --git a/packages/components/src/components/FileUpload/components/Trigger/index.ts b/packages/components/src/components/FileUpload/components/Trigger/index.ts deleted file mode 100644 index 7247adb3f..000000000 --- a/packages/components/src/components/FileUpload/components/Trigger/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Trigger'; diff --git a/packages/components/src/components/FileUpload/components/index.ts b/packages/components/src/components/FileUpload/components/index.ts index 98160927f..27fb4ccf1 100644 --- a/packages/components/src/components/FileUpload/components/index.ts +++ b/packages/components/src/components/FileUpload/components/index.ts @@ -1,4 +1,4 @@ -export * from './Trigger'; -export * from './Item'; -export * from './Empty'; -export * from './List'; +export * from './FileUploadTrigger'; +export * from './FileUploadItem'; +export * from './FileUploadEmpty'; +export * from './FileUploadList';