From 22b605bae5df2068e84e1a2d6ca703b62ea24584 Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Tue, 25 Nov 2025 16:20:54 -0800 Subject: [PATCH 01/27] (feat) Add file-upload machine and types/utilities for files Signed-off-by: Dustin Pham --- .../core/src/file-upload/file-upload.dom.ts | 53 +++ .../src/file-upload/file-upload.machine.ts | 301 ++++++++++++++++ .../core/src/file-upload/file-upload.types.ts | 321 ++++++++++++++++++ .../core/src/file-upload/file-upload.utils.ts | 111 ++++++ packages/common/core/src/file-upload/index.ts | 4 + .../utils/src/files/get-accept-attribute.ts | 36 ++ .../utils/src/files/get-file-mime-type.ts | 7 + packages/common/utils/src/files/index.ts | 6 + .../common/utils/src/files/is-file-equal.ts | 7 + .../utils/src/files/is-valid-file-size.ts | 25 ++ .../utils/src/files/is-valid-file-type.ts | 47 +++ packages/common/utils/src/files/mime-types.ts | 9 + packages/common/utils/src/files/types.ts | 86 +++++ 13 files changed, 1013 insertions(+) create mode 100644 packages/common/core/src/file-upload/file-upload.dom.ts create mode 100644 packages/common/core/src/file-upload/file-upload.machine.ts create mode 100644 packages/common/core/src/file-upload/file-upload.types.ts create mode 100644 packages/common/core/src/file-upload/file-upload.utils.ts create mode 100644 packages/common/core/src/file-upload/index.ts create mode 100644 packages/common/utils/src/files/get-accept-attribute.ts create mode 100644 packages/common/utils/src/files/get-file-mime-type.ts create mode 100644 packages/common/utils/src/files/index.ts create mode 100644 packages/common/utils/src/files/is-file-equal.ts create mode 100644 packages/common/utils/src/files/is-valid-file-size.ts create mode 100644 packages/common/utils/src/files/is-valid-file-type.ts create mode 100644 packages/common/utils/src/files/mime-types.ts create mode 100644 packages/common/utils/src/files/types.ts diff --git a/packages/common/core/src/file-upload/file-upload.dom.ts b/packages/common/core/src/file-upload/file-upload.dom.ts new file mode 100644 index 000000000..e67680b90 --- /dev/null +++ b/packages/common/core/src/file-upload/file-upload.dom.ts @@ -0,0 +1,53 @@ +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"), + 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(domIds.dropzone(scope)), + hiddenInput: (scope) => scope.getById(domIds.hiddenInput(scope)), + root: (scope) => scope.getById(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}` +} diff --git a/packages/common/core/src/file-upload/file-upload.machine.ts b/packages/common/core/src/file-upload/file-upload.machine.ts new file mode 100644 index 000000000..b75a297d2 --- /dev/null +++ b/packages/common/core/src/file-upload/file-upload.machine.ts @@ -0,0 +1,301 @@ +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 = 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 {acceptedFiles, rejectedFiles} = getEventFiles( + params, + event.files, + 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${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(() => ({ + 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"), + }) + }, + value: prop("acceptedFiles"), + })), + rejectedFiles: bindable(() => ({ + 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, + }) + }, + })), + transforming: bindable(() => ({ + 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(ids?.dropzone), + hiddenInput: bindableId(ids?.hiddenInput), + item: bindableIdCollection(), + itemName: bindableIdCollection(), + itemPreview: bindableIdCollection(), + itemSizeText: bindableIdCollection(), + label: bindableId(ids?.label), + root: bindableId(ids?.root), + trigger: bindableId(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: { + 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"]) + }) + }, + }, +) diff --git a/packages/common/core/src/file-upload/file-upload.types.ts b/packages/common/core/src/file-upload/file-upload.types.ts new file mode 100644 index 000000000..9b358b68f --- /dev/null +++ b/packages/common/core/src/file-upload/file-upload.types.ts @@ -0,0 +1,321 @@ +import type {Locale} from "@qualcomm-ui/utils/direction" +import type {FileError, FileMimeType} from "@qualcomm-ui/utils/files" +import type {RequiredBy} from "@qualcomm-ui/utils/guard" +import type { + ActionSchema, + CommonProperties, + EffectSchema, + EventType, + MachineSchema, + PropNormalizer, + ScopeWithIds, +} from "@qualcomm-ui/utils/machine" +import type {Dict} from "@qualcomm-ui/utils/object" + +export interface FileRejection { + errors: FileError[] + file: File +} + +export interface FileDetails { + acceptedFiles: File[] + rejectedFiles: FileRejection[] +} + +export interface FileAcceptDetails { + files: File[] +} + +export interface FileRejectDetails { + files: FileRejection[] +} + +export interface FileUploadElementIds { + dropzone: string + hiddenInput: string + item: string[] + itemName: string[] + itemPreview: string[] + itemSizeText: string[] + label: string + root: string + trigger: string +} + +export interface IntlTranslations { + deleteFile?: ((file: File) => string) | undefined + dropzone?: string | undefined + itemPreview?: ((file: File) => string) | undefined +} + +export interface FileUploadApiProps extends Locale, CommonProperties { + /** + * The accept file types + */ + accept?: Record | FileMimeType | FileMimeType[] | undefined + /** + * The controlled accepted files + */ + acceptedFiles?: File[] | undefined + /** + * Whether to allow drag and drop in the dropzone element + * @default true + */ + allowDrop?: boolean | undefined + /** + * The default camera to use when capturing media + */ + capture?: "user" | "environment" | undefined + /** + * The default accepted files when rendered. + * Use when you don't need to control the accepted files of the input. + */ + defaultAcceptedFiles?: File[] | undefined + /** + * Whether to accept directories, only works in webkit browsers + */ + directory?: boolean | undefined + /** + * Whether the file input is disabled + */ + disabled?: boolean | undefined + /** + * The ids of the elements. Useful for composition. + */ + ids?: Partial | undefined + /** + * Whether the file input is invalid + */ + invalid?: boolean | undefined + /** + * The maximum number of files + * @default 1 + */ + maxFiles?: number | undefined + /** + * The maximum file size in bytes + * + * @default Infinity + */ + maxFileSize?: number | undefined + /** + * The minimum file size in bytes + * + * @default 0 + */ + minFileSize?: number | undefined + /** + * The name of the underlying file input + */ + name?: string | undefined + /** + * Function called when the file is accepted + */ + onFileAccept?: ((details: FileAcceptDetails) => void) | undefined + /** + * Function called when the value changes, whether accepted or rejected + */ + onFileChange?: ((details: FileDetails) => void) | undefined + /** + * Function called when the file is rejected + */ + onFileReject?: ((details: FileRejectDetails) => void) | undefined + /** + * Whether to prevent the drop event on the document + * @default true + */ + preventDocumentDrop?: boolean | undefined + /** + * Whether the file input is required + */ + required?: boolean | undefined + /** + * Function to transform the accepted files to apply transformations + */ + transformFiles?: ((files: File[]) => Promise) | undefined + /** + * The localized messages to use. + */ + translations?: IntlTranslations | undefined + /** + * Function to validate a file + */ + validate?: + | ((file: File, details: FileDetails) => FileError[] | null) + | undefined +} + +type PropsWithDefault = + | "minFileSize" + | "maxFileSize" + | "maxFiles" + | "preventDocumentDrop" + | "allowDrop" + | "translations" + +interface PrivateContext { + /** + * The current value of the file input + */ + acceptedFiles: File[] + /** + * The rejected files + */ + rejectedFiles: FileRejection[] + /** + * Whether files are currently being transformed + */ + transforming: boolean +} + +export interface FileUploadScope extends ScopeWithIds {} + +interface ComputedContext { + /** + * The accept attribute as a string + */ + acceptAttr: string | undefined + /** + * Whether the file can select multiple files + */ + multiple: boolean +} + +type Events = + | { + file: File + itemType: string + type: "FILE.DELETE" + } + | { + files: File[] + type: "FILE.SELECT" | "DROPZONE.DROP" | "FILES.SET" + } + | { + type: + | "FILES.CLEAR" + | "REJECTED_FILES.CLEAR" + | "DROPZONE.DRAG_LEAVE" + | "DROPZONE.BLUR" + | "DROPZONE.CLICK" + | "DROPZONE.DRAG_OVER" + | "OPEN" + | "DROPZONE.FOCUS" + } + +export interface FileUploadSchema extends MachineSchema { + actions: ActionSchema< + | "syncInputElement" + | "openFilePicker" + | "setFiles" + | "setEventFiles" + | "removeFile" + | "clearRejectedFiles" + | "clearFiles" + > + computed: ComputedContext + context: PrivateContext + effects: EffectSchema<"preventDocumentDrop"> + events: Events + ids: FileUploadElementIds + props: RequiredBy + state: "idle" | "focused" | "dragging" +} + +export type ItemType = "accepted" | "rejected" + +export interface ItemTypeProps { + type?: ItemType | undefined +} + +export interface ItemProps extends ItemTypeProps { + file: File +} + +export interface ItemPreviewImageProps extends ItemProps { + url: string +} + +export interface ItemGroupProps extends ItemTypeProps {} + +export interface DropzoneProps { + /** + * Whether to disable the click event on the dropzone + */ + disableClick?: boolean | undefined +} + +export interface FileUploadApi { + /** + * The accepted files that have been dropped or selected + */ + acceptedFiles: File[] + /** + * Clears the accepted files + */ + clearFiles: VoidFunction + /** + * Clears the rejected files + */ + clearRejectedFiles: VoidFunction + /** + * Returns the preview url of a file. + * Returns a function to revoke the url. + */ + createFileUrl: (file: File, cb: (url: string) => void) => VoidFunction + /** + * Function to delete the file from the list + */ + deleteFile: (file: File, type?: ItemType) => void + /** + * Whether the file input is disabled + */ + disabled: boolean + /** + * Whether the user is dragging something over the root element + */ + dragging: boolean + /** + * Whether the user is focused on the dropzone element + */ + focused: boolean + getClearTriggerProps: () => T["button"] + getDropzoneProps: (props?: DropzoneProps) => T["element"] + /** + * Returns the formatted file size (e.g. 1.2MB) + */ + getFileSize: (file: File) => string + getHiddenInputProps: () => T["input"] + getItemDeleteTriggerProps: (props: ItemProps) => T["button"] + getItemGroupProps: (props?: ItemGroupProps) => T["element"] + + getItemNameProps: (props: ItemProps) => T["element"] + getItemPreviewImageProps: (props: ItemPreviewImageProps) => T["img"] + getItemPreviewProps: (props: ItemProps) => T["element"] + getItemProps: (props: ItemProps) => T["element"] + getItemSizeTextProps: (props: ItemProps) => T["element"] + getLabelProps: () => T["label"] + getRootProps: () => T["element"] + getTriggerProps: () => T["button"] + /** + * Function to open the file dialog + */ + openFilePicker: VoidFunction + /** + * The files that have been rejected + */ + rejectedFiles: FileRejection[] + /** + * Sets the clipboard files + * Returns `true` if the clipboard data contains files, `false` otherwise. + */ + setClipboardFiles: (dt: DataTransfer | null) => boolean + /** + * Sets the accepted files + */ + setFiles: (files: File[]) => void + /** + * Whether files are currently being transformed via `transformFiles` + */ + transforming: boolean +} diff --git a/packages/common/core/src/file-upload/file-upload.utils.ts b/packages/common/core/src/file-upload/file-upload.utils.ts new file mode 100644 index 000000000..01bf23ada --- /dev/null +++ b/packages/common/core/src/file-upload/file-upload.utils.ts @@ -0,0 +1,111 @@ +import {getEventTarget, getWindow} from "@qualcomm-ui/dom/query" +import { + type FileError, + isValidFileSize, + isValidFileType, +} from "@qualcomm-ui/utils/files" +import type {Params} from "@qualcomm-ui/utils/machine" + +import type {FileRejection, FileUploadSchema} from "./file-upload.types" + +export function isEventWithFiles( + event: Pick, +): boolean { + const target = getEventTarget(event) + if (!event.dataTransfer) { + return !!target && "files" in target + } + return event.dataTransfer.types.some((type) => { + return type === "Files" || type === "application/x-moz-file" + }) +} + +export function isFilesWithinRange( + ctx: Params, + incomingCount: number, + currentAcceptedFiles: File[], +): boolean { + const {computed, prop} = ctx + if (!computed("multiple") && incomingCount > 1) { + return false + } + if ( + !computed("multiple") && + incomingCount + currentAcceptedFiles.length === 2 + ) { + return true + } + return incomingCount + currentAcceptedFiles.length <= prop("maxFiles") +} + +export function getEventFiles( + ctx: Params, + files: File[], + currentAcceptedFiles: File[] = [], + currentRejectedFiles: FileRejection[] = [], +): { + acceptedFiles: File[] + rejectedFiles: FileRejection[] +} { + const {computed, prop} = ctx + const acceptedFiles: File[] = [] + const rejectedFiles: FileRejection[] = [] + + const validateParams = { + acceptedFiles: currentAcceptedFiles, + rejectedFiles: currentRejectedFiles, + } + + files.forEach((file) => { + const [accepted, acceptError] = isValidFileType( + file, + computed("acceptAttr"), + ) + const [sizeMatch, sizeError] = isValidFileSize( + file, + prop("minFileSize"), + prop("maxFileSize"), + ) + + const validateErrors = prop("validate")?.(file, validateParams) + + const valid = validateErrors ? validateErrors.length === 0 : true + + if (accepted && sizeMatch && valid) { + acceptedFiles.push(file) + } else { + const errors = [acceptError, sizeError] + if (!valid) { + errors.push(...(validateErrors ?? [])) + } + rejectedFiles.push({errors: errors.filter(Boolean) as FileError[], file}) + } + }) + + if (!isFilesWithinRange(ctx, acceptedFiles.length, currentAcceptedFiles)) { + acceptedFiles.forEach((file) => { + rejectedFiles.push({errors: ["TOO_MANY_FILES"], file}) + }) + acceptedFiles.splice(0) + } + + return { + acceptedFiles, + rejectedFiles, + } +} + +export function setInputFiles(inputEl: HTMLInputElement, files: File[]): void { + const win = getWindow(inputEl) + try { + if ("DataTransfer" in win) { + const dataTransfer = new win.DataTransfer() + files.forEach((file) => { + dataTransfer.items.add(file) + }) + inputEl.files = dataTransfer.files + } + } catch { + // do nothing + } +} diff --git a/packages/common/core/src/file-upload/index.ts b/packages/common/core/src/file-upload/index.ts new file mode 100644 index 000000000..534795714 --- /dev/null +++ b/packages/common/core/src/file-upload/index.ts @@ -0,0 +1,4 @@ +export * from "./file-upload.dom" +export * from "./file-upload.machine" +export * from "./file-upload.types" +export * from "./file-upload.utils" diff --git a/packages/common/utils/src/files/get-accept-attribute.ts b/packages/common/utils/src/files/get-accept-attribute.ts new file mode 100644 index 000000000..2e3f82718 --- /dev/null +++ b/packages/common/utils/src/files/get-accept-attribute.ts @@ -0,0 +1,36 @@ +function isMIMEType(v: string) { + return ( + v === "audio/*" || + v === "video/*" || + v === "image/*" || + v === "text/*" || + /\w+\/[-+.\w]+/g.test(v) + ) +} + +function isExt(v: string) { + return /^.*\.\w+$/.test(v) +} + +const isValidMIME = (v: string) => isMIMEType(v) || isExt(v) + +export function getAcceptAttrString( + accept: Record | string | string[] | undefined, +): string | undefined { + if (accept == null) { + return + } + + if (typeof accept === "string") { + return accept + } + + if (Array.isArray(accept)) { + return accept.filter(isValidMIME).join(",") + } + + return Object.entries(accept) + .reduce((a, [mimeType, ext]) => [...a, mimeType, ...ext], [] as string[]) + .filter(isValidMIME) + .join(",") +} diff --git a/packages/common/utils/src/files/get-file-mime-type.ts b/packages/common/utils/src/files/get-file-mime-type.ts new file mode 100644 index 000000000..fc70c031c --- /dev/null +++ b/packages/common/utils/src/files/get-file-mime-type.ts @@ -0,0 +1,7 @@ +import {mimeTypesMap} from "./mime-types" +import type {FileMimeType} from "./types" + +export function getFileMimeType(name: string): FileMimeType | null { + const extension = name.split(".").pop() + return extension ? mimeTypesMap.get(extension) || null : null +} diff --git a/packages/common/utils/src/files/index.ts b/packages/common/utils/src/files/index.ts new file mode 100644 index 000000000..759afb50e --- /dev/null +++ b/packages/common/utils/src/files/index.ts @@ -0,0 +1,6 @@ +export * from "./get-accept-attribute" +export * from "./get-file-mime-type" +export * from "./is-file-equal" +export * from "./is-valid-file-size" +export * from "./is-valid-file-type" +export type * from "./types.ts" diff --git a/packages/common/utils/src/files/is-file-equal.ts b/packages/common/utils/src/files/is-file-equal.ts new file mode 100644 index 000000000..bdc095515 --- /dev/null +++ b/packages/common/utils/src/files/is-file-equal.ts @@ -0,0 +1,7 @@ +export const isFileEqual = (file1: File, file2: File): boolean => { + return ( + file1.name === file2.name && + file1.size === file2.size && + file1.type === file2.type + ) +} diff --git a/packages/common/utils/src/files/is-valid-file-size.ts b/packages/common/utils/src/files/is-valid-file-size.ts new file mode 100644 index 000000000..ccd7c38a3 --- /dev/null +++ b/packages/common/utils/src/files/is-valid-file-size.ts @@ -0,0 +1,25 @@ +import type {FileError} from "./types" + +const isDefined = (v: T | undefined): v is T => v !== undefined && v !== null + +export function isValidFileSize( + file: File, + minSize?: number, + maxSize?: number, +): [boolean, FileError | null] { + if (isDefined(file.size)) { + if (isDefined(minSize) && isDefined(maxSize)) { + if (file.size > maxSize) { + return [false, "FILE_TOO_LARGE"] + } + if (file.size < minSize) { + return [false, "FILE_TOO_SMALL"] + } + } else if (isDefined(minSize) && file.size < minSize) { + return [false, "FILE_TOO_SMALL"] + } else if (isDefined(maxSize) && file.size > maxSize) { + return [false, "FILE_TOO_LARGE"] + } + } + return [true, null] +} diff --git a/packages/common/utils/src/files/is-valid-file-type.ts b/packages/common/utils/src/files/is-valid-file-type.ts new file mode 100644 index 000000000..48063fcd4 --- /dev/null +++ b/packages/common/utils/src/files/is-valid-file-type.ts @@ -0,0 +1,47 @@ +import {getFileMimeType} from "./get-file-mime-type" +import type {FileError} from "./types" + +function isFileAccepted( + file: File | null, + accept: string[] | string | undefined, +) { + if (file && accept) { + const types = Array.isArray(accept) ? accept : accept.split(",") + + if (types.length === 0) { + return true + } + + const fileName = file.name || "" + const mimeType = ( + file.type || + getFileMimeType(fileName) || + "" + ).toLowerCase() + const baseMimeType = mimeType.replace(/\/.*$/, "") + + return types.some((type) => { + const validType = type.trim().toLowerCase() + + if (validType.charAt(0) === ".") { + return fileName.toLowerCase().endsWith(validType) + } + + if (validType.endsWith("/*")) { + return baseMimeType === validType.replace(/\/.*$/, "") + } + + return mimeType === validType + }) + } + return true +} + +export function isValidFileType( + file: File, + accept: string | undefined, +): [boolean, FileError | null] { + const isAcceptable = + file.type === "application/x-moz-file" || isFileAccepted(file, accept) + return [isAcceptable, isAcceptable ? null : "FILE_INVALID_TYPE"] +} diff --git a/packages/common/utils/src/files/mime-types.ts b/packages/common/utils/src/files/mime-types.ts new file mode 100644 index 000000000..6ad580328 --- /dev/null +++ b/packages/common/utils/src/files/mime-types.ts @@ -0,0 +1,9 @@ +export const mimeTypes = + "3g2_video/3gpp2[3gp,3gpp_video/3gpp[3mf_model/3mf[7z_application/x-7z-compressed[aac_audio/aac[ac_application/pkix-attr-cert[adp_audio/adpcm[adts_audio/aac[ai_application/postscript[aml_application/automationml-aml+xml[amlx_application/automationml-amlx+zip[amr_audio/amr[apk_application/vnd.android.package-archive[apng_image/apng[appcache,manifest_text/cache-manifest[appinstaller_application/appinstaller[appx_application/appx[appxbundle_application/appxbundle[asc_application/pgp-keys[atom_application/atom+xml[atomcat_application/atomcat+xml[atomdeleted_application/atomdeleted+xml[atomsvc_application/atomsvc+xml[au,snd_audio/basic[avi_video/x-msvideo[avci_image/avci[avcs_image/avcs[avif_image/avif[aw_application/applixware[bdoc_application/bdoc[bin,bpk,buffer,deb,deploy,dist,distz,dll,dmg,dms,dump,elc,exe,img,iso,lrf,mar,msi,msm,msp,pkg,so_application/octet-stream[bmp,dib_image/bmp[btf,btif_image/prs.btif[bz2_application/x-bzip2[c_text/x-c[ccxml_application/ccxml+xml[cdfx_application/cdfx+xml[cdmia_application/cdmi-capability[cdmic_application/cdmi-container[cdmid_application/cdmi-domain[cdmio_application/cdmi-object[cdmiq_application/cdmi-queue[cer_application/pkix-cert[cgm_image/cgm[cjs_application/node[class_application/java-vm[coffee,litcoffee_text/coffeescript[conf,def,in,ini,list,log,text,txt_text/plain[cpp,cxx,cc_text/x-c++src[cpl_application/cpl+xml[cpt_application/mac-compactpro[crl_application/pkix-crl[css_text/css[csv_text/csv[cu_application/cu-seeme[cwl_application/cwl[cww_application/prs.cww[davmount_application/davmount+xml[dbk_application/docbook+xml[doc_application/msword[docx_application/vnd.openxmlformats-officedocument.wordprocessingml.document[dsc_text/prs.lines.tag[dssc_application/dssc+der[dtd_application/xml-dtd[dwd_application/atsc-dwd+xml[ear,jar,war_application/java-archive[ecma_application/ecmascript[emf_image/emf[eml,mime_message/rfc822[emma_application/emma+xml[emotionml_application/emotionml+xml[eot_application/vnd.ms-fontobject[eps,ps_application/postscript[epub_application/epub+zip[exi_application/exi[exp_application/express[exr_image/aces[ez_application/andrew-inset[fdf_application/fdf[fdt_application/fdt+xml[fits_image/fits[flac_audio/flac[flv_video/x-flv[g3_image/g3fax[geojson_application/geo+json[gif_image/gif[glb_model/gltf-binary[gltf_model/gltf+json[gml_application/gml+xml[go_text/x-go[gpx_application/gpx+xml[gz_application/gzip[h_text/x-h[h261_video/h261[h263_video/h263[h264_video/h264[heic_image/heic[heics_image/heic-sequence[heif_image/heif[heifs_image/heif-sequence[htm,html,shtml_text/html[ico_image/x-icon[icns_image/x-icns[ics,ifb_text/calendar[iges,igs_model/iges[ink,inkml_application/inkml+xml[ipa_application/octet-stream[java_text/x-java-source[jp2,jpg2_image/jp2[jpeg,jpe,jpg_image/jpeg[jpf,jpx_image/jpx[jpm,jpgm_image/jpm[jpgv_video/jpeg[jph_image/jph[js,mjs_text/javascript[json_application/json[json5_application/json5[jsonld_application/ld+json[jsx_text/jsx[jxl_image/jxl[jxr_image/jxr[ktx_image/ktx[ktx2_image/ktx2[less_text/less[m1v,m2v,mpe,mpeg,mpg_video/mpeg[m4a_audio/mp4[m4v_video/x-m4v[md,markdown_text/markdown[mid,midi,kar,rmi_audio/midi[mkv_video/x-matroska[mp2,mp2a,mp3,mpga,m3a,m2a_audio/mpeg[mp4,mp4v,mpg4_video/mp4[mp4a_audio/mp4[mp4s,m4p_application/mp4[odp_application/vnd.oasis.opendocument.presentation[oda_application/oda[ods_application/vnd.oasis.opendocument.spreadsheet[odt_application/vnd.oasis.opendocument.text[oga,ogg,opus,spx_audio/ogg[ogv_video/ogg[ogx_application/ogg[otf_font/otf[p12,pfx_application/x-pkcs12[pdf_application/pdf[pem_application/x-pem-file[php_text/x-php[png_image/png[ppt_application/vnd.ms-powerpoint[pptx_application/vnd.openxmlformats-officedocument.presentationml.presentation[pskcxml_application/pskc+xml[psd_image/vnd.adobe.photoshop[py_text/x-python[qt,mov_video/quicktime[rar_application/vnd.rar[rdf_application/rdf+xml[rtf_text/rtf[sass_text/x-sass[scss_text/x-scss[sgm,sgml_text/sgml[sh_application/x-sh[svg,svgz_image/svg+xml[swf_application/x-shockwave-flash[tar_application/x-tar[tif,tiff_image/tiff[toml_application/toml[ts_video/mp2t[tsx_text/tsx[tsv_text/tab-separated-values[ttc_font/collection[ttf_font/ttf[vtt_text/vtt[wasm_application/wasm[wav_audio/wav[weba_audio/webm[webm_video/webm[webmanifest_application/manifest+json[webp_image/webp[wma_audio/x-ms-wma[wmv_video/x-ms-wmv[woff_font/woff[woff2_font/woff2[xls_application/vnd.ms-excel[xlsx_application/vnd.openxmlformats-officedocument.spreadsheetml.sheet[xml_application/xml[xz_application/x-xz[yaml,yml_text/yaml[zip_application/zip" + +export const mimeTypesMap: Map = new Map( + mimeTypes.split("[").flatMap((mime) => { + const [extensions, mimeType] = mime.split("_") + return extensions.split(",").map((ext) => [ext, mimeType]) + }), +) diff --git a/packages/common/utils/src/files/types.ts b/packages/common/utils/src/files/types.ts new file mode 100644 index 000000000..3f7387efe --- /dev/null +++ b/packages/common/utils/src/files/types.ts @@ -0,0 +1,86 @@ +type AnyString = string & {} + +export type FileError = + | "TOO_MANY_FILES" + | "FILE_INVALID_TYPE" + | "FILE_TOO_LARGE" + | "FILE_TOO_SMALL" + | "FILE_INVALID" + | "FILE_EXISTS" + | AnyString + +export type ImageFileMimeType = + | "image/png" + | "image/gif" + | "image/jpeg" + | "image/svg+xml" + | "image/webp" + | "image/avif" + | "image/heic" + | "image/bmp" + +export type ApplicationFileMimeType = + | "application/pdf" + | "application/zip" + | "application/json" + | "application/xml" + | "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "application/vnd.ms-powerpoint" + | "application/vnd.openxmlformats-officedocument.presentationml.presentation" + | "application/rtf" + | "application/x-rar" + | "application/x-7z-compressed" + | "application/x-tar" + | "application/vnd.microsoft.portable-executable" + +export type TextFileMimeType = + | "text/css" + | "text/csv" + | "text/html" + | "text/markdown" + | "text/plain" + +export type FontFileMimeType = + | "font/ttf" + | "font/otf" + | "font/woff" + | "font/woff2" + | "font/eot" + | "font/svg" + +export type VideoFileMimeType = + | "video/mp4" + | "video/webm" + | "video/ogg" + | "video/quicktime" + | "video/x-msvideo" + +export type AudioFileMimeType = + | "audio/mpeg" + | "audio/ogg" + | "audio/wav" + | "audio/webm" + | "audio/aac" + | "audio/flac" + | "audio/x-m4a" + +export type FileMimeTypeGroup = + | "image/*" + | "audio/*" + | "video/*" + | "text/*" + | "application/*" + | "font/*" + +export type FileMimeType = + | ImageFileMimeType + | ApplicationFileMimeType + | TextFileMimeType + | FontFileMimeType + | VideoFileMimeType + | AudioFileMimeType + | FileMimeTypeGroup + | AnyString From f8eb90fdc4050c68baa82706714ac1aaf4b48e61 Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Thu, 4 Dec 2025 17:27:35 -0800 Subject: [PATCH 02/27] Adding file-upload.api.ts Signed-off-by: Dustin Pham --- .../core/src/file-upload/file-upload.api.ts | 422 ++++++++++++++++++ .../core/src/file-upload/file-upload.props.ts | 44 ++ .../core/src/file-upload/file-upload.types.ts | 147 +++++- packages/common/core/src/file-upload/index.ts | 2 + .../common/utils/src/files/data-transfer.ts | 97 ++++ packages/common/utils/src/files/index.ts | 1 + .../common/utils/src/i18n/format-bytes.ts | 81 ++++ .../common/utils/src/i18n/format-number.ts | 12 + packages/common/utils/src/i18n/index.ts | 2 + 9 files changed, 788 insertions(+), 20 deletions(-) create mode 100644 packages/common/core/src/file-upload/file-upload.api.ts create mode 100644 packages/common/core/src/file-upload/file-upload.props.ts create mode 100644 packages/common/utils/src/files/data-transfer.ts create mode 100644 packages/common/utils/src/i18n/format-bytes.ts create mode 100644 packages/common/utils/src/i18n/format-number.ts diff --git a/packages/common/core/src/file-upload/file-upload.api.ts b/packages/common/core/src/file-upload/file-upload.api.ts new file mode 100644 index 000000000..4182699a9 --- /dev/null +++ b/packages/common/core/src/file-upload/file-upload.api.ts @@ -0,0 +1,422 @@ +import { + contains, + getEventTarget, + visuallyHiddenStyle, +} from "@qualcomm-ui/dom/query" +import {flatArray} from "@qualcomm-ui/utils/array" +import {booleanAriaAttr, booleanDataAttr} from "@qualcomm-ui/utils/attributes" +import type {DirectionProperty} from "@qualcomm-ui/utils/direction" +import {getFileEntries} from "@qualcomm-ui/utils/files" +import {formatBytes} from "@qualcomm-ui/utils/i18n" +import type {Machine, PropNormalizer} from "@qualcomm-ui/utils/machine" + +import { + domEls, + domIds, + getItem, + getItemName, + getItemPreview, + getItemSizeText, +} from "./file-upload.dom" +import type { + FileUploadApi, + FileUploadClearTriggerBindings, + FileUploadDropzoneBindings, + FileUploadSchema, + ItemType, +} from "./file-upload.types" +import {isEventWithFiles} from "./file-upload.utils" + +const DEFAULT_ITEM_TYPE: ItemType = "accepted" + +const INTERACTIVE_SELECTOR = + "button, a[href], input:not([type='file']), select, textarea, [tabindex], [contenteditable]" + +function isInteractiveTarget( + element: HTMLElement | null, + container: HTMLElement, +): boolean { + if (!element || element.getAttribute("type") === "file") { + return false + } + const interactive = element.closest(INTERACTIVE_SELECTOR) + return interactive !== container && contains(container, interactive) +} + +export function createFileUploadApi( + machine: Machine, + normalize: PropNormalizer, +): FileUploadApi { + const {computed, context, prop, scope, send, state} = machine + const disabled = !!prop("disabled") + const required = !!prop("required") + const allowDrop = prop("allowDrop") + const translations = prop("translations") + + const dragging = state.matches("dragging") + const focused = state.matches("focused") && !disabled + const commonBindings: {"data-scope": "file-upload"} & DirectionProperty = { + "data-scope": "file-upload", + dir: prop("dir"), + } + + return { + acceptedFiles: context.get("acceptedFiles"), + clearFiles() { + send({type: "FILES.CLEAR"}) + }, + clearRejectedFiles() { + send({type: "REJECTED_FILES.CLEAR"}) + }, + createFileUrl(file: File, cb: (url: string) => void) { + const win = scope.getWin() + const url = win.window.URL.createObjectURL(file) + cb(url) + return () => win.window.URL.revokeObjectURL(url) + }, + deleteFile(file, type = DEFAULT_ITEM_TYPE) { + send({file, itemType: type, type: "FILE.DELETE"}) + }, + disabled, + dragging, + focused, + getClearTriggerProps(): FileUploadClearTriggerBindings { + return normalize.button({ + ...commonBindings, + "aria-label": "Clear file", + "data-invalid": booleanDataAttr(disabled), + "data-part": "clear-trigger", + disabled, + hidden: context.get("acceptedFiles").length === 0, + onClick(event) { + if (event.defaultPrevented) { + return + } + if (disabled) { + return + } + send({type: "FILES.CLEAR"}) + }, + type: "button", + }) + }, + getDropzoneProps(props = {}): FileUploadDropzoneBindings { + return normalize.element({ + ...commonBindings, + "aria-disabled": booleanAriaAttr(disabled), + "aria-label": translations.dropzone, + "data-disabled": booleanDataAttr(disabled), + "data-dragging": booleanDataAttr(dragging), + "data-invalid": booleanDataAttr(prop("invalid")), + id: domIds.dropzone(scope), + onBlur() { + if (disabled) { + return + } + send({type: "DROPZONE.BLUR"}) + }, + onClick(event) { + if (disabled) { + return + } + if (event.defaultPrevented) { + return + } + if (props.disableClick) { + return + } + + const target = getEventTarget(event) + if (!contains(event.currentTarget, target)) { + return + } + if (isInteractiveTarget(target, event.currentTarget)) { + return + } + + // prevent opening the file dialog when clicking on the label (to avoid + // double opening) + if (event.currentTarget.localName === "label") { + event.preventDefault() + } + send({type: "DROPZONE.CLICK"}) + }, + onDragLeave(event) { + if (disabled) { + return + } + if (!allowDrop) { + return + } + if (contains(event.currentTarget, event.relatedTarget)) { + return + } + send({type: "DROPZONE.DRAG_LEAVE"}) + }, + onDragOver(event) { + if (disabled) { + return + } + if (!allowDrop) { + return + } + event.preventDefault() + event.stopPropagation() + try { + event.dataTransfer.dropEffect = "copy" + } catch {} + + const hasFiles = isEventWithFiles(event) + if (!hasFiles) { + return + } + + const count = event.dataTransfer.items.length + send({count, type: "DROPZONE.DRAG_OVER"}) + }, + onDrop(event) { + if (disabled) { + return + } + if (allowDrop) { + event.preventDefault() + event.stopPropagation() + } + + const hasFiles = isEventWithFiles(event) + if (disabled || !hasFiles) { + return + } + + void getFileEntries(event.dataTransfer.items, prop("directory")).then( + (files) => { + send({files: flatArray(files), type: "DROPZONE.DROP"}) + }, + ) + }, + onFocus() { + if (disabled) { + return + } + send({type: "DROPZONE.FOCUS"}) + }, + onKeyDown(event) { + if (disabled) { + return + } + if (event.defaultPrevented) { + return + } + + const target = getEventTarget(event) + if (!contains(event.currentTarget, target)) { + return + } + if (isInteractiveTarget(target, event.currentTarget)) { + return + } + + if (props.disableClick) { + return + } + if (event.key !== "Enter" && event.key !== " ") { + return + } + send({src: "keydown", type: "DROPZONE.CLICK"}) + }, + role: props.disableClick ? "application" : "button", + tabIndex: disabled || props.disableClick ? undefined : 0, + }) + }, + getFileSize(file) { + return formatBytes(file.size, prop("locale")) + }, + getHiddenInputProps() { + return normalize.input({ + ...commonBindings, + accept: computed("acceptAttr"), + capture: prop("capture"), + disabled, + id: domIds.hiddenInput(scope), + multiple: computed("multiple") || prop("maxFiles") > 1, + name: prop("name"), + onClick(event) { + event.stopPropagation() + // allow for re-selection of the same file + event.currentTarget.value = "" + }, + onInput(event) { + if (disabled) { + return + } + const {files} = event.currentTarget + send({files: files ? Array.from(files) : [], type: "FILE.SELECT"}) + }, + required: prop("required"), + style: visuallyHiddenStyle, + tabIndex: -1, + type: "file", + webkitdirectory: prop("directory") ? "" : undefined, + }) + }, + getItemDeleteTriggerProps(props) { + const {file, type = DEFAULT_ITEM_TYPE} = props + return normalize.button({ + ...commonBindings, + "aria-label": translations.deleteFile?.(file), + "data-disabled": booleanDataAttr(disabled), + "data-type": type, + disabled, + onClick() { + if (disabled) { + return + } + send({file, itemType: type, type: "FILE.DELETE"}) + }, + type: "button", + }) + }, + getItemGroupProps(props = {}) { + const {type = DEFAULT_ITEM_TYPE} = props + return normalize.element({ + ...commonBindings, + "data-disabled": booleanDataAttr(disabled), + "data-type": type, + }) + }, + + getItemNameProps(props) { + const {file, type = DEFAULT_ITEM_TYPE} = props + return normalize.element({ + ...commonBindings, + "data-disabled": booleanDataAttr(disabled), + "data-type": type, + id: getItemName(scope, file.name), + }) + }, + + getItemPreviewImageProps(props) { + const {file, type = DEFAULT_ITEM_TYPE, url} = props + const isImage = file.type.startsWith("image/") + if (!isImage) { + throw new Error("Preview Image is only supported for image files") + } + return normalize.img({ + ...commonBindings, + alt: translations.itemPreview?.(file), + "data-disabled": booleanDataAttr(disabled), + "data-type": type, + src: url, + }) + }, + + getItemPreviewProps(props) { + const {file, type = DEFAULT_ITEM_TYPE} = props + return normalize.element({ + ...commonBindings, + "data-disabled": booleanDataAttr(disabled), + "data-type": type, + id: getItemPreview(scope, file.name), + }) + }, + + getItemProps(props) { + const {file, type = DEFAULT_ITEM_TYPE} = props + return normalize.element({ + ...commonBindings, + "data-disabled": booleanDataAttr(disabled), + "data-type": type, + id: getItem(scope, file.name), + }) + }, + + getItemSizeTextProps(props) { + const {file, type = DEFAULT_ITEM_TYPE} = props + return normalize.element({ + ...commonBindings, + "data-disabled": booleanDataAttr(disabled), + "data-type": type, + id: getItemSizeText(scope, file.name), + }) + }, + + getLabelProps() { + return normalize.label({ + ...commonBindings, + "data-disabled": booleanDataAttr(disabled), + "data-required": booleanDataAttr(required), + dir: prop("dir"), + htmlFor: domIds.hiddenInput(scope), + id: domIds.label(scope), + }) + }, + + getRootProps() { + return normalize.element({ + ...commonBindings, + "data-disabled": booleanDataAttr(disabled), + "data-dragging": booleanDataAttr(dragging), + id: domIds.root(scope), + }) + }, + + getTriggerProps() { + return normalize.button({ + ...commonBindings, + "data-disabled": booleanDataAttr(disabled), + "data-invalid": booleanDataAttr(prop("invalid")), + disabled, + id: domIds.trigger(scope), + onClick(event) { + if (disabled) { + return + } + // if trigger is wrapped within the dropzone, stop propagation to avoid + // double opening + if (contains(domEls.dropzone(scope), event.currentTarget)) { + event.stopPropagation() + } + send({type: "OPEN"}) + }, + type: "button", + }) + }, + + openFilePicker() { + if (disabled) { + return + } + send({type: "OPEN"}) + }, + + rejectedFiles: context.get("rejectedFiles"), + + setClipboardFiles(dt) { + if (disabled) { + return false + } + const items = Array.from(dt?.items ?? []) + const files = items.reduce((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({files, type: "FILES.SET"}) + return true + }, + + setFiles(files) { + send({count: files.length, files, type: "FILES.SET"}) + }, + + transforming: context.get("transforming"), + } +} diff --git a/packages/common/core/src/file-upload/file-upload.props.ts b/packages/common/core/src/file-upload/file-upload.props.ts new file mode 100644 index 000000000..dc813961c --- /dev/null +++ b/packages/common/core/src/file-upload/file-upload.props.ts @@ -0,0 +1,44 @@ +import {createProps, createSplitProps} from "@qualcomm-ui/utils/object" + +import type {FileUploadApiProps, ItemProps} from "./file-upload.types" + +export const fileUploadProps: (keyof FileUploadApiProps)[] = + createProps()( + "accept", + "acceptedFiles", + "allowDrop", + "capture", + "defaultAcceptedFiles", + "dir", + "directory", + "disabled", + "getRootNode", + "ids", + "invalid", + "locale", + "maxFiles", + "maxFileSize", + "minFileSize", + "name", + "onFileAccept", + "onFileChange", + "onFileReject", + "preventDocumentDrop", + "required", + "transformFiles", + "translations", + "validate", + ) +export const splitFileUploadProps: ( + props: Props, +) => [FileUploadApiProps, Omit] = + createSplitProps(fileUploadProps) + +export const itemProps: (keyof ItemProps)[] = createProps()( + "file", + "type", +) +export const splitItemProps: ( + props: Props, +) => [ItemProps, Omit] = + createSplitProps(itemProps) diff --git a/packages/common/core/src/file-upload/file-upload.types.ts b/packages/common/core/src/file-upload/file-upload.types.ts index 9b358b68f..67506468c 100644 --- a/packages/common/core/src/file-upload/file-upload.types.ts +++ b/packages/common/core/src/file-upload/file-upload.types.ts @@ -1,16 +1,19 @@ -import type {Locale} from "@qualcomm-ui/utils/direction" +import type { + BooleanAriaAttr, + BooleanDataAttr, +} from "@qualcomm-ui/utils/attributes" +import type {DirectionProperty, Locale} from "@qualcomm-ui/utils/direction" import type {FileError, FileMimeType} from "@qualcomm-ui/utils/files" import type {RequiredBy} from "@qualcomm-ui/utils/guard" import type { ActionSchema, CommonProperties, EffectSchema, - EventType, + JSX, MachineSchema, PropNormalizer, ScopeWithIds, } from "@qualcomm-ui/utils/machine" -import type {Dict} from "@qualcomm-ui/utils/object" export interface FileRejection { errors: FileError[] @@ -188,17 +191,24 @@ type Events = type: "FILE.DELETE" } | { - files: File[] + count?: number + files: (File | (File | null)[] | null)[] type: "FILE.SELECT" | "DROPZONE.DROP" | "FILES.SET" } + | { + count: number + type: "DROPZONE.DRAG_OVER" + } + | { + src?: "keydown" + type: "DROPZONE.CLICK" + } | { type: | "FILES.CLEAR" | "REJECTED_FILES.CLEAR" | "DROPZONE.DRAG_LEAVE" | "DROPZONE.BLUR" - | "DROPZONE.CLICK" - | "DROPZONE.DRAG_OVER" | "OPEN" | "DROPZONE.FOCUS" } @@ -279,24 +289,27 @@ export interface FileUploadApi { * Whether the user is focused on the dropzone element */ focused: boolean - getClearTriggerProps: () => T["button"] - getDropzoneProps: (props?: DropzoneProps) => T["element"] + getClearTriggerProps: () => FileUploadClearTriggerBindings + getDropzoneProps: (props?: DropzoneProps) => FileUploadDropzoneBindings /** * Returns the formatted file size (e.g. 1.2MB) */ getFileSize: (file: File) => string - getHiddenInputProps: () => T["input"] - getItemDeleteTriggerProps: (props: ItemProps) => T["button"] - getItemGroupProps: (props?: ItemGroupProps) => T["element"] - - getItemNameProps: (props: ItemProps) => T["element"] - getItemPreviewImageProps: (props: ItemPreviewImageProps) => T["img"] - getItemPreviewProps: (props: ItemProps) => T["element"] - getItemProps: (props: ItemProps) => T["element"] - getItemSizeTextProps: (props: ItemProps) => T["element"] - getLabelProps: () => T["label"] - getRootProps: () => T["element"] - getTriggerProps: () => T["button"] + getHiddenInputProps: () => FileUploadHiddenInputBindings + getItemDeleteTriggerProps: ( + props: ItemProps, + ) => FileUploadItemDeleteTriggerBindings + getItemGroupProps: (props?: ItemGroupProps) => FileUploadItemBindings + getItemNameProps: (props: ItemProps) => FileUploadItemIdBindings + getItemPreviewImageProps: ( + props: ItemPreviewImageProps, + ) => FileUploadItemPreviewImageBindings + getItemPreviewProps: (props: ItemProps) => FileUploadItemIdBindings + getItemProps: (props: ItemProps) => FileUploadItemIdBindings + getItemSizeTextProps: (props: ItemProps) => FileUploadItemIdBindings + getLabelProps: () => FileUploadLabelBindings + getRootProps: () => FileUploadRootBindings + getTriggerProps: () => FileUploadTriggerBindings /** * Function to open the file dialog */ @@ -319,3 +332,97 @@ export interface FileUploadApi { */ transforming: boolean } + +interface CommonBindings extends DirectionProperty { + "data-scope": "file-upload" +} + +export interface FileUploadClearTriggerBindings extends CommonBindings { + "aria-label": string | undefined + "data-invalid": BooleanDataAttr + "data-part": "clear-trigger" + disabled: boolean | undefined + hidden: boolean + onClick: JSX.MouseEventHandler + type: "button" +} + +export interface FileUploadDropzoneBindings extends CommonBindings { + "aria-disabled": BooleanAriaAttr + "aria-label": string | undefined + "data-disabled": BooleanDataAttr + "data-dragging": BooleanDataAttr + "data-invalid": BooleanDataAttr + id: string + onBlur: JSX.FocusEventHandler + onClick: JSX.MouseEventHandler + onDragLeave: JSX.DragEventHandler + onDragOver: JSX.DragEventHandler + onDrop: JSX.DragEventHandler + onFocus: JSX.FocusEventHandler + onKeyDown: JSX.KeyboardEventHandler + role: "application" | "button" + tabIndex: 0 | undefined +} + +export interface FileUploadHiddenInputBindings extends CommonBindings { + accept: string | undefined + capture: string | undefined + disabled: boolean | undefined + id: string + multiple: boolean + name?: string + onClick: JSX.MouseEventHandler + onInput: JSX.FormEventHandler + required: boolean | undefined + style: JSX.CSSProperties + tabIndex: -1 + type: "file" + webkitdirectory: string | undefined +} + +export interface FileUploadItemDeleteTriggerBindings extends CommonBindings { + "aria-label": string | undefined + "data-disabled": BooleanDataAttr + "data-type": ItemType + disabled: boolean + onClick: JSX.MouseEventHandler + type: "button" +} + +export interface FileUploadItemBindings extends CommonBindings { + "data-disabled": BooleanDataAttr + "data-type": ItemType +} + +export interface FileUploadItemIdBindings extends FileUploadItemBindings { + id: string +} + +export interface FileUploadItemPreviewImageBindings + extends FileUploadItemBindings { + alt: string | undefined + src: string +} + +export interface FileUploadLabelBindings extends CommonBindings { + "data-disabled": BooleanDataAttr + "data-required": BooleanDataAttr + htmlFor: string + id: string +} + +export interface FileUploadRootBindings extends CommonBindings { + "data-disabled": BooleanDataAttr + "data-dragging": BooleanDataAttr + id: string +} + +export interface FileUploadTriggerBindings extends CommonBindings { + "data-disabled": BooleanDataAttr + "data-invalid": BooleanDataAttr + disabled: boolean + id: string + onClick: JSX.MouseEventHandler + type: "button" +} diff --git a/packages/common/core/src/file-upload/index.ts b/packages/common/core/src/file-upload/index.ts index 534795714..5302e8ad0 100644 --- a/packages/common/core/src/file-upload/index.ts +++ b/packages/common/core/src/file-upload/index.ts @@ -1,4 +1,6 @@ +export * from "./file-upload.api" export * from "./file-upload.dom" export * from "./file-upload.machine" +export * from "./file-upload.props" export * from "./file-upload.types" export * from "./file-upload.utils" diff --git a/packages/common/utils/src/files/data-transfer.ts b/packages/common/utils/src/files/data-transfer.ts new file mode 100644 index 000000000..986d5cc26 --- /dev/null +++ b/packages/common/utils/src/files/data-transfer.ts @@ -0,0 +1,97 @@ +const getItemEntry = (item: any): FileSystemEntry | null => + typeof item.getAsEntry === "function" + ? item.getAsEntry() + : typeof item.webkitGetAsEntry === "function" + ? item.webkitGetAsEntry() + : null + +const isDirectoryEntry = ( + entry: FileSystemEntry, +): entry is FileSystemDirectoryEntry => entry.isDirectory + +const isFileEntry = (entry: FileSystemEntry): entry is FileSystemFileEntry => + entry.isFile + +const addRelativePath = (file: File, path: string) => { + Object.defineProperty(file, "relativePath", { + value: path ? `${path}/${file.name}` : file.name, + }) + return file +} + +export const getFileEntries = ( + items: DataTransferItemList, + traverseDirectories: boolean | undefined, +): Promise<(File | (File | null)[] | null)[]> => + Promise.all( + Array.from(items) + .filter((item) => item.kind === "file") + .map((item) => { + const entry = getItemEntry(item) + if (!entry) { + return null + } + + if (isDirectoryEntry(entry) && traverseDirectories) { + return getDirectoryFiles(entry.createReader(), `${entry.name}`) + } + + if (isFileEntry(entry) && typeof item.getAsFile === "function") { + const file = item.getAsFile() + return Promise.resolve(file ? addRelativePath(file, "") : null) + } + + if (isFileEntry(entry)) { + return new Promise((resolve) => { + entry.file((file) => { + resolve(addRelativePath(file, "")) + }) + }) + } + return null + }) + .filter((b) => b !== null), + ) + +const getDirectoryFiles = ( + reader: FileSystemDirectoryReader, + path = "", +): Promise> => + new Promise((resolve) => { + const entryPromises: Promise[] = [] + const readDirectoryEntries = () => { + reader.readEntries((entries) => { + if (entries.length === 0) { + resolve(Promise.all(entryPromises).then((entries) => entries.flat())) + return + } + + const promises: Promise<(File | null) | (File | null)[]>[] = entries + .map((entry) => { + if (!entry) { + return null + } + + if (isDirectoryEntry(entry)) { + return getDirectoryFiles( + entry.createReader(), + `${path}${entry.name}`, + ) + } + + if (isFileEntry(entry)) { + return new Promise((resolve) => { + entry.file((file) => { + resolve(addRelativePath(file, path)) + }) + }) + } + }) + .filter((b) => !!b) + // @ts-expect-error - some promises may contain null + entryPromises.push(Promise.all(promises)) + readDirectoryEntries() + }) + } + readDirectoryEntries() + }) diff --git a/packages/common/utils/src/files/index.ts b/packages/common/utils/src/files/index.ts index 759afb50e..378141733 100644 --- a/packages/common/utils/src/files/index.ts +++ b/packages/common/utils/src/files/index.ts @@ -1,3 +1,4 @@ +export * from "./data-transfer" export * from "./get-accept-attribute" export * from "./get-file-mime-type" export * from "./is-file-equal" diff --git a/packages/common/utils/src/i18n/format-bytes.ts b/packages/common/utils/src/i18n/format-bytes.ts new file mode 100644 index 000000000..dd1477f16 --- /dev/null +++ b/packages/common/utils/src/i18n/format-bytes.ts @@ -0,0 +1,81 @@ +import {formatNumber} from "./format-number" + +const bitPrefixes = ["", "kilo", "mega", "giga", "tera"] +const bytePrefixes = ["", "kilo", "mega", "giga", "tera", "peta"] + +export interface FormatBytesOptions { + /** + * The number of significant digits to include in the formatted output. + * @default 3 + */ + precision?: number | undefined + /** + * The type of unit to format the value as. + * - "bit": Format as bits (b) + * - "byte": Format as bytes (B) + * @default "byte" + */ + unit?: "bit" | "byte" | undefined + /** + * The display style for the unit. + * - "long": Full unit name (e.g., "kilobytes") + * - "short": Abbreviated unit (e.g., "KB") + * - "narrow": Compact unit (e.g., "K") + * @default "short" + */ + unitDisplay?: "long" | "short" | "narrow" | undefined + /** + * The unit system to use for calculations. + * - "binary": Uses 1024 as the base (e.g., 1 KiB = 1024 bytes) + * - "decimal": Uses 1000 as the base (e.g., 1 KB = 1000 bytes) + * @default "decimal" + */ + unitSystem?: "binary" | "decimal" | undefined +} + +export const formatBytes = ( + bytes: number, + locale = "en-US", + options: FormatBytesOptions = {}, +): string => { + if (Number.isNaN(bytes)) { + return "" + } + if (bytes === 0) { + return "0 B" + } + + const { + precision = 3, + unit = "byte", + unitDisplay = "short", + unitSystem = "decimal", + } = options + + const factor = unitSystem === "binary" ? 1024 : 1000 + const prefix = unit === "bit" ? bitPrefixes : bytePrefixes + + // Handle negative values + const isNegative = bytes < 0 + + // Find the appropriate unit using iterative division + let value = Math.abs(bytes) + let index = 0 + + while (value >= factor && index < prefix.length - 1) { + value /= factor + index++ + } + + // Apply precision + const v = parseFloat(value.toPrecision(precision)) + + // Restore sign if negative + const finalValue = isNegative ? -v : v + + return formatNumber(finalValue, locale, { + style: "unit", + unit: prefix[index] + unit, + unitDisplay, + }) +} diff --git a/packages/common/utils/src/i18n/format-number.ts b/packages/common/utils/src/i18n/format-number.ts new file mode 100644 index 000000000..4377509a7 --- /dev/null +++ b/packages/common/utils/src/i18n/format-number.ts @@ -0,0 +1,12 @@ +import {i18nCache} from "./cache" + +const getNumberFormatter = i18nCache(Intl.NumberFormat) + +export function formatNumber( + v: number, + locale: string, + options: Intl.NumberFormatOptions = {}, +): string { + const formatter = getNumberFormatter(locale, options) + return formatter.format(v) +} diff --git a/packages/common/utils/src/i18n/index.ts b/packages/common/utils/src/i18n/index.ts index a91c2004f..b26cf8548 100644 --- a/packages/common/utils/src/i18n/index.ts +++ b/packages/common/utils/src/i18n/index.ts @@ -1,2 +1,4 @@ export * from "./cache" export * from "./filter" +export * from "./format-bytes" +export * from "./format-number" From ea632834ac0424eb314968802ab8f5a650516bcc Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Fri, 5 Dec 2025 13:15:24 -0800 Subject: [PATCH 03/27] Fixing some types Signed-off-by: Dustin Pham --- packages/common/core/src/file-upload/file-upload.api.ts | 2 +- .../common/core/src/file-upload/file-upload.machine.ts | 5 +++-- .../common/core/src/file-upload/file-upload.types.ts | 9 +++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/common/core/src/file-upload/file-upload.api.ts b/packages/common/core/src/file-upload/file-upload.api.ts index 4182699a9..3a32af6ca 100644 --- a/packages/common/core/src/file-upload/file-upload.api.ts +++ b/packages/common/core/src/file-upload/file-upload.api.ts @@ -83,7 +83,7 @@ export function createFileUploadApi( getClearTriggerProps(): FileUploadClearTriggerBindings { return normalize.button({ ...commonBindings, - "aria-label": "Clear file", + "aria-label": translations.clearTriggerLabel, "data-invalid": booleanDataAttr(disabled), "data-part": "clear-trigger", disabled, diff --git a/packages/common/core/src/file-upload/file-upload.machine.ts b/packages/common/core/src/file-upload/file-upload.machine.ts index b75a297d2..656eecbd1 100644 --- a/packages/common/core/src/file-upload/file-upload.machine.ts +++ b/packages/common/core/src/file-upload/file-upload.machine.ts @@ -53,10 +53,10 @@ export const fileUploadMachine: MachineConfig = createMachine( const currentAcceptedFiles = context.get("acceptedFiles") const currentRejectedFiles = context.get("rejectedFiles") - + const eventFiles = event.files.flat().filter((f) => f !== null) const {acceptedFiles, rejectedFiles} = getEventFiles( params, - event.files, + eventFiles, currentAcceptedFiles, currentRejectedFiles, ) @@ -240,6 +240,7 @@ export const fileUploadMachine: MachineConfig = createMachine( preventDocumentDrop: true, ...props, translations: { + clearTriggerLabel: "Clear file", deleteFile: (file) => `delete file ${file.name}`, dropzone: "dropzone", itemPreview: (file) => `preview of ${file.name}`, diff --git a/packages/common/core/src/file-upload/file-upload.types.ts b/packages/common/core/src/file-upload/file-upload.types.ts index 67506468c..540690737 100644 --- a/packages/common/core/src/file-upload/file-upload.types.ts +++ b/packages/common/core/src/file-upload/file-upload.types.ts @@ -46,6 +46,7 @@ export interface FileUploadElementIds { } export interface IntlTranslations { + clearTriggerLabel: string deleteFile?: ((file: File) => string) | undefined dropzone?: string | undefined itemPreview?: ((file: File) => string) | undefined @@ -191,9 +192,13 @@ type Events = type: "FILE.DELETE" } | { - count?: number files: (File | (File | null)[] | null)[] - type: "FILE.SELECT" | "DROPZONE.DROP" | "FILES.SET" + type: "FILE.SELECT" | "DROPZONE.DROP" + } + | { + count?: number + files: File[] + type: "FILES.SET" } | { count: number From 05a40f0a875e834e0cff9ea0430b5a8a626037c8 Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Wed, 14 Jan 2026 13:46:00 -0800 Subject: [PATCH 04/27] Adding file upload to qds-core and angular-core + fix react-core bad import Signed-off-by: Dustin Pham --- .../core/src/file-upload/file-upload.api.ts | 26 +- .../core/src/file-upload/file-upload.types.ts | 39 +-- .../src/file-upload/file-upload.api.ts | 107 ++++++++ .../src/file-upload/file-upload.classes.ts | 17 ++ .../src/file-upload/file-upload.types.ts | 95 +++++++ .../common/qds-core/src/file-upload/index.ts | 3 + .../src/file-upload/qds-file-upload.css | 7 + ...ore-file-upload-clear-trigger.directive.ts | 21 ++ .../core-file-upload-dropzone.directive.ts | 44 ++++ ...core-file-upload-hidden-input.directive.ts | 21 ++ ...le-upload-item-delete-trigger.directive.ts | 25 ++ .../core-file-upload-item-group.directive.ts | 35 +++ .../core-file-upload-item-name.directive.ts | 25 ++ ...ile-upload-item-preview-image.directive.ts | 42 +++ ...core-file-upload-item-preview.directive.ts | 25 ++ ...re-file-upload-item-size-text.directive.ts | 25 ++ .../core-file-upload-item.directive.ts | 50 ++++ .../core-file-upload-label.directive.ts | 21 ++ .../core-file-upload-root.directive.ts | 245 ++++++++++++++++++ .../core-file-upload-trigger.directive.ts | 21 ++ .../file-upload-context.service.ts | 23 ++ .../file-upload-item-context.service.ts | 23 ++ .../angular-core/src/file-upload/index.ts | 15 ++ .../src/file-upload/ng-package.json | 6 + .../src/machine/use-bindable-id-collection.ts | 3 +- 25 files changed, 933 insertions(+), 31 deletions(-) create mode 100644 packages/common/qds-core/src/file-upload/file-upload.api.ts create mode 100644 packages/common/qds-core/src/file-upload/file-upload.classes.ts create mode 100644 packages/common/qds-core/src/file-upload/file-upload.types.ts create mode 100644 packages/common/qds-core/src/file-upload/index.ts create mode 100644 packages/common/qds-core/src/file-upload/qds-file-upload.css create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-clear-trigger.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-dropzone.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-hidden-input.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-item-delete-trigger.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-item-group.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-item-name.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview-image.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-item-size-text.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-item.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-label.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-root.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-trigger.directive.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/file-upload-context.service.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/file-upload-item-context.service.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/index.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/ng-package.json diff --git a/packages/common/core/src/file-upload/file-upload.api.ts b/packages/common/core/src/file-upload/file-upload.api.ts index 3a32af6ca..898fb7c17 100644 --- a/packages/common/core/src/file-upload/file-upload.api.ts +++ b/packages/common/core/src/file-upload/file-upload.api.ts @@ -80,7 +80,7 @@ export function createFileUploadApi( disabled, dragging, focused, - getClearTriggerProps(): FileUploadClearTriggerBindings { + getClearTriggerBindings(): FileUploadClearTriggerBindings { return normalize.button({ ...commonBindings, "aria-label": translations.clearTriggerLabel, @@ -100,7 +100,7 @@ export function createFileUploadApi( type: "button", }) }, - getDropzoneProps(props = {}): FileUploadDropzoneBindings { + getDropzoneBindings(props = {}): FileUploadDropzoneBindings { return normalize.element({ ...commonBindings, "aria-disabled": booleanAriaAttr(disabled), @@ -231,7 +231,7 @@ export function createFileUploadApi( getFileSize(file) { return formatBytes(file.size, prop("locale")) }, - getHiddenInputProps() { + getHiddenInputBindings() { return normalize.input({ ...commonBindings, accept: computed("acceptAttr"), @@ -259,7 +259,7 @@ export function createFileUploadApi( webkitdirectory: prop("directory") ? "" : undefined, }) }, - getItemDeleteTriggerProps(props) { + getItemDeleteTriggerBindings(props) { const {file, type = DEFAULT_ITEM_TYPE} = props return normalize.button({ ...commonBindings, @@ -276,7 +276,7 @@ export function createFileUploadApi( type: "button", }) }, - getItemGroupProps(props = {}) { + getItemGroupBindings(props = {}) { const {type = DEFAULT_ITEM_TYPE} = props return normalize.element({ ...commonBindings, @@ -285,7 +285,7 @@ export function createFileUploadApi( }) }, - getItemNameProps(props) { + getItemNameBindings(props) { const {file, type = DEFAULT_ITEM_TYPE} = props return normalize.element({ ...commonBindings, @@ -295,7 +295,7 @@ export function createFileUploadApi( }) }, - getItemPreviewImageProps(props) { + getItemPreviewImageBindings(props) { const {file, type = DEFAULT_ITEM_TYPE, url} = props const isImage = file.type.startsWith("image/") if (!isImage) { @@ -310,7 +310,7 @@ export function createFileUploadApi( }) }, - getItemPreviewProps(props) { + getItemPreviewBindings(props) { const {file, type = DEFAULT_ITEM_TYPE} = props return normalize.element({ ...commonBindings, @@ -320,7 +320,7 @@ export function createFileUploadApi( }) }, - getItemProps(props) { + getItemBindings(props) { const {file, type = DEFAULT_ITEM_TYPE} = props return normalize.element({ ...commonBindings, @@ -330,7 +330,7 @@ export function createFileUploadApi( }) }, - getItemSizeTextProps(props) { + getItemSizeTextBindings(props) { const {file, type = DEFAULT_ITEM_TYPE} = props return normalize.element({ ...commonBindings, @@ -340,7 +340,7 @@ export function createFileUploadApi( }) }, - getLabelProps() { + getLabelBindings() { return normalize.label({ ...commonBindings, "data-disabled": booleanDataAttr(disabled), @@ -351,7 +351,7 @@ export function createFileUploadApi( }) }, - getRootProps() { + getRootBindings() { return normalize.element({ ...commonBindings, "data-disabled": booleanDataAttr(disabled), @@ -360,7 +360,7 @@ export function createFileUploadApi( }) }, - getTriggerProps() { + getTriggerBindings() { return normalize.button({ ...commonBindings, "data-disabled": booleanDataAttr(disabled), diff --git a/packages/common/core/src/file-upload/file-upload.types.ts b/packages/common/core/src/file-upload/file-upload.types.ts index 540690737..5a037ed32 100644 --- a/packages/common/core/src/file-upload/file-upload.types.ts +++ b/packages/common/core/src/file-upload/file-upload.types.ts @@ -2,7 +2,10 @@ import type { BooleanAriaAttr, BooleanDataAttr, } from "@qualcomm-ui/utils/attributes" -import type {DirectionProperty, Locale} from "@qualcomm-ui/utils/direction" +import type { + DirectionProperty, + LocaleProperty, +} from "@qualcomm-ui/utils/direction" import type {FileError, FileMimeType} from "@qualcomm-ui/utils/files" import type {RequiredBy} from "@qualcomm-ui/utils/guard" import type { @@ -11,7 +14,6 @@ import type { EffectSchema, JSX, MachineSchema, - PropNormalizer, ScopeWithIds, } from "@qualcomm-ui/utils/machine" @@ -52,7 +54,10 @@ export interface IntlTranslations { itemPreview?: ((file: File) => string) | undefined } -export interface FileUploadApiProps extends Locale, CommonProperties { +export interface FileUploadApiProps + extends LocaleProperty, + DirectionProperty, + CommonProperties { /** * The accept file types */ @@ -260,7 +265,7 @@ export interface DropzoneProps { disableClick?: boolean | undefined } -export interface FileUploadApi { +export interface FileUploadApi { /** * The accepted files that have been dropped or selected */ @@ -294,27 +299,27 @@ export interface FileUploadApi { * Whether the user is focused on the dropzone element */ focused: boolean - getClearTriggerProps: () => FileUploadClearTriggerBindings - getDropzoneProps: (props?: DropzoneProps) => FileUploadDropzoneBindings + getClearTriggerBindings: () => FileUploadClearTriggerBindings + getDropzoneBindings: (props?: DropzoneProps) => FileUploadDropzoneBindings /** * Returns the formatted file size (e.g. 1.2MB) */ getFileSize: (file: File) => string - getHiddenInputProps: () => FileUploadHiddenInputBindings - getItemDeleteTriggerProps: ( + getHiddenInputBindings: () => FileUploadHiddenInputBindings + getItemBindings: (props: ItemProps) => FileUploadItemIdBindings + getItemDeleteTriggerBindings: ( props: ItemProps, ) => FileUploadItemDeleteTriggerBindings - getItemGroupProps: (props?: ItemGroupProps) => FileUploadItemBindings - getItemNameProps: (props: ItemProps) => FileUploadItemIdBindings - getItemPreviewImageProps: ( + getItemGroupBindings: (props?: ItemGroupProps) => FileUploadItemBindings + getItemNameBindings: (props: ItemProps) => FileUploadItemIdBindings + getItemPreviewBindings: (props: ItemProps) => FileUploadItemIdBindings + getItemPreviewImageBindings: ( props: ItemPreviewImageProps, ) => FileUploadItemPreviewImageBindings - getItemPreviewProps: (props: ItemProps) => FileUploadItemIdBindings - getItemProps: (props: ItemProps) => FileUploadItemIdBindings - getItemSizeTextProps: (props: ItemProps) => FileUploadItemIdBindings - getLabelProps: () => FileUploadLabelBindings - getRootProps: () => FileUploadRootBindings - getTriggerProps: () => FileUploadTriggerBindings + getItemSizeTextBindings: (props: ItemProps) => FileUploadItemIdBindings + getLabelBindings: () => FileUploadLabelBindings + getRootBindings: () => FileUploadRootBindings + getTriggerBindings: () => FileUploadTriggerBindings /** * Function to open the file dialog */ diff --git a/packages/common/qds-core/src/file-upload/file-upload.api.ts b/packages/common/qds-core/src/file-upload/file-upload.api.ts new file mode 100644 index 000000000..051bfec0b --- /dev/null +++ b/packages/common/qds-core/src/file-upload/file-upload.api.ts @@ -0,0 +1,107 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {PropNormalizer} from "@qualcomm-ui/utils/machine" + +import {fileUploadClasses} from "./file-upload.classes" +import type { + QdsFileUploadApi, + QdsFileUploadApiProps, + QdsFileUploadClearTriggerBindings, + QdsFileUploadDropzoneBindings, + QdsFileUploadItemBindings, + QdsFileUploadItemDeleteTriggerBindings, + QdsFileUploadItemGroupBindings, + QdsFileUploadItemNameBindings, + QdsFileUploadItemPreviewBindings, + QdsFileUploadItemPreviewImageBindings, + QdsFileUploadItemSizeTextBindings, + QdsFileUploadLabelBindings, + QdsFileUploadRootBindings, + QdsFileUploadTriggerBindings, +} from "./file-upload.types" + +export function createQdsFileUploadApi( + props: QdsFileUploadApiProps, + normalize: PropNormalizer, +): QdsFileUploadApi { + const size = props.size || "sm" + + return { + size, + + // group: bindings + getClearTriggerBindings(): QdsFileUploadClearTriggerBindings { + return normalize.element({ + className: fileUploadClasses.clearTrigger, + "data-size": size, + }) + }, + getDropzoneBindings(): QdsFileUploadDropzoneBindings { + return normalize.element({ + className: fileUploadClasses.dropzone, + "data-size": size, + }) + }, + getItemBindings(): QdsFileUploadItemBindings { + return normalize.element({ + className: fileUploadClasses.item, + "data-size": size, + }) + }, + getItemDeleteTriggerBindings(): QdsFileUploadItemDeleteTriggerBindings { + return normalize.element({ + className: fileUploadClasses.itemDeleteTrigger, + "data-size": size, + }) + }, + getItemGroupBindings(): QdsFileUploadItemGroupBindings { + return normalize.element({ + className: fileUploadClasses.itemGroup, + "data-size": size, + }) + }, + getItemNameBindings(): QdsFileUploadItemNameBindings { + return normalize.element({ + className: fileUploadClasses.itemName, + "data-size": size, + }) + }, + getItemPreviewBindings(): QdsFileUploadItemPreviewBindings { + return normalize.element({ + className: fileUploadClasses.itemPreview, + "data-size": size, + }) + }, + getItemPreviewImageBindings(): QdsFileUploadItemPreviewImageBindings { + return normalize.element({ + className: fileUploadClasses.itemPreviewImage, + "data-size": size, + }) + }, + getItemSizeTextBindings(): QdsFileUploadItemSizeTextBindings { + return normalize.element({ + className: fileUploadClasses.itemSizeText, + "data-size": size, + }) + }, + getLabelBindings(): QdsFileUploadLabelBindings { + return normalize.element({ + className: fileUploadClasses.label, + "data-size": size, + }) + }, + getRootBindings(): QdsFileUploadRootBindings { + return normalize.element({ + className: fileUploadClasses.root, + "data-size": size, + }) + }, + getTriggerBindings(): QdsFileUploadTriggerBindings { + return normalize.element({ + className: fileUploadClasses.trigger, + "data-size": size, + }) + }, + } +} diff --git a/packages/common/qds-core/src/file-upload/file-upload.classes.ts b/packages/common/qds-core/src/file-upload/file-upload.classes.ts new file mode 100644 index 000000000..3918678f4 --- /dev/null +++ b/packages/common/qds-core/src/file-upload/file-upload.classes.ts @@ -0,0 +1,17 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +export const fileUploadClasses = { + clearTrigger: "qui-file-upload__clear-trigger", + dropzone: "qui-file-upload__dropzone", + item: "qui-file-upload__item", + itemDeleteTrigger: "qui-file-upload__item-delete-trigger", + itemGroup: "qui-file-upload__item-group", + itemName: "qui-file-upload__item-name", + itemPreview: "qui-file-upload__item-preview", + itemPreviewImage: "qui-file-upload__item-preview-image", + itemSizeText: "qui-file-upload__item-size-text", + label: "qui-file-upload__label", + root: "qui-file-upload__root", + trigger: "qui-file-upload__trigger", +} as const diff --git a/packages/common/qds-core/src/file-upload/file-upload.types.ts b/packages/common/qds-core/src/file-upload/file-upload.types.ts new file mode 100644 index 000000000..2999d221f --- /dev/null +++ b/packages/common/qds-core/src/file-upload/file-upload.types.ts @@ -0,0 +1,95 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {fileUploadClasses} from "./file-upload.classes" + +export type QdsFileUploadSize = "sm" | "md" | "lg" + +export interface QdsFileUploadApiProps { + /** + * Governs the size and padding of file upload elements. + * + * @default 'sm' + */ + size?: QdsFileUploadSize +} + +type FileUploadClasses = typeof fileUploadClasses + +export interface QdsFileUploadRootBindings { + className: FileUploadClasses["root"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadClearTriggerBindings { + className: FileUploadClasses["clearTrigger"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadDropzoneBindings { + className: FileUploadClasses["dropzone"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadItemBindings { + className: FileUploadClasses["item"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadItemDeleteTriggerBindings { + className: FileUploadClasses["itemDeleteTrigger"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadItemGroupBindings { + className: FileUploadClasses["itemGroup"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadItemNameBindings { + className: FileUploadClasses["itemName"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadItemPreviewBindings { + className: FileUploadClasses["itemPreview"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadItemPreviewImageBindings { + className: FileUploadClasses["itemPreviewImage"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadItemSizeTextBindings { + className: FileUploadClasses["itemSizeText"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadLabelBindings { + className: FileUploadClasses["label"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadTriggerBindings { + className: FileUploadClasses["trigger"] + "data-size": QdsFileUploadSize +} + +export interface QdsFileUploadApi { + size: QdsFileUploadSize + + // group: bindings + getClearTriggerBindings(): QdsFileUploadClearTriggerBindings + getDropzoneBindings(): QdsFileUploadDropzoneBindings + getItemBindings(): QdsFileUploadItemBindings + getItemDeleteTriggerBindings(): QdsFileUploadItemDeleteTriggerBindings + getItemGroupBindings(): QdsFileUploadItemGroupBindings + getItemNameBindings(): QdsFileUploadItemNameBindings + getItemPreviewBindings(): QdsFileUploadItemPreviewBindings + getItemPreviewImageBindings(): QdsFileUploadItemPreviewImageBindings + getItemSizeTextBindings(): QdsFileUploadItemSizeTextBindings + getLabelBindings(): QdsFileUploadLabelBindings + getRootBindings(): QdsFileUploadRootBindings + getTriggerBindings(): QdsFileUploadTriggerBindings +} diff --git a/packages/common/qds-core/src/file-upload/index.ts b/packages/common/qds-core/src/file-upload/index.ts new file mode 100644 index 000000000..d85f79859 --- /dev/null +++ b/packages/common/qds-core/src/file-upload/index.ts @@ -0,0 +1,3 @@ +export * from "./file-upload.api" +export * from "./file-upload.classes" +export type * from "./file-upload.types" diff --git a/packages/common/qds-core/src/file-upload/qds-file-upload.css b/packages/common/qds-core/src/file-upload/qds-file-upload.css new file mode 100644 index 000000000..f04336757 --- /dev/null +++ b/packages/common/qds-core/src/file-upload/qds-file-upload.css @@ -0,0 +1,7 @@ +.qui-file-upload__root { + align-items: center; + display: inline-flex; + flex-wrap: wrap; + gap: 20px; + justify-content: space-between; +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-clear-trigger.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-clear-trigger.directive.ts new file mode 100644 index 000000000..7bbac614c --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-clear-trigger.directive.ts @@ -0,0 +1,21 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useFileUploadContext} from "./file-upload-context.service" + +@Directive() +export class CoreFileUploadClearTriggerDirective implements OnInit { + protected readonly fileUploadContext = useFileUploadContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getClearTriggerBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-dropzone.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-dropzone.directive.ts new file mode 100644 index 000000000..56f139bfb --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-dropzone.directive.ts @@ -0,0 +1,44 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + booleanAttribute, + computed, + Directive, + input, + type OnInit, +} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type {DropzoneProps} from "@qualcomm-ui/core/file-upload" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" +import type {Explicit} from "@qualcomm-ui/utils/guard" + +import {useFileUploadContext} from "./file-upload-context.service" + +@Directive() +export class CoreFileUploadDropzoneDirective + implements SignalifyInput, OnInit +{ + /** + * Whether to disable the click event on the dropzone + */ + readonly disableClick = input(undefined, { + transform: booleanAttribute, + }) + + protected readonly fileUploadContext = useFileUploadContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getDropzoneBindings( + computed>(() => ({ + disableClick: this.disableClick(), + }))(), + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-hidden-input.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-hidden-input.directive.ts new file mode 100644 index 000000000..eecc36fcb --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-hidden-input.directive.ts @@ -0,0 +1,21 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useFileUploadContext} from "./file-upload-context.service" + +@Directive() +export class CoreFileUploadHiddenInputDirective implements OnInit { + protected readonly fileUploadContext = useFileUploadContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getHiddenInputBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-delete-trigger.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-delete-trigger.directive.ts new file mode 100644 index 000000000..edffe5614 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-delete-trigger.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useFileUploadContext} from "./file-upload-context.service" +import {useFileUploadItemContext} from "./file-upload-item-context.service" + +@Directive() +export class CoreFileUploadItemDeleteTriggerDirective implements OnInit { + protected readonly fileUploadContext = useFileUploadContext() + protected readonly fileUploadItemContext = useFileUploadItemContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getItemDeleteTriggerBindings( + this.fileUploadItemContext(), + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-group.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-group.directive.ts new file mode 100644 index 000000000..47ee05ce0 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-group.directive.ts @@ -0,0 +1,35 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type {ItemGroupProps, ItemType} from "@qualcomm-ui/core/file-upload" +import type {Explicit} from "@qualcomm-ui/utils/guard" + +import {useFileUploadContext} from "./file-upload-context.service" + +@Directive() +export class CoreFileUploadItemGroupDirective + implements SignalifyInput, OnInit +{ + /** + * The type of items in this group (accepted or rejected) + */ + readonly type = input() + + protected readonly fileUploadContext = useFileUploadContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getItemGroupBindings( + computed>(() => ({ + type: this.type(), + }))(), + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-name.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-name.directive.ts new file mode 100644 index 000000000..91c7e5143 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-name.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useFileUploadContext} from "./file-upload-context.service" +import {useFileUploadItemContext} from "./file-upload-item-context.service" + +@Directive() +export class CoreFileUploadItemNameDirective implements OnInit { + protected readonly fileUploadContext = useFileUploadContext() + protected readonly fileUploadItemContext = useFileUploadItemContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getItemNameBindings( + this.fileUploadItemContext(), + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview-image.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview-image.directive.ts new file mode 100644 index 000000000..1b47ac7d3 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview-image.directive.ts @@ -0,0 +1,42 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, input, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type {ItemPreviewImageProps} from "@qualcomm-ui/core/file-upload" + +import {useFileUploadContext} from "./file-upload-context.service" +import {useFileUploadItemContext} from "./file-upload-item-context.service" + +@Directive() +export class CoreFileUploadItemPreviewImageDirective + implements + Omit, "file" | "type">, + OnInit +{ + /** + * The URL of the image to preview + */ + readonly url = input.required() + + protected readonly fileUploadContext = useFileUploadContext() + protected readonly fileUploadItemContext = useFileUploadItemContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getItemPreviewImageBindings( + computed( + () => + ({ + ...this.fileUploadItemContext(), + url: this.url(), + }) as ItemPreviewImageProps, + )(), + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview.directive.ts new file mode 100644 index 000000000..1c58393a0 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useFileUploadContext} from "./file-upload-context.service" +import {useFileUploadItemContext} from "./file-upload-item-context.service" + +@Directive() +export class CoreFileUploadItemPreviewDirective implements OnInit { + protected readonly fileUploadContext = useFileUploadContext() + protected readonly fileUploadItemContext = useFileUploadItemContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getItemPreviewBindings( + this.fileUploadItemContext(), + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-size-text.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-size-text.directive.ts new file mode 100644 index 000000000..a954899d2 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-size-text.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useFileUploadContext} from "./file-upload-context.service" +import {useFileUploadItemContext} from "./file-upload-item-context.service" + +@Directive() +export class CoreFileUploadItemSizeTextDirective implements OnInit { + protected readonly fileUploadContext = useFileUploadContext() + protected readonly fileUploadItemContext = useFileUploadItemContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getItemSizeTextBindings( + this.fileUploadItemContext(), + ), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item.directive.ts new file mode 100644 index 000000000..91eef24eb --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item.directive.ts @@ -0,0 +1,50 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, inject, input, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import type {ItemProps, ItemType} from "@qualcomm-ui/core/file-upload" +import type {Explicit} from "@qualcomm-ui/utils/guard" + +import {useFileUploadContext} from "./file-upload-context.service" +import {FileUploadItemContextService} from "./file-upload-item-context.service" + +@Directive() +export class CoreFileUploadItemDirective + implements SignalifyInput, OnInit +{ + /** + * The file associated with this item + */ + readonly file = input.required() + + /** + * The type of item (accepted or rejected) + */ + readonly type = input() + + protected readonly fileUploadContext = useFileUploadContext() + + protected readonly fileUploadItemService = inject( + FileUploadItemContextService, + ) + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getItemBindings( + this.fileUploadItemService.context(), + ), + ) + + ngOnInit() { + this.fileUploadItemService.init( + computed>(() => ({ + file: this.file(), + type: this.type(), + })), + ) + + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-label.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-label.directive.ts new file mode 100644 index 000000000..c179b865e --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-label.directive.ts @@ -0,0 +1,21 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useFileUploadContext} from "./file-upload-context.service" + +@Directive() +export class CoreFileUploadLabelDirective implements OnInit { + protected readonly fileUploadContext = useFileUploadContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getLabelBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-root.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-root.directive.ts new file mode 100644 index 000000000..46bf51e39 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-root.directive.ts @@ -0,0 +1,245 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + booleanAttribute, + computed, + Directive, + inject, + Injector, + input, + type OnInit, + output, +} from "@angular/core" + +import {numberAttributeOrUndefined} from "@qualcomm-ui/angular-core/attributes" +import { + normalizeProps, + useMachine, + useTrackBindings, +} from "@qualcomm-ui/angular-core/machine" +import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" +import { + createFileUploadApi, + type FileAcceptDetails, + type FileDetails, + type FileRejectDetails, + type FileUploadApiProps, + fileUploadMachine, + type IntlTranslations, +} from "@qualcomm-ui/core/file-upload" +import type {Booleanish, NumberInput} from "@qualcomm-ui/utils/coercion" +import type {Direction} from "@qualcomm-ui/utils/direction" +import type {FileError, FileMimeType} from "@qualcomm-ui/utils/files" +import type {Explicit} from "@qualcomm-ui/utils/guard" + +import {FileUploadContextService} from "./file-upload-context.service" + +@Directive() +export class CoreFileUploadRootDirective + implements Omit, "ids">, OnInit +{ + /** + * The accept file types + */ + readonly accept = input< + Record | FileMimeType | FileMimeType[] | undefined + >() + + /** + * The controlled accepted files + */ + readonly acceptedFiles = input() + + /** + * Whether to allow drag and drop in the dropzone element + * @default true + */ + readonly allowDrop = input(undefined, { + transform: booleanAttribute, + }) + + /** + * The default camera to use when capturing media + */ + readonly capture = input<"user" | "environment" | undefined>() + + /** + * The default accepted files when rendered. + * Use when you don't need to control the accepted files of the input. + */ + readonly defaultAcceptedFiles = input() + + /** + * The document's text/writing direction. + */ + readonly dir = input() + + /** + * A root node to correctly resolve the Document in custom environments. i.e., + * Iframes, Electron. + */ + readonly getRootNode = input<() => ShadowRoot | Document | Node>() + + /** + * Whether to accept directories, only works in webkit browsers + */ + readonly directory = input(undefined, { + transform: booleanAttribute, + }) + + /** + * Whether the file input is disabled + */ + readonly disabled = input(undefined, { + transform: booleanAttribute, + }) + + /** + * Whether the file input is invalid + */ + readonly invalid = input(undefined, { + transform: booleanAttribute, + }) + + /** + * The locale for formatting file sizes + */ + readonly locale = input() + + /** + * The maximum number of files + * @default 1 + */ + readonly maxFiles = input(undefined, { + transform: numberAttributeOrUndefined, + }) + + /** + * The maximum file size in bytes + * + * @default Infinity + */ + readonly maxFileSize = input(undefined, { + transform: numberAttributeOrUndefined, + }) + + /** + * The minimum file size in bytes + * + * @default 0 + */ + readonly minFileSize = input(undefined, { + transform: numberAttributeOrUndefined, + }) + + /** + * The name of the underlying file input + */ + readonly name = input() + + /** + * Whether to prevent the drop event on the document + * @default true + */ + readonly preventDocumentDrop = input( + undefined, + { + transform: booleanAttribute, + }, + ) + + /** + * Whether the file input is required + */ + readonly required = input(undefined, { + transform: booleanAttribute, + }) + + /** + * Function to transform the accepted files to apply transformations + */ + readonly transformFiles = input< + ((files: File[]) => Promise) | undefined + >() + + /** + * The localized messages to use. + */ + readonly translations = input() + + /** + * Function to validate a file + */ + readonly validate = input< + ((file: File, details: FileDetails) => FileError[] | null) | undefined + >() + + /** + * Callback fired when the file is accepted + */ + readonly fileAccepted = output() + + /** + * Callback fired when the value changes, whether accepted or rejected + */ + readonly fileChanged = output() + + /** + * Callback fired when the file is rejected + */ + readonly fileRejected = output() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext.context().getRootBindings(), + ) + + private readonly fileUploadContext = inject(FileUploadContextService) + + private readonly injector = inject(Injector) + + ngOnInit() { + const machine = useMachine( + fileUploadMachine, + computed>(() => ({ + accept: this.accept(), + acceptedFiles: this.acceptedFiles(), + allowDrop: this.allowDrop(), + capture: this.capture(), + defaultAcceptedFiles: this.defaultAcceptedFiles(), + dir: this.dir(), + directory: this.directory(), + disabled: this.disabled(), + getRootNode: this.getRootNode(), + ids: undefined, + invalid: this.invalid(), + locale: this.locale(), + maxFiles: this.maxFiles(), + maxFileSize: this.maxFileSize(), + minFileSize: this.minFileSize(), + name: this.name(), + onFileAccept: (details) => { + this.fileAccepted.emit(details) + }, + onFileChange: (details) => { + this.fileChanged.emit(details) + }, + onFileReject: (details) => { + this.fileRejected.emit(details) + }, + preventDocumentDrop: this.preventDocumentDrop(), + required: this.required(), + transformFiles: this.transformFiles(), + translations: this.translations(), + validate: this.validate(), + })), + this.injector, + ) + + this.fileUploadContext.init( + computed(() => createFileUploadApi(machine, normalizeProps)), + ) + + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-trigger.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-trigger.directive.ts new file mode 100644 index 000000000..0662ded77 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-trigger.directive.ts @@ -0,0 +1,21 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, type OnInit} from "@angular/core" + +import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" + +import {useFileUploadContext} from "./file-upload-context.service" + +@Directive() +export class CoreFileUploadTriggerDirective implements OnInit { + protected readonly fileUploadContext = useFileUploadContext() + + protected readonly trackBindings = useTrackBindings(() => + this.fileUploadContext().getTriggerBindings(), + ) + + ngOnInit() { + this.trackBindings() + } +} diff --git a/packages/frameworks/angular-core/src/file-upload/file-upload-context.service.ts b/packages/frameworks/angular-core/src/file-upload/file-upload-context.service.ts new file mode 100644 index 000000000..0cb2bee23 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/file-upload-context.service.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Injectable} from "@angular/core" + +import { + type ApiContext, + BaseApiContextService, + createApiContext, +} from "@qualcomm-ui/angular-core/machine" +import type {FileUploadApi} from "@qualcomm-ui/core/file-upload" + +@Injectable() +export class FileUploadContextService extends BaseApiContextService {} + +export const [ + FILE_UPLOAD_CONTEXT, + useFileUploadContext, + provideFileUploadContext, +]: ApiContext = createApiContext( + "FileUploadContext", + FileUploadContextService, +) diff --git a/packages/frameworks/angular-core/src/file-upload/file-upload-item-context.service.ts b/packages/frameworks/angular-core/src/file-upload/file-upload-item-context.service.ts new file mode 100644 index 000000000..5c2321763 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/file-upload-item-context.service.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Injectable} from "@angular/core" + +import { + type ApiContext, + BaseApiContextService, + createApiContext, +} from "@qualcomm-ui/angular-core/machine" +import type {ItemProps} from "@qualcomm-ui/core/file-upload" + +@Injectable() +export class FileUploadItemContextService extends BaseApiContextService {} + +export const [ + FILE_UPLOAD_ITEM_CONTEXT, + useFileUploadItemContext, + provideFileUploadItemContext, +]: ApiContext = createApiContext( + "FileUploadItemContext", + FileUploadItemContextService, +) diff --git a/packages/frameworks/angular-core/src/file-upload/index.ts b/packages/frameworks/angular-core/src/file-upload/index.ts new file mode 100644 index 000000000..16525d51a --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/index.ts @@ -0,0 +1,15 @@ +export * from "./core-file-upload-clear-trigger.directive" +export * from "./core-file-upload-dropzone.directive" +export * from "./core-file-upload-hidden-input.directive" +export * from "./core-file-upload-item-delete-trigger.directive" +export * from "./core-file-upload-item-group.directive" +export * from "./core-file-upload-item-name.directive" +export * from "./core-file-upload-item-preview-image.directive" +export * from "./core-file-upload-item-preview.directive" +export * from "./core-file-upload-item-size-text.directive" +export * from "./core-file-upload-item.directive" +export * from "./core-file-upload-label.directive" +export * from "./core-file-upload-root.directive" +export * from "./core-file-upload-trigger.directive" +export * from "./file-upload-context.service" +export * from "./file-upload-item-context.service" diff --git a/packages/frameworks/angular-core/src/file-upload/ng-package.json b/packages/frameworks/angular-core/src/file-upload/ng-package.json new file mode 100644 index 000000000..7e82164ba --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "index.ts" + } +} diff --git a/packages/frameworks/react-core/src/machine/use-bindable-id-collection.ts b/packages/frameworks/react-core/src/machine/use-bindable-id-collection.ts index c0f14b4fe..88334f381 100644 --- a/packages/frameworks/react-core/src/machine/use-bindable-id-collection.ts +++ b/packages/frameworks/react-core/src/machine/use-bindable-id-collection.ts @@ -4,7 +4,8 @@ import {useRef, useState} from "react" import {useSafeLayoutEffect} from "@qualcomm-ui/react-core/effects" -import type {BindableIdCollection, Dict} from "@qualcomm-ui/utils/machine" +import type {BindableIdCollection} from "@qualcomm-ui/utils/machine" +import type {Dict} from "@qualcomm-ui/utils/object" export function useBindableIdCollection(): BindableIdCollection< any, From 49a9d88b9d3580d6df4d80f3d9459b15f793c7f5 Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Thu, 22 Jan 2026 14:05:34 -0800 Subject: [PATCH 05/27] adding angular functionality and demos Signed-off-by: Dustin Pham --- .../core/src/file-upload/file-upload.api.ts | 66 ++-- .../core/src/file-upload/file-upload.types.ts | 12 +- .../src/file-upload/file-upload.api.ts | 13 +- .../src/file-upload/file-upload.types.ts | 8 +- .../src/file-upload/qds-file-upload.css | 322 +++++++++++++++++- .../components+/file-upload+/_file-upload.mdx | 125 +++++++ .../demos/file-upload-composite-demo.ts | 129 +++++++ .../demos/file-upload-simple-demo.ts | 20 ++ ...core-file-upload-hidden-input.directive.ts | 14 +- .../core-file-upload-item-name.directive.ts | 12 +- ...ile-upload-item-preview-image.directive.ts | 15 +- ...re-file-upload-item-size-text.directive.ts | 11 +- .../core-file-upload-label.directive.ts | 14 +- .../core-file-upload-trigger.directive.ts | 14 +- .../angular-core/src/file-upload/test | 1 + .../file-upload-clear-trigger.directive.ts | 23 ++ .../file-upload-context.directive.ts | 29 ++ .../file-upload-dropzone.directive.ts | 23 ++ .../file-upload-hidden-input.directive.ts | 12 + ...le-upload-item-delete-trigger.directive.ts | 25 ++ .../file-upload-item-group.directive.ts | 23 ++ .../file-upload-item-name.directive.ts | 23 ++ ...ile-upload-item-preview-image.directive.ts | 25 ++ .../file-upload-item-preview.directive.ts | 23 ++ .../file-upload-item-size-text.directive.ts | 23 ++ .../file-upload/file-upload-item.directive.ts | 27 ++ .../file-upload-label.directive.ts | 23 ++ .../file-upload/file-upload-root.directive.ts | 60 ++++ .../file-upload-trigger.directive.ts | 23 ++ .../src/file-upload/file-upload.component.ts | 173 ++++++++++ .../src/file-upload/file-upload.module.ts | 61 ++++ .../angular/src/file-upload/index.ts | 17 + .../angular/src/file-upload/ng-package.json | 6 + .../qds-file-upload-context.service.ts | 23 ++ 34 files changed, 1351 insertions(+), 67 deletions(-) create mode 100644 packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx create mode 100644 packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts create mode 100644 packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/test create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-clear-trigger.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-context.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-dropzone.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-hidden-input.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-item-delete-trigger.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-item-group.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-item-name.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-item-preview-image.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-item-preview.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-item-size-text.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-item.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-label.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-root.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-trigger.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload.component.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload.module.ts create mode 100644 packages/frameworks/angular/src/file-upload/index.ts create mode 100644 packages/frameworks/angular/src/file-upload/ng-package.json create mode 100644 packages/frameworks/angular/src/file-upload/qds-file-upload-context.service.ts diff --git a/packages/common/core/src/file-upload/file-upload.api.ts b/packages/common/core/src/file-upload/file-upload.api.ts index 898fb7c17..29f0ae2b9 100644 --- a/packages/common/core/src/file-upload/file-upload.api.ts +++ b/packages/common/core/src/file-upload/file-upload.api.ts @@ -100,7 +100,12 @@ export function createFileUploadApi( type: "button", }) }, - getDropzoneBindings(props = {}): FileUploadDropzoneBindings { + getDropzoneBindings( + props: {disableClick?: boolean; id?: string} = {}, + ): FileUploadDropzoneBindings { + if (props.id) { + scope.ids.register("dropzone", props as {id: string}) + } return normalize.element({ ...commonBindings, "aria-disabled": booleanAriaAttr(disabled), @@ -231,7 +236,8 @@ export function createFileUploadApi( getFileSize(file) { return formatBytes(file.size, prop("locale")) }, - getHiddenInputBindings() { + getHiddenInputBindings(props: {id: string}) { + scope.ids.register("hiddenInput", props) return normalize.input({ ...commonBindings, accept: computed("acceptAttr"), @@ -240,18 +246,18 @@ export function createFileUploadApi( id: domIds.hiddenInput(scope), multiple: computed("multiple") || prop("maxFiles") > 1, name: prop("name"), - onClick(event) { - event.stopPropagation() - // allow for re-selection of the same file - event.currentTarget.value = "" - }, - onInput(event) { + onChange(event) { if (disabled) { return } const {files} = event.currentTarget send({files: files ? Array.from(files) : [], type: "FILE.SELECT"}) }, + onClick(event) { + event.stopPropagation() + // allow for re-selection of the same file + event.currentTarget.value = "" + }, required: prop("required"), style: visuallyHiddenStyle, tabIndex: -1, @@ -259,6 +265,15 @@ export function createFileUploadApi( webkitdirectory: prop("directory") ? "" : undefined, }) }, + getItemBindings(props) { + const {file, type = DEFAULT_ITEM_TYPE} = props + return normalize.element({ + ...commonBindings, + "data-disabled": booleanDataAttr(disabled), + "data-type": type, + id: getItem(scope, file.name), + }) + }, getItemDeleteTriggerBindings(props) { const {file, type = DEFAULT_ITEM_TYPE} = props return normalize.button({ @@ -276,6 +291,7 @@ export function createFileUploadApi( type: "button", }) }, + getItemGroupBindings(props = {}) { const {type = DEFAULT_ITEM_TYPE} = props return normalize.element({ @@ -295,21 +311,6 @@ export function createFileUploadApi( }) }, - getItemPreviewImageBindings(props) { - const {file, type = DEFAULT_ITEM_TYPE, url} = props - const isImage = file.type.startsWith("image/") - if (!isImage) { - throw new Error("Preview Image is only supported for image files") - } - return normalize.img({ - ...commonBindings, - alt: translations.itemPreview?.(file), - "data-disabled": booleanDataAttr(disabled), - "data-type": type, - src: url, - }) - }, - getItemPreviewBindings(props) { const {file, type = DEFAULT_ITEM_TYPE} = props return normalize.element({ @@ -320,13 +321,18 @@ export function createFileUploadApi( }) }, - getItemBindings(props) { - const {file, type = DEFAULT_ITEM_TYPE} = props - return normalize.element({ + getItemPreviewImageBindings(props) { + const {file, type = DEFAULT_ITEM_TYPE, url} = props + const isImage = file.type.startsWith("image/") + if (!isImage) { + throw new Error("Preview Image is only supported for image files") + } + return normalize.img({ ...commonBindings, + alt: translations.itemPreview?.(file), "data-disabled": booleanDataAttr(disabled), "data-type": type, - id: getItem(scope, file.name), + src: url, }) }, @@ -340,7 +346,8 @@ export function createFileUploadApi( }) }, - getLabelBindings() { + getLabelBindings(props: {id: string}) { + scope.ids.register("label", props) return normalize.label({ ...commonBindings, "data-disabled": booleanDataAttr(disabled), @@ -360,7 +367,8 @@ export function createFileUploadApi( }) }, - getTriggerBindings() { + getTriggerBindings(props: {id: string}) { + scope.ids.register("trigger", props) return normalize.button({ ...commonBindings, "data-disabled": booleanDataAttr(disabled), diff --git a/packages/common/core/src/file-upload/file-upload.types.ts b/packages/common/core/src/file-upload/file-upload.types.ts index 5a037ed32..a28718987 100644 --- a/packages/common/core/src/file-upload/file-upload.types.ts +++ b/packages/common/core/src/file-upload/file-upload.types.ts @@ -300,12 +300,14 @@ export interface FileUploadApi { */ focused: boolean getClearTriggerBindings: () => FileUploadClearTriggerBindings - getDropzoneBindings: (props?: DropzoneProps) => FileUploadDropzoneBindings + getDropzoneBindings: ( + props?: DropzoneProps & {id?: string}, + ) => FileUploadDropzoneBindings /** * Returns the formatted file size (e.g. 1.2MB) */ getFileSize: (file: File) => string - getHiddenInputBindings: () => FileUploadHiddenInputBindings + getHiddenInputBindings: (props: {id: string}) => FileUploadHiddenInputBindings getItemBindings: (props: ItemProps) => FileUploadItemIdBindings getItemDeleteTriggerBindings: ( props: ItemProps, @@ -317,9 +319,9 @@ export interface FileUploadApi { props: ItemPreviewImageProps, ) => FileUploadItemPreviewImageBindings getItemSizeTextBindings: (props: ItemProps) => FileUploadItemIdBindings - getLabelBindings: () => FileUploadLabelBindings + getLabelBindings: (props: {id: string}) => FileUploadLabelBindings getRootBindings: () => FileUploadRootBindings - getTriggerBindings: () => FileUploadTriggerBindings + getTriggerBindings: (props: {id: string}) => FileUploadTriggerBindings /** * Function to open the file dialog */ @@ -382,8 +384,8 @@ export interface FileUploadHiddenInputBindings extends CommonBindings { id: string multiple: boolean name?: string + onChange: JSX.FormEventHandler onClick: JSX.MouseEventHandler - onInput: JSX.FormEventHandler required: boolean | undefined style: JSX.CSSProperties tabIndex: -1 diff --git a/packages/common/qds-core/src/file-upload/file-upload.api.ts b/packages/common/qds-core/src/file-upload/file-upload.api.ts index 051bfec0b..50db1ee06 100644 --- a/packages/common/qds-core/src/file-upload/file-upload.api.ts +++ b/packages/common/qds-core/src/file-upload/file-upload.api.ts @@ -31,13 +31,18 @@ export function createQdsFileUploadApi( size, // group: bindings - getClearTriggerBindings(): QdsFileUploadClearTriggerBindings { + getClearTriggerBindings(props?: { + id: string + }): QdsFileUploadClearTriggerBindings { return normalize.element({ className: fileUploadClasses.clearTrigger, "data-size": size, }) }, - getDropzoneBindings(): QdsFileUploadDropzoneBindings { + getDropzoneBindings(props?: { + disableClick?: boolean + id: string + }): QdsFileUploadDropzoneBindings { return normalize.element({ className: fileUploadClasses.dropzone, "data-size": size, @@ -85,7 +90,7 @@ export function createQdsFileUploadApi( "data-size": size, }) }, - getLabelBindings(): QdsFileUploadLabelBindings { + getLabelBindings(props?: {id: string}): QdsFileUploadLabelBindings { return normalize.element({ className: fileUploadClasses.label, "data-size": size, @@ -97,7 +102,7 @@ export function createQdsFileUploadApi( "data-size": size, }) }, - getTriggerBindings(): QdsFileUploadTriggerBindings { + getTriggerBindings(props?: {id: string}): QdsFileUploadTriggerBindings { return normalize.element({ className: fileUploadClasses.trigger, "data-size": size, diff --git a/packages/common/qds-core/src/file-upload/file-upload.types.ts b/packages/common/qds-core/src/file-upload/file-upload.types.ts index 2999d221f..a33271678 100644 --- a/packages/common/qds-core/src/file-upload/file-upload.types.ts +++ b/packages/common/qds-core/src/file-upload/file-upload.types.ts @@ -80,8 +80,8 @@ export interface QdsFileUploadApi { size: QdsFileUploadSize // group: bindings - getClearTriggerBindings(): QdsFileUploadClearTriggerBindings - getDropzoneBindings(): QdsFileUploadDropzoneBindings + getClearTriggerBindings(props?: {id: string}): QdsFileUploadClearTriggerBindings + getDropzoneBindings(props?: {id: string; disableClick?: boolean}): QdsFileUploadDropzoneBindings getItemBindings(): QdsFileUploadItemBindings getItemDeleteTriggerBindings(): QdsFileUploadItemDeleteTriggerBindings getItemGroupBindings(): QdsFileUploadItemGroupBindings @@ -89,7 +89,7 @@ export interface QdsFileUploadApi { getItemPreviewBindings(): QdsFileUploadItemPreviewBindings getItemPreviewImageBindings(): QdsFileUploadItemPreviewImageBindings getItemSizeTextBindings(): QdsFileUploadItemSizeTextBindings - getLabelBindings(): QdsFileUploadLabelBindings + getLabelBindings(props?: {id: string}): QdsFileUploadLabelBindings getRootBindings(): QdsFileUploadRootBindings - getTriggerBindings(): QdsFileUploadTriggerBindings + getTriggerBindings(props?: {id: string}): QdsFileUploadTriggerBindings } diff --git a/packages/common/qds-core/src/file-upload/qds-file-upload.css b/packages/common/qds-core/src/file-upload/qds-file-upload.css index f04336757..eedefbdc7 100644 --- a/packages/common/qds-core/src/file-upload/qds-file-upload.css +++ b/packages/common/qds-core/src/file-upload/qds-file-upload.css @@ -1,7 +1,321 @@ .qui-file-upload__root { + display: flex; + flex-direction: column; + gap: var(--spacing-70); + width: 100%; +} + +.qui-file-upload__label { + color: var(--color-text-neutral-primary); + display: flex; + height: 20px; + + &[data-size="sm"] { + font: var(--font-static-heading-xxs-bold); + } + + &[data-size="md"], + &[data-size="lg"] { + font: var(--font-static-heading-xs-bold); + } +} + +.qui-file-upload__dropzone { + align-items: center; + background: var(--color-surface-secondary); + border: var(--border-width-sm) solid var(--color-interactive-border-neutral-idle); + border-radius: var(--border-radius-md); + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + transition: + border-color var(--animation-duration-faster) ease-out, + box-shadow var(--animation-duration-faster) ease-out; + width: 100%; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + + &[data-size="sm"] { + gap: var(--spacing-90); + height: 144px; + padding: var(--spacing-140); + } + + &[data-size="md"] { + gap: var(--spacing-90); + height: 172px; + padding: var(--spacing-160) var(--spacing-140); + } + + &[data-size="lg"] { + gap: var(--spacing-90); + height: 188px; + padding: var(--spacing-180) var(--spacing-140); + } + + &:hover:not([data-disabled]):not([data-invalid]) { + border-color: var(--color-interactive-border-neutral-hover); + } + + &:focus-visible:not([data-disabled]):not([data-invalid]) { + border-color: var(--color-utility-focus-border); + border-width: var(--border-width-md); + outline: none; + } + + &[data-drag-active]:not([data-disabled]):not([data-invalid]) { + border-color: var(--color-utility-focus-border); + box-shadow: var(--elevation-shadow-low); + } + + &[data-disabled] { + background: var(--color-utility-disabled-background); + border-color: var(--color-utility-disabled-border); + cursor: not-allowed; + pointer-events: none; + } + + &[data-invalid] { + border-color: var(--color-interactive-border-danger-idle); + border-width: var(--border-width-md); + } +} + +.qui-file-upload__trigger { + color: var(--color-interactive-text-link-default-idle); + cursor: pointer; + text-decoration: none; + + &[data-size="sm"] { + font: var(--font-static-body-xs-bold); + } + + &[data-size="md"], + &[data-size="lg"] { + font: var(--font-static-body-sm-bold); + } + + &:hover { + color: var(--color-interactive-text-link-default-hover); + text-decoration: underline; + } + + &:active { + color: var(--color-interactive-text-link-default-pressed); + } + + &[data-disabled] { + color: var(--color-utility-disabled-text); + cursor: not-allowed; + pointer-events: none; + } +} + +.qui-file-upload__item-group { + display: flex; + flex-direction: column; + max-height: 144px; + overflow-x: hidden; + overflow-y: auto; + width: 100%; +} + +.qui-file-upload__item { + align-items: flex-start; + background: var(--color-interactive-background-ghost-idle); + display: flex; + transition: background-color var(--animation-duration-faster) ease-out; + width: 100%; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + + &[data-size="sm"] { + gap: var(--spacing-50); + padding: var(--spacing-70) var(--spacing-90); + } + + &[data-size="md"], + &[data-size="lg"] { + gap: var(--spacing-70); + padding: var(--spacing-80) var(--spacing-100); + } + + &:hover { + background: var(--color-interactive-background-ghost-hover); + } + + &:active { + background: var(--color-interactive-background-ghost-pressed); + } +} + +.qui-file-upload__item-preview { + align-items: flex-start; + display: flex; + flex-shrink: 0; + padding: var(--spacing-10) 0; + + &[data-size="sm"] { + height: 18px; + width: 16px; + } + + &[data-size="md"], + &[data-size="lg"] { + height: 20px; + width: 20px; + } +} + +.qui-file-upload__item-preview-image { + height: 100%; + object-fit: cover; + width: 100%; +} + +.qui-file-upload__item-name { + color: var(--color-text-neutral-primary); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &[data-size="sm"] { + font: var(--font-static-body-xs-default); + } + + &[data-size="md"], + &[data-size="lg"] { + font: var(--font-static-body-sm-default); + } +} + +.qui-file-upload__item-size-text { + color: var(--color-text-neutral-secondary); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &[data-size="sm"] { + font: var(--font-static-body-xs-default); + } + + &[data-size="md"], + &[data-size="lg"] { + font: var(--font-static-body-sm-default); + } + + &[data-invalid] { + color: var(--color-text-support-danger); + } +} + +.qui-file-upload__item-delete-trigger { + align-items: center; + background: transparent; + border: none; + color: var(--color-text-neutral-primary); + cursor: pointer; + display: flex; + flex-shrink: 0; + padding: 0; + transition: color var(--animation-duration-faster) ease-out; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + + &[data-size="sm"] { + height: 18px; + width: 16px; + } + + &[data-size="md"], + &[data-size="lg"] { + height: 20px; + width: 20px; + } + + &:hover { + color: var(--color-interactive-text-neutral-hover); + } + + &:active { + color: var(--color-interactive-text-neutral-pressed); + } + + &[data-disabled] { + color: var(--color-utility-disabled-text); + cursor: not-allowed; + pointer-events: none; + } +} + +.qui-file-upload__clear-trigger { align-items: center; - display: inline-flex; - flex-wrap: wrap; - gap: 20px; - justify-content: space-between; + background: var(--color-interactive-background-ghost-idle); + border: var(--border-width-sm) solid var(--color-interactive-border-neutral-idle); + border-radius: var(--border-radius-md); + box-sizing: border-box; + color: var(--color-interactive-text-neutral-idle); + cursor: pointer; + display: flex; + justify-content: center; + transition: + background-color var(--animation-duration-faster) ease-out, + border-color var(--animation-duration-faster) ease-out, + color var(--animation-duration-faster) ease-out; + width: fit-content; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + + &[data-size="sm"] { + font: var(--font-static-body-xs-bold); + gap: var(--spacing-40); + height: var(--sizing-80); + padding: 0 var(--spacing-90); + } + + &[data-size="md"] { + font: var(--font-static-body-sm-bold); + gap: var(--spacing-50); + height: var(--sizing-90); + padding: 0 var(--spacing-100); + } + + &[data-size="lg"] { + font: var(--font-static-body-md-bold); + gap: var(--spacing-70); + height: var(--sizing-100); + padding: 0 var(--spacing-110); + } + + &:hover { + background: var(--color-interactive-background-ghost-hover); + border-color: var(--color-interactive-border-neutral-hover); + color: var(--color-interactive-text-neutral-hover); + } + + &:active { + background: var(--color-interactive-background-ghost-pressed); + border-color: var(--color-interactive-border-neutral-pressed); + color: var(--color-interactive-text-neutral-pressed); + } + + &[data-disabled] { + border-color: var(--color-utility-disabled-border); + color: var(--color-utility-disabled-text); + cursor: not-allowed; + pointer-events: none; + } } diff --git a/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx b/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx new file mode 100644 index 000000000..317f35862 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx @@ -0,0 +1,125 @@ +--- +title: File Upload +group: Form Controls +description: The File Upload component provides an accessible interface for selecting and uploading files, with support for drag-and-drop, file validation, and preview functionality. +component: FileUpload +--- + +import {QdsDemo} from "~components/demo" +import {TypeDocProps} from "@qualcomm-ui/react-mdx/typedoc" + +# {frontmatter.title} + +{frontmatter.description} + +```typescript +import {FileUploadModule} from "@qualcomm-ui/angular/file-upload" +``` + +## Overview + +The File Upload component supports both click-to-browse and drag-and-drop interactions. It provides file validation, size limits, and customizable UI for displaying accepted and rejected files. + +## Examples + +### Simple + +The simple API provides a standalone component with built-in layout. + + + +### Composite + +Build with the composite API for granular control. This API requires you to provide each subcomponent, but gives you full control over the structure and layout. + + + +## API + +### \ + +The simple file upload extends the [q-file-upload-root](./#q-file-upload-root) directive with the following properties: + + + +## Composite API + +This section describes the directives available in the File Upload composite API. + +### q-file-upload-root + +The root directive provides context and state for all child directives. + + + +### q-file-upload-label + +A label for the file input. + + + +### q-file-upload-trigger + +A button that opens the file picker when activated. + + + +### q-file-upload-hidden-input + +The hidden file input element. + + + +### q-file-upload-dropzone + +A drop target for drag-and-drop file uploads. + + + +### q-file-upload-item-group + +A container for the list of file items. + + + +### q-file-upload-item + +A container for each individual file. + + + +### q-file-upload-item-name + +Displays the file name. + + + +### q-file-upload-item-size-text + +Displays the formatted file size. + + + +### q-file-upload-item-preview + +A container for file preview. + + + +### q-file-upload-item-preview-image + +Displays an image preview for image files. + + + +### q-file-upload-item-delete-trigger + +A button to remove a specific file. + + + +### q-file-upload-clear-trigger + +A button to clear all files. + + diff --git a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts new file mode 100644 index 000000000..11a3a6b7e --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts @@ -0,0 +1,129 @@ +import {Component, DestroyRef, inject, signal} from "@angular/core" +import {File, FilePlus, Trash2, Upload} from "lucide-angular" + +import type {FileDetails} from "@qualcomm-ui/core/file-upload" +import {FileUploadModule} from "@qualcomm-ui/angular/file-upload" +import {IconDirective} from "@qualcomm-ui/angular/icon" +import {provideIcons} from "@qualcomm-ui/angular-core/lucide" + +@Component({ + imports: [FileUploadModule, IconDirective], + providers: [provideIcons({File, FilePlus, Trash2, Upload})], + selector: "file-upload-composite-demo", + standalone: true, + template: ` + +
+ + +
+ +
+
+ Drag & drop images, or + +
+ + Supported file types: Images only + +
+
+ + + + + @if (ctx.acceptedFiles.length > 0) { +
+ @for (file of ctx.acceptedFiles; track file) { +
+
+ @if (fileUrls()[file.name]) { + + } +
+
+ + +
+ +
+ } +
+ + + } +
+
+ + `, +}) +export class FileUploadCompositeDemo { + private readonly destroyRef = inject(DestroyRef) + + fileUrls = signal>({}) + + constructor() { + this.destroyRef.onDestroy(() => { + Object.values(this.fileUrls()).forEach((url) => { + URL.revokeObjectURL(url) + }) + }) + } + + onFileChanged(details: FileDetails): void { + const currentUrls = this.fileUrls() + const newUrls: Record = {} + const currentFileNames = new Set(details.acceptedFiles.map((f) => f.name)) + + details.acceptedFiles.forEach((file) => { + if (currentUrls[file.name]) { + newUrls[file.name] = currentUrls[file.name] + } else { + newUrls[file.name] = URL.createObjectURL(file) + } + }) + + Object.keys(currentUrls).forEach((fileName) => { + if (!currentFileNames.has(fileName)) { + URL.revokeObjectURL(currentUrls[fileName]) + } + }) + + this.fileUrls.set(newUrls) + } +} diff --git a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.ts b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.ts new file mode 100644 index 000000000..f28c451f4 --- /dev/null +++ b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.ts @@ -0,0 +1,20 @@ +import {Component} from "@angular/core" + +import {FileUploadModule} from "@qualcomm-ui/angular/file-upload" + +@Component({ + imports: [FileUploadModule], + selector: "file-upload-simple-demo", + standalone: true, + template: ` + + + + `, +}) +export class FileUploadSimpleDemo {} diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-hidden-input.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-hidden-input.directive.ts index eecc36fcb..6be14a560 100644 --- a/packages/frameworks/angular-core/src/file-upload/core-file-upload-hidden-input.directive.ts +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-hidden-input.directive.ts @@ -1,18 +1,28 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -import {Directive, type OnInit} from "@angular/core" +import {Directive, input, type OnInit} from "@angular/core" +import {useId} from "@qualcomm-ui/angular-core/common" import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" import {useFileUploadContext} from "./file-upload-context.service" @Directive() export class CoreFileUploadHiddenInputDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + protected readonly fileUploadContext = useFileUploadContext() + private readonly generatedId = useId(this, null) protected readonly trackBindings = useTrackBindings(() => - this.fileUploadContext().getHiddenInputBindings(), + this.fileUploadContext().getHiddenInputBindings({ + id: this.id() || this.generatedId, + }), ) ngOnInit() { diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-name.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-name.directive.ts index 91c7e5143..0daf31d2e 100644 --- a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-name.directive.ts +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-name.directive.ts @@ -1,7 +1,7 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -import {Directive, type OnInit} from "@angular/core" +import {Directive, ElementRef, inject, type OnInit} from "@angular/core" import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" @@ -12,14 +12,18 @@ import {useFileUploadItemContext} from "./file-upload-item-context.service" export class CoreFileUploadItemNameDirective implements OnInit { protected readonly fileUploadContext = useFileUploadContext() protected readonly fileUploadItemContext = useFileUploadItemContext() + private readonly elementRef = inject(ElementRef) protected readonly trackBindings = useTrackBindings(() => - this.fileUploadContext().getItemNameBindings( - this.fileUploadItemContext(), - ), + this.fileUploadContext().getItemNameBindings(this.fileUploadItemContext()), ) ngOnInit() { this.trackBindings() + + const itemContext = this.fileUploadItemContext() + if (itemContext.file) { + this.elementRef.nativeElement.textContent = itemContext.file.name + } } } diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview-image.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview-image.directive.ts index 1b47ac7d3..29767abac 100644 --- a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview-image.directive.ts +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-preview-image.directive.ts @@ -1,7 +1,7 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -import {computed, Directive, input, type OnInit} from "@angular/core" +import {Directive, input, type OnInit} from "@angular/core" import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" import type {SignalifyInput} from "@qualcomm-ui/angular-core/signals" @@ -25,15 +25,10 @@ export class CoreFileUploadItemPreviewImageDirective protected readonly fileUploadItemContext = useFileUploadItemContext() protected readonly trackBindings = useTrackBindings(() => - this.fileUploadContext().getItemPreviewImageBindings( - computed( - () => - ({ - ...this.fileUploadItemContext(), - url: this.url(), - }) as ItemPreviewImageProps, - )(), - ), + this.fileUploadContext().getItemPreviewImageBindings({ + ...this.fileUploadItemContext(), + url: this.url(), + }), ) ngOnInit() { diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-size-text.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-size-text.directive.ts index a954899d2..57528afc7 100644 --- a/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-size-text.directive.ts +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-item-size-text.directive.ts @@ -1,7 +1,7 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -import {Directive, type OnInit} from "@angular/core" +import {Directive, ElementRef, inject, type OnInit} from "@angular/core" import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" @@ -12,6 +12,7 @@ import {useFileUploadItemContext} from "./file-upload-item-context.service" export class CoreFileUploadItemSizeTextDirective implements OnInit { protected readonly fileUploadContext = useFileUploadContext() protected readonly fileUploadItemContext = useFileUploadItemContext() + private readonly elementRef = inject(ElementRef) protected readonly trackBindings = useTrackBindings(() => this.fileUploadContext().getItemSizeTextBindings( @@ -21,5 +22,13 @@ export class CoreFileUploadItemSizeTextDirective implements OnInit { ngOnInit() { this.trackBindings() + + const context = this.fileUploadContext() + const itemContext = this.fileUploadItemContext() + if (itemContext.file) { + this.elementRef.nativeElement.textContent = context.getFileSize( + itemContext.file, + ) + } } } diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-label.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-label.directive.ts index c179b865e..9ed6bd475 100644 --- a/packages/frameworks/angular-core/src/file-upload/core-file-upload-label.directive.ts +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-label.directive.ts @@ -1,18 +1,28 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -import {Directive, type OnInit} from "@angular/core" +import {computed, Directive, input, type OnInit} from "@angular/core" +import {useId} from "@qualcomm-ui/angular-core/common" import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" import {useFileUploadContext} from "./file-upload-context.service" @Directive() export class CoreFileUploadLabelDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + protected readonly fileUploadContext = useFileUploadContext() + private readonly generatedId = useId(this, null) protected readonly trackBindings = useTrackBindings(() => - this.fileUploadContext().getLabelBindings(), + this.fileUploadContext().getLabelBindings({ + id: this.id() || this.generatedId, + }), ) ngOnInit() { diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-trigger.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-trigger.directive.ts index 0662ded77..d517c0679 100644 --- a/packages/frameworks/angular-core/src/file-upload/core-file-upload-trigger.directive.ts +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-trigger.directive.ts @@ -1,18 +1,28 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -import {Directive, type OnInit} from "@angular/core" +import {computed, Directive, input, type OnInit} from "@angular/core" +import {useId} from "@qualcomm-ui/angular-core/common" import {useTrackBindings} from "@qualcomm-ui/angular-core/machine" import {useFileUploadContext} from "./file-upload-context.service" @Directive() export class CoreFileUploadTriggerDirective implements OnInit { + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input() + protected readonly fileUploadContext = useFileUploadContext() + private readonly generatedId = useId(this, null) protected readonly trackBindings = useTrackBindings(() => - this.fileUploadContext().getTriggerBindings(), + this.fileUploadContext().getTriggerBindings({ + id: this.id() || this.generatedId, + }), ) ngOnInit() { diff --git a/packages/frameworks/angular-core/src/file-upload/test b/packages/frameworks/angular-core/src/file-upload/test new file mode 100644 index 000000000..597a6db29 --- /dev/null +++ b/packages/frameworks/angular-core/src/file-upload/test @@ -0,0 +1 @@ +i \ No newline at end of file diff --git a/packages/frameworks/angular/src/file-upload/file-upload-clear-trigger.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-clear-trigger.directive.ts new file mode 100644 index 000000000..698262bed --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-clear-trigger.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreFileUploadClearTriggerDirective} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + selector: "[q-file-upload-clear-trigger]", + standalone: false, +}) +export class FileUploadClearTriggerDirective extends CoreFileUploadClearTriggerDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsFileUploadContext().getClearTriggerBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-context.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-context.directive.ts new file mode 100644 index 000000000..bd762547f --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-context.directive.ts @@ -0,0 +1,29 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive, inject} from "@angular/core" + +import {ApiContextDirective} from "@qualcomm-ui/angular-core/machine" +import {FileUploadContextService} from "@qualcomm-ui/angular-core/file-upload" +import type {FileUploadApi} from "@qualcomm-ui/core/file-upload" + +@Directive({ + selector: "[fileUploadContext]", + standalone: false, +}) +export class FileUploadContextDirective extends ApiContextDirective { + constructor() { + const contextService = inject(FileUploadContextService) + super(contextService, "fileUploadContext") + } + + /** + * Provides intellisense for the context in the template. + */ + static ngTemplateContextGuard( + dir: FileUploadContextDirective, + ctx: unknown, + ): ctx is {$implicit: FileUploadApi} { + return true + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-dropzone.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-dropzone.directive.ts new file mode 100644 index 000000000..e4e83f973 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-dropzone.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreFileUploadDropzoneDirective} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + selector: "[q-file-upload-dropzone]", + standalone: false, +}) +export class FileUploadDropzoneDirective extends CoreFileUploadDropzoneDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsFileUploadContext().getDropzoneBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-hidden-input.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-hidden-input.directive.ts new file mode 100644 index 000000000..547056787 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-hidden-input.directive.ts @@ -0,0 +1,12 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Directive} from "@angular/core" + +import {CoreFileUploadHiddenInputDirective} from "@qualcomm-ui/angular-core/file-upload" + +@Directive({ + selector: "[q-file-upload-hidden-input]", + standalone: false, +}) +export class FileUploadHiddenInputDirective extends CoreFileUploadHiddenInputDirective {} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-item-delete-trigger.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-item-delete-trigger.directive.ts new file mode 100644 index 000000000..253433eac --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-item-delete-trigger.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreFileUploadItemDeleteTriggerDirective} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + selector: "[q-file-upload-item-delete-trigger]", + standalone: false, +}) +export class FileUploadItemDeleteTriggerDirective extends CoreFileUploadItemDeleteTriggerDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => + this.qdsFileUploadContext().getItemDeleteTriggerBindings(), + ), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-item-group.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-item-group.directive.ts new file mode 100644 index 000000000..cbecc5879 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-item-group.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreFileUploadItemGroupDirective} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + selector: "[q-file-upload-item-group]", + standalone: false, +}) +export class FileUploadItemGroupDirective extends CoreFileUploadItemGroupDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsFileUploadContext().getItemGroupBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-item-name.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-item-name.directive.ts new file mode 100644 index 000000000..d6d31c967 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-item-name.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreFileUploadItemNameDirective} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + selector: "[q-file-upload-item-name]", + standalone: false, +}) +export class FileUploadItemNameDirective extends CoreFileUploadItemNameDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsFileUploadContext().getItemNameBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-item-preview-image.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-item-preview-image.directive.ts new file mode 100644 index 000000000..9bf41a4b7 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-item-preview-image.directive.ts @@ -0,0 +1,25 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreFileUploadItemPreviewImageDirective} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + selector: "[q-file-upload-item-preview-image]", + standalone: false, +}) +export class FileUploadItemPreviewImageDirective extends CoreFileUploadItemPreviewImageDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => + this.qdsFileUploadContext().getItemPreviewImageBindings(), + ), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-item-preview.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-item-preview.directive.ts new file mode 100644 index 000000000..39155112b --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-item-preview.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreFileUploadItemPreviewDirective} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + selector: "[q-file-upload-item-preview]", + standalone: false, +}) +export class FileUploadItemPreviewDirective extends CoreFileUploadItemPreviewDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsFileUploadContext().getItemPreviewBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-item-size-text.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-item-size-text.directive.ts new file mode 100644 index 000000000..b92a937f3 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-item-size-text.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreFileUploadItemSizeTextDirective} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + selector: "[q-file-upload-item-size-text]", + standalone: false, +}) +export class FileUploadItemSizeTextDirective extends CoreFileUploadItemSizeTextDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsFileUploadContext().getItemSizeTextBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-item.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-item.directive.ts new file mode 100644 index 000000000..9483e25b0 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-item.directive.ts @@ -0,0 +1,27 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import { + CoreFileUploadItemDirective, + provideFileUploadItemContext, +} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + providers: [provideFileUploadItemContext()], + selector: "[q-file-upload-item]", + standalone: false, +}) +export class FileUploadItemDirective extends CoreFileUploadItemDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsFileUploadContext().getItemBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-label.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-label.directive.ts new file mode 100644 index 000000000..80d999602 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-label.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreFileUploadLabelDirective} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + selector: "[q-file-upload-label]", + standalone: false, +}) +export class FileUploadLabelDirective extends CoreFileUploadLabelDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsFileUploadContext().getLabelBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-root.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-root.directive.ts new file mode 100644 index 000000000..051511de0 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-root.directive.ts @@ -0,0 +1,60 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive, inject, input, type OnInit} from "@angular/core" + +import {normalizeProps} from "@qualcomm-ui/angular-core/machine" +import { + CoreFileUploadRootDirective, + provideFileUploadContext, +} from "@qualcomm-ui/angular-core/file-upload" +import { + createQdsFileUploadApi, + type QdsFileUploadSize, +} from "@qualcomm-ui/qds-core/file-upload" + +import { + provideQdsFileUploadContext, + QdsFileUploadContextService, +} from "./qds-file-upload-context.service" + +@Directive({ + providers: [provideFileUploadContext(), provideQdsFileUploadContext()], + selector: "[q-file-upload-root]", + standalone: false, +}) +export class FileUploadRootDirective + extends CoreFileUploadRootDirective + implements OnInit +{ + /** + * Governs the size and padding of file upload elements. + * + * @default 'sm' + */ + readonly size = input() + + protected readonly fileUploadService = inject(QdsFileUploadContextService) + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.fileUploadService.context().getRootBindings()), + ) + } + + override ngOnInit() { + super.ngOnInit() + + this.fileUploadService.init( + computed(() => + createQdsFileUploadApi( + { + size: this.size(), + }, + normalizeProps, + ), + ), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload-trigger.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-trigger.directive.ts new file mode 100644 index 000000000..bd245dd6a --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload-trigger.directive.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {computed, Directive} from "@angular/core" + +import {CoreFileUploadTriggerDirective} from "@qualcomm-ui/angular-core/file-upload" + +import {useQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Directive({ + selector: "[q-file-upload-trigger]", + standalone: false, +}) +export class FileUploadTriggerDirective extends CoreFileUploadTriggerDirective { + protected readonly qdsFileUploadContext = useQdsFileUploadContext() + + constructor() { + super() + this.trackBindings.extendWith( + computed(() => this.qdsFileUploadContext().getTriggerBindings()), + ) + } +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload.component.ts b/packages/frameworks/angular/src/file-upload/file-upload.component.ts new file mode 100644 index 000000000..36b6f3251 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload.component.ts @@ -0,0 +1,173 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + contentChild, + input, +} from "@angular/core" +import {File, FilePlus, Trash2, Upload} from "lucide-angular" + +import {provideFileUploadContext} from "@qualcomm-ui/angular-core/file-upload" +import {provideIcons} from "@qualcomm-ui/angular-core/lucide" +import type {Booleanish} from "@qualcomm-ui/utils/coercion" + +import {FileUploadDropzoneDirective} from "./file-upload-dropzone.directive" +import {FileUploadItemGroupDirective} from "./file-upload-item-group.directive" +import {FileUploadRootDirective} from "./file-upload-root.directive" +import {provideQdsFileUploadContext} from "./qds-file-upload-context.service" + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + provideIcons({File, FilePlus, Trash2, Upload}), + provideFileUploadContext(), + provideQdsFileUploadContext(), + ], + selector: "q-file-upload", + standalone: false, + template: ` + + @if (label()) { + + } + + + + @if (shouldRenderDefaultDropzone()) { +
+
+ +
+
+ Drag & drop files, or + +
+ @if (dropzoneHint()) { + + {{ dropzoneHint() }} + + } +
+
+
+ } +
+ + + + + @if (ctx.acceptedFiles.length > 0) { + + @if (shouldRenderDefaultItemGroup()) { +
+ @for (file of ctx.acceptedFiles; track file) { +
+ +
+ + +
+ +
+ } +
+ } +
+ + @if (showClearButton() && ctx.acceptedFiles.length > 0) { + + } + } +
+ + @if (errorText() && invalid()) { +
+ {{ errorText() }} +
+ } + `, +}) +export class FileUploadComponent extends FileUploadRootDirective { + /** + * Optional label describing the file upload. This element is automatically + * associated with the hidden input element for accessibility. + * + * @remarks + * To customize the element, provide it using the directive instead: + * + * ```angular-html + * + * ``` + */ + readonly label = input() + + /** + * Optional hint text to display in the dropzone below the main instruction. + * Typically used to show supported file types or size limits. + * + * @example "Supported file types: .jpg, .png, .pdf" + */ + readonly dropzoneHint = input() + + /** + * Optional error message that describes the element when {@link invalid} is true. + */ + readonly errorText = input() + + /** + * When `true`, renders a button to clear all accepted files. + * + * @default true + */ + readonly showClearButton = input(true, { + transform: booleanAttribute, + }) + + /** + * Text to display on the clear button. + * + * @default "Add more files" + */ + readonly clearButtonText = input("Add more files") + + protected readonly customDropzone = contentChild(FileUploadDropzoneDirective) + protected readonly customItemGroup = contentChild( + FileUploadItemGroupDirective, + ) + + protected readonly shouldRenderDefaultDropzone = computed( + () => !this.customDropzone(), + ) + protected readonly shouldRenderDefaultItemGroup = computed( + () => !this.customItemGroup(), + ) +} diff --git a/packages/frameworks/angular/src/file-upload/file-upload.module.ts b/packages/frameworks/angular/src/file-upload/file-upload.module.ts new file mode 100644 index 000000000..43d86fae3 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/file-upload.module.ts @@ -0,0 +1,61 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {NgModule} from "@angular/core" + +import {IconDirective} from "@qualcomm-ui/angular/icon" + +import {FileUploadClearTriggerDirective} from "./file-upload-clear-trigger.directive" +import {FileUploadContextDirective} from "./file-upload-context.directive" +import {FileUploadDropzoneDirective} from "./file-upload-dropzone.directive" +import {FileUploadHiddenInputDirective} from "./file-upload-hidden-input.directive" +import {FileUploadItemDeleteTriggerDirective} from "./file-upload-item-delete-trigger.directive" +import {FileUploadItemGroupDirective} from "./file-upload-item-group.directive" +import {FileUploadItemNameDirective} from "./file-upload-item-name.directive" +import {FileUploadItemPreviewImageDirective} from "./file-upload-item-preview-image.directive" +import {FileUploadItemPreviewDirective} from "./file-upload-item-preview.directive" +import {FileUploadItemSizeTextDirective} from "./file-upload-item-size-text.directive" +import {FileUploadItemDirective} from "./file-upload-item.directive" +import {FileUploadLabelDirective} from "./file-upload-label.directive" +import {FileUploadRootDirective} from "./file-upload-root.directive" +import {FileUploadTriggerDirective} from "./file-upload-trigger.directive" +import {FileUploadComponent} from "./file-upload.component" + +@NgModule({ + declarations: [ + FileUploadComponent, + FileUploadRootDirective, + FileUploadContextDirective, + FileUploadLabelDirective, + FileUploadTriggerDirective, + FileUploadDropzoneDirective, + FileUploadClearTriggerDirective, + FileUploadHiddenInputDirective, + FileUploadItemDirective, + FileUploadItemGroupDirective, + FileUploadItemNameDirective, + FileUploadItemPreviewDirective, + FileUploadItemPreviewImageDirective, + FileUploadItemSizeTextDirective, + FileUploadItemDeleteTriggerDirective, + ], + exports: [ + FileUploadComponent, + FileUploadRootDirective, + FileUploadContextDirective, + FileUploadLabelDirective, + FileUploadTriggerDirective, + FileUploadDropzoneDirective, + FileUploadClearTriggerDirective, + FileUploadHiddenInputDirective, + FileUploadItemDirective, + FileUploadItemGroupDirective, + FileUploadItemNameDirective, + FileUploadItemPreviewDirective, + FileUploadItemPreviewImageDirective, + FileUploadItemSizeTextDirective, + FileUploadItemDeleteTriggerDirective, + ], + imports: [IconDirective], +}) +export class FileUploadModule {} diff --git a/packages/frameworks/angular/src/file-upload/index.ts b/packages/frameworks/angular/src/file-upload/index.ts new file mode 100644 index 000000000..0babad559 --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/index.ts @@ -0,0 +1,17 @@ +export * from "./file-upload-clear-trigger.directive" +export * from "./file-upload-context.directive" +export * from "./file-upload-dropzone.directive" +export * from "./file-upload-hidden-input.directive" +export * from "./file-upload-item-delete-trigger.directive" +export * from "./file-upload-item-group.directive" +export * from "./file-upload-item-name.directive" +export * from "./file-upload-item-preview-image.directive" +export * from "./file-upload-item-preview.directive" +export * from "./file-upload-item-size-text.directive" +export * from "./file-upload-item.directive" +export * from "./file-upload-label.directive" +export * from "./file-upload-root.directive" +export * from "./file-upload-trigger.directive" +export * from "./file-upload.component" +export * from "./file-upload.module" +export * from "./qds-file-upload-context.service" diff --git a/packages/frameworks/angular/src/file-upload/ng-package.json b/packages/frameworks/angular/src/file-upload/ng-package.json new file mode 100644 index 000000000..020455f6f --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./index.ts" + } +} diff --git a/packages/frameworks/angular/src/file-upload/qds-file-upload-context.service.ts b/packages/frameworks/angular/src/file-upload/qds-file-upload-context.service.ts new file mode 100644 index 000000000..3f652f00c --- /dev/null +++ b/packages/frameworks/angular/src/file-upload/qds-file-upload-context.service.ts @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {Injectable} from "@angular/core" + +import { + type ApiContext, + BaseApiContextService, + createApiContext, +} from "@qualcomm-ui/angular-core/machine" +import type {QdsFileUploadApi} from "@qualcomm-ui/qds-core/file-upload" + +@Injectable() +export class QdsFileUploadContextService extends BaseApiContextService {} + +export const [ + QDS_FILE_UPLOAD_CONTEXT, + useQdsFileUploadContext, + provideQdsFileUploadContext, +]: ApiContext = createApiContext( + "QdsFileUploadContext", + QdsFileUploadContextService, +) From 1518fa50ef7afa41978c1eea5ab4c0e2aeef5ebd Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Fri, 23 Jan 2026 17:19:35 -0800 Subject: [PATCH 06/27] md default size, add more styling to match figma, fix composite functionality to hide dropzone and replace with item group Signed-off-by: Dustin Pham --- .../src/file-upload/file-upload.api.ts | 2 +- .../src/file-upload/file-upload.classes.ts | 5 + .../src/file-upload/file-upload.types.ts | 2 +- .../src/file-upload/qds-file-upload.css | 110 ++++++++++++++++-- .../demos/file-upload-composite-demo.ts | 94 ++++++++------- .../file-upload/file-upload-root.directive.ts | 2 +- 6 files changed, 160 insertions(+), 55 deletions(-) diff --git a/packages/common/qds-core/src/file-upload/file-upload.api.ts b/packages/common/qds-core/src/file-upload/file-upload.api.ts index 50db1ee06..5a405c2f5 100644 --- a/packages/common/qds-core/src/file-upload/file-upload.api.ts +++ b/packages/common/qds-core/src/file-upload/file-upload.api.ts @@ -25,7 +25,7 @@ export function createQdsFileUploadApi( props: QdsFileUploadApiProps, normalize: PropNormalizer, ): QdsFileUploadApi { - const size = props.size || "sm" + const size = props.size || "md" return { size, diff --git a/packages/common/qds-core/src/file-upload/file-upload.classes.ts b/packages/common/qds-core/src/file-upload/file-upload.classes.ts index 3918678f4..3ca6e899a 100644 --- a/packages/common/qds-core/src/file-upload/file-upload.classes.ts +++ b/packages/common/qds-core/src/file-upload/file-upload.classes.ts @@ -4,7 +4,12 @@ export const fileUploadClasses = { clearTrigger: "qui-file-upload__clear-trigger", dropzone: "qui-file-upload__dropzone", + dropzoneHint: "qui-file-upload__dropzone-hint", + dropzoneText: "qui-file-upload__dropzone-text", + dropzoneTextGroup: "qui-file-upload__dropzone-text-group", + dropzoneTextLine: "qui-file-upload__dropzone-text-line", item: "qui-file-upload__item", + itemContent: "qui-file-upload__item-content", itemDeleteTrigger: "qui-file-upload__item-delete-trigger", itemGroup: "qui-file-upload__item-group", itemName: "qui-file-upload__item-name", diff --git a/packages/common/qds-core/src/file-upload/file-upload.types.ts b/packages/common/qds-core/src/file-upload/file-upload.types.ts index a33271678..f1b309567 100644 --- a/packages/common/qds-core/src/file-upload/file-upload.types.ts +++ b/packages/common/qds-core/src/file-upload/file-upload.types.ts @@ -9,7 +9,7 @@ export interface QdsFileUploadApiProps { /** * Governs the size and padding of file upload elements. * - * @default 'sm' + * @default 'md' */ size?: QdsFileUploadSize } diff --git a/packages/common/qds-core/src/file-upload/qds-file-upload.css b/packages/common/qds-core/src/file-upload/qds-file-upload.css index eedefbdc7..f9da345d9 100644 --- a/packages/common/qds-core/src/file-upload/qds-file-upload.css +++ b/packages/common/qds-core/src/file-upload/qds-file-upload.css @@ -23,7 +23,8 @@ .qui-file-upload__dropzone { align-items: center; background: var(--color-surface-secondary); - border: var(--border-width-sm) solid var(--color-interactive-border-neutral-idle); + border: var(--border-width-sm) solid + var(--color-interactive-border-neutral-idle); border-radius: var(--border-radius-md); box-sizing: border-box; display: flex; @@ -67,9 +68,19 @@ outline: none; } - &[data-drag-active]:not([data-disabled]):not([data-invalid]) { + &[data-dragging]:not([data-disabled]):not([data-invalid]) { border-color: var(--color-utility-focus-border); - box-shadow: var(--elevation-shadow-low); + box-shadow: var(--shadow-low); + + & .qui-file-upload__dropzone-text, + & .qui-file-upload__dropzone-hint, + & .qui-file-upload__trigger { + color: var(--color-utility-disabled-text); + } + + & svg { + color: var(--color-utility-disabled-text); + } } &[data-disabled] { @@ -85,9 +96,67 @@ } } +.qui-file-upload__dropzone[data-size="sm"] + .qui-file-upload__dropzone-text-group { + align-items: center; + display: flex; + flex-direction: column; + gap: var(--spacing-70); +} + +.qui-file-upload__dropzone[data-size="md"] + .qui-file-upload__dropzone-text-group, +.qui-file-upload__dropzone[data-size="lg"] + .qui-file-upload__dropzone-text-group { + align-items: center; + display: flex; + flex-direction: column; + gap: var(--spacing-70); +} + +.qui-file-upload__dropzone[data-size="sm"] + .qui-file-upload__dropzone-text-line { + align-items: center; + display: flex; + gap: var(--spacing-20); +} + +.qui-file-upload__dropzone[data-size="md"] .qui-file-upload__dropzone-text-line, +.qui-file-upload__dropzone[data-size="lg"] + .qui-file-upload__dropzone-text-line { + align-items: center; + display: flex; + gap: var(--spacing-20); +} + +.qui-file-upload__dropzone[data-size="sm"] .qui-file-upload__dropzone-text { + color: var(--color-text-neutral-primary); + font: var(--font-static-body-xs-default); +} + +.qui-file-upload__dropzone[data-size="md"] .qui-file-upload__dropzone-text, +.qui-file-upload__dropzone[data-size="lg"] .qui-file-upload__dropzone-text { + color: var(--color-text-neutral-primary); + font: var(--font-static-body-sm-default); +} + +.qui-file-upload__dropzone[data-size="sm"] .qui-file-upload__dropzone-hint { + color: var(--color-text-neutral-secondary); + font: var(--font-static-body-xs-default); +} + +.qui-file-upload__dropzone[data-size="md"] .qui-file-upload__dropzone-hint, +.qui-file-upload__dropzone[data-size="lg"] .qui-file-upload__dropzone-hint { + color: var(--color-text-neutral-secondary); + font: var(--font-static-body-xs-default); +} + .qui-file-upload__trigger { + background: transparent; + border: none; color: var(--color-interactive-text-link-default-idle); cursor: pointer; + padding: 0; text-decoration: none; &[data-size="sm"] { @@ -116,16 +185,20 @@ } .qui-file-upload__item-group { + border: var(--border-width-sm) solid var(--color-border-neutral-02); + border-radius: var(--border-radius-md); display: flex; flex-direction: column; + gap: var(--spacing-100); max-height: 144px; overflow-x: hidden; overflow-y: auto; + padding: var(--spacing-100); width: 100%; } .qui-file-upload__item { - align-items: flex-start; + align-items: center; background: var(--color-interactive-background-ghost-idle); display: flex; transition: background-color var(--animation-duration-faster) ease-out; @@ -156,20 +229,23 @@ } .qui-file-upload__item-preview { - align-items: flex-start; + align-items: center; + border: var(--border-width-sm) solid var(--color-border-neutral-02); + border-radius: var(--border-radius-sm); display: flex; flex-shrink: 0; + overflow: hidden; padding: var(--spacing-10) 0; &[data-size="sm"] { - height: 18px; - width: 16px; + height: 32px; + width: 32px; } &[data-size="md"], &[data-size="lg"] { - height: 20px; - width: 20px; + height: 64px; + width: 64px; } } @@ -179,9 +255,18 @@ width: 100%; } +.qui-file-upload__item-content { + display: flex; + flex: 1; + flex-direction: column; + gap: 0; + min-width: 0; +} + .qui-file-upload__item-name { color: var(--color-text-neutral-primary); flex: 1; + font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -189,11 +274,13 @@ &[data-size="sm"] { font: var(--font-static-body-xs-default); + font-weight: 500; } &[data-size="md"], &[data-size="lg"] { font: var(--font-static-body-sm-default); + font-weight: 500; } } @@ -210,7 +297,7 @@ &[data-size="md"], &[data-size="lg"] { - font: var(--font-static-body-sm-default); + font: var(--font-static-body-xs-default); } &[data-invalid] { @@ -262,7 +349,8 @@ .qui-file-upload__clear-trigger { align-items: center; background: var(--color-interactive-background-ghost-idle); - border: var(--border-width-sm) solid var(--color-interactive-border-neutral-idle); + border: var(--border-width-sm) solid + var(--color-interactive-border-neutral-idle); border-radius: var(--border-radius-md); box-sizing: border-box; color: var(--color-interactive-text-neutral-idle); diff --git a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts index 11a3a6b7e..aaeb25778 100644 --- a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts +++ b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts @@ -1,13 +1,14 @@ import {Component, DestroyRef, inject, signal} from "@angular/core" import {File, FilePlus, Trash2, Upload} from "lucide-angular" -import type {FileDetails} from "@qualcomm-ui/core/file-upload" +import {ButtonModule} from "@qualcomm-ui/angular/button" import {FileUploadModule} from "@qualcomm-ui/angular/file-upload" import {IconDirective} from "@qualcomm-ui/angular/icon" import {provideIcons} from "@qualcomm-ui/angular-core/lucide" +import type {FileDetails} from "@qualcomm-ui/core/file-upload" @Component({ - imports: [FileUploadModule, IconDirective], + imports: [ButtonModule, FileUploadModule, IconDirective], providers: [provideIcons({File, FilePlus, Trash2, Upload})], selector: "file-upload-composite-demo", standalone: true, @@ -17,62 +18,55 @@ import {provideIcons} from "@qualcomm-ui/angular-core/lucide" class="w-full max-w-md" q-file-upload-root [accept]="['image/*']" + [maxFiles]="10" (fileChanged)="onFileChanged($event)" > -
- -
-
- Drag & drop images, or - + @if (fileCount() === 0) { +
+ +
+
+ + Drag & drop images, or + + +
+ + Supported file types: Images only +
- - Supported file types: Images only -
-
+ } @if (ctx.acceptedFiles.length > 0) { -
+
@for (file of ctx.acceptedFiles; track file) { -
-
+
+
@if (fileUrls()[file.name]) { }
-
- - +
+ +
- +
+ + +
}
@@ -95,7 +105,8 @@ import {provideIcons} from "@qualcomm-ui/angular-core/lucide" export class FileUploadCompositeDemo { private readonly destroyRef = inject(DestroyRef) - fileUrls = signal>({}) + readonly fileUrls = signal>({}) + readonly fileCount = signal(0) constructor() { this.destroyRef.onDestroy(() => { @@ -125,5 +136,6 @@ export class FileUploadCompositeDemo { }) this.fileUrls.set(newUrls) + this.fileCount.set(details.acceptedFiles.length) } } diff --git a/packages/frameworks/angular/src/file-upload/file-upload-root.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-root.directive.ts index 051511de0..a5fa34c26 100644 --- a/packages/frameworks/angular/src/file-upload/file-upload-root.directive.ts +++ b/packages/frameworks/angular/src/file-upload/file-upload-root.directive.ts @@ -30,7 +30,7 @@ export class FileUploadRootDirective /** * Governs the size and padding of file upload elements. * - * @default 'sm' + * @default 'md' */ readonly size = input() From 2e3f67dcbc8182d0e9dd0eb4f9934662b18a1035 Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Mon, 26 Jan 2026 17:18:44 -0800 Subject: [PATCH 07/27] update file-upload component, fix css for sm,md,lg sizes Signed-off-by: Dustin Pham --- .../src/file-upload/qds-file-upload.css | 44 ++-- .../demos/file-upload-composite-demo.ts | 7 +- .../demos/file-upload-simple-demo.ts | 3 +- .../src/file-upload/file-upload.component.ts | 239 +++++++++++++++--- .../src/file-upload/file-upload.module.ts | 3 +- 5 files changed, 228 insertions(+), 68 deletions(-) diff --git a/packages/common/qds-core/src/file-upload/qds-file-upload.css b/packages/common/qds-core/src/file-upload/qds-file-upload.css index f9da345d9..6693d775b 100644 --- a/packages/common/qds-core/src/file-upload/qds-file-upload.css +++ b/packages/common/qds-core/src/file-upload/qds-file-upload.css @@ -190,11 +190,22 @@ display: flex; flex-direction: column; gap: var(--spacing-100); - max-height: 144px; overflow-x: hidden; overflow-y: auto; padding: var(--spacing-100); width: 100%; + + &[data-size="sm"] { + max-height: 144px; + } + + &[data-size="md"] { + max-height: 172px; + } + + &[data-size="lg"] { + max-height: 188px; + } } .qui-file-upload__item { @@ -208,15 +219,11 @@ transition: none; } - &[data-size="sm"] { - gap: var(--spacing-50); - padding: var(--spacing-70) var(--spacing-90); - } - + &[data-size="sm"], &[data-size="md"], &[data-size="lg"] { gap: var(--spacing-70); - padding: var(--spacing-80) var(--spacing-100); + padding: var(--spacing-50) var(--spacing-70); } &:hover { @@ -237,15 +244,11 @@ overflow: hidden; padding: var(--spacing-10) 0; - &[data-size="sm"] { - height: 32px; - width: 32px; - } - + &[data-size="sm"], &[data-size="md"], &[data-size="lg"] { - height: 64px; - width: 64px; + height: 36px; + width: 36px; } } @@ -266,22 +269,11 @@ .qui-file-upload__item-name { color: var(--color-text-neutral-primary); flex: 1; - font-weight: 500; + font: var(--font-static-body-xs-default); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - - &[data-size="sm"] { - font: var(--font-static-body-xs-default); - font-weight: 500; - } - - &[data-size="md"], - &[data-size="lg"] { - font: var(--font-static-body-sm-default); - font-weight: 500; - } } .qui-file-upload__item-size-text { diff --git a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts index aaeb25778..8d65f9e33 100644 --- a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts +++ b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts @@ -66,12 +66,13 @@ import type {FileDetails} from "@qualcomm-ui/core/file-upload"
+ variant="ghost" + >
}
diff --git a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.ts b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.ts index f28c451f4..67c89348a 100644 --- a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.ts +++ b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.ts @@ -10,8 +10,9 @@ import {FileUploadModule} from "@qualcomm-ui/angular/file-upload" diff --git a/packages/frameworks/angular/src/file-upload/file-upload.component.ts b/packages/frameworks/angular/src/file-upload/file-upload.component.ts index 36b6f3251..a705ade64 100644 --- a/packages/frameworks/angular/src/file-upload/file-upload.component.ts +++ b/packages/frameworks/angular/src/file-upload/file-upload.component.ts @@ -7,13 +7,18 @@ import { Component, computed, contentChild, + DestroyRef, + inject, input, + signal, } from "@angular/core" import {File, FilePlus, Trash2, Upload} from "lucide-angular" import {provideFileUploadContext} from "@qualcomm-ui/angular-core/file-upload" import {provideIcons} from "@qualcomm-ui/angular-core/lucide" +import type {FileDetails} from "@qualcomm-ui/core/file-upload" import type {Booleanish} from "@qualcomm-ui/utils/coercion" +import type {FileError} from "@qualcomm-ui/utils/files" import {FileUploadDropzoneDirective} from "./file-upload-dropzone.directive" import {FileUploadItemGroupDirective} from "./file-upload-item-group.directive" @@ -36,76 +41,133 @@ import {provideQdsFileUploadContext} from "./qds-file-upload-context.service" } - - @if (shouldRenderDefaultDropzone()) { -
-
+ @if (fileCount() === 0) { + + @if (shouldRenderDefaultDropzone()) { +
-
-
- Drag & drop files, or +
+
+ + {{ dropzoneText() }} +
@if (dropzoneHint()) { - + {{ dropzoneHint() }} }
-
- } - + } + + } - @if (ctx.acceptedFiles.length > 0) { + @if (ctx.acceptedFiles.length > 0 || ctx.rejectedFiles.length > 0) { @if (shouldRenderDefaultItemGroup()) {
@for (file of ctx.acceptedFiles; track file) { -
- -
- +
+ @if (showPreviews() && isImageFile(file)) { +
+ @if (fileUrls()[file.name]) { + + } +
+ } @else { +
+ +
+ } +
+
+
+ } + @for (rejection of ctx.rejectedFiles; track rejection.file) { +
+
- +
+
+ + + {{ getErrorMessage(rejection.errors) }} + +
+
}
} - @if (showClearButton() && ctx.acceptedFiles.length > 0) { - - } +
+ @if (showAddMoreButton()) { + + } + + @if (showClearButton()) { + + } +
} @@ -117,6 +179,8 @@ import {provideQdsFileUploadContext} from "./qds-file-upload-context.service" `, }) export class FileUploadComponent extends FileUploadRootDirective { + private readonly destroyRef = inject(DestroyRef) + /** * Optional label describing the file upload. This element is automatically * associated with the hidden input element for accessibility. @@ -138,11 +202,50 @@ export class FileUploadComponent extends FileUploadRootDirective { */ readonly dropzoneHint = input() + /** + * The main text displayed in the dropzone. + * + * @default "Drag & drop files, or" + */ + readonly dropzoneText = input("Drag & drop files, or") + + /** + * The text displayed on the browse trigger button within the dropzone. + * + * @default "browse" + */ + readonly triggerText = input("browse") + /** * Optional error message that describes the element when {@link invalid} is true. */ readonly errorText = input() + /** + * When `true`, shows image previews for uploaded image files. + * + * @default true + */ + readonly showPreviews = input(true, { + transform: booleanAttribute, + }) + + /** + * When `true`, renders an "Add more files" button when files are present. + * + * @default true + */ + readonly showAddMoreButton = input(true, { + transform: booleanAttribute, + }) + + /** + * Text to display on the "Add more files" button. + * + * @default "Add more files" + */ + readonly addMoreButtonText = input("Add more files") + /** * When `true`, renders a button to clear all accepted files. * @@ -155,9 +258,12 @@ export class FileUploadComponent extends FileUploadRootDirective { /** * Text to display on the clear button. * - * @default "Add more files" + * @default "Clear all" */ - readonly clearButtonText = input("Add more files") + readonly clearButtonText = input("Clear all") + + protected readonly fileUrls = signal>({}) + protected readonly fileCount = signal(0) protected readonly customDropzone = contentChild(FileUploadDropzoneDirective) protected readonly customItemGroup = contentChild( @@ -170,4 +276,63 @@ export class FileUploadComponent extends FileUploadRootDirective { protected readonly shouldRenderDefaultItemGroup = computed( () => !this.customItemGroup(), ) + + protected readonly fileIconSize = computed(() => { + const size = this.size() ?? "md" + return size === "sm" ? "32px" : "64px" + }) + + constructor() { + super() + this.destroyRef.onDestroy(() => { + Object.values(this.fileUrls()).forEach((url) => { + URL.revokeObjectURL(url) + }) + }) + + this.fileChanged.subscribe((details) => { + this.handleFileChanged(details) + }) + } + + protected handleFileChanged(details: FileDetails): void { + const currentUrls = this.fileUrls() + const newUrls: Record = {} + const currentFileNames = new Set(details.acceptedFiles.map((f) => f.name)) + + details.acceptedFiles.forEach((file) => { + if (currentUrls[file.name]) { + newUrls[file.name] = currentUrls[file.name] + } else if (this.isImageFile(file)) { + newUrls[file.name] = URL.createObjectURL(file) + } + }) + + Object.keys(currentUrls).forEach((fileName) => { + if (!currentFileNames.has(fileName)) { + URL.revokeObjectURL(currentUrls[fileName]) + } + }) + + this.fileUrls.set(newUrls) + this.fileCount.set(details.acceptedFiles.length) + } + + protected isImageFile(file: File): boolean { + return file.type.startsWith("image/") + } + + protected getErrorMessage(errors: FileError[]): string { + const errorMessages: Record = { + FILE_INVALID_TYPE: "Invalid file type", + FILE_TOO_LARGE: "File is too large", + FILE_TOO_SMALL: "File is too small", + TOO_MANY_FILES: "Too many files", + FILE_INVALID: "Invalid file", + FILE_EXISTS: "File already exists", + } + + const firstError = errors[0] + return errorMessages[firstError] || firstError + } } diff --git a/packages/frameworks/angular/src/file-upload/file-upload.module.ts b/packages/frameworks/angular/src/file-upload/file-upload.module.ts index 43d86fae3..033ee842c 100644 --- a/packages/frameworks/angular/src/file-upload/file-upload.module.ts +++ b/packages/frameworks/angular/src/file-upload/file-upload.module.ts @@ -3,6 +3,7 @@ import {NgModule} from "@angular/core" +import {ButtonModule} from "@qualcomm-ui/angular/button" import {IconDirective} from "@qualcomm-ui/angular/icon" import {FileUploadClearTriggerDirective} from "./file-upload-clear-trigger.directive" @@ -56,6 +57,6 @@ import {FileUploadComponent} from "./file-upload.component" FileUploadItemSizeTextDirective, FileUploadItemDeleteTriggerDirective, ], - imports: [IconDirective], + imports: [ButtonModule, IconDirective], }) export class FileUploadModule {} From e36e3092937bd63b65fd45b0801d7eb195d832cc Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Mon, 26 Jan 2026 17:49:17 -0800 Subject: [PATCH 08/27] fix error message in item not displaying right size, fix error icon not showing and being wrong size Signed-off-by: Dustin Pham --- .../src/file-upload/qds-file-upload.css | 10 +------- .../src/file-upload/file-upload.component.ts | 23 +++++++++++++------ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/common/qds-core/src/file-upload/qds-file-upload.css b/packages/common/qds-core/src/file-upload/qds-file-upload.css index 6693d775b..3520c3c1e 100644 --- a/packages/common/qds-core/src/file-upload/qds-file-upload.css +++ b/packages/common/qds-core/src/file-upload/qds-file-upload.css @@ -278,20 +278,12 @@ .qui-file-upload__item-size-text { color: var(--color-text-neutral-secondary); + font: var(--font-static-body-xs-default); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - &[data-size="sm"] { - font: var(--font-static-body-xs-default); - } - - &[data-size="md"], - &[data-size="lg"] { - font: var(--font-static-body-xs-default); - } - &[data-invalid] { color: var(--color-text-support-danger); } diff --git a/packages/frameworks/angular/src/file-upload/file-upload.component.ts b/packages/frameworks/angular/src/file-upload/file-upload.component.ts index a705ade64..f584ade43 100644 --- a/packages/frameworks/angular/src/file-upload/file-upload.component.ts +++ b/packages/frameworks/angular/src/file-upload/file-upload.component.ts @@ -12,7 +12,7 @@ import { input, signal, } from "@angular/core" -import {File, FilePlus, Trash2, Upload} from "lucide-angular" +import {AlertCircle, File, FilePlus, Trash2, Upload} from "lucide-angular" import {provideFileUploadContext} from "@qualcomm-ui/angular-core/file-upload" import {provideIcons} from "@qualcomm-ui/angular-core/lucide" @@ -28,7 +28,7 @@ import {provideQdsFileUploadContext} from "./qds-file-upload-context.service" @Component({ changeDetection: ChangeDetectionStrategy.OnPush, providers: [ - provideIcons({File, FilePlus, Trash2, Upload}), + provideIcons({AlertCircle, File, FilePlus, Trash2, Upload}), provideFileUploadContext(), provideQdsFileUploadContext(), ], @@ -123,9 +123,16 @@ import {provideQdsFileUploadContext} from "./qds-file-upload-context.service"
+ {{ getErrorMessage(rejection.errors) }}
@@ -315,7 +322,9 @@ export class FileUploadComponent extends FileUploadRootDirective { }) this.fileUrls.set(newUrls) - this.fileCount.set(details.acceptedFiles.length) + this.fileCount.set( + details.acceptedFiles.length + details.rejectedFiles.length, + ) } protected isImageFile(file: File): boolean { @@ -324,12 +333,12 @@ export class FileUploadComponent extends FileUploadRootDirective { protected getErrorMessage(errors: FileError[]): string { const errorMessages: Record = { + FILE_EXISTS: "File already exists", + FILE_INVALID: "Invalid file", FILE_INVALID_TYPE: "Invalid file type", FILE_TOO_LARGE: "File is too large", FILE_TOO_SMALL: "File is too small", TOO_MANY_FILES: "Too many files", - FILE_INVALID: "Invalid file", - FILE_EXISTS: "File already exists", } const firstError = errors[0] From 95cf25dc4f41f581c3be821543aa94c0d91af066 Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Tue, 27 Jan 2026 13:56:59 -0800 Subject: [PATCH 09/27] cleanup css, add size demo, add rejected files to composite demo, change file icon to layers Signed-off-by: Dustin Pham --- .../src/file-upload/qds-file-upload.css | 153 +----------------- .../components+/file-upload+/_file-upload.mdx | 6 + .../demos/file-upload-composite-demo.ts | 56 ++++++- .../demos/file-upload-sizes-demo.ts | 48 ++++++ .../src/file-upload/file-upload.component.ts | 8 +- 5 files changed, 117 insertions(+), 154 deletions(-) create mode 100644 packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-sizes-demo.ts diff --git a/packages/common/qds-core/src/file-upload/qds-file-upload.css b/packages/common/qds-core/src/file-upload/qds-file-upload.css index 3520c3c1e..15b6b305d 100644 --- a/packages/common/qds-core/src/file-upload/qds-file-upload.css +++ b/packages/common/qds-core/src/file-upload/qds-file-upload.css @@ -96,34 +96,14 @@ } } -.qui-file-upload__dropzone[data-size="sm"] - .qui-file-upload__dropzone-text-group { +.qui-file-upload__dropzone-text-group { align-items: center; display: flex; flex-direction: column; gap: var(--spacing-70); } -.qui-file-upload__dropzone[data-size="md"] - .qui-file-upload__dropzone-text-group, -.qui-file-upload__dropzone[data-size="lg"] - .qui-file-upload__dropzone-text-group { - align-items: center; - display: flex; - flex-direction: column; - gap: var(--spacing-70); -} - -.qui-file-upload__dropzone[data-size="sm"] - .qui-file-upload__dropzone-text-line { - align-items: center; - display: flex; - gap: var(--spacing-20); -} - -.qui-file-upload__dropzone[data-size="md"] .qui-file-upload__dropzone-text-line, -.qui-file-upload__dropzone[data-size="lg"] - .qui-file-upload__dropzone-text-line { +.qui-file-upload__dropzone-text-line { align-items: center; display: flex; gap: var(--spacing-20); @@ -140,13 +120,7 @@ font: var(--font-static-body-sm-default); } -.qui-file-upload__dropzone[data-size="sm"] .qui-file-upload__dropzone-hint { - color: var(--color-text-neutral-secondary); - font: var(--font-static-body-xs-default); -} - -.qui-file-upload__dropzone[data-size="md"] .qui-file-upload__dropzone-hint, -.qui-file-upload__dropzone[data-size="lg"] .qui-file-upload__dropzone-hint { +.qui-file-upload__dropzone-hint { color: var(--color-text-neutral-secondary); font: var(--font-static-body-xs-default); } @@ -212,6 +186,8 @@ align-items: center; background: var(--color-interactive-background-ghost-idle); display: flex; + gap: var(--spacing-70); + padding: var(--spacing-50) var(--spacing-70); transition: background-color var(--animation-duration-faster) ease-out; width: 100%; @@ -219,13 +195,6 @@ transition: none; } - &[data-size="sm"], - &[data-size="md"], - &[data-size="lg"] { - gap: var(--spacing-70); - padding: var(--spacing-50) var(--spacing-70); - } - &:hover { background: var(--color-interactive-background-ghost-hover); } @@ -241,15 +210,10 @@ border-radius: var(--border-radius-sm); display: flex; flex-shrink: 0; + height: 36px; overflow: hidden; padding: var(--spacing-10) 0; - - &[data-size="sm"], - &[data-size="md"], - &[data-size="lg"] { - height: 36px; - width: 36px; - } + width: 36px; } .qui-file-upload__item-preview-image { @@ -288,106 +252,3 @@ color: var(--color-text-support-danger); } } - -.qui-file-upload__item-delete-trigger { - align-items: center; - background: transparent; - border: none; - color: var(--color-text-neutral-primary); - cursor: pointer; - display: flex; - flex-shrink: 0; - padding: 0; - transition: color var(--animation-duration-faster) ease-out; - - @media (prefers-reduced-motion: reduce) { - transition: none; - } - - &[data-size="sm"] { - height: 18px; - width: 16px; - } - - &[data-size="md"], - &[data-size="lg"] { - height: 20px; - width: 20px; - } - - &:hover { - color: var(--color-interactive-text-neutral-hover); - } - - &:active { - color: var(--color-interactive-text-neutral-pressed); - } - - &[data-disabled] { - color: var(--color-utility-disabled-text); - cursor: not-allowed; - pointer-events: none; - } -} - -.qui-file-upload__clear-trigger { - align-items: center; - background: var(--color-interactive-background-ghost-idle); - border: var(--border-width-sm) solid - var(--color-interactive-border-neutral-idle); - border-radius: var(--border-radius-md); - box-sizing: border-box; - color: var(--color-interactive-text-neutral-idle); - cursor: pointer; - display: flex; - justify-content: center; - transition: - background-color var(--animation-duration-faster) ease-out, - border-color var(--animation-duration-faster) ease-out, - color var(--animation-duration-faster) ease-out; - width: fit-content; - - @media (prefers-reduced-motion: reduce) { - transition: none; - } - - &[data-size="sm"] { - font: var(--font-static-body-xs-bold); - gap: var(--spacing-40); - height: var(--sizing-80); - padding: 0 var(--spacing-90); - } - - &[data-size="md"] { - font: var(--font-static-body-sm-bold); - gap: var(--spacing-50); - height: var(--sizing-90); - padding: 0 var(--spacing-100); - } - - &[data-size="lg"] { - font: var(--font-static-body-md-bold); - gap: var(--spacing-70); - height: var(--sizing-100); - padding: 0 var(--spacing-110); - } - - &:hover { - background: var(--color-interactive-background-ghost-hover); - border-color: var(--color-interactive-border-neutral-hover); - color: var(--color-interactive-text-neutral-hover); - } - - &:active { - background: var(--color-interactive-background-ghost-pressed); - border-color: var(--color-interactive-border-neutral-pressed); - color: var(--color-interactive-text-neutral-pressed); - } - - &[data-disabled] { - border-color: var(--color-utility-disabled-border); - color: var(--color-utility-disabled-text); - cursor: not-allowed; - pointer-events: none; - } -} diff --git a/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx b/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx index 317f35862..d0b5cfaeb 100644 --- a/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx +++ b/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx @@ -34,6 +34,12 @@ Build with the composite API for granular control. This API requires you to prov +### Sizes + +The File Upload component supports three size variants: `sm`, `md` (default), and `lg`. Each size adjusts the dropzone height, padding, and spacing accordingly. + + + ## API ### \ diff --git a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts index 8d65f9e33..1d2dd7144 100644 --- a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts +++ b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts @@ -1,15 +1,16 @@ import {Component, DestroyRef, inject, signal} from "@angular/core" -import {File, FilePlus, Trash2, Upload} from "lucide-angular" +import {AlertCircle, FilePlus, Layers, Trash2, Upload} from "lucide-angular" import {ButtonModule} from "@qualcomm-ui/angular/button" import {FileUploadModule} from "@qualcomm-ui/angular/file-upload" import {IconDirective} from "@qualcomm-ui/angular/icon" import {provideIcons} from "@qualcomm-ui/angular-core/lucide" import type {FileDetails} from "@qualcomm-ui/core/file-upload" +import type {FileError} from "@qualcomm-ui/utils/files" @Component({ imports: [ButtonModule, FileUploadModule, IconDirective], - providers: [provideIcons({File, FilePlus, Trash2, Upload})], + providers: [provideIcons({AlertCircle, FilePlus, Layers, Trash2, Upload})], selector: "file-upload-composite-demo", standalone: true, template: ` @@ -49,7 +50,7 @@ import type {FileDetails} from "@qualcomm-ui/core/file-upload" - @if (ctx.acceptedFiles.length > 0) { + @if (ctx.acceptedFiles.length > 0 || ctx.rejectedFiles.length > 0) {
@for (file of ctx.acceptedFiles; track file) {
@@ -66,8 +67,39 @@ import type {FileDetails} from "@qualcomm-ui/core/file-upload"
+
+ } + @for (rejection of ctx.rejectedFiles; track rejection.file) { +
+
+ +
+
+ + + + {{ getErrorMessage(rejection.errors) }} + +
+ + )} + + +
+ + ) +} + +function FileUploadSimpleDemoDropzone(): ReactElement { + const api = useFileUploadContext() + + if (api.acceptedFiles.length > 0 || api.rejectedFiles.length > 0) { + return <> + } + + return ( + + +
+
+ + Drag & drop images, or + + + {(triggerProps) => ( + + )} + +
+ + Supported file types: Images only + +
+
+ ) +} + +export function FileUploadSimpleDemo(): ReactElement { + const [fileUrls, setFileUrls] = useState>({}) + + const handleFileChange = (details: FileDetails) => { + setFileUrls((currentUrls) => { + const newUrls: Record = {} + const currentFileNames = new Set(details.acceptedFiles.map((f) => f.name)) + + details.acceptedFiles.forEach((file) => { + if (currentUrls[file.name]) { + newUrls[file.name] = currentUrls[file.name] + } else { + newUrls[file.name] = URL.createObjectURL(file) + } + }) + + Object.keys(currentUrls).forEach((fileName) => { + if (!currentFileNames.has(fileName)) { + URL.revokeObjectURL(currentUrls[fileName]) + } + }) + + return newUrls + }) + } + + useEffect(() => { + return () => { + Object.values(fileUrls).forEach((url) => { + URL.revokeObjectURL(url) + }) + } + }, [fileUrls]) + + return ( + // preview + + Upload images + + + + + + + + // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/file-upload+/demos/index.ts b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/index.ts new file mode 100644 index 000000000..2a560c95c --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +export * from "./file-upload-simple-demo" diff --git a/packages/frameworks/react-core/src/file-upload/core-file-upload.tsx b/packages/frameworks/react-core/src/file-upload/core-file-upload.tsx new file mode 100644 index 000000000..4a0e6d011 --- /dev/null +++ b/packages/frameworks/react-core/src/file-upload/core-file-upload.tsx @@ -0,0 +1,261 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement, ReactNode} from "react" + +import { + createFileUploadApi, + type FileUploadApiProps, + fileUploadMachine, + type FileUploadTriggerBindings, + type ItemProps, + splitFileUploadProps, +} from "@qualcomm-ui/core/file-upload" +import {normalizeProps, useMachine} from "@qualcomm-ui/react-core/machine" +import {useControlledId} from "@qualcomm-ui/react-core/state" +import { + bindingRenderProp, + type BindingRenderProp, + type ElementRenderProp, + type IdProp, + PolymorphicElement, +} from "@qualcomm-ui/react-core/system" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import { + FileUploadContextProvider, + FileUploadItemContextProvider, + useFileUploadContext, + useFileUploadItemContext, +} from "./file-upload-context" + +export interface CoreFileUploadRootProps + extends FileUploadApiProps, + Omit, "defaultValue" | "dir"> {} + +export function CoreFileUploadRoot({ + children, + ...props +}: CoreFileUploadRootProps): ReactElement { + const [fileUploadProps, localProps] = + splitFileUploadProps(props) + const config = useMachine(fileUploadMachine, fileUploadProps) + const context = createFileUploadApi(config, normalizeProps) + + const mergedProps = mergeProps(context.getRootBindings(), localProps) + + return ( + + + {children} + + + ) +} + +export interface CoreFileUploadClearTriggerProps + extends ElementRenderProp<"button"> {} + +export function CoreFileUploadClearTrigger({ + ...props +}: CoreFileUploadClearTriggerProps): ReactElement { + const fileUploadContext = useFileUploadContext() + const mergedProps = mergeProps( + fileUploadContext.getClearTriggerBindings(), + props, + ) + + return +} + +export interface CoreFileUploadDropzoneProps + extends ElementRenderProp<"div">, + IdProp { + children?: ReactNode +} + +export function CoreFileUploadDropzone( + props: CoreFileUploadDropzoneProps, +): ReactElement { + const context = useFileUploadContext() + const mergedProps = mergeProps( + context.getDropzoneBindings({id: useControlledId(props.id)}), + props, + ) + return +} + +export interface CoreFileUploadLabelProps + extends ElementRenderProp<"label">, + IdProp { + children?: ReactNode +} + +export function CoreFileUploadLabel( + props: CoreFileUploadLabelProps, +): ReactElement { + const context = useFileUploadContext() + const mergedProps = mergeProps( + context.getLabelBindings({id: useControlledId(props.id)}), + props, + ) + return +} + +export interface CoreFileUploadHiddenInputProps + extends Omit, "type">, + IdProp {} + +export function CoreFileUploadHiddenInput( + props: CoreFileUploadHiddenInputProps, +): ReactElement { + const context = useFileUploadContext() + const mergedProps = mergeProps( + context.getHiddenInputBindings({id: useControlledId(props.id)}), + props, + ) + return +} + +export interface CoreFileUploadItemGroupProps extends ElementRenderProp<"div"> { + children?: ReactNode + type?: "accepted" | "rejected" +} + +export function CoreFileUploadItemGroup({ + type, + ...props +}: CoreFileUploadItemGroupProps): ReactElement { + const context = useFileUploadContext() + const mergedProps = mergeProps(context.getItemGroupBindings({type}), props) + return +} + +export interface CoreFileUploadItemNameProps extends ElementRenderProp<"span"> { + children?: ReactNode +} + +export function CoreFileUploadItemName( + props: CoreFileUploadItemNameProps, +): ReactElement { + const fileUploadContext = useFileUploadContext() + const itemContext = useFileUploadItemContext() + const mergedProps = mergeProps( + fileUploadContext.getItemNameBindings(itemContext), + props, + ) + return +} + +export interface CoreFileUploadItemSizeTextProps + extends ElementRenderProp<"span"> { + children?: ReactNode +} + +export function CoreFileUploadItemSizeText( + props: CoreFileUploadItemSizeTextProps, +): ReactElement { + const fileUploadContext = useFileUploadContext() + const itemContext = useFileUploadItemContext() + const mergedProps = mergeProps( + fileUploadContext.getItemSizeTextBindings(itemContext), + props, + ) + return +} + +export interface CoreFileUploadItemProps + extends ItemProps, + ElementRenderProp<"div"> { + children?: ReactNode +} + +export function CoreFileUploadItem({ + children, + file, + type, + ...props +}: CoreFileUploadItemProps): ReactElement { + const context = useFileUploadContext() + const itemContext: ItemProps = {file, type} + const mergedProps = mergeProps(context.getItemBindings(itemContext), props) + + return ( + + + {children} + + + ) +} + +export interface CoreFileUploadItemDeleteTriggerProps + extends ElementRenderProp<"button"> {} + +export function CoreFileUploadItemDeleteTrigger( + props: CoreFileUploadItemDeleteTriggerProps, +): ReactElement { + const fileUploadContext = useFileUploadContext() + const itemContext = useFileUploadItemContext() + const mergedProps = mergeProps( + fileUploadContext.getItemDeleteTriggerBindings(itemContext), + props, + ) + return +} + +export interface CoreFileUploadTriggerProps extends IdProp { + children: BindingRenderProp +} + +export function CoreFileUploadTrigger({ + children, + id, +}: CoreFileUploadTriggerProps): ReactElement { + const context = useFileUploadContext() + return bindingRenderProp( + children, + context.getTriggerBindings({ + id: useControlledId(id), + }), + ) +} + +export interface CoreFileUploadItemPreviewImageProps + extends Omit, "src">, + IdProp { + url: string // Pre-created object URL from consumer +} + +export function CoreFileUploadItemPreviewImage({ + url, + ...props +}: CoreFileUploadItemPreviewImageProps): ReactElement { + const fileUploadContext = useFileUploadContext() + const itemContext = useFileUploadItemContext() + + const mergedProps = mergeProps( + fileUploadContext.getItemPreviewImageBindings({...itemContext, url}), + {src: url}, + props, + ) + + return +} + +export interface CoreFileUploadItemPreviewProps + extends ElementRenderProp<"div"> { + children?: ReactNode +} + +export function CoreFileUploadItemPreview( + props: CoreFileUploadItemPreviewProps, +): ReactElement { + const fileUploadContext = useFileUploadContext() + const itemContext = useFileUploadItemContext() + const mergedProps = mergeProps( + fileUploadContext.getItemPreviewBindings(itemContext), + props, + ) + return +} diff --git a/packages/frameworks/react-core/src/file-upload/file-upload-context.ts b/packages/frameworks/react-core/src/file-upload/file-upload-context.ts new file mode 100644 index 000000000..5c41b44e0 --- /dev/null +++ b/packages/frameworks/react-core/src/file-upload/file-upload-context.ts @@ -0,0 +1,19 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {FileUploadApi, ItemProps} from "@qualcomm-ui/core/file-upload" +import {createGuardedContext} from "@qualcomm-ui/react-core/context" + +export const [FileUploadContextProvider, useFileUploadContext] = + createGuardedContext({ + hookName: "useFileUploadContext", + providerName: "", + strict: true, + }) + +export const [FileUploadItemContextProvider, useFileUploadItemContext] = + createGuardedContext({ + hookName: "useFileUploadItemContext", + providerName: "", + strict: true, + }) diff --git a/packages/frameworks/react-core/src/file-upload/index.ts b/packages/frameworks/react-core/src/file-upload/index.ts new file mode 100644 index 000000000..5ad5a4107 --- /dev/null +++ b/packages/frameworks/react-core/src/file-upload/index.ts @@ -0,0 +1,82 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + CoreFileUploadClearTrigger, + type CoreFileUploadClearTriggerProps, + CoreFileUploadDropzone, + type CoreFileUploadDropzoneProps, + CoreFileUploadHiddenInput, + type CoreFileUploadHiddenInputProps, + CoreFileUploadItem, + CoreFileUploadItemDeleteTrigger, + type CoreFileUploadItemDeleteTriggerProps, + CoreFileUploadItemGroup, + type CoreFileUploadItemGroupProps, + CoreFileUploadItemName, + type CoreFileUploadItemNameProps, + CoreFileUploadItemPreview, + CoreFileUploadItemPreviewImage, + type CoreFileUploadItemPreviewImageProps, + type CoreFileUploadItemPreviewProps, + type CoreFileUploadItemProps, + CoreFileUploadItemSizeText, + type CoreFileUploadItemSizeTextProps, + CoreFileUploadLabel, + type CoreFileUploadLabelProps, + CoreFileUploadRoot, + type CoreFileUploadRootProps, + CoreFileUploadTrigger, + type CoreFileUploadTriggerProps, +} from "./core-file-upload" + +export * from "./file-upload-context" +export * from "./use-file-upload" + +export type { + CoreFileUploadRootProps, + CoreFileUploadClearTriggerProps, + CoreFileUploadDropzoneProps, + CoreFileUploadTriggerProps, + CoreFileUploadLabelProps, + CoreFileUploadHiddenInputProps, + CoreFileUploadItemProps, + CoreFileUploadItemGroupProps, + CoreFileUploadItemNameProps, + CoreFileUploadItemPreviewProps, + CoreFileUploadItemPreviewImageProps, + CoreFileUploadItemSizeTextProps, + CoreFileUploadItemDeleteTriggerProps, +} + +type CoreFileUploadComponent = { + ClearTrigger: typeof CoreFileUploadClearTrigger + Dropzone: typeof CoreFileUploadDropzone + HiddenInput: typeof CoreFileUploadHiddenInput + Item: typeof CoreFileUploadItem + ItemDeleteTrigger: typeof CoreFileUploadItemDeleteTrigger + ItemGroup: typeof CoreFileUploadItemGroup + ItemName: typeof CoreFileUploadItemName + ItemPreview: typeof CoreFileUploadItemPreview + ItemPreviewImage: typeof CoreFileUploadItemPreviewImage + ItemSizeText: typeof CoreFileUploadItemSizeText + Label: typeof CoreFileUploadLabel + Root: typeof CoreFileUploadRoot + Trigger: typeof CoreFileUploadTrigger +} + +export const CoreFileUpload: CoreFileUploadComponent = { + ClearTrigger: CoreFileUploadClearTrigger, + Dropzone: CoreFileUploadDropzone, + HiddenInput: CoreFileUploadHiddenInput, + Item: CoreFileUploadItem, + ItemDeleteTrigger: CoreFileUploadItemDeleteTrigger, + ItemGroup: CoreFileUploadItemGroup, + ItemName: CoreFileUploadItemName, + ItemPreview: CoreFileUploadItemPreview, + ItemPreviewImage: CoreFileUploadItemPreviewImage, + ItemSizeText: CoreFileUploadItemSizeText, + Label: CoreFileUploadLabel, + Root: CoreFileUploadRoot, + Trigger: CoreFileUploadTrigger, +} diff --git a/packages/frameworks/react-core/src/file-upload/use-file-upload.ts b/packages/frameworks/react-core/src/file-upload/use-file-upload.ts new file mode 100644 index 000000000..651ebbf37 --- /dev/null +++ b/packages/frameworks/react-core/src/file-upload/use-file-upload.ts @@ -0,0 +1,100 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + createFileUploadApi, + type DropzoneProps, + type FileUploadApi, + type FileUploadApiProps, + type FileUploadClearTriggerBindings, + type FileUploadDropzoneBindings, + type FileUploadHiddenInputBindings, + type FileUploadLabelBindings, + fileUploadMachine, + type FileUploadTriggerBindings, + type ItemPreviewImageProps, + type ItemProps, +} from "@qualcomm-ui/core/file-upload" +import {normalizeProps, useMachine} from "@qualcomm-ui/react-core/machine" +import {useControlledId} from "@qualcomm-ui/react-core/state" +import type {IdProp} from "@qualcomm-ui/react-core/system" + +import {useFileUploadContext} from "./file-upload-context" + +export interface UseFileUploadProps extends FileUploadApiProps {} + +export function useFileUpload(props: UseFileUploadProps): FileUploadApi { + const config = useMachine(fileUploadMachine, props) + return createFileUploadApi(config, normalizeProps) +} + +export function useFileUploadClearTrigger(): FileUploadClearTriggerBindings { + const context = useFileUploadContext() + return context.getClearTriggerBindings() +} + +export function useFileUploadDropzone( + props: DropzoneProps & IdProp = {}, +): FileUploadDropzoneBindings { + const context = useFileUploadContext() + const id = useControlledId(props.id) + return context.getDropzoneBindings({...props, id}) +} + +export function useFileUploadHiddenInput( + props: IdProp, +): FileUploadHiddenInputBindings { + const context = useFileUploadContext() + return context.getHiddenInputBindings({ + id: useControlledId(props.id), + }) +} + +export function useFileUploadLabel(props: IdProp): FileUploadLabelBindings { + const context = useFileUploadContext() + return context.getLabelBindings({ + id: useControlledId(props.id), + }) +} + +export function useFileUploadTrigger(props: IdProp): FileUploadTriggerBindings { + const context = useFileUploadContext() + return context.getTriggerBindings({ + id: useControlledId(props.id), + }) +} + +export function useFileUploadItem(props: ItemProps) { + const context = useFileUploadContext() + return context.getItemBindings(props) +} + +export function useFileUploadItemDeleteTrigger(props: ItemProps) { + const context = useFileUploadContext() + return context.getItemDeleteTriggerBindings(props) +} + +export function useFileUploadItemGroup(props: ItemProps) { + const context = useFileUploadContext() + return context.getItemGroupBindings(props) +} + +export function useFileUploadItemName(props: ItemProps) { + const context = useFileUploadContext() + return context.getItemNameBindings(props) +} + +export function useFileUploadItemPreview(props: ItemProps) { + const context = useFileUploadContext() + return context.getItemPreviewBindings(props) +} + +export function useFileUploadItemPreviewImage(props: ItemPreviewImageProps) { + const context = useFileUploadContext() + return context.getItemPreviewImageBindings(props) +} + +export function useFileUploadItemSizeText(props: ItemProps) { + const context = useFileUploadContext() + return context.getItemSizeTextBindings(props) +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-clear-trigger.tsx b/packages/frameworks/react/src/file-upload/file-upload-clear-trigger.tsx new file mode 100644 index 000000000..e2ab7072e --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-clear-trigger.tsx @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadClearTriggerProps, +} from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadClearTriggerProps + extends CoreFileUploadClearTriggerProps {} + +export function FileUploadClearTrigger( + props: FileUploadClearTriggerProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getClearTriggerBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-dropzone.tsx b/packages/frameworks/react/src/file-upload/file-upload-dropzone.tsx new file mode 100644 index 000000000..24ac20eb2 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-dropzone.tsx @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadDropzoneProps, +} from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadDropzoneProps + extends CoreFileUploadDropzoneProps {} + +export function FileUploadDropzone( + props: FileUploadDropzoneProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getDropzoneBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-hidden-input.tsx b/packages/frameworks/react/src/file-upload/file-upload-hidden-input.tsx new file mode 100644 index 000000000..5ae19a181 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-hidden-input.tsx @@ -0,0 +1,18 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadHiddenInputProps, +} from "@qualcomm-ui/react-core/file-upload" + +export interface FileUploadHiddenInputProps + extends CoreFileUploadHiddenInputProps {} + +export function FileUploadHiddenInput( + props: FileUploadHiddenInputProps, +): ReactElement { + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-item-delete-trigger.tsx b/packages/frameworks/react/src/file-upload/file-upload-item-delete-trigger.tsx new file mode 100644 index 000000000..13721f60e --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-item-delete-trigger.tsx @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadItemDeleteTriggerProps, +} from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadItemDeleteTriggerProps + extends CoreFileUploadItemDeleteTriggerProps {} + +export function FileUploadItemDeleteTrigger( + props: FileUploadItemDeleteTriggerProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps( + qdsContext.getItemDeleteTriggerBindings(), + props, + ) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-item-group.tsx b/packages/frameworks/react/src/file-upload/file-upload-item-group.tsx new file mode 100644 index 000000000..528e57e28 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-item-group.tsx @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadItemGroupProps, +} from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadItemGroupProps + extends CoreFileUploadItemGroupProps {} + +export function FileUploadItemGroup( + props: FileUploadItemGroupProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getItemGroupBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-item-name.tsx b/packages/frameworks/react/src/file-upload/file-upload-item-name.tsx new file mode 100644 index 000000000..7fd13b9ec --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-item-name.tsx @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadItemNameProps, +} from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadItemNameProps + extends CoreFileUploadItemNameProps {} + +export function FileUploadItemName( + props: FileUploadItemNameProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getItemNameBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-item-preview-image.tsx b/packages/frameworks/react/src/file-upload/file-upload-item-preview-image.tsx new file mode 100644 index 000000000..b10b813f8 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-item-preview-image.tsx @@ -0,0 +1,26 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadItemPreviewImageProps, +} from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadItemPreviewImageProps + extends CoreFileUploadItemPreviewImageProps {} + +export function FileUploadItemPreviewImage( + props: FileUploadItemPreviewImageProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps( + qdsContext.getItemPreviewImageBindings(), + props, + ) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-item-preview.tsx b/packages/frameworks/react/src/file-upload/file-upload-item-preview.tsx new file mode 100644 index 000000000..52e2a6af8 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-item-preview.tsx @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadItemPreviewProps, +} from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadItemPreviewProps + extends CoreFileUploadItemPreviewProps {} + +export function FileUploadItemPreview( + props: FileUploadItemPreviewProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getItemPreviewBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-item-size-text.tsx b/packages/frameworks/react/src/file-upload/file-upload-item-size-text.tsx new file mode 100644 index 000000000..008f5370e --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-item-size-text.tsx @@ -0,0 +1,23 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadItemSizeTextProps, +} from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadItemSizeTextProps + extends CoreFileUploadItemSizeTextProps {} + +export function FileUploadItemSizeText( + props: FileUploadItemSizeTextProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getItemSizeTextBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-item.tsx b/packages/frameworks/react/src/file-upload/file-upload-item.tsx new file mode 100644 index 000000000..ca87c536e --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-item.tsx @@ -0,0 +1,20 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadItemProps, +} from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadItemProps extends CoreFileUploadItemProps {} + +export function FileUploadItem(props: FileUploadItemProps): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getItemBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-label.tsx b/packages/frameworks/react/src/file-upload/file-upload-label.tsx new file mode 100644 index 000000000..930575ae3 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-label.tsx @@ -0,0 +1,20 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadLabelProps, +} from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadLabelProps extends CoreFileUploadLabelProps {} + +export function FileUploadLabel(props: FileUploadLabelProps): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getLabelBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-root.tsx b/packages/frameworks/react/src/file-upload/file-upload-root.tsx new file mode 100644 index 000000000..a3c955327 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-root.tsx @@ -0,0 +1,41 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {type ReactElement, type ReactNode, useMemo} from "react" + +import { + createQdsFileUploadApi, + type QdsFileUploadApiProps, +} from "@qualcomm-ui/qds-core/file-upload" +import { + CoreFileUpload, + type CoreFileUploadRootProps, +} from "@qualcomm-ui/react-core/file-upload" +import {normalizeProps} from "@qualcomm-ui/react-core/machine" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {QdsFileUploadContextProvider} from "./qds-file-upload-context" + +export interface FileUploadRootProps + extends CoreFileUploadRootProps, + QdsFileUploadApiProps { + children: ReactNode +} + +export function FileUploadRoot({ + size, + ...props +}: FileUploadRootProps): ReactElement { + const qdsContext = useMemo( + () => createQdsFileUploadApi({size}, normalizeProps), + [size], + ) + + const mergedProps = mergeProps(qdsContext.getRootBindings(), props) + + return ( + + + + ) +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-trigger.tsx b/packages/frameworks/react/src/file-upload/file-upload-trigger.tsx new file mode 100644 index 000000000..9c61043d0 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-trigger.tsx @@ -0,0 +1,17 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import { + CoreFileUpload, + type CoreFileUploadTriggerProps, +} from "@qualcomm-ui/react-core/file-upload" + +export interface FileUploadTriggerProps extends CoreFileUploadTriggerProps {} + +export function FileUploadTrigger( + props: FileUploadTriggerProps, +): ReactElement { + return +} diff --git a/packages/frameworks/react/src/file-upload/index.ts b/packages/frameworks/react/src/file-upload/index.ts new file mode 100644 index 000000000..1bb6359dd --- /dev/null +++ b/packages/frameworks/react/src/file-upload/index.ts @@ -0,0 +1,96 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import { + FileUploadClearTrigger, + type FileUploadClearTriggerProps, +} from "./file-upload-clear-trigger" +import { + FileUploadDropzone, + type FileUploadDropzoneProps, +} from "./file-upload-dropzone" +import { + FileUploadHiddenInput, + type FileUploadHiddenInputProps, +} from "./file-upload-hidden-input" +import {FileUploadItem, type FileUploadItemProps} from "./file-upload-item" +import { + FileUploadItemDeleteTrigger, + type FileUploadItemDeleteTriggerProps, +} from "./file-upload-item-delete-trigger" +import { + FileUploadItemGroup, + type FileUploadItemGroupProps, +} from "./file-upload-item-group" +import { + FileUploadItemName, + type FileUploadItemNameProps, +} from "./file-upload-item-name" +import { + FileUploadItemPreview, + type FileUploadItemPreviewProps, +} from "./file-upload-item-preview" +import { + FileUploadItemPreviewImage, + type FileUploadItemPreviewImageProps, +} from "./file-upload-item-preview-image" +import { + FileUploadItemSizeText, + type FileUploadItemSizeTextProps, +} from "./file-upload-item-size-text" +import {FileUploadLabel, type FileUploadLabelProps} from "./file-upload-label" +import {FileUploadRoot, type FileUploadRootProps} from "./file-upload-root" +import { + FileUploadTrigger, + type FileUploadTriggerProps, +} from "./file-upload-trigger" + +export * from "./qds-file-upload-context" + +export type { + FileUploadRootProps, + FileUploadClearTriggerProps, + FileUploadDropzoneProps, + FileUploadTriggerProps, + FileUploadLabelProps, + FileUploadHiddenInputProps, + FileUploadItemProps, + FileUploadItemGroupProps, + FileUploadItemNameProps, + FileUploadItemPreviewProps, + FileUploadItemPreviewImageProps, + FileUploadItemSizeTextProps, + FileUploadItemDeleteTriggerProps, +} + +type FileUploadComponent = { + Root: typeof FileUploadRoot + ClearTrigger: typeof FileUploadClearTrigger + Dropzone: typeof FileUploadDropzone + Trigger: typeof FileUploadTrigger + Label: typeof FileUploadLabel + HiddenInput: typeof FileUploadHiddenInput + Item: typeof FileUploadItem + ItemGroup: typeof FileUploadItemGroup + ItemName: typeof FileUploadItemName + ItemPreview: typeof FileUploadItemPreview + ItemPreviewImage: typeof FileUploadItemPreviewImage + ItemSizeText: typeof FileUploadItemSizeText + ItemDeleteTrigger: typeof FileUploadItemDeleteTrigger +} + +export const FileUpload: FileUploadComponent = { + Root: FileUploadRoot, + ClearTrigger: FileUploadClearTrigger, + Dropzone: FileUploadDropzone, + Trigger: FileUploadTrigger, + Label: FileUploadLabel, + HiddenInput: FileUploadHiddenInput, + Item: FileUploadItem, + ItemGroup: FileUploadItemGroup, + ItemName: FileUploadItemName, + ItemPreview: FileUploadItemPreview, + ItemPreviewImage: FileUploadItemPreviewImage, + ItemSizeText: FileUploadItemSizeText, + ItemDeleteTrigger: FileUploadItemDeleteTrigger, +} diff --git a/packages/frameworks/react/src/file-upload/qds-file-upload-context.ts b/packages/frameworks/react/src/file-upload/qds-file-upload-context.ts new file mode 100644 index 000000000..7d9dd8f73 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/qds-file-upload-context.ts @@ -0,0 +1,12 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {QdsFileUploadApi} from "@qualcomm-ui/qds-core/file-upload" +import {createGuardedContext} from "@qualcomm-ui/react-core/context" + +export const [QdsFileUploadContextProvider, useQdsFileUploadContext] = + createGuardedContext({ + hookName: "useQdsFileUploadContext", + providerName: "", + strict: true, + }) From 94c20027752a531fc16c0ec55280e71dc3bf62e0 Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Tue, 3 Feb 2026 14:00:02 -0800 Subject: [PATCH 13/27] Adding error text as directive, fix error messages not showing, add error demo for angular Signed-off-by: Dustin Pham --- .../core/src/file-upload/file-upload.api.ts | 30 +++++++-- .../core/src/file-upload/file-upload.dom.ts | 2 + .../src/file-upload/file-upload.machine.ts | 1 + .../core/src/file-upload/file-upload.types.ts | 11 +++- .../src/file-upload/file-upload.api.ts | 36 +++++++++++ .../src/file-upload/file-upload.classes.ts | 1 + .../src/file-upload/file-upload.types.ts | 30 +++++++++ .../src/file-upload/qds-file-upload.css | 13 ++++ .../components+/file-upload+/_file-upload.mdx | 36 +++++++++++ .../demos/file-upload-composite-demo.ts | 44 ++++++------- .../demos/file-upload-errors-demo.ts | 41 ++++++++++++ .../core-file-upload-error-text.directive.ts | 28 +++++++++ ...re-file-upload-item-size-text.directive.ts | 2 +- .../core-file-upload-label.directive.ts | 2 +- .../angular-core/src/file-upload/index.ts | 1 + .../file-upload-dropzone-hint.directive.ts | 27 ++++++++ ...le-upload-dropzone-text-group.directive.ts | 27 ++++++++ ...ile-upload-dropzone-text-line.directive.ts | 27 ++++++++ .../file-upload-dropzone-text.directive.ts | 27 ++++++++ .../file-upload-error-text.directive.ts | 38 +++++++++++ .../file-upload-item-content.directive.ts | 27 ++++++++ .../file-upload/file-upload-root.directive.ts | 2 +- .../src/file-upload/file-upload.component.ts | 63 +++++++++++-------- .../src/file-upload/file-upload.module.ts | 18 ++++++ .../angular/src/file-upload/index.ts | 10 ++- 25 files changed, 484 insertions(+), 60 deletions(-) create mode 100644 packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-errors-demo.ts create mode 100644 packages/frameworks/angular-core/src/file-upload/core-file-upload-error-text.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-dropzone-hint.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-dropzone-text-group.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-dropzone-text-line.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-dropzone-text.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-error-text.directive.ts create mode 100644 packages/frameworks/angular/src/file-upload/file-upload-item-content.directive.ts diff --git a/packages/common/core/src/file-upload/file-upload.api.ts b/packages/common/core/src/file-upload/file-upload.api.ts index 97907ffad..2668204a1 100644 --- a/packages/common/core/src/file-upload/file-upload.api.ts +++ b/packages/common/core/src/file-upload/file-upload.api.ts @@ -4,11 +4,19 @@ import { visuallyHiddenStyle, } from "@qualcomm-ui/dom/query" import {flatArray} from "@qualcomm-ui/utils/array" -import {booleanAriaAttr, booleanDataAttr} from "@qualcomm-ui/utils/attributes" +import { + ariaAttr, + booleanAriaAttr, + booleanDataAttr, +} from "@qualcomm-ui/utils/attributes" import type {DirectionProperty} from "@qualcomm-ui/utils/direction" import {getFileEntries} from "@qualcomm-ui/utils/files" import {formatBytes} from "@qualcomm-ui/utils/i18n" -import type {Machine, PropNormalizer} from "@qualcomm-ui/utils/machine" +import type { + IdRegistrationProps, + Machine, + PropNormalizer, +} from "@qualcomm-ui/utils/machine" import { domEls, @@ -22,6 +30,7 @@ import type { FileUploadApi, FileUploadClearTriggerBindings, FileUploadDropzoneBindings, + FileUploadErrorTextBindings, FileUploadSchema, ItemType, } from "./file-upload.types" @@ -50,6 +59,7 @@ export function createFileUploadApi( const {computed, context, prop, scope, send, state} = machine const disabled = !!prop("disabled") const required = !!prop("required") + const invalid = prop("invalid") const allowDrop = prop("allowDrop") const translations = prop("translations") @@ -112,7 +122,7 @@ export function createFileUploadApi( "aria-label": translations.dropzone, "data-disabled": booleanDataAttr(disabled), "data-dragging": booleanDataAttr(dragging), - "data-invalid": booleanDataAttr(prop("invalid")), + "data-invalid": booleanDataAttr(invalid), id: domIds.dropzone(scope), onBlur() { if (disabled) { @@ -233,6 +243,16 @@ export function createFileUploadApi( tabIndex: disabled || props.disableClick ? undefined : 0, }) }, + getErrorTextBindings(props: IdRegistrationProps): FileUploadErrorTextBindings { + scope.ids.register("errorText", props) + return normalize.element({ + ...commonBindings, + "aria-live": "polite", + "data-part": "error-text", + hidden: !invalid, + id: domIds.errorText(scope), + }) + }, getFileSize(file) { return formatBytes(file.size, prop("locale")) }, @@ -241,6 +261,8 @@ export function createFileUploadApi( return normalize.input({ ...commonBindings, accept: computed("acceptAttr"), + "aria-describedby": ariaAttr(invalid ? domIds.errorText(scope) : undefined), + "aria-invalid": booleanAriaAttr(invalid), capture: prop("capture"), disabled, id: domIds.hiddenInput(scope), @@ -372,7 +394,7 @@ export function createFileUploadApi( return normalize.button({ ...commonBindings, "data-disabled": booleanDataAttr(disabled), - "data-invalid": booleanDataAttr(prop("invalid")), + "data-invalid": booleanDataAttr(invalid), disabled, id: domIds.trigger(scope), onClick(event) { diff --git a/packages/common/core/src/file-upload/file-upload.dom.ts b/packages/common/core/src/file-upload/file-upload.dom.ts index e67680b90..ac0fe9ad4 100644 --- a/packages/common/core/src/file-upload/file-upload.dom.ts +++ b/packages/common/core/src/file-upload/file-upload.dom.ts @@ -10,6 +10,7 @@ export const domIds: ScopeDomIds< 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"), @@ -32,6 +33,7 @@ export const domEls: ScopeDomElements< hiddenInput: (scope: FileUploadScope) => HTMLInputElement | null } = { dropzone: (scope) => scope.getById(domIds.dropzone(scope)), + errorText: (scope) => scope.getById(domIds.errorText(scope)), hiddenInput: (scope) => scope.getById(domIds.hiddenInput(scope)), root: (scope) => scope.getById(domIds.root(scope)), } diff --git a/packages/common/core/src/file-upload/file-upload.machine.ts b/packages/common/core/src/file-upload/file-upload.machine.ts index 656eecbd1..fb6a971d9 100644 --- a/packages/common/core/src/file-upload/file-upload.machine.ts +++ b/packages/common/core/src/file-upload/file-upload.machine.ts @@ -199,6 +199,7 @@ export const fileUploadMachine: MachineConfig = createMachine( ids: ({bindableId, bindableIdCollection, ids}) => { return { dropzone: bindableId(ids?.dropzone), + errorText: bindableId(ids?.errorText), hiddenInput: bindableId(ids?.hiddenInput), item: bindableIdCollection(), itemName: bindableIdCollection(), diff --git a/packages/common/core/src/file-upload/file-upload.types.ts b/packages/common/core/src/file-upload/file-upload.types.ts index ff1f1499f..cc037dd77 100644 --- a/packages/common/core/src/file-upload/file-upload.types.ts +++ b/packages/common/core/src/file-upload/file-upload.types.ts @@ -42,6 +42,7 @@ export type FileValidateFn = ( export interface FileUploadElementIds { dropzone: string + errorText: string hiddenInput: string item: string[] itemName: string[] @@ -308,6 +309,7 @@ export interface FileUploadApi { getDropzoneBindings: ( props?: DropzoneProps & {id?: string}, ) => FileUploadDropzoneBindings + getErrorTextBindings: (props: {id: string}) => FileUploadErrorTextBindings /** * Returns the formatted file size (e.g. 1.2MB) */ @@ -403,7 +405,7 @@ export interface FileUploadItemDeleteTriggerBindings extends CommonBindings { "data-disabled": BooleanDataAttr "data-type": ItemType disabled: boolean - onClick: JSX.MouseEventHandler + onClick: JSX.MouseEventHandler type: "button" } @@ -443,3 +445,10 @@ export interface FileUploadTriggerBindings extends CommonBindings { onClick: JSX.MouseEventHandler type: "button" } + +export interface FileUploadErrorTextBindings extends CommonBindings { + "aria-live": "polite" + "data-part": "error-text" + hidden: boolean + id: string +} diff --git a/packages/common/qds-core/src/file-upload/file-upload.api.ts b/packages/common/qds-core/src/file-upload/file-upload.api.ts index 5a405c2f5..dd21167f5 100644 --- a/packages/common/qds-core/src/file-upload/file-upload.api.ts +++ b/packages/common/qds-core/src/file-upload/file-upload.api.ts @@ -9,7 +9,13 @@ import type { QdsFileUploadApiProps, QdsFileUploadClearTriggerBindings, QdsFileUploadDropzoneBindings, + QdsFileUploadDropzoneHintBindings, + QdsFileUploadDropzoneTextBindings, + QdsFileUploadDropzoneTextGroupBindings, + QdsFileUploadDropzoneTextLineBindings, + QdsFileUploadErrorTextBindings, QdsFileUploadItemBindings, + QdsFileUploadItemContentBindings, QdsFileUploadItemDeleteTriggerBindings, QdsFileUploadItemGroupBindings, QdsFileUploadItemNameBindings, @@ -48,12 +54,42 @@ export function createQdsFileUploadApi( "data-size": size, }) }, + getDropzoneTextGroupBindings(): QdsFileUploadDropzoneTextGroupBindings { + return normalize.element({ + className: fileUploadClasses.dropzoneTextGroup, + }) + }, + getDropzoneTextLineBindings(): QdsFileUploadDropzoneTextLineBindings { + return normalize.element({ + className: fileUploadClasses.dropzoneTextLine, + }) + }, + getDropzoneTextBindings(): QdsFileUploadDropzoneTextBindings { + return normalize.element({ + className: fileUploadClasses.dropzoneText, + }) + }, + getDropzoneHintBindings(): QdsFileUploadDropzoneHintBindings { + return normalize.element({ + className: fileUploadClasses.dropzoneHint, + }) + }, + getErrorTextBindings(): QdsFileUploadErrorTextBindings { + return normalize.element({ + className: fileUploadClasses.errorText, + }) + }, getItemBindings(): QdsFileUploadItemBindings { return normalize.element({ className: fileUploadClasses.item, "data-size": size, }) }, + getItemContentBindings(): QdsFileUploadItemContentBindings { + return normalize.element({ + className: fileUploadClasses.itemContent, + }) + }, getItemDeleteTriggerBindings(): QdsFileUploadItemDeleteTriggerBindings { return normalize.element({ className: fileUploadClasses.itemDeleteTrigger, diff --git a/packages/common/qds-core/src/file-upload/file-upload.classes.ts b/packages/common/qds-core/src/file-upload/file-upload.classes.ts index 3ca6e899a..3cb6ac3ca 100644 --- a/packages/common/qds-core/src/file-upload/file-upload.classes.ts +++ b/packages/common/qds-core/src/file-upload/file-upload.classes.ts @@ -8,6 +8,7 @@ export const fileUploadClasses = { dropzoneText: "qui-file-upload__dropzone-text", dropzoneTextGroup: "qui-file-upload__dropzone-text-group", dropzoneTextLine: "qui-file-upload__dropzone-text-line", + errorText: "qui-file-upload__error-text", item: "qui-file-upload__item", itemContent: "qui-file-upload__item-content", itemDeleteTrigger: "qui-file-upload__item-delete-trigger", diff --git a/packages/common/qds-core/src/file-upload/file-upload.types.ts b/packages/common/qds-core/src/file-upload/file-upload.types.ts index f1b309567..58fa180ee 100644 --- a/packages/common/qds-core/src/file-upload/file-upload.types.ts +++ b/packages/common/qds-core/src/file-upload/file-upload.types.ts @@ -31,11 +31,35 @@ export interface QdsFileUploadDropzoneBindings { "data-size": QdsFileUploadSize } +export interface QdsFileUploadDropzoneTextGroupBindings { + className: FileUploadClasses["dropzoneTextGroup"] +} + +export interface QdsFileUploadDropzoneTextLineBindings { + className: FileUploadClasses["dropzoneTextLine"] +} + +export interface QdsFileUploadDropzoneTextBindings { + className: FileUploadClasses["dropzoneText"] +} + +export interface QdsFileUploadDropzoneHintBindings { + className: FileUploadClasses["dropzoneHint"] +} + +export interface QdsFileUploadErrorTextBindings { + className: FileUploadClasses["errorText"] +} + export interface QdsFileUploadItemBindings { className: FileUploadClasses["item"] "data-size": QdsFileUploadSize } +export interface QdsFileUploadItemContentBindings { + className: FileUploadClasses["itemContent"] +} + export interface QdsFileUploadItemDeleteTriggerBindings { className: FileUploadClasses["itemDeleteTrigger"] "data-size": QdsFileUploadSize @@ -82,7 +106,13 @@ export interface QdsFileUploadApi { // group: bindings getClearTriggerBindings(props?: {id: string}): QdsFileUploadClearTriggerBindings getDropzoneBindings(props?: {id: string; disableClick?: boolean}): QdsFileUploadDropzoneBindings + getDropzoneTextGroupBindings(): QdsFileUploadDropzoneTextGroupBindings + getDropzoneTextLineBindings(): QdsFileUploadDropzoneTextLineBindings + getDropzoneTextBindings(): QdsFileUploadDropzoneTextBindings + getDropzoneHintBindings(): QdsFileUploadDropzoneHintBindings + getErrorTextBindings(): QdsFileUploadErrorTextBindings getItemBindings(): QdsFileUploadItemBindings + getItemContentBindings(): QdsFileUploadItemContentBindings getItemDeleteTriggerBindings(): QdsFileUploadItemDeleteTriggerBindings getItemGroupBindings(): QdsFileUploadItemGroupBindings getItemNameBindings(): QdsFileUploadItemNameBindings diff --git a/packages/common/qds-core/src/file-upload/qds-file-upload.css b/packages/common/qds-core/src/file-upload/qds-file-upload.css index 15b6b305d..f0acf8062 100644 --- a/packages/common/qds-core/src/file-upload/qds-file-upload.css +++ b/packages/common/qds-core/src/file-upload/qds-file-upload.css @@ -125,6 +125,11 @@ font: var(--font-static-body-xs-default); } +.qui-file-upload__error-text { + color: var(--color-text-support-danger); + font: var(--font-static-body-xs-default); +} + .qui-file-upload__trigger { background: transparent; border: none; @@ -180,6 +185,11 @@ &[data-size="lg"] { max-height: 188px; } + + &[data-invalid] { + border-color: var(--color-interactive-border-danger-idle); + border-width: var(--border-width-md); + } } .qui-file-upload__item { @@ -249,6 +259,9 @@ white-space: nowrap; &[data-invalid] { + align-items: center; color: var(--color-text-support-danger); + display: flex; + gap: var(--spacing-40); } } diff --git a/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx b/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx index d0b5cfaeb..101d72f7b 100644 --- a/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx +++ b/packages/docs/angular-docs/src/routes/components+/file-upload+/_file-upload.mdx @@ -40,6 +40,12 @@ The File Upload component supports three size variants: `sm`, `md` (default), an +### Errors + +The File Upload component displays component-level error messages when the `invalid` property is `true`. Per-file validation errors are shown automatically for rejected files. + + + ## API ### \ @@ -82,6 +88,30 @@ A drop target for drag-and-drop file uploads. +### q-file-upload-dropzone-text-group + +Container for dropzone text content. + + + +### q-file-upload-dropzone-text-line + +Container for the dropzone text line. + + + +### q-file-upload-dropzone-text + +The main dropzone text content. + + + +### q-file-upload-dropzone-hint + +Hint text displayed below the dropzone instructions. + + + ### q-file-upload-item-group A container for the list of file items. @@ -94,6 +124,12 @@ A container for each individual file. +### q-file-upload-item-content + +Container for file item text content (name and size). + + + ### q-file-upload-item-name Displays the file name. diff --git a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts index 1d2dd7144..4f3accf51 100644 --- a/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts +++ b/packages/docs/angular-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.ts @@ -27,20 +27,12 @@ import type {FileError} from "@qualcomm-ui/utils/files" @if (fileCount() === 0) {
-
-
- - Drag & drop images, or - - +
+
+ Drag & drop images, or +
- + Supported file types: Images only
@@ -51,7 +43,10 @@ import type {FileError} from "@qualcomm-ui/utils/files" @if (ctx.acceptedFiles.length > 0 || ctx.rejectedFiles.length > 0) { -
+
@for (file of ctx.acceptedFiles; track file) {
@@ -62,7 +57,7 @@ import type {FileError} from "@qualcomm-ui/utils/files" /> }
-
+
@@ -81,18 +76,15 @@ import type {FileError} from "@qualcomm-ui/utils/files"
-
+
- + {{ getErrorMessage(rejection.errors) }} @@ -108,7 +100,11 @@ import type {FileError} from "@qualcomm-ui/utils/files"
}
+ } + + + @if (ctx.acceptedFiles.length > 0 || ctx.rejectedFiles.length > 0) {
@if (dropzoneHint()) { - + {{ dropzoneHint() }} } @@ -76,7 +72,10 @@ import {provideQdsFileUploadContext} from "./qds-file-upload-context.service" @if (ctx.acceptedFiles.length > 0 || ctx.rejectedFiles.length > 0) { @if (shouldRenderDefaultItemGroup()) { -
+
@for (file of ctx.acceptedFiles; track file) {
@if (showPreviews() && isImageFile(file)) { @@ -97,7 +96,7 @@ import {provideQdsFileUploadContext} from "./qds-file-upload-context.service" >
} -
+
@@ -120,18 +119,15 @@ import {provideQdsFileUploadContext} from "./qds-file-upload-context.service" [style.width]="fileIconSize()" >
-
+
- + {{ getErrorMessage(rejection.errors) }} @@ -149,10 +145,23 @@ import {provideQdsFileUploadContext} from "./qds-file-upload-context.service"
} + } + + + @if (errorText()) { +
+ {{ errorText() }} +
+ } +
+ + + @if (ctx.acceptedFiles.length > 0 || ctx.rejectedFiles.length > 0) {
@if (showAddMoreButton()) { + )} + + +
+ + ) +} + +function FileUploadCompositeDemoDropzone(): ReactElement { + const api = useFileUploadContext() + + if (api.acceptedFiles.length > 0 || api.rejectedFiles.length > 0) { + return <> + } + + return ( + + + + + Drag & drop images, or + + {(triggerProps) => ( + + )} + + + + Supported file types: Images only + + + + ) +} + +export function FileUploadCompositeDemo(): ReactElement { + const [fileUrls, setFileUrls] = useState>({}) + + const handleFileChange = (details: FileDetails) => { + setFileUrls((currentUrls) => { + const newUrls: Record = {} + const currentFileNames = new Set(details.acceptedFiles.map((f) => f.name)) + + details.acceptedFiles.forEach((file) => { + if (currentUrls[file.name]) { + newUrls[file.name] = currentUrls[file.name] + } else { + newUrls[file.name] = URL.createObjectURL(file) + } + }) + + Object.keys(currentUrls).forEach((fileName) => { + if (!currentFileNames.has(fileName)) { + URL.revokeObjectURL(currentUrls[fileName]) + } + }) + + return newUrls + }) + } + + useEffect(() => { + return () => { + Object.values(fileUrls).forEach((url) => { + URL.revokeObjectURL(url) + }) + } + }, [fileUrls]) + + return ( + // preview + + Upload images + + + + + + + + // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-errors-demo.tsx b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-errors-demo.tsx new file mode 100644 index 000000000..28c47b7fb --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-errors-demo.tsx @@ -0,0 +1,42 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {type ReactElement, useState} from "react" + +import type {FileDetails} from "@qualcomm-ui/core/file-upload" +import {FileUpload} from "@qualcomm-ui/react/file-upload" + +export function FileUploadErrorsDemo(): ReactElement { + const [invalid, setInvalid] = useState(true) + const [errorText, setErrorText] = useState("At least one file is required") + + const handleFileChange = (details: FileDetails) => { + if (details.acceptedFiles.length > 0 && !details.rejectedFiles.length) { + setInvalid(false) + setErrorText("") + } else if (details.acceptedFiles.length === 0) { + setInvalid(true) + setErrorText("At least one file is required") + } else { + setInvalid(true) + setErrorText("Files must be .jpg, .jpeg, .png, or .pdf.") + } + } + + return ( + // preview + + {errorText} + + // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.tsx b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.tsx index dfa6e823f..570224d14 100644 --- a/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-simple-demo.tsx @@ -1,172 +1,18 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -import {type ReactElement, useEffect, useState} from "react" - -import {FilePlus, Trash2, Upload} from "lucide-react" - -import type {FileDetails} from "@qualcomm-ui/core/file-upload" -import {Button} from "@qualcomm-ui/react/button" import {FileUpload} from "@qualcomm-ui/react/file-upload" -import {Icon} from "@qualcomm-ui/react/icon" -import { - useFileUploadContext, - useFileUploadItemContext, -} from "@qualcomm-ui/react-core/file-upload" - -function FileUploadItemDeleteButton(): ReactElement { - const api = useFileUploadContext() - const itemContext = useFileUploadItemContext() - - return ( - - )} - - -
- - ) -} - -function FileUploadSimpleDemoDropzone(): ReactElement { - const api = useFileUploadContext() - - if (api.acceptedFiles.length > 0 || api.rejectedFiles.length > 0) { - return <> - } - - return ( - - -
-
- - Drag & drop images, or - - - {(triggerProps) => ( - - )} - -
- - Supported file types: Images only - -
-
- ) -} - -export function FileUploadSimpleDemo(): ReactElement { - const [fileUrls, setFileUrls] = useState>({}) - - const handleFileChange = (details: FileDetails) => { - setFileUrls((currentUrls) => { - const newUrls: Record = {} - const currentFileNames = new Set(details.acceptedFiles.map((f) => f.name)) - - details.acceptedFiles.forEach((file) => { - if (currentUrls[file.name]) { - newUrls[file.name] = currentUrls[file.name] - } else { - newUrls[file.name] = URL.createObjectURL(file) - } - }) - - Object.keys(currentUrls).forEach((fileName) => { - if (!currentFileNames.has(fileName)) { - URL.revokeObjectURL(currentUrls[fileName]) - } - }) - - return newUrls - }) - } - - useEffect(() => { - return () => { - Object.values(fileUrls).forEach((url) => { - URL.revokeObjectURL(url) - }) - } - }, [fileUrls]) +export function FileUploadSimpleDemo() { return ( // preview - - Upload images - - - - - - - + /> // preview ) } diff --git a/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-sizes-demo.tsx b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-sizes-demo.tsx new file mode 100644 index 000000000..eac70cb3e --- /dev/null +++ b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-sizes-demo.tsx @@ -0,0 +1,42 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {FileUpload} from "@qualcomm-ui/react/file-upload" + +export function FileUploadSizesDemo() { + return ( + // preview +
+
+ +
+ +
+ +
+ +
+ +
+
+ // preview + ) +} diff --git a/packages/docs/react-docs/src/routes/components+/file-upload+/demos/index.ts b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/index.ts index 2a560c95c..e48e4dc82 100644 --- a/packages/docs/react-docs/src/routes/components+/file-upload+/demos/index.ts +++ b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/index.ts @@ -1,4 +1,7 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear +export * from "./file-upload-composite-demo" +export * from "./file-upload-errors-demo" export * from "./file-upload-simple-demo" +export * from "./file-upload-sizes-demo" diff --git a/packages/frameworks/angular-core/src/file-upload/core-file-upload-root.directive.ts b/packages/frameworks/angular-core/src/file-upload/core-file-upload-root.directive.ts index 131315bef..803dcedbd 100644 --- a/packages/frameworks/angular-core/src/file-upload/core-file-upload-root.directive.ts +++ b/packages/frameworks/angular-core/src/file-upload/core-file-upload-root.directive.ts @@ -12,6 +12,7 @@ import { output, } from "@angular/core" +import {useId} from "@qualcomm-ui/angular-core/common" import {numberAttributeOrUndefined} from "@qualcomm-ui/angular-core/attributes" import { normalizeProps, @@ -83,6 +84,12 @@ export class CoreFileUploadRootDirective */ readonly getRootNode = input<() => ShadowRoot | Document | Node>() + /** + * {@link https://www.w3schools.com/html/html_id.asp id attribute}. If + * omitted, a unique identifier will be generated for accessibility. + */ + readonly id = input(undefined) + /** * Whether to accept directories, only works in webkit browsers. */ @@ -202,8 +209,9 @@ export class CoreFileUploadRootDirective */ readonly fileRejected = output() + protected readonly hostId = computed(() => useId(this, this.id())) protected readonly trackBindings = useTrackBindings(() => - this.fileUploadContext.context().getRootBindings(), + this.fileUploadContext.context().getRootBindings({id: this.hostId()}), ) private readonly fileUploadContext = inject(FileUploadContextService) diff --git a/packages/frameworks/angular/src/file-upload/file-upload-error-text.directive.ts b/packages/frameworks/angular/src/file-upload/file-upload-error-text.directive.ts index a8ca85937..b1c38dfa6 100644 --- a/packages/frameworks/angular/src/file-upload/file-upload-error-text.directive.ts +++ b/packages/frameworks/angular/src/file-upload/file-upload-error-text.directive.ts @@ -3,8 +3,8 @@ import {Component, computed, input} from "@angular/core" -import type {LucideIcon} from "@qualcomm-ui/angular-core/lucide" import {CoreFileUploadErrorTextDirective} from "@qualcomm-ui/angular-core/file-upload" +import type {LucideIcon} from "@qualcomm-ui/angular-core/lucide" import {useQdsFileUploadContext} from "./qds-file-upload-context.service" diff --git a/packages/frameworks/react-core/src/file-upload/core-file-upload.tsx b/packages/frameworks/react-core/src/file-upload/core-file-upload.tsx index 4a0e6d011..251089580 100644 --- a/packages/frameworks/react-core/src/file-upload/core-file-upload.tsx +++ b/packages/frameworks/react-core/src/file-upload/core-file-upload.tsx @@ -31,10 +31,12 @@ import { export interface CoreFileUploadRootProps extends FileUploadApiProps, - Omit, "defaultValue" | "dir"> {} + Omit, "defaultValue" | "dir">, + IdProp {} export function CoreFileUploadRoot({ children, + id, ...props }: CoreFileUploadRootProps): ReactElement { const [fileUploadProps, localProps] = @@ -42,7 +44,10 @@ export function CoreFileUploadRoot({ const config = useMachine(fileUploadMachine, fileUploadProps) const context = createFileUploadApi(config, normalizeProps) - const mergedProps = mergeProps(context.getRootBindings(), localProps) + const mergedProps = mergeProps( + context.getRootBindings({id: useControlledId(id)}), + localProps, + ) return ( @@ -259,3 +264,20 @@ export function CoreFileUploadItemPreview( ) return } + +export interface CoreFileUploadErrorTextProps + extends ElementRenderProp<"div">, + IdProp { + children?: ReactNode +} + +export function CoreFileUploadErrorText( + props: CoreFileUploadErrorTextProps, +): ReactElement { + const context = useFileUploadContext() + const mergedProps = mergeProps( + context.getErrorTextBindings({id: useControlledId(props.id)}), + props, + ) + return +} diff --git a/packages/frameworks/react-core/src/file-upload/index.ts b/packages/frameworks/react-core/src/file-upload/index.ts index 5ad5a4107..9207884fe 100644 --- a/packages/frameworks/react-core/src/file-upload/index.ts +++ b/packages/frameworks/react-core/src/file-upload/index.ts @@ -6,6 +6,8 @@ import { type CoreFileUploadClearTriggerProps, CoreFileUploadDropzone, type CoreFileUploadDropzoneProps, + CoreFileUploadErrorText, + type CoreFileUploadErrorTextProps, CoreFileUploadHiddenInput, type CoreFileUploadHiddenInputProps, CoreFileUploadItem, @@ -37,6 +39,7 @@ export type { CoreFileUploadRootProps, CoreFileUploadClearTriggerProps, CoreFileUploadDropzoneProps, + CoreFileUploadErrorTextProps, CoreFileUploadTriggerProps, CoreFileUploadLabelProps, CoreFileUploadHiddenInputProps, @@ -52,6 +55,7 @@ export type { type CoreFileUploadComponent = { ClearTrigger: typeof CoreFileUploadClearTrigger Dropzone: typeof CoreFileUploadDropzone + ErrorText: typeof CoreFileUploadErrorText HiddenInput: typeof CoreFileUploadHiddenInput Item: typeof CoreFileUploadItem ItemDeleteTrigger: typeof CoreFileUploadItemDeleteTrigger @@ -68,6 +72,7 @@ type CoreFileUploadComponent = { export const CoreFileUpload: CoreFileUploadComponent = { ClearTrigger: CoreFileUploadClearTrigger, Dropzone: CoreFileUploadDropzone, + ErrorText: CoreFileUploadErrorText, HiddenInput: CoreFileUploadHiddenInput, Item: CoreFileUploadItem, ItemDeleteTrigger: CoreFileUploadItemDeleteTrigger, diff --git a/packages/frameworks/react/src/file-upload/file-upload-dropzone-hint.tsx b/packages/frameworks/react/src/file-upload/file-upload-dropzone-hint.tsx new file mode 100644 index 000000000..48906de2a --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-dropzone-hint.tsx @@ -0,0 +1,21 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import type {ElementRenderProp} from "@qualcomm-ui/react-core/system" +import {PolymorphicElement} from "@qualcomm-ui/react-core/system" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadDropzoneHintProps + extends ElementRenderProp<"span"> {} + +export function FileUploadDropzoneHint( + props: FileUploadDropzoneHintProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getDropzoneHintBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-dropzone-text-group.tsx b/packages/frameworks/react/src/file-upload/file-upload-dropzone-text-group.tsx new file mode 100644 index 000000000..e64125100 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-dropzone-text-group.tsx @@ -0,0 +1,24 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import type {ElementRenderProp} from "@qualcomm-ui/react-core/system" +import {PolymorphicElement} from "@qualcomm-ui/react-core/system" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadDropzoneTextGroupProps + extends ElementRenderProp<"div"> {} + +export function FileUploadDropzoneTextGroup( + props: FileUploadDropzoneTextGroupProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps( + qdsContext.getDropzoneTextGroupBindings(), + props, + ) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-dropzone-text-line.tsx b/packages/frameworks/react/src/file-upload/file-upload-dropzone-text-line.tsx new file mode 100644 index 000000000..3d9dde533 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-dropzone-text-line.tsx @@ -0,0 +1,24 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import type {ElementRenderProp} from "@qualcomm-ui/react-core/system" +import {PolymorphicElement} from "@qualcomm-ui/react-core/system" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadDropzoneTextLineProps + extends ElementRenderProp<"div"> {} + +export function FileUploadDropzoneTextLine( + props: FileUploadDropzoneTextLineProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps( + qdsContext.getDropzoneTextLineBindings(), + props, + ) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-dropzone-text.tsx b/packages/frameworks/react/src/file-upload/file-upload-dropzone-text.tsx new file mode 100644 index 000000000..8fbd9e8ca --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-dropzone-text.tsx @@ -0,0 +1,21 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import type {ElementRenderProp} from "@qualcomm-ui/react-core/system" +import {PolymorphicElement} from "@qualcomm-ui/react-core/system" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadDropzoneTextProps + extends ElementRenderProp<"span"> {} + +export function FileUploadDropzoneText( + props: FileUploadDropzoneTextProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getDropzoneTextBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-error-text.tsx b/packages/frameworks/react/src/file-upload/file-upload-error-text.tsx new file mode 100644 index 000000000..ed70f24ea --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-error-text.tsx @@ -0,0 +1,39 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement, ReactNode} from "react" + +import {IconOrNode} from "@qualcomm-ui/react/icon" +import { + CoreFileUpload, + type CoreFileUploadErrorTextProps, +} from "@qualcomm-ui/react-core/file-upload" +import type {LucideIconOrElement} from "@qualcomm-ui/react-core/lucide" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadErrorTextProps extends CoreFileUploadErrorTextProps { + children?: ReactNode + + /** + * Optional error indicator icon. + */ + icon?: LucideIconOrElement +} + +export function FileUploadErrorText({ + children, + icon, + ...props +}: FileUploadErrorTextProps): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getErrorTextBindings(), props) + + return ( + + {icon ? : null} + {children} + + ) +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-item-content.tsx b/packages/frameworks/react/src/file-upload/file-upload-item-content.tsx new file mode 100644 index 000000000..c564fd5bc --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload-item-content.tsx @@ -0,0 +1,20 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import type {ReactElement} from "react" + +import type {ElementRenderProp} from "@qualcomm-ui/react-core/system" +import {PolymorphicElement} from "@qualcomm-ui/react-core/system" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" + +export interface FileUploadItemContentProps extends ElementRenderProp<"div"> {} + +export function FileUploadItemContent( + props: FileUploadItemContentProps, +): ReactElement { + const qdsContext = useQdsFileUploadContext() + const mergedProps = mergeProps(qdsContext.getItemContentBindings(), props) + return +} diff --git a/packages/frameworks/react/src/file-upload/file-upload-item-name.tsx b/packages/frameworks/react/src/file-upload/file-upload-item-name.tsx index 7fd13b9ec..498c85eaf 100644 --- a/packages/frameworks/react/src/file-upload/file-upload-item-name.tsx +++ b/packages/frameworks/react/src/file-upload/file-upload-item-name.tsx @@ -6,6 +6,7 @@ import type {ReactElement} from "react" import { CoreFileUpload, type CoreFileUploadItemNameProps, + useFileUploadItemContext, } from "@qualcomm-ui/react-core/file-upload" import {mergeProps} from "@qualcomm-ui/utils/merge-props" @@ -18,6 +19,12 @@ export function FileUploadItemName( props: FileUploadItemNameProps, ): ReactElement { const qdsContext = useQdsFileUploadContext() + const itemContext = useFileUploadItemContext() const mergedProps = mergeProps(qdsContext.getItemNameBindings(), props) - return + + return ( + + {props.children ?? itemContext.file.name} + + ) } diff --git a/packages/frameworks/react/src/file-upload/file-upload-item-size-text.tsx b/packages/frameworks/react/src/file-upload/file-upload-item-size-text.tsx index 008f5370e..907c14a64 100644 --- a/packages/frameworks/react/src/file-upload/file-upload-item-size-text.tsx +++ b/packages/frameworks/react/src/file-upload/file-upload-item-size-text.tsx @@ -6,6 +6,8 @@ import type {ReactElement} from "react" import { CoreFileUpload, type CoreFileUploadItemSizeTextProps, + useFileUploadContext, + useFileUploadItemContext, } from "@qualcomm-ui/react-core/file-upload" import {mergeProps} from "@qualcomm-ui/utils/merge-props" @@ -18,6 +20,18 @@ export function FileUploadItemSizeText( props: FileUploadItemSizeTextProps, ): ReactElement { const qdsContext = useQdsFileUploadContext() + const fileUploadContext = useFileUploadContext() + const itemContext = useFileUploadItemContext() const mergedProps = mergeProps(qdsContext.getItemSizeTextBindings(), props) - return + + const defaultContent = + itemContext.type !== "rejected" + ? fileUploadContext.getFileSize(itemContext.file) + : null + + return ( + + {props.children ?? defaultContent} + + ) } diff --git a/packages/frameworks/react/src/file-upload/file-upload-label.tsx b/packages/frameworks/react/src/file-upload/file-upload-label.tsx index 930575ae3..2077b09cf 100644 --- a/packages/frameworks/react/src/file-upload/file-upload-label.tsx +++ b/packages/frameworks/react/src/file-upload/file-upload-label.tsx @@ -1,20 +1,42 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -import type {ReactElement} from "react" +import type {ReactElement, ReactNode} from "react" + +import {Asterisk} from "lucide-react" import { CoreFileUpload, type CoreFileUploadLabelProps, + useFileUploadContext, } from "@qualcomm-ui/react-core/file-upload" +import {Icon} from "@qualcomm-ui/react/icon" import {mergeProps} from "@qualcomm-ui/utils/merge-props" import {useQdsFileUploadContext} from "./qds-file-upload-context" -export interface FileUploadLabelProps extends CoreFileUploadLabelProps {} +export interface FileUploadLabelProps extends CoreFileUploadLabelProps { + children?: ReactNode +} -export function FileUploadLabel(props: FileUploadLabelProps): ReactElement { +export function FileUploadLabel({ + children, + ...props +}: FileUploadLabelProps): ReactElement { + const context = useFileUploadContext() const qdsContext = useQdsFileUploadContext() const mergedProps = mergeProps(qdsContext.getLabelBindings(), props) - return + + return ( + + {children} + {context.required ? ( + + ) : null} + + ) } diff --git a/packages/frameworks/react/src/file-upload/file-upload-root.tsx b/packages/frameworks/react/src/file-upload/file-upload-root.tsx index a3c955327..a6dcc55fe 100644 --- a/packages/frameworks/react/src/file-upload/file-upload-root.tsx +++ b/packages/frameworks/react/src/file-upload/file-upload-root.tsx @@ -1,7 +1,7 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear -import {type ReactElement, type ReactNode, useMemo} from "react" +import {type ReactElement, useMemo} from "react" import { createQdsFileUploadApi, @@ -18,9 +18,7 @@ import {QdsFileUploadContextProvider} from "./qds-file-upload-context" export interface FileUploadRootProps extends CoreFileUploadRootProps, - QdsFileUploadApiProps { - children: ReactNode -} + QdsFileUploadApiProps {} export function FileUploadRoot({ size, diff --git a/packages/frameworks/react/src/file-upload/file-upload-trigger.tsx b/packages/frameworks/react/src/file-upload/file-upload-trigger.tsx index 9c61043d0..b7cc59fa1 100644 --- a/packages/frameworks/react/src/file-upload/file-upload-trigger.tsx +++ b/packages/frameworks/react/src/file-upload/file-upload-trigger.tsx @@ -7,11 +7,27 @@ import { CoreFileUpload, type CoreFileUploadTriggerProps, } from "@qualcomm-ui/react-core/file-upload" +import {mergeProps} from "@qualcomm-ui/utils/merge-props" + +import {useQdsFileUploadContext} from "./qds-file-upload-context" export interface FileUploadTriggerProps extends CoreFileUploadTriggerProps {} -export function FileUploadTrigger( - props: FileUploadTriggerProps, -): ReactElement { - return +export function FileUploadTrigger({ + children, + ...props +}: FileUploadTriggerProps): ReactElement { + const qdsContext = useQdsFileUploadContext() + + return ( + + {(coreBindings) => { + const qdsBindings = qdsContext.getTriggerBindings() + const mergedBindings = mergeProps(qdsBindings, coreBindings) + return typeof children === "function" + ? children(mergedBindings) + : children + }} + + ) } diff --git a/packages/frameworks/react/src/file-upload/file-upload.tsx b/packages/frameworks/react/src/file-upload/file-upload.tsx new file mode 100644 index 000000000..95eb8f151 --- /dev/null +++ b/packages/frameworks/react/src/file-upload/file-upload.tsx @@ -0,0 +1,448 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +import {type ReactElement, type ReactNode, useEffect, useState} from "react" + +import {AlertCircle, FilePlus, Layers, Trash2, Upload} from "lucide-react" + +import type {FileDetails} from "@qualcomm-ui/core/file-upload" +import type {FileError} from "@qualcomm-ui/utils/files" +import {Button} from "@qualcomm-ui/react/button" +import {Icon} from "@qualcomm-ui/react/icon" +import { + useFileUploadContext, + useFileUploadItemContext, +} from "@qualcomm-ui/react-core/file-upload" +import {useControlledId} from "@qualcomm-ui/react-core/state" + +import { + FileUploadDropzone, + type FileUploadDropzoneProps, +} from "./file-upload-dropzone" +import {FileUploadDropzoneHint} from "./file-upload-dropzone-hint" +import {FileUploadDropzoneText} from "./file-upload-dropzone-text" +import {FileUploadDropzoneTextGroup} from "./file-upload-dropzone-text-group" +import {FileUploadDropzoneTextLine} from "./file-upload-dropzone-text-line" +import {FileUploadErrorText} from "./file-upload-error-text" +import { + FileUploadHiddenInput, + type FileUploadHiddenInputProps, +} from "./file-upload-hidden-input" +import {FileUploadItem, type FileUploadItemProps} from "./file-upload-item" +import {FileUploadItemContent} from "./file-upload-item-content" +import { + FileUploadItemGroup, + type FileUploadItemGroupProps, +} from "./file-upload-item-group" +import {FileUploadItemName} from "./file-upload-item-name" +import {FileUploadItemPreview} from "./file-upload-item-preview" +import {FileUploadItemPreviewImage} from "./file-upload-item-preview-image" +import {FileUploadItemSizeText} from "./file-upload-item-size-text" +import {FileUploadLabel, type FileUploadLabelProps} from "./file-upload-label" +import {FileUploadRoot, type FileUploadRootProps} from "./file-upload-root" +import {FileUploadTrigger} from "./file-upload-trigger" + +function FileUploadItemDeleteButton(): ReactElement { + const api = useFileUploadContext() + const itemContext = useFileUploadItemContext() + + return ( + + )} + + + {dropzoneHint && ( + {dropzoneHint} + )} + + + ) +} + +interface FileUploadDefaultContentProps { + addMoreButtonText: string + clearButtonText: string + fileUrls: Record + itemGroupProps?: FileUploadItemGroupProps + itemProps?: FileUploadItemProps + showAddMoreButton: boolean + showClearButton: boolean + showPreviews: boolean + size?: "sm" | "md" | "lg" +} + +function FileUploadDefaultContent({ + addMoreButtonText, + clearButtonText, + fileUrls, + itemGroupProps, + itemProps, + showAddMoreButton, + showClearButton, + showPreviews, + size = "md", +}: FileUploadDefaultContentProps): ReactElement { + const api = useFileUploadContext() + + const fileIconSize = size === "sm" ? 32 : 64 + + if (api.acceptedFiles.length === 0 && api.rejectedFiles.length === 0) { + return <> + } + + return ( + <> + 0 ? true : undefined} + > + {api.acceptedFiles.map((file) => ( + + + {showPreviews && + file.type.startsWith("image/") && + fileUrls[file.name] && ( + + )} + {(!showPreviews || + !file.type.startsWith("image/") || + !fileUrls[file.name]) && ( + + )} + + + + + + + + ))} + {api.rejectedFiles.map((rejection) => ( + + + + + + + + + {getErrorMessage(rejection.errors)} + + + + + ))} + + +
+ {showAddMoreButton && ( + + {(triggerProps) => ( + + )} + + )} + {showClearButton && ( + + )} +
+ + ) +} + +export interface FileUploadProps extends FileUploadRootProps { + /** + * Text to display on the "Add more files" button. + * + * @default "Add more files" + */ + addMoreButtonText?: string + + /** + * Text to display on the clear button. + * + * @default "Clear all" + */ + clearButtonText?: string + + /** + * Optional hint text to display in the dropzone below the main instruction. + * Typically used to show supported file types or size limits. + * + * @example "Supported file types: .jpg, .png, .pdf" + */ + dropzoneHint?: ReactNode + + /** + * Props applied to the dropzone element. + */ + dropzoneProps?: FileUploadDropzoneProps + + /** + * Optional error message that describes the element when {@link invalid} is true. + * + * @remarks + * To customize the element, provide it using the component instead: + * + * ```tsx + * ... + * ``` + */ + errorText?: ReactNode + + /** + * The main text displayed in the dropzone. + * + * @default "Drag & drop files, or" + */ + dropzoneText?: string + + /** + * Props applied to the hidden input element. + */ + hiddenInputProps?: FileUploadHiddenInputProps + + /** + * Props applied to the item group element. + */ + itemGroupProps?: FileUploadItemGroupProps + + /** + * Props applied to each file item element. + */ + itemProps?: FileUploadItemProps + + /** + * Optional label describing the file upload. This element is automatically + * associated with the hidden input element for accessibility. + * + * @remarks + * To customize the element, provide it using the component instead: + * + * ```tsx + * ... + * ``` + */ + label?: ReactNode + + /** + * Props applied to the label element. + */ + labelProps?: FileUploadLabelProps + + /** + * When `true`, renders an "Add more files" button when files are present. + * + * @default true + */ + showAddMoreButton?: boolean + + /** + * When `true`, renders a button to clear all accepted files. + * + * @default true + */ + showClearButton?: boolean + + /** + * When `true`, shows image previews for uploaded image files. + * + * @default true + */ + showPreviews?: boolean + + /** + * The text displayed on the browse trigger button within the dropzone. + * + * @default "browse" + */ + triggerText?: string +} + +export function FileUpload({ + addMoreButtonText = "Add more files", + children, + clearButtonText = "Clear all", + dropzoneHint, + dropzoneProps, + dropzoneText = "Drag & drop files, or", + errorText, + hiddenInputProps, + itemGroupProps, + itemProps, + label, + labelProps, + onFileChange, + showAddMoreButton = true, + showClearButton = true, + showPreviews = true, + triggerText = "browse", + ...props +}: FileUploadProps): ReactElement { + const [fileUrls, setFileUrls] = useState>({}) + + const labelContent = label || labelProps?.children + + const handleFileChange = (details: FileDetails) => { + setFileUrls((currentUrls) => { + const newUrls: Record = {} + const currentFileNames = new Set(details.acceptedFiles.map((f) => f.name)) + + details.acceptedFiles.forEach((file) => { + if (currentUrls[file.name]) { + newUrls[file.name] = currentUrls[file.name] + } else if (file.type.startsWith("image/")) { + newUrls[file.name] = URL.createObjectURL(file) + } + }) + + Object.keys(currentUrls).forEach((fileName) => { + if (!currentFileNames.has(fileName)) { + URL.revokeObjectURL(currentUrls[fileName]) + } + }) + + return newUrls + }) + + onFileChange?.(details) + } + + useEffect(() => { + return () => { + Object.values(fileUrls).forEach((url) => { + URL.revokeObjectURL(url) + }) + } + }, [fileUrls]) + + const ids = { + hiddenInput: useControlledId(hiddenInputProps?.id), + label: useControlledId(labelProps?.id), + root: useControlledId(props.id), + ...props.ids, + } + + return ( + + {labelContent && ( + + {labelContent} + + )} + + + + + + + + {children} + + {errorText && {errorText}} + + ) +} diff --git a/packages/frameworks/react/src/file-upload/index.ts b/packages/frameworks/react/src/file-upload/index.ts index 1bb6359dd..e6ae50852 100644 --- a/packages/frameworks/react/src/file-upload/index.ts +++ b/packages/frameworks/react/src/file-upload/index.ts @@ -1,6 +1,7 @@ // Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. // SPDX-License-Identifier: BSD-3-Clause-Clear +import {FileUpload as SimpleFileUpload} from "./file-upload" import { FileUploadClearTrigger, type FileUploadClearTriggerProps, @@ -9,11 +10,35 @@ import { FileUploadDropzone, type FileUploadDropzoneProps, } from "./file-upload-dropzone" +import { + FileUploadDropzoneHint, + type FileUploadDropzoneHintProps, +} from "./file-upload-dropzone-hint" +import { + FileUploadDropzoneText, + type FileUploadDropzoneTextProps, +} from "./file-upload-dropzone-text" +import { + FileUploadDropzoneTextGroup, + type FileUploadDropzoneTextGroupProps, +} from "./file-upload-dropzone-text-group" +import { + FileUploadDropzoneTextLine, + type FileUploadDropzoneTextLineProps, +} from "./file-upload-dropzone-text-line" +import { + FileUploadErrorText, + type FileUploadErrorTextProps, +} from "./file-upload-error-text" import { FileUploadHiddenInput, type FileUploadHiddenInputProps, } from "./file-upload-hidden-input" import {FileUploadItem, type FileUploadItemProps} from "./file-upload-item" +import { + FileUploadItemContent, + type FileUploadItemContentProps, +} from "./file-upload-item-content" import { FileUploadItemDeleteTrigger, type FileUploadItemDeleteTriggerProps, @@ -51,10 +76,16 @@ export type { FileUploadRootProps, FileUploadClearTriggerProps, FileUploadDropzoneProps, + FileUploadDropzoneHintProps, + FileUploadDropzoneTextProps, + FileUploadDropzoneTextGroupProps, + FileUploadDropzoneTextLineProps, + FileUploadErrorTextProps, FileUploadTriggerProps, FileUploadLabelProps, FileUploadHiddenInputProps, FileUploadItemProps, + FileUploadItemContentProps, FileUploadItemGroupProps, FileUploadItemNameProps, FileUploadItemPreviewProps, @@ -63,34 +94,47 @@ export type { FileUploadItemDeleteTriggerProps, } -type FileUploadComponent = { - Root: typeof FileUploadRoot +type FileUploadComponent = typeof SimpleFileUpload & { ClearTrigger: typeof FileUploadClearTrigger Dropzone: typeof FileUploadDropzone - Trigger: typeof FileUploadTrigger - Label: typeof FileUploadLabel + DropzoneHint: typeof FileUploadDropzoneHint + DropzoneText: typeof FileUploadDropzoneText + DropzoneTextGroup: typeof FileUploadDropzoneTextGroup + DropzoneTextLine: typeof FileUploadDropzoneTextLine + ErrorText: typeof FileUploadErrorText HiddenInput: typeof FileUploadHiddenInput Item: typeof FileUploadItem + ItemContent: typeof FileUploadItemContent + ItemDeleteTrigger: typeof FileUploadItemDeleteTrigger ItemGroup: typeof FileUploadItemGroup ItemName: typeof FileUploadItemName ItemPreview: typeof FileUploadItemPreview ItemPreviewImage: typeof FileUploadItemPreviewImage ItemSizeText: typeof FileUploadItemSizeText - ItemDeleteTrigger: typeof FileUploadItemDeleteTrigger + Label: typeof FileUploadLabel + Root: typeof FileUploadRoot + Trigger: typeof FileUploadTrigger } -export const FileUpload: FileUploadComponent = { - Root: FileUploadRoot, - ClearTrigger: FileUploadClearTrigger, - Dropzone: FileUploadDropzone, - Trigger: FileUploadTrigger, - Label: FileUploadLabel, - HiddenInput: FileUploadHiddenInput, - Item: FileUploadItem, - ItemGroup: FileUploadItemGroup, - ItemName: FileUploadItemName, - ItemPreview: FileUploadItemPreview, - ItemPreviewImage: FileUploadItemPreviewImage, - ItemSizeText: FileUploadItemSizeText, - ItemDeleteTrigger: FileUploadItemDeleteTrigger, -} +export const FileUpload: FileUploadComponent = + SimpleFileUpload as FileUploadComponent + +FileUpload.Root = FileUploadRoot +FileUpload.ClearTrigger = FileUploadClearTrigger +FileUpload.Dropzone = FileUploadDropzone +FileUpload.DropzoneHint = FileUploadDropzoneHint +FileUpload.DropzoneText = FileUploadDropzoneText +FileUpload.DropzoneTextGroup = FileUploadDropzoneTextGroup +FileUpload.DropzoneTextLine = FileUploadDropzoneTextLine +FileUpload.ErrorText = FileUploadErrorText +FileUpload.Trigger = FileUploadTrigger +FileUpload.Label = FileUploadLabel +FileUpload.HiddenInput = FileUploadHiddenInput +FileUpload.Item = FileUploadItem +FileUpload.ItemContent = FileUploadItemContent +FileUpload.ItemGroup = FileUploadItemGroup +FileUpload.ItemName = FileUploadItemName +FileUpload.ItemPreview = FileUploadItemPreview +FileUpload.ItemPreviewImage = FileUploadItemPreviewImage +FileUpload.ItemSizeText = FileUploadItemSizeText +FileUpload.ItemDeleteTrigger = FileUploadItemDeleteTrigger From 8107e69cd288a35fc1f7039c704696b8b9196aa7 Mon Sep 17 00:00:00 2001 From: Dustin Pham Date: Wed, 4 Feb 2026 17:43:37 -0800 Subject: [PATCH 16/27] fix linting Signed-off-by: Dustin Pham --- .../core/src/file-upload/file-upload.api.ts | 12 ++++++++---- .../core/src/file-upload/file-upload.types.ts | 8 ++++---- .../components+/file-upload+/_file-upload.mdx | 5 ++++- .../demos/file-upload-composite-demo.tsx | 6 ++++-- .../core-file-upload-root.directive.ts | 2 +- .../core-file-upload-trigger.directive.ts | 2 +- .../file-upload/file-upload-context.directive.ts | 2 +- .../file-upload-item-preview-image.directive.ts | 4 +--- .../src/file-upload/file-upload.module.ts | 6 +++--- .../file-upload/file-upload-dropzone-hint.tsx | 6 ++++-- .../file-upload-dropzone-text-group.tsx | 6 ++++-- .../file-upload-dropzone-text-line.tsx | 6 ++++-- .../file-upload/file-upload-dropzone-text.tsx | 6 ++++-- .../src/file-upload/file-upload-dropzone.tsx | 3 +-- .../src/file-upload/file-upload-item-content.tsx | 6 ++++-- .../src/file-upload/file-upload-item-name.tsx | 3 +-- .../react/src/file-upload/file-upload-label.tsx | 2 +- .../react/src/file-upload/file-upload.tsx | 16 ++++++++-------- 18 files changed, 58 insertions(+), 43 deletions(-) diff --git a/packages/common/core/src/file-upload/file-upload.api.ts b/packages/common/core/src/file-upload/file-upload.api.ts index 025a8861e..ba16dae69 100644 --- a/packages/common/core/src/file-upload/file-upload.api.ts +++ b/packages/common/core/src/file-upload/file-upload.api.ts @@ -90,7 +90,6 @@ export function createFileUploadApi( disabled, dragging, focused, - required, getClearTriggerBindings(): FileUploadClearTriggerBindings { return normalize.button({ ...commonBindings, @@ -244,7 +243,9 @@ export function createFileUploadApi( tabIndex: disabled || props.disableClick ? undefined : 0, }) }, - getErrorTextBindings(props: IdRegistrationProps): FileUploadErrorTextBindings { + getErrorTextBindings( + props: IdRegistrationProps, + ): FileUploadErrorTextBindings { scope.ids.register("errorText", props) return normalize.element({ ...commonBindings, @@ -262,7 +263,9 @@ export function createFileUploadApi( return normalize.input({ ...commonBindings, accept: computed("acceptAttr"), - "aria-describedby": ariaAttr(invalid ? domIds.errorText(scope) : undefined), + "aria-describedby": ariaAttr( + invalid ? domIds.errorText(scope) : undefined, + ), "aria-invalid": booleanAriaAttr(invalid), capture: prop("capture"), disabled, @@ -314,7 +317,6 @@ export function createFileUploadApi( type: "button", }) }, - getItemGroupBindings(props = {}) { const {type = DEFAULT_ITEM_TYPE} = props return normalize.element({ @@ -423,6 +425,8 @@ export function createFileUploadApi( rejectedFiles: context.get("rejectedFiles"), + required, + setClipboardFiles(dt) { if (disabled) { return false diff --git a/packages/common/core/src/file-upload/file-upload.types.ts b/packages/common/core/src/file-upload/file-upload.types.ts index 800218b7d..b9f3a8fae 100644 --- a/packages/common/core/src/file-upload/file-upload.types.ts +++ b/packages/common/core/src/file-upload/file-upload.types.ts @@ -312,10 +312,6 @@ export interface FileUploadApi { * Whether the user is focused on the dropzone element */ focused: boolean - /** - * Whether the file input is required - */ - required: boolean getClearTriggerBindings: () => FileUploadClearTriggerBindings getDropzoneBindings: ( props?: DropzoneProps & {id?: string}, @@ -348,6 +344,10 @@ export interface FileUploadApi { * The files that have been rejected */ rejectedFiles: FileRejection[] + /** + * Whether the file input is required + */ + required: boolean /** * Sets the clipboard files * Returns `true` if the clipboard data contains files, `false` otherwise. diff --git a/packages/docs/react-docs/src/routes/components+/file-upload+/_file-upload.mdx b/packages/docs/react-docs/src/routes/components+/file-upload+/_file-upload.mdx index f92c9e4f2..666eb98be 100644 --- a/packages/docs/react-docs/src/routes/components+/file-upload+/_file-upload.mdx +++ b/packages/docs/react-docs/src/routes/components+/file-upload+/_file-upload.mdx @@ -33,7 +33,10 @@ The simple API provides a standalone component with built-in layout. Build with the composite API for granular control. This API requires you to provide each subcomponent, but gives you full control over the structure and layout. - + ### Sizes diff --git a/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.tsx b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.tsx index 4080301fd..f9718a694 100644 --- a/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.tsx +++ b/packages/docs/react-docs/src/routes/components+/file-upload+/demos/file-upload-composite-demo.tsx @@ -6,7 +6,6 @@ import {type ReactElement, useEffect, useState} from "react" import {AlertCircle, FilePlus, Layers, Trash2, Upload} from "lucide-react" import type {FileDetails} from "@qualcomm-ui/core/file-upload" -import type {FileError} from "@qualcomm-ui/utils/files" import {Button} from "@qualcomm-ui/react/button" import {FileUpload} from "@qualcomm-ui/react/file-upload" import {Icon} from "@qualcomm-ui/react/icon" @@ -14,6 +13,7 @@ import { useFileUploadContext, useFileUploadItemContext, } from "@qualcomm-ui/react-core/file-upload" +import type {FileError} from "@qualcomm-ui/utils/files" function FileUploadItemDeleteButton(): ReactElement { const api = useFileUploadContext() @@ -140,7 +140,9 @@ function FileUploadCompositeDemoDropzone(): ReactElement { - Drag & drop images, or + + Drag & drop images, or + {(triggerProps) => (