diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index 750f5b0a4d45..49f5daf3edc1 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,10 @@ const buildPath = `${path.resolve(dirname, '../../scss/_design-system')}/`; const THEME_NAME = 'fluent'; const THEME_FOLDER = 'fluent-next'; +// 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}`); const FLUENT_PALETTES = [ @@ -231,6 +236,46 @@ const getModeFiles = (mode) => [ // properties. Absent from the bridge, `ds.$button-color-bg-rest` is now a Sass error. const getBridgeFiles = () => getModeFiles('light'); +/* + * 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-scoped-mixin', + format: async ({ dictionary, file, options }) => { + const { + outputReferences, outputReferenceFallbacks, usesDtcg, formatting, sort, mixin, + } = options; + const header = await fileHeader({ file, formatting: headerFormatting(formatting), options }); + const variables = formattedVariables({ + format: 'css', + dictionary, + outputReferences, + outputReferenceFallbacks, + formatting: { ...formatting, indentation: ' ' }, + usesDtcg, + sort, + }); + + return `${header}@mixin ${mixin}() {\n${variables}\n}\n`; + }, +}); + StyleDictionary.registerFormat({ name: 'scssToCss', format: ({ dictionary }) => dictionary.allTokens @@ -315,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, @@ -330,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: 'css/variables', + 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/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 77bf05a68647..000000000000 Binary files a/packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/grid/text-stub.png and /dev/null differ diff --git a/packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/pulldown.png b/packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/pulldown.png deleted file mode 100644 index 436cc90d9666..000000000000 Binary files a/packages/devextreme-scss/images/widgets/fluent-next/color-schemes/light/pulldown.png and /dev/null differ diff --git a/packages/devextreme-scss/images/widgets/fluent-next/color-schemes/dark/grid/text-stub.png b/packages/devextreme-scss/images/widgets/fluent-next/grid/text-stub.png similarity index 100% rename from packages/devextreme-scss/images/widgets/fluent-next/color-schemes/dark/grid/text-stub.png rename to packages/devextreme-scss/images/widgets/fluent-next/grid/text-stub.png diff --git a/packages/devextreme-scss/images/widgets/fluent-next/color-schemes/dark/pulldown.png b/packages/devextreme-scss/images/widgets/fluent-next/pulldown.png similarity index 100% rename from packages/devextreme-scss/images/widgets/fluent-next/color-schemes/dark/pulldown.png rename to packages/devextreme-scss/images/widgets/fluent-next/pulldown.png 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 e255d380ae46..65e648d24b67 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,8 @@ @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; +@use "../../_design-system/fluent/mode-aliases" as mode-aliases; $accent: colors.$color; @@ -11,10 +14,83 @@ $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"); -@include meta.load-css("../../_design-system/fluent/semantic/colors/#{colors.$mode}"); + +/* + * 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 + * "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. + * + * 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. + */ +@mixin mode-scopes($own, $other) { + :root, + .dx-theme-mode-#{$own} { + @include mode-values($own); + } + + .dx-theme-mode-#{$other}, + .dx-theme-mode-inverted { + @include mode-values($other); + } + + :where(.dx-theme-mode-#{$other}) .dx-theme-mode-inverted { + @include mode-values($own); + } +} + +@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/_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/scss/widgets/fluent-next/gridBase/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss index edf3bd70cefb..1e64daea6d53 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss @@ -50,19 +50,19 @@ $grid-column-chooser-message-content: ds.$color-content-subtle !default; $grid-column-separator-bg: ds.$color-bg-primary !default; $grid-filter-row-bg: ds.$color-bg-canvas !default; $grid-header-filter-icon-active: ds.$color-content-primary !default; -$grid-text-stub-bg: null !default; +$grid-text-stub-bg: data-uri("images/widgets/fluent-next/grid/text-stub.png") !default; $grid-filter-panel-content: ds.$color-content-primary !default; $grid-draggable-column-content: ds.$color-content-subtle !default; -@if $mode == "light" { - $grid-text-stub-bg: data-uri("images/widgets/fluent-next/color-schemes/light/grid/text-stub.png") !default; -} - -@if $mode == "dark" { - $grid-text-stub-bg: data-uri("images/widgets/fluent-next/color-schemes/dark/grid/text-stub.png") !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/icons/_colors.scss b/packages/devextreme-scss/scss/widgets/fluent-next/icons/_colors.scss index 84f774fd7a0f..0b57bccc38a9 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/icons/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/icons/_colors.scss @@ -1,12 +1,2 @@ -@use "../colors" as *; - // Theme-layer values used by this widget's style files (phase 0.5 layer normalization) -$icon-pulldown-bg: null !default; - -@if $mode == "light" { - $icon-pulldown-bg: data-uri("images/widgets/fluent-next/color-schemes/light/pulldown.png") !default; -} - -@if $mode == "dark" { - $icon-pulldown-bg: data-uri("images/widgets/fluent-next/color-schemes/dark/pulldown.png") !default; -} +$icon-pulldown-bg: data-uri("images/widgets/fluent-next/pulldown.png") !default; 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", 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([]); + }); +}); 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": [ 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..57e492176eb5 --- /dev/null +++ b/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts @@ -0,0 +1,198 @@ +import { + 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; + +// 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'); + + 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) as Element; + + beforeEach(() => { + document.head.innerHTML = ``; + $viewport = document.createElement('div'); + $viewport.className = 'dx-viewport'; + document.body.appendChild($viewport); + viewPortMock.mockReturnValue($($viewport)); + }); + + afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + viewPortMock.mockReset(); + }); + + it('returns the viewport itself when the element is in no swatch and in no mode', () => { + expect(containerFor('
')).toBe($viewport); + }); + + 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); + }); + + 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('takes the nearest swatch', () => { + const container = containerFor(` +
+
+
`); + + expect(classesOf(container)).toEqual(['dx-swatch-inner']); + }); + }); + + describe('theme mode', () => { + it('carries the mode the element resolved to', () => { + const container = containerFor('
'); + + expect(classesOf(container)).toEqual(['dx-theme-mode-dark']); + expect(container.parentElement).toBe($viewport); + }); + + /* + * `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-theme-mode-light']); + }); + + it('carries no mode when the theme declares none', () => { + expect(containerFor('
')).toBe($viewport); + }); + + it('carries a swatch and a mode together', () => { + const container = containerFor(` +
+
+
`); + + expect(classesOf(container)).toEqual(['dx-swatch-custom', 'dx-theme-mode-dark']); + }); + }); + + describe('scopes the viewport already resolves to', () => { + it('returns the viewport when it resolves to the same mode', () => { + $viewport.classList.add('mode-dark'); + + expect(containerFor('
')).toBe($viewport); + expect($viewport.children).toHaveLength(0); + }); + + it('returns the viewport when it sits in the same swatch', () => { + const $swatch = document.createElement('div'); + + $swatch.className = 'dx-swatch-custom'; + document.body.appendChild($swatch); + $swatch.appendChild($viewport); + + expect(containerFor('
')).toBe($viewport); + }); + + 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); + }); + }); + + describe('reuse', () => { + 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 carrying a scope the element is not in', () => { + const inBoth = containerFor('
'); + const inSwatch = containerFor('
'); + + expect(inSwatch).not.toBe(inBoth); + expect(classesOf(inSwatch)).toEqual(['dx-swatch-custom']); + }); + + // 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(containerFor('
')).toBe(first); + expect($viewport.children).toHaveLength(1); + }); + }); + + describe('before the viewport is set', () => { + beforeEach(() => { + viewPortMock.mockReturnValue(undefined); + }); + + 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 c426d0b9050d..ec43f7081f9d 100644 --- a/packages/devextreme/js/__internal/core/utils/swatch_container.ts +++ b/packages/devextreme/js/__internal/core/utils/swatch_container.ts @@ -1,29 +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 THEME_MODE_PROPERTY = '--dx-theme-mode'; +const classesByPrefix = ( + element: Element, + prefix: string, +): string[] => [...element.classList].filter((cssClass) => cssClass.startsWith(prefix)); + +const closestClassesByPrefix = ( + $element: dxElementWrapper, + prefix: string, +): string[] => { + const $scope = $element.closest(`[class^="${prefix}"], [class*=" ${prefix}"]`); + + return $scope.length ? classesByPrefix($scope.get(0), prefix) : []; +}; + +/* + * 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 []; + } + + const mode = window.getComputedStyle(element).getPropertyValue(THEME_MODE_PROPERTY).trim(); + + return mode ? [`${THEME_MODE_CLASS_PREFIX}${mode}`] : []; +}; + +const scopeClasses = ($element: dxElementWrapper): string[] => [ + ...closestClassesByPrefix($element, SWATCH_CONTAINER_CLASS_PREFIX), + ...themeModeClasses($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 $element = $(element); - const swatchContainer = $element.closest(`[class^="${SWATCH_CONTAINER_CLASS_PREFIX}"], [class*=" ${SWATCH_CONTAINER_CLASS_PREFIX}"]`); - const viewport: dxElementWrapper = value(); +): dxElementWrapper | undefined => { + const $viewport = value() as dxElementWrapper | undefined; + + if (!$viewport?.length) { + return $viewport; + } + + const containerClasses = getContainerClasses($(element), $viewport); - if (!swatchContainer.length) { - return viewport; + 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(''); + let $container = $($viewport + .children(selector) + .toArray() + .filter((node) => isExactScope(node, containerClasses))); - if (!viewportSwatchContainer.length) { - viewportSwatchContainer = $('
').addClass(swatchClass).appendTo(viewport); + if (!$container.length) { + $container = $('
').addClass(containerClasses.join(' ')).appendTo($viewport); } - return viewportSwatchContainer; + 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,