Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 106 additions & 20 deletions src/BlazorUI/Bit.BlazorUI/Components/BitComponentBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ public abstract partial class BitComponentBase : ComponentBase, IAsyncDisposable
private BitDir? _dir;
private readonly string _uniqueId = BitShortId.NewId();
private readonly HashSet<string> _assignedParameters = [];
private readonly Dictionary<string, object> _htmlAttributes = [];

// The dictionary last passed as the HtmlAttributes parameter, which every pass reads and none changes.
private IEnumerable<KeyValuePair<string, object>>? _htmlAttributesParameter;



Expand Down Expand Up @@ -108,20 +112,41 @@ public BitDir? Dir

/// <summary>
/// Captures additional HTML attributes to be applied to the rendered element, in addition to the component's parameters.
/// <br />
/// <strong>This parameter should not be assigned directly.</strong>
/// </summary>
/// <remarks>
/// 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.
/// <br />
/// 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.
/// <br />
/// This dictionary will be used as the value of the <strong>"@attributes"</strong> blazor directive when rendering the root element of the component.
/// <br />
/// If an attribute in the dictionary matches a property already set by the component, the value in the
/// dictionary may override the default.
/// </remarks>
[Parameter] public Dictionary<string, object> HtmlAttributes { get; set; } = [];
[Parameter]
public Dictionary<string, object> 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;
}
}
}

/// <summary>
/// Gets or sets the unique identifier for the component's root element.
Expand Down Expand Up @@ -171,7 +196,7 @@ public BitDir? Dir
public override Task SetParametersAsync(ParameterView parameters)
{
_assignedParameters.Clear();
HtmlAttributes.Clear();
_htmlAttributes.Clear();
var parametersDictionary = ParametersCache ?? new Dictionary<string, object?>(parameters.ToDictionary());
foreach (var parameter in parametersDictionary!)
{
Expand Down Expand Up @@ -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<KeyValuePair<string, object>>:
_htmlAttributesParameter = (IEnumerable<KeyValuePair<string, object>>?)parameter.Value;
parametersDictionary.Remove(parameter.Key);
break;

case nameof(Id):
_assignedParameters.Add(nameof(Id));
Id = (string?)parameter.Value;
Expand Down Expand Up @@ -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();
Expand All @@ -272,6 +308,71 @@ private async Task SetParametersAfterAsync(Task restore)
await base.SetParametersAsync(ParameterView.Empty);
}

/// <summary>
/// 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.
/// </summary>
internal void AddMissingHtmlAttributes(IEnumerable<KeyValuePair<string, object>>? attributes)
{
if (attributes is null) return;

foreach (var attribute in attributes)
{
if (TryFindAttribute(_htmlAttributes, attribute.Key, out _)) continue;

_htmlAttributes[attribute.Key] = attribute.Value;
}
}

/// <summary>
/// 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.
/// </summary>
private protected static Dictionary<string, object>? MergeAttributes(Dictionary<string, object>? own,
IEnumerable<KeyValuePair<string, object>>? cascaded)
{
if (cascaded is null) return own;

Dictionary<string, object>? 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;
}

/// <summary>
/// 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.
/// </summary>
private protected static bool TryFindAttribute(IEnumerable<KeyValuePair<string, object>>? 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()
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
{
Expand Down
71 changes: 20 additions & 51 deletions src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public abstract class BitInputBase<TValue> : BitComponentBase
private ValidationMessageStore? _parsingValidationMessages;
private readonly EventHandler<ValidationStateChangedEventArgs> _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
Expand Down Expand Up @@ -197,6 +201,7 @@ public override Task SetParametersAsync(ParameterView parameters)
case nameof(InputHtmlAttributes):
_assignedInputParameters.Add(nameof(InputHtmlAttributes));
InputHtmlAttributes = (Dictionary<string, object>?)parameter.Value;
_hasAddedAriaInvalid = false;
parametersDictionary.Remove(parameter.Key);
break;

Expand Down Expand Up @@ -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<string, object>? source, out Dictionary<string, object> result)
{
var newDictionaryCreated = true;
var inputHtmlAttributes = new Dictionary<string, object>(InputHtmlAttributes!);
inputHtmlAttributes.Remove("aria-invalid");

if (source is null)
{
result = [];
}
else if (source is Dictionary<string, object> 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;
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
/// <see cref="BitInputBase{TValue}.DefaultValue"/>, its <see cref="BitInputBase{TValue}.Name"/> and its
/// <see cref="BitInputBase{TValue}.DisplayName"/> - is deliberately left out, since a value shared by every
/// input under the cascade is never what a consumer means. So is
/// <see cref="BitInputBase{TValue}.InputHtmlAttributes"/>, a dictionary the components write into, which they
/// would end up sharing a single instance of, and <see cref="BitInputBase{TValue}.NoValidate"/>, which is read
/// <see cref="BitInputBase{TValue}.InputHtmlAttributes"/>, the attributes of one field's own input element -
/// its id, the ids its aria-describedby points at - and <see cref="BitInputBase{TValue}.NoValidate"/>, which is read
/// while the parameters are still being set and so before a cascade has been applied.
/// </remarks>
public abstract class BitInputBaseParams<TValue> : BitComponentBaseParams
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*@
<div @attributes=@CalloutHtmlAttributes
<div @attributes="_calloutHtmlAttributes"
@onkeydown="HandleOnCalloutKeyDown"
role="@(Standalone ? "group" : "dialog")"
aria-modal="@(Standalone ? null : "true")"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public partial class BitCircularTimePicker : BitInputBase<TimeSpan?>
private BitCircularTimePickerView _view = BitCircularTimePickerView.Hour;
private CultureInfo _culture = CultureInfo.CurrentUICulture;
private DotNetObjectReference<BitCircularTimePicker> _dotnetObj = default!;
private Dictionary<string, object>? _calloutHtmlAttributes;



Expand Down Expand Up @@ -161,6 +162,11 @@ public partial class BitCircularTimePicker : BitInputBase<TimeSpan?>
/// </summary>
[Parameter] public Dictionary<string, object> 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<string, object>? CascadedCalloutHtmlAttributes { get; set; }

/// <summary>
/// Custom CSS classes for different parts of the TimePicker component.
/// </summary>
Expand Down Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading
Loading