From 5a28d32ca2f30c3893c1bf537c49d5a06dc66e0b Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 21 Sep 2026 22:35:57 +0330 Subject: [PATCH 1/3] improve theme infra of BitTagsInput #13337 --- .../Inputs/TagsInput/BitTagsInput.razor | 55 +- .../Inputs/TagsInput/BitTagsInput.razor.cs | 264 ++++- .../Inputs/TagsInput/BitTagsInput.scss | 321 ++++-- .../TagsInput/BitTagsInputClassStyles.cs | 15 +- .../Inputs/TagsInput/BitTagsInputParams.cs | 766 ++++++++++++++ .../Inputs/TagsInput/BitTagsInputDemo.razor | 958 ++++++++---------- .../TagsInput/BitTagsInputDemo.razor.cs | 410 +++++++- .../BitTagsInputDemo.razor.samples.cs | 699 +++++++------ .../Inputs/TagsInput/BitTagsInputTests.cs | 458 ++++++++- 9 files changed, 2987 insertions(+), 959 deletions(-) create mode 100644 src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInputParams.cs diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor index 2cf38e175c2..6d3760b2059 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor @@ -10,7 +10,7 @@ var hasLabel = LabelTemplate is not null || Label.HasValue(); var hasDescription = DescriptionTemplate is not null || Description.HasValue(); var isInteractive = IsEnabled && ReadOnly is false; - var showClearButton = ShowClearButton && isInteractive && (tagCount > 0 || _inputText.Length > 0); + var showClearButton = ShowClearButton && isInteractive && (HasRemovableTag() || _inputText.Length > 0); // The tags beyond the MaxDisplayedTags are folded away behind a chip that says how many they are, // which the reader unfolds the list with; the value itself is untouched, only how much of it is drawn. @@ -27,7 +27,14 @@ var displayedTagCount = GetDisplayedTagCount(); var hiddenTagCount = tagCount - displayedTagCount; - var tagHint = GetTagAriaDescription(); + // What the keyboard can do with the tag just reached. A tag the CanRemoveTag predicate holds in place + // answers to one gesture fewer, so it is described by a sentence of its own rather than being promised + // a removal that does nothing; where the two sentences are the same one element serves every chip. + var tagHint = GetTagAriaDescription(true); + var fixedTagHint = CanRemoveTag is null ? tagHint : GetTagAriaDescription(false); + var sameTagHint = string.Equals(tagHint, fixedTagHint, StringComparison.Ordinal); + var tagHintId = tagHint is null ? null : _hintId; + var fixedTagHintId = fixedTagHint is null ? null : (sameTagHint ? _hintId : _fixedHintId); // The element references are what the arrow key navigation moves the focus with; the array is // rebuilt whenever the number of drawn tags changes so that a stale reference is never focused. @@ -107,6 +114,7 @@ var tag = tags![index]; var isTagFocused = _focusedTagIndex == index; var isTagEditing = _editingTagIndex == index; + var isTagRemovable = CanRemove(tag); @* The tag itself is the focusable element rather than its dismiss button: a single roving tab stop covers the whole list, which the arrow keys then walk through, @@ -133,10 +141,10 @@ draggable="@(CanDragTag(index) ? "true" : null)" aria-posinset="@(index + 1)" aria-setsize="@tagCount" - aria-describedby="@(tagHint is null ? null : _hintId)" + aria-describedby="@(isTagRemovable ? tagHintId : fixedTagHintId)" tabindex="@(isTagEditing ? "-1" : GetTagTabIndex(index, displayedTagCount))" - style="@BuildTagStyle(tag, isTagFocused)" - class="@BuildTagClass(tag, index, isTagFocused)"> + style="@BuildTagStyle(tag, isTagFocused, isTagRemovable)" + class="@BuildTagClass(tag, index, isTagFocused, isTagRemovable)"> @if (isTagEditing) { @* The little input that replaces the tag while it is being corrected in @@ -180,7 +188,7 @@ @tag } - @if (isInteractive && isTagEditing is false) + @if (isInteractive && isTagEditing is false && isTagRemovable) { @* Out of the tab order on purpose: the tag holding it is the tab stop, and the Delete/Backspace keys are what remove it from the keyboard. *@ @@ -218,9 +226,11 @@ } - @* autocomplete is turned off on purpose: the browser's own autofill would otherwise be offered - over the suggestion list of the field, and a value it saved for a single line text box is the - last tag that was typed rather than the list the field holds. *@ + @* autocomplete is turned off unless the consumer asks for something else: the browser's own autofill + would otherwise be offered over the suggestion list of the field, and a value it saved for a single + line text box is the last tag that was typed rather than the list the field holds. + Spell checking is off for the same kind of reason - a tag is a value rather than a sentence - and + SpellCheck turns it back on for a field that does collect words. *@ + aria-describedby="@GetDescribedBy(hasDescription)" + autocomplete="@(AutoComplete ?? "off")" /> + + @* The field is waiting for something of its own - the suggestions being fetched for what is being + typed, most of the time. It is an indeterminate progressbar rather than a decoration: a spinner + that says nothing is a spinner a screen reader never learns about, and a progressbar with no + value is exactly the "busy, for how long nobody knows" this stands for. It never interrupts, + which is why it is not a live region. *@ + @if (IsLoading) + { +
+ } @if (showClearButton) { @@ -315,6 +340,12 @@
@tagHint
} + @if (sameTagHint is false && fixedTagHint is not null) + { + @* The same sentence for the tags CanRemoveTag holds in place, less the removal they do not answer to. *@ +
@fixedTagHint
+ } + @if (suggestions.Count > 0) { @* The suggestion list is the browser's own, which is what keeps it reachable, positioned and diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor.cs index 891410a4304..5739f785976 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor.cs @@ -10,9 +10,11 @@ namespace Bit.BlazorUI; /// pasted text over its separators, caps the number of tags and the length of each of them, rejects the ones /// that fail a pattern or a validator of yours, normalizes them through a transformer, and reports every one of /// those rejections. The chips form an accessible list that the arrow keys walk through, each of them removable -/// with the keyboard as well as with its dismiss button, correctable in place and movable within the list, with -/// a long list folded away behind a chip rather than grown into a wall. Every change can be watched or called -/// off before it happens, the whole component can be driven from code, and the whole thing takes part in an +/// with the keyboard as well as with its dismiss button - unless a predicate of yours pins it in place - and each +/// correctable in place and movable within the list, with a long list folded away behind a chip rather than grown +/// into a wall, and a spinner for the suggestions the field is still fetching. Every change can be watched or +/// called off before it happens, the whole component can be driven from code, its look and its rules can be +/// cascaded to a whole form through , and the whole thing takes part in an /// EditForm like any other input. /// public partial class BitTagsInput : BitInputBase?> @@ -29,6 +31,7 @@ public partial class BitTagsInput : BitInputBase?> private string _listId = string.Empty; private string _tagsId = string.Empty; private string _hintId = string.Empty; + private string _fixedHintId = string.Empty; private string _descriptionId = string.Empty; private bool _tagsExpanded; private string? _separatorsJson; @@ -60,6 +63,26 @@ public partial class BitTagsInput : BitInputBase?> private int _draggingTagIndex = -1; private int _dragOverTagIndex = -1; + private string? _inputMode; + private string? _enterKeyHint; + + + + /// + /// Gets or sets the cascading parameters for the tags input 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 tags input + /// components through the component. What travels down is the configuration of the + /// field - its look, its rules, its wording - and not its value: Value, DefaultValue, + /// Name, Required and ReadOnly belong to the one field that holds them and are written + /// on the component itself. + ///
+ [CascadingParameter(Name = BitTagsInputParams.ParamName)] + public BitTagsInputParams? CascadingParameters { get; set; } + /// @@ -86,6 +109,15 @@ public partial class BitTagsInput : BitInputBase?> /// [Parameter] public string? AddedManyAnnouncementFormat { get; set; } + /// + /// Sets the autocomplete html attribute of the input element. It defaults to off, since the + /// browser's own autofill would otherwise be offered over the suggestion list of the field - and what it + /// saved for a single line text box is the last tag that was typed rather than the list the field holds. + /// Set it to a token of your own (email, off, a one-time-code, ...) where the field collects + /// values the browser does know about, such as a row of recipients. + /// + [Parameter] public string? AutoComplete { get; set; } + /// /// Whether the input should receive focus on first render. /// @@ -106,6 +138,22 @@ public partial class BitTagsInput : BitInputBase?> /// [Parameter] public bool CancelConfirmKeysOnEmpty { get; set; } + /// + /// A predicate deciding which tags the user is allowed to take off the list, for the values a field + /// holds but does not let go of: the owner of the document among its editors, the tag a saved filter + /// is built on. A tag it turns down is drawn without a dismiss button, ignores the Delete and + /// Backspace keys, is left where it is by the Backspace pressed on the empty input, and stays behind + /// when the field is cleared - so "clear" empties the field of everything it can be emptied of. It is + /// still editable and still movable, since neither takes the tag away. It is called for every drawn + /// tag on every render, so it should be a lookup rather than a computation, and an exception thrown + /// out of it leaves the tag removable rather than locking it into the list for good. + ///
+ /// It governs what the user may do, not what the consumer may: and + /// name a tag outright and take it off whatever this says, exactly as + /// moves one without . + ///
+ [Parameter] public Func? CanRemoveTag { get; set; } + /// /// Custom CSS classes for different parts of the component. /// @@ -231,6 +279,16 @@ public partial class BitTagsInput : BitInputBase?> /// [Parameter] public string? EditedAnnouncementFormat { get; set; } + /// + /// Sets the enterkeyhint html attribute of the input element, which decides the label a virtual keyboard + /// draws on its return key. The key confirms a tag here, so and + /// are the ones that describe it on a phone, where the generic "return" + /// says nothing about what pressing it would do. + /// + [Parameter] + [CallOnSet(nameof(OnSetEnterKeyHint))] + public BitEnterKeyHint? EnterKeyHint { get; set; } + /// /// A function returning extra CSS classes for a single tag, which is what tells one chip apart from the /// next: the recipient that is not in the address book drawn in red, the tag that came from a saved @@ -249,6 +307,32 @@ public partial class BitTagsInput : BitInputBase?> /// [Parameter] public Func? GetTagStyle { get; set; } + /// + /// Sets the inputmode html attribute of the input element, which decides the virtual keyboard a phone + /// opens over the field: for a row of recipients, + /// for a list of codes. It changes nothing about what the field + /// accepts - that is what and are for - only about which + /// keys the user is given to type it with. + /// + [Parameter] + [CallOnSet(nameof(OnSetInputMode))] + public BitInputMode? InputMode { get; set; } + + /// + /// Draws a spinner at the end of the field, for the wait the field itself is the cause of: the + /// suggestions being fetched for what is being typed, the tag being checked against a server before it + /// is accepted. It is an indeterminate progressbar rather than a decoration, so a screen reader + /// announces the wait instead of missing it, and it changes nothing about what the field accepts - + /// a field that has to stop taking tags while it waits is one whose is on. + /// + [Parameter] public bool IsLoading { get; set; } + + /// + /// The accessible name of that spinner, which is the whole of what a screen reader has to go on. + /// The default is "Loading". + /// + [Parameter] public string? LoadingAriaLabel { get; set; } + /// /// The format of the message announced by screen readers when a tag is rejected, where {0} is the tag. /// The default is "{0} was not added.". Set it to an empty string to keep the rejection from being announced. @@ -492,8 +576,9 @@ public partial class BitTagsInput : BitInputBase?> /// /// Whether to render a button that removes every tag at once. It is not rendered while the component - /// is read-only, disabled or empty, and it stays out of the tab order, the Escape key pressed on the - /// input being its keyboard equivalent. + /// is read-only, disabled, empty or left holding nothing but the tags pins + /// in place, and it stays out of the tab order, the Escape key pressed on the input being its keyboard + /// equivalent. /// [Parameter] public bool ShowClearButton { get; set; } @@ -510,6 +595,13 @@ public partial class BitTagsInput : BitInputBase?> [Parameter, ResetClassBuilder] public BitSize? Size { get; set; } + /// + /// Sets the spellcheck html attribute of the input element. It is off by default, a tag being a value + /// rather than a sentence - an identifier, a code or a hashtag underlined in red says only that the + /// dictionary has not heard of it. Turn it on for a field that collects words of a natural language. + /// + [Parameter] public bool? SpellCheck { get; set; } + /// /// The values offered to the user while typing, through the suggestion list the browser itself /// renders for a datalist. Picking one fills the input with it, from where the usual Enter (or a @@ -633,7 +725,9 @@ public Task AddTagsAsync(IEnumerable tags) /// /// Removes the first tag equal to (per the ), exactly - /// as its dismiss button does. It does nothing while the component is disabled or read-only. + /// as its dismiss button does. It does nothing while the component is disabled or read-only, and it + /// takes the tag off whatever says, that predicate being what the user may + /// do rather than what the consumer may. /// public Task RemoveTagAsync(string tag) { @@ -644,7 +738,7 @@ public Task RemoveTagAsync(string tag) var index = GetTags().FindIndex(t => string.Equals(t, tag, Comparison)); if (index < 0) return; - await RemoveTagAt(index); + await RemoveTagAt(index, force: true); StateHasChanged(); }); @@ -652,7 +746,8 @@ public Task RemoveTagAsync(string tag) /// /// Removes the tag sitting at , doing nothing when there is none there or - /// while the component is disabled or read-only. + /// while the component is disabled or read-only. Like it names a tag + /// outright, so does not hold it back. /// public Task RemoveTagAtAsync(int index) { @@ -660,7 +755,7 @@ public Task RemoveTagAtAsync(int index) { if (IsEnabled is false || ReadOnly) return; - await RemoveTagAt(index); + await RemoveTagAt(index, force: true); StateHasChanged(); }); @@ -733,13 +828,21 @@ public Task EditTagAsync(int index) /// /// Removes all tags along with the text left in the input, and raises with the - /// tags that were removed. It does nothing while the component is disabled or read-only. + /// tags that were removed. It does nothing while the component is disabled or read-only. The tags + /// holds in place stay where they are and are left out of what is reported: + /// clearing empties the field of everything it can be emptied of. /// public Task Clear() => InvokeAsync(async () => { if (IsEnabled is false || ReadOnly) return; - var removed = GetTags(); + var all = GetTags(); + + // Clearing empties the field of everything it can be emptied of: the tags CanRemoveTag holds in + // place are as fixed here as they are under their missing dismiss button, and what is reported - + // to OnBeforeClear, to OnClear and to the screen reader - is what actually goes. + var removed = CanRemoveTag is null ? all : [.. all.Where(CanRemove)]; + var kept = CanRemoveTag is null ? [] : all.Where(t => CanRemove(t) is false).ToList(); if (OnBeforeClear.HasDelegate) { @@ -758,7 +861,11 @@ public Task Clear() => InvokeAsync(async () => // A field that is already empty has nothing to report: setting the value again would otherwise // mark the form dirty and raise a change for a list that did not change. - if (removed.Count > 0 || CurrentValue is not null) + if (removed.Count > 0) + { + await SetCurrentValueAsync(kept.Count > 0 ? kept : null); + } + else if (CurrentValue is not null && kept.Count == 0) { await SetCurrentValueAsync(null); } @@ -849,6 +956,7 @@ protected override async Task OnInitializedAsync() _listId = $"BitTagsInput-{UniqueId}-list"; _tagsId = $"BitTagsInput-{UniqueId}-tags"; _hintId = $"BitTagsInput-{UniqueId}-hint"; + _fixedHintId = $"BitTagsInput-{UniqueId}-fixed-hint"; _descriptionId = $"BitTagsInput-{UniqueId}-description"; SetDefaultValue(); @@ -856,6 +964,14 @@ protected override async Task OnInitializedAsync() await base.OnInitializedAsync(); } + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BitTagsInputParams))] + protected override void OnParametersSet() + { + CascadingParameters?.UpdateParameters(this); + + base.OnParametersSet(); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); @@ -957,7 +1073,17 @@ protected override bool TryParseValueFromString(string? value, out ICollection - private string? GetTagAriaDescription() + private string? GetTagAriaDescription(bool removable) { if (TagAriaDescription is not null) return TagAriaDescription.HasValue() ? TagAriaDescription : null; if (IsEnabled is false || ReadOnly) return null; + if (removable) + { + return (EditableTags, AllowReorder) switch + { + (true, true) => "Press Enter to edit, Delete to remove, or Alt with the arrow keys to move.", + (true, false) => "Press Enter to edit, or Delete to remove.", + (false, true) => "Press Alt with the arrow keys to move, or Delete to remove.", + _ => null + }; + } + + // A tag CanRemoveTag holds in place answers to everything but the removal, so it is promised + // everything but the removal - and said to be locked even where it answers to nothing else, since + // a chip with no dismiss button next to chips that have one is otherwise only a missing button. return (EditableTags, AllowReorder) switch { - (true, true) => "Press Enter to edit, Delete to remove, or Alt with the arrow keys to move.", - (true, false) => "Press Enter to edit, or Delete to remove.", - (false, true) => "Press Alt with the arrow keys to move, or Delete to remove.", - _ => null + (true, true) => "This tag cannot be removed. Press Enter to edit it, or Alt with the arrow keys to move it.", + (true, false) => "This tag cannot be removed. Press Enter to edit it.", + (false, true) => "This tag cannot be removed. Press Alt with the arrow keys to move it.", + _ => "This tag cannot be removed." }; } @@ -1115,11 +1255,60 @@ private string GetMoreTagsAriaLabel(int hidden) return Format(MoreTagsAriaLabelFormat ?? "Show {0} more tags", hidden.ToString(System.Globalization.CultureInfo.CurrentCulture)); } + /// + /// Whether the user is allowed to take off the list. A predicate of the consumer + /// that throws leaves the tag removable: a tag nobody can ever take off is worse than one that can. + /// + /// + /// Whether the clear button would have anything to take off the field: every tag, unless + /// holds some of them in place - and none at all where it holds all of + /// them, a button that empties nothing being a button that does nothing. + /// + private bool HasRemovableTag() + { + if (CurrentValue is null || CurrentValue.Count == 0) return false; + + if (CanRemoveTag is null) return true; + + return CurrentValue.Any(CanRemove); + } + + private bool CanRemove(string tag) + { + if (CanRemoveTag is null) return true; + + try + { + return CanRemoveTag(tag); + } + catch + { + return true; + } + } + private string? GetPlaceholder() { return CurrentValue is null || CurrentValue.Count == 0 ? Placeholder : TagsPlaceholder; } + /// + /// The aria-describedby of the input: the id of the added to whatever the + /// consumer wrote on the component through , rather than + /// written over it. The attribute is a list of ids, so a field that points at a validation message of its + /// own keeps pointing at it while the helper text is read out as well. + /// + private string? GetDescribedBy(bool hasDescription) + { + var custom = InputHtmlAttributes is not null && InputHtmlAttributes.TryGetValue("aria-describedby", out var value) + ? value?.ToString() + : null; + + if (hasDescription is false) return custom; + + return custom.HasValue() ? $"{custom} {_descriptionId}" : _descriptionId; + } + private string GetDismissAriaLabel(string tag) { return Format(DismissAriaLabelFormat ?? "Remove {0}", tag); @@ -1164,7 +1353,7 @@ private static string Format(string format, string tag) } } - private string? BuildTagStyle(string tag, bool focused) + private string? BuildTagStyle(string tag, bool focused, bool removable) { // The declarations are joined with a semicolon rather than with a space, since one that omits its // trailing semicolon would otherwise swallow whatever is appended after it. @@ -1172,6 +1361,11 @@ private static string Format(string format, string tag) Append(Styles?.Tag); + if (removable is false) + { + Append(Styles?.FixedTag); + } + if (focused) { Append(Styles?.FocusedTag); @@ -1190,11 +1384,15 @@ void Append(string? part) } } - private string BuildTagClass(string tag, int index, bool focused) + private string BuildTagClass(string tag, int index, bool focused, bool removable) { var custom = Classes?.Tag; var focusedClass = focused ? Classes?.FocusedTag : null; + // A tag the field holds in place looks the same as any other apart from the button it does not + // carry, so it is marked for the stylesheet that wants to say more about it than that. + var fixedClass = removable ? null : $"bit-tgi-tag-fix {Classes?.FixedTag}".TrimEnd(); + // The tag being dragged is faded out and the one it hovers over is marked, which is what tells // the pointer where the chip would land before it is let go of. var dragClass = _draggingTagIndex < 0 @@ -1205,7 +1403,7 @@ private string BuildTagClass(string tag, int index, bool focused) var tagClass = InvokeTagStyling(GetTagClass, tag); - return string.Join(' ', new[] { "bit-tgi-tag", custom, focusedClass, dragClass, tagClass }.Where(c => c.HasValue())); + return string.Join(' ', new[] { "bit-tgi-tag", custom, focusedClass, fixedClass, dragClass, tagClass }.Where(c => c.HasValue())); } private string GetTagTabIndex(int index, int count) @@ -1390,8 +1588,9 @@ private async Task HandleClearClick() await Clear(); // A clear that OnBeforeClear called off leaves the button, and the focus that is on it, exactly - // where they were; only a field that did empty has taken its own clear button away. - if (CurrentValue?.Count > 0 || _inputText.Length > 0) return; + // where they were; only a field that did empty has taken its own clear button away - which a field + // left holding nothing but the tags CanRemoveTag pins in place has too. + if (HasRemovableTag() || _inputText.Length > 0) return; FocusInput(); } @@ -1889,7 +2088,13 @@ private async Task CommitEdit(bool restoreFocus = true) if (restoreFocus is false) return; - if (removed && CurrentValue?.Count > 0) + if (removed is false) + { + // The tag is still there - OnBeforeRemove called the removal off, or CanRemoveTag holds + // this one in place - so the focus belongs back on it rather than in the input. + FocusTag(index); + } + else if (CurrentValue?.Count > 0) { FocusTag(Math.Min(index, CurrentValue.Count - 1)); } @@ -2062,7 +2267,12 @@ private async Task HandleRemoveTag(int index) } } - private async Task RemoveTagAt(int index) + /// + /// Takes the tag at off the list. tells whether the + /// predicate applies: it does to every gesture the user makes, and it does + /// not to the consumer naming a tag through the public API. + /// + private async Task RemoveTagAt(int index, bool force = false) { var list = GetTags(); @@ -2070,6 +2280,8 @@ private async Task RemoveTagAt(int index) var tag = list[index]; + if (force is false && CanRemove(tag) is false) return false; + if (OnBeforeRemove.HasDelegate) { var args = new BitTagsInputBeforeArgs { Tag = tag }; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.scss index a8963051700..82b0978c8be 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.scss @@ -1,11 +1,57 @@ @import "../../../Styles/functions.scss"; +// Public CSS variables, read off the elements and never declared here, so a value set on :root re-skins every +// tags input and one set on the Style of an instance re-skins that one alone: +// --bit-TagsInput-font-size text size of the field (default: per size, from the type ramp) +// --bit-TagsInput-color color of the typed text (default: $clr-fg-pri) +// --bit-TagsInput-placeholder-color color of the placeholder (default: $clr-fg-ter) +// --bit-TagsInput-label-color color of the label above the field (default: $clr-fg-pri) +// --bit-TagsInput-required-color color of the asterisk a Required label carries (default: $clr-req) +// --bit-TagsInput-description-color color of the helper text under the field (default: $clr-fg-sec) +// --bit-TagsInput-counter-color color of the tag counter (default: $clr-fg-sec) +// --bit-TagsInput-affix-color color of the prefix and of the suffix (default: $clr-fg-sec) +// --bit-TagsInput-background fill of the field (default: per variant) +// --bit-TagsInput-border-color rule around the field (default: per variant) +// --bit-TagsInput-hover-border-color rule around the hovered field (default: per variant) +// --bit-TagsInput-border-width thickness of that rule (default: $shp-border-width) +// --bit-TagsInput-radius corner of the field and of its focus ring (default: $shp-radius-control) +// --bit-TagsInput-min-height smallest height of the field (default: per size, $siz-ctrl-*) +// --bit-TagsInput-padding inset between the field and its content (default: per size) +// --bit-TagsInput-gap room between the chips, the input and the affixes (default: per size) +// --bit-TagsInput-focus-color color of the focus ring (default: the role's focus color) +// --bit-TagsInput-focus-border-color rule around the focused field (default: the role's main color) +// --bit-TagsInput-invalid-color rule and helper text of a field failing validation (default: $clr-err) +// --bit-TagsInput-disabled-color text of a disabled field and of its chips (default: $clr-fg-dis) +// --bit-TagsInput-disabled-background fill of a disabled field and of its chips (default: $clr-bg-dis) +// --bit-TagsInput-disabled-border-color rule of a disabled field and of its chips (default: $clr-brd-dis) +// --bit-TagsInput-tag-color text of a chip (default: per tag variant, from the role) +// --bit-TagsInput-tag-background fill of a chip (default: per tag variant, from the role) +// --bit-TagsInput-tag-border-color rule of a chip (default: per tag variant, from the role) +// --bit-TagsInput-tag-border-width thickness of that rule (default: $shp-border-width) +// --bit-TagsInput-tag-radius corner of a chip (default: $shp-radius-chip) +// --bit-TagsInput-tag-padding inset of a chip (default: per size) +// --bit-TagsInput-tag-gap room between a chip's text and its dismiss button (default: spacing(0.375)) +// --bit-TagsInput-tag-font-size text size of a chip (default: per size, from the type ramp) +// --bit-TagsInput-tag-min-height smallest height of a chip (default: per size) +// --bit-TagsInput-tag-max-width widest a chip grows before its text is ellipsized (default: 100%) +// --bit-TagsInput-tag-focus-color inset ring of the focused chip and of the chip a +// dragged one would land on (default: per tag variant) +// --bit-TagsInput-tag-dragging-opacity alpha of the chip being dragged (default: 0.4) +// --bit-TagsInput-dismiss-icon-size glyph of a chip's dismiss button (default: 1em of the chip's text) +// --bit-TagsInput-icon-size glyph of the clear button and of the spinner (default: per size, $siz-icon-*) +// --bit-TagsInput-spinner-color the arc of the spinner IsLoading draws (default: the role's main color) +// --bit-TagsInput-spinner-track-color the track the arc turns in (default: $clr-brd-sec) +// --bit-TagsInput-clear-color the clear button at rest (default: $clr-fg-sec) +// --bit-TagsInput-clear-hover-color the clear button under the pointer (default: $clr-fg-pri) +// --bit-TagsInput-toggle-hover-color text of the hovered chip that folds the tags (default: per tag variant) +// --bit-TagsInput-toggle-hover-background fill of that chip while it is hovered (default: per tag variant) + .bit-tgi { font-weight: $tg-fw-regular; position: relative; box-sizing: border-box; font-family: $tg-font-family; - font-size: var(--bit-tgi-fontsize); + font-size: var(--bit-TagsInput-font-size, var(--bit-tgi-fontsize)); } .bit-tgi-lbl { @@ -14,8 +60,11 @@ font-size: inherit; line-height: spacing(2.5); padding: spacing(0.625) 0; - color: $clr-fg-pri; overflow-wrap: break-word; + //the label of an interactive control carries the tracking of the design system, the way every other + //control label in the library does. + letter-spacing: $tg-ctrl-letter-spacing; + color: var(--bit-TagsInput-label-color, #{$clr-fg-pri}); } //the helper text and the counter share a line under the field, the count sitting at its end whether or @@ -37,11 +86,11 @@ //a flex item does not shrink below the width of its longest unbroken word unless it is allowed to, //which would push the counter out of the line instead of wrapping the sentence next to it. min-inline-size: 0; - color: $clr-fg-sec; box-sizing: border-box; overflow-wrap: break-word; padding: spacing(0.625) 0 0; - font-size: calc(var(--bit-tgi-fontsize) - #{spacing(0.25)}); + color: var(--bit-TagsInput-description-color, #{$clr-fg-sec}); + font-size: calc(var(--bit-TagsInput-font-size, var(--bit-tgi-fontsize)) - #{spacing(0.25)}); } //the count of the tags: the same size as the helper text, never wrapping, and pushed to the end of the @@ -49,13 +98,13 @@ .bit-tgi-cnr { margin: 0; flex-shrink: 0; - color: $clr-fg-sec; white-space: nowrap; margin-inline-start: auto; box-sizing: border-box; padding: spacing(0.625) 0 0; font-variant-numeric: tabular-nums; - font-size: calc(var(--bit-tgi-fontsize) - #{spacing(0.25)}); + color: var(--bit-TagsInput-counter-color, #{$clr-fg-sec}); + font-size: calc(var(--bit-TagsInput-font-size, var(--bit-tgi-fontsize)) - #{spacing(0.25)}); } //the announcement of an added, removed or rejected tag, and the sentence telling what the keyboard can @@ -79,13 +128,16 @@ cursor: text; position: relative; align-items: center; - gap: var(--bit-tgi-gap); box-sizing: border-box; - min-height: var(--bit-tgi-minheight); - padding: var(--bit-tgi-padding); - border-radius: $shp-radius-control; - border-width: $shp-border-width; border-style: $shp-border-style; + gap: var(--bit-TagsInput-gap, var(--bit-tgi-gap)); + padding: var(--bit-TagsInput-padding, var(--bit-tgi-padding)); + //the control height of the size class, as a floor rather than a height: the field still grows with + //every line of chips that wraps into it. It is what lines an empty tags input up with the text + //fields, dropdowns and pickers of the same size standing beside it in a form. + min-height: var(--bit-TagsInput-min-height, var(--bit-tgi-minheight)); + border-radius: var(--bit-TagsInput-radius, #{$shp-radius-control}); + border-width: var(--bit-TagsInput-border-width, #{$shp-border-width}); transition: border-color $mot-duration-short $mot-easing, background-color $mot-duration-short $mot-easing; } @@ -99,23 +151,30 @@ display: inline-flex; align-items: center; box-sizing: border-box; - gap: spacing(0.375); - line-height: spacing(2.25); - padding: spacing(0.125) spacing(0.5); - border-radius: $shp-radius-chip; + overflow: hidden; //the rule is drawn in every tag variant, transparent where it is not painted, so that switching from //a filled chip to an outlined one never moves the text in it by the width of a border. //The three colors are read out of custom properties the tag variant sets rather than being painted by - //the variant itself, which keeps this rule as specific as a single class: a class of one's own, handed + //the variant itself, which keeps this rule as specific as a single class: a class of its own, handed //to a chip through Classes, GetTagClass or a scoped stylesheet, has to be able to repaint it. - border-width: $shp-border-width; border-style: $shp-border-style; - border-color: var(--bit-tgi-tag-brd); - color: var(--bit-tgi-tag-clr); - background-color: var(--bit-tgi-tag-bg); - font-size: var(--bit-tgi-tagfontsize); - max-width: 100%; - overflow: hidden; + border-width: var(--bit-TagsInput-tag-border-width, #{$shp-border-width}); + border-color: var(--bit-TagsInput-tag-border-color, var(--bit-tgi-tag-brd)); + color: var(--bit-TagsInput-tag-color, var(--bit-tgi-tag-clr)); + background-color: var(--bit-TagsInput-tag-background, var(--bit-tgi-tag-bg)); + border-radius: var(--bit-TagsInput-tag-radius, #{$shp-radius-chip}); + font-size: var(--bit-TagsInput-tag-font-size, var(--bit-tgi-tagfontsize)); + gap: var(--bit-TagsInput-tag-gap, #{spacing(0.375)}); + padding: var(--bit-TagsInput-tag-padding, var(--bit-tgi-tagpadding)); + //the height of a chip is a floor of its own rather than the line box of the word inside it, so a chip + //holding an icon, a template or the little edit input is exactly as tall as the one beside it. + min-height: var(--bit-TagsInput-tag-min-height, var(--bit-tgi-tagminheight)); + max-width: var(--bit-TagsInput-tag-max-width, 100%); + //the label of a control is tracked by the design system; a chip here is not a label but the value the + //user typed, so the tracking follows the theme while the casing is deliberately left alone - an + //address or an identifier upper-cased by a preset would no longer be the text that was entered. + letter-spacing: $tg-ctrl-letter-spacing; + transition: color $mot-duration-short $mot-easing, border-color $mot-duration-short $mot-easing, background-color $mot-duration-short $mot-easing; //an inset outline rather than the shared focus ring: the field around the tag is already wearing //that ring (it holds the focus too, through :focus-within), and two of them drawn one inside the @@ -123,7 +182,7 @@ //It is drawn with the color of the text of the chip rather than with the accent, since on a filled //chip the accent is what the ring would be drawn on top of. &:focus-visible { - outline: $shp-focus-ring-width solid var(--bit-tgi-tag-ring); + outline: $shp-focus-ring-width solid var(--bit-TagsInput-tag-focus-color, var(--bit-tgi-tag-ring)); outline-offset: calc(-1 * #{$shp-focus-ring-width}); } } @@ -139,13 +198,13 @@ } .bit-tgi-tag-drg { - opacity: 0.4; + opacity: var(--bit-TagsInput-tag-dragging-opacity, 0.4); } //the chip the dragged one would land on: an inset outline rather than a border, so that nothing in the //row moves by a pixel while the drag is hovering over it. .bit-tgi-tag-dro { - outline: $shp-focus-ring-width solid var(--bit-tgi-tag-ring); + outline: $shp-focus-ring-width solid var(--bit-TagsInput-tag-focus-color, var(--bit-tgi-tag-ring)); outline-offset: calc(-1 * #{$shp-focus-ring-width}); } @@ -157,16 +216,15 @@ flex-shrink: 0; align-items: center; box-sizing: border-box; - color: $clr-fg-sec; white-space: nowrap; - line-height: spacing(2.25); + color: var(--bit-TagsInput-affix-color, #{$clr-fg-sec}); + min-height: var(--bit-TagsInput-tag-min-height, var(--bit-tgi-tagminheight)); } .bit-tgi-ttx { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - line-height: spacing(2.25); } //the little input that replaces a tag while it is being corrected in place: it borrows the type and the @@ -180,12 +238,12 @@ margin: 0; color: inherit; font: inherit; + letter-spacing: inherit; box-sizing: content-box; background: none transparent; width: spacing(8); field-sizing: content; min-width: spacing(4); - line-height: spacing(2.25); } //the chip standing for the tags folded away: it is drawn as one of them rather than as a button, since @@ -196,27 +254,32 @@ box-sizing: border-box; cursor: pointer; font: inherit; - line-height: spacing(2.25); - padding: spacing(0.125) spacing(0.5); - border-radius: $shp-radius-chip; - border-width: $shp-border-width; - border-style: $shp-border-style; - border-color: var(--bit-tgi-tag-brd); - color: var(--bit-tgi-tag-clr); - background-color: var(--bit-tgi-tag-bg); - font-size: var(--bit-tgi-tagfontsize); white-space: nowrap; + border-style: $shp-border-style; + letter-spacing: $tg-ctrl-letter-spacing; + //the fold chip is a button carrying a label of the component's own rather than a value of the user's, + //so unlike the tags it follows the casing the design system gives its controls. + text-transform: $tg-ctrl-text-transform; + border-width: var(--bit-TagsInput-tag-border-width, #{$shp-border-width}); + border-color: var(--bit-TagsInput-tag-border-color, var(--bit-tgi-tag-brd)); + color: var(--bit-TagsInput-tag-color, var(--bit-tgi-tag-clr)); + background-color: var(--bit-TagsInput-tag-background, var(--bit-tgi-tag-bg)); + border-radius: var(--bit-TagsInput-tag-radius, #{$shp-radius-chip}); + font-size: var(--bit-TagsInput-tag-font-size, var(--bit-tgi-tagfontsize)); + padding: var(--bit-TagsInput-tag-padding, var(--bit-tgi-tagpadding)); + min-height: var(--bit-TagsInput-tag-min-height, var(--bit-tgi-tagminheight)); + transition: color $mot-duration-short $mot-easing, border-color $mot-duration-short $mot-easing, background-color $mot-duration-short $mot-easing; @media (hover: hover) { &:hover { - color: var(--bit-tgi-tgl-clr-hover); - border-color: var(--bit-tgi-tgl-brd-hover); - background-color: var(--bit-tgi-tgl-bg-hover); + color: var(--bit-TagsInput-toggle-hover-color, var(--bit-tgi-tgl-clr-hover)); + border-color: var(--bit-TagsInput-toggle-hover-background, var(--bit-tgi-tgl-brd-hover)); + background-color: var(--bit-TagsInput-toggle-hover-background, var(--bit-tgi-tgl-bg-hover)); } } &:focus-visible { - outline: $shp-focus-ring-width solid var(--bit-tgi-tag-ring); + outline: $shp-focus-ring-width solid var(--bit-TagsInput-tag-focus-color, var(--bit-tgi-tag-ring)); outline-offset: calc(-1 * #{$shp-focus-ring-width}); } } @@ -232,7 +295,6 @@ flex-shrink: 0; color: inherit; background-color: transparent; - font-size: var(--bit-tgi-iconsize); line-height: 1; } @@ -246,6 +308,11 @@ //of the chip onto the one next to it. align-self: stretch; padding-inline: spacing(0.375); + //the glyph is sized off the chip's own text rather than off the field's icon scale: a dismiss button + //is part of the word it dismisses, and a chip whose cross is taller than its text reads as a button + //carrying a label rather than as a value that can be taken off. + font-size: var(--bit-TagsInput-dismiss-icon-size, 1em); + transition: opacity $mot-duration-short $mot-easing; @media (hover: hover) { &:hover { @@ -255,26 +322,66 @@ //the same inset outline as the tag, and for the same reason: the field is already ringed. &:focus-visible { - outline: $shp-focus-ring-width solid var(--bit-tgi-tag-ring); + outline: $shp-focus-ring-width solid var(--bit-TagsInput-tag-focus-color, var(--bit-tgi-tag-ring)); outline-offset: 0; } } +//a tag the field holds in place: it carries no dismiss button, so it keeps the inline inset the button +//would otherwise have given it on that side and does not read as a chip whose button failed to render. +.bit-tgi-tag-fix { + cursor: default; +} + +//the spinner IsLoading draws at the end of the field, next to the clear button. It is sized off the +//field's icon scale, the way every other glyph of the field is, and turns with the spinner tokens of the +//theme - which slow down rather than stop under reduced motion, since a loader that stands still is a +//loader that says nothing. +.bit-tgi-spn { + flex-shrink: 0; + box-sizing: border-box; + border-radius: $shp-radius-full; + border-style: $shp-border-style; + border-width: $siz-spinner-stroke; + margin-inline-start: auto; + border-color: var(--bit-TagsInput-spinner-track-color, #{$clr-brd-sec}); + border-top-color: var(--bit-TagsInput-spinner-color, var(--bit-tgi-clr)); + width: var(--bit-TagsInput-icon-size, var(--bit-tgi-iconsize)); + height: var(--bit-TagsInput-icon-size, var(--bit-tgi-iconsize)); + animation: bit-tgi-spin $mot-duration-spinner $mot-easing-spinner infinite; +} + +@keyframes bit-tgi-spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + //the clear button sits at the end of the field rather than inside a tag, so it keeps a little room //around itself and follows the secondary foreground until it is pointed at. .bit-tgi-cbt { margin-inline-start: auto; - color: $clr-fg-sec; padding-inline: spacing(0.25); + color: var(--bit-TagsInput-clear-color, #{$clr-fg-sec}); + font-size: var(--bit-TagsInput-icon-size, var(--bit-tgi-iconsize)); + //a control of the field rather than a part of a chip, so it carries the 24 CSS pixel pointer target of + //WCAG 2.2 (SC 2.5.8) on both axes at every size, whatever the glyph inside it measures. + min-width: spacing(3); + min-height: spacing(3); + transition: color $mot-duration-short $mot-easing; @media (hover: hover) { &:hover { - color: $clr-fg-pri; + color: var(--bit-TagsInput-clear-hover-color, #{$clr-fg-pri}); } } &:focus-visible { - outline: $shp-focus-ring-width solid var(--bit-tgi-clr); + outline: $shp-focus-ring-width solid var(--bit-TagsInput-focus-border-color, var(--bit-tgi-clr)); outline-offset: 0; } } @@ -293,12 +400,14 @@ box-sizing: border-box; font-size: inherit; font-family: inherit; - line-height: spacing(2.25); background: none transparent; - color: $clr-fg-pri; + color: var(--bit-TagsInput-color, #{$clr-fg-pri}); + //the caret sits on the line the chips are on rather than on a shorter one of its own, so a field + //with no tag in it yet is exactly as tall as the same field once the first one is added. + min-height: var(--bit-TagsInput-tag-min-height, var(--bit-tgi-tagminheight)); &::placeholder { - color: $clr-fg-ter; + color: var(--bit-TagsInput-placeholder-color, #{$clr-fg-ter}); } } @@ -306,13 +415,13 @@ //Outline - the default: a full rule around the field, filled with the page surface. .bit-tgi-otl { .bit-tgi-cnt { - border-color: $clr-brd-pri; - background-color: $clr-bg-pri; + border-color: var(--bit-TagsInput-border-color, #{$clr-brd-pri}); + background-color: var(--bit-TagsInput-background, #{$clr-bg-pri}); } @media (hover: hover) { &:hover:not(.bit-dis) .bit-tgi-cnt { - border-color: $clr-brd-pri-hover; + border-color: var(--bit-TagsInput-hover-border-color, var(--bit-TagsInput-border-color, #{$clr-brd-pri-hover})); } } } @@ -320,13 +429,14 @@ //Fill: the field is painted with the secondary surface and carries no rule of its own. .bit-tgi-fil { .bit-tgi-cnt { - border-color: transparent; - background-color: $clr-bg-sec; + border-color: var(--bit-TagsInput-border-color, transparent); + background-color: var(--bit-TagsInput-background, #{$clr-bg-sec}); } @media (hover: hover) { &:hover:not(.bit-dis) .bit-tgi-cnt { - background-color: $clr-bg-sec-hover; + border-color: var(--bit-TagsInput-hover-border-color, var(--bit-TagsInput-border-color, transparent)); + background-color: var(--bit-TagsInput-background, #{$clr-bg-sec-hover}); } } } @@ -335,14 +445,14 @@ .bit-tgi-txt { .bit-tgi-cnt { border-radius: 0; - border-color: $clr-brd-pri; - background-color: transparent; - border-width: 0 0 $shp-border-width 0; + border-color: var(--bit-TagsInput-border-color, #{$clr-brd-pri}); + background-color: var(--bit-TagsInput-background, transparent); + border-width: 0 0 var(--bit-TagsInput-border-width, #{$shp-border-width}) 0; } @media (hover: hover) { &:hover:not(.bit-dis) .bit-tgi-cnt { - border-color: $clr-brd-pri-hover; + border-color: var(--bit-TagsInput-hover-border-color, var(--bit-TagsInput-border-color, #{$clr-brd-pri-hover})); } } } @@ -408,8 +518,8 @@ .bit-tgi-req { .bit-tgi-lbl::after { content: "*"; - color: $clr-req; margin-inline-start: spacing(0.625); + color: var(--bit-TagsInput-required-color, #{$clr-req}); } } @@ -418,15 +528,15 @@ //source order keeps a focused field from being painted over by its variant colors. It follows the whole //field rather than the input alone, so that a tag reached with the arrow keys lights it up as well. .bit-tgi .bit-tgi-cnt:focus-within { - @include focus-ring(var(--bit-tgi-clr-focus)); + @include focus-ring(var(--bit-TagsInput-focus-color, var(--bit-tgi-clr-focus))); - border-color: var(--bit-tgi-clr); + border-color: var(--bit-TagsInput-focus-border-color, var(--bit-tgi-clr)); } .bit-tgi-txt .bit-tgi-cnt:focus-within { - @include focus-underline-ring(var(--bit-tgi-clr-focus)); + @include focus-underline-ring(var(--bit-TagsInput-focus-color, var(--bit-tgi-clr-focus))); - border-color: var(--bit-tgi-clr); + border-color: var(--bit-TagsInput-focus-border-color, var(--bit-tgi-clr)); } .bit-tgi-nbd .bit-tgi-cnt:focus-within { @@ -439,30 +549,30 @@ .bit-tgi-cnr, .bit-tgi-pre, .bit-tgi-suf { - color: $clr-fg-dis; + color: var(--bit-TagsInput-disabled-color, #{$clr-fg-dis}); } .bit-tgi-cnt { cursor: default; - color: $clr-fg-dis; - border-color: $clr-brd-dis; - background-color: $clr-bg-dis; + color: var(--bit-TagsInput-disabled-color, #{$clr-fg-dis}); + border-color: var(--bit-TagsInput-disabled-border-color, #{$clr-brd-dis}); + background-color: var(--bit-TagsInput-disabled-background, #{$clr-bg-dis}); } //the tags are the content of a disabled field, so they are dimmed along with it instead of staying //the one part of it that still reads as enabled. .bit-tgi-tag, .bit-tgi-tgl { - color: $clr-fg-dis; - border-color: $clr-brd-dis; - background-color: $clr-bg-dis; + color: var(--bit-TagsInput-disabled-color, #{$clr-fg-dis}); + border-color: var(--bit-TagsInput-disabled-border-color, #{$clr-brd-dis}); + background-color: var(--bit-TagsInput-disabled-background, #{$clr-bg-dis}); } .bit-tgi-inp { - color: $clr-fg-dis; + color: var(--bit-TagsInput-disabled-color, #{$clr-fg-dis}); &::placeholder { - color: $clr-fg-dis; + color: var(--bit-TagsInput-disabled-color, #{$clr-fg-dis}); } } } @@ -471,28 +581,28 @@ //the helper text is what says why the tags were refused, so it reads as the message of the error //state rather than as a hint standing next to it, and the failure is not carried by a color alone. .bit-tgi-dsc { - color: $clr-err; + color: var(--bit-TagsInput-invalid-color, #{$clr-err}); } .bit-tgi-cnt { - border-color: $clr-err; + border-color: var(--bit-TagsInput-invalid-color, #{$clr-err}); &:focus-within { - @include focus-ring($clr-err-focus); + @include focus-ring(var(--bit-TagsInput-invalid-color, #{$clr-err-focus})); - border-color: $clr-err-focus; + border-color: var(--bit-TagsInput-invalid-color, #{$clr-err-focus}); } } &.bit-tgi-txt .bit-tgi-cnt:focus-within { - @include focus-underline-ring($clr-err-focus); + @include focus-underline-ring(var(--bit-TagsInput-invalid-color, #{$clr-err-focus})); - border-color: $clr-err-focus; + border-color: var(--bit-TagsInput-invalid-color, #{$clr-err-focus}); } @media (hover: hover) { &:hover:not(.bit-dis) .bit-tgi-cnt { - border-color: $clr-err; + border-color: var(--bit-TagsInput-invalid-color, #{$clr-err}); } } } @@ -508,6 +618,29 @@ border: $shp-border-width $shp-border-style ButtonText; } + //the inset ring a chip is focused with, and the one marking the chip a drag would land on, are + //painted with a color the forced palette has thrown away, so both are re-drawn in the system + //highlight - the one color a focused element is expected to wear there. + .bit-tgi-tag:focus-visible, + .bit-tgi-tgl:focus-visible, + .bit-tgi-dbt:focus-visible, + .bit-tgi-tag-dro { + outline-color: Highlight; + } + + //the chip being dragged is told apart by its alpha alone, which the forced palette keeps but which + //reads as nothing next to the flat system colors around it, so it is marked with a rule as well. + .bit-tgi-tag-drg { + border-style: dashed; + } + + //the track and the arc of the spinner would be painted the same system color and stop reading as + //motion, so the pair is re-established the way every other spinner of the library is. + .bit-tgi-spn { + border-color: GrayText; + border-top-color: CanvasText; + } + .bit-tgi.bit-dis { .bit-tgi-lbl, .bit-tgi-dsc, @@ -540,29 +673,41 @@ } +// The field takes the control height of its size class, so an empty tags input lines up with the text +// fields and the pickers beside it, and grows from there with every line of chips that wraps into it. Its +// own inset is the denser rhythm a text input keeps rather than the control padding of a button, while the +// chips inside it take half the control padding on the inline axis - the share a BitTag takes, since a +// rounder chip corner eats into the inline inset and a chip is a label rather than a button. The glyph of +// the clear button is on the icon scale every control in the library sizes the icons it carries with. .bit-tgi-sm { --bit-tgi-gap: #{spacing(0.25)}; --bit-tgi-fontsize: #{$tg-fs-xs}; - --bit-tgi-iconsize: #{spacing(1.125)}; + --bit-tgi-iconsize: #{$siz-icon-sm}; --bit-tgi-tagfontsize: #{$tg-fs-xs}; - --bit-tgi-minheight: #{spacing(3.5)}; + --bit-tgi-minheight: #{$siz-ctrl-sm}; --bit-tgi-padding: #{spacing(0.25)} #{spacing(0.375)}; + --bit-tgi-tagminheight: #{spacing(2.25)}; + --bit-tgi-tagpadding: 0 calc(#{$siz-ctrl-pad-x-sm} / 2); } .bit-tgi-md { --bit-tgi-gap: #{spacing(0.375)}; --bit-tgi-fontsize: #{$tg-fs-sm}; - --bit-tgi-iconsize: #{spacing(1.25)}; + --bit-tgi-iconsize: #{$siz-icon-md}; --bit-tgi-tagfontsize: #{$tg-fs-xs}; - --bit-tgi-minheight: #{spacing(4.5)}; + --bit-tgi-minheight: #{$siz-ctrl-md}; --bit-tgi-padding: #{spacing(0.375)} #{spacing(0.5)}; + --bit-tgi-tagminheight: #{spacing(2.5)}; + --bit-tgi-tagpadding: 0 calc(#{$siz-ctrl-pad-x-md} / 2); } .bit-tgi-lg { --bit-tgi-gap: #{spacing(0.5)}; --bit-tgi-fontsize: #{$tg-fs-md}; - --bit-tgi-iconsize: #{spacing(1.5)}; + --bit-tgi-iconsize: #{$siz-icon-lg}; --bit-tgi-tagfontsize: #{$tg-fs-sm}; - --bit-tgi-minheight: #{spacing(5.5)}; + --bit-tgi-minheight: #{$siz-ctrl-lg}; --bit-tgi-padding: #{spacing(0.5)} #{spacing(0.625)}; + --bit-tgi-tagminheight: #{spacing(3)}; + --bit-tgi-tagpadding: 0 calc(#{$siz-ctrl-pad-x-lg} / 2); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInputClassStyles.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInputClassStyles.cs index 6b3627c6394..4acb664355d 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInputClassStyles.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInputClassStyles.cs @@ -8,7 +8,9 @@ public class BitTagsInputClassStyles public string? Root { get; set; } /// - /// Custom CSS classes/styles for the focused state of the root element. + /// Custom CSS classes/styles carried by the root element while the input holds the focus. A tag reached + /// with the arrow keys is the field's focus rather than the input's, so it lights the field's own ring + /// (through :focus-within) without adding this one. /// public string? Focused { get; set; } @@ -47,6 +49,12 @@ public class BitTagsInputClassStyles /// public string? FocusedTag { get; set; } + /// + /// Custom CSS classes/styles for a tag the CanRemoveTag predicate holds in place, which carries no + /// dismiss button of its own. + /// + public string? FixedTag { get; set; } + /// /// Custom CSS classes/styles for the tag text. /// @@ -83,6 +91,11 @@ public class BitTagsInputClassStyles /// public string? Counter { get; set; } + /// + /// Custom CSS classes/styles for the spinner IsLoading draws at the end of the field. + /// + public string? Spinner { get; set; } + /// /// Custom CSS classes/styles for the clear button of the BitTagsInput. /// diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInputParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInputParams.cs new file mode 100644 index 00000000000..db1d778d580 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInputParams.cs @@ -0,0 +1,766 @@ +namespace Bit.BlazorUI; + +/// +/// The parameters for component. +/// +/// +/// What a carries down is the configuration of a tags input - its look, its rules and +/// its wording - and never its value. The parameters that belong to the one field holding them (Value, +/// DefaultValue, Name, Required, ReadOnly), the templates and the event callbacks +/// stay on the component itself, since a list of tags, the form field it is posted as and the handler that +/// watches it are never shared between two fields. +/// +public class BitTagsInputParams : BitComponentBaseParams, IBitComponentParams +{ + /// + /// Represents the parameter name used to identify the cascading parameters within . + /// + /// + /// This constant is typically used when referencing or accessing the BitTagsInput 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(BitTagsInput)}"; + + + + public string Name => ParamName; + + + + /// + /// The format of the message announced by screen readers when a tag is added, where {0} is the tag. + /// + public string? AddedAnnouncementFormat { get; set; } + + /// + /// The format of the message announced by screen readers when several tags are added at once, where {0} + /// is how many of them there were. + /// + public string? AddedManyAnnouncementFormat { get; set; } + + /// + /// Lets a tag be moved within the list, by dragging it onto the position it should take or with Alt and + /// the arrow keys. + /// + public bool? AllowReorder { get; set; } + + /// + /// Sets the autocomplete html attribute of the input element. + /// + public string? AutoComplete { get; set; } + + /// + /// Whether the input should receive focus on first render. + /// + public bool? AutoFocus { get; set; } + + /// + /// Turns the Backspace pressed on an empty input from a removal into a correction: the last tag is taken + /// off the list and its text is put back into the input. + /// + public bool? BackspaceEditsLastTag { get; set; } + + /// + /// Lets the Enter pressed on an empty input through, so that it reaches the form around the field. + /// + public bool? CancelConfirmKeysOnEmpty { get; set; } + + /// + /// A predicate deciding which tags the user is allowed to take off the list. A tag it turns down is + /// drawn without a dismiss button, ignores the Delete and Backspace keys and stays behind when the + /// field is cleared. + /// + public Func? CanRemoveTag { get; set; } + + /// + /// Custom CSS classes for different parts of the component. + /// + public BitTagsInputClassStyles? Classes { get; set; } + + /// + /// Accessible label of the clear button. + /// + public string? ClearButtonAriaLabel { get; set; } + + /// + /// The icon of the clear button, from an external icon library. + /// + public BitIconInfo? ClearButtonIcon { get; set; } + + /// + /// The name of the icon of the clear button, from the built-in Fluent UI icons. + /// + public string? ClearButtonIconName { get; set; } + + /// + /// The tooltip of the clear button. + /// + public string? ClearButtonTitle { get; set; } + + /// + /// The format of the message announced by screen readers when every tag is removed at once, where {0} is + /// how many of them there were. + /// + public string? ClearedAnnouncementFormat { get; set; } + + /// + /// Throws away whatever text is still sitting in the input when the field loses the focus. + /// + public bool? ClearOnBlur { get; set; } + + /// + /// The color role the tags, the border and the focus ring of the field carry. + /// + public BitColor? Color { get; set; } + + /// + /// The string comparison that decides whether a tag is a duplicate of one that is already in the list. + /// + public StringComparison? Comparison { get; set; } + + /// + /// A hint rendered under the field, referenced by the input through its aria-describedby attribute. + /// + public string? Description { get; set; } + + /// + /// The format of the accessible label of the dismiss button of each tag, where {0} is the tag. + /// + public string? DismissAriaLabelFormat { get; set; } + + /// + /// The icon of the dismiss button of each tag, from an external icon library. + /// + public BitIconInfo? DismissIcon { get; set; } + + /// + /// The name of the icon of the dismiss button of each tag, from the built-in Fluent UI icons. + /// + public string? DismissIconName { get; set; } + + /// + /// The title (tooltip) of the dismiss button of each tag. + /// + public string? DismissTitle { get; set; } + + /// + /// Whether duplicate tags are allowed. + /// + public bool? Duplicates { get; set; } + + /// + /// The format of the accessible label of the little input that replaces a tag while it is being edited in + /// place, where {0} is the tag. + /// + public string? EditAriaLabelFormat { get; set; } + + /// + /// Lets a tag be corrected in place with a double click, or with the Enter or F2 key. + /// + public bool? EditableTags { get; set; } + + /// + /// The format of the message announced by screen readers when a tag is edited, where {0} is the tag as it + /// now reads. + /// + public string? EditedAnnouncementFormat { get; set; } + + /// + /// Sets the enterkeyhint html attribute of the input element. + /// + public BitEnterKeyHint? EnterKeyHint { get; set; } + + /// + /// A function returning extra CSS classes for a single tag. + /// + public Func? GetTagClass { get; set; } + + /// + /// A function returning extra inline CSS styles for a single tag. + /// + public Func? GetTagStyle { get; set; } + + /// + /// Sets the inputmode html attribute of the input element. + /// + public BitInputMode? InputMode { get; set; } + + /// + /// Draws a spinner at the end of the field, for the wait the field itself is the cause of. + /// + public bool? IsLoading { get; set; } + + /// + /// The format of the message announced by screen readers when a tag is rejected, where {0} is the tag. + /// + public string? InvalidAnnouncementFormat { get; set; } + + /// + /// The label displayed above the input. + /// + public string? Label { get; set; } + + /// + /// The label of the chip that folds the tags back once MaxDisplayedTags unfolded them. + /// + public string? LessTagsText { get; set; } + + /// + /// The accessible name of the spinner IsLoading draws. + /// + public string? LoadingAriaLabel { get; set; } + + /// + /// The number of tags drawn before the rest of them are folded away behind a chip. 0 means all of them. + /// + public int? MaxDisplayedTags { get; set; } + + /// + /// The maximum number of characters allowed for each individual tag. 0 means no limit. + /// + public int? MaxLength { get; set; } + + /// + /// The number of values the suggestion list is allowed to offer at once. 0 means all of them. + /// + public int? MaxSuggestions { get; set; } + + /// + /// The maximum number of tags allowed. 0 means no limit. + /// + public int? MaxTags { get; set; } + + /// + /// The minimum number of characters a tag has to hold to be accepted. 0 means no limit. + /// + public int? MinLength { get; set; } + + /// + /// The format of the accessible label of the chip standing for the folded tags, where {0} is how many of + /// them are folded away. + /// + public string? MoreTagsAriaLabelFormat { get; set; } + + /// + /// The format of the label of the chip standing for the folded tags, where {0} is how many of them there + /// are. + /// + public string? MoreTagsFormat { get; set; } + + /// + /// The format of the message announced by screen readers when a tag is moved, where {0} is the tag, {1} + /// its new one based position and {2} the number of tags. + /// + public string? MovedAnnouncementFormat { get; set; } + + /// + /// Stops the text left in the input from being committed as a tag when the field loses the focus. + /// + public bool? NoAddOnBlur { get; set; } + + /// + /// Stops the Tab key from committing the text left in the input. + /// + public bool? NoAddOnTab { get; set; } + + /// + /// Stops the Backspace key from removing the last tag when the input is empty. + /// + public bool? NoBackspaceRemove { get; set; } + + /// + /// Whether the input should have no border. + /// + public bool? NoBorder { get; set; } + + /// + /// Keeps the leading and trailing whitespace of a tag instead of trimming it away. + /// + public bool? NoTrim { get; set; } + + /// + /// A regular expression that every tag has to match to be accepted. + /// + public string? Pattern { get; set; } + + /// + /// The placeholder text of the input, shown while there is no tag in the list. + /// + public string? Placeholder { get; set; } + + /// + /// A short text drawn at the start of the field, which is not part of the value. + /// + public string? Prefix { get; set; } + + /// + /// The format of the message announced by screen readers when a tag is removed, where {0} is the tag. + /// + public string? RemovedAnnouncementFormat { get; set; } + + /// + /// Turns the Suggestions into the whole of what the field accepts. + /// + public bool? RestrictToSuggestions { get; set; } + + /// + /// The character(s) that turn the typed text into a tag on top of the Enter key, and that split a pasted + /// list into a tag each. + /// + public IEnumerable? Separators { get; set; } + + /// + /// Whether to render a button that removes every tag at once. + /// + public bool? ShowClearButton { get; set; } + + /// + /// Whether to render the number of tags under the field. + /// + public bool? ShowCounter { get; set; } + + /// + /// The size of the tags input. + /// + public BitSize? Size { get; set; } + + /// + /// Sets the spellcheck html attribute of the input element. + /// + public bool? SpellCheck { get; set; } + + /// + /// Custom CSS styles for different parts of the component. + /// + public BitTagsInputClassStyles? Styles { get; set; } + + /// + /// A short text drawn at the end of the field, which is not part of the value. + /// + public string? Suffix { get; set; } + + /// + /// The values offered to the user while typing, through the browser's own suggestion list. + /// + public IEnumerable? Suggestions { get; set; } + + /// + /// The sentence announced after each tag, telling what the keyboard can do with it. + /// + public string? TagAriaDescription { get; set; } + + /// + /// The accessible name of the list the tags form. + /// + public string? TagsAriaLabel { get; set; } + + /// + /// The placeholder text of the input shown once there is at least one tag in the list. + /// + public string? TagsPlaceholder { get; set; } + + /// + /// How much of the Color the tags are painted with. + /// + public BitVariant? TagVariant { get; set; } + + /// + /// A function applied to the text of a tag before anything else is done with it. + /// + public Func? Transformer { get; set; } + + /// + /// A predicate every tag has to satisfy to be accepted. + /// + public Func? Validator { get; set; } + + /// + /// The visual variant of the field. + /// + public BitVariant? Variant { 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(BitTagsInput bitTagsInput) + { + if (bitTagsInput is null) return; + + UpdateBaseParameters(bitTagsInput); + + if (AddedAnnouncementFormat is not null && bitTagsInput.HasNotBeenSet(nameof(AddedAnnouncementFormat))) + { + bitTagsInput.AddedAnnouncementFormat = AddedAnnouncementFormat; + } + + if (AddedManyAnnouncementFormat is not null && bitTagsInput.HasNotBeenSet(nameof(AddedManyAnnouncementFormat))) + { + bitTagsInput.AddedManyAnnouncementFormat = AddedManyAnnouncementFormat; + } + + if (AllowReorder.HasValue && bitTagsInput.HasNotBeenSet(nameof(AllowReorder))) + { + bitTagsInput.AllowReorder = AllowReorder.Value; + } + + if (AutoComplete.HasValue() && bitTagsInput.HasNotBeenSet(nameof(AutoComplete))) + { + bitTagsInput.AutoComplete = AutoComplete; + } + + if (AutoFocus.HasValue && bitTagsInput.HasNotBeenSet(nameof(AutoFocus))) + { + bitTagsInput.AutoFocus = AutoFocus.Value; + } + + if (BackspaceEditsLastTag.HasValue && bitTagsInput.HasNotBeenSet(nameof(BackspaceEditsLastTag))) + { + bitTagsInput.BackspaceEditsLastTag = BackspaceEditsLastTag.Value; + } + + if (CancelConfirmKeysOnEmpty.HasValue && bitTagsInput.HasNotBeenSet(nameof(CancelConfirmKeysOnEmpty))) + { + bitTagsInput.CancelConfirmKeysOnEmpty = CancelConfirmKeysOnEmpty.Value; + } + + if (CanRemoveTag is not null && bitTagsInput.HasNotBeenSet(nameof(CanRemoveTag))) + { + bitTagsInput.CanRemoveTag = CanRemoveTag; + } + + if (Classes is not null && bitTagsInput.HasNotBeenSet(nameof(Classes))) + { + bitTagsInput.Classes = Classes; + + bitTagsInput.ClassBuilder.Reset(); + } + + if (ClearButtonAriaLabel.HasValue() && bitTagsInput.HasNotBeenSet(nameof(ClearButtonAriaLabel))) + { + bitTagsInput.ClearButtonAriaLabel = ClearButtonAriaLabel; + } + + if (ClearButtonIcon is not null && bitTagsInput.HasNotBeenSet(nameof(ClearButtonIcon))) + { + bitTagsInput.ClearButtonIcon = ClearButtonIcon; + } + + if (ClearButtonIconName.HasValue() && bitTagsInput.HasNotBeenSet(nameof(ClearButtonIconName))) + { + bitTagsInput.ClearButtonIconName = ClearButtonIconName; + } + + if (ClearButtonTitle.HasValue() && bitTagsInput.HasNotBeenSet(nameof(ClearButtonTitle))) + { + bitTagsInput.ClearButtonTitle = ClearButtonTitle; + } + + if (ClearedAnnouncementFormat is not null && bitTagsInput.HasNotBeenSet(nameof(ClearedAnnouncementFormat))) + { + bitTagsInput.ClearedAnnouncementFormat = ClearedAnnouncementFormat; + } + + if (ClearOnBlur.HasValue && bitTagsInput.HasNotBeenSet(nameof(ClearOnBlur))) + { + bitTagsInput.ClearOnBlur = ClearOnBlur.Value; + } + + if (Color.HasValue && bitTagsInput.HasNotBeenSet(nameof(Color))) + { + bitTagsInput.Color = Color.Value; + + bitTagsInput.ClassBuilder.Reset(); + } + + if (Comparison.HasValue && bitTagsInput.HasNotBeenSet(nameof(Comparison))) + { + bitTagsInput.Comparison = Comparison.Value; + } + + if (Description.HasValue() && bitTagsInput.HasNotBeenSet(nameof(Description))) + { + bitTagsInput.Description = Description; + } + + if (DismissAriaLabelFormat.HasValue() && bitTagsInput.HasNotBeenSet(nameof(DismissAriaLabelFormat))) + { + bitTagsInput.DismissAriaLabelFormat = DismissAriaLabelFormat; + } + + if (DismissIcon is not null && bitTagsInput.HasNotBeenSet(nameof(DismissIcon))) + { + bitTagsInput.DismissIcon = DismissIcon; + } + + if (DismissIconName.HasValue() && bitTagsInput.HasNotBeenSet(nameof(DismissIconName))) + { + bitTagsInput.DismissIconName = DismissIconName; + } + + if (DismissTitle.HasValue() && bitTagsInput.HasNotBeenSet(nameof(DismissTitle))) + { + bitTagsInput.DismissTitle = DismissTitle; + } + + if (Duplicates.HasValue && bitTagsInput.HasNotBeenSet(nameof(Duplicates))) + { + bitTagsInput.Duplicates = Duplicates.Value; + } + + if (EditAriaLabelFormat.HasValue() && bitTagsInput.HasNotBeenSet(nameof(EditAriaLabelFormat))) + { + bitTagsInput.EditAriaLabelFormat = EditAriaLabelFormat; + } + + if (EditableTags.HasValue && bitTagsInput.HasNotBeenSet(nameof(EditableTags))) + { + bitTagsInput.EditableTags = EditableTags.Value; + } + + if (EditedAnnouncementFormat is not null && bitTagsInput.HasNotBeenSet(nameof(EditedAnnouncementFormat))) + { + bitTagsInput.EditedAnnouncementFormat = EditedAnnouncementFormat; + } + + if (EnterKeyHint.HasValue && bitTagsInput.HasNotBeenSet(nameof(EnterKeyHint))) + { + bitTagsInput.EnterKeyHint = EnterKeyHint.Value; + + bitTagsInput.OnSetEnterKeyHint(); + } + + if (GetTagClass is not null && bitTagsInput.HasNotBeenSet(nameof(GetTagClass))) + { + bitTagsInput.GetTagClass = GetTagClass; + } + + if (GetTagStyle is not null && bitTagsInput.HasNotBeenSet(nameof(GetTagStyle))) + { + bitTagsInput.GetTagStyle = GetTagStyle; + } + + if (InputMode.HasValue && bitTagsInput.HasNotBeenSet(nameof(InputMode))) + { + bitTagsInput.InputMode = InputMode.Value; + + bitTagsInput.OnSetInputMode(); + } + + if (InvalidAnnouncementFormat is not null && bitTagsInput.HasNotBeenSet(nameof(InvalidAnnouncementFormat))) + { + bitTagsInput.InvalidAnnouncementFormat = InvalidAnnouncementFormat; + } + + if (IsLoading.HasValue && bitTagsInput.HasNotBeenSet(nameof(IsLoading))) + { + bitTagsInput.IsLoading = IsLoading.Value; + } + + if (Label.HasValue() && bitTagsInput.HasNotBeenSet(nameof(Label))) + { + bitTagsInput.Label = Label; + + bitTagsInput.ClassBuilder.Reset(); + } + + if (LessTagsText.HasValue() && bitTagsInput.HasNotBeenSet(nameof(LessTagsText))) + { + bitTagsInput.LessTagsText = LessTagsText; + } + + if (LoadingAriaLabel.HasValue() && bitTagsInput.HasNotBeenSet(nameof(LoadingAriaLabel))) + { + bitTagsInput.LoadingAriaLabel = LoadingAriaLabel; + } + + if (MaxDisplayedTags.HasValue && bitTagsInput.HasNotBeenSet(nameof(MaxDisplayedTags))) + { + bitTagsInput.MaxDisplayedTags = MaxDisplayedTags.Value; + } + + if (MaxLength.HasValue && bitTagsInput.HasNotBeenSet(nameof(MaxLength))) + { + bitTagsInput.MaxLength = MaxLength.Value; + } + + if (MaxSuggestions.HasValue && bitTagsInput.HasNotBeenSet(nameof(MaxSuggestions))) + { + bitTagsInput.MaxSuggestions = MaxSuggestions.Value; + } + + if (MaxTags.HasValue && bitTagsInput.HasNotBeenSet(nameof(MaxTags))) + { + bitTagsInput.MaxTags = MaxTags.Value; + } + + if (MinLength.HasValue && bitTagsInput.HasNotBeenSet(nameof(MinLength))) + { + bitTagsInput.MinLength = MinLength.Value; + } + + if (MoreTagsAriaLabelFormat.HasValue() && bitTagsInput.HasNotBeenSet(nameof(MoreTagsAriaLabelFormat))) + { + bitTagsInput.MoreTagsAriaLabelFormat = MoreTagsAriaLabelFormat; + } + + if (MoreTagsFormat.HasValue() && bitTagsInput.HasNotBeenSet(nameof(MoreTagsFormat))) + { + bitTagsInput.MoreTagsFormat = MoreTagsFormat; + } + + if (MovedAnnouncementFormat is not null && bitTagsInput.HasNotBeenSet(nameof(MovedAnnouncementFormat))) + { + bitTagsInput.MovedAnnouncementFormat = MovedAnnouncementFormat; + } + + if (NoAddOnBlur.HasValue && bitTagsInput.HasNotBeenSet(nameof(NoAddOnBlur))) + { + bitTagsInput.NoAddOnBlur = NoAddOnBlur.Value; + } + + if (NoAddOnTab.HasValue && bitTagsInput.HasNotBeenSet(nameof(NoAddOnTab))) + { + bitTagsInput.NoAddOnTab = NoAddOnTab.Value; + } + + if (NoBackspaceRemove.HasValue && bitTagsInput.HasNotBeenSet(nameof(NoBackspaceRemove))) + { + bitTagsInput.NoBackspaceRemove = NoBackspaceRemove.Value; + } + + if (NoBorder.HasValue && bitTagsInput.HasNotBeenSet(nameof(NoBorder))) + { + bitTagsInput.NoBorder = NoBorder.Value; + + bitTagsInput.ClassBuilder.Reset(); + } + + if (NoTrim.HasValue && bitTagsInput.HasNotBeenSet(nameof(NoTrim))) + { + bitTagsInput.NoTrim = NoTrim.Value; + } + + if (Pattern.HasValue() && bitTagsInput.HasNotBeenSet(nameof(Pattern))) + { + bitTagsInput.Pattern = Pattern; + + bitTagsInput.OnSetPattern(); + } + + if (Placeholder.HasValue() && bitTagsInput.HasNotBeenSet(nameof(Placeholder))) + { + bitTagsInput.Placeholder = Placeholder; + } + + if (Prefix.HasValue() && bitTagsInput.HasNotBeenSet(nameof(Prefix))) + { + bitTagsInput.Prefix = Prefix; + } + + if (RemovedAnnouncementFormat is not null && bitTagsInput.HasNotBeenSet(nameof(RemovedAnnouncementFormat))) + { + bitTagsInput.RemovedAnnouncementFormat = RemovedAnnouncementFormat; + } + + if (RestrictToSuggestions.HasValue && bitTagsInput.HasNotBeenSet(nameof(RestrictToSuggestions))) + { + bitTagsInput.RestrictToSuggestions = RestrictToSuggestions.Value; + } + + if (Separators is not null && bitTagsInput.HasNotBeenSet(nameof(Separators))) + { + bitTagsInput.Separators = Separators; + + bitTagsInput.OnSetSeparators(); + } + + if (ShowClearButton.HasValue && bitTagsInput.HasNotBeenSet(nameof(ShowClearButton))) + { + bitTagsInput.ShowClearButton = ShowClearButton.Value; + } + + if (ShowCounter.HasValue && bitTagsInput.HasNotBeenSet(nameof(ShowCounter))) + { + bitTagsInput.ShowCounter = ShowCounter.Value; + } + + if (Size.HasValue && bitTagsInput.HasNotBeenSet(nameof(Size))) + { + bitTagsInput.Size = Size.Value; + + bitTagsInput.ClassBuilder.Reset(); + } + + if (SpellCheck.HasValue && bitTagsInput.HasNotBeenSet(nameof(SpellCheck))) + { + bitTagsInput.SpellCheck = SpellCheck.Value; + } + + if (Styles is not null && bitTagsInput.HasNotBeenSet(nameof(Styles))) + { + bitTagsInput.Styles = Styles; + + bitTagsInput.StyleBuilder.Reset(); + } + + if (Suffix.HasValue() && bitTagsInput.HasNotBeenSet(nameof(Suffix))) + { + bitTagsInput.Suffix = Suffix; + } + + if (Suggestions is not null && bitTagsInput.HasNotBeenSet(nameof(Suggestions))) + { + bitTagsInput.Suggestions = Suggestions; + } + + if (TagAriaDescription is not null && bitTagsInput.HasNotBeenSet(nameof(TagAriaDescription))) + { + bitTagsInput.TagAriaDescription = TagAriaDescription; + } + + if (TagsAriaLabel.HasValue() && bitTagsInput.HasNotBeenSet(nameof(TagsAriaLabel))) + { + bitTagsInput.TagsAriaLabel = TagsAriaLabel; + } + + if (TagsPlaceholder.HasValue() && bitTagsInput.HasNotBeenSet(nameof(TagsPlaceholder))) + { + bitTagsInput.TagsPlaceholder = TagsPlaceholder; + } + + if (TagVariant.HasValue && bitTagsInput.HasNotBeenSet(nameof(TagVariant))) + { + bitTagsInput.TagVariant = TagVariant.Value; + + bitTagsInput.ClassBuilder.Reset(); + } + + if (Transformer is not null && bitTagsInput.HasNotBeenSet(nameof(Transformer))) + { + bitTagsInput.Transformer = Transformer; + } + + if (Validator is not null && bitTagsInput.HasNotBeenSet(nameof(Validator))) + { + bitTagsInput.Validator = Validator; + } + + if (Variant.HasValue && bitTagsInput.HasNotBeenSet(nameof(Variant))) + { + bitTagsInput.Variant = Variant.Value; + + bitTagsInput.ClassBuilder.Reset(); + } + } +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor index f726d261d5b..eb77b7b1098 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor @@ -3,67 +3,58 @@ + Description="A tags (chips/tokens) input for Blazor: confirm a tag with Enter or a separator of your own, pick one from a suggestion list or accept nothing else, paste a whole separated list at once, cap the count and the length, reject what fails a pattern or a validator and hear why, normalize through a transformer, correct a chip in place, pin the ones that may not be removed, walk and reorder the chips from the keyboard or by dragging, fold a long list away, style each chip by its value, drive it all from code, and bind the list to an EditForm." />
- Type a word and press Enter: it becomes a chip in front of the input, and its dismiss button - takes it back off. ReadOnly keeps the tags visible and navigable while refusing every change - to them, so the dismiss buttons are not rendered at all, and IsEnabled="false" dims the whole - field along with the tags in it. + Type a word and press Enter: it becomes a chip, and its dismiss button takes it back off. + Placeholder is the hint shown while the list is still empty; once there is a tag it would only + compete with the chips for the width of the field, so it is dropped unless a TagsPlaceholder + keeps the invitation visible. ReadOnly keeps the tags visible and navigable while refusing every + change, and IsEnabled="false" dims the whole field along with them.

- + +
- +
Label renders the caption above the field and ties it to the input, so clicking it puts the - caret in there and screen readers announce it with the field. LabelTemplate replaces the text - with markup of your own when a plain string is not enough. Combined with the inherited - Required, the label carries the asterisk that marks the field as mandatory. + caret in there. With the inherited Required it carries the asterisk that marks the field as + mandatory. Description renders a hint under the field which the input references through + aria-describedby, so it is announced along with the field rather than only drawn - which + is where the rules of the field belong. LabelTemplate and DescriptionTemplate take markup + instead of a string.

- - - + + + Custom label - -
-
- - -
- Description renders a hint under the field, which the input references through its - aria-describedby attribute, so it is announced along with the field rather than only - being drawn. It is where the rules of the field belong: the accepted format, the number of tags - allowed, the separator that splits them. DescriptionTemplate takes markup instead of a - string, and is referenced exactly the same way. -
-
-
- - @@ -74,69 +65,30 @@
- -
- Placeholder is the hint shown while the list is still empty. Once there is at least one tag - it would only compete with them for the width of the field, so it is dropped - unless a - TagsPlaceholder is given, which is what keeps the invitation to add another one visible - without repeating the whole sentence. -
-
-
- - -
-
- - +
- Variant decides how much of a frame the field carries: Outline (the default) draws a - full rule around it, Fill paints it with a surface color and drops the rule, and Text - keeps only an underline, for a field that should not outweigh the ones around it. NoBorder - removes the frame altogether, for a field sitting on a surface that already provides one. It is - the frame of the field alone; how the tags in it are painted is the TagVariant. + Variant decides how much of a frame the field carries - Outline (the default), + Fill or only an underline with Text - and NoBorder removes it altogether. + TagVariant decides how much of the color the chips are painted with, exactly as a BitTag + is. The two are independent, so they combine freely.

- - - + + +
- -
- TagVariant decides how much of the Color the tags themselves are painted with: - Fill (the default) fills every chip with it, the way a BitTag is filled, - Outline leaves the chip unfilled and draws the color as its rule and its text, and - Text keeps only the text in it, for tags that should not outweigh the field holding them. - It is independent of the Variant, which is about the frame of the field rather than the - tags in it, so the two combine freely. -
-
-
- - - -
-
- - +
- By default the Enter key is the only thing that turns the typed text into a tag. Separators - adds characters that do the same: typing one of them commits whatever stands before it, and the - character itself never reaches the input. A separator is any string at all - a single character - such as a comma or a space, or a longer one such as ", ". The very same separators - split a paste, so a comma separated list copied out of a spreadsheet or a mail client arrives as a - whole row of tags in one go - each piece of it going through the same validation, so the ones that - are refused are reported while the rest are still added. A pasted text spread over several lines is - joined over the first separator on its way in, since a single line field would otherwise drop the - line breaks and leave one run-on tag behind. + By default only Enter turns the typed text into a tag. Separators adds characters that do the + same: typing one commits what stands before it, and the character itself never reaches the input. A + separator is any string - a comma, a space, or something longer such as ", ". The same + separators split a paste, so a comma separated list arrives as a whole row of tags, each piece going + through the same validation. A paste spread over several lines is joined over the first separator on + its way in, so a column copied out of a spreadsheet does not land as one run-on tag.

@@ -150,27 +102,21 @@
- +
- Suggestions offers a set of known values while the user types, through the suggestion list - the browser itself renders for a datalist - which is what keeps it reachable, - positioned and announced on every platform without a popup of our own. Picking one only fills the - input; it is the usual Enter (or a separator) that turns it into a tag, so every rule of the - field still applies to it. The values already in the list are left out of the suggestions, unless - Duplicates allows them back in, and so are all of them once the MaxTags ceiling - leaves nothing to add. A datalist suggests rather than restricts, so free text is still accepted - next to it; RestrictToSuggestions is what turns the suggestions into the whole of what the - field accepts, rejecting anything else with the NotSuggested reason. What counts as one of - the suggestions is decided by Comparison, and the tag that is stored is the suggestion - rather than the spelling it was typed with - so with an OrdinalIgnoreCase comparison, - typing BLAZOR adds blazor, and a list that has to be grouped or looked up later - never holds two spellings of one value. -

- A catalogue of known values is written into the page in full, and rebuilt on every keystroke, so - MaxSuggestions caps how many of them are offered at once: beyond it only the values holding - what is being typed are kept, and only as many of those as it allows. It changes nothing about - what the user is actually shown - the browser filters the list it is handed all over again - only - about how much of a catalogue of thousands ever reaches the page. + Suggestions offers known values while the user types, through the list the browser itself + renders for a datalist - reachable, positioned and announced on every platform without a + popup of our own. Picking one only fills the input; the usual Enter turns it into a tag, so every rule + still applies. Values already in the list are left out, and so are all of them once MaxTags + leaves nothing to add. A datalist suggests rather than restricts: RestrictToSuggestions is what + makes them the only accepted values, rejecting anything else with the NotSuggested reason. What + counts as one of them is decided by Comparison, and the tag that is stored is the suggestion + rather than the spelling it was typed with, so a list to be grouped later never holds two spellings of + one value. MaxSuggestions caps how many are written into the page at once - beyond it only the + values holding what is being typed are kept, which changes nothing about what the user is shown. + Where the catalogue is fetched rather than held, OnInput is what triggers the fetch and + IsLoading draws the spinner that says the field is waiting - an indeterminate progressbar, so + a screen reader hears the wait instead of missing it.

@@ -181,8 +127,9 @@ @if (suggestionMessage.HasValue()) { @@ -193,68 +140,72 @@ MaxSuggestions="5" Placeholder="Start typing a country..." Description="Only the five best matches are ever written into the page." /> +
- +
- MaxTags caps how many tags the list may hold. Once the ceiling is reached every further tag - is rejected with the MaxTags reason, which the OnInvalid callback receives - so the - field can say why nothing happened instead of appearing to ignore the Enter key. A paste that would - overflow the cap stops there rather than being rejected as a whole. + MaxTags caps how many tags the list may hold; beyond it every tag is rejected with the + MaxTags reason, which OnInvalid receives along with the tag - so the field can say why + nothing happened instead of appearing to ignore the Enter key. A paste that would overflow the cap + stops there rather than being refused whole. + MaxLength caps the characters of each tag and truncates rather than rejects - throwing the + twelfth character away beats throwing a pasted list away - while MinLength rejects what is too + short. Both count the text after trimming and transformation, so they measure exactly what would be + added. ShowCounter draws the number of tags under the field, as a plain count or as + count / MaxTags.

-
Tags: @(maxTagsValue is null ? 0 : maxTagsValue.Count) / 3
@if (maxTagsMessage.HasValue()) {
@maxTagsMessage
} -
-
- - -
- MaxLength caps the number of characters of each individual tag. It is a truncation rather - than a rejection: the input stops accepting characters beyond it, and a pasted value longer than it - is cut down to size instead of being thrown away - which matters for a paste, where throwing the - twelfth character away is far better than throwing the whole list away. The little input that - replaces a tag while it is corrected in place carries the cap natively, since there the limit of a - tag is the limit of the whole field. MinLength works the other way round and does reject: a - tag shorter than it never enters the list, and the rejection is reported with the MinLength - reason. Both of them count the text after the trimming and the Transformer, so they measure - exactly what would be added. -
-
-
- - + MaxLength="10" + ShowCounter + Placeholder="Add tag..." + Description="Typing beyond the tenth character does nothing; shorter than three is refused." />
- +
- Pattern is a regular expression that every tag has to match; an expression that cannot be - compiled is ignored rather than breaking the field, and a match that runs away is timed out and - treated as a failure. Validator covers what an expression cannot express - a lookup in a - list of allowed values, a checksum, a rule that depends on the other tags - by taking a predicate - instead. Both run after the trimming and the transformation, so they see exactly the text that - would be added, and both report their rejection through OnInvalid. + Transformer normalizes the text before anything else is done with it - lower casing, stripping a + leading #, collapsing whitespace - so every rule below sees the normalized text and it is + the normalized text that is stored. Pattern is a regular expression every tag has to match (an + expression that cannot be compiled is ignored rather than breaking the field, and a runaway match is + timed out and treated as a failure); Validator covers what an expression cannot - a lookup, a + checksum, a rule depending on the other tags. A tag already in the list is refused by default and + reported through OnTagExists; Duplicates lifts that, and Comparison decides which + tags count as the same one. Every refusal reaches OnInvalid with the rule that caused it.

+ + @if (duplicateMessage.HasValue()) + { +
@duplicateMessage
+ } @patternMessage
} - @if (validatorMessage.HasValue()) { @@ -277,96 +229,94 @@
- +
- Transformer normalizes the text of a tag before anything else is done with it, which is what - keeps a list consistent whichever way its entries were typed: lower casing them, stripping a - leading #, collapsing the whitespace inside them. It runs before the length, pattern, - validator and duplicate checks, so all of them see the normalized text - which means - #Blazor, blazor and BLAZOR all collapse into the same tag and the second of - them is refused as a duplicate. An exception thrown out of it leaves the text untouched rather than - breaking the field. + EditableTags lets a tag be corrected in place instead of being removed and typed again: double + click a chip, or press Enter or F2 on the focused one, and it turns into a little input + with its text selected. Enter commits, Escape puts the old text back, and leaving the + input commits too. The text goes through the same trimming, transformation and validation as a tag + being added - except that the tag is not a duplicate of itself and the ceiling cannot be reached, since + the list does not grow. Committing an empty text removes the tag, and OnEdit receives the old + and the new text and can call the change off.

- + + @if (editMessage.HasValue()) + { +
@editMessage
+ }
- +
- A tag that is already in the list is refused by default, and the refusal is reported through - OnTagExists as well as through OnInvalid. Duplicates lifts that rule for the - lists where repetition means something. Which tags count as the same one is decided by - Comparison: it is Ordinal by default, so Blazor and blazor are two - different tags, while OrdinalIgnoreCase makes them one. + AllowReorder lets a tag be moved two equal ways. With a pointer, drop a chip onto the one whose + position it should take: the carried chip fades and the one under the pointer is outlined, so the + landing place is visible before the button is let go of. Without a pointer, hold Alt with the + arrow keys to walk the focused chip one position at a time, or Alt+Home / Alt+End to send + it to either end - which is what keeps reordering usable on a touch screen and with a screen reader. + Either way the focus travels with the tag and the move is announced. OnReorder reports the tag + and the two positions, which is what says which chip moved without diffing the list; the order + is part of the value, so the move is written back through @@bind-Value as well.

- - @if (duplicateMessage.HasValue()) + + @if (reorderMessage.HasValue()) { -
@duplicateMessage
+
@reorderMessage
} - -
- +
- EditableTags lets a tag be corrected in place instead of having to be removed and typed - again: double click a chip, or press Enter or F2 on the focused one, and it turns into - a little input with its text selected. Enter commits the new text, Escape puts the old - one back, and leaving the input commits it too. The text goes through the very same trimming, - transformation and validation as a tag being added - except that the tag is not a duplicate of - itself and the MaxTags ceiling cannot be reached, since the list does not grow. Committing an - empty text removes the tag, and OnEdit receives the old and the new text and can call the - change off. The Enter that commits the correction is held back from the form around the field - exactly as the one that adds a tag is, and an input method composing a character keeps it as well, - so a correction is never committed half way through a word. + MaxDisplayedTags draws only the first few chips and folds the rest behind one saying how many + are left, which unfolds the list and folds it back - so a tag that is not drawn is never a tag that + cannot be reached, with the pointer or with the keyboard, where the chip is a tab stop of its own. Only + how much is drawn changes: the value keeps every tag, the counter counts all of them, and the form + posts them. MoreTagsFormat and LessTagsText write its two labels, + MoreTagsAriaLabelFormat what it is announced as - "+3" read out alone says nothing about + what pressing it would do.

- - @if (editMessage.HasValue()) - { -
@editMessage
- } + Description="The rest of the tags are one click away." + DefaultValue="@(new List { "blazor", "dotnet", "web", "ui", "wasm", "razor" })" /> +
- +
- ShowClearButton renders a button at the end of the field that empties the whole list along - with the text left in the input, and raises OnClear with the tags it removed. It is not - rendered while the field is read-only, disabled or already empty, and it stays out of the tab order - - the Escape key pressed on the input being its keyboard equivalent. Escape takes back what - is being typed before it takes anything else, though: while there is text in the input it only - clears that text, and it is the second press, on the now empty input, that empties the list - - since throwing a whole list of tags away over a half typed word is not an undo but a loss. - OnBeforeClear runs first, whichever of the three emptied the field, and receives the whole - list along with a Cancel flag - which is where a confirmation belongs, emptying a list of - fifty tags by accident being exactly the mistake that is worth one. Pressing the button takes the - button itself away with everything else, so the caret is handed back to the input rather than - being dropped on the page; a clear that was called off leaves both exactly where they were. + ShowClearButton renders a button at the end of the field that empties the whole list along with + the text in the input, and raises OnClear with what it removed. It is not rendered while the + field is read-only, disabled or already empty, and it stays out of the tab order - Escape on the + input is its keyboard equivalent. Escape takes back what is being typed first, though: it is the second + press, on the now empty input, that empties the list. OnBeforeClear runs first whichever of the + three emptied the field and can call it off, which is where a confirmation belongs. Pressing the button + takes the button away with everything else, so the caret is handed back to the input rather than + dropped on the page.

@@ -392,59 +342,53 @@
- +
- ShowCounter draws the number of tags under the field, at the end of the line the - Description sits on, as a plain count or as count / MaxTags when there is a ceiling to - reach. It is drawn rather than announced - the list of chips is what a screen reader counts, and - every addition and removal is already spoken by the live region. + CanRemoveTag decides which tags the user may take off the list, for the values a field holds + but does not let go of: the owner among the editors of a document, the tag a saved filter is built + on. A tag it turns down is drawn without a dismiss button, ignores Delete and + Backspace, is left alone by the Backspace pressed on the empty input, and stays behind when + the field is cleared - so clearing empties the field of everything it can be emptied of, and + a field holding nothing else does not render a clear button at all. It is still editable and still + movable, since neither takes the tag away, and the sentence announced after a fixed chip says so + rather than promising a removal that does nothing. It governs what the user may do, not what you + may: RemoveTagAsync names a tag outright and takes it off regardless.

- - + +
Tags: @(fixedTags is not null ? string.Join(", ", fixedTags) : "null")
- +
- The chips are a single stop of the tab order rather than one per tag, which the arrow keys then - walk through: Left and Right (swapped in a right-to-left direction) move between - them, Home and End jump to the two ends, Delete and Backspace remove - the focused one and hand the focus to its neighbour, Enter and F2 open the inline edit - when EditableTags is on, Alt with the arrows moves the tag when AllowReorder is, - and Escape goes back to the input. Every one of those keys carries a browser default that is - held back for it - Home and End would scroll the page away, Alt with an arrow would walk the whole - document back through the history - so a gesture of the field never turns into a gesture of the - browser. - From the input, Backspace on an empty field removes the last tag (which NoBackspaceRemove - turns off, and which BackspaceEditsLastTag turns from a removal into a correction, putting - the text of that tag back into the input to be fixed and confirmed again rather than letting it - simply disappear), and the arrow pointing backwards walks into the last chip. Tab commits - the text left in the input instead of leaving it behind - unless NoAddOnTab is set - while - Shift+Tab always walks straight out, so the field is never a trap. Losing the focus commits - the text as well, which NoAddOnBlur turns off and ClearOnBlur turns into a discard, - so a half typed word is not found again hours later in a field the user believes they finished - with. Escape takes back the text being - typed, and only empties the list on a second press, once there is nothing left to take back and - ShowClearButton is on. + The chips are a single stop of the tab order rather than one per tag. Left and Right + (swapped in RTL) move between them, Home and End jump to the ends, Delete and + Backspace remove the focused one and hand the focus to its neighbour, Enter and F2 + open the inline edit, Alt with the arrows moves the tag, and Escape goes back to the + input. Every one of those keys carries a browser default that is held back for it, so a gesture of the + field never turns into a gesture of the browser. +

+ From the input, Backspace on an empty field removes the last tag - NoBackspaceRemove + turns that off and BackspaceEditsLastTag turns it into a correction, putting the tag's text back + into the input instead of letting it disappear - and the backwards arrow walks into the last chip. + Tab commits the text left in the input unless NoAddOnTab is set, while Shift+Tab + always walks straight out, so the field is never a trap. Losing the focus commits too, which + NoAddOnBlur turns off and ClearOnBlur turns into a discard. While an input method is + composing a character the Enter that picks a candidate is not treated as a confirmation, so a tag is + never cut in half by it.

- None of that is visible on a chip that looks like nothing but a word, so once the inline edit or - the reordering is on, each tag is announced with a sentence saying which keys it answers to - - TagAriaDescription being what replaces, localizes or silences it. Each chip also carries - its own position in the whole list, so "the third of six" stays true even where - MaxDisplayedTags has folded the other three away. And while an input method is composing a - character (Chinese, Japanese, Korean), the Enter that picks a candidate out of the suggestion - window is not treated as a confirmation - neither in the input nor in the inline edit - so a tag - is never cut in half by it. + InputMode and EnterKeyHint decide the virtual keyboard a phone opens and the label on its + return key; SpellCheck turns the red underline back on for a field that collects words rather + than identifiers, and AutoComplete hands the browser's own autofill back where the field + collects values it knows about.

@@ -464,67 +408,144 @@ Placeholder="Add tag..." Description="Backspace on the empty input takes the last tag back for correction; leaving the field throws away what was still being typed." DefaultValue="@(new List { "one", "two" })" /> +
- +
- AllowReorder lets a tag be moved within the list two equal ways. With a pointer, a chip can - be picked up and dropped onto the one whose position it should take: the chip being carried is - faded out and the one under the pointer is outlined, so the landing place is visible before the - button is let go of. Without a pointer, focus a chip and hold Alt while pressing the left or - right arrow to walk it one position at a time, or Alt+Home and Alt+End to send it to - either end - which is what keeps the reordering usable on a touch screen and with a screen reader, - where a drag is not. Either way the focus travels with the tag, so several steps can be taken in a - row, and each move is announced along with the position the tag landed on. In a right-to-left - direction the arrows are mirrored, exactly as they are for the plain navigation, and the browser's - own Alt+arrow history navigation is held back so that reordering a tag never leaves the page. + A chip appearing, disappearing or changing place reaches no screen reader on its own - the list sits + away from the caret - so the component says each of them out loud through a polite live region: + AddedAnnouncementFormat, AddedManyAnnouncementFormat, RemovedAnnouncementFormat, + ClearedAnnouncementFormat, EditedAnnouncementFormat, MovedAnnouncementFormat and + InvalidAnnouncementFormat. Most take {0} for the tag - the move also + {1} for the position it landed on and {2} for how many tags there are - and + an empty string keeps that event silent. The two standing for a batch count instead of naming: reading + fifty names out is not a confirmation but a wall, and the tags are in the list to be walked through + anyway.

- OnReorder reports each move with the tag and the two positions it went between, which is - what tells a consumer which tag moved and where to - the bound value alone would have - to be diffed against its previous state to find that out. The order of the tags is part of the - value, so a move is a change like any other: it is written back through @@bind-Value and - reported through OnChange as well. + The names of the parts follow the same idea: TagsAriaLabel names the list, TagAriaDescription + is the sentence telling what the keyboard can do with the chip just reached (built from the gestures the + field was actually given, and left out when there are none), DismissAriaLabelFormat and + DismissTitle name the dismiss button, EditAriaLabelFormat the little edit input, and + ClearButtonAriaLabel the button that empties the field. Each chip also carries its own position + in the whole list, so "the third of six" stays true where MaxDisplayedTags folded the rest away.

- - @if (reorderMessage.HasValue()) - { -
@reorderMessage
- } + AddedAnnouncementFormat="{0} was added to the list." + AddedManyAnnouncementFormat="{0} tags were added to the list." + RemovedAnnouncementFormat="{0} was taken off the list." + ClearedAnnouncementFormat="The list of {0} tags was emptied." + EditedAnnouncementFormat="{0} is the new text of the tag." + MovedAnnouncementFormat="{0} is now number {1} out of {2}." + InvalidAnnouncementFormat="{0} was refused." + MinLength="3" + DefaultValue="@(new List { "blazor", "dotnet" })" /> + +
+
+ + +
+ Prefix and Suffix draw a short text inside the field, at its start and at its end, which + is not part of the value: the To: of a recipients field, the unit of a list of + measurements. The prefix sits in front of the chips and the suffix after everything else, the clear + button included. PrefixTemplate and SuffixTemplate take markup instead, for an icon or a + badge. Neither is announced with the field, so the meaning they carry has to be in the Label as + well - a reader hearing only "Tags, edit" over a field whose prefix says To: has no idea + what the tags are. +
+
+
+ + + + + + + + + +
- +
- TagTemplate replaces the text of every chip with markup of your own, the tag itself being - the context. The chip around it - its background, its focus ring and its dismiss button - is still - provided by the component, so a template only has to describe what goes inside. + TagTemplate replaces the text of every chip with markup of your own, the tag being the context; + the chip around it - its background, its focus ring, its dismiss button - is still the component's, so + a template only describes what goes inside. GetTagClass and GetTagStyle are what tell one + chip apart from the next: each receives a tag and returns what that particular chip should carry on top + of the shared styling - the address that is not well formed drawn in red, each priority in a color of + its own. They run for every drawn tag on every render, so they belong to a lookup rather than a + computation, and an exception thrown out of one leaves that chip looking like all the others.

- + @tag + +
- +
- The value is the list of tags itself, bound two ways with @@bind-Value. DefaultValue - seeds an uncontrolled field, the one that is read through the OnChange callback rather than - through a bound property. Note that removing the last tag leaves the value at null rather - than at an empty list, which is what makes a plain [Required] annotation catch an empty - field. + The value is the list of tags itself, bound two ways with @@bind-Value; DefaultValue seeds + an uncontrolled field read through OnChange instead. Removing the last tag leaves the value at + null rather than at an empty list, which is what makes a plain [Required] catch an empty + field. Inside an EditForm the field validates like any other input: it reports to the + EditContext, paints the error state, marks its input with aria-invalid and turns its + description red - the annotations that apply being the collection ones. Enter confirms a tag, so it is + held back from submitting the form; CancelConfirmKeysOnEmpty hands it back once there is nothing + left to confirm. Name posts the whole list as a single field of a plain HTML form, the tags + joined by the first separator.

@@ -535,20 +556,35 @@ DefaultValue="@(new List { "blazor" })" OnChange="v => changedTags = v" />
Tags: @(changedTags is not null ? string.Join(", ", changedTags) : "null")
+
+ + + + +
+ Submit +
+
Form submitted: @formSubmitted
+
- +
- OnBeforeAdd and OnBeforeRemove run before the list is changed and can call the change - off by setting args.Cancel, which is the hook for a confirmation or for a rule that lives on - the server. OnAdd receives every tag added in one go (a paste adds several of them at once), - OnRemove the one that was taken off, OnInvalid the one that was refused along with the - rule that refused it, and OnInput the text of the input as it is typed - which is what an - external suggestion list is driven by. OnInput also reports the emptying the component does - itself, when the text becomes a tag or the field is cleared, so a suggestion list of your own is - never left filtering on a word that is already a chip. OnFocusIn, OnFocusOut and - OnKeyDown report the plain input events on top of them. + OnBeforeAdd and OnBeforeRemove run before the list changes and can call the change off + with args.Cancel, which is the hook for a confirmation or a rule that lives on the server. + OnAdd receives every tag added in one go (a paste adds several), OnRemove the one taken + off, OnInvalid the one refused with the rule that refused it, and OnInput the text of the + input as it is typed - which is what an external suggestion list is driven by. OnInput also + reports the emptying the component does itself, so a list of your own is never left filtering on a word + that is already a chip. OnFocusIn, OnFocusOut and OnKeyDown report the plain input + events on top of them.

@@ -565,21 +601,16 @@
- +
- The component can be driven from the outside as well: AddTagAsync and AddTagsAsync - add through exactly the same pipeline as typing does (trimming, transformation, every validation - rule and every callback), RemoveTagAsync and RemoveTagAtAsync take one off, - MoveTagAsync moves one to another position exactly as a drag does - raising OnReorder - with it, and without needing AllowReorder, since that parameter is what offers the gesture - to the user rather than what permits the list to be reordered - EditTagAsync opens the - inline edit of one (which does need EditableTags), SetInputTextAsync writes the text - of the input - which is what fills the field from a suggestion list of your own driven by - OnInput, and what empties it again once the pick has been turned into a tag - - Clear empties the whole list, and - FocusAsync puts the caret in the input. All of them do nothing while the field is disabled - or read-only, and all of them go through the callbacks, the announcements and the validation that - the equivalent gesture does, so nothing reaches the value by a back door. + The component can be driven from the outside: AddTagAsync and AddTagsAsync add through + exactly the same pipeline as typing does, RemoveTagAsync and RemoveTagAtAsync take one + off, MoveTagAsync moves one - raising OnReorder, and without needing AllowReorder, + which offers the gesture to the user rather than permitting the list to be reordered - EditTagAsync + opens the inline edit of one, SetInputTextAsync writes the text of the input (which is what fills + the field from a suggestion list of your own driven by OnInput), Clear empties the list and + FocusAsync puts the caret back. All of them do nothing while the field is disabled or read-only, + and all go through the callbacks, the announcements and the validation the equivalent gesture does.

@@ -603,208 +634,37 @@
- -
- Enter is the key that confirms a tag, so it is held back from submitting the form around the field - - which would otherwise post the form on the very keystroke meant to add a tag. - CancelConfirmKeysOnEmpty hands it back once there is nothing left to confirm, so a field - whose tags are already in place submits with Enter like any other input. Setting Name posts - the whole list as a single field of a plain (non-EditForm) HTML form, the tags joined by the first - separator, rather than posting whatever text happened to be left in the input. -
-
-
- - - -
-
Form submitted: @cancelFormSubmitted
-
-
-
- - -
- Inside an EditForm the field takes part in the validation like any other input: it reports - its changes to the EditContext, paints the error state, marks its input with - aria-invalid and turns its description red. The value is the list itself, so the - annotations that apply to it are the collection ones - [Required] catching the null of an - empty field and [MinLength]/[MaxLength] counting its entries. -
-
-
- - - - -
- Submit -
-
-
- - -
- A field that collects dozens of tags grows into a wall of chips that pushes everything below it - off the screen. MaxDisplayedTags draws only the first few of them and folds the rest away - behind a chip that says how many are left, which unfolds the list and folds it back again - so a - tag that is not drawn is never a tag that cannot be reached, neither with the pointer nor with the - keyboard, where the chip is a tab stop of its own. Only how much of the list is drawn changes: - the value keeps every one of its tags, the counter keeps counting all of them, and the form keeps - posting them. MoreTagsFormat and LessTagsText are what the two labels of the chip - are written and localized with, and MoreTagsAriaLabelFormat what it is announced as - - since "+3" read out on its own says nothing about what pressing it would do. -
-
-
- - -
-
- - -
- Nothing about a chip appearing, disappearing or changing place reaches a screen reader on its - own - the list sits away from the caret the user is typing at - so the component says every one - of those out loud through a polite live region: AddedAnnouncementFormat, - AddedManyAnnouncementFormat, RemovedAnnouncementFormat, - ClearedAnnouncementFormat, EditedAnnouncementFormat, MovedAnnouncementFormat - and InvalidAnnouncementFormat. Most of them take {0} for the tag they are - about - the move also taking {1} for the position it landed on and {2} - for how many tags there are - and an empty string keeps that particular event silent. The two - that stand for a whole batch count instead of naming: a pasted list and a cleared field take - {0} as the number of tags, since reading fifty names out is not a confirmation but - a wall, and the tags themselves are in the list to be walked through anyway. -

- The names of the parts follow the same idea: TagsAriaLabel names the list the chips form, - TagAriaDescription is the sentence telling what the keyboard can do with the one just - reached, DismissAriaLabelFormat and DismissTitle name the dismiss button of each - tag, EditAriaLabelFormat the little input that replaces a tag while it is corrected, and - ClearButtonAriaLabel the button that empties the whole field. Every one of them is a - plain string, which is what makes the component translatable without a single line of CSS or - markup of your own. -
-
-
- - -
-
- - -
- Prefix and Suffix draw a short text inside the field, at its start and at its end, - which is not part of the value: the To: of a recipients field, the unit a list of - measurements is in. The prefix sits in front of the chips and the suffix after everything else, - the clear button included, so it stays at the very end however much of the line the tags have - taken. PrefixTemplate and SuffixTemplate take markup instead of a string, for an - icon or a badge. -

- Neither of them ever reaches the value, and neither is announced with the field, so the meaning - they carry has to be in the Label as well: a screen reader reading "Tags, edit" out of a - field whose prefix says To: would otherwise have no idea what the tags are. -
-
-
- - - - - - - - - - -
-
- - +
- Classes and Styles dress every chip alike, and TagTemplate only changes what is - drawn inside one. GetTagClass and GetTagStyle are what tell one chip apart from - the next: each of them receives a tag and returns the classes or the declarations that this - particular chip should carry on top of the shared ones - the address that is not in the address - book drawn in red, the value that came from a saved filter drawn in grey, the tag that is over - budget outlined. They are called for every drawn tag on every render, so they belong to a lookup - rather than to a computation, and an exception thrown out of one of them leaves that chip looking - like all the others instead of breaking the field. + BitParams carries a BitTagsInputParams down to every tags input under it, so a form, a + panel or a whole page sets the shared look, the shared separators and the shared rules once instead of + on every field. What it carries is a default and not an override: a field that writes a parameter for + itself keeps its own value, and only what it left unset is filled in from the cascade. What travels down + is the configuration and never the value - Value, DefaultValue, Name, + Required and ReadOnly belong to the one field that holds them, as do the templates and the + event callbacks.

- - + + + + + +
+
- +
- Color picks the color role the component carries. The tags are painted with it, as much of - them as the TagVariant asks for, and so are the border and the focus ring of the focused - field, so the color is visible at rest rather than only while the field is focused. The error - state of a failing validation always wins over it on the field, which keeps an invalid field - recognizable whatever its color is. + Color picks the role the component carries: the tags are painted with it, as much of them as the + TagVariant asks for, and so are the border and the focus ring of the focused field. The error + state of a failing validation always wins over it, which keeps an invalid field recognizable whatever + its color is.

@@ -830,14 +690,13 @@
- +
- The dismiss button of each tag and the clear button of the field both take their icon either from - the built-in Fluent UI set, through DismissIconName and ClearButtonIconName, or from - any external icon library through DismissIcon and ClearButtonIcon, which take a - BitIconInfo built by BitIconInfo.Fa (FontAwesome), BitIconInfo.Bi (Bootstrap - Icons) or BitIconInfo.Css (any CSS classes at all). The BitIconInfo wins over the - name when both are given. + The dismiss button of each tag and the clear button of the field take their icon either from the + built-in Fluent UI set, through DismissIconName and ClearButtonIconName, or from any + external library through DismissIcon and ClearButtonIcon, which take a BitIconInfo + built by BitIconInfo.Fa (FontAwesome), BitIconInfo.Bi (Bootstrap Icons) or + BitIconInfo.Css (any CSS classes at all). The BitIconInfo wins when both are given.

@@ -861,11 +720,11 @@
- +
- Size scales the field, the chips and the text inside them together, so the whole thing stays - proportionate at every size. Pick the one that matches the density of the surrounding form: the - large size suits a standalone editor, the small one a filter tucked into a toolbar. + Size scales the field, the chips and the text inside them together. An empty field takes the + control height of its size class, so it lines up with the text fields and pickers beside it in a form, + and grows from there with every line of chips that wraps into it.

@@ -875,15 +734,14 @@
- +
- Style and Class reach the root element only, while Styles and Classes - reach every part of the component by name: the root, the label, the description, the counter, the - input container, the prefix and the suffix, the wrapper of the tags, each tag and its text, the - dismiss button and its icon, the chip that folds the tags away, the input, the little input that - replaces a tag while it is corrected, and the clear button and its icon - plus the two state slots - Focused (the field while its input has the focus) and FocusedTag (the chip the arrow - keys are currently on). + Style and Class reach the root element only, while Styles and Classes reach + every part by name - the label, the description, the counter, the input container, the affixes, the + wrapper of the tags, each tag and its text, the dismiss button and its icon, the fold chip, the input, + the little edit input, the loading spinner and the clear button - plus the three state slots + Focused, FocusedTag and FixedTag. Prefer Classes when your CSS should own + states such as hover, which inline styles cannot express.

@@ -916,13 +774,47 @@ Input = "custom-input", ClearButton = "custom-clear" })" />
+

