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` +
+ ${this.renderStory()} +
+ Reset + Submit +
+
+ `; + } +} + +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-`.