From 2c5511f9d2d4c2715b1b3a6add0a0d840bd34691 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Wed, 2 Sep 2026 01:26:26 +0330 Subject: [PATCH 1/2] apply BitMediaQuery improvements #13102 --- .../Utilities/MediaQuery/BitMediaQuery.razor | 42 +- .../MediaQuery/BitMediaQuery.razor.cs | 86 ++- .../Utilities/MediaQuery/BitMediaQuery.ts | 74 ++- .../Utilities/MediaQuery/BitScreenQuery.cs | 39 +- .../MediaQuery/BitMediaQueryDemo.razor | 131 ++++- .../MediaQuery/BitMediaQueryDemo.razor.cs | 197 ++++++- .../MediaQuery/BitMediaQueryTests.cs | 500 +++++++++++++++++- 7 files changed, 959 insertions(+), 110 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor index 75c7be26fdc..84775c8ccc8 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor @@ -3,18 +3,34 @@ @if ((Matched ?? ChildContent ?? NotMatched) is not null) { -
- @if (_isMatched) - { - @(Matched ?? ChildContent) - } - else + if (NoWrapper) + { + @* No element is rendered any more, so the reference a previous render captured is stale. *@ + RootElement = default; + + @* A collapsed component is asked to be out of the DOM, which still means something without + an element of its own: the content it would have wrapped goes with it. *@ + if (Visibility is not BitVisibility.Collapsed) { - @NotMatched + @(_isMatched ? (Matched ?? ChildContent) : NotMatched) } -
-} \ No newline at end of file + } + else + { +
+ @if (_isMatched) + { + @(Matched ?? ChildContent) + } + else + { + @NotMatched + } +
+ } +} diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor.cs index 18afd359c12..7ac21e441fc 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor.cs @@ -1,11 +1,15 @@ namespace Bit.BlazorUI; /// -/// A component to easily use predefined bit BlazorUI media queries in Blazor components. +/// A component to render content based on CSS media queries, using the browser's matchMedia API. +/// It offers the predefined bit BlazorUI screen queries, built from the live theme breakpoints so +/// customized themes are honored, and also accepts any custom media query, including non-viewport +/// features such as orientation or prefers-color-scheme. /// public partial class BitMediaQuery : BitComponentBase { private string? _query; + private string? _setupId; private bool _isMatched; private DotNetObjectReference? _dotnetObj; @@ -20,6 +24,13 @@ public partial class BitMediaQuery : BitComponentBase /// [Parameter] public RenderFragment? ChildContent { get; set; } + /// + /// The initial matched state to render with until the actual result of the query arrives from + /// the browser. Useful to avoid a flash of the wrong content during prerendering (or before the + /// JavaScript runtime becomes available), where the query cannot be evaluated yet. + /// + [Parameter] public bool DefaultMatched { get; set; } + /// /// The content to be rendered if the provided query is matched (an alias for ChildContent). /// @@ -30,37 +41,77 @@ public partial class BitMediaQuery : BitComponentBase /// [Parameter] public RenderFragment? NotMatched { get; set; } + /// + /// Renders the active content directly, without the wrapping root element. + /// + /// + /// Since no element is rendered, everything that describes one - the class, the style, the id, + /// the direction and the splatted attributes - has nowhere to land and is ignored, and + /// is never captured. The one exception is a + /// , which asks + /// for the component to be out of the DOM and needs no element of its own to say so: nothing is + /// rendered at all, not even the content. A + /// then resolves its breakpoints from the document root rather than the component's own themed + /// scope, so the breakpoints of an enclosing BitThemeProvider are not picked up in this mode. + /// + [Parameter] public bool NoWrapper { get; set; } + /// /// The event callback to be called when the state of the media query has been changed. + /// It is also called once with the initial matched state, right after the query gets evaluated + /// by the browser for the first time. /// [Parameter] public EventCallback OnChange { get; set; } /// - /// Specifies the custom query to be matched. + /// Specifies the custom query to be matched. Any valid CSS media query is accepted, including + /// non-viewport features such as orientation, pointer, or prefers-color-scheme. + /// Takes precedence over when both are provided. /// [Parameter] public string? Query { get; set; } /// /// Defines the screen query to be matched, amongst the predefined Bit screen media queries. + /// The actual query is built at runtime from the live theme breakpoints (the + /// --bit-bp-* CSS variables), so customized theme breakpoints are honored. /// [Parameter] public BitScreenQuery? ScreenQuery { get; set; } + /// + /// Gets the current matched state of the provided query: the latest result reported by the + /// browser, or while no result has arrived yet. + /// + public bool IsMatched => _isMatched; + + + [JSInvokable("OnMatchChange")] public async ValueTask _OnMatchChange(bool isMatched) { + if (IsDisposed) return; + _isMatched = isMatched; await InvokeAsync(StateHasChanged); - _ = OnChange.InvokeAsync(isMatched); + await OnChange.InvokeAsync(isMatched); } protected override string RootElementClass => "bit-mdq"; + protected override void OnInitialized() + { + // Render with the DefaultMatched state until the browser reports the actual result of the + // query (e.g. during prerendering); the first JS notification then takes over. + _isMatched = DefaultMatched; + + base.OnInitialized(); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); @@ -87,24 +138,35 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // stays the same (e.g. after new breakpoints are applied). Re-invoke setup on every // render in that case and let the JS side reuse the existing listener when the resolved // expression is unchanged; a custom Query is verbatim, so the key comparison suffices. - if (effectiveKey != _query || screenQuery is not null) + // The JS listener is keyed by the element id, so a changed Id parameter also needs a + // re-setup (after tearing the old key down, or its listener would leak). + if (effectiveKey != _query || _Id != _setupId || screenQuery is not null) { - _query = effectiveKey; try { + if (_setupId is not null && _setupId != _Id) + { + await _js.BitMediaQueryDispose(_setupId); + } + + _query = effectiveKey; + _setupId = _Id; + await _js.BitMediaQuerySetup(_Id, customQuery, screenQuery, _dotnetObj); } catch (JSDisconnectedException) { } // circuit gone; nothing to set up } } - else if (_query is not null) + else if (_setupId is not null) { // Neither a Query nor a ScreenQuery resolves anymore: tear down the previous listener // and reset so a later (re)assignment sets up cleanly. + var setupId = _setupId; _query = null; + _setupId = null; try { - await _js.BitMediaQueryDispose(_Id); + await _js.BitMediaQueryDispose(setupId); } catch (JSDisconnectedException) { } // circuit gone; nothing to tear down } @@ -118,15 +180,17 @@ protected override async ValueTask DisposeAsync(bool disposing) await base.DisposeAsync(disposing); - if (_dotnetObj is not null) + if (_setupId is not null) { - _dotnetObj.Dispose(); - + // Tear the JS listener down before disposing the .NET reference, so a media change + // firing in between cannot invoke an already disposed object. try { - await _js.BitMediaQueryDispose(_Id); + await _js.BitMediaQueryDispose(_setupId); } catch (JSDisconnectedException) { } // we can ignore this exception here } + + _dotnetObj?.Dispose(); } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.ts b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.ts index 91c7bdb10c5..1891356e327 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.ts +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.ts @@ -45,6 +45,13 @@ const queryList = window.matchMedia(resolvedQuery); + // matchMedia never throws; a query it cannot parse silently becomes "not all", which + // simply never matches. Surface that as a warning so a typo in a custom query is + // diagnosable instead of just rendering the NotMatched content forever. + if (queryList.media === 'not all' && resolvedQuery.trim() !== 'not all') { + console.warn(`BitMediaQuery: the provided query '${resolvedQuery}' is not a valid media query.`); + } + queryList.addEventListener('change', async e => { await handleMatchChange(e.matches); }, { signal: ac.signal }); @@ -52,7 +59,13 @@ await handleMatchChange(queryList.matches); async function handleMatchChange(matches: boolean) { - await dotnetObj.invokeMethodAsync("OnMatchChange", matches); + try { + await dotnetObj.invokeMethodAsync("OnMatchChange", matches); + } catch { + // The .NET side is gone (the component or its circuit was disposed while the + // notification was in flight); stop listening instead of failing on every change. + MediaQuery.dispose(id); + } } } @@ -69,34 +82,47 @@ // Builds the media query for a predefined BitScreenQuery from the resolved theme breakpoints. // Range bounds are half-open (min inclusive, max exclusive), so the upper edge is one CSS - // pixel below the next breakpoint - matching the packaged media-queries.scss mixins. + // pixel below the next breakpoint - matching the packaged media-queries.scss mixins, whose + // "screen and" media-type prefix is kept too so the query does not also match print. private static buildScreenQuery(screenQuery: string, id: string): string { const bp = MediaQuery.resolveBreakpoints(id); const min = (v: string) => `(min-width: ${v})`; const max = (v: string) => `(max-width: ${MediaQuery.below(v)})`; - switch (screenQuery) { - case 'Xs': return `${min(bp.xs)} and ${max(bp.sm)}`; - case 'Sm': return `${min(bp.sm)} and ${max(bp.md)}`; - case 'Md': return `${min(bp.md)} and ${max(bp.lg)}`; - case 'Lg': return `${min(bp.lg)} and ${max(bp.xl)}`; - case 'Xl': return `${min(bp.xl)} and ${max(bp.xxl)}`; - case 'Xxl': return min(bp.xxl); - - case 'LtSm': return max(bp.sm); - case 'LtMd': return max(bp.md); - case 'LtLg': return max(bp.lg); - case 'LtXl': return max(bp.xl); - case 'LtXxl': return max(bp.xxl); - - case 'GtXs': return min(bp.sm); - case 'GtSm': return min(bp.md); - case 'GtMd': return min(bp.lg); - case 'GtLg': return min(bp.xl); - case 'GtXl': return min(bp.xxl); - - default: return ''; - } + const build = () => { + switch (screenQuery) { + case 'Xs': return `${min(bp.xs)} and ${max(bp.sm)}`; + case 'Sm': return `${min(bp.sm)} and ${max(bp.md)}`; + case 'Md': return `${min(bp.md)} and ${max(bp.lg)}`; + case 'Lg': return `${min(bp.lg)} and ${max(bp.xl)}`; + case 'Xl': return `${min(bp.xl)} and ${max(bp.xxl)}`; + case 'Xxl': return min(bp.xxl); + + case 'LtSm': return max(bp.sm); + case 'LtMd': return max(bp.md); + case 'LtLg': return max(bp.lg); + case 'LtXl': return max(bp.xl); + case 'LtXxl': return max(bp.xxl); + + case 'GtXs': return min(bp.sm); + case 'GtSm': return min(bp.md); + case 'GtMd': return min(bp.lg); + case 'GtLg': return min(bp.xl); + case 'GtXl': return min(bp.xxl); + + case 'SmToMd': return `${min(bp.sm)} and ${max(bp.lg)}`; + case 'SmToLg': return `${min(bp.sm)} and ${max(bp.xl)}`; + case 'SmToXl': return `${min(bp.sm)} and ${max(bp.xxl)}`; + case 'MdToLg': return `${min(bp.md)} and ${max(bp.xl)}`; + case 'MdToXl': return `${min(bp.md)} and ${max(bp.xxl)}`; + case 'LgToXl': return `${min(bp.lg)} and ${max(bp.xxl)}`; + + default: return ''; + } + }; + + const query = build(); + return query ? `screen and ${query}` : ''; } // Reads the --bit-bp-* breakpoint tokens from the queried element's themed scope, so the diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitScreenQuery.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitScreenQuery.cs index 2b843937ee6..e0840fceb60 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitScreenQuery.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitScreenQuery.cs @@ -10,8 +10,11 @@ /// into the component - so overriding the theme breakpoints changes these queries too. The pixel /// values shown on each member below are the built-in defaults (used when the matching /// --bit-bp-* variable is unset). Range members are half-open: the upper bound is one CSS -/// pixel below the next breakpoint. For a one-off breakpoint that isn't part of the theme scale, -/// use with an explicit query string instead. +/// pixel below the next breakpoint. The *To* members span from the start of the first named +/// breakpoint through the end of the second (both inclusive); a span starting at Xs or ending at +/// Xxl is one of the Lt* / Gt* members instead. For a one-off breakpoint that isn't +/// part of the theme scale, use with an explicit query string +/// instead. /// public enum BitScreenQuery { @@ -93,5 +96,35 @@ public enum BitScreenQuery /// /// Greater than extra large query: [@media screen and (min-width: 2560px)] /// - GtXl + GtXl, + + /// + /// Small through medium query: [@media screen and (min-width: 600px) and (max-width: 1279px)] + /// + SmToMd, + + /// + /// Small through large query: [@media screen and (min-width: 600px) and (max-width: 1919px)] + /// + SmToLg, + + /// + /// Small through extra large query: [@media screen and (min-width: 600px) and (max-width: 2559px)] + /// + SmToXl, + + /// + /// Medium through large query: [@media screen and (min-width: 960px) and (max-width: 1919px)] + /// + MdToLg, + + /// + /// Medium through extra large query: [@media screen and (min-width: 960px) and (max-width: 2559px)] + /// + MdToXl, + + /// + /// Large through extra large query: [@media screen and (min-width: 1280px) and (max-width: 2559px)] + /// + LgToXl } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/MediaQuery/BitMediaQueryDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/MediaQuery/BitMediaQueryDemo.razor index 8dd675567cf..e26a3d3cf72 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/MediaQuery/BitMediaQueryDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/MediaQuery/BitMediaQueryDemo.razor @@ -1,17 +1,21 @@ -@page "/components/mediaquery" +@page "/components/mediaquery" + Description="A component to render content based on CSS media queries, from the predefined bit BlazorUI screen queries to any custom query." /> -
Explore the media query interaction with the UI by resizing the window (you can try zoom in/out too).
+
+ The content of the component renders only while the screen matches the specified query. + Explore the predefined screen queries by resizing the window (you can try zoom in/out too). +

