diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs
index 72e6d48e0e6..e64c73fc3e7 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileInfo.cs
@@ -32,7 +32,8 @@ public class BitFileInfo
[JsonPropertyName("fileId")] public string FileId { get; set; } = string.Empty;
///
- /// The index of the selected file.
+ /// The index of the file among the ones picked in this browser, which is what names the transfer behind
+ /// it. A preloaded file was never picked and has no transfer of its own, so its index is -1.
///
[JsonPropertyName("index")] public int Index { get; set; }
@@ -95,6 +96,14 @@ public class BitFileInfo
///
[JsonIgnore] public bool IsQueued { get; internal set; }
+ ///
+ /// Whether the file was handed over through the PreloadedFiles parameter of the BitFileUpload instead of
+ /// being picked in the browser, which is to say it is already on the server: there is no content on this
+ /// side to send, so it is never uploaded, and removing it deletes it from the server through the
+ /// RemoveUrl exactly like a file whose bytes got there through this component.
+ ///
+ [JsonIgnore] public bool IsPreloaded { 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
@@ -128,9 +137,19 @@ public class BitFileInfo
///
/// 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.
+ /// A response body is truncated to its first 8 KB, since it crosses the Blazor Server circuit, whose
+ /// default message size limit an error page of a failing endpoint would otherwise exceed.
///
[JsonIgnore] public string? Message { get; internal set; }
+ ///
+ /// The HTTP status code of the last upload or removal response this file received, which is what tells
+ /// an authorization problem from a payload that was too large or from a server that is temporarily down.
+ /// It is 0 when the request never reached the server at all - a network error, a timeout or an abort -
+ /// and null while no request of this file has come back yet.
+ ///
+ [JsonIgnore] public int? ResponseStatus { get; internal set; }
+
///
/// The status of the file in the BitFileUpload.
///
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor
index cb74776f7f2..a2a9e970f5b 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor
@@ -13,7 +13,8 @@
}
else if (_ShowLabelButton)
{
-
}
@@ -57,7 +65,12 @@
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))
{
-
+ @* the list is named, so that a screen reader user arriving at it is told what it holds rather than
+ only that it is a list of so many items. *@
+
@* 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))
@@ -77,5 +90,45 @@
}
+ @* the batch buttons stay where they are for as long as the list has files picked here, and the ones with
+ nothing to act on right now are marked aria-disabled rather than taken out or natively disabled: the
+ state changes on its own as uploads settle, and a focused button that vanished - or turned disabled -
+ under the keyboard would drop the focus on the document body. *@
+ @if (ShowBatchActions && _HasPickedFiles)
+ {
+ var canUploadAll = _CanUploadAll;
+ var canCancelAll = _CanCancelAll;
+ var canClear = _CanClear;
+
+ @if (AutoUpload is false)
+ {
+
+ }
+
+
+
+ }
+
@_announcement
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 bcab21db0a9..fb04a8a47af 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.razor.cs
@@ -1,4 +1,5 @@
-using System.Globalization;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using System.Text;
using System.Text.Encodings.Web;
@@ -6,14 +7,20 @@ namespace Bit.BlazorUI;
///
/// 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.
+/// drag-and-drop onto the component, a built-in drop area or drop zones of the app's own (flagging a drag the
+/// rules will refuse before it lands), clipboard paste, folder and camera capture selection, image previews,
+/// chunked and resumable uploads, a concurrency limit, pause/cancel, batch actions, 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
+ // A preloaded file has no picked file behind it and so no uploader in JavaScript to be named by an
+ // index. It gets one no uploader can ever have, so that a call meant for it reaches no other file's.
+ private const int PRELOADED_FILE_INDEX = -1;
+
// 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);
@@ -24,14 +31,33 @@ public partial class BitFileUpload : BitComponentBase
private DateTime _lastProgressRender = DateTime.MinValue;
private bool _allowPaste = true;
private bool _expandDirectories;
+ private string? _dropZoneSelector;
private string? _dragClass;
private string? _dragStyle;
+ private string? _rejectClass;
+ private string? _rejectStyle;
+ private string[]? _acceptedMimeTypes;
+ private int _remainingSlots = -1;
private string? _announcement;
private bool _announcementMarker;
private int _removingCount;
+ // The collection of preloaded files the list was last built from, and what was in it, so that the same
+ // files arriving again with every render do not put the ones the user has since removed back.
+ private IReadOnlyCollection? _appliedPreloadedFiles;
+ private string[] _appliedPreloadedSignature = [];
private string _buttonId = default!;
private string _descriptionId = default!;
private ElementReference _inputRef;
+ private ElementReference _labelRef;
+ // The button of which file the focus is to be put on after the next render, with the neighbour to fall
+ // back to. An action taken from inside a file item - removing it, starting, pausing or canceling its
+ // upload - takes the very button that was pressed out of the DOM, and without this the focus would go
+ // with it and land back on the document body, dropping a keyboard user at the top of the page.
+ private bool _pendingFocus;
+ private string? _pendingFocusFileId;
+ private string? _pendingFocusFallbackFileId;
+ private BitFileUploadFocusTarget _pendingFocusTarget;
+ private readonly Dictionary _itemRefs = [];
private List _files = [];
private List _uploadQueue = [];
private long _internalChunkSize = MIN_CHUNK_SIZE;
@@ -46,6 +72,19 @@ public partial class BitFileUpload : BitComponentBase
+ ///
+ /// Gets or sets the cascading parameters for the file upload component.
+ ///
+ ///
+ /// This property receives its value from an ancestor component via Blazor's cascading parameter mechanism.
+ ///
+ /// The intended use is to allow shared configuration or settings to be applied to multiple file upload components through the component.
+ ///
+ [CascadingParameter(Name = BitFileUploadParams.ParamName)]
+ public BitFileUploadParams? CascadingParameters { get; set; }
+
+
+
///
/// 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.
@@ -63,7 +102,9 @@ public partial class BitFileUpload : BitComponentBase
/// 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.
+ /// becoming eligible again once the file it duplicates is removed. A file of
+ /// counts as one already in the list, matched by name and size alone since a server reports no
+ /// modification time, which is what keeps an attachment the record already has from being sent again.
/// The default value is true.
///
[Parameter] public bool AllowDuplicates { get; set; } = true;
@@ -110,6 +151,15 @@ public partial class BitFileUpload : BitComponentBase
///
[Parameter] public TimeSpan? AutoRetryDelay { get; set; }
+ ///
+ /// Custom delay before each automatic retry, which is what turns the fixed
+ /// into a backoff: it receives the file - whose says what the
+ /// server answered - and the number of the attempt about to be made, counting from 1, and returns how
+ /// long to wait before it. Returning null falls back to the , which is also
+ /// what a provider that throws does, so a miscalculated delay never swallows the retry itself.
+ ///
+ [Parameter] public Func? AutoRetryDelayProvider { get; set; }
+
///
/// Calculate the chunk size dynamically based on the user's Internet speed between 512 KB and 10 MB.
///
@@ -155,6 +205,11 @@ public partial class BitFileUpload : BitComponentBase
///
[Parameter] public string? CancelButtonTitle { get; set; }
+ ///
+ /// The text of the "Cancel all" button of the batch actions (see ).
+ ///
+ [Parameter] public string CancelAllText { get; set; } = "Cancel all";
+
///
/// The message shown for canceled file uploads.
///
@@ -187,6 +242,12 @@ public partial class BitFileUpload : BitComponentBase
///
[Parameter] public BitFileUploadClassStyles? Classes { get; set; }
+ ///
+ /// The text of the "Clear" button of the batch actions (see ), which resets
+ /// the component through .
+ ///
+ [Parameter] public string ClearText { get; set; } = "Clear";
+
///
/// 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.
@@ -219,6 +280,18 @@ public partial class BitFileUpload : BitComponentBase
///
[Parameter] public bool Directory { get; set; }
+ ///
+ /// A CSS selector of one or more elements outside the component that accept a drop as well, which is how
+ /// a whole form, a card or the page itself becomes the drop target while the browse button stays where it
+ /// is. The root element of the component is always a drop zone and needs no selector of its own; the
+ /// elements this one names are matched whenever a drag reaches them, so one rendered after the component
+ /// - or replaced later on - is a drop zone from the moment it matches. While files are dragged over any
+ /// of them, all of them (the root included) carry the .Dragging class and the
+ /// .Dragging inline style, and the focus being inside one of them is also what lets
+ /// a paste land in this component.
+ ///
+ [Parameter] public string? DropZoneSelector { get; set; }
+
///
/// The message shown for the files rejected for being already in the file list
/// while is disabled.
@@ -249,6 +322,13 @@ public partial class BitFileUpload : BitComponentBase
///
[Parameter] public Func? FileValidator { get; set; }
+ ///
+ /// The accessible name of the file list, so that a screen reader user landing on it is told what the list
+ /// they are in holds instead of only how many items it has. Set it to an empty string to leave the list
+ /// unnamed. The default value is "Selected files".
+ ///
+ [Parameter] public string FileListAriaLabel { get; set; } = "Selected files";
+
///
/// 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
@@ -273,6 +353,32 @@ public partial class BitFileUpload : BitComponentBase
///
[Parameter] public string Label { get; set; } = "Browse";
+ ///
+ /// The icon of the browse button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ ///
+ /// Use this property to render an icon from external libraries like FontAwesome or Bootstrap Icons.
+ /// For built-in Fluent UI icons, use instead.
+ ///
+ [Parameter] public BitIconInfo? LabelIcon { get; set; }
+
+ ///
+ /// The name of the icon of the browse button from the built-in Fluent UI icons.
+ /// Defaults to CloudUpload in the mode, and to no icon otherwise.
+ ///
+ ///
+ /// For external icon libraries, use instead.
+ ///
+ [Parameter] public string? LabelIconName { get; set; }
+
+ ///
+ /// The position of the icon of the browse button relative to its text: before it (the default) or after it.
+ /// In the mode the icon is stacked above or below the text instead.
+ ///
+ [Parameter, ResetClassBuilder]
+ public BitIconPosition? LabelIconPosition { get; set; }
+
///
/// 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
@@ -426,6 +532,28 @@ public partial class BitFileUpload : BitComponentBase
///
[Parameter] public string? PauseButtonTitle { get; set; }
+ ///
+ /// The status message shown for the files of , which are already on the
+ /// server rather than freshly uploaded and would otherwise read as an upload that just succeeded.
+ ///
+ [Parameter] public string PreloadedFileMessage { get; set; } = "Already uploaded";
+
+ ///
+ /// The files that are already on the server, shown in the list from the start so that a form editing a
+ /// record can present the attachments it already has next to the ones the user is adding. They carry no
+ /// content on this side, so they are never uploaded: each of them starts out completed, counts towards
+ /// and like any other file, and offers the same remove
+ /// button, which deletes it from the server through the with the
+ /// in the BIT_FILE_ID header - so that id is what the server's own id
+ /// belongs in. A pointing at a thumbnail the server serves is
+ /// rendered by just like a locally generated one.
+ /// The list is built from the collection again whenever the files in it change - which files, by their
+ /// , rather than which array they arrive in, so one written inline in the
+ /// markup does not undo what the user has done on every render. The instances are the very ones the list
+ /// then holds, so their is where to read what became of each of them.
+ ///
+ [Parameter] public IReadOnlyCollection? PreloadedFiles { 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.
@@ -541,10 +669,28 @@ public partial class BitFileUpload : BitComponentBase
///
[Parameter] public Func? ShouldAutoRetry { get; set; }
+ ///
+ /// Whether an action bar with "Upload all", "Cancel all" and "Clear" buttons is rendered under the file
+ /// list of picked files. The buttons keep their places, and one with nothing to act on is marked
+ /// aria-disabled rather than removed, so a keyboard user never loses the focus to it. The texts come from
+ /// , and .
+ ///
+ [Parameter] public bool ShowBatchActions { get; set; }
+
+ ///
+ /// Whether the browse button is rendered as a large drop area - a full-width dashed panel with an icon
+ /// over the - instead of a regular button. It is still a real button, so it is reached
+ /// with Tab and opens the file dialog with Enter or Space.
+ ///
+ [Parameter, ResetClassBuilder]
+ public bool ShowDropArea { 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.
+ /// A file that is not an image takes a glyph of its type in a box of the same size instead, so that
+ /// the names of a mixed list stay lined up along one edge.
///
[Parameter] public bool ShowPreview { get; set; }
@@ -570,6 +716,11 @@ public partial class BitFileUpload : BitComponentBase
///
[Parameter] public string SuccessfulUploadMessage { get; set; } = "File upload succeeded";
+ ///
+ /// The text of the "Upload all" button of the batch actions (see ).
+ ///
+ [Parameter] public string UploadAllText { get; set; } = "Upload all";
+
///
/// Gets or sets the icon to use for the upload button using custom CSS classes for external icon libraries.
/// Takes precedence over when both are set.
@@ -935,13 +1086,19 @@ public async Task Browse()
}
///
- /// Resets the file upload.
+ /// Resets the file upload, taking the whole selection and its upload state back to where they started.
+ /// The files of stay: they belong to the record rather than to this
+ /// selection - except the ones already deleted from the server, which nothing on this side brings back.
///
public async Task Reset()
{
if (IsDisposed) return;
+ // a reset undoes what was picked here, and what is on the server was never picked here.
+ var preloaded = _files.Where(f => f.IsPreloaded && f.Status is not BitFileUploadStatus.Removed).ToArray();
+
_files.Clear();
+ _files.AddRange(preloaded);
_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.
@@ -963,9 +1120,7 @@ public async Task Reset()
[JSInvokable("HandleChunkUploadProgress")]
public async Task __HandleChunkUploadProgress(int index, long loaded)
{
- if (index < 0 || index >= _files.Count) return;
-
- var file = _files[index];
+ if (GetFileByUploaderIndex(index) is not { } file) return;
if (file.Status != BitFileUploadStatus.InProgress) return;
file.LastChunkUploadedSize = loaded;
@@ -992,12 +1147,11 @@ public async Task __HandleChunkUploadProgress(int index, long loaded)
[JSInvokable("HandleChunkUpload")]
public async Task __HandleChunkUpload(int fileIndex, int responseStatus, string responseText)
{
- if (fileIndex < 0 || fileIndex >= _files.Count) return;
-
- var file = _files[fileIndex];
+ if (GetFileByUploaderIndex(fileIndex) is not { } file) return;
// whatever this response says, the request it answers is over and the file is free again.
file.IsRequestInFlight = false;
+ file.ResponseStatus = responseStatus;
if (file.Status != BitFileUploadStatus.InProgress) return;
@@ -1008,7 +1162,7 @@ public async Task __HandleChunkUpload(int fileIndex, int responseStatus, string
file.TotalUploadedSize += file.PendingChunkSize;
file.AutoRetryAttempts = 0;
- UpdateChunkSize(fileIndex);
+ UpdateChunkSize(file);
if (file.TotalUploadedSize < file.Size)
{
@@ -1030,7 +1184,7 @@ public async Task __HandleChunkUpload(int fileIndex, int responseStatus, string
{
file.AutoRetryAttempts++;
- if (AutoRetryDelay is { } delay && delay > TimeSpan.Zero)
+ if (GetAutoRetryDelay(file) is { } delay && delay > TimeSpan.Zero)
{
await Task.Delay(delay);
}
@@ -1104,6 +1258,10 @@ protected override void RegisterCssClasses()
BitSize.Large => "bit-upl-lg",
_ => "bit-upl-md"
});
+
+ ClassBuilder.Register(() => ShowDropArea ? "bit-upl-dra" : string.Empty);
+
+ ClassBuilder.Register(() => LabelIconPosition is BitIconPosition.End ? "bit-upl-eni" : string.Empty);
}
protected override void RegisterCssStyles()
@@ -1120,10 +1278,24 @@ protected override Task OnInitializedAsync()
return base.OnInitializedAsync();
}
+ [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BitFileUploadParams))]
protected override async Task OnParametersSetAsync()
{
+ CascadingParameters?.UpdateParameters(this);
+
await base.OnParametersSetAsync();
+ if (ShouldApplyPreloadedFiles())
+ {
+ ApplyPreloadedFiles();
+
+ // the preloaded files take their share of the count and total size budgets, so the files the
+ // user had already picked are judged against the list as it stands with them in it.
+ ApplyListValidations();
+
+ Announce();
+ }
+
if (_dropZoneRef is null) return;
await UpdateDropZone();
@@ -1131,18 +1303,36 @@ protected override async Task OnParametersSetAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
- if (firstRender is false) return;
+ if (firstRender is false)
+ {
+ // the room MaxCount leaves changes with every selection and removal rather than only with the
+ // parameters, so the drop zone is brought up to date after each render; it only calls into
+ // JavaScript when something it judges a drag by has actually moved.
+ if (_dropZoneRef is not null)
+ {
+ await UpdateDropZone();
+ }
+
+ await RestorePendingFocus();
+ return;
+ }
_dotnetObj = DotNetObjectReference.Create(this);
_allowDrop = AllowDrop;
_allowPaste = AllowPaste;
_expandDirectories = Directory;
+ _dropZoneSelector = DropZoneSelector;
_dragClass = GetDragClass();
_dragStyle = Styles?.Dragging;
+ _rejectClass = GetRejectClass();
+ _rejectStyle = Styles?.DraggingRejected;
+ _acceptedMimeTypes = GetDragAcceptedMimeTypes();
+ _remainingSlots = GetRemainingSlots();
_dropZoneRef = await _js.BitFileUploadSetupDragDrop(RootElement, _inputRef, _dragClass, _dragStyle,
- _allowDrop, _allowPaste, _expandDirectories);
+ _allowDrop, _allowPaste, _expandDirectories, _dropZoneSelector,
+ _rejectClass, _rejectStyle, _acceptedMimeTypes, _remainingSlots);
if (IsDisposed) return;
if (_dropZoneRef is null) return;
@@ -1154,6 +1344,99 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
+ // The built-in file items report themselves here so that the focus can be handed to a button of another
+ // item once the button that was pressed is gone. A custom FileViewTemplate registers nothing, which is
+ // exactly right: the focus then falls back to the browse button rather than into markup this component
+ // knows nothing about.
+ internal void RegisterItem(string fileId, _BitFileUploadItem item) => _itemRefs[fileId] = item;
+
+ internal void UnregisterItem(string fileId, _BitFileUploadItem item)
+ {
+ if (_itemRefs.TryGetValue(fileId, out var registered) && ReferenceEquals(registered, item))
+ {
+ _itemRefs.Remove(fileId);
+ }
+ }
+
+ internal async Task HandleItemUpload(BitFileInfo file)
+ {
+ // the upload button is replaced by the pause and cancel pair of a running file.
+ RequestFocus(file, BitFileUploadFocusTarget.Pause);
+
+ await Upload(file);
+ }
+
+ internal async Task HandleItemPause(BitFileInfo file)
+ {
+ // the pause button is replaced by the resume (upload) button of a paused file.
+ RequestFocus(file, BitFileUploadFocusTarget.Upload);
+
+ await PauseUpload(file);
+ }
+
+ internal async Task HandleItemCancel(BitFileInfo file)
+ {
+ // the cancel button is replaced by the retry (upload) button of a canceled file.
+ RequestFocus(file, BitFileUploadFocusTarget.Upload);
+
+ await CancelUpload(file);
+ }
+
+ internal async Task HandleItemRemove(BitFileInfo file)
+ {
+ // the whole item goes away with its remove button, so the focus moves to the item that takes its
+ // place - or to the one before it at the end of the list. A removal that fails leaves the item
+ // where it was, and the focus then stays on the very button that was pressed.
+ var visible = _files.Where(f => f.Status != BitFileUploadStatus.Removed).ToList();
+ var index = visible.IndexOf(file);
+ var neighbor = index < 0 ? null
+ : index + 1 < visible.Count ? visible[index + 1]
+ : index > 0 ? visible[index - 1]
+ : null;
+
+ RequestFocus(file, BitFileUploadFocusTarget.Remove, neighbor);
+
+ await RemoveFile(file);
+ }
+
+ // an aria-disabled button still takes a click, so each handler asks for itself whether it has anything to do.
+ private async Task HandleUploadAll()
+ {
+ if (_CanUploadAll is false) return;
+
+ await Upload();
+ }
+
+ private async Task HandleCancelAll()
+ {
+ if (_CanCancelAll is false) return;
+
+ await CancelUpload();
+ }
+
+ private async Task HandleClear()
+ {
+ if (_CanClear is false) return;
+
+ // the whole list goes away with the action bar, so the focus returns to where a new selection starts.
+ RequestFocus(null, BitFileUploadFocusTarget.Upload);
+
+ await Reset();
+ }
+
+ private bool _HasPickedFiles => _files.Any(f => f.IsPreloaded is false && f.Status is not BitFileUploadStatus.Removed);
+
+ // "Upload all" is offered while a file that nobody has started yet - or that was paused, failed or was
+ // canceled - is waiting for it; a file already on the wire or in the queue has been asked already.
+ private bool _CanUploadAll => AutoUpload is false &&
+ _files.Any(f => f.IsQueued is false && f.Status is not BitFileUploadStatus.InProgress && HasPendingWork(f));
+
+ private bool _CanCancelAll => _files.Any(f => f.IsQueued || f.Status is BitFileUploadStatus.InProgress or BitFileUploadStatus.Paused);
+
+ // clearing mid-transfer would throw away the uploads in flight, so "Clear" waits for the list to settle.
+ private bool _CanClear => IsRemoving is false && _HasPickedFiles &&
+ _files.Any(f => f.IsQueued || f.Status is BitFileUploadStatus.InProgress) is false;
+
internal bool IsFileTypeNotAllowed(BitFileInfo file)
{
if (AllowsAllFileTypes(AllowedExtensions)) return false;
@@ -1165,7 +1448,9 @@ internal string GetStatusMessage(BitFileInfo file)
{
return file.Status switch
{
- BitFileUploadStatus.Completed => SuccessfulUploadMessage,
+ // a file that was already on the server when the list was built did not just finish uploading,
+ // and saying that it did would credit this visit with something that happened long before it.
+ BitFileUploadStatus.Completed => file.IsPreloaded ? PreloadedFileMessage : SuccessfulUploadMessage,
BitFileUploadStatus.Failed => FailedUploadMessage,
BitFileUploadStatus.Canceled => CanceledUploadMessage,
BitFileUploadStatus.RemoveFailed => FailedRemoveMessage,
@@ -1188,30 +1473,128 @@ private void RequestRender()
StateHasChanged();
}
+ private void RequestFocus(BitFileInfo? file, BitFileUploadFocusTarget target, BitFileInfo? fallback = null)
+ {
+ _pendingFocus = true;
+ _pendingFocusTarget = target;
+ _pendingFocusFileId = file?.FileId;
+ _pendingFocusFallbackFileId = fallback?.FileId;
+ }
+
+ private async Task RestorePendingFocus()
+ {
+ if (_pendingFocus is false) return;
+
+ // a removal on its way to the server keeps its item in place, with a spinner where the button that
+ // was pressed used to be, and renders again when it settles. moving the focus on already would take
+ // it away from an item that is still there - and away from the failure message if the removal fails.
+ if (IsRemoving) return;
+
+ _pendingFocus = false;
+
+ var target = _pendingFocusTarget;
+ var fileId = _pendingFocusFileId;
+ var fallbackId = _pendingFocusFallbackFileId;
+
+ _pendingFocusFileId = null;
+ _pendingFocusFallbackFileId = null;
+
+ try
+ {
+ // the item the action was taken on comes first: it is still there whenever the action left it in
+ // place, and the focus then belongs on the button that took over from the one that was pressed.
+ if (fileId is not null && _itemRefs.TryGetValue(fileId, out var item) && await item.TryFocus(target)) return;
+
+ if (fallbackId is not null && _itemRefs.TryGetValue(fallbackId, out var neighbor) && await neighbor.TryFocus(target)) return;
+
+ if (_ShowLabelButton && IsEnabled)
+ {
+ await _labelRef.FocusAsync();
+ }
+ }
+ catch (JSDisconnectedException) { } // we can ignore this exception here
+ catch (JSException) { } // an element taken out of the DOM between the render and this call cannot be focused
+ }
+
+ // The file an uploader of the JavaScript side speaks for. The index is that uploader's own, which is the
+ // position of the file among the ones picked here - the preloaded files sit in the list beside them
+ // without an uploader of their own, so the list cannot simply be indexed into.
+ private BitFileInfo? GetFileByUploaderIndex(int index)
+ {
+ if (index < 0) return null;
+
+ return _files.FirstOrDefault(f => f.IsPreloaded is false && f.Index == index);
+ }
+
private static bool IsCountedInOverallProgress(BitFileInfo file)
{
- return file.Status is not BitFileUploadStatus.NotAllowed and not BitFileUploadStatus.Removed;
+ // a file that is already on the server is not part of this transfer at all, and counting it would
+ // report a batch nobody has started yet as complete and water down the progress of a real one.
+ return file.IsPreloaded is false
+ && file.Status is not BitFileUploadStatus.NotAllowed and not BitFileUploadStatus.Removed;
}
private string GetDragClass() => $"bit-upl-drg {Classes?.Dragging}".Trim();
+ private string GetRejectClass() => $"bit-upl-drj {Classes?.DraggingRejected}".Trim();
+
+ // What a drag in flight can be judged against. A browser exposes the MIME type of a dragged file but
+ // never its name until it is dropped, so the rule can only be checked mid-drag when every entry of it is
+ // a MIME type; an extension anywhere in it leaves the drag unjudged rather than guessed at.
+ private string[]? GetDragAcceptedMimeTypes()
+ {
+ if (AllowsAllFileTypes(AllowedExtensions)) return null;
+
+ var entries = GetNormalizedExtensions(AllowedExtensions).ToArray();
+
+ if (entries.Length == 0 || entries.Any(e => e.Contains('/') is false)) return null;
+
+ return entries;
+ }
+
+ // How many more files the list can take before MaxCount turns them away (-1 for no limit). A selection
+ // that does not append replaces everything picked here, so only the record's own files count against it.
+ private int GetRemainingSlots()
+ {
+ if (MaxCount <= 0) return -1;
+
+ var taken = _files.Count(f => f.Status is not BitFileUploadStatus.NotAllowed and not BitFileUploadStatus.Removed
+ && (Append || f.IsPreloaded));
+
+ return Math.Max(0, MaxCount - taken);
+ }
+
private async Task UpdateDropZone()
{
var dragClass = GetDragClass();
var dragStyle = Styles?.Dragging;
+ var rejectClass = GetRejectClass();
+ var rejectStyle = Styles?.DraggingRejected;
+ var acceptedMimeTypes = GetDragAcceptedMimeTypes();
+ var remainingSlots = GetRemainingSlots();
if (_allowDrop == AllowDrop && _allowPaste == AllowPaste && _expandDirectories == Directory &&
- _dragClass == dragClass && _dragStyle == dragStyle) return;
+ _dropZoneSelector == DropZoneSelector && _dragClass == dragClass && _dragStyle == dragStyle &&
+ _rejectClass == rejectClass && _rejectStyle == rejectStyle && _remainingSlots == remainingSlots &&
+ (_acceptedMimeTypes ?? []).SequenceEqual(acceptedMimeTypes ?? [], StringComparer.Ordinal) &&
+ (_acceptedMimeTypes is null) == (acceptedMimeTypes is null)) return;
_allowDrop = AllowDrop;
_allowPaste = AllowPaste;
_expandDirectories = Directory;
+ _dropZoneSelector = DropZoneSelector;
_dragClass = dragClass;
_dragStyle = dragStyle;
+ _rejectClass = rejectClass;
+ _rejectStyle = rejectStyle;
+ _acceptedMimeTypes = acceptedMimeTypes;
+ _remainingSlots = remainingSlots;
try
{
- await _dropZoneRef.InvokeVoidAsync("update", _allowDrop, _allowPaste, _expandDirectories, _dragClass, _dragStyle);
+ await _dropZoneRef.InvokeVoidAsync("update", _allowDrop, _allowPaste, _expandDirectories,
+ _dragClass, _dragStyle, _dropZoneSelector,
+ _rejectClass, _rejectStyle, _acceptedMimeTypes, _remainingSlots);
}
catch (JSDisconnectedException) { } // we can ignore this exception here
}
@@ -1331,7 +1714,10 @@ private async Task HandleOnChange()
if (Append is false)
{
- _files.Clear();
+ // a selection replaces the selection, not the record: the files that are already on the server
+ // are not this component's to drop from the view, and taking them out here would hide
+ // attachments that are still very much there.
+ _files.RemoveAll(f => f.IsPreloaded is false);
_uploadQueue.Clear();
UploadStatus = BitFileUploadStatus.Pending;
}
@@ -1347,11 +1733,6 @@ private async Task HandleOnChange()
_files.AddRange(newFiles);
- for (var i = 0; i < _files.Count; i++)
- {
- _files[i].Index = i;
- }
-
if (_files.Any() is false) return;
// a selection appended to a batch that is still uploading leaves the status of that batch alone:
@@ -1398,6 +1779,84 @@ private static string GetFileIdentity(BitFileInfo file)
return $"{file.Name}|{file.Size}|{file.LastModified}";
}
+ // a file the server reported has no last modified time of a file system behind it, so a file picked
+ // here is the same one as an attachment already on the record when its name and size are - which is
+ // as much as the two of them have in common to be compared on.
+ private static string GetServerFileIdentity(BitFileInfo file)
+ {
+ return $"{file.Name}|{file.Size}";
+ }
+
+ // Whether the list has to be built from the preloaded files again. The collection arriving as another
+ // instance is the obvious case, but a collection written inline in the markup is another instance on
+ // every single render: rebuilding on that alone would put the files the user has removed back and
+ // announce the whole list again each time, so what decides is which files are in it rather than which
+ // array they came in.
+ private bool ShouldApplyPreloadedFiles()
+ {
+ if (ReferenceEquals(_appliedPreloadedFiles, PreloadedFiles)) return false;
+
+ _appliedPreloadedFiles = PreloadedFiles;
+
+ var signature = GetPreloadedSignature();
+
+ if (signature.SequenceEqual(_appliedPreloadedSignature, StringComparer.Ordinal)) return false;
+
+ _appliedPreloadedSignature = signature;
+
+ return true;
+ }
+
+ // What a preloaded file is recognized by from one render to the next: the id the app gave it, and
+ // failing that - the component mints one for a file handed over without it - what it says about itself.
+ private string[] GetPreloadedSignature()
+ {
+ if (PreloadedFiles is null) return [];
+
+ return [.. PreloadedFiles.Where(f => f is not null)
+ .Select(f => f.FileId.HasValue() ? f.FileId : GetServerFileIdentity(f))];
+ }
+
+ // The files that are already on the server take the head of the list, ahead of anything picked here, and
+ // are rebuilt from scratch whenever the collection they come from is replaced - a form that has just
+ // loaded the record it edits hands its attachments over exactly that way. They are adopted rather than
+ // copied, so the very instances the app holds are the ones the list works on and their Status is what
+ // tells it what became of each of them.
+ private void ApplyPreloadedFiles()
+ {
+ _files.RemoveAll(f => f.IsPreloaded);
+
+ if (PreloadedFiles is null) return;
+
+ var position = 0;
+
+ foreach (var file in PreloadedFiles)
+ {
+ if (file is null) continue;
+
+ file.IsPreloaded = true;
+ // there is no picked file and so no uploader behind this one, and the index is what would name
+ // that uploader: it is left invalid on purpose, so that a stray call cannot reach another file's.
+ file.Index = PRELOADED_FILE_INDEX;
+ file.IsQueued = false;
+ file.IsRequestInFlight = false;
+ file.Status = BitFileUploadStatus.Completed;
+ // the whole file is on the server already, which is what makes it count as fully uploaded
+ // towards the progress of the batch instead of dragging it down to nothing.
+ file.TotalUploadedSize = file.Size;
+ file.LastChunkUploadedSize = 0;
+
+ // the id travels to the server in the BIT_FILE_ID header of the remove request, so the one the
+ // app gave it stands; only a file handed over without one needs something to be named by.
+ if (file.FileId.HasNoValue())
+ {
+ file.FileId = Guid.NewGuid().ToString();
+ }
+
+ _files.Insert(position++, file);
+ }
+ }
+
// 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.
@@ -1415,6 +1874,9 @@ private void ApplyListValidations()
if (AllowDuplicates is false)
{
var knownFiles = new HashSet(StringComparer.Ordinal);
+ // the attachments the record already has, which sit at the head of the list, so a file picked
+ // here is judged against them by the time it is reached.
+ var knownServerFiles = new HashSet(StringComparer.Ordinal);
foreach (var file in _files)
{
@@ -1422,7 +1884,18 @@ private void ApplyListValidations()
// 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;
+ var isFirstOfItsKind = knownFiles.Add(GetFileIdentity(file));
+
+ // what the server says is on the record is the truth about it, so one of its files is never
+ // turned away as the copy of another - it only ever makes copies of itself out of the ones
+ // picked here.
+ if (file.IsPreloaded)
+ {
+ knownServerFiles.Add(GetServerFileIdentity(file));
+ continue;
+ }
+
+ if (isFirstOfItsKind && knownServerFiles.Contains(GetServerFileIdentity(file)) is false) 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
@@ -1501,6 +1974,23 @@ private bool IsWorthRetrying(BitFileInfo file, int responseStatus)
// 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.
+ // How long to wait before the attempt the file is about to make, its number being the number of
+ // automatic retries already spent on it - the first retry is attempt 1.
+ private TimeSpan? GetAutoRetryDelay(BitFileInfo file)
+ {
+ if (AutoRetryDelayProvider is null) return AutoRetryDelay;
+
+ try
+ {
+ return AutoRetryDelayProvider(file, file.AutoRetryAttempts) ?? AutoRetryDelay;
+ }
+ catch
+ {
+ // a provider that throws decides nothing, and the retry it was asked about still happens.
+ return AutoRetryDelay;
+ }
+ }
+
private static bool HasPendingWork(BitFileInfo file)
{
return file.Status is not BitFileUploadStatus.Completed
@@ -1533,6 +2023,8 @@ private async Task PumpUploadQueue()
private async Task UploadOneFile(BitFileInfo fileInfo, string? uploadUrl = null)
{
if (_files.Any() is false) return;
+ // a file that is already on the server has nothing on this side to send.
+ if (fileInfo.IsPreloaded) 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
@@ -1703,7 +2195,7 @@ private async Task PauseOneFile(BitFileInfo file)
_uploadQueue.Remove(file);
file.IsQueued = false;
- await PauseUploadOneFile(file.Index);
+ await PauseUploadOneFile(file);
return;
}
@@ -1713,7 +2205,7 @@ private async Task PauseOneFile(BitFileInfo file)
// 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);
+ await PauseUploadOneFile(file);
}
private async Task CancelOneFile(BitFileInfo file)
@@ -1733,35 +2225,36 @@ or BitFileUploadStatus.Removed
_uploadQueue.Remove(file);
file.IsQueued = false;
- await CancelUploadOneFile(file.Index);
+ await CancelUploadOneFile(file);
}
- private async Task PauseUploadOneFile(int index)
+ private async Task PauseUploadOneFile(BitFileInfo file)
{
- 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 paused instead of mistaking the aborted request for a failed upload.
await UpdateStatus(BitFileUploadStatus.Paused, file);
file.IsRequestInFlight = false;
- await _js.BitFileUploadPause(UniqueId, index);
+ await AbortRequestOf(file);
}
- private async Task CancelUploadOneFile(int index)
+ private async Task CancelUploadOneFile(BitFileInfo file)
{
- 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);
+ await AbortRequestOf(file);
+ }
+
+ // a file with no uploader of its own - a preloaded one - has no request to abort, and the index it
+ // carries names no uploader, which the JavaScript side would read as "every file of this component".
+ private async Task AbortRequestOf(BitFileInfo file)
+ {
+ if (file.IsPreloaded) return;
+
+ await _js.BitFileUploadPause(UniqueId, file.Index);
}
// the speed of an upload is what turns a progress bar into an answer to "how long is this going to
@@ -1792,13 +2285,10 @@ private static void UpdateTransferRate(BitFileInfo file)
file.RemainingTime = TimeSpan.FromSeconds(remaining / speed);
}
- private void UpdateChunkSize(int fileIndex)
+ private void UpdateChunkSize(BitFileInfo file)
{
- if (fileIndex < 0 || fileIndex >= _files.Count) return;
if (AutoChunkSize is false || ChunkedUpload is false) return;
- var file = _files[fileIndex];
-
var dtNow = DateTime.UtcNow;
var duration = (dtNow - file.StartTimeUpload.GetValueOrDefault(dtNow)).TotalMilliseconds;
@@ -1847,11 +2337,19 @@ private void Announce()
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);
+ // the files that were already on the server were never selected here and never uploaded here, so
+ // they are counted apart rather than credited to a batch that may not have sent a single byte.
+ var attached = files.Count(f => f.IsPreloaded);
+ var selected = files.Where(f => f.IsPreloaded is false).ToArray();
+
+ var completed = selected.Count(f => f.Status is BitFileUploadStatus.Completed);
+ var failed = selected.Count(f => f.Status is BitFileUploadStatus.Failed);
+ var notAllowed = selected.Count(f => f.Status is BitFileUploadStatus.NotAllowed);
- return $"{files.Length} file{(files.Length == 1 ? string.Empty : "s")} selected." +
+ return (selected.Length > 0
+ ? $"{selected.Length} file{(selected.Length == 1 ? string.Empty : "s")} selected."
+ : "No file selected.") +
+ (attached > 0 ? $" {attached} already attached." : string.Empty) +
(completed > 0 ? $" {completed} uploaded." : string.Empty) +
(failed > 0 ? $" {failed} failed." : string.Empty) +
(notAllowed > 0 ? $" {notAllowed} not allowed." : string.Empty);
@@ -1941,7 +2439,7 @@ private async Task RemoveOneFile(BitFileInfo fileInfo)
// told to delete them.
if (fileInfo.Status is BitFileUploadStatus.InProgress or BitFileUploadStatus.Paused)
{
- await _js.BitFileUploadPause(UniqueId, fileInfo.Index);
+ await AbortRequestOf(fileInfo);
}
// a completed file counts as being on the server even when it carried no byte at all,
@@ -1967,6 +2465,10 @@ private async Task RemoveOneFile(BitFileInfo fileInfo)
// 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.
+ // a preloaded file's thumbnail is a URL of the server's own, which this side neither created nor
+ // gets to hand back, and there is no uploader holding anything for it either.
+ if (fileInfo.IsPreloaded) return;
+
fileInfo.PreviewUrl = null;
try
@@ -2010,6 +2512,8 @@ private async Task RemoveOneFileFromServer(BitFileInfo fileInfo)
var response = await _httpClient.SendAsync(request);
+ fileInfo.ResponseStatus = (int)response.StatusCode;
+
if (response.IsSuccessStatusCode)
{
await UpdateStatus(BitFileUploadStatus.Removed, fileInfo);
@@ -2022,6 +2526,10 @@ private async Task RemoveOneFileFromServer(BitFileInfo fileInfo)
}
catch (Exception ex)
{
+ // the request never came back with a status of its own, which is reported the same way the
+ // upload side reports a request that never reached the server.
+ fileInfo.ResponseStatus = 0;
+
// 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;
@@ -2070,7 +2578,7 @@ private static string AddQueryString(string? url, Dictionary que
return sb.ToString();
}
- private void OnSetChunkSize()
+ internal void OnSetChunkSize()
{
_internalChunkSize = ChunkSize.HasValue is false || AutoChunkSize
? MIN_CHUNK_SIZE
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss
index 6e84abbc7a6..1e9ac2ce879 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.scss
@@ -1,15 +1,108 @@
@import "../../../Styles/functions.scss";
+// Public CSS variables, read off the root and never declared here, so a value set on :root re-skins every
+// file upload and one set on the Style of an instance re-skins that one alone:
+// --bit-FileUpload-max-width widest the whole control grows (default: spacing(43.75))
+// --bit-FileUpload-gap room between the browse button, the
+// description and the file list (default: spacing(0.5))
+//
+// --bit-FileUpload-label-color browse button text at rest (default: per variant, from the Color role)
+// --bit-FileUpload-label-background browse button fill at rest (default: per variant, from the Color role)
+// --bit-FileUpload-label-border-color browse button rule at rest (default: per variant, from the Color role)
+// --bit-FileUpload-label-hover-color browse button text on hover (default: per variant)
+// --bit-FileUpload-label-hover-background browse button fill on hover, and its rule
+// in the Fill variant (default: the role's hover color)
+// --bit-FileUpload-label-active-background browse button fill while pressed, and its
+// rule in the Fill variant (default: the role's active color)
+// --bit-FileUpload-label-radius browse button corner radius (default: $shp-radius-button)
+// --bit-FileUpload-label-min-height browse button height floor (default: per size, $siz-ctrl-*)
+// --bit-FileUpload-label-padding browse button padding (default: per size, $siz-ctrl-pad-*)
+// --bit-FileUpload-label-font-size browse button text size (default: per size, from the type ramp)
+// --bit-FileUpload-label-font-weight browse button text weight (default: $tg-font-weight)
+// --bit-FileUpload-label-gap room between the browse button icon and text (default: spacing(0.75))
+// --bit-FileUpload-label-icon-size browse button icon size (default: per size, $siz-icon-*)
+// --bit-FileUpload-focus-color keyboard focus ring color (default: the role's focus color)
+//
+// --bit-FileUpload-drop-area-min-height height floor of the ShowDropArea panel (default: spacing(16))
+// --bit-FileUpload-drop-area-padding its padding (default: spacing(3))
+// --bit-FileUpload-drop-area-color its text (default: $clr-fg-pri)
+// --bit-FileUpload-drop-area-background its surface at rest (default: $clr-bg-sec)
+// --bit-FileUpload-drop-area-border-color its dashed rule at rest (default: $clr-brd-pri)
+// --bit-FileUpload-drop-area-border-style its rule style (default: dashed)
+// --bit-FileUpload-drop-area-radius its corner radius (default: $shp-radius-surface)
+// --bit-FileUpload-drop-area-hover-border-color its rule on hover (default: the Color role's main color)
+// --bit-FileUpload-drop-area-hover-background its surface on hover and while dragged over (default: the role's main color at 8%)
+// --bit-FileUpload-drop-area-icon-color its large icon (default: the Color role's main color)
+// --bit-FileUpload-drop-area-icon-size its large icon size (default: spacing(4))
+//
+// --bit-FileUpload-description-color hint text under the browse button (default: $clr-fg-sec)
+// --bit-FileUpload-description-font-size hint text size (default: per size)
+//
+// --bit-FileUpload-item-color file item text (default: $clr-fg-pri)
+// --bit-FileUpload-item-background file item surface (default: transparent)
+// --bit-FileUpload-item-border-color file item rule (default: $clr-brd-pri)
+// --bit-FileUpload-item-hover-border-color file item rule on hover (default: $clr-brd-pri-hover)
+// --bit-FileUpload-item-radius file item corner radius (default: $shp-radius-surface)
+// --bit-FileUpload-item-padding padding around the name, size and progress (default: spacing(1) spacing(2))
+// --bit-FileUpload-item-font-size file name size (default: per size)
+// --bit-FileUpload-item-font-weight file name weight (default: $tg-fw-light)
+// --bit-FileUpload-item-meta-font-size size and percentage line under the file name (default: per size)
+// --bit-FileUpload-list-max-height tallest the file list grows before it
+// scrolls, for a folder or a long batch (default: none)
+//
+// --bit-FileUpload-success-color status line of a completed file (default: $clr-suc)
+// --bit-FileUpload-error-color status line of a failed or rejected file (default: $clr-err)
+// --bit-FileUpload-paused-color status line of a paused or canceled file (default: the Color role's main color)
+// --bit-FileUpload-preloaded-color status line of a file that was already on
+// the server when the list was built (default: $clr-fg-sec)
+//
+// --bit-FileUpload-preview-size image thumbnail side (default: per size)
+// --bit-FileUpload-preview-radius image thumbnail corner radius (default: $shp-radius-control)
+// --bit-FileUpload-preview-background box standing in for the thumbnail of a file
+// that is not an image (default: $clr-bg-sec)
+// --bit-FileUpload-preview-icon-color the type glyph inside that box (default: $clr-fg-sec)
+// --bit-FileUpload-preview-icon-size that glyph's size (default: per size, $siz-icon-*)
+//
+// --bit-FileUpload-progress-color filled part of the progress bar (default: the Color role's main color)
+// --bit-FileUpload-progress-track-color unfilled part of the progress bar (default: $clr-bg-sec)
+// --bit-FileUpload-progress-height progress bar thickness (default: $siz-track-sm)
+// --bit-FileUpload-progress-radius progress bar corner radius (default: $shp-radius-full)
+//
+// --bit-FileUpload-action-color upload/pause/cancel/remove glyph at rest (default: $clr-fg-pri)
+// --bit-FileUpload-action-hover-color the same glyph on hover (default: the Color role's main color)
+// --bit-FileUpload-action-hover-background tint behind the hovered action button (default: the role's main color at 10%)
+// --bit-FileUpload-action-size action button width (default: per size, $siz-ctrl-*)
+// --bit-FileUpload-action-icon-size action button glyph size (default: per size, $siz-icon-*)
+// --bit-FileUpload-spinner-color arc of the removal spinner (default: the Color role's main color)
+//
+// --bit-FileUpload-drop-color browse button text while a file is dragged
+// over the component (default: the role's on-color)
+// --bit-FileUpload-drop-background its fill in the same state (default: the role's hover color)
+// --bit-FileUpload-drop-border-color its rule in the same state (default: the role's on-color)
+// --bit-FileUpload-drop-border-style its rule style in the same state (default: dashed)
+// --bit-FileUpload-drop-reject-color browse button text while the dragged files are
+// known to be turned away (default: $clr-err-text)
+// --bit-FileUpload-drop-reject-background its fill in the same state (default: $clr-err)
+// --bit-FileUpload-drop-reject-border-color its rule in the same state (default: $clr-err-text)
+//
+// --bit-FileUpload-batch-action-color text of the Upload all/Cancel all/Clear buttons (default: the Color role's main color)
+// --bit-FileUpload-batch-action-hover-background their tint on hover (default: the role's main color at 10%)
+//
+// --bit-FileUpload-disabled-color text of a disabled component (default: $clr-fg-dis)
+// --bit-FileUpload-disabled-background fill of a disabled browse button (default: $clr-bg-dis)
+// --bit-FileUpload-disabled-border-color rule of a disabled browse button (default: $clr-brd-dis)
+
.bit-upl {
display: flex;
flex-flow: column nowrap;
- max-width: spacing(43.75);
font-family: $tg-font-family;
+ gap: var(--bit-FileUpload-gap, #{spacing(0.5)});
+ max-width: var(--bit-FileUpload-max-width, #{spacing(43.75)});
&.bit-dis {
- color: $clr-fg-dis;
- border-color: $clr-brd-dis;
- background-color: $clr-bg-dis;
+ // only the text dims on the root: its border and fill are the browse button's to carry, and a fill
+ // here would lay a grey slab behind the description and the whole file list.
+ color: var(--bit-FileUpload-disabled-color, #{$clr-fg-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
@@ -18,7 +111,7 @@
//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;
+ color: var(--bit-FileUpload-disabled-color, #{$clr-fg-dis});
pointer-events: none;
}
}
@@ -44,45 +137,70 @@
outline: 0;
display: flex;
cursor: pointer;
- font-weight: $tg-fw-light;
user-select: none;
align-items: center;
+ box-sizing: border-box;
justify-content: center;
- border-radius: $shp-radius-control;
+ font-family: $tg-font-family;
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);
-
- i {
- margin-inline-end: spacing(0.625);
+ letter-spacing: $tg-ctrl-letter-spacing;
+ text-transform: $tg-ctrl-text-transform;
+ -webkit-tap-highlight-color: transparent;
+ border-radius: var(--bit-FileUpload-label-radius, #{$shp-radius-button});
+ gap: var(--bit-FileUpload-label-gap, #{spacing(0.75)});
+ font-weight: var(--bit-FileUpload-label-font-weight, #{$tg-font-weight});
+ // The control height of the size class, as a floor rather than a height, so the button still lines up
+ // with the other controls of its size and stays above the 24px minimum pointer target of WCAG 2.2 (SC 2.5.8).
+ min-height: var(--bit-FileUpload-label-min-height, var(--bit-upl-lbl-height));
+ font-size: var(--bit-FileUpload-label-font-size, var(--bit-upl-lbl-fontsize));
+ padding: var(--bit-FileUpload-label-padding, var(--bit-upl-lbl-padding));
+ transition: color $mot-duration-short $mot-easing,
+ border-color $mot-duration-short $mot-easing,
+ background-color $mot-duration-short $mot-easing;
+
+ // The browse button is the only thing in the component that opens the file dialog, so a keyboard user
+ // who cannot see where the focus is cannot upload anything at all (WCAG 2.4.7 / 2.4.11).
+ &:focus-visible {
+ @include focus-ring(var(--bit-FileUpload-focus-color, var(--bit-upl-clr-focus)));
}
+
+}
+
+.bit-upl-lic {
+ line-height: 1;
+ flex-shrink: 0;
+ font-size: var(--bit-FileUpload-label-icon-size, var(--bit-upl-ico-fontsize));
+}
+
+.bit-upl-eni .bit-upl-lbl {
+ flex-direction: row-reverse;
}
//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);
+ color: var(--bit-FileUpload-label-color, var(--bit-upl-clr-txt));
+ border-color: var(--bit-FileUpload-label-border-color, var(--bit-FileUpload-label-background, var(--bit-upl-clr)));
+ background-color: var(--bit-FileUpload-label-background, var(--bit-upl-clr));
@media (hover: hover) {
&:hover {
- border-color: var(--bit-upl-clr-hover);
- background-color: var(--bit-upl-clr-hover);
+ color: var(--bit-FileUpload-label-hover-color, var(--bit-FileUpload-label-color, var(--bit-upl-clr-txt)));
+ border-color: var(--bit-FileUpload-label-hover-background, var(--bit-upl-clr-hover));
+ background-color: var(--bit-FileUpload-label-hover-background, var(--bit-upl-clr-hover));
}
}
&:active {
- border-color: var(--bit-upl-clr-active);
- background-color: var(--bit-upl-clr-active);
+ border-color: var(--bit-FileUpload-label-active-background, var(--bit-upl-clr-active));
+ background-color: var(--bit-FileUpload-label-active-background, var(--bit-upl-clr-active));
}
}
&.bit-dis .bit-upl-lbl {
- border-color: $clr-brd-dis;
- background-color: $clr-bg-dis;
+ border-color: var(--bit-FileUpload-disabled-border-color, #{$clr-brd-dis});
+ background-color: var(--bit-FileUpload-disabled-background, #{$clr-bg-dis});
}
}
@@ -90,52 +208,52 @@
//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;
+ color: var(--bit-FileUpload-label-color, var(--bit-upl-clr));
+ border-color: var(--bit-FileUpload-label-border-color, var(--bit-upl-clr));
+ background-color: var(--bit-FileUpload-label-background, transparent);
@media (hover: hover) {
&:hover {
- color: var(--bit-upl-clr-txt);
- background-color: var(--bit-upl-clr-hover);
+ color: var(--bit-FileUpload-label-hover-color, var(--bit-upl-clr-txt));
+ background-color: var(--bit-FileUpload-label-hover-background, var(--bit-upl-clr-hover));
}
}
&:active {
- color: var(--bit-upl-clr-txt);
- background-color: var(--bit-upl-clr-active);
+ color: var(--bit-FileUpload-label-hover-color, var(--bit-upl-clr-txt));
+ background-color: var(--bit-FileUpload-label-active-background, var(--bit-upl-clr-active));
}
}
&.bit-dis .bit-upl-lbl {
- border-color: $clr-brd-dis;
- background-color: transparent;
+ border-color: var(--bit-FileUpload-disabled-border-color, #{$clr-brd-dis});
+ background-color: var(--bit-FileUpload-label-background, 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;
+ color: var(--bit-FileUpload-label-color, var(--bit-upl-clr));
+ border-color: var(--bit-FileUpload-label-border-color, transparent);
+ background-color: var(--bit-FileUpload-label-background, transparent);
@media (hover: hover) {
&:hover {
- color: var(--bit-upl-clr-txt);
- background-color: var(--bit-upl-clr-hover);
+ color: var(--bit-FileUpload-label-hover-color, var(--bit-upl-clr-txt));
+ background-color: var(--bit-FileUpload-label-hover-background, var(--bit-upl-clr-hover));
}
}
&:active {
- color: var(--bit-upl-clr-txt);
- background-color: var(--bit-upl-clr-active);
+ color: var(--bit-FileUpload-label-hover-color, var(--bit-upl-clr-txt));
+ background-color: var(--bit-FileUpload-label-active-background, var(--bit-upl-clr-active));
}
}
&.bit-dis .bit-upl-lbl {
- border-color: transparent;
- background-color: transparent;
+ border-color: var(--bit-FileUpload-label-border-color, transparent);
+ background-color: var(--bit-FileUpload-label-background, transparent);
}
}
@@ -145,40 +263,138 @@
//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);
+ border-style: var(--bit-FileUpload-drop-border-style, dashed);
+ color: var(--bit-FileUpload-drop-color, var(--bit-upl-clr-txt));
+ border-color: var(--bit-FileUpload-drop-border-color, var(--bit-upl-clr-txt));
+ background-color: var(--bit-FileUpload-drop-background, var(--bit-upl-clr-hover));
+ }
+}
+
+// while the dragged files are known to be turned away the drop indicator says so in the error role instead
+// of inviting the drop. the class list is longer than the drag state's so that this one wins over it.
+.bit-upl.bit-upl-drg.bit-upl-drj {
+ .bit-upl-lbl {
+ color: var(--bit-FileUpload-drop-reject-color, #{$clr-err-text});
+ border-color: var(--bit-FileUpload-drop-reject-border-color, #{$clr-err-text});
+ background-color: var(--bit-FileUpload-drop-reject-background, #{$clr-err});
+ }
+}
+
+// ShowDropArea: the browse button grows into a full-width panel with its icon stacked over the text. It
+// stays one button, so the whole area is the click target and a keyboard reaches it with Tab. At rest it is
+// a neutral surface under a dashed rule, since a panel this size in the full role color would outweigh
+// everything around it; the role takes over the rule and a tint on hover and while files hover over it.
+.bit-upl.bit-upl-dra {
+ max-width: var(--bit-FileUpload-max-width, none);
+
+ .bit-upl-lbl {
+ width: 100%;
+ flex-direction: column;
+ text-align: center;
+ white-space: normal;
+ border-style: var(--bit-FileUpload-drop-area-border-style, dashed);
+ border-radius: var(--bit-FileUpload-drop-area-radius, #{$shp-radius-surface});
+ min-height: var(--bit-FileUpload-drop-area-min-height, #{spacing(16)});
+ padding: var(--bit-FileUpload-drop-area-padding, #{spacing(3)});
+ color: var(--bit-FileUpload-drop-area-color, #{$clr-fg-pri});
+ border-color: var(--bit-FileUpload-drop-area-border-color, #{$clr-brd-pri});
+ background-color: var(--bit-FileUpload-drop-area-background, #{$clr-bg-sec});
+
+ @media (hover: hover) {
+ &:hover {
+ color: var(--bit-FileUpload-drop-area-color, #{$clr-fg-pri});
+ border-color: var(--bit-FileUpload-drop-area-hover-border-color, var(--bit-upl-clr));
+ background-color: var(--bit-FileUpload-drop-area-hover-background, #{translucent(var(--bit-upl-clr), 8%)});
+ }
+ }
+
+ &:active {
+ color: var(--bit-FileUpload-drop-area-color, #{$clr-fg-pri});
+ border-color: var(--bit-FileUpload-drop-area-hover-border-color, var(--bit-upl-clr));
+ background-color: var(--bit-FileUpload-drop-area-hover-background, #{translucent(var(--bit-upl-clr), 8%)});
+ }
+ }
+
+ .bit-upl-lic {
+ color: var(--bit-FileUpload-drop-area-icon-color, var(--bit-upl-clr));
+ font-size: var(--bit-FileUpload-label-icon-size, var(--bit-FileUpload-drop-area-icon-size, #{spacing(4)}));
+ }
+
+ &.bit-upl-eni .bit-upl-lbl {
+ flex-direction: column-reverse;
+ }
+
+ &.bit-upl-drg .bit-upl-lbl {
+ color: var(--bit-FileUpload-drop-area-color, #{$clr-fg-pri});
+ border-color: var(--bit-FileUpload-drop-border-color, var(--bit-upl-clr));
+ background-color: var(--bit-FileUpload-drop-area-hover-background, #{translucent(var(--bit-upl-clr), 8%)});
+ }
+
+ // the panel only takes a tint of the error role rather than its full fill, so its rule is drawn in the
+ // error color itself - the role's on-color, which the regular drop indicator uses, would vanish here.
+ &.bit-upl-drg.bit-upl-drj .bit-upl-lbl {
+ color: var(--bit-FileUpload-drop-area-color, #{$clr-fg-pri});
+ border-color: var(--bit-FileUpload-drop-reject-border-color, #{$clr-err});
+ background-color: translucent($clr-err, 8%);
+
+ .bit-upl-lic {
+ color: $clr-err;
+ }
+ }
+
+ &.bit-dis .bit-upl-lbl {
+ color: var(--bit-FileUpload-disabled-color, #{$clr-fg-dis});
+ border-color: var(--bit-FileUpload-disabled-border-color, #{$clr-brd-dis});
+ background-color: var(--bit-FileUpload-disabled-background, #{$clr-bg-dis});
+
+ .bit-upl-lic {
+ color: inherit;
+ }
+ }
+
+ .bit-upl-dsc {
+ text-align: center;
}
}
.bit-upl-dsc {
- color: $clr-fg-sec;
- margin-top: spacing(0.5);
- font-size: var(--bit-upl-fs-fontsize);
+ color: var(--bit-FileUpload-description-color, #{$clr-fg-sec});
+ font-size: var(--bit-FileUpload-description-font-size, var(--bit-upl-fs-fontsize));
}
.bit-upl-fl {
padding: 0;
margin: 0;
+ display: flex;
+ flex-flow: column nowrap;
+ gap: var(--bit-FileUpload-gap, #{spacing(0.5)});
+ // A folder or a long batch can put hundreds of items in this list, which would otherwise push the rest
+ // of the form off the screen. The list is left unbounded by default - a height nobody asked for would
+ // be as surprising as no bound at all - and scrolls as soon as one is set.
+ max-height: var(--bit-FileUpload-list-max-height, none);
+ overflow-y: auto;
+ // the items carry the action buttons a keyboard reaches them by, and a button scrolled to by Tab must
+ // not be flush against the edge it came in at.
+ scroll-padding-block: var(--bit-FileUpload-gap, #{spacing(0.5)});
}
.bit-upl-itm {
display: flex;
overflow: hidden;
- font-weight: $tg-fw-light;
align-items: center;
+ font-weight: var(--bit-FileUpload-item-font-weight, #{$tg-fw-light});
flex-flow: row nowrap;
- margin-top: spacing(0.375);
- border-radius: $shp-radius-surface;
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;
+ color: var(--bit-FileUpload-item-color, #{$clr-fg-pri});
+ font-size: var(--bit-FileUpload-item-font-size, var(--bit-upl-itm-fontsize));
+ border-radius: var(--bit-FileUpload-item-radius, #{$shp-radius-surface});
+ background-color: var(--bit-FileUpload-item-background, transparent);
+ border: $shp-border-width $shp-border-style var(--bit-FileUpload-item-border-color, #{$clr-brd-pri});
+ transition: border-color $mot-duration-short $mot-easing;
@media (hover: hover) {
&:hover {
- border-color: $clr-brd-pri-hover;
+ border-color: var(--bit-FileUpload-item-hover-border-color, #{$clr-brd-pri-hover});
}
}
@@ -192,33 +408,58 @@
.bit-upl-uld {
.bit-upl-us {
- color: $clr-suc;
+ color: var(--bit-FileUpload-success-color, #{$clr-suc});
}
}
.bit-upl-fld {
.bit-upl-us {
- color: $clr-err;
+ color: var(--bit-FileUpload-error-color, #{$clr-err});
}
}
.bit-upl-psd {
.bit-upl-us {
- color: var(--bit-upl-clr);
+ color: var(--bit-FileUpload-paused-color, var(--bit-upl-clr));
+ }
+}
+
+// a file that was already on the server when the list was built is a state rather than an outcome, so it
+// stays in the secondary foreground instead of taking the success color of an upload that just landed.
+.bit-upl-pre {
+ .bit-upl-us {
+ color: var(--bit-FileUpload-preloaded-color, #{$clr-fg-sec});
}
}
.bit-upl-prv {
flex-shrink: 0;
object-fit: cover;
- border-radius: $shp-radius-control;
- width: var(--bit-upl-prv-size);
- height: var(--bit-upl-prv-size);
margin-inline-start: spacing(0.5);
+ border-radius: var(--bit-FileUpload-preview-radius, #{$shp-radius-control});
+ width: var(--bit-FileUpload-preview-size, var(--bit-upl-prv-size));
+ height: var(--bit-FileUpload-preview-size, var(--bit-upl-prv-size));
+}
+
+// the box a file that is not an image takes instead of a thumbnail, so that a list of mixed files lines
+// its names up along one edge rather than stepping in and out with every kind of file.
+.bit-upl-pvi {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--bit-FileUpload-preview-icon-color, #{$clr-fg-sec});
+ background-color: var(--bit-FileUpload-preview-background, #{$clr-bg-sec});
+
+ i {
+ line-height: 1;
+ color: inherit;
+ font-size: var(--bit-FileUpload-preview-icon-size, var(--bit-upl-ico-fontsize));
+ }
}
.bit-upl-fic {
flex-grow: 1;
+ min-width: 0;
height: 100%;
display: flex;
overflow: hidden;
@@ -227,7 +468,7 @@
align-items: flex-start;
justify-content: center;
flex-flow: column nowrap;
- padding: spacing(1) spacing(2);
+ padding: var(--bit-FileUpload-item-padding, #{spacing(1)} #{spacing(2)});
}
.bit-upl-fnc {
@@ -252,7 +493,7 @@
align-items: center;
flex-flow: row nowrap;
margin-top: spacing(0.25);
- font-size: var(--bit-upl-fs-fontsize);
+ font-size: var(--bit-FileUpload-item-meta-font-size, var(--bit-upl-fs-fontsize));
}
.bit-upl-fs {
@@ -269,17 +510,19 @@
.bit-upl-pbc {
width: 100%;
overflow: hidden;
- height: spacing(0.25);
margin-top: spacing(1);
margin-bottom: spacing(0.5);
- background-color: $clr-bg-sec;
+ height: var(--bit-FileUpload-progress-height, #{$siz-track-sm});
+ border-radius: var(--bit-FileUpload-progress-radius, #{$shp-radius-full});
+ background-color: var(--bit-FileUpload-progress-track-color, #{$clr-bg-sec});
}
// named -pgb rather than -pbr so that it cannot collide with the generated PrimaryBorder role class.
.bit-upl-pgb {
- height: spacing(0.25);
+ height: 100%;
transition: width $mot-duration $mot-easing 0s;
- background-color: var(--bit-upl-clr);
+ border-radius: var(--bit-FileUpload-progress-radius, #{$shp-radius-full});
+ background-color: var(--bit-FileUpload-progress-color, var(--bit-upl-clr));
}
.bit-upl-usi {
@@ -290,27 +533,94 @@
cursor: pointer;
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);
+ color: var(--bit-FileUpload-action-color, #{$clr-fg-pri});
+ width: var(--bit-FileUpload-action-size, var(--bit-upl-btn-size));
+ -webkit-tap-highlight-color: transparent;
+ transition: background-color $mot-duration-short $mot-easing;
+ // a disabled button is excluded rather than overridden afterwards, since the hover rule reaches the
+ // glyph directly and would otherwise outrank the disabled color the glyph inherits from the button.
@media (hover: hover) {
- &:hover {
+ &:hover:not(:disabled) {
+ background-color: var(--bit-FileUpload-action-hover-background, #{translucent(var(--bit-upl-clr), 10%)});
+
i {
- color: var(--bit-upl-clr);
+ color: var(--bit-FileUpload-action-hover-color, var(--bit-upl-clr));
}
}
}
+ // The action buttons are the whole keyboard operation of a file item - starting, pausing, canceling and
+ // removing a file - so each of them has to say when it holds the focus. The shared focus-ring mixin
+ // draws its indicator as a box-shadow outside the element, which the file item clips away with its own
+ // overflow:hidden, so the indicator is drawn as an outline inset into the button instead - which is also
+ // what survives a forced-colors palette without a media query of its own.
+ &:focus-visible {
+ outline: $shp-focus-ring-width solid var(--bit-FileUpload-focus-color, var(--bit-upl-clr-focus));
+ outline-offset: calc(-1 * #{$shp-focus-ring-width});
+ }
+
&:disabled {
cursor: default;
- color: $clr-fg-dis;
+ color: var(--bit-FileUpload-disabled-color, #{$clr-fg-dis});
+ background-color: transparent;
}
i {
- font-size: var(--bit-upl-ico-fontsize);
+ line-height: 1;
+ color: inherit;
+ transition: color $mot-duration-short $mot-easing;
+ font-size: var(--bit-FileUpload-action-icon-size, var(--bit-upl-ico-fontsize));
+ }
+}
+
+// the batch actions are secondary to the browse button, so they take the look of a text button in the role
+// color, lined up at the end of the list they act on.
+.bit-upl-bat {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: var(--bit-FileUpload-gap, #{spacing(0.5)});
+}
+
+.bit-upl-bab {
+ margin: 0;
+ border: none;
+ cursor: pointer;
+ user-select: none;
+ box-sizing: border-box;
+ font-family: $tg-font-family;
+ font-weight: $tg-font-weight;
+ background-color: transparent;
+ letter-spacing: $tg-ctrl-letter-spacing;
+ text-transform: $tg-ctrl-text-transform;
+ border-radius: $shp-radius-button;
+ -webkit-tap-highlight-color: transparent;
+ min-height: var(--bit-upl-bab-height);
+ font-size: var(--bit-upl-lbl-fontsize);
+ padding: var(--bit-upl-bab-padding);
+ color: var(--bit-FileUpload-batch-action-color, var(--bit-upl-clr));
+ transition: background-color $mot-duration-short $mot-easing;
+
+ @media (hover: hover) {
+ &:hover:not(:disabled, [aria-disabled="true"]) {
+ background-color: var(--bit-FileUpload-batch-action-hover-background, #{translucent(var(--bit-upl-clr), 10%)});
+ }
+ }
+
+ &:focus-visible {
+ outline: none;
+ @include focus-ring(var(--bit-FileUpload-focus-color, var(--bit-upl-clr-focus)));
+ }
+
+ // an idle action keeps its place and its focus, and only reads as unavailable.
+ &:disabled,
+ &[aria-disabled="true"] {
+ cursor: default;
+ color: var(--bit-FileUpload-disabled-color, #{$clr-fg-dis});
}
}
@@ -330,17 +640,17 @@
flex-shrink: 0;
align-items: center;
justify-content: center;
- width: var(--bit-upl-btn-size);
+ width: var(--bit-FileUpload-action-size, var(--bit-upl-btn-size));
}
.bit-upl-spn {
border-radius: 50%;
- width: spacing(2);
- height: spacing(2);
border-width: $siz-spinner-stroke;
border-style: $shp-border-style;
border-color: $clr-brd-sec;
- border-top-color: var(--bit-upl-clr);
+ width: var(--bit-FileUpload-action-icon-size, var(--bit-upl-ico-fontsize));
+ height: var(--bit-FileUpload-action-icon-size, var(--bit-upl-ico-fontsize));
+ border-top-color: var(--bit-FileUpload-spinner-color, var(--bit-upl-clr));
animation: bit-upl-spinner-animation $mot-duration-spinner $mot-easing-spinner infinite;
}
@@ -351,38 +661,102 @@
--bit-upl-clr-txt: #{role($tokens, on)};
--bit-upl-clr-hover: #{role($tokens, hover)};
--bit-upl-clr-active: #{role($tokens, active)};
+ --bit-upl-clr-focus: #{role($tokens, focus)};
}
}
+// The browse button takes the control height and padding of its size class, the action buttons the same
+// height as a square, and the glyphs the icon step of that class - which is what lines a file upload up
+// with every other control of its size instead of only with itself.
.bit-upl-sm {
- --bit-upl-lbl-height: #{spacing(3.5)};
+ --bit-upl-lbl-height: #{$siz-ctrl-sm};
--bit-upl-lbl-fontsize: #{$tg-fs-xs};
- --bit-upl-lbl-padding: #{spacing(0.25)} #{spacing(1.5)};
+ --bit-upl-lbl-padding: #{$siz-ctrl-pad-y-sm} #{$siz-ctrl-pad-x-sm};
+ --bit-upl-bab-height: #{$siz-ctrl-sm};
+ --bit-upl-bab-padding: #{$siz-ctrl-pad-y-sm} #{$siz-ctrl-pad-x-sm};
--bit-upl-itm-fontsize: #{$tg-fs-xs};
--bit-upl-fs-fontsize: #{$tg-fs-xs};
--bit-upl-prv-size: #{spacing(4)};
- --bit-upl-btn-size: #{spacing(3.5)};
+ --bit-upl-btn-size: #{$siz-ctrl-sm};
--bit-upl-ico-fontsize: #{$siz-icon-sm};
}
.bit-upl-md {
- --bit-upl-lbl-height: #{spacing(4.5)};
+ --bit-upl-lbl-height: #{$siz-ctrl-md};
--bit-upl-lbl-fontsize: #{$tg-fs-sm};
- --bit-upl-lbl-padding: #{spacing(0.5)} #{spacing(2)};
+ --bit-upl-lbl-padding: #{$siz-ctrl-pad-y-md} #{$siz-ctrl-pad-x-md};
+ --bit-upl-bab-height: #{$siz-ctrl-sm};
+ --bit-upl-bab-padding: #{$siz-ctrl-pad-y-sm} #{$siz-ctrl-pad-x-sm};
--bit-upl-itm-fontsize: #{$tg-fs-sm};
--bit-upl-fs-fontsize: #{$tg-fs-xs};
--bit-upl-prv-size: #{spacing(5)};
- --bit-upl-btn-size: #{spacing(4)};
+ --bit-upl-btn-size: #{$siz-ctrl-md};
--bit-upl-ico-fontsize: #{$siz-icon-md};
}
.bit-upl-lg {
- --bit-upl-lbl-height: #{spacing(5.5)};
+ --bit-upl-lbl-height: #{$siz-ctrl-lg};
--bit-upl-lbl-fontsize: #{$tg-fs-md};
- --bit-upl-lbl-padding: #{spacing(0.75)} #{spacing(2.5)};
+ --bit-upl-lbl-padding: #{$siz-ctrl-pad-y-lg} #{$siz-ctrl-pad-x-lg};
+ --bit-upl-bab-height: #{$siz-ctrl-md};
+ --bit-upl-bab-padding: #{$siz-ctrl-pad-y-md} #{$siz-ctrl-pad-x-md};
--bit-upl-itm-fontsize: #{$tg-fs-md};
--bit-upl-fs-fontsize: #{$tg-fs-sm};
--bit-upl-prv-size: #{spacing(6.5)};
- --bit-upl-btn-size: #{spacing(5)};
+ --bit-upl-btn-size: #{$siz-ctrl-lg};
--bit-upl-ico-fontsize: #{$siz-icon-lg};
}
+
+// Windows High Contrast / forced-colors: the palette collapses to the system pair, so every color the
+// component paints is dropped. What has to be re-established is what the color alone was saying: the drop
+// state, the disabled state, the progress bar against its track, and the spinner's arc against its ring.
+@media (forced-colors: active) {
+ .bit-upl.bit-dis,
+ .bit-upl.bit-dis .bit-upl-lbl {
+ color: GrayText;
+ border-color: GrayText;
+ }
+
+ .bit-upl-usi:disabled {
+ color: GrayText;
+ }
+
+ .bit-upl-usi:focus-visible {
+ outline-color: Highlight;
+ }
+
+ // The fill of the drop state is stripped, so the dashed rule is the only thing left to say a file is
+ // being dragged over the component - it is given the system highlight to stand out from the rest state.
+ .bit-upl.bit-upl-drg .bit-upl-lbl {
+ border-color: Highlight;
+ }
+
+ // a rejected drag loses its error fill as well, so its rule switches to a dotted one: a difference of
+ // shape, which is all a forced palette leaves to tell the two drag states apart.
+ .bit-upl.bit-upl-drg.bit-upl-drj .bit-upl-lbl {
+ border-style: dotted;
+ border-color: Mark;
+ }
+
+ .bit-upl-bab:disabled,
+ .bit-upl-bab[aria-disabled="true"] {
+ color: GrayText;
+ }
+
+ // The track and the bar are both painted with system colors rather than outlined, since a rule around
+ // a track two pixels high would leave the bar inside it no room at all to be seen.
+ .bit-upl-pbc {
+ background-color: GrayText;
+ forced-color-adjust: none;
+ }
+
+ .bit-upl-pgb {
+ background-color: Highlight;
+ forced-color-adjust: none;
+ }
+
+ .bit-upl-spn {
+ border-color: GrayText;
+ border-top-color: CanvasText;
+ }
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts
index 4a164634a9d..dcaf11ea070 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUpload.ts
@@ -95,11 +95,74 @@ namespace BitBlazorUI {
dragStyle: string | null,
allowDrop: boolean,
allowPaste: boolean,
- expandDirectories: boolean) {
+ expandDirectories: boolean,
+ dropZoneSelector: string | null,
+ rejectClass: string = '',
+ rejectStyle: string | null = null,
+ acceptedMimeTypes: string[] | null = null,
+ remainingSlots: number = -1) {
let dragCounter = 0;
- let originalStyle: string | null = null;
let dragClasses = dragClass.split(' ').filter(c => c.length > 0);
+ let rejectClasses = rejectClass.split(' ').filter(c => c.length > 0);
+ // whether the drag in flight is known to carry files the component is going to turn away, decided
+ // once when it enters, since what is being dragged does not change until it is dropped.
+ let isRejected = false;
+ // the inline style every zone carried before the drag state was applied to it, so that the
+ // element the app owns gets exactly its own style back rather than losing it.
+ const originalStyles = new Map();
+
+ // a page can well point two uploaders at one shared zone, so a drop or a paste landing in it
+ // is claimed by the first of them to see it rather than handed to every one of them.
+ const handledFlag = '__bitUplHandled';
+
+ function claim(e: Event) {
+ if ((e as any)[handledFlag]) return false;
+
+ (e as any)[handledFlag] = true;
+
+ return true;
+ }
+
+ // the root is always a drop zone; the selector adds whatever else the app nominates, which is
+ // how a whole form - or the page - accepts a drop while the browse button stays where it is.
+ // the drag listeners sit on the document and ask this rather than being attached to the zones
+ // themselves, so an element the app renders after this component - or replaces later on - is a
+ // drop zone from the moment it matches the selector, with nothing to re-attach.
+ function isInZone(target: EventTarget | null): boolean {
+ if (!(target instanceof Node)) return false;
+
+ if (dropZoneElement.contains(target)) return true;
+
+ if (!dropZoneSelector) return false;
+
+ const element = target instanceof Element ? target : target.parentElement;
+
+ try {
+ return element?.closest(dropZoneSelector) != null;
+ } catch {
+ // an invalid selector leaves the component with its own root as the only zone.
+ return false;
+ }
+ }
+
+ function resolveZones(): HTMLElement[] {
+ const list: HTMLElement[] = [dropZoneElement];
+
+ if (!dropZoneSelector) return list;
+
+ try {
+ document.querySelectorAll(dropZoneSelector).forEach(e => {
+ if (e !== dropZoneElement && e instanceof HTMLElement) list.push(e);
+ });
+ } catch { /* see isInZone */ }
+
+ return list;
+ }
+
+ // the zones the drag state is currently painted on, captured when it was applied so that it
+ // comes off exactly the elements it went on, whatever the DOM does in between.
+ let zones: HTMLElement[] = [];
function hasFiles(e: DragEvent) {
return !!e.dataTransfer && Array.prototype.includes.call(e.dataTransfer.types, 'Files');
@@ -109,29 +172,100 @@ namespace BitBlazorUI {
return allowDrop && !inputElement.disabled && hasFiles(e);
}
+ // a browser shows the kind and the MIME type of a dragged file but never its name until the drop,
+ // so only what those two can tell is judged here: a type the MIME-only rule does not accept, or
+ // more files than the MaxCount still has room for. anything unknown - a folder, a type the OS left
+ // blank - counts as accepted, and the validations have the final word once the files land anyway.
+ function judgeDrag(e: DragEvent) {
+ const items = e.dataTransfer?.items;
+ if (!items) return false;
+
+ const files = Array.prototype.filter.call(items, (i: DataTransferItem) => i.kind === 'file') as DataTransferItem[];
+ if (files.length === 0) return false;
+
+ if (remainingSlots >= 0) {
+ const acceptsMany = inputElement.multiple || inputElement.webkitdirectory;
+ const count = acceptsMany ? files.length : 1;
+ if (count > remainingSlots) return true;
+ }
+
+ if (!acceptedMimeTypes || acceptedMimeTypes.length === 0) return false;
+
+ return files.some(f => {
+ const type = (f.type || '').toLowerCase();
+ if (!type) return false;
+
+ return !acceptedMimeTypes!.some(a => {
+ const entry = a.toLowerCase();
+ return entry.endsWith('/*') ? type.startsWith(entry.substring(0, entry.length - 1)) : type === entry;
+ });
+ });
+ }
+
+ function paintZone(zone: HTMLElement) {
+ zone.classList.add(...dragClasses);
+ if (isRejected) {
+ zone.classList.add(...rejectClasses);
+ }
+
+ const style = isRejected ? [dragStyle, rejectStyle].filter(s => s).join(';') : dragStyle;
+ if (!style) return;
+ const original = zone.getAttribute('style');
+ originalStyles.set(zone, original);
+ zone.setAttribute('style', [original, style].filter(s => s).join(';'));
+ }
+
function applyDragStyling() {
- dropZoneElement.classList.add(...dragClasses);
+ zones = resolveZones();
+
+ zones.forEach(zone => paintZone(zone));
+ }
- if (!dragStyle) return;
- originalStyle = dropZoneElement.getAttribute('style');
- dropZoneElement.setAttribute('style', [originalStyle, dragStyle].filter(s => s).join(';'));
+ // a zone can be rendered - or start matching the selector - while the drag is already in
+ // flight, and a zone that takes the drop is a zone that shows it is about to. the ones already
+ // painted are left alone so that the style they had before the drag is remembered once rather
+ // than overwritten with the dragging one.
+ // the painted zones are asked by containment rather than by running the selector again, so this
+ // costs nothing on a dragover, which fires several times a second.
+ function isPainted(target: EventTarget | null) {
+ return target instanceof Node && zones.some(zone => zone.contains(target));
+ }
+
+ function syncDragStyling() {
+ resolveZones().forEach(zone => {
+ if (zones.indexOf(zone) >= 0) return;
+
+ zones.push(zone);
+ paintZone(zone);
+ });
}
function clearDragStyling() {
- dropZoneElement.classList.remove(...dragClasses);
+ zones.forEach(zone => {
+ zone.classList.remove(...dragClasses);
+ zone.classList.remove(...rejectClasses);
+
+ if (!originalStyles.has(zone)) return;
+ const original = originalStyles.get(zone);
+ if (original) {
+ zone.setAttribute('style', original);
+ } else {
+ zone.removeAttribute('style');
+ }
+ originalStyles.delete(zone);
+ });
- if (!dragStyle) return;
- if (originalStyle) {
- dropZoneElement.setAttribute('style', originalStyle);
- } else {
- dropZoneElement.removeAttribute('style');
- }
- originalStyle = null;
+ zones = [];
}
- function addDragState() {
+ function addDragState(e: DragEvent) {
dragCounter++;
- if (dragCounter > 1) return;
+ if (dragCounter > 1) {
+ syncDragStyling();
+ return;
+ }
+
+ isRejected = judgeDrag(e);
applyDragStyling();
}
@@ -146,24 +280,38 @@ namespace BitBlazorUI {
}
function onDragEnter(e: DragEvent) {
+ if (!isInZone(e.target)) return;
+
e.preventDefault();
if (!canAcceptDrop(e)) return;
- addDragState();
+ addDragState(e);
}
function onDragOver(e: DragEvent) {
+ if (!isInZone(e.target)) return;
+
// 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;
+ const accepts = canAcceptDrop(e);
+
// gives the OS the correct drag cursor (a copy badge or a no-drop sign).
- e.dataTransfer.dropEffect = canAcceptDrop(e) ? 'copy' : 'none';
+ e.dataTransfer.dropEffect = accepts ? 'copy' : 'none';
+
+ // a zone can also come up underneath a pointer that is already over it, which leaves no
+ // dragenter of its own for it to be noticed by.
+ if (accepts && dragCounter > 0 && !isPainted(e.target)) {
+ syncDragStyling();
+ }
}
function onDragLeave(e: DragEvent) {
+ if (!isInZone(e.target)) return;
+
e.preventDefault();
if (!hasFiles(e)) return;
@@ -204,7 +352,14 @@ namespace BitBlazorUI {
}
function onDrop(e: DragEvent) {
+ if (!isInZone(e.target)) return;
+
e.preventDefault();
+
+ // two uploaders can well be pointed at one shared zone, and the files belong to
+ // whichever of them takes the drop rather than to both of them at once.
+ if (!claim(e)) return;
+
removeDragState(true);
if (!allowDrop || inputElement.disabled || !e.dataTransfer) return;
@@ -228,30 +383,29 @@ namespace BitBlazorUI {
// nor the hidden file input can ever hold it, so the listener sits on the document and decides
// for itself whether the paste was meant for this component: the focus being somewhere inside
// it, or nothing on the page holding the focus at all. in that second case the paste belongs to
- // no one in particular, so the first paste enabled upload on the page takes it and marks the
- // event, otherwise a second one would end up with a copy of the same files.
+ // no one in particular, so whichever paste enabled upload on the page sees it first takes it.
function onPaste(e: ClipboardEvent) {
if (!allowPaste || inputElement.disabled) return;
if (!e.clipboardData || e.clipboardData.files.length === 0) return;
const focused = document.activeElement;
- const isFocusedHere = focused !== null && (focused === inputElement || dropZoneElement.contains(focused));
+ const isFocusedHere = focused !== null && (focused === inputElement || isInZone(focused));
- if (!isFocusedHere) {
- if (focused !== null && focused !== document.body) return;
- if ((e as any).bitPasteHandled) return;
+ // the focus sitting inside some unrelated element is a paste meant for that element.
+ if (!isFocusedHere && focused !== null && focused !== document.body) return;
- (e as any).bitPasteHandled = true;
- }
+ // the focus can be inside a zone two uploaders share just as well as it can be nowhere at
+ // all, so either way the files belong to one of them rather than to both at once.
+ if (!claim(e)) return;
setFiles(e.clipboardData.files);
}
- dropZoneElement.addEventListener("dragenter", onDragEnter);
- dropZoneElement.addEventListener("dragover", onDragOver);
- dropZoneElement.addEventListener("dragleave", onDragLeave);
- dropZoneElement.addEventListener("drop", onDrop);
- dropZoneElement.addEventListener('dragend', onDragCancel);
+ document.addEventListener('dragenter', onDragEnter);
+ document.addEventListener('dragover', onDragOver);
+ document.addEventListener('dragleave', onDragLeave);
+ document.addEventListener('drop', onDrop);
+ document.addEventListener('dragend', onDragCancel);
document.addEventListener('paste', onPaste);
// 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.
@@ -264,13 +418,38 @@ namespace BitBlazorUI {
newAllowPaste: boolean,
newExpandDirectories: boolean,
newDragClass: string,
- newDragStyle: string | null) => {
+ newDragStyle: string | null,
+ newDropZoneSelector: string | null,
+ newRejectClass: string = '',
+ newRejectStyle: string | null = null,
+ newAcceptedMimeTypes: string[] | null = null,
+ newRemainingSlots: number = -1) => {
allowDrop = newAllowDrop;
allowPaste = newAllowPaste;
expandDirectories = newExpandDirectories;
+ // what a drag is judged against only applies to the next one; the one in flight keeps
+ // the verdict it entered with.
+ acceptedMimeTypes = newAcceptedMimeTypes;
+ remainingSlots = newRemainingSlots;
+
+ if (newDropZoneSelector !== dropZoneSelector) {
+ // the zones of the old selector are already wearing the drag state, and only they
+ // can be asked to take it off again.
+ const isDragging = dragCounter > 0;
+ if (isDragging) {
+ clearDragStyling();
+ }
+
+ dropZoneSelector = newDropZoneSelector;
- if (newDragClass !== dragClass || newDragStyle !== dragStyle) {
+ if (isDragging) {
+ applyDragStyling();
+ }
+ }
+
+ if (newDragClass !== dragClass || newDragStyle !== dragStyle ||
+ newRejectClass !== rejectClass || newRejectStyle !== rejectStyle) {
// 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;
@@ -281,6 +460,9 @@ namespace BitBlazorUI {
dragClass = newDragClass;
dragClasses = dragClass.split(' ').filter(c => c.length > 0);
dragStyle = newDragStyle;
+ rejectClass = newRejectClass;
+ rejectClasses = rejectClass.split(' ').filter(c => c.length > 0);
+ rejectStyle = newRejectStyle;
if (isDragging) {
applyDragStyling();
@@ -292,11 +474,15 @@ namespace BitBlazorUI {
}
},
dispose: () => {
- dropZoneElement.removeEventListener('dragenter', onDragEnter);
- dropZoneElement.removeEventListener('dragover', onDragOver);
- dropZoneElement.removeEventListener('dragleave', onDragLeave);
- dropZoneElement.removeEventListener("drop", onDrop);
- dropZoneElement.removeEventListener('dragend', onDragCancel);
+ // a zone the app owns outlives this component, so anything painted on it while a drag
+ // was still in flight has to come off before the listeners that would have done it go.
+ removeDragState(true);
+
+ document.removeEventListener('dragenter', onDragEnter);
+ document.removeEventListener('dragover', onDragOver);
+ document.removeEventListener('dragleave', onDragLeave);
+ document.removeEventListener('drop', onDrop);
+ document.removeEventListener('dragend', onDragCancel);
document.removeEventListener('paste', onPaste);
window.removeEventListener('dragend', onDragCancel);
window.removeEventListener('drop', onDragCancel);
@@ -433,6 +619,21 @@ namespace BitBlazorUI {
}
class BitFileUploader {
+ // a response body is handed to .NET as the Message of the file, and on Blazor Server that hop is a
+ // SignalR message whose default limit is 32 KB - so the body is cut well below it.
+ static readonly MAX_RESPONSE_TEXT_LENGTH = 8 * 1024;
+
+ // the cut counts UTF-16 code units, so it can land between the two halves of a surrogate pair; a lone
+ // high surrogate is not valid UTF-16 and .NET's JSON reader would reject the whole call, leaving the
+ // file in progress for good - so the cut steps back one unit instead of keeping half a character.
+ static capText(text: string): string {
+ const max = BitFileUploader.MAX_RESPONSE_TEXT_LENGTH;
+ if (text.length <= max) return text;
+
+ const last = text.charCodeAt(max - 1);
+ return text.substring(0, last >= 0xD800 && last <= 0xDBFF ? max - 1 : max);
+ }
+
id: string;
dotnetReference: DotNetObject;
file: File | null;
@@ -483,7 +684,13 @@ namespace BitBlazorUI {
const me = this;
this.xhr.onreadystatechange = function (event) {
if (me.xhr.readyState === 4) {
- dotnetReference.invokeMethodAsync("HandleChunkUpload", index, me.xhr.status, me.xhr.responseText);
+ // the body of an error response is whatever the endpoint happens to serve - an error page
+ // of a hundred kilobytes as easily as a one line message - and it travels to .NET over the
+ // Blazor Server circuit, whose default message size is 32 KB. A body over that limit would
+ // tear the circuit down and take the whole page with it, so it is capped to what a Message
+ // is actually read for rather than sent whole.
+ const body = me.xhr.responseText ?? '';
+ dotnetReference.invokeMethodAsync("HandleChunkUpload", index, me.xhr.status, BitFileUploader.capText(body));
}
};
}
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs
index 75ea345941c..55b3ad1da0c 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadClassStyles.cs
@@ -12,11 +12,23 @@ public class BitFileUploadClassStyles
///
public string? Dragging { get; set; }
+ ///
+ /// Custom CSS classes/styles added on top of while the files dragged over the
+ /// BitFileUpload are known to be turned away - a MIME type the AllowedExtensions rule does not accept,
+ /// or more files than the MaxCount leaves room for.
+ ///
+ public string? DraggingRejected { 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 icon of the browse button of the BitFileUpload.
+ ///
+ public string? LabelIcon { get; set; }
+
///
/// Custom CSS classes/styles for the description (hint) of the BitFileUpload.
///
@@ -33,10 +45,17 @@ public class BitFileUploadClassStyles
public string? FileItem { get; set; }
///
- /// Custom CSS classes/styles for the image preview thumbnail of each file item of the BitFileUpload.
+ /// Custom CSS classes/styles for the image preview thumbnail of each file item of the BitFileUpload,
+ /// and for the box that takes its place with a type glyph for a file that is not an image.
///
public string? Preview { get; set; }
+ ///
+ /// Custom CSS classes/styles for the type glyph shown in place of the preview thumbnail
+ /// of a file item that is not an image.
+ ///
+ public string? PreviewIcon { get; set; }
+
///
/// Custom CSS classes/styles for the file name of each file item of the BitFileUpload.
///
@@ -106,4 +125,26 @@ public class BitFileUploadClassStyles
/// Custom CSS classes/styles for the remove button icon of each file item of the BitFileUpload.
///
public string? RemoveIcon { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the container of the spinner that takes the place of the remove button
+ /// of a file item while that file is being removed from the server.
+ ///
+ public string? RemoveSpinnerContainer { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the spinner that takes the place of the remove button of a file item
+ /// while that file is being removed from the server.
+ ///
+ public string? RemoveSpinner { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for the container of the batch action buttons of the BitFileUpload.
+ ///
+ public string? BatchActions { get; set; }
+
+ ///
+ /// Custom CSS classes/styles for each batch action button ("Upload all", "Cancel all", "Clear") of the BitFileUpload.
+ ///
+ public string? BatchActionButton { 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 f1547e457ee..4e1bc25b7bd 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadJsRuntimeExtensions.cs
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadJsRuntimeExtensions.cs
@@ -61,11 +61,17 @@ internal static ValueTask BitFileUploadSetupDragDrop(this IJ
string? dragStyle,
bool allowDrop,
bool allowPaste,
- bool expandDirectories)
+ bool expandDirectories,
+ string? dropZoneSelector,
+ string rejectClass,
+ string? rejectStyle,
+ string[]? acceptedMimeTypes,
+ int remainingSlots)
{
return jsRuntime.Invoke("BitBlazorUI.FileUpload.setupDragDrop",
dragDropZoneElement, inputFileElement, dragClass, dragStyle,
- allowDrop, allowPaste, expandDirectories);
+ allowDrop, allowPaste, expandDirectories, dropZoneSelector,
+ rejectClass, rejectStyle, acceptedMimeTypes, remainingSlots);
}
internal static ValueTask BitFileUploadBrowse(this IJSRuntime jsRuntime, ElementReference inputFileElement)
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadParams.cs
new file mode 100644
index 00000000000..4a8d738fdb0
--- /dev/null
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/BitFileUploadParams.cs
@@ -0,0 +1,1153 @@
+namespace Bit.BlazorUI;
+
+///
+/// The parameters for component.
+///
+public class BitFileUploadParams : BitComponentBaseParams, IBitComponentParams
+{
+ ///
+ /// Represents the parameter name used to identify the cascading parameters within .
+ ///
+ ///
+ /// This constant is typically used when referencing or accessing the BitFileUpload value in
+ /// parameterized APIs or configuration settings. Using this constant helps ensure consistency and reduces the risk
+ /// of typographical errors.
+ ///
+ public const string ParamName = $"{nameof(BitParams)}.{nameof(BitFileUpload)}";
+
+
+
+ public string Name => ParamName;
+
+
+
+ ///
+ /// 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 .
+ ///
+ public string? Accept { get; set; }
+
+ ///
+ /// Whether files can be selected by dragging them from the operating system and dropping them on the component.
+ /// The default value is true.
+ ///
+ public bool? AllowDrop { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool? AllowDuplicates { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool? AllowPaste { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public IReadOnlyCollection? AllowedExtensions { get; set; }
+
+ ///
+ /// 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.
+ ///
+ 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.
+ ///
+ public bool? Append { get; set; }
+
+ ///
+ /// Calculate the chunk size dynamically based on the user's Internet speed between 512 KB and 10 MB.
+ ///
+ public bool? AutoChunkSize { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool? AutoReset { 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.
+ ///
+ public int? AutoRetries { get; set; }
+
+ ///
+ /// The delay before each automatic retry of a failed upload.
+ /// Set to null (the default) to retry immediately.
+ ///
+ public TimeSpan? AutoRetryDelay { get; set; }
+
+ ///
+ /// Custom delay before each automatic retry, which is what turns the fixed
+ /// into a backoff: it receives the file - whose says what the
+ /// server answered - and the number of the attempt about to be made, counting from 1, and returns how
+ /// long to wait before it. Returning null falls back to the , which is also
+ /// what a provider that throws does, so a miscalculated delay never swallows the retry itself.
+ ///
+ public Func? AutoRetryDelayProvider { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool? AutoUpload { get; set; }
+
+ ///
+ /// 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".
+ ///
+ public string? CancelButtonTitle { get; set; }
+
+ ///
+ /// The text of the "Cancel all" button of the batch actions (see ).
+ ///
+ public string? CancelAllText { get; set; }
+
+ ///
+ /// Gets or sets the icon to use for the cancel upload button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ /// Defaults to the built-in Cancel icon when neither is set.
+ ///
+ ///
+ /// Use this property to render a custom cancel icon from external libraries like FontAwesome or Bootstrap Icons.
+ /// For built-in Fluent UI icons, use instead.
+ ///
+ public BitIconInfo? CancelIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to use for the cancel upload button from the built-in Fluent UI icons.
+ /// Defaults to Cancel when not set.
+ ///
+ ///
+ /// The icon name should be from the Fluent UI icon set (e.g., BitIconName.Cancel).
+ ///
+ /// For external icon libraries, use instead.
+ ///
+ public string? CancelIconName { get; set; }
+
+ ///
+ /// The message shown for canceled file uploads.
+ ///
+ public string? CanceledUploadMessage { get; set; }
+
+ ///
+ /// 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).
+ ///
+ public string? Capture { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public long? ChunkSize { 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.
+ ///
+ public bool? ChunkedUpload { get; set; }
+
+ ///
+ /// Custom CSS classes for different parts of the BitFileUpload.
+ ///
+ public BitFileUploadClassStyles? Classes { get; set; }
+
+ ///
+ /// The text of the "Clear" button of the batch actions (see ).
+ ///
+ public string? ClearText { 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.
+ ///
+ 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.
+ ///
+ 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.
+ ///
+ public string? Description { 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.
+ ///
+ public bool? Directory { get; set; }
+
+ ///
+ /// A CSS selector of one or more elements outside the component that accept a drop as well, which is how
+ /// a whole form, a card or the page itself becomes the drop target while the browse button stays where it
+ /// is. The root element of the component is always a drop zone and needs no selector of its own; the
+ /// elements this one names are matched whenever a drag reaches them, so one rendered after the component
+ /// - or replaced later on - is a drop zone from the moment it matches. While files are dragged over any
+ /// of them, all of them (the root included) carry the Classes.Dragging class and the Styles.Dragging
+ /// inline style, and the focus being inside one of them is also what lets a paste land in this component.
+ ///
+ public string? DropZoneSelector { get; set; }
+
+ ///
+ /// The message shown for the files rejected for being already in the file list
+ /// while is disabled.
+ ///
+ public string? DuplicateErrorMessage { get; set; }
+
+ ///
+ /// The message shown for failed file removes.
+ ///
+ public string? FailedRemoveMessage { get; set; }
+
+ ///
+ /// The message shown for failed file uploads.
+ ///
+ public string? FailedUploadMessage { get; set; }
+
+ ///
+ /// The accessible name of the file list, so that a screen reader user landing on it is told what the list
+ /// they are in holds instead of only how many items it has. Set it to an empty string to leave the list
+ /// unnamed. The default value is "Selected files".
+ ///
+ public string? FileListAriaLabel { get; set; }
+
+ ///
+ /// 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.
+ ///
+ 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.
+ ///
+ public Func? FileValidator { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool? HideFileView { get; set; }
+
+ ///
+ /// Whether to hide the default browse button label from the UI.
+ ///
+ public bool? HideLabel { get; set; }
+
+ ///
+ /// The text of the browse button. Setting it to an empty string hides the button altogether.
+ ///
+ public string? Label { get; set; }
+
+ ///
+ /// The icon of the browse button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ ///
+ public BitIconInfo? LabelIcon { get; set; }
+
+ ///
+ /// The name of the icon of the browse button from the built-in Fluent UI icons.
+ /// Defaults to CloudUpload in the mode, and to no icon otherwise.
+ ///
+ public string? LabelIconName { get; set; }
+
+ ///
+ /// The position of the icon of the browse button relative to its text: before it (the default) or after it.
+ ///
+ public BitIconPosition? LabelIconPosition { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public int? MaxCount { get; set; }
+
+ ///
+ /// Specifies the message shown for the files rejected due to exceeding the maximum number of files.
+ ///
+ public string? MaxCountErrorMessage { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public long? MaxSize { get; set; }
+
+ ///
+ /// The message shown for the files rejected for being larger than the .
+ ///
+ public string? MaxSizeErrorMessage { get; set; }
+
+ ///
+ /// 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.
+ ///
+ 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.
+ ///
+ public string? MaxTotalSizeErrorMessage { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public long? MinSize { get; set; }
+
+ ///
+ /// The message shown for the files rejected for being smaller than the .
+ ///
+ public string? MinSizeErrorMessage { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool? Multiple { get; set; }
+
+ ///
+ /// The message shown for the files rejected for not matching any entry of .
+ ///
+ public string? NotAllowedExtensionErrorMessage { 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".
+ ///
+ public string? PauseButtonTitle { get; set; }
+
+ ///
+ /// Gets or sets the icon to use for the pause upload button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ /// Defaults to the built-in Pause icon when neither is set.
+ ///
+ ///
+ /// Use this property to render a custom pause icon from external libraries like FontAwesome or Bootstrap Icons.
+ /// For built-in Fluent UI icons, use instead.
+ ///
+ public BitIconInfo? PauseIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to use for the pause upload button from the built-in Fluent UI icons.
+ /// Defaults to Pause when not set.
+ ///
+ ///
+ /// The icon name should be from the Fluent UI icon set (e.g., BitIconName.Pause).
+ ///
+ /// For external icon libraries, use instead.
+ ///
+ public string? PauseIconName { get; set; }
+
+ ///
+ /// The status message shown for the files of the PreloadedFiles parameter, which are already on the
+ /// server rather than freshly uploaded and would otherwise read as an upload that just succeeded.
+ ///
+ public string? PreloadedFileMessage { 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.
+ ///
+ public string? QueuedUploadMessage { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool? ReadImageDimensions { get; set; }
+
+ ///
+ /// 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".
+ ///
+ public string? RemoveButtonTitle { 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.
+ /// Defaults to the built-in Delete icon when neither is set.
+ ///
+ ///
+ /// Use this property to render a custom remove icon from external libraries like FontAwesome or Bootstrap Icons.
+ /// For built-in Fluent UI icons, use instead.
+ ///
+ public BitIconInfo? RemoveIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to use for the remove file button from the built-in Fluent UI icons.
+ /// Defaults to Delete when not set.
+ ///
+ ///
+ /// The icon name should be from the Fluent UI icon set (e.g., BitIconName.Delete).
+ ///
+ /// For external icon libraries, use instead.
+ ///
+ public string? RemoveIconName { get; set; }
+
+ ///
+ /// Custom HTTP headers attached to the remove request.
+ ///
+ public Dictionary? RemoveRequestHttpHeaders { get; set; }
+
+ ///
+ /// The provider function creating the HTTP headers of the remove request, invoked right before the
+ /// request goes out and taking precedence over .
+ ///
+ public Func>>? RemoveRequestHttpHeadersProvider { get; set; }
+
+ ///
+ /// The HTTP method of the remove request (e.g., "POST"). Defaults to "DELETE".
+ ///
+ public string? RemoveRequestHttpMethod { get; set; }
+
+ ///
+ /// Custom query strings appended to the URL of the remove request.
+ ///
+ public Dictionary? RemoveRequestQueryStrings { get; set; }
+
+ ///
+ /// The provider function creating the query strings of the remove request, invoked right before the
+ /// request goes out and taking precedence over .
+ ///
+ public Func>>? RemoveRequestQueryStringsProvider { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public string? RemoveUrl { 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".
+ ///
+ public string? RetryButtonTitle { get; set; }
+
+ ///
+ /// 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.
+ ///
+ 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.
+ ///
+ public string? RetryIconName { 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.
+ ///
+ public Func? ShouldAutoRetry { get; set; }
+
+ ///
+ /// Whether an action bar with "Upload all", "Cancel all" and "Clear" buttons is rendered under the file list.
+ ///
+ public bool? ShowBatchActions { get; set; }
+
+ ///
+ /// Whether the browse button is rendered as a large drop area instead of a regular button.
+ ///
+ public bool? ShowDropArea { 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.
+ /// A file that is not an image takes a glyph of its type in a box of the same size instead, so that
+ /// the names of a mixed list stay lined up along one edge.
+ ///
+ 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 .
+ ///
+ public bool? ShowRemoveButton { get; set; }
+
+ ///
+ /// The size of the file upload, applied to the browse button and the file list items.
+ ///
+ public BitSize? Size { get; set; }
+
+ ///
+ /// Custom CSS styles for different parts of the BitFileUpload.
+ ///
+ public BitFileUploadClassStyles? Styles { get; set; }
+
+ ///
+ /// The message shown for successful file uploads.
+ ///
+ public string? SuccessfulUploadMessage { get; set; }
+
+ ///
+ /// The text of the "Upload all" button of the batch actions (see ).
+ ///
+ public string? UploadAllText { get; set; }
+
+ ///
+ /// 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".
+ ///
+ public string? UploadButtonTitle { get; set; }
+
+ ///
+ /// The name of the form field carrying the file content in the upload request. Defaults to "file".
+ ///
+ public string? UploadFormFieldName { get; set; }
+
+ ///
+ /// Gets or sets the icon to use for the upload button using custom CSS classes for external icon libraries.
+ /// Takes precedence over when both are set.
+ /// Defaults to the built-in Play icon when neither is set.
+ ///
+ ///
+ /// Use this property to render a custom upload icon from external libraries like FontAwesome or Bootstrap Icons.
+ /// For built-in Fluent UI icons, use instead.
+ ///
+ public BitIconInfo? UploadIcon { get; set; }
+
+ ///
+ /// Gets or sets the name of the icon to use for the upload button from the built-in Fluent UI icons.
+ /// Defaults to Play when not set.
+ ///
+ ///
+ /// The icon name should be from the Fluent UI icon set (e.g., BitIconName.Play).
+ ///
+ /// For external icon libraries, use instead.
+ ///
+ public string? UploadIconName { 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.
+ ///
+ public Dictionary? UploadRequestFormFields { get; set; }
+
+ ///
+ /// Custom HTTP headers attached to the upload requests, fixed at selection time.
+ ///
+ 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.
+ ///
+ public Func>>? UploadRequestHttpHeadersProvider { get; set; }
+
+ ///
+ /// The HTTP method of the upload request (e.g., "PUT"). Defaults to "POST".
+ ///
+ public string? UploadRequestHttpMethod { get; set; }
+
+ ///
+ /// Custom query strings appended to the URL of the upload requests, fixed at selection time.
+ ///
+ 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.
+ ///
+ public Func>>? UploadRequestQueryStringsProvider { get; set; }
+
+ ///
+ /// 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.
+ ///
+ 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.
+ ///
+ 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.
+ ///
+ 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.
+ ///
+ 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).
+ ///
+ public bool? WithCredentials { get; set; }
+
+
+ ///
+ /// Updates the properties of the specified instance with any values that have been set on
+ /// this object, if those properties have not already been set on the .
+ ///
+ ///
+ /// Only properties that have a value set and have not already been set on the will be updated.
+ /// This method does not overwrite existing values on .
+ ///
+ ///
+ /// The instance whose properties will be updated. Cannot be null.
+ ///
+ public void UpdateParameters(BitFileUpload bitFileUpload)
+ {
+ if (bitFileUpload is null) return;
+
+ UpdateBaseParameters(bitFileUpload);
+
+ // the working chunk size is only worth deriving again when one of the two parameters it comes from
+ // actually moves, exactly as the generated setter of each of them decides.
+ var chunkSizeChanged = false;
+
+ if (Accept.HasValue() && bitFileUpload.HasNotBeenSet(nameof(Accept)))
+ {
+ bitFileUpload.Accept = Accept;
+ }
+
+ if (AllowDrop.HasValue && bitFileUpload.HasNotBeenSet(nameof(AllowDrop)))
+ {
+ bitFileUpload.AllowDrop = AllowDrop.Value;
+ }
+
+ if (AllowDuplicates.HasValue && bitFileUpload.HasNotBeenSet(nameof(AllowDuplicates)))
+ {
+ bitFileUpload.AllowDuplicates = AllowDuplicates.Value;
+ }
+
+ if (AllowPaste.HasValue && bitFileUpload.HasNotBeenSet(nameof(AllowPaste)))
+ {
+ bitFileUpload.AllowPaste = AllowPaste.Value;
+ }
+
+ if (AllowedExtensions is not null && bitFileUpload.HasNotBeenSet(nameof(AllowedExtensions)))
+ {
+ bitFileUpload.AllowedExtensions = AllowedExtensions;
+ }
+
+ if (AnnouncementProvider is not null && bitFileUpload.HasNotBeenSet(nameof(AnnouncementProvider)))
+ {
+ bitFileUpload.AnnouncementProvider = AnnouncementProvider;
+ }
+
+ if (Append.HasValue && bitFileUpload.HasNotBeenSet(nameof(Append)))
+ {
+ bitFileUpload.Append = Append.Value;
+ }
+
+ if (AutoChunkSize.HasValue && bitFileUpload.HasNotBeenSet(nameof(AutoChunkSize)))
+ {
+ chunkSizeChanged = chunkSizeChanged || bitFileUpload.AutoChunkSize != AutoChunkSize.Value;
+
+ bitFileUpload.AutoChunkSize = AutoChunkSize.Value;
+ }
+
+ if (AutoReset.HasValue && bitFileUpload.HasNotBeenSet(nameof(AutoReset)))
+ {
+ bitFileUpload.AutoReset = AutoReset.Value;
+ }
+
+ if (AutoRetries.HasValue && bitFileUpload.HasNotBeenSet(nameof(AutoRetries)))
+ {
+ bitFileUpload.AutoRetries = AutoRetries.Value;
+ }
+
+ if (AutoRetryDelay.HasValue && bitFileUpload.HasNotBeenSet(nameof(AutoRetryDelay)))
+ {
+ bitFileUpload.AutoRetryDelay = AutoRetryDelay.Value;
+ }
+
+ if (AutoRetryDelayProvider is not null && bitFileUpload.HasNotBeenSet(nameof(AutoRetryDelayProvider)))
+ {
+ bitFileUpload.AutoRetryDelayProvider = AutoRetryDelayProvider;
+ }
+
+ if (AutoUpload.HasValue && bitFileUpload.HasNotBeenSet(nameof(AutoUpload)))
+ {
+ bitFileUpload.AutoUpload = AutoUpload.Value;
+ }
+
+ if (CancelButtonTitle.HasValue() && bitFileUpload.HasNotBeenSet(nameof(CancelButtonTitle)))
+ {
+ bitFileUpload.CancelButtonTitle = CancelButtonTitle;
+ }
+
+ if (CancelAllText.HasValue() && bitFileUpload.HasNotBeenSet(nameof(CancelAllText)))
+ {
+ bitFileUpload.CancelAllText = CancelAllText!;
+ }
+
+ if (CancelIcon is not null && bitFileUpload.HasNotBeenSet(nameof(CancelIcon)))
+ {
+ bitFileUpload.CancelIcon = CancelIcon;
+ }
+
+ if (CancelIconName.HasValue() && bitFileUpload.HasNotBeenSet(nameof(CancelIconName)))
+ {
+ bitFileUpload.CancelIconName = CancelIconName;
+ }
+
+ if (CanceledUploadMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(CanceledUploadMessage)))
+ {
+ bitFileUpload.CanceledUploadMessage = CanceledUploadMessage!;
+ }
+
+ if (Capture.HasValue() && bitFileUpload.HasNotBeenSet(nameof(Capture)))
+ {
+ bitFileUpload.Capture = Capture;
+ }
+
+ if (ChunkSize.HasValue && bitFileUpload.HasNotBeenSet(nameof(ChunkSize)))
+ {
+ chunkSizeChanged = chunkSizeChanged || bitFileUpload.ChunkSize != ChunkSize.Value;
+
+ bitFileUpload.ChunkSize = ChunkSize.Value;
+ }
+
+ if (ChunkedUpload.HasValue && bitFileUpload.HasNotBeenSet(nameof(ChunkedUpload)))
+ {
+ bitFileUpload.ChunkedUpload = ChunkedUpload.Value;
+ }
+
+ if (Classes is not null && bitFileUpload.HasNotBeenSet(nameof(Classes)))
+ {
+ bitFileUpload.Classes = Classes;
+
+ bitFileUpload.ClassBuilder.Reset();
+ }
+
+ if (ClearText.HasValue() && bitFileUpload.HasNotBeenSet(nameof(ClearText)))
+ {
+ bitFileUpload.ClearText = ClearText!;
+ }
+
+ if (Color.HasValue && bitFileUpload.HasNotBeenSet(nameof(Color)))
+ {
+ bitFileUpload.Color = Color.Value;
+
+ bitFileUpload.ClassBuilder.Reset();
+ }
+
+ if (ConcurrentUploads.HasValue && bitFileUpload.HasNotBeenSet(nameof(ConcurrentUploads)))
+ {
+ bitFileUpload.ConcurrentUploads = ConcurrentUploads.Value;
+ }
+
+ if (Description.HasValue() && bitFileUpload.HasNotBeenSet(nameof(Description)))
+ {
+ bitFileUpload.Description = Description;
+ }
+
+ if (Directory.HasValue && bitFileUpload.HasNotBeenSet(nameof(Directory)))
+ {
+ bitFileUpload.Directory = Directory.Value;
+ }
+
+ if (DropZoneSelector.HasValue() && bitFileUpload.HasNotBeenSet(nameof(DropZoneSelector)))
+ {
+ bitFileUpload.DropZoneSelector = DropZoneSelector;
+ }
+
+ if (DuplicateErrorMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(DuplicateErrorMessage)))
+ {
+ bitFileUpload.DuplicateErrorMessage = DuplicateErrorMessage!;
+ }
+
+ if (FailedRemoveMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(FailedRemoveMessage)))
+ {
+ bitFileUpload.FailedRemoveMessage = FailedRemoveMessage!;
+ }
+
+ if (FailedUploadMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(FailedUploadMessage)))
+ {
+ bitFileUpload.FailedUploadMessage = FailedUploadMessage!;
+ }
+
+ if (FileListAriaLabel.HasValue() && bitFileUpload.HasNotBeenSet(nameof(FileListAriaLabel)))
+ {
+ bitFileUpload.FileListAriaLabel = FileListAriaLabel!;
+ }
+
+ if (FileSizeFormatter is not null && bitFileUpload.HasNotBeenSet(nameof(FileSizeFormatter)))
+ {
+ bitFileUpload.FileSizeFormatter = FileSizeFormatter;
+ }
+
+ if (FileValidator is not null && bitFileUpload.HasNotBeenSet(nameof(FileValidator)))
+ {
+ bitFileUpload.FileValidator = FileValidator;
+ }
+
+ if (HideFileView.HasValue && bitFileUpload.HasNotBeenSet(nameof(HideFileView)))
+ {
+ bitFileUpload.HideFileView = HideFileView.Value;
+ }
+
+ if (HideLabel.HasValue && bitFileUpload.HasNotBeenSet(nameof(HideLabel)))
+ {
+ bitFileUpload.HideLabel = HideLabel.Value;
+ }
+
+ if (Label.HasValue() && bitFileUpload.HasNotBeenSet(nameof(Label)))
+ {
+ bitFileUpload.Label = Label!;
+ }
+
+ if (LabelIcon is not null && bitFileUpload.HasNotBeenSet(nameof(LabelIcon)))
+ {
+ bitFileUpload.LabelIcon = LabelIcon;
+ }
+
+ if (LabelIconName.HasValue() && bitFileUpload.HasNotBeenSet(nameof(LabelIconName)))
+ {
+ bitFileUpload.LabelIconName = LabelIconName;
+ }
+
+ if (LabelIconPosition.HasValue && bitFileUpload.HasNotBeenSet(nameof(LabelIconPosition)))
+ {
+ bitFileUpload.LabelIconPosition = LabelIconPosition.Value;
+
+ bitFileUpload.ClassBuilder.Reset();
+ }
+
+ if (MaxCount.HasValue && bitFileUpload.HasNotBeenSet(nameof(MaxCount)))
+ {
+ bitFileUpload.MaxCount = MaxCount.Value;
+ }
+
+ if (MaxCountErrorMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(MaxCountErrorMessage)))
+ {
+ bitFileUpload.MaxCountErrorMessage = MaxCountErrorMessage!;
+ }
+
+ if (MaxSize.HasValue && bitFileUpload.HasNotBeenSet(nameof(MaxSize)))
+ {
+ bitFileUpload.MaxSize = MaxSize.Value;
+ }
+
+ if (MaxSizeErrorMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(MaxSizeErrorMessage)))
+ {
+ bitFileUpload.MaxSizeErrorMessage = MaxSizeErrorMessage!;
+ }
+
+ if (MaxTotalSize.HasValue && bitFileUpload.HasNotBeenSet(nameof(MaxTotalSize)))
+ {
+ bitFileUpload.MaxTotalSize = MaxTotalSize.Value;
+ }
+
+ if (MaxTotalSizeErrorMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(MaxTotalSizeErrorMessage)))
+ {
+ bitFileUpload.MaxTotalSizeErrorMessage = MaxTotalSizeErrorMessage!;
+ }
+
+ if (MinSize.HasValue && bitFileUpload.HasNotBeenSet(nameof(MinSize)))
+ {
+ bitFileUpload.MinSize = MinSize.Value;
+ }
+
+ if (MinSizeErrorMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(MinSizeErrorMessage)))
+ {
+ bitFileUpload.MinSizeErrorMessage = MinSizeErrorMessage!;
+ }
+
+ if (Multiple.HasValue && bitFileUpload.HasNotBeenSet(nameof(Multiple)))
+ {
+ bitFileUpload.Multiple = Multiple.Value;
+ }
+
+ if (NotAllowedExtensionErrorMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(NotAllowedExtensionErrorMessage)))
+ {
+ bitFileUpload.NotAllowedExtensionErrorMessage = NotAllowedExtensionErrorMessage!;
+ }
+
+ if (PauseButtonTitle.HasValue() && bitFileUpload.HasNotBeenSet(nameof(PauseButtonTitle)))
+ {
+ bitFileUpload.PauseButtonTitle = PauseButtonTitle;
+ }
+
+ if (PauseIcon is not null && bitFileUpload.HasNotBeenSet(nameof(PauseIcon)))
+ {
+ bitFileUpload.PauseIcon = PauseIcon;
+ }
+
+ if (PauseIconName.HasValue() && bitFileUpload.HasNotBeenSet(nameof(PauseIconName)))
+ {
+ bitFileUpload.PauseIconName = PauseIconName;
+ }
+
+ if (PreloadedFileMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(PreloadedFileMessage)))
+ {
+ bitFileUpload.PreloadedFileMessage = PreloadedFileMessage!;
+ }
+
+ if (QueuedUploadMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(QueuedUploadMessage)))
+ {
+ bitFileUpload.QueuedUploadMessage = QueuedUploadMessage!;
+ }
+
+ if (ReadImageDimensions.HasValue && bitFileUpload.HasNotBeenSet(nameof(ReadImageDimensions)))
+ {
+ bitFileUpload.ReadImageDimensions = ReadImageDimensions.Value;
+ }
+
+ if (RemoveButtonTitle.HasValue() && bitFileUpload.HasNotBeenSet(nameof(RemoveButtonTitle)))
+ {
+ bitFileUpload.RemoveButtonTitle = RemoveButtonTitle;
+ }
+
+ if (RemoveIcon is not null && bitFileUpload.HasNotBeenSet(nameof(RemoveIcon)))
+ {
+ bitFileUpload.RemoveIcon = RemoveIcon;
+ }
+
+ if (RemoveIconName.HasValue() && bitFileUpload.HasNotBeenSet(nameof(RemoveIconName)))
+ {
+ bitFileUpload.RemoveIconName = RemoveIconName;
+ }
+
+ if (RemoveRequestHttpHeaders is not null && bitFileUpload.HasNotBeenSet(nameof(RemoveRequestHttpHeaders)))
+ {
+ bitFileUpload.RemoveRequestHttpHeaders = RemoveRequestHttpHeaders;
+ }
+
+ if (RemoveRequestHttpHeadersProvider is not null && bitFileUpload.HasNotBeenSet(nameof(RemoveRequestHttpHeadersProvider)))
+ {
+ bitFileUpload.RemoveRequestHttpHeadersProvider = RemoveRequestHttpHeadersProvider;
+ }
+
+ if (RemoveRequestHttpMethod.HasValue() && bitFileUpload.HasNotBeenSet(nameof(RemoveRequestHttpMethod)))
+ {
+ bitFileUpload.RemoveRequestHttpMethod = RemoveRequestHttpMethod;
+ }
+
+ if (RemoveRequestQueryStrings is not null && bitFileUpload.HasNotBeenSet(nameof(RemoveRequestQueryStrings)))
+ {
+ bitFileUpload.RemoveRequestQueryStrings = RemoveRequestQueryStrings;
+ }
+
+ if (RemoveRequestQueryStringsProvider is not null && bitFileUpload.HasNotBeenSet(nameof(RemoveRequestQueryStringsProvider)))
+ {
+ bitFileUpload.RemoveRequestQueryStringsProvider = RemoveRequestQueryStringsProvider;
+ }
+
+ if (RemoveUrl.HasValue() && bitFileUpload.HasNotBeenSet(nameof(RemoveUrl)))
+ {
+ bitFileUpload.RemoveUrl = RemoveUrl;
+ }
+
+ if (RetryButtonTitle.HasValue() && bitFileUpload.HasNotBeenSet(nameof(RetryButtonTitle)))
+ {
+ bitFileUpload.RetryButtonTitle = RetryButtonTitle;
+ }
+
+ if (RetryIcon is not null && bitFileUpload.HasNotBeenSet(nameof(RetryIcon)))
+ {
+ bitFileUpload.RetryIcon = RetryIcon;
+ }
+
+ if (RetryIconName.HasValue() && bitFileUpload.HasNotBeenSet(nameof(RetryIconName)))
+ {
+ bitFileUpload.RetryIconName = RetryIconName;
+ }
+
+ if (ShouldAutoRetry is not null && bitFileUpload.HasNotBeenSet(nameof(ShouldAutoRetry)))
+ {
+ bitFileUpload.ShouldAutoRetry = ShouldAutoRetry;
+ }
+
+ if (ShowBatchActions.HasValue && bitFileUpload.HasNotBeenSet(nameof(ShowBatchActions)))
+ {
+ bitFileUpload.ShowBatchActions = ShowBatchActions.Value;
+ }
+
+ if (ShowDropArea.HasValue && bitFileUpload.HasNotBeenSet(nameof(ShowDropArea)))
+ {
+ bitFileUpload.ShowDropArea = ShowDropArea.Value;
+
+ bitFileUpload.ClassBuilder.Reset();
+ }
+
+ if (ShowPreview.HasValue && bitFileUpload.HasNotBeenSet(nameof(ShowPreview)))
+ {
+ bitFileUpload.ShowPreview = ShowPreview.Value;
+ }
+
+ if (ShowRemoveButton.HasValue && bitFileUpload.HasNotBeenSet(nameof(ShowRemoveButton)))
+ {
+ bitFileUpload.ShowRemoveButton = ShowRemoveButton.Value;
+ }
+
+ if (Size.HasValue && bitFileUpload.HasNotBeenSet(nameof(Size)))
+ {
+ bitFileUpload.Size = Size.Value;
+
+ bitFileUpload.ClassBuilder.Reset();
+ }
+
+ if (Styles is not null && bitFileUpload.HasNotBeenSet(nameof(Styles)))
+ {
+ bitFileUpload.Styles = Styles;
+
+ bitFileUpload.StyleBuilder.Reset();
+ }
+
+ if (SuccessfulUploadMessage.HasValue() && bitFileUpload.HasNotBeenSet(nameof(SuccessfulUploadMessage)))
+ {
+ bitFileUpload.SuccessfulUploadMessage = SuccessfulUploadMessage!;
+ }
+
+ if (UploadAllText.HasValue() && bitFileUpload.HasNotBeenSet(nameof(UploadAllText)))
+ {
+ bitFileUpload.UploadAllText = UploadAllText!;
+ }
+
+ if (UploadButtonTitle.HasValue() && bitFileUpload.HasNotBeenSet(nameof(UploadButtonTitle)))
+ {
+ bitFileUpload.UploadButtonTitle = UploadButtonTitle;
+ }
+
+ if (UploadFormFieldName.HasValue() && bitFileUpload.HasNotBeenSet(nameof(UploadFormFieldName)))
+ {
+ bitFileUpload.UploadFormFieldName = UploadFormFieldName;
+ }
+
+ if (UploadIcon is not null && bitFileUpload.HasNotBeenSet(nameof(UploadIcon)))
+ {
+ bitFileUpload.UploadIcon = UploadIcon;
+ }
+
+ if (UploadIconName.HasValue() && bitFileUpload.HasNotBeenSet(nameof(UploadIconName)))
+ {
+ bitFileUpload.UploadIconName = UploadIconName;
+ }
+
+ if (UploadRequestFormFields is not null && bitFileUpload.HasNotBeenSet(nameof(UploadRequestFormFields)))
+ {
+ bitFileUpload.UploadRequestFormFields = UploadRequestFormFields;
+ }
+
+ if (UploadRequestHttpHeaders is not null && bitFileUpload.HasNotBeenSet(nameof(UploadRequestHttpHeaders)))
+ {
+ bitFileUpload.UploadRequestHttpHeaders = UploadRequestHttpHeaders;
+ }
+
+ if (UploadRequestHttpHeadersProvider is not null && bitFileUpload.HasNotBeenSet(nameof(UploadRequestHttpHeadersProvider)))
+ {
+ bitFileUpload.UploadRequestHttpHeadersProvider = UploadRequestHttpHeadersProvider;
+ }
+
+ if (UploadRequestHttpMethod.HasValue() && bitFileUpload.HasNotBeenSet(nameof(UploadRequestHttpMethod)))
+ {
+ bitFileUpload.UploadRequestHttpMethod = UploadRequestHttpMethod;
+ }
+
+ if (UploadRequestQueryStrings is not null && bitFileUpload.HasNotBeenSet(nameof(UploadRequestQueryStrings)))
+ {
+ bitFileUpload.UploadRequestQueryStrings = UploadRequestQueryStrings;
+ }
+
+ if (UploadRequestQueryStringsProvider is not null && bitFileUpload.HasNotBeenSet(nameof(UploadRequestQueryStringsProvider)))
+ {
+ bitFileUpload.UploadRequestQueryStringsProvider = UploadRequestQueryStringsProvider;
+ }
+
+ if (UploadTimeout.HasValue && bitFileUpload.HasNotBeenSet(nameof(UploadTimeout)))
+ {
+ bitFileUpload.UploadTimeout = UploadTimeout.Value;
+ }
+
+ if (UploadUrl.HasValue() && bitFileUpload.HasNotBeenSet(nameof(UploadUrl)))
+ {
+ bitFileUpload.UploadUrl = UploadUrl;
+ }
+
+ if (UploadUrlProvider is not null && bitFileUpload.HasNotBeenSet(nameof(UploadUrlProvider)))
+ {
+ bitFileUpload.UploadUrlProvider = UploadUrlProvider;
+ }
+
+ if (Variant.HasValue && bitFileUpload.HasNotBeenSet(nameof(Variant)))
+ {
+ bitFileUpload.Variant = Variant.Value;
+
+ bitFileUpload.ClassBuilder.Reset();
+ }
+
+ if (WithCredentials.HasValue && bitFileUpload.HasNotBeenSet(nameof(WithCredentials)))
+ {
+ bitFileUpload.WithCredentials = WithCredentials.Value;
+ }
+
+ // The working chunk size is derived from ChunkSize and AutoChunkSize by a setter that assigning
+ // the properties here goes around, so it is derived again once both of them are in place. Deriving
+ // it on every parameter set instead would throw away the speed AutoChunkSize has measured, since
+ // this runs on each render of whatever holds the BitParams rather than only when one of them moves.
+ if (chunkSizeChanged)
+ {
+ bitFileUpload.OnSetChunkSize();
+ }
+ }
+}
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor
index 2e2f611ee41..4a62206eeb0 100644
--- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor
+++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/FileUpload/_BitFileUploadItem.razor
@@ -2,36 +2,62 @@
@{
var fileUploadPercent = GetFileUploadPercent(Item);
+
+ // which action buttons this render draws is worked out right here, before any of them is written out,
+ // so the flags the focus management reads are never left over from the render before this one.
+ _hasUploadButton = false;
+ _hasPauseButton = false;
+ _hasCancelButton = false;
+ _hasRemoveButton = false;
}
@if (Item.Status != BitFileUploadStatus.Removed)
{
- @if (FileUpload.ShowPreview && Item.PreviewUrl is not null)
+ class="bit-upl-itm @GetFileElClass(Item) @FileUpload.Classes?.FileItem">
+ @if (FileUpload.ShowPreview)
{
- @* 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. *@
-
+ if (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. *@
+
+ }
+ else
+ {
+ @* only an image has a thumbnail of its own, and a list of mixed files would otherwise be
+ ragged - every other kind takes a glyph of its type in a box of exactly the same size.
+ it says nothing a screen reader has not already been told by the name beside it. *@
+
+
+
+ }
}
@Item.Name
+ @* a file that is already on the server never travels anywhere from here, so it has one size
+ rather than a count running up to it - "1.2 MB/1.2 MB" reads as a transfer that just ran. *@
- @($"{FormatSize(GetFileUploadSize(Item))}/{FormatSize(Item.Size)}")
+ @(Item.IsPreloaded
+ ? FormatSize(Item.Size)
+ : $"{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)
+ @if (Item.Status != BitFileUploadStatus.NotAllowed && Item.IsPreloaded is false)
{
@fileUploadPercent%
@@ -73,7 +99,9 @@
var uploadTitle = isRetry
? (FileUpload.RetryButtonTitle ?? FileUpload.UploadButtonTitle ?? "Retry")
: (FileUpload.UploadButtonTitle ?? "Upload");
-