diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/PhoneInput/BitPhoneInput.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/PhoneInput/BitPhoneInput.razor.cs index c1f57a63cd5..e0f7d8f6632 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/PhoneInput/BitPhoneInput.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/PhoneInput/BitPhoneInput.razor.cs @@ -1459,9 +1459,7 @@ private string? AriaDescribedBy private string? GetInputAttribute(string name) { - return InputHtmlAttributes is not null && InputHtmlAttributes.TryGetValue(name, out var value) - ? value?.ToString() - : null; + return TryFindAttribute(InputHtmlAttributes, name, out var value) ? value?.ToString() : null; } // The forced invalid state and the one the EditContext produces end up on the same attribute, and diff --git a/src/BlazorUI/Bit.BlazorUI/Components/BitComponentBase.cs b/src/BlazorUI/Bit.BlazorUI/Components/BitComponentBase.cs index ddb1cffc6bc..ab477fd9ab2 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/BitComponentBase.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/BitComponentBase.cs @@ -5,6 +5,10 @@ public abstract partial class BitComponentBase : ComponentBase, IAsyncDisposable private BitDir? _dir; private readonly string _uniqueId = BitShortId.NewId(); private readonly HashSet _assignedParameters = []; + private readonly Dictionary _htmlAttributes = []; + + // The dictionary last passed as the HtmlAttributes parameter, which every pass reads and none changes. + private IEnumerable>? _htmlAttributesParameter; @@ -108,20 +112,41 @@ public BitDir? Dir /// /// Captures additional HTML attributes to be applied to the rendered element, in addition to the component's parameters. - ///
- /// This parameter should not be assigned directly. ///
/// /// Each entry in the dictionary represents an attribute name and its corresponding value. This /// allows customization of the rendered element with other HTML attributes such as alt, title, data-* attributes, and /// more. ///
+ /// Every HTML attribute written on the component lands here. A dictionary passed to this parameter adds its entries + /// to those, and an attribute written on the component wins over an entry of the same name, however either of them + /// cases it. The dictionary passed is only read, never changed: its entries are copied in on every render, and so + /// are those of one assigned to this property from code. + ///
/// This dictionary will be used as the value of the "@attributes" blazor directive when rendering the root element of the component. ///
/// If an attribute in the dictionary matches a property already set by the component, the value in the /// dictionary may override the default. ///
- [Parameter] public Dictionary HtmlAttributes { get; set; } = []; + [Parameter] + public Dictionary HtmlAttributes + { + get => _htmlAttributes; + set + { + // The component's own dictionary is cleared on every pass, so one handed to it is copied, never taken as it. + if (ReferenceEquals(value, _htmlAttributes)) return; + + _htmlAttributes.Clear(); + + if (value is null) return; + + foreach (var attribute in value) + { + _htmlAttributes[attribute.Key] = attribute.Value; + } + } + } /// /// Gets or sets the unique identifier for the component's root element. @@ -171,7 +196,7 @@ public BitDir? Dir public override Task SetParametersAsync(ParameterView parameters) { _assignedParameters.Clear(); - HtmlAttributes.Clear(); + _htmlAttributes.Clear(); var parametersDictionary = ParametersCache ?? new Dictionary(parameters.ToDictionary()); foreach (var parameter in parametersDictionary!) { @@ -223,6 +248,14 @@ public override Task SetParametersAsync(ParameterView parameters) parametersDictionary.Remove(parameter.Key); break; + // Merged into the splatted attributes once every other one is in, never taken as the dictionary + // itself: HtmlAttributes is cleared on every pass, which would empty the caller's own dictionary. Any + // other value under that name - a wrapper splatting one it captured - is an attribute like the rest. + case nameof(HtmlAttributes) when parameter.Value is null or IEnumerable>: + _htmlAttributesParameter = (IEnumerable>?)parameter.Value; + parametersDictionary.Remove(parameter.Key); + break; + case nameof(Id): _assignedParameters.Add(nameof(Id)); Id = (string?)parameter.Value; @@ -258,6 +291,9 @@ public override Task SetParametersAsync(ParameterView parameters) } } + // A pass that leaves the parameter out keeps the dictionary passed before, as it keeps every other parameter. + AddMissingHtmlAttributes(_htmlAttributesParameter); + ParametersCache = null; var restore = RestoreDroppedCascadeParameters(); @@ -272,6 +308,71 @@ private async Task SetParametersAfterAsync(Task restore) await base.SetParametersAsync(ParameterView.Empty); } + /// + /// Adds every attribute of the given set the component does not hold yet - the HtmlAttributes parameter, the set a + /// params object cascades - to its splatted attributes, which win over an entry of the same name however either of + /// them cases it: the render tree treats differently cased names as one attribute, so both would otherwise reach it + /// and the last one would win. + /// + internal void AddMissingHtmlAttributes(IEnumerable>? attributes) + { + if (attributes is null) return; + + foreach (var attribute in attributes) + { + if (TryFindAttribute(_htmlAttributes, attribute.Key, out _)) continue; + + _htmlAttributes[attribute.Key] = attribute.Value; + } + } + + /// + /// The attributes a component splats on one of its parts: its own, plus every cascaded one it holds under no casing + /// of the name. The own dictionary is handed back as it is when nothing is added and copied otherwise, so neither + /// of the two is ever changed. + /// + private protected static Dictionary? MergeAttributes(Dictionary? own, + IEnumerable>? cascaded) + { + if (cascaded is null) return own; + + Dictionary? merged = null; + + foreach (var attribute in cascaded) + { + if (TryFindAttribute(own, attribute.Key, out _)) continue; + + merged ??= own is null ? [] : new(own); + merged[attribute.Key] = attribute.Value; + } + + return merged ?? own; + } + + /// + /// Looks an attribute up by its name the way the render tree matches names, case insensitively, taking the last of + /// several differently cased spellings as the render tree does - an exact match included. The one definition of + /// what makes two attribute names the same name, for every set of attributes a component splats. + /// + private protected static bool TryFindAttribute(IEnumerable>? attributes, string name, out object? value) + { + value = null; + + if (attributes is null) return false; + + var found = false; + + foreach (var attribute in attributes) + { + if (string.Equals(attribute.Key, name, StringComparison.OrdinalIgnoreCase) is false) continue; + + value = attribute.Value; + found = true; + } + + return found; + } + protected override void OnInitialized() @@ -455,22 +556,7 @@ private protected bool TryGetSplattedAttribute(string name, out string? value) { value = null; - if (HtmlAttributes.Count == 0) return false; - - // The render tree keeps the last of several differently cased spellings, so the last one in render order wins - // here too, an exact match included. - var found = false; - object? selected = null; - - foreach (var attribute in HtmlAttributes) - { - if (string.Equals(attribute.Key, name, StringComparison.OrdinalIgnoreCase) is false) continue; - - selected = attribute.Value; - found = true; - } - - if (found is false) return false; + if (TryFindAttribute(_htmlAttributes, name, out var selected) is false) return false; value = StringifyAttributeValue(selected); return true; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/BitComponentBaseParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/BitComponentBaseParams.cs index 0221d706bf8..6c34e6a9d01 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/BitComponentBaseParams.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/BitComponentBaseParams.cs @@ -118,15 +118,7 @@ public void UpdateBaseParameters(BitComponentBase bitComponentBase) bitComponentBase.TakeFromCascade(nameof(ForceAnimation), ForceAnimation.Value, static c => c.ForceAnimation, static (c, v) => c.ForceAnimation = v); } - if (HtmlAttributes is not null) - { - foreach (var attr in HtmlAttributes) - { - if (bitComponentBase.HtmlAttributes.ContainsKey(attr.Key)) continue; - - bitComponentBase.HtmlAttributes[attr.Key] = attr.Value; - } - } + bitComponentBase.AddMissingHtmlAttributes(HtmlAttributes); if (Id.HasValue()) { diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs index 1da03656e09..db1236afaba 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs @@ -29,6 +29,10 @@ public abstract class BitInputBase : BitComponentBase private ValidationMessageStore? _parsingValidationMessages; private readonly EventHandler _validationStateChangedHandler; + // Whether InputHtmlAttributes is a copy of the app's dictionary that the aria-invalid of a failed validation was + // added to, which is then the one aria-invalid taken off again once the field is valid. + private bool _hasAddedAriaInvalid; + // The parameters of this class are taken out of the ParameterView below before it reaches // BitComponentBase, so the set that BitComponentBase tracks never sees them, and the one the source // generator writes only ever holds the parameters that the component itself declares. A cascade filling @@ -197,6 +201,7 @@ public override Task SetParametersAsync(ParameterView parameters) case nameof(InputHtmlAttributes): _assignedInputParameters.Add(nameof(InputHtmlAttributes)); InputHtmlAttributes = (Dictionary?)parameter.Value; + _hasAddedAriaInvalid = false; parametersDictionary.Remove(parameter.Key); break; @@ -480,72 +485,36 @@ private void UpdateValidationAttributes() { if (EditContext is null || NoValidate) return; - var hasAriaInvalidAttribute = InputHtmlAttributes is not null && InputHtmlAttributes.ContainsKey("aria-invalid"); - + // The dictionary is the app's own, possibly shared by several inputs, so the attribute is added to and taken off + // a copy of it, never the dictionary itself. if (EditContext.GetValidationMessages(FieldIdentifier).Any()) { - if (hasAriaInvalidAttribute) return; // Do not overwrite the attribute value + ValueInvalid = true; - if (TryConvertingToDictionary(InputHtmlAttributes, out var inputHtmlAttributes)) - { - InputHtmlAttributes = inputHtmlAttributes; - } + if (_hasAddedAriaInvalid) return; + + // Do not overwrite the attribute value the app wrote itself, however it cased its name. + if (TryFindAttribute(InputHtmlAttributes, "aria-invalid", out _)) return; // To make the `Input` components accessible by default // we will automatically render the `aria-invalid` attribute when the validation fails // value must be "true" see https://www.w3.org/TR/wai-aria-1.1/#aria-invalid - inputHtmlAttributes["aria-invalid"] = "true"; - - ValueInvalid = true; + InputHtmlAttributes = new(InputHtmlAttributes ?? []) { ["aria-invalid"] = "true" }; + _hasAddedAriaInvalid = true; } else { ValueInvalid = false; - if (hasAriaInvalidAttribute is false) return; - - // No validation errors. Need to remove `aria-invalid` if it was rendered already - - if (InputHtmlAttributes!.Count == 1) - { - // Only aria-invalid argument is present which we don't need any more - InputHtmlAttributes = null; - } - else - { - if (TryConvertingToDictionary(InputHtmlAttributes, out var inputHtmlAttributes)) - { - InputHtmlAttributes = inputHtmlAttributes; - } - - inputHtmlAttributes.Remove("aria-invalid"); - } - } - } + // No validation errors. Need to remove `aria-invalid` if it was rendered already, and only the one added here. + if (_hasAddedAriaInvalid is false) return; - private static bool TryConvertingToDictionary(IReadOnlyDictionary? source, out Dictionary result) - { - var newDictionaryCreated = true; + var inputHtmlAttributes = new Dictionary(InputHtmlAttributes!); + inputHtmlAttributes.Remove("aria-invalid"); - if (source is null) - { - result = []; - } - else if (source is Dictionary currentDictionary) - { - result = currentDictionary; - newDictionaryCreated = false; + InputHtmlAttributes = inputHtmlAttributes.Count == 0 ? null : inputHtmlAttributes; + _hasAddedAriaInvalid = false; } - else - { - result = []; - foreach (var item in source) - { - result.Add(item.Key, item.Value); - } - } - - return newDictionaryCreated; } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBaseParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBaseParams.cs index 39bb11c013c..f7987dd2a66 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBaseParams.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBaseParams.cs @@ -11,8 +11,8 @@ /// , its and its /// - is deliberately left out, since a value shared by every /// input under the cascade is never what a consumer means. So is -/// , a dictionary the components write into, which they -/// would end up sharing a single instance of, and , which is read +/// , the attributes of one field's own input element - +/// its id, the ids its aria-describedby points at - and , which is read /// while the parameters are still being set and so before a cascade has been applied. /// public abstract class BitInputBaseParams : BitComponentBaseParams diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePicker.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePicker.razor index 39550af4255..98f7f227957 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePicker.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePicker.razor @@ -118,7 +118,7 @@ holds the focus - the toolbar, an action button, anything a template put inside - and not only from the one control that happens to own a key handler of its own. *@ -
private BitCircularTimePickerView _view = BitCircularTimePickerView.Hour; private CultureInfo _culture = CultureInfo.CurrentUICulture; private DotNetObjectReference _dotnetObj = default!; + private Dictionary? _calloutHtmlAttributes; @@ -161,6 +162,11 @@ public partial class BitCircularTimePicker : BitInputBase ///
[Parameter] public Dictionary CalloutHtmlAttributes { get; set; } = []; + // The callout attributes the BitCircularTimePickerParams of this pass cascades, which those of + // CalloutHtmlAttributes win over. Held apart from that dictionary, which is the app's own and never written into, + // and dropped on every pass so a params object that stops supplying one leaves nothing of it behind. + internal Dictionary? CascadedCalloutHtmlAttributes { get; set; } + /// /// Custom CSS classes for different parts of the TimePicker component. /// @@ -803,8 +809,12 @@ protected override void OnInitialized() [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BitCircularTimePickerParams))] protected override void OnParametersSet() { + CascadedCalloutHtmlAttributes = null; + CascadingParameters?.UpdateParameters(this); + _calloutHtmlAttributes = MergeAttributes(CalloutHtmlAttributes, CascadedCalloutHtmlAttributes); + base.OnParametersSet(); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePickerParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePickerParams.cs index ecdf5c36949..9891dde3884 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePickerParams.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePickerParams.cs @@ -395,15 +395,7 @@ public void UpdateParameters(BitCircularTimePicker bitCircularTimePicker) bitCircularTimePicker.TakeFromCascade(nameof(CalloutHeaderTemplate), CalloutHeaderTemplate, static c => c.CalloutHeaderTemplate, static (c, v) => c.CalloutHeaderTemplate = v); } - if (CalloutHtmlAttributes is not null) - { - foreach (var attr in CalloutHtmlAttributes) - { - if (bitCircularTimePicker.CalloutHtmlAttributes.ContainsKey(attr.Key)) continue; - - bitCircularTimePicker.CalloutHtmlAttributes[attr.Key] = attr.Value; - } - } + bitCircularTimePicker.CascadedCalloutHtmlAttributes = CalloutHtmlAttributes; if (Classes is not null) { diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/NumberField/BitNumberField.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/NumberField/BitNumberField.razor.cs index ec4740c8a58..3627dc21c49 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/NumberField/BitNumberField.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/NumberField/BitNumberField.razor.cs @@ -1385,9 +1385,7 @@ private string? AriaDescribedBy private string? GetInputAttribute(string name) { - return InputHtmlAttributes is not null && InputHtmlAttributes.TryGetValue(name, out var value) - ? value?.ToString() - : null; + return TryFindAttribute(InputHtmlAttributes, name, out var value) ? value?.ToString() : null; } /// diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor.cs index 5106873cb89..e5cc3c4bb5a 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor.cs @@ -802,9 +802,7 @@ private void ResizeInputs() { if (IsInvalid) return "true"; - return InputHtmlAttributes?.TryGetValue("aria-invalid", out var ariaInvalid) is true - ? ariaInvalid?.ToString() - : null; + return TryFindAttribute(InputHtmlAttributes, "aria-invalid", out var ariaInvalid) ? ariaInvalid?.ToString() : null; } // The tab order of a group of inputs holding a single value is decided by two things at once: the diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.razor.cs index 597db5cabd5..4731a38b9dd 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.razor.cs @@ -951,9 +951,7 @@ private string? DescribedBy private string? GetInputAttribute(string name) { - return InputHtmlAttributes is not null && InputHtmlAttributes.TryGetValue(name, out var value) - ? value?.ToString() - : null; + return TryFindAttribute(InputHtmlAttributes, name, out var value) ? value?.ToString() : null; } private string? GetAutoCorrect() => AutoCorrect.HasValue ? (AutoCorrect.Value ? "on" : "off") : null; 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 94e118b4700..f07f2aec29b 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.razor.cs @@ -1696,9 +1696,7 @@ private bool CanRemove(string tag) if (_invalidReason != BitTagsInputInvalidReason.None) return "true"; - return InputHtmlAttributes is not null && InputHtmlAttributes.TryGetValue("aria-invalid", out var value) - ? value?.ToString() - : null; + return TryFindAttribute(InputHtmlAttributes, "aria-invalid", out var value) ? value?.ToString() : null; } /// diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TextField/BitTextField.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TextField/BitTextField.razor.cs index fed5efa12c7..c4f49ce62ff 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TextField/BitTextField.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TextField/BitTextField.razor.cs @@ -1242,9 +1242,7 @@ private void Announce(string? text) private string? GetInputAttribute(string name) { - return InputHtmlAttributes is not null && InputHtmlAttributes.TryGetValue(name, out var value) - ? value?.ToString() - : null; + return TryFindAttribute(InputHtmlAttributes, name, out var value) ? value?.ToString() : null; } private string CountText => MaxLength >= 0 ? $"{_charCount}/{MaxLength}" : _charCount.ToString(); diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.razor index 903cf2ebbf7..894be0f50f7 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.razor @@ -172,7 +172,7 @@ class="bit-tpc-cac @Classes?.CalloutContainer" role="@(Standalone ? "group" : "dialog")" aria-modal="@(Standalone ? null : "true")" - @attributes=@CalloutHtmlAttributes + @attributes="_calloutHtmlAttributes" aria-label="@(StandaloneHasLabel ? null : CalloutAriaLabel)" aria-labelledby="@(StandaloneHasLabel ? _labelId : null)"> diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.razor.cs index 97ba3480d68..f9ee731ea52 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.razor.cs @@ -36,6 +36,7 @@ public partial class BitTimePicker : BitInputBase private int? _typedDigit; private int _typedDigitCount; private TimeUnit? _focusedUnit; + private Dictionary? _calloutHtmlAttributes; private enum TimeUnit { Hour, Minute, Second } @@ -266,6 +267,11 @@ private string? _secondView /// [Parameter] public Dictionary CalloutHtmlAttributes { get; set; } = []; + // The callout attributes the BitTimePickerParams of this pass cascades, which those of CalloutHtmlAttributes win + // over. Held apart from that dictionary, which is the app's own and never written into, and dropped on every pass + // so a params object that stops supplying one leaves nothing of it behind. + internal Dictionary? CascadedCalloutHtmlAttributes { get; set; } + /// /// Custom CSS classes for different parts of the BitTimePicker component. /// @@ -1016,8 +1022,12 @@ protected override void OnInitialized() [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BitTimePickerParams))] protected override void OnParametersSet() { + CascadedCalloutHtmlAttributes = null; + CascadingParameters?.UpdateParameters(this); + _calloutHtmlAttributes = MergeAttributes(CalloutHtmlAttributes, CascadedCalloutHtmlAttributes); + base.OnParametersSet(); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePickerParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePickerParams.cs index 55ebbf5d3bc..5a761a045ca 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePickerParams.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePickerParams.cs @@ -368,15 +368,7 @@ public void UpdateParameters(BitTimePicker bitTimePicker) bitTimePicker.TakeFromCascade(nameof(CalloutHeaderTemplate), CalloutHeaderTemplate, static t => t.CalloutHeaderTemplate, static (t, v) => t.CalloutHeaderTemplate = v); } - if (CalloutHtmlAttributes is not null) - { - foreach (var attr in CalloutHtmlAttributes) - { - if (bitTimePicker.CalloutHtmlAttributes.ContainsKey(attr.Key)) continue; - - bitTimePicker.CalloutHtmlAttributes[attr.Key] = attr.Value; - } - } + bitTimePicker.CascadedCalloutHtmlAttributes = CalloutHtmlAttributes; if (Classes is not null) { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor.cs index 36f307c8c84..daa07317033 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor.cs @@ -456,7 +456,7 @@ protected override Task OnParamsSetAsync() Name = "HtmlAttributes", Type = "Dictionary", DefaultValue = "new Dictionary()", - Description = "Captures additional HTML attributes to be applied to the rendered element, in addition to the component's parameters.", + Description = "Captures additional HTML attributes to be applied to the rendered element, in addition to the component's parameters. An attribute written on the component wins over an entry of the same name.", }, new() { diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/BitComponentBaseHtmlAttributesTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/BitComponentBaseHtmlAttributesTests.cs new file mode 100644 index 00000000000..d25f59bce7f --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/BitComponentBaseHtmlAttributesTests.cs @@ -0,0 +1,226 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Bunit; + +namespace Bit.BlazorUI.Tests.Components; + +/// +/// Pins the HtmlAttributes parameter of BitComponentBase when it is passed explicitly rather than filled from the +/// attributes written on the component: its entries are splatted on the root like any of those, and are what the +/// component's own logic reads a splatted attribute from. +/// +[TestClass] +public class BitComponentBaseHtmlAttributesTests : BunitTestContext +{ + [TestMethod] + public void AnExplicitHtmlAttributesDictionaryShouldBeSplattedOnTheRoot() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.HtmlAttributes, new Dictionary { ["data-test"] = "x", ["title"] = "Hint" }); + }); + + var button = component.Find("button"); + + Assert.AreEqual("x", button.GetAttribute("data-test")); + Assert.AreEqual("Hint", button.GetAttribute("title")); + // The dictionary used to be splatted as one attribute named after the parameter, valued with its ToString(). + Assert.IsFalse(button.HasAttribute("HtmlAttributes")); + } + + [TestMethod] + public void TheComponentShouldReadASplattedAttributeFromAnExplicitHtmlAttributesDictionary() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Element, "a"); + parameters.Add(p => p.Disabled, true); + parameters.Add(p => p.HtmlAttributes, new Dictionary { ["href"] = "https://bitplatform.dev/", ["role"] = "menuitem" }); + }); + + var element = component.Find("a"); + + // A disabled anchor drops the href it was given and keeps the role the page chose, both of which it can only + // do by finding them among its splatted attributes. + Assert.IsFalse(element.HasAttribute("href")); + Assert.AreEqual("menuitem", element.GetAttribute("role")); + } + + [TestMethod, + DataRow(true), + DataRow(false)] + public void AnAttributeWrittenOnTheComponentShouldWinOverAnEntryOfTheSameName(bool dictionaryFirst) + { + var htmlAttributes = new Dictionary + { + ["data-test"] = "from-dictionary", + ["title"] = "from-dictionary", + ["data-other"] = "from-dictionary", + }; + + // Written through the render tree, as the markup would: HtmlAttributes is not a CaptureUnmatchedValues + // parameter, so bUnit's AddUnmatched cannot feed it. Either order of the two reaches the component. + var component = Context.Render(builder => + { + builder.OpenComponent(0); + if (dictionaryFirst) builder.AddAttribute(1, nameof(BitButton.HtmlAttributes), htmlAttributes); + builder.AddAttribute(2, "data-test", "written"); + // HTML attribute names are case insensitive, and so is the render tree that writes them. + builder.AddAttribute(3, "Title", "written"); + if (dictionaryFirst is false) builder.AddAttribute(4, nameof(BitButton.HtmlAttributes), htmlAttributes); + builder.CloseComponent(); + }); + + var button = component.Find("button"); + + Assert.AreEqual("written", button.GetAttribute("data-test")); + Assert.AreEqual("written", button.GetAttribute("title")); + Assert.AreEqual("from-dictionary", button.GetAttribute("data-other")); + } + + [TestMethod] + public void AnExplicitHtmlAttributesDictionaryShouldBeLeftAsItWasGiven() + { + var htmlAttributes = new Dictionary { ["data-test"] = "x" }; + + var component = RenderComponent(parameters => parameters.Add(p => p.HtmlAttributes, htmlAttributes)); + + component.Render(parameters => parameters.Add(p => p.HtmlAttributes, htmlAttributes)); + + // The component clears its own dictionary on every render, so it never takes the one it was given as its own. + Assert.AreNotSame(htmlAttributes, component.Instance.HtmlAttributes); + Assert.HasCount(1, htmlAttributes); + Assert.AreEqual("x", htmlAttributes["data-test"]); + Assert.AreEqual("x", component.Find("button").GetAttribute("data-test")); + } + + [TestMethod] + public void TheEntriesOfADroppedHtmlAttributesDictionaryShouldGo() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.HtmlAttributes, new Dictionary { ["data-test"] = "x" }); + }); + + component.Render(parameters => parameters.Add(p => p.HtmlAttributes, null!)); + + Assert.IsFalse(component.Find("button").HasAttribute("data-test")); + } + + [TestMethod] + public void AnExplicitHtmlAttributesDictionaryShouldOutliveAPassThatLeavesItOut() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.HtmlAttributes, new Dictionary { ["data-test"] = "x" }); + }); + + // A pass that sets another parameter alone keeps every parameter it does not name, the dictionary included. + component.Render(parameters => parameters.Add(p => p.Disabled, true)); + + var button = component.Find("button"); + + Assert.IsTrue(button.ClassList.Contains("bit-dis")); + Assert.AreEqual("x", button.GetAttribute("data-test")); + } + + [TestMethod] + public void AnyDictionaryTypeShouldBeTakenAsTheHtmlAttributesParameter() + { + var htmlAttributes = new ReadOnlyDictionary(new Dictionary { ["data-test"] = "x" }); + + // What a wrapper forwarding the attributes it captured passes under the name of the parameter. + var component = Context.Render(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BitButton.HtmlAttributes), htmlAttributes); + builder.CloseComponent(); + }); + + Assert.AreEqual("x", component.Find("button").GetAttribute("data-test")); + } + + [TestMethod] + public void AValueUnderTheNameOfTheParameterThatIsNoDictionaryShouldBeSplattedAsAnAttribute() + { + var component = Context.Render(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BitButton.HtmlAttributes), "text"); + builder.CloseComponent(); + }); + + Assert.AreEqual("text", component.Find("button").GetAttribute(nameof(BitButton.HtmlAttributes))); + } + + [TestMethod] + public void ADictionaryAssignedFromCodeShouldBeCopiedRatherThanTaken() + { + var htmlAttributes = new Dictionary { ["data-test"] = "x" }; + + var component = RenderComponent(); + + // Assigned from outside on purpose: the setter is public, and what it does with the dictionary is under test. +#pragma warning disable BL0005 + component.Instance.HtmlAttributes = htmlAttributes; +#pragma warning restore BL0005 + + Assert.AreNotSame(htmlAttributes, component.Instance.HtmlAttributes); + Assert.AreEqual("x", component.Instance.HtmlAttributes["data-test"]); + + // Every pass clears the component's own dictionary, which leaves the one it was handed as it was. + component.Render(); + + Assert.HasCount(1, htmlAttributes); + Assert.AreEqual("x", htmlAttributes["data-test"]); + } + + [TestMethod] + public void AnExplicitHtmlAttributesDictionaryShouldWinOverTheOneOfAParamsObject() + { + var component = RenderComponent(builder => + { + builder.Add(p => p.Parameters, [new BitButtonParams + { + HtmlAttributes = new() { ["data-shared"] = "cascaded", ["data-cascaded"] = "cascaded" } + }]); + builder.AddChildContent(parameters => + { + parameters.Add(p => p.HtmlAttributes, new Dictionary { ["data-shared"] = "own" }); + }); + }); + + var button = component.Find("button"); + + Assert.AreEqual("own", button.GetAttribute("data-shared")); + Assert.AreEqual("cascaded", button.GetAttribute("data-cascaded")); + } + + [TestMethod] + public void TheAttributesOfTheComponentShouldWinOverTheOnesOfAParamsObjectHoweverEitherCasesThem() + { + var component = RenderComponent(builder => + { + builder.Add(p => p.Parameters, [new BitButtonParams + { + HtmlAttributes = new() { ["data-shared"] = "cascaded", ["title"] = "cascaded" } + }]); + builder.AddChildContent(child => + { + child.OpenComponent(0); + child.AddAttribute(1, nameof(BitButton.HtmlAttributes), new Dictionary { ["Data-Shared"] = "own" }); + child.AddAttribute(2, "Title", "written"); + child.CloseComponent(); + }); + }); + + var button = component.Find("button"); + var instance = component.FindComponent().Instance; + + Assert.AreEqual("own", button.GetAttribute("data-shared")); + Assert.AreEqual("written", button.GetAttribute("title")); + // Neither cascaded spelling reaches the component beside its own, so the component reads what it renders. + Assert.HasCount(2, instance.HtmlAttributes); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/BitInputBaseInputHtmlAttributesTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/BitInputBaseInputHtmlAttributesTests.cs new file mode 100644 index 00000000000..35b65050e59 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/BitInputBaseInputHtmlAttributesTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using Bit.BlazorUI.Tests.Components.Inputs.TextField; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Inputs; + +/// +/// Pins the aria-invalid an input adds to its InputHtmlAttributes while its field fails validation: it reaches the +/// input element, and the dictionary the app passed - which several inputs may share - is never written into. +/// +[TestClass] +public class BitInputBaseInputHtmlAttributesTests : BunitTestContext +{ + [TestMethod] + public void TheAriaInvalidOfAFailedValidationShouldLeaveTheDictionaryOfTheAppAsItWas() + { + var model = new BitTextFieldTestModel(); + var editContext = new EditContext(model); + var messages = new ValidationMessageStore(editContext); + var inputHtmlAttributes = new Dictionary { ["data-test"] = "x" }; + + var component = Context.Render(builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", editContext); + builder.AddAttribute(2, "ChildContent", (RenderFragment)(child => + { + child.OpenComponent(0); + child.AddAttribute(1, nameof(BitTextField.Value), model.Value); + child.AddAttribute(2, nameof(BitTextField.ValueChanged), EventCallback.Factory.Create(this, value => model.Value = value)); + child.AddAttribute(3, nameof(BitTextField.ValueExpression), (Expression>)(() => model.Value)); + child.AddAttribute(4, nameof(BitTextField.InputHtmlAttributes), inputHtmlAttributes); + child.CloseComponent(); + })); + builder.CloseComponent(); + }); + + messages.Add(editContext.Field(nameof(BitTextFieldTestModel.Value)), "Invalid"); + component.InvokeAsync(editContext.NotifyValidationStateChanged); + + var input = component.Find("input"); + + Assert.AreEqual("true", input.GetAttribute("aria-invalid")); + Assert.AreEqual("x", input.GetAttribute("data-test")); + Assert.HasCount(1, inputHtmlAttributes); + + messages.Clear(); + component.InvokeAsync(editContext.NotifyValidationStateChanged); + + input = component.Find("input"); + + Assert.IsFalse(input.HasAttribute("aria-invalid")); + Assert.AreEqual("x", input.GetAttribute("data-test")); + Assert.HasCount(1, inputHtmlAttributes); + } + + // A differently cased name is the same attribute: the field reads it back however the app cased it, rather than + // writing a null over it while it is invalid. + [TestMethod] + [DataRow("aria-invalid")] + [DataRow("Aria-Invalid")] + public void AnAriaInvalidTheAppWroteShouldBeLeftWhereItIs(string name) + { + var model = new BitTextFieldTestModel(); + var editContext = new EditContext(model); + var messages = new ValidationMessageStore(editContext); + var inputHtmlAttributes = new Dictionary { [name] = "grammar" }; + + var component = Context.Render(builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", editContext); + builder.AddAttribute(2, "ChildContent", (RenderFragment)(child => + { + child.OpenComponent(0); + child.AddAttribute(1, nameof(BitTextField.Value), model.Value); + child.AddAttribute(2, nameof(BitTextField.ValueChanged), EventCallback.Factory.Create(this, value => model.Value = value)); + child.AddAttribute(3, nameof(BitTextField.ValueExpression), (Expression>)(() => model.Value)); + child.AddAttribute(4, nameof(BitTextField.InputHtmlAttributes), inputHtmlAttributes); + child.CloseComponent(); + })); + builder.CloseComponent(); + }); + + messages.Add(editContext.Field(nameof(BitTextFieldTestModel.Value)), "Invalid"); + component.InvokeAsync(editContext.NotifyValidationStateChanged); + + Assert.AreEqual("grammar", component.Find("input").GetAttribute("aria-invalid")); + + messages.Clear(); + component.InvokeAsync(editContext.NotifyValidationStateChanged); + + // A valid field only takes off the aria-invalid it added itself. + Assert.AreEqual("grammar", component.Find("input").GetAttribute("aria-invalid")); + Assert.HasCount(1, inputHtmlAttributes); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/TimePicker/BitTimePickerParamsTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/TimePicker/BitTimePickerParamsTests.cs index 95a2683b0a0..1c199684cbf 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/TimePicker/BitTimePickerParamsTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/TimePicker/BitTimePickerParamsTests.cs @@ -332,4 +332,49 @@ public void BitTimePickerParamsShouldMergeTheCalloutHtmlAttributes() // What the instance wrote for itself is never replaced by the cascade. Assert.AreEqual("from-instance", callout.GetAttribute("data-both")); } + + [TestMethod] + public void TheCalloutHtmlAttributesOfTheInstanceShouldWinHoweverEitherCasesThem() + { + var own = new Dictionary { ["Data-Both"] = "from-instance" }; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, [new BitTimePickerParams + { + CalloutHtmlAttributes = new Dictionary { ["data-both"] = "from-cascade", ["data-cascaded"] = "yes" } + }]); + parameters.AddChildContent(RenderTimePicker(builder => + { + builder.AddAttribute(1, nameof(BitTimePicker.CalloutHtmlAttributes), own); + })); + }); + + var callout = component.Find(".bit-tpc-cac"); + + Assert.AreEqual("from-instance", callout.GetAttribute("data-both")); + Assert.AreEqual("yes", callout.GetAttribute("data-cascaded")); + // The dictionary is the app's own, so the cascade is merged into a copy of it. + Assert.HasCount(1, own); + } + + [TestMethod] + public void TheCalloutHtmlAttributesOfADroppedParamsObjectShouldGo() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, [new BitTimePickerParams + { + CalloutHtmlAttributes = new Dictionary { ["data-cascaded"] = "yes" } + }]); + parameters.AddChildContent(RenderTimePicker()); + }); + + Assert.AreEqual("yes", component.Find(".bit-tpc-cac").GetAttribute("data-cascaded")); + + component.Render(parameters => parameters.Add(p => p.Parameters, [new BitTimePickerParams { Label = "Label" }])); + + Assert.IsFalse(component.Find(".bit-tpc-cac").HasAttribute("data-cascaded")); + Assert.IsEmpty(component.FindComponent().Instance.CalloutHtmlAttributes); + } }