Normal screen queries:

This is Xs (Extra Small). @@ -34,10 +38,21 @@ This is GtMd (Greater Than Medium). This is GtLg (Greater Than Large). This is GtXl (Greater Than Extra Large). +



+
Range screen queries (between):

+ This is SmToMd (Small through Medium). + This is SmToLg (Small through Large). + This is SmToXl (Small through Extra Large). + This is MdToLg (Medium through Large). + This is MdToXl (Medium through Extra Large). + This is LgToXl (Large through Extra Large).
-
You can utilize the Matched and NotMatched parameters to render your desired UI.
+
+ The Matched and NotMatched parameters render different content for each state of the query, making an inline responsive if/else. + Matched is an alias for ChildContent and takes precedence over it when both are provided. +

@@ -49,13 +64,38 @@
- +
- You can provide any valid media query as a custom query + The predefined screen queries are not baked into the component; they resolve from the live theme breakpoint tokens + (the --bit-bp-* CSS variables). Overriding the breakpoints of a BitTheme, globally via BitThemeManager or scoped via + BitThemeProvider, changes the predefined queries accordingly. + Here the same Md screen query responds to a different width range inside a provider with customized breakpoints + (resize the window and watch them flip at different widths). +
+
+
Document breakpoints (Md: 960px to 1279px):

