Skip to content

Commit 33393b7

Browse files
committed
refactor: file upload
1 parent b0404e8 commit 33393b7

File tree

6 files changed

+91
-26
lines changed

6 files changed

+91
-26
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@zag-js/file-upload": minor
3+
---
4+
5+
- Add `readOnly` prop to prevent file modifications while keeping component visually active
6+
- Add `maxFilesReached` and `remainingFiles` to exposed API
7+
- Fix item element IDs to use `name-size` combination for uniqueness (prevents ID collisions with same-named files)

packages/machines/file-upload/src/file-upload.connect.ts

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,34 +25,45 @@ export function connect<T extends PropTypes>(
2525
): FileUploadApi<T> {
2626
const { state, send, prop, computed, scope, context } = service
2727
const disabled = !!prop("disabled")
28+
const readOnly = !!prop("readOnly")
2829
const required = !!prop("required")
2930
const allowDrop = prop("allowDrop")
3031
const translations = prop("translations")
3132

3233
const dragging = state.matches("dragging")
3334
const focused = state.matches("focused") && !disabled
3435

36+
const acceptedFiles = context.get("acceptedFiles")
37+
const maxFiles = prop("maxFiles")
38+
3539
return {
3640
dragging,
3741
focused,
38-
disabled: !!disabled,
42+
disabled,
43+
readOnly,
3944
transforming: context.get("transforming"),
45+
maxFilesReached: acceptedFiles.length >= maxFiles,
46+
remainingFiles: Math.max(0, maxFiles - acceptedFiles.length),
4047
openFilePicker() {
41-
if (disabled) return
48+
if (disabled || readOnly) return
4249
send({ type: "OPEN" })
4350
},
4451
deleteFile(file, type = DEFAULT_ITEM_TYPE) {
52+
if (disabled || readOnly) return
4553
send({ type: "FILE.DELETE", file, itemType: type })
4654
},
47-
acceptedFiles: context.get("acceptedFiles"),
55+
acceptedFiles,
4856
rejectedFiles: context.get("rejectedFiles"),
4957
setFiles(files) {
58+
if (disabled || readOnly) return
5059
send({ type: "FILES.SET", files, count: files.length })
5160
},
5261
clearRejectedFiles() {
62+
if (disabled || readOnly) return
5363
send({ type: "REJECTED_FILES.CLEAR" })
5464
},
5565
clearFiles() {
66+
if (disabled || readOnly) return
5667
send({ type: "FILES.CLEAR" })
5768
},
5869
getFileSize(file) {
@@ -65,7 +76,7 @@ export function connect<T extends PropTypes>(
6576
return () => win.URL.revokeObjectURL(url)
6677
},
6778
setClipboardFiles(dt) {
68-
if (disabled) return false
79+
if (disabled || readOnly) return false
6980
const items = Array.from(dt?.items ?? [])
7081
const files = items.reduce<File[]>((acc, item) => {
7182
if (item.kind !== "file") return acc
@@ -84,6 +95,7 @@ export function connect<T extends PropTypes>(
8495
dir: prop("dir"),
8596
id: dom.getRootId(scope),
8697
"data-disabled": dataAttr(disabled),
98+
"data-readonly": dataAttr(readOnly),
8799
"data-dragging": dataAttr(dragging),
88100
})
89101
},
@@ -93,15 +105,17 @@ export function connect<T extends PropTypes>(
93105
...parts.dropzone.attrs,
94106
dir: prop("dir"),
95107
id: dom.getDropzoneId(scope),
96-
tabIndex: disabled || props.disableClick ? undefined : 0,
108+
tabIndex: disabled || readOnly || props.disableClick ? undefined : 0,
97109
role: props.disableClick ? "application" : "button",
98110
"aria-label": translations.dropzone,
99111
"aria-disabled": disabled,
112+
"aria-readonly": readOnly,
100113
"data-invalid": dataAttr(prop("invalid")),
101114
"data-disabled": dataAttr(disabled),
115+
"data-readonly": dataAttr(readOnly),
102116
"data-dragging": dataAttr(dragging),
103117
onKeyDown(event) {
104-
if (disabled) return
118+
if (disabled || readOnly) return
105119
if (event.defaultPrevented) return
106120

107121
const target = getEventTarget<HTMLElement>(event)
@@ -113,7 +127,7 @@ export function connect<T extends PropTypes>(
113127
send({ type: "DROPZONE.CLICK", src: "keydown" })
114128
},
115129
onClick(event) {
116-
if (disabled) return
130+
if (disabled || readOnly) return
117131
if (event.defaultPrevented) return
118132
if (props.disableClick) return
119133

@@ -128,7 +142,7 @@ export function connect<T extends PropTypes>(
128142
send({ type: "DROPZONE.CLICK" })
129143
},
130144
onDragOver(event) {
131-
if (disabled) return
145+
if (disabled || readOnly) return
132146
if (!allowDrop) return
133147
event.preventDefault()
134148
event.stopPropagation()
@@ -143,31 +157,31 @@ export function connect<T extends PropTypes>(
143157
send({ type: "DROPZONE.DRAG_OVER", count })
144158
},
145159
onDragLeave(event) {
146-
if (disabled) return
160+
if (disabled || readOnly) return
147161
if (!allowDrop) return
148162
if (contains(event.currentTarget, event.relatedTarget)) return
149163
send({ type: "DROPZONE.DRAG_LEAVE" })
150164
},
151165
onDrop(event) {
152-
if (disabled) return
166+
if (disabled || readOnly) return
153167
if (allowDrop) {
154168
event.preventDefault()
155169
event.stopPropagation()
156170
}
157171

158172
const hasFiles = isEventWithFiles(event)
159-
if (disabled || !hasFiles) return
173+
if (!hasFiles) return
160174

161175
getFileEntries(event.dataTransfer.items, prop("directory")).then((files) => {
162176
send({ type: "DROPZONE.DROP", files: flatArray(files) })
163177
})
164178
},
165179
onFocus() {
166-
if (disabled) return
180+
if (disabled || readOnly) return
167181
send({ type: "DROPZONE.FOCUS" })
168182
},
169183
onBlur() {
170-
if (disabled) return
184+
if (disabled || readOnly) return
171185
send({ type: "DROPZONE.BLUR" })
172186
},
173187
})
@@ -178,12 +192,13 @@ export function connect<T extends PropTypes>(
178192
...parts.trigger.attrs,
179193
dir: prop("dir"),
180194
id: dom.getTriggerId(scope),
181-
disabled,
195+
disabled: disabled || readOnly,
182196
"data-disabled": dataAttr(disabled),
197+
"data-readonly": dataAttr(readOnly),
183198
"data-invalid": dataAttr(prop("invalid")),
184199
type: "button",
185200
onClick(event) {
186-
if (disabled) return
201+
if (disabled || readOnly) return
187202
// if trigger is wrapped within the dropzone, stop propagation to avoid double opening
188203
if (contains(dom.getDropzoneEl(scope), event.currentTarget)) {
189204
event.stopPropagation()
@@ -197,7 +212,7 @@ export function connect<T extends PropTypes>(
197212
return normalize.input({
198213
id: dom.getHiddenInputId(scope),
199214
tabIndex: -1,
200-
disabled,
215+
disabled: disabled || readOnly,
201216
type: "file",
202217
required: prop("required"),
203218
capture: prop("capture"),
@@ -211,7 +226,7 @@ export function connect<T extends PropTypes>(
211226
event.currentTarget.value = ""
212227
},
213228
onInput(event) {
214-
if (disabled) return
229+
if (disabled || readOnly) return
215230
const { files } = event.currentTarget
216231
send({ type: "FILE.SELECT", files: files ? Array.from(files) : [] })
217232
},
@@ -234,7 +249,7 @@ export function connect<T extends PropTypes>(
234249
return normalize.element({
235250
...parts.item.attrs,
236251
dir: prop("dir"),
237-
id: dom.getItemId(scope, file.name),
252+
id: dom.getItemId(scope, dom.getFileId(file)),
238253
"data-disabled": dataAttr(disabled),
239254
"data-type": type,
240255
})
@@ -245,7 +260,7 @@ export function connect<T extends PropTypes>(
245260
return normalize.element({
246261
...parts.itemName.attrs,
247262
dir: prop("dir"),
248-
id: dom.getItemNameId(scope, file.name),
263+
id: dom.getItemNameId(scope, dom.getFileId(file)),
249264
"data-disabled": dataAttr(disabled),
250265
"data-type": type,
251266
})
@@ -256,7 +271,7 @@ export function connect<T extends PropTypes>(
256271
return normalize.element({
257272
...parts.itemSizeText.attrs,
258273
dir: prop("dir"),
259-
id: dom.getItemSizeTextId(scope, file.name),
274+
id: dom.getItemSizeTextId(scope, dom.getFileId(file)),
260275
"data-disabled": dataAttr(disabled),
261276
"data-type": type,
262277
})
@@ -267,7 +282,7 @@ export function connect<T extends PropTypes>(
267282
return normalize.element({
268283
...parts.itemPreview.attrs,
269284
dir: prop("dir"),
270-
id: dom.getItemPreviewId(scope, file.name),
285+
id: dom.getItemPreviewId(scope, dom.getFileId(file)),
271286
"data-disabled": dataAttr(disabled),
272287
"data-type": type,
273288
})
@@ -293,13 +308,15 @@ export function connect<T extends PropTypes>(
293308
return normalize.button({
294309
...parts.itemDeleteTrigger.attrs,
295310
dir: prop("dir"),
311+
id: dom.getItemDeleteTriggerId(scope, dom.getFileId(file)),
296312
type: "button",
297-
disabled,
313+
disabled: disabled || readOnly,
298314
"data-disabled": dataAttr(disabled),
315+
"data-readonly": dataAttr(readOnly),
299316
"data-type": type,
300317
"aria-label": translations.deleteFile?.(file),
301318
onClick() {
302-
if (disabled) return
319+
if (disabled || readOnly) return
303320
send({ type: "FILE.DELETE", file, itemType: type })
304321
},
305322
})
@@ -321,12 +338,13 @@ export function connect<T extends PropTypes>(
321338
...parts.clearTrigger.attrs,
322339
dir: prop("dir"),
323340
type: "button",
324-
disabled,
325-
hidden: context.get("acceptedFiles").length === 0,
341+
disabled: disabled || readOnly,
342+
hidden: acceptedFiles.length === 0,
326343
"data-disabled": dataAttr(disabled),
344+
"data-readonly": dataAttr(readOnly),
327345
onClick(event) {
328346
if (event.defaultPrevented) return
329-
if (disabled) return
347+
if (disabled || readOnly) return
330348
send({ type: "FILES.CLEAR" })
331349
},
332350
})

packages/machines/file-upload/src/file-upload.dom.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { hash } from "@zag-js/utils"
12
import type { Scope } from "@zag-js/core"
23

34
export const getRootId = (ctx: Scope) => ctx.ids?.root ?? `file:${ctx.id}`
@@ -11,6 +12,10 @@ export const getItemSizeTextId = (ctx: Scope, id: string) =>
1112
ctx.ids?.itemSizeText?.(id) ?? `file:${ctx.id}:item-size:${id}`
1213
export const getItemPreviewId = (ctx: Scope, id: string) =>
1314
ctx.ids?.itemPreview?.(id) ?? `file:${ctx.id}:item-preview:${id}`
15+
export const getItemDeleteTriggerId = (ctx: Scope, id: string) =>
16+
ctx.ids?.itemDeleteTrigger?.(id) ?? `file:${ctx.id}:item-delete:${id}`
17+
18+
export const getFileId = (file: File) => hash(`${file.name}-${file.size}`)
1419

1520
export const getRootEl = (ctx: Scope) => ctx.getById<HTMLElement>(getRootId(ctx))
1621
export const getHiddenInputEl = (ctx: Scope) => ctx.getById<HTMLInputElement>(getHiddenInputId(ctx))

packages/machines/file-upload/src/file-upload.props.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const props = createProps<FileUploadProps>()([
2424
"onFileChange",
2525
"onFileReject",
2626
"preventDocumentDrop",
27+
"readOnly",
2728
"required",
2829
"transformFiles",
2930
"translations",

packages/machines/file-upload/src/file-upload.types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type ElementIds = Partial<{
4545
itemName: (id: string) => string
4646
itemSizeText: (id: string) => string
4747
itemPreview: (id: string) => string
48+
itemDeleteTrigger: (id: string) => string
4849
}>
4950

5051
export interface IntlTranslations {
@@ -142,6 +143,10 @@ export interface FileUploadProps extends LocaleProperties, CommonProperties {
142143
* Whether the file input is invalid
143144
*/
144145
invalid?: boolean | undefined
146+
/**
147+
* Whether the file input is read-only
148+
*/
149+
readOnly?: boolean | undefined
145150
/**
146151
* Function to transform the accepted files to apply transformations
147152
*/
@@ -237,10 +242,22 @@ export interface FileUploadApi<T extends PropTypes = PropTypes> {
237242
* Whether the file input is disabled
238243
*/
239244
disabled: boolean
245+
/**
246+
* Whether the file input is in read-only mode
247+
*/
248+
readOnly: boolean
240249
/**
241250
* Whether files are currently being transformed via `transformFiles`
242251
*/
243252
transforming: boolean
253+
/**
254+
* Whether the maximum number of files has been reached
255+
*/
256+
maxFilesReached: boolean
257+
/**
258+
* The number of files that can still be added
259+
*/
260+
remainingFiles: number
244261
/**
245262
* Function to open the file dialog
246263
*/

packages/utilities/core/src/functions.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,20 @@ export function debounce<T extends (...args: any[]) => void>(fn: T, wait = 0): T
9999
}, wait)
100100
}) as T
101101
}
102+
103+
const toChar = (code: number) => String.fromCharCode(code + (code > 25 ? 39 : 97))
104+
105+
function toName(code: number) {
106+
let name = ""
107+
let x: number
108+
for (x = Math.abs(code); x > 52; x = (x / 52) | 0) name = toChar(x % 52) + name
109+
return toChar(x % 52) + name
110+
}
111+
112+
function toPhash(h: number, x: string) {
113+
let i = x.length
114+
while (i) h = (h * 33) ^ x.charCodeAt(--i)
115+
return h
116+
}
117+
118+
export const hash = (value: string) => toName(toPhash(5381, value) >>> 0)

0 commit comments

Comments
 (0)