diff --git a/apps/site/src/demos/SidebarDemo.tsx b/apps/site/src/demos/SidebarDemo.tsx index 7daa143ee..382cf3d86 100644 --- a/apps/site/src/demos/SidebarDemo.tsx +++ b/apps/site/src/demos/SidebarDemo.tsx @@ -147,6 +147,7 @@ import SearchInputV2Demo from './SearchInputV2Demo' import BadgeDemo from './BadgeDemo' import ChatInputV2Demo from './ChatInputV2Demo' import StepperV2Demo from './StepperV2Demo' +import UploadV2Demo from './UploadV2Demo' const SidebarDemo = () => { const [activeComponent, setActiveComponent] = useState< @@ -249,7 +250,8 @@ const SidebarDemo = () => { | 'searchInputV2' | 'chatInputV2' | 'stepperV2' - >('stepperV2') + | 'uploadV2' + >('uploadV2') const [activeTenant, setActiveTenant] = useState('Juspay') const [activeMerchant, setActiveMerchant] = @@ -582,6 +584,8 @@ const SidebarDemo = () => { return case 'upload': return + case 'uploadV2': + return case 'codeBlock': return case 'codeEditor': @@ -923,6 +927,13 @@ const SidebarDemo = () => { ), onClick: () => setActiveComponent('upload'), }, + { + label: 'File Upload V2', + leftSlot: ( + + ), + onClick: () => setActiveComponent('uploadV2'), + }, ], }, { diff --git a/apps/site/src/demos/UploadDemo.tsx b/apps/site/src/demos/UploadDemo.tsx index ca49e7d42..aae3697ac 100644 --- a/apps/site/src/demos/UploadDemo.tsx +++ b/apps/site/src/demos/UploadDemo.tsx @@ -14,6 +14,8 @@ import { Video, Music, } from 'lucide-react' +import Block from '../../../../packages/blend/lib/components/Primitives/Block/Block' +import Text from '../../../../packages/blend/lib/components/Text/Text' const UploadDemo = () => { // Playground state @@ -27,7 +29,14 @@ const UploadDemo = () => { // Simple state for reset buttons const [resetKey, setResetKey] = useState(0) - const renderCustomSlot = () => + const renderCustomSlot = () => ( + + + Upload Files + + + + ) return (
diff --git a/apps/site/src/demos/UploadV2Demo.tsx b/apps/site/src/demos/UploadV2Demo.tsx new file mode 100644 index 000000000..a818af761 --- /dev/null +++ b/apps/site/src/demos/UploadV2Demo.tsx @@ -0,0 +1,417 @@ +import { useState } from 'react' +import UploadV2 from '../../../../packages/blend/lib/components/InputsV2/UploadV2/UploadV2' +import type { UploadFileV2 } from '../../../../packages/blend/lib/components/InputsV2/UploadV2/UploadV2.types' +import { Switch } from '../../../../packages/blend/lib/components/Switch' +import { UploadState } from '../../../../packages/blend/lib/components/InputsV2/UploadV2/UploadV2.types' +import { Upload as UploadIcon, CheckCircle, AlertCircle } from 'lucide-react' +import Block from '../../../../packages/blend/lib/components/Primitives/Block/Block' +import { UploadErrorReason } from '../../../../packages/blend/lib/components/InputsV2/UploadV2/UploadV2.types' + +const UploadV2Demo = () => { + // Playground state + const [playgroundMultiple, setPlaygroundMultiple] = useState(false) + const [playgroundDisabled, setPlaygroundDisabled] = useState(false) + const [playgroundRequired, setPlaygroundRequired] = useState(false) + const [playgroundCustomSlot, setPlaygroundCustomSlot] = useState(true) + const [playgroundHelpIconText, setPlaygroundHelpIconText] = useState( + 'Upload your files here. Supported formats include CSV files up to 8MB in size.' + ) + // UploadV2 files state + const [uploadV2Files, setUploadV2Files] = useState([]) + const successDummyFiles: UploadFileV2[] = [ + { + file: new File(['dummy success content'], 'sample_file.csv', { + type: 'text/csv', + }), + isValid: true, + }, + ] + const errorDummyFilesSingle: UploadFileV2[] = [ + { + file: new File(['dummy success content'], 'sample_file.csv', { + type: 'text/csv', + }), + isValid: false, + errorReason: UploadErrorReason.OVERSIZED, + }, + ] + const errorDummyFilesMultiple: UploadFileV2[] = [ + { + file: new File(['dummy success content'], 'sample_file.csv', { + type: 'text/csv', + }), + isValid: true, + }, + { + file: new File(['dummy success content'], 'sample_file.csv', { + type: 'text/csv', + }), + isValid: true, + errorReason: UploadErrorReason.MAX_FILES, + }, + { + file: new File(['dummy success content'], 'sample_file.csv', { + type: 'text/csv', + }), + isValid: false, + errorReason: UploadErrorReason.MAX_FILES, + }, + ] + return ( +
+ {/* Playground Section */} +
+

+ UploadV2 Playground +

+
+
+ + setPlaygroundMultiple(!playgroundMultiple) + } + /> + + setPlaygroundDisabled(!playgroundDisabled) + } + /> + + setPlaygroundRequired(!playgroundRequired) + } + /> + + setPlaygroundCustomSlot(!playgroundCustomSlot) + } + /> + + setPlaygroundHelpIconText((prev) => + prev + ? '' + : 'Upload your files here. Supported formats include CSV files up to 8MB in size.' + ) + } + /> +
+
+ +

+ UploadV2 Idle State +

+ + ) : undefined + } + state={UploadState.IDLE} + multiple={playgroundMultiple} + disabled={playgroundDisabled} + files={uploadV2Files} + onChange={(files) => { + setUploadV2Files(files) + }} + maxSize={8 * 1024 * 1024} + maxFiles={2} + description=".csv only | Max size 8 MB" + uploadHeaderText="Choose a file or drag & drop it here" + /> +
+ +

+ Error State - Single File +

+ + ) : undefined + } + multiple={false} + disabled={playgroundDisabled} + files={errorDummyFilesSingle} + onChange={(files) => { + setUploadV2Files(files) + }} + state={UploadState.ERROR} + // errorText="File type not supported" + maxSize={8 * 1024 * 1024} + maxFiles={2} + description=".csv only | Max size 8 MB" + uploadHeaderText="Uploading sample_file.csv..." + /> +
+ +

+ Error State - Multiple Files +

+ + ) : undefined + } + multiple={true} + disabled={playgroundDisabled} + files={errorDummyFilesMultiple} + onChange={(files) => { + setUploadV2Files(files) + }} + state={UploadState.ERROR} + // errorText="File type not supported" + maxSize={8 * 1024 * 1024} + maxFiles={2} + description=".csv only | Max size 8 MB" + uploadHeaderText="Uploading sample_file.csv..." + /> +
+ +

+ UploadV2 Success State - Single File +

+ + ) : undefined + } + multiple={false} + disabled={playgroundDisabled} + files={successDummyFiles} + onChange={(files) => { + setUploadV2Files(files) + }} + state={UploadState.SUCCESS} + maxSize={8 * 1024 * 1024} + maxFiles={2} + description=" Test files uploaded successfully" + progressBarValue={50} + uploadHeaderText="File uploaded successfully" + /> +
+ +

+ UploadV2 Success State - Multiple Files +

+ + ) : undefined + } + multiple={true} + disabled={playgroundDisabled} + files={successDummyFiles} + onChange={(files) => { + setUploadV2Files(files) + }} + state={UploadState.SUCCESS} + maxSize={8 * 1024 * 1024} + maxFiles={2} + description=" Test files uploaded successfully" + progressBarValue={50} + uploadHeaderText="File uploaded successfully" + /> +
+ +

+ UploadV2 with Progress Bar +

