@salesforce/webapp-template-feature-react-file-upload-experimental 1.55.0
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.
- package/LICENSE.txt +82 -0
- package/README.md +102 -0
- package/dist/.a4drules/README.md +35 -0
- package/dist/.a4drules/a4d-webapp-generate.md +27 -0
- package/dist/.a4drules/build-validation.md +78 -0
- package/dist/.a4drules/code-quality.md +137 -0
- package/dist/.a4drules/graphql/tools/knowledge/lds-explore-graphql-schema.md +227 -0
- package/dist/.a4drules/graphql/tools/knowledge/lds-generate-graphql-mutationquery.md +212 -0
- package/dist/.a4drules/graphql/tools/knowledge/lds-generate-graphql-readquery.md +185 -0
- package/dist/.a4drules/graphql/tools/knowledge/lds-guide-graphql.md +205 -0
- package/dist/.a4drules/graphql/tools/schemas/shared.graphqls +1150 -0
- package/dist/.a4drules/graphql.md +409 -0
- package/dist/.a4drules/images.md +13 -0
- package/dist/.a4drules/react.md +387 -0
- package/dist/.a4drules/react_image_processing.md +45 -0
- package/dist/.a4drules/typescript.md +224 -0
- package/dist/.a4drules/ui-layout.md +23 -0
- package/dist/.a4drules/webapp-nav-and-placeholders.md +33 -0
- package/dist/.a4drules/webapp-no-node-e.md +25 -0
- package/dist/.a4drules/webapp-ui-first.md +32 -0
- package/dist/.a4drules/webapp.md +75 -0
- package/dist/.forceignore +15 -0
- package/dist/.husky/pre-commit +4 -0
- package/dist/.prettierignore +11 -0
- package/dist/.prettierrc +17 -0
- package/dist/AGENT.md +75 -0
- package/dist/CHANGELOG.md +803 -0
- package/dist/README.md +18 -0
- package/dist/config/project-scratch-def.json +13 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/.graphqlrc.yml +2 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/.prettierignore +9 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/.prettierrc +11 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/build/vite.config.d.ts +2 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/build/vite.config.js +93 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/codegen.yml +94 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/e2e/app.spec.ts +17 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/eslint.config.js +141 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/feature-react-file-upload.webapplication-meta.xml +7 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/index.html +13 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/package-lock.json +18396 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/package.json +66 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/playwright.config.ts +24 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/scripts/get-graphql-schema.mjs +68 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/scripts/rewrite-e2e-assets.mjs +23 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/api/fileUpload.ts +154 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/api/graphql-operations-types.ts +116 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/api/utils/accounts.ts +41 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/api/utils/query/highRevenueAccountsQuery.graphql +29 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/app.tsx +22 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/appLayout.tsx +9 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/assets/icons/book.svg +3 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/assets/icons/copy.svg +4 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/assets/icons/rocket.svg +3 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/assets/icons/star.svg +3 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/assets/images/codey-1.png +0 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/assets/images/codey-2.png +0 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/assets/images/codey-3.png +0 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/assets/images/vibe-codey.svg +194 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/assets/symbols.svg +1 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/assets/utility.svg +1 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUpload.tsx +83 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadDialog.tsx +79 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadDropZone.tsx +82 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadFileItem.tsx +99 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadIcons.tsx +58 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/alert.tsx +69 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/button.tsx +67 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/card.tsx +92 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/dialog.tsx +143 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/field.tsx +222 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/index.ts +84 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/input.tsx +19 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/label.tsx +19 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/pagination.tsx +112 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/select.tsx +183 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/separator.tsx +26 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/skeleton.tsx +14 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/spinner.tsx +15 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/table.tsx +87 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components/ui/tabs.tsx +78 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/components.json +18 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/hooks/useFileUpload.ts +299 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/hooks/useFileUploadDialog.ts +70 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/index.ts +56 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/lib/utils.ts +6 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/navigationMenu.tsx +80 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/pages/Home.tsx +25 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/pages/NotFound.tsx +18 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/router-utils.tsx +35 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/routes.tsx +22 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/styles/global.css +135 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/types/fileUpload.ts +26 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/utils/fileUploadUtils.ts +44 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/src/utils/labels.ts +21 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/tsconfig.json +36 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/tsconfig.node.json +13 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/vite-env.d.ts +1 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/vite.config.ts +43 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/vitest-env.d.ts +2 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/vitest.config.ts +11 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/vitest.setup.ts +1 -0
- package/dist/force-app/main/default/webapplications/feature-react-file-upload/webapplication.json +7 -0
- package/dist/jest.config.js +6 -0
- package/dist/package.json +38 -0
- package/dist/scripts/apex/hello.apex +10 -0
- package/dist/scripts/soql/account.soql +6 -0
- package/dist/sfdx-project.json +12 -0
- package/package.json +53 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/api/fileUpload.ts +154 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/appLayout.tsx +9 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/assets/symbols.svg +1 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/assets/utility.svg +1 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUpload.tsx +83 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadDialog.tsx +79 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadDropZone.tsx +82 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadFileItem.tsx +99 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/components/FileUploadIcons.tsx +58 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/hooks/useFileUpload.ts +299 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/hooks/useFileUploadDialog.ts +70 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/index.ts +56 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/pages/Home.tsx +25 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/routes.tsx +17 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/types/fileUpload.ts +26 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/utils/fileUploadUtils.ts +44 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/src/utils/labels.ts +21 -0
- package/src/force-app/main/default/webapplications/feature-react-file-upload/vite.config.ts +43 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { FileUploadDropZone } from "./FileUploadDropZone";
|
|
2
|
+
import { FileUploadDialog } from "./FileUploadDialog";
|
|
3
|
+
import { LABELS } from "../utils/labels";
|
|
4
|
+
import { useFileUpload } from "../hooks/useFileUpload";
|
|
5
|
+
import { useFileUploadDialog } from "../hooks/useFileUploadDialog";
|
|
6
|
+
|
|
7
|
+
export interface FileUploadProps {
|
|
8
|
+
/** MIME types to accept (e.g. image/*). Omit for all files. */
|
|
9
|
+
accept?: string;
|
|
10
|
+
/** Whether to allow multiple file selection. Default: false */
|
|
11
|
+
multiple?: boolean;
|
|
12
|
+
/** Record Id for FirstPublishLocationId (e.g. Account, Opportunity). When provided, files are linked to this record. Otherwise, current user Id is used. */
|
|
13
|
+
recordId?: string;
|
|
14
|
+
/** Called when uploads complete. Receives array of successfully uploaded files with name, size, and contentVersionId. */
|
|
15
|
+
onUploadComplete?: (files: { name: string; size: number; contentVersionId?: string }[]) => void;
|
|
16
|
+
onUploadError?: (file: File, error: string) => void;
|
|
17
|
+
/** Optional CSS class for the wrapper div */
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* File upload component. Renders a drop zone for selecting files, a modal dialog
|
|
23
|
+
* showing upload progress, and a list of successfully uploaded files. Supports
|
|
24
|
+
* click-to-select and drag-and-drop. Composes FileUploadDropZone and FileUploadDialog.
|
|
25
|
+
*/
|
|
26
|
+
export function FileUpload({
|
|
27
|
+
accept,
|
|
28
|
+
multiple = false,
|
|
29
|
+
recordId,
|
|
30
|
+
onUploadComplete,
|
|
31
|
+
onUploadError,
|
|
32
|
+
className = "",
|
|
33
|
+
}: FileUploadProps) {
|
|
34
|
+
const { fileItems, getInputProps, getDropZoneProps, isDragging, reset, cancelFile, allDone } =
|
|
35
|
+
useFileUpload({
|
|
36
|
+
accept,
|
|
37
|
+
multiple,
|
|
38
|
+
recordId,
|
|
39
|
+
onUploadComplete,
|
|
40
|
+
onUploadError,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const { dialogOpen, uploadedFileNames, handleOpenChange } = useFileUploadDialog({
|
|
44
|
+
fileItems,
|
|
45
|
+
reset,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const inputProps = getInputProps();
|
|
49
|
+
const dropZoneProps = getDropZoneProps();
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={["w-fit", className].filter(Boolean).join(" ")}>
|
|
53
|
+
<div className="flex w-fit flex-col gap-1">
|
|
54
|
+
<FileUploadDropZone
|
|
55
|
+
inputProps={inputProps}
|
|
56
|
+
dropZoneProps={dropZoneProps}
|
|
57
|
+
isDragging={isDragging}
|
|
58
|
+
/>
|
|
59
|
+
|
|
60
|
+
<FileUploadDialog
|
|
61
|
+
open={dialogOpen}
|
|
62
|
+
onOpenChange={handleOpenChange}
|
|
63
|
+
fileItems={fileItems}
|
|
64
|
+
onCancelFile={cancelFile}
|
|
65
|
+
allDone={allDone}
|
|
66
|
+
/>
|
|
67
|
+
|
|
68
|
+
{uploadedFileNames.length > 0 && (
|
|
69
|
+
<ul
|
|
70
|
+
className="mt-2 list-inside list-disc space-y-1 text-sm text-gray-700"
|
|
71
|
+
aria-label={LABELS.uploadedFiles}
|
|
72
|
+
>
|
|
73
|
+
{uploadedFileNames.map((name, i) => (
|
|
74
|
+
<li key={`${name}-${i}`}>{name}</li>
|
|
75
|
+
))}
|
|
76
|
+
</ul>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default FileUpload;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogClose,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from "@/components/ui";
|
|
10
|
+
import { FileUploadFileItem } from "./FileUploadFileItem";
|
|
11
|
+
import { formatUploadSummary } from "../utils/fileUploadUtils";
|
|
12
|
+
import { LABELS } from "../utils/labels";
|
|
13
|
+
import type { FileUploadItem } from "../types/fileUpload";
|
|
14
|
+
|
|
15
|
+
export interface FileUploadDialogProps {
|
|
16
|
+
/** Whether the dialog is open */
|
|
17
|
+
open: boolean;
|
|
18
|
+
/** Called when the dialog open state changes (e.g. close button, Done) */
|
|
19
|
+
onOpenChange: (open: boolean) => void;
|
|
20
|
+
/** File items with upload state and progress */
|
|
21
|
+
fileItems: FileUploadItem[];
|
|
22
|
+
/** Called when the user cancels an in-progress upload */
|
|
23
|
+
onCancelFile: (fileName: string) => void;
|
|
24
|
+
/** Whether all uploads are complete (success, error, or cancelled) */
|
|
25
|
+
allDone: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Modal dialog showing upload progress. Lists each file with FileUploadFileItem,
|
|
30
|
+
* displays upload summary (e.g. "1 of 2 files uploaded"), and a Done button
|
|
31
|
+
* disabled until all uploads complete.
|
|
32
|
+
*/
|
|
33
|
+
export function FileUploadDialog({
|
|
34
|
+
open,
|
|
35
|
+
onOpenChange,
|
|
36
|
+
fileItems,
|
|
37
|
+
onCancelFile,
|
|
38
|
+
allDone,
|
|
39
|
+
}: FileUploadDialogProps) {
|
|
40
|
+
const successCount = fileItems.filter((i) => i.state === "success").length;
|
|
41
|
+
const summaryText = formatUploadSummary(successCount, fileItems.length);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
45
|
+
<DialogContent showCloseButton>
|
|
46
|
+
<DialogHeader>
|
|
47
|
+
<DialogTitle className="text-blue-900">{LABELS.uploadFilesDialogTitle}</DialogTitle>
|
|
48
|
+
</DialogHeader>
|
|
49
|
+
|
|
50
|
+
<ul className="max-h-60 min-w-0 space-y-2 overflow-y-auto" aria-label={LABELS.uploadStatus}>
|
|
51
|
+
{fileItems.map((item, i) => (
|
|
52
|
+
<FileUploadFileItem
|
|
53
|
+
key={`${item.file.name}-${i}`}
|
|
54
|
+
item={item}
|
|
55
|
+
onCancel={onCancelFile}
|
|
56
|
+
/>
|
|
57
|
+
))}
|
|
58
|
+
</ul>
|
|
59
|
+
|
|
60
|
+
<DialogFooter className="grid min-w-0 grid-cols-[auto_minmax(0,4fr)_minmax(0,5fr)] items-center px-2 pb-1 pt-2">
|
|
61
|
+
<div aria-hidden />
|
|
62
|
+
<p className="min-w-0 text-sm font-medium text-blue-900">{summaryText}</p>
|
|
63
|
+
<div className="flex min-w-0 justify-end">
|
|
64
|
+
<DialogClose asChild>
|
|
65
|
+
<Button
|
|
66
|
+
variant="outline"
|
|
67
|
+
className="flex-shrink-0"
|
|
68
|
+
disabled={!allDone}
|
|
69
|
+
aria-label={LABELS.doneButton}
|
|
70
|
+
>
|
|
71
|
+
{LABELS.done}
|
|
72
|
+
</Button>
|
|
73
|
+
</DialogClose>
|
|
74
|
+
</div>
|
|
75
|
+
</DialogFooter>
|
|
76
|
+
</DialogContent>
|
|
77
|
+
</Dialog>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { UtilityIcon } from "./FileUploadIcons";
|
|
2
|
+
import { LABELS } from "../utils/labels";
|
|
3
|
+
|
|
4
|
+
export interface FileUploadDropZoneProps {
|
|
5
|
+
/** Props for the hidden file input (ref, type, accept, multiple, onChange) */
|
|
6
|
+
inputProps: {
|
|
7
|
+
ref: React.RefObject<HTMLInputElement | null>;
|
|
8
|
+
type: "file";
|
|
9
|
+
accept?: string;
|
|
10
|
+
multiple: boolean;
|
|
11
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
12
|
+
};
|
|
13
|
+
/** Props for the drop zone (onClick, onDragOver, onDragLeave, onDrop, onKeyDown) */
|
|
14
|
+
dropZoneProps: {
|
|
15
|
+
onClick: () => void;
|
|
16
|
+
onDragOver: (e: React.DragEvent) => void;
|
|
17
|
+
onDragLeave: (e: React.DragEvent) => void;
|
|
18
|
+
onDrop: (e: React.DragEvent) => void;
|
|
19
|
+
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
20
|
+
};
|
|
21
|
+
/** Whether the user is currently dragging over the drop zone */
|
|
22
|
+
isDragging: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DROP_ZONE_BASE_CLASSES =
|
|
26
|
+
"mb-2 flex cursor-pointer flex-wrap items-center gap-3 rounded-[4px] border border-dashed p-1 transition-colors";
|
|
27
|
+
const DROP_ZONE_DRAGGING_CLASSES = "border-blue-500 bg-blue-50";
|
|
28
|
+
const DROP_ZONE_IDLE_CLASSES = "hover:bg-gray-50";
|
|
29
|
+
const BORDER_COLOR_IDLE = "rgb(116, 116, 116)";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Drop zone for file selection. Renders a dashed border area with "Upload Files"
|
|
33
|
+
* button and "Or drop files" / "Drop files here" text. Accepts click and drag-and-drop.
|
|
34
|
+
* Uses a hidden file input; props come from useFileUpload.
|
|
35
|
+
*/
|
|
36
|
+
export function FileUploadDropZone({
|
|
37
|
+
inputProps,
|
|
38
|
+
dropZoneProps,
|
|
39
|
+
isDragging,
|
|
40
|
+
}: FileUploadDropZoneProps) {
|
|
41
|
+
const dropZoneClassName = [
|
|
42
|
+
DROP_ZONE_BASE_CLASSES,
|
|
43
|
+
isDragging ? DROP_ZONE_DRAGGING_CLASSES : DROP_ZONE_IDLE_CLASSES,
|
|
44
|
+
].join(" ");
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<p className="mb-1 text-sm font-semibold text-gray-900">{LABELS.attach}</p>
|
|
49
|
+
<div
|
|
50
|
+
role="button"
|
|
51
|
+
tabIndex={0}
|
|
52
|
+
onClick={dropZoneProps.onClick}
|
|
53
|
+
onDragOver={dropZoneProps.onDragOver}
|
|
54
|
+
onDragLeave={dropZoneProps.onDragLeave}
|
|
55
|
+
onDrop={dropZoneProps.onDrop}
|
|
56
|
+
onKeyDown={dropZoneProps.onKeyDown}
|
|
57
|
+
className={dropZoneClassName}
|
|
58
|
+
style={!isDragging ? { borderColor: BORDER_COLOR_IDLE } : undefined}
|
|
59
|
+
aria-label={LABELS.dropZone}
|
|
60
|
+
data-testid="file-upload-drop-zone"
|
|
61
|
+
>
|
|
62
|
+
<input
|
|
63
|
+
id="file-upload-input-id"
|
|
64
|
+
ref={inputProps.ref}
|
|
65
|
+
type="file"
|
|
66
|
+
accept={inputProps.accept}
|
|
67
|
+
multiple={inputProps.multiple}
|
|
68
|
+
onChange={inputProps.onChange}
|
|
69
|
+
className="sr-only"
|
|
70
|
+
aria-hidden
|
|
71
|
+
/>
|
|
72
|
+
<span className="inline-flex items-center gap-2 rounded-[4px] border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-blue-600 hover:bg-gray-50">
|
|
73
|
+
<UtilityIcon id="upload" size="sm" />
|
|
74
|
+
{LABELS.uploadFiles}
|
|
75
|
+
</span>
|
|
76
|
+
<span className="text-sm text-gray-900">
|
|
77
|
+
{isDragging ? LABELS.dropFilesHere : LABELS.orDropFiles}
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Button } from "@/components/ui";
|
|
2
|
+
import { FileTypeIcon, UtilityIcon } from "./FileUploadIcons";
|
|
3
|
+
import {
|
|
4
|
+
formatFileSize,
|
|
5
|
+
getFileExtension,
|
|
6
|
+
getProgressWidth,
|
|
7
|
+
isUploading,
|
|
8
|
+
} from "../utils/fileUploadUtils";
|
|
9
|
+
import { LABELS } from "../utils/labels";
|
|
10
|
+
import type { FileUploadItem } from "../types/fileUpload";
|
|
11
|
+
|
|
12
|
+
export interface FileUploadFileItemProps {
|
|
13
|
+
/** The file item with upload state, progress, and optional error */
|
|
14
|
+
item: FileUploadItem;
|
|
15
|
+
/** Called when the user cancels an in-progress upload */
|
|
16
|
+
onCancel: (fileName: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Renders a single file upload item with file type icon, name, size, progress bar,
|
|
21
|
+
* cancel button (when uploading), and status icon (success, error, or cancelled).
|
|
22
|
+
*/
|
|
23
|
+
export function FileUploadFileItem({ item, onCancel }: FileUploadFileItemProps) {
|
|
24
|
+
const extension = getFileExtension(item.file.name);
|
|
25
|
+
const progressWidth = getProgressWidth(item.state, item.progress);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<li
|
|
29
|
+
className="grid min-w-0 grid-cols-[auto_minmax(0,4fr)_minmax(0,5fr)] items-center gap-2 rounded-lg border border-gray-200 bg-gray-100 p-2"
|
|
30
|
+
data-testid="file-upload-item"
|
|
31
|
+
>
|
|
32
|
+
<FileTypeIcon extension={extension} />
|
|
33
|
+
<div className="min-w-0 overflow-hidden">
|
|
34
|
+
<p
|
|
35
|
+
className="truncate text-sm font-medium text-gray-900"
|
|
36
|
+
title={item.file.name}
|
|
37
|
+
aria-label={LABELS.fileName(item.file.name)}
|
|
38
|
+
>
|
|
39
|
+
{item.file.name}
|
|
40
|
+
</p>
|
|
41
|
+
<p
|
|
42
|
+
className="truncate text-xs text-gray-600"
|
|
43
|
+
aria-label={LABELS.fileSize(formatFileSize(item.file.size))}
|
|
44
|
+
>
|
|
45
|
+
{formatFileSize(item.file.size)}
|
|
46
|
+
</p>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
49
|
+
<div
|
|
50
|
+
className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-gray-200"
|
|
51
|
+
role="progressbar"
|
|
52
|
+
aria-label={LABELS.uploadProgress(item.file.name)}
|
|
53
|
+
aria-valuenow={progressWidth}
|
|
54
|
+
aria-valuemin={0}
|
|
55
|
+
aria-valuemax={100}
|
|
56
|
+
>
|
|
57
|
+
<div
|
|
58
|
+
className="h-full bg-blue-600 transition-all duration-300"
|
|
59
|
+
style={{ width: `${progressWidth}%` }}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
{isUploading(item.state) && (
|
|
63
|
+
<Button
|
|
64
|
+
type="button"
|
|
65
|
+
variant="ghost"
|
|
66
|
+
size="icon"
|
|
67
|
+
className="h-7 w-7 shrink-0 text-gray-600 hover:text-red-600"
|
|
68
|
+
onClick={() => onCancel(item.file.name)}
|
|
69
|
+
aria-label={LABELS.cancelUpload(item.file.name)}
|
|
70
|
+
>
|
|
71
|
+
<UtilityIcon id="clear" fill="rgb(116, 116, 116)" />
|
|
72
|
+
</Button>
|
|
73
|
+
)}
|
|
74
|
+
{renderStatusIcon(item.state)}
|
|
75
|
+
{item.state === "error" && item.error && <p className="sr-only">{item.error}</p>}
|
|
76
|
+
</div>
|
|
77
|
+
</li>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderStatusIcon(state: FileUploadItem["state"]): React.ReactNode {
|
|
82
|
+
if (isUploading(state)) return null;
|
|
83
|
+
if (state === "success") return <UtilityIcon id="success" fill="rgb(46, 132, 74)" />;
|
|
84
|
+
if (state === "cancelled") {
|
|
85
|
+
return (
|
|
86
|
+
<span className="shrink-0 text-xs text-gray-500" aria-label={LABELS.cancelled}>
|
|
87
|
+
{LABELS.cancelled}
|
|
88
|
+
</span>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return (
|
|
92
|
+
<span
|
|
93
|
+
className="inline-flex h-4 w-4 shrink-0 items-center justify-center text-red-600"
|
|
94
|
+
aria-label={LABELS.uploadFailed}
|
|
95
|
+
>
|
|
96
|
+
×
|
|
97
|
+
</span>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import symbolsUrl from "@assets/symbols.svg?url";
|
|
2
|
+
|
|
3
|
+
import utilitySvg from "@assets/utility.svg?url";
|
|
4
|
+
|
|
5
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
6
|
+
"jpg",
|
|
7
|
+
"jpeg",
|
|
8
|
+
"png",
|
|
9
|
+
"gif",
|
|
10
|
+
"webp",
|
|
11
|
+
"svg",
|
|
12
|
+
"bmp",
|
|
13
|
+
"ico",
|
|
14
|
+
"tiff",
|
|
15
|
+
"tif",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Renders a file-type icon based on extension. Uses symbols.svg: "unknown" for
|
|
20
|
+
* no extension, "image" for image types, or the extension (e.g. pdf) for others.
|
|
21
|
+
*/
|
|
22
|
+
export function FileTypeIcon({ extension }: { extension: string }) {
|
|
23
|
+
/** Symbol ID in symbols.svg: unknown (no ext), image (image types), or extension (e.g. pdf). */
|
|
24
|
+
const ext = extension.toLowerCase();
|
|
25
|
+
const symbolId = extension === "FILE" ? "unknown" : IMAGE_EXTENSIONS.has(ext) ? "image" : ext;
|
|
26
|
+
return (
|
|
27
|
+
<span
|
|
28
|
+
className="inline-flex h-8 w-8 shrink-0 items-center justify-center"
|
|
29
|
+
aria-hidden
|
|
30
|
+
title={extension}
|
|
31
|
+
>
|
|
32
|
+
<svg className="h-full w-full" aria-hidden>
|
|
33
|
+
<use href={`${symbolsUrl}#${symbolId}`} />
|
|
34
|
+
</svg>
|
|
35
|
+
</span>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
/** Symbol ID in utility.svg (e.g. clear, success, upload) */
|
|
39
|
+
export interface UtilityIconProps {
|
|
40
|
+
id: string;
|
|
41
|
+
size?: "sm" | "md";
|
|
42
|
+
fill?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Renders a utility icon from utility.svg by symbol id. Supports size (sm/md)
|
|
47
|
+
* and fill color. Used for clear, success, upload, etc.
|
|
48
|
+
*/
|
|
49
|
+
export function UtilityIcon({ id, size = "md", fill = "currentColor" }: UtilityIconProps) {
|
|
50
|
+
const sizeClass = size === "sm" ? "h-4 w-4" : "h-6 w-6";
|
|
51
|
+
return (
|
|
52
|
+
<span className={`inline-flex ${sizeClass} shrink-0 items-center justify-center`} aria-hidden>
|
|
53
|
+
<svg className="h-full w-full" fill={fill} aria-hidden>
|
|
54
|
+
<use href={`${utilitySvg}#${id}`} />
|
|
55
|
+
</svg>
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/utils";
|
|
5
|
+
|
|
6
|
+
const alertVariants = cva(
|
|
7
|
+
"grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 w-full relative group/alert",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-card text-card-foreground",
|
|
12
|
+
destructive:
|
|
13
|
+
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: {
|
|
17
|
+
variant: "default",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
function Alert({
|
|
23
|
+
className,
|
|
24
|
+
variant,
|
|
25
|
+
...props
|
|
26
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
data-slot="alert"
|
|
30
|
+
role="alert"
|
|
31
|
+
className={cn(alertVariants({ variant }), className)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
data-slot="alert-title"
|
|
41
|
+
className={cn(
|
|
42
|
+
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
data-slot="alert-description"
|
|
54
|
+
className={cn(
|
|
55
|
+
"text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
64
|
+
return (
|
|
65
|
+
<div data-slot="alert-action" className={cn("absolute top-2 right-2", className)} {...props} />
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { Alert, AlertTitle, AlertDescription, AlertAction };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { Slot } from "radix-ui";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
13
|
+
outline:
|
|
14
|
+
"border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
|
15
|
+
secondary:
|
|
16
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
17
|
+
ghost:
|
|
18
|
+
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
|
19
|
+
destructive:
|
|
20
|
+
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
|
21
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
22
|
+
},
|
|
23
|
+
size: {
|
|
24
|
+
default:
|
|
25
|
+
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
26
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
27
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
28
|
+
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
|
29
|
+
icon: "size-8",
|
|
30
|
+
"icon-xs":
|
|
31
|
+
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
32
|
+
"icon-sm":
|
|
33
|
+
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
34
|
+
"icon-lg": "size-9",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
defaultVariants: {
|
|
38
|
+
variant: "default",
|
|
39
|
+
size: "default",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function Button({
|
|
45
|
+
className,
|
|
46
|
+
variant = "default",
|
|
47
|
+
size = "default",
|
|
48
|
+
asChild = false,
|
|
49
|
+
...props
|
|
50
|
+
}: React.ComponentProps<"button"> &
|
|
51
|
+
VariantProps<typeof buttonVariants> & {
|
|
52
|
+
asChild?: boolean;
|
|
53
|
+
}) {
|
|
54
|
+
const Comp = asChild ? Slot.Root : "button";
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Comp
|
|
58
|
+
data-slot="button"
|
|
59
|
+
data-variant={variant}
|
|
60
|
+
data-size={size}
|
|
61
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
62
|
+
{...(props as any)}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
|
|
5
|
+
function Card({
|
|
6
|
+
className,
|
|
7
|
+
size = "default",
|
|
8
|
+
...props
|
|
9
|
+
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
data-slot="card"
|
|
13
|
+
data-size={size}
|
|
14
|
+
className={cn(
|
|
15
|
+
"ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col",
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
data-slot="card-header"
|
|
27
|
+
className={cn(
|
|
28
|
+
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
data-slot="card-title"
|
|
40
|
+
className={cn(
|
|
41
|
+
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-slot="card-description"
|
|
53
|
+
className={cn("text-muted-foreground text-sm", className)}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
data-slot="card-action"
|
|
63
|
+
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
data-slot="card-content"
|
|
73
|
+
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
data-slot="card-footer"
|
|
83
|
+
className={cn(
|
|
84
|
+
"bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center",
|
|
85
|
+
className,
|
|
86
|
+
)}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|