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..8296a2003d9 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,39 @@ 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
{
- await _js.BitMediaQuerySetup(_Id, customQuery, screenQuery, _dotnetObj);
+ if (_setupId is not null && _setupId != _Id)
+ {
+ await _js.BitMediaQueryDispose(_setupId);
+ }
+
+ _query = effectiveKey;
+ _setupId = _Id;
+
+ // 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
}
}
- 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 +184,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..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
@@ -45,6 +48,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 +62,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,43 +85,59 @@
// 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.
- private static buildScreenQuery(screenQuery: string, id: string): string {
- const bp = MediaQuery.resolveBreakpoints(id);
+ // 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, 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)})`;
- 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
// 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/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..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
@@ -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,570 @@ 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 _) => { });
+ });
+
+ 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 comp = RenderComponent(parameters =>
+ 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");
+ });
- var changed = false;
- var comp = RenderComponent(parameters =>
+ 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");
+ });
+
+ 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;
+
+ Context.DisposeComponentsAsync().GetAwaiter().GetResult();
- var invokeResult = method!.Invoke(instance, [true]);
+ // A notification racing the disposal must be ignored instead of rendering a disposed component.
+ instance._OnMatchChange(true).GetAwaiter().GetResult();
+
+ Assert.IsNull(changed);
+ }
- if (invokeResult is ValueTask vt)
+ [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]
+ [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()
+ {
+ 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");
}
}