diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss index bde13c2c08b..956ab7cac04 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss @@ -88,7 +88,7 @@ $acd-private-properties: // token is substituted where it is declared (on :root) and inherited as that value, so a -full duration set on // the accordion itself or on any ancestor short of a theme scope would never reach it. The shared factor still // collapses it under reduced motion, as it does a duration set inline. - --bit-acd-dur-full: var(--bit-mot-duration-long-full, #{$mot-duration-long}); + --bit-acd-dur-full: #{$mot-duration-long-full}; --bit-acd-dur: #{mot-scaled-duration(var(--bit-acd-dur-full))}; // Every rule that reaches from the root into a part of the accordion is chained down through the header line diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.scss b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.scss index 3928b49065c..5ef6fe92457 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Modal/BitModal.scss @@ -215,7 +215,7 @@ @media (prefers-reduced-motion: reduce) { .bit-mdl-bna:not(.bit-fam):not(.bit-fam *), .bit-mdl-bnb:not(.bit-fam):not(.bit-fam *) { - animation-duration: var(--bit-mot-duration-long-full, #{$mot-duration-long}); + animation-duration: $mot-duration-long-full; animation-timing-function: steps(1, end); } diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss b/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss index a11d04404a6..a380e7ea1b6 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/Fluent/motion.fluent.scss @@ -1,7 +1,10 @@ // The -full tokens hold the unreduced values and are never touched by the reduced-motion query // below, so they stay the single place to theme a duration: overriding --bit-mot-duration-full -// changes both the normal value and the one .bit-fam restores. Components always read the plain -// --bit-mot-* tokens; only the reduced-motion opt-out (.bit-fam in general.scss) reads -full. +// changes both the normal value and the one .bit-fam restores. That is why BitTheme.Motion writes +// them, never the plain tokens: a plain token set inline would outrank the reduced-motion query. +// Components read the plain --bit-mot-* tokens; besides the aliases below, the only readers of -full +// are the $mot-*-full aliases of theme-variables.scss, for what stays meaningful under the +// preference (a color flash, not a movement). // // The literals live here, on :root alone; the plain tokens that alias them live in the twinned // block below. A preset for another design system (the packaged Fluent 2 / Material / Cupertino @@ -39,48 +42,79 @@ --bit-mot-loop-factor-full: 1; } -// The plain tokens every component reads. They are aliases, so - exactly as in semantic-tokens.scss -// and family-tokens.scss - they need a scoped-preset twin: a custom property's var() references are +// The plain tokens every component reads. Each is the reduced value while the reduction is in force, +// and the unreduced -full value otherwise. +// +// The reduction is itself inherited state: the -reduced tokens hold the values it substitutes, and +// are declared only while it is in force - by the reduced-motion query on :root below, or again by a +// consumer re-applying it to a subtree - and reset to `initial` (no value, so the alias falls back to +// -full) by .bit-fam, the ForceAnimation opt-out, for its element and everything under it. So nothing +// ever has to decide again whether motion is reduced where the aliases are re-substituted: every +// place that declares them reads that answer off its ancestors. +// +// They are aliases, so - exactly as in semantic-tokens.scss and family-tokens.scss - they are +// re-substituted wherever one of their inputs can change: a custom property's var() references are // substituted at the element that DEFINES it, and descendants inherit the already-substituted value. // Declared on :root alone, --bit-mot-duration would freeze the DOCUMENT's 200ms inside an element -// carrying bit-theme="material", whose own --bit-mot-duration-full says 250ms. -// -// The twin is wrapped in :where() so it contributes no specificity of its own: the reduced-motion -// query below re-declares the same two selectors and wins on source order, and .bit-fam (0,1,0) - -// the ForceAnimation opt-out in general.scss - still outranks both, including on an element that -// carries bit-theme and bit-fam at once. -:root, -:where(:root [bit-theme]) { - --bit-mot-duration: var(--bit-mot-duration-full); - --bit-mot-duration-short: var(--bit-mot-duration-short-full); - --bit-mot-duration-long: var(--bit-mot-duration-long-full); - --bit-mot-duration-factor: var(--bit-mot-duration-factor-full); +// carrying bit-theme="material", whose own --bit-mot-duration-full says 250ms, or inside an inline +// theme overlay - a BitThemeProvider wrapper, or ApplyBitThemeAsync on an element - that re-values a +// -full token on its own element (the motion group of theme-overlay.scss). Every theme builds the +// aliases the same way, so this one rule serves every scoped region and every overlay; and it would +// freeze the reduced 0.01ms inside a .bit-fam subtree, which is why .bit-fam re-substitutes them too. +@mixin motion-aliases { + --bit-mot-duration: var(--bit-mot-duration-reduced, var(--bit-mot-duration-full)); + --bit-mot-duration-short: var(--bit-mot-duration-short-reduced, var(--bit-mot-duration-short-full)); + --bit-mot-duration-long: var(--bit-mot-duration-long-reduced, var(--bit-mot-duration-long-full)); + --bit-mot-duration-factor: var(--bit-mot-duration-factor-reduced, var(--bit-mot-duration-factor-full)); + + --bit-mot-duration-spinner: var(--bit-mot-duration-spinner-reduced, var(--bit-mot-duration-spinner-full)); + --bit-mot-easing-spinner: var(--bit-mot-easing-spinner-reduced, var(--bit-mot-easing-spinner-full)); + --bit-mot-loop-factor: var(--bit-mot-loop-factor-reduced, var(--bit-mot-loop-factor-full)); +} - --bit-mot-duration-spinner: var(--bit-mot-duration-spinner-full); - --bit-mot-easing-spinner: var(--bit-mot-easing-spinner-full); - --bit-mot-loop-factor: var(--bit-mot-loop-factor-full); +// The twins are wrapped in :where() so they contribute no specificity of their own: what a +// consumer's own rule declares for a plain token on a scoped region or an overlay element still +// wins there, as it does on any other element. +:root, +:where(:root [bit-theme]), +:where(:root [bit-theme-overlay~="motion"]) { + @include motion-aliases; } // 0.01ms instead of 0ms: a zero-duration transition is never run, so it never fires its // transitionend event, which would hang anything awaiting it (a zero-duration animation still // fires animationend; the one value keeps both kinds of wait working alike). -// Twinned like the block above, and for the same reason in reverse: without the twin the collapse -// would never reach a scoped preset region, whose durations are re-substituted on its own element. @media (prefers-reduced-motion: reduce) { - :root, - :where(:root [bit-theme]) { - --bit-mot-duration: 0.01ms; - --bit-mot-duration-short: 0.01ms; - --bit-mot-duration-long: 0.01ms; + :root { + --bit-mot-duration-reduced: 0.01ms; + --bit-mot-duration-short-reduced: 0.01ms; + --bit-mot-duration-long-reduced: 0.01ms; // A page-set duration collapses to the same 0.01ms (mot-scaled-duration adds it back in // for exactly this case), and a page-set delay to nothing. - --bit-mot-duration-factor: 0; + --bit-mot-duration-factor-reduced: 0; // Calmed rather than collapsed - see above. The spinner also drops its eased timing function, // whose speeding up and slowing down is the part that draws the eye. Stopping a loader // outright is never an option: it is the only signal that the work is still in progress. - --bit-mot-duration-spinner: 4s; - --bit-mot-easing-spinner: linear; - --bit-mot-loop-factor: 3; + --bit-mot-duration-spinner-reduced: 4s; + --bit-mot-easing-spinner-reduced: linear; + --bit-mot-loop-factor-reduced: 3; } } + +// Opts a subtree out of the reduced-motion collapse (BitComponentBase.ForceAnimation): the reduction +// is withdrawn for this element and everything under it, and the aliases are substituted again here, +// where they now resolve to the -full values - without touching the document root. A scoped region +// or an overlay inside the subtree inherits the withdrawn reduction and so stays unreduced as well. +// It comes after the query above, so it still wins on source order on the root element itself. +.bit-fam { + --bit-mot-duration-reduced: initial; + --bit-mot-duration-short-reduced: initial; + --bit-mot-duration-long-reduced: initial; + --bit-mot-duration-factor-reduced: initial; + --bit-mot-duration-spinner-reduced: initial; + --bit-mot-easing-spinner-reduced: initial; + --bit-mot-loop-factor-reduced: initial; + + @include motion-aliases; +} diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/general.scss b/src/BlazorUI/Bit.BlazorUI/Styles/general.scss index 0f115db8758..0bccd28b1cd 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/general.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/general.scss @@ -37,26 +37,6 @@ button { } } -// Opts a subtree out of the reduced-motion collapse in motion.fluent.scss (BitComponentBase.ForceAnimation). -// The durations are inherited custom properties, so re-pointing them at the -full tokens here restores the -// unreduced values for this element and everything under it, without touching the document root. The -// literal fallbacks keep this working for a consumer theme that defines --bit-mot-duration* by hand and -// therefore has no -full tokens to fall back on. -.bit-fam { - --bit-mot-duration: var(--bit-mot-duration-full, 200ms); - --bit-mot-duration-short: var(--bit-mot-duration-short-full, 100ms); - --bit-mot-duration-long: var(--bit-mot-duration-long-full, 300ms); - // Which is what restores a duration the page set on a component as well: every one of them is - // scaled by this factor (mot-scaled-duration), so none needs a restore of its own. - --bit-mot-duration-factor: var(--bit-mot-duration-factor-full, 1); - - // The looping tokens slow down rather than collapse under reduced motion, but they are restored - // here the same way, so a spinner inside a ForceAnimation subtree turns at full speed again. - --bit-mot-duration-spinner: var(--bit-mot-duration-spinner-full, 1.3s); - --bit-mot-easing-spinner: var(--bit-mot-easing-spinner-full, cubic-bezier(0.53, 0.21, 0.29, 0.67)); - --bit-mot-loop-factor: var(--bit-mot-loop-factor-full, 1); -} - // The chain of `display: contents` copies a relocated callout is moved into (Callouts.moveCalloutToBody) carries // the classes of the ancestors it stands for, so that what they declare still inherits into the callout. A // display: contents element still generates its ::before and ::after, which would draw the decorations of diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss b/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss index 7c5c21ff94a..25e94755199 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/theme-overlay.scss @@ -23,6 +23,10 @@ // foreground --bit-clr-fg-pri every role foreground // pri ... err --bit-clr- that role's foreground and tint // typography --bit-tpg-fs-*, --bit-tpg-fw-* the dialog title's size and weight, the field caption's weight +// motion --bit-mot-*-full the plain motion tokens, which alias the -full ones +// (motion.fluent.scss, not this mixin: every theme builds +// them alike, so one zero-specificity rule serves all, +// and they read whether motion is reduced off ancestors) // // This mixin emits the selectors a theme block re-declares a group on, mirroring the selectors of the // block itself: diff --git a/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss b/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss index e13c517b567..feb43565f70 100644 --- a/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss +++ b/src/BlazorUI/Bit.BlazorUI/Styles/theme-variables.scss @@ -562,6 +562,14 @@ $box-shadow-focus-ring: var(--bit-shd-focus-ring); $mot-duration: var(--bit-mot-duration); $mot-duration-short: var(--bit-mot-duration-short); $mot-duration-long: var(--bit-mot-duration-long); +//The unreduced durations: never collapsed by prefers-reduced-motion. Only for motion that stays +//meaningful under the preference - a change of color, not a movement (BitModal's refusal ring) - or +//for the default of a duration the page can set, which mot-scaled-duration collapses through the factor +//below instead (BitAccordion's transition). Each falls back to its plain token, for a consumer theme +//that declares only the plain ones by hand. +$mot-duration-full: var(--bit-mot-duration-full, var(--bit-mot-duration)); +$mot-duration-short-full: var(--bit-mot-duration-short-full, var(--bit-mot-duration-short)); +$mot-duration-long-full: var(--bit-mot-duration-long-full, var(--bit-mot-duration-long)); //A duration the PAGE sets on a component (a public --bit--*-duration variable, a duration //parameter written inline) is none of the tokens above, so it is scaled by this factor instead - 1 //normally, 0 under prefers-reduced-motion, 1 again wherever the tokens are restored (.bit-fam, an app's @@ -595,6 +603,11 @@ $mot-easing-spinner: var(--bit-mot-easing-spinner); //the loop factor multiplies literal durations inside calc(), so it carries a fallback of 1 to keep //those animations valid for consumer-defined themes that never set the token. $mot-loop-factor: var(--bit-mot-loop-factor, 1); +//The unreduced looping tokens, which the reduced-motion query never calms - for the same narrow uses +//as the unreduced durations above, with the same fallback to the plain token. +$mot-duration-spinner-full: var(--bit-mot-duration-spinner-full, var(--bit-mot-duration-spinner)); +$mot-easing-spinner-full: var(--bit-mot-easing-spinner-full, var(--bit-mot-easing-spinner)); +$mot-loop-factor-full: var(--bit-mot-loop-factor-full, var(--bit-mot-loop-factor, 1)); /*---- Typography ----*/ $tg-font-family: var(--bit-tpg-font-family); diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/BitCss.var.cs b/src/BlazorUI/Bit.BlazorUI/Utils/BitCss.var.cs index 6921abd8334..661c8ba6e64 100644 --- a/src/BlazorUI/Bit.BlazorUI/Utils/BitCss.var.cs +++ b/src/BlazorUI/Bit.BlazorUI/Utils/BitCss.var.cs @@ -613,18 +613,32 @@ public static class Card } } + /// + /// The motion tokens. The durations and their factor, the spinner pair and the loop factor come in two forms: the plain + /// one is what a style reads, and what prefers-reduced-motion collapses (or calms) and + /// ForceAnimation restores; the ...Full one holds the unreduced value the plain one aliases, + /// and is the one to set - setting the plain one, on an element above all, would outrank the + /// reduced-motion query. writes the ...Full ones. + /// public static class Motion { public const string Duration = "--bit-mot-duration"; public const string DurationShort = "--bit-mot-duration-short"; public const string DurationLong = "--bit-mot-duration-long"; public const string DurationFactor = "--bit-mot-duration-factor"; + public const string DurationFull = "--bit-mot-duration-full"; + public const string DurationShortFull = "--bit-mot-duration-short-full"; + public const string DurationLongFull = "--bit-mot-duration-long-full"; + public const string DurationFactorFull = "--bit-mot-duration-factor-full"; public const string Easing = "--bit-mot-easing"; public const string EasingDecelerate = "--bit-mot-easing-decelerate"; public const string EasingAccelerate = "--bit-mot-easing-accelerate"; public const string DurationSpinner = "--bit-mot-duration-spinner"; public const string EasingSpinner = "--bit-mot-easing-spinner"; public const string LoopFactor = "--bit-mot-loop-factor"; + public const string DurationSpinnerFull = "--bit-mot-duration-spinner-full"; + public const string EasingSpinnerFull = "--bit-mot-easing-spinner-full"; + public const string LoopFactorFull = "--bit-mot-loop-factor-full"; } public static class Layout diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme.ts b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme.ts index b9c46cff36a..bb46f3000c1 100644 --- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme.ts +++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme.ts @@ -36,6 +36,8 @@ namespace BitBlazorUI { ['err', ['--bit-clr-err']], ['typography', ['--bit-tpg-fs-2xs', '--bit-tpg-fs-xs', '--bit-tpg-fs-sm', '--bit-tpg-fs-md', '--bit-tpg-fs-lg', '--bit-tpg-fs-xl', '--bit-tpg-fs-2xl', '--bit-tpg-fs-3xl', '--bit-tpg-fs-4xl', '--bit-tpg-fw-light', '--bit-tpg-fw-regular', '--bit-tpg-fw-medium', '--bit-tpg-fw-semibold', '--bit-tpg-fw-bold']], + ['motion', ['--bit-mot-duration-full', '--bit-mot-duration-short-full', '--bit-mot-duration-long-full', '--bit-mot-duration-factor-full', + '--bit-mot-duration-spinner-full', '--bit-mot-easing-spinner-full', '--bit-mot-loop-factor-full']], ]; // The page's own background - what a status bar sits above in nearly every app. const THEME_COLOR_VARIABLE = '--bit-clr-bg-pri'; diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeMotion.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeMotion.cs index be5feaa9374..e9c0bd19418 100644 --- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeMotion.cs +++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitTheme/BitThemeMotion.cs @@ -1,21 +1,26 @@ namespace Bit.BlazorUI; +/// +/// The motion tokens of a theme. The durations and their factor, the spinner pair and the loop factor are written to their unreduced +/// -full tokens (--bit-mot-duration-full, ...), as a preset declares them, so a value set here is still +/// collapsed (or calmed) under prefers-reduced-motion and restored by ForceAnimation. +/// public class BitThemeMotion { + /// The standard duration of a transition or a one-shot animation (--bit-mot-duration-full). public string? Duration { get; set; } + + /// The short duration, for small state changes (--bit-mot-duration-short-full). public string? DurationShort { get; set; } + + /// The long duration, for large surfaces entering and leaving (--bit-mot-duration-long-full). public string? DurationLong { get; set; } /// /// Multiplies every duration a page sets on a component itself - a public /// --bit-<Component>-*-duration variable or a duration parameter - which none of the durations /// above reach. It is a unitless number: 1 keeps the pace the page asked for, and reduced motion drops it - /// to 0 so those durations collapse along with the theme's own (--bit-mot-duration-factor). - ///
- /// Like the durations above, it is written to the effective token, and a theme applied inline outranks the - /// reduced-motion query on :root: a value set here keeps every duration a page sets running for a reader - /// who asked for reduced motion. Leave it unset unless the app answers that preference itself, and scale the - /// page's durations with --bit-mot-duration-factor-full in CSS instead, which feeds both states. + /// to 0 so those durations collapse along with the theme's own (--bit-mot-duration-factor-full). ///
public string? DurationFactor { get; set; } diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs index 0b0cb1dfdcb..b66bfe044d9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs +++ b/src/BlazorUI/Bit.BlazorUI/Utils/Theme/BitThemeMapper.cs @@ -125,6 +125,9 @@ internal static void AugmentWithFocusRingReSubstitution(Dictionary @@ -666,16 +669,21 @@ internal static Dictionary MapToCssVariables(BitTheme bitTheme) addCssVar(BitCss.Var.Typography.Subtitle2.LineHeight, bitTheme.Typography.Subtitle2.LineHeight); addCssVar(BitCss.Var.Typography.Subtitle2.LetterSpacing, bitTheme.Typography.Subtitle2.LetterSpacing); - addCssVar(BitCss.Var.Motion.Duration, bitTheme.Motion.Duration); - addCssVar(BitCss.Var.Motion.DurationShort, bitTheme.Motion.DurationShort); - addCssVar(BitCss.Var.Motion.DurationLong, bitTheme.Motion.DurationLong); - addCssVar(BitCss.Var.Motion.DurationFactor, bitTheme.Motion.DurationFactor); + // The durations and their factor, the spinner pair and the loop factor go to their unreduced -full tokens, as a + // preset's do: the plain ones alias them and are what the reduced-motion query collapses and ForceAnimation + // restores, so a plain one written inline would outrank the user's preference and be restored to the default + // theme's value inside a ForceAnimation subtree (the motion group of FamilyAliasOverlayGroups re-substitutes the + // aliases on the overlay's element). + addCssVar(BitCss.Var.Motion.DurationFull, bitTheme.Motion.Duration); + addCssVar(BitCss.Var.Motion.DurationShortFull, bitTheme.Motion.DurationShort); + addCssVar(BitCss.Var.Motion.DurationLongFull, bitTheme.Motion.DurationLong); + addCssVar(BitCss.Var.Motion.DurationFactorFull, bitTheme.Motion.DurationFactor); addCssVar(BitCss.Var.Motion.Easing, bitTheme.Motion.EasingStandard); addCssVar(BitCss.Var.Motion.EasingDecelerate, bitTheme.Motion.EasingDecelerate); addCssVar(BitCss.Var.Motion.EasingAccelerate, bitTheme.Motion.EasingAccelerate); - addCssVar(BitCss.Var.Motion.DurationSpinner, bitTheme.Motion.DurationSpinner); - addCssVar(BitCss.Var.Motion.EasingSpinner, bitTheme.Motion.EasingSpinner); - addCssVar(BitCss.Var.Motion.LoopFactor, bitTheme.Motion.LoopFactor); + addCssVar(BitCss.Var.Motion.DurationSpinnerFull, bitTheme.Motion.DurationSpinner); + addCssVar(BitCss.Var.Motion.EasingSpinnerFull, bitTheme.Motion.EasingSpinner); + addCssVar(BitCss.Var.Motion.LoopFactorFull, bitTheme.Motion.LoopFactor); addCssVar(BitCss.Var.Layout.DensityScale, bitTheme.Layout.DensityScale); addCssVar(BitCss.Var.Layout.DialogActionsDirection, bitTheme.Layout.DialogActionsDirection); diff --git a/src/BlazorUI/CLAUDE.md b/src/BlazorUI/CLAUDE.md index a8dd75ce85b..0c643d54423 100644 --- a/src/BlazorUI/CLAUDE.md +++ b/src/BlazorUI/CLAUDE.md @@ -392,12 +392,17 @@ the whole library from one `:root[bit-theme="..."]` block. surface family (plus `$box-shadow-card-hover`, the lift of a card under the pointer), never `$box-shadow-callout` directly. - **Motion**: `$mot-easing` for state transitions, `$mot-easing-decelerate` / `-accelerate` for popup - entry / exit; never a literal `ease` or `cubic-bezier` outside a looping loader keyframe. + entry / exit; never a literal `ease` or `cubic-bezier` outside a looping loader keyframe. A duration + that must survive `prefers-reduced-motion` (a color flash rather than a movement, BitModal's refusal ring) + reads `$mot-duration-full` / `-short-full` / `-long-full`, never `--bit-mot-*-full` itself + (`BitMotionTokenTests`). Whether motion is reduced is inherited state, the `--bit-mot-*-reduced` tokens: declared on `:root` + under the query, withdrawn (`initial`) by `.bit-fam`, and read by every rule that substitutes the plain + aliases again (`:root`, a scoped preset, a motion overlay, `.bit-fam`), so none of them decides it anew. **Reduced motion is the tokens' job alone, and a duration the page sets follows it too.** The `--bit-mot-duration*` tokens collapse under `prefers-reduced-motion` and `.bit-fam` (ForceAnimation) - or an app's `:root` restores them; a duration the page sets on a component - a public - `--bit--*-duration` variable, a duration parameter written inline as a private token - is - read through `mot-scaled-duration(...)` (a delay through `mot-scaled-delay(...)`) from + or an app withdrawing the `-reduced` tokens at `:root` restores them; a duration the page sets on a + component - a public `--bit--*-duration` variable, a duration parameter written inline as a + private token - is read through `mot-scaled-duration(...)` (a delay through `mot-scaled-delay(...)`) from `Styles/functions.scss`, which scales it by `--bit-mot-duration-factor` (1, 0 under the query, restored with the rest), so it collapses and comes back with them. A component therefore writes **no `prefers-reduced-motion` query and no `.bit-fam` restore for a duration** - the per-component pair missed @@ -462,7 +467,10 @@ global token tier: add the token, let the component read it, and keep the preset `BitThemeProvider` / `ApplyBitThemeAsync` writes only the primitives it was given, plus a `bit-theme-overlay` attribute naming the alias groups those feed (`BitThemeMapper.FamilyAliasOverlayGroups`: `radius`, `radius-control`, `shadow`, `tooltip`, `foreground`, the eight roles, `typography` - the dialog -title and field caption, built from any step of the type ramps); every theme stylesheet +title and field caption, built from any step of the type ramps - and `motion` - the plain `--bit-mot-*` +aliases of the `-full` sources `BitTheme.Motion` writes, built alike by every theme and so re-declared by one +zero-specificity rule of `motion.fluent.scss`, reading whether motion is reduced off the inherited +`--bit-mot-*-reduced` tokens that `.bit-fam` withdraws); every theme stylesheet re-declares its OWN aliases of each group on that element through the `theme-overlay($group, $names)` mixin of `Styles/theme-overlay.scss`, so the aliases follow the overlay as the active theme builds them - never the core default under a preset that decided otherwise. So a group's declarations live in one mixin per theme diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor index e9a615b4d4e..595bb3c7021 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor @@ -847,7 +847,7 @@ BitAccentColorPresets.Rose; // #C239B3 - Re-declare your family aliases for an inline overlay. A BitThemeProvider, or ApplyBitThemeAsync on an element, that re-values a primitive marks its element with bit-theme-overlay, naming the groups of family aliases built from what it re-valued: radius (the global radius: every per-family radius), radius-control (the control or full radius: the button, chip and selection radii), shadow (the callout shadow, the tertiary border, the border width or the primary foreground: the per-family elevations), tooltip (the primary or secondary background or the primary foreground: the tooltip's fill and text), foreground and pri ... err (the role foregrounds and tints), typography (any step of the font-size or font-weight ramp: the dialog title's size and weight and the field caption's weight). The core stylesheet re-declares its defaults for each group there, which is what makes the aliases follow the overlay - so a preset that decides one of them differently re-declares its own value for that group on the same element, or an overlay puts the default back: a callout shadow under a flat tooltip, the field radius on a pill button. Three selectors per group, each one notch above the selector of your block it mirrors, which is how the packaged presets do it: + Re-declare your family aliases for an inline overlay. A BitThemeProvider, or ApplyBitThemeAsync on an element, that re-values a primitive marks its element with bit-theme-overlay, naming the groups of family aliases built from what it re-valued: radius (the global radius: every per-family radius), radius-control (the control or full radius: the button, chip and selection radii), shadow (the callout shadow, the tertiary border, the border width or the primary foreground: the per-family elevations), tooltip (the primary or secondary background or the primary foreground: the tooltip's fill and text), foreground and pri ... err (the role foregrounds and tints), typography (any step of the font-size or font-weight ramp: the dialog title's size and weight and the field caption's weight), and motion (a -full motion source: the effective durations, which every theme builds alike, so a preset has nothing to re-declare for it). The core stylesheet re-declares its defaults for each group there, which is what makes the aliases follow the overlay - so a preset that decides one of them differently re-declares its own value for that group on the same element, or an overlay puts the default back: a callout shadow under a flat tooltip, the field radius on a pill button. Three selectors per group, each one notch above the selector of your block it mirrors, which is how the packaged presets do it: /* acme-material.css, after the main block: the aliases above, again for an overlay of what they are built from */ @@ -1150,7 +1150,7 @@ await BitThemeManager.ApplyBitThemeAsync(dense); + Description="The motion tokens, how a themed duration still honours reduced motion, the safety nets the stylesheets carry on their own, and the markup a disabled component styles from."> @@ -1162,29 +1162,27 @@ await BitThemeManager.ApplyBitThemeAsync(dense); { Motion = { - Duration = "200ms", // --bit-mot-duration - DurationShort = "100ms", // --bit-mot-duration-short - DurationLong = "300ms", // --bit-mot-duration-long + Duration = "200ms", // --bit-mot-duration-full + DurationShort = "100ms", // --bit-mot-duration-short-full + DurationLong = "300ms", // --bit-mot-duration-long-full EasingStandard = "cubic-bezier(0.33, 0, 0.67, 1)", // --bit-mot-easing // Multiplies the durations a page sets on a component itself (--bit-Collapse-duration, // a TransitionDuration parameter, ...), which the tokens above do not reach. Reduced - // motion drops it to 0, which is what collapses those durations as well - so a value - // set here would keep them running for those readers (see the caveat below). Scale - // them with --bit-mot-duration-factor-full in CSS instead. - // DurationFactor = "1", // --bit-mot-duration-factor + // motion drops it to 0, which is what collapses those durations as well. + DurationFactor = "1", // --bit-mot-duration-factor-full // Looping animations (spinners, loaders, indeterminate bars) cannot use the durations // above: those collapse to a near-zero value under reduced motion, which renders as // flicker rather than as stillness. They have their own tokens, which slow down instead. - DurationSpinner = "1.3s", // --bit-mot-duration-spinner - EasingSpinner = "cubic-bezier(0.53, 0.21, 0.29, 0.67)", // --bit-mot-easing-spinner - LoopFactor = "1", // --bit-mot-loop-factor + DurationSpinner = "1.3s", // --bit-mot-duration-spinner-full + EasingSpinner = "cubic-bezier(0.53, 0.21, 0.29, 0.67)", // --bit-mot-easing-spinner-full + LoopFactor = "1", // --bit-mot-loop-factor-full } }; - One caveat when theming durations. BitTheme.Motion.* writes the effective tokens (--bit-mot-duration…, --bit-mot-duration-factor), and those are exactly the tokens the reduced-motion media query collapses to 0.01ms - but it does so on :root, while ApplyBitThemeAsync / BitThemeProvider write inline on the body or a subtree, and inline wins. A duration set through the C# theme therefore also applies for users who asked for reduced motion. To theme durations and keep the opt-out working, set the --bit-mot-duration*-full sources in CSS instead - they feed the effective tokens in both states: + A themed duration still honours reduced motion. BitTheme.Motion.* writes the unreduced -full sources (--bit-mot-duration-full…), exactly as a preset declares them, never the effective tokens (--bit-mot-duration…) components read: those alias the sources, and they are what the reduced-motion media query collapses to 0.01ms and ForceAnimation restores. Where ApplyBitThemeAsync / BitThemeProvider write the sources inline on the body or a subtree, they also mark that element with bit-theme-overlay="motion", and the stylesheet re-declares the effective tokens there - reduced or not exactly as the content around that element is, so a provider inside a ForceAnimation subtree still animates. The same sources are what you set in CSS: :root { @@ -1205,10 +1203,10 @@ await BitThemeManager.ApplyBitThemeAsync(dense);
  • Reduced motion. Under prefers-reduced-motion: reduce, all --bit-mot-duration* tokens collapse to 0.01ms, so transitions effectively disable. The unreduced values stay available in the matching --bit-mot-duration*-full tokens, which is also where you change a duration since it feeds both states. - A single component can opt out with the ForceAnimation parameter (it renders the bit-fam class, which restores the full durations for that element and its content). + A single component can opt out with the ForceAnimation parameter (it renders the bit-fam class, which restores the full durations for that element and its content). The reduction itself is declared on :root as the --bit-mot-*-reduced tokens (--bit-mot-duration-reduced…), which the effective tokens read while they are set: an app that ignores the preference everywhere resets them to initial on :root in its own stylesheet, after the library's, and one that re-applies it to a subtree declares them there.
  • - A duration you set on a component follows the same preference. A pace given through a component's own variable (--bit-Collapse-duration, --bit-Toggle-transition-duration…) or parameter (TransitionDuration, AnimationDuration…) is multiplied by --bit-mot-duration-factor, which is 1 normally and 0 under reduced motion, so it collapses with the theme's own durations. ForceAnimation restores it along with them, and so does an app that turns reduced motion back off for a whole page by restoring the tokens at :root - as long as it restores this one too (--bit-mot-duration-factor: var(--bit-mot-duration-factor-full, 1)). + A duration you set on a component follows the same preference. A pace given through a component's own variable (--bit-Collapse-duration, --bit-Toggle-transition-duration…) or parameter (TransitionDuration, AnimationDuration…) is multiplied by --bit-mot-duration-factor, which is 1 normally and 0 under reduced motion, so it collapses with the theme's own durations. ForceAnimation restores it along with them, and so does an app that turns reduced motion back off for a whole page by resetting the -reduced tokens at :root - this one's is --bit-mot-duration-factor-reduced.
  • Looping animations are slowed, not stopped. A spinner or a loader is the only thing telling the user that work is still in progress, so it keeps running under reduced motion - and collapsing it to 0.01ms would render as flicker rather than as stillness. Instead --bit-mot-duration-spinner goes to 4s and --bit-mot-easing-spinner to linear, while --bit-mot-loop-factor rises to 3, stretching every other looping animation (the indeterminate BitProgress bar, the staggered BitLoading variants) as a whole so the phase offsets between their parts survive. All three have -full sources and are restored by ForceAnimation, exactly like the durations above. The exception is a shimmer or skeleton - a placeholder for content that has not arrived rather than a progress signal - which stops outright. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Styles/app.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Styles/app.scss index 904fbd6466f..094c4886095 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Styles/app.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Styles/app.scss @@ -54,19 +54,21 @@ // the demonstration. Everywhere else, on the marketing and guide pages whose motion is a handful of // 200ms hovers, the setting is ignored rather than leaving the site feeling broken. // -// The mechanics: the library collapses its --bit-mot-* tokens under the query below, and its own -// .bit-fam class (BitComponentBase.ForceAnimation) points them back at the untouched -full values. -// This restores them at :root, which this stylesheet can do because it is linked after the library's; -// the demo pages then opt back IN to the reduction through .demo-reduced-motion. +// The mechanics: under the query below the library declares its --bit-mot-*-reduced tokens on :root, +// and every plain --bit-mot-* token reads the reduced value from them while they are set and the +// untouched -full one otherwise - on :root and wherever it is substituted again (a scoped preset, a +// theme overlay, and the .bit-fam class of BitComponentBase.ForceAnimation, which withdraws them). +// This withdraws them at :root, which this stylesheet can do because it is linked after the +// library's; the demo pages then opt back IN to the reduction through .demo-reduced-motion. @media (prefers-reduced-motion: reduce) { :root { - --bit-mot-duration: var(--bit-mot-duration-full, 200ms); - --bit-mot-duration-short: var(--bit-mot-duration-short-full, 100ms); - --bit-mot-duration-long: var(--bit-mot-duration-long-full, 300ms); - --bit-mot-duration-factor: var(--bit-mot-duration-factor-full, 1); - --bit-mot-duration-spinner: var(--bit-mot-duration-spinner-full, 1.3s); - --bit-mot-easing-spinner: var(--bit-mot-easing-spinner-full, cubic-bezier(0.53, 0.21, 0.29, 0.67)); - --bit-mot-loop-factor: var(--bit-mot-loop-factor-full, 1); + --bit-mot-duration-reduced: initial; + --bit-mot-duration-short-reduced: initial; + --bit-mot-duration-long-reduced: initial; + --bit-mot-duration-factor-reduced: initial; + --bit-mot-duration-spinner-reduced: initial; + --bit-mot-easing-spinner-reduced: initial; + --bit-mot-loop-factor-reduced: initial; // The bar's own height, which the sticky pivot bar under it offsets itself by. It holds a // single BitToggle on a doc surface, so it is that toggle's switch track plus the surface's @@ -82,18 +84,29 @@ --demo-motion-bar-height: calc(max(var(--bit-siz-switch-h-md, calc(var(--bit-spa-scaling-factor, #{rem2(8px)}) * var(--bit-layout-density-scale, 1) * var(--bit-siz-switch-h-md-steps, 3))), #{rem2(24px)}) + #{$doc-space-2xs} * 2 + #{rem2(2px)}); } - // A component demo page with its ForceAnimation toggle off: exactly the library's own reduced - // values, re-applied to this subtree. Mirrors the block in Styles/Fluent/motion.fluent.scss - - // 0.01ms rather than 0ms, because a zero-duration transition never fires transitionend, and the - // looping tokens calmed rather than collapsed, because a near-zero loop renders as flicker. + // A component demo page with its ForceAnimation toggle off: exactly the library's own reduction, + // re-applied to this subtree. Mirrors Styles/Fluent/motion.fluent.scss - 0.01ms rather than 0ms, + // because a zero-duration transition never fires transitionend, and the looping tokens calmed + // rather than collapsed, because a near-zero loop renders as flicker. The plain tokens are + // substituted again here, as the library's .bit-fam does, because what this element inherits was + // already substituted unreduced at :root; a scoped preset or a theme overlay inside the page + // reads the reduction off this element by itself. .demo-reduced-motion { - --bit-mot-duration: 0.01ms; - --bit-mot-duration-short: 0.01ms; - --bit-mot-duration-long: 0.01ms; - --bit-mot-duration-factor: 0; - --bit-mot-duration-spinner: 4s; - --bit-mot-easing-spinner: linear; - --bit-mot-loop-factor: 3; + --bit-mot-duration-reduced: 0.01ms; + --bit-mot-duration-short-reduced: 0.01ms; + --bit-mot-duration-long-reduced: 0.01ms; + --bit-mot-duration-factor-reduced: 0; + --bit-mot-duration-spinner-reduced: 4s; + --bit-mot-easing-spinner-reduced: linear; + --bit-mot-loop-factor-reduced: 3; + + --bit-mot-duration: var(--bit-mot-duration-reduced, var(--bit-mot-duration-full)); + --bit-mot-duration-short: var(--bit-mot-duration-short-reduced, var(--bit-mot-duration-short-full)); + --bit-mot-duration-long: var(--bit-mot-duration-long-reduced, var(--bit-mot-duration-long-full)); + --bit-mot-duration-factor: var(--bit-mot-duration-factor-reduced, var(--bit-mot-duration-factor-full)); + --bit-mot-duration-spinner: var(--bit-mot-duration-spinner-reduced, var(--bit-mot-duration-spinner-full)); + --bit-mot-easing-spinner: var(--bit-mot-easing-spinner-reduced, var(--bit-mot-easing-spinner-full)); + --bit-mot-loop-factor: var(--bit-mot-loop-factor-reduced, var(--bit-mot-loop-factor-full)); } } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalStylesheetTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalStylesheetTests.cs index c849e105217..e4a6a8a14b9 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalStylesheetTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Modal/BitModalStylesheetTests.cs @@ -164,7 +164,7 @@ public void BitModalRefusalShouldStillBeAnsweredUnderReducedMotion() // unless the Modal was asked to animate regardless. StringAssert.Contains(reduced, "animation-name: bit-mdl-refuse-ring-a;"); StringAssert.Contains(reduced, "animation-name: bit-mdl-refuse-ring-b;"); - StringAssert.Contains(reduced, "var(--bit-mot-duration-long-full, #{$mot-duration-long})"); + StringAssert.Contains(reduced, "animation-duration: $mot-duration-long-full;"); Assert.IsFalse(Regex.IsMatch(reduced, @"\d+m?s\b"), "The ring hard-codes a duration instead of reading the motion token."); StringAssert.Contains(reduced, ":not(.bit-fam *)"); } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs new file mode 100644 index 00000000000..02f155743ca --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitMotionTokenTests.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Utils.Theme; + +/// +/// Pins how the motion tokens reach a component: the plain --bit-mot-* ones alias the inherited reduction (the +/// -reduced tokens) or else the unreduced -full ones, wherever they are declared, and a component reads +/// the unreduced values only through the $mot-*-full aliases of theme-variables.scss. +/// +[TestClass] +public sealed class BitMotionTokenTests +{ + private static readonly Regex FullMotionToken = new(@"--bit-mot-[a-z-]+-full\b", RegexOptions.Compiled); + + [TestMethod] + public void NoComponentStylesheetReadsAnUnreducedMotionTokenDirectly() + { + // The -full tokens are the theme's to set and the motion tier's to alias; a component that needs the unreduced + // duration reads it through $mot-duration-*-full, like every other design-system decision it reads. + var offenders = new List(); + + foreach (var file in EnumerateComponentStylesheets()) + { + var stylesheet = SourceFiles.StripScssComments(SourceFiles.ReadFullPath(file)); + + offenders.AddRange(FullMotionToken.Matches(stylesheet).Select(m => $"{Path.GetFileName(file)}: {m.Value}")); + } + + CollectionAssert.AreEqual(Array.Empty(), offenders.ToArray(), + $"Read the unreduced durations through $mot-duration-full / -short-full / -long-full: {string.Join(", ", offenders)}"); + } + + [TestMethod] + public void TheUnreducedTokensHaveThemeAliases() + { + // Each falls back to its plain token, for a consumer theme that declares only the plain ones by hand. + var scss = SourceFiles.ReadThemeStylesheet("theme-variables.scss"); + + StringAssert.Contains(scss, "$mot-duration-full: var(--bit-mot-duration-full, var(--bit-mot-duration));"); + StringAssert.Contains(scss, "$mot-duration-short-full: var(--bit-mot-duration-short-full, var(--bit-mot-duration-short));"); + StringAssert.Contains(scss, "$mot-duration-long-full: var(--bit-mot-duration-long-full, var(--bit-mot-duration-long));"); + StringAssert.Contains(scss, "$mot-duration-spinner-full: var(--bit-mot-duration-spinner-full, var(--bit-mot-duration-spinner));"); + StringAssert.Contains(scss, "$mot-easing-spinner-full: var(--bit-mot-easing-spinner-full, var(--bit-mot-easing-spinner));"); + StringAssert.Contains(scss, "$mot-loop-factor-full: var(--bit-mot-loop-factor-full, var(--bit-mot-loop-factor, 1));"); + } + + [TestMethod] + public void EveryPlaceThatSubstitutesTheAliasesReadsTheInheritedReduction() + { + // Whether motion is reduced is the inherited -reduced tokens, so every rule that substitutes the plain aliases + // again - :root, a scoped preset, a motion overlay (at no weight, so a consumer's own rule on that element still + // wins), and .bit-fam - reads it off its ancestors instead of deciding it anew: an overlay or a scoped preset + // inside a ForceAnimation subtree, or below an app that withdraws the reduction on :root, stays unreduced. + var css = CompiledCoreCss(); + + var aliasing = Regex.Match(css, Regex.Escape(":root,:where(:root [bit-theme]),:where(:root [bit-theme-overlay~=motion]){") + "[^}]*"); + var forced = Regex.Match(css, @"(?--bit-mot-[^}]*)\}\}"); + + Assert.IsTrue(query.Success, "The reduced-motion query of the motion tokens is no longer a :root rule of its own."); + CollectionAssert.AreEquivalent(Aliases.Select(a => a.Reduced).ToArray(), + Regex.Matches(query.Groups["body"].Value, @"(--bit-[a-z-]+):").Select(m => m.Groups[1].Value).ToArray()); + StringAssert.Contains(query.Groups["body"].Value, "--bit-mot-duration-reduced: 0.01ms"); + StringAssert.Contains(query.Groups["body"].Value, "--bit-mot-duration-spinner-reduced: 4s"); + StringAssert.Contains(query.Groups["body"].Value, "--bit-mot-easing-spinner-reduced: linear"); + StringAssert.Contains(query.Groups["body"].Value, "--bit-mot-loop-factor-reduced: 3"); + + Assert.IsTrue(Regex.Match(css, @"(? query.Index, ".bit-fam must come after the query to win on the root element."); + } + + [TestMethod] + public void OnlyTheMotionTierDeclaresTheReduction() + { + // The -reduced tokens are set only while the reduction is in force; a preset or a component declaring one would + // reduce, or un-reduce, motion regardless of the preference. + var declared = new[] { "Bit.BlazorUI", "Bit.BlazorUI.Extras" } + .SelectMany(project => Directory.EnumerateFiles(SourceFiles.GetDirectory(project), "*.scss", SearchOption.AllDirectories)) + .Where(file => file.Contains($"{Path.DirectorySeparatorChar}node_modules{Path.DirectorySeparatorChar}", StringComparison.Ordinal) is false) + .Where(file => Path.GetFileName(file) != "motion.fluent.scss") + .SelectMany(file => Regex.Matches(SourceFiles.StripScssComments(SourceFiles.ReadFullPath(file)), @"--bit-mot-[a-z-]+-reduced\s*:") + .Select(m => $"{Path.GetFileName(file)}: {m.Value}")) + .ToArray(); + + CollectionAssert.AreEqual(Array.Empty(), declared, $"Declared outside motion.fluent.scss: {string.Join(", ", declared)}"); + } + + private static readonly (string Plain, string Reduced, string Full)[] Aliases = + [ + .. new[] { "duration", "duration-short", "duration-long", "duration-factor", "duration-spinner", "easing-spinner", "loop-factor" } + .Select(n => ($"--bit-mot-{n}", $"--bit-mot-{n}-reduced", $"--bit-mot-{n}-full")) + ]; + + private static string CompiledCoreCss() + => Regex.Replace(SourceFiles.Read("Bit.BlazorUI", "wwwroot", "styles", "bit.blazorui.css"), @"\s*([{};,])\s*", "$1").Replace("\"", string.Empty); + + private static IEnumerable EnumerateComponentStylesheets() + => new[] { "Bit.BlazorUI", "Bit.BlazorUI.Extras" } + .SelectMany(project => Directory.EnumerateFiles(SourceFiles.GetDirectory(project, "Components"), "*.scss", SearchOption.AllDirectories)); +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitReducedMotionDurationContractTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitReducedMotionDurationContractTests.cs index c2c0b90aca9..d8ed4b451ce 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitReducedMotionDurationContractTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitReducedMotionDurationContractTests.cs @@ -200,11 +200,9 @@ public void TheFactorCollapsesUnderReducedMotionAndComesBackWithTheTokens() var motion = SourceFiles.StripScssComments(SourceFiles.ReadThemeStylesheet("Fluent", "motion.fluent.scss")).Replace("\r\n", "\n"); StringAssert.Contains(motion, "--bit-mot-duration-factor-full: 1;"); - StringAssert.Contains(motion, "--bit-mot-duration-factor: var(--bit-mot-duration-factor-full);"); - StringAssert.Contains(SourceFiles.GetScssBlock(motion, "@media (prefers-reduced-motion: reduce) {"), "--bit-mot-duration-factor: 0;"); - - var general = SourceFiles.StripScssComments(SourceFiles.ReadThemeStylesheet("general.scss")); - StringAssert.Contains(SourceFiles.GetScssBlock(general, ".bit-fam {"), "--bit-mot-duration-factor: var(--bit-mot-duration-factor-full, 1);"); + StringAssert.Contains(motion, "--bit-mot-duration-factor: var(--bit-mot-duration-factor-reduced, var(--bit-mot-duration-factor-full));"); + StringAssert.Contains(SourceFiles.GetScssBlock(motion, "@media (prefers-reduced-motion: reduce) {"), "--bit-mot-duration-factor-reduced: 0;"); + StringAssert.Contains(SourceFiles.GetScssBlock(motion, ".bit-fam {"), "--bit-mot-duration-factor-reduced: initial;"); var variables = SourceFiles.ReadThemeStylesheet("theme-variables.scss"); StringAssert.Contains(variables, "$mot-duration-factor: var(--bit-mot-duration-factor, 1);"); diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs index 427d5327de6..8b523516a03 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeFamilyAliasReSubstitutionTests.cs @@ -54,6 +54,8 @@ private static Dictionary BuildGroupAliases() ["tooltip"] = ["--bit-clr-tooltip-bg", "--bit-clr-tooltip-fg"], ["foreground"] = [.. Roles.Select(r => $"--bit-clr-{r}-fg")], ["typography"] = ["--bit-tpg-dialog-title-font-size", "--bit-tpg-dialog-title-font-weight", "--bit-tpg-field-label-font-weight"], + ["motion"] = ["--bit-mot-duration", "--bit-mot-duration-short", "--bit-mot-duration-long", "--bit-mot-duration-factor", + "--bit-mot-duration-spinner", "--bit-mot-easing-spinner", "--bit-mot-loop-factor"], }; foreach (var role in Roles) @@ -282,13 +284,42 @@ public void TheCoreStylesheetReDeclaresExactlyTheGroupedAliasesForEachGroup() foreach (var (group, aliases) in GroupAliases) { - var rule = rules.FirstOrDefault(r => r.Selectors.Contains($":root [bit-theme-overlay~={group}]", StringComparer.Ordinal)); + // The motion aliases are built alike by every theme, so their one rule re-declares them at no weight. + var rule = rules.FirstOrDefault(r => r.Selectors.Contains($":root [bit-theme-overlay~={group}]", StringComparer.Ordinal) || + r.Selectors.Contains($":where(:root [bit-theme-overlay~={group}])", StringComparer.Ordinal)); Assert.IsNotNull(rule, $"The core stylesheet has no overlay rule for the {group} group."); CollectionAssert.AreEquivalent(aliases, rule.Declarations.Select(d => d.Key).ToArray(), $"The core overlay rule for the {group} group."); } } + [TestMethod] + public void NoThemeDeclaresTheMotionAliasesOutsideTheRuleItsOverlaysShare() + { + // The motion group has no per-theme overlay rule for EveryThemeReDeclaresItsOwnFamilyAliasesForAnOverlay to pin: + // every theme builds the aliases alike, from the -full tokens a preset re-values, so the one core rule declaring + // them on :root, on every scoped region and on every motion overlay is all of them. That holds only while no + // theme declares an alias of its own: one that did would be the core recipe again under an overlay. + string[] shared = [":root", ":where(:root [bit-theme])", ":where(:root [bit-theme-overlay~=motion])"]; + var checkedRules = 0; + + foreach (var (bundle, rules) in Bundles()) + { + foreach (var rule in rules.Where(r => r.Declarations.Any(d => GroupAliases["motion"].Contains(d.Key)))) + { + // .bit-fam (ForceAnimation) substitutes them again too, from the same mixin, for the subtree it opts out. + if (rule.Selectors is [".bit-fam"]) continue; + + CollectionAssert.AreEqual(shared, rule.Selectors, + $"{bundle}: '{string.Join(", ", rule.Selectors)}' declares a motion alias; a theme re-values the -full tokens instead."); + CollectionAssert.AreEquivalent(GroupAliases["motion"], rule.Declarations.Select(d => d.Key).ToArray(), $"{bundle}: the motion alias rule."); + checkedRules++; + } + } + + Assert.AreEqual(1, checkedRules, "The motion aliases are declared by one rule, in the core stylesheet."); + } + [TestMethod] public void NoThemeReDeclaresAnAliasOutsideItsGroupForAnOverlay() { @@ -365,6 +396,10 @@ public void AnOverlayNamesTheGroupsItsVariablesFeed() ("the medium weight", t => t.Typography.FontWeights.Medium = "550", "typography"), ("the semibold weight", t => t.Typography.FontWeights.SemiBold = "700", "typography"), ("the bold weight", t => t.Typography.FontWeights.Bold = "800", "typography"), + ("the duration", t => t.Motion.Duration = "250ms", "motion"), + ("the duration factor", t => t.Motion.DurationFactor = "0.5", "motion"), + ("the spinner easing", t => t.Motion.EasingSpinner = "linear", "motion"), + ("the loop factor", t => t.Motion.LoopFactor = "2", "motion"), }; foreach (var (what, set, expected) in cases) @@ -394,6 +429,7 @@ public void AnOverlayOfEveryInputNamesEveryGroupInTableOrder() theme.Color.SevereWarning.Main = "#CE4207"; theme.Color.Error.Main = "#D2393B"; theme.Typography.FontWeights.SemiBold = "700"; + theme.Motion.DurationLong = "400ms"; Assert.AreEqual(string.Join(' ', OverlayGroupNames()), RenderOverlay(theme)); } @@ -406,6 +442,7 @@ public void AnOverlayOfNoFamilyInputCarriesNoMarker() theme.Typography.FontWeight = "500"; theme.Typography.LineHeight = "1.5rem"; theme.Shape.Radius.Surface = "2rem"; + theme.Motion.EasingStandard = "linear"; Assert.IsNull(RenderOverlay(theme)); } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs index 4ed4cc9b397..476362e3cd5 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeMapperContractTests.cs @@ -26,10 +26,45 @@ public void ThemeVariablesReferencedTokensAreEmittedByMapperWhenSet() var mapped = BitThemeUtilities.ToCssVariables(theme); - var missing = expectedKeys.Where(k => !mapped.ContainsKey(k)).ToArray(); + // A plain motion token is an alias of its unreduced -full token, which the reduced-motion query collapses and + // ForceAnimation restores, so BitTheme sets it through the -full one it aliases and never itself. + var missing = expectedKeys.Where(k => !mapped.ContainsKey(k) && !(k.StartsWith("--bit-mot-", StringComparison.Ordinal) && mapped.ContainsKey($"{k}-full"))).ToArray(); CollectionAssert.AreEqual(Array.Empty(), missing, $"Mapper missing keys referenced in theme-variables.scss: {string.Join(", ", missing)}"); } + [TestMethod] + public void ThemeMotionIsWrittenToTheUnreducedTokens() + { + // Written inline, a plain duration would outrank the prefers-reduced-motion query that collapses it, and inside + // a ForceAnimation subtree .bit-fam would restore it to the -full token the theme never set. The mapper writes + // the -full tokens, as a preset does, and leaves the plain aliases to the stylesheets. + var theme = new BitTheme(); + theme.Motion.Duration = "250ms"; + theme.Motion.DurationShort = "120ms"; + theme.Motion.DurationLong = "400ms"; + theme.Motion.DurationFactor = "0.5"; + theme.Motion.DurationSpinner = "2s"; + theme.Motion.EasingSpinner = "linear"; + theme.Motion.LoopFactor = "2"; + + var mapped = BitThemeUtilities.ToCssVariables(theme); + + Assert.AreEqual("250ms", mapped[BitCss.Var.Motion.DurationFull]); + Assert.AreEqual("120ms", mapped[BitCss.Var.Motion.DurationShortFull]); + Assert.AreEqual("400ms", mapped[BitCss.Var.Motion.DurationLongFull]); + Assert.AreEqual("0.5", mapped[BitCss.Var.Motion.DurationFactorFull]); + Assert.AreEqual("2s", mapped[BitCss.Var.Motion.DurationSpinnerFull]); + Assert.AreEqual("linear", mapped[BitCss.Var.Motion.EasingSpinnerFull]); + Assert.AreEqual("2", mapped[BitCss.Var.Motion.LoopFactorFull]); + + foreach (var plain in new[] { BitCss.Var.Motion.Duration, BitCss.Var.Motion.DurationShort, BitCss.Var.Motion.DurationLong, + BitCss.Var.Motion.DurationFactor, + BitCss.Var.Motion.DurationSpinner, BitCss.Var.Motion.EasingSpinner, BitCss.Var.Motion.LoopFactor }) + { + Assert.IsFalse(mapped.ContainsKey(plain), $"{plain} is an alias the reduced-motion query collapses; the theme must not set it."); + } + } + [TestMethod] public void MapperEmittedTokensAreConsumedByShippedStyles() { diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeScopedPresetTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeScopedPresetTests.cs index ecfcf3a1984..f2dd763a82b 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeScopedPresetTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Utils/Theme/BitThemeScopedPresetTests.cs @@ -173,20 +173,20 @@ public void MotionAliasesAreReDeclaredOnScopedPresetElements() // The plain --bit-mot-* tokens alias the -full ones, and a preset retunes only the -full // literals. Declared on :root alone the aliases would freeze the DOCUMENT's durations inside // an element carrying bit-theme="material", whose own -full tokens say something else. The - // twin is wrapped in :where() so it adds no specificity: the reduced-motion query must still - // win on source order, and .bit-fam (the ForceAnimation opt-out) must still outrank both. + // twin is wrapped in :where() so it adds no specificity, and it needs no reduced-motion twin: + // the reduction is the inherited -reduced tokens, which the twin reads off its ancestors, so a + // scoped region inside a ForceAnimation subtree stays unreduced and one outside it is reduced. var scss = ReadStylesFile(Path.Combine("Fluent", "motion.fluent.scss")); var twins = Regex.Matches(scss, @":where\(:root \[bit-theme\]\)").Count; - Assert.AreEqual(2, twins, - "motion.fluent.scss must carry the ':where(:root [bit-theme])' scoped twin twice - once on the " + - "block that aliases the -full tokens, and once inside the prefers-reduced-motion query so the " + - "collapse reaches a scoped preset region as well."); + Assert.AreEqual(1, twins, + "motion.fluent.scss must carry the ':where(:root [bit-theme])' scoped twin once, on the block that " + + "aliases the -full tokens."); var reducedMotion = SourceFiles.GetScssBlock(scss, "@media (prefers-reduced-motion: reduce) {"); - Assert.IsTrue(reducedMotion.Contains(":where(:root [bit-theme])", StringComparison.Ordinal), - "The prefers-reduced-motion block must carry the scoped twin too, or a scoped preset region " + - "would keep its unreduced durations."); + Assert.IsFalse(reducedMotion.Contains("[bit-theme]", StringComparison.Ordinal), + "The prefers-reduced-motion block declares the reduction on :root alone; a scoped region reads it by " + + "inheritance, and a twin of its own there would collapse a region inside a ForceAnimation subtree."); } [TestMethod] diff --git a/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitReducedMotionBrowserTests.cs b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitReducedMotionBrowserTests.cs index 7be4449c0a9..51808cdd151 100644 --- a/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitReducedMotionBrowserTests.cs +++ b/src/BlazorUI/Tests/Performance/Bit.BlazorUI.Tests.Performance/BitReducedMotionBrowserTests.cs @@ -31,10 +31,10 @@ public class BitReducedMotionBrowserTests : PerformanceTestBase private const double Collapsed = 1; private const string RestoreTokensAtRoot = @":root { - --bit-mot-duration: var(--bit-mot-duration-full, 200ms); - --bit-mot-duration-short: var(--bit-mot-duration-short-full, 100ms); - --bit-mot-duration-long: var(--bit-mot-duration-long-full, 300ms); - --bit-mot-duration-factor: var(--bit-mot-duration-factor-full, 1); + --bit-mot-duration-reduced: initial; + --bit-mot-duration-short-reduced: initial; + --bit-mot-duration-long-reduced: initial; + --bit-mot-duration-factor-reduced: initial; }"; private static readonly string[] Probes =