diff --git a/react/package-lock.json b/react/package-lock.json index 1a949c7f..b0762a30 100644 --- a/react/package-lock.json +++ b/react/package-lock.json @@ -12,6 +12,8 @@ "@chakra-ui/utils": "^2.0.12", "@floating-ui/react": "^0.26.1", "@fontsource/ibm-plex-mono": "^5.0.3", + "@zag-js/file-upload": "^0.49.0", + "@zag-js/react": "^0.49.0", "country-flag-icons": "^1.4.19", "date-fns": "^2.28.0", "dayzed": "^3.2.3", @@ -7556,18 +7558,103 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/@zag-js/anatomy": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-0.49.0.tgz", + "integrity": "sha512-B6RfUMyQ7BEPQaEjcFKc9qDFZZMGMK4X0IEjQysMzW79jm6+q7PuanjeYtxS/H7gEDabDKKC4Sxx9Cl7Ykh3rw==" + }, + "node_modules/@zag-js/core": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-0.49.0.tgz", + "integrity": "sha512-KovAL21J7WV2Rm7cVsAwp9oo4cZWfcZ4zXABO7rjgDUORq1msEt319qnU6q+mzpNnFhOrQMkgg/9cYQpueezHw==", + "dependencies": { + "@zag-js/store": "0.49.0", + "klona": "2.0.6" + } + }, + "node_modules/@zag-js/dom-query": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-0.49.0.tgz", + "integrity": "sha512-lA+8ZdfU27GcoMZFRUANYfd6FI2ukPNwlecpJp8E9t4BVZK2o5V7xpAaN99m1TePw5JlcZ7gGXe4I82Ej11Yjg==" + }, "node_modules/@zag-js/element-size": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@zag-js/element-size/-/element-size-0.3.2.tgz", "integrity": "sha512-bVvvigUGvAuj7PCkE5AbzvTJDTw5f3bg9nQdv+ErhVN8SfPPppLJEmmWdxqsRzrHXgx8ypJt/+Ty0kjtISVDsQ==", "peer": true }, + "node_modules/@zag-js/file-upload": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/file-upload/-/file-upload-0.49.0.tgz", + "integrity": "sha512-2Csu1s9KPo8hENJNCJ7bUHJvzvJcTkQxZ8rdNXq39aSl8e6wqTOaAfw7+yRxymPkBUk9zCT8kIlL4/X+UexXZg==", + "dependencies": { + "@zag-js/anatomy": "0.49.0", + "@zag-js/core": "0.49.0", + "@zag-js/dom-query": "0.49.0", + "@zag-js/file-utils": "0.49.0", + "@zag-js/i18n-utils": "0.49.0", + "@zag-js/types": "0.49.0", + "@zag-js/utils": "0.49.0" + } + }, + "node_modules/@zag-js/file-utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/file-utils/-/file-utils-0.49.0.tgz", + "integrity": "sha512-4eyw++7aDn8wETsPwtw/FwaVfck1vTr1/9vtHgV8t2q969apotIg2nwDUgejoYq13688fDoyOaWdl8rnlxBsjw==", + "dependencies": { + "@zag-js/i18n-utils": "0.49.0" + } + }, "node_modules/@zag-js/focus-visible": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-0.2.2.tgz", "integrity": "sha512-0j2gZq8HiZ51z4zNnSkF1iSkqlwRDvdH+son3wHdoz+7IUdMN/5Exd4TxMJ+gq2Of1DiXReYLL9qqh2PdQ4wgA==", "peer": true }, + "node_modules/@zag-js/i18n-utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/i18n-utils/-/i18n-utils-0.49.0.tgz", + "integrity": "sha512-IlmVq2zxrM/QY8GXzRu2AJc/lpWsV1Srei872mcw1HS9b/3w/l3fPZq6L/gR252dVFCukoOAlrl48540ddX16Q==", + "dependencies": { + "@zag-js/dom-query": "0.49.0" + } + }, + "node_modules/@zag-js/react": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/react/-/react-0.49.0.tgz", + "integrity": "sha512-bX0fSrZB9cRXedn4MTymg5yZ9JkJM5fTC5mQxDJL0Sjf5k2IthsADlTdyhuK0bxhu/QPXtoA59Irx11dM5vQQg==", + "dependencies": { + "@zag-js/core": "0.49.0", + "@zag-js/store": "0.49.0", + "@zag-js/types": "0.49.0", + "proxy-compare": "2.6.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@zag-js/store": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/store/-/store-0.49.0.tgz", + "integrity": "sha512-2TixeTR5rHScj/u0sa5KzYR/rvZePpSErrRBfBSFT7j4+6ktYK+bFWYNVQHHpxMYEBvoprrQkrO1gQB5E2LaQA==", + "dependencies": { + "proxy-compare": "2.6.0" + } + }, + "node_modules/@zag-js/types": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-0.49.0.tgz", + "integrity": "sha512-hDJG3sF6LXH4C9XFZroJSgeUuSbBAzrxjqEZKs3PG9B4JXjuuZhDW3/Ky12/QTINzomcAae8yfl2CTnuNfcYCA==", + "dependencies": { + "csstype": "3.1.3" + } + }, + "node_modules/@zag-js/utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-0.49.0.tgz", + "integrity": "sha512-6THHmxmj39te5i96sUuIirMGchmYalpR6VQPLPzciTQYkOb+sonygAWYJzAsDtQkoomT8M/0NQ6BW/Ly8uD7sg==" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -8974,9 +9061,9 @@ "dev": true }, "node_modules/csstype": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", - "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/date-fns": { "version": "2.30.0", @@ -12356,6 +12443,14 @@ "node": ">=6" } }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "engines": { + "node": ">= 8" + } + }, "node_modules/lazy-universal-dotenv": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/lazy-universal-dotenv/-/lazy-universal-dotenv-4.0.0.tgz", @@ -14823,6 +14918,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-compare": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz", + "integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==" + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -23320,18 +23420,99 @@ } } }, + "@zag-js/anatomy": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-0.49.0.tgz", + "integrity": "sha512-B6RfUMyQ7BEPQaEjcFKc9qDFZZMGMK4X0IEjQysMzW79jm6+q7PuanjeYtxS/H7gEDabDKKC4Sxx9Cl7Ykh3rw==" + }, + "@zag-js/core": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-0.49.0.tgz", + "integrity": "sha512-KovAL21J7WV2Rm7cVsAwp9oo4cZWfcZ4zXABO7rjgDUORq1msEt319qnU6q+mzpNnFhOrQMkgg/9cYQpueezHw==", + "requires": { + "@zag-js/store": "0.49.0", + "klona": "2.0.6" + } + }, + "@zag-js/dom-query": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-0.49.0.tgz", + "integrity": "sha512-lA+8ZdfU27GcoMZFRUANYfd6FI2ukPNwlecpJp8E9t4BVZK2o5V7xpAaN99m1TePw5JlcZ7gGXe4I82Ej11Yjg==" + }, "@zag-js/element-size": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@zag-js/element-size/-/element-size-0.3.2.tgz", "integrity": "sha512-bVvvigUGvAuj7PCkE5AbzvTJDTw5f3bg9nQdv+ErhVN8SfPPppLJEmmWdxqsRzrHXgx8ypJt/+Ty0kjtISVDsQ==", "peer": true }, + "@zag-js/file-upload": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/file-upload/-/file-upload-0.49.0.tgz", + "integrity": "sha512-2Csu1s9KPo8hENJNCJ7bUHJvzvJcTkQxZ8rdNXq39aSl8e6wqTOaAfw7+yRxymPkBUk9zCT8kIlL4/X+UexXZg==", + "requires": { + "@zag-js/anatomy": "0.49.0", + "@zag-js/core": "0.49.0", + "@zag-js/dom-query": "0.49.0", + "@zag-js/file-utils": "0.49.0", + "@zag-js/i18n-utils": "0.49.0", + "@zag-js/types": "0.49.0", + "@zag-js/utils": "0.49.0" + } + }, + "@zag-js/file-utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/file-utils/-/file-utils-0.49.0.tgz", + "integrity": "sha512-4eyw++7aDn8wETsPwtw/FwaVfck1vTr1/9vtHgV8t2q969apotIg2nwDUgejoYq13688fDoyOaWdl8rnlxBsjw==", + "requires": { + "@zag-js/i18n-utils": "0.49.0" + } + }, "@zag-js/focus-visible": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@zag-js/focus-visible/-/focus-visible-0.2.2.tgz", "integrity": "sha512-0j2gZq8HiZ51z4zNnSkF1iSkqlwRDvdH+son3wHdoz+7IUdMN/5Exd4TxMJ+gq2Of1DiXReYLL9qqh2PdQ4wgA==", "peer": true }, + "@zag-js/i18n-utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/i18n-utils/-/i18n-utils-0.49.0.tgz", + "integrity": "sha512-IlmVq2zxrM/QY8GXzRu2AJc/lpWsV1Srei872mcw1HS9b/3w/l3fPZq6L/gR252dVFCukoOAlrl48540ddX16Q==", + "requires": { + "@zag-js/dom-query": "0.49.0" + } + }, + "@zag-js/react": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/react/-/react-0.49.0.tgz", + "integrity": "sha512-bX0fSrZB9cRXedn4MTymg5yZ9JkJM5fTC5mQxDJL0Sjf5k2IthsADlTdyhuK0bxhu/QPXtoA59Irx11dM5vQQg==", + "requires": { + "@zag-js/core": "0.49.0", + "@zag-js/store": "0.49.0", + "@zag-js/types": "0.49.0", + "proxy-compare": "2.6.0" + } + }, + "@zag-js/store": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/store/-/store-0.49.0.tgz", + "integrity": "sha512-2TixeTR5rHScj/u0sa5KzYR/rvZePpSErrRBfBSFT7j4+6ktYK+bFWYNVQHHpxMYEBvoprrQkrO1gQB5E2LaQA==", + "requires": { + "proxy-compare": "2.6.0" + } + }, + "@zag-js/types": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-0.49.0.tgz", + "integrity": "sha512-hDJG3sF6LXH4C9XFZroJSgeUuSbBAzrxjqEZKs3PG9B4JXjuuZhDW3/Ky12/QTINzomcAae8yfl2CTnuNfcYCA==", + "requires": { + "csstype": "3.1.3" + } + }, + "@zag-js/utils": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-0.49.0.tgz", + "integrity": "sha512-6THHmxmj39te5i96sUuIirMGchmYalpR6VQPLPzciTQYkOb+sonygAWYJzAsDtQkoomT8M/0NQ6BW/Ly8uD7sg==" + }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -24386,9 +24567,9 @@ "dev": true }, "csstype": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", - "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "date-fns": { "version": "2.30.0", @@ -26894,6 +27075,11 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, + "klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==" + }, "lazy-universal-dotenv": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/lazy-universal-dotenv/-/lazy-universal-dotenv-4.0.0.tgz", @@ -28589,6 +28775,11 @@ "ipaddr.js": "1.9.1" } }, + "proxy-compare": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz", + "integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==" + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", diff --git a/react/package.json b/react/package.json index 57697b4b..3eb24a62 100644 --- a/react/package.json +++ b/react/package.json @@ -38,6 +38,8 @@ "@chakra-ui/utils": "^2.0.12", "@floating-ui/react": "^0.26.1", "@fontsource/ibm-plex-mono": "^5.0.3", + "@zag-js/file-upload": "^0.49.0", + "@zag-js/react": "^0.49.0", "country-flag-icons": "^1.4.19", "date-fns": "^2.28.0", "dayzed": "^3.2.3", diff --git a/react/src/Attachment/Attachment.stories.tsx b/react/src/Attachment/Attachment.stories.tsx index 8b8c7f98..924f2416 100644 --- a/react/src/Attachment/Attachment.stories.tsx +++ b/react/src/Attachment/Attachment.stories.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { ErrorCode, FileRejection } from 'react-dropzone' import { useControllableState } from '@chakra-ui/react' -import { Meta, StoryFn } from '@storybook/react' +import { Meta, StoryObj } from '@storybook/react' import { Buffer } from 'buffer' import { getMobileViewParameters } from '~/utils/storybook' @@ -51,13 +51,16 @@ export default { }, } as Meta> -const SingleTemplate: StoryFn> = ({ +type SingleAttachmentStory = StoryObj> +type MultiAttachmentStory = StoryObj> + +const SingleTemplate = ({ value, onChange, rejections, multiple, ...args -}) => { +}: AttachmentProps) => { const [files, onFileChange] = useControllableState({ value, onChange, @@ -78,12 +81,12 @@ const SingleTemplate: StoryFn> = ({ ) } -const MultipleTemplate: StoryFn> = ({ +const MultipleTemplate = ({ value = [], rejections, multiple = true, ...args -}) => { +}: AttachmentProps) => { const [files, setFiles] = useState(value) const [fileRejections, setFileRejections] = useState< FileRejection[] | undefined @@ -101,65 +104,89 @@ const MultipleTemplate: StoryFn> = ({ ) } -export const Default = SingleTemplate.bind({}) +export const Default: SingleAttachmentStory = { + render: SingleTemplate, +} -export const OnlyAcceptImages = SingleTemplate.bind({}) -OnlyAcceptImages.args = { - accept: ['image/*'], +export const OnlyAcceptImages: SingleAttachmentStory = { + render: SingleTemplate, + args: { + accept: ['image/*'], + }, } -export const Invalid = SingleTemplate.bind({}) -Invalid.args = { - isInvalid: true, +export const Invalid: SingleAttachmentStory = { + render: SingleTemplate, + args: { + isInvalid: true, + }, } -export const Disabled = SingleTemplate.bind({}) -Disabled.args = { - isDisabled: true, +export const Disabled: SingleAttachmentStory = { + render: SingleTemplate, + args: { + isDisabled: true, + }, } -export const WithUploadedFile = SingleTemplate.bind({}) -WithUploadedFile.args = { - value: MOCK_OGP_LOGO_FILE, +export const WithUploadedFile: SingleAttachmentStory = { + render: SingleTemplate, + args: { + value: MOCK_OGP_LOGO_FILE, + }, } -export const WithUploadedFileDisabled = SingleTemplate.bind({}) -WithUploadedFileDisabled.args = { - ...WithUploadedFile.args, - isDisabled: true, +export const WithUploadedFileDisabled: SingleAttachmentStory = { + render: SingleTemplate, + args: { + ...WithUploadedFile.args, + isDisabled: true, + }, } -export const WithSmallImagePreview = SingleTemplate.bind({}) -WithSmallImagePreview.args = { - ...WithUploadedFile.args, - imagePreview: 'small', +export const WithSmallImagePreview: SingleAttachmentStory = { + render: SingleTemplate, + args: { + ...WithUploadedFile.args, + imagePreview: 'small', + }, } -export const WithLargeImagePreview = SingleTemplate.bind({}) -WithLargeImagePreview.args = { - ...WithUploadedFile.args, - imagePreview: 'large', +export const WithLargeImagePreview: SingleAttachmentStory = { + render: SingleTemplate, + args: { + ...WithUploadedFile.args, + imagePreview: 'large', + }, + parameters: getMobileViewParameters(), } -WithLargeImagePreview.parameters = getMobileViewParameters() -export const WithMultipleUpload = MultipleTemplate.bind({}) -WithMultipleUpload.args = { - multiple: true, +export const WithMultipleUpload: MultiAttachmentStory = { + render: MultipleTemplate, + args: { + multiple: true, + }, } -export const ShowMaxSize = MultipleTemplate.bind({}) -ShowMaxSize.args = { - ...WithMultipleUpload.args, - showFileSize: true, +export const ShowMaxSize: MultiAttachmentStory = { + render: MultipleTemplate, + args: { + ...WithMultipleUpload.args, + showFileSize: true, + }, } -export const WithMultipleUploadedFiles = MultipleTemplate.bind({}) -WithMultipleUploadedFiles.args = { - ...WithMultipleUpload.args, - value: [MOCK_OGP_LOGO_FILE, MOCK_OGP_ICON_FILE], +export const WithMultipleUploadedFiles: MultiAttachmentStory = { + render: MultipleTemplate, + args: { + ...WithMultipleUpload.args, + value: [MOCK_OGP_LOGO_FILE, MOCK_OGP_ICON_FILE], + }, } -export const WithRejectedFile = SingleTemplate.bind({}) -WithRejectedFile.args = { - rejections: [MOCK_REJECTED_FILE], +export const WithRejectedFile: MultiAttachmentStory = { + render: MultipleTemplate, + args: { + rejections: [MOCK_REJECTED_FILE], + }, } diff --git a/react/src/Attachment/Attachment.tsx b/react/src/Attachment/Attachment.tsx index 37a1b32e..b6fba69a 100644 --- a/react/src/Attachment/Attachment.tsx +++ b/react/src/Attachment/Attachment.tsx @@ -119,6 +119,9 @@ export type AttachmentProps = onRejection?: (rejections: FileRejection[]) => void } & AttachmentValueProp +/** + * @deprecated Use `FileInput` component instead, as that is much more customisable. + */ export const Attachment: WithForwardRefType = forwardRef< HTMLDivElement, AttachmentProps diff --git a/react/src/FileUpload/FileUpload.stories.tsx b/react/src/FileUpload/FileUpload.stories.tsx new file mode 100644 index 00000000..cb5aca17 --- /dev/null +++ b/react/src/FileUpload/FileUpload.stories.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Buffer } from 'buffer' + +import { FileUpload, FileUploadProps, FileUploadRootProps } from './FileUpload' + +const MOCK_OGP_LOGO_FILE = new File( + [ + Buffer.from( + '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', + 'base64', + ), + ], + 'mock file.png', + { type: 'image/png' }, +) + +export default { + title: 'Components/FileUpload', + tags: ['autodocs'], + argTypes: { + imagePreview: { control: 'select', options: ['small', 'large', undefined] }, + maxFileSize: { control: 'number' }, + }, + component: FileUpload, + args: { + maxFileSize: undefined, + isDisabled: false, + imagePreview: 'small', + onFileAccept: (file) => { + console.log('file accepted', file) + }, + onFileReject: (file) => { + console.log('file rejected', file) + }, + onError: (error) => { + console.log('error', error) + }, + }, +} as Meta + +export const Default: StoryObj = {} + +export const WithParts = (args: FileUploadRootProps) => { + return ( + + + + + + + + {({ acceptedFiles }) => + acceptedFiles.length !== 0 && ( + + {acceptedFiles.map((file, index) => ( + + + + + + + + + ))} + + ) + } + + + + + + ) +} + +export const MaxSize: StoryObj = { + args: { + maxFileSize: 1 * 1024 * 1024, + }, +} + +export const OnlyAcceptImage: StoryObj = { + args: { + accept: 'image/*', + }, +} + +export const Invalid: StoryObj = { + args: { + maxFiles: 1, + value: [MOCK_OGP_LOGO_FILE, MOCK_OGP_LOGO_FILE], + }, +} + +export const Disabled: StoryObj = { + args: { + isDisabled: true, + }, +} + +export const DisabledWithFiles: StoryObj = { + args: { + isDisabled: true, + value: [MOCK_OGP_LOGO_FILE], + }, +} + +export const DisabledWithSingleFile: StoryObj = { + args: { + isDisabled: true, + maxFiles: 1, + value: [MOCK_OGP_LOGO_FILE], + }, +} + +export const LargeImagePreview: StoryObj = { + args: { + imagePreview: 'large', + value: [MOCK_OGP_LOGO_FILE], + }, +} + +export const Playground: StoryObj = { + args: { + maxFiles: 2, + }, + render: (args) => { + const [value, setValue] = useState([MOCK_OGP_LOGO_FILE]) + return ( + setValue(files)} + {...args} + /> + ) + }, +} diff --git a/react/src/FileUpload/FileUpload.tsx b/react/src/FileUpload/FileUpload.tsx new file mode 100644 index 00000000..e51aa57d --- /dev/null +++ b/react/src/FileUpload/FileUpload.tsx @@ -0,0 +1,124 @@ +import { FileUploadDraggingLabel } from './FileUploadDropzone/FileUploadDraggingLabel' +import { FileUploadDropzone } from './FileUploadDropzone/FileUploadDropzone' +import { FileUploadDropzoneIcon } from './FileUploadDropzone/FileUploadDropzoneIcon' +import { FileUploadLabel } from './FileUploadDropzone/FileUploadLabel' +import { FileUploadItem } from './FileUploadItem/FileUploadItem' +import { FileUploadItemDeleteTrigger } from './FileUploadItem/FileUploadItemDeleteTrigger' +import { FileUploadItemErrorText } from './FileUploadItem/FileUploadItemErrorText' +import { FileUploadItemName } from './FileUploadItem/FileUploadItemName' +import { FileUploadItemPreview } from './FileUploadItem/FileUploadItemPreview' +import { FileUploadItemPreviewImage } from './FileUploadItem/FileUploadItemPreviewImage' +import { FileUploadItemSizeText } from './FileUploadItem/FileUploadItemSizeText' +import { getFileError } from './utils/getFileError' +import { FileUploadContainer } from './FileUploadContainer' +import { FileUploadContext } from './FileUploadContext' +import { FileUploadErrorText } from './FileUploadErrorText' +import { FileUploadHelperText } from './FileUploadHelperText' +import { FileUploadHiddenInput } from './FileUploadHiddenInput' +import { FileUploadItemGroup } from './FileUploadItemGroup' +import { FileUploadMaxSizeHelperText } from './FileUploadMaxSizeHelperText' +import { FileUploadRoot, FileUploadRootProps } from './FileUploadRoot' + +export type { FileUploadDropzoneProps } from './FileUploadDropzone/FileUploadDropzone' +export type { FileUploadRootProps } from './FileUploadRoot' + +export interface FileUploadProps extends FileUploadRootProps { + /** + * Boolean flag on whether to show the file size helper message below the + * input. + * @default true + */ + showFileSize?: boolean + + /** + * If exists, callback to be invoked with the error string when file has errors. + */ + onError?: (error: string) => void + + /** + * The maximum number of files. + * @default 1 + */ + maxFiles?: number +} + +/** + * Default implementation of FileUpload component. For more control over the + * layout, use the individual subcomponents exposed in `FileUpload.*` + */ +export const FileUpload = ({ + showFileSize = true, + onError, + maxFiles = 1, + ...props +}: FileUploadProps) => { + const handleFileRejection: FileUploadRootProps['onFileReject'] = ( + details, + ) => { + if (details.files.length === 0) return + const firstError = details.files[0].errors[0] + const errorMessage = getFileError({ + context: { + maxFiles: maxFiles, + maxFileSize: props.maxFileSize, + minFileSize: props.minFileSize, + }, + file: details.files[0].file, + error: firstError, + }) + props.onFileReject?.(details) + onError?.(errorMessage) + } + + return ( + + + + ) +} + +FileUpload.Root = FileUploadRoot +FileUpload.Root.displayName = 'FileUpload.Root' +FileUpload.Dropzone = FileUploadDropzone +FileUpload.Dropzone.displayName = 'FileUpload.Dropzone' +FileUpload.DropzoneIcon = FileUploadDropzoneIcon +FileUpload.DropzoneIcon.displayName = 'FileUpload.DropzoneIcon' +FileUpload.Label = FileUploadLabel +FileUpload.Label.displayName = 'FileUpload.Label' +FileUpload.DraggingLabel = FileUploadDraggingLabel +FileUpload.DraggingLabel.displayName = 'FileUpload.DraggingLabel' +FileUpload.HiddenInput = FileUploadHiddenInput +FileUpload.HiddenInput.displayName = 'FileUpload.HiddenInput' +FileUpload.ItemGroup = FileUploadItemGroup +FileUpload.ItemGroup.displayName = 'FileUpload.ItemGroup' +FileUpload.Context = FileUploadContext +// @ts-expect-error displayName type missing +FileUpload.Context.displayName = 'FileUpload.Context' +FileUpload.Item = FileUploadItem +FileUpload.Item.displayName = 'FileUpload.Item' +FileUpload.ItemPreview = FileUploadItemPreview +FileUpload.ItemPreview.displayName = 'FileUpload.ItemPreview' +FileUpload.ItemName = FileUploadItemName +FileUpload.ItemName.displayName = 'FileUpload.ItemName' +FileUpload.ItemSizeText = FileUploadItemSizeText +FileUpload.ItemSizeText.displayName = 'FileUpload.ItemSizeText' +FileUpload.ItemPreviewImage = FileUploadItemPreviewImage +FileUpload.ItemPreviewImage.displayName = 'FileUpload.ItemPreviewImage' +FileUpload.ItemDeleteTrigger = FileUploadItemDeleteTrigger +FileUpload.ItemDeleteTrigger.displayName = 'FileUpload.ItemDeleteTrigger' + +FileUpload.ItemErrorText = FileUploadItemErrorText +FileUpload.ItemErrorText.displayName = 'FileUpload.ItemErrorText' + +FileUpload.ErrorText = FileUploadErrorText +FileUpload.ErrorText.displayName = 'FileUpload.ErrorText' + +FileUpload.MaxSizeHelperText = FileUploadMaxSizeHelperText +FileUpload.MaxSizeHelperText.displayName = 'FileUpload.MaxSizeHelperText' + +FileUpload.HelperText = FileUploadHelperText +FileUpload.HelperText.displayName = 'FileUpload.HelperText' diff --git a/react/src/FileUpload/FileUploadContainer.tsx b/react/src/FileUpload/FileUploadContainer.tsx new file mode 100644 index 00000000..b27bec46 --- /dev/null +++ b/react/src/FileUpload/FileUploadContainer.tsx @@ -0,0 +1,71 @@ +import { FileUploadDraggingLabel } from './FileUploadDropzone/FileUploadDraggingLabel' +import { FileUploadDropzone } from './FileUploadDropzone/FileUploadDropzone' +import { FileUploadDropzoneIcon } from './FileUploadDropzone/FileUploadDropzoneIcon' +import { FileUploadLabel } from './FileUploadDropzone/FileUploadLabel' +import { FileUploadItem } from './FileUploadItem/FileUploadItem' +import { FileUploadItemDeleteTrigger } from './FileUploadItem/FileUploadItemDeleteTrigger' +import { FileUploadItemName } from './FileUploadItem/FileUploadItemName' +import { FileUploadItemPreview } from './FileUploadItem/FileUploadItemPreview' +import { FileUploadItemPreviewImage } from './FileUploadItem/FileUploadItemPreviewImage' +import { FileUploadItemSizeText } from './FileUploadItem/FileUploadItemSizeText' +import { FileUploadContext } from './FileUploadContext' +import { FileUploadHiddenInput } from './FileUploadHiddenInput' +import { FileUploadItemGroup } from './FileUploadItemGroup' +import { FileUploadMaxSizeHelperText } from './FileUploadMaxSizeHelperText' +import { useFileUploadContext } from './FileUploadProvider' + +export interface FileUploadContainerProps { + /** + * Boolean flag on whether to show the file size helper message below the + * input. + */ + showFileSize?: boolean +} + +/** + * Contains the default implementation of file upload component. + */ +export const FileUploadContainer = ({ + showFileSize, +}: FileUploadContainerProps): JSX.Element => { + const { + context: { maxFiles }, + fileUpload, + } = useFileUploadContext() + const singular = maxFiles === 1 + const currentFiles = fileUpload.acceptedFiles.length + + const showDropzone = !singular || currentFiles === 0 + + return ( + <> + {showDropzone && ( + + + + + + )} + + {({ acceptedFiles }) => + acceptedFiles.length !== 0 && ( + + {acceptedFiles.map((file, index) => ( + + + + + + + + + ))} + + ) + } + + {showFileSize && } + + + ) +} diff --git a/react/src/FileUpload/FileUploadContext.tsx b/react/src/FileUpload/FileUploadContext.tsx new file mode 100644 index 00000000..5b639046 --- /dev/null +++ b/react/src/FileUpload/FileUploadContext.tsx @@ -0,0 +1,22 @@ +/** + * This component exposes context values to external children, if needed. + * @example Can be used to display accepted files or hide the dropzone when there is already a file attached. + */ +import type { ReactNode } from 'react' + +import { + FileUploadProviderProps, + useFileUploadContext, +} from './FileUploadProvider' + +export interface FileUploadContextProps { + children: ( + exposedContextProps: FileUploadProviderProps['fileUpload'], + ) => ReactNode +} + +export const FileUploadContext = (props: FileUploadContextProps) => { + const { fileUpload } = useFileUploadContext() + + return props.children(fileUpload) +} diff --git a/react/src/FileUpload/FileUploadDropzone/FileUploadDraggingLabel.tsx b/react/src/FileUpload/FileUploadDropzone/FileUploadDraggingLabel.tsx new file mode 100644 index 00000000..322cebe2 --- /dev/null +++ b/react/src/FileUpload/FileUploadDropzone/FileUploadDraggingLabel.tsx @@ -0,0 +1,27 @@ +import { forwardRef } from 'react' +import { Text, TextProps } from '@chakra-ui/react' + +import { useFileUploadContext } from '../FileUploadProvider' +import { useFileUploadStyles } from '../FileUploadStyleContext' + +export interface FileUploadDraggingLabelProps extends TextProps {} + +export const FileUploadDraggingLabel = forwardRef< + HTMLParagraphElement, + FileUploadDraggingLabelProps +>(({ children, ...props }, ref) => { + const { fileUpload, dropzoneDraggingLabel } = useFileUploadContext() + const styles = useFileUploadStyles() + + if (!fileUpload.dragging) { + return null + } + + return ( + + {children || dropzoneDraggingLabel || 'Drop the file here...'} + + ) +}) + +FileUploadDraggingLabel.displayName = 'FileUploadDraggingLabel' diff --git a/react/src/FileUpload/FileUploadDropzone/FileUploadDropzone.tsx b/react/src/FileUpload/FileUploadDropzone/FileUploadDropzone.tsx new file mode 100644 index 00000000..6ca0ea27 --- /dev/null +++ b/react/src/FileUpload/FileUploadDropzone/FileUploadDropzone.tsx @@ -0,0 +1,38 @@ +import { forwardRef } from 'react' +import { Box, type HTMLChakraProps } from '@chakra-ui/react' +import { dataAttr } from '@chakra-ui/utils' +import { mergeProps } from '@zag-js/react' + +import { useFileUploadContext } from '../FileUploadProvider' +import { useFileUploadStyles } from '../FileUploadStyleContext' + +export interface FileUploadDropzoneProps extends HTMLChakraProps<'div'> {} + +export const FileUploadDropzone = forwardRef< + HTMLDivElement, + FileUploadDropzoneProps +>(({ children, ...props }, ref) => { + const { fileUpload } = useFileUploadContext() + const styles = useFileUploadStyles() + + const hasError = + fileUpload.rejectedFiles.length !== 0 && + fileUpload.rejectedFiles[0].errors.length !== 0 + + // @ts-expect-error types are not correct + const mergedProps = mergeProps(fileUpload.dropzoneProps, props) + + return ( + + {children} + + ) +}) + +FileUploadDropzone.displayName = 'FileUploadDropzone' diff --git a/react/src/FileUpload/FileUploadDropzone/FileUploadDropzoneIcon.tsx b/react/src/FileUpload/FileUploadDropzone/FileUploadDropzoneIcon.tsx new file mode 100644 index 00000000..28446772 --- /dev/null +++ b/react/src/FileUpload/FileUploadDropzone/FileUploadDropzoneIcon.tsx @@ -0,0 +1,26 @@ +import { forwardRef } from 'react' +import { As, Icon } from '@chakra-ui/react' + +import { BxsCloudUpload } from '~/icons' + +import { useFileUploadStyles } from '../FileUploadStyleContext' + +export interface FileUploadDropzoneIconProps { + icon?: As +} + +export const FileUploadDropzoneIcon = forwardRef< + 'svg', + FileUploadDropzoneIconProps +>(({ icon, ...props }, ref) => { + const styles = useFileUploadStyles() + return ( + + ) +}) diff --git a/react/src/FileUpload/FileUploadDropzone/FileUploadLabel.tsx b/react/src/FileUpload/FileUploadDropzone/FileUploadLabel.tsx new file mode 100644 index 00000000..20e3b2b4 --- /dev/null +++ b/react/src/FileUpload/FileUploadDropzone/FileUploadLabel.tsx @@ -0,0 +1,55 @@ +import { forwardRef, useMemo } from 'react' +import { chakra, HTMLChakraProps } from '@chakra-ui/react' +import { mergeProps } from '@zag-js/react' + +import { Link } from '~/Link' + +import { useFileUploadContext } from '../FileUploadProvider' +import { useFileUploadStyles } from '../FileUploadStyleContext' + +export interface FileUploadLabelProps extends HTMLChakraProps<'label'> {} + +export const FileUploadLabel = forwardRef< + HTMLLabelElement, + FileUploadLabelProps +>(({ children, ...props }, ref) => { + const { fileUpload, context, dropzoneLabel } = useFileUploadContext() + const styles = useFileUploadStyles() + + const chooseFileText = useMemo( + () => `Choose file${context.maxFiles !== 1 ? 's' : ''}`, + [context.maxFiles], + ) + + const mergedProps = mergeProps( + fileUpload.labelProps, + { + onClick: (event) => { + // Required to prevent file dialog from opening twice when clicking on the label + // As the event bubbles to the dropzone, which also opens the file dialog + return event.stopPropagation() + }, + }, + // @ts-expect-error types are not correct + props, + ) + + if (fileUpload.dragging) { + return null + } + + return ( + + {children || dropzoneLabel || ( + <> + + {chooseFileText} + + {context.allowDrop !== false ? ' or drag and drop here' : ''} + + )} + + ) +}) + +FileUploadLabel.displayName = 'FileUploadLabel' diff --git a/react/src/FileUpload/FileUploadErrorText.tsx b/react/src/FileUpload/FileUploadErrorText.tsx new file mode 100644 index 00000000..34f73f2b --- /dev/null +++ b/react/src/FileUpload/FileUploadErrorText.tsx @@ -0,0 +1,45 @@ +import { forwardRef, useMemo } from 'react' +import { chakra, type HTMLChakraProps, Icon } from '@chakra-ui/react' + +import { BxsErrorCircle } from '..' + +import { getFileError } from './utils/getFileError' +import { useFileUploadContext } from './FileUploadProvider' +import { useFileUploadStyles } from './FileUploadStyleContext' + +export interface FileUploadErrorTextProps extends HTMLChakraProps<'div'> {} + +export const FileUploadErrorText = forwardRef< + HTMLDivElement, + FileUploadErrorTextProps +>((props, ref) => { + const { children, ...rest } = props + const { fileUpload, context } = useFileUploadContext() + const styles = useFileUploadStyles() + + const hasError = + fileUpload.rejectedFiles.length !== 0 && + fileUpload.rejectedFiles[0].errors.length !== 0 + + const errorMessage = useMemo(() => { + if (children) return children + if (!hasError) return null + const firstRejectedDetail = fileUpload.rejectedFiles[0] + return getFileError({ + context, + file: firstRejectedDetail.file, + error: firstRejectedDetail.errors[0], + }) + }, [children, context, fileUpload.rejectedFiles, hasError]) + + if (!hasError) return null + + return ( + + + {errorMessage} + + ) +}) + +FileUploadErrorText.displayName = 'FileUploadErrorText' diff --git a/react/src/FileUpload/FileUploadHelperText.tsx b/react/src/FileUpload/FileUploadHelperText.tsx new file mode 100644 index 00000000..84944bf0 --- /dev/null +++ b/react/src/FileUpload/FileUploadHelperText.tsx @@ -0,0 +1,25 @@ +import { forwardRef } from 'react' +import { chakra, type HTMLChakraProps } from '@chakra-ui/react' + +import { useFileUploadStyles } from './FileUploadStyleContext' + +export interface FileUploadHelperTextProps extends HTMLChakraProps<'div'> {} + +/** + * Helper text at the bottom of the file upload component. + */ +export const FileUploadHelperText = forwardRef< + HTMLDivElement, + FileUploadHelperTextProps +>((props, ref) => { + const { children, ...rest } = props + const styles = useFileUploadStyles() + + return ( + + {children} + + ) +}) + +FileUploadHelperText.displayName = 'FileUploadHelperText' diff --git a/react/src/FileUpload/FileUploadHiddenInput.tsx b/react/src/FileUpload/FileUploadHiddenInput.tsx new file mode 100644 index 00000000..a35bd6fb --- /dev/null +++ b/react/src/FileUpload/FileUploadHiddenInput.tsx @@ -0,0 +1,24 @@ +/** + * Component used to render a hidden input field for file upload + * Must be available or the file upload dialog will not open + */ +import { forwardRef } from 'react' +import { chakra, type HTMLChakraProps } from '@chakra-ui/react' +import { mergeProps } from '@zag-js/react' + +import { useFileUploadContext } from './FileUploadProvider' + +export interface FileUploadHiddenInputProps extends HTMLChakraProps<'input'> {} + +export const FileUploadHiddenInput = forwardRef< + HTMLInputElement, + FileUploadHiddenInputProps +>((props, ref) => { + const { fileUpload } = useFileUploadContext() + // @ts-expect-error types are not correct + const mergedProps = mergeProps(fileUpload.hiddenInputProps, props) + + return +}) + +FileUploadHiddenInput.displayName = 'FileUploadHiddenInput' diff --git a/react/src/FileUpload/FileUploadItem/FileUploadItem.tsx b/react/src/FileUpload/FileUploadItem/FileUploadItem.tsx new file mode 100644 index 00000000..fb733d9e --- /dev/null +++ b/react/src/FileUpload/FileUploadItem/FileUploadItem.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from 'react' +import { chakra, HTMLChakraProps } from '@chakra-ui/react' +import { dataAttr } from '@chakra-ui/utils' +import { FileError, type ItemProps } from '@zag-js/file-upload' +import { mergeProps } from '@zag-js/react' + +import { useFileUploadContext } from '../FileUploadProvider' +import { useFileUploadStyles } from '../FileUploadStyleContext' + +import { FileUploadItemProvider } from './FileUploadItemProvider' + +export interface FileUploadItemProps + extends Omit, keyof ItemProps>, + ItemProps { + errors?: FileError[] +} + +export const FileUploadItem = forwardRef( + ({ file, errors, ...localProps }, ref) => { + const { fileUpload } = useFileUploadContext() + const styles = useFileUploadStyles() + + const mergedProps = mergeProps( + fileUpload.getItemProps({ file }), + // @ts-expect-error types are not correct + localProps, + ) + + return ( + + 0)} + listStyleType="none" + __css={styles.item} + {...mergedProps} + ref={ref} + /> + + ) + }, +) + +FileUploadItem.displayName = 'FileUploadItem' diff --git a/react/src/FileUpload/FileUploadItem/FileUploadItemDeleteTrigger.tsx b/react/src/FileUpload/FileUploadItem/FileUploadItemDeleteTrigger.tsx new file mode 100644 index 00000000..c8c994e6 --- /dev/null +++ b/react/src/FileUpload/FileUploadItem/FileUploadItemDeleteTrigger.tsx @@ -0,0 +1,54 @@ +import { forwardRef } from 'react' +import { chakra, type HTMLChakraProps } from '@chakra-ui/react' +import { mergeProps } from '@zag-js/react' + +import { IconButton } from '~/IconButton' +import { BxTrash } from '~/icons' + +import { useFileUploadContext } from '../FileUploadProvider' +import { useFileUploadStyles } from '../FileUploadStyleContext' + +import { useFileUploadItemContext } from './FileUploadItemProvider' + +export interface FileUploadItemDeleteTriggerProps + extends HTMLChakraProps<'button'> {} + +export const FileUploadItemDeleteTrigger = forwardRef< + HTMLButtonElement, + FileUploadItemDeleteTriggerProps +>(({ children, ...props }, ref) => { + const { fileUpload } = useFileUploadContext() + const itemProps = useFileUploadItemContext() + const styles = useFileUploadStyles() + const mergedProps = mergeProps( + fileUpload.getItemDeleteTriggerProps(itemProps), + // @ts-expect-error types are not correct + props, + ) + + if (children) { + return ( + + {children} + + ) + } + + return ( + } + {...mergedProps} + aria-label={mergedProps['aria-label'] || 'Remove file'} + ref={ref} + /> + ) +}) + +FileUploadItemDeleteTrigger.displayName = 'FileUploadItemDeleteTrigger' diff --git a/react/src/FileUpload/FileUploadItem/FileUploadItemErrorText.tsx b/react/src/FileUpload/FileUploadItem/FileUploadItemErrorText.tsx new file mode 100644 index 00000000..dd00f6b6 --- /dev/null +++ b/react/src/FileUpload/FileUploadItem/FileUploadItemErrorText.tsx @@ -0,0 +1,57 @@ +import { forwardRef, useMemo } from 'react' +import { chakra, type HTMLChakraProps } from '@chakra-ui/react' +import { mergeProps } from '@zag-js/react' + +import { useFileUploadItemContext } from '../FileUploadItem/FileUploadItemProvider' +import { useFileUploadContext } from '../FileUploadProvider' +import { useFileUploadStyles } from '../FileUploadStyleContext' +import { getFileError } from '../utils/getFileError' + +export interface FileUploadItemErrorTextProps extends HTMLChakraProps<'div'> {} + +export const FileUploadItemErrorText = forwardRef< + HTMLDivElement, + FileUploadItemErrorTextProps +>((props, ref) => { + const { children, ...rest } = props + const { + fileUpload, + context: { maxFiles, maxFileSize, minFileSize }, + } = useFileUploadContext() + const itemProps = useFileUploadItemContext() + const styles = useFileUploadStyles() + + // @ts-expect-error types are not correct + const mergedProps = mergeProps(fileUpload.getItemNameProps(itemProps), rest) + + const errorToDisplay = useMemo(() => { + if (children) return children + if (!itemProps.errors || itemProps.errors.length === 0) { + return null + } + return getFileError({ + context: { maxFiles, maxFileSize, minFileSize }, + file: itemProps.file, + error: itemProps.errors[0], + }) + }, [ + children, + itemProps.errors, + itemProps.file, + maxFileSize, + maxFiles, + minFileSize, + ]) + + if (!itemProps.errors || itemProps.errors.length === 0) { + return null + } + + return ( + + {errorToDisplay} + + ) +}) + +FileUploadItemErrorText.displayName = 'FileUploadItemErrorText' diff --git a/react/src/FileUpload/FileUploadItem/FileUploadItemName.tsx b/react/src/FileUpload/FileUploadItem/FileUploadItemName.tsx new file mode 100644 index 00000000..3e9657c2 --- /dev/null +++ b/react/src/FileUpload/FileUploadItem/FileUploadItemName.tsx @@ -0,0 +1,31 @@ +import { forwardRef } from 'react' +import { chakra, type HTMLChakraProps } from '@chakra-ui/react' +import { mergeProps } from '@zag-js/react' + +import { useFileUploadContext } from '../FileUploadProvider' +import { useFileUploadStyles } from '../FileUploadStyleContext' + +import { useFileUploadItemContext } from './FileUploadItemProvider' + +export interface FileUploadItemNameProps extends HTMLChakraProps<'div'> {} + +export const FileUploadItemName = forwardRef< + HTMLDivElement, + FileUploadItemNameProps +>((props, ref) => { + const { children, ...rest } = props + const { fileUpload } = useFileUploadContext() + const itemProps = useFileUploadItemContext() + const styles = useFileUploadStyles() + + // @ts-expect-error types are not correct + const mergedProps = mergeProps(fileUpload.getItemNameProps(itemProps), rest) + + return ( + + {children || itemProps.file.name} + + ) +}) + +FileUploadItemName.displayName = 'FileUploadItemName' diff --git a/react/src/FileUpload/FileUploadItem/FileUploadItemPreview.tsx b/react/src/FileUpload/FileUploadItem/FileUploadItemPreview.tsx new file mode 100644 index 00000000..49b1ff27 --- /dev/null +++ b/react/src/FileUpload/FileUploadItem/FileUploadItemPreview.tsx @@ -0,0 +1,37 @@ +import { forwardRef } from 'react' +import { chakra, type HTMLChakraProps } from '@chakra-ui/react' +import { mergeProps } from '@zag-js/react' + +import { useFileUploadContext } from '../FileUploadProvider' +import { useFileUploadStyles } from '../FileUploadStyleContext' + +import { useFileUploadItemContext } from './FileUploadItemProvider' + +export interface FileUploadItemPreviewProps extends HTMLChakraProps<'div'> { + /** + * The file type to match against. Matches all file types by default. + * @default '.*' + */ + type?: string +} + +export const FileUploadItemPreview = forwardRef< + HTMLImageElement, + FileUploadItemPreviewProps +>((props, ref) => { + const { fileUpload } = useFileUploadContext() + const itemProps = useFileUploadItemContext() + const styles = useFileUploadStyles() + + const mergedProps = mergeProps( + fileUpload.getItemPreviewProps(itemProps), + // @ts-expect-error types are not correct + props, + ) + + if (!itemProps.file.type.match(props.type ?? '.*')) return null + + return +}) + +FileUploadItemPreview.displayName = 'FileUploadItemPreview' diff --git a/react/src/FileUpload/FileUploadItem/FileUploadItemPreviewImage.tsx b/react/src/FileUpload/FileUploadItem/FileUploadItemPreviewImage.tsx new file mode 100644 index 00000000..46eb6fe2 --- /dev/null +++ b/react/src/FileUpload/FileUploadItem/FileUploadItemPreviewImage.tsx @@ -0,0 +1,40 @@ +import { forwardRef, useEffect, useState } from 'react' +import { type HTMLChakraProps, Image } from '@chakra-ui/react' +import { mergeProps } from '@zag-js/react' + +import { useFileUploadContext } from '../FileUploadProvider' +import { useFileUploadStyles } from '../FileUploadStyleContext' + +import { useFileUploadItemContext } from './FileUploadItemProvider' + +export interface FileUploadItemPreviewImageProps + extends HTMLChakraProps<'img'> {} + +export const FileUploadItemPreviewImage = forwardRef< + HTMLImageElement, + FileUploadItemPreviewImageProps +>((props, ref) => { + const [url, setUrl] = useState('') + const { fileUpload } = useFileUploadContext() + const itemProps = useFileUploadItemContext() + + const styles = useFileUploadStyles() + + const itemPreviewImageProps = itemProps.file.type.startsWith('image/') + ? fileUpload.getItemPreviewImageProps({ ...itemProps, url }) + : {} + + const mergedProps = mergeProps( + itemPreviewImageProps, + // @ts-expect-error types are not correct + props, + ) + + useEffect(() => { + fileUpload.createFileUrl(itemProps.file, (url) => setUrl(url)) + }, [itemProps, fileUpload]) + + return +}) + +FileUploadItemPreviewImage.displayName = 'FileUploadItemPreviewImage' diff --git a/react/src/FileUpload/FileUploadItem/FileUploadItemProvider.ts b/react/src/FileUpload/FileUploadItem/FileUploadItemProvider.ts new file mode 100644 index 00000000..ae0a1e04 --- /dev/null +++ b/react/src/FileUpload/FileUploadItem/FileUploadItemProvider.ts @@ -0,0 +1,14 @@ +import type { FileError, ItemProps } from '@zag-js/file-upload' + +import { createContext } from '~/utils/createContext' + +export interface UseFileUploadItemContext extends ItemProps { + errors?: FileError[] +} + +export const [FileUploadItemProvider, useFileUploadItemContext] = + createContext({ + name: 'FileUploadItemContext', + hookName: 'useFileUploadItemContext', + providerName: '', + }) diff --git a/react/src/FileUpload/FileUploadItem/FileUploadItemSizeText.tsx b/react/src/FileUpload/FileUploadItem/FileUploadItemSizeText.tsx new file mode 100644 index 00000000..3aa62a18 --- /dev/null +++ b/react/src/FileUpload/FileUploadItem/FileUploadItemSizeText.tsx @@ -0,0 +1,34 @@ +import { forwardRef } from 'react' +import { chakra, type HTMLChakraProps } from '@chakra-ui/react' +import { mergeProps } from '@zag-js/react' + +import { useFileUploadContext } from '../FileUploadProvider' +import { useFileUploadStyles } from '../FileUploadStyleContext' + +import { useFileUploadItemContext } from './FileUploadItemProvider' + +export interface FileUploadItemSizeTextProps extends HTMLChakraProps<'div'> {} + +export const FileUploadItemSizeText = forwardRef< + HTMLDivElement, + FileUploadItemSizeTextProps +>((props, ref) => { + const { children, ...rest } = props + const { fileUpload } = useFileUploadContext() + const itemProps = useFileUploadItemContext() + const styles = useFileUploadStyles() + + const mergedProps = mergeProps( + fileUpload.getItemSizeTextProps(itemProps), + // @ts-expect-error types are not correct + rest, + ) + + return ( + + {children || fileUpload.getFileSize(itemProps.file)} + + ) +}) + +FileUploadItemSizeText.displayName = 'FileUploadItemSizeText' diff --git a/react/src/FileUpload/FileUploadItemGroup.tsx b/react/src/FileUpload/FileUploadItemGroup.tsx new file mode 100644 index 00000000..488f2e61 --- /dev/null +++ b/react/src/FileUpload/FileUploadItemGroup.tsx @@ -0,0 +1,23 @@ +import { forwardRef } from 'react' +import { chakra, type HTMLChakraProps } from '@chakra-ui/react' +import { mergeProps } from '@zag-js/react' + +import { useFileUploadContext } from './FileUploadProvider' +import { useFileUploadStyles } from './FileUploadStyleContext' + +export interface FileUploadItemGroupProps extends HTMLChakraProps<'ul'> {} + +export const FileUploadItemGroup = forwardRef< + HTMLUListElement, + FileUploadItemGroupProps +>((props, ref) => { + const { fileUpload } = useFileUploadContext() + const styles = useFileUploadStyles() + + // @ts-expect-error types are not correct + const mergedProps = mergeProps(fileUpload.itemGroupProps, props) + + return +}) + +FileUploadItemGroup.displayName = 'FileUploadItemGroup' diff --git a/react/src/FileUpload/FileUploadMaxSizeHelperText.tsx b/react/src/FileUpload/FileUploadMaxSizeHelperText.tsx new file mode 100644 index 00000000..df88de03 --- /dev/null +++ b/react/src/FileUpload/FileUploadMaxSizeHelperText.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from 'react' +import { type HTMLChakraProps } from '@chakra-ui/react' + +import { getReadableFileSize } from './utils/getReadableFizeSize' +import { FileUploadHelperText } from './FileUploadHelperText' +import { useFileUploadContext } from './FileUploadProvider' + +export interface FileUploadHelperTextProps extends HTMLChakraProps<'div'> {} + +/** + * Helper text at the bottom of the file upload component showing the maximum file size allowed. + */ +export const FileUploadMaxSizeHelperText = forwardRef< + HTMLDivElement, + FileUploadHelperTextProps +>((props, ref) => { + const { context } = useFileUploadContext() + + if (context.maxFileSize === undefined) return null + + return ( + + Maximum file size: {getReadableFileSize(context.maxFileSize)} + + ) +}) + +FileUploadMaxSizeHelperText.displayName = 'FileUploadMaxSizeHelperText' diff --git a/react/src/FileUpload/FileUploadProvider.ts b/react/src/FileUpload/FileUploadProvider.ts new file mode 100644 index 00000000..bd1cc125 --- /dev/null +++ b/react/src/FileUpload/FileUploadProvider.ts @@ -0,0 +1,32 @@ +import { ReactNode } from 'react' + +import { createContext } from '~/utils/createContext' + +import { UseFileUploadProps, UseFileUploadReturn } from './useFileUpload' + +export interface FileUploadPassthroughProps { + /** + * If provided, the image preview will be shown in the given size variant. + */ + imagePreview?: 'small' | 'large' + /** + * If provided, dropzone label will be changed to the provided prop. + */ + dropzoneLabel?: ReactNode + /** + * If provided, element rendered when file is being dragged over the element will be changed to the provided prop. + */ + dropzoneDraggingLabel?: ReactNode +} + +export interface FileUploadProviderProps extends FileUploadPassthroughProps { + fileUpload: UseFileUploadReturn + context: UseFileUploadProps +} + +export const [FileUploadProvider, useFileUploadContext] = + createContext({ + name: 'FileUploadContext', + hookName: 'useFileUploadContext', + providerName: '', + }) diff --git a/react/src/FileUpload/FileUploadRoot.tsx b/react/src/FileUpload/FileUploadRoot.tsx new file mode 100644 index 00000000..f54b46c0 --- /dev/null +++ b/react/src/FileUpload/FileUploadRoot.tsx @@ -0,0 +1,90 @@ +import { forwardRef, useEffect } from 'react' +import { + chakra, + HTMLChakraProps, + ThemingProps, + useMultiStyleConfig, +} from '@chakra-ui/react' +import { splitProps } from '@zag-js/file-upload' +import { mergeProps } from '@zag-js/react' +import { isEqual } from 'lodash' + +import { + FileUploadPassthroughProps, + FileUploadProvider, +} from './FileUploadProvider' +import { FileUploadStylesProvider } from './FileUploadStyleContext' +import { useFileUpload, UseFileUploadProps } from './useFileUpload' + +export interface FileUploadRootProps + extends Omit, + FileUploadPassthroughProps, + Omit< + HTMLChakraProps<'div'>, + 'dir' | 'defaultValue' | 'onError' | 'onChange' + > { + /** + * Color scheme of the component. + */ + colorScheme?: ThemingProps<'Attachment'>['colorScheme'] + isDisabled?: boolean + + /** + * If provided, the component will be a controlled component. + */ + value?: File[] +} + +export const FileUploadRoot = forwardRef( + ( + { + isDisabled, + imagePreview, + dropzoneDraggingLabel, + dropzoneLabel, + value, + ...props + }, + ref, + ) => { + const [fileUploadProps, restProps] = splitProps(props) + + const styles = useMultiStyleConfig('FileUpload', { ...props, imagePreview }) + + const fileUploadContext: UseFileUploadProps = { + ...fileUploadProps, + disabled: isDisabled, + } + + const fileUpload = useFileUpload(fileUploadContext) + // @ts-expect-error types are not correct + const mergedProps = mergeProps(fileUpload.rootProps, restProps) + + useEffect(() => { + if (value) { + if (isEqual(value, fileUpload.acceptedFiles)) return + fileUpload.setFiles(value) + } + // Should only rerender if value changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + return ( + + + + + + ) + }, +) + +FileUploadRoot.displayName = 'FileUploadRoot' diff --git a/react/src/FileUpload/FileUploadStyleContext.tsx b/react/src/FileUpload/FileUploadStyleContext.tsx new file mode 100644 index 00000000..7e1b77a4 --- /dev/null +++ b/react/src/FileUpload/FileUploadStyleContext.tsx @@ -0,0 +1,4 @@ +import { createStylesContext } from '@chakra-ui/react' + +export const [FileUploadStylesProvider, useFileUploadStyles] = + createStylesContext('FileUpload') diff --git a/react/src/FileUpload/index.ts b/react/src/FileUpload/index.ts new file mode 100644 index 00000000..b50bf858 --- /dev/null +++ b/react/src/FileUpload/index.ts @@ -0,0 +1,4 @@ +export * from './FileUpload' +export * from './utils/getFileError' +export * from './utils/getFileExtension' +export * from './utils/getReadableFizeSize' diff --git a/react/src/FileUpload/useFileUpload.ts b/react/src/FileUpload/useFileUpload.ts new file mode 100644 index 00000000..9ef5d136 --- /dev/null +++ b/react/src/FileUpload/useFileUpload.ts @@ -0,0 +1,36 @@ +import { useId, useRef } from 'react' +import { type Api, connect, type Context, machine } from '@zag-js/file-upload' +import { normalizeProps, type PropTypes, useMachine } from '@zag-js/react' + +import { useEvent } from '~/hooks/useEvent' + +export interface UseFileUploadProps + extends Omit { + id?: string +} +export interface UseFileUploadReturn extends Api {} + +export const useFileUpload = ( + props: UseFileUploadProps = {}, +): UseFileUploadReturn => { + const initialContext: Context = { + id: useId(), + ...props, + } + + const context: Context = { + ...initialContext, + onFileAccept: useEvent(props.onFileAccept), + onFileReject: useEvent(props.onFileReject), + onFileChange: useEvent(props.onFileChange, { sync: true }), + } + + const [state, send] = useMachine(machine(initialContext), { + context, + }) + + const apiRef = useRef>() + const api = connect(state, send, normalizeProps) + apiRef.current = api + return api +} diff --git a/react/src/FileUpload/utils/getFileError.ts b/react/src/FileUpload/utils/getFileError.ts new file mode 100644 index 00000000..fdb2122f --- /dev/null +++ b/react/src/FileUpload/utils/getFileError.ts @@ -0,0 +1,44 @@ +import { Context, FileError } from '@zag-js/file-upload' + +import { getFileExtension } from './getFileExtension' +import { getReadableFileSize } from './getReadableFizeSize' + +type GetFileErrorArgs = { + error: FileError + file: File + context: Pick +} + +export const getFileError = ({ + error, + file, + context: { maxFiles, maxFileSize, minFileSize }, +}: GetFileErrorArgs) => { + switch (error) { + case 'FILE_INVALID_TYPE': { + const fileExt = getFileExtension(file.name) + return `Your file's extension ending in *${fileExt} is not allowed` + } + case 'TOO_MANY_FILES': { + return `You can only upload ${maxFiles} file${maxFiles === 1 ? '' : 's'} in this input` + } + case 'FILE_TOO_LARGE': { + let errorMessage = 'This file exceeds the size limit.' + if (maxFileSize !== undefined) { + errorMessage += ` Please upload a file that is under ${getReadableFileSize(maxFileSize)}` + } + return errorMessage + } + case 'FILE_TOO_SMALL': { + let errorMessage = 'This file is too small.' + if (minFileSize !== undefined) { + errorMessage += ` Please upload a file that is at least ${getReadableFileSize(minFileSize)}` + } + return errorMessage + } + default: { + const expect: never = error + return `Unexpected error: ${expect}` + } + } +} diff --git a/react/src/FileUpload/utils/getFileExtension.ts b/react/src/FileUpload/utils/getFileExtension.ts new file mode 100644 index 00000000..711eb566 --- /dev/null +++ b/react/src/FileUpload/utils/getFileExtension.ts @@ -0,0 +1,13 @@ +/** + * Extracts the file extension of a given filename. + * + * @param filename name of the file to check the extension for + * @return the file extension if it exists, otherwise an empty string. + */ +export const getFileExtension = (filename: string): string => { + const splits = filename.split('.') + if (splits.length < 2) { + return '' + } + return `.${splits[splits.length - 1]}` +} diff --git a/react/src/FileUpload/utils/getReadableFizeSize.ts b/react/src/FileUpload/utils/getReadableFizeSize.ts new file mode 100644 index 00000000..d0f3f24d --- /dev/null +++ b/react/src/FileUpload/utils/getReadableFizeSize.ts @@ -0,0 +1,14 @@ +const DECIMAL_BYTE_UNITS = ['B', 'kB', 'MB', 'GB', 'TB'] + +/** + * Converts the given file size in bytes to a human readable string. + * + * @example 1100000 -> "1.1 MB" + * @param fileSizeInBytes the size of the file in bytes to be converted to a readable string + * @returns the human-readable file size string + */ +export const getReadableFileSize = (fileSizeInBytes: number): string => { + const i = Math.floor(Math.log(fileSizeInBytes) / Math.log(1000)) + const size = Number((fileSizeInBytes / Math.pow(1000, i)).toFixed(2)) + return size + ' ' + DECIMAL_BYTE_UNITS[i] +} diff --git a/react/src/hooks/useEvent.ts b/react/src/hooks/useEvent.ts new file mode 100644 index 00000000..b2ecb3e7 --- /dev/null +++ b/react/src/hooks/useEvent.ts @@ -0,0 +1,37 @@ +import { useCallback, useRef } from 'react' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFunction = (...args: any[]) => unknown + +type Options = { + /** + * Whether to use flushSync or not + */ + sync?: boolean +} + +/** + * Returns a memoized callback that will flushSync the callback if sync is true + */ +export function useEvent( + callback: T | undefined, + opts: Options = {}, +): T { + const { sync = false } = opts + + const callbackRef = useLatestRef(callback) + + return useCallback( + (...args: unknown[]) => { + if (sync) return queueMicrotask(() => callbackRef.current?.(...args)) + return callbackRef.current?.(...args) + }, + [sync, callbackRef], + ) as T +} + +function useLatestRef(value: T) { + const ref = useRef(value) + ref.current = value + return ref +} diff --git a/react/src/theme/components/Attachment.ts b/react/src/theme/components/Attachment.ts index 6ee5c60a..74c863e9 100644 --- a/react/src/theme/components/Attachment.ts +++ b/react/src/theme/components/Attachment.ts @@ -15,6 +15,7 @@ const parts = anatomy('attachment').parts( 'fileInfoIcon', 'fileErrorIcon', 'fileErrorMessage', + 'fileItemGroup', ) const { definePartsStyle, defineMultiStyleConfig } = @@ -25,7 +26,13 @@ const baseStyle = definePartsStyle({ transitionProperty: 'common', transitionDuration: 'normal', }, + fileItemGroup: { + display: 'flex', + flexDirection: 'column', + gap: '1rem', + }, fileInfoContainer: { + display: 'flex', borderRadius: 'base', border: '1px solid', borderColor: 'base.divider.medium', diff --git a/react/src/theme/components/FileUpload.ts b/react/src/theme/components/FileUpload.ts new file mode 100644 index 00000000..898bb4aa --- /dev/null +++ b/react/src/theme/components/FileUpload.ts @@ -0,0 +1,250 @@ +import { createMultiStyleConfigHelpers } from '@chakra-ui/react' + +import { Input } from './Input' + +const { definePartsStyle, defineMultiStyleConfig } = + createMultiStyleConfigHelpers([ + 'root', + 'dropzone', + 'dropzoneIcon', + 'item', + 'itemDeleteTrigger', + 'itemGroup', + 'itemName', + 'itemPreview', + 'itemPreviewImage', + 'itemSizeText', + 'label', + 'trigger', + 'itemErrorText', + 'errorText', + 'helperText', + ]) + +const baseStyle = definePartsStyle(({ imagePreview }) => { + return { + root: { + display: 'flex', + flexDirection: 'column', + gap: '0.5rem', + width: '100%', + }, + label: { + cursor: 'pointer', + textStyle: 'subhead-1', + }, + dropzone: { + transitionProperty: 'common', + transitionDuration: 'normal', + }, + itemGroup: { + display: 'flex', + flexDirection: 'column', + gap: '1rem', + }, + item: { + _invalid: { + borderColor: 'interaction.critical.default', + }, + py: '0.875rem', + px: '1rem', + display: 'grid', + borderRadius: 'base', + border: '1px solid', + borderColor: 'base.divider.medium', + bg: 'interaction.main-subtle.default', + color: 'base.content.default', + _disabled: { + bg: 'interaction.support.disabled', + borderColor: 'interaction.support.disabled', + cursor: 'initial', + color: 'interaction.support.disabled-content', + }, + gridTemplateColumns: 'auto 1fr auto', + gridTemplateRows: 'min-content min-content min-content', + gridTemplateAreas: + imagePreview === 'large' + ? `"preview preview preview" "name name delete" "size size delete" "error error delete"` + : `"preview name delete" "preview size delete" "error error delete"`, + }, + itemErrorText: { + gridArea: 'error', + textColor: 'interaction.critical.default', + _disabled: { + color: 'interaction.support.disabled-content', + }, + textStyle: 'caption-1', + mt: '0.25rem', + }, + helperText: { + display: 'flex', + gap: '0.5rem', + flexDirection: 'row', + textColor: 'base.content.medium', + _disabled: { + color: 'interaction.support.disabled-content', + }, + textStyle: 'body-2', + }, + errorText: { + display: 'flex', + gap: '0.5rem', + flexDirection: 'row', + textColor: 'interaction.critical.default', + _disabled: { + color: 'interaction.support.disabled-content', + }, + textStyle: 'body-2', + '& svg': { + mt: '0.125rem', + fontSize: '1rem', + }, + }, + itemPreview: { + gridArea: 'preview', + mt: '-0.875rem', + mb: imagePreview !== 'large' ? '-0.875rem' : undefined, + ml: '-1rem', + mr: imagePreview !== 'large' ? '1rem' : '-1rem', + bg: 'white', + }, + itemName: { + color: 'base.content.default', + noOfLines: 2, + _disabled: { + color: 'interaction.support.disabled-content', + }, + gridArea: 'name', + textStyle: 'subhead-1', + _notFirst: { + mt: imagePreview === 'large' ? '0.875rem' : undefined, + }, + }, + itemSizeText: { + color: 'base.content.medium', + _disabled: { + color: 'interaction.support.disabled-content', + }, + gridArea: 'size', + textStyle: 'caption-1', + mt: '0.25rem', + }, + itemDeleteTrigger: { + alignSelf: 'end', + gridArea: 'delete', + }, + itemPreviewImage: { + aspectRatio: imagePreview !== 'large' ? '1' : undefined, + objectFit: imagePreview === 'large' ? 'contain' : 'scale-down', + borderRight: imagePreview !== 'large' ? '1px solid' : undefined, + borderBottom: imagePreview === 'large' ? '1px solid' : undefined, + borderColor: 'base.divider.medium', + borderLeftRadius: imagePreview !== 'large' ? 'base' : undefined, + borderTopRadius: imagePreview === 'large' ? 'base' : undefined, + }, + } +}) + +const sizes = { + md: definePartsStyle(({ imagePreview }) => { + return { + dropzoneIcon: { + fontSize: '3.5rem', + }, + dropzone: { + px: '3rem', + py: '4rem', + textStyle: 'body-1', + }, + itemPreviewImage: { + height: imagePreview === 'large' ? undefined : '4.5rem', + width: imagePreview === 'large' ? '100%' : '6rem', + }, + } + }), +} + +const getOutlineColours = definePartsStyle(({ colorScheme: c }) => { + switch (c) { + case 'main': { + return { + dropzone: { + borderColor: 'base.divider.strong', + bg: 'interaction.main-subtle.default', + _active: { + bg: 'interaction.main-subtle.active', + }, + _hover: { + bg: 'interaction.main-subtle.hover', + }, + }, + } + } + default: { + return { + dropzone: { + borderColor: `${c}.700`, + bg: `${c}.200`, + _hover: { + bg: `${c}.100`, + }, + _active: { + bg: `${c}.200`, + }, + }, + } + } + } +}) + +const variantOutline = definePartsStyle((props) => { + const { errorBorderColor: ec } = props + + const inputStyle = Input.variants?.outline(props).field + const colorProps = getOutlineColours(props) + + return { + dropzone: { + ...colorProps.dropzone, + color: 'base.content.default', + display: 'flex', + flexDir: 'column', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + border: '1px dashed', + borderRadius: 'base', + outline: 'none', + _invalid: { + // Remove extra 1px of outline. + borderColor: ec, + boxShadow: 'none', + }, + _focus: { + ...inputStyle?._focusVisible, + borderStyle: 'solid', + }, + _disabled: { + ...inputStyle?._disabled, + }, + }, + } +}) + +const variants = { + outline: variantOutline, +} + +export const FileUpload = defineMultiStyleConfig({ + baseStyle, + sizes, + variants, + defaultProps: { + ...Input.defaultProps, + colorScheme: 'main', + size: 'md', + // @ts-expect-error Invalid exported type. + focusBorderColor: 'utility.focus-default', + errorBorderColor: 'interaction.critical.default', + }, +}) diff --git a/react/src/theme/components/index.ts b/react/src/theme/components/index.ts index 599154fd..05cfdeac 100644 --- a/react/src/theme/components/index.ts +++ b/react/src/theme/components/index.ts @@ -13,6 +13,7 @@ import { DatePicker } from './DatePicker' import { DateRangePicker } from './DateRangePicker' import { Divider } from './Divider' import { Drawer } from './Drawer' +import { FileUpload } from './FileUpload' import { Footer } from './Footer' import { FormControl } from './FormControl' import { FormError } from './FormError' @@ -59,6 +60,7 @@ export const components = { DateRangePicker, Divider, Drawer, + FileUpload, Footer, Form: FormControl, FormError, diff --git a/react/src/utils/createContext.ts b/react/src/utils/createContext.ts new file mode 100644 index 00000000..cc50528c --- /dev/null +++ b/react/src/utils/createContext.ts @@ -0,0 +1,51 @@ +import { + createContext as createReactContext, + useContext as useReactContext, +} from 'react' + +interface CreateContextOptions { + strict?: boolean + hookName?: string + providerName?: string + errorMessage?: string + name?: string + defaultValue?: T +} + +type CreateContextReturn = [React.Provider, () => T, React.Context] + +function getErrorMessage(hook: string, provider: string) { + return `${hook} returned \`undefined\`. Seems you forgot to wrap component within ${provider}` +} + +export function createContext(options: CreateContextOptions = {}) { + const { + name, + strict = true, + hookName = 'useContext', + providerName = 'Provider', + errorMessage, + defaultValue, + } = options + + const Context = createReactContext(defaultValue) + + Context.displayName = name + + function useContext() { + const context = useReactContext(Context) + + if (!context && strict) { + const error = new Error( + errorMessage ?? getErrorMessage(hookName, providerName), + ) + error.name = 'ContextError' + Error.captureStackTrace?.(error, useContext) + throw error + } + + return context + } + + return [Context.Provider, useContext, Context] as CreateContextReturn +}