diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Navs/Nav/BitNav.scss b/src/BlazorUI/Bit.BlazorUI/Components/Navs/Nav/BitNav.scss index 29992be734a..c8b683f7385 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Navs/Nav/BitNav.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Navs/Nav/BitNav.scss @@ -9,9 +9,16 @@ // --bit-Nav-disabled-color everything a disabled item draws (default: $clr-fg-dis) // --bit-Nav-hover-background background of a hovered item (default: $clr-bg-pri-hover) // --bit-Nav-hover-color text and chevron color of a hovered item (default: $clr-fg-pri) +// --bit-Nav-hover-icon-color icon color of a hovered item (default: --bit-Nav-icon-color) +// --bit-Nav-hover-description-color description color of a hovered item (default: --bit-Nav-description-color) // --bit-Nav-pressed-background background of a pressed item (default: $clr-bg-pri-active) +// --bit-Nav-pressed-color text and chevron color of a pressed item (default: --bit-Nav-hover-color; $clr-fg-pri on touch) +// --bit-Nav-pressed-icon-color icon color of a pressed item (default: --bit-Nav-hover-icon-color; --bit-Nav-icon-color on touch) +// --bit-Nav-pressed-description-color description color of a pressed item (default: --bit-Nav-hover-description-color; --bit-Nav-description-color on touch) // --bit-Nav-selected-background background of the selected item (default: $clr-bg-pri-active) // --bit-Nav-selected-color text and chevron color of the selected item (default: $clr-fg-pri) +// --bit-Nav-selected-icon-color icon color of the selected item (default: --bit-Nav-icon-color) +// --bit-Nav-selected-description-color description color of the selected item (default: --bit-Nav-description-color) // --bit-Nav-indicator-color leading bar of the selected item (default: $clr-pri) // --bit-Nav-indicator-width thickness of that bar (default: $shp-border-width-thick) // --bit-Nav-item-radius corner radius of an item (default: $shp-radius-control) @@ -29,11 +36,20 @@ // --bit-Nav-separator-color separator color (default: $clr-brd-sec) // // A parameter written on the nav wins over the variable that restyles what it sets: an explicit Color over the -// icon and indicator colors, an explicit Accent over the hover, pressed and selected backgrounds and the colors -// drawn on them (a semantic Accent over the icon and description colors of an accented item too), and an explicit -// Size over the font, icon, description and header sizes and the item and header heights. The variables restyle -// the default, not a choice - which is why Color, Accent and Size publish nothing while they are left unset: a -// class that always carried the default would be indistinguishable from one that was asked for. +// icon and indicator colors, an explicit Accent over the hover, pressed and selected backgrounds and every color +// drawn on them - the text, the chevron, the icon and the description - and an explicit Size over the font, icon, +// description and header sizes and the item and header heights. The variables restyle the default, not a choice - +// which is why Color, Accent and Size publish nothing while they are left unset: a class that always carried the +// default would be indistinguishable from one that was asked for. + +// The colors the parts of an item have at rest, which the hovered, pressed and selected ones fall back on. +$nav-icon-color: var(--bit-Nav-icon-color, #{$clr-pri}); +$nav-description-color: var(--bit-Nav-description-color, #{$clr-fg-ter}); + +// The hovered ones, which a press falls back on where the pointer can hover (see .bit-nav-ict). +$nav-hover-color: var(--bit-Nav-hover-color, #{$clr-fg-pri}); +$nav-hover-icon-color: var(--bit-Nav-hover-icon-color, #{$nav-icon-color}); +$nav-hover-description-color: var(--bit-Nav-hover-description-color, #{$nav-description-color}); // The accent fills the whole hovered or selected item, so everything drawn on top of it moves to the // accent's own colors at once. Each part had been given a color that reads on the page background @@ -43,16 +59,18 @@ // its color, so the rules that declare the colors are the ones at rest - the parts' own rules and the selection's - // and a part the app paints through Classes keeps that paint under the pointer, instead of losing it to a state // that outranks the app's class. Each part unsets the variable on itself at rest (see .bit-nav-itm). -@mixin accented-item-content($color, $state: false) { - // A semantic Accent publishes these; any other leaves the icon and the description in the colors they have - // at rest, ranked the same way as they are there. - $icon: var(--bit-nav-clr-icon, var(--bit-nav-clr, var(--bit-Nav-icon-color, #{$clr-pri}))); - $des: var(--bit-nav-clr-des, var(--bit-Nav-description-color, #{$clr-fg-ter})); +// $color, $icon-color and $description-color are the state's public variables for those parts, so a theme that +// gives the state a vivid fill can move all three with it; an explicit Accent publishes the private ones read +// before them, and an explicit Color the icon's. +@mixin accented-item-content($color, $icon-color, $description-color, $state: false) { + $text: var(--bit-nav-clr-text, #{$color}); + $icon: var(--bit-nav-clr-icon, var(--bit-nav-clr, #{$icon-color})); + $des: var(--bit-nav-clr-des, #{$description-color}); @if $state { .bit-nav-itm, .bit-nav-cbt { - --bit-nav-sfg: #{$color}; + --bit-nav-sfg: #{$text}; } .bit-nav-iic { @@ -65,7 +83,7 @@ } @else { .bit-nav-itm, .bit-nav-cbt { - color: var(--bit-nav-sfg, #{$color}); + color: var(--bit-nav-sfg, #{$text}); } .bit-nav-iic { @@ -287,16 +305,33 @@ $nav-private-properties: item-font-size, item-min-height, icon-size, des-font-si &:hover { --bit-nav-sbg: var(--bit-nav-clr-hover, var(--bit-Nav-hover-background, #{$clr-bg-pri-hover})); - @include accented-item-content(var(--bit-nav-clr-text, var(--bit-Nav-hover-color, #{$clr-fg-pri})), $state: true); + @include accented-item-content($nav-hover-color, $nav-hover-icon-color, $nav-hover-description-color, $state: true); } } // Unlike the hover, the pressed state is not kept to the pointers that can hover: on a touch screen it is - // the one feedback a tap gets before the page changes. + // the one feedback a tap gets before the page changes. There no hover ever applies, so the pressed colors fall + // back on the ones at rest rather than on a hover's that a theme set for a fill the tap never shows. &:active { --bit-nav-sbg: var(--bit-nav-clr-active, var(--bit-Nav-pressed-background, #{$clr-bg-pri-active})); - @include accented-item-content(var(--bit-nav-clr-text, var(--bit-Nav-hover-color, #{$clr-fg-pri})), $state: true); + @include accented-item-content( + var(--bit-Nav-pressed-color, #{$clr-fg-pri}), + var(--bit-Nav-pressed-icon-color, #{$nav-icon-color}), + var(--bit-Nav-pressed-description-color, #{$nav-description-color}), + $state: true); + } + + // Where the pointer can hover, a press is a hover too, so the pressed colors fall back on the hovered ones the + // item is already drawn in. Declared after the rule above at the same weight, so it wins wherever it applies. + @media (hover: hover) { + &:active { + @include accented-item-content( + var(--bit-Nav-pressed-color, #{$nav-hover-color}), + var(--bit-Nav-pressed-icon-color, #{$nav-hover-icon-color}), + var(--bit-Nav-pressed-description-color, #{$nav-hover-description-color}), + $state: true); + } } } @@ -349,7 +384,7 @@ $nav-private-properties: item-font-size, item-min-height, icon-size, des-font-si text-overflow: ellipsis; padding-inline-start: spacing(2.75); --bit-nav-sfg: initial; - color: var(--bit-nav-sfg, var(--bit-Nav-description-color, #{$clr-fg-ter})); + color: var(--bit-nav-sfg, #{$nav-description-color}); font-size: var(--bit-nav-des-font-size, var(--bit-Nav-description-font-size, #{$tg-fs-xs})); transition: color $mot-duration-short $mot-easing; } @@ -377,7 +412,7 @@ $nav-private-properties: item-font-size, item-min-height, icon-size, des-font-si line-height: 1; flex-shrink: 0; --bit-nav-sfg: initial; - color: var(--bit-nav-sfg, var(--bit-nav-clr, var(--bit-Nav-icon-color, #{$clr-pri}))); + color: var(--bit-nav-sfg, var(--bit-nav-clr, #{$nav-icon-color})); font-size: var(--bit-nav-icon-size, var(--bit-Nav-icon-size, #{$siz-icon-md})); transition: color $mot-duration-short $mot-easing; } @@ -416,7 +451,10 @@ $nav-private-properties: item-font-size, item-min-height, icon-size, des-font-si border-color: var(--bit-nav-clr, var(--bit-Nav-indicator-color, #{$clr-pri})); background-color: var(--bit-nav-sbg, var(--bit-nav-clr-active, var(--bit-Nav-selected-background, #{$clr-bg-pri-active}))); - @include accented-item-content(var(--bit-nav-clr-text, var(--bit-Nav-selected-color, #{$clr-fg-pri}))); + @include accented-item-content( + var(--bit-Nav-selected-color, #{$clr-fg-pri}), + var(--bit-Nav-selected-icon-color, #{$nav-icon-color}), + var(--bit-Nav-selected-description-color, #{$nav-description-color})); .bit-nav-itm { font-weight: $tg-fw-semibold; @@ -496,10 +534,14 @@ $nav-private-properties: item-font-size, item-min-height, icon-size, des-font-si // follow the accent's on-color together with the text. --bit-nav-clr-icon: #{role($tokens, on)}; --bit-nav-clr-des: #{role($tokens, on)}; + } @else { + // A surface accent only tints the item's background, so the icon and the description keep the colors + // they have at rest on it - published all the same, so they are read ahead of the hovered, pressed and + // selected variables the way the accent's text color is, which a theme sets for its own fill rather than + // for this one (see accented-item-content). The icon still follows an explicit Color. + --bit-nav-clr-icon: var(--bit-nav-clr, #{$nav-icon-color}); + --bit-nav-clr-des: #{$nav-description-color}; } - // A surface accent only tints the item's background, so it publishes no icon and description colors: - // left unset, they keep the colors they have at rest, the public variables of those two included (see - // accented-item-content). } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/Nav/BitNavDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/Nav/BitNavDemo.razor.cs index b2513fddf4c..61cf04aba2c 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/Nav/BitNavDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/Nav/BitNavDemo.razor.cs @@ -312,13 +312,13 @@ public partial class BitNavDemo { Name = "--bit-Nav-icon-color", DefaultValue = "--bit-clr-pri", - Description = "Icon color of an item at rest; kept on hover and selection unless the Accent is a semantic role. The Color parameter wins over it.", + Description = "Icon color of an item at rest, and of a hovered, pressed or selected one its own variable leaves unset (and of one under an explicit surface Accent). The Color parameter wins over it.", }, new() { Name = "--bit-Nav-description-color", DefaultValue = "--bit-clr-fg-ter", - Description = "Color of the description of an item.", + Description = "Description color of an item at rest, and of a hovered, pressed or selected one its own variable leaves unset (and of one under an explicit surface Accent).", }, new() { @@ -339,12 +339,42 @@ public partial class BitNavDemo Description = "Text and chevron color of a hovered item. The Accent parameter wins over it.", }, new() + { + Name = "--bit-Nav-hover-icon-color", + DefaultValue = "--bit-Nav-icon-color, then --bit-clr-pri", + Description = "Icon color of a hovered item (pointer devices only). The Accent and Color parameters win over it.", + }, + new() + { + Name = "--bit-Nav-hover-description-color", + DefaultValue = "--bit-Nav-description-color, then --bit-clr-fg-ter", + Description = "Description color of a hovered item (pointer devices only). The Accent parameter wins over it.", + }, + new() { Name = "--bit-Nav-pressed-background", DefaultValue = "--bit-clr-bg-pri-active", Description = "Background of an item while it is pressed (the one feedback a tap gets on a touch screen). The Accent parameter wins over it.", }, new() + { + Name = "--bit-Nav-pressed-color", + DefaultValue = "--bit-Nav-hover-color, then --bit-clr-fg-pri (on a touch screen --bit-clr-fg-pri)", + Description = "Text and chevron color of an item while it is pressed. Where the pointer can hover it falls back on the hovered color; on a touch screen, where no hover applies, on the default. The Accent parameter wins over it.", + }, + new() + { + Name = "--bit-Nav-pressed-icon-color", + DefaultValue = "--bit-Nav-hover-icon-color, then --bit-Nav-icon-color, then --bit-clr-pri (on a touch screen --bit-Nav-icon-color, then --bit-clr-pri)", + Description = "Icon color of an item while it is pressed. Where the pointer can hover it falls back on the hovered icon color; on a touch screen, where no hover applies, on the one at rest. The Accent and Color parameters win over it.", + }, + new() + { + Name = "--bit-Nav-pressed-description-color", + DefaultValue = "--bit-Nav-hover-description-color, then --bit-Nav-description-color, then --bit-clr-fg-ter (on a touch screen --bit-Nav-description-color, then --bit-clr-fg-ter)", + Description = "Description color of an item while it is pressed. Where the pointer can hover it falls back on the hovered description color; on a touch screen, where no hover applies, on the one at rest. The Accent parameter wins over it.", + }, + new() { Name = "--bit-Nav-selected-background", DefaultValue = "--bit-clr-bg-pri-active", @@ -357,6 +387,18 @@ public partial class BitNavDemo Description = "Text and chevron color of the selected item, and of a collapsed branch that holds it. The Accent parameter wins over it.", }, new() + { + Name = "--bit-Nav-selected-icon-color", + DefaultValue = "--bit-Nav-icon-color, then --bit-clr-pri", + Description = "Icon color of the selected item, and of a collapsed branch that holds it - the one to move with --bit-Nav-selected-background when that is a vivid fill. The Accent and Color parameters win over it: an explicit Accent draws the icon in its own colors on its own fill (a semantic one in its on-color, a surface one in the icon color at rest), so a value set for the theme's fill never lands on the Accent's.", + }, + new() + { + Name = "--bit-Nav-selected-description-color", + DefaultValue = "--bit-Nav-description-color, then --bit-clr-fg-ter", + Description = "Description color of the selected item, and of a collapsed branch that holds it. The Accent parameter wins over it.", + }, + new() { Name = "--bit-Nav-indicator-color", DefaultValue = "--bit-clr-pri", diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavStylesheetTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavStylesheetTests.cs index 22abc88b3cd..e8c4dd4a6fd 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavStylesheetTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavStylesheetTests.cs @@ -87,7 +87,7 @@ public void BitNavShouldLetAParameterWinOverItsPublicVariable() // So does an explicit Color for the icon and the indicator, and an explicit Accent for the hover, the press // and the selection; an unset Accent is the PrimaryBackground one. // (The states set the state variables the rules at rest read first, rather than painting the colors themselves.) - StringAssert.Contains(stylesheet, "color: var(--bit-nav-sfg, var(--bit-nav-clr, var(--bit-Nav-icon-color, #{$clr-pri})));"); + StringAssert.Contains(stylesheet, "color: var(--bit-nav-sfg, var(--bit-nav-clr, #{$nav-icon-color}));"); StringAssert.Contains(stylesheet, "border-color: var(--bit-nav-clr, var(--bit-Nav-indicator-color, #{$clr-pri}));"); StringAssert.Contains(stylesheet, "--bit-nav-sbg: var(--bit-nav-clr-hover, var(--bit-Nav-hover-background, #{$clr-bg-pri-hover}));"); StringAssert.Contains(stylesheet, "background-color: var(--bit-nav-sbg, var(--bit-nav-clr-active, var(--bit-Nav-selected-background, #{$clr-bg-pri-active})));"); @@ -95,6 +95,68 @@ public void BitNavShouldLetAParameterWinOverItsPublicVariable() Assert.IsFalse(Regex.IsMatch(stylesheet, @"var\(--bit-Nav-[a-z-]+, var\(--bit-nav-"), "A public variable is read before the parameter it restyles the default of."); } + [TestMethod] + public void BitNavShouldLetEveryStateRecolorTheIconAndTheDescriptionOfItsItem() + { + var stylesheet = ReadStylesheet(); + + // A theme that gives a state a vivid fill moves the text with --bit-Nav-*-color; without a variable of their own + // the icon and the description stayed in their colors at rest, unreadable on that fill. Each falls back on the + // one at rest, so a nav that sets none of them looks as it did. + StringAssert.Contains(stylesheet, "$nav-icon-color: var(--bit-Nav-icon-color, #{$clr-pri});"); + StringAssert.Contains(stylesheet, "$nav-description-color: var(--bit-Nav-description-color, #{$clr-fg-ter});"); + StringAssert.Contains(stylesheet, "$nav-hover-color: var(--bit-Nav-hover-color, #{$clr-fg-pri});"); + StringAssert.Contains(stylesheet, "$nav-hover-icon-color: var(--bit-Nav-hover-icon-color, #{$nav-icon-color});"); + StringAssert.Contains(stylesheet, "$nav-hover-description-color: var(--bit-Nav-hover-description-color, #{$nav-description-color});"); + + // Each state reads its own variables, in its own rule: a string searched for in the whole stylesheet would be + // found in another state's fallbacks too. + var item = SourceFiles.GetScssBlock(stylesheet, "\n.bit-nav-ict {"); + var hover = SourceFiles.GetScssBlock(item, "&:hover {"); + StringAssert.Contains(hover, "@include accented-item-content($nav-hover-color, $nav-hover-icon-color, $nav-hover-description-color, $state: true);"); + + // On a touch screen no hover ever applies, so the press falls back on the colors at rest, not on a hover's a + // theme set for a fill the tap never shows; where the pointer can hover, a press is a hover too, and that rule + // comes later to win there. + var pressed = SourceFiles.GetScssBlock(item, "\n &:active {"); + StringAssert.Contains(pressed, "var(--bit-Nav-pressed-color, #{$clr-fg-pri}),"); + StringAssert.Contains(pressed, "var(--bit-Nav-pressed-icon-color, #{$nav-icon-color}),"); + StringAssert.Contains(pressed, "var(--bit-Nav-pressed-description-color, #{$nav-description-color}),"); + Assert.IsFalse(pressed.Contains("hover", StringComparison.Ordinal), "A press on a touch screen falls back on the hovered colors."); + + var hoverablePressAt = item.LastIndexOf("@media (hover: hover) {", StringComparison.Ordinal); + Assert.IsTrue(hoverablePressAt > item.IndexOf("\n &:active {", StringComparison.Ordinal), "The press of a pointer that can hover is not declared after the one of every pointer."); + var hoverablePress = SourceFiles.GetScssBlock(SourceFiles.GetScssBlock(item[hoverablePressAt..], "@media (hover: hover) {"), "&:active {"); + StringAssert.Contains(hoverablePress, "var(--bit-Nav-pressed-color, #{$nav-hover-color}),"); + StringAssert.Contains(hoverablePress, "var(--bit-Nav-pressed-icon-color, #{$nav-hover-icon-color}),"); + StringAssert.Contains(hoverablePress, "var(--bit-Nav-pressed-description-color, #{$nav-hover-description-color}),"); + + var selected = SourceFiles.GetScssBlock(stylesheet, "\n.bit-nav-sel,"); + StringAssert.Contains(selected, "var(--bit-Nav-selected-color, #{$clr-fg-pri}),"); + StringAssert.Contains(selected, "var(--bit-Nav-selected-icon-color, #{$nav-icon-color}),"); + StringAssert.Contains(selected, "var(--bit-Nav-selected-description-color, #{$nav-description-color}));"); + + // An explicit Accent and an explicit Color still win over them: the mixin reads its own private variables first. + var mixin = SourceFiles.GetScssBlock(stylesheet, "@mixin accented-item-content("); + StringAssert.Contains(mixin, "$text: var(--bit-nav-clr-text, #{$color});"); + StringAssert.Contains(mixin, "$icon: var(--bit-nav-clr-icon, var(--bit-nav-clr, #{$icon-color}));"); + StringAssert.Contains(mixin, "$des: var(--bit-nav-clr-des, #{$description-color});"); + } + + [TestMethod] + public void BitNavShouldLetEveryAccentWinOverTheStateColorsOfTheIconAndTheDescription() + { + var accent = SourceFiles.GetScssBlock(ReadStylesheet(), "\n .bit-nav-a#{$role} {"); + + // A semantic accent draws them in its on-color. A surface one keeps them in the colors they have at rest on its + // tint - but publishes them all the same, so a hovered, pressed or selected variable a theme set for its own + // fill never lands on the accent's. + StringAssert.Contains(accent, "--bit-nav-clr-icon: #{role($tokens, on)};"); + StringAssert.Contains(accent, "--bit-nav-clr-des: #{role($tokens, on)};"); + StringAssert.Contains(accent, "--bit-nav-clr-icon: var(--bit-nav-clr, #{$nav-icon-color});"); + StringAssert.Contains(accent, "--bit-nav-clr-des: #{$nav-description-color};"); + } + [TestMethod] public void BitNavShouldPublishItsColorAccentAndSizeOnlyWhereTheyAreSet() { diff --git a/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance.TestHost/Components/Pages/BitNavStateColors.razor b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance.TestHost/Components/Pages/BitNavStateColors.razor new file mode 100644 index 00000000000..67af177f93d --- /dev/null +++ b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance.TestHost/Components/Pages/BitNavStateColors.razor @@ -0,0 +1,77 @@ +@page "/regression/nav-state-colors" + +BitNav State Colors Regression + +@* A theme that gives the hovered and the selected item a vivid fill, and moves the text, the icon and the description of + each with it through the per-state variables, set once on the wrapper so every nav below inherits it. Each section + is one nav under that theme with one parameter changed: none, a surface Accent, a semantic Accent, an explicit + Color, and a theme that sets the pressed colors too. Every color is a distinct rgb() so a test can tell which + variable painted a part. The pressed background is left light, the case of a theme that only made the hover vivid. *@ +
+

