diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor index 82726b5738..b5c2a67bd4 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor @@ -2,42 +2,22 @@ @inherits BitComponentBase @typeparam TItem -@if (ChildContent is not null || Options is not null) -{ - - - -} -
- @foreach (var item in _items) + @if (ChildContent is not null || Options is not null) { - var key = GetItemKey(item); - var isEnabled = IsEnabled && GetIsEnabled(item); - var headerTemplate = GetItemHeaderTemplate(item); - - - @GetItemBody(item) - + + @(Options ?? ChildContent) + + } + else + { + @foreach (var item in _items) + { + <_BitAccordionListItem @key="@GetItemKey(item)" AccordionList="this" Item="item" /> + } }
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor.cs index 79a52ce27b..76be31699a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor.cs @@ -13,8 +13,8 @@ public partial class BitAccordionList : BitComponentBase where TItem : cl private string? _internalExpandedKey; private List _internalExpandedKeys = []; private readonly HashSet _expandedKeys = []; - private BitAccordionClassStyles? _itemClasses; - private BitAccordionClassStyles? _itemStyles; + internal BitAccordionClassStyles? _itemClasses; + internal BitAccordionClassStyles? _itemStyles; @@ -157,6 +157,7 @@ public async Task ExpandAll() } await UpdateBoundKeys(); + RefreshOptions(); StateHasChanged(); } @@ -177,6 +178,7 @@ public async Task CollapseAll() } await UpdateBoundKeys(); + RefreshOptions(); StateHasChanged(); } @@ -325,6 +327,10 @@ protected override async Task OnParametersSetAsync() } } + // Options render their items themselves and Blazor skips re-rendering them when only the + // accordion list's own parameters (Styles, ExpandedKey(s), ...) change, so push a re-render to each one. + RefreshOptions(); + await base.OnParametersSetAsync(); } @@ -470,7 +476,7 @@ private void SyncFromExpandedKeys(IEnumerable? keys) _internalExpandedKeys = GetOrderedExpandedKeys(); } - private async Task HandleOnItemClick(TItem item) + internal async Task HandleOnItemClick(TItem item) { if (IsEnabled is false || GetIsEnabled(item) is false) return; @@ -522,6 +528,23 @@ private async Task ToggleItem(TItem item, string key, bool expand) await OnToggle.InvokeAsync(item); await UpdateBoundKeys(); + + // A toggle can affect other items too (single-expand mode collapses the previously expanded + // item), and the click handler runs on the clicked item's renderer, so both the registered + // options and the accordion list itself need an explicit re-render. + RefreshOptions(); + StateHasChanged(); + } + + private void RefreshOptions() + { + // In the Items API there are no registered options, so there is nothing to refresh. + if ((Options ?? ChildContent) is null) return; + + foreach (var item in _items) + { + (item as BitAccordionListOption)?.InternalStateHasChanged(); + } } private async Task UpdateBoundKeys() @@ -571,13 +594,13 @@ private void BuildItemClassStyles() }; } - private bool IsItemExpanded(TItem item) + internal bool IsItemExpanded(TItem item) { var key = GetItemKey(item); return key.HasValue() && _expandedKeys.Contains(key!); } - private RenderFragment? GetItemHeaderTemplate(TItem item) + internal RenderFragment? GetItemHeaderTemplate(TItem item) { var itemTemplate = GetHeaderTemplate(item); if (itemTemplate is not null) return _ => itemTemplate(item); @@ -590,7 +613,7 @@ private bool IsItemExpanded(TItem item) return null; } - private RenderFragment? GetItemBody(TItem item) + internal RenderFragment? GetItemBody(TItem item) { // The option's plain inline content (ChildContent) is rendered as-is. if (item is BitAccordionListOption listOption && listOption.ChildContent is not null) @@ -609,19 +632,19 @@ private bool IsItemExpanded(TItem item) return null; } - private BitIconInfo? GetItemExpanderIcon(TItem item) + internal BitIconInfo? GetItemExpanderIcon(TItem item) { return GetExpanderIcon(item) ?? ExpanderIcon; } - private string? GetItemExpanderIconName(TItem item) + internal string? GetItemExpanderIconName(TItem item) { return GetExpanderIconName(item) ?? ExpanderIconName; } - private string? GetItemKey(TItem? item) + internal string? GetItemKey(TItem? item) { if (item is null) return null; @@ -664,7 +687,7 @@ private void SetItemKey(TItem item, string value) item.SetValueToProperty(NameSelectors.Key.Name, value); } - private string? GetClass(TItem? item) + internal string? GetClass(TItem? item) { if (item is null) return null; @@ -688,7 +711,7 @@ private void SetItemKey(TItem item, string value) return item.GetValueFromProperty(NameSelectors.Class.Name); } - private string? GetStyle(TItem? item) + internal string? GetStyle(TItem? item) { if (item is null) return null; @@ -712,7 +735,7 @@ private void SetItemKey(TItem item, string value) return item.GetValueFromProperty(NameSelectors.Style.Name); } - private string? GetTitle(TItem? item) + internal string? GetTitle(TItem? item) { if (item is null) return null; @@ -736,7 +759,7 @@ private void SetItemKey(TItem item, string value) return item.GetValueFromProperty(NameSelectors.Title.Name); } - private string? GetDescription(TItem? item) + internal string? GetDescription(TItem? item) { if (item is null) return null; @@ -760,7 +783,7 @@ private void SetItemKey(TItem item, string value) return item.GetValueFromProperty(NameSelectors.Description.Name); } - private bool GetIsEnabled(TItem? item) + internal bool GetIsEnabled(TItem? item) { if (item is null) return false; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListOption.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListOption.cs index c6f060f6e2..ad8c7a4161 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListOption.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListOption.cs @@ -79,6 +79,13 @@ public partial class BitAccordionListOption : ComponentBase, IAsyncDisposable [Parameter] public string? Title { get; set; } + internal void InternalStateHasChanged() + { + StateHasChanged(); + } + + + protected override async Task OnInitializedAsync() { if (Parent is not null) @@ -89,6 +96,18 @@ protected override async Task OnInitializedAsync() await base.OnInitializedAsync(); } + // Renders the option's item in place, so the rendered order of the items always follows the + // markup order of the options, even when an option is added or removed conditionally later on. + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (Parent is null) return; + + builder.OpenComponent<_BitAccordionListItem>(0); + builder.AddComponentParameter(1, nameof(_BitAccordionListItem.AccordionList), Parent); + builder.AddComponentParameter(2, nameof(_BitAccordionListItem.Item), this); + builder.CloseComponent(); + } + public async ValueTask DisposeAsync() { await DisposeAsync(true); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor new file mode 100644 index 0000000000..0140f10a7a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor @@ -0,0 +1,26 @@ +@namespace Bit.BlazorUI +@typeparam TItem where TItem : class + +@{ + var isEnabled = AccordionList.IsEnabled && AccordionList.GetIsEnabled(Item); + var headerTemplate = AccordionList.GetItemHeaderTemplate(Item); +} + + + @AccordionList.GetItemBody(Item) + diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor.cs new file mode 100644 index 0000000000..925952b803 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor.cs @@ -0,0 +1,8 @@ +namespace Bit.BlazorUI; + +public partial class _BitAccordionListItem : ComponentBase where TItem : class +{ + [Parameter] public TItem Item { get; set; } = default!; + + [Parameter] public BitAccordionList AccordionList { get; set; } = default!; +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor index fd067b0edd..96ab173755 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor @@ -2,50 +2,24 @@ @inherits BitComponentBase @typeparam TItem - - - -
- @for (int i = 0; i < _items.Count; i++) + @if (Options is not null || ChildContent is not null) { - var item = _items[i]; - var isEnabled = IsEnabled && GetIsEnabled(item); - var template = GetTemplate(item); - + + @(Options ?? ChildContent) + + } + else + { + @for (int i = 0; i < _items.Count; i++) + { + var item = _items[i]; + <_BitButtonGroupItem @key="@(GetItemKey(item) ?? $"{UniqueId}-{i}")" ButtonGroup="this" Item="item" /> + } }
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor.cs index d1dd16fcba..944b3a5038 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor.cs @@ -7,6 +7,7 @@ namespace Bit.BlazorUI; /// public partial class BitButtonGroup : BitComponentBase where TItem : class { + private int _optionKeySeed; private TItem? _toggleItem; private List _items = []; private string? _internalToggleKey; @@ -127,7 +128,15 @@ internal void RegisterOption(BitButtonGroupOption option) { if (option.Key.HasNoValue()) { - option.Key = _items.Count.ToString(); + // Use a monotonic seed so keys stay unique even after removals (a _items.Count-based key can + // collide with an existing one once an option is removed), and guard against colliding with + // any explicitly supplied keys. + var key = (_optionKeySeed++).ToString(); + while (_items.Any(i => GetItemKey(i) == key)) + { + key = (_optionKeySeed++).ToString(); + } + option.Key = key; } var item = (option as TItem)!; @@ -158,7 +167,17 @@ internal void RegisterOption(BitButtonGroupOption option) internal void UnregisterOption(BitButtonGroupOption option) { - _items.Remove((option as TItem)!); + var item = (option as TItem)!; + + // When the removed option is the currently toggled one, clear the toggle state and the bound + // key so they don't keep referencing an option that no longer exists. + if (_toggleItem == item) + { + _toggleItem = null; + _ = AssignToggleKey(null); + } + + _items.Remove(item); StateHasChanged(); } @@ -220,7 +239,9 @@ protected override void RegisterCssStyles() protected override async Task OnInitializedAsync() { - _items = Items is not null ? [.. Items] : []; + // Only seed _items from Items for the Items API; in the options/child-content path the options + // register themselves, so it must start empty. + _items = (ChildContent is null && Options is null && Items is not null) ? [.. Items] : []; if (Toggle && Items is not null && Items.Any()) { @@ -248,19 +269,14 @@ protected override async Task OnInitializedAsync() protected override async Task OnParametersSetAsync() { - if (ChildContent is null && Items is not null && Items.Any()) + if (ChildContent is null && Options is null && Items is not null && Items.Any()) { - if (_oldItems is null || Items.SequenceEqual(_oldItems) is false) + if (_oldItems is null || (ReferenceEquals(Items, _oldItems) is false && Items.SequenceEqual(_oldItems) is false)) { _oldItems = Items; _items = [.. Items]; - for (int i = 0; i < _items.Count; i++) - { - if (GetItemKey(_items.ElementAt(i)).HasValue()) continue; - - SetItemKey(_items.ElementAt(i), i.ToString()); - } + AssignItemKeys(); } } @@ -275,12 +291,58 @@ protected override async Task OnParametersSetAsync() } } + // Options render their items themselves and Blazor skips re-rendering them when only the + // button group's own parameters (Styles, Toggle, IconOnly, ...) change, so push a re-render to each one. + RefreshOptions(); + await base.OnParametersSetAsync(); } - private async Task HandleOnItemClick(TItem item) + private void RefreshOptions() + { + // In the Items API there are no registered options, so there is nothing to refresh. + if ((Options ?? ChildContent) is null) return; + + foreach (var item in _items) + { + (item as BitButtonGroupOption)?.InternalStateHasChanged(); + } + } + + private void AssignItemKeys() + { + // Collect the explicit keys first so the auto-generated keys never collide with them. + var usedKeys = new HashSet(); + foreach (var item in _items) + { + var key = GetItemKey(item); + if (key.HasValue()) usedKeys.Add(key!); + } + + for (int i = 0; i < _items.Count; i++) + { + var item = _items[i]; + if (GetItemKey(item).HasValue()) continue; + + // Start from the loop index and increment until a non-colliding key is found so the + // result stays deterministic across renders while remaining unique. + var suffix = i; + var candidate = suffix.ToString(); + while (usedKeys.Contains(candidate)) + { + candidate = (++suffix).ToString(); + } + + SetItemKey(item, candidate); + usedKeys.Add(candidate); + } + } + + + + internal async Task HandleOnItemClick(TItem item) { if (GetIsEnabled(item) is false) return; @@ -294,10 +356,8 @@ private async Task HandleOnItemClick(TItem item) { await buttonGroupOption.OnClick.InvokeAsync(buttonGroupOption); } - else + else if (NameSelectors is not null) { - if (NameSelectors is null) return; - if (NameSelectors.OnClick.Selector is not null) { NameSelectors.OnClick.Selector!(item)?.Invoke(item); @@ -311,7 +371,7 @@ private async Task HandleOnItemClick(TItem item) await UpdateItemToggle(item); } - private string? GetItemClass(TItem item) + internal string? GetItemClass(TItem item) { List classes = ["bit-btg-itm"]; @@ -344,7 +404,7 @@ private async Task HandleOnItemClick(TItem item) return string.Join(' ', classes); } - private string? GetItemStyle(TItem item) + internal string? GetItemStyle(TItem item) { List styles = []; @@ -367,7 +427,7 @@ private async Task HandleOnItemClick(TItem item) return string.Join(';', styles); } - private string? GetItemText(TItem item) + internal string? GetItemText(TItem item) { if (IconOnly) return null; @@ -394,7 +454,7 @@ private async Task HandleOnItemClick(TItem item) return GetText(item); } - private string? GetItemTitle(TItem item) + internal string? GetItemTitle(TItem item) { if (Toggle) { @@ -419,7 +479,7 @@ private async Task HandleOnItemClick(TItem item) return GetTitle(item); } - private BitIconInfo? GetItemIcon(TItem item) + internal BitIconInfo? GetItemIcon(TItem item) { if (Toggle) { @@ -475,6 +535,12 @@ private async Task UpdateItemToggle(TItem? item, bool isToggled = true) await AssignToggleKey(toggleKey); await OnToggleChange.InvokeAsync(item); + + // A toggle change affects the rendering of the previously and newly toggled items, but the click + // handler now runs on the clicked item's renderer, so both the parent (Items API) and the + // registered options need an explicit re-render. + RefreshOptions(); + StateHasChanged(); } private bool IsItemToggled(TItem item) @@ -708,7 +774,7 @@ private void SetItemKey(TItem item, string value) return item.GetValueFromProperty(NameSelectors.OffIconName.Name); } - private bool GetIsEnabled(TItem? item) + internal bool GetIsEnabled(TItem? item) { if (item is null) return false; @@ -756,7 +822,7 @@ private bool GetIsEnabled(TItem? item) return item.GetValueFromProperty(NameSelectors.Style.Name); } - private RenderFragment? GetTemplate(TItem? item) + internal RenderFragment? GetTemplate(TItem? item) { if (item is null) return null; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroupOption.cs b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroupOption.cs index 51615525c4..99a138376c 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroupOption.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroupOption.cs @@ -140,13 +140,32 @@ public partial class BitButtonGroupOption : ComponentBase, IDisposable + internal void InternalStateHasChanged() + { + StateHasChanged(); + } + + + protected override async Task OnInitializedAsync() { - Parent.RegisterOption(this); + Parent?.RegisterOption(this); await base.OnInitializedAsync(); } + // Renders the option's item in place, so the rendered order of the items always follows the + // markup order of the options, even when an option is added or removed conditionally later on. + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (Parent is null) return; + + builder.OpenComponent<_BitButtonGroupItem>(0); + builder.AddComponentParameter(1, nameof(_BitButtonGroupItem.ButtonGroup), Parent); + builder.AddComponentParameter(2, nameof(_BitButtonGroupItem.Item), this); + builder.CloseComponent(); + } + public void Dispose() { Dispose(true); @@ -157,7 +176,7 @@ protected virtual void Dispose(bool disposing) { if (disposing is false || _disposed) return; - Parent.UnregisterOption(this); + Parent?.UnregisterOption(this); _disposed = true; } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/_BitButtonGroupItem.razor b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/_BitButtonGroupItem.razor new file mode 100644 index 0000000000..723483917d --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/_BitButtonGroupItem.razor @@ -0,0 +1,39 @@ +@namespace Bit.BlazorUI +@typeparam TItem where TItem : class + +@{ + var isEnabled = ButtonGroup.IsEnabled && ButtonGroup.GetIsEnabled(Item); + var template = ButtonGroup.GetTemplate(Item); +} + + diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/_BitButtonGroupItem.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/_BitButtonGroupItem.razor.cs new file mode 100644 index 0000000000..f7f9285c32 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/_BitButtonGroupItem.razor.cs @@ -0,0 +1,8 @@ +namespace Bit.BlazorUI; + +public partial class _BitButtonGroupItem where TItem : class +{ + [Parameter] public TItem Item { get; set; } = default!; + + [Parameter] public BitButtonGroup ButtonGroup { get; set; } = default!; +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButton.razor b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButton.razor index 75dc2e7f8e..2dc19147f2 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButton.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButton.razor @@ -2,10 +2,6 @@ @inherits BitComponentBase @typeparam TItem - - - - @{ var _selectedItem = Sticky ? SelectedItem : null; } @@ -100,44 +96,19 @@ style="@Styles?.Callout" class="bit-mnb-cal @GetCalloutCss() @Classes?.Callout"> diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButton.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButton.razor.cs index 1f51715736..f074b342b3 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButton.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButton.razor.cs @@ -8,7 +8,7 @@ namespace Bit.BlazorUI; public partial class BitMenuButton : BitComponentBase where TItem : class { private List _items = []; - private BitButtonType _buttonType; + internal BitButtonType _buttonType; private string _calloutId = default!; private string _overlayId = default!; private IEnumerable _oldItems = default!; @@ -162,6 +162,7 @@ public partial class BitMenuButton : BitComponentBase where TItem : class /// Determines the current selected item that acts as the header item. /// [Parameter, ResetClassBuilder, TwoWayBound] + [CallOnSet(nameof(OnSetSelectedItem))] public TItem? SelectedItem { get; set; } /// @@ -331,7 +332,11 @@ protected override async Task OnParametersSetAsync() _buttonType = ButtonType ?? (EditContext is null ? BitButtonType.Button : BitButtonType.Submit); - if (ChildContent is not null || Items.Any() is false || Items == _oldItems) return; + // Options render their items themselves and Blazor skips re-rendering them when only the + // menu button's own parameters (Styles, Sticky, ItemTemplate, ...) change, so push a re-render to each one. + RefreshOptions(); + + if (ChildContent is not null || Options is not null || Items.Any() is false || Items == _oldItems) return; _oldItems = Items; _items = [.. Items]; @@ -365,7 +370,7 @@ protected override void OnAfterRender(bool firstRender) - private string? GetClass(TItem? item) + internal string? GetClass(TItem? item) { if (item is null) return null; @@ -389,7 +394,7 @@ protected override void OnAfterRender(bool firstRender) return item.GetValueFromProperty(NameSelectors.Class.Name); } - private BitIconInfo? GetIcon(TItem? item) + internal BitIconInfo? GetIcon(TItem? item) { if (item is null) return null; @@ -428,7 +433,7 @@ protected override void OnAfterRender(bool firstRender) return BitIconInfo.From(icon, iconName); } - private bool GetIsEnabled(TItem? item) + internal bool GetIsEnabled(TItem? item) { if (item is null) return false; @@ -498,7 +503,7 @@ private bool GetIsSelected(TItem? item) return item.GetValueFromProperty(NameSelectors.Key.Name); } - private string? GetStyle(TItem? item) + internal string? GetStyle(TItem? item) { if (item is null) return null; @@ -522,7 +527,7 @@ private bool GetIsSelected(TItem? item) return item.GetValueFromProperty(NameSelectors.Style.Name); } - private RenderFragment? GetTemplate(TItem? item) + internal RenderFragment? GetTemplate(TItem? item) { if (item is null) return null; @@ -546,7 +551,7 @@ private bool GetIsSelected(TItem? item) return item.GetValueFromProperty?>(NameSelectors.Template.Name); } - private string? GetText(TItem? item) + internal string? GetText(TItem? item) { if (item is null) return null; @@ -600,12 +605,16 @@ private async Task HandleOnHeaderClick(TItem? item) } } - private async Task HandleOnItemClick(TItem item) + internal async Task HandleOnItemClick(TItem item) { if (IsEnabled is false || GetIsEnabled(item) is false) return; await CloseCallout(); + // CloseCallout changes IsOpen but does not re-render the root itself, so refresh now to update + // the open-state classes even when the Sticky branch below returns early. + StateHasChanged(); + if (Sticky) { if (await AssignSelectedItem(item) is false) return; @@ -618,6 +627,10 @@ private async Task HandleOnItemClick(TItem item) await InvokeItemClick(item); } + + // The click handler runs on the clicked item's renderer, so the root element (open state + // classes) needs an explicit re-render here. + StateHasChanged(); } private async Task InvokeItemClick(TItem item) @@ -688,6 +701,25 @@ private void OnSetIsOpen() _ = ToggleCallout(); } + private void OnSetSelectedItem() + { + // The selected item affects both the sticky header (rendered by this component) and the + // items rendered by the options themselves, so re-render all of them. + RefreshOptions(); + StateHasChanged(); + } + + private void RefreshOptions() + { + // In the Items API there are no registered options, so there is nothing to refresh. + if ((Options ?? ChildContent) is null) return; + + foreach (var item in _items) + { + (item as BitMenuButtonOption)?.InternalStateHasChanged(); + } + } + private string GetItemKey(TItem item, string defaultKey) { return GetKey(item) ?? $"{UniqueId}-{defaultKey}"; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButtonOption.cs b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButtonOption.cs index 4d7072e599..f8ac45eca2 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButtonOption.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/BitMenuButtonOption.cs @@ -68,13 +68,32 @@ public class BitMenuButtonOption : ComponentBase, IDisposable [Parameter] public string? Text { get; set; } + internal void InternalStateHasChanged() + { + StateHasChanged(); + } + + + protected override async Task OnInitializedAsync() { - Parent.RegisterOption(this); + Parent?.RegisterOption(this); await base.OnInitializedAsync(); } + // Renders the option's item in place, so the rendered order of the items always follows the + // markup order of the options, even when an option is added or removed conditionally later on. + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (Parent is null) return; + + builder.OpenComponent<_BitMenuButtonItem>(0); + builder.AddComponentParameter(1, nameof(_BitMenuButtonItem.MenuButton), Parent); + builder.AddComponentParameter(2, nameof(_BitMenuButtonItem.Item), this); + builder.CloseComponent(); + } + public void Dispose() { Dispose(true); @@ -85,7 +104,7 @@ protected virtual void Dispose(bool disposing) { if (disposing is false || _disposed) return; - Parent.UnregisterOption(this); + Parent?.UnregisterOption(this); _disposed = true; } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/_BitMenuButtonItem.razor b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/_BitMenuButtonItem.razor new file mode 100644 index 0000000000..f666689be8 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/_BitMenuButtonItem.razor @@ -0,0 +1,39 @@ +@namespace Bit.BlazorUI +@typeparam TItem where TItem : class + +@if (MenuButton.Sticky is false || Item != MenuButton.SelectedItem) +{ + var template = MenuButton.GetTemplate(Item); + var isEnabled = MenuButton.IsEnabled && MenuButton.GetIsEnabled(Item); + + +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/_BitMenuButtonItem.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/_BitMenuButtonItem.razor.cs new file mode 100644 index 0000000000..78b215d733 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/MenuButton/_BitMenuButtonItem.razor.cs @@ -0,0 +1,8 @@ +namespace Bit.BlazorUI; + +public partial class _BitMenuButtonItem where TItem : class +{ + [Parameter] public TItem Item { get; set; } = default!; + + [Parameter] public BitMenuButton MenuButton { get; set; } = default!; +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor index 033fd7ef8c..f7c399f7ef 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor @@ -3,10 +3,6 @@ @typeparam TItem @typeparam TValue - - - -
-
- @foreach (var item in _items) +
+ @if (Options is not null || ChildContent is not null) + { + + @(Options ?? ChildContent) + + } + else { - <_BitChoiceGroupItem Item="item" ChoiceGroup="this" /> + @for (int i = 0; i < _items.Count; i++) + { + var item = _items[i]; + <_BitChoiceGroupItem @key="@($"{UniqueId}-{i}")" Item="item" ChoiceGroup="this" /> + } }
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor.cs index 5b238555a6..1383a26c06 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor.cs @@ -11,15 +11,31 @@ namespace Bit.BlazorUI; private List _items = []; private string _name = default!; private string _labelId = default!; + private bool _optionsOrderDirty; + private string _optionsContainerId = default!; private IEnumerable? _oldItems; + [Inject] private IJSRuntime _js { get; set; } = default!; + + + /// /// Id of an element to use as the aria label for the ChoiceGroup. /// [Parameter] public string? AriaLabelledBy { get; set; } + /// + /// Keeps the assigned Index of each option in sync with the markup order of the options, even when + /// an option is added, removed, or reordered conditionally after the first render (an option that + /// appears later registers itself at the end of the list regardless of its markup position). This is + /// achieved by reading the DOM order of the options after each render, so it adds a JS interop call + /// per render and is opt-in. It only affects the options API (ChildContent/Options); the items API + /// already follows the order of the Items collection. + /// + [Parameter] public bool AutoReorderOptions { get; set; } + /// /// The content of the ChoiceGroup, a list of BitChoiceGroupOption components. /// @@ -121,6 +137,7 @@ namespace Bit.BlazorUI; internal void RegisterOption(BitChoiceGroupOption option) { _items.Add((option as TItem)!); + _optionsOrderDirty = true; SetIndexItems(); @@ -131,28 +148,110 @@ internal void RegisterOption(BitChoiceGroupOption option) internal void UnregisterOption(BitChoiceGroupOption option) { + if (IsDisposed) return; + _items.Remove((option as TItem)!); + _optionsOrderDirty = true; + + SetIndexItems(); + + StateHasChanged(); + } + + // Reorders the registered options based on the DOM order of their rendered markers, since an option + // that gets conditionally rendered (or reordered) after the first render registers itself at the end + // of the items list, no matter where in the markup it is located. Opt-in via AutoReorderOptions. + internal void ReorderOptions(string[] orderedOptionIds) + { + if (orderedOptionIds.Length == 0) return; + + List ordered = new(_items.Count); + + foreach (var optionId in orderedOptionIds) + { + var item = _items.FirstOrDefault(i => (i as BitChoiceGroupOption)?._OptionId == optionId); + if (item is null || ordered.Contains(item)) continue; + + ordered.Add(item); + } + + if (ordered.Count == 0) return; + + ordered.AddRange(_items.Except(ordered)); + + if (ordered.SequenceEqual(_items)) return; + + _items = ordered; + + SetIndexItems(); StateHasChanged(); } + // Emits the marker attribute the DOM read-back uses to recover the markup order of the options. + // Only rendered when AutoReorderOptions is enabled (to avoid the extra attribute otherwise) and only + // for options (the items API keeps the Items collection order and needs no marker). + internal Dictionary? GetItemMarkerAttributes(TItem item) + { + if (AutoReorderOptions is false) return null; + if (item is not BitChoiceGroupOption option) return null; + + return new() { [BitChoiceGroupOption._OPTION_ID_ATTRIBUTE] = option._OptionId }; + } + protected override async Task OnInitializedAsync() { _name = $"BitChoiceGroup-{UniqueId}-input-name"; _labelId = $"BitChoiceGroup-{UniqueId}-label"; + _optionsContainerId = $"BitChoiceGroup-{UniqueId}-options-container"; InitDefaultValue(); await base.OnInitializedAsync(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (AutoReorderOptions is false) return; + if (ChildContent is null && Options is null) return; + if (_optionsOrderDirty is false) return; + + _optionsOrderDirty = false; + + try + { + var orderedOptionIds = await _js.BitUtilsGetChildrenAttributes(_optionsContainerId, BitChoiceGroupOption._OPTION_ID_ATTRIBUTE); + if (IsDisposed) return; + if (orderedOptionIds is not null) + { + ReorderOptions(orderedOptionIds); + } + } + catch (JSDisconnectedException) { } // the circuit is gone (e.g. the user navigated away), nothing to reorder + catch (JSException) { } // a JS-side failure while reading the marker order is not fatal, keep the current order + } + protected override void OnParametersSet() { base.OnParametersSet(); - if (ChildContent is not null || Items is null || Items.Any() is false) return; + // Opt-in: a pure reorder of existing options registers/unregisters nothing, so flag the DOM + // read-back to run after this render to detect it. The read-back only mutates when the order + // actually changed, so a stable order just costs one DOM read. + if (AutoReorderOptions && (ChildContent is not null || Options is not null)) + { + _optionsOrderDirty = true; + } + + // Options render their items themselves and Blazor skips re-rendering them when only the + // choice group's own parameters (Value, Styles, NoCircle, ...) change, so push a re-render to each one. + RefreshOptions(); + + if (ChildContent is not null || Options is not null || Items is null || Items.Any() is false) return; if (_oldItems is not null && Items.SequenceEqual(_oldItems)) return; @@ -265,7 +364,7 @@ internal async Task HandleClick(TItem item) await OnClick.InvokeAsync(item); } - internal async Task HandleChange(TItem item) + internal void HandleChange(TItem item) { if (IsEnabled is false || ReadOnly || GetIsEnabled(item) is false) return; @@ -273,9 +372,22 @@ internal async Task HandleChange(TItem item) CurrentValue = GetValue(item); + RefreshOptions(); + StateHasChanged(); } + private void RefreshOptions() + { + // In the Items API there are no registered options, so there is nothing to refresh. + if ((Options ?? ChildContent) is null) return; + + foreach (var item in _items) + { + (item as BitChoiceGroupOption)?.InternalStateHasChanged(); + } + } + internal bool GetIsCheckedItem(TItem item) { if (CurrentValue is null) return false; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupOption.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupOption.cs index 1a6c671230..571768cba9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupOption.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupOption.cs @@ -2,6 +2,10 @@ public partial class BitChoiceGroupOption : ComponentBase, IDisposable { + internal const string _OPTION_ID_ATTRIBUTE = "data-bit-chg-opt"; + + internal string _OptionId { get; } = BitShortId.NewId(); + private bool _disposed; [CascadingParameter] protected BitChoiceGroup, TValue> Parent { get; set; } = default!; @@ -103,13 +107,32 @@ public partial class BitChoiceGroupOption : ComponentBase, IDisposable + internal void InternalStateHasChanged() + { + StateHasChanged(); + } + + + protected override async Task OnInitializedAsync() { - Parent.RegisterOption(this); + Parent?.RegisterOption(this); await base.OnInitializedAsync(); } + // Renders the option's item in place, so the rendered order of the items always follows the + // markup order of the options, even when an option is added or removed conditionally later on. + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (Parent is null) return; + + builder.OpenComponent<_BitChoiceGroupItem, TValue>>(0); + builder.AddComponentParameter(1, nameof(_BitChoiceGroupItem, TValue>.ChoiceGroup), Parent); + builder.AddComponentParameter(2, nameof(_BitChoiceGroupItem, TValue>.Item), this); + builder.CloseComponent(); + } + public void Dispose() @@ -122,7 +145,7 @@ protected virtual void Dispose(bool disposing) { if (_disposed || disposing is false) return; - Parent.UnregisterOption(this); + Parent?.UnregisterOption(this); _disposed = true; } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor index c24a664563..83e17b8c67 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor @@ -9,7 +9,7 @@ var isChecked = ChoiceGroup.GetIsCheckedItem(Item); } -
+
[Parameter, TwoWayBound] + [CallOnSet(nameof(OnSetSelectedItem))] public TItem? SelectedItem { get; set; } /// @@ -120,14 +122,16 @@ public partial class BitNavBar : BitComponentBase where TItem : class internal void RegisterOption(BitNavBarOption option) { _items.Add((option as TItem)!); - SetSelectedItemByCurrentUrl(); + _selectionDirty = true; StateHasChanged(); } internal void UnregisterOption(BitNavBarOption option) { + if (IsDisposed) return; + _items.Remove((option as TItem)!); - SetSelectedItemByCurrentUrl(); + _selectionDirty = true; StateHasChanged(); } @@ -196,12 +200,52 @@ protected override async Task OnInitializedAsync() await base.OnInitializedAsync(); } + protected override void OnParametersSet() + { + // Options render their items themselves and Blazor skips re-rendering them when only the + // navbar's own parameters (Styles, IconOnly, ItemTemplate, ...) change, so push a re-render to each one. + RefreshOptions(); + + base.OnParametersSet(); + } + + protected override void OnAfterRender(bool firstRender) + { + // Each option flags a selection recompute as it registers instead of matching immediately, so + // registering n options collapses into a single match pass here rather than one O(n) pass each. + if (_selectionDirty) + { + _selectionDirty = false; + SetSelectedItemByCurrentUrl(); + RefreshOptions(); + } + + base.OnAfterRender(firstRender); + } + + private void OnSetSelectedItem() + { + RefreshOptions(); + } + + private void RefreshOptions() + { + // In the Items API there are no registered options, so there is nothing to refresh. + if ((Options ?? ChildContent) is null) return; + + foreach (var item in _items) + { + (item as BitNavBarOption)?.InternalStateHasChanged(); + } + } + private void OnLocationChanged(object? sender, LocationChangedEventArgs args) { SetSelectedItemByCurrentUrl(); + RefreshOptions(); StateHasChanged(); } @@ -222,6 +266,8 @@ private void OnSetParameters() _items = Items?.ToList() ?? []; _oldItems = Items; + + SetSelectedItemByCurrentUrl(); } private void OnSetMode() @@ -239,6 +285,7 @@ private async Task SetSelectedItem(TItem item) await OnSelectItem.InvokeAsync(item); + RefreshOptions(); StateHasChanged(); } @@ -247,7 +294,7 @@ private string GetItemKey(TItem item, string defaultKey) return GetKey(item) ?? $"{UniqueId}-{defaultKey}"; } - private async Task HandleOnClick(TItem item) + internal async Task HandleOnClick(TItem item) { if (GetIsEnabled(item) is false) return; @@ -262,7 +309,7 @@ private async Task HandleOnClick(TItem item) } } - private string GetItemCssStyle(TItem item) + internal string GetItemCssStyle(TItem item) { var itm = Styles?.Item; var style = GetStyle(item); @@ -270,7 +317,7 @@ private string GetItemCssStyle(TItem item) return $"{itm} {style} {selected}".Trim(); } - private string GetItemCssClass(TItem item, bool isEnabled) + internal string GetItemCssClass(TItem item, bool isEnabled) { var itm = Classes?.Item; var @class = GetClass(item); @@ -304,7 +351,7 @@ private string GetItemCssClass(TItem item, bool isEnabled) return item.GetValueFromProperty(NameSelectors.Class.Name); } - private BitIconInfo? GetIcon(TItem item) + internal BitIconInfo? GetIcon(TItem item) { if (item is BitNavBarItem navItem) { @@ -326,7 +373,7 @@ private string GetItemCssClass(TItem item, bool isEnabled) return item.GetValueFromProperty(NameSelectors.Icon.Name); } - private string? GetIconName(TItem item) + internal string? GetIconName(TItem item) { if (item is BitNavBarItem navItem) { @@ -348,7 +395,7 @@ private string GetItemCssClass(TItem item, bool isEnabled) return item.GetValueFromProperty(NameSelectors.IconName.Name); } - private bool GetIsEnabled(TItem item) + internal bool GetIsEnabled(TItem item) { if (item is BitNavBarItem navItem) { @@ -414,7 +461,7 @@ private bool GetIsEnabled(TItem item) return item.GetValueFromProperty(NameSelectors.Style.Name); } - private string? GetTarget(TItem item) + internal string? GetTarget(TItem item) { if (item is BitNavBarItem navItem) { @@ -436,7 +483,7 @@ private bool GetIsEnabled(TItem item) return item.GetValueFromProperty(NameSelectors.Target.Name); } - private RenderFragment? GetTemplate(TItem item) + internal RenderFragment? GetTemplate(TItem item) { if (item is BitNavBarItem navItem) { @@ -458,7 +505,7 @@ private bool GetIsEnabled(TItem item) return item.GetValueFromProperty?>(NameSelectors.Template.Name); } - private string? GetText(TItem item) + internal string? GetText(TItem item) { if (item is BitNavBarItem navItem) { @@ -480,7 +527,7 @@ private bool GetIsEnabled(TItem item) return item.GetValueFromProperty(NameSelectors.Text.Name); } - private string? GetTitle(TItem item) + internal string? GetTitle(TItem item) { if (item is BitNavBarItem navItem) { @@ -502,7 +549,7 @@ private bool GetIsEnabled(TItem item) return item.GetValueFromProperty(NameSelectors.Title.Name); } - private string? GetUrl(TItem item) + internal string? GetUrl(TItem item) { if (item is BitNavBarItem navItem) { diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Navs/NavBar/BitNavBarOption.cs b/src/BlazorUI/Bit.BlazorUI/Components/Navs/NavBar/BitNavBarOption.cs index b4c63bb15e..314cdceb8b 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Navs/NavBar/BitNavBarOption.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Navs/NavBar/BitNavBarOption.cs @@ -92,6 +92,13 @@ public partial class BitNavBarOption : ComponentBase, IDisposable + internal void InternalStateHasChanged() + { + StateHasChanged(); + } + + + protected override async Task OnInitializedAsync() { NavBar?.RegisterOption(this); @@ -99,6 +106,18 @@ protected override async Task OnInitializedAsync() await base.OnInitializedAsync(); } + // Renders the option's item in place, so the rendered order of the items always follows the + // markup order of the options, even when an option is added or removed conditionally later on. + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (NavBar is null) return; + + builder.OpenComponent<_BitNavBarItem>(0); + builder.AddComponentParameter(1, nameof(_BitNavBarItem.NavBar), NavBar); + builder.AddComponentParameter(2, nameof(_BitNavBarItem.Item), this); + builder.CloseComponent(); + } + public void Dispose() diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Navs/NavBar/_BitNavBarItem.razor b/src/BlazorUI/Bit.BlazorUI/Components/Navs/NavBar/_BitNavBarItem.razor new file mode 100644 index 0000000000..005ba7aabf --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Navs/NavBar/_BitNavBarItem.razor @@ -0,0 +1,43 @@ +@namespace Bit.BlazorUI +@typeparam TItem where TItem : class + +@{ + var url = NavBar.GetUrl(Item); + var template = NavBar.GetTemplate(Item); + var isEnabled = NavBar.GetIsEnabled(Item); + var href = (isEnabled && (NavBar.SelectedItem != Item || NavBar.Reselectable)) ? url : null; +} + +<_BitNavBarChild Href="@href" + Title="@NavBar.GetTitle(Item)" + Target="@NavBar.GetTarget(Item)" + Style="@NavBar.GetItemCssStyle(Item)" + Disabled="@(isEnabled is false)" + OnClick="() => NavBar.HandleOnClick(Item)" + Class="@NavBar.GetItemCssClass(Item, isEnabled)"> + @if (template is not null) + { + @template(Item) + } + else if (NavBar.ItemTemplate is not null) + { + @NavBar.ItemTemplate(Item) + } + else + { + var icon = NavBar.GetIcon(Item); + var iconName = NavBar.GetIconName(Item); + + icon = BitIconInfo.From(icon, iconName); + + @if (icon is not null) + { + + } + + @if (NavBar.IconOnly is false) + { + @NavBar.GetText(Item) + } + } + diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Navs/NavBar/_BitNavBarItem.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Navs/NavBar/_BitNavBarItem.razor.cs new file mode 100644 index 0000000000..9625ad7f8c --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Navs/NavBar/_BitNavBarItem.razor.cs @@ -0,0 +1,8 @@ +namespace Bit.BlazorUI; + +public partial class _BitNavBarItem where TItem : class +{ + [Parameter] public TItem Item { get; set; } = default!; + + [Parameter] public BitNavBar NavBar { get; set; } = default!; +} diff --git a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/UtilsJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/UtilsJsRuntimeExtensions.cs index b57f48cdf7..963696aa5d 100644 --- a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/UtilsJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/UtilsJsRuntimeExtensions.cs @@ -20,6 +20,12 @@ internal static ValueTask BitUtilsGetProperty(this IJSRuntime jsRuntime, } + internal static ValueTask BitUtilsGetChildrenAttributes(this IJSRuntime jsRuntime, string containerId, string attribute) + { + return jsRuntime.Invoke("BitBlazorUI.Utils.getChildrenAttributes", containerId, attribute); + } + + internal static ValueTask BitUtilsGetBoundingClientRect(this IJSRuntime jsRuntime, ElementReference element) { return jsRuntime.Invoke("BitBlazorUI.Utils.getBoundingClientRect", element); diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts index 796ee99413..c2a662e0ee 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Utils.ts @@ -83,6 +83,18 @@ } } + public static getChildrenAttributes(containerId: string, attribute: string): string[] { + const container = document.getElementById(containerId); + if (!container) return []; + + try { + return Array.from(container.querySelectorAll(`[${attribute}]`)).map(e => e.getAttribute(attribute) || ''); + } catch (e) { + console.error("BitBlazorUI.Utils.getChildrenAttributes:", e); + return []; + } + } + public static getBoundingClientRect(element: HTMLElement): Partial { if (!element) return {}; diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitButtonTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/Button/BitButtonTests.cs similarity index 99% rename from src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitButtonTests.cs rename to src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/Button/BitButtonTests.cs index 315f56db79..8ba643416a 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitButtonTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/Button/BitButtonTests.cs @@ -3,7 +3,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Bunit; -namespace Bit.BlazorUI.Tests.Components.Buttons; +namespace Bit.BlazorUI.Tests.Components.Buttons.Button; [TestClass] public class BitButtonTests : BunitTestContext diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupOptionsOrderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupOptionsOrderTest.razor new file mode 100644 index 0000000000..8a6b5e3129 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupOptionsOrderTest.razor @@ -0,0 +1,14 @@ +@using Bit.BlazorUI + + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupOptionsOrderTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupOptionsOrderTests.cs new file mode 100644 index 0000000000..06ec914da4 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupOptionsOrderTests.cs @@ -0,0 +1,30 @@ +using System.Linq; +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Buttons.ButtonGroup; + +[TestClass] +public class BitButtonGroupOptionsOrderTests : BunitTestContext +{ + [TestMethod] + public void BitButtonGroupShouldPreserveOptionsOrderWhenAnOptionIsAddedConditionally() + { + var component = RenderComponent(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + CollectionAssert.AreEqual(new[] { "First", "Middle", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + } + + private static string[] GetItemTexts(IRenderedComponent component) + { + return component.FindAll(".bit-btg-btx").Select(e => e.TextContent).ToArray(); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitButtonGroupTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupTests.cs similarity index 98% rename from src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitButtonGroupTests.cs rename to src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupTests.cs index d49fe98370..fb78349808 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitButtonGroupTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupTests.cs @@ -46,7 +46,6 @@ public void BitButtonGroupShouldInvokeOnItemClickAndItemOnClickAction() }); var btn = component.Find(".bit-btg-itm"); - Assert.IsNotNull(btn); btn.Click(); diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/MenuButton/BitMenuButtonOptionsOrderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/MenuButton/BitMenuButtonOptionsOrderTest.razor new file mode 100644 index 0000000000..b7fe418f3a --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/MenuButton/BitMenuButtonOptionsOrderTest.razor @@ -0,0 +1,14 @@ +@using Bit.BlazorUI + + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/MenuButton/BitMenuButtonOptionsOrderTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/MenuButton/BitMenuButtonOptionsOrderTests.cs new file mode 100644 index 0000000000..a2fef8fe8b --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/MenuButton/BitMenuButtonOptionsOrderTests.cs @@ -0,0 +1,30 @@ +using System.Linq; +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Buttons.MenuButton; + +[TestClass] +public class BitMenuButtonOptionsOrderTests : BunitTestContext +{ + [TestMethod] + public void BitMenuButtonShouldPreserveOptionsOrderWhenAnOptionIsAddedConditionally() + { + var component = RenderComponent(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + CollectionAssert.AreEqual(new[] { "First", "Middle", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + } + + private static string[] GetItemTexts(IRenderedComponent component) + { + return component.FindAll(".bit-mnb-itm .bit-mnb-btx").Select(e => e.TextContent.Trim()).ToArray(); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitMenuButtonTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/MenuButton/BitMenuButtonTests.cs similarity index 99% rename from src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitMenuButtonTests.cs rename to src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/MenuButton/BitMenuButtonTests.cs index 570c6d158b..113ff7603d 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitMenuButtonTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/MenuButton/BitMenuButtonTests.cs @@ -3,7 +3,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Bunit; -namespace Bit.BlazorUI.Tests.Components.Buttons; +namespace Bit.BlazorUI.Tests.Components.Buttons.MenuButton; [TestClass] public class BitMenuButtonTests : BunitTestContext diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitToggleButtonTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ToggleButton/BitToggleButtonTests.cs similarity index 99% rename from src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitToggleButtonTests.cs rename to src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ToggleButton/BitToggleButtonTests.cs index fad941e11c..3873f38004 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/BitToggleButtonTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ToggleButton/BitToggleButtonTests.cs @@ -1,7 +1,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Bunit; -namespace Bit.BlazorUI.Tests.Components.Buttons; +namespace Bit.BlazorUI.Tests.Components.Buttons.ToggleButton; [TestClass] public class BitToggleButtonTests : BunitTestContext diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTest.razor new file mode 100644 index 0000000000..7039e3c627 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTest.razor @@ -0,0 +1,14 @@ +@using Bit.BlazorUI + + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTests.cs new file mode 100644 index 0000000000..80f2947ed1 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTests.cs @@ -0,0 +1,30 @@ +using System.Linq; +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Extras.AccordionList; + +[TestClass] +public class BitAccordionListOptionsOrderTests : BunitTestContext +{ + [TestMethod] + public void BitAccordionListShouldPreserveOptionsOrderWhenAnOptionIsAddedConditionally() + { + var component = RenderComponent(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTitles(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + CollectionAssert.AreEqual(new[] { "First", "Middle", "Last" }, GetItemTitles(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTitles(component)); + } + + private static string[] GetItemTitles(IRenderedComponent component) + { + return component.FindAll(".bit-acd-ttl").Select(e => e.TextContent).ToArray(); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupAutoReorderOptionsTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupAutoReorderOptionsTests.cs new file mode 100644 index 0000000000..6eab7bc2cf --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupAutoReorderOptionsTests.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Inputs.ChoiceGroup; + +[TestClass] +public class BitChoiceGroupAutoReorderOptionsTests : BunitTestContext +{ + [TestMethod] + public void BitChoiceGroupShouldAssignMarkupOrderIndexWhenAutoReorderOptionsEnabled() + { + // With AutoReorderOptions the choice group reads the DOM order of the option markers via JS + // interop to keep each option's Index in sync with its markup position, so the test feeds the + // marker ids (read from the rendered markup, i.e. markup order) back as the result of that call. + var handler = Context.JSInterop.Setup("BitBlazorUI.Utils.getChildrenAttributes", _ => true); + + var component = RenderComponent(parameters => parameters.Add(p => p.ShowMiddle, false)); + + // The conditionally-shown option registers last, so without the reorder its Index would be 2 + // while it renders in the middle. The marker ids are supplied only after it is rendered, so the + // pending read-back completes with the full, correctly ordered (markup order) set of ids. + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + handler.SetResult(GetMarkerIds(component)); + + component.WaitForAssertion(() => + { + var indexByText = GetIndexByText(component); + Assert.AreEqual(0, indexByText["First"]); + Assert.AreEqual(1, indexByText["Middle"]); + Assert.AreEqual(2, indexByText["Last"]); + }); + } + + private static string[] GetMarkerIds(IRenderedComponent component) + { + return component.FindAll("[data-bit-chg-opt]").Select(e => e.GetAttribute("data-bit-chg-opt")!).ToArray(); + } + + private static Dictionary GetIndexByText(IRenderedComponent component) + { + return component.FindComponents>() + .ToDictionary(c => c.Instance.Text!, c => c.Instance.Index); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupAutoReorderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupAutoReorderTest.razor new file mode 100644 index 0000000000..558106467f --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupAutoReorderTest.razor @@ -0,0 +1,14 @@ +@using Bit.BlazorUI + + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupOptionsOrderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupOptionsOrderTest.razor new file mode 100644 index 0000000000..573f448789 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupOptionsOrderTest.razor @@ -0,0 +1,14 @@ +@using Bit.BlazorUI + + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupOptionsOrderTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupOptionsOrderTests.cs new file mode 100644 index 0000000000..d061c329b8 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/ChoiceGroup/BitChoiceGroupOptionsOrderTests.cs @@ -0,0 +1,30 @@ +using System.Linq; +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Inputs.ChoiceGroup; + +[TestClass] +public class BitChoiceGroupOptionsOrderTests : BunitTestContext +{ + [TestMethod] + public void BitChoiceGroupShouldPreserveOptionsOrderWhenAnOptionIsAddedConditionally() + { + var component = RenderComponent(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + CollectionAssert.AreEqual(new[] { "First", "Middle", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + } + + private static string[] GetItemTexts(IRenderedComponent component) + { + return component.FindAll(".bit-chg-itx").Select(e => e.TextContent).ToArray(); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Dropdown/BitDropdownOptionsOrderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Dropdown/BitDropdownOptionsOrderTest.razor new file mode 100644 index 0000000000..74b5b435be --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Dropdown/BitDropdownOptionsOrderTest.razor @@ -0,0 +1,14 @@ +@using Bit.BlazorUI + + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Dropdown/BitDropdownOptionsOrderTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Dropdown/BitDropdownOptionsOrderTests.cs new file mode 100644 index 0000000000..9208b17fd9 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Dropdown/BitDropdownOptionsOrderTests.cs @@ -0,0 +1,30 @@ +using System.Linq; +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Inputs.Dropdown; + +[TestClass] +public class BitDropdownOptionsOrderTests : BunitTestContext +{ + [TestMethod] + public void BitDropdownShouldPreserveOptionsOrderWhenAnOptionIsAddedConditionally() + { + var component = RenderComponent(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + CollectionAssert.AreEqual(new[] { "First", "Middle", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + } + + private static string[] GetItemTexts(IRenderedComponent component) + { + return component.FindAll(".bit-drp-itm").Select(e => e.TextContent.Trim()).ToArray(); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Lists/Timeline/BitTimelineOptionsOrderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Lists/Timeline/BitTimelineOptionsOrderTest.razor new file mode 100644 index 0000000000..c2092ece65 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Lists/Timeline/BitTimelineOptionsOrderTest.razor @@ -0,0 +1,14 @@ +@using Bit.BlazorUI + + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Lists/Timeline/BitTimelineOptionsOrderTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Lists/Timeline/BitTimelineOptionsOrderTests.cs new file mode 100644 index 0000000000..dfaa9d7796 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Lists/Timeline/BitTimelineOptionsOrderTests.cs @@ -0,0 +1,30 @@ +using System.Linq; +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Lists.Timeline; + +[TestClass] +public class BitTimelineOptionsOrderTests : BunitTestContext +{ + [TestMethod] + public void BitTimelineShouldPreserveOptionsOrderWhenAnOptionIsAddedConditionally() + { + var component = RenderComponent(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + CollectionAssert.AreEqual(new[] { "First", "Middle", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + } + + private static string[] GetItemTexts(IRenderedComponent component) + { + return component.FindAll(".bit-tln-pcn .bit-tln-ttx").Select(e => e.TextContent).ToArray(); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbAutoReorderOptionsTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbAutoReorderOptionsTests.cs new file mode 100644 index 0000000000..ed59709d21 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbAutoReorderOptionsTests.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Navs.Breadcrumb; + +[TestClass] +public class BitBreadcrumbAutoReorderOptionsTests : BunitTestContext +{ + [TestMethod] + public void BitBreadcrumbShouldReorderOptionsOnPureMoveWhenAutoReorderOptionsEnabled() + { + // A pure reorder of keyed options moves the existing option components (and their markers) without + // registering or unregistering anything. With AutoReorderOptions the breadcrumb still re-reads the + // DOM marker order after the render, so the displayed order follows the new markup order. + var handler = Context.JSInterop.Setup("BitBlazorUI.Utils.getChildrenAttributes", _ => true); + + var component = RenderComponent( + parameters => parameters.Add(p => p.Items, ["A", "B", "C"])); + + component.WaitForAssertion(() => CollectionAssert.AreEqual(new[] { "A", "B", "C" }, GetItemTexts(component))); + + // Re-sort the source list; the @key'd options are moved, not recreated (no register/unregister). + component.Render(parameters => parameters.Add(p => p.Items, ["C", "A", "B"])); + + handler.SetResult(GetMarkerIds(component)); + + component.WaitForAssertion(() => CollectionAssert.AreEqual(new[] { "C", "A", "B" }, GetItemTexts(component))); + } + + private static string[] GetItemTexts(IRenderedComponent component) + { + return component.FindAll(".bit-brc-itm").Select(e => e.TextContent.Trim()).ToArray(); + } + + private static string[] GetMarkerIds(IRenderedComponent component) + { + return component.FindAll("[data-bit-brc-opt]").Select(e => e.GetAttribute("data-bit-brc-opt")!).ToArray(); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbAutoReorderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbAutoReorderTest.razor new file mode 100644 index 0000000000..c4094925f2 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbAutoReorderTest.razor @@ -0,0 +1,13 @@ +@using System.Collections.Generic +@using Bit.BlazorUI + + + @foreach (var item in Items) + { + + } + + +@code { + [Parameter] public List Items { get; set; } = []; +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbOptionsOrderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbOptionsOrderTest.razor new file mode 100644 index 0000000000..181d1e6be0 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbOptionsOrderTest.razor @@ -0,0 +1,14 @@ +@using Bit.BlazorUI + + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbOptionsOrderTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbOptionsOrderTests.cs new file mode 100644 index 0000000000..16248fd812 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Breadcrumb/BitBreadcrumbOptionsOrderTests.cs @@ -0,0 +1,46 @@ +using System.Linq; +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Navs.Breadcrumb; + +[TestClass] +public class BitBreadcrumbOptionsOrderTests : BunitTestContext +{ + [TestMethod] + public void BitBreadcrumbShouldPreserveOptionsOrderWhenAnOptionIsAddedConditionally() + { + // The breadcrumb reads the DOM order of the option markers via JS interop to keep the order of + // its registered items in sync with the markup order of the options, so the test provides the + // marker ids (read from the rendered markup) as the result of that JS call. The result is set + // only after the conditional option is rendered, so the earlier (pending) invocations complete + // with the full, correctly ordered set of marker ids. + var handler = Context.JSInterop.Setup("BitBlazorUI.Utils.getChildrenAttributes", _ => true); + + var component = RenderComponent(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + handler.SetResult(GetMarkerIds(component)); + + component.WaitForAssertion(() => CollectionAssert.AreEqual(new[] { "First", "Middle", "Last" }, GetItemTexts(component))); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, false)); + + handler.SetResult(GetMarkerIds(component)); + + component.WaitForAssertion(() => CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTexts(component))); + } + + private static string[] GetItemTexts(IRenderedComponent component) + { + return component.FindAll(".bit-brc-itm").Select(e => e.TextContent.Trim()).ToArray(); + } + + private static string[] GetMarkerIds(IRenderedComponent component) + { + return component.FindAll("[data-bit-brc-opt]").Select(e => e.GetAttribute("data-bit-brc-opt")!).ToArray(); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavOptionsOrderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavOptionsOrderTest.razor new file mode 100644 index 0000000000..50b60ce88e --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavOptionsOrderTest.razor @@ -0,0 +1,21 @@ +@using Bit.BlazorUI + + + + + @if (ShowMiddle) + { + + } + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavOptionsOrderTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavOptionsOrderTests.cs new file mode 100644 index 0000000000..42148477f5 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavOptionsOrderTests.cs @@ -0,0 +1,30 @@ +using System.Linq; +using Bunit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Navs.Nav; + +[TestClass] +public class BitNavOptionsOrderTests : BunitTestContext +{ + [TestMethod] + public void BitNavShouldPreserveOptionsOrderWhenAnOptionIsAddedConditionally() + { + var component = RenderComponent(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Child1", "Child2", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + CollectionAssert.AreEqual(new[] { "First", "Child1", "ChildMiddle", "Child2", "Middle", "Last" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "First", "Child1", "Child2", "Last" }, GetItemTexts(component)); + } + + private static string[] GetItemTexts(IRenderedComponent component) + { + return component.FindAll(".bit-nav-itx").Select(e => e.TextContent).ToArray(); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarAutomaticSelectionTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarAutomaticSelectionTests.cs new file mode 100644 index 0000000000..5cbf3edc2e --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarAutomaticSelectionTests.cs @@ -0,0 +1,29 @@ +using System.Linq; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Navs.NavBar; + +[TestClass] +public class BitNavBarAutomaticSelectionTests : BunitTestContext +{ + [TestMethod] + public void BitNavBarShouldSelectItemMatchingCurrentUrlInAutomaticMode() + { + // In Automatic mode the options request the URL match as they register, but the match is now + // deferred to a single pass in OnAfterRender (to avoid an O(n^2) match-per-registration mount). + // This verifies that deferred pass still selects the option whose Url matches the current URL. + var navigationManager = Services.GetRequiredService(); + navigationManager.NavigateTo("/profile"); + + var component = RenderComponent(); + + var items = component.FindAll(".bit-nbr-itm"); + Assert.AreEqual(3, items.Count); + Assert.IsFalse(items[0].ClassList.Contains("bit-nbr-sel")); + Assert.IsTrue(items[1].ClassList.Contains("bit-nbr-sel")); + Assert.IsFalse(items[2].ClassList.Contains("bit-nbr-sel")); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarAutomaticTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarAutomaticTest.razor new file mode 100644 index 0000000000..434a75fb24 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarAutomaticTest.razor @@ -0,0 +1,7 @@ +@using Bit.BlazorUI + + + + + + diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarOptionsTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarOptionsTest.razor new file mode 100644 index 0000000000..dba8970ba5 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarOptionsTest.razor @@ -0,0 +1,16 @@ +@using Bit.BlazorUI + + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } + + [Parameter] public BitNavMode Mode { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarTests.cs index dd3c63ca99..e89ca218de 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/NavBar/BitNavBarTests.cs @@ -1,4 +1,5 @@ -using Bunit; +using System.Linq; +using Bunit; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Bit.BlazorUI.Tests.Components.Navs.NavBar; @@ -101,6 +102,63 @@ public void BitNavBarShouldRespectColor(BitColor? color, string expectedClass) Assert.IsTrue(root.ClassList.Contains(expectedClass)); } + [TestMethod] + [DataRow(BitNavMode.Automatic)] + [DataRow(BitNavMode.Manual)] + public void BitNavBarShouldPreserveOptionsOrderWhenAnOptionIsAddedConditionally(BitNavMode mode) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Mode, mode); + parameters.Add(p => p.ShowMiddle, false); + }); + + CollectionAssert.AreEqual(new[] { "Home", "Settings" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + CollectionAssert.AreEqual(new[] { "Home", "Profile", "Settings" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, false)); + + CollectionAssert.AreEqual(new[] { "Home", "Settings" }, GetItemTexts(component)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + CollectionAssert.AreEqual(new[] { "Home", "Profile", "Settings" }, GetItemTexts(component)); + } + + [TestMethod] + public void BitNavBarShouldUpdateOptionsSelectedStateOnClickInManualMode() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Mode, BitNavMode.Manual); + parameters.Add(p => p.ShowMiddle, true); + }); + + var items = component.FindAll(".bit-nbr-itm"); + Assert.AreEqual(3, items.Count); + + items[1].Click(); + + items = component.FindAll(".bit-nbr-itm"); + Assert.IsFalse(items[0].ClassList.Contains("bit-nbr-sel")); + Assert.IsTrue(items[1].ClassList.Contains("bit-nbr-sel")); + Assert.IsFalse(items[2].ClassList.Contains("bit-nbr-sel")); + + items[2].Click(); + + items = component.FindAll(".bit-nbr-itm"); + Assert.IsFalse(items[1].ClassList.Contains("bit-nbr-sel")); + Assert.IsTrue(items[2].ClassList.Contains("bit-nbr-sel")); + } + + private static string[] GetItemTexts(IRenderedComponent component) + { + return component.FindAll(".bit-nbr-txt").Select(e => e.TextContent).ToArray(); + } + [TestMethod] public void BitNavBarShouldRespectHtmlAttributes() {