diff --git a/frontend/src/components/ui/file-input.ts b/frontend/src/components/ui/file-input.ts
new file mode 100644
index 0000000000..58252c12cb
--- /dev/null
+++ b/frontend/src/components/ui/file-input.ts
@@ -0,0 +1,241 @@
+import { localized } from "@lit/localize";
+import clsx from "clsx";
+import { html, nothing, type PropertyValues } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+import { repeat } from "lit/directives/repeat.js";
+import { without } from "lodash/fp";
+
+import type {
+ BtrixFileChangeEvent,
+ BtrixFileRemoveEvent,
+} from "./file-list/events";
+
+import { TailwindElement } from "@/classes/TailwindElement";
+import { FormControl } from "@/mixins/FormControl";
+import { tw } from "@/utils/tailwind";
+
+import "@/components/ui/file-list";
+
+const droppingClass = tw`bg-slate-100`;
+
+/**
+ * Allow attaching one or more files.
+ *
+ * @fires btrix-change
+ * @fires btrix-remove
+ */
+@customElement("btrix-file-input")
+@localized()
+export class FileInput extends FormControl(TailwindElement) {
+ /**
+ * Form control name, if used as a form control
+ */
+ @property({ type: String })
+ name?: string;
+
+ /**
+ * Form control label, if used as a form control
+ */
+ @property({ type: String })
+ label?: string;
+
+ /**
+ * Specify which file types are allowed
+ */
+ @property({ type: String })
+ accept?: HTMLInputElement["accept"];
+
+ /**
+ * Enable selecting more than one file
+ */
+ @property({ type: Boolean })
+ multiple?: HTMLInputElement["multiple"];
+
+ /**
+ * Enable dragging files into drop zone
+ */
+ @property({ type: Boolean })
+ drop = false;
+
+ @state()
+ private files: File[] = [];
+
+ @query("#dropzone")
+ private readonly dropzone?: HTMLElement | null;
+
+ @query("input[type='file']")
+ private readonly input?: HTMLInputElement | null;
+
+ formResetCallback() {
+ this.files = [];
+
+ if (this.input) {
+ this.input.value = "";
+ }
+ }
+
+ protected willUpdate(changedProperties: PropertyValues): void {
+ if (changedProperties.has("files")) {
+ this.syncFormValue();
+ }
+ }
+
+ private syncFormValue() {
+ const formControlName = this.name;
+
+ if (!formControlName) return;
+
+ // `ElementInternals["setFormValue"]` doesn't support `FileList` yet,
+ // construct `FormData` instead
+ const formData = new FormData();
+
+ this.files.forEach((file) => {
+ formData.append(formControlName, file);
+ });
+
+ this.setFormValue(formData);
+ }
+
+ render() {
+ return html`
+ ${this.label
+ ? html``
+ : nothing}
+ ${this.files.length ? this.renderFiles() : this.renderInput()}
+ `;
+ }
+
+ private readonly renderInput = () => {
+ return html`
+
this.dropzone?.classList.add(droppingClass)
+ : undefined}
+ @dragleave=${this.drop
+ ? () => this.dropzone?.classList.remove(droppingClass)
+ : undefined}
+ @click=${() => this.input?.click()}
+ role="button"
+ dropzone="copy"
+ aria-dropeffect="copy"
+ >
+
{
+ const files = this.input?.files;
+
+ if (files) {
+ void this.handleChange(files);
+ }
+ }}
+ />
+
+
+
+
+ `;
+ };
+
+ private readonly renderFiles = () => {
+ return html`
+ {
+ this.files = without([e.detail.item])(this.files);
+ }}
+ >
+ ${repeat(
+ this.files,
+ (file) => file.name,
+ (file) => html`
+
+ `,
+ )}
+
+ `;
+ };
+
+ private readonly onDrop = (e: DragEvent) => {
+ e.preventDefault();
+
+ this.dropzone?.classList.remove(droppingClass);
+
+ const files = e.dataTransfer?.files;
+
+ if (files) {
+ const list = new DataTransfer();
+
+ if (this.multiple) {
+ [...files].forEach((file) => {
+ if (this.valid(file)) {
+ list.items.add(file);
+ }
+ });
+ } else {
+ const file = files[0];
+
+ if (this.valid(file)) {
+ list.items.add(file);
+ }
+ }
+
+ if (list.items.length) {
+ void this.handleChange(list.files);
+ } else {
+ console.debug("none valid:", files);
+ }
+ } else {
+ console.debug("no files dropped");
+ }
+ };
+
+ private readonly onDragover = (e: DragEvent) => {
+ e.preventDefault();
+
+ if (e.dataTransfer) {
+ this.dropzone?.classList.add(droppingClass);
+ e.dataTransfer.dropEffect = "copy";
+ }
+ };
+
+ /**
+ * @TODO More complex validation based on `accept`
+ */
+ private valid(file: File) {
+ if (!this.accept) return true;
+
+ return this.accept.split(",").some((accept) => {
+ if (accept.startsWith(".")) {
+ return file.name.endsWith(accept.trim());
+ }
+
+ return new RegExp(accept.trim().replace("*", ".*")).test(file.type);
+ });
+ }
+
+ private async handleChange(fileList: FileList) {
+ this.files = [...fileList];
+
+ await this.updateComplete;
+
+ this.dispatchEvent(
+ new CustomEvent("btrix-change", {
+ detail: { value: this.files },
+ composed: true,
+ bubbles: true,
+ }),
+ );
+ }
+}
diff --git a/frontend/src/components/ui/file-list/events.ts b/frontend/src/components/ui/file-list/events.ts
new file mode 100644
index 0000000000..233488733f
--- /dev/null
+++ b/frontend/src/components/ui/file-list/events.ts
@@ -0,0 +1,5 @@
+import type { BtrixChangeEvent } from "@/events/btrix-change";
+import type { BtrixRemoveEvent } from "@/events/btrix-remove";
+
+export type BtrixFileRemoveEvent = BtrixRemoveEvent;
+export type BtrixFileChangeEvent = BtrixChangeEvent;
diff --git a/frontend/src/components/ui/file-list.ts b/frontend/src/components/ui/file-list/file-list-item.ts
similarity index 66%
rename from frontend/src/components/ui/file-list.ts
rename to frontend/src/components/ui/file-list/file-list-item.ts
index 1ff8e37124..034f3940dc 100644
--- a/frontend/src/components/ui/file-list.ts
+++ b/frontend/src/components/ui/file-list/file-list-item.ts
@@ -1,26 +1,19 @@
import { localized, msg } from "@lit/localize";
import { css, html } from "lit";
-import {
- customElement,
- property,
- queryAssignedElements,
-} from "lit/decorators.js";
+import { customElement, property } from "lit/decorators.js";
+
+import type { BtrixFileRemoveEvent } from "./events";
-import { BtrixElement } from "@/classes/BtrixElement";
import { TailwindElement } from "@/classes/TailwindElement";
+import { LocalizeController } from "@/controllers/localize";
import { truncate } from "@/utils/css";
-type FileRemoveDetail = {
- file: File;
-};
-export type FileRemoveEvent = CustomEvent;
-
/**
- * @event on-remove FileRemoveEvent
+ * @event btrix-remove
*/
@customElement("btrix-file-list-item")
@localized()
-export class FileListItem extends BtrixElement {
+export class FileListItem extends TailwindElement {
static styles = [
truncate,
css`
@@ -75,6 +68,8 @@ export class FileListItem extends BtrixElement {
@property({ type: Boolean })
progressIndeterminate?: boolean;
+ readonly localize = new LocalizeController(this);
+
render() {
if (!this.file) return;
return html`
@@ -117,50 +112,13 @@ export class FileListItem extends BtrixElement {
if (!this.file) return;
await this.updateComplete;
this.dispatchEvent(
- new CustomEvent
("on-remove", {
+ new CustomEvent("btrix-remove", {
detail: {
- file: this.file,
+ item: this.file,
},
+ composed: true,
+ bubbles: true,
}),
);
};
}
-
-@customElement("btrix-file-list")
-export class FileList extends TailwindElement {
- static styles = [
- css`
- ::slotted(btrix-file-list-item) {
- --border: 1px solid var(--sl-panel-border-color);
- --item-border-top: var(--border);
- --item-border-left: var(--border);
- --item-border-right: var(--border);
- --item-border-bottom: var(--border);
- --item-box-shadow: var(--sl-shadow-x-small);
- --item-border-radius: var(--sl-border-radius-medium);
- display: block;
- }
-
- ::slotted(btrix-file-list-item:not(:last-of-type)) {
- margin-bottom: var(--sl-spacing-x-small);
- }
- `,
- ];
-
- @queryAssignedElements({ selector: "btrix-file-list-item" })
- listItems!: HTMLElement[];
-
- render() {
- return html`
-
-
`;
- }
-
- private handleSlotchange() {
- this.listItems.map((el) => {
- if (!el.attributes.getNamedItem("role")) {
- el.setAttribute("role", "listitem");
- }
- });
- }
-}
diff --git a/frontend/src/components/ui/file-list/file-list.ts b/frontend/src/components/ui/file-list/file-list.ts
new file mode 100644
index 0000000000..9cb6d32673
--- /dev/null
+++ b/frontend/src/components/ui/file-list/file-list.ts
@@ -0,0 +1,43 @@
+import { css, html } from "lit";
+import { customElement, queryAssignedElements } from "lit/decorators.js";
+
+import { TailwindElement } from "@/classes/TailwindElement";
+
+@customElement("btrix-file-list")
+export class FileList extends TailwindElement {
+ static styles = [
+ css`
+ ::slotted(btrix-file-list-item) {
+ --border: 1px solid var(--sl-panel-border-color);
+ --item-border-top: var(--border);
+ --item-border-left: var(--border);
+ --item-border-right: var(--border);
+ --item-border-bottom: var(--border);
+ --item-box-shadow: var(--sl-shadow-x-small);
+ --item-border-radius: var(--sl-border-radius-medium);
+ display: block;
+ }
+
+ ::slotted(btrix-file-list-item:not(:last-of-type)) {
+ margin-bottom: var(--sl-spacing-x-small);
+ }
+ `,
+ ];
+
+ @queryAssignedElements({ selector: "btrix-file-list-item" })
+ listItems!: HTMLElement[];
+
+ render() {
+ return html`
+
+
`;
+ }
+
+ private handleSlotchange() {
+ this.listItems.map((el) => {
+ if (!el.attributes.getNamedItem("role")) {
+ el.setAttribute("role", "listitem");
+ }
+ });
+ }
+}
diff --git a/frontend/src/components/ui/file-list/index.ts b/frontend/src/components/ui/file-list/index.ts
new file mode 100644
index 0000000000..73cf8db20a
--- /dev/null
+++ b/frontend/src/components/ui/file-list/index.ts
@@ -0,0 +1,4 @@
+import "./file-list";
+import "./file-list-item";
+
+export type { BtrixFileRemoveEvent as FileRemoveEvent } from "./events";
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
index d053ee38db..d67d0d68ce 100644
--- a/frontend/src/components/ui/index.ts
+++ b/frontend/src/components/ui/index.ts
@@ -18,6 +18,7 @@ import("./copy-button");
import("./copy-field");
import("./data-grid");
import("./details");
+import("./file-input");
import("./file-list");
import("./format-date");
import("./inline-input");
diff --git a/frontend/src/events/btrix-remove.ts b/frontend/src/events/btrix-remove.ts
new file mode 100644
index 0000000000..b06d8cf25f
--- /dev/null
+++ b/frontend/src/events/btrix-remove.ts
@@ -0,0 +1,7 @@
+export type BtrixRemoveEvent = CustomEvent<{ item: T }>;
+
+declare global {
+ interface GlobalEventHandlersEventMap {
+ "btrix-remove": BtrixRemoveEvent;
+ }
+}
diff --git a/frontend/src/events/index.ts b/frontend/src/events/index.ts
deleted file mode 100644
index ca58e8d0ae..0000000000
--- a/frontend/src/events/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-import "./btrix-change";
-import "./btrix-input";
diff --git a/frontend/src/features/archived-items/file-uploader.ts b/frontend/src/features/archived-items/file-uploader.ts
index eb7406680f..6bf053453b 100644
--- a/frontend/src/features/archived-items/file-uploader.ts
+++ b/frontend/src/features/archived-items/file-uploader.ts
@@ -10,6 +10,7 @@ import queryString from "query-string";
import { BtrixElement } from "@/classes/BtrixElement";
import type { FileRemoveEvent } from "@/components/ui/file-list";
+import type { BtrixFileChangeEvent } from "@/components/ui/file-list/events";
import type {
TagInputEvent,
Tags,
@@ -182,45 +183,26 @@ export class FileUploader extends BtrixElement {
}
private renderFiles() {
- if (!this.fileList.length) {
- return html`
-
-
-
- ${msg("Select a .wacz file to upload")}
-
-
- `;
- }
-
return html`
-
- ${Array.from(this.fileList).map(
- (file) =>
- html``,
- )}
-
+ {
+ this.fileList = e.detail.value;
+ }}
+ @btrix-remove=${this.handleRemoveFile}
+ >
+
+ (e.target as SlButton).parentElement?.click()}
+ >${msg("Browse Files")}
+
+
+ ${msg("Select a .wacz file to upload")}
+
+
`;
}
@@ -278,7 +260,7 @@ export class FileUploader extends BtrixElement {
html``,
)}
@@ -319,7 +301,7 @@ export class FileUploader extends BtrixElement {
html``,
)}
@@ -335,7 +317,7 @@ export class FileUploader extends BtrixElement {
private readonly handleRemoveFile = (e: FileRemoveEvent) => {
this.cancelUpload();
- const idx = this.fileList.indexOf(e.detail.file);
+ const idx = this.fileList.indexOf(e.detail.item);
if (idx === -1) return;
this.fileList = [
...this.fileList.slice(0, idx),
diff --git a/frontend/src/mixins/FormControl.ts b/frontend/src/mixins/FormControl.ts
index 475cded9e0..751fca11f9 100644
--- a/frontend/src/mixins/FormControl.ts
+++ b/frontend/src/mixins/FormControl.ts
@@ -9,6 +9,10 @@ export const FormControl = >(superClass: T) =>
static formAssociated = true;
readonly #internals: ElementInternals;
+ get form() {
+ return this.#internals.form;
+ }
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: any[]) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
diff --git a/frontend/src/stories/components/FileInput.stories.ts b/frontend/src/stories/components/FileInput.stories.ts
new file mode 100644
index 0000000000..e6015e09b2
--- /dev/null
+++ b/frontend/src/stories/components/FileInput.stories.ts
@@ -0,0 +1,89 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+
+import { fileInputFormDecorator } from "./decorators/fileInputForm";
+import { renderComponent, type RenderProps } from "./FileInput";
+
+const meta = {
+ title: "Components/File Input",
+ component: "btrix-file-input",
+ tags: ["autodocs"],
+ render: renderComponent,
+ decorators: (story) => html` ${story()}
`,
+ argTypes: {
+ content: { table: { disable: true } },
+ },
+ args: {
+ content: html`
+ Select File
+ `,
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Basic: Story = {
+ args: {},
+};
+
+export const Multiple: Story = {
+ args: {
+ multiple: true,
+ content: html`
+ Select Files
+ `,
+ },
+};
+
+export const DropZone: Story = {
+ args: {
+ drop: true,
+ content: html`
+
+ Drag file here or
+
+
+ `,
+ },
+};
+
+/**
+ * Open your browser console log to see what value gets submitted.
+ */
+export const FormControl: Story = {
+ decorators: [fileInputFormDecorator],
+ args: {
+ ...DropZone.args,
+ multiple: true,
+ },
+};
+
+/**
+ * When dragging and dropping, files that are not acceptable are filtered out.
+ */
+export const FileFormat: Story = {
+ decorators: [fileInputFormDecorator],
+ args: {
+ label: "Attach a Document",
+ drop: true,
+ multiple: true,
+ accept: ".txt,.doc,.pdf",
+ content: html`
+
+ Drag document here or
+
+ to upload
+
+ TXT, DOC, or PDF
+ `,
+ },
+};
diff --git a/frontend/src/stories/components/FileInput.ts b/frontend/src/stories/components/FileInput.ts
new file mode 100644
index 0000000000..37d0bd4723
--- /dev/null
+++ b/frontend/src/stories/components/FileInput.ts
@@ -0,0 +1,33 @@
+import { html, type TemplateResult } from "lit";
+import { ifDefined } from "lit/directives/if-defined.js";
+
+import { formControlName } from "./decorators/fileInputForm";
+
+import type { FileInput } from "@/components/ui/file-input";
+
+import "@/components/ui/file-input";
+
+export type RenderProps = FileInput & {
+ content: TemplateResult;
+};
+
+export const renderComponent = ({
+ label,
+ accept,
+ multiple,
+ drop,
+ content,
+}: Partial) => {
+ return html`
+
+ ${content}
+
+ `;
+};
diff --git a/frontend/src/stories/components/FileList.stories.ts b/frontend/src/stories/components/FileList.stories.ts
new file mode 100644
index 0000000000..e099cd1cef
--- /dev/null
+++ b/frontend/src/stories/components/FileList.stories.ts
@@ -0,0 +1,27 @@
+import type { Meta, StoryObj } from "@storybook/web-components";
+import { html } from "lit";
+
+import { renderComponent, type RenderProps } from "./FileList";
+
+const meta = {
+ title: "Components/File List",
+ component: "btrix-file-list",
+ subcomponents: { FileListItem: "btrix-file-list-item" },
+ tags: ["autodocs"],
+ render: renderComponent,
+ decorators: (story) => html` ${story()}
`,
+ argTypes: {},
+ args: {
+ files: [
+ new File([new Blob()], "file.txt"),
+ new File([new Blob()], "file-2.txt"),
+ ],
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Basic: Story = {
+ args: {},
+};
diff --git a/frontend/src/stories/components/FileList.ts b/frontend/src/stories/components/FileList.ts
new file mode 100644
index 0000000000..bb4b346c75
--- /dev/null
+++ b/frontend/src/stories/components/FileList.ts
@@ -0,0 +1,17 @@
+import { html } from "lit";
+
+import "@/components/ui/file-list";
+
+export type RenderProps = { files: File[] };
+
+export const renderComponent = ({ files }: Partial) => {
+ return html`
+
+ ${files?.map(
+ (file) => html`
+
+ `,
+ )}
+
+ `;
+};
diff --git a/frontend/src/stories/components/decorators/fileInputForm.ts b/frontend/src/stories/components/decorators/fileInputForm.ts
new file mode 100644
index 0000000000..5aa7a11399
--- /dev/null
+++ b/frontend/src/stories/components/decorators/fileInputForm.ts
@@ -0,0 +1,54 @@
+import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
+import type { StoryContext, StoryFn } from "@storybook/web-components";
+import { html } from "lit";
+import { customElement } from "lit/decorators.js";
+
+import type { RenderProps } from "../FileInput";
+
+import { TailwindElement } from "@/classes/TailwindElement";
+
+export const formControlName = "storybook--file-input-form-example";
+
+@customElement("btrix-storybook-file-input-form")
+export class StorybookFileInputForm extends TailwindElement {
+ public renderStory!: () => ReturnType;
+
+ render() {
+ const onSubmit = (e: SubmitEvent) => {
+ e.preventDefault();
+
+ const form = e.target as HTMLFormElement;
+ const value = serialize(form);
+
+ console.log("form value:", value, form.elements);
+ };
+
+ return html`
+
+ `;
+ }
+}
+
+export function fileInputFormDecorator(
+ story: StoryFn,
+ context: StoryContext,
+) {
+ return html`
+ {
+ return story(
+ {
+ ...context.args,
+ },
+ context,
+ );
+ }}
+ >
+ `;
+}
diff --git a/frontend/src/types/events.d.ts b/frontend/src/types/events.d.ts
index e3eb5847a3..7b08caaa2d 100644
--- a/frontend/src/types/events.d.ts
+++ b/frontend/src/types/events.d.ts
@@ -5,8 +5,6 @@ import { type NotifyEventMap } from "@/controllers/notify";
import { type UserGuideEventMap } from "@/index";
import { type AuthEventMap } from "@/utils/AuthService";
-import "@/events";
-
/**
* Declare custom events here so that typescript can find them.
* Custom event names should be prefixed with `btrix-`.