diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor index b4fadcdb8c7..d1eb298d9f5 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor @@ -23,6 +23,19 @@ var hasChipRemoveButtons = Chips && IsEnabled && ReadOnly is false && _selectedItems.Take(GetDisplayedItemsCount()).Any(GetIsEnabled); + // The combobox role, and the state that goes with it, belong on the element the keyboard actually + // lands on - which is the field itself only while it has nothing focusable inside it. The ComboBox + // mode has an input, so the role moves onto it: a field that keeps a tab stop of its own around an + // input is two tab stops for one control, and the element a screen reader then describes is a plain + // text box that reports neither that it has a list nor whether that list is showing. + var comboboxOnField = Combo is false; + + // The ComboBox input of the responsive panel takes the focus while that panel is on the screen, and + // the combobox role goes with it: the field's own input is behind the panel and its overlay, so + // leaving it a combobox would point a second one at the same listbox and leave a tab stop on an + // element the user cannot see. + var comboboxOnFieldInput = Combo && _isResponsiveMode is false; + RenderFragment emptyContent = @* The empty state is not an option, so it is marked presentational to keep it out of the options of the listbox it is rendered in; its text stays exposed either way. *@ @@ -100,34 +113,30 @@ } - @* The combobox is the interactive element of this input, so it is the one InputElement points at: - the inherited FocusAsync of BitInputBase then focuses what a user would focus by hand. *@ - - @* The combobox role is on this element, so the autocomplete behavior of the ComboBox mode has - to be reported here and not only on the input it wraps. *@ + @* The field is the interactive element of a dropdown that is not typed into, so it is the one + InputElement points at and the one FocusAsync (overridden for the ComboBox mode) focuses. *@
+ role="@(comboboxOnField ? "combobox" : null)" + autofocus="@(comboboxOnField && AutoFocus)" + tabindex="@(IsEnabled && comboboxOnField ? (TabIndex ?? "0") : "-1")" + aria-haspopup="@(comboboxOnField ? "listbox" : null)" + aria-required="@(comboboxOnField && Required ? "true" : null)" + aria-readonly="@(comboboxOnField && ReadOnly ? "true" : null)" + aria-invalid="@(comboboxOnField && IsInvalid ? "true" : null)" + aria-disabled="@(comboboxOnField && IsEnabled is false ? "true" : null)" + aria-expanded="@(comboboxOnField ? (IsOpen ? "true" : "false") : null)" + aria-label="@(comboboxOnField ? AriaLabel : null)" + aria-labelledby="@(comboboxOnField && AriaLabel.HasNoValue() ? GetDropdownAriaLabelledby() : null)" + aria-describedby="@(comboboxOnField ? DescribedBy : null)" + aria-controls="@(comboboxOnField && IsOpen ? _scrollContainerId : null)"> @if (PrefixTemplate is not null) { @@ -190,11 +199,16 @@ @* The chip only shows how many selections it stands for. The ones it hides are named in its title for the pointer, and take its place in the accessible name so that the combobox (which is named after this container) still reports them all. *@ + @* aria-label is not exposed on an element with no role of its own, so the items the + chip stands for are RENDERED for a screen reader rather than asserted in an + attribute: the counter is hidden from it and the names take its place, both in + the accessible name of the combobox (computed from this subtree) and in the + content a reader walks through. The pointer gets them from the title. *@ - @GetOverflowText() + title="@GetOverflowItemsText()"> + + @GetOverflowItemsText() } } @@ -204,6 +218,16 @@ { @TextTemplate(this) } + else if (GetOverflowItemsCount() > 0) + { + @* Past the display limit the text is a count of the selection, which is all a + screen reader would otherwise be told the field holds - and the names of what + is selected are exactly what it is for. They are rendered in its place the + same way the overflow chip renders the ones it stands for, and the pointer + gets them from the title. *@ + + @GetSelectedItemsText() + } else { @GetText() @@ -231,13 +255,16 @@ aria-labelledby is read whatever its hidden state, so it names the combobox while staying out of the content a screen reader walks through - where the chips already carry the same text. *@ - + } @if (Combo) { - @* An input does not inherit the accessible name of the combobox element around - it, so without an AriaLabel it is named after the label by hand. *@ + @* The combobox of this mode: the element the focus lands on, so it is the one that + reports the list and whether it is showing. An input does not inherit the accessible + name of anything around it, so without an AriaLabel it is named after the label by + hand. Its readonly and disabled states are the native attributes, which is what the + aria- pair would only be restating. *@ @@ -322,6 +356,27 @@ } + @if (HasErrorMessage) + { + @* What is wrong with the selection is the first thing to read under the field, so it comes + before the description. It carries no live role of its own - the region at the end of the + component is what announces it, so it is never read out twice. *@ +
+ @if (ErrorMessageTemplate is not null) + { + @ErrorMessageTemplate + } + else + { + + @ErrorMessage + + } +
+ } + @if (HasDescription) { @* Named by the combobox through aria-describedby, so it is read as part of the dropdown @@ -342,6 +397,13 @@ } + @if (AriaDescription.HasValue()) + { + @* aria-describedby takes an id reference, so the text written for a screen reader alone has to + live in an element of its own. It is only ever read as part of the dropdown. *@ + @AriaDescription + } + @* A search silently changes the length of a list a screen reader user cannot see, so the new result count is announced. The container is always rendered because a live region has to be in the accessibility tree before its text changes for the change to be announced; only the text @@ -350,7 +412,14 @@ live region inside a hidden element announces nothing - which the loading state, the one state that changes while the callout may well be closed, depends on. *@
- @if (IsLoading) + @* The rejection wins over everything else here: the rest say what the dropdown is doing, this + says what is wrong with what it holds. Text only, never ErrorMessageTemplate, for the same + reason LoadingTemplate is left out below. *@ + @if (ErrorMessage.HasValue()) + { + @ErrorMessage + } + else if (IsLoading) { @* Text only, never LoadingTemplate: the template is arbitrary markup meant to be seen - a spinner, an image, something interactive - and rendering it a second time here would @@ -375,8 +444,10 @@
+@* Like the callout, the overlay is a sibling of the root element and is reparented to the body while it + is open, so the public variables it reads are copied onto it by hand - see GetOverlayStyles. *@
@@ -391,7 +462,7 @@ id="@_calloutId" tabindex="0" dir="@Dir?.ToString().ToLower()" - style="@Styles?.Callout" + style="@GetCalloutStyles()" class="@GetCalloutCssClasses()"> @if (Responsive) @@ -440,11 +511,14 @@ style="@Styles?.SearchBoxIcon" class="@searchBoxIconCss @Classes?.SearchBoxIcon" /> + @* aria-controls names the list this box filters: the box sits outside the listbox and would + otherwise be a field that narrows nothing a screen reader can tie it to. *@ @@ -476,18 +550,23 @@ {
- @* An input does not inherit the accessible name of the combobox element around - it, so without an AriaLabel it is named after the label by hand. *@ + @* The input of the responsive panel, which takes the focus - and with it the combobox role + and its state - from the one in the field for as long as the panel is on the screen. *@ - @* Reached with the arrow keys like the options below it, so it stays out of the tab order too. *@ + @* Reached with the arrow keys like the options below it, so it stays out of the tab order too. + It sits outside the listbox it commands, so nothing but its aria-controls says which + list it is the select all of. *@