diff --git a/.changeset/clear-clubs-talk.md b/.changeset/clear-clubs-talk.md new file mode 100644 index 0000000000..8f787272b7 --- /dev/null +++ b/.changeset/clear-clubs-talk.md @@ -0,0 +1,6 @@ +--- +"@digdir/designsystemet-react": minor +"@digdir/designsystemet-css": minor +--- + +**New component**: FileUpload diff --git a/apps/www/app/content/components/file-upload/en/accessibility.mdx b/apps/www/app/content/components/file-upload/en/accessibility.mdx new file mode 100644 index 0000000000..db5f0b2afd --- /dev/null +++ b/apps/www/app/content/components/file-upload/en/accessibility.mdx @@ -0,0 +1,3 @@ + + Nothing here yet + \ No newline at end of file diff --git a/apps/www/app/content/components/file-upload/en/code.mdx b/apps/www/app/content/components/file-upload/en/code.mdx new file mode 100644 index 0000000000..db5f0b2afd --- /dev/null +++ b/apps/www/app/content/components/file-upload/en/code.mdx @@ -0,0 +1,3 @@ + + Nothing here yet + \ No newline at end of file diff --git a/apps/www/app/content/components/file-upload/en/overview.mdx b/apps/www/app/content/components/file-upload/en/overview.mdx new file mode 100644 index 0000000000..db5f0b2afd --- /dev/null +++ b/apps/www/app/content/components/file-upload/en/overview.mdx @@ -0,0 +1,3 @@ + + Nothing here yet + \ No newline at end of file diff --git a/apps/www/app/content/components/file-upload/file-upload.stories.tsx b/apps/www/app/content/components/file-upload/file-upload.stories.tsx new file mode 100644 index 0000000000..8c0a55933a --- /dev/null +++ b/apps/www/app/content/components/file-upload/file-upload.stories.tsx @@ -0,0 +1,31 @@ +import { Field, FileUpload, Label } from '@digdir/designsystemet-react'; + +export const FileUploadExample = () => ( + + + description text + + Drop file here + + File must be in csv format and less than 2MB + + Upload file + + + +); + +export const FileUploadReactDropzone = () => ( + + + description text + + Drop file here + + File must be in csv format and less than 2MB + + Upload file + + + +); diff --git a/apps/www/app/content/components/file-upload/metadata.json b/apps/www/app/content/components/file-upload/metadata.json new file mode 100644 index 0000000000..503ffa4d87 --- /dev/null +++ b/apps/www/app/content/components/file-upload/metadata.json @@ -0,0 +1,12 @@ +{ + "no": { + "title": "FileUpload", + "subtitle": "`FileUpload` er et skjemaelement for å laste opp filer." + }, + "en": { + "title": "FileUpload", + "subtitle": "`FileUpload` is a form element used to upload files." + }, + "image": "Input.svg", + "cssFile": "file-upload.css" +} diff --git a/apps/www/app/content/components/file-upload/no/accessibility.mdx b/apps/www/app/content/components/file-upload/no/accessibility.mdx new file mode 100644 index 0000000000..db5f0b2afd --- /dev/null +++ b/apps/www/app/content/components/file-upload/no/accessibility.mdx @@ -0,0 +1,3 @@ + + Nothing here yet + \ No newline at end of file diff --git a/apps/www/app/content/components/file-upload/no/code.mdx b/apps/www/app/content/components/file-upload/no/code.mdx new file mode 100644 index 0000000000..2768c10684 --- /dev/null +++ b/apps/www/app/content/components/file-upload/no/code.mdx @@ -0,0 +1,7 @@ +## Midlertidig fileUpload eksempel +regular + +simulated react dropzone (tabindex="-1" on input, tabindex="0" on Field) + + + \ No newline at end of file diff --git a/apps/www/app/content/components/file-upload/no/overview.mdx b/apps/www/app/content/components/file-upload/no/overview.mdx new file mode 100644 index 0000000000..db5f0b2afd --- /dev/null +++ b/apps/www/app/content/components/file-upload/no/overview.mdx @@ -0,0 +1,3 @@ + + Nothing here yet + \ No newline at end of file diff --git a/packages/css/src/file-upload.css b/packages/css/src/file-upload.css new file mode 100644 index 0000000000..7ccf4efc4f --- /dev/null +++ b/packages/css/src/file-upload.css @@ -0,0 +1,109 @@ +/*double class to increase specificity above .ds-field :is() (0.2.0 + order)*/ +.ds-file-upload { + --dsc-file-upload-background: var(--ds-color-surface-default); + --dsc-file-upload-background--hover: var(--ds-color-surface-tinted); + --dsc-file-upload-icon-color: var(--ds-color-text-subtle); + --dsc-file-upload-border-color: var(--ds-color-border-default); + --dsc-file-upload-border-radius: var(--ds-border-radius-md); + --dsc-file-upload-border-width: max(2px, 0.125rem); + --dsc-file-upload-border-style: dashed; + --dsc-file-upload-border-style--hover: solid; + --dsc-file-upload-color: currentcolor; + --dsc-file-upload-padding: var(--ds-size-8) var(--ds-size-6); + --dsc-file-upload-icon-size: var(--ds-size-8); + --dsc-file-upload-icon-url: url('data:image/svg+xml,'); + --dsc-file-upload-icon-url--readonly: url('data:image/svg+xml,'); + + --_icon-url: var(--dsc-file-upload-icon-url); + position: relative; + border: var(--dsc-file-upload-border-width) var(--dsc-file-upload-border-style) var(--dsc-file-upload-border-color); + border-radius: var(--dsc-file-upload-border-radius); + padding: var(--dsc-file-upload-padding); + background: var(--dsc-file-upload-background); + user-select: none; + outline: none; /* Hide outline when react-dropzone adds tabindex="0" */ + text-align: center; + @composes ds-print-preserve from './base.css'; + + /* React-dropzone puts tabindex="0" on whatever gets {...getRootProps()} and set tabindex="-1" on input.. */ + .ds-field[tabindex]:focus-visible:has(&) { + outline: none; + } + .ds-field[tabindex]:focus-visible &, + &:focus-within { + @composes ds-focus--visible from './base.css'; + } + + &:hover { + border-style: var(--dsc-file-upload-border-style--hover); + background-color: var(--dsc-file-upload-background--hover); + cursor: pointer; + } + &:has(:is([readonly], [aria-readonly='true'])) { + cursor: not-allowed; + pointer-events: none; + --_icon-url: var(--dsc-file-upload-icon-url--readonly); + --dsc-file-upload-background: var(--ds-color-surface-tinted); + --dsc-file-upload-background--hover: var(--ds-color-surface-tinted); + + /* Override entire color scale to neutral as a catch-all*/ + --ds-color-background-default: var(--ds-color-neutral-background-default); + --ds-color-background-tinted: var(--ds-color-neutral-background-tinted); + --ds-color-surface-default: var(--ds-color-neutral-surface-default); + --ds-color-surface-tinted: var(--ds-color-neutral-surface-tinted); + --ds-color-surface-hover: var(--ds-color-neutral-surface-hover); + --ds-color-surface-active: var(--ds-color-neutral-surface-active); + --ds-color-border-subtle: var(--ds-color-neutral-border-subtle); + --ds-color-border-default: var(--ds-color-neutral-border-default); + --ds-color-border-strong: var(--ds-color-neutral-border-strong); + --ds-color-text-subtle: var(--ds-color-neutral-text-subtle); + --ds-color-text-default: var(--ds-color-neutral-text-default); + --ds-color-base-default: var(--ds-color-neutral-base-default); + --ds-color-base-hover: var(--ds-color-neutral-base-hover); + --ds-color-base-active: var(--ds-color-neutral-base-active); + --ds-color-base-contrast-subtle: var(--ds-color-neutral-base-contrast-subtle); + --ds-color-base-contrast-default: var(--ds-color-neutral-base-contrast-default); + } + &:has([aria-invalid='true']) { + border-color: var(--ds-color-danger-border-default); + } + + & > input[type='file'] { + position: absolute; + inset: 0; + margin: 0; + z-index: 1; + opacity: 0; + cursor: inherit; + } + + /* Icon */ + & > svg, + &:not(:has(> svg))::before { + color: var(--dsc-file-upload-icon-color); + display: block; + height: var(--dsc-file-upload-icon-size); + margin: 0 auto var(--ds-size-2); + width: var(--dsc-file-upload-icon-size); + + @media (forced-colors: active) { + color: CanvasText; + } + } + &:not(:has(> svg))::before { + content: ''; + background: currentColor; + mask: var(--_icon-url) center / contain no-repeat; + @media (forced-colors: active) { + background: CanvasText; + } + } + /* First line of text should be currentcolor */ + & > [data-field='description']:not([data-field='description'] + [data-field='description']) { + color: var(--dsc-file-upload-color); + } + & > .ds-button { + width: fit-content; + margin: var(--ds-size-4) auto 0; + } +} diff --git a/packages/css/src/index.css b/packages/css/src/index.css index 23fb4ab15d..2ae7846935 100644 --- a/packages/css/src/index.css +++ b/packages/css/src/index.css @@ -14,6 +14,7 @@ @import url('./input.css') layer(ds.components); @import url('./field.css') layer(ds.components); @import url('./fieldset.css') layer(ds.components); +@import url('./file-upload.css') layer(ds.components); @import url('./alert.css') layer(ds.components); @import url('./popover.css') layer(ds.components); @import url('./skip-link.css') layer(ds.components); diff --git a/packages/react/package.json b/packages/react/package.json index bc5068e583..819dc0ba00 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -61,6 +61,7 @@ "@testing-library/user-event": "14.6.1", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", + "react-dropzone": "15.0.0", "react": "19.2.5", "react-dom": "19.2.5", "rimraf": "6.1.3", diff --git a/packages/react/src/components/file-upload/file-upload-description.tsx b/packages/react/src/components/file-upload/file-upload-description.tsx new file mode 100644 index 0000000000..e47a207284 --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload-description.tsx @@ -0,0 +1,12 @@ +import { forwardRef, type HTMLAttributes } from 'react'; +import { Paragraph, type ParagraphProps } from '../paragraph/paragraph'; + +export type FileUploadDescriptionProps = ParagraphProps & + HTMLAttributes; + +export const FileUploadDescription = forwardRef< + HTMLParagraphElement, + FileUploadDescriptionProps +>(function FileUploadDescription(rest, ref) { + return ; +}); diff --git a/packages/react/src/components/file-upload/file-upload-fake-button.tsx b/packages/react/src/components/file-upload/file-upload-fake-button.tsx new file mode 100644 index 0000000000..b3786767e0 --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload-fake-button.tsx @@ -0,0 +1,17 @@ +import { forwardRef, type HTMLAttributes } from 'react'; +import type { ButtonProps } from '../button/button'; +import { Button } from '../button/button'; + +export type FileUploadFakeButtonProps = Pick & + HTMLAttributes; + +export const FileUploadFakeButton = forwardRef< + HTMLSpanElement, + FileUploadFakeButtonProps +>(function FileUploadFakeButton({ variant = 'secondary', ...rest }, ref) { + return ( + + ); +}); diff --git a/packages/react/src/components/file-upload/file-upload-input.tsx b/packages/react/src/components/file-upload/file-upload-input.tsx new file mode 100644 index 0000000000..e89ae80da7 --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload-input.tsx @@ -0,0 +1,17 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; + +export type FileUploadInputProps = InputHTMLAttributes; + +export const FileUploadInput = forwardRef< + HTMLInputElement, + FileUploadInputProps +>(function FileUploadInput(rest, ref) { + return ( + + ); +}); diff --git a/packages/react/src/components/file-upload/file-upload.mdx b/packages/react/src/components/file-upload/file-upload.mdx new file mode 100644 index 0000000000..a8175b7395 --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload.mdx @@ -0,0 +1,25 @@ +import { Meta, Canvas, Controls, Primary } from '@storybook/addon-docs/blocks'; +import * as FileStories from './file-upload.stories'; + + + +**Dokumentasjonen har blitt flyttet til [Designsystemet.no](https://designsystemet.no/no/components).** + + + + +### working example for testing +Issue: the button in the input gets focus and the screen reader reads "Label -> Ingen fil valgt -> Description inside dropzone wrapper" (then label and ingen fil valgt again but could be due to iframe) + + +### Using react-dropzone +Issue: `react-dropzone` puts `tabindex="0"` on whatever gets `{...getRootProps()}` and set `tabindex="-1"` on input, so screen reader does not read label/description outside of the dropzone wrapper. + + + + +### Link variant + + +### readOnly test + diff --git a/packages/react/src/components/file-upload/file-upload.stories.tsx b/packages/react/src/components/file-upload/file-upload.stories.tsx new file mode 100644 index 0000000000..a4d66b8c80 --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload.stories.tsx @@ -0,0 +1,311 @@ +import { CircleSlashIcon } from '@navikt/aksel-icons'; +import type { Meta, StoryFn, StoryObj } from '@storybook/react-vite'; +import { type DragEvent, useRef, useState } from 'react'; +import type { FileRejection } from 'react-dropzone'; +import { useDropzone } from 'react-dropzone'; +import { Button, Field, Label } from '../'; +import { FileUpload } from './'; + +type Story = StoryObj; + +const meta: Meta = { + title: 'Komponenter/FileUpload', + component: FileUpload, + parameters: { + customStyles: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + flexWrap: 'wrap', + gap: 'var(--ds-size-4)', + }, + }, +}; + +export default meta; + +export const Preview: Story = { + render: ({ ...args }) => { + return ( + + + + Drop file here + + File must be in csv format and less than 2MB + + Upload file + + + + ); + }, +}; + +export const Variants: StoryFn = () => ( + <> + + + + Drop file here + + File must be in csv format and less than 2MB + + Upload file + + + + + + + Drop file here + + File must be in csv format and less than 2MB + + Upload file + + + + +); +export const LinkAlt: StoryFn = () => ( + + + + + Drop files or click to browse + + + File must be in csv format and less than 2MB + + + + +); + +export const ReadOnly: StoryFn = () => ( + + + description text + + Upload limit reached + Upload file + + + +); + +/* export const Disabled: StoryFn = () => ( + + + description text + + + Drop file here + + + File must be in csv format and less than 2MB + + Upload file + + + Invalid file format + +); */ + +export const HiddenLabel: StoryFn = () => ( + + + description text + + Drop file here + + File must be in csv format and less than 2MB + + Upload file + + + +); + +export const WorkingExample: StoryFn = () => { + const fileInputRef = useRef(null); + const [uploadedFiles, setUploadedFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [isReadOnly, setIsReadOnly] = useState(false); + + const addFiles = (files: FileList) => { + setUploadedFiles((prev) => [...prev, ...Array.from(files)]); + setIsReadOnly(true); + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + setIsDragging(false); + if (event.dataTransfer.files.length > 0) { + addFiles(event.dataTransfer.files); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + if (event.target.files && event.target.files.length > 0) { + addFiles(event.target.files); + event.target.value = ''; + } + }; + + const handleReset = () => { + setUploadedFiles([]); + setIsReadOnly(false); + setIsDragging(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + return ( +
+ + + ) => { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > + {isReadOnly && ( + <> + + + {uploadedFiles.length > 0 && ( + <> +
    + {uploadedFiles.map((file, index) => ( +
  • + {file.name} +
  • + ))} +
+ + + )} +
+ ); +}; + +export const ReactDropZoneExample: StoryFn = () => { + const MAX_FILES = 3; + const [files, setFiles] = useState([]); + const [rejected, setRejected] = useState([]); + const isReadOnly = files.length >= MAX_FILES; + const { getRootProps, getInputProps, isDragActive, isDragReject } = + useDropzone({ + onDropAccepted: (newFiles) => { + setFiles((prev) => { + const remaining = MAX_FILES - prev.length; + return [...prev, ...newFiles.slice(0, remaining)]; + }); + }, + onDropRejected: (rej) => { + setRejected((prev) => [...prev, ...rej]); + }, + accept: { + 'image/svg+xml': ['.svg'], + }, + maxFiles: MAX_FILES, + disabled: isReadOnly, + }); + + return ( +
+ {/* When using react-dropzone, it is important to apply the getRootProps to the + Field component and not the FileUpload component so screenreaders see the Label. + This should be noted in the docs */} + + + + {isReadOnly && ( + <> + + + {files.length > 0 && ( + <> +

Accepted files

+
    + {files.map((file) => ( +
  • {file.name}
  • + ))} +
+ + )} + {rejected.length > 0 && ( + <> +

Rejected files

+
    + {rejected.map(({ file, errors }) => ( +
  • + {file.name} — {errors.map((e) => e.message).join(', ')} +
  • + ))} +
+ + )} +
+ ); +}; diff --git a/packages/react/src/components/file-upload/file-upload.tsx b/packages/react/src/components/file-upload/file-upload.tsx new file mode 100644 index 0000000000..62724665e8 --- /dev/null +++ b/packages/react/src/components/file-upload/file-upload.tsx @@ -0,0 +1,37 @@ +import cl from 'clsx/lite'; +import type { HTMLAttributes, ReactNode } from 'react'; +import { forwardRef } from 'react'; +import type { DefaultProps } from '../../types'; +import type { MergeRight } from '../../utilities'; + +export type FileUploadProps = MergeRight< + DefaultProps & HTMLAttributes, + { + /** Instances of `FileUpload.FakeButton`, `FileUpload.Label`, `FileUpload.Description`, `FileUpload.Input` or other React nodes */ + children: ReactNode; + } +>; +/** + * FileUpload component to present a file upload area. + * + * @example + * + * + * + * + * + */ +export const FileUpload = forwardRef( + function FileUpload({ className, ...rest }, ref) { + return ( +
+ ); + }, +); diff --git a/packages/react/src/components/file-upload/index.ts b/packages/react/src/components/file-upload/index.ts new file mode 100644 index 0000000000..d2d2ada08d --- /dev/null +++ b/packages/react/src/components/file-upload/index.ts @@ -0,0 +1,66 @@ +import { FileUpload as FileUploadParent } from './file-upload'; +import { FileUploadDescription } from './file-upload-description'; +import { FileUploadFakeButton } from './file-upload-fake-button'; +import { FileUploadInput } from './file-upload-input'; + +type FileUpload = typeof FileUploadParent & { + /** + * Use `FileUpload.FakeButton` to add a fake button for click affordance + * + * Place as a descendant of `FileUpload` + * + * @example + * + * Upload file + * + */ + FakeButton: typeof FileUploadFakeButton; + /** + * Use `FileUpload.Description` to add secondary text inside FileUpload + * + * Place as a descendant of `FileUpload` + * + * @example + * + * File must be in csv format and less than 2MB + * + */ + Description: typeof FileUploadDescription; + /** + * Use `FileUpload.Input` to render the hidden file input + * + * Place as a descendant of `FileUpload`. Accepts all native `` props + * including `accept`, `multiple`, `disabled`, `readOnly` and `capture`. + * + * @example + * + * + * + */ + Input: typeof FileUploadInput; +}; +/** + * FileUpload component to present a file upload area. + * + * @example + * + * Drop file here + * File must be in csv format + * Upload file + * + */ +const FileUploadComponent: FileUpload = Object.assign(FileUploadParent, { + FakeButton: FileUploadFakeButton, + Description: FileUploadDescription, + Input: FileUploadInput, +}); + +FileUploadComponent.FakeButton.displayName = 'FileUpload.FakeButton'; +FileUploadComponent.Description.displayName = 'FileUpload.Description'; +FileUploadComponent.Input.displayName = 'FileUpload.Input'; + +export type { FileUploadProps } from './file-upload'; +export type { FileUploadDescriptionProps } from './file-upload-description'; +export type { FileUploadFakeButtonProps } from './file-upload-fake-button'; +export type { FileUploadInputProps } from './file-upload-input'; +export { FileUploadComponent as FileUpload }; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 71a5e1ae58..d3bf9d7058 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -134,6 +134,14 @@ export type { } from './fieldset'; export { Fieldset, FieldsetDescription, FieldsetLegend } from './fieldset'; +export type { + FileUploadDescriptionProps, + FileUploadFakeButtonProps, + FileUploadInputProps, + FileUploadProps, +} from './file-upload'; +export { FileUpload } from './file-upload'; + export type { HeadingProps } from './heading/heading'; export { Heading } from './heading/heading'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7c2011082..b11dc195a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -640,6 +640,9 @@ importers: react-dom: specifier: 19.2.5 version: 19.2.5(react@19.2.5) + react-dropzone: + specifier: 15.0.0 + version: 15.0.0(react@19.2.5) rimraf: specifier: 6.1.3 version: 6.1.3 @@ -2841,6 +2844,10 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + attr-accept@2.2.5: + resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} + engines: {node: '>=4'} + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -3553,6 +3560,10 @@ packages: picomatch: optional: true + file-selector@2.1.2: + resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==} + engines: {node: '>= 12'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -4967,6 +4978,9 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + property-information@7.0.0: resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==} @@ -5040,6 +5054,12 @@ packages: peerDependencies: react: ^19.2.5 + react-dropzone@15.0.0: + resolution: {integrity: sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + react-i18next@17.0.4: resolution: {integrity: sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==} peerDependencies: @@ -5056,6 +5076,9 @@ packages: typescript: optional: true + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -8089,6 +8112,8 @@ snapshots: astring@1.9.0: {} + attr-accept@2.2.5: {} + autoprefixer@10.5.0(postcss@8.5.10): dependencies: browserslist: 4.28.2 @@ -8822,6 +8847,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + file-selector@2.1.2: + dependencies: + tslib: 2.8.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -10511,6 +10540,12 @@ snapshots: process@0.11.10: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + property-information@7.0.0: {} proxy-addr@2.0.7: @@ -10587,6 +10622,13 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 + react-dropzone@15.0.0(react@19.2.5): + dependencies: + attr-accept: 2.2.5 + file-selector: 2.1.2 + prop-types: 15.8.1 + react: 19.2.5 + react-i18next@17.0.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 @@ -10609,6 +10651,8 @@ snapshots: react-dom: 19.2.5(react@19.2.5) typescript: 5.9.3 + react-is@16.13.1: {} + react-is@17.0.2: {} react-live@4.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5):