@overmap-ai/forms 0.0.1-master.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/.husky/pre-commit +6 -0
  2. package/.prettierrc.json +10 -0
  3. package/.storybook/StoryDecorator.tsx +22 -0
  4. package/.storybook/main.ts +20 -0
  5. package/.storybook/palettes/green.css +66 -0
  6. package/.storybook/palettes/red.css +66 -0
  7. package/.storybook/preview.css +39 -0
  8. package/.storybook/preview.tsx +31 -0
  9. package/.storybook/tailwind-theme/accentPalette.css +181 -0
  10. package/.storybook/tailwind-theme/backgrounds.css +11 -0
  11. package/.storybook/tailwind-theme/basePalette.css +178 -0
  12. package/dev/publish-alpha.sh +13 -0
  13. package/dev/publish-patch.sh +3 -0
  14. package/eslint.config.js +56 -0
  15. package/package.json +93 -0
  16. package/src/ColorPicker/ColorPicker.tsx +47 -0
  17. package/src/ColorPicker/index.ts +1 -0
  18. package/src/FileBadge/FileBadge.tsx +27 -0
  19. package/src/FileBadge/index.ts +1 -0
  20. package/src/FileCard/FileCard.stories.tsx +69 -0
  21. package/src/FileCard/FileCard.tsx +53 -0
  22. package/src/FileCard/index.ts +1 -0
  23. package/src/FileIcon/FileIcon.tsx +31 -0
  24. package/src/FileIcon/index.ts +1 -0
  25. package/src/FileViewer/FileViewerProvider.stories.tsx +50 -0
  26. package/src/FileViewer/FileViewerProvider.tsx +72 -0
  27. package/src/FileViewer/context.ts +11 -0
  28. package/src/FileViewer/index.ts +3 -0
  29. package/src/FileViewer/typings.ts +5 -0
  30. package/src/ImageCard/ImageCard.stories.tsx +94 -0
  31. package/src/ImageCard/ImageCard.tsx +82 -0
  32. package/src/ImageCard/index.ts +1 -0
  33. package/src/ImageMarkup/ImageMarkup.stories.tsx +65 -0
  34. package/src/ImageMarkup/ImageMarkup.tsx +268 -0
  35. package/src/ImageMarkup/index.ts +1 -0
  36. package/src/ImageViewer/ImageViewer.stories.tsx +57 -0
  37. package/src/ImageViewer/ImageViewer.tsx +124 -0
  38. package/src/ImageViewer/constants.ts +1 -0
  39. package/src/ImageViewer/index.ts +2 -0
  40. package/src/PDFViewer/PDFViewer.stories.tsx +55 -0
  41. package/src/PDFViewer/PDFViewer.tsx +170 -0
  42. package/src/PDFViewer/constants.ts +1 -0
  43. package/src/PDFViewer/index.ts +2 -0
  44. package/src/SpreadsheetViewer/SpreadsheetViewer.stories.tsx +55 -0
  45. package/src/SpreadsheetViewer/SpreadsheetViewer.tsx +162 -0
  46. package/src/SpreadsheetViewer/constants.ts +8 -0
  47. package/src/SpreadsheetViewer/index.ts +2 -0
  48. package/src/forms/builder/DropDispatch.ts +84 -0
  49. package/src/forms/builder/FieldActions.tsx +155 -0
  50. package/src/forms/builder/FieldBuilder.tsx +386 -0
  51. package/src/forms/builder/FieldSectionWithActions.tsx +260 -0
  52. package/src/forms/builder/FieldWithActions.tsx +129 -0
  53. package/src/forms/builder/FieldsEditor.tsx +180 -0
  54. package/src/forms/builder/FormBuilder.stories.tsx +105 -0
  55. package/src/forms/builder/FormBuilder.tsx +237 -0
  56. package/src/forms/builder/constants.ts +18 -0
  57. package/src/forms/builder/hooks.tsx +24 -0
  58. package/src/forms/builder/index.ts +2 -0
  59. package/src/forms/builder/typings.ts +18 -0
  60. package/src/forms/builder/utils.ts +229 -0
  61. package/src/forms/constants.ts +9 -0
  62. package/src/forms/constantsJsx.tsx +67 -0
  63. package/src/forms/fields/BaseField/BaseField.ts +152 -0
  64. package/src/forms/fields/BaseField/hooks.tsx +60 -0
  65. package/src/forms/fields/BaseField/index.ts +4 -0
  66. package/src/forms/fields/BaseField/layouts.tsx +100 -0
  67. package/src/forms/fields/BaseField/typings.ts +9 -0
  68. package/src/forms/fields/BooleanField/BooleanField.tsx +48 -0
  69. package/src/forms/fields/BooleanField/BooleanInput.tsx +54 -0
  70. package/src/forms/fields/BooleanField/index.ts +2 -0
  71. package/src/forms/fields/CustomField/CustomField.tsx +45 -0
  72. package/src/forms/fields/CustomField/FieldInputClonerField/FieldInputCloner.tsx +25 -0
  73. package/src/forms/fields/CustomField/FieldInputClonerField/FieldInputClonerField.tsx +26 -0
  74. package/src/forms/fields/CustomField/FieldInputClonerField/index.ts +3 -0
  75. package/src/forms/fields/CustomField/FieldInputClonerField/typings.ts +8 -0
  76. package/src/forms/fields/CustomField/index.ts +1 -0
  77. package/src/forms/fields/DateField/DateField.tsx +42 -0
  78. package/src/forms/fields/DateField/DateInput.tsx +39 -0
  79. package/src/forms/fields/DateField/index.ts +2 -0
  80. package/src/forms/fields/FieldSection/FieldSection.tsx +173 -0
  81. package/src/forms/fields/FieldSection/FieldSectionLayout.tsx +56 -0
  82. package/src/forms/fields/FieldSection/index.ts +1 -0
  83. package/src/forms/fields/MultiStringField/MultiStringField.tsx +90 -0
  84. package/src/forms/fields/MultiStringField/MultiStringInput.tsx +207 -0
  85. package/src/forms/fields/MultiStringField/index.ts +2 -0
  86. package/src/forms/fields/NumberField/NumberField.tsx +173 -0
  87. package/src/forms/fields/NumberField/NumberInput.tsx +44 -0
  88. package/src/forms/fields/NumberField/index.ts +2 -0
  89. package/src/forms/fields/QrField/QrField.tsx +38 -0
  90. package/src/forms/fields/QrField/QrInput.module.sass +5 -0
  91. package/src/forms/fields/QrField/QrInput.tsx +144 -0
  92. package/src/forms/fields/QrField/index.ts +2 -0
  93. package/src/forms/fields/SelectField/BaseSelectField.ts +73 -0
  94. package/src/forms/fields/SelectField/MultiSelectField.tsx +53 -0
  95. package/src/forms/fields/SelectField/MultiSelectInput.tsx +80 -0
  96. package/src/forms/fields/SelectField/SelectField.tsx +49 -0
  97. package/src/forms/fields/SelectField/SelectInput.tsx +69 -0
  98. package/src/forms/fields/SelectField/index.ts +4 -0
  99. package/src/forms/fields/StringOrTextFields/StringField/StringField.tsx +61 -0
  100. package/src/forms/fields/StringOrTextFields/StringField/StringInput.tsx +41 -0
  101. package/src/forms/fields/StringOrTextFields/StringField/index.ts +2 -0
  102. package/src/forms/fields/StringOrTextFields/StringOrTextField.ts +143 -0
  103. package/src/forms/fields/StringOrTextFields/TextField/TextField.tsx +52 -0
  104. package/src/forms/fields/StringOrTextFields/TextField/TextInput.tsx +42 -0
  105. package/src/forms/fields/StringOrTextFields/TextField/index.ts +2 -0
  106. package/src/forms/fields/StringOrTextFields/index.ts +2 -0
  107. package/src/forms/fields/UploadField/UploadField.tsx +156 -0
  108. package/src/forms/fields/UploadField/UploadInput.tsx +220 -0
  109. package/src/forms/fields/UploadField/index.ts +2 -0
  110. package/src/forms/fields/UploadField/utils.ts +17 -0
  111. package/src/forms/fields/constants.ts +43 -0
  112. package/src/forms/fields/hooks.tsx +26 -0
  113. package/src/forms/fields/index.ts +12 -0
  114. package/src/forms/fields/typings.ts +45 -0
  115. package/src/forms/fields/utils.ts +125 -0
  116. package/src/forms/index.ts +5 -0
  117. package/src/forms/renderer/FormRenderer/FormRenderer.stories.tsx +142 -0
  118. package/src/forms/renderer/FormRenderer/FormRenderer.tsx +135 -0
  119. package/src/forms/renderer/PatchForm/Field.tsx +41 -0
  120. package/src/forms/renderer/PatchForm/PatchForm.stories.tsx +91 -0
  121. package/src/forms/renderer/PatchForm/Provider.tsx +119 -0
  122. package/src/forms/renderer/PatchForm/index.ts +2 -0
  123. package/src/forms/renderer/index.ts +2 -0
  124. package/src/forms/typings.ts +162 -0
  125. package/src/forms/utils.ts +69 -0
  126. package/src/index.ts +11 -0
  127. package/src/vite-env.d.ts +1 -0
  128. package/tailwind.config.ts +8 -0
  129. package/tsconfig.json +26 -0
  130. package/vite.config.ts +23 -0
