From 947eaa1d8276dfcbf6ffc70e61d0f92e741d51e0 Mon Sep 17 00:00:00 2001 From: Robin Zigmond Date: Fri, 6 Oct 2023 10:49:56 +0100 Subject: [PATCH 01/12] feat: add new file-input component Only supports input of a single file at present - multiple mode will follow in the future. --- playwright/components/file-input/index.ts | 18 + playwright/components/file-input/locators.ts | 2 + .../helpers/tags/tags-specs/tags-specs.ts | 9 +- .../file-upload-status.component.tsx | 132 +++ .../file-upload-status.spec.tsx | 280 ++++++ .../file-upload-status.style.tsx | 89 ++ .../__internal__/file-upload-status/index.ts | 2 + .../file-input/components.test-pw.tsx | 6 + .../file-input/file-input-test.stories.tsx | 74 ++ .../file-input/file-input.component.tsx | 254 ++++++ src/components/file-input/file-input.pw.tsx | 831 ++++++++++++++++++ src/components/file-input/file-input.spec.tsx | 432 +++++++++ .../file-input/file-input.stories.mdx | 243 +++++ .../file-input/file-input.stories.tsx | 320 +++++++ .../file-input/file-input.style.tsx | 79 ++ src/components/file-input/index.ts | 3 + src/locales/en-gb.ts | 10 + src/locales/locale.ts | 10 + src/locales/pl-pl.ts | 10 + 19 files changed, 2800 insertions(+), 4 deletions(-) create mode 100644 playwright/components/file-input/index.ts create mode 100644 playwright/components/file-input/locators.ts create mode 100644 src/components/file-input/__internal__/file-upload-status/file-upload-status.component.tsx create mode 100644 src/components/file-input/__internal__/file-upload-status/file-upload-status.spec.tsx create mode 100644 src/components/file-input/__internal__/file-upload-status/file-upload-status.style.tsx create mode 100644 src/components/file-input/__internal__/file-upload-status/index.ts create mode 100644 src/components/file-input/components.test-pw.tsx create mode 100644 src/components/file-input/file-input-test.stories.tsx create mode 100644 src/components/file-input/file-input.component.tsx create mode 100644 src/components/file-input/file-input.pw.tsx create mode 100644 src/components/file-input/file-input.spec.tsx create mode 100644 src/components/file-input/file-input.stories.mdx create mode 100644 src/components/file-input/file-input.stories.tsx create mode 100644 src/components/file-input/file-input.style.tsx create mode 100644 src/components/file-input/index.ts 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 1fd9d09238..33ee4c323f 100644 --- a/src/__internal__/utils/helpers/tags/tags-specs/tags-specs.ts +++ b/src/__internal__/utils/helpers/tags/tags-specs/tags-specs.ts @@ -23,14 +23,15 @@ const rootTagTest = ( }; const rootTagTestRtl = ( - element: HTMLElement, + rootNode: HTMLElement, comp: string, elem?: string, role?: string ) => { - expect(element.getAttribute("data-component")).toBe(comp); - expect(element.getAttribute("data-element")).toBe(elem); - expect(element.getAttribute("data-role")).toBe(role); + 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, 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..dee34a60bf --- /dev/null +++ b/src/components/file-input/__internal__/file-upload-status/file-upload-status.style.tsx @@ -0,0 +1,89 @@ +import styled, { css } from "styled-components"; +import StyledTypography from "../../../typography/typography.style"; +import StyledIcon from "../../../icon/icon.style"; +import { + StyledProgressBar, + InnerBar as ProgressTrackerInnerBar, +} from "../../../progress-tracker/progress-tracker.style"; +import { + StyledLoader, + InnerBar as LoaderBarInnerBar, +} 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}, ${ProgressTrackerInnerBar} { + border-radius: var(--borderRadius050); + border: none; + } + + ${StyledLoader} { + display: flex; + } + + ${LoaderBarInnerBar} { + background-color: var(--colorsSemanticNeutral500); + } +`; 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", }, From fc0c807af71f241da2c1983ce36850b5223ff2be Mon Sep 17 00:00:00 2001 From: Robin Zigmond Date: Tue, 24 Oct 2023 15:19:19 +0100 Subject: [PATCH 02/12] fix(file-input): fix outer background color of loaderbar --- .../file-upload-status/file-upload-status.style.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index dee34a60bf..d4bfd07c33 100644 --- 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 @@ -5,7 +5,7 @@ import { StyledProgressBar, InnerBar as ProgressTrackerInnerBar, } from "../../../progress-tracker/progress-tracker.style"; -import { +import StyledLoaderBar, { StyledLoader, InnerBar as LoaderBarInnerBar, } from "../../../loader-bar/loader-bar.style"; @@ -83,6 +83,10 @@ export const StyledFileUploadStatus = styled.div` display: flex; } + ${StyledLoaderBar} { + background-color: var(--colorsSemanticNeutral200); + } + ${LoaderBarInnerBar} { background-color: var(--colorsSemanticNeutral500); } From adb1e83dab5da19ed26d20ee6783e4a7ad285d7b Mon Sep 17 00:00:00 2001 From: Robin Zigmond Date: Tue, 24 Oct 2023 15:52:00 +0100 Subject: [PATCH 03/12] feat(file-input): make upload status message into aria-live region --- .../file-upload-status/file-upload-status.component.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index b3716b0913..b1cb198c6e 100644 --- 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 @@ -1,7 +1,7 @@ import React from "react"; import Link, { LinkProps } from "../../../link"; import ButtonMinor from "../../../button-minor"; -import Typography from "../../../typography"; +import StyledTypography from "../../../typography/typography.style"; import ProgressTracker from "../../../progress-tracker"; import LoaderBar from "../../../loader-bar"; import Icon, { IconType } from "../../../icon"; @@ -105,7 +105,9 @@ export const FileUploadStatus = ({ const mainRow = status !== "previously" ? ( - {statusMessage} + + {statusMessage} + {actionButton} ) : ( From 7767484abf1fa4b7bdbe7b955d2f9836618d9287 Mon Sep 17 00:00:00 2001 From: Robin Zigmond Date: Fri, 27 Oct 2023 15:04:48 +0100 Subject: [PATCH 04/12] chore: add latest tweaks to file-input design --- .../file-input/file-input.component.tsx | 4 +-- src/components/file-input/file-input.pw.tsx | 18 ++++++------ src/components/file-input/file-input.spec.tsx | 28 +++++++++---------- .../file-input/file-input.stories.mdx | 2 +- src/locales/en-gb.ts | 2 +- src/locales/pl-pl.ts | 2 +- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/components/file-input/file-input.component.tsx b/src/components/file-input/file-input.component.tsx index d9dde01b16..0dc509378e 100644 --- a/src/components/file-input/file-input.component.tsx +++ b/src/components/file-input/file-input.component.tsx @@ -32,7 +32,7 @@ export interface FileInputProps 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" */ + /** Explanatory text to appear inside the input area. Defaults to "or 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; @@ -204,7 +204,6 @@ export const FileInput = React.forwardRef( onDrop={onDrop} {...sizeProps} > - {mainText} {textOnButton} + {mainText} diff --git a/src/components/file-input/file-input.pw.tsx b/src/components/file-input/file-input.pw.tsx index a8350c3301..4b409f433e 100644 --- a/src/components/file-input/file-input.pw.tsx +++ b/src/components/file-input/file-input.pw.tsx @@ -191,7 +191,7 @@ test.describe("FileInput component", () => { test("should render with boolean error prop", async ({ mount, page }) => { await mount(); const borderColor = await page - .getByText("Drag and drop your file") + .getByText("or drag and drop your file") .evaluate((el) => window .getComputedStyle(el.parentElement as HTMLElement) @@ -204,7 +204,7 @@ test.describe("FileInput component", () => { test("should render with string error prop", async ({ mount, page }) => { await mount(); const borderColor = await page - .getByText("Drag and drop your file") + .getByText("or drag and drop your file") .evaluate((el) => window .getComputedStyle(el.parentElement as HTMLElement) @@ -221,7 +221,7 @@ test.describe("FileInput component", () => { await mount(); await expect(selectFileButton(page)).toBeDisabled(); const inputBackground = await page - .getByText("Drag and drop your file") + .getByText("or drag and drop your file") .evaluate((el) => window .getComputedStyle(el.parentElement as HTMLElement) @@ -230,7 +230,7 @@ test.describe("FileInput component", () => { // actually a token value, --colorsUtilityDisabled400 await expect(inputBackground).toBe("rgb(242, 245, 246)"); const inputBorder = await page - .getByText("Drag and drop your file") + .getByText("or drag and drop your file") .evaluate((el) => window .getComputedStyle(el.parentElement as HTMLElement) @@ -594,7 +594,7 @@ test.describe("interactions", () => { fileType: "text/markdown", }); const borderWidth = await page - .getByText("Drag and drop your file") + .getByText("or drag and drop your file") .evaluate((el) => window .getComputedStyle(el.parentElement as HTMLElement) @@ -625,7 +625,7 @@ test.describe("interactions", () => { fileType: "text/markdown", }); const borderWidth = await page - .getByText("Drag and drop your file") + .getByText("or drag and drop your file") .evaluate((el) => window .getComputedStyle(el.parentElement as HTMLElement) @@ -656,7 +656,7 @@ test.describe("interactions", () => { fileType: "text/markdown", }); const borderWidth = await page - .getByText("Drag and drop your file") + .getByText("or drag and drop your file") .evaluate((el) => window .getComputedStyle(el.parentElement as HTMLElement) @@ -679,7 +679,7 @@ test.describe("interactions", () => { fileType: "text/markdown", }); const borderColor = await page - .getByText("Drag and drop your file") + .getByText("or drag and drop your file") .evaluate((el) => window .getComputedStyle(el.parentElement as HTMLElement) @@ -703,7 +703,7 @@ test.describe("interactions", () => { fileType: "text/markdown", }); const backgroundColor = await page - .getByText("Drag and drop your file") + .getByText("or drag and drop your file") .evaluate((el) => window .getComputedStyle(el.parentElement as HTMLElement) diff --git a/src/components/file-input/file-input.spec.tsx b/src/components/file-input/file-input.spec.tsx index 05689becbc..03509898b5 100644 --- a/src/components/file-input/file-input.spec.tsx +++ b/src/components/file-input/file-input.spec.tsx @@ -69,7 +69,7 @@ describe("rendering with no file uploaded", () => { onChange={() => {}} /> ); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement?.parentElement?.parentElement ?.parentElement as HTMLElement; rootTagTestRtl(wrapperElement, "file-input", "element-test", "role-test"); @@ -77,7 +77,7 @@ describe("rendering with no file uploaded", () => { it("accepts an error prop as a boolean", () => { render( {}} />); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement; assertStyleMatch( { @@ -89,7 +89,7 @@ describe("rendering with no file uploaded", () => { it("accepts an error prop as a string", () => { render( {}} />); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement; assertStyleMatch( { @@ -116,7 +116,7 @@ describe("rendering with no file uploaded", () => { it("accepts an isVertical prop which removes the CSS max-height", () => { render( {}} />); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; assertStyleMatch({ maxHeight: undefined }, wrapperElement); }); @@ -171,7 +171,7 @@ describe("interactions", () => { type: "text/plain", }); render( {}} />); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; fireEvent.dragOver(document.body, { dataTransfer: { files: [file], types: ["Files"] }, @@ -187,7 +187,7 @@ describe("interactions", () => { type: "text/plain", }); render( {}} />); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; fireEvent.dragOver(document.body, { dataTransfer: { files: [file], types: ["Files"] }, @@ -205,7 +205,7 @@ describe("interactions", () => { type: "text/plain", }); render( {}} />); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; fireEvent.dragOver(document.body, { dataTransfer: { files: [file], types: ["Files"] }, @@ -220,7 +220,7 @@ describe("interactions", () => { it("dragging something that isn't a file has no effect", () => { render( {}} />); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; fireEvent.dragOver(document.body, { dataTransfer: { files: [], types: [] }, @@ -236,7 +236,7 @@ describe("interactions", () => { type: "text/plain", }); render( {}} />); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; fireEvent.dragOver(wrapperElement, { dataTransfer: { files: [file], types: ["Files"] }, @@ -252,7 +252,7 @@ describe("interactions", () => { type: "text/plain", }); render( {}} />); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; fireEvent.dragOver(wrapperElement, { dataTransfer: { files: [file], types: ["Files"] }, @@ -271,7 +271,7 @@ describe("interactions", () => { type: "text/plain", }); render( {}} />); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; fireEvent.dragOver(wrapperElement, { dataTransfer: { files: [file], types: ["Files"] }, @@ -284,7 +284,7 @@ describe("interactions", () => { 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") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; fireEvent.dragOver(wrapperElement, { dataTransfer: { files: [], types: [] }, @@ -301,7 +301,7 @@ describe("interactions", () => { }); const onChange = jest.fn(); render(); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; fireEvent.drop(wrapperElement, { dataTransfer: { files: [file] } }); expect(onChange).toHaveBeenCalledTimes(1); @@ -314,7 +314,7 @@ describe("interactions", () => { }); const onChange = jest.fn(); render(); - const wrapperElement = screen.getByText("Drag and drop your file") + const wrapperElement = screen.getByText("or drag and drop your file") .parentElement as HTMLElement; fireEvent.drop(wrapperElement, { dataTransfer: { files: [file] } }); expect(onChange).not.toHaveBeenCalled(); diff --git a/src/components/file-input/file-input.stories.mdx b/src/components/file-input/file-input.stories.mdx index 2630d05914..717d4bd411 100644 --- a/src/components/file-input/file-input.stories.mdx +++ b/src/components/file-input/file-input.stories.mdx @@ -97,7 +97,7 @@ 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 +One common variation is to have the text vertically stacked below the button, 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.) diff --git a/src/locales/en-gb.ts b/src/locales/en-gb.ts index a3817ef4b5..8d8071a304 100644 --- a/src/locales/en-gb.ts +++ b/src/locales/en-gb.ts @@ -77,7 +77,7 @@ const enGB: Locale = { }, }, fileInput: { - dragAndDrop: () => "Drag and drop your file", + dragAndDrop: () => "or drag and drop your file", selectFile: () => "Select file", fileUploadStatus: () => "File upload status", actions: { diff --git a/src/locales/pl-pl.ts b/src/locales/pl-pl.ts index d5d9206fc3..090c5b479e 100644 --- a/src/locales/pl-pl.ts +++ b/src/locales/pl-pl.ts @@ -136,7 +136,7 @@ const plPL: Locale = { }, }, fileInput: { - dragAndDrop: () => "Przeciągnij i upuść plik", + dragAndDrop: () => "lub przeciągnij i upuść plik", selectFile: () => "Wybierz pliki", fileUploadStatus: () => "Status przesyłania plików", actions: { From 451f3caa274649c8af7b411440ebeb25702ae54f Mon Sep 17 00:00:00 2001 From: Robin Zigmond Date: Fri, 27 Oct 2023 15:11:06 +0100 Subject: [PATCH 05/12] chore: remove disabled state --- .../file-input/file-input.component.tsx | 23 +++----- src/components/file-input/file-input.pw.tsx | 34 ------------ src/components/file-input/file-input.spec.tsx | 52 ------------------- .../file-input/file-input.stories.mdx | 8 --- .../file-input/file-input.stories.tsx | 4 -- .../file-input/file-input.style.tsx | 34 ++++-------- 6 files changed, 17 insertions(+), 138 deletions(-) diff --git a/src/components/file-input/file-input.component.tsx b/src/components/file-input/file-input.component.tsx index 0dc509378e..8a222d6f1e 100644 --- a/src/components/file-input/file-input.component.tsx +++ b/src/components/file-input/file-input.component.tsx @@ -24,7 +24,7 @@ import useLocale from "../../hooks/__internal__/useLocale"; export interface FileInputProps extends Pick, - Pick, + Pick, TagProps, MarginProps { /** Which file format(s) to accept. Will be passed to the underlying HTML input. @@ -62,7 +62,6 @@ export const FileInput = React.forwardRef( buttonText, "data-element": dataElement, "data-role": dataRole, - disabled, dragAndDropText, error, label, @@ -140,10 +139,8 @@ export const FileInput = React.forwardRef( const onDragOver = (e: React.DragEvent) => { e.preventDefault(); - if (!disabled) { - if (e.dataTransfer?.types.includes("Files")) { - setIsDraggedOver(true); - } + if (e.dataTransfer?.types.includes("Files")) { + setIsDraggedOver(true); } }; @@ -155,10 +152,8 @@ export const FileInput = React.forwardRef( const onDrop = (e: React.DragEvent) => { e.preventDefault(); - if (!disabled) { - setIsDraggedOver(false); - onFileAdded(e.dataTransfer.files); - } + setIsDraggedOver(false); + onFileAdded(e.dataTransfer.files); }; const onInputChange = (e: React.ChangeEvent) => { @@ -195,7 +190,6 @@ export const FileInput = React.forwardRef( {...rest} /> - + {textOnButton} {mainText} @@ -220,7 +210,6 @@ export const FileInput = React.forwardRef( return ( { 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("or 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("or 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(); @@ -770,15 +745,6 @@ test.describe("accessibility tests for FileInput", () => { 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, diff --git a/src/components/file-input/file-input.spec.tsx b/src/components/file-input/file-input.spec.tsx index 03509898b5..e95d14349a 100644 --- a/src/components/file-input/file-input.spec.tsx +++ b/src/components/file-input/file-input.spec.tsx @@ -48,12 +48,6 @@ describe("rendering with no file uploaded", () => { 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"); @@ -200,24 +194,6 @@ describe("interactions", () => { ); }); - 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("or 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("or drag and drop your file") @@ -266,22 +242,6 @@ describe("interactions", () => { ); }); - 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("or 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("or drag and drop your file") @@ -307,18 +267,6 @@ describe("interactions", () => { 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("or 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 diff --git a/src/components/file-input/file-input.stories.mdx b/src/components/file-input/file-input.stories.mdx index 717d4bd411..cb86777d34 100644 --- a/src/components/file-input/file-input.stories.mdx +++ b/src/components/file-input/file-input.stories.mdx @@ -44,14 +44,6 @@ import FileInput from "carbon-react/lib/components/file-input"; -### 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 diff --git a/src/components/file-input/file-input.stories.tsx b/src/components/file-input/file-input.stories.tsx index bab4870e8b..c3049fe185 100644 --- a/src/components/file-input/file-input.stories.tsx +++ b/src/components/file-input/file-input.stories.tsx @@ -9,10 +9,6 @@ export const Default: ComponentStory = () => { return {}} />; }; -export const Disabled: ComponentStory = () => { - return {}} />; -}; - export const WithInputHint: ComponentStory = () => { return ( {}} /> diff --git a/src/components/file-input/file-input.style.tsx b/src/components/file-input/file-input.style.tsx index c10c5d37f0..36ac944c68 100644 --- a/src/components/file-input/file-input.style.tsx +++ b/src/components/file-input/file-input.style.tsx @@ -9,7 +9,6 @@ export const StyledHiddenFileInput = styled.input` interface StyledFileInputPresentationProps extends Pick { - isDisabled?: boolean; isDraggedOver?: boolean; isDraggingFile?: boolean; hasUploadStatus?: boolean; @@ -31,28 +30,19 @@ export const StyledFileInputPresentation = styled.div { + ${({ hasUploadStatus, isDraggedOver, isDraggingFile, error }) => { const borderWidthToken = - error || (isDraggingFile && !isDisabled) - ? "borderWidth200" - : "borderWidth100"; + error || isDraggingFile ? "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 - }`; - } + if (isDraggedOver) { + borderColorToken = "colorsUtilityMajor400"; + backgroundColorToken = "colorsUtilityMajor100"; + } else if (isDraggingFile) { + borderColorToken = "colorsUtilityMajor400"; + } + if (error) { + borderColorToken = `colorsSemanticNegative${isDraggingFile ? 600 : 500}`; } return ( !hasUploadStatus && @@ -69,9 +59,7 @@ export const StyledFileInputPresentation = styled.div Date: Fri, 10 Nov 2023 09:51:33 +0000 Subject: [PATCH 06/12] fix: css syntax for text opacity --- src/components/file-input/file-input.style.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/file-input/file-input.style.tsx b/src/components/file-input/file-input.style.tsx index 36ac944c68..ba5a941d34 100644 --- a/src/components/file-input/file-input.style.tsx +++ b/src/components/file-input/file-input.style.tsx @@ -59,7 +59,7 @@ export const StyledFileInputPresentation = styled.div Date: Fri, 10 Nov 2023 11:03:04 +0000 Subject: [PATCH 07/12] fix: correct default min-width and fix docs --- src/components/file-input/file-input.component.tsx | 10 +++++----- src/components/file-input/file-input.stories.mdx | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/file-input/file-input.component.tsx b/src/components/file-input/file-input.component.tsx index 8a222d6f1e..9f74cd3659 100644 --- a/src/components/file-input/file-input.component.tsx +++ b/src/components/file-input/file-input.component.tsx @@ -41,13 +41,13 @@ export interface FileInputProps isVertical?: boolean; /** Label content */ label?: string; - /** A valid CSS string for the max-height CSS property. Defaults to 40px. */ + /** A valid CSS string for the max-height CSS property. Defaults to the same as the minHeight. */ maxHeight?: string; - /** A valid CSS string for the max-width CSS property. Defaults to 273px. */ + /** A valid CSS string for the max-width CSS property. Defaults to the same as the minWidth. */ maxWidth?: string; - /** A valid CSS string for the min-height CSS property. Defaults to the same as the maxHeight. */ + /** A valid CSS string for the min-height CSS property. Defaults to 40px. */ minHeight?: string; - /** A valid CSS string for the min-width CSS property. Defaults to the same as the maxWidth. */ + /** A valid CSS string for the min-width CSS property. Defaults to 256px. */ minWidth?: string; /** onChange event handler. Accepts a list of all files currently entered to the input. */ onChange: (files: FileList) => void; @@ -71,7 +71,7 @@ export const FileInput = React.forwardRef( maxHeight, maxWidth, minHeight = "40px", - minWidth = "273px", + minWidth = "256px", name, onChange, required, diff --git a/src/components/file-input/file-input.stories.mdx b/src/components/file-input/file-input.stories.mdx index cb86777d34..53f162afbc 100644 --- a/src/components/file-input/file-input.stories.mdx +++ b/src/components/file-input/file-input.stories.mdx @@ -60,7 +60,7 @@ 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 +By default the component has a width of 256px and height of 40px (not including 12px of padding on each side). 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 From 8cdabc93daab76f34964e080309a275ad2bc97c6 Mon Sep 17 00:00:00 2001 From: Robin Zigmond Date: Thu, 16 Nov 2023 09:57:39 +0000 Subject: [PATCH 08/12] feat: ensure text of long file name does not overflow container --- .../file-upload-status.component.tsx | 2 +- .../file-upload-status.style.tsx | 23 +++++++++++++++++-- .../file-input/file-input-test.stories.tsx | 18 ++++++++++++++- 3 files changed, 39 insertions(+), 4 deletions(-) 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 index b1cb198c6e..2d1902b2e6 100644 --- 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 @@ -111,7 +111,7 @@ export const FileUploadStatus = ({ {actionButton} ) : ( - + {fileLink} {actionButton} 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 index d4bfd07c33..8b29d74879 100644 --- 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 @@ -9,12 +9,30 @@ import StyledLoaderBar, { StyledLoader, InnerBar as LoaderBarInnerBar, } from "../../../loader-bar/loader-bar.style"; -import { StyledLink } from "../../../link/link.style"; +import { StyledLink, StyledContent } from "../../../link/link.style"; export const StyledFileLinkContainer = styled.div` color: var(--colorsActionMajorYin090); display: flex; align-items: center; + overflow: hidden; + + ${StyledLink} { + overflow: hidden; + padding-right: var(--spacing150); + } + + ${StyledLink} a { + overflow: hidden; + display: flex; + text-decoration: none; + } + + ${StyledContent} { + overflow: hidden; + text-overflow: ellipsis; + text-decoration: underline; + } &&& ${StyledIcon} { display: inline-flex; @@ -34,12 +52,13 @@ export const StyledFileLinkContainer = styled.div` interface StyledFileUploadStatusRowProps { upperPadding?: boolean; lowerPadding?: boolean; + onlyRow?: boolean; } export const StyledFileUploadStatusRow = styled.div` display: flex; justify-content: space-between; - align-items: baseline; + ${({ onlyRow }) => (onlyRow ? "" : "align-items: baseline;")} padding-left: var(--spacing150); ${({ upperPadding }) => upperPadding ? "padding-top: var(--spacing050);" : ""} diff --git a/src/components/file-input/file-input-test.stories.tsx b/src/components/file-input/file-input-test.stories.tsx index 4c08feaaa0..bf58b568e4 100644 --- a/src/components/file-input/file-input-test.stories.tsx +++ b/src/components/file-input/file-input-test.stories.tsx @@ -4,7 +4,7 @@ import FileInput, { FileInputProps } from "."; export default { component: FileInput, title: "File Input/Test", - includeStories: ["AllStatuses"], + includeStories: ["AllStatuses", "LongFilenameStatus"], parameters: { info: { disable: true }, chromatic: { @@ -72,3 +72,19 @@ export const AllStatuses = () => { {}} /> )); }; + +export const LongFilenameStatus = () => { + return ( + {}, + href: "http://carbon.sage.com/", + filename: "really_long_filename_that_will_overflow_container.doc", + }} + onChange={() => {}} + /> + ); +}; From d0d5131464b7c834c31085df7ddbecda71e40f90 Mon Sep 17 00:00:00 2001 From: Robin Zigmond Date: Fri, 17 Nov 2023 16:48:03 +0000 Subject: [PATCH 09/12] docs: add controls for test stories --- .../file-input/file-input-test.stories.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/file-input/file-input-test.stories.tsx b/src/components/file-input/file-input-test.stories.tsx index bf58b568e4..e6254126e9 100644 --- a/src/components/file-input/file-input-test.stories.tsx +++ b/src/components/file-input/file-input-test.stories.tsx @@ -13,7 +13,7 @@ export default { }, }; -export const AllStatuses = () => { +export const AllStatuses = (args: Partial) => { const statuses: FileInputProps["uploadStatus"][] = [ undefined, { @@ -69,11 +69,17 @@ export const AllStatuses = () => { }, ]; return statuses.map((status) => ( - {}} /> + {}} + {...args} + /> )); }; -export const LongFilenameStatus = () => { +export const LongFilenameStatus = (args: Partial) => { return ( { filename: "really_long_filename_that_will_overflow_container.doc", }} onChange={() => {}} + {...args} /> ); }; From 3fdbcb6452d9337998a037715addcbc33ff689dd Mon Sep 17 00:00:00 2001 From: Robin Zigmond Date: Fri, 17 Nov 2023 16:57:29 +0000 Subject: [PATCH 10/12] docs: add separate subheading for each story --- src/components/file-input/file-input.stories.mdx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/file-input/file-input.stories.mdx b/src/components/file-input/file-input.stories.mdx index 53f162afbc..6e2b4d119f 100644 --- a/src/components/file-input/file-input.stories.mdx +++ b/src/components/file-input/file-input.stories.mdx @@ -66,20 +66,28 @@ 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: +#### Increased height + +#### Both height and width increased + +#### Full width example + 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: +#### Responsive increased width example + 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 @@ -89,6 +97,8 @@ smaller screen-sizes, as illustratred below: +#### Vertical layout + One common variation is to have the text vertically stacked below the button, 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 @@ -146,6 +156,8 @@ a non-empty array for multiple files) in order for the users to see any indicati 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. +#### Client side example + 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. @@ -157,6 +169,8 @@ lifecycle of the component. +#### Other approaches + 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. @@ -165,6 +179,8 @@ alternative endpoint for the upload progress, or use WebSocket messages sent fro +#### Uploading without progress percentage + 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. From 5a75d0339b04d21c9a0052a75c16ff5dd8369e8e Mon Sep 17 00:00:00 2001 From: Robin Zigmond Date: Tue, 21 Nov 2023 15:06:44 +0000 Subject: [PATCH 11/12] fix: link focus styling --- .../file-upload-status/file-upload-status.style.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 8b29d74879..eae0548f22 100644 --- 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 @@ -15,11 +15,12 @@ export const StyledFileLinkContainer = styled.div` color: var(--colorsActionMajorYin090); display: flex; align-items: center; - overflow: hidden; + overflow-x: clip; + overflow-y: visible; + padding-right: var(--spacing150); ${StyledLink} { overflow: hidden; - padding-right: var(--spacing150); } ${StyledLink} a { From 9388b8c847f915f8132c4cd0689a10569ae7b703 Mon Sep 17 00:00:00 2001 From: Robin Zigmond Date: Wed, 6 Dec 2023 11:34:38 +0000 Subject: [PATCH 12/12] docs: restrict file size in client-side upload example --- src/components/file-input/file-input.stories.mdx | 4 ++++ src/components/file-input/file-input.stories.tsx | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/components/file-input/file-input.stories.mdx b/src/components/file-input/file-input.stories.mdx index 6e2b4d119f..f36a5537b1 100644 --- a/src/components/file-input/file-input.stories.mdx +++ b/src/components/file-input/file-input.stories.mdx @@ -165,6 +165,10 @@ to track upload progress on the server, or do not actually send the files to a s 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 also contains validation to restrict the user from uploading large files - this is useful because storing a large file in memory may make the +browser freeze or even crash. If your application needs to process large files on the client side then consider +[reading the file into an ArrayBuffer](https://developer.mozilla.org/en-US/docs/Web/API/FileReader/readAsArrayBuffer) and processing that piece-by-piece. + diff --git a/src/components/file-input/file-input.stories.tsx b/src/components/file-input/file-input.stories.tsx index c3049fe185..6f179a282b 100644 --- a/src/components/file-input/file-input.stories.tsx +++ b/src/components/file-input/file-input.stories.tsx @@ -120,6 +120,7 @@ export const FileTypeValidation: ComponentStory = () => { }; export const UploadStatusClient: ComponentStory = () => { + const [error, setError] = useState(); const [uploadStatus, setUploadStatus] = useState< FileUploadStatusProps | undefined >(); @@ -136,11 +137,21 @@ export const UploadStatusClient: ComponentStory = () => { const onChange = (files: FileList) => { if (!files.length) { + setError(undefined); removeFile(); return; } // as this is a single file input there will only ever be (at most) 1 file const fileUploaded = files[0]; + + // abandon with error if the file is too big + if (fileUploaded.size > 5 * 1024 * 1024) { + setError("This file is too big to be uploaded - maximum size 5MB"); + return; + } + + setError(undefined); + const fileReader = getReader(); const handleLoad = () => { @@ -215,8 +226,10 @@ export const UploadStatusClient: ComponentStory = () => { return ( ); };