+
+ The component also reads a set of CSS variables off its + elements for what no parameter covers. They inherit, so a value on :root or any ancestor + re-skins every tags input below it, and one on the Style of an instance re-skins that one alone. +
+
+
CSS variables:
+
+
+ + + +
+
+
Set once on an ancestor, inherited by every field inside it:
+
+
+ + +
- +
- In a right-to-left direction the chips are laid out from right to left and the arrow keys swap - along with them, so the key pointing at the visual left still walks towards the tag drawn there. - The value itself keeps its logical order: the first chip is always the first tag of the list. + In a right-to-left direction the chips are laid out from right to left and the arrow keys swap along + with them, so the key pointing at the visual left still walks towards the tag drawn there. The value + keeps its logical order: the first chip is always the first tag of the list.

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor.cs index 09d80870e7b..e1a0dc3a0f3 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Inputs.TagsInput; @@ -28,6 +28,13 @@ public partial class BitTagsInputDemo Description = "The format of the message announced by screen readers when several tags are added at once (a pasted list, most of the time), where {0} is how many of them there were. The default is \"{0} tags added.\". An empty string keeps the addition from being announced. A single tag is always announced with AddedAnnouncementFormat instead.", }, new() + { + Name = "AutoComplete", + Type = "string?", + DefaultValue = "off", + Description = "Sets the autocomplete html attribute of the input element. It is off by default, since the browser's own autofill would otherwise be offered over the suggestion list of the field - and what it saved for a single line text box is the last tag that was typed rather than the list the field holds.", + }, + new() { Name = "AutoFocus", Type = "bool", @@ -49,6 +56,13 @@ public partial class BitTagsInputDemo Description = "When true, pressing Enter while the input is empty does not suppress the event, allowing it to propagate (e.g., to submit a parent form).", }, new() + { + Name = "CanRemoveTag", + Type = "Func?", + DefaultValue = "null", + Description = "A predicate deciding which tags the user is allowed to take off the list. A tag it turns down is drawn without a dismiss button, ignores the Delete and Backspace keys, is left alone by the Backspace pressed on the empty input, and stays behind when the field is cleared. It is still editable and still movable. RemoveTagAsync and RemoveTagAtAsync name a tag outright and are not held back by it.", + }, + new() { Name = "Classes", Type = "BitTagsInputClassStyles?", @@ -190,6 +204,15 @@ public partial class BitTagsInputDemo Description = "The format of the message announced by screen readers when a tag is edited, where {0} is the tag as it now reads. The default is \"{0} updated.\". An empty string keeps the edit from being announced.", }, new() + { + Name = "EnterKeyHint", + Type = "BitEnterKeyHint?", + DefaultValue = "null", + Description = "Sets the enterkeyhint html attribute of the input element, which decides the label a virtual keyboard draws on its return key. The key confirms a tag here, so Done and Next are the ones that describe it on a phone.", + LinkType = LinkType.Link, + Href = "#enter-key-hint-enum", + }, + new() { Name = "GetTagClass", Type = "Func?", @@ -204,6 +227,22 @@ public partial class BitTagsInputDemo Description = "A function returning extra inline CSS styles for a single tag, the counterpart of GetTagClass. It is appended after the Tag and FocusedTag of the Styles, so it wins over both.", }, new() + { + Name = "InputMode", + Type = "BitInputMode?", + DefaultValue = "null", + Description = "Sets the inputmode html attribute of the input element, which decides the virtual keyboard a phone opens over the field. It changes nothing about what the field accepts - that is what Pattern and Validator are for - only about which keys the user is given to type it with.", + LinkType = LinkType.Link, + Href = "#input-mode-enum", + }, + new() + { + Name = "IsLoading", + Type = "bool", + DefaultValue = "false", + Description = "Draws a spinner at the end of the field, for the wait the field itself is the cause of: the suggestions being fetched for what is being typed, the tag being checked against a server. It is an indeterminate progressbar rather than a decoration, and it changes nothing about what the field accepts.", + }, + new() { Name = "InvalidAnnouncementFormat", Type = "string?", @@ -232,6 +271,13 @@ public partial class BitTagsInputDemo Description = "The label of the chip that folds the tags back once MaxDisplayedTags unfolded them. The default is \"Show less\".", }, new() + { + Name = "LoadingAriaLabel", + Type = "string?", + DefaultValue = "null", + Description = "The accessible name of the spinner IsLoading draws, which is the whole of what a screen reader has to go on. The default is \"Loading\".", + }, + new() { Name = "MaxDisplayedTags", Type = "int", @@ -491,6 +537,13 @@ public partial class BitTagsInputDemo Href = "#size-enum", }, new() + { + Name = "SpellCheck", + Type = "bool?", + DefaultValue = "false", + Description = "Sets the spellcheck html attribute of the input element. It is off by default, a tag being a value rather than a sentence - an identifier or a hashtag underlined in red says only that the dictionary has not heard of it.", + }, + new() { Name = "Suggestions", Type = "IEnumerable?", @@ -582,6 +635,262 @@ public partial class BitTagsInputDemo }, ]; + private readonly List componentCssVariables = + [ + new() + { + Name = "--bit-TagsInput-font-size", + DefaultValue = "Per Size, from the type ramp", + Description = "Text size of the field. The helper text and the counter are derived from it, so one value resizes the whole component.", + }, + new() + { + Name = "--bit-TagsInput-color", + DefaultValue = "--bit-clr-fg-pri", + Description = "Color of the text being typed into the input.", + }, + new() + { + Name = "--bit-TagsInput-placeholder-color", + DefaultValue = "--bit-clr-fg-ter", + Description = "Color of the Placeholder and the TagsPlaceholder.", + }, + new() + { + Name = "--bit-TagsInput-label-color", + DefaultValue = "--bit-clr-fg-pri", + Description = "Color of the label above the field.", + }, + new() + { + Name = "--bit-TagsInput-required-color", + DefaultValue = "--bit-clr-req", + Description = "Color of the asterisk a Required label carries.", + }, + new() + { + Name = "--bit-TagsInput-description-color", + DefaultValue = "--bit-clr-fg-sec", + Description = "Color of the helper text under the field. The error state overrides it with the invalid color.", + }, + new() + { + Name = "--bit-TagsInput-counter-color", + DefaultValue = "--bit-clr-fg-sec", + Description = "Color of the tag counter ShowCounter draws.", + }, + new() + { + Name = "--bit-TagsInput-affix-color", + DefaultValue = "--bit-clr-fg-sec", + Description = "Color of the Prefix and the Suffix.", + }, + new() + { + Name = "--bit-TagsInput-background", + DefaultValue = "Per Variant", + Description = "Fill of the field: the page surface in Outline, the secondary surface in Fill, transparent in Text.", + }, + new() + { + Name = "--bit-TagsInput-border-color", + DefaultValue = "Per Variant", + Description = "Rule around the field at rest.", + }, + new() + { + Name = "--bit-TagsInput-hover-border-color", + DefaultValue = "Per Variant", + Description = "Rule around the hovered field (pointer devices only).", + }, + new() + { + Name = "--bit-TagsInput-border-width", + DefaultValue = "--bit-shp-brd-width", + Description = "Thickness of that rule, and of the underline the Text variant keeps.", + }, + new() + { + Name = "--bit-TagsInput-radius", + DefaultValue = "--bit-shp-radius-control", + Description = "Corner of the field and of its focus ring. The Text variant squares it off.", + }, + new() + { + Name = "--bit-TagsInput-min-height", + DefaultValue = "Per Size, --bit-siz-ctrl-*", + Description = "Smallest height of the field, which is what lines an empty tags input up with the text fields and pickers beside it. It is a floor: the field still grows with every line of chips that wraps into it.", + }, + new() + { + Name = "--bit-TagsInput-padding", + DefaultValue = "Per Size", + Description = "Inset between the field and the chips, the input and the affixes inside it.", + }, + new() + { + Name = "--bit-TagsInput-gap", + DefaultValue = "Per Size", + Description = "Room between the chips, the input and the affixes, on both axes.", + }, + new() + { + Name = "--bit-TagsInput-focus-color", + DefaultValue = "The Color role's focus color", + Description = "Color of the focus ring the field wears while anything inside it holds the focus.", + }, + new() + { + Name = "--bit-TagsInput-focus-border-color", + DefaultValue = "The Color role's main color", + Description = "Rule around the focused field, and the focus ring of the clear button.", + }, + new() + { + Name = "--bit-TagsInput-invalid-color", + DefaultValue = "--bit-clr-err", + Description = "Rule and helper text of a field failing validation, and its focus ring.", + }, + new() + { + Name = "--bit-TagsInput-disabled-color", + DefaultValue = "--bit-clr-fg-dis", + Description = "Text of a disabled field, of its chips, its label, its helper text and its affixes.", + }, + new() + { + Name = "--bit-TagsInput-disabled-background", + DefaultValue = "--bit-clr-bg-dis", + Description = "Fill of a disabled field and of the chips in it.", + }, + new() + { + Name = "--bit-TagsInput-disabled-border-color", + DefaultValue = "--bit-clr-brd-dis", + Description = "Rule of a disabled field and of the chips in it.", + }, + new() + { + Name = "--bit-TagsInput-tag-color", + DefaultValue = "Per TagVariant, from the Color role", + Description = "Text of a chip, and of the chip that folds the tags away.", + }, + new() + { + Name = "--bit-TagsInput-tag-background", + DefaultValue = "Per TagVariant, from the Color role", + Description = "Fill of a chip. Setting it is how a field paints its chips apart from its accent.", + }, + new() + { + Name = "--bit-TagsInput-tag-border-color", + DefaultValue = "Per TagVariant, from the Color role", + Description = "Rule of a chip, drawn in every tag variant and transparent where it is not painted, so switching variants never moves the text in a chip.", + }, + new() + { + Name = "--bit-TagsInput-tag-border-width", + DefaultValue = "--bit-shp-brd-width", + Description = "Thickness of that rule.", + }, + new() + { + Name = "--bit-TagsInput-tag-radius", + DefaultValue = "--bit-shp-radius-chip", + Description = "Corner of a chip. A pill takes 999px, a square 0.", + }, + new() + { + Name = "--bit-TagsInput-tag-padding", + DefaultValue = "Per Size", + Description = "Inset of a chip. The block half is 0 on purpose: the height is set by the minimum height below, so the text stays centered whatever the chip holds.", + }, + new() + { + Name = "--bit-TagsInput-tag-gap", + DefaultValue = "0.1875rem", + Description = "Room between a chip's text and its dismiss button.", + }, + new() + { + Name = "--bit-TagsInput-tag-font-size", + DefaultValue = "Per Size, from the type ramp", + Description = "Text size of a chip, which is a step under the field's own at the Medium and Large sizes.", + }, + new() + { + Name = "--bit-TagsInput-tag-min-height", + DefaultValue = "Per Size", + Description = "Smallest height of a chip, of the input and of the affixes, so a chip holding an icon or a template is exactly as tall as the one beside it.", + }, + new() + { + Name = "--bit-TagsInput-tag-max-width", + DefaultValue = "100%", + Description = "Widest a chip grows before its text is cut off with an ellipsis. Set it to keep one long value from taking a whole line of the field.", + }, + new() + { + Name = "--bit-TagsInput-tag-focus-color", + DefaultValue = "Per TagVariant", + Description = "The inset ring of the focused chip, of the focused dismiss button and of the chip a dragged one would land on.", + }, + new() + { + Name = "--bit-TagsInput-tag-dragging-opacity", + DefaultValue = "0.4", + Description = "Alpha of the chip being dragged, which is what reads as a gap waiting to be filled rather than as a chip in two places at once.", + }, + new() + { + Name = "--bit-TagsInput-dismiss-icon-size", + DefaultValue = "1em", + Description = "Glyph of a chip's dismiss button, relative to the chip's own text by default so it scales with the chip rather than with the field.", + }, + new() + { + Name = "--bit-TagsInput-icon-size", + DefaultValue = "Per Size, --bit-siz-icon-*", + Description = "Glyph of the clear button, and the diameter of the spinner, at the end of the field.", + }, + new() + { + Name = "--bit-TagsInput-spinner-color", + DefaultValue = "The Color role's main color", + Description = "The turning arc of the spinner IsLoading draws.", + }, + new() + { + Name = "--bit-TagsInput-spinner-track-color", + DefaultValue = "--bit-clr-brd-sec", + Description = "The track that arc turns in.", + }, + new() + { + Name = "--bit-TagsInput-clear-color", + DefaultValue = "--bit-clr-fg-sec", + Description = "The clear button at rest.", + }, + new() + { + Name = "--bit-TagsInput-clear-hover-color", + DefaultValue = "--bit-clr-fg-pri", + Description = "The clear button under the pointer.", + }, + new() + { + Name = "--bit-TagsInput-toggle-hover-color", + DefaultValue = "Per TagVariant", + Description = "Text of the hovered chip that folds and unfolds the tags MaxDisplayedTags put away.", + }, + new() + { + Name = "--bit-TagsInput-toggle-hover-background", + DefaultValue = "Per TagVariant", + Description = "Fill and rule of that same chip while it is hovered.", + }, + ]; + private readonly List componentSubClasses = [ new() @@ -786,6 +1095,13 @@ public partial class BitTagsInputDemo Description = "Custom CSS classes/styles for the tag element that currently has the keyboard focus.", }, new() + { + Name = "FixedTag", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS classes/styles for a tag the CanRemoveTag predicate holds in place, which carries no dismiss button of its own.", + }, + new() { Name = "TagText", Type = "string?", @@ -835,6 +1151,13 @@ public partial class BitTagsInputDemo Description = "Custom CSS classes/styles for the counter of the tags input.", }, new() + { + Name = "Spinner", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS classes/styles for the spinner IsLoading draws at the end of the field.", + }, + new() { Name = "ClearButton", Type = "string?", @@ -942,6 +1265,39 @@ public partial class BitTagsInputDemo ] }, new() + { + Id = "input-mode-enum", + Name = "BitInputMode", + Description = "Defines the inputmode html attribute, which is what lets a browser display an appropriate virtual keyboard.", + Items = + [ + new() { Name = "None", Description = "No virtual keyboard. For when the page implements its own keyboard input control.", Value = "0" }, + new() { Name = "Text", Description = "Standard input keyboard for the user's current locale.", Value = "1" }, + new() { Name = "Decimal", Description = "Fractional numeric input keyboard containing the digits and decimal separator for the user's locale.", Value = "2" }, + new() { Name = "Numeric", Description = "Numeric input keyboard, but only requires the digits 0–9.", Value = "3" }, + new() { Name = "Tel", Description = "A telephone keypad input, including the digits 0–9, the asterisk (*), and the pound (#) key.", Value = "4" }, + new() { Name = "Search", Description = "A virtual keyboard optimized for search input.", Value = "5" }, + new() { Name = "Email", Description = "A virtual keyboard optimized for entering email addresses.", Value = "6" }, + new() { Name = "Url", Description = "A keypad optimized for entering URLs.", Value = "7" }, + ] + }, + new() + { + Id = "enter-key-hint-enum", + Name = "BitEnterKeyHint", + Description = "Tells the browser which action label (or icon) to present for the enter key of a virtual keyboard.", + Items = + [ + new() { Name = "Enter", Description = "Typically inserting a new line.", Value = "0" }, + new() { Name = "Done", Description = "Typically meaning there is nothing more to input and the input method editor will be closed.", Value = "1" }, + new() { Name = "Go", Description = "Typically meaning to take the user to the target of the text they typed.", Value = "2" }, + new() { Name = "Next", Description = "Typically taking the user to the next field that will accept text.", Value = "3" }, + new() { Name = "Previous", Description = "Typically taking the user to the previous field that will accept text.", Value = "4" }, + new() { Name = "Search", Description = "Typically taking the user to the results of searching for the text they have typed.", Value = "5" }, + new() { Name = "Send", Description = "Typically delivering the text to its target.", Value = "6" }, + ] + }, + new() { Id = "variant-enum", Name = "BitVariant", @@ -1101,6 +1457,12 @@ public partial class BitTagsInputDemo "Spain", "Sweden", "Switzerland", "Turkey", "Ukraine"]; private string? suggestionMessage; + private bool asyncLoading; + private int asyncRequestId; + private string[] asyncSuggestions = []; + + private ICollection? fixedTags = ["ada@example.com", "grace@example.com"]; + private const string emailPattern = @"^[^@\s]+@[^@\s]+\.[^@\s]+$"; private string? patternMessage; private string? validatorMessage; @@ -1119,11 +1481,24 @@ public partial class BitTagsInputDemo private BitTagsInput apiTagsInput = default!; - private bool cancelFormSubmitted; - private readonly ValidationTagsInputModel cancelModel = new(); - + private bool formSubmitted; private readonly ValidationTagsInputModel validationModel = new(); + private readonly List tagsInputParams = + [ + new BitTagsInputParams + { + Size = BitSize.Small, + Variant = BitVariant.Fill, + TagVariant = BitVariant.Outline, + Color = BitColor.Info, + Separators = [","], + ShowClearButton = true, + Placeholder = "Add tag...", + Transformer = t => t.ToLowerInvariant() + } + ]; + private void HandleMaxTagsInvalid(BitTagsInputInvalidArgs args) @@ -1145,6 +1520,31 @@ private void HandleSuggestionInvalid(BitTagsInputInvalidArgs args) : $"'{args.Tag}' was refused ({args.Reason})."; } + private async Task HandleAsyncInput(string text) + { + // Only the answer to the last keystroke is kept: an earlier fetch coming back late would + // otherwise replace a newer list and turn the spinner off over a wait that is still running. + var id = ++asyncRequestId; + + if (string.IsNullOrEmpty(text)) + { + asyncLoading = false; + asyncSuggestions = []; + return; + } + + asyncLoading = true; + StateHasChanged(); + + await Task.Delay(500); + + if (id != asyncRequestId) return; + + asyncSuggestions = [.. countrySuggestions.Where(c => c.Contains(text, StringComparison.OrdinalIgnoreCase))]; + asyncLoading = false; + StateHasChanged(); + } + private static bool ValidateFramework(string tag) { return tag is "blazor" or "react" or "vue" or "angular"; @@ -1253,5 +1653,5 @@ private void HandleInvalid(BitTagsInputInvalidArgs args) private async Task ApiFocus() => await apiTagsInput.FocusAsync(); - private void HandleValidSubmit() { } + private void HandleValidSubmit() => formSubmitted = true; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor.samples.cs index 6f0b5a59c67..75883a99a7c 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/TagsInput/BitTagsInputDemo.razor.samples.cs @@ -3,32 +3,30 @@ public partial class BitTagsInputDemo { private readonly string example1RazorCode = @" - + + + { ""blazor"" })"" /> { ""Tag 1"", ""Tag 2"" })"" /> { ""Tag 1"", ""Tag 2"" })"" />"; private readonly string example2RazorCode = @" - + - + - + Custom label -"; - - private readonly string example3RazorCode = @" - - - @@ -37,30 +35,23 @@ public partial class BitTagsInputDemo "; - private readonly string example4RazorCode = @" - - - { ""blazor"" })"" />"; - - private readonly string example5RazorCode = @" - { ""blazor"", ""dotnet"" })"" /> + private readonly string example3RazorCode = @" + { ""blazor"", ""dotnet"" })"" /> - { ""blazor"", ""dotnet"" })"" /> + { ""blazor"", ""dotnet"" })"" /> - { ""blazor"", ""dotnet"" })"" /> + { ""blazor"", ""dotnet"" })"" /> { ""blazor"", ""dotnet"" })"" />"; - private readonly string example6RazorCode = @" - { ""blazor"", ""dotnet"" })"" /> - { ""blazor"", ""dotnet"" })"" /> - { ""blazor"", ""dotnet"" })"" />"; - - private readonly string example7RazorCode = @" + private readonly string example4RazorCode = @" "; - private readonly string example8RazorCode = @" + private readonly string example5RazorCode = @" @if (suggestionMessage.HasValue()) { @@ -91,8 +83,15 @@ public partial class BitTagsInputDemo Suggestions=""countrySuggestions"" MaxSuggestions=""5"" Placeholder=""Start typing a country..."" - Description=""Only the five best matches are ever written into the page."" />"; - private readonly string example8CsharpCode = @" + Description=""Only the five best matches are ever written into the page."" /> + +"; + private readonly string example5CsharpCode = @" private readonly string[] frameworkSuggestions = [""blazor"", ""react"", ""vue"", ""angular"", ""svelte""]; private readonly string[] countrySuggestions = [""Argentina"", ""Australia"", ""Austria"", ""Belgium"", ""Brazil"", ""Canada"", ""Chile"", ""China"", ""Denmark"", ""Egypt"", ""Finland"", @@ -107,22 +106,57 @@ private void HandleSuggestionInvalid(BitTagsInputInvalidArgs args) suggestionMessage = args.Reason == BitTagsInputInvalidReason.NotSuggested ? $""'{args.Tag}' is not one of the suggested values."" : $""'{args.Tag}' was refused ({args.Reason}).""; +} + +private bool asyncLoading; +private int asyncRequestId; +private string[] asyncSuggestions = []; + +private async Task HandleAsyncInput(string text) +{ + // Only the answer to the last keystroke is kept: an earlier fetch coming back late + // would otherwise replace a newer list and turn the spinner off over a newer wait. + var id = ++asyncRequestId; + + if (string.IsNullOrEmpty(text)) + { + asyncLoading = false; + asyncSuggestions = []; + return; + } + + asyncLoading = true; + StateHasChanged(); + + await Task.Delay(500); + + if (id != asyncRequestId) return; + + asyncSuggestions = [.. countrySuggestions.Where(c => c.Contains(text, StringComparison.OrdinalIgnoreCase))]; + asyncLoading = false; + StateHasChanged(); }"; - private readonly string example9RazorCode = @" + private readonly string example6RazorCode = @" - -
Tags: @(maxTagsValue is null ? 0 : maxTagsValue.Count) / 3
@if (maxTagsMessage.HasValue()) {
@maxTagsMessage
-}"; - private readonly string example9CsharpCode = @" +} + +"; + private readonly string example6CsharpCode = @" private ICollection? maxTagsValue = [""blazor""]; private string? maxTagsMessage; @@ -133,18 +167,18 @@ private void HandleMaxTagsInvalid(BitTagsInputInvalidArgs args) : $""'{args.Tag}' was refused ({args.Reason}).""; }"; - private readonly string example10RazorCode = @" - - -"; + private readonly string example7RazorCode = @" + +@if (duplicateMessage.HasValue()) +{ +
@duplicateMessage
+} - private readonly string example11RazorCode = @" @patternMessage
} - @if (validatorMessage.HasValue()) {
@validatorMessage
}"; - private readonly string example11CsharpCode = @" + private readonly string example7CsharpCode = @" private const string emailPattern = @""^[^@\s]+@[^@\s]+\.[^@\s]+$""; private string? patternMessage; private string? validatorMessage; +private string? duplicateMessage; + +private static string NormalizeHashtag(string tag) +{ + return string.Concat(tag.TrimStart('#').Where(c => char.IsWhiteSpace(c) is false)).ToLowerInvariant(); +} + +private void HandleTagExists(string tag) +{ + duplicateMessage = $""'{tag}' is already in the list.""; +} private void HandlePatternInvalid(BitTagsInputInvalidArgs args) { @@ -185,45 +231,7 @@ private void HandleValidatorInvalid(BitTagsInputInvalidArgs args) validatorMessage = $""'{args.Tag}' is not one of the known frameworks.""; }"; - private readonly string example12RazorCode = @" -"; - private readonly string example12CsharpCode = @" -private static string NormalizeHashtag(string tag) -{ - return string.Concat(tag.TrimStart('#').Where(c => char.IsWhiteSpace(c) is false)).ToLowerInvariant(); -}"; - - private readonly string example13RazorCode = @" - { ""blazor"" })"" - OnTagExists=""HandleTagExists"" /> -@if (duplicateMessage.HasValue()) -{ -
@duplicateMessage
-} - - { ""blazor"" })"" /> - -"; - private readonly string example13CsharpCode = @" -private string? duplicateMessage; - -private void HandleTagExists(string tag) -{ - duplicateMessage = $""'{tag}' is already in the list.""; -}"; - - private readonly string example14RazorCode = @" + private readonly string example8RazorCode = @" @editMessage }"; - private readonly string example14CsharpCode = @" + private readonly string example8CsharpCode = @" private string? editMessage; private void HandleEdit(BitTagsInputEditArgs args) { editMessage = $""'{args.Tag}' became '{args.NewTag}'.""; + + // args.Cancel = true; would leave the tag as it was, + // and args.NewTag can be corrected on its way in. }"; - private readonly string example15RazorCode = @" + private readonly string example9RazorCode = @" + { ""first"", ""second"", ""third"", ""fourth"" })"" + OnReorder=""HandleReorder"" /> +@if (reorderMessage.HasValue()) +{ +
@reorderMessage
+}"; + private readonly string example9CsharpCode = @" +private string? reorderMessage; + +private void HandleReorder(BitTagsInputReorderArgs args) +{ + reorderMessage = $""'{args.Tag}' moved from position {args.OldIndex + 1} to {args.NewIndex + 1}.""; +}"; + + private readonly string example10RazorCode = @" + { ""blazor"", ""dotnet"", ""web"", ""ui"", ""wasm"", ""razor"" })"" /> + + { ""blazor"", ""dotnet"", ""web"", ""ui"" })"" />"; + + private readonly string example11RazorCode = @" @beforeClearMessage }"; - private readonly string example15CsharpCode = @" + private readonly string example11CsharpCode = @" private string? clearedMessage; private string? beforeClearMessage; @@ -285,20 +330,20 @@ private void HandleBeforeClear(BitTagsInputClearArgs args) } }"; - private readonly string example16RazorCode = @" - { ""blazor"", ""dotnet"" })"" /> + private readonly string example12RazorCode = @" + t != ""ada@example.com"")"" + @bind-Value=""fixedTags"" /> - { ""blazor"", ""dotnet"" })"" />"; +
Tags: @(fixedTags is not null ? string.Join("", "", fixedTags) : ""null"")
"; + private readonly string example12CsharpCode = @" +private ICollection? fixedTags = [""ada@example.com"", ""grace@example.com""];"; - private readonly string example17RazorCode = @" + private readonly string example13RazorCode = @" { ""one"", ""two"", ""three"" })"" /> @@ -316,37 +361,106 @@ private void HandleBeforeClear(BitTagsInputClearArgs args) NoAddOnBlur Placeholder=""Add tag..."" Description=""Backspace on the empty input takes the last tag back for correction; leaving the field throws away what was still being typed."" - DefaultValue=""@(new List { ""one"", ""two"" })"" />"; + DefaultValue=""@(new List { ""one"", ""two"" })"" /> - private readonly string example18RazorCode = @" -"; + + private readonly string example14RazorCode = @" + { ""first"", ""second"", ""third"", ""fourth"" })"" - OnReorder=""HandleReorder"" /> + AddedAnnouncementFormat=""{0} was added to the list."" + AddedManyAnnouncementFormat=""{0} tags were added to the list."" + RemovedAnnouncementFormat=""{0} was taken off the list."" + ClearedAnnouncementFormat=""The list of {0} tags was emptied."" + EditedAnnouncementFormat=""{0} is the new text of the tag."" + MovedAnnouncementFormat=""{0} is now number {1} out of {2}."" + InvalidAnnouncementFormat=""{0} was refused."" + MinLength=""3"" + DefaultValue=""@(new List { ""blazor"", ""dotnet"" })"" /> -@if (reorderMessage.HasValue()) -{ -
@reorderMessage
-}"; - private readonly string example18CsharpCode = @" -private string? reorderMessage; + { ""blazor"", ""dotnet"" })"" />"; -private void HandleReorder(BitTagsInputReorderArgs args) -{ - reorderMessage = $""'{args.Tag}' moved from position {args.OldIndex + 1} to {args.NewIndex + 1}.""; -}"; + private readonly string example15RazorCode = @" + { ""ada@example.com"" })"" /> - private readonly string example19RazorCode = @" - { ""blazor"", ""dotnet"" })""> + { ""12"", ""34"" })"" /> + + { ""blazor"", ""dotnet"" })""> + + + + + + +"; + + private readonly string example16RazorCode = @" + { ""blazor"", ""dotnet"" })""> @tag -"; + - private readonly string example20RazorCode = @" + { ""ada@example.com"", ""not-an-address"" })"" /> + + { ""low"", ""medium"", ""high"" })"" />"; + private readonly string example16CsharpCode = @" +private const string emailPattern = @""^[^@\s]+@[^@\s]+\.[^@\s]+$""; + +private static string? GetRecipientStyle(string tag) +{ + return Regex.IsMatch(tag, emailPattern) ? null : ""background: #fde7e9; color: #a4262c; border-color: #a4262c;""; +} + +private static string? GetPriorityClass(string tag) => tag.ToLowerInvariant() switch +{ + ""high"" => ""priority-high"", + ""medium"" => ""priority-medium"", + ""low"" => ""priority-low"", + _ => null +};"; + + private readonly string example17RazorCode = @"
Tags: @(boundTags is not null ? string.Join("", "", boundTags) : ""null"")
@@ -354,12 +468,39 @@ private void HandleReorder(BitTagsInputReorderArgs args) Placeholder=""Add tag..."" DefaultValue=""@(new List { ""blazor"" })"" OnChange=""v => changedTags = v"" /> -
Tags: @(changedTags is not null ? string.Join("", "", changedTags) : ""null"")
"; - private readonly string example20CsharpCode = @" +
Tags: @(changedTags is not null ? string.Join("", "", changedTags) : ""null"")
+ + + + + validationModel.Tags"" /> + + Submit + +
Form submitted: @formSubmitted
+
"; + private readonly string example17CsharpCode = @" +public class ValidationTagsInputModel +{ + [Required(ErrorMessage = ""At least one tag is required."")] + public ICollection? Tags { get; set; } +} + private ICollection? boundTags; -private ICollection? changedTags;"; +private ICollection? changedTags; - private readonly string example21RazorCode = @" +private bool formSubmitted; +private readonly ValidationTagsInputModel validationModel = new(); + +private void HandleValidSubmit() => formSubmitted = true;"; + + private readonly string example18RazorCode = @" Typing: @typedText
Last event: @eventsLog
"; - private readonly string example21CsharpCode = @" + private readonly string example18CsharpCode = @" private string? typedText; private string? eventsLog; @@ -404,7 +545,7 @@ private void HandleInvalid(BitTagsInputInvalidArgs args) eventsLog = $""Rejected '{args.Tag}' ({args.Reason})""; }"; - private readonly string example22RazorCode = @" + private readonly string example19RazorCode = @" Clear Focus "; - private readonly string example22CsharpCode = @" + private readonly string example19CsharpCode = @" private BitTagsInput apiTagsInput = default!; private Task ApiAddTag() => apiTagsInput.AddTagAsync(""dotnet""); @@ -444,144 +585,36 @@ private void HandleInvalid(BitTagsInputInvalidArgs args) private async Task ApiFocus() => await apiTagsInput.FocusAsync();"; - private readonly string example23RazorCode = @" - cancelFormSubmitted = true""> - - -
-
Form submitted: @cancelFormSubmitted
-
"; - private readonly string example23CsharpCode = @" -private bool cancelFormSubmitted; -private readonly ValidationTagsInputModel cancelModel = new();"; - - private readonly string example24RazorCode = @" - - - - validationModel.Tags"" /> -
- Submit -
"; - private readonly string example24CsharpCode = @" -private readonly ValidationTagsInputModel validationModel = new(); - -private void HandleValidSubmit() { } - -public class ValidationTagsInputModel -{ - [Required(ErrorMessage = ""At least one tag is required."")] - [MinLength(1, ErrorMessage = ""At least one tag is required."")] - public ICollection? Tags { get; set; } -}"; - - private readonly string example25RazorCode = @" - { ""blazor"", ""dotnet"", ""web"", ""ui"", ""wasm"", ""razor"" })"" /> - - { ""blazor"", ""dotnet"", ""web"", ""ui"" })"" />"; - - private readonly string example26RazorCode = @" - { ""blazor"", ""dotnet"" })"" /> - - { ""blazor"", ""dotnet"" })"" />"; - - private readonly string example27RazorCode = @" - { ""ada@example.com"" })"" /> - - { ""12"", ""34"" })"" /> - - { ""blazor"", ""dotnet"" })""> - - - - - - -"; - - private readonly string example28RazorCode = @" - { ""ada@example.com"", ""not-an-address"" })"" /> + private readonly string example20RazorCode = @" + + - { ""low"", ""medium"", ""high"" })"" />"; + { ""blazor"" })"" /> - private readonly string example28CsharpCode = @" -private static string? GetRecipientStyle(string tag) -{ - return Regex.IsMatch(tag, @""^[^@\s]+@[^@\s]+\.[^@\s]+$"") - ? null - : ""background: #fde7e9; color: #a4262c; border-color: #a4262c;""; -} + { ""dotnet"" })"" /> + -private static string? GetPriorityClass(string tag) => tag.ToLowerInvariant() switch -{ - ""high"" => ""priority-high"", - ""medium"" => ""priority-medium"", - ""low"" => ""priority-low"", - _ => null -};"; +"; + private readonly string example20CsharpCode = @" +private readonly List tagsInputParams = +[ + new BitTagsInputParams + { + Size = BitSize.Small, + Variant = BitVariant.Fill, + TagVariant = BitVariant.Outline, + Color = BitColor.Info, + Separators = ["",""], + ShowClearButton = true, + Placeholder = ""Add tag..."", + Transformer = t => t.ToLowerInvariant() + } +];"; - private readonly string example29RazorCode = @" + private readonly string example21RazorCode = @" { ""tag"" })"" /> { ""tag"" })"" /> { ""tag"" })"" /> @@ -602,7 +635,7 @@ public class ValidationTagsInputModel { ""tag"" })"" /> { ""tag"" })"" />"; - private readonly string example30RazorCode = @" + private readonly string example22RazorCode = @" { ""blazor"", ""dotnet"" })"" />"; - private readonly string example31RazorCode = @" + private readonly string example23RazorCode = @" { ""blazor"", ""dotnet"" })"" /> - { ""blazor"", ""dotnet"" })"" /> - { ""blazor"", ""dotnet"" })"" />"; - private readonly string example32RazorCode = @" - { ""blazor"" })"" /> + private readonly string example24RazorCode = @" + + + { ""blazor"" })"" /> { ""blazor"" })"" /> "; - - private readonly string example33RazorCode = @" -
- { ""بلیزر"", ""دات‌نت"" })"" /> - - { ""بلیزر"", ""دات‌نت"" })"" /> + ClearButton = ""custom-clear"" })"" /> + + +@* CSS variables *@ + + { ""blazor"", ""a-very-long-tag-that-does-not-fit"", ""dotnet"" })"" + Style=""--bit-TagsInput-tag-radius: 999px; --bit-TagsInput-tag-max-width: 33%;"" /> + + { ""design"", ""system"" })"" + Style=""--bit-TagsInput-radius: 1rem; + --bit-TagsInput-background: var(--bit-clr-bg-sec); + --bit-TagsInput-border-color: transparent; + --bit-TagsInput-tag-background: var(--bit-clr-bg-pri); + --bit-TagsInput-tag-color: var(--bit-clr-fg-pri); + --bit-TagsInput-tag-border-color: var(--bit-clr-brd-pri); + --bit-TagsInput-tag-focus-color: var(--bit-clr-pri);"" /> + + { ""blazor"" })"" + Style=""--bit-TagsInput-min-height: 4rem; --bit-TagsInput-padding: 0.75rem; --bit-TagsInput-gap: 0.75rem;"" /> + + +@* Set once on an ancestor, inherited by every field inside it *@ + +
+ { ""blazor"" })"" /> + { ""dotnet"" })"" />
"; + + private readonly string example25RazorCode = @" + { ""بلیزر"", ""دات‌نت"" })"" /> + + { ""بلیزر"", ""دات‌نت"" })"" />"; } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/TagsInput/BitTagsInputTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/TagsInput/BitTagsInputTests.cs index b0b52bc6841..c005fbf9f76 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/TagsInput/BitTagsInputTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/TagsInput/BitTagsInputTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -3941,4 +3941,460 @@ public void BitTagsInputValidationValidatesOnAddTest() } #endregion + + #region input attributes + + [TestMethod] + public void BitTagsInputDefaultInputAttributesTest() + { + var com = RenderComponent(); + + var input = com.Find(".bit-tgi-inp"); + + // A datalist of our own is what the field suggests with, so the browser's autofill is kept out of + // its way, and a tag is a value rather than a sentence, so it is not spell checked either. + Assert.AreEqual("off", input.GetAttribute("autocomplete")); + Assert.AreEqual("false", input.GetAttribute("spellcheck")); + Assert.IsFalse(input.HasAttribute("inputmode")); + Assert.IsFalse(input.HasAttribute("enterkeyhint")); + } + + [TestMethod] + public void BitTagsInputAutoCompleteTest() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.AutoComplete, "email"); + }); + + Assert.AreEqual("email", com.Find(".bit-tgi-inp").GetAttribute("autocomplete")); + } + + [TestMethod, + DataRow(BitInputMode.Email, "email"), + DataRow(BitInputMode.Numeric, "numeric"), + DataRow(BitInputMode.Url, "url")] + public void BitTagsInputInputModeTest(BitInputMode inputMode, string expected) + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.InputMode, inputMode); + }); + + Assert.AreEqual(expected, com.Find(".bit-tgi-inp").GetAttribute("inputmode")); + } + + [TestMethod, + DataRow(BitEnterKeyHint.Done, "done"), + DataRow(BitEnterKeyHint.Next, "next")] + public void BitTagsInputEnterKeyHintTest(BitEnterKeyHint enterKeyHint, string expected) + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.EnterKeyHint, enterKeyHint); + }); + + Assert.AreEqual(expected, com.Find(".bit-tgi-inp").GetAttribute("enterkeyhint")); + } + + [TestMethod, + DataRow(true, "true"), + DataRow(false, "false")] + public void BitTagsInputSpellCheckTest(bool spellCheck, string expected) + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.SpellCheck, spellCheck); + }); + + Assert.AreEqual(expected, com.Find(".bit-tgi-inp").GetAttribute("spellcheck")); + } + + [TestMethod] + public void BitTagsInputDescribedByKeepsTheConsumersOwnIdsTest() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Description, "Press Enter after each tag."); + parameters.Add(p => p.InputHtmlAttributes, new Dictionary { { "aria-describedby", "my-hint" } }); + }); + + var describedBy = com.Find(".bit-tgi-inp").GetAttribute("aria-describedby"); + + // aria-describedby is a list of ids, so the description is added to whatever the consumer wrote + // rather than written over it. + Assert.IsNotNull(describedBy); + StringAssert.StartsWith(describedBy, "my-hint "); + StringAssert.Contains(describedBy, com.Find(".bit-tgi-dsc").Id); + } + + [TestMethod] + public void BitTagsInputDescribedByIsOnlyTheConsumersOwnIdsWithoutADescriptionTest() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.InputHtmlAttributes, new Dictionary { { "aria-describedby", "my-hint" } }); + }); + + Assert.AreEqual("my-hint", com.Find(".bit-tgi-inp").GetAttribute("aria-describedby")); + } + + #endregion + + #region fixed tags + + [TestMethod] + public void BitTagsInputFixedTagRendersNoDismissButtonTest() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, (ICollection?)new List { "owner", "editor" }); + parameters.Add(p => p.CanRemoveTag, (Func)(t => t != "owner")); + }); + + var tags = com.FindAll(".bit-tgi-tag"); + + Assert.AreEqual(2, tags.Count); + Assert.IsTrue(tags[0].ClassList.Contains("bit-tgi-tag-fix")); + Assert.IsFalse(tags[1].ClassList.Contains("bit-tgi-tag-fix")); + Assert.AreEqual(1, com.FindAll(".bit-tgi-dbt").Count); + } + + [TestMethod] + public void BitTagsInputFixedTagIgnoresTheDeleteKeyTest() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, (ICollection?)new List { "owner", "editor" }); + parameters.Add(p => p.CanRemoveTag, (Func)(t => t != "owner")); + }); + + var tags = com.FindAll(".bit-tgi-tag"); + tags[0].KeyDown(new KeyboardEventArgs { Key = "Delete" }); + + Assert.AreEqual(2, com.FindAll(".bit-tgi-tag").Count); + + com.FindAll(".bit-tgi-tag")[1].KeyDown(new KeyboardEventArgs { Key = "Delete" }); + + Assert.AreEqual(1, com.FindAll(".bit-tgi-tag").Count); + } + + [TestMethod] + public void BitTagsInputFixedLastTagSurvivesTheBackspaceOnTheEmptyInputTest() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, (ICollection?)new List { "editor", "owner" }); + parameters.Add(p => p.CanRemoveTag, (Func)(t => t != "owner")); + }); + + com.Find(".bit-tgi-inp").KeyDown(new KeyboardEventArgs { Key = "Backspace" }); + + Assert.AreEqual(2, com.FindAll(".bit-tgi-tag").Count); + } + + [TestMethod] + public void BitTagsInputClearKeepsTheFixedTagsTest() + { + var cleared = new List(); + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, (ICollection?)new List { "owner", "editor", "reader" }); + parameters.Add(p => p.CanRemoveTag, (Func)(t => t != "owner")); + parameters.Add(p => p.ShowClearButton, true); + parameters.Add(p => p.OnClear, (IReadOnlyList tags) => cleared = [.. tags]); + }); + + com.Find(".bit-tgi-cbt").Click(); + + var tags = com.FindAll(".bit-tgi-tag"); + + Assert.AreEqual(1, tags.Count); + Assert.AreEqual("owner", tags[0].TextContent.Trim()); + + // What is reported is what actually went. + CollectionAssert.AreEqual(new[] { "editor", "reader" }, cleared); + } + + [TestMethod] + public void BitTagsInputHasNoClearButtonWhenEveryTagIsFixedTest() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, (ICollection?)new List { "owner" }); + parameters.Add(p => p.CanRemoveTag, (Func)(_ => false)); + parameters.Add(p => p.ShowClearButton, true); + }); + + Assert.AreEqual(0, com.FindAll(".bit-tgi-cbt").Count); + } + + [TestMethod] + public async Task BitTagsInputRemoveTagAsyncIgnoresCanRemoveTagTest() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, (ICollection?)new List { "owner", "editor" }); + parameters.Add(p => p.CanRemoveTag, (Func)(_ => false)); + }); + + // The predicate is what the user may do, not what the consumer may. + await com.Instance.RemoveTagAsync("owner"); + + Assert.AreEqual(1, com.FindAll(".bit-tgi-tag").Count); + } + + [TestMethod] + public void BitTagsInputFixedTagIsDescribedApartTest() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, (ICollection?)new List { "owner", "editor" }); + parameters.Add(p => p.CanRemoveTag, (Func)(t => t != "owner")); + parameters.Add(p => p.EditableTags, true); + }); + + var tags = com.FindAll(".bit-tgi-tag"); + + var fixedHintId = tags[0].GetAttribute("aria-describedby"); + var hintId = tags[1].GetAttribute("aria-describedby"); + + Assert.IsNotNull(fixedHintId); + Assert.IsNotNull(hintId); + Assert.AreNotEqual(hintId, fixedHintId); + + StringAssert.Contains(com.Find($"#{fixedHintId}").TextContent, "cannot be removed"); + StringAssert.Contains(com.Find($"#{hintId}").TextContent, "Delete to remove"); + } + + #endregion + + #region loading + + [TestMethod, + DataRow(true), + DataRow(false)] + public void BitTagsInputIsLoadingTest(bool isLoading) + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.IsLoading, isLoading); + }); + + var spinners = com.FindAll(".bit-tgi-spn"); + + Assert.AreEqual(isLoading ? 1 : 0, spinners.Count); + + if (isLoading is false) return; + + // An indeterminate progressbar: a busy state a screen reader can read rather than a decoration. + Assert.AreEqual("progressbar", spinners[0].GetAttribute("role")); + Assert.AreEqual("Loading", spinners[0].GetAttribute("aria-label")); + } + + [TestMethod] + public void BitTagsInputLoadingAriaLabelTest() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.IsLoading, true); + parameters.Add(p => p.LoadingAriaLabel, "Fetching suggestions"); + }); + + Assert.AreEqual("Fetching suggestions", com.Find(".bit-tgi-spn").GetAttribute("aria-label")); + } + + #endregion + + #region cascading parameters + + [TestMethod] + public void BitTagsInputParamsShouldHaveCorrectParamName() + { + Assert.AreEqual($"{nameof(BitParams)}.{nameof(BitTagsInput)}", BitTagsInputParams.ParamName); + } + + [TestMethod] + public void BitTagsInputParamsShouldImplementIBitComponentParams() + { + var @params = new BitTagsInputParams(); + + Assert.IsInstanceOfType(@params); + Assert.AreEqual(BitTagsInputParams.ParamName, @params.Name); + } + + [TestMethod] + public void BitTagsInputShouldApplyCascadingParametersFromBitParams() + { + var paramsList = new List + { + new BitTagsInputParams + { + Color = BitColor.Success, + Size = BitSize.Large, + Variant = BitVariant.Fill, + TagVariant = BitVariant.Outline, + TagsPlaceholder = "Cascaded placeholder", + ShowClearButton = true, + } + }; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BitTagsInput.DefaultValue), (ICollection?)new List { "blazor" }); + builder.CloseComponent(); + }); + }); + + var root = com.Find(".bit-tgi"); + + Assert.IsTrue(root.ClassList.Contains("bit-tgi-suc")); + Assert.IsTrue(root.ClassList.Contains("bit-tgi-lg")); + Assert.IsTrue(root.ClassList.Contains("bit-tgi-fil")); + Assert.IsTrue(root.ClassList.Contains("bit-tgi-tgo")); + Assert.AreEqual("Cascaded placeholder", com.Find(".bit-tgi-inp").GetAttribute("placeholder")); + Assert.AreEqual(1, com.FindAll(".bit-tgi-cbt").Count); + } + + [TestMethod] + public void BitTagsInputDirectParametersShouldOverrideCascadingParameters() + { + var paramsList = new List + { + new BitTagsInputParams + { + Color = BitColor.Success, + Size = BitSize.Large, + Placeholder = "Cascaded placeholder", + } + }; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BitTagsInput.Color), BitColor.Error); + builder.AddAttribute(2, nameof(BitTagsInput.Placeholder), "Own placeholder"); + builder.CloseComponent(); + }); + }); + + var root = com.Find(".bit-tgi"); + + Assert.IsTrue(root.ClassList.Contains("bit-tgi-err")); + Assert.AreEqual("Own placeholder", com.Find(".bit-tgi-inp").GetAttribute("placeholder")); + + // What the field left unset is still filled in from the cascade. + Assert.IsTrue(root.ClassList.Contains("bit-tgi-lg")); + } + + [TestMethod] + public void BitTagsInputCascadedSeparatorsAndRulesShouldBeAppliedTest() + { + var paramsList = new List + { + new BitTagsInputParams + { + Separators = [","], + MinLength = 3, + Transformer = t => t.ToUpperInvariant(), + } + }; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + }); + + var input = com.Find(".bit-tgi-inp"); + + // The cascaded separators reach the script side through the data attribute, which is what + // proves OnSetSeparators ran for a value that arrived from the cascade rather than from markup. + Assert.AreEqual("[\",\"]", input.GetAttribute("data-separators")); + + // Pasting a separated list splits it, the transformer normalizes each piece, and the one that is + // too short for the cascaded MinLength is refused. + input.Input(new ChangeEventArgs { Value = "blazor,ui" }); + + var tags = com.FindAll(".bit-tgi-tag"); + Assert.AreEqual(1, tags.Count); + Assert.AreEqual("BLAZOR", tags[0].TextContent.Trim()); + } + + [TestMethod] + public void BitTagsInputCascadedPatternShouldBeCompiledTest() + { + var paramsList = new List + { + new BitTagsInputParams { Pattern = "^[0-9]+$" } + }; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + }); + + var input = com.Find(".bit-tgi-inp"); + + input.Input(new ChangeEventArgs { Value = "abc" }); + input.KeyDown(new KeyboardEventArgs { Key = "Enter" }); + + Assert.AreEqual(0, com.FindAll(".bit-tgi-tag").Count); + + input.Input(new ChangeEventArgs { Value = "123" }); + input.KeyDown(new KeyboardEventArgs { Key = "Enter" }); + + Assert.AreEqual(1, com.FindAll(".bit-tgi-tag").Count); + } + + [TestMethod] + public void BitTagsInputCascadedInputAttributesShouldBeAppliedTest() + { + var paramsList = new List + { + new BitTagsInputParams + { + InputMode = BitInputMode.Email, + EnterKeyHint = BitEnterKeyHint.Done, + SpellCheck = true, + AutoComplete = "email", + } + }; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + }); + + var input = com.Find(".bit-tgi-inp"); + + Assert.AreEqual("email", input.GetAttribute("inputmode")); + Assert.AreEqual("done", input.GetAttribute("enterkeyhint")); + Assert.AreEqual("true", input.GetAttribute("spellcheck")); + Assert.AreEqual("email", input.GetAttribute("autocomplete")); + } + + #endregion } From 220cbb21f170c8b16272b7dacd46ac1a23cae218 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 22 Sep 2026 00:30:43 +0330 Subject: [PATCH 2/3] further improvements --- .../Inputs/TagsInput/BitTagsInput.razor | 52 ++- .../Inputs/TagsInput/BitTagsInput.razor.cs | 435 +++++++++++++++++- .../Inputs/TagsInput/BitTagsInput.scss | 115 ++++- .../Inputs/TagsInput/BitTagsInput.ts | 15 +- .../TagsInput/BitTagsInputClassStyles.cs | 22 + .../Inputs/TagsInput/BitTagsInputParams.cs | 112 +++++ .../Inputs/TagsInput/BitTagsInputDemo.razor | 82 ++-- .../TagsInput/BitTagsInputDemo.razor.cs | 125 ++++- .../BitTagsInputDemo.razor.samples.cs | 18 +- .../Inputs/TagsInput/BitTagsInputTests.cs | 401 ++++++++++++++++ 10 files changed, 1315 insertions(+), 62 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor index 6d3760b2059..f709230ae92 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor @@ -55,8 +55,23 @@ var suggestions = GetSuggestions(); - // The same icon on every tag, so it is resolved once per render rather than once per chip. + // A tag picked up with its handle and never put anywhere - because the parent has since taken it + // away, because the fold has closed over it, or because the field no longer offers the reordering at + // all - is put back down: a chip that is lifted with nowhere to land is only a chip drawn askew. + if (_pickedUpTagIndex >= displayedTagCount || (_pickedUpTagIndex >= 0 && (isInteractive is false || AllowReorder is false))) + { + SetPickedUpTag(-1); + } + + // The tag that is currently being carried, read once rather than once per chip: while it is in the + // air, every other handle is labelled by it rather than by the tag it belongs to. + var carriedTag = _pickedUpTagIndex >= 0 && tags is not null && _pickedUpTagIndex < tags.Count + ? tags[_pickedUpTagIndex] + : null; + + // The same icons on every tag, so they are resolved once per render rather than once per chip. var dismissIcon = BitIconInfo.From(DismissIcon, DismissIconName ?? "Cancel"); + var reorderIcon = AllowReorder ? BitIconInfo.From(ReorderIcon, ReorderIconName ?? "GripperBarVertical") : null; }
+ @* Drawn only where there is somewhere to put the tag down: a handle on the only + chip in the field picks a tag up and hands it back, which is chrome that does + nothing on every chip that stands alone. *@ + @if (isInteractive && isTagEditing is false && AllowReorder && displayedTagCount > 1) + { + @* The handle that reorders a tag without a drag. Dragging a chip is the quick + gesture, but it is one no touch screen, no switch and no head pointer can + make, so the very same move is offered as two taps: one on the handle picks + the tag up, one on the tag whose place it should take puts it down, and a + second tap on the handle puts it back. Out of the tab order like the dismiss + button, the keyboard reordering being Alt with the arrow keys. *@ +