@@ -0,0 +1,56 @@
1
+ import pluginJs from "@eslint/js"
2
+ import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"
3
+ import pluginReact from "eslint-plugin-react"
4
+ import simpleImportSort from "eslint-plugin-simple-import-sort"
5
+ import pluginStorybook from "eslint-plugin-storybook"
6
+ import globals from "globals"
7
+ import tseslint from "typescript-eslint"
8
+
9
+ /** @type {import('eslint').Linter.Config[]} */
10
+ export default [
11
+ { files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"] },
12
+ { languageOptions: { globals: globals.browser } },
13
+ pluginJs.configs.recommended,
14
+ ...tseslint.configs.recommended,
15
+ {
16
+ rules: {
17
+ "no-unused-vars": "off",
18
+ "@typescript-eslint/no-unused-vars": [
19
+ "error",
20
+ {
21
+ varsIgnorePattern: "^_",
22
+ argsIgnorePattern: "^_",
23
+ caughtErrorsIgnorePattern: "^_",
24
+ },
25
+ ],
26
+ },
27
+ },
28
+ {
29
+ plugins: {
30
+ "simple-import-sort": simpleImportSort,
31
+ },
32
+ rules: {
33
+ "simple-import-sort/imports": "error",
34
+ "simple-import-sort/exports": "error",
35
+ },
36
+ },
37
+ {
38
+ ...pluginReact.configs.flat.recommended,
39
+ settings: {
40
+ react: { version: "detect" },
41
+ },
42
+ rules: {
43
+ "react/react-in-jsx-scope": "off",
44
+ },
45
+ },
46
+ ...pluginStorybook.configs["flat/recommended"],
47
+ eslintPluginPrettierRecommended,
48
+ {
49
+ linterOptions: {
50
+ reportUnusedDisableDirectives: "error",
51
+ },
52
+ },
53
+ {
54
+ ignores: ["!.storybook", "dist"],
55
+ },
56
+ ]
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@overmap-ai/forms",
3
+ "private": false,
4
+ "version": "0.0.1-master.3",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "tsc && vite build",
10
+ "preview": "vite preview",
11
+ "prepare": "husky",
12
+ "lint:fix": "yarn lint --fix",
13
+ "lint": "tsc --noEmit && eslint . --max-warnings 0",
14
+ "storybook": "storybook dev -p 6006",
15
+ "build-storybook": "storybook build"
16
+ },
17
+ "peerDependencies": {
18
+ "@overmap-ai/blocks": "1.0.31-tailwind-components.22",
19
+ "@overmap-ai/core": "1.0.60-forms-removal.3",
20
+ "react": ">=18.2.0 <20.0.0",
21
+ "react-dom": ">=18.2.0 <20.0.0",
22
+ "tailwindcss": "^4.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@chromatic-com/storybook": "^3.2.4",
26
+ "@eslint/js": "^9.19.0",
27
+ "@overmap-ai/blocks": "1.0.31-tailwind-components.25",
28
+ "@overmap-ai/core": "1.0.60-forms-removal.3",
29
+ "@radix-ui/colors": "^3.0.0",
30
+ "@storybook/addon-a11y": "^8.5.3",
31
+ "@storybook/addon-essentials": "^8.5.3",
32
+ "@storybook/addon-interactions": "^8.5.3",
33
+ "@storybook/addon-onboarding": "^8.5.3",
34
+ "@storybook/addon-themes": "^8.5.3",
35
+ "@storybook/addon-viewport": "^8.5.3",
36
+ "@storybook/blocks": "^8.5.3",
37
+ "@storybook/react": "^8.5.3",
38
+ "@storybook/react-vite": "^8.5.3",
39
+ "@storybook/test": "^8.5.3",
40
+ "@storybook/types": "^8.5.3",
41
+ "@tailwindcss/vite": "^4.0.0",
42
+ "@types/file-saver": "^2.0.7",
43
+ "@types/lodash.clonedeep": "^4.5.9",
44
+ "@types/lodash.get": "^4.4.9",
45
+ "@types/lodash.set": "^4.3.9",
46
+ "@types/node": "^22.13.0",
47
+ "@types/react": "^19.0.0",
48
+ "@types/react-dom": "^19.0.0",
49
+ "@types/react-icons": "^3.0.0",
50
+ "@types/xlsx": "^0.0.36",
51
+ "@vitejs/plugin-react": "^4.3.4",
52
+ "eslint": "^9.19.0",
53
+ "eslint-config-prettier": "^10.0.1",
54
+ "eslint-plugin-prettier": "^5.2.3",
55
+ "eslint-plugin-react": "^7.37.4",
56
+ "eslint-plugin-simple-import-sort": "^12.1.1",
57
+ "eslint-plugin-storybook": "^0.11.2",
58
+ "file-saver": "^2.0.5",
59
+ "globals": "^15.14.0",
60
+ "husky": "^9.1.7",
61
+ "prettier": "^3.4.2",
62
+ "react": "^19.0.0",
63
+ "react-dom": "^19.0.0",
64
+ "storybook": "^8.5.3",
65
+ "tailwindcss": "^4.0.0",
66
+ "typescript": "~5.6.2",
67
+ "typescript-eslint": "^8.22.0",
68
+ "vite": "^6.0.5",
69
+ "vite-plugin-dts": "^4.5.0",
70
+ "vite-plugin-externalize-deps": "^0.9.0"
71
+ },
72
+ "eslintConfig": {
73
+ "extends": [
74
+ "plugin:storybook/recommended"
75
+ ]
76
+ },
77
+ "dependencies": {
78
+ "@hello-pangea/dnd": "^17.0.0",
79
+ "formik": "^2.4.6",
80
+ "lodash.clonedeep": "^4.5.0",
81
+ "lodash.get": "^4.4.2",
82
+ "lodash.set": "^4.3.2",
83
+ "qr-scanner": "^1.4.2",
84
+ "react-icons": "^5.4.0",
85
+ "react-pdf": "^9.2.1",
86
+ "react-sketch-canvas": "^6.2.0",
87
+ "react-spreadsheet": "^0.9.5",
88
+ "xlsx": "^0.18.5"
89
+ },
90
+ "resolutions": {
91
+ "@types/react": "^19.0.0"
92
+ }
93
+ }
@@ -0,0 +1,47 @@
1
+ import { IconButton, Popover, RiIcon } from "@overmap-ai/blocks"
2
+ import { CSSColor } from "@overmap-ai/core"
3
+ import { memo, ReactElement, useCallback } from "react"
4
+
5
+ interface ColorPickerProps {
6
+ selectedColor: CSSColor | null
7
+ allColors: CSSColor[]
8
+ onFinish: (color: CSSColor) => void
9
+ trigger: ReactElement
10
+ }
11
+
12
+ export const ColorPicker = memo((props: ColorPickerProps) => {
13
+ const { selectedColor, allColors, onFinish, trigger } = props
14
+
15
+ const handleSelectedColorChange = useCallback(
16
+ (color: CSSColor) => () => {
17
+ onFinish(color)
18
+ },
19
+ [onFinish],
20
+ )
21
+
22
+ return (
23
+ <Popover.Root>
24
+ <Popover.Trigger>{trigger}</Popover.Trigger>
25
+ <Popover.Content size="sm">
26
+ <div className="grid w-max grid-cols-7 gap-x-1 gap-y-1">
27
+ {allColors.map((color) => (
28
+ <IconButton
29
+ key={color}
30
+ onClick={handleSelectedColorChange(color)}
31
+ style={{
32
+ backgroundColor: color,
33
+ }}
34
+ type="button"
35
+ variant="solid"
36
+ aria-label={color}
37
+ >
38
+ {selectedColor === color && <RiIcon icon="RiCheckLine" />}
39
+ </IconButton>
40
+ ))}
41
+ </div>
42
+ </Popover.Content>
43
+ </Popover.Root>
44
+ )
45
+ })
46
+
47
+ ColorPicker.displayName = "ColorPicker"
@@ -0,0 +1 @@
1
+ export * from "./ColorPicker"
@@ -0,0 +1,27 @@
1
+ import { Badge, BadgeProps } from "@overmap-ai/blocks"
2
+ import { truncate } from "@overmap-ai/core"
3
+ import { memo, useMemo } from "react"
4
+
5
+ import { FileIcon } from "../FileIcon"
6
+
7
+ export interface FileBadgeProps extends Omit<BadgeProps, "children"> {
8
+ file: File
9
+ truncateLength?: number
10
+ hideName?: boolean
11
+ }
12
+ export const FileBadge = memo((props: FileBadgeProps) => {
13
+ const { file, truncateLength, hideName = false, ...rest } = props
14
+
15
+ const fileName = useMemo(
16
+ () => (truncateLength !== undefined ? truncate(file.name, truncateLength) : file.name),
17
+ [file.name, truncateLength],
18
+ )
19
+
20
+ return (
21
+ <Badge {...rest}>
22
+ <FileIcon fileType={file.type} />
23
+ {!hideName && fileName}
24
+ </Badge>
25
+ )
26
+ })
27
+ FileBadge.displayName = "FileBadge"
@@ -0,0 +1 @@
1
+ export * from "./FileBadge"
@@ -0,0 +1,69 @@
1
+ import type { Meta } from "@storybook/react"
2
+ import { ChangeEvent, useCallback, useRef, useState } from "react"
3
+
4
+ import { FileCard } from "./FileCard"
5
+
6
+ const meta = {
7
+ title: "Components/FileCard",
8
+ component: FileCard,
9
+ tags: ["autodocs"],
10
+ parameters: {
11
+ disablePanel: true,
12
+ },
13
+ } satisfies Meta<typeof FileCard>
14
+
15
+ export default meta
16
+
17
+ export const Basic = () => {
18
+ const inputRef = useRef<HTMLInputElement>(null)
19
+ const [files, setFiles] = useState<File[]>([])
20
+
21
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
22
+ if (e.target.files) {
23
+ const files = Array.from(e.target.files)
24
+ if (files.length > 0) {
25
+ setFiles(files)
26
+ }
27
+ }
28
+ }, [])
29
+
30
+ return (
31
+ <>
32
+ <input ref={inputRef} multiple type="file" onChange={handleChange} />
33
+ <div className="flex flex-col gap-1">
34
+ {files.map((file, index) => (
35
+ <FileCard key={index} file={file} />
36
+ ))}
37
+ </div>
38
+ </>
39
+ )
40
+ }
41
+
42
+ export const Loading = () => {
43
+ return <FileCard file={null} />
44
+ }
45
+
46
+ export const Errored = () => {
47
+ const inputRef = useRef<HTMLInputElement>(null)
48
+ const [files, setFiles] = useState<File[]>([])
49
+
50
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
51
+ if (e.target.files) {
52
+ const files = Array.from(e.target.files)
53
+ if (files.length > 0) {
54
+ setFiles(files)
55
+ }
56
+ }
57
+ }, [])
58
+
59
+ return (
60
+ <>
61
+ <input ref={inputRef} multiple type="file" onChange={handleChange} />
62
+ <div className="flex flex-col gap-1">
63
+ {files.map((file, index) => (
64
+ <FileCard key={index} file={file} error={file.name} />
65
+ ))}
66
+ </div>
67
+ </>
68
+ )
69
+ }
@@ -0,0 +1,53 @@
1
+ import { RiIcon, Spinner } from "@overmap-ai/blocks"
2
+ import { truncate } from "@overmap-ai/core"
3
+ import { cx } from "class-variance-authority"
4
+ import { ComponentProps, forwardRef, memo, PropsWithoutRef, ReactNode, useMemo } from "react"
5
+
6
+ import { FileIcon } from "../FileIcon"
7
+
8
+ export interface FileCardProps extends Omit<PropsWithoutRef<ComponentProps<"div">>, "children"> {
9
+ file: File | null
10
+ truncateLength?: number
11
+ error?: string
12
+ rightSlot?: ReactNode
13
+ }
14
+
15
+ export const FileCard = memo(
16
+ forwardRef<HTMLDivElement, FileCardProps>((props, ref) => {
17
+ const { file, className, error, truncateLength, rightSlot, ...rest } = props
18
+
19
+ const fileName = useMemo(() => {
20
+ if (!file) return
21
+ return truncateLength !== undefined ? truncate(file.name, truncateLength) : file.name
22
+ }, [file, truncateLength])
23
+
24
+ return (
25
+ <div
26
+ className={cx(
27
+ className,
28
+ "flex h-5 w-full items-center gap-2 rounded-md border border-(--base-a6) bg-(--base-2) text-sm py-1 px-2 h-max",
29
+ {
30
+ "text-(--gray-11)": error,
31
+ },
32
+ )}
33
+ ref={ref}
34
+ {...rest}
35
+ >
36
+ {error ? <RiIcon icon="RiFileWarningLine" /> : <FileIcon fileType={file?.type ?? ""} />}
37
+ {!error ? (
38
+ file ? (
39
+ fileName
40
+ ) : (
41
+ <div className="flex w-full justify-center">
42
+ <Spinner />
43
+ </div>
44
+ )
45
+ ) : (
46
+ error
47
+ )}
48
+ {rightSlot}
49
+ </div>
50
+ )
51
+ }),
52
+ )
53
+ FileCard.displayName = "FileCard"
@@ -0,0 +1 @@
1
+ export * from "./FileCard"
@@ -0,0 +1,31 @@
1
+ import { RiIcon } from "@overmap-ai/blocks"
2
+ import { memo, useMemo } from "react"
3
+
4
+ import { SUPPORTED_IMAGE_FILE_TYPES } from "../ImageViewer"
5
+ import { SUPPORTED_PDF_FILE_TYPES } from "../PDFViewer"
6
+ import { SUPPORTED_SPREADSHEET_FILE_EXTENSIONS } from "../SpreadsheetViewer"
7
+
8
+ // TODO: create a mapping for each supported file extensions to a appropriate icon.
9
+ // May need to outsource some icons for this.
10
+
11
+ export interface FileIconProps {
12
+ fileType: string
13
+ }
14
+ export const FileIcon = memo((props: FileIconProps) => {
15
+ const { fileType } = props
16
+ const icon = useMemo(() => {
17
+ if (SUPPORTED_SPREADSHEET_FILE_EXTENSIONS.includes(fileType)) {
18
+ return <RiIcon icon="RiFileExcelLine" />
19
+ }
20
+ if (SUPPORTED_PDF_FILE_TYPES.includes(fileType)) {
21
+ return <RiIcon icon="RiFilePdfLine" />
22
+ }
23
+ if (SUPPORTED_IMAGE_FILE_TYPES.includes(fileType)) {
24
+ return <RiIcon icon="RiFileImageLine" />
25
+ }
26
+ return <RiIcon icon="RiFileLine" />
27
+ }, [fileType])
28
+
29
+ return <div className="h-max w-max">{icon}</div>
30
+ })
31
+ FileIcon.displayName = "FileIcon"
@@ -0,0 +1 @@
1
+ export * from "./FileIcon"
@@ -0,0 +1,50 @@
1
+ import { Button } from "@overmap-ai/blocks"
2
+ import type { Meta } from "@storybook/react"
3
+ import { ChangeEvent, useCallback, useRef, useState } from "react"
4
+
5
+ import { useFileViewer } from "./context"
6
+ import { FileViewerProvider } from "./FileViewerProvider"
7
+
8
+ const meta = {
9
+ title: "Contexts/FileViewer",
10
+ component: FileViewerProvider,
11
+ tags: ["autodocs"],
12
+ parameters: {
13
+ disablePanel: true,
14
+ },
15
+ } satisfies Meta<typeof FileViewerProvider>
16
+
17
+ export default meta
18
+
19
+ export const Basic = () => {
20
+ const inputRef = useRef<HTMLInputElement>(null)
21
+ const [file, setFile] = useState<File | null>(null)
22
+ const openFileViewer = useFileViewer()
23
+
24
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
25
+ if (e.target.files) {
26
+ const files = Array.from(e.target.files)
27
+ if (files.length > 0) {
28
+ setFile(files[0] || null)
29
+ }
30
+ }
31
+ }, [])
32
+
33
+ return (
34
+ <>
35
+ <input ref={inputRef} type="file" onChange={handleChange} />
36
+ {file && (
37
+ <Button
38
+ onClick={() => {
39
+ openFileViewer((close) => ({
40
+ file: file,
41
+ onDelete: close,
42
+ }))
43
+ }}
44
+ >
45
+ Open
46
+ </Button>
47
+ )}
48
+ </>
49
+ )
50
+ }
@@ -0,0 +1,72 @@
1
+ import { memo, PropsWithChildren, useCallback, useEffect, useMemo, useState } from "react"
2
+
3
+ import { ImageViewer, SUPPORTED_IMAGE_FILE_TYPES } from "../ImageViewer"
4
+ import { PDFViewer, SUPPORTED_PDF_FILE_TYPES } from "../PDFViewer"
5
+ import { SpreadsheetViewer, SUPPORTED_SPREADSHEET_FILE_EXTENSIONS } from "../SpreadsheetViewer"
6
+ import { FileViewerContext } from "./context"
7
+ import { FileViewerConfig } from "./typings"
8
+
9
+ type FileViewerType = "image" | "spreadsheet" | "pdf"
10
+ export const FileViewerProvider = memo((props: PropsWithChildren) => {
11
+ const { children } = props
12
+ const [config, setConfig] = useState<FileViewerConfig | null>(null)
13
+ const [fileType, setFileType] = useState<FileViewerType | null>(null)
14
+
15
+ const closeFileViewer = useCallback(() => {
16
+ setConfig(null)
17
+ setFileType(null)
18
+ }, [])
19
+
20
+ const openFileViewer = useCallback(
21
+ (func: (closeFileViewer: () => void) => FileViewerConfig) => {
22
+ setConfig(func(closeFileViewer))
23
+ },
24
+ [closeFileViewer],
25
+ )
26
+
27
+ const handleClose = useCallback(() => {
28
+ if (!config) return
29
+ if (config.onClose) config.onClose()
30
+ closeFileViewer()
31
+ }, [closeFileViewer, config])
32
+
33
+ useEffect(() => {
34
+ if (!config) return
35
+ const { file } = config
36
+
37
+ if (SUPPORTED_SPREADSHEET_FILE_EXTENSIONS.includes(file.type)) {
38
+ setFileType("spreadsheet")
39
+ return
40
+ }
41
+ if (SUPPORTED_PDF_FILE_TYPES.includes(file.type)) {
42
+ setFileType("pdf")
43
+ return
44
+ }
45
+ if (SUPPORTED_IMAGE_FILE_TYPES.includes(file.type)) {
46
+ setFileType("image")
47
+ return
48
+ }
49
+ }, [config])
50
+
51
+ const value = useMemo(() => openFileViewer, [openFileViewer])
52
+
53
+ return (
54
+ <FileViewerContext.Provider value={value}>
55
+ {children}
56
+ {config && (
57
+ <>
58
+ {fileType === "spreadsheet" && (
59
+ <SpreadsheetViewer file={config.file} onDelete={config.onDelete} onClose={handleClose} />
60
+ )}
61
+ {fileType === "pdf" && (
62
+ <PDFViewer file={config.file} onDelete={config.onDelete} onClose={handleClose} />
63
+ )}
64
+ {fileType === "image" && (
65
+ <ImageViewer file={config.file} onDelete={config.onDelete} onClose={handleClose} />
66
+ )}
67
+ </>
68
+ )}
69
+ </FileViewerContext.Provider>
70
+ )
71
+ })
72
+ FileViewerProvider.displayName = "FileViewerProvider"
@@ -0,0 +1,11 @@
1
+ import { createContext, useContext } from "react"
2
+
3
+ import { FileViewerConfig } from "./typings"
4
+
5
+ export type FileViewerContextType = (func: (close: () => void) => FileViewerConfig) => void
6
+
7
+ export const FileViewerContext = createContext<FileViewerContextType>(() => {
8
+ throw new Error("No FileViewerProvider found")
9
+ })
10
+
11
+ export const useFileViewer = () => useContext(FileViewerContext)
@@ -0,0 +1,3 @@
1
+ export * from "./context"
2
+ export * from "./FileViewerProvider"
3
+ export * from "./typings"
@@ -0,0 +1,5 @@
1
+ export interface FileViewerConfig {
2
+ file: File
3
+ onDelete?: (file: File) => void
4
+ onClose?: () => void
5
+ }
@@ -0,0 +1,94 @@
1
+ import type { Meta } from "@storybook/react"
2
+ import { ChangeEvent, useCallback, useRef, useState } from "react"
3
+
4
+ import { ImageCard } from "./ImageCard"
5
+
6
+ const meta = {
7
+ title: "Components/ImageCard",
8
+ component: ImageCard,
9
+ tags: ["autodocs"],
10
+ parameters: {
11
+ disablePanel: true,
12
+ },
13
+ } satisfies Meta<typeof ImageCard>
14
+
15
+ export default meta
16
+
17
+ export const Basic = () => {
18
+ const inputRef = useRef<HTMLInputElement>(null)
19
+ const [files, setFiles] = useState<File[]>([])
20
+
21
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
22
+ if (e.target.files) {
23
+ const files = Array.from(e.target.files)
24
+ if (files.length > 0) {
25
+ setFiles(files)
26
+ }
27
+ }
28
+ }, [])
29
+
30
+ return (
31
+ <>
32
+ <input ref={inputRef} multiple type="file" onChange={handleChange} />
33
+ <div className="flex flex-col gap-1">
34
+ {files.map((file, index) => (
35
+ <ImageCard key={index} file={file} />
36
+ ))}
37
+ </div>
38
+ </>
39
+ )
40
+ }
41
+
42
+ export const Loading = () => {
43
+ return <ImageCard file={null} />
44
+ }
45
+
46
+ export const Errored = () => {
47
+ const inputRef = useRef<HTMLInputElement>(null)
48
+ const [files, setFiles] = useState<File[]>([])
49
+
50
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
51
+ if (e.target.files) {
52
+ const files = Array.from(e.target.files)
53
+ if (files.length > 0) {
54
+ setFiles(files)
55
+ }
56
+ }
57
+ }, [])
58
+
59
+ return (
60
+ <>
61
+ <input ref={inputRef} multiple type="file" onChange={handleChange} />
62
+ <div className="flex flex-col gap-1">
63
+ {files.map((file, index) => (
64
+ <ImageCard key={index} file={file} error={file.name} />
65
+ ))}
66
+ </div>
67
+ </>
68
+ )
69
+ }
70
+
71
+ export const Responsive = () => {
72
+ const inputRef = useRef<HTMLInputElement>(null)
73
+ const [files, setFiles] = useState<File[]>([])
74
+
75
+ const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
76
+ if (e.target.files) {
77
+ const files = Array.from(e.target.files)
78
+ if (files.length > 0) {
79
+ setFiles(files)
80
+ }
81
+ }
82
+ }, [])
83
+
84
+ return (
85
+ <>
86
+ <input ref={inputRef} multiple type="file" onChange={handleChange} />
87
+ <div className="flex flex-col gap-1">
88
+ {files.map((file, index) => (
89
+ <ImageCard key={index} file={file} />
90
+ ))}
91
+ </div>
92
+ </>
93
+ )
94
+ }