From c135ac1b4994d3120f8108da8fc268d61be11385 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 04:10:15 +0400 Subject: [PATCH 1/6] Ship both colour modes in every fluent-next bundle, selectable by class Each bundle now carries the opposite mode's roles as well as its own, under dx-theme-mode-light / -dark / -inverted. The role layer is generated as a mixin because one bundle needs it under three different selectors and a :root block cannot be re-scoped on load. The overlay container helper reads the mode prefix alongside the swatch one, carries every class it finds rather than the first, and resolves the relative class against the nearest named scope - the container hangs off the viewport, so a relative class on it would be read against the wrong element. --- .../build/tokens/build-tokens.mjs | 31 +++- .../widgets/fluent-next/_design-system.scss | 50 +++++- .../utils/__tests__/swatch_container.test.ts | 157 ++++++++++++++++++ .../__internal/core/utils/swatch_container.ts | 75 ++++++++- 4 files changed, 302 insertions(+), 11 deletions(-) create mode 100644 packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index 750f5b0a4d45..7ca19ef659ad 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -3,6 +3,7 @@ import url from 'node:url'; import { createRequire } from 'node:module'; import { readdir, readFile, rm } from 'node:fs/promises'; import StyleDictionary from 'style-dictionary'; +import { fileHeader, formattedVariables } from 'style-dictionary/utils'; import { registerTransforms } from './transforms.mjs'; import { buildAvailableNames, @@ -175,6 +176,9 @@ const buildPath = `${path.resolve(dirname, '../../scss/_design-system')}/`; const THEME_NAME = 'fluent'; const THEME_FOLDER = 'fluent-next'; +// Kept in step with the @include in widgets/fluent-next/_design-system.scss. +const MODE_ROLES_MIXIN = 'roles'; + const themePath = path.resolve(dirname, `../../scss/widgets/${THEME_FOLDER}`); const FLUENT_PALETTES = [ @@ -231,6 +235,31 @@ const getModeFiles = (mode) => [ // properties. Absent from the bridge, `ds.$button-color-bg-rest` is now a Sass error. const getBridgeFiles = () => getModeFiles('light'); +// The mode role layer is the one generated file every bundle needs twice: once for the mode it was +// built for and once for the opposite one, under the mode classes. A `:root` block cannot be +// re-scoped on load — `meta.load-css` emits it verbatim and `@use` paths take no interpolation — so +// the roles ship as a mixin the theme places under the selectors it wants. +StyleDictionary.registerFormat({ + name: 'dx/mode-roles-mixin', + format: async ({ dictionary, file, options }) => { + const { + outputReferences, outputReferenceFallbacks, usesDtcg, formatting, sort, + } = options; + const header = await fileHeader({ file, formatting, options }); + const variables = formattedVariables({ + format: 'css', + dictionary, + outputReferences, + outputReferenceFallbacks, + formatting: { ...formatting, indentation: ' ' }, + usesDtcg, + sort, + }); + + return `${header}@mixin ${MODE_ROLES_MIXIN}() {\n${variables}\n}\n`; + }, +}); + StyleDictionary.registerFormat({ name: 'scssToCss', format: ({ dictionary }) => dictionary.allTokens @@ -338,7 +367,7 @@ const createModeConfig = (mode) => createConfig(mode, getModeFiles(mode), [ }, { destination: `${THEME_NAME}/semantic/colors/${mode}.scss`, - format: 'css/variables', + format: 'dx/mode-roles-mixin', filter: (token) => { const filePath = normalizeFilePath(token); diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss index e255d380ae46..358640150a23 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss @@ -1,5 +1,7 @@ @use "sass:meta"; @use "colors"; +@use "../../_design-system/fluent/semantic/colors/light" as light-roles; +@use "../../_design-system/fluent/semantic/colors/dark" as dark-roles; $accent: colors.$color; @@ -17,4 +19,50 @@ $accent: colors.$color; @include meta.load-css("../../_design-system/fluent/accents/#{$accent}"); @include meta.load-css("../../_design-system/fluent/semantic/typography"); @include meta.load-css("../../_design-system/fluent/semantic/box-shadow"); -@include meta.load-css("../../_design-system/fluent/semantic/colors/#{colors.$mode}"); + +/* + * The colour roles are the only mode-dependent tier, so both modes ship in every bundle and a class + * picks between them: `dx-theme-mode-light` / `-dark` name a mode outright, `dx-theme-mode-inverted` + * asks for the opposite of its surroundings. Everything downstream reads the roles through custom + * properties, so any element carrying one of these classes repaints itself and its subtree. + * + * Selector weight is one class throughout, `:root` included, so an override still wins by coming + * after the theme - the rule that held before the classes existed. The third block is what makes + * "inverted" relative: without it an island would keep inverting the bundle rather than the page + * whenever the page names its mode by class. `:where()` keeps that block at the same one-class + * weight as the rest. + * + * "Inverted" is not recursive: an inverted island inside an inverted island stays inverted rather + * than flipping back. Name the mode outright for the inner one. + */ +@if colors.$mode == "light" { + :root, + .dx-theme-mode-light { + @include light-roles.roles(); + } + + .dx-theme-mode-dark, + .dx-theme-mode-inverted { + @include dark-roles.roles(); + } + + :where(.dx-theme-mode-dark) .dx-theme-mode-inverted { + @include light-roles.roles(); + } +} @else if colors.$mode == "dark" { + :root, + .dx-theme-mode-dark { + @include dark-roles.roles(); + } + + .dx-theme-mode-light, + .dx-theme-mode-inverted { + @include light-roles.roles(); + } + + :where(.dx-theme-mode-light) .dx-theme-mode-inverted { + @include dark-roles.roles(); + } +} @else { + @error "fluent-next: unknown colour mode #{meta.inspect(colors.$mode)}; expected \"light\" or \"dark\"."; +} diff --git a/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts b/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts new file mode 100644 index 000000000000..226e6fa23e34 --- /dev/null +++ b/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts @@ -0,0 +1,157 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import { value as viewPort } from '@js/core/utils/view_port'; +import swatchContainer from '@ts/core/utils/swatch_container'; + +const { getSwatchContainer } = swatchContainer; + +const classesOf = (element: Element | undefined): string[] => [...(element?.classList ?? [])] + .sort(); + +describe('getSwatchContainer', () => { + let $viewport = document.createElement('div'); + + const render = (markup: string): HTMLElement => { + const host = document.createElement('div'); + + host.innerHTML = markup; + document.body.appendChild(host); + + return host.querySelector('.target') as HTMLElement; + }; + + const containerFor = (markup: string): Element => getSwatchContainer(render(markup)).get(0); + + beforeEach(() => { + $viewport = document.createElement('div'); + $viewport.className = 'dx-viewport'; + document.body.appendChild($viewport); + viewPort($viewport); + }); + + afterEach(() => { + document.body.innerHTML = ''; + viewPort(undefined); + }); + + it('returns the viewport itself when the element is in no swatch and in no named mode', () => { + expect(containerFor('
')).toBe($viewport); + }); + + it('creates a container in the viewport for a swatch', () => { + const container = containerFor('
'); + + expect(classesOf(container)).toEqual(['dx-swatch-custom']); + expect(container.parentElement).toBe($viewport); + }); + + it('reads the classes off the element itself', () => { + expect(classesOf(containerFor('
'))).toEqual(['dx-swatch-custom']); + }); + + it('carries every swatch class, not just the first', () => { + const container = containerFor('
'); + + expect(classesOf(container)).toEqual(['dx-swatch-a', 'dx-swatch-b']); + }); + + it('carries a named theme mode', () => { + const container = containerFor('
'); + + expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); + expect(container.parentElement).toBe($viewport); + }); + + it('carries a swatch and a theme mode declared on different ancestors', () => { + const container = containerFor(` +
+
+
`); + + expect(classesOf(container)).toEqual(['dx-swatch-custom', 'dx-theme-mode-dark']); + }); + + it('takes the nearest declaration of each kind', () => { + const container = containerFor(` +
+
+
+
+
`); + + expect(classesOf(container)).toEqual(['dx-swatch-inner', 'dx-theme-mode-dark']); + }); + + it('reuses one container for elements in the same swatch and mode', () => { + const markup = '
'; + + expect(containerFor(markup)).toBe(containerFor(markup)); + expect($viewport.children).toHaveLength(1); + }); + + it('does not reuse a container that carries classes the element is not in', () => { + const inBoth = containerFor('
'); + const inSwatch = containerFor('
'); + + expect(inSwatch).not.toBe(inBoth); + expect(classesOf(inSwatch)).toEqual(['dx-swatch-custom']); + }); + + describe('inverted mode', () => { + it('is carried as is when no named mode surrounds it', () => { + const container = containerFor('
'); + + expect(classesOf(container)).toEqual(['dx-theme-mode-inverted']); + }); + + it('resolves to light inside a dark scope', () => { + const container = containerFor(` +
+
+
`); + + expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + }); + + it('resolves to dark inside a light scope', () => { + const container = containerFor(` +
+
+
`); + + expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); + }); + + it('resolves against the nearest named scope, not the outermost', () => { + const container = containerFor(` +
+
+
+
+
`); + + expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + }); + + it('does not invert again when nested in another inverted block', () => { + const container = containerFor(` +
+
+
`); + + expect(classesOf(container)).toEqual(['dx-theme-mode-inverted']); + }); + + it('resolves nested inverted blocks against the named scope around them', () => { + const container = containerFor(` +
+
+
+
+
`); + + expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/core/utils/swatch_container.ts b/packages/devextreme/js/__internal/core/utils/swatch_container.ts index c426d0b9050d..18baa4ddedb7 100644 --- a/packages/devextreme/js/__internal/core/utils/swatch_container.ts +++ b/packages/devextreme/js/__internal/core/utils/swatch_container.ts @@ -3,27 +3,84 @@ import $ from '@js/core/renderer'; import { value } from '@js/core/utils/view_port'; const SWATCH_CONTAINER_CLASS_PREFIX = 'dx-swatch-'; +const THEME_MODE_CLASS_PREFIX = 'dx-theme-mode-'; + +const LIGHT_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}light`; +const DARK_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}dark`; +const INVERTED_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}inverted`; + +const closestByClassPrefix = ( + $element: dxElementWrapper, + prefix: string, +): dxElementWrapper => $element.closest(`[class^="${prefix}"], [class*=" ${prefix}"]`); + +const classesByPrefix = ( + element: Element, + prefix: string, +): string[] => [...element.classList].filter((cssClass) => cssClass.startsWith(prefix)); + +const getThemeModeClasses = ($element: dxElementWrapper): string[] => { + const $scope = closestByClassPrefix($element, THEME_MODE_CLASS_PREFIX); + + if (!$scope.length) { + return []; + } + + const classes = classesByPrefix($scope[0], THEME_MODE_CLASS_PREFIX); + + if (!classes.includes(INVERTED_THEME_MODE_CLASS)) { + return classes; + } + + // The container hangs off the viewport, so "the opposite of my surroundings" would be read + // against the viewport rather than against the element the overlay belongs to. Name the mode the + // element resolves to instead. Without a named mode above it that is the mode the stylesheet + // falls back to, which the container inherits too, so the relative class carries over as is. + const $named = $scope.parent().closest(`.${LIGHT_THEME_MODE_CLASS}, .${DARK_THEME_MODE_CLASS}`); + + if (!$named.length) { + return classes; + } + + return [ + $named[0].classList.contains(DARK_THEME_MODE_CLASS) + ? LIGHT_THEME_MODE_CLASS + : DARK_THEME_MODE_CLASS, + ]; +}; + +const getContainerClasses = ($element: dxElementWrapper): string[] => { + const $swatch = closestByClassPrefix($element, SWATCH_CONTAINER_CLASS_PREFIX); + const swatchClasses = $swatch.length + ? classesByPrefix($swatch[0], SWATCH_CONTAINER_CLASS_PREFIX) + : []; + + return [...swatchClasses, ...getThemeModeClasses($element)]; +}; const getSwatchContainer = ( element: Element | dxElementWrapper, ): dxElementWrapper => { - const $element = $(element); - const swatchContainer = $element.closest(`[class^="${SWATCH_CONTAINER_CLASS_PREFIX}"], [class*=" ${SWATCH_CONTAINER_CLASS_PREFIX}"]`); + const containerClasses = getContainerClasses($(element)); const viewport: dxElementWrapper = value(); - if (!swatchContainer.length) { + if (!containerClasses.length) { return viewport; } - const swatchClassRegex = new RegExp(`(\\s|^)(${SWATCH_CONTAINER_CLASS_PREFIX}.*?)(\\s|$)`); - const swatchClass = swatchContainer[0].className.match(swatchClassRegex)[2]; - let viewportSwatchContainer = viewport.children(`.${swatchClass}`); + const selector = containerClasses.map((cssClass) => `.${cssClass}`).join(''); + // A container carrying more classes than asked for would hand the overlay a swatch or a mode the + // element itself is not in. + let viewportContainer = $(viewport + .children(selector) + .toArray() + .filter((node) => node.classList.length === containerClasses.length)); - if (!viewportSwatchContainer.length) { - viewportSwatchContainer = $('
').addClass(swatchClass).appendTo(viewport); + if (!viewportContainer.length) { + viewportContainer = $('
').addClass(containerClasses.join(' ')).appendTo(viewport); } - return viewportSwatchContainer; + return viewportContainer; }; export default { getSwatchContainer }; From 74d13a64ad52f9764b29ca5b91fc37d143b5ee95 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 04:59:55 +0400 Subject: [PATCH 2/6] Let the system tier follow the theme mode class, and the diagram icon with it A custom property resolves where it is declared, so a :root-only alias onto a role froze at the bundle's mode and ignored a mode class further down: 12 names over 46 reads, among them the focus ring, the modal backdrop and the overlay surface. The system tier is now declared on the mode classes too - same block, same values, a second resolution point. That also settles the diagram toolbar icon, which took its colour from a literal kept for baking into data-uri images. It reads --dx-global-content now. The component tier would not do: half the rule applies inside the toolbar overflow menu, an overlay that renders outside every diagram root. --- .../scss/widgets/fluent-next/_public-tier.scss | 10 +++++++++- .../scss/widgets/fluent-next/diagram/_index.scss | 2 +- .../tools/naming/derive-registries.mjs | 16 +++++++++++++--- .../devextreme-scss/tools/naming/registries.json | 10 ++++++++-- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss index 5097a05b0fc7..207a5ac25719 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss @@ -76,7 +76,15 @@ @use "validation/public" as validationPublic; @use "widget/public" as widgetPublic; -:root { +/* + * The system tier is declared on the document root and on every element that names a theme mode. + * A custom property resolves where it is declared, so a `:root`-only alias onto a role would freeze + * at the bundle's mode and ignore a mode class further down (see _design-system.scss). + */ +:root, +.dx-theme-mode-light, +.dx-theme-mode-dark, +.dx-theme-mode-inverted { @include commonPublic.publish(); @include typographyPublic.publish(); } diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss b/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss index 97ab54c09143..735ce95511dc 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss @@ -431,7 +431,7 @@ .dx-icon { font-size: $diagram-toolbar-icon-size; - color: $diagram-content; + color: var(--dx-global-content); } } } diff --git a/packages/devextreme-scss/tools/naming/derive-registries.mjs b/packages/devextreme-scss/tools/naming/derive-registries.mjs index 9fe6a58010c4..00d06c6a5a8b 100644 --- a/packages/devextreme-scss/tools/naming/derive-registries.mjs +++ b/packages/devextreme-scss/tools/naming/derive-registries.mjs @@ -31,6 +31,9 @@ const output = join(here, 'registries.json'); // Judgment calls. Everything else in registries.json is derived. // --------------------------------------------------------------------------------------------- +// Public contract of widgets/fluent-next/_design-system.scss: an element naming a theme mode. +const THEME_MODE_SELECTORS = ['.dx-theme-mode-light', '.dx-theme-mode-dark', '.dx-theme-mode-inverted']; + const OVERRIDES = { // folder -> component, only where kebab(folder) is not the component name components: { @@ -196,8 +199,15 @@ const OVERRIDES = { * that component's consumption wave lands. */ rootSelectors: { - // system tier: theme-wide values (system concerns of common/) live on the document root - common: [':root'], + /* + * System tier: theme-wide values live on the document root — plus every element that names a + * theme mode. A custom property is resolved where it is DECLARED, so a `:root`-only alias onto + * a role (`--dx-global-content: var(--dxds-color-content)`) freezes at the bundle's mode and + * ignores a mode class further down. Re-declaring the same text on the mode classes makes it + * resolve again against the roles that class carries. The component tier needs no such entry: + * its roots sit inside the mode scope, so they already re-resolve. + */ + common: [':root', ...THEME_MODE_SELECTORS], /* * The drop-down editor's inner button is a dxButton whose root carries dx-button-normal + * dx-dropdowneditor-button but NOT dx-button (found by the F12 runtime reachability audit: @@ -367,7 +377,7 @@ const OVERRIDES = { // the type scale is cross-component (chat, stepper and toolbar read it), so it lives on // :root like icon — the surface class .dx-theme-fluent-next-typography is opt-in and would // leave the borrowers outside the values they read - typography: [':root'], + typography: [':root', ...THEME_MODE_SELECTORS], }, // System-tier concerns (common/). Each must map to a non-component token family. diff --git a/packages/devextreme-scss/tools/naming/registries.json b/packages/devextreme-scss/tools/naming/registries.json index a59397cca05a..483075c30686 100644 --- a/packages/devextreme-scss/tools/naming/registries.json +++ b/packages/devextreme-scss/tools/naming/registries.json @@ -349,7 +349,10 @@ ".dx-gallery" ], "typography": [ - ":root" + ":root", + ".dx-theme-mode-light", + ".dx-theme-mode-dark", + ".dx-theme-mode-inverted" ], "toolbar": [ ".dx-toolbar", @@ -563,7 +566,10 @@ ".dx-cardview-column-chooser-plain" ], "common": [ - ":root" + ":root", + ".dx-theme-mode-light", + ".dx-theme-mode-dark", + ".dx-theme-mode-inverted" ] }, "themeIdentity": [ From 210a52bd3beb5b659fd77dce59c9d168f80c8760 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 05:11:32 +0400 Subject: [PATCH 3/6] Drop two colour-scheme branches that picked the same image either way Both fluent-next PNG pairs were byte-identical, so the $mode branch produced no difference in any bundle. One copy each now lives at a path that does not claim a colour scheme, and the duplicates go. All 49 bundles are unchanged byte for byte. --- .../color-schemes/light/grid/text-stub.png | Bin 1230 -> 0 bytes .../fluent-next/color-schemes/light/pulldown.png | Bin 328 -> 0 bytes .../{color-schemes/dark => }/grid/text-stub.png | Bin .../{color-schemes/dark => }/pulldown.png | Bin .../widgets/fluent-next/gridBase/_colors.scss | 10 +--------- .../scss/widgets/fluent-next/icons/_colors.scss | 12 +----------- 6 files changed, 2 insertions(+), 20 deletions(-) delete mode 100644 packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/grid/text-stub.png delete mode 100644 packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/pulldown.png rename packages/devextreme-scss/images/widgets/fluent-next/{color-schemes/dark => }/grid/text-stub.png (100%) rename packages/devextreme-scss/images/widgets/fluent-next/{color-schemes/dark => }/pulldown.png (100%) diff --git a/packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/grid/text-stub.png b/packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/grid/text-stub.png deleted file mode 100644 index 77bf05a6864773b085005a5376d4d4f73ed5887a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1230 zcmaJ=O>7fK6dpc#4O+ez30}+TrRfISo4k+pgwQ51CR8CRV!mRCeqN?snyYpuB zec$`uytkJo#t()2KI#JigtLd$JY5a?z76%#uXFC+4|Lf<#)@PTmq=A}5s(c$g<#gw zrcoYg#@v}7(Fgz=KQ#+QQp_EZbZoJjk6~+;L)id~9IQE-K7$CHLer+5X8u@uz(CVT zGshD-KIbT?Y#y$=XtF+D(CagL%3uy2fFm`D3Rs9}ux8ELo>WUSOorNOF9V6Jk+bkoX}fmV)hvq1jxcB<0mi zI~U!hnKB`c#BtSXm955D>`rq+DwX1RkrTxzMMS+hn`pJD?F|G9D)MyKbcl&<=qqYd zxI)qlb=oSyayqiM*S;otVO&jfIDzH;k^*2Z_y17K>YzQ6M{9onQ`jraIf%<64_92B zHm)?_r*b64MH<0w0pr==7AMM>V6TiFs3>sHanm+%)!W;I=WJ(4wRF>&dt}pLcj;9By0G^f z05&LDRW8(CzFAoeKjQnxUW6eU!VviBvrqVs zlfA<8O=}!V@A>WF-9(%FZ1m0bM=x$}IdyBHclW>ePUF?a(*DQ04#kvpNqvD0ZFYU> z&r@HDCkKO^&GexLSXlXf^OOEc$8O7YVE2A`Zuz@!uZ5>xx5@~_uf2p^V1As%A0e-Kw~Cy$vmS aUGD{By6(+O*Zar-0000 Date: Wed, 2 Sep 2026 14:11:51 +0400 Subject: [PATCH 4/6] Follow the mode class wherever a value depends on the mode Declaring the system tier on the mode classes covered the names the theme's own rules read. It missed everything else that aliases a role from the document root, and those freeze the same way: 39 custom properties over five blocks. Three are hand-written and get the same selector list as the system tier: the legacy --dx-color-* contract and --dx-component-color-bg (14 names, which the theme does not read but demos and customer code do - 575 reads of --dx-color-options-panel-bg alone), --dx-texteditor-color-text / -label, and --dx-datagrid-row-alternation-bg. Two are generated, so the pipeline had to change. The box-shadow composites are geometry over color.shadow-*, whose alpha differs by mode (0.14 against 0.28), and eleven components read them through ds.$box-shadow-sm/md/lg - a dark island kept the light shadows. The figma-utils shadow layers and the global focus aliases sit in the same position. All three sources now build one mixin, fluent/mode-aliases.scss, which the theme includes in every mode scope: the text is mode-independent, only the resolution point is not. The format that emitted the role mixin serves both files and is named dx/mode-scoped-mixin. Every mode scope also names its outcome in --dx-theme-mode. No amount of class-reading tells you which mode an element ended up in, because "inverted" means "the opposite of my surroundings" - only the cascade knows, and the overlay container has to be given the mode its owner resolved to. The three scopes are one mixin over one pair of mode names now, so they cannot drift apart, and the two limits of the relative block are written down: it reads any ancestor rather than the nearest one, and it does not recurse. Cost: 11.5K raw and 0.85K gzipped per bundle. --- .../build/tokens/build-tokens.mjs | 65 ++++++++++----- .../scss/widgets/fluent-next/_colors.scss | 5 +- .../widgets/fluent-next/_design-system.scss | 82 +++++++++++++------ .../widgets/fluent-next/gridBase/_colors.scss | 10 ++- .../fluent-next/textEditor/_colors.scss | 10 ++- .../tests/fluent-next-naming.baseline.json | 5 +- 6 files changed, 127 insertions(+), 50 deletions(-) diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index 7ca19ef659ad..49f5daf3edc1 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -176,8 +176,9 @@ const buildPath = `${path.resolve(dirname, '../../scss/_design-system')}/`; const THEME_NAME = 'fluent'; const THEME_FOLDER = 'fluent-next'; -// Kept in step with the @include in widgets/fluent-next/_design-system.scss. +// Kept in step with the @includes in widgets/fluent-next/_design-system.scss. const MODE_ROLES_MIXIN = 'roles'; +const MODE_ALIASES_MIXIN = 'aliases'; const themePath = path.resolve(dirname, `../../scss/widgets/${THEME_FOLDER}`); @@ -235,17 +236,32 @@ const getModeFiles = (mode) => [ // properties. Absent from the bridge, `ds.$button-color-bg-rest` is now a Sass error. const getBridgeFiles = () => getModeFiles('light'); -// The mode role layer is the one generated file every bundle needs twice: once for the mode it was -// built for and once for the opposite one, under the mode classes. A `:root` block cannot be -// re-scoped on load — `meta.load-css` emits it verbatim and `@use` paths take no interpolation — so -// the roles ship as a mixin the theme places under the selectors it wants. +/* + * Every bundle needs the mode-dependent declarations more than once: under the mode it was built + * for, under the opposite one, and under the relative "inverted" scope. A `:root` block cannot be + * re-scoped on load — `meta.load-css` emits it verbatim and `@use` paths take no interpolation — so + * these layers ship as mixins the theme places under the selectors it wants. + * + * Two files use it. The roles carry the mode's own values, one file per mode. The aliases carry the + * layers whose TEXT is mode-independent but whose values read a role (`box-shadow.md` is geometry + * over `color.shadow-key`): a custom property resolves where it is declared, so leaving them on + * `:root` would freeze them at the bundle's mode no matter what class sits below. Same text in + * every scope, resolved anew in each. + * + * Otherwise identical to Style Dictionary's own `css/variables` (lib/common/formats.js) minus the + * selector nesting; keep the two in step. + */ +// `prefix` belongs to the declaration lines, not to the header comment — upstream drops it before +// building the header (getFormattingCloneWithoutPrefix), and so must we. +const headerFormatting = ({ prefix, ...formatting } = {}) => formatting; + StyleDictionary.registerFormat({ - name: 'dx/mode-roles-mixin', + name: 'dx/mode-scoped-mixin', format: async ({ dictionary, file, options }) => { const { - outputReferences, outputReferenceFallbacks, usesDtcg, formatting, sort, + outputReferences, outputReferenceFallbacks, usesDtcg, formatting, sort, mixin, } = options; - const header = await fileHeader({ file, formatting, options }); + const header = await fileHeader({ file, formatting: headerFormatting(formatting), options }); const variables = formattedVariables({ format: 'css', dictionary, @@ -256,7 +272,7 @@ StyleDictionary.registerFormat({ sort, }); - return `${header}@mixin ${MODE_ROLES_MIXIN}() {\n${variables}\n}\n`; + return `${header}@mixin ${mixin}() {\n${variables}\n}\n`; }, }); @@ -344,8 +360,6 @@ const createModeConfig = (mode) => createConfig(mode, getModeFiles(mode), [ const filePath = normalizeFilePath(token); return filePath.includes(`base/colors/utility/${THEME_NAME}.json`) - || filePath.includes(`global/${THEME_NAME}.json`) - || filePath.includes(`figma-utils/box-shadow/semantic/${THEME_NAME}.json`) || filePath.includes(`figma-utils/icon/set/${THEME_NAME}.json`); }, options: FILE_OPTIONS, @@ -359,22 +373,35 @@ const createModeConfig = (mode) => createConfig(mode, getModeFiles(mode), [ filter: (token) => normalizeFilePath(token).includes(`semantic/typography/${THEME_NAME}`), options: FILE_OPTIONS, }, - { - destination: `${THEME_NAME}/semantic/box-shadow.scss`, - format: 'css/variables', - filter: (token) => normalizeFilePath(token).includes(`semantic/box-shadow/${THEME_NAME}.json`), - options: FILE_OPTIONS, - }, { destination: `${THEME_NAME}/semantic/colors/${mode}.scss`, - format: 'dx/mode-roles-mixin', + format: 'dx/mode-scoped-mixin', filter: (token) => { const filePath = normalizeFilePath(token); return filePath.includes(`semantic/colors/${THEME_NAME}/${mode}.json`) || filePath.includes(`icons/${THEME_NAME}/${mode}.json`); }, - options: FILE_OPTIONS, + options: { ...FILE_OPTIONS, mixin: MODE_ROLES_MIXIN }, + }, + /* + * The three layers that read a colour role without being one: the box-shadow composites and + * their Figma layer parts (geometry over `color.shadow-*`) and the global aliases (focus rings + * over `color.border-focus*`). Written once, included in every mode scope — see the + * dx/mode-scoped-mixin comment for why they cannot stay on `:root`. Both mode configs emit this + * file; the sources are mode-independent, so the two writes are byte-identical. + */ + { + destination: `${THEME_NAME}/mode-aliases.scss`, + format: 'dx/mode-scoped-mixin', + filter: (token) => { + const filePath = normalizeFilePath(token); + + return filePath.includes(`semantic/box-shadow/${THEME_NAME}.json`) + || filePath.includes(`global/${THEME_NAME}.json`) + || filePath.includes(`figma-utils/box-shadow/semantic/${THEME_NAME}.json`); + }, + options: { ...FILE_OPTIONS, mixin: MODE_ALIASES_MIXIN }, }, ]); diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss index 80a4dfc34aa4..69e97ca69776 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_colors.scss @@ -22,7 +22,10 @@ $theme-marker-mode: null !default; * --dx-color-shadow carries alpha (the DS ships no solid-black token; the shadow roles are * rgba over black) — unlike the legacy solid #000 of the other themes. */ -:root { +:root, +.dx-theme-mode-light, +.dx-theme-mode-dark, +.dx-theme-mode-inverted { --dx-component-color-bg: #{ds.$color-bg}; --dx-color-main-bg: #{ds.$color-bg-canvas}; --dx-color-primary: #{ds.$color-content-primary}; diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss index 358640150a23..65e648d24b67 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_design-system.scss @@ -2,6 +2,7 @@ @use "colors"; @use "../../_design-system/fluent/semantic/colors/light" as light-roles; @use "../../_design-system/fluent/semantic/colors/dark" as dark-roles; +@use "../../_design-system/fluent/mode-aliases" as mode-aliases; $accent: colors.$color; @@ -13,18 +14,44 @@ $accent: colors.$color; * to every stylesheet. Component size tokens are absent for the same reason plus one more: * fluent-next maps sizes onto the base scales (spacing, font-size, border-radius, border-width), * so no widget would read the layout names either. + * + * What is loaded here is what does NOT depend on the colour mode. The rest goes through + * `mode-values` below, because a custom property resolves where it is DECLARED: an alias onto a + * mode-dependent role, left on `:root`, freezes at the bundle's mode and ignores every mode class + * under it. That is why `mode-aliases` exists rather than a plain `:root` box-shadow layer. */ @include meta.load-css("../../_design-system/base"); @include meta.load-css("../../_design-system/fluent/base"); @include meta.load-css("../../_design-system/fluent/accents/#{$accent}"); @include meta.load-css("../../_design-system/fluent/semantic/typography"); -@include meta.load-css("../../_design-system/fluent/semantic/box-shadow"); /* - * The colour roles are the only mode-dependent tier, so both modes ship in every bundle and a class - * picks between them: `dx-theme-mode-light` / `-dark` name a mode outright, `dx-theme-mode-inverted` - * asks for the opposite of its surroundings. Everything downstream reads the roles through custom - * properties, so any element carrying one of these classes repaints itself and its subtree. + * Everything a colour mode decides, in one place so the three scopes below cannot drift apart: + * the roles for that mode, the aliases that read them, and `--dx-theme-mode` naming the outcome. + * + * The marker is what the JS reads. `dx-theme-mode-inverted` means "the opposite of my + * surroundings", so no amount of class-reading tells you which mode an element ended up in - only + * the cascade knows. Overlays are reparented to the viewport and have to be given the mode their + * owner resolved to, so `core/utils/swatch_container.ts` asks the browser for this property + * instead of walking up the ancestor classes. + */ +@mixin mode-values($mode) { + --dx-theme-mode: #{$mode}; + + @if $mode == "light" { + @include light-roles.roles(); + } @else { + @include dark-roles.roles(); + } + + @include mode-aliases.aliases(); +} + +/* + * Both modes ship in every bundle and a class picks between them: `dx-theme-mode-light` / `-dark` + * name a mode outright, `dx-theme-mode-inverted` asks for the opposite of its surroundings. + * Everything downstream reads these values through custom properties, so any element carrying one + * of the classes repaints itself and its subtree. * * Selector weight is one class throughout, `:root` included, so an override still wins by coming * after the theme - the rule that held before the classes existed. The third block is what makes @@ -32,37 +59,38 @@ $accent: colors.$color; * whenever the page names its mode by class. `:where()` keeps that block at the same one-class * weight as the rest. * - * "Inverted" is not recursive: an inverted island inside an inverted island stays inverted rather - * than flipping back. Name the mode outright for the inner one. + * Two limits of that third block, both inherent to descendant selectors - CSS cannot ask for the + * NEAREST matching ancestor: + * + * - "inverted" flips the bundle's mode unless it sits anywhere inside a scope naming the + * opposite mode, at any distance. `dark > light > inverted` therefore resolves against the + * dark, not against the light next to it. Name the mode outright when that matters. + * - it is not recursive: an inverted island inside an inverted island stays inverted rather than + * flipping back. + * + * `--dx-theme-mode` keeps the JS honest about both: whatever these rules resolve to is what the + * overlay container is given. */ -@if colors.$mode == "light" { +@mixin mode-scopes($own, $other) { :root, - .dx-theme-mode-light { - @include light-roles.roles(); + .dx-theme-mode-#{$own} { + @include mode-values($own); } - .dx-theme-mode-dark, + .dx-theme-mode-#{$other}, .dx-theme-mode-inverted { - @include dark-roles.roles(); + @include mode-values($other); } - :where(.dx-theme-mode-dark) .dx-theme-mode-inverted { - @include light-roles.roles(); - } -} @else if colors.$mode == "dark" { - :root, - .dx-theme-mode-dark { - @include dark-roles.roles(); - } - - .dx-theme-mode-light, - .dx-theme-mode-inverted { - @include light-roles.roles(); + :where(.dx-theme-mode-#{$other}) .dx-theme-mode-inverted { + @include mode-values($own); } +} - :where(.dx-theme-mode-light) .dx-theme-mode-inverted { - @include dark-roles.roles(); - } +@if colors.$mode == "light" { + @include mode-scopes("light", "dark"); +} @else if colors.$mode == "dark" { + @include mode-scopes("dark", "light"); } @else { @error "fluent-next: unknown colour mode #{meta.inspect(colors.$mode)}; expected \"light\" or \"dark\"."; } diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss index 6980d2756cd8..1e64daea6d53 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss @@ -54,7 +54,15 @@ $grid-text-stub-bg: data-uri("images/widgets/fluent-next/grid/text-stub.png") !d $grid-filter-panel-content: ds.$color-content-primary !default; $grid-draggable-column-content: ds.$color-content-subtle !default; -:root { +/* + * Declared on the document root and on every element that names a theme mode. A custom property + * resolves where it is DECLARED, so a `:root`-only alias onto a mode-dependent role would freeze + * at the bundle's mode and ignore a mode class further down (see _design-system.scss). + */ +:root, +.dx-theme-mode-light, +.dx-theme-mode-dark, +.dx-theme-mode-inverted { --dx-datagrid-row-alternation-bg: #{$grid-row-alternation-bg}; } diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss index 5370b7cdda86..9348aa6be2e8 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss @@ -28,7 +28,15 @@ $text-editor-content-disabled: ds.$color-content-disabled !default; $text-editor-label-content-focused: ds.$color-content-primary; -:root { +/* + * Declared on the document root and on every element that names a theme mode. A custom property + * resolves where it is DECLARED, so a `:root`-only alias onto a mode-dependent role would freeze + * at the bundle's mode and ignore a mode class further down (see _design-system.scss). + */ +:root, +.dx-theme-mode-light, +.dx-theme-mode-dark, +.dx-theme-mode-inverted { --dx-texteditor-color-text: #{$text-editor-content}; --dx-texteditor-color-label: #{$text-editor-placeholder}; } diff --git a/packages/devextreme-scss/tests/fluent-next-naming.baseline.json b/packages/devextreme-scss/tests/fluent-next-naming.baseline.json index 93d0f2261172..f5bb16841aab 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.baseline.json +++ b/packages/devextreme-scss/tests/fluent-next-naming.baseline.json @@ -511,7 +511,9 @@ "--dx-toolbar-height" ], "publicSurfaceUndeclared": [], - "publicSurfaceDifferences": [], + "publicSurfaceDifferences": [ + "--dx-theme-mode: only in fluent-next" + ], "publicTierManualDeclarations": [ "fluent-next/_colors.scss: --dx-color-border", "fluent-next/_colors.scss: --dx-color-danger", @@ -527,6 +529,7 @@ "fluent-next/_colors.scss: --dx-color-text", "fluent-next/_colors.scss: --dx-color-warning", "fluent-next/_colors.scss: --dx-component-color-bg", + "fluent-next/_design-system.scss: --dx-theme-mode", "fluent-next/_sizes.scss: --dx-border-radius", "fluent-next/_sizes.scss: --dx-border-width", "fluent-next/_sizes.scss: --dx-component-height", From ef96b29f30e6237e3ddeefbd1e0c0ec1e59f1a19 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 14:12:03 +0400 Subject: [PATCH 5/6] Gate the mode invariant against the built bundles A frozen alias breaks the promise silently: the declaration stays valid, the colour is merely the one from the other mode, and none of the usual checks see it. A rule-by-rule diff of the light and dark bundles cannot - the line --dx-color-text: var(--dxds-color-content) is byte-identical in both, since what differs is the resolution point, not the text. The reachability audit only sees what a page materialises, in the mode it was opened in, and the demos set no mode classes at all. Following the references does see it. The gate takes the names declared under the mode classes out of the built bundle and reports anything that reads them - through a chain as well, --dxds-box-shadow-md over --dxds-color-shadow-key - from a rule whose subject is the document element. A declaration on a component root is not a finding: that element may sit inside a mode scope, and then the read resolves there. It also pins the two things the mechanism needs: the three scopes declare the same set of names, and each names its mode in --dx-theme-mode. Everything is derived from the bundle, so there is no list here to keep in step. On the bundles from before the previous commit the last check reports 39 names. --- .../tests/theme-mode-scope.test.ts | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 packages/devextreme-scss/tests/theme-mode-scope.test.ts diff --git a/packages/devextreme-scss/tests/theme-mode-scope.test.ts b/packages/devextreme-scss/tests/theme-mode-scope.test.ts new file mode 100644 index 000000000000..f1f01c408cd9 --- /dev/null +++ b/packages/devextreme-scss/tests/theme-mode-scope.test.ts @@ -0,0 +1,143 @@ +/* + * Gate for the fluent-next theme-mode invariant: an element carrying `dx-theme-mode-light`, + * `-dark` or `-inverted` repaints itself and its subtree. + * + * The invariant is easy to break silently, because a custom property is substituted where it is + * DECLARED, not where it is read. `:root { --dx-color-text: var(--dxds-color-content) }` computes + * on , freezes at the bundle's mode, and every element below inherits that frozen value no + * matter which mode class sits between - the declaration is still valid, the colour is simply the + * wrong one, so nothing fails and only a screenshot would notice. That is what happened to 39 + * properties (the legacy `--dx-color-*` surface, the box-shadow composites and their Figma layer + * colours, the global focus aliases) before this gate existed. + * + * Two things are checked, both derived from the built bundle rather than from a list here: + * + * 1. the three mode scopes declare exactly the same names, so none of them can go missing; + * 2. nothing whose value reads a mode-scoped name is declared where a mode class cannot reach + * it - i.e. on the document element. + * + * A declaration on a component root (`.dx-button { --dx-button-bg: var(--dxds-color-bg) }`) is + * fine and deliberately not flagged: that element may sit inside a mode scope, and then the read + * resolves there. + * + * The bundles come from packages/devextreme/artifacts/css - the `test` target depends on + * `build:themes`, so they are fresh here; a missing bundle fails the suite loudly instead of + * passing silently. + */ + +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import postcss from 'postcss'; + +const packageRoot = process.cwd(); +const artifactsCss = join(packageRoot, '..', 'devextreme', 'artifacts', 'css'); + +const MODE_PROPERTY = '--dx-theme-mode'; +const MODE_SCOPES = ['light', 'dark', 'inverted']; +const MODE_CLASS_PREFIX = '.dx-theme-mode-'; + +const bundleNames = existsSync(artifactsCss) + ? readdirSync(artifactsCss).filter((name) => /^dx\.fluent-next\.[a-z0-9.]+\.css$/.test(name)).sort() + : []; + +if (!bundleNames.length) { + throw new Error(`no dx.fluent-next.*.css bundles found in ${artifactsCss} — the gate needs the ` + + 'built theme; run `pnpm nx run devextreme-scss:build:themes` (the `test` target normally ' + + 'does it for you)'); +} + +/** The compound a selector actually targets: `:where(.a) .b` -> `.b`, `:root` -> `:root`. */ +const subjectOf = (selector: string): string => selector.trim().split(/[\s>+~]+/).filter(Boolean).pop() ?? ''; + +const modeScopesOf = (selector: string): string[] => MODE_SCOPES + .filter((scope) => subjectOf(selector) === `${MODE_CLASS_PREFIX}${scope}`); + +// A rule lands on the document element - the one place a mode class below it cannot reach. +const isDocumentRoot = (selector: string): boolean => [':root', 'html'].includes(subjectOf(selector)); + +interface BundleFacts { + scopeNames: Record>; + rootDeclarations: { property: string; reads: string[]; selector: string }[]; + modeScopedNames: Set; +} + +const readBundle = (name: string): BundleFacts => { + const root = postcss.parse(readFileSync(join(artifactsCss, name), 'utf8'), { from: name }); + const scopeNames: Record> = Object.fromEntries( + MODE_SCOPES.map((scope) => [scope, new Set()]), + ); + const rootDeclarations: BundleFacts['rootDeclarations'] = []; + const modeScopedNames = new Set(); + + root.walkRules((rule) => { + const scopes = new Set(rule.selectors.flatMap(modeScopesOf)); + const onDocumentRoot = rule.selectors.every(isDocumentRoot); + + rule.each((node) => { + if (node.type !== 'decl' || !node.prop.startsWith('--')) { + return; + } + + scopes.forEach((scope) => scopeNames[scope].add(node.prop)); + + if (scopes.size) { + modeScopedNames.add(node.prop); + } + + if (onDocumentRoot) { + rootDeclarations.push({ + property: node.prop, + reads: [...node.value.matchAll(/var\(\s*(--[\w-]+)/g)].map((match) => match[1]), + selector: rule.selector, + }); + } + }); + }); + + return { scopeNames, rootDeclarations, modeScopedNames }; +}; + +/* + * Frozen = declared on the document element and reading, directly or through another such + * declaration, something a mode class redefines. `--dxds-box-shadow-md` reads + * `--dxds-color-shadow-key` (mode-scoped) and is itself read by every popup, so the chain has to + * be followed rather than only the first hop. + */ +const frozenProperties = ({ rootDeclarations, modeScopedNames }: BundleFacts): string[] => { + const frozen = new Map(); + const tainted = new Set(modeScopedNames); + + for (;;) { + const found = rootDeclarations.filter(({ property, reads }) => !tainted.has(property) + && reads.some((name) => tainted.has(name))); + + if (!found.length) { + return [...frozen.keys()].sort(); + } + + found.forEach(({ property, selector, reads }) => { + tainted.add(property); + frozen.set(property, `${selector} { ${property}: … ${reads.find((name) => tainted.has(name)) ?? ''} … }`); + }); + } +}; + +describe.each(bundleNames)('%s', (name) => { + const facts = readBundle(name); + + test('the three mode scopes declare the same names', () => { + const [light, dark, inverted] = MODE_SCOPES.map((scope) => [...facts.scopeNames[scope]].sort()); + + expect(light.length).toBeGreaterThan(0); + expect(dark).toEqual(light); + expect(inverted).toEqual(light); + }); + + test(`every mode scope names its mode in ${MODE_PROPERTY}`, () => { + expect(MODE_SCOPES.filter((scope) => !facts.scopeNames[scope].has(MODE_PROPERTY))).toEqual([]); + }); + + test('nothing reading a mode-scoped value is declared on the document element', () => { + expect(frozenProperties(facts)).toEqual([]); + }); +}); From 286ce26e70f26987e11dda251743b694b7bcc8d8 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Wed, 2 Sep 2026 14:12:19 +0400 Subject: [PATCH 6/6] Give the overlay container the mode its owner resolved to The container is reparented to the viewport, so reading the owner's ancestor classes answers the wrong question twice. "Inverted" means "the opposite of my surroundings" and the container's surroundings are different ones; and the class does not determine the mode anyway, because the relative rule reads any ancestor rather than the nearest. Measured in the browser on the built theme, the ancestor walk disagreed with the cascade in 7 of 46 shapes - dark > light > inverted and its mirrors, plus a bare inverted island whenever the viewport itself named a mode, where the container landed inside that class and inverted it instead. Reading --dx-theme-mode agrees by construction: 46 of 46. Three more things came out of it. The viewport is not always set. Before documentReady value() returns undefined, and the old code returned it for any element outside a swatch - which speed_dial_action relies on to defer to ready() (T713615, T1143527). An element inside a mode scope no longer took that path and dereferenced undefined instead. The signature says | undefined now, so the two call sites that append into the container had to say what they do when there is none. A scope the viewport already resolves to needs no container. It repainted nothing, and popup drag and resize takes the container as its boundary area (popup_position_controller._getDragResizeContainer), so a dxPopup inside an app that names its mode on the viewport was clamped to a div of zero height. Reuse compares the swatch and mode classes rather than counting all of them. A class with neither prefix says nothing about the scope, and disqualifying a container over one grew the viewport by a wrapper per overlay shown. --- .../utils/__tests__/swatch_container.test.ts | 213 +++++++++++------- .../__internal/core/utils/swatch_container.ts | 119 ++++++---- .../speed_dial_action/speed_dial_main_item.ts | 4 +- 3 files changed, 199 insertions(+), 137 deletions(-) diff --git a/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts b/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts index 226e6fa23e34..57e492176eb5 100644 --- a/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts +++ b/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts @@ -1,13 +1,29 @@ import { - afterEach, beforeEach, describe, expect, it, + afterEach, beforeEach, describe, expect, it, jest, } from '@jest/globals'; +import $ from '@js/core/renderer'; import { value as viewPort } from '@js/core/utils/view_port'; import swatchContainer from '@ts/core/utils/swatch_container'; +/* + * The viewport is mocked rather than assigned: `value(x)` falls back to for anything empty, + * so the state before documentReady - `value()` returning undefined - is otherwise unreachable, + * and that is the state overlays created too early run into (T713615, T1143527). + */ +jest.mock('@js/core/utils/view_port'); + +const viewPortMock = viewPort as unknown as jest.Mock<() => unknown>; + const { getSwatchContainer } = swatchContainer; -const classesOf = (element: Element | undefined): string[] => [...(element?.classList ?? [])] - .sort(); +// jsdom resolves a custom property declared ON an element but does not inherit it, so the tests +// name the resolved mode at the elements the code reads it from. +const MODE_STYLES = ` + .mode-light { --dx-theme-mode: light; } + .mode-dark { --dx-theme-mode: dark; } +`; + +const classesOf = (element: Element): string[] => [...element.classList].sort(); describe('getSwatchContainer', () => { let $viewport = document.createElement('div'); @@ -21,137 +37,162 @@ describe('getSwatchContainer', () => { return host.querySelector('.target') as HTMLElement; }; - const containerFor = (markup: string): Element => getSwatchContainer(render(markup)).get(0); + const containerFor = ( + markup: string, + ): Element => getSwatchContainer(render(markup))?.get(0) as Element; beforeEach(() => { + document.head.innerHTML = ``; $viewport = document.createElement('div'); $viewport.className = 'dx-viewport'; document.body.appendChild($viewport); - viewPort($viewport); + viewPortMock.mockReturnValue($($viewport)); }); afterEach(() => { + document.head.innerHTML = ''; document.body.innerHTML = ''; - viewPort(undefined); + viewPortMock.mockReset(); }); - it('returns the viewport itself when the element is in no swatch and in no named mode', () => { + it('returns the viewport itself when the element is in no swatch and in no mode', () => { expect(containerFor('
')).toBe($viewport); }); - it('creates a container in the viewport for a swatch', () => { - const container = containerFor('
'); + describe('swatches', () => { + it('creates a container in the viewport for a swatch', () => { + const container = containerFor('
'); - expect(classesOf(container)).toEqual(['dx-swatch-custom']); - expect(container.parentElement).toBe($viewport); - }); + expect(classesOf(container)).toEqual(['dx-swatch-custom']); + expect(container.parentElement).toBe($viewport); + }); - it('reads the classes off the element itself', () => { - expect(classesOf(containerFor('
'))).toEqual(['dx-swatch-custom']); - }); + it('reads the classes off the element itself', () => { + expect(classesOf(containerFor('
'))).toEqual(['dx-swatch-custom']); + }); - it('carries every swatch class, not just the first', () => { - const container = containerFor('
'); + it('carries every swatch class, not just the first', () => { + const container = containerFor('
'); - expect(classesOf(container)).toEqual(['dx-swatch-a', 'dx-swatch-b']); - }); + expect(classesOf(container)).toEqual(['dx-swatch-a', 'dx-swatch-b']); + }); - it('carries a named theme mode', () => { - const container = containerFor('
'); + it('takes the nearest swatch', () => { + const container = containerFor(` +
+
+
`); - expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); - expect(container.parentElement).toBe($viewport); + expect(classesOf(container)).toEqual(['dx-swatch-inner']); + }); }); - it('carries a swatch and a theme mode declared on different ancestors', () => { - const container = containerFor(` -
-
-
`); + describe('theme mode', () => { + it('carries the mode the element resolved to', () => { + const container = containerFor('
'); - expect(classesOf(container)).toEqual(['dx-swatch-custom', 'dx-theme-mode-dark']); - }); + expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); + expect(container.parentElement).toBe($viewport); + }); - it('takes the nearest declaration of each kind', () => { - const container = containerFor(` -
-
-
-
-
`); + /* + * `dx-theme-mode-inverted` means "the opposite of my surroundings" and the container is + * reparented to the viewport, where the surroundings are different ones - so the mode comes + * from what the cascade resolved, never from the class the element wears. + */ + it('names the resolved mode, not the class the element carries', () => { + const container = containerFor('
'); - expect(classesOf(container)).toEqual(['dx-swatch-inner', 'dx-theme-mode-dark']); - }); + expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + }); + + it('carries no mode when the theme declares none', () => { + expect(containerFor('
')).toBe($viewport); + }); - it('reuses one container for elements in the same swatch and mode', () => { - const markup = '
'; + it('carries a swatch and a mode together', () => { + const container = containerFor(` +
+
+
`); - expect(containerFor(markup)).toBe(containerFor(markup)); - expect($viewport.children).toHaveLength(1); + expect(classesOf(container)).toEqual(['dx-swatch-custom', 'dx-theme-mode-dark']); + }); }); - it('does not reuse a container that carries classes the element is not in', () => { - const inBoth = containerFor('
'); - const inSwatch = containerFor('
'); + describe('scopes the viewport already resolves to', () => { + it('returns the viewport when it resolves to the same mode', () => { + $viewport.classList.add('mode-dark'); - expect(inSwatch).not.toBe(inBoth); - expect(classesOf(inSwatch)).toEqual(['dx-swatch-custom']); - }); + expect(containerFor('
')).toBe($viewport); + expect($viewport.children).toHaveLength(0); + }); - describe('inverted mode', () => { - it('is carried as is when no named mode surrounds it', () => { - const container = containerFor('
'); + it('returns the viewport when it sits in the same swatch', () => { + const $swatch = document.createElement('div'); - expect(classesOf(container)).toEqual(['dx-theme-mode-inverted']); + $swatch.className = 'dx-swatch-custom'; + document.body.appendChild($swatch); + $swatch.appendChild($viewport); + + expect(containerFor('
')).toBe($viewport); }); - it('resolves to light inside a dark scope', () => { - const container = containerFor(` -
-
-
`); + it('creates a container when the modes differ', () => { + $viewport.classList.add('mode-dark'); + + const container = containerFor('
'); expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + expect(container.parentElement).toBe($viewport); }); + }); - it('resolves to dark inside a light scope', () => { - const container = containerFor(` -
-
-
`); + describe('reuse', () => { + it('reuses one container for elements in the same swatch and mode', () => { + const markup = '
'; - expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); + expect(containerFor(markup)).toBe(containerFor(markup)); + expect($viewport.children).toHaveLength(1); }); - it('resolves against the nearest named scope, not the outermost', () => { - const container = containerFor(` -
-
-
-
-
`); + it('does not reuse a container carrying a scope the element is not in', () => { + const inBoth = containerFor('
'); + const inSwatch = containerFor('
'); - expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + expect(inSwatch).not.toBe(inBoth); + expect(classesOf(inSwatch)).toEqual(['dx-swatch-custom']); }); - it('does not invert again when nested in another inverted block', () => { - const container = containerFor(` -
-
-
`); + // Only swatch and mode classes describe the scope; anything else on the page may have tagged + // the container, and re-creating it on every call would grow the viewport without bound. + it('reuses a container that picked up an unrelated class', () => { + const first = containerFor('
'); + + first.classList.add('some-app-class'); - expect(classesOf(container)).toEqual(['dx-theme-mode-inverted']); + expect(containerFor('
')).toBe(first); + expect($viewport.children).toHaveLength(1); }); + }); - it('resolves nested inverted blocks against the named scope around them', () => { - const container = containerFor(` -
-
-
-
-
`); + describe('before the viewport is set', () => { + beforeEach(() => { + viewPortMock.mockReturnValue(undefined); + }); - expect(classesOf(container)).toEqual(['dx-theme-mode-light']); + it('reports no container for an element in no scope', () => { + expect(getSwatchContainer(render('
'))).toBeUndefined(); + }); + + it('reports no container for an element in a mode', () => { + expect(getSwatchContainer(render('
'))).toBeUndefined(); + }); + + it('reports no container for an element in a swatch', () => { + const element = render('
'); + + expect(getSwatchContainer(element)).toBeUndefined(); }); }); }); diff --git a/packages/devextreme/js/__internal/core/utils/swatch_container.ts b/packages/devextreme/js/__internal/core/utils/swatch_container.ts index 18baa4ddedb7..ec43f7081f9d 100644 --- a/packages/devextreme/js/__internal/core/utils/swatch_container.ts +++ b/packages/devextreme/js/__internal/core/utils/swatch_container.ts @@ -1,86 +1,107 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { value } from '@js/core/utils/view_port'; +import { getWindow, hasWindow } from '@js/core/utils/window'; const SWATCH_CONTAINER_CLASS_PREFIX = 'dx-swatch-'; const THEME_MODE_CLASS_PREFIX = 'dx-theme-mode-'; - -const LIGHT_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}light`; -const DARK_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}dark`; -const INVERTED_THEME_MODE_CLASS = `${THEME_MODE_CLASS_PREFIX}inverted`; - -const closestByClassPrefix = ( - $element: dxElementWrapper, - prefix: string, -): dxElementWrapper => $element.closest(`[class^="${prefix}"], [class*=" ${prefix}"]`); +const THEME_MODE_PROPERTY = '--dx-theme-mode'; const classesByPrefix = ( element: Element, prefix: string, ): string[] => [...element.classList].filter((cssClass) => cssClass.startsWith(prefix)); -const getThemeModeClasses = ($element: dxElementWrapper): string[] => { - const $scope = closestByClassPrefix($element, THEME_MODE_CLASS_PREFIX); - - if (!$scope.length) { - return []; - } +const closestClassesByPrefix = ( + $element: dxElementWrapper, + prefix: string, +): string[] => { + const $scope = $element.closest(`[class^="${prefix}"], [class*=" ${prefix}"]`); - const classes = classesByPrefix($scope[0], THEME_MODE_CLASS_PREFIX); + return $scope.length ? classesByPrefix($scope.get(0), prefix) : []; +}; - if (!classes.includes(INVERTED_THEME_MODE_CLASS)) { - return classes; +/* + * The mode an element ended up in is what the cascade decided, not what its ancestor classes + * spell: `dx-theme-mode-inverted` asks for the opposite of its surroundings, and the container is + * reparented to the viewport, whose surroundings are different ones. The theme names the outcome + * in `--dx-theme-mode` (widgets/fluent-next/_design-system.scss), so ask the browser for it. + * Themes that ship one mode per bundle declare nothing and get no class, as before. + */ +const themeModeClasses = ($element: dxElementWrapper): string[] => { + const element = $element.get(0); + const window = hasWindow() ? getWindow() : undefined; + + if (!element || !window?.getComputedStyle) { + return []; } - // The container hangs off the viewport, so "the opposite of my surroundings" would be read - // against the viewport rather than against the element the overlay belongs to. Name the mode the - // element resolves to instead. Without a named mode above it that is the mode the stylesheet - // falls back to, which the container inherits too, so the relative class carries over as is. - const $named = $scope.parent().closest(`.${LIGHT_THEME_MODE_CLASS}, .${DARK_THEME_MODE_CLASS}`); - - if (!$named.length) { - return classes; - } + const mode = window.getComputedStyle(element).getPropertyValue(THEME_MODE_PROPERTY).trim(); - return [ - $named[0].classList.contains(DARK_THEME_MODE_CLASS) - ? LIGHT_THEME_MODE_CLASS - : DARK_THEME_MODE_CLASS, - ]; + return mode ? [`${THEME_MODE_CLASS_PREFIX}${mode}`] : []; }; -const getContainerClasses = ($element: dxElementWrapper): string[] => { - const $swatch = closestByClassPrefix($element, SWATCH_CONTAINER_CLASS_PREFIX); - const swatchClasses = $swatch.length - ? classesByPrefix($swatch[0], SWATCH_CONTAINER_CLASS_PREFIX) - : []; +const scopeClasses = ($element: dxElementWrapper): string[] => [ + ...closestClassesByPrefix($element, SWATCH_CONTAINER_CLASS_PREFIX), + ...themeModeClasses($element), +]; - return [...swatchClasses, ...getThemeModeClasses($element)]; +const getContainerClasses = ( + $element: dxElementWrapper, + $viewport: dxElementWrapper, +): string[] => { + const classes = scopeClasses($element); + // A scope the viewport already resolves to needs no container of its own: it would be a wrapper + // that repaints nothing, and one that measures nothing - callers reading the container as a + // geometric area (popup drag and resize) would be clamped to its zero height. + const sorted = (cssClasses: string[]): string => [...cssClasses].sort().join(' '); + + return sorted(classes) === sorted(scopeClasses($viewport)) ? [] : classes; }; +// A container carrying a swatch or a mode class beyond the ones asked for belongs to a scope the +// element itself is not in. A class with neither prefix says nothing about the scope, so it does +// not disqualify a container - anything on the page may have tagged it. +const isExactScope = ( + node: Element, + containerClasses: string[], +): boolean => [SWATCH_CONTAINER_CLASS_PREFIX, THEME_MODE_CLASS_PREFIX] + .every((prefix) => classesByPrefix(node, prefix) + .every((cssClass) => containerClasses.includes(cssClass))); + +/* + * Where an overlay belonging to `element` should be rendered: the viewport itself, or a child of it + * repeating the swatch and the theme mode the element resolved to. + * + * Undefined while the viewport is unset - before documentReady - which callers read as "not ready + * yet" (speed_dial_action defers to ready(); T713615, T1143527). + */ const getSwatchContainer = ( element: Element | dxElementWrapper, -): dxElementWrapper => { - const containerClasses = getContainerClasses($(element)); - const viewport: dxElementWrapper = value(); +): dxElementWrapper | undefined => { + const $viewport = value() as dxElementWrapper | undefined; + + if (!$viewport?.length) { + return $viewport; + } + + const containerClasses = getContainerClasses($(element), $viewport); if (!containerClasses.length) { - return viewport; + return $viewport; } const selector = containerClasses.map((cssClass) => `.${cssClass}`).join(''); - // A container carrying more classes than asked for would hand the overlay a swatch or a mode the - // element itself is not in. - let viewportContainer = $(viewport + let $container = $($viewport .children(selector) .toArray() - .filter((node) => node.classList.length === containerClasses.length)); + .filter((node) => isExactScope(node, containerClasses))); - if (!viewportContainer.length) { - viewportContainer = $('
').addClass(containerClasses.join(' ')).appendTo(viewport); + if (!$container.length) { + $container = $('
').addClass(containerClasses.join(' ')).appendTo($viewport); } - return viewportContainer; + return $container; }; export default { getSwatchContainer }; diff --git a/packages/devextreme/js/__internal/ui/speed_dial_action/speed_dial_main_item.ts b/packages/devextreme/js/__internal/ui/speed_dial_action/speed_dial_main_item.ts index 8fe2f4a5ed6f..409f80dbbe3f 100644 --- a/packages/devextreme/js/__internal/ui/speed_dial_action/speed_dial_main_item.ts +++ b/packages/devextreme/js/__internal/ui/speed_dial_action/speed_dial_main_item.ts @@ -314,7 +314,7 @@ class SpeedDialMainItem extends SpeedDialItem { for (const action of actions) { const $actionElement = $('
') - .appendTo(getSwatchContainer(action.$element())); + .appendTo(getSwatchContainer(action.$element()) ?? $()); eventsEngine.off($actionElement, 'click'); eventsEngine.on($actionElement, 'click', () => { @@ -483,7 +483,7 @@ export function initAction(newAction: SpeedDialAction): void { if (!speedDialMainItem) { const $fabMainElement = $('
') - .appendTo(getSwatchContainer(newAction.$element())); + .appendTo(getSwatchContainer(newAction.$element()) ?? $()); speedDialMainItem = newAction._createComponent( $fabMainElement,