Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add file upload pattern #4337

Merged
merged 5 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
373 changes: 373 additions & 0 deletions packages/core/stories/patterns/file-upload/file-upload.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,373 @@
import { faker } from "@faker-js/faker";
import {
Button,
Divider,
FileDropZone,
FileDropZoneIcon,
FileDropZoneTrigger,
FlexLayout,
Spinner,
StackLayout,
StatusIndicator,
Text,
Tooltip,
useId,
} from "@salt-ds/core";
import {
DeleteIcon,
PauseIcon,
PlayIcon,
ProgressOnholdIcon,
RefreshIcon,
} from "@salt-ds/icons";
import {
StaticList,
StaticListItem,
StaticListItemContent,
} from "@salt-ds/lab";
import type { Meta } from "@storybook/react";
import { clsx } from "clsx";
import {
Fragment,
type SyntheticEvent,
useCallback,
useEffect,
useState,
} from "react";

export default {
title: "Patterns/File Upload",
} as Meta;

type FileItemStatus =
| "initial"
| "failedValidation"
| "success"
| "uploading"
| "paused"
| "failedConnection";

function getStatusDecoration(status: FileItemStatus) {
switch (status) {
case "failedValidation":
case "failedConnection":
return <StatusIndicator status="error" />;
case "uploading":
return <Spinner size="small" />;
case "success":
return <StatusIndicator status="success" />;
case "paused":
return <ProgressOnholdIcon />;
}
}

function fileSize(bytes: number) {
const sizeFormatter = new Intl.NumberFormat([], {
style: "unit",
unit: "byte",
notation: "compact",
unitDisplay: "narrow",
});
return sizeFormatter.format(bytes);
}

function getDescription({
totalSize,
currentSize = 0,
}: { totalSize: number; currentSize?: number }) {
const downloadCompleted = currentSize === totalSize;
if (totalSize && !downloadCompleted) {
return `${fileSize(currentSize)} of ${fileSize(totalSize)}`;
}
return `${fileSize(currentSize)}`;
}

const updateInterval = 500;

const FileItem = ({
file,
handleDelete,
badConnection,
}: {
file: File;
handleDelete: (file: File) => void;
badConnection?: boolean;
}) => {
const id = useId();
const [status, setStatus] = useState<FileItemStatus>("initial");
const [errorMessage, setErrorMessage] = useState("");
const [currentSize, setCurrentSize] = useState(0);

const validFile = useCallback((file: File) => {
if (file.type !== "application/pdf") {
setErrorMessage("Invalid format. Files must be in .PDF format");
return false;
}

if (file.size > 100_000) {
setErrorMessage("Exceeds file size. 100KB file size limit.");
return false;
}

return true;
}, []);

useEffect(() => {
if (file && validFile(file)) {
setTimeout(() => {
// emulate upload
setStatus("uploading");
}, 700);
} else {
setStatus("failedValidation");
}
}, [file, validFile]);

useEffect(() => {
let timeout: ReturnType<typeof setTimeout>;

if (status === "uploading") {
const update = () => {
setCurrentSize((old) => {
if (old !== file.size) {
timeout = setTimeout(update, updateInterval);

if (badConnection && Math.random() > 0.7) {
setStatus("failedConnection");
setErrorMessage("Connection failed. Please try again.");
return old;
}

return Math.min(old + Math.random() * 1000, file.size);
}

setStatus("success");
return old;
});
};

timeout = setTimeout(update, updateInterval);
}

return () => {
clearTimeout(timeout);
};
}, [status, file.size, badConnection]);

return (
<StaticListItem>
<div
style={{
minHeight: "var(--salt-size-base)",
minWidth: "var(--salt-size-icon)",
display: "flex",
alignItems: "center",
height: "min-content",
}}
>
{getStatusDecoration(status)}
</div>
<StaticListItemContent>
<StackLayout gap={0.5}>
<Text color="inherit" id={`label-${id}`}>
{file.name}
</Text>
<Text
styleAs="label"
color={errorMessage === "" ? "secondary" : "error"}
id={`secondary-label-${id}`}
>
{errorMessage === ""
? getDescription({ totalSize: file.size, currentSize })
: errorMessage}
</Text>
</StackLayout>
</StaticListItemContent>
{status === "failedConnection" && (
<Tooltip content="Retry upload" placement="top">
<Button
id={`retry-button-${id}`}
appearance="transparent"
aria-label="Retry upload"
aria-labelledby={clsx(
`retry-button-${id}`,
`label-${id}`,
`secondary-label-${id}`,
)}
onClick={() => {
setStatus("uploading");
setErrorMessage("");
}}
>
<RefreshIcon aria-hidden />
</Button>
</Tooltip>
)}
{status === "paused" && (
<Tooltip content="Resume upload" placement="top">
<Button
id={`resume-button-${id}`}
appearance="transparent"
aria-label="Resume upload"
aria-labelledby={clsx(
`resume-button-${id}`,
`label-${id}`,
`secondary-label-${id}`,
)}
onClick={() => {
setStatus("uploading");
}}
>
<PlayIcon aria-hidden />
</Button>
</Tooltip>
)}
{status === "uploading" && (
<Tooltip content="Pause upload" placement="top">
<Button
id={`pause-button-${id}`}
appearance="transparent"
aria-label="Pause upload"
aria-labelledby={clsx(
`pause-button-${id}`,
`label-${id}`,
`secondary-label-${id}`,
)}
onClick={() => {
setStatus("paused");
}}
>
<PauseIcon aria-hidden />
</Button>
</Tooltip>
)}
{status !== "uploading" && (
<Tooltip content="Remove" placement="top">
<Button
id={`remove-button-${id}`}
appearance="transparent"
aria-label="Remove"
aria-labelledby={clsx(
`remove-button-${id}`,
`label-${id}`,
`secondary-label-${id}`,
)}
onClick={() => handleDelete(file)}
>
<DeleteIcon aria-hidden />
</Button>
</Tooltip>
)}
</StaticListItem>
);
};

