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