diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs
index 6222c96f43..d282265f94 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs
@@ -29,6 +29,34 @@ public class BitFileInfo
///
[JsonPropertyName("index")] public int Index { get; set; }
+ ///
+ /// The last modified time of the file reported by the browser, in milliseconds since the Unix epoch.
+ ///
+ [JsonPropertyName("lastModified")] public long LastModified { get; set; }
+
+ ///
+ /// An object URL of the file content that can be used as the source of an img element to preview image files.
+ /// This is only populated for image files when the ShowPreview parameter of the BitFileUpload is enabled.
+ ///
+ [JsonPropertyName("previewUrl")] public string? PreviewUrl { get; set; }
+
+ ///
+ /// The width of the image in pixels, only populated for decodable image files when the
+ /// ReadImageDimensions parameter of the BitFileUpload is enabled. It is null for anything else.
+ ///
+ [JsonPropertyName("width")] public int? Width { get; set; }
+
+ ///
+ /// The height of the image in pixels, only populated for decodable image files when the
+ /// ReadImageDimensions parameter of the BitFileUpload is enabled. It is null for anything else.
+ ///
+ [JsonPropertyName("height")] public int? Height { get; set; }
+
+ ///
+ /// The last modified time of the file reported by the browser, as a DateTimeOffset.
+ ///
+ [JsonIgnore] public DateTimeOffset LastModifiedDate => DateTimeOffset.FromUnixTimeMilliseconds(LastModified);
+
///
/// The size of the last uploaded chunk of the file.
///
@@ -39,12 +67,57 @@ public class BitFileInfo
///
public long TotalUploadedSize { get; internal set; }
+ ///
+ /// The observed speed of the upload of this file in bytes per second, measured over the request currently
+ /// in flight. It is null while the file is not uploading and until the first progress report arrives.
+ ///
+ [JsonIgnore] public double? UploadSpeed { get; internal set; }
+
+ ///
+ /// The estimated time left before the upload of this file completes, derived from the
+ /// and the bytes still to be sent. It is null whenever the speed is unknown.
+ ///
+ [JsonIgnore] public TimeSpan? RemainingTime { get; internal set; }
+
+ ///
+ /// Whether the file is waiting in the upload queue for a free slot of the ConcurrentUploads limit,
+ /// which is what tells a file that is about to start apart from one that was never asked to upload.
+ ///
+ [JsonIgnore] public bool IsQueued { get; internal set; }
+
+
+ // Whether a request of this file is on the wire right now. A second request for the same file would
+ // take over the connection of the first one and start it over from the beginning, so a repeated
+ // upload call has to find out that this file is already busy and leave it alone.
+ internal bool IsRequestInFlight { get; set; }
+
+ // The moment the request currently in flight was sent and the byte count already uploaded back then,
+ // which is what the upload speed of that request is measured against.
+ internal DateTime? TransferStartTime { get; set; }
+ internal long TransferStartOffset { get; set; }
- internal bool PauseUploadRequested { get; set; }
- internal bool CancelUploadRequested { get; set; }
+ // The span of the in-flight upload request, remembered at send time since the dynamic chunk size
+ // can change before the response of that request arrives.
+ internal long PendingChunkSize { get; set; }
+
+ // Whether this specific file is being removed from the server, driving its own spinner in the UI.
+ internal bool IsRemoving { get; set; }
+
+ // The number of automatic retries already spent on the upload of this file,
+ // reset by a successful chunk and by a manual retry.
+ internal int AutoRetryAttempts { get; set; }
+
+ // The URL the queued upload of this file was requested with, remembered so that a file waiting for a
+ // free slot still goes to the very endpoint the Upload call that queued it asked for.
+ internal string? QueuedUploadUrl { get; set; }
+
+ // Tracks whether the file was rejected by a list level rule (a duplicate, MaxCount or MaxTotalSize),
+ // so it can be taken back once removals free up room or drop the original of a duplicate.
+ internal bool ListValidationFailed { get; set; }
///
- /// The error message is issued during file validation before uploading the file or at the time of uploading.
+ /// The message attached to the current of the file: the reason it was rejected by
+ /// the validations before the upload, or the body of the server response of its upload or removal.
///
[JsonIgnore] public string? Message { get; internal set; }
@@ -57,5 +130,13 @@ public class BitFileInfo
/// The HTTP header at upload file.
///
[JsonIgnore] public Dictionary? HttpHeaders { get; set; }
+
+ ///
+ /// Additional multipart form fields sent alongside the content of this specific file in its upload
+ /// requests, merged over the ones of the UploadRequestFormFields parameter of the BitFileUpload.
+ /// The natural place to fill it in is the OnUploading callback.
+ ///
+ [JsonIgnore] public Dictionary? FormFields { get; set; }
+
[JsonIgnore] internal DateTime? StartTimeUpload { get; set; }
}
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor
index 87f431b30b..55a3a844d6 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor
@@ -1,4 +1,4 @@
-@namespace Bit.BlazorUI
+@namespace Bit.BlazorUI
@inherits BitComponentBase
+ }
+
+
+ aria-label="@AriaLabel"
+ aria-labelledby="@(AriaLabel is null && LabelTemplate is null && HideLabel is false && Label.HasValue() ? _buttonId : null)"
+ aria-describedby="@(_HasDescription ? _descriptionId : null)"
+ accept="@GetAcceptValue()" />
- @if (Files is not null)
+ @* a list whose every file has been removed renders nothing at all, so the list element itself goes
+ away with them rather than staying behind as an empty landmark in the accessibility tree. *@
+ @if (HideFileView is false && Files.Any(f => f.Status != BitFileUploadStatus.Removed))
{
-
- @for (var i = 0; i < Files.Count; i++)
+
+ @* a removed file is not part of the list anymore, so it is left out of it here rather than
+ leaving an empty listitem behind for a custom template that renders nothing for it. *@
+ @foreach (var file in Files.Where(f => f.Status != BitFileUploadStatus.Removed))
{
- var index = i;
- var file = Files[index];
- file.Index = index;
-
- if (HideFileView is false)
+ if (FileViewTemplate is not null)
{
- if (FileViewTemplate is not null)
- {
+ @* the custom template is wrapped so that every child of the list keeps carrying a listitem role *@
+
+
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor.cs
index a812d01898..dc3cc69b58 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor.cs
@@ -1,20 +1,39 @@
-using System.Text;
+using System.Globalization;
+using System.Text;
using System.Text.Encodings.Web;
namespace Bit.BlazorUI;
///
-/// BitFileUpload component wraps the HTML file input element(s) and uploads them to a given URL. The files can be removed by specifying the URL they have been uploaded.
+/// BitFileUpload wraps the HTML file input element(s) and uploads them to a given URL, with support for
+/// drag-and-drop, clipboard paste, folder and camera capture selection, image previews, chunked and resumable
+/// uploads, a concurrency limit, pause/cancel, automatic retries, validation, and server-side removal.
///
public partial class BitFileUpload : BitComponentBase
{
private const int MIN_CHUNK_SIZE = 512 * 1024; // 512 kb
private const int MAX_CHUNK_SIZE = 10 * 1024 * 1024; // 10 mb
+ // roughly three repaints a second, which is as often as a progress bar is worth redrawing and
+ // far less often than the browser reports the progress of the requests behind it.
+ private static readonly TimeSpan PROGRESS_RENDER_INTERVAL = TimeSpan.FromMilliseconds(300);
+
+ private bool _allowDrop = true;
+ private DateTime _lastProgressRender = DateTime.MinValue;
+ private bool _allowPaste = true;
+ private bool _expandDirectories;
+ private string? _dragClass;
+ private string? _dragStyle;
+ private string? _announcement;
+ private bool _announcementMarker;
+ private int _removingCount;
+ private string _buttonId = default!;
+ private string _descriptionId = default!;
private ElementReference _inputRef;
private List _files = [];
+ private List _uploadQueue = [];
private long _internalChunkSize = MIN_CHUNK_SIZE;
private IJSObjectReference _dropZoneRef = default!;
private DotNetObjectReference _dotnetObj = default!;
@@ -28,32 +47,83 @@ public partial class BitFileUpload : BitComponentBase
///
- /// The value of the accept attribute of the input element.
+ /// Accepted file types for the file browser using MIME types or file extensions (e.g., "image/*", ".pdf,.doc").
+ /// Applied to the underlying HTML input element's accept attribute.
+ /// When not set, the accept attribute is generated from .
///
[Parameter] public string? Accept { get; set; }
///
- /// Filters files by extension.
+ /// Whether files can be selected by dragging them from the operating system and dropping them on the component.
+ /// The default value is true.
+ ///
+ [Parameter] public bool AllowDrop { get; set; } = true;
+
+ ///
+ /// Whether a file that is already in the file list can be selected again.
+ /// When disabled, a newly selected file matching an existing one by name, size and last modified time
+ /// is rejected with the instead of being uploaded a second time,
+ /// becoming eligible again once the file it duplicates is removed.
+ /// The default value is true.
+ ///
+ [Parameter] public bool AllowDuplicates { get; set; } = true;
+
+ ///
+ /// Allowed file types for validation purposes, accepting both file extensions (e.g., [".jpg", ".png", ".pdf"])
+ /// and MIME types with an optional wildcard (e.g., ["image/*", "application/pdf"]).
+ /// The leading dot of an extension is optional and the matching is case-insensitive.
+ /// Use ["*"] to allow all file types. Files not matching any of these entries will not be uploaded.
///
[Parameter] public IReadOnlyCollection AllowedExtensions { get; set; } = ["*"];
///
- /// Enables the append mode that appends any additional selected file(s) to the current file list.
+ /// Whether files can be selected by pasting them from the clipboard onto the component.
+ /// The paste is only captured while the focus is inside the component, so the browse button must be focused first.
+ /// The default value is true.
+ ///
+ [Parameter] public bool AllowPaste { get; set; } = true;
+
+ ///
+ /// Custom provider of the text announced by the screen reader through the live region of the component
+ /// whenever the file list or an upload outcome changes. Receives the current file list and returns the text
+ /// to announce, or null to announce nothing. When not set, a built-in English announcement is used.
+ ///
+ [Parameter] public Func, string?>? AnnouncementProvider { get; set; }
+
+ ///
+ /// Whether a new selection is added to the end of the current file list instead of replacing it,
+ /// which is what lets the user build a batch up over several rounds of browsing, dropping or pasting.
+ /// The files already in the list keep their upload state.
///
[Parameter] public bool Append { get; set; }
+ ///
+ /// The number of times a failed upload of a file gets retried automatically before it is reported as failed.
+ /// In the chunked mode each retry resumes from the last successfully uploaded chunk.
+ /// Set to 0 (the default) to disable the automatic retries.
+ ///
+ [Parameter] public int AutoRetries { get; set; }
+
+ ///
+ /// The delay before each automatic retry of a failed upload.
+ /// Set to null (the default) to retry immediately.
+ ///
+ [Parameter] public TimeSpan? AutoRetryDelay { get; set; }
+
///
/// Calculate the chunk size dynamically based on the user's Internet speed between 512 KB and 10 MB.
///
[Parameter] public bool AutoChunkSize { get; set; }
///
- /// Automatically resets the file-upload before starting to browse for files.
+ /// Whether the file list and the upload state are cleared right before the file dialog opens, so that
+ /// every browse starts from a clean slate - the list empties even if the dialog is then cancelled.
///
[Parameter] public bool AutoReset { get; set; }
///
- /// Automatically starts the upload file(s) process immediately after selecting the file(s).
+ /// Whether the selected files start uploading the moment they are selected, skipping the per-file
+ /// upload button entirely, for the cases where the selection itself expresses the intent to upload.
///
[Parameter] public bool AutoUpload { get; set; }
@@ -80,17 +150,81 @@ public partial class BitFileUpload : BitComponentBase
[Parameter] public string? CancelIconName { get; set; }
///
- /// Enables the chunked upload.
+ /// The tooltip of the cancel upload button, which is also used as the prefix of its accessible label
+ /// (e.g., "Cancel report.pdf"). Defaults to "Cancel".
+ ///
+ [Parameter] public string? CancelButtonTitle { get; set; }
+
+ ///
+ /// The message shown for canceled file uploads.
+ ///
+ [Parameter] public string CanceledUploadMessage { get; set; } = "File upload canceled";
+
+ ///
+ /// The capture behavior of the file input on devices with a camera or microphone,
+ /// rendered as the capture attribute of the input element (e.g., "user" for the front camera,
+ /// "environment" for the rear camera).
+ ///
+ [Parameter] public string? Capture { get; set; }
+
+ ///
+ /// Whether each file is sliced and sent as a series of sequential requests instead of one monolithic
+ /// one, which is what makes a paused or failed file resume from the last chunk that made it through
+ /// rather than starting over, so a dropped connection costs one chunk instead of the whole transfer.
///
[Parameter] public bool ChunkedUpload { get; set; }
///
- /// The size of each chunk of file upload in bytes.
+ /// The size in bytes of each chunk of a chunked upload. When not set - and whenever
+ /// is enabled, which takes the decision over - it starts at 512 KB.
///
[Parameter]
[CallOnSet(nameof(OnSetChunkSize))]
public long? ChunkSize { get; set; }
+ ///
+ /// Custom CSS classes for different parts of the BitFileUpload.
+ ///
+ [Parameter] public BitFileUploadClassStyles? Classes { get; set; }
+
+ ///
+ /// The general color of the file upload, applied to the browse button, the drag-and-drop indicator,
+ /// the progress bars and the hovered action buttons.
+ ///
+ [Parameter, ResetClassBuilder]
+ public BitColor? Color { get; set; }
+
+ ///
+ /// The maximum number of files uploading at the same time, the remaining ones waiting in a queue
+ /// and starting as soon as a slot frees up. Set to 0 (the default) to start every file at once.
+ ///
+ [Parameter] public int ConcurrentUploads { get; set; }
+
+ ///
+ /// A short hint rendered under the browse button and wired to it through aria-describedby,
+ /// which is the place to spell out the accepted file types and the size limits so that both sighted
+ /// and screen reader users learn the constraints before hitting them.
+ ///
+ [Parameter] public string? Description { get; set; }
+
+ ///
+ /// Custom Razor template of the hint rendered under the browse button, taking precedence over .
+ ///
+ [Parameter] public RenderFragment? DescriptionTemplate { get; set; }
+
+ ///
+ /// Whether to select folders (directories) instead of files, rendered as the webkitdirectory attribute.
+ /// All files inside the selected folder and its subfolders will be added to the file list.
+ /// It also makes a dropped folder expand into its contents instead of being ignored.
+ ///
+ [Parameter] public bool Directory { get; set; }
+
+ ///
+ /// The message shown for the files rejected for being already in the file list
+ /// while is disabled.
+ ///
+ [Parameter] public string DuplicateErrorMessage { get; set; } = "The file is already selected";
+
///
/// The message shown for failed file removes.
///
@@ -102,82 +236,165 @@ public partial class BitFileUpload : BitComponentBase
[Parameter] public string FailedUploadMessage { get; set; } = "File upload failed";
///
- /// The custom file view template.
+ /// Custom formatter of the file size shown under the name of each file item.
+ /// Receives the size of the file in bytes and returns the text to display,
+ /// which is the place to localize the units or to switch between the binary and the decimal bases.
+ /// When not set, a built-in humanizer is used.
+ ///
+ [Parameter] public Func? FileSizeFormatter { get; set; }
+
+ ///
+ /// Custom validation function called for each newly selected file after the built-in validations pass.
+ /// Return an error message to reject the file so it will not be uploaded, or null to accept it.
+ ///
+ [Parameter] public Func? FileValidator { get; set; }
+
+ ///
+ /// Custom Razor template rendering each item of the file list in place of the built-in one, receiving
+ /// the file as its context with its name, size, progress, speed and status all available. It is only
+ /// asked for the files that are actually in the list, so a removed file leaves no empty item behind.
///
[Parameter] public RenderFragment? FileViewTemplate { get; set; }
///
- /// Hides the file view section of the file upload.
+ /// Whether the built-in file list is left unrendered. The files are still selected, validated,
+ /// uploaded and reported through and the callbacks - they are simply not drawn,
+ /// which is what the surrounding page needs when it shows the attachments in a layout of its own.
///
[Parameter] public bool HideFileView { get; set; }
///
- /// The text of select file button.
+ /// Whether to hide the default browse button label from the UI.
+ ///
+ [Parameter] public bool HideLabel { get; set; }
+
+ ///
+ /// The text of the browse button. Setting it to an empty string hides the button altogether.
///
[Parameter] public string Label { get; set; } = "Browse";
///
- /// A custom razor template for select button.
+ /// Custom Razor template rendered in place of the browse button, which also replaces the built-in
+ /// dashed drop indicator living on that button - a custom label should bring its own drag feedback
+ /// through the Dragging entry of or .
///
[Parameter] public RenderFragment? LabelTemplate { get; set; }
///
- /// Specifies the maximum size (byte) of the file (0 for unlimited).
+ /// Maximum allowed number of files in the file list (0 for unlimited).
+ /// Files selected beyond this count are rejected at selection time and will not be uploaded.
+ /// Only files that pass the other validations consume a slot.
+ ///
+ [Parameter] public int MaxCount { get; set; }
+
+ ///
+ /// Specifies the message shown for the files rejected due to exceeding the maximum number of files.
+ ///
+ [Parameter] public string MaxCountErrorMessage { get; set; } = "The maximum number of files is exceeded";
+
+ ///
+ /// The maximum allowed size in bytes of each file (0 for unlimited). A larger file is rejected at
+ /// selection time with the and will not be uploaded.
///
[Parameter] public long MaxSize { get; set; }
///
- /// Specifies the message for the failed uploading progress due to exceeding the maximum size.
+ /// The message shown for the files rejected for being larger than the .
///
[Parameter] public string MaxSizeErrorMessage { get; set; } = "The file size is larger than the max size";
///
- /// Enables multi-file selection.
+ /// Maximum allowed total size in bytes of all the files of the file list (0 for unlimited).
+ /// Files pushing the accumulated size beyond this limit are rejected at selection time and will not be
+ /// uploaded, becoming eligible again once removals free up room.
+ /// Only files that pass the other validations consume the budget.
+ ///
+ [Parameter] public long MaxTotalSize { get; set; }
+
+ ///
+ /// Specifies the message shown for the files rejected for making the total size of the file list
+ /// exceed the maximum total size.
+ ///
+ [Parameter] public string MaxTotalSizeErrorMessage { get; set; } = "The total size of the files is larger than the max total size";
+
+ ///
+ /// The minimum allowed size in bytes of each file (0 for no limit). A smaller file is rejected at
+ /// selection time with the and will not be uploaded.
+ ///
+ [Parameter] public long MinSize { get; set; }
+
+ ///
+ /// The message shown for the files rejected for being smaller than the .
+ ///
+ [Parameter] public string MinSizeErrorMessage { get; set; } = "The file size is smaller than the min size";
+
+ ///
+ /// Whether several files can be handed over at once, both through the file dialog and through a
+ /// single drop or paste. Without it a multi-file drop or paste is trimmed down to its first file.
///
[Parameter] public bool Multiple { get; set; }
///
- /// Specifies the message for the failed uploading progress due to the allowed extensions.
+ /// The message shown for the files rejected for not matching any entry of .
///
[Parameter] public string NotAllowedExtensionErrorMessage { get; set; } = "The file type is not allowed";
///
- /// Callback for when all files are uploaded.
+ /// Callback for when every file of a batch that actually started uploading has reached a terminal
+ /// state - completed, failed, canceled, removed or rejected by the validations. A selection that was
+ /// never asked to upload never settles, so it never reports itself as complete.
///
[Parameter] public EventCallback OnAllUploadsComplete { get; set; }
///
- /// Callback for when file or files status change.
+ /// Callback for when file or files status change. It is invoked with the whole file list right after
+ /// a selection, and with only the file that changed whenever a single status changes afterwards, so
+ /// the current state of the batch is better read back from than from the argument.
///
[Parameter] public EventCallback OnChange { get; set; }
///
- /// Callback for when the file upload is progressed.
+ /// Callback invoked right after whenever a selection carries at least one file
+ /// rejected by the validations, providing an array of only the rejected files along with their messages.
+ ///
+ [Parameter] public EventCallback OnInvalid { get; set; }
+
+ ///
+ /// Callback for when the upload of a file makes progress, invoked on every progress report of the
+ /// browser with the file whose ,
+ /// and have just moved.
///
[Parameter] public EventCallback OnProgress { get; set; }
///
- /// Callback for when a remove file is done.
+ /// Callback for when a file has been removed, whether it was dropped from the list on this side or
+ /// deleted from the server through the .
///
[Parameter] public EventCallback OnRemoveComplete { get; set; }
///
- /// Callback for when a remove file is failed.
+ /// Callback for when the removal of a file from the server failed, leaving the file in the list with
+ /// the rather than pretending it is gone.
///
[Parameter] public EventCallback OnRemoveFailed { get; set; }
///
- /// Callback for when a file upload is about to start.
+ /// Callback for when a file upload is about to start, invoked before the request that carries its
+ /// first byte and therefore once per run of the file rather than once per chunk. It is the place to
+ /// attach the and the
+ /// that belong to this one file, both of which are read again for every request it makes.
///
[Parameter] public EventCallback OnUploading { get; set; }
///
- /// Callback for when a file upload is done.
+ /// Callback for when a file has been uploaded successfully, with the body of the server response of
+ /// its last request on its .
///
[Parameter] public EventCallback OnUploadComplete { get; set; }
///
- /// Callback for when an upload file is failed.
+ /// Callback for when the upload of a file failed for good - after the automatic retries, if any, have
+ /// all been spent - with the body of the failed response on its .
///
[Parameter] public EventCallback OnUploadFailed { get; set; }
@@ -203,6 +420,27 @@ public partial class BitFileUpload : BitComponentBase
///
[Parameter] public string? PauseIconName { get; set; }
+ ///
+ /// The tooltip of the pause upload button, which is also used as the prefix of its accessible label
+ /// (e.g., "Pause report.pdf"). Defaults to "Pause".
+ ///
+ [Parameter] public string? PauseButtonTitle { get; set; }
+
+ ///
+ /// The message shown for the files waiting in the queue for a free slot of the
+ /// limit, which is what tells a file that is about to start apart from one that was never asked to upload.
+ ///
+ [Parameter] public string QueuedUploadMessage { get; set; } = "Waiting to upload";
+
+ ///
+ /// Whether to read the pixel dimensions of the selected image files, filling the
+ /// and of each of them before the
+ /// validations run, so that a can reject an image by its dimensions.
+ /// Reading them means decoding every image in the browser, which costs time and memory on a large
+ /// selection, so it is off by default.
+ ///
+ [Parameter] public bool ReadImageDimensions { get; set; }
+
///
/// Gets or sets the icon to use for the remove file button using custom CSS classes for external icon libraries.
/// Takes precedence over when both are set.
@@ -226,39 +464,111 @@ public partial class BitFileUpload : BitComponentBase
[Parameter] public string? RemoveIconName { get; set; }
///
- /// Custom http headers for remove request.
+ /// The tooltip of the remove file button, which is also used as the prefix of its accessible label
+ /// (e.g., "Remove report.pdf"). Defaults to "Remove".
+ ///
+ [Parameter] public string? RemoveButtonTitle { get; set; }
+
+ ///
+ /// Custom HTTP headers attached to the remove request.
///
[Parameter] public Dictionary? RemoveRequestHttpHeaders { get; set; }
///
- /// The provider function to create the http headers for remove request.
+ /// The provider function creating the HTTP headers of the remove request, invoked right before the
+ /// request goes out and taking precedence over .
///
[Parameter] public Func>>? RemoveRequestHttpHeadersProvider { get; set; }
///
- /// Custom query strings for remove request.
+ /// The HTTP method of the remove request (e.g., "POST"). Defaults to "DELETE".
+ ///
+ [Parameter] public string? RemoveRequestHttpMethod { get; set; }
+
+ ///
+ /// Custom query strings appended to the URL of the remove request.
///
[Parameter] public Dictionary? RemoveRequestQueryStrings { get; set; }
///
- /// The provider function to create the query strings for remove request.
+ /// The provider function creating the query strings of the remove request, invoked right before the
+ /// request goes out and taking precedence over .
///
[Parameter] public Func>>? RemoveRequestQueryStringsProvider { get; set; }
///
- /// URL of the server endpoint removing the files.
+ /// URL of the server endpoint removing the files. A file whose bytes already reached the server is
+ /// deleted from it through a request to this URL carrying its name as a query string and its id in
+ /// the BIT_FILE_ID header; a file that never uploaded is simply dropped from the list without one.
///
[Parameter] public string? RemoveUrl { get; set; }
///
- /// Show/Hide after upload remove button.
+ /// Gets or sets the icon to use for the retry button of a failed or canceled file using custom CSS classes
+ /// for external icon libraries. Takes precedence over when both are set.
+ /// Defaults to the built-in Refresh icon when neither is set.
+ ///
+ ///
+ /// Use this property to render a custom retry icon from external libraries like FontAwesome or Bootstrap Icons.
+ /// For built-in Fluent UI icons, use instead.
+ ///
+ [Parameter] public BitIconInfo? RetryIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to use for the retry button of a failed or canceled file
+ /// from the built-in Fluent UI icons. Defaults to Refresh when not set.
+ ///
+ ///
+ /// The icon name should be from the Fluent UI icon set (e.g., BitIconName.Refresh).
+ ///
+ /// For external icon libraries, use instead.
+ ///
+ [Parameter] public string? RetryIconName { get; set; }
+
+ ///
+ /// The tooltip of the retry button of a failed or canceled file, which is also used as the prefix of its
+ /// accessible label (e.g., "Retry report.pdf"). Defaults to "Retry".
+ ///
+ [Parameter] public string? RetryButtonTitle { get; set; }
+
+ ///
+ /// Decides whether a failed upload is worth retrying automatically, receiving the file and the HTTP status
+ /// code of the failed request (0 for a network error, a timeout or an aborted request) and returning true
+ /// to spend one of the attempts on it.
+ /// When not set, a built-in rule retries the failures a second attempt can plausibly survive - network
+ /// errors, timeouts, 408, 429 and the 5xx server errors - and gives up right away on the other 4xx,
+ /// which say that the request itself is the problem and would fail again just the same.
+ ///
+ [Parameter] public Func? ShouldAutoRetry { get; set; }
+
+ ///
+ /// Whether a thumbnail of every selected image is shown at the head of its file item, produced
+ /// entirely in the browser from an object URL that is handed back as soon as the file is removed or
+ /// the component is reset. The same URL is on the of each file.
+ ///
+ [Parameter] public bool ShowPreview { get; set; }
+
+ ///
+ /// Whether each settled file item offers a remove button, which drops a file that never uploaded from
+ /// the list and deletes an uploaded one from the server through the .
///
[Parameter] public bool ShowRemoveButton { get; set; }
+ ///
+ /// The size of the file upload, applied to the browse button and the file list items.
+ ///
+ [Parameter, ResetClassBuilder]
+ public BitSize? Size { get; set; }
+
+ ///
+ /// Custom CSS styles for different parts of the BitFileUpload.
+ ///
+ [Parameter] public BitFileUploadClassStyles? Styles { get; set; }
+
///
/// The message shown for successful file uploads.
///
- [Parameter] public string SuccessfulUploadMessage { get; set; } = "File upload succeed";
+ [Parameter] public string SuccessfulUploadMessage { get; set; } = "File upload succeeded";
///
/// Gets or sets the icon to use for the upload button using custom CSS classes for external icon libraries.
@@ -283,35 +593,85 @@ public partial class BitFileUpload : BitComponentBase
[Parameter] public string? UploadIconName { get; set; }
///
- /// Custom http headers for upload request.
+ /// The tooltip of the upload button, which is also used as the prefix of its accessible label
+ /// (e.g., "Upload report.pdf"). Defaults to "Upload".
+ ///
+ [Parameter] public string? UploadButtonTitle { get; set; }
+
+ ///
+ /// The name of the form field carrying the file content in the upload request. Defaults to "file".
+ ///
+ [Parameter] public string? UploadFormFieldName { get; set; }
+
+ ///
+ /// Additional multipart form fields sent alongside the content of every file in its upload requests,
+ /// which is what carries the metadata a server needs next to the bytes - a target folder, an album id,
+ /// a caption - for the endpoints that read it from the form rather than from the query string.
+ /// The of a file is merged over these for that file.
+ ///
+ [Parameter] public Dictionary? UploadRequestFormFields { get; set; }
+
+ ///
+ /// Custom HTTP headers attached to the upload requests, fixed at selection time.
///
[Parameter] public Dictionary? UploadRequestHttpHeaders { get; set; }
///
/// The provider function to create the http headers for upload request.
+ /// Unlike , it is invoked right before every single request -
+ /// each file and each chunk - which is what lets it hand over a freshly minted access token.
///
[Parameter] public Func>>? UploadRequestHttpHeadersProvider { get; set; }
///
- /// Custom query strings for upload request.
+ /// The HTTP method of the upload request (e.g., "PUT"). Defaults to "POST".
+ ///
+ [Parameter] public string? UploadRequestHttpMethod { get; set; }
+
+ ///
+ /// Custom query strings appended to the URL of the upload requests, fixed at selection time.
///
[Parameter] public Dictionary? UploadRequestQueryStrings { get; set; }
///
/// The provider function to create the query strings for upload request.
+ /// Unlike , it is invoked right before every single request -
+ /// each file and each chunk - which is what lets it hand over a value that does not survive a batch.
///
[Parameter] public Func>>? UploadRequestQueryStringsProvider { get; set; }
///
- /// URL of the server endpoint receiving the files.
+ /// The timeout of the upload request for each file or chunk. When it elapses the upload of the file fails.
+ /// Set to null (the default) for no timeout.
+ ///
+ [Parameter] public TimeSpan? UploadTimeout { get; set; }
+
+ ///
+ /// URL of the server endpoint receiving the files, fixed at selection time. Use
+ /// instead for an endpoint that has to be minted per request.
///
[Parameter] public string? UploadUrl { get; set; }
///
/// The provider function to create the URL of the server endpoint receiving the files.
+ /// Unlike , it is invoked right before every single request - each file and
+ /// each chunk - which is what lets it hand over a presigned URL that expires.
///
[Parameter] public Func>? UploadUrlProvider { get; set; }
+ ///
+ /// The visual variant of the browse button, which decides how much of the it carries:
+ /// a full fill, only an outline, or neither.
+ ///
+ [Parameter, ResetClassBuilder]
+ public BitVariant? Variant { get; set; }
+
+ ///
+ /// Whether the upload request is sent with credentials such as cookies and authorization headers
+ /// for cross-origin requests (the withCredentials flag of the underlying XMLHttpRequest).
+ ///
+ [Parameter] public bool WithCredentials { get; set; }
+
///
@@ -320,7 +680,10 @@ public partial class BitFileUpload : BitComponentBase
public IReadOnlyList Files => _files;
///
- /// The current status of the file uploader.
+ /// The status of the batch as a whole: while nothing has been
+ /// uploaded yet, from the moment an upload is started, and
+ /// once every file has reached a terminal state, whichever it is.
+ /// The outcome of each individual file is on its own .
///
public BitFileUploadStatus UploadStatus { get; private set; }
@@ -330,81 +693,181 @@ public partial class BitFileUpload : BitComponentBase
public string? InputId { get; private set; }
///
- /// Indicates that the file upload is in the middle of removing a file.
+ /// Indicates that the file upload is in the middle of removing at least one file.
///
- public bool IsRemoving { get; private set; }
+ public bool IsRemoving => _removingCount > 0;
///
- /// Starts uploading the file(s).
+ /// The total size in bytes of all the files of the batch, excluding the removed ones
+ /// and the ones rejected by the validations.
///
- public async Task Upload(BitFileInfo? fileInfo = null, string? uploadUrl = null)
+ public long TotalSize => _files.Where(IsCountedInOverallProgress).Sum(f => f.Size);
+
+ ///
+ /// The total uploaded size in bytes across all the files of the batch, excluding the removed ones
+ /// and the ones rejected by the validations.
+ ///
+ public long TotalUploadedSize => _files.Where(IsCountedInOverallProgress)
+ .Sum(f => Math.Min(f.Size, f.TotalUploadedSize + f.LastChunkUploadedSize));
+
+ ///
+ /// The overall upload progress of the batch as a percentage (0 to 100),
+ /// combining the progress of all the files weighted by their size.
+ ///
+ public int OverallUploadProgress
{
- if (_files.Any() is false) return;
+ get
+ {
+ var totalSize = TotalSize;
+
+ return totalSize == 0 ? 0 : (int)(TotalUploadedSize * 100 / totalSize);
+ }
+ }
- if (UploadStatus != BitFileUploadStatus.InProgress)
+ ///
+ /// The combined speed in bytes per second of every file of the batch that is uploading right now,
+ /// which is what the connection as a whole is carrying. It is null while nothing is on the wire.
+ ///
+ public double? TotalUploadSpeed
+ {
+ get
{
- UploadStatus = BitFileUploadStatus.InProgress;
+ var speeds = _files.Where(f => f.UploadSpeed is > 0).Sum(f => f.UploadSpeed!.Value);
+
+ return speeds > 0 ? speeds : null;
}
+ }
- await UpdateStatus(BitFileUploadStatus.InProgress, fileInfo);
+ ///
+ /// The estimated time left before the whole batch is uploaded, derived from the
+ /// and the bytes of the batch that are still to be sent.
+ /// It is null whenever nothing is uploading and the speed is therefore unknown.
+ ///
+ public TimeSpan? OverallRemainingTime
+ {
+ get
+ {
+ if (TotalUploadSpeed is not { } speed) return null;
- if (fileInfo is null)
+ // the progress reports count the multipart overhead too, so the bytes reported as sent can
+ // run slightly past the batch itself, which must not turn into a negative time left.
+ var remaining = Math.Max(0, TotalSize - TotalUploadedSize);
+
+ return TimeSpan.FromSeconds(remaining / speed);
+ }
+ }
+
+ ///
+ /// Starts uploading the file(s), resuming a paused or chunked file from the last chunk that made it
+ /// through and retrying a failed or canceled one with a fresh budget of automatic retries. A file whose
+ /// request is already on the wire is left running rather than being started over, and a file that has
+ /// completed, been removed or been rejected by the validations has nothing left to send.
+ ///
+ ///
+ /// null (default) => all files | else => specific file
+ ///
+ /// A custom URL to upload to, overriding the for this call.
+ public async Task Upload(BitFileInfo? fileInfo = null, string? uploadUrl = null)
+ {
+ if (_files.Any() is false) return;
+
+ BitFileInfo[] targets = fileInfo is null ? [.. _files] : [fileInfo];
+
+ // an upload call with nothing left to upload must not disturb the state of the settled batch,
+ // and in particular must not report its completion once more.
+ if (targets.Any(HasPendingWork) is false) return;
+
+ UploadStatus = BitFileUploadStatus.InProgress;
+
+ foreach (var file in targets)
{
- foreach (var file in _files)
+ // a file already in flight is walking through its own sequence of chunks and keeps the slot
+ // it is holding, so it never goes back to the end of the queue between two chunks.
+ if (ConcurrentUploads > 0 && file.Status != BitFileUploadStatus.InProgress)
{
- await UploadOneFile(file, uploadUrl);
+ if (HasPendingWork(file) is false) continue;
+
+ file.QueuedUploadUrl = uploadUrl;
+
+ if (_uploadQueue.Contains(file) is false)
+ {
+ _uploadQueue.Add(file);
+ file.IsQueued = true;
+ }
+
+ continue;
}
+
+ await UploadOneFile(file, uploadUrl);
}
- else
- {
- await UploadOneFile(fileInfo, uploadUrl);
- }
+
+ await PumpUploadQueue();
+
+ RequestRender();
}
///
- /// Pauses the upload.
+ /// Pauses the upload of the files that are on their way: an in-progress file aborts its in-flight request
+ /// immediately and keeps the bytes that made it, and a file waiting in the queue of the
+ /// limit is taken out of that queue. Both can be resumed later through
+ /// , which picks a chunked file up from its last completed chunk. A file that was never
+ /// asked to upload, and one that has already settled, are left exactly as they are.
///
///
/// null (default) => all files | else => specific file
///
- public void PauseUpload(BitFileInfo? fileInfo = null)
+ public async Task PauseUpload(BitFileInfo? fileInfo = null)
{
if (_files.Any() is false) return;
if (fileInfo is null)
{
- foreach (var file in _files)
+ foreach (var file in _files.ToArray())
{
- file.PauseUploadRequested = true;
+ await PauseOneFile(file);
}
}
else
{
- fileInfo.PauseUploadRequested = true;
+ await PauseOneFile(fileInfo);
}
+
+ // pausing a running file frees its slot up for the next file waiting in the queue.
+ await PumpUploadQueue();
+
+ RequestRender();
}
///
- /// Cancels the upload.
+ /// Cancels the upload of every file that is still in play - running, waiting in the queue, paused or
+ /// merely selected - settling each of them as canceled right away rather than recording an intention
+ /// nobody can see, and aborting the in-flight request of a running one. A file that has already
+ /// settled is left alone, and a canceled file can be started again later through .
///
///
/// null (default) => all files | else => specific file
///
- public void CancelUpload(BitFileInfo? fileInfo = null)
+ public async Task CancelUpload(BitFileInfo? fileInfo = null)
{
if (_files.Any() is false) return;
if (fileInfo is null)
{
- foreach (var file in _files)
+ foreach (var file in _files.ToArray())
{
- file.CancelUploadRequested = true;
+ await CancelOneFile(file);
}
}
else
{
- fileInfo.CancelUploadRequested = true;
+ await CancelOneFile(fileInfo);
}
+
+ // canceling a running file frees its slot up for the next file waiting in the queue,
+ // and canceling the last file still running settles the batch as a whole.
+ await SettleFile();
+
+ RequestRender();
}
///
@@ -416,23 +879,44 @@ public void CancelUpload(BitFileInfo? fileInfo = null)
public async Task RemoveFile(BitFileInfo? fileInfo = null)
{
if (_files.Any() is false) return;
- if (IsRemoving) return;
- IsRemoving = true;
+ // a removal already running for this very file must not be started a second time, but the removal
+ // of another file has no reason to be dropped just because one is already on its way.
+ if (fileInfo is null ? IsRemoving : fileInfo.IsRemoving) return;
- if (fileInfo is null)
+ _removingCount++;
+
+ try
{
- foreach (var file in _files)
+ if (fileInfo is null)
{
- await RemoveOneFile(file);
+ foreach (var file in _files.ToArray())
+ {
+ await RemoveOneFile(file);
+ }
+ }
+ else
+ {
+ await RemoveOneFile(fileInfo);
}
}
- else
+ finally
{
- await RemoveOneFile(fileInfo);
+ // a reset landing in the middle of a removal already dropped the counter to zero,
+ // and this removal must not push it below that into a state nothing recovers from.
+ _removingCount = Math.Max(0, _removingCount - 1);
}
- IsRemoving = false;
+ // the room the removed files gave back can take in the files a list level limit had rejected.
+ ApplyListValidations();
+
+ Announce();
+
+ // and it can also free a slot up for the next file waiting to be uploaded, or settle the
+ // batch as a whole when the file taken away was the last one still running.
+ await SettleFile();
+
+ RequestRender();
}
///
@@ -455,8 +939,20 @@ public async Task Browse()
///
public async Task Reset()
{
+ if (IsDisposed) return;
+
_files.Clear();
+ _uploadQueue.Clear();
+ // the removals of the files that just went away have nobody left to report to, so the counter
+ // they were holding is dropped with them rather than leaving the component removing forever.
+ _removingCount = 0;
+ UploadStatus = BitFileUploadStatus.Pending;
+
await _js.BitFileUploadReset(UniqueId, _inputRef);
+
+ Announce();
+
+ StateHasChanged();
}
@@ -467,13 +963,26 @@ public async Task Reset()
[JSInvokable("HandleChunkUploadProgress")]
public async Task __HandleChunkUploadProgress(int index, long loaded)
{
- if (_files.Any() is false) return;
+ if (index < 0 || index >= _files.Count) return;
var file = _files[index];
if (file.Status != BitFileUploadStatus.InProgress) return;
file.LastChunkUploadedSize = loaded;
+
+ UpdateTransferRate(file);
+
await UpdateStatus(BitFileUploadStatus.InProgress, file);
+
+ // a browser reports the progress of a request many times a second, and every file being uploaded
+ // reports its own, so repainting the whole file list on each of them would spend more time
+ // rendering than uploading on a large batch. the settling of a chunk or of a file renders on its
+ // own anyway, so the only thing a skipped repaint costs is a progress bar a few frames behind.
+ var now = DateTime.UtcNow;
+ if (now - _lastProgressRender < PROGRESS_RENDER_INTERVAL) return;
+
+ _lastProgressRender = now;
+
StateHasChanged();
}
@@ -483,38 +992,68 @@ public async Task __HandleChunkUploadProgress(int index, long loaded)
[JSInvokable("HandleChunkUpload")]
public async Task __HandleChunkUpload(int fileIndex, int responseStatus, string responseText)
{
- if (_files.Any() is false || UploadStatus == BitFileUploadStatus.Paused) return;
+ if (fileIndex < 0 || fileIndex >= _files.Count) return;
var file = _files[fileIndex];
+
+ // whatever this response says, the request it answers is over and the file is free again.
+ file.IsRequestInFlight = false;
+
if (file.Status != BitFileUploadStatus.InProgress) return;
- file.TotalUploadedSize += ChunkedUpload ? _internalChunkSize : file.Size;
file.LastChunkUploadedSize = 0;
- UpdateChunkSize(fileIndex);
-
- if (file.TotalUploadedSize < file.Size)
- {
- await Upload(file);
- }
- else
+ if (responseStatus is >= 200 and <= 299)
{
- file.Message = responseText;
- if (responseStatus is >= 200 and <= 299)
+ file.TotalUploadedSize += file.PendingChunkSize;
+ file.AutoRetryAttempts = 0;
+
+ UpdateChunkSize(fileIndex);
+
+ if (file.TotalUploadedSize < file.Size)
{
- await UpdateStatus(BitFileUploadStatus.Completed, file);
+ await Upload(file);
}
- else if ((responseStatus is 0 && (file.Status is BitFileUploadStatus.Paused or BitFileUploadStatus.Canceled)) is false)
+ else
{
- await UpdateStatus(BitFileUploadStatus.Failed, file);
+ file.Message = responseText;
+ await UpdateStatus(BitFileUploadStatus.Completed, file);
+ await SettleFile();
}
-
- var allFilesUploaded = _files.All(c => c.Status is BitFileUploadStatus.Completed or BitFileUploadStatus.Failed);
- if (allFilesUploaded)
+ }
+ else
+ {
+ // a failed chunk fails the whole file right away instead of blindly moving on to the next chunk.
+ // its size is not counted as uploaded, so a retry - automatic or through the Upload method -
+ // resumes from the last successfully uploaded chunk.
+ if (AutoRetries > 0 && file.AutoRetryAttempts < AutoRetries && IsWorthRetrying(file, responseStatus))
{
- UploadStatus = BitFileUploadStatus.Completed;
- await OnAllUploadsComplete.InvokeAsync([.. _files]);
+ file.AutoRetryAttempts++;
+
+ if (AutoRetryDelay is { } delay && delay > TimeSpan.Zero)
+ {
+ await Task.Delay(delay);
+ }
+
+ if (IsDisposed) return;
+
+ // the file may have been paused, canceled, removed or reset while the delay was pending,
+ // in which case that outcome stands instead of being overwritten by this stale failure.
+ if (file.Status != BitFileUploadStatus.InProgress || _files.Contains(file) is false)
+ {
+ await PumpUploadQueue();
+ StateHasChanged();
+ return;
+ }
+
+ await Upload(file);
+ StateHasChanged();
+ return;
}
+
+ file.Message = responseText;
+ await UpdateStatus(BitFileUploadStatus.Failed, file);
+ await SettleFile();
}
StateHasChanged();
@@ -524,151 +1063,736 @@ public async Task __HandleChunkUpload(int fileIndex, int responseStatus, string
protected override string RootElementClass => "bit-upl";
+ protected override void RegisterCssClasses()
+ {
+ ClassBuilder.Register(() => Classes?.Root);
+
+ ClassBuilder.Register(() => Variant switch
+ {
+ BitVariant.Fill => "bit-upl-fil",
+ BitVariant.Outline => "bit-upl-otl",
+ BitVariant.Text => "bit-upl-txt",
+ _ => "bit-upl-fil"
+ });
+
+ ClassBuilder.Register(() => Color switch
+ {
+ BitColor.Primary => "bit-upl-pri",
+ BitColor.Secondary => "bit-upl-sec",
+ BitColor.Tertiary => "bit-upl-ter",
+ BitColor.Info => "bit-upl-inf",
+ BitColor.Success => "bit-upl-suc",
+ BitColor.Warning => "bit-upl-wrn",
+ BitColor.SevereWarning => "bit-upl-swr",
+ BitColor.Error => "bit-upl-err",
+ BitColor.PrimaryBackground => "bit-upl-pbg",
+ BitColor.SecondaryBackground => "bit-upl-sbg",
+ BitColor.TertiaryBackground => "bit-upl-tbg",
+ BitColor.PrimaryForeground => "bit-upl-pfg",
+ BitColor.SecondaryForeground => "bit-upl-sfg",
+ BitColor.TertiaryForeground => "bit-upl-tfg",
+ BitColor.PrimaryBorder => "bit-upl-pbr",
+ BitColor.SecondaryBorder => "bit-upl-sbr",
+ BitColor.TertiaryBorder => "bit-upl-tbr",
+ _ => "bit-upl-pri"
+ });
+
+ ClassBuilder.Register(() => Size switch
+ {
+ BitSize.Small => "bit-upl-sm",
+ BitSize.Medium => "bit-upl-md",
+ BitSize.Large => "bit-upl-lg",
+ _ => "bit-upl-md"
+ });
+ }
+
+ protected override void RegisterCssStyles()
+ {
+ StyleBuilder.Register(() => Styles?.Root);
+ }
+
protected override Task OnInitializedAsync()
{
InputId = $"FileUpload-{UniqueId}-input";
+ _buttonId = $"FileUpload-{UniqueId}-label";
+ _descriptionId = $"FileUpload-{UniqueId}-description";
return base.OnInitializedAsync();
}
+ protected override async Task OnParametersSetAsync()
+ {
+ await base.OnParametersSetAsync();
+
+ if (_dropZoneRef is null) return;
+
+ await UpdateDropZone();
+ }
+
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
_dotnetObj = DotNetObjectReference.Create(this);
- _dropZoneRef = await _js.BitFileUploadSetupDragDrop(RootElement, _inputRef);
- }
+ _allowDrop = AllowDrop;
+ _allowPaste = AllowPaste;
+ _expandDirectories = Directory;
+ _dragClass = GetDragClass();
+ _dragStyle = Styles?.Dragging;
+ _dropZoneRef = await _js.BitFileUploadSetupDragDrop(RootElement, _inputRef, _dragClass, _dragStyle,
+ _allowDrop, _allowPaste, _expandDirectories);
+ if (IsDisposed) return;
+ if (_dropZoneRef is null) return;
- internal bool IsFileTypeNotAllowed(BitFileInfo file)
+ // a parameter change that arrived while the setup was still awaiting found no drop zone to update yet,
+ // so the drop zone is synchronized once more against the parameters as they stand now.
+ await UpdateDropZone();
+ }
+
+
+
+ internal bool IsFileTypeNotAllowed(BitFileInfo file)
{
- if (Accept.HasNoValue()) return false;
+ if (AllowsAllFileTypes(AllowedExtensions)) return false;
- var fileSections = file.Name.Split('.');
- var extension = $".{fileSections?.Last()}";
- return AllowedExtensions.Count > 0 && AllowedExtensions.All(ext => ext != "*") && AllowedExtensions.All(ext => ext != extension);
+ return IsFileTypeNotAllowed(file, GetNormalizedExtensions(AllowedExtensions).ToArray());
}
- private async Task HandleOnChange()
+ internal string GetStatusMessage(BitFileInfo file)
+ {
+ return file.Status switch
+ {
+ BitFileUploadStatus.Completed => SuccessfulUploadMessage,
+ BitFileUploadStatus.Failed => FailedUploadMessage,
+ BitFileUploadStatus.Canceled => CanceledUploadMessage,
+ BitFileUploadStatus.RemoveFailed => FailedRemoveMessage,
+ BitFileUploadStatus.NotAllowed => file.Message ?? NotAllowedExtensionErrorMessage,
+ // a file waiting for a free slot of the concurrency limit says so, since it looks exactly
+ // like a file nobody asked to upload while it is in fact already on its way.
+ BitFileUploadStatus.Pending when file.IsQueued => QueuedUploadMessage,
+ _ => string.Empty,
+ };
+ }
+
+
+
+ // the public API methods can be called from outside a UI event - from a timer or a service callback -
+ // where nothing would repaint the component on their behalf, so they ask for the render themselves.
+ private void RequestRender()
+ {
+ if (IsDisposed) return;
+
+ StateHasChanged();
+ }
+
+ private static bool IsCountedInOverallProgress(BitFileInfo file)
+ {
+ return file.Status is not BitFileUploadStatus.NotAllowed and not BitFileUploadStatus.Removed;
+ }
+
+ private string GetDragClass() => $"bit-upl-drg {Classes?.Dragging}".Trim();
+
+ private async Task UpdateDropZone()
+ {
+ var dragClass = GetDragClass();
+ var dragStyle = Styles?.Dragging;
+
+ if (_allowDrop == AllowDrop && _allowPaste == AllowPaste && _expandDirectories == Directory &&
+ _dragClass == dragClass && _dragStyle == dragStyle) return;
+
+ _allowDrop = AllowDrop;
+ _allowPaste = AllowPaste;
+ _expandDirectories = Directory;
+ _dragClass = dragClass;
+ _dragStyle = dragStyle;
+
+ try
+ {
+ await _dropZoneRef.InvokeVoidAsync("update", _allowDrop, _allowPaste, _expandDirectories, _dragClass, _dragStyle);
+ }
+ catch (JSDisconnectedException) { } // we can ignore this exception here
+ }
+
+ private static bool AllowsAllFileTypes(IReadOnlyCollection? allowedExtensions)
+ {
+ return allowedExtensions is null
+ || allowedExtensions.Count == 0
+ || allowedExtensions.Any(ext => ext?.Trim() is "*" or "*.*" or "*/*");
+ }
+
+ private static IEnumerable GetNormalizedExtensions(IReadOnlyCollection allowedExtensions)
+ {
+ // an entry is either a MIME type (it contains a slash) or a file extension whose leading dot is optional.
+ return allowedExtensions.Select(ext => ext?.Trim())
+ .Where(ext => ext.HasValue())
+ .Select(ext => ext!.Contains('/') || ext.StartsWith('.') ? ext : $".{ext}");
+ }
+
+ private static bool IsFileTypeNotAllowed(BitFileInfo file, string[] allowedTypes)
+ {
+ var extension = Path.GetExtension(file.Name);
+
+ foreach (var entry in allowedTypes)
+ {
+ if (entry.Contains('/'))
+ {
+ if (file.ContentType.HasNoValue()) continue;
+
+ if (entry.EndsWith("/*", StringComparison.Ordinal))
+ {
+ // a wildcard MIME type like "image/*" matches every subtype of that group.
+ if (file.ContentType.StartsWith(entry[..^1], StringComparison.OrdinalIgnoreCase)) return false;
+ }
+ else if (entry.Equals(file.ContentType, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ continue;
+ }
+
+ // files without an extension can never match an extension entry.
+ if (extension.HasNoValue()) continue;
+
+ if (entry.Equals(extension, StringComparison.OrdinalIgnoreCase)) return false;
+ }
+
+ return true;
+ }
+
+ private string? GetAcceptValue()
{
- var uploadUrl = UploadUrl;
- if (UploadUrlProvider is not null)
+ if (Accept.HasValue()) return Accept;
+
+ if (AllowsAllFileTypes(AllowedExtensions)) return null;
+
+ var accept = string.Join(",", GetNormalizedExtensions(AllowedExtensions));
+
+ return accept.HasValue() ? accept : null;
+ }
+
+ private bool ValidateFile(BitFileInfo file)
+ {
+ if (MaxSize > 0 && file.Size > MaxSize)
+ {
+ file.Status = BitFileUploadStatus.NotAllowed;
+ file.Message = MaxSizeErrorMessage;
+ return false;
+ }
+
+ if (MinSize > 0 && file.Size < MinSize)
{
- uploadUrl = await UploadUrlProvider.Invoke();
+ file.Status = BitFileUploadStatus.NotAllowed;
+ file.Message = MinSizeErrorMessage;
+ return false;
}
- var qs = UploadRequestQueryStrings;
- if (UploadRequestQueryStringsProvider is not null)
+ if (IsFileTypeNotAllowed(file))
{
- qs = await UploadRequestQueryStringsProvider.Invoke();
+ file.Status = BitFileUploadStatus.NotAllowed;
+ file.Message = NotAllowedExtensionErrorMessage;
+ return false;
}
- var url = qs is null ? uploadUrl : AddQueryString(uploadUrl, qs);
+ if (FileValidator is not null)
+ {
+ string? message;
+
+ try
+ {
+ message = FileValidator(file);
+ }
+ catch (Exception ex)
+ {
+ // a throwing custom validator invalidates its own file instead of aborting the whole selection.
+ message = ex.Message;
+ }
+
+ if (message.HasValue())
+ {
+ file.Status = BitFileUploadStatus.NotAllowed;
+ file.Message = message;
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private async Task HandleOnChange()
+ {
+ // only the static configuration is baked into the requests at selection time. the providers are
+ // the answer to a value that does not survive a batch - a token about to expire, a presigned URL -
+ // so they are invoked right before each request instead, and are left out here on purpose.
+ var url = UploadRequestQueryStrings is null ? UploadUrl : AddQueryString(UploadUrl, UploadRequestQueryStrings);
if (Append is false)
{
_files.Clear();
+ _uploadQueue.Clear();
+ UploadStatus = BitFileUploadStatus.Pending;
}
if (IsDisposed) return;
- var httpHeaders = UploadRequestHttpHeadersProvider is null ? UploadRequestHttpHeaders : (await UploadRequestHttpHeadersProvider.Invoke());
+ var newFiles = await _js.BitFileUploadSetup(UniqueId, _dotnetObj, _inputRef, Append, url, UploadRequestHttpHeaders,
+ UploadRequestHttpMethod, WithCredentials,
+ (long)(UploadTimeout?.TotalMilliseconds ?? 0), UploadFormFieldName,
+ ShowPreview, ReadImageDimensions);
+
+ if (IsDisposed) return;
- _files.AddRange(await _js.BitFileUploadSetup(UniqueId, _dotnetObj, _inputRef, Append, url, httpHeaders));
+ _files.AddRange(newFiles);
+
+ for (var i = 0; i < _files.Count; i++)
+ {
+ _files[i].Index = i;
+ }
if (_files.Any() is false) return;
+ UploadStatus = BitFileUploadStatus.Pending;
+
+ // the built-in validations run right at selection time, so the user learns about a rejected file
+ // immediately instead of at the moment the upload gets attempted.
+ foreach (var file in newFiles)
+ {
+ ValidateFile(file);
+ }
+
+ ApplyListValidations();
+
+ Announce();
+
await OnChange.InvokeAsync([.. _files]);
+ if (OnInvalid.HasDelegate)
+ {
+ var invalidFiles = newFiles.Where(f => f.Status == BitFileUploadStatus.NotAllowed).ToArray();
+
+ if (invalidFiles.Length > 0)
+ {
+ await OnInvalid.InvokeAsync(invalidFiles);
+ }
+ }
+
if (AutoUpload)
{
await Upload();
}
}
- private async Task UploadOneFile(BitFileInfo fileInfo, string? uploadUrl = null)
+ // two selections of the same file are indistinguishable by their name, size and last modified time,
+ // which is as close to an identity as the browser exposes for a picked file.
+ private static string GetFileIdentity(BitFileInfo file)
{
- if (_files.Any() is false || fileInfo.Status == BitFileUploadStatus.NotAllowed) return;
+ return $"{file.Name}|{file.Size}|{file.LastModified}";
+ }
- var uploadedSize = fileInfo.TotalUploadedSize;
- if (fileInfo.Size != 0 && uploadedSize >= fileInfo.Size) return;
+ // the rules that judge a file against the rest of the list - being a duplicate, the maximum count and the
+ // maximum total size - are re-evaluated from scratch every time the list changes, so that a file rejected
+ // by one of them can be taken back as soon as a removal frees up room or drops the original it duplicated.
+ private void ApplyListValidations()
+ {
+ foreach (var file in _files)
+ {
+ if (file.ListValidationFailed is false) continue;
+
+ file.ListValidationFailed = false;
+ file.Status = BitFileUploadStatus.Pending;
+ file.Message = null;
+ }
- if (MaxSize > 0 && fileInfo.Size > MaxSize)
+ if (AllowDuplicates is false)
{
- await UpdateStatus(BitFileUploadStatus.NotAllowed, fileInfo);
- return;
+ var knownFiles = new HashSet(StringComparer.Ordinal);
+
+ foreach (var file in _files)
+ {
+ if (file.Status is BitFileUploadStatus.Removed) continue;
+
+ // every file registers its identity, even a rejected one, so that a re-selection of a file
+ // already in the list is caught no matter why that file was rejected.
+ if (knownFiles.Add(GetFileIdentity(file))) continue;
+
+ // a file that already failed a validation of its own keeps that message, which would
+ // otherwise be lost as soon as the duplication is resolved, and one that already started
+ // uploading is committed and is not taken back by a later selection of the same file.
+ if (file.Status is not BitFileUploadStatus.Pending) continue;
+
+ Reject(file, DuplicateErrorMessage);
+ }
}
- if (IsFileTypeNotAllowed(fileInfo))
+ if (MaxCount <= 0 && MaxTotalSize <= 0) return;
+
+ var count = 0;
+ var totalSize = 0L;
+
+ foreach (var file in _files)
{
- await UpdateStatus(BitFileUploadStatus.NotAllowed, fileInfo);
- return;
+ // only the files that made it through the other validations and are still around consume the
+ // budget, so a file rejected for its size or type never pushes a good file over a limit.
+ if (file.Status is BitFileUploadStatus.NotAllowed or BitFileUploadStatus.Removed) continue;
+
+ // a file that already started or finished uploading is committed: it takes its share of the
+ // budget, but a limit that a later selection pushed over never takes it back.
+ if (file.Status is not BitFileUploadStatus.Pending)
+ {
+ count++;
+ totalSize += file.Size;
+ continue;
+ }
+
+ if (MaxCount > 0 && count >= MaxCount)
+ {
+ Reject(file, MaxCountErrorMessage);
+ }
+ else if (MaxTotalSize > 0 && totalSize + file.Size > MaxTotalSize)
+ {
+ Reject(file, MaxTotalSizeErrorMessage);
+ }
+ else
+ {
+ count++;
+ totalSize += file.Size;
+ }
}
- if (fileInfo.PauseUploadRequested)
+ static void Reject(BitFileInfo file, string message)
{
- await PauseUploadOneFile(fileInfo.Index);
- return;
+ file.ListValidationFailed = true;
+ file.Status = BitFileUploadStatus.NotAllowed;
+ file.Message = message;
}
+ }
- if (fileInfo.CancelUploadRequested)
+ // spending the retry budget on a failure that is going to come back identical helps nobody, so a
+ // failure is only retried when a second attempt could plausibly go differently.
+ private bool IsWorthRetrying(BitFileInfo file, int responseStatus)
+ {
+ if (ShouldAutoRetry is not null)
{
- await CancelUploadOneFile(fileInfo.Index);
+ try
+ {
+ return ShouldAutoRetry(file, responseStatus);
+ }
+ catch
+ {
+ // a throwing predicate settles the file rather than taking the whole upload down with it.
+ return false;
+ }
+ }
+
+ // a status of 0 stands for a network error, a timeout or an aborted request, none of which say
+ // anything about the request being wrong; 408 and 429 explicitly ask for another attempt later,
+ // and a 5xx is the server having a bad moment rather than a verdict on what was sent.
+ return responseStatus is 0 or 408 or 429 or (>= 500 and <= 599);
+ }
+
+ // whether the file still has bytes to send, which is what tells an upload call that is worth
+ // starting apart from one landing on an already settled batch.
+ private static bool HasPendingWork(BitFileInfo file)
+ {
+ return file.Status is not BitFileUploadStatus.Completed
+ and not BitFileUploadStatus.Removed
+ and not BitFileUploadStatus.NotAllowed
+ && (file.Size == 0 || file.TotalUploadedSize < file.Size);
+ }
+
+ // starts as many queued files as the concurrency limit still has room for. it is called both when an
+ // upload is requested and whenever a file settles, which is what keeps the queue draining on its own.
+ private async Task PumpUploadQueue()
+ {
+ if (ConcurrentUploads <= 0) return;
+
+ while (_uploadQueue.Count > 0)
+ {
+ if (_files.Count(f => f.Status is BitFileUploadStatus.InProgress) >= ConcurrentUploads) return;
+
+ var next = _uploadQueue[0];
+ _uploadQueue.RemoveAt(0);
+ next.IsQueued = false;
+
+ // a file paused, canceled, removed or reset while it was waiting has nothing left to start.
+ if (HasPendingWork(next) is false) continue;
+
+ await UploadOneFile(next, next.QueuedUploadUrl);
+ }
+ }
+
+ private async Task UploadOneFile(BitFileInfo fileInfo, string? uploadUrl = null)
+ {
+ if (_files.Any() is false) return;
+ if (fileInfo.Status is BitFileUploadStatus.NotAllowed or BitFileUploadStatus.Removed) return;
+
+ // a file whose request is already on the wire is busy: sending a second one would take the
+ // connection away from the first and start the file over from its very first byte, which is
+ // what a second press of an upload-everything button would otherwise do to every running file.
+ if (fileInfo.IsRequestInFlight) return;
+
+ var uploadedSize = fileInfo.TotalUploadedSize;
+ if (fileInfo.Size != 0 && uploadedSize >= fileInfo.Size) return;
+
+ if (ValidateFile(fileInfo) is false)
+ {
+ await UpdateStatus(BitFileUploadStatus.NotAllowed, fileInfo);
return;
}
+ if (fileInfo.Status is BitFileUploadStatus.Failed)
+ {
+ // a manual retry of a failed file starts with a fresh automatic retry budget.
+ fileInfo.AutoRetryAttempts = 0;
+ }
+
+ await UpdateStatus(BitFileUploadStatus.InProgress, fileInfo);
+
long to;
long from = 0;
+
+ // a request that never finished left its partial byte count behind, which would otherwise be
+ // added on top of the bytes of the new request and show a progress running ahead of the truth.
+ fileInfo.LastChunkUploadedSize = 0;
+
if (ChunkedUpload)
{
from = fileInfo.TotalUploadedSize;
- if (fileInfo.Size > _internalChunkSize)
- {
- to = from + _internalChunkSize;
- }
- else
- {
- to = fileInfo.Size;
- }
+ to = Math.Min(fileInfo.Size, from + _internalChunkSize);
fileInfo.StartTimeUpload = DateTime.UtcNow;
- fileInfo.LastChunkUploadedSize = 0;
}
else
{
to = fileInfo.Size;
}
+ // the span of the in-flight request is remembered as sent, since the chunk size can get
+ // adjusted dynamically before the response of this request arrives.
+ fileInfo.PendingChunkSize = to - from;
+
if (from == 0)
{
await OnUploading.InvokeAsync(fileInfo);
}
- await _js.BitFileUploadUpload(UniqueId, from, to, fileInfo.Index, uploadUrl, fileInfo.HttpHeaders);
+ // the providers get their say right before the request goes out, so a token they mint is as
+ // fresh as it can be. they are awaited, and the file may well have been paused, canceled,
+ // removed or reset in the meantime, in which case there is nothing left to send.
+ var requestUrl = await GetRequestUploadUrl(uploadUrl);
+ var requestHeaders = await GetUploadHeaders(fileInfo, from, to);
+
+ if (IsDisposed) return;
+ if (fileInfo.Status is not BitFileUploadStatus.InProgress || _files.Contains(fileInfo) is false) return;
+
+ // the speed of the request is measured from the moment it is handed over to the browser.
+ fileInfo.TransferStartTime = DateTime.UtcNow;
+ fileInfo.TransferStartOffset = fileInfo.TotalUploadedSize;
+ fileInfo.IsRequestInFlight = true;
+
+ await _js.BitFileUploadUpload(UniqueId, from, to, fileInfo.Index, requestUrl, requestHeaders,
+ GetUploadFormFields(fileInfo));
+ }
+
+ // the URL of a single request: an explicit one passed to Upload wins, then the providers get to mint
+ // one for this very request, and otherwise the endpoint baked in at selection time keeps applying.
+ private async Task GetRequestUploadUrl(string? uploadUrl)
+ {
+ if (uploadUrl.HasValue()) return uploadUrl;
+
+ if (UploadUrlProvider is null && UploadRequestQueryStringsProvider is null) return null;
+
+ var url = UploadUrlProvider is null ? UploadUrl : await UploadUrlProvider.Invoke();
+
+ if (UploadRequestQueryStringsProvider is null)
+ {
+ // the static query strings are already part of the URL baked in at selection time, so they
+ // only have to be put back when a provider replaced that URL with one of its own.
+ return UploadRequestQueryStrings is null ? url : AddQueryString(url, UploadRequestQueryStrings);
+ }
+
+ var merged = MergeEntries(UploadRequestQueryStrings, await UploadRequestQueryStringsProvider.Invoke());
+
+ return merged is null ? url : AddQueryString(url, merged);
+ }
+
+ // the extra multipart fields of a request: the ones of the whole component, with the ones of this
+ // specific file - the OnUploading callback is where they are usually filled in - laid over them.
+ private Dictionary? GetUploadFormFields(BitFileInfo fileInfo)
+ {
+ return MergeEntries(UploadRequestFormFields, fileInfo.FormFields);
+ }
+
+ // combines two optional sets of entries into a new one, the second one winning over the first,
+ // without ever handing back - let alone mutating - either of the dictionaries it was given.
+ private static Dictionary? MergeEntries(Dictionary? first, Dictionary? second)
+ {
+ if (first is null || first.Count == 0) return second;
+ if (second is null || second.Count == 0) return first;
+
+ var merged = new Dictionary(first);
+
+ foreach (var entry in second)
+ {
+ merged[entry.Key] = entry.Value;
+ }
+
+ return merged;
+ }
+
+ // the chunked mode leaves the server with the job of putting the pieces of a file back together, which
+ // it can only do reliably when it is told where each piece belongs. a blind append breaks as soon as two
+ // chunks overtake each other or a retry sends one of them twice, so every chunk carries its byte range -
+ // as the standard Content-Range header and as plain numbers for the servers that would rather not parse it.
+ private async Task?> GetUploadHeaders(BitFileInfo fileInfo, long from, long to)
+ {
+ // the headers of the provider are per request, so they are never handed to the setup call and
+ // are instead merged in here, under the ones of this specific file, right before each request.
+ // an entry sent twice would be concatenated into one comma separated value by the browser,
+ // which is exactly why the provider does not also go through the static setup headers.
+ var providedHeaders = UploadRequestHttpHeadersProvider is null
+ ? null
+ : await UploadRequestHttpHeadersProvider.Invoke();
+
+ var fileHeaders = MergeEntries(providedHeaders, fileInfo.HttpHeaders);
+
+ if (ChunkedUpload is false) return fileHeaders;
+
+ var headers = fileHeaders is null
+ ? []
+ : new Dictionary(fileHeaders);
+
+ // an empty file has no byte to describe a range over, so it only carries the size headers.
+ if (fileInfo.Size > 0)
+ {
+ headers["Content-Range"] = $"bytes {from}-{to - 1}/{fileInfo.Size}";
+ }
+
+ headers["BIT_CHUNK_FROM"] = from.ToString(CultureInfo.InvariantCulture);
+ headers["BIT_CHUNK_TO"] = to.ToString(CultureInfo.InvariantCulture);
+ headers["BIT_FILE_SIZE"] = fileInfo.Size.ToString(CultureInfo.InvariantCulture);
+
+ return headers;
+ }
+
+ private async Task PauseOneFile(BitFileInfo file)
+ {
+ // a file waiting in the queue is already on its way, and taking it out of the queue is what
+ // pausing means for it - there is no request of its own to abort yet.
+ if (file.IsQueued)
+ {
+ _uploadQueue.Remove(file);
+ file.IsQueued = false;
+
+ await PauseUploadOneFile(file.Index);
+ return;
+ }
+
+ // there is nothing to pause about a file that was never asked to upload, or one that has already
+ // settled: pausing them would only put them in a state their own buttons cannot get them out of.
+ if (file.Status is not BitFileUploadStatus.InProgress) return;
+
+ // aborting right away instead of waiting for the next chunk boundary, so that pausing
+ // also works for non-chunked uploads whose only request is already in flight.
+ await PauseUploadOneFile(file.Index);
+ }
+
+ private async Task CancelOneFile(BitFileInfo file)
+ {
+ // a file that has already settled has nothing left to cancel: taking a completed upload back would
+ // claim something about the server that canceling on this side cannot deliver, and calling off a
+ // file that already failed would only replace the reason it failed with a less useful one.
+ if (file.Status is BitFileUploadStatus.Completed
+ or BitFileUploadStatus.Failed
+ or BitFileUploadStatus.Canceled
+ or BitFileUploadStatus.Removed
+ or BitFileUploadStatus.NotAllowed) return;
+
+ // an in-progress file aborts its in-flight request; a pending, queued, paused or failed file has
+ // no request to abort, but its cancellation must still land right away rather than sit as an
+ // intention nobody can see, waiting for an upload that may never be asked for.
+ _uploadQueue.Remove(file);
+ file.IsQueued = false;
+
+ await CancelUploadOneFile(file.Index);
}
private async Task PauseUploadOneFile(int index)
{
- if (_files.Any() is false) return;
+ if (index < 0 || index >= _files.Count) return;
- await _js.BitFileUploadPause(UniqueId, index);
var file = _files[index];
+
+ // the status changes before the abort, so that the abort callback coming back from JavaScript
+ // finds the file already paused instead of mistaking the aborted request for a failed upload.
await UpdateStatus(BitFileUploadStatus.Paused, file);
- file.PauseUploadRequested = false;
+ file.IsRequestInFlight = false;
+
+ await _js.BitFileUploadPause(UniqueId, index);
+ }
+
+ private async Task CancelUploadOneFile(int index)
+ {
+ if (index < 0 || index >= _files.Count) return;
+
+ var file = _files[index];
+
+ // the status changes before the abort, so that the abort callback coming back from JavaScript
+ // finds the file already canceled instead of mistaking the aborted request for a failed upload.
+ await UpdateStatus(BitFileUploadStatus.Canceled, file);
+ file.IsRequestInFlight = false;
+
+ await _js.BitFileUploadPause(UniqueId, index);
+ }
+
+ // the speed of an upload is what turns a progress bar into an answer to "how long is this going to
+ // take", so it is measured over the request currently in flight - the only window whose start is
+ // known exactly - and the bytes still to send are divided by it to get the time left.
+ private static void UpdateTransferRate(BitFileInfo file)
+ {
+ if (file.TransferStartTime is not { } startTime) return;
+
+ var elapsed = (DateTime.UtcNow - startTime).TotalSeconds;
+
+ // the very first progress reports arrive too close to the start for their ratio to mean
+ // anything, and a reading taken over no time at all would be an infinity.
+ if (elapsed < 0.2) return;
+
+ var uploaded = file.TotalUploadedSize + file.LastChunkUploadedSize - file.TransferStartOffset;
+
+ if (uploaded <= 0) return;
+
+ var speed = uploaded / elapsed;
+
+ file.UploadSpeed = speed;
+
+ // the progress events count the multipart overhead too, so the bytes reported as sent can run
+ // slightly past the file itself, which must not turn into a negative time left.
+ var remaining = Math.Max(0, file.Size - (file.TotalUploadedSize + file.LastChunkUploadedSize));
+
+ file.RemainingTime = TimeSpan.FromSeconds(remaining / speed);
}
private void UpdateChunkSize(int fileIndex)
{
- if (_files.Any() is false || AutoChunkSize is false) return;
+ if (_files.Any() is false || AutoChunkSize is false || ChunkedUpload is false) return;
+
+ var file = _files[fileIndex];
var dtNow = DateTime.UtcNow;
- var duration = (dtNow - _files[fileIndex].StartTimeUpload.GetValueOrDefault(dtNow)).TotalMilliseconds;
+ var duration = (dtNow - file.StartTimeUpload.GetValueOrDefault(dtNow)).TotalMilliseconds;
+ if (duration <= 0) return;
if (duration is >= 1000 and <= 1500) return;
- _internalChunkSize = Convert.ToInt64(_internalChunkSize / (duration / 1000));
+ // the new size is derived from the chunk that was actually measured rather than from the current
+ // setting, which another file uploading in parallel may well have moved since this one was sent.
+ if (file.PendingChunkSize <= 0) return;
+
+ _internalChunkSize = Convert.ToInt64(file.PendingChunkSize / (duration / 1000));
if (_internalChunkSize > MAX_CHUNK_SIZE)
{
@@ -681,83 +1805,157 @@ private void UpdateChunkSize(int fileIndex)
}
}
- private async Task UpdateStatus(BitFileUploadStatus uploadStatus, BitFileInfo? fileInfo = null)
+ private bool _HasDescription => DescriptionTemplate is not null || Description.HasValue();
+
+ private void Announce()
{
- if (_files.Any() is false) return;
+ var text = GetAnnouncementText();
- if (fileInfo is null)
- {
- UploadStatus = uploadStatus;
+ // screen readers skip a live region update that repeats the previous text verbatim,
+ // so an invisible zero width space is alternated to make every update unique.
+ _announcementMarker = !_announcementMarker;
- var files = _files.Where(c => c.Status != BitFileUploadStatus.NotAllowed).ToArray();
- foreach (var file in files)
- {
- file.Status = uploadStatus;
- }
+ _announcement = text.HasValue() && _announcementMarker ? text + '\u200B' : text;
+ }
+
+ private string? GetAnnouncementText()
+ {
+ if (AnnouncementProvider is not null) return AnnouncementProvider(_files);
- await OnChange.InvokeAsync(files);
+ var files = _files.Where(f => f.Status != BitFileUploadStatus.Removed).ToArray();
+
+ if (files.Length == 0) return "No file selected.";
+
+ var completed = files.Count(f => f.Status is BitFileUploadStatus.Completed);
+ var failed = files.Count(f => f.Status is BitFileUploadStatus.Failed);
+ var notAllowed = files.Count(f => f.Status is BitFileUploadStatus.NotAllowed);
+
+ return $"{files.Length} file{(files.Length == 1 ? string.Empty : "s")} selected." +
+ (completed > 0 ? $" {completed} uploaded." : string.Empty) +
+ (failed > 0 ? $" {failed} failed." : string.Empty) +
+ (notAllowed > 0 ? $" {notAllowed} not allowed." : string.Empty);
+ }
+
+ private async Task UpdateStatus(BitFileUploadStatus uploadStatus, BitFileInfo fileInfo)
+ {
+ if (uploadStatus is not BitFileUploadStatus.InProgress)
+ {
+ // a file that is not on the wire has no speed, and a time left measured over a transfer
+ // that is over would keep counting down towards something that is never going to happen.
+ fileInfo.UploadSpeed = null;
+ fileInfo.RemainingTime = null;
+ fileInfo.TransferStartTime = null;
}
- else
+
+ if (fileInfo.Status != uploadStatus)
{
- if (fileInfo.Status != uploadStatus)
+ fileInfo.Status = uploadStatus;
+
+ // upload outcomes get announced to screen readers; the ever-changing progress does not,
+ // since announcing every tick would drown out everything else.
+ if (uploadStatus is not BitFileUploadStatus.InProgress and not BitFileUploadStatus.Pending)
{
- fileInfo.Status = uploadStatus;
- await OnChange.InvokeAsync([fileInfo]);
+ Announce();
}
- switch (uploadStatus)
- {
- case BitFileUploadStatus.InProgress:
- await OnProgress.InvokeAsync(fileInfo);
- break;
+ await OnChange.InvokeAsync([fileInfo]);
+ }
- case BitFileUploadStatus.Completed:
- await OnUploadComplete.InvokeAsync(fileInfo);
- break;
+ switch (uploadStatus)
+ {
+ case BitFileUploadStatus.InProgress:
+ await OnProgress.InvokeAsync(fileInfo);
+ break;
- case BitFileUploadStatus.Failed:
- await OnUploadFailed.InvokeAsync(fileInfo);
- break;
+ case BitFileUploadStatus.Completed:
+ await OnUploadComplete.InvokeAsync(fileInfo);
+ break;
- case BitFileUploadStatus.Removed:
- await OnRemoveComplete.InvokeAsync(fileInfo);
- break;
+ case BitFileUploadStatus.Failed:
+ await OnUploadFailed.InvokeAsync(fileInfo);
+ break;
- case BitFileUploadStatus.RemoveFailed:
- await OnRemoveFailed.InvokeAsync(fileInfo);
- break;
- }
+ case BitFileUploadStatus.Removed:
+ await OnRemoveComplete.InvokeAsync(fileInfo);
+ break;
+
+ case BitFileUploadStatus.RemoveFailed:
+ await OnRemoveFailed.InvokeAsync(fileInfo);
+ break;
}
}
- private async Task CancelUploadOneFile(int index)
+ // a file reaching a terminal state hands its slot over to the next file waiting in the queue,
+ // and only once nothing is left running does the batch get reported as complete.
+ private async Task SettleFile()
{
- if (_files.Any() is false) return;
+ await PumpUploadQueue();
- await _js.BitFileUploadPause(UniqueId, index);
- var file = _files[index];
- await UpdateStatus(BitFileUploadStatus.Canceled, file);
- file.CancelUploadRequested = false;
+ await CheckAllUploadsComplete();
+ }
+
+ private async Task CheckAllUploadsComplete()
+ {
+ // a batch nobody ever asked to upload has not completed anything, however settled its files look:
+ // a selection of files rejected by the validations is not an upload that finished.
+ if (UploadStatus is not BitFileUploadStatus.InProgress) return;
+
+ // paused files still count as in flight - the batch is only complete once every file has
+ // reached a terminal state (completed, failed, canceled, removed or rejected by validation).
+ if (_files.Any(f => f.Status is BitFileUploadStatus.Pending or BitFileUploadStatus.InProgress or BitFileUploadStatus.Paused)) return;
+
+ UploadStatus = BitFileUploadStatus.Completed;
+ await OnAllUploadsComplete.InvokeAsync([.. _files]);
}
private async Task RemoveOneFile(BitFileInfo fileInfo)
{
if (fileInfo.Status is BitFileUploadStatus.Removed) return;
- if (fileInfo.TotalUploadedSize > 0)
+ _uploadQueue.Remove(fileInfo);
+
+ // a file on its way out has no reason to keep sending itself, so its in-flight request is dropped
+ // before anything else - otherwise the bytes would keep flowing to an endpoint that is about to be
+ // told to delete them.
+ if (fileInfo.Status is BitFileUploadStatus.InProgress or BitFileUploadStatus.Paused)
{
+ await _js.BitFileUploadPause(UniqueId, fileInfo.Index);
+ }
+
+ // a completed file counts as being on the server even when it carried no byte at all,
+ // which is exactly the case of an empty file that uploaded successfully.
+ var isOnServer = fileInfo.TotalUploadedSize > 0 || fileInfo.Status is BitFileUploadStatus.Completed;
+
+ if (isOnServer && RemoveUrl.HasValue())
+ {
+ fileInfo.IsRemoving = true;
+ StateHasChanged();
+
await RemoveOneFileFromServer(fileInfo);
+
+ fileInfo.IsRemoving = false;
}
else
{
await UpdateStatus(BitFileUploadStatus.Removed, fileInfo);
}
+
+ if (fileInfo.Status is not BitFileUploadStatus.Removed) return;
+
+ // a removed file is never going to be sent again, so everything the browser was holding on to for
+ // it is handed back: the picked file itself, which would otherwise stay in memory for the whole
+ // life of the page, and the object URL of the thumbnail that is not rendered anymore.
+ fileInfo.PreviewUrl = null;
+
+ try
+ {
+ await _js.BitFileUploadRelease(UniqueId, fileInfo.Index);
+ }
+ catch (JSDisconnectedException) { } // we can ignore this exception here
}
private async Task RemoveOneFileFromServer(BitFileInfo fileInfo)
{
- if (RemoveUrl.HasNoValue()) return;
-
try
{
var url = AddQueryString(RemoveUrl!, "fileName", fileInfo.Name);
@@ -771,7 +1969,11 @@ private async Task RemoveOneFileFromServer(BitFileInfo fileInfo)
url = AddQueryString(url, qs);
}
- using var request = new HttpRequestMessage(HttpMethod.Delete, url);
+ var method = RemoveRequestHttpMethod.HasValue()
+ ? new HttpMethod(RemoveRequestHttpMethod!)
+ : HttpMethod.Delete;
+
+ using var request = new HttpRequestMessage(method, url);
request.Headers.Add("BIT_FILE_ID", fileInfo.FileId);
@@ -784,13 +1986,23 @@ private async Task RemoveOneFileFromServer(BitFileInfo fileInfo)
request.Headers.Add(header.Key, header.Value);
}
- await _httpClient.SendAsync(request);
+ var response = await _httpClient.SendAsync(request);
- await UpdateStatus(BitFileUploadStatus.Removed, fileInfo);
+ if (response.IsSuccessStatusCode)
+ {
+ await UpdateStatus(BitFileUploadStatus.Removed, fileInfo);
+ }
+ else
+ {
+ fileInfo.Message = $"{(int)response.StatusCode} {response.ReasonPhrase}";
+ await UpdateStatus(BitFileUploadStatus.RemoveFailed, fileInfo);
+ }
}
catch (Exception ex)
{
- fileInfo.Message = ex.ToString();
+ // only the message of the exception, since this text is rendered right in the file item and
+ // a full stack trace there says nothing to the user while telling a stranger far too much.
+ fileInfo.Message = ex.Message;
await UpdateStatus(BitFileUploadStatus.RemoveFailed, fileInfo);
}
}
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss
index d8f2f12f97..369f38aa4f 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss
@@ -1,4 +1,4 @@
-@import "../../../Styles/functions.scss";
+@import "../../../Styles/functions.scss";
.bit-upl {
display: flex;
@@ -11,14 +11,15 @@
border-color: $clr-brd-dis;
background-color: $clr-bg-dis;
- @media (hover: hover) {
- &:hover {
- border-color: $clr-brd-dis;
- }
- }
-
- &:active {
- background-color: $clr-bg-dis;
+ //the browse button carries the role color while enabled, but a disabled file upload reads as a
+ //neutral form field whatever its role and variant (see the disabled conventions in
+ //theme-variables.scss), so it drops the role colors for the generic disabled trio and stops
+ //answering the pointer. the border and the background are left to the variant blocks below,
+ //since which of the two is painted at all is exactly what the variants disagree on.
+ .bit-upl-lbl {
+ cursor: default;
+ color: $clr-fg-dis;
+ pointer-events: none;
}
}
@@ -37,6 +38,8 @@
display: none;
}
+//everything the three variants share; the colors themselves live in the variant blocks right below,
+//which is also why the border is declared without its color here.
.bit-upl-lbl {
outline: 0;
display: flex;
@@ -44,28 +47,115 @@
font-weight: 300;
user-select: none;
align-items: center;
- font-size: spacing(2);
justify-content: center;
- min-height: spacing(4.5);
border-radius: spacing(0.5);
- padding: spacing(0.5) spacing(2);
- color: $clr-fg-pri;
- background-color: $clr-bg-pri;
- border: $shp-border-width $shp-border-style $clr-brd-pri;
+ border-width: $shp-border-width;
+ border-style: $shp-border-style;
+ min-height: var(--bit-upl-lbl-height);
+ font-size: var(--bit-upl-lbl-fontsize);
+ padding: var(--bit-upl-lbl-padding);
- @media (hover: hover) {
- &:hover {
- border-color: $clr-brd-pri-hover;
+ i {
+ margin-inline-end: spacing(0.625);
+ }
+}
+
+//Fill - the default: a solid block of the role color labelled in the role's on-color.
+.bit-upl-fil {
+ .bit-upl-lbl {
+ color: var(--bit-upl-clr-txt);
+ border-color: var(--bit-upl-clr);
+ background-color: var(--bit-upl-clr);
+
+ @media (hover: hover) {
+ &:hover {
+ border-color: var(--bit-upl-clr-hover);
+ background-color: var(--bit-upl-clr-hover);
+ }
+ }
+
+ &:active {
+ border-color: var(--bit-upl-clr-active);
+ background-color: var(--bit-upl-clr-active);
}
}
- &:active {
- background-color: $clr-bg-pri-active;
+ &.bit-dis .bit-upl-lbl {
+ border-color: $clr-brd-dis;
+ background-color: $clr-bg-dis;
}
+}
- i {
- padding-right: spacing(0.625);
+//Outline: only the rule around the button carries the role color at rest, and it fills in like the Fill
+//variant once a pointer lands on it.
+.bit-upl-otl {
+ .bit-upl-lbl {
+ color: var(--bit-upl-clr);
+ border-color: var(--bit-upl-clr);
+ background-color: transparent;
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--bit-upl-clr-txt);
+ background-color: var(--bit-upl-clr-hover);
+ }
+ }
+
+ &:active {
+ color: var(--bit-upl-clr-txt);
+ background-color: var(--bit-upl-clr-active);
+ }
+ }
+
+ &.bit-dis .bit-upl-lbl {
+ border-color: $clr-brd-dis;
+ background-color: transparent;
+ }
+}
+
+//Text: neither rule nor fill at rest, for an uploader that should not outweigh the fields around it.
+.bit-upl-txt {
+ .bit-upl-lbl {
+ color: var(--bit-upl-clr);
+ border-color: transparent;
+ background-color: transparent;
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--bit-upl-clr-txt);
+ background-color: var(--bit-upl-clr-hover);
+ }
+ }
+
+ &:active {
+ color: var(--bit-upl-clr-txt);
+ background-color: var(--bit-upl-clr-active);
+ }
}
+
+ &.bit-dis .bit-upl-lbl {
+ border-color: transparent;
+ background-color: transparent;
+ }
+}
+
+//while a file is dragged over the component the browse button becomes the drop zone, and it does so the
+//same way in every variant: the hover shade of the role fills it, and the rule turns into a dashed one
+//in the on-color - the role color itself would vanish against that fill. the root class is repeated so
+//that the drop state outranks the per-variant :hover rules above, which are otherwise as specific.
+.bit-upl.bit-upl-drg {
+ .bit-upl-lbl {
+ border-style: dashed;
+ color: var(--bit-upl-clr-txt);
+ border-color: var(--bit-upl-clr-txt);
+ background-color: var(--bit-upl-clr-hover);
+ }
+}
+
+.bit-upl-dsc {
+ color: $clr-fg-sec;
+ margin-top: spacing(0.5);
+ font-size: var(--bit-upl-fs-fontsize);
}
.bit-upl-fl {
@@ -79,11 +169,11 @@
font-weight: 100;
align-items: center;
flex-flow: row nowrap;
- font-size: spacing(1.75);
margin-top: spacing(0.375);
border-radius: spacing(0.625);
justify-content: space-between;
color: $clr-fg-pri;
+ font-size: var(--bit-upl-itm-fontsize);
border: $shp-border-width $shp-border-style $clr-brd-pri;
@media (hover: hover) {
@@ -114,93 +204,134 @@
.bit-upl-psd {
.bit-upl-us {
- color: $clr-pri;
+ color: var(--bit-upl-clr);
}
}
+.bit-upl-prv {
+ flex-shrink: 0;
+ object-fit: cover;
+ border-radius: spacing(0.5);
+ width: var(--bit-upl-prv-size);
+ height: var(--bit-upl-prv-size);
+ margin-inline-start: spacing(0.5);
+}
+
.bit-upl-fic {
+ flex-grow: 1;
height: 100%;
display: flex;
+ overflow: hidden;
cursor: default;
box-sizing: border-box;
align-items: flex-start;
justify-content: center;
flex-flow: column nowrap;
- width: calc(100% - #{40px});
padding: spacing(1) spacing(2);
}
.bit-upl-fnc {
width: 100%;
display: flex;
-
- i {
- padding-top: spacing(1);
- }
+ overflow: hidden;
+ align-items: center;
+ flex-flow: row nowrap;
}
.bit-upl-fn {
- height: 100%;
+ width: 100%;
overflow: hidden;
white-space: nowrap;
- padding-top: spacing(1);
text-overflow: ellipsis;
- width: calc(100% - #{32px});
}
.bit-upl-fsc {
width: 100%;
display: flex;
+ overflow: hidden;
+ align-items: center;
+ flex-flow: row nowrap;
+ margin-top: spacing(0.25);
+ font-size: var(--bit-upl-fs-fontsize);
}
.bit-upl-fs {
- padding-top: spacing(1);
- margin-right: spacing(1);
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin-inline-end: spacing(1);
}
.bit-upl-pct {
- padding-top: spacing(1);
+ white-space: nowrap;
}
.bit-upl-pbc {
width: 100%;
overflow: hidden;
height: spacing(0.25);
- padding-top: spacing(1);
- padding-bottom: spacing(1);
- padding-left: spacing(0.25);
+ margin-top: spacing(1);
+ margin-bottom: spacing(0.5);
+ background-color: $clr-bg-sec;
}
-.bit-upl-pbr {
+// named -pgb rather than -pbr so that it cannot collide with the generated PrimaryBorder role class.
+.bit-upl-pgb {
height: spacing(0.25);
transition: width $mot-duration linear 0s;
- background-color: $clr-pri;
+ background-color: var(--bit-upl-clr);
}
.bit-upl-usi {
- height: 100%;
+ border: 0;
+ padding: 0;
display: flex;
+ flex-shrink: 0;
cursor: pointer;
- width: spacing(4);
+ align-self: stretch;
align-items: center;
+ color: $clr-fg-pri;
justify-content: center;
+ background-color: transparent;
+ font-family: $tg-font-family;
+ width: var(--bit-upl-btn-size);
@media (hover: hover) {
&:hover {
i {
- color: $clr-pri;
+ color: var(--bit-upl-clr);
}
}
}
+
+ &:disabled {
+ cursor: default;
+ color: $clr-fg-dis;
+ }
+
+ i {
+ font-size: var(--bit-upl-ico-fontsize);
+ }
+}
+
+.bit-upl-lvr {
+ top: auto;
+ left: -10000px;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ position: absolute;
+ white-space: nowrap;
+ clip-path: inset(50%);
}
.bit-upl-ldg {
height: 100%;
display: flex;
- width: spacing(4);
- margin-right: spacing(0.5);
+ flex-shrink: 0;
align-items: center;
justify-content: center;
+ width: var(--bit-upl-btn-size);
}
.bit-upl-spn {
@@ -210,6 +341,49 @@
border-width: spacing(0.2125);
border-style: $shp-border-style;
border-color: $clr-brd-sec;
- border-top-color: $clr-pri-dark;
+ border-top-color: var(--bit-upl-clr);
animation: bit-upl-spinner-animation $mot-duration-spinner $mot-easing-spinner infinite;
}
+
+// Role classes are generated from the shared $bit-color-roles map (see color-role-maps.scss).
+@each $role, $tokens in $bit-color-roles {
+ .bit-upl-#{$role} {
+ --bit-upl-clr: #{role($tokens, main)};
+ --bit-upl-clr-txt: #{role($tokens, on)};
+ --bit-upl-clr-hover: #{role($tokens, hover)};
+ --bit-upl-clr-active: #{role($tokens, active)};
+ }
+}
+
+.bit-upl-sm {
+ --bit-upl-lbl-height: #{spacing(3.5)};
+ --bit-upl-lbl-fontsize: #{spacing(1.5)};
+ --bit-upl-lbl-padding: #{spacing(0.25)} #{spacing(1.5)};
+ --bit-upl-itm-fontsize: #{spacing(1.5)};
+ --bit-upl-fs-fontsize: #{spacing(1.375)};
+ --bit-upl-prv-size: #{spacing(4)};
+ --bit-upl-btn-size: #{spacing(3.5)};
+ --bit-upl-ico-fontsize: #{spacing(1.5)};
+}
+
+.bit-upl-md {
+ --bit-upl-lbl-height: #{spacing(4.5)};
+ --bit-upl-lbl-fontsize: #{spacing(2)};
+ --bit-upl-lbl-padding: #{spacing(0.5)} #{spacing(2)};
+ --bit-upl-itm-fontsize: #{spacing(1.75)};
+ --bit-upl-fs-fontsize: #{spacing(1.625)};
+ --bit-upl-prv-size: #{spacing(5)};
+ --bit-upl-btn-size: #{spacing(4)};
+ --bit-upl-ico-fontsize: #{spacing(2)};
+}
+
+.bit-upl-lg {
+ --bit-upl-lbl-height: #{spacing(5.5)};
+ --bit-upl-lbl-fontsize: #{spacing(2.25)};
+ --bit-upl-lbl-padding: #{spacing(0.75)} #{spacing(2.5)};
+ --bit-upl-itm-fontsize: #{spacing(2)};
+ --bit-upl-fs-fontsize: #{spacing(1.75)};
+ --bit-upl-prv-size: #{spacing(6.5)};
+ --bit-upl-btn-size: #{spacing(5)};
+ --bit-upl-ico-fontsize: #{spacing(2.5)};
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts
index f22ad996e8..30f0fb28ab 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts
@@ -1,48 +1,79 @@
-namespace BitBlazorUI {
+namespace BitBlazorUI {
export class FileUpload {
+ private static readonly IMAGE_SIZE_CONCURRENCY = 8;
+
private static _fileUploaders: BitFileUploader[] = [];
- public static setup(
+ public static async setup(
id: string,
dotnetReference: DotNetObject,
inputElement: HTMLInputElement,
append: boolean,
uploadEndpointUrl: string | undefined,
- headers: Record | undefined) {
+ headers: Record | undefined,
+ method: string | undefined,
+ withCredentials: boolean,
+ timeout: number,
+ fieldName: string | undefined,
+ showPreview: boolean,
+ readImageDimensions: boolean) {
if (!append) {
FileUpload.clear(id);
}
- const lastIndex = append ? FileUpload._fileUploaders.filter(u => u.id === id).length : 0;
+ const existingUploaders = append ? FileUpload._fileUploaders.filter(u => u.id === id) : [];
+ // a reduce instead of a spread into Math.max, since a selection can carry
+ // more items than the argument limit of a function call.
+ const lastIndex = existingUploaders.reduce((max, u) => u.index + 1 > max ? u.index + 1 : max, 0);
const files = Array.from(inputElement.files!).map((file, index) => ({
name: file.name,
size: file.size,
type: file.type,
+ lastModified: file.lastModified,
+ previewUrl: (showPreview && file.type.startsWith('image/')) ? URL.createObjectURL(file) : null,
fileId: Utils.uuidv4(),
file: file,
- index: (index + lastIndex)
+ index: (index + lastIndex),
+ width: null as number | null,
+ height: null as number | null
}));
files.forEach((f) => {
const h = { ...(headers || {}), ...{ 'BIT_FILE_ID': f.fileId } };
- const uploader = new BitFileUploader(id, dotnetReference, f.file, uploadEndpointUrl, h, f.index);
+ const uploader = new BitFileUploader(id, dotnetReference, f.file, uploadEndpointUrl, h, f.index,
+ method || 'POST', withCredentials, timeout, fieldName || 'file', f.previewUrl);
FileUpload._fileUploaders.push(uploader);
});
+ // the input has to be emptied before awaiting anything, otherwise selecting the same file
+ // right after would not raise a change event.
inputElement.value = '';
- return files;
+ if (readImageDimensions) {
+ await FileUpload.readImageSizes(files.filter(f => f.type.startsWith('image/')));
+ }
+
+ // the File itself is only of use on this side, so it is left out of the interop payload.
+ return files.map(({ file, ...info }) => info);
}
- public static upload(id: string, from: number, to: number, index: number, uploadUrl: string, headers: Record = {}): void {
+ public static upload(
+ id: string,
+ from: number,
+ to: number,
+ index: number,
+ uploadUrl: string,
+ headers: Record = {},
+ formFields: Record = {}): void {
+
const uploaders = FileUpload._fileUploaders.filter(u => u.id === id);
if (index === -1) {
- uploaders.forEach(u => u.upload(from, to, uploadUrl, headers));
+ uploaders.forEach(u => u.upload(from, to, uploadUrl, headers, formFields));
} else {
- const uploader = uploaders.filter(u => u.index === index)[0];
- uploader.upload(from, to, uploadUrl, headers);
+ const uploader = uploaders.find(u => u.index === index);
+ uploader?.upload(from, to, uploadUrl, headers, formFields);
}
}
@@ -52,47 +83,207 @@
if (index === -1) {
uploaders.forEach(u => u.pause());
} else {
- const uploader = uploaders.filter(u => u.index === index)[0];
- uploader.pause();
+ const uploader = uploaders.find(u => u.index === index);
+ uploader?.pause();
}
}
- public static setupDragDrop(dropZoneElement: HTMLElement, inputElement: HTMLInputElement) {
+ public static setupDragDrop(
+ dropZoneElement: HTMLElement,
+ inputElement: HTMLInputElement,
+ dragClass: string,
+ dragStyle: string | null,
+ allowDrop: boolean,
+ allowPaste: boolean,
+ expandDirectories: boolean) {
+
+ let dragCounter = 0;
+ let originalStyle: string | null = null;
+ let dragClasses = dragClass.split(' ').filter(c => c.length > 0);
+
+ function hasFiles(e: DragEvent) {
+ return !!e.dataTransfer && Array.prototype.includes.call(e.dataTransfer.types, 'Files');
+ }
+
+ function canAcceptDrop(e: DragEvent) {
+ return allowDrop && !inputElement.disabled && hasFiles(e);
+ }
+
+ function applyDragStyling() {
+ dropZoneElement.classList.add(...dragClasses);
+
+ if (!dragStyle) return;
+ originalStyle = dropZoneElement.getAttribute('style');
+ dropZoneElement.setAttribute('style', [originalStyle, dragStyle].filter(s => s).join(';'));
+ }
+
+ function clearDragStyling() {
+ dropZoneElement.classList.remove(...dragClasses);
+
+ if (!dragStyle) return;
+ if (originalStyle) {
+ dropZoneElement.setAttribute('style', originalStyle);
+ } else {
+ dropZoneElement.removeAttribute('style');
+ }
+ originalStyle = null;
+ }
+
+ function addDragState() {
+ dragCounter++;
+ if (dragCounter > 1) return;
+
+ applyDragStyling();
+ }
- function onDragHover(e: DragEvent) {
+ function removeDragState(force: boolean) {
+ if (dragCounter === 0) return;
+
+ dragCounter = force ? 0 : dragCounter - 1;
+ if (dragCounter > 0) return;
+
+ clearDragStyling();
+ }
+
+ function onDragEnter(e: DragEvent) {
e.preventDefault();
+ if (!canAcceptDrop(e)) return;
+
+ addDragState();
}
- function onDragLeave(e: DragEvent) {
+ function onDragOver(e: DragEvent) {
+ // the default must always be prevented, otherwise the browser navigates away
+ // to the dropped file and the app state gets lost.
e.preventDefault();
+
+ if (!e.dataTransfer) return;
+
+ // gives the OS the correct drag cursor (a copy badge or a no-drop sign).
+ e.dataTransfer.dropEffect = canAcceptDrop(e) ? 'copy' : 'none';
}
- function onDrop(e: DragEvent) {
+ function onDragLeave(e: DragEvent) {
e.preventDefault();
- inputElement.files = e.dataTransfer!.files;
+ if (!hasFiles(e)) return;
+
+ removeDragState(false);
+ }
+
+ // a drag that ends without a matching dragleave (cancelled with Escape, released outside the
+ // window, or dropped on something else) would otherwise leave the counter above zero and
+ // the drag classes and inline style stuck on the drop zone.
+ function onDragCancel() {
+ removeDragState(true);
+ }
+
+ function setFiles(files: File[] | FileList) {
+ const list = Array.from(files as ArrayLike);
+ if (list.length === 0) return;
+
+ // a directory input always hands over many files through the dialog,
+ // so a dropped folder must not be trimmed down to a single file either.
+ const acceptsMany = inputElement.multiple || inputElement.webkitdirectory;
+
+ if (!acceptsMany && list.length > 1) {
+ // the file dialog can never hand over more than one file without the multiple attribute,
+ // so a multi-file drop or paste is trimmed down to its first file too.
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(list[0]);
+ inputElement.files = dataTransfer.files;
+ } else if (files instanceof FileList) {
+ inputElement.files = files;
+ } else {
+ const dataTransfer = new DataTransfer();
+ list.forEach(f => dataTransfer.items.add(f));
+ inputElement.files = dataTransfer.files;
+ }
+
const event = new Event('change', { bubbles: true });
inputElement.dispatchEvent(event);
}
+ function onDrop(e: DragEvent) {
+ e.preventDefault();
+ removeDragState(true);
+
+ if (!allowDrop || inputElement.disabled || !e.dataTransfer) return;
+
+ if (!expandDirectories) {
+ setFiles(e.dataTransfer.files);
+ return;
+ }
+
+ // the entries of a DataTransfer are only readable synchronously inside the event handler,
+ // so they get collected first and walked afterwards.
+ const entries = FileUpload.readDroppedEntries(e.dataTransfer);
+ const fallback = Array.from(e.dataTransfer.files);
+
+ FileUpload.collectEntries(entries)
+ .then(files => setFiles(files.length ? files : fallback))
+ .catch(() => setFiles(fallback));
+ }
+
function onPaste(e: ClipboardEvent) {
- inputElement.files = e.clipboardData!.files;
- const event = new Event('change', { bubbles: true });
- inputElement.dispatchEvent(event);
+ if (!allowPaste || inputElement.disabled) return;
+ if (!e.clipboardData || e.clipboardData.files.length === 0) return;
+
+ setFiles(e.clipboardData.files);
}
- dropZoneElement.addEventListener("dragenter", onDragHover);
- dropZoneElement.addEventListener("dragover", onDragHover);
+ dropZoneElement.addEventListener("dragenter", onDragEnter);
+ dropZoneElement.addEventListener("dragover", onDragOver);
dropZoneElement.addEventListener("dragleave", onDragLeave);
dropZoneElement.addEventListener("drop", onDrop);
dropZoneElement.addEventListener('paste', onPaste);
+ dropZoneElement.addEventListener('dragend', onDragCancel);
+ // the window listener only cleans the state up, it never prevents the default,
+ // so a drop landing anywhere else on the page keeps behaving as it did.
+ window.addEventListener('dragend', onDragCancel);
+ window.addEventListener('drop', onDragCancel);
return {
+ update: (
+ newAllowDrop: boolean,
+ newAllowPaste: boolean,
+ newExpandDirectories: boolean,
+ newDragClass: string,
+ newDragStyle: string | null) => {
+
+ allowDrop = newAllowDrop;
+ allowPaste = newAllowPaste;
+ expandDirectories = newExpandDirectories;
+
+ if (newDragClass !== dragClass || newDragStyle !== dragStyle) {
+ // an ongoing drag is already showing the old class and style, which have to come off
+ // before they get replaced, otherwise nothing would ever take them off again.
+ const isDragging = dragCounter > 0;
+ if (isDragging) {
+ clearDragStyling();
+ }
+
+ dragClass = newDragClass;
+ dragClasses = dragClass.split(' ').filter(c => c.length > 0);
+ dragStyle = newDragStyle;
+
+ if (isDragging) {
+ applyDragStyling();
+ }
+ }
+
+ if (!allowDrop) {
+ removeDragState(true);
+ }
+ },
dispose: () => {
- dropZoneElement.removeEventListener('dragenter', onDragHover);
- dropZoneElement.removeEventListener('dragover', onDragHover);
+ dropZoneElement.removeEventListener('dragenter', onDragEnter);
+ dropZoneElement.removeEventListener('dragover', onDragOver);
dropZoneElement.removeEventListener('dragleave', onDragLeave);
dropZoneElement.removeEventListener("drop", onDrop);
dropZoneElement.removeEventListener('paste', onPaste);
+ dropZoneElement.removeEventListener('dragend', onDragCancel);
+ window.removeEventListener('dragend', onDragCancel);
+ window.removeEventListener('drop', onDragCancel);
}
}
@@ -103,32 +294,167 @@
}
public static clear(id: string) {
+ // an uploader dropped while its request is still in flight has to be fully detached first,
+ // otherwise the late response of the old request could get attributed to a newly selected
+ // file occupying the same index.
+ FileUpload._fileUploaders.filter(u => u.id === id).forEach(u => u.detach());
+
FileUpload._fileUploaders = FileUpload._fileUploaders.filter(u => u.id !== id);
}
- public static reset(id: string, inputElement: HTMLInputElement,) {
+ // a file that was taken out of the list is never uploaded again, but its uploader stays in place
+ // so that the indexes of the files after it keep pointing at the right uploader. what it does give
+ // up is everything it was holding on to: the File itself, which the browser keeps in memory - and
+ // for a picked file, on disk - for as long as anything references it, and its preview object URL.
+ public static release(id: string, index: number) {
+ const uploader = FileUpload._fileUploaders.find(u => u.id === id && u.index === index);
+
+ uploader?.detach();
+ }
+
+ public static reset(id: string, inputElement: HTMLInputElement) {
FileUpload.clear(id);
inputElement.value = '';
}
+
+ private static async readImageSizes(images: { file: File, width: number | null, height: number | null }[]): Promise {
+ // a directory selection can carry thousands of images, and decoding them all at once would
+ // spike the memory and stall the tab, so a fixed window of workers walks the list instead.
+ let next = 0;
+
+ const worker = async () => {
+ while (next < images.length) {
+ const image = images[next++];
+ const size = await FileUpload.readImageSize(image.file);
+ image.width = size.width;
+ image.height = size.height;
+ }
+ };
+
+ await Promise.all(Array.from({ length: Math.min(FileUpload.IMAGE_SIZE_CONCURRENCY, images.length) }, worker));
+ }
+
+ private static async readImageSize(file: File): Promise<{ width: number | null, height: number | null }> {
+ // createImageBitmap decodes off the main thread and needs no DOM, so it is the fast path.
+ if (typeof createImageBitmap === 'function') {
+ try {
+ const bitmap = await createImageBitmap(file);
+ const size = { width: bitmap.width, height: bitmap.height };
+ bitmap.close();
+ return size;
+ } catch { /* falls back to the image element below (e.g. for SVG on some browsers) */ }
+ }
+
+ return new Promise(resolve => {
+ const url = URL.createObjectURL(file);
+ const image = new Image();
+
+ const finish = (width: number | null, height: number | null) => {
+ URL.revokeObjectURL(url);
+ resolve({ width, height });
+ };
+
+ image.onload = () => finish(image.naturalWidth, image.naturalHeight);
+ image.onerror = () => finish(null, null);
+ image.src = url;
+ });
+ }
+
+ private static readDroppedEntries(dataTransfer: DataTransfer): any[] {
+ const items = dataTransfer.items;
+ if (!items || items.length === 0) return [];
+
+ const entries: any[] = [];
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i] as any;
+ if (item.kind !== 'file') continue;
+
+ entries.push(item.webkitGetAsEntry ? item.webkitGetAsEntry() : item.getAsFile());
+ }
+
+ return entries.filter(e => !!e);
+ }
+
+ private static async collectEntries(entries: any[]): Promise {
+ const files: File[] = [];
+
+ for (const entry of entries) {
+ await FileUpload.collectEntry(entry, files);
+ }
+
+ return files;
+ }
+
+ private static async collectEntry(entry: any, files: File[]): Promise {
+ if (entry instanceof File) {
+ files.push(entry);
+ return;
+ }
+
+ if (entry.isFile) {
+ return new Promise(resolve => entry.file(
+ (file: File) => { files.push(file); resolve(); },
+ () => resolve()));
+ }
+
+ if (entry.isDirectory) {
+ const reader = entry.createReader();
+
+ // readEntries only returns a batch at a time, so it must be called until it comes back empty.
+ while (true) {
+ const batch: any[] = await new Promise(resolve => reader.readEntries(
+ (result: any[]) => resolve(result),
+ () => resolve([])));
+
+ if (batch.length === 0) break;
+
+ for (const child of batch) {
+ await FileUpload.collectEntry(child, files);
+ }
+ }
+ }
+ }
}
class BitFileUploader {
id: string;
dotnetReference: DotNetObject;
- file: File;
+ file: File | null;
uploadUrl: string | undefined;
headers: Record;
index: number;
+ method: string;
+ withCredentials: boolean;
+ timeout: number;
+ fieldName: string;
+ previewUrl: string | null;
private xhr: XMLHttpRequest = new XMLHttpRequest();
- constructor(id: string, dotnetReference: DotNetObject, file: File, uploadEndpointUrl: string | undefined, headers: Record, index: number) {
+ constructor(
+ id: string,
+ dotnetReference: DotNetObject,
+ file: File,
+ uploadEndpointUrl: string | undefined,
+ headers: Record,
+ index: number,
+ method: string,
+ withCredentials: boolean,
+ timeout: number,
+ fieldName: string,
+ previewUrl: string | null) {
+
+ this.previewUrl = previewUrl;
this.id = id;
this.dotnetReference = dotnetReference;
this.file = file;
this.uploadUrl = uploadEndpointUrl;
this.headers = headers;
this.index = index;
+ this.method = method;
+ this.withCredentials = withCredentials;
+ this.timeout = timeout;
+ this.fieldName = fieldName;
if (index < 0) return;
@@ -146,19 +472,38 @@
};
}
- upload(from: number, to: number, uploadUrl: string, headers: Record): void {
+ upload(from: number, to: number, uploadUrl: string, headers: Record, formFields: Record): void {
const file = this.file;
if (file === null) return;
const data: FormData = new FormData();
const chunk = file.slice(from, to);
- data.append('file', chunk, file.name);
+ data.append(this.fieldName, chunk, file.name);
- var url = uploadUrl || this.uploadUrl;
+ // the extra fields go in after the content, so a field accidentally named like the file field
+ // cannot shadow the file itself for the servers that only read the first value of a name.
+ Object.keys(formFields).forEach(f => {
+ data.append(f, formFields[f]);
+ });
+
+ const url = uploadUrl || this.uploadUrl;
+
+ if (!url) {
+ // silently swallowing the missing URL would leave the file stuck as in-progress forever,
+ // so it gets reported as a failed upload instead.
+ this.dotnetReference.invokeMethodAsync("HandleChunkUpload", this.index, 0, 'The upload URL is not provided.');
+ return;
+ }
+
+ this.xhr.open(this.method, url, true);
- if (!url) return;
+ this.xhr.withCredentials = this.withCredentials;
- this.xhr.open('POST', url, true);
+ if (this.timeout > 0) {
+ // a timed out request aborts with the readyState 4 and the status 0,
+ // which the .NET side then reports as a failed upload.
+ this.xhr.timeout = this.timeout;
+ }
Object.keys(this.headers).forEach(h => {
this.xhr.setRequestHeader(h, this.headers[h]);
@@ -174,5 +519,23 @@
pause(): void {
this.xhr.abort();
}
+
+ detach(): void {
+ this.xhr.upload.onprogress = null;
+ this.xhr.onreadystatechange = null;
+ this.xhr.abort();
+ this.revokePreview();
+ // the File is what the browser is really holding on to, so it goes last and it goes for good.
+ this.file = null;
+ }
+
+ // an object URL keeps the whole file alive in memory until it is revoked, so a preview that is
+ // not on screen anymore has to give it back rather than waiting for the tab to be closed.
+ revokePreview(): void {
+ if (!this.previewUrl) return;
+
+ URL.revokeObjectURL(this.previewUrl);
+ this.previewUrl = null;
+ }
}
-}
\ No newline at end of file
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs
new file mode 100644
index 0000000000..75ea345941
--- /dev/null
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs
@@ -0,0 +1,109 @@
+namespace Bit.BlazorUI;
+
+public class BitFileUploadClassStyles
+{
+ ///
+ /// Custom CSS classes/styles for the root element of the BitFileUpload.
+ ///
+ public string? Root { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the root element while files are being dragged over the BitFileUpload.
+ ///
+ public string? Dragging { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the browse button (label) of the BitFileUpload.
+ ///
+ public string? Label { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the description (hint) of the BitFileUpload.
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the file list container of the BitFileUpload.
+ ///
+ public string? FileList { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for each file item of the BitFileUpload.
+ ///
+ public string? FileItem { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the image preview thumbnail of each file item of the BitFileUpload.
+ ///
+ public string? Preview { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the file name of each file item of the BitFileUpload.
+ ///
+ public string? FileName { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the file size of each file item of the BitFileUpload.
+ ///
+ public string? FileSize { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the upload percent indicator of each file item of the BitFileUpload.
+ ///
+ public string? Percentage { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the progress bar container of each file item of the BitFileUpload.
+ ///
+ public string? ProgressBarContainer { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the progress bar of each file item of the BitFileUpload.
+ ///
+ public string? ProgressBar { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the status message of each file item of the BitFileUpload.
+ ///
+ public string? StatusMessage { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the upload button of each file item of the BitFileUpload.
+ ///
+ public string? UploadButton { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the upload button icon of each file item of the BitFileUpload.
+ ///
+ public string? UploadIcon { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the pause button of each file item of the BitFileUpload.
+ ///
+ public string? PauseButton { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the pause button icon of each file item of the BitFileUpload.
+ ///
+ public string? PauseIcon { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the cancel button of each file item of the BitFileUpload.
+ ///
+ public string? CancelButton { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the cancel button icon of each file item of the BitFileUpload.
+ ///
+ public string? CancelIcon { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the remove button of each file item of the BitFileUpload.
+ ///
+ public string? RemoveButton { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the remove button icon of each file item of the BitFileUpload.
+ ///
+ public string? RemoveIcon { get; set; }
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadJsRuntimeExtensions.cs
index eb7de51c30..f1547e457e 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadJsRuntimeExtensions.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadJsRuntimeExtensions.cs
@@ -1,4 +1,4 @@
-using System.Diagnostics.CodeAnalysis;
+using System.Diagnostics.CodeAnalysis;
namespace Bit.BlazorUI;
@@ -11,9 +11,22 @@ internal static ValueTask BitFileUploadSetup(this IJSRuntime jsRu
ElementReference element,
bool append,
string? uploadAddress,
- Dictionary? uploadRequestHttpHeaders)
+ Dictionary? uploadRequestHttpHeaders,
+ string? method,
+ bool withCredentials,
+ long timeout,
+ string? fieldName,
+ bool showPreview,
+ bool readImageDimensions)
{
- return jsRuntime.Invoke("BitBlazorUI.FileUpload.setup", id, dotnetObjectReference, element, append, uploadAddress, uploadRequestHttpHeaders);
+ return jsRuntime.Invoke("BitBlazorUI.FileUpload.setup",
+ id, dotnetObjectReference, element, append, uploadAddress, uploadRequestHttpHeaders,
+ method, withCredentials, timeout, fieldName, showPreview, readImageDimensions);
+ }
+
+ internal static ValueTask BitFileUploadRelease(this IJSRuntime jsRuntime, string id, int index)
+ {
+ return jsRuntime.InvokeVoid("BitBlazorUI.FileUpload.release", id, index);
}
internal static ValueTask BitFileUploadUpload(this IJSRuntime jsRuntime,
@@ -22,8 +35,16 @@ internal static ValueTask BitFileUploadUpload(this IJSRuntime jsRuntime,
long to,
int index,
string? uploadUrl,
- Dictionary? httpHeaders)
+ Dictionary? httpHeaders,
+ Dictionary? formFields)
{
+ // the optional arguments are only left out when there is nothing to send, so that the
+ // defaults of the JavaScript side keep applying instead of an explicit null overriding them.
+ if (formFields is not null)
+ {
+ return jsRuntime.InvokeVoid("BitBlazorUI.FileUpload.upload", id, from, to, index, uploadUrl, httpHeaders ?? [], formFields);
+ }
+
return (httpHeaders is null ? jsRuntime.InvokeVoid("BitBlazorUI.FileUpload.upload", id, from, to, index, uploadUrl)
: jsRuntime.InvokeVoid("BitBlazorUI.FileUpload.upload", id, from, to, index, uploadUrl, httpHeaders));
}
@@ -35,9 +56,16 @@ internal static ValueTask BitFileUploadPause(this IJSRuntime jsRuntime, string i
internal static ValueTask BitFileUploadSetupDragDrop(this IJSRuntime jsRuntime,
ElementReference dragDropZoneElement,
- ElementReference inputFileElement)
+ ElementReference inputFileElement,
+ string dragClass,
+ string? dragStyle,
+ bool allowDrop,
+ bool allowPaste,
+ bool expandDirectories)
{
- return jsRuntime.Invoke("BitBlazorUI.FileUpload.setupDragDrop", dragDropZoneElement, inputFileElement);
+ return jsRuntime.Invoke("BitBlazorUI.FileUpload.setupDragDrop",
+ dragDropZoneElement, inputFileElement, dragClass, dragStyle,
+ allowDrop, allowPaste, expandDirectories);
}
internal static ValueTask BitFileUploadBrowse(this IJSRuntime jsRuntime, ElementReference inputFileElement)
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadStatus.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadStatus.cs
index 88f935f5aa..e50055652b 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadStatus.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadStatus.cs
@@ -3,7 +3,7 @@
public enum BitFileUploadStatus
{
///
- /// File uploading progress is pended because the server cannot be contacted.
+ /// The file is selected and queued, and its uploading has not started yet.
///
Pending,
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor
index 19548c82e0..2e2f611ee4 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor
@@ -6,72 +6,138 @@
@if (Item.Status != BitFileUploadStatus.Removed)
{
-
+
+ @if (FileUpload.ShowPreview && Item.PreviewUrl is not null)
+ {
+ @* draggable is off so that dragging a thumbnail does not start a drag over the drop zone itself.
+ the alt is empty on purpose: the thumbnail is decorative and its name is already
+ right next to it, so announcing it again would just repeat the same text. *@
+
+ }
-
@Item.Name
+
@Item.Name
-
- @($"{GetFileUploadSize(Item)}/{FileSizeHumanizer.Humanize(Item.Size)}")
-
-
- @fileUploadPercent%
+
+ @($"{FormatSize(GetFileUploadSize(Item))}/{FormatSize(Item.Size)}")
+ @* a file that was never going to be sent has no progress to report, and a "0%" next to
+ the reason it was turned away only reads as something that failed halfway. *@
+ @if (Item.Status != BitFileUploadStatus.NotAllowed)
+ {
+
+ @fileUploadPercent%
+
+ }
@if (Item.Status is BitFileUploadStatus.InProgress or BitFileUploadStatus.Paused)
{
-
- @if (Item.Status is BitFileUploadStatus.Pending or BitFileUploadStatus.Paused)
+ @* a file already waiting in the concurrency queue is on its way, so it offers no button to start
+ it a second time - pressing it would do nothing but suggest that nothing is happening. *@
+ @if (Item.IsQueued is false &&
+ Item.Status is BitFileUploadStatus.Pending or BitFileUploadStatus.Paused or BitFileUploadStatus.Failed or BitFileUploadStatus.Canceled)
{
- var uploadIcon = BitIconInfo.From(FileUpload.UploadIcon, FileUpload.UploadIconName ?? "Play");
-
FileUpload.Upload(Item)">
-
-
+ @* a file that already ran and did not make it is retried rather than started, so it says so:
+ a distinct icon and wording, falling back to the upload ones when none are configured. *@
+ var isRetry = Item.Status is BitFileUploadStatus.Failed or BitFileUploadStatus.Canceled;
+ var uploadIcon = isRetry
+ ? BitIconInfo.From(FileUpload.RetryIcon ?? FileUpload.UploadIcon,
+ FileUpload.RetryIconName ?? FileUpload.UploadIconName ?? "Refresh")
+ : BitIconInfo.From(FileUpload.UploadIcon, FileUpload.UploadIconName ?? "Play");
+ var uploadTitle = isRetry
+ ? (FileUpload.RetryButtonTitle ?? FileUpload.UploadButtonTitle ?? "Retry")
+ : (FileUpload.UploadButtonTitle ?? "Upload");
+
}
@if (Item.Status == BitFileUploadStatus.InProgress)
{
var pauseIcon = BitIconInfo.From(FileUpload.PauseIcon, FileUpload.PauseIconName ?? "Pause");
-
FileUpload.PauseUpload(Item)">
-
-
+ var pauseTitle = FileUpload.PauseButtonTitle ?? "Pause";
+
}
- @if (Item.Status is BitFileUploadStatus.InProgress or BitFileUploadStatus.Paused)
+ @* a file waiting in the concurrency queue can be called off before its turn ever comes,
+ which is the only way out of a queue that a long batch would otherwise walk through. *@
+ @if (Item.IsQueued || Item.Status is BitFileUploadStatus.InProgress or BitFileUploadStatus.Paused)
{
var cancelIcon = BitIconInfo.From(FileUpload.CancelIcon, FileUpload.CancelIconName ?? "Cancel");
-
+ @* the spinner carries no text of its own, and the live region of the component already
+ announces the outcome of the removal, so it is left out of the accessibility tree. *@
+
+ var removeTitle = FileUpload.RemoveButtonTitle ?? "Remove";
+
}
}
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor.cs
index 1477a6c43d..2acf4e0e96 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor.cs
@@ -1,4 +1,4 @@
-namespace Bit.BlazorUI;
+namespace Bit.BlazorUI;
public partial class _BitFileUploadItem : ComponentBase
{
@@ -9,50 +9,37 @@ public partial class _BitFileUploadItem : ComponentBase
private static int GetFileUploadPercent(BitFileInfo file)
{
- int uploadedPercent;
- if (file.TotalUploadedSize >= file.Size)
- {
- uploadedPercent = 100;
- }
- else
- {
- uploadedPercent = (int)((file.TotalUploadedSize + file.LastChunkUploadedSize) / (float)file.Size * 100);
- }
+ // an empty file has no byte whose progress could be measured, so it is either done or not
+ // started - reporting it as complete before it has been sent would be telling a story.
+ if (file.Size == 0) return file.Status is BitFileUploadStatus.Completed ? 100 : 0;
+
+ if (file.TotalUploadedSize >= file.Size) return 100;
- return uploadedPercent;
+ // the progress events count the bytes of the whole request body, multipart overhead included,
+ // so the raw ratio can slightly overshoot and has to be capped.
+ return Math.Min(100, (int)((file.TotalUploadedSize + file.LastChunkUploadedSize) / (float)file.Size * 100));
}
- private static string GetFileUploadSize(BitFileInfo file)
+ private static long GetFileUploadSize(BitFileInfo file)
{
- long uploadedSize;
- if (file.TotalUploadedSize >= file.Size)
- {
- uploadedSize = file.Size;
- }
- else
- {
- uploadedSize = file.TotalUploadedSize + file.LastChunkUploadedSize;
- }
+ // the progress events count the bytes of the whole request body, multipart overhead included, so the
+ // running total can overshoot the file and has to be capped - "1.1 MB/1 MB" would read as a bug.
+ return file.TotalUploadedSize >= file.Size
+ ? file.Size
+ : Math.Min(file.Size, file.TotalUploadedSize + file.LastChunkUploadedSize);
+ }
- return FileSizeHumanizer.Humanize(uploadedSize);
+ private string FormatSize(long size)
+ {
+ return FileUpload.FileSizeFormatter is null ? FileSizeHumanizer.Humanize(size) : FileUpload.FileSizeFormatter(size);
}
- private string GetFileElClass(BitFileUploadStatus status)
+ private static string GetFileElClass(BitFileUploadStatus status)
=> status switch
{
BitFileUploadStatus.Completed => $"{ROOT_ELEMENT_CLASS}-uld",
- BitFileUploadStatus.Failed or BitFileUploadStatus.NotAllowed => $"{ROOT_ELEMENT_CLASS}-fld",
- BitFileUploadStatus.Paused => $"{ROOT_ELEMENT_CLASS}-psd",
+ BitFileUploadStatus.Failed or BitFileUploadStatus.NotAllowed or BitFileUploadStatus.RemoveFailed => $"{ROOT_ELEMENT_CLASS}-fld",
+ BitFileUploadStatus.Paused or BitFileUploadStatus.Canceled => $"{ROOT_ELEMENT_CLASS}-psd",
_ => $"{ROOT_ELEMENT_CLASS}-ip",
};
-
- private string GetUploadMessage(BitFileInfo file)
- => file.Status switch
- {
- BitFileUploadStatus.Completed => FileUpload.SuccessfulUploadMessage,
- BitFileUploadStatus.Failed => FileUpload.FailedUploadMessage,
- BitFileUploadStatus.RemoveFailed => FileUpload.FailedRemoveMessage,
- BitFileUploadStatus.NotAllowed => FileUpload.IsFileTypeNotAllowed(file) ? FileUpload.NotAllowedExtensionErrorMessage : FileUpload.MaxSizeErrorMessage,
- _ => string.Empty,
- };
}
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/FileUpload/BitFileUploadDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/FileUpload/BitFileUploadDemo.razor
index e324fe266c..c6d94ba73c 100644
--- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/FileUpload/BitFileUploadDemo.razor
+++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/FileUpload/BitFileUploadDemo.razor
@@ -3,10 +3,10 @@
+ Description="An upload component that sends files to a server endpoint over HTTP with browse, drag-and-drop, clipboard paste, folder and camera capture selection, image previews and pixel dimensions, single-request or chunked transfers with adaptive chunk sizing and byte-range headers, a configurable concurrency limit with a visible queue, pause, resume, cancel and status-aware automatic retries, per-file speed and time remaining plus overall progress and status reporting, size, total size, count, type, duplicate, image dimension and custom validation, server-side removal, per-request customizable requests (headers, query strings, form fields, URL, method, credentials, timeout), color, size and variant theming, screen reader announcements and a fully templatable UI." />
-
Files can be uploaded after selecting them.
+
+ Out of the box the BitFileUpload offers three ways to hand a file over: click the browse button to open
+ the file dialog, drag a file out of the OS and drop it anywhere on the component, or focus the component
+ and paste a file from the clipboard. The selected file appears in the built-in file list with its name,
+ its human-readable size and an upload button that starts sending it to the UploadUrl; while files
+ hover over the component the browse button turns into a dashed drop indicator. During the upload the item
+ shows a progress bar with pause and cancel buttons, and once it settles, a status message reports the
+ outcome.
+
+
-
-
Multiple files can be selected.
+
+
+ The two alternative selection routes can be switched off independently. AllowDrop controls dragging
+ files out of the OS onto the component: turning it off keeps the drop from being handled and shows a
+ "no drop" cursor, while still swallowing the event so the browser does not navigate away to the dropped
+ file and lose the page state. AllowPaste controls pasting files from the clipboard; a paste is only
+ delivered to the component while the focus is inside it, so the browse button has to be focused first.
+ Both take effect immediately when toggled at runtime, as the checkboxes below demonstrate.
+
+
+
+
+
+
+
+
+
+
+
+ Telling the user the rules before they run into them is the single biggest usability win an uploader can
+ offer. Description renders a short hint under the browse button and - the part that is easy to
+ forget - wires it to that button through aria-describedby, so a screen reader reads the constraints out
+ together with the button instead of leaving them to be discovered by trial and error. Spell out the
+ accepted types and the size ceiling there, matching whatever Accept, AllowedExtensions and
+ MaxSize actually enforce. DescriptionTemplate takes over when the hint needs markup of its
+ own, such as the icon below, and Classes.Description / Styles.Description restyle it.
+
+
+
+
+
+
+
+ Images only. Up to 2 MB.
+
+
+
+
+
+
+ Multiple lets the file dialog hand over several files at once and lets a single drop or paste carry
+ several files, each uploading independently with its own progress, pause, cancel and status. Without it
+ the dialog is limited to one file and only the first file of a multi-file drop is taken.
+
+
-
-
The BitFileUpload can automatically starts the upload after file selection is done.
+
+
+ AutoUpload starts sending the files the moment they are selected, skipping the per-file upload
+ button entirely - the pattern to reach for when selection itself expresses the intent to upload, like an
+ avatar picker or an attachment area.
+
+
-
-
Automatically resets the BitFileUpload state each time before browsing files.
+
+
+ AutoReset clears the file list and the upload state just before the file dialog opens, so every
+ browse starts from a clean slate - the list empties even if the dialog is then cancelled. Handy when a
+ stale, already-uploaded selection lingering behind the dialog would be misleading.
+
+
-
-
When selected, additional files will be appended to the existing list without overwriting previous selections.
+
+
+ A new selection normally replaces whatever is in the list. Append keeps the existing files - along
+ with their upload state - and adds the new selection to the end instead, letting the user build the batch
+ up over several rounds of browsing, dropping or pasting.
+
+
-
-
The file size can be limited using the MaxSize parameter.
+
+
+ Building a batch up over several rounds of browsing makes it easy to pick the same file twice without
+ noticing, and uploading it twice is rarely what anyone meant. Turning AllowDuplicates off rejects
+ a newly selected file that matches one already in the list - by name, size and last modified time, which
+ is as close to an identity as the browser exposes for a picked file - showing the
+ DuplicateErrorMessage under it instead of sending it a second time. The rejection is not final:
+ remove the file it duplicates and the copy becomes eligible again, since the rule is re-evaluated over
+ the whole list on every change. Try selecting the same file twice below.
+
+
+
+
+
+
+
+ MaxSize and MinSize bound the size of each file in bytes, while MaxTotalSize bounds
+ what the whole list adds up to - the limit that actually matches a per-request or per-user storage quota,
+ which a per-file ceiling alone cannot express. A file outside the allowed range is marked as not allowed
+ right at selection time - with the MaxSizeErrorMessage, MinSizeErrorMessage or
+ MaxTotalSizeErrorMessage shown under it - and it is never sent to the server; the file stays
+ visible in the list so the user can see exactly what was rejected and why instead of it disappearing
+ silently. Only the files that pass the other validations consume the total-size budget, and a file turned
+ away by it becomes eligible again as soon as removals free up room.
+
+
+
Max 1 MB per file:
+
+
+
Min 1 KB per file:
+
+
+
+
Max 2 MB in total across the batch:
+
+
+
+
Custom error message:
+
+
-
-
Limits file browsing by the provided file extensions.
+
+
+ The two work at different moments and are both worth setting. Accept is passed straight to the
+ accept attribute of the underlying input, which only hints the file dialog to pre-filter what it
+ offers - it is a convenience, never a guarantee, since a drop, a paste or a "show all files" switch walks
+ right past it. AllowedExtensions is the actual rule: it checks every file however it arrived and
+ marks the mismatches as not allowed with the NotAllowedExtensionErrorMessage, so they stay visible
+ in the list with their reason and are never sent.
+
+
+
+ Its entries are matched leniently - an extension may be written with or without its leading dot and in
+ any casing - and an entry containing a slash is treated as a MIME type instead, with a trailing wildcard
+ like image/* covering a whole group. When Accept is not set, the list also generates the
+ accept attribute on its own, which is why setting AllowedExtensions alone is usually all you need;
+ set Accept explicitly only when the dialog filter should differ from the rule.
+
+
+
Accept only (the dialog filters, but a dropped file of any type is still taken and uploaded):
+
+
+
+
AllowedExtensions (the rule, which also generates the accept attribute):
+
+
+
MIME types (every image plus PDFs):
+
+
-
-
Enables the remove functionality of the BitFileUpload.
-
+
+ MaxCount caps how many files the list can hold - 3 in this example. Files arriving beyond the cap
+ stay visible but are marked as not allowed with the MaxCountErrorMessage and never uploaded, so
+ the user sees exactly which files did not make it instead of them disappearing silently. Only files that
+ pass the other validations consume a slot, so a file rejected for its size or type never pushes a good
+ file over the cap, and a file already uploading or uploaded keeps the slot it holds rather than being
+ taken back by a later selection. Combined with Append the cap applies to the whole accumulated
+ list, not just a single selection - and since the cap is re-evaluated on every change, removing a file
+ takes the next one waiting behind it back in.
+
+
+
-
-
Different events can be configured for the upload process.
+
+
+ When the built-in checks are not enough, FileValidator runs a custom function for each newly
+ selected file after those checks pass. Returning an error message marks the file as not allowed and shows
+ the message under it - the file is never uploaded; returning null accepts the file. Any rule expressible
+ over the file's metadata works - this example rejects empty (zero-byte) files. A validator that throws
+ only invalidates its own file, using the exception message as the error text, rather than aborting the
+ whole selection.
+
+
+
+
+
+
+
+ Directory turns the picker into a folder picker: the dialog asks for a directory instead of a file
+ and every file inside it - and inside its subfolders, at any depth - lands in the list as a separate
+ upload with its own progress and status. It does the same for a drag and drop, walking a dropped folder
+ into its contents rather than ignoring it, which the plain file input cannot do on its own. The browser
+ warns the user before handing a whole folder over, and the usual validations still apply to every file
+ that comes out of it, so pairing it with AllowedExtensions, MaxCount or MaxTotalSize
+ is what keeps a stray folder from turning into a thousand uploads. Pair it with ConcurrentUploads
+ to keep the transfers orderly.
+
+
+
+
+
+
+
+ On a phone or a tablet, Capture asks the browser to open the camera or the microphone directly
+ instead of the file browser, which turns the uploader into a one-tap "take a photo and send it" control.
+ "user" asks for the front (selfie) camera and "environment" for the rear one; which one is
+ actually honored is up to the device, and a desktop browser ignores the attribute altogether and opens
+ the usual file dialog. Combine it with Accept to say what kind of capture is wanted - an image, a
+ video or an audio recording - and with AutoUpload so the shot goes out the moment it is taken.
+
+
+
+
+
+
+
+
+
+ A file name is a poor way to tell one photo from another, so ShowPreview puts a thumbnail of every
+ selected image at the head of its file item, letting the user confirm they picked the right picture
+ before a single byte goes out. The thumbnail is produced entirely in the browser from an object URL, so
+ nothing is uploaded to render it and non-image files simply keep the plain item. The URL is also handed
+ back as soon as the file is removed or the component is reset, which is what keeps a long-lived page from
+ holding every picture ever picked in memory. BitFileInfo.PreviewUrl exposes the same URL for a
+ custom FileViewTemplate, and Classes.Preview / Styles.Preview restyle the built-in
+ thumbnail.
+
+
+
+
+
+
+
+ A size limit in bytes is a poor way to say "no tiny avatars" or "nothing bigger than 4K": what matters
+ for an image is its pixels, and a heavily compressed photo can be small in bytes and enormous on screen.
+ ReadImageDimensions decodes every selected image right in the browser and fills the
+ Width and Height of its BitFileInfo before the validations run, so a
+ FileValidator can turn an image away for its dimensions without a single byte leaving the
+ machine - and the rejected file stays in the list with its reason, exactly like any other rejection.
+
+
+
+ Decoding is not free: a folder full of photos costs time and memory to walk through, which is why it is
+ off by default and why the decoding runs a few images at a time rather than all at once. Non-image
+ files, and images the browser cannot decode, simply keep both values null - a validator has to treat
+ that as "unknown" rather than as a failure. The values are also there for a custom
+ FileViewTemplate to render, as the second uploader below does.
+
+
+
Between 200x200 and 4000x4000 pixels:
+
+
+
+
Showing the dimensions of each image:
+
+
+
+
+ @file.Name
+ @(file.Width is null ? "unknown size" : $"{file.Width} x {file.Height}")
+
+
+
+
+
+
+
+ ShowRemoveButton puts a remove button on each settled file item. A file whose bytes never reached
+ the server is simply dropped from the view, while a file that has (partially or fully) uploaded is
+ deleted from the server through a DELETE request to the RemoveUrl, carrying the file name as a
+ query string and the file id in the BIT_FILE_ID header; a non-success response marks the file with
+ the FailedRemoveMessage instead of pretending it is gone. RemoveButtonTitle localizes the
+ button tooltip and its accessible label.
+
+
+
+
+
+
+ Every stage of the process surfaces through a callback: OnChange fires whenever a file's status
+ changes, OnInvalid right after it whenever a selection carries rejected files - handy for driving
+ a summary or blocking a submit button - OnUploading right before a file's first request (the
+ natural place to attach per-file HTTP headers like below), OnProgress on every progress tick,
+ OnUploadComplete / OnUploadFailed when a file settles, OnRemoveComplete /
+ OnRemoveFailed for removals, and OnAllUploadsComplete once the whole batch has reached a
+ terminal state, which is what drives the message below. Try a file over 1 MB to see OnInvalid at
+ work.
+
The http requests of Upload and Remove can be customized with http headers and query strings.
+
+
+ The upload and remove requests are fully customizable. Static UploadRequestHttpHeaders /
+ RemoveRequestHttpHeaders and UploadRequestQueryStrings / RemoveRequestQueryStrings
+ attach headers and query strings to the respective requests, and UploadRequestFormFields adds
+ extra multipart fields next to the file content itself - the target folder, an album id, a caption -
+ for the endpoints that read their metadata from the form rather than from the URL.
+
+
+
+
+ Each of the three has an async ...Provider counterpart, and the difference is not only that it
+ is a function: a provider is invoked right before every single request - each file, and each
+ chunk of a chunked file - while the static values are fixed once, when the files are selected. That is
+ what makes a provider the right place for anything that expires: an access token that has to be fresh
+ when the request actually goes out, or a presigned URL good for one upload only, which
+ UploadUrlProvider mints for the endpoint itself. A value that never changes belongs in the
+ static parameter instead, since a provider costs an await per request.
+
+
+
+
+
Requests so far: @tokenRequestCount
+
+
+ The transport itself is configurable too: UploadRequestHttpMethod switches the verb ("POST" by
+ default - a PUT is what presigned URL flows usually expect), UploadFormFieldName renames the form
+ field carrying the file content ("file" by default), WithCredentials sends cookies and
+ authorization headers along on cross-origin requests, and UploadTimeout fails a request - each
+ file or chunk - that takes longer than the given time. On the other side,
+ RemoveRequestHttpMethod does the same for the removal, whose "DELETE" some APIs would rather
+ receive as a "POST".
+
+
+
-
-
Files can be uploaded in chunks.
+
+
+ ChunkedUpload slices each file and sends it as a series of sequential requests instead of one
+ monolithic one, which is what makes pausing, resuming and recovering large uploads practical: a paused or
+ failed file resumes from the last successfully uploaded chunk rather than starting over, so a dropped
+ connection costs one chunk instead of the whole transfer. ChunkSize pins the size of each slice in
+ bytes, while AutoChunkSize adapts it to the observed connection speed on the fly, between 512 KB
+ and 10 MB - large enough on a fast link to keep the request overhead negligible, small enough on a slow
+ one to keep the progress moving and a retry cheap.
+
+
+
+ The server side reassembles the file, and every chunk request carries what it needs to do so reliably:
+ the BIT_FILE_ID header correlating the chunks of one file, and the byte range of the chunk both as
+ the standard Content-Range header (bytes from-to/total) and as the plain
+ BIT_CHUNK_FROM, BIT_CHUNK_TO and BIT_FILE_SIZE headers for the handlers that would
+ rather not parse it. Writing each chunk at its own offset rather than blindly appending is what makes the
+ server correct in the face of a retried chunk. Note that CORS treats these as custom request headers, so
+ a cross-origin endpoint has to allow them.
+
+
+
+
Adaptive chunk sizing:
+
+
-
-
The BitFileUpload can be further customized using templates.
-
+
+
+ Networks fail, so a professional uploader plans for it. AutoRetries retries a failed upload
+ automatically - up to the given number of times per file, waiting AutoRetryDelay before each
+ attempt - and in the chunked mode every retry resumes from the last successfully uploaded chunk instead
+ of starting over. Pausing or canceling a file while a retry is still waiting out its delay wins over that
+ retry, so the outcome the user asked for is the one that stands.
+
+
+
+ Not every failure deserves another attempt, so the budget is only spent where a second try could go
+ differently: network errors, timeouts, 408, 429 and the 5xx server errors are retried, while the other
+ 4xx - a 404 pointing nowhere, a 413 that is simply too large, a 401 - settle the file immediately rather
+ than repeating an identical doomed request. ShouldAutoRetry replaces that rule with your own,
+ receiving the file and the status code and returning whether to retry.
+
+
+
+ Once the automatic budget is exhausted the file settles as failed, and the item offers a retry button -
+ distinct from the plain upload button of a never-started file, both in its icon and in its wording, so a
+ second attempt never reads as a first one. Pressing it tries again with a fresh automatic budget, exactly
+ like calling Upload with that file from code does. RetryIcon, RetryIconName and
+ RetryButtonTitle restyle and localize it, falling back to their upload counterparts when unset.
+
+
+
+
+
+ A non-existing endpoint (404): the built-in rule does not retry it, so the file fails at once and offers
+ the manual retry button.
+
+
+
+
+
The same endpoint with a ShouldAutoRetry that insists: watch it retry twice before settling.
+
+
+
+
+
+
+ By default every file of a batch is put on the wire at once, which is the fastest option on a healthy
+ connection but not always the kindest one: browsers only keep a handful of connections open per host,
+ and a server behind a rate limit or a slow disk would rather not meet fifty simultaneous uploads.
+ ConcurrentUploads caps how many files may be in flight at the same time; the rest wait in a queue
+ in selection order and each one starts the moment a slot frees up - whether the file holding it completed,
+ failed, was canceled or was merely paused. Set it to 0 (the default) to keep starting everything at once.
+
+
+
+ The cap counts files, not requests: a chunked file holds its single slot for the whole run of its chunks
+ rather than going back to the end of the queue between two of them. Note that the queue only governs the
+ start of an upload, so a file started explicitly through Upload(file) waits its turn just like a
+ file of a batch does.
+
+
+
+ A file waiting for its turn says so rather than looking exactly like one nobody asked to upload: its
+ item shows the QueuedUploadMessage - "Waiting to upload" by default, and the place to translate
+ it - drops the start button, since starting something already on its way would do nothing, and keeps a
+ cancel button so it can be called off before its turn ever comes. BitFileInfo.IsQueued exposes
+ the same state to a custom FileViewTemplate.
+
+
+
Two files at a time:
+
+
+
+
One file at a time (a strictly sequential queue), with a translated waiting message:
+
+
+
+
+
+
+ Besides the per-file progress, the component aggregates the batch as a whole: TotalSize and
+ TotalUploadedSize expose the combined byte counts of the files that are actually being uploaded -
+ rejected and removed files do not count - and OverallUploadProgress turns them into a single
+ percentage, weighting every file by its size. Rendering it is a matter of re-reading those properties
+ whenever OnProgress or OnChange fires, which is exactly what drives the BitProgress below.
+ Select several files and upload them all to see the combined bar move.
+
+
+
+
+
+
+
+
+
+ A percentage answers "how far along is this"; what a user waiting on a large upload actually wants to
+ know is "how much longer". Every running file measures the speed of the request currently in flight and
+ exposes it on its BitFileInfo: UploadSpeed in bytes per second and RemainingTime as
+ a TimeSpan derived from it and the bytes still to send. Both are null while a file is not on the
+ wire - before it starts, once it settles, and for the first fraction of a second of a request, where a
+ reading taken over almost no time would say nothing but a huge number.
+
+
+
+ They are updated on every progress tick, so rendering them is a matter of re-reading them from
+ OnProgress, as the summary below does. In the chunked mode the speed is measured over the chunk
+ in flight, which is what makes it follow a connection that speeds up or slows down mid-file instead of
+ averaging the whole transfer into a number that stops reacting.
+
+
+
+ The batch has the same two numbers of its own: TotalUploadSpeed adds up the speed of every file
+ currently on the wire - what the connection as a whole is carrying, which is the honest figure when
+ several files are uploading at once - and OverallRemainingTime divides the bytes still to go by
+ it. Both are null while nothing is uploading, which is the difference between "not moving" and
+ "moving at zero".
+
+ Each file item shows its uploaded and total size through a built-in humanizer that speaks English and
+ counts in binary units. FileSizeFormatter takes that decision over: it receives a size in bytes
+ and returns whatever text should appear, which is where a localized unit name, a different rounding, or
+ the decimal base some platforms prefer belongs. The example below drops down to raw byte counts with
+ thousands separators.
+
+
+
+
+
+
+
+ HideFileView takes the built-in file list away without taking the files with it: they are still
+ selected, still validated, still uploaded and still reported through Files and the callbacks -
+ they are simply not drawn. That is what you want when the surrounding page already shows the attachments
+ in its own layout, when the uploader has to collapse into a single button, or when the list belongs
+ somewhere else on the screen entirely. The example below renders its own summary from the OnChange
+ callback while the component itself shows nothing but the browse button. Reach for
+ FileViewTemplate instead when the list should stay where it is and only look different.
+
+ The default UI can be swapped out entirely: LabelTemplate replaces the browse button - here with a
+ full drop-zone panel that hides itself once files are selected - and FileViewTemplate replaces
+ each item of the file list, receiving the file info as its context with its name, size, progress, speed
+ and status all available for rendering. The template is only asked for the files that are actually in
+ the list, so a removed file never leaves an empty item behind. Replacing the browse button also replaces
+ the built-in dashed drop indicator that lives on it, so a custom label should bring its own drag
+ feedback: Classes.Dragging puts a class on the root element for as long as files hover over the
+ component.
+
+
+
+ Two things are worth carrying over from the default UI when replacing it. The controls should be real
+ button elements rather than clickable divs or icons, so they stay reachable with Tab and can be
+ activated with Enter or Space, and each of them should name the file it acts on through an aria-label -
+ a list of identical "Remove" buttons tells a screen reader user nothing about which file they are on.
+ And since the context is the file itself, the per-file overloads are the ones to call:
+ Upload(file) and RemoveFile(file) act on that one file, while their no-argument forms
+ would sweep through the whole batch.
+
+
+
@if (FileUploadIsEmpty())
{
-
bitFileUpload.Browse()">
+ @* a real button rather than a clickable div, so the drop zone is reachable with Tab
+ and activated with Enter or Space like the built-in browse button it replaces. *@
+
+
}
@@ -118,9 +663,15 @@
@file.Name
+ @* each action names the file it acts on and applies to that file
+ alone, which is what the context of the template is there for. *@
-
-
+
+
@if (file.Status is BitFileUploadStatus.InProgress or BitFileUploadStatus.Pending)
@@ -143,7 +694,7 @@
Max file size: 2 MB
Use a custom method for the open file selection dialog.
-
+
+ The component can be driven entirely from code: Browse opens the file dialog (here the built-in
+ browse button is hidden with HideLabel and replaced by external BitButtons), Upload starts
+ the upload of a specific file or every file, PauseUpload and CancelUpload stop them,
+ RemoveFile removes files, and Reset clears the whole state. Each of them takes a single
+ file or, with no argument, applies to the whole batch. Files exposes the current selection with
+ each file's status and progress, and UploadStatus reports the state of the batch as a whole -
+ pending until the first upload starts, in progress from then on, and completed once every file has
+ reached a terminal state.
+
+
+
+ The difference between the two ways of stopping is what happens next. PauseUpload applies to the
+ files that are actually on their way: it aborts the in-flight request and keeps the bytes that made it,
+ so calling Upload again resumes from there - from the last completed chunk in the chunked mode -
+ and it takes a file still waiting in the concurrency queue back out of that queue. A file nobody asked
+ to upload has nothing to pause, so it is left exactly as it is.
+
+
+
+ CancelUpload settles the file as canceled instead, and does so immediately whether it was
+ running, waiting in the queue, paused or merely selected, so the outcome is visible on every file rather
+ than being recorded as an intention on some of them. Files that have already settled are left alone: a
+ completed upload cannot be taken back from the server by anything happening on this side, and calling
+ off a file that already failed would only replace the reason it failed with a less useful one. A
+ canceled file can still be started again later, and its item offers the retry button for exactly that.
+
+
+
- Browse file
+
+ Browse files
+ Upload all
+ Pause all
+ Cancel all
+ Reset
+
-
+
- Use icons from external libraries like FontAwesome and Bootstrap Icons with the UploadIcon,
- PauseIcon, CancelIcon, and RemoveIcon parameters (BitIconInfo).
+ The BitFileUpload is accessible out of the box: the browse button is a real button - focusable with Tab
+ and activated with Enter or Space - the hidden native input points to it via aria-labelledby, the file
+ list carries list and listitem roles, every action button (upload, pause, cancel, remove) gets an
+ aria-label naming the file it acts on, and the progress bar exposes its percentage through the
+ progressbar role. A visually hidden live region announces the selection and every upload outcome, so a
+ screen reader user hears how many files were taken, which uploads succeeded and which failed without
+ having to go looking. AnnouncementProvider replaces that built-in English sentence with text of
+ your own - the natural place to translate it or to word it around your domain, as the second example
+ does. AriaLabel labels the browse button, overriding its visible text for assistive technologies
+ when a short label does not say enough on its own.
+
+
+
+
+
+
+
+
+
+ The browse button is the visual weight the component carries on a page, and Variant decides how
+ much of it there is. Fill - the default - paints the button in a solid block of the
+ Color and is the right call when uploading is the main thing the
+ screen is for. Outline keeps only the rule around it in that color and fills in on hover, for an
+ uploader sitting among other form fields that should not shout over them. Text drops both the rule
+ and the fill, leaving a colored label for the places where the uploader is a secondary affordance. The
+ drop indicator and the file list follow the variant along, and a disabled component drops the role colors
+ entirely for the neutral disabled look, whichever variant it uses.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Color picks the role color the component speaks in. It is not only the browse button: the dashed
+ drop indicator, the progress bar of every running file, the paused status message, the removal spinner
+ and the hovered action buttons all take it, so the whole control reads as one piece instead of a themed
+ button next to a default-blue list. All the standard bit color roles are available - the semantic ones
+ (Primary, Secondary, Tertiary, Info, Success, Warning, SevereWarning, Error) as well as the background,
+ foreground and border families - each of them carrying its own hover and active shades and its own
+ readable on-color for the label.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Background, foreground and border colors:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The action button icons are not limited to the built-in icon font: UploadIcon, RetryIcon,
+ PauseIcon, CancelIcon and RemoveIcon take a
+ BitIconInfo or a raw CSS class string that can point at any icon
+ library whose stylesheet is loaded, like FontAwesome or Bootstrap Icons below. Use
+ BitIconInfo.Fa(), BitIconInfo.Bi() or BitIconInfo.Css() to build one, or stick with
+ the built-in font and just pick different icons via the ...IconName counterparts. Upload a file
+ against a failing endpoint to see the retry icon take the place of the upload one.
@@ -180,6 +849,7 @@
@@ -188,6 +858,7 @@
@@ -200,6 +871,7 @@
@@ -208,8 +880,92 @@
-
\ No newline at end of file
+
+
+ Size scales the whole control in one move - the height, padding and font of the browse button, the
+ text of the file items and their size line, the action buttons and their icons, and the image preview
+ thumbnails all step together, so a Small uploader tucked into a dense form and a Large one
+ headlining a drop area both stay internally proportioned. Medium is the default. The dimensions
+ come from CSS custom properties on the root element, so a size can still be fine-tuned from
+ Styles without redefining the whole scale.
+
+
+
+
+
+
+
+
+
+
+
+ Style and Class land on the root element, which inline styles and a single class cover
+ well. For anything deeper, Styles and Classes reach the individual parts - the browse
+ button, the description, the file list, each file item, its name, size, percentage, progress bar, status
+ message and every action button and icon - and their Dragging entry applies to the root element
+ only while files are being dragged over the component, which is how the examples below restyle the drop
+ state without touching the idle look.
+
+
+
+
+
Component's Style & Class:
+
+
+
+
+
+
+
+
+
+
Styles & Classes:
+
+
+
+
+
+
+
+
+
+
+ Setting Dir to BitDir.Rtl mirrors the whole layout for right-to-left languages - the browse
+ button, the file names, sizes, progress bars and action buttons all follow - and the localizable texts
+ (the Label, the status messages and the button titles) complete the picture, so the BitFileUpload
+ reads naturally inside an RTL page like the Farsi example below.
+
+
+
+
+
+
+
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/FileUpload/BitFileUploadDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/FileUpload/BitFileUploadDemo.razor.cs
index 4565fbca01..87f3faadc2 100644
--- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/FileUpload/BitFileUploadDemo.razor.cs
+++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/FileUpload/BitFileUploadDemo.razor.cs
@@ -1,4 +1,4 @@
-namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Inputs.FileUpload;
+namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Inputs.FileUpload;
public partial class BitFileUploadDemo
{
@@ -9,21 +9,51 @@ public partial class BitFileUploadDemo
Name = "Accept",
Type = "string?",
DefaultValue = "null",
- Description = "The value of the accept attribute of the input element.",
+ Description = "Accepted file types for the file browser using MIME types or file extensions (e.g., \"image/*\", \".pdf,.doc\"), applied to the accept attribute of the underlying input element. When not set, the accept attribute is generated from AllowedExtensions.",
+ },
+ new()
+ {
+ Name = "AllowDrop",
+ Type = "bool",
+ DefaultValue = "true",
+ Description = "Whether files can be selected by dragging them from the operating system and dropping them on the component.",
+ },
+ new()
+ {
+ Name = "AllowDuplicates",
+ Type = "bool",
+ DefaultValue = "true",
+ Description = "Whether a file that is already in the file list can be selected again. When disabled, a newly selected file matching an existing one by name, size and last modified time is rejected with the DuplicateErrorMessage instead of being uploaded a second time, becoming eligible again once the file it duplicates is removed.",
},
new()
{
Name = "AllowedExtensions",
Type = "IReadOnlyCollection",
DefaultValue = "[\"*\"]",
- Description = "Filters files by extension.",
+ Description = "Allowed file types for validation purposes, accepting both file extensions (with an optional leading dot, case-insensitive) and MIME types with an optional wildcard (e.g., \"image/*\"). Use [\"*\"] to allow all file types. Files not matching any of these entries will not be uploaded.",
+ },
+ new()
+ {
+ Name = "AllowPaste",
+ Type = "bool",
+ DefaultValue = "true",
+ Description = "Whether files can be selected by pasting them from the clipboard onto the component. The paste is only captured while the focus is inside the component.",
+ },
+ new()
+ {
+ Name = "AnnouncementProvider",
+ Type = "Func, string?>?",
+ DefaultValue = "null",
+ Description = "Custom provider of the text announced by the screen reader through the live region of the component whenever the file list or an upload outcome changes. Receives the current file list and returns the text to announce, or null to announce nothing. When not set, a built-in English announcement is used.",
+ LinkType = LinkType.Link,
+ Href = "#file-info"
},
new()
{
Name = "Append",
Type = "bool",
DefaultValue = "false",
- Description = "Enables the append mode that appends any additional selected file(s) to the current file list."
+ Description = "Whether a new selection is added to the end of the current file list instead of replacing it, which is what lets the user build a batch up over several rounds of browsing, dropping or pasting. The files already in the list keep their upload state."
},
new()
{
@@ -37,14 +67,35 @@ public partial class BitFileUploadDemo
Name = "AutoReset",
Type = "bool",
DefaultValue = "false",
- Description = "Automatically resets the file-upload before starting to browse for files."
+ Description = "Whether the file list and the upload state are cleared right before the file dialog opens, so that every browse starts from a clean slate - the list empties even if the dialog is then cancelled."
+ },
+ new()
+ {
+ Name = "AutoRetries",
+ Type = "int",
+ DefaultValue = "0",
+ Description = "The number of times a failed upload of a file gets retried automatically before it is reported as failed. In the chunked mode each retry resumes from the last successfully uploaded chunk. Set to 0 (the default) to disable the automatic retries."
+ },
+ new()
+ {
+ Name = "AutoRetryDelay",
+ Type = "TimeSpan?",
+ DefaultValue = "null",
+ Description = "The delay before each automatic retry of a failed upload. Set to null (the default) to retry immediately."
},
new()
{
Name = "AutoUpload",
Type = "bool",
DefaultValue = "false",
- Description = "Automatically starts the upload file(s) process immediately after selecting the file(s)."
+ Description = "Whether the selected files start uploading the moment they are selected, skipping the per-file upload button entirely, for the cases where the selection itself expresses the intent to upload."
+ },
+ new()
+ {
+ Name = "CancelButtonTitle",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "The tooltip of the cancel upload button, which is also used as the prefix of its accessible label (e.g., \"Cancel report.pdf\"). Defaults to \"Cancel\".",
},
new()
{
@@ -65,18 +116,85 @@ public partial class BitFileUploadDemo
Href = "https://blazorui.bitplatform.dev/iconography"
},
new()
+ {
+ Name = "CanceledUploadMessage",
+ Type = "string",
+ DefaultValue = "File upload canceled",
+ Description = "The message shown for canceled file uploads."
+ },
+ new()
+ {
+ Name = "Capture",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "The capture behavior of the file input on devices with a camera or microphone, rendered as the capture attribute of the input element (e.g., \"user\" for the front camera, \"environment\" for the rear camera).",
+ },
+ new()
{
Name = "ChunkedUpload",
Type = "bool",
DefaultValue = "false",
- Description = "Enables the chunked upload."
+ Description = "Whether each file is sliced and sent as a series of sequential requests instead of one monolithic one, which is what makes a paused or failed file resume from the last chunk that made it through rather than starting over, so a dropped connection costs one chunk instead of the whole transfer."
},
new()
{
Name = "ChunkSize",
Type = "long?",
DefaultValue = "null",
- Description = "The size of each chunk of file upload in bytes."
+ Description = "The size in bytes of each chunk of a chunked upload. When not set - and whenever AutoChunkSize is enabled, which takes the decision over - it starts at 512 KB."
+ },
+ new()
+ {
+ Name = "Classes",
+ Type = "BitFileUploadClassStyles?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes for different parts of the BitFileUpload.",
+ LinkType = LinkType.Link,
+ Href = "#class-styles"
+ },
+ new()
+ {
+ Name = "Color",
+ Type = "BitColor?",
+ DefaultValue = "null",
+ Description = "The general color of the file upload, applied to the browse button, the drag-and-drop indicator, the progress bars and the hovered action buttons.",
+ LinkType = LinkType.Link,
+ Href = "#color-enum"
+ },
+ new()
+ {
+ Name = "ConcurrentUploads",
+ Type = "int",
+ DefaultValue = "0",
+ Description = "The maximum number of files uploading at the same time, the remaining ones waiting in a queue in selection order and starting as soon as a slot frees up. Set to 0 (the default) to start every file at once."
+ },
+ new()
+ {
+ Name = "Description",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "A short hint rendered under the browse button and wired to it through aria-describedby, which is the place to spell out the accepted file types and the size limits so that both sighted and screen reader users learn the constraints before hitting them.",
+ },
+ new()
+ {
+ Name = "DescriptionTemplate",
+ Type = "RenderFragment?",
+ DefaultValue = "null",
+ Description = "Custom Razor template of the hint rendered under the browse button, taking precedence over Description.",
+ },
+ new()
+ {
+ Name = "Directory",
+ Type = "bool",
+ DefaultValue = "false",
+ Description = "Whether to select folders (directories) instead of files, rendered as the webkitdirectory attribute. All files inside the selected folder and its subfolders will be added to the file list. It also makes a dropped folder expand into its contents instead of being ignored.",
+ },
+ new()
+ {
+ Name = "DuplicateErrorMessage",
+ Type = "string",
+ DefaultValue = "The file is already selected",
+ Description = "The message shown for the files rejected for being already in the file list while AllowDuplicates is disabled."
},
new()
{
@@ -93,66 +211,133 @@ public partial class BitFileUploadDemo
Description = "The message shown for failed file uploads."
},
new()
+ {
+ Name = "FileSizeFormatter",
+ Type = "Func?",
+ DefaultValue = "null",
+ Description = "Custom formatter of the file size shown under the name of each file item. Receives the size of the file in bytes and returns the text to display, which is the place to localize the units or to switch between the binary and the decimal bases. When not set, a built-in humanizer is used.",
+ },
+ new()
+ {
+ Name = "FileValidator",
+ Type = "Func?",
+ DefaultValue = "null",
+ Description = "Custom validation function called for each newly selected file after the built-in validations pass. Return an error message to reject the file so it will not be uploaded, or null to accept it.",
+ LinkType = LinkType.Link,
+ Href = "#file-info"
+ },
+ new()
{
Name = "FileViewTemplate",
Type = "RenderFragment?",
DefaultValue = "null",
- Description = "The custom file view template."
+ Description = "Custom Razor template rendering each item of the file list in place of the built-in one, receiving the file as its context with its name, size, progress, speed and status all available. It is only asked for the files that are actually in the list, so a removed file leaves no empty item behind.",
+ LinkType = LinkType.Link,
+ Href = "#file-info"
},
new()
{
Name = "HideFileView",
Type = "bool",
DefaultValue = "false",
- Description = "Hides the file view section of the file upload."
+ Description = "Whether the built-in file list is left unrendered. The files are still selected, validated, uploaded and reported through the Files property and the callbacks - they are simply not drawn, which is what the surrounding page needs when it shows the attachments in a layout of its own."
+ },
+ new()
+ {
+ Name = "HideLabel",
+ Type = "bool",
+ DefaultValue = "false",
+ Description = "Whether to hide the default browse button label from the UI."
},
new()
{
Name = "Label",
Type = "string",
DefaultValue = "Browse",
- Description = "The text of select file button."
+ Description = "The text of the browse button. Setting it to an empty string hides the button altogether."
},
new()
{
Name = "LabelTemplate",
Type = "RenderFragment?",
DefaultValue = "null",
- Description = "A custom razor fragment for select button."
+ Description = "Custom Razor template rendered in place of the browse button, which also replaces the built-in dashed drop indicator living on that button - a custom label should bring its own drag feedback through the Dragging entry of Classes or Styles."
+ },
+ new()
+ {
+ Name = "MaxCount",
+ Type = "int",
+ DefaultValue = "0",
+ Description = "Maximum allowed number of files in the file list (0 for unlimited). Files selected beyond this count are rejected at selection time and will not be uploaded. Only files that pass the other validations consume a slot."
+ },
+ new()
+ {
+ Name = "MaxCountErrorMessage",
+ Type = "string",
+ DefaultValue = "The maximum number of files is exceeded",
+ Description = "Specifies the message shown for the files rejected due to exceeding the maximum number of files."
},
new()
{
Name = "MaxSize",
Type = "long",
DefaultValue = "0",
- Description = "Specifies the maximum size (byte) of the file (0 for unlimited)."
+ Description = "The maximum allowed size in bytes of each file (0 for unlimited). A larger file is rejected at selection time with the MaxSizeErrorMessage and will not be uploaded."
},
new()
{
Name = "MaxSizeErrorMessage",
Type = "string",
DefaultValue = "The file size is larger than the max size",
- Description = "Specifies the message for the failed uploading progress due to exceeding the maximum size."
+ Description = "The message shown for the files rejected for being larger than the MaxSize."
+ },
+ new()
+ {
+ Name = "MaxTotalSize",
+ Type = "long",
+ DefaultValue = "0",
+ Description = "Maximum allowed total size in bytes of all the files of the file list (0 for unlimited). Files pushing the accumulated size beyond this limit are rejected at selection time and will not be uploaded, becoming eligible again once removals free up room. Only files that pass the other validations consume the budget."
+ },
+ new()
+ {
+ Name = "MaxTotalSizeErrorMessage",
+ Type = "string",
+ DefaultValue = "The total size of the files is larger than the max total size",
+ Description = "Specifies the message shown for the files rejected for making the total size of the file list exceed the maximum total size."
+ },
+ new()
+ {
+ Name = "MinSize",
+ Type = "long",
+ DefaultValue = "0",
+ Description = "The minimum allowed size in bytes of each file (0 for no limit). A smaller file is rejected at selection time with the MinSizeErrorMessage and will not be uploaded."
+ },
+ new()
+ {
+ Name = "MinSizeErrorMessage",
+ Type = "string",
+ DefaultValue = "The file size is smaller than the min size",
+ Description = "The message shown for the files rejected for being smaller than the MinSize."
},
new()
{
Name = "Multiple",
Type = "bool",
DefaultValue = "false",
- Description = "Enables multi-file selection."
+ Description = "Whether several files can be handed over at once, both through the file dialog and through a single drop or paste. Without it a multi-file drop or paste is trimmed down to its first file."
},
new()
{
Name = "NotAllowedExtensionErrorMessage",
Type = "string",
DefaultValue = "The file type is not allowed",
- Description = "Specifies the message for the failed uploading progress due to the allowed extensions."
+ Description = "The message shown for the files rejected for not matching any entry of AllowedExtensions."
},
new()
{
Name = "OnAllUploadsComplete",
Type = "EventCallback",
- Description = "Callback for when all files are uploaded.",
+ Description = "Callback for when every file of a batch that actually started uploading has reached a terminal state - completed, failed, canceled, removed or rejected by the validations. A selection that was never asked to upload never settles, so it never reports itself as complete.",
LinkType = LinkType.Link,
Href = "#file-info"
},
@@ -160,7 +345,15 @@ public partial class BitFileUploadDemo
{
Name = "OnChange",
Type = "EventCallback",
- Description = "Callback for when file or files status change.",
+ Description = "Callback for when file or files status change. It is invoked with the whole file list right after a selection, and with only the file that changed whenever a single status changes afterwards, so the current state of the batch is better read back from the Files property than from the argument.",
+ LinkType = LinkType.Link,
+ Href = "#file-info"
+ },
+ new()
+ {
+ Name = "OnInvalid",
+ Type = "EventCallback",
+ Description = "Callback invoked right after OnChange whenever a selection carries at least one file rejected by the validations, providing an array of only the rejected files along with their messages.",
LinkType = LinkType.Link,
Href = "#file-info"
},
@@ -168,7 +361,7 @@ public partial class BitFileUploadDemo
{
Name = "OnProgress",
Type = "EventCallback",
- Description = "Callback for when the file upload is progressed.",
+ Description = "Callback for when the upload of a file makes progress, invoked on every progress report of the browser with the file whose TotalUploadedSize, UploadSpeed and RemainingTime have just moved.",
LinkType = LinkType.Link,
Href = "#file-info"
},
@@ -176,7 +369,7 @@ public partial class BitFileUploadDemo
{
Name = "OnRemoveComplete",
Type = "EventCallback",
- Description = "Callback for when a remove file is done.",
+ Description = "Callback for when a file has been removed, whether it was dropped from the list on this side or deleted from the server through the RemoveUrl.",
LinkType = LinkType.Link,
Href = "#file-info"
},
@@ -184,7 +377,7 @@ public partial class BitFileUploadDemo
{
Name = "OnRemoveFailed",
Type = "EventCallback",
- Description = "Callback for when a remove file is failed.",
+ Description = "Callback for when the removal of a file from the server failed, leaving the file in the list with the FailedRemoveMessage rather than pretending it is gone.",
LinkType = LinkType.Link,
Href = "#file-info"
},
@@ -192,7 +385,7 @@ public partial class BitFileUploadDemo
{
Name = "OnUploading",
Type = "EventCallback",
- Description = "Callback for when a file upload is about to start.",
+ Description = "Callback for when a file upload is about to start, invoked before the request that carries its first byte and therefore once per run of the file rather than once per chunk. It is the place to attach the HttpHeaders and the FormFields that belong to this one file, both of which are read again for every request it makes.",
LinkType = LinkType.Link,
Href = "#file-info"
},
@@ -200,7 +393,7 @@ public partial class BitFileUploadDemo
{
Name = "OnUploadComplete",
Type = "EventCallback",
- Description = "Callback for when a file upload is done.",
+ Description = "Callback for when a file has been uploaded successfully, with the body of the server response of its last request on its Message.",
LinkType = LinkType.Link,
Href = "#file-info"
},
@@ -208,11 +401,18 @@ public partial class BitFileUploadDemo
{
Name = "OnUploadFailed",
Type = "EventCallback",
- Description = "Callback for when an upload file is failed.",
+ Description = "Callback for when the upload of a file failed for good - after the automatic retries, if any, have all been spent - with the body of the failed response on its Message.",
LinkType = LinkType.Link,
Href = "#file-info"
},
new()
+ {
+ Name = "PauseButtonTitle",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "The tooltip of the pause upload button, which is also used as the prefix of its accessible label (e.g., \"Pause report.pdf\"). Defaults to \"Pause\".",
+ },
+ new()
{
Name = "PauseIcon",
Type = "BitIconInfo?",
@@ -231,6 +431,29 @@ public partial class BitFileUploadDemo
Href = "https://blazorui.bitplatform.dev/iconography"
},
new()
+ {
+ Name = "QueuedUploadMessage",
+ Type = "string",
+ DefaultValue = "Waiting to upload",
+ Description = "The message shown for the files waiting in the queue for a free slot of the ConcurrentUploads limit, which is what tells a file that is about to start apart from one that was never asked to upload."
+ },
+ new()
+ {
+ Name = "ReadImageDimensions",
+ Type = "bool",
+ DefaultValue = "false",
+ Description = "Whether to read the pixel dimensions of the selected image files, filling the Width and Height of each of them before the validations run, so that a FileValidator can reject an image by its dimensions. Reading them means decoding every image in the browser, which costs time and memory on a large selection, so it is off by default.",
+ LinkType = LinkType.Link,
+ Href = "#file-info"
+ },
+ new()
+ {
+ Name = "RemoveButtonTitle",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "The tooltip of the remove file button, which is also used as the prefix of its accessible label (e.g., \"Remove report.pdf\"). Defaults to \"Remove\".",
+ },
+ new()
{
Name = "RemoveIcon",
Type = "BitIconInfo?",
@@ -253,51 +476,131 @@ public partial class BitFileUploadDemo
Name = "RemoveRequestHttpHeaders",
Type = "Dictionary?",
DefaultValue = "null",
- Description = "Custom http headers for remove request."
+ Description = "Custom HTTP headers attached to the remove request."
},
new()
{
Name = "RemoveRequestHttpHeadersProvider",
Type = "Func>>?",
DefaultValue = "null",
- Description = "The provider function to create the http headers for remove request."
+ Description = "The provider function creating the HTTP headers of the remove request, invoked right before the request goes out and taking precedence over RemoveRequestHttpHeaders."
+ },
+ new()
+ {
+ Name = "RemoveRequestHttpMethod",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "The HTTP method of the remove request (e.g., \"POST\"). Defaults to \"DELETE\".",
},
new()
{
Name = "RemoveRequestQueryStrings",
Type = "Dictionary?",
DefaultValue = "null",
- Description = "Custom query strings for remove request."
+ Description = "Custom query strings appended to the URL of the remove request."
},
new()
{
Name = "RemoveRequestQueryStringsProvider",
Type = "Func>>?",
DefaultValue = "null",
- Description = "The provider function to create the query strings for remove request."
+ Description = "The provider function creating the query strings of the remove request, invoked right before the request goes out and taking precedence over RemoveRequestQueryStrings."
},
new()
{
Name = "RemoveUrl",
Type = "string?",
DefaultValue = "null",
- Description = "URL of the server endpoint removing the files."
+ Description = "URL of the server endpoint removing the files. A file whose bytes already reached the server is deleted from it through a request to this URL carrying its name as a query string and its id in the BIT_FILE_ID header; a file that never uploaded is simply dropped from the list without one."
+ },
+ new()
+ {
+ Name = "RetryButtonTitle",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "The tooltip of the retry button of a failed or canceled file, which is also used as the prefix of its accessible label (e.g., \"Retry report.pdf\"). Falls back to UploadButtonTitle and then to \"Retry\".",
+ },
+ new()
+ {
+ Name = "RetryIcon",
+ Type = "BitIconInfo?",
+ DefaultValue = "null",
+ Description = "The icon to use for the retry button of a failed or canceled file using custom CSS classes for external icon libraries. Takes precedence over RetryIconName when both are set.",
+ LinkType = LinkType.Link,
+ Href = "#bit-icon-info"
+ },
+ new()
+ {
+ Name = "RetryIconName",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "The name of the icon to use for the retry button of a failed or canceled file from the built-in Fluent UI icons. Falls back to UploadIconName and then to Refresh.",
+ LinkType = LinkType.Link,
+ Href = "https://blazorui.bitplatform.dev/iconography"
+ },
+ new()
+ {
+ Name = "ShouldAutoRetry",
+ Type = "Func?",
+ DefaultValue = "null",
+ Description = "Decides whether a failed upload is worth retrying automatically, receiving the file and the HTTP status code of the failed request (0 for a network error, a timeout or an aborted request) and returning true to spend one of the AutoRetries attempts on it. When not set, a built-in rule retries network errors, timeouts, 408, 429 and the 5xx server errors, and gives up right away on the other 4xx.",
+ LinkType = LinkType.Link,
+ Href = "#file-info"
+ },
+ new()
+ {
+ Name = "ShowPreview",
+ Type = "bool",
+ DefaultValue = "false",
+ Description = "Whether a thumbnail of every selected image is shown at the head of its file item, produced entirely in the browser from an object URL that is handed back as soon as the file is removed or the component is reset. The same URL is on the PreviewUrl of each file."
},
new()
{
Name = "ShowRemoveButton",
Type = "bool",
DefaultValue = "false",
- Description = "URL of the server endpoint removing the files."
+ Description = "Whether each settled file item offers a remove button, which drops a file that never uploaded from the list and deletes an uploaded one from the server through the RemoveUrl."
+ },
+ new()
+ {
+ Name = "Size",
+ Type = "BitSize?",
+ DefaultValue = "null",
+ Description = "The size of the file upload, applied to the browse button and the file list items.",
+ LinkType = LinkType.Link,
+ Href = "#size-enum"
+ },
+ new()
+ {
+ Name = "Styles",
+ Type = "BitFileUploadClassStyles?",
+ DefaultValue = "null",
+ Description = "Custom CSS styles for different parts of the BitFileUpload.",
+ LinkType = LinkType.Link,
+ Href = "#class-styles"
},
new()
{
Name = "SuccessfulUploadMessage",
Type = "string",
- DefaultValue = "File upload successful",
+ DefaultValue = "File upload succeeded",
Description = "The message shown for successful file uploads."
},
new()
+ {
+ Name = "UploadButtonTitle",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "The tooltip of the upload button, which is also used as the prefix of its accessible label (e.g., \"Upload report.pdf\"). Defaults to \"Upload\".",
+ },
+ new()
+ {
+ Name = "UploadFormFieldName",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "The name of the form field carrying the file content in the upload request. Defaults to \"file\".",
+ },
+ new()
{
Name = "UploadIcon",
Type = "BitIconInfo?",
@@ -316,46 +619,85 @@ public partial class BitFileUploadDemo
Href = "https://blazorui.bitplatform.dev/iconography"
},
new()
+ {
+ Name = "UploadRequestFormFields",
+ Type = "Dictionary?",
+ DefaultValue = "null",
+ Description = "Additional multipart form fields sent alongside the content of every file in its upload requests, for the endpoints that read their metadata from the form rather than from the query string. The FormFields of a file is merged over these for that file.",
+ LinkType = LinkType.Link,
+ Href = "#file-info"
+ },
+ new()
{
Name = "UploadRequestHttpHeaders",
Type = "Dictionary?",
DefaultValue = "null",
- Description = "Custom http headers for upload request."
+ Description = "Custom HTTP headers attached to the upload requests, fixed at selection time."
},
new()
{
Name = "UploadRequestHttpHeadersProvider",
Type = "Func>>?",
DefaultValue = "null",
- Description = "The provider function to create the http headers for upload request."
+ Description = "The provider function to create the http headers for upload request. Unlike UploadRequestHttpHeaders, it is invoked right before every single request - each file and each chunk - which is what lets it hand over a freshly minted access token."
+ },
+ new()
+ {
+ Name = "UploadRequestHttpMethod",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "The HTTP method of the upload request (e.g., \"PUT\"). Defaults to \"POST\".",
},
new()
{
Name = "UploadRequestQueryStrings",
Type = "Dictionary?",
DefaultValue = "null",
- Description = "Custom query strings for upload request."
+ Description = "Custom query strings appended to the URL of the upload requests, fixed at selection time."
},
new()
{
Name = "UploadRequestQueryStringsProvider",
Type = "Func>>?",
DefaultValue = "null",
- Description = "The provider function to create the query strings for upload request."
+ Description = "The provider function to create the query strings for upload request. Unlike UploadRequestQueryStrings, it is invoked right before every single request - each file and each chunk - which is what lets it hand over a value that does not survive a batch."
+ },
+ new()
+ {
+ Name = "UploadTimeout",
+ Type = "TimeSpan?",
+ DefaultValue = "null",
+ Description = "The timeout of the upload request for each file or chunk. When it elapses the upload of the file fails. Set to null (the default) for no timeout.",
},
new()
{
Name = "UploadUrl",
Type = "string?",
DefaultValue = "null",
- Description = "URL of the server endpoint receiving the files."
+ Description = "URL of the server endpoint receiving the files, fixed at selection time. Use UploadUrlProvider instead for an endpoint that has to be minted per request."
},
new()
{
Name = "UploadUrlProvider",
Type = "Func>?",
DefaultValue = "null",
- Description = "The provider function to create the URL of the server endpoint receiving the files."
+ Description = "The provider function to create the URL of the server endpoint receiving the files. Unlike UploadUrl, it is invoked right before every single request - each file and each chunk - which is what lets it hand over a presigned URL that expires."
+ },
+ new()
+ {
+ Name = "Variant",
+ Type = "BitVariant?",
+ DefaultValue = "null",
+ Description = "The visual variant of the browse button, which decides how much of the Color it carries: a full fill, only an outline, or neither.",
+ LinkType = LinkType.Link,
+ Href = "#variant-enum"
+ },
+ new()
+ {
+ Name = "WithCredentials",
+ Type = "bool",
+ DefaultValue = "false",
+ Description = "Whether the upload request is sent with credentials such as cookies and authorization headers for cross-origin requests (the withCredentials flag of the underlying XMLHttpRequest).",
}
];
@@ -399,14 +741,14 @@ public partial class BitFileUploadDemo
new()
{
Name = "ContentType",
- Type = "String",
+ Type = "string",
DefaultValue = "string.Empty",
Description = "The Content-Type of the selected file."
},
new()
{
Name = "Name",
- Type = "String",
+ Type = "string",
DefaultValue = "string.Empty",
Description = "The name of the selected file."
},
@@ -419,7 +761,7 @@ public partial class BitFileUploadDemo
new()
{
Name = "FileId",
- Type = "String",
+ Type = "string",
DefaultValue = "string.Empty",
Description = "The file ID of the selected file, this is a GUID."
},
@@ -430,6 +772,18 @@ public partial class BitFileUploadDemo
Description = "The index of the selected file."
},
new()
+ {
+ Name = "LastModified",
+ Type = "long",
+ Description = "The last modified time of the file reported by the browser, in milliseconds since the Unix epoch."
+ },
+ new()
+ {
+ Name = "LastModifiedDate",
+ Type = "DateTimeOffset",
+ Description = "The last modified time of the file reported by the browser, as a DateTimeOffset."
+ },
+ new()
{
Name = "LastChunkUploadedSize",
Type = "long",
@@ -442,11 +796,53 @@ public partial class BitFileUploadDemo
Description = "The total uploaded size of the file."
},
new()
+ {
+ Name = "PreviewUrl",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "An object URL of the file content that can be used as the source of an img element to preview image files. This is only populated for image files when the ShowPreview parameter of the BitFileUpload is enabled."
+ },
+ new()
+ {
+ Name = "Width",
+ Type = "int?",
+ DefaultValue = "null",
+ Description = "The width of the image in pixels, only populated for decodable image files when the ReadImageDimensions parameter of the BitFileUpload is enabled. It is null for anything else."
+ },
+ new()
+ {
+ Name = "Height",
+ Type = "int?",
+ DefaultValue = "null",
+ Description = "The height of the image in pixels, only populated for decodable image files when the ReadImageDimensions parameter of the BitFileUpload is enabled. It is null for anything else."
+ },
+ new()
+ {
+ Name = "UploadSpeed",
+ Type = "double?",
+ DefaultValue = "null",
+ Description = "The observed speed of the upload of this file in bytes per second, measured over the request currently in flight. It is null while the file is not uploading and until the first progress report arrives."
+ },
+ new()
+ {
+ Name = "RemainingTime",
+ Type = "TimeSpan?",
+ DefaultValue = "null",
+ Description = "The estimated time left before the upload of this file completes, derived from the UploadSpeed and the bytes still to be sent. It is null whenever the speed is unknown."
+ },
+ new()
+ {
+ Name = "IsQueued",
+ Type = "bool",
+ DefaultValue = "false",
+ Description = "Whether the file is waiting in the upload queue for a free slot of the ConcurrentUploads limit, which is what tells a file that is about to start apart from one that was never asked to upload."
+ },
+ new()
{
Name = "Message",
Type = "string?",
DefaultValue = "null",
- Description = "The error message is issued during file validation before uploading the file or at the time of uploading."
+ Description = "The message attached to the current Status of the file: the reason it was rejected by the validations before the upload, or the body of the server response of its upload or removal."
},
new()
{
@@ -462,14 +858,226 @@ public partial class BitFileUploadDemo
Name = "HttpHeaders",
Type = "Dictionary?",
DefaultValue = "null",
- Description = "The HTTP header at upload file."
+ Description = "Additional custom HTTP headers attached to the upload requests of this specific file (e.g., set from the OnUploading callback)."
+ },
+ new()
+ {
+ Name = "FormFields",
+ Type = "Dictionary?",
+ DefaultValue = "null",
+ Description = "Additional multipart form fields sent alongside the content of this specific file in its upload requests, merged over the ones of the UploadRequestFormFields parameter of the BitFileUpload. The natural place to fill it in is the OnUploading callback."
}
]
- }
- ];
-
- private readonly List componentSubEnums =
- [
+ },
+ new()
+ {
+ Id = "class-styles",
+ Title = "BitFileUploadClassStyles",
+ Parameters =
+ [
+ new()
+ {
+ Name = "Root",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the root element of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "Dragging",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the root element while files are being dragged over the BitFileUpload."
+ },
+ new()
+ {
+ Name = "Label",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the browse button (label) of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "Description",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the description (hint) of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "FileList",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the file list container of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "FileItem",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "Preview",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the image preview thumbnail of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "FileName",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the file name of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "FileSize",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the file size of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "Percentage",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the upload percent indicator of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "ProgressBarContainer",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the progress bar container of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "ProgressBar",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the progress bar of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "StatusMessage",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the status message of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "UploadButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the upload button of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "UploadIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the upload button icon of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "PauseButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the pause button of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "PauseIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the pause button icon of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "CancelButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the cancel button of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "CancelIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the cancel button icon of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "RemoveButton",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the remove button of each file item of the BitFileUpload."
+ },
+ new()
+ {
+ Name = "RemoveIcon",
+ Type = "string?",
+ DefaultValue = "null",
+ Description = "Custom CSS classes/styles for the remove button icon of each file item of the BitFileUpload."
+ },
+ ]
+ }
+ ];
+
+ private readonly List componentSubEnums =
+ [
+ new()
+ {
+ Id = "color-enum",
+ Name = "BitColor",
+ Description = "Defines the general colors available in the bit BlazorUI.",
+ Items =
+ [
+ new() { Name = "Primary", Description = "Primary general color.", Value = "0" },
+ new() { Name = "Secondary", Description = "Secondary general color.", Value = "1" },
+ new() { Name = "Tertiary", Description = "Tertiary general color.", Value = "2" },
+ new() { Name = "Info", Description = "Info general color.", Value = "3" },
+ new() { Name = "Success", Description = "Success general color.", Value = "4" },
+ new() { Name = "Warning", Description = "Warning general color.", Value = "5" },
+ new() { Name = "SevereWarning", Description = "SevereWarning general color.", Value = "6" },
+ new() { Name = "Error", Description = "Error general color.", Value = "7" },
+ new() { Name = "PrimaryBackground", Description = "Primary background color.", Value = "8" },
+ new() { Name = "SecondaryBackground", Description = "Secondary background color.", Value = "9" },
+ new() { Name = "TertiaryBackground", Description = "Tertiary background color.", Value = "10" },
+ new() { Name = "PrimaryForeground", Description = "Primary foreground color.", Value = "11" },
+ new() { Name = "SecondaryForeground", Description = "Secondary foreground color.", Value = "12" },
+ new() { Name = "TertiaryForeground", Description = "Tertiary foreground color.", Value = "13" },
+ new() { Name = "PrimaryBorder", Description = "Primary border color.", Value = "14" },
+ new() { Name = "SecondaryBorder", Description = "Secondary border color.", Value = "15" },
+ new() { Name = "TertiaryBorder", Description = "Tertiary border color.", Value = "16" }
+ ]
+ },
+ new()
+ {
+ Id = "size-enum",
+ Name = "BitSize",
+ Description = "Defines the general sizes available in the bit BlazorUI.",
+ Items =
+ [
+ new() { Name = "Small", Description = "The small size file upload.", Value = "0" },
+ new() { Name = "Medium", Description = "The medium size file upload.", Value = "1" },
+ new() { Name = "Large", Description = "The large size file upload.", Value = "2" }
+ ]
+ },
+ new()
+ {
+ Id = "variant-enum",
+ Name = "BitVariant",
+ Description = "Determines the variant of the content that controls the rendered style of the corresponding element(s).",
+ Items =
+ [
+ new() { Name = "Fill", Description = "Fill styled variant.", Value = "0" },
+ new() { Name = "Outline", Description = "Outline styled variant.", Value = "1" },
+ new() { Name = "Text", Description = "Text styled variant.", Value = "2" }
+ ]
+ },
new()
{
Id = "upload-status-enum",
@@ -480,7 +1088,7 @@ public partial class BitFileUploadDemo
new()
{
Name = "Pending",
- Description = "File uploading progress is pended because the server cannot be contacted.",
+ Description = "The file is selected and queued, and its uploading has not started yet.",
Value = "0",
},
new()
@@ -528,7 +1136,7 @@ public partial class BitFileUploadDemo
new()
{
Name = "NotAllowed",
- Description = "The type of uploaded file is not allowed.",
+ Description = "The file is rejected by the validations (size, count, type or a custom rule) and will not be uploaded.",
Value = "8",
}
]
@@ -540,8 +1148,8 @@ public partial class BitFileUploadDemo
new()
{
Name = "Files",
- Type = "IReadOnlyList?",
- DefaultValue = "null",
+ Type = "IReadOnlyList",
+ DefaultValue = "[]",
Description = "A list of all of the selected files to upload.",
LinkType = LinkType.Link,
Href = "#file-info"
@@ -550,7 +1158,7 @@ public partial class BitFileUploadDemo
{
Name = "UploadStatus",
Type = "BitFileUploadStatus",
- DefaultValue = "",
+ DefaultValue = "Pending",
Description = "The current status of the file uploader.",
LinkType = LinkType.Link,
Href = "#upload-status-enum"
@@ -570,99 +1178,170 @@ public partial class BitFileUploadDemo
Description = "Indicates that the file upload is in the middle of removing a file.",
},
new()
+ {
+ Name = "TotalSize",
+ Type = "long",
+ DefaultValue = "0",
+ Description = "The total size in bytes of all the files of the batch, excluding the removed ones and the ones rejected by the validations.",
+ },
+ new()
+ {
+ Name = "TotalUploadedSize",
+ Type = "long",
+ DefaultValue = "0",
+ Description = "The total uploaded size in bytes across all the files of the batch, excluding the removed ones and the ones rejected by the validations.",
+ },
+ new()
+ {
+ Name = "OverallUploadProgress",
+ Type = "int",
+ DefaultValue = "0",
+ Description = "The overall upload progress of the batch as a percentage (0 to 100), combining the progress of all the files weighted by their size.",
+ },
+ new()
+ {
+ Name = "TotalUploadSpeed",
+ Type = "double?",
+ DefaultValue = "null",
+ Description = "The combined speed in bytes per second of every file of the batch that is uploading right now, which is what the connection as a whole is carrying. It is null while nothing is on the wire.",
+ },
+ new()
+ {
+ Name = "OverallRemainingTime",
+ Type = "TimeSpan?",
+ DefaultValue = "null",
+ Description = "The estimated time left before the whole batch is uploaded, derived from the TotalUploadSpeed and the bytes of the batch that are still to be sent. It is null whenever nothing is uploading and the speed is therefore unknown.",
+ },
+ new()
{
Name = "Upload",
Type = "(BitFileInfo? fileInfo = null, string? uploadUrl = null) => Task",
DefaultValue = "",
- Description = "Starts uploading the file(s).",
+ Description = "Starts uploading a specific file, or all files when no file is specified, resuming a paused or chunked file from the last chunk that made it through and retrying a failed or canceled one with a fresh budget of automatic retries. A file whose request is already on the wire is left running rather than being started over.",
LinkType = LinkType.Link,
Href = "#file-info"
},
new()
{
Name = "PauseUpload",
- Type = "(BitFileInfo? fileInfo = null) => void",
+ Type = "(BitFileInfo? fileInfo = null) => Task",
DefaultValue = "",
- Description = "Pauses the upload.",
+ Description = "Pauses the upload of a specific file, or all files when no file is specified, applying to the files that are on their way: an in-progress file aborts its in-flight request and keeps the bytes that made it, and a file waiting in the concurrency queue is taken out of it. Both can be resumed later through the Upload method. A file that was never asked to upload, and one that has already settled, are left as they are.",
LinkType = LinkType.Link,
Href = "#file-info"
},
new()
{
Name = "CancelUpload",
- Type = "(BitFileInfo? fileInfo = null) => void",
+ Type = "(BitFileInfo? fileInfo = null) => Task",
DefaultValue = "",
- Description = "Cancels the upload.",
+ Description = "Cancels the upload of a specific file, or all files when no file is specified, settling every file that is still in play - running, queued, paused or merely selected - as canceled right away and aborting the in-flight request of a running one. A file that has already settled is left alone, and a canceled file can be started again later.",
LinkType = LinkType.Link,
Href = "#file-info"
},
new()
{
Name = "RemoveFile",
- Type = "(BitFileInfo? fileInfo = null) => void",
+ Type = "(BitFileInfo? fileInfo = null) => Task",
DefaultValue = "",
- Description = "Removes a file by calling the RemoveUrl if the file upload is already started.",
+ Description = "Removes a specific file, or all files when no file is specified, deleting the (partially) uploaded ones from the server through the RemoveUrl.",
LinkType = LinkType.Link,
Href = "#file-info"
},
new()
{
Name = "Browse",
- Type = "Task",
+ Type = "() => Task",
DefaultValue = "",
Description = "Opens a file selection dialog.",
},
new()
{
Name = "Reset",
- Type = "Task",
+ Type = "() => Task",
DefaultValue = "",
- Description = "Resets the file upload.",
+ Description = "Resets the file upload, clearing the file list and the upload state.",
}
];
- [Inject] private IJSRuntime _js { get; set; } = default!;
[Inject] private IConfiguration _configuration { get; set; } = default!;
+ private bool allowDrop = true;
+ private bool allowPaste = true;
+ private int tokenRequestCount;
+ private BitVariant variant = BitVariant.Fill;
+ private string onInvalidText = string.Empty;
private string onAllUploadsCompleteText = "No File";
private string UploadUrl => $"{_configuration.GetApiServerAddress()}FileUpload/UploadNonChunkedFile";
private string ChunkedUploadUrl => $"{_configuration.GetApiServerAddress()}FileUpload/UploadChunkedFile";
+ private string NonExistingUploadUrl => $"{_configuration.GetApiServerAddress()}FileUpload/MissingUploadEndpoint";
private string RemoveUrl => $"{_configuration.GetApiServerAddress()}FileUpload/RemoveFile";
private BitFileUpload bitFileUpload = default!;
+ private BitFileUpload? overallFileUpload;
+ private BitFileUpload? speedFileUpload;
+ private BitFileUpload? hiddenViewFileUpload;
+
+ // OnChange reports a single file when its status changes and the whole selection when files are picked,
+ // so the summary is read back from the Files property instead of from the argument.
+ private IEnumerable HiddenViewFiles =>
+ hiddenViewFileUpload?.Files.Where(f => f.Status != BitFileUploadStatus.Removed) ?? [];
+
+ private IEnumerable SpeedFiles =>
+ speedFileUpload?.Files.Where(f => f.Status != BitFileUploadStatus.Removed) ?? [];
+
private BitFileUpload bitFileUploadWithBrowseFile = default!;
private bool FileUploadIsEmpty() => !bitFileUpload.Files?.Any(f => f.Status != BitFileUploadStatus.Removed) ?? true;
- private async Task HandleUploadOnClick()
+ private static string? ValidateEmptyFile(BitFileInfo file)
{
- if (bitFileUpload.Files is null) return;
+ return file.Size == 0 ? "Empty files cannot be uploaded." : null;
+ }
- await bitFileUpload.Upload();
+ private static string? ValidateImageDimensions(BitFileInfo file)
+ {
+ // an image the browser could not decode has no dimensions to judge, which is not the same
+ // as failing the rule, so it is let through for the other validations to deal with.
+ if (file.Width is null || file.Height is null) return null;
+
+ if (file.Width < 200 || file.Height < 200) return "The image is smaller than 200x200 pixels.";
+
+ if (file.Width > 4000 || file.Height > 4000) return "The image is larger than 4000x4000 pixels.";
+
+ return null;
+ }
+
+ private Task> GetFreshAuthHeaders()
+ {
+ // a provider is called once per request - per chunk in the chunked mode - which is what makes it
+ // the right place for a token that would have gone stale by the time a long upload reaches its end.
+ tokenRequestCount++;
+
+ return Task.FromResult(new Dictionary { { "Authorization", $"Bearer token-{tokenRequestCount}" } });
}
- private async Task HandleRemoveOnClick()
+ private static string? AnnounceUploads(IReadOnlyList files)
+ {
+ var completed = files.Count(f => f.Status == BitFileUploadStatus.Completed);
+
+ return $"{files.Count} attachment(s), {completed} uploaded so far.";
+ }
+
+ private async Task HandleUploadOnClick()
{
if (bitFileUpload.Files is null) return;
- await bitFileUpload.RemoveFile();
+ await bitFileUpload.Upload();
}
private static int GetFileUploadPercent(BitFileInfo file)
{
- int uploadedPercent;
- if (file.TotalUploadedSize >= file.Size)
- {
- uploadedPercent = 100;
- }
- else
- {
- uploadedPercent = (int)((file.TotalUploadedSize + file.LastChunkUploadedSize) / (float)file.Size * 100);
- }
+ if (file.Size == 0 || file.TotalUploadedSize >= file.Size) return 100;
- return uploadedPercent;
+ return (int)((file.TotalUploadedSize + file.LastChunkUploadedSize) / (float)file.Size * 100);
}
private static string GetFileUploadSize(BitFileInfo file)
@@ -685,19 +1364,12 @@ private static string GetFileUploadSize(BitFileInfo file)
{
BitFileUploadStatus.Completed => bitFileUpload.SuccessfulUploadMessage,
BitFileUploadStatus.Failed => bitFileUpload.FailedUploadMessage,
- BitFileUploadStatus.NotAllowed => IsFileTypeNotAllowed(file) ? bitFileUpload.NotAllowedExtensionErrorMessage : bitFileUpload.MaxSizeErrorMessage,
+ BitFileUploadStatus.Canceled => bitFileUpload.CanceledUploadMessage,
+ BitFileUploadStatus.RemoveFailed => bitFileUpload.FailedRemoveMessage,
+ BitFileUploadStatus.NotAllowed => file.Message ?? bitFileUpload.NotAllowedExtensionErrorMessage,
_ => string.Empty,
};
- private bool IsFileTypeNotAllowed(BitFileInfo file)
- {
- if (bitFileUpload.Accept is not null) return false;
-
- var fileSections = file.Name.Split('.');
- var extension = $".{fileSections?.Last()}";
- return bitFileUpload.AllowedExtensions.Count > 0 && bitFileUpload.AllowedExtensions.All(ext => ext != "*") && bitFileUpload.AllowedExtensions.All(ext => ext != extension);
- }
-
private async Task HandleBrowseFileOnClick()
{
await bitFileUploadWithBrowseFile.Browse();
@@ -708,72 +1380,318 @@ private async Task HandleBrowseFileOnClick()
private readonly string example1RazorCode = @"
";
private readonly string example1CsharpCode = @"
-private string UploadUrl = $""/Upload"";";
+private string UploadUrl = ""/Upload"";";
private readonly string example2RazorCode = @"
-";
+
+
+
+";
private readonly string example2CsharpCode = @"
-private string UploadUrl = $""/Upload"";";
+private bool allowDrop = true;
+private bool allowPaste = true;
+private string UploadUrl = ""/Upload"";";
private readonly string example3RazorCode = @"
-";
+
+
+
+
+
+ Images only. Up to 2 MB.
+
+";
private readonly string example3CsharpCode = @"
-private string UploadUrl = $""/Upload"";";
+private string UploadUrl = ""/Upload"";";
private readonly string example4RazorCode = @"
-";
+";
private readonly string example4CsharpCode = @"
-private string UploadUrl = $""/Upload"";";
+private string UploadUrl = ""/Upload"";";
private readonly string example5RazorCode = @"
-";
+";
private readonly string example5CsharpCode = @"
-private string UploadUrl = $""/Upload"";";
+private string UploadUrl = ""/Upload"";";
private readonly string example6RazorCode = @"
-";
+";
private readonly string example6CsharpCode = @"
-private string UploadUrl = $""/Upload"";";
+private string UploadUrl = ""/Upload"";";
private readonly string example7RazorCode = @"
- { "".gif"","".jpg"","".mp4"" })"" />";
+";
private readonly string example7CsharpCode = @"
-private string UploadUrl = $""/Upload"";";
+private string UploadUrl = ""/Upload"";";
private readonly string example8RazorCode = @"
-";
+";
private readonly string example8CsharpCode = @"
-private string UploadUrl = $""/Upload"";
-private string RemoveUrl = $""/Remove"";";
+private string UploadUrl = ""/Upload"";
+private string RemoveUrl = ""/Remove"";";
private readonly string example9RazorCode = @"
+
+
+
+
+
+
+";
+ private readonly string example9CsharpCode = @"
+private string UploadUrl = ""/Upload"";
+private string RemoveUrl = ""/Remove"";";
+
+ private readonly string example10RazorCode = @"
+
+
+ { "".gif"","".jpg"","".mp4"" })"" />
+
+ { ""image/*"", ""application/pdf"" })"" />";
+ private readonly string example10CsharpCode = @"
+private string UploadUrl = ""/Upload"";";
+
+ private readonly string example11RazorCode = @"
+";
+ private readonly string example11CsharpCode = @"
+private string UploadUrl = ""/Upload"";
+private string RemoveUrl = ""/Remove"";";
+
+ private readonly string example12RazorCode = @"
+";
+ private readonly string example12CsharpCode = @"
+private string UploadUrl = ""/Upload"";
+
+private static string? ValidateEmptyFile(BitFileInfo file)
+{
+ return file.Size == 0 ? ""Empty files cannot be uploaded."" : null;
+}";
+
+ private readonly string example13RazorCode = @"
+";
+ private readonly string example13CsharpCode = @"
+private string UploadUrl = ""/Upload"";";
+
+ private readonly string example14RazorCode = @"
+
+
+";
+ private readonly string example14CsharpCode = @"
+private string UploadUrl = ""/Upload"";";
+
+ private readonly string example15RazorCode = @"
+";
+ private readonly string example15CsharpCode = @"
+private string UploadUrl = ""/Upload"";
+private string RemoveUrl = ""/Remove"";";
+
+ private readonly string example16RazorCode = @"
+
+
+
+
+
+
+
+
+ @file.Name
+ @(file.Width is null ? ""unknown size"" : $""{file.Width} x {file.Height}"")
+
+
+";
+ private readonly string example16CsharpCode = @"
+private string UploadUrl = ""/Upload"";
+
+private static string? ValidateImageDimensions(BitFileInfo file)
+{
+ // an image the browser could not decode has no dimensions to judge, which is not the same
+ // as failing the rule, so it is let through for the other validations to deal with.
+ if (file.Width is null || file.Height is null) return null;
+
+ if (file.Width < 200 || file.Height < 200) return ""The image is smaller than 200x200 pixels."";
+
+ if (file.Width > 4000 || file.Height > 4000) return ""The image is larger than 4000x4000 pixels."";
+
+ return null;
+}";
+
+ private readonly string example17RazorCode = @"
onAllUploadsCompleteText = ""All File Uploaded"")""
+ ShowRemoveButton RemoveUrl=""@RemoveUrl"" />";
+ private readonly string example17CsharpCode = @"
+private string UploadUrl = ""/Upload"";
+private string RemoveUrl = ""/Remove"";";
+
+ private readonly string example18RazorCode = @"
+ onAllUploadsCompleteText = ""All files are uploaded"")""
+ OnInvalid=""@(files => onInvalidText = $""{files.Length} file(s) rejected: {string.Join("", "", files.Select(f => f.Name))}"")""
OnUploading=""@(info => info.HttpHeaders = new Dictionary { {""key1"", ""value1""} })"" />
-
+}";
+ private readonly string example26CsharpCode = @"
+private string UploadUrl = ""/Upload"";
+private BitFileUpload? hiddenViewFileUpload;
+
+// OnChange reports a single file when its status changes and the whole selection when files are picked,
+// so the summary is read back from the Files property instead of from the argument.
+private IEnumerable HiddenViewFiles =>
+ hiddenViewFileUpload?.Files.Where(f => f.Status != BitFileUploadStatus.Removed) ?? [];";
+
+ private readonly string example27RazorCode = @"
-
+
@if (FileUploadIsEmpty())
{
-
+
+
}
@@ -957,8 +1893,12 @@ Browse file
@file.Name
-
-
+
+
@if (file.Status is BitFileUploadStatus.InProgress or BitFileUploadStatus.Pending)
@@ -981,7 +1921,7 @@ Browse file
Max file size: 2 MB