+ + ) : undefined + } + multiple={playgroundMultiple} + disabled={playgroundDisabled} + files={uploadV2Files} + onChange={(files) => { + setUploadV2Files(files) + }} + state={UploadState.UPLOADING} + // errorText="File type not supported" + maxSize={8 * 1024 * 1024} + maxFiles={2} + description=".csv only | Max size 8 MB" + uploadHeaderText="Uploading sample_file.csv..." + progressBarValue={50} + /> +
+
+
+
+
+ ) +} + +export default UploadV2Demo diff --git a/apps/storybook/stories/components/Upload/v2/UploadV2.stories.tsx b/apps/storybook/stories/components/Upload/v2/UploadV2.stories.tsx new file mode 100644 index 000000000..04b4a8c8d --- /dev/null +++ b/apps/storybook/stories/components/Upload/v2/UploadV2.stories.tsx @@ -0,0 +1,246 @@ +import type { Meta, StoryObj } from '@storybook/react' +import React, { useState } from 'react' +import { Upload as UploadIcon, AlertCircle, CheckCircle } from 'lucide-react' +import { + getA11yConfig, + CHROMATIC_CONFIG, +} from '../../../../.storybook/a11y.config' +import { ThemeProvider } from '@juspay/blend-design-system' +import { + UploadErrorReason, + UploadFileV2, + UploadState, + UploadV2, +} from '../../../../../../packages/blend/lib/components/InputsV2/UploadV2' +import { InputSizeV2 } from '../../../../../../packages/blend/lib/components/InputsV2/inputV2.types' + +const baseFile = new File(['dummy content'], 'sample_file.csv', { + type: 'text/csv', +}) + +const successFiles: UploadFileV2[] = [ + { + file: baseFile, + isValid: true, + }, +] + +const errorFilesSingle: UploadFileV2[] = [ + { + file: baseFile, + isValid: false, + errorReason: UploadErrorReason.OVERSIZED, + }, +] + +const errorFilesMultiple: UploadFileV2[] = [ + { + file: new File(['dummy content'], 'sample_1.csv', { type: 'text/csv' }), + isValid: true, + }, + { + file: new File(['dummy content'], 'sample_2.csv', { type: 'text/csv' }), + isValid: false, + errorReason: UploadErrorReason.MAX_FILES, + }, +] + +const ACCEPTED_TYPES = [ + '.csv', + '.txt', + '.pdf', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.ppt', + '.pptx', + '.jpg', + '.jpeg', + '.png', + '.gif', + '.mp4', + '.avi', + '.mov', + '.mp3', + '.wav', +] + +const meta: Meta = { + title: 'Components/Inputs/UploadV2', + component: UploadV2, + decorators: [ + (Story) => ( + + + + ), + ], + parameters: { + layout: 'padded', + a11y: getA11yConfig('form'), + chromatic: CHROMATIC_CONFIG, + docs: { + description: { + component: ` +An upload input component (V2) with drag-and-drop support, validation, upload states, progress, and file list management. + +## Features +- Idle, error, uploading, success, and disabled states +- Single or multiple file uploads +- Max file size and max file count validation +- Upload icon slot support +- File tags with remove action for multiple mode + +## Accessibility +- Uses native file input semantics internally +- Label/help text support via InputLabels V2 +- Required state via \`required\` and \`aria-required\` +- Error state reflected with \`aria-invalid\` + +## Usage +\`\`\`tsx +import { UploadV2, UploadState } from '@juspay/blend-design-system/...' + + +\`\`\` + `, + }, + }, + }, + argTypes: { + label: { control: 'text', table: { category: 'Content' } }, + subLabel: { control: 'text', table: { category: 'Content' } }, + description: { control: 'text', table: { category: 'Content' } }, + uploadHeaderText: { control: 'text', table: { category: 'Content' } }, + helpIconText: { control: 'text', table: { category: 'Content' } }, + size: { + control: 'select', + options: Object.values(InputSizeV2), + table: { category: 'Appearance' }, + }, + state: { + control: 'select', + options: Object.values(UploadState), + table: { category: 'State' }, + }, + required: { control: 'boolean', table: { category: 'Validation' } }, + disabled: { control: 'boolean', table: { category: 'State' } }, + multiple: { control: 'boolean', table: { category: 'Core' } }, + maxSize: { control: 'number', table: { category: 'Validation' } }, + maxFiles: { control: 'number', table: { category: 'Validation' } }, + progressBarValue: { + control: 'number', + table: { category: 'State' }, + }, + progressBarMaxWidth: { + control: 'text', + table: { category: 'Appearance' }, + }, + acceptedFileTypes: { + control: false, + table: { category: 'Validation' }, + }, + slot: { control: false, table: { category: 'Slots' } }, + files: { control: false, table: { category: 'Core' } }, + onChange: { action: 'change', table: { category: 'Events' } }, + }, + tags: ['autodocs'], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + label: 'Upload Files', + subLabel: 'Max 8MB', + description: '.csv only | Max size 8 MB', + uploadHeaderText: 'Choose a file or drag & drop it here', + helpIconText: + 'Upload your files here. Supported formats include CSV files up to 8MB in size.', + size: InputSizeV2.SM, + multiple: true, + disabled: false, + required: false, + maxSize: 8 * 1024 * 1024, + maxFiles: 2, + acceptedFileTypes: ACCEPTED_TYPES, + progressBarValue: 50, + progressBarMaxWidth: '300px', + state: UploadState.IDLE, + slot: , + }, + render: function DefaultUploadStory(args) { + const [files, setFiles] = useState([]) + return ( + { + setFiles(nextFiles) + args.onChange?.(nextFiles) + }} + /> + ) + }, +} + +export const ErrorSingleFile: Story = { + args: { + ...Default.args, + multiple: false, + state: UploadState.ERROR, + uploadHeaderText: 'Uploading sample_file.csv...', + files: errorFilesSingle, + slot: , + }, +} + +export const ErrorMultipleFiles: Story = { + args: { + ...Default.args, + multiple: true, + state: UploadState.ERROR, + files: errorFilesMultiple, + slot: , + }, +} + +export const Success: Story = { + args: { + ...Default.args, + state: UploadState.SUCCESS, + files: successFiles, + uploadHeaderText: 'File uploaded successfully', + description: 'Test files uploaded successfully', + slot: , + }, +} + +export const Uploading: Story = { + args: { + ...Default.args, + state: UploadState.UPLOADING, + files: successFiles, + uploadHeaderText: 'Uploading sample_file.csv...', + slot: , + }, +} + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + state: UploadState.DISABLED, + }, +} diff --git a/packages/blend/Design-docs/Upload/UploadAnatomy.png b/packages/blend/Design-docs/Upload/UploadAnatomy.png new file mode 100644 index 000000000..de095bdf1 Binary files /dev/null and b/packages/blend/Design-docs/Upload/UploadAnatomy.png differ diff --git a/packages/blend/Design-docs/Upload/UploadDoc.md b/packages/blend/Design-docs/Upload/UploadDoc.md new file mode 100644 index 000000000..eea32cb50 --- /dev/null +++ b/packages/blend/Design-docs/Upload/UploadDoc.md @@ -0,0 +1,181 @@ +# UploadV2 Component Documentation + +## Requirements + +Create a scalable upload input component that supports: + +- **Controlled files**: Single source of truth via `files` and `onChange` +- **Labels**: Primary label, optional subLabel, optional required indicator +- **States**: Idle, uploading, success, error, disabled +- **Upload modes**: Single-file and multi-file upload (`multiple`) +- **Validation**: Max size (`maxSize`) and max count (`maxFiles`) with per-file validity (`isValid`) +- **Error reasons**: Typed reasons via `UploadErrorReason` (`oversized`, `maxFiles`, `invalidType`) +- **Slots**: Optional icon/content slot in upload container +- **Drag and drop**: Visual drag states (`drag_enter`, `drag_leave`, `drag_over`, `drop`) +- **Progress**: Upload progress support (`progressBarValue`, `progressBarMaxWidth`) in uploading state +- **Accessibility**: Native hidden file input, `required`, `aria-required`, `aria-invalid`, and fallback aria-label when label is absent +- **Theme**: Light/dark responsive tokens via `useResponsiveTokens('UPLOADV2')` + +## Anatomy + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Top container: Label, SubLabel, Required *, Help icon] │ +├─────────────────────────────────────────────────────────────┤ +│ [Upload container] │ +│ [slot/icon] │ +│ [header text + description / selected-file / status text]│ +│ [browse/replace button OR progress bar OR file tags] │ +│ [error text (if applicable)] │ +├─────────────────────────────────────────────────────────────┤ +│ [Hidden native ] │ +└─────────────────────────────────────────────────────────────┘ +``` + +- **Top container**: `InputLabelsV2` renders label, subLabel, help tooltip, required marker +- **Upload container**: Handles visuals and interaction for click/drag/upload states +- **File tags**: In `multiple` mode, selected files render as removable tags +- **Hidden input**: Real file input handles browser file picker and file list changes + +## Props & Types + +```typescript +enum UploadState { + IDLE = 'idle', + UPLOADING = 'uploading', + SUCCESS = 'success', + ERROR = 'error', + DISABLED = 'disabled', +} + +enum UploadDragState { + DRAG_ENTER = 'drag_enter', + DRAG_LEAVE = 'drag_leave', + DRAG_OVER = 'drag_over', + DROP = 'drop', +} + +const UploadErrorReason = { + OVERSIZED: 'oversized', + MAX_FILES: 'maxFiles', + INVALID_TYPE: 'invalidType', +} as const + +type UploadFileV2 = { + id?: string + file: File + isValid: boolean + errorReason?: 'oversized' | 'maxFiles' | 'invalidType' +} + +type UploadV2Props = { + label?: string + subLabel?: string + description?: string + size?: InputSizeV2 + helpIconText?: string + inputId?: string + required?: boolean + multiple?: boolean + acceptedFileTypes?: string[] + disabled?: boolean + slot?: React.ReactNode + files?: UploadFileV2[] + onChange?: (files: UploadFileV2[]) => void + state?: UploadState + errorText?: string + maxSize?: number + maxFiles?: number + progressBarValue?: number + progressBarMaxWidth?: string + uploadHeaderText?: string +} & Omit< + React.InputHTMLAttributes, + 'size' | 'style' | 'className' | 'multiple' | 'slot' | 'onChange' +> +``` + +### Notes + +- Native input `onChange` is omitted intentionally to avoid conflict with component-level `onChange(files)` +- `files` is fully controlled by consumer; component computes next files and calls `onChange` +- `id` on `UploadFileV2` enables stable remove behavior for duplicate filenames + +## Usage + +```tsx +import { UploadV2, UploadState } from '@juspay/blend-design-system/...' +import { Upload } from 'lucide-react' + +const [files, setFiles] = useState([]) + +} +/> +``` + +## Final Token Type + +`UploadV2` uses responsive token bundles (`sm`/`md`/`lg` breakpoints) with component state keys (`idle`, `uploading`, `success`, `error`, `disabled`) and drag state keys. + +At a high level, tokens cover: + +- **Container layout**: `gap`, paddings, border radius +- **Header text**: title/description/error text typography and colors +- **Upload surface**: border/background by upload and drag states +- **File tags and spacing**: tag stack spacing, width constraints +- **Progress area**: max width and spacing alignment + +## Design Decisions + +### 1. Controlled file model with validation metadata + +**Decision**: Keep file list controlled (`files`) and attach `isValid` + `errorReason` per file. + +**Rationale**: Consumers can render mixed valid/invalid files and decide upload behavior without losing rejected file context. + +### 2. Duplicate-filename safe removal + +**Decision**: Remove files by stable item identity (`id` fallback strategy), not just file name. + +**Rationale**: Two files can have the same `file.name`; name-based removal can accidentally remove multiple entries. + +### 3. Internal state derivation with external override + +**Decision**: Component tracks internal `uploadState`, synchronized from external `state`. + +**Rationale**: Allows both controlled state usage and internal transitions (e.g., validation-triggered error visuals). + +### 4. Graceful error reason normalization + +**Decision**: Normalize error reasons before building messages. + +**Rationale**: Protects UI from unknown/custom values and guarantees fallback messaging. + +### 5. Dynamic error messages with limits + +**Decision**: Include configured `maxSize`/`maxFiles` in validation messages. + +**Rationale**: Error copy becomes actionable and contextual (`Max size is 8 MB`, `Maximum 2 files allowed`). + +### 6. Interaction guard in blocked states + +**Decision**: Block click/drag/remove interactions when `disabled`, `uploading`, or `success`. + +**Rationale**: Prevents accidental mutations during non-editable phases and keeps behavior predictable. + +### 7. Hidden native input + visible rich container + +**Decision**: Use a hidden native file input and route user interactions through styled UI. + +**Rationale**: Keeps browser file picker semantics while enabling custom visuals and drag-drop UX. diff --git a/packages/blend/__tests__/components/UploadV2/UploadV2.accessibility.test.tsx b/packages/blend/__tests__/components/UploadV2/UploadV2.accessibility.test.tsx new file mode 100644 index 000000000..892d4aca5 --- /dev/null +++ b/packages/blend/__tests__/components/UploadV2/UploadV2.accessibility.test.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, screen, act } from '../../test-utils' +import { axe } from 'jest-axe' +import UploadV2 from '../../../lib/components/InputsV2/UploadV2/UploadV2' +import { + UploadErrorReason, + UploadFileV2, + UploadState, +} from '../../../lib/components/InputsV2/UploadV2/UploadV2.types' + +const createMockFile = ( + name: string, + size: number = 1024, + type: string = 'text/csv' +) => { + const file = new File(['test-content'], name, { type }) + Object.defineProperty(file, 'size', { value: size }) + return file +} + +const getFileInput = () => + document.querySelector('input[type="file"]') as HTMLInputElement + +describe('UploadV2 Accessibility', () => { + describe('WCAG 2.1/2.2 Compliance (Level A, AA)', () => { + it('meets WCAG standards for default upload (axe-core validation)', async () => { + const { container } = render( + {}} /> + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + + it('meets WCAG standards for disabled state', async () => { + const { container } = render( + {}} /> + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + + it('meets WCAG standards for error state', async () => { + const files: UploadFileV2[] = [ + { + file: createMockFile('invalid.csv'), + isValid: false, + errorReason: UploadErrorReason.OVERSIZED, + }, + ] + const { container } = render( + {}} + /> + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + + it('meets WCAG standards for success state', async () => { + const files: UploadFileV2[] = [ + { file: createMockFile('done.csv'), isValid: true }, + ] + const { container } = render( + {}} + /> + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + }) + + describe('WCAG 3.3.2 Labels or Instructions (Level A)', () => { + it('renders visible label and description', () => { + render( + {}} + /> + ) + expect(screen.getByText('Upload File')).toBeInTheDocument() + expect(screen.getByText('CSV only, up to 8 MB')).toBeInTheDocument() + }) + + it('sets required semantics on file input', () => { + render( + {}} /> + ) + const input = getFileInput() + expect(input).toHaveAttribute('required') + expect(input).toHaveAttribute('aria-required', 'true') + }) + }) + + describe('WCAG 3.3.1 Error Identification (Level A)', () => { + it('sets aria-invalid true for error state', () => { + const files: UploadFileV2[] = [ + { + file: createMockFile('invalid.csv'), + isValid: false, + errorReason: UploadErrorReason.MAX_FILES, + }, + ] + render( + {}} + /> + ) + expect(getFileInput()).toHaveAttribute('aria-invalid', 'true') + }) + }) + + describe('WCAG 2.1.1 Keyboard (Level A)', () => { + it('browse button is keyboard focusable', () => { + render( {}} />) + const browseButton = screen.getByRole('button', { + name: /browse files/i, + }) + act(() => { + browseButton.focus() + }) + expect(document.activeElement).toBe(browseButton) + }) + }) +}) diff --git a/packages/blend/__tests__/components/UploadV2/UploadV2.test.tsx b/packages/blend/__tests__/components/UploadV2/UploadV2.test.tsx new file mode 100644 index 000000000..56f96e9df --- /dev/null +++ b/packages/blend/__tests__/components/UploadV2/UploadV2.test.tsx @@ -0,0 +1,168 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { fireEvent, render, screen } from '../../test-utils' +import UploadV2 from '../../../lib/components/InputsV2/UploadV2/UploadV2' +import { + UploadErrorReason, + UploadFileV2, + UploadState, +} from '../../../lib/components/InputsV2/UploadV2/UploadV2.types' + +const createMockFile = ( + name: string, + size: number = 1024, + type: string = 'text/csv' +) => { + const file = new File(['test-content'], name, { type }) + Object.defineProperty(file, 'size', { value: size }) + return file +} + +describe('UploadV2 Component', () => { + const getFileInput = () => + document.querySelector('input[type="file"]') as HTMLInputElement + + describe('Rendering', () => { + it('renders with label and description', () => { + render( + {}} + /> + ) + + expect(screen.getByText('Upload Files')).toBeInTheDocument() + expect( + screen.getByText('.csv only | Max size 8 MB') + ).toBeInTheDocument() + expect( + screen.getByText('Choose a file or drag & drop it here') + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /browse files/i }) + ).toBeInTheDocument() + }) + + it('sets required aria attributes', () => { + render( + {}} + /> + ) + + const input = getFileInput() + expect(input).toHaveAttribute('aria-required', 'true') + }) + + it('renders disabled input when disabled', () => { + render( + {}} + /> + ) + + const input = getFileInput() + expect(input).toBeDisabled() + }) + }) + + describe('File selection', () => { + it('calls onChange with validated files on file input change', () => { + const handleChange = vi.fn() + const file = createMockFile('invoice.csv') + + render() + + const input = getFileInput() + fireEvent.change(input, { target: { files: [file] } }) + + expect(handleChange).toHaveBeenCalledTimes(1) + const nextFiles = handleChange.mock.calls[0][0] as UploadFileV2[] + expect(nextFiles).toHaveLength(1) + expect(nextFiles[0].file.name).toBe('invoice.csv') + expect(nextFiles[0].isValid).toBe(true) + }) + + it('marks file invalid when maxSize is exceeded', () => { + const handleChange = vi.fn() + const oversizedFile = createMockFile('large.csv', 10 * 1024 * 1024) + + render( + + ) + + const input = getFileInput() + fireEvent.change(input, { target: { files: [oversizedFile] } }) + + const nextFiles = handleChange.mock.calls[0][0] as UploadFileV2[] + expect(nextFiles[0].isValid).toBe(false) + expect(nextFiles[0].errorReason).toBe(UploadErrorReason.OVERSIZED) + }) + }) + + describe('Remove behavior', () => { + it('removes only one file when duplicate names exist', async () => { + const handleChange = vi.fn() + const duplicateA = createMockFile('duplicate.csv') + const duplicateB = createMockFile('duplicate.csv') + + const files: UploadFileV2[] = [ + { id: 'file-1', file: duplicateA, isValid: true }, + { id: 'file-2', file: duplicateB, isValid: true }, + ] + + const { user } = render( + + ) + + const fileTags = screen.getAllByText('duplicate.csv') + await user.click(fileTags[0]) + + expect(handleChange).toHaveBeenCalledTimes(1) + const nextFiles = handleChange.mock.calls[0][0] as UploadFileV2[] + expect(nextFiles).toHaveLength(1) + expect(nextFiles[0].id).toBe('file-2') + }) + }) + + describe('Error messaging', () => { + it('shows maxSize in error text for single-file error state', () => { + const files: UploadFileV2[] = [ + { + file: createMockFile('oversized.csv', 10 * 1024 * 1024), + isValid: false, + errorReason: UploadErrorReason.OVERSIZED, + }, + ] + + render( + {}} + /> + ) + + expect( + screen.getByText('File is too large. Max size is 8 MB') + ).toBeInTheDocument() + }) + }) +}) diff --git a/packages/blend/lib/components/InputsV2/UploadV2/UploadContainerV2.tsx b/packages/blend/lib/components/InputsV2/UploadV2/UploadContainerV2.tsx new file mode 100644 index 000000000..c291c3ce6 --- /dev/null +++ b/packages/blend/lib/components/InputsV2/UploadV2/UploadContainerV2.tsx @@ -0,0 +1,309 @@ +import React from 'react' +import Block from '../../Primitives/Block/Block' +import { ButtonV2, ButtonV2Size, ButtonV2Type } from '../../ButtonV2' +import { SwapIcon } from '@phosphor-icons/react/dist/ssr/Swap' +import Text from '../../Text/Text' +import { UploadV2TokensType } from './UploadV2.tokens' +import { + TagV2, + TagV2Color, + TagV2Size, + TagV2SubType, + TagV2Type, +} from '../../TagV2' +import { XIcon } from '@phosphor-icons/react' +import TooltipV2 from '../../TooltipV2/TooltipV2' +import { + getFileId, + getValidationMessage, + normalizeUploadErrorReason, + truncateFileNameForTag, +} from './utils' +import { UploadDragState, UploadFileV2, UploadState } from './UploadV2.types' +import { + ProgressBarV2, + ProgressBarV2Appearance, + ProgressBarV2Size, + ProgressBarV2Variant, +} from '../../ProgressBarV2' + +const UploadContainerV2 = ({ + description, + slot, + disabled, + onClick, + tokens, + files, + onFileRemove, + multiple, + state, + errorText = '', + progressBarValue, + progressBarMaxWidth, + uploadHeaderText, + dragState, + maxSize, + maxFiles, +}: { + description: string + slot: React.ReactNode + disabled: boolean + onClick: () => void + tokens: UploadV2TokensType + files: UploadFileV2[] + onFileRemove: (fileId: string) => void + multiple: boolean + state: UploadState + errorText: string + progressBarValue: number + progressBarMaxWidth: string + uploadHeaderText: string + dragState: UploadDragState + maxSize?: number + maxFiles?: number +}) => { + const isUploading = state === UploadState.UPLOADING + const isSuccess = state === UploadState.SUCCESS + const isInteractionBlocked = isUploading || isSuccess + const { uploadContainer } = tokens + const showEmptyDescription = files.length === 0 && !isUploading + const showSingleFileInfo = files.length > 0 && !multiple && !isUploading + const showBrowseButton = files.length === 0 && !isUploading + const showReplaceButton = files.length > 0 && !multiple && !isUploading + const showMultiFileTags = files.length > 0 && multiple && !isUploading + const isDragEnter = dragState === UploadDragState.DRAG_ENTER + const isDragOver = dragState === UploadDragState.DRAG_OVER + const isDrop = dragState === UploadDragState.DROP + const isDragActive = isDragEnter || isDragOver || isDrop + + return ( + + {slot && {slot}} + + + {uploadHeaderText} + + {showEmptyDescription && ( + + {description} + + )} + {showSingleFileInfo && ( + + {'Selected file: ' + files[0].file.name} + + )} + {isUploading && ( + + + {'Please wait while uploading'} + + + )} + + {isUploading && ( + + + + )} + {showBrowseButton && !isInteractionBlocked && ( + { + e.stopPropagation() + onClick() + }} + /> + )} + {showReplaceButton && !isInteractionBlocked && ( + { + e.stopPropagation() + onClick() + }} + leftSlot={{ slot: }} + /> + )} + + {/* for multiple files, show a list of files */} + + {showMultiFileTags && ( + + {files.map((uploadFile, index) => { + const fileId = getFileId(uploadFile, index) + const fileColor = + state === UploadState.SUCCESS + ? TagV2Color.SUCCESS + : uploadFile.isValid + ? TagV2Color.PRIMARY + : TagV2Color.ERROR + const tooltipContent = uploadFile.isValid + ? uploadFile.file.name + : `${uploadFile.file.name} - ${getValidationMessage( + uploadFile.errorReason, + maxSize, + maxFiles + )}` + return ( + + }} + text={truncateFileNameForTag( + uploadFile.file.name + )} + size={TagV2Size.MD} + type={TagV2Type.SUBTLE} + subType={TagV2SubType.ROUNDED} + color={fileColor} + onClick={(e) => { + if (disabled || isInteractionBlocked) + return + e.stopPropagation() + onFileRemove?.(fileId) + }} + /> + + ) + })} + + )} + {state === UploadState.ERROR && !errorText && !multiple && ( + + {(() => { + const invalidFiles = files.filter((f) => !f.isValid) + const hasOversized = invalidFiles.some( + (f) => + normalizeUploadErrorReason(f.errorReason) === + 'oversized' + ) + const hasMaxFiles = invalidFiles.some( + (f) => + normalizeUploadErrorReason(f.errorReason) === + 'maxFiles' + ) + const hasInvalidType = invalidFiles.some( + (f) => + normalizeUploadErrorReason(f.errorReason) === + 'invalidType' + ) + const errors: string[] = [] + if (hasOversized) + errors.push( + getValidationMessage( + 'oversized', + maxSize, + maxFiles + ) + ) + if (hasMaxFiles) + errors.push( + getValidationMessage( + 'maxFiles', + maxSize, + maxFiles + ) + ) + if (hasInvalidType) + errors.push( + getValidationMessage( + 'invalidType', + maxSize, + maxFiles + ) + ) + return errors.length > 0 + ? `${errors.join(', ')}` + : getValidationMessage(undefined, maxSize, maxFiles) + })()} + + )} + {errorText && ( + + {errorText} + + )} + + ) +} + +export default UploadContainerV2 diff --git a/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.dark.tokens.ts b/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.dark.tokens.ts new file mode 100644 index 000000000..8b40bf72b --- /dev/null +++ b/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.dark.tokens.ts @@ -0,0 +1,260 @@ +import { FoundationTokenType } from '../../../tokens/theme.token' +import { ResponsiveUploadV2Tokens } from './UploadV2.tokens' +import { UploadDragState, UploadState } from './UploadV2.types' + +export const getUploadV2DarkTokens = ( + foundationTokens: FoundationTokenType +): ResponsiveUploadV2Tokens => { + return { + sm: { + gap: foundationTokens.unit[8], + topContainer: { + label: { + fontSize: { + sm: foundationTokens.font.fontSize[14], + md: foundationTokens.font.fontSize[14], + lg: foundationTokens.font.fontSize[14], + }, + fontWeight: { + sm: foundationTokens.font.weight[500], + md: foundationTokens.font.weight[500], + lg: foundationTokens.font.weight[500], + }, + color: { + default: foundationTokens.colors.gray[700], + hover: foundationTokens.colors.gray[700], + focus: foundationTokens.colors.gray[700], + disabled: foundationTokens.colors.gray[400], + error: foundationTokens.colors.red[600], + }, + lineHeight: { + sm: foundationTokens.font.lineHeight[20], + md: foundationTokens.font.lineHeight[20], + lg: foundationTokens.font.lineHeight[20], + }, + }, + subLabel: { + fontSize: { + sm: foundationTokens.font.fontSize[14], + md: foundationTokens.font.fontSize[14], + lg: foundationTokens.font.fontSize[14], + }, + fontWeight: { + sm: foundationTokens.font.weight[400], + md: foundationTokens.font.weight[400], + lg: foundationTokens.font.weight[400], + }, + lineHeight: { + sm: foundationTokens.font.lineHeight[20], + md: foundationTokens.font.lineHeight[20], + lg: foundationTokens.font.lineHeight[20], + }, + color: { + default: foundationTokens.colors.gray[400], + hover: foundationTokens.colors.gray[400], + focus: foundationTokens.colors.gray[400], + disabled: foundationTokens.colors.gray[300], + error: foundationTokens.colors.red[600], + }, + }, + helpIcon: { + width: { + sm: foundationTokens.unit[14], + md: foundationTokens.unit[14], + lg: foundationTokens.unit[14], + }, + color: { + default: foundationTokens.colors.gray[400], + hover: foundationTokens.colors.gray[400], + focus: foundationTokens.colors.gray[400], + disabled: foundationTokens.colors.gray[400], + error: foundationTokens.colors.red[600], + }, + }, + required: { + color: foundationTokens.colors.red[600], + }, + }, + uploadContainer: { + gap: foundationTokens.unit[20], + paddingTop: foundationTokens.unit[40], + paddingBottom: foundationTokens.unit[40], + paddingLeft: foundationTokens.unit[40], + paddingRight: foundationTokens.unit[40], + borderRadius: foundationTokens.border.radius[12], + border: { + [UploadState.IDLE]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadDragState.DRAG_ENTER]: `1px dashed ${foundationTokens.colors.primary[500]}`, + [UploadDragState.DRAG_LEAVE]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadDragState.DRAG_OVER]: `1px dashed ${foundationTokens.colors.primary[500]}`, + [UploadDragState.DROP]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadState.UPLOADING]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadState.SUCCESS]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadState.ERROR]: `1px dashed ${foundationTokens.colors.red[200]}`, + [UploadState.DISABLED]: `1px dashed ${foundationTokens.colors.gray[200]}`, + }, + backgroundColor: { + [UploadState.IDLE]: foundationTokens.colors.gray[900], + [UploadDragState.DRAG_ENTER]: + foundationTokens.colors.primary[50], + [UploadDragState.DRAG_LEAVE]: + foundationTokens.colors.gray[900], + [UploadDragState.DRAG_OVER]: + foundationTokens.colors.primary[50], + [UploadDragState.DROP]: foundationTokens.colors.gray[900], + [UploadState.UPLOADING]: foundationTokens.colors.gray[900], + [UploadState.SUCCESS]: foundationTokens.colors.gray[900], + [UploadState.ERROR]: foundationTokens.colors.gray[900], + [UploadState.DISABLED]: foundationTokens.colors.gray[900], + }, + header: { + gap: foundationTokens.unit[4], + title: { + fontSize: foundationTokens.font.fontSize[16], + fontWeight: foundationTokens.font.weight[600], + color: foundationTokens.colors.gray[700], + }, + description: { + fontSize: foundationTokens.font.fontSize[14], + fontWeight: foundationTokens.font.weight[400], + color: foundationTokens.colors.gray[400], + }, + errorText: { + fontSize: foundationTokens.font.fontSize[14], + fontWeight: foundationTokens.font.weight[400], + color: foundationTokens.colors.red[600], + }, + }, + fileTag: { + maxWidth: foundationTokens.unit[200], + gap: foundationTokens.unit[4], + }, + }, + }, + lg: { + gap: foundationTokens.unit[8], + topContainer: { + label: { + fontSize: { + sm: foundationTokens.font.fontSize[14], + md: foundationTokens.font.fontSize[14], + lg: foundationTokens.font.fontSize[14], + }, + fontWeight: { + sm: foundationTokens.font.weight[500], + md: foundationTokens.font.weight[500], + lg: foundationTokens.font.weight[500], + }, + lineHeight: { + sm: foundationTokens.font.lineHeight[20], + md: foundationTokens.font.lineHeight[20], + lg: foundationTokens.font.lineHeight[20], + }, + color: { + default: foundationTokens.colors.gray[700], + hover: foundationTokens.colors.gray[700], + focus: foundationTokens.colors.gray[700], + disabled: foundationTokens.colors.gray[400], + error: foundationTokens.colors.red[600], + }, + }, + subLabel: { + fontSize: { + sm: foundationTokens.font.fontSize[14], + md: foundationTokens.font.fontSize[14], + lg: foundationTokens.font.fontSize[14], + }, + fontWeight: { + sm: foundationTokens.font.weight[400], + md: foundationTokens.font.weight[400], + lg: foundationTokens.font.weight[400], + }, + lineHeight: { + sm: foundationTokens.font.lineHeight[20], + md: foundationTokens.font.lineHeight[20], + lg: foundationTokens.font.lineHeight[20], + }, + color: { + default: foundationTokens.colors.gray[400], + hover: foundationTokens.colors.gray[400], + focus: foundationTokens.colors.gray[400], + disabled: foundationTokens.colors.gray[300], + error: foundationTokens.colors.red[600], + }, + }, + helpIcon: { + width: { + sm: foundationTokens.unit[14], + md: foundationTokens.unit[14], + lg: foundationTokens.unit[14], + }, + color: { + default: foundationTokens.colors.gray[400], + hover: foundationTokens.colors.gray[400], + focus: foundationTokens.colors.gray[400], + disabled: foundationTokens.colors.gray[400], + error: foundationTokens.colors.red[600], + }, + }, + required: { + color: foundationTokens.colors.red[600], + }, + }, + uploadContainer: { + gap: foundationTokens.unit[20], + paddingTop: foundationTokens.unit[40], + paddingBottom: foundationTokens.unit[40], + paddingLeft: foundationTokens.unit[40], + paddingRight: foundationTokens.unit[40], + borderRadius: foundationTokens.border.radius[12], + border: { + [UploadState.IDLE]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadDragState.DRAG_ENTER]: `1px dashed ${foundationTokens.colors.primary[500]}`, + [UploadDragState.DRAG_LEAVE]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadDragState.DRAG_OVER]: `1px dashed ${foundationTokens.colors.primary[500]}`, + [UploadDragState.DROP]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadState.UPLOADING]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadState.SUCCESS]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadState.ERROR]: `1px dashed ${foundationTokens.colors.red[200]}`, + [UploadState.DISABLED]: `1px dashed ${foundationTokens.colors.gray[200]}`, + }, + backgroundColor: { + [UploadState.IDLE]: foundationTokens.colors.gray[900], + [UploadDragState.DRAG_ENTER]: + foundationTokens.colors.primary[50], + [UploadDragState.DRAG_LEAVE]: + foundationTokens.colors.gray[900], + [UploadDragState.DRAG_OVER]: + foundationTokens.colors.primary[50], + [UploadDragState.DROP]: foundationTokens.colors.gray[900], + [UploadState.UPLOADING]: foundationTokens.colors.gray[900], + [UploadState.SUCCESS]: foundationTokens.colors.gray[900], + [UploadState.ERROR]: foundationTokens.colors.gray[900], + [UploadState.DISABLED]: foundationTokens.colors.gray[900], + }, + header: { + gap: foundationTokens.unit[4], + title: { + fontSize: foundationTokens.font.fontSize[16], + fontWeight: foundationTokens.font.weight[600], + color: foundationTokens.colors.gray[100], + }, + description: { + fontSize: foundationTokens.font.fontSize[14], + fontWeight: foundationTokens.font.weight[400], + color: foundationTokens.colors.gray[400], + }, + errorText: { + fontSize: foundationTokens.font.fontSize[14], + fontWeight: foundationTokens.font.weight[400], + color: foundationTokens.colors.red[600], + }, + }, + fileTag: { + maxWidth: foundationTokens.unit[200], + gap: foundationTokens.unit[4], + }, + }, + }, + } +} diff --git a/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.light.tokens.ts b/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.light.tokens.ts new file mode 100644 index 000000000..504717f71 --- /dev/null +++ b/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.light.tokens.ts @@ -0,0 +1,260 @@ +import { FoundationTokenType } from '../../../tokens/theme.token' +import { ResponsiveUploadV2Tokens } from './UploadV2.tokens' +import { UploadDragState, UploadState } from './UploadV2.types' + +export const getUploadV2LightTokens = ( + foundationTokens: FoundationTokenType +): ResponsiveUploadV2Tokens => { + return { + sm: { + gap: foundationTokens.unit[8], + topContainer: { + label: { + fontSize: { + sm: foundationTokens.font.fontSize[14], + md: foundationTokens.font.fontSize[14], + lg: foundationTokens.font.fontSize[14], + }, + fontWeight: { + sm: foundationTokens.font.weight[500], + md: foundationTokens.font.weight[500], + lg: foundationTokens.font.weight[500], + }, + color: { + default: foundationTokens.colors.gray[700], + hover: foundationTokens.colors.gray[700], + focus: foundationTokens.colors.gray[700], + disabled: foundationTokens.colors.gray[400], + error: foundationTokens.colors.red[600], + }, + lineHeight: { + sm: foundationTokens.font.lineHeight[20], + md: foundationTokens.font.lineHeight[20], + lg: foundationTokens.font.lineHeight[20], + }, + }, + subLabel: { + fontSize: { + sm: foundationTokens.font.fontSize[14], + md: foundationTokens.font.fontSize[14], + lg: foundationTokens.font.fontSize[14], + }, + fontWeight: { + sm: foundationTokens.font.weight[400], + md: foundationTokens.font.weight[400], + lg: foundationTokens.font.weight[400], + }, + lineHeight: { + sm: foundationTokens.font.lineHeight[20], + md: foundationTokens.font.lineHeight[20], + lg: foundationTokens.font.lineHeight[20], + }, + color: { + default: foundationTokens.colors.gray[400], + hover: foundationTokens.colors.gray[400], + focus: foundationTokens.colors.gray[400], + disabled: foundationTokens.colors.gray[300], + error: foundationTokens.colors.red[600], + }, + }, + helpIcon: { + width: { + sm: foundationTokens.unit[14], + md: foundationTokens.unit[14], + lg: foundationTokens.unit[14], + }, + color: { + default: foundationTokens.colors.gray[400], + hover: foundationTokens.colors.gray[400], + focus: foundationTokens.colors.gray[400], + disabled: foundationTokens.colors.gray[400], + error: foundationTokens.colors.red[600], + }, + }, + required: { + color: foundationTokens.colors.red[600], + }, + }, + uploadContainer: { + gap: foundationTokens.unit[20], + paddingTop: foundationTokens.unit[20], + paddingBottom: foundationTokens.unit[20], + paddingLeft: foundationTokens.unit[40], + paddingRight: foundationTokens.unit[40], + borderRadius: foundationTokens.border.radius[12], + border: { + [UploadState.IDLE]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadDragState.DRAG_ENTER]: `1px dashed ${foundationTokens.colors.primary[500]}`, + [UploadDragState.DRAG_LEAVE]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadDragState.DRAG_OVER]: `1px dashed ${foundationTokens.colors.primary[500]}`, + [UploadDragState.DROP]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadState.UPLOADING]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadState.SUCCESS]: `1px dashed ${foundationTokens.colors.green[500]}`, + [UploadState.ERROR]: `1px dashed ${foundationTokens.colors.red[500]}`, + [UploadState.DISABLED]: `1px dashed ${foundationTokens.colors.gray[200]}`, + }, + backgroundColor: { + [UploadState.IDLE]: foundationTokens.colors.gray[0], + [UploadDragState.DRAG_ENTER]: + foundationTokens.colors.primary[50], + [UploadDragState.DRAG_LEAVE]: + foundationTokens.colors.gray[0], + [UploadDragState.DRAG_OVER]: + foundationTokens.colors.primary[50], + [UploadDragState.DROP]: foundationTokens.colors.gray[0], + [UploadState.UPLOADING]: foundationTokens.colors.gray[0], + [UploadState.SUCCESS]: foundationTokens.colors.gray[0], + [UploadState.ERROR]: foundationTokens.colors.gray[0], + [UploadState.DISABLED]: foundationTokens.colors.gray[0], + }, + header: { + gap: foundationTokens.unit[4], + title: { + fontSize: foundationTokens.font.fontSize[16], + fontWeight: foundationTokens.font.weight[600], + color: foundationTokens.colors.gray[700], + }, + description: { + fontSize: foundationTokens.font.fontSize[14], + fontWeight: foundationTokens.font.weight[400], + color: foundationTokens.colors.gray[400], + }, + errorText: { + fontSize: foundationTokens.font.fontSize[14], + fontWeight: foundationTokens.font.weight[400], + color: foundationTokens.colors.red[600], + }, + }, + fileTag: { + maxWidth: foundationTokens.unit[200], + gap: foundationTokens.unit[4], + }, + }, + }, + lg: { + gap: foundationTokens.unit[8], + topContainer: { + label: { + fontSize: { + sm: foundationTokens.font.fontSize[14], + md: foundationTokens.font.fontSize[14], + lg: foundationTokens.font.fontSize[14], + }, + fontWeight: { + sm: foundationTokens.font.weight[500], + md: foundationTokens.font.weight[500], + lg: foundationTokens.font.weight[500], + }, + lineHeight: { + sm: foundationTokens.font.lineHeight[20], + md: foundationTokens.font.lineHeight[20], + lg: foundationTokens.font.lineHeight[20], + }, + color: { + default: foundationTokens.colors.gray[700], + hover: foundationTokens.colors.gray[700], + focus: foundationTokens.colors.gray[700], + disabled: foundationTokens.colors.gray[400], + error: foundationTokens.colors.red[600], + }, + }, + subLabel: { + fontSize: { + sm: foundationTokens.font.fontSize[14], + md: foundationTokens.font.fontSize[14], + lg: foundationTokens.font.fontSize[14], + }, + fontWeight: { + sm: foundationTokens.font.weight[400], + md: foundationTokens.font.weight[400], + lg: foundationTokens.font.weight[400], + }, + lineHeight: { + sm: foundationTokens.font.lineHeight[20], + md: foundationTokens.font.lineHeight[20], + lg: foundationTokens.font.lineHeight[20], + }, + color: { + default: foundationTokens.colors.gray[400], + hover: foundationTokens.colors.gray[400], + focus: foundationTokens.colors.gray[400], + disabled: foundationTokens.colors.gray[300], + error: foundationTokens.colors.red[600], + }, + }, + helpIcon: { + width: { + sm: foundationTokens.unit[14], + md: foundationTokens.unit[14], + lg: foundationTokens.unit[14], + }, + color: { + default: foundationTokens.colors.gray[400], + hover: foundationTokens.colors.gray[400], + focus: foundationTokens.colors.gray[400], + disabled: foundationTokens.colors.gray[400], + error: foundationTokens.colors.red[600], + }, + }, + required: { + color: foundationTokens.colors.red[600], + }, + }, + uploadContainer: { + gap: foundationTokens.unit[20], + paddingTop: foundationTokens.unit[20], + paddingBottom: foundationTokens.unit[20], + paddingLeft: foundationTokens.unit[40], + paddingRight: foundationTokens.unit[40], + borderRadius: foundationTokens.border.radius[12], + border: { + [UploadState.IDLE]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadDragState.DRAG_ENTER]: `1px dashed ${foundationTokens.colors.primary[500]}`, + [UploadDragState.DRAG_LEAVE]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadDragState.DRAG_OVER]: `1px dashed ${foundationTokens.colors.primary[500]}`, + [UploadDragState.DROP]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadState.UPLOADING]: `1px dashed ${foundationTokens.colors.gray[200]}`, + [UploadState.SUCCESS]: `1px dashed ${foundationTokens.colors.green[500]}`, + [UploadState.ERROR]: `1px dashed ${foundationTokens.colors.red[500]}`, + [UploadState.DISABLED]: `1px dashed ${foundationTokens.colors.gray[200]}`, + }, + backgroundColor: { + [UploadState.IDLE]: foundationTokens.colors.gray[0], + [UploadDragState.DRAG_ENTER]: + foundationTokens.colors.primary[50], + [UploadDragState.DRAG_LEAVE]: + foundationTokens.colors.gray[0], + [UploadDragState.DRAG_OVER]: + foundationTokens.colors.primary[50], + [UploadDragState.DROP]: foundationTokens.colors.gray[0], + [UploadState.UPLOADING]: foundationTokens.colors.gray[0], + [UploadState.SUCCESS]: foundationTokens.colors.gray[0], + [UploadState.ERROR]: foundationTokens.colors.gray[0], + [UploadState.DISABLED]: foundationTokens.colors.gray[0], + }, + header: { + gap: foundationTokens.unit[4], + title: { + fontSize: foundationTokens.font.fontSize[16], + fontWeight: foundationTokens.font.weight[600], + color: foundationTokens.colors.gray[700], + }, + description: { + fontSize: foundationTokens.font.fontSize[14], + fontWeight: foundationTokens.font.weight[400], + color: foundationTokens.colors.gray[400], + }, + errorText: { + fontSize: foundationTokens.font.fontSize[14], + fontWeight: foundationTokens.font.weight[400], + color: foundationTokens.colors.red[600], + }, + }, + fileTag: { + maxWidth: foundationTokens.unit[200], + gap: foundationTokens.unit[4], + }, + }, + }, + } +} diff --git a/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.tokens.ts b/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.tokens.ts new file mode 100644 index 000000000..3ef065e6d --- /dev/null +++ b/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.tokens.ts @@ -0,0 +1,103 @@ +import { CSSObject } from 'styled-components' +import { InputSizeV2, InputStateV2 } from '../inputV2.types' +import { BreakpointType } from '../../../breakpoints/breakPoints' +import { FoundationTokenType } from '../../../tokens/theme.token' +import { Theme } from '../../../context/theme.enum' +import { getUploadV2DarkTokens } from './UploadV2.dark.tokens' +import { getUploadV2LightTokens } from './UploadV2.light.tokens' +import { UploadDragState, UploadState } from './UploadV2.types' + +export type UploadV2TokensType = { + gap: CSSObject['gap'] + topContainer: { + label: { + fontSize: { + [key in InputSizeV2]: CSSObject['fontSize'] + } + fontWeight: { + [key in InputSizeV2]: CSSObject['fontWeight'] + } + lineHeight: { + [key in InputSizeV2]: CSSObject['lineHeight'] + } + color: { + [key in InputStateV2]: CSSObject['color'] + } + } + subLabel: { + fontSize: { + [key in InputSizeV2]: CSSObject['fontSize'] + } + fontWeight: { + [key in InputSizeV2]: CSSObject['fontWeight'] + } + lineHeight: { + [key in InputSizeV2]: CSSObject['lineHeight'] + } + color: { + [key in InputStateV2]: CSSObject['color'] + } + } + required: { + color: CSSObject['color'] + } + helpIcon: { + width: { + [key in InputSizeV2]: CSSObject['width'] + } + color: { + [key in InputStateV2]: CSSObject['color'] + } + } + } + uploadContainer: { + gap: CSSObject['gap'] + paddingTop: CSSObject['paddingTop'] + paddingBottom: CSSObject['paddingBottom'] + paddingLeft: CSSObject['paddingLeft'] + paddingRight: CSSObject['paddingRight'] + borderRadius: CSSObject['borderRadius'] + border: { + [key in UploadState | UploadDragState]: CSSObject['border'] + } + backgroundColor: { + [key in UploadState | UploadDragState]: CSSObject['backgroundColor'] + } + header: { + gap: CSSObject['gap'] + title: { + fontSize: CSSObject['fontSize'] + fontWeight: CSSObject['fontWeight'] + color: CSSObject['color'] + } + description: { + fontSize: CSSObject['fontSize'] + fontWeight: CSSObject['fontWeight'] + color: CSSObject['color'] + } + errorText: { + fontSize: CSSObject['fontSize'] + fontWeight: CSSObject['fontWeight'] + color: CSSObject['color'] + } + } + fileTag: { + maxWidth: CSSObject['maxWidth'] + gap: CSSObject['gap'] + } + } +} + +export type ResponsiveUploadV2Tokens = { + [key in keyof BreakpointType]: UploadV2TokensType +} + +export const getUploadV2Tokens = ( + foundationToken: FoundationTokenType, + theme: Theme | string = Theme.LIGHT +): ResponsiveUploadV2Tokens => { + if (theme === Theme.DARK || theme === 'dark') { + return getUploadV2DarkTokens(foundationToken) + } + return getUploadV2LightTokens(foundationToken) +} diff --git a/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.tsx b/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.tsx new file mode 100644 index 000000000..58ffe253b --- /dev/null +++ b/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.tsx @@ -0,0 +1,266 @@ +import { forwardRef, useEffect, useId, useRef, useState } from 'react' +import Block from '../../Primitives/Block/Block' +import { + UploadDragState, + UploadErrorReason, + UploadFileV2, + UploadState, + UploadV2Props, +} from './UploadV2.types' +import InputLabelsV2 from '../utils/InputLabels/InputLabelsV2' +import { InputLabelsV2Tokens } from '../inputV2.tokens' +import { useResponsiveTokens } from '../../../hooks/useResponsiveTokens' +import { UploadV2TokensType } from './UploadV2.tokens' +import { InputSizeV2, InputStateV2 } from '../inputV2.types' +import { createClickHandler, getFileId } from './utils' +import UploadContainerV2 from './UploadContainerV2' + +const UploadV2 = forwardRef( + ( + { + label, + subLabel, + description = '', + size = InputSizeV2.SM, + helpIconText, + inputId, + required, + multiple = true, + acceptedFileTypes = [], + disabled = false, + slot, + files = [], + onChange, + state = UploadState.IDLE, + maxSize = 0, + maxFiles = multiple ? undefined : 1, + errorText = '', + progressBarValue = 0, + progressBarMaxWidth = '300px', + uploadHeaderText = 'Choose a file or drag & drop it here', + }, + ref + ) => { + const tokens = useResponsiveTokens('UPLOADV2') + const fileInputRef = useRef(null) + const uploadId = useId() + const [uploadState, setUploadState] = useState( + UploadState.IDLE + ) + const [dragState, setDragState] = useState( + UploadDragState.DRAG_LEAVE + ) + const isUploading = state === UploadState.UPLOADING + const isSuccess = state === UploadState.SUCCESS + const isInteractionBlocked = disabled || isUploading || isSuccess + + // Validate files and mark with isValid flag instead of filtering + const validateFiles = (newFiles: File[]): UploadFileV2[] => { + const limit = maxFiles ?? (multiple ? undefined : 1) + const remainingSlots = limit ? limit - files.length : Infinity + let hasRejection = false + + const validatedFiles: UploadFileV2[] = newFiles.map( + (file, index) => { + // Check maxFiles limit + if (limit && index >= remainingSlots) { + hasRejection = true + return { + id: `${file.name}-${file.size}-${file.lastModified}-${Date.now()}-${index}`, + file, + isValid: false, + errorReason: UploadErrorReason.MAX_FILES, + } + } + + // Check maxSize + if (maxSize && file.size > maxSize) { + hasRejection = true + return { + id: `${file.name}-${file.size}-${file.lastModified}-${Date.now()}-${index}`, + file, + isValid: false, + errorReason: UploadErrorReason.OVERSIZED, + } + } + + return { + id: `${file.name}-${file.size}-${file.lastModified}-${Date.now()}-${index}`, + file, + isValid: true, + } + } + ) + + // Set state based on validation results + if (hasRejection) { + setUploadState(UploadState.ERROR) + } else if (validatedFiles.length > 0) { + setUploadState(UploadState.IDLE) + } + + return validatedFiles + } + useEffect(() => { + if (state === UploadState.UPLOADING) { + setUploadState(UploadState.UPLOADING) + } else if (state === UploadState.SUCCESS) { + setUploadState(UploadState.SUCCESS) + } else if (state === UploadState.ERROR) { + setUploadState(UploadState.ERROR) + } else if (disabled) { + setUploadState(UploadState.DISABLED) + } else { + setUploadState(UploadState.IDLE) + } + }, [state, disabled]) + return ( + + + {/* fake input file */} + { + if (isInteractionBlocked) { + e.preventDefault() + return + } + createClickHandler( + disabled, + fileInputRef as React.RefObject + )() + }} + onDragEnter={(e) => { + if (isInteractionBlocked) { + e.preventDefault() + return + } + setDragState(UploadDragState.DRAG_ENTER) + }} + onDragLeave={(e) => { + if (isInteractionBlocked) { + e.preventDefault() + return + } + setDragState(UploadDragState.DRAG_LEAVE) + }} + onDragOver={(e) => { + if (isInteractionBlocked) { + e.preventDefault() + return + } + e.preventDefault() + setDragState(UploadDragState.DRAG_OVER) + }} + onDrop={(e) => { + if (isInteractionBlocked) { + e.preventDefault() + return + } + e.preventDefault() + e.stopPropagation() + setDragState(UploadDragState.DROP) + const droppedFiles = Array.from(e.dataTransfer.files) + const validatedFiles = validateFiles(droppedFiles) + const updatedFiles = multiple + ? [...files, ...validatedFiles] + : validatedFiles[0] + ? [validatedFiles[0]] + : [] + onChange?.(updatedFiles) + }} + > + { + fileInputRef.current?.click() + }} + tokens={tokens} + files={files} + multiple={multiple} + onFileRemove={(fileId) => { + const updatedFiles = files.filter( + (f, index) => getFileId(f, index) !== fileId + ) + // Check if any invalid files remain + const hasInvalidFiles = updatedFiles.some( + (f) => !f.isValid + ) + setUploadState( + hasInvalidFiles + ? UploadState.ERROR + : UploadState.IDLE + ) + onChange?.(updatedFiles) + }} + state={uploadState} + errorText={errorText} + progressBarValue={progressBarValue} + progressBarMaxWidth={progressBarMaxWidth} + uploadHeaderText={uploadHeaderText} + dragState={dragState} + maxSize={maxSize} + maxFiles={maxFiles} + /> + + + {/* real input file */} + { + const newFiles = Array.from(e.target.files || []) + const validatedFiles = validateFiles(newFiles) + const updatedFiles = multiple + ? [...files, ...validatedFiles] + : validatedFiles[0] + ? [validatedFiles[0]] + : [] + onChange?.(updatedFiles) + e.target.value = '' + }} + disabled={disabled} + required={required} + aria-required={required} + aria-invalid={uploadState === UploadState.ERROR} + aria-describedby={ + [description, errorText, helpIconText] + .filter(Boolean) + .join(' ') || undefined + } + aria-label={label ? undefined : 'File upload'} + style={{ display: 'none' }} + /> + + ) + } +) + +export default UploadV2 diff --git a/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.types.ts b/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.types.ts new file mode 100644 index 000000000..32f2e87a3 --- /dev/null +++ b/packages/blend/lib/components/InputsV2/UploadV2/UploadV2.types.ts @@ -0,0 +1,61 @@ +import { InputSizeV2 } from '../inputV2.types' + +export enum UploadState { + IDLE = 'idle', + UPLOADING = 'uploading', + SUCCESS = 'success', + ERROR = 'error', + + DISABLED = 'disabled', +} + +export enum UploadDragState { + DRAG_ENTER = 'drag_enter', + DRAG_LEAVE = 'drag_leave', + DRAG_OVER = 'drag_over', + DROP = 'drop', +} + +export const UploadErrorReason = { + OVERSIZED: 'oversized', + MAX_FILES: 'maxFiles', + INVALID_TYPE: 'invalidType', +} as const + +export type UploadErrorReasonValue = + (typeof UploadErrorReason)[keyof typeof UploadErrorReason] + +export type UploadFileV2 = { + id?: string + file: File + isValid: boolean + errorReason?: UploadErrorReasonValue +} + +export type UploadV2Props = { + label?: string + subLabel?: string + description?: string + size?: InputSizeV2 + helpIconText?: string + inputId?: string + required?: boolean + multiple?: boolean + /** File types to accept (e.g., ['.jpg', '.png', 'image/*', '.pdf']) */ + acceptedFileTypes?: string[] + disabled?: boolean + slot?: React.ReactNode + files?: UploadFileV2[] + /** Callback when files are selected or changed */ + onChange?: (files: UploadFileV2[]) => void + state?: UploadState + errorText?: string + maxSize?: number + maxFiles?: number + progressBarValue?: number + progressBarMaxWidth?: string + uploadHeaderText?: string +} & Omit< + React.InputHTMLAttributes, + 'size' | 'style' | 'className' | 'multiple' | 'slot' | 'onChange' +> diff --git a/packages/blend/lib/components/InputsV2/UploadV2/index.ts b/packages/blend/lib/components/InputsV2/UploadV2/index.ts new file mode 100644 index 000000000..f921e9506 --- /dev/null +++ b/packages/blend/lib/components/InputsV2/UploadV2/index.ts @@ -0,0 +1,3 @@ +export { default as UploadV2 } from './UploadV2' +export * from './UploadV2.types' +export * from './UploadV2.tokens' diff --git a/packages/blend/lib/components/InputsV2/UploadV2/utils.ts b/packages/blend/lib/components/InputsV2/UploadV2/utils.ts new file mode 100644 index 000000000..5ba0a9b69 --- /dev/null +++ b/packages/blend/lib/components/InputsV2/UploadV2/utils.ts @@ -0,0 +1,147 @@ +import { useState } from 'react' +import { UploadFileV2 } from './UploadV2.types' + +export const FILE_NAME_TAG_MAX_LEN = 15 + +export const createClickHandler = + (disabled: boolean, fileInputRef: React.RefObject) => + () => { + if (disabled) return + fileInputRef.current?.click() + } + +export const useUploadState = () => { + const [internalDragState, setInternalDragState] = useState({ + isDragActive: false, + isDragAccept: false, + isDragReject: false, + }) + const [, setDragCounter] = useState(0) + + return { + internalDragState, + setInternalDragState, + setDragCounter, + } +} + +export const createDragHandlers = ( + disabled: boolean, + setDragCounter: (fn: (prev: number) => number) => void, + updateDragStateFn: (isDragActive: boolean, files?: File[]) => void, + processFilesFn: (files: FileList) => void +) => { + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (disabled) return + + setDragCounter((prev) => { + const newCounter = prev + 1 + if (newCounter === 1 && e.dataTransfer.items) { + const files = Array.from(e.dataTransfer.items) + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()) + .filter(Boolean) as File[] + updateDragStateFn(true, files) + } + return newCounter + }) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (disabled) return + + setDragCounter((prev) => { + const newCounter = prev - 1 + if (newCounter === 0) { + updateDragStateFn(false) + } + return newCounter + }) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (disabled) return + + setDragCounter(() => 0) + updateDragStateFn(false) + + if (e.dataTransfer.files) { + processFilesFn(e.dataTransfer.files) + } + } + + return { + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + } +} + +export const truncateFileNameForTag = (name: string): string => + name.length > FILE_NAME_TAG_MAX_LEN + ? `${name.slice(0, FILE_NAME_TAG_MAX_LEN)}…` + : name + +export const getFileId = (uploadFile: UploadFileV2, index: number) => + uploadFile.id ?? + `${uploadFile.file.name}-${uploadFile.file.size}-${uploadFile.file.lastModified}-${index}` + +const KNOWN_UPLOAD_ERROR_REASONS = [ + 'oversized', + 'maxFiles', + 'invalidType', +] as const +type KnownUploadErrorReason = (typeof KNOWN_UPLOAD_ERROR_REASONS)[number] + +export const formatFileSize = (sizeInBytes: number) => { + if (sizeInBytes >= 1024 * 1024) { + const sizeInMb = sizeInBytes / (1024 * 1024) + return `${Number(sizeInMb.toFixed(2))} MB` + } + if (sizeInBytes >= 1024) { + const sizeInKb = sizeInBytes / 1024 + return `${Number(sizeInKb.toFixed(2))} KB` + } + return `${sizeInBytes} B` +} + +export const isKnownUploadErrorReason = ( + reason: unknown +): reason is KnownUploadErrorReason => + KNOWN_UPLOAD_ERROR_REASONS.includes(reason as KnownUploadErrorReason) + +export const normalizeUploadErrorReason = (reason: unknown) => + isKnownUploadErrorReason(reason) ? reason : undefined + +export const getValidationMessage = ( + reason?: unknown, + maxSize?: number, + maxFiles?: number +) => { + switch (normalizeUploadErrorReason(reason)) { + case 'oversized': + return maxSize + ? `File is too large. Max size is ${formatFileSize(maxSize)}` + : 'File is too large' + case 'maxFiles': + return maxFiles + ? `File limit exceeded. Maximum ${maxFiles} files allowed` + : 'File limit exceeded' + case 'invalidType': + return 'Invalid file type' + default: + return 'Invalid file' + } +} diff --git a/packages/blend/lib/components/Primitives/PrimitiveInput/PrimitiveInput.tsx b/packages/blend/lib/components/Primitives/PrimitiveInput/PrimitiveInput.tsx index d93cb02f6..873e2f02a 100644 --- a/packages/blend/lib/components/Primitives/PrimitiveInput/PrimitiveInput.tsx +++ b/packages/blend/lib/components/Primitives/PrimitiveInput/PrimitiveInput.tsx @@ -231,7 +231,7 @@ const stateToSelector: Record = { export type InputProps = React.InputHTMLAttributes & PrimitiveInputProps & { - as?: 'input' | 'textarea' + as?: 'input' | 'textarea' | 'file' key?: string | number ref?: React.Ref } diff --git a/packages/blend/lib/context/ThemeContext.tsx b/packages/blend/lib/context/ThemeContext.tsx index 20aad05c3..e56ec41b0 100644 --- a/packages/blend/lib/context/ThemeContext.tsx +++ b/packages/blend/lib/context/ThemeContext.tsx @@ -227,6 +227,10 @@ import { getChatInputV2MobileTokens, ChatInputV2MobileTokensType, } from '../components/InputsV2/ChatInputV2/ChatInputV2Mobile.tokens' +import { + ResponsiveUploadV2Tokens, + getUploadV2Tokens, +} from '../components/InputsV2/UploadV2/UploadV2.tokens' export type ComponentTokenType = { TAGS?: ResponsiveTagTokens SEARCH_INPUT?: ResponsiveSearchInputTokens @@ -303,6 +307,7 @@ export type ComponentTokenType = { SEARCH_INPUT_V2?: ResponsiveSearchInputV2Tokens CHAT_INPUTV2_MOBILE?: ChatInputV2MobileTokensType STEPPERV2?: ResponsiveStepperV2Tokens + UPLOADV2?: ResponsiveUploadV2Tokens } type ThemeContextType = { @@ -399,6 +404,7 @@ const ThemeContext = createContext({ Theme.LIGHT ), STEPPERV2: getStepperV2Tokens(FOUNDATION_THEME, Theme.LIGHT), + UPLOADV2: getUploadV2Tokens(FOUNDATION_THEME, Theme.LIGHT), }, breakpoints: BREAKPOINTS, theme: 'light', diff --git a/packages/blend/lib/context/initComponentTokens.ts b/packages/blend/lib/context/initComponentTokens.ts index b9b75b15d..d7abfb8f9 100644 --- a/packages/blend/lib/context/initComponentTokens.ts +++ b/packages/blend/lib/context/initComponentTokens.ts @@ -77,6 +77,7 @@ import { getStepperV2Tokens } from '../components/StepperV2/stepperV2.tokens' import { getChatInputV2Tokens } from '../components/InputsV2/ChatInputV2/ChatInputV2.tokens' import { getChatInputV2MobileTokens } from '../components/InputsV2/ChatInputV2/ChatInputV2Mobile.tokens' +import { getUploadV2Tokens } from '../components/InputsV2/UploadV2/UploadV2.tokens' const initTokens = ( componentTokens: ComponentTokenType, foundationTokens: ThemeType, @@ -253,6 +254,9 @@ const initTokens = ( STEPPERV2: componentTokens.STEPPERV2 ?? getStepperV2Tokens(foundationTokens, theme), + UPLOADV2: + componentTokens.UPLOADV2 ?? + getUploadV2Tokens(foundationTokens, theme), } } diff --git a/packages/blend/lib/context/useComponentToken.ts b/packages/blend/lib/context/useComponentToken.ts index b38ae1f26..200404d7e 100644 --- a/packages/blend/lib/context/useComponentToken.ts +++ b/packages/blend/lib/context/useComponentToken.ts @@ -73,6 +73,7 @@ import { ResponsiveSearchInputV2Tokens } from '../components/InputsV2/SearchInpu import { ResponsiveChatInputV2TokensType } from '../components/InputsV2/ChatInputV2/ChatInputV2.tokens' import { ChatInputV2MobileTokensType } from '../components/InputsV2/ChatInputV2/ChatInputV2Mobile.tokens' import type { ResponsiveStepperV2Tokens } from '../components/StepperV2/stepperV2.tokens' +import { ResponsiveUploadV2Tokens } from '../components/InputsV2/UploadV2/UploadV2.tokens' export const useComponentToken = ( component: keyof ComponentTokenType @@ -151,7 +152,9 @@ export const useComponentToken = ( | ResponsiveBadgeTokens | ResponsiveChatInputV2TokensType | ChatInputV2MobileTokensType - | ResponsiveStepperV2Tokens => { + | ResponsiveStepperV2Tokens + | ResponsiveUploadV2Tokens + | ChatInputV2MobileTokensType => { const { componentTokens } = useTheme() switch (component) { case 'TOOLTIP': @@ -304,6 +307,8 @@ export const useComponentToken = ( return componentTokens.SEARCH_INPUT_V2 case 'STEPPERV2': return componentTokens.STEPPERV2 + case 'UPLOADV2': + return componentTokens.UPLOADV2 default: throw new Error(`Unknown component token: ${component}`) }