+ + Md is matched. + Md is not matched. + +

+
Customized breakpoints (Md: 700px to 899px):

+ + + Md is matched. + Md is not matched. + + +
+ + +
+ You can provide any valid media query as a custom query using the Query parameter ( more info - ). + ), including the modern range syntax such as (400px <= width <= 700px). + When both are provided, Query takes precedence over ScreenQuery.


screen and (max-width: 999px):

@@ -67,12 +107,81 @@ Not matched yet!
+

+
(400px <= width <= 700px):

+ + + The width is between 400px and 700px (range syntax). + + + The width is outside the 400px to 700px range. + + - -
Using the OnChange event one can be notified about the matching of the provided query.
+ +
+ Media queries are not only about the viewport width. Any media feature the browser understands works as a custom query, + such as the orientation of the screen, the preferred color scheme, the precision of the pointing device, or the reduced motion preference. +
+
+ + The screen is in landscape orientation. + The screen is in portrait orientation. + +
+ + The system prefers a dark color scheme. + The system prefers a light color scheme. + +
+ + The primary pointing device is precise (e.g. a mouse). + The primary pointing device is coarse or absent (e.g. a touchscreen). + +
+ + Reduced motion is requested by the system. + Reduced motion is not requested by the system. + +
+ + +
+ By default the active content renders inside a root div element. NoWrapper removes it and renders the content directly, + which is useful when the extra element would interfere with the surrounding layout (for example inside a flex or grid container). + You can inspect the rendered output below to see that no wrapping element exists around the content. +
+
+ + This content renders without a wrapping element (BitScreenQuery.GtSm). + [BitScreenQuery.GtSm] NotMatched! (still no wrapping element) + +
+ + +
+ Until the browser evaluates the query for the first time, the component renders as not matched. + DefaultMatched flips that initial state so the Matched content renders first, + avoiding a flash of the wrong content during prerendering, where no JavaScript is available to evaluate the query yet. + As soon as the actual result arrives from the browser, it takes over. +
+
+ + This is Matched (BitScreenQuery.GtSm), also rendered before the query gets evaluated. + [BitScreenQuery.GtSm] NotMatched!. + +
+ + +
+ The OnChange event notifies about each change of the matched state of the provided query, + including once with the initial result, right after the query gets evaluated by the browser for the first time. + The current state is also always available through the IsMatched property of the component. +

