From d4efbd6a2d5c5b044fef7da86b537532f20f1144 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 20 Sep 2026 08:40:01 +0330 Subject: [PATCH 1/7] Improve theme infra of BitDropdown #13307 --- .../Inputs/Dropdown/BitDropdown.razor | 16 +- .../Inputs/Dropdown/BitDropdown.razor.cs | 69 ++ .../Inputs/Dropdown/BitDropdown.scss | 653 +++++++++++------- src/BlazorUI/CLAUDE.md | 11 + .../Inputs/Dropdown/BitDropdownDemo.razor | 1 + .../Inputs/Dropdown/BitDropdownDemo.razor.cs | 255 +++++++ .../Dropdown/_BitDropdownCustomDemo.razor | 33 + .../_BitDropdownCustomDemo.razor.samples.cs | 30 +- .../Dropdown/_BitDropdownItemDemo.razor | 32 + .../_BitDropdownItemDemo.razor.samples.cs | 29 +- .../Dropdown/_BitDropdownOptionDemo.razor | 41 ++ .../_BitDropdownOptionDemo.razor.samples.cs | 36 +- .../Inputs/Dropdown/BitDropdownTests.cs | 86 ++- 13 files changed, 1029 insertions(+), 263 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor index b4fadcdb8c7..00b48d64349 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor @@ -190,11 +190,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() } } @@ -391,7 +396,7 @@ id="@_calloutId" tabindex="0" dir="@Dir?.ToString().ToLower()" - style="@Styles?.Callout" + style="@GetCalloutStyles()" class="@GetCalloutCssClasses()"> @if (Responsive) @@ -440,11 +445,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. *@ diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor.cs index 612b2f29b0a..81c6980ef55 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.razor.cs @@ -3978,6 +3978,75 @@ private void SetIsSelectedForSelectedItems() } } + // The public custom properties of the component, which are what its stylesheet reads off the root with a + // fallback (see BitDropdown.scss). Nothing else in a style string is copied to the callout. + private const string PUBLIC_CSS_VARIABLE_PREFIX = "--bit-Dropdown-"; + + private string? _calloutStyles; + private string? _lastRootStyle; + private string? _lastStylesRoot; + private string? _lastStylesCallout; + + // The callout is rendered outside the root element - and reparented to the body while it is open - so it + // inherits nothing an author sets on the dropdown: neither the Style of the instance nor a custom property + // declared on an ancestor of it (only :root and body stay ancestors of it once it has moved). The public + // --bit-Dropdown-* declarations are therefore carried across by hand, so ONE Style on the component + // restyles the field and the list it opens together, the way it reads as if it would. + // Styles.Callout is appended last, so a value written for the callout still wins over the copy. + private string? GetCalloutStyles() + { + var style = Style; + var stylesRoot = Styles?.Root; + var stylesCallout = Styles?.Callout; + + // Rebuilt only when one of the three strings it is made of has actually changed: the callout is + // re-rendered on every keystroke typed into the search box, and parsing three style strings per + // render for a result that almost never changes is work no one asked for. + if (string.Equals(style, _lastRootStyle, StringComparison.Ordinal) && + string.Equals(stylesRoot, _lastStylesRoot, StringComparison.Ordinal) && + string.Equals(stylesCallout, _lastStylesCallout, StringComparison.Ordinal)) + { + return _calloutStyles; + } + + _lastRootStyle = style; + _lastStylesRoot = stylesRoot; + _lastStylesCallout = stylesCallout; + + StringBuilder? builder = null; + + AppendPublicCssVariables(ref builder, style); + AppendPublicCssVariables(ref builder, stylesRoot); + + if (builder is null) + { + _calloutStyles = stylesCallout; + } + else + { + if (stylesCallout.HasValue()) + { + builder.Append(stylesCallout); + } + + _calloutStyles = builder.ToString(); + } + + return _calloutStyles; + } + + private static void AppendPublicCssVariables(ref StringBuilder? builder, string? style) + { + if (style.HasNoValue()) return; + + foreach (var declaration in style!.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (declaration.StartsWith(PUBLIC_CSS_VARIABLE_PREFIX, StringComparison.Ordinal) is false) continue; + + (builder ??= new StringBuilder()).Append(declaration).Append(';'); + } + } + private string GetCalloutCssClasses() { List classes = ["bit-drp-cal"]; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.scss index 2a029e40d07..880334c028f 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.scss @@ -1,16 +1,131 @@ @import "../../../Styles/functions.scss"; @import "../../../Styles/media-queries.scss"; +// Public CSS variables, read with a fallback and never declared here, so a value set on :root re-skins +// every dropdown and one set on the Style of an instance re-skins that one alone. +// The callout is rendered outside the root element (and reparented to the body while it is open), so the +// component copies the --bit-Dropdown-* declarations of Style and Styles.Root onto it - see GetCalloutStyles. +// +// FIELD +// --bit-Dropdown-background background of the field (default: $clr-bg-pri, transparent with Transparent) +// --bit-Dropdown-color text of the field once something +// is selected (default: $clr-fg-pri) +// --bit-Dropdown-placeholder-color text of the field while it is empty (default: $clr-fg-sec) +// --bit-Dropdown-border-color border at rest (default: $clr-brd-pri) +// --bit-Dropdown-hover-border-color border on hover (default: $clr-brd-pri-hover) +// --bit-Dropdown-focus-color focus indicator color (default: the role's focus color) +// --bit-Dropdown-invalid-color border and focus indicator while the +// bound value fails validation (default: $clr-err / $clr-err-focus) +// --bit-Dropdown-disabled-color text and glyphs when disabled (default: $clr-fg-dis) +// --bit-Dropdown-disabled-background background when disabled (default: $clr-bg-dis) +// --bit-Dropdown-disabled-border-color border when disabled (default: $clr-brd-dis) +// --bit-Dropdown-radius corner radius of the field (default: $shp-radius-control) +// --bit-Dropdown-min-height smallest height of the field (default: per size, $siz-ctrl-*) +// --bit-Dropdown-font-size text size of the field (default: per size, from the type ramp) +// --bit-Dropdown-icon-color caret and affix glyphs (default: $clr-fg-sec) +// --bit-Dropdown-icon-size size of the glyphs of the component (default: per size, $siz-icon-*) +// +// ACCENT - the role color, which reaches the group headers, the search glyph, the checkbox fill, the +// search-match highlight, the overflow chip, the spinner arc and the bar marking the commit target. +// --bit-Dropdown-accent-color the accent itself (default: the Color role's main color) +// --bit-Dropdown-accent-hover-color the accent while the part is hovered (default: the role's hover color) +// --bit-Dropdown-accent-text-color text and glyphs drawn ON the accent - +// set it along with the accent to keep the +// check mark and the highlight legible (default: the role's on-color) +// +// LABEL AND DESCRIPTION +// --bit-Dropdown-label-color label text (default: $clr-fg-pri) +// --bit-Dropdown-label-font-size label text size (default: --bit-Dropdown-font-size) +// --bit-Dropdown-label-font-weight label weight (default: $tg-fw-semibold) +// --bit-Dropdown-required-color the asterisk a Required field carries (default: $clr-req) +// --bit-Dropdown-description-color description text (default: $clr-fg-pri) +// --bit-Dropdown-description-font-size description text size (default: $tg-fs-2xs) +// +// CHIPS (the multi select Chips display) +// --bit-Dropdown-chip-background background of a chip (default: $clr-bg-sec) +// --bit-Dropdown-chip-color text of a chip (default: inherited from the field) +// --bit-Dropdown-chip-border-color border of a chip (default: $clr-brd-sec) +// --bit-Dropdown-chip-radius corner radius of a chip (default: $shp-radius-chip) +// +// CALLOUT +// --bit-Dropdown-callout-background background of the list surface (default: $clr-bg-pri) +// --bit-Dropdown-callout-radius corner radius of that surface (default: $shp-radius-popup) +// --bit-Dropdown-callout-shadow its elevation (default: $box-shadow-popup) +// --bit-Dropdown-callout-max-height how tall the scrolling list may grow (default: $siz-popup-max-height) +// --bit-Dropdown-overlay-background the layer between the page and an open +// callout, transparent by default - give it +// a color for a modal-style scrim (default: transparent) +// +// ITEMS +// --bit-Dropdown-item-height height of one row (default: per size, $siz-item-*) +// --bit-Dropdown-item-font-size text size of one row (default: per size, from the type ramp) +// --bit-Dropdown-item-color text of one row (default: $clr-fg-pri) +// --bit-Dropdown-item-hover-background background of the hovered row (default: $clr-bg-pri-hover) +// --bit-Dropdown-item-selected-background background of a selected row (default: $clr-bg-sec) +// --bit-Dropdown-item-disabled-color text of a row that cannot be picked (default: $clr-fg-dis) +// --bit-Dropdown-header-color text of a group header (default: the accent) +// --bit-Dropdown-divider-color the rule a Divider item draws (default: $clr-bg-sec) + + // The size of the control and of its callout is driven by these variables so a single size class, // applied to the root element and repeated on the callout, resizes every part consistently. // A preset that raises the control height (Material 40px, Cupertino 36px) then resizes the trigger, // the callout and the rows inside it together. +@mixin drp-size($fs, $h, $lbl-lh, $itm-h, $itm-fs, $ico, $sel) { + --bit-drp-sz-fs: #{$fs}; + --bit-drp-sz-h: #{$h}; + --bit-drp-sz-lbl-lh: #{$lbl-lh}; + --bit-drp-sz-itm-h: #{$itm-h}; + --bit-drp-sz-itm-fs: #{$itm-fs}; + --bit-drp-sz-ico: #{$ico}; + --bit-drp-sz-sel: #{$sel}; +} + @mixin drp-size-medium { - --bit-drp-fs: #{$tg-fs-sm}; - --bit-drp-h: #{$siz-ctrl-md}; - --bit-drp-lbl-lh: #{spacing(2.75)}; - --bit-drp-itm-h: #{$siz-item-md}; - --bit-drp-itm-fs: #{$tg-fs-sm}; + @include drp-size($tg-fs-sm, $siz-ctrl-md, spacing(2.75), $siz-item-md, $tg-fs-sm, $siz-icon-md, $siz-sel-md); +} + +// The one place the public variables meet the per-size and per-role defaults. Every rule below reads the +// private variables this leaves behind, so no use site repeats the fallback chain - and the block is +// included on the callout as well, which is a sibling of the root and inherits nothing from it. +@mixin drp-vars { + --bit-drp-fs: var(--bit-Dropdown-font-size, var(--bit-drp-sz-fs)); + --bit-drp-h: var(--bit-Dropdown-min-height, var(--bit-drp-sz-h)); + --bit-drp-lbl-lh: var(--bit-drp-sz-lbl-lh); + --bit-drp-itm-h: var(--bit-Dropdown-item-height, var(--bit-drp-sz-itm-h)); + --bit-drp-itm-fs: var(--bit-Dropdown-item-font-size, var(--bit-drp-sz-itm-fs)); + --bit-drp-ico: var(--bit-Dropdown-icon-size, var(--bit-drp-sz-ico)); + --bit-drp-sel: var(--bit-drp-sz-sel); + --bit-drp-radius: var(--bit-Dropdown-radius, #{$shp-radius-control}); + + // The accent, and the two colors that go with it. A theme that repaints the accent has to be able to + // repaint what is drawn on top of it too, or the check mark and the highlighted text lose their contrast. + --bit-drp-clr: var(--bit-Dropdown-accent-color, var(--bit-drp-role-main, #{$clr-pri})); + --bit-drp-clr-hover: var(--bit-Dropdown-accent-hover-color, var(--bit-Dropdown-accent-color, var(--bit-drp-role-hover, #{$clr-pri-hover}))); + --bit-drp-clr-text: var(--bit-Dropdown-accent-text-color, var(--bit-drp-role-on, #{$clr-pri-text})); + --bit-drp-clr-focus: var(--bit-Dropdown-focus-color, var(--bit-drp-role-focus, #{$clr-pri-focus})); + + --bit-drp-bg: #{$clr-bg-pri}; + --bit-drp-itm-clr: var(--bit-Dropdown-item-color, #{$clr-fg-pri}); + --bit-drp-itm-hover-bg: var(--bit-Dropdown-item-hover-background, #{$clr-bg-pri-hover}); + --bit-drp-itm-sel-bg: var(--bit-Dropdown-item-selected-background, #{$clr-bg-sec}); + --bit-drp-itm-dis-clr: var(--bit-Dropdown-item-disabled-color, #{$clr-fg-dis}); + --bit-drp-cal-bg: var(--bit-Dropdown-callout-background, #{$clr-bg-pri}); +} + +// The focus indicator of everything that sits INSIDE a box the browser may clip - the rows of the scrolling +// list, the buttons flush with the edge of the field or of the search box. An outline drawn inwards is never +// cut off by an ancestor's overflow the way the outward ring of focus-ring would be, and it survives a +// forced-colors palette, which strips the box-shadow that ring is drawn with. +// Every one of these is a