diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor index 2cf38e175c2..c94d57a5eed 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. @@ -48,8 +55,28 @@ var suggestions = GetSuggestions(); - // The same icon on every tag, so it is resolved once per render rather than once per chip. + // The sentence that says why the last tag was refused, and the one that says how many tags the field + // already holds: the first replaces the helper text while it stands, the second is only ever read out. + var invalidMessage = ShowInvalidMessage ? _invalidMessage : null; + var countDescription = GetTagCountDescription(tagCount); + + // 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; }
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.
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.
- ", ". 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.
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.
- 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, matched without regard to case the way the browser
+ filters the list it is handed, so what the user is shown does not change.
+ 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. DebounceTime waits for the typing to
+ stop before raising it, which turns a request per keystroke into a request per word
+ (ThrottleTime spaces them out instead, for a list that should keep up while the word is
+ still being typed). Only the callback waits: the text itself is tracked as it is typed, so the tag
+ Enter commits is never a keystroke behind, and the emptying that follows a committed tag is
+ reported at once, taking the pending call down with it.
#, 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.
+ {0} the tag, {1} the reason). Alongside it the field wears its
+ invalid color - and says so through aria-invalid - until the next keystroke answers it,
+ and a tag refused as a duplicate marks the chip already in the list that it collided with, since "you
+ already have this one" is only an answer if it says which one. NoInvalidHighlight turns that
+ mark off, and OnInvalid (with OnTagExists for the duplicate alone) hands the refusal to
+ a report of your own.
#, 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 - carrying the same InputMode, EnterKeyHint and SpellCheck
+ as the field, since those belong to the values it collects rather than to the one box they are typed
+ into. 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. A correction those rules turn down is handed back in the still open input, caret at its
+ end, rather than being thrown away with the chip snapping back: an error is the one moment a field
+ must not lose what was just entered. Committing an empty text removes the tag, and OnEdit
+ receives the old and the new text and can call the change off.
{0} for the tag - the move also
+ {1} for the position it landed on and {2} for how many tags there are, the
+ refusal {1} for why it was turned down - 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.
--bit-TagsInput-tag-min-height: 1.5rem with a roomier
+ --bit-TagsInput-tag-padding - and widen the buttons through
+ Classes.DismissButton; the keyboard reaches both at any size, Delete removing the
+ focused chip and Alt with the arrows moving it.
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. Both are announced with the field - the input references them, ahead of the
+ Description - so a reader is told what a sighted user reads inside it.
+ PrefixTemplate and SuffixTemplate take markup instead, for an icon or a badge; markup
+ is as likely to be a glyph as a word, so those are not announced and what they mean has to be
+ in the Label as well.
ada@@example.com is announced by neither
+ the one nor the other: GetTagName is what names it, and the name it gives is carried by the
+ chip, by its dismiss button, by its reorder handle, by its inline edit and by every announcement the
+ tag takes part in. 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.
+ 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.
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.
- {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.
- 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.
- To: would otherwise have no idea what the tags are.
- :root or any ancestor
+ re-skins every tags input below it, and one on the Style of an instance re-skins that one alone.
+