- +
[BitScreenQuery.Md] IsMatched?: @isMatched
+
[BitScreenQuery.Md] via the IsMatched property: @(mediaQueryRef?.IsMatched ?? false)
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/MediaQuery/BitMediaQueryDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/MediaQuery/BitMediaQueryDemo.razor.cs index 5afbb1ae352..60d182d72f6 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/MediaQuery/BitMediaQueryDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Utilities/MediaQuery/BitMediaQueryDemo.razor.cs @@ -12,6 +12,14 @@ public partial class BitMediaQueryDemo Description = "The content of the element to render if the specified query is matched.", }, new() + { + Name = "DefaultMatched", + Type = "bool", + DefaultValue = "false", + Description = "The initial matched state to render with until the actual result of the query arrives from the browser. " + + "Useful to avoid a flash of the wrong content during prerendering, where the query cannot be evaluated yet.", + }, + new() { Name = "Matched", Type = "RenderFragment?", @@ -26,135 +34,196 @@ public partial class BitMediaQueryDemo Description = "The content to be rendered if the provided query is not matched.", }, new() + { + Name = "NoWrapper", + Type = "bool", + DefaultValue = "false", + Description = "Renders the active content directly, without the wrapping root element. " + + "Since no element is rendered, everything that describes one (class, style, id, dir, ...) is ignored.", + }, + new() { Name = "OnChange", Type = "EventCallback", DefaultValue = "", - Description = "The event callback to be called when the state of the media query has been changed.", + Description = "The event callback to be called when the state of the media query has been changed. " + + "It is also called once with the initial matched state, right after the query gets evaluated by the browser for the first time.", }, new() { Name = "Query", Type = "string?", DefaultValue = "null", - Description = "Specifies the custom query to be matched.", + Description = "Specifies the custom query to be matched. Any valid CSS media query is accepted, including non-viewport features " + + "such as orientation, pointer, or prefers-color-scheme. Takes precedence over ScreenQuery when both are provided.", }, new() { Name = "ScreenQuery", Type = "BitScreenQuery?", DefaultValue = "null", - Description = "Defines the screen query to be matched, amongst the predefined Bit screen media queries.", + Description = "Defines the screen query to be matched, amongst the predefined Bit screen media queries. " + + "The actual query is built at runtime from the live theme breakpoints (the --bit-bp-* CSS variables), " + + "so customized theme breakpoints are honored.", LinkType = LinkType.Link, Href = "#screen-query-enum" }, ]; + private readonly List componentPublicMembers = + [ + new() + { + Name = "IsMatched", + Type = "bool", + DefaultValue = "false", + Description = "Gets the current matched state of the provided query: the latest result reported by the browser, " + + "or DefaultMatched while no result has arrived yet.", + }, + ]; + private readonly List componentSubEnums = [ new() { Id = "screen-query-enum", Name = "BitScreenQuery", - Description = "The predefined screen media queries in the bit BlazorUI.", + Description = "The predefined screen media queries in the bit BlazorUI. The actual query is built at runtime from the live theme breakpoints " + + "(the --bit-bp-* CSS variables), so customized theme breakpoints are honored; the pixel values below are the built-in defaults.", Items = [ new() { Name= "Xs", - Description="Extra small query: [@media screen and (max-width: 600px)]", + Description="Extra small query: [@media screen and (max-width: 599px)]", Value="0", }, new() { Name= "Sm", - Description="Small query: [@media screen and (min-width: 601px) and (max-width: 960px)]", + Description="Small query: [@media screen and (min-width: 600px) and (max-width: 959px)]", Value="1", }, new() { Name= "Md", - Description="Medium query: [@media screen and (min-width: 961px) and (max-width: 1280px)]", + Description="Medium query: [@media screen and (min-width: 960px) and (max-width: 1279px)]", Value="2", }, new() { Name= "Lg", - Description="Large query: [@media screen and (min-width: 1281px) and (max-width: 1920px)]", + Description="Large query: [@media screen and (min-width: 1280px) and (max-width: 1919px)]", Value="3", }, new() { Name= "Xl", - Description="Extra large query: [@media screen and (min-width: 1921px) and (max-width: 2560px)]", + Description="Extra large query: [@media screen and (min-width: 1920px) and (max-width: 2559px)]", Value="4", }, new() { Name= "Xxl", - Description="Extra extra large query: [@media screen and (min-width: 2561px)]", + Description="Extra extra large query: [@media screen and (min-width: 2560px)]", Value="5", }, new() { Name= "LtSm", - Description="Less than small query: [@media screen and (max-width: 600px)]", + Description="Less than small query: [@media screen and (max-width: 599px)]", Value="6", }, new() { Name= "LtMd", - Description="Less than medium query: [@media screen and (max-width: 960px)]", + Description="Less than medium query: [@media screen and (max-width: 959px)]", Value="7", }, new() { Name= "LtLg", - Description="Less than large query: [@media screen and (max-width: 1280px)]", + Description="Less than large query: [@media screen and (max-width: 1279px)]", Value="8", }, new() { Name= "LtXl", - Description="Less than extra large query: [@media screen and (max-width: 1920px)]", + Description="Less than extra large query: [@media screen and (max-width: 1919px)]", Value="9", }, new() { Name= "LtXxl", - Description="Less than extra extra large query: [@media screen and (max-width: 2560px)]", + Description="Less than extra extra large query: [@media screen and (max-width: 2559px)]", Value="10", }, new() { Name= "GtXs", - Description="Greater than extra small query: [@media screen and (min-width: 601px)]", + Description="Greater than extra small query: [@media screen and (min-width: 600px)]", Value="11", }, new() { Name= "GtSm", - Description="Greater than extra small query: [@media screen and (min-width: 601px)]", + Description="Greater than small query: [@media screen and (min-width: 960px)]", Value="12", }, new() { Name= "GtMd", - Description="Greater than medium query: [@media screen and (min-width: 1281px)]", + Description="Greater than medium query: [@media screen and (min-width: 1280px)]", Value="13", }, new() { Name= "GtLg", - Description="Greater than large query: [@media screen and (min-width: 1921px)]", + Description="Greater than large query: [@media screen and (min-width: 1920px)]", Value="14", }, new() { Name= "GtXl", - Description="Greater than extra large query: [@media screen and (min-width: 2561px)]", + Description="Greater than extra large query: [@media screen and (min-width: 2560px)]", Value="15", }, + new() + { + Name= "SmToMd", + Description="Small through medium query: [@media screen and (min-width: 600px) and (max-width: 1279px)]", + Value="16", + }, + new() + { + Name= "SmToLg", + Description="Small through large query: [@media screen and (min-width: 600px) and (max-width: 1919px)]", + Value="17", + }, + new() + { + Name= "SmToXl", + Description="Small through extra large query: [@media screen and (min-width: 600px) and (max-width: 2559px)]", + Value="18", + }, + new() + { + Name= "MdToLg", + Description="Medium through large query: [@media screen and (min-width: 960px) and (max-width: 1919px)]", + Value="19", + }, + new() + { + Name= "MdToXl", + Description="Medium through extra large query: [@media screen and (min-width: 960px) and (max-width: 2559px)]", + Value="20", + }, + new() + { + Name= "LgToXl", + Description="Large through extra large query: [@media screen and (min-width: 1280px) and (max-width: 2559px)]", + Value="21", + }, ] } ]; @@ -162,6 +231,11 @@ public partial class BitMediaQueryDemo private bool isMatched; + private BitMediaQuery? mediaQueryRef; + private readonly BitTheme breakpointsTheme = new() + { + Layout = { Breakpoints = { Md = "700px", Lg = "900px" } } + }; @@ -183,7 +257,14 @@ public partial class BitMediaQueryDemo This is GtSm (Greater Than Small). This is GtMd (Greater Than Medium). This is GtLg (Greater Than Large). -This is GtXl (Greater Than Extra Large)."; +This is GtXl (Greater Than Extra Large). + +This is SmToMd (Small through Medium). +This is SmToLg (Small through Large). +This is SmToXl (Small through Extra Large). +This is MdToLg (Medium through Large). +This is MdToXl (Medium through Extra Large). +This is LgToXl (Large through Extra Large)."; private string example2RazorCode = @" @@ -196,6 +277,26 @@ public partial class BitMediaQueryDemo "; private string example3RazorCode = @" +
Document breakpoints (Md: 960px to 1279px):

+ + Md is matched. + Md is not matched. + + +
Customized breakpoints (Md: 700px to 899px):

