diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor index f7c399f7ef..b89dc27263 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor @@ -3,6 +3,8 @@ @typeparam TItem @typeparam TValue +@* The invalid state belongs to the choice as a whole, not to the option that happens to be focused, + so the group carries the aria-invalid too and not only the individual radio inputs of the base class. *@
+ aria-labelledby="@GetAriaLabelledBy()" + aria-describedby="@GetAriaDescribedBy()" + aria-required="@(Required ? "true" : null)" + aria-invalid="@(ValueInvalid is true ? "true" : null)" + aria-readonly="@(ReadOnly ? "true" : null)" + aria-disabled="@(IsEnabled ? null : "true")" + aria-orientation="@(Horizontal ? "horizontal" : "vertical")"> - + @if (HasLabel) + { + @* Not a label element: with no single input to point a "for" at, a label here would label + nothing; the group is named through aria-labelledby referencing this id instead. *@ +
+ @if (LabelTemplate is not null) + { + @LabelTemplate + } + else + { + @Label + } +
+ } + + @if (HasDescription) + { +
+ @if (DescriptionTemplate is not null) + { + @DescriptionTemplate + } + else + { + @Description + } +
+ }
@if (Options is not null || ChildContent is not null) 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 1383a26c06..2145c288e9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.razor.cs @@ -1,5 +1,4 @@ -using System.Text; -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; namespace Bit.BlazorUI; @@ -10,10 +9,13 @@ namespace Bit.BlazorUI; { private List _items = []; private string _name = default!; + private bool _autoFocusDone; private string _labelId = default!; private bool _optionsOrderDirty; + private bool _defaultValueApplied; + private TValue? _appliedDefaultValue; + private string _descriptionId = default!; private string _optionsContainerId = default!; - private IEnumerable? _oldItems; @@ -26,6 +28,13 @@ namespace Bit.BlazorUI; /// [Parameter] public string? AriaLabelledBy { get; set; } + /// + /// Determines if the ChoiceGroup is auto focused on first render, focusing its checked item, or its first + /// enabled item when nothing is checked. Nothing is focused when the ChoiceGroup is read-only or the + /// target item is disabled. + /// + [Parameter] public bool AutoFocus { 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 @@ -52,6 +61,31 @@ namespace Bit.BlazorUI; [Parameter, ResetClassBuilder] public BitColor? Color { get; set; } + /// + /// The description (helper text) of the ChoiceGroup, rendered under its label. The group references it + /// through its aria-describedby, so screen readers announce it along with the name of the group. + /// + [Parameter] public string? Description { get; set; } + + /// + /// Custom RenderFragment for the description (helper text) of the ChoiceGroup. + /// Takes precedence over when both are set. + /// + [Parameter] public RenderFragment? DescriptionTemplate { get; set; } + + /// + /// Expands the ChoiceGroup to the full width of its container instead of hugging its widest item. + /// In the horizontal layout the items also share that width equally. + /// + [Parameter, ResetClassBuilder] + public bool FullWidth { get; set; } + + /// + /// The gap between the items of the ChoiceGroup. + /// + [Parameter, ResetStyleBuilder] + public string? Gap { get; set; } + /// /// Renders the items in the ChoiceGroup horizontally. /// @@ -79,6 +113,11 @@ namespace Bit.BlazorUI; /// [Parameter] public RenderFragment? ItemPrefixTemplate { get; set; } + /// + /// Used to add a suffix to each item, rendered after the content of the item. + /// + [Parameter] public RenderFragment? ItemSuffixTemplate { get; set; } + /// /// Used to customize the label for the Item content. /// @@ -89,6 +128,21 @@ namespace Bit.BlazorUI; /// [Parameter] public string? Label { get; set; } + /// + /// The position of the content of each item relative to its radio circle. The default is + /// , which renders the circle first and the content after it. + /// Items rendered as image or icon tiles lay their own content out and ignore this parameter. + /// + /// + /// Replaces the removed Reversed parameter, which only offered the two horizontal positions. + /// Migrate an existing Reversed="true" (in either layout) to + /// LabelPosition="BitLabelPosition.Start"; Reversed="false" was the default and needs + /// no replacement. A binding of the form Reversed="@flag" becomes + /// LabelPosition="@(flag ? BitLabelPosition.Start : BitLabelPosition.End)". + /// + [Parameter, ResetClassBuilder] + public BitLabelPosition? LabelPosition { get; set; } + /// /// Custom RenderFragment for the label of the ChoiceGroup. /// @@ -105,21 +159,25 @@ namespace Bit.BlazorUI; [Parameter, ResetClassBuilder] public bool NoCircle { get; set; } + /// + /// Callback for when an item of the ChoiceGroup loses focus. + /// + [Parameter] public EventCallback OnBlur { get; set; } + /// /// Callback for when the option clicked. /// [Parameter] public EventCallback OnClick { get; set; } /// - /// Alias of ChildContent. + /// Callback for when an item of the ChoiceGroup receives focus. /// - [Parameter] public RenderFragment? Options { get; set; } + [Parameter] public EventCallback OnFocus { get; set; } /// - /// Reverses the label and radio button location. + /// Alias of ChildContent. /// - [Parameter, ResetClassBuilder] - public bool Reversed { get; set; } + [Parameter] public RenderFragment? Options { get; set; } /// /// The size of the BitChoiceGroup. @@ -143,6 +201,8 @@ internal void RegisterOption(BitChoiceGroupOption option) InitDefaultValue(); + UpdateIsSelected(); + StateHasChanged(); } @@ -205,6 +265,7 @@ protected override async Task OnInitializedAsync() { _name = $"BitChoiceGroup-{UniqueId}-input-name"; _labelId = $"BitChoiceGroup-{UniqueId}-label"; + _descriptionId = $"BitChoiceGroup-{UniqueId}-description"; _optionsContainerId = $"BitChoiceGroup-{UniqueId}-options-container"; InitDefaultValue(); @@ -251,16 +312,33 @@ protected override void OnParametersSet() // 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; + // A new DefaultValue has to be applied again, the previous one has already had its chance. + if (EqualityComparer.Default.Equals(DefaultValue, _appliedDefaultValue) is false) + { + _appliedDefaultValue = DefaultValue; + _defaultValueApplied = false; + } - if (_oldItems is not null && Items.SequenceEqual(_oldItems)) return; + if (ChildContent is null && Options is null) + { + // The comparison is against the items the component currently holds (not against the previously + // assigned collection), so an in-place mutation of the same collection instance is detected too, + // and clearing the collection actually clears the rendered items. + var items = Items ?? []; - _oldItems = Items; - _items = [.. Items]; + if (_items.SequenceEqual(items) is false) + { + _items = [.. items]; - SetIndexItems(); + SetIndexItems(); + } + } InitDefaultValue(); + + // Value can change without the items changing (one-way binding, a programmatic assignment, ...), + // so the IsSelected of every item is refreshed on each parameters set, not only when the items change. + UpdateIsSelected(); } protected override string RootElementClass => "bit-chg"; @@ -277,7 +355,18 @@ protected override void RegisterCssClasses() ClassBuilder.Register(() => Horizontal ? "bit-chg-hor" : string.Empty); - ClassBuilder.Register(() => Reversed ? "bit-chg-rvs" : string.Empty); + ClassBuilder.Register(() => LabelPosition switch + { + BitLabelPosition.Top => "bit-chg-ltp", + BitLabelPosition.Bottom => "bit-chg-lbm", + BitLabelPosition.Start => "bit-chg-lst", + BitLabelPosition.End => "bit-chg-led", + _ => "bit-chg-led" + }); + + ClassBuilder.Register(() => FullWidth ? "bit-chg-flw" : string.Empty); + + ClassBuilder.Register(() => ReadOnly ? "bit-chg-rdo" : string.Empty); ClassBuilder.Register(() => Color switch { @@ -313,6 +402,8 @@ protected override void RegisterCssClasses() protected override void RegisterCssStyles() { StyleBuilder.Register(() => Styles?.Root); + + StyleBuilder.Register(() => Gap.HasValue() ? $"--bit-chg-item-gap:{Gap}" : string.Empty); } protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) @@ -320,58 +411,113 @@ protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(fa + // Applies the DefaultValue of the uncontrolled mode at most once per assigned DefaultValue, so a later + // change of the items (or an option registering itself afterwards) cannot revert the user's own choice. private void InitDefaultValue() { - if (ValueHasBeenSet) - { - var item = _items.FirstOrDefault(item => EqualityComparer.Default.Equals(GetValue(item), Value)); - if (item is not null) - { - SetIsSelectedForSelectedItem(item); - } - } - else if (DefaultValue is not null) + if (ValueHasBeenSet || _defaultValueApplied || DefaultValue is null) return; + + var item = _items.FirstOrDefault(item => EqualityComparer.Default.Equals(GetValue(item), DefaultValue)); + if (item is null) return; + + _defaultValueApplied = true; + + Value = DefaultValue; + } + + private void SetIndexItems() + { + for (var i = 0; i < _items.Count; i++) { - var item = _items.FirstOrDefault(item => EqualityComparer.Default.Equals(GetValue(item), DefaultValue)); - if (item is not null) - { - SetIsSelectedForSelectedItem(item); - Value = DefaultValue; - } + SetIndex(_items[i], i); } } - private void SetIndexItems() + // The internal label element is only rendered (and therefore only worth referencing) when there is a + // label to show. Referencing it unconditionally would point the radiogroup at an empty element, and + // since aria-labelledby wins over aria-label that would leave the group without an accessible name. + internal bool HasLabel => LabelTemplate is not null || Label.HasValue(); + + private string? GetAriaLabelledBy() => AriaLabelledBy ?? (HasLabel ? _labelId : null); + + // Same reasoning as the label: the description element is only rendered when there is a description to + // show, so the reference is only emitted then. Left null otherwise, which lets an aria-describedby that + // the consumer splatted through HtmlAttributes survive instead of being overwritten with an empty value. + internal bool HasDescription => DescriptionTemplate is not null || Description.HasValue(); + + private string? GetAriaDescribedBy() => HasDescription ? _descriptionId : null; + + // The index is used instead of the value, because a value is free-form (it can contain spaces or any + // other character that is not valid in an id) and is not guaranteed to be unique among the items, + // while the id has to be a valid, unique IDREF for the label's "for" and for aria-describedby. + internal string? GetInputId(TItem item) => GetId(item) ?? $"ChoiceGroup-{UniqueId}-Input-{GetItemIndex(item)}"; + + // A custom item type is not guaranteed to carry the Index property the stamping writes to (the write + // silently no-ops without it), so only the two built-in types can trust the stamped value; a custom + // item falls back to its position in the collection, which is always available. + private int GetItemIndex(TItem item) => item switch { - if (_items.Any() is false) return; + BitChoiceGroupItem choiceGroupItem => choiceGroupItem.Index, + BitChoiceGroupOption choiceGroupOption => choiceGroupOption.Index, + _ => _items.FindIndex(i => ReferenceEquals(i, item)), + }; + + // Keeps the InputElement of the base class pointing at the input the Tab key would land on: the input + // of the checked item, or the first enabled one when nothing is checked (the browser skips a disabled + // radio when tabbing into the group). Called by each item after render; also performs the one-time AutoFocus. + internal async Task SetInputElement(TItem item, ElementReference inputElement) + { + if (GetIsCheckedItem(item) is false) + { + if (_items.Any(GetIsCheckedItem)) return; + if (ReferenceEquals(GetTabTargetItem(), item) is false) return; + } - for (var i = 0; i < _items.Count; i++) + InputElement = inputElement; + + if (AutoFocus && _autoFocusDone is false && ReadOnly is false && GetIsItemEnabled(item)) { - var index = i; - var item = _items[i]; - SetIndex(item, index); + _autoFocusDone = true; + + try + { + await InputElement.FocusAsync(); + } + catch (JSDisconnectedException) { } // the circuit is gone (e.g. the user navigated away), nothing to focus + catch (JSException) { } // the element is no longer in the document, failing to focus it is not fatal + catch (InvalidOperationException) { } // the element reference is detached from its renderer, same as above } } - private string GetAriaLabelledBy() => AriaLabelledBy ?? _labelId; + private TItem? GetTabTargetItem() => _items.FirstOrDefault(GetIsItemEnabled) ?? _items.FirstOrDefault(); - internal string? GetInputId(TItem item) => GetId(item) ?? $"ChoiceGroup-{UniqueId}-Input-{GetValue(item)}"; + internal async Task HandleFocus(TItem item) + { + await OnFocus.InvokeAsync(item); + } + + internal async Task HandleBlur(TItem item) + { + await OnBlur.InvokeAsync(item); + } internal async Task HandleClick(TItem item) { - if (IsEnabled is false || ReadOnly || GetIsEnabled(item) is false) return; + if (ReadOnly || GetIsItemEnabled(item) is false) return; await OnClick.InvokeAsync(item); } internal void HandleChange(TItem item) { - if (IsEnabled is false || ReadOnly || GetIsEnabled(item) is false) return; - - SetIsSelectedForSelectedItem(item); + if (ReadOnly || GetIsItemEnabled(item) is false) return; CurrentValue = GetValue(item); + // After the value has been written, so an assignment the base class rejects + // (a bound Value without a way to write it back) does not leave a wrong IsSelected behind. + UpdateIsSelected(); + RefreshOptions(); StateHasChanged(); @@ -395,69 +541,82 @@ internal bool GetIsCheckedItem(TItem item) return EqualityComparer.Default.Equals(GetValue(item), CurrentValue); } + // The parts are joined with a semicolon: they are separate declaration lists and a value that does not + // already end with one (a plain "color:red" of an item's Style) would otherwise merge into the next part. internal string GetItemContainerCssStyles(TItem item) { - StringBuilder cssStyle = new(); + List styles = []; - if (GetStyle(item).HasValue()) + var itemStyle = GetStyle(item); + if (itemStyle.HasValue()) { - cssStyle.Append(GetStyle(item)); + styles.Add(itemStyle!); } - if (string.IsNullOrEmpty(Styles?.ItemContainer) is false) + if (Styles?.ItemContainer.HasValue() ?? false) { - cssStyle.Append(' ').Append(Styles?.ItemContainer); + styles.Add(Styles.ItemContainer!); } - if (GetIsCheckedItem(item)) + if (GetIsCheckedItem(item) && (Styles?.ItemChecked.HasValue() ?? false)) { - cssStyle.Append(' ').Append(Styles?.ItemChecked); + styles.Add(Styles.ItemChecked!); } - return cssStyle.ToString(); + return string.Join(';', styles); } internal string GetItemContainerCssClasses(TItem item) { - StringBuilder cssClass = new("bit-chg-icn"); + List classes = ["bit-chg-icn"]; - if (GetClass(item).HasValue()) + var itemClass = GetClass(item); + if (itemClass.HasValue()) { - cssClass.Append(' ').Append(GetClass(item)); + classes.Add(itemClass!); } - if (string.IsNullOrEmpty(Classes?.ItemContainer) is false) + if (Classes?.ItemContainer.HasValue() ?? false) { - cssClass.Append(' ').Append(Classes?.ItemContainer); + classes.Add(Classes.ItemContainer!); } - if (ItemTemplate is not null) return cssClass.ToString(); - + // The checked and disabled markers are emitted for every rendering mode, including the templated + // ones, so Classes.ItemChecked stays in sync with Styles.ItemChecked and a custom template can + // style its checked and disabled states through the same hooks the built-in rendering uses. if (GetIsCheckedItem(item)) { - cssClass.Append(' ').Append("bit-chg-ich"); - cssClass.Append(' ').Append(Classes?.ItemChecked); - } + classes.Add("bit-chg-ich"); - if (ItemLabelTemplate is not null) return cssClass.ToString(); + if (Classes?.ItemChecked.HasValue() ?? false) + { + classes.Add(Classes.ItemChecked!); + } + } - if (IsEnabled is false || GetIsEnabled(item) is false) + if (GetIsItemEnabled(item) is false) { - cssClass.Append(' ').Append("bit-chg-ids"); + classes.Add("bit-chg-ids"); } - if (GetImageSrc(item).HasValue() || GetIcon(item) is not null) + // Only meaningful for the built-in item content, a template renders its own image or icon. + if (GetTemplate(item) is null && ItemTemplate is null && ItemLabelTemplate is null && + (GetImageSrc(item).HasValue() || GetIcon(item) is not null)) { - cssClass.Append(' ').Append("bit-chg-ihi"); + classes.Add("bit-chg-ihi"); } - return cssClass.ToString(); + return string.Join(' ', classes); } + // The tile layout only applies to the built-in image/icon markup of an item, so it follows the exact + // same conditions that markup is rendered under; a template renders its own content and lays it out itself. internal string GetItemLabelCssClasses(TItem item) { - var hasImageOrIcon = GetImageSrc(item).HasValue() || GetIcon(item) is not null; - return hasImageOrIcon && ItemLabelTemplate is null && Inline is false + if (Inline) return string.Empty; + if (GetTemplate(item) is not null || ItemTemplate is not null || ItemLabelTemplate is not null) return string.Empty; + + return GetImageSrc(item).HasValue() || GetIcon(item) is not null ? "bit-chg-ili" : string.Empty; } @@ -489,6 +648,11 @@ internal string GetName() return item.GetValueFromProperty(NameSelectors.AriaLabel.Name); } + /// + /// An item is only interactive when both the ChoiceGroup and the item itself are enabled. + /// + internal bool GetIsItemEnabled(TItem item) => IsEnabled && GetIsEnabled(item); + internal bool GetIsEnabled(TItem item) { if (item is BitChoiceGroupItem choiceGroupItem) @@ -592,26 +756,60 @@ internal bool GetIsEnabled(TItem item) return item.GetValueFromProperty(NameSelectors.ImageAlt.Name); } - internal BitImageSize GetImageSize(TItem item) + // Null when no size was provided, so the image keeps its intrinsic size instead of collapsing + // to the 0x0 an unset size would otherwise render as. + internal BitImageSize? GetImageSize(TItem item) { if (item is BitChoiceGroupItem choiceGroupItem) { - return choiceGroupItem.ImageSize ?? new BitImageSize(0, 0); + return choiceGroupItem.ImageSize; } if (item is BitChoiceGroupOption choiceGroupOption) { - return choiceGroupOption.ImageSize ?? new BitImageSize(0, 0); + return choiceGroupOption.ImageSize; } - if (NameSelectors is null) return new BitImageSize(0, 0); + if (NameSelectors is null) return null; if (NameSelectors.ImageSize.Selector is not null) { - return NameSelectors.ImageSize.Selector!(item) ?? new BitImageSize(0, 0); + return NameSelectors.ImageSize.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.ImageSize.Name); + } + + internal string GetImageWrapperCssStyles(TItem item) + { + var imageSize = GetImageSize(item); + + List styles = []; + + if (imageSize is not null) + { + styles.Add(FormattableString.Invariant($"width:{imageSize.Value.Width}px;height:{imageSize.Value.Height}px")); } - return item.GetValueFromProperty(NameSelectors.ImageSize.Name) ?? new BitImageSize(0, 0); + if (Styles?.ItemImageWrapper.HasValue() ?? false) + { + styles.Add(Styles.ItemImageWrapper!); + } + + return string.Join(';', styles); + } + + // The checked state only swaps the image when a dedicated one was provided, otherwise the item keeps + // showing its normal image instead of losing it as soon as it gets selected. + internal string? GetImageSrcForState(TItem item, bool isChecked) + { + var imageSrc = GetImageSrc(item); + + if (isChecked is false) return imageSrc; + + var selectedImageSrc = GetSelectedImageSrc(item); + + return selectedImageSrc.HasValue() ? selectedImageSrc : imageSrc; } internal string? GetPrefix(TItem item) @@ -636,6 +834,28 @@ internal BitImageSize GetImageSize(TItem item) return item.GetValueFromProperty(NameSelectors.Prefix.Name); } + internal string? GetSuffix(TItem item) + { + if (item is BitChoiceGroupItem choiceGroupItem) + { + return choiceGroupItem.Suffix; + } + + if (item is BitChoiceGroupOption choiceGroupOption) + { + return choiceGroupOption.Suffix; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.Suffix.Selector is not null) + { + return NameSelectors.Suffix.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.Suffix.Name); + } + internal string? GetSelectedImageSrc(TItem item) { if (item is BitChoiceGroupItem choiceGroupItem) @@ -680,6 +900,28 @@ internal BitImageSize GetImageSize(TItem item) return item.GetValueFromProperty?>(NameSelectors.Template.Name); } + internal string? GetDescription(TItem item) + { + if (item is BitChoiceGroupItem choiceGroupItem) + { + return choiceGroupItem.Description; + } + + if (item is BitChoiceGroupOption choiceGroupOption) + { + return choiceGroupOption.Description; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.Description.Selector is not null) + { + return NameSelectors.Description.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.Description.Name); + } + internal string? GetText(TItem item) { if (item is BitChoiceGroupItem choiceGroupItem) @@ -702,6 +944,28 @@ internal BitImageSize GetImageSize(TItem item) return item.GetValueFromProperty(NameSelectors.Text.Name); } + internal string? GetTitle(TItem item) + { + if (item is BitChoiceGroupItem choiceGroupItem) + { + return choiceGroupItem.Title; + } + + if (item is BitChoiceGroupOption choiceGroupOption) + { + return choiceGroupOption.Title; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.Title.Selector is not null) + { + return NameSelectors.Title.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.Title.Name); + } + internal TValue? GetValue(TItem item) { if (item is BitChoiceGroupItem choiceGroupItem) @@ -795,11 +1059,13 @@ private void SetIndex(TItem item, int value) if (item is BitChoiceGroupItem choiceGroupItem) { choiceGroupItem.Index = value; + return; } if (item is BitChoiceGroupOption choiceGroupOption) { choiceGroupOption.Index = value; + return; } if (NameSelectors is null) return; @@ -812,11 +1078,13 @@ private void SetIsSelected(TItem item, bool value) if (item is BitChoiceGroupItem choiceGroupItem) { choiceGroupItem.IsSelected = value; + return; } if (item is BitChoiceGroupOption choiceGroupOption) { choiceGroupOption.IsSelected = value; + return; } if (NameSelectors is null) return; @@ -824,13 +1092,14 @@ private void SetIsSelected(TItem item, bool value) item.SetValueToProperty(NameSelectors.IsSelected, value); } - private void SetIsSelectedForSelectedItem(TItem item) + // Mirrors the current value onto the IsSelected of every item. The value can change without the items + // changing at all (one-way binding, a programmatic assignment, a reset to null), so this runs on every + // parameters set and after every change, not only when an item is picked. + private void UpdateIsSelected() { - foreach (var itm in _items) + foreach (var item in _items) { - SetIsSelected(itm, false); + SetIsSelected(item, GetIsCheckedItem(item)); } - - SetIsSelected(item, true); } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.scss index 27401c2ace..a52864f473 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.scss @@ -1,4 +1,4 @@ -@import "../../../Styles/functions.scss"; +@import "../../../Styles/functions.scss"; .bit-chg { display: flex; @@ -13,19 +13,24 @@ --bit-chg-flex-flow: column nowrap; --bit-chg-justify-content: flex-start; + // Wins over the "fit-content" above through the extra class, so the declaration order + // of the two rules in this file cannot silently decide which width applies. + &.bit-chg-flw { + width: 100%; + } + &.bit-chg-req { .bit-chg-lbl { &::after { content: " *"; color: $clr-req; - padding-right: spacing(1.5); + padding-inline-end: spacing(1.5); } } } &.bit-inv { - .bit-chg-ili:focus-visible, - input:focus-visible + .bit-chg-ili { + .bit-chg-inp:focus-visible + .bit-chg-itl { @include focus-ring($clr-err-focus); } @@ -53,6 +58,14 @@ overflow-wrap: break-word; } +.bit-chg-gds { + display: block; + color: $clr-fg-sec; + box-sizing: border-box; + overflow-wrap: break-word; + font-size: calc(var(--bit-chg-fontsize) - #{spacing(0.25)}); +} + .bit-chg-cnt { display: flex; gap: var(--bit-chg-item-gap); @@ -63,11 +76,51 @@ --bit-chg-flex-flow: row wrap; } -.bit-chg-rvs { +// Fills the container instead of hugging the widest item. In the horizontal layout the items also share +// the width equally, so the group reads as a set of evenly distributed options rather than a left aligned +// row trailed by empty space. "min-width: fit-content" keeps an item from being squeezed below its own +// content, so a long item pushes the row to wrap instead of collapsing every item into a narrow column. +.bit-chg-flw { + .bit-chg-cnt { + width: 100%; + } + + &.bit-chg-hor { + .bit-chg-icn { + flex: 1 1 0; + min-width: fit-content; + } + } +} + +// The position of the content of an item relative to its radio circle. The markup order inside an item +// label is [prefix, circle, content, suffix], so each position is just the flex-direction that puts the +// content on the requested side of the circle. These are custom properties rather than a direct +// "flex-direction" declaration on purpose: the tile layout of an image or icon item (.bit-chg-ili) +// overrides flex-direction at a single class of specificity, and a descendant selector here would beat +// it and break the tile. +.bit-chg-led { + --bit-chg-flex-direction: row; + --bit-chg-justify-content: flex-start; +} + +// Start also pushes the items to the end of the group, so that in the vertical layout the circles line up +// in a single column at the far edge instead of drifting with the width of each item's content. +.bit-chg-lst { --bit-chg-justify-content: flex-end; --bit-chg-flex-direction: row-reverse; } +.bit-chg-ltp { + --bit-chg-justify-content: flex-start; + --bit-chg-flex-direction: column-reverse; +} + +.bit-chg-lbm { + --bit-chg-flex-direction: column; + --bit-chg-justify-content: flex-start; +} + .bit-chg-icn { border: none; display: flex; @@ -108,20 +161,25 @@ transition: border-color $mot-duration $mot-easing; } + // Centered with "inset:0 + margin:auto" rather than an inset/translate pair, which would disagree in + // RTL: a logical inset flips side there while a physical translate does not, so the two would compound + // and push the dot off center by half its width instead of cancelling out. + // The dot has its own size (rather than being derived as half the circle) so that the space left over + // around it is always an even number of pixels. Halving an odd leftover would put the dot on a half + // pixel while the ring stays on the pixel grid, which reads as an off center dot at the small and + // large sizes; only the medium size happens to divide evenly. &::after { - top: 50%; + inset: 0; opacity: 0; content: ""; - aspect-ratio: 1; + margin: auto; position: absolute; border-radius: 50%; box-sizing: border-box; - inset-inline-start: 50%; - transform: translate(-50%, -50%); background-color: $clr-fg-sec-hover; - transition-property: background-color; - width: calc(var(--bit-chg-circle-size) / 2); - transition: border-width $mot-duration $mot-easing; + width: var(--bit-chg-dot-size, calc(var(--bit-chg-circle-size) / 2)); + height: var(--bit-chg-dot-size, calc(var(--bit-chg-circle-size) / 2)); + transition: opacity $mot-duration $mot-easing, background-color $mot-duration $mot-easing; } @media (hover: hover) { @@ -137,14 +195,52 @@ } } +// The input is visually hidden but stays focusable, so the browser provides the full native radio +// group keyboard interaction (a single tab stop landing on the checked item, arrow keys moving the +// selection with wrapping and RTL awareness, and Space checking the focused item). +.bit-chg-inp { + margin: 0; + padding: 0; + opacity: 0; + width: 1px; + height: 1px; + border: none; + overflow: hidden; + position: absolute; + pointer-events: none; + clip-path: inset(50%); +} + +// The input carries the focus but is visually hidden, so the ring is rendered on the label it labels. +// Every item label carries .bit-chg-itl and the tile layout only adds .bit-chg-ili on top of it, so the +// single rule here covers both the plain and the tile item without them stacking two rings on each other. +.bit-chg-inp:focus-visible + .bit-chg-itl { + @include focus-ring(var(--bit-chg-clr-focus)); +} + .bit-chg-itl { display: flex; cursor: pointer; gap: spacing(0.75); align-items: center; + + // Matches the radius the tile layout sets below, so the focus ring (a box-shadow, which follows the + // border radius) is rounded on a plain item too instead of only on a tile. + border-radius: $shp-border-radius; flex-direction: var(--bit-chg-flex-direction); } +.bit-chg-txd { + display: flex; + flex-direction: column; +} + +.bit-chg-dsc { + display: block; + color: $clr-fg-sec; + font-size: calc(var(--bit-chg-fontsize) - #{spacing(0.25)}); +} + .bit-chg-ili { margin: 0; display: flex; @@ -163,15 +259,10 @@ padding-block-start: spacing(2.75); border: $shp-border-width $shp-border-style transparent; - &:focus-visible, - input:focus-visible + & { - @include focus-ring(var(--bit-chg-clr-focus)); - } - .bit-chg-rad { position: absolute; top: calc(var(--bit-chg-circle-size) / 4); - right: calc(var(--bit-chg-circle-size) / 4); + inset-inline-end: calc(var(--bit-chg-circle-size) / 4); &::before { opacity: 0; @@ -191,19 +282,19 @@ } } -.bit-chg-ncr { -} - .bit-chg-itx { display: inline-block; } +// A minimum rather than a fixed height: it still reserves the two lines the tile layout is designed +// around (so a row of tiles keeps a common baseline), but a longer text or a description grows the tile +// instead of being silently cut off by the overflow below. .bit-chg-itw { display: block; overflow: hidden; font-weight: 400; - height: spacing(4); position: relative; + min-height: spacing(4); max-width: spacing(8); white-space: pre-wrap; line-height: spacing(1.875); @@ -242,7 +333,6 @@ .bit-chg-ico { speak: none; display: inline-block; - //width: var(--bit-chg-ico-size); } .bit-chg-inl { @@ -260,10 +350,6 @@ padding: 0; font-size: calc(var(--bit-chg-fontsize) + spacing(0.25)); } - - .bit-chg-ico { - //width: unset; - } } .bit-chg-ich { @@ -316,6 +402,56 @@ } } +// Read-only keeps the enabled colors (it is not a disabled group) but drops every affordance that +// promises the value can be changed: the pointer cursor and the hover feedback of the circle and the tile. +.bit-chg-rdo { + .bit-chg-itl, + .bit-chg-ili { + cursor: default; + } + + @media (hover: hover) { + // An unchecked item must not preview a selection that cannot be made, so its hover is reset + // to the resting look: no dot, no highlighted ring and no tile border. + .bit-chg-icn:not(.bit-chg-ich) { + .bit-chg-rad:hover { + &::before { + border-color: $clr-brd-pri; + } + + &::after { + opacity: 0; + } + } + + .bit-chg-ili:hover { + border-color: transparent; + + .bit-chg-rad::before { + opacity: 0; + } + } + } + + // The checked item keeps its dot and its border, they just stop brightening on hover. + .bit-chg-ich { + .bit-chg-rad:hover { + &::before { + border-color: var(--bit-chg-clr); + } + + &::after { + background-color: var(--bit-chg-clr); + } + } + + .bit-chg-ili:hover { + border-color: var(--bit-chg-clr); + } + } + } +} + .bit-chg-ids { .bit-chg-rad { cursor: default; @@ -350,18 +486,21 @@ .bit-chg-ico { color: var(--bit-chg-clr-dis-text); } + + .bit-chg-dsc { + color: var(--bit-chg-clr-dis-text); + } } // Per-role color blocks - intentionally hand-written, NOT an @each loop over $bit-color-roles. // Like BitCheckbox, BitChoiceGroup diverges from the shared role vocabulary: the semantic roles -// pin fixed neutral chrome (--bit-chg-clr-bg -> bg-sec, --bit-chg-clr-brd -> brd-pri) rather than +// pin fixed neutral chrome (--bit-chg-clr-bg -> bg-sec) rather than // role slots, and the surface tiers use the matching-tier contrast (sfg -> bg-sec, not the map's // `on` = bg-pri). Looping over role($tokens, on) would recolor the secondary/tertiary surface // roles - a visual regression. Keep them explicit. (See BitCheckbox for the same rationale.) .bit-chg-pri { --bit-chg-clr: #{$clr-pri}; --bit-chg-clr-bg: #{$clr-bg-sec}; - --bit-chg-clr-brd: #{$clr-brd-pri}; --bit-chg-clr-hover: #{$clr-pri-hover}; --bit-chg-clr-dis: #{$clr-pri-dis}; --bit-chg-clr-dis-text: #{$clr-pri-dis-text}; @@ -371,7 +510,6 @@ .bit-chg-sec { --bit-chg-clr: #{$clr-sec}; --bit-chg-clr-bg: #{$clr-bg-sec}; - --bit-chg-clr-brd: #{$clr-brd-sec}; --bit-chg-clr-hover: #{$clr-sec-hover}; --bit-chg-clr-dis: #{$clr-sec-dis}; --bit-chg-clr-dis-text: #{$clr-sec-dis-text}; @@ -381,7 +519,6 @@ .bit-chg-ter { --bit-chg-clr: #{$clr-ter}; --bit-chg-clr-bg: #{$clr-bg-sec}; - --bit-chg-clr-brd: #{$clr-brd-ter}; --bit-chg-clr-hover: #{$clr-ter-hover}; --bit-chg-clr-dis: #{$clr-ter-dis}; --bit-chg-clr-dis-text: #{$clr-ter-dis-text}; @@ -391,7 +528,6 @@ .bit-chg-inf { --bit-chg-clr: #{$clr-inf}; --bit-chg-clr-bg: #{$clr-bg-sec}; - --bit-chg-clr-brd: #{$clr-inf}; --bit-chg-clr-hover: #{$clr-inf-hover}; --bit-chg-clr-dis: #{$clr-inf-dis}; --bit-chg-clr-dis-text: #{$clr-inf-dis-text}; @@ -401,7 +537,6 @@ .bit-chg-suc { --bit-chg-clr: #{$clr-suc}; --bit-chg-clr-bg: #{$clr-bg-sec}; - --bit-chg-clr-brd: #{$clr-suc}; --bit-chg-clr-hover: #{$clr-suc-hover}; --bit-chg-clr-dis: #{$clr-suc-dis}; --bit-chg-clr-dis-text: #{$clr-suc-dis-text}; @@ -410,7 +545,6 @@ .bit-chg-wrn { --bit-chg-clr: #{$clr-wrn}; - --bit-chg-clr-brd: #{$clr-wrn}; --bit-chg-clr-hover: #{$clr-wrn-hover}; --bit-chg-clr-bg: #{$clr-bg-sec}; --bit-chg-clr-dis: #{$clr-wrn-dis}; @@ -421,7 +555,6 @@ .bit-chg-swr { --bit-chg-clr: #{$clr-swr}; --bit-chg-clr-bg: #{$clr-bg-sec}; - --bit-chg-clr-brd: #{$clr-swr}; --bit-chg-clr-hover: #{$clr-swr-hover}; --bit-chg-clr-dis: #{$clr-swr-dis}; --bit-chg-clr-dis-text: #{$clr-swr-dis-text}; @@ -430,7 +563,6 @@ .bit-chg-err { --bit-chg-clr: #{$clr-err}; - --bit-chg-clr-brd: #{$clr-err}; --bit-chg-clr-hover: #{$clr-err-hover}; --bit-chg-clr-bg: #{$clr-bg-sec}; --bit-chg-clr-dis: #{$clr-err-dis}; @@ -441,7 +573,6 @@ .bit-chg-pbg { --bit-chg-clr: #{$clr-bg-pri}; --bit-chg-clr-bg: #{$clr-fg-pri}; - --bit-chg-clr-brd: #{$clr-bg-pri}; --bit-chg-clr-hover: #{$clr-bg-pri-hover}; --bit-chg-clr-dis: #{$clr-bg-pri-dis}; --bit-chg-clr-dis-text: #{$clr-bg-pri-dis-text}; @@ -451,7 +582,6 @@ .bit-chg-sbg { --bit-chg-clr: #{$clr-bg-sec}; --bit-chg-clr-bg: #{$clr-fg-pri}; - --bit-chg-clr-brd: #{$clr-bg-sec}; --bit-chg-clr-hover: #{$clr-bg-sec-hover}; --bit-chg-clr-dis: #{$clr-bg-sec-dis}; --bit-chg-clr-dis-text: #{$clr-bg-sec-dis-text}; @@ -460,7 +590,6 @@ .bit-chg-tbg { --bit-chg-clr: #{$clr-bg-ter}; - --bit-chg-clr-brd: #{$clr-bg-ter}; --bit-chg-clr-hover: #{$clr-bg-ter-hover}; --bit-chg-clr-bg: #{$clr-fg-pri}; --bit-chg-clr-dis: #{$clr-bg-ter-dis}; @@ -471,7 +600,6 @@ .bit-chg-pfg { --bit-chg-clr: #{$clr-fg-pri}; --bit-chg-clr-bg: #{$clr-bg-sec}; - --bit-chg-clr-brd: #{$clr-fg-pri}; --bit-chg-clr-hover: #{$clr-fg-pri-hover}; --bit-chg-clr-dis: #{$clr-fg-pri-dis}; --bit-chg-clr-dis-text: #{$clr-fg-pri-dis-text}; @@ -480,7 +608,6 @@ .bit-chg-sfg { --bit-chg-clr: #{$clr-fg-sec}; - --bit-chg-clr-brd: #{$clr-fg-sec}; --bit-chg-clr-hover: #{$clr-fg-sec-hover}; --bit-chg-clr-bg: #{$clr-bg-sec}; --bit-chg-clr-dis: #{$clr-fg-sec-dis}; @@ -491,7 +618,6 @@ .bit-chg-tfg { --bit-chg-clr: #{$clr-fg-ter}; --bit-chg-clr-bg: #{$clr-bg-sec}; - --bit-chg-clr-brd: #{$clr-fg-ter}; --bit-chg-clr-hover: #{$clr-fg-ter-hover}; --bit-chg-clr-dis: #{$clr-fg-ter-dis}; --bit-chg-clr-dis-text: #{$clr-fg-ter-dis-text}; @@ -500,7 +626,6 @@ .bit-chg-pbr { --bit-chg-clr: #{$clr-brd-pri}; - --bit-chg-clr-brd: #{$clr-brd-pri}; --bit-chg-clr-hover: #{$clr-brd-pri-hover}; --bit-chg-clr-bg: #{$clr-bg-sec}; --bit-chg-clr-dis: #{$clr-brd-pri-dis}; @@ -511,7 +636,6 @@ .bit-chg-sbr { --bit-chg-clr: #{$clr-brd-sec}; --bit-chg-clr-bg: #{$clr-bg-sec}; - --bit-chg-clr-brd: #{$clr-brd-sec}; --bit-chg-clr-hover: #{$clr-brd-sec-hover}; --bit-chg-clr-dis: #{$clr-brd-sec-dis}; --bit-chg-clr-dis-text: #{$clr-brd-sec-dis-text}; @@ -521,19 +645,21 @@ .bit-chg-tbr { --bit-chg-clr: #{$clr-brd-ter}; --bit-chg-clr-bg: #{$clr-bg-sec}; - --bit-chg-clr-brd: #{$clr-brd-ter}; --bit-chg-clr-hover: #{$clr-brd-ter-hover}; --bit-chg-clr-dis: #{$clr-brd-ter-dis}; --bit-chg-clr-dis-text: #{$clr-brd-ter-dis-text}; --bit-chg-clr-focus: #{$clr-brd-ter-focus}; } +// The dot size of each size is picked so that the circle size minus the dot size stays an even number of +// pixels at the default spacing scale, which is what keeps the centered dot on the pixel grid: 14-6, 20-10 +// and 26-12. Deriving it as half the circle instead would leave an odd 7 and 13 for small and large. .bit-chg-sm { --bit-chg-item-gap: #{spacing(1)}; --bit-chg-ico-size: #{spacing(3)}; --bit-chg-fontsize: #{spacing(1.5)}; --bit-chg-circle-size: #{spacing(1.75)}; - --bit-chg-dot-position: #{spacing(0.8125)}; + --bit-chg-dot-size: #{spacing(0.75)}; } .bit-chg-md { @@ -541,7 +667,7 @@ --bit-chg-ico-size: #{spacing(4)}; --bit-chg-fontsize: #{spacing(1.75)}; --bit-chg-circle-size: #{spacing(2.5)}; - --bit-chg-dot-position: #{spacing(1)}; + --bit-chg-dot-size: #{spacing(1.25)}; } .bit-chg-lg { @@ -549,7 +675,7 @@ --bit-chg-ico-size: #{spacing(5)}; --bit-chg-fontsize: #{spacing(2)}; --bit-chg-circle-size: #{spacing(3.25)}; - --bit-chg-dot-position: #{spacing(1.25)}; + --bit-chg-dot-size: #{spacing(1.5)}; } .bit-chg-hor { diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupClassStyles.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupClassStyles.cs index b532ee5c90..af916ff151 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupClassStyles.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupClassStyles.cs @@ -17,6 +17,11 @@ public class BitChoiceGroupClassStyles /// public string? Label { get; set; } + /// + /// Custom CSS classes/styles for the description (helper text) of the BitChoiceGroup. + /// + public string? Description { get; set; } + /// /// Custom CSS classes/styles for the container of the BitChoiceGroup. /// @@ -72,6 +77,11 @@ public class BitChoiceGroupClassStyles /// public string? ItemPrefix { get; set; } + /// + /// Custom CSS classes/styles for the suffix of each item of the BitChoiceGroup. + /// + public string? ItemSuffix { get; set; } + /// /// Custom CSS classes/styles for the text wrapper of each item of the BitChoiceGroup. /// @@ -81,4 +91,9 @@ public class BitChoiceGroupClassStyles /// Custom CSS classes/styles for the text of each item of the BitChoiceGroup. /// public string? ItemText { get; set; } + + /// + /// Custom CSS classes/styles for the description of each item of the BitChoiceGroup. + /// + public string? ItemDescription { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupItem.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupItem.cs index ae4013fe59..96e7f6deb7 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupItem.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupItem.cs @@ -12,6 +12,11 @@ public class BitChoiceGroupItem /// public string? Class { get; set; } + /// + /// The secondary text to show under the text of the BitChoiceGroup item. + /// + public string? Description { get; set; } + /// /// Id attribute of the BitChoiceGroup item. /// @@ -70,6 +75,11 @@ public class BitChoiceGroupItem /// public string? Style { get; set; } + /// + /// The text to show as a suffix for the BitChoiceGroup item, rendered after its content. + /// + public string? Suffix { get; set; } + /// /// The custom template for the BitChoiceGroup item. /// @@ -80,6 +90,15 @@ public class BitChoiceGroupItem /// public string? Text { get; set; } + /// + /// The title attribute (the native tooltip) of the BitChoiceGroup item. This is supplementary text only: + /// content that has to reach every user belongs in (or a template) or in + /// , both of which are visible and exposed to assistive technology. + /// is not an alternative: it only replaces the accessible name for assistive + /// technology and is never visible. + /// + public string? Title { get; set; } + /// /// The value returned when BitChoiceGroup item is checked. /// diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupNameSelectors.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupNameSelectors.cs index 0dd9a83f9f..164a5f3a5b 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupNameSelectors.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupNameSelectors.cs @@ -12,6 +12,11 @@ public class BitChoiceGroupNameSelectors /// public BitNameSelectorPair Class { get; set; } = new(nameof(BitChoiceGroupItem.Class)); + /// + /// The Description field name and selector of the custom input class. + /// + public BitNameSelectorPair Description { get; set; } = new(nameof(BitChoiceGroupItem.Description)); + /// /// The Id field name and selector of the custom input class. /// @@ -62,6 +67,11 @@ public class BitChoiceGroupNameSelectors /// public BitNameSelectorPair Style { get; set; } = new(nameof(BitChoiceGroupItem.Style)); + /// + /// The Suffix field name and selector of the custom input class. + /// + public BitNameSelectorPair Suffix { get; set; } = new(nameof(BitChoiceGroupItem.Suffix)); + /// /// Template field name and selector of the custom input class. /// @@ -72,6 +82,11 @@ public class BitChoiceGroupNameSelectors /// public BitNameSelectorPair Text { get; set; } = new(nameof(BitChoiceGroupItem.Text)); + /// + /// The Title field name and selector of the custom input class. + /// + public BitNameSelectorPair Title { get; set; } = new(nameof(BitChoiceGroupItem.Title)); + /// /// The Value field name and selector of the custom input class. /// diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupOption.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupOption.cs index 571768cba9..2f412c18d4 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupOption.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroupOption.cs @@ -20,6 +20,11 @@ public partial class BitChoiceGroupOption : ComponentBase, IDisposable /// [Parameter] public string? Class { get; set; } + /// + /// The secondary text to show under the text of the BitChoiceGroup option. + /// + [Parameter] public string? Description { get; set; } + /// /// Id attribute of the BitChoiceGroup option. /// @@ -78,6 +83,11 @@ public partial class BitChoiceGroupOption : ComponentBase, IDisposable /// [Parameter] public string? Style { get; set; } + /// + /// The text to show as a suffix for the BitChoiceGroup option, rendered after its content. + /// + [Parameter] public string? Suffix { get; set; } + /// /// The custom template for the BitChoiceGroup option. /// @@ -88,6 +98,15 @@ public partial class BitChoiceGroupOption : ComponentBase, IDisposable /// [Parameter] public string? Text { get; set; } + /// + /// The title attribute (the native tooltip) of the BitChoiceGroup option. This is supplementary text only: + /// content that has to reach every user belongs in (or a template) or in + /// , both of which are visible and exposed to assistive technology. + /// is not an alternative: it only replaces the accessible name for assistive + /// technology and is never visible. + /// + [Parameter] public string? Title { get; set; } + /// /// This value is returned when BitChoiceGroup option is checked. /// diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor index 83e17b8c67..cafd9ff5a2 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor @@ -1,15 +1,51 @@ -@namespace Bit.BlazorUI +@namespace Bit.BlazorUI @typeparam TItem @typeparam TValue @{ var prefix = ChoiceGroup.GetPrefix(Item); + var suffix = ChoiceGroup.GetSuffix(Item); var inputId = ChoiceGroup.GetInputId(Item); var template = ChoiceGroup.GetTemplate(Item); var isChecked = ChoiceGroup.GetIsCheckedItem(Item); + var description = ChoiceGroup.GetDescription(Item); + var descriptionId = $"{inputId}-description"; + + // The description element belongs to the built-in item content, so a template that takes the content + // over also takes the description over. aria-describedby is only emitted when the element it points + // at is actually rendered, otherwise it would be a dangling reference. + var hasDescription = description.HasValue() + && template is null + && ChoiceGroup.ItemTemplate is null + && ChoiceGroup.ItemLabelTemplate is null; } -
+
+ @* A read-only group stays reachable and keeps its value in the submitted form data (that is what + separates it from a disabled one), so the inputs are left in the tab order. Cancelling the click is + what makes it read-only: the browser routes both a mouse click and a keyboard activation (Space, and + the arrow keys that move the selection of a radio group) through a cancelable click, and cancelling + it runs the radio's "canceled activation steps", which restore the previous checkedness. *@ + +
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor.cs index 7e9ba342e0..dbb3a87a88 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/ChoiceGroup/_BitChoiceGroupItem.razor.cs @@ -1,8 +1,17 @@ -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; public partial class _BitChoiceGroupItem : ComponentBase where TItem : class, new () { + private ElementReference _inputElement; + [Parameter] public TItem Item { get; set; } = default!; [Parameter] public BitChoiceGroup ChoiceGroup { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + await ChoiceGroup.SetInputElement(Item, _inputElement); + } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor index 2226794bf7..3fbd617f0b 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Components/DemoPage.razor @@ -168,7 +168,6 @@ else Class="page-section">
Notes -
@if (NotesTemplate is not null) { diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/BitChoiceGroupDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/BitChoiceGroupDemo.razor index 7f23991586..61182f67c8 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/BitChoiceGroupDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/BitChoiceGroupDemo.razor @@ -4,12 +4,12 @@ + Description="ChoiceGroup, also known as Radio or RadioGroup, lets people select a single value from two or more mutually exclusive choices. It builds on native radio inputs, so the whole group is one tab stop and the arrow keys move the selection, and it adds labels, helper text, icons, images, per-item descriptions, vertical/horizontal/full width layouts and custom templates on top." />
+
The basic ChoiceGroup lets people select a single item from a set of mutually exclusive choices, using any custom class as its item type through the NameSelectors mapping. It is fully keyboard accessible: Tab focuses the checked item and the arrow keys move the selection. The NoCircle mode removes the radio circle of each item.
+
-
Illustrates how to disable the entire BitChoiceGroup and individual items.
+
Disables the whole ChoiceGroup or its individual items. Disabled items cannot be selected and are skipped by the keyboard navigation.

-
Showcases BitChoiceGroup with image and icon items.
+
Displays items as image or icon tiles: ImageSrc with an optional ImageSize renders a picture, IconName (or Icon for an external library) renders a glyph, and SelectedImageSrc swaps in a second picture while the item is checked. The images are marked decorative so the item text carries the accessible name; set ImageAlt only when the picture says something the text does not. The Inline mode drops the tile layout and puts the image or icon on the same line as the text.


-
Displays the BitChoiceGroup component in a horizontal layout, demonstrating various configurations.
+
Lays out the items in a row instead of the default vertical stack, wrapping onto new lines when the available width runs out. The aria-orientation of the radiogroup follows this parameter, so assistive technology announces the same orientation the eye sees.

- -
Adjust the label position of BitChoiceGroup's item to be reversed.
+ +
Places the content of each item on any of the four sides of its radio circle. End is the default and renders the circle first; Start renders the content first and additionally aligns the items to the end of the group, so the circles line up in one column once the group is wider than its items (which is what the FullWidth section combines it with). Top and Bottom stack the content above or below the circle. Items rendered as image or icon tiles lay their own content out and are not affected.

- + + + + + + @@ -131,7 +151,7 @@ -
Demonstrates how to customize the label of the BitChoiceGroup using a template.
+
Replaces the plain text label of the ChoiceGroup with fully custom content using the LabelTemplate. The label element itself is still rendered and the group still points its aria-labelledby at it, so whatever the template renders becomes the accessible name of the radio group; keep readable text in there, and reach for AriaLabel or AriaLabelledBy when the template is purely decorative.

-
Illustrates how to customize the appearance of BitChoiceGroup options using item templates.
+
Customizes the item rendering at four different levels, from the smallest scope to the largest: ItemPrefixTemplate and ItemSuffixTemplate add content before and after the built-in item content and leave it in place, ItemLabelTemplate replaces the content next to the radio circle but keeps the circle, and ItemTemplate (or the Template name selector of a single item, which wins over it) takes over the whole item rendering including the circle.

- @(item.Idx + 1).  + +  (@item.ItemValue) + -
Shows how to use one-way and two-way data binding with BitChoiceGroup.
+
Binds the selected value in three ways. DefaultValue only seeds the initial selection and then leaves the ChoiceGroup to own its value, reporting every change through OnChange; this uncontrolled mode is how most of the examples on this page are set up. Value alone is one-way: the model drives the ChoiceGroup and a click cannot write back, so editing the text box below moves the selection but clicking an item does not change the text box. @@bind-Value is two-way and keeps the model and the ChoiceGroup in sync in both directions.

+
+ +
+
Selected: @uncontrolledValue
+
-
Shows how to use data annotations for validating the selected value in BitChoiceGroup.
+
Integrates with EditForm and data annotations. While the field is invalid the radiogroup and its inputs carry aria-invalid and the radio circles turn red, until a choice is made. Required is separate from the validation attribute of the model: it adds the required asterisk to the label and the aria-required of the group, so set both.

@if (string.IsNullOrEmpty(successMessage)) { - @@ -235,93 +266,209 @@ Reset
- -
Varying sizes for BitChoiceGroup tailored to meet diverse design needs, ensuring flexibility and visual hierarchy within your interface.
-

-
Basic ChoiceGroup
-
- +
Adds secondary text under each item using the Description name selector. The description is automatically linked to its input via aria-describedby, so screen readers announce it along with the item label.
+
+
+ +
+ + + +
Prevents changing the value while keeping the normal, non-disabled appearance. Unlike IsEnabled="false" the group stays fully reachable: Tab still lands on the checked item and the arrow keys still move the focus, so the choice can be read with the keyboard and a screen reader, and the value is still submitted with the form. The group reports aria-readonly, and every attempt to commit a change (a click, Space, or an arrow key) is cancelled, so the selection cannot move.
+
+
+ + @bind-Value="readOnlyValue" + NameSelectors="@(new() { Text = { Name = nameof(Order.Name) }, Value = { Name = nameof(Order.ItemValue) } })" /> +
+
- +
Customizes the gap between the items using any CSS length value, overriding the default spacing of the current size.
+
+
+ + DefaultValue="@("A")" + NameSelectors="@(new() { Text = { Name = nameof(Order.Name) }, Value = { Name = nameof(Order.ItemValue) } })" /> - + DefaultValue="@("A")" + NameSelectors="@(new() { Text = { Name = nameof(Order.Name) }, Value = { Name = nameof(Order.ItemValue) } })" />
-

-
ChoiceGroup with inline icon
+ + + +
Wraps the content of each item in plain text: the Prefix name selector maps a field that is rendered at the start of the item label, before the item text, and the Suffix name selector maps one that is rendered after the content of the item. Pairing Suffix with FullWidth and an auto inline start margin turns it into a trailing column, which is where a price or a hint usually belongs. For arbitrary content on either end, use the ItemPrefixTemplate and ItemSuffixTemplate shown in the Item templates section.
+
- + - +
+ +
+
+
- + +
Raises OnChange with the new value whenever the selection actually changes, and OnClick with the whole item on every activation of an enabled item, including a click on the already selected one and an arrow key press, which the browser also reports as a click. OnFocus and OnBlur fire with the item as the focus moves between the items. All four stay silent while the group is disabled or read-only.
+
+
+ +
Changed value: @changedValue
+
Clicked custom: @clickedCustom
+
Focused custom: @focusedCustom
+
Blurred custom: @blurredCustom
-

-
ChoiceGroup with icon
+
+ + +
Handles dynamic item collections: assigning a new Items collection re-renders the group and re-assigns the index of each item (shown here as a number prefix), preserving the selected value as long as its item survives the change.
+
- +
+ Add item + Remove item + Reverse items +
+ + + @(custom.Idx + 1).  + + +
+
- + +
Adds a helper text under the label to explain the whole set of choices, as opposed to the per-item Description name selector that explains a single item. The group points its aria-describedby at it, so a screen reader announces it right after the name of the group. DescriptionTemplate replaces the plain text with custom content and takes precedence over Description when both are set.
+
+
+ - + + +
+ + Only the selected environment receives the new build. +
+
+
+
+
+ + +
Stretches the ChoiceGroup to the full width of its container instead of letting it hug its widest item, which is what lines a ChoiceGroup up with the other full width fields of a form. In the horizontal layout the items additionally share that width in equal columns. In the vertical layout every item row spans the full width, so LabelPosition.Start now has room to push the items to the far edge; stretching the item label over that row as well turns it into the label-at-the-start, circle-at-the-end list found in settings pages.
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
Sets the native browser tooltip of an item through the Title name selector, which is handy when the item text is an abbreviation or gets clipped. Keep it supplementary: a title never appears on keyboard focus and is announced inconsistently by screen readers, so anything every user has to read belongs in the item text, its AriaLabel, or its Description.
+
+
+ +
+
+ + +
Moves the keyboard focus into the ChoiceGroup on its first render. It lands on the checked item, or on the first enabled item when nothing is checked, which is exactly the item the Tab key would reach, and it is skipped entirely while the group is disabled or read-only. The button below mounts a fresh ChoiceGroup so the focus move stays visible on demand instead of stealing the focus as soon as this page loads.
+
+
+ @(showAutoFocus ? "Unmount" : "Mount") the auto focused ChoiceGroup + @if (showAutoFocus) + { + + }
- -
Offering a range of specialized color variants with Primary being the default, providing visual cues for specific actions or states within your application.
+ +
Colors the radio circle and the checked state of the items using the BitColor palette, with Primary being the default.

@@ -659,9 +806,8 @@
- - -
Use icons from external libraries like FontAwesome or Bootstrap Icons with the Icon parameter and NameSelectors.
+ +
Renders icons from external libraries like Font Awesome or Bootstrap Icons using the Icon name selector, instead of the built-in icon font used by IconName.

@@ -694,8 +840,93 @@
- -
Explores styling and class customization for BitChoiceGroup, including component styles, custom classes, and detailed style items.
+ +
Comes in Small, Medium (default), and Large sizes that scale the radio circle, text, icons, and item spacing together.
+

+
Basic ChoiceGroup
+
+ + + + + +
+

+
ChoiceGroup with inline icon
+
+ + + + + +
+

+
ChoiceGroup with icon
+
+ + + + + +
+
+ + +
Customizes the appearance at every level: Style and Class on the root element, per-item styles and classes, and the Styles and Classes objects that target the internal parts of the component, including the checked state of the items.


Component's Style & Class:

@@ -747,8 +978,8 @@
- -
Use BitChoiceGroup in right-to-left (RTL).
+ +
Renders the ChoiceGroup in right-to-left mode; the layout is mirrored and the keyboard arrow keys follow the text direction.

dynamicCustoms = + [ + new() { Name = "Custom 1", ItemValue = "1" }, + new() { Name = "Custom 2", ItemValue = "2" }, + new() { Name = "Custom 3", ItemValue = "3" } + ]; private string itemTemplateValue = "Day"; private string itemTemplateValue2 = "Day"; private string itemLabelTemplateValue = "Day"; - public ChoiceGroupValidationModel validationModel = new(); - public string? successMessage; + private ChoiceGroupValidationModel validationModel = new(); + private string? successMessage; private readonly List basicCustoms = @@ -93,6 +110,41 @@ public partial class _BitChoiceGroupCustomDemo new() { Name = "Custom D", ItemValue = "D", Class = "custom-item" } ]; + private readonly List prefixCustoms = + [ + new() { Name = "Standard", ItemValue = "Standard", Prefix = "$0 — " }, + new() { Name = "Express", ItemValue = "Express", Prefix = "$10 — " }, + new() { Name = "Overnight", ItemValue = "Overnight", Prefix = "$25 — " } + ]; + + private readonly List suffixCustoms = + [ + new() { Name = "Standard", ItemValue = "Standard", Fee = "Free" }, + new() { Name = "Express", ItemValue = "Express", Fee = "$10" }, + new() { Name = "Overnight", ItemValue = "Overnight", Fee = "$25" } + ]; + + private readonly List descriptionCustoms = + [ + new() { Name = "Daily", ItemValue = "Daily", Summary = "Backs up every night at 2 AM." }, + new() { Name = "Weekly", ItemValue = "Weekly", Summary = "Backs up every Sunday at 2 AM." }, + new() { Name = "Monthly", ItemValue = "Monthly", Summary = "Backs up on the first day of each month." } + ]; + + private readonly List deploymentCustoms = + [ + new() { Name = "Development", ItemValue = "Development" }, + new() { Name = "Staging", ItemValue = "Staging" }, + new() { Name = "Production", ItemValue = "Production" } + ]; + + private readonly List titleCustoms = + [ + new() { Name = "1 h", ItemValue = "1h", Tooltip = "Delivered within one hour of dispatch" }, + new() { Name = "24 h", ItemValue = "24h", Tooltip = "Delivered within one business day" }, + new() { Name = "72 h", ItemValue = "72h", Tooltip = "Delivered within three business days" } + ]; + private readonly List itemLabelTemplateCustoms = [ new() { Name = "Day", ItemValue = "Day", IconName = BitIconName.CalendarDay }, @@ -116,6 +168,24 @@ public partial class _BitChoiceGroupCustomDemo ]; + private void AddDynamicCustom() + { + dynamicCounter++; + dynamicCustoms = [.. dynamicCustoms, new Order { Name = $"Custom {dynamicCounter}", ItemValue = $"{dynamicCounter}" }]; + } + + private void RemoveDynamicCustom() + { + if (dynamicCustoms.Count <= 1) return; + + dynamicCustoms = [.. dynamicCustoms.Take(dynamicCustoms.Count - 1)]; + } + + private void ReverseDynamicCustoms() + { + dynamicCustoms = [.. Enumerable.Reverse(dynamicCustoms)]; + } + private void HandleValidSubmit() { successMessage = "Form Submitted Successfully!"; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/_BitChoiceGroupCustomDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/_BitChoiceGroupCustomDemo.razor.samples.cs index 0c643917e9..315010af6b 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/_BitChoiceGroupCustomDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/ChoiceGroup/_BitChoiceGroupCustomDemo.razor.samples.cs @@ -8,7 +8,7 @@ public partial class _BitChoiceGroupCustomDemo DefaultValue=""basicCustoms[1].ItemValue"" NameSelectors=""@(new() { Text = { Name = nameof(Order.Name) }, Value = { Name = nameof(Order.ItemValue) } })"" /> -"; + + + + + + +"; private readonly string example5CsharpCode = @" public class Order { @@ -375,13 +393,16 @@ public class Order } - i.Name}, Value = { Selector = i => i.ItemValue }, Index = nameof(Order.Idx) })""> @(item.Idx + 1).  + +  (@item.ItemValue) + uncontrolledValue = value"" + NameSelectors=""@(new() { Text = { Name = nameof(Order.Name) }, Value = { Name = nameof(Order.ItemValue) } })"" /> +
Selected: @uncontrolledValue
+ @@ -495,6 +522,7 @@ protected override void OnInitialized() private readonly string example8CsharpCode = @" private string oneWayValue = ""A""; private string twoWayValue = ""A""; +private string? uncontrolledValue = ""A""; public class Order { @@ -520,7 +548,7 @@ public class Order - validationModel.Value)"" /> @@ -554,84 +582,138 @@ public class Order ];"; private readonly string example10RazorCode = @" -"; + private readonly string example10CsharpCode = @" +public class Order +{ + public string Name { get; set; } + public string Summary { get; set; } + public string ItemValue { get; set; } +} + +private readonly List descriptionCustoms = +[ + new() { Name = ""Daily"", ItemValue = ""Daily"", Summary = ""Backs up every night at 2 AM."" }, + new() { Name = ""Weekly"", ItemValue = ""Weekly"", Summary = ""Backs up every Sunday at 2 AM."" }, + new() { Name = ""Monthly"", ItemValue = ""Monthly"", Summary = ""Backs up on the first day of each month."" } +];"; + + private readonly string example11RazorCode = @" + + @bind-Value=""readOnlyValue"" + NameSelectors=""@(new() { Text = { Name = nameof(Order.Name) }, Value = { Name = nameof(Order.ItemValue) } })"" />"; + private readonly string example11CsharpCode = @" +private string readOnlyValue = ""A""; - basicCustoms = +[ + new() { Name = ""Custom A"", ItemValue = ""A"" }, + new() { Name = ""Custom B"", ItemValue = ""B"" }, + new() { Name = ""Custom C"", ItemValue = ""C"" }, + new() { Name = ""Custom D"", ItemValue = ""D"" } +];"; + + private readonly string example12RazorCode = @" + + DefaultValue=""@(""A"")"" + NameSelectors=""@(new() { Text = { Name = nameof(Order.Name) }, Value = { Name = nameof(Order.ItemValue) } })"" /> - + DefaultValue=""@(""A"")"" + NameSelectors=""@(new() { Text = { Name = nameof(Order.Name) }, Value = { Name = nameof(Order.ItemValue) } })"" />"; + private readonly string example12CsharpCode = @" +public class Order +{ + public string Name { get; set; } + public string ItemValue { get; set; } +} - i.Name }, - Value = { Selector = i => i.ItemValue }, - IconName = { Selector = i => i.IconName }, - IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal Inline /> +private readonly List basicCustoms = +[ + new() { Name = ""Custom A"", ItemValue = ""A"" }, + new() { Name = ""Custom B"", ItemValue = ""B"" }, + new() { Name = ""Custom C"", ItemValue = ""C"" }, + new() { Name = ""Custom D"", ItemValue = ""D"" } +];"; - i.Name }, - Value = { Selector = i => i.ItemValue }, - IconName = { Selector = i => i.IconName }, - IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal Inline /> + private readonly string example13RazorCode = @" + - i.Name }, - Value = { Selector = i => i.ItemValue }, - IconName = { Selector = i => i.IconName }, - IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal Inline /> +"; + private readonly string example13CsharpCode = @" +public class Order +{ + public string Name { get; set; } + public string Fee { get; set; } + public string Prefix { get; set; } + public string ItemValue { get; set; } +} - i.Name }, - Value = { Selector = i => i.ItemValue }, - IconName = { Selector = i => i.IconName }, - IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal /> +private readonly List prefixCustoms = +[ + new() { Name = ""Standard"", ItemValue = ""Standard"", Prefix = ""$0 — "" }, + new() { Name = ""Express"", ItemValue = ""Express"", Prefix = ""$10 — "" }, + new() { Name = ""Overnight"", ItemValue = ""Overnight"", Prefix = ""$25 — "" } +]; - i.Name }, - Value = { Selector = i => i.ItemValue }, - IconName = { Selector = i => i.IconName }, - IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal /> +private readonly List suffixCustoms = +[ + new() { Name = ""Standard"", ItemValue = ""Standard"", Fee = ""Free"" }, + new() { Name = ""Express"", ItemValue = ""Express"", Fee = ""$10"" }, + new() { Name = ""Overnight"", ItemValue = ""Overnight"", Fee = ""$25"" } +];"; + + private readonly string example14RazorCode = @" + changedValue = value"" + OnClick=""(Order custom) => clickedCustom = custom.Name"" + OnFocus=""(Order custom) => focusedCustom = custom.Name"" + OnBlur=""(Order custom) => blurredCustom = custom.Name"" + NameSelectors=""@(new() { Text = { Name = nameof(Order.Name) }, Value = { Name = nameof(Order.ItemValue) } })"" /> +
Changed value: @changedValue
+
Clicked custom: @clickedCustom
+
Focused custom: @focusedCustom
+
Blurred custom: @blurredCustom
"; + private readonly string example14CsharpCode = @" +private string? changedValue; +private string? clickedCustom; +private string? focusedCustom; +private string? blurredCustom; - i.Name }, - Value = { Selector = i => i.ItemValue }, - IconName = { Selector = i => i.IconName }, - IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal />"; - private readonly string example10CsharpCode = @" public class Order { public string Name { get; set; } public string ItemValue { get; set; } - public string? IconName { get; set; } - public bool IsDisabled { get; set; } } private readonly List basicCustoms = @@ -640,17 +722,195 @@ public class Order new() { Name = ""Custom B"", ItemValue = ""B"" }, new() { Name = ""Custom C"", ItemValue = ""C"" }, new() { Name = ""Custom D"", ItemValue = ""D"" } +];"; + + private readonly string example15RazorCode = @" +Add item +Remove item +Reverse items + + + + @(custom.Idx + 1).  + +"; + private readonly string example15CsharpCode = @" +public class Order +{ + public string Name { get; set; } + public string ItemValue { get; set; } + public int Idx { get; set; } +} + +private int dynamicCounter = 3; +private string? dynamicValue = ""1""; +private List dynamicCustoms = +[ + new() { Name = ""Custom 1"", ItemValue = ""1"" }, + new() { Name = ""Custom 2"", ItemValue = ""2"" }, + new() { Name = ""Custom 3"", ItemValue = ""3"" } ]; -private readonly List iconCustoms = +private void AddDynamicCustom() +{ + dynamicCounter++; + dynamicCustoms = [.. dynamicCustoms, new Order { Name = $""Custom {dynamicCounter}"", ItemValue = $""{dynamicCounter}"" }]; +} + +private void RemoveDynamicCustom() +{ + if (dynamicCustoms.Count <= 1) return; + + dynamicCustoms = [.. dynamicCustoms.Take(dynamicCustoms.Count - 1)]; +} + +private void ReverseDynamicCustoms() +{ + dynamicCustoms = [.. Enumerable.Reverse(dynamicCustoms)]; +}"; + + private readonly string example16RazorCode = @" + + + + + + +
+ + Only the selected environment receives the new build. +
+
+
"; + private readonly string example16CsharpCode = @" +public class Order +{ + public string? Name { get; set; } + public string? ItemValue { get; set; } +} + +private readonly List deploymentCustoms = [ - new() { Name = ""Day"", ItemValue = ""Day"", IconName = BitIconName.CalendarDay }, - new() { Name = ""Week"", ItemValue = ""Week"", IconName = BitIconName.CalendarWeek }, - new() { Name = ""Month"", ItemValue = ""Month"", IconName = BitIconName.Calendar, IsDisabled = true } + new() { Name = ""Development"", ItemValue = ""Development"" }, + new() { Name = ""Staging"", ItemValue = ""Staging"" }, + new() { Name = ""Production"", ItemValue = ""Production"" } ];"; - private readonly string example11RazorCode = @" - + + + + + +"; + private readonly string example17CsharpCode = @" +public class Order +{ + public string? Name { get; set; } + public string? ItemValue { get; set; } +} + +private readonly List basicCustoms = +[ + new() { Name = ""Custom A"", ItemValue = ""A"" }, + new() { Name = ""Custom B"", ItemValue = ""B"" }, + new() { Name = ""Custom C"", ItemValue = ""C"" }, + new() { Name = ""Custom D"", ItemValue = ""D"" } +];"; + + private readonly string example18RazorCode = @" +"; + private readonly string example18CsharpCode = @" +public class Order +{ + public string? Name { get; set; } + public string? Tooltip { get; set; } + public string? ItemValue { get; set; } +} + +private readonly List titleCustoms = +[ + new() { Name = ""1 h"", ItemValue = ""1h"", Tooltip = ""Delivered within one hour of dispatch"" }, + new() { Name = ""24 h"", ItemValue = ""24h"", Tooltip = ""Delivered within one business day"" }, + new() { Name = ""72 h"", ItemValue = ""72h"", Tooltip = ""Delivered within three business days"" } +];"; + + private readonly string example19RazorCode = @" + showAutoFocus = !showAutoFocus"">@(showAutoFocus ? ""Unmount"" : ""Mount"") the auto focused ChoiceGroup + +@if (showAutoFocus) +{ + +}"; + private readonly string example19CsharpCode = @" +public class Order +{ + public string Name { get; set; } + public string ItemValue { get; set; } +} + +private bool showAutoFocus; + +private readonly List basicCustoms = +[ + new() { Name = ""Custom A"", ItemValue = ""A"" }, + new() { Name = ""Custom B"", ItemValue = ""B"" }, + new() { Name = ""Custom C"", ItemValue = ""C"" }, + new() { Name = ""Custom D"", ItemValue = ""D"" } +];"; + + private readonly string example20RazorCode = @" +"; - private readonly string example11CsharpCode = @" + private readonly string example20CsharpCode = @" public class Order { public string Name { get; set; } @@ -921,7 +1181,7 @@ public class Order new() { Name = ""Custom D"", ItemValue = ""D"" } ];"; - private readonly string example12RazorCode = @" + private readonly string example21RazorCode = @" @@ -945,7 +1205,7 @@ public class Order NameSelectors=""@(new() { Text = { Name = nameof(Order.Name) }, Value = { Name = nameof(Order.ItemValue) }, Icon = { Name = nameof(Order.Icon) } })"" />"; - private readonly string example12CsharpCode = @" + private readonly string example21CsharpCode = @" public class Order { public string? Name { get; set; } @@ -960,7 +1220,103 @@ public class Order new() { Name = ""Month"", ItemValue = ""Month"", Icon = BitIconInfo.Bi(""calendar-month"") } ];"; - private readonly string example13RazorCode = @" + private readonly string example22RazorCode = @" + + + + + + + i.Name }, + Value = { Selector = i => i.ItemValue }, + IconName = { Selector = i => i.IconName }, + IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal Inline /> + + i.Name }, + Value = { Selector = i => i.ItemValue }, + IconName = { Selector = i => i.IconName }, + IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal Inline /> + + i.Name }, + Value = { Selector = i => i.ItemValue }, + IconName = { Selector = i => i.IconName }, + IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal Inline /> + + i.Name }, + Value = { Selector = i => i.ItemValue }, + IconName = { Selector = i => i.IconName }, + IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal /> + + i.Name }, + Value = { Selector = i => i.ItemValue }, + IconName = { Selector = i => i.IconName }, + IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal /> + + i.Name }, + Value = { Selector = i => i.ItemValue }, + IconName = { Selector = i => i.IconName }, + IsEnabled = { Selector = i => i.IsDisabled is false } })"" Horizontal />"; + private readonly string example22CsharpCode = @" +public class Order +{ + public string Name { get; set; } + public string ItemValue { get; set; } + public string? IconName { get; set; } + public bool IsDisabled { get; set; } +} + +private readonly List basicCustoms = +[ + new() { Name = ""Custom A"", ItemValue = ""A"" }, + new() { Name = ""Custom B"", ItemValue = ""B"" }, + new() { Name = ""Custom C"", ItemValue = ""C"" }, + new() { Name = ""Custom D"", ItemValue = ""D"" } +]; + +private readonly List iconCustoms = +[ + new() { Name = ""Day"", ItemValue = ""Day"", IconName = BitIconName.CalendarDay }, + new() { Name = ""Week"", ItemValue = ""Week"", IconName = BitIconName.CalendarWeek }, + new() { Name = ""Month"", ItemValue = ""Month"", IconName = BitIconName.Calendar, IsDisabled = true } +];"; + + private readonly string example23RazorCode = @" - + @(item.Index + 1).  + +  (@item.Value) + @@ -336,6 +345,10 @@ protected override void OnInitialized() }"; private readonly string example8RazorCode = @" + uncontrolledValue = value"" /> +
Selected: @uncontrolledValue
+ @@ -344,6 +357,7 @@ protected override void OnInitialized() private readonly string example8CsharpCode = @" private string oneWayValue = ""A""; private string twoWayValue = ""A""; +private string? uncontrolledValue = ""A""; private readonly List> basicItems = [ @@ -363,7 +377,7 @@ protected override void OnInitialized() - + validationModel.Value)"" /> Submit @@ -389,74 +403,208 @@ private void HandleInvalidSubmit() { } ];"; private readonly string example10RazorCode = @" - +"; + private readonly string example10CsharpCode = @" +private readonly List> descriptionItems = +[ + new() { Text = ""Daily"", Value = ""Daily"", Description = ""Backs up every night at 2 AM."" }, + new() { Text = ""Weekly"", Value = ""Weekly"", Description = ""Backs up every Sunday at 2 AM."" }, + new() { Text = ""Monthly"", Value = ""Monthly"", Description = ""Backs up on the first day of each month."" } +];"; - + private readonly string example11RazorCode = @" +"; + private readonly string example11CsharpCode = @" +private string readOnlyValue = ""A""; - +private readonly List> basicItems = +[ + new() { Text = ""Item A"", Value = ""A"" }, + new() { Text = ""Item B"", Value = ""B"" }, + new() { Text = ""Item C"", Value = ""C"" }, + new() { Text = ""Item D"", Value = ""D"" } +];"; - + private readonly string example12RazorCode = @" + - +"; + private readonly string example12CsharpCode = @" +private readonly List> basicItems = +[ + new() { Text = ""Item A"", Value = ""A"" }, + new() { Text = ""Item B"", Value = ""B"" }, + new() { Text = ""Item C"", Value = ""C"" }, + new() { Text = ""Item D"", Value = ""D"" } +];"; - + private readonly string example13RazorCode = @" + - +"; + private readonly string example13CsharpCode = @" +private readonly List> prefixItems = +[ + new() { Text = ""Standard"", Value = ""Standard"", Prefix = ""$0 — "" }, + new() { Text = ""Express"", Value = ""Express"", Prefix = ""$10 — "" }, + new() { Text = ""Overnight"", Value = ""Overnight"", Prefix = ""$25 — "" } +]; - +private readonly List> suffixItems = +[ + new() { Text = ""Standard"", Value = ""Standard"", Suffix = ""Free"" }, + new() { Text = ""Express"", Value = ""Express"", Suffix = ""$10"" }, + new() { Text = ""Overnight"", Value = ""Overnight"", Suffix = ""$25"" } +];"; + + private readonly string example14RazorCode = @" + changedValue = value"" + OnClick=""(BitChoiceGroupItem item) => clickedItem = item.Text"" + OnFocus=""(BitChoiceGroupItem item) => focusedItem = item.Text"" + OnBlur=""(BitChoiceGroupItem item) => blurredItem = item.Text"" /> +
Changed value: @changedValue
+
Clicked item: @clickedItem
+
Focused item: @focusedItem
+
Blurred item: @blurredItem
"; + private readonly string example14CsharpCode = @" +private string? changedValue; +private string? clickedItem; +private string? focusedItem; +private string? blurredItem; -"; - private readonly string example10CsharpCode = @" private readonly List> basicItems = [ new() { Text = ""Item A"", Value = ""A"" }, new() { Text = ""Item B"", Value = ""B"" }, new() { Text = ""Item C"", Value = ""C"" }, new() { Text = ""Item D"", Value = ""D"" } +];"; + + private readonly string example15RazorCode = @" +Add item +Remove item +Reverse items + + + + @(item.Index + 1).  + +"; + private readonly string example15CsharpCode = @" +private int dynamicCounter = 3; +private string? dynamicValue = ""1""; +private List> dynamicItems = +[ + new() { Text = ""Item 1"", Value = ""1"" }, + new() { Text = ""Item 2"", Value = ""2"" }, + new() { Text = ""Item 3"", Value = ""3"" } ]; -private readonly List> iconItems = +private void AddDynamicItem() +{ + dynamicCounter++; + dynamicItems = [.. dynamicItems, new BitChoiceGroupItem { Text = $""Item {dynamicCounter}"", Value = $""{dynamicCounter}"" }]; +} + +private void RemoveDynamicItem() +{ + if (dynamicItems.Count <= 1) return; + + dynamicItems = [.. dynamicItems.Take(dynamicItems.Count - 1)]; +} + +private void ReverseDynamicItems() +{ + dynamicItems = [.. Enumerable.Reverse(dynamicItems)]; +}"; + + private readonly string example16RazorCode = @" + + + + + + +
+ + Only the selected environment receives the new build. +
+
+
"; + private readonly string example16CsharpCode = @" +private readonly List> deploymentItems = [ - new() { Text = ""Day"", Value = ""Day"", IconName = BitIconName.CalendarDay }, - new() { Text = ""Week"", Value = ""Week"", IconName = BitIconName.CalendarWeek }, - new() { Text = ""Month"", Value = ""Month"", IconName = BitIconName.Calendar, IsEnabled = false } + new() { Text = ""Development"", Value = ""Development"" }, + new() { Text = ""Staging"", Value = ""Staging"" }, + new() { Text = ""Production"", Value = ""Production"" } ];"; - private readonly string example11RazorCode = @" - + + + + + +"; + private readonly string example17CsharpCode = @" +private readonly List> basicItems = +[ + new() { Text = ""Item A"", Value = ""A"" }, + new() { Text = ""Item B"", Value = ""B"" }, + new() { Text = ""Item C"", Value = ""C"" }, + new() { Text = ""Item D"", Value = ""D"" } +];"; + + private readonly string example18RazorCode = @" +"; + private readonly string example18CsharpCode = @" +private readonly List> titleItems = +[ + new() { Text = ""1 h"", Value = ""1h"", Title = ""Delivered within one hour of dispatch"" }, + new() { Text = ""24 h"", Value = ""24h"", Title = ""Delivered within one business day"" }, + new() { Text = ""72 h"", Value = ""72h"", Title = ""Delivered within three business days"" } +];"; + + private readonly string example19RazorCode = @" + showAutoFocus = !showAutoFocus"">@(showAutoFocus ? ""Unmount"" : ""Mount"") the auto focused ChoiceGroup + +@if (showAutoFocus) +{ + +}"; + private readonly string example19CsharpCode = @" +private bool showAutoFocus; + +private readonly List> basicItems = +[ + new() { Text = ""Item A"", Value = ""A"" }, + new() { Text = ""Item B"", Value = ""B"" }, + new() { Text = ""Item C"", Value = ""C"" }, + new() { Text = ""Item D"", Value = ""D"" } +];"; + + private readonly string example20RazorCode = @" + Horizontal Items=""basicItems"" DefaultValue=""basicItems[0].Value"" />"; - private readonly string example11CsharpCode = @" + private readonly string example20CsharpCode = @" private readonly List> basicItems = [ new() { Text = ""Item A"", Value = ""A"" }, @@ -687,7 +835,7 @@ Horizontal Inline /> new() { Text = ""Item D"", Value = ""D"" } ];"; - private readonly string example12RazorCode = @" + private readonly string example21RazorCode = @" @@ -696,7 +844,7 @@ Horizontal Inline /> "; - private readonly string example12CsharpCode = @" + private readonly string example21CsharpCode = @" private readonly List> externalIconItems = [ new() { Text = ""Day"", Value = ""Day"", Icon = BitIconInfo.Fa(""solid sun"") }, @@ -704,7 +852,74 @@ Horizontal Inline /> new() { Text = ""Month"", Value = ""Month"", Icon = BitIconInfo.Bi(""calendar-month"") } ];"; - private readonly string example13RazorCode = @" + private readonly string example22RazorCode = @" + + + + + + + + + + + + + + + + +"; + private readonly string example22CsharpCode = @" +private readonly List> basicItems = +[ + new() { Text = ""Item A"", Value = ""A"" }, + new() { Text = ""Item B"", Value = ""B"" }, + new() { Text = ""Item C"", Value = ""C"" }, + new() { Text = ""Item D"", Value = ""D"" } +]; + +private readonly List> iconItems = +[ + new() { Text = ""Day"", Value = ""Day"", IconName = BitIconName.CalendarDay }, + new() { Text = ""Week"", Value = ""Week"", IconName = BitIconName.CalendarWeek }, + new() { Text = ""Month"", Value = ""Month"", IconName = BitIconName.Calendar, IsEnabled = false } +];"; + + private readonly string example23RazorCode = @" -"" TValue=""string""> +"" TValue=""string""> @(option.Index + 1).  + +  (@option.Value) + @@ -269,6 +293,17 @@ public partial class _BitChoiceGroupOptionDemo private string itemLabelTemplateValue = ""Day"";"; private readonly string example8RazorCode = @" + uncontrolledValue = value"" + TItem=""BitChoiceGroupOption"" TValue=""string""> + + + + + +
Selected: @uncontrolledValue
+ + "" TValue=""string""> @@ -289,7 +324,8 @@ public partial class _BitChoiceGroupOptionDemo "; private readonly string example8CsharpCode = @" private string oneWayValue = ""A""; -private string twoWayValue = ""A"";"; +private string twoWayValue = ""A""; +private string? uncontrolledValue = ""A"";"; private readonly string example9RazorCode = @" + +"" TValue=""string""> + + + -"" - TValue=""string"" Horizontal Inline> - - - +"" TValue=""string""> + +
+ + Only the selected environment receives the new build. +
+
+ + + + + +
"; + + private readonly string example17RazorCode = @" +"" TValue=""string""> + + + + -"" - TValue=""string"" Horizontal Inline> - - - +"" TValue=""string""> + + + + -"" - TValue=""string"" Horizontal Inline> - - - +"" TValue=""string""> + + + + -"" - TValue=""string"" Horizontal Inline> - - - +"" TValue=""string""> + + + + "; - private readonly string example11RazorCode = @" + private readonly string example18RazorCode = @" +"" TValue=""string""> + + + +"; + + private readonly string example19RazorCode = @" + showAutoFocus = !showAutoFocus"">@(showAutoFocus ? ""Unmount"" : ""Mount"") the auto focused ChoiceGroup + +@if (showAutoFocus) +{ + "" TValue=""string""> + + + + + +}"; + private readonly string example19CsharpCode = @" +private bool showAutoFocus;"; + + private readonly string example20RazorCode = @" "" TValue=""string"" Horizontal> @@ -646,7 +801,7 @@ private void HandleValidSubmit() { } "; - private readonly string example12RazorCode = @" + private readonly string example21RazorCode = @" @@ -668,7 +823,89 @@ private void HandleValidSubmit() { } "; - private readonly string example13RazorCode = @" + private readonly string example22RazorCode = @" +"" TValue=""string"" Horizontal> + + + + + + +"" TValue=""string"" Horizontal> + + + + + + +"" TValue=""string"" Horizontal> + + + + + + +"" + TValue=""string"" Horizontal Inline> + + + + + +"" + TValue=""string"" Horizontal Inline> + + + + + +"" + TValue=""string"" Horizontal Inline> + + + + + +"" + TValue=""string"" Horizontal> + + + + + +"" + TValue=""string"" Horizontal> + + + + + +"" + TValue=""string"" Horizontal> + + + +"; + + private readonly string example23RazorCode = @"