BitNav State Colors Regression

+

Status: @_status

+
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +@* No theme at all: every state falls back on the defaults it had before the per-state variables existed. *@ +
+ +
+ +@code { + private const string ThemeStyle = + "--bit-Nav-icon-color: rgb(10, 20, 30);" + + "--bit-Nav-description-color: rgb(40, 50, 60);" + + "--bit-Nav-hover-background: rgb(0, 0, 200);" + + "--bit-Nav-hover-color: rgb(255, 255, 255);" + + "--bit-Nav-hover-icon-color: rgb(255, 255, 1);" + + "--bit-Nav-hover-description-color: rgb(255, 255, 2);" + + "--bit-Nav-pressed-background: rgb(230, 230, 230);" + + "--bit-Nav-selected-background: rgb(0, 100, 0);" + + "--bit-Nav-selected-color: rgb(255, 255, 3);" + + "--bit-Nav-selected-icon-color: rgb(255, 255, 4);" + + "--bit-Nav-selected-description-color: rgb(255, 255, 5);"; + + private const string PressedStyle = + "--bit-Nav-pressed-background: rgb(0, 0, 100);" + + "--bit-Nav-pressed-color: rgb(255, 255, 6);" + + "--bit-Nav-pressed-icon-color: rgb(255, 255, 7);" + + "--bit-Nav-pressed-description-color: rgb(255, 255, 8);"; + + private readonly List _items = + [ + new() { Key = "selected", Text = "Selected", IconName = BitIconName.Home, Description = "The selected item" }, + new() { Key = "other", Text = "Other", IconName = BitIconName.Mail, Description = "An item at rest" }, + ]; + + private string _status = "Loading"; + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender is false) return; + + _status = "Ready"; + StateHasChanged(); + } +} diff --git a/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitNavStateColorsBrowserTests.cs b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitNavStateColorsBrowserTests.cs new file mode 100644 index 00000000000..5c2b010d9e3 --- /dev/null +++ b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitNavStateColorsBrowserTests.cs @@ -0,0 +1,285 @@ +using System.Threading.Tasks; + +namespace Bit.BlazorUI.Tests.Performance; + +/// +/// Browser regression tests for the colors a BitNav draws an item's parts in while it is hovered, pressed or selected. +/// A theme that gives a state a vivid fill moves the text, the icon and the description with it through the state's +/// own variables; an explicit Accent or Color still wins over them, a surface Accent included, whose tint they were +/// never set for. The stylesheet tests pin the source; these read what each engine resolves the var() chains to. +/// +/// Run them with BROWSER=firefox and BROWSER=webkit as well. +/// +[TestClass] +[TestCategory("Browser")] +[RequiresBrowser] // opt-in: RUN_BROWSER_TESTS=1 dotnet test --filter FullyQualifiedName~BitNavStateColorsBrowserTests +public class BitNavStateColorsBrowserTests : PerformanceTestBase +{ + internal const string PageUrl = "/regression/nav-state-colors"; + + internal const string RestIcon = "rgb(10, 20, 30)"; + internal const string RestDescription = "rgb(40, 50, 60)"; + private const string HoverText = "rgb(255, 255, 255)"; + private const string HoverIcon = "rgb(255, 255, 1)"; + private const string HoverDescription = "rgb(255, 255, 2)"; + private const string SelectedText = "rgb(255, 255, 3)"; + private const string SelectedIcon = "rgb(255, 255, 4)"; + private const string SelectedDescription = "rgb(255, 255, 5)"; + + [TestMethod] + public async Task BitNav_Selected_TakesTheThemesSelectedColors() + { + await Open(Page); + + await AssertParts(Page, "theme-default", Selected, SelectedText, SelectedIcon, SelectedDescription); + } + + [TestMethod] + public async Task BitNav_Selected_UnderASurfaceAccent_KeepsTheIconAndDescriptionAtRest() + { + await Open(Page); + + // The theme's selected colors were set for its vivid fill, not for the accent's tint. + await AssertParts(Page, "theme-surface", Selected, await Resolve(Page, "theme-surface", "var(--bit-nav-clr-text)"), RestIcon, RestDescription); + } + + [TestMethod] + public async Task BitNav_Selected_UnderASemanticAccent_DrawsEveryPartInTheAccentsOnColor() + { + await Open(Page); + + var on = await Resolve(Page, "theme-semantic", "var(--bit-nav-clr-text)"); + await AssertParts(Page, "theme-semantic", Selected, on, on, on); + } + + [TestMethod] + public async Task BitNav_Selected_UnderAnExplicitColor_DrawsTheIconInThatColor() + { + await Open(Page); + + await AssertParts(Page, "theme-color", Selected, SelectedText, await Resolve(Page, "theme-color", "var(--bit-nav-clr)"), SelectedDescription); + } + + [TestMethod] + public async Task BitNav_Selected_WithNoTheme_LooksAsItDid() + { + await Open(Page); + + await AssertParts(Page, "plain", Selected, + await Resolve(Page, "plain", "var(--bit-clr-fg-pri)"), + await Resolve(Page, "plain", "var(--bit-clr-pri)"), + await Resolve(Page, "plain", "var(--bit-clr-fg-ter)")); + } + + [TestMethod] + public async Task BitNav_Hover_TakesTheThemesHoveredColors() + { + await Open(Page); + + await Hover(Page, "theme-default", Other); + + await AssertParts(Page, "theme-default", Other, HoverText, HoverIcon, HoverDescription); + } + + [TestMethod] + public async Task BitNav_Hover_UnderASurfaceAccent_KeepsTheIconAndDescriptionAtRest() + { + await Open(Page); + + await Hover(Page, "theme-surface", Other); + + await AssertParts(Page, "theme-surface", Other, await Resolve(Page, "theme-surface", "var(--bit-nav-clr-text)"), RestIcon, RestDescription); + } + + [TestMethod] + public async Task BitNav_Hover_UnderASemanticAccent_DrawsEveryPartInTheAccentsOnColor() + { + await Open(Page); + + await Hover(Page, "theme-semantic", Other); + + var on = await Resolve(Page, "theme-semantic", "var(--bit-nav-clr-text)"); + await AssertParts(Page, "theme-semantic", Other, on, on, on); + } + + [TestMethod] + public async Task BitNav_Hover_UnderAnExplicitColor_DrawsTheIconInThatColor() + { + await Open(Page); + + await Hover(Page, "theme-color", Other); + + await AssertParts(Page, "theme-color", Other, HoverText, await Resolve(Page, "theme-color", "var(--bit-nav-clr)"), HoverDescription); + } + + [TestMethod] + public async Task BitNav_Hover_EndsBackInTheColorsAtRest() + { + await Open(Page); + + await Hover(Page, "theme-default", Other); + await AssertParts(Page, "theme-default", Other, HoverText, HoverIcon, HoverDescription); + + await Page.Mouse.MoveAsync(0, 0); + await Page.WaitForFunctionAsync("selector => !document.querySelector(selector).matches(':hover')", Item("theme-default", Other)); + + await AssertParts(Page, "theme-default", Other, await Resolve(Page, "theme-default", "var(--bit-clr-fg-pri)"), RestIcon, RestDescription); + } + + [TestMethod] + public async Task BitNav_PressedWithAMouse_FallsBackOnTheHoveredColors() + { + await Open(Page); + Assert.IsTrue(await Page.EvaluateAsync("() => matchMedia('(hover: hover)').matches"), "The desktop context cannot hover."); + + await Press(Page, "theme-default", Other); + + await AssertParts(Page, "theme-default", Other, HoverText, HoverIcon, HoverDescription); + await Page.Mouse.UpAsync(); + } + + [TestMethod] + public async Task BitNav_PressedWithAMouse_TakesThePressedColorsWhereTheThemeSetsThem() + { + await Open(Page); + + await Press(Page, "theme-pressed", Other); + + await AssertParts(Page, "theme-pressed", Other, "rgb(255, 255, 6)", "rgb(255, 255, 7)", "rgb(255, 255, 8)"); + await Page.Mouse.UpAsync(); + } + + [TestMethod] + public async Task BitNav_PressedWithAMouse_UnderASurfaceAccent_KeepsTheIconAndDescriptionAtRest() + { + await Open(Page); + + await Press(Page, "theme-surface", Other); + + await AssertParts(Page, "theme-surface", Other, await Resolve(Page, "theme-surface", "var(--bit-nav-clr-text)"), RestIcon, RestDescription); + await Page.Mouse.UpAsync(); + } + + + + internal const string Selected = ".bit-nav-sel"; + internal const string Other = ".bit-nav-ict:not(.bit-nav-sel)"; + + internal static string Item(string section, string item) => $"#{section} {item}"; + + internal static async Task Open(IPage page) + { + await page.GotoAsync($"{BaseUrl}{PageUrl}"); + await page.WaitForFunctionAsync("() => document.getElementById('status')?.innerText === 'Ready'"); + await Expect(page.Locator(Item("theme-default", Selected))).ToHaveCountAsync(1); + + // The parts move to a state's colors through a short transition, and until the next frame a running one still + // reports the color at rest - which a polled assertion of a part kept at rest would take for the answer, however + // short the transition (reduced motion's collapsed tokens included). With none, a state's colors are computed + // the moment it applies. + await page.AddStyleTagAsync(new() { Content = ".bit-nav, .bit-nav * { transition: none !important; }" }); + await Expect(page.Locator($"{Item("theme-default", Other)} .bit-nav-iic").First).ToHaveCSSAsync("transition-property", "none"); + } + + /// Moves the pointer over an item and waits until it is :hover, so its colors are read in that state. + internal static async Task Hover(IPage page, string section, string item) + { + await page.HoverAsync(Item(section, item)); + await WaitForState(page, section, item, ":hover"); + } + + /// Holds the primary button down over an item, so it stays :active while the colors are read. + internal static async Task Press(IPage page, string section, string item) + { + await page.HoverAsync(Item(section, item)); + await page.Mouse.DownAsync(); + await WaitForState(page, section, item, ":active"); + } + + private static Task WaitForState(IPage page, string section, string item, string state) + { + return page.WaitForFunctionAsync("([selector, state]) => document.querySelector(selector)?.matches(state)", + new[] { Item(section, item), state }, new() { Timeout = 5000 }); + } + + /// What a color expression resolves to inside the root of a section's nav, its private variables included. + internal static Task Resolve(IPage page, string section, string expression) + { + return page.EvaluateAsync(@"([selector, expression]) => { + const probe = document.createElement('span'); + probe.style.color = expression; + document.querySelector(selector).appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }", new[] { $"#{section} .bit-nav", expression }); + } + + /// + /// The colors the item's text, icon and description are drawn in. Expect polls, so the short color transition of + /// a state change has run out before a value counts as wrong. + /// + internal static async Task AssertParts(IPage page, string section, string item, string text, string icon, string description) + { + var container = page.Locator(Item(section, item)).First; + + await Expect(container.Locator(".bit-nav-itm").First).ToHaveCSSAsync("color", text); + await Expect(container.Locator(".bit-nav-iic").First).ToHaveCSSAsync("color", icon); + await Expect(container.Locator(".bit-nav-des").First).ToHaveCSSAsync("color", description); + } +} + +/// +/// The touch-screen half of : with no hover a press falls back on the colors +/// at rest, not on the hovered ones a theme set for a fill the tap never shows. A class of its own because the touch +/// screen is a property of the browser context, not of the page. An engine that still reports a pointer that can hover +/// with touch on is inconclusive rather than failing, since it would be testing the desktop rule again. +/// +[TestClass] +[TestCategory("Browser")] +[RequiresBrowser] +public class BitNavStateColorsTouchBrowserTests : PerformanceTestBase +{ + protected override BrowserNewContextOptions? ContextOptions => new() + { + HasTouch = true, + IsMobile = (System.Environment.GetEnvironmentVariable("BROWSER") ?? "chromium").ToLowerInvariant() is not "firefox", + ViewportSize = new() { Width = 412, Height = 800 } + }; + + [TestMethod] + public async Task BitNav_PressedOnATouchScreen_FallsBackOnTheColorsAtRest() + { + await BitNavStateColorsBrowserTests.Open(Page); + + if (await Page.EvaluateAsync("() => matchMedia('(hover: hover)').matches")) + { + Assert.Inconclusive("This engine reports a pointer that can hover even with touch on."); + } + + await BitNavStateColorsBrowserTests.Press(Page, "theme-default", BitNavStateColorsBrowserTests.Other); + + await BitNavStateColorsBrowserTests.AssertParts(Page, "theme-default", BitNavStateColorsBrowserTests.Other, + await BitNavStateColorsBrowserTests.Resolve(Page, "theme-default", "var(--bit-clr-fg-pri)"), + BitNavStateColorsBrowserTests.RestIcon, + BitNavStateColorsBrowserTests.RestDescription); + await Page.Mouse.UpAsync(); + } + + [TestMethod] + public async Task BitNav_PressedOnATouchScreen_TakesThePressedColorsWhereTheThemeSetsThem() + { + await BitNavStateColorsBrowserTests.Open(Page); + + if (await Page.EvaluateAsync("() => matchMedia('(hover: hover)').matches")) + { + Assert.Inconclusive("This engine reports a pointer that can hover even with touch on."); + } + + await BitNavStateColorsBrowserTests.Press(Page, "theme-pressed", BitNavStateColorsBrowserTests.Other); + + await BitNavStateColorsBrowserTests.AssertParts(Page, "theme-pressed", BitNavStateColorsBrowserTests.Other, + "rgb(255, 255, 6)", "rgb(255, 255, 7)", "rgb(255, 255, 8)"); + await Page.Mouse.UpAsync(); + } +}