diff --git a/playwright/components/file-input/index.ts b/playwright/components/file-input/index.ts
new file mode 100644
index 0000000000..ae403a8b97
--- /dev/null
+++ b/playwright/components/file-input/index.ts
@@ -0,0 +1,18 @@
+import { Page } from "playwright-core";
+import { LABEL, FILE_INPUT } from "./locators";
+
+export const hiddenInput = (page: Page, label: string) => {
+ return page.getByLabel(label);
+};
+
+export const selectFileButton = (page: Page, buttonText = "Select file") => {
+ return page.getByRole("button", { name: buttonText });
+};
+
+export const label = (page: Page) => {
+ return page.locator(LABEL);
+};
+
+export const fileInput = (page: Page) => {
+ return page.locator(FILE_INPUT);
+};
diff --git a/playwright/components/file-input/locators.ts b/playwright/components/file-input/locators.ts
new file mode 100644
index 0000000000..6af312cd23
--- /dev/null
+++ b/playwright/components/file-input/locators.ts
@@ -0,0 +1,2 @@
+export const LABEL = "label";
+export const FILE_INPUT = '[data-component="file-input"]';
diff --git a/src/__internal__/utils/helpers/tags/tags-specs/tags-specs.ts b/src/__internal__/utils/helpers/tags/tags-specs/tags-specs.ts
index a85998d6a2..33ee4c323f 100644
--- a/src/__internal__/utils/helpers/tags/tags-specs/tags-specs.ts
+++ b/src/__internal__/utils/helpers/tags/tags-specs/tags-specs.ts
@@ -22,5 +22,16 @@ const rootTagTest = (
expect(rootNode.prop("data-role")).toEqual(role);
};
+const rootTagTestRtl = (
+ rootNode: HTMLElement,
+ comp: string,
+ elem?: string,
+ role?: string
+) => {
+ expect(rootNode).toHaveAttribute("data-component", comp);
+ expect(rootNode).toHaveAttribute("data-element", elem);
+ expect(rootNode).toHaveAttribute("data-role", role);
+};
+
// eslint-disable-next-line jest/no-export
-export { elementsTagTest, rootTagTest };
+export { elementsTagTest, rootTagTest, rootTagTestRtl };
diff --git a/src/components/file-input/__internal__/file-upload-status/file-upload-status.component.tsx b/src/components/file-input/__internal__/file-upload-status/file-upload-status.component.tsx
new file mode 100644
index 0000000000..b3716b0913
--- /dev/null
+++ b/src/components/file-input/__internal__/file-upload-status/file-upload-status.component.tsx
@@ -0,0 +1,132 @@
+import React from "react";
+import Link, { LinkProps } from "../../../link";
+import ButtonMinor from "../../../button-minor";
+import Typography from "../../../typography";
+import ProgressTracker from "../../../progress-tracker";
+import LoaderBar from "../../../loader-bar";
+import Icon, { IconType } from "../../../icon";
+import {
+ StyledFileUploadStatus,
+ StyledFileUploadStatusRow,
+ StyledFileLinkContainer,
+} from "./file-upload-status.style";
+import useLocale from "../../../../hooks/__internal__/useLocale";
+
+interface StatusUploadingProps {
+ /** the status of the upload */
+ status: "uploading";
+ /** a number from 0-100 giving the current upload progress as a percentage. Only used for the `uploading` status.
+ * If the progress prop is not specified in the `uploading` status, a loading animation will be shown instead
+ * (or text equivalent for users with a reduced-motion operating system preference).
+ */
+ progress?: number;
+}
+
+interface StatusDoneProps extends LinkProps {
+ /** the status of the upload */
+ status: "completed" | "previously";
+ /** the URL opened by the file link. Must be provided for only the `completed` and `previously` statuses. */
+ href: string;
+}
+
+interface StatusErrorProps {
+ /** the status of the upload */
+ status: "error";
+}
+
+interface MandatoryStatusProps {
+ /** the name of the file */
+ filename: string;
+ /** a function to be executed when the user clicks the appropriate action button (Clear/Delete File/Cancel Upload) */
+ onAction: () => void;
+ /** The status message. Used to display the current upload progress, including error messages where appropriate. Not used for the `previously` status. */
+ message?: string;
+ /** The icon to use for the file during or after upload */
+ iconType?: IconType;
+}
+
+export type FileUploadStatusProps = MandatoryStatusProps &
+ (StatusUploadingProps | StatusErrorProps | StatusDoneProps);
+
+export const FileUploadStatus = ({
+ status,
+ filename,
+ message,
+ onAction,
+ iconType = "file_generic",
+ ...statusProps
+}: FileUploadStatusProps) => {
+ const locale = useLocale();
+ const statusMessage = message || locale.fileInput.fileUploadStatus();
+
+ let buttonText;
+ let linkProps;
+ let progressBar = null;
+ switch (status) {
+ case "uploading":
+ buttonText = locale.fileInput.actions.cancel();
+ progressBar =
+ (statusProps as StatusUploadingProps).progress === undefined ? (
+
+ ) : (
+
+ );
+ break;
+ case "previously":
+ case "completed":
+ buttonText = locale.fileInput.actions.delete();
+ linkProps = { ...statusProps, icon: iconType };
+ break;
+ case "error":
+ buttonText = locale.fileInput.actions.clear();
+ break;
+ // istanbul ignore next
+ default:
+ // no other cases if consumers are using TS, but ESLint still insists on it
+ break;
+ }
+ const actionButton = (
+
+ {buttonText}
+
+ );
+ const fileLink = linkProps ? (
+ {filename}
+ ) : (
+ <>
+
+ {filename}
+ >
+ );
+ const mainRow =
+ status !== "previously" ? (
+
+ {statusMessage}
+ {actionButton}
+
+ ) : (
+
+ {fileLink}
+ {actionButton}
+
+ );
+ const secondRow =
+ status !== "previously" ? (
+
+ {fileLink}
+
+ ) : null;
+ return (
+
+ {mainRow}
+ {secondRow}
+ {progressBar}
+
+ );
+};
+
+export default FileUploadStatus;
diff --git a/src/components/file-input/__internal__/file-upload-status/file-upload-status.spec.tsx b/src/components/file-input/__internal__/file-upload-status/file-upload-status.spec.tsx
new file mode 100644
index 0000000000..0dda575aae
--- /dev/null
+++ b/src/components/file-input/__internal__/file-upload-status/file-upload-status.spec.tsx
@@ -0,0 +1,280 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import FileUploadStatus from ".";
+
+describe("in uploading state", () => {
+ it("renders the provided status message", () => {
+ render(
+ {}}
+ progress={30}
+ message="my status message"
+ />
+ );
+ expect(screen.queryByText("my status message")).toBeInTheDocument();
+ });
+
+ it("renders the default status message if none is provided", () => {
+ render(
+ {}}
+ progress={30}
+ />
+ );
+ expect(screen.queryByText("File upload status")).toBeInTheDocument();
+ });
+
+ it("renders a button with the cancel text, which performs the onAction function prop on click", async () => {
+ const onAction = jest.fn();
+ render(
+
+ );
+ const actionButton = screen.getByRole("button", {
+ name: "Cancel upload",
+ });
+ expect(actionButton).toBeInTheDocument();
+ await userEvent.click(actionButton);
+ expect(onAction).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the file name, but not as a link", () => {
+ render(
+ {}}
+ progress={30}
+ />
+ );
+ expect(screen.queryByText("foo.pdf")).toBeInTheDocument();
+ expect(
+ screen.queryByRole("link", { name: "foo.pdf" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders a progress bar with progress matching the progress prop", () => {
+ render(
+ {}}
+ progress={30}
+ />
+ );
+ const progressBar = screen.queryByRole("progressbar");
+ expect(progressBar).toBeInTheDocument();
+ expect(progressBar).toHaveAttribute("aria-valuenow", "30");
+ });
+
+ it("renders a loader bar if the progress prop is not provided", () => {
+ render(
+ {}}
+ />
+ );
+ const progressBar = screen.queryByRole("progressbar");
+ expect(progressBar).toBeInTheDocument();
+ expect(progressBar).not.toHaveAttribute("aria-valuenow");
+ });
+});
+
+describe("in completed state", () => {
+ it("renders the provided status message", () => {
+ render(
+ {}}
+ message="my status message"
+ />
+ );
+ expect(screen.queryByText("my status message")).toBeInTheDocument();
+ });
+
+ it("renders the default status message if none is provided", () => {
+ render(
+ {}}
+ />
+ );
+ expect(screen.queryByText("File upload status")).toBeInTheDocument();
+ });
+
+ it("renders a button with the delete text, which performs the onAction function prop on click", async () => {
+ const onAction = jest.fn();
+ render(
+
+ );
+ const actionButton = screen.getByRole("button", { name: "Delete file" });
+ expect(actionButton).toBeInTheDocument();
+ await userEvent.click(actionButton);
+ expect(onAction).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the file name as a link with the provided props", () => {
+ render(
+ {}}
+ />
+ );
+ const link = screen.queryByRole("link", { name: "foo.pdf" });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute("href", "http://carbon.sage.com");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noreferrer");
+ });
+
+ it("does not render a progress bar", () => {
+ render(
+ {}}
+ />
+ );
+ expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
+ });
+});
+
+describe("in previously state", () => {
+ it("does not render a status message", () => {
+ render(
+ {}}
+ message="my status message"
+ />
+ );
+ expect(screen.queryByText("my status message")).not.toBeInTheDocument();
+ });
+
+ it("renders the file name as a link with the provided props", () => {
+ render(
+ {}}
+ />
+ );
+ const link = screen.queryByRole("link", { name: "foo.pdf" });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute("href", "http://carbon.sage.com");
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noreferrer");
+ });
+
+ it("renders a button with the delete text, which performs the onAction function prop on click", async () => {
+ const onAction = jest.fn();
+ render(
+
+ );
+ const actionButton = screen.getByRole("button", { name: "Delete file" });
+ expect(actionButton).toBeInTheDocument();
+ await userEvent.click(actionButton);
+ expect(onAction).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not render a progress bar", () => {
+ render(
+ {}}
+ />
+ );
+ expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
+ });
+});
+
+describe("in error state", () => {
+ it("renders the provided status message", () => {
+ render(
+ {}}
+ message="my status message"
+ />
+ );
+ expect(screen.queryByText("my status message")).toBeInTheDocument();
+ });
+
+ it("renders the default status message if none is provided", () => {
+ render(
+ {}} />
+ );
+ expect(screen.queryByText("File upload status")).toBeInTheDocument();
+ });
+
+ it("renders a button with the clear text, which performs the onAction function prop on click", async () => {
+ const onAction = jest.fn();
+ render(
+
+ );
+ const actionButton = screen.getByRole("button", { name: "Clear" });
+ expect(actionButton).toBeInTheDocument();
+ await userEvent.click(actionButton);
+ expect(onAction).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the file name, but not as a link", () => {
+ render(
+ {}} />
+ );
+ expect(screen.queryByText("foo.pdf")).toBeInTheDocument();
+ expect(
+ screen.queryByRole("link", { name: "foo.pdf" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not render a progress bar", () => {
+ render(
+ {}} />
+ );
+ expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/file-input/__internal__/file-upload-status/file-upload-status.style.tsx b/src/components/file-input/__internal__/file-upload-status/file-upload-status.style.tsx
new file mode 100644
index 0000000000..30a2ca91f1
--- /dev/null
+++ b/src/components/file-input/__internal__/file-upload-status/file-upload-status.style.tsx
@@ -0,0 +1,82 @@
+import styled, { css } from "styled-components";
+import StyledTypography from "../../../typography/typography.style";
+import StyledIcon from "../../../icon/icon.style";
+import {
+ StyledProgressBar,
+ InnerBar,
+} from "../../../progress-tracker/progress-tracker.style";
+import { StyledLoader } from "../../../loader-bar/loader-bar.style";
+import { StyledLink } from "../../../link/link.style";
+
+export const StyledFileLinkContainer = styled.div`
+ color: var(--colorsActionMajorYin090);
+ display: flex;
+ align-items: center;
+
+ &&& ${StyledIcon} {
+ display: inline-flex;
+ justify-content: center;
+ align-items: flex-start;
+ width: 24px;
+ height: 24px;
+
+ // only apply these styles when the icon is not part of a Link component
+ :not(${StyledLink} ${StyledIcon}) {
+ color: var(--colorsUtilityYin065);
+ padding-right: var(--spacing100);
+ }
+ }
+`;
+
+interface StyledFileUploadStatusRowProps {
+ upperPadding?: boolean;
+ lowerPadding?: boolean;
+}
+
+export const StyledFileUploadStatusRow = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ padding-left: var(--spacing150);
+ ${({ upperPadding }) =>
+ upperPadding ? "padding-top: var(--spacing050);" : ""}
+ ${({ lowerPadding }) =>
+ lowerPadding ? "padding-bottom: var(--spacing125);" : ""}
+
+ ${StyledTypography} {
+ color: var(--colorsUtilityYin055);
+ }
+`;
+
+interface StyledFileUploadStatusProps {
+ hasError: boolean;
+}
+
+export const StyledFileUploadStatus = styled.div`
+ background-color: var(--colorsUtilityYang100);
+ ${({ hasError }) => {
+ const borderWidthToken = hasError ? "borderWidth200" : "borderWidth100";
+ const colorToken = hasError
+ ? "colorsSemanticNegative500"
+ : "colorsUtilityMajor300";
+ return css`
+ border: var(--${borderWidthToken}) solid var(--${colorToken});
+ ${hasError &&
+ `&& ${StyledTypography} {
+ color: var(--${colorToken});
+ font-weight: 500;
+ }`}
+ `;
+ }}
+ border-radius: var(--borderRadius050);
+ width: 100%;
+
+ ${StyledProgressBar}, ${InnerBar} {
+ border-radius: var(--borderRadius050);
+ border: none;
+ }
+
+ ${StyledLoader} {
+ display: flex;
+ }
+`;
diff --git a/src/components/file-input/__internal__/file-upload-status/index.ts b/src/components/file-input/__internal__/file-upload-status/index.ts
new file mode 100644
index 0000000000..d2dea61a17
--- /dev/null
+++ b/src/components/file-input/__internal__/file-upload-status/index.ts
@@ -0,0 +1,2 @@
+export { default } from "./file-upload-status.component";
+export type { FileUploadStatusProps } from "./file-upload-status.component";
diff --git a/src/components/file-input/components.test-pw.tsx b/src/components/file-input/components.test-pw.tsx
new file mode 100644
index 0000000000..beea0e2348
--- /dev/null
+++ b/src/components/file-input/components.test-pw.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import FileInput, { FileInputProps } from ".";
+
+export default (props: Partial) => {
+ return {}} {...props} />;
+};
diff --git a/src/components/file-input/file-input-test.stories.tsx b/src/components/file-input/file-input-test.stories.tsx
new file mode 100644
index 0000000000..4c08feaaa0
--- /dev/null
+++ b/src/components/file-input/file-input-test.stories.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import FileInput, { FileInputProps } from ".";
+
+export default {
+ component: FileInput,
+ title: "File Input/Test",
+ includeStories: ["AllStatuses"],
+ parameters: {
+ info: { disable: true },
+ chromatic: {
+ disableSnapshot: false,
+ },
+ },
+};
+
+export const AllStatuses = () => {
+ const statuses: FileInputProps["uploadStatus"][] = [
+ undefined,
+ {
+ status: "uploading",
+ filename: "foo.pdf",
+ onAction: () => {},
+ progress: 30,
+ },
+ {
+ status: "uploading",
+ filename: "foo.pdf",
+ onAction: () => {},
+ progress: 100,
+ },
+ {
+ status: "uploading",
+ filename: "foo.pdf",
+ onAction: () => {},
+ },
+ {
+ status: "uploading",
+ filename: "foo.pdf",
+ onAction: () => {},
+ iconType: "upload",
+ progress: 30,
+ },
+ {
+ status: "completed",
+ filename: "foo.pdf",
+ onAction: () => {},
+ href: "http://carbon.sage.com/",
+ target: "_blank",
+ },
+ {
+ status: "completed",
+ filename: "foo.pdf",
+ onAction: () => {},
+ href: "http://carbon.sage.com/",
+ target: "_blank",
+ iconType: "pdf",
+ },
+ {
+ status: "previously",
+ filename: "foo.pdf",
+ onAction: () => {},
+ href: "http://carbon.sage.com/",
+ },
+ {
+ status: "error",
+ filename: "foo.pdf",
+ onAction: () => {},
+ message: "oops, that's not right",
+ },
+ ];
+ return statuses.map((status) => (
+ {}} />
+ ));
+};
diff --git a/src/components/file-input/file-input.component.tsx b/src/components/file-input/file-input.component.tsx
new file mode 100644
index 0000000000..d9dde01b16
--- /dev/null
+++ b/src/components/file-input/file-input.component.tsx
@@ -0,0 +1,254 @@
+import React, { useRef, useState, useEffect } from "react";
+import { MarginProps } from "styled-system";
+import { filterStyledSystemMarginProps } from "../../style/utils";
+import { ValidationProps } from "../../__internal__/validations";
+import { InputProps } from "../../__internal__/input";
+import { InputBehaviour } from "../../__internal__/input-behaviour";
+import FormField from "../../__internal__/form-field";
+import { TagProps } from "../../__internal__/utils/helpers/tags";
+import useUniqueId from "../../hooks/__internal__/useUniqueId";
+import useInputAccessibility from "../../hooks/__internal__/useInputAccessibility/useInputAccessibility";
+import ValidationMessage from "../../__internal__/validation-message";
+import {
+ StyledHiddenFileInput,
+ StyledFileInputPresentation,
+} from "./file-input.style";
+import { StyledHintText, ErrorBorder } from "../textbox/textbox.style";
+import ButtonMinor from "../button-minor";
+import Typography from "../typography";
+import FileUploadStatus, {
+ FileUploadStatusProps,
+} from "./__internal__/file-upload-status";
+import Box from "../box";
+import useLocale from "../../hooks/__internal__/useLocale";
+
+export interface FileInputProps
+ extends Pick,
+ Pick,
+ TagProps,
+ MarginProps {
+ /** Which file format(s) to accept. Will be passed to the underlying HTML input.
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept */
+ accept?: string;
+ /** Text to appear on the main button. Defaults to "Select file" */
+ buttonText?: string;
+ /** Explanatory text to appear inside the input area. Defaults to "Drag and drop your file" */
+ dragAndDropText?: string;
+ /** A hint string rendered before the input but after the label. Intended to describe the purpose or content of the input. */
+ inputHint?: React.ReactNode;
+ /** Sets the default layout to vertical - with the button below the explanatory text rather than next to it.
+ * This is the equivalent of removing the maxHeight prop - it will be over-ridden if this prop is set explicitly. */
+ isVertical?: boolean;
+ /** Label content */
+ label?: string;
+ /** A valid CSS string for the max-height CSS property. Defaults to 40px. */
+ maxHeight?: string;
+ /** A valid CSS string for the max-width CSS property. Defaults to 273px. */
+ maxWidth?: string;
+ /** A valid CSS string for the min-height CSS property. Defaults to the same as the maxHeight. */
+ minHeight?: string;
+ /** A valid CSS string for the min-width CSS property. Defaults to the same as the maxWidth. */
+ minWidth?: string;
+ /** onChange event handler. Accepts a list of all files currently entered to the input. */
+ onChange: (files: FileList) => void;
+ /** used to control how to display the progress of uploaded file(s) within the component */
+ uploadStatus?: FileUploadStatusProps | FileUploadStatusProps[];
+}
+
+export const FileInput = React.forwardRef(
+ (
+ {
+ accept,
+ buttonText,
+ "data-element": dataElement,
+ "data-role": dataRole,
+ disabled,
+ dragAndDropText,
+ error,
+ label,
+ id,
+ inputHint,
+ isVertical,
+ maxHeight,
+ maxWidth,
+ minHeight = "40px",
+ minWidth = "273px",
+ name,
+ onChange,
+ required,
+ uploadStatus = [],
+ ...rest
+ }: FileInputProps,
+ ref: React.ForwardedRef
+ ) => {
+ const locale = useLocale();
+ const textOnButton = buttonText || locale.fileInput.selectFile();
+ const mainText = dragAndDropText || locale.fileInput.dragAndDrop();
+
+ const sizeProps = {
+ maxHeight: maxHeight || (isVertical ? undefined : minHeight),
+ maxWidth: maxWidth || minWidth,
+ minHeight,
+ minWidth,
+ };
+
+ const [uniqueId, uniqueName] = useUniqueId(id, name);
+ const [isDraggedOver, setIsDraggedOver] = useState(false);
+ const [isDraggingFile, setIsDraggingFile] = useState(false);
+
+ const internalInputRef = useRef(null);
+ const internalCallbackRef = (fileInput: HTMLInputElement | null) => {
+ internalInputRef.current = fileInput;
+ if (typeof ref === "function") {
+ ref(fileInput);
+ } else if (ref) {
+ ref.current = fileInput;
+ }
+ };
+
+ const startDrag = (e: DragEvent) => {
+ e.preventDefault();
+ if (e.dataTransfer?.types.includes("Files")) {
+ setIsDraggingFile(true);
+ }
+ };
+
+ const stopDrag = (e: DragEvent) => {
+ e.preventDefault();
+ setIsDraggingFile(false);
+ };
+
+ useEffect(() => {
+ document.addEventListener("dragover", startDrag);
+ document.addEventListener("drop", stopDrag);
+ document.addEventListener("dragleave", stopDrag);
+
+ return () => {
+ document.removeEventListener("dragover", startDrag);
+ document.removeEventListener("drop", stopDrag);
+ document.removeEventListener("dragleave", stopDrag);
+ };
+ }, []);
+
+ const onSelectFileClick = () => {
+ internalInputRef.current?.click();
+ };
+
+ const onFileAdded = (files: FileList) => {
+ onChange?.(files);
+ };
+
+ const onDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ if (!disabled) {
+ if (e.dataTransfer?.types.includes("Files")) {
+ setIsDraggedOver(true);
+ }
+ }
+ };
+
+ const onDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation(); // stop event triggering the document listener that resets the styles
+ setIsDraggedOver(false);
+ };
+
+ const onDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ if (!disabled) {
+ setIsDraggedOver(false);
+ onFileAdded(e.dataTransfer.files);
+ }
+ };
+
+ const onInputChange = (e: React.ChangeEvent) => {
+ onFileAdded(e.target.files as FileList);
+ };
+
+ const { labelId, validationId } = useInputAccessibility({
+ id: uniqueId,
+ validationRedesignOptIn: true,
+ error,
+ label,
+ });
+
+ // allow for single input that a single set of status props is provided
+ const filesUploaded = Array.isArray(uploadStatus)
+ ? uploadStatus
+ : [uploadStatus];
+
+ const input = (
+ <>
+ {inputHint && {inputHint}}
+
+
+ {error && }
+
+
+ {mainText}
+
+ {textOnButton}
+
+
+
+ >
+ );
+
+ return (
+
+
+ {filesUploaded.length === 0
+ ? input
+ : filesUploaded.map((props) => (
+
+
+
+ ))}
+
+
+ );
+ }
+);
+
+FileInput.displayName = "FileInput";
+
+export default FileInput;
diff --git a/src/components/file-input/file-input.pw.tsx b/src/components/file-input/file-input.pw.tsx
new file mode 100644
index 0000000000..a8350c3301
--- /dev/null
+++ b/src/components/file-input/file-input.pw.tsx
@@ -0,0 +1,831 @@
+import React from "react";
+import { Page } from "@playwright/test";
+import { test, expect } from "@playwright/experimental-ct-react17";
+import path from "path";
+import { readFileSync } from "fs";
+import FileInputComponent from "./components.test-pw";
+import {
+ hiddenInput,
+ selectFileButton,
+ label,
+ fileInput,
+} from "../../../playwright/components/file-input";
+import {
+ checkAccessibility,
+ verifyRequiredAsteriskForLabel,
+} from "../../../playwright/support/helper";
+import { CHARACTERS } from "../../../playwright/support/constants";
+import { FileUploadStatusProps } from ".";
+
+declare global {
+ interface File {
+ toJSON: () => object;
+ }
+}
+
+// util needed for testing the file argument passed to onChange - browser File objects natively JSON serialise only to
+// the empty object.
+// Playwright uses JSON serialisation to send data between the browser and Node, but File objects by default all stringify
+// as empty objects. Therefore need to override this for the test to work properly
+const enableFileJSON = (page: Page) => {
+ return page.evaluate(() => {
+ File.prototype.toJSON = function () {
+ return { name: this.name, type: this.type };
+ };
+ });
+};
+
+// adapted from https://github.com/microsoft/playwright/issues/13364#issuecomment-1156288428
+const dragFile = async ({
+ page,
+ eventName,
+ selector,
+ filePath,
+ fileName,
+ fileType = "",
+}: {
+ page: Page;
+ eventName: string;
+ selector: string;
+ filePath: string;
+ fileName: string;
+ fileType?: string;
+}) => {
+ const buffer = readFileSync(filePath).toString("base64");
+
+ const dataTransfer = await page.evaluateHandle(
+ async ({ bufferData, localFileName, localFileType }) => {
+ const dataTransferObject = new DataTransfer();
+
+ const blobData = await fetch(bufferData).then((res) => res.blob());
+
+ const file = new File([blobData], localFileName, { type: localFileType });
+ dataTransferObject.items.add(file);
+ return dataTransferObject;
+ },
+ {
+ bufferData: `data:application/octet-stream;base64,${buffer}`,
+ localFileName: fileName,
+ localFileType: fileType,
+ }
+ );
+
+ await page.dispatchEvent(selector, eventName, { dataTransfer });
+};
+
+const specialCharacters = [CHARACTERS.DIACRITICS, CHARACTERS.SPECIALCHARACTERS];
+
+const uploadingStatusProps: FileUploadStatusProps = {
+ status: "uploading",
+ filename: "foo.pdf",
+ onAction: () => {},
+ progress: 30,
+ message: "my status message",
+};
+const completedStatusProps: FileUploadStatusProps = {
+ status: "completed",
+ filename: "foo.pdf",
+ onAction: () => {},
+ href: "http://carbon.sage.com",
+ target: "_blank",
+ rel: "noreferrer",
+ message: "my status message",
+};
+const previouslyStatusProps: FileUploadStatusProps = {
+ status: "previously",
+ filename: "foo.pdf",
+ onAction: () => {},
+ href: "http://carbon.sage.com",
+ target: "_blank",
+ rel: "noreferrer",
+ message: "my status message",
+};
+const errorStatusProps: FileUploadStatusProps = {
+ status: "error",
+ filename: "foo.pdf",
+ onAction: () => {},
+ message: "my status message",
+};
+
+const uploadStatuses = [
+ uploadingStatusProps,
+ completedStatusProps,
+ previouslyStatusProps,
+ errorStatusProps,
+];
+
+test.describe("FileInput component", () => {
+ test("should render a hidden file input and visible button", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(hiddenInput(page, "File input")).toBeHidden();
+ await expect(selectFileButton(page)).toBeVisible();
+ });
+
+ [
+ "audio/*",
+ "video/*",
+ "image/*",
+ ".pdf",
+ ".jpg",
+ ".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ].forEach((accept) => {
+ test(`should render with accept prop '${accept}'`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ const input = await hiddenInput(page, "File input");
+ await expect(input).toHaveAttribute("accept", accept);
+ });
+ });
+
+ specialCharacters.forEach((testVal) => {
+ test(`should render with buttonText '${testVal}'`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ const buttonElement = await selectFileButton(page, testVal);
+ await expect(buttonElement).toBeVisible();
+ });
+ });
+
+ specialCharacters.forEach((testVal) => {
+ test(`should render with dragAndDropText '${testVal}'`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(page.getByText(testVal)).toBeVisible();
+ });
+ });
+
+ specialCharacters.forEach((testVal) => {
+ test(`should render with visible label '${testVal}'`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ const input = await hiddenInput(page, testVal);
+ await expect(input).toHaveCount(1);
+ const labelElement = await label(page);
+ await expect(labelElement).toHaveText(testVal);
+ await expect(labelElement).toBeVisible();
+ });
+ });
+
+ specialCharacters.forEach((testVal) => {
+ test(`should render with visible inputHint '${testVal}'`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ const inputHintElement = await page.getByText(testVal);
+ await expect(inputHintElement).toBeVisible();
+ });
+ });
+
+ test("should render with boolean error prop", async ({ mount, page }) => {
+ await mount();
+ const borderColor = await page
+ .getByText("Drag and drop your file")
+ .evaluate((el) =>
+ window
+ .getComputedStyle(el.parentElement as HTMLElement)
+ .getPropertyValue("border-color")
+ );
+ // TODO: should check token value (--colorsSemanticNegative500), rewrite this when we have the equivalent playwright util merged in
+ await expect(borderColor).toBe("rgb(203, 55, 74)");
+ });
+
+ test("should render with string error prop", async ({ mount, page }) => {
+ await mount();
+ const borderColor = await page
+ .getByText("Drag and drop your file")
+ .evaluate((el) =>
+ window
+ .getComputedStyle(el.parentElement as HTMLElement)
+ .getPropertyValue("border-color")
+ );
+ // TODO: should check token value (--colorsSemanticNegative500), rewrite this when we have the equivalent playwright util merged in
+ await expect(borderColor).toBe("rgb(203, 55, 74)");
+ const errorMessage = await page.getByText("error text");
+ await expect(errorMessage).toBeVisible();
+ await expect(errorMessage).toHaveCSS("color", "rgb(203, 55, 74)");
+ });
+
+ test("should render with disabled prop", async ({ mount, page }) => {
+ await mount();
+ await expect(selectFileButton(page)).toBeDisabled();
+ const inputBackground = await page
+ .getByText("Drag and drop your file")
+ .evaluate((el) =>
+ window
+ .getComputedStyle(el.parentElement as HTMLElement)
+ .getPropertyValue("background-color")
+ );
+ // actually a token value, --colorsUtilityDisabled400
+ await expect(inputBackground).toBe("rgb(242, 245, 246)");
+ const inputBorder = await page
+ .getByText("Drag and drop your file")
+ .evaluate((el) =>
+ window
+ .getComputedStyle(el.parentElement as HTMLElement)
+ .getPropertyValue("border-color")
+ );
+ // actually a token value, --colorsUtilityDisabled600
+ await expect(inputBorder).toBe("rgb(204, 214, 219)");
+ // actually a token value, --colorsUtilityYin030
+ await expect(label(page)).toHaveCSS("color", "rgba(0, 0, 0, 0.3)");
+ });
+
+ specialCharacters.forEach((testVal) => {
+ test(`should render with id '${testVal}'`, async ({ mount, page }) => {
+ await mount();
+ const input = await hiddenInput(page, "File input");
+ await expect(input).toHaveAttribute("id", testVal);
+ });
+ });
+
+ specialCharacters.forEach((testVal) => {
+ test(`should render with name prop '${testVal}'`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(hiddenInput(page, "File input")).toHaveAttribute(
+ "name",
+ testVal
+ );
+ });
+ });
+
+ test("should render with required prop", async ({ mount, page }) => {
+ await mount();
+ await verifyRequiredAsteriskForLabel(page);
+ });
+
+ specialCharacters.forEach((testVal) => {
+ test(`should render with data-element prop '${testVal}'`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(fileInput(page)).toHaveAttribute("data-element", testVal);
+ });
+ });
+
+ specialCharacters.forEach((testVal) => {
+ test(`should render with data-role prop '${testVal}'`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(fileInput(page)).toHaveAttribute("data-role", testVal);
+ });
+ });
+});
+
+test.describe("with uploadStatus prop", () => {
+ uploadStatuses.forEach((statusProps) => {
+ test(`with status ${statusProps.status}, no hidden input or button is rendered`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(hiddenInput(page, "File input")).toHaveCount(0);
+ await expect(selectFileButton(page)).toHaveCount(0);
+ });
+ });
+
+ uploadStatuses.forEach((statusProps) => {
+ test(`with status ${statusProps.status}, an icon of the correct type is rendered`, async ({
+ mount,
+ page,
+ }) => {
+ await mount(
+
+ );
+ const icon = await page.getByTestId("icon");
+ await expect(icon).toHaveAttribute("data-element", "pdf");
+ });
+ });
+
+ uploadStatuses.forEach((statusProps) => {
+ test(`with status ${statusProps.status}, a file_generic icon is rendered if iconType is not specified`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ const icon = await page.getByTestId("icon");
+ await expect(icon).toHaveAttribute("data-element", "file_generic");
+ });
+ });
+
+ test("in the uploading state, it renders a status message", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(page.getByText("my status message")).toBeVisible();
+ });
+
+ test("in the uploading state, it renders a cancel button", async ({
+ mount,
+ page,
+ }) => {
+ let clickCount = 0;
+ const onAction = () => {
+ clickCount += 1;
+ };
+ await mount(
+
+ );
+ const actionButton = await page.getByRole("button", {
+ name: "Cancel upload",
+ });
+ await expect(actionButton).toBeVisible();
+ await actionButton.click();
+ await expect(clickCount).toBe(1);
+ });
+
+ test("in the uploading state, it renders the file name, but not as a link", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(page.getByText("foo.pdf")).toBeVisible();
+ await expect(page.getByRole("link", { name: "foo.pdf" })).toHaveCount(0);
+ });
+
+ test("in the uploading state, it renders a progress bar with progress matching the progress prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ const progressBar = await page.getByRole("progressbar");
+ await expect(progressBar).toBeVisible();
+ await expect(progressBar).toHaveAttribute("aria-valuenow", "30");
+ });
+
+ test("in the uploading state with no progress, it renders a loader par with no aria-valuenow", async ({
+ mount,
+ page,
+ }) => {
+ await mount(
+
+ );
+ const loaderBar = await page.getByRole("progressbar");
+ await expect(loaderBar).toBeVisible();
+ await expect(await loaderBar.getAttribute("aria-valuenow")).toBeNull();
+ });
+
+ test("in the completed state, it renders a status message", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(page.getByText("my status message")).toBeVisible();
+ });
+
+ test("in the completed state, it renders a delete button", async ({
+ mount,
+ page,
+ }) => {
+ let clickCount = 0;
+ const onAction = () => {
+ clickCount += 1;
+ };
+ await mount(
+
+ );
+ const actionButton = await page.getByRole("button", {
+ name: "Delete file",
+ });
+ await expect(actionButton).toBeVisible();
+ await actionButton.click();
+ await expect(clickCount).toBe(1);
+ });
+
+ test("in the completed state, it renders the file name as a link with the provided props", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ const link = await page.getByRole("link", { name: "foo.pdf" });
+ await expect(link).toBeVisible();
+ await expect(link).toHaveAttribute("href", "http://carbon.sage.com");
+ await expect(link).toHaveAttribute("target", "_blank");
+ await expect(link).toHaveAttribute("rel", "noreferrer");
+ });
+
+ test("in the completed state, it does not render a progress bar", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(page.getByRole("progressbar")).toHaveCount(0);
+ });
+
+ test("in the previously state, it does not render a status message", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(page.getByText("my status message")).toHaveCount(0);
+ });
+
+ test("in the previously state, it renders the file name as a link with the provided props", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ const link = await page.getByRole("link", { name: "foo.pdf" });
+ await expect(link).toBeVisible();
+ await expect(link).toHaveAttribute("href", "http://carbon.sage.com");
+ await expect(link).toHaveAttribute("target", "_blank");
+ await expect(link).toHaveAttribute("rel", "noreferrer");
+ });
+
+ test("in the previously state, it renders a delete button", async ({
+ mount,
+ page,
+ }) => {
+ let clickCount = 0;
+ const onAction = () => {
+ clickCount += 1;
+ };
+ await mount(
+
+ );
+ const actionButton = await page.getByRole("button", {
+ name: "Delete file",
+ });
+ await expect(actionButton).toBeVisible();
+ await actionButton.click();
+ await expect(clickCount).toBe(1);
+ });
+
+ test("in the previously state, it does not render a progress bar", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(page.getByRole("progressbar")).toHaveCount(0);
+ });
+
+ test("in the error state, it renders a status message", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(page.getByText("my status message")).toBeVisible();
+ });
+
+ test("in the error state, it renders a clear button", async ({
+ mount,
+ page,
+ }) => {
+ let clickCount = 0;
+ const onAction = () => {
+ clickCount += 1;
+ };
+ await mount(
+
+ );
+ const actionButton = await page.getByRole("button", { name: "Clear" });
+ await expect(actionButton).toBeVisible();
+ await actionButton.click();
+ await expect(clickCount).toBe(1);
+ });
+
+ test("in the error state, it renders the file name, but not as a link", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(page.getByText("foo.pdf")).toBeVisible();
+ await expect(page.getByRole("link", { name: "foo.pdf" })).toHaveCount(0);
+ });
+
+ test("in the error state, it does not render a progress bar", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await expect(page.getByRole("progressbar")).toHaveCount(0);
+ });
+});
+
+test.describe("interactions", () => {
+ test("clicking the button allows choosing a file which is passed to the onChange callback", async ({
+ mount,
+ page,
+ }) => {
+ await enableFileJSON(page);
+ const onChangeCalls: File[] = [];
+ const onChange = (files: FileList) => {
+ onChangeCalls.push(files[0]);
+ };
+ await mount();
+ const fileChooserPromise = page.waitForEvent("filechooser");
+ await selectFileButton(page).click();
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles(
+ path.join(process.cwd(), "playwright", "README.md")
+ );
+ await expect(onChangeCalls.length).toBe(1);
+ await expect(onChangeCalls[0]).toMatchObject({
+ name: "README.md",
+ type: "text/markdown",
+ });
+ });
+
+ test("dragging and dropping a file passes it to the onChange callback", async ({
+ mount,
+ page,
+ }) => {
+ await enableFileJSON(page);
+ const onChangeCalls: File[] = [];
+ const onChange = (files: FileList) => {
+ onChangeCalls.push(files[0]);
+ };
+ await mount();
+
+ await dragFile({
+ page,
+ eventName: "drop",
+ selector: '[data-component="file-input"] > div > div > div:last-child',
+ filePath: path.join(process.cwd(), "playwright", "README.md"),
+ fileName: "README.md",
+ fileType: "text/markdown",
+ });
+ await expect(onChangeCalls.length).toBe(1);
+ await expect(onChangeCalls[0]).toMatchObject({
+ name: "README.md",
+ type: "text/markdown",
+ });
+ });
+
+ test("while dragging a file, the component border becomes thicker", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await dragFile({
+ page,
+ eventName: "dragover",
+ selector: "body",
+ filePath: path.join(process.cwd(), "playwright", "README.md"),
+ fileName: "README.md",
+ fileType: "text/markdown",
+ });
+ const borderWidth = await page
+ .getByText("Drag and drop your file")
+ .evaluate((el) =>
+ window
+ .getComputedStyle(el.parentElement as HTMLElement)
+ .getPropertyValue("border-width")
+ );
+ await expect(borderWidth).toBe("2px");
+ });
+
+ test("when dragging a file off the document, the component border returns to the original thickness", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await dragFile({
+ page,
+ eventName: "dragover",
+ selector: "body",
+ filePath: path.join(process.cwd(), "playwright", "README.md"),
+ fileName: "README.md",
+ fileType: "text/markdown",
+ });
+ await dragFile({
+ page,
+ eventName: "dragleave",
+ selector: "body",
+ filePath: path.join(process.cwd(), "playwright", "README.md"),
+ fileName: "README.md",
+ fileType: "text/markdown",
+ });
+ const borderWidth = await page
+ .getByText("Drag and drop your file")
+ .evaluate((el) =>
+ window
+ .getComputedStyle(el.parentElement as HTMLElement)
+ .getPropertyValue("border-width")
+ );
+ await expect(borderWidth).toBe("1px");
+ });
+
+ test("after dropping a file, the component border returns to the original thickness", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await dragFile({
+ page,
+ eventName: "dragover",
+ selector: "body",
+ filePath: path.join(process.cwd(), "playwright", "README.md"),
+ fileName: "README.md",
+ fileType: "text/markdown",
+ });
+ await dragFile({
+ page,
+ eventName: "drop",
+ selector: "body",
+ filePath: path.join(process.cwd(), "playwright", "README.md"),
+ fileName: "README.md",
+ fileType: "text/markdown",
+ });
+ const borderWidth = await page
+ .getByText("Drag and drop your file")
+ .evaluate((el) =>
+ window
+ .getComputedStyle(el.parentElement as HTMLElement)
+ .getPropertyValue("border-width")
+ );
+ await expect(borderWidth).toBe("1px");
+ });
+
+ test("while dragging a file with the component in the error state, the border color changes", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await dragFile({
+ page,
+ eventName: "dragover",
+ selector: "body",
+ filePath: path.join(process.cwd(), "playwright", "README.md"),
+ fileName: "README.md",
+ fileType: "text/markdown",
+ });
+ const borderColor = await page
+ .getByText("Drag and drop your file")
+ .evaluate((el) =>
+ window
+ .getComputedStyle(el.parentElement as HTMLElement)
+ .getPropertyValue("border-color")
+ );
+ // TODO: should check token value (--colorsSemanticNegative600), rewrite this when we have the equivalent playwright util merged in
+ await expect(borderColor).toBe("rgb(162, 44, 59)");
+ });
+
+ test("while dragging a file over the component, the background color changes", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await dragFile({
+ page,
+ eventName: "dragover",
+ selector: '[data-component="file-input"] > div > div > div:last-child',
+ filePath: path.join(process.cwd(), "playwright", "README.md"),
+ fileName: "README.md",
+ fileType: "text/markdown",
+ });
+ const backgroundColor = await page
+ .getByText("Drag and drop your file")
+ .evaluate((el) =>
+ window
+ .getComputedStyle(el.parentElement as HTMLElement)
+ .getPropertyValue("background-color")
+ );
+ await expect(backgroundColor).toBe("rgb(204, 214, 219)");
+ });
+});
+
+test.describe("accessibility tests for FileInput", () => {
+ test("should pass accessibility tests with label", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with accept prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with buttonText prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with dragAndDropText prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with inputHint prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with boolean error prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with string error prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with disabled prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ // the disabled colours don't meet WCAG color-contrast guidelines. DS to recommend this prop not to be used.
+ await checkAccessibility(page, "color-contrast");
+ });
+
+ test("should pass accessibility tests with id prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with name prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with required prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with data-element prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ test("should pass accessibility tests with data-role prop", async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+
+ uploadStatuses.forEach((statusProps) => {
+ test(`should pass accessibility tests with upload status ${statusProps.status}`, async ({
+ mount,
+ page,
+ }) => {
+ await mount();
+ await checkAccessibility(page);
+ });
+ });
+});
diff --git a/src/components/file-input/file-input.spec.tsx b/src/components/file-input/file-input.spec.tsx
new file mode 100644
index 0000000000..05689becbc
--- /dev/null
+++ b/src/components/file-input/file-input.spec.tsx
@@ -0,0 +1,432 @@
+import React from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { rootTagTestRtl } from "../../__internal__/utils/helpers/tags/tags-specs/tags-specs";
+import { assertStyleMatch } from "../../__spec_helper__/test-utils";
+
+import FileInput, { FileUploadStatusProps } from ".";
+
+describe("rendering with no file uploaded", () => {
+ it("renders a hidden HTML file input element", () => {
+ render( {}} />);
+ const hiddenInput = screen.queryByLabelText("file input");
+ expect(hiddenInput).toBeInTheDocument();
+ expect(hiddenInput).not.toBeVisible();
+ });
+
+ it("renders an HTML button to choose a file to add", () => {
+ render( {}} />);
+ expect(
+ screen.queryByRole("button", { name: "Select file" })
+ ).toBeInTheDocument();
+ });
+
+ it("accepts an accept prop and passes it to the underlying input", () => {
+ render(
+ {}} />
+ );
+ const hiddenInput = screen.queryByLabelText("file input");
+ expect(hiddenInput).toHaveAttribute("accept", "image/*,.pdf");
+ });
+
+ it("accepts a buttonText prop to change the button text", () => {
+ render( {}} />);
+ expect(
+ screen.queryByRole("button", { name: "add a file" })
+ ).toBeInTheDocument();
+ });
+
+ it("accepts a dragAndDropText prop to change the main text", () => {
+ render( {}} />);
+ expect(screen.queryByText("drop zone")).toBeInTheDocument();
+ });
+
+ it("accepts a label prop and renders it as a visible label", () => {
+ render( {}} />);
+ const label = screen.getByText("file input");
+ expect(label).toBeInTheDocument();
+ expect(label).toBeVisible();
+ });
+
+ it("accepts a disabled prop that disables the button", () => {
+ render( {}} />);
+ const button = screen.queryByRole("button", { name: "Select file" });
+ expect(button).toBeDisabled();
+ });
+
+ it("accepts an inputHint prop", () => {
+ render( {}} />);
+ const hintText = screen.getByText("help");
+ expect(hintText).toBeInTheDocument();
+ expect(hintText).toBeVisible();
+ });
+
+ it("accepts data tag props", () => {
+ render(
+ {}}
+ />
+ );
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement?.parentElement?.parentElement
+ ?.parentElement as HTMLElement;
+ rootTagTestRtl(wrapperElement, "file-input", "element-test", "role-test");
+ });
+
+ it("accepts an error prop as a boolean", () => {
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement;
+ assertStyleMatch(
+ {
+ border: "var(--borderWidth200) dashed var(--colorsSemanticNegative500)",
+ },
+ wrapperElement
+ );
+ });
+
+ it("accepts an error prop as a string", () => {
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement;
+ assertStyleMatch(
+ {
+ border: "var(--borderWidth200) dashed var(--colorsSemanticNegative500)",
+ },
+ wrapperElement
+ );
+ expect(screen.getByText("error text")).toBeVisible();
+ });
+
+ it("accepts a name prop and passes it to the underlying input", () => {
+ render(
+ {}} />
+ );
+ const hiddenInput = screen.queryByLabelText("file input");
+ expect(hiddenInput).toHaveAttribute("name", "input-name");
+ });
+
+ it("accepts a required prop", () => {
+ render( {}} />);
+ const label = screen.getByText("file input");
+ assertStyleMatch({ content: '"*"' }, label, { modifier: "::after" });
+ });
+
+ it("accepts an isVertical prop which removes the CSS max-height", () => {
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ assertStyleMatch({ maxHeight: undefined }, wrapperElement);
+ });
+
+ it("accepts a ref object", () => {
+ const refObject: React.MutableRefObject = {
+ current: null,
+ };
+ render(
+ {}} />
+ );
+ const hiddenInput = screen.queryByLabelText("file input");
+ expect(refObject.current).toBe(hiddenInput);
+ });
+
+ it("accepts a callback ref", () => {
+ const callbackRef = jest.fn();
+ render(
+ {}} />
+ );
+ const hiddenInput = screen.queryByLabelText("file input");
+ expect(callbackRef).toHaveBeenCalledTimes(1);
+ expect(callbackRef).toHaveBeenCalledWith(hiddenInput);
+ });
+});
+
+describe("interactions", () => {
+ it("clicking the button fires a click on the hidden file input", async () => {
+ const inputOnClick = jest.spyOn(HTMLInputElement.prototype, "click");
+ render( {}} />);
+ await userEvent.click(screen.getByRole("button", { name: "Select file" }));
+ expect(inputOnClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("uploading a file via the hidden input calls the onChange prop with the file as argument", async () => {
+ const file = new File(["dummy file content"], "foo.txt", {
+ type: "text/plain",
+ });
+ const onChange = jest.fn();
+ render();
+ const hiddenInput = screen.getByLabelText("file input");
+ await userEvent.upload(hiddenInput, file);
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange.mock.calls[0][0][0]).toBe(file);
+ });
+
+ // note: these style changes on dragging are better tested with Playwright (and will be too). The tests are here as
+ // well in order to achieve coverage - because that's still better than just putting istanbul ignore everywhere.
+
+ it("dragging a file onto the page causes the border style of the input area to change", () => {
+ const file = new File(["dummy file content"], "foo.txt", {
+ type: "text/plain",
+ });
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ fireEvent.dragOver(document.body, {
+ dataTransfer: { files: [file], types: ["Files"] },
+ });
+ assertStyleMatch(
+ { border: "var(--borderWidth200) dashed var(--colorsUtilityMajor400)" },
+ wrapperElement
+ );
+ });
+
+ it("dragging a file onto the page causes the error state border-colour to change", () => {
+ const file = new File(["dummy file content"], "foo.txt", {
+ type: "text/plain",
+ });
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ fireEvent.dragOver(document.body, {
+ dataTransfer: { files: [file], types: ["Files"] },
+ });
+ assertStyleMatch(
+ {
+ border: "var(--borderWidth200) dashed var(--colorsSemanticNegative600)",
+ },
+ wrapperElement
+ );
+ });
+
+ it("dragging a file causes no change to a disabled FileInput", () => {
+ const file = new File(["dummy file content"], "foo.txt", {
+ type: "text/plain",
+ });
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ fireEvent.dragOver(document.body, {
+ dataTransfer: { files: [file], types: ["Files"] },
+ });
+ assertStyleMatch(
+ {
+ border: "var(--borderWidth100) dashed var(--colorsUtilityDisabled600)",
+ },
+ wrapperElement
+ );
+ });
+
+ it("dragging something that isn't a file has no effect", () => {
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ fireEvent.dragOver(document.body, {
+ dataTransfer: { files: [], types: [] },
+ });
+ assertStyleMatch(
+ { border: "var(--borderWidth100) dashed var(--colorsUtilityMajor300)" },
+ wrapperElement
+ );
+ });
+
+ it("dragging a file over the input area causes the background of the input area to change", () => {
+ const file = new File(["dummy file content"], "foo.txt", {
+ type: "text/plain",
+ });
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ fireEvent.dragOver(wrapperElement, {
+ dataTransfer: { files: [file], types: ["Files"] },
+ });
+ assertStyleMatch(
+ { background: "var(--colorsUtilityMajor100)" },
+ wrapperElement
+ );
+ });
+
+ it("dragging a file over the input area and then away causes the background of the input area to return to the default", () => {
+ const file = new File(["dummy file content"], "foo.txt", {
+ type: "text/plain",
+ });
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ fireEvent.dragOver(wrapperElement, {
+ dataTransfer: { files: [file], types: ["Files"] },
+ });
+ fireEvent.dragLeave(wrapperElement, {
+ dataTransfer: { files: [file], types: ["Files"] },
+ });
+ assertStyleMatch(
+ { background: "var(--colorsUtilityYang100)" },
+ wrapperElement
+ );
+ });
+
+ it("dragging a file over a disabled FileInput causes no change", () => {
+ const file = new File(["dummy file content"], "foo.txt", {
+ type: "text/plain",
+ });
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ fireEvent.dragOver(wrapperElement, {
+ dataTransfer: { files: [file], types: ["Files"] },
+ });
+ assertStyleMatch(
+ { background: "var(--colorsUtilityDisabled400)" },
+ wrapperElement
+ );
+ });
+
+ it("dragging something that isn't a file over the input area has no effect", () => {
+ render( {}} />);
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ fireEvent.dragOver(wrapperElement, {
+ dataTransfer: { files: [], types: [] },
+ });
+ assertStyleMatch(
+ { background: "var(--colorsUtilityYang100)" },
+ wrapperElement
+ );
+ });
+
+ it("dragging and dropping a file over the input area calls the onChange prop with the dragged file as argument", () => {
+ const file = new File(["dummy file content"], "foo.txt", {
+ type: "text/plain",
+ });
+ const onChange = jest.fn();
+ render();
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ fireEvent.drop(wrapperElement, { dataTransfer: { files: [file] } });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange.mock.calls[0][0][0]).toBe(file);
+ });
+
+ it("when disabled, dragging and dropping a file over the input area does not call the onChange prop", () => {
+ const file = new File(["dummy file content"], "foo.txt", {
+ type: "text/plain",
+ });
+ const onChange = jest.fn();
+ render();
+ const wrapperElement = screen.getByText("Drag and drop your file")
+ .parentElement as HTMLElement;
+ fireEvent.drop(wrapperElement, { dataTransfer: { files: [file] } });
+ expect(onChange).not.toHaveBeenCalled();
+ });
+});
+
+// there are more comprehensive tests for the FileUploadStatus subcomponent in that component's subfolder - this
+// is just to check that the prop on FileUpload does as it should. Using RTL not enzyme we can't directly check
+// the props passed to the subcomponent so we have to check this indirectly.
+describe("with uploadStatus prop set", () => {
+ it.each([
+ {
+ status: "uploading",
+ filename: "foo.pdf",
+ onAction: () => {},
+ progress: 30,
+ },
+ {
+ status: "completed",
+ filename: "foo.pdf",
+ onAction: () => {},
+ href: "http://carbon.sage.com",
+ },
+ {
+ status: "previously",
+ filename: "foo.pdf",
+ onAction: () => {},
+ href: "http://carbon.sage.com",
+ },
+ { status: "error", filename: "foo.pdf", onAction: () => {} },
+ ] as FileUploadStatusProps[])(
+ "when the status is `$status`, the hidden file input and the button are not rendered",
+ (statusProps) => {
+ render(
+ {}}
+ />
+ );
+ const hiddenInput = screen.queryByLabelText("file input");
+ const button = screen.queryByRole("button", { name: "Select file" });
+ expect(hiddenInput).not.toBeInTheDocument();
+ expect(button).not.toBeInTheDocument();
+ }
+ );
+
+ it("when the status is `uploading`, a progress bar or loader bar is rendered", () => {
+ render(
+ {},
+ progress: 30,
+ }}
+ onChange={() => {}}
+ />
+ );
+ expect(screen.queryByRole("progressbar")).toBeInTheDocument();
+ });
+
+ it("when the status is `completed`, a link is rendered with the status message", () => {
+ render(
+ {},
+ href: "http://carbon.sage.com",
+ }}
+ onChange={() => {}}
+ />
+ );
+ expect(screen.queryByRole("link", { name: "foo.pdf" })).toBeInTheDocument();
+ expect(screen.queryByText("File upload status")).toBeInTheDocument();
+ });
+
+ it("when the status is `previously`, a link is rendered with no message", () => {
+ render(
+ {},
+ href: "http://carbon.sage.com",
+ }}
+ onChange={() => {}}
+ />
+ );
+ expect(screen.queryByRole("link", { name: "foo.pdf" })).toBeInTheDocument();
+ expect(screen.queryByText("File upload status")).not.toBeInTheDocument();
+ });
+
+ it("when the status is `error`, the component has an error-colored border", () => {
+ render(
+ {},
+ }}
+ onChange={() => {}}
+ />
+ );
+ const wrapperElement = screen.getByText("File upload status").parentElement
+ ?.parentElement as HTMLElement;
+ assertStyleMatch(
+ {
+ border: "var(--borderWidth200) solid var(--colorsSemanticNegative500)",
+ },
+ wrapperElement
+ );
+ });
+});
diff --git a/src/components/file-input/file-input.stories.mdx b/src/components/file-input/file-input.stories.mdx
new file mode 100644
index 0000000000..2630d05914
--- /dev/null
+++ b/src/components/file-input/file-input.stories.mdx
@@ -0,0 +1,243 @@
+import { useState } from "react";
+import { Meta, Story, Canvas, ArgsTable } from "@storybook/addon-docs";
+import LinkTo from "@storybook/addon-links/react";
+
+import TranslationKeysTable from "../../../.storybook/utils/translation-keys-table";
+import StyledSystemProps from "../../../.storybook/utils/styled-system-props";
+
+import FileInput from ".";
+import FileUploadStatus from "./__internal__/file-upload-status";
+
+import * as stories from "./file-input.stories";
+
+
+
+# File Input
+
+
+ Product Design System component
+
+
+Allows a single file to be uploaded.
+
+## Contents
+
+- [Quick Start](#quick-start)
+- [Examples](#examples)
+- [Props](#props)
+
+## Quick Start
+
+```javascript
+import FileInput from "carbon-react/lib/components/file-input";
+```
+
+## Examples
+
+### Default
+
+
+
+### Disabled
+
+Try to avoid the disabled state. If you do use it, make it clear what the user needs to do to activate the file input.
+
+
+
+### With inputHint
+
+
+
+### Required
+
+You can use the `required` prop to indicate if the field is mandatory.
+
+
+
+### Size variations
+
+By default the component has a width of 273px and height of 40px (not including 12px of padding on both top and bottom). This makes both text and button
+render on a single line (as seen in all the examples above).
+
+You can increase the width and/or height component by setting the `maxHeight` and/or `maxWidth` props. You might need to do this to fit especially long
+explanatory text, or to match the precise width/height of a particular design. Examples of increasing width and height are shown below:
+
+
+
+
+
+Note that any valid CSS values can be used for these props, and that you can therefore set the `maxWidth` to `"100%"` to fill out the entire container width:
+
+
+
+It isn't recommended to increase the `maxWidth` to more than 320px without also increasing the `maxHeight`, as this could lead to the text overflowing the
+container on smaller mobile devices. If you really need to put the text and button on the same row if space allows, you can use the CSS `min` function to
+ensure no horizontal overflow, and the useMediaQuery hook to set the `maxHeight` conditionally on
+smaller screen-sizes, as illustratred below:
+
+
+
+One common variation is to have the button vertically stacked below the text, without necessarily increasing the width. This can be achieved by setting the
+`isVertical` boolean prop. Note that this is equivalent to simply removing the default `maxHeight` value and will be overridden if `maxHeight` is explicitly
+provided - so `maxHeight` should not be used in conjunction with `isVertical`. (A sufficiently large `maxHeight` will make the component lay out vertically
+anyway.)
+
+
+
+### Validations
+
+Validation status can be set by passing an `error` or prop to the component
+
+Passing a string will display a properly colored border and error message.
+
+Passing a boolean to these props will display only a properly colored border.
+
+For more information check our [Validations](?path=/docs/documentation-validations--page "Validations") documentation page.
+
+NOTE: old-style tooltip validation is not supported for this component - no matter whether the nearest wrapping `CarbonProvider` sets the `validationRedesignOptIn` flag or not.
+
+
+
+### Accept
+
+The `accept` prop is passed directly to the underlying HTML `` element, and helps guide users towards uploading files of the correct format or type.
+
+Please see the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept) for more details on this attribute. Note that it does not prevent the user
+from uploading a file of the incorrect type, for example via drag and drop - so you might still want to use the `onChange` prop to add an error message as soon as an
+inappropriate file is added. And of course nothing takes away the need for validation on the server side. However the `accept` prop is still recommended to guide the user
+- when selecting a file via the "select file" button, the operating system's file picker will either highlight the appropriate files, or make it impossible to select
+incorrect ones.
+
+
+
+### Validation of file type
+
+Here is a simple example showing how the `onChange` and `error` props can be used in conjunction with `accept` to warn the user if they have chosen an inappropriate
+file.
+
+
+
+### Tracking upload status
+
+The `uploadStatus` prop is used to indicate when one or more files have been added, as well as the current status/progress of
+the upload - and allowing users to cancel or remove uploaded files. It is required to set this (to an actual props object, or to
+a non-empty array for multiple files) in order for the users to see any indication of the uploaded file(s).
+
+For this reason the `onChange` function prop is mandatory - without providing an implementation for this event handler that updates the
+components `uploadStatus`, there will be no visual change once a file has been added to the input.
+
+Here is a relatively simple example of using a [FileReader](https://developer.mozilla.org/en-US/docs/Web/API/FileReader) to track progress purely
+on the client side, resulting in storing the file locally as a `data:` URL. This is a way to show users a preview of their file if you do not intend
+to track upload progress on the server, or do not actually send the files to a server until form submission.
+
+Note the use of a `ref` to store the `FileReader` object - this is not essential but avoids having to create a new `FileReader` object more than once in the
+lifecycle of the component.
+
+
+
+This example mocks an alternative approach where files added to the file input are immediately sent to the server, and the UI tracks the progress of the upload.
+This simple example mocks the progress (and the possibility of errors, such as network errors) with random numbers - in a real scenario this would poll an
+alternative endpoint for the upload progress, or use WebSocket messages sent from the server to update progress.
+
+
+
+Note that you can set the `uploading` without the `progress` property, to renders an
+animated LoaderBar component, rather
+that a ProgressBar as it does when `progress` is provided.
+
+
+
+## Props
+
+### File Input
+
+
+
+Props for the `uploadStatus` prop. Note that only `status`,`filename` and `onAction` are required, and the other props are specific
+to the appropriate status(es) as described below.
+
+Note in particular that, other than the 3 props noted above, and the `message` and `progress` props, all others are the same as the props of
+the Link component, to override the props of the link to the uploaded file. There is one exception here however,
+in that the `href` prop is mandatory. Note that none of these `Link` props can be given except for the statuses `completed` and `previously`.
+
+
+
+## Translation keys
+
+The following keys are available to override the translations for this component by passing in a custom locale object to the
+[i18nProvider](https://carbon.sage.com/?path=/story/documentation-i18n--page).
+
+
diff --git a/src/components/file-input/file-input.stories.tsx b/src/components/file-input/file-input.stories.tsx
new file mode 100644
index 0000000000..bab4870e8b
--- /dev/null
+++ b/src/components/file-input/file-input.stories.tsx
@@ -0,0 +1,320 @@
+/* eslint-disable @typescript-eslint/no-use-before-define */
+import React, { useState, useRef } from "react";
+import { ComponentStory } from "@storybook/react";
+import useMediaQuery from "../../hooks/useMediaQuery";
+
+import FileInput, { FileUploadStatusProps } from ".";
+
+export const Default: ComponentStory = () => {
+ return {}} />;
+};
+
+export const Disabled: ComponentStory = () => {
+ return {}} />;
+};
+
+export const WithInputHint: ComponentStory = () => {
+ return (
+ {}} />
+ );
+};
+
+export const Required: ComponentStory = () => {
+ return {}} />;
+};
+
+export const IncreasedHeight: ComponentStory = () => {
+ return (
+ {}}
+ />
+ );
+};
+
+export const IncreasedWidthResponsive: ComponentStory<
+ typeof FileInput
+> = () => {
+ const isSmallScreen = useMediaQuery("(max-width: 800px)");
+
+ return (
+ {}}
+ />
+ );
+};
+
+export const IncreasedBoth: ComponentStory = () => {
+ return (
+ {}}
+ />
+ );
+};
+
+export const FullWidth: ComponentStory = () => {
+ return {}} />;
+};
+
+export const Vertical: ComponentStory = () => {
+ return {}} />;
+};
+
+export const Validation: ComponentStory = () => {
+ return (
+ <>
+ {}}
+ />
+ {}}
+ />
+ >
+ );
+};
+
+export const Accept: ComponentStory = () => {
+ return (
+ {}}
+ />
+ );
+};
+
+export const FileTypeValidation: ComponentStory = () => {
+ const [error, setError] = useState();
+ const onChange = (files: FileList) => {
+ let errorMessage;
+ if (files.length > 0) {
+ const fileType = files[0].type;
+ if (!fileType.startsWith("image/")) {
+ errorMessage = "Please choose an image file to upload";
+ }
+ }
+ setError(errorMessage);
+ };
+ return (
+
+ );
+};
+
+export const UploadStatusClient: ComponentStory = () => {
+ const [uploadStatus, setUploadStatus] = useState<
+ FileUploadStatusProps | undefined
+ >();
+ const reader = useRef();
+
+ const getReader = () => {
+ if (!reader.current) {
+ reader.current = new FileReader();
+ }
+ return reader.current;
+ };
+
+ const removeFile = () => setUploadStatus(undefined);
+
+ const onChange = (files: FileList) => {
+ if (!files.length) {
+ removeFile();
+ return;
+ }
+ // as this is a single file input there will only ever be (at most) 1 file
+ const fileUploaded = files[0];
+ const fileReader = getReader();
+
+ const handleLoad = () => {
+ const uploadProps: FileUploadStatusProps = {
+ status: "uploading",
+ filename: fileUploaded.name,
+ onAction: () => fileReader.abort(),
+ progress: 0,
+ };
+ setUploadStatus(uploadProps);
+ };
+
+ const handleProgress = (e: ProgressEvent) => {
+ const progress = (100 * e.loaded) / e.total;
+ const isComplete = e.type === "loadend" || progress >= 100;
+ if (isComplete) {
+ removeListeners();
+ }
+ const uploadProps: FileUploadStatusProps = isComplete
+ ? {
+ status: "completed",
+ filename: fileUploaded.name,
+ onAction: () => removeFile(),
+ href: fileReader.result as string,
+ message: "File uploaded",
+ }
+ : {
+ status: "uploading",
+ filename: fileUploaded.name,
+ onAction: () => fileReader.abort(),
+ progress,
+ message: `${progress} percent uploaded`,
+ };
+ setUploadStatus(uploadProps);
+ };
+
+ const handleError = () => {
+ const uploadProps: FileUploadStatusProps = {
+ status: "error",
+ filename: fileUploaded.name,
+ onAction: () => removeFile(),
+ message: "failed to upload",
+ };
+ setUploadStatus(uploadProps);
+ removeListeners();
+ };
+
+ const handleAbort = () => {
+ removeFile();
+ removeListeners();
+ };
+
+ const removeListeners = () => {
+ fileReader.removeEventListener("loadstart", handleLoad);
+ fileReader.removeEventListener("load", handleLoad);
+ fileReader.removeEventListener("loadend", handleProgress);
+ fileReader.removeEventListener("progress", handleProgress);
+ fileReader.removeEventListener("error", handleError);
+ fileReader.removeEventListener("abort", handleAbort);
+ };
+
+ fileReader.addEventListener("loadstart", handleLoad);
+ fileReader.addEventListener("load", handleProgress);
+ fileReader.addEventListener("loadend", handleProgress);
+ fileReader.addEventListener("progress", handleProgress);
+ fileReader.addEventListener("error", handleError);
+ fileReader.addEventListener("abort", handleAbort);
+
+ fileReader.readAsDataURL(fileUploaded);
+ };
+
+ return (
+
+ );
+};
+
+export const UploadStatusAlternative: ComponentStory = () => {
+ const [uploadStatus, setUploadStatus] = useState<
+ FileUploadStatusProps | undefined
+ >();
+
+ const removeFile = () => setUploadStatus(undefined);
+
+ const onChange = (files: FileList) => {
+ if (!files.length) {
+ removeFile();
+ return;
+ }
+ // as this is a single file input there will only ever be (at most) 1 file
+ const fileUploaded = files[0];
+
+ setUploadStatus({
+ status: "uploading",
+ filename: fileUploaded.name,
+ onAction: () => {
+ // in practice you might need to send a new request to the server here to ensure nothing of the file gets stored
+ removeFile();
+ },
+ progress: 0,
+ });
+
+ // mock progress, and possibility of error, at regular intervals. In practice you could poll an endpoint to monitor progress,
+ // or use a WebSocket connection for the server to give regular updates.
+ const interval = setInterval(() => {
+ const randomNumber = Math.floor(Math.random() * 20);
+ // mock possiblity of server error
+ if (randomNumber === 0) {
+ setUploadStatus({
+ status: "error",
+ filename: fileUploaded.name,
+ onAction: () => {
+ // in practice you might need to send a new request to the server here to ensure nothing of the file gets stored
+ removeFile();
+ },
+ message:
+ "something went wrong with uploading the file - please try again",
+ });
+ clearInterval(interval);
+ } else {
+ setUploadStatus((currentStatus) => {
+ if (currentStatus?.status !== "uploading") {
+ return currentStatus;
+ }
+ const currentProgress = currentStatus.progress as number;
+ const newProgress = currentProgress + randomNumber;
+ if (newProgress >= 100) {
+ clearInterval(interval);
+ return {
+ status: "completed",
+ filename: fileUploaded.name,
+ onAction: () => {
+ // in practice you might need to send a new request to the server here to ensure nothing of the file gets stored
+ removeFile();
+ },
+ href: "https://carbon.sage.com/", // real href will be whatever URL the file is stored at
+ message: "File uploaded",
+ };
+ }
+ return {
+ ...currentStatus,
+ progress: newProgress,
+ message: `${newProgress} percent uploaded`,
+ };
+ });
+ }
+ }, 100);
+ };
+
+ return (
+
+ );
+};
+
+export const UploadStatusNoProgress: ComponentStory = () => {
+ return (
+ {},
+ }}
+ onChange={() => {}}
+ />
+ );
+};
diff --git a/src/components/file-input/file-input.style.tsx b/src/components/file-input/file-input.style.tsx
new file mode 100644
index 0000000000..c10c5d37f0
--- /dev/null
+++ b/src/components/file-input/file-input.style.tsx
@@ -0,0 +1,79 @@
+import styled, { css } from "styled-components";
+import type { InputProps } from "../../__internal__/input";
+import type { ValidationProps } from "../../__internal__/validations";
+import StyledTypography from "../typography/typography.style";
+
+export const StyledHiddenFileInput = styled.input`
+ display: none;
+`;
+
+interface StyledFileInputPresentationProps
+ extends Pick {
+ isDisabled?: boolean;
+ isDraggedOver?: boolean;
+ isDraggingFile?: boolean;
+ hasUploadStatus?: boolean;
+ maxHeight?: string;
+ maxWidth?: string;
+ minHeight?: string;
+ minWidth?: string;
+}
+
+export const StyledFileInputPresentation = styled.div`
+ ${({ hasUploadStatus, minWidth, minHeight, maxWidth, maxHeight }) => css`
+ min-width: ${minWidth};
+ min-height: ${minHeight};
+ max-width: ${maxWidth};
+ ${!hasUploadStatus &&
+ css`
+ padding: var(--spacing150);
+ max-height: ${maxHeight};
+ `}
+ `}
+
+ ${({ hasUploadStatus, isDisabled, isDraggedOver, isDraggingFile, error }) => {
+ const borderWidthToken =
+ error || (isDraggingFile && !isDisabled)
+ ? "borderWidth200"
+ : "borderWidth100";
+ let borderColorToken = "colorsUtilityMajor300";
+ let backgroundColorToken = "colorsUtilityYang100";
+ if (isDisabled) {
+ borderColorToken = "colorsUtilityDisabled600";
+ backgroundColorToken = "colorsUtilityDisabled400";
+ } else {
+ if (isDraggedOver) {
+ borderColorToken = "colorsUtilityMajor400";
+ backgroundColorToken = "colorsUtilityMajor100";
+ } else if (isDraggingFile) {
+ borderColorToken = "colorsUtilityMajor400";
+ }
+ if (error) {
+ borderColorToken = `colorsSemanticNegative${
+ isDraggingFile ? 600 : 500
+ }`;
+ }
+ }
+ return (
+ !hasUploadStatus &&
+ css`
+ display: flex;
+ flex-direction: column;
+ flex-wrap: wrap;
+ justify-content: center;
+ align-content: center;
+ align-items: center;
+ text-align: center;
+ gap: var(--spacing150);
+ border-radius: var(--borderRadius050);
+ border: var(--${borderWidthToken}) dashed var(--${borderColorToken});
+ background: var(--${backgroundColorToken});
+ ${StyledTypography} {
+ color: var(
+ --${isDisabled ? "colorsUtilityYin030" : "colorsUtilityYin055"}
+ );
+ }
+ `
+ );
+ }}
+`;
diff --git a/src/components/file-input/index.ts b/src/components/file-input/index.ts
new file mode 100644
index 0000000000..277fb76146
--- /dev/null
+++ b/src/components/file-input/index.ts
@@ -0,0 +1,3 @@
+export { default } from "./file-input.component";
+export type { FileInputProps } from "./file-input.component";
+export type { FileUploadStatusProps } from "./__internal__/file-upload-status";
diff --git a/src/locales/en-gb.ts b/src/locales/en-gb.ts
index f7196982f9..a3817ef4b5 100644
--- a/src/locales/en-gb.ts
+++ b/src/locales/en-gb.ts
@@ -76,6 +76,16 @@ const enGB: Locale = {
},
},
},
+ fileInput: {
+ dragAndDrop: () => "Drag and drop your file",
+ selectFile: () => "Select file",
+ fileUploadStatus: () => "File upload status",
+ actions: {
+ cancel: () => "Cancel upload",
+ clear: () => "Clear",
+ delete: () => "Delete file",
+ },
+ },
heading: {
backLinkAriaLabel: () => "Back",
},
diff --git a/src/locales/locale.ts b/src/locales/locale.ts
index bd4a8be0fe..d4f3994f34 100644
--- a/src/locales/locale.ts
+++ b/src/locales/locale.ts
@@ -51,6 +51,16 @@ interface Locale {
) => [string, string] | null;
};
};
+ fileInput: {
+ dragAndDrop: () => string;
+ selectFile: () => string;
+ fileUploadStatus: () => string;
+ actions: {
+ cancel: () => string;
+ clear: () => string;
+ delete: () => string;
+ };
+ };
heading: {
backLinkAriaLabel: () => string;
};
diff --git a/src/locales/pl-pl.ts b/src/locales/pl-pl.ts
index ea42aded9f..d5d9206fc3 100644
--- a/src/locales/pl-pl.ts
+++ b/src/locales/pl-pl.ts
@@ -135,6 +135,16 @@ const plPL: Locale = {
},
},
},
+ fileInput: {
+ dragAndDrop: () => "Przeciągnij i upuść plik",
+ selectFile: () => "Wybierz pliki",
+ fileUploadStatus: () => "Status przesyłania plików",
+ actions: {
+ cancel: () => "Anuluj przesyłanie",
+ clear: () => "Wyczyść",
+ delete: () => "Usuń plik",
+ },
+ },
heading: {
backLinkAriaLabel: () => "Wstecz",
},