diff --git a/e2e/smoke-test.spec.ts b/e2e/smoke-test.spec.ts index d59fa14..319d5d1 100644 --- a/e2e/smoke-test.spec.ts +++ b/e2e/smoke-test.spec.ts @@ -9,7 +9,7 @@ test("Renders main header", async ({ page }) => { test("Renders component sections", async ({ page }) => { await page.goto("/") - for (const component of ["Button", "LoadingSpinner", "Input"]) { + for (const component of ["Button", "LoadingSpinner", "Input", "FileInput"]) { await expect(page.getByRole("heading", { name: component, exact: true })).toBeVisible() } }) diff --git a/package-lock.json b/package-lock.json index 146e51f..8217133 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pk-components", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pk-components", - "version": "0.4.0", + "version": "0.5.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/package.json b/package.json index c52bf33..0b375c2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "component", "library" ], - "version": "0.4.0", + "version": "0.5.0", "type": "module", "scripts": { "dev": "vite --open --port 3001", diff --git a/src/dev/AppDev.tsx b/src/dev/AppDev.tsx index 3162f02..386c0a8 100644 --- a/src/dev/AppDev.tsx +++ b/src/dev/AppDev.tsx @@ -1,29 +1,44 @@ import React from "react" import { LoadingSpinner } from "../lib/components/LoadingSpinner" +import { InputSection } from "./demos/Inputs" import { ButtonSection } from "./demos/Buttons" +import { FileInputSection } from "./demos/FileInputs" import "./index.css" -import { InputSection } from "./demos/Inputs" export const AppDev = () => { return ( <>

PK Component Library

-

- Button -

- -

- LoadingSpinner -

-
- -
-

- Input -

-
- -
+
+

+ Button +

+ +
+
+

+ LoadingSpinner +

+
+ +
+
+
+

+ Input +

+
+ +
+
+
+

+ FileInput +

+
+ +
+
) } diff --git a/src/dev/demos/FileInputs.tsx b/src/dev/demos/FileInputs.tsx new file mode 100644 index 0000000..955e02a --- /dev/null +++ b/src/dev/demos/FileInputs.tsx @@ -0,0 +1,38 @@ +import React from "react" +import { FileInput } from "../../lib/components/FileInput" + +export function FileInputSection() { + const onChange = (e: React.ChangeEvent) => { + for (const file of e.target.files || []) { + console.log(file.name) + } + } + + return ( + <> +
+

With label, hint, and file list

+ +
+
+

With label hint and file preview

+ +
+ + ) +} diff --git a/src/dev/index.css b/src/dev/index.css index 0729cb8..bbf8e22 100644 --- a/src/dev/index.css +++ b/src/dev/index.css @@ -12,6 +12,7 @@ color-scheme: light dark; color: rgba(255, 255, 255, 0.87); background-color: #242424; + --background-color: #242424; font-synthesis: none; text-rendering: optimizeLegibility; @@ -47,6 +48,13 @@ hr { width: 100%; } +.section-header { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--background-color); +} + section { display: flex; flex-wrap: wrap; @@ -71,6 +79,7 @@ section { :root { color: #213547; background-color: #ffffff; + --background-color: #ffffff; } } diff --git a/src/lib/components/Button/Button.css b/src/lib/components/Button/Button.css index 0614761..5b4adc0 100644 --- a/src/lib/components/Button/Button.css +++ b/src/lib/components/Button/Button.css @@ -7,18 +7,14 @@ --color-lightness: 100%; --color-saturation: 100%; cursor: pointer; - transition: - background-color 100ms, - border-color 100ms, - color 100ms, - transform 50ms; + transition: var(--pk-hover-transition); border: 1px solid currentColor; - border-radius: 0.375rem; + border-radius: var(--pk-btn-border-radius); font: inherit; letter-spacing: 0.05em; color: hsl(0, var(--color-saturation), var(--color-lightness)); background-color: hsl(var(--hue), var(--saturation), var(--lightness)); - box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3); + box-shadow: var(--pk-box-shadow); position: relative; } @@ -77,7 +73,7 @@ .pk-button-large, .pk-button-block { - padding: 0.75rem 1.25rem; + padding: var(--pk-padding-lg); text-transform: uppercase; font-weight: 700; border-width: 2px; diff --git a/src/lib/components/FileInput/FileInput.css b/src/lib/components/FileInput/FileInput.css new file mode 100644 index 0000000..3a864a9 --- /dev/null +++ b/src/lib/components/FileInput/FileInput.css @@ -0,0 +1,122 @@ +@import "../index.css"; + +.pk-file-input-wrapper { + position: relative; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.pk-file-input-label { + --lightness: 50%; + --saturation: 0%; + display: inline-block; + padding: var(--pk-padding-lg); + border: 2px solid hsl(var(--pk-hue-secondary), var(--saturation), var(--lightness)); + border-radius: var(--pk-btn-border-radius); + box-shadow: var(--pk-box-shadow); + color: hsl(var(--pk-hue-secondary), var(--saturation), var(--lightness)); + background-color: transparent; + cursor: pointer; + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: center; + transition: var(--pk-hover-transition); + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.05em; +} + +.pk-file-input-label:hover { + --lightness: 40%; +} + +.pk-file-input-label:active { + transform: translate3d(0px, 1px, 5px); +} + +.pk-file-input:focus-visible + .pk-file-input-label { + outline-color: hsl(var(--pk-hue-secondary), var(--saturation), var(--lightness)); + outline-width: 2px; + outline-offset: 2px; + outline-style: solid; +} + +.folder-upload-icon { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + fill: hsl(var(--pk-hue-secondary), var(--saturation), var(--lightness)); +} + +.pk-file-input { + width: 0.0001px; + height: 0.0001px; + position: absolute; + -webkit-appearance: none; + appearance: none; + opacity: 0; +} + +.pk-file-input:focus { + outline: none; +} + +.pk-file-input-description { + font-weight: bold; +} + +.pk-file-input-accept { + font-size: 0.875rem; + font-weight: thin; +} + +.pk-file-list { + margin: 0; + padding: 0; + list-style: none; + font-size: 0.875rem; + font-style: italic; +} + +.pk-file-list-item { + position: relative; +} + +.pk-file-list-remove { + padding: 0 0.25rem; +} + +.pk-file-preview-wrapper { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.pk-file-preview { + height: 200px; + position: relative; +} + +.pk-file-preview-image { + max-width: 100%; + height: 100%; + object-fit: cover; +} + +.pk-file-preview-remove { + position: absolute; + top: 0.25rem; + right: 0.25rem; + padding: 0 0.25rem; +} + +.pk-file-list-remove > svg, +.pk-file-preview-remove > svg { + width: 1rem; + height: 1rem; + vertical-align: middle; + margin-top: calc(1ex - 1cap); + fill: currentColor; +} diff --git a/src/lib/components/FileInput/FileInput.model.ts b/src/lib/components/FileInput/FileInput.model.ts new file mode 100644 index 0000000..9aa984c --- /dev/null +++ b/src/lib/components/FileInput/FileInput.model.ts @@ -0,0 +1,7 @@ +import { AllHtmlAttributes } from "../../core-types" + +export type TFileInputProps = AllHtmlAttributes & { + id: string + onChange: React.ChangeEventHandler + fileDisplay?: "list" | "preview" +} diff --git a/src/lib/components/FileInput/FileInput.spec.tsx b/src/lib/components/FileInput/FileInput.spec.tsx new file mode 100644 index 0000000..8526fc2 --- /dev/null +++ b/src/lib/components/FileInput/FileInput.spec.tsx @@ -0,0 +1,162 @@ +import React from "react" +import { fireEvent, render, screen } from "@testing-library/react" +import "@testing-library/jest-dom" +import { vi } from "vitest" + +import { FileInput } from "./FileInput" + +describe("FileInput", () => { + const onChange = vi.fn() + + describe("with basic props", () => { + beforeEach(() => { + render( + , + ) + }) + + it("should render the file input", () => { + const fileInput = screen.getByLabelText("Browse your files") + expect(fileInput).toBeInTheDocument() + }) + + it("should render the label", () => { + const labelText = screen.getByText("Test Upload File") + expect(labelText).toBeInTheDocument() + }) + + it("should render the accept hint text", () => { + const acceptHint = screen.getByText("accepts: .jpg,.png") + expect(acceptHint).toBeInTheDocument() + }) + + it("should be described by the label, hint, and accept", () => { + const fileInput = screen.getByLabelText("Browse your files") + expect(fileInput).toHaveAccessibleDescription("Test Upload File accepts: .jpg,.png") + }) + }) + + describe("with multiple files", () => { + beforeEach(() => { + render() + }) + + it("should display multiple in hint text", () => { + const acceptHint = screen.getByText("accepts multiple: image/*") + expect(acceptHint).toBeInTheDocument() + }) + }) + + describe("when fileDisplay is list", () => { + const testFileName = "test-file.jpg" + + beforeEach(() => { + render() + const fileInput = screen.getByLabelText("Browse your files") + + fireEvent.change(fileInput, { target: { files: [{ name: testFileName }] } }) + }) + + it("should call the onChange function", () => { + expect(onChange).toHaveBeenCalled() + }) + + it("should display the file name", () => { + expect(screen.getByRole("listitem")).toBeInTheDocument() + expect(screen.getByText(testFileName)).toBeInTheDocument() + }) + + it("should display file remove button", () => { + const removeButton = screen.getByRole("button", { name: `Remove ${testFileName}` }) + expect(removeButton).toBeInTheDocument() + }) + + it("should remove the file on button click", () => { + const removeButton = screen.getByRole("button", { name: `Remove ${testFileName}` }) + fireEvent.click(removeButton) + + expect(screen.queryByText(testFileName)).not.toBeInTheDocument() + }) + + it("should render no feedback when no file is selected", () => { + const fileInput = screen.getByLabelText("Browse your files") + fireEvent.change(fileInput, { target: { files: null } }) + + expect(screen.queryByRole("listitem")).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: /^Remove/ })).not.toBeInTheDocument() + }) + + // it("should be described by the file list", () => { + // const fileInput = screen.getByLabelText("Browse your files") + // expect(fileInput).toHaveAccessibleDescription(testFileName) + // }) + }) + + describe("when fileDisplay is preview", () => { + const testFileName = "test-file.jpg" + const file = new File([""], testFileName, { type: "image/jpg" }) + + beforeEach(() => { + render() + + const fileInput = screen.getByLabelText("Browse your files") + fireEvent.change(fileInput, { target: { files: [file] } }) + }) + + beforeAll(() => { + vi.stubGlobal("URL", { createObjectURL: vi.fn().mockReturnValue(new Image().src) }) + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + it("should call the onChange function", () => { + expect(onChange).toHaveBeenCalled() + }) + + it("should display the file preview", () => { + const imageElement = screen.getByRole("img", { name: testFileName }) + + expect(imageElement).toBeInTheDocument() + expect(URL.createObjectURL).toHaveBeenCalledWith(file) + }) + + it("should display file remove button", () => { + const removeButton = screen.getByRole("button", { name: `Remove ${testFileName}` }) + expect(removeButton).toBeInTheDocument() + }) + + it("should remove the file preview on button click", () => { + const imageElement = screen.getByRole("img", { name: testFileName }) + const removeButton = screen.getByRole("button", { name: `Remove ${testFileName}` }) + fireEvent.click(removeButton) + + expect(imageElement).not.toBeInTheDocument() + }) + + it("should render no feedback when no file is selected", () => { + const fileInput = screen.getByLabelText("Browse your files") + fireEvent.change(fileInput, { target: { files: null } }) + + expect(screen.queryByRole("img")).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: /^Remove/ })).not.toBeInTheDocument() + }) + + // it("should be described by the preview images", () => { + // const fileInput = screen.getByLabelText("Browse your files") + // expect(fileInput).toHaveAccessibleDescription(testFileName) + // }) + }) + + describe("when required", () => { + beforeEach(() => { + render() + }) + + it("should render a required label", () => { + const labelText = screen.getByText("test label") + expect(labelText).toHaveClass("label-required") + }) + }) +}) diff --git a/src/lib/components/FileInput/FileInput.tsx b/src/lib/components/FileInput/FileInput.tsx new file mode 100644 index 0000000..04714e8 --- /dev/null +++ b/src/lib/components/FileInput/FileInput.tsx @@ -0,0 +1,135 @@ +import { forwardRef, useCallback, useMemo, useState } from "react" +import { TFileInputProps } from "./FileInput.model" +import "./FileInput.css" +import { Button } from "../Button" + +export const FileInput = forwardRef((props, ref) => { + const { id, onChange, label, accept, required, fileDisplay, className = "", ...rest } = props + + const [fileNames, setFileNames] = useState([]) + const [filePreview, setFilePreview] = useState>(new Map()) + + const internalOnChange = useCallback( + (e: React.ChangeEvent) => { + if (fileDisplay === "list") { + const newFilesNames = e.target.files + ? Array.from(e.target.files).map(file => file.name) + : [] + setFileNames(newFilesNames) + } + if (fileDisplay === "preview") { + const filePreviewMap = new Map() + if (e.target.files) { + Array.from(e.target.files).forEach(file => { + const src = URL.createObjectURL(file) + filePreviewMap.set(file.name, src) + }) + } + + setFilePreview(filePreviewMap) + } + onChange(e) + }, + [onChange, fileDisplay], + ) + + const labelClassNames = useMemo(() => { + return `pk-file-input-description ${required ? "label-required" : ""}` + }, [required]) + + const removeFile = useCallback( + (fileName: string) => { + if (fileDisplay === "preview") { + setFilePreview(prev => { + prev.delete(fileName) + return new Map(prev) + }) + } + if (fileDisplay === "list") { + setFileNames(prev => prev.filter(name => name !== fileName)) + } + }, + [fileDisplay], + ) + + return ( +
+ + {label} + + {accept ? ( + {`accepts${rest.multiple ? " multiple" : ""}: ${accept}`} + ) : null} + + + + {fileDisplay === "list" ? ( +
    + {fileNames.map(fileName => ( +
  • + + {fileName} +
  • + ))} +
+ ) : fileDisplay === "preview" ? ( +
+ {[...filePreview.entries()].map(([fileName, preview]) => ( +
+ + {fileName} +
+ ))} +
+ ) : null} +
+ ) +}) + +function FileSvg() { + return ( + + ) +} + +function CloseIcon() { + return ( + + ) +} diff --git a/src/lib/components/FileInput/README.md b/src/lib/components/FileInput/README.md new file mode 100644 index 0000000..e23100a --- /dev/null +++ b/src/lib/components/FileInput/README.md @@ -0,0 +1,32 @@ +### FileInput Component + +#### Description + +A styled file input. Hides the native browser display and substitutes a button-like label which triggers the input on click. Optionally displays an additional label text, and displays the `accept` attribute as hint text. The `fileDisplay` prop optionally shows a file name or image preview of the selected file or files. The component forwards a Ref object to the input for clearing the inputs value after form submission. + +#### Props + +| Prop Name | Type | Required | Default | Description | +| ------------------ | -------------------------------------------- | -------- | ----------- | ------------------------------------------------------------------ | +| `[htmlAttributes]` | `React.AllHTMLAttributes` | No | `undefined` | Any valid HTML attribute for the element type | +| `className` | `string` | No | `undefined` | Additional class names to apply to the element. | +| `id` | `string` | Yes | `undefined` | The unique ID for applying to the accessible label | +| `onChange` | `React.ChangeEventHandler` | Yes | `undefined` | The handler for the input's change events | +| `fileDisplay` | `"list" \| "preview"` | No | `undefined` | Optionally present the selected files as a list or image previews. | +| `ref` | `React.ForwardedRef` | No | `undefined` | A ref object attached to the input | + +#### Example + +```tsx +import { FileInput } from "pk-components" + +function YourComponent() { + const onChange = (e: React.ChangeEvent) => { + console.log(e.target.files) + } + + return +} +``` + +[Live Demo](https://psikai.github.io/pk-components#FileInput) diff --git a/src/lib/components/FileInput/index.ts b/src/lib/components/FileInput/index.ts new file mode 100644 index 0000000..930db33 --- /dev/null +++ b/src/lib/components/FileInput/index.ts @@ -0,0 +1,2 @@ +export { FileInput } from "./FileInput" +export type { TFileInputProps } from "./FileInput.model" diff --git a/src/lib/components/Input/Input.css b/src/lib/components/Input/Input.css index e58f1cd..64b833f 100644 --- a/src/lib/components/Input/Input.css +++ b/src/lib/components/Input/Input.css @@ -74,21 +74,15 @@ font-weight: bold; } -.pk-input-label.label-required::before { - content: "*"; - color: hsl(var(--pk-hue-danger), 100%, 40%); - padding-right: 0.25rem; -} - .pk-input-hint { - font-size: 0.9rem; + font-size: 0.875rem; font-weight: thin; } .pk-input-feedback-default, .pk-input-feedback-error, .pk-input-feedback-clean { - font-size: 0.9rem; + font-size: 0.875rem; font-style: italic; } diff --git a/src/lib/components/index.css b/src/lib/components/index.css index 4d5838e..ad1354f 100644 --- a/src/lib/components/index.css +++ b/src/lib/components/index.css @@ -1,7 +1,20 @@ :root { + /* Main colors */ --pk-hue-primary: 200; --pk-hue-secondary: 0; --pk-hue-warning: 30; --pk-hue-danger: 0; --pk-hue-success: 120; + + /* Global Style Dimensions */ + --pk-btn-border-radius: 0.375rem; + --pk-hover-transition: background-color 100ms, border-color 100ms, color 100ms, transform 50ms; + --pk-box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3); + --pk-padding-lg: 0.75rem 1.25rem; +} + +.label-required::before { + content: "*"; + color: hsl(var(--pk-hue-danger), 100%, 40%); + padding-right: 0.25rem; } diff --git a/src/lib/index.tsx b/src/lib/index.tsx index 7d5e164..758d1b2 100644 --- a/src/lib/index.tsx +++ b/src/lib/index.tsx @@ -1,3 +1,4 @@ export { Button } from "./components/Button" export { LoadingSpinner } from "./components/LoadingSpinner" export { Input } from "./components/Input" +export { FileInput } from "./components/FileInput"