Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
class="@ClassBuilder.Value"
dir="@Dir?.ToString().ToLower()"
aria-label="@AriaLabel"
aria-labelledby="@GetAriaLabelledBy()">
aria-labelledby="@GetAriaLabelledBy()"
aria-required="@(Required ? "true" : null)"
aria-readonly="@(ReadOnly ? "true" : null)"
aria-disabled="@(IsEnabled ? null : "true")"
aria-orientation="@(Horizontal ? "horizontal" : "vertical")">

<label id="@_labelId"
disabled="@(IsEnabled is false)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace Bit.BlazorUI;
{
private List<TItem> _items = [];
private string _name = default!;
private bool _autoFocusDone;
private string _labelId = default!;
private bool _optionsOrderDirty;
private string _optionsContainerId = default!;
Expand All @@ -26,6 +27,11 @@ namespace Bit.BlazorUI;
/// </summary>
[Parameter] public string? AriaLabelledBy { get; set; }

/// <summary>
/// Determines if the ChoiceGroup is auto focused on first render, focusing its checked item (or its first item).
/// </summary>
[Parameter] public bool AutoFocus { get; set; }

/// <summary>
/// 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
Expand All @@ -52,6 +58,12 @@ namespace Bit.BlazorUI;
[Parameter, ResetClassBuilder]
public BitColor? Color { get; set; }

/// <summary>
/// The gap between the items of the ChoiceGroup.
/// </summary>
[Parameter, ResetStyleBuilder]
public string? Gap { get; set; }

/// <summary>
/// Renders the items in the ChoiceGroup horizontally.
/// </summary>
Expand Down Expand Up @@ -313,6 +325,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)
Expand Down Expand Up @@ -357,6 +371,30 @@ private void SetIndexItems()

internal string? GetInputId(TItem item) => GetId(item) ?? $"ChoiceGroup-{UniqueId}-Input-{GetValue(item)}";

internal string GetDescriptionId(TItem item) => $"{GetInputId(item)}-description";

// Keeps the InputElement of the base class pointing at the input of the checked item (or the first
// item when nothing is checked), so FocusAsync targets the same input the Tab key would land on.
// Called by each item after render; also performs the one-time AutoFocus on that input.
internal async Task SetInputElement(TItem item, ElementReference inputElement)
{
var isChecked = GetIsCheckedItem(item);

if (isChecked is false)
{
if (_items.Any(GetIsCheckedItem)) return;
if (ReferenceEquals(_items.FirstOrDefault(), item) is false) return;
}

InputElement = inputElement;

if (AutoFocus && _autoFocusDone is false && IsEnabled && ReadOnly is false && GetIsEnabled(item))
{
_autoFocusDone = true;
await InputElement.FocusAsync();
}
}
Comment thread
msynk marked this conversation as resolved.

Comment thread
msynk marked this conversation as resolved.
Outdated
internal async Task HandleClick(TItem item)
{
if (IsEnabled is false || ReadOnly || GetIsEnabled(item) is false) return;
Expand Down Expand Up @@ -680,6 +718,28 @@ internal BitImageSize GetImageSize(TItem item)
return item.GetValueFromProperty<RenderFragment<TItem>?>(NameSelectors.Template.Name);
}

internal string? GetDescription(TItem item)
{
if (item is BitChoiceGroupItem<TValue> choiceGroupItem)
{
return choiceGroupItem.Description;
}

if (item is BitChoiceGroupOption<TValue> 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<string?>(NameSelectors.Description.Name);
}

internal string? GetText(TItem item)
{
if (item is BitChoiceGroupItem<TValue> choiceGroupItem)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,27 @@
}
}

// 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%);
}

.bit-chg-inp:focus-visible + .bit-chg-itl {
outline-offset: spacing(0.25);
outline: spacing(0.25) solid var(--bit-chg-clr);
}

.bit-chg-itl {
display: flex;
cursor: pointer;
Expand All @@ -140,6 +161,17 @@
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;
Expand Down Expand Up @@ -339,6 +371,10 @@
.bit-chg-ico {
color: $clr-fg-dis;
}

.bit-chg-dsc {
color: $clr-fg-dis;
}
}

