Skip to content

Commit

Permalink
feat(file-upload): add new api method and sync input
Browse files Browse the repository at this point in the history
  • Loading branch information
segunadebayo committed Jan 1, 2025
1 parent 1aca991 commit 59b04ae
Show file tree
Hide file tree
Showing 8 changed files with 98 additions and 67 deletions.
10 changes: 10 additions & 0 deletions .changeset/nine-oranges-pretend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@zag-js/file-upload": minor
---

- Add support for preventing drop on document when the file upload is used. Use the `preventDropOnDocument` context
property. Set to `true` by default to prevent drop on document.

- Add `api.setClipboardFiles` method to set the files from the clipboard data.

- Fix issue where hidden input isn't synced with the accepted files.
15 changes: 5 additions & 10 deletions .xstate/file-upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const fetchMachine = createMachine({
actions: ["clearRejectedFiles"]
}
},
activities: ["preventDocumentDrop"],
on: {
UPDATE_CONTEXT: {
actions: "updateContext"
Expand All @@ -42,9 +43,7 @@ const fetchMachine = createMachine({
actions: ["openFilePicker"]
},
"DROPZONE.FOCUS": "focused",
"DROPZONE.DRAG_OVER": {
target: "dragging"
}
"DROPZONE.DRAG_OVER": "dragging"
}
},
focused: {
Expand All @@ -56,20 +55,16 @@ const fetchMachine = createMachine({
"DROPZONE.CLICK": {
actions: ["openFilePicker"]
},
"DROPZONE.DRAG_OVER": {
target: "dragging"
}
"DROPZONE.DRAG_OVER": "dragging"
}
},
dragging: {
on: {
"DROPZONE.DROP": {
target: "idle",
actions: ["setFilesFromEvent", "syncInputElement"]
actions: ["setFilesFromEvent"]
},
"DROPZONE.DRAG_LEAVE": {
target: "idle"
}
"DROPZONE.DRAG_LEAVE": "idle"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion examples/svelte-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
"@sveltejs/vite-plugin-svelte": "5.0.1",
"@tsconfig/svelte": "5.0.4",
"@types/form-serialize": "0.7.4",
"svelte": "5.15.0",
"svelte": "5.16.0",
"svelte-check": "4.1.1",
"tslib": "2.7.0",
"typescript": "5.7.2",
Expand Down
12 changes: 12 additions & 0 deletions packages/machines/file-upload/src/file-upload.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ export function connect<T extends PropTypes>(state: State, send: Send, normalize
cb(url)
return () => win.URL.revokeObjectURL(url)
},
setClipboardFiles(dt) {
const items = Array.from(dt?.items ?? [])
const files = items.reduce<File[]>((acc, item) => {
if (item.kind !== "file") return acc
const file = item.getAsFile()
if (!file) return acc
return [...acc, file]
}, [])
if (!files.length) return false
send({ type: "FILES.SET", files })
return true
},

getRootProps() {
return normalize.element({
Expand Down
1 change: 1 addition & 0 deletions packages/machines/file-upload/src/file-upload.dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const dom = createScope({
getItemSizeTextId: (ctx: Ctx, id: string) => ctx.ids?.itemSizeText?.(id) ?? `file:${ctx.id}:item-size:${id}`,
getItemPreviewId: (ctx: Ctx, id: string) => ctx.ids?.itemPreview?.(id) ?? `file:${ctx.id}:item-preview:${id}`,

getRootEl: (ctx: Ctx) => dom.getById<HTMLElement>(ctx, dom.getRootId(ctx)),
getHiddenInputEl: (ctx: Ctx) => dom.getById<HTMLInputElement>(ctx, dom.getHiddenInputId(ctx)),
getDropzoneEl: (ctx: Ctx) => dom.getById(ctx, dom.getDropzoneId(ctx)),
})
63 changes: 43 additions & 20 deletions packages/machines/file-upload/src/file-upload.machine.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createMachine, ref } from "@zag-js/core"
import { raf } from "@zag-js/dom-query"
import { addDomEvent, contains, getEventTarget, raf } from "@zag-js/dom-query"
import { getAcceptAttrString, isFileEqual } from "@zag-js/file-utils"
import { compact } from "@zag-js/utils"
import { callAll, compact } from "@zag-js/utils"
import { dom } from "./file-upload.dom"
import type { FileRejection, MachineContext, MachineState, UserDefinedContext } from "./file-upload.types"
import { getFilesFromEvent } from "./file-upload.utils"
Expand All @@ -12,12 +12,14 @@ export function machine(userContext: UserDefinedContext) {
{
id: "fileupload",
initial: "idle",

context: {
minFileSize: 0,
maxFileSize: Number.POSITIVE_INFINITY,
maxFiles: 1,
allowDrop: true,
accept: ctx.accept,
preventDocumentDrop: true,
...ctx,
acceptedFiles: ref([]),
rejectedFiles: ref([]),
Expand All @@ -28,10 +30,16 @@ export function machine(userContext: UserDefinedContext) {
...ctx.translations,
},
},

computed: {
acceptAttr: (ctx) => getAcceptAttrString(ctx.accept),
multiple: (ctx) => ctx.maxFiles > 1,
},

watch: {
acceptedFiles: ["syncInputElement"],
},

on: {
"FILES.SET": {
actions: ["setFilesFromEvent"],
Expand All @@ -46,6 +54,9 @@ export function machine(userContext: UserDefinedContext) {
actions: ["clearRejectedFiles"],
},
},

activities: ["preventDocumentDrop"],

states: {
idle: {
on: {
Expand All @@ -56,9 +67,7 @@ export function machine(userContext: UserDefinedContext) {
actions: ["openFilePicker"],
},
"DROPZONE.FOCUS": "focused",
"DROPZONE.DRAG_OVER": {
target: "dragging",
},
"DROPZONE.DRAG_OVER": "dragging",
},
},
focused: {
Expand All @@ -70,38 +79,52 @@ export function machine(userContext: UserDefinedContext) {
"DROPZONE.CLICK": {
actions: ["openFilePicker"],
},
"DROPZONE.DRAG_OVER": {
target: "dragging",
},
"DROPZONE.DRAG_OVER": "dragging",
},
},
dragging: {
on: {
"DROPZONE.DROP": {
target: "idle",
actions: ["setFilesFromEvent", "syncInputElement"],
},
"DROPZONE.DRAG_LEAVE": {
target: "idle",
actions: ["setFilesFromEvent"],
},
"DROPZONE.DRAG_LEAVE": "idle",
},
},
},
},
{
activities: {
preventDocumentDrop(ctx) {
if (!ctx.preventDocumentDrop) return
if (!ctx.allowDrop) return
if (ctx.disabled) return
const doc = dom.getDoc(ctx)
const onDragOver = (event: DragEvent) => {
event?.preventDefault()
}
const onDrop = (event: DragEvent) => {
if (contains(dom.getRootEl(ctx), getEventTarget(event))) return
event.preventDefault()
}
return callAll(addDomEvent(doc, "dragover", onDragOver, false), addDomEvent(doc, "drop", onDrop, false))
},
},
actions: {
syncInputElement(ctx) {
const inputEl = dom.getHiddenInputEl(ctx)
if (!inputEl) return
queueMicrotask(() => {
const inputEl = dom.getHiddenInputEl(ctx)
if (!inputEl) return

const win = dom.getWin(ctx)
const dataTransfer = new win.DataTransfer()
const win = dom.getWin(ctx)
const dataTransfer = new win.DataTransfer()

ctx.acceptedFiles.forEach((v) => {
dataTransfer.items.add(v)
})
ctx.acceptedFiles.forEach((v) => {
dataTransfer.items.add(v)
})

inputEl.files = dataTransfer.files
inputEl.files = dataTransfer.files
})
},
openFilePicker(ctx) {
raf(() => {
Expand Down
12 changes: 11 additions & 1 deletion packages/machines/file-upload/src/file-upload.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ interface PublicContext extends LocaleProperties, CommonProperties {
* @default 1
*/
maxFiles: number
/**
* Whether to prevent the drop event on the document
* @default true
*/
preventDocumentDrop?: boolean | undefined
/**
* Function to validate a file
*/
Expand Down Expand Up @@ -225,9 +230,14 @@ export interface MachineApi<T extends PropTypes> {
getFileSize(file: File): string
/**
* Function to get the preview url of a file.
* It returns a function to revoke the url.
* Returns a function to revoke the url.
*/
createFileUrl(file: File, cb: (url: string) => void): VoidFunction
/**
* Function to set the clipboard files.
* Returns `true` if the clipboard data contains files, `false` otherwise.
*/
setClipboardFiles(dt: DataTransfer | null): boolean

getLabelProps(): T["label"]
getRootProps(): T["element"]
Expand Down
50 changes: 15 additions & 35 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 59b04ae

Please sign in to comment.