Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
22b605b
(feat) Add file-upload machine and types/utilities for files
DustinPham Nov 26, 2025
f8eb90f
Adding file-upload.api.ts
DustinPham Dec 5, 2025
ea63283
Fixing some types
DustinPham Dec 5, 2025
05a40f0
Adding file upload to qds-core and angular-core + fix react-core bad …
DustinPham Jan 14, 2026
49a9d88
adding angular functionality and demos
DustinPham Jan 22, 2026
1518fa5
md default size, add more styling to match figma, fix composite funct…
DustinPham Jan 24, 2026
2e3f67d
update file-upload component, fix css for sm,md,lg sizes
DustinPham Jan 27, 2026
e36e309
fix error message in item not displaying right size, fix error icon n…
DustinPham Jan 27, 2026
95cf25d
cleanup css, add size demo, add rejected files to composite demo, cha…
DustinPham Jan 27, 2026
01ff011
Fixing type docs, cleanup file upload size demo
DustinPham Jan 28, 2026
0b444e0
Change file-upload api back to onInput as onChange causes infinite lo…
DustinPham Jan 30, 2026
e82b06a
initial stopping point for react file-upload implementation
DustinPham Jan 31, 2026
94c2002
Adding error text as directive, fix error messages not showing, add e…
DustinPham Feb 3, 2026
10c39d2
add required styling, update jsdocs, add additional format for errorT…
DustinPham Feb 4, 2026
a36eb6d
feat: finish implementation of file upload for both angular and react
DustinPham Feb 5, 2026
8107e69
fix linting
DustinPham Feb 5, 2026
b5d1186
add license info for file-upload
DustinPham Feb 5, 2026
206f190
remove unnecessary changes
DustinPham Feb 5, 2026
a9e4c46
fix: address critical issues in file-upload component
DustinPham Feb 6, 2026
e30422c
change getFileErrorMessage to util to reuse
DustinPham Feb 6, 2026
dc32778
fix lint
DustinPham Feb 6, 2026
827e302
fix lint(2)
DustinPham Feb 6, 2026
8238b62
add warn for catch in setInputFiles and remove computed from trackBin…
DustinPham Feb 6, 2026
ba15588
add catch for dropzone handling
DustinPham Feb 6, 2026
26c98a4
add disabled demos and update error demo to also showcase max size
DustinPham Feb 7, 2026
26551de
fix(file-upload): add syncRead as bindable parameter to fix stale onF…
DustinPham Feb 9, 2026
660bc57
add figma code connect for file-upload (will need to update when actu…
DustinPham Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
469 changes: 469 additions & 0 deletions packages/common/core/src/file-upload/file-upload.api.ts

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions packages/common/core/src/file-upload/file-upload.dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Modified from https://github.com/chakra-ui/zag
// MIT License
// Changes from Qualcomm Technologies, Inc. are provided under the following license:
// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries.
// SPDX-License-Identifier: BSD-3-Clause-Clear

import type {ScopeDomElements, ScopeDomIds} from "@qualcomm-ui/utils/machine"

import type {FileUploadElementIds, FileUploadScope} from "./file-upload.types"

export const domIds: ScopeDomIds<
Omit<
FileUploadElementIds,
"item" | "itemName" | "itemPreview" | "itemSizeText"
>,
FileUploadScope
> = {
dropzone: (scope) => scope.ids.get("dropzone"),
errorText: (scope) => scope.ids.get("errorText"),
hiddenInput: (scope) => scope.ids.get("hiddenInput"),
label: (scope) => scope.ids.get("label"),
root: (scope) => scope.ids.get("root"),
trigger: (scope) => scope.ids.get("trigger"),
}

export const domEls: ScopeDomElements<
Omit<
FileUploadElementIds,
| "trigger"
| "item"
| "itemName"
| "itemPreview"
| "itemSizeText"
| "label"
| "hiddenInput"
>,
FileUploadScope
> & {
hiddenInput: (scope: FileUploadScope) => HTMLInputElement | null
} = {
dropzone: (scope) => scope.getById<HTMLElement>(domIds.dropzone(scope)),
errorText: (scope) => scope.getById(domIds.errorText(scope)),
hiddenInput: (scope) => scope.getById(domIds.hiddenInput(scope)),
root: (scope) => scope.getById<HTMLElement>(domIds.root(scope)),
}

export function getItem(scope: FileUploadScope, id: string) {
return `${domIds.root(scope)}-item-${id}`
}

export function getItemName(scope: FileUploadScope, id: string) {
return `${domIds.root(scope)}-item-name-${id}`
}

export function getItemPreview(scope: FileUploadScope, id: string) {
return `${domIds.root(scope)}-item-preview-${id}`
}

export function getItemSizeText(scope: FileUploadScope, id: string) {
return `${domIds.root(scope)}-item-size-text-${id}`
}
313 changes: 313 additions & 0 deletions packages/common/core/src/file-upload/file-upload.machine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
// Modified from https://github.com/chakra-ui/zag
// MIT License
// Changes from Qualcomm Technologies, Inc. are provided under the following license:
// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries.
// SPDX-License-Identifier: BSD-3-Clause-Clear

import {
addDomEvent,
contains,
getEventTarget,
getWindow,
raf,
} from "@qualcomm-ui/dom/query"
import {getAcceptAttrString, isFileEqual} from "@qualcomm-ui/utils/files"
import {callAll} from "@qualcomm-ui/utils/functions"
import {createMachine, type MachineConfig} from "@qualcomm-ui/utils/machine"
import {warn} from "@qualcomm-ui/utils/warning"

import {domEls} from "./file-upload.dom"
import type {FileRejection, FileUploadSchema} from "./file-upload.types"
import {getEventFiles, setInputFiles} from "./file-upload.utils"

export const fileUploadMachine: MachineConfig<FileUploadSchema> = createMachine(
{
actions: {
clearFiles({context}) {
context.set("acceptedFiles", [])
context.set("rejectedFiles", [])
},
clearRejectedFiles({context}) {
context.set("rejectedFiles", [])
},
openFilePicker({scope}) {
raf(() => {
domEls.hiddenInput(scope)?.click()
})
},
removeFile({context, event}) {
if (event.type !== "FILE.DELETE") {
return
}
if (event.itemType === "rejected") {
const rejectedFiles = context
.get("rejectedFiles")
.filter((item) => !isFileEqual(item.file, event.file))
context.set("rejectedFiles", rejectedFiles)
} else {
const files = context
.get("acceptedFiles")
.filter((file) => !isFileEqual(file, event.file))
context.set("acceptedFiles", files)
}
},
setEventFiles(params) {
const {computed, context, event, prop} = params
if (event.type !== "FILE.SELECT" && event.type !== "DROPZONE.DROP") {
return
}

const currentAcceptedFiles = context.get("acceptedFiles")
const currentRejectedFiles = context.get("rejectedFiles")
const eventFiles = event.files.flat().filter((f) => f !== null)
const {acceptedFiles, rejectedFiles} = getEventFiles(
params,
eventFiles,
currentAcceptedFiles,
currentRejectedFiles,
)

const set = (files: File[]) => {
if (computed("multiple")) {
context.set("acceptedFiles", (prev) => [...prev, ...files])
context.set("rejectedFiles", rejectedFiles)
return
}

if (files.length) {
context.set("acceptedFiles", [files[0]])
context.set("rejectedFiles", rejectedFiles)
return
}

if (rejectedFiles.length) {
context.set("acceptedFiles", context.get("acceptedFiles"))
context.set("rejectedFiles", rejectedFiles)
}
}

const transform = prop("transformFiles")
if (transform) {
context.set("transforming", true)
transform(acceptedFiles)
.then(set)
.catch((err) => {
warn(
`[qualcomm-ui/file-upload] error transforming files\n${String(err)}`,
)
})
.finally(() => {
context.set("transforming", false)
})
} else {
set(acceptedFiles)
}
},
setFiles(params) {
const {computed, context, event} = params
if (event.type !== "FILES.SET") {
return
}
const {acceptedFiles, rejectedFiles} = getEventFiles(
params,
event.files,
)
context.set(
"acceptedFiles",
computed("multiple")
? acceptedFiles
: acceptedFiles.length > 0
? [acceptedFiles[0]]
: [],
)
context.set("rejectedFiles", rejectedFiles)
},
syncInputElement({context, scope}) {
queueMicrotask(() => {
const inputEl = domEls.hiddenInput(scope)
if (!inputEl) {
return
}
setInputFiles(inputEl, context.get("acceptedFiles"))
const win = getWindow(inputEl)
inputEl.dispatchEvent(new win.Event("change", {bubbles: true}))
})
},
},
computed: {
acceptAttr: ({prop}) => getAcceptAttrString(prop("accept")),
multiple: ({prop}) => prop("maxFiles") > 1,
},
context({bindable, getContext, prop}) {
return {
acceptedFiles: bindable<File[]>(() => ({
defaultValue: prop("defaultAcceptedFiles"),
hash(value) {
return value.map((file) => `${file.name}-${file.size}`).join(",")
},
isEqual: (a, b) =>
a.length === b?.length &&
a.every((file, i) => isFileEqual(file, b[i])),
onChange(value) {
const ctx = getContext()
prop("onFileAccept")?.({files: value})
prop("onFileChange")?.({
acceptedFiles: value,
rejectedFiles: ctx.get("rejectedFiles"),
})
},
syncRead: true,
value: prop("acceptedFiles"),
})),
rejectedFiles: bindable<FileRejection[]>(() => ({
defaultValue: [],
isEqual: (a, b) =>
a.length === b?.length &&
a.every((file, i) => isFileEqual(file.file, b[i].file)),
onChange(value) {
const ctx = getContext()
prop("onFileReject")?.({files: value})
prop("onFileChange")?.({
acceptedFiles: ctx.get("acceptedFiles"),
rejectedFiles: value,
})
},
syncRead: true,
})),
transforming: bindable<boolean>(() => ({
defaultValue: false,
})),
}
},
effects: {
preventDocumentDrop({prop, scope}) {
if (!prop("preventDocumentDrop")) {
return
}
if (!prop("allowDrop")) {
return
}
if (prop("disabled")) {
return
}
const doc = scope.getDoc()
const onDragOver = (event: DragEvent) => {
event?.preventDefault()
}
const onDrop = (event: DragEvent) => {
if (contains(domEls.root(scope), getEventTarget(event))) {
return
}
event.preventDefault()
}
return callAll(
addDomEvent(doc, "dragover", onDragOver, false),
addDomEvent(doc, "drop", onDrop, false),
)
},
},
ids: ({bindableId, bindableIdCollection, ids}) => {
return {
dropzone: bindableId<string>(ids?.dropzone),
errorText: bindableId<string>(ids?.errorText),
hiddenInput: bindableId<string>(ids?.hiddenInput),
item: bindableIdCollection(),
itemName: bindableIdCollection(),
itemPreview: bindableIdCollection(),
itemSizeText: bindableIdCollection(),
label: bindableId<string>(ids?.label),
root: bindableId<string>(ids?.root),
trigger: bindableId<string>(ids?.trigger),
}
},
initialState() {
return "idle"
},
on: {
"FILE.DELETE": {
actions: ["removeFile"],
},
"FILE.SELECT": {
actions: ["setEventFiles"],
},
"FILES.CLEAR": {
actions: ["clearFiles"],
},
"FILES.SET": {
actions: ["setFiles"],
},
"REJECTED_FILES.CLEAR": {
actions: ["clearRejectedFiles"],
},
},
onInit: undefined,
props({props}) {
return {
allowDrop: true,
defaultAcceptedFiles: [],
maxFiles: 1,
maxFileSize: Number.POSITIVE_INFINITY,
minFileSize: 0,
preventDocumentDrop: true,
...props,
translations: {
clearTriggerLabel: "Clear file",
deleteFile: (file) => `delete file ${file.name}`,
dropzone: "dropzone",
itemPreview: (file) => `preview of ${file.name}`,
...props.translations,
},
}
},
states: {
dragging: {
on: {
"DROPZONE.DRAG_LEAVE": {
target: "idle",
},
"DROPZONE.DROP": {
actions: ["setEventFiles"],
target: "idle",
},
},
},
focused: {
on: {
"DROPZONE.BLUR": {
target: "idle",
},
"DROPZONE.CLICK": {
actions: ["openFilePicker"],
},
"DROPZONE.DRAG_OVER": {
target: "dragging",
},
OPEN: {
actions: ["openFilePicker"],
},
},
},
idle: {
on: {
"DROPZONE.CLICK": {
actions: ["openFilePicker"],
},
"DROPZONE.DRAG_OVER": {
target: "dragging",
},
"DROPZONE.FOCUS": {
target: "focused",
},
OPEN: {
actions: ["openFilePicker"],
},
},
},
},
watch({action, context, track}) {
track([() => context.hash("acceptedFiles")], () => {
action(["syncInputElement"])
})
},
},
)
Loading
Loading