{"version":3,"file":"file-input-upload.cjs","names":[],"sources":["../../../src/inputs/file-input/file-input-upload.ts"],"sourcesContent":["import { matchesAccept } from '@vielzeug/dnd';\nimport { type Readable, signal, watch } from '@vielzeug/ripple';\n\nimport { announce } from '../../core';\n\n// ── Byte/speed formatting ────────────────────────────────────────────────────\n\nexport const formatBytes = (bytes: number): string => {\n  if (bytes === 0) return '0 B';\n\n  const k = 1024;\n  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\n\n  return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;\n};\n\nconst isFileSizeAllowed = (file: File, maxSize: number): boolean => !maxSize || file.size <= maxSize;\n\nconst formatSpeed = (bytesPerSecond: number): string => (bytesPerSecond > 0 ? `${formatBytes(bytesPerSecond)}/s` : '');\n\nconst parseAccept = (accept: string | undefined): string[] => (accept ? accept.split(',').map((t) => t.trim()) : []);\n\n// ── Upload lifecycle types ───────────────────────────────────────────────────\n\n/** Upload lifecycle status for a single file. `idle` covers both \"no `upload` handler wired up\"\n * (picker-only mode, fully supported) and \"not started yet\". */\nexport type FileUploadStatus = 'error' | 'idle' | 'success' | 'uploading';\n\n/** Per-file upload snapshot. One reactive signal per file — see `createFileQueue` — not one\n * signal holding a map, so a progress tick for one file never invalidates another's bindings. */\nexport type FileUploadState = {\n  error: string | null;\n  loaded: number;\n  speedBps: number;\n  status: FileUploadStatus;\n  total: number;\n};\n\nconst IDLE_UPLOAD_STATE: Omit<FileUploadState, 'total'> = { error: null, loaded: 0, speedBps: 0, status: 'idle' };\n\nconst formatEta = (state: FileUploadState): string => {\n  if (state.speedBps <= 0 || state.status !== 'uploading') return '';\n\n  const remainingSeconds = Math.max(0, Math.round((state.total - state.loaded) / state.speedBps));\n\n  if (remainingSeconds < 60) return `${remainingSeconds}s left`;\n\n  return `${Math.round(remainingSeconds / 60)}m left`;\n};\n\n/**\n * Consumer-supplied transport — this component has no built-in network layer (zero-dependency,\n * transport-agnostic by design, same reasoning as `courier`'s own fetch-wrapping-only stance).\n * This function is the seam between the UI's upload lifecycle (progress, retry, per-file\n * isolation) and however the app actually moves bytes (`fetch`, `XMLHttpRequest`, a presigned S3\n * PUT, `@vielzeug/courier`, …).\n *\n * `resumeFrom` is a *hint*, not a guarantee: it's the byte offset the component last observed\n * via `onProgress` before the previous attempt failed (0 on a fresh upload). A transport that\n * supports byte ranges (e.g. an HTTP `Range`/`Content-Range` header, S3 multipart, tus) can use\n * it to genuinely resume from that point; a plain `fetch(url, { body: file })` can ignore it and\n * simply re-send the whole file — the UI still shows a resumed-from-N-bytes experience either\n * way, since the file itself was never dropped from memory.\n */\nexport type FileUploadFn = (\n  file: File,\n  ctx: { onProgress: (loaded: number, total: number) => void; resumeFrom: number; signal: AbortSignal },\n) => Promise<void>;\n\n// ── Queue ─────────────────────────────────────────────────────────────────────\n\nexport type FileQueueOptions = {\n  accept: Readable<string | undefined>;\n  disabled: Readable<boolean>;\n  maxFiles: Readable<number>;\n  maxSize: Readable<number>;\n  multiple: Readable<boolean>;\n  onChange: (files: File[], originalEvent?: Event) => void;\n  onRemove: (file: File, files: File[], originalEvent?: Event) => void;\n  onUploadError: (file: File, error: unknown) => void;\n  onUploadProgress: (file: File, loaded: number, total: number) => void;\n  onUploadSuccess: (file: File) => void;\n  upload: Readable<FileUploadFn | undefined>;\n};\n\nexport type FileQueue = {\n  addFiles(newFiles: File[], originalEvent?: Event): void;\n  dispose(): void;\n  readonly files: Readable<File[]>;\n  fileState(file: File): FileUploadState;\n  removeFile(file: File, originalEvent?: Event): void;\n  /** Validates `newFile` (accept/max-size) and, if accepted, swaps it in for `oldFile` in place\n   * and starts its upload. Returns whether the replacement was accepted. Also fires `onRemove`\n   * for `oldFile` — a replace is a removal-and-add that happen to land in the same slot, and a\n   * consumer listening only for removal (e.g. to clean up a server-side record) still needs to\n   * hear that the old file is gone. */\n  replaceFile(oldFile: File, newFile: File, originalEvent?: Event): boolean;\n  retryUpload(file: File): void;\n  statusIconName(file: File): string;\n  uploadMetaText(file: File): string;\n  uploadPercent(file: File): number;\n};\n\n/**\n * Owns file selection *and* the opt-in upload lifecycle together — they were never really\n * separable (adding a file starts its upload; removing one must abort it; replacing one is both\n * at once). Independent of `html`/rendering, following the same shape as `datagrid`'s\n * `createDataGridControls` and `combobox`'s `combobox-options.ts`: a factory `file-input.ts`\n * wires into drag/drop/native-input events and the template, directly unit-testable without\n * mounting a component.\n *\n * Every file-level rule (accept, max-size, max-files, disabled) and every upload-lifecycle rule\n * (progress, retry-from-offset, per-file isolation) lives here in one place.\n */\nexport function createFileQueue(options: FileQueueOptions): FileQueue {\n  const files = signal<File[]>([]);\n  const stateSignals = new Map<File, ReturnType<typeof signal<FileUploadState>>>();\n  const controllers = new Map<File, AbortController>();\n\n  function stateSignal(file: File): ReturnType<typeof signal<FileUploadState>> {\n    let s = stateSignals.get(file);\n\n    if (!s) {\n      s = signal<FileUploadState>({ ...IDLE_UPLOAD_STATE, total: file.size });\n      stateSignals.set(file, s);\n    }\n\n    return s;\n  }\n\n  function fileState(file: File): FileUploadState {\n    return stateSignal(file).value;\n  }\n\n  function patchState(file: File, patch: Partial<FileUploadState>): void {\n    const s = stateSignal(file);\n\n    s.value = { ...s.value, ...patch };\n  }\n\n  // Each file gets its own `AbortController` and its own promise chain — nothing here `await`s\n  // another file's upload, so one file's rejection can never block, freeze, or cancel the others.\n  function startUpload(file: File, resumeFrom = 0): void {\n    if (options.disabled.value) return;\n\n    const uploadFn = options.upload.value;\n\n    if (!uploadFn) return;\n\n    controllers.get(file)?.abort();\n\n    const controller = new AbortController();\n\n    controllers.set(file, controller);\n    patchState(file, { error: null, loaded: resumeFrom, speedBps: 0, status: 'uploading', total: file.size });\n\n    // Rolling (exponential-moving-average) speed estimate rather than a naive\n    // `loaded / elapsedSinceStart` average — the latter drags heavily behind the file's actual\n    // *current* rate right after a slow start or a mid-transfer stall.\n    let lastSampleAt = performance.now();\n    let lastSampleLoaded = resumeFrom;\n\n    uploadFn(file, {\n      onProgress: (loaded, total) => {\n        if (controller.signal.aborted) return;\n\n        const now = performance.now();\n        const elapsedSeconds = (now - lastSampleAt) / 1000;\n        const instantBps = elapsedSeconds > 0 ? Math.max(0, (loaded - lastSampleLoaded) / elapsedSeconds) : 0;\n        const previousBps = fileState(file).speedBps;\n        const speedBps = previousBps > 0 ? previousBps * 0.7 + instantBps * 0.3 : instantBps;\n\n        lastSampleAt = now;\n        lastSampleLoaded = loaded;\n        patchState(file, { loaded, speedBps, total: total || file.size });\n        options.onUploadProgress(file, loaded, total || file.size);\n      },\n      resumeFrom,\n      signal: controller.signal,\n    })\n      .then(() => {\n        if (controller.signal.aborted) return;\n\n        patchState(file, { loaded: file.size, speedBps: 0, status: 'success' });\n        announce(`${file.name} uploaded successfully.`);\n        options.onUploadSuccess(file);\n      })\n      .catch((error: unknown) => {\n        if (controller.signal.aborted) return;\n\n        const message = error instanceof Error ? error.message : String(error);\n\n        patchState(file, { error: message, speedBps: 0, status: 'error' });\n        announce(`${file.name} failed to upload: ${message}`, { politeness: 'assertive' });\n        options.onUploadError(file, error);\n      });\n  }\n\n  // Resumes from the last byte offset this file actually reached, not from 0 — see\n  // `FileUploadFn`'s doc comment on what \"resume\" honestly means when the transport is\n  // consumer-supplied.\n  function retryUpload(file: File): void {\n    if (options.disabled.value) return;\n\n    startUpload(file, fileState(file).loaded);\n  }\n\n  // Aborts + drops upload state for any file no longer in `files` — covers plain removal, a\n  // non-`multiple` selection replacing the previous file, and `replaceFile()` swapping one File\n  // for another. One cleanup path instead of duplicating it at every call site that can shrink\n  // `files`.\n  function dropOrphanedState(activeFiles: File[]): void {\n    const keep = new Set(activeFiles);\n\n    for (const file of stateSignals.keys()) {\n      if (keep.has(file)) continue;\n\n      controllers.get(file)?.abort();\n      controllers.delete(file);\n      stateSignals.delete(file);\n    }\n  }\n\n  const filesWatch = watch(files, dropOrphanedState);\n\n  function addFiles(newFiles: File[], originalEvent?: Event): void {\n    if (options.disabled.value) return;\n\n    const isMultiple = options.multiple.value;\n    let incoming = Array.from(newFiles);\n\n    if (!isMultiple) incoming = incoming.slice(0, 1);\n\n    const acceptList = parseAccept(options.accept.value);\n    const maxSize = options.maxSize.value;\n\n    incoming = incoming.filter((f) => matchesAccept(f, acceptList) && isFileSizeAllowed(f, maxSize));\n\n    let updated: File[] = isMultiple ? [...files.value] : [];\n    const added: File[] = [];\n\n    for (const f of incoming) {\n      if (!updated.includes(f)) {\n        updated.push(f);\n        added.push(f);\n      }\n    }\n\n    const maxFiles = options.maxFiles.value;\n\n    if (maxFiles > 0 && updated.length > maxFiles) updated = updated.slice(0, maxFiles);\n\n    files.value = updated;\n    options.onChange(files.value, originalEvent);\n\n    for (const f of added) {\n      if (files.value.includes(f)) startUpload(f);\n    }\n  }\n\n  function removeFile(file: File, originalEvent?: Event): void {\n    if (options.disabled.value) return;\n\n    files.value = files.value.filter((f) => f !== file);\n    options.onRemove(file, files.value, originalEvent);\n    options.onChange(files.value, originalEvent);\n  }\n\n  function replaceFile(oldFile: File, newFile: File, originalEvent?: Event): boolean {\n    if (options.disabled.value) return false;\n\n    const acceptList = parseAccept(options.accept.value);\n\n    if (!matchesAccept(newFile, acceptList) || !isFileSizeAllowed(newFile, options.maxSize.value)) return false;\n\n    files.value = files.value.map((f) => (f === oldFile ? newFile : f));\n    options.onRemove(oldFile, files.value, originalEvent);\n    options.onChange(files.value, originalEvent);\n    startUpload(newFile);\n\n    return true;\n  }\n\n  function uploadPercent(file: File): number {\n    const state = fileState(file);\n\n    if (state.total <= 0) return 0;\n\n    return Math.min(100, Math.round((state.loaded / state.total) * 100));\n  }\n\n  // Speed + ETA on one line — percent itself is rendered separately (e.g. `<ore-progress>`'s\n  // own `label`), so this only needs the two metrics that wouldn't otherwise fit there.\n  function uploadMetaText(file: File): string {\n    const state = fileState(file);\n\n    return [formatSpeed(state.speedBps), formatEta(state)].filter(Boolean).join(' · ');\n  }\n\n  function statusIconName(file: File): string {\n    const status = fileState(file).status;\n\n    if (status === 'success') return 'check';\n\n    if (status === 'error') return 'alert-circle';\n\n    return 'file';\n  }\n\n  function dispose(): void {\n    filesWatch.dispose();\n\n    for (const controller of controllers.values()) controller.abort();\n\n    controllers.clear();\n    stateSignals.clear();\n  }\n\n  return {\n    addFiles,\n    dispose,\n    fileState,\n    files,\n    removeFile,\n    replaceFile,\n    retryUpload,\n    statusIconName,\n    uploadMetaText,\n    uploadPercent,\n  };\n}\n"],"mappings":"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"}