diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss index 6fcc34e306..4139de8936 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss @@ -139,7 +139,7 @@ $rte-rule-color: var(--bit-RichTextEditor-table-border-color, #{$clr-brd-pri}); display: block; color: inherit; font-size: $tg-fs-sm; - font-weight: $tg-fw-semibold; + font-weight: $tg-field-label-font-weight; letter-spacing: $tg-ctrl-letter-spacing; padding: spacing(0.75) spacing(1) spacing(0.5); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss index 537daf47cd..9250fc3b1f 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss @@ -23,7 +23,7 @@ // --bit-Rating-active-scale how much the pressed item dips (default: 0.9; 1 turns it off) // --bit-Rating-label-color text color of the label (default: $clr-fg-pri) // --bit-Rating-label-font-size text size of the label (default: $tg-fs-sm) -// --bit-Rating-label-font-weight text weight of the label (default: $tg-fw-semibold) +// --bit-Rating-label-font-weight text weight of the label (default: $tg-field-label-font-weight) // --bit-Rating-label-gap room between the label and the items, and // between the items and the description (default: spacing(1)) // --bit-Rating-description-color text color of the description (default: $clr-fg-sec) @@ -161,7 +161,7 @@ $rtg-private-properties: clr, clr-hover, clr-active, clr-focus, size, lbl-size, letter-spacing: $tg-ctrl-letter-spacing; color: var(--bit-Rating-label-color, #{$clr-fg-pri}); font-size: var(--bit-rtg-lbl-size, var(--bit-Rating-label-font-size, #{$tg-fs-sm})); - font-weight: var(--bit-Rating-label-font-weight, #{$tg-fw-semibold}); + font-weight: var(--bit-Rating-label-font-weight, #{$tg-field-label-font-weight}); } // The hint that sits under the items, pointed at by aria-describedby rather than read as part of the diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss index 392a310df3..3c7eca44a9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss @@ -48,7 +48,7 @@ // --bit-Toggle-content-padding inset of the track content from the ends of // the track (default: spacing(0.375)) // --bit-Toggle-label-font-size the label (default: $tg-fs-sm) -// --bit-Toggle-label-font-weight the label (default: $tg-fw-semibold) +// --bit-Toggle-label-font-weight the label (default: $tg-field-label-font-weight) // --bit-Toggle-text-font-size the state text (default: inherited from the page) // --bit-Toggle-description-font-size the description (default: $tg-fs-xs) // --bit-Toggle-error-font-size the error message line (default: $tg-fs-xs) diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs index c39db5a61b..2b4e6f948e 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs @@ -554,7 +554,7 @@ public partial class BitRatingDemo new() { Name = "--bit-Rating-label-font-weight", - DefaultValue = "--bit-tpg-fw-semibold", + DefaultValue = "--bit-tpg-field-label-font-weight", Description = "Text weight of the label.", }, new() diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs new file mode 100644 index 0000000000..347d6178c4 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitFieldLabelFontWeightContractTests.cs @@ -0,0 +1,254 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Utils.Theme; + +/// +/// Pins that every caption naming a form field takes its weight from the theme's field-label token +/// ($tg-field-label-font-weight), the one BitLabel reads too, and nothing else. +/// +/// +/// Fluent captions a field semibold, while Fluent 2, Material and Cupertino caption it regular, so a caption that +/// reads any weight of its own - the semibold step, another step, $tg-font-weight or a literal - stays that +/// weight under those presets beside every other input and BitLabel. Reading the token through the theme also keeps +/// a caption following a FontWeights override of a BitThemeProvider, since the alias is re-declared for the +/// overlay (the typography group). +/// +/// Every class a component stylesheet names like a caption (a part ending in -lbl, -rlb or +/// -label) is classified below, either as a field's caption or as a label that is not one (a button's text, a +/// tag's, a pager's), so a new input's caption cannot slip past the contract unlisted. Every rule that +/// targets a field caption is read - a grouped selector, a nested one, one inside @media - and each of its +/// font-weight declarations has to read the token. +/// +/// +[TestClass] +public sealed class BitFieldLabelFontWeightContractTests +{ + private const string FieldLabelToken = "$tg-field-label-font-weight"; + + // The captions that name a form field, with the stylesheet each is styled in. + private static readonly (string Caption, string Path)[] FieldCaptions = + [ + (".bit-chg-lbl", "Bit.BlazorUI/Components/Inputs/ChoiceGroup/BitChoiceGroup.scss"), + (".bit-ctp-lbl", "Bit.BlazorUI/Components/Inputs/CircularTimePicker/BitCircularTimePicker.scss"), + (".bit-clp-lbl", "Bit.BlazorUI/Components/Inputs/ColorPicker/BitColorPicker.scss"), + (".bit-dtp-lbl", "Bit.BlazorUI/Components/Inputs/DatePicker/BitDatePicker.scss"), + (".bit-dtrp-lbl", "Bit.BlazorUI/Components/Inputs/DateRangePicker/BitDateRangePicker.scss"), + (".bit-drp-lbl", "Bit.BlazorUI/Components/Inputs/Dropdown/BitDropdown.scss"), + (".bit-nfl-lbl", "Bit.BlazorUI/Components/Inputs/NumberField/BitNumberField.scss"), + (".bit-otp-lbl", "Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss"), + (".bit-rtg-lbl", "Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss"), + (".bit-srb-lbl", "Bit.BlazorUI/Components/Inputs/SearchBox/BitSearchBox.scss"), + (".bit-sld-lbl", "Bit.BlazorUI/Components/Inputs/Slider/BitSlider.scss"), + (".bit-tgi-lbl", "Bit.BlazorUI/Components/Inputs/TagsInput/BitTagsInput.scss"), + (".bit-tfl-lbl", "Bit.BlazorUI/Components/Inputs/TextField/BitTextField.scss"), + (".bit-tpc-lbl", "Bit.BlazorUI/Components/Inputs/TimePicker/BitTimePicker.scss"), + (".bit-tgl-lbl", "Bit.BlazorUI/Components/Inputs/Toggle/BitToggle.scss"), + (".bit-bfc-field-label", "Bit.BlazorUI.Extras/Components/FullCalendar/BitFullCalendar.scss"), + (".bit-mde-lbl", "Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss"), + (".bit-phi-lbl", "Bit.BlazorUI.Extras/Components/PhoneInput/BitPhoneInput.scss"), + (".bit-phi-rlb", "Bit.BlazorUI.Extras/Components/PhoneInput/BitPhoneInput.scss"), + (".bit-rte-lbl", "Bit.BlazorUI.Extras/Components/RichTextEditor/BitRichTextEditor.scss"), + ]; + + // The classes named like a caption that caption no field, so their weight is their own component's decision. + private static readonly HashSet OtherLabels = + [ + ".bit-acb-lbl", // the text of an action button + ".bit-btn-lbl", // the loading text of a button + ".bit-tgb-lbl", // the loading text of a toggle button + ".bit-chb-lbl", // the clickable row of a checkbox: the box and its text, read at the body weight + ".bit-fin-lbl", // the browse button of a file input + ".bit-upl-lbl", // the browse button / drop area of a file upload + ".bit-ldn-lbl", // the text under a loading indicator + ".bit-prb-lbl", // the text of a progress bar + ".bit-pgn-lbl", // the page text of a pagination + ".bit-tag-lbl", // the text of a tag + ".bit-dtg-chooser-label", // a checkbox row of a data grid's column chooser + ".bit-pdv-page-label", // the page number of a pdf viewer + ".bit-pdv-thumb-label", // the number under a pdf viewer's thumbnail + ".bit-bfc-bg-event-label", // the title of a full calendar's background event + ".bit-bfc-dropdown-label", // the heading of a section of a full calendar's settings menu + ".bit-bfc-dtp-month-label", // the month a full calendar's date picker shows + ".bit-bfc-event-detail-label", // the term of a row of a full calendar's event details + ".bit-bfc-repeat-dates-label", // the heading of the skipped / added dates of a repeating event + ".bit-bfc-time-slot-label", // the time of a full calendar's slot + ".bit-bfc-timeline-label", // the time on a full calendar's timeline + ".bit-bfc-tl-time-cell-label", // the time of a full calendar's timeline cell + ]; + + // A class named like a caption: a part of a component whose name ends in lbl, rlb or label. The name must end + // there: --bit-btn-lbl-fontsize is no class, .bit-tfl-lbl-x is another part. + private static readonly Regex CaptionClass = new( + @"\.bit-[a-z0-9]+(?:-[a-z0-9]+)*-(?:lbl|rlb|label)(?![-\w])", + RegexOptions.Compiled); + + // The font-weight property itself - never a custom property whose name ends in font-weight - with its value, + // interpolations (#{...}) included. + private static readonly Regex FontWeight = new( + @"(?(?:#\{[^}]*\}|[^;{}])+)", + RegexOptions.Compiled); + + // A weight of its own, once the token is taken out of the value: a typography step or the base weight, a number + // or a keyword. + private static readonly Regex OtherWeight = new( + @"\$tg-(?:fw-|font-weight)|(? c.Caption).ToHashSet(); + var found = new Dictionary(); + + foreach (var file in EnumerateComponentStylesheets()) + { + var stylesheet = SourceFiles.StripScssComments(SourceFiles.ReadFullPath(file)); + + foreach (var rule in SourceFiles.GetScssRules(stylesheet)) + { + foreach (Match match in CaptionClass.Matches(rule.Header)) + { + found.TryAdd(match.Value, Path.GetRelativePath(SourceFiles.Root, file)); + } + } + } + + var unclassified = found.Where(c => fieldCaptions.Contains(c.Key) is false && OtherLabels.Contains(c.Key) is false) + .Select(c => $"{c.Value}: {c.Key}") + .ToList(); + + Assert.AreEqual(0, unclassified.Count, + "These classes are named like a caption but are in neither list of this test - a field's caption goes in " + + "FieldCaptions, any other label in OtherLabels:\n" + string.Join("\n", unclassified)); + + var stale = fieldCaptions.Concat(OtherLabels).Where(c => found.ContainsKey(c) is false).ToList(); + + Assert.AreEqual(0, stale.Count, "No component stylesheet styles these listed classes any more:\n" + string.Join("\n", stale)); + } + + [TestMethod] + public void FieldCaptionShouldTakeItsWeightFromTheFieldLabelToken() + { + var offenders = new List(); + + foreach (var (caption, path) in FieldCaptions) + { + var stylesheet = SourceFiles.StripScssComments(SourceFiles.Read(path.Split('/'))); + var weighted = 0; + + foreach (var rule in SourceFiles.GetScssRules(stylesheet)) + { + if (ResolveSelectors(rule).Any(selector => Targets(selector, caption)) is false) continue; + + var declarations = SourceFiles.GetScssDeclarations(stylesheet[rule.Index..], rule.Header); + + foreach (Match weight in FontWeight.Matches(declarations)) + { + weighted++; + + var value = weight.Groups["value"].Value.Trim(); + + if (value.Contains(FieldLabelToken) is false || OtherWeight.IsMatch(value.Replace(FieldLabelToken, ""))) + { + offenders.Add($"{path}: {caption} in \"{rule.Header}\" has font-weight: {value}"); + } + } + } + + if (weighted == 0) + { + offenders.Add($"{path}: no rule sets the font-weight of {caption}"); + } + } + + Assert.AreEqual(0, offenders.Count, + $"Every font-weight of a field caption reads {FieldLabelToken} and nothing else:\n" + string.Join("\n", offenders)); + } + + // Whether the selector's subject - its last compound, less any :not() - carries the caption class. A rule whose + // subject is a descendant of the caption (.bit-chb-lbl .bit-chb-box) or its pseudo-element (the required + // asterisk of ::after) styles something else. + private static bool Targets(string selector, string caption) + { + var subject = LastCompound(selector); + + if (subject.Contains("::")) return false; + + subject = Regex.Replace(subject, @":not\([^()]*\)", ""); + + return Regex.IsMatch(subject, Regex.Escape(caption) + @"(?![-\w])"); + } + + // The selectors a rule matches, its ancestors' folded in the way Sass nests them: & stands for the parent, and a + // selector without one is a descendant of it. An at-rule (@media, @supports, @include, @mixin) adds no selector. + private static IEnumerable ResolveSelectors(SourceFiles.ScssRule rule) + { + if (rule.Header.StartsWith('@')) return []; + + IEnumerable resolved = [""]; + + foreach (var header in rule.Ancestors.Append(rule.Header)) + { + if (header.StartsWith('@')) continue; + + var parents = resolved.ToList(); + + resolved = SplitSelectorList(header).SelectMany(child => parents.Select(parent => + parent.Length == 0 ? child.Replace("&", "") + : child.Contains('&') ? child.Replace("&", parent) + : $"{parent} {child}")).ToList(); + } + + return resolved; + } + + private static IEnumerable SplitSelectorList(string header) + { + var depth = 0; + var current = new StringBuilder(); + + foreach (var c in header) + { + if (c is '(' or '[') depth++; + else if (c is ')' or ']') depth--; + + if (c == ',' && depth == 0) + { + yield return current.ToString().Trim(); + current.Clear(); + } + else + { + current.Append(c); + } + } + + yield return current.ToString().Trim(); + } + + private static string LastCompound(string selector) + { + var depth = 0; + var start = 0; + + for (var i = 0; i < selector.Length; i++) + { + var c = selector[i]; + + if (c is '(' or '[') depth++; + else if (c is ')' or ']') depth--; + else if (depth == 0 && (char.IsWhiteSpace(c) || c is '>' or '~' or '+')) start = i + 1; + } + + return selector[start..]; + } + + private static IEnumerable EnumerateComponentStylesheets() + => new[] { "Bit.BlazorUI", "Bit.BlazorUI.Extras" } + .SelectMany(project => Directory.EnumerateFiles(SourceFiles.GetDirectory(project, "Components"), "*.scss", SearchOption.AllDirectories)); +}