export const FileUploadExample = () => {
const [files, setFiles] = useState<{ file: File; badConnection?: boolean }[]>(
[],
);

const handleFiles = (_: SyntheticEvent, files: File[]) => {
setFiles((old) =>
old.concat(
files.map((file) => ({
file,
})),
),
);
};

const handleDelete = (fileToRemove: File) => {
setFiles((old) => old.filter((file) => file.file !== fileToRemove));
};

return (
<StackLayout style={{ height: "80vh" }}>
<FlexLayout direction="row" wrap>
<Button
onClick={() => {
const file = new File(
[new ArrayBuffer(Math.random() * 12000)],
faker.system.commonFileName("pdf"),
{
type: "application/pdf",
},
);
setFiles((old) => old.concat({ file }));
}}
>
Add PDF that will succeed
</Button>
<Button
onClick={() => {
const file = new File(
[new ArrayBuffer(Math.random() * 12000)],
faker.system.commonFileName("pdf"),
{
type: "application/pdf",
},
);
setFiles((old) => old.concat({ file, badConnection: true }));
}}
>
Simulate bad connection
</Button>
<Button
onClick={() => {
const file = new File(
[new ArrayBuffer(200000)],
faker.system.commonFileName("pdf"),
{
type: "application/pdf",
},
);
setFiles((old) => old.concat({ file }));
}}
>
Add large PDF
</Button>
<Button
onClick={() => {
const file = new File(
[new ArrayBuffer(10000)],
faker.system.commonFileName("png"),
{
type: "image/png",
},
);
setFiles((old) => old.concat({ file }));
}}
>
Add image
</Button>
</FlexLayout>
<StackLayout gap={1}>
<Text styleAs="h2">Upload files</Text>
<Text>
Please drag and drop file(s) in the below area; or browse files by
using the button.
</Text>
</StackLayout>
<FileDropZone onDrop={handleFiles}>
<FileDropZoneIcon />
<strong>Drop files here or</strong>
<FileDropZoneTrigger accept=".pdf" onChange={handleFiles} />
<Text>Files must be in .PDF format. 100KB file size limit.</Text>
</FileDropZone>
<StaticList style={{ width: "100%" }}>
{files.map(({ file, badConnection }, index) => (
<Fragment key={file.name + file.lastModified}>
<FileItem
file={file}
handleDelete={handleDelete}
badConnection={badConnection}
/>
{index < files.length - 1 && (
<Divider variant="tertiary" aria-hidden />
)}
</Fragment>
))}
</StaticList>
</StackLayout>
);
};
2 changes: 1 addition & 1 deletion site/docs/patterns/contact-details.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ We recommend emails to be interactive links by default, you can also define othe

<Diagram
src="/img/patterns/contact-details/contact-details-using-links.png"
alt="Contact Details using Icon Descriptors"
alt="Contact Details using links"
caption="An example of contact details using links."
border
/>
Expand Down
Loading
Loading