From 097be1fce42ab3cbf2454ea6bd6d0271290544c9 Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:42:44 +0600 Subject: [PATCH 1/2] feat: add FilePicker component Button-styled file input mirroring Input type="file" styling, with controlled value support (File, File[], or saved filename string), keyboard-accessible hidden native input, and opt-in clearable action. --- .../file-picker/file-picker.stories.tsx | 109 ++++++ src/components/file-picker/file-picker.tsx | 335 ++++++++++++++++++ src/components/file-picker/index.ts | 1 + src/components/index.ts | 1 + 4 files changed, 446 insertions(+) create mode 100644 src/components/file-picker/file-picker.stories.tsx create mode 100644 src/components/file-picker/file-picker.tsx create mode 100644 src/components/file-picker/index.ts diff --git a/src/components/file-picker/file-picker.stories.tsx b/src/components/file-picker/file-picker.stories.tsx new file mode 100644 index 00000000..13494dc2 --- /dev/null +++ b/src/components/file-picker/file-picker.stories.tsx @@ -0,0 +1,109 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import FilePicker from './file-picker'; + +// FilePicker component story configuration +const meta: Meta = { + title: 'Atoms/FilePicker', + component: FilePicker, + parameters: { + layout: 'centered', + }, + tags: [ 'autodocs' ], + argTypes: { + size: { + control: 'select', + options: [ 'sm', 'md', 'lg' ], + }, + }, + decorators: [ + ( Story ) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + size: 'md', + label: 'Upload document', + disabled: false, + error: false, + clearable: false, + }, +}; + +export const PrefilledValue: Story = { + args: { + ...Default.args, + value: 'annual-report-2025.pdf', + }, +}; + +export const ControlledFile: Story = { + args: { + ...Default.args, + value: new File( [ 'force-ui' ], 'design-tokens.json', { + type: 'application/json', + } ), + }, +}; + +export const Multiple: Story = { + args: { + ...Default.args, + multiple: true, + value: [ + new File( [ 'a' ], 'logo-light.svg', { type: 'image/svg+xml' } ), + new File( [ 'b' ], 'logo-dark.svg', { type: 'image/svg+xml' } ), + ], + }, +}; + +export const Clearable: Story = { + args: { + ...Default.args, + clearable: true, + value: 'brand-guidelines.pdf', + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; + +export const Error: Story = { + args: { + ...Default.args, + error: true, + }, +}; + +export const SizeSm: Story = { + args: { + ...Default.args, + size: 'sm', + }, +}; + +export const SizeMd: Story = { + args: { + ...Default.args, + size: 'md', + }, +}; + +export const SizeLg: Story = { + args: { + ...Default.args, + size: 'lg', + }, +}; diff --git a/src/components/file-picker/file-picker.tsx b/src/components/file-picker/file-picker.tsx new file mode 100644 index 00000000..17a1bc3c --- /dev/null +++ b/src/components/file-picker/file-picker.tsx @@ -0,0 +1,335 @@ +import React, { + useState, + useEffect, + useMemo, + forwardRef, + useRef, + LabelHTMLAttributes, +} from 'react'; +import { nanoid } from 'nanoid'; +import { cn } from '@/utilities/functions'; +import { Upload, X } from 'lucide-react'; +import Label from '../label'; +import { mergeRefs } from '@/components/toaster/utils'; + +export declare interface FilePickerProps { + /** + * Unique identifier for the picker trigger element. + * + * @since x.x.x + */ + id?: string; + + /** + * Controlled display value. Accepts a File, an array of Files, or a + * previously saved filename string. Renders in the preview segment as if + * the file was selected via the picker. + * + * @since x.x.x + */ + value?: File | File[] | string | null; + + /** + * Defines the size of the picker (e.g., 'sm', 'md', 'lg'). + * + * @since x.x.x + */ + size?: 'sm' | 'md' | 'lg'; + + /** + * Additional custom classes for styling. + * + * @since x.x.x + */ + className?: string; + + /** + * Disables the picker when true. + * + * @since x.x.x + */ + disabled?: boolean; + + /** + * Function called with the selected FileList, or null when the selection + * is cleared. + * + * @since x.x.x + */ + onChange?: ( files: FileList | null ) => void; + + /** + * Indicates whether the picker has an error state. + * + * @since x.x.x + */ + error?: boolean; + + /** + * Label displayed above the picker. + * + * @since x.x.x + */ + label?: string; + + /** + * Indicates whether a file selection is required. + * + * @since x.x.x + */ + required?: boolean; + + /** + * Shows a remove ("X") affordance when a file is selected, replacing the + * upload icon. Clicking it clears the selection and fires onChange(null). + * + * @since x.x.x + */ + clearable?: boolean; +} + +export const FilePickerComponent = ( + { + id, + value, + size = 'sm', // sm, md, lg + className = '', + disabled = false, + onChange = () => {}, + error = false, + label = '', + required = false, + clearable = false, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + ...props + }: FilePickerProps & + Omit< + React.InputHTMLAttributes, + 'size' | 'onChange' | 'value' | 'type' + >, + ref: React.ForwardedRef +) => { + const inputRef = useRef( null ); + const pickerId = useMemo( () => id || `file-picker-${ nanoid() }`, [ id ] ); + // undefined = untouched (fall back to the value prop), null = explicitly cleared. + const [ selection, setSelection ] = useState( + undefined + ); + + useEffect( () => { + // A changed value prop takes over the display again (controlled replace). + setSelection( undefined ); + }, [ value ] ); + + const valueDisplayName = useMemo( () => { + if ( value === null || value === undefined ) { + return null; + } + if ( typeof value === 'string' ) { + return value || null; + } + if ( Array.isArray( value ) ) { + return value.map( ( file ) => file.name ).join( ', ' ) || null; + } + return value.name; + }, [ value ] ); + + const displayName = selection !== undefined ? selection : valueDisplayName; + + const handleChange = ( event: React.ChangeEvent ) => { + if ( disabled ) { + return; + } + + const files = event.target.files; + if ( files && files.length > 0 ) { + setSelection( + Array.from( files ) + .map( ( file ) => file.name ) + .join( ', ' ) + ); + } else { + setSelection( null ); + } + + if ( typeof onChange !== 'function' ) { + return; + } + onChange( files ); + }; + + const handleReset = () => { + // Reset file selection when "X" icon is clicked + setSelection( null ); + if ( inputRef.current ) { + inputRef.current.value = ''; + } + onChange( null ); + }; + + const openFileDialog = () => { + inputRef.current?.click(); + }; + + const baseClasses = + 'bg-field-secondary-background font-normal placeholder-text-tertiary text-text-primary w-full outline outline-1 outline-border-subtle border-none transition-[color,box-shadow,outline] duration-200'; + const sizeClasses = { + sm: 'p-3 py-2 rounded', + md: 'p-3.5 py-2.5 rounded-md', + lg: 'p-4 py-3 rounded-lg', + }; + const labelClasses = { + sm: 'text-sm font-medium', + md: 'text-sm font-medium', + lg: 'text-base font-medium', + }; + const textClasses = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base', + }; + + const hoverClasses = disabled + ? 'hover:outline-border-disabled' + : 'hover:outline-border-strong'; + const focusClasses = + 'focus:outline-focus-border focus:ring-2 focus:ring-toggle-on focus:ring-offset-2'; + const errorFileClasses = error + ? 'focus:outline-focus-error-border focus:ring-field-color-error outline-focus-error-border' + : ''; + const disabledUploadFileClasses = disabled + ? 'outline-border-disabled cursor-not-allowed text-text-disabled' + : ''; + const uploadIconClasses = disabled + ? 'font-normal placeholder-text-tertiary text-icon-disabled pointer-events-none absolute inset-y-0 flex flex-1 items-center' + : 'font-normal placeholder-text-tertiary text-field-placeholder pointer-events-none absolute inset-y-0 flex flex-1 items-center'; + + const uploadIconSizeClasses = { + sm: '[&>svg]:size-4', + md: '[&>svg]:size-5', + lg: '[&>svg]:size-6', + }; + + const fileClasses = 'pr-10'; + + const renderLabel = useMemo( () => { + if ( ! label ) { + return null; + } + return ( + > + className={ cn( labelClasses[ size ] ) } + htmlFor={ pickerId } + { ...( required && { required: true } ) } + > + { label } + + ); + }, [ label, size, pickerId, required ] ); + + const renderSuffix = () => { + if ( clearable && displayName && ! disabled ) { + return ( +
{ + if ( e.key === 'Enter' || e.key === ' ' ) { + handleReset(); + } + } } + > +
+ ); + } + return ( + + ); + }; + + return ( +
+ { renderLabel } +
+ + { renderSuffix() } + +
+
+ ); +}; + +const FilePicker = forwardRef( FilePickerComponent ); +FilePicker.displayName = 'FilePicker'; + +export default FilePicker; diff --git a/src/components/file-picker/index.ts b/src/components/file-picker/index.ts new file mode 100644 index 00000000..0fc35fb7 --- /dev/null +++ b/src/components/file-picker/index.ts @@ -0,0 +1 @@ +export { default } from './file-picker'; diff --git a/src/components/index.ts b/src/components/index.ts index 9a302428..b2c79e97 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -41,4 +41,5 @@ export { default as AreaChart } from './area-chart'; export { default as Dropzone } from './dropzone'; export { default as Table } from './table'; export { default as FilePreview } from './file-preview'; +export { default as FilePicker } from './file-picker'; export { default as Text } from './text'; From 291b1571c1734e92db7cdc4ee91c87b392211df6 Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:15:00 +0600 Subject: [PATCH 2/2] feat: delegate Input type="file" to FilePicker Input's file branch now renders FilePicker, so existing consumers get controlled value support and the filename preview without code changes. Removes the file-only class constants that became unused. --- src/components/input/input.tsx | 64 ++++++++-------------------------- 1 file changed, 14 insertions(+), 50 deletions(-) diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx index 2d5e12bd..61a5e77a 100644 --- a/src/components/input/input.tsx +++ b/src/components/input/input.tsx @@ -11,6 +11,7 @@ import { nanoid } from 'nanoid'; import { cn } from '@/utilities/functions'; import { Upload, X } from 'lucide-react'; import Label from '../label'; +import FilePicker from '../file-picker'; import { mergeRefs } from '@/components/toaster/utils'; export declare interface InputProps { @@ -169,15 +170,9 @@ export const InputComponent = ( const errorClasses = error ? 'focus:outline-focus-error-border focus:ring-field-color-error outline-focus-error-border' : ''; - const errorFileClasses = error - ? 'focus:outline-focus-error-border focus:ring-field-color-error outline-focus-error-border' - : ''; const disabledClasses = disabled ? 'outline-border-disabled bg-field-background-disabled cursor-not-allowed text-text-disabled' : ''; - const disabledUploadFileClasses = disabled - ? 'outline-border-disabled cursor-not-allowed text-text-disabled file:text-text-tertiary' - : ''; const iconClasses = 'font-normal placeholder-text-tertiary text-text-primary pointer-events-none absolute inset-y-0 flex flex-1 items-center [&>svg]:h-4 [&>svg]:w-4'; const uploadIconClasses = disabled @@ -266,52 +261,21 @@ export const InputComponent = ( ); }, [ label, size, inputId ] ); - const fileClasses = selectedFile - ? 'file:border-0 file:bg-transparent pr-10' - : 'text-text-tertiary file:border-0 file:bg-transparent pr-10'; - if ( type === 'file' ) { return ( -
- { renderLabel } -
- - -
-
+ ); }