.bit-chg-pri {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,9 @@ public class BitChoiceGroupClassStyles
/// Custom CSS classes/styles for the text of each item of the BitChoiceGroup.
/// </summary>
public string? ItemText { get; set; }

/// <summary>
/// Custom CSS classes/styles for the description of each item of the BitChoiceGroup.
/// </summary>
public string? ItemDescription { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ public class BitChoiceGroupItem<TValue>
/// </summary>
public string? Class { get; set; }

/// <summary>
/// The secondary text to show under the text of the BitChoiceGroup item.
/// </summary>
public string? Description { get; set; }

/// <summary>
/// Id attribute of the BitChoiceGroup item.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ public class BitChoiceGroupNameSelectors<TItem, TValue>
/// </summary>
public BitNameSelectorPair<TItem, string?> Class { get; set; } = new(nameof(BitChoiceGroupItem<TValue>.Class));

/// <summary>
/// The Description field name and selector of the custom input class.
/// </summary>
public BitNameSelectorPair<TItem, string?> Description { get; set; } = new(nameof(BitChoiceGroupItem<TValue>.Description));

/// <summary>
/// The Id field name and selector of the custom input class.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public partial class BitChoiceGroupOption<TValue> : ComponentBase, IDisposable
/// </summary>
[Parameter] public string? Class { get; set; }

/// <summary>
/// The secondary text to show under the text of the BitChoiceGroup option.
/// </summary>
[Parameter] public string? Description { get; set; }

/// <summary>
/// Id attribute of the BitChoiceGroup option.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,27 @@
var inputId = ChoiceGroup.GetInputId(Item);
var template = ChoiceGroup.GetTemplate(Item);
var isChecked = ChoiceGroup.GetIsCheckedItem(Item);
var description = ChoiceGroup.GetDescription(Item);
var descriptionId = ChoiceGroup.GetDescriptionId(Item);
}

<div style="@ChoiceGroup.GetItemContainerCssStyles(Item)" class="@ChoiceGroup.GetItemContainerCssClasses(Item)" @attributes="ChoiceGroup.GetItemMarkerAttributes(Item)">
<input @ref="_inputElement" @attributes="ChoiceGroup.InputHtmlAttributes"
@onclick="e => ChoiceGroup.HandleClick(Item)"
@onclick:preventDefault="ChoiceGroup.ReadOnly"
@onchange="e => ChoiceGroup.HandleChange(Item)"
type="radio"
id="@inputId"
checked=@isChecked
class="bit-chg-inp"
name="@ChoiceGroup.GetName()"
required="@ChoiceGroup.Required"
tabindex="@(ChoiceGroup.ReadOnly ? "-1" : null)"
aria-label="@ChoiceGroup.GetAriaLabel(Item)"
aria-describedby="@(description.HasValue() ? descriptionId : null)"
value="@ChoiceGroup.GetValue(Item)?.ToString()"
disabled="@(ChoiceGroup.GetIsEnabled(Item) is false)" />

<label for="@inputId" style="@ChoiceGroup.Styles?.ItemLabel" class="bit-chg-itl @ChoiceGroup.GetItemLabelCssClasses(Item) @ChoiceGroup.Classes?.ItemLabel">
@if (template is not null)
{
Expand Down Expand Up @@ -71,6 +89,17 @@
{
<div style="@ChoiceGroup.Styles?.ItemTextWrapper" class="bit-chg-itw @ChoiceGroup.Classes?.ItemTextWrapper">
<span style="@ChoiceGroup.Styles?.ItemText" class="bit-chg-itx @ChoiceGroup.Classes?.ItemText">@ChoiceGroup.GetText(Item)</span>
@if (description.HasValue())
{
<span id="@descriptionId" style="@ChoiceGroup.Styles?.ItemDescription" class="bit-chg-dsc @ChoiceGroup.Classes?.ItemDescription">@description</span>
}
</div>
}
else if (description.HasValue())
{
<div class="bit-chg-txd">
<span style="@ChoiceGroup.Styles?.ItemText" class="bit-chg-itx @ChoiceGroup.Classes?.ItemText">@ChoiceGroup.GetText(Item)</span>
<span id="@descriptionId" style="@ChoiceGroup.Styles?.ItemDescription" class="bit-chg-dsc @ChoiceGroup.Classes?.ItemDescription">@description</span>
</div>
}
else
Expand All @@ -80,17 +109,4 @@
}
}
</label>

<input @attributes="ChoiceGroup.InputHtmlAttributes"
@onclick="e => ChoiceGroup.HandleClick(Item)"
@onchange="e => ChoiceGroup.HandleChange(Item)"
hidden
type="radio"
name="@ChoiceGroup.GetName()"
id="@inputId"
checked=@isChecked
required="@ChoiceGroup.Required"
aria-label="@ChoiceGroup.GetAriaLabel(Item)"
value="@ChoiceGroup.GetValue(Item)?.ToString()"
disabled="@(ChoiceGroup.GetIsEnabled(Item) is false)" />
</div>
</div>
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
namespace Bit.BlazorUI;
namespace Bit.BlazorUI;

public partial class _BitChoiceGroupItem<TItem, TValue> : ComponentBase where TItem : class, new ()
{
private ElementReference _inputElement;

[Parameter] public TItem Item { get; set; } = default!;

[Parameter] public BitChoiceGroup<TItem, TValue> ChoiceGroup { get; set; } = default!;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);

await ChoiceGroup.SetInputElement(Item, _inputElement);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

<PageOutlet Url="components/choicegroup"
Title="ChoiceGroup"
Description="ChoiceGroup lets people select a single option from two or more choices." />
Description="ChoiceGroup, also known as Radio or RadioGroup, lets people select a single value from two or more mutually exclusive choices, using fully keyboard accessible native radio inputs with support for icons, images, descriptions, and custom templates." />

<div>
<DemoPage Name="ChoiceGroup"
SecondaryNames="@(["Radio", "RadioButton", "RadioGroup", "RadioButtonGroup"])"
Description="ChoiceGroup lets people select a single option from two or more choices."
Description="ChoiceGroup, also known as Radio or RadioGroup, lets people select a single value from two or more mutually exclusive choices. It renders fully keyboard accessible native radio inputs and supports icons, images, prefixes, per-item descriptions, and custom templates through three different item APIs."
Parameters="componentParameters"
SubEnums="componentSubEnums"
SubClasses="componentSubClasses"
Expand Down
Loading
Loading