+ + + Md is matched. + Md is not matched. + +"; + private string example3CsharpCode = @" +private readonly BitTheme breakpointsTheme = new() +{ + Layout = { Breakpoints = { Md = ""700px"", Lg = ""900px"" } } +};"; + + private string example4RazorCode = @" This is screen and (max-width: 999px). @@ -203,11 +304,55 @@ public partial class BitMediaQueryDemo Not matched yet! + + + + + The width is between 400px and 700px (range syntax). + + + The width is outside the 400px to 700px range. + "; - private string example4RazorCode = @" - isMatched = v"" /> -
[BitScreenQuery.Md] IsMatched?: @isMatched
"; - private string example4CsharpCode = @" -private bool isMatched;"; + private string example5RazorCode = @" + + The screen is in landscape orientation. + The screen is in portrait orientation. + + + + The system prefers a dark color scheme. + The system prefers a light color scheme. + + + + The primary pointing device is precise (e.g. a mouse). + The primary pointing device is coarse or absent (e.g. a touchscreen). + + + + Reduced motion is requested by the system. + Reduced motion is not requested by the system. +"; + + private string example6RazorCode = @" + + This content renders without a wrapping element (BitScreenQuery.GtSm). + [BitScreenQuery.GtSm] NotMatched! (still no wrapping element) +"; + + private string example7RazorCode = @" + + This is Matched (BitScreenQuery.GtSm), also rendered before the query gets evaluated. + [BitScreenQuery.GtSm] NotMatched!. +"; + + private string example8RazorCode = @" + isMatched = v"" /> +
[BitScreenQuery.Md] IsMatched?: @isMatched
+
[BitScreenQuery.Md] via the IsMatched property: @(mediaQueryRef?.IsMatched ?? false)
"; + private string example8CsharpCode = @" +private bool isMatched; +private BitMediaQuery? mediaQueryRef;"; } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/MediaQuery/BitMediaQueryTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/MediaQuery/BitMediaQueryTests.cs index 2f35e1e9b2e..c6e0720c092 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/MediaQuery/BitMediaQueryTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/MediaQuery/BitMediaQueryTests.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Linq; +using System.Threading.Tasks; using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -9,50 +10,505 @@ namespace Bit.BlazorUI.Tests.Components.Utilities.MediaQuery; public class BitMediaQueryTests : BunitTestContext { [TestMethod] - public void BitMediaQueryShouldRenderMatchedChildContentWhenQueryGiven() + public void BitMediaQueryShouldRenderNothingWithoutAnyContent() { - Context.JSInterop.SetupVoid("BitBlazorUI.MediaQuery.setup"); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScreenQuery, BitScreenQuery.Md); + parameters.Add(p => p.OnChange, (bool _) => { }); + }); - var comp = RenderComponent(parameters => + Assert.AreEqual(string.Empty, component.Markup.Trim()); + } + + [TestMethod] + public void BitMediaQueryShouldRenderRootElementWhenContentProvided() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.AddChildContent("content"); + }); + + var root = component.Find(".bit-mdq"); + Assert.IsNotNull(root); + Assert.IsFalse(string.IsNullOrEmpty(root.Id)); + } + + [TestMethod] + public void BitMediaQueryShouldRenderNotMatchedContentInitially() + { + var component = RenderComponent(parameters => { parameters.Add(p => p.Query, "(max-width: 600px)"); parameters.Add(p => p.Matched, (RenderFragment)(b => b.AddMarkupContent(0, "
Matched
"))); parameters.Add(p => p.NotMatched, (RenderFragment)(b => b.AddMarkupContent(0, "
NotMatched
"))); }); - // initial call to JS returns default(T) so no content change expected, component should render container - var root = comp.Find(".bit-mdq"); - Assert.IsNotNull(root); + Assert.AreEqual(0, component.FindAll(".matched").Count); + Assert.AreEqual(1, component.FindAll(".notmatched").Count); + } + + [TestMethod] + public void BitMediaQueryShouldRenderMatchedContentInitiallyWithDefaultMatched() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.DefaultMatched, true); + parameters.Add(p => p.Matched, (RenderFragment)(b => b.AddMarkupContent(0, "
Matched
"))); + parameters.Add(p => p.NotMatched, (RenderFragment)(b => b.AddMarkupContent(0, "
NotMatched
"))); + }); + + Assert.AreEqual(1, component.FindAll(".matched").Count); + Assert.AreEqual(0, component.FindAll(".notmatched").Count); + Assert.IsTrue(component.Instance.IsMatched); + } + + [TestMethod] + public void BitMediaQueryShouldSwitchContentOnMatchChange() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.Matched, (RenderFragment)(b => b.AddMarkupContent(0, "
Matched
"))); + parameters.Add(p => p.NotMatched, (RenderFragment)(b => b.AddMarkupContent(0, "
NotMatched
"))); + }); + + component.InvokeAsync(() => component.Instance._OnMatchChange(true).AsTask()).GetAwaiter().GetResult(); + + Assert.AreEqual(1, component.FindAll(".matched").Count); + Assert.AreEqual(0, component.FindAll(".notmatched").Count); + + component.InvokeAsync(() => component.Instance._OnMatchChange(false).AsTask()).GetAwaiter().GetResult(); + + Assert.AreEqual(0, component.FindAll(".matched").Count); + Assert.AreEqual(1, component.FindAll(".notmatched").Count); + } + + [TestMethod] + public void BitMediaQueryShouldRenderChildContentAsMatchedContent() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.AddChildContent("
Child
"); + }); + + Assert.AreEqual(0, component.FindAll(".child").Count); + + component.InvokeAsync(() => component.Instance._OnMatchChange(true).AsTask()).GetAwaiter().GetResult(); + + Assert.AreEqual(1, component.FindAll(".child").Count); + } + + [TestMethod] + public void BitMediaQueryShouldPreferMatchedOverChildContent() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.Matched, (RenderFragment)(b => b.AddMarkupContent(0, "
Matched
"))); + parameters.AddChildContent("
Child
"); + }); + + component.InvokeAsync(() => component.Instance._OnMatchChange(true).AsTask()).GetAwaiter().GetResult(); + + Assert.AreEqual(1, component.FindAll(".matched").Count); + Assert.AreEqual(0, component.FindAll(".child").Count); } [TestMethod] public void BitMediaQueryShouldInvokeOnChangeWhenJsNotifies() { - Context.JSInterop.SetupVoid("BitBlazorUI.MediaQuery.setup"); + bool? changed = null; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.OnChange, (bool v) => changed = v); + }); + + component.InvokeAsync(() => component.Instance._OnMatchChange(true).AsTask()).GetAwaiter().GetResult(); + Assert.IsTrue(changed); + + component.InvokeAsync(() => component.Instance._OnMatchChange(false).AsTask()).GetAwaiter().GetResult(); + Assert.IsFalse(changed); + } + + [TestMethod] + public void BitMediaQueryShouldExposeIsMatchedState() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.AddChildContent("content"); + }); + + Assert.IsFalse(component.Instance.IsMatched); + + component.InvokeAsync(() => component.Instance._OnMatchChange(true).AsTask()).GetAwaiter().GetResult(); + + Assert.IsTrue(component.Instance.IsMatched); + } + + [TestMethod] + public void BitMediaQueryShouldCallJsSetupWithCustomQuery() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.AddChildContent("content"); + }); + + var invocation = Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.setup"); + Assert.AreEqual("(max-width: 600px)", invocation.Arguments[1]); + Assert.IsNull(invocation.Arguments[2]); + } + + [TestMethod] + public void BitMediaQueryShouldCallJsSetupWithScreenQuery() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScreenQuery, BitScreenQuery.GtSm); + parameters.AddChildContent("content"); + }); + + var invocation = Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.setup"); + Assert.IsNull(invocation.Arguments[1]); + Assert.AreEqual("GtSm", invocation.Arguments[2]); + } + + [TestMethod] + [DataRow(BitScreenQuery.Xs, "Xs")] + [DataRow(BitScreenQuery.LtXxl, "LtXxl")] + [DataRow(BitScreenQuery.GtXl, "GtXl")] + [DataRow(BitScreenQuery.SmToMd, "SmToMd")] + [DataRow(BitScreenQuery.LgToXl, "LgToXl")] + public void BitMediaQueryShouldPassScreenQueryNameToJs(BitScreenQuery screenQuery, string expectedName) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScreenQuery, screenQuery); + parameters.AddChildContent("content"); + }); + + var invocation = Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.setup"); + Assert.IsNull(invocation.Arguments[1]); + Assert.AreEqual(expectedName, invocation.Arguments[2]); + } + + [TestMethod] + public void BitMediaQueryShouldPreferCustomQueryOverScreenQuery() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.ScreenQuery, BitScreenQuery.Md); + parameters.AddChildContent("content"); + }); + + var invocation = Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.setup"); + Assert.AreEqual("(max-width: 600px)", invocation.Arguments[1]); + Assert.IsNull(invocation.Arguments[2]); + } + + [TestMethod] + public void BitMediaQueryShouldTreatBlankQueryAsAbsent() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, " "); + parameters.Add(p => p.ScreenQuery, BitScreenQuery.Lg); + parameters.AddChildContent("content"); + }); + + var invocation = Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.setup"); + Assert.IsNull(invocation.Arguments[1]); + Assert.AreEqual("Lg", invocation.Arguments[2]); + } + + [TestMethod] + public void BitMediaQueryShouldNotRepeatJsSetupForUnchangedCustomQuery() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.AddChildContent("content"); + }); + + component.Render(); + + Assert.AreEqual(1, Context.JSInterop.Invocations.Count(i => i.Identifier == "BitBlazorUI.MediaQuery.setup")); + } + + [TestMethod] + public void BitMediaQueryShouldRepeatJsSetupForChangedCustomQuery() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.AddChildContent("content"); + }); + + component.Render(parameters => + { + parameters.Add(p => p.Query, "(max-width: 900px)"); + }); + + var invocations = Context.JSInterop.Invocations.Where(i => i.Identifier == "BitBlazorUI.MediaQuery.setup").ToList(); + Assert.AreEqual(2, invocations.Count); + Assert.AreEqual("(max-width: 900px)", invocations[1].Arguments[1]); + } + + [TestMethod] + public void BitMediaQueryShouldRepeatJsSetupForScreenQueryOnRerender() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScreenQuery, BitScreenQuery.Md); + parameters.AddChildContent("content"); + }); + + component.Render(); + + // The effective query of a ScreenQuery is resolved on the JS side from the live theme + // breakpoints, so setup is re-invoked on every render (JS reuses the listener when the + // resolved expression is unchanged). + Assert.AreEqual(2, Context.JSInterop.Invocations.Count(i => i.Identifier == "BitBlazorUI.MediaQuery.setup")); + } + + [TestMethod] + public void BitMediaQueryShouldSwitchBetweenScreenQueryAndCustomQuery() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScreenQuery, BitScreenQuery.Md); + parameters.AddChildContent("content"); + }); + + component.Render(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + }); + + var invocations = Context.JSInterop.Invocations.Where(i => i.Identifier == "BitBlazorUI.MediaQuery.setup").ToList(); + Assert.AreEqual(2, invocations.Count); + Assert.AreEqual("Md", invocations[0].Arguments[2]); + Assert.AreEqual("(max-width: 600px)", invocations[1].Arguments[1]); + Assert.IsNull(invocations[1].Arguments[2]); + } + + [TestMethod] + public void BitMediaQueryShouldReSetupWhenIdChanges() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.Id, "first-id"); + parameters.AddChildContent("content"); + }); + + component.Render(parameters => + { + parameters.Add(p => p.Id, "second-id"); + }); + + // The JS listener is keyed by the element id, so a changed Id disposes the old key and + // sets the listener up again under the new one. + var disposeInvocation = Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.dispose"); + Assert.AreEqual("first-id", disposeInvocation.Arguments[0]); + + var setups = Context.JSInterop.Invocations.Where(i => i.Identifier == "BitBlazorUI.MediaQuery.setup").ToList(); + Assert.AreEqual(2, setups.Count); + Assert.AreEqual("first-id", setups[0].Arguments[0]); + Assert.AreEqual("second-id", setups[1].Arguments[0]); + } + + [TestMethod] + public void BitMediaQueryShouldSetupAgainAfterQueryRemovedAndReadded() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.AddChildContent("content"); + }); - var changed = false; - var comp = RenderComponent(parameters => + component.Render(parameters => + { + parameters.Add(p => p.Query, (string?)null); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.dispose"); + + component.Render(parameters => + { + parameters.Add(p => p.Query, "(max-width: 900px)"); + }); + + var setups = Context.JSInterop.Invocations.Where(i => i.Identifier == "BitBlazorUI.MediaQuery.setup").ToList(); + Assert.AreEqual(2, setups.Count); + Assert.AreEqual("(max-width: 900px)", setups[1].Arguments[1]); + } + + [TestMethod] + public void BitMediaQueryShouldNotCallJsSetupWithoutAnyQuery() + { + var component = RenderComponent(parameters => + { + parameters.AddChildContent("content"); + }); + + Assert.AreEqual(0, Context.JSInterop.Invocations.Count(i => i.Identifier == "BitBlazorUI.MediaQuery.setup")); + } + + [TestMethod] + public void BitMediaQueryShouldCallJsDisposeWhenQueryRemoved() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.AddChildContent("content"); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.setup"); + + component.Render(parameters => + { + parameters.Add(p => p.Query, (string?)null); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.dispose"); + } + + [TestMethod] + public void BitMediaQueryShouldCallJsDisposeOnDispose() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.AddChildContent("content"); + }); + + Context.DisposeComponentsAsync().GetAwaiter().GetResult(); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.dispose"); + } + + [TestMethod] + public void BitMediaQueryShouldNotCallJsDisposeOnDisposeWithoutAnyQuery() + { + var component = RenderComponent(parameters => + { + parameters.AddChildContent("content"); + }); + + Context.DisposeComponentsAsync().GetAwaiter().GetResult(); + + // No listener was ever set up, so there is nothing to tear down on the JS side. + Assert.AreEqual(0, Context.JSInterop.Invocations.Count(i => i.Identifier == "BitBlazorUI.MediaQuery.dispose")); + } + + [TestMethod] + public void BitMediaQueryShouldNotInvokeOnChangeAfterDispose() + { + bool? changed = null; + var component = RenderComponent(parameters => { parameters.Add(p => p.Query, "(max-width: 600px)"); parameters.Add(p => p.OnChange, (bool v) => changed = v); }); - // simulate JS invocation by calling the internal method via reflection - var instance = comp.Instance; - var method = instance.GetType().GetMethod("_OnMatchChange", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); - Assert.IsNotNull(method); + var instance = component.Instance; - var invokeResult = method!.Invoke(instance, [true]); + Context.DisposeComponentsAsync().GetAwaiter().GetResult(); - if (invokeResult is ValueTask vt) + // A notification racing the disposal must be ignored instead of rendering a disposed component. + instance._OnMatchChange(true).GetAwaiter().GetResult(); + + Assert.IsNull(changed); + } + + [TestMethod] + public void BitMediaQueryShouldRenderAriaLabel() + { + var component = RenderComponent(parameters => { - vt.GetAwaiter().GetResult(); - } - else if (invokeResult is Task t) + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.AriaLabel, "media query region"); + parameters.AddChildContent("content"); + }); + + var root = component.Find(".bit-mdq"); + Assert.AreEqual("media query region", root.GetAttribute("aria-label")); + } + + [TestMethod] + public void BitMediaQueryShouldRespectClassStyleIdAndDir() + { + var component = RenderComponent(parameters => { - t.GetAwaiter().GetResult(); - } + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.Class, "custom-class"); + parameters.Add(p => p.Style, "color: red;"); + parameters.Add(p => p.Id, "custom-id"); + parameters.Add(p => p.Dir, BitDir.Rtl); + parameters.AddChildContent("content"); + }); - Assert.IsTrue(changed); + var root = component.Find(".bit-mdq"); + Assert.IsTrue(root.ClassList.Contains("custom-class")); + Assert.IsTrue(root.ClassList.Contains("bit-rtl")); + StringAssert.Contains(root.GetAttribute("style"), "color: red"); + Assert.AreEqual("custom-id", root.Id); + Assert.AreEqual("rtl", root.GetAttribute("dir")); + } + + [TestMethod] + public void BitMediaQueryShouldRenderWithoutRootElementWithNoWrapper() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.NoWrapper, true); + parameters.Add(p => p.Matched, (RenderFragment)(b => b.AddMarkupContent(0, "
Matched
"))); + parameters.Add(p => p.NotMatched, (RenderFragment)(b => b.AddMarkupContent(0, "
NotMatched
"))); + }); + + Assert.AreEqual(0, component.FindAll(".bit-mdq").Count); + Assert.AreEqual(1, component.FindAll(".notmatched").Count); + + component.InvokeAsync(() => component.Instance._OnMatchChange(true).AsTask()).GetAwaiter().GetResult(); + + Assert.AreEqual(0, component.FindAll(".bit-mdq").Count); + Assert.AreEqual(1, component.FindAll(".matched").Count); + } + + [TestMethod] + public void BitMediaQueryShouldRenderNothingWithNoWrapperWhenCollapsed() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.NoWrapper, true); + parameters.Add(p => p.Visibility, BitVisibility.Collapsed); + parameters.Add(p => p.NotMatched, (RenderFragment)(b => b.AddMarkupContent(0, "
NotMatched
"))); + }); + + Assert.AreEqual(string.Empty, component.Markup.Trim()); + } + + [TestMethod] + public void BitMediaQueryShouldRespectVisibility() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Query, "(max-width: 600px)"); + parameters.Add(p => p.Visibility, BitVisibility.Collapsed); + parameters.AddChildContent("content"); + }); + + var root = component.Find(".bit-mdq"); + StringAssert.Contains(root.GetAttribute("style"), "display:none"); } } From b1ef6dd87546a2f824bc8052bb9dfc7024058944 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Wed, 2 Sep 2026 19:57:12 +0330 Subject: [PATCH 2/2] resolve review comments --- .../MediaQuery/BitMediaQuery.razor.cs | 6 +- .../Utilities/MediaQuery/BitMediaQuery.ts | 24 ++++--- .../BitMediaQueryJsRuntimeExtensions.cs | 3 +- .../MediaQuery/BitMediaQueryTests.cs | 65 +++++++++++++++++++ 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor.cs index 7ac21e441fc..8296a2003d9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.razor.cs @@ -152,7 +152,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _query = effectiveKey; _setupId = _Id; - await _js.BitMediaQuerySetup(_Id, customQuery, screenQuery, _dotnetObj); + // In NoWrapper mode no element of this component's own is rendered, so the id + // is only the JS listener key: the flag tells the JS side to read the theme + // breakpoints off the document root instead of off whatever element happens to + // carry that id (the rendered content itself, when it is given the same id). + await _js.BitMediaQuerySetup(_Id, customQuery, screenQuery, NoWrapper, _dotnetObj); } catch (JSDisconnectedException) { } // circuit gone; nothing to set up } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.ts b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.ts index 1891356e327..87f5090afda 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.ts +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQuery.ts @@ -25,11 +25,14 @@ * @param screenQuery One of the predefined BitScreenQuery names (e.g. "Md", "LtLg", "GtSm"). * When set (and no custom query), the query is built from the live * --bit-bp-* theme breakpoints so a customized theme is honored. + * @param noWrapper Whether the component renders no element of its own, in which case + * `id` is only a listener key and the breakpoints are read from the + * document root rather than from an element carrying that id. */ - public static async setup(id: string, query: string | null, screenQuery: string | null, dotnetObj: DotNetObject) { + public static async setup(id: string, query: string | null, screenQuery: string | null, noWrapper: boolean, dotnetObj: DotNetObject) { if (!dotnetObj) return; - const resolvedQuery = query || (screenQuery ? MediaQuery.buildScreenQuery(screenQuery, id) : ''); + const resolvedQuery = query || (screenQuery ? MediaQuery.buildScreenQuery(screenQuery, id, noWrapper) : ''); if (!resolvedQuery) return; // The C# side re-invokes setup for screen queries on every render (the expression @@ -84,8 +87,8 @@ // Range bounds are half-open (min inclusive, max exclusive), so the upper edge is one CSS // pixel below the next breakpoint - matching the packaged media-queries.scss mixins, whose // "screen and" media-type prefix is kept too so the query does not also match print. - private static buildScreenQuery(screenQuery: string, id: string): string { - const bp = MediaQuery.resolveBreakpoints(id); + private static buildScreenQuery(screenQuery: string, id: string, noWrapper: boolean): string { + const bp = MediaQuery.resolveBreakpoints(id, noWrapper); const min = (v: string) => `(min-width: ${v})`; const max = (v: string) => `(max-width: ${MediaQuery.below(v)})`; @@ -127,11 +130,14 @@ // Reads the --bit-bp-* breakpoint tokens from the queried element's themed scope, so the // breakpoints of an enclosing BitThemeProvider are honored. Custom properties inherit, so a - // document-root definition still resolves through the element; the root itself is only read - // directly when the element is not rendered (e.g. an OnChange-only usage with no content). - // Built-in defaults fill in for any token that is unset everywhere. - private static resolveBreakpoints(id: string): Record { - const element = document.getElementById(id) ?? document.documentElement; + // document-root definition still resolves through the element. In no-wrapper mode there is + // no element of the component's own, so the root is read directly: `id` is then only the + // listener key, and any other element that happens to carry it - the rendered content + // itself, when it is given the same id - is not the component's themed scope. The root is + // also what is read when nothing is rendered at all (e.g. an OnChange-only usage with no + // content). Built-in defaults fill in for any token that is unset everywhere. + private static resolveBreakpoints(id: string, noWrapper: boolean): Record { + const element = (noWrapper ? null : document.getElementById(id)) ?? document.documentElement; const styles = typeof getComputedStyle === 'function' ? getComputedStyle(element) : null; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQueryJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQueryJsRuntimeExtensions.cs index cb97634f1ed..ab3f7f0d4c1 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQueryJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Utilities/MediaQuery/BitMediaQueryJsRuntimeExtensions.cs @@ -6,9 +6,10 @@ internal static ValueTask BitMediaQuerySetup(this IJSRuntime jsRuntime, string id, string? query, string? screenQuery, + bool noWrapper, DotNetObjectReference? dotnetObj) { - return jsRuntime.InvokeVoid("BitBlazorUI.MediaQuery.setup", id, query, screenQuery, dotnetObj); + return jsRuntime.InvokeVoid("BitBlazorUI.MediaQuery.setup", id, query, screenQuery, noWrapper, dotnetObj); } internal static ValueTask BitMediaQueryDispose(this IJSRuntime jsRuntime, string id) diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/MediaQuery/BitMediaQueryTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/MediaQuery/BitMediaQueryTests.cs index c6e0720c092..7fb3b7661b9 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/MediaQuery/BitMediaQueryTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Utilities/MediaQuery/BitMediaQueryTests.cs @@ -484,6 +484,71 @@ public void BitMediaQueryShouldRenderWithoutRootElementWithNoWrapper() Assert.AreEqual(1, component.FindAll(".matched").Count); } + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void BitMediaQueryShouldPassNoWrapperFlagToJsSetup(bool noWrapper) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScreenQuery, BitScreenQuery.Md); + parameters.Add(p => p.NoWrapper, noWrapper); + parameters.AddChildContent("content"); + }); + + var invocation = Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.setup"); + Assert.AreEqual(noWrapper, invocation.Arguments[3]); + } + + [TestMethod] + public void BitMediaQueryShouldPassNoWrapperFlagWhenTheContentCarriesTheSameId() + { + var component = RenderComponent(parameters => + { + // A themed ScreenQuery is the case that reads the --bit-bp-* breakpoints off an + // element, and NoWrapper renders the content directly, so nothing but the content + // itself can carry the id the listener is keyed by. + parameters.Add(p => p.ScreenQuery, BitScreenQuery.Md); + parameters.Add(p => p.NoWrapper, true); + parameters.Add(p => p.Id, "mdq-id"); + parameters.Add(p => p.DefaultMatched, true); + parameters.AddChildContent("
Child
"); + }); + + Assert.AreEqual(0, component.FindAll(".bit-mdq").Count); + Assert.AreEqual("mdq-id", component.Find(".child").Id); + + // The id is only the listener key here: the flag tells the JS side to resolve the + // breakpoints from the document root rather than from the content that carries it. + var invocation = Context.JSInterop.VerifyInvoke("BitBlazorUI.MediaQuery.setup"); + Assert.AreEqual("mdq-id", invocation.Arguments[0]); + Assert.AreEqual("Md", invocation.Arguments[2]); + Assert.AreEqual(true, invocation.Arguments[3]); + } + + [TestMethod] + public void BitMediaQueryShouldPassTheUpdatedNoWrapperFlagWhenItToggles() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScreenQuery, BitScreenQuery.Md); + parameters.Add(p => p.Id, "mdq-id"); + parameters.AddChildContent("content"); + }); + + component.Render(parameters => + { + parameters.Add(p => p.NoWrapper, true); + }); + + // A ScreenQuery re-invokes setup on every render, so the scope the breakpoints are read + // from follows the toggle instead of staying at what the first setup was told. + var setups = Context.JSInterop.Invocations.Where(i => i.Identifier == "BitBlazorUI.MediaQuery.setup").ToList(); + Assert.AreEqual(2, setups.Count); + Assert.AreEqual(false, setups[0].Arguments[3]); + Assert.AreEqual(true, setups[1].Arguments[3]); + } + [TestMethod] public void BitMediaQueryShouldRenderNothingWithNoWrapperWhenCollapsed() {