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
-
-
-
- LoadingSpinner
-
-
-
-
+
+
+
+
+
+
+ LoadingSpinner
+
+
+
+
+
+
+
+
+
+
+
>
)
}
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